dsh-solmirror 0.11.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/LICENSE +21 -0
- package/README.md +65 -0
- package/client/client.js +594 -0
- package/cordis.patch.yml +8 -0
- package/lib/index.js +754 -0
- package/lib/upload.js +306 -0
- package/package.json +47 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,754 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-solmirror 宿主入口
|
|
3
|
+
*
|
|
4
|
+
* 自持 MCP 连接(StreamableHTTPClientTransport + Bearer 密钥),把服务端工具注册到
|
|
5
|
+
* ToolRuntime(公开名 mcp__solmirror__<raw>),并按"可见模型组"过滤:
|
|
6
|
+
* - list_models 结果按 visibleGroups 收窄
|
|
7
|
+
* - generate_image / generate_video 对不可见组的模型直接报错,不打上游
|
|
8
|
+
*
|
|
9
|
+
* generate_video 由 dsh 原生后台任务跟进(宿主未组合 jobs 服务时降级为
|
|
10
|
+
* "提交即返 + agent 轮询")。密钥解析:插件配置 apiKey → dsh 凭据存储
|
|
11
|
+
* (apiKeyEnv 引用);明文只在内存拼 Authorization 头。
|
|
12
|
+
*
|
|
13
|
+
* dsh webserver 管理路由(与网页同源):
|
|
14
|
+
* GET /plugins/solmirror/status 连接状态 + 目录快照
|
|
15
|
+
* POST /plugins/solmirror/test 一次性握手(测试连接)
|
|
16
|
+
* POST /plugins/solmirror/reconnect 强制重连(手动端点)
|
|
17
|
+
* GET /plugins/solmirror/calls callId→generationId 映射(媒体卡反查)
|
|
18
|
+
* GET /plugins/solmirror/generation/{id} 状态 + 媒体地址
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'
|
|
22
|
+
import { createMcpToolDefinition } from '@deepseek-ai/dsh-mcp-client'
|
|
23
|
+
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
|
24
|
+
import z from '@deepseek-ai/schemastery'
|
|
25
|
+
import { uploadReference, UploadError } from './upload.js'
|
|
26
|
+
|
|
27
|
+
// 包名:须与 package.json / cordis.patch.yml 保持一致
|
|
28
|
+
export const name = 'dsh-solmirror'
|
|
29
|
+
|
|
30
|
+
/** 依赖宿主服务:工具注册、凭据解析、webserver 管理路由(base+web-app 组合均提供)。 */
|
|
31
|
+
export const inject = ['tools', 'credentials', 'webServer']
|
|
32
|
+
|
|
33
|
+
export const Config = z.object({
|
|
34
|
+
url: z.string().default('https://aigcserver.solmirror.cn/mcp').volatile()
|
|
35
|
+
.description('瞬镜 MCP 服务地址'),
|
|
36
|
+
apiKey: z.string().role('secret').volatile()
|
|
37
|
+
.description('API 密钥(sk- 开头)。直接粘贴到这里,或留空走下方凭据引用'),
|
|
38
|
+
apiKeyEnv: z.string().role('credential-ref').default('SOLMIRROR_API_KEY').volatile()
|
|
39
|
+
.description('凭据引用名:设置页保存的密钥存在 dsh 凭据存储的这个名下'),
|
|
40
|
+
visibleGroups: z.array(z.string()).volatile()
|
|
41
|
+
.description('可见模型组 id 列表;留空 = 全部可见(在配置页勾选)'),
|
|
42
|
+
toolCallTimeoutMs: z.number().step(1).min(5000).max(3600000).default(300000).volatile()
|
|
43
|
+
.description('单个工具调用超时(毫秒);图片生成内部等待较长,默认 5 分钟'),
|
|
44
|
+
videoJobPollMs: z.number().step(1).min(3000).max(120000).default(15000).volatile()
|
|
45
|
+
.description('视频后台任务的轮询间隔(毫秒),默认 15 秒'),
|
|
46
|
+
videoJobTimeoutMs: z.number().step(1).min(60000).max(7200000).default(1200000).volatile()
|
|
47
|
+
.description('视频后台任务的最长等待(毫秒),超时收尾但平台生成继续,默认 20 分钟'),
|
|
48
|
+
disabled: z.boolean().default(false).volatile()
|
|
49
|
+
.description('停用:断开连接并注销全部工具'),
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
const SERVER_NAME = 'solmirror'
|
|
53
|
+
|
|
54
|
+
// callId→generationId 私有映射:工具返回文本不携带 id,气泡媒体面经
|
|
55
|
+
// /plugins/solmirror/calls 路由反查本表;持久化到 ~/.dsh/plugins/,重启与历史会话回放有效。
|
|
56
|
+
import { readFileSync, writeFileSync } from 'node:fs'
|
|
57
|
+
import { homedir } from 'node:os'
|
|
58
|
+
import { join } from 'node:path'
|
|
59
|
+
|
|
60
|
+
// 文件名历史沿用,勿改(旧会话映射兼容)
|
|
61
|
+
const CALLS_FILE = join(homedir(), '.dsh', 'plugins', 'solmirror-dsh-calls.json')
|
|
62
|
+
|
|
63
|
+
export function apply(ctx, config) {
|
|
64
|
+
/** 全部连接态集中在此;volatile 字段一律 .get() 实时读,改配置无需重启。 */
|
|
65
|
+
const state = {
|
|
66
|
+
client: null,
|
|
67
|
+
disposers: new Map(),
|
|
68
|
+
connectPromise: null,
|
|
69
|
+
connectedUrl: null,
|
|
70
|
+
connectedKey: null,
|
|
71
|
+
status: 'idle', // idle | connecting | connected | error
|
|
72
|
+
lastError: null,
|
|
73
|
+
catalog: { groups: [], modelGroup: new Map() },
|
|
74
|
+
disposed: false,
|
|
75
|
+
}
|
|
76
|
+
const callMap = new Map()
|
|
77
|
+
try {
|
|
78
|
+
const stored = JSON.parse(readFileSync(CALLS_FILE, 'utf8'))
|
|
79
|
+
for (const [callId, entry] of Object.entries(stored ?? {})) {
|
|
80
|
+
if (entry && Number.isInteger(entry.generationId)) callMap.set(callId, entry)
|
|
81
|
+
}
|
|
82
|
+
} catch { /* 首次运行/文件损坏:空表起步 */ }
|
|
83
|
+
|
|
84
|
+
function recordCall(callId, entry) {
|
|
85
|
+
if (!callId || !Number.isInteger(entry.generationId)) return
|
|
86
|
+
callMap.set(String(callId), entry)
|
|
87
|
+
if (callMap.size > 1000) { // 有界:只留最近一千条
|
|
88
|
+
for (const key of callMap.keys()) {
|
|
89
|
+
callMap.delete(key)
|
|
90
|
+
if (callMap.size <= 1000) break
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
writeFileSync(CALLS_FILE, JSON.stringify(Object.fromEntries(callMap)))
|
|
95
|
+
} catch (error) { ctx.logger.warn(`solmirror: 调用映射写盘失败:${describeError(error)}`) }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const readUrl = () => String(config.url.get() ?? '').trim() || null
|
|
99
|
+
|
|
100
|
+
async function readKey() {
|
|
101
|
+
const direct = String(config.apiKey.get() ?? '').trim()
|
|
102
|
+
if (direct) return { key: direct, source: 'config' }
|
|
103
|
+
const refName = String(config.apiKeyEnv.get() ?? '').trim()
|
|
104
|
+
if (refName) {
|
|
105
|
+
try {
|
|
106
|
+
// resolve 返回 {value, source},须取 .value 作密钥明文
|
|
107
|
+
const resolved = await ctx.credentials.resolve(credentialRef(refName))
|
|
108
|
+
const value = typeof resolved === 'string' ? resolved : resolved?.value
|
|
109
|
+
if (value) return { key: String(value).trim(), source: `credential:${resolved.source ?? '?'}` }
|
|
110
|
+
} catch (error) {
|
|
111
|
+
ctx.logger.warn(`solmirror: 凭据解析失败: ${String(error)}`)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return { key: null, source: null }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** 当前可见组集合;null = 不过滤(全部可见)。 */
|
|
118
|
+
const allowedGroups = () => {
|
|
119
|
+
const list = config.visibleGroups.get()
|
|
120
|
+
return Array.isArray(list) && list.length ? new Set(list.map(String)) : null
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function disposeTools() {
|
|
124
|
+
for (const dispose of state.disposers.values()) dispose()
|
|
125
|
+
state.disposers = new Map()
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function closeClient() {
|
|
129
|
+
const client = state.client
|
|
130
|
+
state.client = null
|
|
131
|
+
try { await client?.close() } catch { /* 断开途中的传输错误忽略 */ }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function describeError(error) {
|
|
135
|
+
const text = String(error?.message ?? error ?? '')
|
|
136
|
+
if (/\b(401|403)\b|unauthorized|forbidden/i.test(text)) return '鉴权失败:密钥无效、未创建或已删除'
|
|
137
|
+
if (/ENOTFOUND|ECONNREFUSED|ETIMEDOUT|fetch failed|timeout/i.test(text)) {
|
|
138
|
+
return `连不上瞬镜服务(检查地址与本机后端):${text}`
|
|
139
|
+
}
|
|
140
|
+
return text || '未知错误'
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** 建立连接并完成一次工具同步(含目录预热)。串行化,避免并发双连接。 */
|
|
144
|
+
function connect() {
|
|
145
|
+
if (state.connectPromise) return state.connectPromise
|
|
146
|
+
state.connectPromise = (async () => {
|
|
147
|
+
disposeTools()
|
|
148
|
+
await closeClient()
|
|
149
|
+
state.connectedUrl = null
|
|
150
|
+
state.connectedKey = null
|
|
151
|
+
if (config.disabled.get()) { state.status = 'idle'; state.lastError = null; return }
|
|
152
|
+
|
|
153
|
+
const url = readUrl()
|
|
154
|
+
const { key, source } = await readKey()
|
|
155
|
+
if (!url) { state.status = 'error'; state.lastError = '未配置服务器地址'; return }
|
|
156
|
+
if (!key) {
|
|
157
|
+
state.status = 'error'
|
|
158
|
+
state.lastError = '未配置 API 密钥:在 dsh 设置 → 瞬镜开放平台 粘贴保存'
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
state.status = 'connecting'
|
|
162
|
+
state.lastError = null
|
|
163
|
+
try {
|
|
164
|
+
const transport = new StreamableHTTPClientTransport(new URL(url), {
|
|
165
|
+
requestInit: { headers: { Authorization: `Bearer ${key}` } },
|
|
166
|
+
})
|
|
167
|
+
const client = new Client({ name: 'dsh-solmirror', version: '0.11.0' })
|
|
168
|
+
await client.connect(transport)
|
|
169
|
+
state.client = client
|
|
170
|
+
state.connectedUrl = url
|
|
171
|
+
state.connectedKey = key
|
|
172
|
+
|
|
173
|
+
// 工具同步:generate_video 用原生后台任务包装,其余走 createMcpToolDefinition;
|
|
174
|
+
// 可见性过滤包在每个工具的 call/execute 里做。
|
|
175
|
+
const listed = await client.listTools()
|
|
176
|
+
const next = new Map()
|
|
177
|
+
for (const tool of listed.tools ?? []) {
|
|
178
|
+
const publicName = `mcp__${SERVER_NAME}__${tool.name}`
|
|
179
|
+
if (next.has(publicName)) continue
|
|
180
|
+
next.set(publicName, tool.name === 'generate_video'
|
|
181
|
+
? videoToolDefinition(tool)
|
|
182
|
+
: createMcpToolDefinition(ctx, {
|
|
183
|
+
name: publicName,
|
|
184
|
+
rawName: tool.name,
|
|
185
|
+
description: tool.description ?? '',
|
|
186
|
+
inputSchema: tool.inputSchema,
|
|
187
|
+
outputSchema: tool.outputSchema,
|
|
188
|
+
taskRequired: tool.execution?.taskSupport === 'required',
|
|
189
|
+
call: (args, execution) => callUpstream(client, tool, args, execution),
|
|
190
|
+
}))
|
|
191
|
+
}
|
|
192
|
+
const disposers = new Map()
|
|
193
|
+
// 本地工具:参考素材上传(文件字节不过 MCP——参数走路径,上传走 REST/直传)
|
|
194
|
+
next.set(`mcp__${SERVER_NAME}__upload_reference`, uploadToolDefinition())
|
|
195
|
+
try {
|
|
196
|
+
for (const [publicName, definition] of next) {
|
|
197
|
+
disposers.set(publicName, ctx.tools.register(definition))
|
|
198
|
+
}
|
|
199
|
+
} catch (error) {
|
|
200
|
+
// 命名冲突(外部占了 mcp__solmirror__ 前缀):整代回滚,宁可零工具不可半套
|
|
201
|
+
for (const dispose of disposers.values()) dispose()
|
|
202
|
+
throw new Error(`工具注册失败(${String(error)})`)
|
|
203
|
+
}
|
|
204
|
+
state.disposers = disposers
|
|
205
|
+
|
|
206
|
+
try { await refreshCatalog(client) } catch { /* 目录预热失败不阻断连接,拦截守卫降级 */ }
|
|
207
|
+
|
|
208
|
+
state.status = 'connected'
|
|
209
|
+
state.lastError = null
|
|
210
|
+
ctx.logger.info(`solmirror: 已连接 ${url}(密钥来源 ${source}),注册 ${disposers.size} 个工具`)
|
|
211
|
+
} catch (error) {
|
|
212
|
+
await closeClient()
|
|
213
|
+
disposeTools()
|
|
214
|
+
state.status = 'error'
|
|
215
|
+
state.lastError = describeError(error)
|
|
216
|
+
ctx.logger.warn(`solmirror: 连接失败: ${state.lastError}`)
|
|
217
|
+
}
|
|
218
|
+
})().finally(() => { state.connectPromise = null })
|
|
219
|
+
return state.connectPromise
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** 拉一次服务端目录(已按平台路由可见性过滤),刷新勾选列表与组归属索引。 */
|
|
223
|
+
async function refreshCatalog(client) {
|
|
224
|
+
const result = await client.callTool({ name: 'list_models', arguments: {} }, { timeout: 30_000 })
|
|
225
|
+
applyCatalogPayload(catalogPayloadOf(result))
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// 服务端作废长会话时 callTool 报会话类错误而状态仍停在 connected;命中特征即重连重试。
|
|
229
|
+
const SESSION_DEAD = /no valid session|session id|session terminated|invalid session|\b404\b|not found/i
|
|
230
|
+
|
|
231
|
+
/** 统一 MCP 调用出口:会话失效自动重建常驻连接并重试一次。 */
|
|
232
|
+
async function callMcp(name, args, opts) {
|
|
233
|
+
const attempt = () => {
|
|
234
|
+
const client = state.client
|
|
235
|
+
if (!client) throw new Error('瞬镜 MCP 未连接:请到 dsh 设置 → 瞬镜开放平台 检查连接')
|
|
236
|
+
return client.callTool({ name, arguments: (args && typeof args === 'object') ? args : {} }, opts)
|
|
237
|
+
}
|
|
238
|
+
try {
|
|
239
|
+
return await attempt()
|
|
240
|
+
} catch (error) {
|
|
241
|
+
if (!SESSION_DEAD.test(String(error?.message ?? error))) throw error
|
|
242
|
+
ctx.logger.warn(`solmirror: MCP 会话失效(${String(error?.message ?? error)}),重连后重试`)
|
|
243
|
+
await connect()
|
|
244
|
+
return attempt()
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function catalogPayloadOf(result) {
|
|
249
|
+
return result?.structuredContent
|
|
250
|
+
?? safeJsonParse(Array.isArray(result?.content) ? result.content[0]?.text : null)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** 按 obj_name 现取临时媒体地址;失败返回空串(调用方降级文案)。 */
|
|
254
|
+
async function fetchMediaUrl(objName) {
|
|
255
|
+
if (!objName) return ''
|
|
256
|
+
try {
|
|
257
|
+
const result = await callMcp('get_media_url', { obj_name: String(objName) }, { timeout: 30_000 })
|
|
258
|
+
const payload = result?.structuredContent
|
|
259
|
+
?? safeJsonParse(Array.isArray(result?.content) ? result.content[0]?.text : null)
|
|
260
|
+
return String(payload?.url ?? '')
|
|
261
|
+
} catch (error) {
|
|
262
|
+
ctx.logger.warn(`solmirror: 取媒体地址失败 ${objName}: ${describeError(error)}`)
|
|
263
|
+
return ''
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function applyCatalogPayload(payload) {
|
|
268
|
+
if (!payload) return
|
|
269
|
+
const groups = []
|
|
270
|
+
const modelGroup = new Map()
|
|
271
|
+
for (const category of ['image', 'video']) {
|
|
272
|
+
for (const group of payload[category] ?? []) {
|
|
273
|
+
const id = String(group?.group ?? '')
|
|
274
|
+
if (!id) continue
|
|
275
|
+
groups.push({ id, name: String(group?.name ?? id), category })
|
|
276
|
+
for (const model of group?.models ?? []) {
|
|
277
|
+
for (const key of ['id', 'name', 'model']) {
|
|
278
|
+
const value = model?.[key]
|
|
279
|
+
if (typeof value === 'string' && value) modelGroup.set(value, id)
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
state.catalog = { groups, modelGroup }
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function safeJsonParse(text) {
|
|
288
|
+
if (typeof text !== 'string') return null
|
|
289
|
+
try { return JSON.parse(text) } catch { return null }
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** 参数对应的模型组:显式 group 优先,否则查目录索引(未知模型返回 null 不拦,交给服务端校验)。 */
|
|
293
|
+
function resolveGroupOf(args) {
|
|
294
|
+
if (args?.group) return String(args.group)
|
|
295
|
+
const model = String(args?.model ?? '')
|
|
296
|
+
return model ? state.catalog.modelGroup.get(model) ?? null : null
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** 生成类工具的可见组守卫:越组时返回面向 agent 的错误文案,放行返回 null。 */
|
|
300
|
+
function visibilityViolation(toolName, args) {
|
|
301
|
+
const allow = allowedGroups()
|
|
302
|
+
if (!allow || (toolName !== 'generate_image' && toolName !== 'generate_video')) return null
|
|
303
|
+
const group = resolveGroupOf(args)
|
|
304
|
+
if (group && !allow.has(group)) {
|
|
305
|
+
return `模型 ${String(args?.model ?? '')}(组 ${group})不在插件设置的可见模型范围内。`
|
|
306
|
+
+ ' 请在 dsh 设置 → 瞬镜开放平台 勾选该组,或清空选择恢复全部可见。'
|
|
307
|
+
}
|
|
308
|
+
return null
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function callUpstream(_client, tool, args, execution) {
|
|
312
|
+
const violation = visibilityViolation(tool.name, args)
|
|
313
|
+
if (violation) {
|
|
314
|
+
return { isError: true, content: [{ type: 'text', text: violation }] }
|
|
315
|
+
}
|
|
316
|
+
const result = await callMcp(tool.name, args,
|
|
317
|
+
{ signal: execution.signal, timeout: config.toolCallTimeoutMs.get(), toolDefinition: tool })
|
|
318
|
+
if (tool.name === 'generate_image') {
|
|
319
|
+
// 登记私有映射(媒体面经 /plugins/solmirror/calls 反查)
|
|
320
|
+
try {
|
|
321
|
+
const payload = result?.structuredContent ?? catalogPayloadOf(result)
|
|
322
|
+
if (Number.isInteger(payload?.generation_id)) {
|
|
323
|
+
recordCall(execution.callId, {
|
|
324
|
+
generationId: payload.generation_id,
|
|
325
|
+
media: 'image',
|
|
326
|
+
aspect: String(args?.aspect_ratio ?? ''),
|
|
327
|
+
})
|
|
328
|
+
}
|
|
329
|
+
} catch { /* 登记失败不影响返回 */ }
|
|
330
|
+
}
|
|
331
|
+
if (tool.name === 'list_models') {
|
|
332
|
+
// 每次调用同步服务端目录,勾选列表/组归属索引即时跟上
|
|
333
|
+
try { applyCatalogPayload(catalogPayloadOf(result)) } catch { /* 刷新失败不影响本次返回 */ }
|
|
334
|
+
const allow = allowedGroups()
|
|
335
|
+
if (allow) return filterCatalogResult(result, allow)
|
|
336
|
+
}
|
|
337
|
+
return result
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** 按可见组收窄 list_models 结果(structuredContent 与首块 JSON 文本同步重写)。 */
|
|
341
|
+
function filterCatalogResult(result, allow) {
|
|
342
|
+
try {
|
|
343
|
+
// structuredContent 缺席时回退解析首块 JSON 文本(部分工具只回 text 块)
|
|
344
|
+
const payload = result?.structuredContent ?? catalogPayloadOf(result)
|
|
345
|
+
if (!payload || (!Array.isArray(payload.image) && !Array.isArray(payload.video))) return result
|
|
346
|
+
const filtered = {}
|
|
347
|
+
for (const category of ['image', 'video']) {
|
|
348
|
+
filtered[category] = (payload[category] ?? []).filter(group => allow.has(String(group?.group)))
|
|
349
|
+
}
|
|
350
|
+
const content = Array.isArray(result.content)
|
|
351
|
+
? result.content.map((block, index) => index === 0 && block?.type === 'text'
|
|
352
|
+
? { ...block, text: JSON.stringify(filtered) }
|
|
353
|
+
: block)
|
|
354
|
+
: result.content
|
|
355
|
+
return { ...result, content, structuredContent: filtered }
|
|
356
|
+
} catch {
|
|
357
|
+
return result
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// ---- generate_video:dsh 原生后台任务包装 ----
|
|
362
|
+
//
|
|
363
|
+
// 提交走 MCP(服务端受理即返 generation_id),随后在 dsh 的 jobs 注册表挂一个
|
|
364
|
+
// 跟进 job 轮询 get_generation;完成/失败经通知链以用户消息推给主代理,agent
|
|
365
|
+
// 无需自己轮询。宿主未组合 jobs 服务(或调用无 agent 上下文)时降级为
|
|
366
|
+
// “提交即返 + agent 轮询”。
|
|
367
|
+
|
|
368
|
+
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
|
|
369
|
+
|
|
370
|
+
function extractText(result) {
|
|
371
|
+
return Array.isArray(result?.content)
|
|
372
|
+
? result.content.filter(block => block?.type === 'text').map(block => block.text).join('\n')
|
|
373
|
+
: ''
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** 字节数人性化(1.0MB / 345KB / 890B)。 */
|
|
377
|
+
function formatSize(bytes) {
|
|
378
|
+
const n = Number(bytes)
|
|
379
|
+
if (!Number.isFinite(n) || n <= 0) return ''
|
|
380
|
+
if (n >= 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)}MB`
|
|
381
|
+
if (n >= 1024) return `${Math.round(n / 1024)}KB`
|
|
382
|
+
return `${n}B`
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** 生成结果的一行媒体摘要:积分 | 分辨率 | 比例 | 时长 | 大小(缺什么跳什么)。 */
|
|
386
|
+
function mediaSummary(payload) {
|
|
387
|
+
const parts = []
|
|
388
|
+
if (payload?.points_charged !== null && payload?.points_charged !== undefined) {
|
|
389
|
+
parts.push(`积分 ${payload.points_charged}`)
|
|
390
|
+
}
|
|
391
|
+
const v = payload?.video ?? payload ?? {}
|
|
392
|
+
if (v.resolution) parts.push(`分辨率 ${v.resolution}`)
|
|
393
|
+
if (v.aspect_ratio) parts.push(`比例 ${v.aspect_ratio}`)
|
|
394
|
+
if (v.width && v.height) parts.push(`尺寸 ${v.width}x${v.height}`)
|
|
395
|
+
if (v.duration) parts.push(`时长 ${v.duration}s`)
|
|
396
|
+
const size = formatSize(v.file_size)
|
|
397
|
+
if (size) parts.push(`大小 ${size}`)
|
|
398
|
+
return parts.join(' | ')
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function videoToolDefinition(tool) {
|
|
402
|
+
return {
|
|
403
|
+
name: `mcp__${SERVER_NAME}__generate_video`,
|
|
404
|
+
description: '提交视频生成(文生视频/图生视频/参考视频)。提交后立即返回,由 dsh 后台任务'
|
|
405
|
+
+ '自动跟进:完成(成功或失败)时你会自动收到含无水印视频地址的通知,无需自行轮询;'
|
|
406
|
+
+ '仅当返回 kind=submitted(宿主无后台任务服务)时才用 mcp__solmirror__get_generation 轮询。'
|
|
407
|
+
+ '参数取值一律以 mcp__solmirror__list_models 该模型条目为准(各档位不同,勿凭组名猜):'
|
|
408
|
+
+ 'function_type 只认英文枚举 t2v(文生视频)/i2v(图生视频,单首帧)/kf2v(首尾帧,'
|
|
409
|
+
+ '首/尾帧图作为参考图传入)/multi-ref(参考视频生成)/edit(视频编辑,须含参考视频)/'
|
|
410
|
+
+ 'extend(视频延长,须含参考视频)——中文标签只是展示文案不能传参;缺省按输入推导:'
|
|
411
|
+
+ '无输入=t2v;仅图=i2v;带参考视频=multi-ref。resolution 合法值=该模型的 resolutions'
|
|
412
|
+
+ '(如 seedance-2.0-mini/fast 仅 480p/720p),缺省用模型默认;duration 档位与 '
|
|
413
|
+
+ 'aspect_ratios 同样以 list_models 为准(edit/extend 可传 -1 跟随参考视频)。'
|
|
414
|
+
+ 'reference_images / reference_videos 每项为 obj_name(本地文件先调'
|
|
415
|
+
+ ' mcp__solmirror__upload_reference 上传获得;可重复引用,没用过的 1 天/用过的 3 天'
|
|
416
|
+
+ '有效,过期需重新上传;视频上传时 ≤50MB 且 ≤30 秒)。参考音色音频走'
|
|
417
|
+
+ ' reference_audios(仅 list_models 带 reference_audios 能力的模型,须配合参考图/'
|
|
418
|
+
+ '视频):在 prompt 的说话台词处标注(参考音频1)式标记(编号=列表顺序),模型'
|
|
419
|
+
+ '会让对应角色用该音色说话。',
|
|
420
|
+
parameters: tool.inputSchema,
|
|
421
|
+
output: {
|
|
422
|
+
schema: {
|
|
423
|
+
type: 'object',
|
|
424
|
+
properties: {
|
|
425
|
+
kind: { type: 'string', enum: ['background', 'submitted'] },
|
|
426
|
+
jobId: { type: 'string' },
|
|
427
|
+
generation_id: { type: 'integer' },
|
|
428
|
+
status: { type: 'string' },
|
|
429
|
+
},
|
|
430
|
+
required: ['kind', 'generation_id', 'status'],
|
|
431
|
+
additionalProperties: false,
|
|
432
|
+
},
|
|
433
|
+
render: (_args, value) => [{
|
|
434
|
+
type: 'text',
|
|
435
|
+
// generation id 不进模型可见文本(降级路径除外:无后台任务服务时须拿 id 轮询)
|
|
436
|
+
text: value.kind === 'background'
|
|
437
|
+
? '视频生成已提交,由 dsh 后台任务自动跟进,完成或失败会自动通知你无水印链接与积分摘要,无需轮询。'
|
|
438
|
+
: `视频生成已提交(generation ${value.generation_id},状态 ${value.status})。宿主无后台任务服务,请以 15~30 秒间隔调用 mcp__solmirror__get_generation(generation_id=${value.generation_id}) 轮询到终态。`,
|
|
439
|
+
}],
|
|
440
|
+
},
|
|
441
|
+
async execute(args, exec) {
|
|
442
|
+
const violation = visibilityViolation('generate_video', args)
|
|
443
|
+
if (violation) throw new Error(violation)
|
|
444
|
+
const result = await callMcp('generate_video', args,
|
|
445
|
+
{ signal: exec.signal, timeout: config.toolCallTimeoutMs.get(), toolDefinition: tool })
|
|
446
|
+
if (result?.isError) throw new Error(extractText(result) || '提交失败')
|
|
447
|
+
const payload = result?.structuredContent
|
|
448
|
+
?? safeJsonParse(Array.isArray(result?.content) ? result.content[0]?.text : null)
|
|
449
|
+
const generationId = payload?.generation_id
|
|
450
|
+
if (!generationId) throw new Error(extractText(result) || '提交返回缺少 generation_id')
|
|
451
|
+
recordCall(exec.callId, {
|
|
452
|
+
generationId,
|
|
453
|
+
media: 'video',
|
|
454
|
+
aspect: String(args?.aspect_ratio ?? ''),
|
|
455
|
+
})
|
|
456
|
+
const registry = ctx.get('jobs')
|
|
457
|
+
if (!registry || !exec.agent) {
|
|
458
|
+
return { kind: 'submitted', generation_id: generationId, status: String(payload.status ?? '') }
|
|
459
|
+
}
|
|
460
|
+
const jobId = startVideoJob(registry, exec, args ?? {}, payload)
|
|
461
|
+
return { kind: 'background', jobId, generation_id: generationId, status: String(payload.status ?? '') }
|
|
462
|
+
},
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** 本地工具:上传参考素材拿 obj_name(可复用)。 */
|
|
467
|
+
function uploadToolDefinition() {
|
|
468
|
+
return {
|
|
469
|
+
name: `mcp__${SERVER_NAME}__upload_reference`,
|
|
470
|
+
description: '上传本地参考素材(图片/视频/音频)拿 obj_name:图片 ≤50MB(jpg/png/webp/gif),'
|
|
471
|
+
+ '视频 ≤50MB 且 ≤30 秒(mp4/mov/webm),音频 ≤50MB 且 ≤60 秒(mp3/wav,参考音色)。'
|
|
472
|
+
+ '上传成功返回 obj_name——把它填进 mcp__solmirror__generate_image 的'
|
|
473
|
+
+ ' reference_images 或 mcp__solmirror__generate_video 的 reference_images /'
|
|
474
|
+
+ ' reference_videos / reference_audios 即可作为参考素材,可重复引用:没用过的 1 天、'
|
|
475
|
+
+ '引用过的自最后一次使用起 3 天内有效,过期再引用会提示重新上传。',
|
|
476
|
+
parameters: {
|
|
477
|
+
type: 'object',
|
|
478
|
+
properties: {
|
|
479
|
+
file_path: { type: 'string', description: '本地文件的绝对路径;类型按扩展名自动识别(jpg/png/webp/gif=图片、mp4/mov/webm=视频、mp3/wav=参考音频),无需也无法指定类型' },
|
|
480
|
+
},
|
|
481
|
+
required: ['file_path'],
|
|
482
|
+
additionalProperties: false,
|
|
483
|
+
},
|
|
484
|
+
output: {
|
|
485
|
+
schema: {
|
|
486
|
+
type: 'object',
|
|
487
|
+
properties: {
|
|
488
|
+
obj_name: { type: 'string' },
|
|
489
|
+
kind: { type: 'string', enum: ['image', 'video', 'audio'] },
|
|
490
|
+
},
|
|
491
|
+
required: ['obj_name', 'kind'],
|
|
492
|
+
additionalProperties: false,
|
|
493
|
+
},
|
|
494
|
+
render: (_args, value) => [{
|
|
495
|
+
type: 'text',
|
|
496
|
+
text: `参考素材已上传(${{ image: '图片', video: '视频', audio: '参考音频' }[value.kind] ?? value.kind}),`
|
|
497
|
+
+ `obj_name ${value.obj_name} `
|
|
498
|
+
+ '可重复填入生成工具的 reference_images / reference_videos / reference_audios(没用过 1 天 / 用过 3 天有效)。',
|
|
499
|
+
}],
|
|
500
|
+
},
|
|
501
|
+
async execute(args) {
|
|
502
|
+
const url = readUrl()
|
|
503
|
+
const { key } = await readKey()
|
|
504
|
+
if (!url) throw new Error('未配置服务器地址:请到 dsh 设置 → 瞬镜开放平台 检查连接')
|
|
505
|
+
if (!key) throw new Error('未配置 API 密钥:请到 dsh 设置 → 瞬镜开放平台 粘贴保存')
|
|
506
|
+
const result = await uploadReference({
|
|
507
|
+
filePath: String(args?.file_path ?? ''),
|
|
508
|
+
key,
|
|
509
|
+
mcpUrl: url,
|
|
510
|
+
})
|
|
511
|
+
return { obj_name: result.obj_name, kind: result.kind }
|
|
512
|
+
},
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** 注册视频跟进 job(轮询 get_generation 到终态);返回 jobId。 */
|
|
517
|
+
function startVideoJob(registry, exec, args, submitPayload) {
|
|
518
|
+
const generationId = submitPayload.generation_id
|
|
519
|
+
const pollMs = config.videoJobPollMs.get()
|
|
520
|
+
const timeoutMs = config.videoJobTimeoutMs.get()
|
|
521
|
+
const label = `视频 ${String(args.model ?? '')} · ${String(args.prompt ?? '').slice(0, 30)}`.trim()
|
|
522
|
+
return registry.start({
|
|
523
|
+
kind: 'solmirror',
|
|
524
|
+
label,
|
|
525
|
+
toolCall: { callId: exec.callId, rootCallId: exec.rootCallId },
|
|
526
|
+
owner: exec.agent.id,
|
|
527
|
+
run(job) {
|
|
528
|
+
let cancelled = false
|
|
529
|
+
const done = (async () => {
|
|
530
|
+
const startedAt = Date.now()
|
|
531
|
+
let lastStatus = null
|
|
532
|
+
let failures = 0
|
|
533
|
+
job.append(`已提交,后台跟进中(每 ${Math.round(pollMs / 1000)}s 查询一次)。\n`)
|
|
534
|
+
job.updateProgress('已提交')
|
|
535
|
+
while (true) {
|
|
536
|
+
if (cancelled || state.disposed) {
|
|
537
|
+
job.append('跟进已终止(平台生成未取消,可到 开放平台·生成记录 查看结果)。\n')
|
|
538
|
+
return { status: 'killed', detail: '跟进被终止(平台生成继续)' }
|
|
539
|
+
}
|
|
540
|
+
let payload = null
|
|
541
|
+
try {
|
|
542
|
+
const result = await callMcp('get_generation',
|
|
543
|
+
{ generation_id: generationId, include_images: false }, { timeout: 30_000 })
|
|
544
|
+
payload = result?.structuredContent
|
|
545
|
+
?? safeJsonParse(Array.isArray(result?.content) ? result.content[0]?.text : null)
|
|
546
|
+
if (!payload?.status) throw new Error('返回缺少状态载荷')
|
|
547
|
+
failures = 0
|
|
548
|
+
} catch (error) {
|
|
549
|
+
failures += 1
|
|
550
|
+
if (failures >= 10) {
|
|
551
|
+
const message = describeError(error)
|
|
552
|
+
job.append(`连续 ${failures} 次查询失败(${message}),跟进结束。平台生成未受影响,可到 开放平台·生成记录 查看结果。\n`)
|
|
553
|
+
return { status: 'failed', detail: `查询失败:${message}` }
|
|
554
|
+
}
|
|
555
|
+
await sleep(pollMs)
|
|
556
|
+
continue
|
|
557
|
+
}
|
|
558
|
+
const status = String(payload.status)
|
|
559
|
+
const elapsed = Math.round((Date.now() - startedAt) / 1000)
|
|
560
|
+
if (status !== lastStatus) {
|
|
561
|
+
job.append(`[${elapsed}s] ${status}\n`)
|
|
562
|
+
job.updateProgress(status)
|
|
563
|
+
lastStatus = status
|
|
564
|
+
}
|
|
565
|
+
if (status === 'succeeded') {
|
|
566
|
+
// 载荷只带 obj_name,临时地址经 get_media_url 现取
|
|
567
|
+
const url = await fetchMediaUrl(payload.video?.obj_name)
|
|
568
|
+
const summary = mediaSummary(payload)
|
|
569
|
+
job.append(url ? `生成成功:${url}\n` : '生成成功(地址见对话媒体卡片 / 开放平台·生成记录)。\n')
|
|
570
|
+
if (summary) job.append(`${summary}\n`)
|
|
571
|
+
return {
|
|
572
|
+
status: 'completed',
|
|
573
|
+
detail: '视频生成成功',
|
|
574
|
+
result: [
|
|
575
|
+
url ? `视频已生成:${url}` : '视频已生成(地址见对话媒体卡片 / 开放平台·生成记录)',
|
|
576
|
+
summary,
|
|
577
|
+
].filter(Boolean).join('\n'),
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
if (status === 'failed' || status === 'review_rejected') {
|
|
581
|
+
const reason = String(payload.error_message || '')
|
|
582
|
+
job.append(`生成未通过(${status}):${reason}\n`)
|
|
583
|
+
return { status: 'failed', detail: `${status}${reason ? `: ${reason}` : ''}` }
|
|
584
|
+
}
|
|
585
|
+
if (Date.now() - startedAt > timeoutMs) {
|
|
586
|
+
job.append(`等待超过 ${Math.round(timeoutMs / 60000)} 分钟,跟进收尾。平台生成未取消,可到 开放平台·生成记录 查看结果。\n`)
|
|
587
|
+
return { status: 'failed', detail: '等待超时(平台生成继续)' }
|
|
588
|
+
}
|
|
589
|
+
await sleep(pollMs)
|
|
590
|
+
}
|
|
591
|
+
})()
|
|
592
|
+
return {
|
|
593
|
+
cancel() { cancelled = true },
|
|
594
|
+
done,
|
|
595
|
+
}
|
|
596
|
+
},
|
|
597
|
+
})
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/** 一次性握手测试(不动常驻连接):配置页「测试连接」。 */
|
|
601
|
+
async function testConnection() {
|
|
602
|
+
const url = readUrl()
|
|
603
|
+
const { key, source } = await readKey()
|
|
604
|
+
// 诊断回显:仅前 8 位 + 长度(排查存错密钥)
|
|
605
|
+
const diag = key ? { keyPrefix: key.slice(0, 8), keyLength: key.length } : {}
|
|
606
|
+
if (!url) return { ok: false, message: '未配置服务器地址', ...diag }
|
|
607
|
+
if (!key) return { ok: false, message: '未配置 API 密钥', ...diag }
|
|
608
|
+
const started = Date.now()
|
|
609
|
+
try {
|
|
610
|
+
const transport = new StreamableHTTPClientTransport(new URL(url), {
|
|
611
|
+
requestInit: { headers: { Authorization: `Bearer ${key}` } },
|
|
612
|
+
})
|
|
613
|
+
const client = new Client({ name: 'dsh-solmirror-test', version: '0.11.0' })
|
|
614
|
+
await client.connect(transport)
|
|
615
|
+
try { await client.close() } catch { /* 关闭竞态忽略 */ }
|
|
616
|
+
return { ok: true, message: `握手成功(${Date.now() - started}ms)`, latencyMs: Date.now() - started, keySource: source, ...diag }
|
|
617
|
+
} catch (error) {
|
|
618
|
+
return { ok: false, message: describeError(error), keySource: source, ...diag }
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
async function statusSnapshot() {
|
|
623
|
+
const { key, source } = await readKey()
|
|
624
|
+
const allow = allowedGroups()
|
|
625
|
+
return {
|
|
626
|
+
status: state.status,
|
|
627
|
+
connected: state.status === 'connected',
|
|
628
|
+
lastError: state.lastError,
|
|
629
|
+
url: readUrl(),
|
|
630
|
+
disabled: !!config.disabled.get(),
|
|
631
|
+
keyConfigured: !!key,
|
|
632
|
+
keySource: source,
|
|
633
|
+
visibleGroups: allow ? [...allow] : [],
|
|
634
|
+
visibleAll: !allow,
|
|
635
|
+
groups: state.catalog.groups,
|
|
636
|
+
tools: [...state.disposers.keys()],
|
|
637
|
+
updatedAt: new Date().toISOString(),
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// ---- webserver 管理路由(与 dsh 网页同源;不回传任何密钥材料) ----
|
|
642
|
+
ctx.effect(() => {
|
|
643
|
+
const send = (res, status, value) => {
|
|
644
|
+
res.statusCode = status
|
|
645
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
646
|
+
res.setHeader('cache-control', 'no-store')
|
|
647
|
+
res.end(JSON.stringify(value))
|
|
648
|
+
}
|
|
649
|
+
const disposers = [
|
|
650
|
+
ctx.webServer.register({
|
|
651
|
+
kind: 'exact', path: '/plugins/solmirror/status',
|
|
652
|
+
handler: async (req, res) => send(res, 200, await statusSnapshot()),
|
|
653
|
+
}),
|
|
654
|
+
ctx.webServer.register({
|
|
655
|
+
kind: 'exact', path: '/plugins/solmirror/test',
|
|
656
|
+
handler: async (req, res) => {
|
|
657
|
+
if (req.method !== 'POST') return send(res, 405, { ok: false, message: 'POST only' })
|
|
658
|
+
send(res, 200, await testConnection())
|
|
659
|
+
},
|
|
660
|
+
}),
|
|
661
|
+
ctx.webServer.register({
|
|
662
|
+
kind: 'exact', path: '/plugins/solmirror/reconnect',
|
|
663
|
+
handler: async (req, res) => {
|
|
664
|
+
if (req.method !== 'POST') return send(res, 405, { ok: false, message: 'POST only' })
|
|
665
|
+
await connect()
|
|
666
|
+
send(res, 200, { ok: state.status === 'connected', status: state.status, message: state.lastError })
|
|
667
|
+
},
|
|
668
|
+
}),
|
|
669
|
+
// callId→generationId 私有映射(媒体面从这里反查)
|
|
670
|
+
ctx.webServer.register({
|
|
671
|
+
kind: 'exact', path: '/plugins/solmirror/calls',
|
|
672
|
+
handler: async (req, res) => {
|
|
673
|
+
const calls = {}
|
|
674
|
+
for (const [callId, entry] of callMap) calls[callId] = entry
|
|
675
|
+
send(res, 200, { ok: true, calls })
|
|
676
|
+
},
|
|
677
|
+
}),
|
|
678
|
+
// 媒体卡数据源:浏览器半边每次挂载/轮询都来这里取最新状态与媒体地址
|
|
679
|
+
//(临时地址会过期,不能把旧 URL 固化进会话渲染)。
|
|
680
|
+
ctx.webServer.register({
|
|
681
|
+
kind: 'prefix', path: '/plugins/solmirror/generation',
|
|
682
|
+
handler: async (req, res) => {
|
|
683
|
+
const tail = String(new URL(req.url, 'http://localhost').pathname).split('/').pop() ?? ''
|
|
684
|
+
const generationId = Number(tail)
|
|
685
|
+
if (!Number.isInteger(generationId) || generationId <= 0) {
|
|
686
|
+
return send(res, 400, { ok: false, message: 'generation id 无效' })
|
|
687
|
+
}
|
|
688
|
+
const client = state.client
|
|
689
|
+
if (!client) return send(res, 503, { ok: false, message: '瞬镜 MCP 未连接' })
|
|
690
|
+
try {
|
|
691
|
+
// 媒体卡只要状态+地址:include_images=false 免去服务端内嵌图片内容,
|
|
692
|
+
// 载荷只带 obj_name,地址经 get_media_url 每次现取
|
|
693
|
+
const result = await callMcp('get_generation',
|
|
694
|
+
{ generation_id: generationId, include_images: false }, { timeout: 30_000 })
|
|
695
|
+
const payload = result?.structuredContent
|
|
696
|
+
?? safeJsonParse(Array.isArray(result?.content) ? result.content[0]?.text : null)
|
|
697
|
+
if (!payload?.status) throw new Error('返回缺少状态载荷')
|
|
698
|
+
// 视频载荷也固定带 images:[],判别只认 media_type
|
|
699
|
+
const status = String(payload.status)
|
|
700
|
+
let urls = []
|
|
701
|
+
if (status === 'succeeded') {
|
|
702
|
+
const names = payload.media_type === 'image'
|
|
703
|
+
? (payload.images ?? []).map(item => item?.obj_name).filter(Boolean)
|
|
704
|
+
: payload.video?.obj_name ? [String(payload.video.obj_name)] : []
|
|
705
|
+
urls = (await Promise.all(names.map(fetchMediaUrl))).filter(Boolean)
|
|
706
|
+
}
|
|
707
|
+
send(res, 200, {
|
|
708
|
+
ok: true,
|
|
709
|
+
generation_id: generationId,
|
|
710
|
+
status,
|
|
711
|
+
media_type: payload.media_type === 'image' ? 'image' : 'video',
|
|
712
|
+
url: urls[0] ?? '',
|
|
713
|
+
urls,
|
|
714
|
+
summary: mediaSummary(payload),
|
|
715
|
+
error: payload.error_message ? String(payload.error_message) : '',
|
|
716
|
+
})
|
|
717
|
+
} catch (error) {
|
|
718
|
+
send(res, 502, { ok: false, message: describeError(error) })
|
|
719
|
+
}
|
|
720
|
+
},
|
|
721
|
+
}),
|
|
722
|
+
]
|
|
723
|
+
return () => { for (const dispose of disposers) dispose() }
|
|
724
|
+
}, 'solmirror: 管理路由')
|
|
725
|
+
|
|
726
|
+
// ---- 变更感知:低频漂移对账 ----
|
|
727
|
+
// 保存密钥/地址不主动触发重连(验证走「测试连接」);新配置由本对账循环在
|
|
728
|
+
// 10s 内静默换用,外部直接改凭据/配置文件同理覆盖。
|
|
729
|
+
ctx.effect(() => {
|
|
730
|
+
const disposers = []
|
|
731
|
+
const timer = setInterval(() => {
|
|
732
|
+
if (state.connectPromise || state.status === 'connecting') return
|
|
733
|
+
if (config.disabled.get()) {
|
|
734
|
+
if (state.client || state.disposers.size) void connect()
|
|
735
|
+
return
|
|
736
|
+
}
|
|
737
|
+
if (readUrl() !== state.connectedUrl) { void connect(); return }
|
|
738
|
+
void (async () => {
|
|
739
|
+
const { key } = await readKey()
|
|
740
|
+
if (key !== state.connectedKey) void connect()
|
|
741
|
+
})()
|
|
742
|
+
}, 10_000)
|
|
743
|
+
disposers.push(() => clearInterval(timer))
|
|
744
|
+
return () => { for (const dispose of disposers) dispose() }
|
|
745
|
+
}, 'solmirror: 变更感知重连')
|
|
746
|
+
|
|
747
|
+
ctx.effect(() => () => {
|
|
748
|
+
state.disposed = true
|
|
749
|
+
disposeTools()
|
|
750
|
+
void closeClient()
|
|
751
|
+
}, 'solmirror: 卸载清理')
|
|
752
|
+
|
|
753
|
+
void connect()
|
|
754
|
+
}
|