@brainpilot/web 0.0.8 → 0.0.10
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-D63mUJxx.js +450 -0
- package/dist/assets/index-D8J9Cnup.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +2 -2
- package/src/__tests__/api.test.ts +90 -1
- package/src/__tests__/demoTruncatedExport.test.ts +104 -0
- package/src/__tests__/rehydrateMerge.test.ts +40 -0
- package/src/__tests__/runningScripts.test.ts +139 -0
- package/src/__tests__/timelineBounds.test.ts +51 -0
- package/src/components/chat/MessageStream.tsx +1 -11
- package/src/components/chat/PromptComposer.tsx +118 -16
- package/src/components/chat/RunningScriptsPanel.tsx +118 -0
- package/src/components/chat/runningScripts.ts +88 -0
- package/src/components/demo/demoBundle.ts +8 -3
- package/src/components/files/FileSidebar.tsx +82 -11
- package/src/components/session/AgentNetwork.tsx +1 -0
- package/src/components/session/TimelineTab.tsx +39 -9
- package/src/components/settings/KnowledgeBasePanel.tsx +594 -0
- package/src/components/settings/SettingsDialog.tsx +12 -4
- package/src/contexts/SessionContext.tsx +31 -7
- package/src/contexts/messageReducer.ts +19 -0
- package/src/contracts/backend.ts +8 -1
- package/src/i18n/messages/chat.ts +4 -0
- package/src/i18n/messages/files.ts +2 -0
- package/src/i18n/messages/settings.ts +57 -0
- package/src/styles/global.css +139 -1
- package/src/utils/api.ts +139 -3
- package/src/utils/format.ts +9 -0
- package/dist/assets/index-162Pskp8.js +0 -438
- package/dist/assets/index-DWOsU22G.css +0 -1
|
@@ -102,6 +102,25 @@ const SessionContext = createContext<SessionContextValue | null>(null);
|
|
|
102
102
|
// CONTENT / END leaves old long sessions looking empty.
|
|
103
103
|
const HISTORY_REHYDRATE_LIMIT = 0;
|
|
104
104
|
|
|
105
|
+
/**
|
|
106
|
+
* #194-B1: merge the full rehydrated history under whatever the live message
|
|
107
|
+
* list already holds. On refresh the SSE ring-buffer tail seeds a few recent
|
|
108
|
+
* messages before history arrives; we must NOT discard the (complete) history
|
|
109
|
+
* just because the list is non-empty. The persisted history is the base; we
|
|
110
|
+
* append only the messages already shown that history doesn't contain (by id) —
|
|
111
|
+
* in-flight optimistic sends, or events newer than the persisted file. Ordering
|
|
112
|
+
* matters: history first (chronological), then the live-only tail.
|
|
113
|
+
*/
|
|
114
|
+
export function mergeRehydratedMessages(
|
|
115
|
+
existing: ChatMessage[],
|
|
116
|
+
history: ChatMessage[],
|
|
117
|
+
): ChatMessage[] {
|
|
118
|
+
if (existing.length === 0) return history;
|
|
119
|
+
const historyIds = new Set(history.map((m) => m.id));
|
|
120
|
+
const extra = existing.filter((m) => !historyIds.has(m.id));
|
|
121
|
+
return [...history, ...extra];
|
|
122
|
+
}
|
|
123
|
+
|
|
105
124
|
function foldSessionHistory(events: unknown[], sessionId: string): {
|
|
106
125
|
messages: ChatMessage[];
|
|
107
126
|
trace: TraceGraph | null;
|
|
@@ -293,13 +312,18 @@ export function SessionProvider({ children }: { children: ReactNode }) {
|
|
|
293
312
|
hydratedSessionsRef.current.add(sessionId);
|
|
294
313
|
if (lastUsage) setTokenUsage(lastUsage);
|
|
295
314
|
|
|
296
|
-
//
|
|
297
|
-
//
|
|
298
|
-
//
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
315
|
+
// Merge the full history under whatever SSE / optimistic messages have
|
|
316
|
+
// already landed — do NOT bail just because the list is non-empty
|
|
317
|
+
// (#194-B1). On refresh the SSE ring-buffer tail arrives first and seeds
|
|
318
|
+
// a few recent messages; the old `length > 0 → skip` guard then dropped
|
|
319
|
+
// the entire rehydrated history, leaving only those few. The persisted
|
|
320
|
+
// history is the complete log, so use it as the base and append only the
|
|
321
|
+
// messages SSE already showed that the history doesn't contain (by id) —
|
|
322
|
+
// in-flight optimistic sends, or events newer than the persisted file.
|
|
323
|
+
setMessagesBySession((current) => ({
|
|
324
|
+
...current,
|
|
325
|
+
[sessionId]: mergeRehydratedMessages(current[sessionId] ?? [], nextMessages),
|
|
326
|
+
}));
|
|
303
327
|
if (nextTrace) {
|
|
304
328
|
setTraceBySession((current) =>
|
|
305
329
|
current[sessionId] ? current : { ...current, [sessionId]: nextTrace! },
|
|
@@ -171,6 +171,25 @@ export function reduceMessagesForEvent(existing: ChatMessage[], event: WebSocket
|
|
|
171
171
|
// Strip NO-RENDER wrapper used by record_trace "Message Complete" hint
|
|
172
172
|
delta = delta.replace(/<!--NO-RENDER-->[\s\S]*?<!--\/NO-RENDER-->/g, "");
|
|
173
173
|
if (!delta) return existing;
|
|
174
|
+
// Orphaned CONTENT (no matching START) — recover gracefully instead of
|
|
175
|
+
// dropping it. This happens when a demo bundle was exported from a
|
|
176
|
+
// tail-sliced history: the leading START of the earliest messages is gone,
|
|
177
|
+
// and a plain `.map` here would no-op, silently swallowing the opening
|
|
178
|
+
// replies. Synthesize the message so the content still renders.
|
|
179
|
+
if (!existing.some((m) => m.id === id)) {
|
|
180
|
+
return [
|
|
181
|
+
...existing,
|
|
182
|
+
{
|
|
183
|
+
id,
|
|
184
|
+
role: "assistant",
|
|
185
|
+
content: delta,
|
|
186
|
+
createdAt: new Date().toISOString(),
|
|
187
|
+
agent,
|
|
188
|
+
streaming: true,
|
|
189
|
+
kind: "text",
|
|
190
|
+
},
|
|
191
|
+
];
|
|
192
|
+
}
|
|
174
193
|
return existing.map((m) =>
|
|
175
194
|
m.id === id ? { ...m, content: (m.content ?? "") + delta } : m,
|
|
176
195
|
);
|
package/src/contracts/backend.ts
CHANGED
|
@@ -481,7 +481,14 @@ function normalizeStringArray(value: unknown): string[] {
|
|
|
481
481
|
}
|
|
482
482
|
|
|
483
483
|
function camelizeKey(key: string): string {
|
|
484
|
-
|
|
484
|
+
// Preserve a leading-underscore prefix: `_ts` / `_seq` are AG-UI transport
|
|
485
|
+
// metadata whose underscore is significant. Without this guard the regex
|
|
486
|
+
// turns `_ts` into `Ts`, so `normalizeAgUiEvent` strips the timestamp and the
|
|
487
|
+
// demo replay's timeline collapses (every event lands at ms=0). Only internal
|
|
488
|
+
// snake_case boundaries (e.g. `agent_name` → `agentName`) are camelized.
|
|
489
|
+
const lead = key.match(/^_+/)?.[0] ?? "";
|
|
490
|
+
const rest = key.slice(lead.length);
|
|
491
|
+
return lead + rest.replace(/_([a-z])/g, (_, char: string) => char.toUpperCase());
|
|
485
492
|
}
|
|
486
493
|
|
|
487
494
|
function camelizeObject(value: unknown): unknown {
|
|
@@ -23,6 +23,8 @@ export default defineMessages(
|
|
|
23
23
|
"chat.agentsWorking": "{names} 正在工作",
|
|
24
24
|
"chat.aria.stop": "停止生成",
|
|
25
25
|
"chat.stop": "停止",
|
|
26
|
+
"chat.runningScripts.count": "{count} 个脚本正在运行",
|
|
27
|
+
"chat.runningScripts.pending": "参数传输中…",
|
|
26
28
|
"chat.aria.newPrompt": "新的研究提问",
|
|
27
29
|
"chat.srAsk": "提出一个研究问题",
|
|
28
30
|
"chat.placeholder": "向 MAS 提问。输入 @ 使用插件或提及文件",
|
|
@@ -86,6 +88,8 @@ export default defineMessages(
|
|
|
86
88
|
"chat.agentsWorking": "{names} are working",
|
|
87
89
|
"chat.aria.stop": "Stop generating",
|
|
88
90
|
"chat.stop": "stop",
|
|
91
|
+
"chat.runningScripts.count": "{count} script(s) running",
|
|
92
|
+
"chat.runningScripts.pending": "receiving args…",
|
|
89
93
|
"chat.aria.newPrompt": "New research prompt",
|
|
90
94
|
"chat.srAsk": "Ask a research question",
|
|
91
95
|
"chat.placeholder": "Ask MAS. Type @ to use plugins or mention files",
|
|
@@ -17,6 +17,7 @@ export default defineMessages(
|
|
|
17
17
|
"files.aria.tree": "文件树",
|
|
18
18
|
"files.selectForDownload": "选择 {name} 以下载",
|
|
19
19
|
"files.error.notRunning": "Sandbox 未运行,无法读取文件。",
|
|
20
|
+
"files.error.loadFailed": "加载文件列表失败",
|
|
20
21
|
"files.error.downloadFailed": "下载文件失败",
|
|
21
22
|
"files.error.refreshFailed": "刷新文件失败",
|
|
22
23
|
"files.error.previewFailed": "无法预览文件",
|
|
@@ -56,6 +57,7 @@ export default defineMessages(
|
|
|
56
57
|
"files.aria.tree": "File tree",
|
|
57
58
|
"files.selectForDownload": "Select {name} for download",
|
|
58
59
|
"files.error.notRunning": "Sandbox is not running; cannot read files.",
|
|
60
|
+
"files.error.loadFailed": "Failed to load file list",
|
|
59
61
|
"files.error.downloadFailed": "Failed to download file",
|
|
60
62
|
"files.error.refreshFailed": "Failed to refresh files",
|
|
61
63
|
"files.error.previewFailed": "Unable to preview file",
|
|
@@ -12,7 +12,34 @@ export default defineMessages(
|
|
|
12
12
|
"settings.tab.account": "账户",
|
|
13
13
|
"settings.tab.providers": "服务商",
|
|
14
14
|
"settings.tab.mcp": "MCP",
|
|
15
|
+
"settings.tab.knowledgeBase": "知识库",
|
|
15
16
|
"settings.tab.preferences": "偏好",
|
|
17
|
+
// knowledge base
|
|
18
|
+
"settings.kb.title": "本地知识库",
|
|
19
|
+
"settings.kb.desc": "将 PDF 放入 KnowledgeBase/source/pdf/,然后构建本地 RAG 索引。嵌入模型在本机运行;仅 OCR 与元数据抽取阶段需要联网。",
|
|
20
|
+
"settings.kb.ocrKey": "SiliconFlow OCR API Key",
|
|
21
|
+
"settings.kb.metaKey": "元数据抽取 API Key",
|
|
22
|
+
"settings.kb.metaBaseUrl": "元数据抽取 Base URL",
|
|
23
|
+
"settings.kb.metaModel": "元数据抽取模型",
|
|
24
|
+
"settings.kb.reuseAgentKey": "复用 agent 当前的 LLM key 进行元数据抽取",
|
|
25
|
+
"settings.kb.stages": "要运行的阶段",
|
|
26
|
+
"settings.kb.start": "构建知识库",
|
|
27
|
+
"settings.kb.cancel": "取消构建",
|
|
28
|
+
"settings.kb.progress": "阶段进度",
|
|
29
|
+
"settings.kb.log": "实时日志",
|
|
30
|
+
"settings.kb.logEmpty": "尚无构建事件 — 点击「构建知识库」开始。",
|
|
31
|
+
"settings.kb.error.missingOcrKey": "缺少 SiliconFlow OCR API Key",
|
|
32
|
+
"settings.kb.error.missingMetaKey": "缺少元数据抽取 API Key(或勾选「复用 agent LLM key」)",
|
|
33
|
+
"settings.kb.env.title": "Python 运行环境",
|
|
34
|
+
"settings.kb.env.venvMissing": "尚未为 KnowledgeBase 创建 Python 虚拟环境。点下面按钮一键创建:",
|
|
35
|
+
"settings.kb.env.setupButton": "一键配置 Python 环境",
|
|
36
|
+
"settings.kb.env.setupHint": "在 KnowledgeBase/.venv 里创建 venv 并安装 requirements.txt 中的所有依赖(约 2-5 分钟,需联网下载 PyTorch / FlagEmbedding)",
|
|
37
|
+
"settings.kb.env.reinstallButton": "重建虚拟环境",
|
|
38
|
+
"settings.kb.env.reinstallHint": "删除现有 .venv 并从头重装所有依赖",
|
|
39
|
+
"settings.kb.env.cliFallback": "或者也可以在终端手动执行",
|
|
40
|
+
"settings.kb.env.venvHint": "venv 创建后无需任何额外环境变量配置 —— 构建按钮会自动发现它。如果你想用别的解释器,把绝对路径写到 BP_KB_PYTHON 环境变量里再启动 BrainPilot。",
|
|
41
|
+
"settings.kb.env.needSetupFirst": "请先点击「一键配置 Python 环境」",
|
|
42
|
+
"settings.kb.env.busy": "Python 环境配置正在进行中…",
|
|
16
43
|
// account
|
|
17
44
|
"settings.account.title": "账户",
|
|
18
45
|
"settings.account.username": "用户名",
|
|
@@ -76,6 +103,8 @@ export default defineMessages(
|
|
|
76
103
|
"settings.providerForm.models": "模型",
|
|
77
104
|
"settings.providerForm.addModel": "添加模型",
|
|
78
105
|
"settings.providerForm.removeModel": "移除模型",
|
|
106
|
+
"settings.providerForm.modelsHint":
|
|
107
|
+
"示例模型仅作占位,保存前请改成你的网关支持的模型,否则发消息时才会报模型不可用。",
|
|
79
108
|
"settings.providerForm.useColor": "使用颜色 {color}",
|
|
80
109
|
"settings.providerForm.color": "颜色",
|
|
81
110
|
"settings.providerForm.cancel": "取消",
|
|
@@ -107,7 +136,33 @@ export default defineMessages(
|
|
|
107
136
|
"settings.tab.account": "Account",
|
|
108
137
|
"settings.tab.providers": "Providers",
|
|
109
138
|
"settings.tab.mcp": "MCP",
|
|
139
|
+
"settings.tab.knowledgeBase": "Knowledge Base",
|
|
110
140
|
"settings.tab.preferences": "Preferences",
|
|
141
|
+
"settings.kb.title": "Local Knowledge Base",
|
|
142
|
+
"settings.kb.desc": "Drop PDFs into KnowledgeBase/source/pdf/, then build a local RAG index. Embedding models run on your machine; only the OCR and metadata-extraction stages call out.",
|
|
143
|
+
"settings.kb.ocrKey": "SiliconFlow OCR API key",
|
|
144
|
+
"settings.kb.metaKey": "Metadata-extract API key",
|
|
145
|
+
"settings.kb.metaBaseUrl": "Metadata-extract base URL",
|
|
146
|
+
"settings.kb.metaModel": "Metadata-extract model",
|
|
147
|
+
"settings.kb.reuseAgentKey": "Reuse the agent's active LLM key for metadata extraction",
|
|
148
|
+
"settings.kb.stages": "Stages to run",
|
|
149
|
+
"settings.kb.start": "Build Knowledge Base",
|
|
150
|
+
"settings.kb.cancel": "Cancel build",
|
|
151
|
+
"settings.kb.progress": "Stage progress",
|
|
152
|
+
"settings.kb.log": "Live log",
|
|
153
|
+
"settings.kb.logEmpty": "No build events yet — press \"Build Knowledge Base\" to start.",
|
|
154
|
+
"settings.kb.error.missingOcrKey": "SiliconFlow OCR API key required",
|
|
155
|
+
"settings.kb.error.missingMetaKey": "Metadata-extract API key required (or check \"reuse agent LLM key\")",
|
|
156
|
+
"settings.kb.env.title": "Python environment",
|
|
157
|
+
"settings.kb.env.venvMissing": "No Python venv set up for KnowledgeBase yet. Click below to create one:",
|
|
158
|
+
"settings.kb.env.setupButton": "Set up Python environment",
|
|
159
|
+
"settings.kb.env.setupHint": "Creates KnowledgeBase/.venv and installs everything in requirements.txt (2-5 min; downloads PyTorch + FlagEmbedding)",
|
|
160
|
+
"settings.kb.env.reinstallButton": "Reinstall venv",
|
|
161
|
+
"settings.kb.env.reinstallHint": "Remove the existing .venv and reinstall every dependency from scratch",
|
|
162
|
+
"settings.kb.env.cliFallback": "Or run this in a terminal yourself",
|
|
163
|
+
"settings.kb.env.venvHint": "Once the venv exists no env vars are needed — the build button auto-detects it. To use a different interpreter, set BP_KB_PYTHON to its absolute path before launching BrainPilot.",
|
|
164
|
+
"settings.kb.env.needSetupFirst": "Click \"Set up Python environment\" first",
|
|
165
|
+
"settings.kb.env.busy": "Python environment setup is currently running…",
|
|
111
166
|
"settings.account.title": "Account",
|
|
112
167
|
"settings.account.username": "Username",
|
|
113
168
|
"settings.account.userId": "User ID",
|
|
@@ -166,6 +221,8 @@ export default defineMessages(
|
|
|
166
221
|
"settings.providerForm.models": "Models",
|
|
167
222
|
"settings.providerForm.addModel": "Add model",
|
|
168
223
|
"settings.providerForm.removeModel": "Remove model",
|
|
224
|
+
"settings.providerForm.modelsHint":
|
|
225
|
+
"The example model is only a placeholder — replace it with one your gateway supports before saving, or the model will be rejected when you send a message.",
|
|
169
226
|
"settings.providerForm.useColor": "Use color {color}",
|
|
170
227
|
"settings.providerForm.color": "Color",
|
|
171
228
|
"settings.providerForm.cancel": "Cancel",
|
package/src/styles/global.css
CHANGED
|
@@ -1683,7 +1683,10 @@ button {
|
|
|
1683
1683
|
.prompt-home--active .prompt-home__inner {
|
|
1684
1684
|
width: min(1040px, 100%);
|
|
1685
1685
|
height: 100%;
|
|
1686
|
-
|
|
1686
|
+
/* Rows: message stream (fills) | toast | running-scripts panel | composer.
|
|
1687
|
+
* The two middle rows are optional and collapse to zero when their children
|
|
1688
|
+
* are unmounted (empty child on `auto` row occupies no space). */
|
|
1689
|
+
grid-template-rows: minmax(0, 1fr) auto auto auto;
|
|
1687
1690
|
align-content: end;
|
|
1688
1691
|
gap: 16px;
|
|
1689
1692
|
justify-items: stretch;
|
|
@@ -1965,6 +1968,134 @@ button {
|
|
|
1965
1968
|
animation: pulse 1.5s infinite;
|
|
1966
1969
|
}
|
|
1967
1970
|
|
|
1971
|
+
/* Running-scripts panel — sits directly above the composer while any bash
|
|
1972
|
+
* tool call is streaming. Collapses/expands with a native <details>. Visual
|
|
1973
|
+
* weight matches .agent-running-toast so the two stack cleanly. */
|
|
1974
|
+
.running-scripts {
|
|
1975
|
+
justify-self: stretch;
|
|
1976
|
+
width: 100%;
|
|
1977
|
+
margin: 0 0 6px;
|
|
1978
|
+
border: 1px solid var(--color-border);
|
|
1979
|
+
border-radius: 12px;
|
|
1980
|
+
background: var(--color-surface-soft);
|
|
1981
|
+
color: var(--color-text);
|
|
1982
|
+
font-size: 13px;
|
|
1983
|
+
overflow: hidden;
|
|
1984
|
+
animation: fadeSlideIn var(--ease-layout) both;
|
|
1985
|
+
}
|
|
1986
|
+
.running-scripts > details > summary {
|
|
1987
|
+
display: flex;
|
|
1988
|
+
align-items: center;
|
|
1989
|
+
gap: 8px;
|
|
1990
|
+
padding: 8px 10px 8px 14px;
|
|
1991
|
+
cursor: pointer;
|
|
1992
|
+
list-style: none;
|
|
1993
|
+
user-select: none;
|
|
1994
|
+
}
|
|
1995
|
+
.running-scripts > details > summary::-webkit-details-marker {
|
|
1996
|
+
display: none;
|
|
1997
|
+
}
|
|
1998
|
+
.running-scripts__dot {
|
|
1999
|
+
display: inline-block;
|
|
2000
|
+
width: 8px;
|
|
2001
|
+
height: 8px;
|
|
2002
|
+
border-radius: 50%;
|
|
2003
|
+
background: var(--color-info);
|
|
2004
|
+
animation: pulse 1.5s infinite;
|
|
2005
|
+
flex: 0 0 auto;
|
|
2006
|
+
}
|
|
2007
|
+
.running-scripts__chevron {
|
|
2008
|
+
transition: transform var(--ease-standard);
|
|
2009
|
+
flex: 0 0 auto;
|
|
2010
|
+
color: var(--color-text-subtle);
|
|
2011
|
+
}
|
|
2012
|
+
.running-scripts > details:not([open]) .running-scripts__chevron {
|
|
2013
|
+
transform: rotate(-90deg);
|
|
2014
|
+
}
|
|
2015
|
+
.running-scripts__label {
|
|
2016
|
+
flex: 1 1 auto;
|
|
2017
|
+
min-width: 0;
|
|
2018
|
+
overflow: hidden;
|
|
2019
|
+
text-overflow: ellipsis;
|
|
2020
|
+
white-space: nowrap;
|
|
2021
|
+
}
|
|
2022
|
+
.running-scripts__stop {
|
|
2023
|
+
display: inline-flex;
|
|
2024
|
+
align-items: center;
|
|
2025
|
+
gap: 4px;
|
|
2026
|
+
flex: 0 0 auto;
|
|
2027
|
+
height: 22px;
|
|
2028
|
+
padding: 0 9px;
|
|
2029
|
+
border: 1px solid var(--color-border);
|
|
2030
|
+
border-radius: 999px;
|
|
2031
|
+
background: var(--color-surface);
|
|
2032
|
+
color: var(--color-text-muted);
|
|
2033
|
+
font-size: 11px;
|
|
2034
|
+
cursor: pointer;
|
|
2035
|
+
transition:
|
|
2036
|
+
background var(--ease-standard),
|
|
2037
|
+
border-color var(--ease-standard),
|
|
2038
|
+
color var(--ease-standard);
|
|
2039
|
+
}
|
|
2040
|
+
.running-scripts__stop:hover {
|
|
2041
|
+
background: var(--color-danger-soft);
|
|
2042
|
+
border-color: color-mix(in srgb, var(--color-danger) 40%, var(--color-border));
|
|
2043
|
+
color: var(--color-danger);
|
|
2044
|
+
}
|
|
2045
|
+
.running-scripts__list {
|
|
2046
|
+
list-style: none;
|
|
2047
|
+
margin: 0;
|
|
2048
|
+
padding: 0 10px 10px;
|
|
2049
|
+
display: flex;
|
|
2050
|
+
flex-direction: column;
|
|
2051
|
+
gap: 8px;
|
|
2052
|
+
}
|
|
2053
|
+
.running-scripts__item {
|
|
2054
|
+
border: 1px solid var(--color-border);
|
|
2055
|
+
border-radius: 10px;
|
|
2056
|
+
background: var(--color-surface);
|
|
2057
|
+
padding: 8px 10px;
|
|
2058
|
+
display: flex;
|
|
2059
|
+
flex-direction: column;
|
|
2060
|
+
gap: 6px;
|
|
2061
|
+
}
|
|
2062
|
+
.running-scripts__item-head {
|
|
2063
|
+
display: flex;
|
|
2064
|
+
align-items: center;
|
|
2065
|
+
gap: 8px;
|
|
2066
|
+
font-size: 12px;
|
|
2067
|
+
color: var(--color-text-subtle);
|
|
2068
|
+
}
|
|
2069
|
+
.running-scripts__item-agent {
|
|
2070
|
+
padding: 1px 8px;
|
|
2071
|
+
border-radius: 999px;
|
|
2072
|
+
background: var(--color-hover);
|
|
2073
|
+
color: var(--color-text);
|
|
2074
|
+
font-size: 11px;
|
|
2075
|
+
}
|
|
2076
|
+
.running-scripts__item-name {
|
|
2077
|
+
font-family: var(--font-mono, ui-monospace, SFMono-Regular, monospace);
|
|
2078
|
+
color: var(--color-text);
|
|
2079
|
+
}
|
|
2080
|
+
.running-scripts__item-elapsed {
|
|
2081
|
+
margin-left: auto;
|
|
2082
|
+
font-variant-numeric: tabular-nums;
|
|
2083
|
+
}
|
|
2084
|
+
.running-scripts__cmd {
|
|
2085
|
+
margin: 0;
|
|
2086
|
+
padding: 8px 10px;
|
|
2087
|
+
border-radius: 8px;
|
|
2088
|
+
background: var(--color-surface-raised);
|
|
2089
|
+
color: var(--color-text);
|
|
2090
|
+
font-family: var(--font-mono, ui-monospace, SFMono-Regular, monospace);
|
|
2091
|
+
font-size: 12px;
|
|
2092
|
+
line-height: 1.45;
|
|
2093
|
+
white-space: pre-wrap;
|
|
2094
|
+
word-break: break-word;
|
|
2095
|
+
overflow-x: auto;
|
|
2096
|
+
max-height: 180px;
|
|
2097
|
+
}
|
|
2098
|
+
|
|
1968
2099
|
.command-picker {
|
|
1969
2100
|
position: relative;
|
|
1970
2101
|
}
|
|
@@ -4755,6 +4886,13 @@ button {
|
|
|
4755
4886
|
gap: 8px;
|
|
4756
4887
|
}
|
|
4757
4888
|
|
|
4889
|
+
.provider-form__models-hint {
|
|
4890
|
+
margin: 8px 0 0;
|
|
4891
|
+
color: var(--color-text-subtle);
|
|
4892
|
+
font-size: 11px;
|
|
4893
|
+
line-height: 1.45;
|
|
4894
|
+
}
|
|
4895
|
+
|
|
4758
4896
|
.provider-model-row {
|
|
4759
4897
|
display: grid !important;
|
|
4760
4898
|
grid-template-columns: minmax(0, 1fr) 40px;
|
package/src/utils/api.ts
CHANGED
|
@@ -66,13 +66,44 @@ function authHeaders(json = true): Record<string, string> {
|
|
|
66
66
|
return json ? { "Content-Type": "application/json" } : {};
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
/**
|
|
70
|
+
* #206: build a readable message from a Zod issue list. The backend returns
|
|
71
|
+
* `details: parsed.error.issues` — each issue has a `path` (field) and a
|
|
72
|
+
* `message`. We render `field: message` per issue so a validation 400 tells the
|
|
73
|
+
* user *which* field is wrong (empty name, invalid url, …) instead of degrading
|
|
74
|
+
* to a generic "Request failed (400)".
|
|
75
|
+
*/
|
|
76
|
+
function formatIssues(details: unknown): string | null {
|
|
77
|
+
if (!Array.isArray(details) || details.length === 0) return null;
|
|
78
|
+
const parts: string[] = [];
|
|
79
|
+
for (const issue of details) {
|
|
80
|
+
if (!issue || typeof issue !== "object") continue;
|
|
81
|
+
const { path, message } = issue as { path?: unknown; message?: unknown };
|
|
82
|
+
if (typeof message !== "string" || message.length === 0) continue;
|
|
83
|
+
const field = Array.isArray(path) ? path.filter((p) => p !== "" && p != null).join(".") : "";
|
|
84
|
+
parts.push(field ? `${field}: ${message}` : message);
|
|
85
|
+
}
|
|
86
|
+
return parts.length > 0 ? parts.join("; ") : null;
|
|
87
|
+
}
|
|
88
|
+
|
|
69
89
|
async function parseError(res: Response): Promise<string> {
|
|
70
90
|
const contentType = res.headers.get("content-type") || "";
|
|
71
91
|
if (contentType.includes("application/json")) {
|
|
72
|
-
|
|
73
|
-
|
|
92
|
+
// #206: the backend uses two shapes — `{ detail }` (single string) and the
|
|
93
|
+
// Zod validation shape `{ error, details }`. parseError previously read only
|
|
94
|
+
// `detail`, so every `{ error, details }` 400/409 fell through to the generic
|
|
95
|
+
// text fallback. Read all three: detail → error(+formatted details) → error.
|
|
96
|
+
const body = (await res.json().catch(() => null)) as
|
|
97
|
+
| { detail?: unknown; error?: unknown; details?: unknown }
|
|
98
|
+
| null;
|
|
99
|
+
if (typeof body?.detail === "string" && body.detail.length > 0) {
|
|
74
100
|
return body.detail;
|
|
75
101
|
}
|
|
102
|
+
const issues = formatIssues(body?.details);
|
|
103
|
+
if (typeof body?.error === "string" && body.error.length > 0) {
|
|
104
|
+
return issues ? `${body.error} (${issues})` : body.error;
|
|
105
|
+
}
|
|
106
|
+
if (issues) return issues;
|
|
76
107
|
}
|
|
77
108
|
const text = await res.text().catch(() => "");
|
|
78
109
|
return text || `Request failed (${res.status})`;
|
|
@@ -123,6 +154,20 @@ export const api = {
|
|
|
123
154
|
return handleJson(await apiFetch(`${API_BASE}/version`));
|
|
124
155
|
},
|
|
125
156
|
|
|
157
|
+
// #156: real on-disk paths for the Files panel (local mode only). Hosted
|
|
158
|
+
// backends return `{ localMode: false }` with no host path. Best-effort:
|
|
159
|
+
// any failure resolves to a non-local shape so callers fall back cleanly.
|
|
160
|
+
async getInfo(): Promise<{ localMode: boolean; dataDir?: string; workspacesRoot?: string }> {
|
|
161
|
+
if (runtimeConfig.useMockBackend) {
|
|
162
|
+
return { localMode: false };
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
return await handleJson(await apiFetch(`${API_BASE}/info`));
|
|
166
|
+
} catch {
|
|
167
|
+
return { localMode: false };
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
|
|
126
171
|
auth: {
|
|
127
172
|
async me(): Promise<User> {
|
|
128
173
|
if (runtimeConfig.useMockBackend) {
|
|
@@ -505,7 +550,14 @@ export const api = {
|
|
|
505
550
|
`${API_BASE}/sessions/${sessionId}/history${qs}`,
|
|
506
551
|
{ headers: authHeaders() },
|
|
507
552
|
);
|
|
508
|
-
|
|
553
|
+
// A 404 means the session has no transcript on disk (genuinely empty) —
|
|
554
|
+
// return an empty history. Any OTHER non-OK status is a real failure
|
|
555
|
+
// (routing / storage / auth); surface it instead of silently rendering an
|
|
556
|
+
// empty transcript, which historically masked broken rehydrates (#223).
|
|
557
|
+
if (res.status === 404) return { events: [], total: 0, truncated: false };
|
|
558
|
+
if (!res.ok) {
|
|
559
|
+
throw new Error(`history fetch failed: ${res.status} ${res.statusText}`);
|
|
560
|
+
}
|
|
509
561
|
const raw = (await res.json().catch(() => null)) as
|
|
510
562
|
| { events?: unknown[]; total?: number; truncated?: boolean }
|
|
511
563
|
| null;
|
|
@@ -721,4 +773,88 @@ export const api = {
|
|
|
721
773
|
return normalizeProviderProfile(raw as Parameters<typeof normalizeProviderProfile>[0]);
|
|
722
774
|
},
|
|
723
775
|
},
|
|
776
|
+
|
|
777
|
+
// Knowledge Base — local pipeline build orchestration.
|
|
778
|
+
// Mirrors the backend /api/kb/* routes. The SSE event stream is consumed
|
|
779
|
+
// directly via `new EventSource()` in the panel component (so it can stay
|
|
780
|
+
// attached for the lifetime of the dialog), so we don't expose a helper
|
|
781
|
+
// here for it.
|
|
782
|
+
kb: {
|
|
783
|
+
async build(opts: {
|
|
784
|
+
ocrApiKey?: string;
|
|
785
|
+
metaApiKey?: string;
|
|
786
|
+
metaBaseUrl?: string;
|
|
787
|
+
metaModel?: string;
|
|
788
|
+
kbRoot?: string;
|
|
789
|
+
ocrConcurrency?: number;
|
|
790
|
+
ocrLimit?: number;
|
|
791
|
+
skip?: Array<"ocr" | "extract" | "chunk" | "vectorize">;
|
|
792
|
+
only?: Array<"ocr" | "extract" | "chunk" | "vectorize">;
|
|
793
|
+
}): Promise<{ ok: boolean; startedAt?: number; error?: string }> {
|
|
794
|
+
const res = await apiFetch(`${API_BASE}/kb/build`, {
|
|
795
|
+
method: "POST",
|
|
796
|
+
headers: { "content-type": "application/json" },
|
|
797
|
+
body: JSON.stringify(opts),
|
|
798
|
+
});
|
|
799
|
+
if (!res.ok) {
|
|
800
|
+
const body = await res.json().catch(() => ({}));
|
|
801
|
+
return { ok: false, error: body.error || `kb build failed (${res.status})` };
|
|
802
|
+
}
|
|
803
|
+
return handleJson(res);
|
|
804
|
+
},
|
|
805
|
+
|
|
806
|
+
async status(): Promise<{
|
|
807
|
+
active: boolean;
|
|
808
|
+
startedAt: number | null;
|
|
809
|
+
finishedAt: number | null;
|
|
810
|
+
exitCode: number | null | undefined;
|
|
811
|
+
error?: string;
|
|
812
|
+
recentEvents: Array<{
|
|
813
|
+
ts: string;
|
|
814
|
+
stage: string;
|
|
815
|
+
event: string;
|
|
816
|
+
msg: string;
|
|
817
|
+
[k: string]: unknown;
|
|
818
|
+
}>;
|
|
819
|
+
environment: {
|
|
820
|
+
python: string;
|
|
821
|
+
pythonIsVenv: boolean;
|
|
822
|
+
venvExists: boolean;
|
|
823
|
+
expectedVenvPath: string;
|
|
824
|
+
scriptsPresent: boolean;
|
|
825
|
+
kbRoot: string;
|
|
826
|
+
};
|
|
827
|
+
}> {
|
|
828
|
+
return handleJson(await apiFetch(`${API_BASE}/kb/status`));
|
|
829
|
+
},
|
|
830
|
+
|
|
831
|
+
async cancel(): Promise<{ ok: boolean; message?: string }> {
|
|
832
|
+
const res = await apiFetch(`${API_BASE}/kb/cancel`, { method: "POST" });
|
|
833
|
+
return res.json().catch(() => ({ ok: false }));
|
|
834
|
+
},
|
|
835
|
+
|
|
836
|
+
// Bootstrap the Python venv (KnowledgeBase/.venv) before the first
|
|
837
|
+
// build can run. Streams progress on the same SSE channel as `build`,
|
|
838
|
+
// so the panel only needs one EventSource subscription.
|
|
839
|
+
async setupEnv(opts: {
|
|
840
|
+
python?: string;
|
|
841
|
+
reinstall?: boolean;
|
|
842
|
+
kbRoot?: string;
|
|
843
|
+
} = {}): Promise<{ ok: boolean; startedAt?: number; error?: string }> {
|
|
844
|
+
const res = await apiFetch(`${API_BASE}/kb/setup-env`, {
|
|
845
|
+
method: "POST",
|
|
846
|
+
headers: { "content-type": "application/json" },
|
|
847
|
+
body: JSON.stringify(opts),
|
|
848
|
+
});
|
|
849
|
+
if (!res.ok) {
|
|
850
|
+
const body = await res.json().catch(() => ({}));
|
|
851
|
+
return { ok: false, error: body.error || `setup-env failed (${res.status})` };
|
|
852
|
+
}
|
|
853
|
+
return handleJson(res);
|
|
854
|
+
},
|
|
855
|
+
|
|
856
|
+
eventsUrl(): string {
|
|
857
|
+
return `${API_BASE}/kb/events`;
|
|
858
|
+
},
|
|
859
|
+
},
|
|
724
860
|
};
|
package/src/utils/format.ts
CHANGED
|
@@ -5,3 +5,12 @@ export function formatBytes(bytes: number) {
|
|
|
5
5
|
const value = bytes / 1024 ** index;
|
|
6
6
|
return `${value >= 10 ? value.toFixed(0) : value.toFixed(1)} ${units[index]}`;
|
|
7
7
|
}
|
|
8
|
+
|
|
9
|
+
/** Compact elapsed formatter: "3.2s" under a minute, "1m 05s" above. */
|
|
10
|
+
export function formatElapsed(ms: number): string {
|
|
11
|
+
const totalSeconds = Math.max(0, ms) / 1000;
|
|
12
|
+
if (totalSeconds < 60) return `${totalSeconds.toFixed(1)}s`;
|
|
13
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
14
|
+
const seconds = Math.floor(totalSeconds % 60);
|
|
15
|
+
return `${minutes}m ${String(seconds).padStart(2, "0")}s`;
|
|
16
|
+
}
|