zcbot/web/static/js/state.js

117 lines
6.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 全局状态 + 持久化键 + 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_USERNAME = "zcbot.user_name"; // 平台账号名(hover 展示)
export const LS_EMAIL = "zcbot.email"; // 邮箱(hover 展示)
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";
export const LS_TASK_FILTERS_COLLAPSED = "zcbot.task-filters-collapsed"; // 左栏筛选区折叠偏好(默认折叠)
// ?embed=1&parent_origin=https://... → iframe 模式;父页面用 postMessage 推 token
// ?embed=1&wecom=1 → 企业微信免登模式;token 由 /v1/wecom/entry 回调经 URL fragment 带入,
// 401 时重定向回 entry 静默重签(不走 postMessage,不需要 parent_origin)
// ?embed=1&relogin_url=<绝对地址> → App 套壳模式;token 同样经 URL fragment 带入,
// 401 时 location.replace(relogin_url) 回登录入口重签(原生壳拦自定义 scheme,或 H5 登录页)
// 可选 task_id=<uuid>:首次签发 token 后自动定位到该 task 并加载消息
const _embedQS = new URLSearchParams(location.search);
export const EMBED = _embedQS.get("embed") === "1";
export const EMBED_WECOM = EMBED && _embedQS.get("wecom") === "1";
export const EMBED_PARENT_ORIGIN = (_embedQS.get("parent_origin") || "").trim();
export const EMBED_INITIAL_TASK_ID = (_embedQS.get("task_id") || "").trim();
// relogin_url 消毒:必须带 scheme 的绝对地址(https:// 或 App 自定义 scheme 如 zcbotapp://),
// 拦掉 javascript:/data: 等可执行 scheme —— 这个值会喂给 location.replace,不消毒就是 XSS 口子
function _sanitizeReloginUrl(raw) {
const u = (raw || "").trim();
const m = u.match(/^([a-z][a-z0-9+.-]*):/i);
if (!m) return "";
if (["javascript", "data", "vbscript", "blob", "file"].includes(m[1].toLowerCase())) return "";
return u;
}
export const EMBED_RELOGIN_URL = EMBED ? _sanitizeReloginUrl(_embedQS.get("relogin_url")) : "";
export const state = {
token: localStorage.getItem(LS_TOKEN) || "",
userId: localStorage.getItem(LS_UID) || "",
userName: localStorage.getItem(LS_NAME) || "", // name(显示名/姓名)
userUserName: localStorage.getItem(LS_USERNAME) || "", // user_name(平台账号名)
userEmail: localStorage.getItem(LS_EMAIL) || "", // email
taskId: null,
taskMeta: null,
// “+ 新对话”/登录空态共用的前端草稿;首发成功后才创建真实 task。
draftWorkingDir: "",
draftNewDirMode: false,
filesPath: "",
showHiddenDirs: false, // 仅当前 task 工作目录子树生效user_root 永不展示
// 同 wd 内除自己外其他活跃 task(run_status in running/cancelling),供 banner 显示
concurrentWarnings: [],
evtSrc: null,
streaming: false, // 兼容旧判断:任一 task 是否在流式中
liveRuns: new Map(), // task_id -> 当前浏览器会话内运行中的回复卡/累计文本
taskProgressByTask: new Map(), // task_id -> 历史消息重放后的当前进度步骤
// 消息分页(尾部窗口 + 向上滚动加载更早):切 task 默认只拉最近一批,
// 顶部 sentinel 进视口自动往前补。loadedMessages 是当前已加载的升序窗口,
// renderMessages 对它做纯函数渲染(时序累积逻辑无需改)。
loadedMessages: [],
msgHasMore: false, // 更早是否还有(后端 has_more)
msgLoadingEarlier: false, // 向上加载在途标记,防 observer 重复触发
msgHasMoreNewer: false, // 更新是否还有(从目录跳到旧消息后,下方还有未加载的新消息)
msgLoadingNewer: false, // 向下加载在途标记
// 消息目录(右侧悬浮圆点轨道):全部 user 轮次的 {idx, snippet},点圆点滚动定位;
// 跳到未加载的旧轮次时用 before_idx 拉居中窗口再滚过去。GET /v1/tasks/{id}/outline。
outline: [],
// 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);默认 = is_default 项
// (后端按档位定:有 gpt_image 权限默认 GPT 生图,否则 seedream)。下次 send 消息时随 POST body 带给 backend。
imageModel: "",
// 视频生成模型清单(GET /v1/video_models)+ 当前选中 variant。同 imageModels 范式。
videoModels: [],
videoModel: "",
// 润色按钮进行中标记:防止双击,同时让 syncOptimizeBtn 在 in-flight 期间不覆盖
// disabled 状态(否则用户键入 input 会把按钮从"润色中"误启回 enabled)
optimizing: false,
};
// 写入当前用户身份到 state + localStorage(登录 / embed 签发 / /v1/me 刷新共用)。
// 缺省字段写空串并清掉对应 LS,避免上一个用户的残留串到下一个。token 单独存,不在这里。
export function setIdentity({ user_id, name, user_name, email } = {}) {
if (user_id !== undefined) {
state.userId = user_id || "";
if (state.userId) localStorage.setItem(LS_UID, state.userId); else localStorage.removeItem(LS_UID);
}
state.userName = name || "";
state.userUserName = user_name || "";
state.userEmail = email || "";
for (const [k, v] of [[LS_NAME, state.userName], [LS_USERNAME, state.userUserName], [LS_EMAIL, state.userEmail]]) {
if (v) localStorage.setItem(k, v); else localStorage.removeItem(k);
}
}
// 顶栏显示名兜底链:name → user_name → email → uid8。hover(title)给完整身份。
export function userDisplayName() {
return state.userName || state.userUserName || state.userEmail || (state.userId || "").slice(0, 8);
}
export function userDisplayTitle() {
const parts = [];
if (state.userName) parts.push(`姓名 ${state.userName}`);
if (state.userUserName) parts.push(`账号 ${state.userUserName}`);
if (state.userEmail) parts.push(`邮箱 ${state.userEmail}`);
if (state.userId) parts.push(`ID ${state.userId}`);
return parts.join("\n");
}