Commit 3d778a92 authored by AI-甘富林's avatar AI-甘富林

feat(ui): streamline chat composer and inline trace rendering

parent a4d243ae
......@@ -15,6 +15,7 @@ import type {
RuntimeTelemetryStatus,
SaveConfigInput,
SessionSummary,
SetupMode,
SystemSummary,
WorkspaceSummary
} from "@qjclaw/shared-types";
......@@ -22,11 +23,28 @@ import type {
type ViewMode = "chat" | "skills" | "plugins" | "settings";
type Tone = "positive" | "warning";
type MessageStreamState = "streaming" | "error";
type SendPhase = "idle" | "preparing" | "streaming" | "finalizing";
type TraceTone = "info" | "error" | "success";
type UiChatMessage = ChatMessage & {
streamState?: MessageStreamState;
statusLabel?: string;
};
interface ConversationTraceItem {
id: string;
stage: string;
label: string;
detail?: string;
tone: TraceTone;
createdAt: string;
}
interface MessageTraceState {
items: ConversationTraceItem[];
expanded: boolean;
}
interface ActiveStreamState {
requestId: string;
assistantMessageId: string;
......@@ -48,6 +66,7 @@ interface SmokeStreamSnapshot {
runId?: string;
assistantMessageId?: string;
startedEventCount: number;
statusEventCount: number;
deltaEventCount: number;
completedEventCount: number;
errorEventCount: number;
......@@ -56,23 +75,38 @@ interface SmokeStreamSnapshot {
finalContent: string;
executionPolicySource?: string;
executionPolicyModel?: string;
latestStatusLabel?: string;
lastError?: string;
}
const DEFAULT_SESSION_ID = "desktop-main";
const SUCCESS_NOTICE_TIMEOUT_MS = 2400;
const TYPEWRITER_CHARS_PER_FRAME = 3;
const MAX_TRACE_ITEMS = 60;
function createClientMessageId(prefix: string): string {
return globalThis.crypto?.randomUUID?.() ?? `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
function toUiChatMessage(message: ChatMessage, streamState?: MessageStreamState): UiChatMessage {
return streamState ? { ...message, streamState } : { ...message };
function toUiChatMessage(message: ChatMessage, streamState?: MessageStreamState, statusLabel?: string): UiChatMessage {
return streamState || statusLabel ? { ...message, streamState, statusLabel } : { ...message };
}
function toPlainMessages(items: UiChatMessage[]): ChatMessage[] {
return items.map(({ streamState, ...message }) => message);
return items.map(({ streamState, statusLabel, ...message }) => message);
}
function isPrimaryChatMessage(message: ChatMessage): boolean {
return message.role === "user" || message.role === "assistant";
}
function pushTraceItem(current: ConversationTraceItem[], item: ConversationTraceItem): ConversationTraceItem[] {
const lastItem = current.at(-1);
if (lastItem && lastItem.stage === item.stage && lastItem.label === item.label && lastItem.detail === item.detail && lastItem.tone === item.tone) {
return current;
}
const next = [...current, item];
return next.slice(-MAX_TRACE_ITEMS);
}
function buildUserMessage(content: string): UiChatMessage {
......@@ -85,69 +119,127 @@ function buildUserMessage(content: string): UiChatMessage {
};
}
function buildAssistantPlaceholder(): UiChatMessage {
function buildAssistantPlaceholder(statusLabel: string): UiChatMessage {
return {
id: createClientMessageId("assistant"),
role: "assistant",
content: "",
createdAt: new Date().toISOString(),
streamState: "streaming"
streamState: "streaming",
statusLabel
};
}
const DEFAULT_SKILL = {
id: "default-chat",
name: "默认对话",
description: "通用对话技能",
category: "通用",
name: "\u9ed8\u8ba4\u5bf9\u8bdd",
description: "\u901a\u7528\u5bf9\u8bdd\u80fd\u529b",
category: "\u901a\u7528",
enabled: true,
ready: true,
downloadState: "ready" as const
};
const ui = {
app: "千匠Claw",
app: "\u5343\u5320Claw",
subtitle: "OpenClaw Client",
appDesc: "绑定 api_key 后自动拉取运行时配置",
heroLine: "千匠Claw,您身边最得力的员工,Start Your Ideas....",
chat: "对话",
skills: "技能",
plugins: "插件",
settings: "设置",
bound: "已绑定",
unbound: "未绑定",
defaultChat: "默认对话",
bindTitle: "绑定员工密钥",
bindDesc: "输入 OpenClaw employee api_key 后,桌面端会自动拉取运行时配置。",
apiKey: "员工密钥",
apiKeyPlaceholder: "请输入 OpenClaw employee api_key",
bindNow: "立即绑定",
binding: "绑定中...",
changeApiKey: "更换员工密钥",
skillChoice: "选择技能",
clearSkill: "清空技能",
noMessages: "当前没有消息,请先发送一条消息。",
taskPlaceholder: "输入消息后回车或点击发送",
taskDisabledPlaceholder: "请先绑定员工密钥后开始对话。",
send: "发送",
sending: "发送中...",
bindFirst: "请先绑定",
bindFirstError: "请先绑定员工密钥后再发送消息。",
startingHint: "运行时正在启动,请稍候。",
chatNotReadyError: "当前聊天暂不可用,请检查运行时状态。",
noSkillCards: "当前没有可用技能。",
pluginTitle: "插件列表",
noPlugins: "当前没有可用插件。",
settingsTitle: "设置",
settingsDesc: "配置运行时、密钥和工作目录。",
workspacePath: "工作目录",
save: "保存",
saving: "保存中...",
diagnostics: "诊断",
diagnosticsDesc: "查看运行时、网关和安装信息。",
export: "导出诊断",
exported: "诊断已导出:",
currentBinding: "当前绑定",
none: "无"
appDesc: "\u7ed1\u5b9a api_key \u540e\u81ea\u52a8\u540c\u6b65\u8fd0\u884c\u65f6\u914d\u7f6e\u3002",
heroLine: "\u5343\u5320Claw\uff0c\u60a8\u8eab\u8fb9\u6700\u5f97\u529b\u7684\u5458\u5de5\uff0cStart Your Ideas.",
chat: "\u5bf9\u8bdd",
skills: "\u6280\u80fd",
plugins: "\u63d2\u4ef6",
settings: "\u8bbe\u7f6e",
bound: "\u5df2\u7ed1\u5b9a",
unbound: "\u672a\u7ed1\u5b9a",
defaultChat: "\u9ed8\u8ba4\u5bf9\u8bdd",
bindTitle: "\u7ed1\u5b9a\u5458\u5de5\u5bc6\u94a5",
bindDesc: "\u8f93\u5165 OpenClaw employee api_key \u540e\uff0c\u684c\u9762\u7aef\u4f1a\u81ea\u52a8\u62c9\u53d6\u8fd0\u884c\u65f6\u914d\u7f6e\u3002",
apiKey: "\u5458\u5de5\u5bc6\u94a5",
apiKeyPlaceholder: "\u8bf7\u8f93\u5165 OpenClaw employee api_key",
bindNow: "\u7acb\u5373\u7ed1\u5b9a",
binding: "\u7ed1\u5b9a\u4e2d...",
changeApiKey: "\u66f4\u6362\u5458\u5de5\u5bc6\u94a5",
skillChoice: "\u9009\u62e9\u6280\u80fd",
clearSkill: "\u6e05\u7a7a\u6280\u80fd",
skillMenuTitle: "\u9009\u62e9\u6280\u80fd",
noMessages: "\u5f53\u524d\u6ca1\u6709\u6d88\u606f\uff0c\u8bf7\u5148\u53d1\u9001\u4e00\u6761\u6d88\u606f\u3002",
taskPlaceholder: "\u8f93\u5165\u6d88\u606f\u540e\u56de\u8f66\u6216\u70b9\u51fb\u53d1\u9001\u3002",
taskDisabledPlaceholder: "\u8bf7\u5148\u7ed1\u5b9a\u5458\u5de5\u5bc6\u94a5\u540e\u5f00\u59cb\u5bf9\u8bdd\u3002",
send: "\u53d1\u9001",
sending: "\u53d1\u9001\u4e2d...",
preparingChat: "\u6b63\u5728\u51c6\u5907\u5bf9\u8bdd\u73af\u5883",
preparingChatHint: "\u670d\u52a1\u5c31\u7eea\u540e\u5373\u53ef\u53d1\u9001\u9996\u6761\u6d88\u606f\u3002",
preparing: "\u51c6\u5907\u4e2d...",
generating: "\u56de\u7b54\u4e2d...",
thinking: "\u6b63\u5728\u6574\u7406\u7b54\u6848",
startupTitle: "\u6b63\u5728\u51c6\u5907\u5343\u5320Claw",
startupDesc: "\u9996\u6b21\u8fdb\u5165\u4f1a\u5148\u5b8c\u6210\u5fc5\u8981\u51c6\u5907\uff0c\u5b8c\u6210\u540e\u81ea\u52a8\u8fdb\u5165\u5bf9\u8bdd\u3002",
startupReadySoon: "\u51c6\u5907\u5b8c\u6210\u540e\u5c06\u81ea\u52a8\u8fdb\u5165\u5bf9\u8bdd\u3002",
startupBooting: "\u6b63\u5728\u52a0\u8f7d\u5e94\u7528\u4fe1\u606f\u3002",
startupRetry: "\u91cd\u65b0\u51c6\u5907",
openSettings: "\u6253\u5f00\u8bbe\u7f6e",
startupLoadLocal: "\u8bfb\u53d6\u672c\u5730\u914d\u7f6e",
startupPrepareRuntime: "\u51c6\u5907\u672c\u5730\u52a9\u624b",
startupConnectService: "\u8fde\u63a5\u804a\u5929\u670d\u52a1",
startupEnterChat: "\u8fdb\u5165\u5bf9\u8bdd",
traceTitle: "\u601d\u8003\u8fc7\u7a0b",
showTrace: "\u67e5\u770b\u8be6\u60c5",
hideTrace: "\u6536\u8d77\u8be6\u60c5",
traceEmpty: "\u8fd8\u6ca1\u6709\u53ef\u663e\u793a\u7684\u8fdb\u5ea6\u3002",
traceCollapsed: "\u67e5\u770b\u601d\u8003\u8fc7\u7a0b",
preparingReply: "\u6b63\u5728\u7406\u89e3\u4f60\u7684\u95ee\u9898",
checkingChat: "\u6b63\u5728\u68c0\u67e5\u5bf9\u8bdd\u73af\u5883",
startingRuntime: "\u6b63\u5728\u542f\u52a8\u672c\u5730\u52a9\u624b",
connectingGateway: "\u6b63\u5728\u8fde\u63a5\u670d\u52a1",
waitingReply: "\u5df2\u6536\u5230\u95ee\u9898\uff0c\u6b63\u5728\u7ec4\u7ec7\u56de\u7b54",
traceNote: "\u8fd9\u91cc\u53ea\u5c55\u793a\u5904\u7406\u8fdb\u5ea6\u6458\u8981\uff0c\u4e0d\u5c55\u793a\u5185\u90e8\u63a8\u7406\u7ec6\u8282\u3002",
traceLatest: "\u6700\u65b0\u8fdb\u5c55",
traceSteps: "\u6b65",
checkingChatDetail: "\u9996\u6b21\u63d0\u95ee\u65f6\u4f1a\u5148\u68c0\u67e5\u5f53\u524d\u5bf9\u8bdd\u73af\u5883\u3002",
startingRuntimeDetail: "\u9996\u6b21\u542f\u52a8\u672c\u5730\u52a9\u624b\u53ef\u80fd\u9700\u8981\u51e0\u79d2\u949f\u3002",
connectingGatewayDetail: "\u6b63\u5728\u5efa\u7acb\u4e0e\u804a\u5929\u670d\u52a1\u7684\u8fde\u63a5\u3002",
replyStarted: "\u5df2\u5f00\u59cb\u6574\u7406\u56de\u7b54",
fallbackReply: "\u5b9e\u65f6\u8f93\u51fa\u6682\u4e0d\u53ef\u7528\uff0c\u6b63\u5728\u6574\u7406\u5b8c\u6574\u7b54\u590d",
fallbackComplete: "\u5df2\u751f\u6210\u5b8c\u6574\u7b54\u590d",
replyReady: "\u56de\u7b54\u5df2\u5b8c\u6210",
replyFailed: "\u56de\u590d\u5931\u8d25",
user: "\u7528\u6237",
system: "\u7cfb\u7edf",
saveSuccessPending: "\u5458\u5de5\u5bc6\u94a5\u5df2\u4fdd\u5b58\uff0c\u6b63\u5728\u540c\u6b65\u8fd0\u884c\u65f6\u914d\u7f6e\u3002",
bindFirst: "\u8bf7\u5148\u7ed1\u5b9a",
bindFirstError: "\u8bf7\u5148\u7ed1\u5b9a\u5458\u5de5\u5bc6\u94a5\u540e\u518d\u53d1\u9001\u6d88\u606f\u3002",
startingHint: "\u6b63\u5728\u51c6\u5907\u8fd0\u884c\u73af\u5883\uff0c\u8bf7\u7a0d\u5019\u3002",
chatNotReadyError: "\u5f53\u524d\u804a\u5929\u6682\u4e0d\u53ef\u7528\uff0c\u8bf7\u68c0\u67e5\u8fd0\u884c\u65f6\u72b6\u6001\u3002",
noSkillCards: "\u5f53\u524d\u6ca1\u6709\u53ef\u7528\u6280\u80fd\u3002",
pluginTitle: "\u63d2\u4ef6\u5217\u8868",
noPlugins: "\u5f53\u524d\u6ca1\u6709\u53ef\u7528\u63d2\u4ef6\u3002",
settingsTitle: "\u8bbe\u7f6e",
settingsDesc: "\u914d\u7f6e\u8fd0\u884c\u65f6\u3001\u5bc6\u94a5\u548c\u5de5\u4f5c\u76ee\u5f55\u3002",
chatPageDesc: "\u5728\u8fd9\u91cc\u4e0e\u5343\u5320Claw\u5b8c\u6210\u5bf9\u8bdd\u548c\u534f\u4f5c\u3002",
skillsPageDesc: "\u67e5\u770b\u5f53\u524d\u5df2\u51c6\u5907\u597d\u7684\u6280\u80fd\u80fd\u529b\u3002",
pluginsPageDesc: "\u67e5\u770b\u5f53\u524d\u5df2\u542f\u7528\u7684\u63d2\u4ef6\u80fd\u529b\u3002",
workspacePath: "\u5de5\u4f5c\u76ee\u5f55",
save: "\u4fdd\u5b58",
saving: "\u4fdd\u5b58\u4e2d...",
diagnostics: "\u8bca\u65ad",
diagnosticsDesc: "\u67e5\u770b\u8fd0\u884c\u65f6\u3001\u7f51\u5173\u548c\u5b89\u88c5\u4fe1\u606f\u3002",
export: "\u5bfc\u51fa\u8bca\u65ad",
exported: "\u8bca\u65ad\u5df2\u5bfc\u51fa\uff1a",
currentBinding: "\u5f53\u524d\u7ed1\u5b9a",
none: "\u65e0"
} as const;
const startupCurtainCopy = {
kicker: "Qianjiang Claw",
brandTitle: "\u5343\u5320Claw",
brandSubtitle: "\u60a8\u8eab\u8fb9\u6700\u5f97\u529b\u7684\u5458\u5de5",
brandTagline: "Start Your Ideas.",
loadingLabel: "\u6b63\u5728\u4e3a\u60a8\u51c6\u5907\u5bf9\u8bdd\u73af\u5883",
syncingConfig: "\u6b63\u5728\u540c\u6b65\u5de5\u4f5c\u914d\u7f6e",
startingRuntime: "\u6b63\u5728\u5524\u8d77\u672c\u5730\u52a9\u624b",
connectingGateway: "\u6b63\u5728\u5efa\u7acb\u5bf9\u8bdd\u8fde\u63a5",
ready: "\u51c6\u5907\u5b8c\u6210\uff0c\u6b63\u5728\u8fdb\u5165\u5bf9\u8bdd",
retryHint: "\u51c6\u5907\u5931\u8d25\u540e\u53ef\u91cd\u65b0\u5c1d\u8bd5\uff0c\u6216\u524d\u5f80\u8bbe\u7f6e\u68c0\u67e5\u5bc6\u94a5\u4e0e\u7f51\u7edc\u3002"
} as const;
const mockChatStreamListeners = new Set<ChatStreamListener>();
......@@ -159,27 +251,32 @@ function emitMockChatStreamEvent(event: ChatStreamEvent) {
}
const pluginDisplayMap: Record<string, { name: string; description: string }> = {
"spreadsheet-tools": { name: "表格工具", description: "读取、统计和处理 Excel、CSV 等常见表格文件。" },
"sheet-plugin": { name: "表格工具", description: "读取、统计和处理 Excel、CSV 等常见表格文件。" },
"document-tools": { name: "文档工具", description: "处理 txt、md、docx、pdf 等常见文档。" },
"doc-plugin": { name: "文档工具", description: "处理 txt、md、docx、pdf 等常见文档。" },
"web-tools": { name: "网页信息提取", description: "抓取网页内容并进行提取、清洗和汇总。" },
"web-plugin": { name: "网页信息提取", description: "抓取网页内容并进行提取、清洗和汇总。" },
"file-tools": { name: "文件工具", description: "进行文件复制、移动和归档等操作。" },
"runtime-diagnostics": { name: "运行时诊断", description: "查看运行时信息、日志和状态。" },
"browser-automation": { name: "网页自动化", description: "自动执行网页浏览、点击和表单操作。" },
"browser-plugin": { name: "网页自动化", description: "自动执行网页浏览、点击和表单操作。" },
"ocr-tools": { name: "OCR 识别", description: "识别扫描件和图片文字并提取结构。" }
"spreadsheet-tools": { name: "\u8868\u683c\u5de5\u5177", description: "\u8bfb\u53d6\u548c\u5904\u7406 Excel\u3001CSV \u7b49\u5e38\u89c1\u8868\u683c\u6587\u4ef6\u3002" },
"sheet-plugin": { name: "\u8868\u683c\u5de5\u5177", description: "\u8bfb\u53d6\u548c\u5904\u7406 Excel\u3001CSV \u7b49\u5e38\u89c1\u8868\u683c\u6587\u4ef6\u3002" },
"document-tools": { name: "\u6587\u6863\u5de5\u5177", description: "\u5904\u7406 txt\u3001md\u3001docx\u3001pdf \u7b49\u5e38\u89c1\u6587\u6863\u3002" },
"doc-plugin": { name: "\u6587\u6863\u5de5\u5177", description: "\u5904\u7406 txt\u3001md\u3001docx\u3001pdf \u7b49\u5e38\u89c1\u6587\u6863\u3002" },
"web-tools": { name: "\u7f51\u9875\u4fe1\u606f\u63d0\u53d6", description: "\u6293\u53d6\u7f51\u9875\u5185\u5bb9\u5e76\u8fdb\u884c\u63d0\u53d6\u3001\u6e05\u6d17\u548c\u6c47\u603b\u3002" },
"web-plugin": { name: "\u7f51\u9875\u4fe1\u606f\u63d0\u53d6", description: "\u6293\u53d6\u7f51\u9875\u5185\u5bb9\u5e76\u8fdb\u884c\u63d0\u53d6\u3001\u6e05\u6d17\u548c\u6c47\u603b\u3002" },
"file-tools": { name: "\u6587\u4ef6\u5de5\u5177", description: "\u6267\u884c\u6587\u4ef6\u590d\u5236\u3001\u79fb\u52a8\u548c\u5f52\u6863\u7b49\u64cd\u4f5c\u3002" },
"runtime-diagnostics": { name: "\u8fd0\u884c\u65f6\u8bca\u65ad", description: "\u67e5\u770b\u8fd0\u884c\u65f6\u4fe1\u606f\u3001\u65e5\u5fd7\u548c\u72b6\u6001\u3002" },
"browser-automation": { name: "\u7f51\u9875\u81ea\u52a8\u5316", description: "\u81ea\u52a8\u6267\u884c\u7f51\u9875\u6d4f\u89c8\u3001\u70b9\u51fb\u548c\u8868\u5355\u64cd\u4f5c\u3002" },
"browser-plugin": { name: "\u7f51\u9875\u81ea\u52a8\u5316", description: "\u81ea\u52a8\u6267\u884c\u7f51\u9875\u6d4f\u89c8\u3001\u70b9\u51fb\u548c\u8868\u5355\u64cd\u4f5c\u3002" },
"ocr-tools": { name: "OCR \u8bc6\u522b", description: "\u8bc6\u522b\u626b\u63cf\u4ef6\u548c\u56fe\u7247\u6587\u5b57\u5e76\u63d0\u53d6\u7ed3\u6784\u3002" }
};
const mockDesktopApi = {
workspace: {
getSummary: async () => ({
apiKeyConfigured: true,
bindingRequired: false,
setupRequired: false,
setupMode: "employee-key",
chatReady: true,
chatLaunchState: "ready",
chatStatusMessage: "员工密钥已绑定。",
chatStatusMessage: "\u804a\u5929\u670d\u52a1\u5df2\u5c31\u7eea\u3002",
startupPhase: "ready",
startupMessage: "\u804a\u5929\u670d\u52a1\u5df2\u5c31\u7eea\u3002",
employeeId: "demo",
employeeName: ui.app + " Demo",
welcomeMessage: ui.bindDesc,
......@@ -196,11 +293,12 @@ const mockDesktopApi = {
{ id: "doc", name: "Document Tools", description: "Process documents and content organization.", category: "office", enabled: true, ready: true, downloadState: "ready", fileName: "doc.md" }
],
plugins: [
{ id: "spreadsheet-tools", name: "表格工具", description: "读取、统计和处理 Excel、CSV 等常见表格文件。", status: "included", includedByDefault: true },
{ id: "document-tools", name: "文档工具", description: "处理 txt、md、docx、pdf 等常见文档。", status: "included", includedByDefault: true },
{ id: "web-tools", name: "网页信息提取", description: "抓取网页内容并进行提取、清洗和汇总。", status: "included", includedByDefault: true }
{ id: "spreadsheet-tools", name: "\u8868\u683c\u5de5\u5177", description: "\u8bfb\u53d6\u548c\u5904\u7406 Excel\u3001CSV \u7b49\u5e38\u89c1\u8868\u683c\u6587\u4ef6\u3002", status: "included", includedByDefault: true },
{ id: "document-tools", name: "\u6587\u6863\u5de5\u5177", description: "\u5904\u7406 txt\u3001md\u3001docx\u3001pdf \u7b49\u5e38\u89c1\u6587\u6863\u3002", status: "included", includedByDefault: true },
{ id: "web-tools", name: "\u7f51\u9875\u4fe1\u606f\u63d0\u53d6", description: "\u6293\u53d6\u7f51\u9875\u5185\u5bb9\u5e76\u8fdb\u884c\u63d0\u53d6\u3001\u6e05\u6d17\u548c\u6c47\u603b\u3002", status: "included", includedByDefault: true }
]
})
}),
warmup: async () => ({ accepted: true, state: "scheduled", message: "mock" })
},
gateway: {
status: async () => ({ state: "connected", url: "ws://127.0.0.1:18789", host: "127.0.0.1", port: 18789, version: "mock-gateway", transport: "websocket", message: "mock" }),
......@@ -226,8 +324,8 @@ const mockDesktopApi = {
getStatus: async () => ({ state: "running", heartbeatIntervalMs: 30000, configSyncIntervalMs: 60000, eventFlushIntervalMs: 10000, eventBatchSize: 20, queuedEventCount: 0, droppedEventCount: 0, messageCount: 1, activeConversationCount: 1, errorCount: 0, lastHeartbeatAt: new Date().toISOString(), lastConfigSyncAt: new Date().toISOString(), configSyncSuccessCount: 1, heartbeatSuccessCount: 1, lastEventTypes: ["startup"], totalAcceptedEventCount: 1 })
},
config: {
load: async () => ({ provider: "openai", baseUrl: "https://api.openai.com/v1", apiKeyConfigured: true, gatewayTokenConfigured: true, authTokenConfigured: false, defaultModel: "gpt-5.4-mini", workspacePath: "D:/workspace", gatewayUrl: "ws://127.0.0.1:18789", cloudApiBaseUrl: "", runtimeCloudApiBaseUrl: "https://xuphfkscoptnjoaecbvn.supabase.co/functions/v1", runtimeMode: "bundled-runtime" }),
save: async (input: SaveConfigInput) => ({ provider: input.provider, baseUrl: input.baseUrl, apiKeyConfigured: true, gatewayTokenConfigured: true, authTokenConfigured: false, defaultModel: input.defaultModel, workspacePath: input.workspacePath, gatewayUrl: input.gatewayUrl, cloudApiBaseUrl: input.cloudApiBaseUrl, runtimeCloudApiBaseUrl: input.runtimeCloudApiBaseUrl, runtimeMode: input.runtimeMode })
load: async () => ({ setupMode: "employee-key", provider: "openai", baseUrl: "https://api.openai.com/v1", apiKeyConfigured: true, gatewayTokenConfigured: true, authTokenConfigured: false, defaultModel: "gpt-5.4-mini", workspacePath: "D:/workspace", gatewayUrl: "ws://127.0.0.1:18789", cloudApiBaseUrl: "", runtimeCloudApiBaseUrl: "https://xuphfkscoptnjoaecbvn.supabase.co/functions/v1", runtimeMode: "bundled-runtime" }),
save: async (input: SaveConfigInput) => ({ setupMode: input.setupMode, provider: input.provider, baseUrl: input.baseUrl, apiKeyConfigured: true, gatewayTokenConfigured: true, authTokenConfigured: false, defaultModel: input.defaultModel, workspacePath: input.workspacePath, gatewayUrl: input.gatewayUrl, cloudApiBaseUrl: input.cloudApiBaseUrl, runtimeCloudApiBaseUrl: input.runtimeCloudApiBaseUrl, runtimeMode: input.runtimeMode })
},
auth: { getSessionSummary: async () => ({ state: "anonymous", tokenConfigured: false, message: "mock" }), signIn: async () => ({ state: "authenticated", tokenConfigured: true, message: "mock" }), signOut: async () => ({ state: "anonymous", tokenConfigured: false, message: "mock" }) },
profile: { getSummary: async () => ({ userId: "demo", displayName: "demo", email: "demo@example.com", organizationName: "demo" }) },
......@@ -247,8 +345,12 @@ const mockDesktopApi = {
const chunks = replyText.match(/.{1,6}/g) ?? [replyText];
let fullText = "";
window.setTimeout(() => {
emitMockChatStreamEvent({ type: "status", requestId, sessionId: DEFAULT_SESSION_ID, runId, stage: "prepare-request", label: ui.preparingReply });
emitMockChatStreamEvent({ type: "started", requestId, sessionId: DEFAULT_SESSION_ID, runId, executionPolicy });
}, 0);
window.setTimeout(() => {
emitMockChatStreamEvent({ type: "status", requestId, sessionId: DEFAULT_SESSION_ID, runId, stage: "await-model", label: ui.waitingReply });
}, 30);
chunks.forEach((chunk, index) => {
window.setTimeout(() => {
fullText += chunk;
......@@ -346,6 +448,46 @@ function canExchangeMessages(runtimeStatus: RuntimeStatus | null, gatewayStatus:
return runtimeStatus.activeMode === "external-gateway" || runtimeStatus.processState === "running";
}
function getStartupProgress(phase: WorkspaceSummary["startupPhase"] | undefined): number {
switch (phase) {
case "syncing-config":
return 0.24;
case "starting-runtime":
return 0.56;
case "connecting-gateway":
return 0.82;
case "ready":
return 1;
case "error":
return 0.82;
default:
return 0.12;
}
}
function getStartupCurtainStatus(
phase: WorkspaceSummary["startupPhase"] | undefined,
launchState: ChatLaunchState,
message?: string
): string {
if (launchState === "error") {
return message || ui.chatNotReadyError;
}
switch (phase) {
case "syncing-config":
return startupCurtainCopy.syncingConfig;
case "starting-runtime":
return startupCurtainCopy.startingRuntime;
case "connecting-gateway":
return startupCurtainCopy.connectingGateway;
case "ready":
return startupCurtainCopy.ready;
default:
return startupCurtainCopy.loadingLabel;
}
}
export default function App() {
const [viewMode, setViewMode] = useState<ViewMode>("chat");
const [config, setConfig] = useState<AppConfig | null>(null);
......@@ -361,30 +503,55 @@ export default function App() {
const [selectedSkillId, setSelectedSkillId] = useState(DEFAULT_SKILL.id);
const [prompt, setPrompt] = useState("");
const [apiKeyDraft, setApiKeyDraft] = useState("");
const [setupModeDraft, setSetupModeDraft] = useState<SetupMode>("employee-key");
const [providerDraft, setProviderDraft] = useState("openai");
const [baseUrlDraft, setBaseUrlDraft] = useState("");
const [defaultModelDraft, setDefaultModelDraft] = useState("gpt-5.4-mini");
const [workspacePathDraft, setWorkspacePathDraft] = useState("");
const [refreshing, setRefreshing] = useState(false);
const [saving, setSaving] = useState(false);
const [sending, setSending] = useState(false);
const [sendPhase, setSendPhase] = useState<SendPhase>("idle");
const [errorText, setErrorText] = useState("");
const [infoText, setInfoText] = useState("");
const [messageTraces, setMessageTraces] = useState<Record<string, MessageTraceState>>({});
const [skillMenuOpen, setSkillMenuOpen] = useState(false);
const activeStreamRef = useRef<ActiveStreamState | null>(null);
const skillMenuRef = useRef<HTMLDivElement | null>(null);
const startupWarmupRequestedRef = useRef(false);
const [streamSmoke, setStreamSmoke] = useState<SmokeStreamSnapshot | null>(null);
const catalogSkills = workspace?.skills ?? [];
const readySkills = useMemo(() => catalogSkills.filter((skill) => skill.ready), [catalogSkills]);
const effectiveSkills = useMemo(() => (readySkills.length ? [DEFAULT_SKILL, ...readySkills] : [DEFAULT_SKILL]), [readySkills]);
const selectedSkill = useMemo(() => effectiveSkills.find((skill) => skill.id === selectedSkillId) ?? effectiveSkills[0] ?? DEFAULT_SKILL, [effectiveSkills, selectedSkillId]);
const chatLaunchState: ChatLaunchState = workspace?.chatLaunchState ?? (workspace?.apiKeyConfigured ? "starting" : "unbound");
const setupMode = workspace?.setupMode ?? config?.setupMode ?? "employee-key";
const setupRequired = workspace?.setupRequired ?? !Boolean(workspace?.apiKeyConfigured ?? config?.apiKeyConfigured);
const chatLaunchState: ChatLaunchState = workspace?.chatLaunchState ?? (!setupRequired ? "starting" : "unbound");
const chatStatusMessage = workspace?.chatStatusMessage ?? (chatLaunchState === "starting" ? ui.startingHint : chatLaunchState === "error" ? ui.chatNotReadyError : "");
const sessions = workspace?.apiKeyConfigured ? [{ id: activeSessionId, title: ui.defaultChat, updatedAt: new Date().toISOString() }] : [];
const isBound = Boolean(workspace?.apiKeyConfigured);
const startupMessage = workspace?.startupMessage ?? ((refreshing && !workspace) ? ui.startupBooting : chatStatusMessage);
const startupPhase = workspace?.startupPhase ?? ((refreshing && !workspace) ? "syncing-config" : "idle");
const startupProgress = getStartupProgress(startupPhase);
const startupCurtainStatus = getStartupCurtainStatus(startupPhase, chatLaunchState, startupMessage);
const startupCurtainFootnote = chatLaunchState === "error"
? startupCurtainCopy.retryHint
: startupCurtainCopy.loadingLabel;
const sessions = !setupRequired ? [{ id: activeSessionId, title: ui.defaultChat, updatedAt: new Date().toISOString() }] : [];
const isBound = !setupRequired;
const showStartupOverlay = viewMode !== "settings" && ((refreshing && !workspace) || setupRequired || (isBound && chatLaunchState !== "ready"));
const sending = sendPhase !== "idle";
const canSend = isBound && prompt.trim().length > 0 && !sending && !saving;
const sendButtonLabel = sending ? ui.sending : !isBound ? ui.bindFirst : ui.send;
const showBindEntry = !isBound;
const showChatStatusHint = isBound && chatLaunchState !== "ready" && Boolean(chatStatusMessage);
const sendButtonLabel = sendPhase === "preparing"
? ui.preparing
: sendPhase === "streaming" || sendPhase === "finalizing"
? ui.generating
: !isBound
? ui.bindFirst
: ui.send;
const isDirectProviderSetup = setupModeDraft === "direct-provider";
const setupActionDisabled = saving || !apiKeyDraft.trim() || (isDirectProviderSetup && (!baseUrlDraft.trim() || !defaultModelDraft.trim()));
const showBindEntry = !isBound && !showStartupOverlay;
const showSettingsStatusHint = viewMode === "settings" && isBound && chatLaunchState !== "ready" && Boolean(startupMessage);
const pageTitle = viewMode === "chat" ? ui.chat : viewMode === "skills" ? ui.skills : viewMode === "plugins" ? ui.plugins : ui.settings;
const pageDesc = viewMode === "chat" ? "查看聊天、技能和运行状态。" : viewMode === "skills" ? "查看当前可用技能。" : viewMode === "plugins" ? "查看已集成插件。" : ui.settingsDesc;
const pageDesc = viewMode === "chat" ? ui.chatPageDesc : viewMode === "skills" ? ui.skillsPageDesc : viewMode === "plugins" ? ui.pluginsPageDesc : ui.settingsDesc;
useEffect(() => {
if (!infoText) {
return;
......@@ -404,7 +571,7 @@ export default function App() {
}
try {
setMessages((await desktopApi.chat.listMessages(sessionId)).map((message) => toUiChatMessage(message)));
setMessages((await desktopApi.chat.listMessages(sessionId)).filter(isPrimaryChatMessage).map((message) => toUiChatMessage(message)));
} catch (error) {
setMessages([]);
if (showError) {
......@@ -413,29 +580,25 @@ export default function App() {
}
}
async function refresh() {
async function refresh(clearError = true): Promise<WorkspaceSummary | null> {
setRefreshing(true);
if (clearError) {
setErrorText("");
}
try {
const [nextConfig, initialRuntime, nextCloud, nextTelemetry, nextSystem] = await Promise.all([
const [nextConfig, nextRuntime, nextCloud, nextTelemetry, nextSystem, nextWorkspace] = await Promise.all([
desktopApi.config.load(),
desktopApi.runtime.getStatus(),
desktopApi.runtimeCloud.getStatus(),
desktopApi.runtimeTelemetry.getStatus(),
desktopApi.system.getSummary()
desktopApi.system.getSummary(),
desktopApi.workspace.getSummary()
]);
let nextRuntime = initialRuntime;
if (nextCloud.apiKeyConfigured && initialRuntime.processState !== "running" && nextConfig.runtimeMode !== "external-gateway") {
const startResult = await desktopApi.runtime.start().catch(() => null);
nextRuntime = await desktopApi.runtime.getStatus().catch(() => startResult ?? initialRuntime);
}
const [nextWorkspace, statusResult] = await Promise.all([
desktopApi.workspace.getSummary(),
nextCloud.apiKeyConfigured ? desktopApi.gateway.status().catch(() => null) : Promise.resolve(null)
]);
const statusResult = nextWorkspace.apiKeyConfigured
? await desktopApi.gateway.status().catch(() => null)
: null;
setConfig(nextConfig);
setWorkspace(nextWorkspace);
......@@ -453,7 +616,7 @@ export default function App() {
setSelectedSkillId(nextSkills[0].id);
}
const canReadMessages = canExchangeMessages(nextRuntime, statusResult);
const canReadMessages = nextWorkspace.chatReady && canExchangeMessages(nextRuntime, statusResult);
if (canReadMessages) {
setGatewayHealth(await desktopApi.gateway.health().catch(() => null));
await loadMessages(DEFAULT_SESSION_ID, true, false);
......@@ -461,8 +624,11 @@ export default function App() {
setGatewayHealth(null);
setMessages([]);
}
return nextWorkspace;
} catch (error) {
setErrorText(err(error));
return null;
} finally {
setRefreshing(false);
}
......@@ -473,17 +639,80 @@ export default function App() {
}, []);
useEffect(() => {
if (workspace?.chatLaunchState !== "starting") {
if (viewMode === "settings" || !showStartupOverlay || !isBound || chatLaunchState !== "starting") {
return;
}
const timer = window.setTimeout(() => {
void refresh();
}, 2000);
let cancelled = false;
let timer: number | undefined;
return () => window.clearTimeout(timer);
}, [workspace?.chatLaunchState]);
const pollWorkspace = async () => {
const nextWorkspace = await refresh(false);
if (cancelled) {
return;
}
if (nextWorkspace?.chatLaunchState === "starting") {
timer = window.setTimeout(() => {
void pollWorkspace();
}, 1000);
}
};
timer = window.setTimeout(() => {
void pollWorkspace();
}, 800);
return () => {
cancelled = true;
if (typeof timer !== "undefined") {
window.clearTimeout(timer);
}
};
}, [chatLaunchState, isBound, showStartupOverlay, viewMode]);
useEffect(() => {
if (!showStartupOverlay || !isBound || chatLaunchState !== "starting") {
startupWarmupRequestedRef.current = false;
return;
}
if (startupWarmupRequestedRef.current) {
return;
}
startupWarmupRequestedRef.current = true;
void desktopApi.workspace.warmup().catch(() => undefined);
}, [chatLaunchState, isBound, showStartupOverlay]);
useEffect(() => {
if (!skillMenuOpen) {
return;
}
const handlePointerDown = (event: MouseEvent) => {
if (skillMenuRef.current?.contains(event.target as Node)) {
return;
}
setSkillMenuOpen(false);
};
document.addEventListener("pointerdown", handlePointerDown);
return () => {
document.removeEventListener("pointerdown", handlePointerDown);
};
}, [skillMenuOpen]);
useEffect(() => {
if (!config) {
return;
}
setSetupModeDraft(config.setupMode);
setProviderDraft(config.provider);
setBaseUrlDraft(config.baseUrl);
setDefaultModelDraft(config.defaultModel);
}, [config?.setupMode, config?.provider, config?.baseUrl, config?.defaultModel]);
useEffect(() => {
if (!effectiveSkills.some((skill) => skill.id === selectedSkillId)) {
setSelectedSkillId(effectiveSkills[0]?.id ?? DEFAULT_SKILL.id);
......@@ -554,6 +783,74 @@ export default function App() {
setStreamSmoke((current) => updater(current));
}
function initializeMessageTrace(messageId: string, item?: ConversationTraceItem) {
setMessageTraces((current) => ({
...current,
[messageId]: {
items: item ? [item] : [],
expanded: true
}
}));
}
function appendTrace(messageId: string, stage: string, label: string, detail?: string, tone: TraceTone = "info") {
setMessageTraces((current) => {
const existing = current[messageId] ?? { items: [], expanded: true };
return {
...current,
[messageId]: {
items: pushTraceItem(existing.items, {
id: createClientMessageId("trace"),
stage,
label,
detail,
tone,
createdAt: new Date().toISOString()
}),
expanded: tone === "error" ? true : existing.expanded
}
};
});
}
function setMessageTraceExpanded(messageId: string, expanded: boolean) {
setMessageTraces((current) => {
const existing = current[messageId];
if (!existing || existing.expanded === expanded) {
return current;
}
return {
...current,
[messageId]: {
...existing,
expanded
}
};
});
}
function collapseMessageTrace(messageId: string) {
setMessageTraceExpanded(messageId, false);
}
function updateAssistantStatus(messageId: string, statusLabel: string) {
updateMessageById(messageId, (message) => ({
...message,
statusLabel,
streamState: message.streamState ?? "streaming"
}));
}
function failPendingAssistant(messageId: string, message: string) {
updateMessageById(messageId, (current) => ({
...current,
content: message,
statusLabel: undefined,
streamState: "error"
}));
}
useEffect(() => {
updateStreamSmoke((current) => {
if (!current?.assistantMessageId) {
......@@ -615,7 +912,8 @@ export default function App() {
...message,
content: activeStream.finalReply?.content ?? activeStream.targetText,
createdAt: activeStream.finalReply?.createdAt ?? message.createdAt,
streamState: undefined
streamState: undefined,
statusLabel: undefined
}));
updateStreamSmoke((current) => current ? {
...current,
......@@ -623,9 +921,10 @@ export default function App() {
renderedContent: activeStream.finalReply?.content ?? activeStream.targetText,
finalContent: activeStream.finalReply?.content ?? activeStream.targetText
} : current);
collapseMessageTrace(activeStream.assistantMessageId);
const sessionId = activeStream.sessionId;
activeStreamRef.current = null;
setSending(false);
setSendPhase("idle");
void syncChatAfterSend(sessionId);
}
......@@ -646,13 +945,13 @@ export default function App() {
const nextChunk = currentStream.targetText.slice(
currentStream.renderedText.length,
currentStream.renderedText.length + TYPEWRITER_CHARS_PER_FRAME
);
currentStream.renderedText += nextChunk;
updateMessageById(currentStream.assistantMessageId, (message) => ({
...message,
content: currentStream.renderedText,
streamState: "streaming"
streamState: "streaming",
statusLabel: undefined
}));
}
......@@ -672,7 +971,8 @@ export default function App() {
updateMessageById(activeStream.assistantMessageId, (current) => ({
...current,
content: activeStream.renderedText || activeStream.targetText || current.content,
streamState: "error"
streamState: "error",
statusLabel: undefined
}));
updateStreamSmoke((current) => current ? {
...current,
......@@ -682,9 +982,10 @@ export default function App() {
lastError: message,
errorEventCount: current.errorEventCount + 1
} : current);
setMessageTraceExpanded(activeStream.assistantMessageId, true);
activeStreamRef.current = null;
}
setSending(false);
setSendPhase("idle");
setErrorText(message);
}
......@@ -696,7 +997,8 @@ export default function App() {
...message,
content: result.reply.content,
createdAt: result.reply.createdAt,
streamState: undefined
streamState: undefined,
statusLabel: undefined
}));
updateStreamSmoke((current) => current ? {
...current,
......@@ -708,8 +1010,10 @@ export default function App() {
executionPolicyModel: result.executionPolicy?.modelLabel ?? current.executionPolicyModel,
lastError: current.lastError
} : current);
appendTrace(assistantMessageId, "fallback-complete", ui.fallbackComplete, undefined, "success");
collapseMessageTrace(assistantMessageId);
await syncChatAfterSend(result.sessionId);
setSending(false);
setSendPhase("idle");
}
useEffect(() => {
......@@ -722,6 +1026,9 @@ export default function App() {
if (event.type === "started") {
activeStream.sessionId = event.sessionId;
setActiveSessionId(event.sessionId);
setSendPhase("streaming");
appendTrace(activeStream.assistantMessageId, "started", ui.replyStarted);
updateAssistantStatus(activeStream.assistantMessageId, ui.thinking);
updateStreamSmoke((current) => current ? {
...current,
phase: "started",
......@@ -735,10 +1042,24 @@ export default function App() {
return;
}
if (event.type === "status") {
activeStream.sessionId = event.sessionId;
appendTrace(activeStream.assistantMessageId, event.stage, event.label, event.detail);
updateAssistantStatus(activeStream.assistantMessageId, event.label);
updateStreamSmoke((current) => current ? {
...current,
sessionId: event.sessionId,
runId: event.runId ?? current.runId,
statusEventCount: current.statusEventCount + 1,
latestStatusLabel: event.label
} : current);
return;
}
if (event.type === "delta") {
activeStream.sessionId = event.sessionId;
setSendPhase("streaming");
activeStream.targetText = event.fullText && event.fullText.length >= activeStream.targetText.length
? event.fullText
: activeStream.targetText + event.textDelta;
updateStreamSmoke((current) => current ? {
......@@ -758,6 +1079,7 @@ export default function App() {
if (event.reply.content.length >= activeStream.targetText.length) {
activeStream.targetText = event.reply.content;
}
appendTrace(activeStream.assistantMessageId, "completed", ui.replyReady, undefined, "success");
updateStreamSmoke((current) => current ? {
...current,
sessionId: event.sessionId,
......@@ -767,11 +1089,22 @@ export default function App() {
executionPolicySource: event.executionPolicy?.source ?? current.executionPolicySource,
executionPolicyModel: event.executionPolicy?.modelLabel ?? current.executionPolicyModel
} : current);
if (!activeStream.targetText.trim() && event.reply.content.trim()) {
updateMessageById(activeStream.assistantMessageId, (message) => ({
...message,
content: event.reply.content,
streamState: undefined,
statusLabel: undefined
}));
finalizeActiveStream();
return;
}
scheduleTypewriter();
return;
}
if (event.type === "error") {
appendTrace(activeStream.assistantMessageId, "error", ui.replyFailed, event.message, "error");
updateStreamSmoke((current) => current ? {
...current,
phase: "error",
......@@ -790,35 +1123,57 @@ export default function App() {
activeStreamRef.current = null;
};
}, []);
async function saveConfig(nextApiKey?: string) {
async function saveConfig(nextApiKey?: string, nextSetupMode?: SetupMode) {
if (!config) {
return;
}
const resolvedSetupMode = nextSetupMode ?? setupModeDraft;
const trimmedApiKey = (nextApiKey ?? apiKeyDraft).trim();
const resolvedProvider = resolvedSetupMode === "direct-provider" ? providerDraft : config.provider;
const resolvedBaseUrl = (resolvedSetupMode === "direct-provider" ? baseUrlDraft : config.baseUrl).trim();
const resolvedDefaultModel = (resolvedSetupMode === "direct-provider" ? defaultModelDraft : config.defaultModel).trim() || config.defaultModel;
if (!trimmedApiKey) {
setErrorText(resolvedSetupMode === "direct-provider" ? "?????? API Key?" : ui.bindFirstError);
return;
}
if (resolvedSetupMode === "direct-provider" && !resolvedBaseUrl) {
setErrorText("?????? Base URL?");
return;
}
setSaving(true);
setErrorText("");
setInfoText("");
try {
const trimmedApiKey = nextApiKey?.trim();
const input: SaveConfigInput = {
provider: config.provider,
baseUrl: config.baseUrl,
defaultModel: config.defaultModel,
setupMode: resolvedSetupMode,
provider: resolvedProvider,
baseUrl: resolvedBaseUrl || config.baseUrl,
defaultModel: resolvedDefaultModel,
workspacePath: workspacePathDraft.trim() || config.workspacePath,
gatewayUrl: config.gatewayUrl,
cloudApiBaseUrl: config.cloudApiBaseUrl,
runtimeCloudApiBaseUrl: config.runtimeCloudApiBaseUrl,
runtimeMode: "bundled-runtime",
...(trimmedApiKey ? { apiKey: trimmedApiKey } : {})
apiKey: trimmedApiKey
};
const savedConfig = await desktopApi.config.save(input);
setConfig(savedConfig);
setWorkspacePathDraft(savedConfig.workspacePath);
setApiKeyDraft("");
setInfoText(trimmedApiKey ? "员工密钥已保存。" : "已清除员工密钥。");
await refresh();
setSetupModeDraft(savedConfig.setupMode);
setProviderDraft(savedConfig.provider);
setBaseUrlDraft(savedConfig.baseUrl);
setDefaultModelDraft(savedConfig.defaultModel);
setInfoText(resolvedSetupMode === "direct-provider"
? "?????????????????"
: ui.saveSuccessPending);
void refresh(false);
} catch (error) {
setErrorText(err(error));
} finally {
......@@ -826,7 +1181,17 @@ export default function App() {
}
}
async function ensureChatAvailable() {
async function ensureChatAvailable(assistantMessageId: string) {
const reportStatus = (stage: string, label: string, detail?: string) => {
updateAssistantStatus(assistantMessageId, label);
appendTrace(assistantMessageId, stage, label, detail);
updateStreamSmoke((current) => current ? {
...current,
latestStatusLabel: label
} : current);
};
reportStatus("check-chat", ui.checkingChat, ui.checkingChatDetail);
const [latestWorkspace, latestRuntime, latestGateway] = await Promise.all([
desktopApi.workspace.getSummary(),
desktopApi.runtime.getStatus().catch(() => null),
......@@ -848,6 +1213,7 @@ export default function App() {
let nextRuntime = latestRuntime;
if (nextRuntime && nextRuntime.selectedMode === "bundled-runtime" && nextRuntime.processState !== "running") {
reportStatus("start-runtime", ui.startingRuntime, ui.startingRuntimeDetail);
const startResult = await desktopApi.runtime.start().catch(() => null);
nextRuntime = await desktopApi.runtime.getStatus().catch(() => startResult ?? nextRuntime);
if (nextRuntime) {
......@@ -857,6 +1223,7 @@ export default function App() {
let nextGateway = latestGateway;
if (nextGateway?.state !== "connected") {
reportStatus("connect-gateway", ui.connectingGateway, ui.connectingGatewayDetail);
nextGateway = await desktopApi.gateway.reconnect().catch(() => desktopApi.gateway.connect().catch(() => null));
setGatewayStatus(nextGateway);
}
......@@ -886,14 +1253,23 @@ export default function App() {
return;
}
setSending(true);
setErrorText("");
try {
await ensureChatAvailable();
const skillId = requestedSkillId === DEFAULT_SKILL.id ? undefined : requestedSkillId;
const userMessage = buildUserMessage(trimmedPrompt);
const assistantMessage = buildAssistantPlaceholder();
const assistantMessage = buildAssistantPlaceholder(ui.preparingReply);
setSendPhase("preparing");
setErrorText("");
setPrompt("");
setSkillMenuOpen(false);
initializeMessageTrace(assistantMessage.id, {
id: createClientMessageId("trace"),
stage: "request",
label: ui.preparingReply,
tone: "info",
createdAt: new Date().toISOString()
});
setMessages((current) => [...current, userMessage, assistantMessage]);
setActiveSessionId(DEFAULT_SESSION_ID);
updateStreamSmoke(() => ({
phase: "requested",
......@@ -904,17 +1280,23 @@ export default function App() {
runId: undefined,
assistantMessageId: assistantMessage.id,
startedEventCount: 0,
statusEventCount: 0,
deltaEventCount: 0,
completedEventCount: 0,
errorEventCount: 0,
fallbackUsed: false,
renderedContent: "",
finalContent: ""
finalContent: "",
latestStatusLabel: ui.preparingReply
}));
setPrompt("");
setMessages((current) => [...current, userMessage, assistantMessage]);
setActiveSessionId(DEFAULT_SESSION_ID);
try {
const chatReadyAtSend = Boolean(workspace?.chatReady) && canExchangeMessages(runtimeStatus, gatewayStatus);
if (!chatReadyAtSend) {
await ensureChatAvailable(assistantMessage.id);
}
updateAssistantStatus(assistantMessage.id, ui.waitingReply);
appendTrace(assistantMessage.id, "await-model", ui.waitingReply);
try {
const stream = await desktopApi.chat.streamPrompt(DEFAULT_SESSION_ID, trimmedPrompt, skillId);
......@@ -935,11 +1317,17 @@ export default function App() {
} : current);
setActiveSessionId(stream.sessionId);
} catch {
setSendPhase("finalizing");
appendTrace(assistantMessage.id, "fallback", ui.fallbackReply);
updateAssistantStatus(assistantMessage.id, ui.generating);
await completeWithFallback(DEFAULT_SESSION_ID, trimmedPrompt, skillId, assistantMessage.id);
}
} catch (error) {
setSending(false);
setSendPhase("idle");
const message = err(error);
setMessageTraceExpanded(assistantMessage.id, true);
failPendingAssistant(assistantMessage.id, message);
appendTrace(assistantMessage.id, "error", "\u53d1\u9001\u5931\u8d25", message, "error");
updateStreamSmoke((current) => current ? {
...current,
phase: "error",
......@@ -958,6 +1346,22 @@ export default function App() {
await submitPrompt(prompt, skillId);
}
function chooseSkill(skillId: string) {
setSelectedSkillId(skillId);
setSkillMenuOpen(false);
}
function clearSelectedSkill() {
setSelectedSkillId(DEFAULT_SKILL.id);
setSkillMenuOpen(false);
}
async function retryStartup() {
setErrorText("");
await desktopApi.workspace.warmup().catch(() => undefined);
await refresh(false);
}
async function exportDiagnostics() {
setErrorText("");
setInfoText("");
......@@ -1004,6 +1408,7 @@ export default function App() {
<main className="content-area">
{viewMode === "chat" ? (
<section className="panel chat-panel">
<>
{showBindEntry ? (
<div className="bind-entry">
<div className="bind-entry-copy">
......@@ -1016,49 +1421,260 @@ export default function App() {
</div>
</div>
) : null}
{showChatStatusHint ? <div className={"inline-hint" + (chatLaunchState === "error" ? " error" : "")}>{chatStatusMessage}</div> : null}
<div className="message-list">
{messages.map((message) => (
{messages.map((message) => {
const showThinking = message.role === "assistant" && message.streamState === "streaming" && !message.content.trim();
const messageTrace = message.role === "assistant" ? messageTraces[message.id] : undefined;
const hasTrace = Boolean(messageTrace?.items.length);
const isTraceExpanded = Boolean(messageTrace?.expanded);
return (
<article key={message.id} className={"message-card " + message.role + (message.streamState ? " " + message.streamState : "")}>
<header><strong>{message.role === "assistant" ? ui.app : message.role === "user" ? "\u7528\u6237" : "\u7cfb\u7edf"}</strong></header>
<header><strong>{message.role === "assistant" ? ui.app : message.role === "user" ? ui.user : ui.system}</strong></header>
{showThinking ? (
<div className="thinking-indicator" aria-live="polite">
<span className="thinking-spinner" aria-hidden="true" />
<span className="thinking-label">{message.statusLabel ?? ui.thinking}</span>
</div>
) : message.content ? (
<p>
{message.content}
{message.streamState === "streaming" ? <span className="message-cursor" aria-hidden="true" /> : null}
</p>
</article>
) : null}
{hasTrace ? (
<div className="message-trace">
<button type="button" className="trace-inline-toggle" onClick={() => setMessageTraceExpanded(message.id, !isTraceExpanded)}>
{isTraceExpanded ? ui.hideTrace : ui.traceCollapsed}
</button>
{isTraceExpanded ? (
<div className="message-trace-content">
{messageTrace?.items.map((item) => (
<p key={item.id} className={"message-trace-line " + item.tone}>
<span className="message-trace-time">{new Date(item.createdAt).toLocaleTimeString("zh-CN", { hour12: false })}</span>
<span className="message-trace-text">{item.label}</span>
{item.detail ? <span className="message-trace-detail">{item.detail}</span> : null}
</p>
))}
</div>
) : null}
</div>
) : null}
</article>
);
})}
{!messages.length ? <div className="empty-state">{ui.noMessages}</div> : null}
</div>
<div className="composer-shell">
<div className="composer-meta">
<label className="skill-select">
<span className="field-label">{ui.skillChoice}</span>
<div className="skill-select-row">
<select value={selectedSkillId} disabled={!isBound} onChange={(event) => setSelectedSkillId(event.target.value)}>
{effectiveSkills.map((skill) => <option key={skill.id} value={skill.id}>{skill.name}</option>)}
</select>
<label className="composer-field">
<textarea value={prompt} disabled={!isBound} onChange={(event) => setPrompt(event.target.value)} placeholder={isBound ? ui.taskPlaceholder : ui.taskDisabledPlaceholder} />
</label>
<div className="composer-footer">
<div className="composer-left-tools" ref={skillMenuRef}>
<button type="button" className="skill-trigger" disabled={!isBound} aria-label={ui.skillMenuTitle} aria-expanded={skillMenuOpen} onClick={() => setSkillMenuOpen((current) => !current)}>
@
</button>
{selectedSkillId !== DEFAULT_SKILL.id ? (
<button type="button" className="secondary skill-clear-button" disabled={!isBound} onClick={() => setSelectedSkillId(DEFAULT_SKILL.id)}>{ui.clearSkill}</button>
<button type="button" className="skill-chip" disabled={!isBound} onClick={() => clearSelectedSkill()}>
{"@" + selectedSkill.name}
</button>
) : null}
{skillMenuOpen ? (
<div className="skill-menu" role="menu" aria-label={ui.skillMenuTitle}>
{effectiveSkills.map((skill) => {
const active = skill.id === selectedSkillId;
return (
<button
key={skill.id}
type="button"
role="menuitemradio"
aria-checked={active}
className={"skill-menu-item" + (active ? " active" : "")}
onClick={() => chooseSkill(skill.id)}
>
<strong>{skill.id === DEFAULT_SKILL.id ? ui.defaultChat : "@" + skill.name}</strong>
<span>{skill.description}</span>
</button>
);
})}
</div>
</label>
<p className="composer-hint">{isBound ? selectedSkill.description : ui.taskDisabledPlaceholder}</p>
) : null}
</div>
<label>
<span className="field-label">{selectedSkill.name}</span>
<textarea value={prompt} disabled={!isBound} onChange={(event) => setPrompt(event.target.value)} placeholder={isBound ? ui.taskPlaceholder : ui.taskDisabledPlaceholder} />
</label>
<div className="button-row composer-actions">
<button disabled={!canSend} onClick={() => void sendPrompt()}>{sendButtonLabel}</button>
</div>
</div>
</>
</section>
) : null}
{viewMode === "skills" ? <section className="panel catalog-list"><div className="scroll-panel">{catalogSkills.map((skill) => <button key={skill.id} type="button" className="catalog-item" disabled={!skill.ready} onClick={() => { if (!skill.ready) { return; } setSelectedSkillId(skill.id); setViewMode("chat"); }}><strong>{skill.name}</strong><p>{skill.description}</p><p>{getSkillStatusText(skill)}{skill.fileName ? ` - ${skill.fileName}` : ""}</p></button>)}{!catalogSkills.length ? <div className="empty-state">{ui.noSkillCards}</div> : null}</div></section> : null}
{viewMode === "plugins" ? <section className="panel catalog-list"><div className="section-head compact"><div><h3>{ui.pluginTitle}</h3></div></div><div className="scroll-panel">{workspace?.plugins.map((plugin) => { const copy = getPluginCopy(plugin); return <article key={plugin.id} className="catalog-item static"><strong>{copy.name}</strong><p>{copy.description}</p></article>; })}{!workspace?.plugins.length ? <div className="empty-state">{ui.noPlugins}</div> : null}</div></section> : null}
{viewMode === "settings" ? <div className="page-stack"><section className="panel settings-panel"><div className="section-head compact"><div><h3>{ui.settingsTitle}</h3><p>{ui.settingsDesc}</p></div><StatusChip tone={workspace?.apiKeyConfigured ? "positive" : "warning"}>{workspace?.apiKeyConfigured ? ui.bound : ui.unbound}</StatusChip></div><div className="form-grid single"><label>{ui.apiKey}<input type="password" value={apiKeyDraft} placeholder={workspace?.apiKeyConfigured ? ui.changeApiKey : ui.apiKeyPlaceholder} onChange={(event) => setApiKeyDraft(event.target.value)} /></label><label>{ui.workspacePath}<input value={workspacePathDraft} onChange={(event) => setWorkspacePathDraft(event.target.value)} /></label></div><div className="button-row"><button disabled={saving} onClick={() => void saveConfig(apiKeyDraft)}>{saving ? ui.saving : ui.save}</button></div><div className="mini-info"><span>{ui.currentBinding}</span><strong>{workspace?.apiKeyConfigured ? ui.bound : ui.unbound}</strong></div></section><section className="panel settings-panel"><div className="section-head compact"><div><h3>{ui.diagnostics}</h3><p>{ui.diagnosticsDesc}</p></div></div><div className="mini-info"><span>{ui.workspacePath}</span><strong>{config?.workspacePath || workspacePathDraft || ui.none}</strong></div><div className="button-row"><button className="secondary" onClick={() => void exportDiagnostics()}>{ui.export}</button></div></section></div> : null}
{viewMode === "settings" ? (
<div className="page-stack">
<section className="panel settings-panel">
<div className="section-head compact">
<div>
<h3>{ui.settingsTitle}</h3>
<p>{ui.settingsDesc}</p>
</div>
<StatusChip tone={workspace?.apiKeyConfigured ? "positive" : "warning"}>{workspace?.apiKeyConfigured ? ui.bound : ui.unbound}</StatusChip>
</div>
<div className="form-grid single">
<label>
????
<select value={setupModeDraft} onChange={(event) => setSetupModeDraft(event.target.value as SetupMode)}>
<option value="employee-key">????</option>
<option value="direct-provider">?????</option>
</select>
</label>
{setupModeDraft === "direct-provider" ? (
<>
<label>
??
<select value={providerDraft} onChange={(event) => setProviderDraft(event.target.value)}>
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
<option value="openai-compatible">OpenAI Compatible</option>
</select>
</label>
<label>
Base URL
<input value={baseUrlDraft} placeholder="https://api.openai.com/v1" onChange={(event) => setBaseUrlDraft(event.target.value)} />
</label>
<label>
????
<input value={defaultModelDraft} placeholder="gpt-5.4-mini" onChange={(event) => setDefaultModelDraft(event.target.value)} />
</label>
</>
) : null}
<label>
{setupModeDraft === "direct-provider" ? "API Key" : ui.apiKey}
<input type="password" value={apiKeyDraft} placeholder={setupModeDraft === "direct-provider" ? "????? API Key" : (workspace?.apiKeyConfigured ? ui.changeApiKey : ui.apiKeyPlaceholder)} onChange={(event) => setApiKeyDraft(event.target.value)} />
</label>
</div>
<div className="button-row settings-actions">
<button disabled={saving} onClick={() => void saveConfig(apiKeyDraft, setupModeDraft)}>{saving ? ui.saving : ui.save}</button>
</div>
{showSettingsStatusHint ? <div className={"inline-hint" + (chatLaunchState === "error" ? " error" : "")}>{startupMessage}</div> : null}
<div className="form-grid single">
<label>
{ui.workspacePath}
<input value={workspacePathDraft} onChange={(event) => setWorkspacePathDraft(event.target.value)} />
</label>
</div>
</section>
<section className="panel settings-panel">
<div className="section-head compact">
<div>
<h3>{ui.diagnostics}</h3>
<p>{ui.diagnosticsDesc}</p>
</div>
</div>
<div className="mini-info"><span>{ui.workspacePath}</span><strong>{config?.workspacePath || workspacePathDraft || ui.none}</strong></div>
<div className="button-row"><button className="secondary" onClick={() => void exportDiagnostics()}>{ui.export}</button></div>
</section>
</div>
) : null}
</main>
</div>
{showStartupOverlay ? (
<div className={"startup-overlay" + (chatLaunchState === "error" ? " error" : "")} role="dialog" aria-modal="true" aria-live="polite">
<div className="startup-overlay-panel">
<span className="startup-overlay-kicker">{startupCurtainCopy.kicker}</span>
<div className="startup-overlay-copy">
<h1>{startupCurtainCopy.brandTitle}</h1>
<p className="startup-overlay-subtitle">{startupCurtainCopy.brandSubtitle}</p>
<p className="startup-overlay-tagline">{startupCurtainCopy.brandTagline}</p>
</div>
{setupRequired ? (
<div className="startup-setup-shell">
<div className="startup-setup-tabs">
<button type="button" className={"startup-setup-tab" + (setupModeDraft === "employee-key" ? " active" : "")} onClick={() => setSetupModeDraft("employee-key")}>????</button>
<button type="button" className={"startup-setup-tab" + (setupModeDraft === "direct-provider" ? " active" : "")} onClick={() => setSetupModeDraft("direct-provider")}>?????</button>
</div>
<div className="startup-setup-form">
{setupModeDraft === "direct-provider" ? (
<>
<label>
<span className="field-label">??</span>
<select value={providerDraft} onChange={(event) => setProviderDraft(event.target.value)}>
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
<option value="openai-compatible">OpenAI Compatible</option>
</select>
</label>
<label>
<span className="field-label">Base URL</span>
<input value={baseUrlDraft} placeholder="https://api.openai.com/v1" onChange={(event) => setBaseUrlDraft(event.target.value)} />
</label>
<label>
<span className="field-label">????</span>
<input value={defaultModelDraft} placeholder="gpt-5.4-mini" onChange={(event) => setDefaultModelDraft(event.target.value)} />
</label>
</>
) : (
<p className="startup-setup-note">??????????Claw ?????????????????</p>
)}
<label>
<span className="field-label">{setupModeDraft === "direct-provider" ? "API Key" : ui.apiKey}</span>
<input type="password" value={apiKeyDraft} placeholder={setupModeDraft === "direct-provider" ? "????? API Key" : ui.apiKeyPlaceholder} onChange={(event) => setApiKeyDraft(event.target.value)} />
</label>
</div>
<div className="button-row startup-overlay-actions">
<button type="button" disabled={setupActionDisabled} onClick={() => void saveConfig(apiKeyDraft, setupModeDraft)}>{saving ? ui.preparing : "?????"}</button>
<button type="button" className="secondary" onClick={() => setViewMode("settings")}>{ui.openSettings}</button>
</div>
</div>
) : (
<>
<div className="startup-overlay-progress" aria-hidden="true">
<span style={{ width: String(Math.round(startupProgress * 100)) + "%" }} />
</div>
<div className="startup-overlay-status">
<strong>{startupCurtainStatus}</strong>
<span>{chatLaunchState === "error" ? (startupMessage || ui.chatNotReadyError) : startupCurtainFootnote}</span>
</div>
{chatLaunchState === "error" ? (
<div className="button-row startup-overlay-actions">
<button type="button" disabled={refreshing} onClick={() => void retryStartup()}>{refreshing ? ui.preparing : ui.startupRetry}</button>
<button type="button" className="secondary" onClick={() => setViewMode("settings")}>{ui.openSettings}</button>
</div>
) : null}
</>
)}
</div>
</div>
) : null}
</div>
);
}
......@@ -63,6 +63,8 @@ p, h1, h2, h3, strong, span { margin: 0; }
strong { font-weight: 600; }
.shell {
position: relative;
isolation: isolate;
height: 100vh;
min-height: 100vh;
display: grid;
......@@ -155,6 +157,207 @@ strong { font-weight: 600; }
overflow: hidden;
}
.startup-overlay {
position: fixed;
inset: 0;
z-index: 60;
display: flex;
align-items: center;
justify-content: center;
padding: 28px;
overflow: hidden;
background:
radial-gradient(circle at 16% 18%, rgba(90, 176, 255, 0.34), transparent 28%),
radial-gradient(circle at 84% 16%, rgba(255, 255, 255, 0.92), transparent 34%),
radial-gradient(circle at 50% 78%, rgba(190, 228, 255, 0.36), transparent 38%),
linear-gradient(145deg, #dff1ff 0%, #edf7ff 42%, #ffffff 100%);
}
.startup-overlay::before,
.startup-overlay::after {
content: "";
position: absolute;
border-radius: 999px;
filter: blur(18px);
opacity: 0.55;
pointer-events: none;
}
.startup-overlay::before {
width: 320px;
height: 320px;
top: -64px;
left: -48px;
background: rgba(103, 188, 255, 0.24);
}
.startup-overlay::after {
width: 420px;
height: 420px;
right: -120px;
bottom: -140px;
background: rgba(209, 237, 255, 0.7);
}
.startup-overlay-panel {
position: relative;
z-index: 1;
width: min(560px, 100%);
display: grid;
gap: 22px;
justify-items: center;
padding: 48px 42px;
text-align: center;
border-radius: 34px;
border: 1px solid rgba(255, 255, 255, 0.72);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.78), rgba(255, 255, 255, 0.58));
box-shadow: 0 28px 80px rgba(53, 90, 135, 0.16);
backdrop-filter: blur(16px);
animation: startup-overlay-enter 420ms ease-out both;
}
.startup-overlay.error .startup-overlay-panel {
border-color: rgba(255, 255, 255, 0.82);
box-shadow: 0 24px 72px rgba(53, 90, 135, 0.18);
}
.startup-overlay-kicker {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 34px;
padding: 0 16px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.64);
box-shadow: inset 0 0 0 1px rgba(114, 157, 201, 0.14);
color: #4687c9;
font-size: 12px;
letter-spacing: 0.18em;
text-transform: uppercase;
}
.startup-overlay-copy {
display: grid;
gap: 10px;
}
.startup-overlay-copy h1 {
font-size: clamp(40px, 8vw, 64px);
line-height: 1.04;
letter-spacing: -0.04em;
color: #16365f;
}
.startup-overlay-subtitle {
font-size: clamp(20px, 3.6vw, 28px);
line-height: 1.35;
color: #29507f;
}
.startup-overlay-tagline {
font-size: 15px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: #6094c8;
}
.startup-overlay-progress {
width: min(360px, 100%);
height: 7px;
overflow: hidden;
border-radius: 999px;
background: rgba(128, 174, 223, 0.18);
}
.startup-overlay-progress span {
display: block;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #69bcff 0%, #3f8cff 100%);
box-shadow: 0 0 18px rgba(63, 140, 255, 0.28);
transition: width 240ms ease;
}
.startup-overlay-status {
width: min(420px, 100%);
display: grid;
gap: 8px;
}
.startup-overlay-status strong {
color: #1f426d;
font-size: 16px;
line-height: 1.5;
}
.startup-overlay-status span {
color: #67819f;
font-size: 13px;
line-height: 1.7;
}
.startup-overlay-actions {
justify-content: center;
}
.startup-setup-shell {
width: min(420px, 100%);
display: grid;
gap: 14px;
}
.startup-setup-tabs {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.startup-setup-tab {
width: 100%;
min-width: 0;
padding: 10px 14px;
border-radius: 14px;
border: 1px solid rgba(152, 184, 220, 0.28);
background: rgba(255, 255, 255, 0.6);
color: #4673a0;
}
.startup-setup-tab.active {
border-color: rgba(63, 140, 255, 0.32);
background: rgba(233, 245, 255, 0.92);
color: #1f5d96;
}
.startup-setup-form {
display: grid;
gap: 12px;
text-align: left;
}
.startup-setup-form label {
display: grid;
gap: 6px;
}
.startup-setup-note {
margin: 0;
color: #67819f;
font-size: 13px;
line-height: 1.7;
text-align: left;
}
@keyframes startup-overlay-enter {
0% {
opacity: 0;
transform: translateY(18px) scale(0.985);
}
100% {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.panel,
.notice,
.empty-state,
......@@ -253,6 +456,7 @@ strong { font-weight: 600; }
border-color: rgba(239, 68, 68, 0.18);
color: #972f2f;
}
.settings-actions { justify-content: flex-start; }
.field-label { color: #53637f; font-size: 13px; }
.message-list,
......@@ -285,6 +489,28 @@ strong { font-weight: 600; }
margin-top: 6px;
}
.thinking-indicator {
display: inline-flex;
align-items: center;
gap: 10px;
padding-top: 6px;
color: #46607f;
}
.thinking-spinner {
width: 16px;
height: 16px;
border-radius: 999px;
border: 2px solid rgba(15, 123, 255, 0.18);
border-top-color: #0f7bff;
animation: spinner-rotate 0.8s linear infinite;
}
.thinking-label {
font-size: 14px;
font-weight: 600;
}
.message-cursor {
display: inline-block;
width: 8px;
......@@ -296,8 +522,198 @@ strong { font-weight: 600; }
animation: cursor-blink 1s steps(1, end) infinite;
}
.composer-shell {
.trace-summary-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 14px;
border-radius: 16px;
border: 1px solid #dbe5f1;
background: linear-gradient(180deg, rgba(246, 250, 255, 0.96), rgba(255, 255, 255, 0.96));
flex: 0 0 auto;
}
.trace-summary-bar.success {
border-color: rgba(16, 185, 129, 0.2);
background: rgba(236, 253, 245, 0.88);
}
.trace-summary-bar.error {
border-color: rgba(239, 68, 68, 0.24);
background: rgba(254, 242, 242, 0.92);
}
.trace-summary-copy {
min-width: 0;
display: grid;
gap: 2px;
}
.trace-summary-copy strong,
.trace-item-head strong,
.trace-drawer-copy strong {
color: #20304b;
}
.trace-summary-copy span,
.trace-summary-meta small,
.trace-note,
.trace-item-head span,
.trace-item p,
.trace-drawer-copy span {
color: #667794;
line-height: 1.6;
font-size: 13px;
margin: 0;
}
.trace-summary-copy span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.trace-summary-meta {
display: inline-flex;
align-items: center;
gap: 12px;
flex: 0 0 auto;
}
.trace-toggle {
min-width: 104px;
}
.trace-drawer-scrim {
position: fixed;
inset: 0;
border: 0;
background: rgba(19, 35, 58, 0.14);
backdrop-filter: blur(3px);
z-index: 45;
}
.trace-drawer {
position: fixed;
top: 18px;
right: 18px;
bottom: 18px;
width: min(420px, calc(100vw - 36px));
padding: 20px;
border-radius: 24px;
border: 1px solid rgba(219, 229, 241, 0.92);
background: rgba(255, 255, 255, 0.97);
box-shadow: 0 28px 80px rgba(33, 52, 84, 0.18);
z-index: 46;
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
gap: 14px;
}
.trace-drawer-head,
.trace-item-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.trace-drawer-copy {
display: grid;
gap: 4px;
}
.trace-list {
min-height: 0;
overflow: auto;
display: grid;
align-content: start;
gap: 10px;
padding-right: 2px;
}
.trace-item {
display: grid;
gap: 6px;
padding: 12px 14px;
border-radius: 14px;
border: 1px solid #dbe5f1;
background: #fff;
}
.trace-item.info {
border-color: rgba(15, 123, 255, 0.14);
}
.trace-item.success {
border-color: rgba(16, 185, 129, 0.18);
background: rgba(236, 253, 245, 0.82);
}
.trace-item.error {
border-color: rgba(239, 68, 68, 0.18);
background: rgba(254, 242, 242, 0.88);
}
.message-trace {
margin-top: 10px;
display: grid;
gap: 8px;
}
.trace-inline-toggle {
width: fit-content;
min-width: 0;
padding: 0;
border: 0;
background: transparent;
color: #4f6f98;
font-size: 13px;
line-height: 1.6;
}
.message-trace-content {
display: grid;
gap: 8px;
}
.message-trace-line {
margin: 0;
color: #667794;
font-size: 13px;
line-height: 1.75;
white-space: pre-wrap;
}
.message-trace-line.success {
color: #0f7f59;
}
.message-trace-line.error {
color: #972f2f;
}
.message-trace-time {
display: inline-block;
min-width: 56px;
margin-right: 8px;
color: #8ca0ba;
font-variant-numeric: tabular-nums;
}
.message-trace-text {
color: inherit;
}
.message-trace-detail {
display: block;
padding-left: 64px;
color: inherit;
}
.composer-shell {
gap: 12px;
padding: 14px;
border-radius: 16px;
border: 1px solid #dbe5f1;
......@@ -305,25 +721,92 @@ strong { font-weight: 600; }
flex: 0 0 auto;
}
.composer-meta {
.composer-field {
display: grid;
}
.composer-field textarea {
min-height: 124px;
}
.composer-footer {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 12px;
}
.skill-select {
min-width: 220px;
max-width: 300px;
.composer-left-tools {
position: relative;
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.composer-hint {
flex: 1 1 auto;
.skill-trigger,
.skill-chip {
min-width: 0;
padding: 8px 12px;
border-radius: 999px;
}
.skill-trigger {
width: 38px;
padding: 0;
font-size: 16px;
font-weight: 700;
}
.composer-actions {
justify-content: flex-end;
.skill-chip {
background: rgba(15, 123, 255, 0.08);
border-color: rgba(15, 123, 255, 0.16);
color: #1e568d;
}
.skill-menu {
position: absolute;
left: 0;
bottom: calc(100% + 10px);
z-index: 12;
width: min(320px, calc(100vw - 96px));
display: grid;
gap: 6px;
padding: 10px;
border-radius: 18px;
border: 1px solid #dbe5f1;
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 18px 40px rgba(35, 52, 82, 0.14);
}
.skill-menu-item {
width: 100%;
display: grid;
gap: 4px;
padding: 10px 12px;
text-align: left;
border-radius: 14px;
border: 1px solid transparent;
background: transparent;
}
.skill-menu-item strong {
color: #20304b;
}
.skill-menu-item span {
color: #667794;
font-size: 12px;
line-height: 1.6;
}
.skill-menu-item.active {
border-color: rgba(15, 123, 255, 0.16);
background: rgba(238, 245, 255, 0.94);
}
.catalog-item {
text-align: left;
display: grid;
gap: 8px;
......@@ -377,17 +860,27 @@ strong { font-weight: 600; }
}
}
@keyframes spinner-rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (max-width: 1100px) {
.hero-line { font-size: 21px; }
.composer-meta {
.composer-footer {
align-items: stretch;
flex-direction: column;
}
.skill-select {
min-width: 0;
max-width: none;
.composer-left-tools {
width: 100%;
}
.skill-menu {
width: min(100%, 320px);
}
}
@media (max-width: 960px) {
......@@ -408,6 +901,11 @@ strong { font-weight: 600; }
@media (max-width: 720px) {
.main-shell, .sidebar { padding: 16px; }
.startup-overlay { padding: 18px; }
.startup-overlay-panel { padding: 36px 22px; border-radius: 28px; }
.startup-overlay-kicker { letter-spacing: 0.14em; }
.startup-overlay-status { width: 100%; }
.startup-setup-tabs { grid-template-columns: 1fr; }
.page-topbar,
.header-actions,
.button-row,
......@@ -421,3 +919,13 @@ strong { font-weight: 600; }
.hero-line { font-size: 18px; }
.chat-panel { padding: 16px; }
}
@media (prefers-reduced-motion: reduce) {
.startup-overlay-panel,
.startup-overlay-progress span,
.toast-notice {
animation: none;
transition: none;
}
}
import { randomUUID } from "node:crypto";
import { randomUUID } from "node:crypto";
import WebSocket from "ws";
import type {
ChatMessage,
GatewayHealth,
GatewayStatus,
LogEntry,
MessageRole,
PromptResult,
SessionSummary
} from "@qjclaw/shared-types";
......@@ -48,6 +49,7 @@ interface PendingChatRun {
sessionKey: string;
accumulatedText: string;
onDelta?: (value: GatewayPromptStreamDelta) => void;
onStatus?: (value: GatewayPromptStreamStatus) => void;
onCompleted?: (value: { sessionId: string; runId: string; reply: ChatMessage }) => void;
onError?: (value: { sessionId: string; runId?: string; error: Error }) => void;
}
......@@ -100,7 +102,7 @@ interface SessionsListResult {
interface ChatHistoryResult {
sessionKey?: string;
messages?: Array<{
role?: "system" | "user" | "assistant";
role?: string;
timestamp?: number;
content?: Array<{ type?: string; text?: string }>;
}>;
......@@ -119,9 +121,18 @@ export interface GatewayPromptStreamDelta {
fullText?: string;
}
export interface GatewayPromptStreamStatus {
sessionId: string;
runId?: string;
stage: string;
label: string;
detail?: string;
}
export interface GatewayPromptStreamHandlers {
onStarted?: (value: GatewayPromptStreamStart) => void;
onDelta?: (value: GatewayPromptStreamDelta) => void;
onStatus?: (value: GatewayPromptStreamStatus) => void;
onCompleted?: (value: { sessionId: string; runId: string; reply: ChatMessage }) => void;
onError?: (value: { sessionId: string; runId?: string; error: Error }) => void;
}
......@@ -410,7 +421,7 @@ export class GatewayClient {
const result = (await this.request("chat.history", { sessionKey, limit: 100 })) as ChatHistoryResult;
const messages = (result.messages ?? []).map((message, messageIndex) => ({
id: `${sessionKey}:${message.timestamp ?? messageIndex}:${messageIndex}`,
role: message.role ?? "assistant",
role: this.normalizeChatRole(message.role),
content: this.flattenContent(message.content),
createdAt: new Date(message.timestamp ?? Date.now()).toISOString()
}));
......@@ -492,7 +503,8 @@ export class GatewayClient {
if (runId) {
this.emitChatDelta(runId, payload);
if (state === "final") {
this.completeChatRun(runId, this.buildChatMessage(runId, payload));
const reply = await this.resolveCompletedChatReply(runId, payload);
this.completeChatRun(runId, reply);
}
}
}
......@@ -511,6 +523,11 @@ export class GatewayClient {
if (runId) {
this.emitChatDelta(runId, payload.data ?? payload);
}
} else if (runId) {
const status = this.describeAgentStatus(payload, stream);
if (status) {
this.emitChatStatus(runId, status);
}
}
}
......@@ -614,6 +631,7 @@ export class GatewayClient {
sessionKey,
accumulatedText: "",
onDelta: handlers.onDelta,
onStatus: handlers.onStatus,
onCompleted: handlers.onCompleted,
onError: handlers.onError
});
......@@ -679,6 +697,80 @@ export class GatewayClient {
});
}
private emitChatStatus(runId: string, status: Omit<GatewayPromptStreamStatus, "sessionId" | "runId">): void {
const pending = this.pendingChatRuns.get(runId);
if (!pending) {
return;
}
this.refreshChatRunTimer(runId);
pending.onStatus?.({
sessionId: pending.sessionKey,
runId,
...status
});
}
private async resolveCompletedChatReply(runId: string, payload: Record<string, unknown>): Promise<ChatMessage> {
const reply = this.buildChatMessage(runId, payload);
if (reply.content.trim()) {
return reply;
}
const pending = this.pendingChatRuns.get(runId);
if (!pending) {
return reply;
}
try {
const history = await this.listMessages(pending.sessionKey);
const assistant = [...history].reverse().find((message) => message.role === "assistant" && message.content.trim());
if (assistant) {
return assistant;
}
} catch {
}
return reply;
}
private describeAgentStatus(payload: Record<string, unknown>, stream: string): Omit<GatewayPromptStreamStatus, "sessionId" | "runId"> | null {
const normalizedStage = stream.trim().toLowerCase();
const toolName = this.findStringDeep(payload, ["toolName", "tool_name", "name"]);
const detail = this.extractTextCandidate(payload.data) ?? this.extractTextCandidate(payload);
const label = this.buildStatusLabel(normalizedStage, toolName);
const compactDetail = detail && detail !== label ? detail.slice(0, 240) : undefined;
if (!label && !compactDetail) {
return null;
}
return {
stage: stream,
label: label || "\u6b63\u5728\u6574\u7406\u4e2d\u95f4\u7ed3\u679c",
detail: compactDetail
};
}
private buildStatusLabel(stage: string, toolName?: string): string {
if (stage.includes("reason") || stage.includes("think")) {
return "\u6b63\u5728\u7406\u89e3\u4f60\u7684\u95ee\u9898";
}
if (stage.includes("tool") && stage.includes("result")) {
return toolName ? `${toolName} \u5df2\u8fd4\u56de\u4fe1\u606f` : "\u5df2\u62ff\u5230\u8865\u5145\u4fe1\u606f";
}
if (stage.includes("tool") || stage.includes("call")) {
return toolName ? `\u6b63\u5728\u8c03\u7528 ${toolName}` : "\u6b63\u5728\u8c03\u7528\u8f85\u52a9\u80fd\u529b";
}
if (stage.includes("search") || stage.includes("fetch") || stage.includes("browser") || stage.includes("web")) {
return toolName ? `\u6b63\u5728\u901a\u8fc7 ${toolName} \u67e5\u627e\u4fe1\u606f` : "\u6b63\u5728\u67e5\u627e\u8865\u5145\u4fe1\u606f";
}
if (stage.includes("plan") || stage.includes("route")) {
return "\u6b63\u5728\u5b89\u6392\u5904\u7406\u6b65\u9aa4";
}
return toolName ? `\u6b63\u5728\u6574\u7406 ${toolName} \u8fd4\u56de\u7684\u4fe1\u606f` : "\u6b63\u5728\u6574\u7406\u4e2d\u95f4\u7ed3\u679c";
}
private completeChatRun(runId: string, reply: ChatMessage): void {
const pending = this.pendingChatRuns.get(runId);
if (!pending) {
......@@ -717,6 +809,13 @@ export class GatewayClient {
};
}
private normalizeChatRole(role: unknown): MessageRole {
if (role === "system" || role === "user" || role === "assistant" || role === "tool" || role === "toolResult") {
return role;
}
return "system";
}
private extractTextCandidate(value: unknown): string | undefined {
if (typeof value === "string") {
const normalized = value.replace(/\r\n/g, "\n");
......@@ -993,7 +1092,7 @@ export class GatewayClient {
private stripStructuredLogPrefix(message: string): string {
return message
.replace(LOG_PREFIX_PATTERN, "")
.replace(/闁跨喓绁?/g, "ok ")
.replace(/闂佽法鍠撶粊?/g, "ok ")
.replace(/[?]{2,}/g, "")
.replace(/\s+/g, " ")
.trim();
......@@ -1043,3 +1142,6 @@ export class GatewayClient {
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment