@brainpilot/web 0.0.3 → 0.0.5
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-C-8G4D4j.js +448 -0
- package/dist/assets/index-C501m5OS.css +1 -0
- package/dist/index.html +2 -2
- package/index.html +13 -0
- package/package.json +9 -3
- package/src/App.tsx +10 -0
- package/src/__tests__/api.test.ts +103 -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/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 +464 -0
- package/src/components/chat/PromptComposer.tsx +398 -0
- package/src/components/chat/SystemMessageBubble.tsx +46 -0
- package/src/components/demo/DemoFileTree.tsx +146 -0
- package/src/components/demo/DemoView.tsx +668 -0
- package/src/components/demo/TraceNodeModal.tsx +76 -0
- package/src/components/demo/demoBundle.ts +218 -0
- package/src/components/demo/demoCache.ts +42 -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 +1240 -0
- package/src/components/session/AgentTraceViews.tsx +381 -0
- package/src/components/session/AnalyticsTab.tsx +386 -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 +301 -0
- package/src/components/session/TraceNodeDetail.tsx +142 -0
- package/src/components/session/agentAnalytics.ts +397 -0
- package/src/components/session/agentNetworkShared.ts +329 -0
- package/src/components/session/traceLayout.ts +150 -0
- package/src/components/settings/SettingsDialog.tsx +719 -0
- package/src/components/shell/DesktopShell.tsx +236 -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 +187 -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 +175 -0
- package/src/contexts/SandboxContext.tsx +310 -0
- package/src/contexts/SessionContext.tsx +608 -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/contracts/backend.ts +846 -0
- package/src/contracts/demoBundle.ts +83 -0
- package/src/i18n/messages/analytics.ts +96 -0
- package/src/i18n/messages/chat.ts +108 -0
- package/src/i18n/messages/contexts.ts +40 -0
- package/src/i18n/messages/demo.ts +80 -0
- package/src/i18n/messages/files.ts +82 -0
- package/src/i18n/messages/network.ts +186 -0
- package/src/i18n/messages/profile.ts +40 -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 +184 -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 +84 -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 +722 -0
- package/src/styles/global.css +7429 -0
- package/src/styles/tokens.css +161 -0
- package/src/utils/api.ts +627 -0
- package/src/utils/download.ts +18 -0
- package/src/utils/format.ts +7 -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,398 @@
|
|
|
1
|
+
import { Bot, Mic, Paperclip, Plus, Square } 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 { draftStore } from "../../contexts/draftStore";
|
|
7
|
+
import { applyMessageFilters } from "../../contexts/messageFilters";
|
|
8
|
+
import { useT } from "../../i18n/useT";
|
|
9
|
+
import { api } from "../../utils/api";
|
|
10
|
+
import { CustomSelect } from "../primitives/CustomSelect";
|
|
11
|
+
import { IconButton } from "../primitives/IconButton";
|
|
12
|
+
import { ComposerInput } from "./ComposerInput";
|
|
13
|
+
import { ComposerSendButton } from "./ComposerSendButton";
|
|
14
|
+
import { MessageStream } from "./MessageStream";
|
|
15
|
+
|
|
16
|
+
export function PromptComposer() {
|
|
17
|
+
const t = useT();
|
|
18
|
+
const [suggestedTasks, setSuggestedTasks] = useState<string[]>([]);
|
|
19
|
+
const [activeProvider, setActiveProvider] = useState<ProviderProfile | null>(null);
|
|
20
|
+
const [selectedModel, setSelectedModel] = useState("");
|
|
21
|
+
// 可用命令(已通过真实 API 测试 /context ✅ /cost ✅;/compact 由 SDK 内置 ✅)
|
|
22
|
+
// 不可用命令(已移除):/usage ❌ /clear ❌ /init ❌
|
|
23
|
+
const DEFAULT_SLASH_COMMANDS = ["/compact", "/context", "/cost"];
|
|
24
|
+
const [slashCommands, setSlashCommands] = useState<string[]>(DEFAULT_SLASH_COMMANDS);
|
|
25
|
+
|
|
26
|
+
const [showCommands, setShowCommands] = useState(false);
|
|
27
|
+
const commandsRef = useRef<HTMLDivElement | null>(null);
|
|
28
|
+
const menuRef = useRef<HTMLDivElement | null>(null);
|
|
29
|
+
const [menuPos, setMenuPos] = useState<{ top: number; left: number } | null>(null);
|
|
30
|
+
const { status: sandboxStatus, currentSandbox, reloadConfig } = useSandbox();
|
|
31
|
+
const [composerError, setComposerError] = useState<string | null>(null);
|
|
32
|
+
const { currentSession, messages, isSending, error, sendPrompt, isConnected, isDraft, agents, agentFilters, interruptCurrent, respondToInput, messageFilters } = useSessions();
|
|
33
|
+
// In draft mode there's no session/connection yet — allow composing so the
|
|
34
|
+
// first send can create + connect the session.
|
|
35
|
+
const canSend = sandboxStatus === "running" && !isSending && (isConnected || isDraft);
|
|
36
|
+
|
|
37
|
+
const visibleMessages = useMemo(() => {
|
|
38
|
+
const agentFiltered = messages.filter((msg) => {
|
|
39
|
+
if (msg.role === "user") return true;
|
|
40
|
+
const agent = msg.agent || "principal";
|
|
41
|
+
const filters = agentFilters[agent];
|
|
42
|
+
if (!filters) return true;
|
|
43
|
+
// "隐藏消息" 只隐藏普通消息,不碰 tool / hook
|
|
44
|
+
if (filters.hideMessages && msg.kind !== "tool" && msg.kind !== "hook") return false;
|
|
45
|
+
// "隐藏工具调用" 只隐藏 tool
|
|
46
|
+
if (filters.hideTools && msg.kind === "tool") return false;
|
|
47
|
+
// "隐藏 Hooks" 只隐藏 hook
|
|
48
|
+
if (filters.hideHooks && msg.kind === "hook") return false;
|
|
49
|
+
return true;
|
|
50
|
+
});
|
|
51
|
+
return applyMessageFilters(agentFiltered, messageFilters);
|
|
52
|
+
}, [messages, agentFilters, messageFilters]);
|
|
53
|
+
|
|
54
|
+
const hasMessages = visibleMessages.length > 0;
|
|
55
|
+
const isAgentRunning = agents.some((a) => a.status === "running");
|
|
56
|
+
const lastAssistantStreaming = visibleMessages[visibleMessages.length - 1]?.role === "assistant" && visibleMessages[visibleMessages.length - 1]?.streaming;
|
|
57
|
+
|
|
58
|
+
// Agents whose run is still active. Threaded to MessageStream so a folded
|
|
59
|
+
// activity block stays "in progress" across ReAct rounds — without this, the
|
|
60
|
+
// per-message streaming flags all clear between rounds and the block flashes
|
|
61
|
+
// "完成思考" in the gap. Memoized so its identity is stable for MessageStream's
|
|
62
|
+
// memo() (a fresh Set each render would defeat the memoization).
|
|
63
|
+
const runningAgents = useMemo(
|
|
64
|
+
() => new Set(agents.filter((a) => a.status === "running").map((a) => a.name)),
|
|
65
|
+
[agents],
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
let cancelled = false;
|
|
70
|
+
void api.ui.promptSuggestions().then((suggestions) => {
|
|
71
|
+
if (!cancelled) {
|
|
72
|
+
setSuggestedTasks(suggestions);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
return () => {
|
|
76
|
+
cancelled = true;
|
|
77
|
+
};
|
|
78
|
+
}, []);
|
|
79
|
+
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
let cancelled = false;
|
|
82
|
+
if (!currentSession) {
|
|
83
|
+
setSlashCommands(DEFAULT_SLASH_COMMANDS);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
void api.sessions.commands(currentSession.id).then((res) => {
|
|
87
|
+
if (!cancelled) {
|
|
88
|
+
// Only override defaults when the backend actually returned commands
|
|
89
|
+
if (res.commands.length > 0) {
|
|
90
|
+
setSlashCommands(res.commands);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}).catch(() => {
|
|
94
|
+
if (!cancelled) {
|
|
95
|
+
// Keep defaults on API failure so the button stays visible
|
|
96
|
+
setSlashCommands(DEFAULT_SLASH_COMMANDS);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
return () => {
|
|
100
|
+
cancelled = true;
|
|
101
|
+
};
|
|
102
|
+
}, [currentSession?.id]);
|
|
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
|
+
const handleSubmit = async (event: FormEvent) => {
|
|
216
|
+
event.preventDefault();
|
|
217
|
+
if (!sessionId) return;
|
|
218
|
+
const content = draftStore.get(sessionId).trim();
|
|
219
|
+
if (!content || !canSend) {
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
draftStore.set(sessionId, "");
|
|
223
|
+
// Carry the chosen provider/model so a freshly-created session records its
|
|
224
|
+
// per-session selection (no-op for an already-running session).
|
|
225
|
+
await sendPrompt(content, {
|
|
226
|
+
providerId: activeProvider?.id,
|
|
227
|
+
modelId: selectedModel || undefined,
|
|
228
|
+
});
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
// Writes to the draft store from non-text controls (slash command picks,
|
|
232
|
+
// suggestion cards). PromptComposer never reads the draft, so these don't
|
|
233
|
+
// pull it onto the keystroke render path.
|
|
234
|
+
const setDraftFor = (value: string) => {
|
|
235
|
+
if (sessionId) draftStore.set(sessionId, value);
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
return (
|
|
239
|
+
<section className={`prompt-home ${hasMessages ? "prompt-home--active" : ""}`} aria-labelledby="prompt-heading">
|
|
240
|
+
<div className="prompt-home__inner">
|
|
241
|
+
{hasMessages ? null : <h1 id="prompt-heading">{currentSession?.title ?? t("chat.heading")}</h1>}
|
|
242
|
+
|
|
243
|
+
{hasMessages ? (
|
|
244
|
+
<MessageStream
|
|
245
|
+
messages={visibleMessages}
|
|
246
|
+
autoScroll
|
|
247
|
+
showTiming
|
|
248
|
+
runningAgents={runningAgents}
|
|
249
|
+
onAskUserSubmit={(requestId, answer) => void respondToInput(requestId, answer)}
|
|
250
|
+
onRetryCancel={() => void interruptCurrent()}
|
|
251
|
+
/>
|
|
252
|
+
) : null}
|
|
253
|
+
|
|
254
|
+
{isAgentRunning || lastAssistantStreaming ? (
|
|
255
|
+
<div className="agent-running-toast" role="status" aria-live="polite">
|
|
256
|
+
<span className="agent-running-toast__dot" />
|
|
257
|
+
<span className="agent-running-toast__label">{t("chat.agentThinking")}</span>
|
|
258
|
+
<button
|
|
259
|
+
className="agent-running-toast__stop"
|
|
260
|
+
type="button"
|
|
261
|
+
onClick={() => void interruptCurrent()}
|
|
262
|
+
aria-label={t("chat.aria.stop")}
|
|
263
|
+
title={t("chat.aria.stop")}
|
|
264
|
+
>
|
|
265
|
+
<Square size={10} fill="currentColor" />
|
|
266
|
+
<span>{t("chat.stop")}</span>
|
|
267
|
+
</button>
|
|
268
|
+
</div>
|
|
269
|
+
) : null}
|
|
270
|
+
|
|
271
|
+
<form className="composer" aria-label={t("chat.aria.newPrompt")} onSubmit={handleSubmit}>
|
|
272
|
+
<ComposerInput
|
|
273
|
+
sessionId={sessionId}
|
|
274
|
+
placeholder={t("chat.placeholder")}
|
|
275
|
+
ariaLabel={t("chat.srAsk")}
|
|
276
|
+
/>
|
|
277
|
+
|
|
278
|
+
<div className="composer__toolbar">
|
|
279
|
+
<div className="composer__tools">
|
|
280
|
+
<IconButton label={t("chat.aria.attachContext")}>
|
|
281
|
+
<Plus size={18} />
|
|
282
|
+
</IconButton>
|
|
283
|
+
{slashCommands.length > 0 && (
|
|
284
|
+
<div className="command-picker" ref={commandsRef}>
|
|
285
|
+
<IconButton
|
|
286
|
+
label={t("chat.command")}
|
|
287
|
+
onClick={() => setShowCommands((s) => !s)}
|
|
288
|
+
className={`command-trigger ${showCommands ? "is-active" : ""}`}
|
|
289
|
+
>
|
|
290
|
+
<span>{t("chat.command")}</span>
|
|
291
|
+
</IconButton>
|
|
292
|
+
{showCommands && (
|
|
293
|
+
<div
|
|
294
|
+
className="command-picker__menu"
|
|
295
|
+
ref={menuRef}
|
|
296
|
+
style={menuPos ? { top: menuPos.top, left: menuPos.left } : { top: -9999, left: -9999 }}
|
|
297
|
+
>
|
|
298
|
+
{slashCommands.map((cmd) => (
|
|
299
|
+
<button
|
|
300
|
+
key={cmd}
|
|
301
|
+
className="command-picker__option"
|
|
302
|
+
type="button"
|
|
303
|
+
onClick={() => {
|
|
304
|
+
setDraftFor(cmd);
|
|
305
|
+
setShowCommands(false);
|
|
306
|
+
}}
|
|
307
|
+
>
|
|
308
|
+
{cmd}
|
|
309
|
+
</button>
|
|
310
|
+
))}
|
|
311
|
+
</div>
|
|
312
|
+
)}
|
|
313
|
+
</div>
|
|
314
|
+
)}
|
|
315
|
+
</div>
|
|
316
|
+
|
|
317
|
+
<div className="composer__send-tools">
|
|
318
|
+
<CustomSelect
|
|
319
|
+
ariaLabel={t("chat.modelPlaceholder")}
|
|
320
|
+
className="model-select"
|
|
321
|
+
disabled={!currentSandbox || !activeProvider || activeProvider.models.length === 0}
|
|
322
|
+
onChange={async (model) => {
|
|
323
|
+
setSelectedModel(model);
|
|
324
|
+
setComposerError(null);
|
|
325
|
+
try {
|
|
326
|
+
await api.settings.update({ model });
|
|
327
|
+
} catch (e) {
|
|
328
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
329
|
+
console.error("Failed to save model selection", e);
|
|
330
|
+
setComposerError(t("chat.error.saveModel", { msg }));
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
try {
|
|
334
|
+
await reloadConfig();
|
|
335
|
+
} catch (e) {
|
|
336
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
337
|
+
console.error("Failed to reload config after model change", e);
|
|
338
|
+
setComposerError(t("chat.error.reloadConfig", { msg }));
|
|
339
|
+
}
|
|
340
|
+
}}
|
|
341
|
+
options={activeProvider?.models.map((model) => {
|
|
342
|
+
const mh = activeProvider.modelHealth?.find((m) => m.model === model);
|
|
343
|
+
const status = mh?.status ?? "unknown";
|
|
344
|
+
return {
|
|
345
|
+
value: model,
|
|
346
|
+
label: model,
|
|
347
|
+
indicator: (
|
|
348
|
+
<span
|
|
349
|
+
className={`model-status-dot model-status-dot--${status}`}
|
|
350
|
+
title={mh?.error ?? status}
|
|
351
|
+
/>
|
|
352
|
+
),
|
|
353
|
+
};
|
|
354
|
+
}) ?? []}
|
|
355
|
+
placeholder={t("chat.modelPlaceholder")}
|
|
356
|
+
title={activeProvider ? t("chat.providerTitle", { name: activeProvider.name }) : t("chat.noActiveProvider")}
|
|
357
|
+
value={selectedModel}
|
|
358
|
+
/>
|
|
359
|
+
<IconButton label={t("chat.aria.voice")}>
|
|
360
|
+
<Mic size={17} />
|
|
361
|
+
</IconButton>
|
|
362
|
+
<IconButton label={t("chat.aria.attachFile")}>
|
|
363
|
+
<Paperclip size={17} />
|
|
364
|
+
</IconButton>
|
|
365
|
+
<ComposerSendButton
|
|
366
|
+
sessionId={sessionId}
|
|
367
|
+
canSend={canSend}
|
|
368
|
+
label={t("chat.aria.send")}
|
|
369
|
+
/>
|
|
370
|
+
</div>
|
|
371
|
+
</div>
|
|
372
|
+
|
|
373
|
+
</form>
|
|
374
|
+
|
|
375
|
+
{error ? <p className="composer-status composer-status--error">{error}</p> : null}
|
|
376
|
+
{composerError ? <p className="composer-status composer-status--error">{composerError}</p> : null}
|
|
377
|
+
{!canSend ? (
|
|
378
|
+
<p className="composer-status">
|
|
379
|
+
{sandboxStatus !== "running"
|
|
380
|
+
? t("chat.status.startSandbox")
|
|
381
|
+
: isConnected
|
|
382
|
+
? t("chat.status.preparing")
|
|
383
|
+
: t("chat.status.connecting")}
|
|
384
|
+
</p>
|
|
385
|
+
) : null}
|
|
386
|
+
|
|
387
|
+
{!hasMessages && suggestedTasks.length > 0 ? <div className="suggestions" aria-label={t("chat.aria.suggested")}>
|
|
388
|
+
{suggestedTasks.map((task) => (
|
|
389
|
+
<button className="suggestion-row" key={task} onClick={() => setDraftFor(task)} type="button">
|
|
390
|
+
<Bot size={15} />
|
|
391
|
+
<span>{task}</span>
|
|
392
|
+
</button>
|
|
393
|
+
))}
|
|
394
|
+
</div> : null}
|
|
395
|
+
</div>
|
|
396
|
+
</section>
|
|
397
|
+
);
|
|
398
|
+
}
|
|
@@ -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,146 @@
|
|
|
1
|
+
import { useMemo, useState } from "react";
|
|
2
|
+
import { ChevronRight, File, FileImage, FileText, Folder } from "lucide-react";
|
|
3
|
+
import type { DemoFile } from "../../contracts/demoBundle";
|
|
4
|
+
import { getPreviewKind } from "../files/filePreview";
|
|
5
|
+
|
|
6
|
+
interface TreeNode {
|
|
7
|
+
name: string;
|
|
8
|
+
path: string;
|
|
9
|
+
isDir: boolean;
|
|
10
|
+
children: TreeNode[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Build a nested tree from a flat list of file paths. Folders first, alpha. */
|
|
14
|
+
export function buildFileTree(paths: string[]): TreeNode[] {
|
|
15
|
+
const root: TreeNode = { name: "", path: "", isDir: true, children: [] };
|
|
16
|
+
for (const raw of paths) {
|
|
17
|
+
const norm = raw.replace(/^\/+/, "");
|
|
18
|
+
const parts = norm.split("/").filter(Boolean);
|
|
19
|
+
let cursor = root;
|
|
20
|
+
let acc = "";
|
|
21
|
+
parts.forEach((part, idx) => {
|
|
22
|
+
acc = acc ? `${acc}/${part}` : part;
|
|
23
|
+
const isLeaf = idx === parts.length - 1;
|
|
24
|
+
let child = cursor.children.find((c) => c.name === part);
|
|
25
|
+
if (!child) {
|
|
26
|
+
// Preserve the original (possibly leading-slash) path on the leaf so it
|
|
27
|
+
// matches DemoFile.path / artifact paths exactly.
|
|
28
|
+
child = { name: part, path: isLeaf ? raw : acc, isDir: !isLeaf, children: [] };
|
|
29
|
+
cursor.children.push(child);
|
|
30
|
+
}
|
|
31
|
+
if (!isLeaf) {
|
|
32
|
+
child.isDir = true;
|
|
33
|
+
}
|
|
34
|
+
cursor = child;
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
sortTree(root);
|
|
38
|
+
return root.children;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function sortTree(node: TreeNode) {
|
|
42
|
+
node.children.sort((a, b) => {
|
|
43
|
+
if (a.isDir !== b.isDir) {
|
|
44
|
+
return a.isDir ? -1 : 1;
|
|
45
|
+
}
|
|
46
|
+
return a.name.localeCompare(b.name);
|
|
47
|
+
});
|
|
48
|
+
node.children.forEach(sortTree);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function leafIcon(name: string) {
|
|
52
|
+
const kind = getPreviewKind(name);
|
|
53
|
+
if (kind === "image") {
|
|
54
|
+
return <FileImage size={14} />;
|
|
55
|
+
}
|
|
56
|
+
if (kind === "text") {
|
|
57
|
+
return <FileText size={14} />;
|
|
58
|
+
}
|
|
59
|
+
return <File size={14} />;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface DemoFileTreeProps {
|
|
63
|
+
files: DemoFile[];
|
|
64
|
+
highlightedPaths: Set<string>;
|
|
65
|
+
activePath: string | null;
|
|
66
|
+
onSelect: (path: string) => void;
|
|
67
|
+
emptyLabel: string;
|
|
68
|
+
skippedLabel: string;
|
|
69
|
+
unreadableLabel: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function DemoFileTree({ files, highlightedPaths, activePath, onSelect, emptyLabel, skippedLabel, unreadableLabel }: DemoFileTreeProps) {
|
|
73
|
+
const tree = useMemo(() => buildFileTree(files.map((f) => f.path)), [files]);
|
|
74
|
+
const truncated = useMemo(() => new Set(files.filter((f) => f.truncated).map((f) => f.path)), [files]);
|
|
75
|
+
const unreadable = useMemo(
|
|
76
|
+
() => new Set(files.filter((f) => f.reason === "unreadable").map((f) => f.path)),
|
|
77
|
+
[files],
|
|
78
|
+
);
|
|
79
|
+
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set());
|
|
80
|
+
|
|
81
|
+
if (files.length === 0) {
|
|
82
|
+
return <p className="demo-panel__empty">{emptyLabel}</p>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const toggle = (path: string) => {
|
|
86
|
+
setCollapsed((current) => {
|
|
87
|
+
const next = new Set(current);
|
|
88
|
+
if (next.has(path)) {
|
|
89
|
+
next.delete(path);
|
|
90
|
+
} else {
|
|
91
|
+
next.add(path);
|
|
92
|
+
}
|
|
93
|
+
return next;
|
|
94
|
+
});
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const renderNode = (node: TreeNode, depth: number) => {
|
|
98
|
+
const pad = 8 + depth * 14;
|
|
99
|
+
if (node.isDir) {
|
|
100
|
+
const isOpen = !collapsed.has(node.path);
|
|
101
|
+
return (
|
|
102
|
+
<div key={node.path || node.name} className="demo-tree-node">
|
|
103
|
+
<button
|
|
104
|
+
className="demo-tree-row demo-tree-row--dir"
|
|
105
|
+
style={{ paddingLeft: pad }}
|
|
106
|
+
onClick={() => toggle(node.path)}
|
|
107
|
+
type="button"
|
|
108
|
+
>
|
|
109
|
+
<span className={`demo-tree-chevron ${isOpen ? "is-open" : ""}`}>
|
|
110
|
+
<ChevronRight size={13} />
|
|
111
|
+
</span>
|
|
112
|
+
<Folder size={14} />
|
|
113
|
+
<span className="demo-tree-name">{node.name}</span>
|
|
114
|
+
</button>
|
|
115
|
+
{isOpen ? node.children.map((child) => renderNode(child, depth + 1)) : null}
|
|
116
|
+
</div>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
const isSkipped = truncated.has(node.path);
|
|
120
|
+
const isUnreadable = unreadable.has(node.path);
|
|
121
|
+
const isProduced = highlightedPaths.has(node.path);
|
|
122
|
+
const isActive = activePath === node.path;
|
|
123
|
+
return (
|
|
124
|
+
<div key={node.path} className="demo-tree-node">
|
|
125
|
+
<button
|
|
126
|
+
className={`demo-tree-row ${isActive ? "is-active" : ""} ${isProduced ? "is-produced" : ""} ${isSkipped ? "is-skipped" : ""}`}
|
|
127
|
+
style={{ paddingLeft: pad + 13 }}
|
|
128
|
+
disabled={isSkipped && !isUnreadable}
|
|
129
|
+
onClick={() => onSelect(node.path)}
|
|
130
|
+
title={node.path}
|
|
131
|
+
type="button"
|
|
132
|
+
>
|
|
133
|
+
{leafIcon(node.name)}
|
|
134
|
+
<span className="demo-tree-name">{node.name}</span>
|
|
135
|
+
{isUnreadable ? (
|
|
136
|
+
<small className="demo-tree-skip">{unreadableLabel}</small>
|
|
137
|
+
) : isSkipped ? (
|
|
138
|
+
<small className="demo-tree-skip">{skippedLabel}</small>
|
|
139
|
+
) : null}
|
|
140
|
+
</button>
|
|
141
|
+
</div>
|
|
142
|
+
);
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
return <div className="demo-file-tree">{tree.map((node) => renderNode(node, 0))}</div>;
|
|
146
|
+
}
|