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/client/client.js
ADDED
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-solmirror 浏览器模块
|
|
3
|
+
*
|
|
4
|
+
* 1) 在 dsh 设置面板注册「瞬镜开放平台」自定义页:
|
|
5
|
+
* - 连接状态徽章 + 最近错误
|
|
6
|
+
* - API 密钥(保存进 dsh 凭据存储,回显只显示"已配置",永不显示明文)
|
|
7
|
+
* - 服务器地址(写入插件 volatile 配置,热生效)
|
|
8
|
+
* - 可见模型组勾选(写入插件配置;宿主侧实时过滤 list_models 并拦截越组生成)
|
|
9
|
+
* - 测试连接(宿主侧用当前密钥做一次性 MCP 握手)
|
|
10
|
+
*
|
|
11
|
+
* 2) 接管生成工具行的渲染(tool.call.toolview 键控插槽):气泡内展示生成
|
|
12
|
+
* 进度,成功后内嵌 <video> / 出图墙(媒体地址经宿主半边本地路由动态取新,
|
|
13
|
+
* 过期也能回放历史会话)。
|
|
14
|
+
*
|
|
15
|
+
* 格式约定:dsh client 模块表工厂(window.__ModuleLoader__.load)。
|
|
16
|
+
*/
|
|
17
|
+
window.__ModuleLoader__.load({
|
|
18
|
+
id: 'dsh-solmirror',
|
|
19
|
+
factory(require) {
|
|
20
|
+
const React = require('react')
|
|
21
|
+
|
|
22
|
+
const S = {
|
|
23
|
+
wrap: { display: 'flex', flexDirection: 'column', gap: '18px', fontSize: '13px', lineHeight: 1.6 },
|
|
24
|
+
card: { border: '1px solid rgba(128,128,140,0.28)', borderRadius: '10px', padding: '12px 14px' },
|
|
25
|
+
cardTitle: { fontWeight: 600, marginBottom: '6px' },
|
|
26
|
+
muted: { color: 'rgba(160,160,175,0.95)', fontSize: '12px' },
|
|
27
|
+
row: { display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' },
|
|
28
|
+
input: {
|
|
29
|
+
flex: '1 1 220px', minWidth: '200px', padding: '6px 9px', borderRadius: '8px',
|
|
30
|
+
border: '1px solid rgba(128,128,140,0.35)', background: 'transparent',
|
|
31
|
+
color: 'inherit', fontSize: '13px', outline: 'none',
|
|
32
|
+
},
|
|
33
|
+
button: {
|
|
34
|
+
padding: '6px 14px', borderRadius: '8px', border: '1px solid rgba(128,128,140,0.4)',
|
|
35
|
+
background: 'rgba(128,128,140,0.14)', color: 'inherit', fontSize: '13px', cursor: 'pointer',
|
|
36
|
+
},
|
|
37
|
+
primary: { background: 'rgba(64,120,220,0.9)', borderColor: 'transparent', color: '#fff' },
|
|
38
|
+
badge: { display: 'inline-flex', alignItems: 'center', gap: '6px', padding: '2px 10px', borderRadius: '999px', fontSize: '12px' },
|
|
39
|
+
msgOk: { color: '#3fbf6f' },
|
|
40
|
+
msgErr: { color: '#e0566b' },
|
|
41
|
+
groupRow: { display: 'flex', alignItems: 'center', gap: '8px', padding: '3px 0', cursor: 'pointer' },
|
|
42
|
+
groupsGrid: { display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: '2px 14px', marginTop: '6px' },
|
|
43
|
+
tag: { fontSize: '11px', padding: '1px 7px', borderRadius: '6px', background: 'rgba(128,128,140,0.18)', color: 'rgba(170,170,185,0.95)' },
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function badgeOf(status) {
|
|
47
|
+
if (status === 'connected') return { text: '已连接', color: '#2f9e5f', bg: 'rgba(47,158,95,0.14)' }
|
|
48
|
+
if (status === 'connecting') return { text: '连接中…', color: '#c99a2e', bg: 'rgba(201,154,46,0.14)' }
|
|
49
|
+
if (status === 'error') return { text: '未连接', color: '#e0566b', bg: 'rgba(224,86,107,0.14)' }
|
|
50
|
+
return { text: '未启用', color: 'rgba(160,160,175,0.95)', bg: 'rgba(128,128,140,0.14)' }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function SolmirrorSection(ops) {
|
|
54
|
+
const [snap, setSnap] = React.useState(null)
|
|
55
|
+
const [keyInput, setKeyInput] = React.useState('')
|
|
56
|
+
const [urlDraft, setUrlDraft] = React.useState('')
|
|
57
|
+
const [selected, setSelected] = React.useState(null)
|
|
58
|
+
const [draftsReady, setDraftsReady] = React.useState(false)
|
|
59
|
+
const [busy, setBusy] = React.useState('')
|
|
60
|
+
const [msg, setMsg] = React.useState(null)
|
|
61
|
+
const [testResult, setTestResult] = React.useState(null)
|
|
62
|
+
|
|
63
|
+
const note = (kind, text) => setMsg({ kind, text })
|
|
64
|
+
|
|
65
|
+
const refresh = async () => {
|
|
66
|
+
try {
|
|
67
|
+
const next = await ops.fetchStatus()
|
|
68
|
+
setSnap(next)
|
|
69
|
+
if (!draftsReady) {
|
|
70
|
+
setUrlDraft(next.url ?? '')
|
|
71
|
+
setSelected(next.visibleGroups ?? [])
|
|
72
|
+
setDraftsReady(true)
|
|
73
|
+
}
|
|
74
|
+
} catch (error) {
|
|
75
|
+
note('err', `读取状态失败:${String(error?.message ?? error)}`)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
React.useEffect(() => { void refresh() }, [])
|
|
80
|
+
|
|
81
|
+
const withBusy = async (tag, fn) => {
|
|
82
|
+
setBusy(tag)
|
|
83
|
+
try { await fn() } finally { setBusy(''); void refresh() }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 保存只保存,不主动触发连接:验证走「测试连接」;常驻连接由宿主侧
|
|
87
|
+
// 10s 漂移对账自动换用新密钥/新地址。
|
|
88
|
+
const saveKey = () => withBusy('key', async () => {
|
|
89
|
+
const value = keyInput.trim()
|
|
90
|
+
if (!value) return note('err', '请先粘贴密钥(sk- 开头)')
|
|
91
|
+
const error = await ops.saveKey(value)
|
|
92
|
+
if (error) return note('err', `保存失败:${error}`)
|
|
93
|
+
setKeyInput('')
|
|
94
|
+
note('ok', '密钥已保存,连接将在几秒内自动切换;可点「测试连接」立即验证')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
const saveUrl = () => withBusy('url', async () => {
|
|
98
|
+
const value = urlDraft.trim()
|
|
99
|
+
if (!value) return note('err', '地址不能为空')
|
|
100
|
+
const error = await ops.saveConfig('url', value)
|
|
101
|
+
if (error) return note('err', `保存失败:${error}`)
|
|
102
|
+
note('ok', '地址已保存,连接将在几秒内自动切换;可点「测试连接」立即验证')
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
const saveGroups = () => withBusy('groups', async () => {
|
|
106
|
+
const error = await ops.saveConfig('visibleGroups', selected ?? [])
|
|
107
|
+
if (error) return note('err', `保存失败:${error}`)
|
|
108
|
+
note('ok', (selected ?? []).length === 0 ? '已恢复全部模型组可见' : `已收窄到 ${selected.length} 个模型组(实时生效,无需重连)`)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
const runTest = () => withBusy('test', async () => {
|
|
112
|
+
const result = await ops.post('/plugins/solmirror/test')
|
|
113
|
+
setTestResult(result)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
const toggleGroup = id => setSelected(prev => {
|
|
117
|
+
const list = prev ?? []
|
|
118
|
+
return list.includes(id) ? list.filter(x => x !== id) : [...list, id]
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
const badge = badgeOf(snap?.status ?? 'idle')
|
|
122
|
+
const groups = snap?.groups ?? []
|
|
123
|
+
const allVisible = !selected || selected.length === 0
|
|
124
|
+
|
|
125
|
+
return React.createElement('div', { style: S.wrap },
|
|
126
|
+
// 连接状态
|
|
127
|
+
React.createElement('div', { style: S.card },
|
|
128
|
+
React.createElement('div', { style: { ...S.cardTitle, ...S.row, justifyContent: 'space-between' } },
|
|
129
|
+
React.createElement('span', null, '连接状态'),
|
|
130
|
+
React.createElement('span', { style: { ...S.badge, color: badge.color, background: badge.bg } },
|
|
131
|
+
`● ${badge.text}`),
|
|
132
|
+
),
|
|
133
|
+
React.createElement('div', { style: S.muted },
|
|
134
|
+
snap?.keyConfigured ? 'API 密钥:已配置' : 'API 密钥:未配置(在下方粘贴保存)',
|
|
135
|
+
snap?.url ? ` · 服务地址:${snap.url}` : '',
|
|
136
|
+
snap?.tools?.length ? ` · 已注册工具 ${snap.tools.length} 个` : '',
|
|
137
|
+
),
|
|
138
|
+
snap?.lastError ? React.createElement('div', { style: { ...S.muted, ...S.msgErr, marginTop: '4px' } }, snap.lastError) : null,
|
|
139
|
+
),
|
|
140
|
+
|
|
141
|
+
// API 密钥
|
|
142
|
+
React.createElement('div', { style: S.card },
|
|
143
|
+
React.createElement('div', { style: S.cardTitle }, 'API 密钥'),
|
|
144
|
+
React.createElement('div', { style: S.row },
|
|
145
|
+
React.createElement('input', {
|
|
146
|
+
type: 'password', style: S.input, value: keyInput, placeholder: 'sk-…(粘贴后点保存)',
|
|
147
|
+
onChange: e => setKeyInput(e.target.value),
|
|
148
|
+
onKeyDown: e => { if (e.key === 'Enter') void saveKey() },
|
|
149
|
+
}),
|
|
150
|
+
React.createElement('button', { type: 'button', style: { ...S.button, ...(keyInput.trim() ? S.primary : {}) }, disabled: busy === 'key', onClick: saveKey },
|
|
151
|
+
busy === 'key' ? '保存中…' : '保存'),
|
|
152
|
+
),
|
|
153
|
+
React.createElement('div', { style: { ...S.muted, marginTop: '6px' } },
|
|
154
|
+
'密钥只保存在本机 dsh 凭据存储(~/.dsh/.credentials.yaml),不会回显明文、不会写入任何配置文件。'),
|
|
155
|
+
),
|
|
156
|
+
|
|
157
|
+
// 服务器地址
|
|
158
|
+
React.createElement('div', { style: S.card },
|
|
159
|
+
React.createElement('div', { style: S.cardTitle }, '服务器地址'),
|
|
160
|
+
React.createElement('div', { style: S.row },
|
|
161
|
+
React.createElement('input', {
|
|
162
|
+
type: 'text', style: S.input, value: urlDraft, placeholder: 'https://aigcserver.solmirror.cn/mcp',
|
|
163
|
+
onChange: e => setUrlDraft(e.target.value),
|
|
164
|
+
onKeyDown: e => { if (e.key === 'Enter') void saveUrl() },
|
|
165
|
+
}),
|
|
166
|
+
React.createElement('button', { type: 'button', style: S.button, disabled: busy === 'url', onClick: saveUrl },
|
|
167
|
+
busy === 'url' ? '保存中…' : '保存'),
|
|
168
|
+
),
|
|
169
|
+
React.createElement('div', { style: { ...S.muted, marginTop: '6px' } }, '默认已填好线上地址,一般无需修改;也可以从瞬镜平台「开放平台 → 插件接入」页一键复制。'),
|
|
170
|
+
),
|
|
171
|
+
|
|
172
|
+
// 可见模型组
|
|
173
|
+
React.createElement('div', { style: S.card },
|
|
174
|
+
React.createElement('div', { style: { ...S.cardTitle, ...S.row, justifyContent: 'space-between' } },
|
|
175
|
+
React.createElement('span', null, '可见模型组'),
|
|
176
|
+
React.createElement('span', { style: S.tag }, allVisible ? '当前:全部可见' : `当前:${selected.length} 个组`),
|
|
177
|
+
),
|
|
178
|
+
groups.length === 0
|
|
179
|
+
? React.createElement('div', { style: S.muted }, '连接成功后这里会列出平台全部模型组;未连接时不过滤。')
|
|
180
|
+
: React.createElement('div', { style: S.groupsGrid },
|
|
181
|
+
groups.map(g => React.createElement('label', { key: `${g.category}:${g.id}`, style: S.groupRow },
|
|
182
|
+
React.createElement('input', {
|
|
183
|
+
type: 'checkbox',
|
|
184
|
+
checked: (selected ?? []).includes(g.id),
|
|
185
|
+
onChange: () => toggleGroup(g.id),
|
|
186
|
+
}),
|
|
187
|
+
React.createElement('span', null, g.name),
|
|
188
|
+
React.createElement('span', { style: S.tag }, g.category === 'image' ? '图片' : '视频'),
|
|
189
|
+
)),
|
|
190
|
+
),
|
|
191
|
+
React.createElement('div', { style: { ...S.row, marginTop: '8px' } },
|
|
192
|
+
React.createElement('button', { type: 'button', style: { ...S.button, ...(!allVisible ? S.primary : {}) }, disabled: busy === 'groups', onClick: saveGroups },
|
|
193
|
+
busy === 'groups' ? '保存中…' : '保存可见范围'),
|
|
194
|
+
React.createElement('span', { style: S.muted }, '不勾选任何组 = 全部可见。过滤只影响 agent 看到的目录与生成入口。'),
|
|
195
|
+
),
|
|
196
|
+
),
|
|
197
|
+
|
|
198
|
+
// 测试连接 + 消息
|
|
199
|
+
React.createElement('div', { style: S.card },
|
|
200
|
+
React.createElement('div', { style: S.row },
|
|
201
|
+
React.createElement('button', { type: 'button', style: S.button, disabled: busy === 'test', onClick: runTest },
|
|
202
|
+
busy === 'test' ? '测试中…' : '测试连接'),
|
|
203
|
+
testResult ? React.createElement('span', { style: testResult.ok ? S.msgOk : S.msgErr },
|
|
204
|
+
testResult.ok ? `✓ ${testResult.message}` : `✗ ${testResult.message}`)
|
|
205
|
+
: React.createElement('span', { style: S.muted }, '用当前保存的密钥向瞬镜服务发一次 MCP 握手。'),
|
|
206
|
+
),
|
|
207
|
+
msg ? React.createElement('div', { style: { marginTop: '8px', ...(msg.kind === 'ok' ? S.msgOk : S.msgErr) } }, msg.text) : null,
|
|
208
|
+
))
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
// ---- 会话媒体面:挂在回合尾部 turnTail(对话主流、不随工具行折叠)----
|
|
213
|
+
// dsh 工具行在回合结束后会被折叠收起,媒体放里面看不见;「生成中占位框 +
|
|
214
|
+
// 完成的视频/图片」挂在 turnTail 永远展开,工具行只留一行状态回执。
|
|
215
|
+
const VIDEO_TOOL = 'mcp__solmirror__generate_video'
|
|
216
|
+
const IMAGE_TOOL = 'mcp__solmirror__generate_image'
|
|
217
|
+
const TERMINAL_STATUSES = new Set(['succeeded', 'failed', 'review_rejected'])
|
|
218
|
+
const MS = {
|
|
219
|
+
wrap: { display: 'flex', flexDirection: 'column', gap: '6px', fontSize: '13px', lineHeight: 1.5, padding: '4px 0' },
|
|
220
|
+
head: { display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' },
|
|
221
|
+
title: { fontWeight: 600 },
|
|
222
|
+
tag: { fontSize: '11px', padding: '1px 7px', borderRadius: '6px', background: 'rgba(128,128,140,0.18)', color: 'rgba(170,170,185,0.95)' },
|
|
223
|
+
muted: { color: 'rgba(160,160,175,0.95)', fontSize: '12px' },
|
|
224
|
+
err: { color: '#e0566b', fontSize: '12px' },
|
|
225
|
+
ok: { color: '#2f9e5f' },
|
|
226
|
+
link: { color: 'rgba(90,140,230,0.95)', fontSize: '12px' },
|
|
227
|
+
video: { width: '100%', maxWidth: '420px', borderRadius: '10px', background: '#000', display: 'block' },
|
|
228
|
+
imgRow: { display: 'flex', flexWrap: 'wrap', gap: '8px' },
|
|
229
|
+
rowLine: { display: 'flex', flexDirection: 'column', gap: '2px', fontSize: '12px', color: 'rgba(165,165,180,0.95)', padding: '2px 0' },
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// 占位框动效:慢速斜向扫光 + 文案呼吸。样式表随模块物化注入一次。
|
|
233
|
+
const styleTag = document.createElement('style')
|
|
234
|
+
styleTag.dataset.plugin = 'dsh-solmirror'
|
|
235
|
+
styleTag.textContent = [
|
|
236
|
+
'@keyframes solmirror-sweep { 0% { transform: translateX(-120%);} 100% { transform: translateX(220%);} }',
|
|
237
|
+
'@keyframes solmirror-breathe { 0%,100% { opacity: 0.45;} 50% { opacity: 1;} }',
|
|
238
|
+
'.solmirror-ph { position: relative; overflow: hidden; border-radius: 10px;',
|
|
239
|
+
' border: 1px solid rgba(128,128,140,0.22); background: rgba(128,128,140,0.10); }',
|
|
240
|
+
'.solmirror-ph-sweep { position: absolute; inset: 0; transform: skewX(-16deg);',
|
|
241
|
+
' background: linear-gradient(90deg, transparent, rgba(200,205,225,0.07) 45%, rgba(200,205,225,0.12) 50%, rgba(200,205,225,0.07) 55%, transparent);',
|
|
242
|
+
' animation: solmirror-sweep 2.4s ease-in-out infinite; }',
|
|
243
|
+
'.solmirror-ph-face { position: absolute; inset: 0; display: flex; flex-direction: column;',
|
|
244
|
+
' align-items: center; justify-content: center; gap: 6px; }',
|
|
245
|
+
'.solmirror-ph-title { font-size: 14px; font-weight: 600; color: rgba(175,178,195,0.95);',
|
|
246
|
+
' animation: solmirror-breathe 2s ease-in-out infinite; letter-spacing: 2px; }',
|
|
247
|
+
'.solmirror-ph-sub { font-size: 11px; color: rgba(150,153,170,0.8); }',
|
|
248
|
+
].join('\n')
|
|
249
|
+
document.head.append(styleTag)
|
|
250
|
+
|
|
251
|
+
function statusTextOf(status) {
|
|
252
|
+
// 服务端返回小写状态('generating'/'pending')
|
|
253
|
+
const s = String(status ?? '').toLowerCase()
|
|
254
|
+
if (s === 'succeeded') return '已生成'
|
|
255
|
+
if (s === 'failed') return '生成失败'
|
|
256
|
+
if (s === 'review_rejected') return '审核未通过'
|
|
257
|
+
if (s === 'pending' || s === 'preparing') return '排队中'
|
|
258
|
+
if (s === 'reviewing') return '素材审核中'
|
|
259
|
+
if (s === 'generating') return '生成中'
|
|
260
|
+
return status || '查询中'
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function textOf(block) {
|
|
264
|
+
return (block?.content ?? [])
|
|
265
|
+
.filter(item => item && item.type === 'text')
|
|
266
|
+
.map(item => item.text)
|
|
267
|
+
.join('\n')
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// 终态结果短期缓存:页面重进/历史气泡回看时同 id 媒体卡直接用缓存渲染、
|
|
271
|
+
// 零请求(临时地址有效期远大于 TTL)
|
|
272
|
+
const terminalCache = new Map()
|
|
273
|
+
const TERMINAL_CACHE_TTL = 5 * 60 * 1000
|
|
274
|
+
|
|
275
|
+
function cachedTerminal(generationId) {
|
|
276
|
+
const hit = terminalCache.get(generationId)
|
|
277
|
+
return hit && Date.now() - hit.at < TERMINAL_CACHE_TTL ? hit.data : null
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function generationIdOf(media, resultText) {
|
|
281
|
+
if (!resultText) return null
|
|
282
|
+
const byText = /generation (\d+)/.exec(resultText)
|
|
283
|
+
if (byText) return Number(byText[1])
|
|
284
|
+
if (media === 'image') {
|
|
285
|
+
const byJson = /"generation_id"\s*:\s*(\d+)/.exec(resultText)
|
|
286
|
+
if (byJson) return Number(byJson[1])
|
|
287
|
+
}
|
|
288
|
+
return null
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function ratioOf(value) {
|
|
292
|
+
const match = /^(\d+(?:\.\d+)?)\s*[:/x]\s*(\d+(?:\.\d+)?)$/.exec(String(value ?? ''))
|
|
293
|
+
return match ? { w: Number(match[1]), h: Number(match[2]) } : null
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function parseAspect(rawArgs) {
|
|
297
|
+
try {
|
|
298
|
+
const args = JSON.parse(rawArgs ?? '')
|
|
299
|
+
return ratioOf(args?.aspect_ratio)
|
|
300
|
+
} catch { /* 非 JSON 参数按缺省比例 */ }
|
|
301
|
+
return null
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// callId→generationId 映射(宿主半边私有路由):新会话的媒体面靠这张表
|
|
305
|
+
// 反查;旧会话文本里本就有 id,regex 兜底。
|
|
306
|
+
const callsCache = { at: 0, data: {} }
|
|
307
|
+
async function loadCalls() {
|
|
308
|
+
if (Date.now() - callsCache.at < 30_000) return callsCache.data
|
|
309
|
+
try {
|
|
310
|
+
const response = await fetch('/plugins/solmirror/calls', { cache: 'no-store' })
|
|
311
|
+
const data = await response.json()
|
|
312
|
+
if (data && data.ok && data.calls && typeof data.calls === 'object') {
|
|
313
|
+
callsCache.at = Date.now()
|
|
314
|
+
callsCache.data = data.calls
|
|
315
|
+
}
|
|
316
|
+
} catch { /* 映射不可用时旧文本 regex 兜底 */ }
|
|
317
|
+
return callsCache.data
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** 占位框:跟随生成比例的空白框 + 扫光动效 + 生成中文案。 */
|
|
321
|
+
function Placeholder({ aspect, sub }) {
|
|
322
|
+
const ratio = aspect ?? { w: 16, h: 9 }
|
|
323
|
+
const portrait = ratio.h > ratio.w
|
|
324
|
+
const style = portrait
|
|
325
|
+
? { height: '360px', width: 'auto', aspectRatio: `${ratio.w} / ${ratio.h}`, maxWidth: '100%' }
|
|
326
|
+
: { width: '100%', maxWidth: '420px', aspectRatio: `${ratio.w} / ${ratio.h}` }
|
|
327
|
+
return React.createElement('div', { className: 'solmirror-ph', style },
|
|
328
|
+
React.createElement('div', { className: 'solmirror-ph-sweep' }),
|
|
329
|
+
React.createElement('div', { className: 'solmirror-ph-face' },
|
|
330
|
+
React.createElement('div', { className: 'solmirror-ph-title' }, '生成中'),
|
|
331
|
+
sub ? React.createElement('div', { className: 'solmirror-ph-sub' }, sub) : null),
|
|
332
|
+
)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** 单个生成的媒体卡:轮询本地路由,占位 → 播放器/图片/错误。 */
|
|
336
|
+
function MediaCard({ gen }) {
|
|
337
|
+
const [info, setInfo] = React.useState(() => cachedTerminal(gen.generationId))
|
|
338
|
+
const [netError, setNetError] = React.useState('')
|
|
339
|
+
const generationId = gen.generationId
|
|
340
|
+
|
|
341
|
+
React.useEffect(() => {
|
|
342
|
+
if (!generationId) return undefined
|
|
343
|
+
// 缓存命中的终态:零请求。id 经 calls 反查比首渲染晚一拍,初始化器可能
|
|
344
|
+
// 已在 id=null 时跑过——须先播种缓存再停轮询,否则卡片停在占位框
|
|
345
|
+
const cached = cachedTerminal(generationId)
|
|
346
|
+
if (cached) {
|
|
347
|
+
setInfo(prev => (prev === null ? cached : prev))
|
|
348
|
+
return undefined
|
|
349
|
+
}
|
|
350
|
+
let stopped = false
|
|
351
|
+
let timer = null
|
|
352
|
+
let failures = 0
|
|
353
|
+
const tick = async () => {
|
|
354
|
+
try {
|
|
355
|
+
const response = await fetch(`/plugins/solmirror/generation/${generationId}`, { cache: 'no-store' })
|
|
356
|
+
const data = await response.json().catch(() => null)
|
|
357
|
+
if (stopped) return
|
|
358
|
+
if (data && data.ok) {
|
|
359
|
+
failures = 0
|
|
360
|
+
setNetError('')
|
|
361
|
+
setInfo(data)
|
|
362
|
+
if (TERMINAL_STATUSES.has(String(data.status).toLowerCase())) {
|
|
363
|
+
terminalCache.set(generationId, { at: Date.now(), data })
|
|
364
|
+
return
|
|
365
|
+
}
|
|
366
|
+
} else {
|
|
367
|
+
failures += 1
|
|
368
|
+
setNetError((data && data.message) || `HTTP ${response.status}`)
|
|
369
|
+
}
|
|
370
|
+
} catch (error) {
|
|
371
|
+
if (stopped) return
|
|
372
|
+
failures += 1
|
|
373
|
+
setNetError(String((error && error.message) || error))
|
|
374
|
+
}
|
|
375
|
+
// 轮询失败指数退避(5s→60s 封顶)无限重试,服务恢复后回到 5s 节奏
|
|
376
|
+
timer = setTimeout(tick, failures === 0 ? 5000 : Math.min(5000 * 2 ** failures, 60000))
|
|
377
|
+
}
|
|
378
|
+
void tick()
|
|
379
|
+
return () => { stopped = true; if (timer !== null) clearTimeout(timer) }
|
|
380
|
+
}, [generationId])
|
|
381
|
+
|
|
382
|
+
const isVideo = gen.media === 'video'
|
|
383
|
+
const label = isVideo ? '视频生成' : '图片生成'
|
|
384
|
+
if (gen.error) {
|
|
385
|
+
return React.createElement('div', { style: MS.rowLine },
|
|
386
|
+
React.createElement('span', null, label, ' · 提交未成功'),
|
|
387
|
+
gen.error ? React.createElement('span', { style: MS.err }, gen.error.slice(0, 200)) : null)
|
|
388
|
+
}
|
|
389
|
+
if (!generationId) {
|
|
390
|
+
return React.createElement('div', { style: MS.wrap },
|
|
391
|
+
Placeholder({ aspect: gen.aspect, sub: '已提交' }))
|
|
392
|
+
}
|
|
393
|
+
const status = info ? String(info.status).toLowerCase() : ''
|
|
394
|
+
if (!TERMINAL_STATUSES.has(status)) {
|
|
395
|
+
// 从未拿到状态且轮询失败中 → 副文案明示在自动重试
|
|
396
|
+
const sub = status ? statusTextOf(status)
|
|
397
|
+
: (netError ? '状态暂时取不到,自动重试中…' : undefined)
|
|
398
|
+
return React.createElement('div', { style: MS.wrap },
|
|
399
|
+
Placeholder({ aspect: gen.aspect, sub }),
|
|
400
|
+
netError && status ? React.createElement('div', { style: MS.muted }, `状态刷新失败:${netError}`) : null)
|
|
401
|
+
}
|
|
402
|
+
if (status !== 'succeeded') {
|
|
403
|
+
return React.createElement('div', { style: MS.wrap },
|
|
404
|
+
React.createElement('div', { style: MS.head },
|
|
405
|
+
React.createElement('span', { style: MS.title }, label),
|
|
406
|
+
React.createElement('span', { style: MS.err }, statusTextOf(status))),
|
|
407
|
+
info && info.error ? React.createElement('div', { style: MS.err }, info.error) : null)
|
|
408
|
+
}
|
|
409
|
+
const urls = (info && Array.isArray(info.urls) ? info.urls : []).map(String).filter(Boolean)
|
|
410
|
+
const children = [
|
|
411
|
+
React.createElement('div', { key: 'head', style: MS.head },
|
|
412
|
+
React.createElement('span', { style: MS.title }, label),
|
|
413
|
+
React.createElement('span', { style: MS.ok }, '已生成')),
|
|
414
|
+
]
|
|
415
|
+
if (isVideo) {
|
|
416
|
+
const url = urls[0] || (info && info.url) || ''
|
|
417
|
+
if (url) {
|
|
418
|
+
children.push(React.createElement('video', {
|
|
419
|
+
key: 'video', controls: true, preload: 'metadata', src: url, style: MS.video,
|
|
420
|
+
}))
|
|
421
|
+
if (info.summary) children.push(React.createElement('div', { key: 'summary', style: MS.muted }, info.summary))
|
|
422
|
+
children.push(React.createElement('a', { key: 'open', href: url, target: '_blank', rel: 'noreferrer', style: MS.link }, '在新标签页打开'))
|
|
423
|
+
}
|
|
424
|
+
} else if (urls.length) {
|
|
425
|
+
children.push(React.createElement('div', { key: 'imgs', style: MS.imgRow },
|
|
426
|
+
urls.map((url, index) => React.createElement('a', {
|
|
427
|
+
key: index, href: url, target: '_blank', rel: 'noreferrer', style: { display: 'block', borderRadius: '10px' },
|
|
428
|
+
},
|
|
429
|
+
React.createElement('img', {
|
|
430
|
+
src: url, alt: `生成图 ${index + 1}`, loading: 'lazy',
|
|
431
|
+
style: { display: 'block', maxWidth: '280px', maxHeight: '360px', borderRadius: '10px' },
|
|
432
|
+
})))))
|
|
433
|
+
if (info.summary) children.push(React.createElement('div', { key: 'summary', style: MS.muted }, info.summary))
|
|
434
|
+
}
|
|
435
|
+
if (netError) children.push(React.createElement('div', { key: 'net', style: MS.muted }, `状态刷新失败:${netError}`))
|
|
436
|
+
return React.createElement('div', { style: MS.wrap }, ...children)
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** 从会话快照收集本回合的 solmirror 生成(含运行中与子调用)。 */
|
|
440
|
+
function collectGenerations(snapshot, turnNumber, callsMap) {
|
|
441
|
+
const found = []
|
|
442
|
+
const seen = new Set()
|
|
443
|
+
const visit = block => {
|
|
444
|
+
if (!block || typeof block !== 'object') return
|
|
445
|
+
const subCalls = Array.isArray(block.subCalls) ? block.subCalls : []
|
|
446
|
+
for (const child of subCalls) visit(child)
|
|
447
|
+
// ToolCallBlock 三态:ToolResultNode 用 kind:'tool-result' 标记(无 phase
|
|
448
|
+
// 字段),Preparing/Started 才有 phase;结果相名字按 call.name 优先解析
|
|
449
|
+
const isResult = typeof block.kind === 'string' && 'content' in block
|
|
450
|
+
const name = String(isResult ? (block.call?.name ?? block.name ?? '') : (block.name ?? ''))
|
|
451
|
+
if (name !== VIDEO_TOOL && name !== IMAGE_TOOL) return
|
|
452
|
+
const callId = String(block.callId ?? '')
|
|
453
|
+
if (callId && seen.has(callId)) return
|
|
454
|
+
if (callId) seen.add(callId)
|
|
455
|
+
const resultText = isResult && !block.isError ? textOf(block) : ''
|
|
456
|
+
const media = name === VIDEO_TOOL ? 'video' : 'image'
|
|
457
|
+
const argsRaw = isResult ? block.call?.argsRaw : block.argsRaw
|
|
458
|
+
const mapped = callId ? callsMap?.[callId] : undefined
|
|
459
|
+
found.push({
|
|
460
|
+
callId: callId || `${name}-${found.length}`,
|
|
461
|
+
media,
|
|
462
|
+
// 提交就失败的调用没有 generationId:渲染一行失败回执,不占位框
|
|
463
|
+
// (避免误当"还有一个在生成")
|
|
464
|
+
error: isResult && block.isError ? textOf(block) : '',
|
|
465
|
+
// 新会话文本无 id → 私有映射反查;旧会话/降级路径文本带 id → regex
|
|
466
|
+
generationId: generationIdOf(media, resultText)
|
|
467
|
+
?? (Number.isInteger(mapped?.generationId) ? mapped.generationId : null),
|
|
468
|
+
aspect: parseAspect(argsRaw) ?? ratioOf(mapped?.aspect),
|
|
469
|
+
})
|
|
470
|
+
}
|
|
471
|
+
try {
|
|
472
|
+
const keys = snapshot?.locations?.getTurn?.(turnNumber) ?? []
|
|
473
|
+
for (const key of keys) {
|
|
474
|
+
const node = snapshot?.nodes?.get?.(key)
|
|
475
|
+
if (node?.kind !== 'tool-call') continue
|
|
476
|
+
visit(node?.data?.root)
|
|
477
|
+
}
|
|
478
|
+
} catch { /* 快照结构异常时静默不渲染 */ }
|
|
479
|
+
return found
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** 回合尾部的媒体面:本回合每条 solmirror 生成一张媒体卡。 */
|
|
483
|
+
function SolmirrorTurnTail(props) {
|
|
484
|
+
const turnNumber = props?.turn?.turn
|
|
485
|
+
const [calls, setCalls] = React.useState(null)
|
|
486
|
+
const gens = typeof props.useChat === 'function'
|
|
487
|
+
? props.useChat(snapshot => collectGenerations(snapshot, turnNumber, calls))
|
|
488
|
+
: collectGenerations(null, turnNumber, calls)
|
|
489
|
+
React.useEffect(() => {
|
|
490
|
+
if (!gens || gens.length === 0) return undefined
|
|
491
|
+
if (gens.every(gen => gen.generationId)) return undefined
|
|
492
|
+
let stopped = false
|
|
493
|
+
// id 反查定时重试直到本回合全部解析(loadCalls 自带 30s TTL);
|
|
494
|
+
// 全部解析后本 effect 重建即清掉 interval。
|
|
495
|
+
const attempt = () => {
|
|
496
|
+
loadCalls().then(data => {
|
|
497
|
+
if (stopped || !data || Object.keys(data).length === 0) return
|
|
498
|
+
// 函数式更新:同一引用不触发渲染,避免 30s TTL 命中时的无谓重挂
|
|
499
|
+
setCalls(prev => (prev === data ? prev : data))
|
|
500
|
+
}).catch(() => { /* 重试由 interval 驱动 */ })
|
|
501
|
+
}
|
|
502
|
+
attempt()
|
|
503
|
+
const timer = setInterval(attempt, 10_000)
|
|
504
|
+
return () => { stopped = true; clearInterval(timer) }
|
|
505
|
+
}) // 无依赖数组:gens 每快照变化都是新引用;重试有 30s TTL 兜底,开销可控
|
|
506
|
+
if (!gens || gens.length === 0) return null
|
|
507
|
+
return React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: '12px' } },
|
|
508
|
+
gens.map(gen => React.createElement(MediaCard, { key: gen.callId, gen })))
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/** 工具行视图:turn-tail 节点只在回合结束时发布,活跃回合里回合尾部不存在,
|
|
512
|
+
* 这段窗口的媒体面挂工具行(toolview 键控替换整行):进行中=跟随比例占位框、
|
|
513
|
+
* 结果相=媒体卡(图片常已是终态直接出图墙;视频交给轮询)。回合收尾后工具行
|
|
514
|
+
* 折叠、回合尾部的媒体卡接管。id 解析不出或错误态回退一行回执;id 不上 UI。 */
|
|
515
|
+
function toolRowView(media) {
|
|
516
|
+
return function ToolRow(props) {
|
|
517
|
+
const phase = props.phase
|
|
518
|
+
const block = props.block
|
|
519
|
+
const label = media === 'video' ? '视频生成' : '图片生成'
|
|
520
|
+
if (phase !== 'result') {
|
|
521
|
+
// preparing 相参数可能不全(比例取不到就 16:9 兜底);start 相=已提交待出
|
|
522
|
+
return React.createElement('div', { style: MS.wrap },
|
|
523
|
+
Placeholder({
|
|
524
|
+
aspect: parseAspect(block && block.argsRaw),
|
|
525
|
+
sub: phase === 'preparing' ? '提交中…' : '已提交',
|
|
526
|
+
}))
|
|
527
|
+
}
|
|
528
|
+
if (block.isError) {
|
|
529
|
+
const resultText = textOf(block)
|
|
530
|
+
return React.createElement('div', { style: MS.rowLine },
|
|
531
|
+
React.createElement('span', null, label, ' · 失败'),
|
|
532
|
+
resultText ? React.createElement('span', { style: MS.err }, resultText.slice(0, 200)) : null)
|
|
533
|
+
}
|
|
534
|
+
const resultText = textOf(block)
|
|
535
|
+
const gen = {
|
|
536
|
+
callId: String(props.callId ?? ''),
|
|
537
|
+
media,
|
|
538
|
+
generationId: generationIdOf(media, resultText),
|
|
539
|
+
aspect: parseAspect(block.call && block.call.argsRaw),
|
|
540
|
+
}
|
|
541
|
+
// 结果相文本带 id → 行内媒体卡;无 id(新式视频文本)→ 一行回执
|
|
542
|
+
//(尾部卡有映射反查全通道,接管展示)
|
|
543
|
+
if (gen.generationId) return React.createElement(MediaCard, { gen })
|
|
544
|
+
return React.createElement('div', { style: MS.rowLine },
|
|
545
|
+
React.createElement('span', null, label, ' · 已提交'))
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
return {
|
|
550
|
+
inject: ['slots', 'remote', 'remote.credentials', 'remote.settings'],
|
|
551
|
+
apply(ctx) {
|
|
552
|
+
const injected = () => ({
|
|
553
|
+
async fetchStatus() {
|
|
554
|
+
const response = await fetch('/plugins/solmirror/status', { cache: 'no-store' })
|
|
555
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
556
|
+
return response.json()
|
|
557
|
+
},
|
|
558
|
+
async post(path) {
|
|
559
|
+
const response = await fetch(path, { method: 'POST' })
|
|
560
|
+
return response.json().catch(() => ({}))
|
|
561
|
+
},
|
|
562
|
+
async saveKey(value) {
|
|
563
|
+
const response = await ctx.remote.credentials.set('SOLMIRROR_API_KEY', value)
|
|
564
|
+
return response.ok ? null : response.error.message
|
|
565
|
+
},
|
|
566
|
+
async saveConfig(field, value) {
|
|
567
|
+
const response = await ctx.remote.settings.mutate(
|
|
568
|
+
'solmirror', [{ op: 'set', path: [field], value }], undefined)
|
|
569
|
+
return response.ok ? null : response.error.message
|
|
570
|
+
},
|
|
571
|
+
})
|
|
572
|
+
|
|
573
|
+
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
|
574
|
+
name: 'settings.section',
|
|
575
|
+
id: 'solmirror',
|
|
576
|
+
order: 40,
|
|
577
|
+
label: () => '瞬镜开放平台',
|
|
578
|
+
inject: injected,
|
|
579
|
+
}, SolmirrorSection))
|
|
580
|
+
// 媒体面(占位/播放器/图片)挂回合尾部:对话主流、不随工具行折叠
|
|
581
|
+
ctx.slots.inject('conversation.chat.turnTail', () => ctx.slots.register(
|
|
582
|
+
{ name: 'conversation.chat.turnTail', id: 'solmirror' }, SolmirrorTurnTail))
|
|
583
|
+
// 工具行=活跃回合的媒体面(占位框/出图墙);回合收尾后随行折叠、尾部卡接管
|
|
584
|
+
ctx.slots.inject('tool.call.toolview', () => {
|
|
585
|
+
const disposers = [
|
|
586
|
+
ctx.slots.register({ name: 'tool.call.toolview', key: VIDEO_TOOL }, toolRowView('video')),
|
|
587
|
+
ctx.slots.register({ name: 'tool.call.toolview', key: IMAGE_TOOL }, toolRowView('image')),
|
|
588
|
+
]
|
|
589
|
+
return () => { for (const dispose of disposers) dispose() }
|
|
590
|
+
})
|
|
591
|
+
},
|
|
592
|
+
}
|
|
593
|
+
},
|
|
594
|
+
})
|