@modesto_ma/dsh-channels 0.5.0-beta.2 → 0.5.0-beta.4
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/README.md +72 -15
- package/docs/attachment-pipeline-v2.md +2 -2
- package/docs/feishu-onboarding.md +4 -3
- package/lib/client.js +369 -94
- package/lib/client.js.map +1 -1
- package/lib/index.js +7847 -5293
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/access/controller.ts +32 -1
- package/src/analyze/contract.ts +121 -0
- package/src/analyze/launcher.ts +353 -0
- package/src/analyze/server.ts +201 -0
- package/src/cards/analyze.ts +116 -0
- package/src/cards/index.ts +1 -0
- package/src/cards/streaming.ts +58 -0
- package/src/client/api.ts +15 -0
- package/src/client/panel/access-card.tsx +96 -70
- package/src/client/panel/analyze-token-card.tsx +70 -0
- package/src/client/panel/index.tsx +72 -10
- package/src/client/panel/sessions-tab.tsx +7 -4
- package/src/client/panel/shared.tsx +12 -0
- package/src/client/panel/wiki-tab.tsx +62 -0
- package/src/config.ts +52 -14
- package/src/domain.ts +55 -0
- package/src/feishu/scopes.ts +19 -1
- package/src/feishu/tools.ts +3 -1
- package/src/feishu/wiki/a1.ts +97 -0
- package/src/feishu/wiki/api.ts +21 -6
- package/src/feishu/wiki/base-api.ts +267 -0
- package/src/feishu/wiki/base-tools.ts +423 -0
- package/src/feishu/wiki/shared.ts +284 -0
- package/src/feishu/wiki/sheet-api.ts +250 -0
- package/src/feishu/wiki/sheet-tools.ts +493 -0
- package/src/feishu/wiki/tools.ts +102 -189
- package/src/feishu-channel.ts +14 -1
- package/src/health.ts +12 -0
- package/src/host/agent.ts +5 -1
- package/src/index.ts +1 -1
- package/src/interactions/pending.ts +11 -2
- package/src/routing/inbound.ts +57 -14
- package/src/service.ts +161 -28
- package/src/settings/contract.ts +37 -6
- package/src/settings/routes.ts +66 -14
- package/src/settings/surface.ts +69 -20
- package/src/streamer.ts +156 -7
- package/src/workspaces.ts +31 -0
package/src/streamer.ts
CHANGED
|
@@ -35,6 +35,12 @@
|
|
|
35
35
|
* on the card. Ownership checks and the cancellation itself belong to the
|
|
36
36
|
* caller.
|
|
37
37
|
*
|
|
38
|
+
* Queued turns: a turn attached behind the session's active turn renders as
|
|
39
|
+
* a queued card (notice + steer button) until it becomes the front.
|
|
40
|
+
* `steerTargetOf()` resolves a clicked queued card; `mergeQueued()` lets the
|
|
41
|
+
* caller move the queued message into the running turn and retires the
|
|
42
|
+
* queued card, handing its trigger reaction to the running turn.
|
|
43
|
+
*
|
|
38
44
|
* The first visible text delta flips the panel title 推理中 → 回复中; the
|
|
39
45
|
* final patch flips it to 回复结束 or 回复失败. Patching is throttled to `refreshMs` and
|
|
40
46
|
* only runs when the content actually changed. Segment sends are serialized
|
|
@@ -48,8 +54,8 @@
|
|
|
48
54
|
* @module dsh-channels/streamer
|
|
49
55
|
*/
|
|
50
56
|
|
|
51
|
-
import type { AssistantStreamFrame, Session, SessionEvent, TurnEndReason } from './dsh-types.ts'
|
|
52
|
-
import { buildCard, segmentCardContent, segmentCardOptions } from './cards.ts'
|
|
57
|
+
import type { AssistantStreamFrame, Session, SessionEvent, TurnEndReason, UserMessage } from './dsh-types.ts'
|
|
58
|
+
import { buildCard, buildMergedCard, segmentCardContent, segmentCardOptions } from './cards.ts'
|
|
53
59
|
import type { CardState, PanelPhase, StopControl } from './cards.ts'
|
|
54
60
|
import type { FeishuChannel } from './feishu-channel.ts'
|
|
55
61
|
import { cacheHitPct, foldAll, foldEvent } from './stats.ts'
|
|
@@ -147,13 +153,40 @@ interface SegmentCard {
|
|
|
147
153
|
nextRetryAt: number
|
|
148
154
|
}
|
|
149
155
|
|
|
156
|
+
/** A Feishu message whose Typing reaction swaps to DONE/No when a turn settles. */
|
|
157
|
+
interface TriggerReaction {
|
|
158
|
+
triggerMessageId: string
|
|
159
|
+
/** Reaction id of the Typing reaction (undefined when the add failed). */
|
|
160
|
+
reactionId: string | undefined
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Settled outcome of one turn, as its card shows it. */
|
|
164
|
+
export type TurnOutcome = 'done' | 'stopped' | 'error'
|
|
165
|
+
|
|
166
|
+
/** Per-turn options of {@link TurnStreamer.attach}. */
|
|
167
|
+
export interface AttachOptions {
|
|
168
|
+
/** Called once when the turn's card reaches its final state. */
|
|
169
|
+
readonly onSettled?: (outcome: TurnOutcome) => void
|
|
170
|
+
/**
|
|
171
|
+
* The prompt this turn will run; only a turn that carries it can be
|
|
172
|
+
* steered into the running turn while queued.
|
|
173
|
+
*/
|
|
174
|
+
readonly message?: UserMessage
|
|
175
|
+
}
|
|
176
|
+
|
|
150
177
|
interface TurnState {
|
|
178
|
+
/** `AttachOptions.onSettled`. */
|
|
179
|
+
onSettled: ((outcome: TurnOutcome) => void) | undefined
|
|
151
180
|
/** Turn number once known; undefined until the first `step/start`. */
|
|
152
181
|
turn: number | undefined
|
|
153
182
|
/** Message whose reaction carries Typing → DONE/No. */
|
|
154
183
|
triggerMessageId: string
|
|
155
184
|
/** Reaction id of the Typing reaction (undefined when the add failed). */
|
|
156
185
|
reactionId: string | undefined
|
|
186
|
+
/** Prompt queued for this turn; a queued card can steer it into the running turn. */
|
|
187
|
+
message: UserMessage | undefined
|
|
188
|
+
/** Triggers of queued messages merged into this turn (settle together). */
|
|
189
|
+
mergedTriggers: TriggerReaction[]
|
|
157
190
|
phase: PanelPhase
|
|
158
191
|
reasoning: string
|
|
159
192
|
/** Request header produced during THIS turn; never inherited from history. */
|
|
@@ -216,6 +249,8 @@ interface TurnState {
|
|
|
216
249
|
stopRequested: boolean
|
|
217
250
|
/** A non-owner clicked stop; the owner-only notice renders under the button. */
|
|
218
251
|
stopDenied: boolean
|
|
252
|
+
/** A non-owner clicked the queued card's steer button. */
|
|
253
|
+
queuedDenied: boolean
|
|
219
254
|
}
|
|
220
255
|
|
|
221
256
|
/** Card/body lengths captured at one stream attempt's `start` frame. */
|
|
@@ -327,8 +362,21 @@ export class TurnStreamer {
|
|
|
327
362
|
this.heartbeatMs = heartbeatMs
|
|
328
363
|
}
|
|
329
364
|
|
|
330
|
-
/**
|
|
331
|
-
|
|
365
|
+
/**
|
|
366
|
+
* Bind a freshly sent reply card to a session; seeds the stats fold from the log.
|
|
367
|
+
* @param session - the Session whose next turn renders into the card.
|
|
368
|
+
* @param cardMessageId - the main card message (patched in place).
|
|
369
|
+
* @param triggerMessageId - the message carrying the Typing → DONE/No reaction.
|
|
370
|
+
* @param reactionId - the Typing reaction to remove, when one was added.
|
|
371
|
+
* @param options - per-turn options.
|
|
372
|
+
*/
|
|
373
|
+
attach(
|
|
374
|
+
session: Session,
|
|
375
|
+
cardMessageId: string,
|
|
376
|
+
triggerMessageId: string,
|
|
377
|
+
reactionId: string | undefined,
|
|
378
|
+
options: AttachOptions = {},
|
|
379
|
+
): void {
|
|
332
380
|
this.sessions.set(session.id, session)
|
|
333
381
|
let fold = this.folds.get(session.id)
|
|
334
382
|
if (fold === undefined) {
|
|
@@ -337,9 +385,12 @@ export class TurnStreamer {
|
|
|
337
385
|
}
|
|
338
386
|
const queue = this.bySession.get(session.id) ?? []
|
|
339
387
|
queue.push({
|
|
388
|
+
onSettled: options.onSettled,
|
|
340
389
|
turn: undefined,
|
|
341
390
|
triggerMessageId,
|
|
342
391
|
reactionId,
|
|
392
|
+
message: options.message,
|
|
393
|
+
mergedTriggers: [],
|
|
343
394
|
phase: 'thinking',
|
|
344
395
|
reasoning: '',
|
|
345
396
|
header: undefined,
|
|
@@ -377,6 +428,7 @@ export class TurnStreamer {
|
|
|
377
428
|
attempts: new Map(),
|
|
378
429
|
stopRequested: false,
|
|
379
430
|
stopDenied: false,
|
|
431
|
+
queuedDenied: false,
|
|
380
432
|
})
|
|
381
433
|
this.bySession.set(session.id, queue)
|
|
382
434
|
const state = queue[queue.length - 1]!
|
|
@@ -424,6 +476,94 @@ export class TurnStreamer {
|
|
|
424
476
|
return undefined
|
|
425
477
|
}
|
|
426
478
|
|
|
479
|
+
/**
|
|
480
|
+
* Whether the session has an unsettled turn that a new message would queue
|
|
481
|
+
* behind.
|
|
482
|
+
* @param sessionId - the session to inspect.
|
|
483
|
+
* @returns true while a turn is streaming or queued.
|
|
484
|
+
*/
|
|
485
|
+
hasLiveTurn(sessionId: string): boolean {
|
|
486
|
+
return this.front(sessionId) !== undefined
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* The queued turn whose card is `messageId`, or undefined when the card is
|
|
491
|
+
* not a queued card (active, settled, merged, detached, or unknown).
|
|
492
|
+
* @param messageId Feishu message id of a clicked card.
|
|
493
|
+
* @returns The owning session id and the queued prompt.
|
|
494
|
+
*/
|
|
495
|
+
steerTargetOf(messageId: string): { sessionId: string; message: UserMessage } | undefined {
|
|
496
|
+
const queued = this.queuedState(messageId)
|
|
497
|
+
return queued === undefined ? undefined : { sessionId: queued.sessionId, message: queued.message }
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** The steerable queued turn whose main card is `messageId`. */
|
|
501
|
+
private queuedState(messageId: string): { sessionId: string; state: TurnState; message: UserMessage } | undefined {
|
|
502
|
+
for (const [sessionId, queue] of this.bySession) {
|
|
503
|
+
const front = this.front(sessionId)
|
|
504
|
+
const state = queue.find(entry => !entry.finalized && entry !== front && entry.cards[0]?.messageId === messageId)
|
|
505
|
+
if (state?.message !== undefined) return { sessionId, state, message: state.message }
|
|
506
|
+
}
|
|
507
|
+
return undefined
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Move the queued turn of `messageId` into the session's running turn.
|
|
512
|
+
* Refused (false) while the running turn is stopping, or when `steer`
|
|
513
|
+
* reports the message was no longer pending. On success the queued card
|
|
514
|
+
* becomes the merged notice, and its trigger reaction settles with the
|
|
515
|
+
* running turn.
|
|
516
|
+
* @param messageId - the queued card the owner clicked.
|
|
517
|
+
* @param steer - moves the message in the agent inbox; returns false when
|
|
518
|
+
* the driver already claimed it.
|
|
519
|
+
* @returns whether the message moved.
|
|
520
|
+
*/
|
|
521
|
+
mergeQueued(messageId: string, steer: (sessionId: string, message: UserMessage) => boolean): boolean {
|
|
522
|
+
const target = this.queuedState(messageId)
|
|
523
|
+
if (target === undefined) return false
|
|
524
|
+
const { sessionId, state } = target
|
|
525
|
+
const front = this.front(sessionId)!
|
|
526
|
+
if (front.stopRequested || !steer(sessionId, target.message)) return false
|
|
527
|
+
const queue = this.bySession.get(sessionId)!
|
|
528
|
+
queue.splice(queue.indexOf(state), 1)
|
|
529
|
+
state.disposed = true
|
|
530
|
+
if (state.timer !== undefined) clearTimeout(state.timer)
|
|
531
|
+
if (state.heartbeatTimer !== undefined) clearTimeout(state.heartbeatTimer)
|
|
532
|
+
front.mergedTriggers.push(
|
|
533
|
+
{ triggerMessageId: state.triggerMessageId, reactionId: state.reactionId },
|
|
534
|
+
...state.mergedTriggers,
|
|
535
|
+
)
|
|
536
|
+
void this.settleMergedCard(sessionId, messageId)
|
|
537
|
+
return true
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Patch a merged queued card into its terminal notice, with up to two
|
|
542
|
+
* retries (1s / 2s backoff) so a transient failure does not leave a stale
|
|
543
|
+
* queued card whose button no longer acts.
|
|
544
|
+
*/
|
|
545
|
+
private async settleMergedCard(sessionId: string, messageId: string): Promise<void> {
|
|
546
|
+
let lastError: unknown
|
|
547
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
548
|
+
try {
|
|
549
|
+
await this.channel.updateCard(messageId, buildMergedCard())
|
|
550
|
+
return
|
|
551
|
+
} catch (error) {
|
|
552
|
+
lastError = error
|
|
553
|
+
if (attempt < 2) await this.delay(1_000 * 2 ** attempt)
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
this.hooks.onError(sessionId, lastError)
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** Add the owner-only notice under a queued card's steer button (once). */
|
|
560
|
+
markQueuedDenied(messageId: string): void {
|
|
561
|
+
const target = this.queuedState(messageId)
|
|
562
|
+
if (target === undefined || target.state.queuedDenied) return
|
|
563
|
+
target.state.queuedDenied = true
|
|
564
|
+
this.markDirty(target.sessionId, target.state)
|
|
565
|
+
}
|
|
566
|
+
|
|
427
567
|
/**
|
|
428
568
|
* Show (or clear) the approval wait on the session's live turn: the tool
|
|
429
569
|
* line reads `⏳ 等待你审批:bash` while any approval card is undecided.
|
|
@@ -958,7 +1098,8 @@ export class TurnStreamer {
|
|
|
958
1098
|
const kind = this.noteKind('tool', tool, state.toolKind)
|
|
959
1099
|
if (kind !== undefined) state.toolKind = kind
|
|
960
1100
|
}
|
|
961
|
-
const
|
|
1101
|
+
const queued = !state.finalized && this.front(sessionId) !== state
|
|
1102
|
+
const stop: StopControl | undefined = !active || state.finalized || queued
|
|
962
1103
|
? undefined
|
|
963
1104
|
: state.stopRequested ? 'stopping' : 'ready'
|
|
964
1105
|
return {
|
|
@@ -968,6 +1109,7 @@ export class TurnStreamer {
|
|
|
968
1109
|
status: active ? state.status : '',
|
|
969
1110
|
tool: active ? state.tool : '',
|
|
970
1111
|
...(stop === undefined ? {} : { stop, stopDenied: state.stopDenied }),
|
|
1112
|
+
...(queued ? { queued, queuedDenied: state.queuedDenied } : {}),
|
|
971
1113
|
stats: this.statsFor(sessionId, state),
|
|
972
1114
|
}
|
|
973
1115
|
}
|
|
@@ -1190,7 +1332,7 @@ export class TurnStreamer {
|
|
|
1190
1332
|
}
|
|
1191
1333
|
|
|
1192
1334
|
// Reaction swap: Typing → DONE on success, Typing → No on failure.
|
|
1193
|
-
void this.swapReaction(
|
|
1335
|
+
for (const trigger of [state, ...state.mergedTriggers]) void this.swapReaction(trigger, failure !== undefined)
|
|
1194
1336
|
|
|
1195
1337
|
// Prune the finalized state lazily (kept until the next attach so the
|
|
1196
1338
|
// front-scan skips it, then removed).
|
|
@@ -1205,9 +1347,16 @@ export class TurnStreamer {
|
|
|
1205
1347
|
})
|
|
1206
1348
|
|
|
1207
1349
|
this.hooks.onTurnFinalized(sessionId, state.turn ?? 0, state.cards[0]?.messageId ?? '')
|
|
1350
|
+
if (state.onSettled !== undefined) {
|
|
1351
|
+
try {
|
|
1352
|
+
state.onSettled(state.phase === 'error' ? 'error' : state.phase === 'stopped' ? 'stopped' : 'done')
|
|
1353
|
+
} catch (error) {
|
|
1354
|
+
this.hooks.onError(sessionId, error)
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1208
1357
|
}
|
|
1209
1358
|
|
|
1210
|
-
private async swapReaction(state:
|
|
1359
|
+
private async swapReaction(state: TriggerReaction, failed: boolean): Promise<void> {
|
|
1211
1360
|
try {
|
|
1212
1361
|
if (state.reactionId !== undefined) {
|
|
1213
1362
|
await this.channel.removeReaction(state.triggerMessageId, state.reactionId)
|
package/src/workspaces.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* - p2p: workspace `<baseDir>/p2p/<user_id>/`
|
|
6
6
|
* - group: workspace `<baseDir>/groups/<chat_id>/<user_id>/`
|
|
7
7
|
* - topic: `<workspace>/topics/<topic-token>/` (the Session's effective cwd)
|
|
8
|
+
* - log-platform analysis: workspace `<baseDir>/analyze/<app>/<errorCode>/`
|
|
8
9
|
*
|
|
9
10
|
* The workspace root is the Session header cwd: it keeps the Session grouped
|
|
10
11
|
* under the user's project in the GUI and stays the sandbox write boundary.
|
|
@@ -232,3 +233,33 @@ export async function ensureWorkspace(
|
|
|
232
233
|
workspaceTitleFor(chatType, chatId, senderUserId),
|
|
233
234
|
)
|
|
234
235
|
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Workspace directory of one log-platform analysis subject:
|
|
239
|
+
* `<baseDir>/analyze/<app>/<errorCode>/`. Every analysis of the same
|
|
240
|
+
* application and error code is grouped there, each in its own topic
|
|
241
|
+
* directory.
|
|
242
|
+
*/
|
|
243
|
+
export function analyzeWorkspacePathFor(baseDir: string, app: string, errorCode: string): string {
|
|
244
|
+
return path.join(baseDir, 'analyze', safeSegment(app, 'app'), safeSegment(errorCode, 'errorCode'))
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Workspace title of one analysis subject in the DSH workspace list. */
|
|
248
|
+
export function analyzeWorkspaceTitleFor(app: string, errorCode: string): string {
|
|
249
|
+
return `日志分析 · ${app} · ${errorCode}`
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Ensure one analysis subject's workspace exists and is registered (idempotent
|
|
254
|
+
* like {@link ensureWorkspace}).
|
|
255
|
+
*/
|
|
256
|
+
export async function ensureAnalyzeWorkspace(
|
|
257
|
+
ctx: HostContext,
|
|
258
|
+
baseDir: string,
|
|
259
|
+
app: string,
|
|
260
|
+
errorCode: string,
|
|
261
|
+
): Promise<Workspace> {
|
|
262
|
+
const dir = analyzeWorkspacePathFor(baseDir, app, errorCode)
|
|
263
|
+
await mkdir(dir, { recursive: true })
|
|
264
|
+
return ctx.workspaceRegistry.create(dir, analyzeWorkspaceTitleFor(app, errorCode))
|
|
265
|
+
}
|