3 Commits

Author SHA1 Message Date
030bce8a60 feat(litellm): 添加 reasoning_content 支持并强制禁用思考模式
- 在 LiteLLMProvider 中添加 "reasoning_content" 到允许的消息键集合中
- 修改 _apply_thinking_mode 方法以强制禁用思考模式,不再基于模型名称判断
- 总是设置 enable_thinking 为 False 并添加 thinking.type: disabled 配置
- 更新相关测试用例验证新的思考模式行为

fix(web): 修复非运行状态下的直接处理逻辑

- 创建 _run_web_direct 辅助函数来处理代理服务的直接提交/处理逻辑
- 当代理服务未运行时使用 process_direct 而不是 submit_direct
- 更新 REST 和 WebSocket 接口以使用新的处理逻辑
- 添加相应的单元测试验证非运行状态下使用直接处理

test(config): 添加代理配置重载功能的测试

- 添加 test_reload_agent_config_updates_booted_loop_config 测试函数
- 验证配置文件更新后代理循环能够正确加载新配置
- 测试模型、API 基础地址和 API 密钥的更新

chore(frontend): 默认禁用前端思考模式偏好

- 将前端思考模式存储的默认值从 true 改为 false
- 确保窗口未定义时返回 false 而不是 true
- 更新本地存储缺失时的默认行为为禁用思考模式
2026-05-22 17:43:21 +08:00
c671b66043 feat(frontend): restore session progress sidebar 2026-05-22 14:34:45 +08:00
e061961a79 merge agent team evidence validation work 2026-05-22 11:51:05 +08:00
13 changed files with 1239 additions and 21 deletions

View File

@ -23,7 +23,7 @@ except ModuleNotFoundError: # pragma: no cover
litellm = None # type: ignore[assignment] litellm = None # type: ignore[assignment]
acompletion = None # type: ignore[assignment] acompletion = None # type: ignore[assignment]
_ALLOWED_MSG_KEYS = frozenset({"role", "content", "tool_calls", "tool_call_id", "name"}) _ALLOWED_MSG_KEYS = frozenset({"role", "content", "tool_calls", "tool_call_id", "name", "reasoning_content"})
class LiteLLMProvider(LLMProvider): class LiteLLMProvider(LLMProvider):
@ -175,15 +175,11 @@ class LiteLLMProvider(LLMProvider):
kwargs["provider"] = provider_payload kwargs["provider"] = provider_payload
def _apply_thinking_mode(self, original_model: str, resolved_model: str, kwargs: dict[str, Any], enabled: bool | None) -> None: def _apply_thinking_mode(self, original_model: str, resolved_model: str, kwargs: dict[str, Any], enabled: bool | None) -> None:
if enabled is None:
return
model_key = f"{original_model} {resolved_model}".lower()
if "qwen" not in model_key:
return
extra_body = dict(kwargs.get("extra_body") or {}) extra_body = dict(kwargs.get("extra_body") or {})
chat_template_kwargs = dict(extra_body.get("chat_template_kwargs") or {}) chat_template_kwargs = dict(extra_body.get("chat_template_kwargs") or {})
chat_template_kwargs["enable_thinking"] = bool(enabled) chat_template_kwargs["enable_thinking"] = False
extra_body["chat_template_kwargs"] = chat_template_kwargs extra_body["chat_template_kwargs"] = chat_template_kwargs
extra_body["thinking"] = {"type": "disabled"}
kwargs["extra_body"] = extra_body kwargs["extra_body"] = extra_body
async def chat( async def chat(

View File

@ -1745,7 +1745,7 @@ def create_app(
} }
if payload.thinking_enabled is not None: if payload.thinking_enabled is not None:
direct_kwargs["thinking_enabled"] = payload.thinking_enabled direct_kwargs["thinking_enabled"] = payload.thinking_enabled
result = await agent_service.submit_direct(message, **direct_kwargs) result = await _run_web_direct(agent_service, message, **direct_kwargs)
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc
except RuntimeError as exc: except RuntimeError as exc:
@ -1855,7 +1855,7 @@ def create_app(
websocket_thinking_enabled = _bool_or_none(payload.get("thinking_enabled")) websocket_thinking_enabled = _bool_or_none(payload.get("thinking_enabled"))
if websocket_thinking_enabled is not None: if websocket_thinking_enabled is not None:
direct_kwargs["thinking_enabled"] = websocket_thinking_enabled direct_kwargs["thinking_enabled"] = websocket_thinking_enabled
result = await agent_service.submit_direct(content, **direct_kwargs) result = await _run_web_direct(agent_service, content, **direct_kwargs)
except Exception as exc: except Exception as exc:
await websocket.send_json( await websocket.send_json(
{ {
@ -1940,6 +1940,12 @@ def _session_detail(session_manager: Any, session_id: str, session: dict[str, An
} }
async def _run_web_direct(agent_service: AgentService, message: str, **kwargs: Any) -> Any:
if agent_service.is_running:
return await agent_service.submit_direct(message, **kwargs)
return await agent_service.process_direct(message, **kwargs)
def _create_skill_upload_draft(loaded: Any, filename: str, content: bytes) -> dict[str, Any]: def _create_skill_upload_draft(loaded: Any, filename: str, content: bytes) -> dict[str, Any]:
try: try:
archive = zipfile.ZipFile(io.BytesIO(content), "r") archive = zipfile.ZipFile(io.BytesIO(content), "r")

View File

@ -4,6 +4,8 @@ from beaver.engine import AgentLoop, EngineLoader
from beaver.engine.providers import make_provider_bundle from beaver.engine.providers import make_provider_bundle
from beaver.engine.providers.litellm import LiteLLMProvider from beaver.engine.providers.litellm import LiteLLMProvider
from beaver.foundation.config import load_config from beaver.foundation.config import load_config
from beaver.interfaces.web.app import _reload_agent_config
from beaver.services.agent_service import AgentService
def test_load_config_reads_current_instance_shape(tmp_path) -> None: def test_load_config_reads_current_instance_shape(tmp_path) -> None:
@ -124,6 +126,41 @@ def test_agent_loop_config_drives_provider_bundle(tmp_path) -> None:
loop.close() loop.close()
def test_reload_agent_config_updates_booted_loop_config(tmp_path) -> None:
workspace = tmp_path / "workspace"
config_path = tmp_path / "config.json"
config_path.write_text(
json.dumps(
{
"agents": {"defaults": {"workspace": str(workspace), "model": "old-model"}},
"providers": {"openai": {"apiKey": "sk-test", "apiBase": "https://old.example.com/v1"}},
}
),
encoding="utf-8",
)
service = AgentService(config_path=config_path)
loaded = service.create_loop().boot()
assert loaded.config.default_model == "old-model"
config_path.write_text(
json.dumps(
{
"agents": {"defaults": {"workspace": str(workspace), "model": "new-model"}},
"providers": {"openai": {"apiKey": "sk-test", "apiBase": "https://new.example.com/v1"}},
}
),
encoding="utf-8",
)
_reload_agent_config(service, config_path)
target = service.create_loop().boot().config.resolve_provider_target()
assert target["model"] == "new-model"
assert target["api_base"] == "https://new.example.com/v1"
assert target["api_key"] == "sk-test"
service.close()
def test_openai_compatible_qwen_config_keeps_openai_provider() -> None: def test_openai_compatible_qwen_config_keeps_openai_provider() -> None:
bundle = make_provider_bundle( bundle = make_provider_bundle(
model="qwen-plus", model="qwen-plus",

View File

@ -45,10 +45,13 @@ def test_qwen_thinking_mode_is_sent_as_chat_template_kwargs(monkeypatch: pytest.
) )
assert response.content == "可以" assert response.content == "可以"
assert captured["extra_body"] == {"chat_template_kwargs": {"enable_thinking": False}} assert captured["extra_body"] == {
"chat_template_kwargs": {"enable_thinking": False},
"thinking": {"type": "disabled"},
}
def test_non_qwen_thinking_mode_is_not_sent(monkeypatch: pytest.MonkeyPatch) -> None: def test_thinking_mode_disabled_is_sent_without_model_name_matching(monkeypatch: pytest.MonkeyPatch) -> None:
captured: dict = {} captured: dict = {}
class Message: class Message:
@ -85,7 +88,72 @@ def test_non_qwen_thinking_mode_is_not_sent(monkeypatch: pytest.MonkeyPatch) ->
) )
) )
assert "extra_body" not in captured assert captured["extra_body"] == {
"chat_template_kwargs": {"enable_thinking": False},
"thinking": {"type": "disabled"},
}
def test_litellm_provider_preserves_reasoning_content_for_tool_round_trip() -> None:
messages = [
{
"role": "assistant",
"content": "",
"reasoning_content": "must be passed back",
"tool_calls": [
{
"id": "call-1",
"type": "function",
"function": {"name": "lookup", "arguments": "{}"},
}
],
}
]
assert LiteLLMProvider._sanitize_messages(messages)[0]["reasoning_content"] == "must be passed back"
def test_thinking_mode_is_forced_disabled_even_when_requested_enabled(monkeypatch: pytest.MonkeyPatch) -> None:
captured: dict = {}
class Message:
content = "ok"
reasoning_content = None
tool_calls = []
class Choice:
message = Message()
finish_reason = "stop"
class Response:
choices = [Choice()]
usage = None
async def fake_acompletion(**kwargs):
captured.update(kwargs)
return Response()
monkeypatch.setattr("beaver.engine.providers.litellm.acompletion", fake_acompletion)
monkeypatch.setattr("beaver.engine.providers.litellm.litellm", SimpleNamespace())
provider = LiteLLMProvider(
api_key="sk-test",
api_base="https://oai.example.com/v1",
default_model="gpt-4.1-mini",
provider_name="openai",
)
asyncio.run(
provider.chat(
[{"role": "user", "content": "reply ok"}],
model="gpt-4.1-mini",
thinking_enabled=True,
)
)
assert captured["extra_body"] == {
"chat_template_kwargs": {"enable_thinking": False},
"thinking": {"type": "disabled"},
}
def test_litellm_provider_sanitizes_tool_call_arguments(monkeypatch: pytest.MonkeyPatch) -> None: def test_litellm_provider_sanitizes_tool_call_arguments(monkeypatch: pytest.MonkeyPatch) -> None:

View File

@ -30,6 +30,15 @@ class StubAgentService(AgentService):
self.fail = fail self.fail = fail
self.calls: list[dict[str, Any]] = [] self.calls: list[dict[str, Any]] = []
async def process_direct(self, message: str, **kwargs: Any) -> StubRunResult: # type: ignore[override]
self.calls.append({"message": message, **kwargs})
if self.fail:
raise RuntimeError("boom")
return StubRunResult(
session_id=kwargs.get("session_id") or "web:default",
output_text=f"echo:{message}",
)
async def submit_direct(self, message: str, **kwargs: Any) -> StubRunResult: # type: ignore[override] async def submit_direct(self, message: str, **kwargs: Any) -> StubRunResult: # type: ignore[override]
self.calls.append({"message": message, **kwargs}) self.calls.append({"message": message, **kwargs})
if self.fail: if self.fail:
@ -40,6 +49,11 @@ class StubAgentService(AgentService):
) )
class DirectModeOnlyAgentService(StubAgentService):
async def submit_direct(self, message: str, **kwargs: Any) -> StubRunResult: # type: ignore[override]
raise RuntimeError("submit_direct should not be used when service is not running")
def test_websocket_ping_pong() -> None: def test_websocket_ping_pong() -> None:
app = create_app(service=StubAgentService(), manage_service_lifecycle=False) app = create_app(service=StubAgentService(), manage_service_lifecycle=False)
@ -101,6 +115,64 @@ def test_websocket_message_returns_chat_metadata_and_session_updated() -> None:
} }
def test_websocket_message_uses_direct_processing_when_loop_is_not_running() -> None:
service = DirectModeOnlyAgentService()
app = create_app(service=service, manage_service_lifecycle=False)
with TestClient(app) as client:
with client.websocket_connect("/ws/web:alpha") as websocket:
websocket.send_json({"type": "message", "content": "hello"})
assert websocket.receive_json() == {"type": "status", "status": "thinking"}
message = websocket.receive_json()
assert service.calls == [
{
"message": "hello",
"session_id": "web:alpha",
"source": "websocket",
"user_id": None,
"title": None,
"execution_context": None,
"model": None,
"provider_name": None,
"embedding_model": None,
"max_tool_iterations": None,
}
]
assert message["type"] == "message"
assert message["content"] == "echo:hello"
def test_rest_chat_uses_direct_processing_when_loop_is_not_running() -> None:
service = DirectModeOnlyAgentService()
app = create_app(service=service, manage_service_lifecycle=False)
with TestClient(app) as client:
response = client.post("/api/chat", json={"session_id": "web:alpha", "message": "hello"})
assert response.status_code == 200
assert service.calls == [
{
"message": "hello",
"session_id": "web:alpha",
"source": "web",
"user_id": None,
"title": None,
"execution_context": None,
"model": None,
"provider_name": None,
"embedding_model": None,
"temperature": None,
"max_tokens": None,
"max_tool_iterations": None,
"fallback_target": None,
"auxiliary_target": None,
"embedding_target": None,
}
]
assert response.json()["output_text"] == "echo:hello"
def test_websocket_empty_content_returns_error_without_runtime_call() -> None: def test_websocket_empty_content_returns_error_without_runtime_call() -> None:
service = StubAgentService() service = StubAgentService()
app = create_app(service=service, manage_service_lifecycle=False) app = create_app(service=service, manage_service_lifecycle=False)

View File

@ -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';
@ -39,10 +41,10 @@ const THINKING_MODE_STORAGE_KEY = 'beaver_chat_thinking_enabled';
function loadThinkingModePreference(): boolean { function loadThinkingModePreference(): boolean {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return true; return false;
} }
const stored = window.localStorage.getItem(THINKING_MODE_STORAGE_KEY); const stored = window.localStorage.getItem(THINKING_MODE_STORAGE_KEY);
return stored == null ? true : stored !== 'false'; return stored == null ? false : stored !== 'false';
} }
export default function ChatPage() { export default function ChatPage() {
@ -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>
); );
} }

View File

@ -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>
)}
</>
);
}

View File

@ -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[] = [
{ {

View File

@ -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>();

View 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 步',
});
});
});

View 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),
};
}

View File

@ -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');
});
}); });

View File

@ -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) =>