scrum-skills 0.2.2 → 0.2.3
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 +37 -5
- package/assets/board/200.html +1 -1
- package/assets/board/404.html +1 -1
- package/assets/board/_nuxt/-_kK9OzO.js +1 -0
- package/assets/board/_nuxt/3G_ZLrk2.js +1 -0
- package/assets/board/_nuxt/4FVXNzN-.js +9 -0
- package/assets/board/_nuxt/51OWHj8c.js +2 -0
- package/assets/board/_nuxt/5qqsfNvn.js +1 -0
- package/assets/board/_nuxt/8QIcohze.js +1 -0
- package/assets/board/_nuxt/ADHYAojV.js +1 -0
- package/assets/board/_nuxt/B0YDpoC7.js +1 -0
- package/assets/board/_nuxt/B3oUyqcV.js +1 -0
- package/assets/board/_nuxt/B43-KCi6.js +1 -0
- package/assets/board/_nuxt/B5oiGeHN.js +1 -0
- package/assets/board/_nuxt/B5u1XkK4.js +3 -0
- package/assets/board/_nuxt/B7foC6AH.js +1 -0
- package/assets/board/_nuxt/BCXs9rBJ.js +1 -0
- package/assets/board/_nuxt/BGssl5Ao.js +1 -0
- package/assets/board/_nuxt/BJ0EVa07.js +1 -0
- package/assets/board/_nuxt/BJW3UkS7.js +1 -0
- package/assets/board/_nuxt/BJcBC4jN.js +1 -0
- package/assets/board/_nuxt/BKgQBiDB.js +1 -0
- package/assets/board/_nuxt/BQSM1Ln0.js +1 -0
- package/assets/board/_nuxt/BXQZAVYz.js +1 -0
- package/assets/board/_nuxt/B_SlFcWp.js +1 -0
- package/assets/board/_nuxt/BannxgAO.js +1 -0
- package/assets/board/_nuxt/Baq-Hrpf.js +1 -0
- package/assets/board/_nuxt/Bhg7noz0.js +1 -0
- package/assets/board/_nuxt/BpR5gMZX.js +95 -0
- package/assets/board/_nuxt/BuBpFHLu.js +2 -0
- package/assets/board/_nuxt/BuOKDFiJ.js +1 -0
- package/assets/board/_nuxt/BuVRe79A.js +6 -0
- package/assets/board/_nuxt/BvaAl29K.js +13 -0
- package/assets/board/_nuxt/C3ssq3NT.js +1 -0
- package/assets/board/_nuxt/C6wyeu66.js +1 -0
- package/assets/board/_nuxt/C9Eg7sGC.js +1 -0
- package/assets/board/_nuxt/C9MiPNdJ.js +1 -0
- package/assets/board/_nuxt/C9lP5soO.js +1 -0
- package/assets/board/_nuxt/CAJYxF-h.js +1 -0
- package/assets/board/_nuxt/CDL4GvLz.js +1 -0
- package/assets/board/_nuxt/CK9_veoz.js +1 -0
- package/assets/board/_nuxt/CL3OrPP7.js +1 -0
- package/assets/board/_nuxt/CPfeOlrH.js +1 -0
- package/assets/board/_nuxt/CQTvWh4i.js +1 -0
- package/assets/board/_nuxt/CTOFhZkc.js +1 -0
- package/assets/board/_nuxt/CZPwmwA5.js +1 -0
- package/assets/board/_nuxt/CZc2Mypg.js +1 -0
- package/assets/board/_nuxt/C_uy5_kh.js +1 -0
- package/assets/board/_nuxt/Ck3md8pL.js +1 -0
- package/assets/board/_nuxt/CkDrD28D.js +1 -0
- package/assets/board/_nuxt/CmvybFI5.js +1 -0
- package/assets/board/_nuxt/CoBNWjS2.js +1 -0
- package/assets/board/_nuxt/CoPhTDCT.js +1 -0
- package/assets/board/_nuxt/Cq4Q0czX.js +1 -0
- package/assets/board/_nuxt/CsyY7ot8.js +1 -0
- package/assets/board/_nuxt/Cu_-fa7D.js +1 -0
- package/assets/board/_nuxt/CviJuCa4.js +1 -0
- package/assets/board/_nuxt/Cw7Z2eHM.js +1 -0
- package/assets/board/_nuxt/D13eMNHe.js +1 -0
- package/assets/board/_nuxt/D1_aWe_t.js +1 -0
- package/assets/board/_nuxt/D8NAsEfE.js +1 -0
- package/assets/board/_nuxt/DA0S9GHd.js +1 -0
- package/assets/board/_nuxt/DEBhVwtl.js +3 -0
- package/assets/board/_nuxt/DHihWhf5.js +1 -0
- package/assets/board/_nuxt/DQRn5Z3H.js +1 -0
- package/assets/board/_nuxt/DSaWoFHS.js +1 -0
- package/assets/board/_nuxt/DVC07Kf9.js +1 -0
- package/assets/board/_nuxt/DWxQkuvT.js +1 -0
- package/assets/board/_nuxt/DZo3dYLJ.js +3 -0
- package/assets/board/_nuxt/Db4IStii.js +1 -0
- package/assets/board/_nuxt/DcWIG384.js +1 -0
- package/assets/board/_nuxt/DdjjthKs.js +1 -0
- package/assets/board/_nuxt/DhkiugsE.js +1 -0
- package/assets/board/_nuxt/DiYBussm.js +1 -0
- package/assets/board/_nuxt/DpXJ_tCU.js +1 -0
- package/assets/board/_nuxt/Dpzighda.js +1 -0
- package/assets/board/_nuxt/DsF2J4jZ.js +1 -0
- package/assets/board/_nuxt/DtVfI_h9.js +1 -0
- package/assets/board/_nuxt/DttMo5b5.js +1 -0
- package/assets/board/_nuxt/DwNOEVN_.js +1 -0
- package/assets/board/_nuxt/Dwd1XRDX.js +1 -0
- package/assets/board/_nuxt/FCuSfyjP.js +1 -0
- package/assets/board/_nuxt/GzmVZqnA.js +1 -0
- package/assets/board/_nuxt/HclZy1k7.js +1 -0
- package/assets/board/_nuxt/HvSJjbUd.js +1 -0
- package/assets/board/_nuxt/Oe3jsM_c.js +1 -0
- package/assets/board/_nuxt/ZuoAH0hh.js +1 -0
- package/assets/board/_nuxt/_id_.B2NDKSlX.css +1 -0
- package/assets/board/_nuxt/bXQUY80V.js +1 -0
- package/assets/board/_nuxt/builds/latest.json +1 -1
- package/assets/board/_nuxt/builds/meta/24603984-d514-4dff-badc-898a1fd5e770.json +1 -0
- package/assets/board/_nuxt/burndown.BCLMmigw.css +1 -0
- package/assets/board/_nuxt/entry.ChOvfapw.css +2 -0
- package/assets/board/_nuxt/epics.B4-eK1Py.css +1 -0
- package/assets/board/_nuxt/gantt.DATOqoZf.css +1 -0
- package/assets/board/_nuxt/jCMbKsJ3.js +1 -0
- package/assets/board/_nuxt/joIhBaO-.js +1 -0
- package/assets/board/_nuxt/milestones.CVvAMKg4.css +1 -0
- package/assets/board/_nuxt/oXSDGTSr.js +1 -0
- package/assets/board/_nuxt/pages.DSSttn9g.css +1 -0
- package/assets/board/_nuxt/u88eXCXy.js +1 -0
- package/assets/board/burndown/index.html +1 -1
- package/assets/board/epics/index.html +1 -1
- package/assets/board/gantt/index.html +1 -1
- package/assets/board/index.html +1 -1
- package/assets/board/milestones/index.html +1 -1
- package/package.json +1 -1
- package/tools/agile-tools.mjs +504 -0
- package/tools/ai.mjs +442 -0
- package/tools/projects.mjs +140 -0
- package/tools/serve.mjs +60 -17
- package/assets/board/_nuxt/4lorSwrA.js +0 -1
- package/assets/board/_nuxt/7WA2NiBu.js +0 -1
- package/assets/board/_nuxt/BMkghBSN.js +0 -6
- package/assets/board/_nuxt/BO7jzDkR.js +0 -1
- package/assets/board/_nuxt/BZ3zxHIj.js +0 -1
- package/assets/board/_nuxt/BhF-44v-.js +0 -13
- package/assets/board/_nuxt/Brv_Bdja.js +0 -3
- package/assets/board/_nuxt/C3UUYAQd.js +0 -1
- package/assets/board/_nuxt/CJAcsUQO.js +0 -3
- package/assets/board/_nuxt/CNsYxQaQ.js +0 -1
- package/assets/board/_nuxt/CRaaiMq0.js +0 -1
- package/assets/board/_nuxt/CW444i7s.js +0 -1
- package/assets/board/_nuxt/CfONUjDo.js +0 -1
- package/assets/board/_nuxt/DKNZ_ohS.js +0 -1
- package/assets/board/_nuxt/DuWpPgcw.js +0 -3
- package/assets/board/_nuxt/LjrS__tx.js +0 -1
- package/assets/board/_nuxt/_id_.DhbZaLbc.css +0 -1
- package/assets/board/_nuxt/builds/meta/460252c3-0ea6-40d0-8f13-d20a01a88ad5.json +0 -1
- package/assets/board/_nuxt/burndown.BENzf335.css +0 -1
- package/assets/board/_nuxt/entry.CRMqPpqu.css +0 -2
- package/assets/board/_nuxt/epics.BeC881O2.css +0 -1
- package/assets/board/_nuxt/gantt.B1Ztrpq6.css +0 -1
- package/assets/board/_nuxt/laFvd4Y3.js +0 -16
- package/assets/board/_nuxt/milestones.B2QTaR0Y.css +0 -1
- package/assets/board/_nuxt/pages.DuMz_V7L.css +0 -1
package/tools/ai.mjs
ADDED
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 看板内置 AI 助手服务(零依赖,node >= 18)
|
|
3
|
+
*
|
|
4
|
+
* 三件事:
|
|
5
|
+
* 1. 参数落盘:服务商 / 地址 / Key / 模型 / 系统提示词 / 温度 / 工具轮数 / 上下文上限。
|
|
6
|
+
* Key 只存本机文件(0600),HTTP 读配置永远不回传,只回 hasKey。
|
|
7
|
+
* 2. 流式代理:把前端的「一问」拼成 OpenAI 兼容的 /chat/completions 请求,
|
|
8
|
+
* 边收 SSE 边把 text / reasoning / tool 事件吐给前端,浏览器不必碰 Key 也不受 CORS 限制。
|
|
9
|
+
* 3. 工具循环:模型要 search_agile / update_agile 就执行,结果回灌再来一轮;
|
|
10
|
+
* 到轮数上限强制收口(最后一轮不带工具),否则模型会一直调下去。
|
|
11
|
+
*
|
|
12
|
+
* 审核策略(reviewMode)在这里落地:manual 只调 tools.propose 产待审变更,
|
|
13
|
+
* auto 直接 tools.patch 落盘(写入方自己带校验与回滚)。
|
|
14
|
+
* manual 一旦产出待审变更就停流(awaiting-approval):等人点头再继续,
|
|
15
|
+
* 不能一边等批准一边把后面的活干完——用户会看到「没批就已经输出完了」。
|
|
16
|
+
*/
|
|
17
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
18
|
+
import { join, dirname } from 'node:path';
|
|
19
|
+
import { homedir } from 'node:os';
|
|
20
|
+
|
|
21
|
+
export const PROVIDER_PRESETS = [
|
|
22
|
+
{ id: 'deepseek', label: 'DeepSeek', baseUrl: 'https://api.deepseek.com', model: 'deepseek-chat' },
|
|
23
|
+
{ id: 'openai', label: 'OpenAI', baseUrl: 'https://api.openai.com/v1', model: 'gpt-4o-mini' },
|
|
24
|
+
{ id: 'moonshot', label: 'Moonshot 月之暗面', baseUrl: 'https://api.moonshot.cn/v1', model: 'moonshot-v1-8k' },
|
|
25
|
+
{ id: 'siliconflow', label: '硅基流动', baseUrl: 'https://api.siliconflow.cn/v1', model: 'deepseek-ai/DeepSeek-V3' },
|
|
26
|
+
{ id: 'ollama', label: '本地 Ollama', baseUrl: 'http://localhost:11434/v1', model: 'qwen2.5:7b' },
|
|
27
|
+
{ id: 'custom', label: '自定义', baseUrl: '', model: '' },
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 内置工作说明:只写「能碰什么 / 怎么碰 / 答的时候守什么」。
|
|
32
|
+
* 不写人设、不提厂商、不要求自我介绍——那些都是无关提示词:占 token、把
|
|
33
|
+
* 项目数据挤到后面,还容易让模型把回答变成「我是某某开发的 AI」。
|
|
34
|
+
* 用户自己在设置里填的系统提示词会追加在这段后面,两者都不为空才拼进去。
|
|
35
|
+
*/
|
|
36
|
+
export const BASE_SYSTEM = [
|
|
37
|
+
'你在一个敏捷看板里工作:只能通过工具访问当前项目 agile/ 下的数据。',
|
|
38
|
+
'- search_agile:检索 backlog / sprints / journal',
|
|
39
|
+
'- update_agile:按路径改数据(路径形如 stories.S-011.status);写前自动校验,手动审核时先出待审变更',
|
|
40
|
+
'规则:',
|
|
41
|
+
'1. 涉及具体故事 / 任务 / 迭代的问题,先查再答;查不到就说没有。',
|
|
42
|
+
'2. 不要凭空编 id、状态、日期、人名,也不要造测试数据。',
|
|
43
|
+
'3. 改数据前用一句话说明要改什么,改完说明改了哪几条。',
|
|
44
|
+
'4. 用简体中文,直接给结论:不复述问题、不做自我介绍、不寒暄。',
|
|
45
|
+
].join('\n');
|
|
46
|
+
|
|
47
|
+
export const DEFAULT_CONFIG = {
|
|
48
|
+
provider: 'deepseek',
|
|
49
|
+
baseUrl: 'https://api.deepseek.com',
|
|
50
|
+
apiKey: '',
|
|
51
|
+
model: 'deepseek-chat',
|
|
52
|
+
// 系统提示词留空:内置说明已经够用,用户想加偏好再填
|
|
53
|
+
systemPrompt: '',
|
|
54
|
+
temperature: 0.3,
|
|
55
|
+
maxToolRounds: 30,
|
|
56
|
+
contextLimit: 256000,
|
|
57
|
+
autoCompact: true,
|
|
58
|
+
reviewMode: 'manual',
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const TOOL_SPECS = [
|
|
62
|
+
{
|
|
63
|
+
type: 'function',
|
|
64
|
+
function: {
|
|
65
|
+
name: 'search_agile',
|
|
66
|
+
description: '在当前项目的 agile/ 数据里检索(backlog / sprints / journal)。多个关键词用空格分隔,需同时命中同一条记录。',
|
|
67
|
+
parameters: {
|
|
68
|
+
type: 'object',
|
|
69
|
+
properties: {
|
|
70
|
+
query: { type: 'string', description: '检索关键词,例如「S-011 阻塞」' },
|
|
71
|
+
limit: { type: 'integer', description: '最多返回多少条,默认 20' },
|
|
72
|
+
file: { type: 'string', enum: ['backlog', 'sprints', 'journal'], description: '只搜某个文件(可选)' },
|
|
73
|
+
},
|
|
74
|
+
required: ['query'],
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
type: 'function',
|
|
80
|
+
function: {
|
|
81
|
+
name: 'update_agile',
|
|
82
|
+
description:
|
|
83
|
+
'按路径更新敏捷数据。path 用点号写法:stories.S-011.status(对象字段)、stories.S-011.tasks(数组本身,配 op push/remove)。'
|
|
84
|
+
+ '写前会跑 tools/validate.mjs,校验不过自动回滚。',
|
|
85
|
+
parameters: {
|
|
86
|
+
type: 'object',
|
|
87
|
+
properties: {
|
|
88
|
+
file: { type: 'string', enum: ['backlog', 'sprints', 'journal'], description: '要改哪个文件' },
|
|
89
|
+
reason: { type: 'string', description: '为什么改,写给人看的一句话' },
|
|
90
|
+
patches: {
|
|
91
|
+
type: 'array',
|
|
92
|
+
description: '一批补丁,按顺序应用',
|
|
93
|
+
items: {
|
|
94
|
+
type: 'object',
|
|
95
|
+
properties: {
|
|
96
|
+
op: { type: 'string', enum: ['set', 'push', 'remove'], description: '默认 set' },
|
|
97
|
+
path: { type: 'string' },
|
|
98
|
+
value: {},
|
|
99
|
+
},
|
|
100
|
+
required: ['path'],
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
required: ['file', 'patches'],
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
];
|
|
109
|
+
|
|
110
|
+
// SCRUM_AI_STORE 只服务「换一份配置跑冒烟」的场景(验证脚本别去动用户真实配置)
|
|
111
|
+
const defaultStore = process.env.SCRUM_AI_STORE || join(process.env.APPDATA || join(homedir(), '.config'), 'scrum-skills', 'ai.json');
|
|
112
|
+
|
|
113
|
+
const CJK = /[\u2e80-\u9fff\uf900-\ufaff\uff00-\uffef\u3000-\u303f]/;
|
|
114
|
+
|
|
115
|
+
/** 粗略估 token:中日韩 0.7 字/个,其它 4 字符 1 个。宁可高估,别把请求撑爆。 */
|
|
116
|
+
export function estimateTokens(text) {
|
|
117
|
+
let cjk = 0;
|
|
118
|
+
let rest = 0;
|
|
119
|
+
for (const ch of String(text ?? '')) {
|
|
120
|
+
if (CJK.test(ch)) cjk += 1;
|
|
121
|
+
else rest += 1;
|
|
122
|
+
}
|
|
123
|
+
return cjk * 0.7 + rest / 4;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const costOf = messages => messages.reduce((sum, m) => sum + estimateTokens(m.content) + 8, 0);
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 上下文预算:保留系统提示词与最后一块(当前提问),从最早处整块丢弃。
|
|
130
|
+
* assistant(tool_calls) 与其后的 tool 结果必须同进同退——拆开接口直接 400。
|
|
131
|
+
*/
|
|
132
|
+
export function trimMessages(messages, limit) {
|
|
133
|
+
const all = (messages ?? []).filter(m => m && m.role);
|
|
134
|
+
const systems = all.filter(m => m.role === 'system');
|
|
135
|
+
const rest = all.filter(m => m.role !== 'system');
|
|
136
|
+
const blocks = [];
|
|
137
|
+
for (const m of rest) {
|
|
138
|
+
const prev = blocks[blocks.length - 1];
|
|
139
|
+
if (m.role === 'tool') {
|
|
140
|
+
if (prev && prev.messages[0]?.role === 'assistant') prev.messages.push(m);
|
|
141
|
+
else blocks.push({ messages: [m] });
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (m.role === 'assistant' && Array.isArray(m.tool_calls) && m.tool_calls.length) {
|
|
145
|
+
blocks.push({ messages: [m] });
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
blocks.push({ messages: [m] });
|
|
149
|
+
}
|
|
150
|
+
if (!blocks.length) return [...systems];
|
|
151
|
+
const tail = blocks[blocks.length - 1];
|
|
152
|
+
let head = blocks.slice(0, -1);
|
|
153
|
+
const fits = () => costOf([...systems, ...head.flatMap(b => b.messages), ...tail.messages]) <= limit;
|
|
154
|
+
while (head.length && !fits()) head = head.slice(1);
|
|
155
|
+
return [...systems, ...head.flatMap(b => b.messages), ...tail.messages];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** 把 OpenAI 兼容的 SSE 响应拆成事件;半个 chunk 拼不出来的先留着等下一段 */
|
|
159
|
+
async function* readSse(response) {
|
|
160
|
+
const reader = response.body.getReader();
|
|
161
|
+
const decoder = new TextDecoder();
|
|
162
|
+
let buffer = '';
|
|
163
|
+
while (true) {
|
|
164
|
+
const { value, done } = await reader.read();
|
|
165
|
+
if (done) break;
|
|
166
|
+
buffer += decoder.decode(value, { stream: true });
|
|
167
|
+
const lines = buffer.split('\n');
|
|
168
|
+
buffer = lines.pop() ?? '';
|
|
169
|
+
for (const raw of lines) {
|
|
170
|
+
const line = raw.trim();
|
|
171
|
+
if (!line.startsWith('data:')) continue;
|
|
172
|
+
const payload = line.slice(5).trim();
|
|
173
|
+
if (!payload) continue;
|
|
174
|
+
if (payload === '[DONE]') {
|
|
175
|
+
yield { kind: 'finish', reason: 'stop' };
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
let json;
|
|
179
|
+
try {
|
|
180
|
+
json = JSON.parse(payload);
|
|
181
|
+
} catch {
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
const choice = json.choices?.[0];
|
|
185
|
+
const delta = choice?.delta ?? {};
|
|
186
|
+
if (delta.reasoning_content) yield { kind: 'reasoning', text: String(delta.reasoning_content) };
|
|
187
|
+
if (typeof delta.content === 'string' && delta.content) yield { kind: 'text', text: delta.content };
|
|
188
|
+
for (const call of delta.tool_calls ?? []) {
|
|
189
|
+
yield {
|
|
190
|
+
kind: 'tool',
|
|
191
|
+
index: call.index ?? 0,
|
|
192
|
+
id: call.id,
|
|
193
|
+
name: call.function?.name,
|
|
194
|
+
argsText: call.function?.arguments ?? '',
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
if (choice?.finish_reason) yield { kind: 'finish', reason: choice.finish_reason };
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const clampInt = (value, min, max, fallback) => {
|
|
203
|
+
const n = Number(value);
|
|
204
|
+
if (!Number.isFinite(n)) return fallback;
|
|
205
|
+
return Math.min(max, Math.max(min, Math.round(n)));
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
export function createAiService({ root, store = defaultStore, token = '', tools, fetchImpl = globalThis.fetch } = {}) {
|
|
209
|
+
const pickKnown = source => Object.fromEntries(
|
|
210
|
+
Object.keys(DEFAULT_CONFIG).filter(k => source[k] !== undefined).map(k => [k, source[k]]),
|
|
211
|
+
);
|
|
212
|
+
const load = () => {
|
|
213
|
+
try {
|
|
214
|
+
return { ...DEFAULT_CONFIG, ...pickKnown(JSON.parse(readFileSync(store, 'utf8'))) };
|
|
215
|
+
} catch {
|
|
216
|
+
return { ...DEFAULT_CONFIG };
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
let config = load();
|
|
220
|
+
|
|
221
|
+
const masked = () => {
|
|
222
|
+
const { apiKey, ...rest } = config;
|
|
223
|
+
return { ...rest, hasKey: !!apiKey, presets: PROVIDER_PRESETS, tools: TOOL_SPECS.map(t => t.function.name) };
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
function updateConfig(patch = {}) {
|
|
227
|
+
const next = { ...config };
|
|
228
|
+
const num = (key, min, max, label) => {
|
|
229
|
+
if (patch[key] === undefined) return;
|
|
230
|
+
const n = Number(patch[key]);
|
|
231
|
+
if (!Number.isFinite(n) || n < min || n > max) throw new Error(`配置非法:${label} 必须是 ${min}~${max} 之间的数字`);
|
|
232
|
+
next[key] = key === 'temperature' ? n : Math.round(n);
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
if (patch.provider !== undefined) {
|
|
236
|
+
const preset = PROVIDER_PRESETS.find(p => p.id === patch.provider);
|
|
237
|
+
if (!preset) throw new Error(`配置非法:服务商 ${patch.provider} 不在预设列表里`);
|
|
238
|
+
next.provider = preset.id;
|
|
239
|
+
if (patch.baseUrl === undefined && preset.baseUrl) next.baseUrl = preset.baseUrl;
|
|
240
|
+
if (patch.model === undefined && preset.model) next.model = preset.model;
|
|
241
|
+
}
|
|
242
|
+
if (patch.baseUrl !== undefined) {
|
|
243
|
+
const url = String(patch.baseUrl).trim().replace(/\/+$/, '');
|
|
244
|
+
if (!/^https?:\/\/\S+$/i.test(url)) throw new Error('配置非法:接口地址必须以 http:// 或 https:// 开头');
|
|
245
|
+
next.baseUrl = url;
|
|
246
|
+
}
|
|
247
|
+
if (patch.model !== undefined) {
|
|
248
|
+
const model = String(patch.model).trim();
|
|
249
|
+
if (!model) throw new Error('配置非法:模型名不能为空');
|
|
250
|
+
next.model = model;
|
|
251
|
+
}
|
|
252
|
+
if (patch.apiKey !== undefined) {
|
|
253
|
+
if (patch.apiKey === null) next.apiKey = '';
|
|
254
|
+
else if (typeof patch.apiKey === 'string') next.apiKey = patch.apiKey.trim();
|
|
255
|
+
else throw new Error('配置非法:API Key 必须是字符串或 null');
|
|
256
|
+
}
|
|
257
|
+
if (patch.systemPrompt !== undefined) next.systemPrompt = String(patch.systemPrompt);
|
|
258
|
+
if (patch.autoCompact !== undefined) next.autoCompact = !!patch.autoCompact;
|
|
259
|
+
if (patch.reviewMode !== undefined) {
|
|
260
|
+
if (!['manual', 'auto'].includes(patch.reviewMode)) throw new Error('配置非法:reviewMode 只能是 manual 或 auto');
|
|
261
|
+
next.reviewMode = patch.reviewMode;
|
|
262
|
+
}
|
|
263
|
+
num('temperature', 0, 2, '温度');
|
|
264
|
+
num('maxToolRounds', 1, 50, '工具调用轮数上限');
|
|
265
|
+
num('contextLimit', 8000, 2_000_000, '上下文上限');
|
|
266
|
+
|
|
267
|
+
config = next;
|
|
268
|
+
mkdirSync(dirname(store), { recursive: true });
|
|
269
|
+
writeFileSync(store, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
|
|
270
|
+
return masked();
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function executeTool(name, args = {}) {
|
|
274
|
+
try {
|
|
275
|
+
if (!tools) return { ok: false, output: '当前没有可用的工具。' };
|
|
276
|
+
if (name === 'search_agile') {
|
|
277
|
+
const result = tools.search(args.query, { limit: clampInt(args.limit, 1, 50, 20), file: args.file });
|
|
278
|
+
return { ok: true, output: result, wrote: false };
|
|
279
|
+
}
|
|
280
|
+
if (name === 'update_agile') {
|
|
281
|
+
const args2 = { file: args.file, patches: args.patches, reason: args.reason };
|
|
282
|
+
if (config.reviewMode === 'manual') {
|
|
283
|
+
const record = tools.propose(args2);
|
|
284
|
+
return {
|
|
285
|
+
ok: true,
|
|
286
|
+
pending: record,
|
|
287
|
+
wrote: false,
|
|
288
|
+
output: { status: '已生成待审变更,等用户批准后才写入', id: record.id, file: record.file, changes: record.changes },
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
const result = tools.patch(args2);
|
|
292
|
+
return { ok: true, output: { status: '已写入并通过校验', ...result }, wrote: true };
|
|
293
|
+
}
|
|
294
|
+
return { ok: false, output: `未知工具 ${name}` };
|
|
295
|
+
} catch (e) {
|
|
296
|
+
return { ok: false, output: `工具执行失败:${e.message}` };
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async function* runChat({ messages = [], signal } = {}) {
|
|
301
|
+
if (!config.baseUrl) {
|
|
302
|
+
yield { type: 'error', message: '还没配置接口地址,先点开设置选一个服务商。' };
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (!config.apiKey && config.provider !== 'ollama') {
|
|
306
|
+
yield { type: 'error', message: '还没配置 API Key:点抽屉右上角的设置按钮填好再聊(本地 Ollama 可以留空)。' };
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
let history = config.autoCompact === false ? [...messages] : trimMessages(messages, config.contextLimit);
|
|
311
|
+
// 内置说明 + 用户自定义偏好,两者都为空的极端情况也别发一条空 system
|
|
312
|
+
const system = [BASE_SYSTEM, String(config.systemPrompt ?? '').trim()].filter(Boolean).join('\n\n');
|
|
313
|
+
const convo = [{ role: 'system', content: system }, ...history];
|
|
314
|
+
let rounds = 0;
|
|
315
|
+
try {
|
|
316
|
+
while (true) {
|
|
317
|
+
const useTools = rounds < config.maxToolRounds;
|
|
318
|
+
const body = { model: config.model, temperature: config.temperature, stream: true, messages: convo };
|
|
319
|
+
if (useTools) {
|
|
320
|
+
body.tools = TOOL_SPECS;
|
|
321
|
+
body.tool_choice = 'auto';
|
|
322
|
+
}
|
|
323
|
+
const response = await fetchImpl(`${config.baseUrl.replace(/\/+$/, '')}/chat/completions`, {
|
|
324
|
+
method: 'POST',
|
|
325
|
+
headers: { 'content-type': 'application/json', authorization: `Bearer ${config.apiKey}` },
|
|
326
|
+
body: JSON.stringify(body),
|
|
327
|
+
signal,
|
|
328
|
+
});
|
|
329
|
+
if (!response.ok) {
|
|
330
|
+
const detail = await response.text().catch(() => '');
|
|
331
|
+
yield { type: 'error', message: `模型接口返回 ${response.status}:${detail.slice(0, 300) || response.statusText}` };
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
let content = '';
|
|
336
|
+
const calls = new Map();
|
|
337
|
+
for await (const event of readSse(response)) {
|
|
338
|
+
if (event.kind === 'text') {
|
|
339
|
+
content += event.text;
|
|
340
|
+
yield { type: 'text', delta: event.text };
|
|
341
|
+
} else if (event.kind === 'reasoning') {
|
|
342
|
+
yield { type: 'reasoning', delta: event.text };
|
|
343
|
+
} else if (event.kind === 'tool') {
|
|
344
|
+
const key = event.index ?? 0;
|
|
345
|
+
const current = calls.get(key) ?? { id: '', name: '', argsText: '' };
|
|
346
|
+
if (event.id) current.id = event.id;
|
|
347
|
+
if (event.name) current.name = event.name;
|
|
348
|
+
current.argsText += event.argsText ?? '';
|
|
349
|
+
calls.set(key, current);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const list = [...calls.values()].filter(c => c.name);
|
|
354
|
+
if (!list.length) {
|
|
355
|
+
yield { type: 'done', rounds };
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
convo.push({
|
|
360
|
+
role: 'assistant',
|
|
361
|
+
content,
|
|
362
|
+
tool_calls: list.map((c, i) => ({
|
|
363
|
+
id: c.id || `call_${rounds + 1}_${i}`,
|
|
364
|
+
type: 'function',
|
|
365
|
+
function: { name: c.name, arguments: c.argsText || '{}' },
|
|
366
|
+
})),
|
|
367
|
+
});
|
|
368
|
+
rounds += 1;
|
|
369
|
+
|
|
370
|
+
for (const call of convo[convo.length - 1].tool_calls) {
|
|
371
|
+
let parsed = {};
|
|
372
|
+
try {
|
|
373
|
+
parsed = JSON.parse(call.function.arguments || '{}');
|
|
374
|
+
} catch {
|
|
375
|
+
parsed = {};
|
|
376
|
+
}
|
|
377
|
+
yield { type: 'tool-call', id: call.id, name: call.function.name, args: parsed };
|
|
378
|
+
const executed = executeTool(call.function.name, parsed);
|
|
379
|
+
convo.push({
|
|
380
|
+
role: 'tool',
|
|
381
|
+
tool_call_id: call.id,
|
|
382
|
+
name: call.function.name,
|
|
383
|
+
content: typeof executed.output === 'string' ? executed.output : JSON.stringify(executed.output),
|
|
384
|
+
});
|
|
385
|
+
yield {
|
|
386
|
+
type: 'tool-result',
|
|
387
|
+
id: call.id,
|
|
388
|
+
name: call.function.name,
|
|
389
|
+
ok: executed.ok,
|
|
390
|
+
wrote: !!executed.wrote,
|
|
391
|
+
output: typeof executed.output === 'string' ? executed.output : JSON.stringify(executed.output, null, 2),
|
|
392
|
+
};
|
|
393
|
+
if (executed.pending) {
|
|
394
|
+
yield { type: 'pending', record: executed.pending };
|
|
395
|
+
// 手动审核:变更在等人点头,就地停流。
|
|
396
|
+
// 继续跑下一轮,模型会把「已排队等批准」理解成「已经做完」,接着把剩下的活说完,
|
|
397
|
+
// 用户还没审就已经在看后续输出了;同一批里的其它工具调用也不再执行,免得一次冒出多条待审。
|
|
398
|
+
yield { type: 'awaiting-approval', id: executed.pending.id };
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
} catch (e) {
|
|
404
|
+
yield { type: 'error', message: String(e?.message ?? e) };
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return {
|
|
409
|
+
root,
|
|
410
|
+
token: () => token,
|
|
411
|
+
readConfig: masked,
|
|
412
|
+
updateConfig,
|
|
413
|
+
runChat,
|
|
414
|
+
pending: () => (tools?.listPending ? tools.listPending() : []),
|
|
415
|
+
applyPending: id => tools.applyPending(id),
|
|
416
|
+
rejectPending: id => tools.rejectPending(id),
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** HTTP 分发:与 handleProjectApi 同一套本机同来源校验,POST 还要带项目 token */
|
|
421
|
+
export async function handleAiApi(service, { method, path, headers = {}, body } = {}) {
|
|
422
|
+
const host = headers.host || '';
|
|
423
|
+
const denied = () => ({ status: 403, body: { error: '仅允许本机同来源访问。' } });
|
|
424
|
+
if (!/^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/.test(host)) return denied();
|
|
425
|
+
if (headers.origin && headers.origin !== `http://${host}`) return denied();
|
|
426
|
+
if (headers['sec-fetch-site'] === 'cross-site') return denied();
|
|
427
|
+
|
|
428
|
+
const url = new URL(path, `http://${host}`);
|
|
429
|
+
try {
|
|
430
|
+
if (method === 'GET' && url.pathname === '/api/ai/config') return { status: 200, body: service.readConfig() };
|
|
431
|
+
if (method === 'GET' && url.pathname === '/api/ai/pending') return { status: 200, body: { pending: service.pending() } };
|
|
432
|
+
if (method !== 'POST') return { status: 405, body: { error: '不支持的请求方法。' } };
|
|
433
|
+
if (headers['x-project-token'] !== service.token()) return denied();
|
|
434
|
+
if (url.pathname === '/api/ai/config') return { status: 200, body: service.updateConfig(body ?? {}) };
|
|
435
|
+
if (url.pathname === '/api/ai/chat') return { status: 200, stream: service.runChat({ messages: body?.messages ?? [], signal: body?.signal }) };
|
|
436
|
+
if (url.pathname === '/api/ai/pending/apply') return { status: 200, body: service.applyPending(body?.id) };
|
|
437
|
+
if (url.pathname === '/api/ai/pending/reject') return { status: 200, body: service.rejectPending(body?.id) };
|
|
438
|
+
return { status: 404, body: { error: '接口不存在。' } };
|
|
439
|
+
} catch (e) {
|
|
440
|
+
return { status: 400, body: { error: String(e?.message ?? e) } };
|
|
441
|
+
}
|
|
442
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { join, dirname } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { execFile, execFileSync } from 'node:child_process';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
import { randomBytes } from 'node:crypto';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
const exec = promisify(execFile);
|
|
10
|
+
const validator = fileURLToPath(new URL('./validate.mjs', import.meta.url));
|
|
11
|
+
const defaultStore = join(process.env.APPDATA || join(homedir(), '.config'), 'scrum-skills', 'projects.json');
|
|
12
|
+
|
|
13
|
+
export async function pickDirectory() {
|
|
14
|
+
let command, args;
|
|
15
|
+
if (process.platform === 'win32') {
|
|
16
|
+
command = 'powershell.exe';
|
|
17
|
+
const script = `
|
|
18
|
+
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new()
|
|
19
|
+
Add-Type -TypeDefinition @'
|
|
20
|
+
using System;
|
|
21
|
+
using System.Runtime.InteropServices;
|
|
22
|
+
public static class FolderPickerDpi {
|
|
23
|
+
[DllImport("user32.dll")] private static extern IntPtr SetThreadDpiAwarenessContext(IntPtr context);
|
|
24
|
+
[DllImport("user32.dll")] private static extern bool SetProcessDPIAware();
|
|
25
|
+
public static void Enable() {
|
|
26
|
+
try {
|
|
27
|
+
if (SetThreadDpiAwarenessContext(new IntPtr(-4)) != IntPtr.Zero) return;
|
|
28
|
+
if (SetThreadDpiAwarenessContext(new IntPtr(-3)) != IntPtr.Zero) return;
|
|
29
|
+
} catch (EntryPointNotFoundException) {}
|
|
30
|
+
SetProcessDPIAware();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
'@
|
|
34
|
+
[FolderPickerDpi]::Enable()
|
|
35
|
+
Add-Type -AssemblyName System.Windows.Forms
|
|
36
|
+
[System.Windows.Forms.Application]::EnableVisualStyles()
|
|
37
|
+
$d = New-Object System.Windows.Forms.FolderBrowserDialog
|
|
38
|
+
$d.Description = '选择包含 agile 文件夹的项目根目录'
|
|
39
|
+
$d.ShowNewFolderButton = $false
|
|
40
|
+
try {
|
|
41
|
+
if ($d.ShowDialog() -eq 'OK') { [Console]::Write($d.SelectedPath) }
|
|
42
|
+
} finally { $d.Dispose() }
|
|
43
|
+
`;
|
|
44
|
+
args = ['-NoProfile', '-STA', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')];
|
|
45
|
+
} else if (process.platform === 'darwin') {
|
|
46
|
+
command = 'osascript';
|
|
47
|
+
args = ['-e', 'try', '-e', 'POSIX path of (choose folder with prompt "选择包含 agile 的项目根目录")', '-e', 'on error number -128', '-e', 'return ""', '-e', 'end try'];
|
|
48
|
+
} else {
|
|
49
|
+
command = 'zenity';
|
|
50
|
+
args = ['--file-selection', '--directory', '--title=选择包含 agile 的项目根目录'];
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
const { stdout } = await exec(command, args, { encoding: 'utf8', timeout: 300000, maxBuffer: 65536 });
|
|
54
|
+
return stdout.trim() || null;
|
|
55
|
+
} catch (e) {
|
|
56
|
+
if (process.platform === 'linux' && e.code === 1) return null;
|
|
57
|
+
throw new Error('无法打开系统目录选择框,请确认本机有图形桌面(Linux 需安装 zenity)。');
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function snapshot(root) {
|
|
62
|
+
const read = file => JSON.parse(readFileSync(join(root, 'agile', file + '.json'), 'utf8'));
|
|
63
|
+
return { backlog: read('backlog'), sprints: read('sprints'), journal: read('journal'), meta: { root, servedAt: new Date().toISOString() } };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function validate(root) {
|
|
67
|
+
try {
|
|
68
|
+
execFileSync(process.execPath, [validator, root], { encoding: 'utf8', timeout: 15000, maxBuffer: 1024 * 1024 });
|
|
69
|
+
} catch (e) {
|
|
70
|
+
throw new Error(`项目敏捷数据校验失败:${e.stdout || e.message}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function createProjects({ root, store = defaultStore, pick = pickDirectory }) {
|
|
75
|
+
const token = randomBytes(24).toString('hex');
|
|
76
|
+
let recent = [];
|
|
77
|
+
try {
|
|
78
|
+
const saved = JSON.parse(readFileSync(store, 'utf8'));
|
|
79
|
+
if (Array.isArray(saved.recent)) recent = saved.recent.filter(x => typeof x === 'string').slice(0, 10);
|
|
80
|
+
} catch {}
|
|
81
|
+
const initial = realpathSync(root);
|
|
82
|
+
let current = initial;
|
|
83
|
+
for (const candidate of [...recent, initial]) {
|
|
84
|
+
try {
|
|
85
|
+
const path = realpathSync(candidate);
|
|
86
|
+
validate(path);
|
|
87
|
+
snapshot(path);
|
|
88
|
+
current = path;
|
|
89
|
+
break;
|
|
90
|
+
} catch {}
|
|
91
|
+
}
|
|
92
|
+
recent = [...new Set([current, ...recent, initial])].slice(0, 10);
|
|
93
|
+
let selecting = false;
|
|
94
|
+
const list = () => ({ recent: [...recent], root: current, token });
|
|
95
|
+
const read = (path = current) => {
|
|
96
|
+
if (!recent.includes(path)) throw new Error('该项目未打开,请先选择文件夹。');
|
|
97
|
+
return snapshot(path);
|
|
98
|
+
};
|
|
99
|
+
const open = async path => {
|
|
100
|
+
const canonical = realpathSync(path);
|
|
101
|
+
validate(canonical);
|
|
102
|
+
const data = snapshot(canonical);
|
|
103
|
+
const next = [canonical, ...recent.filter(x => x !== canonical)].slice(0, 10);
|
|
104
|
+
mkdirSync(dirname(store), { recursive: true });
|
|
105
|
+
writeFileSync(store, JSON.stringify({ recent: next }, null, 2) + '\n', { mode: 0o600 });
|
|
106
|
+
recent = next;
|
|
107
|
+
current = canonical;
|
|
108
|
+
return { ...list(), data };
|
|
109
|
+
};
|
|
110
|
+
const select = async () => {
|
|
111
|
+
if (selecting) throw new Error('目录选择框已打开,请先完成选择。');
|
|
112
|
+
selecting = true;
|
|
113
|
+
try {
|
|
114
|
+
const selected = await pick();
|
|
115
|
+
return selected ? await open(selected) : null;
|
|
116
|
+
} finally { selecting = false; }
|
|
117
|
+
};
|
|
118
|
+
return { list, read, open, select };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function handleProjectApi(projects, { method, path, headers, body }) {
|
|
122
|
+
const host = headers.host || '';
|
|
123
|
+
const denied = () => ({ status: 403, body: { error: '仅允许本机同来源访问。' } });
|
|
124
|
+
if (!/^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/.test(host)) return denied();
|
|
125
|
+
if (headers.origin && headers.origin !== `http://${host}`) return denied();
|
|
126
|
+
if (headers['sec-fetch-site'] === 'cross-site') return denied();
|
|
127
|
+
const url = new URL(path, `http://${host}`);
|
|
128
|
+
try {
|
|
129
|
+
if (method === 'GET' && url.pathname === '/api/projects') return { status: 200, body: projects.list() };
|
|
130
|
+
if (method === 'GET' && url.pathname === '/api/agile') return { status: 200, body: projects.read(url.searchParams.get('root') || undefined) };
|
|
131
|
+
if (method !== 'POST') return { status: 405, body: { error: '不支持的请求方法。' } };
|
|
132
|
+
if (headers['x-project-token'] !== projects.list().token) return denied();
|
|
133
|
+
if (url.pathname === '/api/projects/select') return { status: 200, body: await projects.select() };
|
|
134
|
+
if (url.pathname === '/api/projects/open') {
|
|
135
|
+
if (!projects.list().recent.includes(body?.root)) throw new Error('该项目不在最近列表中,请选择文件夹。');
|
|
136
|
+
return { status: 200, body: await projects.open(body.root) };
|
|
137
|
+
}
|
|
138
|
+
return { status: 404, body: { error: '接口不存在。' } };
|
|
139
|
+
} catch (e) { return { status: 400, body: { error: String(e.message) } }; }
|
|
140
|
+
}
|