@brainpilot/web 0.0.4 → 0.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/index-Br55rkHb.css +1 -0
- package/dist/assets/index-CeUzk-ej.js +445 -0
- package/dist/index.html +2 -2
- package/index.html +13 -0
- package/package.json +12 -3
- package/src/App.tsx +10 -0
- package/src/__tests__/agentsReducer.test.ts +67 -0
- package/src/__tests__/api.test.ts +221 -0
- package/src/__tests__/chatScrollMemory.test.ts +49 -0
- package/src/__tests__/demoConversation.test.ts +73 -0
- package/src/__tests__/demoReset.test.ts +24 -0
- package/src/__tests__/messageGroups.test.ts +80 -0
- package/src/__tests__/newUiComponents.test.tsx +101 -0
- package/src/__tests__/newUiEvents.test.ts +236 -0
- package/src/__tests__/runningToast.test.ts +29 -0
- package/src/__tests__/tokenUsage.test.ts +48 -0
- package/src/__tests__/toolDisplay.test.ts +55 -0
- package/src/__tests__/traceReducer.test.ts +62 -0
- package/src/components/chat/AskUserCard.tsx +123 -0
- package/src/components/chat/AutoRetryIndicator.tsx +71 -0
- package/src/components/chat/ComposerInput.tsx +73 -0
- package/src/components/chat/ComposerSendButton.tsx +26 -0
- package/src/components/chat/MarkdownMessage.tsx +24 -0
- package/src/components/chat/MessageStream.tsx +505 -0
- package/src/components/chat/PromptComposer.tsx +489 -0
- package/src/components/chat/SystemMessageBubble.tsx +46 -0
- package/src/components/chat/chatScrollMemory.ts +49 -0
- package/src/components/demo/DemoFileTree.tsx +146 -0
- package/src/components/demo/DemoView.tsx +730 -0
- package/src/components/demo/TraceNodeModal.tsx +80 -0
- package/src/components/demo/demoBundle.ts +223 -0
- package/src/components/demo/demoCache.ts +42 -0
- package/src/components/demo/demoReset.ts +16 -0
- package/src/components/files/FilePreviewView.tsx +153 -0
- package/src/components/files/FileSidebar.tsx +664 -0
- package/src/components/files/filePreview.ts +113 -0
- package/src/components/primitives/CustomSelect.tsx +200 -0
- package/src/components/primitives/IconButton.tsx +27 -0
- package/src/components/quota/DiskQuotaCriticalDialog.tsx +56 -0
- package/src/components/quota/DiskQuotaWarningDialog.tsx +65 -0
- package/src/components/quota/QuotaFileManager.tsx +197 -0
- package/src/components/search/SearchDialog.tsx +101 -0
- package/src/components/session/AgentNetwork.tsx +1233 -0
- package/src/components/session/AgentTraceViews.tsx +346 -0
- package/src/components/session/AnalyticsTab.tsx +220 -0
- package/src/components/session/GlobalOverview.tsx +108 -0
- package/src/components/session/NodeTooltip.tsx +127 -0
- package/src/components/session/TimelineTab.tsx +320 -0
- package/src/components/session/TraceGraphView.tsx +307 -0
- package/src/components/session/TraceNodeDetail.tsx +179 -0
- package/src/components/session/agentAnalytics.ts +397 -0
- package/src/components/session/agentNetworkShared.ts +339 -0
- package/src/components/session/traceLayout.ts +182 -0
- package/src/components/settings/SettingsDialog.tsx +737 -0
- package/src/components/shell/DesktopShell.tsx +261 -0
- package/src/components/shell/SandboxBuildingOverlay.tsx +73 -0
- package/src/components/shell/SandboxStatus.tsx +287 -0
- package/src/components/shell/TerminalDrawer.tsx +387 -0
- package/src/components/sidebar/Sidebar.tsx +191 -0
- package/src/config.ts +10 -0
- package/src/contexts/AppProviders.tsx +20 -0
- package/src/contexts/AuthContext.tsx +61 -0
- package/src/contexts/PreferencesContext.tsx +125 -0
- package/src/contexts/SSEContext.tsx +264 -0
- package/src/contexts/SandboxContext.tsx +310 -0
- package/src/contexts/SessionContext.tsx +919 -0
- package/src/contexts/agentsReducer.ts +49 -0
- package/src/contexts/draftStore.ts +103 -0
- package/src/contexts/messageFilters.ts +29 -0
- package/src/contexts/messageGroups.ts +77 -0
- package/src/contexts/messageReducer.ts +401 -0
- package/src/contexts/newUiEvents.ts +190 -0
- package/src/contexts/runningToast.ts +33 -0
- package/src/contexts/traceReducer.ts +62 -0
- package/src/contexts/turnTimer.test.ts +97 -0
- package/src/contexts/turnTimer.ts +108 -0
- package/src/contexts/useTurnTimer.ts +104 -0
- package/src/contracts/backend.ts +897 -0
- package/src/contracts/demoBundle.ts +83 -0
- package/src/i18n/messages/analytics.ts +106 -0
- package/src/i18n/messages/chat.ts +130 -0
- package/src/i18n/messages/contexts.ts +42 -0
- package/src/i18n/messages/demo.ts +80 -0
- package/src/i18n/messages/files.ts +82 -0
- package/src/i18n/messages/network.ts +190 -0
- package/src/i18n/messages/profile.ts +44 -0
- package/src/i18n/messages/quota.ts +36 -0
- package/src/i18n/messages/sandbox.ts +116 -0
- package/src/i18n/messages/search.ts +16 -0
- package/src/i18n/messages/settings.ts +188 -0
- package/src/i18n/messages/shell.ts +38 -0
- package/src/i18n/messages/sidebar.ts +52 -0
- package/src/i18n/messages/terminal.ts +22 -0
- package/src/i18n/messages/trace.ts +136 -0
- package/src/i18n/messages.ts +32 -0
- package/src/i18n/translate.ts +46 -0
- package/src/i18n/types.ts +15 -0
- package/src/i18n/useT.ts +15 -0
- package/src/main.tsx +13 -0
- package/src/mocks/backend.ts +729 -0
- package/src/styles/global.css +7578 -0
- package/src/styles/tokens.css +161 -0
- package/src/utils/api.ts +724 -0
- package/src/utils/download.ts +18 -0
- package/src/utils/format.ts +7 -0
- package/src/utils/toolDisplay.ts +74 -0
- package/src/utils/zip.ts +119 -0
- package/src/vite-env.d.ts +1 -0
- package/tsconfig.app.json +22 -0
- package/tsconfig.json +7 -0
- package/tsconfig.node.json +13 -0
- package/vite.config.ts +13 -0
- package/dist/assets/index-Cd0Mi_WU.css +0 -1
- package/dist/assets/index-FGg-DeYR.js +0 -448
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
import { Bot, Paperclip, Square, X } from "lucide-react";
|
|
2
|
+
import { FormEvent, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
3
|
+
import type { ProviderProfile } from "../../contracts/backend";
|
|
4
|
+
import { useSandbox } from "../../contexts/SandboxContext";
|
|
5
|
+
import { DRAFT_SESSION_ID, useSessions } from "../../contexts/SessionContext";
|
|
6
|
+
import { useTurnTimer } from "../../contexts/useTurnTimer";
|
|
7
|
+
import { draftStore } from "../../contexts/draftStore";
|
|
8
|
+
import { applyMessageFilters } from "../../contexts/messageFilters";
|
|
9
|
+
import { runningToastLabel } from "../../contexts/runningToast";
|
|
10
|
+
import { useT } from "../../i18n/useT";
|
|
11
|
+
import { api } from "../../utils/api";
|
|
12
|
+
import { CustomSelect } from "../primitives/CustomSelect";
|
|
13
|
+
import { IconButton } from "../primitives/IconButton";
|
|
14
|
+
import { ComposerInput } from "./ComposerInput";
|
|
15
|
+
import { ComposerSendButton } from "./ComposerSendButton";
|
|
16
|
+
import { MessageStream } from "./MessageStream";
|
|
17
|
+
|
|
18
|
+
export function PromptComposer() {
|
|
19
|
+
const t = useT();
|
|
20
|
+
const [suggestedTasks, setSuggestedTasks] = useState<string[]>([]);
|
|
21
|
+
const [activeProvider, setActiveProvider] = useState<ProviderProfile | null>(null);
|
|
22
|
+
const [selectedModel, setSelectedModel] = useState("");
|
|
23
|
+
// 可用命令(已通过真实 API 测试 /context ✅ /cost ✅;/compact 由 SDK 内置 ✅)
|
|
24
|
+
// 不可用命令(已移除):/usage ❌ /clear ❌ /init ❌
|
|
25
|
+
const DEFAULT_SLASH_COMMANDS = ["/compact", "/context", "/cost"];
|
|
26
|
+
// issue #43: temporarily hide the whole slash-command button until the
|
|
27
|
+
// dynamic command list (GET /sessions/:id/commands) is implemented backend
|
|
28
|
+
// side. Flip to true to restore. Code below is kept intact for that.
|
|
29
|
+
const SHOW_SLASH_COMMANDS = false;
|
|
30
|
+
const [slashCommands, setSlashCommands] = useState<string[]>(DEFAULT_SLASH_COMMANDS);
|
|
31
|
+
|
|
32
|
+
const [showCommands, setShowCommands] = useState(false);
|
|
33
|
+
const commandsRef = useRef<HTMLDivElement | null>(null);
|
|
34
|
+
const menuRef = useRef<HTMLDivElement | null>(null);
|
|
35
|
+
const [menuPos, setMenuPos] = useState<{ top: number; left: number } | null>(null);
|
|
36
|
+
// #47: file upload — names of files uploaded into the workspace this turn,
|
|
37
|
+
// shown as removable chips and announced to the agent on send.
|
|
38
|
+
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
|
39
|
+
const [attachments, setAttachments] = useState<string[]>([]);
|
|
40
|
+
const [uploading, setUploading] = useState(false);
|
|
41
|
+
const { status: sandboxStatus, currentSandbox, reloadConfig } = useSandbox();
|
|
42
|
+
const [composerError, setComposerError] = useState<string | null>(null);
|
|
43
|
+
const { currentSession, messages, isSending, error, sendPrompt, isConnected, isDraft, agents, runActive, agentFilters, interruptCurrent, respondToInput, messageFilters } = useSessions();
|
|
44
|
+
// In draft mode there's no session/connection yet — allow composing so the
|
|
45
|
+
// first send can create + connect the session.
|
|
46
|
+
const canSend = sandboxStatus === "running" && !isSending && (isConnected || isDraft);
|
|
47
|
+
|
|
48
|
+
const visibleMessages = useMemo(() => {
|
|
49
|
+
const agentFiltered = messages.filter((msg) => {
|
|
50
|
+
if (msg.role === "user") return true;
|
|
51
|
+
const agent = msg.agent || "principal";
|
|
52
|
+
const filters = agentFilters[agent];
|
|
53
|
+
if (!filters) return true;
|
|
54
|
+
// "隐藏消息" 只隐藏普通消息,不碰 tool / hook
|
|
55
|
+
if (filters.hideMessages && msg.kind !== "tool" && msg.kind !== "hook") return false;
|
|
56
|
+
// "隐藏工具调用" 只隐藏 tool
|
|
57
|
+
if (filters.hideTools && msg.kind === "tool") return false;
|
|
58
|
+
// "隐藏 Hooks" 只隐藏 hook
|
|
59
|
+
if (filters.hideHooks && msg.kind === "hook") return false;
|
|
60
|
+
return true;
|
|
61
|
+
});
|
|
62
|
+
return applyMessageFilters(agentFiltered, messageFilters);
|
|
63
|
+
}, [messages, agentFilters, messageFilters]);
|
|
64
|
+
|
|
65
|
+
const hasMessages = visibleMessages.length > 0;
|
|
66
|
+
const isAgentRunning = agents.some((a) => a.status === "running");
|
|
67
|
+
const lastAssistantStreaming = visibleMessages[visibleMessages.length - 1]?.role === "assistant" && visibleMessages[visibleMessages.length - 1]?.streaming;
|
|
68
|
+
|
|
69
|
+
// Agents whose run is still active. Threaded to MessageStream so a folded
|
|
70
|
+
// activity block stays "in progress" across ReAct rounds — without this, the
|
|
71
|
+
// per-message streaming flags all clear between rounds and the block flashes
|
|
72
|
+
// "完成思考" in the gap. Memoized so its identity is stable for MessageStream's
|
|
73
|
+
// memo() (a fresh Set each render would defeat the memoization).
|
|
74
|
+
const runningAgents = useMemo(
|
|
75
|
+
() => new Set(agents.filter((a) => a.status === "running").map((a) => a.name)),
|
|
76
|
+
[agents],
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
// Names of agents actively working, for the "X 正在工作" toast. Excludes the
|
|
80
|
+
// trace agent (it self-records continuously and isn't "the user's task"),
|
|
81
|
+
// matching the runtime's run-active aggregation (#76).
|
|
82
|
+
const workingAgentNames = useMemo(
|
|
83
|
+
() => agents.filter((a) => a.status === "running" && a.name !== "trace").map((a) => a.name),
|
|
84
|
+
[agents],
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
let cancelled = false;
|
|
89
|
+
void api.ui.promptSuggestions().then((suggestions) => {
|
|
90
|
+
if (!cancelled) {
|
|
91
|
+
setSuggestedTasks(suggestions);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
return () => {
|
|
95
|
+
cancelled = true;
|
|
96
|
+
};
|
|
97
|
+
}, []);
|
|
98
|
+
|
|
99
|
+
// issue #43: the dynamic slash-command list (GET /sessions/:id/commands) is
|
|
100
|
+
// not implemented on the backend yet — fetching it 404'd on every selected
|
|
101
|
+
// session. The whole slash-command button is hidden below until that lands,
|
|
102
|
+
// so we no longer fetch and just keep the local DEFAULT_SLASH_COMMANDS.
|
|
103
|
+
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
const handleClickOutside = (event: MouseEvent) => {
|
|
106
|
+
if (commandsRef.current && !commandsRef.current.contains(event.target as Node)) {
|
|
107
|
+
setShowCommands(false);
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
document.addEventListener("mousedown", handleClickOutside);
|
|
111
|
+
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
112
|
+
}, []);
|
|
113
|
+
|
|
114
|
+
useLayoutEffect(() => {
|
|
115
|
+
if (!showCommands || !commandsRef.current || !menuRef.current) return;
|
|
116
|
+
const buttonRect = commandsRef.current.getBoundingClientRect();
|
|
117
|
+
const menuRect = menuRef.current.getBoundingClientRect();
|
|
118
|
+
setMenuPos({
|
|
119
|
+
top: buttonRect.top - menuRect.height - 6,
|
|
120
|
+
left: buttonRect.left,
|
|
121
|
+
});
|
|
122
|
+
}, [showCommands]);
|
|
123
|
+
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
if (!showCommands) return;
|
|
126
|
+
const handleClose = () => setShowCommands(false);
|
|
127
|
+
window.addEventListener("resize", handleClose);
|
|
128
|
+
return () => {
|
|
129
|
+
window.removeEventListener("resize", handleClose);
|
|
130
|
+
};
|
|
131
|
+
}, [showCommands]);
|
|
132
|
+
|
|
133
|
+
// Textarea autoresize, key handling, and draft state moved to ComposerInput,
|
|
134
|
+
// which owns the textarea ref and subscribes to draftStore directly.
|
|
135
|
+
// PromptComposer no longer re-renders on keystrokes — that's the whole point
|
|
136
|
+
// of the split.
|
|
137
|
+
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
let cancelled = false;
|
|
140
|
+
const loadProviderAndSettings = async () => {
|
|
141
|
+
try {
|
|
142
|
+
const [providerRes, settings, healthProfiles] = await Promise.all([
|
|
143
|
+
api.providers.getActive(),
|
|
144
|
+
api.settings.get(),
|
|
145
|
+
api.providers.health().catch(() => [] as ProviderProfile[]),
|
|
146
|
+
]);
|
|
147
|
+
if (cancelled) {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
let provider = providerRes;
|
|
151
|
+
if (provider && healthProfiles.length > 0) {
|
|
152
|
+
const activeId = provider.id;
|
|
153
|
+
const hp = healthProfiles.find((p) => p.id === activeId);
|
|
154
|
+
if (hp) {
|
|
155
|
+
provider = { ...provider, healthStatus: hp.healthStatus, healthCheckedAt: hp.healthCheckedAt, modelHealth: hp.modelHealth };
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
setActiveProvider(provider);
|
|
159
|
+
setSelectedModel((current) => {
|
|
160
|
+
if (current && provider?.models.includes(current)) {
|
|
161
|
+
return current;
|
|
162
|
+
}
|
|
163
|
+
if (settings.model && provider?.models.includes(settings.model)) {
|
|
164
|
+
return settings.model;
|
|
165
|
+
}
|
|
166
|
+
return provider?.models[0] ?? "";
|
|
167
|
+
});
|
|
168
|
+
} catch {
|
|
169
|
+
if (!cancelled) {
|
|
170
|
+
setActiveProvider(null);
|
|
171
|
+
setSelectedModel("");
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
void loadProviderAndSettings();
|
|
176
|
+
window.addEventListener("provider-profiles-updated", loadProviderAndSettings);
|
|
177
|
+
return () => {
|
|
178
|
+
cancelled = true;
|
|
179
|
+
window.removeEventListener("provider-profiles-updated", loadProviderAndSettings);
|
|
180
|
+
};
|
|
181
|
+
}, []);
|
|
182
|
+
|
|
183
|
+
useEffect(() => {
|
|
184
|
+
const refreshProvider = async () => {
|
|
185
|
+
try {
|
|
186
|
+
const [providerRes, healthProfiles] = await Promise.all([
|
|
187
|
+
api.providers.getActive(),
|
|
188
|
+
api.providers.health().catch(() => [] as ProviderProfile[]),
|
|
189
|
+
]);
|
|
190
|
+
let provider = providerRes;
|
|
191
|
+
if (provider && healthProfiles.length > 0) {
|
|
192
|
+
const providerId = provider.id;
|
|
193
|
+
const hp = healthProfiles.find((p) => p.id === providerId);
|
|
194
|
+
if (hp) {
|
|
195
|
+
provider = { ...provider, healthStatus: hp.healthStatus, healthCheckedAt: hp.healthCheckedAt, modelHealth: hp.modelHealth };
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
setActiveProvider(provider);
|
|
199
|
+
setSelectedModel((current) => {
|
|
200
|
+
if (current && provider?.models.includes(current)) {
|
|
201
|
+
return current;
|
|
202
|
+
}
|
|
203
|
+
return provider?.models[0] ?? "";
|
|
204
|
+
});
|
|
205
|
+
} catch {
|
|
206
|
+
// ignore silent refresh errors
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
const id = window.setInterval(() => void refreshProvider(), 30000);
|
|
210
|
+
return () => window.clearInterval(id);
|
|
211
|
+
}, []);
|
|
212
|
+
|
|
213
|
+
const sessionId = currentSession?.id ?? (isDraft ? DRAFT_SESSION_ID : null);
|
|
214
|
+
|
|
215
|
+
// #99: whole-turn timer — spans user input → every agent finished (runState
|
|
216
|
+
// settles false), debounced against hook/system re-wakes.
|
|
217
|
+
const turnTiming = useTurnTimer({ runActive, resetKey: currentSession?.id ?? null });
|
|
218
|
+
|
|
219
|
+
const handleSubmit = async (event: FormEvent) => {
|
|
220
|
+
event.preventDefault();
|
|
221
|
+
if (!sessionId) return;
|
|
222
|
+
const content = draftStore.get(sessionId).trim();
|
|
223
|
+
if (!content || !canSend) {
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
draftStore.set(sessionId, "");
|
|
227
|
+
// #47: if files were uploaded this turn, prepend a notice so the agent knows
|
|
228
|
+
// they exist in its workspace and can `read` them. Cleared after send.
|
|
229
|
+
const notice =
|
|
230
|
+
attachments.length > 0 ? `${t("chat.upload.notice", { names: attachments.join(", ") })}\n\n` : "";
|
|
231
|
+
const sentAttachments = attachments;
|
|
232
|
+
if (attachments.length > 0) setAttachments([]);
|
|
233
|
+
// Carry the chosen provider/model so a freshly-created session records its
|
|
234
|
+
// per-session selection (no-op for an already-running session).
|
|
235
|
+
const ok = await sendPrompt(`${notice}${content}`, {
|
|
236
|
+
providerId: activeProvider?.id,
|
|
237
|
+
modelId: selectedModel || undefined,
|
|
238
|
+
});
|
|
239
|
+
// #106: a failed/timed-out send must not silently eat the user's input.
|
|
240
|
+
// Restore the draft (and attachment chips) so they can retry without
|
|
241
|
+
// retyping. Only restore if they haven't already started typing again.
|
|
242
|
+
if (!ok) {
|
|
243
|
+
if (draftStore.get(sessionId).trim().length === 0) {
|
|
244
|
+
draftStore.set(sessionId, content);
|
|
245
|
+
}
|
|
246
|
+
if (sentAttachments.length > 0) {
|
|
247
|
+
setAttachments((prev) => (prev.length === 0 ? sentAttachments : prev));
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
// #47: upload the chosen files into the session workspace, then track their
|
|
253
|
+
// names as chips. Uses the current sandbox/session id (single-user: same id).
|
|
254
|
+
const handleFilesChosen = async (files: FileList | null) => {
|
|
255
|
+
if (!files || files.length === 0) return;
|
|
256
|
+
const sandboxId = currentSandbox?.id;
|
|
257
|
+
if (!sandboxId) return;
|
|
258
|
+
setUploading(true);
|
|
259
|
+
setComposerError(null);
|
|
260
|
+
try {
|
|
261
|
+
for (const file of Array.from(files)) {
|
|
262
|
+
await api.sandbox.uploadFile(sandboxId, file.name, file);
|
|
263
|
+
setAttachments((prev) => (prev.includes(file.name) ? prev : [...prev, file.name]));
|
|
264
|
+
}
|
|
265
|
+
} catch (e) {
|
|
266
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
267
|
+
setComposerError(t("chat.upload.failed", { msg }));
|
|
268
|
+
} finally {
|
|
269
|
+
setUploading(false);
|
|
270
|
+
if (fileInputRef.current) fileInputRef.current.value = ""; // allow re-selecting the same file
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
// Writes to the draft store from non-text controls (slash command picks,
|
|
275
|
+
// suggestion cards). PromptComposer never reads the draft, so these don't
|
|
276
|
+
// pull it onto the keystroke render path.
|
|
277
|
+
const setDraftFor = (value: string) => {
|
|
278
|
+
if (sessionId) draftStore.set(sessionId, value);
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
return (
|
|
282
|
+
<section className={`prompt-home ${hasMessages ? "prompt-home--active" : ""}`} aria-labelledby="prompt-heading">
|
|
283
|
+
<div className="prompt-home__inner">
|
|
284
|
+
{hasMessages ? null : <h1 id="prompt-heading">{currentSession?.title ?? t("chat.heading")}</h1>}
|
|
285
|
+
|
|
286
|
+
{hasMessages ? (
|
|
287
|
+
<MessageStream
|
|
288
|
+
messages={visibleMessages}
|
|
289
|
+
autoScroll
|
|
290
|
+
scrollKey={sessionId ?? undefined}
|
|
291
|
+
showTiming
|
|
292
|
+
turnTiming={turnTiming}
|
|
293
|
+
runningAgents={runningAgents}
|
|
294
|
+
onAskUserSubmit={(requestId, answer) => void respondToInput(requestId, answer)}
|
|
295
|
+
onRetryCancel={() => void interruptCurrent()}
|
|
296
|
+
/>
|
|
297
|
+
) : null}
|
|
298
|
+
|
|
299
|
+
{isAgentRunning || lastAssistantStreaming ? (
|
|
300
|
+
<div className="agent-running-toast" role="status" aria-live="polite">
|
|
301
|
+
<span className="agent-running-toast__dot" />
|
|
302
|
+
<span className="agent-running-toast__label">
|
|
303
|
+
{(() => {
|
|
304
|
+
const label = runningToastLabel(workingAgentNames);
|
|
305
|
+
return t(label.key, label.vars);
|
|
306
|
+
})()}
|
|
307
|
+
</span>
|
|
308
|
+
<button
|
|
309
|
+
className="agent-running-toast__stop"
|
|
310
|
+
type="button"
|
|
311
|
+
onClick={() => void interruptCurrent()}
|
|
312
|
+
aria-label={t("chat.aria.stop")}
|
|
313
|
+
title={t("chat.aria.stop")}
|
|
314
|
+
>
|
|
315
|
+
<Square size={10} fill="currentColor" />
|
|
316
|
+
<span>{t("chat.stop")}</span>
|
|
317
|
+
</button>
|
|
318
|
+
</div>
|
|
319
|
+
) : null}
|
|
320
|
+
|
|
321
|
+
<form className="composer" aria-label={t("chat.aria.newPrompt")} onSubmit={handleSubmit}>
|
|
322
|
+
<ComposerInput
|
|
323
|
+
sessionId={sessionId}
|
|
324
|
+
placeholder={t("chat.placeholder")}
|
|
325
|
+
ariaLabel={t("chat.srAsk")}
|
|
326
|
+
/>
|
|
327
|
+
|
|
328
|
+
{attachments.length > 0 || uploading ? (
|
|
329
|
+
<div className="composer__attachments" aria-label={t("chat.aria.attachFile")}>
|
|
330
|
+
{attachments.map((name) => (
|
|
331
|
+
<span className="composer__chip" key={name}>
|
|
332
|
+
<Paperclip size={12} />
|
|
333
|
+
<span className="composer__chip-name">{name}</span>
|
|
334
|
+
<button
|
|
335
|
+
type="button"
|
|
336
|
+
className="composer__chip-remove"
|
|
337
|
+
aria-label={t("chat.aria.removeAttachment")}
|
|
338
|
+
onClick={() => setAttachments((prev) => prev.filter((n) => n !== name))}
|
|
339
|
+
>
|
|
340
|
+
<X size={12} />
|
|
341
|
+
</button>
|
|
342
|
+
</span>
|
|
343
|
+
))}
|
|
344
|
+
{uploading ? <span className="composer__chip composer__chip--pending">{t("chat.upload.uploading")}</span> : null}
|
|
345
|
+
</div>
|
|
346
|
+
) : null}
|
|
347
|
+
|
|
348
|
+
<div className="composer__toolbar">
|
|
349
|
+
<div className="composer__tools">
|
|
350
|
+
{/*
|
|
351
|
+
issue #47: 添加上下文 (Plus) has no picker yet — hidden until the
|
|
352
|
+
context-attachment flow exists. The chat.aria.attachContext i18n
|
|
353
|
+
key is kept. Re-add the Plus lucide import when restoring this.
|
|
354
|
+
<IconButton label={t("chat.aria.attachContext")}>
|
|
355
|
+
<Plus size={18} />
|
|
356
|
+
</IconButton>
|
|
357
|
+
*/}
|
|
358
|
+
{SHOW_SLASH_COMMANDS && slashCommands.length > 0 && (
|
|
359
|
+
<div className="command-picker" ref={commandsRef}>
|
|
360
|
+
<IconButton
|
|
361
|
+
label={t("chat.command")}
|
|
362
|
+
onClick={() => setShowCommands((s) => !s)}
|
|
363
|
+
className={`command-trigger ${showCommands ? "is-active" : ""}`}
|
|
364
|
+
>
|
|
365
|
+
<span>{t("chat.command")}</span>
|
|
366
|
+
</IconButton>
|
|
367
|
+
{showCommands && (
|
|
368
|
+
<div
|
|
369
|
+
className="command-picker__menu"
|
|
370
|
+
ref={menuRef}
|
|
371
|
+
style={menuPos ? { top: menuPos.top, left: menuPos.left } : { top: -9999, left: -9999 }}
|
|
372
|
+
>
|
|
373
|
+
{slashCommands.map((cmd) => (
|
|
374
|
+
<button
|
|
375
|
+
key={cmd}
|
|
376
|
+
className="command-picker__option"
|
|
377
|
+
type="button"
|
|
378
|
+
onClick={() => {
|
|
379
|
+
setDraftFor(cmd);
|
|
380
|
+
setShowCommands(false);
|
|
381
|
+
}}
|
|
382
|
+
>
|
|
383
|
+
{cmd}
|
|
384
|
+
</button>
|
|
385
|
+
))}
|
|
386
|
+
</div>
|
|
387
|
+
)}
|
|
388
|
+
</div>
|
|
389
|
+
)}
|
|
390
|
+
</div>
|
|
391
|
+
|
|
392
|
+
<div className="composer__send-tools">
|
|
393
|
+
<CustomSelect
|
|
394
|
+
ariaLabel={t("chat.modelPlaceholder")}
|
|
395
|
+
className="model-select"
|
|
396
|
+
disabled={!currentSandbox || !activeProvider || activeProvider.models.length === 0}
|
|
397
|
+
onChange={async (model) => {
|
|
398
|
+
setSelectedModel(model);
|
|
399
|
+
setComposerError(null);
|
|
400
|
+
try {
|
|
401
|
+
await api.settings.update({ model });
|
|
402
|
+
} catch (e) {
|
|
403
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
404
|
+
console.error("Failed to save model selection", e);
|
|
405
|
+
setComposerError(t("chat.error.saveModel", { msg }));
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
try {
|
|
409
|
+
await reloadConfig();
|
|
410
|
+
} catch (e) {
|
|
411
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
412
|
+
console.error("Failed to reload config after model change", e);
|
|
413
|
+
setComposerError(t("chat.error.reloadConfig", { msg }));
|
|
414
|
+
}
|
|
415
|
+
}}
|
|
416
|
+
options={activeProvider?.models.map((model) => {
|
|
417
|
+
const mh = activeProvider.modelHealth?.find((m) => m.model === model);
|
|
418
|
+
const status = mh?.status ?? "unknown";
|
|
419
|
+
return {
|
|
420
|
+
value: model,
|
|
421
|
+
label: model,
|
|
422
|
+
indicator: (
|
|
423
|
+
<span
|
|
424
|
+
className={`model-status-dot model-status-dot--${status}`}
|
|
425
|
+
title={mh?.error ?? status}
|
|
426
|
+
/>
|
|
427
|
+
),
|
|
428
|
+
};
|
|
429
|
+
}) ?? []}
|
|
430
|
+
placeholder={t("chat.modelPlaceholder")}
|
|
431
|
+
title={activeProvider ? t("chat.providerTitle", { name: activeProvider.name }) : t("chat.noActiveProvider")}
|
|
432
|
+
value={selectedModel}
|
|
433
|
+
/>
|
|
434
|
+
{/*
|
|
435
|
+
issue #47: 语音输入 (Mic) has no capture/permission flow yet —
|
|
436
|
+
hidden until implemented. The chat.aria.voice i18n key is kept.
|
|
437
|
+
Re-add the Mic lucide import when restoring this.
|
|
438
|
+
<IconButton label={t("chat.aria.voice")}>
|
|
439
|
+
<Mic size={17} />
|
|
440
|
+
</IconButton>
|
|
441
|
+
*/}
|
|
442
|
+
<input
|
|
443
|
+
ref={fileInputRef}
|
|
444
|
+
type="file"
|
|
445
|
+
multiple
|
|
446
|
+
style={{ display: "none" }}
|
|
447
|
+
onChange={(e) => void handleFilesChosen(e.target.files)}
|
|
448
|
+
/>
|
|
449
|
+
<IconButton
|
|
450
|
+
label={t("chat.aria.attachFile")}
|
|
451
|
+
onClick={() => fileInputRef.current?.click()}
|
|
452
|
+
disabled={uploading || !currentSandbox}
|
|
453
|
+
>
|
|
454
|
+
<Paperclip size={17} />
|
|
455
|
+
</IconButton>
|
|
456
|
+
<ComposerSendButton
|
|
457
|
+
sessionId={sessionId}
|
|
458
|
+
canSend={canSend}
|
|
459
|
+
label={t("chat.aria.send")}
|
|
460
|
+
/>
|
|
461
|
+
</div>
|
|
462
|
+
</div>
|
|
463
|
+
|
|
464
|
+
</form>
|
|
465
|
+
|
|
466
|
+
{error ? <p className="composer-status composer-status--error">{error}</p> : null}
|
|
467
|
+
{composerError ? <p className="composer-status composer-status--error">{composerError}</p> : null}
|
|
468
|
+
{!canSend ? (
|
|
469
|
+
<p className="composer-status">
|
|
470
|
+
{sandboxStatus !== "running"
|
|
471
|
+
? t("chat.status.startSandbox")
|
|
472
|
+
: isConnected
|
|
473
|
+
? t("chat.status.preparing")
|
|
474
|
+
: t("chat.status.connecting")}
|
|
475
|
+
</p>
|
|
476
|
+
) : null}
|
|
477
|
+
|
|
478
|
+
{!hasMessages && suggestedTasks.length > 0 ? <div className="suggestions" aria-label={t("chat.aria.suggested")}>
|
|
479
|
+
{suggestedTasks.map((task) => (
|
|
480
|
+
<button className="suggestion-row" key={task} onClick={() => setDraftFor(task)} type="button">
|
|
481
|
+
<Bot size={15} />
|
|
482
|
+
<span>{task}</span>
|
|
483
|
+
</button>
|
|
484
|
+
))}
|
|
485
|
+
</div> : null}
|
|
486
|
+
</div>
|
|
487
|
+
</section>
|
|
488
|
+
);
|
|
489
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { AlertTriangle, Info, OctagonAlert, XCircle } from "lucide-react";
|
|
2
|
+
import type { SystemMessageView } from "../../contracts/backend";
|
|
3
|
+
import { useT } from "../../i18n/useT";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 修正6 — system_message bubble. Renders a `system_message` event (level
|
|
7
|
+
* info|warning|error|fatal) as a 4-level styled inline bubble in the
|
|
8
|
+
* conversation stream. `fatal` gets the emphasized red treatment; `details`
|
|
9
|
+
* (debug) is revealed on expand.
|
|
10
|
+
*/
|
|
11
|
+
export function SystemMessageBubble({ view }: { view: SystemMessageView }) {
|
|
12
|
+
const t = useT();
|
|
13
|
+
const level = view.level;
|
|
14
|
+
const Icon =
|
|
15
|
+
level === "fatal" ? OctagonAlert :
|
|
16
|
+
level === "error" ? XCircle :
|
|
17
|
+
level === "warning" ? AlertTriangle :
|
|
18
|
+
Info;
|
|
19
|
+
const labelKey =
|
|
20
|
+
level === "fatal" ? "chat.system.fatal" :
|
|
21
|
+
level === "error" ? "chat.system.error" :
|
|
22
|
+
level === "warning" ? "chat.system.warning" :
|
|
23
|
+
"chat.system.info";
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div
|
|
27
|
+
className={`system-message system-message--${level}${level === "fatal" ? " system-message--emphasis" : ""}`}
|
|
28
|
+
role={level === "error" || level === "fatal" ? "alert" : "status"}
|
|
29
|
+
data-testid="system-message"
|
|
30
|
+
data-level={level}
|
|
31
|
+
>
|
|
32
|
+
<div className="system-message__head">
|
|
33
|
+
<Icon size={15} className="system-message__icon" aria-hidden="true" />
|
|
34
|
+
<span className="system-message__label">{t(labelKey)}</span>
|
|
35
|
+
{view.agent ? <span className="system-message__agent">{view.agent}</span> : null}
|
|
36
|
+
</div>
|
|
37
|
+
<p className="system-message__text">{view.message}</p>
|
|
38
|
+
{view.details ? (
|
|
39
|
+
<details className="system-message__details">
|
|
40
|
+
<summary>{t("chat.system.details")}</summary>
|
|
41
|
+
<pre>{view.details}</pre>
|
|
42
|
+
</details>
|
|
43
|
+
) : null}
|
|
44
|
+
</div>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-session chat scroll memory (#89).
|
|
3
|
+
*
|
|
4
|
+
* Switching workspace tabs (Chat ↔ Agents ↔ Trace) unmounts/remounts the Chat
|
|
5
|
+
* subtree in DesktopShell, so MessageStream loses its scroll position and its
|
|
6
|
+
* "is the user pinned to the bottom" intent. This module-level store survives
|
|
7
|
+
* those remounts, keyed by session id, so returning to Chat can restore where
|
|
8
|
+
* the user was — at the bottom following live output, or up in the history they
|
|
9
|
+
* were reading — without a visible top-to-bottom replay.
|
|
10
|
+
*
|
|
11
|
+
* Module-level (not React state) on purpose: it must outlive the component that
|
|
12
|
+
* reads it, and it is deliberately ephemeral (lost on full page reload, which
|
|
13
|
+
* is the right default — a reload starts a fresh view).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export interface ChatScrollState {
|
|
17
|
+
/** Last observed scrollTop of the message stack. */
|
|
18
|
+
scrollTop: number;
|
|
19
|
+
/** Whether the user was pinned to (near) the bottom. */
|
|
20
|
+
pinned: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const store = new Map<string, ChatScrollState>();
|
|
24
|
+
|
|
25
|
+
export function getChatScroll(key: string | undefined): ChatScrollState | undefined {
|
|
26
|
+
if (!key) return undefined;
|
|
27
|
+
return store.get(key);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function setChatScroll(key: string | undefined, state: ChatScrollState): void {
|
|
31
|
+
if (!key) return;
|
|
32
|
+
store.set(key, state);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Resolve the scrollTop to apply on (re)mount.
|
|
37
|
+
*
|
|
38
|
+
* - no memory yet, or the user was pinned → bottom (scrollHeight); this is the
|
|
39
|
+
* default for a freshly-opened conversation and for "following live output".
|
|
40
|
+
* - the user had scrolled up to read history → restore that exact position,
|
|
41
|
+
* clamped to the current scrollHeight in case content shrank.
|
|
42
|
+
*/
|
|
43
|
+
export function resolveScrollTop(
|
|
44
|
+
mem: ChatScrollState | undefined,
|
|
45
|
+
scrollHeight: number,
|
|
46
|
+
): number {
|
|
47
|
+
if (!mem || mem.pinned) return scrollHeight;
|
|
48
|
+
return Math.min(mem.scrollTop, scrollHeight);
|
|
49
|
+
}
|