dsh-hams 0.1.3 → 0.1.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.
Files changed (50) hide show
  1. package/CHANGELOG.md +3 -3
  2. package/README.md +4 -0
  3. package/dist/index.js +11335 -0
  4. package/package.json +9 -5
  5. package/src/domain/check.ts +0 -193
  6. package/src/domain/errors.ts +0 -59
  7. package/src/domain/evidence.ts +0 -134
  8. package/src/domain/ids.ts +0 -31
  9. package/src/host/chat-runtime.ts +0 -489
  10. package/src/host/dsh-adapter.ts +0 -867
  11. package/src/host/session-adapter.ts +0 -276
  12. package/src/http/handler.ts +0 -1132
  13. package/src/index.js +0 -53
  14. package/src/service/audit.ts +0 -92
  15. package/src/service/chat.ts +0 -1100
  16. package/src/service/contracts.ts +0 -193
  17. package/src/service/defaults.ts +0 -182
  18. package/src/service/deliverables.ts +0 -240
  19. package/src/service/hams.ts +0 -3103
  20. package/src/service/knowledge.ts +0 -940
  21. package/src/service/member-config.ts +0 -39
  22. package/src/service/members.ts +0 -506
  23. package/src/service/memory.ts +0 -375
  24. package/src/service/permissions.ts +0 -118
  25. package/src/service/project-lifecycle.ts +0 -233
  26. package/src/service/projects.ts +0 -840
  27. package/src/service/receipts.ts +0 -63
  28. package/src/service/templates.ts +0 -140
  29. package/src/service/tick.ts +0 -751
  30. package/src/service/tools.ts +0 -711
  31. package/src/service/types.ts +0 -219
  32. package/src/service/work-item-create.ts +0 -159
  33. package/src/service/workspace-profile.ts +0 -443
  34. package/src/store/backup.ts +0 -239
  35. package/src/store/bootstrap.ts +0 -225
  36. package/src/store/db.ts +0 -179
  37. package/src/store/lock.ts +0 -194
  38. package/src/store/migrations.ts +0 -211
  39. package/src/store/schema/001_p1.sql +0 -846
  40. package/src/store/schema/001_p1.sql.ts +0 -865
  41. package/src/store/schema/002_knowledge.sql +0 -54
  42. package/src/store/schema/002_knowledge.sql.ts +0 -68
  43. package/src/store/schema/003_projects.sql.ts +0 -3
  44. package/src/store/schema/004_team.sql.ts +0 -50
  45. package/src/store/schema/005_target_check.sql.ts +0 -22
  46. package/src/store/schema/006_target_flow.sql.ts +0 -19
  47. package/src/store/schema/007_project_delete.sql.ts +0 -17
  48. package/src/store/schema/008_memory.sql.ts +0 -47
  49. package/src/store/schema/generate-projects.mjs +0 -16
  50. package/src/store/seed.ts +0 -114
@@ -1,489 +0,0 @@
1
- /**
2
- * 宿主侧对话运行时:把 HAMS 的单聊/群聊接到真实的 DSH agent 会话上。
3
- *
4
- * 三条边界(与设计一致):
5
- * - **不改宿主 profile**:bot 用的 `hams-bot` preset 在插件运行时注册(C11 实测结论:
6
- * preset 是 agent 作用域的组合,不碰公司 YAML 与 permissionPresets);空组合 = 没有
7
- * shell / 文件写这类 agent 作用域插件,bot 只剩全局注册的 HAMS 工具。
8
- * - **不抄聊天正文**:消息用 `sessionQuery.readSession()` 从会话日志读(活会话也走它),
9
- * HAMS 库只存会话指针。
10
- * - **不 import 宿主包**:插件不在宿主的 node_modules 里,所有宿主能力都从 `ctx.get()`
11
- * 现取;拿不到就报 HOST_CAPABILITY_MISSING,绝不假装有会话。
12
- */
13
- import path from 'node:path'
14
- import { mkdir } from 'node:fs/promises'
15
- import { randomUUID } from 'node:crypto'
16
- import type { ChatMessage, ChatModelRow, ChatPresetRow, ChatRuntime } from '../service/chat.ts'
17
-
18
- interface SessionLike {
19
- id: string
20
- events?: unknown
21
- }
22
- interface AgentLike {
23
- session: SessionLike
24
- status?: string
25
- followup(message: unknown): void
26
- inject?(message: unknown): void
27
- cancel(cause: { kind: 'user' }, options: { keepInbox: true }): void
28
- }
29
- interface AgentHandleLike {
30
- agent: AgentLike
31
- dispose(): void | Promise<void>
32
- }
33
- interface AgentsService {
34
- create(options: Record<string, unknown>): Promise<AgentHandleLike>
35
- resume(options: Record<string, unknown>): Promise<AgentHandleLike>
36
- get?(sessionId: string): { status?: string } | undefined
37
- }
38
- interface SessionQueryService {
39
- readSession(sessionId: string): Promise<{ events?: readonly unknown[] }>
40
- }
41
- interface PresetsService {
42
- list(): Promise<ChatPresetRow[]>
43
- register(definition: { id: string; name?: string; description?: string; order?: number; plugins: readonly unknown[] }): Promise<() => Promise<void>>
44
- resolve(id?: string): Promise<ChatPresetRow & { id: string }>
45
- mount(agentCtx: unknown, id: string): Promise<unknown>
46
- presetForSession?(session: unknown): string | null
47
- }
48
- interface LlmService {
49
- listProviders(): { id: string; name: string }[]
50
- listModels(provider: string): Promise<{ provider: string; id: string; name: string }[]>
51
- }
52
- interface DefaultModelService {
53
- currentSelection(): { provider: string; model: string; reasoningEffort?: string } | undefined
54
- }
55
-
56
- export const HAMS_BOT_PRESET = 'hams-bot'
57
-
58
- /**
59
- * bot 组合:persona(告诉它是 HAMS 的执行体)+ 压缩(长会话必备)。
60
- * 刻意不装 bash / tool-fs / subagent / 联网:C11 要求 bot 会话里没有 shell 与文件写,
61
- * 而且「装了再关 write」在 compositionInventory 里无法自证,干脆不装。
62
- */
63
- const HAMS_BOT_PRESET_DEFINITION = {
64
- id: HAMS_BOT_PRESET,
65
- name: 'HAMS bot',
66
- description: 'HAMS 工作项执行体:没有 shell、文件写、委派与联网,只用宿主全局工具(含 HAMS 窄语义工具)',
67
- order: 90,
68
- plugins: [
69
- {
70
- id: 'persona',
71
- name: '@deepseek-ai/dsh-persona',
72
- config: {
73
- prefix:
74
- 'You are a HAMS work-item agent. Facts about the work item come from the HAMS tools; never invent state or close a work item yourself. ' +
75
- 'Answer the human in Chinese, briefly. When the human is chatting in a project group, you are the coordinator: you may break work into tasks and assign them to other bots via the HAMS tools.',
76
- },
77
- },
78
- {
79
- id: 'compaction',
80
- name: 'cordis:group',
81
- group: true,
82
- isolate: { compaction: true, toolResultPruner: true },
83
- config: [
84
- { id: 'compaction-basic', name: '@deepseek-ai/dsh-compaction-basic' },
85
- { id: 'command-compact', name: '@deepseek-ai/dsh-command-compact' },
86
- {
87
- id: 'tool-result-pruner',
88
- name: '@deepseek-ai/dsh-compaction-tool-result-pruner',
89
- config: { thresholdChars: 8192, headChars: 4096, tailChars: 1024 },
90
- },
91
- ],
92
- },
93
- ],
94
- } as const
95
-
96
- export interface HostChatRuntimeOptions {
97
- ctx: { get?: (name: string) => unknown }
98
- /** `$DSH_HOME/hams`:bot 会话的工作目录在 `<dataDir>/bots/<memberId>`。 */
99
- dataDir: string
100
- log?: (message: string) => void
101
- }
102
-
103
- /** 会话事件 → 聊天消息。只取真人输入与助手正文,工具调用与推理不进聊天。 */
104
- export function chatMessagesFromEvents(events: readonly unknown[]): { messages: ChatMessage[]; running: boolean; error?: string } {
105
- const messages: ChatMessage[] = []
106
- const openTurns = new Set<number>()
107
- let running = false
108
- /** 最近一轮以 error 结束时的原因:模型没配、鉴权失败、provider 挂了都靠这个浮到界面上。 */
109
- let lastError: string | undefined
110
- const text = (content: unknown): string => {
111
- if (!Array.isArray(content)) return ''
112
- return content
113
- .filter((block) => block && typeof block === 'object' && (block as { type?: string }).type === 'text' && typeof (block as { text?: unknown }).text === 'string')
114
- .map((block) => String((block as { text: string }).text))
115
- .join('\n')
116
- .trim()
117
- }
118
- for (const raw of events) {
119
- if (!raw || typeof raw !== 'object') continue
120
- const event = raw as { type?: string; seq?: number; time?: number; data?: Record<string, unknown>; surfaceOp?: unknown }
121
- // 压缩会用 replace 把一段历史替换成摘要副本;人看的 transcript 只取 append 的事件。
122
- if (event.surfaceOp !== undefined && event.surfaceOp !== 'append') continue
123
- const data = event.data || {}
124
- if (event.type === 'turn/start') {
125
- const turn = Number(data.turn)
126
- if (Number.isFinite(turn)) openTurns.add(turn)
127
- running = true
128
- lastError = undefined
129
- continue
130
- }
131
- if (event.type === 'turn/end') {
132
- const turn = Number(data.turn)
133
- if (Number.isFinite(turn)) openTurns.delete(turn)
134
- running = openTurns.size > 0
135
- const reason = data.reason as { kind?: string; error?: { message?: unknown } } | undefined
136
- if (reason && reason.kind === 'error') {
137
- const message = reason.error && typeof reason.error.message === 'string' ? reason.error.message : ''
138
- lastError = message || '这一轮执行失败(宿主没有给出原因)'
139
- }
140
- continue
141
- }
142
- if (event.type === 'user/message') {
143
- const source = data.source as { kind?: string } | undefined
144
- if (!source || source.kind !== 'user') continue
145
- const body = text(data.content)
146
- if (!body) continue
147
- messages.push({ id: String(data.id || `u-${messages.length}`), role: 'user', text: body, at: Number(event.time) || 0, seq: Number(event.seq) || 0 })
148
- continue
149
- }
150
- if (event.type === 'assistant/message') {
151
- const message = data.message as { id?: string; content?: unknown } | undefined
152
- const body = text(message && message.content)
153
- if (!body) continue
154
- messages.push({ id: String((message && message.id) || `a-${messages.length}`), role: 'assistant', text: body, at: Number(event.time) || 0, seq: Number(event.seq) || 0 })
155
- }
156
- }
157
- return { messages, running: running && openTurns.size > 0, ...(lastError ? { error: lastError } : {}) }
158
- }
159
-
160
- /**
161
- * 模型串 → agents 的 agentOptions。
162
- *
163
- * model id 本身可能带 `/`(本机默认路由就是 `auto/deepseek-v4.1-flash`),所以不能按第一个
164
- * 斜杠硬切:先跨 provider 精确匹配完整 id,再看前缀是不是已注册 provider,最后才当裸名字
165
- * 交给默认 provider。目录探测失败时按前缀/默认处理,不阻塞开会话。
166
- */
167
- export interface DefaultSelection { provider?: string; model?: string; reasoningEffort?: string }
168
-
169
- /**
170
- * 会话要用的 AgentOptions:优先 bot 自己配的模型;没配就用宿主默认模型兜底。
171
- * 两边都不存在时返回 undefined(调用方应给出「配置模型」的明确错误,而不是让宿主抛 has no provider/model)。
172
- */
173
- export async function resolveAgentOptions(
174
- choice: string | null,
175
- llmService?: LlmService,
176
- fallback?: DefaultSelection,
177
- ): Promise<{ provider?: string; model: string; reasoningEffort?: string } | undefined> {
178
- const parsed = await parseModelChoice(choice, llmService, fallback?.provider)
179
- if (parsed?.model) return parsed
180
- if (!fallback?.provider || !fallback.model) return undefined
181
- return {
182
- provider: String(fallback.provider),
183
- model: String(fallback.model),
184
- ...(fallback.reasoningEffort ? { reasoningEffort: String(fallback.reasoningEffort) } : {}),
185
- }
186
- }
187
-
188
- export async function parseModelChoice(
189
- value: string | null,
190
- llmService?: LlmService,
191
- fallbackProvider?: string,
192
- ): Promise<{ provider?: string; model: string } | undefined> {
193
- const raw = (value || '').trim()
194
- if (!raw) return undefined
195
- let providerIds: string[] = []
196
- if (llmService) {
197
- try {
198
- providerIds = llmService.listProviders().map((row) => row.id)
199
- } catch {
200
- providerIds = []
201
- }
202
- }
203
- if (llmService && providerIds.length) {
204
- for (const provider of providerIds) {
205
- try {
206
- const models = await llmService.listModels(provider)
207
- if (models.some((row) => row.id === raw)) return { provider, model: raw }
208
- } catch {
209
- // 单个 provider 目录失败不影响其它
210
- }
211
- }
212
- const longestFirst = [...providerIds].sort((a, b) => b.length - a.length)
213
- for (const provider of longestFirst) {
214
- if (raw.startsWith(`${provider}/`)) return { provider, model: raw.slice(provider.length + 1) }
215
- }
216
- }
217
- return { provider: fallbackProvider, model: raw }
218
- }
219
-
220
- /** 注册 bot preset(进程内只做一次);装不起来返回 null,让调用方退回宿主默认 preset。 */
221
- let presetPromise: Promise<string | null> | null = null
222
- export function ensureHamsBotPreset(ctx: { get?: (name: string) => unknown }, log?: (line: string) => void): Promise<string | null> {
223
- if (presetPromise) return presetPromise
224
- const note = log || (() => {})
225
- presetPromise = (async () => {
226
- const registry = (() => {
227
- try {
228
- const found = ctx.get ? ctx.get('agentPresets') : undefined
229
- return found && typeof found === 'object' ? (found as PresetsService) : undefined
230
- } catch {
231
- return undefined
232
- }
233
- })()
234
- if (!registry) return null
235
- try {
236
- const list = await registry.list()
237
- if (Array.isArray(list) && list.some((row) => row && row.id === HAMS_BOT_PRESET)) {
238
- const resolved = await registry.resolve(HAMS_BOT_PRESET)
239
- if (resolved && resolved.broken) {
240
- note(`[hams] agent preset ${HAMS_BOT_PRESET} 不可用:${resolved.broken}`)
241
- return null
242
- }
243
- return HAMS_BOT_PRESET
244
- }
245
- // register 不因装不起来而 reject:失败只会落成 record.broken,所以必须再 resolve 一次。
246
- await registry.register(HAMS_BOT_PRESET_DEFINITION as never)
247
- const resolved = await registry.resolve(HAMS_BOT_PRESET)
248
- if (resolved && resolved.broken) {
249
- note(`[hams] agent preset ${HAMS_BOT_PRESET} 不可用:${resolved.broken}`)
250
- return null
251
- }
252
- note(`[hams] 已注册 agent preset ${HAMS_BOT_PRESET}`)
253
- return HAMS_BOT_PRESET
254
- } catch (error) {
255
- note(`[hams] 注册 ${HAMS_BOT_PRESET} 失败,退回宿主默认 preset:${String(error)}`)
256
- return null
257
- }
258
- })()
259
- return presetPromise
260
- }
261
-
262
- /** 目录名只保留安全字符:会话 key 里带冒号与成员 id,不能直接当路径。 */
263
- function safeSegment(value: string): string {
264
- return String(value || 'default').replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'default'
265
- }
266
-
267
- export function createHostChatRuntime(options: HostChatRuntimeOptions): ChatRuntime & { dispose(): Promise<void> } {
268
- const { ctx, dataDir } = options
269
- const log = options.log || (() => {})
270
- const handles = new Map<string, Promise<AgentHandleLike>>()
271
- const openings = new Map<string, Promise<AgentHandleLike>>()
272
- const meta = new Map<string, { memberId: string; preset: string; model: string | null }>()
273
- const contextSent = new Set<string>()
274
- let modelsCache: { at: number; rows: ChatModelRow[] } | null = null
275
- let closed = false
276
-
277
- const service = <T>(name: string): T | undefined => {
278
- if (typeof ctx.get !== 'function') return undefined
279
- try {
280
- const found = ctx.get(name)
281
- return found && typeof found === 'object' ? (found as T) : undefined
282
- } catch {
283
- return undefined
284
- }
285
- }
286
- const agents = () => service<AgentsService>('agents')
287
- const sessionQuery = () => service<SessionQueryService>('sessionQuery')
288
- const presets = () => service<PresetsService>('agentPresets')
289
- const llm = () => service<LlmService>('llm')
290
- const defaultModelService = () => service<DefaultModelService>('agentDefaultModel')
291
-
292
- /** 把「你是谁、在哪个项目」作为注入上下文送进会话:模型看得到,聊天记录里不显示。 */
293
- function sendContext(handle: AgentHandleLike, sessionId: string, context?: string): void {
294
- if (!context || contextSent.has(sessionId) || typeof handle.agent.inject !== 'function') return
295
- try {
296
- handle.agent.inject({
297
- id: `msg-${randomUUID()}`,
298
- role: 'user',
299
- content: [{ type: 'text', text: context }],
300
- source: { kind: 'hams-chat-context', form: 'instructions' },
301
- })
302
- contextSent.add(sessionId)
303
- } catch (error) {
304
- log(`[hams] 注入对话上下文失败:${String(error)}`)
305
- }
306
- }
307
-
308
- async function openSession(input: { sessionId?: string; memberId: string; preset: string; model: string | null; context?: string; cwdKey?: string }): Promise<AgentHandleLike> {
309
- const registry = agents()
310
- if (!registry) throw new Error('宿主没有 agents 服务')
311
- // bot 没配模型时用宿主默认模型兜底:否则新会话一开就报 has no provider/model。
312
- const parsedModel = await resolveAgentOptions(input.model, llm(), defaultModelService()?.currentSelection?.())
313
- if (!parsedModel) throw new Error('这个 bot 没有配置模型,宿主也没有默认模型;请给 bot 选一个模型或设置宿主默认模型')
314
- const agentOptions = Object.fromEntries(Object.entries(parsedModel).filter(([, value]) => value !== undefined && value !== ''))
315
- const presetId = input.preset === HAMS_BOT_PRESET ? await ensureHamsBotPreset(ctx, log) : input.preset
316
- // 一个 bot 被多个项目引用时,每个对话各占一个子目录,免得以后给了文件工具互相串。
317
- const cwd = path.resolve(dataDir, 'bots', input.memberId, 'chats', safeSegment(input.cwdKey || 'default'))
318
- const base = path.resolve(dataDir, 'bots') + path.sep
319
- if (!cwd.startsWith(base)) throw new Error('bot 工作目录越界')
320
- await mkdir(cwd, { recursive: true })
321
- const meta = { cwd, ...(presetId ? { agentPreset: presetId } : {}) }
322
- const registryPresets = presets()
323
- // meta.agentPreset 只写 session header;真正挂载组合必须在 setup 里 mount,否则是「假安全」。
324
- const setup = presetId && registryPresets && typeof registryPresets.mount === 'function'
325
- ? async (agentCtx: unknown) => { await registryPresets.mount(agentCtx, presetId) }
326
- : undefined
327
- if (input.sessionId) {
328
- // resume 只认 resumeSessionId/agentOptions/setup;meta 只在 create 时写。
329
- const handle = await registry.resume({ resumeSessionId: input.sessionId, agentOptions, ...(setup ? { setup } : {}) })
330
- sendContext(handle, input.sessionId, input.context)
331
- return handle
332
- }
333
- const sessionId = `hams-${input.memberId}-${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`
334
- const handle = await registry.create({ sessionId, meta, agentOptions, ...(setup ? { setup } : {}) })
335
- sendContext(handle, sessionId, input.context)
336
- return handle
337
- }
338
-
339
- function handleFor(sessionId: string, input: { memberId: string; preset: string; model: string | null; context?: string }): Promise<AgentHandleLike> {
340
- const existing = handles.get(sessionId)
341
- if (existing) return existing
342
- const pending = openings.get(sessionId)
343
- if (pending) return pending
344
- const opening = openSession({ sessionId, memberId: input.memberId, preset: input.preset, model: input.model, context: input.context })
345
- openings.set(sessionId, opening)
346
- return opening
347
- .then((handle) => { handles.set(sessionId, Promise.resolve(handle)); return handle })
348
- .finally(() => { openings.delete(sessionId) })
349
- }
350
-
351
- const runtime: ChatRuntime & { dispose(): Promise<void> } = {
352
- get available() {
353
- return Boolean(agents())
354
- },
355
- get unavailableReason() {
356
- return agents() ? undefined : '宿主没有 agents 服务(只读实例或没有 agent 运行时)'
357
- },
358
- async ensure(input) {
359
- if (closed) throw new Error('对话运行时已停止')
360
- if (input.sessionId) {
361
- meta.set(input.sessionId, { memberId: input.memberId, preset: input.agentPreset || HAMS_BOT_PRESET, model: input.model })
362
- await handleFor(input.sessionId, { ...meta.get(input.sessionId)!, context: input.context })
363
- return { sessionId: input.sessionId }
364
- }
365
- const presetId = input.agentPreset || HAMS_BOT_PRESET
366
- const handle = await openSession({
367
- memberId: input.memberId,
368
- preset: presetId,
369
- model: input.model,
370
- cwdKey: input.key,
371
- context: input.context,
372
- })
373
- const sessionId = handle.agent.session.id
374
- meta.set(sessionId, { memberId: input.memberId, preset: presetId, model: input.model })
375
- handles.set(sessionId, Promise.resolve(handle))
376
- return { sessionId }
377
- },
378
- async send(sessionId, text) {
379
- const info = meta.get(sessionId)
380
- if (!info) throw new Error('未知会话:先发一次 chat/open 再发消息')
381
- const handle = await handleFor(sessionId, info)
382
- handle.agent.followup({
383
- id: `msg-${randomUUID()}`,
384
- role: 'user',
385
- content: [{ type: 'text', text }],
386
- source: { kind: 'user' },
387
- })
388
- },
389
- async status(sessionId) {
390
- const live = agents()?.get?.(sessionId)
391
- const status = live && typeof live.status === 'string' ? live.status : undefined
392
- if (status) return { running: status === 'running' }
393
- const query = sessionQuery()
394
- let events: readonly unknown[] = []
395
- if (query) {
396
- try {
397
- const snapshot = await query.readSession(sessionId)
398
- events = snapshot && Array.isArray(snapshot.events) ? snapshot.events : []
399
- } catch {
400
- return { running: false }
401
- }
402
- }
403
- const scanned = chatMessagesFromEvents(events)
404
- return { running: scanned.running, ...(scanned.error ? { error: scanned.error } : {}) }
405
- },
406
- async read(sessionId) {
407
- const query = sessionQuery()
408
- let events: readonly unknown[] = []
409
- if (query) {
410
- try {
411
- const snapshot = await query.readSession(sessionId)
412
- events = snapshot && Array.isArray(snapshot.events) ? snapshot.events : []
413
- } catch (error) {
414
- log(`[hams] 读会话 ${sessionId} 失败:${String(error)}`)
415
- }
416
- }
417
- const scanned = chatMessagesFromEvents(events)
418
- const live = agents()?.get?.(sessionId)
419
- const status = live && typeof live.status === 'string' ? live.status : undefined
420
- return { messages: scanned.messages, running: status ? status === 'running' : scanned.running, ...(scanned.error ? { error: scanned.error } : {}) }
421
- },
422
- async stop(sessionId) {
423
- const pending = handles.get(sessionId)
424
- if (!pending) return
425
- const handle = await pending
426
- handle.agent.cancel({ kind: 'user' }, { keepInbox: true })
427
- },
428
- async defaultModel() {
429
- const selection = defaultModelService()?.currentSelection?.()
430
- if (!selection || !selection.provider || !selection.model) return null
431
- return { provider: String(selection.provider), model: String(selection.model) }
432
- },
433
- async presets() {
434
- const registry = presets()
435
- if (!registry) return []
436
- try {
437
- const list = await registry.list()
438
- return Array.isArray(list) ? list.filter((row) => row && row.id) : []
439
- } catch (error) {
440
- log(`[hams] 读 preset 目录失败:${String(error)}`)
441
- return []
442
- }
443
- },
444
- async models() {
445
- const now = Date.now()
446
- if (modelsCache && now - modelsCache.at < 60_000) return modelsCache.rows
447
- const rows: ChatModelRow[] = []
448
- const seen = new Set<string>()
449
- const push = (provider: string, model: string, label?: string) => {
450
- const key = `${provider}/${model}`
451
- if (!provider || !model || seen.has(key)) return
452
- seen.add(key)
453
- rows.push({ provider, model, label })
454
- }
455
- const llmService = llm()
456
- if (llmService) {
457
- try {
458
- for (const provider of llmService.listProviders()) {
459
- try {
460
- for (const model of await llmService.listModels(provider.id)) push(model.provider || provider.id, model.id, model.name)
461
- } catch {
462
- // 单个 provider 目录失败不影响别的
463
- }
464
- }
465
- } catch (error) {
466
- log(`[hams] 读模型目录失败:${String(error)}`)
467
- }
468
- }
469
- const fallback = defaultModelService()?.currentSelection()
470
- if (fallback && fallback.provider && fallback.model) push(fallback.provider, fallback.model, `${fallback.model}(宿主默认)`)
471
- modelsCache = { at: now, rows }
472
- return rows
473
- },
474
- async dispose() {
475
- closed = true
476
- for (const pending of [...handles.values(), ...openings.values()]) {
477
- try {
478
- const handle = await pending
479
- await handle.dispose()
480
- } catch {
481
- // 关闭阶段的失败只记日志,不阻塞插件卸载
482
- }
483
- }
484
- handles.clear()
485
- openings.clear()
486
- },
487
- }
488
- return runtime
489
- }