codebee 0.1.47 → 0.1.49
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/CHANGELOG.md +12 -0
- package/app/core/aiflavor.py +80 -2
- package/app/core/automation.py +55 -2
- package/app/core/flows.py +12 -3
- package/app/core/hooks.py +156 -0
- package/app/core/pipeline.py +135 -8
- package/app/core/planner.py +127 -11
- package/app/core/store.py +67 -1
- package/app/core/usage.py +290 -286
- package/app/core/volumes.py +355 -0
- package/app/core/zentao.py +9 -4
- package/app/main.py +38 -0
- package/app/ui/app.js +737 -69
- package/app/ui/i18n.js +49 -1
- package/app/ui/index.html +81 -7
- package/app/ui/style.css +177 -0
- package/package.json +1 -1
package/app/ui/app.js
CHANGED
|
@@ -10,7 +10,7 @@ function codebeeDocumentTitle(lang) {
|
|
|
10
10
|
document.title = codebeeDocumentTitle((localStorage.getItem("orch.lang") || "zh").toLowerCase());
|
|
11
11
|
|
|
12
12
|
const $ = (id) => document.getElementById(id);
|
|
13
|
-
const S = { state: null, catalog: null, catSig: "", providers: null, bindings: null, modelsSig: "", bindSig: "", tab: "tasks", detailRunId: null, pollTimer: null, showArchived: false, /* 会话内开关:每次加载默认隐藏已归档、图标不选中(不持久化,见 btn-side-arch) */ selProvs: {}, selModels: {}, selRuns: {}, bindSel: {}, catalogChecking: false, updateCheckAt: 0, control: null, sseLive: false, es: null, flows: null, orch: null, skills: null, orchSig: "", settings: null, sessionAgents: new Set(), atts: [], gitInfo: null, gitWb: null, gitWbKey: "", gitWbAt: 0, gitWbBusy: false, creatingTask: false, inspKey: null, inspData: null, inspSig: "", inspAt: 0, inspTab: "git", inspAutoSig: "", rdTab: null, rdTabSig: "", rdTabPin: false, _rdCtx: {}, lastPrefs: null, prefsApplied: false };
|
|
13
|
+
const S = { state: null, catalog: null, catSig: "", providers: null, bindings: null, modelsSig: "", bindSig: "", tab: "tasks", mainPage: "", /* 主栏正显示的全局页("" = 新建任务表单);左栏是任务树还是设置导航看 body.settings-mode */ detailRunId: null, pollTimer: null, showArchived: false, /* 会话内开关:每次加载默认隐藏已归档、图标不选中(不持久化,见 btn-side-arch) */ selProvs: {}, selModels: {}, selRuns: {}, bindSel: {}, catalogChecking: false, updateCheckAt: 0, control: null, sseLive: false, es: null, flows: null, orch: null, skills: null, orchSig: "", settings: null, sessionAgents: new Set(), atts: [], gitInfo: null, gitWb: null, gitWbKey: "", gitWbAt: 0, gitWbBusy: false, creatingTask: false, inspKey: null, inspData: null, inspSig: "", inspAt: 0, inspTab: "git", inspAutoSig: "", rdTab: null, rdTabSig: "", rdTabPin: false, _rdCtx: {}, lastPrefs: null, prefsApplied: false };
|
|
14
14
|
|
|
15
15
|
/* ---------------------------------------------------------- 任务类型(流程) */
|
|
16
16
|
async function loadFlows() {
|
|
@@ -301,7 +301,8 @@ function onTypeChange() {
|
|
|
301
301
|
const bibleCreate = $("bible-create-field");
|
|
302
302
|
const isSerialReview = isReview && !!(flow && flow.serial);
|
|
303
303
|
if (bibleCreate) bibleCreate.classList.toggle("hidden", !isSerialReview);
|
|
304
|
-
const clearSerialFields = () => ["f-chapters", "f-words-per-ch", "f-variants", "f-bible"
|
|
304
|
+
const clearSerialFields = () => ["f-chapters", "f-words-per-ch", "f-variants", "f-bible",
|
|
305
|
+
"f-vol-chapters", "f-vol-spec"].forEach((id) => {
|
|
305
306
|
const el = $(id);
|
|
306
307
|
if (el) el.value = "";
|
|
307
308
|
});
|
|
@@ -330,6 +331,12 @@ function onTypeChange() {
|
|
|
330
331
|
if (flow.serial) {
|
|
331
332
|
if (!$("f-chapters").value) $("f-chapters").value = lp.chapters || flow.serial.chapters || "";
|
|
332
333
|
if (!$("f-words-per-ch").value) $("f-words-per-ch").value = lp.words_per_chapter || flow.serial.words_per_chapter || "";
|
|
334
|
+
// 分卷开箱即用:没填过就预填一个常见每卷章数(用户可改;清空 = 不分卷)。
|
|
335
|
+
// 卷边界由后端按全书章号推导,续写批次自动接上同一卷。
|
|
336
|
+
if (!$("f-vol-chapters").value && !$("f-vol-spec").value) {
|
|
337
|
+
$("f-vol-chapters").value = (lp.volume_chapters != null && lp.volume_chapters !== "")
|
|
338
|
+
? lp.volume_chapters : (flow.serial.volume_chapters || 20);
|
|
339
|
+
}
|
|
333
340
|
} else clearSerialFields();
|
|
334
341
|
} else {
|
|
335
342
|
// 隐藏控件仍保留 DOM 值;换流程时清理,避免章节/圣经状态污染下一次提交。
|
|
@@ -1002,6 +1009,7 @@ function applyState(d) {
|
|
|
1002
1009
|
$("conn").textContent = t("已连接");
|
|
1003
1010
|
$("conn").className = "conn ok";
|
|
1004
1011
|
restoreInspector(); // 刷新后恢复上次打开的检查器(只在已开时为空操作)
|
|
1012
|
+
ovMaybeRefresh(); // 停在概览页时:概览语与最近运行跟着这次状态一起刷新
|
|
1005
1013
|
}
|
|
1006
1014
|
|
|
1007
1015
|
/* ---------------------------------------------------------- 供应商健康告警横幅 */
|
|
@@ -2464,6 +2472,12 @@ async function createTask() {
|
|
|
2464
2472
|
words_per_chapter: parseInt($("f-words-per-ch").value, 10) || 2500,
|
|
2465
2473
|
variants: Math.max(1, Math.min(3, parseInt($("f-variants").value, 10) || 1)),
|
|
2466
2474
|
};
|
|
2475
|
+
// 分卷:显式卷结构文本优先(后端解析成卷表),否则用每卷章数。
|
|
2476
|
+
// 两个都留空 = 不分卷(后端收到 0/缺字段即不分卷)。
|
|
2477
|
+
const volSpec = ($("f-vol-spec") || {}).value ? $("f-vol-spec").value.trim() : "";
|
|
2478
|
+
const volPer = parseInt(($("f-vol-chapters") || {}).value, 10);
|
|
2479
|
+
if (volSpec) payload.serial.volumes_text = volSpec;
|
|
2480
|
+
else if (volPer >= 2) payload.serial.volume_chapters = volPer;
|
|
2467
2481
|
} else if (flow.serial) {
|
|
2468
2482
|
// 显式 null 覆盖 serial_novel 的流程默认,空章节就是单稿件。
|
|
2469
2483
|
payload.serial = null;
|
|
@@ -3138,6 +3152,16 @@ function newFromTask(id) {
|
|
|
3138
3152
|
$("f-chapters").value = tk.serial ? tk.serial.chapters : "";
|
|
3139
3153
|
$("f-words-per-ch").value = tk.serial ? tk.serial.words_per_chapter : "";
|
|
3140
3154
|
$("f-variants").value = tk.serial && tk.serial.variants ? tk.serial.variants : "";
|
|
3155
|
+
// 分卷:还原每卷章数;指定卷结构还原成可读文本(卷名 + 章数/章号范围)
|
|
3156
|
+
$("f-vol-chapters").value = tk.serial && tk.serial.volume_chapters ? tk.serial.volume_chapters : "";
|
|
3157
|
+
$("f-vol-spec").value = tk.serial && Array.isArray(tk.serial.volumes)
|
|
3158
|
+
? tk.serial.volumes.map((v) => {
|
|
3159
|
+
const ttl = (v && v.title) || "未命名";
|
|
3160
|
+
if (v && v.start && v.end) return ttl + " 第" + v.start + "-" + v.end + "章";
|
|
3161
|
+
if (v && v.chapters) return ttl + " " + v.chapters + "章";
|
|
3162
|
+
return ttl;
|
|
3163
|
+
}).join(";")
|
|
3164
|
+
: "";
|
|
3141
3165
|
const want = new Set(tk.critics || []);
|
|
3142
3166
|
$("f-critics").querySelectorAll("input").forEach((i) => { i.checked = want.has(i.value); });
|
|
3143
3167
|
// 同目录同稿件名再开一篇会覆盖旧产出——提醒但不阻止(有意重写也合理)
|
|
@@ -3643,7 +3667,7 @@ function showDetailInMain() {
|
|
|
3643
3667
|
function jumpToRun(id) {
|
|
3644
3668
|
S.focusStep = 0;
|
|
3645
3669
|
S.focusDone = false;
|
|
3646
|
-
if (document.body.classList.contains("settings-mode")) switchTab("runs");
|
|
3670
|
+
if (document.body.classList.contains("settings-mode")) switchTab("runs", "settings"); // 已在设置里:留在设置侧栏
|
|
3647
3671
|
else showDetailInMain();
|
|
3648
3672
|
openRun(id);
|
|
3649
3673
|
}
|
|
@@ -3991,12 +4015,17 @@ function closeRun() {
|
|
|
3991
4015
|
detailSideReset();
|
|
3992
4016
|
$("run-detail").classList.add("hidden");
|
|
3993
4017
|
renderChatNav(); // 解除 main.chat-fill(对话为主的固定高度),恢复外层滚动
|
|
3994
|
-
if (document.body.classList.contains("settings-mode")) {
|
|
4018
|
+
if (document.body.classList.contains("settings-mode") || S.mainPage === "runs") {
|
|
4019
|
+
// 回运行列表:设置导航里点进来的,或主栏本就停在运行记录页(快捷导航/概览的入口)
|
|
3995
4020
|
document.querySelector("#sub-runs .panel:first-child").classList.remove("hidden");
|
|
4021
|
+
} else if (S.mainPage) {
|
|
4022
|
+
// 主栏停在别的快捷导航页(概览/自动化):返回点开详情前那一页,左栏任务树不动
|
|
4023
|
+
switchTab(S.mainPage, "main");
|
|
3996
4024
|
} else {
|
|
3997
4025
|
// 从主视图(侧栏点任务行)进来的详情:返回直接回任务页,不露出运行列表
|
|
3998
4026
|
document.querySelectorAll("#page-settings .subpage").forEach((d) => d.classList.toggle("hidden", d.id !== "sub-tasks"));
|
|
3999
4027
|
document.querySelectorAll(".set-item").forEach((b) => b.classList.toggle("active", b.dataset.sub === "tasks"));
|
|
4028
|
+
document.querySelectorAll(".qitem").forEach((b) => b.classList.remove("active"));
|
|
4000
4029
|
const title = $("page-title");
|
|
4001
4030
|
if (title) title.textContent = tabTitle("tasks");
|
|
4002
4031
|
}
|
|
@@ -4725,8 +4754,11 @@ window.pbUploadChapter = async function (taskId, platform) {
|
|
|
4725
4754
|
if (!box.classList.contains("hidden")) { box.classList.add("hidden"); return; }
|
|
4726
4755
|
box.classList.remove("hidden");
|
|
4727
4756
|
box.innerHTML = '<div class="pb-loading">' + esc(t("正在列出章节文件…")) + "</div>";
|
|
4728
|
-
let wd = "";
|
|
4729
|
-
try {
|
|
4757
|
+
let wd = "", pubTask = null;
|
|
4758
|
+
try {
|
|
4759
|
+
pubTask = ((S.state || {}).tasks || []).find((x) => x.id === taskId);
|
|
4760
|
+
wd = (pubTask && pubTask.workdir) || "";
|
|
4761
|
+
} catch (e) { /* 兜底空 */ }
|
|
4730
4762
|
if (!wd) { box.innerHTML = '<div class="pb-err">' + esc(t("找不到任务工作目录")) + "</div>"; return; }
|
|
4731
4763
|
let files = [];
|
|
4732
4764
|
try {
|
|
@@ -4739,11 +4771,33 @@ window.pbUploadChapter = async function (taskId, platform) {
|
|
|
4739
4771
|
}
|
|
4740
4772
|
if (!files.length) { box.innerHTML = '<div class="pb-err">' + esc(t("工作目录里没有 .md 章稿")) + "</div>"; return; }
|
|
4741
4773
|
files.sort((a, b) => (a.name > b.name ? 1 : -1));
|
|
4742
|
-
|
|
4743
|
-
|
|
4744
|
-
|
|
4745
|
-
|
|
4746
|
-
|
|
4774
|
+
const item = (f) =>
|
|
4775
|
+
'<button class="ghost pb-ch-item" onclick="pbSendChapter(\'' + esc(taskId) + "', '" + platform +
|
|
4776
|
+
'\',\'' + esc(f.name) + '\')" title="' + esc(f.name) + '">' + esc(f.name.split("/").pop()) + "</button>";
|
|
4777
|
+
const hint = '<div class="pb-hint">' + esc(t("选一章发送(表单填好后停,人工确认提交);已成功发过的章会被台账拦下:")) + "</div>";
|
|
4778
|
+
// 最高章号:让卷规划表铺到最后一章(否则超出显式卷表的章节会掉进「其他」)
|
|
4779
|
+
let maxCh = 0;
|
|
4780
|
+
files.forEach((f) => {
|
|
4781
|
+
const n = chapterNumOf(f.name);
|
|
4782
|
+
if (n > maxCh) maxCh = n;
|
|
4783
|
+
});
|
|
4784
|
+
const plan = volumePlanOf(pubTask, maxCh);
|
|
4785
|
+
if (!plan.length) { box.innerHTML = hint + files.map(item).join(""); return; }
|
|
4786
|
+
// 分卷:按卷分组列出可发章节(章号取自 chapter-NN.md)。未落入卷的文件归「其他」。
|
|
4787
|
+
const groups = plan.map((v) => ({ v: v, files: [] }));
|
|
4788
|
+
const rest = [];
|
|
4789
|
+
files.forEach((f) => {
|
|
4790
|
+
const n = chapterNumOf(f.name);
|
|
4791
|
+
if (!n) { rest.push(f); return; }
|
|
4792
|
+
const g = groups.find((x) => n >= x.v.first && (x.v.last == null || n <= x.v.last));
|
|
4793
|
+
if (g) g.files.push(f); else rest.push(f);
|
|
4794
|
+
});
|
|
4795
|
+
box.innerHTML = hint + groups.filter((g) => g.files.length).map((g) => {
|
|
4796
|
+
const label = t("第") + " " + g.v.vol + " " + t("卷") + (g.v.title ? " 《" + g.v.title + "》" : "");
|
|
4797
|
+
return '<div class="pb-vol"><div class="pb-vol-head">' + esc(label) + "</div>" +
|
|
4798
|
+
g.files.map(item).join("") + "</div>";
|
|
4799
|
+
}).join("") + (rest.length ? '<div class="pb-vol"><div class="pb-vol-head">' +
|
|
4800
|
+
esc(t("其他文件")) + "</div>" + rest.map(item).join("") + "</div>" : "");
|
|
4747
4801
|
};
|
|
4748
4802
|
|
|
4749
4803
|
window.pbSendChapter = async function (taskId, platform, relName) {
|
|
@@ -5231,7 +5285,7 @@ window.toggleInspector = function () {
|
|
|
5231
5285
|
openInspector(key);
|
|
5232
5286
|
};
|
|
5233
5287
|
|
|
5234
|
-
/*
|
|
5288
|
+
/* 检查器只属于「列表快捷预览」上下文:设置子页、快捷导航页、新建表单、运行/任务详情一律收起。
|
|
5235
5289
|
* 详情页已铺开全部信息(蜂巢/步骤/成果/版本/圣经/指挥),检查器留着只会同屏
|
|
5236
5290
|
* 出现两份标题/成品/Git——让它让位,但不丢选中,回到列表上下文自动滑回 */
|
|
5237
5291
|
function syncInspectorVis() {
|
|
@@ -5243,11 +5297,12 @@ function syncInspectorVis() {
|
|
|
5243
5297
|
// 主视图下正在看「要完成什么?」新建表单:不是任务上下文,不自动滑出。
|
|
5244
5298
|
// (本函数只在模式切换时调用,不在轮询里——用户在表单页手动点树里的任务,
|
|
5245
5299
|
// openInspector 直接开,不会被这里关掉)
|
|
5246
|
-
const onComposer = !document.body.classList.contains("settings-mode") &&
|
|
5300
|
+
const onComposer = !S.mainPage && !document.body.classList.contains("settings-mode") &&
|
|
5247
5301
|
!$("sub-tasks").classList.contains("hidden");
|
|
5248
5302
|
// 主栏正开着运行/任务详情:详情页是全功能视图,检查器同屏只会重复
|
|
5249
5303
|
const onDetail = !$("run-detail").classList.contains("hidden");
|
|
5250
|
-
|
|
5304
|
+
// 主栏停在快捷导航页(概览/运行记录/自动化):同设置子页,检查器让位不抢版面
|
|
5305
|
+
if (document.body.classList.contains("settings-mode") || S.mainPage || onComposer || onDetail ||
|
|
5251
5306
|
!S.inspKey || runsGone) {
|
|
5252
5307
|
document.body.classList.remove("inspector-open");
|
|
5253
5308
|
insp.classList.add("hidden");
|
|
@@ -5853,36 +5908,117 @@ function stepsMatch(runs, current) {
|
|
|
5853
5908
|
(r.steps || []).some((s) => s.log === current.rel));
|
|
5854
5909
|
}
|
|
5855
5910
|
|
|
5911
|
+
/* 从章稿文件名取全书章号:chapter-07.md → 7;非章稿(或解析不出)返回 0。
|
|
5912
|
+
* 只认「chapter-数字.md」这一约定(与后端 _CH_FILE_RE 一致)。 */
|
|
5913
|
+
function chapterNumOf(name) {
|
|
5914
|
+
const base = String(name || "").split("/").pop();
|
|
5915
|
+
if (!/^chapter-\d{1,4}\.md$/i.test(base)) return 0;
|
|
5916
|
+
const digits = base.slice("chapter-".length, -3);
|
|
5917
|
+
const n = parseInt(digits, 10);
|
|
5918
|
+
return n > 0 ? n : 0;
|
|
5919
|
+
}
|
|
5920
|
+
|
|
5921
|
+
/* 分卷规划表(前端只读副本):把任务的 serial 分卷配置还原成
|
|
5922
|
+
* [{vol, title, first, last}],供发布页按卷分组列章。后端 volumes.py 是唯一
|
|
5923
|
+
* 真源(写作与成书都走它);这里只做「列清单」这一用途的最小推导,取不到
|
|
5924
|
+
* 分卷配置时返回 [](调用方退化为平铺列表)。
|
|
5925
|
+
* serial.volumes 显式卷表:[{title, chapters|start/end}](优先)
|
|
5926
|
+
* serial.volume_chapters 每卷章数:等长切卷(兜底)
|
|
5927
|
+
* last=null 表示开放到书末。 */
|
|
5928
|
+
function volumePlanOf(task, upto) {
|
|
5929
|
+
const s = (task || {}).serial || {};
|
|
5930
|
+
const spec = Array.isArray(s.volumes) ? s.volumes : [];
|
|
5931
|
+
let per = parseInt(s.volume_chapters, 10);
|
|
5932
|
+
if (!(per >= 2)) per = 0;
|
|
5933
|
+
if (!spec.length && !per) return [];
|
|
5934
|
+
const plan = [];
|
|
5935
|
+
let prevLast = 0;
|
|
5936
|
+
const push = (title, first, last) => plan.push({ vol: plan.length + 1, title: title || "", first: first, last: last });
|
|
5937
|
+
for (const it of spec) {
|
|
5938
|
+
if (!it) continue;
|
|
5939
|
+
const first = (it.start > 0) ? it.start : prevLast + 1;
|
|
5940
|
+
let last = null;
|
|
5941
|
+
if (it.end > 0) last = it.end;
|
|
5942
|
+
else if (it.chapters > 0) last = first + it.chapters - 1;
|
|
5943
|
+
if (last != null && last < first) last = first;
|
|
5944
|
+
push(it.title, first, last);
|
|
5945
|
+
if (last == null) return plan; // 开放卷:后面不再有卷
|
|
5946
|
+
prevLast = last;
|
|
5947
|
+
}
|
|
5948
|
+
// 显式卷表用尽后按每卷章数(或末卷长度)续卷,保证覆盖到 upto(与后端
|
|
5949
|
+
// volumes.build_plan 同规则)——否则第 21 章起会掉进「其他文件」分组。
|
|
5950
|
+
const fb = per || (plan.length && plan[plan.length - 1].last != null
|
|
5951
|
+
? plan[plan.length - 1].last - plan[plan.length - 1].first + 1 : 0);
|
|
5952
|
+
if (fb > 0) {
|
|
5953
|
+
let cursor = prevLast + 1;
|
|
5954
|
+
const limit = (upto > 0) ? upto : (plan.length ? prevLast : fb);
|
|
5955
|
+
while (cursor <= limit && plan.length < 200) {
|
|
5956
|
+
push("", cursor, cursor + fb - 1);
|
|
5957
|
+
cursor += fb;
|
|
5958
|
+
}
|
|
5959
|
+
}
|
|
5960
|
+
return plan;
|
|
5961
|
+
}
|
|
5962
|
+
|
|
5963
|
+
|
|
5856
5964
|
/* 连载章节评审卡:每章均分/是否达标/字数 + 逐项目标审稿结果(event_check,
|
|
5857
5965
|
* 借鉴 AI-Novel-Writer:大纲要点逐项判定+正文证据)。非连载 run 无
|
|
5858
|
-
* chapter_scores 保持隐藏。
|
|
5966
|
+
* chapter_scores 保持隐藏。
|
|
5967
|
+
* 分卷:章节卡按卷分组显示(卷名来自 run.volumes / chapter_scores[].vol_title),
|
|
5968
|
+
* 不分卷时退化为原来的平铺列表,视觉零变化。 */
|
|
5859
5969
|
function renderChapterScores(run) {
|
|
5860
5970
|
const box = $("rd-chapters");
|
|
5861
5971
|
if (!box) return;
|
|
5862
5972
|
const cs = run && run.chapter_scores;
|
|
5863
5973
|
if (!Array.isArray(cs) || !cs.length) { box.classList.add("hidden"); return; }
|
|
5864
5974
|
box.classList.remove("hidden");
|
|
5865
|
-
|
|
5866
|
-
|
|
5867
|
-
|
|
5868
|
-
|
|
5869
|
-
|
|
5870
|
-
|
|
5871
|
-
|
|
5872
|
-
|
|
5873
|
-
|
|
5874
|
-
|
|
5875
|
-
|
|
5876
|
-
|
|
5877
|
-
|
|
5878
|
-
'<span class="tag">' + (c.passed ? t("达标") : t("未达标")) + "</span>" +
|
|
5879
|
-
(c.reused ? '<span class="tag">' + t("沿用") + "</span>" : "") +
|
|
5880
|
-
'<span class="ch-meta">' + Number(c.words || 0) + t(" 字") +
|
|
5881
|
-
(c.rounds > 1 ? " · " + c.rounds + t(" 轮") : "") + "</span></div>" +
|
|
5882
|
-
(dims ? '<div class="ch-dims">' + dims + "</div>" : "") +
|
|
5883
|
-
(ev ? '<div class="ch-evs"><span class="hint">' + t("逐项目标核对:") + "</span>" + ev + "</div>" : "") +
|
|
5884
|
-
"</div>";
|
|
5975
|
+
// 卷名表:优先 run.volumes(全书卷规划),退到章节自带的 vol_title
|
|
5976
|
+
const volTitles = {};
|
|
5977
|
+
(run.volumes || []).forEach((v) => { volTitles[v.vol] = v.title || ""; });
|
|
5978
|
+
cs.forEach((c) => {
|
|
5979
|
+
if (c.vol && c.vol_title && !volTitles[c.vol]) volTitles[c.vol] = c.vol_title;
|
|
5980
|
+
});
|
|
5981
|
+
const rowHtml = (c) => {
|
|
5982
|
+
const means = c.means || {};
|
|
5983
|
+
const dims = Object.keys(means).map((d) =>
|
|
5984
|
+
'<span class="ch-dim' + (means[d] >= 7 ? "" : " low") + '">' + esc(d) + " " + means[d] + "</span>").join("");
|
|
5985
|
+
const ev = (c.event_check || []).map((line) => {
|
|
5986
|
+
const ok = /已完成/.test(line), bad = /未完成/.test(line);
|
|
5987
|
+
return '<div class="ch-ev' + (ok ? " ok" : bad ? " bad" : "") + '">' + esc(line) + "</div>";
|
|
5885
5988
|
}).join("");
|
|
5989
|
+
return '<div class="ch-row' + (c.passed ? "" : " fail") + '">' +
|
|
5990
|
+
'<div class="ch-head"><b>' + esc(t("第") + " " + c.chapter + " " + t("章")) + '</b>' +
|
|
5991
|
+
'<span class="ch-title">' + esc(c.title || "") + "</span>" +
|
|
5992
|
+
'<span class="tag">' + (c.passed ? t("达标") : t("未达标")) + "</span>" +
|
|
5993
|
+
(c.reused ? '<span class="tag">' + t("沿用") + "</span>" : "") +
|
|
5994
|
+
'<span class="ch-meta">' + Number(c.words || 0) + t(" 字") +
|
|
5995
|
+
(c.rounds > 1 ? " · " + c.rounds + t(" 轮") : "") + "</span></div>" +
|
|
5996
|
+
(dims ? '<div class="ch-dims">' + dims + "</div>" : "") +
|
|
5997
|
+
(ev ? '<div class="ch-evs"><span class="hint">' + t("逐项目标核对:") + "</span>" + ev + "</div>" : "") +
|
|
5998
|
+
"</div>";
|
|
5999
|
+
};
|
|
6000
|
+
const head = '<h3 class="sec-title">' + t("章节评审") +
|
|
6001
|
+
'<span class="tag">' + cs.filter((c) => c.passed).length + "/" + cs.length + " " + t("达标") + "</span></h3>";
|
|
6002
|
+
const hasVol = cs.some((c) => c.vol);
|
|
6003
|
+
if (!hasVol) { box.innerHTML = head + cs.map(rowHtml).join(""); return; }
|
|
6004
|
+
// 按卷分组(章号顺序,卷内保持原序)
|
|
6005
|
+
const groups = [];
|
|
6006
|
+
cs.forEach((c) => {
|
|
6007
|
+
const v = c.vol || 0;
|
|
6008
|
+
let g = groups.find((x) => x.vol === v);
|
|
6009
|
+
if (!g) { g = { vol: v, rows: [] }; groups.push(g); }
|
|
6010
|
+
g.rows.push(c);
|
|
6011
|
+
});
|
|
6012
|
+
box.innerHTML = head + groups.map((g) => {
|
|
6013
|
+
const title = volTitles[g.vol] || "";
|
|
6014
|
+
const nPass = g.rows.filter((c) => c.passed).length;
|
|
6015
|
+
const label = g.vol ? (t("第") + " " + g.vol + " " + t("卷") + (title ? " 《" + title + "》" : "")) : t("未分卷");
|
|
6016
|
+
return '<div class="ch-vol"><div class="ch-vol-head"><b>' + esc(label) + "</b>" +
|
|
6017
|
+
'<span class="tag">' + nPass + "/" + g.rows.length + " " + t("达标") + "</span>" +
|
|
6018
|
+
'<span class="ch-meta">' + t("第 ") + g.rows[0].chapter + "–" +
|
|
6019
|
+
g.rows[g.rows.length - 1].chapter + t(" 章") + "</span></div>" +
|
|
6020
|
+
g.rows.map(rowHtml).join("") + "</div>";
|
|
6021
|
+
}).join("");
|
|
5886
6022
|
}
|
|
5887
6023
|
|
|
5888
6024
|
function renderPlan(run) {
|
|
@@ -8354,6 +8490,7 @@ function flowForm(fid) {
|
|
|
8354
8490
|
'<div class="field"><label>' + t("连载章节数(留空 = 单稿件)") + '</label><input id="fl-chapters" type="number" min="2" max="20" value="' + (f && f.serial ? f.serial.chapters : "") + '" placeholder="' + t("例:8") + '"></div>' +
|
|
8355
8491
|
'<div class="field"><label>' + t("每章约字数") + '</label><input id="fl-words-per-ch" type="number" min="500" max="8000" step="100" value="' + (f && f.serial ? f.serial.words_per_chapter : "") + '" placeholder="' + t("例:2500") + '"></div>' +
|
|
8356
8492
|
'<div class="field"><label>' + t("同章赛马稿件数") + '</label><input id="fl-variants" type="number" min="1" max="3" step="1" value="' + (f && f.serial ? (f.serial.variants || 1) : 1) + '" placeholder="' + t("1 = 关闭") + '"></div>' +
|
|
8493
|
+
'<div class="field"><label>' + t("分卷:每卷章数(留空 = 不分卷)") + '</label><input id="fl-vol-chapters" type="number" min="2" max="200" step="1" value="' + (f && f.serial && f.serial.volume_chapters ? f.serial.volume_chapters : "") + '" placeholder="' + t("例:20") + '"></div>' +
|
|
8357
8494
|
"</div>" +
|
|
8358
8495
|
'<div class="field"><label>' + t("起草提示词(可选,占位符 __FILE__ __GOAL__ __CONTEXT__ __SKILLS__)") + '</label><textarea id="fl-draft" rows="3" placeholder="' + t("留空 = 内置通用模板") + '">' + esc(f && f.draft_prompt ? f.draft_prompt : "") + "</textarea></div>" +
|
|
8359
8496
|
'<div class="field"><label>' + t("评审提示词(可选,占位符 __DIMKEYS__ __MANUSCRIPT__)") + '</label><textarea id="fl-critique" rows="3" placeholder="' + t("留空 = 内置通用模板") + '">' + esc(f && f.critique_prompt ? f.critique_prompt : "") + "</textarea></div>" +
|
|
@@ -8393,11 +8530,15 @@ async function saveFlow() {
|
|
|
8393
8530
|
const rb = $("fl-rubric").value.split(/[,,、]/).map((s) => s.trim()).filter(Boolean);
|
|
8394
8531
|
if (rb.length) payload.rubric = rb;
|
|
8395
8532
|
const ch = parseInt($("fl-chapters").value, 10);
|
|
8396
|
-
if (ch >= 2)
|
|
8397
|
-
|
|
8398
|
-
|
|
8399
|
-
|
|
8400
|
-
|
|
8533
|
+
if (ch >= 2) {
|
|
8534
|
+
payload.serial = {
|
|
8535
|
+
chapters: ch,
|
|
8536
|
+
words_per_chapter: parseInt($("fl-words-per-ch").value, 10) || 2500,
|
|
8537
|
+
variants: Math.max(1, Math.min(3, parseInt($("fl-variants").value, 10) || 1)),
|
|
8538
|
+
};
|
|
8539
|
+
const vp = parseInt(($("fl-vol-chapters") || {}).value, 10);
|
|
8540
|
+
if (vp >= 2) payload.serial.volume_chapters = vp; // 流程默认分卷(任务级可覆盖)
|
|
8541
|
+
}
|
|
8401
8542
|
const dp = $("fl-draft").value.trim();
|
|
8402
8543
|
if (dp) payload.draft_prompt = dp;
|
|
8403
8544
|
const cp = $("fl-critique").value.trim();
|
|
@@ -8694,6 +8835,26 @@ function autoStatusTag(tsk) {
|
|
|
8694
8835
|
return "";
|
|
8695
8836
|
}
|
|
8696
8837
|
|
|
8838
|
+
/* 运行偏好标签:只显示「偏离默认」的那些(编排默认 auto、思考默认 standard),
|
|
8839
|
+
* 否则每张卡都挂两颗没有信息量的胶囊,反而盖住真正要看的节奏与流程 */
|
|
8840
|
+
function autoPrefTags(tsk) {
|
|
8841
|
+
const MODE = { auto: "自动", fast: "快速", expert: "专家", manual: "手动" };
|
|
8842
|
+
const TH = { auto: "自动", low: "快速", standard: "标准", high: "深度" };
|
|
8843
|
+
const out = [];
|
|
8844
|
+
if (tsk.mode && tsk.mode !== "auto" && MODE[tsk.mode]) {
|
|
8845
|
+
out.push(t("编排模式") + ":" + t(MODE[tsk.mode]));
|
|
8846
|
+
}
|
|
8847
|
+
if (tsk.thinking && tsk.thinking !== "standard" && TH[tsk.thinking]) {
|
|
8848
|
+
out.push(t("思考程度") + ":" + t(TH[tsk.thinking]));
|
|
8849
|
+
}
|
|
8850
|
+
if (tsk.direct_provider_id) {
|
|
8851
|
+
const p = directProviders().find((x) => x.id === tsk.direct_provider_id);
|
|
8852
|
+
out.push(t("对话模型") + ":" + (p ? (p.name || p.id) : tsk.direct_provider_id) +
|
|
8853
|
+
"/" + (tsk.direct_model || t("随厂商推荐")));
|
|
8854
|
+
}
|
|
8855
|
+
return out.map((s) => '<span class="tag">' + esc(s) + "</span>").join("");
|
|
8856
|
+
}
|
|
8857
|
+
|
|
8697
8858
|
function autoCardHtml(tsk) {
|
|
8698
8859
|
const flow = tsk.flow ? flowById(tsk.flow) : null;
|
|
8699
8860
|
return '<div class="card' + (tsk.enabled ? "" : " off") + '"><div class="head">' +
|
|
@@ -8703,7 +8864,8 @@ function autoCardHtml(tsk) {
|
|
|
8703
8864
|
' onchange="autoToggle(\'' + esc(tsk.id) + '\', this.checked)"><span>' + (tsk.enabled ? t("启用中") : t("已停用")) + "</span></label>" +
|
|
8704
8865
|
"</div>" +
|
|
8705
8866
|
'<div class="auto-kind">' + esc(autoKindText(tsk)) +
|
|
8706
|
-
(flow ? '<span class="tag">' + t("流程:") + esc(t(flow.name)) + "</span>" : "") +
|
|
8867
|
+
(flow ? '<span class="tag">' + t("流程:") + esc(t(flow.name)) + "</span>" : "") +
|
|
8868
|
+
autoPrefTags(tsk) + "</div>" +
|
|
8707
8869
|
'<div class="auto-prompt">' + esc(tsk.prompt) + "</div>" +
|
|
8708
8870
|
'<div class="auto-meta">' +
|
|
8709
8871
|
(tsk.enabled && tsk.next_run ? "<span>" + t("下次运行 ") + esc(tsk.next_run) + "</span>" : "") +
|
|
@@ -8746,6 +8908,8 @@ async function autoForm(task, tpl) {
|
|
|
8746
8908
|
const f = task || {};
|
|
8747
8909
|
const kind = f.kind || (tpl && tpl.suggested_kind) || "daily";
|
|
8748
8910
|
const curFlow = f.flow || (tpl && tpl.suggested_flow) || "";
|
|
8911
|
+
const mode = f.mode || "auto";
|
|
8912
|
+
const thinking = f.thinking || "standard";
|
|
8749
8913
|
const wdOpts = AUTO_WD.map((w, i) =>
|
|
8750
8914
|
'<option value="' + i + '"' + (Number(f.weekday) === i || (!task && tpl && tpl.suggested_weekday === i) ? " selected" : "") + ">" + t(w) + "</option>").join("");
|
|
8751
8915
|
const runAt = f.run_at ? String(f.run_at).replace(" ", "T").slice(0, 16) : "";
|
|
@@ -8769,6 +8933,18 @@ async function autoForm(task, tpl) {
|
|
|
8769
8933
|
'<option value="' + esc(fl.id) + '"' + (fl.id === curFlow ? " selected" : "") + ">" + esc(t(fl.name)) + "</option>").join("") +
|
|
8770
8934
|
"</select></div>" +
|
|
8771
8935
|
"</div>" +
|
|
8936
|
+
/* 运行偏好:与新建任务 Composer 的三颗胶囊同一套取值(编排模式/思考程度/
|
|
8937
|
+
对话模型),到点拉起的运行与手动建的任务完全等价 */
|
|
8938
|
+
'<div class="grid-2">' +
|
|
8939
|
+
'<div class="field"><label>' + t("编排模式") + "</label>" +
|
|
8940
|
+
autoPillHtml("au-mode", AUTO_MODES, mode) + "</div>" +
|
|
8941
|
+
'<div class="field"><label>' + t("思考程度") + "</label>" +
|
|
8942
|
+
autoPillHtml("au-thinking", AUTO_THINKINGS, thinking) + "</div>" +
|
|
8943
|
+
"</div>" +
|
|
8944
|
+
'<div id="au-model-field" class="field hidden"><label>' + t("对话模型(可选)") + '</label><div class="grid-2">' +
|
|
8945
|
+
'<select id="au-direct-provider" aria-label="' + t("对话厂商") + '"></select>' +
|
|
8946
|
+
'<select id="au-direct-model-name" aria-label="' + t("对话模型") + '"></select>' +
|
|
8947
|
+
'</div><p class="hint">' + t("只对「直接执行」类流程生效;留「自动推荐」时按系统路由挑模型。") + "</p></div>" +
|
|
8772
8948
|
'<div class="grid-2">' +
|
|
8773
8949
|
'<div class="field au-fld" data-k="time"><label>' + t("时间") + '</label><input id="au-time" type="time" value="' +
|
|
8774
8950
|
esc(f.time || (tpl && tpl.suggested_time) || "09:00") + '"></div>' +
|
|
@@ -8788,6 +8964,71 @@ async function autoForm(task, tpl) {
|
|
|
8788
8964
|
kindSel.addEventListener("change", autoFormSyncKind);
|
|
8789
8965
|
autoFormSyncKind();
|
|
8790
8966
|
}
|
|
8967
|
+
["au-mode", "au-thinking"].forEach((id) => {
|
|
8968
|
+
const sel = $(id);
|
|
8969
|
+
if (sel) sel.addEventListener("change", () => syncCmpSelFace(id));
|
|
8970
|
+
syncCmpSelFace(id); // 弹框是动态插入的:选项文字要在这里同步一次
|
|
8971
|
+
});
|
|
8972
|
+
renderAuModelPicker(f.direct_provider_id || "", f.direct_model || "");
|
|
8973
|
+
const flowSel = $("au-flow");
|
|
8974
|
+
if (flowSel) {
|
|
8975
|
+
flowSel.addEventListener("change", autoFormSyncFlow);
|
|
8976
|
+
autoFormSyncFlow();
|
|
8977
|
+
}
|
|
8978
|
+
}
|
|
8979
|
+
|
|
8980
|
+
/* 运行偏好取值(真源 = automation.py 的 MODES/THINKINGS,选项文案与 Composer 共用) */
|
|
8981
|
+
const AUTO_MODES = [
|
|
8982
|
+
{ v: "auto", label: "自动(推荐):按任务类型与风险动态决定步骤" },
|
|
8983
|
+
{ v: "fast", label: "快速:少步骤优先,先做确定性验收" },
|
|
8984
|
+
{ v: "expert", label: "专家:完整规划、多评审、失败修复与换将" },
|
|
8985
|
+
{ v: "manual", label: "手动:自行指定实现者与评审组" },
|
|
8986
|
+
];
|
|
8987
|
+
const AUTO_THINKINGS = [
|
|
8988
|
+
{ v: "standard", label: "标准:平衡质量与速度" },
|
|
8989
|
+
{ v: "auto", label: "自动:随任务难度调整" },
|
|
8990
|
+
{ v: "low", label: "快速:少推理、优先响应" },
|
|
8991
|
+
{ v: "high", label: "深度:更多推理与检查" },
|
|
8992
|
+
];
|
|
8993
|
+
|
|
8994
|
+
/* 偏好胶囊:外观与 Composer 同款(透明原生 select 铺满整颗,短名由
|
|
8995
|
+
* syncCmpSelFace 从选中 option 文本截取——语言切换后短名跟着换) */
|
|
8996
|
+
function autoPillHtml(id, opts, cur) {
|
|
8997
|
+
return '<span class="cmp-sel"><select id="' + id + '" class="cmp-sel-native" aria-label="' +
|
|
8998
|
+
esc(id === "au-mode" ? t("编排模式") : t("思考程度")) + '">' +
|
|
8999
|
+
opts.map((o) => '<option value="' + o.v + '"' + (o.v === cur ? " selected" : "") +
|
|
9000
|
+
' data-i18n="' + esc(o.label) + '">' + esc(t(o.label)) + "</option>").join("") +
|
|
9001
|
+
'</select><span class="cmp-sel-face" id="' + id + '-face"></span>' +
|
|
9002
|
+
'<svg class="ico chev" aria-hidden="true"><use href="#i-chevron-r"></use></svg></span>';
|
|
9003
|
+
}
|
|
9004
|
+
|
|
9005
|
+
/* 对话模型(仅 direct 流程):复用 Composer 的厂商/模型数据源与重建规则 */
|
|
9006
|
+
function renderAuModelPicker(pid, model) {
|
|
9007
|
+
const ps = $("au-direct-provider"), ms = $("au-direct-model-name");
|
|
9008
|
+
if (!ps || !ms) return;
|
|
9009
|
+
const provs = directProviders();
|
|
9010
|
+
const keepP = pid != null ? pid : ps.value;
|
|
9011
|
+
ps.innerHTML = '<option value="">' + t("自动推荐") + "</option>" + provs.map((p) =>
|
|
9012
|
+
'<option value="' + esc(p.id) + '">' + esc(p.name || p.id) + "</option>").join("");
|
|
9013
|
+
ps.value = provs.some((p) => p.id === keepP) ? keepP : "";
|
|
9014
|
+
const fillModels = (keepM) => {
|
|
9015
|
+
const p = provs.find((x) => x.id === ps.value);
|
|
9016
|
+
const names = p ? (p.models || []).filter((m) => !m.hidden)
|
|
9017
|
+
.map((m) => m.name).filter(Boolean) : [];
|
|
9018
|
+
ms.innerHTML = '<option value="">' + t("随厂商推荐") + "</option>" + names.map((name) =>
|
|
9019
|
+
'<option value="' + esc(name) + '">' + esc(name) + "</option>").join("");
|
|
9020
|
+
ms.value = names.includes(keepM) ? keepM : "";
|
|
9021
|
+
};
|
|
9022
|
+
fillModels(model != null ? model : ms.value);
|
|
9023
|
+
// 换厂商 = 旧模型名多半不在新厂商名录里:回「随厂商推荐」,不留悬空值
|
|
9024
|
+
ps.onchange = () => fillModels("");
|
|
9025
|
+
}
|
|
9026
|
+
|
|
9027
|
+
/* 流程 → 对话模型字段显隐:只有 direct 引擎流程用得上(其余流程模型由系统路由) */
|
|
9028
|
+
function autoFormSyncFlow() {
|
|
9029
|
+
const flow = flowById(($("au-flow") || {}).value);
|
|
9030
|
+
const box = $("au-model-field");
|
|
9031
|
+
if (box) box.classList.toggle("hidden", !(flow && flow.engine === "direct"));
|
|
8791
9032
|
}
|
|
8792
9033
|
|
|
8793
9034
|
/* 类型 → 字段联动:daily/weekly→时间;weekly→星期几;interval→N 小时;once→未来时间 */
|
|
@@ -8812,6 +9053,17 @@ async function saveAutoForm(id) {
|
|
|
8812
9053
|
if (!prompt) { toast(t("请填写执行内容"), true); return; }
|
|
8813
9054
|
const kind = $("au-kind").value;
|
|
8814
9055
|
const payload = { name, prompt, workdir: ($("au-workdir").value || "").trim(), kind, flow: $("au-flow").value };
|
|
9056
|
+
// 运行偏好:与 Composer 同名字段,后端 _apply_run_prefs 再归一一次
|
|
9057
|
+
payload.mode = $("au-mode") ? $("au-mode").value : "";
|
|
9058
|
+
payload.thinking = $("au-thinking") ? $("au-thinking").value : "";
|
|
9059
|
+
if (!$("au-model-field") || !$("au-model-field").classList.contains("hidden")) {
|
|
9060
|
+
payload.direct_provider_id = $("au-direct-provider") ? $("au-direct-provider").value : "";
|
|
9061
|
+
payload.direct_model = $("au-direct-model-name") ? $("au-direct-model-name").value : "";
|
|
9062
|
+
if (payload.direct_model && !payload.direct_provider_id) {
|
|
9063
|
+
toast(t("选择对话模型前请先选厂商"), true);
|
|
9064
|
+
return;
|
|
9065
|
+
}
|
|
9066
|
+
}
|
|
8815
9067
|
if (kind === "daily" || kind === "weekly") payload.time = $("au-time").value;
|
|
8816
9068
|
if (kind === "weekly") payload.weekday = parseInt($("au-weekday").value, 10);
|
|
8817
9069
|
if (kind === "interval") payload.interval_hours = parseInt($("au-interval").value, 10) || 0;
|
|
@@ -8931,7 +9183,31 @@ function ztProdChanged(el) {
|
|
|
8931
9183
|
|
|
8932
9184
|
/* 工作目录「选择…」:复用全局 pickFolder(原生对话框→网页目录弹框回落),回填同一行的输入框 */
|
|
8933
9185
|
function ztWdPick(btn) {
|
|
8934
|
-
|
|
9186
|
+
const inp = btn.closest(".zt-wd-row").querySelector(".zt-p-wd");
|
|
9187
|
+
window.pickFolder(inp, true);
|
|
9188
|
+
// 选完目录顺手拉该仓库的分支清单,基线分支变下拉可选
|
|
9189
|
+
const card = btn.closest(".zt-prof");
|
|
9190
|
+
const side = inp.dataset.side;
|
|
9191
|
+
if (card && side) setTimeout(() => ztRevFill(card, side), 400);
|
|
9192
|
+
}
|
|
9193
|
+
|
|
9194
|
+
/* 基线分支下拉(datalist):从仓库工作目录拉本地分支列表。
|
|
9195
|
+
* 复用 /api/git/info(只读探测,仅限本机);拉不到就保持手填,不报错打扰。 */
|
|
9196
|
+
function ztRevFill(card, side, force) {
|
|
9197
|
+
if (!card) return;
|
|
9198
|
+
const wd = (card.querySelector(".zt-p-wd") || {}).value || "";
|
|
9199
|
+
const rev = card.querySelector(".zt-p-rev");
|
|
9200
|
+
const dl = card.querySelector('datalist[data-side="' + side + '"]');
|
|
9201
|
+
if (!rev || !dl) return;
|
|
9202
|
+
if (!wd.trim()) { dl.innerHTML = ""; return; }
|
|
9203
|
+
if (!force && dl.options.length) return; // 已有清单不重拉
|
|
9204
|
+
api("/api/git/info?workdir=" + encodeURIComponent(wd.trim()))
|
|
9205
|
+
.then((r) => {
|
|
9206
|
+
if (!r || !r.repo) return;
|
|
9207
|
+
dl.innerHTML = (r.branches || []).map((b) =>
|
|
9208
|
+
'<option value="' + esc(b) + '">').join("");
|
|
9209
|
+
})
|
|
9210
|
+
.catch(() => {});
|
|
8935
9211
|
}
|
|
8936
9212
|
|
|
8937
9213
|
function ztRepoHtml(i, side) {
|
|
@@ -8942,7 +9218,10 @@ function ztRepoHtml(i, side) {
|
|
|
8942
9218
|
return '<label>' + cn + t("仓库·工作目录") + '</label><span class="zt-wd-row">' +
|
|
8943
9219
|
'<input class="zt-p-wd" data-side="' + side + '" value="' + esc(repo.workdir || "") + '" placeholder="' + t("空 = 用默认保存路径") + '">' +
|
|
8944
9220
|
'<button class="ghost small" type="button" title="' + t("浏览本机目录选择") + '" onclick="ztWdPick(this)">' + t("选择…") + "</button></span>" +
|
|
8945
|
-
'<label>' + cn + t("仓库·基线分支") + '</label><
|
|
9221
|
+
'<label>' + cn + t("仓库·基线分支") + '</label><span class="zt-wd-row">' +
|
|
9222
|
+
'<input class="zt-p-rev" data-side="' + side + '" list="zt-rev-' + i + '-' + side + '" value="' + esc(repo.git_rev || "") + '" placeholder="' + t("如 main;空 = 直接改工作目录") + '" onfocus="ztRevFill(this.closest(\'.zt-prof\'), \'' + side + '\')">' +
|
|
9223
|
+
'<button class="ghost small" type="button" title="' + t("刷新分支列表") + '" onclick="ztRevFill(this.closest(\'.zt-prof\'), \'' + side + '\', true)">▾</button>' +
|
|
9224
|
+
'<datalist id="zt-rev-' + i + '-' + side + '" data-side="' + side + '"></datalist></span>' +
|
|
8946
9225
|
'<label>' + cn + t("仓库·验证命令") + '</label><input class="zt-p-verify" data-side="' + side + '" value="' + esc(repo.verify_command || "") + '" placeholder="' + t("如 npm test;空 = 靠评审把关") + '">' +
|
|
8947
9226
|
'<label>' + cn + t("仓库·一句话描述") + '</label><input class="zt-p-hint" data-side="' + side + '" value="' + esc(hint) + '" placeholder="' + t("给 AI 排查看,如:Vue3 管理台前端") + '">';
|
|
8948
9227
|
}
|
|
@@ -9090,7 +9369,10 @@ async function ztFetchMods(i, silent) {
|
|
|
9090
9369
|
|
|
9091
9370
|
/* 拉产品/账号清单:产品 ID 变下拉,负责人/转派账号挂 datalist。silent=进页自动拉 */
|
|
9092
9371
|
async function ztFetchCatalog(silent) {
|
|
9093
|
-
|
|
9372
|
+
// 后台拉清单:静默(不画顶部滑动线、不挂按钮转圈)+ 前端 45s 超时——
|
|
9373
|
+
// 禅道内网不可达时此前会挂住页面加载指示十几二十秒(用户实测反馈)
|
|
9374
|
+
const grab = (url) => api(url, { method: "POST", body: "{}",
|
|
9375
|
+
timeout: 45000, busy: false, silent: true })
|
|
9094
9376
|
.catch((e) => ({ ok: false, error: e.message }));
|
|
9095
9377
|
const [pr, ur] = await Promise.all([grab("/api/zentao/products"), grab("/api/zentao/users")]);
|
|
9096
9378
|
if (pr && pr.ok) S.ztProducts = pr.products || [];
|
|
@@ -9111,6 +9393,99 @@ async function ztFetchCatalog(silent) {
|
|
|
9111
9393
|
else toast((pr && pr.error) || (ur && ur.error) || t("拉取失败——先「测试连接」确认可用"), true);
|
|
9112
9394
|
}
|
|
9113
9395
|
|
|
9396
|
+
/* ---------------------------------------------------------- 钩子(任务生命周期) */
|
|
9397
|
+
/* 设置 → 钩子:task_start / message_submit / run_end 三事件挂本机命令。
|
|
9398
|
+
* stdin 收 JSON、stdout 回 {"inject"} 或纯文本即注入;失败超时静默跳过。 */
|
|
9399
|
+
function loadHooksPanel() {
|
|
9400
|
+
api("/api/hooks").then((d) => {
|
|
9401
|
+
S.hooks = d.hooks || [];
|
|
9402
|
+
S.hookEvents = d.events || [];
|
|
9403
|
+
renderHooks();
|
|
9404
|
+
}).catch((e) => toast(t("加载失败:") + (e.message || e), true));
|
|
9405
|
+
}
|
|
9406
|
+
|
|
9407
|
+
function renderHooks() {
|
|
9408
|
+
const box = $("hooks-list"), empty = $("hooks-empty");
|
|
9409
|
+
if (!box) return;
|
|
9410
|
+
const list = S.hooks || [];
|
|
9411
|
+
if (empty) empty.classList.toggle("hidden", list.length > 0);
|
|
9412
|
+
const evLabel = { task_start: t("任务开始"), message_submit: t("消息送达"), run_end: t("运行结束") };
|
|
9413
|
+
box.innerHTML = list.map((h, i) => {
|
|
9414
|
+
return '<div class="card hook-card" data-i="' + i + '">' +
|
|
9415
|
+
'<div class="hook-row">' +
|
|
9416
|
+
'<input class="hook-name" value="' + esc(h.name || "") + '" placeholder="' + esc(t("钩子名称")) + '" data-hf="name">' +
|
|
9417
|
+
'<select class="hook-event" data-hf="event">' +
|
|
9418
|
+
["task_start", "message_submit", "run_end"].map((ev) =>
|
|
9419
|
+
'<option value="' + ev + '"' + (h.event === ev ? " selected" : "") + ">" +
|
|
9420
|
+
esc(evLabel[ev]) + "</option>").join("") +
|
|
9421
|
+
"</select>" +
|
|
9422
|
+
"</div>" +
|
|
9423
|
+
'<input class="hook-cmd mono" value="' + esc(h.cmd || "") + '" placeholder="' +
|
|
9424
|
+
esc(t('命令:python D:\\proj\\hook.py(stdin 收 JSON,stdout 回 {"inject":"文本"})')) + '" data-hf="cmd">' +
|
|
9425
|
+
'<div class="hook-row hook-foot">' +
|
|
9426
|
+
'<label class="switch" title="' + esc(t("启用")) + '"><input type="checkbox" data-hf="enabled"' +
|
|
9427
|
+
(h.enabled ? " checked" : "") + '><span></span><em>' + esc(t("启用")) + '</em></label>' +
|
|
9428
|
+
'<span class="hook-foot-lab">' + esc(t("超时(秒)")) + '</span>' +
|
|
9429
|
+
'<input class="hook-timeout" type="number" min="1" max="120" value="' + Number(h.timeout_s || 10) + '" data-hf="timeout_s">' +
|
|
9430
|
+
'<button type="button" class="ghost small" data-hook-test="' + i + '">' + t("测试") + "</button>" +
|
|
9431
|
+
'<button type="button" class="ghost small" onclick="removeHook(' + i + ')">' + t("删除") + "</button>" +
|
|
9432
|
+
'<span class="hint hook-test-out"></span>' +
|
|
9433
|
+
"</div></div>";
|
|
9434
|
+
}).join("");
|
|
9435
|
+
}
|
|
9436
|
+
|
|
9437
|
+
window.removeHook = function (i) {
|
|
9438
|
+
(S.hooks || []).splice(i, 1);
|
|
9439
|
+
renderHooks();
|
|
9440
|
+
};
|
|
9441
|
+
|
|
9442
|
+
function hookHarvest() {
|
|
9443
|
+
return Array.from(document.querySelectorAll("#hooks-list .hook-card")).map((card) => {
|
|
9444
|
+
const gv = (f) => card.querySelector('[data-hf="' + f + '"]');
|
|
9445
|
+
return { id: (S.hooks || [])[Number(card.dataset.i)]?.id || "",
|
|
9446
|
+
name: gv("name").value.trim(), event: gv("event").value,
|
|
9447
|
+
cmd: gv("cmd").value.trim(), enabled: gv("enabled").checked,
|
|
9448
|
+
timeout_s: Number(gv("timeout_s").value || 10) };
|
|
9449
|
+
});
|
|
9450
|
+
}
|
|
9451
|
+
|
|
9452
|
+
function bindHooksPanel() {
|
|
9453
|
+
if ($("btn-hooks-add").dataset.bound) return;
|
|
9454
|
+
$("btn-hooks-add").dataset.bound = "1";
|
|
9455
|
+
$("btn-hooks-add").addEventListener("click", () => {
|
|
9456
|
+
S.hooks = S.hooks || [];
|
|
9457
|
+
S.hooks.push({ id: "hk-" + Date.now(), name: "", event: "task_start",
|
|
9458
|
+
cmd: "", enabled: true, timeout_s: 10 });
|
|
9459
|
+
renderHooks();
|
|
9460
|
+
});
|
|
9461
|
+
$("btn-hooks-save").addEventListener("click", async () => {
|
|
9462
|
+
try {
|
|
9463
|
+
const r = await api("/api/hooks/save", { method: "POST",
|
|
9464
|
+
body: JSON.stringify({ hooks: hookHarvest() }) });
|
|
9465
|
+
S.hooks = r.hooks || [];
|
|
9466
|
+
renderHooks();
|
|
9467
|
+
toast(t("钩子已保存"));
|
|
9468
|
+
} catch (e) { toast(t("保存失败:") + (e.message || e), true); }
|
|
9469
|
+
});
|
|
9470
|
+
$("hooks-list").addEventListener("click", async (e) => {
|
|
9471
|
+
const btn = e.target.closest("[data-hook-test]");
|
|
9472
|
+
if (!btn) return;
|
|
9473
|
+
const card = btn.closest(".hook-card");
|
|
9474
|
+
const gv = (f) => card.querySelector('[data-hf="' + f + '"]');
|
|
9475
|
+
const out = card.querySelector(".hook-test-out");
|
|
9476
|
+
btn.disabled = true;
|
|
9477
|
+
out.textContent = t("运行中…");
|
|
9478
|
+
try {
|
|
9479
|
+
const r = await api("/api/hooks/test", { method: "POST",
|
|
9480
|
+
body: JSON.stringify({ cmd: gv("cmd").value.trim(), event: gv("event").value,
|
|
9481
|
+
timeout_s: Number(gv("timeout_s").value || 10) }) });
|
|
9482
|
+
out.textContent = (r.ok ? "✓ " : "✗ ") + String(r.output || "").slice(0, 120);
|
|
9483
|
+
} catch (e2) { out.textContent = "✗ " + (e2.message || e2); }
|
|
9484
|
+
btn.disabled = false;
|
|
9485
|
+
});
|
|
9486
|
+
}
|
|
9487
|
+
bindHooksPanel();
|
|
9488
|
+
|
|
9114
9489
|
/* ---- 状态行 / 修复记录 ---- */
|
|
9115
9490
|
|
|
9116
9491
|
function zentaoStateTag(st) {
|
|
@@ -10333,13 +10708,29 @@ async function suStartupCheck() {
|
|
|
10333
10708
|
}
|
|
10334
10709
|
|
|
10335
10710
|
/* ---------------------------------------------------------- 页签 & 初始化 */
|
|
10336
|
-
const TAB_TITLES = { tasks: "任务", runs: "运行记录", automation: "自动化", zentao: "禅道 Bug 自动修复", __wxdigest: "群摘要", usage: "用量统计", agents: "本机智能体", models: "模型接入", bindings: "模型调度(可选)", skills: "经验库", knowledge: "知识库", market: "插件市场", orch: "编排设置", data: "数据与备份", appearance: "皮肤", about: "关于与更新" };
|
|
10337
|
-
const SET_TABS = new Set(Object.keys(TAB_TITLES)); //
|
|
10711
|
+
const TAB_TITLES = { overview: "概览", tasks: "任务", runs: "运行记录", automation: "自动化", zentao: "禅道 Bug 自动修复", __wxdigest: "群摘要", usage: "用量统计", agents: "本机智能体", models: "模型接入", bindings: "模型调度(可选)", skills: "经验库", knowledge: "知识库", market: "插件市场", orch: "编排设置", data: "数据与备份", appearance: "皮肤", about: "关于与更新" };
|
|
10712
|
+
const SET_TABS = new Set(Object.keys(TAB_TITLES)); // 全部设置子页(__phone 是弹框,不算)
|
|
10338
10713
|
|
|
10339
10714
|
function tabTitle(name) {
|
|
10340
10715
|
return t(TAB_TITLES[name]) || t("设置");
|
|
10341
10716
|
}
|
|
10342
10717
|
|
|
10718
|
+
/* 左栏两套导航各管哪些页,一律从 index.html 现读——HTML 改了 JS 自动跟上,
|
|
10719
|
+
* 不再各存一份常量、两处漂移:
|
|
10720
|
+
* .side-quick 快捷导航(概览/运行记录/自动化)→ main 形态:只换主栏内容,左栏任务树不动
|
|
10721
|
+
* .side-settings 设置导航 → settings 形态:左栏整体换成设置导航
|
|
10722
|
+
* 同一个页只应挂在其中一边(重复挂就是两处入口)。 */
|
|
10723
|
+
function mainPageSubs() {
|
|
10724
|
+
const s = new Set();
|
|
10725
|
+
document.querySelectorAll(".side-quick .qitem").forEach((b) => { if (b.dataset.page) s.add(b.dataset.page); });
|
|
10726
|
+
return s;
|
|
10727
|
+
}
|
|
10728
|
+
function settingsNavSubs() {
|
|
10729
|
+
const s = new Set();
|
|
10730
|
+
document.querySelectorAll(".side-settings .set-item").forEach((b) => { if (b.dataset.sub) s.add(b.dataset.sub); });
|
|
10731
|
+
return s;
|
|
10732
|
+
}
|
|
10733
|
+
|
|
10343
10734
|
|
|
10344
10735
|
|
|
10345
10736
|
/* ---------------------------------------------------------- 用量统计 */
|
|
@@ -10516,12 +10907,57 @@ function setLangBtn(lang) {
|
|
|
10516
10907
|
}
|
|
10517
10908
|
}
|
|
10518
10909
|
|
|
10519
|
-
/*
|
|
10520
|
-
|
|
10521
|
-
|
|
10522
|
-
|
|
10523
|
-
|
|
10524
|
-
|
|
10910
|
+
/* KPI 卡:图标块 + 环比徽章 + 数值 + 标签 + 副行 + 迷你走势图。
|
|
10911
|
+
* opts 全部可选;不传时回落成旧版布局(图标内联在标签行),老调用点无需改动。
|
|
10912
|
+
* tone: 图标块/走势图色相(accent|ok|warn|bad|calm)
|
|
10913
|
+
* delta: { pct:Number, invert?:true } 环比;invert 给花钱类指标(涨了显示告警色)
|
|
10914
|
+
* spark: [Number,...] 迷你走势序列 */
|
|
10915
|
+
function kpiCard(label, value, sub, wide, icon, opts) {
|
|
10916
|
+
opts = opts || {};
|
|
10917
|
+
const tone = opts.tone || "accent";
|
|
10918
|
+
const ico = icon ? '<span class="kpi-ico t-' + esc(tone) + '">' +
|
|
10919
|
+
'<svg class="ico" aria-hidden="true"><use href="#' + esc(icon) + '"></use></svg></span>' : "";
|
|
10920
|
+
const delta = kpiDelta(opts.delta);
|
|
10921
|
+
const top = (ico || delta) ? '<div class="kpi-top">' + ico + delta + "</div>" : "";
|
|
10922
|
+
const spark = (opts.spark && opts.spark.length > 1)
|
|
10923
|
+
? '<div class="kpi-spark">' + kpiSparkSvg(opts.spark, tone) + "</div>" : "";
|
|
10924
|
+
return '<div class="kpi' + (wide ? " wide" : "") + (top ? " has-top" : "") +
|
|
10925
|
+
(spark ? " has-spark" : "") + '">' + top +
|
|
10926
|
+
'<div class="kpi-v">' + value + "</div>" +
|
|
10927
|
+
'<div class="kpi-l">' + (top ? "" : ico) + esc(label) + "</div>" +
|
|
10928
|
+
(sub ? '<div class="kpi-s">' + esc(sub) + "</div>" : "") + spark + "</div>";
|
|
10929
|
+
}
|
|
10930
|
+
|
|
10931
|
+
/* 环比徽章:涨跌用颜色区分,不用箭头字符(几何字形跨平台宽度不一)。
|
|
10932
|
+
* 花钱类指标要 invert——费用涨 20% 不是好事,不能标绿。 */
|
|
10933
|
+
function kpiDelta(d) {
|
|
10934
|
+
if (!d || typeof d.pct !== "number" || !isFinite(d.pct)) return "";
|
|
10935
|
+
const pct = d.pct;
|
|
10936
|
+
const flat = Math.abs(pct) < 0.05;
|
|
10937
|
+
const dir = flat ? "flat" : (pct > 0 ? "up" : "down");
|
|
10938
|
+
const happy = d.invert ? (dir === "down") : (dir === "up");
|
|
10939
|
+
const cls = flat ? "flat" : (happy ? "good" : "bad");
|
|
10940
|
+
const sign = pct > 0 ? "+" : "";
|
|
10941
|
+
return '<span class="kpi-delta ' + cls + '" title="' +
|
|
10942
|
+
esc(t("与上一个同长周期相比")) + '">' + esc(t("较上期")) + " " +
|
|
10943
|
+
esc(sign + pct.toFixed(1) + "%") + "</span>";
|
|
10944
|
+
}
|
|
10945
|
+
|
|
10946
|
+
/* 迷你走势图:折线 + 面积。preserveAspectRatio=none 让图随卡宽拉伸,
|
|
10947
|
+
* 折线靠 vector-effect 保住描边不被横向拉粗。 */
|
|
10948
|
+
function kpiSparkSvg(vals, tone) {
|
|
10949
|
+
const w = 88, h = 26, n = vals.length;
|
|
10950
|
+
const max = Math.max.apply(null, vals), min = Math.min.apply(null, vals);
|
|
10951
|
+
const span = Math.max(1, max - min);
|
|
10952
|
+
const px = (i) => (n <= 1 ? w : i * w / (n - 1));
|
|
10953
|
+
const py = (v) => h - 3 - (v - min) * (h - 8) / span;
|
|
10954
|
+
const line = vals.map((v, i) => px(i).toFixed(1) + "," + py(v).toFixed(1)).join(" ");
|
|
10955
|
+
const area = "0," + h + " " + line + " " + w + "," + h;
|
|
10956
|
+
return '<svg class="kspark t-' + esc(tone) + '" viewBox="0 0 ' + w + " " + h +
|
|
10957
|
+
'" preserveAspectRatio="none" aria-hidden="true" focusable="false">' +
|
|
10958
|
+
'<polygon class="ks-area" points="' + area + '"></polygon>' +
|
|
10959
|
+
'<polyline class="ks-line" points="' + line +
|
|
10960
|
+
'" vector-effect="non-scaling-stroke"></polyline></svg>';
|
|
10525
10961
|
}
|
|
10526
10962
|
|
|
10527
10963
|
/* 单日没有趋势可言,一根孤柱悬在空白里很难看:改画全宽的
|
|
@@ -10875,20 +11311,28 @@ function renderUsage() {
|
|
|
10875
11311
|
// 回填记录只有总量、没有输入/输出细分:细分全为 0 时说明当前范围只有历史数据,
|
|
10876
11312
|
// 不提示的话「输入 0 · 输出 0」会被误读成统计坏了
|
|
10877
11313
|
const noBreakdown = tot.tokens > 0 && !tot.input && !tot.output && !tot.cached;
|
|
11314
|
+
// 迷你走势取范围内逐日真值(by_day 已连续补零);单日范围只有一格,kpiCard 自动不出图。
|
|
11315
|
+
// 用量页不做环比:范围由用户任选(含「全部」),没有等长上一期可比,硬算会是假数。
|
|
11316
|
+
const dayTok = (u.by_day || []).map((d) => d.tokens || 0);
|
|
11317
|
+
const dayDur = (u.by_day || []).map((d) => d.duration_s || 0);
|
|
10878
11318
|
$("usage-kpis").innerHTML = [
|
|
10879
11319
|
kpiCard(t("累计 Token 数"), fmtTok(tot.tokens),
|
|
10880
11320
|
t("调用 ") + fmtTok(tot.calls) + t(" 次 · 成功率 ") +
|
|
10881
11321
|
(tot.calls ? Math.round(tot.ok * 100 / tot.calls) : 0) + "% · " +
|
|
10882
11322
|
t("失败 ") + fmtTok(tot.failed || 0) + t(" · ") + fmtUsd(tot.cost_usd),
|
|
10883
|
-
true, "i-sigma"),
|
|
11323
|
+
true, "i-sigma", { tone: "accent", spark: dayTok }),
|
|
10884
11324
|
kpiCard(t("峰值 Token 数"), fmtTok(tot.peak_tokens),
|
|
10885
|
-
t("单日最高 · 日均 ") + fmtTok(activeDays ? tot.tokens / activeDays : 0),
|
|
11325
|
+
t("单日最高 · 日均 ") + fmtTok(activeDays ? tot.tokens / activeDays : 0),
|
|
11326
|
+
false, "i-gauge", { tone: "warn" }),
|
|
10886
11327
|
kpiCard(t("最长单次时长"), fmtDur(tot.max_duration_s),
|
|
10887
|
-
t("累计调用 ") + fmtDur(tot.duration_s), false, "i-history"
|
|
11328
|
+
t("累计调用 ") + fmtDur(tot.duration_s), false, "i-history",
|
|
11329
|
+
{ tone: "calm", spark: dayDur }),
|
|
10888
11330
|
kpiCard(t("当前连续天数"), fmtTok(tot.streak_current) + t(" 天"),
|
|
10889
|
-
t("范围内活跃 ") + fmtTok(activeDays) + t(" 天"), false, "i-calendar-days"
|
|
11331
|
+
t("范围内活跃 ") + fmtTok(activeDays) + t(" 天"), false, "i-calendar-days",
|
|
11332
|
+
{ tone: "ok" }),
|
|
10890
11333
|
kpiCard(t("最长连续天数"), fmtTok(tot.streak_longest) + t(" 天"),
|
|
10891
|
-
t("缓存命中率 ") + (tot.cache_rate || 0) + "%", false, "i-calendar-days"
|
|
11334
|
+
t("缓存命中率 ") + (tot.cache_rate || 0) + "%", false, "i-calendar-days",
|
|
11335
|
+
{ tone: "ok" }),
|
|
10892
11336
|
].join("");
|
|
10893
11337
|
const note = $("usage-note");
|
|
10894
11338
|
if (note) {
|
|
@@ -10912,6 +11356,213 @@ function renderUsage() {
|
|
|
10912
11356
|
|
|
10913
11357
|
|
|
10914
11358
|
|
|
11359
|
+
/* ---------------------------------------------------------- 概览首页 */
|
|
11360
|
+
/* 数据全部来自真实台账与运行记录,没有任何占位假数:
|
|
11361
|
+
* - /api/usage?days=60 一次拿两期:by_day 是连续补零的日序列,前 30 天=上期、
|
|
11362
|
+
* 后 30 天=本期,环比在本地对半切算出来,不额外发请求、不加后端端点;
|
|
11363
|
+
* - 最近运行取 S.state.runs(SSE 推送,天然实时,不需要自己轮询)。
|
|
11364
|
+
* 周期固定 60 天而不是可调:环比必须有等长的上一期,做成可选范围就得再拉一次。 */
|
|
11365
|
+
const OV_PERIOD = 30; // 一期长度(上期 / 本期各 30 天)
|
|
11366
|
+
const OV_DAYS_KEY = "orch.ovDays"; // 趋势图范围(7 / 30),与用量页的范围各自独立
|
|
11367
|
+
let _ovSeq = 0; // 请求序号:重入/切走时丢弃过期响应
|
|
11368
|
+
|
|
11369
|
+
function ovDays() {
|
|
11370
|
+
if (S.ovDays === undefined || S.ovDays === null || S.ovDays === "") {
|
|
11371
|
+
let saved = null;
|
|
11372
|
+
try { saved = localStorage.getItem(OV_DAYS_KEY); } catch (e) { /* 隐私模式忽略 */ }
|
|
11373
|
+
S.ovDays = Number(saved) === 30 ? 30 : 7;
|
|
11374
|
+
}
|
|
11375
|
+
return S.ovDays === 30 ? 30 : 7;
|
|
11376
|
+
}
|
|
11377
|
+
|
|
11378
|
+
function setOvDays(n) {
|
|
11379
|
+
S.ovDays = Number(n) === 30 ? 30 : 7;
|
|
11380
|
+
try { localStorage.setItem(OV_DAYS_KEY, String(S.ovDays)); } catch (e) { /* 隐私模式忽略 */ }
|
|
11381
|
+
syncOvRanges();
|
|
11382
|
+
renderOverviewTrend();
|
|
11383
|
+
}
|
|
11384
|
+
|
|
11385
|
+
function syncOvRanges() {
|
|
11386
|
+
const cur = ovDays();
|
|
11387
|
+
document.querySelectorAll("#ov-ranges [data-days]").forEach((b) =>
|
|
11388
|
+
b.classList.toggle("active", Number(b.dataset.days) === cur));
|
|
11389
|
+
}
|
|
11390
|
+
|
|
11391
|
+
async function loadOverview() {
|
|
11392
|
+
const seq = ++_ovSeq;
|
|
11393
|
+
const kp = $("ov-kpis");
|
|
11394
|
+
if (kp) kp.innerHTML = '<p class="hint">' + t("加载中…") + "</p>";
|
|
11395
|
+
try {
|
|
11396
|
+
const u = await api("/api/usage?days=" + (OV_PERIOD * 2));
|
|
11397
|
+
if (seq !== _ovSeq) return; // 期间已切走/重入:过期响应不落盘
|
|
11398
|
+
S.ovUsage = u;
|
|
11399
|
+
} catch (e) {
|
|
11400
|
+
if (seq !== _ovSeq) return;
|
|
11401
|
+
S.ovUsage = null;
|
|
11402
|
+
}
|
|
11403
|
+
renderOverview();
|
|
11404
|
+
}
|
|
11405
|
+
|
|
11406
|
+
/* 日序列对半切:前一半=上期,后一半=本期。服务刚装不久、总天数不足两期时
|
|
11407
|
+
* 上期为空数组,环比自然不显示(而不是拿不足一期的数据硬算出一个假百分比)。 */
|
|
11408
|
+
function ovSplit(byDay) {
|
|
11409
|
+
const days = byDay || [];
|
|
11410
|
+
const cut = Math.max(0, days.length - OV_PERIOD);
|
|
11411
|
+
return { prev: days.slice(0, cut), cur: days.slice(cut) };
|
|
11412
|
+
}
|
|
11413
|
+
|
|
11414
|
+
function ovSum(rows, k) {
|
|
11415
|
+
return (rows || []).reduce((s, r) => s + (Number(r[k]) || 0), 0);
|
|
11416
|
+
}
|
|
11417
|
+
|
|
11418
|
+
/* 环比:上期为 0 时不显示——除零得到 Infinity,硬算出来是假数 */
|
|
11419
|
+
function ovDelta(cur, prev, invert) {
|
|
11420
|
+
if (!prev) return null;
|
|
11421
|
+
return { pct: (cur - prev) * 100 / prev, invert: !!invert };
|
|
11422
|
+
}
|
|
11423
|
+
|
|
11424
|
+
/* 日期眉标:跟随界面语言排版(en 走 en-US),拿不到就回落空串不阻塞渲染 */
|
|
11425
|
+
function ovDateText() {
|
|
11426
|
+
try {
|
|
11427
|
+
return new Date().toLocaleDateString(getLang() === "en" ? "en-US" : "zh-CN",
|
|
11428
|
+
{ weekday: "long", month: "long", day: "numeric" });
|
|
11429
|
+
} catch (e) { return ""; }
|
|
11430
|
+
}
|
|
11431
|
+
|
|
11432
|
+
function renderOverview() {
|
|
11433
|
+
ovRenderHead();
|
|
11434
|
+
ovRenderKpis();
|
|
11435
|
+
syncOvRanges();
|
|
11436
|
+
renderOverviewTrend();
|
|
11437
|
+
ovRenderQuick();
|
|
11438
|
+
ovRenderRecent();
|
|
11439
|
+
}
|
|
11440
|
+
|
|
11441
|
+
function ovRenderHead() {
|
|
11442
|
+
const dateEl = $("ov-date");
|
|
11443
|
+
if (dateEl) dateEl.textContent = ovDateText();
|
|
11444
|
+
const sub = $("ov-sub");
|
|
11445
|
+
if (!sub) return;
|
|
11446
|
+
const u = S.ovUsage;
|
|
11447
|
+
if (!u || !u.totals) { sub.textContent = ""; return; }
|
|
11448
|
+
const cur = ovSplit(u.by_day).cur;
|
|
11449
|
+
const calls = ovSum(cur, "calls"), ok = ovSum(cur, "ok");
|
|
11450
|
+
const rate = calls ? Math.round(ok * 100 / calls) : 0;
|
|
11451
|
+
let s = t("近 30 天执行 ") + fmtTok(calls) + t(" 次 · 成功率 ") + rate + "%" +
|
|
11452
|
+
t(" · 消耗 ") + fmtTok(ovSum(cur, "tokens")) + t(" tokens · 费用 ") + fmtUsd(ovSum(cur, "cost_usd"));
|
|
11453
|
+
const running = ((S.state && S.state.runs) || []).filter((r) => r.status === "running").length;
|
|
11454
|
+
if (running) s += t(" · 当前 ") + running + t(" 个任务运行中");
|
|
11455
|
+
sub.textContent = s;
|
|
11456
|
+
}
|
|
11457
|
+
|
|
11458
|
+
function ovRenderKpis() {
|
|
11459
|
+
const box = $("ov-kpis");
|
|
11460
|
+
if (!box) return;
|
|
11461
|
+
const u = S.ovUsage;
|
|
11462
|
+
if (!u || !u.totals) {
|
|
11463
|
+
box.innerHTML = '<p class="hint">' + t("暂无用量记录。跑一个任务后这里会出现统计。") + "</p>";
|
|
11464
|
+
return;
|
|
11465
|
+
}
|
|
11466
|
+
const { prev, cur } = ovSplit(u.by_day);
|
|
11467
|
+
const C = { calls: ovSum(cur, "calls"), ok: ovSum(cur, "ok"),
|
|
11468
|
+
tok: ovSum(cur, "tokens"), cost: ovSum(cur, "cost_usd") };
|
|
11469
|
+
const P = { calls: ovSum(prev, "calls"), ok: ovSum(prev, "ok"),
|
|
11470
|
+
tok: ovSum(prev, "tokens"), cost: ovSum(prev, "cost_usd") };
|
|
11471
|
+
const cRate = C.calls ? C.ok * 100 / C.calls : 0;
|
|
11472
|
+
const pRate = P.calls ? P.ok * 100 / P.calls : 0;
|
|
11473
|
+
// 两个「日均」按真实活跃天数算:刚装不久只跑了 3 天,除以 30 会得到一个
|
|
11474
|
+
// 看着像「用量很低」的假平均值
|
|
11475
|
+
const activeDays = Math.max(1, cur.filter((d) => (d.calls || 0) > 0).length);
|
|
11476
|
+
box.innerHTML = [
|
|
11477
|
+
kpiCard(t("执行次数"), fmtTok(C.calls),
|
|
11478
|
+
t("成功 ") + fmtTok(C.ok) + t(" · 失败 ") + fmtTok(Math.max(0, C.calls - C.ok)),
|
|
11479
|
+
false, "i-tasks",
|
|
11480
|
+
{ tone: "accent", delta: ovDelta(C.calls, P.calls),
|
|
11481
|
+
spark: cur.map((d) => d.calls || 0) }),
|
|
11482
|
+
// 成功率环比给「百分点差」:98.7% 比 96.3% 高 2.4 个点,写成 (+2.4/96.3)=+2.5%
|
|
11483
|
+
// 反而绕;kpiDelta 输出的 +X.X% 在这里就是百分点,读起来是对的
|
|
11484
|
+
kpiCard(t("成功率"), C.calls ? cRate.toFixed(1) + "%" : "—",
|
|
11485
|
+
t("上期 ") + (P.calls ? pRate.toFixed(1) + "%" : "—"),
|
|
11486
|
+
false, "i-clip-check",
|
|
11487
|
+
{ tone: "ok", delta: (P.calls && C.calls) ? { pct: cRate - pRate } : null,
|
|
11488
|
+
spark: cur.map((d) => ((d.calls || 0) ? (d.ok || 0) * 100 / d.calls : 0)) }),
|
|
11489
|
+
kpiCard(t("Token 消耗"), fmtTok(C.tok),
|
|
11490
|
+
t("日均 ") + fmtTok(C.tok / activeDays),
|
|
11491
|
+
false, "i-sigma",
|
|
11492
|
+
{ tone: "calm", delta: ovDelta(C.tok, P.tok),
|
|
11493
|
+
spark: cur.map((d) => d.tokens || 0) }),
|
|
11494
|
+
kpiCard(t("费用"), fmtUsd(C.cost),
|
|
11495
|
+
t("日均 ") + fmtUsd(C.cost / activeDays),
|
|
11496
|
+
false, "i-coin",
|
|
11497
|
+
{ tone: "warn", delta: ovDelta(C.cost, P.cost, true),
|
|
11498
|
+
spark: cur.map((d) => d.cost_usd || 0) }),
|
|
11499
|
+
].join("");
|
|
11500
|
+
}
|
|
11501
|
+
|
|
11502
|
+
/* 趋势复用用量页同一套折线(Top5 模型 + 其他),只是把日序列截到所选范围 */
|
|
11503
|
+
function renderOverviewTrend() {
|
|
11504
|
+
const box = $("ov-trend-body");
|
|
11505
|
+
if (!box) return;
|
|
11506
|
+
const u = S.ovUsage;
|
|
11507
|
+
if (!u || !u.by_day || !u.by_day.length) {
|
|
11508
|
+
box.innerHTML = '<p class="hint">' + t("(暂无数据)") + "</p>";
|
|
11509
|
+
return;
|
|
11510
|
+
}
|
|
11511
|
+
const byDay = u.by_day.slice(-ovDays());
|
|
11512
|
+
const modelOfDay = {};
|
|
11513
|
+
(u.by_day_model || []).forEach((d) => { modelOfDay[d.day] = d.models || {}; });
|
|
11514
|
+
box.innerHTML = usageMultilineSvg(byDay,
|
|
11515
|
+
byDay.map((d) => ({ day: d.day, models: modelOfDay[d.day] || {} })));
|
|
11516
|
+
}
|
|
11517
|
+
|
|
11518
|
+
function ovRenderQuick() {
|
|
11519
|
+
const box = $("ov-quick-body");
|
|
11520
|
+
if (!box) return;
|
|
11521
|
+
const items = [
|
|
11522
|
+
{ key: "tasks", icon: "i-plus", label: t("新建任务"), sub: t("描述目标,交给智能体执行") },
|
|
11523
|
+
{ key: "knowledge", icon: "i-sigma", label: t("知识库"), sub: t("项目资料与沉淀") },
|
|
11524
|
+
{ key: "market", icon: "i-blocks", label: t("插件市场"), sub: t("装技能扩展能力") },
|
|
11525
|
+
{ key: "automation", icon: "i-calendar-days", label: t("自动化"), sub: t("定时与周期执行") },
|
|
11526
|
+
];
|
|
11527
|
+
box.innerHTML = items.map((it) =>
|
|
11528
|
+
'<button class="ov-q" type="button" onclick="switchTab(\'' + it.key + '\')">' +
|
|
11529
|
+
'<span class="ov-q-ico"><svg class="ico" aria-hidden="true"><use href="#' + it.icon + '"></use></svg></span>' +
|
|
11530
|
+
'<span class="ov-q-text"><b>' + esc(it.label) + "</b><i>" + esc(it.sub) + "</i></span>" +
|
|
11531
|
+
'<span class="ov-q-go" aria-hidden="true"><svg class="ico" aria-hidden="true"><use href="#i-arrow-up"></use></svg></span>' +
|
|
11532
|
+
"</button>").join("");
|
|
11533
|
+
}
|
|
11534
|
+
|
|
11535
|
+
/* 最近运行:状态点复用侧栏那套字形(staskGlyph),行样式照参考站的
|
|
11536
|
+
* 「图标 + 主副标题 + 右侧状态与相对时间」。点击走 jumpToRun——
|
|
11537
|
+
* 它会先切到运行记录页再开详情;在概览页直接 openRun 会把详情开在错的分区上。 */
|
|
11538
|
+
function ovRenderRecent() {
|
|
11539
|
+
const box = $("ov-recent-body");
|
|
11540
|
+
if (!box) return;
|
|
11541
|
+
const runs = ((S.state && S.state.runs) || []).slice(0, 5);
|
|
11542
|
+
if (!runs.length) { box.innerHTML = '<div class="empty">' + t("暂无运行记录") + "</div>"; return; }
|
|
11543
|
+
box.innerHTML = runs.map((r) => {
|
|
11544
|
+
const st = String(r.status || "");
|
|
11545
|
+
const sub = String(r.summary || r.error || "").trim();
|
|
11546
|
+
return '<button class="ov-run" type="button" onclick="jumpToRun(\'' + esc(r.id) + '\')">' +
|
|
11547
|
+
'<span class="ov-run-ico">' + staskGlyph(st) + "</span>" +
|
|
11548
|
+
'<span class="ov-run-main"><b>' + esc(r.title || "") + "</b>" +
|
|
11549
|
+
(sub ? "<i>" + esc(sub.length > 90 ? sub.slice(0, 89) + "…" : sub) + "</i>" : "") +
|
|
11550
|
+
"</span>" +
|
|
11551
|
+
'<span class="ov-run-meta">' +
|
|
11552
|
+
'<span class="chip ' + esc(st) + '">' + esc(runStatusText(r)) + "</span>" +
|
|
11553
|
+
'<span class="ov-run-time">' + esc(relTime(r.created_at)) + "</span>" +
|
|
11554
|
+
"</span></button>";
|
|
11555
|
+
}).join("");
|
|
11556
|
+
}
|
|
11557
|
+
|
|
11558
|
+
/* 运行状态经 SSE 推来:停在概览页时只重画依赖 runs 的两处(概览语 + 最近运行),
|
|
11559
|
+
* 不重新拉用量(台账不会因为一次运行状态变化就变) */
|
|
11560
|
+
function ovMaybeRefresh() {
|
|
11561
|
+
if (S.tab !== "overview") return;
|
|
11562
|
+
ovRenderHead();
|
|
11563
|
+
ovRenderRecent();
|
|
11564
|
+
}
|
|
11565
|
+
|
|
10915
11566
|
/* ---------------------------------------------------------- 手机连接(扫码) */
|
|
10916
11567
|
/* ZCode 桌面端式样:大二维码居中,地址+复制在下方;多来源(Tailscale/局域网)用 chips 切换 */
|
|
10917
11568
|
async function openPhoneConnect() {
|
|
@@ -11169,16 +11820,23 @@ function collapseDrawerIfMobile() {
|
|
|
11169
11820
|
if (window.innerWidth < 900) document.body.classList.add("side-collapsed");
|
|
11170
11821
|
}
|
|
11171
11822
|
|
|
11172
|
-
|
|
11823
|
+
/* 切页:主栏换成目标子页。shell 决定左栏形态——"main" 保持任务树、"settings" 换成
|
|
11824
|
+
* 设置导航,缺省按目标页归哪套导航自动选(见 mainPageSubs/settingsNavSubs)。 */
|
|
11825
|
+
function switchTab(name, shell) {
|
|
11173
11826
|
if (name === "__phone") { openPhoneConnect(); return; } // 手机连接是弹框,不切页
|
|
11174
11827
|
if (name === "__guide") { welcomeOpen(); return; } // 帮助中心是弹层,不切页(设置导航「软件」组)
|
|
11175
|
-
//
|
|
11828
|
+
// 形态由目标页决定:快捷导航页(概览/运行记录/自动化)走 main——只换主栏内容、
|
|
11829
|
+
// 左栏任务树原地不动;其余页进设置导航。调用方可用 shell 显式覆盖(如已在设置里
|
|
11830
|
+
// 点运行详情,仍要留在设置侧栏)。用户主动进设置时左栏一定会换成设置导航。
|
|
11831
|
+
const inMain = (shell || (mainPageSubs().has(name) ? "main" : "settings")) === "main";
|
|
11176
11832
|
S.tab = name;
|
|
11177
|
-
|
|
11178
|
-
|
|
11179
|
-
document.body.classList.
|
|
11833
|
+
S.mainPage = inMain ? name : ""; // 主栏停在哪个全局页;closeRun 靠它回到点开详情前那一页
|
|
11834
|
+
if (settingsNavSubs().has(name)) localStorage.setItem("orch.setTab", name);
|
|
11835
|
+
document.body.classList.toggle("settings-mode", !inMain);
|
|
11836
|
+
document.body.classList.remove("files-mode"); // 文件浏览页与两套导航都互斥,别叠在左栏
|
|
11180
11837
|
document.querySelectorAll(".page").forEach((p) => p.classList.toggle("hidden", p.id !== "page-settings"));
|
|
11181
|
-
document.querySelectorAll(".set-item").forEach((b) => b.classList.toggle("active", b.dataset.sub === name));
|
|
11838
|
+
document.querySelectorAll(".set-item").forEach((b) => b.classList.toggle("active", !inMain && b.dataset.sub === name));
|
|
11839
|
+
document.querySelectorAll(".qitem").forEach((b) => b.classList.toggle("active", inMain && b.dataset.page === name));
|
|
11182
11840
|
document.querySelectorAll("#page-settings .subpage").forEach((d) => d.classList.toggle("hidden", d.id !== "sub-" + name));
|
|
11183
11841
|
const title = $("page-title");
|
|
11184
11842
|
if (title) title.textContent = tabTitle(name);
|
|
@@ -11189,11 +11847,13 @@ function switchTab(name) {
|
|
|
11189
11847
|
if (name === "knowledge") loadKnowledge(); // 进知识库页拉取条目
|
|
11190
11848
|
if (name === "automation") { loadAutomation(); startAutoPoll(); } // 进自动化页:拉取 + 页面可见时每 8s 轮询
|
|
11191
11849
|
else stopAutoPoll(); // 离开自动化页(或切到别的子页)即停表
|
|
11850
|
+
if (name === "hooks") loadHooksPanel(); // 进钩子页:拉配置渲染管理列表
|
|
11192
11851
|
if (name === "zentao") loadZentao(); // 进禅道页:拉配置与修复记录回填表单
|
|
11193
11852
|
if (name === "__wxdigest") { beeRefresh(); beeMarkSeen(); } // 进群摘要页:拉取视图并清未读
|
|
11194
11853
|
if (name === "market") loadMarket(); // 进插件市场页拉取目录
|
|
11195
11854
|
if (name === "data") loadDataPage(); // 进数据与备份页:清理配置/状态/可清理预估
|
|
11196
11855
|
if (name === "usage") { syncUsageRange(); loadUsage(); } // 进用量页:对齐范围选中态并拉取
|
|
11856
|
+
if (name === "overview") { ovDays(); loadOverview(); } // 进概览页:对齐趋势范围并拉两期台账
|
|
11197
11857
|
if (name === "appearance") renderAppearance(); // 进皮肤页:按当前皮肤/明暗重画卡片
|
|
11198
11858
|
if (name === "appearance") renderCodeSettings(); // 代码设置行 + 双主题预览卡同步当前值
|
|
11199
11859
|
if (name === "about") loadSelfupdate(false); // 进关于页:拉版本与更新状态
|
|
@@ -11201,13 +11861,15 @@ function switchTab(name) {
|
|
|
11201
11861
|
syncInspectorVis(); // 检查器只属于任务上下文:进设置子页自动收起
|
|
11202
11862
|
}
|
|
11203
11863
|
|
|
11204
|
-
/*
|
|
11864
|
+
/* 退出设置/回到新建任务表单:左栏恢复任务树,主栏回到任务页 */
|
|
11205
11865
|
function exitSettings() {
|
|
11206
11866
|
S.tab = "tasks";
|
|
11867
|
+
S.mainPage = ""; // 主栏回到表单:不再是快捷导航页
|
|
11207
11868
|
stopAutoPoll(); // 离开设置视图:自动化页轮询一并停掉
|
|
11208
11869
|
document.querySelectorAll(".page").forEach((p) => p.classList.toggle("hidden", p.id !== "page-settings"));
|
|
11209
11870
|
document.querySelectorAll("#page-settings .subpage").forEach((d) => d.classList.toggle("hidden", d.id !== "sub-tasks"));
|
|
11210
11871
|
document.querySelectorAll(".set-item").forEach((b) => b.classList.toggle("active", b.dataset.sub === "tasks"));
|
|
11872
|
+
document.querySelectorAll(".qitem").forEach((b) => b.classList.remove("active"));
|
|
11211
11873
|
document.body.classList.remove("settings-mode");
|
|
11212
11874
|
document.body.classList.remove("files-mode");
|
|
11213
11875
|
cmpGreeting(); // 回到任务页:问候语刷新
|
|
@@ -11217,10 +11879,12 @@ function exitSettings() {
|
|
|
11217
11879
|
syncInspectorVis(); // 回到新建表单:不是任务上下文,检查器收起(点运行详情才会滑回)
|
|
11218
11880
|
}
|
|
11219
11881
|
|
|
11220
|
-
/*
|
|
11882
|
+
/* 侧栏底部齿轮:直接进设置(左栏换成设置导航,不再弹菜单)。回到上次看的那页,默认编排设置。
|
|
11883
|
+
* 只认设置导航里真实存在的子页——概览/运行记录/自动化已升为快捷导航,落回它们会停在
|
|
11884
|
+
* 一个没有导航项高亮的设置页。 */
|
|
11221
11885
|
function enterSettings() {
|
|
11222
11886
|
const saved = localStorage.getItem("orch.setTab") || "";
|
|
11223
|
-
switchTab(
|
|
11887
|
+
switchTab(settingsNavSubs().has(saved) ? saved : "orch", "settings");
|
|
11224
11888
|
}
|
|
11225
11889
|
|
|
11226
11890
|
window.openRun = openRun;
|
|
@@ -11247,6 +11911,8 @@ window.bindPromote = bindPromote;
|
|
|
11247
11911
|
window.mgmt = mgmt;
|
|
11248
11912
|
window.switchTab = switchTab;
|
|
11249
11913
|
window.setUsageDays = setUsageDays;
|
|
11914
|
+
window.setOvDays = setOvDays;
|
|
11915
|
+
window.jumpToRun = jumpToRun;
|
|
11250
11916
|
window.saveProvider = saveProvider;
|
|
11251
11917
|
window.delProvider = delProvider;
|
|
11252
11918
|
window.openAddProviderDialog = openAddProviderDialog;
|
|
@@ -11567,9 +12233,11 @@ document.addEventListener("DOMContentLoaded", () => {
|
|
|
11567
12233
|
if (e.target.closest("button, summary, .stask")) collapseDrawerIfMobile();
|
|
11568
12234
|
});
|
|
11569
12235
|
$("btn-new-task").addEventListener("click", exitSettings);
|
|
11570
|
-
//
|
|
11571
|
-
|
|
11572
|
-
$("btn-q-
|
|
12236
|
+
// 侧栏快捷导航:主栏换成目标页,左栏任务树不动(shell="main")。
|
|
12237
|
+
// 页面清单以 index.html .side-quick 的 data-page 为准,这里逐项绑 id。
|
|
12238
|
+
$("btn-q-overview").addEventListener("click", () => switchTab("overview", "main"));
|
|
12239
|
+
$("btn-q-runs").addEventListener("click", () => switchTab("runs", "main"));
|
|
12240
|
+
$("btn-q-automation").addEventListener("click", () => switchTab("automation", "main"));
|
|
11573
12241
|
bindCtxMenus();
|
|
11574
12242
|
bindInspector();
|
|
11575
12243
|
if (window.innerWidth < 900) document.body.classList.add("side-collapsed");
|