@brainpilot/web 0.0.10 → 0.0.11
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-DkoqxJfs.css +1 -0
- package/dist/assets/index-DtLW483q.js +451 -0
- package/dist/index.html +2 -2
- package/package.json +2 -2
- package/src/__tests__/messageGroups.test.ts +150 -0
- package/src/__tests__/newUiEvents.test.ts +32 -0
- package/src/components/chat/MessageStream.tsx +102 -32
- package/src/components/chat/PromptComposer.tsx +1 -0
- package/src/components/demo/DemoView.tsx +1 -1
- package/src/components/session/AgentTraceViews.tsx +5 -9
- package/src/components/settings/KnowledgeBasePanel.tsx +307 -143
- package/src/components/settings/SettingsDialog.tsx +115 -57
- package/src/components/shell/SandboxStatus.tsx +128 -84
- package/src/contexts/messageGroups.ts +110 -4
- package/src/contexts/messageReducer.ts +11 -1
- package/src/i18n/messages/chat.ts +10 -0
- package/src/i18n/messages/sandbox.ts +3 -0
- package/src/i18n/messages/settings.ts +40 -4
- package/src/i18n/messages/trace.ts +0 -2
- package/src/styles/global.css +821 -69
- package/src/utils/api.ts +63 -0
- package/dist/assets/index-D63mUJxx.js +0 -450
- package/dist/assets/index-D8J9Cnup.css +0 -1
|
@@ -6,6 +6,7 @@ import { useAuth } from "../../contexts/AuthContext";
|
|
|
6
6
|
import { usePreferences } from "../../contexts/PreferencesContext";
|
|
7
7
|
import { useT } from "../../i18n/useT";
|
|
8
8
|
import { api } from "../../utils/api";
|
|
9
|
+
import { runtimeConfig } from "../../config";
|
|
9
10
|
import { EXAMPLE_MODEL } from "@brainpilot/protocol";
|
|
10
11
|
import { CustomSelect } from "../primitives/CustomSelect";
|
|
11
12
|
import { IconButton } from "../primitives/IconButton";
|
|
@@ -18,7 +19,7 @@ type SettingsDialogProps = {
|
|
|
18
19
|
onClose: () => void;
|
|
19
20
|
};
|
|
20
21
|
|
|
21
|
-
const
|
|
22
|
+
const ALL_TABS: Array<{ id: SettingsTab; labelKey: string; icon: LucideIcon }> = [
|
|
22
23
|
{ id: "account", labelKey: "settings.tab.account", icon: UserRound },
|
|
23
24
|
{ id: "providers", labelKey: "settings.tab.providers", icon: SlidersHorizontal },
|
|
24
25
|
{ id: "mcp", labelKey: "settings.tab.mcp", icon: Plug },
|
|
@@ -26,6 +27,12 @@ const tabs: Array<{ id: SettingsTab; labelKey: string; icon: LucideIcon }> = [
|
|
|
26
27
|
{ id: "preferences", labelKey: "settings.tab.preferences", icon: Settings },
|
|
27
28
|
];
|
|
28
29
|
|
|
30
|
+
// Local single-user mode has no host-managed identity — the account tab would
|
|
31
|
+
// only show placeholder "local / local / 1970" values, so drop it entirely.
|
|
32
|
+
const tabs = runtimeConfig.localMode
|
|
33
|
+
? ALL_TABS.filter((tab) => tab.id !== "account")
|
|
34
|
+
: ALL_TABS;
|
|
35
|
+
|
|
29
36
|
const DEFAULT_PROVIDER_FORM = {
|
|
30
37
|
name: "",
|
|
31
38
|
baseUrl: "https://api.anthropic.com",
|
|
@@ -59,7 +66,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
|
|
59
66
|
const { user } = useAuth();
|
|
60
67
|
const preferences = usePreferences();
|
|
61
68
|
const t = useT();
|
|
62
|
-
const [activeTab, setActiveTab] = useState<SettingsTab>(
|
|
69
|
+
const [activeTab, setActiveTab] = useState<SettingsTab>(tabs[0].id);
|
|
63
70
|
const [providers, setProviders] = useState<ProviderProfile[]>([]);
|
|
64
71
|
const [mcpServers, setMcpServers] = useState<McpServerEntry[]>([]);
|
|
65
72
|
const [providerForm, setProviderForm] = useState(DEFAULT_PROVIDER_FORM);
|
|
@@ -367,7 +374,6 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
|
|
367
374
|
</div>
|
|
368
375
|
</dl>
|
|
369
376
|
<p className="settings-note">{t("settings.account.managedByHost")}</p>
|
|
370
|
-
{version ? <span className="settings-version">{version}</span> : null}
|
|
371
377
|
</section>
|
|
372
378
|
) : null}
|
|
373
379
|
|
|
@@ -443,6 +449,19 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
|
|
443
449
|
</article>
|
|
444
450
|
);
|
|
445
451
|
|
|
452
|
+
if (providers.length === 0) {
|
|
453
|
+
return (
|
|
454
|
+
<div className="settings-empty">
|
|
455
|
+
<SlidersHorizontal size={22} />
|
|
456
|
+
<strong>{t("settings.providers.empty")}</strong>
|
|
457
|
+
<p>{t("settings.providers.emptyHint")}</p>
|
|
458
|
+
<button className="settings-button" onClick={openProviderForm} type="button">
|
|
459
|
+
{t("settings.providers.add")}
|
|
460
|
+
</button>
|
|
461
|
+
</div>
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
|
|
446
465
|
return (
|
|
447
466
|
<>
|
|
448
467
|
{sharedProviders.length > 0 ? (
|
|
@@ -478,21 +497,34 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
|
|
478
497
|
{t("settings.mcp.addServer")}
|
|
479
498
|
</button>
|
|
480
499
|
</div>
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
<
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
500
|
+
{mcpServers.length === 0 ? (
|
|
501
|
+
<div className="settings-empty">
|
|
502
|
+
<Plug size={22} />
|
|
503
|
+
<strong>{t("settings.mcp.empty")}</strong>
|
|
504
|
+
<p>{t("settings.mcp.emptyHint")}</p>
|
|
505
|
+
<button className="settings-button" onClick={openMcpForm} type="button">
|
|
506
|
+
{t("settings.mcp.addServer")}
|
|
507
|
+
</button>
|
|
508
|
+
</div>
|
|
509
|
+
) : (
|
|
510
|
+
<div className="settings-list">
|
|
511
|
+
{mcpServers.map((server) => (
|
|
512
|
+
<article className="settings-list-item" key={server.name}>
|
|
513
|
+
<div>
|
|
514
|
+
<strong>
|
|
515
|
+
{server.name}
|
|
516
|
+
<span className={`mcp-transport-chip mcp-transport-chip--${server.type}`}>{server.type}</span>
|
|
517
|
+
</strong>
|
|
518
|
+
<span>{server.type === "stdio" ? [server.command, ...(server.args || [])].filter(Boolean).join(" ") : server.url}</span>
|
|
519
|
+
</div>
|
|
520
|
+
<div className="settings-list-item__actions mcp-actions">
|
|
521
|
+
<button onClick={() => editMcpServer(server)} type="button">{t("settings.mcp.edit")}</button>
|
|
522
|
+
<button onClick={() => void removeMcpServer(server.name)} type="button">{t("settings.mcp.remove")}</button>
|
|
523
|
+
</div>
|
|
524
|
+
</article>
|
|
525
|
+
))}
|
|
526
|
+
</div>
|
|
527
|
+
)}
|
|
496
528
|
</section>
|
|
497
529
|
) : null}
|
|
498
530
|
|
|
@@ -501,51 +533,77 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
|
|
501
533
|
{activeTab === "preferences" ? (
|
|
502
534
|
<section className="settings-section">
|
|
503
535
|
<h3>{t("settings.prefs.title")}</h3>
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
<
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
{
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
536
|
+
|
|
537
|
+
<div className="settings-group">
|
|
538
|
+
<h4 className="settings-group__title">{t("settings.prefs.groupAppearance")}</h4>
|
|
539
|
+
<div className="settings-field settings-field--split">
|
|
540
|
+
<div className="settings-field__label">
|
|
541
|
+
<span>{t("settings.prefs.theme")}</span>
|
|
542
|
+
<small>{t("settings.prefs.themeDesc")}</small>
|
|
543
|
+
</div>
|
|
544
|
+
<CustomSelect
|
|
545
|
+
ariaLabel={t("settings.prefs.theme")}
|
|
546
|
+
onChange={(value) => preferences.setTheme(value as typeof preferences.theme)}
|
|
547
|
+
options={[
|
|
548
|
+
{ label: t("settings.prefs.themeLight"), value: "light" },
|
|
549
|
+
{ label: t("settings.prefs.themeDark"), value: "dark" },
|
|
550
|
+
{ label: t("settings.prefs.themeSystem"), value: "system" },
|
|
551
|
+
]}
|
|
552
|
+
value={preferences.theme}
|
|
553
|
+
/>
|
|
554
|
+
</div>
|
|
555
|
+
<div className="settings-field settings-field--split">
|
|
556
|
+
<div className="settings-field__label">
|
|
557
|
+
<span>{t("settings.prefs.language")}</span>
|
|
558
|
+
<small>{t("settings.prefs.languageDesc")}</small>
|
|
559
|
+
</div>
|
|
560
|
+
<CustomSelect
|
|
561
|
+
ariaLabel={t("settings.prefs.language")}
|
|
562
|
+
onChange={(value) => preferences.setLanguage(value as typeof preferences.language)}
|
|
563
|
+
options={[
|
|
564
|
+
{ label: "简体中文", value: "zh-CN" },
|
|
565
|
+
{ label: "English", value: "en-US" },
|
|
566
|
+
]}
|
|
567
|
+
value={preferences.language}
|
|
568
|
+
/>
|
|
569
|
+
</div>
|
|
516
570
|
</div>
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
<
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
{
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
571
|
+
|
|
572
|
+
<div className="settings-group">
|
|
573
|
+
<h4 className="settings-group__title">{t("settings.prefs.groupBehavior")}</h4>
|
|
574
|
+
<label className="settings-toggle-row">
|
|
575
|
+
<span className="settings-toggle-row__text">
|
|
576
|
+
<span>{t("settings.prefs.confirmDangerous")}</span>
|
|
577
|
+
<small>{t("settings.prefs.confirmDangerousDesc")}</small>
|
|
578
|
+
</span>
|
|
579
|
+
<input
|
|
580
|
+
checked={preferences.security.confirmDangerousActions}
|
|
581
|
+
onChange={(event) => preferences.setSecurity({ ...preferences.security, confirmDangerousActions: event.target.checked })}
|
|
582
|
+
type="checkbox"
|
|
583
|
+
/>
|
|
584
|
+
</label>
|
|
585
|
+
<label className="settings-toggle-row">
|
|
586
|
+
<span className="settings-toggle-row__text">
|
|
587
|
+
<span>{t("settings.prefs.notifyDone")}</span>
|
|
588
|
+
<small>{t("settings.prefs.notifyDoneDesc")}</small>
|
|
589
|
+
</span>
|
|
590
|
+
<input
|
|
591
|
+
checked={preferences.notifications.agentDone}
|
|
592
|
+
onChange={(event) => preferences.setNotifications({ ...preferences.notifications, agentDone: event.target.checked })}
|
|
593
|
+
type="checkbox"
|
|
594
|
+
/>
|
|
595
|
+
</label>
|
|
528
596
|
</div>
|
|
529
|
-
<label className="settings-check">
|
|
530
|
-
<input
|
|
531
|
-
checked={preferences.security.confirmDangerousActions}
|
|
532
|
-
onChange={(event) => preferences.setSecurity({ ...preferences.security, confirmDangerousActions: event.target.checked })}
|
|
533
|
-
type="checkbox"
|
|
534
|
-
/>
|
|
535
|
-
<span>{t("settings.prefs.confirmDangerous")}</span>
|
|
536
|
-
</label>
|
|
537
|
-
<label className="settings-check">
|
|
538
|
-
<input
|
|
539
|
-
checked={preferences.notifications.agentDone}
|
|
540
|
-
onChange={(event) => preferences.setNotifications({ ...preferences.notifications, agentDone: event.target.checked })}
|
|
541
|
-
type="checkbox"
|
|
542
|
-
/>
|
|
543
|
-
<span>{t("settings.prefs.notifyDone")}</span>
|
|
544
|
-
</label>
|
|
545
597
|
</section>
|
|
546
598
|
) : null}
|
|
547
599
|
</div>
|
|
548
600
|
</div>
|
|
601
|
+
|
|
602
|
+
{version ? (
|
|
603
|
+
<footer className="settings-modal__footer">
|
|
604
|
+
<span className="settings-version">{version}</span>
|
|
605
|
+
</footer>
|
|
606
|
+
) : null}
|
|
549
607
|
</section>
|
|
550
608
|
|
|
551
609
|
{isProviderFormOpen ? (
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { Radio, Server } from "lucide-react";
|
|
2
3
|
import { useSandbox } from "../../contexts/SandboxContext";
|
|
3
4
|
import { useSessions } from "../../contexts/SessionContext";
|
|
4
5
|
import { useT } from "../../i18n/useT";
|
|
@@ -24,6 +25,13 @@ function formatBytes(bytes?: number) {
|
|
|
24
25
|
return `${value >= 10 ? value.toFixed(0) : value.toFixed(1)} ${units[index]}`;
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
/** Clamp a percentage into [0, 100] and pick a severity band for the meter fill. */
|
|
29
|
+
function meterLevel(percent?: number): { pct: number; level: "ok" | "warning" | "critical" } {
|
|
30
|
+
const pct = Math.max(0, Math.min(100, Math.round(percent ?? 0)));
|
|
31
|
+
const level = pct >= 90 ? "critical" : pct >= 75 ? "warning" : "ok";
|
|
32
|
+
return { pct, level };
|
|
33
|
+
}
|
|
34
|
+
|
|
27
35
|
function getConnectionState(status: string, isConnected: boolean): SandboxConnectionState {
|
|
28
36
|
if (status === "running" && isConnected) {
|
|
29
37
|
return "connected";
|
|
@@ -64,6 +72,7 @@ function getStatusKey(status: string, isConnected: boolean) {
|
|
|
64
72
|
export function SandboxStatus() {
|
|
65
73
|
const [isOpen, setIsOpen] = useState(false);
|
|
66
74
|
const [logs, setLogs] = useState("");
|
|
75
|
+
const [showLogs, setShowLogs] = useState(false);
|
|
67
76
|
const [health, setHealth] = useState<Record<string, unknown> | null>(null);
|
|
68
77
|
const [detailsLoading, setDetailsLoading] = useState(false);
|
|
69
78
|
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
@@ -157,98 +166,119 @@ export function SandboxStatus() {
|
|
|
157
166
|
id="sandbox-status-popover"
|
|
158
167
|
role="dialog"
|
|
159
168
|
>
|
|
160
|
-
<div className=
|
|
161
|
-
<span className="sandbox-
|
|
162
|
-
<
|
|
169
|
+
<div className={`sandbox-status__hero sandbox-status__hero--${connection}`}>
|
|
170
|
+
<span className="sandbox-status__hero-dot" aria-hidden="true" />
|
|
171
|
+
<div className="sandbox-status__hero-text">
|
|
172
|
+
<strong>{t(connectionLabelKey[connection])}</strong>
|
|
173
|
+
<span className={isLoading ? "is-loading" : ""}>
|
|
174
|
+
{(() => {
|
|
175
|
+
const notice = getStatusKey(effectiveStatus, isConnected);
|
|
176
|
+
return t(notice.key, notice.vars);
|
|
177
|
+
})()}
|
|
178
|
+
</span>
|
|
179
|
+
</div>
|
|
163
180
|
</div>
|
|
164
|
-
|
|
165
|
-
<p className={`sandbox-status__notice ${isLoading ? "is-loading" : ""}`}>
|
|
166
|
-
{(() => {
|
|
167
|
-
const notice = getStatusKey(effectiveStatus, isConnected);
|
|
168
|
-
return t(notice.key, notice.vars);
|
|
169
|
-
})()}
|
|
170
|
-
</p>
|
|
171
181
|
{error ? <p className="sandbox-status__empty">{error}</p> : null}
|
|
172
182
|
|
|
173
183
|
{hasSandbox ? (
|
|
174
184
|
<>
|
|
175
|
-
<
|
|
176
|
-
|
|
177
|
-
<
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
<div>
|
|
211
|
-
<span>{t("sandbox.label.cpu")}</span>
|
|
212
|
-
<strong>{t("sandbox.cpuUsed", { percent: stats?.cpu.usedPercent ?? 0 })}</strong>
|
|
213
|
-
<small>
|
|
214
|
-
{t("sandbox.cpuQuota", { quota: stats?.cpu.quotaPercent ?? 0, cpus: stats?.cpu.onlineCpus ?? 0 })}
|
|
215
|
-
</small>
|
|
216
|
-
</div>
|
|
217
|
-
<div>
|
|
218
|
-
<span>{t("sandbox.label.pids")}</span>
|
|
219
|
-
<strong>
|
|
220
|
-
{stats?.pids.current ?? 0} / {stats?.pids.limit ?? t("sandbox.unlimited")}
|
|
221
|
-
</strong>
|
|
222
|
-
</div>
|
|
223
|
-
<div>
|
|
224
|
-
<span>{t("sandbox.label.disk")}</span>
|
|
225
|
-
<strong>
|
|
226
|
-
{formatBytes(stats?.disk.workspaceUsedBytes)} / {formatBytes(stats?.disk.quotaBytes)}
|
|
227
|
-
</strong>
|
|
228
|
-
<small>{stats?.disk.percentOfQuota ?? 0}%</small>
|
|
229
|
-
</div>
|
|
185
|
+
<div className="sandbox-status__meters">
|
|
186
|
+
{(() => {
|
|
187
|
+
const rows: Array<{ label: string; percent?: number; detail: string }> = [
|
|
188
|
+
{
|
|
189
|
+
label: t("sandbox.label.memory"),
|
|
190
|
+
percent: stats?.memory.percent,
|
|
191
|
+
detail: `${formatBytes(stats?.memory.usedBytes)} / ${formatBytes(stats?.memory.limitBytes)}`,
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
label: t("sandbox.label.disk"),
|
|
195
|
+
percent: stats?.disk.percentOfQuota,
|
|
196
|
+
detail: `${formatBytes(stats?.disk.workspaceUsedBytes)} / ${formatBytes(stats?.disk.quotaBytes)}`,
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
label: t("sandbox.label.cpu"),
|
|
200
|
+
percent: stats?.cpu.usedPercent,
|
|
201
|
+
detail: t("sandbox.cpuQuota", { quota: stats?.cpu.quotaPercent ?? 0, cpus: stats?.cpu.onlineCpus ?? 0 }),
|
|
202
|
+
},
|
|
203
|
+
];
|
|
204
|
+
return rows.map((row) => {
|
|
205
|
+
const { pct, level } = meterLevel(row.percent);
|
|
206
|
+
return (
|
|
207
|
+
<div className="sandbox-meter" key={row.label}>
|
|
208
|
+
<div className="sandbox-meter__head">
|
|
209
|
+
<span>{row.label}</span>
|
|
210
|
+
<strong>{pct}%</strong>
|
|
211
|
+
</div>
|
|
212
|
+
<div className="sandbox-meter__track" aria-hidden="true">
|
|
213
|
+
<span className={`sandbox-meter__fill sandbox-meter__fill--${level}`} style={{ width: `${pct}%` }} />
|
|
214
|
+
</div>
|
|
215
|
+
<small>{row.detail}</small>
|
|
216
|
+
</div>
|
|
217
|
+
);
|
|
218
|
+
});
|
|
219
|
+
})()}
|
|
230
220
|
</div>
|
|
231
221
|
|
|
232
|
-
<div className="sandbox-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
<
|
|
248
|
-
</
|
|
222
|
+
<div className="sandbox-status__chips">
|
|
223
|
+
{(() => {
|
|
224
|
+
const runtimeOnline = health?.agent_runtime !== false && !!health?.agent_runtime;
|
|
225
|
+
const runtimeKnown = health?.agent_runtime !== undefined;
|
|
226
|
+
return (
|
|
227
|
+
<span className={`sandbox-chip sandbox-chip--${runtimeKnown ? (runtimeOnline ? "ok" : "off") : "unknown"}`}>
|
|
228
|
+
<Server size={13} />
|
|
229
|
+
{t("sandbox.label.runtime")}
|
|
230
|
+
<i className="sandbox-chip__dot" aria-hidden="true" />
|
|
231
|
+
</span>
|
|
232
|
+
);
|
|
233
|
+
})()}
|
|
234
|
+
<span className={`sandbox-chip sandbox-chip--${isConnected ? "ok" : "off"}`}>
|
|
235
|
+
<Radio size={13} />
|
|
236
|
+
{t("sandbox.label.sse")}
|
|
237
|
+
<i className="sandbox-chip__dot" aria-hidden="true" />
|
|
238
|
+
</span>
|
|
249
239
|
</div>
|
|
250
240
|
|
|
251
|
-
<
|
|
241
|
+
<details className="sandbox-status__details">
|
|
242
|
+
<summary>{t("sandbox.details.more")}</summary>
|
|
243
|
+
<dl className="sandbox-status__grid">
|
|
244
|
+
<div>
|
|
245
|
+
<dt>{t("sandbox.label.name")}</dt>
|
|
246
|
+
<dd>{currentSandbox.name}</dd>
|
|
247
|
+
</div>
|
|
248
|
+
<div>
|
|
249
|
+
<dt>{t("sandbox.label.id")}</dt>
|
|
250
|
+
<dd>{currentSandbox.id}</dd>
|
|
251
|
+
</div>
|
|
252
|
+
<div>
|
|
253
|
+
<dt>{t("sandbox.label.container")}</dt>
|
|
254
|
+
<dd>{currentSandbox.containerName || "-"}</dd>
|
|
255
|
+
</div>
|
|
256
|
+
<div>
|
|
257
|
+
<dt>{t("sandbox.label.hostApi")}</dt>
|
|
258
|
+
<dd>{currentSandbox.hostApiUrl || "-"}</dd>
|
|
259
|
+
</div>
|
|
260
|
+
<div>
|
|
261
|
+
<dt>{t("sandbox.label.port")}</dt>
|
|
262
|
+
<dd>{currentSandbox.port ?? "-"}</dd>
|
|
263
|
+
</div>
|
|
264
|
+
<div>
|
|
265
|
+
<dt>{t("sandbox.label.pids")}</dt>
|
|
266
|
+
<dd>{stats?.pids.current ?? 0} / {stats?.pids.limit ?? t("sandbox.unlimited")}</dd>
|
|
267
|
+
</div>
|
|
268
|
+
<div>
|
|
269
|
+
<dt>{t("sandbox.label.created")}</dt>
|
|
270
|
+
<dd>{new Date(currentSandbox.createdAt).toLocaleString()}</dd>
|
|
271
|
+
</div>
|
|
272
|
+
<div>
|
|
273
|
+
<dt>{t("sandbox.label.checked")}</dt>
|
|
274
|
+
<dd>{health?.checked_at ? new Date(String(health.checked_at)).toLocaleTimeString() : "-"}</dd>
|
|
275
|
+
</div>
|
|
276
|
+
</dl>
|
|
277
|
+
</details>
|
|
278
|
+
|
|
279
|
+
{showLogs ? (
|
|
280
|
+
<pre className="sandbox-status__logs">{detailsLoading ? t("sandbox.logs.loading") : logs || t("sandbox.logs.empty")}</pre>
|
|
281
|
+
) : null}
|
|
252
282
|
</>
|
|
253
283
|
) : (
|
|
254
284
|
<p className="sandbox-status__empty">{t("sandbox.empty")}</p>
|
|
@@ -263,7 +293,21 @@ export function SandboxStatus() {
|
|
|
263
293
|
<button disabled={isLoading} onClick={() => void refresh()} type="button">
|
|
264
294
|
{t("sandbox.action.refresh")}
|
|
265
295
|
</button>
|
|
266
|
-
<button
|
|
296
|
+
<button
|
|
297
|
+
aria-pressed={showLogs}
|
|
298
|
+
className={showLogs ? "is-active" : ""}
|
|
299
|
+
disabled={detailsLoading}
|
|
300
|
+
onClick={() => {
|
|
301
|
+
setShowLogs((current) => {
|
|
302
|
+
const next = !current;
|
|
303
|
+
if (next) {
|
|
304
|
+
void loadDetails();
|
|
305
|
+
}
|
|
306
|
+
return next;
|
|
307
|
+
});
|
|
308
|
+
}}
|
|
309
|
+
type="button"
|
|
310
|
+
>
|
|
267
311
|
{t("sandbox.action.logs")}
|
|
268
312
|
</button>
|
|
269
313
|
<button
|
|
@@ -2,12 +2,17 @@ import { ChatMessage } from "../contracts/backend";
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* A unit of rendering in the chat stream. Either a single standalone message
|
|
5
|
-
* (user prompt, assistant text, error, hook note)
|
|
6
|
-
* folds adjacent reasoning and tool calls/results into one collapsible block
|
|
5
|
+
* (user prompt, assistant text, error, hook note), an "activity" group that
|
|
6
|
+
* folds adjacent reasoning and tool calls/results into one collapsible block,
|
|
7
|
+
* or (#219) an "expertGroup" that folds a run of non-PI (specialist) agent
|
|
8
|
+
* render items behind one more level of disclosure so the PI narrative reads
|
|
9
|
+
* cleanly by default. An expertGroup nests already-built single/activity items
|
|
10
|
+
* so the reasoning/tool folds inside it are preserved when expanded.
|
|
7
11
|
*/
|
|
8
12
|
export type RenderItem =
|
|
9
13
|
| { type: "single"; message: ChatMessage }
|
|
10
|
-
| { type: "activity"; id: string; steps: ChatMessage[]; streaming: boolean }
|
|
14
|
+
| { type: "activity"; id: string; steps: ChatMessage[]; streaming: boolean }
|
|
15
|
+
| { type: "expertGroup"; id: string; agents: string[]; items: RenderItem[]; streaming: boolean };
|
|
11
16
|
|
|
12
17
|
/**
|
|
13
18
|
* #134 — tool visibility model. Internal tools are part of the agent's plumbing
|
|
@@ -101,6 +106,7 @@ function isStandalone(message: ChatMessage): boolean {
|
|
|
101
106
|
export function buildRenderItems(
|
|
102
107
|
messages: ChatMessage[],
|
|
103
108
|
runningAgents?: ReadonlySet<string>,
|
|
109
|
+
groupExpert = false,
|
|
104
110
|
): RenderItem[] {
|
|
105
111
|
const items: RenderItem[] = [];
|
|
106
112
|
// #134 — internal tools (trace bookkeeping) are hidden from the chat UI.
|
|
@@ -129,5 +135,105 @@ export function buildRenderItems(
|
|
|
129
135
|
}
|
|
130
136
|
}
|
|
131
137
|
flush();
|
|
132
|
-
|
|
138
|
+
// #219 — second pass: fold consecutive specialist (non-PI) items into
|
|
139
|
+
// collapsible expert groups. Off by default (demo replay / legacy callers).
|
|
140
|
+
return groupExpert ? groupExpertItems(items, runningAgents) : items;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/* -------------------------------------------------------------------------- *
|
|
144
|
+
* #219 — expert-agent activity grouping.
|
|
145
|
+
* -------------------------------------------------------------------------- */
|
|
146
|
+
|
|
147
|
+
/** The principal (PI) agent name; unattributed items default to it. */
|
|
148
|
+
const PRINCIPAL = "principal";
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Important events stay visible even when they come from a specialist agent —
|
|
152
|
+
* they must never be buried inside a collapsed group (issue #219 UX goal:
|
|
153
|
+
* "avoid hiding important failures, blockers, or user-action-required events").
|
|
154
|
+
* Errors, approval/user-input requests, and warning+ system messages / hooks
|
|
155
|
+
* escape grouping and render standalone.
|
|
156
|
+
*/
|
|
157
|
+
function isImportantEvent(message: ChatMessage): boolean {
|
|
158
|
+
if (message.kind === "error" || message.kind === "ask_user") return true;
|
|
159
|
+
if (message.kind === "system_message") {
|
|
160
|
+
const level = message.systemMessage?.level;
|
|
161
|
+
return level === "warning" || level === "error" || level === "fatal";
|
|
162
|
+
}
|
|
163
|
+
if (message.kind === "hook") {
|
|
164
|
+
return message.hookLevel === "warning" || message.hookLevel === "error";
|
|
165
|
+
}
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Owning agent of a render item: the message's agent, or the activity's first step. */
|
|
170
|
+
function itemAgent(item: RenderItem): string {
|
|
171
|
+
if (item.type === "single") return item.message.agent ?? PRINCIPAL;
|
|
172
|
+
if (item.type === "activity") return item.steps[0]?.agent ?? PRINCIPAL;
|
|
173
|
+
return PRINCIPAL;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* A render item is a foldable specialist item when it belongs to a non-PI agent
|
|
178
|
+
* AND (for singles) is not an important event that must stay surfaced. User
|
|
179
|
+
* prompts and PI/unattributed items always break the run.
|
|
180
|
+
*/
|
|
181
|
+
function isFoldableExpertItem(item: RenderItem): boolean {
|
|
182
|
+
if (itemAgent(item) === PRINCIPAL) return false;
|
|
183
|
+
if (item.type === "single") {
|
|
184
|
+
if (item.message.role === "user") return false;
|
|
185
|
+
if (isImportantEvent(item.message)) return false;
|
|
186
|
+
}
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function itemStreaming(item: RenderItem, runningAgents?: ReadonlySet<string>): boolean {
|
|
191
|
+
if (item.type === "activity") return item.streaming;
|
|
192
|
+
if (item.type === "single") {
|
|
193
|
+
return !!item.message.streaming || (runningAgents?.has(itemAgent(item)) ?? false);
|
|
194
|
+
}
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Fold consecutive foldable specialist items into one expertGroup each. */
|
|
199
|
+
function groupExpertItems(items: RenderItem[], runningAgents?: ReadonlySet<string>): RenderItem[] {
|
|
200
|
+
const out: RenderItem[] = [];
|
|
201
|
+
let run: RenderItem[] = [];
|
|
202
|
+
const flushRun = () => {
|
|
203
|
+
if (run.length === 0) return;
|
|
204
|
+
// A lone expert `activity` item is already collapsed on its own — wrapping
|
|
205
|
+
// it in a second disclosure level just makes the user click twice. Leave it.
|
|
206
|
+
// A lone expert `single` (standalone text) still gets grouped so specialist
|
|
207
|
+
// chatter is collapsed by default (issue #219 acceptance criteria).
|
|
208
|
+
if (run.length === 1 && run[0].type === "activity") {
|
|
209
|
+
out.push(run[0]);
|
|
210
|
+
run = [];
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const agents = Array.from(new Set(run.map(itemAgent)));
|
|
214
|
+
out.push({
|
|
215
|
+
type: "expertGroup",
|
|
216
|
+
id: `expert-${idOf(run[0])}`,
|
|
217
|
+
agents,
|
|
218
|
+
items: run,
|
|
219
|
+
streaming: run.some((it) => itemStreaming(it, runningAgents)),
|
|
220
|
+
});
|
|
221
|
+
run = [];
|
|
222
|
+
};
|
|
223
|
+
for (const item of items) {
|
|
224
|
+
if (isFoldableExpertItem(item)) {
|
|
225
|
+
run.push(item);
|
|
226
|
+
} else {
|
|
227
|
+
flushRun();
|
|
228
|
+
out.push(item);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
flushRun();
|
|
232
|
+
return out;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Stable id for a render item (drives <details> DOM reuse across re-renders). */
|
|
236
|
+
function idOf(item: RenderItem): string {
|
|
237
|
+
if (item.type === "single") return item.message.id;
|
|
238
|
+
return item.id;
|
|
133
239
|
}
|
|
@@ -338,7 +338,17 @@ export function reduceMessagesForEvent(existing: ChatMessage[], event: WebSocket
|
|
|
338
338
|
|
|
339
339
|
// 修正6 — system_message: 4-level styled bubble in the conversation stream.
|
|
340
340
|
case "system_message": {
|
|
341
|
-
|
|
341
|
+
const msg = systemMessageToChatMessage(event);
|
|
342
|
+
// #167: coalesce by stable id — a repeated system_message carrying an id
|
|
343
|
+
// that already exists (e.g. an agent's retry warning ticking n/N) updates
|
|
344
|
+
// the existing bubble in place instead of stacking a new one. Messages
|
|
345
|
+
// without a stable id (random-id path) always append, as before.
|
|
346
|
+
const e = event as Record<string, unknown>;
|
|
347
|
+
const hasStableId = typeof (e.id ?? e.messageId) === "string";
|
|
348
|
+
if (hasStableId && existing.some((m) => m.id === msg.id)) {
|
|
349
|
+
return existing.map((m) => (m.id === msg.id ? msg : m));
|
|
350
|
+
}
|
|
351
|
+
return [...existing, msg];
|
|
342
352
|
}
|
|
343
353
|
|
|
344
354
|
// 修正6 — user_input_request (ask_user): interactive card. Keyed by
|