// API client — reads credentials from localStorage, injects headers on every request. // All functions return parsed JSON or throw an Error with .message set. const API_BASE = ""; // same origin (served by FastAPI) const LS = { token: "jwt_token", rpaHost: "rpa_host", rpaPort: "rpa_port", rpaGitUrl: "rpa_git_url", rpaPrompt: "rpa_prompt", // 自定义分析提示词(空 = 使用服务端默认) feishuFields:"feishu_fields_json", // Hermes 推导的字段定义(JSON 字符串,仅供展示) hermesKey: "hermes_api_key", hermesUrl: "hermes_api_url", feishuId: "feishu_app_id", feishuSecret:"feishu_app_secret", feishuBase: "feishu_base_token", feishuMeta: "feishu_metadata_table_id", feishuAna: "feishu_analysis_table_id", feishuFolder:"feishu_doc_folder", userId: "user_id", userEmail: "user_email", commentLimit: "comment_limit", // 评论抓取条数(字符串,默认 "100") commentAll: "comment_fetch_all", // "1" = 全量,空 = 按 commentLimit }; window.LS = LS; // ── 前端日志环形缓冲(供反馈功能采集)────────────────────────────────────── // 固定长度,超出丢弃最早的。绝不采集 localStorage 里的 Key。 const _LOG_CAP = 200; const _logBuffer = []; // 脱敏:擦掉疑似密钥/令牌的内容,避免反馈时泄露 const _SECRET_RE = [ /(?:authorization|bearer)\s*[:=]?\s*\S+/gi, /(?:api[_-]?key|secret|token|password|app[_-]?secret)\s*[:=]\s*\S+/gi, /\bAIza[0-9A-Za-z\-_]{20,}\b/g, // Google API key /\beyJ[0-9A-Za-z\-_]+\.[0-9A-Za-z\-_]+\.[0-9A-Za-z\-_]+/g, // JWT ]; function _scrub(s) { let t = String(s); for (const re of _SECRET_RE) t = t.replace(re, "[REDACTED]"); return t; } function _pushLog(level, msg) { _logBuffer.push({ ts: new Date().toISOString(), level, msg: _scrub(msg).slice(0, 1000) }); if (_logBuffer.length > _LOG_CAP) _logBuffer.shift(); } // monkey-patch console,保留原行为同时写入缓冲 (function patchConsole() { ["log", "info", "warn", "error"].forEach(level => { const orig = console[level] ? console[level].bind(console) : function(){}; console[level] = function (...args) { try { const msg = args.map(a => { if (typeof a === "string") return a; try { return JSON.stringify(a); } catch { return String(a); } }).join(" "); _pushLog(level, msg); } catch {} orig(...args); }; }); // 捕获未处理异常 window.addEventListener("error", e => _pushLog("error", `uncaught: ${e.message} @ ${e.filename}:${e.lineno}`)); window.addEventListener("unhandledrejection", e => _pushLog("error", `unhandledrejection: ${e.reason}`)); })(); window.__getLogBuffer = () => _logBuffer.slice(); function _get(key) { return localStorage.getItem(key) || ""; } function _authHeaders() { const token = _get(LS.token); if (!token) throw new Error("未登录,请先登录"); return { "Authorization": `Bearer ${token}` }; } function _keyHeaders() { const feishuId = _get(LS.feishuId); const feishuSecret = _get(LS.feishuSecret); const feishuBase = _get(LS.feishuBase); const feishuMeta = _get(LS.feishuMeta); const feishuAna = _get(LS.feishuAna); const rpaHost = _get(LS.rpaHost); const rpaPort = _get(LS.rpaPort); const rpaGitUrl = _get(LS.rpaGitUrl); const missingFeishu = !feishuId || !feishuSecret || !feishuBase || !feishuMeta || !feishuAna; const missingRpa = !rpaHost || !rpaPort || !rpaGitUrl; if (missingFeishu) throw new Error("请先在设置页填写飞书配置(App ID / Secret / Base Token / 两张表 ID)"); if (missingRpa) throw new Error("请先在设置页填写 RPA 服务地址、端口和 Git 仓库地址"); const rpaUrl = `http://${rpaHost}:${rpaPort}`; const h = { "X-Feishu-App-Id": feishuId, "X-Feishu-App-Secret": feishuSecret, "X-Feishu-Base-Token": feishuBase, "X-Feishu-Metadata-Table-Id": feishuMeta, "X-Feishu-Analysis-Table-Id": feishuAna, "X-Rpa-Url": rpaUrl, "X-Rpa-Git-Url": rpaGitUrl, }; const folder = _get(LS.feishuFolder); if (folder) h["X-Feishu-Doc-Folder"] = folder; // 自定义提示词:有内容才传,空时服务端使用默认 prompt 文件 const rpaPrompt = _get(LS.rpaPrompt); if (rpaPrompt) h["X-Rpa-Prompt"] = rpaPrompt; const hermesUrl = _get(LS.hermesUrl); const hermesKey = _get(LS.hermesKey); if (hermesUrl) h["X-Hermes-Url"] = hermesUrl; if (hermesKey) h["X-Hermes-Key"] = hermesKey; // 评论抓取配置(始终传,后端有默认值兜底) h["X-Comment-Limit"] = _get(LS.commentLimit) || "100"; if (_get(LS.commentAll) === "1") h["X-Comment-Fetch-All"] = "1"; return h; } function _hermesHeaders() { /** 仅需 Hermes 凭证(用于提示词优化/字段推导,不需要飞书/RPA 配置)。 */ const hermesUrl = _get(LS.hermesUrl); const hermesKey = _get(LS.hermesKey); if (!hermesUrl || !hermesKey) throw new Error("请先在设置页填写 Hermes API URL 和 Key"); return { "X-Hermes-Url": hermesUrl, "X-Hermes-Key": hermesKey }; } async function _fetch(path, opts = {}) { const _t0 = performance.now(); const _method = (opts.method || "GET").toUpperCase(); let resp; try { resp = await fetch(API_BASE + path, { ...opts, headers: { "Content-Type": "application/json", ...(opts.headers || {}) }, }); } catch (netErr) { _pushLog("net", `${_method} ${path} → 网络错误: ${netErr.message}`); throw netErr; } _pushLog("net", `${_method} ${path} → ${resp.status} (${Math.round(performance.now() - _t0)}ms)`); const text = await resp.text(); let data; try { data = JSON.parse(text); } catch { data = { detail: text }; } if (!resp.ok) { // JWT 过期 / 无效 → 清除 token,1.5s 后刷新到登录页 // 注意:只有携带了 Authorization 头的请求(即已登录状态)返回 401 时才视为 token 过期。 // 登录/注册请求本身不带 Authorization,若密码错误后端也返回 401, // 不应触发此逻辑,应让下面的正常错误处理显示真实原因。 if (resp.status === 401 && opts.headers && ("Authorization" in opts.headers)) { localStorage.removeItem(LS.token); setTimeout(() => window.location.reload(), 1500); throw new Error("登录已过期,请重新登录(1.5 秒后自动跳转到登录页)"); } let detail = data.detail; if (Array.isArray(detail)) { detail = detail.map(e => e.msg || JSON.stringify(e)).join("; "); } else if (detail && typeof detail !== "string") { detail = JSON.stringify(detail); } throw new Error(detail || `HTTP ${resp.status}`); } return data; } // ── Auth ───────────────────────────────────────────────────────────────────── window.API = { async register(email, password) { const data = await _fetch("/auth/register", { method: "POST", body: JSON.stringify({ email, password }), }); localStorage.setItem(LS.token, data.token); localStorage.setItem(LS.userId, data.user_id); localStorage.setItem(LS.userEmail, email); return data; }, async login(email, password) { const data = await _fetch("/auth/login", { method: "POST", body: JSON.stringify({ email, password }), }); localStorage.setItem(LS.token, data.token); localStorage.setItem(LS.userId, data.user_id); localStorage.setItem(LS.userEmail, email); return data; }, logout() { [LS.token, LS.userId, LS.userEmail].forEach(k => localStorage.removeItem(k)); }, isLoggedIn() { return !!localStorage.getItem(LS.token); }, currentEmail() { return localStorage.getItem(LS.userEmail) || ""; }, // ── Jobs ──────────────────────────────────────────────────────────────────── async submitCollect(url) { return _fetch("/api/jobs/collect", { method: "POST", headers: { ..._authHeaders(), ..._keyHeaders() }, body: JSON.stringify({ url }), }); }, /** * 关键词搜索预览:只需 JWT,返回视频列表,不创建 Job。 * platform: "youtube" | "bilibili" */ async searchPreview(keyword, platform = "youtube", maxResults = 20) { return _fetch("/api/collect/preview", { method: "POST", headers: _authHeaders(), body: JSON.stringify({ keyword, platform, max_results: maxResults }), }); }, /** * 批量采集:为 urls 数组中每个 URL 创建独立 Job。 * 需要 JWT + API Keys(飞书 + RPA)。 */ async batchCollect(urls) { return _fetch("/api/collect/batch", { method: "POST", headers: { ..._authHeaders(), ..._keyHeaders() }, body: JSON.stringify({ urls }), }); }, async getJob(jobId) { return _fetch(`/api/jobs/${jobId}`, { headers: _authHeaders(), }); }, async listJobs() { return _fetch("/api/jobs", { headers: _authHeaders(), }); }, // ── Videos ────────────────────────────────────────────────────────────────── async listVideos(filters = {}) { const params = new URLSearchParams(); Object.entries(filters).forEach(([k, v]) => { if (v !== undefined && v !== null && v !== "") params.set(k, v); }); const qs = params.toString(); return _fetch(`/api/videos${qs ? "?" + qs : ""}`, { headers: _authHeaders(), }); }, async getVideo(videoId) { return _fetch(`/api/videos/${videoId}`, { headers: _authHeaders(), }); }, // ── Search ────────────────────────────────────────────────────────────────── async search(query, topK = 20) { return _fetch("/api/search", { method: "POST", headers: { ..._authHeaders(), ..._keyHeaders() }, body: JSON.stringify({ query, top_k: topK }), }); }, // ── Hermes ────────────────────────────────────────────────────────────────── async checkHermesWiki() { // 只需 JWT + Hermes URL/Key,不需要飞书/RPA 配置 return _fetch("/api/hermes/check-wiki", { method: "POST", headers: { ..._authHeaders(), ..._hermesHeaders() }, }); }, async initHermesWiki(agentsMd) { // 只需 JWT + Hermes URL/Key return _fetch("/api/hermes/init-wiki", { method: "POST", headers: { ..._authHeaders(), ..._hermesHeaders() }, body: JSON.stringify({ agents_md: agentsMd }), }); }, // ── Test ──────────────────────────────────────────────────────────────────── /** * Verify Feishu credentials and permissions step by step. * Reads keys from localStorage (save first if you changed them on-screen). * Returns { auth, base_read, base_write, doc_folder } — each { ok, msg }. */ async testFeishu() { const feishuId = _get(LS.feishuId); const feishuSecret = _get(LS.feishuSecret); const feishuBase = _get(LS.feishuBase); const feishuMeta = _get(LS.feishuMeta); const feishuAna = _get(LS.feishuAna); if (!feishuId || !feishuSecret || !feishuBase || !feishuMeta || !feishuAna) throw new Error("请先填写完整的飞书配置(App ID / Secret / Base Token / 两张表 ID)"); const headers = { ..._authHeaders(), "X-Feishu-App-Id": feishuId, "X-Feishu-App-Secret": feishuSecret, "X-Feishu-Base-Token": feishuBase, "X-Feishu-Metadata-Table-Id": feishuMeta, "X-Feishu-Analysis-Table-Id": feishuAna, }; const folder = _get(LS.feishuFolder); if (folder) headers["X-Feishu-Doc-Folder"] = folder; return _fetch("/api/test/feishu", { method: "POST", headers }); }, // ── Prompt ────────────────────────────────────────────────────────────────── /** 获取服务端默认提示词(首次加载时填充 textarea)。 */ async getDefaultPrompt() { return _fetch("/api/prompt/default", { headers: _authHeaders() }); }, /** * 使用 Hermes 优化提示词。 * 不需要飞书/RPA 配置,只需 Hermes URL + Key。 */ async optimizePrompt(prompt) { return _fetch("/api/prompt/optimize", { method: "POST", headers: { ..._authHeaders(), ..._hermesHeaders() }, body: JSON.stringify({ prompt }), }); }, /** * 使用 Hermes 推导飞书字段定义。 * 返回 { fields: [{name, type, label, description}] } */ async genFeishuFields(prompt) { return _fetch("/api/prompt/gen-fields", { method: "POST", headers: { ..._authHeaders(), ..._hermesHeaders() }, body: JSON.stringify({ prompt }), }); }, // ── RPA ───────────────────────────────────────────────────────────────────── /** * 检测 RPA 服务是否可达。 * 后端会代理请求到 {rpa_url}/ping,避免跨域问题。 * 返回 { reachable: bool, status?, error? } */ async pingRpa() { const rpaHost = _get(LS.rpaHost); const rpaPort = _get(LS.rpaPort); if (!rpaHost || !rpaPort) throw new Error("未配置 RPA 服务地址"); const rpaUrl = `http://${rpaHost}:${rpaPort}`; return _fetch("/api/rpa/ping", { headers: { ..._authHeaders(), "X-Rpa-Url": rpaUrl, }, }); }, // ── Health ────────────────────────────────────────────────────────────────── async health() { return _fetch("/health"); }, // ── Feedback ────────────────────────────────────────────────────────────────── /** * 提交反馈 → 后端聚合前后端日志生成 GitHub Issue。 * 支持匿名(登录页也能反馈):已登录则带 JWT,否则后端记为 anonymous。 * 自动附带脱敏后的前端日志缓冲、当前页面、UA。 * 返回 { issue_number, issue_url, message } */ async submitFeedback(description) { const token = _get(LS.token); const headers = token ? { "Authorization": `Bearer ${token}` } : {}; return _fetch("/api/feedback", { method: "POST", headers, body: JSON.stringify({ description, page: token ? (localStorage.getItem("__page") || "") : "login", user_agent: navigator.userAgent, client_logs: (window.__getLogBuffer ? window.__getLogBuffer() : []), ts: new Date().toISOString(), }), }); }, };