@mzzsfy/dsh-keep-working 0.0.0-stage → 0.1.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 +84 -2
- package/cordis.patch.yml +4 -0
- package/package.json +49 -4
- package/src/client.js +366 -0
- package/src/closure.mjs +182 -0
- package/src/config.mjs +103 -0
- package/src/engine.mjs +240 -0
- package/src/failure.mjs +84 -0
- package/src/index.js +259 -0
- package/src/offline.mjs +69 -0
- package/src/route-gate.mjs +29 -0
- package/src/route-guard.mjs +35 -0
- package/src/scan.mjs +425 -0
- package/src/session-kind.mjs +7 -0
- package/src/settings.mjs +59 -0
- package/test/client-id.test.mjs +34 -0
- package/test/client-panel.test.mjs +101 -0
- package/test/closure.test.mjs +302 -0
- package/test/config.test.mjs +24 -0
- package/test/engine.test.mjs +436 -0
- package/test/failure.test.mjs +81 -0
- package/test/offline.test.mjs +85 -0
- package/test/prompt-section.test.mjs +25 -0
- package/test/route-gate.test.mjs +57 -0
- package/test/route-guard.test.mjs +51 -0
- package/test/route.test.mjs +210 -0
- package/test/scan.test.mjs +685 -0
- package/test/settings.test.mjs +52 -0
package/README.md
CHANGED
|
@@ -1,3 +1,85 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @mzzsfy/dsh-keep-working
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
持续工作:回合未声明结案就静默结束时,自动补一条用户消息把 AI 拉回,直到它交付产物或明确中止。
|
|
4
|
+
|
|
5
|
+
替代 `dsh-client-auto-continue` 的全部宿主侧恢复能力,并补上“任务没做完但回合正常结束”这一缺口。
|
|
6
|
+
|
|
7
|
+
## 结案规则
|
|
8
|
+
|
|
9
|
+
一句话:改过文件就 `present` 交付(之后不要再改文件、不再写 `(TASK_DONE)`),没改文件就末行写 `(TASK_DONE)`,做不下去末行写 `(TASK_BLOCKED: 原因)`。
|
|
10
|
+
|
|
11
|
+
`write` / `edit` / `apply_patch` / `str_replace_editor` 都算改过文件;失败的写与 `str_replace_editor` 的 `view` 子命令不算。判定层在 present 有效时即判定结案,不追罚多余标记;二选一由提示词约束。
|
|
12
|
+
|
|
13
|
+
边界:经 shell 工具改写文件(`sed -i`、重定向)不在识别面内,这类回合不会被要求 present。工具名穷举不可能覆盖所有写路径,这里不追罚也不补判,是显式的口径边界。
|
|
14
|
+
|
|
15
|
+
规则经 `systemPrompt.section` 常驻注入,不依赖提醒文案。段落在 `DELIVERABLE_FILE_REFERENCES` 之后注册:宿主那段建议里写着“不要只为列出编辑过的源文件就调用 present”,结案规则必须压在其后,并显式声明 present 的时机以本段为准。
|
|
16
|
+
|
|
17
|
+
未结案时补发一条用户消息,内容按原因选择:结案提醒 / 输出截断 / 重启中断 / 失败详情 / 无可见输出。
|
|
18
|
+
|
|
19
|
+
## 恢复矩阵
|
|
20
|
+
|
|
21
|
+
| `turn/end` 原因 | 动作 |
|
|
22
|
+
|---|---|
|
|
23
|
+
| `completed` 且已结案 | 清连续计数,不发送 |
|
|
24
|
+
| `completed` 且有可见输出但未结案 | 结案提醒 |
|
|
25
|
+
| `completed` 且无可见输出 | 无输出提醒(用户已在回合中插话则让路) |
|
|
26
|
+
| `error` | 分类后恢复;鉴权、余额、模型不存在、上下文超限等永久错误跳过。宿主把 HTTP 状态并进错误消息而非独立字段,401/403 按文本识别 |
|
|
27
|
+
| `interrupted` | 重启中断提醒 |
|
|
28
|
+
| `max-tokens` | 截断提醒 |
|
|
29
|
+
| `aborted` | 用户取消不恢复;`disposed` / `legacy` 按中断恢复 |
|
|
30
|
+
| `forked` 等 | 不处理 |
|
|
31
|
+
|
|
32
|
+
以下回合一律不发送:子会话(`origin === 'subagent'`,旧记录按 `parentSession` + `delegationDepth >= 1`)、用户中途插话的回合、非用户发起的回合的结案提醒、已达连续上限的会话。
|
|
33
|
+
|
|
34
|
+
## 节奏
|
|
35
|
+
|
|
36
|
+
`graceMs` 到点才发送;两次发送之间至少间隔 `cooldownMs`,连续失败按 `backoffFactor` 退避,封顶 `backoffMaxMs`;单会话连续 `maxConsecutive` 次后放弃,等待人工介入;真人发起的回合一到就清零,额度恢复。判定看回合前导段:宿主注入的上下文(指令文件、工作目录、任务事件等,来源不是 `user`)不算发起,本插件补发的消息也不算。
|
|
37
|
+
|
|
38
|
+
断网时挂起,`offlineCheckMs` 后复查;探测失败按在线处理,不会永久卡住。
|
|
39
|
+
|
|
40
|
+
## 重启恢复
|
|
41
|
+
|
|
42
|
+
冷启动延迟 5 秒扫存储会话:直接扫 `$DSH_HOME/sessions` 取最近 `freshMs` 内写过的候选(上千个文件 stat 一遍是百毫秒级,候选通常只有几个),再按日志规模从小到大逐个判定。拿不到归档集合时本轮不恢复,下一轮重试。
|
|
43
|
+
|
|
44
|
+
判定只看尾部:回合正常结束且有可见回复就跳过(重启后重复执行整轮任务的代价,高于漏恢复的概率),未闭合回合、临时错误、重启中断、截断、无可见输出才恢复。子会话(origin 或旧格式 parentSession+depth)、已归档、用户主动取消同样排除。剩余会话物化后补一条恢复消息,每轮最多 `scanLimit` 个。
|
|
45
|
+
|
|
46
|
+
`goal` 在进程重启后会被宿主 disarm,`resumeGoals` 开启时对 `active` 且 `disarmed` 的 goal 重新武装。
|
|
47
|
+
|
|
48
|
+
启动扫描不常驻轮询:目录快照完整可读时一轮即止(近期没有会话被写过就是常态结果);持久层或全量回退给出空列表时按重试间隔重试,直到列出会话或超出新鲜窗口;快照不完整(部分子项读失败)时一律重试到窗口耗尽,不因已列出若干会话而收工。
|
|
49
|
+
|
|
50
|
+
## 配置
|
|
51
|
+
|
|
52
|
+
见 `src/config.mjs` 的 `DEFAULTS`;全部字段可在 profile 条目 `config` 中覆盖。语义要点:
|
|
53
|
+
|
|
54
|
+
- `paused`:全局刹车,置 true 后所有恢复停止
|
|
55
|
+
- `resumeGoals`:默认开启,与宿主“重启后 disarm”的默认相反,这是本插件有意的行为差异
|
|
56
|
+
- `resumePausedGoals`:默认关闭,暂停是人的决定
|
|
57
|
+
- `classify`:关闭后所有 `error` 都会重试
|
|
58
|
+
|
|
59
|
+
## 设置面板
|
|
60
|
+
|
|
61
|
+
配置卡片挂在插件管理页的包详情里(`plugins.bundle.config`,key 为包名),写的是宿主 `settings` 服务(profile 条目 `config` 的 volatile 字段),保存即时重建恢复引擎,不需要重启。
|
|
62
|
+
|
|
63
|
+
面板字段:总开关、等待时间、冷却、最大连续次数、重启恢复、空回合恢复、断网暂停、错误分类,以及五段补发文案。其余字段(退避倍数、扫描窗口等)仍走 `DEFAULTS` 与 profile 配置。
|
|
64
|
+
|
|
65
|
+
宿主路由 `GET/POST /api/keep-working/config` 供面板读写;`GET` 同时返回默认值,供「恢复默认」使用。
|
|
66
|
+
|
|
67
|
+
## 与 dsh-client-auto-continue 的关系
|
|
68
|
+
|
|
69
|
+
本包接管其宿主侧全部职责,装上后可卸载对方。未实现的部分:浏览器通知、会话级模式开关、循环护栏、子会话唤醒根会话。两包同时启用会出现同一回合被推两次。
|
|
70
|
+
|
|
71
|
+
## 开发
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
node --test "test/*.test.mjs"
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
判定、分类、探测、调度、扫描均可单测,不依赖宿主进程。
|
|
78
|
+
|
|
79
|
+
## dsh 版本兼容
|
|
80
|
+
|
|
81
|
+
宿主 API 面:`agents`、`systemPrompt`、`sessionQuery`、`workspaceRegistry`、`goals`、`@deepseek-ai/dsh-llm` 的 `createUserMessage`。
|
|
82
|
+
|
|
83
|
+
缺 `agents` 或 `systemPrompt` 时插件干净停用(不注册任何服务与提示词);缺 `sessionQuery` 时只停用重启扫描,实时恢复照常。`@deepseek-ai/dsh-llm` 动态导入失败时整体停用并留日志。
|
|
84
|
+
|
|
85
|
+
当前实测版本见 `dsh.compatibility.dshReleases`。
|
package/cordis.patch.yml
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,51 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mzzsfy/dsh-keep-working",
|
|
3
|
-
"
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"description": "持续工作:回合未声明结案就静默结束时,自动补一条用户消息把 AI 拉回,直到 present 交付、(TASK_DONE) 或 (TASK_BLOCKED: 原因);含重启恢复与 goal 重新武装",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
"./client": "./src/client.js",
|
|
9
|
+
".": "./src/index.js",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
},
|
|
12
|
+
"dsh": {
|
|
13
|
+
"bundle": {
|
|
14
|
+
"patch": "./cordis.patch.yml"
|
|
15
|
+
},
|
|
16
|
+
"compatibility": {
|
|
17
|
+
"dshReleases": {
|
|
18
|
+
"0.2.2-rc.1": "compatible"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"hostMin": "0.2.2-rc.1",
|
|
22
|
+
"client": {
|
|
23
|
+
"platform": "web"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"test": "node --test \"test/*.test.mjs\"",
|
|
28
|
+
"npmPublish": "npm publish --access public --provenance=false --registry=https://registry.npmjs.org"
|
|
29
|
+
},
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public",
|
|
32
|
+
"registry": "https://registry.npmjs.org"
|
|
33
|
+
},
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "git+https://github.com/mzzsfy/dsh-plugin.git"
|
|
38
|
+
},
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": ">=22"
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"@deepseek-ai/schemastery": ">=3.18.0"
|
|
44
|
+
},
|
|
45
|
+
"files": [
|
|
46
|
+
"src",
|
|
47
|
+
"test",
|
|
48
|
+
"cordis.patch.yml",
|
|
49
|
+
"README.md"
|
|
50
|
+
]
|
|
51
|
+
}
|
package/src/client.js
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
// dsh-keep-working Client 半区:设置面板(总开关、节奏、恢复项、文案)。
|
|
2
|
+
// 以 DSH client-modules 自注册格式发布:__ModuleLoader__.load({id, factory}),
|
|
3
|
+
// factory(require) 中 require('react') 由 DSH client runtime 模块表解析。
|
|
4
|
+
// 配置读写走宿主路由 /api/keep-working/config,写入后由宿主重建恢复引擎。
|
|
5
|
+
|
|
6
|
+
window.__ModuleLoader__.load({
|
|
7
|
+
id: '@mzzsfy/dsh-keep-working',
|
|
8
|
+
factory(require) {
|
|
9
|
+
const React = require('react')
|
|
10
|
+
const { useCallback, useEffect, useState } = React
|
|
11
|
+
|
|
12
|
+
const PKG_ID = '@mzzsfy/dsh-keep-working'
|
|
13
|
+
|
|
14
|
+
const API_PATH = '/api/keep-working/config'
|
|
15
|
+
|
|
16
|
+
// 毫秒字段在面板按秒编辑,避免用户数零
|
|
17
|
+
const MS_PER_SECOND = 1000
|
|
18
|
+
|
|
19
|
+
const HEAD_HINT = '开关与数字在点保存后才生效;恢复默认只把默认值填回输入框,仍需保存。'
|
|
20
|
+
|
|
21
|
+
// 色彩槽位:根作用域声明包局部 token,直接引用宿主语义别名,不写 fallback
|
|
22
|
+
// (规范见 DEVELOPMENT/plugin-ui-design.md,字面量在暗色主题与主题切换时漏色)
|
|
23
|
+
const CSS = `
|
|
24
|
+
.kw-panel { --kw-accent: var(--dsw-alias-brand-primary); --kw-accent-deep: var(--dsw-alias-button-primary-hover); --kw-danger: var(--dsw-alias-state-error-primary); --kw-text: var(--dsw-alias-label-primary); --kw-text-sub: var(--dsw-alias-label-secondary); --kw-text-dim: var(--dsw-alias-label-tertiary); --kw-bg: var(--dsw-alias-bg-layer-1); --kw-bg-muted: var(--dsw-alias-bg-module-platform); --kw-card: var(--dsw-alias-bg-layer-1); --kw-card-border: var(--dsw-alias-border-l2); --kw-border: var(--dsw-alias-border-l2); --kw-border-strong: var(--dsw-alias-border-l3); --kw-hover: var(--dsw-alias-interactive-bg-hover); --kw-skeleton: var(--dsw-alias-bg-skeleton); --kw-radius-sm: var(--dsw-radius-sm); --kw-radius-md: var(--dsw-radius-md);
|
|
25
|
+
display: flex; flex-direction: column; gap: 16px; max-width: 560px; color: var(--kw-text); font-size: 14px; line-height: 22px; }
|
|
26
|
+
.kw-head { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
|
|
27
|
+
.kw-head__hint { color: var(--kw-text-sub); font-size: 13px; line-height: 20px; }
|
|
28
|
+
.kw-card { display: flex; flex-direction: column; padding: 14px 16px; background: var(--kw-card); border: .5px solid var(--kw-card-border); border-radius: var(--kw-radius-md); }
|
|
29
|
+
.kw-card__head { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; padding-bottom: 8px; }
|
|
30
|
+
.kw-card__title { font-weight: 600; font-size: 14px; line-height: 22px; color: var(--kw-text); }
|
|
31
|
+
.kw-card__meta { margin-left: auto; color: var(--kw-text-dim); font-size: 12px; line-height: 18px; }
|
|
32
|
+
.kw-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px 16px; align-items: center; padding: 12px 0; border-top: .5px solid var(--kw-border); }
|
|
33
|
+
.kw-row__text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
|
34
|
+
.kw-row__label { color: var(--kw-text); font-size: 14px; line-height: 22px; }
|
|
35
|
+
.kw-row__hint { color: var(--kw-text-sub); font-size: 13px; line-height: 20px; }
|
|
36
|
+
.kw-row__control { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: flex-end; min-width: 0; }
|
|
37
|
+
.kw-unit { color: var(--kw-text-dim); font-size: 11px; line-height: 18px; }
|
|
38
|
+
.kw-number { width: 96px; height: 32px; box-sizing: border-box; padding: 0 10px; font: inherit; font-size: 14px; line-height: 22px; text-align: right; color: var(--kw-text); background: var(--kw-bg); border: 1px solid var(--kw-border-strong); border-radius: var(--kw-radius-sm); }
|
|
39
|
+
.kw-number:focus-visible { outline: 2px solid var(--dsw-alias-state-business-primary); outline-offset: 2px; }
|
|
40
|
+
.kw-number:disabled { opacity: .5; cursor: not-allowed; }
|
|
41
|
+
.kw-field { display: flex; flex-direction: column; gap: 8px; padding: 12px 0; border-top: .5px solid var(--kw-border); }
|
|
42
|
+
.kw-field__label { color: var(--kw-text); font-size: 14px; line-height: 22px; }
|
|
43
|
+
.kw-textarea { width: 100%; box-sizing: border-box; min-height: 64px; padding: 8px 10px; font: inherit; font-size: 14px; line-height: 22px; color: var(--kw-text); background: var(--kw-bg); border: 1px solid var(--kw-border-strong); border-radius: var(--kw-radius-sm); resize: vertical; }
|
|
44
|
+
.kw-textarea:focus-visible { outline: 2px solid var(--dsw-alias-state-business-primary); outline-offset: 2px; }
|
|
45
|
+
.kw-actions { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding-top: 4px; }
|
|
46
|
+
.kw-btn { height: 32px; padding: 0 14px; font: inherit; font-size: 14px; line-height: 22px; border-radius: var(--kw-radius-sm); border: 1px solid var(--kw-border-strong); background: transparent; color: var(--kw-text); cursor: pointer; transition: background .15s, border-color .15s; }
|
|
47
|
+
.kw-btn:hover { background: var(--kw-hover); }
|
|
48
|
+
.kw-btn--primary { background: var(--kw-accent); border-color: var(--kw-accent); color: var(--dsw-alias-bg-base); }
|
|
49
|
+
.kw-btn--primary:hover { background: var(--kw-accent-deep); border-color: var(--kw-accent-deep); }
|
|
50
|
+
.kw-btn:disabled { opacity: .5; cursor: not-allowed; }
|
|
51
|
+
.kw-status { font-size: 12px; line-height: 18px; color: var(--kw-text-dim); }
|
|
52
|
+
.kw-status--error { color: var(--kw-danger); }
|
|
53
|
+
.kw-skeleton { display: flex; flex-direction: column; gap: 8px; padding: 12px 0; }
|
|
54
|
+
.kw-skeleton__row { height: 16px; border-radius: var(--kw-radius-sm); background: var(--kw-skeleton); }
|
|
55
|
+
.kw-switch { position: relative; display: inline-flex; align-items: center; cursor: pointer; }
|
|
56
|
+
.kw-switch input[type="checkbox"] { position: absolute; opacity: 0; width: 0; height: 0; }
|
|
57
|
+
.kw-switch__track { position: relative; flex: none; width: 34px; height: 19px; box-sizing: border-box; border-radius: 999px; background: var(--kw-bg-muted); border: 1px solid var(--kw-border); transition: background .15s, border-color .15s; }
|
|
58
|
+
.kw-switch__thumb { position: absolute; top: 50%; left: 2px; width: 13px; height: 13px; border-radius: 50%; background: var(--kw-text-dim); transform: translateY(-50%); transition: left .15s, background .15s; }
|
|
59
|
+
.kw-switch:hover .kw-switch__track { background: color-mix(in srgb, var(--kw-bg-muted) 88%, var(--kw-text)); }
|
|
60
|
+
.kw-switch input[type="checkbox"]:checked + .kw-switch__track { background: var(--kw-accent); border-color: var(--kw-accent); }
|
|
61
|
+
.kw-switch input[type="checkbox"]:checked + .kw-switch__track .kw-switch__thumb { left: 17px; background: var(--kw-bg); }
|
|
62
|
+
.kw-switch input[type="checkbox"]:focus-visible + .kw-switch__track { outline: 2px solid var(--dsw-alias-state-business-primary); outline-offset: 2px; }
|
|
63
|
+
.kw-switch input[type="checkbox"]:disabled + .kw-switch__track { opacity: .5; cursor: not-allowed; }
|
|
64
|
+
@media (max-width: 640px) {
|
|
65
|
+
.kw-card { padding: 16px; }
|
|
66
|
+
.kw-row { grid-template-columns: 1fr; gap: 8px; }
|
|
67
|
+
.kw-row__control { justify-content: flex-start; }
|
|
68
|
+
}
|
|
69
|
+
`
|
|
70
|
+
|
|
71
|
+
/** 布尔项:label 包裹视觉隐藏的 checkbox 与胶囊轨道,规约要求的开关形态。 */
|
|
72
|
+
const switchRow = (key, label, hint, value, onChange) =>
|
|
73
|
+
React.createElement(
|
|
74
|
+
'div',
|
|
75
|
+
{ className: 'kw-row', key },
|
|
76
|
+
React.createElement(
|
|
77
|
+
'div',
|
|
78
|
+
{ className: 'kw-row__text' },
|
|
79
|
+
React.createElement('div', { className: 'kw-row__label' }, label),
|
|
80
|
+
hint === undefined ? null : React.createElement('div', { className: 'kw-row__hint' }, hint),
|
|
81
|
+
),
|
|
82
|
+
React.createElement(
|
|
83
|
+
'div',
|
|
84
|
+
{ className: 'kw-row__control' },
|
|
85
|
+
React.createElement(
|
|
86
|
+
'label',
|
|
87
|
+
{ className: 'kw-switch' },
|
|
88
|
+
React.createElement('input', {
|
|
89
|
+
type: 'checkbox',
|
|
90
|
+
'aria-label': label,
|
|
91
|
+
checked: value === true,
|
|
92
|
+
onChange: (event) => onChange(event.target.checked),
|
|
93
|
+
}),
|
|
94
|
+
React.createElement(
|
|
95
|
+
'span',
|
|
96
|
+
{ className: 'kw-switch__track' },
|
|
97
|
+
React.createElement('span', { className: 'kw-switch__thumb' }),
|
|
98
|
+
),
|
|
99
|
+
),
|
|
100
|
+
),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
/** 数值项:毫秒字段按秒显示,写回时换算;单位随字段类型固定展示。 */
|
|
104
|
+
const numberRow = (key, label, hint, value, unitMs, onChange) =>
|
|
105
|
+
React.createElement(
|
|
106
|
+
'div',
|
|
107
|
+
{ className: 'kw-row', key },
|
|
108
|
+
React.createElement(
|
|
109
|
+
'div',
|
|
110
|
+
{ className: 'kw-row__text' },
|
|
111
|
+
React.createElement('div', { className: 'kw-row__label' }, label),
|
|
112
|
+
hint === undefined ? null : React.createElement('div', { className: 'kw-row__hint' }, hint),
|
|
113
|
+
),
|
|
114
|
+
React.createElement(
|
|
115
|
+
'div',
|
|
116
|
+
{ className: 'kw-row__control' },
|
|
117
|
+
React.createElement('input', {
|
|
118
|
+
className: 'kw-number',
|
|
119
|
+
'aria-label': label,
|
|
120
|
+
type: 'number',
|
|
121
|
+
min: 1,
|
|
122
|
+
value: unitMs ? Math.round(value / MS_PER_SECOND) : value,
|
|
123
|
+
onChange: (event) => {
|
|
124
|
+
const next = Number(event.target.value)
|
|
125
|
+
if (!Number.isFinite(next) || next <= 0) return
|
|
126
|
+
onChange(unitMs ? next * MS_PER_SECOND : next)
|
|
127
|
+
},
|
|
128
|
+
}),
|
|
129
|
+
React.createElement('span', { className: 'kw-unit' }, unitMs ? '秒' : '次'),
|
|
130
|
+
),
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
const textField = (key, label, value, onChange) =>
|
|
134
|
+
React.createElement(
|
|
135
|
+
'div',
|
|
136
|
+
{ className: 'kw-field', key },
|
|
137
|
+
React.createElement('div', { className: 'kw-field__label' }, label),
|
|
138
|
+
React.createElement('textarea', {
|
|
139
|
+
className: 'kw-textarea',
|
|
140
|
+
'aria-label': label,
|
|
141
|
+
value,
|
|
142
|
+
onChange: (event) => onChange(event.target.value),
|
|
143
|
+
}),
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
function KeepWorkingPanel() {
|
|
147
|
+
const [config, setConfig] = useState(null)
|
|
148
|
+
const [defaults, setDefaults] = useState(null)
|
|
149
|
+
const [status, setStatus] = useState('')
|
|
150
|
+
const [failed, setFailed] = useState(false)
|
|
151
|
+
|
|
152
|
+
const load = useCallback(async () => {
|
|
153
|
+
try {
|
|
154
|
+
const response = await fetch(API_PATH)
|
|
155
|
+
const payload = await response.json()
|
|
156
|
+
setConfig(payload.value)
|
|
157
|
+
setDefaults(payload.defaults)
|
|
158
|
+
setFailed(false)
|
|
159
|
+
} catch (error) {
|
|
160
|
+
setStatus(`读取失败:${error instanceof Error ? error.message : String(error)}`)
|
|
161
|
+
setFailed(true)
|
|
162
|
+
}
|
|
163
|
+
}, [])
|
|
164
|
+
|
|
165
|
+
useEffect(() => {
|
|
166
|
+
void load()
|
|
167
|
+
}, [load])
|
|
168
|
+
|
|
169
|
+
const patch = useCallback((key, value) => {
|
|
170
|
+
setConfig((current) => (current === null ? current : { ...current, [key]: value }))
|
|
171
|
+
}, [])
|
|
172
|
+
|
|
173
|
+
const save = useCallback(async () => {
|
|
174
|
+
if (config === null) return
|
|
175
|
+
setStatus('保存中')
|
|
176
|
+
try {
|
|
177
|
+
const response = await fetch(API_PATH, {
|
|
178
|
+
method: 'POST',
|
|
179
|
+
headers: { 'content-type': 'application/json' },
|
|
180
|
+
body: JSON.stringify(config),
|
|
181
|
+
})
|
|
182
|
+
const payload = await response.json()
|
|
183
|
+
if (payload.ok !== true) throw new Error(payload.error ?? '保存失败')
|
|
184
|
+
setConfig(payload.value)
|
|
185
|
+
setStatus('已保存,立即生效')
|
|
186
|
+
setFailed(false)
|
|
187
|
+
} catch (error) {
|
|
188
|
+
setStatus(`保存失败:${error instanceof Error ? error.message : String(error)}`)
|
|
189
|
+
setFailed(true)
|
|
190
|
+
}
|
|
191
|
+
}, [config])
|
|
192
|
+
|
|
193
|
+
const reset = useCallback(() => {
|
|
194
|
+
if (defaults === null) return
|
|
195
|
+
// 面板字段全量回填:只回填一部分会让"恢复默认"留下旧开关,保存后插件仍是关的
|
|
196
|
+
setConfig((current) => ({ ...current, ...defaults }))
|
|
197
|
+
setStatus('已填入默认值,点保存生效')
|
|
198
|
+
setFailed(false)
|
|
199
|
+
}, [defaults])
|
|
200
|
+
|
|
201
|
+
if (config === null) {
|
|
202
|
+
return React.createElement(
|
|
203
|
+
'div',
|
|
204
|
+
{ className: 'kw-panel' },
|
|
205
|
+
React.createElement(
|
|
206
|
+
'div',
|
|
207
|
+
{ className: 'kw-head' },
|
|
208
|
+
React.createElement('span', { className: 'kw-head__hint' }, status === '' ? '正在读取配置…' : status),
|
|
209
|
+
),
|
|
210
|
+
React.createElement(
|
|
211
|
+
'div',
|
|
212
|
+
{ className: 'kw-card' },
|
|
213
|
+
React.createElement(
|
|
214
|
+
'div',
|
|
215
|
+
{ className: 'kw-skeleton', 'aria-busy': 'true' },
|
|
216
|
+
[0, 1, 2].map((index) => React.createElement('div', { className: 'kw-skeleton__row', key: index })),
|
|
217
|
+
),
|
|
218
|
+
),
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return React.createElement(
|
|
223
|
+
'div',
|
|
224
|
+
{ className: 'kw-panel' },
|
|
225
|
+
React.createElement(
|
|
226
|
+
'div',
|
|
227
|
+
{ className: 'kw-head' },
|
|
228
|
+
React.createElement('span', { className: 'kw-head__hint' }, HEAD_HINT),
|
|
229
|
+
),
|
|
230
|
+
React.createElement(
|
|
231
|
+
'div',
|
|
232
|
+
{ className: 'kw-card' },
|
|
233
|
+
React.createElement('div', { className: 'kw-card__head' }, React.createElement('span', { className: 'kw-card__title' }, '总开关')),
|
|
234
|
+
switchRow(
|
|
235
|
+
'enabled',
|
|
236
|
+
'启用自动继续',
|
|
237
|
+
'关闭后不再补发任何消息,已排队的发送一并取消',
|
|
238
|
+
config.paused !== true,
|
|
239
|
+
(next) => patch('paused', next !== true),
|
|
240
|
+
),
|
|
241
|
+
),
|
|
242
|
+
React.createElement(
|
|
243
|
+
'div',
|
|
244
|
+
{ className: 'kw-card' },
|
|
245
|
+
React.createElement('div', { className: 'kw-card__head' }, React.createElement('span', { className: 'kw-card__title' }, '节奏')),
|
|
246
|
+
numberRow(
|
|
247
|
+
'graceMs',
|
|
248
|
+
'等待时间',
|
|
249
|
+
'回合结束后等多久再补发,留出用户插话的间隙',
|
|
250
|
+
config.graceMs,
|
|
251
|
+
true,
|
|
252
|
+
(next) => patch('graceMs', next),
|
|
253
|
+
),
|
|
254
|
+
numberRow(
|
|
255
|
+
'cooldownMs',
|
|
256
|
+
'连续补发后的冷却',
|
|
257
|
+
'同一会话连续补发时,每次都按倍数拉长,这是起始值',
|
|
258
|
+
config.cooldownMs,
|
|
259
|
+
true,
|
|
260
|
+
(next) => patch('cooldownMs', next),
|
|
261
|
+
),
|
|
262
|
+
numberRow(
|
|
263
|
+
'maxConsecutive',
|
|
264
|
+
'最多连续补发',
|
|
265
|
+
'同一回合连续补发到该次数就停手,等你回来处理',
|
|
266
|
+
config.maxConsecutive,
|
|
267
|
+
false,
|
|
268
|
+
(next) => patch('maxConsecutive', next),
|
|
269
|
+
),
|
|
270
|
+
),
|
|
271
|
+
React.createElement(
|
|
272
|
+
'div',
|
|
273
|
+
{ className: 'kw-card' },
|
|
274
|
+
React.createElement('div', { className: 'kw-card__head' }, React.createElement('span', { className: 'kw-card__title' }, '恢复项')),
|
|
275
|
+
switchRow(
|
|
276
|
+
'scanOnBoot',
|
|
277
|
+
'重启后接着跑',
|
|
278
|
+
'宿主启动时扫描上次没结案的会话',
|
|
279
|
+
config.scanOnBoot,
|
|
280
|
+
(next) => patch('scanOnBoot', next),
|
|
281
|
+
),
|
|
282
|
+
switchRow(
|
|
283
|
+
'resumeSilentTurns',
|
|
284
|
+
'恢复空回合',
|
|
285
|
+
'模型只输出了思考、没有可见回复时也补发',
|
|
286
|
+
config.resumeSilentTurns,
|
|
287
|
+
(next) => patch('resumeSilentTurns', next),
|
|
288
|
+
),
|
|
289
|
+
switchRow(
|
|
290
|
+
'pauseWhenOffline',
|
|
291
|
+
'断网时暂停',
|
|
292
|
+
'网络不通时不重试,联网后自动继续',
|
|
293
|
+
config.pauseWhenOffline,
|
|
294
|
+
(next) => patch('pauseWhenOffline', next),
|
|
295
|
+
),
|
|
296
|
+
switchRow(
|
|
297
|
+
'classify',
|
|
298
|
+
'区分永久错误',
|
|
299
|
+
'鉴权失败、余额不足、模型不存在这类重试无用的错误直接放弃',
|
|
300
|
+
config.classify,
|
|
301
|
+
(next) => patch('classify', next),
|
|
302
|
+
),
|
|
303
|
+
),
|
|
304
|
+
React.createElement(
|
|
305
|
+
'div',
|
|
306
|
+
{ className: 'kw-card' },
|
|
307
|
+
React.createElement(
|
|
308
|
+
'div',
|
|
309
|
+
{ className: 'kw-card__head' },
|
|
310
|
+
React.createElement('span', { className: 'kw-card__title' }, '补发文案'),
|
|
311
|
+
React.createElement('span', { className: 'kw-card__meta' }, '按触发原因分别配置'),
|
|
312
|
+
),
|
|
313
|
+
textField('continueText', '出错后', config.continueText, (next) => patch('continueText', next)),
|
|
314
|
+
textField('continueTextInterrupted', '重启后', config.continueTextInterrupted, (next) =>
|
|
315
|
+
patch('continueTextInterrupted', next),
|
|
316
|
+
),
|
|
317
|
+
textField('continueTextSilent', '空回合', config.continueTextSilent, (next) =>
|
|
318
|
+
patch('continueTextSilent', next),
|
|
319
|
+
),
|
|
320
|
+
textField('continueTextMaxTokens', '输出截断', config.continueTextMaxTokens, (next) =>
|
|
321
|
+
patch('continueTextMaxTokens', next),
|
|
322
|
+
),
|
|
323
|
+
textField('closureText', '未结案提醒', config.closureText, (next) => patch('closureText', next)),
|
|
324
|
+
),
|
|
325
|
+
React.createElement(
|
|
326
|
+
'div',
|
|
327
|
+
{ className: 'kw-actions' },
|
|
328
|
+
React.createElement(
|
|
329
|
+
'button',
|
|
330
|
+
{ className: 'kw-btn kw-btn--primary', onClick: () => void save() },
|
|
331
|
+
'保存',
|
|
332
|
+
),
|
|
333
|
+
React.createElement('button', { className: 'kw-btn', onClick: reset }, '恢复默认'),
|
|
334
|
+
React.createElement(
|
|
335
|
+
'span',
|
|
336
|
+
{ className: failed ? 'kw-status kw-status--error' : 'kw-status', role: failed ? 'alert' : 'status' },
|
|
337
|
+
status,
|
|
338
|
+
),
|
|
339
|
+
),
|
|
340
|
+
)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return {
|
|
344
|
+
name: 'keep-working',
|
|
345
|
+
// 设置分区挂载在 slots 上,缺声明时宿主拒绝服务访问
|
|
346
|
+
inject: ['slots'],
|
|
347
|
+
apply(ctx) {
|
|
348
|
+
ctx.effect(() => {
|
|
349
|
+
const style = document.createElement('style')
|
|
350
|
+
// 自带 data-plugin:缺失时宿主 claimStyles 会把样式归属给后续材质化插件,其重建即误删
|
|
351
|
+
style.setAttribute('data-plugin', '@mzzsfy/dsh-keep-working')
|
|
352
|
+
style.textContent = CSS
|
|
353
|
+
document.head.appendChild(style)
|
|
354
|
+
return () => style.remove()
|
|
355
|
+
}, 'keep-working styles')
|
|
356
|
+
// 配置卡片挂在插件管理页的包详情里,与 context 等插件同形态,不占设置导航项
|
|
357
|
+
ctx.slots.inject('plugins.bundle.config', () =>
|
|
358
|
+
ctx.slots.register(
|
|
359
|
+
{ name: 'plugins.bundle.config', key: PKG_ID },
|
|
360
|
+
() => React.createElement(KeepWorkingPanel),
|
|
361
|
+
))
|
|
362
|
+
},
|
|
363
|
+
__test: { KeepWorkingPanel },
|
|
364
|
+
}
|
|
365
|
+
},
|
|
366
|
+
})
|