@modesto_ma/dsh-channels 0.6.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 -557
- 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 -125
- package/src/analyze/launcher.ts +0 -376
- package/src/analyze/server.ts +0 -207
- package/src/cards/analyze.ts +0 -116
package/src/index.ts
CHANGED
|
@@ -17,13 +17,13 @@
|
|
|
17
17
|
|
|
18
18
|
import type { HostContext } from './dsh-types.ts'
|
|
19
19
|
import { FeishuService } from './service.ts'
|
|
20
|
-
import { pluginConfigSchema, resolveConfig } from './config.ts'
|
|
20
|
+
import { MOVED_ANALYZE_NOTICE, movedAnalyzeKeysOf, pluginConfigSchema, resolveConfig } from './config.ts'
|
|
21
21
|
import type { FeishuConfig } from './config.ts'
|
|
22
22
|
import { defaultChannelFactory } from './feishu-channel.ts'
|
|
23
23
|
import type { ChannelFactory } from './feishu-channel.ts'
|
|
24
24
|
import { OverlayStore, defaultOverlayPath } from './settings/overlay.ts'
|
|
25
25
|
import { mountSettingsRoutes } from './settings/routes.ts'
|
|
26
|
-
import {
|
|
26
|
+
import { FEISHU_TOPICS_SERVICE } from './topics/launcher.ts'
|
|
27
27
|
import { SettingsSurface } from './settings/surface.ts'
|
|
28
28
|
import { createHostCatalog } from './settings/catalog.ts'
|
|
29
29
|
import { createSettingsDirectoryPicker } from './settings/directory-picker.ts'
|
|
@@ -40,6 +40,7 @@ export type * from './session-export.ts'
|
|
|
40
40
|
export type * from './stats.ts'
|
|
41
41
|
export type * from './questions.ts'
|
|
42
42
|
export type * from './settings/contract.ts'
|
|
43
|
+
export type * from './topics/contract.ts'
|
|
43
44
|
export * from './access/index.ts'
|
|
44
45
|
export type { AccessGroupRecord, AccessRequestRecord, AccessUserRecord } from './domain.ts'
|
|
45
46
|
export { CATALOG_ROUTE, CLEAR_CONFIG_ROUTE, CLEAR_CREDENTIAL_ROUTE, EDITABLE_KEYS, PANEL_COPY, PANEL_ROUTE, PICK_DIRECTORY_ROUTE, RECONNECT_ROUTE, RESTART_KEYS, SAVE_CONFIG_ROUTE, SAVE_CREDENTIALS_ROUTE, STATUS_ROUTE } from './settings/contract.ts'
|
|
@@ -54,7 +55,7 @@ export type { HostDirectoryPickerService, SettingsDirectoryPicker } from './sett
|
|
|
54
55
|
export { mountSettingsRoutes, registerSettingsRoutes } from './settings/routes.ts'
|
|
55
56
|
export type { HostConnection, HostFetchRoute } from './settings/routes.ts'
|
|
56
57
|
export type { ApprovalOutcome, ApprovalRequest, ApprovalAnswererListener, AssistantStreamFrame, AskUserQuestionAnswer, AskUserQuestionAnswerItem, AskUserQuestionItem, AskUserQuestionRequestEvent, UserQuestionAnswererListener, AgentCancelCause, SessionStatsView, TokenUsageView, ContextPressureView, SessionProjections } from './dsh-types.ts'
|
|
57
|
-
export { buildCard, buildInterruptionCard, cardJsonBytes, CARD_MAX_BYTES, CARD_TARGET_BYTES, REASONING_MAX_BYTES, INTERRUPTION_NOTICE, MAX_TABLES_PER_CARD, PANEL_TITLES, PANEL_CHEVRON_TOKEN, segmentCardContent, splitBodyForCard, stripTables, tableBlocks, TRUNCATION_SUFFIX, buildApprovalCard, buildApprovalSettledCard, buildQuestionCard, buildQuestionSettledCard, MAX_QUESTION_OPTIONS } from './cards.ts'
|
|
58
|
+
export { buildCard, buildInterruptionCard, cardJsonBytes, CARD_MAX_BYTES, CARD_TARGET_BYTES, REASONING_MAX_BYTES, INTERRUPTION_NOTICE, MAX_TABLES_PER_CARD, PANEL_TITLES, PANEL_CHEVRON_TOKEN, segmentCardContent, splitBodyForCard, stripTables, tableBlocks, TRUNCATION_SUFFIX, buildApprovalCard, buildApprovalSettledCard, buildQuestionCard, buildQuestionSettledCard, buildTopicRootCard, MAX_QUESTION_OPTIONS } from './cards.ts'
|
|
58
59
|
export { ApprovalAnswerer } from './approvals.ts'
|
|
59
60
|
export { QuestionAnswerer } from './questions.ts'
|
|
60
61
|
export { armModelCommand, buildCommandResultCard, CHANNEL_COMMAND_NAMES, commandResultCardKind, isSupportedCommand, parseSlashCommand, registerChannelCommands, registerModelCommand, RouteOperationQueue, SUPPORTED_COMMAND_NAMES, supportedCommandHelp, supportedCommandList } from './commands.ts'
|
|
@@ -63,6 +64,8 @@ export { buildCompactComparisonCard, buildRenewCard, buildSessionHistoryCard, bu
|
|
|
63
64
|
export { createLogicalSessionExport, DSH_CHANNELS_VERSION, LOGICAL_EXPORT_FORMAT, LOGICAL_EXPORT_FORMAT_VERSION, serializeLogicalSession } from './session-export.ts'
|
|
64
65
|
export { addUsage, applyProjectionViews, averageDecodeTokensPerSecond, averageTtftMs, cacheHitPct, EMPTY_TOTALS, foldAll, foldEvent, foldSessionStats, measureSessionContext, readProjectionViews, usageFromBuckets } from './stats.ts'
|
|
65
66
|
export { FeishuService } from './service.ts'
|
|
67
|
+
export { FEISHU_TOPICS_SERVICE, FEISHU_TOPICS_VERSION, TopicLauncher } from './topics/launcher.ts'
|
|
68
|
+
export type { TopicLauncherDeps } from './topics/launcher.ts'
|
|
66
69
|
export { TurnStreamer } from './streamer.ts'
|
|
67
70
|
export { ensureWorkspace, stagingPathFor, stagingRoot, topicDirFor, topicTokenFor, workspacePathFor, workspaceTitleFor, worktreeBranchFor } from './workspaces.ts'
|
|
68
71
|
export { accessGroupRecordSchema, accessRequestRecordSchema, accessUserRecordSchema, appendSessionHistory, chatRouteKey, expiredRouteRecordSchema, feishuDomain, normalizeRouteRecord, normalizeSessionHistory, routeRecordSchema, sessionDirOf, sessionHistoryEntrySchema, inflightSnapshotSchema, processedRecordSchema } from './domain.ts'
|
|
@@ -125,7 +128,11 @@ export function apply(ctx: HostContext, config?: unknown): void {
|
|
|
125
128
|
createHostCatalog(ctx, warn),
|
|
126
129
|
createSettingsDirectoryPicker(ctx),
|
|
127
130
|
))
|
|
128
|
-
|
|
131
|
+
// Other plugins (观象's analyze API) start group-shared topics through this
|
|
132
|
+
// service; it answers `not-connected` until the Feishu connection is up.
|
|
133
|
+
ctx.provide?.(FEISHU_TOPICS_SERVICE, service.topics())
|
|
134
|
+
const moved = movedAnalyzeKeysOf(config)
|
|
135
|
+
if (moved.length > 0) warn(MOVED_ANALYZE_NOTICE, `cordis.yml 中的 ${moved.join('、')}`)
|
|
129
136
|
ctx.effect(() => {
|
|
130
137
|
// Boot loads the Web overlay and connects only when the Feishu application
|
|
131
138
|
// credentials are complete; an incomplete configuration or a failed
|
package/src/routing/inbound.ts
CHANGED
|
@@ -273,7 +273,7 @@ export class InboundRouter {
|
|
|
273
273
|
*/
|
|
274
274
|
private readonly firstDeliveries = new Map<string, { promise: Promise<void>; release(): void }>()
|
|
275
275
|
/**
|
|
276
|
-
* topic id → reservation by an external starter (the
|
|
276
|
+
* topic id → reservation by an external starter (the `feishuTopics` launcher) that
|
|
277
277
|
* opened the topic but has not published its route yet. A reply in the topic
|
|
278
278
|
* waits for the release and then routes against the published record, or
|
|
279
279
|
* falls through as an unbound topic when the start failed.
|
package/src/service.ts
CHANGED
|
@@ -52,12 +52,11 @@ import type { PermissionPresets, SessionQuery } from './dsh-types.ts'
|
|
|
52
52
|
import { FEISHU_USER_ID_PATTERN, feishuDomain, sessionDirOf } from './domain.ts'
|
|
53
53
|
import type {
|
|
54
54
|
AccessGroupRecord, AccessRequestRecord, AccessUserRecord,
|
|
55
|
-
|
|
56
|
-
ProcessedRecord, RawDomainHandle, RouteRecord, SessionOriginRecord,
|
|
55
|
+
ExpiredRouteRecord, FeishuDomainGlobal, FeishuDomainHandle, InflightSnapshot,
|
|
56
|
+
ProcessedRecord, RawDomainHandle, RouteRecord, SessionOriginRecord, TopicLaunchRecord,
|
|
57
57
|
} from './domain.ts'
|
|
58
58
|
import { RouteStore } from './session/routes.ts'
|
|
59
|
-
import {
|
|
60
|
-
import type { AnalyzeHandler } from './analyze/server.ts'
|
|
59
|
+
import { TopicLauncher } from './topics/launcher.ts'
|
|
61
60
|
import { InflightRepair } from './session/repair.ts'
|
|
62
61
|
import { SessionManager } from './session/manager.ts'
|
|
63
62
|
import type { LiveAgent, RenewCommit } from './session/manager.ts'
|
|
@@ -168,7 +167,7 @@ async function openFeishuDomain(ctx: HostContext): Promise<FeishuDomainHandle> {
|
|
|
168
167
|
const accessUsersRaw = domain.table('access_users')
|
|
169
168
|
const accessRequestsRaw = domain.table('access_requests')
|
|
170
169
|
const accessWikiUsersRaw = domain.table('access_wiki_users')
|
|
171
|
-
const
|
|
170
|
+
const topicLaunchesRaw = domain.table('analyze_requests')
|
|
172
171
|
const sessionOriginsRaw = domain.table('session_origins')
|
|
173
172
|
return {
|
|
174
173
|
routes: {
|
|
@@ -219,10 +218,10 @@ async function openFeishuDomain(ctx: HostContext): Promise<FeishuDomainHandle> {
|
|
|
219
218
|
delete: key => accessWikiUsersRaw.delete(key),
|
|
220
219
|
entries: () => accessWikiUsersRaw.entries() as IterableIterator<[string, AccessUserRecord]>,
|
|
221
220
|
},
|
|
222
|
-
|
|
223
|
-
get: key =>
|
|
224
|
-
put: (key, value) =>
|
|
225
|
-
entries: () =>
|
|
221
|
+
topicLaunches: {
|
|
222
|
+
get: key => topicLaunchesRaw.get(key) as TopicLaunchRecord | undefined,
|
|
223
|
+
put: (key, value) => topicLaunchesRaw.put(key, value),
|
|
224
|
+
entries: () => topicLaunchesRaw.entries() as IterableIterator<[string, TopicLaunchRecord]>,
|
|
226
225
|
},
|
|
227
226
|
sessionOrigins: {
|
|
228
227
|
get: key => sessionOriginsRaw.get(key) as SessionOriginRecord | undefined,
|
|
@@ -310,8 +309,8 @@ export class FeishuService {
|
|
|
310
309
|
private readonly messageReader = new MessageReader({ channel: () => this.channel })
|
|
311
310
|
/** Inbound routing: dedup, admission, decision tree, session establishment. */
|
|
312
311
|
private readonly inbound: InboundRouter
|
|
313
|
-
/**
|
|
314
|
-
private readonly
|
|
312
|
+
/** The `feishuTopics` service (other plugins' launches → shared topics). */
|
|
313
|
+
private readonly topicLauncher: TopicLauncher
|
|
315
314
|
/** Agent/session lifecycle: live registry, resume, presets, renew. */
|
|
316
315
|
private readonly sessions: SessionManager
|
|
317
316
|
/**
|
|
@@ -591,7 +590,7 @@ export class FeishuService {
|
|
|
591
590
|
handleSlash: (msg, line) => this.handleSlashMessage(msg, line),
|
|
592
591
|
},
|
|
593
592
|
})
|
|
594
|
-
this.
|
|
593
|
+
this.topicLauncher = new TopicLauncher({
|
|
595
594
|
ctx: this.ctx,
|
|
596
595
|
config: this.config,
|
|
597
596
|
started: () => this.started,
|
|
@@ -603,8 +602,7 @@ export class FeishuService {
|
|
|
603
602
|
streamer: () => this.streamer,
|
|
604
603
|
queue: this.routeOperations,
|
|
605
604
|
reserveTopic: threadId => this.inbound.reserveTopic(threadId),
|
|
606
|
-
|
|
607
|
-
resolveToken: () => resolveCredentialRef(this.ctx, this.config.analyzeToken).catch(() => undefined),
|
|
605
|
+
mayLaunchIn: chatId => this.accessController.mayLaunchIn(chatId),
|
|
608
606
|
})
|
|
609
607
|
}
|
|
610
608
|
|
|
@@ -975,8 +973,8 @@ export class FeishuService {
|
|
|
975
973
|
}
|
|
976
974
|
}
|
|
977
975
|
|
|
978
|
-
/** The
|
|
979
|
-
|
|
976
|
+
/** The `feishuTopics` service, provided by `apply()`. */
|
|
977
|
+
topics(): TopicLauncher { return this.topicLauncher }
|
|
980
978
|
isRunning(): boolean { return this.started }
|
|
981
979
|
isRestarting(): boolean { return this.restarting }
|
|
982
980
|
/** True while the panel's explicit connect action is in flight. */
|
|
@@ -1475,10 +1473,8 @@ export class FeishuService {
|
|
|
1475
1473
|
return { configured: false, writable: false }
|
|
1476
1474
|
}
|
|
1477
1475
|
}
|
|
1478
|
-
const [appId, appSecret
|
|
1479
|
-
|
|
1480
|
-
])
|
|
1481
|
-
return { appId, appSecret, analyzeToken }
|
|
1476
|
+
const [appId, appSecret] = await Promise.all([read(this.config.appId), read(this.config.appSecret)])
|
|
1477
|
+
return { appId, appSecret }
|
|
1482
1478
|
}
|
|
1483
1479
|
|
|
1484
1480
|
/** The host credentials seam, when mounted. */
|
|
@@ -1521,7 +1517,6 @@ export class FeishuService {
|
|
|
1521
1517
|
switch (field) {
|
|
1522
1518
|
case 'appId': return this.config.appId
|
|
1523
1519
|
case 'appSecret': return this.config.appSecret
|
|
1524
|
-
case 'analyzeToken': return this.config.analyzeToken
|
|
1525
1520
|
}
|
|
1526
1521
|
}
|
|
1527
1522
|
|
|
@@ -1658,8 +1653,8 @@ export class FeishuService {
|
|
|
1658
1653
|
void this.runScopeCheck(channel, appId, ownerLookup)
|
|
1659
1654
|
if (this.config.slashCommandPanel) void this.runSlashCommandSync(channel)
|
|
1660
1655
|
// Before admission opens, so every `running` record left is a previous instance's.
|
|
1661
|
-
await this.
|
|
1662
|
-
this.
|
|
1656
|
+
await this.topicLauncher.markInterrupted()
|
|
1657
|
+
this.topicLauncher.open()
|
|
1663
1658
|
// Repair cards a previous instance left mid-flight (best-effort, background).
|
|
1664
1659
|
void this.repair.repairAll().catch((error: unknown) => {
|
|
1665
1660
|
this.ctx.logger?.warn?.('dsh-channels: inflight repair failed: %s', String(error))
|
|
@@ -1723,8 +1718,8 @@ export class FeishuService {
|
|
|
1723
1718
|
// before their Agent/channel/storage dependencies are disposed, while
|
|
1724
1719
|
// already queued route operations observe `started === false` and retire.
|
|
1725
1720
|
this.started = false
|
|
1726
|
-
this.
|
|
1727
|
-
await this.
|
|
1721
|
+
this.topicLauncher.close()
|
|
1722
|
+
await this.topicLauncher.drain()
|
|
1728
1723
|
for (const controller of this.commandControllers) controller.abort()
|
|
1729
1724
|
await Promise.allSettled([...this.commandOperations])
|
|
1730
1725
|
this.commandControllers.clear()
|
package/src/settings/contract.ts
CHANGED
|
@@ -69,13 +69,13 @@ export interface SessionOriginWire {
|
|
|
69
69
|
chatName?: string
|
|
70
70
|
/** 群头像 URL(群聊且能读取群信息时)。 */
|
|
71
71
|
chatAvatarUrl?: string
|
|
72
|
-
/** 发起人 user_id
|
|
72
|
+
/** 发起人 user_id(群共享话题为调用方指定的发起人)。 */
|
|
73
73
|
senderUserId: string
|
|
74
74
|
/** 发起人姓名(通讯录或群成员列表能查到时)。 */
|
|
75
75
|
senderName?: string
|
|
76
76
|
/** 发起人头像 URL(通讯录能查到时)。 */
|
|
77
77
|
senderAvatarUrl?: string
|
|
78
|
-
/**
|
|
78
|
+
/** 群共享话题(经 `feishuTopics` 发起):群内成员都可以继续。 */
|
|
79
79
|
shared: boolean
|
|
80
80
|
/** Epoch ms when the Session was bound to its Feishu route. */
|
|
81
81
|
createdAt: number
|
|
@@ -187,8 +187,6 @@ export const EDITABLE_KEYS = [
|
|
|
187
187
|
'feishuTools.wikiRead',
|
|
188
188
|
'feishuTools.wikiWrite',
|
|
189
189
|
'wikiRoots',
|
|
190
|
-
// 日志平台分析接口(访问令牌是凭据引用 analyzeToken,值写在 DSH 凭据里)
|
|
191
|
-
'analyzeEnabled',
|
|
192
190
|
// 日志
|
|
193
191
|
'loggerLevel',
|
|
194
192
|
] as const
|
|
@@ -242,7 +240,6 @@ export type FieldGroup =
|
|
|
242
240
|
| 'limits'
|
|
243
241
|
| 'access'
|
|
244
242
|
| 'wiki'
|
|
245
|
-
| 'analyze'
|
|
246
243
|
| 'logging'
|
|
247
244
|
|
|
248
245
|
/** One selectable value rendered by the generic form. */
|
|
@@ -355,11 +352,8 @@ export interface CredentialRefWire {
|
|
|
355
352
|
source?: string
|
|
356
353
|
}
|
|
357
354
|
|
|
358
|
-
/**
|
|
359
|
-
|
|
360
|
-
* (resolved on connect) and the analyze API token (resolved per request).
|
|
361
|
-
*/
|
|
362
|
-
export type CredentialField = 'appId' | 'appSecret' | 'analyzeToken'
|
|
355
|
+
/** The credential slots the panel may write: the Feishu application pair (resolved on connect). */
|
|
356
|
+
export type CredentialField = 'appId' | 'appSecret'
|
|
363
357
|
|
|
364
358
|
/** Official-SDK WebSocket lifecycle snapshot (structural copy, no SDK import on the wire). */
|
|
365
359
|
export interface ConnectionWire {
|
|
@@ -466,7 +460,6 @@ export interface PanelConfigWire {
|
|
|
466
460
|
credentials: {
|
|
467
461
|
appId: CredentialRefWire
|
|
468
462
|
appSecret: CredentialRefWire
|
|
469
|
-
analyzeToken: CredentialRefWire
|
|
470
463
|
}
|
|
471
464
|
}
|
|
472
465
|
|
|
@@ -602,7 +595,6 @@ export type ClearConfigResponse =
|
|
|
602
595
|
export interface SaveCredentialsRequest {
|
|
603
596
|
appId?: string
|
|
604
597
|
appSecret?: string
|
|
605
|
-
analyzeToken?: string
|
|
606
598
|
}
|
|
607
599
|
|
|
608
600
|
/** `POST /api/dsh-channels.clearCredential` body. */
|
|
@@ -615,8 +607,7 @@ export interface ClearCredentialRequest {
|
|
|
615
607
|
*
|
|
616
608
|
* - `auto`: the channel was stopped and connected immediately after the save.
|
|
617
609
|
* - `pending`: the channel is running; reconnect for the value to take effect.
|
|
618
|
-
* - `not-needed`: removed while stopped,
|
|
619
|
-
* changed (it applies to the next request), nothing to reconnect.
|
|
610
|
+
* - `not-needed`: removed while stopped, nothing to reconnect.
|
|
620
611
|
* - `unconfigured`: saved, but the other credential of the pair is still
|
|
621
612
|
* missing — the plugin keeps waiting (no connection attempt) until it is
|
|
622
613
|
* configured.
|
|
@@ -666,7 +657,7 @@ export const TAB_FIELD_GROUPS: Readonly<Record<PanelTabId, readonly FieldGroup[]
|
|
|
666
657
|
attachments: ['attachments', 'tools', 'limits'],
|
|
667
658
|
access: ['access'],
|
|
668
659
|
wiki: ['wiki'],
|
|
669
|
-
advanced: ['
|
|
660
|
+
advanced: ['logging'],
|
|
670
661
|
}
|
|
671
662
|
|
|
672
663
|
/** Section heading for one field group inside its tab. */
|
|
@@ -681,7 +672,6 @@ export const FIELD_GROUP_LABELS: Readonly<Record<FieldGroup, string>> = {
|
|
|
681
672
|
limits: '限额与超时',
|
|
682
673
|
access: '准入',
|
|
683
674
|
wiki: '知识库工具',
|
|
684
|
-
analyze: '日志分析接口',
|
|
685
675
|
logging: '日志',
|
|
686
676
|
}
|
|
687
677
|
|
|
@@ -808,11 +798,6 @@ export const PANEL_COPY = {
|
|
|
808
798
|
loading: '加载中…',
|
|
809
799
|
loadFailed: '设置面板加载失败',
|
|
810
800
|
// ---- advanced -----------------------------------------------------------
|
|
811
|
-
analyzeTokenLabel: '访问令牌',
|
|
812
|
-
analyzeTokenSummary: '请求头 Authorization: Bearer 的值,保存后立即生效;未配置时接口拒绝所有请求',
|
|
813
|
-
analyzeTokenPlaceholder: '填写新令牌(建议 32 位以上随机串)',
|
|
814
|
-
analyzeTokenSaved: '令牌已保存,下一次请求生效',
|
|
815
|
-
analyzeTokenRemoved: '令牌已移除,接口将拒绝所有请求',
|
|
816
801
|
overlayTitle: 'Web 覆盖',
|
|
817
802
|
overlayPath: '覆盖文件',
|
|
818
803
|
overlayCount: '{n} 项配置由面板覆盖',
|
package/src/settings/overlay.ts
CHANGED
|
@@ -16,7 +16,7 @@ import { promises as fs } from 'node:fs'
|
|
|
16
16
|
import { homedir } from 'node:os'
|
|
17
17
|
import { dirname, join } from 'node:path'
|
|
18
18
|
import type { z } from 'zod'
|
|
19
|
-
import { configSchema } from '../config.ts'
|
|
19
|
+
import { configSchema, MOVED_ANALYZE_NOTICE, movedAnalyzeKeysOf } from '../config.ts'
|
|
20
20
|
import type { FeishuConfig } from '../config.ts'
|
|
21
21
|
import { normalizeWorkspaceBaseDir } from '../workspaces.ts'
|
|
22
22
|
import { EDITABLE_KEYS } from './contract.ts'
|
|
@@ -245,6 +245,8 @@ export class OverlayStore {
|
|
|
245
245
|
this.logger?.warn?.('dsh-channels: Web 覆盖配置不是合法 JSON(%s),按未覆盖处理:%s', this.file, String(error))
|
|
246
246
|
return {}
|
|
247
247
|
}
|
|
248
|
+
const moved = movedAnalyzeKeysOf(raw)
|
|
249
|
+
if (moved.length > 0) this.logger?.warn?.(MOVED_ANALYZE_NOTICE, `${this.file} 中的 ${moved.join('、')}`)
|
|
248
250
|
const parsed = parseOverlay(raw)
|
|
249
251
|
if (!parsed.ok) {
|
|
250
252
|
this.logger?.warn?.(
|
package/src/settings/routes.ts
CHANGED
|
@@ -175,13 +175,12 @@ export function registerSettingsRoutes(connection: HostConnection, surface: Sett
|
|
|
175
175
|
const values: SaveCredentialsRequest = {}
|
|
176
176
|
if (payload.appId !== undefined) values.appId = payload.appId as string
|
|
177
177
|
if (payload.appSecret !== undefined) values.appSecret = payload.appSecret as string
|
|
178
|
-
if (payload.analyzeToken !== undefined) values.analyzeToken = payload.analyzeToken as string
|
|
179
178
|
return result(await surface.saveCredentials(values))
|
|
180
179
|
}),
|
|
181
180
|
post(CLEAR_CREDENTIAL_ROUTE, async (request) => {
|
|
182
181
|
const field = (await readJsonObject(request)).field
|
|
183
|
-
if (field !== 'appId' && field !== 'appSecret'
|
|
184
|
-
return json(400, { ok: false, error: 'field 必须是 appId
|
|
182
|
+
if (field !== 'appId' && field !== 'appSecret') {
|
|
183
|
+
return json(400, { ok: false, error: 'field 必须是 appId 或 appSecret' })
|
|
185
184
|
}
|
|
186
185
|
return result(await surface.clearCredential(field as CredentialField))
|
|
187
186
|
}),
|
package/src/settings/surface.ts
CHANGED
|
@@ -135,7 +135,7 @@ export interface CredentialInfo {
|
|
|
135
135
|
const RESTART_SET = new Set<string>(RESTART_KEYS)
|
|
136
136
|
|
|
137
137
|
/** Every credential slot the panel writes, in form order. */
|
|
138
|
-
const CREDENTIAL_FIELDS: readonly CredentialField[] = ['appId', 'appSecret'
|
|
138
|
+
const CREDENTIAL_FIELDS: readonly CredentialField[] = ['appId', 'appSecret']
|
|
139
139
|
|
|
140
140
|
/** Optional fields: an empty string in the panel clears the override. */
|
|
141
141
|
const OPTIONAL_TEXT_KEYS = new Set<EditableKey>([
|
|
@@ -160,7 +160,6 @@ const BOOLEAN_KEYS = new Set<EditableKey>([
|
|
|
160
160
|
'feishuTools.sendFile',
|
|
161
161
|
'feishuTools.wikiRead',
|
|
162
162
|
'feishuTools.wikiWrite',
|
|
163
|
-
'analyzeEnabled',
|
|
164
163
|
])
|
|
165
164
|
|
|
166
165
|
/** Bounds and entry format of one `string-list` field. */
|
|
@@ -438,12 +437,6 @@ const FIELD_META: Record<EditableKey, FieldMeta> = {
|
|
|
438
437
|
placeholder: 'https://<租户>.feishu.cn/wiki/space/<space_id>',
|
|
439
438
|
wide: true,
|
|
440
439
|
},
|
|
441
|
-
analyzeEnabled: {
|
|
442
|
-
label: '日志分析接口',
|
|
443
|
-
group: 'analyze',
|
|
444
|
-
summary: '关闭时接口返回 404',
|
|
445
|
-
help: '接口挂在 DSH Web 端口上:POST /api/dsh-channels.logalyze,请求需带 Authorization: Bearer <访问令牌>。只有带 Web 的 profile 才有这个接口。改后立即生效,不需要重连。',
|
|
446
|
-
},
|
|
447
440
|
loggerLevel: { label: 'SDK 日志级别', group: 'logging' },
|
|
448
441
|
}
|
|
449
442
|
|
|
@@ -785,7 +778,6 @@ export class SettingsSurface {
|
|
|
785
778
|
let credentialStates: Record<CredentialField, CredentialInfo> = {
|
|
786
779
|
appId: { configured: false, writable: false },
|
|
787
780
|
appSecret: { configured: false, writable: false },
|
|
788
|
-
analyzeToken: { configured: false, writable: false },
|
|
789
781
|
}
|
|
790
782
|
try {
|
|
791
783
|
credentialStates = await this.target.credentialStates()
|
|
@@ -801,7 +793,6 @@ export class SettingsSurface {
|
|
|
801
793
|
const credentials: PanelConfigWire['credentials'] = {
|
|
802
794
|
appId: toWire(effective.appId, credentialStates.appId),
|
|
803
795
|
appSecret: toWire(effective.appSecret, credentialStates.appSecret),
|
|
804
|
-
analyzeToken: toWire(effective.analyzeToken, credentialStates.analyzeToken),
|
|
805
796
|
}
|
|
806
797
|
|
|
807
798
|
// The access snapshot comes from in-memory maps, but a broken seam must
|
|
@@ -963,9 +954,6 @@ export class SettingsSurface {
|
|
|
963
954
|
} catch (error) {
|
|
964
955
|
return { ok: false, error: messageOf(error) }
|
|
965
956
|
}
|
|
966
|
-
if (writes.every(write => write.field === 'analyzeToken')) {
|
|
967
|
-
return { ok: true, panel: await this.panel(), reconnect: 'not-needed' }
|
|
968
|
-
}
|
|
969
957
|
return this.finishCredentialChange('save')
|
|
970
958
|
}
|
|
971
959
|
|
|
@@ -981,7 +969,6 @@ export class SettingsSurface {
|
|
|
981
969
|
} catch (error) {
|
|
982
970
|
return { ok: false, error: messageOf(error) }
|
|
983
971
|
}
|
|
984
|
-
if (field === 'analyzeToken') return { ok: true, panel: await this.panel(), reconnect: 'not-needed' }
|
|
985
972
|
return this.finishCredentialChange('clear')
|
|
986
973
|
}
|
|
987
974
|
|
package/src/streamer.ts
CHANGED
|
@@ -245,6 +245,14 @@ interface TurnState {
|
|
|
245
245
|
* durable model history, so the card must drop them again.
|
|
246
246
|
*/
|
|
247
247
|
attempts: Map<string, AttemptMark>
|
|
248
|
+
/**
|
|
249
|
+
* The model-request retry `dsh-llm-retry` scheduled for the current step
|
|
250
|
+
* (`llm/retry`), shown on the tool line until the retried request produces
|
|
251
|
+
* output. A retry stays inside the open turn: only `turn/end` settles it.
|
|
252
|
+
*/
|
|
253
|
+
retry: RetryActivity | undefined
|
|
254
|
+
/** Retries scheduled during this turn (reported on a failed settled card). */
|
|
255
|
+
retries: number
|
|
248
256
|
/** The owner clicked stop; the button renders disabled until the turn settles. */
|
|
249
257
|
stopRequested: boolean
|
|
250
258
|
/** A non-owner clicked stop; the owner-only notice renders under the button. */
|
|
@@ -253,6 +261,20 @@ interface TurnState {
|
|
|
253
261
|
queuedDenied: boolean
|
|
254
262
|
}
|
|
255
263
|
|
|
264
|
+
/** One scheduled model-request retry (`llm/retry` → `llm/retry-started`). */
|
|
265
|
+
interface RetryActivity {
|
|
266
|
+
/** 1-based retry number. */
|
|
267
|
+
retry: number
|
|
268
|
+
/** Normal-mode budget; undefined in always mode. */
|
|
269
|
+
maxRetries: number | undefined
|
|
270
|
+
/** Failure code of the attempt that triggered the retry. */
|
|
271
|
+
code: string
|
|
272
|
+
/** Unix ms when the backoff wait ends. */
|
|
273
|
+
until: number
|
|
274
|
+
/** Set by `llm/retry-started`: the retried request is in flight. */
|
|
275
|
+
started: boolean
|
|
276
|
+
}
|
|
277
|
+
|
|
256
278
|
/** Card/body lengths captured at one stream attempt's `start` frame. */
|
|
257
279
|
interface AttemptMark {
|
|
258
280
|
cardCount: number
|
|
@@ -426,6 +448,8 @@ export class TurnStreamer {
|
|
|
426
448
|
toolKind: '',
|
|
427
449
|
heartbeatTimer: undefined,
|
|
428
450
|
attempts: new Map(),
|
|
451
|
+
retry: undefined,
|
|
452
|
+
retries: 0,
|
|
429
453
|
stopRequested: false,
|
|
430
454
|
stopDenied: false,
|
|
431
455
|
queuedDenied: false,
|
|
@@ -638,6 +662,10 @@ export class TurnStreamer {
|
|
|
638
662
|
}
|
|
639
663
|
case 'assistant/message': {
|
|
640
664
|
if (state.turn === undefined) state.turn = event.data.turn
|
|
665
|
+
if (state.retry !== undefined) {
|
|
666
|
+
state.retry = undefined
|
|
667
|
+
this.markDirty(session.id, state)
|
|
668
|
+
}
|
|
641
669
|
if (event.data.usage !== undefined) {
|
|
642
670
|
state.lastUsage = {
|
|
643
671
|
usage: event.data.usage,
|
|
@@ -726,6 +754,28 @@ export class TurnStreamer {
|
|
|
726
754
|
}
|
|
727
755
|
break
|
|
728
756
|
}
|
|
757
|
+
case 'llm/retry': {
|
|
758
|
+
// The failed attempt ended the step's request, not the turn: the loop
|
|
759
|
+
// re-runs the step after the backoff, so the card stays live.
|
|
760
|
+
if (state.turn === undefined) state.turn = event.data.turn
|
|
761
|
+
state.retry = {
|
|
762
|
+
retry: event.data.retry,
|
|
763
|
+
maxRetries: event.data.mode === 'normal' ? event.data.maxRetries : undefined,
|
|
764
|
+
code: event.data.failure.code,
|
|
765
|
+
until: Date.now() + Math.max(0, event.data.delayMs),
|
|
766
|
+
started: false,
|
|
767
|
+
}
|
|
768
|
+
state.retries++
|
|
769
|
+
this.markDirty(session.id, state)
|
|
770
|
+
break
|
|
771
|
+
}
|
|
772
|
+
case 'llm/retry-started': {
|
|
773
|
+
if (state.retry !== undefined && state.retry.retry === event.data.retry) {
|
|
774
|
+
state.retry.started = true
|
|
775
|
+
this.markDirty(session.id, state)
|
|
776
|
+
}
|
|
777
|
+
break
|
|
778
|
+
}
|
|
729
779
|
case 'turn/end': {
|
|
730
780
|
// The ended turn belongs to the front state: assign it if a turn was
|
|
731
781
|
// rejected/emptied before any step started.
|
|
@@ -762,6 +812,12 @@ export class TurnStreamer {
|
|
|
762
812
|
}
|
|
763
813
|
case 'chunk': {
|
|
764
814
|
const chunk = frame.chunk
|
|
815
|
+
if ((chunk.type === 'text-delta' || chunk.type === 'reasoning-delta')
|
|
816
|
+
&& chunk.text !== '' && state.retry !== undefined) {
|
|
817
|
+
// The retried request is producing output again.
|
|
818
|
+
state.retry = undefined
|
|
819
|
+
this.markDirty(session.id, state)
|
|
820
|
+
}
|
|
765
821
|
if (chunk.type === 'text-delta') {
|
|
766
822
|
if (chunk.text !== '') this.closeReasoning(state)
|
|
767
823
|
const last = state.cards[state.cards.length - 1]
|
|
@@ -784,7 +840,13 @@ export class TurnStreamer {
|
|
|
784
840
|
this.closeReasoning(state)
|
|
785
841
|
const mark = state.attempts.get(frame.attemptId)
|
|
786
842
|
state.attempts.delete(frame.attemptId)
|
|
787
|
-
|
|
843
|
+
// A failed request attempt settles as `assistant/attempt`: like an
|
|
844
|
+
// abandoned one, its partial output is not model-visible history, and
|
|
845
|
+
// a retry streams the step again from scratch — keeping it would
|
|
846
|
+
// repeat the reply on the card.
|
|
847
|
+
const discarded = frame.outcome.kind === 'abandoned'
|
|
848
|
+
|| frame.outcome.eventType === 'assistant/attempt'
|
|
849
|
+
if (discarded && mark !== undefined) {
|
|
788
850
|
this.rollbackAttempt(state, mark)
|
|
789
851
|
this.markDirty(session.id, state)
|
|
790
852
|
}
|
|
@@ -812,11 +874,13 @@ export class TurnStreamer {
|
|
|
812
874
|
}
|
|
813
875
|
|
|
814
876
|
/**
|
|
815
|
-
* Drop one
|
|
877
|
+
* Drop one discarded attempt's deltas. The attempt's text is a contiguous
|
|
816
878
|
* suffix of the logical body: segments created during the attempt are
|
|
817
879
|
* removed, the segment that was last at `start` is truncated, and the
|
|
818
880
|
* reasoning tail is cut. Already-delivered plain-text segments cannot be
|
|
819
|
-
* retracted and stop the removal
|
|
881
|
+
* retracted and stop the removal; segment cards already sent (or being
|
|
882
|
+
* sent) cannot be withdrawn, so they are emptied and kept. The newest
|
|
883
|
+
* remaining card is reopened so a retry streams into it again.
|
|
820
884
|
*/
|
|
821
885
|
private rollbackAttempt(state: TurnState, mark: AttemptMark): void {
|
|
822
886
|
const lastIndex = mark.cardCount - 1
|
|
@@ -824,6 +888,11 @@ export class TurnStreamer {
|
|
|
824
888
|
const card = state.cards[index]
|
|
825
889
|
if (card === undefined) continue
|
|
826
890
|
if (card.mode === 'text' && card.messageId !== undefined) break
|
|
891
|
+
if (card.messageId !== undefined || card.sending) {
|
|
892
|
+
card.body = ''
|
|
893
|
+
card.dirty = true
|
|
894
|
+
continue
|
|
895
|
+
}
|
|
827
896
|
state.cards.splice(index, 1)
|
|
828
897
|
}
|
|
829
898
|
const tail = state.cards[lastIndex]
|
|
@@ -833,8 +902,16 @@ export class TurnStreamer {
|
|
|
833
902
|
tail.body = tail.body.slice(0, length)
|
|
834
903
|
tail.dirty = true
|
|
835
904
|
}
|
|
905
|
+
const last = state.cards[state.cards.length - 1]
|
|
906
|
+
if (last !== undefined) {
|
|
907
|
+
if ((last.mode === 'text' && last.messageId !== undefined) || last.failed) {
|
|
908
|
+
state.cards.push(this.freshSegment(''))
|
|
909
|
+
} else {
|
|
910
|
+
last.frozen = false
|
|
911
|
+
}
|
|
912
|
+
}
|
|
836
913
|
state.reasoning = state.reasoning.slice(0, mark.reasoningLength)
|
|
837
|
-
// The
|
|
914
|
+
// The discarded attempt's reasoning time is not part of the visible turn.
|
|
838
915
|
state.reasoningMs = mark.reasoningMs
|
|
839
916
|
state.reasoningSince = mark.reasoningSince
|
|
840
917
|
}
|
|
@@ -1049,6 +1126,13 @@ export class TurnStreamer {
|
|
|
1049
1126
|
if (state.awaitingApproval.length > 0) {
|
|
1050
1127
|
return clipActivity(`⏳ 等待你审批:${[...new Set(state.awaitingApproval)].join('、')}`)
|
|
1051
1128
|
}
|
|
1129
|
+
const retry = state.retry
|
|
1130
|
+
if (retry !== undefined) {
|
|
1131
|
+
const count = retry.maxRetries === undefined ? `${retry.retry}` : `${retry.retry}/${retry.maxRetries}`
|
|
1132
|
+
if (retry.started) return clipActivity(`🔁 模型请求重试中(第 ${count} 次)`)
|
|
1133
|
+
const wait = Math.max(0, Math.ceil((retry.until - now) / 1_000))
|
|
1134
|
+
return clipActivity(`⚠️ 模型请求失败(${retry.code}),${wait} 秒后第 ${count} 次重试`)
|
|
1135
|
+
}
|
|
1052
1136
|
const tool = state.toolActivity
|
|
1053
1137
|
if (tool === undefined) return ''
|
|
1054
1138
|
const prefix = tool.name === 'subagent'
|
|
@@ -1295,7 +1379,8 @@ export class TurnStreamer {
|
|
|
1295
1379
|
this.closeReasoning(state)
|
|
1296
1380
|
if (failure !== undefined) {
|
|
1297
1381
|
const code = failure.code === undefined ? '' : `\n\n错误码:\`${failure.code}\``
|
|
1298
|
-
const
|
|
1382
|
+
const retried = state.retries === 0 ? '' : `\n\n已自动重试 ${state.retries} 次`
|
|
1383
|
+
const notice = `❌ **本轮运行失败**\n\n${failure.message}${code}${retried}`
|
|
1299
1384
|
const tail = state.cards[state.cards.length - 1]
|
|
1300
1385
|
if (tail !== undefined) {
|
|
1301
1386
|
if (tail.mode === 'text' || tail.failed) {
|
|
@@ -1322,6 +1407,7 @@ export class TurnStreamer {
|
|
|
1322
1407
|
state.heartbeatTimer = undefined
|
|
1323
1408
|
}
|
|
1324
1409
|
state.tool = ''
|
|
1410
|
+
state.retry = undefined
|
|
1325
1411
|
// Freeze the tail and spawn any remaining segment cards.
|
|
1326
1412
|
this.splitOverflow(sessionId, state)
|
|
1327
1413
|
|