feat: release 1.1.0 with responsive task control
This commit is contained in:
1 parent
057f0c3050
commit
bfcd828fa8
37 files changed
+1601
-151
No files matched your search
@@ -17,7 +17,43 @@ limitations under the License.
|
||||
<div class="workspace-container" [class.is-resizing]="isDragging()">
|
||||
<!-- Left Main Panel: Raw Agent Log Stream -->
|
||||
<main class="left-panel glass-panel">
|
||||
<app-agent-stream></app-agent-stream>
|
||||
<app-agent-stream>
|
||||
@if (guidanceStatus(); as status) {
|
||||
<div task-guidance class="task-guidance-control">
|
||||
<button type="button" class="guidance-trigger" (click)="toggleGuidance()"
|
||||
[attr.aria-expanded]="guidanceOpenId() === agentService.currentSessionId()"
|
||||
aria-controls="workspace-guidance-panel" aria-label="指导当前任务" title="暂停或指导当前任务">
|
||||
<span class="material-symbols-outlined">edit_note</span>
|
||||
指导任务
|
||||
</button>
|
||||
@if (guidanceOpenId() === agentService.currentSessionId()) {
|
||||
<section id="workspace-guidance-panel" class="task-guidance-panel" aria-label="当前任务指导">
|
||||
<div class="guidance-heading">
|
||||
<strong>指导当前任务 · {{ status === 'running' ? '执行中' : status === 'resuming' ? '等待设备恢复' : status === 'paused' ? '模型调用暂停' : '等待指导' }}</strong>
|
||||
<span [title]="agentService.currentSession()?.initial_goal || ''">{{ agentService.currentSession()?.initial_goal || agentService.currentSessionId() }}</span>
|
||||
</div>
|
||||
<label for="workspace-task-guidance">补充信息或调整方向</label>
|
||||
<textarea id="workspace-task-guidance" [(ngModel)]="taskGuidance" rows="2" maxlength="10000"
|
||||
[disabled]="guidanceBusy()" placeholder="输入指导;运行中将在下一步执行前生效"></textarea>
|
||||
<div class="guidance-actions">
|
||||
<span>{{ status === 'paused' ? '提交指导后请在执行日志中恢复模型调用' : '下方输入框用于开始新任务' }}</span>
|
||||
@if (status === 'running') {
|
||||
<button type="button" (click)="pauseCurrentTask()"
|
||||
[disabled]="guidanceBusy() || pauseRequestId() === agentService.currentSessionId()">
|
||||
{{ pauseRequestId() === agentService.currentSessionId() ? '等待暂停…' : '暂停当前任务' }}
|
||||
</button>
|
||||
}
|
||||
@if (status === 'task_paused') {
|
||||
<button type="button" (click)="continueCurrentTask()" [disabled]="guidanceBusy()">直接继续</button>
|
||||
}
|
||||
<button type="button" class="guidance-submit" (click)="submitGuidance()"
|
||||
[disabled]="guidanceBusy() || !taskGuidance.trim()">{{ status === 'task_paused' ? '提交并恢复' : '提交指导' }}</button>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</app-agent-stream>
|
||||
|
||||
<!-- Bottom Floating Command Bar Dock -->
|
||||
<div class="workspace-floating-bar-wrapper" [class.is-expanded]="isBarExpanded()" [class.is-dormant]="!isBarExpanded()"
|
||||
|
||||
@@ -108,6 +108,110 @@ $rose-dark: #dc2626;
|
||||
}
|
||||
}
|
||||
|
||||
.task-guidance-control {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
|
||||
.guidance-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
height: 30px;
|
||||
padding: 0 11px;
|
||||
border: 1px solid rgba(226, 232, 240, .85);
|
||||
border-radius: 15px;
|
||||
background: rgba(255, 255, 255, .72);
|
||||
box-shadow: 0 2px 6px -1px rgba(15, 23, 42, .05);
|
||||
color: #334155;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
.material-symbols-outlined { font-size: 16px; }
|
||||
&:hover { background: rgba(255, 255, 255, .92); border-color: #cbd5e1; }
|
||||
&[aria-expanded='true'] { background: #eff6ff; border-color: #bfdbfe; color: #1d4ed8; }
|
||||
&:focus-visible { outline: 2px solid #93c5fd; outline-offset: 2px; }
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.guidance-trigger {
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
padding: 0;
|
||||
font-size: 0;
|
||||
.material-symbols-outlined { font-size: 16px; }
|
||||
}
|
||||
}
|
||||
|
||||
.task-guidance-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 9px);
|
||||
right: 0;
|
||||
box-sizing: border-box;
|
||||
width: min(420px, calc(100vw - 40px));
|
||||
padding: 15px;
|
||||
border: 1px solid #dbe4ef;
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
box-shadow: 0 18px 48px rgba(15, 23, 42, .16);
|
||||
color: #1e293b;
|
||||
font-size: 12px;
|
||||
|
||||
.guidance-heading {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
margin-bottom: 10px;
|
||||
strong { flex-shrink: 0; color: #1d4ed8; font-size: 13px; }
|
||||
span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #475569; }
|
||||
}
|
||||
|
||||
label { display: block; margin-bottom: 6px; font-weight: 600; }
|
||||
textarea {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 62px;
|
||||
max-height: 160px;
|
||||
padding: 9px 10px;
|
||||
resize: vertical;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 9px;
|
||||
background: #fff;
|
||||
color: #0f172a;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
&:focus-visible { outline: 2px solid #93c5fd; outline-offset: 1px; }
|
||||
}
|
||||
|
||||
.guidance-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
span { flex: 1 0 100%; color: #64748b; }
|
||||
button {
|
||||
min-height: 32px;
|
||||
padding: 5px 11px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #334155;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
&:hover:not(:disabled) { background: #eff6ff; border-color: #93c5fd; }
|
||||
&:disabled { opacity: .55; cursor: not-allowed; }
|
||||
&.guidance-submit { background: #2563eb; border-color: #2563eb; color: #fff; }
|
||||
&.guidance-submit:hover:not(:disabled) { background: #1d4ed8; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================================================= */
|
||||
/* BOTTOM FLOATING COMMAND BAR (Proximity Reveal & Minimalist Glassmorphism) */
|
||||
/* ========================================================================= */
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { Component, computed, signal } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { AgentService } from '../../services/agent.service';
|
||||
import { AgentStreamComponent } from '../../components/agent-stream/agent-stream.component';
|
||||
import { ChatInterfaceComponent } from '../../components/chat-interface/chat-interface.component';
|
||||
import { FloatingVideoPlayerComponent } from '../../components/floating-video-player/floating-video-player.component';
|
||||
import { WorkspaceComponent } from './workspace.component';
|
||||
|
||||
@Component({ selector: 'app-agent-stream', standalone: true,
|
||||
template: '<div class="stream-floating-toolbar"><ng-content select="[task-guidance]"></ng-content></div>' })
|
||||
class StreamStub {}
|
||||
@Component({ selector: 'app-chat-interface', standalone: true, template: '' })
|
||||
class ChatStub {}
|
||||
@Component({ selector: 'app-floating-video-player', standalone: true, template: '' })
|
||||
class VideoStub {}
|
||||
|
||||
describe('Workspace task guidance', () => {
|
||||
const currentId = signal<string | null>('running-task');
|
||||
const session = signal({ session_id: 'running-task', status: 'running', initial_goal: '观察设置' });
|
||||
const agent = {
|
||||
currentSessionId: currentId,
|
||||
currentSession: computed(() => currentId() === session().session_id ? session() : null),
|
||||
runningSessionId: signal<string | null>('running-task'),
|
||||
agentStatus: signal('running'),
|
||||
isCurrentSessionRunning: computed(() => ['running', 'paused'].includes(session().status)),
|
||||
fetchStatus: jasmine.createSpy('fetchStatus'),
|
||||
fetchSessions: jasmine.createSpy('fetchSessions'),
|
||||
fetchGuidance: jasmine.createSpy('fetchGuidance')
|
||||
};
|
||||
const http = jasmine.createSpyObj<HttpClient>('HttpClient', ['post']);
|
||||
|
||||
beforeEach(async () => {
|
||||
currentId.set('running-task');
|
||||
session.set({ session_id: 'running-task', status: 'running', initial_goal: '观察设置' });
|
||||
agent.runningSessionId.set('running-task');
|
||||
http.post.and.returnValue(of({}));
|
||||
http.post.calls.reset();
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [WorkspaceComponent],
|
||||
providers: [
|
||||
{ provide: AgentService, useValue: agent },
|
||||
{ provide: HttpClient, useValue: http }
|
||||
]
|
||||
}).overrideComponent(WorkspaceComponent, {
|
||||
remove: { imports: [AgentStreamComponent, ChatInterfaceComponent, FloatingVideoPlayerComponent] },
|
||||
add: { imports: [StreamStub, ChatStub, VideoStub] }
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
it('shows pause and guidance for the selected running task, then hides them on cancellation', async () => {
|
||||
const fixture = TestBed.createComponent(WorkspaceComponent);
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('.task-guidance-panel')).toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('.stream-floating-toolbar .guidance-trigger')).not.toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('.workspace-floating-bar-wrapper .guidance-trigger')).toBeNull();
|
||||
(fixture.nativeElement.querySelector('.guidance-trigger') as HTMLButtonElement).click();
|
||||
fixture.detectChanges();
|
||||
const panel = fixture.nativeElement.querySelector('.task-guidance-panel') as HTMLElement;
|
||||
expect(panel.textContent).toContain('指导当前任务');
|
||||
expect(panel.textContent).toContain('下方输入框用于开始新任务');
|
||||
|
||||
const pause = Array.from(panel.querySelectorAll('button')).find(b => b.textContent?.includes('暂停当前任务'))!;
|
||||
pause.click();
|
||||
await fixture.whenStable();
|
||||
expect(http.post).toHaveBeenCalledWith('/api/tasks/running-task/pause', {});
|
||||
|
||||
fixture.detectChanges();
|
||||
(fixture.nativeElement.querySelector('.guidance-trigger') as HTMLButtonElement).click();
|
||||
fixture.detectChanges();
|
||||
const reopened = fixture.nativeElement.querySelector('.task-guidance-panel') as HTMLElement;
|
||||
const input = reopened.querySelector('textarea')!;
|
||||
input.value = '先确认设置首页';
|
||||
input.dispatchEvent(new Event('input'));
|
||||
fixture.detectChanges();
|
||||
const submit = Array.from(reopened.querySelectorAll('button')).find(b => b.textContent?.includes('提交指导'))!;
|
||||
submit.click();
|
||||
await fixture.whenStable();
|
||||
expect(http.post).toHaveBeenCalledWith('/api/tasks/running-task/guide', { instruction: '先确认设置首页' });
|
||||
expect(agent.fetchGuidance).toHaveBeenCalledWith('running-task');
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('.task-guidance-panel')).toBeNull();
|
||||
expect(fixture.nativeElement.textContent).not.toContain('指导已提交');
|
||||
|
||||
session.set({ session_id: 'running-task', status: 'cancelled', initial_goal: '观察设置' });
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('.task-guidance-panel')).toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('.guidance-trigger')).toBeNull();
|
||||
});
|
||||
|
||||
it('continues a paused task without creating a new task', async () => {
|
||||
session.set({ session_id: 'running-task', status: 'task_paused', initial_goal: '观察设置' });
|
||||
const fixture = TestBed.createComponent(WorkspaceComponent);
|
||||
fixture.detectChanges();
|
||||
(fixture.nativeElement.querySelector('.guidance-trigger') as HTMLButtonElement).click();
|
||||
fixture.detectChanges();
|
||||
const button = Array.from<HTMLElement>(fixture.nativeElement.querySelectorAll('.task-guidance-panel button'))
|
||||
.find(b => b.textContent?.includes('直接继续'))!;
|
||||
button.click();
|
||||
await fixture.whenStable();
|
||||
expect(http.post).toHaveBeenCalledWith('/api/tasks/running-task/continue', {});
|
||||
});
|
||||
|
||||
it('does not offer task resume for a paused model request', () => {
|
||||
session.set({ session_id: 'running-task', status: 'paused', initial_goal: '观察设置' });
|
||||
const fixture = TestBed.createComponent(WorkspaceComponent);
|
||||
fixture.detectChanges();
|
||||
(fixture.nativeElement.querySelector('.guidance-trigger') as HTMLButtonElement).click();
|
||||
fixture.detectChanges();
|
||||
const panel = fixture.nativeElement.querySelector('.task-guidance-panel') as HTMLElement;
|
||||
expect(panel.textContent).toContain('模型调用暂停');
|
||||
expect(panel.textContent).toContain('请在执行日志中恢复模型调用');
|
||||
expect(panel.textContent).not.toContain('直接继续');
|
||||
expect(panel.textContent).not.toContain('提交并恢复');
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,8 @@ import { AgentStreamComponent } from '../../components/agent-stream/agent-stream
|
||||
import { ChatInterfaceComponent } from '../../components/chat-interface/chat-interface.component';
|
||||
import { FloatingVideoPlayerComponent } from '../../components/floating-video-player/floating-video-player.component';
|
||||
import { AgentService } from '../../services/agent.service';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
@Component({
|
||||
selector: 'app-workspace',
|
||||
@@ -37,6 +39,7 @@ import { AgentService } from '../../services/agent.service';
|
||||
})
|
||||
export class WorkspaceComponent implements OnInit {
|
||||
public agentService = inject(AgentService);
|
||||
private http = inject(HttpClient);
|
||||
private zone = inject(NgZone);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
@@ -56,6 +59,33 @@ export class WorkspaceComponent implements OnInit {
|
||||
public isSubmitting = signal<boolean>(false);
|
||||
public errorMessage = signal<string | null>(null);
|
||||
public selectedProfile = signal<'flash' | 'pro'>('flash');
|
||||
private guidanceDrafts = new Map<string, string>();
|
||||
public guidanceBusy = signal(false);
|
||||
public guidanceOpenId = signal<string | null>(null);
|
||||
public pauseRequestId = signal<string | null>(null);
|
||||
|
||||
public get taskGuidance(): string {
|
||||
const id = this.agentService.currentSessionId();
|
||||
return id ? this.guidanceDrafts.get(id) || '' : '';
|
||||
}
|
||||
|
||||
public set taskGuidance(value: string) {
|
||||
const id = this.agentService.currentSessionId();
|
||||
if (id) this.guidanceDrafts.set(id, value);
|
||||
}
|
||||
|
||||
public guidanceStatus = computed(() => {
|
||||
const id = this.agentService.currentSessionId();
|
||||
if (!id) return null;
|
||||
const status = this.agentService.currentSession()?.status
|
||||
|| (this.agentService.runningSessionId() === id ? this.agentService.agentStatus() : '');
|
||||
return ['running', 'paused', 'task_paused', 'resuming'].includes(status || '') ? status : null;
|
||||
});
|
||||
|
||||
public toggleGuidance(): void {
|
||||
const id = this.agentService.currentSessionId();
|
||||
this.guidanceOpenId.set(id && this.guidanceOpenId() !== id ? id : null);
|
||||
}
|
||||
|
||||
// Expand States (Signals for 0-latency reactivity)
|
||||
public isHoveringCard = signal<boolean>(false);
|
||||
@@ -103,6 +133,69 @@ export class WorkspaceComponent implements OnInit {
|
||||
return this.agentService.isCurrentSessionRunning();
|
||||
});
|
||||
|
||||
public async pauseCurrentTask(): Promise<void> {
|
||||
const id = this.agentService.currentSessionId();
|
||||
if (!id || this.guidanceStatus() !== 'running' || this.guidanceBusy()) return;
|
||||
this.guidanceBusy.set(true);
|
||||
this.errorMessage.set(null);
|
||||
try {
|
||||
await firstValueFrom(this.http.post(`/api/tasks/${encodeURIComponent(id)}/pause`, {}));
|
||||
this.pauseRequestId.set(id);
|
||||
this.guidanceOpenId.set(null);
|
||||
this.agentService.fetchStatus();
|
||||
this.agentService.fetchSessions();
|
||||
} catch (error) {
|
||||
this.showGuidanceError(error);
|
||||
} finally {
|
||||
this.guidanceBusy.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
public async submitGuidance(): Promise<void> {
|
||||
const id = this.agentService.currentSessionId();
|
||||
const instruction = this.taskGuidance.trim();
|
||||
const status = this.guidanceStatus();
|
||||
if (!id || !status || !instruction || this.guidanceBusy()) return;
|
||||
this.guidanceBusy.set(true);
|
||||
this.errorMessage.set(null);
|
||||
try {
|
||||
await firstValueFrom(this.http.post(`/api/tasks/${encodeURIComponent(id)}/guide`, { instruction }));
|
||||
this.guidanceDrafts.delete(id);
|
||||
this.pauseRequestId.set(null);
|
||||
this.guidanceOpenId.set(null);
|
||||
this.agentService.fetchGuidance(id);
|
||||
this.agentService.fetchStatus();
|
||||
this.agentService.fetchSessions();
|
||||
} catch (error) {
|
||||
this.showGuidanceError(error);
|
||||
} finally {
|
||||
this.guidanceBusy.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
public async continueCurrentTask(): Promise<void> {
|
||||
const id = this.agentService.currentSessionId();
|
||||
if (!id || this.guidanceStatus() !== 'task_paused' || this.guidanceBusy()) return;
|
||||
this.guidanceBusy.set(true);
|
||||
this.errorMessage.set(null);
|
||||
try {
|
||||
await firstValueFrom(this.http.post(`/api/tasks/${encodeURIComponent(id)}/continue`, {}));
|
||||
this.pauseRequestId.set(null);
|
||||
this.guidanceOpenId.set(null);
|
||||
this.agentService.fetchStatus();
|
||||
this.agentService.fetchSessions();
|
||||
} catch (error) {
|
||||
this.showGuidanceError(error);
|
||||
} finally {
|
||||
this.guidanceBusy.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
private showGuidanceError(error: unknown): void {
|
||||
const response = error as { error?: { detail?: string }; message?: string };
|
||||
this.errorMessage.set(response.error?.detail || response.message || '操作失败,请刷新任务状态后重试');
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamic tooltip and label indicating which task will be stopped
|
||||
*/
|
||||
|
||||
Reference in new issue
Block a user