281 lines
9.8 KiB
TypeScript
281 lines
9.8 KiB
TypeScript
import { TaskIdCopyComponent } from '../task-id-copy/task-id-copy.component';
|
|
import { FavoriteButtonComponent } from '../favorite-button/favorite-button.component';
|
|
import { AutomationPanelComponent } from '../automation-panel/automation-panel.component';
|
|
import { TaskHistoryComponent } from '../task-history/task-history.component';
|
|
import { UiLabelPipe } from '../../utils/ui-label.pipe';
|
|
/**
|
|
* Copyright 2026 Google LLC
|
|
*
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
* you may not use this file except in compliance with the License.
|
|
* You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
* See the License for the specific language governing permissions and
|
|
* limitations under the License.
|
|
*/
|
|
|
|
import { Component, ChangeDetectionStrategy, inject, computed, signal } from '@angular/core';
|
|
import { CommonModule } from '@angular/common';
|
|
import { FormsModule } from '@angular/forms';
|
|
import { AgentService } from '../../services/agent.service';
|
|
import { Session } from '../../core/models/session.model';
|
|
import { TestResultBadgeComponent } from '../test-result-badge/test-result-badge.component';
|
|
import { TaskModeBadgeComponent } from '../task-mode-badge/task-mode-badge.component';
|
|
import { TaskBatchFilterComponent } from '../task-batch-filter/task-batch-filter.component';
|
|
import { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote } from '../../core/models/markdown.model';
|
|
import { parseNote, parseNoteLines } from '../../utils/markdown-parser.util';
|
|
|
|
export type { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote };
|
|
|
|
@Component({
|
|
selector: 'app-chat-interface',
|
|
standalone: true,
|
|
imports: [FavoriteButtonComponent, TaskIdCopyComponent, UiLabelPipe, CommonModule, FormsModule, AutomationPanelComponent, TaskHistoryComponent, TestResultBadgeComponent, TaskModeBadgeComponent, TaskBatchFilterComponent],
|
|
templateUrl: './chat-interface.component.html',
|
|
styleUrl: './chat-interface.component.scss',
|
|
changeDetection: ChangeDetectionStrategy.OnPush
|
|
})
|
|
export class ChatInterfaceComponent {
|
|
public agentService = inject(AgentService);
|
|
|
|
public taskInput: string = '';
|
|
// Signals so async completion handlers refresh this OnPush view.
|
|
public isSubmitting = signal<boolean>(false);
|
|
public errorMessage = signal<string | null>(null);
|
|
|
|
// Device-serial resolution can require a JSON.parse of device_info; memoize
|
|
// it per session object so template re-evaluation stays cheap.
|
|
private deviceSerialCache = new WeakMap<Session, string | null>();
|
|
|
|
/**
|
|
* Filtered computed list of active tasks (running or pending) sorted by status and submission order
|
|
*/
|
|
public activeQueue = computed(() => {
|
|
const list = this.agentService.sessions().filter((s) => {
|
|
const status = this.getTaskStatus(s);
|
|
return ['running', 'paused', 'task_paused', 'resuming', 'pending', 'queued'].includes(status) && this.agentService.matchesBatchFilter(s) && this.agentService.isQueueRepresentative(s);
|
|
});
|
|
return list.sort((a, b) => {
|
|
const statusA = this.getTaskStatus(a);
|
|
const statusB = this.getTaskStatus(b);
|
|
const isRunA = statusA === 'running' || statusA === 'paused';
|
|
const isRunB = statusB === 'running' || statusB === 'paused';
|
|
if (isRunA && !isRunB) return -1;
|
|
if (!isRunA && isRunB) return 1;
|
|
return (a.start_time || 0) - (b.start_time || 0); // stable FIFO order
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Filtered computed list of historical/completed tasks (completed, failed, or cancelled)
|
|
*/
|
|
public historyTasks = computed(() => {
|
|
return this.agentService.sessions().filter((s) => {
|
|
const status = this.getTaskStatus(s);
|
|
return status === 'completed' || status === 'failed' || status === 'cancelled';
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Submit a new task goal to the backend
|
|
*/
|
|
public submitTask(): void {
|
|
const goal = this.taskInput.trim();
|
|
if (!goal) {
|
|
return;
|
|
}
|
|
|
|
this.isSubmitting.set(true);
|
|
this.errorMessage.set(null);
|
|
|
|
this.agentService.runTask(goal).subscribe({
|
|
next: (res) => {
|
|
this.taskInput = '';
|
|
this.isSubmitting.set(false);
|
|
// Fetch status to refresh sessions list and select new session
|
|
this.agentService.fetchStatus();
|
|
},
|
|
error: (err) => {
|
|
console.error('Failed to submit task:', err);
|
|
this.isSubmitting.set(false);
|
|
this.errorMessage.set(err.error?.detail || "执行器忙碌,请等待当前任务结束。");
|
|
// Auto-dismiss error banner after 5 seconds
|
|
setTimeout(() => {
|
|
this.errorMessage.set(null);
|
|
}, 5000);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Stop an individual task session
|
|
*/
|
|
public stopTask(sessionId: string, event: MouseEvent): void {
|
|
event.stopPropagation();
|
|
this.isSubmitting.set(true);
|
|
this.errorMessage.set(null);
|
|
this.agentService.stopTask(sessionId, false);
|
|
setTimeout(() => {
|
|
this.isSubmitting.set(false);
|
|
}, 400);
|
|
}
|
|
|
|
/**
|
|
* Stop the currently running task and optionally clear the backend queue
|
|
*/
|
|
public stopTasks(stopAll: boolean = false): void {
|
|
this.isSubmitting.set(true);
|
|
this.errorMessage.set(null);
|
|
this.agentService.stopTask(stopAll);
|
|
setTimeout(() => {
|
|
this.isSubmitting.set(false);
|
|
}, 400);
|
|
}
|
|
|
|
/**
|
|
* Clear all database data/history to start fresh
|
|
*/
|
|
public clearHistory(): void {
|
|
if (!confirm("确定清空所有任务和历史记录?此操作无法撤销。")) {
|
|
return;
|
|
}
|
|
this.isSubmitting.set(true);
|
|
this.errorMessage.set(null);
|
|
this.agentService.clearAllHistory().subscribe({
|
|
next: () => {
|
|
this.isSubmitting.set(false);
|
|
},
|
|
error: (err: any) => {
|
|
console.error('Failed to clear history:', err);
|
|
this.isSubmitting.set(false);
|
|
this.errorMessage.set(err.error?.detail || "清空历史记录失败。");
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Delete an individual task / session
|
|
*/
|
|
public deleteTask(sessionId: string, event: MouseEvent): void {
|
|
event.stopPropagation();
|
|
if (!confirm(this.agentService.deletionPrompt(sessionId))) {
|
|
return;
|
|
}
|
|
this.isSubmitting.set(true);
|
|
this.errorMessage.set(null);
|
|
this.agentService.deleteSession(sessionId).subscribe({
|
|
next: () => {
|
|
this.isSubmitting.set(false);
|
|
},
|
|
error: (err: any) => {
|
|
console.error(`删除任务失败:${sessionId}:`, err);
|
|
this.isSubmitting.set(false);
|
|
this.errorMessage.set(err.error?.detail || "删除任务失败。");
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Determine the current task execution status
|
|
*/
|
|
public getTaskStatus(session: Session): 'running' | 'paused' | 'completed' | 'pending' | 'failed' | 'cancelled' {
|
|
const exploration = this.agentService.explorationQueueStatus(session);
|
|
if (exploration) return exploration as 'running' | 'paused' | 'completed' | 'pending' | 'failed' | 'cancelled';
|
|
|
|
if (session.status) {
|
|
const s = session.status.toLowerCase();
|
|
if (s === 'completed' || s === 'success' || s === 'failed' || s === 'cancelled') {
|
|
return (s === 'success' ? 'completed' : s) as any;
|
|
}
|
|
if (s === 'running' || s === 'paused' || s === 'pending') {
|
|
return s as any;
|
|
}
|
|
}
|
|
if (session.session_id === this.agentService.runningSessionId() && (this.agentService.agentStatus() === 'running' || this.agentService.agentStatus() === 'paused')) {
|
|
return this.agentService.agentStatus() as 'running' | 'paused';
|
|
}
|
|
return 'completed';
|
|
}
|
|
|
|
/**
|
|
* Determine the device serial number for the session
|
|
*/
|
|
public getDeviceSerial(session: Session): string | null {
|
|
if (this.deviceSerialCache.has(session)) {
|
|
return this.deviceSerialCache.get(session) ?? null;
|
|
}
|
|
let resolved: string | null = null;
|
|
const serial = session.device_serial || session.device_id;
|
|
if (serial && serial !== 'pending' && serial !== 'null' && serial !== 'undefined') {
|
|
resolved = serial;
|
|
} else if (session.device_info) {
|
|
try {
|
|
const info = typeof session.device_info === 'string' ? JSON.parse(session.device_info) : session.device_info;
|
|
const s = info?.device_id || info?.device_serial;
|
|
if (s && s !== 'pending' && s !== 'null' && s !== 'undefined') {
|
|
resolved = s;
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
this.deviceSerialCache.set(session, resolved);
|
|
return resolved;
|
|
}
|
|
|
|
/**
|
|
* Select a session in the UI to monitor its steps
|
|
*/
|
|
public selectTask(sessionId: string): void {
|
|
this.agentService.selectSession(sessionId, true);
|
|
}
|
|
|
|
// Notes Computed Properties
|
|
public currentNoteContent = computed(() => {
|
|
const notes = this.agentService.currentNotes();
|
|
const key = this.agentService.selectedNoteKey();
|
|
return notes[key] || '';
|
|
});
|
|
|
|
public noteKeys = computed(() => {
|
|
return Object.keys(this.agentService.currentNotes()).filter(key => key.toLowerCase().endsWith('.md'));
|
|
});
|
|
|
|
public parsedNote = computed<ParsedNote>(() => {
|
|
return this.getParsedNote(this.currentNoteContent());
|
|
});
|
|
|
|
public getParsedNote(content: string): ParsedNote {
|
|
return parseNote(content);
|
|
}
|
|
|
|
public getParsedNoteLines(content: string): MarkdownLine[] {
|
|
return parseNoteLines(content);
|
|
}
|
|
|
|
public selectNote(key: string): void {
|
|
this.agentService.selectedNoteKey.set(key);
|
|
}
|
|
|
|
public trackSession(index: number, session: Session): string {
|
|
return session.session_id;
|
|
}
|
|
|
|
public trackNoteKey(index: number, key: string): string {
|
|
return key;
|
|
}
|
|
|
|
public trackMilestone(index: number, milestone: NoteMilestone): number {
|
|
return milestone.index;
|
|
}
|
|
|
|
public trackMarkdownLine(index: number, line: MarkdownLine): number {
|
|
return index;
|
|
}
|
|
}
|