@modesto_ma/dsh-channels 0.7.0 → 0.8.0-beta.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/README.md +31 -47
- package/lib/client.js +44 -122
- package/lib/client.js.map +1 -1
- package/lib/index.js +436 -558
- package/lib/index.js.map +1 -1
- package/package.json +4 -1
- package/src/access/controller.ts +3 -3
- package/src/cards/index.ts +1 -1
- package/src/cards/topic.ts +104 -0
- package/src/client/origin.tsx +1 -1
- package/src/client/panel/advanced-tab.tsx +5 -29
- package/src/client/panel/index.tsx +2 -39
- package/src/client/panel/shared.tsx +0 -12
- package/src/config.ts +26 -16
- package/src/domain.ts +89 -32
- package/src/host/core.ts +32 -1
- package/src/host/services.ts +5 -0
- package/src/index.ts +11 -4
- package/src/routing/inbound.ts +1 -1
- package/src/service.ts +20 -25
- package/src/settings/contract.ts +6 -21
- package/src/settings/overlay.ts +3 -1
- package/src/settings/routes.ts +2 -3
- package/src/settings/surface.ts +1 -14
- package/src/streamer.ts +91 -5
- package/src/topics/contract.ts +149 -0
- package/src/topics/launcher.ts +428 -0
- package/src/workspaces.ts +25 -18
- package/src/analyze/contract.ts +0 -126
- package/src/analyze/launcher.ts +0 -376
- package/src/analyze/server.ts +0 -207
- package/src/cards/analyze.ts +0 -116
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@modesto_ma/dsh-channels",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0-beta.1",
|
|
4
4
|
"description": "DeepSeek Harness (DSH) 飞书渠道插件:一个 dsh 实例对应一个飞书机器人,私聊/群聊/话题群 @ 触发会话,话题绑定 session,卡片 2.0 流式回复;Web 左侧栏提供「飞书渠道」状态与常用配置面板",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -19,6 +19,9 @@
|
|
|
19
19
|
"types": "./src/client/index.ts",
|
|
20
20
|
"default": "./lib/client.js"
|
|
21
21
|
},
|
|
22
|
+
"./topics": {
|
|
23
|
+
"types": "./src/topics/contract.ts"
|
|
24
|
+
},
|
|
22
25
|
"./package.json": "./package.json"
|
|
23
26
|
},
|
|
24
27
|
"files": [
|
package/src/access/controller.ts
CHANGED
|
@@ -185,12 +185,12 @@ export class AccessController {
|
|
|
185
185
|
}
|
|
186
186
|
|
|
187
187
|
/**
|
|
188
|
-
* Whether
|
|
189
|
-
* any group in `open` access mode.
|
|
188
|
+
* Whether a `feishuTopics` launch may post into `chatId`: an authorized
|
|
189
|
+
* group, or any group in `open` access mode.
|
|
190
190
|
* @param chatId - target group chat id.
|
|
191
191
|
* @returns true when the chat is a permitted target.
|
|
192
192
|
*/
|
|
193
|
-
|
|
193
|
+
mayLaunchIn(chatId: string): boolean {
|
|
194
194
|
if (this.config().accessMode === 'open') return true
|
|
195
195
|
return this.deps.store.snapshot().groups.has(chatId)
|
|
196
196
|
}
|
package/src/cards/index.ts
CHANGED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Root card of a topic started through the `feishuTopics` service: the
|
|
3
|
+
* caller's title, an @-mention of the requester followed by the caller's lead
|
|
4
|
+
* sentence, an optional link table, and a status tag (进行中 → 已完成 /
|
|
5
|
+
* 已停止 / 失败 / 已中断) whose wording uses the caller's task name. The work
|
|
6
|
+
* itself streams in the topic below the card through the ordinary streaming
|
|
7
|
+
* card.
|
|
8
|
+
*
|
|
9
|
+
* @module dsh-channels/cards/topic
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { TopicRootCard } from '../topics/contract.ts'
|
|
13
|
+
import { CARD_CONFIG, CARD_SCHEMA, markdown, note } from './primitives.ts'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Root card status: `running` until the first turn settles (`done`,
|
|
17
|
+
* `stopped`, `error`), `failed` when the topic could not start, or
|
|
18
|
+
* `interrupted` when DSH restarted before the first turn settled.
|
|
19
|
+
*/
|
|
20
|
+
export type TopicStatus =
|
|
21
|
+
| { readonly kind: 'running' }
|
|
22
|
+
| { readonly kind: 'done' }
|
|
23
|
+
| { readonly kind: 'stopped' }
|
|
24
|
+
| { readonly kind: 'error' }
|
|
25
|
+
| { readonly kind: 'failed'; readonly reason: string }
|
|
26
|
+
| { readonly kind: 'interrupted' }
|
|
27
|
+
|
|
28
|
+
/** Rows per table page (Feishu caps `page_size` at 10). */
|
|
29
|
+
const TABLE_PAGE_SIZE = 10
|
|
30
|
+
|
|
31
|
+
/** Escape markdown emphasis characters in caller-supplied text. */
|
|
32
|
+
function plain(text: string): string {
|
|
33
|
+
return text.replace(/([*_~`[\]\\])/gu, '\\$1')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Percent-encode the characters that would end a markdown link target early. */
|
|
37
|
+
function linkTarget(url: string): string {
|
|
38
|
+
return url.replace(/[()\s]/gu, char => `%${char.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0')}`)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Header tag text, tag color, and header template of each status. */
|
|
42
|
+
function statusLook(status: TopicStatus, task: string): { tag: string; color: string; template: string } {
|
|
43
|
+
switch (status.kind) {
|
|
44
|
+
case 'running': return { tag: `${task}中`, color: 'blue', template: 'blue' }
|
|
45
|
+
case 'done': return { tag: '已完成', color: 'green', template: 'green' }
|
|
46
|
+
case 'stopped': return { tag: '已停止', color: 'neutral', template: 'grey' }
|
|
47
|
+
case 'error':
|
|
48
|
+
case 'failed': return { tag: '失败', color: 'red', template: 'red' }
|
|
49
|
+
case 'interrupted': return { tag: '已中断', color: 'orange', template: 'orange' }
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Footer line of each status. */
|
|
54
|
+
function statusNote(status: TopicStatus, task: string): string {
|
|
55
|
+
switch (status.kind) {
|
|
56
|
+
case 'running': return `${task}过程在本卡片下的话题内实时输出;群内已授权的成员可以在话题内直接回复追问。`
|
|
57
|
+
case 'done': return `${task}已完成,结果见话题;在话题内回复即可继续追问。`
|
|
58
|
+
case 'stopped': return `${task}已停止;在话题内回复即可继续。`
|
|
59
|
+
case 'error': return `${task}回合失败,原因见话题;在话题内回复即可重试或追问。`
|
|
60
|
+
case 'failed': return `${task}未能开始:${plain(status.reason)}`
|
|
61
|
+
case 'interrupted': return `DSH 重启导致${task}中断;在话题内回复即可继续。`
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The topic root card.
|
|
67
|
+
* @param requesterUserId - `user_id` @-mentioned before the lead sentence.
|
|
68
|
+
* @param card - what the caller asked the card to show.
|
|
69
|
+
* @param status - the status the card shows.
|
|
70
|
+
* @returns card JSON.
|
|
71
|
+
*/
|
|
72
|
+
export function buildTopicRootCard(requesterUserId: string, card: TopicRootCard, status: TopicStatus): object {
|
|
73
|
+
const task = plain(card.taskName)
|
|
74
|
+
const look = statusLook(status, card.taskName)
|
|
75
|
+
const lead = card.lead.map(run => (run.bold === true ? `**${plain(run.text)}**` : plain(run.text))).join('')
|
|
76
|
+
const elements: object[] = [markdown(`<at id=${requesterUserId}></at> ${lead}`)]
|
|
77
|
+
if (card.links !== undefined && card.links.rows.length > 0) {
|
|
78
|
+
elements.push({
|
|
79
|
+
tag: 'table',
|
|
80
|
+
page_size: TABLE_PAGE_SIZE,
|
|
81
|
+
row_height: 'low',
|
|
82
|
+
header_style: { background_style: 'grey', bold: true, lines: 1 },
|
|
83
|
+
columns: [
|
|
84
|
+
{ name: 'name', display_name: card.links.nameHeader, data_type: 'text', width: 'auto' },
|
|
85
|
+
{ name: 'link', display_name: card.links.linkHeader, data_type: 'markdown', width: 'auto' },
|
|
86
|
+
],
|
|
87
|
+
rows: card.links.rows.map(row => ({
|
|
88
|
+
name: row.name,
|
|
89
|
+
link: `[查看](${linkTarget(row.url)})`,
|
|
90
|
+
})),
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
elements.push(note(statusNote(status, task)))
|
|
94
|
+
return {
|
|
95
|
+
schema: CARD_SCHEMA,
|
|
96
|
+
config: CARD_CONFIG,
|
|
97
|
+
header: {
|
|
98
|
+
title: { tag: 'plain_text', content: card.title },
|
|
99
|
+
template: look.template,
|
|
100
|
+
text_tag_list: [{ tag: 'text_tag', text: { tag: 'plain_text', content: look.tag }, color: look.color }],
|
|
101
|
+
},
|
|
102
|
+
body: { elements },
|
|
103
|
+
}
|
|
104
|
+
}
|
package/src/client/origin.tsx
CHANGED
|
@@ -171,7 +171,7 @@ function OriginDetails({ origin, onRefresh }: { origin: SessionOriginWire; onRef
|
|
|
171
171
|
</span>
|
|
172
172
|
)])
|
|
173
173
|
const heading = originHeading(origin)
|
|
174
|
-
if (origin.shared) rows.push(['说明', '
|
|
174
|
+
if (origin.shared) rows.push(['说明', '群共享话题,群内成员都可以继续'])
|
|
175
175
|
rows.push(['开始于', formatTime(origin.createdAt)])
|
|
176
176
|
rows.push(origin.bound && origin.lastActivityAt !== undefined
|
|
177
177
|
? ['最近活跃', formatTime(origin.lastActivityAt)]
|
|
@@ -1,48 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 「高级」tab
|
|
3
|
-
*
|
|
2
|
+
* 「高级」tab:SDK 日志级别与 Web 覆盖文件(「恢复 profile 配置」删除整个
|
|
3
|
+
* 覆盖层,不走 draft)。
|
|
4
4
|
*
|
|
5
5
|
* @module dsh-channels/client/panel/advanced-tab
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { fillCopy, PANEL_COPY, TAB_FIELD_GROUPS } from '../../settings/contract.ts'
|
|
9
|
-
import { CredentialRow } from './credentials-card.tsx'
|
|
10
9
|
import { FieldSections, Row, Section } from './shared.tsx'
|
|
11
|
-
import type {
|
|
10
|
+
import type { FormView } from './shared.tsx'
|
|
12
11
|
|
|
13
12
|
export function AdvancedTab(props: {
|
|
14
13
|
form: FormView
|
|
15
|
-
analyzeToken: AnalyzeTokenView
|
|
16
14
|
onClear: () => void
|
|
17
15
|
}): JSX.Element {
|
|
18
|
-
const { form,
|
|
16
|
+
const { form, onClear } = props
|
|
19
17
|
const { panel, busy, locked } = form
|
|
20
|
-
const tokenState = panel.config.credentials.analyzeToken
|
|
21
18
|
const overridden = panel.config.overriddenKeys.length
|
|
22
19
|
return (
|
|
23
20
|
<div className="dsh-channels-cfg__tabBody">
|
|
24
|
-
<FieldSections
|
|
25
|
-
form={form}
|
|
26
|
-
groups={TAB_FIELD_GROUPS.advanced}
|
|
27
|
-
extra={{
|
|
28
|
-
analyze: (
|
|
29
|
-
<CredentialRow
|
|
30
|
-
label={PANEL_COPY.analyzeTokenLabel}
|
|
31
|
-
summary={PANEL_COPY.analyzeTokenSummary}
|
|
32
|
-
state={tokenState}
|
|
33
|
-
value={analyzeToken.value}
|
|
34
|
-
secret
|
|
35
|
-
placeholder={PANEL_COPY.analyzeTokenPlaceholder}
|
|
36
|
-
issue={analyzeToken.issues.find(item => item.key === 'analyzeToken')}
|
|
37
|
-
busy={analyzeToken.busy}
|
|
38
|
-
disabled={analyzeToken.busy !== null || analyzeToken.locked || !tokenState.writable}
|
|
39
|
-
onChange={analyzeToken.onChange}
|
|
40
|
-
onSave={analyzeToken.onSave}
|
|
41
|
-
onClear={analyzeToken.onClear}
|
|
42
|
-
/>
|
|
43
|
-
),
|
|
44
|
-
}}
|
|
45
|
-
/>
|
|
21
|
+
<FieldSections form={form} groups={TAB_FIELD_GROUPS.advanced} />
|
|
46
22
|
<Section title={PANEL_COPY.overlayTitle}>
|
|
47
23
|
<Row
|
|
48
24
|
label={PANEL_COPY.overlayPath}
|
|
@@ -60,7 +60,6 @@ import {
|
|
|
60
60
|
validateDraft,
|
|
61
61
|
} from './shared.tsx'
|
|
62
62
|
import type {
|
|
63
|
-
AnalyzeTokenView,
|
|
64
63
|
BusyAction,
|
|
65
64
|
CredentialsView,
|
|
66
65
|
FormView,
|
|
@@ -95,7 +94,6 @@ function FeishuChannelsPanel(): JSX.Element | null {
|
|
|
95
94
|
const [pollError, setPollError] = useState<string | null>(null)
|
|
96
95
|
const [appIdInput, setAppIdInput] = useState('')
|
|
97
96
|
const [appSecretInput, setAppSecretInput] = useState('')
|
|
98
|
-
const [analyzeTokenInput, setAnalyzeTokenInput] = useState('')
|
|
99
97
|
const [catalog, setCatalog] = useState<CatalogWire | null>(null)
|
|
100
98
|
const [catalogBusy, setCatalogBusy] = useState(false)
|
|
101
99
|
const [accessNotice, setAccessNotice] = useState<string | null>(null)
|
|
@@ -122,7 +120,6 @@ function FeishuChannelsPanel(): JSX.Element | null {
|
|
|
122
120
|
setDraft({})
|
|
123
121
|
setAppIdInput('')
|
|
124
122
|
setAppSecretInput('')
|
|
125
|
-
setAnalyzeTokenInput('')
|
|
126
123
|
} catch (error) {
|
|
127
124
|
setLoad({ phase: 'error', message: messageOf(error) })
|
|
128
125
|
}
|
|
@@ -573,28 +570,6 @@ function FeishuChannelsPanel(): JSX.Element | null {
|
|
|
573
570
|
}
|
|
574
571
|
}, [appIdInput, appSecretInput])
|
|
575
572
|
|
|
576
|
-
/** Save the analyze API token; it applies to the next request, no reconnect. */
|
|
577
|
-
const onSaveAnalyzeToken = useCallback(async (): Promise<void> => {
|
|
578
|
-
if (analyzeTokenInput.trim() === '') return
|
|
579
|
-
setBusy('credentials')
|
|
580
|
-
setNotice(null)
|
|
581
|
-
try {
|
|
582
|
-
const result = await saveCredentials({ analyzeToken: analyzeTokenInput })
|
|
583
|
-
if (!result.ok) {
|
|
584
|
-
setNotice({ kind: 'error', text: result.error, issues: result.issues })
|
|
585
|
-
return
|
|
586
|
-
}
|
|
587
|
-
setLoad({ phase: 'ready', panel: result.panel })
|
|
588
|
-
setStatus(result.panel.status)
|
|
589
|
-
setAnalyzeTokenInput('')
|
|
590
|
-
setNotice({ kind: 'ok', text: PANEL_COPY.analyzeTokenSaved })
|
|
591
|
-
} catch (error) {
|
|
592
|
-
setNotice({ kind: 'error', text: messageOf(error) })
|
|
593
|
-
} finally {
|
|
594
|
-
setBusy(null)
|
|
595
|
-
}
|
|
596
|
-
}, [analyzeTokenInput])
|
|
597
|
-
|
|
598
573
|
const onClearCredential = useCallback(async (field: CredentialField): Promise<void> => {
|
|
599
574
|
const confirmed = typeof window.confirm === 'function'
|
|
600
575
|
? window.confirm(PANEL_COPY.credentialRemoveConfirm)
|
|
@@ -612,9 +587,7 @@ function FeishuChannelsPanel(): JSX.Element | null {
|
|
|
612
587
|
setStatus(result.panel.status)
|
|
613
588
|
setNotice({
|
|
614
589
|
kind: 'ok',
|
|
615
|
-
text:
|
|
616
|
-
? PANEL_COPY.analyzeTokenRemoved
|
|
617
|
-
: result.reconnect === 'pending' ? PANEL_COPY.credentialRemovedPending : PANEL_COPY.credentialRemoved,
|
|
590
|
+
text: result.reconnect === 'pending' ? PANEL_COPY.credentialRemovedPending : PANEL_COPY.credentialRemoved,
|
|
618
591
|
})
|
|
619
592
|
} catch (error) {
|
|
620
593
|
setNotice({ kind: 'error', text: messageOf(error) })
|
|
@@ -676,16 +649,6 @@ function FeishuChannelsPanel(): JSX.Element | null {
|
|
|
676
649
|
onClear: (field: CredentialField) => { void onClearCredential(field) },
|
|
677
650
|
}), [appIdInput, appSecretInput, busy, hostBusy, issues, onClearCredential, onSaveCredentials])
|
|
678
651
|
|
|
679
|
-
const analyzeToken = useMemo<AnalyzeTokenView>(() => ({
|
|
680
|
-
value: analyzeTokenInput,
|
|
681
|
-
issues,
|
|
682
|
-
busy,
|
|
683
|
-
locked: hostBusy,
|
|
684
|
-
onChange: setAnalyzeTokenInput,
|
|
685
|
-
onSave: () => { void onSaveAnalyzeToken() },
|
|
686
|
-
onClear: () => { void onClearCredential('analyzeToken') },
|
|
687
|
-
}), [analyzeTokenInput, busy, hostBusy, issues, onClearCredential, onSaveAnalyzeToken])
|
|
688
|
-
|
|
689
652
|
if (load.phase === 'loading') {
|
|
690
653
|
return (
|
|
691
654
|
<div className="dsh-channels-cfg">
|
|
@@ -777,7 +740,7 @@ function FeishuChannelsPanel(): JSX.Element | null {
|
|
|
777
740
|
)
|
|
778
741
|
: null}
|
|
779
742
|
{activeTab === 'advanced'
|
|
780
|
-
? <AdvancedTab form={form}
|
|
743
|
+
? <AdvancedTab form={form} onClear={() => { void onClear() }} />
|
|
781
744
|
: null}
|
|
782
745
|
</div>
|
|
783
746
|
<SaveBar
|
|
@@ -162,18 +162,6 @@ export interface CredentialsView {
|
|
|
162
162
|
onClear: (field: CredentialField) => void
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
-
/** Analyze API token editor data owned by the page shell, rendered by the advanced tab. */
|
|
166
|
-
export interface AnalyzeTokenView {
|
|
167
|
-
value: string
|
|
168
|
-
issues: FieldIssueWire[]
|
|
169
|
-
busy: BusyAction | null
|
|
170
|
-
/** Host-side lifecycle busy (connecting/restarting): block writes. */
|
|
171
|
-
locked: boolean
|
|
172
|
-
onChange: (value: string) => void
|
|
173
|
-
onSave: () => void
|
|
174
|
-
onClear: () => void
|
|
175
|
-
}
|
|
176
|
-
|
|
177
165
|
/** Everything a field-bearing tab needs from the page shell's single draft. */
|
|
178
166
|
export interface FormView {
|
|
179
167
|
panel: PanelWire
|
package/src/config.ts
CHANGED
|
@@ -80,8 +80,26 @@ export const DEFAULT_TOPIC_MESSAGES_TIMEOUT_MS = 20_000
|
|
|
80
80
|
*/
|
|
81
81
|
export const DEFAULT_PROMPT_CONTEXT_TIMEOUT_MS = 3_000
|
|
82
82
|
|
|
83
|
-
/**
|
|
84
|
-
|
|
83
|
+
/**
|
|
84
|
+
* Keys of the log-platform analyze API, which moved to 观象
|
|
85
|
+
* (`@modesto_ma/dsh-guanxiang`). A value still set in `cordis.yml` or the Web
|
|
86
|
+
* overlay is ignored; the loader schema passes them through and
|
|
87
|
+
* {@link movedAnalyzeKeysOf} finds them so the deployment is told.
|
|
88
|
+
*/
|
|
89
|
+
export const MOVED_ANALYZE_KEYS = ['analyzeEnabled', 'analyzeToken'] as const
|
|
90
|
+
|
|
91
|
+
/** Log line naming where the moved analyze API settings went. */
|
|
92
|
+
export const MOVED_ANALYZE_NOTICE = 'dsh-channels: 日志分析接口已迁到观象(@modesto_ma/dsh-guanxiang),忽略 %s:请在观象面板「分析接口」中开启并保存令牌(GUANXIANG_ANALYZE_TOKEN)'
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The moved analyze API keys one raw config or overlay object still sets.
|
|
96
|
+
* @param raw - undecoded `config` or overlay JSON.
|
|
97
|
+
* @returns the keys present, in {@link MOVED_ANALYZE_KEYS} order.
|
|
98
|
+
*/
|
|
99
|
+
export function movedAnalyzeKeysOf(raw: unknown): string[] {
|
|
100
|
+
if (typeof raw !== 'object' || raw === null) return []
|
|
101
|
+
return MOVED_ANALYZE_KEYS.filter(key => Object.hasOwn(raw, key))
|
|
102
|
+
}
|
|
85
103
|
|
|
86
104
|
/** A credential reference: `FEISHU_APP_ID`-style name resolved through ctx.credentials. */
|
|
87
105
|
const credentialRefSchema = z.string().regex(
|
|
@@ -421,18 +439,6 @@ export const configSchema = z.object({
|
|
|
421
439
|
* its own deadline and is left to the host signal instead.
|
|
422
440
|
*/
|
|
423
441
|
questionTimeoutMs: z.number().int().min(10_000).max(3_600_000).default(DEFAULT_QUESTION_TIMEOUT_MS),
|
|
424
|
-
/**
|
|
425
|
-
* Switch for the log-platform analyze API (`POST /open-api/logalyze`
|
|
426
|
-
* on the DSH Web server). Off (default) answers 404; read per request, so
|
|
427
|
-
* toggling it needs no reconnect. Needs a host with a Web server.
|
|
428
|
-
*/
|
|
429
|
-
analyzeEnabled: z.boolean().default(false),
|
|
430
|
-
/**
|
|
431
|
-
* Credential reference holding the analyze API bearer token (defaults to
|
|
432
|
-
* `DSH_CHANNELS_ANALYZE_TOKEN`). Resolved per request, so rotating the value
|
|
433
|
-
* applies to the next request; an unresolvable token refuses every request.
|
|
434
|
-
*/
|
|
435
|
-
analyzeToken: credentialRefSchema.default(DEFAULT_ANALYZE_TOKEN_REF),
|
|
436
442
|
})
|
|
437
443
|
|
|
438
444
|
export type FeishuConfig = z.infer<typeof configSchema>
|
|
@@ -442,9 +448,13 @@ export type FeishuConfig = z.infer<typeof configSchema>
|
|
|
442
448
|
* `config` object; the loader validates that row with `undefined` before the
|
|
443
449
|
* user's patch layer supplies values. Accept that absence and fill the same
|
|
444
450
|
* defaults as {@link resolveConfig}, so enabling the plugin from the Web UI
|
|
445
|
-
* cannot fail with "expected object, received undefined".
|
|
451
|
+
* cannot fail with "expected object, received undefined". The moved analyze
|
|
452
|
+
* keys pass through unchanged so `apply()` can warn about them.
|
|
446
453
|
*/
|
|
447
|
-
export const pluginConfigSchema = configSchema.
|
|
454
|
+
export const pluginConfigSchema = configSchema.extend({
|
|
455
|
+
analyzeEnabled: z.unknown().optional(),
|
|
456
|
+
analyzeToken: z.unknown().optional(),
|
|
457
|
+
}).prefault({})
|
|
448
458
|
|
|
449
459
|
/**
|
|
450
460
|
* Validate the raw loader config. Values stay credential *references*; the
|
package/src/domain.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { z } from 'zod'
|
|
12
|
+
import type { TopicRootCard } from './topics/contract.ts'
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* Route key kinds. `thread` = real Feishu topic; `chat` = degraded chat-scoped
|
|
@@ -62,7 +63,7 @@ export const routeRecordSchema = z.object({
|
|
|
62
63
|
/** Sessions this topic has pointed at, oldest first. Optional for v1 compatibility. */
|
|
63
64
|
sessionHistory: z.array(sessionHistoryEntrySchema).optional(),
|
|
64
65
|
/**
|
|
65
|
-
* Group-shared route (
|
|
66
|
+
* Group-shared route (topics started through `feishuTopics`): every member admitted
|
|
66
67
|
* in `chatId` may continue the Session and decide its cards, not only
|
|
67
68
|
* `senderUserId` (who then names the requester). Absent = owner-only.
|
|
68
69
|
*/
|
|
@@ -287,44 +288,100 @@ export const processedRecordSchema = z.object({
|
|
|
287
288
|
|
|
288
289
|
export type ProcessedRecord = z.infer<typeof processedRecordSchema>
|
|
289
290
|
|
|
291
|
+
/** {@link TopicRootCard} as stored in a launch record. */
|
|
292
|
+
export const topicRootCardSchema = z.object({
|
|
293
|
+
title: z.string(),
|
|
294
|
+
taskName: z.string(),
|
|
295
|
+
lead: z.array(z.object({ text: z.string(), bold: z.boolean().optional() })),
|
|
296
|
+
links: z.object({
|
|
297
|
+
nameHeader: z.string(),
|
|
298
|
+
linkHeader: z.string(),
|
|
299
|
+
rows: z.array(z.object({ name: z.string(), url: z.string() })),
|
|
300
|
+
}).optional(),
|
|
301
|
+
})
|
|
302
|
+
|
|
290
303
|
/**
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
*
|
|
304
|
+
* Root card content written by plugin versions before the `feishuTopics`
|
|
305
|
+
* service (the built-in log-platform analyze API). Read only; converted by
|
|
306
|
+
* {@link rootCardOf}.
|
|
294
307
|
*/
|
|
295
|
-
|
|
296
|
-
|
|
308
|
+
const legacyAnalyzeSubjectSchema = z.object({
|
|
309
|
+
requesterUserId: z.string(),
|
|
310
|
+
triggeredAt: z.string(),
|
|
311
|
+
app: z.string(),
|
|
312
|
+
errorCode: z.string(),
|
|
313
|
+
logDetails: z.array(z.object({ name: z.string(), url: z.string() })),
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* One accepted `feishuTopics` launch, keyed by its idempotency key. Kept
|
|
318
|
+
* forever: a retried key answers the stored root card instead of posting a
|
|
319
|
+
* second topic, and a reused key with another fingerprint is refused. The
|
|
320
|
+
* records live in the `analyze_requests` table, named after the analyze API
|
|
321
|
+
* that wrote the first of them; those older records carry `subject` instead
|
|
322
|
+
* of `requesterUserId` + `card`, and their keys are the analyze API's
|
|
323
|
+
* `Idempotency-Key`s.
|
|
324
|
+
*/
|
|
325
|
+
export const topicLaunchRecordSchema = z.object({
|
|
326
|
+
/** The launch's idempotency key (also the table key). */
|
|
297
327
|
key: z.string().min(1),
|
|
298
|
-
/** SHA-256
|
|
328
|
+
/** The launch's fingerprint (older records: SHA-256 of the analyze request). */
|
|
299
329
|
bodyHash: z.string().min(1),
|
|
300
330
|
/** Target group chat. */
|
|
301
331
|
chatId: z.string().min(1),
|
|
302
|
-
/** Root card message id (the
|
|
332
|
+
/** Root card message id (the topic's root). */
|
|
303
333
|
messageId: z.string().min(1),
|
|
304
|
-
/** Epoch ms when the
|
|
334
|
+
/** Epoch ms when the launch was accepted. */
|
|
305
335
|
acceptedAt: z.number().int().nonnegative(),
|
|
306
|
-
/**
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
logDetails: z.array(z.object({ name: z.string(), url: z.string() })),
|
|
313
|
-
}),
|
|
336
|
+
/** Requester @-mentioned on the root card; absent on older records. */
|
|
337
|
+
requesterUserId: z.string().optional(),
|
|
338
|
+
/** What the root card shows, kept so the card can be redrawn after a restart; absent on older records. */
|
|
339
|
+
card: topicRootCardSchema.optional(),
|
|
340
|
+
/** Root card content of older records. */
|
|
341
|
+
subject: legacyAnalyzeSubjectSchema.optional(),
|
|
314
342
|
/**
|
|
315
343
|
* Root card status. `running` until the first turn settles; a record still
|
|
316
|
-
* `running` when the channel starts belongs to
|
|
344
|
+
* `running` when the channel starts belongs to a topic the previous
|
|
317
345
|
* instance never finished, and becomes `interrupted`.
|
|
318
346
|
*/
|
|
319
347
|
status: z.enum(['running', 'done', 'stopped', 'error', 'failed', 'interrupted']),
|
|
320
348
|
})
|
|
321
349
|
|
|
322
|
-
export type
|
|
350
|
+
export type TopicLaunchRecord = z.infer<typeof topicLaunchRecordSchema>
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* The requester and root card content of one launch record, converting the
|
|
354
|
+
* analyze API's older records to the card that API drew.
|
|
355
|
+
* @param record - stored launch record.
|
|
356
|
+
* @returns what to redraw, or undefined for a record carrying neither form.
|
|
357
|
+
*/
|
|
358
|
+
export function rootCardOf(record: TopicLaunchRecord): { requesterUserId: string; card: TopicRootCard } | undefined {
|
|
359
|
+
if (record.card !== undefined && record.requesterUserId !== undefined) {
|
|
360
|
+
return { requesterUserId: record.requesterUserId, card: record.card }
|
|
361
|
+
}
|
|
362
|
+
const subject = record.subject
|
|
363
|
+
if (subject === undefined) return undefined
|
|
364
|
+
return {
|
|
365
|
+
requesterUserId: subject.requesterUserId,
|
|
366
|
+
card: {
|
|
367
|
+
title: '日志分析',
|
|
368
|
+
taskName: '分析',
|
|
369
|
+
lead: [
|
|
370
|
+
{ text: `于 ${subject.triggeredAt} 发起了对 ` },
|
|
371
|
+
{ text: subject.app, bold: true },
|
|
372
|
+
{ text: ' 应用 ' },
|
|
373
|
+
{ text: subject.errorCode, bold: true },
|
|
374
|
+
{ text: ' 错误码不良日志的分析。' },
|
|
375
|
+
],
|
|
376
|
+
links: { nameHeader: '日志', linkHeader: '详情页', rows: subject.logDetails },
|
|
377
|
+
},
|
|
378
|
+
}
|
|
379
|
+
}
|
|
323
380
|
|
|
324
381
|
/**
|
|
325
382
|
* Where one Feishu-routed Session came from, keyed by its DSH session id.
|
|
326
383
|
* Written the first time a route record names the Session (new topic,
|
|
327
|
-
* `/renew`,
|
|
384
|
+
* `/renew`, `feishuTopics` launch) and kept after the route retires, so the Web
|
|
328
385
|
* client can still show the origin of an old Session. Names are resolved
|
|
329
386
|
* live when read, never stored.
|
|
330
387
|
*/
|
|
@@ -339,11 +396,11 @@ export const sessionOriginRecordSchema = z.object({
|
|
|
339
396
|
chatId: z.string().min(1),
|
|
340
397
|
/** `p2p` or `group`. */
|
|
341
398
|
chatType: z.enum(['p2p', 'group']),
|
|
342
|
-
/** `user_id` of the user who started the Session (the requester for
|
|
399
|
+
/** `user_id` of the user who started the Session (the requester for launched topics). */
|
|
343
400
|
senderUserId: z.string().min(1),
|
|
344
401
|
/** Epoch ms when the Session was bound to the route. */
|
|
345
402
|
createdAt: z.number().int().nonnegative(),
|
|
346
|
-
/** Group-shared route (
|
|
403
|
+
/** Group-shared route (topic started through `feishuTopics`). */
|
|
347
404
|
shared: z.literal(true).optional(),
|
|
348
405
|
})
|
|
349
406
|
|
|
@@ -390,7 +447,7 @@ export interface FeishuDomainSpecShape {
|
|
|
390
447
|
__key?: string
|
|
391
448
|
}
|
|
392
449
|
analyze_requests: {
|
|
393
|
-
valueSchema: typeof
|
|
450
|
+
valueSchema: typeof topicLaunchRecordSchema
|
|
394
451
|
__key?: string
|
|
395
452
|
}
|
|
396
453
|
session_origins: {
|
|
@@ -437,8 +494,8 @@ export const feishuDomain: FeishuDomainSpecShape = {
|
|
|
437
494
|
// independent grant. Added without a version bump like the other access
|
|
438
495
|
// tables: a missing table loads empty.
|
|
439
496
|
access_wiki_users: { valueSchema: accessUserRecordSchema },
|
|
440
|
-
//
|
|
441
|
-
analyze_requests: { valueSchema:
|
|
497
|
+
// `feishuTopics` launch records (historical name); a missing table loads empty.
|
|
498
|
+
analyze_requests: { valueSchema: topicLaunchRecordSchema },
|
|
442
499
|
// Session id → Feishu origin; a missing table loads empty.
|
|
443
500
|
session_origins: { valueSchema: sessionOriginRecordSchema },
|
|
444
501
|
},
|
|
@@ -507,11 +564,11 @@ export interface AccessRequestsTable {
|
|
|
507
564
|
entries(): IterableIterator<[string, AccessRequestRecord]>
|
|
508
565
|
}
|
|
509
566
|
|
|
510
|
-
/** Normalized table handle for
|
|
511
|
-
export interface
|
|
512
|
-
get(key: string):
|
|
513
|
-
put(key: string, value:
|
|
514
|
-
entries(): IterableIterator<[string,
|
|
567
|
+
/** Normalized table handle for `feishuTopics` launches (key = idempotency key). */
|
|
568
|
+
export interface TopicLaunchesTable {
|
|
569
|
+
get(key: string): TopicLaunchRecord | undefined
|
|
570
|
+
put(key: string, value: TopicLaunchRecord): Promise<unknown> | void
|
|
571
|
+
entries(): IterableIterator<[string, TopicLaunchRecord]>
|
|
515
572
|
}
|
|
516
573
|
|
|
517
574
|
/** Normalized table handle for the session-origin table (key = DSH session id). */
|
|
@@ -531,8 +588,8 @@ export interface FeishuDomainHandle {
|
|
|
531
588
|
accessRequests: AccessRequestsTable
|
|
532
589
|
/** Wiki whitelist (key = `user_id`); same records as {@link AccessUsersTable}. */
|
|
533
590
|
accessWikiUsers: AccessUsersTable
|
|
534
|
-
/**
|
|
535
|
-
|
|
591
|
+
/** `feishuTopics` launch records. */
|
|
592
|
+
topicLaunches: TopicLaunchesTable
|
|
536
593
|
/** Feishu origin of every Session a route ever named. */
|
|
537
594
|
sessionOrigins: SessionOriginsTable
|
|
538
595
|
getGlobal(): FeishuDomainGlobal
|
package/src/host/core.ts
CHANGED
|
@@ -317,10 +317,39 @@ export interface AgentPresetSelectedEvent extends SessionEventEnvelope {
|
|
|
317
317
|
data: { agentPreset: string }
|
|
318
318
|
}
|
|
319
319
|
|
|
320
|
+
/**
|
|
321
|
+
* Durable record of one model-request retry scheduled by `dsh-llm-retry`
|
|
322
|
+
* (DSH `packages/llm/llm-retry/src/types.ts`), appended before the backoff
|
|
323
|
+
* wait. The retry re-runs the failed step inside the SAME open turn: only
|
|
324
|
+
* `turn/end` ends the turn.
|
|
325
|
+
*/
|
|
326
|
+
export interface LlmRetryEvent extends SessionEventEnvelope {
|
|
327
|
+
type: 'llm/retry'
|
|
328
|
+
data: {
|
|
329
|
+
turn: number
|
|
330
|
+
step: number
|
|
331
|
+
provider: string
|
|
332
|
+
mode: 'normal' | 'always'
|
|
333
|
+
/** 1-based retry number. */
|
|
334
|
+
retry: number
|
|
335
|
+
/** Retry budget in `normal` mode; absent in `always` mode (unbounded). */
|
|
336
|
+
maxRetries?: number
|
|
337
|
+
delayMs: number
|
|
338
|
+
failure: { message: string; code: string; [key: string]: unknown }
|
|
339
|
+
[key: string]: unknown
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Appended when a retry's backoff wait ends, right before the retried request starts. */
|
|
344
|
+
export interface LlmRetryStartedEvent extends SessionEventEnvelope {
|
|
345
|
+
type: 'llm/retry-started'
|
|
346
|
+
data: { turn: number; step: number; retry: number; [key: string]: unknown }
|
|
347
|
+
}
|
|
348
|
+
|
|
320
349
|
export interface OtherSessionEvent extends SessionEventEnvelope {
|
|
321
350
|
/** Unknown/extension event types are ignored by the folds. */
|
|
322
351
|
type: Exclude<string, 'assistant/message' | 'step/start' | 'turn/end' | 'request/header'
|
|
323
|
-
| 'tool/call' | 'tool/result' | 'subagent/descriptor'
|
|
352
|
+
| 'tool/call' | 'tool/result' | 'subagent/descriptor' | 'llm/retry' | 'llm/retry-started'
|
|
324
353
|
| 'command/run' | 'command/done'
|
|
325
354
|
| 'tool-workflow/run-start' | 'tool-workflow/agent-start' | 'tool-workflow/agent-end' | 'tool-workflow/run-end'>
|
|
326
355
|
data: unknown
|
|
@@ -342,4 +371,6 @@ export type SessionEvent =
|
|
|
342
371
|
| ToolWorkflowAgentStartEvent
|
|
343
372
|
| ToolWorkflowAgentEndEvent
|
|
344
373
|
| ToolWorkflowRunEndEvent
|
|
374
|
+
| LlmRetryEvent
|
|
375
|
+
| LlmRetryStartedEvent
|
|
345
376
|
|
package/src/host/services.ts
CHANGED
|
@@ -288,6 +288,11 @@ export interface HostContext {
|
|
|
288
288
|
logger?: HostLogger
|
|
289
289
|
/** Safe service lookup (returns undefined for absent services). */
|
|
290
290
|
get?(name: string): unknown
|
|
291
|
+
/**
|
|
292
|
+
* Provide a service owned by this plugin's fiber; it is withdrawn when the
|
|
293
|
+
* plugin unloads. Optional so test contexts may omit it.
|
|
294
|
+
*/
|
|
295
|
+
provide?(name: string, value: unknown): () => void
|
|
291
296
|
/** Host events this plugin subscribes to (overloads per event). */
|
|
292
297
|
on(name: 'session/event', listener: (session: Session, event: SessionEvent) => void): () => boolean
|
|
293
298
|
/** Live (transient) model stream for every agent; replaces the removed `assistant/chunk` events. */
|