dsh-dock 0.9.8 → 0.10.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 +56 -31
- package/client.js +1839 -1474
- package/features/animation/host.js +16 -557
- package/features/animation/view.jsx +57 -642
- package/features/modelconfig/host.js +4 -1
- package/features/notify/host.js +374 -0
- package/features/notify/view.jsx +751 -0
- package/features/runstate/host.js +64 -0
- package/features/runstate/view.jsx +269 -0
- package/index.js +25 -5
- package/package.json +7 -2
- package/src/client.jsx +17 -12
- package/src/host-core.js +74 -3
- package/src/shared.js +36 -11
- package/src/task-track.js +359 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// dsh-dock · 功能模块【运行状态】· 宿主半部
|
|
2
|
+
//
|
|
3
|
+
// 职责:把会话级任务追踪的快照下发给客户端。追踪本身在 src/task-track.js
|
|
4
|
+
// (与【任务动画】【任务通知】共用一份数据,引用计数共享);本模块只读——
|
|
5
|
+
// 没有自己的 settings 配置段,也不写盘(启停由功能坞的 features.runstate 开关负责)。
|
|
6
|
+
//
|
|
7
|
+
// RPC(webServer HTTP 路由,前缀 /dsh-dock/runstate/):
|
|
8
|
+
// POST /status —— 活跃任务 + 最近完成(客户端据此渲染运行状态页与首页概要)
|
|
9
|
+
//
|
|
10
|
+
// 数据形状由客户端消费,字段改动要同时看:features/runstate/view.jsx、
|
|
11
|
+
// features/animation/view.jsx、features/notify/view.jsx。
|
|
12
|
+
import { sendJson, readBody } from '../../src/host-core.js'
|
|
13
|
+
import { acquireTaskTracker } from '../../src/task-track.js'
|
|
14
|
+
|
|
15
|
+
export const feature = {
|
|
16
|
+
id: 'runstate',
|
|
17
|
+
name: '运行状态',
|
|
18
|
+
description: '任务运行状态一览:进行中任务(阶段 / 耗时 / 回合 / Token / 等待确认)与最近完成',
|
|
19
|
+
defaultEnabled: false,
|
|
20
|
+
setup(ctx) {
|
|
21
|
+
const disposers = []
|
|
22
|
+
const dispose = () => {
|
|
23
|
+
while (disposers.length > 0) {
|
|
24
|
+
const fn = disposers.pop()
|
|
25
|
+
try { if (typeof fn === 'function') fn() } catch { /* 停用清理失败不阻断 */ }
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// 会话级任务追踪(与【任务动画】【任务通知】共享一份;三个功能都停用后自动退订会话事件)
|
|
30
|
+
const lease = acquireTaskTracker(ctx)
|
|
31
|
+
disposers.push(() => lease.release())
|
|
32
|
+
const tracker = lease.tracker
|
|
33
|
+
|
|
34
|
+
// ===== RPC 路由 =====
|
|
35
|
+
disposers.push(ctx.inject(['webServer'], (wsCtx) => {
|
|
36
|
+
wsCtx.effect(() => wsCtx.webServer.register({
|
|
37
|
+
kind: 'prefix',
|
|
38
|
+
path: '/dsh-dock/runstate',
|
|
39
|
+
async handler(req, res) {
|
|
40
|
+
try {
|
|
41
|
+
const url = new URL(req.url || '/', 'http://dsh.internal')
|
|
42
|
+
const method = url.pathname.replace(/^\/dsh-dock\/runstate\/?/, '').split('/')[0] || ''
|
|
43
|
+
await readBody(req) // 读空请求体,避免连接上残留未消费数据
|
|
44
|
+
|
|
45
|
+
if (method === 'status') {
|
|
46
|
+
return sendJson(res, 200, { ok: true, data: tracker.snapshot(Date.now()) })
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return sendJson(res, 404, { ok: false, error: { code: 'method-not-found', message: 'unknown method: ' + method } })
|
|
50
|
+
} catch (e) {
|
|
51
|
+
const status = e && e.statusCode ? e.statusCode : 500
|
|
52
|
+
console.error('[dsh-dock] runstate HTTP error:', status, e && e.message)
|
|
53
|
+
return sendJson(res, status, {
|
|
54
|
+
ok: false,
|
|
55
|
+
error: { code: status >= 500 ? 'internal' : 'bad-request', message: (e && e.message) || String(e) },
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
}), 'dsh-dock runstate: /dsh-dock/runstate HTTP route')
|
|
60
|
+
}))
|
|
61
|
+
|
|
62
|
+
return dispose
|
|
63
|
+
},
|
|
64
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// dsh-dock · 功能模块【运行状态】· 客户端视图
|
|
2
|
+
//
|
|
3
|
+
// 两部分:
|
|
4
|
+
// 1. View —— 功能坞面板页:进行中任务(阶段 / 耗时 / 回合 / 工具 / Token / 等待确认)与最近完成一览;
|
|
5
|
+
// 2. HomeStat —— 首页总揽概要(N 个任务进行中 / 等待确认 / 空闲 · 最近完成…)。
|
|
6
|
+
//
|
|
7
|
+
// 只读页面:数据全部来自宿主侧会话级任务追踪(src/task-track.js,与【任务动画】【任务通知】
|
|
8
|
+
// 共用同一份实例),本模块不写任何配置——动效在 features/animation/、结束与确认提醒在
|
|
9
|
+
// features/notify/。没有全局浮层,因此只在面板打开时轮询,关闭面板即零开销。
|
|
10
|
+
//
|
|
11
|
+
// Host 通信:fetch('/dsh-dock/runstate/status')(见 features/runstate/host.js)。
|
|
12
|
+
import { useState, useEffect } from "react";
|
|
13
|
+
|
|
14
|
+
// ---------- Host RPC 桥接 ----------
|
|
15
|
+
function rpcCall(method, args) {
|
|
16
|
+
return fetch("/dsh-dock/runstate/" + method, {
|
|
17
|
+
method: "POST",
|
|
18
|
+
headers: { "content-type": "application/json" },
|
|
19
|
+
body: JSON.stringify(args === undefined ? {} : args),
|
|
20
|
+
})
|
|
21
|
+
.then(async (res) => {
|
|
22
|
+
const data = await res.json().catch(() => ({}));
|
|
23
|
+
if (res.ok && data && data.ok === true) return data.data;
|
|
24
|
+
// 宿主侧没有运行状态路由(405/404):宿主进程可能是旧版本,或本功能在宿主侧未 setup
|
|
25
|
+
if (res.status === 405 || res.status === 404) {
|
|
26
|
+
throw new Error("宿主侧没有「运行状态」接口:宿主进程可能是旧版本,或该功能在宿主侧未启用(重启 dsh web,或把功能坞里的开关关掉再打开)");
|
|
27
|
+
}
|
|
28
|
+
if (data && data.ok === false) throw new Error((data.error && data.error.message) || ("HTTP " + res.status));
|
|
29
|
+
throw new Error("HTTP " + res.status + (data && data.error && data.error.message ? ": " + data.error.message : ""));
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ---------- 结束原因 / 阶段(与宿主追踪的字段一一对应) ----------
|
|
34
|
+
const END_LABELS = {
|
|
35
|
+
completed: { label: "完成", cls: "ok" },
|
|
36
|
+
error: { label: "出错", cls: "err" },
|
|
37
|
+
aborted: { label: "已中止", cls: "warn" },
|
|
38
|
+
blocked: { label: "受阻", cls: "warn" },
|
|
39
|
+
"max-tokens": { label: "达输出上限", cls: "warn" },
|
|
40
|
+
interrupted: { label: "中断", cls: "warn" },
|
|
41
|
+
};
|
|
42
|
+
function endInfo(reason) {
|
|
43
|
+
return END_LABELS[reason] || END_LABELS.completed;
|
|
44
|
+
}
|
|
45
|
+
const PHASE_LABELS = { think: "思考中", write: "输出中", code: "编写代码", search: "查资料" };
|
|
46
|
+
function phaseLabel(p) { return PHASE_LABELS[p] || "工作中"; }
|
|
47
|
+
// 阶段配色(与任务动画的徽标同源,便于一眼对上)
|
|
48
|
+
const PHASE_COLORS = { think: "#2f6fed", write: "#0d9488", code: "#b45309", search: "#0e7490" };
|
|
49
|
+
function phaseColor(p) { return PHASE_COLORS[p] || "#2f6fed"; }
|
|
50
|
+
|
|
51
|
+
// ---------- 格式化 ----------
|
|
52
|
+
function fmtCompact(n) {
|
|
53
|
+
n = Number(n) || 0;
|
|
54
|
+
if (n >= 1e6) return (n / 1e6).toFixed(2) + "M";
|
|
55
|
+
if (n >= 1e3) return (n / 1e3).toFixed(1) + "K";
|
|
56
|
+
return String(Math.round(n));
|
|
57
|
+
}
|
|
58
|
+
// 毫秒 → "3分21秒" / "1小时2分"
|
|
59
|
+
function fmtDur(ms) {
|
|
60
|
+
const s = Math.max(0, Math.round((ms || 0) / 1000));
|
|
61
|
+
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60;
|
|
62
|
+
if (h > 0) return h + "小时" + m + "分" + sec + "秒";
|
|
63
|
+
if (m > 0) return m + "分" + sec + "秒";
|
|
64
|
+
return sec + "秒";
|
|
65
|
+
}
|
|
66
|
+
// 毫秒 → "12:34" / "1:02:11"(进行中任务的计时)
|
|
67
|
+
function fmtClock(ms) {
|
|
68
|
+
const s = Math.max(0, Math.floor((ms || 0) / 1000));
|
|
69
|
+
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60;
|
|
70
|
+
const p = (x) => String(x).padStart(2, "0");
|
|
71
|
+
return h > 0 ? h + ":" + p(m) + ":" + p(sec) : p(m) + ":" + p(sec);
|
|
72
|
+
}
|
|
73
|
+
function fmtTime(ts) {
|
|
74
|
+
if (!ts) return "";
|
|
75
|
+
const d = new Date(ts);
|
|
76
|
+
const p = (x) => String(x).padStart(2, "0");
|
|
77
|
+
return p(d.getHours()) + ":" + p(d.getMinutes()) + ":" + p(d.getSeconds());
|
|
78
|
+
}
|
|
79
|
+
function truncate(str, max) {
|
|
80
|
+
if (!str) return "";
|
|
81
|
+
return String(str).length > max ? String(str).slice(0, max) + "…" : str;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---------- 共享快照:面板页与首页概要共用一份数据 ----------
|
|
85
|
+
const runStateStore = {
|
|
86
|
+
snap: { status: null, loading: false, error: null },
|
|
87
|
+
listeners: new Set(),
|
|
88
|
+
subscribe(fn) { this.listeners.add(fn); return () => { this.listeners.delete(fn); }; },
|
|
89
|
+
emit() { for (const fn of this.listeners) fn(); },
|
|
90
|
+
refresh() {
|
|
91
|
+
if (this.snap.loading) return Promise.resolve();
|
|
92
|
+
this.snap = Object.assign({}, this.snap, { loading: true });
|
|
93
|
+
this.emit();
|
|
94
|
+
return rpcCall("status")
|
|
95
|
+
.then((data) => {
|
|
96
|
+
this.snap = { status: data, loading: false, error: null };
|
|
97
|
+
this.emit();
|
|
98
|
+
})
|
|
99
|
+
.catch((e) => {
|
|
100
|
+
this.snap = Object.assign({}, this.snap, { loading: false, error: (e && e.message) || String(e) });
|
|
101
|
+
this.emit();
|
|
102
|
+
});
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
function useRunState() {
|
|
106
|
+
const [snap, setSnap] = useState(runStateStore.snap);
|
|
107
|
+
useEffect(() => runStateStore.subscribe(() => setSnap(runStateStore.snap)), []);
|
|
108
|
+
return snap;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// 等待确认的审批项总数(标题、横幅与首页概要共用;任何分支之外都能引用)
|
|
112
|
+
function waitingOf(active) {
|
|
113
|
+
return (active || []).reduce((n, t) => n + (Array.isArray(t.approvals) ? t.approvals.length : 0), 0);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ---------- 任务卡 ----------
|
|
117
|
+
function TaskCard(props) {
|
|
118
|
+
const t = props.t;
|
|
119
|
+
const info = endInfo(t.endReason);
|
|
120
|
+
const waiting = !props.done && Array.isArray(t.approvals) && t.approvals.length > 0;
|
|
121
|
+
return (
|
|
122
|
+
<div className="dkrs-task">
|
|
123
|
+
<div className="dkrs-task-head">
|
|
124
|
+
<span className="dkrs-task-title" title={t.title}>{t.title || "(无标题)"}</span>
|
|
125
|
+
{props.done ? <span className={"dkrs-tag " + info.cls}>{info.label}</span>
|
|
126
|
+
: waiting ? <span className="dkrs-tag warn">等待确认</span>
|
|
127
|
+
: <span className="dkrs-tag on">{phaseLabel(t.phase)}</span>}
|
|
128
|
+
</div>
|
|
129
|
+
<div className="dkrs-task-meta">
|
|
130
|
+
<span>{(t.models && t.models.length ? t.models.join(", ") : "未知") + (t.provider ? "(" + t.provider + ")" : "")}</span>
|
|
131
|
+
<span>{props.done ? fmtDur(t.duration) : fmtClock(t.elapsed)}</span>
|
|
132
|
+
{"turns" in t ? <span>{"回合 " + (t.turns || 0) + " · 步骤 " + (t.steps || 0) + (t.toolCalls ? " · 工具 " + t.toolCalls : "")}</span> : null}
|
|
133
|
+
{t.totalTokens ? <span>{"↧" + fmtCompact(t.inputTokens) + " ↥" + fmtCompact(t.outputTokens)}</span> : null}
|
|
134
|
+
{props.done && t.endTime ? <span>{fmtTime(t.endTime)}</span> : null}
|
|
135
|
+
</div>
|
|
136
|
+
{waiting ? (
|
|
137
|
+
<div className="dkrs-task-wait">
|
|
138
|
+
{t.approvals.map((a, i) => (a && a.toolName ? a.toolName : "未知工具") + (a && a.reason ? ":" + truncate(a.reason, 80) : "")).join(";")}
|
|
139
|
+
</div>
|
|
140
|
+
) : null}
|
|
141
|
+
{props.done && t.errorMessage ? <div className="dkrs-task-err">{truncate(t.errorMessage, 120)}</div> : null}
|
|
142
|
+
</div>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ---------- 面板页 ----------
|
|
147
|
+
function RunStateView() {
|
|
148
|
+
const snap = useRunState();
|
|
149
|
+
const st = snap.status;
|
|
150
|
+
const active = st && st.active ? st.active : [];
|
|
151
|
+
const recent = st && st.recent ? st.recent.slice(0, 10) : [];
|
|
152
|
+
const waitingCount = waitingOf(active);
|
|
153
|
+
// 面板打开期间自动刷新:有任务 2s(阶段/耗时/待确认跟得上),空闲 8s,出错 15s。
|
|
154
|
+
// 本模块没有常驻浮层,所以关掉面板即停止轮询。
|
|
155
|
+
useEffect(() => {
|
|
156
|
+
let stopped = false;
|
|
157
|
+
let timer = null;
|
|
158
|
+
const loop = async () => {
|
|
159
|
+
await runStateStore.refresh();
|
|
160
|
+
if (stopped) return;
|
|
161
|
+
const s = runStateStore.snap;
|
|
162
|
+
timer = setTimeout(loop, s.error ? 15000 : (s.status && s.status.active && s.status.active.length > 0 ? 2000 : 8000));
|
|
163
|
+
};
|
|
164
|
+
loop();
|
|
165
|
+
return () => { stopped = true; if (timer) clearTimeout(timer); };
|
|
166
|
+
}, []);
|
|
167
|
+
|
|
168
|
+
const sub = snap.error ? "状态拉取失败:" + snap.error
|
|
169
|
+
: !st ? (snap.loading ? "正在拉取任务状态…" : "等待任务状态")
|
|
170
|
+
: waitingCount > 0 ? waitingCount + " 项等待确认 · " + active.length + " 个任务进行中"
|
|
171
|
+
: active.length > 0 ? active.length + " 个任务进行中"
|
|
172
|
+
: recent.length > 0 ? "空闲 · 显示最近完成" : "空闲 · 暂无任务记录";
|
|
173
|
+
|
|
174
|
+
return (
|
|
175
|
+
<div className="dkrs-root">
|
|
176
|
+
<div className="dkrs-sec">
|
|
177
|
+
<div className="dkrs-sec-head">
|
|
178
|
+
<span className="dkrs-sec-title">运行状态</span>
|
|
179
|
+
<span className="dkrs-sec-sub">{sub}</span>
|
|
180
|
+
<button type="button" className="dkrs-refresh" onClick={() => runStateStore.refresh()}>
|
|
181
|
+
{snap.loading ? "刷新中…" : "刷新"}
|
|
182
|
+
</button>
|
|
183
|
+
</div>
|
|
184
|
+
{waitingCount > 0 ? (
|
|
185
|
+
<div className="dkrs-waitbanner">✋ {waitingCount} 项等待确认:任务停在需要你批准的步骤上,请在会话里处理后继续。</div>
|
|
186
|
+
) : null}
|
|
187
|
+
{active.length > 0 ? (
|
|
188
|
+
<div className="dkrs-tasks">{active.map((t) => <TaskCard key={t.sessionId} t={t} />)}</div>
|
|
189
|
+
) : null}
|
|
190
|
+
{!snap.error && active.length === 0 && recent.length === 0
|
|
191
|
+
? <div className="dkrs-note">发起新会话任务后,这里会显示进行中与最近完成的任务(动效见左侧「任务动画」,提醒见「任务通知」)。</div> : null}
|
|
192
|
+
{active.length === 0 && recent.length > 0 ? <div className="dkrs-note">当前没有进行中的任务。</div> : null}
|
|
193
|
+
{recent.length > 0 ? (
|
|
194
|
+
<>
|
|
195
|
+
<div className="dkrs-done-title">最近完成</div>
|
|
196
|
+
<div className="dkrs-tasks dkrs-tasks-done">
|
|
197
|
+
{recent.map((t) => <TaskCard key={t.sessionId + ":" + t.endTime} t={t} done />)}
|
|
198
|
+
</div>
|
|
199
|
+
</>
|
|
200
|
+
) : null}
|
|
201
|
+
<div className="dkrs-note dkrs-foot">
|
|
202
|
+
本页只读:数据来自宿主侧会话级任务追踪,与「任务动画」「任务通知」共用同一份(三个功能可各自启停)。
|
|
203
|
+
动效在「任务动画」,结束 / 需确认提醒在「任务通知」。
|
|
204
|
+
</div>
|
|
205
|
+
</div>
|
|
206
|
+
</div>
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ---------- 首页总揽概要 ----------
|
|
211
|
+
function RunStateStat(props) {
|
|
212
|
+
const snap = useRunState();
|
|
213
|
+
// 首页卡片没有常驻轮询(本模块无浮层):首次进入拉一次,之后随面板页刷新。
|
|
214
|
+
useEffect(() => { if (!runStateStore.snap.status) runStateStore.refresh(); }, []);
|
|
215
|
+
const st = snap.status;
|
|
216
|
+
if (snap.error) return <span className="dkrs-err">运行状态不可用(宿主需重启加载运行状态路由)</span>;
|
|
217
|
+
if (!st) return <span>等待任务状态…</span>;
|
|
218
|
+
const active = st.active || [];
|
|
219
|
+
const waiting = waitingOf(active);
|
|
220
|
+
if (waiting > 0) return <span className="dkrs-warn">✋ {waiting} 项等待确认 · {active.length} 个任务进行中</span>;
|
|
221
|
+
if (active.length > 0) {
|
|
222
|
+
return <span>{active.length + " 个任务进行中 · " + fmtDur(Math.max(...active.map((x) => x.elapsed || 0)))}</span>;
|
|
223
|
+
}
|
|
224
|
+
const last = (st.recent || [])[0];
|
|
225
|
+
return <span>{last ? "空闲 · 最近完成 " + truncate(last.title, 24) : "空闲 · 暂无任务记录"}</span>;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ---------- 样式(dkrs- 前缀;全部走主题变量,暗/亮色自适应) ----------
|
|
229
|
+
const css = [
|
|
230
|
+
".dkrs-root{display:flex;flex-direction:column;gap:10px;}",
|
|
231
|
+
".dkrs-note{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.6;}",
|
|
232
|
+
".dkrs-err{color:var(--dsw-alias-state-error-primary);}",
|
|
233
|
+
".dkrs-warn{color:var(--dk-warn);}",
|
|
234
|
+
".dkrs-foot{border-top:1px solid var(--dsw-alias-border-l1);padding-top:8px;font-size:11px;}",
|
|
235
|
+
".dkrs-sec{border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-1);border-radius:10px;padding:10px 12px;display:flex;flex-direction:column;gap:8px;}",
|
|
236
|
+
".dkrs-sec-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap;}",
|
|
237
|
+
".dkrs-sec-title{font-weight:600;font-size:13px;color:var(--dsw-alias-label-primary);flex:none;}",
|
|
238
|
+
".dkrs-sec-sub{font-size:11px;color:var(--dsw-alias-label-tertiary);flex:1;min-width:120px;}",
|
|
239
|
+
".dkrs-refresh{cursor:pointer;flex:none;color:var(--dsw-alias-label-primary);background:transparent;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:2px 10px;font-family:inherit;font-size:12px;}",
|
|
240
|
+
".dkrs-refresh:hover{background:var(--dsw-alias-interactive-bg-hover);}",
|
|
241
|
+
// 等待确认横幅:琥珀描边,常驻在列表上方(任务卡各自也带「等待确认」标签)
|
|
242
|
+
".dkrs-waitbanner{border:1px solid color-mix(in srgb,var(--dk-warn) 45%,transparent);background:color-mix(in srgb,var(--dk-warn) 10%,transparent);color:var(--dk-warn);border-radius:8px;padding:6px 10px;font-size:12px;line-height:1.6;}",
|
|
243
|
+
".dkrs-done-title{font-size:12px;font-weight:600;color:var(--dsw-alias-label-secondary);}",
|
|
244
|
+
// 任务列表
|
|
245
|
+
".dkrs-tasks{display:flex;flex-direction:column;gap:6px;}",
|
|
246
|
+
".dkrs-tasks-done{border-top:1px dashed var(--dsw-alias-border-l2);padding-top:6px;}",
|
|
247
|
+
".dkrs-task{border:1px solid var(--dsw-alias-border-l1);border-radius:8px;padding:6px 10px;display:flex;flex-direction:column;gap:3px;}",
|
|
248
|
+
".dkrs-task-head{display:flex;align-items:center;gap:8px;min-width:0;}",
|
|
249
|
+
".dkrs-task-title{font-size:12px;font-weight:600;color:var(--dsw-alias-label-primary);flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}",
|
|
250
|
+
".dkrs-task-meta{display:flex;gap:10px;flex-wrap:wrap;font-size:11px;color:var(--dsw-alias-label-tertiary);}",
|
|
251
|
+
".dkrs-task-wait{font-size:11px;color:var(--dk-warn);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}",
|
|
252
|
+
".dkrs-task-err{font-size:11px;color:var(--dsw-alias-state-error-primary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}",
|
|
253
|
+
".dkrs-tag{flex:none;font-size:10px;border-radius:999px;padding:0 8px;color:var(--dsw-alias-label-tertiary);border:1px solid var(--dsw-alias-border-l2);}",
|
|
254
|
+
".dkrs-tag.on{color:var(--dk-accent);border-color:currentColor;}",
|
|
255
|
+
".dkrs-tag.ok{color:var(--dsw-alias-state-success-primary);border-color:currentColor;}",
|
|
256
|
+
".dkrs-tag.err{color:var(--dsw-alias-state-error-primary);border-color:currentColor;}",
|
|
257
|
+
".dkrs-tag.warn{color:var(--dk-warn);border-color:currentColor;}",
|
|
258
|
+
].join("\n");
|
|
259
|
+
|
|
260
|
+
export const feature = {
|
|
261
|
+
id: "runstate",
|
|
262
|
+
name: "运行状态",
|
|
263
|
+
order: 150,
|
|
264
|
+
accent: "#38bdf8",
|
|
265
|
+
description: "任务运行状态一览:进行中任务的阶段 / 耗时 / Token / 等待确认与最近完成记录(只读)",
|
|
266
|
+
css,
|
|
267
|
+
View: RunStateView,
|
|
268
|
+
HomeStat: RunStateStat,
|
|
269
|
+
};
|
package/index.js
CHANGED
|
@@ -8,18 +8,22 @@
|
|
|
8
8
|
// 不含任何具体功能逻辑。
|
|
9
9
|
//
|
|
10
10
|
// 功能清单(宿主半部;客户端菜单次序由 view 模块的 order 字段决定):
|
|
11
|
-
// -
|
|
11
|
+
// - modelconfig 模型设置(v0.3.0):模型目录读 + 官方配置写
|
|
12
12
|
// - visionproxy 图片理解代理(v0.3.1):纯文本模型收图自动走视觉模型识别
|
|
13
13
|
// - balance 模型余额(v0.2.0):各 Provider 账户余额/配额
|
|
14
14
|
// - tokenlog 用量记录(v0.4.0):LLM 调用记账与统计(移植自 @wycto/dsh-token-usage)
|
|
15
|
-
// - animation 任务动画(v0.5.0):会话任务追踪 +
|
|
15
|
+
// - animation 任务动画(v0.5.0):会话任务追踪 + 动效配置持久化(参照 @wycto/dsh-task-pulse)
|
|
16
|
+
// - notify 任务通知:完成/异常/需确认通知 + 提示音/系统通知/钉钉飞书推送(从任务动画拆出)
|
|
17
|
+
// - runstate 运行状态:进行中任务与最近完成一览(从任务动画拆出;只读,无配置段)
|
|
16
18
|
// - mobile-relay 手机接力(未发布):扫码反向代理接力 + 局域网电脑直连(0.0.0.0)
|
|
17
|
-
import { DOCK_NS, DockConfig, sendJson, readBody } from './src/host-core.js'
|
|
19
|
+
import { DOCK_NS, DockConfig, sendJson, readBody, migrateNotifyConfig, migrateModelsFeatureId } from './src/host-core.js'
|
|
18
20
|
import { feature as fModels } from './features/modelconfig/host.js'
|
|
19
21
|
import { feature as fVisionProxy } from './features/visionproxy/host.js'
|
|
20
22
|
import { feature as fBalance } from './features/balance/host.js'
|
|
21
23
|
import { feature as fTokenlog } from './features/tokenlog/host.js'
|
|
22
24
|
import { feature as fAnimation } from './features/animation/host.js'
|
|
25
|
+
import { feature as fNotify } from './features/notify/host.js'
|
|
26
|
+
import { feature as fRunState } from './features/runstate/host.js'
|
|
23
27
|
import { feature as fMobileRelay } from './features/mobile-relay/host.js'
|
|
24
28
|
|
|
25
29
|
export const name = 'dsh-dock'
|
|
@@ -56,18 +60,29 @@ export function apply(ctx) {
|
|
|
56
60
|
fBalance,
|
|
57
61
|
fTokenlog,
|
|
58
62
|
fAnimation,
|
|
63
|
+
fNotify,
|
|
64
|
+
fRunState,
|
|
59
65
|
fMobileRelay,
|
|
60
66
|
]
|
|
61
67
|
|
|
62
68
|
const state = new Map()
|
|
63
69
|
for (const f of FEATURES) state.set(f.id, { enabled: false, dispose: null, error: null })
|
|
64
70
|
|
|
65
|
-
/** 读宿主侧功能开关表(settings 持久化;settings 未挂载时回退 defaultEnabled)。
|
|
71
|
+
/** 读宿主侧功能开关表(settings 持久化;settings 未挂载时回退 defaultEnabled)。
|
|
72
|
+
* 兼容别名:宿主侧【模型设置】功能 id 曾叫 models(与客户端 modelconfig 对不上,
|
|
73
|
+
* 开关永远推不到宿主);迁移落盘前(异步)在这里读时兜底换算,保证启动开关表
|
|
74
|
+
* 与 /dsh-dock/features 响应第一时间就是新口径。 */
|
|
66
75
|
function persistedFeatureMap() {
|
|
67
76
|
try {
|
|
68
77
|
const settings = ctx.get('settings')
|
|
69
78
|
const v = settings && typeof settings.get === 'function' ? settings.get(DOCK_NS) : null
|
|
70
|
-
if (v && typeof v === 'object' && v.features && typeof v.features === 'object')
|
|
79
|
+
if (v && typeof v === 'object' && v.features && typeof v.features === 'object') {
|
|
80
|
+
const map = v.features
|
|
81
|
+
if (typeof map.models === 'boolean' && typeof map.modelconfig !== 'boolean') {
|
|
82
|
+
return Object.assign({}, map, { modelconfig: map.models })
|
|
83
|
+
}
|
|
84
|
+
return map
|
|
85
|
+
}
|
|
71
86
|
} catch {
|
|
72
87
|
// settings 未挂载
|
|
73
88
|
}
|
|
@@ -107,6 +122,11 @@ export function apply(ctx) {
|
|
|
107
122
|
let initialTogglesApplied = false
|
|
108
123
|
ctx.inject(['settings'], (sctx) => {
|
|
109
124
|
sctx.settings.register(DOCK_NS, DockConfig, {})
|
|
125
|
+
// 一次性迁移:通知配置从 animation 段搬到 notify 段(【任务通知】独立成模块)。
|
|
126
|
+
// 必须在任何面板保存动作之前跑——animation 模块保存时整段写回,旧字段会被覆盖丢失。
|
|
127
|
+
migrateNotifyConfig(sctx)
|
|
128
|
+
// 一次性迁移:宿主侧【模型设置】功能 id models → modelconfig(开关表键名对齐客户端)。
|
|
129
|
+
migrateModelsFeatureId(sctx)
|
|
110
130
|
if (initialTogglesApplied) return
|
|
111
131
|
initialTogglesApplied = true
|
|
112
132
|
const persisted = persistedFeatureMap()
|
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 调用记账/费用统计)、模型设置、图片理解代理、模型余额、任务动画(19 种运行动画 + 桌面伙伴 3D
|
|
3
|
+
"version": "0.10.0",
|
|
4
|
+
"description": "DeepSeek Harness 功能坞插件(dsh-dock):模块化功能中枢——每个功能一个 features/ 模块(可单独提取成包发布,又可经 dockBridge 装回面板)。含用量记录(LLM 调用记账/费用统计)、模型设置、图片理解代理、模型余额、任务动画(19 种运行动画 + 桌面伙伴 3D 场景)、任务通知(完成/异常/需确认通知、提示音、系统通知、钉钉/飞书推送)、运行状态(进行中任务与最近完成一览);侧栏入口 + 可拖拽弹窗(最大化/最小化/缩放)。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"files": [
|
|
@@ -40,6 +40,11 @@
|
|
|
40
40
|
],
|
|
41
41
|
"author": "wycto",
|
|
42
42
|
"license": "MIT",
|
|
43
|
+
"scripts": {
|
|
44
|
+
"build:client": "node scripts/build-client.mjs",
|
|
45
|
+
"test:client": "node scripts/test-client-views.mjs",
|
|
46
|
+
"test:host": "node scripts/test-task-host.mjs"
|
|
47
|
+
},
|
|
43
48
|
"repository": {
|
|
44
49
|
"type": "git",
|
|
45
50
|
"url": "git+https://github.com/wycto/dsh-dock.git"
|
package/src/client.jsx
CHANGED
|
@@ -24,14 +24,16 @@ import { feature as fHeartbeat } from "../features/heartbeat/view.js";
|
|
|
24
24
|
import { feature as fTheme } from "../features/theme/view.js";
|
|
25
25
|
import { feature as fBalance } from "../features/balance/view.js";
|
|
26
26
|
import { feature as fAnimation } from "../features/animation/view.jsx";
|
|
27
|
+
import { feature as fNotify } from "../features/notify/view.jsx";
|
|
28
|
+
import { feature as fRunState } from "../features/runstate/view.jsx";
|
|
27
29
|
import { feature as fGames } from "../features/games/view.jsx";
|
|
28
30
|
import { feature as fMobileRelay } from "../features/mobile-relay/view.jsx";
|
|
29
31
|
|
|
30
32
|
const name = "dsh-dock";
|
|
31
|
-
const DOCK_VERSION = "0.
|
|
33
|
+
const DOCK_VERSION = "0.10.0";
|
|
32
34
|
|
|
33
35
|
// ---- 内置功能注册表:新功能 = features/<id>/ 加模块 + 这里 import 一行 ----
|
|
34
|
-
const BUILTIN_FEATURES = [fTokenlog, fModelconfig, fHeartbeat, fTheme, fBalance, fAnimation, fGames, fMobileRelay];
|
|
36
|
+
const BUILTIN_FEATURES = [fTokenlog, fModelconfig, fHeartbeat, fTheme, fBalance, fAnimation, fNotify, fRunState, fGames, fMobileRelay];
|
|
35
37
|
// 规划占位(路线图):接入后移除并建 features/<id>/ 模块
|
|
36
38
|
const PLANNED_FEATURES = [];
|
|
37
39
|
const PLANNED_NOTES = {};
|
|
@@ -357,12 +359,14 @@ function DockModal() {
|
|
|
357
359
|
const isHome = active === "home";
|
|
358
360
|
const mod = isHome ? null : (MODULES.find((m) => m.id === active) || MODULES[0]);
|
|
359
361
|
const st = mod ? stateOf(mod.id) : null;
|
|
360
|
-
//
|
|
362
|
+
// 视图统一收到 { ctx, feature, params } props
|
|
361
363
|
//(params 来自导航总线:如 { provider } 高亮余额行、{ sessionId } 按会话筛选用量)
|
|
362
364
|
const View = mod ? mod.View : null;
|
|
365
|
+
// 所有视图都包错误边界(含内置):视图抛错只降级为一行提示,不能把整块面板打没。
|
|
366
|
+
// key 用 mod.id:切换功能时重建边界,避免上一个功能的错误态留在新页面上。
|
|
363
367
|
const viewNode = mod && View
|
|
364
368
|
? react.createElement("div", { className: "dockm-view" },
|
|
365
|
-
react.createElement(
|
|
369
|
+
react.createElement(FeatureBoundary, { key: mod.id, label: mod.name },
|
|
366
370
|
react.createElement(View, { ctx: ctxRef.current, feature: mod, params: navParams })))
|
|
367
371
|
: null;
|
|
368
372
|
const enabledCount = MODULES.filter((m) => { const s = stateOf(m.id); return !!(s && s.enabled); }).length;
|
|
@@ -500,7 +504,7 @@ function HomeView(props) {
|
|
|
500
504
|
const statNode = m.planned
|
|
501
505
|
? react.createElement("span", null, PLANNED_NOTES[m.id] || "待接入:见 README 路线图")
|
|
502
506
|
: enabled && Stat
|
|
503
|
-
? react.createElement(
|
|
507
|
+
? react.createElement(FeatureBoundary, { key: m.id, label: m.name },
|
|
504
508
|
react.createElement(Stat, { ctx: ctx }))
|
|
505
509
|
: react.createElement("span", null, "已停用,启用后在此展示运行概要");
|
|
506
510
|
return react.createElement("div", {
|
|
@@ -560,7 +564,7 @@ function DockPanel() {
|
|
|
560
564
|
const View = f.View;
|
|
561
565
|
const viewNode = (!f.planned && st.enabled && View)
|
|
562
566
|
? react.createElement("div", { className: "dock-body" },
|
|
563
|
-
react.createElement(
|
|
567
|
+
react.createElement(FeatureBoundary, { key: f.id, label: f.name },
|
|
564
568
|
react.createElement(View, { ctx: ctx, feature: f })))
|
|
565
569
|
: null;
|
|
566
570
|
return react.createElement("div", { className: "dock-card", key: f.id },
|
|
@@ -608,17 +612,18 @@ function DockChips(props) {
|
|
|
608
612
|
const items = [];
|
|
609
613
|
for (const f of allModules()) {
|
|
610
614
|
if (f.planned || !stateOf(f.id).enabled || !chipShown(f.id) || typeof f.Chip !== "function") continue;
|
|
611
|
-
items.push(react.createElement(f.
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
+
items.push(react.createElement(FeatureBoundary, { key: f.id, label: f.name },
|
|
616
|
+
react.createElement(f.Chip, {
|
|
617
|
+
ctx: props.ctx, feature: f,
|
|
618
|
+
session: props.session, sessionId: props.sessionId, input: props.input,
|
|
619
|
+
})));
|
|
615
620
|
}
|
|
616
621
|
if (items.length === 0) return null;
|
|
617
622
|
return react.createElement("div", { className: "dockchip-row" }, items);
|
|
618
623
|
}
|
|
619
624
|
|
|
620
625
|
// ---- 功能全局浮层:模块可在描述符上挂 Overlay 组件(props: { ctx, feature }) ----
|
|
621
|
-
// 与 Chip(会话输入区小控件)不同,Overlay 是常驻整页挂载的全局 UI
|
|
626
|
+
// 与 Chip(会话输入区小控件)不同,Overlay 是常驻整页挂载的全局 UI(如任务动画的动效、任务通知的卡片栈),
|
|
622
627
|
// 只要所属功能启用就渲染(功能停用即卸载,浮层自身负责按数据状态决定显示什么)。
|
|
623
628
|
function FeatureOverlays() {
|
|
624
629
|
const [, force] = react.useReducer((n) => n + 1, 0);
|
|
@@ -627,7 +632,7 @@ function FeatureOverlays() {
|
|
|
627
632
|
const items = [];
|
|
628
633
|
for (const f of allModules()) {
|
|
629
634
|
if (f.planned || !stateOf(f.id).enabled || typeof f.Overlay !== "function") continue;
|
|
630
|
-
items.push(react.createElement(FeatureBoundary, { key: f.id },
|
|
635
|
+
items.push(react.createElement(FeatureBoundary, { key: f.id, label: f.name },
|
|
631
636
|
react.createElement(f.Overlay, { ctx: ctxRef.current, feature: f })));
|
|
632
637
|
}
|
|
633
638
|
if (items.length === 0) return null;
|
package/src/host-core.js
CHANGED
|
@@ -5,7 +5,8 @@ import z from '@deepseek-ai/schemastery'
|
|
|
5
5
|
/** dsh-dock 自有 settings 命名空间(插件级配置,如图片理解代理)。 */
|
|
6
6
|
export const DOCK_NS = 'dsh-dock'
|
|
7
7
|
|
|
8
|
-
/** 自有命名空间的 schema(功能开关 + 图片理解代理 +
|
|
8
|
+
/** 自有命名空间的 schema(功能开关 + 图片理解代理 + 任务动画/任务通知配置 + 远程访问账号)。
|
|
9
|
+
* 运行状态模块只读共享追踪、不写配置,因此没有自己的段。 */
|
|
9
10
|
export const DockConfig = z.object({
|
|
10
11
|
// 宿主侧功能开关(id -> boolean):客户端面板 toggle 即时同步过来,
|
|
11
12
|
// 重启 dsh 后按此表决定宿主半部 setup 哪些功能(路由注册、事件订阅等)。
|
|
@@ -21,13 +22,18 @@ export const DockConfig = z.object({
|
|
|
21
22
|
provider: z.string().default(''),
|
|
22
23
|
model: z.string().default(''),
|
|
23
24
|
}).default({}),
|
|
25
|
+
// 【任务动画】模块:只放动画本身(动效开关/模式/桌面伙伴大小)。
|
|
26
|
+
// 通知相关的字段已全部迁往下面的 notify 段(见 migrateNotifyConfig);
|
|
27
|
+
// 旧值不在 schema 里也会被 schemastery 原样保留,迁移读得到。
|
|
24
28
|
animation: z.object({
|
|
25
|
-
// 动画与通知两个独立开关(可只开其一)
|
|
26
29
|
animationEnabled: z.boolean().default(true),
|
|
27
30
|
effectMode: z.string().default('flow'),
|
|
28
31
|
// 桌面伙伴场景缩放(0.85~2.2;浮层右下角也可直接拖动缩放)
|
|
29
32
|
robotScale: z.number().default(1.35),
|
|
30
|
-
|
|
33
|
+
}).default({}),
|
|
34
|
+
// 【任务通知】模块:页内卡片 / 提示音 / 浏览器系统通知 / 群机器人推送。
|
|
35
|
+
// 模块自身的启停由 features.notify 开关负责(独立菜单项),这里只放通知行为配置。
|
|
36
|
+
notify: z.object({
|
|
31
37
|
notifyOnComplete: z.boolean().default(true),
|
|
32
38
|
notifyOnError: z.boolean().default(true),
|
|
33
39
|
// 工具等待用户确认/批准时提醒(dsh 会话流 approval/asked → decided)
|
|
@@ -46,9 +52,74 @@ export const DockConfig = z.object({
|
|
|
46
52
|
// 飞书群机器人推送(宿主侧直发,浏览器关着也能推;事件跟随 notifyOnComplete/notifyOnError)
|
|
47
53
|
feishuEnabled: z.boolean().default(false),
|
|
48
54
|
feishuWebhook: z.string().default(''),
|
|
55
|
+
// 一次性迁移标记:animation 段的旧通知配置已搬到本段(搬完置 true,避免每次启动重复写)
|
|
56
|
+
migratedFromAnimation: z.boolean().default(false),
|
|
49
57
|
}).default({}),
|
|
50
58
|
})
|
|
51
59
|
|
|
60
|
+
/** 通知配置字段清单(schema、迁移、客户端默认值三处共用同一份键名)。 */
|
|
61
|
+
export const NOTIFY_FIELDS = [
|
|
62
|
+
'notifyOnComplete', 'notifyOnError', 'notifyOnConfirm', 'notifyStayMs',
|
|
63
|
+
'systemNotify', 'soundNotify', 'soundEffect',
|
|
64
|
+
'dingtalkEnabled', 'dingtalkWebhook', 'feishuEnabled', 'feishuWebhook',
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 一次性迁移:通知配置从 settings 的 animation 段搬到 notify 段
|
|
69
|
+
* (【任务通知】独立成功能模块,配置也该由它自己拥有)。
|
|
70
|
+
*
|
|
71
|
+
* 为什么必须搬:animation 模块保存配置时整段写回,旧字段会被覆盖丢失;
|
|
72
|
+
* 迁移在插件加载时(settings 注册回调里)先跑,早于任何面板保存动作。
|
|
73
|
+
* 取值规则:animation 段的旧值优先(拆分前它就是用户的真实选择),notify 段保留其余键。
|
|
74
|
+
*/
|
|
75
|
+
export async function migrateNotifyConfig(ctx) {
|
|
76
|
+
try {
|
|
77
|
+
const settings = ctx.get('settings')
|
|
78
|
+
if (!settings || typeof settings.get !== 'function' || typeof settings.mutate !== 'function') return
|
|
79
|
+
const root = settings.get(DOCK_NS)
|
|
80
|
+
const notify = root && typeof root === 'object' && root.notify && typeof root.notify === 'object' ? root.notify : null
|
|
81
|
+
if (notify && notify.migratedFromAnimation === true) return
|
|
82
|
+
const animation = root && typeof root === 'object' && root.animation && typeof root.animation === 'object' ? root.animation : null
|
|
83
|
+
const legacy = {}
|
|
84
|
+
let copied = 0
|
|
85
|
+
for (const key of NOTIFY_FIELDS) {
|
|
86
|
+
if (animation && animation[key] !== undefined) { legacy[key] = animation[key]; copied++ }
|
|
87
|
+
}
|
|
88
|
+
// 没有可搬的旧字段(全新安装 / 已搬完)就不写盘,也不置标记——
|
|
89
|
+
// 避免 settings 命名空间尚未生效时误把「空迁移」当完成(那会丢掉旧配置)。
|
|
90
|
+
if (copied === 0) return
|
|
91
|
+
const next = Object.assign({}, notify || {}, legacy, { migratedFromAnimation: true })
|
|
92
|
+
await settings.mutate(DOCK_NS, [{ op: 'set', path: ['notify'], value: next }])
|
|
93
|
+
console.log('[dsh-dock] notify config migrated from animation section; fields copied:', copied)
|
|
94
|
+
} catch (e) {
|
|
95
|
+
console.warn('[dsh-dock] notify config migration skipped:', (e && e.message) || String(e))
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* 一次性迁移:宿主侧【模型设置】功能 id 从 models 改为 modelconfig(与客户端视图 id 对齐)。
|
|
101
|
+
*
|
|
102
|
+
* 为什么必须迁:面板开关按客户端 id(modelconfig)POST /dsh-dock/features,
|
|
103
|
+
* 旧宿主 id 叫 models → 永远「未知功能」404(客户端静默吞掉),开关表里因此
|
|
104
|
+
* 只可能出现 API 手工写入的 models 键。新键未落盘时继承旧值并删掉旧键;
|
|
105
|
+
* 判断条件本身幂等(迁完后 models 键不存在,直接早退),无需额外标记位。
|
|
106
|
+
*/
|
|
107
|
+
export async function migrateModelsFeatureId(ctx) {
|
|
108
|
+
try {
|
|
109
|
+
const settings = ctx.get('settings')
|
|
110
|
+
if (!settings || typeof settings.get !== 'function' || typeof settings.mutate !== 'function') return
|
|
111
|
+
const root = settings.get(DOCK_NS)
|
|
112
|
+
const features = root && typeof root === 'object' && root.features && typeof root.features === 'object' ? root.features : null
|
|
113
|
+
if (!features || typeof features.models !== 'boolean' || typeof features.modelconfig === 'boolean') return
|
|
114
|
+
const next = Object.assign({}, features, { modelconfig: features.models })
|
|
115
|
+
delete next.models
|
|
116
|
+
await settings.mutate(DOCK_NS, [{ op: 'set', path: ['features'], value: next }])
|
|
117
|
+
console.log('[dsh-dock] feature id migrated: features.models -> features.modelconfig =', features.models)
|
|
118
|
+
} catch (e) {
|
|
119
|
+
console.warn('[dsh-dock] models feature id migration skipped:', (e && e.message) || String(e))
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
52
123
|
/** 任务动画 effectMode 合法值(客户端动画模式)。 */
|
|
53
124
|
export const ANIMATION_MODES = [
|
|
54
125
|
'flow', 'breathe', 'ring', 'orbit', 'robot', 'matrix', 'stars', 'aurora', 'space',
|