dsh-mcmp 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +191 -0
- package/lib/client.js +193 -0
- package/lib/index.js +582 -0
- package/package.json +32 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Aampidy
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# mcmp-1 · 数学建模论文自动化流水线
|
|
2
|
+
|
|
3
|
+
DeepSeek Harness **动态 Cordis 插件**:面向全国大学生数学建模竞赛论文撰写的全自动流水线。
|
|
4
|
+
粘贴赛题 → 发送一条以 `/loopbegin` 开头的消息 → 插件按 8 大步骤、每步多次迭代自动完成论文撰写,
|
|
5
|
+
全程由专家子智能体「先质疑、再迭代」驱动,成果全部落盘保存,右下角浮动面板实时显示进度百分比。
|
|
6
|
+
|
|
7
|
+
## 功能特性
|
|
8
|
+
|
|
9
|
+
- **消息触发**:输入文本以 `/loopbegin` 开头即自动运行(兼容斜杠命令路径)
|
|
10
|
+
- **8 大步骤 × 多迭代**:每轮 19 次迭代,外循环轮数可配置(`--round=N`,默认 1 轮)
|
|
11
|
+
- **质疑驱动**:每个子智能体必须先对上一版成果提出 ≥3 条质疑,再开始本轮工作
|
|
12
|
+
- **成果全落盘**:每步迭代完整追加保存(含质疑清单),旧内容绝不覆盖;代码保留全部历史版本
|
|
13
|
+
- **实时进度面板**:独立浮动窗口(可拖拽/最小化/关闭),显示百分比进度条、当前轮/步骤/迭代、8 步骤打点、日志、产出文件,支持一键中止
|
|
14
|
+
- **原生工作流卡片**:聊天区实时显示每个子智能体的运行状态(按阶段分组)
|
|
15
|
+
|
|
16
|
+
## 八大步骤(每轮 19 次迭代)
|
|
17
|
+
|
|
18
|
+
| 步骤 | 内容 | 迭代次数 |
|
|
19
|
+
|------|------|----------|
|
|
20
|
+
| S1 | 赛题分析 | 3 次(初步拆解→深度分析→最终定调) |
|
|
21
|
+
| S2 | 模型求解 | 3 次(模型建立→模型求解→模型验证) |
|
|
22
|
+
| S3 | 编程实现 | 3 次(核心代码→完善调试→优化封装) |
|
|
23
|
+
| S4 | 图表生成 | 3 次(基础图表→美化优化→精选定稿) |
|
|
24
|
+
| S5 | 流程与架构图 | 3 次(结构设计→细节完善→美化定稿) |
|
|
25
|
+
| S6 | 论文撰写 | 2 次(初稿→深化润色) |
|
|
26
|
+
| S7 | 编译与合规检查 | 1 次 |
|
|
27
|
+
| S8 | 评审改进循环 | 1 次 |
|
|
28
|
+
|
|
29
|
+
## 使用方法
|
|
30
|
+
|
|
31
|
+
1. **发送赛题**:把数学建模竞赛题目完整粘贴到对话框并发送;
|
|
32
|
+
2. **触发插件**:发送一条以 `/loopbegin` 开头的消息:
|
|
33
|
+
- `/loopbegin` —— 默认 1 轮
|
|
34
|
+
- `/loopbegin --round=3` —— 3 轮外循环(上限 10)
|
|
35
|
+
- `/loopbegin --from 题目.txt` —— 从文件读取赛题(支持含空格/中文的文件名,如 `--from 2024 年高教社杯全国大学生数学建模竞赛题目.md`,也可用 `--from=文件` 形式)
|
|
36
|
+
3. **查看进度**:右下角浮动面板(百分比、步骤打点、日志、文件)+ 聊天区工作流卡片。
|
|
37
|
+
|
|
38
|
+
辅助命令:`/loopstatus`(查看进度)、`/loopabort`(中止,已产出成果保留)。
|
|
39
|
+
|
|
40
|
+
### 触发规则
|
|
41
|
+
|
|
42
|
+
| 消息内容 | 是否触发 |
|
|
43
|
+
|---|---|
|
|
44
|
+
| `/loopbegin --round=3`(开头) | ✅ |
|
|
45
|
+
| ` /loopbegin`(前导空格) | ✅ |
|
|
46
|
+
| `题目正文… /loopbegin`(不在开头) | ❌ |
|
|
47
|
+
| `/loopstatus`、`/loopabort` | 辅助命令,不触发 |
|
|
48
|
+
|
|
49
|
+
## 安装方式
|
|
50
|
+
|
|
51
|
+
> 本仓库根目录**就是插件包本体**(`package.json` + `lib/`),与部署目录同构。
|
|
52
|
+
|
|
53
|
+
### 方式一:`dsh plugin add` 安装(推荐,需插件已发布到 npm)
|
|
54
|
+
|
|
55
|
+
与官方插件相同的安装体验,一条命令安装、注册:
|
|
56
|
+
|
|
57
|
+
```powershell
|
|
58
|
+
# 1. 安装插件包(等价于在 profile 目录执行 pnpm add,链接进 profile 的 node_modules)
|
|
59
|
+
pnpm dsh plugin --profile web add dsh-mcmp
|
|
60
|
+
# 其他等价的调用形式:
|
|
61
|
+
# dsh plugin --profile web add dsh-mcmp
|
|
62
|
+
# npx @deepseek-ai/dsh plugin --profile web add dsh-mcmp
|
|
63
|
+
|
|
64
|
+
# 2. 注册配置行(在本仓库目录执行)
|
|
65
|
+
.\deploy.ps1 -RowOnly
|
|
66
|
+
|
|
67
|
+
# 3. 重启并刷新
|
|
68
|
+
# 重启 pnpm dsh web → 刷新网页 → 访问 /mcmp-api/state 返回 JSON 即成功
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
**卸载**:
|
|
72
|
+
|
|
73
|
+
```powershell
|
|
74
|
+
pnpm dsh plugin --profile web remove dsh-mcmp # 移除包
|
|
75
|
+
# 并删除 cordis.patch.yml 中的插件行(或恢复 cordis.patch.yml.bak)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### 方式二:本地一键安装(插件未发布到 npm 时)
|
|
79
|
+
|
|
80
|
+
```powershell
|
|
81
|
+
.\deploy.ps1
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
脚本自动完成:复制插件文件 → 建立 node_modules junction → 注册配置行,然后重启 `dsh web` 并刷新网页即可。手动步骤如下:
|
|
85
|
+
|
|
86
|
+
**1. 放置插件包**
|
|
87
|
+
|
|
88
|
+
把仓库根目录的 `package.json` 与 `lib/` 复制到配置层目录:
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
$DSH_HOME/profiles/web/plugins/dsh-mcmp/
|
|
92
|
+
├── package.json # dsh.client 声明 + 包入口
|
|
93
|
+
└── lib/
|
|
94
|
+
├── index.js # Host 半:命令、编排、断点续跑、/mcmp-api 路由
|
|
95
|
+
└── client.js # Client 半:浮动进度面板(__ModuleLoader__ bundle)
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
**2. 建立包解析链接**(Windows 无需管理员权限)
|
|
99
|
+
|
|
100
|
+
```cmd
|
|
101
|
+
mklink /J "%USERPROFILE%\.dsh\profiles\node_modules\dsh-mcmp" "%USERPROFILE%\.dsh\profiles\web\plugins\dsh-mcmp"
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
**3. 在配置层注册插件行**
|
|
105
|
+
|
|
106
|
+
编辑 `$DSH_HOME/profiles/web/cordis.patch.yml`,追加(注意必须用 `insert:` 列表语法):
|
|
107
|
+
|
|
108
|
+
```yaml
|
|
109
|
+
- insert:
|
|
110
|
+
- id: mcmp
|
|
111
|
+
name: dsh-mcmp
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
**4. 重启并验证**
|
|
115
|
+
|
|
116
|
+
- 重启 `dsh web`,刷新网页(插件随启动加载,面板 bundle 注入启动清单);
|
|
117
|
+
- 验证接口:浏览器访问 `http://127.0.0.1:3080/mcmp-api/state`,返回 JSON 即安装成功;
|
|
118
|
+
- 之后任何重启都无需再安装,`/loopbegin` 永久可用。
|
|
119
|
+
|
|
120
|
+
### 发布到 npm(维护者)
|
|
121
|
+
|
|
122
|
+
```powershell
|
|
123
|
+
npm login # 首次需要登录(注册表账号)
|
|
124
|
+
npm publish # 发布后即可用「方式一」安装
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### 方式三:动态插件安装(临时,仅当前进程)
|
|
128
|
+
|
|
129
|
+
适合试用与开发调试;DSH 重启后自动消失,需重新安装:
|
|
130
|
+
|
|
131
|
+
1. 把 `legacy/dynamic-host.js` 内容作为 `code.host`、`legacy/dynamic-client.js` 内容作为 `code.client`,通过 `cordis_define` 定义插件;
|
|
132
|
+
2. `cordis_run` 激活(客户端包需在界面批准);
|
|
133
|
+
3. 发送以 `/loopbegin` 开头的消息即可使用。
|
|
134
|
+
|
|
135
|
+
## 输出目录(工作区 `数学建模流水线/`)
|
|
136
|
+
|
|
137
|
+
```
|
|
138
|
+
数学建模流水线/
|
|
139
|
+
├── 00_赛题原文.md # 启动时自动保存的赛题
|
|
140
|
+
├── 01_赛题分析.md ~ 08_评审改进.md # 八大步骤完整成果(逐迭代追加,含质疑清单)
|
|
141
|
+
├── 代码/ # 全部验证与求解代码(_vN 后缀保留历史版本,绝不删除)
|
|
142
|
+
├── 图表/ # 全部图表(含归档)
|
|
143
|
+
├── 论文/ # LaTeX 源码(若环境支持)
|
|
144
|
+
└── 流水线说明.md # 目录说明
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## 架构
|
|
148
|
+
|
|
149
|
+
```
|
|
150
|
+
用户消息(/loopbegin 开头)
|
|
151
|
+
│ session/event 全局监听(或 commands 斜杠命令)
|
|
152
|
+
▼
|
|
153
|
+
Host 插件(dsh-mcmp,HOST 组合)
|
|
154
|
+
├─ 提取赛题(对话文本或 --from 文件)
|
|
155
|
+
├─ 断点续跑:扫描会话日志中 tool-workflow/* 记录,跳过已完成迭代
|
|
156
|
+
├─ 编排器:按 8 步骤 × 19 迭代 × N 轮逐个启动专家子智能体(subagents.spawn)
|
|
157
|
+
│ 子智能体:读必读文件 → 质疑先行 → 完成任务 → 追加落盘 → 返回总结
|
|
158
|
+
├─ 向会话追加 tool-workflow/* 事件 → 聊天区原生工作流卡片
|
|
159
|
+
└─ 面板 API:webServer 路由 /mcmp-api/{state,abort,reset}
|
|
160
|
+
▲
|
|
161
|
+
Client 插件(dsh-mcmp,标准 __ModuleLoader__ bundle)
|
|
162
|
+
├─ shell.overlay 浮动面板,每 1.2s fetch('/mcmp-api/state') 轮询
|
|
163
|
+
└─ 进度条/步骤打点/日志/文件/中止按钮(拖拽仅绑定标题文字)
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## 文件说明
|
|
167
|
+
|
|
168
|
+
> 仓库根目录即插件包本体,与部署目录 `$DSH_HOME/profiles/web/plugins/dsh-mcmp/` 同构。
|
|
169
|
+
|
|
170
|
+
| 文件 | 内容 |
|
|
171
|
+
| --- | --- |
|
|
172
|
+
| `package.json` | 插件包清单:`dsh.client` 声明、`exports` 入口(`.` → Host,`./client` → 面板 bundle) |
|
|
173
|
+
| `lib/index.js` | **Host 半**:命令注册、断点续跑、Host 侧子智能体编排、`/mcmp-api` 面板路由 |
|
|
174
|
+
| `lib/client.js` | **Client 半**:浮动进度面板(标准 `__ModuleLoader__` bundle,`fetch` 轮询) |
|
|
175
|
+
| `deploy.ps1` | 一键部署脚本:复制插件文件 → 建 junction → 注册插件行 |
|
|
176
|
+
| `legacy/` | 历史动态插件源码(`dynamic-host.js` / `dynamic-client.js`),仅当前进程临时安装时参考 |
|
|
177
|
+
|
|
178
|
+
## 版本历史
|
|
179
|
+
|
|
180
|
+
- **v1(pkg-1)**:斜杠命令触发(`/loopbegin`),8 步骤流水线、进度面板、成果落盘
|
|
181
|
+
- **v2(pkg-2)**:新增消息内容触发(输入文本以 `/loopbegin` 开头即运行),注册模型提示避免重复执行
|
|
182
|
+
- **v3(pkg-3)**:修复「工作流引擎不可用」——引擎从 Host 上下文三级回退到 Agent 上下文获取;工作流事件改为全局监听;`--from` 支持含空格/中文文件名;赛题随工作流参数传递、由 S1 首迭代落盘
|
|
183
|
+
- **v4(pkg-4)**:重构为 **Host 侧直接编排子智能体**(`subagents` 服务),彻底摆脱工作流引擎的 Agent 上下文依赖——关闭/刷新聊天窗口不再中断流水线,后台继续执行;启动逻辑全部同步化,命令通道永不阻塞
|
|
184
|
+
- **v5(pkg-5)**:移除 `maxDepth: 0` 参数(该语义会拒绝所有子智能体创建),改用部署默认深度限制
|
|
185
|
+
- **v6(pkg-6)**:①**断点续跑**——启动时扫描会话日志中落盘的工作流记录,自动跳过已完成的迭代,中断/重启后不再从头开始;新增 `--fresh` 强制重跑;②修复浮动面板 ×/— 按钮被拖拽指针捕获吞掉点击的问题(拖拽仅绑定标题文字);③提升状态徽章对比度与按钮可点击区域
|
|
186
|
+
- **v7(pkg-7)**:**提示词优化**——明确"产出可提交论文级内容"的目标;新增五条工作纪律(质疑先行并逐条给出处理决定、只改有据之处、禁止编造数据、符号与术语一致、论文级表达);明确回复结构(质疑清单→工作成果→文件清单总结)
|
|
187
|
+
- **v8(固化版)**:**持久化部署插件**——以正式插件包(`dsh-mcmp`)写入用户配置层 `$DSH_HOME/profiles/web/cordis.patch.yml`,DSH 重启后自动加载,不再依赖动态安装。声明 `inject` 硬依赖等待服务就绪,面板 RPC 改为 `webServer` 路由(`/mcmp-api/*`),客户端为标准 `__ModuleLoader__` bundle + fetch。源码见 `persistent/` 目录
|
|
188
|
+
|
|
189
|
+
## License
|
|
190
|
+
|
|
191
|
+
MIT
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-mcmp · Client 半(标准 __ModuleLoader__ 客户端 bundle)
|
|
3
|
+
* 浮动进度面板:每 1.2s 通过 fetch('/mcmp-api/state') 轮询 Host 状态,
|
|
4
|
+
* 中止/重置通过 POST /mcmp-api/abort|reset。拖拽仅绑定标题文字。
|
|
5
|
+
*/
|
|
6
|
+
window.__ModuleLoader__.load({
|
|
7
|
+
id: "dsh-mcmp",
|
|
8
|
+
factory: (require) => {
|
|
9
|
+
var module = { exports: {} };
|
|
10
|
+
var exports = module.exports;
|
|
11
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
12
|
+
let react = require("react");
|
|
13
|
+
|
|
14
|
+
const CSS = [
|
|
15
|
+
'.mcmp-panel { position: fixed; right: 16px; bottom: 16px; z-index: 9990; width: 344px; max-height: 78vh; display: flex; flex-direction: column; pointer-events: auto; background: var(--dsw-alias-bg-overlay); color: var(--dsw-alias-label-primary); border: 1px solid var(--dsw-alias-border-l1); border-radius: 12px; box-shadow: 0 16px 48px rgba(0,0,0,0.4); font-size: 12px; line-height: 1.55; overflow: hidden; font-family: system-ui, -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif; }',
|
|
16
|
+
'.mcmp-head { display: flex; align-items: center; gap: 8px; padding: 8px 10px; background: var(--dsw-alias-bg-layer-1); border-bottom: 1px solid var(--dsw-alias-border-l1); user-select: none; }',
|
|
17
|
+
'.mcmp-title { font-weight: 600; font-size: 13px; flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: grab; padding: 2px 0; }',
|
|
18
|
+
'.mcmp-title:active { cursor: grabbing; }',
|
|
19
|
+
'.mcmp-chip { padding: 2px 9px; border-radius: 999px; font-size: 11px; color: #ffffff; flex-shrink: 0; text-shadow: 0 1px 1px rgba(0,0,0,0.3); font-weight: 600; }',
|
|
20
|
+
'.mcmp-chip-run { background: var(--dsw-alias-brand-primary, #2563eb); animation: mcmp-pulse 1.6s ease-in-out infinite; }',
|
|
21
|
+
'.mcmp-chip-ok { background: var(--dsw-alias-state-success-primary, #16a34a); }',
|
|
22
|
+
'.mcmp-chip-err { background: var(--dsw-alias-state-error-primary, #dc2626); }',
|
|
23
|
+
'.mcmp-chip-warn { background: var(--dsw-alias-state-warn-primary, #d97706); }',
|
|
24
|
+
'@keyframes mcmp-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.55; } }',
|
|
25
|
+
'.mcmp-btn { border: none; background: transparent; color: var(--dsw-alias-label-primary); cursor: pointer; font-size: 14px; padding: 4px 9px; border-radius: 6px; line-height: 1; min-width: 26px; }',
|
|
26
|
+
'.mcmp-btn:hover { background: var(--dsw-alias-bg-layer-2); }',
|
|
27
|
+
'.mcmp-body { padding: 10px; display: flex; flex-direction: column; gap: 8px; overflow-y: auto; }',
|
|
28
|
+
'.mcmp-row { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; }',
|
|
29
|
+
'.mcmp-label { color: var(--dsw-alias-label-secondary, #6b7280); }',
|
|
30
|
+
'.mcmp-bar { height: 8px; border-radius: 999px; background: var(--dsw-alias-bg-layer-2); overflow: hidden; }',
|
|
31
|
+
'.mcmp-fill { height: 100%; border-radius: 999px; background: var(--dsw-alias-brand-primary, #2563eb); transition: width 0.5s ease; }',
|
|
32
|
+
'.mcmp-pct { font-weight: 700; font-size: 14px; }',
|
|
33
|
+
'.mcmp-cur { font-size: 12px; }',
|
|
34
|
+
'.mcmp-steps { display: flex; flex-direction: column; gap: 3px; }',
|
|
35
|
+
'.mcmp-step { display: flex; align-items: center; gap: 6px; padding: 2px 6px; border-radius: 6px; }',
|
|
36
|
+
'.mcmp-step.active { background: var(--dsw-alias-bg-layer-1); }',
|
|
37
|
+
'.mcmp-step .mname { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }',
|
|
38
|
+
'.mcmp-dots { color: var(--dsw-alias-label-secondary, #6b7280); letter-spacing: 1px; font-size: 11px; flex-shrink: 0; }',
|
|
39
|
+
'.mcmp-done-dot { color: var(--dsw-alias-state-success-primary, #16a34a); }',
|
|
40
|
+
'.mcmp-logs { font-family: ui-monospace, Consolas, monospace; font-size: 11px; color: var(--dsw-alias-label-secondary, #6b7280); background: var(--dsw-alias-bg-layer-1); border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px; padding: 6px 8px; max-height: 96px; overflow-y: auto; white-space: pre-wrap; word-break: break-all; }',
|
|
41
|
+
'.mcmp-files { max-height: 140px; overflow-y: auto; background: var(--dsw-alias-bg-layer-1); border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px; padding: 6px 8px; font-size: 11px; }',
|
|
42
|
+
'.mcmp-file { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--dsw-alias-label-secondary, #6b7280); }',
|
|
43
|
+
'.mcmp-error { color: var(--dsw-alias-state-error-primary, #dc2626); }',
|
|
44
|
+
'.mcmp-actions { display: flex; gap: 8px; justify-content: flex-end; }',
|
|
45
|
+
'.mcmp-actions .mcmp-btn { border: 1px solid var(--dsw-alias-border-l1); padding: 4px 12px; font-size: 12px; }',
|
|
46
|
+
'.mcmp-abort { color: var(--dsw-alias-state-error-primary, #dc2626); border-color: var(--dsw-alias-state-error-primary, #dc2626); }',
|
|
47
|
+
].join('\n')
|
|
48
|
+
|
|
49
|
+
const h = react.createElement
|
|
50
|
+
const STATUS_TEXT = { running: '运行中', completed: '已完成', error: '出错', cancelled: '已中止', idle: '空闲' }
|
|
51
|
+
const STATUS_CLASS = { running: 'run', completed: 'ok', error: 'err', cancelled: 'warn', idle: '' }
|
|
52
|
+
|
|
53
|
+
function Panel() {
|
|
54
|
+
const [snap, setSnap] = react.useState(null)
|
|
55
|
+
const [minimized, setMinimized] = react.useState(false)
|
|
56
|
+
const [dismissedRun, setDismissedRun] = react.useState(null)
|
|
57
|
+
const [pos, setPos] = react.useState(null)
|
|
58
|
+
const [drag, setDrag] = react.useState(null)
|
|
59
|
+
|
|
60
|
+
react.useEffect(() => {
|
|
61
|
+
let unmounted = false
|
|
62
|
+
const tick = async () => {
|
|
63
|
+
try {
|
|
64
|
+
const res = await fetch('/mcmp-api/state', { cache: 'no-store' })
|
|
65
|
+
if (res.ok) {
|
|
66
|
+
const s = await res.json()
|
|
67
|
+
if (!unmounted && s) setSnap(s)
|
|
68
|
+
}
|
|
69
|
+
} catch (err) { /* 面板未连接时忽略 */ }
|
|
70
|
+
}
|
|
71
|
+
tick()
|
|
72
|
+
const id = setInterval(tick, 1200)
|
|
73
|
+
return () => { unmounted = true; clearInterval(id) }
|
|
74
|
+
}, [])
|
|
75
|
+
|
|
76
|
+
if (!snap || snap.status === 'idle' || dismissedRun === snap.runId) return null
|
|
77
|
+
const st = snap.status
|
|
78
|
+
const pct = snap.pct
|
|
79
|
+
|
|
80
|
+
// 拖拽只绑定在标题文字上,避免指针捕获吞掉同栏按钮的点击
|
|
81
|
+
const onTitleDown = (e) => {
|
|
82
|
+
if (e.button !== 0) return
|
|
83
|
+
const box = e.currentTarget.parentElement ? e.currentTarget.parentElement.parentElement : null
|
|
84
|
+
if (!box) return
|
|
85
|
+
const rect = box.getBoundingClientRect()
|
|
86
|
+
box.setPointerCapture(e.pointerId)
|
|
87
|
+
setPos({ x: rect.left, y: rect.top })
|
|
88
|
+
setDrag({ dx: e.clientX - rect.left, dy: e.clientY - rect.top })
|
|
89
|
+
}
|
|
90
|
+
const onMove = (e) => {
|
|
91
|
+
if (!drag) return
|
|
92
|
+
setPos({ x: e.clientX - drag.dx, y: e.clientY - drag.dy })
|
|
93
|
+
}
|
|
94
|
+
const onUp = (e) => {
|
|
95
|
+
setDrag(null)
|
|
96
|
+
try { e.currentTarget.releasePointerCapture(e.pointerId) } catch (err) { /* ignore */ }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const rootStyle = pos
|
|
100
|
+
? { left: pos.x, top: pos.y, right: 'auto', bottom: 'auto' }
|
|
101
|
+
: undefined
|
|
102
|
+
|
|
103
|
+
const elapsed = snap.startedAt ? Math.max(0, (snap.endedAt || Date.now()) - snap.startedAt) : 0
|
|
104
|
+
const mm = Math.floor(elapsed / 60000)
|
|
105
|
+
const ss = Math.floor((elapsed % 60000) / 1000)
|
|
106
|
+
const elapsedText = mm > 0 ? mm + '分' + (ss < 10 ? '0' : '') + ss + '秒' : ss + '秒'
|
|
107
|
+
|
|
108
|
+
const stepRows = (snap.steps || []).map((step) => {
|
|
109
|
+
let dots = ''
|
|
110
|
+
for (let k = 0; k < step.iterTotal; k++) {
|
|
111
|
+
dots += k < step.done ? '●' : '○'
|
|
112
|
+
}
|
|
113
|
+
const name = h('span', { className: 'mname' }, step.key + ' ' + step.name)
|
|
114
|
+
const dotEl = h('span', { className: 'mcmp-dots ' + (step.done > 0 ? 'mcmp-done-dot' : '') }, dots)
|
|
115
|
+
const cnt = h('span', { className: 'mcmp-label' }, step.done + '/' + step.iterTotal)
|
|
116
|
+
return h('div', { key: step.key, className: 'mcmp-step' + (step.active ? ' active' : '') }, name, dotEl, cnt)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
const logLines = (snap.logs || []).map((l, i) => l.t + ' ' + l.m).join('\n')
|
|
120
|
+
const fileRows = (snap.files || []).map((f, i) => h('div', { key: 'f' + i, className: 'mcmp-file' }, (f.type === 'dir' ? '📁 ' : '📄 ') + f.name))
|
|
121
|
+
|
|
122
|
+
const curLine = snap.cur
|
|
123
|
+
? snap.cur.stepKey + ' ' + snap.cur.stepName + ' · 迭代 ' + (snap.cur.iterIdx + 1) + '/' + snap.cur.iterTotal + ' 「' + snap.cur.iterName + '」'
|
|
124
|
+
: st === 'completed' ? '🎉 全部迭代已完成' : '等待中…'
|
|
125
|
+
|
|
126
|
+
const children = []
|
|
127
|
+
children.push(h('div', { key: 'head', className: 'mcmp-head' },
|
|
128
|
+
h('span', { className: 'mcmp-title', title: '按住拖动面板', onPointerDown: onTitleDown }, '数学建模论文流水线'),
|
|
129
|
+
h('span', { className: 'mcmp-chip mcmp-chip-' + (STATUS_CLASS[st] || '') }, STATUS_TEXT[st] || st),
|
|
130
|
+
h('button', { key: 'min', className: 'mcmp-btn', title: minimized ? '展开面板' : '最小化面板', onClick: () => setMinimized(!minimized) }, minimized ? '□' : '—'),
|
|
131
|
+
h('button', { key: 'close', className: 'mcmp-btn', title: '关闭面板', onClick: () => setDismissedRun(snap.runId) }, '×'),
|
|
132
|
+
))
|
|
133
|
+
if (!minimized) {
|
|
134
|
+
const body = []
|
|
135
|
+
body.push(h('div', { key: 'pct', className: 'mcmp-row' },
|
|
136
|
+
h('span', { className: 'mcmp-label' }, '总体进度'),
|
|
137
|
+
h('span', { className: 'mcmp-pct' }, pct + '%'),
|
|
138
|
+
))
|
|
139
|
+
body.push(h('div', { key: 'bar', className: 'mcmp-bar' },
|
|
140
|
+
h('div', { className: 'mcmp-fill', style: { width: pct + '%' } }),
|
|
141
|
+
))
|
|
142
|
+
body.push(h('div', { key: 'meta', className: 'mcmp-row' },
|
|
143
|
+
h('span', { className: 'mcmp-label' }, '第 ' + snap.round + '/' + snap.rounds + ' 轮 · ' + snap.done + '/' + snap.total + ' 次迭代 · ' + elapsedText),
|
|
144
|
+
h('span', { className: 'mcmp-label' }, '已完成'),
|
|
145
|
+
))
|
|
146
|
+
body.push(h('div', { key: 'cur', className: 'mcmp-cur' }, curLine))
|
|
147
|
+
if (snap.agentLabel && st === 'running') {
|
|
148
|
+
body.push(h('div', { key: 'agent', className: 'mcmp-label' }, '子任务: ' + snap.agentLabel))
|
|
149
|
+
}
|
|
150
|
+
body.push(h('div', { key: 'steps', className: 'mcmp-steps' }, stepRows))
|
|
151
|
+
if (logLines) body.push(h('div', { key: 'logs', className: 'mcmp-logs' }, logLines))
|
|
152
|
+
if (fileRows.length > 0) body.push(h('div', { key: 'files', className: 'mcmp-files' }, fileRows))
|
|
153
|
+
if (snap.error) body.push(h('div', { key: 'err', className: 'mcmp-error' }, snap.error))
|
|
154
|
+
const actions = []
|
|
155
|
+
if (st === 'running') {
|
|
156
|
+
actions.push(h('button', { key: 'abort', className: 'mcmp-btn mcmp-abort', onClick: () => { fetch('/mcmp-api/abort', { method: 'POST' }).catch(() => {}) } }, '中止'))
|
|
157
|
+
} else {
|
|
158
|
+
actions.push(h('button', { key: 'reset', className: 'mcmp-btn', onClick: () => { fetch('/mcmp-api/reset', { method: 'POST' }).then(() => setDismissedRun(snap.runId)).catch(() => {}) } }, '清空状态'))
|
|
159
|
+
}
|
|
160
|
+
actions.push(h('button', { key: 'hide', className: 'mcmp-btn', onClick: () => setDismissedRun(snap.runId) }, '关闭'))
|
|
161
|
+
body.push(h('div', { key: 'actions', className: 'mcmp-actions' }, actions))
|
|
162
|
+
children.push(h('div', { key: 'body', className: 'mcmp-body' }, body))
|
|
163
|
+
}
|
|
164
|
+
return h('div', {
|
|
165
|
+
className: 'mcmp-panel',
|
|
166
|
+
style: rootStyle,
|
|
167
|
+
onPointerMove: onMove,
|
|
168
|
+
onPointerUp: onUp,
|
|
169
|
+
onPointerCancel: onUp,
|
|
170
|
+
}, children)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const inject = ["slots", "timer"]
|
|
174
|
+
|
|
175
|
+
function apply(ctx) {
|
|
176
|
+
if (typeof document !== 'undefined' && document.querySelector('style[data-plugin-css="dsh-mcmp/panel.css"]') === null) {
|
|
177
|
+
const tag = document.createElement('style')
|
|
178
|
+
tag.dataset.plugin = 'dsh-mcmp'
|
|
179
|
+
tag.dataset.pluginCss = 'dsh-mcmp/panel.css'
|
|
180
|
+
tag.textContent = CSS
|
|
181
|
+
document.head.appendChild(tag)
|
|
182
|
+
}
|
|
183
|
+
ctx.slots.inject('shell.overlay', () => ctx.slots.register(
|
|
184
|
+
{ name: 'shell.overlay', id: 'mcmp-pipeline-panel', order: 50, label: '数学建模流水线进度面板' },
|
|
185
|
+
() => h(Panel),
|
|
186
|
+
))
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
exports.apply = apply;
|
|
190
|
+
exports.inject = inject;
|
|
191
|
+
return module.exports;
|
|
192
|
+
}
|
|
193
|
+
});
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-mcmp · 数学建模论文自动化流水线 —— Host 半(持久化部署插件)
|
|
3
|
+
*
|
|
4
|
+
* 挂载于用户配置层($DSH_HOME/profiles/web/cordis.patch.yml),重启不丢。
|
|
5
|
+
* 与动态版本的区别:
|
|
6
|
+
* - 面板 RPC 由 harness.handle 改为 webServer 路由(/mcmp-api/*)
|
|
7
|
+
* - 命令/提示注册全部防御化(与动态插件共存期间冲突时静默降级)
|
|
8
|
+
* 其余编排逻辑与动态版 v6 完全一致:断点续跑、Host 侧子智能体编排、
|
|
9
|
+
* 质疑驱动、成果落盘、tool-workflow/* 会话记录(原生工作流卡片)。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const name = 'mcmp'
|
|
13
|
+
// 硬依赖:这些服务挂载后 Cordis 才会激活本插件(避免过早激活导致 ctx.get 全部为空)
|
|
14
|
+
export const inject = ['commands', 'subagents', 'agents', 'webServer']
|
|
15
|
+
|
|
16
|
+
export function apply(ctx) {
|
|
17
|
+
const fs = ctx.get('fs')
|
|
18
|
+
const commands = ctx.get('commands')
|
|
19
|
+
const agents = ctx.get('agents')
|
|
20
|
+
const subagents = ctx.get('subagents')
|
|
21
|
+
const systemPrompt = ctx.get('systemPrompt')
|
|
22
|
+
const webServer = ctx.get('webServer')
|
|
23
|
+
console.log('dsh-mcmp: services available -> commands=' + !!commands + ' fs=' + !!fs + ' agents=' + !!agents + ' subagents=' + !!subagents + ' systemPrompt=' + !!systemPrompt + ' webServer=' + !!webServer)
|
|
24
|
+
|
|
25
|
+
// ---------- 步骤元数据(含每个迭代的任务提示词) ----------
|
|
26
|
+
const STEPS = [
|
|
27
|
+
{ key: 'S1', name: '赛题分析', phase: 'S1 赛题分析', file: '01_赛题分析.md', iters: [
|
|
28
|
+
{ name: '初步拆解', task: '通读赛题,完成初步拆解:(1) 确认所选题目(如 A/B/C 题)与全部子问题;(2) 列出问题背景、已知条件、数据(附件)说明、待求目标;(3) 明确提交要求(论文页数、程序、结果表等);(4) 判断问题类型(预测、优化、评价、机理、决策等);(5) 圈出关键词与易误解之处,给出你的理解与疑问。' },
|
|
29
|
+
{ name: '深度分析', task: '对每个子问题深入分析:(1) 明确评价指标或目标函数;(2) 列出约束条件;(3) 分析数据特征(缺失、异常、量纲、规模),给出预处理方案;(4) 提出合理假设,逐条编号并说明理由;(5) 列出 2-3 种候选建模思路,逐一分析优劣与可行性;(6) 指出主要难点与风险。' },
|
|
30
|
+
{ name: '最终定调', task: '在前两轮成果基础上质疑并定稿:(1) 给出总体建模框架(问题→数据→假设→模型→求解→验证→结论);(2) 为每个子问题敲定选用的模型与方法,并说明选择理由;(3) 列出所需的编程实现清单与图表清单;(4) 给出论文结构大纲;(5) 输出《建模方案定稿》,作为后续所有步骤的权威依据。' },
|
|
31
|
+
] },
|
|
32
|
+
{ key: 'S2', name: '模型求解', phase: 'S2 模型求解', file: '02_模型求解.md', iters: [
|
|
33
|
+
{ name: '模型建立', task: '依据 01_赛题分析.md 的《建模方案定稿》:(1) 建立统一的符号体系(符号表,含含义与单位);(2) 逐条给出模型假设(编号);(3) 对每个子问题建立数学模型:目标函数、约束条件或方程,并给出推导过程;(4) 说明模型与赛题要求的对应关系;(5) 指出模型的适用范围。' },
|
|
34
|
+
{ name: '模型求解', task: '对每个已建立的模型给出求解:(1) 能解析求解的给出完整推导;(2) 需要数值求解的给出算法原理、步骤与伪代码(如最优化、微分方程数值解、统计方法、启发式算法);(3) 说明复杂度、收敛性与可行性;(4) 明确哪些结果将在步骤3中用程序实现。' },
|
|
35
|
+
{ name: '模型验证', task: '验证模型的正确性与可靠性:(1) 用特殊情形、退化情形或手算小例子验证解析结果;(2) 若数据可用,做数据驱动的验证(拟合优度、误差分析、残差检验);(3) 设计灵敏度分析方案(参数扰动范围与观察指标);(4) 总结模型的优点、不足与假设的合理性;(5) 明确哪些假设可以放宽、放宽后模型如何修正。' },
|
|
36
|
+
] },
|
|
37
|
+
{ key: 'S3', name: '编程实现', phase: 'S3 编程实现', file: '03_编程实现.md', iters: [
|
|
38
|
+
{ name: '核心代码', task: '依据 01、02 两步成果,用 Python 实现每个模型的核心算法:(1) 代码写入 代码/ 目录,文件名体现步骤与版本(如 s2_model_v1.py),配中文注释;(2) 处理数据读取与预处理;(3) 确保代码可直接运行,运行并把关键输出(数值结果、评价指标)记录到 03_编程实现.md;(4) 若环境缺少依赖,先安装(pip)再运行;(5) 旧代码一律保留。' },
|
|
39
|
+
{ name: '完善调试', task: '对已有代码严格调试:(1) 用手算小例子与极端参数做对照测试,验证正确性;(2) 修复所有 bug,处理数据缺失与异常;(3) 补充边界情形测试;(4) 补充中文注释与文档字符串;(5) 重新运行并与 02_模型求解.md 的推导核对一致;(6) 新版本用 _v2、_v3 后缀另存,绝不删除旧版本。' },
|
|
40
|
+
{ name: '优化封装', task: '将代码工程化:(1) 整理为函数/类,参数化关键量;(2) 统一输出:数值结果写入 代码/results/ 下的 CSV/JSON;(3) 提供 main 入口脚本一键复现全部结果;(4) 规范命名与注释;(5) 在 03_编程实现.md 记录运行环境(语言版本、依赖库及版本)与复现步骤;(6) 最终完整运行一次并保存全部输出。' },
|
|
41
|
+
] },
|
|
42
|
+
{ key: 'S4', name: '图表生成', phase: 'S4 图表生成', file: '04_图表生成.md', iters: [
|
|
43
|
+
{ name: '基础图表', task: '依据 03_编程实现.md 的结果数据生成论文所需的基础图表:(1) 至少包括:结果曲线、对比柱状图、误差或收敛图、数据散点与拟合对比等(按实际需要);(2) 图片保存为 PNG 到 图表/ 目录,文件名语义化;(3) 每张图配中文标题、轴标签、图例与单位;(4) 在 04_图表生成.md 记录每张图的含义、数据来源与生成代码位置;(5) 绘图代码保存到 代码/ 下。' },
|
|
44
|
+
{ name: '美化优化', task: '统一美化全部图表:(1) 统一字体大小、配色、线型风格;(2) 修复中文乱码(配置中文字体,如 SimHei 或 Microsoft YaHei);(3) 分辨率不低于 300dpi;(4) 补充关键标注(重要数值、结论性说明);(5) 确保每张图脱离正文也能看懂;(6) 更新 04_图表生成.md 中的说明。' },
|
|
45
|
+
{ name: '精选定稿', task: '对图表做最终取舍:(1) 从全部图表中精选论文真正需要的图,宁缺毋滥;(2) 确定编号(图1、图2…)与插入位置;(3) 冗余图移入 图表/归档/ 保留,不物理删除;(4) 导出最终版本;(5) 在 04_图表生成.md 输出《图表清单》:编号、文件路径、插入章节、一句话说明。' },
|
|
46
|
+
] },
|
|
47
|
+
{ key: 'S5', name: '流程与架构图', phase: 'S5 流程与架构图', file: '05_流程与架构图.md', iters: [
|
|
48
|
+
{ name: '结构设计', task: '设计论文所需的两类图:(1) 总体技术路线/流程图:问题分析→数据预处理→模型建立→求解→验证→结果→论文,体现完整流程;(2) 系统/模型架构图:模块划分与数据流。要求:节点、层次、连线语义明确。可用 graphviz(dot)、matplotlib/networkx 或 mermaid 生成;源文件保存到 代码/,图片输出到 图表/。' },
|
|
49
|
+
{ name: '细节完善', task: '检查并完善流程图与架构图:(1) 逻辑完整:每个节点有输入输出、无孤立节点、方向一致;(2) 命名与论文符号统一;(3) 补充关键公式或参数标注;(4) 调整布局避免重叠;(5) 生成修订版,并在 05_流程与架构图.md 说明每张图的结构与用途。' },
|
|
50
|
+
{ name: '美化定稿', task: '最终美化:(1) 统一配色与字体(支持中文);(2) 导出高清 PNG 或 SVG;(3) 确定图编号(如“图0 技术路线”)与插入位置;(4) 在 05_流程与架构图.md 输出最终文件清单;(5) 中间版本归档保留。' },
|
|
51
|
+
] },
|
|
52
|
+
{ key: 'S6', name: '论文撰写', phase: 'S6 论文撰写', file: '06_论文.md', iters: [
|
|
53
|
+
{ name: '初稿', task: '依据前 5 步全部成果撰写论文初稿,写入 06_论文.md(如有 LaTeX 环境,同时输出 tex 源文件到 论文/ 目录)。标准结构:(1) 摘要(单独成页、含关键词,突出方法、结果与创新);(2) 一、问题重述与分析;(3) 二、模型假设与符号说明;(4) 三、模型建立与求解(分问题撰写、公式编号);(5) 四、模型验证与灵敏度分析;(6) 五、模型评价与推广;(7) 六、参考文献;(8) 附录(程序清单)。要求:正文所有结论必须引用前序成果(图表、代码输出),不得凭空捏造数据;语言规范、逻辑严密、图表引用齐全。' },
|
|
54
|
+
{ name: '深化润色', task: '以评审专家视角逐节质疑并润色初稿:(1) 摘要是否突出方法、结果与创新,是否控制在一页;(2) 公式与符号是否前后一致、编号连续;(3) 图表引用是否齐全、编号是否正确;(4) 表述是否严谨,删除“显然”“容易得到”等无依据表述;(5) 补充灵敏度分析的量化结论;(6) 检查参考文献格式;(7) 逐段润色语言,输出终稿(在 06_论文.md 中新增“终稿”小节,保留初稿)。' },
|
|
55
|
+
] },
|
|
56
|
+
{ key: 'S7', name: '编译与合规检查', phase: 'S7 编译与合规检查', file: '07_编译与合规检查.md', iters: [
|
|
57
|
+
{ name: '编译与合规检查', task: '按国赛要求逐项检查并输出报告:(1) 摘要单独成页、含关键词;(2) 论文不含任何学校、队员、指导教师信息;(3) 正文页数符合当年要求(以赛题要求为准);(4) 图表编号与正文引用一一对应;(5) 公式编号连续;(6) 参考文献格式规范;(7) 附录含程序清单;(8) 结果表格完整、数值与程序输出一致;(9) 提交文件命名符合赛方要求(如 论文.pdf、支撑材料.zip)。若环境存在 pdflatex/xelatex,尝试编译论文为 PDF 并记录编译日志;若无 LaTeX 环境,明确说明并给出“提交前手工检查清单”。全部结论写入 07_编译与合规检查.md,并输出《合规检查报告》与《待修改清单》。' },
|
|
58
|
+
] },
|
|
59
|
+
{ key: 'S8', name: '评审改进循环', phase: 'S8 评审改进循环', file: '08_评审改进.md', iters: [
|
|
60
|
+
{ name: '评审改进', task: '以竞赛评审专家身份,按国赛评审标准(摘要约10%、假设合理性、建模科学性、求解正确性、验证充分性、写作规范性、创新性)对 06_论文.md 打分并写评语:(1) 列出必须修改的高优先级问题与建议改进项;(2) 针对高优先级问题,直接在 06_论文.md 中给出修改后的关键段落(全文修订亦可),确保论文达到可提交水准;(3) 输出《评审报告》到 08_评审改进.md;(4) 最后给出整条流水线的总结:方法、主要结果、亮点与遗留问题。' },
|
|
61
|
+
] },
|
|
62
|
+
]
|
|
63
|
+
const PER_ROUND = 19
|
|
64
|
+
const FLAT = []
|
|
65
|
+
STEPS.forEach((st, si) => { st.iters.forEach((nm, ii) => { FLAT.push({ s: si, i: ii, name: nm.name }) }) })
|
|
66
|
+
|
|
67
|
+
// ---------- 运行状态 ----------
|
|
68
|
+
function freshState() {
|
|
69
|
+
return {
|
|
70
|
+
status: 'idle', runId: '', rounds: 0, round: 0, total: 0, done: 0,
|
|
71
|
+
phase: '', agentLabel: '', logs: [], files: [], error: '',
|
|
72
|
+
title: '', wsDir: '', problemPath: '', startedAt: 0, endedAt: 0,
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
let state = freshState()
|
|
76
|
+
let active = null
|
|
77
|
+
const recording = new Map()
|
|
78
|
+
|
|
79
|
+
function fmtTime(ts) {
|
|
80
|
+
const d = new Date(ts)
|
|
81
|
+
const p = (n) => (n < 10 ? '0' + n : String(n))
|
|
82
|
+
return p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds())
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function pushLog(m) {
|
|
86
|
+
state.logs.push({ t: fmtTime(Date.now()), m })
|
|
87
|
+
if (state.logs.length > 60) state.logs.splice(0, state.logs.length - 60)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function mintRunId() {
|
|
91
|
+
return 'run-' + Date.now().toString(36) + '-' + Math.floor(Math.random() * 1679616).toString(36)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function makeController() {
|
|
95
|
+
try {
|
|
96
|
+
if (typeof AbortController === 'function') return new AbortController()
|
|
97
|
+
} catch (err) { /* ignore */ }
|
|
98
|
+
const listeners = []
|
|
99
|
+
const sig = {
|
|
100
|
+
aborted: false,
|
|
101
|
+
reason: undefined,
|
|
102
|
+
addEventListener: (t, fn) => { if (t === 'abort') listeners.push(fn) },
|
|
103
|
+
removeEventListener: () => {},
|
|
104
|
+
dispatchEvent: () => false,
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
signal: sig,
|
|
108
|
+
abort: (reason) => {
|
|
109
|
+
if (sig.aborted) return
|
|
110
|
+
sig.aborted = true
|
|
111
|
+
sig.reason = reason
|
|
112
|
+
for (const fn of listeners) { try { fn() } catch (err) { /* ignore */ } }
|
|
113
|
+
},
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function pickProvider() {
|
|
118
|
+
try {
|
|
119
|
+
const names = subagents && typeof subagents.list === 'function' ? subagents.list() : []
|
|
120
|
+
if (names && names.length > 0) return names.indexOf('spawn') >= 0 ? 'spawn' : names[0]
|
|
121
|
+
} catch (err) { /* ignore */ }
|
|
122
|
+
return undefined
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ---------- 断点续跑:从会话日志(落盘)恢复已完成迭代数 ----------
|
|
126
|
+
function completedIterations(session) {
|
|
127
|
+
if (!session || !Array.isArray(session.events)) return 0
|
|
128
|
+
const runs = []
|
|
129
|
+
let current = null
|
|
130
|
+
for (const ev of session.events) {
|
|
131
|
+
try {
|
|
132
|
+
if (ev.type === 'tool-workflow/run-start') {
|
|
133
|
+
const d = ev.data || {}
|
|
134
|
+
if (d.name === '数学建模论文流水线') {
|
|
135
|
+
current = { started: [], ended: [] }
|
|
136
|
+
runs.push(current)
|
|
137
|
+
} else {
|
|
138
|
+
current = null
|
|
139
|
+
}
|
|
140
|
+
} else if (ev.type === 'tool-workflow/agent-start' && current) {
|
|
141
|
+
const d = ev.data || {}
|
|
142
|
+
if (Number.isSafeInteger(d.seq) && d.seq >= 1) current.started[d.seq - 1] = true
|
|
143
|
+
} else if (ev.type === 'tool-workflow/agent-end' && current) {
|
|
144
|
+
const d = ev.data || {}
|
|
145
|
+
if (Number.isSafeInteger(d.seq) && d.seq >= 1) current.ended[d.seq - 1] = d.outcome === 'completed'
|
|
146
|
+
}
|
|
147
|
+
} catch (err) { /* 单条事件解析失败忽略 */ }
|
|
148
|
+
}
|
|
149
|
+
const prefix = (run) => {
|
|
150
|
+
let n = 0
|
|
151
|
+
for (let i = 0; i < run.started.length; i++) {
|
|
152
|
+
if (run.started[i] === true && run.ended[i] === true) n = i + 1
|
|
153
|
+
else break
|
|
154
|
+
}
|
|
155
|
+
return n
|
|
156
|
+
}
|
|
157
|
+
const last = runs[runs.length - 1]
|
|
158
|
+
let n = last ? prefix(last) : 0
|
|
159
|
+
if (n === 0) {
|
|
160
|
+
let m = 0
|
|
161
|
+
for (const run of runs) m = Math.max(m, prefix(run))
|
|
162
|
+
n = m
|
|
163
|
+
}
|
|
164
|
+
return n
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function snapshot() {
|
|
168
|
+
const done = state.done
|
|
169
|
+
const total = state.total
|
|
170
|
+
const pct = total > 0 ? Math.min(100, Math.floor((done * 100) / total)) : 0
|
|
171
|
+
const round = state.rounds > 0 ? Math.min(Math.floor(done / PER_ROUND) + 1, state.rounds) : 0
|
|
172
|
+
const roundStart = (round - 1) * PER_ROUND
|
|
173
|
+
const inRound = Math.max(0, Math.min(done - roundStart, PER_ROUND))
|
|
174
|
+
let cur = null
|
|
175
|
+
if (state.status === 'running' && total > 0 && done < total) {
|
|
176
|
+
const f = FLAT[done % PER_ROUND]
|
|
177
|
+
cur = { stepKey: STEPS[f.s].key, stepName: STEPS[f.s].name, stepIdx: f.s, iterIdx: f.i, iterName: f.name, iterTotal: STEPS[f.s].iters.length }
|
|
178
|
+
}
|
|
179
|
+
const counts = {}
|
|
180
|
+
for (let k = 0; k < inRound && k < FLAT.length; k++) {
|
|
181
|
+
const f = FLAT[k]
|
|
182
|
+
counts[f.s] = (counts[f.s] || 0) + 1
|
|
183
|
+
}
|
|
184
|
+
const steps = STEPS.map((st, si) => ({
|
|
185
|
+
key: st.key, name: st.name, file: st.file, phase: st.phase,
|
|
186
|
+
iterTotal: st.iters.length, iters: st.iters.map((n) => n.name),
|
|
187
|
+
done: counts[si] || 0, active: cur !== null && cur.stepIdx === si,
|
|
188
|
+
}))
|
|
189
|
+
return {
|
|
190
|
+
status: state.status, runId: state.runId, rounds: state.rounds, round, done, total, pct,
|
|
191
|
+
cur, steps, phase: state.phase, agentLabel: state.agentLabel,
|
|
192
|
+
logs: state.logs.slice(-12), files: state.files.slice(0, 80),
|
|
193
|
+
error: state.error, title: state.title, wsDir: state.wsDir, problemPath: state.problemPath,
|
|
194
|
+
startedAt: state.startedAt, endedAt: state.endedAt,
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function refreshFiles() {
|
|
199
|
+
if (!fs || !state.wsDir) return
|
|
200
|
+
try {
|
|
201
|
+
const rootT = await fs.resolve(state.wsDir)
|
|
202
|
+
const rootL = await fs.listDir(rootT)
|
|
203
|
+
const files = []
|
|
204
|
+
for (const e of rootL) {
|
|
205
|
+
if (files.length >= 80) break
|
|
206
|
+
files.push({ name: e.name, type: e.type === 'directory' ? 'dir' : 'file' })
|
|
207
|
+
if (e.type === 'directory') {
|
|
208
|
+
try {
|
|
209
|
+
const sub = await fs.listDir(e.target)
|
|
210
|
+
for (const se of sub) {
|
|
211
|
+
if (files.length >= 80) break
|
|
212
|
+
files.push({ name: e.name + '/' + se.name, type: se.type === 'directory' ? 'dir' : 'file' })
|
|
213
|
+
}
|
|
214
|
+
} catch (err) { /* 子目录读取失败忽略 */ }
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
state.files = files
|
|
218
|
+
} catch (err) { /* 目录尚不存在时忽略 */ }
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function messageText(data) {
|
|
222
|
+
let m = null
|
|
223
|
+
if (data && Array.isArray(data.content)) m = data
|
|
224
|
+
else if (data && data.message && Array.isArray(data.message.content)) m = data.message
|
|
225
|
+
if (!m) return ''
|
|
226
|
+
let text = ''
|
|
227
|
+
for (const block of m.content) {
|
|
228
|
+
if (block && block.type === 'text' && typeof block.text === 'string') text += block.text
|
|
229
|
+
}
|
|
230
|
+
return text
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function extractProblem(session) {
|
|
234
|
+
if (!session || !Array.isArray(session.events)) return ''
|
|
235
|
+
const parts = []
|
|
236
|
+
const recent = session.events.slice(-80)
|
|
237
|
+
for (const ev of recent) {
|
|
238
|
+
if (ev.type !== 'user/message') continue
|
|
239
|
+
const text = messageText(ev.data)
|
|
240
|
+
if (text) parts.push(text)
|
|
241
|
+
}
|
|
242
|
+
const joined = parts.join('\n')
|
|
243
|
+
const filtered = joined.split(/\r?\n/).filter((l) => !/^\s*\/[a-z][a-z0-9_-]*\b/i.test(l)).join('\n')
|
|
244
|
+
return filtered.trim()
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function buildPrompt(round, si, ii, s, it, args) {
|
|
248
|
+
const p = []
|
|
249
|
+
p.push('你正在参与「全国大学生数学建模竞赛论文」的多轮自动化撰写流水线,担任本轮「' + s.name + ' · ' + it.name + '」的执行专家。你的目标不是"写完",而是产出**可直接放进竞赛论文、经得起评审质疑**的内容。')
|
|
250
|
+
p.push('')
|
|
251
|
+
p.push('【流水线位置】第 ' + round + '/' + args.rounds + ' 轮 · 步骤 ' + (si + 1) + '/' + STEPS.length + ' ' + s.name + ' · 迭代 ' + (ii + 1) + '/' + s.iters.length + '「' + it.name + '」')
|
|
252
|
+
p.push('【工作区(绝对路径)】' + args.wsDir + ' —— 所有读写都在该目录内,使用绝对路径或相对它的路径。')
|
|
253
|
+
if (args.problem.kind === 'file') {
|
|
254
|
+
p.push('【赛题原文文件】' + args.problem.path + ' —— 先用你的文件工具读取该文件全文,作为唯一赛题依据。')
|
|
255
|
+
} else {
|
|
256
|
+
p.push('【赛题原文(已完整嵌入,请逐字精读)】')
|
|
257
|
+
p.push(args.problem.text)
|
|
258
|
+
if (si === 0 && ii === 0) p.push('【先存档】请先把嵌入的赛题原文完整保存为 ' + args.problemPath + ' 文件,供后续所有步骤读取。')
|
|
259
|
+
}
|
|
260
|
+
p.push('')
|
|
261
|
+
p.push('【必读文件(先全部读完再动手,禁止跳过)】')
|
|
262
|
+
p.push('- ' + s.file + ' (本步骤历次迭代成果,本次在其基础上改进)')
|
|
263
|
+
for (let q = 0; q < si; q++) p.push('- ' + STEPS[q].file + ' (前序步骤的权威成果,必须严格遵守其结论与符号体系)')
|
|
264
|
+
if (round > 1) {
|
|
265
|
+
p.push('上一轮(第 ' + (round - 1) + ' 轮)成果已保存在上述文件中(标题形如「## 第' + (round - 1) + '轮…」)。先阅读上一轮对应内容,本轮必须在其基础上优化,质量不得低于上一轮。')
|
|
266
|
+
}
|
|
267
|
+
if (ii === 0) {
|
|
268
|
+
p.push('若本步骤文件尚不存在或为空,从零建立本步骤成果。')
|
|
269
|
+
} else {
|
|
270
|
+
p.push('若文件中缺失上一迭代(「第' + round + '轮·迭代' + ii + ' ' + s.iters[ii - 1].name + '」)成果,说明其失败:先补齐该迭代成果,再完成本次迭代。')
|
|
271
|
+
}
|
|
272
|
+
p.push('')
|
|
273
|
+
p.push('【工作纪律】')
|
|
274
|
+
p.push('1. 质疑先行:开工前对既有成果提出至少 3 条具体质疑(假设是否成立、是否偏离赛题、推导有无漏洞、数据与结论是否一致、格式是否规范),并逐条给出处理决定(采纳并改进 / 否决并说明理由)。质疑清单必须放在回复最前面。')
|
|
275
|
+
p.push('2. 只改有据之处:经得起质疑的既有内容必须保留,不做无谓重写。')
|
|
276
|
+
p.push('3. 禁止编造:所有数值与结论必须来自赛题、实际计算或前序成果;信息不足时明确写出你的假设并说明理由,不得假装已知。')
|
|
277
|
+
p.push('4. 符号与术语一致:沿用前序步骤的符号体系,新增符号必须先定义。')
|
|
278
|
+
p.push('5. 论文级表达:推导完整、步骤清晰、结论可复现,杜绝"显然""易得"式跳步。')
|
|
279
|
+
p.push('')
|
|
280
|
+
p.push('【本轮任务】' + it.task)
|
|
281
|
+
p.push('')
|
|
282
|
+
p.push('【交付要求(强制)】')
|
|
283
|
+
p.push('1. 完整成果追加写入 ' + s.file + ',标题「## 第' + round + '轮·迭代' + (ii + 1) + ' ' + it.name + '」;内容完整可复现,不得缩写,严禁删除或覆盖已有内容。')
|
|
284
|
+
p.push('2. 代码保存到 ' + args.wsDir + '/代码/,新版本用 _vN 后缀,旧版本一律保留;图片保存到 ' + args.wsDir + '/图表/,旧图保留。')
|
|
285
|
+
p.push('')
|
|
286
|
+
p.push('【回复结构】开头为「质疑清单:」;随后是本次工作与成果;结尾附不超过 200 字总结:完成内容、关键改进、产出文件清单(含路径)。')
|
|
287
|
+
return p.join('\n')
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function appendRec(runId, session, type, data) {
|
|
291
|
+
const rec = recording.get(runId)
|
|
292
|
+
if (!rec || !rec.ok) return false
|
|
293
|
+
try {
|
|
294
|
+
session.append(type, data)
|
|
295
|
+
return true
|
|
296
|
+
} catch (err) {
|
|
297
|
+
rec.ok = false
|
|
298
|
+
return false
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function finishRun(runId, stopReason, errorMsg) {
|
|
303
|
+
const rec = recording.get(runId)
|
|
304
|
+
if (rec) {
|
|
305
|
+
if (rec.ok) {
|
|
306
|
+
try { rec.session.append('tool-workflow/run-end', { runId, stopReason }) } catch (err) { /* ignore */ }
|
|
307
|
+
}
|
|
308
|
+
recording.delete(runId)
|
|
309
|
+
}
|
|
310
|
+
if (state.runId === runId) {
|
|
311
|
+
state.status = stopReason
|
|
312
|
+
state.endedAt = Date.now()
|
|
313
|
+
if (stopReason === 'error') state.error = errorMsg || '流水线执行出错'
|
|
314
|
+
if (stopReason === 'cancelled') state.error = '已被用户中止'
|
|
315
|
+
state.agentLabel = ''
|
|
316
|
+
}
|
|
317
|
+
refreshFiles()
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function doAbort() {
|
|
321
|
+
if (!active) return { ok: false, reason: '没有运行中的流水线' }
|
|
322
|
+
try { active.controller.abort(new Error('用户在进度面板中中止')) } catch (err) { /* ignore */ }
|
|
323
|
+
return { ok: true }
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function doReset() {
|
|
327
|
+
if (state.status === 'running') return { ok: false, reason: '流水线运行中,不能重置' }
|
|
328
|
+
state = freshState()
|
|
329
|
+
active = null
|
|
330
|
+
return { ok: true }
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ---------- Host 侧编排器:从断点继续(不依赖聊天 Agent 存活) ----------
|
|
334
|
+
async function runPipeline(runId, session, agent, args, controller) {
|
|
335
|
+
let failStreak = 0
|
|
336
|
+
let cancelled = false
|
|
337
|
+
const provider = pickProvider()
|
|
338
|
+
if (!provider) {
|
|
339
|
+
finishRun(runId, 'error', '没有可用的子智能体提供方(当前部署未注册 subagent provider)')
|
|
340
|
+
if (active && active.runId === runId) active = null
|
|
341
|
+
return
|
|
342
|
+
}
|
|
343
|
+
const total = args.rounds * PER_ROUND
|
|
344
|
+
for (let g = args.startIndex; g < total; g++) {
|
|
345
|
+
if (controller.signal.aborted) { cancelled = true; break }
|
|
346
|
+
const r = Math.floor(g / PER_ROUND) + 1
|
|
347
|
+
const f = FLAT[g % PER_ROUND]
|
|
348
|
+
const s = STEPS[f.s]
|
|
349
|
+
const it = s.iters[f.i]
|
|
350
|
+
if (g % PER_ROUND === 0) pushLog('【第 ' + r + '/' + args.rounds + ' 轮开始】')
|
|
351
|
+
const label = 'R' + r + '·S' + (f.s + 1) + ' ' + s.name + ' ' + (f.i + 1) + '/' + s.iters.length + ' ' + it.name
|
|
352
|
+
state.phase = s.phase
|
|
353
|
+
state.agentLabel = label
|
|
354
|
+
pushLog('开始: ' + label)
|
|
355
|
+
let child = null
|
|
356
|
+
let started = false
|
|
357
|
+
let outcome = 'failed'
|
|
358
|
+
let note = ''
|
|
359
|
+
try {
|
|
360
|
+
// 每次启动子任务前重新解析父 Agent(关闭聊天后 Agent 可能被重建)
|
|
361
|
+
const parent = (agents && agents.get(session.id)) || agent
|
|
362
|
+
child = await subagents.start(provider, {
|
|
363
|
+
label,
|
|
364
|
+
prompt: [{ type: 'text', text: buildPrompt(r, f.s, f.i, s, it, args) }],
|
|
365
|
+
parent,
|
|
366
|
+
signal: controller.signal,
|
|
367
|
+
})
|
|
368
|
+
appendRec(runId, session, 'tool-workflow/agent-start', { runId, seq: g + 1, label, phase: s.phase, childId: String(child.id) })
|
|
369
|
+
started = true
|
|
370
|
+
const res = await child.result
|
|
371
|
+
const text = (Array.isArray(res.output) ? res.output.filter((b) => b && b.type === 'text').map((b) => b.text).join('') : '')
|
|
372
|
+
outcome = res.stopReason === 'completed' ? 'completed' : res.stopReason === 'cancelled' ? 'cancelled' : 'failed'
|
|
373
|
+
note = (text || '').trim().slice(0, 300)
|
|
374
|
+
} catch (err) {
|
|
375
|
+
outcome = controller.signal.aborted ? 'cancelled' : 'failed'
|
|
376
|
+
note = '执行异常: ' + String((err && err.message) || err).slice(0, 200)
|
|
377
|
+
} finally {
|
|
378
|
+
if (child) { try { await child.dispose() } catch (err) { /* ignore */ } }
|
|
379
|
+
}
|
|
380
|
+
if (started) appendRec(runId, session, 'tool-workflow/agent-end', { runId, seq: g + 1, outcome })
|
|
381
|
+
state.done = Math.min(g + 1, state.total)
|
|
382
|
+
refreshFiles()
|
|
383
|
+
if (outcome === 'completed') {
|
|
384
|
+
failStreak = 0
|
|
385
|
+
pushLog('完成: ' + label)
|
|
386
|
+
} else {
|
|
387
|
+
failStreak += 1
|
|
388
|
+
pushLog(outcome === 'cancelled' ? '中止: ' : '失败: ' + label + (note ? '(' + note.slice(0, 80) + ')' : ''))
|
|
389
|
+
}
|
|
390
|
+
if ((g + 1) % PER_ROUND === 0 && !controller.signal.aborted) pushLog('【第 ' + r + '/' + args.rounds + ' 轮完成】')
|
|
391
|
+
if (controller.signal.aborted) { cancelled = true; break }
|
|
392
|
+
if (failStreak >= 3) {
|
|
393
|
+
finishRun(runId, 'error', '连续 3 个子任务失败,流水线提前终止(已有成果全部保留,可重新 /loopbegin 续跑)')
|
|
394
|
+
if (active && active.runId === runId) active = null
|
|
395
|
+
return
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
finishRun(runId, cancelled ? 'cancelled' : 'completed', cancelled ? '已被用户中止' : undefined)
|
|
399
|
+
if (active && active.runId === runId) active = null
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// ---------- 共享的流水线启动逻辑(同步执行,永不阻塞命令通道) ----------
|
|
403
|
+
function startPipeline(agent, rawInput) {
|
|
404
|
+
try {
|
|
405
|
+
if (state.status === 'running') {
|
|
406
|
+
return { kind: 'error', text: '已有流水线正在运行(当前进度 ' + (state.total > 0 ? Math.floor((state.done * 100) / state.total) : 0) + '%)。可在浮动面板点「中止」或运行 /loopabort 后再启动新流水线。' }
|
|
407
|
+
}
|
|
408
|
+
if (!subagents) return { kind: 'error', text: '子智能体服务不可用(当前部署未提供),无法启动流水线。' }
|
|
409
|
+
const session = agent && agent.session
|
|
410
|
+
const cwd = session && session.header ? session.header.cwd : undefined
|
|
411
|
+
if (typeof cwd !== 'string' || cwd.length === 0) {
|
|
412
|
+
return { kind: 'error', text: '无法确定当前会话的工作区目录,无法启动流水线。' }
|
|
413
|
+
}
|
|
414
|
+
const raw = rawInput || ''
|
|
415
|
+
const mRound = /(?:^|\s)--round\s*[=:]?\s*(\d+)/i.exec(raw)
|
|
416
|
+
let rounds = 1
|
|
417
|
+
if (mRound) rounds = parseInt(mRound[1], 10)
|
|
418
|
+
if (!Number.isSafeInteger(rounds) || rounds < 1) rounds = 1
|
|
419
|
+
if (rounds > 10) return { kind: 'error', text: '--round 最大为 10。每轮 19 次迭代、每次迭代启动一个专家子智能体,轮数过大会非常耗时。' }
|
|
420
|
+
const fresh = /(?:^|\s)--fresh\b/i.test(raw)
|
|
421
|
+
const mFrom = /(?:^|\s)--from(?:=)?\s*([^\r\n]+)$/i.exec(raw)
|
|
422
|
+
let problem = null
|
|
423
|
+
if (mFrom) {
|
|
424
|
+
let p = mFrom[1].trim().replace(/^["']|["']$/g, '')
|
|
425
|
+
if (p.length === 0) return { kind: 'error', text: '--from 需要指定赛题文件路径。' }
|
|
426
|
+
const abs = /^[A-Za-z]:[\\/]/.test(p) || p.indexOf('/') === 0 || p.indexOf('\\') === 0
|
|
427
|
+
? p
|
|
428
|
+
: cwd.replace(/[\\/]+$/, '') + '/' + p
|
|
429
|
+
problem = { kind: 'file', path: abs }
|
|
430
|
+
} else {
|
|
431
|
+
const text = extractProblem(session)
|
|
432
|
+
if (text.trim().length < 40) {
|
|
433
|
+
return { kind: 'error', text: '未能从对话中提取到赛题。请先把完整赛题粘贴到对话框并发送,再发送以 /loopbegin 开头的消息;或使用 /loopbegin --from 题目.txt 指定赛题文件。' }
|
|
434
|
+
}
|
|
435
|
+
problem = { kind: 'text', text: text.length > 6000 ? text.slice(0, 6000) + '\n…(原文过长,已截断)' : text }
|
|
436
|
+
}
|
|
437
|
+
const wsDir = cwd.replace(/[\\/]+$/, '') + '/数学建模流水线'
|
|
438
|
+
const problemPath = problem.kind === 'file' ? problem.path : wsDir + '/00_赛题原文.md'
|
|
439
|
+
const title = problem.kind === 'text' ? problem.text.split(/\r?\n/).map((l) => l.trim()).filter((l) => l.length > 0)[0] : problem.path
|
|
440
|
+
const args = { rounds, wsDir, problemPath, problem, title: title ? title.slice(0, 40) : '赛题' }
|
|
441
|
+
// 断点续跑:扫描本会话落盘的工作流记录,跳过已完成的迭代
|
|
442
|
+
const resumeCount = fresh ? 0 : completedIterations(session)
|
|
443
|
+
args.startIndex = resumeCount
|
|
444
|
+
if (resumeCount >= rounds * PER_ROUND) {
|
|
445
|
+
return { kind: 'error', text: '检测到本会话已完成 ' + resumeCount + ' 次迭代(≥ 本次请求的 ' + (rounds * PER_ROUND) + ' 次),无需重复。\n- 如需继续优化,请增大轮数,例如 /loopbegin --round=' + Math.min(rounds + 1, 10) + '\n- 如需从头重跑,请使用 /loopbegin --fresh' }
|
|
446
|
+
}
|
|
447
|
+
const runId = mintRunId()
|
|
448
|
+
const rec = { session, ok: true }
|
|
449
|
+
recording.set(runId, rec)
|
|
450
|
+
try {
|
|
451
|
+
session.append('tool-workflow/run-start', { runId, name: '数学建模论文流水线' })
|
|
452
|
+
} catch (err) {
|
|
453
|
+
recording.delete(runId)
|
|
454
|
+
}
|
|
455
|
+
state = freshState()
|
|
456
|
+
state.status = 'running'
|
|
457
|
+
state.runId = runId
|
|
458
|
+
state.rounds = rounds
|
|
459
|
+
state.total = rounds * PER_ROUND
|
|
460
|
+
state.done = resumeCount
|
|
461
|
+
state.round = Math.min(Math.floor(resumeCount / PER_ROUND) + 1, rounds)
|
|
462
|
+
state.startedAt = Date.now()
|
|
463
|
+
state.wsDir = wsDir
|
|
464
|
+
state.problemPath = problemPath
|
|
465
|
+
state.title = args.title
|
|
466
|
+
const controller = makeController()
|
|
467
|
+
active = { runId, session, controller }
|
|
468
|
+
pushLog('流水线已启动: ' + rounds + ' 轮 × 19 次迭代,输出目录 ' + wsDir + (resumeCount > 0 ? '(断点续跑:跳过已完成的 ' + resumeCount + ' 次迭代)' : '') + '(关闭本聊天窗口不影响后台执行)')
|
|
469
|
+
refreshFiles()
|
|
470
|
+
void runPipeline(runId, session, agent, args, controller).catch((err) => {
|
|
471
|
+
pushLog('流水线异常: ' + String((err && err.message) || err))
|
|
472
|
+
finishRun(runId, 'error', '流水线异常: ' + String((err && err.message) || err))
|
|
473
|
+
if (active && active.runId === runId) active = null
|
|
474
|
+
})
|
|
475
|
+
return {
|
|
476
|
+
kind: 'success',
|
|
477
|
+
text: '数学建模论文自动化流水线已启动:' + rounds + ' 轮 × 19 次迭代 = ' + (rounds * PER_ROUND) + ' 个子任务。\n输出目录: ' + wsDir
|
|
478
|
+
+ (resumeCount > 0 ? '\n断点续跑:已跳过此前完成的 ' + resumeCount + ' 次迭代,从第 ' + (resumeCount + 1) + ' 次继续。' : '\n全新运行。')
|
|
479
|
+
+ '\n每个子任务由专家子智能体执行,先对已有成果质疑、再迭代优化,并把完整成果追加保存到对应步骤文件(旧内容绝不删除)。\n进度请在右下角浮动面板实时查看(含百分比),聊天区也会出现流水线运行卡片。\n提示:中断或关闭聊天窗口都不会丢进度,再次发送 /loopbegin 即从断点续跑;如要重头开始请加 --fresh。',
|
|
480
|
+
}
|
|
481
|
+
} catch (err) {
|
|
482
|
+
return { kind: 'error', text: '启动失败: ' + String((err && err.message) || err) }
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// ---------- 触发路径一:斜杠命令注册(防御化:与动态插件共存时冲突静默降级) ----------
|
|
487
|
+
if (commands) {
|
|
488
|
+
try {
|
|
489
|
+
commands.register({
|
|
490
|
+
name: 'loopbegin',
|
|
491
|
+
description: '启动数学建模论文自动化流水线(8 步骤、每步多迭代、多轮外循环,自动质疑与优化,成果全部落盘)',
|
|
492
|
+
input: { hint: '--round=N 外循环轮数(默认 1);可选 --from 题目文件 指定赛题文件;--fresh 从头重跑' },
|
|
493
|
+
handler: (invocation) => startPipeline(invocation.agent, invocation.rawInput),
|
|
494
|
+
})
|
|
495
|
+
} catch (err) { console.warn('dsh-mcmp: loopbegin 注册失败(可能被其他实例占用): ' + String((err && err.message) || err)) }
|
|
496
|
+
try {
|
|
497
|
+
commands.register({
|
|
498
|
+
name: 'loopabort',
|
|
499
|
+
description: '中止正在运行的数学建模论文流水线',
|
|
500
|
+
handler: () => {
|
|
501
|
+
if (!active) return { kind: 'error', text: '当前没有运行中的流水线。' }
|
|
502
|
+
try { active.controller.abort(new Error('用户通过 /loopabort 中止')) } catch (err) { /* ignore */ }
|
|
503
|
+
return { kind: 'success', text: '已发送中止请求,流水线将在当前子任务结束后停止(已产出的成果全部保留,重新 /loopbegin 可从断点续跑)。' }
|
|
504
|
+
},
|
|
505
|
+
})
|
|
506
|
+
} catch (err) { console.warn('dsh-mcmp: loopabort 注册失败: ' + String((err && err.message) || err)) }
|
|
507
|
+
try {
|
|
508
|
+
commands.register({
|
|
509
|
+
name: 'loopstatus',
|
|
510
|
+
description: '查看数学建模论文流水线的当前进度',
|
|
511
|
+
handler: () => {
|
|
512
|
+
if (state.status === 'idle') return { kind: 'success', text: '流水线空闲。粘贴赛题后发送以 /loopbegin 开头的消息(可带 --round=N)即可启动。' }
|
|
513
|
+
const s = snapshot()
|
|
514
|
+
let text = '状态: ' + ({ running: '运行中', completed: '已完成', error: '出错', cancelled: '已中止' }[state.status] || state.status)
|
|
515
|
+
text += '\n进度: 第 ' + s.round + '/' + s.rounds + ' 轮,已完成 ' + s.done + '/' + s.total + ' 次迭代(' + s.pct + '%)'
|
|
516
|
+
if (s.cur) text += '\n当前: ' + s.cur.stepKey + ' ' + s.cur.stepName + ' · 迭代 ' + (s.cur.iterIdx + 1) + '/' + s.cur.iterTotal + ' ' + s.cur.iterName
|
|
517
|
+
if (s.agentLabel) text += '\n子任务: ' + s.agentLabel
|
|
518
|
+
if (state.error) text += '\n错误: ' + state.error
|
|
519
|
+
text += '\n输出目录: ' + (state.wsDir || '-')
|
|
520
|
+
return { kind: 'success', text }
|
|
521
|
+
},
|
|
522
|
+
})
|
|
523
|
+
} catch (err) { console.warn('dsh-mcmp: loopstatus 注册失败: ' + String((err && err.message) || err)) }
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// ---------- 触发路径二:消息内容检测(输入文本以 /loopbegin 开头即自动运行) ----------
|
|
527
|
+
ctx.on('session/event', (session, event) => {
|
|
528
|
+
try {
|
|
529
|
+
if (!session || !event || event.type !== 'user/message') return
|
|
530
|
+
if (session.header && (session.header.origin === 'subagent' || session.header.delegationDepth !== undefined)) return
|
|
531
|
+
const text = messageText(event.data)
|
|
532
|
+
if (!text) return
|
|
533
|
+
const firstLine = text.split(/\r?\n/, 1)[0] || ''
|
|
534
|
+
if (!/^\s*\/loopbegin(?=$|\s)/i.test(firstLine)) return
|
|
535
|
+
const agent = agents && agents.get(session.id)
|
|
536
|
+
if (!agent) return
|
|
537
|
+
console.log('dsh-mcmp: 检测到以 /loopbegin 开头的用户消息,自动启动流水线')
|
|
538
|
+
const res = startPipeline(agent, firstLine)
|
|
539
|
+
if (res && res.kind === 'error') pushLog('消息触发启动失败: ' + res.text)
|
|
540
|
+
} catch (err) { /* 触发器自身异常忽略,不影响消息流 */ }
|
|
541
|
+
}, { global: true })
|
|
542
|
+
|
|
543
|
+
// ---------- 模型侧提示:收到 /loopbegin 消息时不要重复执行 ----------
|
|
544
|
+
if (systemPrompt) {
|
|
545
|
+
try {
|
|
546
|
+
systemPrompt.section({
|
|
547
|
+
name: 'mcmp-pipeline',
|
|
548
|
+
order: 130,
|
|
549
|
+
text: '数学建模流水线说明:当用户消息以 /loopbegin 开头(可带 --round=N、--from 文件或 --fresh 参数)时,插件已自动在后台启动「数学建模论文自动化流水线」(8 步骤、每轮 19 次迭代,由工作流子智能体执行)。此时你只需用一两句话确认已启动,并提示用户查看右下角进度面板与聊天区的工作流运行卡片;绝对不要自己去编写或执行该流水线的任何步骤,不要重复启动。若用户消息包含赛题但未以 /loopbegin 开头,说明尚未启动流水线,不要擅自运行。辅助命令 /loopstatus、/loopabort 由系统处理,你无需执行。',
|
|
550
|
+
})
|
|
551
|
+
} catch (err) { console.warn('dsh-mcmp: systemPrompt 注册失败: ' + String((err && err.message) || err)) }
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// ---------- 面板 API:webServer 路由(替代动态插件的 harness RPC) ----------
|
|
555
|
+
if (webServer && typeof webServer.register === 'function') {
|
|
556
|
+
try {
|
|
557
|
+
webServer.register({
|
|
558
|
+
kind: 'prefix',
|
|
559
|
+
path: '/mcmp-api',
|
|
560
|
+
handler: (req, res) => {
|
|
561
|
+
try {
|
|
562
|
+
// prefix 路由会把完整 URL 交给处理器,先剥离 /mcmp-api 前缀
|
|
563
|
+
const raw = (req.url || '/').split('?')[0] || '/'
|
|
564
|
+
const path = raw === '/mcmp-api' ? '/' : raw.startsWith('/mcmp-api/') ? raw.slice('/mcmp-api'.length) : raw
|
|
565
|
+
const json = (code, body) => {
|
|
566
|
+
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' })
|
|
567
|
+
res.end(JSON.stringify(body))
|
|
568
|
+
}
|
|
569
|
+
if (req.method === 'GET' && (path === '/' || path === '/state')) return json(200, snapshot())
|
|
570
|
+
if (req.method === 'POST' && path === '/abort') return json(200, doAbort())
|
|
571
|
+
if (req.method === 'POST' && path === '/reset') return json(200, doReset())
|
|
572
|
+
res.writeHead(404)
|
|
573
|
+
res.end('not found')
|
|
574
|
+
} catch (err) {
|
|
575
|
+
try { res.writeHead(500); res.end(String((err && err.message) || err)) } catch (err2) { /* ignore */ }
|
|
576
|
+
}
|
|
577
|
+
},
|
|
578
|
+
})
|
|
579
|
+
console.log('dsh-mcmp: /mcmp-api 面板路由已注册')
|
|
580
|
+
} catch (err) { console.warn('dsh-mcmp: webServer 路由注册失败: ' + String((err && err.message) || err)) }
|
|
581
|
+
}
|
|
582
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-mcmp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "DeepSeek Harness 插件:数学建模论文自动化流水线(8 步骤 × 19 迭代 × N 轮、质疑驱动、断点续跑、进度面板)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./lib/index.js",
|
|
9
|
+
"./client": "./lib/client.js",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"lib"
|
|
14
|
+
],
|
|
15
|
+
"dsh": {
|
|
16
|
+
"client": {
|
|
17
|
+
"platform": "web",
|
|
18
|
+
"inject": [
|
|
19
|
+
"@deepseek-ai/dsh-client-runtime"
|
|
20
|
+
]
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"deepseek-harness",
|
|
25
|
+
"dsh",
|
|
26
|
+
"cordis-plugin",
|
|
27
|
+
"math-modeling",
|
|
28
|
+
"workflow",
|
|
29
|
+
"automation"
|
|
30
|
+
],
|
|
31
|
+
"license": "MIT"
|
|
32
|
+
}
|