@chenacc/agentbridge 0.0.0-stage → 0.1.1
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/.env.example +44 -0
- package/LICENSE +21 -0
- package/README.md +308 -2
- package/bin/agentbridge.js +7 -0
- package/docs/RELEASING.md +47 -0
- package/package.json +65 -4
- package/public/adapter-capabilities.js +29 -0
- package/public/app.js +2211 -0
- package/public/background.jpg +0 -0
- package/public/command-id.js +16 -0
- package/public/highlight.js +125 -0
- package/public/index.html +339 -0
- package/public/markdown.js +291 -0
- package/public/styles.css +1073 -0
- package/public/transcript.js +368 -0
- package/public/workspace-navigation.js +241 -0
- package/src/adapter-registry.js +23 -0
- package/src/adapters/capabilities.js +36 -0
- package/src/adapters/claude.js +493 -0
- package/src/adapters/codex.js +529 -0
- package/src/adapters/deepseek-harness.js +413 -0
- package/src/adapters/dsh-desktop.js +322 -0
- package/src/adapters/echo.js +57 -0
- package/src/adapters/process-utils.js +125 -0
- package/src/adapters/unavailable.js +30 -0
- package/src/adapters/zcode-native.js +40 -0
- package/src/attachments.js +187 -0
- package/src/cc-switch-models.js +86 -0
- package/src/cli.js +199 -0
- package/src/config.js +195 -0
- package/src/conversations/conversation-catalog.js +283 -0
- package/src/dsh-desktop-channels.js +460 -0
- package/src/event-store.js +330 -0
- package/src/notifier.js +266 -0
- package/src/pairing.js +167 -0
- package/src/server.js +964 -0
- package/src/session-manager.js +1355 -0
- package/src/workspaces/workspace-catalog.js +196 -0
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
import { renderMessageMarkdown } from "./markdown.js";
|
|
2
|
+
|
|
3
|
+
// Pure transcript logic, kept out of app.js so it can be tested directly with
|
|
4
|
+
// Node and the real Markdown renderer instead of only in a browser. Everything
|
|
5
|
+
// here is about one promise: the phone shows the whole conversation, no matter
|
|
6
|
+
// how many tokens the agent streamed.
|
|
7
|
+
|
|
8
|
+
// Real conversation keeps its place: token deltas are collapsed to one entry
|
|
9
|
+
// per turn, so completed messages and status changes are never squeezed out.
|
|
10
|
+
export const MAX_EVENT_HISTORY = 4000;
|
|
11
|
+
export const HISTORY_TRIM_CHUNK = 750;
|
|
12
|
+
|
|
13
|
+
function escapeHtml(value) {
|
|
14
|
+
return String(value ?? "").replace(/[&<>'"]/g, (character) => ({
|
|
15
|
+
"&": "&", "<": "<", ">": ">", "'": "'", '"': """,
|
|
16
|
+
})[character]);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function deltaKeyOf(event) {
|
|
20
|
+
return `${event.sessionId ?? ""}\u0000${event.turnId ?? ""}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function indexDeltas(events, index = new Map()) {
|
|
24
|
+
index.clear();
|
|
25
|
+
for (const event of events) {
|
|
26
|
+
if (event.kind === "message.delta") index.set(deltaKeyOf(event), event);
|
|
27
|
+
}
|
|
28
|
+
return index;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Token deltas are folded into a single cumulative entry per turn, matching the
|
|
32
|
+
// bridge. Without this a long turn's tokens fill the whole buffer and push the
|
|
33
|
+
// actual conversation out of it, which is why replies used to arrive missing or
|
|
34
|
+
// half-rendered.
|
|
35
|
+
export function absorbEvent(events, deltaIndex, event) {
|
|
36
|
+
if (event.kind !== "message.delta") {
|
|
37
|
+
events.push(event);
|
|
38
|
+
return event;
|
|
39
|
+
}
|
|
40
|
+
const text = String(event.payload?.text ?? "");
|
|
41
|
+
const existing = deltaIndex.get(deltaKeyOf(event));
|
|
42
|
+
if (existing) {
|
|
43
|
+
const current = String(existing.payload?.text ?? "");
|
|
44
|
+
const offset = Number.isInteger(event.payload?.offset) ? event.payload.offset : current.length;
|
|
45
|
+
// `offset` is the character position this chunk starts at, so a merged or
|
|
46
|
+
// replayed chunk rewrites the same text instead of duplicating it.
|
|
47
|
+
const merged = offset >= current.length ? current + text : current.slice(0, offset) + text;
|
|
48
|
+
existing.payload = { ...existing.payload, text: merged, offset: 0 };
|
|
49
|
+
existing.time = event.time;
|
|
50
|
+
return existing;
|
|
51
|
+
}
|
|
52
|
+
const entry = { ...event, payload: { ...event.payload, offset: 0 } };
|
|
53
|
+
deltaIndex.set(deltaKeyOf(event), entry);
|
|
54
|
+
events.push(entry);
|
|
55
|
+
return entry;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Trimming normally drops the oldest entries, which is exactly what a freshly
|
|
59
|
+
// fetched history page is. `keepSessionId` drops other conversations' oldest
|
|
60
|
+
// entries first, so switching to a quiet conversation cannot be undone a moment
|
|
61
|
+
// later by a busy one that is still streaming.
|
|
62
|
+
export function trimEvents(events, deltaIndex, { maxEvents = MAX_EVENT_HISTORY, trimChunk = HISTORY_TRIM_CHUNK, keepSessionId = null } = {}) {
|
|
63
|
+
if (events.length <= maxEvents) return false;
|
|
64
|
+
const target = events.length - maxEvents + trimChunk;
|
|
65
|
+
let removed = 0;
|
|
66
|
+
if (keepSessionId) {
|
|
67
|
+
for (let index = 0; index < events.length && removed < target; index += 1) {
|
|
68
|
+
if (events[index].sessionId === keepSessionId) continue;
|
|
69
|
+
events.splice(index, 1);
|
|
70
|
+
index -= 1;
|
|
71
|
+
removed += 1;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (removed < target) events.splice(0, target - removed);
|
|
75
|
+
indexDeltas(events, deltaIndex);
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function foldDeltas(relevant) {
|
|
80
|
+
const completedTurns = new Set();
|
|
81
|
+
const textByTurn = new Map();
|
|
82
|
+
for (const event of relevant) {
|
|
83
|
+
if (event.kind === "message.completed" && event.payload.role === "assistant") completedTurns.add(event.turnId);
|
|
84
|
+
}
|
|
85
|
+
for (const event of relevant) {
|
|
86
|
+
if (event.kind !== "message.delta") continue;
|
|
87
|
+
const text = String(event.payload?.text ?? "");
|
|
88
|
+
const current = textByTurn.get(event.turnId) ?? "";
|
|
89
|
+
const offset = Number.isInteger(event.payload?.offset) ? event.payload.offset : current.length;
|
|
90
|
+
textByTurn.set(event.turnId, offset >= current.length ? current + text : current.slice(0, offset) + text);
|
|
91
|
+
}
|
|
92
|
+
return { textByTurn, completedTurns };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function entryKey(event) {
|
|
96
|
+
return event.kind === "message.delta" ? `delta:${event.sessionId}:${event.turnId}` : `${event.kind}:${event.eventId}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Live events and per-session history describe the same seq space, and a switch
|
|
100
|
+
// can overlap a stream that is still arriving. De-duplicating by eventId and
|
|
101
|
+
// re-sorting by seq keeps both sources honest: no entry is rendered twice, and
|
|
102
|
+
// a fetched page slots in ahead of whatever already arrived live.
|
|
103
|
+
export function mergeHistory(events, deltaIndex, incoming, knownIds = null) {
|
|
104
|
+
if (!Array.isArray(incoming) || !incoming.length) return 0;
|
|
105
|
+
// `knownIds` is the caller's live set of every id in the buffer; without it the
|
|
106
|
+
// set is derived from `events`, which is enough for a one-shot merge but not for
|
|
107
|
+
// a buffer that keeps growing from the live stream.
|
|
108
|
+
const known = knownIds instanceof Set ? knownIds : new Set(events.map((event) => event.eventId).filter(Boolean));
|
|
109
|
+
let added = 0;
|
|
110
|
+
for (const event of incoming) {
|
|
111
|
+
if (!event || !Number.isSafeInteger(event.seq)) continue;
|
|
112
|
+
if (event.eventId && known.has(event.eventId)) continue;
|
|
113
|
+
if (event.eventId) known.add(event.eventId);
|
|
114
|
+
events.push(event);
|
|
115
|
+
added += 1;
|
|
116
|
+
}
|
|
117
|
+
if (!added) return 0;
|
|
118
|
+
events.sort((a, b) => a.seq - b.seq);
|
|
119
|
+
indexDeltas(events, deltaIndex);
|
|
120
|
+
return added;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const DEFAULT_STATUS_LABELS = {
|
|
124
|
+
"session.created": "会话已创建",
|
|
125
|
+
"session.ready": "Agent 已就绪",
|
|
126
|
+
"turn.started": "本轮已开始",
|
|
127
|
+
"turn.completed": "本轮已完成",
|
|
128
|
+
"turn.cancelled": "本轮已取消",
|
|
129
|
+
"control.granted": "输入控制权已更新",
|
|
130
|
+
"turn.stop_requested": "已请求停止本轮",
|
|
131
|
+
"model.changed": "模型已切换",
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const STATUS_KINDS = [
|
|
135
|
+
"session.created", "session.ready", "turn.started", "turn.completed",
|
|
136
|
+
"turn.cancelled", "control.granted", "turn.stop_requested", "model.changed",
|
|
137
|
+
];
|
|
138
|
+
|
|
139
|
+
// A question answered on the desktop, or skipped on the phone, still has to
|
|
140
|
+
// leave a readable trace: the card above it can never be answered again.
|
|
141
|
+
const RESOLVED_LABELS = {
|
|
142
|
+
answered: "已回答",
|
|
143
|
+
cancelled: "已跳过",
|
|
144
|
+
desktop: "已在电脑上回答",
|
|
145
|
+
expired: "超时未答,已自动拒绝",
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const APPROVAL_RESOLVED_LABELS = {
|
|
149
|
+
approve: "已批准(仅本次)",
|
|
150
|
+
deny: "已拒绝",
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const entryCache = new WeakMap();
|
|
154
|
+
|
|
155
|
+
// Math rendering arrives one tick after the first paint (KaTeX is fetched only
|
|
156
|
+
// when the transcript needs it). Bumping the generation invalidates the memoised
|
|
157
|
+
// HTML so the formulas re-render instead of staying as raw TeX.
|
|
158
|
+
let renderGeneration = 0;
|
|
159
|
+
|
|
160
|
+
export function bumpRenderGeneration() {
|
|
161
|
+
renderGeneration += 1;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// `key` lets one event render two ways (a question card before and after it is
|
|
165
|
+
// answered): the memoised HTML is only reused when the key still matches.
|
|
166
|
+
function cachedEntry(cache, event, key) {
|
|
167
|
+
const cached = cache.get(event);
|
|
168
|
+
if (!cached || cached.generation !== renderGeneration) return null;
|
|
169
|
+
if (key !== undefined && cached.key !== key) return null;
|
|
170
|
+
return cached.entry;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function rememberEntry(cache, event, entry, key) {
|
|
174
|
+
cache.set(event, { generation: renderGeneration, entry, key });
|
|
175
|
+
return entry;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function formatTime(value, formatter) {
|
|
179
|
+
return formatter ? formatter(value) : new Date(value).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Attachments the phone sent with a message.
|
|
184
|
+
*
|
|
185
|
+
* Images render as a thumbnail that opens full size; anything else renders as a
|
|
186
|
+
* named chip. Both point at the bridge's own preview URL, so the picture is the
|
|
187
|
+
* real file the agent received, not a local copy.
|
|
188
|
+
*/
|
|
189
|
+
function attachmentsHtml(list) {
|
|
190
|
+
if (!Array.isArray(list) || !list.length) return "";
|
|
191
|
+
const items = list.map((attachment) => {
|
|
192
|
+
const id = escapeHtml(String(attachment?.id ?? ""));
|
|
193
|
+
const name = escapeHtml(String(attachment?.name ?? "附件"));
|
|
194
|
+
const size = Number(attachment?.bytes) > 0 ? `${Math.max(1, Math.round(Number(attachment.bytes) / 1024))} KB` : "";
|
|
195
|
+
const url = `/api/attachments/${encodeURIComponent(String(attachment?.id ?? ""))}`;
|
|
196
|
+
if (attachment?.isImage) {
|
|
197
|
+
return `<a class="attachment-thumb" href="${url}" target="_blank" rel="noreferrer" data-attachment="${id}">
|
|
198
|
+
<img src="${url}" alt="${name}" loading="lazy" decoding="async">
|
|
199
|
+
<span class="attachment-caption">${name}${size ? ` · ${size}` : ""}</span>
|
|
200
|
+
</a>`;
|
|
201
|
+
}
|
|
202
|
+
return `<a class="attachment-file" href="${url}" target="_blank" rel="noreferrer" data-attachment="${id}">📎 ${name}${size ? ` · ${size}` : ""}</a>`;
|
|
203
|
+
}).join("");
|
|
204
|
+
return `<div class="message-attachments">${items}</div>`;
|
|
205
|
+
}
|
|
206
|
+
function entryFor(event, label, text, className, supportsMarkdown, { formatMessageTime, cache, math }) {
|
|
207
|
+
const stable = event.kind !== "message.delta";
|
|
208
|
+
const cached = stable ? cachedEntry(cache, event) : null;
|
|
209
|
+
if (cached) return cached;
|
|
210
|
+
const content = supportsMarkdown
|
|
211
|
+
? renderMessageMarkdown(text, math ? { math } : undefined)
|
|
212
|
+
: `<p>${escapeHtml(String(text ?? ""))}</p>`;
|
|
213
|
+
const attachments = attachmentsHtml(event.payload?.attachments);
|
|
214
|
+
const entry = {
|
|
215
|
+
key: entryKey(event),
|
|
216
|
+
className,
|
|
217
|
+
html: `<time class="event-time">${formatTime(event.time, formatMessageTime)}</time><div class="event-body"><strong>${escapeHtml(label)}</strong><div class="message-markdown">${content}</div>${attachments}</div>`,
|
|
218
|
+
};
|
|
219
|
+
if (stable) rememberEntry(cache, event, entry);
|
|
220
|
+
return entry;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Tool calls get the two affordances a person actually needs: the command is
|
|
225
|
+
* copyable, and a long output collapses instead of pushing the conversation off the
|
|
226
|
+
* screen. Both work without JavaScript state — `<details>` is the disclosure.
|
|
227
|
+
*/
|
|
228
|
+
function toolEntry(event, label, text, options) {
|
|
229
|
+
const cached = cachedEntry(options.cache, event);
|
|
230
|
+
if (cached) return cached;
|
|
231
|
+
const body = String(text ?? "");
|
|
232
|
+
const long = body.length > 240;
|
|
233
|
+
const command = typeof event.payload?.command === "string" ? event.payload.command : "";
|
|
234
|
+
const copy = command
|
|
235
|
+
? `<button class="tool-copy" type="button" data-copy-tool="${escapeHtml(body)}">复制命令</button>`
|
|
236
|
+
: "";
|
|
237
|
+
const shown = long
|
|
238
|
+
? `<details class="tool-output"><summary>展开完整输出(${body.length} 字符)</summary><pre>${escapeHtml(body)}</pre></details>`
|
|
239
|
+
: `<div class="message-markdown"><p>${escapeHtml(body)}</p></div>`;
|
|
240
|
+
const entry = {
|
|
241
|
+
key: entryKey(event),
|
|
242
|
+
className: "tool",
|
|
243
|
+
html: `<time class="event-time">${formatTime(event.time, options.formatMessageTime)}</time><div class="event-body"><strong>${escapeHtml(label)}</strong>${copy}${shown}</div>`,
|
|
244
|
+
};
|
|
245
|
+
return rememberEntry(options.cache, event, entry);
|
|
246
|
+
}
|
|
247
|
+
function approvalEntry(event, options) {
|
|
248
|
+
const cached = cachedEntry(options.cache, event);
|
|
249
|
+
if (cached) return cached;
|
|
250
|
+
const request = JSON.stringify(event.payload.request ?? {}, null, 2).slice(0, 1600);
|
|
251
|
+
const entry = {
|
|
252
|
+
key: entryKey(event),
|
|
253
|
+
className: "",
|
|
254
|
+
html: `<time class="event-time">${formatTime(event.time, options.formatMessageTime)}</time><div class="event-body approval-entry"><strong>需要你的批准</strong><p>${escapeHtml(event.payload.method)}\n${escapeHtml(request)}</p><div class="approval-actions"><button class="approve" data-approval="${event.payload.approvalId}" data-decision="approve" type="button">仅本次批准</button><button class="deny" data-approval="${event.payload.approvalId}" data-decision="deny" type="button">拒绝</button></div></div>`,
|
|
255
|
+
};
|
|
256
|
+
return rememberEntry(options.cache, event, entry);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// An agent that stops to ask a question is blocked until a human answers, so a
|
|
260
|
+
// card of dead text would strand the turn. Options render as real inputs and the
|
|
261
|
+
// submit button posts one batch, in the order the questions were asked.
|
|
262
|
+
//
|
|
263
|
+
// Once the question is resolved the card must stop looking answerable: it used to keep
|
|
264
|
+
// its buttons forever, so a reload offered a card whose submit could only 409. The card
|
|
265
|
+
// is folded into a settled form (options as static text, no buttons, outcome named).
|
|
266
|
+
function inputEntry(event, options, settledOutcome) {
|
|
267
|
+
const cacheKey = settledOutcome ? `${settledOutcome}:${entryKey(event)}` : entryKey(event);
|
|
268
|
+
const cached = cachedEntry(options.cache, event, cacheKey);
|
|
269
|
+
if (cached) return cached;
|
|
270
|
+
const settled = Boolean(settledOutcome);
|
|
271
|
+
const requestId = escapeHtml(event.payload.requestId);
|
|
272
|
+
const questions = Array.isArray(event.payload.questions) ? event.payload.questions : [];
|
|
273
|
+
const blocks = questions.map((question, index) => {
|
|
274
|
+
const questionId = escapeHtml(question.id);
|
|
275
|
+
const multi = question.multiSelect === true;
|
|
276
|
+
const options = Array.isArray(question.options) ? question.options : [];
|
|
277
|
+
const choices = options.map((option) => {
|
|
278
|
+
const label = String(option.label ?? "");
|
|
279
|
+
const description = option.description
|
|
280
|
+
? `<small class="input-option-note">${escapeHtml(option.description)}</small>`
|
|
281
|
+
: "";
|
|
282
|
+
if (settled) {
|
|
283
|
+
return `<span class="input-option settled"><span>${escapeHtml(label)}${description}</span></span>`;
|
|
284
|
+
}
|
|
285
|
+
return `<label class="input-option"><input type="${multi ? "checkbox" : "radio"}" name="q-${requestId}-${index}" value="${escapeHtml(label)}" data-input-option> <span>${escapeHtml(label)}${description}</span></label>`;
|
|
286
|
+
}).join("");
|
|
287
|
+
return `<div class="input-question" data-input-question="${questionId}"${multi ? ' data-input-multi="1"' : ""}>`
|
|
288
|
+
+ (question.header ? `<p class="input-header">${escapeHtml(question.header)}</p>` : "")
|
|
289
|
+
+ `<p class="input-prompt">${escapeHtml(question.question)}</p>`
|
|
290
|
+
+ (choices ? `<div class="input-options">${choices}</div>` : "")
|
|
291
|
+
+ (settled
|
|
292
|
+
? ""
|
|
293
|
+
: `<input class="input-custom" type="${question.secret === true ? "password" : "text"}" data-input-custom placeholder="${options.length ? "或者直接输入回答" : "输入你的回答"}">`)
|
|
294
|
+
+ "</div>";
|
|
295
|
+
}).join("");
|
|
296
|
+
const footnote = settled ? `<p class="input-settled">${escapeHtml(RESOLVED_LABELS[settledOutcome] ?? "已处理")}</p>` : "";
|
|
297
|
+
const entry = {
|
|
298
|
+
// The key changes with the settled state: the transcript diff only rewrites an
|
|
299
|
+
// entry when its key changes, so a card that resolves in place would otherwise
|
|
300
|
+
// keep offering its buttons until the whole list re-rendered.
|
|
301
|
+
key: settled ? `settled:${entryKey(event)}` : entryKey(event),
|
|
302
|
+
className: settled ? "settled" : "",
|
|
303
|
+
html: `<time class="event-time">${formatTime(event.time, options.formatMessageTime)}</time><div class="event-body input-entry${settled ? " is-settled" : ""}"><strong>${settled ? "Agent 的提问" : "Agent 在等你回答"}</strong>${blocks}${footnote}`
|
|
304
|
+
+ (settled ? "" : `<div class="approval-actions"><button class="approve" data-input-submit="${requestId}" type="button">提交回答</button><button class="deny" data-input-cancel="${requestId}" type="button">跳过</button></div>`)
|
|
305
|
+
+ "</div>",
|
|
306
|
+
};
|
|
307
|
+
return rememberEntry(options.cache, event, entry, cacheKey);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Turn a session's events into the list of transcript entries to display. */
|
|
311
|
+
export function buildEntries(relevant, options = {}) {
|
|
312
|
+
const statusLabel = options.statusLabel ?? ((kind) => DEFAULT_STATUS_LABELS[kind] ?? kind);
|
|
313
|
+
const settings = { cache: entryCache, formatMessageTime: options.formatMessageTime, math: options.math };
|
|
314
|
+
const { textByTurn, completedTurns } = foldDeltas(relevant);
|
|
315
|
+
// A question's outcome arrives as a later event, so it has to be known before the
|
|
316
|
+
// card that asked it is rendered — otherwise the card keeps offering buttons that
|
|
317
|
+
// can only fail once the desktop has already answered.
|
|
318
|
+
const settledInputs = new Map();
|
|
319
|
+
for (const event of relevant) {
|
|
320
|
+
if (event.kind === "input.resolved" && event.payload?.requestId) {
|
|
321
|
+
settledInputs.set(event.payload.requestId, event.payload.outcome ?? "answered");
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const renderedDeltas = new Set();
|
|
326
|
+
const entries = [];
|
|
327
|
+
for (const event of relevant) {
|
|
328
|
+
if (event.kind === "message.delta") {
|
|
329
|
+
if (completedTurns.has(event.turnId) || renderedDeltas.has(event.turnId)) continue;
|
|
330
|
+
renderedDeltas.add(event.turnId);
|
|
331
|
+
entries.push(entryFor(event, "Agent 正在回复", textByTurn.get(event.turnId) ?? "", "assistant", true, settings));
|
|
332
|
+
} else if (event.kind === "message.completed") {
|
|
333
|
+
// An assistant message that carried only tool calls has no text. Rendering it makes
|
|
334
|
+
// an empty "Agent" bubble between the tool rows is pure noise, so skip it — unless it
|
|
335
|
+
// brought attachments, which are worth a row on their own.
|
|
336
|
+
const messageText = String(event.payload.text ?? "");
|
|
337
|
+
const messageAttachments = Array.isArray(event.payload.attachments) ? event.payload.attachments : [];
|
|
338
|
+
if (!messageText.trim() && messageAttachments.length === 0) continue;
|
|
339
|
+
entries.push(entryFor(event, event.payload.role === "user" ? "你" : "Agent", messageText, event.payload.role, true, settings));
|
|
340
|
+
} else if (event.kind === "tool.started" || event.kind === "tool.completed") {
|
|
341
|
+
const label = event.kind.endsWith("started") ? "工具开始" : "工具完成";
|
|
342
|
+
const detail = event.payload.command ? String(event.payload.command) : `${event.payload.type ?? "tool"}${event.payload.status ? ` · ${event.payload.status}` : ""}`;
|
|
343
|
+
entries.push(toolEntry(event, label, detail, settings));
|
|
344
|
+
} else if (event.kind === "approval.requested") {
|
|
345
|
+
entries.push(approvalEntry(event, settings));
|
|
346
|
+
} else if (event.kind === "input.requested") {
|
|
347
|
+
entries.push(inputEntry(event, settings, settledInputs.get(event.payload?.requestId)));
|
|
348
|
+
} else if (event.kind === "input.resolved") {
|
|
349
|
+
entries.push(entryFor(event, "提问", RESOLVED_LABELS[event.payload?.outcome] ?? "已处理", "tool", false, settings));
|
|
350
|
+
} else if (event.kind === "approval.resolved") {
|
|
351
|
+
entries.push(entryFor(
|
|
352
|
+
event,
|
|
353
|
+
"审批",
|
|
354
|
+
event.payload?.auto
|
|
355
|
+
? `已自动放行(限时策略)`
|
|
356
|
+
: APPROVAL_RESOLVED_LABELS[event.payload?.decision] ?? "已处理",
|
|
357
|
+
"tool",
|
|
358
|
+
false,
|
|
359
|
+
settings,
|
|
360
|
+
));
|
|
361
|
+
} else if (event.kind === "turn.failed" || event.kind === "session.failed" || event.kind === "adapter.exited") {
|
|
362
|
+
entries.push(entryFor(event, "执行异常", event.payload.error ?? event.payload.stderr ?? event.kind, "error", false, settings));
|
|
363
|
+
} else if (STATUS_KINDS.includes(event.kind)) {
|
|
364
|
+
entries.push(entryFor(event, "状态", statusLabel(event.kind), "tool", false, settings));
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return entries;
|
|
368
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
function workspaceIdForSession(session, workspaces) {
|
|
2
|
+
if (!session) return null;
|
|
3
|
+
if (session.workspaceId && workspaces.some((workspace) => workspace.id === session.workspaceId)) return session.workspaceId;
|
|
4
|
+
return workspaces.find((workspace) => workspace.alias === session.projectAlias)?.id ?? null;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function sessionsForWorkspace(sessions, workspaces, workspaceId) {
|
|
8
|
+
if (!workspaceId) return [];
|
|
9
|
+
return sessions.filter((session) => workspaceIdForSession(session, workspaces) === workspaceId);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function resolveWorkspaceNavigation({
|
|
13
|
+
workspaces = [],
|
|
14
|
+
sessions = [],
|
|
15
|
+
selectedWorkspaceId = null,
|
|
16
|
+
selectedSessionId = null,
|
|
17
|
+
} = {}) {
|
|
18
|
+
const enabledWorkspaces = workspaces.filter((workspace) => workspace.enabled !== false);
|
|
19
|
+
const selectedSession = sessions.find((session) => session.id === selectedSessionId) ?? null;
|
|
20
|
+
const selectedSessionWorkspaceId = workspaceIdForSession(selectedSession, enabledWorkspaces);
|
|
21
|
+
const requestedWorkspace = enabledWorkspaces.find((workspace) => workspace.id === selectedWorkspaceId) ?? null;
|
|
22
|
+
const workspaceId = selectedSessionWorkspaceId ?? requestedWorkspace?.id ?? enabledWorkspaces[0]?.id ?? null;
|
|
23
|
+
const workspaceSessions = sessionsForWorkspace(sessions, enabledWorkspaces, workspaceId);
|
|
24
|
+
const sessionId = selectedSessionWorkspaceId === workspaceId
|
|
25
|
+
? selectedSession.id
|
|
26
|
+
: workspaceSessions[0]?.id ?? null;
|
|
27
|
+
return { workspaceId, sessionId };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function discoverableAdapters(adapters = []) {
|
|
31
|
+
return adapters.filter((adapter) => (
|
|
32
|
+
adapter?.capabilities?.discover === true
|
|
33
|
+
&& adapter?.availability?.available === true
|
|
34
|
+
&& adapter?.availability?.supported !== false
|
|
35
|
+
));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// How an Agent's row is labelled and ranked in the drawer. "可以新建" is the
|
|
39
|
+
// strongest claim a row can make, so those sort first; an Agent that can only be
|
|
40
|
+
// attached from the desktop is useful but cannot open a conversation by itself,
|
|
41
|
+
// and an uninstalled Agent must say so instead of offering a dead end.
|
|
42
|
+
const CAPABILITY_RANK = { create: 0, attach: 1, unavailable: 2 };
|
|
43
|
+
|
|
44
|
+
function drawerCapability(adapter) {
|
|
45
|
+
const availability = adapter?.availability ?? {};
|
|
46
|
+
const capabilities = adapter?.capabilities ?? {};
|
|
47
|
+
const usable = availability.available === true && availability.supported !== false;
|
|
48
|
+
if (!usable) return "unavailable";
|
|
49
|
+
return capabilities.create === true ? "create" : capabilities.attach === true ? "attach" : "unavailable";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const CAPABILITY_LABEL = { create: "可新建", attach: "仅桌面附着", unavailable: "未安装" };
|
|
53
|
+
|
|
54
|
+
function lastActiveOf(session) {
|
|
55
|
+
const value = Date.parse(session?.lastActiveAt ?? session?.updatedAt ?? "");
|
|
56
|
+
return Number.isFinite(value) ? value : 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function workspaceIdOf(session, workspaces) {
|
|
60
|
+
if (!session) return null;
|
|
61
|
+
if (session.workspaceId && workspaces.some((workspace) => workspace.id === session.workspaceId)) return session.workspaceId;
|
|
62
|
+
return workspaces.find((workspace) => workspace.alias === session.projectAlias)?.id ?? null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function matchesQuery(query, ...fields) {
|
|
66
|
+
if (!query) return true;
|
|
67
|
+
return fields.some((field) => String(field ?? "").toLowerCase().includes(query));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Build the Agent → project → conversation tree the drawer renders.
|
|
72
|
+
*
|
|
73
|
+
* Structure follows how people actually choose: which tool first, then which
|
|
74
|
+
* project, then which conversation. Two rules keep that from becoming three taps
|
|
75
|
+
* on every visit: an Agent that only has one project skips the project row
|
|
76
|
+
* (`skipProjectRows`), and a search expands every path it matched. Everything is
|
|
77
|
+
* derived here so the drawer stays a renderer and this stays testable.
|
|
78
|
+
*/
|
|
79
|
+
export function buildAgentDrawer({
|
|
80
|
+
sessions = [],
|
|
81
|
+
workspaces = [],
|
|
82
|
+
adapters = [],
|
|
83
|
+
selectedSessionId = null,
|
|
84
|
+
query = "",
|
|
85
|
+
// `null` means "nothing remembered yet"; an empty array is a real state the
|
|
86
|
+
// user produced by collapsing everything.
|
|
87
|
+
expandedAgents = null,
|
|
88
|
+
// Projects default to open once their Agent is open, so a project only needs
|
|
89
|
+
// remembering when the user closed it. Tracking the exceptions instead of the
|
|
90
|
+
// open set is what keeps "☰ → Agent → conversation" two taps.
|
|
91
|
+
collapsedProjects = [],
|
|
92
|
+
} = {}) {
|
|
93
|
+
const normalizedQuery = String(query ?? "").trim().toLowerCase();
|
|
94
|
+
const enabledWorkspaces = workspaces.filter((workspace) => workspace.enabled !== false);
|
|
95
|
+
const expandedAgentSet = new Set(Array.isArray(expandedAgents) ? expandedAgents : []);
|
|
96
|
+
const collapsedProjectSet = new Set(Array.isArray(collapsedProjects) ? collapsedProjects : []);
|
|
97
|
+
|
|
98
|
+
const groups = adapters.flatMap((adapter) => {
|
|
99
|
+
const capability = drawerCapability(adapter);
|
|
100
|
+
const agentSessions = sessions
|
|
101
|
+
.filter((session) => session.adapterId === adapter.id)
|
|
102
|
+
.sort((a, b) => lastActiveOf(b) - lastActiveOf(a));
|
|
103
|
+
if (!agentSessions.length && capability === "unavailable") {
|
|
104
|
+
// An unavailable Agent with nothing in it is a footnote, not a section.
|
|
105
|
+
return [{
|
|
106
|
+
id: adapter.id,
|
|
107
|
+
label: adapter.label ?? adapter.id,
|
|
108
|
+
capability,
|
|
109
|
+
capabilityLabel: CAPABILITY_LABEL[capability],
|
|
110
|
+
note: adapter.availability?.note ?? null,
|
|
111
|
+
sessionCount: 0,
|
|
112
|
+
expanded: false,
|
|
113
|
+
workspaces: [],
|
|
114
|
+
empty: true,
|
|
115
|
+
}];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const projects = enabledWorkspaces.flatMap((workspace) => {
|
|
119
|
+
const projectSessions = agentSessions.filter((session) => workspaceIdOf(session, enabledWorkspaces) === workspace.id);
|
|
120
|
+
if (!projectSessions.length) return [];
|
|
121
|
+
return [{
|
|
122
|
+
id: workspace.id,
|
|
123
|
+
name: workspace.name ?? workspace.alias,
|
|
124
|
+
alias: workspace.alias ?? workspace.name,
|
|
125
|
+
sessionCount: projectSessions.length,
|
|
126
|
+
sessions: projectSessions.map((session) => ({
|
|
127
|
+
id: session.id,
|
|
128
|
+
title: session.title ?? "未命名会话",
|
|
129
|
+
status: session.status ?? "offline",
|
|
130
|
+
adapterId: session.adapterId,
|
|
131
|
+
workspaceName: workspace.name ?? workspace.alias,
|
|
132
|
+
lastActiveAt: session.lastActiveAt ?? session.updatedAt ?? null,
|
|
133
|
+
selected: session.id === selectedSessionId,
|
|
134
|
+
})),
|
|
135
|
+
}];
|
|
136
|
+
});
|
|
137
|
+
// Sessions whose workspace is gone still belong to the Agent; dropping them
|
|
138
|
+
// would hide real conversations behind a stale catalog entry.
|
|
139
|
+
const orphaned = agentSessions.filter((session) => !projects.some((project) => project.sessions.some((entry) => entry.id === session.id)));
|
|
140
|
+
if (orphaned.length) {
|
|
141
|
+
projects.push({
|
|
142
|
+
id: `unknown-${adapter.id}`,
|
|
143
|
+
name: "未归属项目",
|
|
144
|
+
alias: "unknown",
|
|
145
|
+
sessionCount: orphaned.length,
|
|
146
|
+
sessions: orphaned.map((session) => ({
|
|
147
|
+
id: session.id,
|
|
148
|
+
title: session.title ?? "未命名会话",
|
|
149
|
+
status: session.status ?? "offline",
|
|
150
|
+
adapterId: session.adapterId,
|
|
151
|
+
workspaceName: session.projectAlias ?? "未归属项目",
|
|
152
|
+
lastActiveAt: session.lastActiveAt ?? session.updatedAt ?? null,
|
|
153
|
+
selected: session.id === selectedSessionId,
|
|
154
|
+
})),
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const sessionCount = projects.reduce((total, project) => total + project.sessionCount, 0);
|
|
159
|
+
const agentMatches = matchesQuery(normalizedQuery, adapter.label, adapter.id);
|
|
160
|
+
const visibleProjects = projects.flatMap((project) => {
|
|
161
|
+
if (!normalizedQuery) return [project];
|
|
162
|
+
const projectMatches = matchesQuery(normalizedQuery, project.name, project.alias);
|
|
163
|
+
const matchingSessions = project.sessions.filter((session) => matchesQuery(normalizedQuery, session.title));
|
|
164
|
+
if (!agentMatches && !projectMatches && !matchingSessions.length) return [];
|
|
165
|
+
return [{
|
|
166
|
+
...project,
|
|
167
|
+
sessions: agentMatches || projectMatches ? project.sessions : matchingSessions,
|
|
168
|
+
sessionCount: agentMatches || projectMatches ? project.sessionCount : matchingSessions.length,
|
|
169
|
+
}];
|
|
170
|
+
});
|
|
171
|
+
if (normalizedQuery && !visibleProjects.length) return [];
|
|
172
|
+
|
|
173
|
+
const visibleSessionCount = visibleProjects.reduce((total, project) => total + project.sessionCount, 0);
|
|
174
|
+
// A search always reveals what it matched. Otherwise the remembered expansion
|
|
175
|
+
// decides — and when nothing is remembered yet, the Agent holding the selected
|
|
176
|
+
// conversation opens by itself, so returning to the app lands on a path the
|
|
177
|
+
// user can see rather than on a collapsed tree.
|
|
178
|
+
const remembered = expandedAgents !== null;
|
|
179
|
+
const holdsSelection = visibleProjects.some((project) => project.sessions.some((session) => session.selected));
|
|
180
|
+
const expanded = normalizedQuery
|
|
181
|
+
? true
|
|
182
|
+
: remembered
|
|
183
|
+
? expandedAgentSet.has(adapter.id)
|
|
184
|
+
: holdsSelection;
|
|
185
|
+
|
|
186
|
+
return [{
|
|
187
|
+
id: adapter.id,
|
|
188
|
+
label: adapter.label ?? adapter.id,
|
|
189
|
+
capability,
|
|
190
|
+
capabilityLabel: CAPABILITY_LABEL[capability],
|
|
191
|
+
note: adapter.availability?.note ?? null,
|
|
192
|
+
sessionCount: normalizedQuery ? visibleSessionCount : sessionCount,
|
|
193
|
+
expanded,
|
|
194
|
+
empty: false,
|
|
195
|
+
workspaces: visibleProjects.map((project) => ({
|
|
196
|
+
...project,
|
|
197
|
+
// The project row is only worth a tap when it actually splits something.
|
|
198
|
+
skipProjectRow: visibleProjects.length === 1,
|
|
199
|
+
expanded: normalizedQuery ? true : !collapsedProjectSet.has(project.id),
|
|
200
|
+
})),
|
|
201
|
+
}];
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const visible = groups.filter((group) => !group.empty || !normalizedQuery);
|
|
205
|
+
// Ordering follows what people are actually doing: the tool holding the most
|
|
206
|
+
// recent work comes first, then usable-but-empty tools, and uninstalled ones
|
|
207
|
+
// last. Ranking purely by "can create" would bury DSH Desktop — the one Agent
|
|
208
|
+
// that can only be attached — under tools nobody has used.
|
|
209
|
+
const recentOf = (group) => group.workspaces
|
|
210
|
+
.flatMap((project) => project.sessions)
|
|
211
|
+
.reduce((latest, session) => Math.max(latest, Date.parse(session.lastActiveAt ?? "") || 0), 0);
|
|
212
|
+
return visible.sort((a, b) => {
|
|
213
|
+
const groupRank = (group) => {
|
|
214
|
+
if (group.sessionCount > 0) return 0;
|
|
215
|
+
return group.capability === "unavailable" ? 2 : 1;
|
|
216
|
+
};
|
|
217
|
+
const rank = groupRank(a) - groupRank(b);
|
|
218
|
+
if (rank !== 0) return rank;
|
|
219
|
+
const recency = recentOf(b) - recentOf(a);
|
|
220
|
+
if (Number.isFinite(recency) && recency !== 0) return recency;
|
|
221
|
+
const capability = CAPABILITY_RANK[a.capability] - CAPABILITY_RANK[b.capability];
|
|
222
|
+
if (capability !== 0) return capability;
|
|
223
|
+
return String(a.label).localeCompare(String(b.label));
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** "3 分钟前" style label for a drawer row; older entries fall back to a date. */
|
|
228
|
+
export function relativeTimeLabel(value, now = Date.now()) {
|
|
229
|
+
const time = Date.parse(value ?? "");
|
|
230
|
+
if (!Number.isFinite(time)) return "";
|
|
231
|
+
const minutes = Math.floor((now - time) / 60_000);
|
|
232
|
+
if (minutes < 1) return "刚刚";
|
|
233
|
+
if (minutes < 60) return `${minutes} 分钟前`;
|
|
234
|
+
const hours = Math.floor(minutes / 60);
|
|
235
|
+
if (hours < 24) return `${hours} 小时前`;
|
|
236
|
+
const days = Math.floor(hours / 24);
|
|
237
|
+
if (days === 1) return "昨天";
|
|
238
|
+
if (days < 7) return `${days} 天前`;
|
|
239
|
+
return new Date(time).toLocaleDateString();
|
|
240
|
+
}
|
|
241
|
+
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describeAdapterCapabilities } from "./adapters/capabilities.js";
|
|
2
|
+
|
|
3
|
+
export class AdapterRegistry {
|
|
4
|
+
#adapters = new Map();
|
|
5
|
+
|
|
6
|
+
register(adapter) {
|
|
7
|
+
if (!adapter?.id || this.#adapters.has(adapter.id)) throw new Error(`Invalid or duplicate adapter: ${adapter?.id}`);
|
|
8
|
+
this.#adapters.set(adapter.id, adapter);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
get(id) {
|
|
12
|
+
return this.#adapters.get(id);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async describe() {
|
|
16
|
+
return Promise.all([...this.#adapters.values()].map(async (adapter) => ({
|
|
17
|
+
id: adapter.id,
|
|
18
|
+
label: adapter.label,
|
|
19
|
+
capabilities: describeAdapterCapabilities(adapter),
|
|
20
|
+
availability: await adapter.detect(),
|
|
21
|
+
})));
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
const methodCapabilities = {
|
|
2
|
+
create: "start",
|
|
3
|
+
attach: "attach",
|
|
4
|
+
discover: "discoverConversations",
|
|
5
|
+
cancel: "stop",
|
|
6
|
+
approve: "resolveApproval",
|
|
7
|
+
// Answering an agent's own question ("which option?") is a different seam
|
|
8
|
+
// from approving a risky operation, so it gets its own capability instead of
|
|
9
|
+
// riding on `approve`.
|
|
10
|
+
answer: "resolveInput",
|
|
11
|
+
selectModel: "selectModel",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function describeAdapterCapabilities(adapter) {
|
|
15
|
+
const declared = adapter?.capabilities ?? {};
|
|
16
|
+
const described = { ...declared };
|
|
17
|
+
|
|
18
|
+
for (const [capability, method] of Object.entries(methodCapabilities)) {
|
|
19
|
+
const legacy = capability === "cancel"
|
|
20
|
+
? declared.stop
|
|
21
|
+
: capability === "approve"
|
|
22
|
+
? declared.approvals
|
|
23
|
+
: undefined;
|
|
24
|
+
described[capability] = Boolean(
|
|
25
|
+
declared[capability]
|
|
26
|
+
?? legacy
|
|
27
|
+
?? (typeof adapter?.[method] === "function"),
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
described.resume = Boolean(declared.resume);
|
|
31
|
+
return described;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function adapterSupports(adapter, capability) {
|
|
35
|
+
return describeAdapterCapabilities(adapter)[capability] === true;
|
|
36
|
+
}
|