diff --git a/.gitignore b/.gitignore index 12a09a8f..704be522 100644 --- a/.gitignore +++ b/.gitignore @@ -38,4 +38,10 @@ todo.md plans/ .pi/hf-sessions/ .pi/hf-sessions-backup/ +.pi/agent/ +.pi/sessions/ +.pi/extensions/webui/.webui.pid +.pi/extensions/webui/.webui.log +tmp/ +bun.lock collect.sh diff --git a/.pi/extensions/sproutclaw-setup.ts b/.pi/extensions/sproutclaw-setup.ts new file mode 100644 index 00000000..451d9c94 --- /dev/null +++ b/.pi/extensions/sproutclaw-setup.ts @@ -0,0 +1,63 @@ +/** + * sproutclaw / mengya 命令安装扩展 + * + * 启动后自动在 /usr/local/bin/ 下创建 mengya 和 sproutclaw 命令, + * 方便快速启动 sproutclaw(cd 到项目目录并执行 ./pi-test.sh)。 + * + * 命令 /install-commands 可随时手动重装。 + */ + +import { existsSync, writeFileSync, chmodSync, readFileSync } from "node:fs"; +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; + +const SPROUTCLAW_DIR = "/shumengya/project/agent/sproutclaw"; +const BIN_DIR = "/usr/local/bin"; +const COMMANDS = ["mengya", "sproutclaw"]; + +/** 创建或修复命令脚本 */ +function installCommand(name: string): boolean { + const path = `${BIN_DIR}/${name}`; + + const script = `#!/usr/bin/env bash +set -euo pipefail +cd ${SPROUTCLAW_DIR} +exec ./pi-test.sh "$@" +`; + if (existsSync(path)) { + try { + const current = readFileSync(path, "utf-8"); + if (current === script) return false; + } catch { + // Rewrite unreadable or invalid command files below. + } + } + writeFileSync(path, script, "utf-8"); + chmodSync(path, 0o755); + return true; +} + +export default function (pi: ExtensionAPI) { + // 启动时自动安装 + for (const name of COMMANDS) { + const created = installCommand(name); + if (created) { + console.log(`[sproutclaw-setup] Created /usr/local/bin/${name}`); + } + } + + // 命令:手动重装 + pi.registerCommand("install-commands", { + description: "Install mengya and sproutclaw commands to /usr/local/bin", + handler: async (_args, ctx) => { + let count = 0; + for (const name of COMMANDS) { + if (installCommand(name)) count++; + } + if (count > 0) { + ctx.ui.notify(`Installed ${count} command(s): mengya, sproutclaw`, "success"); + } else { + ctx.ui.notify("Both commands already exist", "info"); + } + }, + }); +} diff --git a/.pi/extensions/startup-chinese.ts b/.pi/extensions/startup-chinese.ts new file mode 100644 index 00000000..79c25637 --- /dev/null +++ b/.pi/extensions/startup-chinese.ts @@ -0,0 +1,83 @@ +/** + * 启动界面中文翻译扩展 + * + * 将 pi 的初始启动界面中的介绍和引导文字翻译为中文, + * 快捷键和命令提示保持原样。 + */ + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { keyHint, keyText, rawKeyHint, VERSION } from "@mariozechner/pi-coding-agent"; + +export default function (pi: ExtensionAPI) { + pi.on("session_start", async (_event, ctx) => { + if (!ctx.hasUI) return; + + ctx.ui.setHeader((_tui, theme) => { + const expandedInstructions = [ + keyHint("app.interrupt", "to interrupt"), + keyHint("app.clear", "to clear"), + rawKeyHint(`${keyText("app.clear")} twice`, "to exit"), + keyHint("app.exit", "to exit (empty)"), + keyHint("app.suspend", "to suspend"), + keyHint("tui.editor.deleteToLineEnd" as any, "to delete to end"), + keyHint("app.thinking.cycle", "to cycle thinking level"), + rawKeyHint( + `${keyText("app.model.cycleForward")}/${keyText("app.model.cycleBackward")}`, + "to cycle models", + ), + keyHint("app.model.select", "to select model"), + keyHint("app.tools.expand", "to expand tools"), + keyHint("app.thinking.toggle", "to expand thinking"), + keyHint("app.editor.external", "for external editor"), + rawKeyHint("/", "for commands"), + rawKeyHint("!", "to run bash"), + rawKeyHint("!!", "to run bash (no context)"), + keyHint("app.message.followUp", "to queue follow-up"), + keyHint("app.message.dequeue", "to edit all queued messages"), + keyHint("app.clipboard.pasteImage", "to paste image"), + rawKeyHint("drop files", "to attach"), + ].join("\n"); + const compactInstructions = [ + keyHint("app.interrupt", "interrupt"), + rawKeyHint(`${keyText("app.clear")}/${keyText("app.exit")}`, "clear/exit"), + rawKeyHint("/", "commands"), + rawKeyHint("!", "bash"), + keyHint("app.tools.expand", "more"), + ].join(theme.fg("muted", " · ")); + const compactOnboarding = theme.fg( + "dim", + `按 ${keyText("app.tools.expand")} 查看完整帮助和已加载资源。`, + ); + const onboarding = theme.fg( + "dim", + "Pi 可以解释自身功能并查阅文档。询问它如何使用或扩展 Pi。", + ); + + let expanded = false; + + return { + render(_width: number): string[] { + const logo = + theme.bold(theme.fg("accent", "pi")) + theme.fg("dim", ` v${VERSION}`); + + const lines: string[] = [logo]; + if (expanded) { + lines.push(expandedInstructions); + lines.push(""); + lines.push(onboarding); + } else { + lines.push(compactInstructions); + lines.push(compactOnboarding); + lines.push(""); + lines.push(onboarding); + } + return lines; + }, + invalidate() {}, + setExpanded(v: boolean) { + expanded = v; + }, + }; + }); + }); +} diff --git a/.pi/extensions/webui/index.ts b/.pi/extensions/webui/index.ts new file mode 100644 index 00000000..01c545e6 --- /dev/null +++ b/.pi/extensions/webui/index.ts @@ -0,0 +1,262 @@ +/** + * WebUI 扩展 —— 在浏览器中通过网页与 pi 对话 + * + * 安装: + * 将本文件放入 .pi/extensions/webui/index.ts + * + * 用法: + * /webui on → 启动网页服务(默认端口 19133) + * /webui off → 停止网页服务 + * /webui on 8080 → 指定端口启动 + */ + +import { spawn, type ChildProcess } from "node:child_process"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { existsSync, readFileSync, writeFileSync, unlinkSync } from "node:fs"; +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const PID_FILE = join(__dirname, ".webui.pid"); + +let serverProcess: ChildProcess | null = null; +let serverPort = 19133; + +function findTsx(): string { + // 从扩展所在目录向上找 repo 根目录 + let dir = __dirname; + for (let i = 0; i < 10; i++) { + const candidate = join(dir, "node_modules", ".bin", "tsx"); + if (existsSync(candidate)) return candidate; + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } + // fallback + return join(__dirname, "..", "..", "..", "node_modules", ".bin", "tsx"); +} + +function readPidFile(): number | null { + if (!existsSync(PID_FILE)) return null; + try { + const pid = parseInt(readFileSync(PID_FILE, "utf8").trim(), 10); + return Number.isFinite(pid) && pid > 0 ? pid : null; + } catch { + return null; + } +} + +function writePidFile(pid: number): void { + try { + writeFileSync(PID_FILE, String(pid), "utf8"); + } catch { + // 忽略:PID 文件只是辅助信息 + } +} + +function clearPidFile(): void { + try { + if (existsSync(PID_FILE)) unlinkSync(PID_FILE); + } catch { + // 忽略 + } +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function waitForStartup(port: number, child: ChildProcess): Promise { + return new Promise((resolve, reject) => { + let settled = false; + let timeout: NodeJS.Timeout | null = null; + + const finish = (err?: Error) => { + if (settled) return; + settled = true; + if (timeout) clearTimeout(timeout); + if (err) reject(err); + else resolve(); + }; + + const check = () => { + if (child.exitCode !== null || child.signalCode !== null) { + finish(new Error(`webui 子进程已退出,code=${child.exitCode ?? "null"}`)); + return; + } + + const http = require("node:http"); + const req = http.get( + { hostname: "127.0.0.1", port, path: "/api/sessions", timeout: 1500 }, + (res: any) => { + res.resume(); + if (res.statusCode === 200) finish(); + else setTimeout(check, 200); + }, + ); + req.on("error", () => setTimeout(check, 200)); + req.on("timeout", () => { + req.destroy(); + setTimeout(check, 200); + }); + }; + + timeout = setTimeout(() => finish(new Error("webui 启动超时")), 10000); + setTimeout(check, 300); + }); +} + +function probePort(port: number): Promise<"free" | "running" | "occupied"> { + return new Promise((resolve) => { + const http = require("node:http"); + const req = http.get( + { hostname: "127.0.0.1", port, path: "/api/sessions", timeout: 1200 }, + (res: any) => { + res.resume(); + resolve(res.statusCode === 200 ? "running" : "occupied"); + }, + ); + req.on("error", () => resolve("free")); + req.on("timeout", () => { + req.destroy(); + resolve("free"); + }); + }); +} + +async function startServer(port: number, ctx: any): Promise { + if (serverProcess) { + ctx.ui.notify(`网页服务已在端口 ${serverPort} 运行`, "error"); + return; + } + + const oldPid = readPidFile(); + if (oldPid && !isProcessAlive(oldPid)) { + clearPidFile(); + } + + const state = await probePort(port); + if (state === "running") { + serverPort = port; + ctx.ui.notify(`网页服务已在端口 ${port} 运行`, "info"); + return; + } + if (state === "occupied") { + ctx.ui.notify(`端口 ${port} 已被其他进程占用`, "error"); + return; + } + + const tsxBin = findTsx(); + const serverFile = join(__dirname, "server.ts"); + + if (!existsSync(serverFile)) { + ctx.ui.notify(`服务器文件未找到: ${serverFile}`, "error"); + return; + } + + serverPort = port; + serverProcess = spawn(tsxBin, [serverFile, "--port", String(port)], { + cwd: join(__dirname, "..", "..", ".."), // repo 根目录 + stdio: ["ignore", "inherit", "inherit"], + env: { ...process.env }, + }); + + serverProcess.on("exit", (code) => { + serverProcess = null; + clearPidFile(); + }); + + if (serverProcess.pid) { + writePidFile(serverProcess.pid); + } + + try { + await waitForStartup(port, serverProcess); + } catch (err: any) { + clearPidFile(); + serverProcess = null; + ctx.ui.notify(`网页服务启动失败: ${err.message}`, "error"); + return; + } + + ctx.ui.notify(`网页服务已启动 → http://smallmengya:${port}`, "info"); + + // 显示局域网地址 + const { networkInterfaces } = require("node:os"); + const nets = networkInterfaces(); + for (const name of Object.keys(nets)) { + for (const net of nets[name]) { + if (net.family === "IPv4" && !net.internal) { + ctx.ui.notify(` 局域网: http://${net.address}:${port}`, "info"); + } + } + } +} + +async function stopServer(ctx: any): Promise { + if (!serverProcess) { + const pid = readPidFile(); + if (!pid) { + const state = await probePort(serverPort || 19133); + if (state === "running") { + ctx.ui.notify( + `网页服务正在端口 ${serverPort || 19133} 运行,但当前没有 PID 记录,无法直接停止`, + "error", + ); + return; + } + + ctx.ui.notify("网页服务未运行", "info"); + return; + } + + try { + if (isProcessAlive(pid)) { + process.kill(pid, "SIGTERM"); + ctx.ui.notify(`网页服务已停止(PID ${pid})`, "info"); + } else { + ctx.ui.notify("网页服务未运行,已清理旧 PID", "info"); + } + clearPidFile(); + } catch { + clearPidFile(); + ctx.ui.notify("网页服务未运行", "info"); + } + return; + } + + serverProcess.kill("SIGTERM"); + serverProcess = null; + serverPort = 0; + clearPidFile(); + ctx.ui.notify("网页服务已停止", "info"); +} + +export default function (pi: ExtensionAPI) { + pi.registerCommand("webui", { + description: "通过 /webui on 启动、/webui off 停止 Web 聊天界面", + handler: async (args, ctx) => { + const trimmed = args.trim(); + const [command = "", value = ""] = trimmed.split(/\s+/, 2); + + if (command === "off" || command === "stop" || command === "0") { + await stopServer(ctx); + return; + } + + const portInput = command === "on" ? value : command; + const port = portInput ? parseInt(portInput, 10) : 19133; + if (isNaN(port) || port < 1 || port > 65535) { + ctx.ui.notify(`无效端口: ${trimmed || "(空)"},使用 19133`, "error"); + return; + } + + await startServer(port, ctx); + }, + }); +} diff --git a/.pi/extensions/webui/public/app.js b/.pi/extensions/webui/public/app.js new file mode 100644 index 00000000..c86e6335 --- /dev/null +++ b/.pi/extensions/webui/public/app.js @@ -0,0 +1,1115 @@ +"use strict"; + +// ─── DOM 引用 ───────────────────────────────────────────────────────────── + +const chat = document.getElementById("chat"); +const input = document.getElementById("input"); +const sendBtn = document.getElementById("sendBtn"); +const abortBtn = document.getElementById("abortBtn"); +const statusDot = document.getElementById("statusDot"); +const statusText = document.getElementById("statusText"); +const statusModel = document.getElementById("statusModel"); +const headerTitle = document.getElementById("headerTitle"); +const headerMeta = document.getElementById("headerMeta"); +const sidebar = document.getElementById("sidebar"); +const overlay = document.getElementById("sidebarOverlay"); +const sessionList = document.getElementById("sessionList"); +const sessionContextBar = document.getElementById("sessionContextBar"); +const sessionContextPrimary = document.getElementById("sessionContextPrimary"); +const sessionContextSecondary = document.getElementById("sessionContextSecondary"); +const modelSelect = document.getElementById("modelSelect"); +const thinkingSelect = document.getElementById("thinkingSelect"); + +// ─── 状态 ───────────────────────────────────────────────────────────────── + +let currentAssistantEl = null; +let assistantBuffer = ""; +let isStreaming = false; +let eventSource = null; +let sessions = []; +let activeSessionPath = null; // 当前加载的会话文件路径 +let backendSessionPath = null; +let sessionActivationPromise = null; +let newSessionPromise = null; +const isTouchLike = window.matchMedia("(max-width: 768px)").matches || navigator.maxTouchPoints > 0; +const sessionCache = new Map(); +let availableModels = []; +let currentModelKey = ""; +let isApplyingModelSettings = false; +const thinkingLevels = ["off", "minimal", "low", "medium", "high", "xhigh"]; + +let sessionDashboardRaf = null; + +/** 与 interactive footer 相同的 token 缩写 */ +function formatFooterTokens(count) { + const n = Number(count) || 0; + if (n <= 0) return "0"; + if (n < 1000) return String(Math.round(n)); + if (n < 10000) return `${(n / 1000).toFixed(1)}k`; + if (n < 1_000_000) return `${Math.round(n / 1000)}k`; + if (n < 10_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + return `${Math.round(n / 1_000_000)}M`; +} + +function renderSessionContextBar(data) { + if (!sessionContextBar || !sessionContextPrimary || !sessionContextSecondary) return; + if (!data || data.error || !data.stats || !data.model) { + sessionContextBar.hidden = true; + return; + } + + sessionContextBar.hidden = false; + + const tok = data.stats.tokens || {}; + const parts = []; + if (tok.input) parts.push(`↑${formatFooterTokens(tok.input)}`); + if (tok.output) parts.push(`↓${formatFooterTokens(tok.output)}`); + if (tok.cacheRead) parts.push(`R${formatFooterTokens(tok.cacheRead)}`); + if (tok.cacheWrite) parts.push(`W${formatFooterTokens(tok.cacheWrite)}`); + + const costNum = Number(data.stats.cost ?? 0); + parts.push(`$${costNum.toFixed(3)}`); + + const cx = data.stats.contextUsage; + const cw = cx?.contextWindow ?? data.model.contextWindow ?? 0; + const pctRaw = cx?.percent; + const pctStr = pctRaw != null ? Number(pctRaw).toFixed(1) : "?"; + const pctNum = pctRaw != null ? Number(pctRaw) : null; + const autoInd = data.autoCompactionEnabled ? " (auto)" : ""; + const ctxTxt = + pctStr === "?" && cw + ? `?/${formatFooterTokens(cw)}${autoInd}` + : `${pctStr}%/${formatFooterTokens(cw)}${autoInd}`; + + sessionContextPrimary.textContent = ""; + const row = document.createElement("div"); + row.className = "session-context-row"; + + const left = document.createElement("div"); + left.className = "session-context-left"; + left.appendChild(document.createTextNode(`${parts.join(" ")} `)); + const ctxSpan = document.createElement("span"); + ctxSpan.textContent = ctxTxt; + if (pctNum != null) { + if (pctNum > 90) ctxSpan.classList.add("ctx-danger"); + else if (pctNum > 70) ctxSpan.classList.add("ctx-warn"); + } + left.appendChild(ctxSpan); + + row.appendChild(left); + sessionContextPrimary.appendChild(row); + + const liveStreaming = isStreaming || data.isStreaming; + let sub = ""; + if (data.isCompacting) sub = "⚡ 正在整理上下文…"; + else if (liveStreaming) sub = "⋯ 回复中…"; + else if (data.turnIndex > 0) sub = `✓ Turn ${data.turnIndex} complete`; + sessionContextSecondary.textContent = sub; +} + +function modelKey(model) { + return model ? `${model.provider}/${model.id}` : ""; +} + +function modelLabel(model) { + if (!model) return ""; + return `${model.provider}/${model.id}`; +} + +function getAvailableThinkingLevels(model) { + if (!model?.reasoning) return ["off"]; + return thinkingLevels.filter((level) => { + const mapped = model.thinkingLevelMap?.[level]; + if (mapped === null) return false; + if (level === "xhigh") return mapped !== undefined; + return true; + }); +} + +function syncThinkingOptions(model, currentLevel) { + if (!thinkingSelect) return; + + const levels = getAvailableThinkingLevels(model); + const nextOptionsKey = levels.join("|"); + if (thinkingSelect.dataset.optionsKey !== nextOptionsKey) { + thinkingSelect.innerHTML = ""; + for (const level of levels) { + const option = document.createElement("option"); + option.value = level; + option.textContent = level; + thinkingSelect.appendChild(option); + } + thinkingSelect.dataset.optionsKey = nextOptionsKey; + } + + const effectiveLevel = levels.includes(currentLevel) ? currentLevel : levels[0] || "off"; + if (thinkingSelect.value !== effectiveLevel) { + thinkingSelect.value = effectiveLevel; + } +} + +function updateModelControlsFromState(data) { + if (!modelSelect || !thinkingSelect || !data || data.error) return; + + currentModelKey = modelKey(data.model); + if (currentModelKey && modelSelect.value !== currentModelKey) { + modelSelect.value = currentModelKey; + } + + const thinking = data.thinkingLevel || "off"; + syncThinkingOptions(data.model, thinking); + + const supportsThinking = !!data.model?.reasoning; + thinkingSelect.disabled = !supportsThinking || isStreaming || isApplyingModelSettings; + modelSelect.disabled = isStreaming || isApplyingModelSettings; +} + +async function loadAvailableModels() { + if (!modelSelect) return; + try { + const res = await fetch("/api/models"); + const data = await res.json(); + if (!res.ok || data.error) throw new Error(data.error || res.statusText); + availableModels = data.models || []; + modelSelect.innerHTML = ""; + for (const model of availableModels) { + const option = document.createElement("option"); + option.value = modelKey(model); + option.textContent = modelLabel(model); + modelSelect.appendChild(option); + } + if (currentModelKey) modelSelect.value = currentModelKey; + } catch (err) { + modelSelect.innerHTML = ``; + addMessage("system", `模型列表加载失败: ${err.message}`); + } +} + +async function applyModelSelection() { + if (!modelSelect || !modelSelect.value || isApplyingModelSettings) return; + const [provider, ...idParts] = modelSelect.value.split("/"); + const modelId = idParts.join("/"); + if (!provider || !modelId) return; + + isApplyingModelSettings = true; + modelSelect.disabled = true; + thinkingSelect.disabled = true; + setLoadingState("正在切换模型"); + try { + const res = await fetch("/api/model", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider, modelId }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok || data.error) throw new Error(data.error || res.statusText); + await fetchSessionState(); + setLoadingState("模型已切换"); + setTimeout(() => setLoadingState(""), 1000); + } catch (err) { + setLoadingState(""); + addMessage("system", `切换模型失败: ${err.message}`); + await fetchSessionState(); + } finally { + isApplyingModelSettings = false; + await fetchSessionState(); + } +} + +async function applyThinkingSelection() { + if (!thinkingSelect || isApplyingModelSettings) return; + isApplyingModelSettings = true; + modelSelect.disabled = true; + thinkingSelect.disabled = true; + setLoadingState("正在设置推理强度"); + try { + const res = await fetch("/api/thinking", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ level: thinkingSelect.value }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok || data.error) throw new Error(data.error || res.statusText); + await fetchSessionState(); + setLoadingState("推理强度已设置"); + setTimeout(() => setLoadingState(""), 1000); + } catch (err) { + setLoadingState(""); + addMessage("system", `设置推理强度失败: ${err.message}`); + await fetchSessionState(); + } finally { + isApplyingModelSettings = false; + await fetchSessionState(); + } +} + +function scheduleSessionDashboardRefresh() { + if (sessionDashboardRaf != null) return; + sessionDashboardRaf = requestAnimationFrame(() => { + sessionDashboardRaf = null; + fetchSessionState(); + }); +} + +function syncViewportHeight() { + const viewportHeight = window.visualViewport?.height || window.innerHeight; + document.documentElement.style.setProperty("--app-height", `${viewportHeight}px`); +} + +function formatSessionTitle(title) { + const text = typeof title === "string" ? title.trim() : ""; + return text || "未命名"; +} + +/** 与服务端一致的机器标签(会话 id、纯 hex 段等),不写进界面标题 */ +function isMachineSessionLabel(text, headerId) { + const t = (text ?? "").trim(); + if (!t) return true; + if (headerId && t === headerId) return true; + if (/^[0-9a-f]{8,}$/i.test(t)) return true; + if (/^[0-9]{10,}$/.test(t)) return true; + return false; +} + +/** 首句截取(侧边栏兜底与顶栏回填,需与 server.ts 保持一致逻辑) */ +function titleFromFirstUserMessage(text, maxChars = 56) { + const cleaned = String(text ?? "").replace(/\s+/g, " ").trim(); + if (!cleaned) return ""; + const sentenceMatch = cleaned.match(/^(.+?[。!?.!?])(\s|$)/); + let candidate = + sentenceMatch && sentenceMatch[1] ? sentenceMatch[1].trim() : cleaned; + if (candidate.length > maxChars) { + candidate = `${candidate.slice(0, maxChars).trimEnd()}…`; + } + return candidate; +} + +async function activateSessionBackend(path) { + const activateRes = await fetch("/api/sessions/activate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path }), + }); + const activateData = await activateRes.json().catch(() => ({})); + + if (activateRes.ok && !activateData.error) { + backendSessionPath = path; + return; + } + + const activateError = activateData.error || activateRes.statusText || "Unknown error"; + if (activateRes.status !== 404 && !/not found/i.test(activateError)) { + throw new Error(activateError); + } + + const fallbackRes = await fetch("/api/sessions/load", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path }), + }); + const fallbackData = await fallbackRes.json().catch(() => ({})); + if (!fallbackRes.ok || fallbackData.error) { + throw new Error(fallbackData.error || fallbackRes.statusText); + } + + backendSessionPath = path; +} + +// ─── 输入框自动伸缩 ─────────────────────────────────────────────────── + +input.addEventListener("input", () => { + input.style.height = "auto"; + input.style.height = Math.min(input.scrollHeight, 150) + "px"; +}); + +input.addEventListener("keydown", (e) => { + if (e.isComposing) return; + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + send(); + } +}); + +input.addEventListener("beforeinput", (e) => { + if (isTouchLike && e.inputType === "insertLineBreak") { + e.preventDefault(); + send(); + } +}); + +// ─── 侧边栏切换 ──────────────────────────────────────────────────────── + +function toggleSidebar() { + const open = sidebar.classList.toggle("open"); + overlay.classList.toggle("open", open); +} + +window.addEventListener("resize", () => { + syncViewportHeight(); + if (window.innerWidth > 768) { + sidebar.classList.remove("open"); + overlay.classList.remove("open"); + } +}); + +if (window.visualViewport) { + window.visualViewport.addEventListener("resize", syncViewportHeight); + window.visualViewport.addEventListener("scroll", syncViewportHeight); +} + +window.addEventListener("orientationchange", syncViewportHeight); + +// ─── 状态更新 ──────────────────────────────────────────────────────────── + +function setStatus(connected, streaming) { + if (connected) { + statusDot.className = streaming ? "status-dot streaming" : "status-dot connected"; + statusText.textContent = streaming ? "输入中..." : "就绪"; + headerMeta.textContent = streaming ? "回复中" : ""; + } else { + statusDot.className = "status-dot disconnected"; + statusText.textContent = "未连接"; + headerMeta.textContent = "未连接"; + } + abortBtn.style.display = streaming ? "flex" : "none"; + sendBtn.disabled = streaming; +} + +// ─── Markdown 渲染 ────────────────────────────────────────────────────── + +function escapeMarkdownCode(raw) { + return String(raw) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function renderAssistantCodeBlock(token) { + const langSlug = (((token.lang || "").match(/^\S+/) || [""])[0]).trim().toLowerCase(); + const codeRaw = String(token.text ?? "").replace(/\n+$/, ""); + /** marked:escaped 已为 HTML 字面量片段,Highlighter 跳过 */ + const skipHl = !!token.escaped; + + let innerHtml; + if (skipHl) { + innerHtml = codeRaw; + } else { + try { + const hl = + typeof globalThis.hljs !== "undefined" + ? globalThis.hljs + : typeof hljs !== "undefined" + ? hljs + : null; + if (hl && typeof hl.highlight === "function") { + if (langSlug && hl.getLanguage(langSlug)) { + innerHtml = hl.highlight(codeRaw, { language: langSlug }).value; + } else { + innerHtml = hl.highlightAuto(codeRaw).value; + } + } else { + innerHtml = escapeMarkdownCode(codeRaw); + } + } catch { + innerHtml = escapeMarkdownCode(codeRaw); + } + } + + const safeLangSlug = /^[a-z][a-z0-9_-]*$/i.test(langSlug) ? langSlug : ""; + const codeClass = + safeLangSlug ? `hljs language-${safeLangSlug}` : "hljs"; + return `
${innerHtml}
\n`; +} + +(function initMarkedCodeHighlight() { + if (typeof marked === "undefined" || typeof marked.use !== "function") return; + marked.use({ + renderer: { + code: renderAssistantCodeBlock, + }, + }); +})(); + +function renderMarkdown(text) { + if (!text) return ""; + return marked.parse(text, { breaks: true, gfm: true }); +} + +// ─── 消息渲染 ─────────────────────────────────────────────────────────── + +function removeEmptyState() { + const el = chat.querySelector(".empty-state"); + if (el) el.remove(); +} + +function scrollToBottom() { + chat.scrollTop = chat.scrollHeight; +} + +function findToolCallMessageEl(toolCallId) { + if (toolCallId == null || toolCallId === "") return null; + const id = String(toolCallId); + for (const el of chat.querySelectorAll(".msg.tool_call")) { + if (el.dataset.toolCallId === id) return el; + } + return null; +} + +/** summary 单行:图标 + 工具名 + 短 id(不展开整条 JSON) */ +function deriveToolCallSummary(fullText) { + const raw = String(fullText || "").trim(); + if (!raw) return "tool"; + let icon = ""; + if (raw.startsWith("🔧")) icon = "🔧"; + else if (raw.startsWith("✅")) icon = "✅"; + else if (raw.startsWith("❌")) icon = "❌"; + const rest = icon ? raw.slice(icon.length).trimStart() : raw; + const idLine = rest.split("\n").find((l) => l.trimStart().startsWith("# ")); + const idPart = idLine ? idLine.replace(/^\s*#\s*/, "").trim().split(/\s/)[0] : ""; + const oneLine = rest.replace(/\s+/g, " "); + const nameMatch = oneLine.match(/(\w+)\s*\(/); + const name = nameMatch ? nameMatch[1] : "tool"; + let label = idPart ? `${name} · ${idPart}` : name; + if (icon) label = `${icon} ${label}`; + return label; +} + +function buildToolCallCollapsible(fullText) { + const details = document.createElement("details"); + details.className = "tool-call-collapsible"; + const summary = document.createElement("summary"); + summary.className = "tool-call-summary"; + const sumSpan = document.createElement("span"); + sumSpan.className = "tool-call-summary-text"; + sumSpan.textContent = deriveToolCallSummary(fullText); + summary.appendChild(sumSpan); + const detail = document.createElement("div"); + detail.className = "tool-call-detail"; + const pre = document.createElement("pre"); + pre.className = "tool-call-detail-pre"; + pre.textContent = fullText; + detail.appendChild(pre); + details.appendChild(summary); + details.appendChild(detail); + return details; +} + +function getToolCallFullText(el) { + const pre = el.querySelector(".tool-call-detail-pre"); + return pre ? pre.textContent : el.textContent; +} + +function syncToolCallBody(el, fullText) { + const txt = fullText ?? ""; + if (!el.querySelector(".tool-call-collapsible")) { + el.textContent = ""; + el.appendChild(buildToolCallCollapsible(txt)); + return; + } + const pre = el.querySelector(".tool-call-detail-pre"); + const sum = el.querySelector(".tool-call-summary-text"); + if (pre) pre.textContent = txt; + if (sum) sum.textContent = deriveToolCallSummary(txt); +} + +function appendToolCallMessage(text, toolCallId) { + removeEmptyState(); + const el = document.createElement("div"); + el.className = "msg tool_call"; + if (toolCallId != null && toolCallId !== "") el.dataset.toolCallId = String(toolCallId); + el.appendChild(buildToolCallCollapsible(text || "")); + chat.appendChild(el); + scrollToBottom(); + return el; +} + +/** 助手消息落盘时的工具摘要;若该行已在 tool_execution 中更新则不再覆盖 */ +function upsertToolCallSummary(tc) { + const tid = tc.toolCallId || tc.id; + const text = formatToolCall(tc); + const existing = findToolCallMessageEl(tid); + if (existing) { + const cur = getToolCallFullText(existing).trimStart(); + const running = cur.startsWith("🔧") || cur.startsWith("✅") || cur.startsWith("❌"); + if (!running) syncToolCallBody(existing, text); + if (tid && !existing.dataset.toolCallId) existing.dataset.toolCallId = tid; + return; + } + appendToolCallMessage(text, tid); +} + +function addMessage(role, content, extraClass) { + removeEmptyState(); + + if (role === "assistant" && extraClass === "streaming") { + if (!currentAssistantEl) { + currentAssistantEl = document.createElement("div"); + currentAssistantEl.className = "msg assistant streaming"; + chat.appendChild(currentAssistantEl); + } + const raw = content || assistantBuffer; + currentAssistantEl.innerHTML = renderMarkdown(raw || ""); + scrollToBottom(); + return currentAssistantEl; + } + + const el = document.createElement("div"); + el.className = `msg ${role}`; + if (extraClass) el.classList.add(extraClass); + + if (role === "assistant") { + el.innerHTML = renderMarkdown(content || ""); + } else { + el.textContent = content; + } + + chat.appendChild(el); + if (role !== "system") scrollToBottom(); + return el; +} + +function setLoadingState(text) { + headerMeta.textContent = text || ""; +} + +function finalizeAssistantMessage(content) { + const trimmed = + typeof content === "string" + ? content.trim() + : content + ? String(content).trim() + : ""; + if (currentAssistantEl) { + currentAssistantEl.classList.remove("streaming"); + if (trimmed) { + currentAssistantEl.innerHTML = renderMarkdown(trimmed); + } else { + currentAssistantEl.remove(); + } + currentAssistantEl = null; + } else if (trimmed) { + addMessage("assistant", trimmed); + } + assistantBuffer = ""; +} + +function displayMessages(messages, isHistory = false) { + for (const m of messages) { + if (m.role === "user") { + addMessage("user", extractContent(m.content)); + } else if (m.role === "assistant") { + const text = extractContent(m.content); + const toolCalls = getToolCalls(m.content); + if (text) addMessage("assistant", text); + for (const tc of toolCalls) upsertToolCallSummary(tc); + } + } +} + +function extractContent(content) { + if (!content) return ""; + if (typeof content === "string") return content; + if (Array.isArray(content)) { + return content + .filter(c => c.type === "text") + .map(c => c.text) + .join(""); + } + return String(content); +} + +function getToolCalls(content) { + if (!Array.isArray(content)) return []; + return content.filter(c => c.type === "toolCall"); +} + +function formatToolCall(tc) { + const name = tc.toolName || tc.name || "tool"; + // pi 会话块为 { type, id, name, arguments };部分来源用 args / input + const args = tc.arguments ?? tc.args ?? tc.input ?? {}; + let text = `${name}(${JSON.stringify(args)})`; + const shortId = tc.toolCallId || tc.id; + if (shortId) text = `# ${String(shortId).slice(0, 8)}\n` + text; + return text; +} + +function formatToolResult(tr) { + let text = ""; + if (typeof tr.content === "string") text = tr.content; + else if (Array.isArray(tr.content)) { + text = tr.content.map(c => (c && c.text) || JSON.stringify(c)).join("\n"); + } else if (tr.content && tr.content.text) text = tr.content.text; + else text = JSON.stringify(tr.content || tr, null, 2); + return text.length > 800 ? text.slice(0, 800) + "\n… (已截断)" : text; +} + +function escHtml(s) { + const div = document.createElement("div"); + div.textContent = s; + return div.innerHTML; +} + +// ─── 会话列表 ─────────────────────────────────────────────────────────── + +async function loadSessions() { + sessionList.innerHTML = '
加载中...
'; + try { + const res = await fetch("/api/sessions"); + const data = await res.json(); + sessions = data.sessions || []; + renderSessionList(); + } catch (err) { + sessionList.innerHTML = `
加载失败: ${err.message}
`; + } +} + +function renderSessionList() { + if (!sessions.length) { + sessionList.innerHTML = '
暂无历史会话
'; + return; + } + + sessionList.innerHTML = sessions.map((s) => { + const timeAgo = formatTimeAgo(s.modified || s.created); + const isActive = activeSessionPath === s.path; + const pathAttr = escHtml(s.path); + const title = formatSessionTitle(s.name); + return `
+ + +
`; + }).join(""); +} + +async function deleteSession(event, path) { + event?.stopPropagation(); + if (isStreaming) { + addMessage("system", "正在回复中,暂不能删除会话"); + return; + } + + const row = sessions.find((s) => s.path === path); + const title = formatSessionTitle(row?.name || row?.firstMessage || "该会话"); + if (!confirm(`删除会话「${title}」?此操作不可恢复。`)) return; + + try { + const res = await fetch("/api/sessions/delete", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok || data.error) throw new Error(data.error || res.statusText); + + sessionCache.delete(path); + sessions = sessions.filter((s) => s.path !== path); + if (activeSessionPath === path) { + clearChat(); + backendSessionPath = null; + sessionActivationPromise = null; + } else { + renderSessionList(); + } + } catch (err) { + addMessage("system", `删除会话失败: ${err.message}`); + } +} + +function formatTimeAgo(isoStr) { + const d = new Date(isoStr); + const now = new Date(); + const diffMs = now - d; + const mins = Math.floor(diffMs / 60000); + if (mins < 1) return "刚刚"; + if (mins < 60) return `${mins} 分钟前`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours} 小时前`; + const days = Math.floor(hours / 24); + if (days < 30) return `${days} 天前`; + return d.toLocaleDateString("zh-CN", { month: "short", day: "numeric" }); +} + +// ─── 加载会话 ─────────────────────────────────────────────────────────── + +async function loadSession(path) { + toggleSidebar(); // close sidebar on mobile + activeSessionPath = path; + renderSessionList(); + setLoadingState("加载中..."); + + try { + let payload = sessionCache.get(path); + const poisonName = (p) => + typeof p?.session?.name === "string" && + p.session.name.trim() && + isMachineSessionLabel(p.session.name.trim(), String(p.session?.id ?? "")); + if (payload && poisonName(payload)) { + sessionCache.delete(path); + payload = null; + } + if (!payload) { + const res = await fetch("/api/sessions/history", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path }), + }); + payload = await res.json(); + if (payload.error) throw new Error(payload.error); + sessionCache.set(path, payload); + } + + // 先把历史内容立即渲染出来,后台再同步会话上下文 + chat.innerHTML = ""; + currentAssistantEl = null; + assistantBuffer = ""; + const sid = payload.session?.id != null ? String(payload.session.id) : ""; + let headerLabel = ""; + if ( + typeof payload.session?.name === "string" && + payload.session.name.trim() && + !isMachineSessionLabel(payload.session.name.trim(), sid) + ) { + headerLabel = payload.session.name.trim(); + } + if (!headerLabel) { + for (const m of payload.messages || []) { + if (m.role === "user") { + const fb = titleFromFirstUserMessage(extractContent(m.content)); + if (fb) { + headerLabel = fb; + break; + } + } + } + } + headerTitle.textContent = formatSessionTitle(headerLabel); + displayMessages(payload.messages, true); + scrollToBottom(); + // 历史已可见,勿再占用顶栏「加载中…」(后台激活可能较慢) + setLoadingState(""); + scheduleSessionDashboardRefresh(); + + const activationPromise = (async () => { + try { + await activateSessionBackend(path); + } catch (err) { + addMessage("system", `会话同步失败: ${err.message}`); + throw err; + } finally { + if (sessionActivationPromise === activationPromise) { + sessionActivationPromise = null; + } + } + })(); + + sessionActivationPromise = activationPromise; + activationPromise.catch(() => {}); + } catch (err) { + setLoadingState(""); + addMessage("system", `加载会话失败: ${err.message}`); + } +} + +// ─── SSE 事件流 ──────────────────────────────────────────────────────── + +function connectSSE() { + if (eventSource) eventSource.close(); + + eventSource = new EventSource("/api/events"); + + eventSource.onopen = () => { + setStatus(true, isStreaming); + }; + + eventSource.onmessage = (e) => { + try { + const ev = JSON.parse(e.data); + handleEvent(ev); + } catch { /* ignore */ } + }; + + eventSource.onerror = () => { + setStatus(false, false); + setTimeout(connectSSE, 2000); + }; +} + +function handleEvent(ev) { + switch (ev.type) { + case "connected": + setStatus(true, false); + scheduleSessionDashboardRefresh(); + break; + + case "agent_start": + isStreaming = true; + setStatus(true, true); + assistantBuffer = ""; + currentAssistantEl = null; + scheduleSessionDashboardRefresh(); + break; + + case "agent_end": + isStreaming = false; + setStatus(true, false); + if (currentAssistantEl) { + currentAssistantEl.classList.remove("streaming"); + currentAssistantEl = null; + } + assistantBuffer = ""; + if (backendSessionPath) { + sessionCache.delete(backendSessionPath); + } + // 后台刷新会话列表(可能有新消息产生) + setTimeout(loadSessions, 1000); + // 获取当前会话状态以显示模型信息 + fetchSessionState(); + break; + + case "message_start": { + const m = ev.message; + if (m.role === "user") { + const text = extractContent(m.content); + addMessage("user", text); + } + break; + } + + case "message_update": { + const m = ev.message; + if (m.role === "assistant") { + const text = extractContent(m.content); + assistantBuffer = text; + addMessage("assistant", text, "streaming"); + } + break; + } + + case "message_end": { + const m = ev.message; + if (m.role === "assistant") { + const text = extractContent(m.content); + const toolCalls = getToolCalls(m.content); + for (const tc of toolCalls) upsertToolCallSummary(tc); + finalizeAssistantMessage(text); + scheduleSessionDashboardRefresh(); + } + break; + } + + case "tool_execution_start": { + const tid = ev.toolCallId; + const label = `🔧 ${ev.toolName}(${JSON.stringify(ev.args ?? {})})`; + const existing = findToolCallMessageEl(tid); + if (existing) { + syncToolCallBody(existing, label); + } else { + appendToolCallMessage(label, tid); + } + break; + } + + case "tool_execution_update": { + updateLastToolCall(ev); + break; + } + + case "tool_execution_end": { + updateLastToolCall(ev, ev.isError); + if (ev.isError) { + addMessage("system", `工具 ${ev.toolName} 执行出错`); + } + break; + } + } +} + +function updateLastToolCall(ev, isError) { + const els = chat.querySelectorAll(".msg.tool_call"); + const last = findToolCallMessageEl(ev.toolCallId) ?? els[els.length - 1]; + if (!last) return; + const icon = isError === undefined ? "🔧" : isError ? "❌" : "✅"; + let detail = ""; + if (ev.partialResult?.content) { + detail = "\n" + formatToolResult(ev.partialResult); + } + let full = ""; + if (isError !== undefined) { + const resultText = ev.result?.content + ? "\n" + formatToolResult(ev.result) + : ""; + full = `${icon} ${ev.toolName}${resultText || detail}`; + } else { + full = `${icon} ${ev.toolName}${detail}`; + } + syncToolCallBody(last, full); +} + +// ─── API 调用 ─────────────────────────────────────────────────────────── + +async function send() { + const text = input.value.trim(); + if (!text || isStreaming) return; + + if (newSessionPromise) { + try { + await newSessionPromise; + } catch { + sendBtn.disabled = false; + return; + } + } + + if (activeSessionPath && backendSessionPath !== activeSessionPath) { + if (sessionActivationPromise) { + try { + await sessionActivationPromise; + } catch { + return; + } + } else { + try { + await activateSessionBackend(activeSessionPath); + } catch (err) { + addMessage("system", `切换会话失败: ${err.message}`); + sendBtn.disabled = false; + return; + } + } + } + + input.value = ""; + input.style.height = "auto"; + sendBtn.disabled = true; + + try { + const res = await fetch("/api/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: text }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.statusText })); + addMessage("system", `发送失败: ${err.error}`); + sendBtn.disabled = false; + } + } catch (err) { + addMessage("system", `网络错误: ${err.message}`); + sendBtn.disabled = false; + } + + if (isTouchLike) { + input.blur(); + } +} + +async function abortStream() { + if (!isStreaming) return; + try { + await fetch("/api/abort", { method: "POST" }); + } catch { /* ignore */ } +} + +function clearChat(title = "聊天") { + chat.innerHTML = `
+
+ +
+

发送一条消息开始对话

+

按 Enter 发送 · Shift+Enter 换行

+
`; + currentAssistantEl = null; + assistantBuffer = ""; + activeSessionPath = null; + headerTitle.textContent = title; + if (sessionContextBar) sessionContextBar.hidden = true; + renderSessionList(); +} + +function newSession() { + if (isStreaming) { + addMessage("system", "正在回复中,暂不能创建新会话"); + return; + } + if (newSessionPromise) return; + + statusText.textContent = "新建中..."; + setLoadingState("正在创建新会话"); + backendSessionPath = null; + activeSessionPath = null; + sessionActivationPromise = null; + clearChat("新会话"); + + newSessionPromise = (async () => { + const res = await fetch("/api/new-session", { method: "POST" }); + const data = await res.json().catch(() => ({})); + if (!res.ok || data.error) throw new Error(data.error || res.statusText); + + backendSessionPath = data.sessionFile || null; + setStatus(true, false); + setLoadingState("已创建"); + setTimeout(() => setLoadingState(""), 1200); + await fetchSessionState(); + })(); + + newSessionPromise + .catch((err) => { + setStatus(true, false); + setLoadingState(""); + addMessage("system", `创建新会话失败: ${err.message}`); + }) + .finally(() => { + newSessionPromise = null; + }); +} + +async function fetchSessionState() { + try { + const res = await fetch("/api/session-state"); + if (!res.ok) return; + const data = await res.json(); + if (data.error) return; + + renderSessionContextBar(data); + updateModelControlsFromState(data); + if (statusModel) statusModel.textContent = ""; + + if (!(!activeSessionPath || backendSessionPath === activeSessionPath)) return; + + const sid = data.sessionId != null ? String(data.sessionId) : ""; + const pathKey = backendSessionPath || activeSessionPath; + const row = pathKey ? sessions.find((s) => s.path === pathKey) : null; + + if (data.sessionName && !isMachineSessionLabel(data.sessionName, sid)) { + headerTitle.textContent = formatSessionTitle(data.sessionName); + } else if (row) { + if (row.name && !isMachineSessionLabel(row.name, sid)) { + headerTitle.textContent = formatSessionTitle(row.name); + } else if (row.firstMessage && row.firstMessage !== "(空)") { + headerTitle.textContent = formatSessionTitle( + titleFromFirstUserMessage(row.firstMessage), + ); + } + } + } catch { /* ignore */ } +} + +// ─── 初始化 ───────────────────────────────────────────────────────────── + +syncViewportHeight(); +connectSSE(); +loadSessions(); +loadAvailableModels(); +if (modelSelect) modelSelect.addEventListener("change", applyModelSelection); +if (thinkingSelect) thinkingSelect.addEventListener("change", applyThinkingSelection); +requestAnimationFrame(() => { + requestAnimationFrame(() => { + fetchSessionState(); + }); +}); diff --git a/.pi/extensions/webui/public/index.html b/.pi/extensions/webui/public/index.html new file mode 100644 index 00000000..9d35ce45 --- /dev/null +++ b/.pi/extensions/webui/public/index.html @@ -0,0 +1,125 @@ + + + + + +萌小芽 + + + + + + + + + + + + +
+ + + + +
+ + + + +
+
+
+ +
+

发送一条消息开始对话

+

按 Enter 发送 · Shift+Enter 换行

+
+
+ + +
+
+ + +
+
+
+
+ + + + + + diff --git a/.pi/extensions/webui/public/logo.png b/.pi/extensions/webui/public/logo.png new file mode 100644 index 00000000..2914e6d6 Binary files /dev/null and b/.pi/extensions/webui/public/logo.png differ diff --git a/.pi/extensions/webui/public/marked.umd.js b/.pi/extensions/webui/public/marked.umd.js new file mode 100644 index 00000000..5ca252d9 --- /dev/null +++ b/.pi/extensions/webui/public/marked.umd.js @@ -0,0 +1,2213 @@ +/** + * marked v15.0.12 - a markdown parser + * Copyright (c) 2011-2025, Christopher Jeffrey. (MIT Licensed) + * https://github.com/markedjs/marked + */ + +/** + * DO NOT EDIT THIS FILE + * The code in this file is generated from files in ./src/ + */ +(function(g,f){if(typeof exports=="object"&&typeof module<"u"){module.exports=f()}else if("function"==typeof define && define.amd){define("marked",f)}else {g["marked"]=f()}}(typeof globalThis < "u" ? globalThis : typeof self < "u" ? self : this,function(){var exports={};var __exports=exports;var module={exports}; +"use strict"; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); + +// src/marked.ts +var marked_exports = {}; +__export(marked_exports, { + Hooks: () => _Hooks, + Lexer: () => _Lexer, + Marked: () => Marked, + Parser: () => _Parser, + Renderer: () => _Renderer, + TextRenderer: () => _TextRenderer, + Tokenizer: () => _Tokenizer, + defaults: () => _defaults, + getDefaults: () => _getDefaults, + lexer: () => lexer, + marked: () => marked, + options: () => options, + parse: () => parse, + parseInline: () => parseInline, + parser: () => parser, + setOptions: () => setOptions, + use: () => use, + walkTokens: () => walkTokens +}); +module.exports = __toCommonJS(marked_exports); + +// src/defaults.ts +function _getDefaults() { + return { + async: false, + breaks: false, + extensions: null, + gfm: true, + hooks: null, + pedantic: false, + renderer: null, + silent: false, + tokenizer: null, + walkTokens: null + }; +} +var _defaults = _getDefaults(); +function changeDefaults(newDefaults) { + _defaults = newDefaults; +} + +// src/rules.ts +var noopTest = { exec: () => null }; +function edit(regex, opt = "") { + let source = typeof regex === "string" ? regex : regex.source; + const obj = { + replace: (name, val) => { + let valSource = typeof val === "string" ? val : val.source; + valSource = valSource.replace(other.caret, "$1"); + source = source.replace(name, valSource); + return obj; + }, + getRegex: () => { + return new RegExp(source, opt); + } + }; + return obj; +} +var other = { + codeRemoveIndent: /^(?: {1,4}| {0,3}\t)/gm, + outputLinkReplace: /\\([\[\]])/g, + indentCodeCompensation: /^(\s+)(?:```)/, + beginningSpace: /^\s+/, + endingHash: /#$/, + startingSpaceChar: /^ /, + endingSpaceChar: / $/, + nonSpaceChar: /[^ ]/, + newLineCharGlobal: /\n/g, + tabCharGlobal: /\t/g, + multipleSpaceGlobal: /\s+/g, + blankLine: /^[ \t]*$/, + doubleBlankLine: /\n[ \t]*\n[ \t]*$/, + blockquoteStart: /^ {0,3}>/, + blockquoteSetextReplace: /\n {0,3}((?:=+|-+) *)(?=\n|$)/g, + blockquoteSetextReplace2: /^ {0,3}>[ \t]?/gm, + listReplaceTabs: /^\t+/, + listReplaceNesting: /^ {1,4}(?=( {4})*[^ ])/g, + listIsTask: /^\[[ xX]\] /, + listReplaceTask: /^\[[ xX]\] +/, + anyLine: /\n.*\n/, + hrefBrackets: /^<(.*)>$/, + tableDelimiter: /[:|]/, + tableAlignChars: /^\||\| *$/g, + tableRowBlankLine: /\n[ \t]*$/, + tableAlignRight: /^ *-+: *$/, + tableAlignCenter: /^ *:-+: *$/, + tableAlignLeft: /^ *:-+ *$/, + startATag: /^/i, + startPreScriptTag: /^<(pre|code|kbd|script)(\s|>)/i, + endPreScriptTag: /^<\/(pre|code|kbd|script)(\s|>)/i, + startAngleBracket: /^$/, + pedanticHrefTitle: /^([^'"]*[^\s])\s+(['"])(.*)\2/, + unicodeAlphaNumeric: /[\p{L}\p{N}]/u, + escapeTest: /[&<>"']/, + escapeReplace: /[&<>"']/g, + escapeTestNoEncode: /[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/, + escapeReplaceNoEncode: /[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/g, + unescapeTest: /&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/ig, + caret: /(^|[^\[])\^/g, + percentDecode: /%25/g, + findPipe: /\|/g, + splitPipe: / \|/, + slashPipe: /\\\|/g, + carriageReturn: /\r\n|\r/g, + spaceLine: /^ +$/gm, + notSpaceStart: /^\S*/, + endingNewline: /\n$/, + listItemRegex: (bull) => new RegExp(`^( {0,3}${bull})((?:[ ][^\\n]*)?(?:\\n|$))`), + nextBulletRegex: (indent) => new RegExp(`^ {0,${Math.min(3, indent - 1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ ][^\\n]*)?(?:\\n|$))`), + hrRegex: (indent) => new RegExp(`^ {0,${Math.min(3, indent - 1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`), + fencesBeginRegex: (indent) => new RegExp(`^ {0,${Math.min(3, indent - 1)}}(?:\`\`\`|~~~)`), + headingBeginRegex: (indent) => new RegExp(`^ {0,${Math.min(3, indent - 1)}}#`), + htmlBeginRegex: (indent) => new RegExp(`^ {0,${Math.min(3, indent - 1)}}<(?:[a-z].*>|!--)`, "i") +}; +var newline = /^(?:[ \t]*(?:\n|$))+/; +var blockCode = /^((?: {4}| {0,3}\t)[^\n]+(?:\n(?:[ \t]*(?:\n|$))*)?)+/; +var fences = /^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/; +var hr = /^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/; +var heading = /^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/; +var bullet = /(?:[*+-]|\d{1,9}[.)])/; +var lheadingCore = /^(?!bull |blockCode|fences|blockquote|heading|html|table)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html|table))+?)\n {0,3}(=+|-+) *(?:\n+|$)/; +var lheading = edit(lheadingCore).replace(/bull/g, bullet).replace(/blockCode/g, /(?: {4}| {0,3}\t)/).replace(/fences/g, / {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g, / {0,3}>/).replace(/heading/g, / {0,3}#{1,6}/).replace(/html/g, / {0,3}<[^\n>]+>\n/).replace(/\|table/g, "").getRegex(); +var lheadingGfm = edit(lheadingCore).replace(/bull/g, bullet).replace(/blockCode/g, /(?: {4}| {0,3}\t)/).replace(/fences/g, / {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g, / {0,3}>/).replace(/heading/g, / {0,3}#{1,6}/).replace(/html/g, / {0,3}<[^\n>]+>\n/).replace(/table/g, / {0,3}\|?(?:[:\- ]*\|)+[\:\- ]*\n/).getRegex(); +var _paragraph = /^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/; +var blockText = /^[^\n]+/; +var _blockLabel = /(?!\s*\])(?:\\.|[^\[\]\\])+/; +var def = edit(/^ {0,3}\[(label)\]: *(?:\n[ \t]*)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n[ \t]*)?| *\n[ \t]*)(title))? *(?:\n+|$)/).replace("label", _blockLabel).replace("title", /(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/).getRegex(); +var list = edit(/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/).replace(/bull/g, bullet).getRegex(); +var _tag = "address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul"; +var _comment = /|$))/; +var html = edit( + "^ {0,3}(?:<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>\\n*|$)|\\n*|$)|\\n*|$)|)[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$))", + "i" +).replace("comment", _comment).replace("tag", _tag).replace("attribute", / +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(); +var paragraph = edit(_paragraph).replace("hr", hr).replace("heading", " {0,3}#{1,6}(?:\\s|$)").replace("|lheading", "").replace("|table", "").replace("blockquote", " {0,3}>").replace("fences", " {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list", " {0,3}(?:[*+-]|1[.)]) ").replace("html", ")|<(?:script|pre|style|textarea|!--)").replace("tag", _tag).getRegex(); +var blockquote = edit(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/).replace("paragraph", paragraph).getRegex(); +var blockNormal = { + blockquote, + code: blockCode, + def, + fences, + heading, + hr, + html, + lheading, + list, + newline, + paragraph, + table: noopTest, + text: blockText +}; +var gfmTable = edit( + "^ *([^\\n ].*)\\n {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)" +).replace("hr", hr).replace("heading", " {0,3}#{1,6}(?:\\s|$)").replace("blockquote", " {0,3}>").replace("code", "(?: {4}| {0,3} )[^\\n]").replace("fences", " {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list", " {0,3}(?:[*+-]|1[.)]) ").replace("html", ")|<(?:script|pre|style|textarea|!--)").replace("tag", _tag).getRegex(); +var blockGfm = { + ...blockNormal, + lheading: lheadingGfm, + table: gfmTable, + paragraph: edit(_paragraph).replace("hr", hr).replace("heading", " {0,3}#{1,6}(?:\\s|$)").replace("|lheading", "").replace("table", gfmTable).replace("blockquote", " {0,3}>").replace("fences", " {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list", " {0,3}(?:[*+-]|1[.)]) ").replace("html", ")|<(?:script|pre|style|textarea|!--)").replace("tag", _tag).getRegex() +}; +var blockPedantic = { + ...blockNormal, + html: edit( + `^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+? *(?:\\n{2,}|\\s*$)|\\s]*)*?/?> *(?:\\n{2,}|\\s*$))` + ).replace("comment", _comment).replace(/tag/g, "(?!(?:a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)\\b)\\w+(?!:|[^\\w\\s@]*@)\\b").getRegex(), + def: /^ *\[([^\]]+)\]: *]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/, + heading: /^(#{1,6})(.*)(?:\n+|$)/, + fences: noopTest, + // fences not supported + lheading: /^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/, + paragraph: edit(_paragraph).replace("hr", hr).replace("heading", " *#{1,6} *[^\n]").replace("lheading", lheading).replace("|table", "").replace("blockquote", " {0,3}>").replace("|fences", "").replace("|list", "").replace("|html", "").replace("|tag", "").getRegex() +}; +var escape = /^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/; +var inlineCode = /^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/; +var br = /^( {2,}|\\)\n(?!\s*$)/; +var inlineText = /^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\]*?>/g; +var emStrongLDelimCore = /^(?:\*+(?:((?!\*)punct)|[^\s*]))|^_+(?:((?!_)punct)|([^\s_]))/; +var emStrongLDelim = edit(emStrongLDelimCore, "u").replace(/punct/g, _punctuation).getRegex(); +var emStrongLDelimGfm = edit(emStrongLDelimCore, "u").replace(/punct/g, _punctuationGfmStrongEm).getRegex(); +var emStrongRDelimAstCore = "^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)|[^*]+(?=[^*])|(?!\\*)punct(\\*+)(?=[\\s]|$)|notPunctSpace(\\*+)(?!\\*)(?=punctSpace|$)|(?!\\*)punctSpace(\\*+)(?=notPunctSpace)|[\\s](\\*+)(?!\\*)(?=punct)|(?!\\*)punct(\\*+)(?!\\*)(?=punct)|notPunctSpace(\\*+)(?=notPunctSpace)"; +var emStrongRDelimAst = edit(emStrongRDelimAstCore, "gu").replace(/notPunctSpace/g, _notPunctuationOrSpace).replace(/punctSpace/g, _punctuationOrSpace).replace(/punct/g, _punctuation).getRegex(); +var emStrongRDelimAstGfm = edit(emStrongRDelimAstCore, "gu").replace(/notPunctSpace/g, _notPunctuationOrSpaceGfmStrongEm).replace(/punctSpace/g, _punctuationOrSpaceGfmStrongEm).replace(/punct/g, _punctuationGfmStrongEm).getRegex(); +var emStrongRDelimUnd = edit( + "^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)|[^_]+(?=[^_])|(?!_)punct(_+)(?=[\\s]|$)|notPunctSpace(_+)(?!_)(?=punctSpace|$)|(?!_)punctSpace(_+)(?=notPunctSpace)|[\\s](_+)(?!_)(?=punct)|(?!_)punct(_+)(?!_)(?=punct)", + "gu" +).replace(/notPunctSpace/g, _notPunctuationOrSpace).replace(/punctSpace/g, _punctuationOrSpace).replace(/punct/g, _punctuation).getRegex(); +var anyPunctuation = edit(/\\(punct)/, "gu").replace(/punct/g, _punctuation).getRegex(); +var autolink = edit(/^<(scheme:[^\s\x00-\x1f<>]*|email)>/).replace("scheme", /[a-zA-Z][a-zA-Z0-9+.-]{1,31}/).replace("email", /[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/).getRegex(); +var _inlineComment = edit(_comment).replace("(?:-->|$)", "-->").getRegex(); +var tag = edit( + "^comment|^|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^|^" +).replace("comment", _inlineComment).replace("attribute", /\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/).getRegex(); +var _inlineLabel = /(?:\[(?:\\.|[^\[\]\\])*\]|\\.|`[^`]*`|[^\[\]\\`])*?/; +var link = edit(/^!?\[(label)\]\(\s*(href)(?:(?:[ \t]*(?:\n[ \t]*)?)(title))?\s*\)/).replace("label", _inlineLabel).replace("href", /<(?:\\.|[^\n<>\\])+>|[^ \t\n\x00-\x1f]*/).replace("title", /"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/).getRegex(); +var reflink = edit(/^!?\[(label)\]\[(ref)\]/).replace("label", _inlineLabel).replace("ref", _blockLabel).getRegex(); +var nolink = edit(/^!?\[(ref)\](?:\[\])?/).replace("ref", _blockLabel).getRegex(); +var reflinkSearch = edit("reflink|nolink(?!\\()", "g").replace("reflink", reflink).replace("nolink", nolink).getRegex(); +var inlineNormal = { + _backpedal: noopTest, + // only used for GFM url + anyPunctuation, + autolink, + blockSkip, + br, + code: inlineCode, + del: noopTest, + emStrongLDelim, + emStrongRDelimAst, + emStrongRDelimUnd, + escape, + link, + nolink, + punctuation, + reflink, + reflinkSearch, + tag, + text: inlineText, + url: noopTest +}; +var inlinePedantic = { + ...inlineNormal, + link: edit(/^!?\[(label)\]\((.*?)\)/).replace("label", _inlineLabel).getRegex(), + reflink: edit(/^!?\[(label)\]\s*\[([^\]]*)\]/).replace("label", _inlineLabel).getRegex() +}; +var inlineGfm = { + ...inlineNormal, + emStrongRDelimAst: emStrongRDelimAstGfm, + emStrongLDelim: emStrongLDelimGfm, + url: edit(/^((?:ftp|https?):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*|^email/, "i").replace("email", /[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])/).getRegex(), + _backpedal: /(?:[^?!.,:;*_'"~()&]+|\([^)]*\)|&(?![a-zA-Z0-9]+;$)|[?!.,:;*_'"~)]+(?!$))+/, + del: /^(~~?)(?=[^\s~])((?:\\.|[^\\])*?(?:\\.|[^\s~\\]))\1(?=[^~]|$)/, + text: /^([`~]+|[^`~])(?:(?= {2,}\n)|(?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)|[\s\S]*?(?:(?=[\\": ">", + '"': """, + "'": "'" +}; +var getEscapeReplacement = (ch) => escapeReplacements[ch]; +function escape2(html2, encode) { + if (encode) { + if (other.escapeTest.test(html2)) { + return html2.replace(other.escapeReplace, getEscapeReplacement); + } + } else { + if (other.escapeTestNoEncode.test(html2)) { + return html2.replace(other.escapeReplaceNoEncode, getEscapeReplacement); + } + } + return html2; +} +function cleanUrl(href) { + try { + href = encodeURI(href).replace(other.percentDecode, "%"); + } catch { + return null; + } + return href; +} +function splitCells(tableRow, count) { + const row = tableRow.replace(other.findPipe, (match, offset, str) => { + let escaped = false; + let curr = offset; + while (--curr >= 0 && str[curr] === "\\") escaped = !escaped; + if (escaped) { + return "|"; + } else { + return " |"; + } + }), cells = row.split(other.splitPipe); + let i = 0; + if (!cells[0].trim()) { + cells.shift(); + } + if (cells.length > 0 && !cells.at(-1)?.trim()) { + cells.pop(); + } + if (count) { + if (cells.length > count) { + cells.splice(count); + } else { + while (cells.length < count) cells.push(""); + } + } + for (; i < cells.length; i++) { + cells[i] = cells[i].trim().replace(other.slashPipe, "|"); + } + return cells; +} +function rtrim(str, c, invert) { + const l = str.length; + if (l === 0) { + return ""; + } + let suffLen = 0; + while (suffLen < l) { + const currChar = str.charAt(l - suffLen - 1); + if (currChar === c && !invert) { + suffLen++; + } else if (currChar !== c && invert) { + suffLen++; + } else { + break; + } + } + return str.slice(0, l - suffLen); +} +function findClosingBracket(str, b) { + if (str.indexOf(b[1]) === -1) { + return -1; + } + let level = 0; + for (let i = 0; i < str.length; i++) { + if (str[i] === "\\") { + i++; + } else if (str[i] === b[0]) { + level++; + } else if (str[i] === b[1]) { + level--; + if (level < 0) { + return i; + } + } + } + if (level > 0) { + return -2; + } + return -1; +} + +// src/Tokenizer.ts +function outputLink(cap, link2, raw, lexer2, rules) { + const href = link2.href; + const title = link2.title || null; + const text = cap[1].replace(rules.other.outputLinkReplace, "$1"); + lexer2.state.inLink = true; + const token = { + type: cap[0].charAt(0) === "!" ? "image" : "link", + raw, + href, + title, + text, + tokens: lexer2.inlineTokens(text) + }; + lexer2.state.inLink = false; + return token; +} +function indentCodeCompensation(raw, text, rules) { + const matchIndentToCode = raw.match(rules.other.indentCodeCompensation); + if (matchIndentToCode === null) { + return text; + } + const indentToCode = matchIndentToCode[1]; + return text.split("\n").map((node) => { + const matchIndentInNode = node.match(rules.other.beginningSpace); + if (matchIndentInNode === null) { + return node; + } + const [indentInNode] = matchIndentInNode; + if (indentInNode.length >= indentToCode.length) { + return node.slice(indentToCode.length); + } + return node; + }).join("\n"); +} +var _Tokenizer = class { + options; + rules; + // set by the lexer + lexer; + // set by the lexer + constructor(options2) { + this.options = options2 || _defaults; + } + space(src) { + const cap = this.rules.block.newline.exec(src); + if (cap && cap[0].length > 0) { + return { + type: "space", + raw: cap[0] + }; + } + } + code(src) { + const cap = this.rules.block.code.exec(src); + if (cap) { + const text = cap[0].replace(this.rules.other.codeRemoveIndent, ""); + return { + type: "code", + raw: cap[0], + codeBlockStyle: "indented", + text: !this.options.pedantic ? rtrim(text, "\n") : text + }; + } + } + fences(src) { + const cap = this.rules.block.fences.exec(src); + if (cap) { + const raw = cap[0]; + const text = indentCodeCompensation(raw, cap[3] || "", this.rules); + return { + type: "code", + raw, + lang: cap[2] ? cap[2].trim().replace(this.rules.inline.anyPunctuation, "$1") : cap[2], + text + }; + } + } + heading(src) { + const cap = this.rules.block.heading.exec(src); + if (cap) { + let text = cap[2].trim(); + if (this.rules.other.endingHash.test(text)) { + const trimmed = rtrim(text, "#"); + if (this.options.pedantic) { + text = trimmed.trim(); + } else if (!trimmed || this.rules.other.endingSpaceChar.test(trimmed)) { + text = trimmed.trim(); + } + } + return { + type: "heading", + raw: cap[0], + depth: cap[1].length, + text, + tokens: this.lexer.inline(text) + }; + } + } + hr(src) { + const cap = this.rules.block.hr.exec(src); + if (cap) { + return { + type: "hr", + raw: rtrim(cap[0], "\n") + }; + } + } + blockquote(src) { + const cap = this.rules.block.blockquote.exec(src); + if (cap) { + let lines = rtrim(cap[0], "\n").split("\n"); + let raw = ""; + let text = ""; + const tokens = []; + while (lines.length > 0) { + let inBlockquote = false; + const currentLines = []; + let i; + for (i = 0; i < lines.length; i++) { + if (this.rules.other.blockquoteStart.test(lines[i])) { + currentLines.push(lines[i]); + inBlockquote = true; + } else if (!inBlockquote) { + currentLines.push(lines[i]); + } else { + break; + } + } + lines = lines.slice(i); + const currentRaw = currentLines.join("\n"); + const currentText = currentRaw.replace(this.rules.other.blockquoteSetextReplace, "\n $1").replace(this.rules.other.blockquoteSetextReplace2, ""); + raw = raw ? `${raw} +${currentRaw}` : currentRaw; + text = text ? `${text} +${currentText}` : currentText; + const top = this.lexer.state.top; + this.lexer.state.top = true; + this.lexer.blockTokens(currentText, tokens, true); + this.lexer.state.top = top; + if (lines.length === 0) { + break; + } + const lastToken = tokens.at(-1); + if (lastToken?.type === "code") { + break; + } else if (lastToken?.type === "blockquote") { + const oldToken = lastToken; + const newText = oldToken.raw + "\n" + lines.join("\n"); + const newToken = this.blockquote(newText); + tokens[tokens.length - 1] = newToken; + raw = raw.substring(0, raw.length - oldToken.raw.length) + newToken.raw; + text = text.substring(0, text.length - oldToken.text.length) + newToken.text; + break; + } else if (lastToken?.type === "list") { + const oldToken = lastToken; + const newText = oldToken.raw + "\n" + lines.join("\n"); + const newToken = this.list(newText); + tokens[tokens.length - 1] = newToken; + raw = raw.substring(0, raw.length - lastToken.raw.length) + newToken.raw; + text = text.substring(0, text.length - oldToken.raw.length) + newToken.raw; + lines = newText.substring(tokens.at(-1).raw.length).split("\n"); + continue; + } + } + return { + type: "blockquote", + raw, + tokens, + text + }; + } + } + list(src) { + let cap = this.rules.block.list.exec(src); + if (cap) { + let bull = cap[1].trim(); + const isordered = bull.length > 1; + const list2 = { + type: "list", + raw: "", + ordered: isordered, + start: isordered ? +bull.slice(0, -1) : "", + loose: false, + items: [] + }; + bull = isordered ? `\\d{1,9}\\${bull.slice(-1)}` : `\\${bull}`; + if (this.options.pedantic) { + bull = isordered ? bull : "[*+-]"; + } + const itemRegex = this.rules.other.listItemRegex(bull); + let endsWithBlankLine = false; + while (src) { + let endEarly = false; + let raw = ""; + let itemContents = ""; + if (!(cap = itemRegex.exec(src))) { + break; + } + if (this.rules.block.hr.test(src)) { + break; + } + raw = cap[0]; + src = src.substring(raw.length); + let line = cap[2].split("\n", 1)[0].replace(this.rules.other.listReplaceTabs, (t) => " ".repeat(3 * t.length)); + let nextLine = src.split("\n", 1)[0]; + let blankLine = !line.trim(); + let indent = 0; + if (this.options.pedantic) { + indent = 2; + itemContents = line.trimStart(); + } else if (blankLine) { + indent = cap[1].length + 1; + } else { + indent = cap[2].search(this.rules.other.nonSpaceChar); + indent = indent > 4 ? 1 : indent; + itemContents = line.slice(indent); + indent += cap[1].length; + } + if (blankLine && this.rules.other.blankLine.test(nextLine)) { + raw += nextLine + "\n"; + src = src.substring(nextLine.length + 1); + endEarly = true; + } + if (!endEarly) { + const nextBulletRegex = this.rules.other.nextBulletRegex(indent); + const hrRegex = this.rules.other.hrRegex(indent); + const fencesBeginRegex = this.rules.other.fencesBeginRegex(indent); + const headingBeginRegex = this.rules.other.headingBeginRegex(indent); + const htmlBeginRegex = this.rules.other.htmlBeginRegex(indent); + while (src) { + const rawLine = src.split("\n", 1)[0]; + let nextLineWithoutTabs; + nextLine = rawLine; + if (this.options.pedantic) { + nextLine = nextLine.replace(this.rules.other.listReplaceNesting, " "); + nextLineWithoutTabs = nextLine; + } else { + nextLineWithoutTabs = nextLine.replace(this.rules.other.tabCharGlobal, " "); + } + if (fencesBeginRegex.test(nextLine)) { + break; + } + if (headingBeginRegex.test(nextLine)) { + break; + } + if (htmlBeginRegex.test(nextLine)) { + break; + } + if (nextBulletRegex.test(nextLine)) { + break; + } + if (hrRegex.test(nextLine)) { + break; + } + if (nextLineWithoutTabs.search(this.rules.other.nonSpaceChar) >= indent || !nextLine.trim()) { + itemContents += "\n" + nextLineWithoutTabs.slice(indent); + } else { + if (blankLine) { + break; + } + if (line.replace(this.rules.other.tabCharGlobal, " ").search(this.rules.other.nonSpaceChar) >= 4) { + break; + } + if (fencesBeginRegex.test(line)) { + break; + } + if (headingBeginRegex.test(line)) { + break; + } + if (hrRegex.test(line)) { + break; + } + itemContents += "\n" + nextLine; + } + if (!blankLine && !nextLine.trim()) { + blankLine = true; + } + raw += rawLine + "\n"; + src = src.substring(rawLine.length + 1); + line = nextLineWithoutTabs.slice(indent); + } + } + if (!list2.loose) { + if (endsWithBlankLine) { + list2.loose = true; + } else if (this.rules.other.doubleBlankLine.test(raw)) { + endsWithBlankLine = true; + } + } + let istask = null; + let ischecked; + if (this.options.gfm) { + istask = this.rules.other.listIsTask.exec(itemContents); + if (istask) { + ischecked = istask[0] !== "[ ] "; + itemContents = itemContents.replace(this.rules.other.listReplaceTask, ""); + } + } + list2.items.push({ + type: "list_item", + raw, + task: !!istask, + checked: ischecked, + loose: false, + text: itemContents, + tokens: [] + }); + list2.raw += raw; + } + const lastItem = list2.items.at(-1); + if (lastItem) { + lastItem.raw = lastItem.raw.trimEnd(); + lastItem.text = lastItem.text.trimEnd(); + } else { + return; + } + list2.raw = list2.raw.trimEnd(); + for (let i = 0; i < list2.items.length; i++) { + this.lexer.state.top = false; + list2.items[i].tokens = this.lexer.blockTokens(list2.items[i].text, []); + if (!list2.loose) { + const spacers = list2.items[i].tokens.filter((t) => t.type === "space"); + const hasMultipleLineBreaks = spacers.length > 0 && spacers.some((t) => this.rules.other.anyLine.test(t.raw)); + list2.loose = hasMultipleLineBreaks; + } + } + if (list2.loose) { + for (let i = 0; i < list2.items.length; i++) { + list2.items[i].loose = true; + } + } + return list2; + } + } + html(src) { + const cap = this.rules.block.html.exec(src); + if (cap) { + const token = { + type: "html", + block: true, + raw: cap[0], + pre: cap[1] === "pre" || cap[1] === "script" || cap[1] === "style", + text: cap[0] + }; + return token; + } + } + def(src) { + const cap = this.rules.block.def.exec(src); + if (cap) { + const tag2 = cap[1].toLowerCase().replace(this.rules.other.multipleSpaceGlobal, " "); + const href = cap[2] ? cap[2].replace(this.rules.other.hrefBrackets, "$1").replace(this.rules.inline.anyPunctuation, "$1") : ""; + const title = cap[3] ? cap[3].substring(1, cap[3].length - 1).replace(this.rules.inline.anyPunctuation, "$1") : cap[3]; + return { + type: "def", + tag: tag2, + raw: cap[0], + href, + title + }; + } + } + table(src) { + const cap = this.rules.block.table.exec(src); + if (!cap) { + return; + } + if (!this.rules.other.tableDelimiter.test(cap[2])) { + return; + } + const headers = splitCells(cap[1]); + const aligns = cap[2].replace(this.rules.other.tableAlignChars, "").split("|"); + const rows = cap[3]?.trim() ? cap[3].replace(this.rules.other.tableRowBlankLine, "").split("\n") : []; + const item = { + type: "table", + raw: cap[0], + header: [], + align: [], + rows: [] + }; + if (headers.length !== aligns.length) { + return; + } + for (const align of aligns) { + if (this.rules.other.tableAlignRight.test(align)) { + item.align.push("right"); + } else if (this.rules.other.tableAlignCenter.test(align)) { + item.align.push("center"); + } else if (this.rules.other.tableAlignLeft.test(align)) { + item.align.push("left"); + } else { + item.align.push(null); + } + } + for (let i = 0; i < headers.length; i++) { + item.header.push({ + text: headers[i], + tokens: this.lexer.inline(headers[i]), + header: true, + align: item.align[i] + }); + } + for (const row of rows) { + item.rows.push(splitCells(row, item.header.length).map((cell, i) => { + return { + text: cell, + tokens: this.lexer.inline(cell), + header: false, + align: item.align[i] + }; + })); + } + return item; + } + lheading(src) { + const cap = this.rules.block.lheading.exec(src); + if (cap) { + return { + type: "heading", + raw: cap[0], + depth: cap[2].charAt(0) === "=" ? 1 : 2, + text: cap[1], + tokens: this.lexer.inline(cap[1]) + }; + } + } + paragraph(src) { + const cap = this.rules.block.paragraph.exec(src); + if (cap) { + const text = cap[1].charAt(cap[1].length - 1) === "\n" ? cap[1].slice(0, -1) : cap[1]; + return { + type: "paragraph", + raw: cap[0], + text, + tokens: this.lexer.inline(text) + }; + } + } + text(src) { + const cap = this.rules.block.text.exec(src); + if (cap) { + return { + type: "text", + raw: cap[0], + text: cap[0], + tokens: this.lexer.inline(cap[0]) + }; + } + } + escape(src) { + const cap = this.rules.inline.escape.exec(src); + if (cap) { + return { + type: "escape", + raw: cap[0], + text: cap[1] + }; + } + } + tag(src) { + const cap = this.rules.inline.tag.exec(src); + if (cap) { + if (!this.lexer.state.inLink && this.rules.other.startATag.test(cap[0])) { + this.lexer.state.inLink = true; + } else if (this.lexer.state.inLink && this.rules.other.endATag.test(cap[0])) { + this.lexer.state.inLink = false; + } + if (!this.lexer.state.inRawBlock && this.rules.other.startPreScriptTag.test(cap[0])) { + this.lexer.state.inRawBlock = true; + } else if (this.lexer.state.inRawBlock && this.rules.other.endPreScriptTag.test(cap[0])) { + this.lexer.state.inRawBlock = false; + } + return { + type: "html", + raw: cap[0], + inLink: this.lexer.state.inLink, + inRawBlock: this.lexer.state.inRawBlock, + block: false, + text: cap[0] + }; + } + } + link(src) { + const cap = this.rules.inline.link.exec(src); + if (cap) { + const trimmedUrl = cap[2].trim(); + if (!this.options.pedantic && this.rules.other.startAngleBracket.test(trimmedUrl)) { + if (!this.rules.other.endAngleBracket.test(trimmedUrl)) { + return; + } + const rtrimSlash = rtrim(trimmedUrl.slice(0, -1), "\\"); + if ((trimmedUrl.length - rtrimSlash.length) % 2 === 0) { + return; + } + } else { + const lastParenIndex = findClosingBracket(cap[2], "()"); + if (lastParenIndex === -2) { + return; + } + if (lastParenIndex > -1) { + const start = cap[0].indexOf("!") === 0 ? 5 : 4; + const linkLen = start + cap[1].length + lastParenIndex; + cap[2] = cap[2].substring(0, lastParenIndex); + cap[0] = cap[0].substring(0, linkLen).trim(); + cap[3] = ""; + } + } + let href = cap[2]; + let title = ""; + if (this.options.pedantic) { + const link2 = this.rules.other.pedanticHrefTitle.exec(href); + if (link2) { + href = link2[1]; + title = link2[3]; + } + } else { + title = cap[3] ? cap[3].slice(1, -1) : ""; + } + href = href.trim(); + if (this.rules.other.startAngleBracket.test(href)) { + if (this.options.pedantic && !this.rules.other.endAngleBracket.test(trimmedUrl)) { + href = href.slice(1); + } else { + href = href.slice(1, -1); + } + } + return outputLink(cap, { + href: href ? href.replace(this.rules.inline.anyPunctuation, "$1") : href, + title: title ? title.replace(this.rules.inline.anyPunctuation, "$1") : title + }, cap[0], this.lexer, this.rules); + } + } + reflink(src, links) { + let cap; + if ((cap = this.rules.inline.reflink.exec(src)) || (cap = this.rules.inline.nolink.exec(src))) { + const linkString = (cap[2] || cap[1]).replace(this.rules.other.multipleSpaceGlobal, " "); + const link2 = links[linkString.toLowerCase()]; + if (!link2) { + const text = cap[0].charAt(0); + return { + type: "text", + raw: text, + text + }; + } + return outputLink(cap, link2, cap[0], this.lexer, this.rules); + } + } + emStrong(src, maskedSrc, prevChar = "") { + let match = this.rules.inline.emStrongLDelim.exec(src); + if (!match) return; + if (match[3] && prevChar.match(this.rules.other.unicodeAlphaNumeric)) return; + const nextChar = match[1] || match[2] || ""; + if (!nextChar || !prevChar || this.rules.inline.punctuation.exec(prevChar)) { + const lLength = [...match[0]].length - 1; + let rDelim, rLength, delimTotal = lLength, midDelimTotal = 0; + const endReg = match[0][0] === "*" ? this.rules.inline.emStrongRDelimAst : this.rules.inline.emStrongRDelimUnd; + endReg.lastIndex = 0; + maskedSrc = maskedSrc.slice(-1 * src.length + lLength); + while ((match = endReg.exec(maskedSrc)) != null) { + rDelim = match[1] || match[2] || match[3] || match[4] || match[5] || match[6]; + if (!rDelim) continue; + rLength = [...rDelim].length; + if (match[3] || match[4]) { + delimTotal += rLength; + continue; + } else if (match[5] || match[6]) { + if (lLength % 3 && !((lLength + rLength) % 3)) { + midDelimTotal += rLength; + continue; + } + } + delimTotal -= rLength; + if (delimTotal > 0) continue; + rLength = Math.min(rLength, rLength + delimTotal + midDelimTotal); + const lastCharLength = [...match[0]][0].length; + const raw = src.slice(0, lLength + match.index + lastCharLength + rLength); + if (Math.min(lLength, rLength) % 2) { + const text2 = raw.slice(1, -1); + return { + type: "em", + raw, + text: text2, + tokens: this.lexer.inlineTokens(text2) + }; + } + const text = raw.slice(2, -2); + return { + type: "strong", + raw, + text, + tokens: this.lexer.inlineTokens(text) + }; + } + } + } + codespan(src) { + const cap = this.rules.inline.code.exec(src); + if (cap) { + let text = cap[2].replace(this.rules.other.newLineCharGlobal, " "); + const hasNonSpaceChars = this.rules.other.nonSpaceChar.test(text); + const hasSpaceCharsOnBothEnds = this.rules.other.startingSpaceChar.test(text) && this.rules.other.endingSpaceChar.test(text); + if (hasNonSpaceChars && hasSpaceCharsOnBothEnds) { + text = text.substring(1, text.length - 1); + } + return { + type: "codespan", + raw: cap[0], + text + }; + } + } + br(src) { + const cap = this.rules.inline.br.exec(src); + if (cap) { + return { + type: "br", + raw: cap[0] + }; + } + } + del(src) { + const cap = this.rules.inline.del.exec(src); + if (cap) { + return { + type: "del", + raw: cap[0], + text: cap[2], + tokens: this.lexer.inlineTokens(cap[2]) + }; + } + } + autolink(src) { + const cap = this.rules.inline.autolink.exec(src); + if (cap) { + let text, href; + if (cap[2] === "@") { + text = cap[1]; + href = "mailto:" + text; + } else { + text = cap[1]; + href = text; + } + return { + type: "link", + raw: cap[0], + text, + href, + tokens: [ + { + type: "text", + raw: text, + text + } + ] + }; + } + } + url(src) { + let cap; + if (cap = this.rules.inline.url.exec(src)) { + let text, href; + if (cap[2] === "@") { + text = cap[0]; + href = "mailto:" + text; + } else { + let prevCapZero; + do { + prevCapZero = cap[0]; + cap[0] = this.rules.inline._backpedal.exec(cap[0])?.[0] ?? ""; + } while (prevCapZero !== cap[0]); + text = cap[0]; + if (cap[1] === "www.") { + href = "http://" + cap[0]; + } else { + href = cap[0]; + } + } + return { + type: "link", + raw: cap[0], + text, + href, + tokens: [ + { + type: "text", + raw: text, + text + } + ] + }; + } + } + inlineText(src) { + const cap = this.rules.inline.text.exec(src); + if (cap) { + const escaped = this.lexer.state.inRawBlock; + return { + type: "text", + raw: cap[0], + text: cap[0], + escaped + }; + } + } +}; + +// src/Lexer.ts +var _Lexer = class __Lexer { + tokens; + options; + state; + tokenizer; + inlineQueue; + constructor(options2) { + this.tokens = []; + this.tokens.links = /* @__PURE__ */ Object.create(null); + this.options = options2 || _defaults; + this.options.tokenizer = this.options.tokenizer || new _Tokenizer(); + this.tokenizer = this.options.tokenizer; + this.tokenizer.options = this.options; + this.tokenizer.lexer = this; + this.inlineQueue = []; + this.state = { + inLink: false, + inRawBlock: false, + top: true + }; + const rules = { + other, + block: block.normal, + inline: inline.normal + }; + if (this.options.pedantic) { + rules.block = block.pedantic; + rules.inline = inline.pedantic; + } else if (this.options.gfm) { + rules.block = block.gfm; + if (this.options.breaks) { + rules.inline = inline.breaks; + } else { + rules.inline = inline.gfm; + } + } + this.tokenizer.rules = rules; + } + /** + * Expose Rules + */ + static get rules() { + return { + block, + inline + }; + } + /** + * Static Lex Method + */ + static lex(src, options2) { + const lexer2 = new __Lexer(options2); + return lexer2.lex(src); + } + /** + * Static Lex Inline Method + */ + static lexInline(src, options2) { + const lexer2 = new __Lexer(options2); + return lexer2.inlineTokens(src); + } + /** + * Preprocessing + */ + lex(src) { + src = src.replace(other.carriageReturn, "\n"); + this.blockTokens(src, this.tokens); + for (let i = 0; i < this.inlineQueue.length; i++) { + const next = this.inlineQueue[i]; + this.inlineTokens(next.src, next.tokens); + } + this.inlineQueue = []; + return this.tokens; + } + blockTokens(src, tokens = [], lastParagraphClipped = false) { + if (this.options.pedantic) { + src = src.replace(other.tabCharGlobal, " ").replace(other.spaceLine, ""); + } + while (src) { + let token; + if (this.options.extensions?.block?.some((extTokenizer) => { + if (token = extTokenizer.call({ lexer: this }, src, tokens)) { + src = src.substring(token.raw.length); + tokens.push(token); + return true; + } + return false; + })) { + continue; + } + if (token = this.tokenizer.space(src)) { + src = src.substring(token.raw.length); + const lastToken = tokens.at(-1); + if (token.raw.length === 1 && lastToken !== void 0) { + lastToken.raw += "\n"; + } else { + tokens.push(token); + } + continue; + } + if (token = this.tokenizer.code(src)) { + src = src.substring(token.raw.length); + const lastToken = tokens.at(-1); + if (lastToken?.type === "paragraph" || lastToken?.type === "text") { + lastToken.raw += "\n" + token.raw; + lastToken.text += "\n" + token.text; + this.inlineQueue.at(-1).src = lastToken.text; + } else { + tokens.push(token); + } + continue; + } + if (token = this.tokenizer.fences(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + if (token = this.tokenizer.heading(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + if (token = this.tokenizer.hr(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + if (token = this.tokenizer.blockquote(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + if (token = this.tokenizer.list(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + if (token = this.tokenizer.html(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + if (token = this.tokenizer.def(src)) { + src = src.substring(token.raw.length); + const lastToken = tokens.at(-1); + if (lastToken?.type === "paragraph" || lastToken?.type === "text") { + lastToken.raw += "\n" + token.raw; + lastToken.text += "\n" + token.raw; + this.inlineQueue.at(-1).src = lastToken.text; + } else if (!this.tokens.links[token.tag]) { + this.tokens.links[token.tag] = { + href: token.href, + title: token.title + }; + } + continue; + } + if (token = this.tokenizer.table(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + if (token = this.tokenizer.lheading(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + let cutSrc = src; + if (this.options.extensions?.startBlock) { + let startIndex = Infinity; + const tempSrc = src.slice(1); + let tempStart; + this.options.extensions.startBlock.forEach((getStartIndex) => { + tempStart = getStartIndex.call({ lexer: this }, tempSrc); + if (typeof tempStart === "number" && tempStart >= 0) { + startIndex = Math.min(startIndex, tempStart); + } + }); + if (startIndex < Infinity && startIndex >= 0) { + cutSrc = src.substring(0, startIndex + 1); + } + } + if (this.state.top && (token = this.tokenizer.paragraph(cutSrc))) { + const lastToken = tokens.at(-1); + if (lastParagraphClipped && lastToken?.type === "paragraph") { + lastToken.raw += "\n" + token.raw; + lastToken.text += "\n" + token.text; + this.inlineQueue.pop(); + this.inlineQueue.at(-1).src = lastToken.text; + } else { + tokens.push(token); + } + lastParagraphClipped = cutSrc.length !== src.length; + src = src.substring(token.raw.length); + continue; + } + if (token = this.tokenizer.text(src)) { + src = src.substring(token.raw.length); + const lastToken = tokens.at(-1); + if (lastToken?.type === "text") { + lastToken.raw += "\n" + token.raw; + lastToken.text += "\n" + token.text; + this.inlineQueue.pop(); + this.inlineQueue.at(-1).src = lastToken.text; + } else { + tokens.push(token); + } + continue; + } + if (src) { + const errMsg = "Infinite loop on byte: " + src.charCodeAt(0); + if (this.options.silent) { + console.error(errMsg); + break; + } else { + throw new Error(errMsg); + } + } + } + this.state.top = true; + return tokens; + } + inline(src, tokens = []) { + this.inlineQueue.push({ src, tokens }); + return tokens; + } + /** + * Lexing/Compiling + */ + inlineTokens(src, tokens = []) { + let maskedSrc = src; + let match = null; + if (this.tokens.links) { + const links = Object.keys(this.tokens.links); + if (links.length > 0) { + while ((match = this.tokenizer.rules.inline.reflinkSearch.exec(maskedSrc)) != null) { + if (links.includes(match[0].slice(match[0].lastIndexOf("[") + 1, -1))) { + maskedSrc = maskedSrc.slice(0, match.index) + "[" + "a".repeat(match[0].length - 2) + "]" + maskedSrc.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex); + } + } + } + } + while ((match = this.tokenizer.rules.inline.anyPunctuation.exec(maskedSrc)) != null) { + maskedSrc = maskedSrc.slice(0, match.index) + "++" + maskedSrc.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex); + } + while ((match = this.tokenizer.rules.inline.blockSkip.exec(maskedSrc)) != null) { + maskedSrc = maskedSrc.slice(0, match.index) + "[" + "a".repeat(match[0].length - 2) + "]" + maskedSrc.slice(this.tokenizer.rules.inline.blockSkip.lastIndex); + } + let keepPrevChar = false; + let prevChar = ""; + while (src) { + if (!keepPrevChar) { + prevChar = ""; + } + keepPrevChar = false; + let token; + if (this.options.extensions?.inline?.some((extTokenizer) => { + if (token = extTokenizer.call({ lexer: this }, src, tokens)) { + src = src.substring(token.raw.length); + tokens.push(token); + return true; + } + return false; + })) { + continue; + } + if (token = this.tokenizer.escape(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + if (token = this.tokenizer.tag(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + if (token = this.tokenizer.link(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + if (token = this.tokenizer.reflink(src, this.tokens.links)) { + src = src.substring(token.raw.length); + const lastToken = tokens.at(-1); + if (token.type === "text" && lastToken?.type === "text") { + lastToken.raw += token.raw; + lastToken.text += token.text; + } else { + tokens.push(token); + } + continue; + } + if (token = this.tokenizer.emStrong(src, maskedSrc, prevChar)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + if (token = this.tokenizer.codespan(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + if (token = this.tokenizer.br(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + if (token = this.tokenizer.del(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + if (token = this.tokenizer.autolink(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + if (!this.state.inLink && (token = this.tokenizer.url(src))) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + let cutSrc = src; + if (this.options.extensions?.startInline) { + let startIndex = Infinity; + const tempSrc = src.slice(1); + let tempStart; + this.options.extensions.startInline.forEach((getStartIndex) => { + tempStart = getStartIndex.call({ lexer: this }, tempSrc); + if (typeof tempStart === "number" && tempStart >= 0) { + startIndex = Math.min(startIndex, tempStart); + } + }); + if (startIndex < Infinity && startIndex >= 0) { + cutSrc = src.substring(0, startIndex + 1); + } + } + if (token = this.tokenizer.inlineText(cutSrc)) { + src = src.substring(token.raw.length); + if (token.raw.slice(-1) !== "_") { + prevChar = token.raw.slice(-1); + } + keepPrevChar = true; + const lastToken = tokens.at(-1); + if (lastToken?.type === "text") { + lastToken.raw += token.raw; + lastToken.text += token.text; + } else { + tokens.push(token); + } + continue; + } + if (src) { + const errMsg = "Infinite loop on byte: " + src.charCodeAt(0); + if (this.options.silent) { + console.error(errMsg); + break; + } else { + throw new Error(errMsg); + } + } + } + return tokens; + } +}; + +// src/Renderer.ts +var _Renderer = class { + options; + parser; + // set by the parser + constructor(options2) { + this.options = options2 || _defaults; + } + space(token) { + return ""; + } + code({ text, lang, escaped }) { + const langString = (lang || "").match(other.notSpaceStart)?.[0]; + const code = text.replace(other.endingNewline, "") + "\n"; + if (!langString) { + return "
" + (escaped ? code : escape2(code, true)) + "
\n"; + } + return '
' + (escaped ? code : escape2(code, true)) + "
\n"; + } + blockquote({ tokens }) { + const body = this.parser.parse(tokens); + return `
+${body}
+`; + } + html({ text }) { + return text; + } + heading({ tokens, depth }) { + return `${this.parser.parseInline(tokens)} +`; + } + hr(token) { + return "
\n"; + } + list(token) { + const ordered = token.ordered; + const start = token.start; + let body = ""; + for (let j = 0; j < token.items.length; j++) { + const item = token.items[j]; + body += this.listitem(item); + } + const type = ordered ? "ol" : "ul"; + const startAttr = ordered && start !== 1 ? ' start="' + start + '"' : ""; + return "<" + type + startAttr + ">\n" + body + "\n"; + } + listitem(item) { + let itemBody = ""; + if (item.task) { + const checkbox = this.checkbox({ checked: !!item.checked }); + if (item.loose) { + if (item.tokens[0]?.type === "paragraph") { + item.tokens[0].text = checkbox + " " + item.tokens[0].text; + if (item.tokens[0].tokens && item.tokens[0].tokens.length > 0 && item.tokens[0].tokens[0].type === "text") { + item.tokens[0].tokens[0].text = checkbox + " " + escape2(item.tokens[0].tokens[0].text); + item.tokens[0].tokens[0].escaped = true; + } + } else { + item.tokens.unshift({ + type: "text", + raw: checkbox + " ", + text: checkbox + " ", + escaped: true + }); + } + } else { + itemBody += checkbox + " "; + } + } + itemBody += this.parser.parse(item.tokens, !!item.loose); + return `
  • ${itemBody}
  • +`; + } + checkbox({ checked }) { + return "'; + } + paragraph({ tokens }) { + return `

    ${this.parser.parseInline(tokens)}

    +`; + } + table(token) { + let header = ""; + let cell = ""; + for (let j = 0; j < token.header.length; j++) { + cell += this.tablecell(token.header[j]); + } + header += this.tablerow({ text: cell }); + let body = ""; + for (let j = 0; j < token.rows.length; j++) { + const row = token.rows[j]; + cell = ""; + for (let k = 0; k < row.length; k++) { + cell += this.tablecell(row[k]); + } + body += this.tablerow({ text: cell }); + } + if (body) body = `${body}`; + return "\n\n" + header + "\n" + body + "
    \n"; + } + tablerow({ text }) { + return ` +${text} +`; + } + tablecell(token) { + const content = this.parser.parseInline(token.tokens); + const type = token.header ? "th" : "td"; + const tag2 = token.align ? `<${type} align="${token.align}">` : `<${type}>`; + return tag2 + content + ` +`; + } + /** + * span level renderer + */ + strong({ tokens }) { + return `${this.parser.parseInline(tokens)}`; + } + em({ tokens }) { + return `${this.parser.parseInline(tokens)}`; + } + codespan({ text }) { + return `${escape2(text, true)}`; + } + br(token) { + return "
    "; + } + del({ tokens }) { + return `${this.parser.parseInline(tokens)}`; + } + link({ href, title, tokens }) { + const text = this.parser.parseInline(tokens); + const cleanHref = cleanUrl(href); + if (cleanHref === null) { + return text; + } + href = cleanHref; + let out = '
    "; + return out; + } + image({ href, title, text, tokens }) { + if (tokens) { + text = this.parser.parseInline(tokens, this.parser.textRenderer); + } + const cleanHref = cleanUrl(href); + if (cleanHref === null) { + return escape2(text); + } + href = cleanHref; + let out = `${text} { + const tokens2 = genericToken[childTokens].flat(Infinity); + values = values.concat(this.walkTokens(tokens2, callback)); + }); + } else if (genericToken.tokens) { + values = values.concat(this.walkTokens(genericToken.tokens, callback)); + } + } + } + } + return values; + } + use(...args) { + const extensions = this.defaults.extensions || { renderers: {}, childTokens: {} }; + args.forEach((pack) => { + const opts = { ...pack }; + opts.async = this.defaults.async || opts.async || false; + if (pack.extensions) { + pack.extensions.forEach((ext) => { + if (!ext.name) { + throw new Error("extension name required"); + } + if ("renderer" in ext) { + const prevRenderer = extensions.renderers[ext.name]; + if (prevRenderer) { + extensions.renderers[ext.name] = function(...args2) { + let ret = ext.renderer.apply(this, args2); + if (ret === false) { + ret = prevRenderer.apply(this, args2); + } + return ret; + }; + } else { + extensions.renderers[ext.name] = ext.renderer; + } + } + if ("tokenizer" in ext) { + if (!ext.level || ext.level !== "block" && ext.level !== "inline") { + throw new Error("extension level must be 'block' or 'inline'"); + } + const extLevel = extensions[ext.level]; + if (extLevel) { + extLevel.unshift(ext.tokenizer); + } else { + extensions[ext.level] = [ext.tokenizer]; + } + if (ext.start) { + if (ext.level === "block") { + if (extensions.startBlock) { + extensions.startBlock.push(ext.start); + } else { + extensions.startBlock = [ext.start]; + } + } else if (ext.level === "inline") { + if (extensions.startInline) { + extensions.startInline.push(ext.start); + } else { + extensions.startInline = [ext.start]; + } + } + } + } + if ("childTokens" in ext && ext.childTokens) { + extensions.childTokens[ext.name] = ext.childTokens; + } + }); + opts.extensions = extensions; + } + if (pack.renderer) { + const renderer = this.defaults.renderer || new _Renderer(this.defaults); + for (const prop in pack.renderer) { + if (!(prop in renderer)) { + throw new Error(`renderer '${prop}' does not exist`); + } + if (["options", "parser"].includes(prop)) { + continue; + } + const rendererProp = prop; + const rendererFunc = pack.renderer[rendererProp]; + const prevRenderer = renderer[rendererProp]; + renderer[rendererProp] = (...args2) => { + let ret = rendererFunc.apply(renderer, args2); + if (ret === false) { + ret = prevRenderer.apply(renderer, args2); + } + return ret || ""; + }; + } + opts.renderer = renderer; + } + if (pack.tokenizer) { + const tokenizer = this.defaults.tokenizer || new _Tokenizer(this.defaults); + for (const prop in pack.tokenizer) { + if (!(prop in tokenizer)) { + throw new Error(`tokenizer '${prop}' does not exist`); + } + if (["options", "rules", "lexer"].includes(prop)) { + continue; + } + const tokenizerProp = prop; + const tokenizerFunc = pack.tokenizer[tokenizerProp]; + const prevTokenizer = tokenizer[tokenizerProp]; + tokenizer[tokenizerProp] = (...args2) => { + let ret = tokenizerFunc.apply(tokenizer, args2); + if (ret === false) { + ret = prevTokenizer.apply(tokenizer, args2); + } + return ret; + }; + } + opts.tokenizer = tokenizer; + } + if (pack.hooks) { + const hooks = this.defaults.hooks || new _Hooks(); + for (const prop in pack.hooks) { + if (!(prop in hooks)) { + throw new Error(`hook '${prop}' does not exist`); + } + if (["options", "block"].includes(prop)) { + continue; + } + const hooksProp = prop; + const hooksFunc = pack.hooks[hooksProp]; + const prevHook = hooks[hooksProp]; + if (_Hooks.passThroughHooks.has(prop)) { + hooks[hooksProp] = (arg) => { + if (this.defaults.async) { + return Promise.resolve(hooksFunc.call(hooks, arg)).then((ret2) => { + return prevHook.call(hooks, ret2); + }); + } + const ret = hooksFunc.call(hooks, arg); + return prevHook.call(hooks, ret); + }; + } else { + hooks[hooksProp] = (...args2) => { + let ret = hooksFunc.apply(hooks, args2); + if (ret === false) { + ret = prevHook.apply(hooks, args2); + } + return ret; + }; + } + } + opts.hooks = hooks; + } + if (pack.walkTokens) { + const walkTokens2 = this.defaults.walkTokens; + const packWalktokens = pack.walkTokens; + opts.walkTokens = function(token) { + let values = []; + values.push(packWalktokens.call(this, token)); + if (walkTokens2) { + values = values.concat(walkTokens2.call(this, token)); + } + return values; + }; + } + this.defaults = { ...this.defaults, ...opts }; + }); + return this; + } + setOptions(opt) { + this.defaults = { ...this.defaults, ...opt }; + return this; + } + lexer(src, options2) { + return _Lexer.lex(src, options2 ?? this.defaults); + } + parser(tokens, options2) { + return _Parser.parse(tokens, options2 ?? this.defaults); + } + parseMarkdown(blockType) { + const parse2 = (src, options2) => { + const origOpt = { ...options2 }; + const opt = { ...this.defaults, ...origOpt }; + const throwError = this.onError(!!opt.silent, !!opt.async); + if (this.defaults.async === true && origOpt.async === false) { + return throwError(new Error("marked(): The async option was set to true by an extension. Remove async: false from the parse options object to return a Promise.")); + } + if (typeof src === "undefined" || src === null) { + return throwError(new Error("marked(): input parameter is undefined or null")); + } + if (typeof src !== "string") { + return throwError(new Error("marked(): input parameter is of type " + Object.prototype.toString.call(src) + ", string expected")); + } + if (opt.hooks) { + opt.hooks.options = opt; + opt.hooks.block = blockType; + } + const lexer2 = opt.hooks ? opt.hooks.provideLexer() : blockType ? _Lexer.lex : _Lexer.lexInline; + const parser2 = opt.hooks ? opt.hooks.provideParser() : blockType ? _Parser.parse : _Parser.parseInline; + if (opt.async) { + return Promise.resolve(opt.hooks ? opt.hooks.preprocess(src) : src).then((src2) => lexer2(src2, opt)).then((tokens) => opt.hooks ? opt.hooks.processAllTokens(tokens) : tokens).then((tokens) => opt.walkTokens ? Promise.all(this.walkTokens(tokens, opt.walkTokens)).then(() => tokens) : tokens).then((tokens) => parser2(tokens, opt)).then((html2) => opt.hooks ? opt.hooks.postprocess(html2) : html2).catch(throwError); + } + try { + if (opt.hooks) { + src = opt.hooks.preprocess(src); + } + let tokens = lexer2(src, opt); + if (opt.hooks) { + tokens = opt.hooks.processAllTokens(tokens); + } + if (opt.walkTokens) { + this.walkTokens(tokens, opt.walkTokens); + } + let html2 = parser2(tokens, opt); + if (opt.hooks) { + html2 = opt.hooks.postprocess(html2); + } + return html2; + } catch (e) { + return throwError(e); + } + }; + return parse2; + } + onError(silent, async) { + return (e) => { + e.message += "\nPlease report this to https://github.com/markedjs/marked."; + if (silent) { + const msg = "

    An error occurred:

    " + escape2(e.message + "", true) + "
    "; + if (async) { + return Promise.resolve(msg); + } + return msg; + } + if (async) { + return Promise.reject(e); + } + throw e; + }; + } +}; + +// src/marked.ts +var markedInstance = new Marked(); +function marked(src, opt) { + return markedInstance.parse(src, opt); +} +marked.options = marked.setOptions = function(options2) { + markedInstance.setOptions(options2); + marked.defaults = markedInstance.defaults; + changeDefaults(marked.defaults); + return marked; +}; +marked.getDefaults = _getDefaults; +marked.defaults = _defaults; +marked.use = function(...args) { + markedInstance.use(...args); + marked.defaults = markedInstance.defaults; + changeDefaults(marked.defaults); + return marked; +}; +marked.walkTokens = function(tokens, callback) { + return markedInstance.walkTokens(tokens, callback); +}; +marked.parseInline = markedInstance.parseInline; +marked.Parser = _Parser; +marked.parser = _Parser.parse; +marked.Renderer = _Renderer; +marked.TextRenderer = _TextRenderer; +marked.Lexer = _Lexer; +marked.lexer = _Lexer.lex; +marked.Tokenizer = _Tokenizer; +marked.Hooks = _Hooks; +marked.parse = marked; +var options = marked.options; +var setOptions = marked.setOptions; +var use = marked.use; +var walkTokens = marked.walkTokens; +var parseInline = marked.parseInline; +var parse = marked; +var parser = _Parser.parse; +var lexer = _Lexer.lex; + +if(__exports != exports)module.exports = exports;return module.exports})); +//# sourceMappingURL=marked.umd.js.map diff --git a/.pi/extensions/webui/public/settings.html b/.pi/extensions/webui/public/settings.html new file mode 100644 index 00000000..98bde48e --- /dev/null +++ b/.pi/extensions/webui/public/settings.html @@ -0,0 +1,86 @@ + + + + + +设置 - 萌小芽 + + + + + + + + +
    +
    +
    + +
    +

    设置

    +

    萌小芽 Agent

    +
    +
    +
    返回聊天 +
    + +
    +
    + + +
    +
    +
    +
    +

    系统提示词

    +

    +
    + +
    + +
    +
    + +
    +
    +
    +

    Skills

    +

    +
    + +
    +
    +
    加载中...
    +
    +
    + +
    +
    +
    +

    插件扩展

    +

    +
    + +
    +
    +
    加载中...
    +
    +
    +
    +
    +
    +
    + + + + diff --git a/.pi/extensions/webui/public/settings.js b/.pi/extensions/webui/public/settings.js new file mode 100644 index 00000000..25e8649c --- /dev/null +++ b/.pi/extensions/webui/public/settings.js @@ -0,0 +1,177 @@ +"use strict"; + +const systemPromptInput = document.getElementById("systemPromptInput"); +const systemPromptPath = document.getElementById("systemPromptPath"); +const saveSystemPromptBtn = document.getElementById("saveSystemPromptBtn"); +const refreshSettingsBtn = document.getElementById("refreshSettingsBtn"); +const settingsStatus = document.getElementById("settingsStatus"); +const skillsList = document.getElementById("skillsList"); +const skillsCount = document.getElementById("skillsCount"); +const extensionsList = document.getElementById("extensionsList"); +const extensionsMeta = document.getElementById("extensionsMeta"); +const settingsNavBtns = document.querySelectorAll(".settings-nav-btn"); +const settingsPanes = document.querySelectorAll(".settings-pane"); + +const SETTINGS_PANE_KEY = "sproutclaw-settings-pane"; + +function escHtml(s) { + const div = document.createElement("div"); + div.textContent = s; + return div.innerHTML; +} + +function setSettingsStatus(text, kind = "") { + if (!settingsStatus) return; + settingsStatus.textContent = text || ""; + settingsStatus.className = `settings-status${kind ? ` ${kind}` : ""}`; +} + +function showSettingsPane(paneId) { + const id = ["prompt", "skills", "extensions"].includes(paneId) ? paneId : "prompt"; + settingsNavBtns.forEach((btn) => { + const active = btn.dataset.pane === id; + btn.classList.toggle("is-active", active); + btn.setAttribute("aria-selected", active ? "true" : "false"); + }); + settingsPanes.forEach((pane) => { + const active = pane.id === `pane-${id}`; + pane.classList.toggle("is-active", active); + pane.toggleAttribute("hidden", !active); + }); + try { + sessionStorage.setItem(SETTINGS_PANE_KEY, id); + } catch { + /* ignore */ + } +} + +function initSettingsNav() { + settingsNavBtns.forEach((btn) => { + btn.addEventListener("click", () => showSettingsPane(btn.dataset.pane)); + }); + let initial = "prompt"; + try { + const saved = sessionStorage.getItem(SETTINGS_PANE_KEY); + if (["prompt", "skills", "extensions"].includes(saved)) initial = saved; + } catch { + /* ignore */ + } + showSettingsPane(initial); +} + +async function loadSettings() { + if (skillsList) skillsList.innerHTML = '
    加载中...
    '; + if (extensionsList) extensionsList.innerHTML = '
    加载中...
    '; + setSettingsStatus(""); + try { + const res = await fetch("/api/settings"); + const data = await res.json().catch(() => ({})); + if (!res.ok || data.error) throw new Error(data.error || res.statusText); + if (systemPromptInput) systemPromptInput.value = data.systemPrompt || ""; + if (systemPromptPath) systemPromptPath.textContent = data.systemPromptPath || ""; + renderSkills(data.skills || []); + renderExtensions(data.extensions || [], data.extensionsPath || ""); + } catch (err) { + if (skillsList) skillsList.innerHTML = `
    加载失败: ${escHtml(err.message)}
    `; + if (extensionsList) extensionsList.innerHTML = `
    加载失败: ${escHtml(err.message)}
    `; + setSettingsStatus(`加载设置失败: ${err.message}`, "error"); + } +} + +function renderSkills(skills) { + if (!skillsList) return; + if (skillsCount) skillsCount.textContent = `${skills.length} 个已加载`; + if (!skills.length) { + skillsList.innerHTML = '
    暂无已加载 skills
    '; + return; + } + + skillsList.innerHTML = skills.map((skill) => { + const meta = [skill.scope, skill.source].filter(Boolean).join(" · "); + const path = skill.path || ""; + return `
    +
    +
    ${escHtml(skill.name || skill.command || "未命名")}
    + ${meta ? `
    ${escHtml(meta)}
    ` : ""} +
    + ${skill.description ? `
    ${escHtml(skill.description)}
    ` : ""} + ${path ? `
    ${escHtml(path)}
    ` : ""} +
    `; + }).join(""); +} + +function renderExtensions(extensions, extensionsPath) { + if (!extensionsList) return; + if (extensionsMeta) { + extensionsMeta.textContent = extensionsPath + ? `${extensions.length} 个已加载 · ${extensionsPath}` + : `${extensions.length} 个已加载`; + } + if (!extensions.length) { + extensionsList.innerHTML = '
    暂无已加载插件扩展
    '; + return; + } + + extensionsList.innerHTML = extensions.map((extension) => { + const meta = extension.kind || [extension.scope, extension.source].filter(Boolean).join(" · "); + const counts = [ + [`命令`, extension.commands?.length || 0], + [`工具`, extension.tools?.length || 0], + [`事件`, extension.handlers?.length || 0], + [`Flags`, extension.flags?.length || 0], + [`快捷键`, extension.shortcuts?.length || 0], + ].filter(([, count]) => count > 0); + const hasDetails = ["commands", "tools", "handlers", "flags", "shortcuts"].some((key) => extension[key]?.length); + return `
    +
    +
    ${escHtml(extension.name || extension.path || "未命名")}
    + ${meta ? `
    ${escHtml(meta)}
    ` : ""} +
    + ${counts.length ? `
    ${counts.map(([label, count]) => `${escHtml(label)} ${count}`).join("")}
    ` : ""} + ${hasDetails ? `
    + 查看注册项 + ${renderNameList("命令", extension.commands)} + ${renderNameList("工具", extension.tools)} + ${renderNameList("事件", extension.handlers)} + ${renderNameList("Flags", extension.flags)} + ${renderNameList("快捷键", extension.shortcuts)} +
    ` : ""} + ${extension.location ? `
    ${escHtml(extension.location)}
    ` : ""} +
    `; + }).join(""); +} + +function renderNameList(label, items) { + if (!Array.isArray(items) || !items.length) return ""; + return `
    + ${escHtml(label)} +
    ${items.map((item) => `${escHtml(item)}`).join("")}
    +
    `; +} + +async function saveSystemPrompt() { + if (!systemPromptInput || !saveSystemPromptBtn) return; + saveSystemPromptBtn.disabled = true; + setSettingsStatus("保存中..."); + try { + const res = await fetch("/api/settings/system-prompt", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ systemPrompt: systemPromptInput.value }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok || data.error) throw new Error(data.error || res.statusText); + if (systemPromptPath && data.systemPromptPath) systemPromptPath.textContent = data.systemPromptPath; + setSettingsStatus("已保存,Agent 已重新加载", "ok"); + } catch (err) { + setSettingsStatus(`保存失败: ${err.message}`, "error"); + } finally { + saveSystemPromptBtn.disabled = false; + } +} + +initSettingsNav(); +saveSystemPromptBtn?.addEventListener("click", saveSystemPrompt); +refreshSettingsBtn?.addEventListener("click", loadSettings); +document.getElementById("refreshExtensionsBtn")?.addEventListener("click", loadSettings); +loadSettings(); diff --git a/.pi/extensions/webui/public/style.css b/.pi/extensions/webui/public/style.css new file mode 100644 index 00000000..75bcd478 --- /dev/null +++ b/.pi/extensions/webui/public/style.css @@ -0,0 +1,1500 @@ +:root { + --app-height: 100vh; + /* + * 全站字体栈(改 font-family 族前须先明确需求)。 + * UI:有网时 index.html 外链 Inter + Noto Sans SC;外链失败则仅用后续系统无衬线。 + * 拉丁优先 Inter,中文缺字形由 Noto Sans SC 补。 + * 代码:行内/块 code 与 pre → Source Code Pro(Google 文件名 source-code-pro)+ Menlo … + */ + --font-ui: Inter, "Noto Sans SC", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", + "Segoe UI", system-ui, sans-serif; + --font-mono: "Source Code Pro", Menlo, Monaco, Consolas, "Courier New", monospace; +} + +/* ─── 重置与基础 ──────────────────────────────────────────────────── */ + +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + font-size: 16px; + -webkit-text-size-adjust: 100%; + font-family: var(--font-ui); +} + +body { + font-family: inherit; + background: + radial-gradient(circle at top left, rgba(37, 99, 235, 0.06), transparent 28%), + linear-gradient(180deg, #f7f8fb 0%, #f2f4f8 100%); + color: #1a1a1a; + height: var(--app-height); + overflow: hidden; +} + +body.settings-page { + display: flex; + flex-direction: column; + min-height: var(--app-height); + height: var(--app-height); + overflow: hidden; + background: #fff; +} + +button, +input, +textarea, +select, +optgroup { + font-family: inherit; +} + +[hidden] { + display: none !important; +} + +/* 全文等宽:
    (含 fenced 与非 Markdown 片段) */
    +code,
    +kbd,
    +samp,
    +pre {
    +  font-family: var(--font-mono);
    +}
    +
    +/* highlight.js github 主题会写 SFMono/ui-monospace;统一回站点等宽栈 */
    +.hljs,
    +pre code.hljs,
    +code.hljs {
    +  font-family: var(--font-mono) !important;
    +}
    +
    +/* ─── 布局 ─────────────────────────────────────────────────────────── */
    +
    +#app {
    +  display: flex;
    +  height: var(--app-height);
    +  min-height: 0;
    +}
    +
    +/* ─── 侧边栏 ───────────────────────────────────────────────────────── */
    +
    +#sidebar {
    +  width: 236px;
    +  background: rgba(255, 255, 255, 0.94);
    +  backdrop-filter: blur(10px);
    +  border-right: 1px solid #e5e5e5;
    +  display: flex;
    +  flex-direction: column;
    +  flex-shrink: 0;
    +  z-index: 100;
    +  transition: transform 0.25s ease;
    +}
    +
    +.sidebar-header {
    +  padding: 18px 16px 10px;
    +  border-bottom: 1px solid #f0f0f0;
    +}
    +
    +.sidebar-brand {
    +  display: flex;
    +  align-items: center;
    +  gap: 10px;
    +  min-width: 0;
    +}
    +
    +.sidebar-logo {
    +  width: 36px;
    +  height: 36px;
    +  object-fit: contain;
    +  flex-shrink: 0;
    +}
    +
    +.sidebar-title {
    +  font-size: 17px;
    +  font-weight: 700;
    +  color: #1a1a1a;
    +  letter-spacing: -0.3px;
    +  margin: 0;
    +  min-width: 0;
    +  overflow: hidden;
    +  text-overflow: ellipsis;
    +  white-space: nowrap;
    +}
    +
    +.sidebar-subtitle {
    +  font-size: 12px;
    +  color: #999;
    +  margin-top: 2px;
    +}
    +
    +/* ─── 会话列表 ─────────────────────────────────────────────────────── */
    +
    +.sidebar-section {
    +  flex: 1;
    +  display: flex;
    +  flex-direction: column;
    +  min-height: 0;
    +  overflow: hidden;
    +}
    +
    +.section-header {
    +  display: flex;
    +  align-items: center;
    +  justify-content: space-between;
    +  padding: 10px 16px 6px;
    +  font-size: 11px;
    +  font-weight: 600;
    +  color: #999;
    +  text-transform: uppercase;
    +  letter-spacing: 0.5px;
    +}
    +
    +.section-refresh {
    +  background: none;
    +  border: none;
    +  color: #bbb;
    +  cursor: pointer;
    +  padding: 2px;
    +  border-radius: 4px;
    +  display: flex;
    +  transition: color 0.15s, background 0.15s;
    +}
    +
    +.section-refresh:hover {
    +  color: #666;
    +  background: #f0f0f0;
    +}
    +
    +.session-list {
    +  flex: 1;
    +  overflow-y: auto;
    +  padding: 0 8px 6px;
    +}
    +
    +.session-list::-webkit-scrollbar { width: 4px; }
    +.session-list::-webkit-scrollbar-thumb { background: #ddd; border-radius: 2px; }
    +
    +.session-empty {
    +  padding: 16px 10px;
    +  color: #bbb;
    +  font-size: 12px;
    +  text-align: center;
    +}
    +
    +/* 会话条目 */
    +.session-item {
    +  display: grid;
    +  grid-template-columns: minmax(0, 1fr) 28px;
    +  align-items: center;
    +  width: 100%;
    +  background: transparent;
    +  border: none;
    +  border-radius: 10px;
    +  transition: background 0.15s;
    +  margin-bottom: 3px;
    +}
    +
    +.session-item:hover { background: #f5f5f5; }
    +.session-item.active { background: #f0f7ff; }
    +
    +.session-item-main {
    +  min-width: 0;
    +  text-align: left;
    +  padding: 8px 4px 8px 10px;
    +  background: transparent;
    +  border: none;
    +  cursor: pointer;
    +}
    +
    +.session-item-name {
    +  font-size: 13px;
    +  font-weight: 500;
    +  color: #1a1a1a;
    +  white-space: nowrap;
    +  overflow: hidden;
    +  text-overflow: ellipsis;
    +}
    +
    +.session-item-meta {
    +  font-size: 10px;
    +  color: #bbb;
    +  white-space: nowrap;
    +  overflow: hidden;
    +  text-overflow: ellipsis;
    +  margin-top: 4px;
    +}
    +
    +.session-delete-btn {
    +  width: 24px;
    +  height: 24px;
    +  display: flex;
    +  align-items: center;
    +  justify-content: center;
    +  justify-self: end;
    +  margin-right: 4px;
    +  background: transparent;
    +  border: none;
    +  border-radius: 6px;
    +  color: #bbb;
    +  cursor: pointer;
    +  opacity: 0;
    +  transition: background 0.15s, color 0.15s, opacity 0.15s;
    +}
    +
    +.session-item:hover .session-delete-btn,
    +.session-item.active .session-delete-btn,
    +.session-delete-btn:focus-visible {
    +  opacity: 1;
    +}
    +
    +.session-delete-btn:hover {
    +  color: #dc2626;
    +  background: #fee2e2;
    +}
    +
    +/* ─── 侧边栏操作按钮 ─────────────────────────────────────────────── */
    +
    +.sidebar-actions {
    +  padding: 8px 10px 6px;
    +  display: flex;
    +  flex-direction: column;
    +  gap: 2px;
    +}
    +
    +.sidebar-actions button {
    +  display: flex;
    +  align-items: center;
    +  gap: 8px;
    +  width: 100%;
    +  padding: 8px 10px;
    +  background: transparent;
    +  border: none;
    +  border-radius: 10px;
    +  color: #555;
    +  font-size: 13px;
    +  cursor: pointer;
    +  transition: background 0.15s, color 0.15s;
    +}
    +
    +.sidebar-actions button:hover {
    +  background: #f0f0f0;
    +  color: #1a1a1a;
    +}
    +
    +.sidebar-actions button svg {
    +  flex-shrink: 0;
    +}
    +
    +/* ─── 侧边栏底部状态 ──────────────────────────────────────────────── */
    +
    +.sidebar-status {
    +  padding: 12px 16px;
    +  border-top: 1px solid #f0f0f0;
    +}
    +
    +.status-row {
    +  display: flex;
    +  align-items: center;
    +  gap: 8px;
    +  font-size: 12px;
    +  color: #888;
    +}
    +
    +.status-dot {
    +  width: 8px;
    +  height: 8px;
    +  border-radius: 50%;
    +  flex-shrink: 0;
    +}
    +
    +.status-dot.connected { background: #22c55e; }
    +.status-dot.streaming { background: #f59e0b; animation: pulse 1.2s ease-in-out infinite; }
    +.status-dot.disconnected { background: #ef4444; }
    +
    +.status-model {
    +  font-size: 10px;
    +  color: #bbb;
    +  margin-top: 4px;
    +  white-space: nowrap;
    +  overflow: hidden;
    +  text-overflow: ellipsis;
    +}
    +
    +@keyframes pulse {
    +  0%, 100% { opacity: 1; }
    +  50% { opacity: 0.4; }
    +}
    +
    +/* ─── 移动端遮罩层 ────────────────────────────────────────────────── */
    +
    +.sidebar-overlay {
    +  display: none;
    +  position: fixed;
    +  inset: 0;
    +  background: rgba(0,0,0,0.3);
    +  z-index: 99;
    +}
    +
    +/* ─── 主区域 ───────────────────────────────────────────────────────── */
    +
    +#main {
    +  flex: 1;
    +  display: flex;
    +  flex-direction: column;
    +  min-width: 0;
    +  min-height: 0;
    +  background: #fff;
    +}
    +
    +/* ─── 顶部栏 ───────────────────────────────────────────────────────── */
    +
    +#header {
    +  display: flex;
    +  align-items: center;
    +  gap: 10px;
    +  padding: 12px 16px;
    +  border-bottom: 1px solid #f0f0f0;
    +  flex-shrink: 0;
    +  background: rgba(255, 255, 255, 0.92);
    +  backdrop-filter: blur(10px);
    +  flex-wrap: wrap;
    +}
    +
    +.header-trailing {
    +  margin-left: auto;
    +  display: flex;
    +  flex-direction: row;
    +  align-items: center;
    +  justify-content: flex-end;
    +  gap: 8px;
    +  flex-shrink: 0;
    +  max-width: min(68%, 680px);
    +  min-width: 0;
    +}
    +
    +.menu-btn {
    +  display: none;
    +  width: 34px;
    +  height: 34px;
    +  align-items: center;
    +  justify-content: center;
    +  background: transparent;
    +  border: none;
    +  border-radius: 8px;
    +  color: #555;
    +  cursor: pointer;
    +  flex-shrink: 0;
    +}
    +
    +.menu-btn:hover { background: #f0f0f0; color: #1a1a1a; }
    +
    +.header-info {
    +  flex: 1 1 auto;
    +  min-width: 0;
    +}
    +
    +.header-info h1 {
    +  font-size: 15px;
    +  font-weight: 600;
    +  color: #1a1a1a;
    +}
    +
    +.header-meta {
    +  font-size: 11px;
    +  color: #999;
    +}
    +
    +.header-btn {
    +  display: flex;
    +  align-items: center;
    +  gap: 6px;
    +  padding: 7px 12px;
    +  background: #fef2f2;
    +  border: 1px solid #fecaca;
    +  border-radius: 8px;
    +  color: #dc2626;
    +  font-size: 12px;
    +  cursor: pointer;
    +  transition: background 0.15s;
    +  flex-shrink: 0;
    +}
    +
    +.header-btn:hover { background: #fee2e2; }
    +
    +.model-controls {
    +  display: flex;
    +  align-items: center;
    +  gap: 6px;
    +  min-width: 0;
    +}
    +
    +.model-select,
    +.thinking-select {
    +  height: 30px;
    +  border: 1px solid #e5e7eb;
    +  border-radius: 8px;
    +  background: #fff;
    +  color: #374151;
    +  font-size: 11px;
    +  outline: none;
    +}
    +
    +.model-select {
    +  width: min(260px, 28vw);
    +}
    +
    +.thinking-select {
    +  width: 86px;
    +}
    +
    +.model-select:focus,
    +.thinking-select:focus {
    +  border-color: #93c5fd;
    +  box-shadow: 0 0 0 2px rgba(147, 197, 253, 0.22);
    +}
    +
    +.model-select:disabled,
    +.thinking-select:disabled {
    +  color: #9ca3af;
    +  background: #f9fafb;
    +  cursor: not-allowed;
    +}
    +
    +/* ─── 主区上下文条(与 TUI Footer 统计行同源:get_state.stats + model) ─ */
    +
    +.session-context-bar {
    +  flex-shrink: 0;
    +  padding: 6px 16px 7px;
    +  border-bottom: 1px solid #f0f0f0;
    +  background: rgba(250, 251, 253, 0.97);
    +  font-size: 11px;
    +  line-height: 1.42;
    +  color: #5c6578;
    +}
    +
    +/* 并入顶栏右上角:不占整宽独立一行 */
    +.session-context-bar.session-context-in-header {
    +  border: none;
    +  background: transparent;
    +  padding: 0;
    +  max-width: 100%;
    +  text-align: right;
    +}
    +
    +.session-context-in-header .session-context-row {
    +  justify-content: flex-end;
    +}
    +
    +.session-context-in-header.session-context-bar .session-context-primary-wrap .session-context-row {
    +  flex-direction: column;
    +  align-items: flex-end;
    +  gap: 2px;
    +}
    +
    +.session-context-row {
    +  display: flex;
    +  flex-wrap: wrap;
    +  align-items: baseline;
    +  justify-content: space-between;
    +  gap: 10px;
    +}
    +
    +.session-context-left {
    +  flex: 1;
    +  min-width: 0;
    +  font-variant-numeric: tabular-nums;
    +  word-break: break-word;
    +}
    +
    +.session-context-in-header .session-context-left,
    +.session-context-in-header .session-context-right {
    +  flex: 0 1 auto;
    +  max-width: 100%;
    +  text-align: right;
    +}
    +
    +.session-context-right {
    +  flex-shrink: 0;
    +  text-align: right;
    +  opacity: 0.95;
    +  max-width: 46%;
    +}
    +
    +#sessionContextSecondary {
    +  margin-top: 3px;
    +  font-size: 10px;
    +  color: #8b95a8;
    +}
    +
    +.session-context-in-header #sessionContextSecondary {
    +  margin-top: 2px;
    +}
    +
    +#sessionContextSecondary:empty {
    +  display: none;
    +}
    +
    +.session-context-bar .ctx-warn {
    +  color: #b45309;
    +  font-weight: 500;
    +}
    +
    +.session-context-bar .ctx-danger {
    +  color: #b91c1c;
    +  font-weight: 500;
    +}
    +
    +/* ─── 聊天区域 ─────────────────────────────────────────────────────── */
    +
    +#chat {
    +  flex: 1;
    +  overflow-y: auto;
    +  padding: 12px 16px 10px;
    +  display: flex;
    +  flex-direction: column;
    +  gap: 6px;
    +  min-height: 0;
    +}
    +
    +#chat::-webkit-scrollbar { width: 6px; }
    +#chat::-webkit-scrollbar-track { background: transparent; }
    +#chat::-webkit-scrollbar-thumb { background: #ddd; border-radius: 3px; }
    +#chat::-webkit-scrollbar-thumb:hover { background: #ccc; }
    +
    +/* 空状态 */
    +.empty-state {
    +  display: flex;
    +  flex-direction: column;
    +  align-items: center;
    +  justify-content: center;
    +  flex: 1;
    +  color: #bbb;
    +  text-align: center;
    +  gap: 8px;
    +}
    +
    +.empty-icon { color: #ddd; }
    +.empty-state p { font-size: 13px; }
    +.empty-hint { font-size: 11px; color: #ccc; }
    +
    +/* ─── 消息 ─────────────────────────────────────────────────────────── */
    +
    +.msg {
    +  max-width: min(700px, 100%);
    +  padding: 10px 12px;
    +  line-height: 1.4;
    +  font-size: 16px;
    +  word-wrap: break-word;
    +  white-space: pre-wrap;
    +}
    +
    +.msg.user {
    +  align-self: flex-end;
    +  background: linear-gradient(180deg, #f0f7ff 0%, #eaf2ff 100%);
    +  border: 1px solid #dbeafe;
    +  border-radius: 14px 14px 4px 14px;
    +  margin: 0 0 2px;
    +  color: #1a1a1a;
    +}
    +
    +.msg.assistant {
    +  align-self: flex-start;
    +  margin: 0;
    +  padding: 0;
    +  color: #1a1a1a;
    +  line-height: 1.32;
    +}
    +
    +.msg.system {
    +  align-self: center;
    +  color: #aaa;
    +  font-size: 11px;
    +  text-align: center;
    +  padding: 6px 10px;
    +}
    +
    +.msg.thinking {
    +  align-self: flex-start;
    +  color: #888;
    +  font-size: 13px;
    +  font-style: italic;
    +  padding: 8px 0 8px 16px;
    +  border-left: 3px solid #e5e5e5;
    +  margin: 0;
    +}
    +
    +.msg.tool_call {
    +  align-self: flex-start;
    +  font-size: 13px;
    +  font-family: var(--font-mono);
    +  color: #666;
    +  padding: 0;
    +  margin: 0 0 4px 12px;
    +  background: #fafafa;
    +  border-radius: 10px;
    +  border: 1px solid #eee;
    +  line-height: 1.4;
    +  max-width: min(680px, 100%);
    +}
    +
    +.tool-call-collapsible {
    +  margin: 0;
    +}
    +
    +.tool-call-collapsible > .tool-call-summary {
    +  list-style: none;
    +  cursor: pointer;
    +  display: flex;
    +  align-items: center;
    +  gap: 6px;
    +  padding: 6px 10px;
    +  user-select: none;
    +  font-weight: 500;
    +  color: #555;
    +}
    +
    +.tool-call-collapsible > .tool-call-summary::-webkit-details-marker {
    +  display: none;
    +}
    +
    +.tool-call-summary::before {
    +  content: "";
    +  width: 0;
    +  height: 0;
    +  border-left: 5px solid #888;
    +  border-top: 3.5px solid transparent;
    +  border-bottom: 3.5px solid transparent;
    +  flex-shrink: 0;
    +  transform: rotate(0deg);
    +  transition: transform 0.15s ease;
    +}
    +
    +.tool-call-collapsible[open] > .tool-call-summary::before {
    +  transform: rotate(90deg);
    +}
    +
    +.tool-call-summary-text {
    +  min-width: 0;
    +  flex: 1;
    +  overflow: hidden;
    +  text-overflow: ellipsis;
    +  white-space: nowrap;
    +}
    +
    +.tool-call-detail {
    +  margin: 0;
    +  padding: 6px 10px 8px;
    +  border-top: 1px solid #eee;
    +}
    +
    +.tool-call-detail-pre {
    +  margin: 0;
    +  padding: 0;
    +  white-space: pre-wrap;
    +  word-break: break-word;
    +  font-size: 12px;
    +  line-height: 1.45;
    +}
    +
    +.msg.tool_result {
    +  align-self: flex-start;
    +  font-size: 12px;
    +  font-family: var(--font-mono);
    +  color: #666;
    +  padding: 8px 12px;
    +  margin: 0 0 2px 14px;
    +  background: #fafafa;
    +  border-radius: 10px;
    +  border: 1px solid #eee;
    +  max-height: 200px;
    +  overflow-y: auto;
    +}
    +
    +/* ─── Markdown 渲染内容 ──────────────────────────────────────────── */
    +
    +.msg.assistant h1, .msg.assistant h2, .msg.assistant h3,
    +.msg.assistant h4, .msg.assistant h5, .msg.assistant h6 {
    +  margin: 0.2em 0 0.04em;
    +  font-weight: 600;
    +  line-height: 1.25;
    +}
    +.msg.assistant h1 { font-size: 1.22em; }
    +.msg.assistant h2 { font-size: 1.12em; }
    +.msg.assistant h3 { font-size: 1.04em; }
    +.msg.assistant h4 { font-size: 1em; }
    +
    +.msg.assistant p { margin: 0.03em 0; }
    +.msg.assistant p:first-child { margin-top: 0; }
    +.msg.assistant p:last-child { margin-bottom: 0; }
    +
    +.msg.assistant strong { font-weight: 600; }
    +.msg.assistant em { font-style: italic; }
    +
    +.msg.assistant a {
    +  color: #2563eb;
    +  text-decoration: none;
    +}
    +.msg.assistant a:hover { text-decoration: underline; }
    +
    +.msg.assistant ul, .msg.assistant ol {
    +  margin: 0.02em 0;
    +  padding-left: 1.05em;
    +}
    +.msg.assistant li {
    +  margin: 0;
    +}
    +.msg.assistant li + li {
    +  margin-top: 0.04em;
    +}
    +
    +/* marked 常在 
  • 内包一层或多层

    ,会与 li 边距叠加出大块留白 */ +.msg.assistant li > p { + margin: 0; +} +.msg.assistant li > p + p { + margin-top: 0.06em; +} + +.msg.assistant li ul, +.msg.assistant li ol { + margin: 0.03em 0 0.06em; +} + +.msg.assistant blockquote { + margin: 0.08em 0; + padding: 2px 8px; + border-left: 2px solid #e5e5e5; + color: #666; +} + +.msg.assistant hr { + border: none; + border-top: 1px solid #eee; + margin: 0.2em 0; +} + +.msg.assistant table { + border-collapse: collapse; + margin: 0.06em 0; + font-size: 13px; + width: 100%; +} +.msg.assistant th, .msg.assistant td { + border: 1px solid #e5e5e5; + padding: 4px 7px; + text-align: left; +} +.msg.assistant th { + background: #fafafa; + font-weight: 600; +} + +.msg.assistant code:not(.hljs) { + font-size: 0.9em; + background: #f5f5f5; + padding: 2px 6px; + border-radius: 4px; + color: #d63384; + word-break: break-word; +} + +.msg.assistant pre.assistant-pre { + margin: 0.35em 0; + padding: 0; + background: transparent; + border: 1px solid #e8edf2; + border-radius: 8px; + overflow-x: auto; + line-height: 1.5; +} + +.msg.assistant pre.assistant-pre code.hljs { + display: block; + padding: 12px 14px; + background: none; + border: none; + color: inherit; + font-size: 14px; + line-height: 1.5; + word-break: normal; + tab-size: 2; +} + +.msg.assistant img { + max-width: 100%; + border-radius: 8px; + margin: 0.06em 0; +} + +/* 流式光标 */ +.msg.streaming::after { + content: "▊"; + animation: blink 0.8s step-end infinite; + color: #2563eb; + margin-left: 1px; +} + +@keyframes blink { 50% { opacity: 0; } } + +/* 消息分隔线 */ +.msg.assistant + .msg.assistant { + border-top: 1px solid #f0f0f0; + padding-top: 4px; +} + +/* ─── 设置页 ─────────────────────────────────────────────────────── */ + +.settings-view { + flex: 1; + min-height: 0; + overflow-y: auto; + background: #fff; + padding: 18px 18px 22px; +} + +.settings-page-main { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + width: 100%; + padding: 0; +} + +.settings-page-header { + flex-shrink: 0; + width: 100%; + margin: 0; + padding: max(14px, env(safe-area-inset-top)) max(18px, env(safe-area-inset-right)) 14px max(18px, env(safe-area-inset-left)); + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + border-bottom: 1px solid #eef0f3; + background: #fafbfc; +} + +.settings-page-title { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.settings-page-title h1 { + font-size: 18px; + font-weight: 700; + color: #111827; + line-height: 1.2; +} + +.settings-page-title p { + margin-top: 2px; + font-size: 12px; + color: #8b95a8; +} + +.settings-page-shell { + flex: 1; + min-height: 0; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; +} + +.settings-link-btn { + display: inline-flex; + align-items: center; + justify-content: center; + text-decoration: none; +} + +/* 设置页:侧栏 + 单栏内容(移动端侧栏改为顶部分段) */ +.settings-layout { + flex: 1; + min-height: 0; + width: 100%; + display: flex; + flex-direction: row; + align-items: stretch; + overflow: hidden; +} + +.settings-sidebar { + flex-shrink: 0; + width: 220px; + display: flex; + flex-direction: column; + gap: 6px; + padding: 14px 12px; + padding-left: max(12px, env(safe-area-inset-left)); + background: #f4f6f9; + border-right: 1px solid #e5e7eb; +} + +.settings-nav-btn { + display: flex; + align-items: center; + width: 100%; + margin: 0; + padding: 11px 14px; + border: 1px solid transparent; + border-radius: 10px; + background: transparent; + font-family: inherit; + font-size: 14px; + font-weight: 500; + color: #4b5563; + text-align: left; + cursor: pointer; + transition: background 0.15s, border-color 0.15s, color 0.15s, box-shadow 0.15s; + touch-action: manipulation; +} + +.settings-nav-btn:hover { + background: rgba(255, 255, 255, 0.72); + color: #111827; +} + +.settings-nav-btn.is-active { + background: #fff; + color: #2563eb; + border-color: #e5e7eb; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06); +} + +.settings-nav-btn:focus-visible { + outline: 2px solid rgba(37, 99, 235, 0.45); + outline-offset: 2px; +} + +.settings-nav-btn-label { + min-width: 0; +} + +.settings-panes { + flex: 1; + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + padding: 14px max(14px, env(safe-area-inset-right)) max(14px, env(safe-area-inset-bottom)) 14px; +} + +.settings-pane:not(.is-active) { + display: none !important; +} + +.settings-pane.is-active { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +.settings-panel { + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #fff; + min-width: 0; + min-height: 0; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.settings-panel-header { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + row-gap: 8px; + padding: 12px 14px; + border-bottom: 1px solid #eef0f3; + background: #fafbfc; +} + +.settings-panel-header h2 { + font-size: 14px; + line-height: 1.3; + font-weight: 600; + color: #111827; +} + +.settings-panel-header p { + margin-top: 3px; + font-size: 11px; + color: #8b95a8; + word-break: break-all; +} + +.settings-primary-btn, +.settings-secondary-btn { + height: 30px; + padding: 0 12px; + border-radius: 7px; + font-size: 12px; + font-weight: 500; + cursor: pointer; + flex-shrink: 0; + transition: background 0.15s, border-color 0.15s, color 0.15s, opacity 0.15s; +} + +.settings-primary-btn { + color: #fff; + background: #2563eb; + border: 1px solid #2563eb; +} + +.settings-primary-btn:hover { + background: #1d4ed8; + border-color: #1d4ed8; +} + +.settings-primary-btn:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.settings-secondary-btn { + color: #374151; + background: #fff; + border: 1px solid #d1d5db; +} + +.settings-secondary-btn:hover { + background: #f3f4f6; +} + +.settings-textarea { + display: block; + width: 100%; + flex: 1 1 auto; + min-height: 0; + padding: 14px; + border: none; + outline: none; + resize: none; + color: #111827; + background: #fff; + font-family: var(--font-mono); + font-size: 13px; + line-height: 1.55; + white-space: pre; + overflow: auto; +} + +.settings-textarea:focus { + box-shadow: inset 0 0 0 2px rgba(37, 99, 235, 0.18); +} + +.settings-status { + flex-shrink: 0; + min-height: 30px; + padding: 7px 14px 9px; + border-top: 1px solid #eef0f3; + color: #8b95a8; + font-size: 12px; +} + +.settings-status.ok { + color: #15803d; +} + +.settings-status.error { + color: #b91c1c; +} + +.skills-list, +.extensions-list { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + padding: 8px; +} + +.skill-item, +.extension-item { + padding: 10px 10px 9px; + border: 1px solid #edf0f4; + border-radius: 8px; + background: #fff; +} + +.skill-item + .skill-item, +.extension-item + .extension-item { + margin-top: 7px; +} + +.skill-title-row, +.extension-title-row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + min-width: 0; +} + +.skill-name, +.extension-name { + min-width: 0; + color: #111827; + font-size: 13px; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.skill-meta, +.extension-meta { + flex-shrink: 0; + color: #9ca3af; + font-size: 10px; +} + +.skill-desc { + margin-top: 5px; + color: #4b5563; + font-size: 12px; + line-height: 1.45; +} + +.skill-path { + margin-top: 6px; + color: #9ca3af; + font-family: var(--font-mono); + font-size: 10px; + line-height: 1.4; + word-break: break-all; +} + +.extension-counts { + display: flex; + flex-wrap: wrap; + gap: 5px; + margin-top: 7px; +} + +.extension-counts span { + display: inline-flex; + align-items: center; + height: 20px; + padding: 0 7px; + border-radius: 999px; + background: #f3f4f6; + color: #4b5563; + font-size: 11px; + line-height: 1; +} + +.extension-details { + margin-top: 8px; + border-top: 1px solid #f1f3f6; + padding-top: 7px; +} + +.extension-details > summary { + cursor: pointer; + color: #6b7280; + font-size: 11px; + user-select: none; +} + +.extension-details > summary:hover { + color: #2563eb; +} + +.extension-name-list { + margin-top: 8px; + display: grid; + grid-template-columns: 42px minmax(0, 1fr); + gap: 8px; + align-items: start; + font-size: 11px; + color: #8b95a8; +} + +.extension-name-list > div { + display: flex; + flex-wrap: wrap; + gap: 5px; + min-width: 0; +} + +.extension-name-list code { + padding: 2px 6px; + border-radius: 5px; + background: #f8fafc; + border: 1px solid #edf0f4; + color: #374151; + font-family: var(--font-mono); + font-size: 10px; + word-break: break-all; +} + +.extension-path { + margin-top: 8px; + color: #9ca3af; + font-family: var(--font-mono); + font-size: 10px; + line-height: 1.4; + word-break: break-all; +} + +.settings-empty { + padding: 20px 10px; + color: #9ca3af; + font-size: 12px; + text-align: center; +} + +/* ─── 输入区域 ─────────────────────────────────────────────────────── */ + +#inputArea { + padding: 10px 16px 12px; + border-top: 1px solid #f0f0f0; + flex-shrink: 0; + background: rgba(255, 255, 255, 0.92); + backdrop-filter: blur(10px); +} + +.input-wrapper { + display: flex; + gap: 8px; + align-items: flex-end; + max-width: 720px; + margin: 0 auto; + background: #f6f7fb; + border: 1px solid #e5e5e5; + border-radius: 14px; + padding: 4px 4px 4px 12px; + transition: border-color 0.15s; +} + +.input-wrapper:focus-within { + border-color: #2563eb; + background: #fff; +} + +#input { + flex: 1; + background: transparent; + border: none; + color: #1a1a1a; + padding: 8px 0; + font-size: 16px; + font-family: inherit; + resize: none; + outline: none; + min-height: 24px; + max-height: 120px; + line-height: 1.45; +} + +#input::placeholder { color: #bbb; } + +#sendBtn { + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + background: #2563eb; + border: none; + border-radius: 11px; + color: #fff; + cursor: pointer; + flex-shrink: 0; + transition: background 0.15s, opacity 0.15s; + touch-action: manipulation; +} + +#sendBtn:hover { background: #1d4ed8; } +#sendBtn:disabled { background: #d1d5db; cursor: not-allowed; } + +/* ─── 移动端适配 ──────────────────────────────────────────────────── */ + +@media (max-width: 768px) { + body, + #app { + height: var(--app-height); + } + + #sidebar { + position: fixed; + top: 0; + left: 0; + bottom: 0; + transform: translateX(-100%); + } + + #sidebar.open { + transform: translateX(0); + } + + .sidebar-overlay.open { + display: block; + } + + .menu-btn { + display: flex; + } + + #header { + padding: 10px 12px; + align-items: flex-start; + } + + .header-trailing { + max-width: 100%; + width: 100%; + flex-basis: 100%; + margin-left: 0; + justify-content: flex-start; + padding-left: 44px; + box-sizing: border-box; + flex-wrap: wrap; + } + + .model-controls { + width: 100%; + } + + .model-select { + width: min(100%, 260px); + flex: 1 1 180px; + } + + .session-context-bar:not(.session-context-in-header) { + padding: 5px 12px 6px; + font-size: 10px; + } + + .session-context-in-header { + font-size: 10px; + } + + #sessionContextSecondary { + font-size: 9px; + } + + #chat { + padding: 10px 12px 8px; + } + + .settings-view { + padding: 12px; + } + + .settings-page-main { + padding: 0; + } + + .settings-page-header { + align-items: flex-start; + padding-top: max(12px, env(safe-area-inset-top)); + padding-bottom: 12px; + padding-left: max(12px, env(safe-area-inset-left)); + padding-right: max(12px, env(safe-area-inset-right)); + } + + .settings-layout { + flex-direction: column; + } + + .settings-sidebar { + width: 100%; + flex-direction: row; + align-items: stretch; + gap: 8px; + padding: 10px max(12px, env(safe-area-inset-left)) 10px max(12px, env(safe-area-inset-right)); + border-right: none; + border-bottom: 1px solid #e5e7eb; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + } + + .settings-sidebar::-webkit-scrollbar { + height: 0; + width: 0; + } + + .settings-nav-btn { + flex: 1 1 0; + min-width: 0; + justify-content: center; + text-align: center; + padding: 12px 8px; + font-size: 13px; + } + + .settings-panes { + flex: 1; + min-height: 0; + padding: 10px max(10px, env(safe-area-inset-left)) max(12px, env(safe-area-inset-bottom)) max(10px, env(safe-area-inset-right)); + } + + .settings-textarea { + font-size: 12px; + } + + .msg { + padding: 11px 12px; + font-size: 16px; + } + + .msg.user { + max-width: 88%; + } + + .msg.assistant { + padding-left: 0; + } + + .msg.tool_call, + .msg.tool_result { + margin-left: 0; + font-size: 12px; + } + + #inputArea { + position: sticky; + bottom: 0; + z-index: 5; + padding: 8px 10px calc(10px + env(safe-area-inset-bottom)); + } + + .input-wrapper { + padding: 2px 2px 2px 10px; + } +} + +@media (max-width: 480px) { + .msg.user { + max-width: 92%; + padding: 10px 12px; + font-size: 16px; + } + + .msg.assistant { + font-size: 16px; + padding: 10px 0; + } +} + +/* ─── 动效偏好 ───────────────────────────────────────────────────── */ + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + } +} diff --git a/.pi/extensions/webui/server.ts b/.pi/extensions/webui/server.ts new file mode 100644 index 00000000..d03a13f1 --- /dev/null +++ b/.pi/extensions/webui/server.ts @@ -0,0 +1,552 @@ +#!/usr/bin/env node + +/** + * pi-mono WebUI Server + * + * 被 webui 扩展启动,作为独立子进程运行。 + * 内嵌 HTTP 服务 + pi RPC 子进程,提供浏览器聊天界面。 + * + * 用法(由扩展自动调用): + * tsx server.ts --port 19133 + * + * 前端静态文件位于同目录 public/ 下。 + */ + +import { spawn } from "node:child_process"; +import { createServer } from "node:http"; +import { readFileSync, readdirSync, statSync, existsSync, unlinkSync, writeFileSync, mkdirSync } from "node:fs"; +import { join, dirname, resolve, basename } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const PUBLIC_DIR = join(__dirname, "public"); + +// 从 CWD 确定 repo 根目录(启动时 CWD 为 repo 根目录) +const REPO_ROOT = process.cwd(); +const SESSIONS_DIR = join(REPO_ROOT, ".pi", "sessions"); +const PROJECT_EXTENSIONS_DIR = join(REPO_ROOT, ".pi", "extensions"); +const SYSTEM_PROMPT_FILE = join(REPO_ROOT, ".pi", "agent", "AGENTS.md"); +const TSX_BIN = join(REPO_ROOT, "node_modules", ".bin", "tsx"); + +// ─── 解析 CLI 参数 ───────────────────────────────────────────────── + +const args = process.argv.slice(2); +function getArg(flag: string): string | undefined { + const idx = args.indexOf(flag); + return idx !== -1 ? args[idx + 1] : undefined; +} +const PORT = parseInt(getArg("--port") || "19133", 10); + +// ─── 启动 pi RPC 子进程 ──────────────────────────────────────────── + +const piArgs = [ + join(REPO_ROOT, "packages", "coding-agent", "src", "cli.ts"), + "--mode", "rpc", +]; + +console.log(`[webui] 启动 pi RPC: ${TSX_BIN} ${piArgs.join(" ")}`); + +const pi = spawn(TSX_BIN, piArgs, { + cwd: REPO_ROOT, + stdio: ["pipe", "pipe", "pipe"], + env: { + ...process.env, + PI_CODING_AGENT_DIR: join(REPO_ROOT, ".pi", "agent"), + }, +}); + +pi.stderr.on("data", (data) => process.stderr.write(`[pi] ${data}`)); +pi.on("exit", (code) => { + console.log(`[webui] pi 退出, code=${code}`); + process.exit(1); +}); + +// ─── JSONL 行协议 ────────────────────────────────────────────────── + +let buffer = ""; +const pending = new Map void; reject: (e: Error) => void }>(); +const sseClients = new Set(); +let reqId = 0; + +function onLine(line: string) { + if (!line.trim()) return; + try { + const msg = JSON.parse(line); + if (msg.type === "response" && msg.id && pending.has(msg.id)) { + const p = pending.get(msg.id)!; + pending.delete(msg.id); + p.resolve(msg); + return; + } + const data = `data: ${JSON.stringify(msg)}\n\n`; + for (const res of sseClients) res.write(data); + } catch { + // 忽略非 JSON 行 + } +} + +pi.stdout.on("data", (chunk: Buffer) => { + buffer += chunk.toString(); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + for (const line of lines) onLine(line); +}); + +function sendCmd(command: Record): Promise { + return new Promise((resolve, reject) => { + const id = `req_${++reqId}`; + const line = JSON.stringify({ ...command, id }) + "\n"; + pending.set(id, { resolve, reject }); + pi.stdin.write(line); + setTimeout(() => { + if (pending.has(id)) { + pending.delete(id); + reject(new Error(`命令超时: ${command.type}`)); + } + }, 60000); + }); +} + +// ─── 会话文件读取 ────────────────────────────────────────────────── + +function listSessionFiles(): string[] { + if (!existsSync(SESSIONS_DIR)) return []; + return readdirSync(SESSIONS_DIR) + .filter((f) => f.endsWith(".jsonl")) + .sort() + .reverse() + .map((f) => join(SESSIONS_DIR, f)); +} + +function readSessionSummary(filePath: string): Record | null { + try { + const content = readFileSync(filePath, "utf8"); + const lines = content.trim().split("\n"); + if (!lines.length) return null; + const header = JSON.parse(lines[0]); + if (header.type !== "session") return null; + + let nameFromInfo = ""; + let messageCount = 0; + let firstMessage = ""; + const stats = statSync(filePath); + + for (const line of lines) { + if (!line.trim()) continue; + try { + const entry = JSON.parse(line); + if (entry.type === "session_info" && entry.name) { + const n = String(entry.name).trim(); + if (n && !isMachineSessionLabel(n, header.id)) { + nameFromInfo = n; + } + } + if (entry.type === "message") { + messageCount++; + if (!firstMessage && entry.message?.role === "user") { + firstMessage = extractPreview(entry.message); + } + } + } catch { /* skip */ } + } + + const fromFirstUser = titleFromFirstUserMessage(firstMessage); + let name = nameFromInfo; + if (!name || isMachineSessionLabel(name, header.id)) { + name = fromFirstUser || ""; + } + + return { + path: filePath, + id: header.id, + name, + created: header.timestamp, + modified: stats.mtime.toISOString(), + messageCount, + firstMessage: firstMessage || "(空)", + }; + } catch { + return null; + } +} + +function isMachineSessionLabel(text: string, sessionHeaderId: string): boolean { + const t = (text ?? "").trim(); + if (!t) return true; + if (sessionHeaderId && t === sessionHeaderId) return true; + if (/^[0-9a-f]{8,}$/i.test(t)) return true; + if (/^[0-9]{10,}$/.test(t)) return true; + return false; +} + +/** 侧边栏标题:优先用户首句(遇句号等截断),否则整段截取 */ +function titleFromFirstUserMessage(text: string, maxChars = 56): string { + const cleaned = String(text ?? "").replace(/\s+/g, " ").trim(); + if (!cleaned) return ""; + const sentenceMatch = cleaned.match(/^(.+?[。!?.!?])(\s|$)/); + let candidate = + sentenceMatch && sentenceMatch[1] ? sentenceMatch[1].trim() : cleaned; + if (candidate.length > maxChars) { + candidate = `${candidate.slice(0, maxChars).trimEnd()}…`; + } + return candidate; +} + +function readSessionMessages(filePath: string): unknown[] { + try { + const content = readFileSync(filePath, "utf8"); + return content + .trim() + .split("\n") + .map((l) => { try { const e = JSON.parse(l); return e.type === "message" ? e.message : null; } catch { return null; } }) + .filter(Boolean); + } catch { + return []; + } +} + +function extractPreview(msg: any): string { + const c = msg.content; + if (!c) return ""; + if (typeof c === "string") return c.slice(0, 200); + if (Array.isArray(c)) return c.filter((x: any) => x.type === "text").map((x: any) => x.text).join("").slice(0, 200); + return ""; +} + +function resolveSessionFile(filePath: string): string { + const resolved = resolve(filePath); + const sessionsRoot = resolve(SESSIONS_DIR); + if (!resolved.startsWith(`${sessionsRoot}/`) || !resolved.endsWith(".jsonl")) { + throw new Error("无效会话路径"); + } + return resolved; +} + +function readSystemPrompt(): string { + if (!existsSync(SYSTEM_PROMPT_FILE)) return ""; + return readFileSync(SYSTEM_PROMPT_FILE, "utf8"); +} + +function writeSystemPrompt(content: string): void { + mkdirSync(dirname(SYSTEM_PROMPT_FILE), { recursive: true }); + writeFileSync(SYSTEM_PROMPT_FILE, content, "utf8"); +} + +function normalizeSkillCommand(command: any): Record { + const rawName = String(command.name || ""); + const sourceInfo = command.sourceInfo || {}; + return { + name: rawName.replace(/^skill:/, ""), + command: rawName, + description: command.description || "", + scope: sourceInfo.scope || "", + source: sourceInfo.source || "", + path: sourceInfo.path || "", + }; +} + +async function listLoadedSkills(): Promise[]> { + const response = await sendCmd({ type: "get_commands" }); + if (!response.success) throw new Error(response.error || "读取 skills 失败"); + const commands = response.data?.commands || []; + return commands + .filter((command: any) => command?.source === "skill") + .map(normalizeSkillCommand) + .sort((a: any, b: any) => String(a.name).localeCompare(String(b.name))); +} + +function normalizeExtension(extension: any): Record { + const sourceInfo = extension.sourceInfo || {}; + const pathValue = String(extension.path || ""); + const source = String(sourceInfo.source || ""); + const resolvedPath = String(extension.resolvedPath || ""); + return { + name: displayExtensionName(pathValue, source), + rawName: extension.name || "", + path: pathValue, + resolvedPath, + scope: sourceInfo.scope || "", + source, + sourcePath: sourceInfo.path || "", + location: displayExtensionLocation(pathValue, resolvedPath), + kind: displayExtensionKind(sourceInfo.scope, source), + commands: extension.commands || [], + tools: extension.tools || [], + flags: extension.flags || [], + shortcuts: extension.shortcuts || [], + handlers: extension.handlers || [], + }; +} + +function isProjectExtension(extension: any): boolean { + const pathValue = String(extension.path || extension.resolvedPath || ""); + if (!pathValue) return false; + const resolved = resolve(pathValue); + const projectExtensionsRoot = resolve(PROJECT_EXTENSIONS_DIR); + return resolved === projectExtensionsRoot || resolved.startsWith(`${projectExtensionsRoot}/`); +} + +function displayExtensionName(extensionPath: string, source: string): string { + const sourceMatch = source.match(/^npm:(.+)$/); + if (sourceMatch?.[1]) return sourceMatch[1]; + + const normalized = extensionPath.replace(/\\/g, "/"); + const parts = normalized.split("/").filter(Boolean); + const file = parts[parts.length - 1] || normalized; + if (/^index\.[tj]s$/i.test(file) && parts.length >= 2) { + return cleanExtensionName(parts[parts.length - 2]); + } + return cleanExtensionName(file); +} + +function cleanExtensionName(name: string): string { + return basename(name).replace(/\.[cm]?[tj]s$/i, ""); +} + +function displayExtensionKind(scope: string, source: string): string { + const scopeText = scope === "project" ? "项目" : scope === "user" ? "用户" : scope || "未知"; + if (source.startsWith("npm:")) return `${scopeText} NPM`; + if (source === "auto") return `${scopeText}本地`; + return source ? `${scopeText} · ${source}` : scopeText; +} + +function displayExtensionLocation(extensionPath: string, resolvedPath: string): string { + const pathValue = extensionPath || resolvedPath; + if (!pathValue) return ""; + return pathValue.replace(REPO_ROOT, "."); +} + +async function listLoadedExtensions(): Promise[]> { + const response = await sendCmd({ type: "get_extensions" }); + if (!response.success) throw new Error(response.error || "读取扩展失败"); + return (response.data?.extensions || []) + .filter(isProjectExtension) + .map(normalizeExtension) + .sort((a: any, b: any) => String(a.name).localeCompare(String(b.name))); +} + +// ─── HTTP 服务 ───────────────────────────────────────────────────── + +const MIME: Record = { + ".html": "text/html; charset=utf-8", + ".js": "application/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".png": "image/png", + ".svg": "image/svg+xml", +}; + +function serveStatic(urlPath: string, res: any): void { + const file = urlPath === "/" ? "/index.html" : urlPath; + const full = join(PUBLIC_DIR, file); + if (!full.startsWith(PUBLIC_DIR)) { res.writeHead(403); res.end("Forbidden"); return; } + if (!existsSync(full)) { res.writeHead(404); res.end("Not found"); return; } + const ext = file.match(/\.\w+$/)?.[0] || ".html"; + res.writeHead(200, { "Content-Type": MIME[ext] || "application/octet-stream" }); + res.end(readFileSync(full)); +} + +function json(res: any, data: unknown, status = 200): void { + res.writeHead(status, { "Content-Type": "application/json" }); + res.end(JSON.stringify(data)); +} + +function readBody(req: any): Promise> { + return new Promise((resolve, reject) => { + let body = ""; + req.on("data", (c: Buffer) => (body += c.toString())); + req.on("end", () => { try { resolve(JSON.parse(body)); } catch { reject(new Error("无效 JSON")); } }); + req.on("error", reject); + }); +} + +const server = createServer((req, res) => { + const url = new URL(req.url!, `http://localhost:${PORT}`); + + // 静态文件 + if (req.method === "GET" && !url.pathname.startsWith("/api/")) { + return serveStatic(url.pathname, res); + } + + // POST /api/chat + if (req.method === "POST" && url.pathname === "/api/chat") { + return readBody(req) + .then(({ message }) => sendCmd({ type: "prompt", message }).then(() => json(res, { ok: true }))) + .catch((err) => json(res, { error: err.message }, 500)); + } + + // GET /api/events (SSE) + if (req.method === "GET" && url.pathname === "/api/events") { + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }); + res.write('data: {"type":"connected"}\n\n'); + sseClients.add(res); + req.on("close", () => sseClients.delete(res)); + return; + } + + // POST /api/messages + if (req.method === "POST" && url.pathname === "/api/messages") { + return sendCmd({ type: "get_messages" }) + .then((r: any) => (r.success ? json(res, r.data) : json(res, { error: r.error }, 500))) + .catch((err) => json(res, { error: err.message }, 500)); + } + + // POST /api/new-session + if (req.method === "POST" && url.pathname === "/api/new-session") { + return sendCmd({ type: "new_session" }) + .then((r: any) => (r.success ? json(res, r.data) : json(res, { error: r.error }, 500))) + .catch((err) => json(res, { error: err.message }, 500)); + } + + // POST /api/abort + if (req.method === "POST" && url.pathname === "/api/abort") { + return sendCmd({ type: "abort" }) + .then(() => json(res, { ok: true })) + .catch((err) => json(res, { error: err.message }, 500)); + } + + // GET /api/models + if (req.method === "GET" && url.pathname === "/api/models") { + return sendCmd({ type: "get_available_models" }) + .then((r: any) => (r.success ? json(res, r.data) : json(res, { error: r.error }, 500))) + .catch((err) => json(res, { error: err.message }, 500)); + } + + // POST /api/model + if (req.method === "POST" && url.pathname === "/api/model") { + return readBody(req) + .then(({ provider, modelId }) => + sendCmd({ type: "set_model", provider, modelId }).then((r: any) => + r.success ? json(res, { model: r.data }) : json(res, { error: r.error }, 500), + ), + ) + .catch((err) => json(res, { error: err.message }, 500)); + } + + // POST /api/thinking + if (req.method === "POST" && url.pathname === "/api/thinking") { + return readBody(req) + .then(({ level }) => + sendCmd({ type: "set_thinking_level", level }).then((r: any) => + r.success ? json(res, { ok: true }) : json(res, { error: r.error }, 500), + ), + ) + .catch((err) => json(res, { error: err.message }, 500)); + } + + // GET /api/settings + if (req.method === "GET" && url.pathname === "/api/settings") { + return Promise.all([listLoadedSkills(), listLoadedExtensions()]) + .then(([skills, extensions]) => json(res, { + systemPrompt: readSystemPrompt(), + systemPromptPath: SYSTEM_PROMPT_FILE, + extensionsPath: PROJECT_EXTENSIONS_DIR, + skills, + extensions, + })) + .catch((err) => json(res, { error: err.message }, 500)); + } + + // POST /api/settings/system-prompt + if (req.method === "POST" && url.pathname === "/api/settings/system-prompt") { + return readBody(req) + .then(async ({ systemPrompt }) => { + if (typeof systemPrompt !== "string") { + throw new Error("systemPrompt 必须是字符串"); + } + writeSystemPrompt(systemPrompt); + const reload = await sendCmd({ type: "reload" }); + if (!reload.success) throw new Error(reload.error || "Agent 重新加载失败"); + json(res, { ok: true, systemPromptPath: SYSTEM_PROMPT_FILE }); + }) + .catch((err) => json(res, { error: err.message }, 500)); + } + + // GET /api/sessions + if (req.method === "GET" && url.pathname === "/api/sessions") { + const summaries = listSessionFiles().map(readSessionSummary).filter(Boolean); + return json(res, { sessions: summaries }); + } + + // POST /api/sessions/history + if (req.method === "POST" && url.pathname === "/api/sessions/history") { + return readBody(req) + .then(({ path: sp }) => { + const messages = readSessionMessages(sp as string); + const summary = readSessionSummary(sp as string); + json(res, { messages, session: summary }); + }) + .catch((err) => json(res, { error: err.message }, 500)); + } + + // POST /api/sessions/delete + if (req.method === "POST" && url.pathname === "/api/sessions/delete") { + return readBody(req) + .then(({ path: sp }) => { + const sessionPath = resolveSessionFile(sp as string); + if (!existsSync(sessionPath)) throw new Error("会话不存在"); + unlinkSync(sessionPath); + json(res, { ok: true }); + }) + .catch((err) => json(res, { error: err.message }, 500)); + } + + // POST /api/sessions/load + if (req.method === "POST" && url.pathname === "/api/sessions/load") { + return readBody(req) + .then(async ({ path: sp }) => { + const sw = await sendCmd({ type: "switch_session", sessionPath: sp, cwdOverride: REPO_ROOT }); + if (!sw.success) throw new Error(sw.error); + const mr = await sendCmd({ type: "get_messages" }); + if (!mr.success) throw new Error(mr.error); + const summary = readSessionSummary(sp as string); + json(res, { messages: mr.data.messages, session: summary }); + }) + .catch((err) => json(res, { error: err.message }, 500)); + } + + // POST /api/sessions/activate + if (req.method === "POST" && url.pathname === "/api/sessions/activate") { + return readBody(req) + .then(async ({ path: sp }) => { + const sw = await sendCmd({ type: "switch_session", sessionPath: sp, cwdOverride: REPO_ROOT }); + if (!sw.success) throw new Error(sw.error); + json(res, { ok: true }); + }) + .catch((err) => json(res, { error: err.message }, 500)); + } + + // POST /api/sessions/name + if (req.method === "POST" && url.pathname === "/api/sessions/name") { + return readBody(req) + .then(({ name }) => sendCmd({ type: "set_session_name", name }).then(() => json(res, { ok: true }))) + .catch((err) => json(res, { error: err.message }, 500)); + } + + // GET /api/session-state + if (req.method === "GET" && url.pathname === "/api/session-state") { + return sendCmd({ type: "get_state" }) + .then((r: any) => (r.success ? json(res, r.data) : json(res, { error: r.error }, 500))) + .catch((err) => json(res, { error: err.message }, 500)); + } + + res.writeHead(404); + res.end("Not found"); +}); + +server.on("error", (err: NodeJS.ErrnoException) => { + if (err.code === "EADDRINUSE") { + console.error(`[webui] 端口 ${PORT} 已被占用`); + } else { + console.error(`[webui] HTTP 服务启动失败: ${err.message}`); + } + process.exit(1); +}); + +server.listen(PORT, "0.0.0.0", () => { + console.log(`[webui] HTTP 服务已启动: http://localhost:${PORT}`); + console.log(`[webui] 局域网访问: http://smallmengya:${PORT}`); +}); diff --git a/README.md b/README.md index 90d9fc10..e33ac5b0 100644 --- a/README.md +++ b/README.md @@ -1,77 +1,57 @@ -

    - - pi logo - -

    -

    - Discord -

    -

    - pi.dev domain graciously donated by -

    - Exy mascot
    exe.dev
    -

    +# sproutclaw -> New issues and PRs from new contributors are auto-closed by default. Maintainers review auto-closed issues daily. See [CONTRIBUTING.md](CONTRIBUTING.md). +`sproutclaw` 是一个面向服务器运维与开发工作的 Agent 助手,基于 pi-mono 改造,重点服务于日常服务器管理、Docker 部署、项目开发、故障排查和自动化工作流。 ---- +## 定位 -# Pi Agent Harness Mono Repo +- 服务器运维助手:协助 SSH 登录、服务状态检查、日志分析、配置调整和部署排障。 +- 开发协作助手:理解本地项目结构,执行代码修改、测试验证和 Git 工作流。 +- Docker 部署助手:按项目目录组织 `docker compose` 服务,关注数据持久化、端口规划和资源限制。 +- 内网服务助手:适配 smallmengya、bigmengya、alycd 等内网服务器使用习惯。 +- WebUI/TUI 双入口:保留控制台 TUI,同时扩展网页前端,方便在浏览器里管理会话和模型。 -This is the home of the pi agent harness project including our self extensible coding agent. +## 项目结构 -* **[@earendil-works/pi-coding-agent](packages/coding-agent)**: Interactive coding agent CLI -* **[@earendil-works/pi-agent-core](packages/agent)**: Agent runtime with tool calling and state management -* **[@earendil-works/pi-ai](packages/ai)**: Unified multi-provider LLM API (OpenAI, Anthropic, Google, …) +| 路径 | 说明 | +| --- | --- | +| `packages/coding-agent` | 交互式 Agent CLI 与 TUI 主体 | +| `packages/agent` | Agent 运行时、工具调用和状态管理 | +| `packages/ai` | 多模型、多 provider 的 LLM 接入层 | +| `packages/tui` | 终端 UI 渲染库 | +| `packages/web-ui` | Web UI 组件 | +| `.pi/extensions/webui` | 本地扩展的网页对话入口 | -To learn more about pi: - -* [Visit pi.dev](https://pi.dev), the project website with demos -* [Read the documentation](https://pi.dev/docs/latest), but you can also ask the agent to explain itself - -## Share your OSS coding agent sessions - -If you use pi or other coding agents for open source work, please share your sessions. - -Public OSS session data helps improve coding agents with real-world tasks, tool use, failures, and fixes instead of toy benchmarks. - -For the full explanation, see [this post on X](https://x.com/badlogicgames/status/2037811643774652911). - -To publish sessions, use [`badlogic/pi-share-hf`](https://github.com/badlogic/pi-share-hf). Read its README.md for setup instructions. All you need is a Hugging Face account, the Hugging Face CLI, and `pi-share-hf`. - -You can also watch [this video](https://x.com/badlogicgames/status/2041151967695634619), where I show how I publish my `pi-mono` sessions. - -I regularly publish my own `pi-mono` work sessions here: - -- [badlogicgames/pi-mono on Hugging Face](https://huggingface.co/datasets/badlogicgames/pi-mono) - -## All Packages - -| Package | Description | -|---------|-------------| -| **[@earendil-works/pi-ai](packages/ai)** | Unified multi-provider LLM API (OpenAI, Anthropic, Google, etc.) | -| **[@earendil-works/pi-agent-core](packages/agent)** | Agent runtime with tool calling and state management | -| **[@earendil-works/pi-coding-agent](packages/coding-agent)** | Interactive coding agent CLI | -| **[@earendil-works/pi-tui](packages/tui)** | Terminal UI library with differential rendering | -| **[@earendil-works/pi-web-ui](packages/web-ui)** | Web components for AI chat interfaces | - -For Slack/chat automation and workflows see [earendil-works/pi-chat](https://github.com/earendil-works/pi-chat). - -## Contributing - -See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution guidelines and [AGENTS.md](AGENTS.md) for project-specific rules (for both humans and agents). - -## Development +## 常用命令 ```bash -npm install # Install all dependencies -npm run build # Build all packages -npm run check # Lint, format, and type check -./test.sh # Run tests (skips LLM-dependent tests without API keys) -./pi-test.sh # Run pi from sources (can be run from any directory) +npm install +npm run build +npm run check +./test.sh +./pi-test.sh ``` -> **Note:** `npm run check` requires `npm run build` to be run first. The web-ui package uses `tsc` which needs compiled `.d.ts` files from dependencies. +运行本地源码版 Agent: + +```bash +./pi-test.sh +``` + +启动 WebUI 时,在 TUI 中执行: + +```text +/webui on 19133 +``` + +关闭 WebUI: + +```text +/webui off +``` + +## 说明 + +这个仓库是自用分支,默认会围绕树萌芽的服务器环境、部署规范和日常开发习惯进行调整。上游能力来自 pi-mono,后续会继续精简、增强并沉淀适合服务器运维开发场景的 Agent 工作流。 ## License diff --git a/packages/ai/src/env-api-keys.ts b/packages/ai/src/env-api-keys.ts index 846623aa..6ed76d41 100644 --- a/packages/ai/src/env-api-keys.ts +++ b/packages/ai/src/env-api-keys.ts @@ -90,7 +90,7 @@ function hasVertexAdcCredentials(): boolean { function getApiKeyEnvVars(provider: string): readonly string[] | undefined { if (provider === "github-copilot") { - return ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"]; + return ["COPILOT_GITHUB_TOKEN"]; } // ANTHROPIC_OAUTH_TOKEN takes precedence over ANTHROPIC_API_KEY diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 4985779f..3f08569f 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -876,6 +876,14 @@ export class AgentSession { return this.sessionManager.getSessionName(); } + /** + * Monotonic counter emitted on each completed turn ({@link TurnEndEvent.turnIndex}); resets on agent_start. + * Mirrors the value shown next to ✓ Turn N complete-style UI (N equals this counter after idle). + */ + get turnIndex(): number { + return this._turnIndex; + } + /** Scoped models for cycling (from --models flag) */ get scopedModels(): ReadonlyArray<{ model: Model; thinkingLevel?: ThinkingLevel }> { return this._scopedModels; diff --git a/packages/coding-agent/src/core/extensions/loader.ts b/packages/coding-agent/src/core/extensions/loader.ts index b2d7b275..45fddb82 100644 --- a/packages/coding-agent/src/core/extensions/loader.ts +++ b/packages/coding-agent/src/core/extensions/loader.ts @@ -13,7 +13,7 @@ import * as _bundledPiAi from "@earendil-works/pi-ai"; import * as _bundledPiAiOauth from "@earendil-works/pi-ai/oauth"; import type { KeyId } from "@earendil-works/pi-tui"; import * as _bundledPiTui from "@earendil-works/pi-tui"; -import { createJiti } from "jiti/static"; +import { createJiti } from "jiti"; // Static imports of packages that extensions may use. // These MUST be static so Bun bundles them into the compiled binary. // The virtualModules option then makes them available to extensions. diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index b10692ed..6204bf4e 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -25,6 +25,7 @@ import { type Theme, theme } from "../interactive/theme/theme.js"; import { attachJsonlLineReader, serializeJsonLine } from "./jsonl.js"; import type { RpcCommand, + RpcExtensionInfo, RpcExtensionUIRequest, RpcExtensionUIResponse, RpcResponse, @@ -442,10 +443,18 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise { + const name = extension.path.split(/[\\/]/).filter(Boolean).pop() || extension.path; + return { + name, + path: extension.path, + resolvedPath: extension.resolvedPath, + sourceInfo: extension.sourceInfo, + commands: Array.from(extension.commands.keys()).sort(), + tools: Array.from(extension.tools.keys()).sort(), + flags: Array.from(extension.flags.keys()).sort(), + shortcuts: Array.from(extension.shortcuts.keys()).map(String).sort(), + handlers: Array.from(extension.handlers.keys()).sort(), + }; + }); + + return success(id, "get_extensions", { extensions, errors: extensionsResult.errors }); + } + default: { const unknownCommand = command as { type: string }; return error(undefined, unknownCommand.type, `Unknown command: ${unknownCommand.type}`); diff --git a/packages/coding-agent/src/modes/rpc/rpc-types.ts b/packages/coding-agent/src/modes/rpc/rpc-types.ts index 43aeeeeb..340d945b 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-types.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-types.ts @@ -26,6 +26,7 @@ export type RpcCommand = // State | { id?: string; type: "get_state" } + | { id?: string; type: "reload" } // Model | { id?: string; type: "set_model"; provider: string; modelId: string } @@ -55,7 +56,7 @@ export type RpcCommand = // Session | { id?: string; type: "get_session_stats" } | { id?: string; type: "export_html"; outputPath?: string } - | { id?: string; type: "switch_session"; sessionPath: string } + | { id?: string; type: "switch_session"; sessionPath: string; cwdOverride?: string } | { id?: string; type: "fork"; entryId: string } | { id?: string; type: "clone" } | { id?: string; type: "get_fork_messages" } @@ -66,7 +67,8 @@ export type RpcCommand = | { id?: string; type: "get_messages" } // Commands (available for invocation via prompt) - | { id?: string; type: "get_commands" }; + | { id?: string; type: "get_commands" } + | { id?: string; type: "get_extensions" }; // ============================================================================ // RPC Slash Command (for get_commands response) @@ -84,6 +86,19 @@ export interface RpcSlashCommand { sourceInfo: SourceInfo; } +/** A loaded extension with basic inventory metadata */ +export interface RpcExtensionInfo { + name: string; + path: string; + resolvedPath: string; + sourceInfo: SourceInfo; + commands: string[]; + tools: string[]; + flags: string[]; + shortcuts: string[]; + handlers: string[]; +} + // ============================================================================ // RPC State // ============================================================================ @@ -101,6 +116,9 @@ export interface RpcSessionState { autoCompactionEnabled: boolean; messageCount: number; pendingMessageCount: number; + /** Mirrors {@link AgentSession.turnIndex} (ticks every turn_end, resets agent_start). */ + turnIndex: number; + stats: SessionStats; } // ============================================================================ @@ -118,6 +136,7 @@ export type RpcResponse = // State | { id?: string; type: "response"; command: "get_state"; success: true; data: RpcSessionState } + | { id?: string; type: "response"; command: "reload"; success: true } // Model | { @@ -201,6 +220,13 @@ export type RpcResponse = success: true; data: { commands: RpcSlashCommand[] }; } + | { + id?: string; + type: "response"; + command: "get_extensions"; + success: true; + data: { extensions: RpcExtensionInfo[]; errors: Array<{ path: string; error: string }> }; + } // Error response (any command can fail) | { id?: string; type: "response"; command: string; success: false; error: string }; diff --git a/pi-test.sh b/pi-test.sh index ece44929..b036b8fa 100755 --- a/pi-test.sh +++ b/pi-test.sh @@ -3,6 +3,11 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# Default to the repository-local pi config so this source checkout uses the +# copied learning config instead of ~/.pi. +export PI_CODING_AGENT_DIR="${PI_CODING_AGENT_DIR:-$SCRIPT_DIR/.pi/agent}" +export PI_CODING_AGENT_SESSION_DIR="${PI_CODING_AGENT_SESSION_DIR:-$SCRIPT_DIR/.pi/sessions}" + # Check for --no-env flag NO_ENV=false ARGS=()