dsh-dock 0.5.0 → 0.7.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/README.md +26 -12
- package/client.js +3198 -365
- package/features/animation/host.js +150 -24
- package/features/animation/view.jsx +311 -131
- package/features/mobile-relay/gateway.js +279 -0
- package/features/mobile-relay/host.js +328 -0
- package/features/mobile-relay/view.jsx +207 -0
- package/features/modelconfig/view.js +55 -14
- package/features/theme/view.js +35 -10
- package/features/tokenlog/view.jsx +3 -1
- package/features/visionproxy/host.js +24 -10
- package/index.js +2 -0
- package/package.json +4 -3
- package/screenshots/dock-animation.png +0 -0
- package/screenshots/dock-modelconfig.png +0 -0
- package/src/client.jsx +44 -18
- package/src/host-core.js +3 -0
- package/screenshots/dock-robot.png +0 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// dsh-dock · 功能模块【手机接力】· 客户端视图
|
|
2
|
+
// 手机和电脑均轮询同一个短时配对记录;业务会话继续由 DSH 原生会话服务负责同步。
|
|
3
|
+
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
4
|
+
import QRCode from 'qrcode'
|
|
5
|
+
|
|
6
|
+
const DEVICE_KEY = 'dsh-dock/mobile-relay/device/v1'
|
|
7
|
+
const SESSION_KEY = 'dsh-dock/mobile-relay/session/v1'
|
|
8
|
+
const LAUNCH_KEY = 'dsh-dock/mobile-relay/launch/v1'
|
|
9
|
+
|
|
10
|
+
function rpc(method, payload) {
|
|
11
|
+
return fetch('/dsh-dock/mobile-relay/' + method, {
|
|
12
|
+
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload || {}),
|
|
13
|
+
}).then(async (res) => {
|
|
14
|
+
const body = await res.json().catch(() => ({}))
|
|
15
|
+
if (res.ok && body && body.ok) return body.data
|
|
16
|
+
if (res.status === 404 || res.status === 405) throw new Error('宿主进程仍是旧版本,请重启 dsh web 后再开启局域网连接')
|
|
17
|
+
throw new Error((body && body.error && body.error.message) || ('请求失败(' + res.status + ')'))
|
|
18
|
+
})
|
|
19
|
+
}
|
|
20
|
+
function deviceId() {
|
|
21
|
+
let id = ''
|
|
22
|
+
try { id = localStorage.getItem(DEVICE_KEY) || '' } catch { /* memory only */ }
|
|
23
|
+
if (!id) {
|
|
24
|
+
id = (globalThis.crypto && crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2))
|
|
25
|
+
try { localStorage.setItem(DEVICE_KEY, id) } catch { /* memory only */ }
|
|
26
|
+
}
|
|
27
|
+
return id
|
|
28
|
+
}
|
|
29
|
+
function readSession() {
|
|
30
|
+
try { const raw = sessionStorage.getItem(SESSION_KEY); return raw ? JSON.parse(raw) : null } catch { return null }
|
|
31
|
+
}
|
|
32
|
+
function saveSession(value) { try { sessionStorage.setItem(SESSION_KEY, JSON.stringify(value)) } catch { /* memory state still works */ } }
|
|
33
|
+
function clearSession() { try { sessionStorage.removeItem(SESSION_KEY) } catch { /* ignore */ } }
|
|
34
|
+
function fmtDuration(ms) {
|
|
35
|
+
const sec = Math.max(0, Math.floor((ms || 0) / 1000)); const min = Math.floor(sec / 60); const rest = sec % 60
|
|
36
|
+
return min > 0 ? min + ' 分 ' + String(rest).padStart(2, '0') + ' 秒' : rest + ' 秒'
|
|
37
|
+
}
|
|
38
|
+
function fmtTime(value) { return value ? new Date(value).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '' }
|
|
39
|
+
function phaseLabel(phase) { return ({ think: '正在思考', write: '正在输出', code: '正在开发', search: '正在查资料' })[phase] || '进行中' }
|
|
40
|
+
function copy(value) {
|
|
41
|
+
if (navigator.clipboard && navigator.clipboard.writeText) return navigator.clipboard.writeText(value)
|
|
42
|
+
return Promise.reject(new Error('当前浏览器不支持复制,请长按或手动复制'))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function RelayIcon({ name, size = 18 }) {
|
|
46
|
+
const common = { width: size, height: size, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true }
|
|
47
|
+
if (name === 'phone') return <svg {...common}><rect x="7" y="2.5" width="10" height="19" rx="2.2"/><path d="M10 18.5h4"/></svg>
|
|
48
|
+
if (name === 'link') return <svg {...common}><path d="M10.5 13.5a4 4 0 0 0 5.66.01l2-2a4 4 0 0 0-5.66-5.66l-1.15 1.14"/><path d="M13.5 10.5a4 4 0 0 0-5.66-.01l-2 2a4 4 0 0 0 5.66 5.66l1.15-1.14"/></svg>
|
|
49
|
+
if (name === 'copy') return <svg {...common}><rect x="8" y="8" width="11" height="12" rx="1.5"/><path d="M16 8V5.5A1.5 1.5 0 0 0 14.5 4h-9A1.5 1.5 0 0 0 4 5.5v9A1.5 1.5 0 0 0 5.5 16H8"/></svg>
|
|
50
|
+
if (name === 'check') return <svg {...common}><path d="m5 12 4.2 4.2L19 6.5"/></svg>
|
|
51
|
+
if (name === 'arrow') return <svg {...common}><path d="M5 12h13"/><path d="m14 7 5 5-5 5"/></svg>
|
|
52
|
+
if (name === 'close') return <svg {...common}><path d="m6 6 12 12M18 6 6 18"/></svg>
|
|
53
|
+
return <svg {...common}><circle cx="12" cy="12" r="8"/><path d="M12 8v4l2.5 2"/></svg>
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function useCompact() {
|
|
57
|
+
const [compact, setCompact] = useState(() => typeof window !== 'undefined' && window.matchMedia('(max-width: 680px)').matches)
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
const media = window.matchMedia('(max-width: 680px)')
|
|
60
|
+
const update = () => setCompact(media.matches); update(); media.addEventListener('change', update)
|
|
61
|
+
return () => media.removeEventListener('change', update)
|
|
62
|
+
}, [])
|
|
63
|
+
return compact
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function MobileRelayView() {
|
|
67
|
+
const compact = useCompact()
|
|
68
|
+
const [session, setSession] = useState(readSession)
|
|
69
|
+
const [pair, setPair] = useState(null)
|
|
70
|
+
const [network, setNetwork] = useState(null)
|
|
71
|
+
const [port, setPort] = useState(3081)
|
|
72
|
+
const [selectedAddress, setSelectedAddress] = useState('')
|
|
73
|
+
const [qrDataUrl, setQrDataUrl] = useState('')
|
|
74
|
+
const [note, setNote] = useState('')
|
|
75
|
+
const [busy, setBusy] = useState('')
|
|
76
|
+
const [notice, setNotice] = useState('')
|
|
77
|
+
const [error, setError] = useState('')
|
|
78
|
+
const mobile = session && session.role === 'mobile'
|
|
79
|
+
|
|
80
|
+
const setCurrent = useCallback((next) => { setSession(next); next ? saveSession(next) : clearSession() }, [])
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
if (session) return
|
|
83
|
+
rpc('network').then((data) => {
|
|
84
|
+
setNetwork(data)
|
|
85
|
+
if (data && data.defaultPort) setPort(data.defaultPort)
|
|
86
|
+
const first = data && data.addresses && data.addresses[0]
|
|
87
|
+
if (first) setSelectedAddress(first.address)
|
|
88
|
+
}).catch((e) => setError(e && e.message ? e.message : String(e)))
|
|
89
|
+
}, [session])
|
|
90
|
+
const refresh = useCallback(async (candidate = session) => {
|
|
91
|
+
if (!candidate) return
|
|
92
|
+
try {
|
|
93
|
+
const result = await rpc('status', candidate)
|
|
94
|
+
setPair(result.pair); setError('')
|
|
95
|
+
} catch (e) {
|
|
96
|
+
const message = e && e.message ? e.message : String(e)
|
|
97
|
+
setError(message)
|
|
98
|
+
if (/过期|未完成配对/.test(message)) setCurrent(null)
|
|
99
|
+
}
|
|
100
|
+
}, [session, setCurrent])
|
|
101
|
+
|
|
102
|
+
useEffect(() => { if (session) refresh(session) }, [session, refresh])
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
if (!session) return
|
|
105
|
+
const timer = setInterval(() => refresh(session), 2500)
|
|
106
|
+
return () => clearInterval(timer)
|
|
107
|
+
}, [session, refresh])
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
if (session) return
|
|
110
|
+
let launch = ''
|
|
111
|
+
try { launch = sessionStorage.getItem(LAUNCH_KEY) || '' } catch { /* ignore */ }
|
|
112
|
+
if (!launch) return
|
|
113
|
+
try { sessionStorage.removeItem(LAUNCH_KEY) } catch { /* ignore */ }
|
|
114
|
+
const [pairId, code] = launch.split('.')
|
|
115
|
+
if (!pairId || !code) return
|
|
116
|
+
setBusy('join')
|
|
117
|
+
rpc('join', { pairId, code, deviceId: deviceId(), label: '手机端' })
|
|
118
|
+
.then((data) => { setCurrent({ pairId, secret: data.secret, role: 'mobile', deviceId: deviceId() }); setPair(data.pair); setNotice('已连接到电脑端,任务状态会持续同步。') })
|
|
119
|
+
.catch((e) => setError(e && e.message ? e.message : String(e)))
|
|
120
|
+
.finally(() => setBusy(''))
|
|
121
|
+
}, [session, setCurrent])
|
|
122
|
+
|
|
123
|
+
const mobileLink = useMemo(() => {
|
|
124
|
+
if (!session || !session.gatewayToken || !session.gatewayAddress || !session.gatewayPort) return ''
|
|
125
|
+
return 'http://' + session.gatewayAddress + ':' + session.gatewayPort + '/__dsh_mobile/connect#' + session.gatewayToken
|
|
126
|
+
}, [session])
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
let active = true
|
|
129
|
+
if (!mobileLink) { setQrDataUrl(''); return () => { active = false } }
|
|
130
|
+
QRCode.toDataURL(mobileLink, {
|
|
131
|
+
errorCorrectionLevel: 'M', margin: 2, width: 260,
|
|
132
|
+
color: { dark: '#111827', light: '#ffffff' },
|
|
133
|
+
}).then((value) => { if (active) setQrDataUrl(value) })
|
|
134
|
+
.catch((e) => { if (active) setError('二维码生成失败:' + (e && e.message ? e.message : String(e))) })
|
|
135
|
+
return () => { active = false }
|
|
136
|
+
}, [mobileLink])
|
|
137
|
+
|
|
138
|
+
async function start() {
|
|
139
|
+
setError(''); setNotice('')
|
|
140
|
+
const numericPort = Number(port)
|
|
141
|
+
if (!selectedAddress) { setError('没有检测到可用的局域网 IPv4 地址,请先连接 Wi-Fi 或有线网络。'); return }
|
|
142
|
+
if (!Number.isInteger(numericPort) || numericPort < 1024 || numericPort > 65535) { setError('局域网端口需为 1024 到 65535 之间的整数。'); return }
|
|
143
|
+
setBusy('start')
|
|
144
|
+
try {
|
|
145
|
+
const data = await rpc('start', { deviceId: deviceId(), port: numericPort })
|
|
146
|
+
const next = {
|
|
147
|
+
pairId: data.pairId, secret: data.secret, code: data.code, role: 'desktop', deviceId: deviceId(),
|
|
148
|
+
gatewayToken: data.gatewayToken, gatewayPort: data.gateway.port, gatewayAddress: selectedAddress,
|
|
149
|
+
}
|
|
150
|
+
setCurrent(next); setPair(data.pair)
|
|
151
|
+
setNotice('受保护的局域网反向代理已开启。手机扫码即可进入同一 DSH。')
|
|
152
|
+
} catch (e) { setError(e && e.message ? e.message : String(e)) } finally { setBusy('') }
|
|
153
|
+
}
|
|
154
|
+
async function sendNote(event) {
|
|
155
|
+
event.preventDefault(); if (!session || !note.trim()) return
|
|
156
|
+
setBusy('note'); setError('')
|
|
157
|
+
try { const data = await rpc('note', Object.assign({}, session, { note })); setPair(data.pair); setNote(''); setNotice('接力备注已同步。') }
|
|
158
|
+
catch (e) { setError(e && e.message ? e.message : String(e)) } finally { setBusy('') }
|
|
159
|
+
}
|
|
160
|
+
async function end() {
|
|
161
|
+
if (!session) return
|
|
162
|
+
setBusy('end')
|
|
163
|
+
try { await rpc('end', session); setCurrent(null); setPair(null); setNotice('手机连接已关闭。') }
|
|
164
|
+
catch (e) { setError(e && e.message ? e.message : String(e)) } finally { setBusy('') }
|
|
165
|
+
}
|
|
166
|
+
function copyLink() { copy(mobileLink).then(() => setNotice('连接链接已复制。')).catch((e) => setError(e.message)) }
|
|
167
|
+
|
|
168
|
+
if (!session) return <section className={'dmr ' + (compact ? 'dmr-compact' : '')}>
|
|
169
|
+
<div className="dmr-hero"><span className="dmr-hero-icon"><RelayIcon name="phone" size={22}/></span><div><h3>把开发任务接到手机</h3><p>主 DSH 继续仅限本机;开启局域网反向代理后,通过一次性二维码安全接入。</p></div></div>
|
|
170
|
+
<ol className="dmr-steps"><li><span>1</span><div><strong>确认 IP 与端口</strong><small>自动识别电脑局域网 IPv4,默认使用独立端口 3081。</small></div></li><li><span>2</span><div><strong>开启反向代理</strong><small>代理 HTTP 与 WebSocket,未认证设备无法访问。</small></div></li><li><span>3</span><div><strong>手机扫码接力</strong><small>扫码进入原会话,电脑端同步看到开发进度。</small></div></li></ol>
|
|
171
|
+
<div className="dmr-network-grid">
|
|
172
|
+
<label className="dmr-field"><span>局域网 IP</span><select value={selectedAddress} onChange={(e) => setSelectedAddress(e.target.value)} disabled={!network || !network.addresses || !network.addresses.length}>{network && network.addresses && network.addresses.length ? network.addresses.map((item) => <option key={item.interface + item.address} value={item.address}>{item.address} · {item.interface}</option>) : <option value="">{network ? '未检测到地址' : '正在检测网络…'}</option>}</select><small>手机需与电脑位于可互访的同一局域网。</small></label>
|
|
173
|
+
<label className="dmr-field"><span>监听端口</span><input value={port} onChange={(e) => setPort(e.target.value)} inputMode="numeric" type="number" min="1024" max="65535" aria-describedby="dmr-port-help"/><small id="dmr-port-help">主服务仍为 127.0.0.1:3080;手机入口建议使用 3081。</small></label>
|
|
174
|
+
</div>
|
|
175
|
+
<div className="dmr-security"><strong>安全说明</strong><p>链接相当于临时登录凭据,只发给自己的手机。验证令牌 10 分钟后失效;关闭连接会立即撤销手机会话并停止端口监听。</p></div>
|
|
176
|
+
<button type="button" className="dmr-primary" onClick={start} disabled={busy === 'start' || !selectedAddress}>{busy === 'start' ? '正在开启反向代理…' : <><RelayIcon name="link"/>开启反向代理并生成二维码</>}</button>
|
|
177
|
+
{error && <div className="dmr-message error" role="alert">{error}</div>}{notice && <div className="dmr-message success" aria-live="polite"><RelayIcon name="check"/>{notice}</div>}
|
|
178
|
+
</section>
|
|
179
|
+
|
|
180
|
+
const onlineMobiles = pair && pair.devices ? pair.devices.filter((device) => device.role === 'mobile') : []
|
|
181
|
+
return <section className={'dmr ' + (compact ? 'dmr-compact' : '')}>
|
|
182
|
+
<div className="dmr-status-head"><div><span className="dmr-eyebrow"><i/> {mobile ? '已连接到电脑' : '手机连接已开启'}</span><h3>{mobile ? '可以在这里继续开发' : '等待手机接入'}</h3><p>{mobile ? '任务状态和接力备注会自动同步。继续开发请回到当前 DSH 会话输入区。' : '将下面的安全链接发到手机;连接在 ' + fmtTime(pair && pair.expiresAt) + ' 前有效。'}</p></div><button type="button" className="dmr-icon-button" onClick={end} disabled={busy === 'end'} title="关闭手机连接" aria-label="关闭手机连接"><RelayIcon name="close"/></button></div>
|
|
183
|
+
{!mobile && <div className="dmr-share"><div className="dmr-share-layout"><div className="dmr-qr-card">{qrDataUrl ? <img src={qrDataUrl} alt="手机接力一次性连接二维码" width="260" height="260"/> : <div className="dmr-qr-loading" aria-live="polite">正在生成二维码…</div>}<strong>使用手机相机扫码</strong><small>手机与电脑需在同一局域网</small></div><div className="dmr-share-detail"><div><span className="dmr-eyebrow"><i/> 反向代理已运行</span><h4>{session.gatewayAddress}:{session.gatewayPort} → 127.0.0.1:3080</h4></div><div className="dmr-link" title={mobileLink}>{mobileLink || '局域网入口未就绪'}</div><div className="dmr-share-actions"><button type="button" className="dmr-primary" onClick={copyLink} disabled={!mobileLink}><RelayIcon name="copy"/>复制连接链接</button></div><small>二维码和链接只可成功验证一次,10 分钟后失效。如需另一台手机,请关闭后重新开启。</small></div></div></div>}
|
|
184
|
+
<div className="dmr-overview"><div className="dmr-overview-card"><span>设备状态</span><strong>{mobile ? '手机已接力' : onlineMobiles.length ? onlineMobiles.length + ' 台手机在线' : '等待手机'}</strong><small>{mobile ? '保持此页面打开即可同步状态' : onlineMobiles.length ? '状态每 2.5 秒刷新' : '连接码仅在当前配对窗口有效'}</small></div><div className="dmr-overview-card"><span>进行中的任务</span><strong>{pair && pair.tasks ? pair.tasks.length : '—'}</strong><small>{pair && pair.tasks && pair.tasks.length ? '与 DSH 当前会话共用同一任务' : '开始任务后会自动显示在这里'}</small></div></div>
|
|
185
|
+
<div className="dmr-section"><div className="dmr-section-head"><h4>实时任务</h4><button type="button" className="dmr-text-button" onClick={() => refresh()} disabled={busy}>刷新</button></div>{pair && pair.tasks && pair.tasks.length ? <div className="dmr-task-list">{pair.tasks.map((task) => <article key={task.sessionId} className="dmr-task"><span className={'dmr-phase ' + task.phase}>{phaseLabel(task.phase)}</span><div><strong>{task.title}</strong><small>已运行 {fmtDuration(task.elapsed)} · 最近活动 {fmtTime(task.lastActivityAt)}</small></div></article>)}</div> : <div className="dmr-empty">当前没有正在运行的任务。启动任务后,手机和电脑都会看到同一份状态。</div>}</div>
|
|
186
|
+
<form className="dmr-note" onSubmit={sendNote}><div className="dmr-section-head"><h4>接力备注</h4>{pair && pair.noteAt ? <small>{pair.noteFrom} · {fmtTime(pair.noteAt)}</small> : null}</div>{pair && pair.note ? <blockquote>{pair.note}</blockquote> : <p>把当前进度、下一步或需要注意的事项留给另一台设备。</p>}<label><span className="sr-only">接力备注</span><textarea value={note} onChange={(e) => setNote(e.target.value)} maxLength="1200" placeholder={mobile ? '例如:我已在手机查看到构建错误,下一步请在电脑运行测试。' : '例如:已经完成接口修改,手机端继续检查页面交互。'} /></label><button className="dmr-secondary" disabled={!note.trim() || busy === 'note'}>{busy === 'note' ? '正在同步…' : <><RelayIcon name="arrow"/>同步备注</>}</button></form>
|
|
187
|
+
{error && <div className="dmr-message error" role="alert">{error}</div>}{notice && <div className="dmr-message success" aria-live="polite"><RelayIcon name="check"/>{notice}</div>}
|
|
188
|
+
</section>
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function MobileRelayHomeStat() {
|
|
192
|
+
const [summary, setSummary] = useState('可在手机继续当前任务')
|
|
193
|
+
useEffect(() => {
|
|
194
|
+
const session = readSession(); if (!session) return
|
|
195
|
+
rpc('status', session).then((data) => { const p = data.pair; setSummary((p.tasks || []).length ? (p.tasks.length + ' 个任务同步中') : '手机已连接,等待任务') }).catch(() => setSummary('连接已失效,请重新配对'))
|
|
196
|
+
}, [])
|
|
197
|
+
return <span>{summary}</span>
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export const feature = {
|
|
201
|
+
id: 'mobile-relay', name: '手机接力', order: 80, accent: '#38bdf8',
|
|
202
|
+
description: '安全反向代理与扫码接力,同步当前任务状态和备注', defaultEnabled: true,
|
|
203
|
+
css: `
|
|
204
|
+
.dmr{--dmr-accent:#38bdf8;--dmr-accent-soft:color-mix(in srgb,var(--dmr-accent) 13%,transparent);display:flex;flex-direction:column;gap:16px;max-width:720px;color:var(--dsw-alias-label-primary);font-size:13px;line-height:1.5}.dmr h3,.dmr h4,.dmr p{margin:0}.dmr h3{font-size:17px;line-height:1.25;letter-spacing:-.01em}.dmr h4{font-size:13px}.dmr-hero,.dmr-status-head{display:flex;align-items:flex-start;gap:12px}.dmr-hero>div,.dmr-status-head>div{min-width:0;display:flex;flex-direction:column;gap:4px}.dmr-hero p,.dmr-status-head p,.dmr-field small,.dmr-share small,.dmr-overview-card small,.dmr-task small,.dmr-note p{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.55}.dmr-hero-icon{width:42px;height:42px;display:inline-flex;align-items:center;justify-content:center;flex:none;border-radius:14px;background:var(--dmr-accent-soft);color:var(--dmr-accent);border:1px solid color-mix(in srgb,var(--dmr-accent) 35%,var(--dsw-alias-border-l1))}.dmr-steps{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin:0;padding:0;list-style:none}.dmr-steps li{display:flex;gap:9px;padding:11px;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-1);border-radius:12px}.dmr-steps li>span{width:21px;height:21px;display:inline-flex;align-items:center;justify-content:center;flex:none;border-radius:50%;font-size:11px;font-weight:700;color:var(--dmr-accent);background:var(--dmr-accent-soft)}.dmr-steps div{display:flex;flex-direction:column;gap:3px;min-width:0}.dmr-steps strong{font-size:12px}.dmr-steps small{font-size:11px;color:var(--dsw-alias-label-secondary);line-height:1.45}.dmr-field{display:flex;flex-direction:column;gap:6px;font-weight:600}.dmr-field input,.dmr-field select,.dmr-note textarea{box-sizing:border-box;width:100%;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);border-radius:10px;padding:10px 12px;min-height:44px;font:inherit;outline:none;transition:border-color .18s ease,box-shadow .18s ease}.dmr-field input:focus,.dmr-field select:focus,.dmr-note textarea:focus{border-color:var(--dmr-accent);box-shadow:0 0 0 3px var(--dmr-accent-soft)}.dmr-field small{font-weight:400}.dmr-network-grid{display:grid;grid-template-columns:minmax(0,1.4fr) minmax(150px,.6fr);gap:12px}.dmr-security{padding:12px 13px;border:1px solid color-mix(in srgb,#f59e0b 38%,var(--dsw-alias-border-l1));border-radius:12px;background:color-mix(in srgb,#f59e0b 8%,transparent)}.dmr-security strong{display:block;margin-bottom:4px;color:#f59e0b;font-size:12px}.dmr-security p{font-size:12px;color:var(--dsw-alias-label-secondary);line-height:1.55}.dmr-primary,.dmr-secondary,.dmr-text-button,.dmr-icon-button{font:inherit;touch-action:manipulation;cursor:pointer;transition:transform .15s ease,background .18s ease,border-color .18s ease,opacity .18s ease}.dmr-primary,.dmr-secondary{min-height:44px;display:inline-flex;align-items:center;justify-content:center;gap:8px;border-radius:10px;padding:9px 13px;font-weight:600}.dmr-primary{align-self:flex-start;border:1px solid var(--dmr-accent);background:var(--dmr-accent);color:#062238}.dmr-primary:hover{filter:brightness(1.05)}.dmr-secondary{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary)}.dmr-secondary:hover{border-color:var(--dmr-accent);background:var(--dmr-accent-soft)}.dmr-primary:active,.dmr-secondary:active,.dmr-icon-button:active{transform:scale(.98)}.dmr-primary:disabled,.dmr-secondary:disabled,.dmr-text-button:disabled,.dmr-icon-button:disabled{cursor:not-allowed;opacity:.5}.dmr-message{display:flex;align-items:flex-start;gap:7px;padding:10px 12px;border-radius:10px;font-size:12px}.dmr-message.success{color:var(--dsw-alias-state-success-primary);background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 10%,transparent)}.dmr-message.error{color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 10%,transparent)}.dmr-status-head{justify-content:space-between}.dmr-eyebrow{display:inline-flex;align-items:center;gap:6px;font-size:11px;font-weight:600;color:var(--dmr-accent)}.dmr-eyebrow i{width:7px;height:7px;border-radius:50%;background:currentColor;box-shadow:0 0 0 4px var(--dmr-accent-soft)}.dmr-icon-button{display:inline-flex;align-items:center;justify-content:center;flex:none;width:44px;height:44px;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:transparent;color:var(--dsw-alias-label-secondary)}.dmr-icon-button:hover{color:var(--dsw-alias-state-error-primary);border-color:currentColor}.dmr-share{display:flex;flex-direction:column;gap:9px;padding:13px;border:1px solid color-mix(in srgb,var(--dmr-accent) 32%,var(--dsw-alias-border-l1));border-radius:13px;background:var(--dmr-accent-soft)}.dmr-share-layout{display:grid;grid-template-columns:196px minmax(0,1fr);gap:16px;align-items:center}.dmr-qr-card{box-sizing:border-box;display:flex;flex-direction:column;align-items:center;gap:5px;padding:10px;border-radius:12px;background:#fff;color:#111827;text-align:center}.dmr-qr-card img{display:block;width:176px;height:176px;max-width:100%;object-fit:contain}.dmr-qr-card strong{font-size:12px}.dmr-qr-card small{color:#4b5563;font-size:10px}.dmr-qr-loading{display:grid;place-items:center;width:176px;height:176px;color:#64748b;font-size:12px}.dmr-share-detail{min-width:0;display:flex;flex-direction:column;gap:10px}.dmr-share-detail>div:first-child{display:flex;flex-direction:column;gap:4px}.dmr-link{padding:10px 11px;border-radius:8px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);font:12px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;word-break:break-all;color:var(--dsw-alias-label-secondary)}.dmr-share-actions{display:flex;gap:8px;flex-wrap:wrap}.dmr-share-actions .dmr-primary{align-self:auto}.dmr-overview{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.dmr-overview-card{display:flex;flex-direction:column;gap:3px;padding:12px;border:1px solid var(--dsw-alias-border-l1);border-radius:12px;background:var(--dsw-alias-bg-layer-1)}.dmr-overview-card>span{font-size:11px;color:var(--dsw-alias-label-tertiary)}.dmr-overview-card strong{font-size:16px;letter-spacing:-.01em}.dmr-section{display:flex;flex-direction:column;gap:9px}.dmr-section-head{display:flex;align-items:center;justify-content:space-between;gap:10px}.dmr-section-head small{font-size:11px;color:var(--dsw-alias-label-tertiary)}.dmr-text-button{border:0;background:transparent;color:var(--dmr-accent);padding:8px;min-height:36px;font-weight:600}.dmr-task-list{display:flex;flex-direction:column;gap:8px}.dmr-task{display:flex;align-items:flex-start;gap:9px;padding:10px 11px;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;background:var(--dsw-alias-bg-layer-1)}.dmr-task>div{display:flex;min-width:0;flex:1;flex-direction:column;gap:2px}.dmr-task strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dmr-phase{flex:none;border-radius:999px;padding:3px 8px;font-size:11px;background:var(--dmr-accent-soft);color:var(--dmr-accent)}.dmr-phase.write{color:var(--dsw-alias-state-success-primary)}.dmr-phase.code{color:#f59e0b}.dmr-phase.search{color:#14b8a6}.dmr-empty{padding:18px 12px;text-align:center;border:1px dashed var(--dsw-alias-border-l2);border-radius:10px;color:var(--dsw-alias-label-secondary);font-size:12px}.dmr-note{display:flex;flex-direction:column;gap:9px;padding-top:2px}.dmr-note blockquote{margin:0;padding:10px 12px;border-left:3px solid var(--dmr-accent);border-radius:0 9px 9px 0;background:var(--dmr-accent-soft);white-space:pre-wrap;font-size:12px}.dmr-note textarea{min-height:88px;resize:vertical;line-height:1.5}.dmr-note .dmr-secondary{align-self:flex-start}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}@media (max-width:680px){.dmr{gap:14px;font-size:14px}.dmr h3{font-size:18px}.dmr-steps{grid-template-columns:1fr;gap:8px}.dmr-steps li{padding:10px}.dmr-steps small,.dmr-hero p,.dmr-status-head p,.dmr-field small,.dmr-share small,.dmr-overview-card small,.dmr-task small,.dmr-note p{font-size:12px}.dmr-network-grid{grid-template-columns:1fr;gap:10px}.dmr-field input,.dmr-field select{font-size:16px}.dmr-primary,.dmr-secondary{width:100%;font-size:14px}.dmr-share-layout{grid-template-columns:1fr;gap:12px}.dmr-qr-card{width:min(240px,100%);margin:0 auto}.dmr-qr-card img,.dmr-qr-loading{width:210px;height:210px}.dmr-share-actions{flex-direction:column}.dmr-share-actions .dmr-primary{width:100%}.dmr-overview{gap:8px}.dmr-overview-card{padding:11px}.dmr-status-head{gap:8px}.dmr-link{font-size:11px}.dmr-note textarea{font-size:16px;min-height:104px}.dmr-note .dmr-secondary{align-self:stretch}.dmr-compact .dmr-hero-icon{width:40px;height:40px;border-radius:13px}}@media (prefers-reduced-motion:reduce){.dmr-primary,.dmr-secondary,.dmr-text-button,.dmr-icon-button,.dmr-field input,.dmr-field select,.dmr-note textarea{transition:none}}
|
|
205
|
+
`,
|
|
206
|
+
View: MobileRelayView, HomeStat: MobileRelayHomeStat,
|
|
207
|
+
}
|
|
@@ -12,8 +12,10 @@ const modelsStore = {
|
|
|
12
12
|
set(patch) { this.snap = Object.assign({}, this.snap, patch); for (const fn of this.listeners) fn(); },
|
|
13
13
|
load() {
|
|
14
14
|
if (this.snap.loading) return;
|
|
15
|
-
this.set({ loading: true });
|
|
16
|
-
|
|
15
|
+
this.set({ loading: true, error: null });
|
|
16
|
+
// Provider 可在 DSH 原生模型设置中随时增删;不要复用浏览器缓存,
|
|
17
|
+
// 否则返回本页时仍可能看到旧的共享快照。
|
|
18
|
+
fetch("/dsh-dock/models", { cache: "no-store", signal: AbortSignal.timeout(20000) })
|
|
17
19
|
.then((res) => (res.ok ? res.json() : Promise.reject(new Error("模型目录接口 HTTP " + res.status))))
|
|
18
20
|
.then((data) => this.set({ data: data, loading: false, error: null }))
|
|
19
21
|
.catch((e) => this.set({ loading: false, error: (e && e.message) || String(e) }));
|
|
@@ -81,6 +83,15 @@ function ModelsView() {
|
|
|
81
83
|
const [vpDirty, setVpDirty] = react.useState(false);
|
|
82
84
|
const [vpSaving, setVpSaving] = react.useState(false);
|
|
83
85
|
const [vpMsg, setVpMsg] = react.useState(null);
|
|
86
|
+
const [vpDirectExpanded, setVpDirectExpanded] = react.useState(false);
|
|
87
|
+
|
|
88
|
+
// 每次进入模型设置都重新读取官方目录。modelsStore 是跨页面共享快照,
|
|
89
|
+
// 原生设置刚新增 Provider 时不能要求用户再滚到页面底部手动刷新。
|
|
90
|
+
react.useEffect(() => { modelsStore.load(); }, []);
|
|
91
|
+
const refreshModels = () => {
|
|
92
|
+
setBuiltKey(null);
|
|
93
|
+
modelsStore.load();
|
|
94
|
+
};
|
|
84
95
|
|
|
85
96
|
const cur = providers.length > 0 ? (providers.find((p) => p.id === selId) || providers[0]) : null;
|
|
86
97
|
// 选中 Provider 或目录刷新时重建草稿(render 期受控重置;msg 只在切 Provider/编辑时清)
|
|
@@ -199,7 +210,7 @@ function ModelsView() {
|
|
|
199
210
|
DKM_MODALITIES.map(([v, label]) => react.createElement("label", {
|
|
200
211
|
key: v,
|
|
201
212
|
className: "dkm-check",
|
|
202
|
-
title: v === "image" ? "
|
|
213
|
+
title: v === "image" ? "由你声明该端点支持图片:保存后原图直发,并可作为视觉模型候选。系统不会替你猜测;端点实际不支持时取消勾选即可恢复走图片理解代理" : null
|
|
203
214
|
},
|
|
204
215
|
react.createElement("input", {
|
|
205
216
|
type: "checkbox",
|
|
@@ -251,6 +262,11 @@ function ModelsView() {
|
|
|
251
262
|
react.createElement("button", { type: "button", className: "dkm-mini", title: "一键取消全部强度档", onClick: () => patchModel(i, allLevels(false)) }, "取消全选"))));
|
|
252
263
|
|
|
253
264
|
const body = [];
|
|
265
|
+
if (data) {
|
|
266
|
+
body.push(react.createElement("div", { key: "refresh", className: "dkm-topbar" },
|
|
267
|
+
react.createElement("span", { className: "dkm-sub" }, "已载入 " + providers.length + " 个 Provider;在 DSH 原生模型设置中新增后,返回本页会自动更新"),
|
|
268
|
+
react.createElement("button", { type: "button", className: "dkb-refresh", disabled: snap.loading || saving, onClick: refreshModels }, snap.loading ? "刷新中…" : "刷新模型目录")));
|
|
269
|
+
}
|
|
254
270
|
if (snap.error) {
|
|
255
271
|
body.push(react.createElement("div", { key: "err", className: "dkm-error" },
|
|
256
272
|
"模型目录拉取失败:" + snap.error + " ",
|
|
@@ -266,14 +282,26 @@ function ModelsView() {
|
|
|
266
282
|
"图片理解代理需要新版宿主进程:当前 dsh web 较旧,重启后此面板可用。"));
|
|
267
283
|
}
|
|
268
284
|
if (data && data.visionProxy !== undefined && vpDraft) {
|
|
269
|
-
//
|
|
270
|
-
|
|
285
|
+
// 用户在模型设置里勾选「图片」即可决定原图直发和视觉候选;
|
|
286
|
+
// 运行时目录仅作自动提示,不能排除用户明确声明的模型。
|
|
287
|
+
// 无论哪种来源都不等同于联网实测,凭据、额度和上游状态仍以真实调用为准。
|
|
288
|
+
const runtimeImage = (m) => Array.isArray(m.runtimeInput) && m.runtimeInput.indexOf("image") >= 0;
|
|
289
|
+
const declaredImage = (m) => Array.isArray(m.input) && m.input.indexOf("image") >= 0;
|
|
271
290
|
const vpDirect = [];
|
|
272
291
|
const vpProxied = [];
|
|
273
292
|
for (const p of providers) {
|
|
274
293
|
for (const m of (p.models || [])) {
|
|
275
|
-
const
|
|
276
|
-
|
|
294
|
+
const declared = declaredImage(m);
|
|
295
|
+
const runtime = runtimeImage(m);
|
|
296
|
+
const source = declared ? "你已标记图片" : (runtime ? "运行时识别" : "未标记图片");
|
|
297
|
+
const item = {
|
|
298
|
+
key: p.id + "/" + m.id,
|
|
299
|
+
label: (p.displayName || p.id) + " / " + (m.name || m.id) + "(" + source + ")",
|
|
300
|
+
model: m.name || m.id,
|
|
301
|
+
source: source,
|
|
302
|
+
declared: declared,
|
|
303
|
+
};
|
|
304
|
+
((declared || runtime) ? vpDirect : vpProxied).push(item);
|
|
277
305
|
}
|
|
278
306
|
}
|
|
279
307
|
const vpCandidates = vpDirect.slice();
|
|
@@ -309,18 +337,28 @@ function ModelsView() {
|
|
|
309
337
|
!vpKnown && vpDraft.provider ? react.createElement("option", { value: vpKey }, vpDraft.provider + " / " + vpDraft.model + "(当前)") : null,
|
|
310
338
|
vpCandidates.map((c) => react.createElement("option", { key: c.key, value: c.key }, c.label))),
|
|
311
339
|
vpCandidates.length === 0
|
|
312
|
-
? react.createElement("span", { className: "dkm-sub" }, "
|
|
340
|
+
? react.createElement("span", { className: "dkm-sub" }, "还没有标记图片能力的模型——在下方给你确认支持图片的模型勾选「图片」并保存")
|
|
313
341
|
: null),
|
|
314
342
|
react.createElement("div", { className: "dkm-sub" },
|
|
315
|
-
"
|
|
343
|
+
"以你在下方勾选的「图片」为准;运行时识别只是辅助提示。它不代表已联网实测可调用,实际可用性仍取决于 Provider 凭据、额度与上游服务。若端点实际不认图,取消该模型的「图片」勾选即可恢复走图片理解代理。"),
|
|
316
344
|
vpDirect.length > 0
|
|
317
|
-
? react.createElement("div", { className: "dkm-
|
|
318
|
-
react.createElement("
|
|
319
|
-
|
|
345
|
+
? react.createElement("div", { className: "dkm-capabilities" },
|
|
346
|
+
react.createElement("div", { className: "dkm-checks" },
|
|
347
|
+
react.createElement("span", { className: "dkm-label" }, "原图直发候选(你的标记优先," + vpDirect.length + "):"),
|
|
348
|
+
react.createElement("button", {
|
|
349
|
+
type: "button",
|
|
350
|
+
className: "dkm-mini",
|
|
351
|
+
"aria-expanded": vpDirectExpanded,
|
|
352
|
+
onClick: () => setVpDirectExpanded(!vpDirectExpanded)
|
|
353
|
+
}, vpDirectExpanded ? "收起列表" : "展开列表")),
|
|
354
|
+
vpDirectExpanded
|
|
355
|
+
? react.createElement("div", { className: "dkm-checks dkm-capability-list" },
|
|
356
|
+
vpDirect.map((c) => react.createElement("span", { key: c.key, className: "dkm-chip", title: c.key + " · " + c.source }, c.model)))
|
|
357
|
+
: null)
|
|
320
358
|
: null,
|
|
321
359
|
vpProxied.length > 0
|
|
322
360
|
? react.createElement("div", { className: "dkm-checks" },
|
|
323
|
-
|
|
361
|
+
react.createElement("span", { className: "dkm-label" }, "走视觉代理(未标记图片," + vpProxied.length + "):"),
|
|
324
362
|
react.createElement("span", { className: "dkm-sub" }, vpProxied.map((c) => c.model).join("、")))
|
|
325
363
|
: null,
|
|
326
364
|
react.createElement("div", { className: "dkm-savebar" },
|
|
@@ -379,7 +417,7 @@ function ModelsView() {
|
|
|
379
417
|
react.createElement("div", { className: "dkm-savebar" },
|
|
380
418
|
react.createElement("span", { className: "dkm-msg" }, dirty ? "有未保存的修改" : "无未保存修改"),
|
|
381
419
|
react.createElement("button", { type: "button", className: "dkm-save", disabled: !dirty || saving, onClick: save }, saving ? "保存中…" : "保存写回官方配置"),
|
|
382
|
-
react.createElement("button", { type: "button", className: "dkb-refresh", disabled: saving, onClick:
|
|
420
|
+
react.createElement("button", { type: "button", className: "dkb-refresh", disabled: saving || snap.loading, onClick: refreshModels }, snap.loading ? "刷新中…" : "重新拉取"),
|
|
383
421
|
msg ? react.createElement("span", { className: "dkm-msg " + (msg.ok ? "ok" : "err") }, msg.text) : null)));
|
|
384
422
|
}
|
|
385
423
|
return react.createElement("div", { className: "dkm-list" }, body);
|
|
@@ -438,6 +476,9 @@ export const feature = {
|
|
|
438
476
|
".dkm-msg.ok{color:var(--dsw-alias-state-success-primary);}",
|
|
439
477
|
".dkm-msg.err{color:var(--dsw-alias-state-error-primary);}",
|
|
440
478
|
".dkm-list{display:flex;flex-direction:column;gap:8px;}",
|
|
479
|
+
".dkm-topbar{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap;}",
|
|
480
|
+
".dkm-capabilities{display:flex;flex-direction:column;gap:6px;}",
|
|
481
|
+
".dkm-capability-list{max-height:168px;overflow:auto;padding:2px 0 2px 2px;}",
|
|
441
482
|
".dkm-ro{color:var(--dsw-alias-label-secondary);font-size:12px;}",
|
|
442
483
|
".dkm-mini{cursor:pointer;border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-tertiary);border-radius:999px;padding:1px 8px;font-family:inherit;font-size:11px;flex:none;}",
|
|
443
484
|
".dkm-mini:hover{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-secondary);}",
|
package/features/theme/view.js
CHANGED
|
@@ -1,27 +1,52 @@
|
|
|
1
1
|
// dsh-dock · 功能模块【主题信息】· 客户端视图(纯 Client 功能,无宿主半部)
|
|
2
2
|
import react from "react";
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
3
4
|
|
|
4
|
-
|
|
5
|
+
// dsh 客户端主题服务(@deepseek-ai/dsh-client-ui-theme 的 ctx.provide("theme", ...))快照结构:
|
|
6
|
+
// { preference: 'light'|'dark'|'system', active: { id, colorScheme, tokens }, themes, revision }
|
|
7
|
+
// preference 是用户偏好;active.id 是解析后的实际生效主题(preference 为 system 时落到 light/dark)。
|
|
8
|
+
// 注意:快照顶层没有 id/name 字段——直接读 snap.id 只会得到 undefined(曾导致本页显示"未知")。
|
|
9
|
+
const PREFERENCE_LABELS = { light: "亮色", dark: "暗色", system: "跟随系统" };
|
|
10
|
+
const THEME_LABELS = { light: "亮色", dark: "暗色" };
|
|
11
|
+
|
|
12
|
+
function readThemeInfo(ctx) {
|
|
5
13
|
const theme = ctx && ctx.get ? ctx.get("theme") : undefined;
|
|
6
|
-
if (theme
|
|
14
|
+
if (!theme || typeof theme.getTheme !== "function") return { err: "theme 服务不可用" };
|
|
7
15
|
let snap = null;
|
|
8
|
-
try { snap = theme.getTheme(); }
|
|
9
|
-
|
|
10
|
-
const
|
|
11
|
-
|
|
16
|
+
try { snap = theme.getTheme(); } catch (err) { return { err: "读取失败:" + String((err && err.message) || err) }; }
|
|
17
|
+
const preference = snap && typeof snap.preference === "string" ? snap.preference : "";
|
|
18
|
+
const activeId = snap && snap.active && typeof snap.active.id === "string" ? snap.active.id : "";
|
|
19
|
+
// 偏好 → 中文;system 且实际主题与偏好不同名时补注实际生效值(跟随系统 = 亮色/暗色)
|
|
20
|
+
const label = (PREFERENCE_LABELS[preference] || preference || "未知")
|
|
21
|
+
+ (activeId && activeId !== preference && THEME_LABELS[activeId] ? "(实际" + THEME_LABELS[activeId] + ")" : "");
|
|
12
22
|
return { label };
|
|
13
23
|
}
|
|
14
24
|
|
|
25
|
+
function useThemeInfo(ctx) {
|
|
26
|
+
const [info, setInfo] = useState(() => readThemeInfo(ctx));
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
// 设置页「外观」行切换主题时实时刷新(快照 revision 递增,theme/change 事件广播)
|
|
29
|
+
let off = null;
|
|
30
|
+
try {
|
|
31
|
+
if (ctx && typeof ctx.on === "function") {
|
|
32
|
+
off = ctx.on("theme/change", () => setInfo(readThemeInfo(ctx)));
|
|
33
|
+
}
|
|
34
|
+
} catch { /* 无订阅能力时降级为静态显示 */ }
|
|
35
|
+
return () => { try { if (typeof off === "function") off(); } catch { /* 清理失败静默 */ } };
|
|
36
|
+
}, [ctx]);
|
|
37
|
+
return info;
|
|
38
|
+
}
|
|
39
|
+
|
|
15
40
|
function ThemeView(props) {
|
|
16
|
-
const r =
|
|
41
|
+
const r = useThemeInfo(props && props.ctx);
|
|
17
42
|
if (r.err) return react.createElement("div", null, r.err);
|
|
18
43
|
return react.createElement("div", null, "当前主题:" + r.label);
|
|
19
44
|
}
|
|
20
45
|
|
|
21
46
|
function ThemeStat(props) {
|
|
22
|
-
const r =
|
|
47
|
+
const r = useThemeInfo(props && props.ctx);
|
|
23
48
|
if (r.err) return react.createElement("span", null, r.err);
|
|
24
|
-
return react.createElement("span", null, "
|
|
49
|
+
return react.createElement("span", null, "主题:" + r.label);
|
|
25
50
|
}
|
|
26
51
|
|
|
27
52
|
export const feature = {
|
|
@@ -29,7 +54,7 @@ export const feature = {
|
|
|
29
54
|
name: "主题信息",
|
|
30
55
|
order: 150,
|
|
31
56
|
accent: "#a78bfa",
|
|
32
|
-
description: "
|
|
57
|
+
description: "读取当前主题快照:偏好与实际生效主题(纯 Client,主题切换实时刷新)",
|
|
33
58
|
View: ThemeView,
|
|
34
59
|
HomeStat: ThemeStat,
|
|
35
60
|
};
|
|
@@ -540,9 +540,11 @@ export function TokenLogChip(props) {
|
|
|
540
540
|
}, [sid]);
|
|
541
541
|
if (!sid) return null;
|
|
542
542
|
const t = snap.totals;
|
|
543
|
+
// chip 文案用紧凑金额(2 位小数):fmtCost 对小金额输出 6 位小数,chip 里太长会撑爆输入卡工具行;
|
|
544
|
+
// 完整金额在 title 悬浮提示里。
|
|
543
545
|
const label = snap.err && !t
|
|
544
546
|
? (snap.err.includes("重启") ? "用量·需重启宿主" : "用量·失败")
|
|
545
|
-
: (t ? "⛁ " + fmtCompact(t.totalTokens) + (t.cost > 0 ? " · " +
|
|
547
|
+
: (t ? "⛁ " + fmtCompact(t.totalTokens) + (t.cost > 0 ? " · $" + t.cost.toFixed(2) : "") : "⛁ …");
|
|
546
548
|
const title = t
|
|
547
549
|
? "本会话 " + fmtNum(t.calls) + " 次调用 · " + fmtNum(t.totalTokens) + " Token · " + fmtCost(t.cost) + "(估算)\n点击在功能坞查看用量记录"
|
|
548
550
|
: (snap.err ? snap.err + "\n" : "") + "点击在功能坞查看用量记录";
|
|
@@ -20,6 +20,17 @@ function contentHasImageDeep(content) {
|
|
|
20
20
|
return false
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* 用户在 dsh-dock 模型设置里勾选「图片」后的声明。
|
|
25
|
+
* 这不是联网探测结果,而是明确让用户决定端点能力:声明后原图直发,
|
|
26
|
+
* 未声明时才继续参考运行时目录或走图片理解代理。
|
|
27
|
+
*/
|
|
28
|
+
function hasUserDeclaredImage(directory, provider, model) {
|
|
29
|
+
const p = (directory.providers || []).find((x) => x && x.id === provider)
|
|
30
|
+
const m = p && Array.isArray(p.models) ? p.models.find((x) => x && x.id === model) : undefined
|
|
31
|
+
return !!(m && Array.isArray(m.input) && m.input.includes('image'))
|
|
32
|
+
}
|
|
33
|
+
|
|
23
34
|
/** 让视觉模型描述一张图片,返回识别文本(失败抛错)。 */
|
|
24
35
|
async function describeImage(deps, cfg, block, signal) {
|
|
25
36
|
const prompt = '请用中文详细描述这张图片的内容:主体、文字(若有则逐字转写)、数据/图表要点、与编码任务相关的细节。控制在 300 字以内,直接输出描述,不要客套。'
|
|
@@ -127,10 +138,19 @@ async function transformForVisionProxy(deps, options) {
|
|
|
127
138
|
if (!options.messages.some((m) => m && contentHasImageDeep(m.content))) return options
|
|
128
139
|
log(`带图请求 ${options.provider}/${options.model}`)
|
|
129
140
|
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
//
|
|
141
|
+
// 图片能力判定的优先级:用户在模型设置里勾选「图片」优先。
|
|
142
|
+
// 运行时目录只是自动提示,无法识别或识别错误时不能覆盖用户的明确决定。
|
|
143
|
+
// 这让 kimi-k2.7-code 等端点可由用户声明多模态并原图直发;若端点实际不支持,
|
|
144
|
+
// 上游会返回错误,用户取消勾选即可恢复走图片理解代理。
|
|
145
|
+
const directory = deps.directory()
|
|
146
|
+
if (hasUserDeclaredImage(directory, options.provider, options.model)) {
|
|
147
|
+
log(`跳过 ${options.provider}/${options.model}:用户已声明图片能力(原图直发)`)
|
|
148
|
+
return options
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// 未声明时再问运行时(llm.resolveModelInfo,与官方投影同源:
|
|
152
|
+
// entry.input → 内置目录 → 路由默认);旧宿主无此方法或单次解析失败时,
|
|
153
|
+
// 保守走图片理解代理,避免猜错端点能力。
|
|
134
154
|
let imageCapable
|
|
135
155
|
try {
|
|
136
156
|
const info = deps.resolveModelInfo ? await deps.resolveModelInfo(options.provider, options.model) : undefined
|
|
@@ -138,12 +158,6 @@ async function transformForVisionProxy(deps, options) {
|
|
|
138
158
|
} catch {
|
|
139
159
|
// 落到目录回退
|
|
140
160
|
}
|
|
141
|
-
if (imageCapable === undefined) {
|
|
142
|
-
const dir = deps.directory()
|
|
143
|
-
const p = (dir.providers || []).find((x) => x.id === options.provider)
|
|
144
|
-
const m = p && Array.isArray(p.models) ? p.models.find((x) => x.id === options.model) : undefined
|
|
145
|
-
imageCapable = !!(m && Array.isArray(m.input) && m.input.includes('image'))
|
|
146
|
-
}
|
|
147
161
|
if (imageCapable) {
|
|
148
162
|
log(`跳过 ${options.provider}/${options.model}:运行时判定多模态(原图直发)`)
|
|
149
163
|
return options
|
package/index.js
CHANGED
|
@@ -19,6 +19,7 @@ import { feature as fVisionProxy } from './features/visionproxy/host.js'
|
|
|
19
19
|
import { feature as fBalance } from './features/balance/host.js'
|
|
20
20
|
import { feature as fTokenlog } from './features/tokenlog/host.js'
|
|
21
21
|
import { feature as fAnimation } from './features/animation/host.js'
|
|
22
|
+
import { feature as fMobileRelay } from './features/mobile-relay/host.js'
|
|
22
23
|
|
|
23
24
|
export const name = 'dsh-dock'
|
|
24
25
|
|
|
@@ -38,6 +39,7 @@ export function apply(ctx) {
|
|
|
38
39
|
fBalance,
|
|
39
40
|
fTokenlog,
|
|
40
41
|
fAnimation,
|
|
42
|
+
fMobileRelay,
|
|
41
43
|
]
|
|
42
44
|
|
|
43
45
|
const state = new Map()
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-dock",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "DeepSeek Harness 功能坞插件(dsh-dock):模块化功能中枢——每个功能一个 features/ 模块(可单独提取成包发布,又可经 dockBridge 装回面板)。含用量记录(LLM 调用记账/费用统计)、模型设置、图片理解代理、模型余额、任务动画(8 种氛围动效 + 桌面伙伴 3D
|
|
3
|
+
"version": "0.7.0",
|
|
4
|
+
"description": "DeepSeek Harness 功能坞插件(dsh-dock):模块化功能中枢——每个功能一个 features/ 模块(可单独提取成包发布,又可经 dockBridge 装回面板)。含用量记录(LLM 调用记账/费用统计)、模型设置、图片理解代理、模型余额、任务动画(8 种氛围动效 + 桌面伙伴 3D 场景、完成通知与提示音、钉钉/飞书推送,开关独立、配置持久化);侧栏入口 + 可拖拽弹窗(最大化/最小化/缩放)。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"files": [
|
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
"@deepseek-ai/dsh-credentials": "^0.1.0-rc.6",
|
|
53
53
|
"@deepseek-ai/dsh-llm": "^0.1.1-rc.2",
|
|
54
54
|
"@deepseek-ai/dsh-settings": "^0.1.1-rc.2",
|
|
55
|
-
"@deepseek-ai/schemastery": "^3.18.1"
|
|
55
|
+
"@deepseek-ai/schemastery": "^3.18.1",
|
|
56
|
+
"qrcode": "^1.5.4"
|
|
56
57
|
}
|
|
57
58
|
}
|
|
Binary file
|
|
Binary file
|