dsh-card-dispatch 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +249 -2
- package/cordis.patch.yml +25 -0
- package/icon.svg +21 -0
- package/index.js +88 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +56 -4
- package/src/catalog-source.js +122 -0
- package/src/client/index.js +155 -0
- package/src/config-store.js +49 -0
- package/src/config.js +229 -0
- package/src/console.html +964 -0
- package/src/criteria-llm.js +79 -0
- package/src/criteria.js +103 -0
- package/src/gates.js +123 -0
- package/src/human-channel.js +794 -0
- package/src/import.js +193 -0
- package/src/model-call.js +89 -0
- package/src/models.js +249 -0
- package/src/parse-llm.js +96 -0
- package/src/prompts-llm.js +104 -0
- package/src/selfcheck.js +117 -0
- package/src/split.js +397 -0
- package/src/verdict.js +133 -0
package/src/console.html
ADDED
|
@@ -0,0 +1,964 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
6
|
+
<title>派卡台 · 需求 → 任务看板</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root{
|
|
9
|
+
--bg:#0d1014; --surface:#151a21; --surface-2:#1b212a; --line:#252c37;
|
|
10
|
+
--text:#e8ebf1; --dim:#98a1b1; --faint:#6b7482;
|
|
11
|
+
--accent:#5b8cff; --accent-weak:rgba(91,140,255,.14);
|
|
12
|
+
--ok:#43c26b; --ok-weak:rgba(67,194,107,.14);
|
|
13
|
+
--warn:#e0a83a; --warn-weak:rgba(224,168,58,.14);
|
|
14
|
+
--bad:#ef5f5f; --bad-weak:rgba(239,95,95,.14);
|
|
15
|
+
--radius:12px;
|
|
16
|
+
}
|
|
17
|
+
@media (prefers-color-scheme: light){
|
|
18
|
+
:root{
|
|
19
|
+
--bg:#f6f7f9; --surface:#fff; --surface-2:#f0f2f5; --line:#e2e6ec;
|
|
20
|
+
--text:#1a1f28; --dim:#5b6472; --faint:#8b94a3;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
*{box-sizing:border-box}
|
|
24
|
+
html,body{margin:0;padding:0;background:var(--bg);color:var(--text);
|
|
25
|
+
font:14px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei",sans-serif}
|
|
26
|
+
.wrap{max-width:1040px;margin:0 auto;padding:20px 18px 40px}
|
|
27
|
+
h1{font-size:17px;margin:0 0 2px;font-weight:650;letter-spacing:.2px}
|
|
28
|
+
.sub{color:var(--dim);font-size:12.5px;margin-bottom:16px}
|
|
29
|
+
.card{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);overflow:hidden}
|
|
30
|
+
.card header{display:flex;align-items:center;justify-content:space-between;gap:10px;
|
|
31
|
+
padding:11px 14px;border-bottom:1px solid var(--line);background:var(--surface-2)}
|
|
32
|
+
.card header h2{font-size:13px;margin:0;font-weight:600}
|
|
33
|
+
.hint{color:var(--faint);font-size:12px}
|
|
34
|
+
.body{padding:14px}
|
|
35
|
+
.field{margin-bottom:12px}
|
|
36
|
+
.field:last-child{margin-bottom:0}
|
|
37
|
+
label{display:block;font-size:12.5px;color:var(--dim);margin-bottom:5px}
|
|
38
|
+
input[type=text],textarea{width:100%;background:var(--bg);color:var(--text);
|
|
39
|
+
border:1px solid var(--line);border-radius:8px;padding:8px 10px;font:inherit;font-size:13px;resize:vertical}
|
|
40
|
+
input[type=text]:focus,textarea:focus,select:focus{outline:none;border-color:var(--accent)}
|
|
41
|
+
select{width:100%;background:var(--bg);color:var(--text);border:1px solid var(--line);
|
|
42
|
+
border-radius:8px;padding:8px 10px;font:inherit;font-size:13px}
|
|
43
|
+
input[type=number]{background:var(--bg);color:var(--text);border:1px solid var(--line);
|
|
44
|
+
border-radius:7px;padding:4px 7px;font:inherit;font-size:12.5px}
|
|
45
|
+
.sw{display:flex;align-items:flex-start;gap:7px;font-size:12.5px;color:var(--dim);margin:0 0 7px}
|
|
46
|
+
.sw input{margin:2px 0 0}
|
|
47
|
+
.sw b{color:var(--text);font-weight:600}
|
|
48
|
+
.row{display:flex;align-items:center;gap:8px;font-size:12.5px;color:var(--dim);margin-top:6px}
|
|
49
|
+
.row input{width:auto;margin:0}
|
|
50
|
+
.actions{display:flex;gap:8px;margin-top:14px}
|
|
51
|
+
.btn{appearance:none;border:1px solid var(--line);background:var(--surface-2);color:var(--text);
|
|
52
|
+
border-radius:8px;padding:7px 13px;font:inherit;font-size:12.5px;cursor:pointer}
|
|
53
|
+
.btn:hover{border-color:var(--faint)}
|
|
54
|
+
.btn.primary{background:var(--accent);border-color:var(--accent);color:#fff;font-weight:600}
|
|
55
|
+
.btn.primary:hover{filter:brightness(1.08)}
|
|
56
|
+
.btn:disabled{opacity:.5;cursor:not-allowed}
|
|
57
|
+
.out{margin-top:14px;white-space:pre-wrap;font-size:12.5px;color:var(--dim);
|
|
58
|
+
background:var(--surface-2);border:1px solid var(--line);border-radius:8px;padding:10px 12px;display:none}
|
|
59
|
+
.out.show{display:block}
|
|
60
|
+
.out b{color:var(--text)}
|
|
61
|
+
/* 页面撑满宿主给的空间:以前封顶 1040,宽屏上右边空一大片。 */
|
|
62
|
+
.wrap{max-width:none;margin:0 auto;padding:18px 20px 36px}
|
|
63
|
+
/* 提需求:左边填,右边一栏装「工作区 / 怎么跑 / 开关 / 体检」,下面整条预览。 */
|
|
64
|
+
.split-layout{display:grid;grid-template-columns:minmax(0,1fr) minmax(290px, 30%);gap:18px;align-items:start}
|
|
65
|
+
.split-side{background:var(--surface-2);border:1px solid var(--line);border-radius:10px;padding:14px}
|
|
66
|
+
.side-block + .side-block{margin-top:13px;padding-top:13px;border-top:1px solid var(--line)}
|
|
67
|
+
.side-title{display:flex;align-items:center;justify-content:space-between;gap:8px;
|
|
68
|
+
font-size:12px;font-weight:600;color:var(--dim);margin-bottom:6px}
|
|
69
|
+
.split-side select{width:100%}
|
|
70
|
+
.side-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:6px;margin-top:9px}
|
|
71
|
+
.side-grid label{display:flex;flex-direction:column;gap:3px;font-size:11.5px;color:var(--dim)}
|
|
72
|
+
.side-grid input{width:100%}
|
|
73
|
+
/* 体检结论可能很长:右栏里给个上限自己滚,别把整页拖长。 */
|
|
74
|
+
.split-side .out{margin-top:8px;max-height:230px;overflow:auto}
|
|
75
|
+
/* 一栏装不下(窄宿主面板)就叠起来,宁可长一点也不把输入框压成一条缝。 */
|
|
76
|
+
@media (max-width:900px){
|
|
77
|
+
.split-layout{grid-template-columns:minmax(0,1fr)}
|
|
78
|
+
}
|
|
79
|
+
.preview-panel{margin-top:16px;border:1px solid var(--line);border-radius:10px;
|
|
80
|
+
background:var(--surface-2);display:flex;flex-direction:column;min-height:190px}
|
|
81
|
+
.preview-head{display:flex;align-items:center;justify-content:space-between;gap:10px;
|
|
82
|
+
padding:9px 12px;border-bottom:1px solid var(--line);font-size:12px;color:var(--dim)}
|
|
83
|
+
.preview-body{padding:12px;white-space:pre-wrap;font-size:12.5px;color:var(--dim);flex:1;overflow:auto}
|
|
84
|
+
.preview-body b{color:var(--text)}
|
|
85
|
+
.preview-body.empty{color:var(--faint)}
|
|
86
|
+
.preview-foot{display:flex;justify-content:flex-end;gap:8px;padding:10px 12px;border-top:1px solid var(--line)}
|
|
87
|
+
.criteria-tools{display:flex;gap:6px;align-items:center;margin:6px 0}
|
|
88
|
+
.prompt{white-space:pre-wrap;font-size:11.5px;background:var(--surface-2);border:1px solid var(--line);
|
|
89
|
+
border-radius:8px;padding:8px;color:var(--dim);max-height:220px;overflow:auto;margin:6px 0 0} .criteria-tools select{flex:1 1 auto;min-width:0}
|
|
90
|
+
.criteria-tools .btn{flex:0 0 auto;white-space:nowrap}
|
|
91
|
+
.btn{white-space:nowrap}
|
|
92
|
+
.label-row{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:5px}
|
|
93
|
+
.label-row .btn{padding:3px 8px;font-size:11.5px}
|
|
94
|
+
.label-row label{margin:0}
|
|
95
|
+
.pill{display:inline-flex;align-items:center;gap:5px;font-size:11.5px;padding:2px 8px;
|
|
96
|
+
border-radius:999px;border:1px solid var(--line);color:var(--dim)}
|
|
97
|
+
.pill.ok{color:var(--ok);border-color:var(--ok-weak);background:var(--ok-weak)}
|
|
98
|
+
.pill.bad{color:var(--bad);border-color:var(--bad-weak);background:var(--bad-weak)}
|
|
99
|
+
.toast{position:fixed;left:50%;bottom:22px;transform:translate(-50%,16px);opacity:0;
|
|
100
|
+
padding:9px 15px;border-radius:9px;font-size:12.5px;pointer-events:none;transition:.18s;
|
|
101
|
+
background:var(--surface-2);border:1px solid var(--line);color:var(--text);z-index:99}
|
|
102
|
+
.toast.show{opacity:1;transform:translate(-50%,0)}
|
|
103
|
+
.toast.err{border-color:var(--bad-weak);background:var(--bad-weak);color:var(--bad)}
|
|
104
|
+
.toast.ok{border-color:var(--ok-weak);background:var(--ok-weak);color:var(--ok)}
|
|
105
|
+
.foot{margin-top:14px;color:var(--faint);font-size:12px;line-height:1.7}
|
|
106
|
+
code{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:12px;
|
|
107
|
+
background:var(--surface-2);border:1px solid var(--line);border-radius:5px;padding:1px 5px}
|
|
108
|
+
</style>
|
|
109
|
+
</head>
|
|
110
|
+
<body>
|
|
111
|
+
<div class="wrap">
|
|
112
|
+
<h1>需求 → 任务看板</h1>
|
|
113
|
+
<div class="sub">本页只做两件事:把需求拆成卡片、写进任务看板。执行(开会话、钉权限、跑、结算)全部由任务看板负责。</div>
|
|
114
|
+
|
|
115
|
+
<section class="card">
|
|
116
|
+
<header>
|
|
117
|
+
<h2>提需求</h2>
|
|
118
|
+
<span class="pill" id="board-state">检查看板…</span>
|
|
119
|
+
</header>
|
|
120
|
+
<div class="body">
|
|
121
|
+
<div class="split-layout">
|
|
122
|
+
<div class="split-main">
|
|
123
|
+
<div class="field">
|
|
124
|
+
<label>从文档导入(.md / .txt / .json)</label>
|
|
125
|
+
<div class="row" style="gap:8px;flex-wrap:wrap">
|
|
126
|
+
<input type="file" id="brd-file" accept=".md,.markdown,.txt,.text,.json">
|
|
127
|
+
</div>
|
|
128
|
+
<div class="hint" id="brd-file-hint" style="margin-top:5px">
|
|
129
|
+
选一个文件,自动填标题、描述和判据。docx / pdf 请先另存为文本。
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
<div class="field">
|
|
133
|
+
<label>需求标题</label>
|
|
134
|
+
<input type="text" id="brd-title" placeholder="做一个可视化计算器">
|
|
135
|
+
</div>
|
|
136
|
+
<div class="field">
|
|
137
|
+
<label>需求描述(可选)</label>
|
|
138
|
+
<textarea id="brd-desc" rows="5" placeholder="把整段需求原文贴进来,点下面的「AI 解析并拆分」自动整理成标题 / 描述 / 判据,并拆成卡片"></textarea>
|
|
139
|
+
<div class="row" style="gap:8px;margin-top:6px">
|
|
140
|
+
<button class="btn" id="btn-parse" onclick="aiParse()">AI 解析并拆分</button>
|
|
141
|
+
<span class="hint" id="brd-parse-hint" style="margin:0">贴原文 → 自动整理标题/描述/判据、拆卡,并给每张卡写提示词。</span>
|
|
142
|
+
</div>
|
|
143
|
+
<details id="brd-prompt-box" style="display:none">
|
|
144
|
+
<summary class="hint" style="cursor:pointer;margin-top:6px">看解析用的提示词</summary>
|
|
145
|
+
<pre class="prompt" id="brd-prompt"></pre>
|
|
146
|
+
</details>
|
|
147
|
+
</div>
|
|
148
|
+
<div class="field">
|
|
149
|
+
<label>验收判据(一行一条;<code>C-01</code> 编号可省,会自动补)</label>
|
|
150
|
+
<div class="criteria-tools">
|
|
151
|
+
<button class="btn" id="btn-criteria" onclick="generateCriteria()">生成草稿</button>
|
|
152
|
+
<span class="hint" style="margin:0">用右栏选的模型;模型不可用时按规则从描述里提炼。</span>
|
|
153
|
+
</div>
|
|
154
|
+
<textarea id="brd-criteria" rows="9" placeholder="能算加减乘除 除数为 0 要提示 支持键盘输入"></textarea>
|
|
155
|
+
<div class="hint" id="brd-criteria-hint" style="margin-top:5px">
|
|
156
|
+
优先用右栏选中的模型写;模型不可用或关掉时,从描述里按规则提炼。都是草稿,改完再用。
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
|
|
161
|
+
<aside class="split-side">
|
|
162
|
+
<div class="side-block">
|
|
163
|
+
<div class="side-title">工作区</div>
|
|
164
|
+
<select id="brd-workspace">
|
|
165
|
+
<option value="">默认(看板会选最近用过的那个)</option>
|
|
166
|
+
</select>
|
|
167
|
+
<div class="hint" id="brd-workspace-hint" style="margin-top:5px"></div>
|
|
168
|
+
</div>
|
|
169
|
+
|
|
170
|
+
<div class="side-block">
|
|
171
|
+
<div class="side-title">模型</div>
|
|
172
|
+
<select id="brd-model" title="解析需求、写判据、写每张卡提示词都用这一个模型(宿主设置里已添加的)">
|
|
173
|
+
<option value="">模型…</option>
|
|
174
|
+
</select>
|
|
175
|
+
<div class="hint" id="brd-model-hint" style="margin-top:5px">
|
|
176
|
+
全局:解析并拆分、生成草稿都用它。来自宿主设置里已添加的模型。
|
|
177
|
+
</div>
|
|
178
|
+
</div>
|
|
179
|
+
|
|
180
|
+
<div class="side-block">
|
|
181
|
+
<div class="side-title">怎么跑</div>
|
|
182
|
+
<div class="row"><input type="checkbox" id="brd-parallel" checked> 按判据拆成并行子卡</div>
|
|
183
|
+
<div class="row"><input type="checkbox" id="brd-run"> 写入后立即运行(计费)</div>
|
|
184
|
+
</div>
|
|
185
|
+
|
|
186
|
+
<div class="side-block">
|
|
187
|
+
<div class="side-title">
|
|
188
|
+
<span>开关</span>
|
|
189
|
+
<span class="pill" id="cfg-state">读取中…</span>
|
|
190
|
+
</div>
|
|
191
|
+
<label class="sw"><input type="checkbox" id="cfg-enabled"> <span><b>总开关</b> 关掉只拆卡、不开会话</span></label>
|
|
192
|
+
<label class="sw"><input type="checkbox" id="cfg-plan"> <span><b>计划闸</b> 没批准不许开跑</span></label>
|
|
193
|
+
<label class="sw"><input type="checkbox" id="cfg-evidence"> <span><b>交付门槛</b> 没有证据不算完成</span></label>
|
|
194
|
+
<label class="sw"><input type="checkbox" id="cfg-teamrun"> <span><b>智能体团队</b> 父卡起 Lead、子卡起 teammate(关掉则不拆子卡,每张卡各开一条会话)</span></label>
|
|
195
|
+
<label class="sw"><input type="checkbox" id="cfg-audit"> <span><b>拓扑稽查</b> 事后核对有没有人绕开主管</span></label>
|
|
196
|
+
|
|
197
|
+
<div class="side-grid">
|
|
198
|
+
<label>预算/日 <input type="number" id="cfg-budget" min="0" max="100000"></label>
|
|
199
|
+
<label>并发 <input type="number" id="cfg-parallel" min="1" max="32"></label>
|
|
200
|
+
<label>打回 <input type="number" id="cfg-rework" min="0" max="10"></label>
|
|
201
|
+
</div>
|
|
202
|
+
</div>
|
|
203
|
+
|
|
204
|
+
<div class="side-block">
|
|
205
|
+
<div class="side-title">
|
|
206
|
+
<span>体检</span>
|
|
207
|
+
<span class="row" style="margin:0;gap:6px">
|
|
208
|
+
<button class="btn" id="btn-recheck" onclick="runStatus()">复检</button>
|
|
209
|
+
<button class="btn primary" id="btn-save-cfg" onclick="saveConfig()">保存</button>
|
|
210
|
+
</span>
|
|
211
|
+
</div>
|
|
212
|
+
<div class="out" id="cfg-findings"></div>
|
|
213
|
+
<div class="hint" id="cfg-file" style="margin-top:6px"></div>
|
|
214
|
+
</div>
|
|
215
|
+
</aside>
|
|
216
|
+
</div>
|
|
217
|
+
|
|
218
|
+
<div class="preview-panel">
|
|
219
|
+
<div class="preview-head">
|
|
220
|
+
<span>拆分后的预览</span>
|
|
221
|
+
<span class="hint" id="brd-preview-hint">还没拆分</span>
|
|
222
|
+
</div>
|
|
223
|
+
<div class="preview-body empty" id="brd-preview"></div>
|
|
224
|
+
<div class="preview-foot">
|
|
225
|
+
<button class="btn" id="btn-preview" onclick="previewSplit()">只看拆分</button>
|
|
226
|
+
<button class="btn primary" id="btn-push" onclick="pushToBoard()">确认添加到看板</button>
|
|
227
|
+
</div>
|
|
228
|
+
</div>
|
|
229
|
+
</div>
|
|
230
|
+
</section>
|
|
231
|
+
|
|
232
|
+
<section class="card" style="margin-top:14px">
|
|
233
|
+
<header>
|
|
234
|
+
<h2>返工检查</h2>
|
|
235
|
+
<span class="pill" id="rework-state">等一次拆分</span>
|
|
236
|
+
</header>
|
|
237
|
+
<div class="body">
|
|
238
|
+
<div class="hint" id="rework-hint">
|
|
239
|
+
验收卡会把结论写到 <b>out/<父卡 id>/verdict.json</b>;这里读它,决定是重跑被拒的开发卡,还是停下来交给你。
|
|
240
|
+
没有轮询——你不点,它就不动。
|
|
241
|
+
</div>
|
|
242
|
+
<div class="actions">
|
|
243
|
+
<button class="btn" id="btn-rework" onclick="checkRework()" disabled>检查验收结论</button>
|
|
244
|
+
</div>
|
|
245
|
+
<div class="out" id="rework-out"></div>
|
|
246
|
+
</div>
|
|
247
|
+
</section>
|
|
248
|
+
|
|
249
|
+
<div class="foot">
|
|
250
|
+
写完去侧边栏「任务看板」看卡片。看板没有「卡片间依赖」字段,所以子卡是并行的——
|
|
251
|
+
每张卡的 prompt 自带它那组判据和「怎么算完」,不靠顺序。
|
|
252
|
+
</div>
|
|
253
|
+
</div>
|
|
254
|
+
|
|
255
|
+
<div class="toast" id="toast"></div>
|
|
256
|
+
|
|
257
|
+
<script>
|
|
258
|
+
'use strict';
|
|
259
|
+
|
|
260
|
+
/* 请求基址:本页挂在 /api/card-dispatch/ 下,也可能没有结尾斜杠。
|
|
261
|
+
绝不能直接写成站点根路径——那会打到 http://host/view,变成 404(真机踩过)。 */
|
|
262
|
+
var BASE = (function () {
|
|
263
|
+
var path = location.pathname;
|
|
264
|
+
return path.charAt(path.length - 1) === '/' ? path : path + '/';
|
|
265
|
+
})();
|
|
266
|
+
|
|
267
|
+
/* 看板路由挂在同一个宿主的 <appRoot>/api/task-board 上;从自己的 BASE 推导,
|
|
268
|
+
子路径部署也不会打歪,末尾斜杠必须去掉(否则拼出 //action)。 */
|
|
269
|
+
var BOARD = BASE.replace(/card-dispatch\/?$/, 'task-board').replace(/\/+$/, '');
|
|
270
|
+
|
|
271
|
+
/* 页面哈希:服务端渲染时替换成真实值。轮询回来的版本与它不同,说明 iframe 里是旧文档,
|
|
272
|
+
旧文档会调不存在的路由,看着像功能没做——直接整页 reload。 */
|
|
273
|
+
var PAGE_VERSION = '__CARD_DISPATCH_PAGE_VERSION__';
|
|
274
|
+
|
|
275
|
+
function el(id) { return document.getElementById(id); }
|
|
276
|
+
|
|
277
|
+
var toastTimer = null;
|
|
278
|
+
function toast(message, kind) {
|
|
279
|
+
var node = el('toast');
|
|
280
|
+
node.textContent = message;
|
|
281
|
+
node.className = 'toast show' + (kind ? ' ' + kind : '');
|
|
282
|
+
clearTimeout(toastTimer);
|
|
283
|
+
toastTimer = setTimeout(function () { node.className = 'toast'; }, 4200);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function request(path, body) {
|
|
287
|
+
var init = { credentials: 'same-origin', headers: { 'x-card-dispatch-human': 'true' } };
|
|
288
|
+
if (body !== undefined) {
|
|
289
|
+
init.method = 'POST';
|
|
290
|
+
init.headers['content-type'] = 'application/json';
|
|
291
|
+
init.body = JSON.stringify(body);
|
|
292
|
+
}
|
|
293
|
+
return fetch(BASE + path, init).then(function (response) {
|
|
294
|
+
return response.json().catch(function () { return {}; }).then(function (payload) {
|
|
295
|
+
if (response.ok) return payload;
|
|
296
|
+
var error = (payload && payload.error) || {};
|
|
297
|
+
throw new Error((error.code || ('HTTP ' + response.status)) + (error.message ? ' · ' + error.message : ''));
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function boardPost(envelope) {
|
|
303
|
+
return fetch(BOARD + '/action', {
|
|
304
|
+
method: 'POST',
|
|
305
|
+
credentials: 'same-origin',
|
|
306
|
+
headers: { 'content-type': 'application/json' },
|
|
307
|
+
body: JSON.stringify(envelope)
|
|
308
|
+
}).then(function (response) {
|
|
309
|
+
return response.json().catch(function () { return {}; }).then(function (payload) {
|
|
310
|
+
if (response.ok && payload && payload.ok !== false) return payload;
|
|
311
|
+
var error = (payload && payload.error) || ('HTTP ' + response.status);
|
|
312
|
+
throw new Error(typeof error === 'string' ? error : (error.message || error.code || '看板拒绝了这次写入'));
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/* ── 返工检查 ──────────────────────────────────────────────────────────────
|
|
318
|
+
判定在服务端(纯函数),这里只做两件笨事:把「哪张卡该重跑」找出来,把 run 信封投出去。
|
|
319
|
+
故意不做成自动的:没有轮询、没有定时器,人点一次才判一次。 */
|
|
320
|
+
|
|
321
|
+
var lastPlan = null; /* 最近一次真正写进看板的拆分结果 */
|
|
322
|
+
var rejectedCount = 0; /* 这一批已经打回几次(服务端也记,这里只是喂给它) */
|
|
323
|
+
var WORKSPACE_PATHS = {}; /* 工作区 id → 根目录,读 verdict.json 要用绝对路径 */
|
|
324
|
+
var hostHasRework = false;/* 宿主有没有 /rework:旧宿主上这个按钮点了只会 404 */
|
|
325
|
+
var hostHasPlan = true;/* 宿主有没有 /plan:没有就退回只整理字段 */
|
|
326
|
+
|
|
327
|
+
function rememberPlan(plan) {
|
|
328
|
+
lastPlan = plan;
|
|
329
|
+
rejectedCount = 0;
|
|
330
|
+
el('btn-rework').disabled = !hostHasRework;
|
|
331
|
+
el('rework-state').className = 'pill' + (hostHasRework ? '' : ' bad');
|
|
332
|
+
el('rework-state').textContent = hostHasRework ? '等验收' : '宿主是旧代码';
|
|
333
|
+
el('rework-out').className = 'out';
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/* 判据 id → 负责它的那张开发卡。子卡的 description 里带着自己那组判据编号,
|
|
337
|
+
所以不用另外维护映射;单卡需求就直接回落到父卡。 */
|
|
338
|
+
function failedCardIds(plan, items) {
|
|
339
|
+
var workers = plan.cards.filter(function (card) { return card.key !== 'root' && card.key !== 'verify'; });
|
|
340
|
+
var ids = [];
|
|
341
|
+
(items || []).forEach(function (item) {
|
|
342
|
+
var criteriaId = String(item && item.criteriaId || '');
|
|
343
|
+
if (!criteriaId) return;
|
|
344
|
+
var hit = workers.filter(function (card) {
|
|
345
|
+
return String(card.input.description || '').indexOf(criteriaId) >= 0
|
|
346
|
+
|| String(card.input.prompt || '').indexOf(criteriaId) >= 0;
|
|
347
|
+
})[0];
|
|
348
|
+
if (!hit && workers.length === 1) hit = workers[0];
|
|
349
|
+
if (hit && ids.indexOf(hit.id) < 0) ids.push(hit.id);
|
|
350
|
+
});
|
|
351
|
+
if (!ids.length) {
|
|
352
|
+
var root = plan.cards.filter(function (card) { return card.key === 'root'; })[0];
|
|
353
|
+
if (root) ids.push(root.id);
|
|
354
|
+
}
|
|
355
|
+
return ids;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function showRework(result) {
|
|
359
|
+
var decision = result.decision || {};
|
|
360
|
+
var labels = { pass: '已通过', rework: '已退回', escalate: '要人接手', invalid: '结论不可用' };
|
|
361
|
+
var tones = { pass: 'ok', escalate: 'bad', invalid: 'bad', rework: '' };
|
|
362
|
+
el('rework-state').className = 'pill ' + (tones[decision.action] || '');
|
|
363
|
+
el('rework-state').textContent = labels[decision.action] || '未知';
|
|
364
|
+
var lines = [decision.message || ''];
|
|
365
|
+
if (decision.items && decision.items.length) {
|
|
366
|
+
lines.push('', '未通过的判据:');
|
|
367
|
+
decision.items.forEach(function (item) {
|
|
368
|
+
lines.push('- ' + item.criteriaId + ' 期望:' + item.expected + ' 实际:' + item.actual + ' 证据:' + item.evidencePath);
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
if (result.hint) lines.push('', result.hint);
|
|
372
|
+
el('rework-out').className = 'out show';
|
|
373
|
+
el('rework-out').textContent = lines.join('\n');
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function checkRework() {
|
|
377
|
+
if (!lastPlan) { toast('先往看板里写一次拆分', 'err'); return; }
|
|
378
|
+
var workspaceRoot = WORKSPACE_PATHS[el('brd-workspace').value] || '';
|
|
379
|
+
if (!workspaceRoot) {
|
|
380
|
+
toast('先在拆分区选一个具体工作区——「默认」没有路径,读不到验收结论', 'err');
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
var button = el('btn-rework');
|
|
384
|
+
button.disabled = true;
|
|
385
|
+
el('rework-state').className = 'pill';
|
|
386
|
+
el('rework-state').textContent = '检查中…';
|
|
387
|
+
|
|
388
|
+
var base = { rootId: lastPlan.rootId, workspaceRoot: workspaceRoot, rejectedCount: rejectedCount };
|
|
389
|
+
request('rework', base)
|
|
390
|
+
.then(function (first) {
|
|
391
|
+
var decision = first.decision || {};
|
|
392
|
+
if (decision.action !== 'rework') { showRework(first); return first; }
|
|
393
|
+
// 要返工:先找出被拒判据对应的卡,再要 run 信封(同一次请求里带 taskIds)
|
|
394
|
+
var ids = failedCardIds(lastPlan, first.verdict && first.verdict.items);
|
|
395
|
+
return request('rework', Object.assign({}, base, { taskIds: ids })).then(function (second) {
|
|
396
|
+
var chain = Promise.resolve();
|
|
397
|
+
(second.runs || []).forEach(function (run) {
|
|
398
|
+
chain = chain.then(function () { return boardPost(run); });
|
|
399
|
+
});
|
|
400
|
+
return chain.then(function () { showRework(second); return second; });
|
|
401
|
+
});
|
|
402
|
+
})
|
|
403
|
+
.then(function (result) {
|
|
404
|
+
var action = (result.decision || {}).action;
|
|
405
|
+
if (action === 'rework') {
|
|
406
|
+
rejectedCount = result.decision.rejectedCount;
|
|
407
|
+
toast('已退回开发:' + (result.runs || []).length + ' 张卡重跑中', 'ok');
|
|
408
|
+
} else if (action === 'pass') {
|
|
409
|
+
toast('验收通过', 'ok');
|
|
410
|
+
} else {
|
|
411
|
+
toast(result.decision.message || '检查未通过', 'err');
|
|
412
|
+
}
|
|
413
|
+
})
|
|
414
|
+
.catch(function (error) {
|
|
415
|
+
el('rework-out').className = 'out show';
|
|
416
|
+
el('rework-out').textContent = '检查失败:' + error.message;
|
|
417
|
+
toast('检查失败:' + error.message, 'err');
|
|
418
|
+
})
|
|
419
|
+
.then(function () { button.disabled = false; });
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/* 工作区下拉:卡片不带 workspaceId 时,看板会回退到「最近改动过的那个工作区」——
|
|
423
|
+
对自动跑卡是隐患(人一切会话,卡片就跑别处去了)。所以把选择显式化。 */
|
|
424
|
+
function loadWorkspaces() {
|
|
425
|
+
return fetch(BASE + 'view', { credentials: 'same-origin' })
|
|
426
|
+
.then(function (response) { return response.ok ? response.json() : null; })
|
|
427
|
+
.then(function (snapshot) {
|
|
428
|
+
var select = el('brd-workspace');
|
|
429
|
+
var hint = el('brd-workspace-hint');
|
|
430
|
+
var items = (snapshot && snapshot.workspaces) || [];
|
|
431
|
+
if (!items.length) {
|
|
432
|
+
hint.textContent = '宿主没给出工作区列表(或一个都还没建)。不选也能建卡,但卡片会跑在看板自己挑的工作区里。';
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
var keep = select.value;
|
|
436
|
+
select.innerHTML = '<option value="">默认(看板会选最近用过的那个)</option>';
|
|
437
|
+
items.forEach(function (item) {
|
|
438
|
+
WORKSPACE_PATHS[item.id] = item.path || '';
|
|
439
|
+
var option = document.createElement('option');
|
|
440
|
+
option.value = item.id;
|
|
441
|
+
option.textContent = (item.title || item.id) + (item.path ? ' · ' + item.path : '');
|
|
442
|
+
select.appendChild(option);
|
|
443
|
+
});
|
|
444
|
+
if (keep) select.value = keep;
|
|
445
|
+
/* 上次选的工作区要还原:选项是异步加载的,所以放在这里而不是 restoreDraft 里 */
|
|
446
|
+
if (restoredDraft && restoredDraft.workspaceId && WORKSPACE_PATHS[restoredDraft.workspaceId]) {
|
|
447
|
+
select.value = restoredDraft.workspaceId;
|
|
448
|
+
}
|
|
449
|
+
hint.textContent = '共 ' + items.length + ' 个工作区;选中的会写进每张卡片的 workspaceId。';
|
|
450
|
+
return snapshot;
|
|
451
|
+
})
|
|
452
|
+
.catch(function () {
|
|
453
|
+
el('brd-workspace-hint').textContent = '拿不到工作区列表(宿主还是旧代码?)。';
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function checkBoard() {
|
|
458
|
+
return fetch(BOARD + '/state', { credentials: 'same-origin' })
|
|
459
|
+
.then(function (response) {
|
|
460
|
+
if (!response.ok) {
|
|
461
|
+
el('board-state').className = 'pill bad';
|
|
462
|
+
el('board-state').textContent = '看板不可用(HTTP ' + response.status + ')';
|
|
463
|
+
return null;
|
|
464
|
+
}
|
|
465
|
+
return response.json().then(function (state) {
|
|
466
|
+
el('board-state').className = 'pill ok';
|
|
467
|
+
el('board-state').textContent = '看板可用 · 默认权限 ' + (state.sessionDefaultPermission || '?');
|
|
468
|
+
return state;
|
|
469
|
+
});
|
|
470
|
+
})
|
|
471
|
+
.catch(function () {
|
|
472
|
+
el('board-state').className = 'pill bad';
|
|
473
|
+
el('board-state').textContent = '看板未安装或未启动';
|
|
474
|
+
return null;
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/* 判据按行拆;编号交给服务端归一化(不写就补 C-01),页面不重复实现一遍规则。 */
|
|
479
|
+
function splitPayload() {
|
|
480
|
+
var lines = el('brd-criteria').value.split('\n').map(function (line) { return line.trim(); })
|
|
481
|
+
.filter(function (line) { return line !== ''; });
|
|
482
|
+
return {
|
|
483
|
+
title: el('brd-title').value.trim(),
|
|
484
|
+
description: el('brd-desc').value.trim(),
|
|
485
|
+
criteria: lines,
|
|
486
|
+
parallel: el('brd-parallel').checked,
|
|
487
|
+
/* 权限不在这里写死:服务端按配置里的默认权限补,页面别覆盖它。 */
|
|
488
|
+
workspaceId: el('brd-workspace').value || null,
|
|
489
|
+
/* 模型按需求写好的提示词开头:编辑表单后会被清掉(见 dropIntros),
|
|
490
|
+
免得把上一轮需求的措辞贴到改过的卡上。 */
|
|
491
|
+
intros: aiIntros,
|
|
492
|
+
/* 人点「写入并运行」这一下,就是计划闸要的那次批准。只写卡不运行时不给批准,
|
|
493
|
+
免得预演也把闸门算成已过。 */
|
|
494
|
+
planApproved: el('brd-run').checked
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/* 表单被人改过之后,上一轮模型写的提示词不再对应这张卡,直接丢掉。
|
|
499
|
+
清空时不弹提示:这只是让写卡回落到模板,不是错误。 */
|
|
500
|
+
function dropIntros() {
|
|
501
|
+
if (Object.keys(aiIntros).length === 0) return;
|
|
502
|
+
aiIntros = {};
|
|
503
|
+
var hint = el('brd-parse-hint');
|
|
504
|
+
if (hint && hint.textContent.indexOf('提示词') >= 0) hint.textContent = '表单改过了,提示词按模板拼(想重写就再点一次「AI 解析并拆分」)。';
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function renderPlan(plan) {
|
|
508
|
+
var lines = plan.cards.map(function (card, index) {
|
|
509
|
+
return (index + 1) + '. ' + card.title + (card.input.parentId ? ' (子卡)' : ' (父卡)');
|
|
510
|
+
});
|
|
511
|
+
/* 每张卡的提示词也摆出来:写卡前能看见执行者会读到什么,才算"可审"。 */
|
|
512
|
+
plan.cards.forEach(function (card, index) {
|
|
513
|
+
var prompt = String(card.input.prompt || '').trim();
|
|
514
|
+
if (prompt === '') return;
|
|
515
|
+
var short = prompt.split('\n').slice(0, 6).join('\n');
|
|
516
|
+
if (prompt.split('\n').length > 6) short += '\n…';
|
|
517
|
+
lines.push('', '── 第 ' + (index + 1) + ' 张卡的提示词 ──', short);
|
|
518
|
+
});
|
|
519
|
+
if (plan.warnings && plan.warnings.length) lines.push('', plan.warnings.join('\n'));
|
|
520
|
+
el('brd-preview').className = 'preview-body';
|
|
521
|
+
el('brd-preview').textContent = lines.join('\n');
|
|
522
|
+
el('brd-preview-hint').textContent = '共 ' + plan.cards.length + ' 张卡'
|
|
523
|
+
+ (plan.cards.some(function (card) { return String(card.input.prompt || '').length > 0; }) ? '(含提示词)' : '');
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function clearPreview(hint) {
|
|
527
|
+
el('brd-preview').className = 'preview-body empty';
|
|
528
|
+
el('brd-preview').textContent = '';
|
|
529
|
+
el('brd-preview-hint').textContent = hint;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function previewSplit() {
|
|
533
|
+
var payload = splitPayload();
|
|
534
|
+
if (!payload.title) { toast('先写需求标题', 'err'); return; }
|
|
535
|
+
request('split', payload).then(renderPlan).catch(function (error) {
|
|
536
|
+
toast('拆分失败:' + error.message, 'err');
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function pushToBoard() {
|
|
541
|
+
var payload = splitPayload();
|
|
542
|
+
if (!payload.title) { toast('先写需求标题', 'err'); return; }
|
|
543
|
+
var runNow = el('brd-run').checked;
|
|
544
|
+
var button = el('btn-push');
|
|
545
|
+
button.disabled = true;
|
|
546
|
+
request('split', payload).then(function (plan) {
|
|
547
|
+
// 闸门不允许时服务端不返回运行信封:这里必须同步降级,否则会把「没批准」当「已排队」。
|
|
548
|
+
if (runNow && !plan.run) {
|
|
549
|
+
runNow = false;
|
|
550
|
+
el('brd-run').checked = false;
|
|
551
|
+
toast('只写卡,不运行:' + (plan.gate ? plan.gate.message : '闸门未放行'), 'err');
|
|
552
|
+
}
|
|
553
|
+
el('brd-preview').className = 'preview-body';
|
|
554
|
+
el('brd-preview').textContent = '正在写入看板:' + plan.cards.length + ' 张卡…';
|
|
555
|
+
el('brd-preview-hint').textContent = '写入中…';
|
|
556
|
+
var chain = Promise.resolve();
|
|
557
|
+
plan.envelopes.forEach(function (envelope) {
|
|
558
|
+
chain = chain.then(function () { return boardPost(envelope); });
|
|
559
|
+
});
|
|
560
|
+
if (runNow) chain = chain.then(function () { return boardPost(plan.run); });
|
|
561
|
+
return chain.then(function () {
|
|
562
|
+
renderPlan(plan);
|
|
563
|
+
rememberPlan(plan);
|
|
564
|
+
el('brd-preview').textContent += '\n\n已写入 ' + plan.cards.length + ' 张卡'
|
|
565
|
+
+ (runNow ? ',并已排队运行。' : '。去侧边栏「任务看板」点「运行」开始执行。');
|
|
566
|
+
el('brd-preview-hint').textContent = '已写入看板';
|
|
567
|
+
toast('已写入任务看板:' + plan.cards.length + ' 张卡' + (runNow ? '(已排队运行)' : ''), 'ok');
|
|
568
|
+
el('brd-title').value = ''; el('brd-desc').value = ''; el('brd-criteria').value = '';
|
|
569
|
+
saveDraft();
|
|
570
|
+
});
|
|
571
|
+
}).catch(function (error) {
|
|
572
|
+
clearPreview('写入失败');
|
|
573
|
+
toast('写入看板失败:' + error.message, 'err');
|
|
574
|
+
}).then(function () {
|
|
575
|
+
button.disabled = false;
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/* ── 开关与体检 ─────────────────────────────────────────────────────────── */
|
|
580
|
+
|
|
581
|
+
/* 服务端返回的是「声明表 + 当前值」的数组,这里按路径摊成一张表再回填控件。
|
|
582
|
+
控件只覆盖常用的几个;没列出来的项仍然能通过配置文件改,不会被这页删掉。 */
|
|
583
|
+
function applyConfig(snapshot) {
|
|
584
|
+
var byPath = {};
|
|
585
|
+
((snapshot && snapshot.config) || []).forEach(function (item) { byPath[item.path] = item.value; });
|
|
586
|
+
|
|
587
|
+
el('cfg-enabled').checked = byPath['enabled'] === true;
|
|
588
|
+
el('cfg-plan').checked = byPath['gates.requirePlanApproval'] === true;
|
|
589
|
+
el('cfg-evidence').checked = byPath['gates.requireEvidence'] === true;
|
|
590
|
+
el('cfg-teamrun').checked = byPath['execution.teamRun'] === true;
|
|
591
|
+
el('cfg-audit').checked = byPath['audit.orgChart'] === true;
|
|
592
|
+
el('cfg-budget').value = byPath['gates.budgetPerDay'];
|
|
593
|
+
el('cfg-parallel').value = byPath['gates.maxParallelRuns'];
|
|
594
|
+
el('cfg-rework').value = byPath['gates.maxReworkRounds'];
|
|
595
|
+
|
|
596
|
+
el('cfg-state').className = 'pill ' + (byPath['enabled'] === true ? 'ok' : '');
|
|
597
|
+
el('cfg-state').textContent = byPath['enabled'] === true ? '执行已开启' : '执行未开启';
|
|
598
|
+
el('cfg-file').textContent = snapshot && snapshot.configFile
|
|
599
|
+
? '开关保存在 ' + snapshot.configFile + '(它优先于部署配置;删掉该文件即回到部署值)'
|
|
600
|
+
: '';
|
|
601
|
+
syncTeamRunUi();
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/* 拆不拆子卡由「智能体团队」决定:没开团队,每张子卡都是独立会话,所以「按判据拆」
|
|
605
|
+
这一项在那时没有意义——直接禁掉并说明,别让人以为它还在起作用。 */
|
|
606
|
+
function syncTeamRunUi() {
|
|
607
|
+
var team = el('cfg-teamrun').checked;
|
|
608
|
+
el('brd-parallel').disabled = !team;
|
|
609
|
+
el('brd-parallel').parentElement.style.opacity = team ? '' : '0.5';
|
|
610
|
+
el('brd-parallel').parentElement.title = team ? '' : '没开智能体团队:不拆子卡(每张子卡都会各开一条会话)';
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function loadConfig() {
|
|
614
|
+
return request('config').then(applyConfig).catch(function (error) {
|
|
615
|
+
el('cfg-state').className = 'pill bad';
|
|
616
|
+
el('cfg-state').textContent = '开关读取失败';
|
|
617
|
+
el('cfg-file').textContent = error.message;
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/* 每次保存都提交**全量**控件值:服务端按声明表逐项收敛,不会因为漏提交而悄悄关掉别的开关。 */
|
|
622
|
+
function configPatch() {
|
|
623
|
+
return {
|
|
624
|
+
'enabled': el('cfg-enabled').checked,
|
|
625
|
+
'gates.requirePlanApproval': el('cfg-plan').checked,
|
|
626
|
+
'gates.requireEvidence': el('cfg-evidence').checked,
|
|
627
|
+
'execution.teamRun': el('cfg-teamrun').checked,
|
|
628
|
+
'audit.orgChart': el('cfg-audit').checked,
|
|
629
|
+
'gates.budgetPerDay': Number(el('cfg-budget').value),
|
|
630
|
+
'gates.maxParallelRuns': Number(el('cfg-parallel').value),
|
|
631
|
+
'gates.maxReworkRounds': Number(el('cfg-rework').value)
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/* 模型是全局设置:换一个就写回配置(models.draftRoute),刷新/重启后仍是它。
|
|
636
|
+
写失败不拦人用——本次会话里那个模型照样生效,只在提示里说清楚。 */
|
|
637
|
+
function saveModelChoice(route) {
|
|
638
|
+
el('brd-model-hint').textContent = '正在把 ' + route + ' 记成全局模型…';
|
|
639
|
+
request('config', { patch: { 'models.draftRoute': route } })
|
|
640
|
+
.then(function (snapshot) {
|
|
641
|
+
applyConfig(snapshot);
|
|
642
|
+
el('brd-model-hint').textContent = '全局模型:' + route + '。解析并拆分、生成草稿都用它。';
|
|
643
|
+
})
|
|
644
|
+
.catch(function (error) {
|
|
645
|
+
el('brd-model-hint').textContent = '这次会话用 ' + route + ';没存进配置(' + error.message + ')。';
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function saveConfig() {
|
|
650
|
+
var button = el('btn-save-cfg');
|
|
651
|
+
button.disabled = true;
|
|
652
|
+
return request('config', { patch: configPatch() }).then(function (snapshot) {
|
|
653
|
+
applyConfig(snapshot);
|
|
654
|
+
if (snapshot.rejected && snapshot.rejected.length) {
|
|
655
|
+
toast('这些键本版不认识:' + snapshot.rejected.join('、'), 'err');
|
|
656
|
+
} else {
|
|
657
|
+
toast('开关已保存', 'ok');
|
|
658
|
+
}
|
|
659
|
+
return runStatus();
|
|
660
|
+
}).catch(function (error) {
|
|
661
|
+
toast('保存失败:' + error.message, 'err');
|
|
662
|
+
}).then(function () {
|
|
663
|
+
button.disabled = false;
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/* 体检结论按严重度直接摊开:fatal 会挡住开工,warn 是「能跑但难受」,info 是知道了就行。 */
|
|
668
|
+
function runStatus() {
|
|
669
|
+
return fetch(BASE + 'status', { credentials: 'same-origin' })
|
|
670
|
+
.then(function (response) { return response.json().catch(function () { return {}; }); })
|
|
671
|
+
.then(function (snapshot) {
|
|
672
|
+
var check = snapshot.selfCheck || { findings: [] };
|
|
673
|
+
el('cfg-state').className = 'pill ' + (snapshot.enabled ? 'ok' : '');
|
|
674
|
+
el('cfg-state').textContent = snapshot.enabled ? '执行已开启' : '执行未开启';
|
|
675
|
+
var lines = (check.findings || []).map(function (finding) {
|
|
676
|
+
return '[' + finding.severity + '] ' + finding.title + '\n ' + finding.detail + '\n → ' + finding.hint;
|
|
677
|
+
});
|
|
678
|
+
el('cfg-findings').className = 'out show';
|
|
679
|
+
el('cfg-findings').textContent = lines.length
|
|
680
|
+
? (check.ok ? '没有阻塞项:\n\n' : '有阻塞项,先修这个:\n\n') + lines.join('\n\n')
|
|
681
|
+
: '体检通过,没有发现问题。';
|
|
682
|
+
})
|
|
683
|
+
.catch(function (error) {
|
|
684
|
+
el('cfg-findings').className = 'out show';
|
|
685
|
+
el('cfg-findings').textContent = '体检失败:' + error.message;
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/* ── 判据草稿 + 填到一半别丢 ───────────────────────────────────────────────
|
|
690
|
+
两件事都刻意轻:判据草稿是「把描述里像要求的话捞出来」,不是让模型编;
|
|
691
|
+
草稿保存只写 localStorage——这是「填到一半别丢」,只在本机浏览器里。 */
|
|
692
|
+
|
|
693
|
+
var DRAFT_KEY = 'card-dispatch:draft:v1';
|
|
694
|
+
var restoredDraft = null;
|
|
695
|
+
|
|
696
|
+
function saveDraft() {
|
|
697
|
+
try {
|
|
698
|
+
localStorage.setItem(DRAFT_KEY, JSON.stringify({
|
|
699
|
+
title: el('brd-title').value,
|
|
700
|
+
description: el('brd-desc').value,
|
|
701
|
+
criteria: el('brd-criteria').value,
|
|
702
|
+
workspaceId: el('brd-workspace').value,
|
|
703
|
+
parallel: el('brd-parallel').checked
|
|
704
|
+
}));
|
|
705
|
+
} catch (error) {
|
|
706
|
+
/* 无痕模式 / 存储满:写不进去就算了,别因为它把页面搞崩 */
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function restoreDraft() {
|
|
711
|
+
var raw = null;
|
|
712
|
+
try { raw = localStorage.getItem(DRAFT_KEY); } catch (error) { return; }
|
|
713
|
+
if (!raw) return;
|
|
714
|
+
var draft = null;
|
|
715
|
+
try { draft = JSON.parse(raw); } catch (error) { return; }
|
|
716
|
+
if (!draft || typeof draft !== 'object') return;
|
|
717
|
+
if (draft.title) el('brd-title').value = draft.title;
|
|
718
|
+
if (draft.description) el('brd-desc').value = draft.description;
|
|
719
|
+
if (draft.criteria) el('brd-criteria').value = draft.criteria;
|
|
720
|
+
if (typeof draft.parallel === 'boolean') el('brd-parallel').checked = draft.parallel;
|
|
721
|
+
restoredDraft = draft;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/* 生成判据草稿:模型优先(用宿主设置里已添加的模型),失败自动退回规则提炼。
|
|
725
|
+
只往空里加,已有的行一条不动——人手改过的内容不能被机器覆盖。 */
|
|
726
|
+
function loadModels() {
|
|
727
|
+
return fetch(BASE + 'models', { credentials: 'same-origin' })
|
|
728
|
+
.then(function (response) { return response.ok ? response.json() : null; })
|
|
729
|
+
.then(function (data) {
|
|
730
|
+
var select = el('brd-model');
|
|
731
|
+
if (!data || !data.options || data.options.length === 0) {
|
|
732
|
+
select.innerHTML = '<option value="">(没读到模型)</option>';
|
|
733
|
+
el('brd-model-hint').textContent = '没读到模型:'
|
|
734
|
+
+ ((data && data.error) || '宿主没返回模型清单')
|
|
735
|
+
+ '。解析与生成草稿都会退回规则。';
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
select.innerHTML = '';
|
|
739
|
+
data.options.forEach(function (option) {
|
|
740
|
+
var node = document.createElement('option');
|
|
741
|
+
node.value = option.route;
|
|
742
|
+
node.textContent = option.label + (option.isDefault ? '(默认)' : '');
|
|
743
|
+
select.appendChild(node);
|
|
744
|
+
});
|
|
745
|
+
if (data.effective) select.value = data.effective;
|
|
746
|
+
/* 用 configured(老宿主也有这个字段)判断"存没存":没存就是跟随宿主默认。 */
|
|
747
|
+
var pinned = String(data.configured || '') !== '';
|
|
748
|
+
el('brd-model-hint').textContent = '全局:解析并拆分、生成草稿都用它'
|
|
749
|
+
+ (pinned ? '(已存进配置:models.draftRoute)' : '(跟随宿主默认,换一个就会存进配置)') + '。';
|
|
750
|
+
})
|
|
751
|
+
.catch(function () { el('brd-model').innerHTML = '<option value="">(模型清单不可用)</option>'; });
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/* 一次点击走完:需求原文 →(模型)标题/描述/判据 →(规则)拆成哪几张卡
|
|
755
|
+
→(模型)每张卡的提示词开头 → 表单填好、预览立刻出来。
|
|
756
|
+
标题只在空着时填(不覆盖人写的那句),判据只追加不覆盖。 */
|
|
757
|
+
var aiIntros = {};
|
|
758
|
+
|
|
759
|
+
function aiParse() {
|
|
760
|
+
var source = el('brd-desc').value.trim() || el('brd-title').value.trim();
|
|
761
|
+
if (!source) { toast('先把需求原文贴进描述里', 'err'); return; }
|
|
762
|
+
var button = el('btn-parse');
|
|
763
|
+
button.disabled = true;
|
|
764
|
+
button.textContent = '解析并拆分中…';
|
|
765
|
+
el('brd-parse-hint').textContent = '正在让模型整理需求、给每张卡写提示词…(两次调用会计费)';
|
|
766
|
+
request(hostHasPlan ? 'plan' : 'parse', {
|
|
767
|
+
text: source,
|
|
768
|
+
model: el('brd-model').value,
|
|
769
|
+
parallel: el('brd-parallel').checked
|
|
770
|
+
})
|
|
771
|
+
.then(function (result) {
|
|
772
|
+
if (result.prompts) {
|
|
773
|
+
el('brd-prompt').textContent = '【解析用】\n' + result.prompts.parse
|
|
774
|
+
+ '\n\n【每张卡的提示词用】\n' + result.prompts.cardIntros;
|
|
775
|
+
el('brd-prompt-box').style.display = '';
|
|
776
|
+
} else if (result.prompt) {
|
|
777
|
+
// 旧宿主只做字段整理,没有第二段提示词
|
|
778
|
+
el('brd-prompt').textContent = result.prompt;
|
|
779
|
+
el('brd-prompt-box').style.display = '';
|
|
780
|
+
}
|
|
781
|
+
if (!result.ok) {
|
|
782
|
+
el('brd-parse-hint').textContent = (result.notes || []).join(' ') || '没解析出内容';
|
|
783
|
+
toast('没解析出内容:' + ((result.notes || [])[0] || '原文太短?'), 'err');
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
var keptTitle = false;
|
|
787
|
+
if (result.title) {
|
|
788
|
+
if (el('brd-title').value.trim() === '') el('brd-title').value = result.title;
|
|
789
|
+
else keptTitle = true;
|
|
790
|
+
}
|
|
791
|
+
if (result.description) el('brd-desc').value = result.description;
|
|
792
|
+
|
|
793
|
+
var lines = el('brd-criteria').value.split('\n').map(function (line) { return line.trim(); })
|
|
794
|
+
.filter(function (line) { return line !== ''; });
|
|
795
|
+
var added = 0;
|
|
796
|
+
(result.criteria || []).forEach(function (item) {
|
|
797
|
+
if (lines.indexOf(item) < 0) { lines.push(item); added += 1; }
|
|
798
|
+
});
|
|
799
|
+
if (added > 0) el('brd-criteria').value = lines.join('\n');
|
|
800
|
+
|
|
801
|
+
/* 提示词跟着这一轮走:写卡时 /split 收到同一份 intros,预览与写入不会各说一套。 */
|
|
802
|
+
aiIntros = result.intros || {};
|
|
803
|
+
var notes = (result.notes || []).slice();
|
|
804
|
+
if (keptTitle) notes.push('标题保留了你写的那句。');
|
|
805
|
+
var written = Object.keys(aiIntros).length;
|
|
806
|
+
el('brd-parse-hint').textContent = notes.join(' ')
|
|
807
|
+
+ (written > 0 ? '(' + written + ' 张卡的提示词已按需求写好)' : '');
|
|
808
|
+
saveDraft();
|
|
809
|
+
toast((result.source === 'model' ? '解析完成' : '按格式解析完成')
|
|
810
|
+
+ (added > 0 ? ':判据 +' + added : '')
|
|
811
|
+
+ (written > 0 ? ',提示词 ' + written + ' 段' : ''), 'ok');
|
|
812
|
+
previewSplit();
|
|
813
|
+
})
|
|
814
|
+
.catch(function (error) {
|
|
815
|
+
el('brd-parse-hint').textContent = '解析失败:' + error.message;
|
|
816
|
+
toast('解析失败:' + error.message, 'err');
|
|
817
|
+
})
|
|
818
|
+
.then(function () {
|
|
819
|
+
button.disabled = false;
|
|
820
|
+
button.textContent = 'AI 解析并拆分';
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
function generateCriteria() {
|
|
825
|
+
var button = el('btn-criteria');
|
|
826
|
+
button.disabled = true;
|
|
827
|
+
button.textContent = '生成中…';
|
|
828
|
+
request('criteria', {
|
|
829
|
+
title: el('brd-title').value.trim(),
|
|
830
|
+
description: el('brd-desc').value.trim(),
|
|
831
|
+
model: el('brd-model').value
|
|
832
|
+
})
|
|
833
|
+
.then(function (result) {
|
|
834
|
+
var lines = el('brd-criteria').value.split('\n').map(function (line) { return line.trim(); })
|
|
835
|
+
.filter(function (line) { return line !== ''; });
|
|
836
|
+
var added = 0;
|
|
837
|
+
(result.criteria || []).forEach(function (item) {
|
|
838
|
+
if (lines.indexOf(item) < 0) { lines.push(item); added += 1; }
|
|
839
|
+
});
|
|
840
|
+
if (added > 0) el('brd-criteria').value = lines.join('\n');
|
|
841
|
+
el('brd-criteria-hint').textContent = (result.notes || []).join(' ')
|
|
842
|
+
+ (added === 0 && (result.criteria || []).length > 0 ? '(这些判据已经在里面了)' : '');
|
|
843
|
+
saveDraft();
|
|
844
|
+
toast(added > 0
|
|
845
|
+
? (result.source === 'model' ? '模型写了 ' : '加了 ') + added + ' 条判据草稿'
|
|
846
|
+
: '没有新判据可加', added > 0 ? 'ok' : 'err');
|
|
847
|
+
})
|
|
848
|
+
.catch(function (error) { toast('生成失败:' + error.message, 'err'); })
|
|
849
|
+
.then(function () { button.disabled = false; button.textContent = '生成草稿'; });
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/* ── 从文档导入 ────────────────────────────────────────────────────────────
|
|
853
|
+
文件由浏览器读成文本再发给插件,插件不碰磁盘路径——也就没有「它能读哪些目录」的权限问题。
|
|
854
|
+
抽取规则在服务端(纯函数、有单测),这里只负责把结果填进表单。 */
|
|
855
|
+
|
|
856
|
+
function importDoc(event) {
|
|
857
|
+
var file = event.target.files && event.target.files[0];
|
|
858
|
+
if (!file) return;
|
|
859
|
+
var hint = el('brd-file-hint');
|
|
860
|
+
hint.textContent = '正在读 ' + file.name + '…';
|
|
861
|
+
file.text()
|
|
862
|
+
.then(function (text) { return request('import', { filename: file.name, text: text }); })
|
|
863
|
+
.then(function (result) {
|
|
864
|
+
var warnings = (result.warnings || []).slice();
|
|
865
|
+
if (result.title) el('brd-title').value = result.title;
|
|
866
|
+
if (result.description) el('brd-desc').value = result.description;
|
|
867
|
+
if (result.criteria && result.criteria.length) el('brd-criteria').value = result.criteria.join('\n');
|
|
868
|
+
if (result.ok) {
|
|
869
|
+
hint.textContent = '已导入 ' + file.name + ':标题 + 描述 + ' + (result.criteria || []).length + ' 条判据'
|
|
870
|
+
+ (warnings.length ? '(' + warnings.join(';') + ')' : '');
|
|
871
|
+
toast('已导入 ' + file.name, 'ok');
|
|
872
|
+
} else {
|
|
873
|
+
hint.textContent = warnings.join(';') || '这个文件里没抽出可用的内容';
|
|
874
|
+
toast(warnings[0] || '导入失败', 'err');
|
|
875
|
+
}
|
|
876
|
+
})
|
|
877
|
+
.catch(function (error) {
|
|
878
|
+
hint.textContent = '导入失败:' + error.message;
|
|
879
|
+
toast('导入失败:' + error.message, 'err');
|
|
880
|
+
})
|
|
881
|
+
.then(function () { event.target.value = ''; }); /* 允许重复选同一个文件 */
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
checkBoard();
|
|
885
|
+
restoreDraft();
|
|
886
|
+
loadWorkspaces();
|
|
887
|
+
loadModels();
|
|
888
|
+
runStatus();
|
|
889
|
+
el('brd-file').addEventListener('change', importDoc);
|
|
890
|
+
['brd-title', 'brd-desc', 'brd-criteria'].forEach(function (id) {
|
|
891
|
+
el(id).addEventListener('input', function () { dropIntros(); saveDraft(); });
|
|
892
|
+
});
|
|
893
|
+
el('brd-workspace').addEventListener('change', saveDraft);
|
|
894
|
+
el('brd-parallel').addEventListener('change', function () { dropIntros(); saveDraft(); });
|
|
895
|
+
el('brd-model').addEventListener('change', function () {
|
|
896
|
+
var route = el('brd-model').value;
|
|
897
|
+
if (route) saveModelChoice(route);
|
|
898
|
+
});
|
|
899
|
+
/* 团队开关立刻生效:它决定拆不拆子卡、也就是这次会开几条会话,
|
|
900
|
+
让人勾完再点一次「保存」才知道预览变了,属于会让人算错账的那种设计。 */
|
|
901
|
+
el('cfg-teamrun').addEventListener('change', function () {
|
|
902
|
+
syncTeamRunUi();
|
|
903
|
+
saveConfig().then(function () {
|
|
904
|
+
if (el('brd-preview').textContent.trim() !== '') previewSplit();
|
|
905
|
+
});
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
/* 宿主同步:一次拉 /view,判断「宿主是不是旧代码」「这个文档是不是旧的」。
|
|
909
|
+
页面每次请求都从磁盘读,所以**新页面 + 旧宿主**是可能的(改了插件没重启)——
|
|
910
|
+
那时 /config、/status 都是 404,必须明说「重启一次应用」,而不是让人对着报错猜。 */
|
|
911
|
+
var hostSynced = false;
|
|
912
|
+
|
|
913
|
+
function syncHost() {
|
|
914
|
+
fetch(BASE + 'view', { credentials: 'same-origin' })
|
|
915
|
+
.then(function (response) { return response.ok ? response.json() : null; })
|
|
916
|
+
.then(function (snapshot) {
|
|
917
|
+
if (!snapshot) return;
|
|
918
|
+
var caps = snapshot.capabilities || [];
|
|
919
|
+
// 宿主没有 /split = 它还是旧代码。这种情况 reload 没用(旧代码在启动时就固定了),
|
|
920
|
+
// 只能重启应用;先把按钮禁掉,别让人点了只看到 404,更不能陷入 20 秒一次的重载循环。
|
|
921
|
+
var ready = caps.indexOf('split') >= 0;
|
|
922
|
+
el('btn-push').disabled = !ready;
|
|
923
|
+
el('btn-preview').disabled = !ready;
|
|
924
|
+
if (!ready) {
|
|
925
|
+
el('board-state').className = 'pill bad';
|
|
926
|
+
el('board-state').textContent = '宿主是旧代码:重启一次应用';
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
var hasConfig = caps.indexOf('config') >= 0;
|
|
930
|
+
hostHasRework = caps.indexOf('rework') >= 0;
|
|
931
|
+
var hasImport = caps.indexOf('import') >= 0;
|
|
932
|
+
// 旧宿主没有 /plan:解析按钮还能用(退回 /parse),只是不会顺手拆卡、也不写提示词。
|
|
933
|
+
hostHasPlan = caps.indexOf('plan') >= 0;
|
|
934
|
+
if (!hostHasPlan) {
|
|
935
|
+
el('brd-parse-hint').textContent = '宿主是旧代码:重启一次应用才能「解析并拆分 + 写每张卡的提示词」;现在只能整理字段。';
|
|
936
|
+
}
|
|
937
|
+
el('btn-rework').disabled = !hostHasRework || !lastPlan;
|
|
938
|
+
el('brd-file').disabled = !hasImport;
|
|
939
|
+
el('brd-model').disabled = caps.indexOf('models') < 0 || caps.indexOf('criteria') < 0;
|
|
940
|
+
if (!hasImport) el('brd-file-hint').textContent = '宿主是旧代码:重启一次应用才能导入文档。';
|
|
941
|
+
el('btn-save-cfg').disabled = !hasConfig;
|
|
942
|
+
el('btn-recheck').disabled = !hasConfig;
|
|
943
|
+
if (!hasConfig) {
|
|
944
|
+
el('cfg-state').className = 'pill bad';
|
|
945
|
+
el('cfg-state').textContent = '宿主是旧代码:重启一次应用';
|
|
946
|
+
el('cfg-file').textContent = '开关与体检要新版宿主才有;页面已经是新的,重启一次应用即可对上。';
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
if (!hostSynced) {
|
|
950
|
+
hostSynced = true;
|
|
951
|
+
loadConfig();
|
|
952
|
+
runStatus();
|
|
953
|
+
}
|
|
954
|
+
// 宿主是新的,但这个 iframe 里的文档是旧的(改了 HTML 没刷新)→ 整页重载
|
|
955
|
+
if (snapshot.pageVersion && snapshot.pageVersion !== PAGE_VERSION) location.reload();
|
|
956
|
+
})
|
|
957
|
+
.catch(function () {});
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
syncHost();
|
|
961
|
+
setInterval(syncHost, 20000);
|
|
962
|
+
</script>
|
|
963
|
+
</body>
|
|
964
|
+
</html>
|