// 让本机真酒馆发一次真实请求,落到 capture-server 上。 // 用法: node drive-st.mjs --preset "C:/Users/zed/Downloads/linux_1.3.json" --message 你好 --capture-port 8801 --st-port 8123 // 用独立 dataRoot,不改 D:\game\sillytavern\data 里的东西。 // 预设走页面上的「导入预设」文件输入(纯导入 json),和本仓库那边保持同一条链路。 import { spawn } from "node:child_process"; import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { captureConsole, evaluate, launchBrowser, openPage, sleep } from "./cdp.mjs"; import { listCaptures, makeLogger, parseArgs, waitForNewCapture, waitHttp, waitStReady, writeCharacter } from "./lib.mjs"; const HERE = path.dirname(fileURLToPath(import.meta.url)); const log = makeLogger("drive-st"); const DEFAULTS = { preset: "", message: "你好", capturePort: 8801, stPort: 8123, stRoot: "D:/game/sillytavern", dataRoot: path.join(HERE, ".st-data"), out: path.join(HERE, "captures"), card: "tokens", browser: "", headful: false, keep: false, timeoutMs: 180000, }; const CLOSE_POPUPS = `(() => { document.querySelectorAll(".popup-button-cancel, .popup-button-ok").forEach((b) => b.click()); return true; })()`; async function main() { const args = parseArgs(process.argv.slice(2), DEFAULTS); if (!args.preset || !existsSync(args.preset)) throw new Error("预设文件不存在: " + args.preset); if (!existsSync(args.stRoot)) throw new Error("酒馆目录不存在: " + args.stRoot); mkdirSync(args.out, { recursive: true }); const presetName = path.basename(args.preset, ".json"); const presetText = readFileSync(args.preset, "utf8"); log("预设: " + args.preset + " (名字按酒馆规则取文件名: " + presetName + ")"); const cardName = "ProbeCard"; writeCharacter(args.dataRoot, cardName, args.card); log("角色卡就位: " + cardName + ".png (card=" + args.card + ")"); const staleFiles = [path.join(args.dataRoot, "default-user", "OpenAI Settings", presetName + ".json")]; for (const stale of staleFiles) { try { rmSync(stale, { force: true }); } catch (e) {} } const known = new Set(listCaptures(args.out, "st")); const st = spawn(process.execPath, ["server.js", "--port", String(args.stPort), "--dataRoot", args.dataRoot, "--browserLaunchEnabled=false"], { cwd: args.stRoot, stdio: ["ignore", "pipe", "pipe"] }); const stLog = []; st.stdout.on("data", (d) => stLog.push(String(d))); st.stderr.on("data", (d) => stLog.push(String(d))); let browser; let page; try { await waitHttp("http://127.0.0.1:" + args.stPort + "/", args.timeoutMs, "酒馆服务起来"); log("酒馆已启动"); browser = await launchBrowser({ exe: args.browser || undefined, port: args.stPort + 100, headless: !args.headful }); page = await openPage(browser, "http://127.0.0.1:" + args.stPort + "/"); captureConsole(page); log("浏览器已打开页面"); await waitStReady(page, args.timeoutMs, log); log("前端就绪"); const importExpr = `(async () => { const input = document.getElementById("openai_preset_import_file"); const file = new File([${JSON.stringify(presetText)}], ${JSON.stringify(presetName + ".json")}, { type: "application/json" }); const dt = new DataTransfer(); dt.items.add(file); input.files = dt.files; window.jQuery(input).trigger("input"); return true; })()`; await evaluate(page, importExpr); log("已把预设丢进酒馆的导入框,等它落盘..."); let importState = null; for (let i = 0; i < 120; i++) { importState = await evaluate(page, `(() => { const ctx = window.SillyTavern.getContext(); const names = ctx.getPresetManager("openai").getPresetList().preset_names || {}; const popup = document.querySelector(".popup-button-cancel"); if (popup) popup.click(); return { has: Object.keys(names).includes(${JSON.stringify(presetName)}), keys: Object.keys(names), popup: Boolean(document.querySelector(".popup")) }; })()`); if (importState.has) break; await sleep(500); } if (!importState || !importState.has) throw new Error("预设导入后没出现在列表里: " + JSON.stringify(importState)); log("预设已导入: " + presetName); const presetState = await evaluate(page, `(async () => { const ctx = window.SillyTavern.getContext(); const names = ctx.getPresetManager("openai").getPresetList().preset_names || {}; const idx = names[${JSON.stringify(presetName)}]; if (idx === undefined) return { error: "preset-not-found", keys: Object.keys(names) }; ctx.chatCompletionSettings.bind_preset_to_connection = false; const sel = window.jQuery("#settings_preset_openai"); sel.val(String(idx)); sel.trigger("change"); const s = ctx.chatCompletionSettings; return { preset: s.preset_settings_openai, temp: s.temp_openai, topP: s.top_p_openai, maxTokens: s.openai_max_tokens, stream: s.stream_openai, squash: s.squash_system_messages, useSysprompt: s.use_sysprompt, maxContext: s.openai_max_context, functionCalling: s.function_calling, unlocked: s.max_context_unlocked, }; })()`); if (presetState && presetState.error) throw new Error("预设没被酒馆识别: " + JSON.stringify(presetState)); const presetJson = JSON.parse(presetText); const expect = { temp: presetJson.temperature, topP: presetJson.top_p, maxTokens: presetJson.openai_max_tokens }; log("预设已选中: " + JSON.stringify(presetState) + " , 预设文件里: " + JSON.stringify(expect)); if (expect.temp !== undefined && Math.abs(Number(expect.temp) - Number(presetState.temp)) > 1e-6) throw new Error("导入后参数没生效: temp 期望 " + expect.temp + " 实得 " + presetState.temp); const connUrl = "http://127.0.0.1:" + args.capturePort + "/v1"; const connState = await evaluate(page, `(async () => { const ctx = window.SillyTavern.getContext(); const jq = window.jQuery; jq("#main_api").val("openai").trigger("change"); jq("#chat_completion_source").val("custom").trigger("change"); const s = ctx.chatCompletionSettings; s.chat_completion_source = "custom"; s.reverse_proxy = ""; s.proxy_password = ""; s.custom_url = ${JSON.stringify(connUrl)}; s.custom_model = "mock-model"; jq("#custom_url").val(s.custom_url); jq("#custom_model").val(s.custom_model); s.openai_max_context = 2000000; s.max_context_unlocked = true; jq("#openai_max_context").val(2000000).trigger("input"); jq("#oai_max_context_unlocked").prop("checked", true).trigger("input"); s.function_calling = false; jq("#openai_function_calling").prop("checked", false).trigger("input"); return { source: s.chat_completion_source, url: s.custom_url, model: s.custom_model, mainApi: window.SillyTavern.getContext().mainApi, maxContext: s.openai_max_context, functionCalling: s.function_calling }; })()`); log("连接已指向 mock: " + connState.url + " (source=" + connState.source + ")"); const charState = await evaluate(page, `(async () => { const ctx = window.SillyTavern.getContext(); await ctx.getCharacters(); const list = ctx.characters || []; const idx = list.findIndex((c) => c.name === ${JSON.stringify(cardName)}); const other = list.findIndex((c) => c.name !== ${JSON.stringify(cardName)}); if (idx < 0) return { error: "character-not-found", names: list.map((c) => c.name) }; if (other >= 0) await ctx.selectCharacterById(other); for (let t = 0; t < 40 && window.SillyTavern.getContext().name2 !== ${JSON.stringify(cardName)}; t++) { if (t === 0 && other >= 0) await ctx.selectCharacterById(other); await ctx.selectCharacterById(idx); await new Promise((r) => setTimeout(r, 500)); } await ctx.clearChat({ clearData: true }); const fresh = window.SillyTavern.getContext(); return { ok: true, chatLen: fresh.chat.length, charName: fresh.name2, chatId: fresh.chatId, chid: fresh.characterId }; })()`); log("角色状态: " + JSON.stringify(charState)); if (!charState || charState.charName !== cardName) throw new Error("角色没选中: " + JSON.stringify(charState)); await evaluate(page, CLOSE_POPUPS); await evaluate(page, `(() => { const jq = window.jQuery; const ta = jq("#send_textarea"); ta.val(${JSON.stringify(args.message)}).trigger("input"); window.__genErr = null; window.SillyTavern.getContext().generate("normal").catch((e) => { window.__genErr = String((e && e.stack) || e); }); return true; })()`); log("已点发送, 等 mock 落盘..."); for (let i = 0; i < 20; i++) { const sendState = await evaluate(page, `(() => { const c = window.SillyTavern.getContext(); const ta = document.getElementById("send_textarea"); return { chatLen: c.chat.length, err: window.__genErr, api: c.mainApi, txt: String(ta.value).slice(0, 20), ro: ta.readOnly, extCount: c.extensionSettings ? Object.keys(c.extensionSettings).length : -1 }; })()`); log("发送状态: " + JSON.stringify(sendState)); if (sendState.chatLen || sendState.err) break; await sleep(500); } log("页面日志尾: " + JSON.stringify(page.logs.slice(-12))); const captureFile = await waitForNewCapture(args.out, "st", known, args.timeoutMs); log("抓到请求: " + captureFile); await sleep(1500); console.log(JSON.stringify({ side: "st", preset: presetName, capture: path.join(args.out, captureFile) })); } catch (err) { console.error("[drive-st] 失败: " + err.message); if (stLog.length) console.error(stLog.join("").slice(-2500)); if (page) { const dump = await evaluate(page, `(() => ({ url: location.href, ready: document.readyState, toast: Array.from(document.querySelectorAll(".toast-message, .toast")).map((e) => e.textContent).slice(0, 6), splash: Array.from(document.querySelectorAll(".splash-message")).map((e) => e.textContent), popups: Array.from(document.querySelectorAll(".popup")).map((e) => e.innerText.slice(0, 300)), chatLen: window.SillyTavern ? window.SillyTavern.getContext().chat.length : -1, chid: window.SillyTavern ? window.SillyTavern.getContext().characterId : -1, name2: window.SillyTavern ? window.SillyTavern.getContext().name2 : "", sendDisabled: document.querySelector("#send_but") ? document.querySelector("#send_but").className : "", }))()`).catch((e) => ({ error: e.message })); console.error("页面状态: " + JSON.stringify(dump, null, 2)); if (page.logs) console.error("页面日志: " + JSON.stringify(page.logs.slice(-15), null, 2)); } process.exitCode = 1; } finally { if (!args.keep) { try { if (browser) browser.child.kill(); } catch (e) {} try { st.kill(); } catch (e) {} await sleep(500); try { st.kill("SIGKILL"); } catch (e) {} } } } await main();