refactor(dev): 前端模块化 Step 1 — dev.html 拆零构建 ES module(叶子优先)
dev.html 原 4087 行单文件原生 JS。本步只做"拆文件"(路径 1),
不引框架、不改逻辑——纯剪切 + import 连线。
抽出 4 个无依赖叶子到 web/static/js/:
- state.js state 单例 + LS_* + EMBED*
- format.js escapeHtml / humanSize / fmtTokens / usage 系列等纯格式化
- dom.js $ + 浮层菜单 showMenu/hideMenu(import escapeHtml)
- api.js api() Bearer 封装(import state)
- markdown.js renderMd / highlightIn(依赖 vendor 全局)
剩余主体(login→boot)落 main.js 并 import 叶子;dev.html 内联大
<script> 换成一行 <script type="module" src="js/main.js">,降到 1121 行。
app.py 加 mimetypes.add_type("text/javascript", ".js") 兜底,防 Windows
把 .js 判成 text/plain 致 module 被浏览器拒执行(本机实测本就 OK,纯防御)。
校验:6 模块 node --check 全过 + 无私有符号泄漏到 main。
后续 Step 2+ 再从 main.js 把 login/tasks/stream/files/preview 逐个剥成独立模块。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6f9ca26e3f
commit
72ae41e122
|
|
@ -23,6 +23,7 @@
|
||||||
|
|
||||||
### 2026-06-05
|
### 2026-06-05
|
||||||
|
|
||||||
|
- **前端模块化 Step 1:`dev.html` 单文件拆零构建 ES module(叶子优先)**:`web/static/dev.html` 原 4087 行(纯原生 JS、手写 `state` + 手动 DOM、零内联 `onclick` 全 `addEventListener`、唯一 `window.*` 是比较非赋值)。定方案「1 拆文件 → 2 后续引 Alpine/petite-vue 局部响应式 → 3 永不上 Vue+构建链」,本步只做 1。抽出 4 个无依赖叶子模块到 `web/static/js/`:`state.js`(`state` 单例 + `LS_*` + `EMBED*`)、`format.js`(escapeHtml/humanSize/fmtTokens/fmtCost/usage 系列等纯格式化)、`dom.js`(`$` + 浮层菜单 showMenu/hideMenu,import escapeHtml)、`api.js`(`api()` Bearer 封装,import state)、`markdown.js`(renderMd/highlightIn,依赖 vendor 全局)。剩余主体(login→boot,原 1387–4084)整体落 `main.js` 并 import 上述叶子;`dev.html` 内联大 `<script>` 换成一行 `<script type="module" src="js/main.js">`,降到 1121 行。**逻辑零改动,纯剪切+连线**。`app.py` 加 `mimetypes.add_type("text/javascript", ".js")` 兜底(防 Windows 把 `.js` 判 text/plain 致 module 拒执行;本机实测 `.js`→application/javascript 本就 OK,纯防御)。校验:6 模块 `node --check` 全过 + 无私有符号(`_menuItems`/`_embedQS`)泄漏到 main。后续步骤将从 main.js 把 login/tasks/stream/files/preview 等逐个剥成独立模块(tasks↔stream 循环依赖靠 ES live binding 解)。
|
||||||
- **改密码弹框样式修复**:`#chpw-modal` 原先没专属 CSS,`.card` 只继承公共骨架(背景+圆角+阴影),缺 padding/width/表单整形 → 卡片被撑到近全宽、无内边距很素。改为复用「选入文件」弹框(`#src-picker-modal`)的头/体/脚分隔布局:标题区底部分隔线、表单内容包进 `.body`(内边距 16/18)、按钮区顶部分隔线右对齐,`.card` 收到 400px + flex column,input focus 红框高亮。纯 CSS/HTML 结构调整,无对外行为变化(不动 DESIGN/RUN)。
|
- **改密码弹框样式修复**:`#chpw-modal` 原先没专属 CSS,`.card` 只继承公共骨架(背景+圆角+阴影),缺 padding/width/表单整形 → 卡片被撑到近全宽、无内边距很素。改为复用「选入文件」弹框(`#src-picker-modal`)的头/体/脚分隔布局:标题区底部分隔线、表单内容包进 `.body`(内边距 16/18)、按钮区顶部分隔线右对齐,`.card` 收到 400px + flex column,input focus 红框高亮。纯 CSS/HTML 结构调整,无对外行为变化(不动 DESIGN/RUN)。
|
||||||
- **run_python 过程脚本约定 `<task_dir>/scripts/`**:确认现状是模型生成的 `.py` 直接落 task_dir 根(系统提示只说"只写到 task_dir",无 scripts/ 分层),过程脚本和交付产物(.docx/.pptx/spec)混在一起。定调:**模型显式写文件再 `script_path` 跑的过程脚本** → `<task_dir>/scripts/`(可见/持久/可重跑,`WriteTool` 自动建父目录);**inline `code` 匿名片段** → 维持临时用后即焚(host 走系统 temp、docker 走 `.zcbot_tmp/<task_id>/` dotfile 隐藏+删,均不动)——不持久化到 scripts/,免把目录污染成匿名垃圾堆。改 `core/agent_builder.py` 系统提示工作目录段加一条 scripts/ 引导(>~15 行/要迭代/出产物用文件,短抛弃代码才内联)+ `tools/run_python.py` 的 tool description / `script_path` 参数说明同步。inline 执行逻辑两后端均未改。tests `test_run_python_script_path` / `test_executor_docker` 全过(2 skip 为 Linux-only)。
|
- **run_python 过程脚本约定 `<task_dir>/scripts/`**:确认现状是模型生成的 `.py` 直接落 task_dir 根(系统提示只说"只写到 task_dir",无 scripts/ 分层),过程脚本和交付产物(.docx/.pptx/spec)混在一起。定调:**模型显式写文件再 `script_path` 跑的过程脚本** → `<task_dir>/scripts/`(可见/持久/可重跑,`WriteTool` 自动建父目录);**inline `code` 匿名片段** → 维持临时用后即焚(host 走系统 temp、docker 走 `.zcbot_tmp/<task_id>/` dotfile 隐藏+删,均不动)——不持久化到 scripts/,免把目录污染成匿名垃圾堆。改 `core/agent_builder.py` 系统提示工作目录段加一条 scripts/ 引导(>~15 行/要迭代/出产物用文件,短抛弃代码才内联)+ `tools/run_python.py` 的 tool description / `script_path` 参数说明同步。inline 执行逻辑两后端均未改。tests `test_run_python_script_path` / `test_executor_docker` 全过(2 skip 为 Linux-only)。
|
||||||
- **新增 `standard` skill(国标/行标/团标起草)**:联网核实市面无可直接复用的"写标准文件本身"的 skill(搜到的 technical-proposal GB/T 8567、official-document GB/T 9704 都是相邻品类——投标书/公文,非标准),据 GB/T 1.1—2020 自建。覆盖三层级(国标 GB·T / 行标 JC·T / 团标 T/,重点对接 **CSTM → T/CSTM**,材料试验团标对口建材院检测方向)× 两体裁骨架(试验方法 GB/T 20001.4 + 产品标准)。文件:`SKILL.md`(阶段化:定层级体裁→八条 spec→逐章段段卡→自检渲染)+ 3 references(`gbt_1_1_structure` 要素骨架/必备可选/规范性资料性/封面前言套话、`standard_levels` 选型+CSTM 体系立项、`drafting_rules` 能愿动词应宜可能/不可考核词过滤/指标量化闭环/术语规则/引用真实性+§8 自检清单)+ 4 templates(spec/test_method/product_standard/编制说明)。**渲染复用 proposal `render_docx.py`+`render_diagrams.py`**(兄弟 skill `../proposal/scripts/`,同 patent 范式);冒烟测过表格/中文渲染正常。**坑**:proposal `quality_check.py` 按申报书固定章节名查"缺章节",对标准全是误报且无跳过开关→阶段三不用机检,改 drafting_rules §8 人工 12 条清单(与 patent self_check 同思路)。产出是结构合规草稿 docx,正式报批再灌官方 TCS/CSTM 模板做版式精修。
|
- **新增 `standard` skill(国标/行标/团标起草)**:联网核实市面无可直接复用的"写标准文件本身"的 skill(搜到的 technical-proposal GB/T 8567、official-document GB/T 9704 都是相邻品类——投标书/公文,非标准),据 GB/T 1.1—2020 自建。覆盖三层级(国标 GB·T / 行标 JC·T / 团标 T/,重点对接 **CSTM → T/CSTM**,材料试验团标对口建材院检测方向)× 两体裁骨架(试验方法 GB/T 20001.4 + 产品标准)。文件:`SKILL.md`(阶段化:定层级体裁→八条 spec→逐章段段卡→自检渲染)+ 3 references(`gbt_1_1_structure` 要素骨架/必备可选/规范性资料性/封面前言套话、`standard_levels` 选型+CSTM 体系立项、`drafting_rules` 能愿动词应宜可能/不可考核词过滤/指标量化闭环/术语规则/引用真实性+§8 自检清单)+ 4 templates(spec/test_method/product_standard/编制说明)。**渲染复用 proposal `render_docx.py`+`render_diagrams.py`**(兄弟 skill `../proposal/scripts/`,同 patent 范式);冒烟测过表格/中文渲染正常。**坑**:proposal `quality_check.py` 按申报书固定章节名查"缺章节",对标准全是误报且无跳过开关→阶段三不用机检,改 drafting_rules §8 人工 12 条清单(与 patent self_check 同思路)。产出是结构合规草稿 docx,正式报批再灌官方 TCS/CSTM 模板做版式精修。
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
@ -686,6 +687,9 @@ def create_app() -> FastAPI:
|
||||||
)
|
)
|
||||||
|
|
||||||
if _STATIC_DIR.is_dir():
|
if _STATIC_DIR.is_dir():
|
||||||
|
# Windows 上 mimetypes 偶尔把 .js 判成 text/plain,会令 <script type="module"> 被浏览器拒执行;
|
||||||
|
# 显式兜底,保证静态 ES module 以正确 MIME 下发。
|
||||||
|
mimetypes.add_type("text/javascript", ".js")
|
||||||
app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static")
|
app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static")
|
||||||
|
|
||||||
# ───────────── Misc ─────────────
|
# ───────────── Misc ─────────────
|
||||||
|
|
|
||||||
2969
web/static/dev.html
2969
web/static/dev.html
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,27 @@
|
||||||
|
// 统一 JSON fetch 封装:注入 Bearer token,解析 JSON/文本,非 2xx 抛带 status 的 Error。
|
||||||
|
// (login / create_user / SSE / 文件下载 因 header / 流式 / blob 特殊,仍各自手写 fetch)
|
||||||
|
import { state } from "./state.js";
|
||||||
|
|
||||||
|
export async function api(method, path, body) {
|
||||||
|
const opts = { method, headers: {} };
|
||||||
|
if (state.token) opts.headers["Authorization"] = "Bearer " + state.token;
|
||||||
|
if (body !== undefined) {
|
||||||
|
opts.headers["Content-Type"] = "application/json";
|
||||||
|
opts.body = JSON.stringify(body);
|
||||||
|
}
|
||||||
|
const r = await fetch(path, opts);
|
||||||
|
let data = null;
|
||||||
|
const ct = r.headers.get("content-type") || "";
|
||||||
|
if (ct.includes("application/json")) {
|
||||||
|
try { data = await r.json(); } catch (e) {}
|
||||||
|
} else {
|
||||||
|
data = await r.text();
|
||||||
|
}
|
||||||
|
if (!r.ok) {
|
||||||
|
const msg = (data && data.detail) || data || (r.status + " " + r.statusText);
|
||||||
|
const err = new Error(typeof msg === "string" ? msg : JSON.stringify(msg));
|
||||||
|
err.status = r.status;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
// DOM 小工具 + 单例浮层菜单。
|
||||||
|
import { escapeHtml } from "./format.js";
|
||||||
|
|
||||||
|
export const $ = (id) => document.getElementById(id);
|
||||||
|
|
||||||
|
// ───── floating dropdown menu (single instance) ─────
|
||||||
|
// 用 position: fixed 单例避免被 pane overflow 裁剪;按位置算出右上角对齐
|
||||||
|
let _menuItems = null;
|
||||||
|
export function showMenu(triggerEl, items) {
|
||||||
|
_menuItems = items;
|
||||||
|
const menu = $("floating-menu");
|
||||||
|
menu.innerHTML = items.map((it) => {
|
||||||
|
const cls = "dd-item " + (it.cls || "");
|
||||||
|
const dis = it.disabled ? " disabled" : "";
|
||||||
|
return `<button class="${cls}" data-act="${escapeHtml(it.act)}"${dis}>${escapeHtml(it.label)}</button>`;
|
||||||
|
}).join("");
|
||||||
|
menu.querySelectorAll(".dd-item").forEach((btn) => {
|
||||||
|
btn.onclick = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const act = btn.dataset.act;
|
||||||
|
const item = _menuItems && _menuItems.find((i) => i.act === act);
|
||||||
|
hideMenu();
|
||||||
|
if (item && item.onclick) item.onclick();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
// 默认右下展开;若空间不足则改向上
|
||||||
|
const rect = triggerEl.getBoundingClientRect();
|
||||||
|
menu.style.visibility = "hidden";
|
||||||
|
menu.classList.add("show");
|
||||||
|
const mh = menu.offsetHeight || 120;
|
||||||
|
menu.style.right = Math.max(4, window.innerWidth - rect.right) + "px";
|
||||||
|
menu.style.left = "auto";
|
||||||
|
if (rect.bottom + mh + 8 > window.innerHeight) {
|
||||||
|
menu.style.top = Math.max(4, rect.top - mh - 4) + "px";
|
||||||
|
} else {
|
||||||
|
menu.style.top = (rect.bottom + 4) + "px";
|
||||||
|
}
|
||||||
|
menu.style.visibility = "";
|
||||||
|
}
|
||||||
|
export function hideMenu() {
|
||||||
|
_menuItems = null;
|
||||||
|
$("floating-menu").classList.remove("show");
|
||||||
|
}
|
||||||
|
document.addEventListener("click", (e) => {
|
||||||
|
if (e.target.closest(".dd-toggle")) return;
|
||||||
|
if (e.target.closest("#floating-menu")) return;
|
||||||
|
hideMenu();
|
||||||
|
}, true);
|
||||||
|
window.addEventListener("resize", hideMenu);
|
||||||
|
// 滚动 pane 时菜单位置失效,直接关
|
||||||
|
document.addEventListener("scroll", hideMenu, true);
|
||||||
|
|
@ -0,0 +1,121 @@
|
||||||
|
// 纯格式化 / 转义工具(无 DOM、无状态依赖)。dom.js / markdown.js / main.js 共用。
|
||||||
|
|
||||||
|
export function escapeHtml(s) {
|
||||||
|
return (s || "").replace(/[&<>"']/g, (c) => (
|
||||||
|
{ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function humanSize(n) {
|
||||||
|
if (n == null) return "";
|
||||||
|
if (n < 1024) return n + " B";
|
||||||
|
if (n < 1024*1024) return (n/1024).toFixed(1) + " K";
|
||||||
|
if (n < 1024*1024*1024) return (n/1024/1024).toFixed(1) + " M";
|
||||||
|
return (n/1024/1024/1024).toFixed(1) + " G";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtTime(iso) {
|
||||||
|
if (!iso) return "";
|
||||||
|
try { return new Date(iso).toLocaleString(); } catch (e) { return iso; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 紧凑 token 显示:<1k 原数,<10k 一位小数 k,>=10k 整数 k,>=1M 一位小数 M
|
||||||
|
// 目的:让列表行 "N tok" 槽位宽度有上限,跨行对齐
|
||||||
|
export function fmtTokens(n) {
|
||||||
|
n = n || 0;
|
||||||
|
if (n < 1000) return String(n);
|
||||||
|
if (n < 10000) return (n / 1000).toFixed(1) + "k";
|
||||||
|
if (n < 1000000) return Math.round(n / 1000) + "k";
|
||||||
|
return (n / 1000000).toFixed(1) + "M";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 紧凑成本显示(¥,已按缓存折价的真实花费):0 不显;<0.01 三位小数;否则两位
|
||||||
|
export function fmtCost(n) {
|
||||||
|
n = n || 0;
|
||||||
|
if (n <= 0) return "";
|
||||||
|
if (n < 0.01) return "¥" + n.toFixed(3);
|
||||||
|
return "¥" + n.toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 任务累计用量的 hover 详情(多行):输入/输出拆分 · 缓存命中 + 命中率 · 真实花费。
|
||||||
|
// 列表行 + 顶栏共用(列表只显 tok 数,花费/缓存藏 tooltip;顶栏额外内联简版)。
|
||||||
|
export function taskUsageTooltip(t) {
|
||||||
|
const pin = t.tokens_prompt || 0;
|
||||||
|
const pout = t.tokens_completion || 0;
|
||||||
|
const hit = t.tokens_cache_hit || 0;
|
||||||
|
const lines = [`输入 ${pin.toLocaleString()} / 输出 ${pout.toLocaleString()} tok(合计 ${(pin + pout).toLocaleString()})`];
|
||||||
|
if (pin > 0 && hit > 0) {
|
||||||
|
lines.push(`前缀缓存命中 ${hit.toLocaleString()} tok(命中率 ${Math.round(hit / pin * 100)}%,命中部分按低价计费)`);
|
||||||
|
}
|
||||||
|
if (t.cost_cny > 0) {
|
||||||
|
lines.push(`真实花费 ¥${(t.cost_cny).toFixed(4)}(已按缓存命中折价)`);
|
||||||
|
}
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 任务级累计用量(顶栏):总 token · 缓存命中率 · 真实花费;详情走 taskUsageTooltip。
|
||||||
|
// 缓存命中率 = cache_hit / 总输入(tokens_prompt);命中越高说明前缀复用越好、越省钱。
|
||||||
|
export function formatTaskUsage(t) {
|
||||||
|
const tok = t.tokens || 0;
|
||||||
|
if (!tok) return "";
|
||||||
|
const hit = t.tokens_cache_hit || 0;
|
||||||
|
const pin = t.tokens_prompt || 0;
|
||||||
|
const bits = [`${fmtTokens(tok)} tok`];
|
||||||
|
if (pin > 0 && hit > 0) {
|
||||||
|
bits.push(`缓存命中 ${Math.round(hit / pin * 100)}%`);
|
||||||
|
}
|
||||||
|
const cost = fmtCost(t.cost_cny);
|
||||||
|
if (cost) bits.push(cost);
|
||||||
|
return `<span class="muted" title="${escapeHtml(taskUsageTooltip(t))}" style="white-space:nowrap;">${bits.join(" · ")}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatContextStats(d) {
|
||||||
|
d = d || {};
|
||||||
|
const orig = d.context_original_chars || 0;
|
||||||
|
const sent = d.context_sent_chars || 0;
|
||||||
|
const saved = d.context_saved_chars || 0;
|
||||||
|
const tools = d.context_compacted_tool_messages || 0;
|
||||||
|
const skills = d.context_compacted_skill_messages || 0;
|
||||||
|
if (!orig) return "准备中…";
|
||||||
|
const bits = [`ctx ${fmtTokens(orig)}→${fmtTokens(sent)} chars`];
|
||||||
|
if (saved > 0) bits.push(`省 ${fmtTokens(saved)}`);
|
||||||
|
if (tools > 0) bits.push(`压缩工具 ${tools}`);
|
||||||
|
if (skills > 0) bits.push(`skill ${skills}`);
|
||||||
|
return bits.join(" · ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatUsageStats(d, contextStats) {
|
||||||
|
d = d || {};
|
||||||
|
const pt = d.prompt_tokens || 0;
|
||||||
|
const ct = d.completion_tokens || 0;
|
||||||
|
const hit = d.cache_hit_tokens || 0;
|
||||||
|
const miss = d.cache_miss_tokens || 0;
|
||||||
|
const bits = [`${fmtTokens(pt)}+${fmtTokens(ct)} tok`];
|
||||||
|
if (hit || miss) bits.push(`cache ${fmtTokens(hit)}/${fmtTokens(miss)}`);
|
||||||
|
if (contextStats && contextStats.context_saved_chars) {
|
||||||
|
bits.push(`ctx省 ${fmtTokens(contextStats.context_saved_chars)}`);
|
||||||
|
}
|
||||||
|
return bits.join(" · ");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 相对时间(任务列表用):刚刚 / N 分钟前 / N 小时前 / 昨天 HH:MM / MM-DD / YYYY-MM-DD
|
||||||
|
export function fmtTimeAgo(iso) {
|
||||||
|
if (!iso) return "";
|
||||||
|
let d;
|
||||||
|
try { d = new Date(iso); } catch (e) { return iso; }
|
||||||
|
if (isNaN(d.getTime())) return iso;
|
||||||
|
const now = new Date();
|
||||||
|
const diffSec = Math.floor((now - d) / 1000);
|
||||||
|
if (diffSec < 0) return d.toLocaleString(); // 时钟漂移兜底
|
||||||
|
if (diffSec < 60) return "刚刚";
|
||||||
|
if (diffSec < 3600) return `${Math.floor(diffSec / 60)} 分钟前`;
|
||||||
|
if (diffSec < 86400 && now.getDate() === d.getDate()) return `${Math.floor(diffSec / 3600)} 小时前`;
|
||||||
|
const pad = (n) => String(n).padStart(2, "0");
|
||||||
|
const hhmm = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
const yest = new Date(now); yest.setDate(now.getDate() - 1);
|
||||||
|
if (d.getFullYear() === yest.getFullYear() && d.getMonth() === yest.getMonth() && d.getDate() === yest.getDate()) {
|
||||||
|
return `昨天 ${hhmm}`;
|
||||||
|
}
|
||||||
|
if (d.getFullYear() === now.getFullYear()) return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${hhmm}`;
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,27 @@
|
||||||
|
// markdown 渲染 + 代码高亮。依赖 vendor 全局(window.marked / DOMPurify / hljs)。
|
||||||
|
// 三个库任一缺失 → 优雅降级回 <pre>escapeHtml</pre>(plain text wrap)。
|
||||||
|
import { escapeHtml } from "./format.js";
|
||||||
|
|
||||||
|
if (window.marked && window.marked.setOptions) {
|
||||||
|
window.marked.setOptions({ gfm: true, breaks: true, headerIds: false, mangle: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderMd(text) {
|
||||||
|
const raw = String(text || "");
|
||||||
|
if (!window.marked || !window.marked.parse) {
|
||||||
|
return `<pre style="white-space:pre-wrap;word-break:break-word;font-family:inherit;margin:0;">${escapeHtml(raw)}</pre>`;
|
||||||
|
}
|
||||||
|
let html = window.marked.parse(raw);
|
||||||
|
if (window.DOMPurify) {
|
||||||
|
html = window.DOMPurify.sanitize(html, { USE_PROFILES: { html: true } });
|
||||||
|
}
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function highlightIn(container) {
|
||||||
|
if (!window.hljs || !container) return;
|
||||||
|
container.querySelectorAll("pre code").forEach((b) => {
|
||||||
|
if (b.dataset.hl === "1") return;
|
||||||
|
try { window.hljs.highlightElement(b); b.dataset.hl = "1"; } catch (e) {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
// 全局状态 + 持久化键 + embed 标志。
|
||||||
|
// (从 dev.html 内联脚本抽出;路径 1 模块化第一步,逻辑零改动)
|
||||||
|
|
||||||
|
export const LS_TOKEN = "zcbot.token";
|
||||||
|
export const LS_UID = "zcbot.user_id";
|
||||||
|
export const LS_NAME = "zcbot.name";
|
||||||
|
export const LS_LEFT_COLLAPSED = "zcbot.left-collapsed";
|
||||||
|
export const LS_RIGHT_COLLAPSED = "zcbot.right-collapsed";
|
||||||
|
export const LS_LEFT_WIDTH = "zcbot.left-width";
|
||||||
|
export const LS_RIGHT_WIDTH = "zcbot.right-width";
|
||||||
|
|
||||||
|
// ?embed=1&parent_origin=https://... → iframe 模式;父页面用 postMessage 推 token
|
||||||
|
// 可选 task_id=<uuid>:首次签发 token 后自动定位到该 task 并加载消息
|
||||||
|
const _embedQS = new URLSearchParams(location.search);
|
||||||
|
export const EMBED = _embedQS.get("embed") === "1";
|
||||||
|
export const EMBED_PARENT_ORIGIN = (_embedQS.get("parent_origin") || "").trim();
|
||||||
|
export const EMBED_INITIAL_TASK_ID = (_embedQS.get("task_id") || "").trim();
|
||||||
|
|
||||||
|
export const state = {
|
||||||
|
token: localStorage.getItem(LS_TOKEN) || "",
|
||||||
|
userId: localStorage.getItem(LS_UID) || "",
|
||||||
|
userName: localStorage.getItem(LS_NAME) || "",
|
||||||
|
taskId: null,
|
||||||
|
taskMeta: null,
|
||||||
|
filesPath: "",
|
||||||
|
// 同 wd 内除自己外其他活跃 task(run_status in running/cancelling),供 banner 显示
|
||||||
|
concurrentWarnings: [],
|
||||||
|
evtSrc: null,
|
||||||
|
streaming: false, // 兼容旧判断:任一 task 是否在流式中
|
||||||
|
liveRuns: new Map(), // task_id -> 当前浏览器会话内运行中的回复卡/累计文本
|
||||||
|
// task list 滚动加载 + 筛选
|
||||||
|
taskPage: 0, // 已加载到的最后一页(0 = 未加载)
|
||||||
|
taskPageSize: 20,
|
||||||
|
taskTotal: 0,
|
||||||
|
taskLoaded: 0, // 已渲染条数(用于 has-more 判断)
|
||||||
|
taskLoading: false, // 在途请求标记,防 observer 重复触发
|
||||||
|
taskHasMore: true,
|
||||||
|
// 模型清单(GET /v1/models 一次缓存):新建对话框 + 顶栏切换下拉 + 历史小标显示名都用
|
||||||
|
models: [],
|
||||||
|
// 图像生成模型清单(GET /v1/image_models;ARK_API_KEY 未设也会拿到 yaml 元数据)
|
||||||
|
imageModels: [],
|
||||||
|
// 当前选中的图像生成 variant key(per-session,不入 DB);默认 = imageModels[0].variant
|
||||||
|
// (=yaml 第一个 = agent_builder fallback)。下次 send 消息时随 POST body 带给 backend。
|
||||||
|
imageModel: "",
|
||||||
|
// 视频生成模型清单(GET /v1/video_models)+ 当前选中 variant。同 imageModels 范式。
|
||||||
|
videoModels: [],
|
||||||
|
videoModel: "",
|
||||||
|
// 润色按钮进行中标记:防止双击,同时让 syncOptimizeBtn 在 in-flight 期间不覆盖
|
||||||
|
// disabled 状态(否则用户键入 input 会把按钮从"润色中"误启回 enabled)
|
||||||
|
optimizing: false,
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue