From c02253451e7dfe677c3582b59016d4eff3ea4b80 Mon Sep 17 00:00:00 2001 From: FlashingChen2024 Date: Sun, 26 Jul 2026 14:31:04 +0800 Subject: [PATCH 1/8] docs: design one-click installer --- .../2026-07-26-one-click-installer-design.md | 127 ++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-26-one-click-installer-design.md diff --git a/docs/superpowers/specs/2026-07-26-one-click-installer-design.md b/docs/superpowers/specs/2026-07-26-one-click-installer-design.md new file mode 100644 index 0000000..073b527 --- /dev/null +++ b/docs/superpowers/specs/2026-07-26-one-click-installer-design.md @@ -0,0 +1,127 @@ +# OI.SKILL 一键安装脚本设计 + +## 目标 + +为没有预装 Node.js、npm 或 npx 的用户提供 macOS、Linux 和 Windows 一键安装入口。安装命令必须在用户执行前明确说明:**该命令会在缺少 Node.js 时安装 Node.js,然后通过 npx 安装 OI.SKILL。** + +## 范围 + +首版提供两个入口: + +- `install.sh`:支持 macOS 与主流 Linux 发行版。 +- `install.ps1`:支持 Windows PowerShell。 + +脚本负责: + +1. 检测 Node.js、npm 和 npx 是否均可用。 +2. 缺失时通过系统包管理器安装 Node.js。 +3. Node.js 工具链就绪后运行 `npx --yes skills add FlashingChen/oi.skill --skill oi-skill`。 +4. 任一步失败时立即停止,并输出失败环节及可执行的手动恢复命令。 + +脚本不负责维护 Node.js 版本、不安装版本管理器,也不修改 Agent 自身配置;skill 的目标发现和安装继续由 `skills` CLI 负责。 + +## 用户入口与告知 + +README 的“快速开始”首先展示警告,再展示命令。文案必须同时出现在命令前和脚本启动后的第一屏,避免通过 `curl | sh` 或 PowerShell 下载执行时遗漏告知: + +> 此安装命令会在你的电脑缺少 Node.js 时,通过系统包管理器安装 Node.js,然后使用 npx 安装 OI.SKILL。安装 Node.js 可能请求管理员权限。 + +推荐入口: + +```bash +curl -fsSL https://raw.githubusercontent.com/FlashingChen/oi.skill/main/install.sh | sh +``` + +```powershell +irm https://raw.githubusercontent.com/FlashingChen/oi.skill/main/install.ps1 | iex +``` + +保留现有 `npx skills add` 方式,供已经具备 Node.js 环境的用户使用;保留手动安装方式作为最终兜底。 + +## 安装流程 + +### 公共流程 + +1. 打印 Node.js 安装告知和可能的权限影响。 +2. 检测 `node`、`npm`、`npx` 三个命令。 +3. 三者均存在时,不修改 Node.js 安装,直接进入 skill 安装。 +4. 任一命令缺失时,进入对应平台的 Node.js 安装流程。 +5. 安装后重新检测三项命令;仍有缺失则失败退出。 +6. 执行固定参数的 `npx --yes skills add FlashingChen/oi.skill --skill oi-skill`。 +7. 成功后打印激活提示;失败时保留底层命令的错误输出并给出手动命令。 + +脚本不接受远程输入拼接到 shell 命令。仓库标识、skill 名称与所有包管理器参数均为常量。 + +### macOS + +1. 若 Homebrew 已存在,执行 `brew install node`。 +2. 若 Homebrew 不存在,先明确打印即将安装 Homebrew 及 Node.js,再调用 Homebrew 官方安装脚本。 +3. 在 Apple Silicon 和 Intel 环境下根据 Homebrew 输出加载其 `shellenv`,使当前脚本进程立即能找到 `node`、`npm` 与 `npx`。 +4. 执行公共后置检测和 skill 安装。 + +### Linux + +按顺序检测并使用首个可用包管理器: + +1. `apt-get` +2. `dnf` +3. `yum` +4. `pacman` +5. `zypper` + +安装映射: + +- apt:更新索引后安装 `nodejs npm`。 +- dnf/yum:安装 `nodejs npm`。 +- pacman:同步安装 `nodejs npm`。 +- zypper:安装 `nodejs npm`。 + +需要提权时使用 `sudo`;若当前用户为 root,则直接执行。没有可用包管理器或需要提权但 `sudo` 不存在时,停止并打印 Node.js 官方下载地址与重新运行脚本的命令。 + +### Windows + +1. 检测 `winget`。 +2. 使用 `winget install --id OpenJS.NodeJS.LTS --exact` 安装 Node.js LTS。 +3. 安装完成后刷新当前 PowerShell 进程的 PATH,再检测 `node.exe`、`npm.cmd` 与 `npx.cmd`。 +4. winget 不存在或安装后命令仍不可见时,停止并打印 Node.js 官方下载地址与重新运行脚本的命令。 + +## 错误处理 + +- shell 脚本启用未定义变量检查和管道失败传播;PowerShell 使用终止型错误。 +- 不隐藏包管理器、Homebrew 或 npx 的标准错误输出。 +- 错误消息包含:失败阶段、平台、下一条可执行命令。 +- 已存在 Node.js 但缺少 npm/npx 时,按工具链不完整处理并通过包管理器补齐,不覆盖或删除用户现有安装。 +- 不自动重试,不静默切换到来源不明的 Node.js 安装渠道。 +- `skills` CLI 失败时不卸载刚安装的 Node.js,因为 Node.js 是独立、可复用的系统工具;提示用户直接重试固定的 npx 命令。 + +## 文件职责 + +- `install.sh`:macOS/Linux 平台检测、Node.js 安装、工具链复检和 skills 调用。 +- `install.ps1`:Windows winget 安装、PATH 刷新、工具链复检和 skills 调用。 +- `tests/install-sh.test.sh`:通过隔离 PATH 和伪命令验证 Unix 分支,不对开发机执行真实安装。 +- `tests/install-ps1.Tests.ps1`:通过 PowerShell 测试夹具验证 Windows 分支,不对开发机执行真实安装。 +- `README.md`:明确风险告知、一键命令、已有 Node.js 的 npx 命令和手动安装兜底。 + +## 测试策略 + +测试验证可观察行为,而不是脚本文本: + +- Node.js 工具链齐全时跳过系统安装,只调用固定的 npx 命令。 +- macOS 有 Homebrew时通过 brew 安装 Node.js。 +- macOS 无 Homebrew 时先打印告知,再调用 Homebrew 官方安装入口。 +- Linux 对每个受支持包管理器调用正确的软件包与权限命令。 +- Linux 没有受支持包管理器时非零退出并打印恢复步骤。 +- Windows 工具链齐全时跳过 winget。 +- Windows 缺少工具链时调用准确的 Node.js LTS winget 包 ID。 +- Node.js 安装成功但工具链复检失败时,不运行 npx。 +- npx 安装失败时原样返回非零状态并打印手动重试命令。 + +完成自动化测试后,再分别进行 shell 语法检查和 PowerShell 语法加载。最终冒烟验证使用已具备 Node.js 的隔离环境执行脚本,确认真实 `skills` 安装路径可完成;不会为了验证缺失 Node.js 分支而改动开发机的系统安装。 + +## 安全与维护 + +- README 明确告知远程脚本及 Node.js 安装行为,用户也可先下载审阅再执行。 +- Homebrew 仅使用其官方安装 URL;Node.js 仅来自系统包管理器或 winget 的官方 LTS 标识。 +- 固定仓库与 skill 参数,避免命令注入面。 +- 不使用 `eval`,不持久修改 shell 配置,不自行下载和解压 Node 二进制。 +- 包管理器列表保持有限;新增发行版必须同时增加对应行为测试和 README 支持说明。 From 97cef2f21f12b29e10805f9a9b9b95c3d3bbb3ac Mon Sep 17 00:00:00 2001 From: FlashingChen2024 Date: Sun, 26 Jul 2026 14:35:05 +0800 Subject: [PATCH 2/8] docs: plan one-click installer --- .../plans/2026-07-26-one-click-installer.md | 539 ++++++++++++++++++ 1 file changed, 539 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-26-one-click-installer.md diff --git a/docs/superpowers/plans/2026-07-26-one-click-installer.md b/docs/superpowers/plans/2026-07-26-one-click-installer.md new file mode 100644 index 0000000..c7a915d --- /dev/null +++ b/docs/superpowers/plans/2026-07-26-one-click-installer.md @@ -0,0 +1,539 @@ +# OI.SKILL One-Click Installer Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add explicit, one-command macOS/Linux and Windows installers that install Node.js when required, then install OI.SKILL through `npx skills`. + +**Architecture:** Keep platform logic in two dependency-free entrypoints: POSIX `install.sh` and PowerShell `install.ps1`. Each script warns before making changes, skips Node.js installation when `node`, `npm`, and `npx` already work, uses only the approved platform package manager, revalidates the toolchain, and delegates Agent target discovery to the existing `skills` CLI. + +**Tech Stack:** POSIX shell, PowerShell 5.1+, system package managers, Node.js/npm/npx, fixture-based script tests. + +## Global Constraints + +- README and both scripts MUST state that the command may install Node.js before installing OI.SKILL. +- Supported systems are macOS, Linux, and Windows. +- macOS/Linux use `install.sh`; Windows uses `install.ps1`. +- Node.js installation is package-manager-first: Homebrew, apt-get, dnf, yum, pacman, zypper, or winget. +- Existing complete Node.js toolchains MUST NOT be modified. +- The final skill command is exactly `npx --yes skills add FlashingChen/oi.skill --skill oi-skill`. +- Scripts MUST stop on failure and print an actionable manual recovery command. +- Scripts MUST NOT persistently modify shell profiles, use `eval`, or download unofficial Node.js binaries. + +--- + +### Task 1: macOS and Linux installer + +**Files:** +- Create: `tests/install-sh.test.sh` +- Create: `install.sh` + +**Interfaces:** +- Consumes: platform commands exposed through `PATH`; no arguments. +- Produces: executable `install.sh`, exit code `0` on success, failing command's nonzero code on npx failure. + +- [ ] **Step 1: Write the failing Unix behavior test** + +Create `tests/install-sh.test.sh` as a dependency-free harness. It must create a temporary fake `PATH`, run `/bin/sh install.sh`, and assert these independent cases: + +```sh +#!/bin/sh +set -eu + +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +TMP=${TMPDIR:-/tmp}/oi-skill-install-test.$$ +LOG=$TMP/commands.log +trap 'rm -rf "$TMP"' EXIT HUP INT TERM +mkdir -p "$TMP/bin" + +fail() { printf 'FAIL: %s\n' "$1" >&2; exit 1; } +assert_contains() { grep -F "$2" "$1" >/dev/null || fail "missing: $2"; } +assert_not_contains() { ! grep -F "$2" "$1" >/dev/null || fail "unexpected: $2"; } + +make_command() { + name=$1 + body=$2 + cat >"$TMP/bin/$name" <"$LOG" +} + +make_toolchain() { + make_command node 'exit 0' + make_command npm 'exit 0' + make_command npx 'printf "npx %s\\n" "$*" >>"$OI_TEST_LOG"; exit "${OI_NPX_EXIT:-0}"' +} + +run_installer() { + OI_TEST_LOG=$LOG PATH="$TMP/bin:/usr/bin:/bin" /bin/sh "$ROOT/install.sh" >"$TMP/out" 2>&1 +} + +reset_case +make_command uname 'printf "Linux\\n"' +make_toolchain +run_installer +assert_contains "$LOG" 'npx --yes skills add FlashingChen/oi.skill --skill oi-skill' +assert_not_contains "$LOG" 'apt-get' + +reset_case +make_command uname 'printf "Linux\\n"' +make_command id 'printf "0\\n"' +make_command apt-get ' +printf "apt-get %s\\n" "$*" >>"$OI_TEST_LOG" +if [ "$1" = install ]; then + for tool in node npm; do printf "#!/bin/sh\\nexit 0\\n" >"${0%/*}/$tool"; chmod +x "${0%/*}/$tool"; done + cat >"${0%/*}/npx" < + + + diff --git a/app/src/renderer/renderer.js b/app/src/renderer/renderer.js new file mode 100644 index 0000000..c2d6952 --- /dev/null +++ b/app/src/renderer/renderer.js @@ -0,0 +1,546 @@ +const state = { + sessions: [], + activeId: null, + loading: false, + runtimeDir: "", + skillReady: false, + piCommand: "bundled PiAgent", + piBundled: false, + models: [], + activeModelId: "", + lastSource: null, + streamingAssistant: null, + inputMinHeight: 27, + inputMaxHeight: 170 +}; + +const $ = (selector) => document.querySelector(selector); +const sessionList = $("#session-list"); +const messages = $("#messages"); +const emptyState = $("#empty-state"); +const input = $("#message-input"); +const sendButton = $("#send-button"); +const composer = $("#composer"); +const settingsModal = $("#settings-modal"); + +function activeSession() { + return state.sessions.find((session) => session.id === state.activeId) || state.sessions[0] || null; +} + +function modeLabel(mode) { + return mode === "analyst" ? "分析师" : "讲师"; +} + +function formatTime(timestamp) { + const date = new Date(timestamp); + if (Number.isNaN(date.getTime())) return "刚刚"; + const today = new Date(); + if (date.toDateString() === today.toDateString()) { + return date.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" }); + } + return date.toLocaleDateString("zh-CN", { month: "numeric", day: "numeric" }); +} + +function escapeHtml(value) { + return String(value).replace(/[&<>"']/g, (character) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[character])); +} + +function renderInlineMarkdown(value) { + const protectedTokens = []; + const protect = (html) => { + protectedTokens.push(html); + return `\u0000${protectedTokens.length - 1}\u0000`; + }; + let html = escapeHtml(value); + html = html.replace(/`([^`\n]+)`/g, (_match, code) => protect(`${code}`)); + html = html.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, (_match, label, url) => protect(`${label}`)); + html = html.replace(/\*\*([^*\n]+)\*\*/g, "$1"); + html = html.replace(/__([^_\n]+)__/g, "$1"); + html = html.replace(/~~([^~\n]+)~~/g, "$1"); + html = html.replace(/\*([^*\n]+)\*/g, "$1"); + html = html.replace(/_([^_\n]+)_/g, "$1"); + return html.replace(/\u0000(\d+)\u0000/g, (_match, index) => protectedTokens[Number(index)]); +} + +function splitTableCells(line) { + let value = line.trim(); + if (value.startsWith("|")) value = value.slice(1); + if (value.endsWith("|") && !value.endsWith("\\|")) value = value.slice(0, -1); + const cells = []; + let cell = ""; + let inCode = false; + for (let index = 0; index < value.length; index += 1) { + const character = value[index]; + if (character === "`" && value[index - 1] !== "\\") inCode = !inCode; + if (character === "|" && !inCode && value[index - 1] !== "\\") { + cells.push(cell.trim()); + cell = ""; + } else { + cell += character; + } + } + cells.push(cell.trim()); + return cells.map((cellValue) => cellValue.replace(/\\\|/g, "|")); +} + +function isTableSeparator(line) { + const cells = splitTableCells(line); + return cells.length > 0 && cells.every((cell) => /^:?-{3,}:?$/.test(cell)); +} + +function tableAlignment(cell) { + if (cell.startsWith(":") && cell.endsWith(":")) return "center"; + if (cell.endsWith(":")) return "right"; + if (cell.startsWith(":")) return "left"; + return ""; +} + +function renderTable(lines, start) { + const headers = splitTableCells(lines[start]); + const separators = splitTableCells(lines[start + 1]); + const rows = []; + let index = start + 2; + while (index < lines.length && lines[index].trim() && lines[index].includes("|")) { + rows.push(splitTableCells(lines[index])); + index += 1; + } + const renderCell = (cell, cellIndex, tag) => { + const alignment = tableAlignment(separators[cellIndex] || ""); + const attribute = alignment ? ` style="text-align:${alignment}"` : ""; + return `<${tag}${attribute}>${renderInlineMarkdown(cell || "")}`; + }; + const headerHtml = headers.map((cell, cellIndex) => renderCell(cell, cellIndex, "th")).join(""); + const bodyHtml = rows.map((row) => { + const cells = headers.map((_header, cellIndex) => renderCell(row[cellIndex] || "", cellIndex, "td")).join(""); + return `${cells}`; + }).join(""); + return { + html: `
${headerHtml}${bodyHtml}
`, + nextIndex: index + }; +} + +function renderMarkdownFallback(value) { + const lines = String(value ?? "").replace(/\r\n?/g, "\n").split("\n"); + const blocks = []; + let index = 0; + while (index < lines.length) { + const line = lines[index]; + if (/^\s*```(?:[\w+-]+)?\s*$/.test(line)) { + const codeLines = []; + index += 1; + while (index < lines.length && !/^\s*```(?:[\w+-]+)?\s*$/.test(lines[index])) { + codeLines.push(lines[index]); + index += 1; + } + if (index < lines.length) index += 1; + blocks.push(`
${escapeHtml(codeLines.join("\n"))}
`); + continue; + } + if (index + 1 < lines.length && line.includes("|") && isTableSeparator(lines[index + 1])) { + const table = renderTable(lines, index); + blocks.push(table.html); + index = table.nextIndex; + continue; + } + const listItem = line.match(/^\s*(?:[-*]|\d+\.)\s+(.+)$/); + const heading = line.match(/^\s*#{1,6}\s+(.+)$/); + if (listItem) { + blocks.push(`• ${renderInlineMarkdown(listItem[1])}`); + } else if (heading) { + blocks.push(`${renderInlineMarkdown(heading[1])}`); + } else { + blocks.push(renderInlineMarkdown(line)); + } + index += 1; + } + return blocks.join("
"); +} + +function safeMarkdownUrl(value) { + const url = String(value || "").trim(); + return /^(?:https?:|mailto:|#)/i.test(url) ? url : ""; +} + +function createMarkdownRenderer(markedApi) { + const renderer = new markedApi.Renderer(); + const renderTable = renderer.table.bind(renderer); + renderer.html = ({ text }) => escapeHtml(text); + renderer.table = (token) => `
${renderTable(token)}
`; + renderer.link = function renderLink({ href, title, tokens }) { + const safeHref = safeMarkdownUrl(href); + const label = this.parser.parseInline(tokens); + if (!safeHref) return label; + const titleAttribute = title ? ` title="${escapeHtml(title)}"` : ""; + return `${label}`; + }; + renderer.image = ({ href, title, text }) => { + const safeHref = safeMarkdownUrl(href); + if (!safeHref) return escapeHtml(text || ""); + const titleAttribute = title ? ` title="${escapeHtml(title)}"` : ""; + return `${escapeHtml(text || `; + }; + return renderer; +} + +function renderMarkdown(value) { + const markedApi = typeof globalThis !== "undefined" ? globalThis.marked : null; + if (!markedApi?.parse || !markedApi?.Renderer) return renderMarkdownFallback(value); + return markedApi.parse(String(value ?? ""), { + gfm: true, + breaks: true, + renderer: createMarkdownRenderer(markedApi) + }); +} + +function formatActivityText(value) { + return String(value || ""); +} + +function renderActivity(activity, { open = false } = {}) { + if (!activity || typeof activity !== "object") return ""; + const thinking = formatActivityText(activity.thinking); + const tools = Array.isArray(activity.tools) ? activity.tools : []; + const blocks = []; + if (thinking) { + blocks.push(`
+ 思考过程已完成 +
${renderMarkdown(thinking)}
+
`); + } + if (tools.length) { + const toolCards = tools.map((tool) => { + const status = tool.status === "error" ? "error" : tool.status === "running" ? "running" : "done"; + const statusLabel = status === "error" ? "失败" : status === "running" ? "执行中" : "已完成"; + const output = formatActivityText(tool.output); + const args = formatActivityText(tool.args); + return `
+ 工具:${escapeHtml(tool.name || "工具")}${statusLabel} +
+ ${args ? `
参数
${escapeHtml(args)}
` : ""} + ${output ? `
输出
${escapeHtml(output)}
` : ""} +
+
`; + }).join(""); + blocks.push(`
${toolCards}
`); + } + return blocks.length ? `
${blocks.join("")}
` : ""; +} + +function renderSessions() { + $("#session-count").textContent = String(state.sessions.length); + sessionList.innerHTML = state.sessions.map((session) => ` +
+ + +
`).join(""); +} + +function renderMessages() { + const session = activeSession(); + if (!session) return; + const streaming = state.streamingAssistant?.sessionId === session.id ? state.streamingAssistant : null; + const streamingContent = streaming?.content || ""; + const streamingActivity = streaming ? { + thinking: streaming.thinking, + tools: streaming.tools + } : null; + const hasStreamingActivity = Boolean(streamingContent || streamingActivity?.thinking || streamingActivity?.tools?.length); + const hasMessages = session.messages.length > 0 || hasStreamingActivity; + emptyState.hidden = hasMessages; + messages.innerHTML = session.messages.map((message) => { + const isAssistant = message.role === "assistant"; + return `
+
${isAssistant ? "✦" : "你"}
+
${isAssistant ? "OI.skill" : "你"}${formatTime(message.createdAt)}
${isAssistant ? renderActivity(message.activity) : ""}
${renderMarkdown(message.content)}
+
`; + }).join(""); + if (hasStreamingActivity) { + messages.insertAdjacentHTML("beforeend", `
OI.skill正在处理
${renderActivity(streamingActivity, { open: true })}${streamingContent ? `
${renderMarkdown(streamingContent)}
` : ""}
`); + } + const shouldShowPending = state.loading && !streamingContent && !hasStreamingActivity; + if (shouldShowPending) { + messages.insertAdjacentHTML("beforeend", `
OI.skill等待 PiAgent
`); + } + messages.scrollTop = messages.scrollHeight; +} + +function renderActive() { + const session = activeSession(); + if (!session) return; + state.activeId = session.id; + $("#breadcrumb-title").textContent = session.title; + $("#conversation-title").textContent = session.title; + $("#mode-select").value = session.mode; + input.disabled = state.loading; + sendButton.disabled = state.loading || !input.value.trim(); + renderMessages(); +} + +function renderStatus() { + const dot = $("#agent-dot"); + const label = $("#agent-status"); + dot.className = `status-dot ${state.piBundled ? "ready" : "warning"}`; + label.textContent = state.piBundled ? "PiAgent · 内置 runtime" : "PiAgent runtime 缺失"; + $("#runtime-path").textContent = state.runtimeDir || "~/.oi-skill"; +} + +function currentModel() { + return state.models.find((model) => model.id === state.activeModelId) || state.models[0] || null; +} + +function renderModelSummary() { + const model = currentModel(); + $("#model-name").textContent = model?.name || "未配置模型"; + $("#model-settings").classList.toggle("unconfigured", !model?.hasApiKey); +} + +function renderModelList() { + const list = $("#model-list"); + list.innerHTML = state.models.map((model) => ``).join(""); +} + +function fillModelForm(model) { + $("#model-id").value = model?.id || ""; + $("#model-display-name").value = model?.name || "新模型"; + $("#model-provider").value = model?.provider || "openai"; + $("#model-model-id").value = model?.model || "gpt-4o-mini"; + $("#model-base-url").value = model?.baseUrl || ""; + $("#model-api-key-env").value = model?.apiKeyEnv || ""; + $("#model-api-key").value = ""; + $("#settings-save-status").textContent = model?.hasApiKey ? "已有 API Key,留空可保留" : "尚未配置 API Key"; +} + +function openSettings() { + renderModelList(); + fillModelForm(currentModel()); + settingsModal.hidden = false; + input.blur(); + $("#model-display-name").focus(); +} + +function closeSettings() { + settingsModal.hidden = true; +} + +function render() { + renderSessions(); + renderActive(); + renderStatus(); + renderModelSummary(); + updateCharCount(); +} + +function updateCharCount() { + const length = input.value.length; + $("#char-count").textContent = String(length); + sendButton.disabled = state.loading || !input.value.trim(); +} + +function resizeInput() { + input.style.height = "auto"; + const scrollHeight = input.scrollHeight; + const nextHeight = Math.min(Math.max(scrollHeight, state.inputMinHeight), state.inputMaxHeight); + input.style.height = `${nextHeight}px`; + input.style.overflowY = scrollHeight > state.inputMaxHeight ? "auto" : "hidden"; +} + +function showToast(message, kind = "") { + const toast = $("#toast"); + toast.textContent = message; + toast.className = `toast visible ${kind}`; + window.setTimeout(() => { toast.className = "toast"; }, 3600); +} + +async function createSession() { + try { + const session = await window.oi.createSession(activeSession()?.mode || "tutor"); + state.sessions = [session, ...state.sessions]; + state.activeId = session.id; + render(); + input.focus(); + } catch (error) { showToast(error.message, "error"); } +} + +async function changeMode(mode) { + const session = activeSession(); + if (!session || state.loading) return; + try { + const updated = await window.oi.updateMode(session.id, mode); + replaceSession(updated); + showToast(`已切换到${modeLabel(mode)}模式`); + } catch (error) { showToast(error.message, "error"); } +} + +function replaceSession(session) { + state.sessions = state.sessions.map((item) => item.id === session.id ? session : item); + state.activeId = session.id; + render(); +} + +async function sendMessage() { + const session = activeSession(); + const content = input.value.trim(); + if (!session || !content || state.loading) return; + state.loading = true; + state.streamingAssistant = { sessionId: session.id, content: "", thinking: "", tools: [] }; + input.value = ""; + resizeInput(); + updateCharCount(); + const optimisticMessage = { + id: `pending-${Date.now()}`, + role: "user", + content, + createdAt: new Date().toISOString() + }; + replaceSession({ + ...session, + title: session.title === "新对话" ? content.replace(/\s+/g, " ").slice(0, 34) || "新对话" : session.title, + updatedAt: optimisticMessage.createdAt, + messages: [...session.messages, optimisticMessage] + }); + try { + const response = await window.oi.sendMessage(session.id, content); + replaceSession(response.session); + state.lastSource = response.source; + if (response.error) showToast(`PiAgent 请求失败:${response.error}`, "error"); + } catch (error) { + showToast(error.message, "error"); + input.value = content; + resizeInput(); + updateCharCount(); + } finally { + state.streamingAssistant = null; + state.loading = false; + render(); + } +} + +window.oi.onMessageDelta((payload) => { + if (!payload || payload.id !== state.activeId || !state.streamingAssistant) return; + if (payload.delta !== undefined) state.streamingAssistant.content += String(payload.delta); + if (payload.event) applyStreamEvent(payload.event); + renderMessages(); +}); + +function applyStreamEvent(event) { + if (!event || !state.streamingAssistant) return; + if (event.type === "thinking_delta") { + state.streamingAssistant.thinking += String(event.delta || ""); + return; + } + if (event.type === "tool_start") { + state.streamingAssistant.tools.push({ + id: String(event.id || `tool-${state.streamingAssistant.tools.length + 1}`), + name: String(event.name || "工具"), + args: String(event.args || ""), + output: "", + status: "running" + }); + return; + } + const tool = state.streamingAssistant.tools.find((item) => item.id === String(event.id || "")); + if (!tool) return; + if (event.type === "tool_update" || event.type === "tool_end") { + tool.output = String(event.output || ""); + if (event.type === "tool_end") tool.status = event.status === "error" ? "error" : "done"; + } +} + +sessionList.addEventListener("click", async (event) => { + const button = event.target.closest("button[data-action]"); + if (!button) return; + const id = button.dataset.id; + if (button.dataset.action === "select") { + state.activeId = id; + render(); + return; + } + if (button.dataset.action === "delete") { + const target = state.sessions.find((session) => session.id === id); + if (!target || !window.confirm(`删除“${target.title}”?`)) return; + try { + const result = await window.oi.deleteSession(id); + state.sessions = result.sessions; + if (!state.sessions.some((session) => session.id === state.activeId)) state.activeId = state.sessions[0]?.id; + render(); + } catch (error) { showToast(error.message, "error"); } + } +}); + +$("#new-session").addEventListener("click", createSession); +$("#mode-select").addEventListener("change", (event) => changeMode(event.target.value)); +$("#model-settings").addEventListener("click", openSettings); +$("#settings-close").addEventListener("click", closeSettings); +$("#cancel-model").addEventListener("click", () => { fillModelForm(currentModel()); closeSettings(); }); +document.querySelectorAll("[data-settings-close]").forEach((element) => element.addEventListener("click", closeSettings)); +$("#model-list").addEventListener("click", async (event) => { + const button = event.target.closest("button[data-model-id]"); + if (!button) return; + try { + const settings = await window.oi.setActiveModel(button.dataset.modelId); + state.models = settings.models; + state.activeModelId = settings.activeModelId; + renderModelList(); + fillModelForm(currentModel()); + render(); + } catch (error) { showToast(error.message, "error"); } +}); +$("#new-model").addEventListener("click", () => fillModelForm(null)); +$("#model-form").addEventListener("submit", async (event) => { + event.preventDefault(); + try { + const settings = await window.oi.upsertModel({ + id: $("#model-id").value, + name: $("#model-display-name").value, + provider: $("#model-provider").value, + model: $("#model-model-id").value, + baseUrl: $("#model-base-url").value, + apiKeyEnv: $("#model-api-key-env").value, + apiKey: $("#model-api-key").value + }); + state.models = settings.models; + state.activeModelId = settings.activeModelId; + renderModelList(); + fillModelForm(currentModel()); + render(); + showToast("模型已保存,下一条消息将使用它"); + } catch (error) { showToast(error.message, "error"); } +}); +$("#rename-session").addEventListener("click", async () => { + const session = activeSession(); + if (!session) return; + const title = window.prompt("给这个 session 一个名字", session.title); + if (title === null) return; + try { replaceSession(await window.oi.renameSession(session.id, title)); } catch (error) { showToast(error.message, "error"); } +}); +composer.addEventListener("submit", (event) => { event.preventDefault(); sendMessage(); }); +input.addEventListener("input", () => { resizeInput(); updateCharCount(); }); +input.addEventListener("keydown", (event) => { + if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); sendMessage(); } +}); +document.querySelectorAll(".suggestion").forEach((button) => button.addEventListener("click", () => { input.value = button.dataset.prompt; input.dispatchEvent(new Event("input")); input.focus(); })); +document.addEventListener("keydown", (event) => { if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "n") { event.preventDefault(); createSession(); } }); +document.addEventListener("keydown", (event) => { if (event.key === "Escape" && !settingsModal.hidden) closeSettings(); }); + +(async () => { + try { + const bootstrap = await window.oi.bootstrap(); + state.sessions = bootstrap.sessions; + state.activeId = state.sessions[0]?.id || null; + state.runtimeDir = bootstrap.runtimeDir; + state.skillReady = bootstrap.skillReady; + state.piCommand = bootstrap.piCommand; + state.piBundled = bootstrap.piBundled; + state.models = bootstrap.models || []; + state.activeModelId = bootstrap.activeModelId || state.models[0]?.id || ""; + render(); + resizeInput(); + input.focus(); + } catch (error) { + showToast(`应用初始化失败:${error.message}`, "error"); + } +})(); diff --git a/app/src/renderer/styles.css b/app/src/renderer/styles.css new file mode 100644 index 0000000..0103633 --- /dev/null +++ b/app/src/renderer/styles.css @@ -0,0 +1,154 @@ +:root { + color-scheme: light; + font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "SF Pro Display", "PingFang SC", "Microsoft YaHei", sans-serif; + color: #1f1f1d; + background: #f3f3f1; + font-synthesis: none; + --sidebar: #e9e9e7; + --line: #dededb; + --muted: #8c8c87; + --soft: #f8f8f6; + --ink: #20201e; + --accent: #252522; + --blue: #6a86bf; +} + +* { box-sizing: border-box; } +html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; } +body { background: #f3f3f1; } +button, textarea, select { font: inherit; } +button { cursor: pointer; } + +.app-shell { display: flex; width: 100vw; height: 100vh; padding: 0; } +.app-shell { -webkit-app-region: no-drag; } +.sidebar { width: 286px; flex: 0 0 286px; display: flex; flex-direction: column; background: var(--sidebar); border-right: 1px solid #dcdcd9; padding: 24px 14px 16px; } +.brand-row { display: flex; align-items: center; gap: 11px; padding: 0 11px 26px; -webkit-app-region: drag; } +.brand-row > * { -webkit-app-region: no-drag; } +.brand-mark { width: 34px; height: 34px; display: grid; place-items: center; border-radius: 11px; color: #fff; background: #232321; font-size: 11px; font-weight: 800; letter-spacing: -.6px; box-shadow: 0 3px 8px #00000020; } +.brand-title { font-size: 15px; font-weight: 700; letter-spacing: -.2px; } +.brand-subtitle { color: #8b8b86; font-size: 11px; margin-top: 3px; } +.new-session { height: 43px; width: 100%; border: 1px solid #d5d5d1; border-radius: 11px; background: #f5f5f3; color: #252522; display: flex; align-items: center; gap: 9px; padding: 0 12px; font-size: 13px; font-weight: 600; box-shadow: 0 1px 2px #00000008; -webkit-app-region: no-drag; } +.new-session:hover { background: #fff; border-color: #c9c9c4; } +.plus-icon { font-size: 22px; font-weight: 300; line-height: 0; margin-top: -2px; } +.shortcut { margin-left: auto; color: #a0a09b; font-size: 10px; font-weight: 500; } +.section-heading { display: flex; align-items: center; gap: 8px; color: #a09f99; font-size: 10px; font-weight: 800; letter-spacing: .12em; padding: 27px 11px 10px; } +.count-pill { min-width: 17px; height: 17px; padding: 0 5px; display: inline-grid; place-items: center; border-radius: 9px; color: #85847e; background: #dededb; font-size: 9px; letter-spacing: 0; } +.session-list { flex: 1; min-height: 0; overflow: auto; padding-right: 2px; } +.session-list::-webkit-scrollbar { width: 5px; } +.session-list::-webkit-scrollbar-thumb { background: #c9c9c5; border-radius: 3px; } +.session-item { display: flex; align-items: stretch; min-height: 58px; margin: 2px 0; border-radius: 10px; } +.session-item.active { background: #dcdcd9; } +.session-item:hover { background: #e1e1de; } +.session-main { min-width: 0; flex: 1; display: flex; gap: 9px; align-items: center; padding: 9px 6px 9px 9px; text-align: left; border: 0; background: transparent; color: inherit; } +.session-icon { width: 26px; height: 26px; flex: 0 0 26px; display: grid; place-items: center; border-radius: 8px; color: #71716b; background: #f2f2ef; font-size: 17px; } +.session-icon.analyst { color: #56729e; background: #e8eef9; } +.session-copy { min-width: 0; display: flex; flex-direction: column; gap: 4px; } +.session-title { overflow: hidden; color: #33332f; font-size: 12px; font-weight: 600; line-height: 1.2; text-overflow: ellipsis; white-space: nowrap; } +.session-meta { color: #999993; font-size: 10px; } +.session-more { width: 30px; border: 0; background: transparent; color: #999993; opacity: 0; font-size: 15px; letter-spacing: 1px; } +.session-item:hover .session-more, .session-item.active .session-more { opacity: 1; } +.session-more:hover { color: #3f3f3b; } +.sidebar-bottom { border-top: 1px solid #d5d5d1; padding: 14px 11px 0; } +.agent-status-row { display: flex; align-items: center; gap: 7px; color: #686862; font-size: 11px; font-weight: 600; } +.status-dot { width: 7px; height: 7px; border-radius: 50%; background: #b6b6b1; } +.status-dot.ready { background: #72a777; box-shadow: 0 0 0 3px #72a77722; }.status-dot.warning { background: #c58b48; box-shadow: 0 0 0 3px #c58b4822; } +.runtime-path { overflow: hidden; color: #9a9a94; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 9px; text-overflow: ellipsis; white-space: nowrap; margin-top: 10px; } +.sidebar-note { color: #a3a39d; font-size: 10px; line-height: 1.45; margin-top: 4px; } + +.main-panel { flex: 1; min-width: 0; display: flex; flex-direction: column; background: #f3f3f1; } +.topbar { height: 68px; flex: 0 0 68px; display: flex; align-items: center; justify-content: space-between; padding: 0 33px; color: #494944; -webkit-app-region: drag; } +.topbar > * { -webkit-app-region: no-drag; } +.breadcrumb { font-size: 12px; font-weight: 600; } +.crumb-muted { color: #9c9c96; font-weight: 500; } +.crumb-separator { color: #c4c4bf; padding: 0 9px; } +.topbar-meta { display: flex; align-items: center; gap: 15px; } +.built-in-badge { color: #85857e; font-size: 10px; font-weight: 600; } +.spark { color: #7889ad; font-size: 14px; vertical-align: -1px; padding-right: 3px; } +.model-button { display: inline-flex; align-items: center; gap: 6px; padding: 7px 9px; border: 1px solid #e4e4e0; border-radius: 9px; color: #777770; background: #fff; font-size: 10px; font-weight: 700; } +.model-button:hover { color: #4e4e48; border-color: #cfd5e2; background: #fbfcff; }.model-status-dot { width: 6px; height: 6px; border-radius: 50%; background: #78a77d; }.model-button.unconfigured .model-status-dot { background: #c8914c; }.model-gear { color: #a5a59e; font-size: 12px; } +.window-avatar { width: 26px; height: 26px; display: grid; place-items: center; border-radius: 50%; color: #fff; background: #242421; font-size: 11px; font-weight: 800; } +.conversation-panel { position: relative; flex: 1; min-height: 0; display: flex; flex-direction: column; margin: 0 24px 24px 0; overflow: hidden; border: 1px solid #e5e5e2; border-radius: 22px; background: #fff; box-shadow: 0 8px 28px #27271f0a; } +.conversation-header { display: flex; align-items: center; justify-content: space-between; padding: 31px 43px 21px; -webkit-app-region: drag; } +.conversation-header > * { -webkit-app-region: no-drag; } +.eyebrow { color: #a4a49d; font-size: 9px; font-weight: 800; letter-spacing: .16em; } +h1 { margin: 7px 0 0; color: #1f1f1d; font-size: 24px; letter-spacing: -.04em; } +.conversation-actions { display: flex; align-items: center; gap: 9px; } +.quiet-button { padding: 9px 12px; border: 1px solid #e5e5e1; border-radius: 9px; background: #fff; color: #92928c; font-size: 11px; } +.quiet-button:hover { color: #3b3b37; border-color: #cfcfca; } +.mode-control { display: flex; align-items: center; gap: 7px; padding: 9px 10px; border: 1px solid #e2e2de; border-radius: 9px; background: #fafaf8; color: #4f4f49; } +.mode-dot { width: 7px; height: 7px; border-radius: 50%; background: #7f97c6; } +.mode-control select { appearance: none; padding: 0 16px 0 0; border: 0; outline: 0; background: transparent; color: #55554f; font-size: 11px; font-weight: 700; } +.chevron { color: #a0a099; font-size: 13px; margin-left: -18px; pointer-events: none; } +.messages { flex: 1; min-height: 0; overflow: auto; padding: 12px 11% 24px; } +.messages::-webkit-scrollbar { width: 6px; } +.messages::-webkit-scrollbar-thumb { background: #e3e3df; border-radius: 4px; } +.message-row { display: flex; gap: 12px; max-width: 760px; margin: 0 auto 23px; } +.message-row.user { justify-content: flex-end; } +.message-row.user .message-body { flex: 0 1 auto; max-width: 78%; order: 1; } +.message-row.user .message-avatar { order: 2; } +.message-avatar { width: 28px; height: 28px; flex: 0 0 28px; display: grid; place-items: center; border-radius: 9px; font-size: 10px; font-weight: 800; } +.assistant-avatar { color: #4d6088; background: #e8eef9; } +.user-avatar { color: #63635d; background: #f0f0ed; } +.message-body { min-width: 0; flex: 1; } +.message-meta { display: flex; gap: 8px; align-items: baseline; color: #4d4d47; font-size: 11px; font-weight: 700; } +.message-time { color: #b0b0a9; font-size: 10px; font-weight: 500; } +.message-content { color: #3e3e39; font-size: 13px; line-height: 1.7; white-space: pre-wrap; overflow-wrap: anywhere; margin-top: 6px; } +.message-content strong { font-weight: 700; color: #292925; } +.message-content p { margin: 0 0 10px; }.message-content p:last-child { margin-bottom: 0; } +.message-content h1, .message-content h2, .message-content h3, .message-content h4, .message-content h5, .message-content h6 { margin: 14px 0 7px; color: #292925; font-size: 1.08em; letter-spacing: -.02em; }.message-content h1 { font-size: 1.3em; }.message-content h2 { font-size: 1.2em; } +.message-content ul, .message-content ol { margin: 7px 0 10px; padding-left: 23px; }.message-content li { padding-left: 3px; }.message-content li + li { margin-top: 3px; } +.message-content blockquote { margin: 9px 0; padding: 3px 13px; border-left: 3px solid #cbd5e8; color: #707570; background: #fafbfe; }.message-content hr { margin: 14px 0; border: 0; border-top: 1px solid #e8e8e3; } +.message-content a { color: #5e78aa; text-decoration: underline; text-decoration-color: #c8d3e8; text-underline-offset: 2px; }.message-content img { max-width: 100%; height: auto; border-radius: 8px; } +.message-content code { padding: 1px 5px; border-radius: 5px; background: #f0f0ec; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .92em; } +.message-content pre { margin: 8px 0; padding: 10px 12px; overflow-x: auto; border-radius: 8px; background: #f0f0ec; white-space: pre; } +.message-content pre code { padding: 0; background: transparent; } +.message-content input[type="checkbox"] { margin-right: 6px; accent-color: #7188b7; } +.markdown-table-wrap { max-width: 100%; overflow-x: auto; margin: 8px 0; } +.message-content table { min-width: 100%; border-collapse: collapse; font-size: 12px; white-space: normal; } +.message-content th, .message-content td { padding: 7px 10px; border: 1px solid #e4e4df; text-align: left; vertical-align: top; } +.message-content th[align="center"], .message-content td[align="center"] { text-align: center; }.message-content th[align="right"], .message-content td[align="right"] { text-align: right; } +.message-content th { background: #f4f4f1; font-weight: 700; color: #30302c; } +.message-row.user .message-content { display: block; width: fit-content; max-width: 100%; margin-left: auto; padding: 10px 13px; border-radius: 13px 5px 13px 13px; background: #f4f4f1; } +.activity-stack { display: flex; flex-direction: column; gap: 7px; margin-top: 9px; margin-bottom: 7px; } +.activity-card { overflow: hidden; border: 1px solid #e5e8ef; border-radius: 9px; color: #646b79; background: #fafbfe; font-size: 11px; } +.activity-card summary { display: flex; align-items: center; gap: 7px; min-height: 32px; padding: 7px 10px; cursor: pointer; list-style: none; font-weight: 700; }.activity-card summary::-webkit-details-marker { display: none; }.activity-card summary::after { content: "⌄"; margin-left: 2px; color: #9da7b8; font-size: 12px; }.activity-card:not([open]) summary::after { content: "›"; }.activity-icon { width: 18px; height: 18px; display: grid; place-items: center; border-radius: 5px; color: #6b7fa9; background: #e9eef8; font-size: 10px; }.activity-status { margin-left: auto; color: #9ba4b1; font-size: 10px; font-weight: 600; }.activity-card.error { border-color: #efd7d7; background: #fffafa; }.activity-card.error .activity-icon { color: #ad6a6a; background: #fae8e8; }.activity-card.error .activity-status { color: #b36b6b; }.activity-card.running .activity-status { color: #8b78a5; } +.activity-content { padding: 8px 11px 10px; border-top: 1px solid #e9ebf0; color: #707681; font-size: 12px; line-height: 1.65; }.activity-content p { margin: 0 0 8px; }.activity-content p:last-child { margin-bottom: 0; }.activity-content pre { margin: 6px 0; padding: 8px 10px; background: #f0f2f7; } +.activity-tools { display: flex; flex-direction: column; gap: 6px; }.tool-detail { padding: 8px 11px 10px; border-top: 1px solid #e9ebf0; }.tool-label { margin: 2px 0 4px; color: #9aa2ae; font-size: 9px; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }.tool-detail pre { max-height: 220px; margin: 0 0 8px; padding: 8px 10px; overflow: auto; border-radius: 6px; color: #657080; background: #f0f2f7; font-size: 10px; line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere; }.tool-detail pre:last-child { margin-bottom: 0; } +.pending-row { margin-bottom: 0; } +.typing-indicator { display: flex; align-items: center; gap: 4px; height: 30px; } +.typing-indicator i { width: 5px; height: 5px; border-radius: 50%; background: #a7b6d0; animation: blink 1s infinite ease-in-out; } +.typing-indicator i:nth-child(2) { animation-delay: .15s; }.typing-indicator i:nth-child(3) { animation-delay: .3s; } +@keyframes blink { 0%, 80%, 100% { opacity: .35; transform: translateY(0); } 40% { opacity: 1; transform: translateY(-3px); } } +.empty-state { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 10px 30px 40px; text-align: center; } +.empty-state[hidden] { display: none; } +.empty-orbit { width: 48px; height: 48px; display: grid; place-items: center; margin-bottom: 17px; border: 1px solid #e4e5eb; border-radius: 16px; color: #7a8cb0; background: linear-gradient(145deg, #f8f9fd, #eef2f9); box-shadow: inset 0 0 0 5px #fff; font-size: 22px; } +.empty-kicker { color: #b0b0a9; font-size: 9px; font-weight: 800; letter-spacing: .18em; } +.empty-state h2 { margin: 13px 0 8px; color: #22221f; font-size: 25px; letter-spacing: -.06em; } +.empty-state p { max-width: 430px; margin: 0; color: #a3a39c; font-size: 12px; line-height: 1.65; } +.suggestion-row { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; margin-top: 26px; } +.suggestion { padding: 8px 11px; border: 1px solid #e9e9e5; border-radius: 20px; color: #898983; background: #fff; font-size: 10px; } +.suggestion:hover { color: #5c6f97; border-color: #ccd7eb; background: #fafbff; } +.composer { width: min(760px, 76%); margin: 0 auto 31px; padding: 12px 13px 10px 17px; border: 1px solid #e1e1dd; border-radius: 15px; background: #fff; box-shadow: 0 5px 18px #29291f0d; } +.composer:focus-within { border-color: #becae0; box-shadow: 0 5px 18px #657ca614, 0 0 0 3px #dfe7f533; } +.composer textarea { display: block; width: 100%; max-height: 170px; min-height: 27px; resize: none; overflow-y: hidden; overflow-x: hidden; padding: 5px 0 8px; border: 0; outline: 0; color: #33332f; background: transparent; font-size: 13px; line-height: 1.45; } +.composer textarea::placeholder { color: #b4b4ad; } +.composer-footer { display: flex; align-items: center; justify-content: space-between; color: #b1b1aa; font-size: 9px; } +.hint-key { color: #92928c; font-weight: 700; }.hint-separator { padding: 0 5px; color: #d3d3ce; } +.composer-right { display: flex; align-items: center; gap: 10px; }.char-count { color: #c2c2bc; font-variant-numeric: tabular-nums; } +.send-button { width: 29px; height: 29px; display: grid; place-items: center; border: 0; border-radius: 9px; color: #fff; background: #252522; font-size: 17px; line-height: 1; } +.send-button:hover:not(:disabled) { background: #515e79; }.send-button:disabled { color: #aaa; background: #e8e8e4; cursor: default; } +.toast { position: absolute; z-index: 3; left: 50%; bottom: 102px; max-width: min(680px, 80%); padding: 10px 14px; border: 1px solid #e2e2de; border-radius: 10px; opacity: 0; transform: translate(-50%, 8px); pointer-events: none; color: #66665f; background: #fff; box-shadow: 0 8px 24px #00000012; font-size: 11px; transition: opacity .2s, transform .2s; } +.toast.visible { opacity: 1; transform: translate(-50%, 0); }.toast.error { color: #a65c5c; border-color: #f0d7d7; background: #fffafa; }.toast.warning { color: #94733d; border-color: #f1e3c5; background: #fffdf6; } + +.settings-modal { position: fixed; inset: 0; z-index: 20; display: grid; place-items: center; }.settings-modal[hidden] { display: none; }.settings-backdrop { position: absolute; inset: 0; background: #20201e2b; backdrop-filter: blur(4px); } +.settings-card { position: relative; width: min(760px, calc(100vw - 48px)); overflow: hidden; border: 1px solid #e0e0dc; border-radius: 18px; background: #fff; box-shadow: 0 24px 70px #20201e2b; -webkit-app-region: no-drag; }.settings-header { display: flex; align-items: flex-start; justify-content: space-between; padding: 25px 29px 20px; border-bottom: 1px solid #ededE9; }.settings-header h2 { margin: 7px 0 0; font-size: 21px; letter-spacing: -.04em; }.settings-close { width: 29px; height: 29px; border: 0; border-radius: 8px; color: #999992; background: #f3f3f0; font-size: 20px; line-height: 1; }.settings-close:hover { color: #33332f; background: #e8e8e4; } +.settings-content { display: grid; grid-template-columns: 248px 1fr; min-height: 390px; }.model-list-panel { padding: 18px 13px; border-right: 1px solid #ededE9; background: #fafaf8; }.settings-section-label { display: flex; align-items: center; justify-content: space-between; padding: 0 8px 10px; color: #9d9d96; font-size: 10px; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; }.add-model { width: 24px; height: 24px; border: 1px solid #e1e1dd; border-radius: 7px; color: #7e7e77; background: #fff; font-size: 16px; line-height: 1; }.add-model:hover { color: #516c9d; border-color: #cdd8eb; }.model-list { display: flex; flex-direction: column; gap: 5px; }.model-list-item { width: 100%; display: flex; align-items: center; gap: 8px; padding: 9px 8px; border: 1px solid transparent; border-radius: 9px; text-align: left; color: #5f5f58; background: transparent; }.model-list-item:hover { background: #f0f0ed; }.model-list-item.active { border-color: #d7dfed; background: #edf2fb; }.model-list-icon { width: 24px; height: 24px; display: grid; place-items: center; border-radius: 7px; color: #6f86b2; background: #e6ecf8; font-size: 13px; }.model-list-item strong, .model-list-item small { display: block; overflow: hidden; max-width: 150px; text-overflow: ellipsis; white-space: nowrap; }.model-list-item strong { font-size: 11px; }.model-list-item small { color: #a2a29b; font-size: 9px; margin-top: 3px; }.model-check { margin-left: auto; color: #74a178; font-size: 9px; }.settings-footnote { margin: 18px 8px 0; color: #aaa9a2; font-size: 9px; line-height: 1.55; } +.model-form { display: flex; flex-direction: column; gap: 13px; padding: 23px 28px; }.model-form label { display: flex; flex-direction: column; gap: 6px; color: #6d6d66; font-size: 10px; font-weight: 700; }.model-form input { height: 36px; padding: 0 10px; border: 1px solid #e3e3df; border-radius: 8px; outline: 0; color: #383834; background: #fff; font-size: 12px; font-weight: 500; }.model-form input:focus { border-color: #b9c8e1; box-shadow: 0 0 0 3px #dfe7f533; }.form-grid { display: grid; grid-template-columns: 1fr 1.35fr; gap: 12px; }.model-isolation-note { display: flex; gap: 8px; padding: 9px 10px; border: 1px solid #e1e9dc; border-radius: 8px; color: #7a8a72; background: #f7fbf4; font-size: 10px; line-height: 1.45; }.model-isolation-note > span:first-child { color: #6aa36f; font-size: 14px; }.settings-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; margin-top: auto; }.settings-save-status { flex: 1; color: #aaa9a2; font-size: 10px; }.save-model { padding: 9px 13px; border: 0; border-radius: 8px; color: #fff; background: #282824; font-size: 11px; font-weight: 700; }.save-model:hover { background: #55698f; } + +@media (max-width: 1080px) { + .sidebar { width: 240px; flex-basis: 240px; }.conversation-panel { margin-right: 14px; }.topbar { padding-left: 22px; padding-right: 22px; }.conversation-header { padding-left: 28px; padding-right: 28px; }.messages { padding-left: 7%; padding-right: 7%; } +} + +@media (max-width: 720px) { + .settings-content { grid-template-columns: 1fr; }.model-list-panel { border-right: 0; border-bottom: 1px solid #ededE9; }.model-list { max-height: 110px; overflow: auto; }.model-form { padding: 18px; }.topbar { padding: 0 18px; }.built-in-badge { display: none; } +} diff --git a/app/tests/markdown.test.js b/app/tests/markdown.test.js new file mode 100644 index 0000000..822fed0 --- /dev/null +++ b/app/tests/markdown.test.js @@ -0,0 +1,45 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const vm = require("node:vm"); +const test = require("node:test"); +const marked = require("marked"); + +const source = fs.readFileSync(path.join(__dirname, "../src/renderer/renderer.js"), "utf8"); +const start = source.indexOf("function escapeHtml"); +const end = source.indexOf("function renderSessions"); +const context = {}; +vm.runInNewContext(`${source.slice(start, end)}\nthis.renderMarkdown = renderMarkdown;`, context); + +test("Markdown 会渲染加粗、行内代码和代码块", () => { + const html = context.renderMarkdown("这是 **重点**,以及 `x + 1`。\n```cpp\nint x = 1;\n```"); + assert.match(html, /重点<\/strong>/); + assert.match(html, /x \+ 1<\/code>/); + assert.match(html, /
int x = 1;<\/code><\/pre>/);
+});
+
+test("GFM Markdown 表格会渲染为 table", () => {
+  const html = context.renderMarkdown("| 文件 | 大小 |\n| :--- | ---: |\n| **main.md** | 1 KB |");
+  assert.match(html, //);
+  assert.match(html, /
文件<\/th>/); + assert.match(html, /大小<\/th>/); + assert.match(html, /main\.md<\/strong>/); + assert.match(html, /1 KB<\/td>/); +}); + +test("Markdown 渲染会转义不可信 HTML", () => { + const html = context.renderMarkdown(""); + assert.doesNotMatch(html, /"); + assert.match(html, /

标题<\/h1>/); + assert.match(html, /
/); + assert.match(html, /type="checkbox"/); + assert.match(html, /href="https:\/\/example\.com"/); + assert.doesNotMatch(html, /