@sanqianx/claude-ai-workbench 0.11.0
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/LICENSE +201 -0
- package/README.md +129 -0
- package/apps/agent-terminal/app.css +567 -0
- package/apps/agent-terminal/app.js +2392 -0
- package/apps/agent-terminal/index.html +158 -0
- package/apps/ai-profile-settings-host/index.html +25 -0
- package/apps/ai-profile-settings-host/page.css +1 -0
- package/apps/ai-profile-settings-host/page.js +113 -0
- package/apps/minimal-test-host/README.md +15 -0
- package/apps/minimal-test-host/host.css +2 -0
- package/apps/minimal-test-host/host.js +35 -0
- package/apps/minimal-test-host/index.html +58 -0
- package/apps/minimal-test-host/optional-mock/fault-injection.js +15 -0
- package/docs/sdk/FILE_LAYOUT.md +31 -0
- package/docs/sdk/INTEGRATION.md +126 -0
- package/docs/sdk/README.md +21 -0
- package/docs/sdk/openapi.yaml +760 -0
- package/package.json +51 -0
- package/packages/client/README.md +11 -0
- package/packages/client/index.d.ts +81 -0
- package/packages/client/index.js +198 -0
- package/packages/client/package.json +29 -0
- package/packages/contracts/README.md +9 -0
- package/packages/contracts/index.d.ts +252 -0
- package/packages/contracts/index.js +295 -0
- package/packages/contracts/package.json +24 -0
- package/packages/core/README.md +8 -0
- package/packages/core/index.d.ts +76 -0
- package/packages/core/index.js +214 -0
- package/packages/core/package.json +29 -0
- package/packages/server/README.md +17 -0
- package/packages/server/bin/agent-terminal-server.js +36 -0
- package/packages/server/bin/claude-workbench-server.js +46 -0
- package/packages/server/bin/claude-workbench.js +225 -0
- package/packages/server/index.d.ts +145 -0
- package/packages/server/index.js +183 -0
- package/packages/server/lib/agent-discovery.js +112 -0
- package/packages/server/lib/agent-registry.js +98 -0
- package/packages/server/lib/attachment-store.js +78 -0
- package/packages/server/lib/bridges/app-server-bridge.js +372 -0
- package/packages/server/lib/bridges/opencode-server-bridge.js +490 -0
- package/packages/server/lib/capture-settings-store.js +34 -0
- package/packages/server/lib/context-resolver.js +40 -0
- package/packages/server/lib/drivers/cli-json-runner.js +960 -0
- package/packages/server/lib/drivers/codex-driver.js +1413 -0
- package/packages/server/lib/drivers/opencode-driver.js +1527 -0
- package/packages/server/lib/drivers/zcode-driver.js +1167 -0
- package/packages/server/lib/event-stream.js +35 -0
- package/packages/server/lib/folder-picker.js +623 -0
- package/packages/server/lib/http-handler.js +254 -0
- package/packages/server/lib/json-file.js +19 -0
- package/packages/server/lib/model-detection.js +295 -0
- package/packages/server/lib/profile-store.js +149 -0
- package/packages/server/lib/project-store.js +102 -0
- package/packages/server/lib/session-archive-store.js +53 -0
- package/packages/server/lib/system-doctor.js +119 -0
- package/packages/server/lib/ui-preferences-store.js +48 -0
- package/packages/server/lib/workbench-service.js +620 -0
- package/packages/server/package.json +34 -0
- package/packages/ui/README.md +30 -0
- package/packages/ui/ai-profile-settings.d.ts +45 -0
- package/packages/ui/ai-profile-settings.js +283 -0
- package/packages/ui/claude-terminal.d.ts +31 -0
- package/packages/ui/claude-terminal.js +343 -0
- package/packages/ui/index.d.ts +18 -0
- package/packages/ui/index.js +51 -0
- package/packages/ui/package.json +42 -0
- package/packages/ui/theme.css +13 -0
- package/src/assets/claude-prompts.json +21 -0
- package/src/backend/http/ai-profile-routes.js +61 -0
- package/src/backend/http/claude-routes.js +170 -0
- package/src/backend/http/node-helpers.js +45 -0
- package/src/backend/index.js +21 -0
- package/src/backend/lib/ai-adapter.js +144 -0
- package/src/backend/lib/ai-profile-store.js +259 -0
- package/src/backend/lib/ai-vendor-presets.js +83 -0
- package/src/backend/lib/ai-workspace.js +140 -0
- package/src/backend/lib/automation-config.js +253 -0
- package/src/backend/lib/claude-cli-runner.js +1749 -0
- package/src/backend/lib/data-dir.js +169 -0
- package/src/backend/lib/llm-client.js +176 -0
- package/src/backend/lib/model-context-windows.js +82 -0
- package/src/backend/lib/prompt-registry.js +166 -0
- package/src/backend/lib/runtime-config.js +121 -0
- package/src/index.js +7 -0
- package/src/sdk-wave-a.ts +1 -0
- package/src/sdk-wave-b.ts +1 -0
- package/src/sdk-wave-c.ts +1 -0
- package/src/sdk-wave-d.ts +1 -0
- package/src/sdk-wave-e.ts +1 -0
|
@@ -0,0 +1,2392 @@
|
|
|
1
|
+
/* Agent Terminal — full-page UI over the versioned REST/SSE API. */
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const client = window.createClaudeWorkbenchClient
|
|
5
|
+
? window.createClaudeWorkbenchClient({ apiBase: '/api/claude-workbench/v1' })
|
|
6
|
+
: null;
|
|
7
|
+
|
|
8
|
+
const PERMISSION_LABELS = { default: '默认防护', acceptEdits: '接受编辑', plan: '计划模式', bypassPermissions: '完全访问' };
|
|
9
|
+
const EFFORT_LABELS = { low: '低', medium: '中', high: '高', max: '最高' };
|
|
10
|
+
const AGENT_LABELS = { 'claude-code': 'Claude Code', codex: 'Codex', opencode: 'OpenCode', zcode: 'ZCode' };
|
|
11
|
+
|
|
12
|
+
const state = {
|
|
13
|
+
agents: [],
|
|
14
|
+
projects: [],
|
|
15
|
+
profiles: [],
|
|
16
|
+
sessionTitles: new Map(),
|
|
17
|
+
activeProjectId: null,
|
|
18
|
+
activeSessionId: null,
|
|
19
|
+
unsub: null,
|
|
20
|
+
permissionMode: 'default',
|
|
21
|
+
effort: 'high',
|
|
22
|
+
currentAgent: 'claude-code',
|
|
23
|
+
currentProfileId: undefined, // undefined: project default; null: agent-owned auth
|
|
24
|
+
currentModel: null,
|
|
25
|
+
sessionSummary: null,
|
|
26
|
+
attachments: [],
|
|
27
|
+
busy: false,
|
|
28
|
+
follow: true, // auto-scroll sticks to bottom until the user scrolls away
|
|
29
|
+
replaying: false, // true while replaying a session snapshot: no live timers/durations
|
|
30
|
+
expanded: new Set(),
|
|
31
|
+
caps: null,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const $ = id => document.getElementById(id);
|
|
35
|
+
const el = (tag, cls, text) => { const node = document.createElement(tag); if (cls) node.className = cls; if (text != null) node.textContent = text; return node; };
|
|
36
|
+
|
|
37
|
+
// Typed toasts: good / bad / neutral, each with an icon, stacked bottom-center.
|
|
38
|
+
// `bad` stays boolean-compatible with the original signature; 'good' opts into
|
|
39
|
+
// the success treatment. Purely visual — timing and message text are unchanged.
|
|
40
|
+
function toast(message, bad, good) {
|
|
41
|
+
const host = $('toast');
|
|
42
|
+
const kind = bad ? 'bad' : good ? 'good' : '';
|
|
43
|
+
const item = el('div', `toast${kind ? ` ${kind}` : ''}`);
|
|
44
|
+
item.append(icon(bad ? 'warn' : good ? 'check' : 'info', 14));
|
|
45
|
+
item.append(el('span', null, String(message)));
|
|
46
|
+
host.append(item);
|
|
47
|
+
while (host.children.length > 3) host.firstChild.remove();
|
|
48
|
+
setTimeout(() => {
|
|
49
|
+
item.classList.add('leaving');
|
|
50
|
+
setTimeout(() => item.remove(), 160);
|
|
51
|
+
}, 3200);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function capabilitiesOf(agentId) {
|
|
55
|
+
const agent = state.agents.find(item => item.id === agentId);
|
|
56
|
+
return agent ? agent.capabilities : { thinking: true, permissions: true, toolStream: true, images: true, resume: true, modelOverride: true, generationParams: [] };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/* ===================== last-used preferences ===================== */
|
|
60
|
+
|
|
61
|
+
// The composer selectors (protection mode, model, effort, agent) must survive
|
|
62
|
+
// conversation/project switches and full restarts. The server keeps the last
|
|
63
|
+
// used combination in its data directory; the UI loads it on boot and stores
|
|
64
|
+
// every user-driven change back.
|
|
65
|
+
const PREFS_KEYS = ['permissionMode', 'effort', 'currentAgent', 'currentProfileId', 'currentModel'];
|
|
66
|
+
let savedPreferences = {};
|
|
67
|
+
let prefsSaveTimer = null;
|
|
68
|
+
|
|
69
|
+
function applyPreferences(values) {
|
|
70
|
+
if (values.permissionMode) { state.permissionMode = values.permissionMode; renderModeLabel(); }
|
|
71
|
+
if (values.effort) { state.effort = values.effort; $('effort-label').textContent = EFFORT_LABELS[state.effort] || state.effort; }
|
|
72
|
+
if (values.currentAgent) { state.currentAgent = values.currentAgent; }
|
|
73
|
+
if (values.currentProfileId !== undefined) { state.currentProfileId = values.currentProfileId; }
|
|
74
|
+
if (values.currentModel) { state.currentModel = values.currentModel; renderModelLabel(); }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function loadPreferences() {
|
|
78
|
+
try {
|
|
79
|
+
const response = await fetch('/api/claude-workbench/v1/ui-preferences');
|
|
80
|
+
if (!response.ok) return;
|
|
81
|
+
const body = await response.json();
|
|
82
|
+
savedPreferences = { ...(body && body.values || {}) };
|
|
83
|
+
applyPreferences(savedPreferences);
|
|
84
|
+
} catch { /* unreachable server: hardcoded defaults remain */ }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function savePreferences(partial) {
|
|
88
|
+
for (const key of PREFS_KEYS) {
|
|
89
|
+
if (partial[key] !== undefined) savedPreferences[key] = partial[key];
|
|
90
|
+
}
|
|
91
|
+
clearTimeout(prefsSaveTimer);
|
|
92
|
+
prefsSaveTimer = setTimeout(() => {
|
|
93
|
+
if (typeof fetch !== 'function') return; // non-browser harness (unit tests)
|
|
94
|
+
fetch('/api/claude-workbench/v1/ui-preferences', {
|
|
95
|
+
method: 'PUT',
|
|
96
|
+
headers: { 'Content-Type': 'application/json' },
|
|
97
|
+
body: JSON.stringify(partial),
|
|
98
|
+
}).catch(() => { /* retried on the next change */ });
|
|
99
|
+
}, 300);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/* ===================== init ===================== */
|
|
103
|
+
|
|
104
|
+
async function init() {
|
|
105
|
+
try {
|
|
106
|
+
const health = await client.health();
|
|
107
|
+
$('app-version').textContent = `v${health.version}`;
|
|
108
|
+
$('about-text').textContent = `Agent Terminal v${health.version} · API ${health.apiVersion} · ${health.sessions} 个会话`;
|
|
109
|
+
} catch { /* server unreachable is surfaced by the panels themselves */ }
|
|
110
|
+
await Promise.all([refreshAgents(), refreshProfiles()]);
|
|
111
|
+
await loadPreferences();
|
|
112
|
+
await refreshProjects();
|
|
113
|
+
// 首个 kb:ready 在项目清单就绪前就已发出(上方 health/agents/profiles 都要
|
|
114
|
+
// 先跑完),壳届时下发的 kb:select-project 会扑空。清单就绪后再广播一次,
|
|
115
|
+
// 让壳重新选中项目,避免嵌入态卡在"未登记/无项目可选"。
|
|
116
|
+
if (window.parent !== window) window.parent.postMessage({ type: 'kb:ready', app: 'agent-terminal' }, '*');
|
|
117
|
+
wireChrome();
|
|
118
|
+
wireComposer();
|
|
119
|
+
wireSettings();
|
|
120
|
+
wireProjectModal();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/* ===================== agents & capabilities ===================== */
|
|
124
|
+
|
|
125
|
+
async function refreshAgents() {
|
|
126
|
+
try { state.agents = await client.listAgents(); } catch { state.agents = []; }
|
|
127
|
+
renderAgentSummary();
|
|
128
|
+
renderAgentCards();
|
|
129
|
+
applyCapabilityGates();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function renderAgentSummary() {
|
|
133
|
+
const available = state.agents.filter(agent => agent.available);
|
|
134
|
+
const dot = $('agent-dot');
|
|
135
|
+
dot.classList.toggle('run', false);
|
|
136
|
+
dot.style.background = available.length ? 'var(--good)' : 'var(--bad)';
|
|
137
|
+
$('agent-summary').textContent = available.length
|
|
138
|
+
? `已发现 ${available.length} 个 Agent:${available.map(a => a.label).join(' · ')}`
|
|
139
|
+
: '未发现可用 Agent';
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function renderAgentCards() {
|
|
143
|
+
const wrap = $('agent-cards');
|
|
144
|
+
wrap.textContent = '';
|
|
145
|
+
for (const agent of state.agents) {
|
|
146
|
+
const card = el('div', 'agent-card' + (agent.available ? '' : ' missing'));
|
|
147
|
+
const badge = el('span', `agent-badge ${agent.id}`, agent.id === 'claude-code' ? 'C' : agent.id === 'codex' ? 'X' : agent.id === 'zcode' ? 'Z' : 'O');
|
|
148
|
+
const body = el('div', 'a-body');
|
|
149
|
+
const name = el('div', 'a-name');
|
|
150
|
+
name.append(el('span', null, agent.label));
|
|
151
|
+
const chip = el('span', 'chip ' + (agent.available ? 'good' : ''), agent.available ? (agent.version ? `v${agent.version}` : '可用') : '不可用');
|
|
152
|
+
name.append(chip);
|
|
153
|
+
body.append(name);
|
|
154
|
+
body.append(el('div', 'a-meta', `${agent.transport} · 认证:${agent.authModes.join(' / ')}${agent.reason ? ` · ${agent.reason}` : ''}`));
|
|
155
|
+
const chips = el('div', 'cap-chips');
|
|
156
|
+
const caps = agent.capabilities;
|
|
157
|
+
for (const [key, label] of [['thinking', '思考流'], ['permissions', '权限'], ['toolStream', '工具流'], ['images', '图片'], ['resume', '恢复会话'], ['modelOverride', '模型覆盖']]) {
|
|
158
|
+
const chip = el('span', 'chip' + (caps[key] ? '' : ' off'));
|
|
159
|
+
if (caps[key]) chip.append(el('span', null, label));
|
|
160
|
+
else {
|
|
161
|
+
const off = el('span', null, label);
|
|
162
|
+
off.style.opacity = '.7';
|
|
163
|
+
chip.append(icon('close', 10), off);
|
|
164
|
+
}
|
|
165
|
+
chips.append(chip);
|
|
166
|
+
}
|
|
167
|
+
if (caps.generationParams.includes('effort')) chips.append(el('span', 'chip', '推理力度'));
|
|
168
|
+
body.append(chips);
|
|
169
|
+
card.append(badge, body);
|
|
170
|
+
if (agent.id === state.currentAgent) card.style.outline = '1px solid var(--blue)';
|
|
171
|
+
card.addEventListener('click', () => { selectAgent(agent.id); });
|
|
172
|
+
wrap.append(card);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function selectAgent(agentId) {
|
|
177
|
+
prepareSelectionChange();
|
|
178
|
+
state.currentAgent = agentId;
|
|
179
|
+
savePreferences({ currentAgent: agentId });
|
|
180
|
+
applyCapabilityGates();
|
|
181
|
+
renderAgentCards();
|
|
182
|
+
renderModelLabel();
|
|
183
|
+
toast(`新会话将使用 ${AGENT_LABELS[agentId] || agentId}`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function applyCapabilityGates() {
|
|
187
|
+
const caps = capabilitiesOf(state.currentAgent);
|
|
188
|
+
state.caps = caps;
|
|
189
|
+
$('effort-anchor').hidden = !caps.generationParams.includes('effort');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/* ===================== profiles & model selector ===================== */
|
|
193
|
+
|
|
194
|
+
async function refreshProfiles() {
|
|
195
|
+
try { state.profiles = await client.listProfiles(); } catch { state.profiles = []; }
|
|
196
|
+
renderModelLabel();
|
|
197
|
+
renderProviderList();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function renderModelLabel() {
|
|
201
|
+
const selection = resolvedSelection();
|
|
202
|
+
const label = `${selection.providerName} · ${selection.model || '默认模型'}`;
|
|
203
|
+
$('model-label').textContent = label;
|
|
204
|
+
$('btn-model').title = `${AGENT_LABELS[state.currentAgent] || state.currentAgent} · ${label}`;
|
|
205
|
+
const session = state.sessionSummary;
|
|
206
|
+
$('session-provider').textContent = session
|
|
207
|
+
? `${AGENT_LABELS[session.agentId] || session.agentId} · ${session.authSource === 'agent-owned' ? 'Agent 自身登录' : session.providerName || selection.providerName} · ${session.model || selection.model || '默认模型'}`
|
|
208
|
+
: `${AGENT_LABELS[state.currentAgent] || state.currentAgent} · ${state.currentProfileId === undefined ? '项目默认 · ' : ''}${label}`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function resolvedSelection() {
|
|
212
|
+
const project = state.projects.find(p => p.id === state.activeProjectId) || {};
|
|
213
|
+
const profileId = state.currentProfileId === undefined ? project.defaultAiProfileId || null : state.currentProfileId;
|
|
214
|
+
const profile = state.profiles.find(p => p.id === profileId);
|
|
215
|
+
const model = state.currentProfileId === undefined ? project.defaultModel || (profile && profile.models.default) : state.currentModel || (profile && profile.models.default);
|
|
216
|
+
return { profileId, model: model || null, providerName: profile ? profile.name : profileId ? '供应商不可用' : 'Agent 自身登录' };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Agent choice is immutable per session, so switching agents detaches and the
|
|
220
|
+
// next message starts a fresh session.
|
|
221
|
+
function prepareSelectionChange() {
|
|
222
|
+
if (!state.activeSessionId) return;
|
|
223
|
+
detachSession({ preserveAttachments: true });
|
|
224
|
+
resetStream();
|
|
225
|
+
setBusyState('idle');
|
|
226
|
+
$('session-title').textContent = '新会话';
|
|
227
|
+
renderProjectList();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Rebind the provider/model selection. With an active session the server
|
|
231
|
+
// switches that session in place — same conversation, next turn runs under
|
|
232
|
+
// the new selection. Without one it just primes the next session.
|
|
233
|
+
async function switchSelection(profileId, model) {
|
|
234
|
+
if (!state.activeSessionId) {
|
|
235
|
+
state.currentProfileId = profileId;
|
|
236
|
+
state.currentModel = model || null;
|
|
237
|
+
savePreferences({ currentProfileId: profileId, currentModel: state.currentModel });
|
|
238
|
+
renderModelLabel();
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
241
|
+
try {
|
|
242
|
+
const summary = await client.updateSessionSelection({ sessionId: state.activeSessionId, aiProfileId: profileId === undefined ? null : profileId, model: model || null });
|
|
243
|
+
if (state.sessionSummary) {
|
|
244
|
+
state.sessionSummary = { ...state.sessionSummary, ...summary, title: state.sessionSummary.title };
|
|
245
|
+
}
|
|
246
|
+
state.currentProfileId = summary.aiProfileId !== undefined ? summary.aiProfileId : profileId;
|
|
247
|
+
state.currentModel = summary.selectedModel || summary.model || model || null;
|
|
248
|
+
savePreferences({ currentProfileId: state.currentProfileId, currentModel: state.currentModel });
|
|
249
|
+
renderModelLabel();
|
|
250
|
+
toast(`当前会话已切换:${summary.providerName || (summary.aiProfileId ? '供应商' : 'Agent 自身登录')} · ${summary.model || '默认模型'}`, false, true);
|
|
251
|
+
return true;
|
|
252
|
+
} catch (error) {
|
|
253
|
+
toast(error.message, true);
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Menu groups for the model picker: with auto-detection a provider can carry
|
|
259
|
+
// dozens of models, so the picker filters by id/label/tag/provider name.
|
|
260
|
+
function filterMenuEntries(profiles, query) {
|
|
261
|
+
const q = String(query || '').trim().toLowerCase();
|
|
262
|
+
const groups = [];
|
|
263
|
+
for (const profile of profiles || []) {
|
|
264
|
+
const entries = profile.models.list && profile.models.list.length
|
|
265
|
+
? profile.models.list
|
|
266
|
+
: [{ id: profile.models.default, label: profile.models.default, tags: [] }];
|
|
267
|
+
const filtered = q
|
|
268
|
+
? entries.filter(entry => `${entry.id || ''} ${entry.label || ''} ${(entry.tags || []).join(' ')} ${profile.name || ''}`.toLowerCase().includes(q))
|
|
269
|
+
: entries;
|
|
270
|
+
if (q && !filtered.length) continue;
|
|
271
|
+
groups.push({ profile, entries: filtered });
|
|
272
|
+
}
|
|
273
|
+
return groups;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function openModelMenu() {
|
|
277
|
+
const pop = $('pop-model');
|
|
278
|
+
closePops();
|
|
279
|
+
pop.textContent = '';
|
|
280
|
+
const project = state.projects.find(p => p.id === state.activeProjectId);
|
|
281
|
+
for (const [value, label] of [[undefined, '使用项目默认'], [null, 'Agent 自身登录 · 默认模型']]) {
|
|
282
|
+
if (value === undefined && !project) continue;
|
|
283
|
+
const item = el('button', 'pop-item' + (state.currentProfileId === value ? ' selected' : ''));
|
|
284
|
+
item.append(el('span', 'grow', label), checkMark());
|
|
285
|
+
item.addEventListener('click', () => {
|
|
286
|
+
const active = Boolean(state.activeSessionId);
|
|
287
|
+
// "使用项目默认" stays lazy while composing (undefined); an active
|
|
288
|
+
// session must be rebound to the project's concrete defaults.
|
|
289
|
+
const targetId = value === undefined && active ? project.defaultAiProfileId || null : value;
|
|
290
|
+
const targetModel = value === undefined && active ? project.defaultModel || null : null;
|
|
291
|
+
switchSelection(targetId, targetModel).then(ok => {
|
|
292
|
+
if (!ok) return;
|
|
293
|
+
if (value === undefined && !active && project.defaultAgentId) state.currentAgent = project.defaultAgentId;
|
|
294
|
+
applyCapabilityGates();
|
|
295
|
+
closePops();
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
pop.append(item);
|
|
299
|
+
}
|
|
300
|
+
pop.append(el('div', 'pop-sep'));
|
|
301
|
+
const search = el('input', 'pop-search');
|
|
302
|
+
search.type = 'search';
|
|
303
|
+
search.placeholder = `搜索模型(共 ${filterMenuEntries(state.profiles).reduce((sum, group) => sum + group.entries.length, 0)} 个)…`;
|
|
304
|
+
const groups = el('div', 'pop-groups');
|
|
305
|
+
const renderGroups = query => {
|
|
306
|
+
groups.textContent = '';
|
|
307
|
+
for (const { profile, entries } of filterMenuEntries(state.profiles, query)) {
|
|
308
|
+
const group = el('div', 'pop-group');
|
|
309
|
+
const glyph = el('span', null);
|
|
310
|
+
glyph.append(icon('spark', 11));
|
|
311
|
+
group.append(glyph, el('span', null, ` ${profile.name}`));
|
|
312
|
+
if (!profile.enabled) group.append(el('span', 'tag', '已禁用'));
|
|
313
|
+
else if (!profile.credentialConfigured) group.append(el('span', 'tag', '未配置密钥'));
|
|
314
|
+
groups.append(group);
|
|
315
|
+
for (const entry of entries) {
|
|
316
|
+
const item = el('button', 'pop-item' + (state.currentProfileId === profile.id && state.currentModel === entry.id ? ' selected' : ''));
|
|
317
|
+
item.disabled = !profile.enabled || !profile.credentialConfigured || !entry.id;
|
|
318
|
+
const grow = el('span', 'grow', entry.label || entry.id);
|
|
319
|
+
item.append(grow);
|
|
320
|
+
const ctxBadge = contextWindowBadge(entry.contextWindow);
|
|
321
|
+
if (ctxBadge) item.append(el('span', 'tag ctx', ctxBadge));
|
|
322
|
+
for (const tag of entry.tags || []) item.append(el('span', 'tag', tag));
|
|
323
|
+
item.append(checkMark());
|
|
324
|
+
item.addEventListener('click', () => {
|
|
325
|
+
switchSelection(profile.id, entry.id).then(ok => { if (ok) closePops(); });
|
|
326
|
+
});
|
|
327
|
+
groups.append(item);
|
|
328
|
+
}
|
|
329
|
+
groups.append(el('div', 'pop-sep'));
|
|
330
|
+
}
|
|
331
|
+
if (!groups.children.length) groups.append(el('div', 'pop-group', '没有匹配的模型'));
|
|
332
|
+
};
|
|
333
|
+
search.addEventListener('input', () => renderGroups(search.value));
|
|
334
|
+
renderGroups('');
|
|
335
|
+
pop.append(search, groups);
|
|
336
|
+
if (project) {
|
|
337
|
+
const saveDefault = el('button', 'pop-item muted');
|
|
338
|
+
saveDefault.append(el('span', 'grow', '将当前选择设为项目默认'));
|
|
339
|
+
saveDefault.addEventListener('click', async () => {
|
|
340
|
+
const selection = resolvedSelection();
|
|
341
|
+
try {
|
|
342
|
+
await client.updateProject(project.id, { defaultAiProfileId: selection.profileId, defaultModel: selection.model, defaultAgentId: state.currentAgent });
|
|
343
|
+
await refreshProjects(true);
|
|
344
|
+
closePops();
|
|
345
|
+
toast('已保存项目默认 Agent、供应商与模型', false, true);
|
|
346
|
+
} catch (error) { toast(error.message, true); }
|
|
347
|
+
});
|
|
348
|
+
pop.append(saveDefault);
|
|
349
|
+
}
|
|
350
|
+
const manage = el('button', 'pop-item muted');
|
|
351
|
+
manage.append(el('span', 'grow', '管理模型'));
|
|
352
|
+
manage.addEventListener('click', () => { closePops(); openSettings('providers'); });
|
|
353
|
+
pop.append(manage);
|
|
354
|
+
pop.hidden = false;
|
|
355
|
+
search.focus();
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/* ===================== projects & sidebar ===================== */
|
|
359
|
+
|
|
360
|
+
async function refreshProjects(keepActive) {
|
|
361
|
+
try { state.projects = await client.listProjects(); } catch { state.projects = []; }
|
|
362
|
+
if (!keepActive || !state.projects.some(p => p.id === state.activeProjectId)) {
|
|
363
|
+
state.activeProjectId = state.projects.length ? state.projects[0].id : null;
|
|
364
|
+
if (state.activeProjectId && !state.expanded.has(state.activeProjectId)) state.expanded.add(state.activeProjectId);
|
|
365
|
+
if (!state.activeSessionId && state.currentProfileId === undefined) {
|
|
366
|
+
const project = state.projects.find(p => p.id === state.activeProjectId);
|
|
367
|
+
state.currentAgent = project && project.defaultAgentId || state.currentAgent;
|
|
368
|
+
applyCapabilityGates();
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
renderModelLabel();
|
|
372
|
+
await renderProjectList();
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function sessionTitle(summary) {
|
|
376
|
+
return state.sessionTitles.get(summary.sessionId) || summary.title || '会话';
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Replays fire many result events at once; without single-flight the
|
|
380
|
+
// concurrent renders all clear first and then all append, duplicating rows.
|
|
381
|
+
let projectListInFlight = false;
|
|
382
|
+
let projectListQueued = false;
|
|
383
|
+
async function renderProjectList() {
|
|
384
|
+
if (projectListInFlight) { projectListQueued = true; return; }
|
|
385
|
+
projectListInFlight = true;
|
|
386
|
+
try {
|
|
387
|
+
await renderProjectListOnce();
|
|
388
|
+
} finally {
|
|
389
|
+
projectListInFlight = false;
|
|
390
|
+
if (projectListQueued) { projectListQueued = false; renderProjectList(); }
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async function renderProjectListOnce() {
|
|
395
|
+
const wrap = $('project-list');
|
|
396
|
+
wrap.textContent = '';
|
|
397
|
+
if (!state.projects.length) {
|
|
398
|
+
const empty = el('div', 'side-empty');
|
|
399
|
+
const art = el('span', 'side-empty-art');
|
|
400
|
+
art.append(icon('folder', 18));
|
|
401
|
+
empty.append(art, el('div', null, '还没有项目'), el('div', 'side-empty-sub', '点击下方「添加项目」开始'));
|
|
402
|
+
wrap.append(empty);
|
|
403
|
+
renderArchived();
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
for (const project of state.projects) {
|
|
407
|
+
const proj = el('div', 'proj' + (state.activeProjectId === project.id ? ' active' : ''));
|
|
408
|
+
proj.dataset.project = project.id;
|
|
409
|
+
const head = el('button', 'proj-item');
|
|
410
|
+
const caret = el('span', 'p-caret' + (state.expanded.has(project.id) ? '' : ' closed'));
|
|
411
|
+
caret.append(icon('caret', 12));
|
|
412
|
+
const name = el('span', 'p-name', project.name);
|
|
413
|
+
const acts = el('span', 'row-acts');
|
|
414
|
+
const remove = el('span', 'act-btn danger');
|
|
415
|
+
remove.title = '移除项目';
|
|
416
|
+
remove.setAttribute('aria-label', remove.title);
|
|
417
|
+
remove.append(icon('close', 12));
|
|
418
|
+
remove.addEventListener('click', async ev => {
|
|
419
|
+
ev.stopPropagation();
|
|
420
|
+
if (!confirm(`移除项目“${project.name}”及其全部会话?`)) return;
|
|
421
|
+
try {
|
|
422
|
+
await client.removeProject(project.id);
|
|
423
|
+
if (state.activeProjectId === project.id) { detachSession(); }
|
|
424
|
+
await refreshProjects();
|
|
425
|
+
toast('项目已移除', false, true);
|
|
426
|
+
} catch (error) { toast(error.message, true); }
|
|
427
|
+
});
|
|
428
|
+
acts.append(remove);
|
|
429
|
+
head.append(caret, name, acts);
|
|
430
|
+
head.title = project.rootPath;
|
|
431
|
+
head.addEventListener('click', () => {
|
|
432
|
+
if (state.expanded.has(project.id)) state.expanded.delete(project.id);
|
|
433
|
+
else state.expanded.add(project.id);
|
|
434
|
+
if (state.activeProjectId !== project.id) {
|
|
435
|
+
detachSession();
|
|
436
|
+
resetStream();
|
|
437
|
+
setBusyState('idle');
|
|
438
|
+
state.activeProjectId = project.id;
|
|
439
|
+
state.currentProfileId = savedPreferences.currentProfileId !== undefined ? savedPreferences.currentProfileId : undefined;
|
|
440
|
+
state.currentModel = savedPreferences.currentModel || null;
|
|
441
|
+
state.currentAgent = project.defaultAgentId || savedPreferences.currentAgent || 'claude-code';
|
|
442
|
+
$('session-title').textContent = '新会话';
|
|
443
|
+
applyCapabilityGates();
|
|
444
|
+
renderModelLabel();
|
|
445
|
+
}
|
|
446
|
+
renderProjectList();
|
|
447
|
+
});
|
|
448
|
+
proj.append(head);
|
|
449
|
+
const list = el('div', 'sess-list');
|
|
450
|
+
list.hidden = !state.expanded.has(project.id);
|
|
451
|
+
proj.append(list);
|
|
452
|
+
wrap.append(proj);
|
|
453
|
+
loadProjectSessions(project.id, list);
|
|
454
|
+
}
|
|
455
|
+
renderArchived();
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
async function loadProjectSessions(projectId, listNode) {
|
|
459
|
+
try {
|
|
460
|
+
const sessions = await client.listSessions(projectId);
|
|
461
|
+
if (!listNode.isConnected) return;
|
|
462
|
+
listNode.textContent = '';
|
|
463
|
+
if (!sessions.length) {
|
|
464
|
+
listNode.append(el('div', 'proj-empty', '还没有会话'));
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
sessions.sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)));
|
|
468
|
+
for (const summary of sessions) listNode.append(sessionRow(summary, projectId));
|
|
469
|
+
} catch { listNode.textContent = ''; }
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function sessionRow(summary, projectId) {
|
|
473
|
+
const row = el('button', 'session' + (state.activeSessionId === summary.sessionId ? ' active' : ''));
|
|
474
|
+
const badge = el('span', `agent-badge ${summary.agentId}`, badgeLetter(summary.agentId));
|
|
475
|
+
const title = el('span', 's-title', sessionTitle(summary));
|
|
476
|
+
const acts = el('span', 'row-acts');
|
|
477
|
+
const archive = el('span', 'act-btn');
|
|
478
|
+
archive.title = '归档';
|
|
479
|
+
archive.setAttribute('aria-label', archive.title);
|
|
480
|
+
archive.append(icon('archive', 12));
|
|
481
|
+
archive.addEventListener('click', async ev => {
|
|
482
|
+
ev.stopPropagation();
|
|
483
|
+
try {
|
|
484
|
+
await client.archiveSession(summary.sessionId);
|
|
485
|
+
if (state.activeSessionId === summary.sessionId) detachSession();
|
|
486
|
+
await renderProjectList();
|
|
487
|
+
} catch (error) { toast(error.message, true); }
|
|
488
|
+
});
|
|
489
|
+
acts.append(archive);
|
|
490
|
+
const when = el('span', 'when', shortWhen(summary.updatedAt));
|
|
491
|
+
row.append(badge, title, acts, when);
|
|
492
|
+
row.addEventListener('click', () => openSession(summary.sessionId));
|
|
493
|
+
return row;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
async function renderArchived() {
|
|
497
|
+
const list = $('arch-list');
|
|
498
|
+
const empty = $('arch-empty');
|
|
499
|
+
list.textContent = '';
|
|
500
|
+
const rows = [];
|
|
501
|
+
for (const project of state.projects) {
|
|
502
|
+
let archived = [];
|
|
503
|
+
try { archived = await client.listSessions(project.id, { archived: true }); } catch { archived = []; }
|
|
504
|
+
for (const summary of archived) {
|
|
505
|
+
if (!summary.archived) continue;
|
|
506
|
+
const row = el('button', 'session');
|
|
507
|
+
row.append(el('span', `agent-badge ${summary.agentId}`, badgeLetter(summary.agentId)));
|
|
508
|
+
row.append(el('span', 's-title', sessionTitle(summary)));
|
|
509
|
+
const acts = el('span', 'row-acts');
|
|
510
|
+
const restore = el('span', 'act-btn');
|
|
511
|
+
restore.title = '恢复会话';
|
|
512
|
+
restore.setAttribute('aria-label', restore.title);
|
|
513
|
+
restore.append(icon('retry', 12));
|
|
514
|
+
restore.addEventListener('click', async ev => {
|
|
515
|
+
ev.stopPropagation();
|
|
516
|
+
try { await client.restoreSession(summary.sessionId); await renderProjectList(); } catch (error) { toast(error.message, true); }
|
|
517
|
+
});
|
|
518
|
+
acts.append(restore);
|
|
519
|
+
row.append(acts);
|
|
520
|
+
row.addEventListener('click', () => openSession(summary.sessionId));
|
|
521
|
+
rows.push(row);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
if (empty) list.append(empty);
|
|
525
|
+
empty && (empty.hidden = rows.length > 0);
|
|
526
|
+
for (const row of rows) list.append(row);
|
|
527
|
+
const count = $('arch-count');
|
|
528
|
+
if (count) { count.textContent = String(rows.length); count.hidden = !rows.length; }
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function badgeLetter(agentId) {
|
|
532
|
+
return { 'claude-code': 'C', codex: 'X', zcode: 'Z', opencode: 'O' }[agentId] || '?';
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function shortWhen(iso) {
|
|
536
|
+
if (!iso) return '';
|
|
537
|
+
const date = new Date(iso);
|
|
538
|
+
const now = new Date();
|
|
539
|
+
const minutes = Math.floor((now - date) / 60000);
|
|
540
|
+
if (minutes < 1) return '刚刚';
|
|
541
|
+
if (minutes < 60) return `${minutes}分钟`;
|
|
542
|
+
if (minutes < 60 * 24) return `${Math.floor(minutes / 60)}小时`;
|
|
543
|
+
return `${date.getMonth() + 1}/${date.getDate()}`;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/* ===================== session lifecycle ===================== */
|
|
547
|
+
|
|
548
|
+
async function newSession() {
|
|
549
|
+
if (!state.activeProjectId) { openProjectModal(); return; }
|
|
550
|
+
try {
|
|
551
|
+
const summary = await client.startSession(
|
|
552
|
+
{ contextId: state.activeProjectId, workspaceRef: state.activeProjectId, aiProfileId: state.currentProfileId, displayName: (state.projects.find(p => p.id === state.activeProjectId) || {}).name },
|
|
553
|
+
{ agentId: state.currentAgent, model: state.currentProfileId === undefined ? undefined : state.currentModel || undefined, permissionMode: state.permissionMode, effort: state.caps && state.caps.generationParams.includes('effort') ? state.effort : undefined },
|
|
554
|
+
);
|
|
555
|
+
if (state.activeSessionId === summary.sessionId) return;
|
|
556
|
+
state.sessionTitles.set(summary.sessionId, '新会话');
|
|
557
|
+
await openSession(summary.sessionId, { preserveAttachments: true });
|
|
558
|
+
await renderProjectList();
|
|
559
|
+
$('chat-input').focus();
|
|
560
|
+
} catch (error) { toast(error.message, true); }
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
async function openSession(sessionId, options = {}) {
|
|
564
|
+
detachSession(options);
|
|
565
|
+
state.activeSessionId = sessionId;
|
|
566
|
+
resetStream();
|
|
567
|
+
let snapshot;
|
|
568
|
+
try { snapshot = await client.loadSession(sessionId); } catch (error) { state.activeSessionId = null; toast(error.message, true); return; }
|
|
569
|
+
const summary = snapshot.session;
|
|
570
|
+
state.sessionSummary = summary;
|
|
571
|
+
state.activeProjectId = summary.contextId || state.activeProjectId;
|
|
572
|
+
state.currentAgent = summary.agentId || 'claude-code';
|
|
573
|
+
state.currentProfileId = summary.aiProfileId || null;
|
|
574
|
+
state.currentModel = summary.selectedModel || summary.model || null;
|
|
575
|
+
renderModelLabel();
|
|
576
|
+
applyCapabilityGates();
|
|
577
|
+
state.permissionMode = snapshot.permissionMode || 'default';
|
|
578
|
+
renderModeLabel();
|
|
579
|
+
$('session-title').textContent = sessionTitle(summary);
|
|
580
|
+
setBusyState(summary.state);
|
|
581
|
+
let last = 0;
|
|
582
|
+
// Timers and per-step durations are wall-clock side effects; replayed
|
|
583
|
+
// snapshots did not witness them, so the stream must not fake them.
|
|
584
|
+
state.replaying = true;
|
|
585
|
+
try {
|
|
586
|
+
for (const event of snapshot.events || []) { renderEvent(event); last = Math.max(last, event.sequence || 0); }
|
|
587
|
+
} finally { state.replaying = false; }
|
|
588
|
+
rememberTitleFromEvents(snapshot.events || []);
|
|
589
|
+
state.unsub = client.subscribe(sessionId, event => renderEvent(event), () => {}, { afterSequence: last });
|
|
590
|
+
await renderProjectList();
|
|
591
|
+
refreshContextUsage();
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function detachSession(options = {}) {
|
|
595
|
+
if (state.unsub) { try { state.unsub(); } catch {} state.unsub = null; }
|
|
596
|
+
state.activeSessionId = null;
|
|
597
|
+
state.sessionSummary = null;
|
|
598
|
+
if (!options.preserveAttachments) state.attachments = [];
|
|
599
|
+
$('btn-ctx').hidden = true;
|
|
600
|
+
renderAttachmentTray();
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function rememberTitleFromEvents(events) {
|
|
604
|
+
const prompt = events.find(event => event.type === 'claude/user-prompt' && event.text);
|
|
605
|
+
if (prompt) {
|
|
606
|
+
const title = prompt.text.replace(/\s+/g, ' ').slice(0, 24);
|
|
607
|
+
state.sessionTitles.set(state.activeSessionId, title);
|
|
608
|
+
$('session-title').textContent = title;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function resetStream() {
|
|
613
|
+
endTurnVisuals();
|
|
614
|
+
const col = $('stream-col');
|
|
615
|
+
col.textContent = '';
|
|
616
|
+
turn = null;
|
|
617
|
+
state.follow = true;
|
|
618
|
+
const jump = $('jump-latest');
|
|
619
|
+
if (jump) jump.hidden = true;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/* ===================== icons ===================== */
|
|
623
|
+
|
|
624
|
+
// One inline SVG set, stroked with currentColor — replaces the mixed
|
|
625
|
+
// emoji/unicode glyphs that read as unstyled "AI default".
|
|
626
|
+
const ICONS = {
|
|
627
|
+
copy: '<svg viewBox="0 0 24 24"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></svg>',
|
|
628
|
+
retry: '<svg viewBox="0 0 24 24"><path d="M3 12a9 9 0 1 0 3-6.7"/><path d="M3 4v5h5"/></svg>',
|
|
629
|
+
spark: '<svg viewBox="0 0 24 24"><path d="M12 3l1.9 5.8L20 12l-6.1 3.2L12 21l-1.9-5.8L4 12l6.1-3.2z"/></svg>',
|
|
630
|
+
think: '<svg viewBox="0 0 24 24"><path d="M12 3a6 6 0 0 1 6 6c0 2-1 3.5-2 4.5V16h-8v-2.5C7 12.5 6 11 6 9a6 6 0 0 1 6-6z"/><path d="M10 19h4"/></svg>',
|
|
631
|
+
shield: '<svg viewBox="0 0 24 24"><path d="M12 3l8 3v6c0 5-3.5 8-8 9-4.5-1-8-4-8-9V6z"/></svg>',
|
|
632
|
+
eye: '<svg viewBox="0 0 24 24"><path d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6-10-6-10-6z"/><circle cx="12" cy="12" r="2.5"/></svg>',
|
|
633
|
+
eyeOff: '<svg viewBox="0 0 24 24"><path d="M3 3l18 18"/><path d="M10.5 5.2A10.8 10.8 0 0 1 12 5c6.5 0 10 6 10 6a17 17 0 0 1-3 3.4M6.6 6.6C3.7 8.5 2 12 2 12s3.5 6 10 6c1.4 0 2.7-.3 3.8-.8"/></svg>',
|
|
634
|
+
plus: '<svg viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></svg>',
|
|
635
|
+
trash: '<svg viewBox="0 0 24 24"><path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/></svg>',
|
|
636
|
+
pen: '<svg viewBox="0 0 24 24"><path d="M4 20h4L20 8l-4-4L4 16z"/></svg>',
|
|
637
|
+
gear: '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19 12a7 7 0 0 0-.1-1.2l2-1.6-2-3.4-2.4 1a7 7 0 0 0-2-1.2L14 3h-4l-.5 2.6a7 7 0 0 0-2 1.2l-2.4-1-2 3.4 2 1.6a7 7 0 0 0 0 2.4l-2 1.6 2 3.4 2.4-1a7 7 0 0 0 2 1.2L10 21h4l.5-2.6a7 7 0 0 0 2-1.2l2.4 1 2-3.4-2-1.6c.06-.4.1-.8.1-1.2z"/></svg>',
|
|
638
|
+
caret: '<svg viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></svg>',
|
|
639
|
+
terminal: '<svg viewBox="0 0 24 24"><path d="m5 8 4 4-4 4"/><path d="M13 16h6"/></svg>',
|
|
640
|
+
file: '<svg viewBox="0 0 24 24"><path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><path d="M14 3v6h6"/></svg>',
|
|
641
|
+
pencil: '<svg viewBox="0 0 24 24"><path d="M4 20h4L20 8l-4-4L4 16z"/></svg>',
|
|
642
|
+
search: '<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>',
|
|
643
|
+
globe: '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3c3 3.5 3 14 0 18-3-4-3-14.5 0-18z"/></svg>',
|
|
644
|
+
agent: '<svg viewBox="0 0 24 24"><rect x="4" y="7" width="16" height="12" rx="2"/><path d="M12 7V3M8 3h8"/><circle cx="9" cy="12" r="1"/><circle cx="15" cy="12" r="1"/></svg>',
|
|
645
|
+
warn: '<svg viewBox="0 0 24 24"><path d="M12 3 2 20h20z"/><path d="M12 10v4M12 17.5v.5"/></svg>',
|
|
646
|
+
switch: '<svg viewBox="0 0 24 24"><path d="M4 8h13l-3-3M20 16H7l3 3"/></svg>',
|
|
647
|
+
dot: '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/></svg>',
|
|
648
|
+
send: '<svg viewBox="0 0 24 24"><path d="M12 19V5"/><path d="m5 12 7-7 7 7"/></svg>',
|
|
649
|
+
check: '<svg viewBox="0 0 24 24"><path d="m5 12 5 5L20 7"/></svg>',
|
|
650
|
+
close: '<svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6L6 18"/></svg>',
|
|
651
|
+
archive: '<svg viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="4" rx="1"/><path d="M5 8v11a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V8"/><path d="M10 12h4"/></svg>',
|
|
652
|
+
arrowDown: '<svg viewBox="0 0 24 24"><path d="M12 5v14"/><path d="m19 12-7 7-7-7"/></svg>',
|
|
653
|
+
arrowLeft: '<svg viewBox="0 0 24 24"><path d="M20 12H4"/><path d="m11 5-7 7 7 7"/></svg>',
|
|
654
|
+
bolt: '<svg viewBox="0 0 24 24"><path d="M13 2 4 14h6l-1 8 9-12h-6z"/></svg>',
|
|
655
|
+
info: '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 11v5"/><path d="M12 8v.5"/></svg>',
|
|
656
|
+
stop: '<svg viewBox="0 0 24 24"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>',
|
|
657
|
+
server: '<svg viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><path d="M7 7.5v.5M7 16.5v.5"/></svg>',
|
|
658
|
+
folder: '<svg viewBox="0 0 24 24"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>',
|
|
659
|
+
};
|
|
660
|
+
|
|
661
|
+
function icon(name, size = 14) {
|
|
662
|
+
const wrap = el('span', `ic ic-${name}`);
|
|
663
|
+
wrap.style.width = `${size}px`;
|
|
664
|
+
wrap.style.height = `${size}px`;
|
|
665
|
+
wrap.innerHTML = ICONS[name] || ICONS.dot;
|
|
666
|
+
return wrap;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// Selected-item tick for pop menus (visibility is CSS-driven via .selected).
|
|
670
|
+
function checkMark() {
|
|
671
|
+
const mark = el('span', 'check');
|
|
672
|
+
mark.append(icon('check', 13));
|
|
673
|
+
return mark;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/* ===================== event rendering ===================== */
|
|
677
|
+
|
|
678
|
+
// One turn = everything between a user message and its result. Process output
|
|
679
|
+
// (thinking, tool calls, narration that precedes a tool call) folds into a
|
|
680
|
+
// single collapsible block headed by the running/elapsed timer, ZCode-style
|
|
681
|
+
// (工作中 X 秒 → 已工作 X 秒); only the final message stays in the open stream.
|
|
682
|
+
let turn = null;
|
|
683
|
+
|
|
684
|
+
function startTurn() {
|
|
685
|
+
endTurnVisuals();
|
|
686
|
+
turn = {
|
|
687
|
+
startedAt: Date.now(), timerId: null, proc: null, steps: null, meta: null,
|
|
688
|
+
texts: [], liveText: null, liveBody: null, liveRaw: '', liveThinking: null, liveThinkingBody: null,
|
|
689
|
+
lastPrompt: '', changes: new Map(), interrupted: false,
|
|
690
|
+
stderrNote: null, stderrLast: '', thinkHint: null, lastError: '',
|
|
691
|
+
};
|
|
692
|
+
return turn;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function ensureProc(t) {
|
|
696
|
+
if (!(t.proc && t.proc.isConnected)) {
|
|
697
|
+
const details = el('details', 'proc');
|
|
698
|
+
details.open = true; // live process output stays visible while running; folds when the turn ends
|
|
699
|
+
const summary = el('summary', null, '');
|
|
700
|
+
const procCaret = el('span', 'caret');
|
|
701
|
+
procCaret.append(icon('caret', 12));
|
|
702
|
+
summary.append(procCaret);
|
|
703
|
+
t.meta = el('span', 'p-meta', '工作中 0 秒');
|
|
704
|
+
summary.append(t.meta);
|
|
705
|
+
details.append(summary);
|
|
706
|
+
t.steps = el('div', 'proc-steps');
|
|
707
|
+
details.append(t.steps);
|
|
708
|
+
$('stream-col').append(details);
|
|
709
|
+
t.proc = details;
|
|
710
|
+
if (!state.replaying) {
|
|
711
|
+
t.timerId = setInterval(() => {
|
|
712
|
+
if (t.meta && t.meta.isConnected) t.meta.textContent = `工作中 ${fmtElapsed(Date.now() - t.startedAt)}`;
|
|
713
|
+
}, 500);
|
|
714
|
+
} else {
|
|
715
|
+
t.meta.textContent = '';
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
// Narration streamed before this process entry belongs inside the fold —
|
|
719
|
+
// finalize each block first: its pacer and streaming cursor must not leak
|
|
720
|
+
// into the collapsed view (codex emits one text block per step).
|
|
721
|
+
hideThinkingHint(t);
|
|
722
|
+
for (const node of t.texts.splice(0)) {
|
|
723
|
+
if (node._pacer) node._pacer.finish(node._rawFull || '');
|
|
724
|
+
const body = node.querySelector && node.querySelector('.md-body');
|
|
725
|
+
if (body) body.classList.remove('streaming');
|
|
726
|
+
t.steps.append(node);
|
|
727
|
+
}
|
|
728
|
+
t.liveText = null; t.liveBody = null; t.liveRaw = '';
|
|
729
|
+
return t;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function stampThinking(t) {
|
|
733
|
+
if (!t || !t.liveThinking) return;
|
|
734
|
+
const seg = t.liveThinking;
|
|
735
|
+
const label = seg.querySelector('.t-meta');
|
|
736
|
+
if (label && !label.textContent) label.textContent = `· 持续了 ${fmtElapsed(Date.now() - (seg._startedAt || Date.now()))}`;
|
|
737
|
+
t.liveThinking = null;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function hideThinkingHint(t) {
|
|
741
|
+
if (t && t.thinkHint && t.thinkHint.isConnected) t.thinkHint.remove();
|
|
742
|
+
if (t) t.thinkHint = null;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function showThinkingHint(t) {
|
|
746
|
+
if (!t || t.thinkHint) return;
|
|
747
|
+
const hint = el('div', 'think-hint');
|
|
748
|
+
hint.append(el('span', 'ring'), el('span', null, '思考中…'));
|
|
749
|
+
$('stream-col').append(hint);
|
|
750
|
+
t.thinkHint = hint;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function endTurnVisuals(durationMs) {
|
|
754
|
+
const t = turn;
|
|
755
|
+
if (!t) return;
|
|
756
|
+
stampThinking(t);
|
|
757
|
+
hideThinkingHint(t);
|
|
758
|
+
if (t.timerId) { clearInterval(t.timerId); t.timerId = null; }
|
|
759
|
+
const elapsed = fmtElapsed(durationMs != null && Number.isFinite(durationMs) ? durationMs : Date.now() - t.startedAt);
|
|
760
|
+
if (t.meta && t.meta.isConnected) {
|
|
761
|
+
// One title shape for live and replayed turns alike.
|
|
762
|
+
const stepCount = t.proc ? t.proc.querySelectorAll('.step').length : 0;
|
|
763
|
+
t.meta.textContent = state.replaying
|
|
764
|
+
? (stepCount ? `${stepCount} 个步骤` : '无步骤')
|
|
765
|
+
: (stepCount ? `${stepCount} 个步骤 · 已工作 ${elapsed}` : `已工作 ${elapsed}`);
|
|
766
|
+
}
|
|
767
|
+
for (const node of [...t.texts, t.liveText]) {
|
|
768
|
+
if (!node) continue;
|
|
769
|
+
if (node._pacer) node._pacer.finish(node._rawFull || '');
|
|
770
|
+
const body = node.querySelector && node.querySelector('.md-body');
|
|
771
|
+
if (body) body.classList.remove('streaming');
|
|
772
|
+
}
|
|
773
|
+
if (t.liveBody) t.liveBody.classList.remove('streaming');
|
|
774
|
+
if (t.proc) {
|
|
775
|
+
// Belt and braces: no residual cursors or "running" steps inside the fold,
|
|
776
|
+
// even when the driver never sent the matching completion events.
|
|
777
|
+
for (const body of t.proc.querySelectorAll('.streaming')) body.classList.remove('streaming');
|
|
778
|
+
for (const step of t.proc.querySelectorAll('.step')) {
|
|
779
|
+
if (step.classList.contains('done')) continue;
|
|
780
|
+
step.classList.add('done');
|
|
781
|
+
const meta = step.querySelector('.s-meta');
|
|
782
|
+
if (meta) { meta.classList.remove('running'); meta.textContent = t.interrupted ? '已中断' : '完成'; }
|
|
783
|
+
}
|
|
784
|
+
t.proc.open = false;
|
|
785
|
+
}
|
|
786
|
+
turn = null;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
function fmtElapsed(ms) {
|
|
790
|
+
const total = Math.max(0, Math.round(Number(ms || 0) / 1000));
|
|
791
|
+
if (total < 60) return `${total} 秒`;
|
|
792
|
+
const minutes = Math.floor(total / 60);
|
|
793
|
+
if (minutes < 60) return total % 60 ? `${minutes} 分 ${total % 60} 秒` : `${minutes} 分`;
|
|
794
|
+
return `${Math.floor(minutes / 60)} 小时 ${minutes % 60} 分`;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/* Streamed text is rendered at a human pace instead of once per delta
|
|
798
|
+
(ported from OpenCode's PacedMarkdown): small jumps land immediately,
|
|
799
|
+
big backlogs advance in word-sized steps that snap to punctuation. */
|
|
800
|
+
const TEXT_RENDER_PACE_MS = 24;
|
|
801
|
+
const TEXT_RENDER_IMMEDIATE = 512;
|
|
802
|
+
const TEXT_RENDER_SNAP = /[\s.,!?;:)\]]/;
|
|
803
|
+
|
|
804
|
+
function pacedStep(remaining) {
|
|
805
|
+
if (remaining <= 12) return 2;
|
|
806
|
+
if (remaining <= 48) return 4;
|
|
807
|
+
if (remaining <= 96) return 8;
|
|
808
|
+
if (remaining <= 512) return 32;
|
|
809
|
+
// Whole-block arrivals (codex/opencode deliver a step's text at once)
|
|
810
|
+
// still reveal at a readable pace instead of slamming in.
|
|
811
|
+
return 48;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
function pacedNext(text, start) {
|
|
815
|
+
const end = Math.min(text.length, start + pacedStep(text.length - start));
|
|
816
|
+
const max = Math.min(text.length, end + 8);
|
|
817
|
+
for (let i = end; i < max; i += 1) {
|
|
818
|
+
if (TEXT_RENDER_SNAP.test(text[i] || '')) return i + 1;
|
|
819
|
+
}
|
|
820
|
+
return end;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function createPacer(render) {
|
|
824
|
+
let shown = '';
|
|
825
|
+
let timer = null;
|
|
826
|
+
const stop = () => { if (timer) { clearTimeout(timer); timer = null; } };
|
|
827
|
+
const tick = full => {
|
|
828
|
+
const end = pacedNext(full, shown.length);
|
|
829
|
+
shown = full.slice(0, end);
|
|
830
|
+
render(shown);
|
|
831
|
+
if (end < full.length) timer = setTimeout(() => { timer = null; tick(full); }, TEXT_RENDER_PACE_MS);
|
|
832
|
+
};
|
|
833
|
+
return {
|
|
834
|
+
push(full) {
|
|
835
|
+
if (!full.startsWith(shown) || full.length <= shown.length || full.length - shown.length <= TEXT_RENDER_IMMEDIATE) {
|
|
836
|
+
stop();
|
|
837
|
+
shown = full;
|
|
838
|
+
render(shown);
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
stop();
|
|
842
|
+
tick(full);
|
|
843
|
+
},
|
|
844
|
+
finish(full) {
|
|
845
|
+
stop();
|
|
846
|
+
shown = full;
|
|
847
|
+
render(shown);
|
|
848
|
+
},
|
|
849
|
+
};
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
function renderEvent(event) {
|
|
853
|
+
const col = $('stream-col');
|
|
854
|
+
const empty = $('empty-hint');
|
|
855
|
+
if (empty && empty.isConnected) empty.remove();
|
|
856
|
+
const t = turn;
|
|
857
|
+
switch (event.type) {
|
|
858
|
+
case 'claude/session-ready':
|
|
859
|
+
if (event.model) setBusyState('idle');
|
|
860
|
+
if (state.sessionSummary && event.model) { state.sessionSummary.model = event.model; renderModelLabel(); }
|
|
861
|
+
break;
|
|
862
|
+
case 'claude/user-prompt': {
|
|
863
|
+
rememberTitleFromEvents([event]);
|
|
864
|
+
if (state.activeSessionId && event.text) state.sessionTitles.set(state.activeSessionId, event.text.replace(/\s+/g, ' ').slice(0, 24));
|
|
865
|
+
const wrap = el('div', 'msg user');
|
|
866
|
+
const bubble = el('div', 'bubble', event.text || '');
|
|
867
|
+
wrap.append(bubble);
|
|
868
|
+
const copy = el('button', 'bubble-copy');
|
|
869
|
+
copy.title = '复制';
|
|
870
|
+
copy.append(icon('copy', 13));
|
|
871
|
+
copy.addEventListener('click', () => copyText(event.text || '', copy));
|
|
872
|
+
wrap.append(copy);
|
|
873
|
+
if (Array.isArray(event.attachments) && event.attachments.length) {
|
|
874
|
+
const tray = el('div', 'attach-tray');
|
|
875
|
+
for (const item of event.attachments) {
|
|
876
|
+
if (String(item.mediaType || '').startsWith('image/')) {
|
|
877
|
+
const img = document.createElement('img');
|
|
878
|
+
img.src = item.url || '';
|
|
879
|
+
img.alt = item.name || '图片附件';
|
|
880
|
+
tray.append(img);
|
|
881
|
+
} else {
|
|
882
|
+
const chip = el('span', 'attachment-file');
|
|
883
|
+
chip.append(icon('file', 12), el('span', null, ` ${item.name || '文本附件'}${item.size ? ` · ${formatBytes(item.size)}` : ''}`));
|
|
884
|
+
tray.append(chip);
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
wrap.append(tray);
|
|
888
|
+
}
|
|
889
|
+
col.append(wrap);
|
|
890
|
+
const ctx = startTurn();
|
|
891
|
+
ctx.lastPrompt = event.text || '';
|
|
892
|
+
break;
|
|
893
|
+
}
|
|
894
|
+
case 'claude/text-start':
|
|
895
|
+
case 'claude/text-delta': {
|
|
896
|
+
stampThinking(t);
|
|
897
|
+
const ctx = t || startTurn();
|
|
898
|
+
if (!ctx.liveText || !ctx.liveText.isConnected) {
|
|
899
|
+
const msg = el('div', 'msg ai');
|
|
900
|
+
const body = el('div', 'md-body streaming');
|
|
901
|
+
msg.append(body);
|
|
902
|
+
col.append(msg);
|
|
903
|
+
ctx.liveText = msg;
|
|
904
|
+
ctx.liveBody = body;
|
|
905
|
+
ctx.liveRaw = '';
|
|
906
|
+
ctx.texts.push(msg); // ensureProc later folds narration that precedes a tool call
|
|
907
|
+
msg._pacer = createPacer(shown => setMarkdown(body, shown));
|
|
908
|
+
}
|
|
909
|
+
if (event.type === 'claude/text-delta') {
|
|
910
|
+
ctx.liveRaw += event.text || '';
|
|
911
|
+
ctx.liveText._rawFull = ctx.liveRaw;
|
|
912
|
+
ctx.liveText._pacer.push(ctx.liveRaw);
|
|
913
|
+
}
|
|
914
|
+
break;
|
|
915
|
+
}
|
|
916
|
+
case 'claude/thinking-start': {
|
|
917
|
+
const ctx = ensureProc(t || startTurn());
|
|
918
|
+
stampThinking(ctx);
|
|
919
|
+
const seg = el('div', 'think-seg');
|
|
920
|
+
seg._startedAt = Date.now();
|
|
921
|
+
const label = el('div', 'think-label');
|
|
922
|
+
const glyph = el('span', 't-glyph');
|
|
923
|
+
glyph.append(icon('think', 13));
|
|
924
|
+
label.append(glyph, el('span', null, '思考'), el('span', 't-meta', ''));
|
|
925
|
+
seg.append(label);
|
|
926
|
+
const body = el('div', 'think-body');
|
|
927
|
+
seg.append(body);
|
|
928
|
+
ctx.steps.append(seg);
|
|
929
|
+
ctx.liveThinking = seg;
|
|
930
|
+
ctx.liveThinkingBody = body;
|
|
931
|
+
break;
|
|
932
|
+
}
|
|
933
|
+
case 'claude/thinking-delta':
|
|
934
|
+
if (t && t.liveThinkingBody) t.liveThinkingBody.textContent += event.text || '';
|
|
935
|
+
break;
|
|
936
|
+
case 'claude/tool-use': {
|
|
937
|
+
stampThinking(t);
|
|
938
|
+
const ctx = ensureProc(t || startTurn());
|
|
939
|
+
const input = event.input || {};
|
|
940
|
+
ctx.steps.append(buildToolStep(event, input));
|
|
941
|
+
break;
|
|
942
|
+
}
|
|
943
|
+
case 'claude/tool-result': {
|
|
944
|
+
const target = col.querySelector(`.step[data-tool-id="${cssEscape(event.id || '')}"]`);
|
|
945
|
+
const step = target || col.appendChild(buildToolStep({ id: event.id, name: event.toolName || '' }, {}));
|
|
946
|
+
const body = step.querySelector('.step-body');
|
|
947
|
+
if (body) {
|
|
948
|
+
const output = event.output && typeof event.output === 'object' ? JSON.stringify(event.output, null, 2) : String(event.output || '');
|
|
949
|
+
body.append(ioCard('text', output.slice(0, 4000), '输出'));
|
|
950
|
+
}
|
|
951
|
+
renderFileChanges(step, event, step._toolInput || {});
|
|
952
|
+
const meta = step.querySelector('.s-meta');
|
|
953
|
+
if (meta) {
|
|
954
|
+
const done = event.isError ? '失败' : '完成';
|
|
955
|
+
const seconds = state.replaying ? null : Math.round((Date.now() - (step._startedAt || Date.now())) / 1000);
|
|
956
|
+
meta.textContent = seconds == null ? done : `${done} · ${fmtElapsed(seconds * 1000)}`;
|
|
957
|
+
meta.classList.remove('running');
|
|
958
|
+
meta.classList.toggle('failed', Boolean(event.isError));
|
|
959
|
+
}
|
|
960
|
+
step.classList.add('done');
|
|
961
|
+
if (event.isError) step.classList.add('failed');
|
|
962
|
+
if (t) collectTurnChanges(t, event, step._toolInput || {});
|
|
963
|
+
break;
|
|
964
|
+
}
|
|
965
|
+
case 'claude/permission-request': {
|
|
966
|
+
const card = el('div', 'perm');
|
|
967
|
+
card.dataset.permId = event.requestId || '';
|
|
968
|
+
const pIcon = el('span', 'p-icon');
|
|
969
|
+
pIcon.append(icon('shield', 15));
|
|
970
|
+
card.append(pIcon);
|
|
971
|
+
const body = el('div', 'p-body');
|
|
972
|
+
body.append(el('div', 'p-title', `${event.toolName || 'Agent'} 请求执行操作`));
|
|
973
|
+
body.append(el('div', 'p-sub', permissionSubtitle(event)));
|
|
974
|
+
card.append(body);
|
|
975
|
+
const actions = el('div', 'p-actions');
|
|
976
|
+
const allow = el('button', 'btn-allow', '允许');
|
|
977
|
+
const deny = el('button', 'btn-deny', '拒绝');
|
|
978
|
+
allow.addEventListener('click', () => settlePermission(card, event.requestId, true));
|
|
979
|
+
deny.addEventListener('click', () => settlePermission(card, event.requestId, false));
|
|
980
|
+
actions.append(allow, deny);
|
|
981
|
+
card.append(actions);
|
|
982
|
+
col.append(card);
|
|
983
|
+
setBusyState('pending-permission');
|
|
984
|
+
break;
|
|
985
|
+
}
|
|
986
|
+
case 'claude/permission-resolved': {
|
|
987
|
+
const card = col.querySelector(`.perm[data-perm-id="${cssEscape(event.requestId || '')}"]`);
|
|
988
|
+
if (card) settlePermissionCard(card, event.allow === true);
|
|
989
|
+
setBusyState('running');
|
|
990
|
+
break;
|
|
991
|
+
}
|
|
992
|
+
case 'claude/usage':
|
|
993
|
+
refreshContextUsage();
|
|
994
|
+
break;
|
|
995
|
+
case 'claude/result': {
|
|
996
|
+
stampThinking(t);
|
|
997
|
+
const hadLiveText = Boolean(t && t.liveText && t.liveText.isConnected);
|
|
998
|
+
const lastPrompt = t ? t.lastPrompt : '';
|
|
999
|
+
const turnChanges = t ? t.changes : null;
|
|
1000
|
+
const turnModel = event.model || (state.sessionSummary && state.sessionSummary.model) || '';
|
|
1001
|
+
endTurnVisuals(event.durationMs != null ? event.durationMs : event.duration_ms);
|
|
1002
|
+
if (event.isError) {
|
|
1003
|
+
const err = el('div', 'msg ai');
|
|
1004
|
+
const dim = el('p', 'dim');
|
|
1005
|
+
dim.append(icon('warn', 13), el('span', null, ` ${event.result || event.message || '执行出错'}`));
|
|
1006
|
+
err.append(dim);
|
|
1007
|
+
col.append(err);
|
|
1008
|
+
} else {
|
|
1009
|
+
if (!hadLiveText && event.result) {
|
|
1010
|
+
const msg = el('div', 'msg ai');
|
|
1011
|
+
const body = el('div', 'md-body');
|
|
1012
|
+
msg.append(body);
|
|
1013
|
+
col.append(msg);
|
|
1014
|
+
setMarkdown(body, String(event.result));
|
|
1015
|
+
}
|
|
1016
|
+
const lastMsg = [...col.children].reverse().find(node => node.classList && node.classList.contains('ai'));
|
|
1017
|
+
if (lastMsg) lastMsg.append(msgFooter(String(event.result || lastMsg.textContent), lastPrompt, { model: turnModel }));
|
|
1018
|
+
if (turnChanges && turnChanges.size) col.append(renderTurnDiffs(turnChanges));
|
|
1019
|
+
}
|
|
1020
|
+
setBusyState('idle');
|
|
1021
|
+
refreshContextUsage();
|
|
1022
|
+
if (!state.replaying) refreshSessionRow();
|
|
1023
|
+
break;
|
|
1024
|
+
}
|
|
1025
|
+
case 'claude/state': {
|
|
1026
|
+
setBusyState(event.state);
|
|
1027
|
+
// The thinking phase can last tens of seconds with no driver events at
|
|
1028
|
+
// all (codex only reports reasoning on completion) — show a live hint
|
|
1029
|
+
// instead of dead air, and clear it as soon as anything real happens.
|
|
1030
|
+
if (event.state === 'thinking' && t && !t.proc && !t.liveText) showThinkingHint(t);
|
|
1031
|
+
else hideThinkingHint(t);
|
|
1032
|
+
// Turn finalization is driven by result/aborted/error events: step-level
|
|
1033
|
+
// state flapping (opencode emits idle between steps) must not split a
|
|
1034
|
+
// turn into several collapses.
|
|
1035
|
+
break;
|
|
1036
|
+
}
|
|
1037
|
+
case 'claude/aborted':
|
|
1038
|
+
if (t) t.interrupted = true;
|
|
1039
|
+
endTurnVisuals();
|
|
1040
|
+
{
|
|
1041
|
+
const stopNote = el('div', 'turn-note');
|
|
1042
|
+
stopNote.append(icon('stop', 12), el('span', null, ' 已停止'));
|
|
1043
|
+
col.append(stopNote);
|
|
1044
|
+
}
|
|
1045
|
+
{
|
|
1046
|
+
// The interrupted footer belongs to THIS turn's message only — when
|
|
1047
|
+
// nothing was streamed yet, the previous turn's message must not
|
|
1048
|
+
// grow a second footer.
|
|
1049
|
+
const ownMsg = t && t.liveText && t.liveText.isConnected ? t.liveText : null;
|
|
1050
|
+
if (ownMsg) ownMsg.append(msgFooter(ownMsg.textContent, t ? t.lastPrompt : '', { interrupted: true }));
|
|
1051
|
+
}
|
|
1052
|
+
setBusyState('idle');
|
|
1053
|
+
break;
|
|
1054
|
+
case 'claude/error': {
|
|
1055
|
+
const message = String(event.message || event.error || '执行出错');
|
|
1056
|
+
endTurnVisuals();
|
|
1057
|
+
const err = el('div', 'msg ai error');
|
|
1058
|
+
err.append(icon('warn', 15), el('span', 'err-text', message));
|
|
1059
|
+
col.append(err);
|
|
1060
|
+
setBusyState('idle');
|
|
1061
|
+
break;
|
|
1062
|
+
}
|
|
1063
|
+
case 'claude/stderr': {
|
|
1064
|
+
const ctx = t ? ensureProc(t) : null;
|
|
1065
|
+
const text = String(event.text || '').trim();
|
|
1066
|
+
if (!text) break;
|
|
1067
|
+
// One reusable "runtime output" block per turn: drivers emit a burst of
|
|
1068
|
+
// stderr chunks on startup, which must not become N separate folds.
|
|
1069
|
+
let note = ctx ? ctx.stderrNote : null;
|
|
1070
|
+
if (!note || !note.isConnected) {
|
|
1071
|
+
note = el('details', 'step note done');
|
|
1072
|
+
note._count = 0;
|
|
1073
|
+
const summary = el('summary', null, '');
|
|
1074
|
+
const noteCaret = el('span', 's-caret');
|
|
1075
|
+
noteCaret.append(icon('caret', 12));
|
|
1076
|
+
summary.append(noteCaret, el('span', 's-dot'), el('span', 's-name', '运行时输出'), el('span', 's-meta', ''));
|
|
1077
|
+
const noteBody = el('div', 'step-body');
|
|
1078
|
+
note.append(summary, noteBody);
|
|
1079
|
+
(ctx ? ctx.steps : col).append(note);
|
|
1080
|
+
if (ctx) { ctx.stderrNote = note; ctx.stderrLast = ''; }
|
|
1081
|
+
}
|
|
1082
|
+
const last = ctx ? ctx.stderrLast : note._last || '';
|
|
1083
|
+
if (text === last) break; // consecutive duplicates collapse
|
|
1084
|
+
if (ctx) ctx.stderrLast = text; else note._last = text;
|
|
1085
|
+
note.querySelector('.step-body').append(ioCard('text', text.slice(0, 2000), '输出'));
|
|
1086
|
+
note._count += 1;
|
|
1087
|
+
const meta = note.querySelector('.s-meta');
|
|
1088
|
+
if (meta) meta.textContent = `${note._count} 条`;
|
|
1089
|
+
break;
|
|
1090
|
+
}
|
|
1091
|
+
case 'claude/retry': {
|
|
1092
|
+
const ctx = t ? ensureProc(t) : null;
|
|
1093
|
+
const step = el('div', 'step-line');
|
|
1094
|
+
step.append(el('span', 's-dot'), el('span', 's-desc', `重试(第 ${event.attempt || '?'} 次)`));
|
|
1095
|
+
(ctx ? ctx.steps : col).append(step);
|
|
1096
|
+
break;
|
|
1097
|
+
}
|
|
1098
|
+
case 'claude/selection-changed': {
|
|
1099
|
+
if (state.sessionSummary) {
|
|
1100
|
+
if (event.toModel) state.sessionSummary.model = event.toModel;
|
|
1101
|
+
if (event.toAiProfileId !== undefined) state.sessionSummary.aiProfileId = event.toAiProfileId;
|
|
1102
|
+
renderModelLabel();
|
|
1103
|
+
}
|
|
1104
|
+
const from = event.fromModel || '默认模型';
|
|
1105
|
+
const to = event.toModel || '默认模型';
|
|
1106
|
+
const switchNote = el('div', 'turn-note');
|
|
1107
|
+
switchNote.append(icon('switch', 12), el('span', null, ` 已切换:${from} → ${to}`));
|
|
1108
|
+
col.append(switchNote);
|
|
1109
|
+
break;
|
|
1110
|
+
}
|
|
1111
|
+
default:
|
|
1112
|
+
break;
|
|
1113
|
+
}
|
|
1114
|
+
const stream = $('stream');
|
|
1115
|
+
// Follow-mode autoscroll (OpenCode-style): stick to the bottom while the
|
|
1116
|
+
// user hasn't scrolled away; the jump button restores following.
|
|
1117
|
+
if (stream && state.follow) stream.scrollTop = stream.scrollHeight;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
function buildToolStep(event, input) {
|
|
1121
|
+
const step = el('details', 'step');
|
|
1122
|
+
step.dataset.toolId = event.id || '';
|
|
1123
|
+
step._toolInput = input;
|
|
1124
|
+
step._toolName = event.name;
|
|
1125
|
+
step._startedAt = Date.now();
|
|
1126
|
+
const verb = toolVerb(event.name);
|
|
1127
|
+
const summary = el('summary', null, '');
|
|
1128
|
+
const caret = el('span', 's-caret');
|
|
1129
|
+
caret.append(icon('caret', 12));
|
|
1130
|
+
summary.append(caret, icon(TOOL_ICONS[verb] || 'dot', 13), el('span', 's-name', verb), el('span', 's-desc', toolDetail(event.name, input)), buildToolArgs(input), el('span', 's-meta running', '运行中'));
|
|
1131
|
+
step.append(summary);
|
|
1132
|
+
// Edit/write steps change the workspace; keep them visible when the fold opens.
|
|
1133
|
+
if (verb === '编辑' || verb === '写入') step.open = true;
|
|
1134
|
+
const body = el('div', 'step-body');
|
|
1135
|
+
body.append(ioCard('json', input, '输入'));
|
|
1136
|
+
step.append(body);
|
|
1137
|
+
renderFileChanges(step, event, input);
|
|
1138
|
+
return step;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
const TOOL_ICONS = {
|
|
1142
|
+
'命令': 'terminal', '读取': 'file', '写入': 'pencil', '编辑': 'pencil',
|
|
1143
|
+
'搜索': 'search', '网络': 'globe', '子任务': 'agent',
|
|
1144
|
+
};
|
|
1145
|
+
|
|
1146
|
+
function buildToolArgs(input) {
|
|
1147
|
+
const wrap = el('span', 's-args');
|
|
1148
|
+
for (const key of ['pattern', 'include', 'glob', 'offset', 'limit', 'encoding', 'agent']) {
|
|
1149
|
+
const value = input ? input[key] : undefined;
|
|
1150
|
+
if (value === undefined || value === null || value === '') continue;
|
|
1151
|
+
wrap.append(el('i', null, `${key}=${value}`));
|
|
1152
|
+
if (wrap.children.length >= 3) break;
|
|
1153
|
+
}
|
|
1154
|
+
return wrap;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
function toolVerb(name) {
|
|
1158
|
+
const tool = String(name || '').toLowerCase();
|
|
1159
|
+
if (tool === 'bash' || tool === 'terminal' || tool === 'shell') return '命令';
|
|
1160
|
+
if (tool === 'read') return '读取';
|
|
1161
|
+
if (tool === 'write') return '写入';
|
|
1162
|
+
if (['edit', 'apply_patch', 'multiedit', 'notebookedit'].includes(tool)) return '编辑';
|
|
1163
|
+
if (['grep', 'glob', 'search'].includes(tool)) return '搜索';
|
|
1164
|
+
if (['webfetch', 'websearch', 'fetch'].includes(tool)) return '网络';
|
|
1165
|
+
if (['task', 'agent'].includes(tool)) return '子任务';
|
|
1166
|
+
return '工具';
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
function toolDetail(name, input) {
|
|
1170
|
+
const tool = String(name || '').toLowerCase();
|
|
1171
|
+
const file = input.file_path || input.path || input.filePath || '';
|
|
1172
|
+
if (tool === 'bash' && input.command) return String(input.command).replace(/\s+/g, ' ').slice(0, 90);
|
|
1173
|
+
if (file) return String(file).slice(0, 90);
|
|
1174
|
+
if (input.pattern || input.query) return String(input.pattern || input.query).slice(0, 60);
|
|
1175
|
+
if (input.url) return String(input.url).slice(0, 60);
|
|
1176
|
+
return String(name || '').slice(0, 40);
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
// Copy feedback swaps the button's icon to a check mark instead of overwriting
|
|
1180
|
+
// textContent (which would destroy the SVG and leave a blank button behind).
|
|
1181
|
+
function copyText(text, button) {
|
|
1182
|
+
if (!navigator.clipboard) return;
|
|
1183
|
+
navigator.clipboard.writeText(String(text || '')).then(() => {
|
|
1184
|
+
const glyph = button.querySelector('.ic');
|
|
1185
|
+
if (!glyph) return;
|
|
1186
|
+
const restore = icon(glyph.classList.contains('ic-check') ? 'check' : 'copy', 13);
|
|
1187
|
+
button.classList.add('copied');
|
|
1188
|
+
glyph.replaceWith(icon('check', 13));
|
|
1189
|
+
setTimeout(() => {
|
|
1190
|
+
button.classList.remove('copied');
|
|
1191
|
+
const current = button.querySelector('.ic');
|
|
1192
|
+
if (current) current.replaceWith(restore);
|
|
1193
|
+
}, 1200);
|
|
1194
|
+
}).catch(() => {});
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
// 复制 / 重试 / 模型 · 已中断 · 时间 — the action row under the final assistant message.
|
|
1198
|
+
function msgFooter(text, lastPrompt, options = {}) {
|
|
1199
|
+
const row = el('div', 'msg-foot');
|
|
1200
|
+
const copy = el('button', 'foot-btn');
|
|
1201
|
+
copy.title = '复制';
|
|
1202
|
+
copy.append(icon('copy', 12));
|
|
1203
|
+
copy.addEventListener('click', () => copyText(text, copy));
|
|
1204
|
+
row.append(copy);
|
|
1205
|
+
if (lastPrompt && !state.replaying && state.activeSessionId) {
|
|
1206
|
+
const retry = el('button', 'foot-btn');
|
|
1207
|
+
retry.title = '重试';
|
|
1208
|
+
retry.append(icon('retry', 13));
|
|
1209
|
+
retry.addEventListener('click', async () => {
|
|
1210
|
+
if (state.busy || state.sending) return;
|
|
1211
|
+
try {
|
|
1212
|
+
await client.send({ sessionId: state.activeSessionId, text: lastPrompt });
|
|
1213
|
+
setBusyState('running');
|
|
1214
|
+
} catch (error) { toast(error.message, true); }
|
|
1215
|
+
});
|
|
1216
|
+
row.append(retry);
|
|
1217
|
+
}
|
|
1218
|
+
const bits = [];
|
|
1219
|
+
if (options.model) bits.push(options.model);
|
|
1220
|
+
if (options.interrupted) bits.push('已中断');
|
|
1221
|
+
bits.push(new Date().toTimeString().slice(0, 5));
|
|
1222
|
+
row.append(el('span', 'foot-time', bits.join(' · ')));
|
|
1223
|
+
return row;
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
// Per-turn file change ledger: rows merged across tool calls (OpenCode-style
|
|
1227
|
+
// "N 个文件更改 +A −D" summary block under the final message).
|
|
1228
|
+
function collectTurnChanges(t, event, input) {
|
|
1229
|
+
if (!t.changes) t.changes = new Map();
|
|
1230
|
+
for (const change of fileChangeSummaries(event, input)) {
|
|
1231
|
+
if (!change.path) continue;
|
|
1232
|
+
const prev = t.changes.get(change.path) || { path: change.path, kind: change.kind, additions: 0, deletions: 0, counted: false, approximate: false };
|
|
1233
|
+
if (change.counts) {
|
|
1234
|
+
prev.additions += change.counts.additions || 0;
|
|
1235
|
+
prev.deletions += change.counts.deletions || 0;
|
|
1236
|
+
prev.counted = true;
|
|
1237
|
+
prev.approximate = prev.approximate || Boolean(change.counts.approximate);
|
|
1238
|
+
} else if (change.lines != null) {
|
|
1239
|
+
prev.additions += change.lines;
|
|
1240
|
+
prev.counted = true;
|
|
1241
|
+
}
|
|
1242
|
+
t.changes.set(change.path, prev);
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
function renderTurnDiffs(changes) {
|
|
1247
|
+
const entries = [...changes.values()];
|
|
1248
|
+
const block = el('div', 'turn-diffs');
|
|
1249
|
+
const head = el('div', 'turn-diffs-head');
|
|
1250
|
+
head.append(el('span', 'td-label', `${entries.length} 个文件更改`));
|
|
1251
|
+
const counts = el('span', 'td-count');
|
|
1252
|
+
const adds = entries.reduce((sum, entry) => sum + entry.additions, 0);
|
|
1253
|
+
const dels = entries.reduce((sum, entry) => sum + entry.deletions, 0);
|
|
1254
|
+
counts.append(el('i', 'diff-add', `+${adds}`), el('i', 'diff-remove', `−${dels}`));
|
|
1255
|
+
if (entries.some(entry => entry.approximate)) counts.append(el('i', 'td-approx', '约'));
|
|
1256
|
+
head.append(counts);
|
|
1257
|
+
block.append(head);
|
|
1258
|
+
const list = el('div', 'turn-diffs-list');
|
|
1259
|
+
for (const entry of entries.slice(0, 10)) {
|
|
1260
|
+
const row = el('div', 'td-row');
|
|
1261
|
+
const label = { add: '新增', create: '新增', delete: '删除', remove: '删除', write: '写入', update: '修改' }[entry.kind] || '修改';
|
|
1262
|
+
row.append(el('span', 'file-path', `${label} ${entry.path}`));
|
|
1263
|
+
if (entry.counted) row.append(el('span', 'td-rowcount', `+${entry.additions} −${entry.deletions}`));
|
|
1264
|
+
list.append(row);
|
|
1265
|
+
}
|
|
1266
|
+
if (entries.length > 10) list.append(el('div', 'td-row td-more', `还有 ${entries.length - 10} 个文件…`));
|
|
1267
|
+
block.append(list);
|
|
1268
|
+
return block;
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
// Single-pass JSON highlighter: tokenizes raw text first (escaping each slice),
|
|
1272
|
+
// because escaping first would turn quotes into " and break the key regex.
|
|
1273
|
+
function highlightJson(source) {
|
|
1274
|
+
const text = String(source);
|
|
1275
|
+
const re = /("(?:[^"\\]|\\.)*")(\s*:)|("(?:[^"\\]|\\.)*")|\b(true|false|null)\b|(-?\d+(?:\.\d+)?)/g;
|
|
1276
|
+
let out = '';
|
|
1277
|
+
let last = 0;
|
|
1278
|
+
for (let match; (match = re.exec(text));) {
|
|
1279
|
+
out += escapeHtml(text.slice(last, match.index));
|
|
1280
|
+
if (match[1]) out += `<span class="j-key">${escapeHtml(match[1])}</span>${escapeHtml(match[2])}`;
|
|
1281
|
+
else if (match[3]) out += `<span class="j-str">${escapeHtml(match[3])}</span>`;
|
|
1282
|
+
else if (match[4]) out += `<span class="j-bool">${match[4]}</span>`;
|
|
1283
|
+
else if (match[5]) out += `<span class="j-num">${match[5]}</span>`;
|
|
1284
|
+
last = re.lastIndex;
|
|
1285
|
+
}
|
|
1286
|
+
out += escapeHtml(text.slice(last));
|
|
1287
|
+
return out;
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
// Collapsible payload card with a language tag and copy button, ZCode-style.
|
|
1291
|
+
function ioCard(lang, value, fallbackLabel) {
|
|
1292
|
+
const card = el('div', 'io-card');
|
|
1293
|
+
const text = value == null ? '' : typeof value === 'string' ? value : JSON.stringify(value, null, 2);
|
|
1294
|
+
const pretty = typeof value === 'string' && lang === 'json' && value.trim().startsWith('{') ? safeStringify(value) : (typeof value === 'object' && value != null ? JSON.stringify(value, null, 2) : text);
|
|
1295
|
+
const head = el('div', 'io-head');
|
|
1296
|
+
const tag = el('span', 'io-tag');
|
|
1297
|
+
if (lang === 'json') { tag.append(el('span', 'io-brace', '{ }'), el('span', null, 'json')); }
|
|
1298
|
+
else tag.textContent = fallbackLabel || lang;
|
|
1299
|
+
head.append(tag);
|
|
1300
|
+
const copy = el('button', 'io-copy');
|
|
1301
|
+
copy.title = '复制';
|
|
1302
|
+
copy.append(icon('copy', 12));
|
|
1303
|
+
copy.addEventListener('click', () => copyText(pretty || text, copy));
|
|
1304
|
+
head.append(copy);
|
|
1305
|
+
card.append(head);
|
|
1306
|
+
const pre = el('pre');
|
|
1307
|
+
if (lang === 'json' && pretty) pre.innerHTML = highlightJson(pretty);
|
|
1308
|
+
else pre.textContent = String(text).slice(0, 4000);
|
|
1309
|
+
card.append(pre);
|
|
1310
|
+
return card;
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
function safeStringify(value) {
|
|
1314
|
+
try { return JSON.stringify(JSON.parse(value), null, 2); } catch { return null; }
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
function textLines(value) {
|
|
1318
|
+
const text = String(value || '').replace(/\r\n/g, '\n');
|
|
1319
|
+
return text ? text.replace(/\n$/, '').split('\n') : [];
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
function changedLineCounts(before, after) {
|
|
1323
|
+
const oldLines = textLines(before), newLines = textLines(after);
|
|
1324
|
+
while (oldLines.length && newLines.length && oldLines[0] === newLines[0]) { oldLines.shift(); newLines.shift(); }
|
|
1325
|
+
while (oldLines.length && newLines.length && oldLines[oldLines.length - 1] === newLines[newLines.length - 1]) { oldLines.pop(); newLines.pop(); }
|
|
1326
|
+
// Bound the work for very large edits; never freeze the event stream.
|
|
1327
|
+
if (oldLines.length * newLines.length > 250000) return { additions: newLines.length, deletions: oldLines.length, approximate: true };
|
|
1328
|
+
const row = new Uint32Array(newLines.length + 1);
|
|
1329
|
+
for (const oldLine of oldLines) {
|
|
1330
|
+
let previous = 0;
|
|
1331
|
+
for (let j = 1; j <= newLines.length; j += 1) {
|
|
1332
|
+
const saved = row[j];
|
|
1333
|
+
row[j] = oldLine === newLines[j - 1] ? previous + 1 : Math.max(row[j], row[j - 1]);
|
|
1334
|
+
previous = saved;
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
return { additions: newLines.length - row[newLines.length], deletions: oldLines.length - row[newLines.length] };
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
function patchCounts(patch) {
|
|
1341
|
+
if (typeof patch !== 'string' || !patch.trim()) return null;
|
|
1342
|
+
const lines = patch.split(/\r?\n/);
|
|
1343
|
+
const relevant = lines.filter(line => /^[+-]/.test(line) && !/^(---|\+\+\+)/.test(line));
|
|
1344
|
+
if (!relevant.length) return null;
|
|
1345
|
+
return { additions: relevant.filter(line => line[0] === '+').length, deletions: relevant.filter(line => line[0] === '-').length };
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
function fileChangeSummaries(event, input = {}) {
|
|
1349
|
+
const output = event.output && typeof event.output === 'object' ? event.output : {};
|
|
1350
|
+
const metadata = event.metadata || output.metadata || input.metadata || {};
|
|
1351
|
+
const changes = event.changes || input.changes || metadata.changes;
|
|
1352
|
+
if (Array.isArray(changes) && changes.length) {
|
|
1353
|
+
return changes.map(change => ({
|
|
1354
|
+
path: change.path || change.file_path || change.filePath || '',
|
|
1355
|
+
kind: typeof change.kind === 'string' ? change.kind : change.kind && change.kind.type || 'update',
|
|
1356
|
+
counts: Number.isFinite(change.additions) && Number.isFinite(change.deletions)
|
|
1357
|
+
? { additions: change.additions, deletions: change.deletions }
|
|
1358
|
+
: patchCounts(change.diff || change.patch),
|
|
1359
|
+
}));
|
|
1360
|
+
}
|
|
1361
|
+
const path = input.file_path || input.path || input.filePath || metadata.filePath || metadata.filepath || '';
|
|
1362
|
+
const patch = event.diff || metadata.diff || input.diff || input.patch;
|
|
1363
|
+
if (patch) return [{ path, kind: 'update', counts: patchCounts(patch) }];
|
|
1364
|
+
const oldText = input.old_string == null ? input.oldString : input.old_string;
|
|
1365
|
+
const newText = input.new_string == null ? input.newString : input.new_string;
|
|
1366
|
+
if (typeof oldText === 'string' && typeof newText === 'string') return [{ path, kind: 'update', counts: changedLineCounts(oldText, newText) }];
|
|
1367
|
+
if (Array.isArray(input.edits)) return input.edits.flatMap(edit => fileChangeSummaries({}, { ...edit, file_path: path }));
|
|
1368
|
+
if (typeof input.content === 'string' && path) return [{ path, kind: 'write', lines: textLines(input.content).length }];
|
|
1369
|
+
if (input.files) return [{ path: String(input.files), kind: 'update', counts: null }];
|
|
1370
|
+
return [];
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
function renderFileChanges(details, event, input) {
|
|
1374
|
+
const changes = fileChangeSummaries(event, input);
|
|
1375
|
+
if (!changes.length) return;
|
|
1376
|
+
const old = details.querySelector('.file-changes');
|
|
1377
|
+
if (old) old.remove();
|
|
1378
|
+
const wrap = el('div', 'file-changes');
|
|
1379
|
+
for (const change of changes) {
|
|
1380
|
+
const row = el('div', 'file-change');
|
|
1381
|
+
const label = { add: '新增', create: '新增', delete: '删除', remove: '删除', write: '写入', update: '修改' }[change.kind] || '修改';
|
|
1382
|
+
row.append(el('span', 'file-path', `${label} ${change.path || '文件'}`));
|
|
1383
|
+
if (change.counts) {
|
|
1384
|
+
if (change.counts.approximate) row.append(el('span', 'meta', '约'));
|
|
1385
|
+
row.append(el('span', 'diff-add', `+${change.counts.additions}`), el('span', 'diff-remove', `−${change.counts.deletions}`));
|
|
1386
|
+
} else if (change.lines != null) row.append(el('span', 'meta', `${change.lines} 行`));
|
|
1387
|
+
else row.append(el('span', 'meta', '未提供行差异'));
|
|
1388
|
+
wrap.append(row);
|
|
1389
|
+
}
|
|
1390
|
+
// The chips stay above the fold inside the step body; the full tool payload remains collapsed.
|
|
1391
|
+
const host = details.querySelector('.step-body') || details.querySelector('summary');
|
|
1392
|
+
if (host) host.append(wrap);
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
/* ===================== markdown ===================== */
|
|
1396
|
+
|
|
1397
|
+
function escapeHtml(value) {
|
|
1398
|
+
return String(value).replace(/[&<>"']/g, ch => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch]));
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
function inlineMd(text) {
|
|
1402
|
+
const codes = [];
|
|
1403
|
+
let out = text.replace(/`([^`]+)`/g, (match, code) => { codes.push(code); return `\u0001${codes.length - 1}\u0001`; });
|
|
1404
|
+
out = out
|
|
1405
|
+
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
|
1406
|
+
.replace(/(^|[\s(])\*([^*\s][^*]*)\*/g, '$1<em>$2</em>')
|
|
1407
|
+
.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
|
|
1408
|
+
return out.replace(/\u0001(\d+)\u0001/g, (match, index) => `<code>${codes[Number(index)]}</code>`);
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
// Minimal renderer for agent replies: escape first, then apply the handful of
|
|
1412
|
+
// constructs replies actually use. The model output is never trusted as HTML.
|
|
1413
|
+
function renderMarkdown(source) {
|
|
1414
|
+
const text = String(source || '').replace(/\r\n/g, '\n');
|
|
1415
|
+
const blocks = [];
|
|
1416
|
+
const withCode = text.replace(/```([\w+#.-]*)[ \t]*\n?([\s\S]*?)(?:```|$)/g, (match, lang, code) => {
|
|
1417
|
+
blocks.push({ lang: String(lang || '').slice(0, 24), code: String(code).replace(/\n$/, '') });
|
|
1418
|
+
return `\u0000${blocks.length - 1}\u0000`;
|
|
1419
|
+
});
|
|
1420
|
+
const out = [];
|
|
1421
|
+
let list = null;
|
|
1422
|
+
let para = [];
|
|
1423
|
+
const flushPara = () => { if (para.length) { out.push(`<p>${para.join('<br>')}</p>`); para = []; } };
|
|
1424
|
+
const flushList = () => { if (list) { out.push(`</${list}>`); list = null; } };
|
|
1425
|
+
for (const line of escapeHtml(withCode).split('\n')) {
|
|
1426
|
+
const stripped = line.trim();
|
|
1427
|
+
const bullet = stripped.match(/^[-*+]\s+(.*)$/);
|
|
1428
|
+
const ordered = stripped.match(/^(\d+)[.)]\s+(.*)$/);
|
|
1429
|
+
if (bullet || ordered) {
|
|
1430
|
+
flushPara();
|
|
1431
|
+
const want = bullet ? 'ul' : 'ol';
|
|
1432
|
+
if (list !== want) { flushList(); out.push(`<${want}>`); list = want; }
|
|
1433
|
+
out.push(`<li>${inlineMd(bullet ? bullet[1] : ordered[2])}</li>`);
|
|
1434
|
+
continue;
|
|
1435
|
+
}
|
|
1436
|
+
flushList();
|
|
1437
|
+
if (!stripped) { flushPara(); continue; }
|
|
1438
|
+
const heading = stripped.match(/^(#{1,6})\s+(.*)$/);
|
|
1439
|
+
if (heading) {
|
|
1440
|
+
flushPara();
|
|
1441
|
+
const level = Math.min(heading[1].length + 2, 6);
|
|
1442
|
+
out.push(`<h${level}>${inlineMd(heading[2])}</h${level}>`);
|
|
1443
|
+
continue;
|
|
1444
|
+
}
|
|
1445
|
+
if (/^(---+|\*\*\*+|___+)\s*$/.test(stripped)) { flushPara(); out.push('<hr>'); continue; }
|
|
1446
|
+
const quote = stripped.match(/^>\s?(.*)$/);
|
|
1447
|
+
if (quote) { flushPara(); out.push(`<blockquote>${inlineMd(quote[1])}</blockquote>`); continue; }
|
|
1448
|
+
para.push(inlineMd(stripped));
|
|
1449
|
+
}
|
|
1450
|
+
flushList();
|
|
1451
|
+
flushPara();
|
|
1452
|
+
return out.join('\n').replace(/\u0000(\d+)\u0000/g, (match, index) => {
|
|
1453
|
+
const block = blocks[Number(index)];
|
|
1454
|
+
if (!block) return '';
|
|
1455
|
+
return `<div class="codeblock"><div class="cb-head"><span class="cb-lang">${escapeHtml(block.lang)}</span><button type="button" class="cb-copy">复制</button></div><pre><code>${escapeHtml(block.code)}</code></pre></div>`;
|
|
1456
|
+
});
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
function setMarkdown(node, source) {
|
|
1460
|
+
// Some agents leak raw <think> blocks into the text stream; that is process
|
|
1461
|
+
// narration, not reply content — never render it in the open stream.
|
|
1462
|
+
const text = stripThink(String(source || ''));
|
|
1463
|
+
// Very large streams would re-render markdown on every delta; degrade to text.
|
|
1464
|
+
if (text.length > 200000) { node.textContent = text; return; }
|
|
1465
|
+
node.innerHTML = renderMarkdown(text);
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
function stripThink(text) {
|
|
1469
|
+
return text.replace(/<think>[\s\S]*?<\/think>/g, '').replace(/<think>[\s\S]*$/, '').trim();
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
function permissionSubtitle(event) {
|
|
1473
|
+
const input = event.input || {};
|
|
1474
|
+
return `${event.toolName || ''} ${input.file_path || input.command || ''}`.trim().slice(0, 120) || '请求授权';
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
async function settlePermission(card, requestId, allow) {
|
|
1478
|
+
try {
|
|
1479
|
+
await client.resolvePermission({ sessionId: state.activeSessionId, requestId, allow });
|
|
1480
|
+
settlePermissionCard(card, allow);
|
|
1481
|
+
} catch (error) { toast(error.message, true); }
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
function settlePermissionCard(card, allow) {
|
|
1485
|
+
const actions = card.querySelector('.p-actions');
|
|
1486
|
+
if (actions) {
|
|
1487
|
+
const stateNode = el('span', 'p-state' + (allow ? '' : ' no'), allow ? '已允许' : '已拒绝');
|
|
1488
|
+
actions.replaceWith(stateNode);
|
|
1489
|
+
}
|
|
1490
|
+
// Settled approvals fold to a single inline line (OpenCode-style), not a card.
|
|
1491
|
+
card.classList.add('settled');
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
function cssEscape(value) { return String(value).replace(/[^a-zA-Z0-9_-]/g, '\\$&'); }
|
|
1495
|
+
function fmtTokens(value) {
|
|
1496
|
+
const n = Number(value || 0);
|
|
1497
|
+
if (n >= 1000000) return `${(n / 1000000).toFixed(1)}M`;
|
|
1498
|
+
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
|
|
1499
|
+
return String(n);
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
/* ===================== busy / activity / context ===================== */
|
|
1503
|
+
|
|
1504
|
+
function setBusyState(sessionState) {
|
|
1505
|
+
state.busy = ['running', 'thinking', 'tool-running', 'spawning', 'pending-permission'].includes(sessionState);
|
|
1506
|
+
const status = $('session-status');
|
|
1507
|
+
const send = $('btn-send');
|
|
1508
|
+
if (sessionState && sessionState !== 'idle') {
|
|
1509
|
+
status.hidden = false;
|
|
1510
|
+
$('session-status-text').textContent = {
|
|
1511
|
+
running: '工作中', thinking: '思考中', 'tool-running': '执行工具', spawning: '启动中',
|
|
1512
|
+
'pending-permission': '等待授权', aborted: '已停止', failed: '失败',
|
|
1513
|
+
}[sessionState] || sessionState;
|
|
1514
|
+
} else {
|
|
1515
|
+
status.hidden = true;
|
|
1516
|
+
}
|
|
1517
|
+
send.innerHTML = '';
|
|
1518
|
+
if (state.busy) send.append(el('span', 'sq'));
|
|
1519
|
+
else { const up = el('span', 'up'); up.append(icon('send', 16)); send.append(up); }
|
|
1520
|
+
send.title = state.busy ? '停止' : '发送';
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
async function refreshContextUsage() {
|
|
1524
|
+
if (!state.activeSessionId) return;
|
|
1525
|
+
try {
|
|
1526
|
+
const usage = await fetch(`/api/claude-workbench/v1/sessions/${encodeURIComponent(state.activeSessionId)}/context-usage`).then(r => (r.ok ? r.json() : null));
|
|
1527
|
+
if (!usage) return;
|
|
1528
|
+
$('btn-ctx').hidden = false;
|
|
1529
|
+
$('btn-ctx').title = usage.percent != null ? `上下文用量 ${usage.percent}%` : '上下文用量';
|
|
1530
|
+
$('ctx-label').textContent = `${fmtTokens(usage.used)} / ${fmtTokens(usage.contextWindow)}`;
|
|
1531
|
+
let mini = $('ctx-mini');
|
|
1532
|
+
if (!mini) { mini = el('span', 'ctx-mini'); mini.id = 'ctx-mini'; $('btn-ctx').append(mini); }
|
|
1533
|
+
mini.textContent = '';
|
|
1534
|
+
const miniFill = el('i');
|
|
1535
|
+
miniFill.style.width = `${Math.min(100, usage.percent || 0)}%`;
|
|
1536
|
+
mini.append(miniFill);
|
|
1537
|
+
const pop = $('pop-ctx');
|
|
1538
|
+
pop.textContent = '';
|
|
1539
|
+
const head = el('div', 'ctx-head');
|
|
1540
|
+
head.append(el('span', null, '上下文容量'), el('span', 'num', `${fmtTokens(usage.used)}/${fmtTokens(usage.contextWindow)}${usage.percent != null ? ` (${usage.percent}%)` : ''}`));
|
|
1541
|
+
pop.append(head);
|
|
1542
|
+
const bar = el('div', 'bar');
|
|
1543
|
+
const fill = el('i');
|
|
1544
|
+
fill.style.width = `${Math.min(100, usage.percent || 0)}%`;
|
|
1545
|
+
bar.append(fill);
|
|
1546
|
+
pop.append(bar);
|
|
1547
|
+
for (const [key, label] of [['messages', '消息'], ['tools', '工具'], ['other', '其他']]) {
|
|
1548
|
+
const row = el('div', 'ctx-row');
|
|
1549
|
+
row.append(el('span', null, `${label}(估算)`), el('span', null, fmtTokens(usage.breakdown[key])));
|
|
1550
|
+
pop.append(row);
|
|
1551
|
+
}
|
|
1552
|
+
pop.append(el('div', 'ctx-sep'));
|
|
1553
|
+
const foot = el('div', 'ctx-row');
|
|
1554
|
+
foot.append(el('span', null, '模型'), el('span', null, usage.model || '-'));
|
|
1555
|
+
pop.append(foot);
|
|
1556
|
+
} catch { /* leave the chip hidden */ }
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
async function refreshSessionRow() { await renderProjectList(); }
|
|
1560
|
+
|
|
1561
|
+
/* ===================== composer ===================== */
|
|
1562
|
+
|
|
1563
|
+
function wireComposer() {
|
|
1564
|
+
const input = $('chat-input');
|
|
1565
|
+
input.addEventListener('input', () => {
|
|
1566
|
+
input.style.height = 'auto';
|
|
1567
|
+
input.style.height = `${Math.min(150, input.scrollHeight)}px`;
|
|
1568
|
+
});
|
|
1569
|
+
input.addEventListener('keydown', event => {
|
|
1570
|
+
if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); sendCurrent(); }
|
|
1571
|
+
});
|
|
1572
|
+
$('btn-send').addEventListener('click', () => {
|
|
1573
|
+
if (state.busy) { stopCurrent(); } else { sendCurrent(); }
|
|
1574
|
+
});
|
|
1575
|
+
$('btn-attach').addEventListener('click', () => $('file-input').click());
|
|
1576
|
+
$('file-input').addEventListener('change', async event => {
|
|
1577
|
+
for (const file of event.target.files || []) await addAttachment(file);
|
|
1578
|
+
event.target.value = '';
|
|
1579
|
+
});
|
|
1580
|
+
$('btn-mode').addEventListener('click', () => openModeMenu());
|
|
1581
|
+
$('btn-model').addEventListener('click', openModelMenu);
|
|
1582
|
+
$('btn-effort').addEventListener('click', openEffortMenu);
|
|
1583
|
+
$('btn-ctx').addEventListener('click', () => { refreshContextUsage(); togglePop('pop-ctx'); });
|
|
1584
|
+
document.addEventListener('click', ev => { if (!ev.target.closest('.anchor')) closePops(); });
|
|
1585
|
+
document.addEventListener('keydown', ev => { if (ev.key === 'Escape') closePops(); });
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
async function addAttachment(file) {
|
|
1589
|
+
try {
|
|
1590
|
+
const mediaType = attachmentMediaType(file);
|
|
1591
|
+
const isImage = mediaType.startsWith('image/');
|
|
1592
|
+
if (isImage && (!state.caps || !state.caps.images)) throw new Error('当前 Agent 不支持图片输入;可以添加文本文件');
|
|
1593
|
+
if (state.attachments.length >= 20) throw new Error('每条消息最多 20 件附件');
|
|
1594
|
+
if (!file.size) throw new Error('不能添加空文件');
|
|
1595
|
+
if (file.size > (isImage ? 10 * 1024 * 1024 : 512 * 1024)) throw new Error(isImage ? '图片每件最多 10MB' : '文本文件每件最多 512KB');
|
|
1596
|
+
if (state.attachments.reduce((sum, item) => sum + item.size, file.size) > 20 * 1024 * 1024) throw new Error('每条消息的附件总计最多 20MB');
|
|
1597
|
+
const data = await new Promise((resolve, reject) => {
|
|
1598
|
+
const reader = new FileReader();
|
|
1599
|
+
reader.onload = () => resolve(String(reader.result).split(',')[1]);
|
|
1600
|
+
reader.onerror = () => reject(new Error(`无法读取 ${file.name}`));
|
|
1601
|
+
reader.readAsDataURL(file);
|
|
1602
|
+
});
|
|
1603
|
+
state.attachments.push({ name: file.name, mediaType, data, size: file.size });
|
|
1604
|
+
renderAttachmentTray();
|
|
1605
|
+
} catch (error) { toast(error.message, true); }
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
function attachmentMediaType(file) {
|
|
1609
|
+
const textTypes = { txt: 'text/plain', md: 'text/markdown', json: 'application/json', csv: 'text/csv' };
|
|
1610
|
+
const extension = String(file.name || '').split('.').pop().toLowerCase();
|
|
1611
|
+
if (textTypes[extension]) return textTypes[extension];
|
|
1612
|
+
if (['image/jpeg', 'image/png', 'image/gif', 'image/webp'].includes(file.type)) return file.type;
|
|
1613
|
+
throw new Error('支持 JPG、PNG、GIF、WebP 图片及 txt、md、json、csv 文本文件');
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
function formatBytes(size) {
|
|
1617
|
+
return size < 1024 ? `${size} B` : `${Math.ceil(size / 1024)} KB`;
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
function renderAttachmentTray() {
|
|
1621
|
+
let tray = document.querySelector('.composer .attach-tray');
|
|
1622
|
+
if (!state.attachments.length) { tray && tray.remove(); return; }
|
|
1623
|
+
if (!tray) {
|
|
1624
|
+
tray = el('div', 'attach-tray');
|
|
1625
|
+
$('composer').prepend(tray);
|
|
1626
|
+
}
|
|
1627
|
+
tray.textContent = '';
|
|
1628
|
+
state.attachments.forEach((item, index) => {
|
|
1629
|
+
const attachment = el('div', 'attachment-item');
|
|
1630
|
+
const isImage = item.mediaType.startsWith('image/');
|
|
1631
|
+
attachment.classList.toggle('img', isImage);
|
|
1632
|
+
if (isImage) {
|
|
1633
|
+
const img = document.createElement('img');
|
|
1634
|
+
img.src = `data:${item.mediaType};base64,${item.data}`;
|
|
1635
|
+
img.alt = item.name;
|
|
1636
|
+
attachment.append(img);
|
|
1637
|
+
} else {
|
|
1638
|
+
const chip = el('span', 'attachment-file');
|
|
1639
|
+
chip.append(icon('file', 12), el('span', null, ` ${item.name} · ${formatBytes(item.size)}`));
|
|
1640
|
+
attachment.append(chip);
|
|
1641
|
+
}
|
|
1642
|
+
const remove = el('button', 'attachment-remove');
|
|
1643
|
+
remove.title = `移除 ${item.name}`;
|
|
1644
|
+
remove.setAttribute('aria-label', remove.title);
|
|
1645
|
+
remove.append(icon('close', 11));
|
|
1646
|
+
remove.addEventListener('click', () => { state.attachments.splice(index, 1); renderAttachmentTray(); });
|
|
1647
|
+
attachment.append(remove);
|
|
1648
|
+
tray.append(attachment);
|
|
1649
|
+
});
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
async function sendCurrent() {
|
|
1653
|
+
if (state.busy || state.sending) return;
|
|
1654
|
+
const input = $('chat-input');
|
|
1655
|
+
const text = input.value.trim();
|
|
1656
|
+
if (!text && !state.attachments.length) return;
|
|
1657
|
+
state.sending = true;
|
|
1658
|
+
if (!state.activeSessionId) { await newSession(); if (!state.activeSessionId) { state.sending = false; return; } }
|
|
1659
|
+
input.value = '';
|
|
1660
|
+
input.style.height = 'auto';
|
|
1661
|
+
const attachments = state.attachments.splice(0);
|
|
1662
|
+
renderAttachmentTray();
|
|
1663
|
+
try {
|
|
1664
|
+
await client.send({
|
|
1665
|
+
sessionId: state.activeSessionId,
|
|
1666
|
+
text,
|
|
1667
|
+
attachments: attachments.map(item => ({ name: item.name, mediaType: item.mediaType, data: item.data })),
|
|
1668
|
+
// Effort rides on every message so mid-session switches take effect.
|
|
1669
|
+
options: { effort: state.caps && state.caps.generationParams.includes('effort') ? state.effort : undefined },
|
|
1670
|
+
});
|
|
1671
|
+
setBusyState('running');
|
|
1672
|
+
state.follow = true; // sending a message re-engages follow mode
|
|
1673
|
+
} catch (error) {
|
|
1674
|
+
toast(error.message, true);
|
|
1675
|
+
input.value = text;
|
|
1676
|
+
state.attachments.unshift(...attachments);
|
|
1677
|
+
renderAttachmentTray();
|
|
1678
|
+
} finally {
|
|
1679
|
+
state.sending = false;
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
async function stopCurrent() {
|
|
1684
|
+
if (!state.activeSessionId) return;
|
|
1685
|
+
try { await client.abort(state.activeSessionId); } catch (error) { toast(error.message, true); }
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
/* ===================== pop menus ===================== */
|
|
1689
|
+
|
|
1690
|
+
function closePops() { document.querySelectorAll('.pop').forEach(pop => { pop.hidden = true; }); }
|
|
1691
|
+
function togglePop(id) {
|
|
1692
|
+
const pop = $(id);
|
|
1693
|
+
const wasHidden = pop.hidden;
|
|
1694
|
+
closePops();
|
|
1695
|
+
pop.hidden = !wasHidden;
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
function openModeMenu() {
|
|
1699
|
+
if (!state.caps || !state.caps.permissions) { toast('当前 Agent 不支持权限配置', true); return; }
|
|
1700
|
+
const pop = $('pop-mode');
|
|
1701
|
+
pop.textContent = '';
|
|
1702
|
+
for (const [mode, label] of Object.entries(PERMISSION_LABELS)) {
|
|
1703
|
+
const item = el('button', 'pop-item' + (state.permissionMode === mode ? ' selected' : ''));
|
|
1704
|
+
item.append(el('span', 'grow', label), checkMark());
|
|
1705
|
+
item.addEventListener('click', async () => {
|
|
1706
|
+
state.permissionMode = mode;
|
|
1707
|
+
savePreferences({ permissionMode: mode });
|
|
1708
|
+
renderModeLabel();
|
|
1709
|
+
closePops();
|
|
1710
|
+
if (state.activeSessionId) {
|
|
1711
|
+
try { await client.setPermissionMode(state.activeSessionId, mode); } catch (error) { toast(error.message, true); }
|
|
1712
|
+
}
|
|
1713
|
+
});
|
|
1714
|
+
pop.append(item);
|
|
1715
|
+
}
|
|
1716
|
+
pop.hidden = false;
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
function openEffortMenu() {
|
|
1720
|
+
const pop = $('pop-effort');
|
|
1721
|
+
pop.textContent = '';
|
|
1722
|
+
for (const [level, label] of Object.entries(EFFORT_LABELS)) {
|
|
1723
|
+
const item = el('button', 'pop-item' + (state.effort === level ? ' selected' : ''));
|
|
1724
|
+
item.append(el('span', 'grow', label), checkMark());
|
|
1725
|
+
item.addEventListener('click', () => {
|
|
1726
|
+
state.effort = level;
|
|
1727
|
+
$('effort-label').textContent = label;
|
|
1728
|
+
savePreferences({ effort: level });
|
|
1729
|
+
closePops();
|
|
1730
|
+
});
|
|
1731
|
+
pop.append(item);
|
|
1732
|
+
}
|
|
1733
|
+
pop.hidden = false;
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
function renderModeLabel() {
|
|
1737
|
+
$('mode-label').textContent = PERMISSION_LABELS[state.permissionMode] || state.permissionMode;
|
|
1738
|
+
$('btn-mode').classList.toggle('accent', state.permissionMode === 'bypassPermissions');
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
/* ===================== settings ===================== */
|
|
1742
|
+
|
|
1743
|
+
function openSettings(tab) {
|
|
1744
|
+
closePops();
|
|
1745
|
+
document.querySelectorAll('.stab').forEach(node => node.classList.toggle('active', node.dataset.s === tab));
|
|
1746
|
+
document.querySelectorAll('.spanel').forEach(node => node.classList.remove('active'));
|
|
1747
|
+
$(`s-${tab}`).classList.add('active');
|
|
1748
|
+
$('view-terminal').classList.remove('active');
|
|
1749
|
+
$('view-settings').classList.add('active');
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
function wireSettings() {
|
|
1753
|
+
$('open-settings').addEventListener('click', () => openSettings('providers'));
|
|
1754
|
+
$('settings-back').addEventListener('click', () => {
|
|
1755
|
+
$('view-settings').classList.remove('active');
|
|
1756
|
+
$('view-terminal').classList.add('active');
|
|
1757
|
+
});
|
|
1758
|
+
document.querySelectorAll('.stab').forEach(tab => {
|
|
1759
|
+
tab.addEventListener('click', () => {
|
|
1760
|
+
document.querySelectorAll('.stab').forEach(node => node.classList.remove('active'));
|
|
1761
|
+
tab.classList.add('active');
|
|
1762
|
+
document.querySelectorAll('.spanel').forEach(node => node.classList.remove('active'));
|
|
1763
|
+
$(`s-${tab.dataset.s}`).classList.add('active');
|
|
1764
|
+
});
|
|
1765
|
+
});
|
|
1766
|
+
$('btn-rescan').addEventListener('click', async () => { await refreshAgents(); toast('已重新扫描', false, true); });
|
|
1767
|
+
$('mgmt-add').addEventListener('click', () => renderProviderDetail(null));
|
|
1768
|
+
wireCaptureSetting();
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
/* ===================== 会话捕获(终端对话是否留档) ===================== */
|
|
1772
|
+
|
|
1773
|
+
const CAPTURE_API = '/api/claude-workbench/v1/capture-settings';
|
|
1774
|
+
|
|
1775
|
+
function renderCaptureNote(text, bad) {
|
|
1776
|
+
const note = $('capture-note');
|
|
1777
|
+
if (note) { note.textContent = text || ''; note.style.color = bad ? 'var(--red, #e5695e)' : ''; }
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
async function loadCaptureSetting() {
|
|
1781
|
+
const toggle = $('capture-toggle');
|
|
1782
|
+
if (!toggle) return;
|
|
1783
|
+
try {
|
|
1784
|
+
const response = await fetch(CAPTURE_API);
|
|
1785
|
+
const body = await response.json();
|
|
1786
|
+
toggle.checked = Boolean(body && body.terminalConversations);
|
|
1787
|
+
renderCaptureNote(toggle.checked ? '当前开启:新建的终端对话会进入开发对话记录。' : '当前关闭:终端对话不留档。');
|
|
1788
|
+
} catch (error) {
|
|
1789
|
+
toggle.disabled = true;
|
|
1790
|
+
renderCaptureNote(`读取捕获设置失败:${error.message}`, true);
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
async function onCaptureToggle(event) {
|
|
1795
|
+
const toggle = event.target;
|
|
1796
|
+
const enabled = toggle.checked;
|
|
1797
|
+
toggle.disabled = true;
|
|
1798
|
+
try {
|
|
1799
|
+
const response = await fetch(CAPTURE_API, {
|
|
1800
|
+
method: 'PUT',
|
|
1801
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1802
|
+
body: JSON.stringify({ terminalConversations: enabled }),
|
|
1803
|
+
});
|
|
1804
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
1805
|
+
renderCaptureNote(enabled ? '当前开启:新建的终端对话会进入开发对话记录。' : '当前关闭:终端对话不留档。');
|
|
1806
|
+
toast(enabled ? '已开启:此后新建的终端对话会留档' : '已关闭:此后新建的终端对话不留档', false, true);
|
|
1807
|
+
} catch (error) {
|
|
1808
|
+
toggle.checked = !enabled;
|
|
1809
|
+
toast(`保存失败:${error.message}`, true);
|
|
1810
|
+
} finally {
|
|
1811
|
+
toggle.disabled = false;
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
function wireCaptureSetting() {
|
|
1816
|
+
const toggle = $('capture-toggle');
|
|
1817
|
+
if (!toggle) return;
|
|
1818
|
+
toggle.addEventListener('change', onCaptureToggle);
|
|
1819
|
+
loadCaptureSetting();
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
function renderProviderList() {
|
|
1823
|
+
const list = $('provider-list');
|
|
1824
|
+
if (!list) return;
|
|
1825
|
+
list.textContent = '';
|
|
1826
|
+
for (const profile of state.profiles) {
|
|
1827
|
+
const item = el('button', 'mgmt-item' + (profile.id === state.providerDetailId ? ' active' : ''));
|
|
1828
|
+
item.dataset.profile = profile.id;
|
|
1829
|
+
const glyph = el('span', null);
|
|
1830
|
+
glyph.append(icon('server', 13));
|
|
1831
|
+
const name = el('span', 'grow', profile.name);
|
|
1832
|
+
const dot = el('span', 'dot' + (profile.enabled && profile.credentialConfigured ? '' : ' off'));
|
|
1833
|
+
item.append(glyph, name, dot);
|
|
1834
|
+
item.addEventListener('click', () => renderProviderDetail(profile.id));
|
|
1835
|
+
list.append(item);
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
let providerDraft = null;
|
|
1840
|
+
|
|
1841
|
+
function renderProviderDetail(profileId) {
|
|
1842
|
+
state.providerDetailId = profileId || 'new';
|
|
1843
|
+
renderProviderList();
|
|
1844
|
+
const detail = $('provider-detail');
|
|
1845
|
+
detail.textContent = '';
|
|
1846
|
+
const profile = state.profiles.find(p => p.id === profileId);
|
|
1847
|
+
providerDraft = profile
|
|
1848
|
+
? { ...profile, original: profile, models: JSON.parse(JSON.stringify(profile.models.list || [])) }
|
|
1849
|
+
: { id: '', name: '', baseUrl: '', openaiBaseUrl: '', protocol: 'anthropic-compatible', models: [], enabled: true, credentialConfigured: false, credentialMasked: '' };
|
|
1850
|
+
// Two explicit slots: baseUrl = Anthropic endpoint, openaiBaseUrl = OpenAI
|
|
1851
|
+
// endpoint. The dropdown picks which slot the single visible input edits;
|
|
1852
|
+
// either slot alone is enough to save the provider.
|
|
1853
|
+
|
|
1854
|
+
const panel = el('div', 'pd active');
|
|
1855
|
+
const head = el('div', 'pd-head');
|
|
1856
|
+
head.append(el('h3', null, profile ? providerDraft.name : '添加模型供应商'));
|
|
1857
|
+
if (profile) {
|
|
1858
|
+
const chip = el('span', 'status-chip' + (profile.enabled ? '' : ' off'), profile.enabled ? '已启用' : '已禁用');
|
|
1859
|
+
head.append(chip);
|
|
1860
|
+
const toggle = el('button', 'ghost', profile.enabled ? '禁用' : '启用');
|
|
1861
|
+
toggle.addEventListener('click', async () => {
|
|
1862
|
+
try { await client.saveProfile({ ...profile, enabled: !profile.enabled }); await refreshProfiles(); renderProviderDetail(profileId); } catch (error) { toast(error.message, true); }
|
|
1863
|
+
});
|
|
1864
|
+
head.append(toggle);
|
|
1865
|
+
head.append(el('span', 'sp'));
|
|
1866
|
+
const del = el('button', 'del');
|
|
1867
|
+
del.title = '删除供应商';
|
|
1868
|
+
del.append(icon('trash', 13));
|
|
1869
|
+
del.addEventListener('click', async () => {
|
|
1870
|
+
if (!confirm(`删除供应商“${profile.name}”?`)) return;
|
|
1871
|
+
try { await client.deleteProfile(profile.id); state.providerDetailId = null; await refreshProfiles(); renderProviderDetail(null); } catch (error) { toast(error.message, true); }
|
|
1872
|
+
});
|
|
1873
|
+
head.append(del);
|
|
1874
|
+
}
|
|
1875
|
+
panel.append(head);
|
|
1876
|
+
|
|
1877
|
+
const nameField = field('名称', 'text', providerDraft.name, '如:智谱 GLM', value => { providerDraft.name = value; });
|
|
1878
|
+
panel.append(nameField);
|
|
1879
|
+
|
|
1880
|
+
const protocolField = el('div', 'f');
|
|
1881
|
+
protocolField.append(el('label', null, '接口类型'));
|
|
1882
|
+
const protocolSelect = el('select', 'fin');
|
|
1883
|
+
for (const [value, label] of [['anthropic-compatible', 'Anthropic 兼容(Claude Code)'], ['openai-compatible', 'OpenAI 兼容(Codex / OpenCode)']]) {
|
|
1884
|
+
const option = el('option', null, label);
|
|
1885
|
+
option.value = value;
|
|
1886
|
+
protocolSelect.append(option);
|
|
1887
|
+
}
|
|
1888
|
+
protocolSelect.value = providerDraft.protocol === 'openai-compatible' ? 'openai-compatible' : 'anthropic-compatible';
|
|
1889
|
+
protocolSelect.setAttribute('aria-label', '接口类型');
|
|
1890
|
+
protocolField.append(protocolSelect);
|
|
1891
|
+
protocolField.append(el('span', 'field-note', '切换类型即可分别填写两种协议的 Base URL;只填一个也能添加供应商,两种端点共用同一个 API Key。'));
|
|
1892
|
+
panel.append(protocolField);
|
|
1893
|
+
|
|
1894
|
+
// One visible input bound to the slot the dropdown selects — switching the
|
|
1895
|
+
// dropdown swaps the shown url so each protocol's endpoint is edited in place.
|
|
1896
|
+
const baseUrlField = el('div', 'f');
|
|
1897
|
+
baseUrlField.append(el('label', null, 'Base URL'));
|
|
1898
|
+
const baseUrlInput = el('input', 'fin');
|
|
1899
|
+
baseUrlInput.type = 'url';
|
|
1900
|
+
baseUrlInput.spellcheck = false;
|
|
1901
|
+
baseUrlInput.setAttribute('aria-label', 'Base URL');
|
|
1902
|
+
const slotFor = protocol => (protocol === 'openai-compatible' ? 'openaiBaseUrl' : 'baseUrl');
|
|
1903
|
+
const syncBaseUrlInput = () => {
|
|
1904
|
+
baseUrlInput.value = providerDraft[slotFor(providerDraft.protocol)] || '';
|
|
1905
|
+
baseUrlInput.placeholder = providerDraft.protocol === 'openai-compatible'
|
|
1906
|
+
? 'https://api.example.com/v1'
|
|
1907
|
+
: 'https://api.example.com/anthropic';
|
|
1908
|
+
};
|
|
1909
|
+
baseUrlInput.addEventListener('input', () => { providerDraft[slotFor(providerDraft.protocol)] = baseUrlInput.value.trim().replace(/\/+$/, ''); });
|
|
1910
|
+
syncBaseUrlInput();
|
|
1911
|
+
protocolSelect.addEventListener('change', () => {
|
|
1912
|
+
providerDraft.protocol = protocolSelect.value;
|
|
1913
|
+
syncBaseUrlInput();
|
|
1914
|
+
});
|
|
1915
|
+
baseUrlField.append(baseUrlInput);
|
|
1916
|
+
baseUrlField.append(el('span', 'field-note', '当前填写的是所选接口类型的端点;另一协议端点留空时,保存会自动探测同域名路径并回填(仅验证过的)。'));
|
|
1917
|
+
panel.append(baseUrlField);
|
|
1918
|
+
|
|
1919
|
+
// Escape hatch for providers whose other-protocol endpoint sits on an
|
|
1920
|
+
// unguessable path (e.g. Zhipu /api/paas/v4): only when auto-detection
|
|
1921
|
+
// could not verify one does this field appear.
|
|
1922
|
+
const keyField = el('div', 'f');
|
|
1923
|
+
keyField.append(el('label', null, `API Key${providerDraft.credentialConfigured ? `(已配置 ${providerDraft.credentialMasked})` : ''}`));
|
|
1924
|
+
const keyRow = el('div', 'key-row');
|
|
1925
|
+
const keyInput = el('input', 'fin');
|
|
1926
|
+
keyInput.type = 'password';
|
|
1927
|
+
keyInput.placeholder = providerDraft.credentialConfigured ? '留空保持不变' : '输入 API Key';
|
|
1928
|
+
keyInput.addEventListener('input', () => { providerDraft.credential = keyInput.value; });
|
|
1929
|
+
// Finishing the key entry (blur after typing) pulls the models by itself.
|
|
1930
|
+
keyInput.addEventListener('change', async () => {
|
|
1931
|
+
const added = await autoDetectDraftModels();
|
|
1932
|
+
if (added > 0) toast(`已自动填入 ${added} 个模型,保存后即可在聊天中选用`, false, true);
|
|
1933
|
+
});
|
|
1934
|
+
const eye = el('button', 'eye');
|
|
1935
|
+
eye.append(icon('eye', 15));
|
|
1936
|
+
eye.addEventListener('click', () => {
|
|
1937
|
+
keyInput.type = keyInput.type === 'password' ? 'text' : 'password';
|
|
1938
|
+
eye.textContent = '';
|
|
1939
|
+
eye.append(icon(keyInput.type === 'password' ? 'eye' : 'eyeOff', 15));
|
|
1940
|
+
});
|
|
1941
|
+
keyRow.append(keyInput, eye);
|
|
1942
|
+
keyField.append(keyRow);
|
|
1943
|
+
panel.append(keyField);
|
|
1944
|
+
|
|
1945
|
+
const foot = el('div', 'pd-foot');
|
|
1946
|
+
const note = el('span', 'note');
|
|
1947
|
+
note.append(icon('info', 13), el('span', null, ` ${profile ? '密钥单独加密存储,不随配置回传。' : '填好 Base URL 与 API Key 后点「添加供应商」,模型会自动检测填入。'}`));
|
|
1948
|
+
foot.append(note);
|
|
1949
|
+
const errorBox = el('div', 'form-error');
|
|
1950
|
+
errorBox.hidden = true;
|
|
1951
|
+
const showError = message => { errorBox.textContent = message; errorBox.hidden = false; };
|
|
1952
|
+
if (profile) {
|
|
1953
|
+
const test = el('button', 'ghost');
|
|
1954
|
+
test.append(icon('bolt', 13), el('span', null, '测试'));
|
|
1955
|
+
test.addEventListener('click', async () => {
|
|
1956
|
+
toast('测试中…');
|
|
1957
|
+
try {
|
|
1958
|
+
const result = await client.testProfileDraft(buildProfilePayload(), providerDraft.credential || '');
|
|
1959
|
+
toast(`测试通过:${result.model || 'ok'} · ${result.durationMs}ms`, false, true);
|
|
1960
|
+
} catch (error) { toast(`测试失败:${error.message}`, true); }
|
|
1961
|
+
});
|
|
1962
|
+
foot.append(test);
|
|
1963
|
+
}
|
|
1964
|
+
const save = el('button', 'cta', profile ? '保存' : '添加供应商');
|
|
1965
|
+
save.addEventListener('click', async () => {
|
|
1966
|
+
errorBox.hidden = true;
|
|
1967
|
+
save.disabled = true;
|
|
1968
|
+
try {
|
|
1969
|
+
// The default flow is zero manual models: fill them from the provider
|
|
1970
|
+
// right before saving so the saved profile is immediately usable.
|
|
1971
|
+
const added = await autoDetectDraftModels();
|
|
1972
|
+
if (added > 0) toast(`已自动填入 ${added} 个模型`, false, true);
|
|
1973
|
+
const payload = buildProfilePayload();
|
|
1974
|
+
const saved = await client.saveProfile(payload);
|
|
1975
|
+
if (providerDraft.credential) await client.setCredential(saved.id, providerDraft.credential);
|
|
1976
|
+
state.providerDetailId = saved.id;
|
|
1977
|
+
if (!state.activeSessionId) { state.currentProfileId = saved.id; state.currentModel = saved.models.default || null; }
|
|
1978
|
+
await refreshProfiles();
|
|
1979
|
+
renderProviderDetail(saved.id);
|
|
1980
|
+
toast('已保存', false, true);
|
|
1981
|
+
} catch (error) { showError(error.message); } finally { save.disabled = false; }
|
|
1982
|
+
});
|
|
1983
|
+
foot.append(save);
|
|
1984
|
+
panel.append(foot, errorBox);
|
|
1985
|
+
detail.append(panel);
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
function buildProfilePayload() {
|
|
1989
|
+
const models = providerDraft.models.filter(entry => entry.id && entry.id.trim());
|
|
1990
|
+
const id = providerDraft.id || slugify(providerDraft.name || `provider-${Date.now().toString(36)}`);
|
|
1991
|
+
// Two explicit slots pass straight through; the stored protocol is the slot
|
|
1992
|
+
// the dropdown last selected — but an empty OpenAI slot must never be stored
|
|
1993
|
+
// as openai-compatible, because the server would then derive the OpenAI
|
|
1994
|
+
// endpoint from the Anthropic URL.
|
|
1995
|
+
const baseUrl = String(providerDraft.baseUrl || '').trim();
|
|
1996
|
+
const openaiBaseUrl = String(providerDraft.openaiBaseUrl || '').trim() || inferredOpenaiBaseUrl(baseUrl);
|
|
1997
|
+
const protocol = openaiBaseUrl && providerDraft.protocol === 'openai-compatible'
|
|
1998
|
+
? 'openai-compatible'
|
|
1999
|
+
: 'anthropic-compatible';
|
|
2000
|
+
return {
|
|
2001
|
+
...(providerDraft.original || {}),
|
|
2002
|
+
id,
|
|
2003
|
+
name: providerDraft.name || id,
|
|
2004
|
+
provider: providerDraft.name || id,
|
|
2005
|
+
protocol,
|
|
2006
|
+
baseUrl,
|
|
2007
|
+
openaiBaseUrl,
|
|
2008
|
+
enabled: providerDraft.enabled !== false,
|
|
2009
|
+
models: {
|
|
2010
|
+
...(providerDraft.original ? providerDraft.original.models : {}),
|
|
2011
|
+
default: models.some(m => m.id === (providerDraft.original && providerDraft.original.models.default)) ? providerDraft.original.models.default : models.length ? models[0].id : '',
|
|
2012
|
+
list: models.map(m => ({ id: m.id.trim(), openaiId: m.openaiId || m.id.trim().replace(/\[1m\]$/i, ''), label: m.label || m.id.trim(), tags: m.tags || [], ...(m.contextWindow ? { contextWindow: m.contextWindow } : {}) })),
|
|
2013
|
+
},
|
|
2014
|
+
};
|
|
2015
|
+
}
|
|
2016
|
+
|
|
2017
|
+
function inferredOpenaiBaseUrl(baseUrl) {
|
|
2018
|
+
try {
|
|
2019
|
+
const url = new URL(baseUrl);
|
|
2020
|
+
return ['api.minimaxi.com', 'api.minimax.io'].includes(url.hostname) ? `${url.origin}/v1` : '';
|
|
2021
|
+
} catch { return ''; }
|
|
2022
|
+
}
|
|
2023
|
+
|
|
2024
|
+
// Union of manually configured and detected model entries: user-typed ids and
|
|
2025
|
+
// openaiId mappings survive, detection only enriches with capabilities.
|
|
2026
|
+
function mergeDetectedModels(existing, detected) {
|
|
2027
|
+
const byId = new Map();
|
|
2028
|
+
for (const entry of existing) {
|
|
2029
|
+
const id = String(entry.id || '').trim();
|
|
2030
|
+
if (id) byId.set(id, { tags: [], ...entry, id });
|
|
2031
|
+
}
|
|
2032
|
+
for (const item of detected) {
|
|
2033
|
+
const id = String(item && item.id || '').trim();
|
|
2034
|
+
if (!id) continue;
|
|
2035
|
+
const current = byId.get(id);
|
|
2036
|
+
if (current) {
|
|
2037
|
+
if (item.contextWindow && !current.contextWindow) current.contextWindow = item.contextWindow;
|
|
2038
|
+
if (Array.isArray(item.tags) && item.tags.length) current.tags = [...new Set([...(current.tags || []), ...item.tags])].slice(0, 8);
|
|
2039
|
+
if (!current.label || current.label === id) current.label = item.label || current.label;
|
|
2040
|
+
} else {
|
|
2041
|
+
byId.set(id, {
|
|
2042
|
+
id,
|
|
2043
|
+
openaiId: item.openaiId || id.replace(/\[1m\]$/i, ''),
|
|
2044
|
+
label: item.label || id,
|
|
2045
|
+
tags: Array.isArray(item.tags) ? item.tags.slice(0, 8) : [],
|
|
2046
|
+
...(item.contextWindow ? { contextWindow: item.contextWindow } : {}),
|
|
2047
|
+
});
|
|
2048
|
+
}
|
|
2049
|
+
}
|
|
2050
|
+
return [...byId.values()];
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
function contextWindowBadge(value) {
|
|
2054
|
+
const n = Number(value);
|
|
2055
|
+
if (!Number.isFinite(n) || n <= 0) return '';
|
|
2056
|
+
if (n >= 1000000) return `${+(n / 1000000).toFixed(n % 1000000 ? 1 : 0)}M ctx`;
|
|
2057
|
+
if (n >= 1000) return `${Math.round(n / 1000)}K ctx`;
|
|
2058
|
+
return `${n} ctx`;
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
// Verified detection probes may fill an endpoint slot the user left empty —
|
|
2062
|
+
// they show up in the form by switching the protocol dropdown. Filled slots
|
|
2063
|
+
// are never overwritten and nothing is ever guessed.
|
|
2064
|
+
function applyDetectedEndpoints(result) {
|
|
2065
|
+
if (!result || !result.sources) return;
|
|
2066
|
+
const { anthropic, openai } = result.sources;
|
|
2067
|
+
if (!providerDraft.baseUrl && anthropic && anthropic.ok && anthropic.baseUrl) {
|
|
2068
|
+
providerDraft.baseUrl = String(anthropic.baseUrl).replace(/\/+$/, '');
|
|
2069
|
+
}
|
|
2070
|
+
if (!providerDraft.openaiBaseUrl && openai && openai.ok && openai.baseUrl) {
|
|
2071
|
+
providerDraft.openaiBaseUrl = String(openai.baseUrl).replace(/\/+$/, '');
|
|
2072
|
+
}
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
// Zero-click detection: as soon as a key (or a saved credential) meets a base
|
|
2076
|
+
// URL, the provider's model list fills itself. Runs only while the list is
|
|
2077
|
+
// empty so curated selections are never churned. Returns the number of added
|
|
2078
|
+
// models, 0 when there was nothing to do, -1 on failure.
|
|
2079
|
+
async function autoDetectDraftModels() {
|
|
2080
|
+
if (providerDraft.models.some(entry => entry.id && entry.id.trim())) return 0;
|
|
2081
|
+
if (!providerDraft.baseUrl && !providerDraft.openaiBaseUrl) return 0;
|
|
2082
|
+
if (!providerDraft.credential && !providerDraft.credentialConfigured) return 0;
|
|
2083
|
+
try {
|
|
2084
|
+
const result = await client.detectProfileModels(buildProfilePayload(), providerDraft.credential || '');
|
|
2085
|
+
providerDraft.models = mergeDetectedModels(providerDraft.models, result.models || []);
|
|
2086
|
+
applyDetectedEndpoints(result);
|
|
2087
|
+
return providerDraft.models.filter(entry => entry.id && entry.id.trim()).length;
|
|
2088
|
+
} catch {
|
|
2089
|
+
return -1;
|
|
2090
|
+
}
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2093
|
+
function field(labelText, type, value, placeholder, onChange) {
|
|
2094
|
+
const wrap = el('div', 'f');
|
|
2095
|
+
const label = el('label', null, labelText);
|
|
2096
|
+
const input = el('input', 'fin');
|
|
2097
|
+
input.type = type;
|
|
2098
|
+
input.value = value || '';
|
|
2099
|
+
input.placeholder = placeholder || '';
|
|
2100
|
+
input.spellcheck = false;
|
|
2101
|
+
input.setAttribute('aria-label', labelText);
|
|
2102
|
+
input.addEventListener('input', () => onChange(input.value));
|
|
2103
|
+
wrap.append(label, input);
|
|
2104
|
+
return wrap;
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2107
|
+
function slugify(value) {
|
|
2108
|
+
return String(value).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48) || `provider-${Date.now().toString(36)}`;
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
/* ===================== project modal ===================== */
|
|
2112
|
+
|
|
2113
|
+
function openProjectModal() { $('btn-add-proj').click(); }
|
|
2114
|
+
|
|
2115
|
+
function wireProjectModal() {
|
|
2116
|
+
const modal = $('modal-add-proj');
|
|
2117
|
+
const path = $('np-path');
|
|
2118
|
+
const name = $('np-name');
|
|
2119
|
+
const confirmBtn = $('np-confirm');
|
|
2120
|
+
const errorBox = $('np-error');
|
|
2121
|
+
const deriveName = () => {
|
|
2122
|
+
const value = path.value.trim();
|
|
2123
|
+
const parts = value.split(/[\\/]+/).filter(Boolean);
|
|
2124
|
+
if (!name.dataset.touched && parts.length) name.value = parts[parts.length - 1];
|
|
2125
|
+
// Only a plausible absolute path (drive letter or UNC/POSIX root) enables
|
|
2126
|
+
// confirm; relative fragments surface the server's clearer error instead
|
|
2127
|
+
// of a dead-looking click.
|
|
2128
|
+
const absolute = /^([a-zA-Z]:[\\/]|\\\\|\/)/.test(value);
|
|
2129
|
+
confirmBtn.disabled = !absolute;
|
|
2130
|
+
};
|
|
2131
|
+
path.addEventListener('input', deriveName);
|
|
2132
|
+
name.addEventListener('input', () => { name.dataset.touched = '1'; });
|
|
2133
|
+
|
|
2134
|
+
// The native folder picker IS the add-project flow: one click opens the
|
|
2135
|
+
// system dialog and a selection creates the project directly. The manual
|
|
2136
|
+
// form only appears as a fallback (picker unsupported / creation failed).
|
|
2137
|
+
const openModalPrefilled = (prefillPath, errorMessage) => {
|
|
2138
|
+
path.value = prefillPath || '';
|
|
2139
|
+
name.dataset.touched = '';
|
|
2140
|
+
deriveName();
|
|
2141
|
+
errorBox.textContent = errorMessage || '';
|
|
2142
|
+
errorBox.hidden = !errorMessage;
|
|
2143
|
+
modal.classList.add('open');
|
|
2144
|
+
path.focus();
|
|
2145
|
+
};
|
|
2146
|
+
|
|
2147
|
+
let pickingProject = false;
|
|
2148
|
+
const pickerUrl = () => '/api/claude-workbench/v1/system/pick-folder';
|
|
2149
|
+
const addProjectViaPicker = async () => {
|
|
2150
|
+
if (pickingProject) return;
|
|
2151
|
+
pickingProject = true;
|
|
2152
|
+
try {
|
|
2153
|
+
let response;
|
|
2154
|
+
try {
|
|
2155
|
+
response = await fetch(pickerUrl());
|
|
2156
|
+
} catch { response = null; }
|
|
2157
|
+
if (!response || !response.ok) {
|
|
2158
|
+
// Picker unavailable (or transport error): fall back to manual entry.
|
|
2159
|
+
openModalPrefilled('', response && response.status !== 501 ? '无法打开文件夹选择器,请手动输入路径' : '');
|
|
2160
|
+
return;
|
|
2161
|
+
}
|
|
2162
|
+
const data = await response.json().catch(() => null);
|
|
2163
|
+
const picked = data && data.path;
|
|
2164
|
+
if (!picked) return; // user cancelled the native dialog — silent no-op
|
|
2165
|
+
const fallbackName = picked.split(/[\\/]+/).filter(Boolean).pop() || '新项目';
|
|
2166
|
+
try {
|
|
2167
|
+
const project = await client.createProject({ name: fallbackName, rootPath: picked });
|
|
2168
|
+
state.expanded.add(project.id);
|
|
2169
|
+
state.activeProjectId = project.id;
|
|
2170
|
+
await refreshProjects(true);
|
|
2171
|
+
toast(`项目“${project.name}”已添加`, false, true);
|
|
2172
|
+
} catch (error) {
|
|
2173
|
+
openModalPrefilled(picked, error.message);
|
|
2174
|
+
}
|
|
2175
|
+
} finally {
|
|
2176
|
+
pickingProject = false;
|
|
2177
|
+
}
|
|
2178
|
+
};
|
|
2179
|
+
|
|
2180
|
+
$('btn-add-proj').addEventListener('click', addProjectViaPicker);
|
|
2181
|
+
$('hint-add-proj').addEventListener('click', () => $('btn-add-proj').click());
|
|
2182
|
+
const browseBtn = $('np-browse');
|
|
2183
|
+
if (browseBtn) browseBtn.addEventListener('click', async () => {
|
|
2184
|
+
browseBtn.disabled = true;
|
|
2185
|
+
try {
|
|
2186
|
+
// Direct fetch: the client SDK does not expose the picker endpoint.
|
|
2187
|
+
const response = await fetch(pickerUrl());
|
|
2188
|
+
if (!response.ok) {
|
|
2189
|
+
toast(response.status === 501 ? '当前系统不支持文件夹选择器,请手动输入路径' : '打开文件夹选择器失败', true);
|
|
2190
|
+
return;
|
|
2191
|
+
}
|
|
2192
|
+
const data = await response.json().catch(() => null);
|
|
2193
|
+
if (!data || !data.path) { toast('已取消选择'); return; }
|
|
2194
|
+
path.value = data.path;
|
|
2195
|
+
deriveName(); // fills the project name and enables confirm
|
|
2196
|
+
path.focus();
|
|
2197
|
+
} catch { toast('打开文件夹选择器失败', true); }
|
|
2198
|
+
finally { browseBtn.disabled = false; }
|
|
2199
|
+
});
|
|
2200
|
+
$('np-cancel').addEventListener('click', () => modal.classList.remove('open'));
|
|
2201
|
+
modal.addEventListener('click', ev => { if (ev.target === modal) modal.classList.remove('open'); });
|
|
2202
|
+
confirmBtn.addEventListener('click', async () => {
|
|
2203
|
+
errorBox.hidden = true;
|
|
2204
|
+
try {
|
|
2205
|
+
const project = await client.createProject({ name: name.value.trim() || undefined, rootPath: path.value.trim() });
|
|
2206
|
+
state.expanded.add(project.id);
|
|
2207
|
+
state.activeProjectId = project.id;
|
|
2208
|
+
modal.classList.remove('open');
|
|
2209
|
+
await refreshProjects(true);
|
|
2210
|
+
toast(`项目“${project.name}”已添加`, false, true);
|
|
2211
|
+
} catch (error) {
|
|
2212
|
+
errorBox.textContent = error.message;
|
|
2213
|
+
errorBox.hidden = false;
|
|
2214
|
+
}
|
|
2215
|
+
});
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
/* ===================== chrome wiring ===================== */
|
|
2219
|
+
|
|
2220
|
+
function wireChrome() {
|
|
2221
|
+
$('btn-new-session').addEventListener('click', newSession);
|
|
2222
|
+
$('stream-col').addEventListener('click', event => {
|
|
2223
|
+
const button = event.target.closest && event.target.closest('.cb-copy');
|
|
2224
|
+
if (button) {
|
|
2225
|
+
const code = button.closest('.codeblock') && button.closest('.codeblock').querySelector('code');
|
|
2226
|
+
if (code && navigator.clipboard) {
|
|
2227
|
+
navigator.clipboard.writeText(code.textContent).then(() => {
|
|
2228
|
+
button.textContent = '已复制';
|
|
2229
|
+
setTimeout(() => { button.textContent = '复制'; }, 1500);
|
|
2230
|
+
}).catch(() => {});
|
|
2231
|
+
}
|
|
2232
|
+
return;
|
|
2233
|
+
}
|
|
2234
|
+
// Tool steps stay closed while running (OpenCode pending-no-expand).
|
|
2235
|
+
const trigger = event.target.closest && event.target.closest('.step > summary');
|
|
2236
|
+
if (trigger) {
|
|
2237
|
+
const step = trigger.closest('.step');
|
|
2238
|
+
if (step && !step.classList.contains('done') && !step.open) event.preventDefault();
|
|
2239
|
+
}
|
|
2240
|
+
});
|
|
2241
|
+
const stream = $('stream');
|
|
2242
|
+
const jump = $('jump-latest');
|
|
2243
|
+
if (stream && stream.addEventListener) {
|
|
2244
|
+
stream.addEventListener('scroll', () => {
|
|
2245
|
+
const distance = stream.scrollHeight - stream.scrollTop - stream.clientHeight;
|
|
2246
|
+
if (distance > 120) state.follow = false;
|
|
2247
|
+
else if (distance < 40) state.follow = true;
|
|
2248
|
+
if (jump) jump.hidden = state.follow;
|
|
2249
|
+
});
|
|
2250
|
+
}
|
|
2251
|
+
if (jump) {
|
|
2252
|
+
jump.addEventListener('click', () => {
|
|
2253
|
+
state.follow = true;
|
|
2254
|
+
jump.hidden = true;
|
|
2255
|
+
if (stream) stream.scrollTop = stream.scrollHeight;
|
|
2256
|
+
});
|
|
2257
|
+
}
|
|
2258
|
+
// Archive section fold: visual-only toggle over the static sidebar markup.
|
|
2259
|
+
const archToggle = $('arch-toggle');
|
|
2260
|
+
if (archToggle) archToggle.addEventListener('click', () => {
|
|
2261
|
+
const archList = $('arch-list');
|
|
2262
|
+
const archCaret = $('arch-caret');
|
|
2263
|
+
const collapsed = !archList.hidden;
|
|
2264
|
+
archList.hidden = collapsed;
|
|
2265
|
+
if (archCaret) archCaret.classList.toggle('closed', collapsed);
|
|
2266
|
+
archToggle.setAttribute('aria-expanded', String(!collapsed));
|
|
2267
|
+
});
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2270
|
+
/* ===================== embed bridge(被外部壳嵌入时受控) =====================
|
|
2271
|
+
* 壳 → 终端:{type:'kb:sidebar', visible} 折叠/展开左侧项目栏;
|
|
2272
|
+
* {type:'kb:select-project', project} 按 id/name/rootPath 选中项目;
|
|
2273
|
+
* {type:'kb:list-sessions', project} 列出该项目非归档会话(≤50);
|
|
2274
|
+
* {type:'kb:open-session', sessionId} 打开指定会话;
|
|
2275
|
+
* {type:'kb:archive-session', sessionId} 归档会话;
|
|
2276
|
+
* {type:'kb:remove-project', project, keepSessions} 移除项目登记(默认保留会话记录)。
|
|
2277
|
+
* 终端 → 壳:kb:ready(就绪)/ kb:projects(选择结果与项目清单)/
|
|
2278
|
+
* kb:sessions / kb:session / kb:archived / kb:removed-project(各操作回执)。
|
|
2279
|
+
* URL 参数:?embed=1 进入嵌入态(默认折叠项目栏,sidebar=1 保持展开),project= 预选。 */
|
|
2280
|
+
function embedAllowedOrigin(origin) {
|
|
2281
|
+
if (origin === 'null') return true; // file:// 壳
|
|
2282
|
+
try {
|
|
2283
|
+
const u = new URL(origin);
|
|
2284
|
+
return u.hostname === 'localhost' || u.hostname === '127.0.0.1' || u.hostname === '[::1]';
|
|
2285
|
+
} catch { return false; }
|
|
2286
|
+
}
|
|
2287
|
+
function setEmbedSidebarVisible(visible) {
|
|
2288
|
+
document.body.classList.toggle('embed-collapse', !visible);
|
|
2289
|
+
}
|
|
2290
|
+
function embedFindProject(ref) {
|
|
2291
|
+
const refStr = String(ref || '');
|
|
2292
|
+
if (!refStr) return null;
|
|
2293
|
+
return state.projects.find(p => p.id === refStr || p.name === refStr || (p.rootPath || '') === refStr) || null;
|
|
2294
|
+
}
|
|
2295
|
+
function embedSelectProject(ref) {
|
|
2296
|
+
const project = embedFindProject(ref);
|
|
2297
|
+
if (!project) return false;
|
|
2298
|
+
if (state.activeProjectId !== project.id) {
|
|
2299
|
+
detachSession();
|
|
2300
|
+
resetStream();
|
|
2301
|
+
setBusyState('idle');
|
|
2302
|
+
state.activeProjectId = project.id;
|
|
2303
|
+
state.currentProfileId = undefined;
|
|
2304
|
+
state.currentModel = null;
|
|
2305
|
+
state.currentAgent = project.defaultAgentId || 'claude-code';
|
|
2306
|
+
$('session-title').textContent = '新会话';
|
|
2307
|
+
applyCapabilityGates();
|
|
2308
|
+
renderModelLabel();
|
|
2309
|
+
}
|
|
2310
|
+
state.expanded.add(project.id);
|
|
2311
|
+
renderProjectList();
|
|
2312
|
+
return true;
|
|
2313
|
+
}
|
|
2314
|
+
window.addEventListener('message', async ev => {
|
|
2315
|
+
if (!embedAllowedOrigin(ev.origin)) return;
|
|
2316
|
+
const data = ev.data;
|
|
2317
|
+
if (!data || typeof data !== 'object') return;
|
|
2318
|
+
const reply = payload => { if (ev.source) ev.source.postMessage(Object.assign({ app: 'agent-terminal' }, payload), '*'); };
|
|
2319
|
+
if (data.type === 'kb:sidebar') setEmbedSidebarVisible(!!data.visible);
|
|
2320
|
+
else if (data.type === 'kb:select-project') {
|
|
2321
|
+
reply({ type: 'kb:projects', ok: embedSelectProject(data.project), activeProjectId: state.activeProjectId,
|
|
2322
|
+
projects: state.projects.map(p => ({ id: p.id, name: p.name, rootPath: p.rootPath })) });
|
|
2323
|
+
} else if (data.type === 'kb:list-sessions') {
|
|
2324
|
+
const project = embedFindProject(data.project);
|
|
2325
|
+
if (!project) { reply({ type: 'kb:sessions', ok: false, project: data.project, sessions: [] }); return; }
|
|
2326
|
+
const includeArchived = data.includeArchived === true;
|
|
2327
|
+
try {
|
|
2328
|
+
// client 的 archived=1 语义是"仅归档"——含归档时合并两段查询
|
|
2329
|
+
const sessions = includeArchived
|
|
2330
|
+
? [...(await client.listSessions(project.id)), ...(await client.listSessions(project.id, { archived: true }))]
|
|
2331
|
+
: await client.listSessions(project.id);
|
|
2332
|
+
const rows = (Array.isArray(sessions) ? sessions : [])
|
|
2333
|
+
.filter(s => includeArchived ? true : !s.archived)
|
|
2334
|
+
.sort((a, b) => String(b.updatedAt || '').localeCompare(String(a.updatedAt || '')))
|
|
2335
|
+
.slice(0, 50)
|
|
2336
|
+
.map(s => ({ sessionId: s.sessionId, title: sessionTitle(s), agentId: s.agentId, updatedAt: s.updatedAt, archived: !!s.archived }));
|
|
2337
|
+
// project 字段回显请求方传入的引用(id/name/rootPath)——壳按它发来的键匹配回执
|
|
2338
|
+
reply({ type: 'kb:sessions', ok: true, project: String(data.project || project.id), projectId: project.id, includeArchived, sessions: rows });
|
|
2339
|
+
} catch (error) { reply({ type: 'kb:sessions', ok: false, error: error.message, sessions: [] }); }
|
|
2340
|
+
} else if (data.type === 'kb:open-session') {
|
|
2341
|
+
const sessionId = String(data.sessionId || '');
|
|
2342
|
+
try { await openSession(sessionId); } catch { /* openSession 内部已 toast */ }
|
|
2343
|
+
reply({ type: 'kb:session', ok: state.activeSessionId === sessionId, sessionId });
|
|
2344
|
+
} else if (data.type === 'kb:archive-session') {
|
|
2345
|
+
const sessionId = String(data.sessionId || '');
|
|
2346
|
+
try {
|
|
2347
|
+
await client.archiveSession(sessionId);
|
|
2348
|
+
if (state.activeSessionId === sessionId) detachSession();
|
|
2349
|
+
await renderProjectList();
|
|
2350
|
+
reply({ type: 'kb:archived', ok: true, sessionId });
|
|
2351
|
+
} catch (error) { reply({ type: 'kb:archived', ok: false, error: error.message }); }
|
|
2352
|
+
} else if (data.type === 'kb:restore-session') {
|
|
2353
|
+
const sessionId = String(data.sessionId || '');
|
|
2354
|
+
try {
|
|
2355
|
+
await client.restoreSession(sessionId);
|
|
2356
|
+
await renderProjectList();
|
|
2357
|
+
reply({ type: 'kb:restored', ok: true, sessionId });
|
|
2358
|
+
} catch (error) { reply({ type: 'kb:restored', ok: false, error: error.message }); }
|
|
2359
|
+
} else if (data.type === 'kb:remove-project') {
|
|
2360
|
+
const project = embedFindProject(data.project);
|
|
2361
|
+
if (!project) { reply({ type: 'kb:removed-project', ok: false, error: 'project not found' }); return; }
|
|
2362
|
+
try {
|
|
2363
|
+
await client.removeProject(project.id, { keepSessions: data.keepSessions !== false });
|
|
2364
|
+
if (state.activeProjectId === project.id) detachSession();
|
|
2365
|
+
await refreshProjects();
|
|
2366
|
+
reply({ type: 'kb:removed-project', ok: true, projectId: project.id, keepSessions: data.keepSessions !== false });
|
|
2367
|
+
} catch (error) { reply({ type: 'kb:removed-project', ok: false, error: error.message }); }
|
|
2368
|
+
}
|
|
2369
|
+
});
|
|
2370
|
+
function embedInit() {
|
|
2371
|
+
const q = new URLSearchParams(location.search);
|
|
2372
|
+
if (q.get('embed') === '1') {
|
|
2373
|
+
setEmbedSidebarVisible(q.get('sidebar') !== '1');
|
|
2374
|
+
const preset = q.get('project');
|
|
2375
|
+
if (preset) {
|
|
2376
|
+
const trySelect = () => { if (!embedSelectProject(preset)) setTimeout(trySelect, 500); };
|
|
2377
|
+
setTimeout(trySelect, 0);
|
|
2378
|
+
}
|
|
2379
|
+
}
|
|
2380
|
+
if (window.parent !== window) window.parent.postMessage({ type: 'kb:ready', app: 'agent-terminal' }, '*');
|
|
2381
|
+
}
|
|
2382
|
+
embedInit();
|
|
2383
|
+
// 主区顶栏常驻设置入口(嵌入折叠侧栏后仍可达;与模型弹层内「管理模型」同目标)
|
|
2384
|
+
const bindTopSettings = () => {
|
|
2385
|
+
const btn = $('btn-top-settings');
|
|
2386
|
+
if (btn) btn.addEventListener('click', () => openSettings('providers'));
|
|
2387
|
+
};
|
|
2388
|
+
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', bindTopSettings);
|
|
2389
|
+
else bindTopSettings();
|
|
2390
|
+
|
|
2391
|
+
if (client) init();
|
|
2392
|
+
else document.addEventListener('DOMContentLoaded', () => toast('client SDK 加载失败', true));
|