@modesto_ma/dsh-channels 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -29
- package/docs/access-control-design.md +1 -1
- package/docs/feasibility-and-design.md +54 -26
- package/docs/feishu-onboarding.md +15 -13
- package/docs/persistence.md +4 -4
- package/docs/refactor-plan.md +6 -5
- package/docs/workspace-isolation-design.md +78 -0
- package/lib/client.js +23 -13
- package/lib/client.js.map +1 -1
- package/lib/index.js +1110 -603
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/cards/index.ts +1 -0
- package/src/cards/onboarding.ts +23 -0
- package/src/cards/primitives.ts +20 -0
- package/src/cards/streaming.ts +310 -291
- package/src/cleanup.ts +256 -119
- package/src/client/panel/cleanup-card.tsx +4 -2
- package/src/client/panel/workspace-tab.tsx +1 -1
- package/src/config.ts +27 -8
- package/src/domain.ts +34 -3
- package/src/feishu/agent-context.ts +9 -5
- package/src/index.ts +8 -6
- package/src/routing/inbound.ts +88 -43
- package/src/service.ts +119 -41
- package/src/session/isolation.ts +277 -0
- package/src/session/manager.ts +63 -20
- package/src/session/repair.ts +2 -3
- package/src/session/routes.ts +2 -0
- package/src/session-commands.ts +41 -14
- package/src/settings/contract.ts +18 -14
- package/src/settings/surface.ts +12 -4
- package/src/streamer.ts +70 -30
- package/src/workspaces.ts +50 -28
package/src/streamer.ts
CHANGED
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
* lines: the delegation status line (子代理 / 工作流 progress, settled
|
|
12
12
|
* workflow outcome, heartbeat) and the tool line (the parent's in-flight
|
|
13
13
|
* tool call). The lines are INDEPENDENT — a later tool call never
|
|
14
|
-
* supersedes the settled workflow status.
|
|
14
|
+
* supersedes the settled workflow status. The lines render only on the
|
|
15
|
+
* card that is currently streaming, so frozen segment cards never repeat
|
|
16
|
+
* a stale status. Subagent children live in their
|
|
15
17
|
* own sessions: their `subagent/descriptor` / `tool/call` / `turn/end`
|
|
16
18
|
* events are folded into the parent turn's status line through
|
|
17
19
|
* `session.header.parentSession` (status only — the child's text output
|
|
@@ -19,10 +21,11 @@
|
|
|
19
21
|
* 3. When the body outgrows one card's budget, the overflow is split at a
|
|
20
22
|
* clean breakpoint and sent as a continuation segment card (segment 2, 3,
|
|
21
23
|
* …) into the same topic; the stream then keeps flowing into the newest
|
|
22
|
-
* card. Reasoning is never segmented — it stays on card 1
|
|
23
|
-
*
|
|
24
|
-
* 4. `
|
|
25
|
-
*
|
|
24
|
+
* card. Reasoning is never segmented — it stays on card 1 inside the
|
|
25
|
+
* fold panel's own fixed window, independent of the body length.
|
|
26
|
+
* 4. `finalize(turn)` — flip every segment card to its terminal state
|
|
27
|
+
* (`回复结束` / `回复失败`), render the settled footer on the main card and
|
|
28
|
+
* request the Typing → DONE/No reaction swap on the triggering message.
|
|
26
29
|
*
|
|
27
30
|
* The first visible text delta flips the panel title 推理中 → 回复中; the
|
|
28
31
|
* final patch flips it to 回复结束 or 回复失败. Patching is throttled to `refreshMs` and
|
|
@@ -38,7 +41,7 @@
|
|
|
38
41
|
*/
|
|
39
42
|
|
|
40
43
|
import type { AssistantStreamFrame, Session, SessionEvent, TurnEndReason } from './dsh-types.ts'
|
|
41
|
-
import { buildCard, segmentCardContent } from './cards.ts'
|
|
44
|
+
import { buildCard, segmentCardContent, segmentCardOptions } from './cards.ts'
|
|
42
45
|
import type { CardState, PanelPhase } from './cards.ts'
|
|
43
46
|
import type { FeishuChannel } from './feishu-channel.ts'
|
|
44
47
|
import { cacheHitPct, foldAll, foldEvent } from './stats.ts'
|
|
@@ -49,7 +52,7 @@ import type { HeaderSample, StatsFold, UsageSample } from './stats.ts'
|
|
|
49
52
|
* console can confirm which build of the activity status line is loaded.
|
|
50
53
|
* Bump on every behavior change to this feature.
|
|
51
54
|
*/
|
|
52
|
-
export const ACTIVITY_BUILD_MARKER = 'activity-status-
|
|
55
|
+
export const ACTIVITY_BUILD_MARKER = 'activity-status-v7'
|
|
53
56
|
|
|
54
57
|
/** How a segment card reached Feishu. */
|
|
55
58
|
export interface SegmentDelivery {
|
|
@@ -149,8 +152,6 @@ interface TurnState {
|
|
|
149
152
|
header: HeaderSample | undefined
|
|
150
153
|
/** Provider usage produced during THIS turn; never inherited from history. */
|
|
151
154
|
lastUsage: UsageSample | undefined
|
|
152
|
-
/** Structured terminal failure for this turn, when `turn/end.kind=error`. */
|
|
153
|
-
failure: { message: string; code?: string } | undefined
|
|
154
155
|
/** Segment cards in order; `cards[0]` is the attached reply card. */
|
|
155
156
|
cards: SegmentCard[]
|
|
156
157
|
dirty: boolean
|
|
@@ -161,6 +162,12 @@ interface TurnState {
|
|
|
161
162
|
disposed: boolean
|
|
162
163
|
/** Unix ms when the turn was attached (heartbeat elapsed base). */
|
|
163
164
|
turnStartedAt: number
|
|
165
|
+
/** Wall-clock start of the currently open reasoning phase (undefined = not reasoning). */
|
|
166
|
+
reasoningSince: number | undefined
|
|
167
|
+
/** Accumulated wall-clock time the turn spent reasoning, in ms. A phase
|
|
168
|
+
* opens at the first reasoning delta and closes when the step's output ends
|
|
169
|
+
* (first answer text or step end), so tool execution is never counted. */
|
|
170
|
+
reasoningMs: number
|
|
164
171
|
/** The in-flight tool call, if any (cleared by its `tool/result`). */
|
|
165
172
|
toolActivity: { name: string; label: string; since: number } | undefined
|
|
166
173
|
/** Live workflow-run folds projected by the workflow tool into this session. */
|
|
@@ -202,6 +209,8 @@ interface AttemptMark {
|
|
|
202
209
|
cardCount: number
|
|
203
210
|
bodyLengths: number[]
|
|
204
211
|
reasoningLength: number
|
|
212
|
+
reasoningMs: number
|
|
213
|
+
reasoningSince: number | undefined
|
|
205
214
|
}
|
|
206
215
|
|
|
207
216
|
/** One live `tool-workflow/run-*` fold: run identity and member progress. */
|
|
@@ -321,7 +330,6 @@ export class TurnStreamer {
|
|
|
321
330
|
reasoning: '',
|
|
322
331
|
header: undefined,
|
|
323
332
|
lastUsage: undefined,
|
|
324
|
-
failure: undefined,
|
|
325
333
|
cards: [{
|
|
326
334
|
messageId: cardMessageId,
|
|
327
335
|
body: '',
|
|
@@ -339,6 +347,8 @@ export class TurnStreamer {
|
|
|
339
347
|
finalized: false,
|
|
340
348
|
disposed: false,
|
|
341
349
|
turnStartedAt: Date.now(),
|
|
350
|
+
reasoningSince: undefined,
|
|
351
|
+
reasoningMs: 0,
|
|
342
352
|
toolActivity: undefined,
|
|
343
353
|
workflowRuns: new Map(),
|
|
344
354
|
workflowEndStatus: undefined,
|
|
@@ -531,6 +541,9 @@ export class TurnStreamer {
|
|
|
531
541
|
if (state === undefined || state.disposed) return
|
|
532
542
|
switch (frame.type) {
|
|
533
543
|
case 'start': {
|
|
544
|
+
// A new step starts: close any reasoning phase the previous step left
|
|
545
|
+
// open (defensive — its `end` frame normally does this first).
|
|
546
|
+
this.closeReasoning(state)
|
|
534
547
|
state.attempts.set(frame.attemptId, this.attemptMark(state))
|
|
535
548
|
if (state.turn === undefined) state.turn = frame.turn
|
|
536
549
|
break
|
|
@@ -538,6 +551,7 @@ export class TurnStreamer {
|
|
|
538
551
|
case 'chunk': {
|
|
539
552
|
const chunk = frame.chunk
|
|
540
553
|
if (chunk.type === 'text-delta') {
|
|
554
|
+
if (chunk.text !== '') this.closeReasoning(state)
|
|
541
555
|
const last = state.cards[state.cards.length - 1]
|
|
542
556
|
if (last !== undefined && !last.frozen && chunk.text !== '') {
|
|
543
557
|
last.body += chunk.text
|
|
@@ -545,12 +559,17 @@ export class TurnStreamer {
|
|
|
545
559
|
this.markDirty(session.id, state)
|
|
546
560
|
}
|
|
547
561
|
} else if (chunk.type === 'reasoning-delta' && chunk.text !== '') {
|
|
562
|
+
if (state.reasoningSince === undefined) state.reasoningSince = Date.now()
|
|
548
563
|
state.reasoning += chunk.text
|
|
549
564
|
this.markDirty(session.id, state)
|
|
550
565
|
}
|
|
551
566
|
break
|
|
552
567
|
}
|
|
553
568
|
case 'end': {
|
|
569
|
+
// The step's output is complete: a reasoning phase that ended with a
|
|
570
|
+
// tool call (no answer text) stops counting here, so the tool run that
|
|
571
|
+
// follows is not miscounted as reasoning time.
|
|
572
|
+
this.closeReasoning(state)
|
|
554
573
|
const mark = state.attempts.get(frame.attemptId)
|
|
555
574
|
state.attempts.delete(frame.attemptId)
|
|
556
575
|
if (frame.outcome.kind === 'abandoned' && mark !== undefined) {
|
|
@@ -568,9 +587,18 @@ export class TurnStreamer {
|
|
|
568
587
|
cardCount: state.cards.length,
|
|
569
588
|
bodyLengths: state.cards.map(card => card.body.length),
|
|
570
589
|
reasoningLength: state.reasoning.length,
|
|
590
|
+
reasoningMs: state.reasoningMs,
|
|
591
|
+
reasoningSince: state.reasoningSince,
|
|
571
592
|
}
|
|
572
593
|
}
|
|
573
594
|
|
|
595
|
+
/** Close an open reasoning phase, accumulating its wall-clock time. */
|
|
596
|
+
private closeReasoning(state: TurnState, now = Date.now()): void {
|
|
597
|
+
if (state.reasoningSince === undefined) return
|
|
598
|
+
state.reasoningMs += Math.max(0, now - state.reasoningSince)
|
|
599
|
+
state.reasoningSince = undefined
|
|
600
|
+
}
|
|
601
|
+
|
|
574
602
|
/**
|
|
575
603
|
* Drop one abandoned attempt's deltas. The attempt's text is a contiguous
|
|
576
604
|
* suffix of the logical body: segments created during the attempt are
|
|
@@ -594,6 +622,9 @@ export class TurnStreamer {
|
|
|
594
622
|
tail.dirty = true
|
|
595
623
|
}
|
|
596
624
|
state.reasoning = state.reasoning.slice(0, mark.reasoningLength)
|
|
625
|
+
// The abandoned attempt's reasoning time is not part of the visible turn.
|
|
626
|
+
state.reasoningMs = mark.reasoningMs
|
|
627
|
+
state.reasoningSince = mark.reasoningSince
|
|
597
628
|
}
|
|
598
629
|
|
|
599
630
|
private markDirty(sessionId: string, state: TurnState): void {
|
|
@@ -641,6 +672,9 @@ export class TurnStreamer {
|
|
|
641
672
|
: context?.contextWindow ?? projected?.contextWindow,
|
|
642
673
|
model: header?.model,
|
|
643
674
|
effort: header?.effort,
|
|
675
|
+
// Only a turn that actually reasoned reports a duration; a non-reasoning
|
|
676
|
+
// model leaves the field undefined (rendered as `—`).
|
|
677
|
+
reasoningMs: state.reasoning === '' ? undefined : state.reasoningMs,
|
|
644
678
|
}
|
|
645
679
|
}
|
|
646
680
|
|
|
@@ -717,13 +751,10 @@ export class TurnStreamer {
|
|
|
717
751
|
* an elapsed counter so the card never sits silently.
|
|
718
752
|
* Once the turn is finalized, the line switches to the SETTLED form
|
|
719
753
|
* (workflow conclusion + finished-subagent summary) — the final card keeps
|
|
720
|
-
* the delegation status; only the tool line disappears.
|
|
754
|
+
* the delegation status; only the tool line disappears. A failed turn shows
|
|
755
|
+
* its diagnostic once, in the body: the status line never repeats it.
|
|
721
756
|
*/
|
|
722
757
|
private delegationLine(state: TurnState, now: number): string {
|
|
723
|
-
if (state.failure !== undefined) {
|
|
724
|
-
const suffix = state.failure.code === undefined ? '' : ` · ${state.failure.code}`
|
|
725
|
-
return clipActivity(`🔴 本轮运行失败${suffix}`)
|
|
726
|
-
}
|
|
727
758
|
if (state.finalized) return this.settledLine(state)
|
|
728
759
|
|
|
729
760
|
let latest: WorkflowRun | undefined
|
|
@@ -832,10 +863,14 @@ export class TurnStreamer {
|
|
|
832
863
|
}, this.heartbeatMs)
|
|
833
864
|
}
|
|
834
865
|
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
866
|
+
/**
|
|
867
|
+
* Snapshot one card. `active` is true for the card currently streaming:
|
|
868
|
+
* only that card renders the transient activity lines, so frozen segment
|
|
869
|
+
* cards never repeat a stale tool/delegation line. Both lines are still
|
|
870
|
+
* recomputed (and remembered) on every snapshot so heartbeat/transition
|
|
871
|
+
* logging stays accurate.
|
|
872
|
+
*/
|
|
873
|
+
private cardState(sessionId: string, state: TurnState, body: string, active = true): CardState {
|
|
839
874
|
const now = Date.now()
|
|
840
875
|
const status = this.delegationLine(state, now)
|
|
841
876
|
if (status !== state.status) {
|
|
@@ -853,8 +888,8 @@ export class TurnStreamer {
|
|
|
853
888
|
phase: state.phase,
|
|
854
889
|
reasoning: state.reasoning,
|
|
855
890
|
body,
|
|
856
|
-
status: state.status,
|
|
857
|
-
tool: state.tool,
|
|
891
|
+
status: active ? state.status : '',
|
|
892
|
+
tool: active ? state.tool : '',
|
|
858
893
|
stats: this.statsFor(sessionId, state),
|
|
859
894
|
}
|
|
860
895
|
}
|
|
@@ -882,7 +917,7 @@ export class TurnStreamer {
|
|
|
882
917
|
const card = state.cards[index]
|
|
883
918
|
if (card === undefined || card.frozen || card.mode === 'text') return
|
|
884
919
|
|
|
885
|
-
const options = index
|
|
920
|
+
const options = segmentCardOptions(index)
|
|
886
921
|
const split = segmentCardContent(this.cardState(sessionId, state, card.body), options)
|
|
887
922
|
if (split.head === '') {
|
|
888
923
|
// Degenerate budget (cannot happen with the real fixed overhead):
|
|
@@ -944,10 +979,10 @@ export class TurnStreamer {
|
|
|
944
979
|
let lastError: unknown
|
|
945
980
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
946
981
|
try {
|
|
947
|
-
const snapshot = buildCard(
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
982
|
+
const snapshot = buildCard(
|
|
983
|
+
this.cardState(sessionId, state, card.body, index === state.cards.length - 1),
|
|
984
|
+
segmentCardOptions(index),
|
|
985
|
+
)
|
|
951
986
|
const delivery = await this.hooks.sendSegmentCard(sessionId, index + 1, snapshot, card.body)
|
|
952
987
|
card.sending = false
|
|
953
988
|
card.messageId = delivery.messageId
|
|
@@ -996,8 +1031,11 @@ export class TurnStreamer {
|
|
|
996
1031
|
if (!force && !card.dirty && !turnDirty) return
|
|
997
1032
|
if (!force && card.nextRetryAt > now) return
|
|
998
1033
|
card.dirty = false
|
|
999
|
-
const options = index
|
|
1000
|
-
const snapshot = buildCard(
|
|
1034
|
+
const options = segmentCardOptions(index)
|
|
1035
|
+
const snapshot = buildCard(
|
|
1036
|
+
this.cardState(sessionId, state, card.body, index === state.cards.length - 1),
|
|
1037
|
+
options,
|
|
1038
|
+
)
|
|
1001
1039
|
this.channel.updateCard(card.messageId, snapshot).catch((error: unknown) => {
|
|
1002
1040
|
card.dirty = true
|
|
1003
1041
|
card.failures++
|
|
@@ -1027,8 +1065,10 @@ export class TurnStreamer {
|
|
|
1027
1065
|
): void {
|
|
1028
1066
|
if (state.finalized) return
|
|
1029
1067
|
state.finalized = true
|
|
1030
|
-
state.failure = failure
|
|
1031
1068
|
state.phase = failure === undefined ? 'done' : 'error'
|
|
1069
|
+
// A turn that ends inside a reasoning phase (e.g. no answer text) still
|
|
1070
|
+
// reports the time it spent reasoning.
|
|
1071
|
+
this.closeReasoning(state)
|
|
1032
1072
|
if (failure !== undefined) {
|
|
1033
1073
|
const code = failure.code === undefined ? '' : `\n\n错误码:\`${failure.code}\``
|
|
1034
1074
|
const notice = `❌ **本轮运行失败**\n\n${failure.message}${code}`
|
|
@@ -1040,7 +1080,7 @@ export class TurnStreamer {
|
|
|
1040
1080
|
// terminal diagnostic in its own continuation card instead.
|
|
1041
1081
|
state.cards.push(this.freshSegment(notice))
|
|
1042
1082
|
} else {
|
|
1043
|
-
tail.body = tail.body.trim() === '' ? notice : `${tail.body}\n\n
|
|
1083
|
+
tail.body = tail.body.trim() === '' ? notice : `${tail.body}\n\n${notice}`
|
|
1044
1084
|
tail.dirty = true
|
|
1045
1085
|
}
|
|
1046
1086
|
}
|
package/src/workspaces.ts
CHANGED
|
@@ -1,13 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Workspace directory resolution: one DSH workspace per
|
|
2
|
+
* Workspace directory resolution: one DSH workspace per user, one working
|
|
3
|
+
* directory per Feishu topic.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
* -
|
|
6
|
-
* -
|
|
5
|
+
* - p2p: workspace `<baseDir>/p2p/<open_id>/`
|
|
6
|
+
* - group: workspace `<baseDir>/groups/<chat_id>/<open_id>/`
|
|
7
|
+
* - topic: `<workspace>/topics/<topic-token>/` (the Session's effective cwd)
|
|
8
|
+
*
|
|
9
|
+
* The workspace root is the Session header cwd: it keeps the Session grouped
|
|
10
|
+
* under the user's project in the GUI and stays the sandbox write boundary.
|
|
11
|
+
* The per-topic directory is where the tools actually work (see
|
|
12
|
+
* `./session/isolation.ts`), so files produced by different topics never mix.
|
|
13
|
+
* Older versions registered one workspace per topic under `<baseDir>/topics/`;
|
|
14
|
+
* those directories are kept verbatim for legacy routes and are only removed
|
|
15
|
+
* by the retention cleanup, never migrated.
|
|
7
16
|
*
|
|
8
17
|
* Attachments received before a topic exists are staged below
|
|
9
18
|
* `<baseDir>/_staging/<chatType>/<chat_id>/<open_id>/` and adopted into the
|
|
10
|
-
* topic
|
|
19
|
+
* topic directory once its first Session is created.
|
|
11
20
|
*
|
|
12
21
|
* Feishu ids (`ou_…`, `oc_…`) are safe path segments, but they are validated
|
|
13
22
|
* anyway so a hostile id can never escape `baseDir`.
|
|
@@ -123,36 +132,51 @@ export function topicTokenFor(
|
|
|
123
132
|
return kind === 'thread' ? safeSegment(routeKey, 'route key') : safeSegment(sessionId, 'session id')
|
|
124
133
|
}
|
|
125
134
|
|
|
135
|
+
/** Git branch (and worktree directory) name for one topic token. */
|
|
136
|
+
export function worktreeBranchFor(topicToken: string): string {
|
|
137
|
+
return `feishu-${safeSegment(topicToken, 'topic token')}`
|
|
138
|
+
}
|
|
139
|
+
|
|
126
140
|
/**
|
|
127
|
-
* Deterministic workspace directory for one
|
|
128
|
-
*
|
|
141
|
+
* Deterministic workspace directory for one user in one chat context: the
|
|
142
|
+
* durable project the user's Sessions are grouped under.
|
|
129
143
|
*
|
|
130
|
-
* - p2p: `<baseDir>/
|
|
131
|
-
* - group: `<baseDir>/
|
|
144
|
+
* - p2p: `<baseDir>/p2p/<open_id>/`
|
|
145
|
+
* - group: `<baseDir>/groups/<chat_id>/<open_id>/`
|
|
132
146
|
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
147
|
+
* These are the same paths older versions used as their shared (user × chat)
|
|
148
|
+
* workspaces, so an upgraded deployment reuses the existing registry record
|
|
149
|
+
* instead of creating a second project for the same user.
|
|
136
150
|
*/
|
|
137
151
|
export function workspacePathFor(
|
|
138
152
|
baseDir: string,
|
|
139
153
|
chatType: 'p2p' | 'group',
|
|
140
154
|
chatId: string,
|
|
141
155
|
senderOpenId: string,
|
|
142
|
-
topicToken: string,
|
|
143
156
|
): string {
|
|
144
|
-
const chat = safeSegment(chatId, 'chatId')
|
|
145
157
|
const user = safeSegment(senderOpenId, 'senderOpenId')
|
|
146
|
-
|
|
147
|
-
return
|
|
148
|
-
|
|
149
|
-
|
|
158
|
+
if (chatType === 'p2p') return path.join(baseDir, 'p2p', user)
|
|
159
|
+
return path.join(baseDir, 'groups', safeSegment(chatId, 'chatId'), user)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Per-topic working directory inside one user workspace. */
|
|
163
|
+
export function topicDirFor(workspacePath: string, topicToken: string): string {
|
|
164
|
+
return path.join(workspacePath, 'topics', safeSegment(topicToken, 'topic token'))
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Root of the legacy (one workspace per topic) directories. Legacy workspaces
|
|
169
|
+
* live strictly below this path, which is how the retention cleanup tells them
|
|
170
|
+
* apart from user workspaces that must never be deleted.
|
|
171
|
+
*/
|
|
172
|
+
export function legacyTopicWorkspaceRoot(baseDir: string): string {
|
|
173
|
+
return path.join(baseDir, 'topics')
|
|
150
174
|
}
|
|
151
175
|
|
|
152
176
|
/**
|
|
153
177
|
* Staging directory for attachments that arrive before their topic (and so
|
|
154
|
-
* its
|
|
155
|
-
*
|
|
178
|
+
* its topic directory) exists. Not a registered workspace: files move into the
|
|
179
|
+
* real topic directory as soon as the first Session of that topic is created.
|
|
156
180
|
*/
|
|
157
181
|
export function stagingPathFor(
|
|
158
182
|
baseDir: string,
|
|
@@ -170,17 +194,16 @@ export function workspaceTitleFor(
|
|
|
170
194
|
chatType: 'p2p' | 'group',
|
|
171
195
|
chatId: string,
|
|
172
196
|
senderOpenId: string,
|
|
173
|
-
topicToken?: string,
|
|
174
197
|
): string {
|
|
175
|
-
|
|
198
|
+
return chatType === 'p2p'
|
|
176
199
|
? `飞书私聊 · ${senderOpenId}`
|
|
177
200
|
: `飞书群聊 · ${chatId} · ${senderOpenId}`
|
|
178
|
-
return topicToken === undefined ? base : `${base} · ${topicToken}`
|
|
179
201
|
}
|
|
180
202
|
|
|
181
203
|
/**
|
|
182
|
-
* Ensure one
|
|
183
|
-
* host's workspace registry. Idempotent: repeated calls return the same record
|
|
204
|
+
* Ensure one user's workspace directory exists and is registered with the
|
|
205
|
+
* host's workspace registry. Idempotent: repeated calls return the same record,
|
|
206
|
+
* and an existing record keeps its (possibly renamed) title.
|
|
184
207
|
*/
|
|
185
208
|
export async function ensureWorkspace(
|
|
186
209
|
ctx: HostContext,
|
|
@@ -188,12 +211,11 @@ export async function ensureWorkspace(
|
|
|
188
211
|
chatType: 'p2p' | 'group',
|
|
189
212
|
chatId: string,
|
|
190
213
|
senderOpenId: string,
|
|
191
|
-
topicToken: string,
|
|
192
214
|
): Promise<Workspace> {
|
|
193
|
-
const dir = workspacePathFor(baseDir, chatType, chatId, senderOpenId
|
|
215
|
+
const dir = workspacePathFor(baseDir, chatType, chatId, senderOpenId)
|
|
194
216
|
await mkdir(dir, { recursive: true })
|
|
195
217
|
return ctx.workspaceRegistry.create(
|
|
196
218
|
dir,
|
|
197
|
-
workspaceTitleFor(chatType, chatId, senderOpenId
|
|
219
|
+
workspaceTitleFor(chatType, chatId, senderOpenId),
|
|
198
220
|
)
|
|
199
221
|
}
|