@sanqianx/claude-ai-workbench 0.13.2-rc.21 → 0.13.2-rc.22
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/apps/agent-terminal/app.js +289 -23
- package/apps/agent-terminal/index.html +6 -0
- package/apps/agent-terminal/panel-host-proof.html +152 -0
- package/apps/agent-terminal/panel-proof-content.html +41 -0
- package/apps/agent-terminal/right-dock-panel-registry.js +216 -0
- package/apps/agent-terminal/right-workspace-dock.css +159 -0
- package/apps/agent-terminal/right-workspace-dock.js +1929 -0
- package/docs/sdk/SESSION_EVENTS.md +2 -0
- package/docs/sdk/openapi.yaml +343 -1
- package/package.json +2 -2
- package/packages/client/README.md +18 -0
- package/packages/client/index.d.ts +33 -0
- package/packages/client/index.js +36 -1
- package/packages/client/package.json +2 -2
- package/packages/contracts/index.d.ts +219 -0
- package/packages/contracts/index.js +12 -0
- package/packages/contracts/package.json +1 -1
- package/packages/core/package.json +2 -2
- package/packages/server/bin/claude-workbench-server.js +5 -2
- package/packages/server/index.d.ts +23 -1
- package/packages/server/index.js +30 -7
- package/packages/server/lib/agent-registry.js +42 -0
- package/packages/server/lib/context-resolver.js +15 -0
- package/packages/server/lib/delegation-phase-store.js +357 -0
- package/packages/server/lib/delegation-store.js +468 -0
- package/packages/server/lib/event-stream.js +10 -1
- package/packages/server/lib/http-handler.js +36 -1
- package/packages/server/lib/runtime-loader.js +120 -0
- package/packages/server/lib/session-history.js +446 -18
- package/packages/server/lib/workbench-service.js +856 -8
- package/packages/server/package.json +2 -2
- package/packages/ui/package.json +2 -2
- package/src/backend/lib/claude-cli-runner.js +472 -16
- package/src/backend/lib/native-task-control.js +217 -0
- package/src/backend/lib/native-task-history-index.js +475 -0
- package/src/backend/lib/native-task-history-reader.js +526 -0
- package/src/backend/lib/native-task-messages.js +457 -0
- package/src/backend/lib/native-task-registry.js +325 -0
- package/src/backend/lib/native-task-runtime.js +242 -0
- package/src/backend/lib/native-task-trace.js +440 -0
- package/src/backend/lib/native-task-transcript.js +393 -0
- package/src/backend/lib/sdk-session-stream.js +33 -13
- package/src/backend/lib/store-scope.js +131 -0
- package/src/backend/lib/task-identity.js +104 -0
- package/src/backend/lib/task-state-projection.js +58 -0
|
@@ -103,6 +103,23 @@ function embeddedTransport() {
|
|
|
103
103
|
const client = window.createClaudeWorkbenchClient
|
|
104
104
|
? window.createClaudeWorkbenchClient({ apiBase, ...embeddedTransport() })
|
|
105
105
|
: null;
|
|
106
|
+
// The dock is a feature-detected add-on: sandboxes and older hosts without
|
|
107
|
+
// the module keep working with dock === null. W3 adds the panel registry the
|
|
108
|
+
// same way - absent module means native-only behavior, never a broken page.
|
|
109
|
+
const panelRegistry = window.createRightDockPanelRegistry
|
|
110
|
+
? window.createRightDockPanelRegistry({ document, baseUrl: typeof location !== 'undefined' && location.href ? location.href : null })
|
|
111
|
+
: null;
|
|
112
|
+
const hostPanelOwner = panelRegistry ? panelRegistry.createOwner({ role: 'embed-host' }) : null;
|
|
113
|
+
const dock = window.createRightWorkspaceDock && client
|
|
114
|
+
? window.createRightWorkspaceDock({
|
|
115
|
+
client, apiBase, document,
|
|
116
|
+
storage: typeof localStorage !== 'undefined' ? localStorage : null,
|
|
117
|
+
renderMarkdown, toast,
|
|
118
|
+
registry: panelRegistry,
|
|
119
|
+
onPanelsChanged: reason => postPanelState(reason),
|
|
120
|
+
onBackgroundActivityChanged: activity => renderSessionStatus(activity),
|
|
121
|
+
})
|
|
122
|
+
: null;
|
|
106
123
|
|
|
107
124
|
const PERMISSION_LABELS = { default: '默认防护', acceptEdits: '接受编辑', plan: '计划模式', bypassPermissions: '完全访问' };
|
|
108
125
|
const EFFORT_LABELS = { low: '低', medium: '中', high: '高', max: '最高' };
|
|
@@ -203,6 +220,15 @@ function savePreferences(partial) {
|
|
|
203
220
|
}, 300);
|
|
204
221
|
}
|
|
205
222
|
|
|
223
|
+
// Health can resolve after a session is already attached; the dock then holds
|
|
224
|
+
// a null storeScope and every read answers TASK_IDENTITY_UNAVAILABLE. Re-bind
|
|
225
|
+
// the live session's scope to the now-known identity.
|
|
226
|
+
function rebindDockIdentity() {
|
|
227
|
+
if (!dock || !state.activeSessionId) return;
|
|
228
|
+
const identity = state.taskIdentity && state.taskIdentity.storeScope ? state.taskIdentity.storeScope : null;
|
|
229
|
+
dock.refreshIdentity(identity);
|
|
230
|
+
}
|
|
231
|
+
|
|
206
232
|
/* ===================== init ===================== */
|
|
207
233
|
|
|
208
234
|
async function init() {
|
|
@@ -216,19 +242,24 @@ async function init() {
|
|
|
216
242
|
const healthJob = client.health().then(health => {
|
|
217
243
|
$('app-version').textContent = `v${health.version}`;
|
|
218
244
|
$('about-text').textContent = `Agent Terminal v${health.version} · API ${health.apiVersion} · ${health.sessions} 个会话`;
|
|
245
|
+
state.taskIdentity = health.taskIdentity || null;
|
|
246
|
+
rebindDockIdentity();
|
|
247
|
+
if (dock && state.activeSessionId) dock.handleResync();
|
|
219
248
|
}).catch(() => {});
|
|
220
249
|
const preferences = loadPreferences();
|
|
221
250
|
const projects = preferences.then(() => refreshProjects()).catch(error => toast(`项目读取失败:${error.message}`, true));
|
|
222
251
|
await Promise.allSettled([healthJob, refreshAgents(), refreshProfiles(), projects]);
|
|
223
252
|
window.addEventListener('beforeunload', () => {
|
|
224
253
|
saveCurrentDraft();
|
|
254
|
+
if (dock) dock.flushPersist();
|
|
225
255
|
for (const stop of summaryStreams.values()) stop();
|
|
226
256
|
if (state.unsub) state.unsub();
|
|
227
257
|
});
|
|
228
258
|
if (!state.activeSessionId) $('session-title').textContent = '选择或创建一个会话';
|
|
229
259
|
// Ready is the usable-page boundary: the host may immediately select a
|
|
230
260
|
// project or open settings, so data and handlers must both be available.
|
|
231
|
-
|
|
261
|
+
// Panel registrations before this point are late arrivals and stay inert.
|
|
262
|
+
if (window.parent !== window) { embedPanelsReady = true; window.parent.postMessage({ type: 'kb:ready', app: 'agent-terminal' }, '*'); }
|
|
232
263
|
}
|
|
233
264
|
|
|
234
265
|
/* ===================== agents & capabilities ===================== */
|
|
@@ -754,6 +785,7 @@ function receiveSummary(id, event) {
|
|
|
754
785
|
sessionListDirty.delete(id);
|
|
755
786
|
}
|
|
756
787
|
else if (event.type === 'workbench/session-changed') {
|
|
788
|
+
if (dock && typeof dock.notifySessionChanged === 'function') dock.notifySessionChanged(event.session);
|
|
757
789
|
sessionListJobs.get(id)?.changes.set(event.session.sessionId, event);
|
|
758
790
|
if (!sessionLists.has(id)) sessionListDirty.add(id);
|
|
759
791
|
rows = [...(sessionLists.get(id) || [])].filter(s => s.sessionId !== event.session.sessionId);
|
|
@@ -911,6 +943,11 @@ async function openSession(sessionId, options = {}) {
|
|
|
911
943
|
$('chat-input').disabled = false;
|
|
912
944
|
state.sessionSummary = summary;
|
|
913
945
|
restoreBackgroundTasks(summary.tasks || snapshot.tasks || []);
|
|
946
|
+
if (dock) dock.attachSession({
|
|
947
|
+
storeScope: state.taskIdentity?.storeScope || null,
|
|
948
|
+
contextId: summary.contextId || state.activeProjectId || null,
|
|
949
|
+
parentSessionId: sessionId,
|
|
950
|
+
});
|
|
914
951
|
if (summary.contextId && summary.contextId !== state.activeProjectId) {
|
|
915
952
|
state.activeProjectId = summary.contextId;
|
|
916
953
|
restoreDraft(state.activeProjectId, sessionId);
|
|
@@ -945,6 +982,7 @@ async function openSession(sessionId, options = {}) {
|
|
|
945
982
|
state.unsub = client.subscribe(sessionId, event => {
|
|
946
983
|
if (epoch !== sessionEpoch || state.activeSessionId !== sessionId) return;
|
|
947
984
|
if (event.type === 'workbench/resync') {
|
|
985
|
+
if (dock) dock.handleResync(event);
|
|
948
986
|
if (historyView?.supported && !historyView.error) {
|
|
949
987
|
last = Number(event.highWater) || 0;
|
|
950
988
|
// The replay cache is not conversation truth. Re-read durable history
|
|
@@ -967,6 +1005,7 @@ async function openSession(sessionId, options = {}) {
|
|
|
967
1005
|
if (state.replaying && !(Number.isFinite(sequence) && sequence > 0 && sequence <= replayThrough)) state.replaying = false;
|
|
968
1006
|
if (historyView?.sessionId === sessionId) historyView.events.push(event);
|
|
969
1007
|
state.liveRevision = (state.liveRevision || 0) + 1;
|
|
1008
|
+
if (dock) dock.handleSessionEvent(event);
|
|
970
1009
|
renderEvent(event);
|
|
971
1010
|
}, () => {}, { afterSequence: last, onOpen: () => {
|
|
972
1011
|
if (epoch !== sessionEpoch || state.activeSessionId !== sessionId) return;
|
|
@@ -1051,7 +1090,6 @@ async function reloadHistory(view) {
|
|
|
1051
1090
|
const oldest = view.events.find(event => event.type === 'claude/user-prompt') || view.events[0];
|
|
1052
1091
|
const oldestKey = oldest && historyEventKey(oldest);
|
|
1053
1092
|
const buffered = view.events;
|
|
1054
|
-
const liveStart = buffered.length;
|
|
1055
1093
|
try {
|
|
1056
1094
|
let page = await client.loadHistory(view.sessionId);
|
|
1057
1095
|
let events = page.events, nextCursor = page.nextCursor;
|
|
@@ -1066,7 +1104,12 @@ async function reloadHistory(view) {
|
|
|
1066
1104
|
if (historyView !== view || revision !== view.revision) return;
|
|
1067
1105
|
}
|
|
1068
1106
|
if (historyView !== view || view.epoch !== sessionEpoch || revision !== view.revision) return;
|
|
1069
|
-
|
|
1107
|
+
// Union by row key: live rows received before the first page returned
|
|
1108
|
+
// (including before this reload even started) survive unless the fetched
|
|
1109
|
+
// history already contains them. The fetched page must never overwrite
|
|
1110
|
+
// the live tail into silence.
|
|
1111
|
+
const fetchedKeys = new Set(events.map(historyEventKey));
|
|
1112
|
+
const queued = buffered.filter(event => !fetchedKeys.has(historyEventKey(event)));
|
|
1070
1113
|
view.nextCursor = nextCursor; view.incomplete = page.incomplete; view.error = false;
|
|
1071
1114
|
rebuildHistory(view, [...events, ...queued], false);
|
|
1072
1115
|
} catch { if (historyView === view) view.error = true; }
|
|
@@ -1085,6 +1128,7 @@ function detachSession(options = {}) {
|
|
|
1085
1128
|
state.sessionSummary = null;
|
|
1086
1129
|
state.loadingSession = false;
|
|
1087
1130
|
$('chat-input').disabled = false;
|
|
1131
|
+
if (dock) dock.detachSession();
|
|
1088
1132
|
restoreBackgroundTasks([]);
|
|
1089
1133
|
setBusyState('idle');
|
|
1090
1134
|
historyView = null;
|
|
@@ -1346,9 +1390,24 @@ function createPacer(render) {
|
|
|
1346
1390
|
}
|
|
1347
1391
|
|
|
1348
1392
|
const backgroundTasks = new Map();
|
|
1393
|
+
function trustedTaskRef(ref) {
|
|
1394
|
+
if (!ref || typeof ref !== 'object' || ref.kind !== 'native-task') return null;
|
|
1395
|
+
for (const field of ['storeScope', 'contextId', 'parentSessionId', 'ownerRunId', 'taskId']) {
|
|
1396
|
+
if (typeof ref[field] !== 'string' || ref[field].trim() === '') return null;
|
|
1397
|
+
}
|
|
1398
|
+
return { kind: 'native-task', storeScope: ref.storeScope, contextId: ref.contextId, parentSessionId: ref.parentSessionId, ownerRunId: ref.ownerRunId, taskId: ref.taskId };
|
|
1399
|
+
}
|
|
1400
|
+
function taskRowKey(ref) { return JSON.stringify([ref.storeScope, ref.contextId, ref.parentSessionId, ref.ownerRunId, ref.taskId]); }
|
|
1349
1401
|
function restoreBackgroundTasks(tasks) {
|
|
1350
1402
|
backgroundTasks.clear();
|
|
1351
|
-
|
|
1403
|
+
// Only a trusted native-task ref anchors a row; bare id rows remain
|
|
1404
|
+
// status echoes that can never grow into an interactive identity.
|
|
1405
|
+
for (const task of Array.isArray(tasks) ? tasks : []) {
|
|
1406
|
+
if (!task || typeof task !== 'object') continue;
|
|
1407
|
+
const ref = trustedTaskRef(task.taskRef);
|
|
1408
|
+
if (!ref && !task.taskId) continue;
|
|
1409
|
+
backgroundTasks.set(ref ? taskRowKey(ref) : 'bare|' + task.taskId, { ...task, taskRef: ref });
|
|
1410
|
+
}
|
|
1352
1411
|
renderBackgroundTasks();
|
|
1353
1412
|
}
|
|
1354
1413
|
function renderBackgroundTasks() {
|
|
@@ -1363,11 +1422,8 @@ function renderBackgroundTasks() {
|
|
|
1363
1422
|
if (unsupported) { panel.append(el('p', 'task-detail', '当前 Agent 未提供后台任务状态;文字声明不能证明任务已启动。')); return; }
|
|
1364
1423
|
const running = [...backgroundTasks.values()].filter(t => ['running', 'pending'].includes(t.status)).length;
|
|
1365
1424
|
panel.append(el('strong', null, `后台任务 · ${running} 个运行中`));
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
stop.addEventListener('click', stopCurrent);
|
|
1369
|
-
panel.append(stop);
|
|
1370
|
-
}
|
|
1425
|
+
// 单个后台任务的精确停止 API 未交付:本切片没有任何停止入口,也不以
|
|
1426
|
+
// 主会话 abort 冒充(stopCurrent 仅属于前台发送按钮)。
|
|
1371
1427
|
for (const task of backgroundTasks.values()) {
|
|
1372
1428
|
const card = el('article', 'task-card');
|
|
1373
1429
|
card.dataset.taskId = task.taskId;
|
|
@@ -1376,15 +1432,44 @@ function renderBackgroundTasks() {
|
|
|
1376
1432
|
const detail = [task.lastToolName && `最近工具:${task.lastToolName}`, task.summary,
|
|
1377
1433
|
task.usage && `工具调用 ${task.usage.tool_uses ?? '未知'} · 用时 ${task.usage.duration_ms ?? '未知'}ms`].filter(Boolean).join('\n');
|
|
1378
1434
|
if (detail) card.append(el('div', 'task-detail', detail));
|
|
1435
|
+
if (task.taskRef && dock) {
|
|
1436
|
+
const open = el('button', 'linklike', '在右侧任务面板查看');
|
|
1437
|
+
open.addEventListener('click', () => { dock.openTask(task.taskRef); });
|
|
1438
|
+
card.append(open);
|
|
1439
|
+
}
|
|
1379
1440
|
panel.append(card);
|
|
1380
1441
|
}
|
|
1381
1442
|
}
|
|
1382
1443
|
function renderEvent(event) {
|
|
1444
|
+
if (event.type === 'claude/native-task-state' || event.type === 'claude/native-task-transcript') {
|
|
1445
|
+
// Native facts render in the dock; the strip only mirrors a status line
|
|
1446
|
+
// when the event carries a trusted taskRef, and terminal states stick.
|
|
1447
|
+
const ref = trustedTaskRef(event.taskRef);
|
|
1448
|
+
if (ref) {
|
|
1449
|
+
const key = taskRowKey(ref);
|
|
1450
|
+
const old = backgroundTasks.get(key) || { taskRef: ref, taskId: ref.taskId };
|
|
1451
|
+
if (!(['completed', 'failed', 'stopped'].includes(old.status) && ['running', 'pending'].includes(event.status))) {
|
|
1452
|
+
backgroundTasks.set(key, { ...old, taskRef: ref, taskId: ref.taskId,
|
|
1453
|
+
taskKind: event.taskKind || old.taskKind, taskType: event.taskType || old.taskType,
|
|
1454
|
+
status: event.status || old.status, summary: event.summary || old.summary });
|
|
1455
|
+
}
|
|
1456
|
+
renderBackgroundTasks();
|
|
1457
|
+
}
|
|
1458
|
+
return;
|
|
1459
|
+
}
|
|
1383
1460
|
if (/^claude\/task-(started|progress|updated|notification)$/.test(event.type)) {
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1461
|
+
// Bare echoes cannot create identity: they merge only into rows that
|
|
1462
|
+
// already exist with the same taskId, and terminal states stick.
|
|
1463
|
+
if (event.taskId) {
|
|
1464
|
+
let touched = false;
|
|
1465
|
+
for (const [key, old] of backgroundTasks) {
|
|
1466
|
+
if (old.taskId !== event.taskId) continue;
|
|
1467
|
+
if (['completed', 'failed', 'stopped'].includes(old.status) && ['running', 'pending'].includes(event.status)) continue;
|
|
1468
|
+
backgroundTasks.set(key, { ...old, ...event, taskRef: old.taskRef });
|
|
1469
|
+
touched = true;
|
|
1470
|
+
}
|
|
1471
|
+
if (touched) renderBackgroundTasks();
|
|
1472
|
+
}
|
|
1388
1473
|
return;
|
|
1389
1474
|
}
|
|
1390
1475
|
// Subagent text/tool streams belong to task cards, never the foreground turn.
|
|
@@ -2052,21 +2137,40 @@ function fmtTokens(value) {
|
|
|
2052
2137
|
function setBusyState(sessionState) {
|
|
2053
2138
|
state.sessionState = sessionState;
|
|
2054
2139
|
state.busy = ['running', 'thinking', 'tool-running', 'spawning', 'pending-permission'].includes(sessionState);
|
|
2055
|
-
const status = $('session-status');
|
|
2056
2140
|
const send = $('btn-send');
|
|
2141
|
+
send.innerHTML = '';
|
|
2142
|
+
if (state.busy) send.append(el('span', 'sq'));
|
|
2143
|
+
else { const up = el('span', 'up'); up.append(icon('send', 16)); send.append(up); }
|
|
2144
|
+
send.title = state.busy ? '停止' : '发送';
|
|
2145
|
+
renderSessionStatus();
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
// The status chip follows the main session's own state machine; background
|
|
2149
|
+
// work (native or delegated) only ever appends a suffix and never revives a
|
|
2150
|
+
// terminal main state. Composer availability stays bound to the main session
|
|
2151
|
+
// alone - a busy delegated child never locks the main input.
|
|
2152
|
+
function renderSessionStatus(activity) {
|
|
2153
|
+
const status = $('session-status');
|
|
2154
|
+
const text = $('session-status-text');
|
|
2155
|
+
if (!status || !text) return;
|
|
2156
|
+
const snapshot = activity && typeof activity === 'object' ? activity
|
|
2157
|
+
: (dock && typeof dock.backgroundActivity === 'function' ? dock.backgroundActivity() : null);
|
|
2158
|
+
const background = snapshot && Number.isFinite(Number(snapshot.total)) ? Number(snapshot.total) : 0;
|
|
2159
|
+
const sessionState = state.sessionState;
|
|
2057
2160
|
if (sessionState && sessionState !== 'idle') {
|
|
2058
2161
|
status.hidden = false;
|
|
2059
|
-
|
|
2162
|
+
text.textContent = ({
|
|
2060
2163
|
running: '工作中', thinking: '思考中', 'tool-running': '执行工具', spawning: '启动中',
|
|
2061
2164
|
'pending-permission': '等待授权', aborted: '已停止', failed: '失败',
|
|
2062
|
-
}[sessionState] || sessionState;
|
|
2063
|
-
|
|
2064
|
-
status.hidden = true;
|
|
2165
|
+
}[sessionState] || sessionState) + (background > 0 ? ' · 后台运行中' : '');
|
|
2166
|
+
return;
|
|
2065
2167
|
}
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2168
|
+
if (background > 0) {
|
|
2169
|
+
status.hidden = false;
|
|
2170
|
+
text.textContent = '主回复已结束 · 后台运行中';
|
|
2171
|
+
return;
|
|
2172
|
+
}
|
|
2173
|
+
status.hidden = true;
|
|
2070
2174
|
}
|
|
2071
2175
|
|
|
2072
2176
|
async function refreshContextUsage() {
|
|
@@ -2889,6 +2993,96 @@ function embedAllowedOrigin(origin) {
|
|
|
2889
2993
|
} catch { return false; }
|
|
2890
2994
|
}
|
|
2891
2995
|
let visibleRefreshJob = null, lastVisibleRefresh = 0, embedVisible = false;
|
|
2996
|
+
// Panel registrations are only honored once the workbench is ready; earlier
|
|
2997
|
+
// messages are late arrivals from a previous page lifecycle.
|
|
2998
|
+
let embedPanelsReady = false;
|
|
2999
|
+
function panelScopeMatches(scope) {
|
|
3000
|
+
if (!scope || typeof scope !== 'object') return true;
|
|
3001
|
+
const project = scope.projectId === undefined ? null : String(scope.projectId == null ? '' : scope.projectId);
|
|
3002
|
+
const session = scope.sessionId === undefined ? null : String(scope.sessionId == null ? '' : scope.sessionId);
|
|
3003
|
+
if (project !== null && project !== String(state.activeProjectId == null ? '' : state.activeProjectId)) return false;
|
|
3004
|
+
if (session !== null && session !== String(state.activeSessionId == null ? '' : state.activeSessionId)) return false;
|
|
3005
|
+
return true;
|
|
3006
|
+
}
|
|
3007
|
+
// K1 host→panel content: a plain JSON object only. Limits: ≤1MiB serialized,
|
|
3008
|
+
// nesting depth ≤5, ≤500 items per array. Functions, cycles and other
|
|
3009
|
+
// non-cloneable values fail structuredClone here with an explicit receipt -
|
|
3010
|
+
// the workbench never interprets, evaluates or stringifies business HTML.
|
|
3011
|
+
const PANEL_CONTENT_MAX_CHARS = 1048576;
|
|
3012
|
+
const PANEL_CONTENT_MAX_DEPTH = 5;
|
|
3013
|
+
const PANEL_CONTENT_MAX_ARRAY = 500;
|
|
3014
|
+
function panelContentCheck(content) {
|
|
3015
|
+
if (content === null || typeof content !== 'object' || Array.isArray(content)) {
|
|
3016
|
+
return { ok: false, code: 'INVALID_CONTENT', message: 'content must be a plain JSON object' };
|
|
3017
|
+
}
|
|
3018
|
+
if (typeof structuredClone !== 'function') {
|
|
3019
|
+
return { ok: false, code: 'CONTENT_NOT_CLONEABLE', message: 'structured clone is unavailable' };
|
|
3020
|
+
}
|
|
3021
|
+
let value;
|
|
3022
|
+
try { value = structuredClone(content); } catch (error) {
|
|
3023
|
+
return { ok: false, code: 'CONTENT_NOT_CLONEABLE', message: 'content rejected by structured clone: ' + (error && error.message ? error.message : String(error)) };
|
|
3024
|
+
}
|
|
3025
|
+
let text;
|
|
3026
|
+
try { text = JSON.stringify(value); } catch {
|
|
3027
|
+
return { ok: false, code: 'INVALID_CONTENT', message: 'content must be JSON-serializable' };
|
|
3028
|
+
}
|
|
3029
|
+
if (text === null || text.length > PANEL_CONTENT_MAX_CHARS) {
|
|
3030
|
+
return { ok: false, code: 'CONTENT_TOO_LARGE', message: 'serialized content exceeds ' + PANEL_CONTENT_MAX_CHARS + ' characters' };
|
|
3031
|
+
}
|
|
3032
|
+
let violation = null;
|
|
3033
|
+
const walk = (node, depth) => {
|
|
3034
|
+
if (violation) return;
|
|
3035
|
+
if (node === null || typeof node !== 'object') {
|
|
3036
|
+
if (typeof node === 'number' && !Number.isFinite(node)) {
|
|
3037
|
+
violation = { code: 'INVALID_CONTENT', message: 'content must contain finite JSON numbers only' };
|
|
3038
|
+
}
|
|
3039
|
+
return;
|
|
3040
|
+
}
|
|
3041
|
+
if (!Array.isArray(node)) {
|
|
3042
|
+
// Structured clones can be cross-realm in embedding tests; classify by
|
|
3043
|
+
// the JSON object tag rather than comparing realm-specific prototypes.
|
|
3044
|
+
// Date, Map, Set, and other exotic boxed values still fail this test.
|
|
3045
|
+
if (Object.prototype.toString.call(node) !== '[object Object]') {
|
|
3046
|
+
violation = { code: 'INVALID_CONTENT', message: 'content must contain plain JSON objects only' };
|
|
3047
|
+
return;
|
|
3048
|
+
}
|
|
3049
|
+
if (Object.getOwnPropertySymbols(node).length) {
|
|
3050
|
+
violation = { code: 'INVALID_CONTENT', message: 'content must not contain symbol properties' };
|
|
3051
|
+
return;
|
|
3052
|
+
}
|
|
3053
|
+
}
|
|
3054
|
+
if (Array.isArray(node)) {
|
|
3055
|
+
if (depth > PANEL_CONTENT_MAX_DEPTH) { violation = { code: 'CONTENT_TOO_DEEP', message: 'content nests deeper than ' + PANEL_CONTENT_MAX_DEPTH + ' levels' }; return; }
|
|
3056
|
+
if (node.length > PANEL_CONTENT_MAX_ARRAY) { violation = { code: 'CONTENT_ARRAY_TOO_LONG', message: 'arrays are limited to ' + PANEL_CONTENT_MAX_ARRAY + ' items' }; return; }
|
|
3057
|
+
for (const item of node) walk(item, depth + 1);
|
|
3058
|
+
} else if (node !== null && typeof node === 'object') {
|
|
3059
|
+
if (depth > PANEL_CONTENT_MAX_DEPTH) { violation = { code: 'CONTENT_TOO_DEEP', message: 'content nests deeper than ' + PANEL_CONTENT_MAX_DEPTH + ' levels' }; return; }
|
|
3060
|
+
for (const key of Object.keys(node)) walk(node[key], depth + 1);
|
|
3061
|
+
}
|
|
3062
|
+
};
|
|
3063
|
+
walk(value, 1);
|
|
3064
|
+
if (violation) return { ok: false, code: violation.code, message: violation.message };
|
|
3065
|
+
return { ok: true, value };
|
|
3066
|
+
}
|
|
3067
|
+
function panelStatePayload(reason) {
|
|
3068
|
+
const snap = dock ? dock.snapshot() : null;
|
|
3069
|
+
const panels = snap ? snap.tabs.filter(tab => tab.kind === 'host').map(tab => ({
|
|
3070
|
+
panelId: tab.id,
|
|
3071
|
+
providerId: tab.providerId,
|
|
3072
|
+
panelType: tab.panelType,
|
|
3073
|
+
open: true,
|
|
3074
|
+
active: tab.id === snap.activeTabId,
|
|
3075
|
+
error: tab.hostError || null,
|
|
3076
|
+
content: tab.content || null,
|
|
3077
|
+
scope: snap.scope ? { projectId: snap.scope.contextId, sessionId: snap.scope.parentSessionId } : null,
|
|
3078
|
+
})) : [];
|
|
3079
|
+
const providers = panelRegistry ? panelRegistry.list().map(d => ({ providerId: d.providerId, panelType: d.panelType, title: d.title })) : [];
|
|
3080
|
+
return { type: 'kb:panel/state', app: 'agent-terminal', reason, panels, providers };
|
|
3081
|
+
}
|
|
3082
|
+
function postPanelState(reason) {
|
|
3083
|
+
if (!embedded || window.parent === window) return;
|
|
3084
|
+
try { window.parent.postMessage(panelStatePayload(reason), document.referrer ? new URL(document.referrer).origin : location.origin); } catch { /* a broken referrer never breaks the app */ }
|
|
3085
|
+
}
|
|
2892
3086
|
function refreshVisibleState() {
|
|
2893
3087
|
if (visibleRefreshJob) return visibleRefreshJob;
|
|
2894
3088
|
if (Date.now() - lastVisibleRefresh < 1000) return Promise.resolve();
|
|
@@ -2946,10 +3140,82 @@ function embedSelectProject(ref) {
|
|
|
2946
3140
|
return true;
|
|
2947
3141
|
}
|
|
2948
3142
|
window.addEventListener('message', async ev => {
|
|
2949
|
-
if (ev.source !== window.parent || !embedAllowedOrigin(ev.origin)) return;
|
|
2950
3143
|
const data = ev.data;
|
|
3144
|
+
// Panel receipts arrive from sandboxed panel iframes whose origin is the
|
|
3145
|
+
// opaque "null" - identity is the source window itself, never an origin
|
|
3146
|
+
// string. Only the ack verb exists; panels get no other channel in.
|
|
3147
|
+
if (data && typeof data === 'object' && data.type === 'kb:panel/content-ack') {
|
|
3148
|
+
if (embedded && dock && ev.source) dock.ackHostPanelContent(ev.source, data);
|
|
3149
|
+
return;
|
|
3150
|
+
}
|
|
3151
|
+
if (ev.source !== window.parent || !embedAllowedOrigin(ev.origin)) return;
|
|
2951
3152
|
if (!data || typeof data !== 'object') return;
|
|
2952
3153
|
const reply = payload => { if (ev.source) ev.source.postMessage(Object.assign({ app: 'agent-terminal', requestId: data.requestId }, payload), ev.origin); };
|
|
3154
|
+
if (typeof data.type === 'string' && data.type.startsWith('kb:panel/')) {
|
|
3155
|
+
// W3 trusted host panel protocol. Same gate as every embed message: only
|
|
3156
|
+
// the real parent on the allowed origin, and only an embedded workbench.
|
|
3157
|
+
if (!embedded) { reply({ type: data.type, ok: false, code: 'NOT_EMBEDDED', error: 'panel protocol requires embed mode' }); return; }
|
|
3158
|
+
// W0-CONTRACTS: a referrer alone never authorizes provider registration —
|
|
3159
|
+
// a malicious parent is its own referrer. Panel messages demand a strictly
|
|
3160
|
+
// same-origin host this round; future cross-origin hosts need an explicit
|
|
3161
|
+
// allowlist, never a widening of the referrer rule.
|
|
3162
|
+
if (ev.origin !== location.origin || !embedAllowedOrigin(ev.origin)) { reply({ type: data.type, ok: false, code: 'FORBIDDEN_HOST_ORIGIN', error: 'panel hosts must be same-origin this round' }); return; }
|
|
3163
|
+
if (!embedPanelsReady) { reply({ type: data.type, ok: false, code: 'NOT_READY', error: 'panel messages are ignored before kb:ready' }); return; }
|
|
3164
|
+
if (!panelRegistry || !dock || !hostPanelOwner) { reply({ type: data.type, ok: false, code: 'PANELS_UNAVAILABLE', error: 'panel registry is unavailable' }); return; }
|
|
3165
|
+
if (!panelScopeMatches(data.scope)) { reply({ type: data.type, ok: false, code: 'SCOPE_MISMATCH', error: 'message scope does not match the active project/session' }); return; }
|
|
3166
|
+
if (data.type === 'kb:panel/register') {
|
|
3167
|
+
// kind and origin are bound here, never taken from the message body:
|
|
3168
|
+
// hosts may only ever register same-origin iframe panels.
|
|
3169
|
+
const descriptor = Object.assign({}, data.panel, { kind: 'iframe', origin: ev.origin });
|
|
3170
|
+
const result = panelRegistry.register(descriptor, hostPanelOwner);
|
|
3171
|
+
if (result.ok) postPanelState('register');
|
|
3172
|
+
reply({ type: 'kb:panel/registered', ok: result.ok, code: result.code || null, error: result.message || null, providerId: result.ok ? result.providerId : null });
|
|
3173
|
+
return;
|
|
3174
|
+
}
|
|
3175
|
+
if (data.type === 'kb:panel/unregister') {
|
|
3176
|
+
const result = panelRegistry.unregister(String(data.providerId || ''), hostPanelOwner);
|
|
3177
|
+
if (result.ok) postPanelState('unregister');
|
|
3178
|
+
reply({ type: 'kb:panel/unregistered', ok: result.ok, code: result.code || null, error: result.message || null, providerId: result.ok ? result.providerId : null });
|
|
3179
|
+
return;
|
|
3180
|
+
}
|
|
3181
|
+
if (data.type === 'kb:panel/open') {
|
|
3182
|
+
const panelId = dock.openPanel(String(data.providerId || ''));
|
|
3183
|
+
reply({ type: 'kb:panel/opened', ok: Boolean(panelId), panelId: panelId || null, providerId: String(data.providerId || '') });
|
|
3184
|
+
return;
|
|
3185
|
+
}
|
|
3186
|
+
if (data.type === 'kb:panel/close') {
|
|
3187
|
+
const closed = dock.closeHostPanels({ panelId: data.panelId, providerId: data.providerId });
|
|
3188
|
+
reply({ type: 'kb:panel/closed', ok: true, closed });
|
|
3189
|
+
return;
|
|
3190
|
+
}
|
|
3191
|
+
if (data.type === 'kb:panel/activate') {
|
|
3192
|
+
const ok = dock.activateHostPanel(String(data.panelId || ''));
|
|
3193
|
+
reply({ type: 'kb:panel/activated', ok, panelId: String(data.panelId || '') });
|
|
3194
|
+
return;
|
|
3195
|
+
}
|
|
3196
|
+
if (data.type === 'kb:panel/content') {
|
|
3197
|
+
// Host→panel structured content. The panel is chosen by panelId AND
|
|
3198
|
+
// providerId, and the provider must be an iframe panel this exact host
|
|
3199
|
+
// origin registered - a same-origin neighbor cannot feed foreign panels.
|
|
3200
|
+
const panelId = typeof data.panelId === 'string' ? data.panelId.trim() : '';
|
|
3201
|
+
const providerId = typeof data.providerId === 'string' ? data.providerId.trim() : '';
|
|
3202
|
+
const refuse = (code, error) => reply({ type: 'kb:panel/content-relayed', ok: false, code, error, panelId, providerId, version: data.version === undefined ? null : data.version });
|
|
3203
|
+
if (!panelId || !providerId) { refuse('INVALID_PANEL_REF', 'panelId and providerId are required'); return; }
|
|
3204
|
+
const descriptor = panelRegistry.get(providerId);
|
|
3205
|
+
if (!descriptor || descriptor.kind !== 'iframe' || descriptor.origin !== ev.origin) { refuse('UNKNOWN_PROVIDER', 'providerId is not an iframe panel registered by this host origin'); return; }
|
|
3206
|
+
let version = null;
|
|
3207
|
+
if (typeof data.version === 'number' && Number.isFinite(data.version)) version = data.version;
|
|
3208
|
+
else if (typeof data.version === 'string' && data.version.trim() && data.version.trim().length <= 200) version = data.version.trim();
|
|
3209
|
+
if (version === null) { refuse('INVALID_VERSION', 'version must be a finite number or a short non-empty string'); return; }
|
|
3210
|
+
const check = panelContentCheck(data.content);
|
|
3211
|
+
if (!check.ok) { refuse(check.code, check.message); return; }
|
|
3212
|
+
const relayed = dock.relayHostPanelContent(panelId, providerId, version, check.value);
|
|
3213
|
+
reply({ type: 'kb:panel/content-relayed', ok: Boolean(relayed.ok), code: relayed.code || null, error: relayed.message || null, panelId, providerId, version });
|
|
3214
|
+
return;
|
|
3215
|
+
}
|
|
3216
|
+
reply({ type: 'kb:panel/error', ok: false, code: 'UNKNOWN_PANEL_MESSAGE', error: 'unsupported panel message' });
|
|
3217
|
+
return;
|
|
3218
|
+
}
|
|
2953
3219
|
if (data.type === 'kb:theme') { document.body.classList.toggle('light', data.theme !== 'dark'); document.body.dataset.kbTheme = data.theme === 'dark' ? 'dark' : 'light'; reply({ type: 'kb:configured', theme: document.body.dataset.kbTheme }); return; }
|
|
2954
3220
|
if (data.type === 'kb:visibility' || data.type === 'kb:refresh') {
|
|
2955
3221
|
if (data.type === 'kb:visibility') {
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
6
|
<title>Agent Terminal</title>
|
|
7
7
|
<link rel="stylesheet" href="./app.css">
|
|
8
|
+
<link rel="stylesheet" href="./right-workspace-dock.css">
|
|
8
9
|
</head>
|
|
9
10
|
<body>
|
|
10
11
|
|
|
@@ -37,6 +38,7 @@
|
|
|
37
38
|
<span class="session-provider" id="session-provider"></span>
|
|
38
39
|
<span class="st" id="session-status" hidden><svg class="ics" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3l1.9 5.8L20 12l-6.1 3.2L12 21l-1.9-5.8L4 12l6.1-3.2z"/></svg><span id="session-status-text"></span></span>
|
|
39
40
|
<span style="flex:1"></span>
|
|
41
|
+
<button class="ghost" id="btn-dock-toggle" title="任务面板(原生后台任务)" aria-expanded="false" aria-controls="right-dock" hidden><svg class="ics" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 5h16v14H4z" fill="none" stroke="currentColor" stroke-width="2"/><path d="M15 5v14" stroke="currentColor" stroke-width="2"/></svg><span class="rd-toggle-count" hidden></span></button>
|
|
40
42
|
<button class="ghost" id="btn-top-settings" title="设置(模型供应商)"><svg class="ics" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33h.01a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51h.01a1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82v.01a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></button>
|
|
41
43
|
</div>
|
|
42
44
|
|
|
@@ -86,6 +88,8 @@
|
|
|
86
88
|
</div>
|
|
87
89
|
</div>
|
|
88
90
|
</div>
|
|
91
|
+
|
|
92
|
+
<aside class="right-dock" id="right-dock" hidden aria-label="原生后台任务面板"></aside>
|
|
89
93
|
</div>
|
|
90
94
|
|
|
91
95
|
<!-- ============ 设置视图 ============ -->
|
|
@@ -159,6 +163,8 @@
|
|
|
159
163
|
<div class="toast-host" id="toast" aria-live="polite"></div>
|
|
160
164
|
|
|
161
165
|
<script src="../packages/client/index.js"></script>
|
|
166
|
+
<script src="./right-dock-panel-registry.js"></script>
|
|
167
|
+
<script src="./right-workspace-dock.js"></script>
|
|
162
168
|
<script src="./app.js"></script>
|
|
163
169
|
</body>
|
|
164
170
|
</html>
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>W3 Panel Host Proof(最小可信宿主)</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root { color-scheme: dark; }
|
|
9
|
+
body { margin: 0; font: 13px/1.5 system-ui, sans-serif; background: #101016; color: #e8e8ee; display: flex; flex-direction: column; height: 100vh; }
|
|
10
|
+
header { padding: 8px 12px; border-bottom: 1px solid #2a2a33; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
11
|
+
header h1 { font-size: 14px; margin: 0 12px 0 0; }
|
|
12
|
+
header .note { color: #9a9aa5; font-size: 11px; }
|
|
13
|
+
button { font: inherit; padding: 3px 10px; border: 1px solid #2a2a33; border-radius: 6px; background: #1a1a22; color: inherit; cursor: pointer; }
|
|
14
|
+
button:disabled { opacity: 0.45; cursor: not-allowed; }
|
|
15
|
+
main { flex: 1; display: flex; min-height: 0; }
|
|
16
|
+
#terminal-frame { flex: 1 1 62%; border: none; background: #14141a; }
|
|
17
|
+
aside { flex: 0 0 38%; border-left: 1px solid #2a2a33; display: flex; flex-direction: column; min-width: 0; }
|
|
18
|
+
.state-head { padding: 6px 10px; border-bottom: 1px solid #2a2a33; display: flex; gap: 8px; align-items: baseline; }
|
|
19
|
+
.state-head strong { font-size: 12px; }
|
|
20
|
+
#panel-status { font-size: 11px; color: #9a9aa5; }
|
|
21
|
+
#state-log { flex: 1; overflow-y: auto; margin: 0; padding: 6px 10px; font: 11px/1.6 ui-monospace, monospace; white-space: pre-wrap; word-break: break-all; }
|
|
22
|
+
#state-log .line { display: block; border-bottom: 1px dashed #22222b; padding: 2px 0; }
|
|
23
|
+
#state-log .dir-host { color: #7fa3e8; }
|
|
24
|
+
#state-log .dir-app { color: #2e9e5b; }
|
|
25
|
+
#state-log .dir-err { color: #d64545; }
|
|
26
|
+
</style>
|
|
27
|
+
</head>
|
|
28
|
+
<body>
|
|
29
|
+
<header>
|
|
30
|
+
<h1>W3 Panel Host Proof</h1>
|
|
31
|
+
<span class="note">同源最小宿主:以 embed 模式加载真实 Agent Terminal,注册同源 knowledge iframe 面板。独立 Workbench 不依赖本页代码。</span>
|
|
32
|
+
</header>
|
|
33
|
+
<header>
|
|
34
|
+
<button id="btn-register" disabled>注册知识面板</button>
|
|
35
|
+
<button id="btn-unregister" disabled>注销</button>
|
|
36
|
+
<button id="btn-open-1" disabled>打开一个</button>
|
|
37
|
+
<button id="btn-open-2" disabled>打开第二个</button>
|
|
38
|
+
<button id="btn-close-last" disabled>关闭最后一个</button>
|
|
39
|
+
<button id="btn-activate-first" disabled>切换激活到第一个</button>
|
|
40
|
+
</header>
|
|
41
|
+
<main>
|
|
42
|
+
<iframe id="terminal-frame" title="Agent Terminal(embed)" src="/agent-terminal/?embed=1"></iframe>
|
|
43
|
+
<aside>
|
|
44
|
+
<div class="state-head"><strong>panel state 事件日志</strong><span id="panel-status">等待 kb:ready…</span></div>
|
|
45
|
+
<pre id="state-log"></pre>
|
|
46
|
+
</aside>
|
|
47
|
+
</main>
|
|
48
|
+
<script>
|
|
49
|
+
(function () {
|
|
50
|
+
'use strict';
|
|
51
|
+
var frame = document.getElementById('terminal-frame');
|
|
52
|
+
var log = document.getElementById('state-log');
|
|
53
|
+
var status = document.getElementById('panel-status');
|
|
54
|
+
var buttons = {
|
|
55
|
+
register: document.getElementById('btn-register'),
|
|
56
|
+
unregister: document.getElementById('btn-unregister'),
|
|
57
|
+
open1: document.getElementById('btn-open-1'),
|
|
58
|
+
open2: document.getElementById('btn-open-2'),
|
|
59
|
+
closeLast: document.getElementById('btn-close-last'),
|
|
60
|
+
activateFirst: document.getElementById('btn-activate-first'),
|
|
61
|
+
};
|
|
62
|
+
var ORIGIN = window.location.origin;
|
|
63
|
+
var PROVIDER_ID = 'kb.proof.knowledge';
|
|
64
|
+
var ready = false;
|
|
65
|
+
var registered = false;
|
|
66
|
+
var panels = [];
|
|
67
|
+
var requestSeq = 0;
|
|
68
|
+
|
|
69
|
+
function line(text, dir) {
|
|
70
|
+
var entry = document.createElement('span');
|
|
71
|
+
entry.className = 'line ' + (dir === 'host' ? 'dir-host' : dir === 'app' ? 'dir-app' : 'dir-err');
|
|
72
|
+
entry.textContent = new Date().toISOString().slice(11, 23) + ' ' + text;
|
|
73
|
+
log.append(entry);
|
|
74
|
+
log.scrollTop = log.scrollHeight;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function send(message) {
|
|
78
|
+
if (!ready) { line('宿主 → 终端:忽略(终端未 ready)', 'err'); return; }
|
|
79
|
+
message.requestId = 'proof-' + (++requestSeq);
|
|
80
|
+
frame.contentWindow.postMessage(message, ORIGIN);
|
|
81
|
+
line('宿主 → 终端 ' + message.type + ' ' + JSON.stringify(message), 'host');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function refreshButtons() {
|
|
85
|
+
var knowledgeOpen = panels.filter(function (p) { return p.providerId === PROVIDER_ID; });
|
|
86
|
+
buttons.register.disabled = !ready || registered;
|
|
87
|
+
buttons.unregister.disabled = !ready || !registered;
|
|
88
|
+
buttons.open1.disabled = !ready || !registered;
|
|
89
|
+
buttons.open2.disabled = !ready || !registered;
|
|
90
|
+
buttons.closeLast.disabled = knowledgeOpen.length === 0;
|
|
91
|
+
buttons.activateFirst.disabled = knowledgeOpen.length < 2;
|
|
92
|
+
status.textContent = ready
|
|
93
|
+
? (registered ? '已注册 knowledge 面板;当前打开 ' + knowledgeOpen.length + ' 个实例' : '未注册 provider:knowledge 不显示为可用')
|
|
94
|
+
: '等待 kb:ready…';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
window.addEventListener('message', function (ev) {
|
|
98
|
+
if (ev.source !== frame.contentWindow || ev.origin !== ORIGIN) return;
|
|
99
|
+
var data = ev.data;
|
|
100
|
+
if (!data || typeof data !== 'object') return;
|
|
101
|
+
if (data.type === 'kb:ready') {
|
|
102
|
+
ready = true;
|
|
103
|
+
line('终端 → 宿主 kb:ready(注册自此生效)', 'app');
|
|
104
|
+
refreshButtons();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (data.type === 'kb:panel/state') {
|
|
108
|
+
panels = Array.isArray(data.panels) ? data.panels : [];
|
|
109
|
+
var providers = (data.providers || []).map(function (p) { return p.providerId; });
|
|
110
|
+
line('终端 → 宿主 kb:panel/state reason=' + data.reason +
|
|
111
|
+
' panels=' + JSON.stringify(panels.map(function (p) {
|
|
112
|
+
return { id: p.panelId, provider: p.providerId, active: p.active, error: p.error, scope: p.scope };
|
|
113
|
+
})) +
|
|
114
|
+
' providers=' + JSON.stringify(providers), 'app');
|
|
115
|
+
refreshButtons();
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (typeof data.type === 'string' && data.type.indexOf('kb:panel/') === 0) {
|
|
119
|
+
line('终端 → 宿主 ' + data.type + ' ' + JSON.stringify(data), 'app');
|
|
120
|
+
if (data.type === 'kb:panel/registered' && data.ok) registered = true;
|
|
121
|
+
if (data.type === 'kb:panel/unregistered' && data.ok) registered = false;
|
|
122
|
+
refreshButtons();
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
buttons.register.addEventListener('click', function () {
|
|
127
|
+
send({ type: 'kb:panel/register', panel: {
|
|
128
|
+
providerId: PROVIDER_ID,
|
|
129
|
+
panelType: 'knowledge',
|
|
130
|
+
title: '知识引用(proof)',
|
|
131
|
+
url: new URL('panel-proof-content.html?panel=knowledge&source=host-proof', window.location.href).href,
|
|
132
|
+
capabilities: ['read-only', 'proof'],
|
|
133
|
+
multiInstance: 'multiple',
|
|
134
|
+
appliesTo: { projects: '*' },
|
|
135
|
+
} });
|
|
136
|
+
});
|
|
137
|
+
buttons.unregister.addEventListener('click', function () { send({ type: 'kb:panel/unregister', providerId: PROVIDER_ID }); });
|
|
138
|
+
buttons.open1.addEventListener('click', function () { send({ type: 'kb:panel/open', providerId: PROVIDER_ID }); });
|
|
139
|
+
buttons.open2.addEventListener('click', function () { send({ type: 'kb:panel/open', providerId: PROVIDER_ID }); });
|
|
140
|
+
buttons.closeLast.addEventListener('click', function () {
|
|
141
|
+
var open = panels.filter(function (p) { return p.providerId === PROVIDER_ID; });
|
|
142
|
+
if (open.length) send({ type: 'kb:panel/close', panelId: open[open.length - 1].panelId });
|
|
143
|
+
});
|
|
144
|
+
buttons.activateFirst.addEventListener('click', function () {
|
|
145
|
+
var open = panels.filter(function (p) { return p.providerId === PROVIDER_ID; });
|
|
146
|
+
if (open.length >= 2) send({ type: 'kb:panel/activate', panelId: open[0].panelId });
|
|
147
|
+
});
|
|
148
|
+
refreshButtons();
|
|
149
|
+
})();
|
|
150
|
+
</script>
|
|
151
|
+
</body>
|
|
152
|
+
</html>
|