feat(frontend): restore session progress sidebar
This commit is contained in:
@ -5,6 +5,7 @@ import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useSta
|
|||||||
import { Brain, Plus, Send, Trash2, X } from 'lucide-react';
|
import { Brain, Plus, Send, Trash2, X } from 'lucide-react';
|
||||||
|
|
||||||
import { ChatWorkbench } from '@/components/chat-workbench/ChatWorkbench';
|
import { ChatWorkbench } from '@/components/chat-workbench/ChatWorkbench';
|
||||||
|
import { CurrentSessionProgressSidebar } from '@/components/chat-workbench/CurrentSessionProgressSidebar';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import {
|
import {
|
||||||
archiveSession,
|
archiveSession,
|
||||||
@ -18,9 +19,10 @@ import {
|
|||||||
uploadFile,
|
uploadFile,
|
||||||
wsManager,
|
wsManager,
|
||||||
} from '@/lib/api';
|
} from '@/lib/api';
|
||||||
import { mergeServerWithPendingUsers } from '@/lib/chat-messages';
|
import { mergeServerWithPendingUsers, shouldMergePendingUsers } from '@/lib/chat-messages';
|
||||||
import { pickAppText } from '@/lib/i18n/core';
|
import { pickAppText } from '@/lib/i18n/core';
|
||||||
import { useAppI18n } from '@/lib/i18n/provider';
|
import { useAppI18n } from '@/lib/i18n/provider';
|
||||||
|
import { buildSessionProgressView } from '@/lib/session-progress';
|
||||||
import { useChatStore } from '@/lib/store';
|
import { useChatStore } from '@/lib/store';
|
||||||
import type { ActiveTask, ChatMessage, FileAttachment, SessionUpdatedEvent, WsEvent } from '@/types';
|
import type { ActiveTask, ChatMessage, FileAttachment, SessionUpdatedEvent, WsEvent } from '@/types';
|
||||||
|
|
||||||
@ -60,6 +62,9 @@ export default function ChatPage() {
|
|||||||
setSessionId,
|
setSessionId,
|
||||||
setMessages,
|
setMessages,
|
||||||
addMessage,
|
addMessage,
|
||||||
|
setInputDraft,
|
||||||
|
getInputDraft,
|
||||||
|
clearInputDraft,
|
||||||
setIsLoading,
|
setIsLoading,
|
||||||
clearMessages,
|
clearMessages,
|
||||||
setIsThinking,
|
setIsThinking,
|
||||||
@ -68,7 +73,7 @@ export default function ChatPage() {
|
|||||||
updateMessageFeedback,
|
updateMessageFeedback,
|
||||||
} = useChatStore();
|
} = useChatStore();
|
||||||
|
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState(() => useChatStore.getState().getInputDraft(useChatStore.getState().sessionId));
|
||||||
const [thinkingModeEnabled, setThinkingModeEnabled] = useState(loadThinkingModePreference);
|
const [thinkingModeEnabled, setThinkingModeEnabled] = useState(loadThinkingModePreference);
|
||||||
const [pendingFiles, setPendingFiles] = useState<Array<{ file: File; id?: string; progress: number; error?: string }>>([]);
|
const [pendingFiles, setPendingFiles] = useState<Array<{ file: File; id?: string; progress: number; error?: string }>>([]);
|
||||||
const [activeTask, setActiveTask] = useState<ActiveTask | null>(null);
|
const [activeTask, setActiveTask] = useState<ActiveTask | null>(null);
|
||||||
@ -105,6 +110,17 @@ export default function ChatPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const selectedSessionRunId = selectedRunId && sessionRunIds.has(selectedRunId) ? selectedRunId : null;
|
const selectedSessionRunId = selectedRunId && sessionRunIds.has(selectedRunId) ? selectedRunId : null;
|
||||||
|
const sessionProgressView = useMemo(
|
||||||
|
() =>
|
||||||
|
buildSessionProgressView({
|
||||||
|
sessionId,
|
||||||
|
processRuns,
|
||||||
|
processEvents,
|
||||||
|
processArtifacts,
|
||||||
|
locale,
|
||||||
|
}),
|
||||||
|
[locale, processArtifacts, processEvents, processRuns, sessionId]
|
||||||
|
);
|
||||||
|
|
||||||
const loadSessions = useCallback(async () => {
|
const loadSessions = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@ -141,7 +157,8 @@ export default function ChatPage() {
|
|||||||
setSessionProcess(key, process);
|
setSessionProcess(key, process);
|
||||||
}
|
}
|
||||||
void loadActiveTask(key);
|
void loadActiveTask(key);
|
||||||
const nextMessages = waitingForReply
|
const shouldMergePending = shouldMergePendingUsers(detail.messages, localSnapshot, waitingForReply);
|
||||||
|
const nextMessages = shouldMergePending
|
||||||
? mergeServerWithPendingUsers(detail.messages, localSnapshot)
|
? mergeServerWithPendingUsers(detail.messages, localSnapshot)
|
||||||
: detail.messages;
|
: detail.messages;
|
||||||
setMessages(nextMessages);
|
setMessages(nextMessages);
|
||||||
@ -167,6 +184,7 @@ export default function ChatPage() {
|
|||||||
}
|
}
|
||||||
setActiveTask(null);
|
setActiveTask(null);
|
||||||
setRevisionTargetRunId(null);
|
setRevisionTargetRunId(null);
|
||||||
|
setInput(useChatStore.getState().getInputDraft(sessionId));
|
||||||
void loadSessionMessages(sessionId);
|
void loadSessionMessages(sessionId);
|
||||||
void loadActiveTask(sessionId);
|
void loadActiveTask(sessionId);
|
||||||
}, [clearMessages, loadActiveTask, loadSessionMessages, sessionId, setIsLoading, setIsThinking]);
|
}, [clearMessages, loadActiveTask, loadSessionMessages, sessionId, setIsLoading, setIsThinking]);
|
||||||
@ -308,6 +326,7 @@ export default function ChatPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setInput('');
|
setInput('');
|
||||||
|
clearInputDraft(sessionId);
|
||||||
setPendingFiles([]);
|
setPendingFiles([]);
|
||||||
addMessage({
|
addMessage({
|
||||||
role: 'user',
|
role: 'user',
|
||||||
@ -372,7 +391,7 @@ export default function ChatPage() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [addMessage, input, isLoading, loadActiveTask, loadSessionMessages, loadSessions, locale, pendingFiles, revisionTargetRunId, sessionId, setIsLoading, setIsThinking, setSessionProcess, thinkingModeEnabled, updateMessageFeedback]);
|
}, [addMessage, clearInputDraft, input, isLoading, loadActiveTask, loadSessionMessages, loadSessions, locale, pendingFiles, revisionTargetRunId, sessionId, setIsLoading, setIsThinking, setSessionProcess, thinkingModeEnabled, updateMessageFeedback]);
|
||||||
|
|
||||||
const handleFeedback = useCallback(async (runId: string, feedbackType: 'satisfied' | 'revise' | 'abandon', comment?: string) => {
|
const handleFeedback = useCallback(async (runId: string, feedbackType: 'satisfied' | 'revise' | 'abandon', comment?: string) => {
|
||||||
updateMessageFeedback(runId, feedbackType);
|
updateMessageFeedback(runId, feedbackType);
|
||||||
@ -433,6 +452,8 @@ export default function ChatPage() {
|
|||||||
setSelectedRunId(null);
|
setSelectedRunId(null);
|
||||||
setActiveTask(null);
|
setActiveTask(null);
|
||||||
setRevisionTargetRunId(null);
|
setRevisionTargetRunId(null);
|
||||||
|
clearInputDraft(id);
|
||||||
|
setInput('');
|
||||||
clearMessages();
|
clearMessages();
|
||||||
useChatStore.getState().resetProcessState();
|
useChatStore.getState().resetProcessState();
|
||||||
try {
|
try {
|
||||||
@ -452,6 +473,8 @@ export default function ChatPage() {
|
|||||||
setSessionId('web:default');
|
setSessionId('web:default');
|
||||||
setActiveTask(null);
|
setActiveTask(null);
|
||||||
setRevisionTargetRunId(null);
|
setRevisionTargetRunId(null);
|
||||||
|
clearInputDraft(key);
|
||||||
|
setInput(useChatStore.getState().getInputDraft('web:default'));
|
||||||
clearMessages();
|
clearMessages();
|
||||||
useChatStore.getState().resetProcessState();
|
useChatStore.getState().resetProcessState();
|
||||||
}
|
}
|
||||||
@ -469,6 +492,7 @@ export default function ChatPage() {
|
|||||||
setSelectedRunId(null);
|
setSelectedRunId(null);
|
||||||
setActiveTask(null);
|
setActiveTask(null);
|
||||||
setRevisionTargetRunId(null);
|
setRevisionTargetRunId(null);
|
||||||
|
setInput(useChatStore.getState().getInputDraft(key));
|
||||||
setSessionId(key);
|
setSessionId(key);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -619,7 +643,10 @@ export default function ChatPage() {
|
|||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={(e) => {
|
||||||
|
setInput(e.target.value);
|
||||||
|
setInputDraft(sessionId, e.target.value);
|
||||||
|
}}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder={
|
placeholder={
|
||||||
revisionTargetRunId
|
revisionTargetRunId
|
||||||
@ -678,6 +705,8 @@ export default function ChatPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{sessionProgressView && <CurrentSessionProgressSidebar view={sessionProgressView} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,324 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle2,
|
||||||
|
Circle,
|
||||||
|
FileJson,
|
||||||
|
FileOutput,
|
||||||
|
FileText,
|
||||||
|
Image as ImageIcon,
|
||||||
|
Link2,
|
||||||
|
ListChecks,
|
||||||
|
Loader2,
|
||||||
|
PanelRightOpen,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { appStatusLabel } from '@/lib/i18n/common';
|
||||||
|
import { pickAppText } from '@/lib/i18n/core';
|
||||||
|
import { useAppI18n } from '@/lib/i18n/provider';
|
||||||
|
import type {
|
||||||
|
SessionProgressArtifactView,
|
||||||
|
SessionProgressStepView,
|
||||||
|
SessionProgressView,
|
||||||
|
} from '@/lib/session-progress';
|
||||||
|
import type { ProcessArtifact, ProcessRunStatus } from '@/types';
|
||||||
|
|
||||||
|
function formatShortTime(value: string, locale: 'zh-CN' | 'en-US') {
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return value;
|
||||||
|
return new Intl.DateTimeFormat(locale, {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusTone(status: ProcessRunStatus) {
|
||||||
|
if (status === 'done') return 'text-[#2F8D50] bg-[#E3F1E7] border-[#B8D9C2]';
|
||||||
|
if (status === 'running') return 'text-[#2F6FCA] bg-[#E7EEF9] border-[#B8CBE8]';
|
||||||
|
if (status === 'error') return 'text-[#8A3A2D] bg-[#F0E5E1] border-[#D9BDB4]';
|
||||||
|
if (status === 'cancelled') return 'text-[#6A5E58] bg-[#ECE8E5] border-[#D8D2CE]';
|
||||||
|
return 'text-[#6A5E58] bg-[#F0ECE9] border-[#D8D2CE]';
|
||||||
|
}
|
||||||
|
|
||||||
|
function StepMarker({ step, index }: { step: SessionProgressStepView; index: number }) {
|
||||||
|
if (step.status === 'done') {
|
||||||
|
return (
|
||||||
|
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-[#2F8D50] text-white">
|
||||||
|
<CheckCircle2 className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (step.status === 'running') {
|
||||||
|
return (
|
||||||
|
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-[#2F6FCA] text-[11px] font-semibold text-white">
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (step.status === 'error') {
|
||||||
|
return (
|
||||||
|
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-[#8A3A2D] text-white">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-[#D8D2CE] text-[#6A5E58]">
|
||||||
|
<Circle className="h-3.5 w-3.5" />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function artifactIcon(type: ProcessArtifact['artifact_type']) {
|
||||||
|
if (type === 'json') return <FileJson className="h-4 w-4" />;
|
||||||
|
if (type === 'image') return <ImageIcon className="h-4 w-4" />;
|
||||||
|
if (type === 'link') return <Link2 className="h-4 w-4" />;
|
||||||
|
if (type === 'markdown' || type === 'text') return <FileText className="h-4 w-4" />;
|
||||||
|
return <FileOutput className="h-4 w-4" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProgressHeader({ view }: { view: SessionProgressView }) {
|
||||||
|
const { locale } = useAppI18n();
|
||||||
|
const percent = view.progress.percent;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="rounded-lg border border-[#ECE7E3] bg-white px-4 py-4 shadow-[0_8px_24px_rgba(0,0,0,0.04)]">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[#E3F1E7] text-[#2F8D50]">
|
||||||
|
<ListChecks className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="line-clamp-2 text-sm font-semibold text-foreground">{view.title}</div>
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<span className={`rounded-full border px-2 py-0.5 text-[11px] font-medium ${statusTone(view.status)}`}>
|
||||||
|
{appStatusLabel(view.status, locale)}
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] text-muted-foreground">
|
||||||
|
{pickAppText(locale, '更新于', 'Updated')} {formatShortTime(view.updatedAt, locale)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<div className="mb-2 flex items-center justify-between gap-3 text-xs text-muted-foreground">
|
||||||
|
<span>{view.progress.label}</span>
|
||||||
|
{percent !== null && <span className="font-medium text-foreground">{percent}%</span>}
|
||||||
|
</div>
|
||||||
|
<div className="h-2 overflow-hidden rounded-full bg-[#ECE8E5]">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-[#5DB56F] transition-all"
|
||||||
|
style={{ width: `${percent ?? 0}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{view.summary && (
|
||||||
|
<p className="mt-3 line-clamp-3 text-xs leading-5 text-muted-foreground">{view.summary}</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StepList({ steps }: { steps: SessionProgressStepView[] }) {
|
||||||
|
const { locale } = useAppI18n();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="rounded-lg border border-[#ECE7E3] bg-white px-4 py-4">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-semibold text-foreground">
|
||||||
|
{pickAppText(locale, '运行步骤', 'Run Steps')}
|
||||||
|
</h3>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{pickAppText(locale, `${steps.length} 步`, `${steps.length} steps`)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-0">
|
||||||
|
{steps.map((step, index) => (
|
||||||
|
<div key={step.runId} className="grid grid-cols-[24px_1fr] gap-3">
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<StepMarker step={step} index={index} />
|
||||||
|
{index < steps.length - 1 && <span className="mt-2 h-full min-h-8 w-px bg-[#E6E1DE]" />}
|
||||||
|
</div>
|
||||||
|
<div className="pb-5">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="line-clamp-2 text-sm font-medium text-foreground">
|
||||||
|
{index + 1}. {step.title}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-[11px] text-muted-foreground">
|
||||||
|
{step.actorName} · {formatShortTime(step.updatedAt, locale)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className={`shrink-0 rounded-full border px-2 py-0.5 text-[11px] ${statusTone(step.status)}`}>
|
||||||
|
{appStatusLabel(step.status, locale)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{step.description && (
|
||||||
|
<p className="mt-2 line-clamp-3 text-xs leading-5 text-muted-foreground">
|
||||||
|
{step.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{step.status === 'running' && (
|
||||||
|
<div className="mt-2 flex items-center gap-1.5 text-[11px] text-[#2F6FCA]">
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
<span>{pickAppText(locale, '正在处理', 'In progress')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ArtifactRow({ artifact }: { artifact: SessionProgressArtifactView }) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={artifact.url || undefined}
|
||||||
|
target={artifact.url ? '_blank' : undefined}
|
||||||
|
rel={artifact.url ? 'noreferrer' : undefined}
|
||||||
|
className="block rounded-lg border border-[#ECE7E3] bg-[#FDFDFC] px-3 py-3 transition-colors hover:bg-[#F7F6F5]"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[#ECE8E5] text-[#5F5550]">
|
||||||
|
{artifactIcon(artifact.type)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate text-sm font-medium text-foreground">{artifact.title}</div>
|
||||||
|
<div className="mt-1 text-[11px] text-muted-foreground">
|
||||||
|
{artifact.actorName} · {artifact.typeLabel}
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 line-clamp-2 text-xs leading-5 text-muted-foreground">{artifact.preview}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ArtifactSection({ view }: { view: SessionProgressView }) {
|
||||||
|
const { locale } = useAppI18n();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="rounded-lg border border-[#ECE7E3] bg-white px-4 py-4">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-semibold text-foreground">
|
||||||
|
{pickAppText(locale, '生成内容', 'Generated Content')}
|
||||||
|
</h3>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{pickAppText(locale, `${view.artifacts.length} 个`, `${view.artifacts.length} items`)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{view.artifactTypeSummaries.length > 0 ? (
|
||||||
|
<div className="mb-3 flex flex-wrap gap-2">
|
||||||
|
{view.artifactTypeSummaries.map((item) => (
|
||||||
|
<span
|
||||||
|
key={item.type}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-full border border-[#E6E1DE] bg-[#F7F6F5] px-2.5 py-1 text-xs text-[#4F4642]"
|
||||||
|
>
|
||||||
|
{artifactIcon(item.type)}
|
||||||
|
<span>{item.label}</span>
|
||||||
|
<span className="font-semibold">{item.count}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="mb-3 text-xs text-muted-foreground">
|
||||||
|
{pickAppText(locale, '暂时还没有生成内容。', 'No generated content yet.')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{view.artifacts.map((artifact) => (
|
||||||
|
<ArtifactRow key={artifact.artifactId} artifact={artifact} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProgressPanel({
|
||||||
|
view,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
view: SessionProgressView;
|
||||||
|
onClose?: () => void;
|
||||||
|
}) {
|
||||||
|
const { locale } = useAppI18n();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col bg-[#FBFAF9]">
|
||||||
|
<div className="flex h-16 shrink-0 items-center justify-between border-b border-[#E6E1DE] px-5">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-semibold text-foreground">
|
||||||
|
{pickAppText(locale, '当前会话的运行进度', 'Current Session Progress')}
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{pickAppText(locale, '任务列表会自动刷新', 'Task updates refresh automatically')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{onClose && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-full p-2 text-muted-foreground transition-colors hover:bg-[#ECE8E5] hover:text-foreground"
|
||||||
|
aria-label={pickAppText(locale, '关闭进度面板', 'Close progress panel')}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollArea className="min-h-0 flex-1 px-4 py-4">
|
||||||
|
<div className="space-y-4 pb-6">
|
||||||
|
<ProgressHeader view={view} />
|
||||||
|
<StepList steps={view.steps} />
|
||||||
|
<ArtifactSection view={view} />
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CurrentSessionProgressSidebar({ view }: { view: SessionProgressView }) {
|
||||||
|
const { locale } = useAppI18n();
|
||||||
|
const [mobileOpen, setMobileOpen] = React.useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<aside className="hidden h-full w-[380px] shrink-0 border-l border-[#E6E1DE] xl:flex">
|
||||||
|
<ProgressPanel view={view} />
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMobileOpen(true)}
|
||||||
|
className="fixed right-3 top-24 z-40 flex h-11 w-11 items-center justify-center rounded-full border border-[#E6E1DE] bg-white text-[#342E2B] shadow-[0_8px_22px_rgba(0,0,0,0.16)] transition-colors hover:bg-[#F7F6F5] xl:hidden"
|
||||||
|
aria-label={pickAppText(locale, '查看当前会话运行进度', 'View current session progress')}
|
||||||
|
>
|
||||||
|
<PanelRightOpen className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{mobileOpen && (
|
||||||
|
<div className="fixed inset-0 z-50 xl:hidden">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute inset-0 bg-black/30"
|
||||||
|
onClick={() => setMobileOpen(false)}
|
||||||
|
aria-label={pickAppText(locale, '关闭进度面板', 'Close progress panel')}
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-y-0 right-0 w-[min(92vw,390px)] border-l border-[#E6E1DE] shadow-2xl">
|
||||||
|
<ProgressPanel view={view} onClose={() => setMobileOpen(false)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { getTaskCardMessageIndexes, mergeServerWithPendingUsers } from '@/lib/chat-messages';
|
import { getTaskCardMessageIndexes, mergeServerWithPendingUsers, shouldMergePendingUsers } from '@/lib/chat-messages';
|
||||||
import type { ChatMessage } from '@/types';
|
import type { ChatMessage } from '@/types';
|
||||||
|
|
||||||
describe('chat message helpers', () => {
|
describe('chat message helpers', () => {
|
||||||
@ -46,6 +46,26 @@ describe('chat message helpers', () => {
|
|||||||
expect(mergeServerWithPendingUsers(serverMessages, localMessages)).toEqual(serverMessages);
|
expect(mergeServerWithPendingUsers(serverMessages, localMessages)).toEqual(serverMessages);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('merges pending user messages when local state has an unpersisted trailing user turn', () => {
|
||||||
|
const serverMessages: ChatMessage[] = [
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'Earlier answer',
|
||||||
|
timestamp: '2026-05-21T08:00:00.000Z',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const localMessages: ChatMessage[] = [
|
||||||
|
...serverMessages,
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: 'Do this long task',
|
||||||
|
timestamp: '2026-05-21T08:01:00.000Z',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(shouldMergePendingUsers(serverMessages, localMessages, false)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('shows a task card only on the latest assistant message for the same task', () => {
|
it('shows a task card only on the latest assistant message for the same task', () => {
|
||||||
const messages: ChatMessage[] = [
|
const messages: ChatMessage[] = [
|
||||||
{
|
{
|
||||||
|
|||||||
@ -30,6 +30,42 @@ export function mergeServerWithPendingUsers(serverMessages: ChatMessage[], local
|
|||||||
return [...serverMessages, ...pendingUsers];
|
return [...serverMessages, ...pendingUsers];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function shouldMergePendingUsers(
|
||||||
|
serverMessages: ChatMessage[],
|
||||||
|
localMessages: ChatMessage[],
|
||||||
|
waitingForReply: boolean
|
||||||
|
): boolean {
|
||||||
|
if (waitingForReply) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastLocal = localMessages[localMessages.length - 1];
|
||||||
|
if (lastLocal?.role !== 'user') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
for (const message of serverMessages) {
|
||||||
|
const key = messageFingerprint(message);
|
||||||
|
counts.set(key, (counts.get(key) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const message of localMessages) {
|
||||||
|
if (message.role !== 'user') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const key = messageFingerprint(message);
|
||||||
|
const count = counts.get(key) ?? 0;
|
||||||
|
if (count > 0) {
|
||||||
|
counts.set(key, count - 1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
export function getTaskCardMessageIndexes(messages: ChatMessage[]): Set<number> {
|
export function getTaskCardMessageIndexes(messages: ChatMessage[]): Set<number> {
|
||||||
const latestByTask = new Map<string, number>();
|
const latestByTask = new Map<string, number>();
|
||||||
|
|
||||||
|
|||||||
201
app-instance/frontend/lib/session-progress.test.ts
Normal file
201
app-instance/frontend/lib/session-progress.test.ts
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { buildSessionProgressView } from '@/lib/session-progress';
|
||||||
|
import type { ProcessArtifact, ProcessEvent, ProcessRun } from '@/types';
|
||||||
|
|
||||||
|
describe('session progress view builder', () => {
|
||||||
|
it('selects the latest active root run for the current session and builds its run tree', () => {
|
||||||
|
const processRuns: ProcessRun[] = [
|
||||||
|
{
|
||||||
|
run_id: 'old-root',
|
||||||
|
parent_run_id: null,
|
||||||
|
session_id: 'web:current',
|
||||||
|
actor_type: 'agent',
|
||||||
|
actor_id: 'main',
|
||||||
|
actor_name: 'Main Agent',
|
||||||
|
title: '旧任务',
|
||||||
|
status: 'done',
|
||||||
|
started_at: '2026-05-22T08:00:00.000Z',
|
||||||
|
finished_at: '2026-05-22T08:05:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
run_id: 'latest-root',
|
||||||
|
parent_run_id: null,
|
||||||
|
session_id: 'web:current',
|
||||||
|
actor_type: 'agent',
|
||||||
|
actor_id: 'main',
|
||||||
|
actor_name: 'Main Agent',
|
||||||
|
title: '销售数据分析报告生成',
|
||||||
|
status: 'running',
|
||||||
|
started_at: '2026-05-22T09:00:00.000Z',
|
||||||
|
metadata: {
|
||||||
|
step_index: 3,
|
||||||
|
step_total: 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
run_id: 'collect-data',
|
||||||
|
parent_run_id: 'latest-root',
|
||||||
|
session_id: 'web:current',
|
||||||
|
actor_type: 'agent',
|
||||||
|
actor_id: 'collector',
|
||||||
|
actor_name: 'Data Agent',
|
||||||
|
title: '收集销售数据',
|
||||||
|
status: 'done',
|
||||||
|
started_at: '2026-05-22T09:01:00.000Z',
|
||||||
|
finished_at: '2026-05-22T09:03:00.000Z',
|
||||||
|
summary: '已获取 Q1 销售数据',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
run_id: 'clean-data',
|
||||||
|
parent_run_id: 'latest-root',
|
||||||
|
session_id: 'web:current',
|
||||||
|
actor_type: 'agent',
|
||||||
|
actor_id: 'cleaner',
|
||||||
|
actor_name: 'Cleaning Agent',
|
||||||
|
title: '数据清洗与预处理',
|
||||||
|
status: 'running',
|
||||||
|
started_at: '2026-05-22T09:04:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
run_id: 'other-root',
|
||||||
|
parent_run_id: null,
|
||||||
|
session_id: 'web:other',
|
||||||
|
actor_type: 'agent',
|
||||||
|
actor_id: 'main',
|
||||||
|
actor_name: 'Main Agent',
|
||||||
|
title: '其他会话任务',
|
||||||
|
status: 'running',
|
||||||
|
started_at: '2026-05-22T10:00:00.000Z',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const processEvents: ProcessEvent[] = [
|
||||||
|
{
|
||||||
|
event_id: 'evt-clean',
|
||||||
|
run_id: 'clean-data',
|
||||||
|
parent_run_id: 'latest-root',
|
||||||
|
kind: 'run_progress',
|
||||||
|
actor_type: 'agent',
|
||||||
|
actor_id: 'cleaner',
|
||||||
|
actor_name: 'Cleaning Agent',
|
||||||
|
text: '清洗缺失值、异常值,统一格式',
|
||||||
|
created_at: '2026-05-22T09:05:00.000Z',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const processArtifacts: ProcessArtifact[] = [
|
||||||
|
{
|
||||||
|
artifact_id: 'artifact-json',
|
||||||
|
run_id: 'collect-data',
|
||||||
|
actor_type: 'agent',
|
||||||
|
actor_id: 'collector',
|
||||||
|
actor_name: 'Data Agent',
|
||||||
|
title: '销售数据',
|
||||||
|
artifact_type: 'json',
|
||||||
|
data: { rows: 120 },
|
||||||
|
created_at: '2026-05-22T09:03:30.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
artifact_id: 'artifact-markdown',
|
||||||
|
run_id: 'clean-data',
|
||||||
|
actor_type: 'agent',
|
||||||
|
actor_id: 'cleaner',
|
||||||
|
actor_name: 'Cleaning Agent',
|
||||||
|
title: '清洗说明',
|
||||||
|
artifact_type: 'markdown',
|
||||||
|
content: '已完成数据标准化。',
|
||||||
|
created_at: '2026-05-22T09:05:30.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
artifact_id: 'artifact-other-session',
|
||||||
|
run_id: 'other-root',
|
||||||
|
actor_type: 'agent',
|
||||||
|
actor_id: 'main',
|
||||||
|
title: '其他会话产物',
|
||||||
|
artifact_type: 'text',
|
||||||
|
content: '不应出现',
|
||||||
|
created_at: '2026-05-22T10:01:00.000Z',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const view = buildSessionProgressView({
|
||||||
|
sessionId: 'web:current',
|
||||||
|
processRuns,
|
||||||
|
processEvents,
|
||||||
|
processArtifacts,
|
||||||
|
locale: 'zh-CN',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(view).not.toBeNull();
|
||||||
|
expect(view?.rootRunId).toBe('latest-root');
|
||||||
|
expect(view?.title).toBe('销售数据分析报告生成');
|
||||||
|
expect(view?.progress).toMatchObject({
|
||||||
|
value: 3,
|
||||||
|
max: 5,
|
||||||
|
percent: 60,
|
||||||
|
label: '运行中:3 / 5 步',
|
||||||
|
});
|
||||||
|
expect(view?.steps.map((step) => step.runId)).toEqual(['collect-data', 'clean-data', 'latest-root']);
|
||||||
|
expect(view?.steps.find((step) => step.runId === 'clean-data')?.description).toBe('清洗缺失值、异常值,统一格式');
|
||||||
|
expect(view?.artifactTypeSummaries).toEqual([
|
||||||
|
{ type: 'json', count: 1, label: 'JSON' },
|
||||||
|
{ type: 'markdown', count: 1, label: 'Markdown' },
|
||||||
|
]);
|
||||||
|
expect(view?.artifacts.map((artifact) => artifact.artifactId)).toEqual(['artifact-markdown', 'artifact-json']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to completed child run counts when no explicit progress metadata exists', () => {
|
||||||
|
const processRuns: ProcessRun[] = [
|
||||||
|
{
|
||||||
|
run_id: 'root',
|
||||||
|
parent_run_id: null,
|
||||||
|
session_id: 'web:current',
|
||||||
|
actor_type: 'agent',
|
||||||
|
actor_id: 'main',
|
||||||
|
actor_name: 'Main Agent',
|
||||||
|
title: '生成总结',
|
||||||
|
status: 'running',
|
||||||
|
started_at: '2026-05-22T09:00:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
run_id: 'done-child',
|
||||||
|
parent_run_id: 'root',
|
||||||
|
session_id: 'web:current',
|
||||||
|
actor_type: 'agent',
|
||||||
|
actor_id: 'writer',
|
||||||
|
actor_name: 'Writer',
|
||||||
|
title: '整理结果',
|
||||||
|
status: 'done',
|
||||||
|
started_at: '2026-05-22T09:01:00.000Z',
|
||||||
|
finished_at: '2026-05-22T09:02:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
run_id: 'running-child',
|
||||||
|
parent_run_id: 'root',
|
||||||
|
session_id: 'web:current',
|
||||||
|
actor_type: 'agent',
|
||||||
|
actor_id: 'reviewer',
|
||||||
|
actor_name: 'Reviewer',
|
||||||
|
title: '复核结果',
|
||||||
|
status: 'running',
|
||||||
|
started_at: '2026-05-22T09:03:00.000Z',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const view = buildSessionProgressView({
|
||||||
|
sessionId: 'web:current',
|
||||||
|
processRuns,
|
||||||
|
processEvents: [],
|
||||||
|
processArtifacts: [],
|
||||||
|
locale: 'zh-CN',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(view?.progress).toMatchObject({
|
||||||
|
value: 1,
|
||||||
|
max: 2,
|
||||||
|
percent: 50,
|
||||||
|
label: '已完成 1 / 2 步',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
392
app-instance/frontend/lib/session-progress.ts
Normal file
392
app-instance/frontend/lib/session-progress.ts
Normal file
@ -0,0 +1,392 @@
|
|||||||
|
import type { ProcessArtifact, ProcessEvent, ProcessRun, ProcessRunStatus } from '@/types';
|
||||||
|
import { getCurrentAppLocale, pickAppText, type AppLocale } from '@/lib/i18n/core';
|
||||||
|
|
||||||
|
const TERMINAL_STATUSES = new Set<ProcessRunStatus>(['done', 'error', 'cancelled']);
|
||||||
|
const ACTIVE_STATUSES = new Set<ProcessRunStatus>(['queued', 'running', 'waiting']);
|
||||||
|
const ARTIFACT_TYPE_ORDER: ProcessArtifact['artifact_type'][] = [
|
||||||
|
'text',
|
||||||
|
'json',
|
||||||
|
'file',
|
||||||
|
'image',
|
||||||
|
'link',
|
||||||
|
'markdown',
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface SessionProgressValueView {
|
||||||
|
label: string;
|
||||||
|
value: number | null;
|
||||||
|
max: number | null;
|
||||||
|
percent: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionProgressStepView {
|
||||||
|
runId: string;
|
||||||
|
title: string;
|
||||||
|
actorName: string;
|
||||||
|
status: ProcessRunStatus;
|
||||||
|
description: string | null;
|
||||||
|
startedAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
finishedAt: string | null;
|
||||||
|
artifactCount: number;
|
||||||
|
isRoot: boolean;
|
||||||
|
isCurrent: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionProgressArtifactView {
|
||||||
|
artifactId: string;
|
||||||
|
runId: string;
|
||||||
|
title: string;
|
||||||
|
type: ProcessArtifact['artifact_type'];
|
||||||
|
typeLabel: string;
|
||||||
|
actorName: string;
|
||||||
|
preview: string;
|
||||||
|
createdAt: string;
|
||||||
|
url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionProgressArtifactTypeSummary {
|
||||||
|
type: ProcessArtifact['artifact_type'];
|
||||||
|
count: number;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionProgressView {
|
||||||
|
rootRunId: string;
|
||||||
|
title: string;
|
||||||
|
status: ProcessRunStatus;
|
||||||
|
summary: string | null;
|
||||||
|
updatedAt: string;
|
||||||
|
progress: SessionProgressValueView;
|
||||||
|
steps: SessionProgressStepView[];
|
||||||
|
artifacts: SessionProgressArtifactView[];
|
||||||
|
artifactTypeSummaries: SessionProgressArtifactTypeSummary[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BuildSessionProgressInput = {
|
||||||
|
sessionId: string;
|
||||||
|
processRuns: ProcessRun[];
|
||||||
|
processEvents: ProcessEvent[];
|
||||||
|
processArtifacts: ProcessArtifact[];
|
||||||
|
locale?: AppLocale;
|
||||||
|
};
|
||||||
|
|
||||||
|
function toTime(value?: string | null): number | null {
|
||||||
|
if (!value) return null;
|
||||||
|
const parsed = new Date(value).getTime();
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function latestTimestamp(values: Array<string | null | undefined>): string | null {
|
||||||
|
let selected: string | null = null;
|
||||||
|
let selectedTime = -1;
|
||||||
|
for (const value of values) {
|
||||||
|
const time = toTime(value);
|
||||||
|
if (time === null || time <= selectedTime) continue;
|
||||||
|
selected = value ?? null;
|
||||||
|
selectedTime = time;
|
||||||
|
}
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareIsoDesc(a?: string | null, b?: string | null): number {
|
||||||
|
return (toTime(b) ?? 0) - (toTime(a) ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstNumber(metadata: Record<string, unknown> | undefined, keys: string[]): number | null {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = metadata?.[key];
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildChildrenMap(processRuns: ProcessRun[]): Map<string, ProcessRun[]> {
|
||||||
|
const map = new Map<string, ProcessRun[]>();
|
||||||
|
for (const run of processRuns) {
|
||||||
|
if (!run.parent_run_id) continue;
|
||||||
|
const children = map.get(run.parent_run_id);
|
||||||
|
if (children) {
|
||||||
|
children.push(run);
|
||||||
|
} else {
|
||||||
|
map.set(run.parent_run_id, [run]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectRunTree(rootRun: ProcessRun, childrenMap: Map<string, ProcessRun[]>): ProcessRun[] {
|
||||||
|
const collected: ProcessRun[] = [];
|
||||||
|
const stack = [rootRun];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const current = stack.pop();
|
||||||
|
if (!current || seen.has(current.run_id)) continue;
|
||||||
|
seen.add(current.run_id);
|
||||||
|
collected.push(current);
|
||||||
|
const children = childrenMap.get(current.run_id) ?? [];
|
||||||
|
for (let index = children.length - 1; index >= 0; index -= 1) {
|
||||||
|
stack.push(children[index]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return collected;
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupByRunId<T extends { run_id: string }>(items: T[]): Map<string, T[]> {
|
||||||
|
const map = new Map<string, T[]>();
|
||||||
|
for (const item of items) {
|
||||||
|
const existing = map.get(item.run_id);
|
||||||
|
if (existing) {
|
||||||
|
existing.push(item);
|
||||||
|
} else {
|
||||||
|
map.set(item.run_id, [item]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRunUpdatedAt(
|
||||||
|
run: ProcessRun,
|
||||||
|
eventsByRun: Map<string, ProcessEvent[]>,
|
||||||
|
artifactsByRun: Map<string, ProcessArtifact[]>,
|
||||||
|
): string {
|
||||||
|
return (
|
||||||
|
latestTimestamp([
|
||||||
|
run.finished_at,
|
||||||
|
run.started_at,
|
||||||
|
...(eventsByRun.get(run.run_id) ?? []).map((event) => event.created_at),
|
||||||
|
...(artifactsByRun.get(run.run_id) ?? []).map((artifact) => artifact.created_at),
|
||||||
|
]) ?? run.started_at
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTreeUpdatedAt(
|
||||||
|
runs: ProcessRun[],
|
||||||
|
eventsByRun: Map<string, ProcessEvent[]>,
|
||||||
|
artifactsByRun: Map<string, ProcessArtifact[]>,
|
||||||
|
): string {
|
||||||
|
return latestTimestamp(runs.map((run) => getRunUpdatedAt(run, eventsByRun, artifactsByRun))) ?? runs[0]?.started_at ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function latestEventText(events: ProcessEvent[]): string | null {
|
||||||
|
const event = [...events]
|
||||||
|
.filter((item) => item.text?.trim())
|
||||||
|
.sort((a, b) => compareIsoDesc(a.created_at, b.created_at))[0];
|
||||||
|
return event?.text?.trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function percent(value: number, max: number): number {
|
||||||
|
return Math.max(0, Math.min(100, Math.round((value / max) * 100)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function explicitProgress(
|
||||||
|
rootRun: ProcessRun,
|
||||||
|
treeEvents: ProcessEvent[],
|
||||||
|
locale: AppLocale,
|
||||||
|
): SessionProgressValueView | null {
|
||||||
|
const metadataSources = [
|
||||||
|
rootRun.metadata,
|
||||||
|
...[...treeEvents]
|
||||||
|
.sort((a, b) => compareIsoDesc(a.created_at, b.created_at))
|
||||||
|
.map((event) => event.metadata),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const metadata of metadataSources) {
|
||||||
|
const stepValue = firstNumber(metadata, ['step_index']);
|
||||||
|
const stepMax = firstNumber(metadata, ['step_total']);
|
||||||
|
if (stepValue !== null && stepMax !== null && stepMax > 0) {
|
||||||
|
const safeValue = Math.min(stepValue, stepMax);
|
||||||
|
return {
|
||||||
|
label: pickAppText(locale, `运行中:${safeValue} / ${stepMax} 步`, `Running: ${safeValue} / ${stepMax} steps`),
|
||||||
|
value: safeValue,
|
||||||
|
max: stepMax,
|
||||||
|
percent: percent(safeValue, stepMax),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const stageValue = firstNumber(metadata, ['stage_index', 'phase_index']);
|
||||||
|
const stageMax = firstNumber(metadata, ['stage_total', 'phase_total']);
|
||||||
|
if (stageValue !== null && stageMax !== null && stageMax > 0) {
|
||||||
|
const safeValue = Math.min(stageValue, stageMax);
|
||||||
|
return {
|
||||||
|
label: pickAppText(locale, `运行中:${safeValue} / ${stageMax} 阶段`, `Running: ${safeValue} / ${stageMax} stages`),
|
||||||
|
value: safeValue,
|
||||||
|
max: stageMax,
|
||||||
|
percent: percent(safeValue, stageMax),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackProgress(taskRuns: ProcessRun[], locale: AppLocale): SessionProgressValueView {
|
||||||
|
const childRuns = taskRuns.filter((run) => run.parent_run_id);
|
||||||
|
const runsForProgress = childRuns.length > 0 ? childRuns : taskRuns;
|
||||||
|
const doneRuns = runsForProgress.filter((run) => run.status === 'done').length;
|
||||||
|
const totalRuns = runsForProgress.length;
|
||||||
|
|
||||||
|
if (totalRuns > 0) {
|
||||||
|
return {
|
||||||
|
label: pickAppText(locale, `已完成 ${doneRuns} / ${totalRuns} 步`, `Completed ${doneRuns} / ${totalRuns} steps`),
|
||||||
|
value: doneRuns,
|
||||||
|
max: totalRuns,
|
||||||
|
percent: percent(doneRuns, totalRuns),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: pickAppText(locale, '等待任务数据', 'Waiting for task data'),
|
||||||
|
value: null,
|
||||||
|
max: null,
|
||||||
|
percent: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function artifactTypeLabel(type: ProcessArtifact['artifact_type'], locale: AppLocale): string {
|
||||||
|
if (type === 'text') return pickAppText(locale, '文本', 'Text');
|
||||||
|
if (type === 'json') return 'JSON';
|
||||||
|
if (type === 'file') return pickAppText(locale, '文件', 'File');
|
||||||
|
if (type === 'image') return pickAppText(locale, '图片', 'Image');
|
||||||
|
if (type === 'link') return pickAppText(locale, '链接', 'Link');
|
||||||
|
return 'Markdown';
|
||||||
|
}
|
||||||
|
|
||||||
|
function artifactPreview(artifact: ProcessArtifact, locale: AppLocale): string {
|
||||||
|
if (artifact.content?.trim()) {
|
||||||
|
return artifact.content.trim().replace(/\s+/g, ' ').slice(0, 120);
|
||||||
|
}
|
||||||
|
if (artifact.url?.trim()) return artifact.url.trim();
|
||||||
|
if (artifact.data !== undefined) {
|
||||||
|
return JSON.stringify(artifact.data).slice(0, 120);
|
||||||
|
}
|
||||||
|
return pickAppText(locale, '暂无预览', 'No preview');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildArtifactSummaries(
|
||||||
|
artifacts: ProcessArtifact[],
|
||||||
|
locale: AppLocale,
|
||||||
|
): SessionProgressArtifactTypeSummary[] {
|
||||||
|
const counts = new Map<ProcessArtifact['artifact_type'], number>();
|
||||||
|
for (const artifact of artifacts) {
|
||||||
|
counts.set(artifact.artifact_type, (counts.get(artifact.artifact_type) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
return ARTIFACT_TYPE_ORDER
|
||||||
|
.filter((type) => counts.has(type))
|
||||||
|
.map((type) => ({
|
||||||
|
type,
|
||||||
|
count: counts.get(type) ?? 0,
|
||||||
|
label: artifactTypeLabel(type, locale),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildArtifactViews(
|
||||||
|
artifacts: ProcessArtifact[],
|
||||||
|
locale: AppLocale,
|
||||||
|
): SessionProgressArtifactView[] {
|
||||||
|
return [...artifacts]
|
||||||
|
.sort((a, b) => compareIsoDesc(a.created_at, b.created_at))
|
||||||
|
.map((artifact) => ({
|
||||||
|
artifactId: artifact.artifact_id,
|
||||||
|
runId: artifact.run_id,
|
||||||
|
title: artifact.title,
|
||||||
|
type: artifact.artifact_type,
|
||||||
|
typeLabel: artifactTypeLabel(artifact.artifact_type, locale),
|
||||||
|
actorName: artifact.actor_name || artifact.actor_id,
|
||||||
|
preview: artifactPreview(artifact, locale),
|
||||||
|
createdAt: artifact.created_at,
|
||||||
|
url: artifact.url,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSteps(
|
||||||
|
rootRun: ProcessRun,
|
||||||
|
taskRuns: ProcessRun[],
|
||||||
|
eventsByRun: Map<string, ProcessEvent[]>,
|
||||||
|
artifactsByRun: Map<string, ProcessArtifact[]>,
|
||||||
|
): SessionProgressStepView[] {
|
||||||
|
return [...taskRuns]
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.run_id === rootRun.run_id) return 1;
|
||||||
|
if (b.run_id === rootRun.run_id) return -1;
|
||||||
|
return (toTime(a.started_at) ?? 0) - (toTime(b.started_at) ?? 0);
|
||||||
|
})
|
||||||
|
.map((run) => {
|
||||||
|
const runEvents = eventsByRun.get(run.run_id) ?? [];
|
||||||
|
const runArtifacts = artifactsByRun.get(run.run_id) ?? [];
|
||||||
|
return {
|
||||||
|
runId: run.run_id,
|
||||||
|
title: run.title,
|
||||||
|
actorName: run.actor_name,
|
||||||
|
status: run.status,
|
||||||
|
description: latestEventText(runEvents) || run.summary?.trim() || null,
|
||||||
|
startedAt: run.started_at,
|
||||||
|
updatedAt: getRunUpdatedAt(run, eventsByRun, artifactsByRun),
|
||||||
|
finishedAt: run.finished_at ?? null,
|
||||||
|
artifactCount: runArtifacts.length,
|
||||||
|
isRoot: run.run_id === rootRun.run_id,
|
||||||
|
isCurrent: !TERMINAL_STATUSES.has(run.status),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSessionProgressView({
|
||||||
|
sessionId,
|
||||||
|
processRuns,
|
||||||
|
processEvents,
|
||||||
|
processArtifacts,
|
||||||
|
locale = getCurrentAppLocale(),
|
||||||
|
}: BuildSessionProgressInput): SessionProgressView | null {
|
||||||
|
const sessionRuns = processRuns.filter((run) => run.session_id === sessionId);
|
||||||
|
const rootRuns = sessionRuns.filter((run) => !run.parent_run_id);
|
||||||
|
if (rootRuns.length === 0) return null;
|
||||||
|
|
||||||
|
const allChildrenMap = buildChildrenMap(processRuns);
|
||||||
|
const runTreeCache = new Map<string, ProcessRun[]>();
|
||||||
|
const treeForRoot = (root: ProcessRun) => {
|
||||||
|
const cached = runTreeCache.get(root.run_id);
|
||||||
|
if (cached) return cached;
|
||||||
|
const tree = collectRunTree(root, allChildrenMap).filter(
|
||||||
|
(run) => run.session_id === sessionId || run.run_id === root.run_id
|
||||||
|
);
|
||||||
|
runTreeCache.set(root.run_id, tree);
|
||||||
|
return tree;
|
||||||
|
};
|
||||||
|
|
||||||
|
const allEventsByRun = groupByRunId(processEvents);
|
||||||
|
const allArtifactsByRun = groupByRunId(processArtifacts);
|
||||||
|
const selectedRoot = [...rootRuns].sort((a, b) => {
|
||||||
|
const aActive = ACTIVE_STATUSES.has(a.status);
|
||||||
|
const bActive = ACTIVE_STATUSES.has(b.status);
|
||||||
|
if (aActive !== bActive) return aActive ? -1 : 1;
|
||||||
|
return compareIsoDesc(
|
||||||
|
getTreeUpdatedAt(treeForRoot(a), allEventsByRun, allArtifactsByRun),
|
||||||
|
getTreeUpdatedAt(treeForRoot(b), allEventsByRun, allArtifactsByRun)
|
||||||
|
);
|
||||||
|
})[0];
|
||||||
|
|
||||||
|
if (!selectedRoot) return null;
|
||||||
|
|
||||||
|
const taskRuns = treeForRoot(selectedRoot);
|
||||||
|
const taskRunIds = new Set(taskRuns.map((run) => run.run_id));
|
||||||
|
const taskEvents = processEvents.filter((event) => taskRunIds.has(event.run_id));
|
||||||
|
const taskArtifacts = processArtifacts.filter((artifact) => taskRunIds.has(artifact.run_id));
|
||||||
|
const eventsByRun = groupByRunId(taskEvents);
|
||||||
|
const artifactsByRun = groupByRunId(taskArtifacts);
|
||||||
|
const updatedAt = getTreeUpdatedAt(taskRuns, eventsByRun, artifactsByRun);
|
||||||
|
const progress = explicitProgress(selectedRoot, taskEvents, locale) ?? fallbackProgress(taskRuns, locale);
|
||||||
|
|
||||||
|
return {
|
||||||
|
rootRunId: selectedRoot.run_id,
|
||||||
|
title: selectedRoot.title,
|
||||||
|
status: selectedRoot.status,
|
||||||
|
summary: selectedRoot.summary?.trim() || latestEventText(eventsByRun.get(selectedRoot.run_id) ?? []) || null,
|
||||||
|
updatedAt,
|
||||||
|
progress,
|
||||||
|
steps: buildSteps(selectedRoot, taskRuns, eventsByRun, artifactsByRun),
|
||||||
|
artifacts: buildArtifactViews(taskArtifacts, locale),
|
||||||
|
artifactTypeSummaries: buildArtifactSummaries(taskArtifacts, locale),
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -6,6 +6,7 @@ describe('chat store process event ingestion', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
useChatStore.setState({
|
useChatStore.setState({
|
||||||
sessionId: 'web:alpha',
|
sessionId: 'web:alpha',
|
||||||
|
inputDrafts: {},
|
||||||
processRuns: [],
|
processRuns: [],
|
||||||
processEvents: [],
|
processEvents: [],
|
||||||
processArtifacts: [],
|
processArtifacts: [],
|
||||||
@ -18,6 +19,7 @@ describe('chat store process event ingestion', () => {
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
useChatStore.setState({
|
useChatStore.setState({
|
||||||
sessionId: 'web:default',
|
sessionId: 'web:default',
|
||||||
|
inputDrafts: {},
|
||||||
processRuns: [],
|
processRuns: [],
|
||||||
processEvents: [],
|
processEvents: [],
|
||||||
processArtifacts: [],
|
processArtifacts: [],
|
||||||
@ -49,4 +51,17 @@ describe('chat store process event ingestion', () => {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('stores input drafts per session', () => {
|
||||||
|
useChatStore.getState().setInputDraft('web:alpha', 'message for alpha');
|
||||||
|
useChatStore.getState().setInputDraft('web:beta', 'message for beta');
|
||||||
|
|
||||||
|
expect(useChatStore.getState().getInputDraft('web:alpha')).toBe('message for alpha');
|
||||||
|
expect(useChatStore.getState().getInputDraft('web:beta')).toBe('message for beta');
|
||||||
|
|
||||||
|
useChatStore.getState().clearInputDraft('web:alpha');
|
||||||
|
|
||||||
|
expect(useChatStore.getState().getInputDraft('web:alpha')).toBe('');
|
||||||
|
expect(useChatStore.getState().getInputDraft('web:beta')).toBe('message for beta');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -36,6 +36,7 @@ interface ChatStore {
|
|||||||
isAuthLoading: boolean;
|
isAuthLoading: boolean;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
messages: ChatMessage[];
|
messages: ChatMessage[];
|
||||||
|
inputDrafts: Record<string, string>;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
streamingContent: string;
|
streamingContent: string;
|
||||||
wsStatus: WsStatus;
|
wsStatus: WsStatus;
|
||||||
@ -56,6 +57,9 @@ interface ChatStore {
|
|||||||
setSessionId: (id: string) => void;
|
setSessionId: (id: string) => void;
|
||||||
setMessages: (msgs: ChatMessage[]) => void;
|
setMessages: (msgs: ChatMessage[]) => void;
|
||||||
addMessage: (msg: ChatMessage) => void;
|
addMessage: (msg: ChatMessage) => void;
|
||||||
|
setInputDraft: (sessionId: string, value: string) => void;
|
||||||
|
getInputDraft: (sessionId: string) => string;
|
||||||
|
clearInputDraft: (sessionId: string) => void;
|
||||||
updateMessageFeedback: (
|
updateMessageFeedback: (
|
||||||
runId: string,
|
runId: string,
|
||||||
feedbackState: ChatMessage['feedback_state'],
|
feedbackState: ChatMessage['feedback_state'],
|
||||||
@ -126,11 +130,12 @@ function createEventId(event: ProcessWsEvent): string {
|
|||||||
return `${event.type}:${event.run_id}:${event.created_at}:${suffix}`;
|
return `${event.type}:${event.run_id}:${event.created_at}:${suffix}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useChatStore = create<ChatStore>((set) => ({
|
export const useChatStore = create<ChatStore>((set, get) => ({
|
||||||
user: null,
|
user: null,
|
||||||
isAuthLoading: true,
|
isAuthLoading: true,
|
||||||
sessionId: getInitialSessionId(),
|
sessionId: getInitialSessionId(),
|
||||||
messages: [],
|
messages: [],
|
||||||
|
inputDrafts: {},
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
streamingContent: '',
|
streamingContent: '',
|
||||||
wsStatus: 'disconnected',
|
wsStatus: 'disconnected',
|
||||||
@ -155,6 +160,23 @@ export const useChatStore = create<ChatStore>((set) => ({
|
|||||||
},
|
},
|
||||||
setMessages: (msgs) => set({ messages: msgs }),
|
setMessages: (msgs) => set({ messages: msgs }),
|
||||||
addMessage: (msg) => set((s) => ({ messages: [...s.messages, msg] })),
|
addMessage: (msg) => set((s) => ({ messages: [...s.messages, msg] })),
|
||||||
|
setInputDraft: (sessionId, value) =>
|
||||||
|
set((state) => ({
|
||||||
|
inputDrafts: {
|
||||||
|
...state.inputDrafts,
|
||||||
|
[sessionId]: value,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
getInputDraft: (sessionId) => get().inputDrafts[sessionId] ?? '',
|
||||||
|
clearInputDraft: (sessionId) =>
|
||||||
|
set((state) => {
|
||||||
|
if (!(sessionId in state.inputDrafts)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const nextDrafts = { ...state.inputDrafts };
|
||||||
|
delete nextDrafts[sessionId];
|
||||||
|
return { inputDrafts: nextDrafts };
|
||||||
|
}),
|
||||||
updateMessageFeedback: (runId, feedbackState, error) =>
|
updateMessageFeedback: (runId, feedbackState, error) =>
|
||||||
set((s) => ({
|
set((s) => ({
|
||||||
messages: s.messages.map((message) =>
|
messages: s.messages.map((message) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user