fix: improve cross-device evidence, request diagnostics and prompt efficiency
This commit is contained in:
1 parent
621f30e437
commit
a66803651a
60 files changed
+3097
-160
No files matched your search
@@ -17,6 +17,7 @@ import json
|
||||
import time
|
||||
from uuid import UUID
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import Response
|
||||
|
||||
from artemis.config import DB_PATH, TRACES_PATH
|
||||
from artemis.runtime import trace_store
|
||||
@@ -35,6 +36,20 @@ from apps.admin_console.services.exploration_listing import exploration_metadata
|
||||
router = APIRouter(tags=["sessions"])
|
||||
|
||||
|
||||
@router.get("/api/sessions/{session_id}/request-diagnostics")
|
||||
def get_request_diagnostics(session_id: str, download: bool = False):
|
||||
from apps.admin_console.services.request_diagnostics import request_diagnostics
|
||||
|
||||
result = request_diagnostics(session_id, limit=None if download else 100)
|
||||
if not download:
|
||||
return result
|
||||
return Response(
|
||||
json.dumps(result, ensure_ascii=False, indent=2),
|
||||
media_type="application/json",
|
||||
headers={"Content-Disposition": 'attachment; filename="model-request-diagnostics.json"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/sessions")
|
||||
async def list_sessions():
|
||||
# The body does blocking work (sqlite queries, filesystem scans, ffmpeg
|
||||
|
||||
@@ -7,6 +7,7 @@ from pathlib import Path
|
||||
import re
|
||||
import threading
|
||||
import zipfile
|
||||
from apps.admin_console.services.request_diagnostics import diagnostics_json
|
||||
|
||||
from docx import Document
|
||||
from docx.enum.table import WD_CELL_VERTICAL_ALIGNMENT
|
||||
@@ -900,6 +901,7 @@ def export_session_media(session_id: str, kind: str) -> Path:
|
||||
else zipfile.ZIP_DEFLATED,
|
||||
)
|
||||
if kind == "all":
|
||||
archive.writestr(prefix + "模型请求诊断.json", diagnostics_json(session_id))
|
||||
archive.writestr(
|
||||
prefix + "证据索引.json",
|
||||
json.dumps(
|
||||
@@ -980,6 +982,7 @@ def export_batch(batch_id: str, progress: Callable[[int, int, str], None] | None
|
||||
if source.suffix.lower() in {".mp4", ".mkv", ".webm"}
|
||||
else zipfile.ZIP_DEFLATED,
|
||||
)
|
||||
archive.writestr(f"{prefix}/模型请求诊断.json", diagnostics_json(report["id"]))
|
||||
archive.writestr(
|
||||
f"{prefix}/证据索引.json",
|
||||
json.dumps(
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Read/export model diagnostics from the task's existing trace database."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
import json
|
||||
import time
|
||||
|
||||
from apps.admin_console.database.connection import db_session
|
||||
|
||||
|
||||
def request_diagnostics(session_id: str, *, db_path=None, limit: int | None = None) -> dict:
|
||||
with db_session(db_path) as conn:
|
||||
session = conn.execute(
|
||||
"SELECT status FROM sessions WHERE session_id = ?", (session_id,)
|
||||
).fetchone()
|
||||
sql = (
|
||||
"SELECT payload FROM traces WHERE session_id = ? AND type = 'llm_diagnostic' "
|
||||
"ORDER BY timestamp DESC"
|
||||
)
|
||||
params: tuple = (session_id,)
|
||||
if limit is not None:
|
||||
sql += " LIMIT ?"
|
||||
params += (limit,)
|
||||
rows = conn.execute(sql, params).fetchall()
|
||||
requests = []
|
||||
seen = set()
|
||||
running = bool(session and session["status"] in {"running", "resuming"})
|
||||
for row in rows:
|
||||
try:
|
||||
value = json.loads(row["payload"])
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
if not isinstance(value, dict):
|
||||
continue
|
||||
request_id = value.get("request_id")
|
||||
value["active"] = (
|
||||
running
|
||||
and request_id not in seen
|
||||
and value.get("phase")
|
||||
in {
|
||||
"waiting_response",
|
||||
"waiting_output",
|
||||
"receiving_output",
|
||||
"generating_tool_arguments",
|
||||
"retry_wait",
|
||||
}
|
||||
)
|
||||
seen.add(request_id)
|
||||
value["timestamps_utc"] = {
|
||||
field: datetime.fromtimestamp(value[field], UTC).isoformat()
|
||||
for field in (
|
||||
"started_at",
|
||||
"headers_at",
|
||||
"first_output_at",
|
||||
"last_output_at",
|
||||
"ended_at",
|
||||
"retry_at",
|
||||
)
|
||||
if isinstance(value.get(field), (int, float))
|
||||
}
|
||||
requests.append(value)
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"session_id": session_id,
|
||||
"session_status": session["status"] if session else "unknown",
|
||||
"collected_at": time.time(),
|
||||
"requests": requests,
|
||||
"note": "时间戳为 Unix 秒;timestamps_utc 为 UTC。HTTP 200 不代表完整输出。usage=null 表示服务未报告用量。历史任务没有记录的字段无法补推。",
|
||||
}
|
||||
|
||||
|
||||
def diagnostics_json(session_id: str) -> str:
|
||||
return json.dumps(request_diagnostics(session_id), ensure_ascii=False, indent=2)
|
||||
@@ -509,6 +509,7 @@ limitations under the License.
|
||||
<!-- Event Logs Section -->
|
||||
<main class="stream-logs-content">
|
||||
<ng-content select="[task-summary]"></ng-content>
|
||||
<app-llm-request-status [sessionId]="agentService.currentSessionId()" [running]="agentService.isCurrentSessionRunning()" (activeChange)="hasModelRequest.set($event)" />
|
||||
@if (agentService.isSessionContentLoading()) {
|
||||
<div class="session-loading-state" aria-live="polite">
|
||||
<span class="material-symbols-outlined spin-animation">progress_activity</span>
|
||||
@@ -516,7 +517,7 @@ limitations under the License.
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!agentService.isSessionContentLoading() && phases().length === 0 && !showPlanningLoader() && startupWorkItems().length === 0) {
|
||||
@if (!agentService.isSessionContentLoading() && phases().length === 0 && !showPlanningLoader() && !hasModelRequest() && startupWorkItems().length === 0) {
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon"><span class="material-symbols-outlined">sensors</span></div>
|
||||
<h3>尚未选择执行记录</h3>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ExplorationMessage, Demonstration } from '../../services/exploration.se
|
||||
import { mergeConversation } from '../../utils/exploration-timeline.util';
|
||||
import { TaskIdCopyComponent } from '../task-id-copy/task-id-copy.component';
|
||||
import { TaskHistoryComponent } from '../task-history/task-history.component';
|
||||
import { LlmRequestStatusComponent } from '../llm-request-status/llm-request-status.component';
|
||||
import { AutomationPanelComponent } from '../automation-panel/automation-panel.component';
|
||||
import { UiLabelPipe } from '../../utils/ui-label.pipe';
|
||||
/**
|
||||
@@ -246,7 +247,7 @@ export type { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote, StreamRe
|
||||
@Component({
|
||||
selector: 'app-agent-stream',
|
||||
standalone: true,
|
||||
imports: [TaskIdCopyComponent, UiLabelPipe, CommonModule, FormsModule, OverlayModule, TaskHistoryComponent, AutomationPanelComponent, TestResultBadgeComponent, TestResultCardComponent, TaskBatchFilterComponent],
|
||||
imports: [LlmRequestStatusComponent, TaskIdCopyComponent, UiLabelPipe, CommonModule, FormsModule, OverlayModule, TaskHistoryComponent, AutomationPanelComponent, TestResultBadgeComponent, TestResultCardComponent, TaskBatchFilterComponent],
|
||||
templateUrl: './agent-stream.component.html',
|
||||
styleUrl: './agent-stream.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
@@ -396,6 +397,7 @@ export class AgentStreamComponent implements AfterViewInit {
|
||||
public expandedActionCards = signal<Set<string>>(new Set<string>());
|
||||
public collapsedActionCards = signal<Set<string>>(new Set<string>());
|
||||
public retryClock = signal<number>(Date.now());
|
||||
public hasModelRequest = signal(false);
|
||||
|
||||
// Performance caches for parameter and event extractions
|
||||
private actionParamsCache = new WeakMap<any, ActionParam[]>();
|
||||
@@ -470,6 +472,7 @@ export class AgentStreamComponent implements AfterViewInit {
|
||||
});
|
||||
|
||||
public showPlanningLoader = computed(() => {
|
||||
if (this.hasModelRequest()) return false;
|
||||
const isRunning = this.agentService.agentStatus() === 'running';
|
||||
const currentSessionId = this.agentService.currentSessionId();
|
||||
const runningSessionId = this.agentService.runningSessionId();
|
||||
|
||||
+23
-2
@@ -29,6 +29,7 @@ limitations under the License.
|
||||
[class.theater]="isTheaterMode()"
|
||||
[style.left.px]="!isTheaterMode() ? posX() : null"
|
||||
[style.top.px]="!isTheaterMode() ? posY() : null"
|
||||
[style.--player-top]="posY() + 'px'"
|
||||
>
|
||||
<!-- Window Header (Draggable Handle) -->
|
||||
<header class="window-header" (mousedown)="startDrag($event)">
|
||||
@@ -111,6 +112,9 @@ limitations under the License.
|
||||
<div class="recording-notice">此来源暂无可播放录像,请查看步骤截图或日志。</div>
|
||||
}
|
||||
}
|
||||
@if (!agentService.isVideoMinimized() && agentService.playerMode() === 'video' && missingRecordingLabels().length) {
|
||||
<div class="recording-notice" role="status">{{ missingRecordingLabels().join('、') }}步骤没有对应录像,可切换「步骤」查看已有截图。</div>
|
||||
}
|
||||
<!-- Video Main Content Area -->
|
||||
@if (!agentService.isVideoMinimized()) {
|
||||
<div class="video-body">
|
||||
@@ -369,8 +373,14 @@ limitations under the License.
|
||||
<!-- Dedicated Bottom Controls Bar for Video -->
|
||||
@if (!videoLoadError()) {
|
||||
<div class="video-controls-footer">
|
||||
@if (hasRecordingGaps()) {
|
||||
<div class="recording-coverage-summary">
|
||||
<span>已录制 {{ formatTime(recordedDuration()) }}</span>
|
||||
<span>空白区间自动跳过</span>
|
||||
</div>
|
||||
}
|
||||
<!-- Timeline Scrubber Bar -->
|
||||
<div class="scrubber-bar-container">
|
||||
<div class="scrubber-bar-container" [class.has-recording-gaps]="hasRecordingGaps()">
|
||||
<input
|
||||
type="range"
|
||||
class="scrubber-range"
|
||||
@@ -382,6 +392,14 @@ limitations under the License.
|
||||
(input)="onScrub($event)"
|
||||
/>
|
||||
<div class="scrubber-progress-fill" [style.width.%]="progressPercent()"></div>
|
||||
@if (hasRecordingGaps()) {
|
||||
<div class="recording-coverage" aria-hidden="true">
|
||||
@for (range of recordedCoverage(); track $index) {
|
||||
<span [style.left.%]="range.left" [style.width.%]="range.width"></span>
|
||||
}
|
||||
<i [style.left.%]="progressPercent()"></i>
|
||||
</div>
|
||||
}
|
||||
@if (videoStepMarkers().length > 0) {
|
||||
<div class="video-markers" aria-label="录像关键步骤">
|
||||
@for (marker of videoStepMarkers(); track marker.time) {
|
||||
@@ -412,7 +430,7 @@ limitations under the License.
|
||||
</button>
|
||||
<!-- Time Display -->
|
||||
<span class="time-display">
|
||||
{{ formatTime(currentTime()) }} / {{ formatTime(duration()) }}
|
||||
<span [title]="agentService.usesSessionTimeline() ? '任务时间;蓝色区间有录像,空白区间未录制' : '录像时间'">{{ formatTime(currentTime()) }} / {{ formatTime(duration()) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="controls-right">
|
||||
@@ -443,6 +461,9 @@ limitations under the License.
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@if (skippedInterval()) {
|
||||
<div class="recording-skip-notice" role="status">{{ skippedInterval() }}</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
+48
@@ -31,6 +31,7 @@
|
||||
position: fixed;
|
||||
width: 400px;
|
||||
max-width: calc(100vw - 32px);
|
||||
max-height: calc(100dvh - var(--player-top, 60px) - 12px);
|
||||
background: rgba(255, 255, 255, 0.84);
|
||||
backdrop-filter: blur(24px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(180%);
|
||||
@@ -298,6 +299,7 @@
|
||||
|
||||
/* --- Video Body Area --- */
|
||||
.video-body {
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
background: #ffffff;
|
||||
display: flex;
|
||||
@@ -655,6 +657,7 @@
|
||||
}
|
||||
|
||||
.video-container {
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
@@ -664,6 +667,7 @@
|
||||
background: #ffffff;
|
||||
|
||||
video {
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 520px;
|
||||
@@ -1056,3 +1060,47 @@
|
||||
text-align: center;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.recording-coverage-summary {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 12px;
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.recording-skip-notice {
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.has-recording-gaps {
|
||||
.scrubber-progress-fill { opacity: 0; }
|
||||
|
||||
.recording-coverage {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
|
||||
span {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
height: 5px;
|
||||
border-radius: 3px;
|
||||
background: #0284c7;
|
||||
}
|
||||
|
||||
i {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 3px;
|
||||
height: 8px;
|
||||
background: #0f172a;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
}
|
||||
+118
@@ -233,6 +233,124 @@ describe('FloatingVideoPlayerComponent video markers', () => {
|
||||
fixture.detectChanges();
|
||||
}
|
||||
|
||||
function setGappedRecording(): void {
|
||||
agentService.usesSessionTimeline.set(true);
|
||||
agentService.recordingSessionDuration.set(1040);
|
||||
agentService.activeVideoSegments.set([
|
||||
{ url: '/test.mp4', start: 2, offset_ms: 2000, duration: 20, width: 400, height: 800 },
|
||||
{ url: '/resume.mp4', start: 1000, offset_ms: 1000000, duration: 20, width: 400, height: 800 }
|
||||
]);
|
||||
fixture.detectChanges();
|
||||
video.dispatchEvent(new Event('loadedmetadata'));
|
||||
}
|
||||
|
||||
it('automatically skips a long pause and plays the next clip after it loads', () => {
|
||||
setGappedRecording();
|
||||
component.togglePlay();
|
||||
play.calls.reset();
|
||||
time = 20;
|
||||
paused = true;
|
||||
component.onEnded();
|
||||
expect(component.activeSegmentIndex()).toBe(1);
|
||||
expect(component.currentTime()).toBe(1000);
|
||||
expect(component.gapMessage()).toBe('');
|
||||
expect(component.skippedInterval()).toContain('00:22–16:40');
|
||||
expect(play).not.toHaveBeenCalled();
|
||||
component.onTimeUpdate(); // Late timeupdate from the ended clip must not move the new timeline.
|
||||
expect(component.currentTime()).toBe(1000);
|
||||
fixture.detectChanges();
|
||||
video.dispatchEvent(new Event('loadedmetadata'));
|
||||
finishSeek();
|
||||
expect(time).toBe(0);
|
||||
expect(paused).toBeFalse();
|
||||
expect(play).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('restarts and loops at the first recorded frame, skipping leading and trailing gaps', () => {
|
||||
setGappedRecording();
|
||||
component.restart();
|
||||
finishSeek();
|
||||
expect(component.currentTime()).toBe(2);
|
||||
expect(paused).toBeFalse();
|
||||
component.seek(1010, true);
|
||||
fixture.detectChanges();
|
||||
video.dispatchEvent(new Event('loadedmetadata'));
|
||||
finishSeek();
|
||||
component.isLooping.set(true);
|
||||
paused = true;
|
||||
component.onEnded();
|
||||
fixture.detectChanges();
|
||||
video.dispatchEvent(new Event('loadedmetadata'));
|
||||
finishSeek();
|
||||
expect(component.activeSegmentIndex()).toBe(0);
|
||||
expect(component.currentTime()).toBe(2);
|
||||
expect(component.gapMessage()).toBe('');
|
||||
expect(paused).toBeFalse();
|
||||
});
|
||||
|
||||
for (const playing of [false, true]) {
|
||||
it(`snaps a scrub in a gap forward while preserving ${playing ? 'playing' : 'paused'} intent`, () => {
|
||||
setGappedRecording();
|
||||
if (playing) component.togglePlay();
|
||||
const input = fixture.nativeElement.querySelector('.scrubber-range') as HTMLInputElement;
|
||||
input.value = '500';
|
||||
input.dispatchEvent(new Event('input'));
|
||||
fixture.detectChanges();
|
||||
video.dispatchEvent(new Event('loadedmetadata'));
|
||||
finishSeek();
|
||||
expect(component.currentTime()).toBe(1000);
|
||||
expect(component.skippedInterval()).toContain('08:20–16:40');
|
||||
expect(paused).toBe(!playing);
|
||||
});
|
||||
}
|
||||
|
||||
it('does not substitute another time for an exact step seek in a gap', () => {
|
||||
setGappedRecording();
|
||||
component.seekToSessionTime(500, true);
|
||||
expect(component.currentTime()).toBe(500);
|
||||
expect(component.gapMessage()).toContain('未录制');
|
||||
expect(paused).toBeTrue();
|
||||
expect(play).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ends at the last recorded frame without playing the trailing gap', () => {
|
||||
setGappedRecording();
|
||||
component.seek(1010, true);
|
||||
fixture.detectChanges();
|
||||
video.dispatchEvent(new Event('loadedmetadata'));
|
||||
finishSeek();
|
||||
play.calls.reset();
|
||||
paused = true;
|
||||
component.onEnded();
|
||||
expect(component.currentTime()).toBe(1020);
|
||||
expect(component.isPlaying()).toBeFalse();
|
||||
expect(component.skippedInterval()).toContain('录像已结束');
|
||||
expect(play).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows actual recorded duration and coverage separately from task duration', () => {
|
||||
setGappedRecording();
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('.recording-coverage-summary').textContent).toContain('00:40');
|
||||
expect(fixture.nativeElement.querySelector('.time-display').textContent).toContain('17:20');
|
||||
expect(fixture.nativeElement.querySelectorAll('.recording-coverage span').length).toBe(2);
|
||||
expect(component.recordedCoverage()[1].left).toBeCloseTo(1000 / 1040 * 100);
|
||||
});
|
||||
|
||||
it('explains missing computer footage without treating Android video as its evidence', () => {
|
||||
agentService.currentSessionStepFrames.update(frames => [...frames,
|
||||
{ ...frames[0], sourceId: 'plugin:open-computer-use', sourceLabel: '电脑', index: 1 }
|
||||
]);
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('.recording-notice').textContent).toContain('电脑步骤没有对应录像');
|
||||
agentService.recordingSources.set([
|
||||
{ id: 'pc', label: '电脑', kind: 'computer', target_ids: ['plugin:open-computer-use'],
|
||||
status: 'ready', segments: agentService.activeVideoSegments() }
|
||||
]);
|
||||
fixture.detectChanges();
|
||||
expect(component.missingRecordingLabels()).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles a seek request once without seeking back on play or pause', () => {
|
||||
const seek = spyOn(component, 'seekToSessionTime').and.callThrough();
|
||||
agentService.videoSeekRequest.set({ seconds: 5, requestId: 1 });
|
||||
|
||||
+58
-12
@@ -63,6 +63,7 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
||||
public isTheaterMode = signal<boolean>(false);
|
||||
public videoLoadError = signal<boolean>(false);
|
||||
public gapMessage = signal('');
|
||||
public skippedInterval = signal('');
|
||||
private sourceSwitch: { time: number; play: boolean } | null = null;
|
||||
private lastHandledVideoSeekRequestId: number | null = null;
|
||||
private gapCanAdvance = true;
|
||||
@@ -93,6 +94,23 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
||||
private stepTimer: any = null;
|
||||
|
||||
public stepFrames = computed(() => this.agentService.currentSessionStepFrames());
|
||||
public missingRecordingLabels = computed(() => {
|
||||
const sources = this.sources();
|
||||
return [...new Set(this.stepFrames()
|
||||
.filter(frame => frame.sourceId && frame.sourceId !== 'android' && !sources.some(source =>
|
||||
(source.id === frame.sourceId || source.target_ids.includes(frame.sourceId!)) &&
|
||||
(source.segments.length > 0 || source.url)))
|
||||
.map(frame => frame.sourceLabel || '插件'))];
|
||||
});
|
||||
public recordedDuration = computed(() => this.agentService.activeVideoSegments()
|
||||
.reduce((sum, segment) => sum + segment.duration, 0));
|
||||
public hasRecordingGaps = computed(() => !!this.agentService.usesSessionTimeline?.() &&
|
||||
this.agentService.activeVideoSegments().length > 0 && this.duration() - this.recordedDuration() > 0.1);
|
||||
public recordedCoverage = computed(() => this.agentService.activeVideoSegments().map(segment => ({
|
||||
start: segment.start,
|
||||
left: this.duration() > 0 ? segment.start / this.duration() * 100 : 0,
|
||||
width: this.duration() > 0 ? segment.duration / this.duration() * 100 : 0
|
||||
})));
|
||||
public videoStepMarkers = computed(() => buildVideoStepMarkers(
|
||||
this.stepFrames().filter(frame => {
|
||||
const source = this.sources().find(s => s.id === this.agentService.selectedRecordingSource?.());
|
||||
@@ -153,6 +171,7 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
||||
this.sourceSwitch = null;
|
||||
this.currentTime.set(switching?.time || 0);
|
||||
this.gapMessage.set('');
|
||||
this.skippedInterval.set('');
|
||||
this.gapCanAdvance = true;
|
||||
this.duration.set(this.agentService.usesSessionTimeline?.()
|
||||
? Math.max(this.agentService.recordingSessionDuration?.() || 0, ...segments.map(s => s.start + s.duration))
|
||||
@@ -385,11 +404,12 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
||||
} else if (shouldPlay) {
|
||||
this.playVideo();
|
||||
}
|
||||
if (this.pendingLocalTime === null) this.onTimeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
public onTimeUpdate(): void {
|
||||
if (this.gapMessage()) return;
|
||||
if (this.gapMessage() || this.pendingLocalTime !== null) return;
|
||||
const v = this.videoRef?.nativeElement;
|
||||
if (v) {
|
||||
const segments = this.agentService.activeVideoSegments();
|
||||
@@ -428,21 +448,23 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
||||
if (segments.length && this.activeSegmentIndex() < segments.length - 1) {
|
||||
const current = segments[this.activeSegmentIndex()];
|
||||
const next = segments[this.activeSegmentIndex() + 1];
|
||||
this.seek(next.start, true);
|
||||
if (this.agentService.usesSessionTimeline?.() && next.start - current.start - current.duration > 0.05) {
|
||||
this.currentTime.set(current.start + current.duration);
|
||||
this.gapCanAdvance = true;
|
||||
this.gapMessage.set('此处录像存在中断。点击播放可前往下一段,或选择其他步骤。');
|
||||
return;
|
||||
this.showSkippedInterval(current.start + current.duration, next.start);
|
||||
}
|
||||
this.pendingLocalTime = 0;
|
||||
this.pendingAutoplay = true;
|
||||
this.activeSegmentIndex.update(index => index + 1);
|
||||
return;
|
||||
}
|
||||
if (segments.length && this.isLooping()) {
|
||||
this.seek(0, true);
|
||||
this.restart();
|
||||
return;
|
||||
}
|
||||
const last = segments[segments.length - 1];
|
||||
if (last && this.agentService.usesSessionTimeline?.()) {
|
||||
this.currentTime.set(last.start + last.duration);
|
||||
if (this.duration() - this.currentTime() > 0.05) {
|
||||
this.skippedInterval.set('录像已结束,之后的任务时间未录制。');
|
||||
}
|
||||
}
|
||||
this.isPlaying.set(false);
|
||||
}
|
||||
|
||||
@@ -462,11 +484,19 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
||||
if (this.gapMessage()) {
|
||||
if (!this.gapCanAdvance) return;
|
||||
const next = this.agentService.activeVideoSegments().find(s => s.start > this.currentTime());
|
||||
if (next) this.seek(next.start, true);
|
||||
if (next) {
|
||||
const from = this.currentTime();
|
||||
this.seek(next.start, true);
|
||||
this.showSkippedInterval(from, next.start);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const v = this.videoRef?.nativeElement;
|
||||
if (!v) return;
|
||||
if (v.ended && this.agentService.activeVideoSegments().length) {
|
||||
this.restart();
|
||||
return;
|
||||
}
|
||||
if (this.pendingLocalTime !== null) {
|
||||
this.pendingAutoplay = !this.isPlaying();
|
||||
this.isPlaying.set(this.pendingAutoplay);
|
||||
@@ -516,7 +546,8 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
public restart(): void {
|
||||
this.seek(0, true);
|
||||
const first = this.agentService.activeVideoSegments()[0];
|
||||
this.seek(this.agentService.usesSessionTimeline?.() ? first?.start || 0 : 0, true);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -549,10 +580,11 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
||||
: `步骤 ${first.stepNumber} · ${this.formatTime(marker.time)} · ${action}`;
|
||||
}
|
||||
|
||||
/** Seek on the back-to-back playlist axis used by the scrubber. */
|
||||
/** Seek exactly: a step/source request must never display unrelated evidence. */
|
||||
public seek(seconds: number, autoplay = false): void {
|
||||
const v = this.videoRef?.nativeElement;
|
||||
if (!v) return;
|
||||
this.skippedInterval.set('');
|
||||
const target = Math.max(0, Math.min(this.duration(), seconds));
|
||||
const segments = this.agentService.activeVideoSegments();
|
||||
// URL-only legacy recordings have no coverage manifest. Preserve their
|
||||
@@ -584,6 +616,7 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
||||
this.isPlaying.set(false);
|
||||
this.pendingLocalTime = location.localTime;
|
||||
this.pendingAutoplay = shouldPlay;
|
||||
this.waitingForMetadata = true;
|
||||
this.activeSegmentIndex.set(location.index);
|
||||
}
|
||||
this.currentTime.set(target);
|
||||
@@ -592,9 +625,22 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
||||
public onScrub(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const targetTime = parseFloat(input.value);
|
||||
const segments = this.agentService.activeVideoSegments();
|
||||
if (this.agentService.usesSessionTimeline?.() && segments.length && !locateCoveredSessionTime(segments, targetTime)) {
|
||||
const next = segments.find(segment => segment.start > targetTime);
|
||||
if (next) {
|
||||
this.seek(next.start);
|
||||
this.showSkippedInterval(targetTime, next.start);
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.seek(targetTime);
|
||||
}
|
||||
|
||||
private showSkippedInterval(from: number, to: number): void {
|
||||
this.skippedInterval.set(`已跳过未录制区间 ${this.formatTime(from)}–${this.formatTime(to)}`);
|
||||
}
|
||||
|
||||
public setSpeed(rate: number): void {
|
||||
this.playbackRate.set(rate);
|
||||
const v = this.videoRef?.nativeElement;
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
@if (sessionId()) {
|
||||
<section class="model-requests" aria-label="模型请求状态">
|
||||
@for (request of active(); track request.attempt_id) {
|
||||
<div class="request-live" [class.retrying]="request.phase === 'retry_wait'" [class.stale]="unavailable()">
|
||||
<div class="request-heading" aria-live="polite">
|
||||
<span class="status-dot" [class.waiting]="!unavailable()"></span>
|
||||
<strong>{{ unavailable() ? '请求状态暂时无法更新' : phase(request) }}</strong>
|
||||
<span class="model">{{ request.model || '模型未记录' }} · {{ role(request) }}{{ request.is_fallback ? ' · 兜底模型' : '' }}</span>
|
||||
<span class="elapsed">已用 {{ elapsed(request) }} 秒</span>
|
||||
</div>
|
||||
@if (!unavailable()) {
|
||||
<p>{{ hint(request, now()) }} @if (request.phase !== 'retry_wait') { 本次请求最长等待 {{ request.timeout_seconds }} 秒。 }</p>
|
||||
}
|
||||
@if (request.error) { <p class="error">{{ request.error }}</p> }
|
||||
</div>
|
||||
}
|
||||
@if (unavailable()) { <p class="notice" role="status">诊断信息暂时不可用;显示的是上次记录,不能据此判断任务是否卡住。</p> }
|
||||
<details>
|
||||
<summary>模型请求诊断 @if (requests().length) { <span>· 最近 {{ requests().length }} 次尝试</span> }</summary>
|
||||
<div class="diagnostics-header"><span>时间按本机时区显示;下载文件同时包含 UTC 时间。</span><a [href]="downloadUrl()" download>下载完整诊断</a></div>
|
||||
@if (loading()) { <p class="notice">正在加载请求记录…</p> }
|
||||
@if (!loading() && !unavailable() && !requests().length) { <p class="notice">暂无请求诊断。旧任务没有记录的时间和用量无法补推。</p> }
|
||||
<div class="request-history">
|
||||
@for (request of requests(); track request.attempt_id) {
|
||||
<details class="request-detail">
|
||||
<summary><span>{{ request.started_at * 1000 | date:'HH:mm:ss' }}</span> {{ request.model || '模型' }} · {{ role(request) }} · {{ phase(request) }} @if (!request.active && !request.ended_at) { <span>(未记录结束,当前未在执行)</span> } @if (request.is_fallback) { <span class="fallback">兜底</span> }</summary>
|
||||
<dl>
|
||||
<dt>本地请求编号</dt><dd>{{ request.request_id }}</dd>
|
||||
<dt>本次尝试编号</dt><dd>{{ request.attempt_id }}</dd>
|
||||
<dt>服务端请求编号</dt><dd>{{ request.server_request_id || '服务未提供' }}</dd>
|
||||
<dt>模型配置</dt><dd>{{ request.model || '未记录' }} / {{ request.reasoning_effort || '默认' }} · 第 {{ request.attempt }} 次请求,最多重试 {{ request.max_retries }} 次</dd>
|
||||
<dt>输出 Token 上限</dt><dd>{{ request.max_output_tokens || '未设置(由模型服务决定)' }}</dd>
|
||||
<dt>有效输出片段</dt><dd>{{ request.output_chunks ?? '未记录' }}(客户端计数,与服务端 SSE 事件数口径不同)</dd>
|
||||
<dt>工具参数</dt><dd>{{ request.tool_argument_chars ?? '未记录' }} 字符 @for (tool of request.tools || []; track $index) { <span class="image-size">{{ tool.name || '工具名未提供' }} · {{ tool.argument_chars }} 字符</span> }</dd>
|
||||
<dt>请求开始</dt><dd>{{ request.started_at * 1000 | date:'yyyy-MM-dd HH:mm:ss.SSS Z' }}</dd>
|
||||
<dt>收到响应头</dt><dd>{{ request.headers_at ? (request.headers_at * 1000 | date:'HH:mm:ss.SSS') : '未记录' }} · HTTP {{ request.http_status || '未知' }}</dd>
|
||||
<dt>首个有效输出</dt><dd>{{ request.first_output_at ? (request.first_output_at * 1000 | date:'HH:mm:ss.SSS') : '未收到/未记录' }}</dd>
|
||||
<dt>最后有效输出</dt><dd>{{ request.last_output_at ? (request.last_output_at * 1000 | date:'HH:mm:ss.SSS') : '未收到/未记录' }}</dd>
|
||||
<dt>结束时间</dt><dd>{{ request.ended_at ? (request.ended_at * 1000 | date:'HH:mm:ss.SSS') : '尚未记录' }}</dd>
|
||||
<dt>输入图片</dt><dd>{{ request.images.length }} 张 @for (image of request.images; track $index) { <span class="image-size">{{ image.width && image.height ? image.width + ' × ' + image.height : '尺寸未知' }}</span> }</dd>
|
||||
<dt>Token 用量</dt><dd>{{ request.usage ? '输入 ' + request.usage.prompt_tokens + ' / 输出 ' + request.usage.completion_tokens + ' / 合计 ' + request.usage.total_tokens : '未知(服务未报告)' }}</dd>
|
||||
@if (request.error) { <dt>失败原因</dt><dd class="error">{{ request.error_type }} · {{ request.error }} · 阶段 {{ phase(request.failure_phase || '') }}</dd> }
|
||||
</dl>
|
||||
</details>
|
||||
}
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
:host { display: block; }
|
||||
.model-requests { margin: 10px 0 16px; color: #475569; font-size: 12px; }
|
||||
.request-live { padding: 12px 14px; border: 1px solid #dbeafe; border-radius: 10px; background: #f8fbff; margin-bottom: 8px; }
|
||||
.request-heading { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
|
||||
strong { color: #334155; font-size: 13px; }
|
||||
.model { color: #64748b; overflow-wrap: anywhere; }
|
||||
.elapsed { margin-left: auto; color: #1d4ed8; font-variant-numeric: tabular-nums; }
|
||||
.status-dot { height: 7px; width: 7px; border-radius: 50%; background: #64748b; flex: 0 0 auto; }
|
||||
.waiting { background: #3b82f6; animation: pulse 1.5s ease-in-out infinite; }
|
||||
.retrying { border-color: #fde68a; background: #fffbeb; .elapsed { color: #92400e; } .waiting { background: #d97706; } }
|
||||
.stale { border-color: #cbd5e1; background: #f8fafc; }
|
||||
p { margin: 6px 0 0; line-height: 1.7; }
|
||||
.error { color: #b45309; overflow-wrap: anywhere; }
|
||||
summary { cursor: pointer; padding: 5px 0; line-height: 1.7; } summary:hover { color: #2563eb; }
|
||||
summary:focus-visible, a:focus-visible { outline: 2px solid #3b82f6; outline-offset: 2px; }
|
||||
.diagnostics-header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px; margin: 8px 0; color: #64748b; }
|
||||
a { color: #2563eb; text-decoration: none; border: 1px solid #dbeafe; border-radius: 6px; padding: 4px 9px; }
|
||||
.request-history { max-height: 360px; overflow-y: auto; }
|
||||
.request-detail { border-top: 1px solid #e2e8f0; padding: 4px 0; }
|
||||
dl { display: grid; grid-template-columns: 110px minmax(0, 1fr); gap: 7px 10px; margin: 8px 0 12px; line-height: 1.6; }
|
||||
dt { color: #64748b; } dd { margin: 0; overflow-wrap: anywhere; font-variant-numeric: tabular-nums; }
|
||||
.image-size, .fallback { display: inline-block; padding: 0 5px; margin: 2px 3px; background: #f1f5f9; border-radius: 4px; }
|
||||
.notice { color: #64748b; }
|
||||
@keyframes pulse { 50% { opacity: .35; } }
|
||||
@media (prefers-reduced-motion: reduce) { .waiting { animation: none; } }
|
||||
@media (max-width: 480px) { dl { grid-template-columns: 90px minmax(0, 1fr); } .elapsed { margin-left: 15px; } }
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
import { fakeAsync, TestBed, tick } from '@angular/core/testing';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { LlmRequestStatusComponent, ModelRequest, requestHint } from './llm-request-status.component';
|
||||
|
||||
const waiting: ModelRequest = {
|
||||
request_id: 'call-1', attempt_id: 'attempt-1', model: 'gpt-6-luna', role: 'FlashRunner',
|
||||
is_fallback: false, phase: 'waiting_output', active: true, attempt: 1, max_retries: 5,
|
||||
timeout_seconds: 300, elapsed_seconds: 42, started_at: Date.now() / 1000 - 42,
|
||||
http_status: 200, usage: null, images: []
|
||||
};
|
||||
|
||||
describe('模型请求诊断', () => {
|
||||
beforeEach(() => TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] }));
|
||||
afterEach(() => TestBed.inject(HttpTestingController).verify());
|
||||
|
||||
it('收到200仍显示等待,未知用量不当作0;完成后取消轮询', fakeAsync(() => {
|
||||
const fixture = TestBed.createComponent(LlmRequestStatusComponent);
|
||||
fixture.componentRef.setInput('sessionId', 'task-one');
|
||||
fixture.componentRef.setInput('running', true);
|
||||
fixture.detectChanges();
|
||||
const http = TestBed.inject(HttpTestingController);
|
||||
http.expectOne('/api/sessions/task-one/request-diagnostics').flush({ requests: [waiting] });
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('.request-live').textContent).toContain('等待模型输出');
|
||||
expect(fixture.nativeElement.textContent).toContain('未知(服务未报告)');
|
||||
expect(fixture.nativeElement.querySelector('details').open).toBeFalse();
|
||||
tick(3000);
|
||||
http.expectOne('/api/sessions/task-one/request-diagnostics').flush({ requests: [{ ...waiting, active: false, phase: 'completed', ended_at: Date.now() / 1000 }] });
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('.request-live')).toBeNull();
|
||||
fixture.destroy(); tick(5000);
|
||||
http.expectNone('/api/sessions/task-one/request-diagnostics');
|
||||
}));
|
||||
|
||||
it('切换会话取消旧请求,历史失败不会显示正在重试', () => {
|
||||
const fixture = TestBed.createComponent(LlmRequestStatusComponent);
|
||||
const http = TestBed.inject(HttpTestingController);
|
||||
fixture.componentRef.setInput('sessionId', 'old'); fixture.detectChanges();
|
||||
const old = http.expectOne('/api/sessions/old/request-diagnostics');
|
||||
fixture.componentRef.setInput('sessionId', 'new'); fixture.detectChanges();
|
||||
expect(old.cancelled).toBeTrue();
|
||||
http.expectOne('/api/sessions/new/request-diagnostics').flush({ requests: [{ ...waiting, phase: 'retry_wait' }] });
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('.request-live')).toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('a').href).toContain('/new/request-diagnostics?download=true');
|
||||
fixture.destroy();
|
||||
});
|
||||
|
||||
it('诊断接口失败时明确提示状态未知', () => {
|
||||
const fixture = TestBed.createComponent(LlmRequestStatusComponent);
|
||||
fixture.componentRef.setInput('sessionId', 'task'); fixture.detectChanges();
|
||||
TestBed.inject(HttpTestingController).expectOne('/api/sessions/task/request-diagnostics').flush({}, { status: 503, statusText: 'Unavailable' });
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.textContent).toContain('不能据此判断任务是否卡住');
|
||||
fixture.destroy();
|
||||
});
|
||||
|
||||
it('重试倒计时到0后不谎称已发送请求', () => {
|
||||
expect(requestHint({ ...waiting, phase: 'retry_wait', retry_at: 110 }, 105)).toContain('5 秒后进行第 1/5 次重试');
|
||||
expect(requestHint({ ...waiting, phase: 'retry_wait', retry_at: 110 }, 111)).toContain('等待下一次请求状态');
|
||||
});
|
||||
|
||||
it('持续生成工具参数时展示工具名和字符数,不误报工具已执行', () => {
|
||||
const fixture = TestBed.createComponent(LlmRequestStatusComponent);
|
||||
fixture.componentRef.setInput('sessionId', 'tool-task');
|
||||
fixture.componentRef.setInput('running', true);
|
||||
fixture.detectChanges();
|
||||
TestBed.inject(HttpTestingController).expectOne('/api/sessions/tool-task/request-diagnostics').flush({ requests: [{
|
||||
...waiting, phase: 'generating_tool_arguments', tool_argument_chars: 15000,
|
||||
tools: [{ name: 'click', argument_chars: 15000 }], output_chunks: 200
|
||||
}] });
|
||||
fixture.detectChanges();
|
||||
const text = fixture.nativeElement.querySelector('.request-live').textContent;
|
||||
expect(text).toContain('正在生成工具参数');
|
||||
expect(text).toContain('click');
|
||||
expect(text).toContain((15000).toLocaleString());
|
||||
expect(text).toContain('本次工具尚未执行');
|
||||
fixture.destroy();
|
||||
});
|
||||
});
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, effect, inject, input, output, signal } from '@angular/core';
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Subscription } from 'rxjs';
|
||||
|
||||
export interface ModelRequest {
|
||||
request_id: string; attempt_id: string; model?: string; reasoning_effort?: string;
|
||||
role: string; is_fallback: boolean; phase: string; active: boolean;
|
||||
attempt: number; max_retries: number; timeout_seconds: number; elapsed_seconds: number;
|
||||
started_at: number; headers_at?: number; first_output_at?: number; last_output_at?: number;
|
||||
ended_at?: number; retry_at?: number; http_status?: number; server_request_id?: string;
|
||||
error?: string; error_type?: string; failure_phase?: string;
|
||||
output_chunks?: number; tool_argument_chars?: number; max_output_tokens?: number | null;
|
||||
tools?: { name: string | null; argument_chars: number }[];
|
||||
usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number } | null;
|
||||
images: { width: number | null; height: number | null }[];
|
||||
}
|
||||
|
||||
export function requestPhase(request: ModelRequest | string): string {
|
||||
const phase = typeof request === 'string' ? request : request.phase;
|
||||
return ({ waiting_response: '等待模型响应', waiting_output: '等待模型输出', receiving_output: '正在接收模型输出', generating_tool_arguments: '正在生成工具参数', retry_wait: '等待重试', completed: '请求完成', failed: '请求失败', cancelled: '请求已取消' } as Record<string, string>)[phase] || '请求状态待核实';
|
||||
}
|
||||
|
||||
export function requestHint(request: ModelRequest, now: number): string {
|
||||
if (request.phase === 'retry_wait') {
|
||||
const remaining = Math.max(0, Math.ceil((request.retry_at || now) - now));
|
||||
return remaining ? `${remaining} 秒后进行第 ${request.attempt}/${request.max_retries} 次重试。` : '重试等待已结束,正在等待下一次请求状态。';
|
||||
}
|
||||
if (request.phase === 'waiting_output') return '已收到服务响应,尚未收到有效模型输出。';
|
||||
if (request.phase === 'generating_tool_arguments') {
|
||||
const names = (request.tools || []).map(tool => tool.name).filter(Boolean).join('、');
|
||||
return `${names ? names + ' · ' : ''}已接收 ${(request.tool_argument_chars || 0).toLocaleString()} 个参数字符,最近输出在 ${Math.max(0, Math.floor(now - (request.last_output_at || now)))} 秒前;本次工具尚未执行。`;
|
||||
}
|
||||
if (request.phase === 'receiving_output') return `最近一次输出在 ${Math.max(0, Math.floor(now - (request.last_output_at || now)))} 秒前;完整响应仍在接收中。`;
|
||||
return '正在等待服务响应;尚不能确认模型是否开始生成。';
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-llm-request-status', standalone: true, imports: [DatePipe],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
templateUrl: './llm-request-status.component.html', styleUrl: './llm-request-status.component.scss'
|
||||
})
|
||||
export class LlmRequestStatusComponent {
|
||||
private http = inject(HttpClient);
|
||||
sessionId = input<string | null>(null);
|
||||
running = input(false);
|
||||
activeChange = output<boolean>();
|
||||
requests = signal<ModelRequest[]>([]);
|
||||
now = signal(Date.now() / 1000);
|
||||
unavailable = signal(false);
|
||||
loading = signal(false);
|
||||
active = computed(() => this.running() ? this.requests().filter(r => r.active) : []);
|
||||
downloadUrl = computed(() => `/api/sessions/${encodeURIComponent(this.sessionId() || '')}/request-diagnostics?download=true`);
|
||||
phase = requestPhase;
|
||||
hint = requestHint;
|
||||
|
||||
constructor() {
|
||||
effect(() => this.activeChange.emit(this.active().length > 0));
|
||||
effect(onCleanup => {
|
||||
const id = this.sessionId();
|
||||
const running = this.running();
|
||||
this.requests.set([]);
|
||||
this.unavailable.set(false);
|
||||
this.loading.set(!!id);
|
||||
if (!id) return;
|
||||
let stopped = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let subscription: Subscription | undefined;
|
||||
const schedule = () => { if (running && !stopped) timer = setTimeout(load, 3000); };
|
||||
const load = () => {
|
||||
subscription = this.http.get<{ requests: ModelRequest[] }>(`/api/sessions/${encodeURIComponent(id)}/request-diagnostics`).subscribe({
|
||||
next: data => {
|
||||
this.requests.set(data.requests || []);
|
||||
this.unavailable.set(false);
|
||||
this.loading.set(false);
|
||||
schedule();
|
||||
},
|
||||
error: () => { this.unavailable.set(true); this.loading.set(false); schedule(); }
|
||||
});
|
||||
};
|
||||
load();
|
||||
const clock = running ? setInterval(() => this.now.set(Date.now() / 1000), 1000) : undefined;
|
||||
onCleanup(() => { stopped = true; clearTimeout(timer); clearInterval(clock); subscription?.unsubscribe(); });
|
||||
});
|
||||
}
|
||||
|
||||
elapsed(request: ModelRequest): number {
|
||||
return Math.max(0, Math.floor(request.ended_at ? request.elapsed_seconds : this.now() - request.started_at));
|
||||
}
|
||||
|
||||
role(request: ModelRequest): string {
|
||||
return ({ FlashRunner: '任务执行', operator: '任务执行', planner: '规划', checker: '结果检查', outputter: '报告整理', step_summarizer: '步骤整理' } as Record<string, string>)[request.role] || request.role;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { Clipboard } from '@angular/cdk/clipboard';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { fakeAsync, TestBed, tick } from '@angular/core/testing';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { AgentService } from '../../services/agent.service';
|
||||
@@ -99,6 +99,127 @@ describe('历史任务检索', () => {
|
||||
fixture.destroy();
|
||||
});
|
||||
|
||||
it('定时刷新不插入加载行或禁用翻页,并保留滚动、焦点、折叠和选中项', fakeAsync(() => {
|
||||
const fixture = TestBed.createComponent(TaskHistoryComponent);
|
||||
const now = Date.now()/1000;
|
||||
const items = Array.from({length:8}, (_,i) => ({session_id:`task-${i}`, name:`历史用例 ${i}`, status:'completed', start_time:now}));
|
||||
items.push({session_id:'older', name:'前一天的用例', status:'completed', start_time:now-86400});
|
||||
TestBed.inject(AgentService).currentSessionId.set('task-0');
|
||||
http.expectOne(r => r.url === '/api/automation/history').flush({total:60,items});
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
const host: HTMLElement = fixture.nativeElement;
|
||||
host.style.cssText = 'display:block;width:360px;height:400px;overflow:auto';
|
||||
const group = host.querySelectorAll('details')[1];
|
||||
group.open = false;
|
||||
group.dispatchEvent(new Event('toggle'));
|
||||
const search = host.querySelector('input')!;
|
||||
search.focus({preventScroll:true});
|
||||
host.scrollTop = 100;
|
||||
const row = host.querySelector('.row.selected')!;
|
||||
const before = {height:host.scrollHeight, scroll:host.scrollTop, top:row.getBoundingClientRect().top};
|
||||
expect(before.scroll).toBeGreaterThan(0);
|
||||
|
||||
tick(15000);
|
||||
const refresh = http.expectOne(r => r.url === '/api/automation/history');
|
||||
fixture.detectChanges();
|
||||
expect(host.textContent).not.toContain('正在查询');
|
||||
expect(host.querySelectorAll<HTMLButtonElement>('.pager button')[1].disabled).toBeFalse();
|
||||
expect(host.scrollHeight).toBe(before.height);
|
||||
expect(host.scrollTop).toBe(before.scroll);
|
||||
expect(row.getBoundingClientRect().top).toBe(before.top);
|
||||
refresh.flush({total:60,items:items.map(item => ({...item,name:item.name.replace('历史','更新')}))});
|
||||
fixture.detectChanges();
|
||||
expect(host.querySelector('.row.selected')).toBe(row);
|
||||
expect(row.textContent).toContain('更新用例 0');
|
||||
expect(host.querySelectorAll('details')[1]).toBe(group);
|
||||
expect(group.open).toBeFalse();
|
||||
expect(document.activeElement).toBe(search);
|
||||
expect(host.scrollTop).toBe(before.scroll);
|
||||
fixture.destroy();
|
||||
}));
|
||||
|
||||
it('空历史在后台刷新期间保持空状态,收到新任务后正常更新', fakeAsync(() => {
|
||||
const fixture = TestBed.createComponent(TaskHistoryComponent);
|
||||
http.expectOne(r => r.url === '/api/automation/history').flush({total:0,items:[]});
|
||||
fixture.detectChanges();
|
||||
const empty = fixture.nativeElement.querySelector('p');
|
||||
tick(15000);
|
||||
const refresh = http.expectOne(r => r.url === '/api/automation/history');
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('p')).toBe(empty);
|
||||
expect(fixture.nativeElement.textContent).not.toContain('正在查询');
|
||||
refresh.flush({total:1,items:[{session_id:'new',name:'新完成任务',status:'completed',start_time:Date.now()/1000}]});
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.textContent).toContain('新完成任务');
|
||||
expect(fixture.nativeElement.textContent).not.toContain('没有符合条件');
|
||||
fixture.destroy();
|
||||
}));
|
||||
|
||||
it('慢刷新不叠加请求,搜索优先且旧刷新不能覆盖搜索结果', fakeAsync(() => {
|
||||
const fixture = TestBed.createComponent(TaskHistoryComponent);
|
||||
const first = http.expectOne(r => r.url === '/api/automation/history');
|
||||
tick(15000);
|
||||
http.expectNone(r => r.url === '/api/automation/history');
|
||||
first.flush({total:0,items:[]});
|
||||
tick(15000);
|
||||
const refresh = http.expectOne(r => r.url === '/api/automation/history');
|
||||
tick(15000);
|
||||
http.expectNone(r => r.url === '/api/automation/history');
|
||||
fixture.componentInstance.query = '设置';
|
||||
fixture.componentInstance.search();
|
||||
tick(250);
|
||||
const search = http.expectOne(r => r.params.get('q') === '设置');
|
||||
search.flush({total:1,items:[{session_id:'settings',name:'设置',status:'completed',start_time:100}]});
|
||||
refresh.flush({total:99,items:[]});
|
||||
expect(fixture.componentInstance.total()).toBe(1);
|
||||
expect(fixture.componentInstance.groups()[0].rows[0].session_id).toBe('settings');
|
||||
fixture.destroy();
|
||||
}));
|
||||
|
||||
it('轮询和结果通知不抢先提交仍在输入的搜索条件', fakeAsync(() => {
|
||||
const fixture = TestBed.createComponent(TaskHistoryComponent);
|
||||
http.expectOne(r => r.url === '/api/automation/history').flush({total:0,items:[]});
|
||||
fixture.detectChanges();
|
||||
tick(14900);
|
||||
fixture.componentInstance.query = '新条件';
|
||||
fixture.componentInstance.search();
|
||||
TestBed.inject(AgentService).resultRevision.update(value => value+1);
|
||||
fixture.detectChanges();
|
||||
tick(100);
|
||||
http.expectNone(r => r.url === '/api/automation/history');
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('h3 [role="status"]').textContent).toContain('正在查询');
|
||||
tick(150);
|
||||
http.expectOne(r => r.params.get('q') === '新条件').flush({total:0,items:[]});
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('h3 [role="status"]').textContent).toBe('');
|
||||
fixture.destroy();
|
||||
}));
|
||||
|
||||
it('刷新失败保留列表和错误提示,重试期间不反复移除提示', fakeAsync(() => {
|
||||
const fixture = TestBed.createComponent(TaskHistoryComponent);
|
||||
const items = [{session_id:'kept',name:'已有历史',status:'completed',start_time:100}];
|
||||
http.expectOne(r => r.url === '/api/automation/history').flush({total:1,items});
|
||||
fixture.detectChanges();
|
||||
const row = fixture.nativeElement.querySelector('.row');
|
||||
tick(15000);
|
||||
http.expectOne(r => r.url === '/api/automation/history').flush({}, {status:503,statusText:'Unavailable'});
|
||||
fixture.detectChanges();
|
||||
const alert = fixture.nativeElement.querySelector('[role="alert"]');
|
||||
expect(alert.textContent).toContain('历史查询失败');
|
||||
tick(15000);
|
||||
const retry = http.expectOne(r => r.url === '/api/automation/history');
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('[role="alert"]')).toBe(alert);
|
||||
expect(fixture.nativeElement.querySelector('.row')).toBe(row);
|
||||
retry.flush({total:1,items});
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('[role="alert"]')).toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('.row')).toBe(row);
|
||||
fixture.destroy();
|
||||
}));
|
||||
|
||||
it('显示明确的结果、完成时间与总耗时,标题悬停显示完整提示词', () => {
|
||||
const fixture = TestBed.createComponent(TaskHistoryComponent);
|
||||
http.expectOne(r => r.url === '/api/automation/history').flush({total: 1, items: [
|
||||
|
||||
@@ -9,52 +9,56 @@ import { AgentService } from '../../services/agent.service';
|
||||
|
||||
@Component({
|
||||
selector:'app-task-history', standalone:true, imports:[CommonModule,FormsModule,TaskIdCopyComponent,TestResultBadgeComponent,TaskModeBadgeComponent],
|
||||
template:`<section aria-label="历史任务"><h3>历史任务 <small>{{total()}} 条</small></h3>
|
||||
template:`<section aria-label="历史任务"><h3>历史任务 <small>{{total()}} 条</small><small class="query-status" role="status">{{loading() ? '正在查询…' : ''}}</small></h3>
|
||||
<input aria-label="搜索历史任务" placeholder="搜索名称、目标、任务编号或标签" [(ngModel)]="query" (ngModelChange)="search()">
|
||||
<div class="filters"><label>起始日期<input type="date" [(ngModel)]="start" (ngModelChange)="search()"></label><label>结束日期<input type="date" [(ngModel)]="end" (ngModelChange)="search()"></label><label>状态<select [(ngModel)]="status" (ngModelChange)="search()"><option value="">全部状态</option><option value="completed">执行完成</option><option value="failed">失败</option><option value="cancelled">已取消</option><option value="interrupted">中断</option><option value="skipped">跳过</option></select></label></div>
|
||||
@if(error()){<p role="alert">{{error()}}</p>}
|
||||
@if(loading()){<small>正在查询…</small>}
|
||||
@for(group of groups(); track group.key){<details [open]="!collapsed.has(group.key)" (toggle)="toggle(group.key,$event)"><summary>{{group.title}} <small>本页 {{group.rows.length}} 条</small></summary>
|
||||
@for(row of group.rows; track row.session_id){<div class="history-item" [class]="'history-item status-'+row.status"><button class="row" [class.selected]="agent.currentSessionId() === row.session_id" (click)="agent.selectSession(row.session_id,true)" [title]="row.initial_goal || ''"><strong>{{row.name || row.initial_goal}}</strong><span class="history-meta"><app-task-mode-badge [session]="row" />@if(row.exploration_kind === "validation"){<span>用例复跑 · v{{row.case_version}}</span>}<b class="history-status">{{label(row.status)}}</b><app-test-result-badge [result]="row.test_result" [executionStatus]="row.status" [compact]="true" /> <span>{{row.end_time ? '完成于 '+(row.end_time*1000 | date:'MM-dd HH:mm:ss') : '开始于 '+(row.start_time*1000 | date:'MM-dd HH:mm:ss')}}</span><span>总耗时 {{duration(row.start_time,row.end_time)}}</span></span>@if(row.error){<span class="history-error" [title]="row.error">{{row.error}}</span>}</button><app-task-id-copy [taskId]="row.session_id" />@if(row.exploration_kind !== "exploration"){<button class="delete-history" (click)="remove(row.session_id)" aria-label="删除此历史任务"><span class="material-symbols-outlined">delete</span></button>}</div>}
|
||||
</details>}
|
||||
@if(!loading() && !total()){<p>没有符合条件的历史任务。</p>}
|
||||
<div class="pager"><button [disabled]="page === 1 || loading()" (click)="page=page-1;load()">上一页</button><span>{{page}} / {{pages()}}</span><button [disabled]="page >= pages() || loading()" (click)="page=page+1;load()">下一页</button></div>
|
||||
</section>`,
|
||||
styles:[`:host{display:block;font-size:12px;color:inherit}h3{font-size:14px;margin:14px 0 8px}small{opacity:.6}input,select,button{font:inherit;color:inherit;border:1px solid #64748b55;border-radius:6px;padding:8px;background:transparent;box-sizing:border-box}input{width:100%}select option{background:white;color:#1e293b}.filters{display:flex;gap:6px;flex-wrap:wrap;margin:8px 0}.filters label{flex:1;min-width:110px}.filters input,.filters select{display:block;width:100%}summary{padding:10px 0;cursor:pointer}.row{display:flex;flex-direction:column;gap:6px;width:100%;text-align:left;cursor:pointer;margin:5px 0;overflow-wrap:anywhere}.row strong{font-weight:600}.row.selected{border-color:#60a5fa;background:#3b82f615}.history-item{position:relative;border:1px solid #cbd5e1;border-left:3px solid #94a3b8;border-radius:8px;margin:6px 0;padding:8px;background:#fff}.history-item.status-completed,.history-item.status-success{border-left-color:#16a34a}.history-item.status-failed{border-left-color:#dc2626;background:#fffafa}.history-item.status-cancelled,.history-item.status-interrupted{border-left-color:#d97706;background:#fffdf8}.history-item.status-skipped{border-left-color:#64748b}.history-item:has(.row.selected){border-color:#60a5fa;background:#eff6ff}.history-item .row{border:0;padding:0;margin:0 0 4px}.history-item .row.selected{background:transparent}.history-item .row{padding-right:35px}.history-meta{display:flex;gap:5px;align-items:center;flex-wrap:wrap;color:#475569;font-size:11px}.history-status{display:inline-flex;align-items:center;box-sizing:border-box;height:20px;line-height:14px;border-radius:5px;padding:0 6px;background:#f1f5f9;color:#334155}.status-completed .history-status,.status-success .history-status{background:#dcfce7;color:#166534}.status-failed .history-status{background:#fee2e2;color:#991b1b}.status-cancelled .history-status,.status-interrupted .history-status{background:#fef3c7;color:#92400e}.history-error{font-size:11px;color:#b91c1c;line-height:1.4}.delete-history{position:absolute;right:5px;top:8px;border:0;padding:3px;color:#64748b;cursor:pointer;font-size:12px}.delete-history .material-symbols-outlined{font-size:16px}.delete-history:hover{color:#b91c1c}.pager{display:flex;align-items:center;justify-content:space-between;gap:8px;margin:12px 0}button:disabled{opacity:.4}p{opacity:.7}`]
|
||||
styles:[`:host{display:block;font-size:12px;color:inherit}h3{display:flex;align-items:center;gap:6px;font-size:14px;margin:14px 0 8px}.query-status{margin-left:auto;font-size:11px;font-weight:400;white-space:nowrap}small{opacity:.6}input,select,button{font:inherit;color:inherit;border:1px solid #64748b55;border-radius:6px;padding:8px;background:transparent;box-sizing:border-box}input{width:100%}select option{background:white;color:#1e293b}.filters{display:flex;gap:6px;flex-wrap:wrap;margin:8px 0}.filters label{flex:1;min-width:110px}.filters input,.filters select{display:block;width:100%}summary{padding:10px 0;cursor:pointer}.row{display:flex;flex-direction:column;gap:6px;width:100%;text-align:left;cursor:pointer;margin:5px 0;overflow-wrap:anywhere}.row strong{font-weight:600}.row.selected{border-color:#60a5fa;background:#3b82f615}.history-item{position:relative;border:1px solid #cbd5e1;border-left:3px solid #94a3b8;border-radius:8px;margin:6px 0;padding:8px;background:#fff}.history-item.status-completed,.history-item.status-success{border-left-color:#16a34a}.history-item.status-failed{border-left-color:#dc2626;background:#fffafa}.history-item.status-cancelled,.history-item.status-interrupted{border-left-color:#d97706;background:#fffdf8}.history-item.status-skipped{border-left-color:#64748b}.history-item:has(.row.selected){border-color:#60a5fa;background:#eff6ff}.history-item .row{border:0;padding:0;margin:0 0 4px}.history-item .row.selected{background:transparent}.history-item .row{padding-right:35px}.history-meta{display:flex;gap:5px;align-items:center;flex-wrap:wrap;color:#475569;font-size:11px}.history-status{display:inline-flex;align-items:center;box-sizing:border-box;height:20px;line-height:14px;border-radius:5px;padding:0 6px;background:#f1f5f9;color:#334155}.status-completed .history-status,.status-success .history-status{background:#dcfce7;color:#166534}.status-failed .history-status{background:#fee2e2;color:#991b1b}.status-cancelled .history-status,.status-interrupted .history-status{background:#fef3c7;color:#92400e}.history-error{font-size:11px;color:#b91c1c;line-height:1.4}.delete-history{position:absolute;right:5px;top:8px;border:0;padding:3px;color:#64748b;cursor:pointer;font-size:12px}.delete-history .material-symbols-outlined{font-size:16px}.delete-history:hover{color:#b91c1c}.pager{display:flex;align-items:center;justify-content:space-between;gap:8px;margin:12px 0}button:disabled{opacity:.4}p{opacity:.7}`]
|
||||
})
|
||||
export class TaskHistoryComponent {
|
||||
agent=inject(AgentService); private http=inject(HttpClient); query='';start='';end='';status='';page=1;
|
||||
total=signal(0);groups=signal<any[]>([]);loading=signal(false);error=signal('');collapsed=new Set<string>();
|
||||
private revision=0;private debounce: ReturnType<typeof setTimeout>|undefined;
|
||||
private revision=0;private pendingRequest=0;private debounce: ReturnType<typeof setTimeout>|undefined;
|
||||
constructor(){
|
||||
let filter=this.agent.batchFilter(), resultRevision=this.agent.resultRevision();
|
||||
this.load();
|
||||
effect(()=>{
|
||||
const nextFilter=this.agent.batchFilter(), nextRevision=this.agent.resultRevision();
|
||||
if(nextFilter!==filter || nextRevision!==resultRevision){
|
||||
if(nextFilter!==filter){this.page=1;clearTimeout(this.debounce);}
|
||||
filter=nextFilter;resultRevision=nextRevision;untracked(()=>this.load());
|
||||
const filterChanged=nextFilter!==filter;
|
||||
if(filterChanged)this.page=1;
|
||||
filter=nextFilter;resultRevision=nextRevision;untracked(()=>this.load(!filterChanged));
|
||||
}
|
||||
});
|
||||
const timer=setInterval(()=>this.load(),15000);inject(DestroyRef).onDestroy(()=>{this.revision++;clearInterval(timer);clearTimeout(this.debounce);});
|
||||
const timer=setInterval(()=>this.load(true),15000);inject(DestroyRef).onDestroy(()=>{this.revision++;clearInterval(timer);clearTimeout(this.debounce);});
|
||||
}
|
||||
remove(id:string){if(!confirm("确定删除此历史任务?此操作无法撤销。"))return;this.agent.deleteSession(id).subscribe({next:()=>this.load(),error:()=>this.error.set("删除失败,请稍后重试")});}
|
||||
pages(){return Math.max(1,Math.ceil(this.total()/30));}
|
||||
label(s:string){return ({completed:'执行完成',success:'执行完成',failed:'失败',cancelled:'已终止',interrupted:'中断',skipped:'跳过'} as Record<string,string>)[s] || s;}
|
||||
duration(start:number,end?:number){if(!start||!end)return '—';const seconds=Math.max(0,Math.floor(end-start));const hours=Math.floor(seconds/3600);const minutes=Math.floor((seconds%3600)/60);return hours?`${hours}时${minutes}分`:minutes?`${minutes}分${seconds%60}秒`:`${seconds}秒`;}
|
||||
toggle(key:string,event:Event){if((event.target as HTMLDetailsElement).open)this.collapsed.delete(key);else this.collapsed.add(key);}
|
||||
search(){this.page=1;this.revision++;clearTimeout(this.debounce);this.debounce=setTimeout(()=>this.load(),250);}
|
||||
load(){
|
||||
const revision=++this.revision;this.loading.set(true);this.error.set('');
|
||||
search(){this.page=1;this.revision++;this.loading.set(true);clearTimeout(this.debounce);this.debounce=setTimeout(()=>this.load(),250);}
|
||||
load(background=false){
|
||||
// Polling must not displace rows, toggle pagination, or supersede a user's query.
|
||||
if(background && (this.pendingRequest || this.debounce!==undefined))return;
|
||||
if(!background){clearTimeout(this.debounce);this.debounce=undefined;this.loading.set(true);}
|
||||
const revision=++this.revision;this.pendingRequest=revision;
|
||||
let params=new HttpParams().set('q',this.query).set('page',this.page).set('status',this.status);
|
||||
if(this.agent.batchFilter())params=params.set('batch_id',this.agent.batchFilter());
|
||||
if(this.start)params=params.set('start',new Date(this.start+'T00:00:00').getTime()/1000);
|
||||
if(this.end){const d=new Date(this.end+'T00:00:00');d.setDate(d.getDate()+1);params=params.set('end',d.getTime()/1000);}
|
||||
this.http.get<any>('/api/automation/history',{params}).subscribe({next:r=>{
|
||||
if(revision!==this.revision)return;
|
||||
this.pendingRequest=0;this.error.set('');
|
||||
this.total.set(r.total);const map=new Map<string,any>();const today=new Date();const yesterday=new Date();yesterday.setDate(today.getDate()-1);
|
||||
for(const row of r.items){const d=new Date(row.start_time*1000);const key=d.toDateString();if(!map.has(key))map.set(key,{key,title:key===today.toDateString()?'今天':key===yesterday.toDateString()?'昨天':d.toLocaleDateString('zh-CN'),rows:[]});map.get(key).rows.push(row);}
|
||||
this.groups.set([...map.values()]);this.loading.set(false);
|
||||
},error:e=>{if(revision===this.revision){this.loading.set(false);this.error.set(e.error?.detail || '历史查询失败,请稍后重试');}}});
|
||||
},error:e=>{if(revision===this.revision){this.pendingRequest=0;this.loading.set(false);this.error.set(e.error?.detail || '历史查询失败,请稍后重试');}}});
|
||||
}
|
||||
}
|
||||
@@ -906,6 +906,9 @@ export class AgentService {
|
||||
try {
|
||||
const parsedData = JSON.parse(event.data);
|
||||
const evtSessionId = parsedData?.session_id;
|
||||
// Request snapshots have a separate compact panel. They must not end
|
||||
// a live text stream or appear as duplicate tool cards.
|
||||
if (eventType === 'trace_recorded' && parsedData?.type === 'llm_diagnostic') return;
|
||||
|
||||
// Preserve ordering: buffered stream text must land before any
|
||||
// non-stream event is appended to the session logs.
|
||||
@@ -1029,9 +1032,9 @@ export class AgentService {
|
||||
const attempt = Number(parsedData.attempt || 0);
|
||||
const max = Number(parsedData.max_retries || 0);
|
||||
const delay = Number(parsedData.delay || 0);
|
||||
const attemptText = attempt && max ? `(第 ${attempt}/${max})` : '';
|
||||
const attemptText = attempt && max ? `(第 ${attempt}/${max} 次重试)` : '';
|
||||
const delayText = delay > 0 ? `;重试等待 ${delay.toFixed(2).replace(/\.00$/, '')}s` : '';
|
||||
this.retryMessage.set(`AI 服务暂时繁忙${attemptText}${delayText}...`);
|
||||
this.retryMessage.set(`${parsedData.error || '模型请求未完成'}${attemptText}${delayText}...`);
|
||||
if (curId) {
|
||||
this.appendLiveLLMRetryTrace(parsedData, curId);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user