@co0ontty/wand 4.77.0 → 4.78.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 +21 -46
- package/dist/agent-dispatch.js +3 -2
- package/dist/ai-team-live.d.ts +25 -0
- package/dist/ai-team-live.js +135 -0
- package/dist/ai-team-prompts.d.ts +10 -6
- package/dist/ai-team-prompts.js +74 -41
- package/dist/ai-team-runner.d.ts +85 -3
- package/dist/ai-team-runner.js +336 -33
- package/dist/ai-team-types.d.ts +57 -2
- package/dist/ai-team-types.js +4 -0
- package/dist/build-info.json +3 -3
- package/dist/commit-task-archive.js +14 -18
- package/dist/config.js +14 -6
- package/dist/git-quick-commit.js +1 -1
- package/dist/middleware/rate-limit.d.ts +18 -4
- package/dist/middleware/rate-limit.js +55 -23
- package/dist/missions.js +0 -3
- package/dist/models.d.ts +6 -1
- package/dist/models.js +35 -0
- package/dist/path-repair.d.ts +6 -5
- package/dist/path-repair.js +3 -3
- package/dist/render-protocol.d.ts +1 -1
- package/dist/render-protocol.js +1 -1
- package/dist/server-ai-team-routes.d.ts +1 -0
- package/dist/server-ai-team-routes.js +28 -14
- package/dist/server-local-preview-routes.js +1 -1
- package/dist/server-session-routes.js +0 -3
- package/dist/server-settings-routes.d.ts +1 -1
- package/dist/server-settings-routes.js +3 -2
- package/dist/server-task-routes.js +16 -32
- package/dist/server-workspace-routes.js +31 -24
- package/dist/server.js +60 -21
- package/dist/storage.d.ts +54 -21
- package/dist/storage.js +485 -122
- package/dist/types.d.ts +7 -0
- package/dist/upload-routes.d.ts +2 -2
- package/dist/upload-routes.js +10 -3
- package/dist/wand-task-sync.d.ts +2 -34
- package/dist/wand-task-sync.js +0 -206
- package/dist/web-ui/asset-version.d.ts +5 -0
- package/dist/web-ui/asset-version.js +5 -0
- package/dist/web-ui/content/ai-teams.js +17 -8
- package/dist/web-ui/content/scripts.js +67 -66
- package/dist/web-ui/content/styles.css +1 -1
- package/dist/web-ui/content/tailwind.css +0 -21
- package/dist/web-ui/embedded-assets.js +8 -7
- package/dist/web-ui/index.js +9 -17
- package/dist/web-ui/scripts.d.ts +3 -1
- package/dist/web-ui/scripts.js +52 -25
- package/dist/web-ui/styles.d.ts +2 -0
- package/dist/web-ui/styles.js +12 -0
- package/package.json +13 -12
- package/scripts/install-render-binary.js +2 -2
- package/scripts/stage-render-binaries.js +1 -1
package/README.md
CHANGED
|
@@ -13,15 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
Wand is a web console for remotely accessing and managing local CLI tools from a browser. It supports [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Codex](https://github.com/openai/codex), [OpenCode](https://opencode.ai/), [Grok Build](https://grok.com/), [Qoder CLI](https://docs.qoder.com/en/cli/quick-start), and [Pi](https://pi.dev/), with terminal and structured conversation views, persistent resumable sessions, permission controls, file browsing, and native clients for multiple platforms.
|
|
15
15
|
|
|
16
|
-
The browser password manager extension source lives in `browser-extension
|
|
17
|
-
|
|
18
|
-
<p align="center">
|
|
19
|
-
<img src="docs/screenshots/clients-overview.png" width="100%" alt="Web centered with iOS and Android clients on both sides" />
|
|
20
|
-
</p>
|
|
21
|
-
|
|
22
|
-
<p align="center">
|
|
23
|
-
<img src="docs/screenshots/hero.png" width="100%" alt="PTY terminal and structured conversation views" />
|
|
24
|
-
</p>
|
|
16
|
+
The browser password manager extension source lives in `browser-extension/`.
|
|
25
17
|
|
|
26
18
|
### Installation
|
|
27
19
|
|
|
@@ -135,14 +127,20 @@ Use an isolated config for testing:
|
|
|
135
127
|
npm run dev -- -c /tmp/wand-test/config.json
|
|
136
128
|
```
|
|
137
129
|
|
|
138
|
-
|
|
130
|
+
For server development, check out only the pinned Render binaries. Native clients and
|
|
131
|
+
Rust source are independent submodules; initialize the one you are changing:
|
|
139
132
|
|
|
140
133
|
```bash
|
|
141
|
-
git clone
|
|
142
|
-
|
|
143
|
-
git submodule update --init
|
|
134
|
+
git clone https://github.com/co0ontty/wand.git
|
|
135
|
+
cd wand
|
|
136
|
+
git submodule update --init -- render-bin
|
|
137
|
+
# Optional: git submodule update --init -- android # or ios, macos, render
|
|
144
138
|
```
|
|
145
139
|
|
|
140
|
+
The full cross-platform test suite reads native source contracts, so initialize
|
|
141
|
+
`android ios macos render` before running `npm test`. To preview removable compiler
|
|
142
|
+
caches, run `npm run clean:build-cache`; add `-- --apply` to remove them.
|
|
143
|
+
|
|
146
144
|
Runtime data is stored under `~/.wand/`: `config.json`, `wand.db`, and `sessions/`.
|
|
147
145
|
|
|
148
146
|
OpenCode structured sessions require the current `opencode-ai` CLI (0.1 or newer). Remove the unrelated legacy `opencode` 0.0.x package before installing it:
|
|
@@ -158,14 +156,6 @@ npm install -g opencode-ai@latest
|
|
|
158
156
|
|
|
159
157
|
通过浏览器远程访问和管理本地 CLI 工具的 Web 控制台。支持 [Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[Codex](https://github.com/openai/codex)、[OpenCode](https://opencode.ai/)、[Grok Build](https://grok.com/)、[Qoder CLI](https://docs.qoder.com/zh/cli/quick-start) 和 [Pi](https://pi.dev/),提供终端和结构化对话双视图、会话持久化与恢复、权限管控、文件浏览和多平台客户端。
|
|
160
158
|
|
|
161
|
-
<p align="center">
|
|
162
|
-
<img src="docs/screenshots/clients-overview.png" width="100%" alt="Web 居中,iOS 和 Android 客户端在两侧的多端详情总览" />
|
|
163
|
-
</p>
|
|
164
|
-
|
|
165
|
-
<p align="center">
|
|
166
|
-
<img src="docs/screenshots/hero.png" width="100%" alt="PTY 终端与结构化对话双视图" />
|
|
167
|
-
</p>
|
|
168
|
-
|
|
169
159
|
### 安装
|
|
170
160
|
|
|
171
161
|
选择下面任意一种安装方式:
|
|
@@ -226,24 +216,6 @@ npm install -g opencode-ai@latest
|
|
|
226
216
|
- **版本管理** — 内置更新检查与升级提示,支持 stable/beta 双通道
|
|
227
217
|
- **CLI 更新** — 在服务端设置中检查并快速更新 Claude Code、Codex、OpenCode、Qoder CLI 和 Pi,也可开启自动更新
|
|
228
218
|
|
|
229
|
-
### 截图
|
|
230
|
-
|
|
231
|
-
| Web 结构化详情 | Web PTY 详情 |
|
|
232
|
-
|:---:|:---:|
|
|
233
|
-
| <img src="docs/screenshots/web-structured-detail.png" width="400" alt="Web 结构化会话详情" /> | <img src="docs/screenshots/web-pty-detail.png" width="400" alt="Web PTY 终端详情" /> |
|
|
234
|
-
|
|
235
|
-
| iOS 客户端 | Android 客户端 |
|
|
236
|
-
|:---:|:---:|
|
|
237
|
-
| <img src="docs/screenshots/ios-home.png" width="300" alt="iOS 原生会话列表" /> | <img src="docs/screenshots/android-terminal.png" width="300" alt="Android 移动端 PTY 详情" /> |
|
|
238
|
-
|
|
239
|
-
| 登录页 | App 连接页 |
|
|
240
|
-
|:---:|:---:|
|
|
241
|
-
| <img src="docs/screenshots/login.png" width="400" alt="登录页" /> | <img src="docs/screenshots/android-main.png" width="300" alt="Android App 连接页" /> |
|
|
242
|
-
|
|
243
|
-
| Android 会话列表 | Android 聊天视图 |
|
|
244
|
-
|:---:|:---:|
|
|
245
|
-
| <img src="docs/screenshots/android-home.png" width="300" alt="Android 会话列表" /> | <img src="docs/screenshots/android-chat.png" width="300" alt="Android 聊天视图" /> |
|
|
246
|
-
|
|
247
219
|
### 配置
|
|
248
220
|
|
|
249
221
|
配置文件位于 `~/.wand/config.json`,首次 `wand init` 时自动生成。
|
|
@@ -321,19 +293,22 @@ wand/
|
|
|
321
293
|
│ └── web-ui/ # 前端 HTML/CSS/JS
|
|
322
294
|
├── android/ # Android 客户端(submodule)
|
|
323
295
|
├── ios/ # iOS 客户端(submodule)
|
|
324
|
-
|
|
325
|
-
├── 架构图.md # 运行时架构与代码边界(mermaid)
|
|
326
|
-
└── docs/ # 文档与截图
|
|
296
|
+
└── macos/ # macOS 客户端(submodule)
|
|
327
297
|
```
|
|
328
298
|
|
|
329
|
-
|
|
299
|
+
服务端开发只需检出固定版本的 Render 二进制。原生客户端和 Rust 源码都是独立子模块,修改哪个再检出哪个:
|
|
330
300
|
|
|
331
301
|
```bash
|
|
332
|
-
git clone
|
|
333
|
-
|
|
334
|
-
git submodule update --init
|
|
302
|
+
git clone https://github.com/co0ontty/wand.git
|
|
303
|
+
cd wand
|
|
304
|
+
git submodule update --init -- render-bin
|
|
305
|
+
# 按需:git submodule update --init -- android # 或 ios、macos、render
|
|
335
306
|
```
|
|
336
307
|
|
|
308
|
+
全量 `npm test` 包含跨端源码契约检查,运行前另检出 `android ios macos render`。
|
|
309
|
+
`npm run clean:build-cache` 预览可清理的编译缓存,追加 `-- --apply` 才执行删除;
|
|
310
|
+
保留分发包、Release Render 二进制、签名、依赖和会话数据。
|
|
311
|
+
|
|
337
312
|
数据存储在 `~/.wand/` 下:`config.json`(配置)、`wand.db`(SQLite)、`sessions/`(日志)。
|
|
338
313
|
|
|
339
314
|
### License
|
package/dist/agent-dispatch.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { getDefaultModelForProvider } from "./config.js";
|
|
2
2
|
import { providerCliCommand } from "./session-provider.js";
|
|
3
|
-
import { ensureWorkspaceTaskForBoardTask } from "./wand-task-sync.js";
|
|
4
3
|
/** 任务卡的执行目录:工作任务的 worktree / 目录 → 所属项目目录 → 全局默认目录。 */
|
|
5
4
|
export function resolveTaskDispatchTarget(deps, task) {
|
|
6
5
|
const workspace = task.workspaceId ? deps.storage.getWorkspace(task.workspaceId) : null;
|
|
7
6
|
if (task.workspaceId && !workspace)
|
|
8
7
|
throw new Error("任务所属项目已被删除,请重新指定。");
|
|
9
|
-
const group =
|
|
8
|
+
const group = task.workspaceTaskId ? deps.storage.getWorkspaceTask(task.workspaceTaskId) : null;
|
|
9
|
+
if (!group)
|
|
10
|
+
throw new Error("任务容器不存在,请恢复任务后再派发。");
|
|
10
11
|
const cwd = group.worktree?.path || group.cwd || workspace?.cwd || deps.config.defaultCwd;
|
|
11
12
|
return { cwd, workspaceId: group.workspaceId, workspaceTaskId: group.id };
|
|
12
13
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ConversationTurn } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* 运行中步骤的 live 文本渲染结果。`omittedChars` > 0 表示前面被截掉的字数,
|
|
4
|
+
* 客户端据此显示「已省略前面 N 字」。
|
|
5
|
+
*/
|
|
6
|
+
export interface LiveStepText {
|
|
7
|
+
text: string;
|
|
8
|
+
omittedChars: number;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* 渲染「成员此刻干到哪儿了」的紧凑文本,给固定尺寸、内部滚动的 live 卡片用。
|
|
12
|
+
* 口径:从最后一条 user turn(没有就取最后一条 assistant turn)渲染到末尾;
|
|
13
|
+
* text 原样保留,tool_use 压成一行 `▸ 名称[ · 描述]`,tool_result 与 thinking 不渲染;
|
|
14
|
+
* 尾部保留最多 AI_TEAM_LIVE_TEXT_MAX_CHARS 字;渲染结果为空时回落原始 output 尾部
|
|
15
|
+
* (先按尾部截窗再剥 ANSI、同样截尾,见 `outputTailText`),保证 PTY 会话也有内容可显示。
|
|
16
|
+
* 纯函数、无 IO。
|
|
17
|
+
*
|
|
18
|
+
* `preferOutput`:以终端 output 尾部为唯一来源,完全不看 `messages`。给「PTY 但没有人把
|
|
19
|
+
* 流式输出解析成 messages」的会话用(判定在调用方,见 `ai-team-runner.ts` 的 `liveStep`)——
|
|
20
|
+
* 这类会话的 `messages` 在流式期不增长,里面若留着上一条 turn,按上面的口径会一直渲染那段
|
|
21
|
+
* 旧文本,卡片停在过期内容上。此时 output 为空也照实返回空串,不拿旧 messages 凑数。
|
|
22
|
+
*/
|
|
23
|
+
export declare function renderLiveStepText(messages: ConversationTurn[], output: string, options?: {
|
|
24
|
+
preferOutput?: boolean;
|
|
25
|
+
}): LiveStepText;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { AI_TEAM_LIVE_TEXT_MAX_CHARS } from "./ai-team-types.js";
|
|
2
|
+
/** tool_use 行的 description 单行截断长度。 */
|
|
3
|
+
const TOOL_DESCRIPTION_MAX_CHARS = 60;
|
|
4
|
+
/** CSI + OSC + 两字节转义,覆盖 PTY 输出里常见的控制序列。 */
|
|
5
|
+
const ANSI_PATTERN = /\x1B(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1B]*(?:\x07|\x1B\\)|[ @-Z\\-_])/g;
|
|
6
|
+
function stripAnsi(text) {
|
|
7
|
+
return text.replace(ANSI_PATTERN, "");
|
|
8
|
+
}
|
|
9
|
+
/** 行尾空白清掉;连续空行(≥2 个)压成一个换行,单个空行保留,段落还在。 */
|
|
10
|
+
function collapseBlankLines(text) {
|
|
11
|
+
return text.replace(/[ \t]+$/gm, "").replace(/\n{3,}/g, "\n");
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* 把切点往前挪一个 code unit,避开「半个 emoji」。
|
|
15
|
+
* `slice` 按 UTF-16 code unit 切,切点正好落在代理对中间(前一个是高代理、这一个是低代理)时,
|
|
16
|
+
* 尾巴会以一个孤立低代理开头 —— 下游渲染成 1 个 U+FFFD。往**前**挪(丢掉这一对的后半截,
|
|
17
|
+
* 整对归到被省略的前缀里)而不是往后挪,才能保证 `text.length <= AI_TEAM_LIVE_TEXT_MAX_CHARS`。
|
|
18
|
+
* 起点是 0(没切)或 `start - 1` 不是高代理时原样返回。
|
|
19
|
+
*/
|
|
20
|
+
function skipOrphanSurrogate(text, start) {
|
|
21
|
+
if (start <= 0 || start >= text.length)
|
|
22
|
+
return start;
|
|
23
|
+
const previous = text.charCodeAt(start - 1);
|
|
24
|
+
const current = text.charCodeAt(start);
|
|
25
|
+
const splitPair = previous >= 0xD800 && previous <= 0xDBFF && current >= 0xDC00 && current <= 0xDFFF;
|
|
26
|
+
return splitPair ? start + 1 : start;
|
|
27
|
+
}
|
|
28
|
+
function takeTail(text) {
|
|
29
|
+
if (text.length <= AI_TEAM_LIVE_TEXT_MAX_CHARS)
|
|
30
|
+
return { text, omittedChars: 0 };
|
|
31
|
+
const start = skipOrphanSurrogate(text, text.length - AI_TEAM_LIVE_TEXT_MAX_CHARS);
|
|
32
|
+
const tail = text.slice(start);
|
|
33
|
+
return { text: tail, omittedChars: text.length - tail.length };
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* output 回落的截窗宽度:只要保留上限的 8 倍。转义序列通常远少于正文,窗内可见文本一般仍 ≥
|
|
37
|
+
* AI_TEAM_LIVE_TEXT_MAX_CHARS,此时窗内显示的文本与「全量剥完再截尾」逐字相同;极端全是转义的
|
|
38
|
+
* 输出只会让显示的比全量少一截,省略字数仍按截窗点如实计入(是估算值,见 `outputTailText`)。
|
|
39
|
+
*/
|
|
40
|
+
const LIVE_OUTPUT_WINDOW_CHARS = AI_TEAM_LIVE_TEXT_MAX_CHARS * 8;
|
|
41
|
+
/**
|
|
42
|
+
* 截窗点最多往前回退这么多字符去找转义序列的起点(ESC)。
|
|
43
|
+
* 宽度按能装下最长的常见序列给:OSC 标题/超链接可以横跨几 K(`ESC ] 0; <长标题> BEL`),
|
|
44
|
+
* 只回退 64 字会把这种序列从中间切开,残留裸 BEL 和标题片段当正文显示。
|
|
45
|
+
*/
|
|
46
|
+
const LIVE_OUTPUT_ESCAPE_LOOKBACK = 4096;
|
|
47
|
+
/**
|
|
48
|
+
* 把截窗点挪到一个安全边界上:
|
|
49
|
+
* 1. 回退范围内有 ESC → 从该序列的起点开始,剥 ANSI 就能整条剥掉;
|
|
50
|
+
* 2. 退一步到最近的换行之后 → 至少不显示半行的残片;
|
|
51
|
+
* 3. 两者都找不到(超长转义负载里既没 ESC 也没换行)就不兜底,照原切点显示。
|
|
52
|
+
* 只在 `[start - LOOKBACK, start)` 这段里搜,不对整串(能到几 MB)跑 lastIndexOf。
|
|
53
|
+
*/
|
|
54
|
+
function safeTailStart(output, start) {
|
|
55
|
+
const from = Math.max(0, start - LIVE_OUTPUT_ESCAPE_LOOKBACK);
|
|
56
|
+
const window = output.slice(from, start);
|
|
57
|
+
const esc = window.lastIndexOf("\x1B");
|
|
58
|
+
if (esc >= 0)
|
|
59
|
+
return from + esc;
|
|
60
|
+
const newline = window.lastIndexOf("\n");
|
|
61
|
+
return newline >= 0 ? from + newline + 1 : start;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* 终端原始输出的尾部:先按尾部截窗,再剥 ANSI / 换行归一 / 压缩空行 / 截尾。
|
|
65
|
+
* live 每 500ms 就可能算一遍,而 PTY 的 output 能长到几 MB —— 顺序反过来等于每半秒对全量
|
|
66
|
+
* 跑一遍正则。丢掉的前缀计入 omittedChars,是**估算值(可能偏大也可能偏小,用于量级提示)**:
|
|
67
|
+
* 按截窗点的原始字数计,含 ANSI 与控制字符,不精确到账。三条边界写进 docs/ai-teams.md。
|
|
68
|
+
*/
|
|
69
|
+
function outputTailText(output) {
|
|
70
|
+
const rawStart = Math.max(0, output.length - LIVE_OUTPUT_WINDOW_CHARS);
|
|
71
|
+
const start = rawStart > 0 ? skipOrphanSurrogate(output, safeTailStart(output, rawStart)) : 0;
|
|
72
|
+
const stripped = collapseBlankLines(stripAnsi(output.slice(start)).replace(/\r\n?/g, "\n")).trim();
|
|
73
|
+
// 窗内全是转义序列时 stripped 为空,但窗外的正文确实被丢了,省略字数照 start 记,不报 0。
|
|
74
|
+
const tail = takeTail(stripped);
|
|
75
|
+
return start > 0 ? { text: tail.text, omittedChars: tail.omittedChars + start } : tail;
|
|
76
|
+
}
|
|
77
|
+
/** 压成一行并截断到 max 字(省略号算在长度内)。 */
|
|
78
|
+
function oneLine(text, max) {
|
|
79
|
+
const collapsed = text.replace(/\s+/g, " ").trim();
|
|
80
|
+
if (collapsed.length <= max)
|
|
81
|
+
return collapsed;
|
|
82
|
+
return `${collapsed.slice(0, max - 1)}…`;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* 渲染「成员此刻干到哪儿了」的紧凑文本,给固定尺寸、内部滚动的 live 卡片用。
|
|
86
|
+
* 口径:从最后一条 user turn(没有就取最后一条 assistant turn)渲染到末尾;
|
|
87
|
+
* text 原样保留,tool_use 压成一行 `▸ 名称[ · 描述]`,tool_result 与 thinking 不渲染;
|
|
88
|
+
* 尾部保留最多 AI_TEAM_LIVE_TEXT_MAX_CHARS 字;渲染结果为空时回落原始 output 尾部
|
|
89
|
+
* (先按尾部截窗再剥 ANSI、同样截尾,见 `outputTailText`),保证 PTY 会话也有内容可显示。
|
|
90
|
+
* 纯函数、无 IO。
|
|
91
|
+
*
|
|
92
|
+
* `preferOutput`:以终端 output 尾部为唯一来源,完全不看 `messages`。给「PTY 但没有人把
|
|
93
|
+
* 流式输出解析成 messages」的会话用(判定在调用方,见 `ai-team-runner.ts` 的 `liveStep`)——
|
|
94
|
+
* 这类会话的 `messages` 在流式期不增长,里面若留着上一条 turn,按上面的口径会一直渲染那段
|
|
95
|
+
* 旧文本,卡片停在过期内容上。此时 output 为空也照实返回空串,不拿旧 messages 凑数。
|
|
96
|
+
*/
|
|
97
|
+
export function renderLiveStepText(messages, output, options = {}) {
|
|
98
|
+
if (options.preferOutput === true)
|
|
99
|
+
return outputTailText(output);
|
|
100
|
+
let start = -1;
|
|
101
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
102
|
+
if (messages[index].role === "user") {
|
|
103
|
+
start = index;
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (start < 0) {
|
|
108
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
109
|
+
if (messages[index].role === "assistant") {
|
|
110
|
+
start = index;
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const fragments = [];
|
|
116
|
+
for (const turn of start >= 0 ? messages.slice(start) : []) {
|
|
117
|
+
for (const block of turn.content) {
|
|
118
|
+
if (block.type === "text") {
|
|
119
|
+
const text = block.text?.trim();
|
|
120
|
+
if (text)
|
|
121
|
+
fragments.push(text);
|
|
122
|
+
}
|
|
123
|
+
else if (block.type === "tool_use") {
|
|
124
|
+
const description = block.description?.trim();
|
|
125
|
+
fragments.push(description
|
|
126
|
+
? `▸ ${block.name} · ${oneLine(description, TOOL_DESCRIPTION_MAX_CHARS)}`
|
|
127
|
+
: `▸ ${block.name}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const rendered = collapseBlankLines(fragments.join("\n")).trim();
|
|
132
|
+
if (rendered)
|
|
133
|
+
return takeTail(rendered);
|
|
134
|
+
return outputTailText(output);
|
|
135
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type AiTeam, type AiTeamMember, type AiTeamRun, type AiTeamRunStatus, type AiTeamStep } from "./ai-team-types.js";
|
|
2
|
+
import type { WandTaskAgent } from "./task-types.js";
|
|
2
3
|
import type { ConversationTurn } from "./types.js";
|
|
3
4
|
export declare const AI_TEAM_REPORT_DIR = ".wand-team";
|
|
4
5
|
export declare const AI_TEAM_MAX_ASSIGN_STEPS = 5;
|
|
@@ -23,7 +24,7 @@ export type AiTeamLeaderDecision = {
|
|
|
23
24
|
/**
|
|
24
25
|
* 团队这一轮要做的事。
|
|
25
26
|
* 指派框 / 群聊里刚输入的文字就是本轮任务,不再把任务卡上的旧描述静默拼进去。
|
|
26
|
-
* 没有单独输入时(创建任务后立刻交给团队)才用标题 +
|
|
27
|
+
* 没有单独输入时(创建任务后立刻交给团队)才用标题 + 描述,标题已被描述覆盖时不重复。
|
|
27
28
|
*/
|
|
28
29
|
export declare function buildAiTeamObjective(task: {
|
|
29
30
|
title: string;
|
|
@@ -84,21 +85,24 @@ export declare function renderChatHistoryFile(input: {
|
|
|
84
85
|
maxBytes?: number;
|
|
85
86
|
}): string;
|
|
86
87
|
/**
|
|
87
|
-
* 一轮提示词:`system` 交给 CLI
|
|
88
|
-
*
|
|
88
|
+
* 一轮提示词:`system` 交给 CLI 的系统提示通道(角色、规则、回复格式、交接文件的用法),
|
|
89
|
+
* `message` 是这一轮的用户消息(目标、指派、文件路径)。
|
|
90
|
+
* provider 支持系统提示时一律走 `system`:规则不伪装成用户发言,也不每轮重复。
|
|
89
91
|
*/
|
|
90
92
|
export interface AiTeamPrompt {
|
|
91
93
|
system: string;
|
|
92
94
|
message: string;
|
|
93
95
|
}
|
|
96
|
+
/** `default` 哨兵在提示词里怎么写:换成服务端为该 CLI 配置的默认模型名;没配就退回「默认」。 */
|
|
97
|
+
export type AiTeamModelNameResolver = (provider: WandTaskAgent["provider"]) => string;
|
|
94
98
|
/** `chatHistoryPath` 有值时说明这一轮是同一个群聊的续跑(见 `aiTeamChatHistoryPath`)。 */
|
|
95
|
-
export declare function buildLeaderKickoffPrompt(run: AiTeamRun, reportPath: string, fresh: boolean, chatHistoryPath?: string | null): AiTeamPrompt;
|
|
99
|
+
export declare function buildLeaderKickoffPrompt(run: AiTeamRun, reportPath: string, fresh: boolean, chatHistoryPath?: string | null, resolveModel?: AiTeamModelNameResolver): AiTeamPrompt;
|
|
96
100
|
export interface AiTeamFinishedStepSummary {
|
|
97
101
|
step: AiTeamStep;
|
|
98
102
|
memberName: string;
|
|
99
103
|
}
|
|
100
|
-
export declare function buildLeaderFollowupPrompt(run: AiTeamRun, finished: AiTeamFinishedStepSummary[], reportPath: string, userNote: string | undefined, fresh: boolean, handoffPath?: string | null, chatHistoryPath?: string | null): AiTeamPrompt;
|
|
101
|
-
export declare function buildLeaderFormatRetryPrompt(run: AiTeamRun, error: string, reportPath: string, fresh: boolean, chatHistoryPath?: string | null): AiTeamPrompt;
|
|
104
|
+
export declare function buildLeaderFollowupPrompt(run: AiTeamRun, finished: AiTeamFinishedStepSummary[], reportPath: string, userNote: string | undefined, fresh: boolean, handoffPath?: string | null, chatHistoryPath?: string | null, resolveModel?: AiTeamModelNameResolver): AiTeamPrompt;
|
|
105
|
+
export declare function buildLeaderFormatRetryPrompt(run: AiTeamRun, error: string, reportPath: string, fresh: boolean, chatHistoryPath?: string | null, resolveModel?: AiTeamModelNameResolver): AiTeamPrompt;
|
|
102
106
|
/**
|
|
103
107
|
* fresh=false 时成员会话已有上下文,省掉目标段落。`upstream` 有值时只给交接文件路径与
|
|
104
108
|
* 各步报告文件,不在提示词里复述上游报告正文(上一个成员的信息走文件交接)。
|
package/dist/ai-team-prompts.js
CHANGED
|
@@ -4,10 +4,31 @@ export const AI_TEAM_MAX_ASSIGN_STEPS = 5;
|
|
|
4
4
|
const MESSAGE_MAX = 2000;
|
|
5
5
|
const TITLE_MAX = 80;
|
|
6
6
|
const INSTRUCTIONS_MAX = 8000;
|
|
7
|
+
/**
|
|
8
|
+
* 消息里引用文件用的固定标签:用户消息只给路径,怎么用写在系统提示的「交接文件」段
|
|
9
|
+
* (`leaderSystemPrompt` / `memberSystemPrompt`),每轮不重复同一句规则。
|
|
10
|
+
*/
|
|
11
|
+
const ROUND_REPORT_LABEL = "本轮报告文件";
|
|
12
|
+
const HANDOFF_LABEL = "上游交接文件";
|
|
13
|
+
const CHAT_HISTORY_LABEL = "本群聊之前的记录";
|
|
14
|
+
/** 后一段已经完整包含前一段时只留后一段(任务标题常是描述开头被截断出来的前缀)。 */
|
|
15
|
+
function mergeTaskTexts(title, description) {
|
|
16
|
+
const head = title.trim();
|
|
17
|
+
const tail = description.trim();
|
|
18
|
+
if (!head)
|
|
19
|
+
return tail;
|
|
20
|
+
if (!tail)
|
|
21
|
+
return head;
|
|
22
|
+
if (tail.includes(head))
|
|
23
|
+
return tail;
|
|
24
|
+
if (head.includes(tail))
|
|
25
|
+
return head;
|
|
26
|
+
return `${head}\n\n${tail}`;
|
|
27
|
+
}
|
|
7
28
|
/**
|
|
8
29
|
* 团队这一轮要做的事。
|
|
9
30
|
* 指派框 / 群聊里刚输入的文字就是本轮任务,不再把任务卡上的旧描述静默拼进去。
|
|
10
|
-
* 没有单独输入时(创建任务后立刻交给团队)才用标题 +
|
|
31
|
+
* 没有单独输入时(创建任务后立刻交给团队)才用标题 + 描述,标题已被描述覆盖时不重复。
|
|
11
32
|
*/
|
|
12
33
|
export function buildAiTeamObjective(task, note) {
|
|
13
34
|
const assignment = note?.trim() ?? "";
|
|
@@ -15,7 +36,7 @@ export function buildAiTeamObjective(task, note) {
|
|
|
15
36
|
const title = task.title.trim();
|
|
16
37
|
return title && !assignment.includes(title) ? `任务:${title}\n\n${assignment}` : assignment;
|
|
17
38
|
}
|
|
18
|
-
return
|
|
39
|
+
return mergeTaskTexts(task.title, task.description) || "完成这张任务";
|
|
19
40
|
}
|
|
20
41
|
export function leaderOf(team) {
|
|
21
42
|
return team.members.find((member) => member.isLeader) ?? team.members[0];
|
|
@@ -151,10 +172,12 @@ const TEAM_ROLE_LABELS = {
|
|
|
151
172
|
work: "干活实现",
|
|
152
173
|
verify: "验收测试",
|
|
153
174
|
};
|
|
154
|
-
function memberLine(member) {
|
|
175
|
+
function memberLine(member, resolveModel) {
|
|
155
176
|
// 只渲染首选候选(§3.6);备用候选不进提示词,降级由系统处理。
|
|
156
177
|
const preferred = memberAgents(member)[0] ?? member.agent;
|
|
157
|
-
const
|
|
178
|
+
const configured = preferred.model === "default" ? resolveModel?.(preferred.provider)?.trim() ?? "" : "";
|
|
179
|
+
// `default` 不是模型名,写真正会用的那个(拿不到名字才写「默认」)。
|
|
180
|
+
const model = preferred.model === "default" ? configured || "默认" : preferred.model;
|
|
158
181
|
const role = member.role ? TEAM_ROLE_LABELS[member.role] : undefined;
|
|
159
182
|
const duty = member.duty.trim().replace(/\s+/g, " ") || "(未填写)";
|
|
160
183
|
const parts = [`id: ${member.id}`, `名字: ${member.name}`];
|
|
@@ -166,7 +189,7 @@ function memberLine(member) {
|
|
|
166
189
|
function leaderReplyFormat() {
|
|
167
190
|
return [
|
|
168
191
|
"## 回复方式(必须遵守)",
|
|
169
|
-
|
|
192
|
+
`每轮消息末尾都给出这一轮的报告文件(标签「${ROUND_REPORT_LABEL}」);把决定写成 JSON 写入该文件,写完只简短回复「已写入」,不要在消息里复述计划。`,
|
|
170
193
|
"格式三选一:",
|
|
171
194
|
'{"action":"assign","message":"给用户看的一句话说明","steps":[{"member":"成员 id","title":"简短标题","instructions":"具体要做什么、做到什么程度"}]}',
|
|
172
195
|
'{"action":"ask","message":"要问用户的问题"}',
|
|
@@ -176,6 +199,9 @@ function leaderReplyFormat() {
|
|
|
176
199
|
"- 省略 after:等上一步完成后开始(顺序执行);",
|
|
177
200
|
"- \"after\": []:立刻开始,可以和别的成员并行;",
|
|
178
201
|
"- \"after\": [1, 2]:等第 1、2 步都做完再开始。",
|
|
202
|
+
// 依据就是被依赖步骤的产物(设计 v2.1 规则 7):群里显示成「依据:第 N 步「标题」的产物」,
|
|
203
|
+
// 所以 after 要指向真正的上游,不要挂着不相关的步骤。
|
|
204
|
+
"- 这个依赖关系会在群里显示成「依据:第 N 步「标题」的产物」,所以 after 只能指向这一步真正要接着做的上游步骤。",
|
|
179
205
|
"",
|
|
180
206
|
"instructions 的写法要求:",
|
|
181
207
|
"- 实现步骤的 instructions 必须列出允许改动的文件/目录范围;",
|
|
@@ -189,28 +215,25 @@ function teamInstructions(team) {
|
|
|
189
215
|
/** 目标与用户补充都是用户原话(常带口述笔误),别按字面较真。 */
|
|
190
216
|
const OBJECTIVE_INTENT_NOTE = "目标与用户补充都是用户原话,可能带口述笔误(例如把 CLI 写成 ci),按用户意图理解,不要照字面复述。";
|
|
191
217
|
/**
|
|
192
|
-
*
|
|
193
|
-
*
|
|
218
|
+
* 交接文件怎么用,写在系统提示里(消息里只给路径,会话级规则只说一次)。
|
|
219
|
+
* 负责人用它决定下一步,成员用它接着上游干活。
|
|
194
220
|
*/
|
|
195
|
-
function
|
|
196
|
-
if (!path)
|
|
197
|
-
return [];
|
|
198
|
-
const shared = `用户是在这个群聊里接着提要求,之前几轮的步骤摘要与群聊原文在:${path}`;
|
|
221
|
+
function handoffRules(audience) {
|
|
199
222
|
return audience === "leader"
|
|
200
223
|
? [
|
|
201
|
-
"##
|
|
202
|
-
|
|
203
|
-
|
|
224
|
+
"## 交接文件(消息里出现下列标签时,先读文件再动手)",
|
|
225
|
+
`- ${HANDOFF_LABEL}:上一批步骤的报告全文汇总,先读它再决定下一步;不要凭标题猜成员做了什么,也不要让他们把报告复述进消息里。`,
|
|
226
|
+
`- ${CHAT_HISTORY_LABEL}:用户是在这个群聊里接着提要求;先读它,已经做完的不要重做,上一轮没做完的接着做。`,
|
|
204
227
|
"",
|
|
205
228
|
]
|
|
206
229
|
: [
|
|
207
|
-
"##
|
|
208
|
-
|
|
209
|
-
|
|
230
|
+
"## 交接文件(消息里出现下列标签时,先读文件再动手)",
|
|
231
|
+
`- ${HANDOFF_LABEL}:上游步骤的报告全文汇总,先读它再开始本步骤;不要猜上游做了什么,也不要重复劳动。`,
|
|
232
|
+
`- ${CHAT_HISTORY_LABEL}:用户是在这个群聊里接着提要求;先读它,在已有改动的基础上继续,已经完成的部分不要重做。`,
|
|
210
233
|
"",
|
|
211
234
|
];
|
|
212
235
|
}
|
|
213
|
-
function leaderSystemPrompt(run) {
|
|
236
|
+
function leaderSystemPrompt(run, resolveModel) {
|
|
214
237
|
const leader = leaderOf(run.team);
|
|
215
238
|
return [
|
|
216
239
|
`你是 AI 团队「${run.team.name}」的负责人(${leader.name})。`,
|
|
@@ -218,7 +241,7 @@ function leaderSystemPrompt(run) {
|
|
|
218
241
|
"",
|
|
219
242
|
"## 成员(只能把工作派给下列成员;id 必须原样使用)",
|
|
220
243
|
"成员的名单行只列首选执行配置;执行配置由系统按候选顺序自动降级,你只按成员能力分派,不操心模型可用性。",
|
|
221
|
-
...workersOf(run.team).map(memberLine),
|
|
244
|
+
...workersOf(run.team).map((member) => memberLine(member, resolveModel)),
|
|
222
245
|
...teamInstructions(run.team),
|
|
223
246
|
"",
|
|
224
247
|
"## 工作方式",
|
|
@@ -231,6 +254,7 @@ function leaderSystemPrompt(run) {
|
|
|
231
254
|
"- 需要用户拍板时就提问,不要猜。",
|
|
232
255
|
`- ${OBJECTIVE_INTENT_NOTE}`,
|
|
233
256
|
"",
|
|
257
|
+
...handoffRules("leader"),
|
|
234
258
|
...leaderReplyFormat(),
|
|
235
259
|
].join("\n");
|
|
236
260
|
}
|
|
@@ -245,57 +269,66 @@ function memberSystemPrompt(run, member) {
|
|
|
245
269
|
"- 不要 git commit / push。",
|
|
246
270
|
"- 你的发言会以群聊气泡出现,署名是你的名字;报告仍写进报告文件。",
|
|
247
271
|
"",
|
|
272
|
+
...handoffRules("member"),
|
|
248
273
|
"## 报告约定",
|
|
249
|
-
|
|
274
|
+
`每轮消息末尾都给出这一轮的报告文件(标签「${ROUND_REPORT_LABEL}」);完成后把报告写进该文件(Markdown):`,
|
|
250
275
|
"第一行写 `状态: 完成`、`状态: 受阻` 或 `状态: 失败`,然后写做了什么、改了哪些文件、怎么验证的、还有什么问题。",
|
|
251
276
|
].join("\n");
|
|
252
277
|
}
|
|
278
|
+
/** 本轮报告文件的引用行;怎么用写在系统提示里(见 `leaderReplyFormat` / 「报告约定」)。 */
|
|
279
|
+
function reportLine(reportPath) {
|
|
280
|
+
return `${ROUND_REPORT_LABEL}:${reportPath}`;
|
|
281
|
+
}
|
|
253
282
|
/** 会话是新建的时候才需要补目标:老会话的上下文里已经有目标了。 */
|
|
254
283
|
function objectiveBlock(run, fresh) {
|
|
255
284
|
// 标签内联而不是单独的 `## 团队目标` 行:团队消息也会被会话标题 / 迭代提示词记录取首行,
|
|
256
285
|
// 单独一行的话标题会退化成「团队目标」这个空壳。
|
|
257
286
|
return fresh ? [`团队目标:${run.objective}`, ""] : [];
|
|
258
287
|
}
|
|
288
|
+
/**
|
|
289
|
+
* 续跑(用户在同一个群聊里接着说话)的上下文文件(见 `aiTeamChatHistoryPath`):
|
|
290
|
+
* 消息只给路径,「先读它、别重做已完成的」写在系统提示的「交接文件」段。
|
|
291
|
+
*/
|
|
292
|
+
function chatHistoryBlock(path) {
|
|
293
|
+
return path ? [`${CHAT_HISTORY_LABEL}:${path}`, ""] : [];
|
|
294
|
+
}
|
|
259
295
|
/** `chatHistoryPath` 有值时说明这一轮是同一个群聊的续跑(见 `aiTeamChatHistoryPath`)。 */
|
|
260
|
-
export function buildLeaderKickoffPrompt(run, reportPath, fresh, chatHistoryPath) {
|
|
296
|
+
export function buildLeaderKickoffPrompt(run, reportPath, fresh, chatHistoryPath, resolveModel) {
|
|
261
297
|
return {
|
|
262
|
-
system: leaderSystemPrompt(run),
|
|
298
|
+
system: leaderSystemPrompt(run, resolveModel),
|
|
263
299
|
message: [
|
|
264
300
|
...objectiveBlock(run, fresh),
|
|
265
|
-
...chatHistoryBlock(chatHistoryPath
|
|
266
|
-
|
|
267
|
-
`把计划决策写成 JSON 写入:${reportPath}`,
|
|
301
|
+
...chatHistoryBlock(chatHistoryPath),
|
|
302
|
+
reportLine(reportPath),
|
|
268
303
|
].join("\n"),
|
|
269
304
|
};
|
|
270
305
|
}
|
|
271
|
-
export function buildLeaderFollowupPrompt(run, finished, reportPath, userNote, fresh, handoffPath, chatHistoryPath) {
|
|
306
|
+
export function buildLeaderFollowupPrompt(run, finished, reportPath, userNote, fresh, handoffPath, chatHistoryPath, resolveModel) {
|
|
272
307
|
// 续跑上下文只在新建的负责人会话里补一次:会话复用的时候,kickoff 那一轮已经给过路径。
|
|
273
|
-
const lines = [...objectiveBlock(run, fresh), ...chatHistoryBlock(fresh ? chatHistoryPath : null
|
|
308
|
+
const lines = [...objectiveBlock(run, fresh), ...chatHistoryBlock(fresh ? chatHistoryPath : null)];
|
|
274
309
|
if (finished.length > 0) {
|
|
275
310
|
lines.push("## 已结束的步骤", "");
|
|
276
311
|
for (const { step, memberName } of finished) {
|
|
277
312
|
lines.push(handoffStepLine({ ...step, memberName }));
|
|
278
313
|
}
|
|
279
|
-
if (handoffPath)
|
|
280
|
-
lines.push("",
|
|
281
|
-
}
|
|
314
|
+
if (handoffPath)
|
|
315
|
+
lines.push("", `${HANDOFF_LABEL}:${handoffPath}`);
|
|
282
316
|
lines.push("");
|
|
283
317
|
}
|
|
284
318
|
if (userNote?.trim())
|
|
285
319
|
lines.push("## 用户补充", userNote.trim(), "");
|
|
286
|
-
lines.push(
|
|
287
|
-
return { system: leaderSystemPrompt(run), message: lines.join("\n") };
|
|
320
|
+
lines.push(reportLine(reportPath));
|
|
321
|
+
return { system: leaderSystemPrompt(run, resolveModel), message: lines.join("\n") };
|
|
288
322
|
}
|
|
289
|
-
export function buildLeaderFormatRetryPrompt(run, error, reportPath, fresh, chatHistoryPath) {
|
|
323
|
+
export function buildLeaderFormatRetryPrompt(run, error, reportPath, fresh, chatHistoryPath, resolveModel) {
|
|
290
324
|
return {
|
|
291
|
-
system: leaderSystemPrompt(run),
|
|
325
|
+
system: leaderSystemPrompt(run, resolveModel),
|
|
292
326
|
message: [
|
|
293
327
|
...objectiveBlock(run, fresh),
|
|
294
|
-
...chatHistoryBlock(fresh ? chatHistoryPath : null
|
|
328
|
+
...chatHistoryBlock(fresh ? chatHistoryPath : null),
|
|
295
329
|
`上次的回复没能解析:${error}`,
|
|
296
330
|
"",
|
|
297
|
-
|
|
298
|
-
`请严格按上面的 JSON 格式重新写入:${reportPath}`,
|
|
331
|
+
reportLine(reportPath),
|
|
299
332
|
].join("\n"),
|
|
300
333
|
};
|
|
301
334
|
}
|
|
@@ -307,18 +340,18 @@ export function buildMemberPrompt(run, step, member, fresh, upstream, chatHistor
|
|
|
307
340
|
const lines = [
|
|
308
341
|
...objectiveBlock(run, fresh),
|
|
309
342
|
// 续跑上下文只在新建的成员会话里补一次:成员这一轮以前的信息都在文件里。
|
|
310
|
-
...chatHistoryBlock(fresh ? chatHistoryPath : null
|
|
343
|
+
...chatHistoryBlock(fresh ? chatHistoryPath : null),
|
|
311
344
|
`## 本步骤:${step.title}`,
|
|
312
345
|
step.instructions,
|
|
313
346
|
"",
|
|
314
347
|
];
|
|
315
348
|
if (upstream && upstream.steps.length > 0) {
|
|
316
|
-
lines.push("##
|
|
349
|
+
lines.push("## 上游交接", "");
|
|
317
350
|
for (const item of upstream.steps)
|
|
318
351
|
lines.push(handoffStepLine(item));
|
|
319
|
-
lines.push("",
|
|
352
|
+
lines.push("", `${HANDOFF_LABEL}:${upstream.handoffPath}`, "");
|
|
320
353
|
}
|
|
321
|
-
lines.push(
|
|
354
|
+
lines.push(reportLine(step.reportPath));
|
|
322
355
|
return { system: memberSystemPrompt(run, member), message: lines.join("\n") };
|
|
323
356
|
}
|
|
324
357
|
/** 从 start 处的 `{` 找到配对的 `}`(跳过字符串里的括号)。 */
|