dsh-dock 0.14.0 → 0.16.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 +49 -19
- package/client.js +2461 -1008
- package/features/animation/host.js +2 -1
- package/features/balance/host.js +2 -1
- package/features/mobile/host.js +93 -98
- package/features/mobile/view.js +136 -133
- package/features/mobile-relay/host.js +2 -1
- package/features/modelconfig/host.js +21 -462
- package/features/modelconfig/view.js +19 -704
- package/features/notify/host.js +2 -1
- package/features/notify/view.jsx +153 -30
- package/features/providerconfig/host.js +235 -0
- package/features/providerconfig/view.js +604 -0
- package/features/runstate/host.js +12 -1
- package/features/tokenlog/host.js +2 -1
- package/features/tokenlog/view.jsx +42 -12
- package/features/tts/host.js +164 -0
- package/features/tts/view.jsx +1189 -0
- package/features/visionproxy/host.js +1 -1
- package/index.js +9 -1
- package/package.json +3 -3
- package/screenshots/dock-modelconfig.png +0 -0
- package/src/client.jsx +54 -24
- package/src/host-core.js +84 -0
- package/src/model-directory.js +275 -0
- package/src/model-draft.js +119 -0
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
// 分时段价支持多段(start>end 跨零点、start=end 全天、小数小时表示半点);旧单段 peak 自动归一。
|
|
29
29
|
// 每段可带 days=all|workday|nonworkday:workday=非周末且非 holidays;nonworkday=周末或节假日。
|
|
30
30
|
// 费用在查询/导出时按当前单价即时重算,因此改价无需重扫历史。
|
|
31
|
-
import { sendJson, readBody, readDockRoot, mutateDockSection } from '../../src/host-core.js'
|
|
31
|
+
import { sendJson, readBody, rejectUntrustedRequest, readDockRoot, mutateDockSection } from '../../src/host-core.js'
|
|
32
32
|
|
|
33
33
|
export const feature = {
|
|
34
34
|
id: 'tokenlog',
|
|
@@ -866,6 +866,7 @@ export const feature = {
|
|
|
866
866
|
kind: 'prefix',
|
|
867
867
|
path: '/dsh-dock/tokenlog',
|
|
868
868
|
async handler(req, res) {
|
|
869
|
+
if (rejectUntrustedRequest(ctx, req, res)) return
|
|
869
870
|
try {
|
|
870
871
|
const url = new URL(req.url || '/', 'http://dsh.internal')
|
|
871
872
|
const method = url.pathname.replace(/^\/dsh-dock\/tokenlog\/?/, '').split('/')[0] || ''
|
|
@@ -13,7 +13,21 @@ import react, { useState, useEffect, useCallback, useMemo, useRef } from "react"
|
|
|
13
13
|
import { openPanel } from "../../src/shared.js";
|
|
14
14
|
|
|
15
15
|
// ---------- Host RPC 桥接 ----------
|
|
16
|
+
// 路由不可用(宿主没注册用量路由)的两种成因必须区分开,否则提示会误导人:
|
|
17
|
+
// - 404/405:宿主进程是旧版本,**或**本功能当前在宿主侧是停用的(多设备开关不一致、
|
|
18
|
+
// localStorage 与宿主 settings 不同步都会这样)。此时路由就是不存在,**重试无用**。
|
|
19
|
+
// - 其余状态码:网络/服务端错误,重试有意义。
|
|
20
|
+
// 因此 404/405 会置 routeGone 闩锁:所有调用方据此**停止轮询**,既不再刷控制台报错,
|
|
21
|
+
// 也不会每 10s 打一次无效请求(chip 原本会一直重试到页面关闭)。
|
|
22
|
+
let routeGone = false;
|
|
23
|
+
/** 路由是否已确认不可用(宿主未注册)。一旦为真,本次页面生命周期内不再重试。 */
|
|
24
|
+
export function isTokenlogRouteGone() { return routeGone; }
|
|
25
|
+
/** 把 404/405 的错误翻译成人能看懂、且不误导的提示。 */
|
|
26
|
+
function routeGoneMessage() {
|
|
27
|
+
return "宿主未启用「用量记录」路由(或宿主进程是旧版本),请在功能坞里启用该功能或重启 dsh web";
|
|
28
|
+
}
|
|
16
29
|
function rpcCall(method, args) {
|
|
30
|
+
if (routeGone) return Promise.reject(new Error(routeGoneMessage()));
|
|
17
31
|
return fetch("dsh-dock/tokenlog/" + method, {
|
|
18
32
|
method: "POST",
|
|
19
33
|
headers: { "content-type": "application/json" },
|
|
@@ -22,9 +36,13 @@ function rpcCall(method, args) {
|
|
|
22
36
|
.then(async (res) => {
|
|
23
37
|
const data = await res.json().catch(() => ({}));
|
|
24
38
|
if (res.ok && data && data.ok === true) return data.data;
|
|
25
|
-
//
|
|
39
|
+
// 宿主没注册用量路由(405/404):置闩锁并给出可操作提示而非裸状态码。
|
|
40
|
+
// 注意这既可能是「宿主旧版本」,也可能是「功能在宿主侧被停用」——
|
|
41
|
+
// 实测后者更常见(多设备开关/localStorage 与宿主 settings 不一致),
|
|
42
|
+
// 所以文案两种都提到,且置闩锁后不再无谓重试。
|
|
26
43
|
if (res.status === 405 || res.status === 404) {
|
|
27
|
-
|
|
44
|
+
routeGone = true;
|
|
45
|
+
throw new Error(routeGoneMessage());
|
|
28
46
|
}
|
|
29
47
|
if (data && data.ok === false) throw new Error((data.error && data.error.message) || ("HTTP " + res.status));
|
|
30
48
|
throw new Error("HTTP " + res.status + (data && data.error && data.error.message ? ": " + data.error.message : ""));
|
|
@@ -1377,27 +1395,39 @@ export function TokenLogChip(props) {
|
|
|
1377
1395
|
useEffect(() => {
|
|
1378
1396
|
if (!sid) return;
|
|
1379
1397
|
let cancel = false;
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1398
|
+
let timer = null;
|
|
1399
|
+
const load = () => {
|
|
1400
|
+
// 路由不可用是**终态**(宿主没注册该路由):直接早退,不再每 10s 打一次
|
|
1401
|
+
// 无效请求、不再刷控制台报错。此前会一直重试到页面关闭(实测控制台连续 405)。
|
|
1402
|
+
if (cancel || isTokenlogRouteGone()) return null;
|
|
1403
|
+
return rpcCall("query", { sessionId: sid })
|
|
1404
|
+
.then((d) => { if (!cancel) setSnap({ totals: d && d.totals, rate: (d && d.rateUsdCny) || 7.2, err: "" }); })
|
|
1405
|
+
.catch((e) => { if (!cancel) setSnap((s) => ({ totals: s.totals, rate: s.rate, err: String((e && e.message) || e) })); });
|
|
1406
|
+
};
|
|
1383
1407
|
load();
|
|
1384
|
-
|
|
1385
|
-
return () => { cancel = true; clearInterval(timer); };
|
|
1408
|
+
timer = setInterval(load, 10000);
|
|
1409
|
+
return () => { cancel = true; if (timer) clearInterval(timer); };
|
|
1386
1410
|
}, [sid]);
|
|
1387
1411
|
if (!sid) return null;
|
|
1388
1412
|
const t = snap.totals;
|
|
1413
|
+
const gone = isTokenlogRouteGone();
|
|
1389
1414
|
// chip 文案用紧凑人民币金额(2 位小数),避免撑爆输入卡工具行;
|
|
1390
1415
|
// 完整金额在 title 悬浮提示里。
|
|
1391
|
-
const label =
|
|
1392
|
-
?
|
|
1416
|
+
const label = gone
|
|
1417
|
+
? "用量·未启用"
|
|
1418
|
+
: snap.err && !t
|
|
1419
|
+
? (snap.err.includes("重启") || snap.err.includes("启用") ? "用量·未启用" : "用量·失败")
|
|
1393
1420
|
: (t ? "⛁ " + fmtCompact(t.totalTokens) + (t.cost > 0 ? " · " + fmtCostCny(t.cost, snap.rate) : "") : "⛁ …");
|
|
1394
|
-
const title =
|
|
1421
|
+
const title = gone
|
|
1422
|
+
? routeGoneMessage() + "\n(可在功能坞里启用「用量记录」)"
|
|
1423
|
+
: t
|
|
1395
1424
|
? "本会话 " + fmtNum(t.calls) + " 次调用 · " + fmtNum(t.totalTokens) + " Token · " + fmtCostCny(t.cost, snap.rate) + "(估算)\n点击在功能坞查看用量记录"
|
|
1396
1425
|
: (snap.err ? snap.err + "\n" : "") + "点击在功能坞查看用量记录";
|
|
1426
|
+
// 路由不可用时不做成报错态(红色),它不是错误,是「这个功能当前没开」。
|
|
1397
1427
|
return (
|
|
1398
|
-
<button type="button" className={"dockchip" + (snap.err && !t ? " err" : "")} title={title} aria-label="会话用量"
|
|
1428
|
+
<button type="button" className={"dockchip" + (snap.err && !t && !gone ? " err" : "")} title={title} aria-label="会话用量"
|
|
1399
1429
|
onClick={() => openPanel("tokenlog", { sessionId: sid, navAt: Date.now() })}>
|
|
1400
|
-
<span className="dockchip-dot" style={{ background: "var(--dk-warn)" }} />
|
|
1430
|
+
<span className="dockchip-dot" style={{ background: gone ? "var(--dsw-alias-label-tertiary,#8b95ab)" : "var(--dk-warn)" }} />
|
|
1401
1431
|
<span>{label}</span>
|
|
1402
1432
|
</button>
|
|
1403
1433
|
);
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// dsh-dock · 功能模块【语音朗读】· 宿主半部
|
|
2
|
+
//
|
|
3
|
+
// 职责:只做**配置持久化**。朗读本身发生在浏览器——Web Speech API 的
|
|
4
|
+
// speechSynthesis 是宿主页面(浏览器)的能力,声音从用户那台设备的扬声器出来;
|
|
5
|
+
// Node 侧既没有 speechSynthesis,也不该把会话正文搬到服务端绕一圈。
|
|
6
|
+
//
|
|
7
|
+
// RPC(webServer HTTP 路由,前缀 /dsh-dock/tts/):
|
|
8
|
+
// POST /status —— 当前朗读配置 + 会话级任务追踪快照(客户端据此触发自动朗读)
|
|
9
|
+
// POST /config —— 增量更新配置字段并持久化到 settings(dsh-dock 命名空间 tts 段)
|
|
10
|
+
//
|
|
11
|
+
// 配置模型(schema 见 src/host-core.js DockConfig.tts):
|
|
12
|
+
// autoPlay / autoPlayOnFinish / interruptOnNew / rate / pitch / volume /
|
|
13
|
+
// voiceURI / lang / skipCode / skipTables / maxChars / maxSentences
|
|
14
|
+
//
|
|
15
|
+
// ⚠️ voiceURI 是**设备相关**的:同一份配置在另一台机器上可能没有那个音色。
|
|
16
|
+
// 客户端使用前会用本机 getVoices() 校验,取不到就按语言回落(见 features/tts/view.jsx),
|
|
17
|
+
// 所以这里只负责原样存取,不做存在性校验(宿主也拿不到浏览器的音色表)。
|
|
18
|
+
import { sendJson, readBody, rejectUntrustedRequest, readDockRoot, mutateDockSection } from '../../src/host-core.js'
|
|
19
|
+
import { acquireTaskTracker } from '../../src/task-track.js'
|
|
20
|
+
|
|
21
|
+
/** 朗读配置字段清单(schema、默认值、增量合并三处共用同一份键名)。 */
|
|
22
|
+
export const TTS_FIELDS = [
|
|
23
|
+
'autoPlay', 'autoPlayOnFinish', 'interruptOnNew',
|
|
24
|
+
'rate', 'pitch', 'volume', 'voiceURI', 'lang',
|
|
25
|
+
'skipCode', 'skipTables', 'maxChars', 'maxSentences',
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
/** 数值字段的合法区间(越界钳制,不拒绝:面板滑块与手工 POST 都可能给到边界值)。 */
|
|
29
|
+
const NUMERIC_RANGE = {
|
|
30
|
+
rate: [0.1, 10], // 规范区间是 0.1~10,面板只开放 0.5~2
|
|
31
|
+
pitch: [0, 2],
|
|
32
|
+
volume: [0, 1],
|
|
33
|
+
maxChars: [0, 200000],
|
|
34
|
+
maxSentences: [0, 200],
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const BOOLEAN_FIELDS = [
|
|
38
|
+
'autoPlay', 'autoPlayOnFinish', 'interruptOnNew', 'skipCode', 'skipTables',
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
const STRING_FIELDS = ['voiceURI', 'lang']
|
|
42
|
+
|
|
43
|
+
function defaultConfig() {
|
|
44
|
+
return {
|
|
45
|
+
autoPlay: false,
|
|
46
|
+
autoPlayOnFinish: true,
|
|
47
|
+
interruptOnNew: true,
|
|
48
|
+
rate: 1,
|
|
49
|
+
pitch: 1,
|
|
50
|
+
volume: 1,
|
|
51
|
+
voiceURI: '',
|
|
52
|
+
lang: '',
|
|
53
|
+
skipCode: true,
|
|
54
|
+
skipTables: true,
|
|
55
|
+
maxChars: 0,
|
|
56
|
+
maxSentences: 0,
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 读本地 Config 镜像里的 tts 配置(schema 默认值已含在 resolved 里),异常回退默认。 */
|
|
61
|
+
export function readTtsConfig(ctx) {
|
|
62
|
+
const cfg = defaultConfig()
|
|
63
|
+
try {
|
|
64
|
+
const v = readDockRoot(ctx)
|
|
65
|
+
const t = v && typeof v === 'object' && v.tts && typeof v.tts === 'object' ? v.tts : null
|
|
66
|
+
if (t) {
|
|
67
|
+
for (const key of Object.keys(cfg)) {
|
|
68
|
+
if (t[key] !== undefined) cfg[key] = t[key]
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
} catch { /* settings 未挂载,用默认 */ }
|
|
72
|
+
return cfg
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 增量合并请求体到配置:只接受白名单字段,类型不符忽略,数值越界钳制。
|
|
77
|
+
* 非法 JSON 由 readBody 抛错(400),未知字段静默忽略(面板与宿主版本可能不同步)。
|
|
78
|
+
*/
|
|
79
|
+
export function mergeTtsConfig(cfg, patch) {
|
|
80
|
+
const p = patch && typeof patch === 'object' ? patch : {}
|
|
81
|
+
for (const key of BOOLEAN_FIELDS) {
|
|
82
|
+
if (typeof p[key] === 'boolean') cfg[key] = p[key]
|
|
83
|
+
}
|
|
84
|
+
for (const key of Object.keys(NUMERIC_RANGE)) {
|
|
85
|
+
if (typeof p[key] === 'number' && Number.isFinite(p[key])) {
|
|
86
|
+
const [lo, hi] = NUMERIC_RANGE[key]
|
|
87
|
+
cfg[key] = Math.min(hi, Math.max(lo, key === 'maxSentences' || key === 'maxChars' ? Math.round(p[key]) : p[key]))
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
for (const key of STRING_FIELDS) {
|
|
91
|
+
if (typeof p[key] === 'string') {
|
|
92
|
+
// voiceURI/lang 是浏览器给的标识串,限长即可;不解析、不访问网络
|
|
93
|
+
cfg[key] = p[key].trim().slice(0, 200)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return cfg
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export const feature = {
|
|
100
|
+
id: 'tts',
|
|
101
|
+
name: '语音朗读',
|
|
102
|
+
description: '把会话回复读出来(浏览器语音合成 TTS):回合结束在会话下方点喇叭播放,也可设置自动朗读',
|
|
103
|
+
defaultEnabled: false,
|
|
104
|
+
setup(ctx) {
|
|
105
|
+
const disposers = []
|
|
106
|
+
const dispose = () => {
|
|
107
|
+
while (disposers.length > 0) {
|
|
108
|
+
const fn = disposers.pop()
|
|
109
|
+
try { if (typeof fn === 'function') fn() } catch { /* 停用清理失败不阻断 */ }
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// 会话级任务追踪(与【任务动画】【任务通知】【运行状态】共享一份):
|
|
114
|
+
// 自动朗读的触发源——浏览器侧轮询 /status,看到新出现的结束记录(带 lastText)就读。
|
|
115
|
+
const lease = acquireTaskTracker(ctx)
|
|
116
|
+
disposers.push(() => lease.release())
|
|
117
|
+
const tracker = lease.tracker
|
|
118
|
+
|
|
119
|
+
disposers.push(ctx.inject(['webServer'], (wsCtx) => {
|
|
120
|
+
wsCtx.effect(() => wsCtx.webServer.register({
|
|
121
|
+
kind: 'prefix',
|
|
122
|
+
path: '/dsh-dock/tts',
|
|
123
|
+
async handler(req, res) {
|
|
124
|
+
if (rejectUntrustedRequest(ctx, req, res)) return
|
|
125
|
+
try {
|
|
126
|
+
const url = new URL(req.url || '/', 'http://dsh.internal')
|
|
127
|
+
const method = url.pathname.replace(/^\/dsh-dock\/tts\/?/, '').split('/')[0] || ''
|
|
128
|
+
const payload = await readBody(req)
|
|
129
|
+
|
|
130
|
+
if (method === 'status') {
|
|
131
|
+
return sendJson(res, 200, {
|
|
132
|
+
ok: true,
|
|
133
|
+
data: Object.assign({}, tracker.snapshot(Date.now()), { config: readTtsConfig(ctx) }),
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (method === 'config') {
|
|
138
|
+
const cfg = mergeTtsConfig(readTtsConfig(ctx), payload)
|
|
139
|
+
try {
|
|
140
|
+
await mutateDockSection(ctx, ['tts'], cfg)
|
|
141
|
+
} catch (e) {
|
|
142
|
+
const err = new Error('保存配置被拒绝:' + ((e && e.message) || String(e)))
|
|
143
|
+
err.statusCode = 400
|
|
144
|
+
throw err
|
|
145
|
+
}
|
|
146
|
+
return sendJson(res, 200, { ok: true, data: { config: cfg, savedAt: Date.now() } })
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return sendJson(res, 404, { ok: false, error: { code: 'method-not-found', message: 'unknown method: ' + method } })
|
|
150
|
+
} catch (e) {
|
|
151
|
+
const status = e && e.statusCode ? e.statusCode : 500
|
|
152
|
+
console.error('[dsh-dock] tts HTTP error:', status, e && e.message)
|
|
153
|
+
return sendJson(res, status, {
|
|
154
|
+
ok: false,
|
|
155
|
+
error: { code: status >= 500 ? 'internal' : 'bad-request', message: (e && e.message) || String(e) },
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
}), 'dsh-dock tts: /dsh-dock/tts HTTP route')
|
|
160
|
+
}))
|
|
161
|
+
|
|
162
|
+
return dispose
|
|
163
|
+
},
|
|
164
|
+
}
|