@zhin.js/client 2.1.9 → 2.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +6 -0
  2. package/client/console-events.ts +37 -0
  3. package/client/index.ts +44 -2
  4. package/client/mediaSrc.test.ts +15 -0
  5. package/client/mediaSrc.ts +59 -12
  6. package/client/message-content/MarkdownContent.tsx +128 -0
  7. package/client/message-content/markdown.test.ts +48 -0
  8. package/client/message-content/markdown.ts +247 -0
  9. package/client/persistence/idb-store.test.ts +11 -0
  10. package/client/persistence/idb-store.ts +20 -4
  11. package/client/websocket/index.ts +6 -1
  12. package/client/websocket/manager.test.ts +145 -12
  13. package/client/websocket/manager.ts +189 -17
  14. package/client/websocket/types.ts +5 -0
  15. package/client/workroom/WorkroomRunsPage.tsx +248 -0
  16. package/client/{orchestration/registerOrchestrationConsole.test.ts → workroom/registerWorkroomConsole.test.ts} +8 -8
  17. package/client/workroom/registerWorkroomConsole.ts +17 -0
  18. package/dist/console-events.d.ts +7 -0
  19. package/dist/console-events.d.ts.map +1 -0
  20. package/dist/console-events.js +26 -0
  21. package/dist/console-events.js.map +1 -0
  22. package/dist/index.d.ts +6 -3
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +7 -3
  25. package/dist/index.js.map +1 -1
  26. package/dist/mediaSrc.d.ts.map +1 -1
  27. package/dist/mediaSrc.js +66 -12
  28. package/dist/mediaSrc.js.map +1 -1
  29. package/dist/message-content/MarkdownContent.d.ts +11 -0
  30. package/dist/message-content/MarkdownContent.d.ts.map +1 -0
  31. package/dist/message-content/MarkdownContent.js +68 -0
  32. package/dist/message-content/MarkdownContent.js.map +1 -0
  33. package/dist/message-content/markdown.d.ts +47 -0
  34. package/dist/message-content/markdown.d.ts.map +1 -0
  35. package/dist/message-content/markdown.js +219 -0
  36. package/dist/message-content/markdown.js.map +1 -0
  37. package/dist/persistence/idb-store.d.ts +3 -0
  38. package/dist/persistence/idb-store.d.ts.map +1 -1
  39. package/dist/persistence/idb-store.js +13 -3
  40. package/dist/persistence/idb-store.js.map +1 -1
  41. package/dist/websocket/index.d.ts +1 -1
  42. package/dist/websocket/index.d.ts.map +1 -1
  43. package/dist/websocket/index.js +1 -1
  44. package/dist/websocket/index.js.map +1 -1
  45. package/dist/websocket/manager.d.ts +19 -0
  46. package/dist/websocket/manager.d.ts.map +1 -1
  47. package/dist/websocket/manager.js +176 -18
  48. package/dist/websocket/manager.js.map +1 -1
  49. package/dist/websocket/types.d.ts +4 -0
  50. package/dist/websocket/types.d.ts.map +1 -1
  51. package/dist/websocket/types.js.map +1 -1
  52. package/dist/workroom/WorkroomRunsPage.d.ts +2 -0
  53. package/dist/workroom/WorkroomRunsPage.d.ts.map +1 -0
  54. package/dist/workroom/WorkroomRunsPage.js +64 -0
  55. package/dist/workroom/WorkroomRunsPage.js.map +1 -0
  56. package/dist/workroom/registerWorkroomConsole.d.ts +4 -0
  57. package/dist/workroom/registerWorkroomConsole.d.ts.map +1 -0
  58. package/dist/workroom/registerWorkroomConsole.js +16 -0
  59. package/dist/workroom/registerWorkroomConsole.js.map +1 -0
  60. package/package.json +3 -3
  61. package/client/orchestration/OrchestrationRunsPage.tsx +0 -217
  62. package/client/orchestration/registerOrchestrationConsole.ts +0 -17
  63. package/dist/orchestration/OrchestrationRunsPage.d.ts +0 -2
  64. package/dist/orchestration/OrchestrationRunsPage.d.ts.map +0 -1
  65. package/dist/orchestration/OrchestrationRunsPage.js +0 -65
  66. package/dist/orchestration/OrchestrationRunsPage.js.map +0 -1
  67. package/dist/orchestration/registerOrchestrationConsole.d.ts +0 -4
  68. package/dist/orchestration/registerOrchestrationConsole.d.ts.map +0 -1
  69. package/dist/orchestration/registerOrchestrationConsole.js +0 -16
  70. package/dist/orchestration/registerOrchestrationConsole.js.map +0 -1
@@ -2,11 +2,32 @@ import { ConnectionState, WebSocketError, ConnectionError, MessageError, Request
2
2
 
3
3
  import { getApiBase, getStoredToken, resolveApiUrl } from "./remote-settings.js";
4
4
  import { applyConsoleEvent } from "../persistence/idb-store.js";
5
+ import { fetchConsoleEventHistory } from '../console-events.js';
5
6
  import {
7
+ CONSOLE_EVENT_RECOVERY_GAP_EVENT,
6
8
  SIDE_EVENT_PUSH,
7
9
  normalizeConsolePushMessage,
10
+ parseConsoleSseFrame,
11
+ type ConsoleEventData,
12
+ type ConsoleEventEnvelope,
13
+ type ConsoleEventHistoryPage,
14
+ type ConsoleEventHistoryQuery,
8
15
  } from "@zhin.js/console-protocol";
9
16
 
17
+ interface ConsoleEventCursor {
18
+ readonly runtimeId: string;
19
+ readonly eventId: number;
20
+ }
21
+
22
+ export type ConsoleEventListener<Type extends string> = (
23
+ event: ConsoleEventEnvelope<Type, ConsoleEventData<Type>>,
24
+ ) => void;
25
+ export interface ConsoleEventRecoveryGap {
26
+ readonly query: ConsoleEventHistoryQuery;
27
+ readonly page: ConsoleEventHistoryPage;
28
+ }
29
+ export type ConsoleEventRecoveryGapListener = (gap: ConsoleEventRecoveryGap) => void;
30
+
10
31
  export class WebSocketManager {
11
32
  private ws: WebSocket | null = null;
12
33
  private config: Required<WebSocketConfig>;
@@ -20,7 +41,12 @@ export class WebSocketManager {
20
41
  { resolve: (value: unknown) => void; reject: (error: Error) => void; timer: ReturnType<typeof setTimeout> }
21
42
  >();
22
43
  private connectedListeners = new Set<(connected: boolean) => void>();
44
+ private eventListeners = new Map<string, Set<ConsoleEventListener<string>>>();
45
+ private recoveryGapListeners = new Set<ConsoleEventRecoveryGapListener>();
23
46
  private sseAbort: AbortController | null = null;
47
+ private eventRuntimeId = '';
48
+ private lastEventId = 0;
49
+ private durableCursorBlocked = false;
24
50
  private useRestTransport = true;
25
51
 
26
52
  constructor(config: WebSocketConfig = {}, callbacks: WebSocketCallbacks = {}) {
@@ -38,6 +64,27 @@ export class WebSocketManager {
38
64
  return () => this.connectedListeners.delete(listener);
39
65
  }
40
66
 
67
+ /** Subscribe by event name; known names infer their exact payload type. */
68
+ onConsoleEvent<Type extends string>(type: Type, listener: ConsoleEventListener<Type>): () => void {
69
+ let listeners = this.eventListeners.get(type);
70
+ if (!listeners) {
71
+ listeners = new Set();
72
+ this.eventListeners.set(type, listeners);
73
+ }
74
+ const stored = listener as ConsoleEventListener<string>;
75
+ listeners.add(stored);
76
+ return () => {
77
+ listeners?.delete(stored);
78
+ if (listeners?.size === 0) this.eventListeners.delete(type);
79
+ };
80
+ }
81
+
82
+ /** Observe a non-resumable cursor so domain views can perform a full resync. */
83
+ onConsoleEventRecoveryGap(listener: ConsoleEventRecoveryGapListener): () => void {
84
+ this.recoveryGapListeners.add(listener);
85
+ return () => this.recoveryGapListeners.delete(listener);
86
+ }
87
+
41
88
  private notifyConnection(connected: boolean) {
42
89
  for (const l of this.connectedListeners) l(connected);
43
90
  }
@@ -73,7 +120,12 @@ export class WebSocketManager {
73
120
  Accept: "text/event-stream",
74
121
  };
75
122
  if (token) headers.Authorization = `Bearer ${token}`;
76
- const res = await fetch(resolveApiUrl("/api/events"), {
123
+ await this.recoverEventHistory(this.sseAbort.signal);
124
+ const params = new URLSearchParams();
125
+ if (this.eventRuntimeId) params.set('runtimeId', this.eventRuntimeId);
126
+ if (this.lastEventId > 0) params.set('after', String(this.lastEventId));
127
+ const suffix = params.size ? `?${params}` : '';
128
+ const res = await fetch(resolveApiUrl(`/api/events${suffix}`), {
77
129
  headers,
78
130
  signal: this.sseAbort.signal,
79
131
  });
@@ -107,22 +159,21 @@ export class WebSocketManager {
107
159
  const { done, value } = await reader.read();
108
160
  if (done) break;
109
161
  buffer += decoder.decode(value, { stream: true });
110
- const parts = buffer.split("\n\n");
162
+ const parts = buffer.split(/\r?\n\r?\n/u);
111
163
  buffer = parts.pop() ?? "";
112
164
  for (const part of parts) {
113
- const line = part.split("\n").find((l) => l.startsWith("data: "));
114
- if (!line) continue;
115
- const json = line.slice(6);
116
- try {
117
- const message = normalizeConsolePushMessage(
118
- JSON.parse(json) as WebSocketMessage,
119
- ) as WebSocketMessage;
120
- void applyConsoleEvent(message);
121
- this.handleBroadcast(message);
122
- this.callbacks.onMessage?.(message);
123
- } catch {
124
- /* skip */
125
- }
165
+ const parsed = parseConsoleSseFrame(part);
166
+ if (!parsed) continue;
167
+ const runtimeId = parsed.runtimeId ?? this.eventRuntimeId;
168
+ const event: ConsoleEventEnvelope = Object.freeze({
169
+ runtimeId,
170
+ eventId: parsed.eventId ?? 0,
171
+ type: parsed.type,
172
+ data: parsed.data,
173
+ timestamp: parsed.timestamp ?? Date.now(),
174
+ delivery: 'live',
175
+ });
176
+ await this.deliverConsoleEvent(event);
126
177
  }
127
178
  }
128
179
  } catch {
@@ -136,6 +187,99 @@ export class WebSocketManager {
136
187
  }
137
188
  }
138
189
 
190
+ private async recoverEventHistory(signal: AbortSignal): Promise<void> {
191
+ const stored = this.eventRuntimeId
192
+ ? { runtimeId: this.eventRuntimeId, eventId: this.lastEventId }
193
+ : readEventCursor();
194
+ let runtimeId = stored?.runtimeId;
195
+ let after = stored?.eventId ?? 0;
196
+ if (runtimeId) {
197
+ this.eventRuntimeId = runtimeId;
198
+ this.lastEventId = after;
199
+ }
200
+ for (;;) {
201
+ const query = { runtimeId, after, limit: 500 } satisfies ConsoleEventHistoryQuery;
202
+ const page = await fetchConsoleEventHistory(query, { signal });
203
+ if (page.runtimeId !== runtimeId) {
204
+ runtimeId = page.runtimeId;
205
+ after = 0;
206
+ this.lastEventId = 0;
207
+ this.durableCursorBlocked = false;
208
+ }
209
+ this.eventRuntimeId = page.runtimeId;
210
+ if (page.gap) this.notifyRecoveryGap({ query, page });
211
+ for (const item of page.items) {
212
+ await this.deliverConsoleEvent(Object.freeze({ ...item, delivery: 'history' }));
213
+ after = item.eventId;
214
+ }
215
+ if (!page.hasMore) {
216
+ this.lastEventId = Math.max(after, page.latestEventId);
217
+ if (!this.durableCursorBlocked) {
218
+ writeEventCursor({ runtimeId: page.runtimeId, eventId: this.lastEventId });
219
+ }
220
+ return;
221
+ }
222
+ after = page.nextAfter;
223
+ }
224
+ }
225
+
226
+ private async deliverConsoleEvent(event: ConsoleEventEnvelope): Promise<void> {
227
+ if (event.runtimeId && event.runtimeId !== this.eventRuntimeId) {
228
+ this.eventRuntimeId = event.runtimeId;
229
+ this.lastEventId = 0;
230
+ this.durableCursorBlocked = false;
231
+ }
232
+ if (event.eventId > 0
233
+ && event.runtimeId === this.eventRuntimeId
234
+ && event.eventId <= this.lastEventId) return;
235
+ const message = normalizeConsolePushMessage({
236
+ type: event.type,
237
+ data: event.data,
238
+ runtimeId: event.runtimeId,
239
+ eventId: event.eventId || undefined,
240
+ timestamp: event.timestamp,
241
+ delivery: event.delivery,
242
+ }) as WebSocketMessage;
243
+ try {
244
+ await applyConsoleEvent(message);
245
+ } catch (error) {
246
+ this.durableCursorBlocked = true;
247
+ console.error('[Console transport] Failed to persist event:', error);
248
+ }
249
+ this.handleBroadcast(message);
250
+ try {
251
+ this.callbacks.onMessage?.(message);
252
+ } catch (error) {
253
+ console.error('[Console transport] onMessage listener failed:', error);
254
+ }
255
+ for (const listener of this.eventListeners.get(event.type) ?? []) {
256
+ try {
257
+ listener(event as ConsoleEventEnvelope<string, unknown>);
258
+ } catch (error) {
259
+ console.error('[Console transport] event listener failed:', error);
260
+ }
261
+ }
262
+ if (event.eventId > 0 && event.runtimeId) {
263
+ this.lastEventId = Math.max(this.lastEventId, event.eventId);
264
+ if (!this.durableCursorBlocked) {
265
+ writeEventCursor({ runtimeId: event.runtimeId, eventId: this.lastEventId });
266
+ }
267
+ }
268
+ }
269
+
270
+ private notifyRecoveryGap(gap: ConsoleEventRecoveryGap): void {
271
+ if (typeof window !== 'undefined') {
272
+ window.dispatchEvent(new CustomEvent(CONSOLE_EVENT_RECOVERY_GAP_EVENT, { detail: gap }));
273
+ }
274
+ for (const listener of this.recoveryGapListeners) {
275
+ try {
276
+ listener(gap);
277
+ } catch (error) {
278
+ console.error('[Console transport] recovery gap listener failed:', error);
279
+ }
280
+ }
281
+ }
282
+
139
283
  disconnect(): void {
140
284
  this.clearReconnectTimer();
141
285
  this.clearPendingRequests();
@@ -362,12 +506,12 @@ export class WebSocketManager {
362
506
  }
363
507
  return;
364
508
  }
365
- if (t === "hmr:reload") {
509
+ if (t === "hmr:reload" && message.delivery !== 'history') {
366
510
  console.info(`[HMR] File changed, reloading...`);
367
511
  window.location.reload();
368
512
  return;
369
513
  }
370
- if (t === "system:restarting") {
514
+ if (t === "system:restarting" && message.delivery !== 'history') {
371
515
  console.info("[System] Server restarting, will reload shortly...");
372
516
  setTimeout(() => window.location.reload(), 3000);
373
517
  return;
@@ -443,3 +587,31 @@ export class WebSocketManager {
443
587
  this.pendingRequests.clear();
444
588
  }
445
589
  }
590
+
591
+ function eventCursorStorageKey(): string {
592
+ return `zhin.console.event-cursor:${getApiBase()}`;
593
+ }
594
+
595
+ function readEventCursor(): ConsoleEventCursor | null {
596
+ if (typeof localStorage === 'undefined') return null;
597
+ try {
598
+ const parsed = JSON.parse(localStorage.getItem(eventCursorStorageKey()) ?? 'null') as Partial<ConsoleEventCursor> | null;
599
+ return parsed
600
+ && typeof parsed.runtimeId === 'string'
601
+ && Number.isSafeInteger(parsed.eventId)
602
+ && (parsed.eventId ?? -1) >= 0
603
+ ? { runtimeId: parsed.runtimeId, eventId: parsed.eventId as number }
604
+ : null;
605
+ } catch {
606
+ return null;
607
+ }
608
+ }
609
+
610
+ function writeEventCursor(cursor: ConsoleEventCursor): void {
611
+ if (typeof localStorage === 'undefined') return;
612
+ try {
613
+ localStorage.setItem(eventCursorStorageKey(), JSON.stringify(cursor));
614
+ } catch {
615
+ // HTTP history remains available when browser storage is unavailable.
616
+ }
617
+ }
@@ -1,6 +1,11 @@
1
+ import type { ConsoleEventDelivery } from '@zhin.js/console-protocol';
2
+
1
3
  export interface BaseMessage {
2
4
  type: string;
3
5
  timestamp?: number;
6
+ runtimeId?: string;
7
+ eventId?: number;
8
+ delivery?: ConsoleEventDelivery;
4
9
  requestId?: number;
5
10
  error?: string;
6
11
  }
@@ -0,0 +1,248 @@
1
+ import { useCallback, useState } from 'react';
2
+ import { apiFetch } from '../console-utils/remoteApi.js';
3
+
4
+ type WorkroomTask = {
5
+ key: string;
6
+ title: string;
7
+ status: string;
8
+ revision: number;
9
+ attempt: number;
10
+ maxAttempts: number;
11
+ reportRef?: string;
12
+ terminalReason?: string;
13
+ currentAssignmentId?: string;
14
+ blockers?: Array<{ id: string; kind: string; owner: string; reason: string; deadline: number }>;
15
+ latestProgress?: { summary: string; completedUnits?: number; totalUnits?: number };
16
+ };
17
+
18
+ type WorkroomAssignment = {
19
+ id: string;
20
+ taskKey: string;
21
+ role: string;
22
+ status: string;
23
+ owner: string;
24
+ attempt: number;
25
+ leaseExpiresAt: number;
26
+ latestProgress?: { summary: string; completedUnits?: number; totalUnits?: number };
27
+ outcome?: string;
28
+ };
29
+
30
+ type WorkroomWait = {
31
+ id: string;
32
+ taskKey: string;
33
+ status: string;
34
+ owner: string;
35
+ route: string;
36
+ riskTier: string;
37
+ deadline: number;
38
+ };
39
+
40
+ type RunListItem = {
41
+ runId: string;
42
+ projectId: string;
43
+ status: string;
44
+ title: string;
45
+ sequence: number;
46
+ now: number;
47
+ cancelRequested: boolean;
48
+ tasks: Record<string, WorkroomTask>;
49
+ assignments: Record<string, WorkroomAssignment>;
50
+ reviewerAssignments: Record<string, WorkroomWait>;
51
+ sponsorGates: Record<string, WorkroomWait>;
52
+ };
53
+
54
+ type RunDetail = RunListItem;
55
+
56
+ export default function WorkroomRunsPage() {
57
+ const [projectId, setProjectId] = useState('');
58
+ const [runs, setRuns] = useState<RunListItem[]>([]);
59
+ const [selected, setSelected] = useState<RunDetail | null>(null);
60
+ const [loading, setLoading] = useState(false);
61
+ const [error, setError] = useState('');
62
+ const [detailView, setDetailView] = useState<'tasks' | 'assignments' | 'acceptance'>('tasks');
63
+
64
+ const loadRuns = useCallback(async () => {
65
+ if (!projectId.trim()) {
66
+ setError('请输入 projectId');
67
+ return;
68
+ }
69
+ setLoading(true);
70
+ setError('');
71
+ setSelected(null);
72
+ try {
73
+ const res = await apiFetch(
74
+ `/api/agent/workroom/runs?projectId=${encodeURIComponent(projectId.trim())}`,
75
+ );
76
+ const body = await res.json() as { success?: boolean; error?: string; data?: { runs?: RunListItem[] } };
77
+ if (!res.ok || !body.success) {
78
+ throw new Error(body.error ?? `HTTP ${res.status}`);
79
+ }
80
+ setRuns(body.data?.runs ?? []);
81
+ } catch (err) {
82
+ setError(err instanceof Error ? err.message : String(err));
83
+ setRuns([]);
84
+ } finally {
85
+ setLoading(false);
86
+ }
87
+ }, [projectId]);
88
+
89
+ const loadRunDetail = useCallback(async (runId: string, runProjectId: string) => {
90
+ setLoading(true);
91
+ setError('');
92
+ try {
93
+ const res = await apiFetch(`/api/agent/workroom/runs/${encodeURIComponent(runId)}?projectId=${encodeURIComponent(runProjectId)}`);
94
+ const body = await res.json() as { success?: boolean; error?: string; data?: RunDetail };
95
+ if (!res.ok || !body.success || !body.data) {
96
+ throw new Error(body.error ?? `HTTP ${res.status}`);
97
+ }
98
+ setSelected(body.data);
99
+ } catch (err) {
100
+ setError(err instanceof Error ? err.message : String(err));
101
+ } finally {
102
+ setLoading(false);
103
+ }
104
+ }, []);
105
+
106
+ return (
107
+ <div className="p-4 md:p-6 space-y-5 max-w-7xl mx-auto">
108
+ <header className="flex flex-wrap items-end justify-between gap-3 border-b pb-4">
109
+ <div>
110
+ <span className="text-[11px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">Project control plane</span>
111
+ <h1 className="mt-1 text-xl font-semibold tracking-tight">Workroom 控制台</h1>
112
+ <p className="mt-1 max-w-3xl text-sm text-muted-foreground">查看 Journal + CAS Kernel 投影出的 Run、Task、Assignment 与验收门;普通聊天不会隐式创建 Workroom。</p>
113
+ </div>
114
+ <span className="rounded-full border px-2.5 py-1 text-xs text-muted-foreground">只读事实投影</span>
115
+ </header>
116
+
117
+ <details className="rounded-xl border bg-muted/20 open:bg-muted/10">
118
+ <summary className="cursor-pointer list-none px-4 py-3 text-sm font-medium">运行与持久化配置 <span className="ml-2 text-xs font-normal text-muted-foreground">ai.sessions.useDatabase · 修改后需重启</span></summary>
119
+ <div className="grid gap-4 border-t px-4 py-4 md:grid-cols-[1fr_1fr]">
120
+ <div className="space-y-2 text-sm">
121
+ <p className="font-medium">数据库 Journal(默认)</p>
122
+ <p className="text-muted-foreground">`ai.sessions.useDatabase` 不为 false 时写入 `workroom_events`;Database Root Host 未就绪会阻止 candidate generation 发布。</p>
123
+ <pre className="overflow-auto rounded-lg border bg-background p-3 text-xs">{'ai:\n sessions:\n useDatabase: true'}</pre>
124
+ </div>
125
+ <div className="space-y-2 text-sm">
126
+ <p className="font-medium">原子文件 Journal</p>
127
+ <p className="text-muted-foreground">显式设为 false 后使用 `.zhin/workroom-journal`。后端在进程启动时固定,热重载不会切换事实源。</p>
128
+ <pre className="overflow-auto rounded-lg border bg-background p-3 text-xs">{'ai:\n sessions:\n useDatabase: false'}</pre>
129
+ </div>
130
+ </div>
131
+ <div className="flex items-center justify-between gap-3 border-t px-4 py-3 text-xs text-muted-foreground"><span>配置项属于 Agent session storage;切换 Journal 后端必须重启 Host,不能只做热重载。</span><a className="rounded-md border bg-background px-2.5 py-1.5 font-medium text-foreground hover:bg-muted" href="/config">打开配置编辑器</a></div>
132
+ </details>
133
+
134
+ <div className="flex flex-wrap gap-2 items-end">
135
+ <label className="flex flex-col gap-1 text-sm flex-1 min-w-[240px]">
136
+ projectId
137
+ <input
138
+ className="border rounded px-2 py-1 text-sm bg-background"
139
+ value={projectId}
140
+ onChange={(e) => setProjectId(e.target.value)}
141
+ placeholder="project-alpha"
142
+ />
143
+ </label>
144
+ <button
145
+ type="button"
146
+ className="border rounded px-3 py-1 text-sm hover:bg-muted"
147
+ disabled={loading}
148
+ onClick={() => void loadRuns()}
149
+ >
150
+ 查询 Runs
151
+ </button>
152
+ </div>
153
+
154
+ {error ? <p className="text-sm text-red-600">{error}</p> : null}
155
+
156
+ <div className="grid gap-4 lg:grid-cols-[minmax(16rem,0.75fr)_minmax(0,1.5fr)]">
157
+ <section className="rounded-xl border p-3 space-y-2">
158
+ <h2 className="text-sm font-medium">Runs ({runs.length})</h2>
159
+ {runs.length === 0 ? (
160
+ <p className="text-sm text-muted-foreground">无数据</p>
161
+ ) : (
162
+ <ul className="space-y-2 text-sm">
163
+ {runs.map((item) => (
164
+ <li key={item.runId} className={`rounded-lg border p-2 transition-colors ${selected?.runId === item.runId ? 'border-primary/40 bg-primary/5' : 'hover:bg-muted/30'}`}>
165
+ <button
166
+ type="button"
167
+ className="text-left w-full"
168
+ onClick={() => void loadRunDetail(item.runId, item.projectId)}
169
+ >
170
+ <div className="font-mono text-xs">{item.runId}</div>
171
+ <div className="mt-1 font-medium">{item.title || '(无标题)'}</div>
172
+ <div className="text-muted-foreground">
173
+ {item.status}
174
+ {' · '}
175
+ {Object.keys(item.tasks).length}
176
+ {' tasks'}
177
+ </div>
178
+ </button>
179
+ </li>
180
+ ))}
181
+ </ul>
182
+ )}
183
+ </section>
184
+
185
+ <section className="rounded-xl border p-3 space-y-3">
186
+ <h2 className="text-sm font-medium">Run 详情</h2>
187
+ {!selected ? (
188
+ <p className="text-sm text-muted-foreground">选择左侧 Run</p>
189
+ ) : (
190
+ <>
191
+ <div className="flex flex-wrap items-start justify-between gap-3 rounded-lg bg-muted/25 p-3 text-sm">
192
+ <div><div className="font-medium">{selected.title}</div><code className="text-xs text-muted-foreground">{selected.runId}</code></div>
193
+ <span className={`rounded-full border px-2 py-0.5 text-xs ${statusTone(selected.status)}`}>{selected.status}</span>
194
+ </div>
195
+
196
+ <dl className="grid grid-cols-2 overflow-hidden rounded-lg border text-sm sm:grid-cols-4">
197
+ <div className="p-2.5"><dt className="text-xs text-muted-foreground">Sequence</dt><dd className="mt-0.5 font-medium tabular-nums">{selected.sequence}</dd></div>
198
+ <div className="border-l p-2.5"><dt className="text-xs text-muted-foreground">Tasks</dt><dd className="mt-0.5 font-medium tabular-nums">{Object.keys(selected.tasks).length}</dd></div>
199
+ <div className="border-t p-2.5 sm:border-l sm:border-t-0"><dt className="text-xs text-muted-foreground">Assignments</dt><dd className="mt-0.5 font-medium tabular-nums">{Object.keys(selected.assignments ?? {}).length}</dd></div>
200
+ <div className="border-l border-t p-2.5 sm:border-t-0"><dt className="text-xs text-muted-foreground">Acceptance waits</dt><dd className="mt-0.5 font-medium tabular-nums">{Object.keys(selected.reviewerAssignments ?? {}).length + Object.keys(selected.sponsorGates ?? {}).length}</dd></div>
201
+ </dl>
202
+
203
+ <div className="flex gap-1 rounded-lg bg-muted/35 p-1" role="tablist" aria-label="Workroom 详情视图">
204
+ {(['tasks', 'assignments', 'acceptance'] as const).map((view) => <button key={view} type="button" role="tab" aria-selected={detailView === view} className={`flex-1 rounded-md px-2 py-1.5 text-xs font-medium ${detailView === view ? 'bg-background shadow-sm' : 'text-muted-foreground hover:text-foreground'}`} onClick={() => setDetailView(view)}>{view === 'tasks' ? '任务' : view === 'assignments' ? '执行分配' : '验收门'}</button>)}
205
+ </div>
206
+
207
+ {detailView === 'tasks' && <ul className="space-y-2 text-sm">
208
+ {Object.values(selected.tasks).map((task) => (
209
+ <li key={task.key} className="rounded-lg border p-3">
210
+ <div className="flex items-start justify-between gap-2"><div><code className="text-xs text-muted-foreground">{task.key}</code><div className="font-medium">{task.title}</div></div><span className={`rounded-full border px-2 py-0.5 text-[11px] ${statusTone(task.status)}`}>{task.status}</span></div>
211
+ <div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground"><span>rev {task.revision}</span><span>attempt {task.attempt}/{task.maxAttempts}</span>{task.currentAssignmentId ? <span>assignment {task.currentAssignmentId}</span> : null}</div>
212
+ {task.blockers?.map((blocker) => <div key={blocker.id} className="mt-2 rounded-md border border-amber-500/25 bg-amber-500/5 p-2 text-xs"><strong>{blocker.kind}</strong> · {blocker.reason}<div className="text-muted-foreground">owner {blocker.owner}</div></div>)}
213
+ {task.reportRef ? <div className="mt-2 break-words rounded bg-muted/35 p-2 font-mono text-xs">report: {task.reportRef}</div> : null}
214
+ {task.terminalReason ? <div className="mt-2 whitespace-pre-wrap text-xs text-red-600">{task.terminalReason}</div> : null}
215
+ </li>
216
+ ))}
217
+ </ul>}
218
+
219
+ {detailView === 'assignments' && <ul className="space-y-2 text-sm">
220
+ {Object.values(selected.assignments ?? {}).length === 0 ? <li className="py-8 text-center text-muted-foreground">暂无 Assignment</li> : Object.values(selected.assignments ?? {}).map((assignment) => (
221
+ <li key={assignment.id} className="rounded-lg border p-3">
222
+ <div className="flex items-start justify-between gap-2"><div><code className="text-xs text-muted-foreground">{assignment.id}</code><div className="font-medium">{assignment.taskKey} · {assignment.role}</div></div><span className={`rounded-full border px-2 py-0.5 text-[11px] ${statusTone(assignment.status)}`}>{assignment.status}</span></div>
223
+ <div className="mt-2 grid gap-1 text-xs text-muted-foreground sm:grid-cols-3"><span>owner {assignment.owner}</span><span>attempt {assignment.attempt}</span><span>lease {new Date(assignment.leaseExpiresAt).toLocaleString()}</span></div>
224
+ {assignment.latestProgress ? <div className="mt-2 rounded-md bg-muted/35 p-2 text-xs"><strong>Progress</strong> · {assignment.latestProgress.summary}{assignment.latestProgress.totalUnits ? <div className="mt-1 h-1.5 overflow-hidden rounded-full bg-muted"><i className="block h-full bg-primary" style={{ width: `${Math.min(100, Math.max(0, ((assignment.latestProgress.completedUnits ?? 0) / assignment.latestProgress.totalUnits) * 100))}%` }} /></div> : null}</div> : null}
225
+ </li>
226
+ ))}
227
+ </ul>}
228
+
229
+ {detailView === 'acceptance' && <ul className="space-y-2 text-sm">
230
+ {[...Object.values(selected.reviewerAssignments ?? {}), ...Object.values(selected.sponsorGates ?? {})].length === 0 ? <li className="py-8 text-center text-muted-foreground">当前没有 Reviewer / Sponsor 等待项</li> : [...Object.values(selected.reviewerAssignments ?? {}), ...Object.values(selected.sponsorGates ?? {})].map((wait) => (
231
+ <li key={wait.id} className="rounded-lg border p-3"><div className="flex items-center justify-between gap-2"><div><div className="font-medium">{wait.taskKey} · {wait.route}</div><code className="text-xs text-muted-foreground">{wait.id}</code></div><span className={`rounded-full border px-2 py-0.5 text-[11px] ${statusTone(wait.status)}`}>{wait.status}</span></div><div className="mt-2 flex flex-wrap gap-3 text-xs text-muted-foreground"><span>risk {wait.riskTier}</span><span>owner {wait.owner}</span><span>deadline {new Date(wait.deadline).toLocaleString()}</span></div></li>
232
+ ))}
233
+ </ul>}
234
+
235
+ </>
236
+ )}
237
+ </section>
238
+ </div>
239
+ </div>
240
+ );
241
+ }
242
+
243
+ function statusTone(status: string): string {
244
+ if (/completed|accepted|approved|passed|execution_completed/u.test(status)) return 'border-emerald-500/25 bg-emerald-500/5 text-emerald-700 dark:text-emerald-300';
245
+ if (/failed|lost|rejected|expired/u.test(status)) return 'border-red-500/25 bg-red-500/5 text-red-700 dark:text-red-300';
246
+ if (/blocked|replan|rework|cancell/u.test(status)) return 'border-amber-500/25 bg-amber-500/5 text-amber-700 dark:text-amber-300';
247
+ return 'border-sky-500/25 bg-sky-500/5 text-sky-700 dark:text-sky-300';
248
+ }
@@ -1,13 +1,13 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
- import { registerOrchestrationConsole } from './registerOrchestrationConsole.js';
2
+ import { registerWorkroomConsole } from './registerWorkroomConsole.js';
3
3
 
4
- describe('registerOrchestrationConsole', () => {
5
- it('registers orchestration route and tool', () => {
4
+ describe('registerWorkroomConsole', () => {
5
+ it('registers Workroom route and tool', () => {
6
6
  const addRoute = vi.fn();
7
7
  const addTool = vi.fn();
8
8
  const React = { createElement: vi.fn((type) => ({ type })) };
9
9
 
10
- registerOrchestrationConsole({
10
+ registerWorkroomConsole({
11
11
  React: React as never,
12
12
  addRoute,
13
13
  addPage: addRoute,
@@ -15,12 +15,12 @@ describe('registerOrchestrationConsole', () => {
15
15
  });
16
16
 
17
17
  expect(addRoute).toHaveBeenCalledWith(expect.objectContaining({
18
- path: '/console/orchestration',
19
- name: '编排',
18
+ path: '/console/workroom',
19
+ name: 'Workroom',
20
20
  }));
21
21
  expect(addTool).toHaveBeenCalledWith(expect.objectContaining({
22
- id: 'orchestration',
23
- path: '/console/orchestration',
22
+ id: 'workroom',
23
+ path: '/console/workroom',
24
24
  }));
25
25
  });
26
26
  });
@@ -0,0 +1,17 @@
1
+ import type { PluginRegisterHostApi } from '@zhin.js/contract';
2
+ import WorkroomRunsPage from './WorkroomRunsPage.js';
3
+
4
+ /** Read-only Workroom Run/Task/Assignment projection. */
5
+ export function registerWorkroomConsole(api: PluginRegisterHostApi): void {
6
+ api.addRoute({
7
+ path: '/console/workroom',
8
+ name: 'Workroom',
9
+ element: api.React.createElement(WorkroomRunsPage),
10
+ meta: { order: 40, group: 'Agent' },
11
+ });
12
+ api.addTool({
13
+ id: 'workroom',
14
+ name: 'Workroom',
15
+ path: '/console/workroom',
16
+ });
17
+ }
@@ -0,0 +1,7 @@
1
+ import type { ConsoleEventHistoryPage, ConsoleEventHistoryQuery } from '@zhin.js/console-protocol';
2
+ export interface FetchConsoleEventHistoryOptions {
3
+ readonly signal?: AbortSignal;
4
+ }
5
+ /** Read a typed page from the Host's bounded Console event journal. */
6
+ export declare function fetchConsoleEventHistory(query?: ConsoleEventHistoryQuery, options?: FetchConsoleEventHistoryOptions): Promise<ConsoleEventHistoryPage>;
7
+ //# sourceMappingURL=console-events.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"console-events.d.ts","sourceRoot":"","sources":["../client/console-events.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,uBAAuB,EACvB,wBAAwB,EACzB,MAAM,2BAA2B,CAAC;AAGnC,MAAM,WAAW,+BAA+B;IAC9C,QAAQ,CAAC,MAAM,CAAC,EAAE,WAAW,CAAC;CAC/B;AAED,uEAAuE;AACvE,wBAAsB,wBAAwB,CAC5C,KAAK,GAAE,wBAA6B,EACpC,OAAO,GAAE,+BAAoC,GAC5C,OAAO,CAAC,uBAAuB,CAAC,CAsBlC"}
@@ -0,0 +1,26 @@
1
+ import { getStoredToken, resolveApiUrl } from './websocket/remote-settings.js';
2
+ /** Read a typed page from the Host's bounded Console event journal. */
3
+ export async function fetchConsoleEventHistory(query = {}, options = {}) {
4
+ const params = new URLSearchParams();
5
+ if (query.runtimeId)
6
+ params.set('runtimeId', query.runtimeId);
7
+ if (query.after !== undefined)
8
+ params.set('after', String(query.after));
9
+ if (query.limit !== undefined)
10
+ params.set('limit', String(query.limit));
11
+ const token = getStoredToken();
12
+ const headers = { Accept: 'application/json' };
13
+ if (token)
14
+ headers.Authorization = `Bearer ${token}`;
15
+ const suffix = params.size ? `?${params}` : '';
16
+ const response = await fetch(resolveApiUrl(`/api/events/history${suffix}`), {
17
+ headers,
18
+ signal: options.signal,
19
+ });
20
+ const body = await response.json();
21
+ if (!response.ok || body.success !== true || !body.data) {
22
+ throw new Error(body.error ?? `Console event history failed: HTTP ${response.status}`);
23
+ }
24
+ return body.data;
25
+ }
26
+ //# sourceMappingURL=console-events.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"console-events.js","sourceRoot":"","sources":["../client/console-events.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,gCAAgC,CAAC;AAM/E,uEAAuE;AACvE,MAAM,CAAC,KAAK,UAAU,wBAAwB,CAC5C,QAAkC,EAAE,EACpC,UAA2C,EAAE;IAE7C,MAAM,MAAM,GAAG,IAAI,eAAe,EAAE,CAAC;IACrC,IAAI,KAAK,CAAC,SAAS;QAAE,MAAM,CAAC,GAAG,CAAC,WAAW,EAAE,KAAK,CAAC,SAAS,CAAC,CAAC;IAC9D,IAAI,KAAK,CAAC,KAAK,KAAK,SAAS;QAAE,MAAM,CAAC,GAAG,CAAC,OAAO,EAAE,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC;IACxE,IAAI,KAAK,CAAC,KAAK,KAAK,SAAS;QAAE,MAAM,CAAC,GAAG,CAAC,OAAO,EAAE,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC;IACxE,MAAM,KAAK,GAAG,cAAc,EAAE,CAAC;IAC/B,MAAM,OAAO,GAA2B,EAAE,MAAM,EAAE,kBAAkB,EAAE,CAAC;IACvE,IAAI,KAAK;QAAE,OAAO,CAAC,aAAa,GAAG,UAAU,KAAK,EAAE,CAAC;IACrD,MAAM,MAAM,GAAG,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;IAC/C,MAAM,QAAQ,GAAG,MAAM,KAAK,CAAC,aAAa,CAAC,sBAAsB,MAAM,EAAE,CAAC,EAAE;QAC1E,OAAO;QACP,MAAM,EAAE,OAAO,CAAC,MAAM;KACvB,CAAC,CAAC;IACH,MAAM,IAAI,GAAG,MAAM,QAAQ,CAAC,IAAI,EAI/B,CAAC;IACF,IAAI,CAAC,QAAQ,CAAC,EAAE,IAAI,IAAI,CAAC,OAAO,KAAK,IAAI,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC;QACxD,MAAM,IAAI,KAAK,CAAC,IAAI,CAAC,KAAK,IAAI,sCAAsC,QAAQ,CAAC,MAAM,EAAE,CAAC,CAAC;IACzF,CAAC;IACD,OAAO,IAAI,CAAC,IAAI,CAAC;AACnB,CAAC"}
package/dist/index.d.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  export * from "./types";
2
- export { ENDPOINT_RPC, ENDPOINT_MANAGEMENT_CAPABILITIES, INBOX_RPC, SIDE_EVENT_PUSH, SIDE_EVENT_RPC, normalizeConsolePushMessage, normalizeConsolePushType, parseConsoleInboxEvent, type ConsoleInboxEvent, type ConsoleInboxEventKind, type ConsoleEndpointPhase, type ConsoleEndpointSummary, type EndpointManagementCapability, } from "@zhin.js/console-protocol";
2
+ export { ENDPOINT_RPC, CONSOLE_EVENT_RECOVERY_GAP_EVENT, ENDPOINT_MANAGEMENT_CAPABILITIES, INBOX_RPC, SIDE_EVENT_PUSH, SIDE_EVENT_RPC, normalizeConsolePushMessage, normalizeConsolePushType, parseConsoleInboxEvent, type ConsoleInboxEvent, type ConsoleInboxEventKind, type ConsoleInboxNoticeRow, type ConsoleInboxNoticesQuery, type ConsoleInboxNoticesResult, type ConsoleEndpointPhase, type ConsoleEndpointSummary, type ConsoleEventActor, type ConsoleEventChannel, type EndpointManagementCapability, parseConsoleSseFrame, type ConsoleEventDelivery, type ConsoleEventData, type ConsoleEndpointEventData, type ConsoleEventEnvelope, type ConsoleEventHistoryPage, type ConsoleEventHistoryQuery, type ConsoleEventPayloadMap, type ConsoleMessageEventData, type ConsoleNoticeEventData, type ConsoleRequestEventData, type KnownConsoleEventType, type KnownConsoleEventEnvelope, type ParsedConsoleSseFrame, } from "@zhin.js/console-protocol";
3
+ export { fetchConsoleEventHistory, type FetchConsoleEventHistoryOptions, } from './console-events.js';
3
4
  export { resolveMediaSrc, pickMediaRawUrl, type MediaKind } from "./mediaSrc";
5
+ export { MarkdownContent, CodeBlock, type MarkdownContentProps, } from "./message-content/MarkdownContent.js";
6
+ export { parseMarkdown, parseMarkdownInline, highlightCodeLine, isSafeMarkdownHref, type MarkdownBlock, type MarkdownInline, type CodeToken, type CodeTokenKind, } from "./message-content/markdown.js";
4
7
  export { segmentsForImDelivery, segmentsForAgentPanel } from "./segments.js";
5
8
  export { app, type ConsoleApp, type AddRouteInput, type AddToolInput, type ConsoleRouteRecord, type RouteTreeNode, type ToolTreeNode, type ConsoleRouteRenderer, } from "./app";
6
9
  export * from "./websocket";
@@ -9,7 +12,7 @@ export { createRegistryStore, useRegistry, type RegistryStore, } from "./store/c
9
12
  export { cn } from "./console-utils/cn.js";
10
13
  export { apiFetch, getApiBase, getToken, resolveApiUrl, resolveWebSocketUrl, } from "./console-utils/remoteApi.js";
11
14
  export { fetchConsoleEntries, createPluginRegisterHostApi, getRegisterFn, loadConsoleEntries, registerConsolePluginsFromEntries, resolveEntryRegister, type CreatePluginRegisterHostApiOptions, type FetchConsoleEntriesOptions, type LoadConsoleEntriesOptions, } from "./bootstrap/loadConsoleEntries.js";
12
- export { registerOrchestrationConsole } from "./orchestration/registerOrchestrationConsole.js";
13
- export { default as OrchestrationRunsPage } from "./orchestration/OrchestrationRunsPage.js";
15
+ export { registerWorkroomConsole } from "./workroom/registerWorkroomConsole.js";
16
+ export { default as WorkroomRunsPage } from "./workroom/WorkroomRunsPage.js";
14
17
  export { startAgentSession, continueAgentSession, subscribeAgentStream, iterateAgentStreamNdjson, foldAgentStreamNdjson, type AgentStreamClientOptions, type SubscribeAgentStreamOptions, } from "./agent-stream.js";
15
18
  //# sourceMappingURL=index.d.ts.map