/** * 金蝶云社区智能问答 — Token 管理 + SSE API 调用 * * 移植自 kdclub-ai-product-qa/scripts/cosmic_qa.py v2.0 * 纯 TypeScript 实现,零外部依赖,使用 Node.js 内置 fetch。 * * API: GET https://vip.kingdee.com/aisapi/ai-search * 认证: Authorization: Bearer * 响应: SSE 流式 */ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import { tryParseJson } from "@oh-my-pi/pi-utils"; // ─── Token 管理 ─────────────────────────────────────────── const TOKEN_DIR = join(homedir(), ".kdclub"); const TOKEN_FILE = join(TOKEN_DIR, "pat_token.json"); const LEGACY_TOKEN_FILES = [ join(TOKEN_DIR, "token_vip_kingdee_com.json"), join(homedir(), ".qoderwork", "skills", "kdclub-login", "data", "token_vip_kingdee_com.json"), ]; export const TOKEN_NOT_FOUND_MSG = "未找到有效的 PAT Token。请按以下步骤获取并提供 token:\n" + "1. 打开浏览器访问 https://vip.kingdee.com\n" + "2. 登录您的金蝶云社区账号\n" + "3. 点击右上角头像 → 个人主页 → 编辑资料\n" + "4. 找到「个人访问令牌」区域 → 新建令牌\n" + "5. 复制生成的 token(格式如 kdt_xxxxxxxx...)\n" + "6. 使用 kd_cosmic_qa({ saveToken: 'kdt_xxx', query: '测试连接', communityProduct: 'enterprise' }) 保存 token;Cosmic 项目先让用户选择金蝶AI苍穹/金蝶AI星瀚/金蝶AI套件"; interface TokenData { token: string; domain: string; last_updated: string; } export function saveToken(token: string): string { const trimmed = token.trim(); mkdirSync(TOKEN_DIR, { recursive: true }); const data: TokenData = { token: trimmed, domain: "vip.kingdee.com", last_updated: new Date().toISOString(), }; writeFileSync(TOKEN_FILE, `${JSON.stringify(data, null, 2)}\n`, "utf8"); return `Token 已保存到 ${TOKEN_FILE},后续会话无需重复提供。`; } export function loadToken(): { token: string } | { error: string } { // 1. 环境变量 const envToken = (process.env.KDCLOUD_PAT_TOKEN ?? "").trim(); if (envToken) return { token: envToken }; // 2. 本地文件 if (existsSync(TOKEN_FILE)) { try { const data = tryParseJson(readFileSync(TOKEN_FILE, "utf8")); const t = (data?.token ?? "").trim(); if (t) { process.env.KDCLOUD_PAT_TOKEN = t; return { token: t }; } } catch { /* fall through */ } } for (const file of LEGACY_TOKEN_FILES) { if (!existsSync(file)) continue; try { const data = tryParseJson(readFileSync(file, "utf8")); const t = (data?.token ?? "").trim(); if (t) { process.env.KDCLOUD_PAT_TOKEN = t; return { token: t }; } } catch { /* fall through */ } } return { error: TOKEN_NOT_FOUND_MSG }; } // ─── 引用来源标题补全 ────────────────────────────────────── export interface SearchSource { entityId: string; entityType: string; title: string; url: string; } export interface CosmicQaResult { answer: string; answerFormat: "html" | "markdown"; thinkContent: string; sources: SearchSource[]; sessionId: string; } // ─── 图片 URL 修复 ──────────────────────────────────────── function fixImageUrls(content: string): string { const base = "https://vip.kingdee.com"; // 1. 补全相对路径(排除 // 开头的协议相对路径) content = content.replace(/src="(\/(?!\/)[^"]+)"/g, (_, p1) => `src="${base}${p1}"`); content = content.replace(/src='(\/(?!\/)[^']+)'/g, (_, p1) => `src='${base}${p1}'`); content = content.replace(/data-src="(\/(?!\/)[^"]+)"/g, (_, p1) => `data-src="${base}${p1}"`); content = content.replace(/data-src='(\/(?!\/)[^']+)'/g, (_, p1) => `data-src='${base}${p1}'`); // 2. 懒加载 data-src → src 提升 content = content.replace( /]*?)data-src=["']([^"']+)["']([^>]*?)\/?>/gi, (_match, before, dataSrc, after) => { const srcMatch = (before + after).match(/src=["']([^"']*)["']/); if (srcMatch) { const existingSrc = srcMatch[1].trim(); if (!existingSrc || existingSrc.startsWith("data:") || /placeholder/i.test(existingSrc)) { return ``; } return _match; } return ``; }, ); // 3. Markdown 图片相对路径补全 content = content.replace(/!\[([^\]]*)\]\((\/(?!\/)[^)]+)\)/g, (_, alt, p2) => `![${alt}](${base}${p2})`); return content; } // ─── 引用来源标题补全 ────────────────────────────────────── function needsTitleFetch(title: string): boolean { if (!title) return true; const t = title.trim(); if (/^\d+$/.test(t)) return true; const generic = [ "点击查看完整文档", "查看完整文档", "点击查看", "查看详情", "点击查看详情", "详情", "文档详情", "知识详情", "undefined", "null", ]; return generic.map(g => g.toLowerCase()).includes(t.toLowerCase()); } async function fetchPageTitle(url: string, token: string, timeoutMs = 5000): Promise { try { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); const resp = await fetch(url, { headers: { Authorization: `Bearer ${token}`, "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0", Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", }, signal: controller.signal, }); clearTimeout(timer); const html = await resp.text(); const m = html.match(/]*>(.*?)<\/title>/is); if (m) { let title = m[1] .trim() .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") .replace(/'/g, "'") .replace(/"/g, '"') .replace(/ /g, " "); for (const suffix of [" - 金蝶云社区", " - 金蝶社区", " | 金蝶云社区"]) { if (title.endsWith(suffix)) { title = title.slice(0, -suffix.length).trim(); break; } } const generic = ["金蝶云社区", "金蝶社区", "金蝶云社区官网", "点击查看完整文档", "查看完整文档"]; if (generic.includes(title)) return ""; return title; } } catch { /* ignore */ } return ""; } async function enrichSearchSources(sources: SearchSource[], token: string): Promise { if (!sources.length) return sources; const indices = sources.map((s, i) => (needsTitleFetch(s.title) ? i : -1)).filter(i => i >= 0); if (!indices.length) return sources; const tasks = indices.map(async idx => { const url = sources[idx].url; if (!url) return; const title = await fetchPageTitle(url, token); if (title) sources[idx].title = title; }); await Promise.all(tasks); return sources; } // ─── 核心:流式调用金蝶云社区 API ────────────────────────── export async function queryCosmicQa( question: string, productId: number, options?: { sessionId?: string; useDeepThink?: boolean; productLineId?: string; }, ): Promise { const tokenResult = loadToken(); if ("error" in tokenResult) { throw new Error(tokenResult.error); } const token = tokenResult.token; const params = new URLSearchParams({ scene: "1", searchText: question, productId: String(productId), useDeepThink: options?.useDeepThink ? "true" : "false", useClarification: "false", productLineId: options?.productLineId || "35", channel_level: "Agent Skill", }); if (options?.sessionId) { params.set("sessionId", options.sessionId); } const url = `https://vip.kingdee.com/aisapi/ai-search?${params.toString()}`; // 重试逻辑(401/403 时最多 3 次) let lastError: Error | undefined; for (let attempt = 0; attempt < 3; attempt++) { try { const result = await doStreamRequest(url, token); return result; } catch (e) { const msg = e instanceof Error ? e.message : String(e); if (msg.includes("401") || msg.includes("403") || msg.includes("未授权")) { // 重新加载 token(可能中途刷新了) const reloaded = loadToken(); if ("token" in reloaded) { lastError = e instanceof Error ? e : new Error(msg); continue; } } lastError = e instanceof Error ? e : new Error(msg); break; } } throw lastError ?? new Error("请求金蝶云社区 API 失败"); } async function doStreamRequest(url: string, token: string): Promise { const resp = await fetch(url, { headers: { Authorization: `Bearer ${token}`, Accept: "text/event-stream", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0", }, }); if (resp.status === 401 || resp.status === 403) { throw new Error(`HTTP ${resp.status}: 未授权操作,PAT Token 可能已过期或无效。`); } if (!resp.ok) { throw new Error(`HTTP ${resp.status}: ${resp.statusText}`); } const body = resp.body; if (!body) { throw new Error("响应体为空"); } // 解析 SSE 流 let fullAnswer = ""; let thinkContent = ""; let sessionId = ""; let searchSources: SearchSource[] = []; const reader = body.getReader(); const decoder = new TextDecoder(); let buffer = ""; try { while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n"); buffer = lines.pop() ?? ""; // 保留最后一行(可能不完整) for (const line of lines) { const trimmed = line.trim(); if (!trimmed.startsWith("data:")) continue; const jsonStr = trimmed.slice(5).trim(); let data: Record; try { data = JSON.parse(jsonStr) as Record; } catch { continue; } const msg = String(data.message ?? ""); const isThink = Boolean(data.isThink); if (msg === "未授权操作") { throw new Error("未授权操作"); } if (isThink && msg) { thinkContent += msg; } else if (msg) { fullAnswer += fixImageUrls(msg); } const sid = data.aiSearchSessionId; if (sid) sessionId = String(sid); const src = data.searchSources; if (Array.isArray(src) && src.length > 0) { searchSources = src as SearchSource[]; } if (data.answerEnd) { // 对完整内容再做一次图片修复 fullAnswer = fixImageUrls(fullAnswer); const answerFormat = fullAnswer.trim().startsWith("<") ? "html" : "markdown"; searchSources = await enrichSearchSources(searchSources, token); return { answer: fullAnswer, answerFormat, thinkContent, sources: searchSources, sessionId }; } } } } finally { reader.releaseLock(); } // 流结束但未收到 answerEnd fullAnswer = fixImageUrls(fullAnswer); const answerFormat = fullAnswer.trim().startsWith("<") ? "html" : "markdown"; searchSources = await enrichSearchSources(searchSources, token); return { answer: fullAnswer, answerFormat, thinkContent, sources: searchSources, sessionId }; } // ─── 输出格式化 ──────────────────────────────────────────── export function formatCosmicQaResult(result: CosmicQaResult, question: string): string { const parts: string[] = []; parts.push(`## 金蝶云社区智能问答`); parts.push(`**问题**: ${question}`); parts.push(""); if (result.thinkContent) { parts.push("
"); parts.push("思考过程(点击展开)"); parts.push(""); parts.push(result.thinkContent); parts.push(""); parts.push("
"); parts.push(""); } parts.push("### 回答"); parts.push(result.answer); if (result.sources.length > 0) { parts.push(""); parts.push("### 参考来源"); for (const src of result.sources) { parts.push(`- [${src.title || "查看详情"}](${src.url})`); } } return parts.join("\n"); }