From e79485bf4cd8167600ab8813e17e6d8bd48c701d Mon Sep 17 00:00:00 2001 From: luckyyzh Date: Mon, 3 Aug 2026 05:06:34 +0800 Subject: [PATCH] feat: add vision and extension management to WebUI --- .agents/vision.md | 22 + README.md | 64 + pi-web/app/api/extensions/route.ts | 127 + pi-web/app/api/vision-config/route.ts | 65 + pi-web/components/AppShell.tsx | 3857 ++++++++++------- pi-web/components/ExtensionsConfig.tsx | 193 + pi-web/components/ModelsConfig.tsx | 5274 ++++++++++++++++-------- pi-web/components/VisionConfig.tsx | 424 ++ pi-web/lib/api-types.ts | 18 + pi-web/lib/i18n/messages/en.ts | 36 +- pi-web/lib/i18n/messages/zh-CN.ts | 36 +- resources/extensions/package.json | 3 +- resources/extensions/vision.ts | 539 +++ scripts/configure-pi-ai-vision.mjs | 122 + scripts/install-managed-packages.mjs | 6 + scripts/run.mjs | 6 + 16 files changed, 7612 insertions(+), 3180 deletions(-) create mode 100644 .agents/vision.md create mode 100644 pi-web/app/api/extensions/route.ts create mode 100644 pi-web/app/api/vision-config/route.ts create mode 100644 pi-web/components/ExtensionsConfig.tsx create mode 100644 pi-web/components/VisionConfig.tsx create mode 100644 resources/extensions/vision.ts create mode 100644 scripts/configure-pi-ai-vision.mjs diff --git a/.agents/vision.md b/.agents/vision.md new file mode 100644 index 0000000..e72a994 --- /dev/null +++ b/.agents/vision.md @@ -0,0 +1,22 @@ +--- +name: vision +description: 视觉子代理 —— 读取并描述图片(截图/图表/文档/照片),输出完整结构化描述(OCR/版式/语义),供不支持图片输入的主模型(如 DeepSeek)推理使用。后端默认本地 Ollama(qwen3-vl:8b),也可切换 OpenAI 兼容视觉 API +tools: vision +subagentOnlyExtensions: ./resources/extensions/vision.ts +thinking: false +systemPromptMode: replace +inheritProjectContext: false +inheritSkills: false +defaultProgress: true +--- + +你是视觉子代理。主会话会把一个或多个图片文件路径交给你,你调用 `vision` 工具让视觉模型看图并返回文本描述。 + +工作规则: + +- 对每个图片路径调用一次 `vision`;相关图片可一次传入多张。 +- 工具返回的是视觉模型的转录:忠实转达,OCR 文字逐字保留,不要改写或脑补。 +- 工具报错时(文件不存在 / 后端未配置 / 模型未拉取)如实报告,并给出明确的修复提示(如 `ollama pull qwen3-vl:8b`,或检查 `VISION_OPENAI_*` 环境变量)。 +- 输出保持结构化:多图按图分组,先给结论性总结,再附关键细节;文字类图片保证转录完整。 + +主会话(通常是 DeepSeek 这类纯文本模型)看不到图片,完全依赖你的描述,完整性优先。 diff --git a/README.md b/README.md index 8fd68d6..f16c985 100644 --- a/README.md +++ b/README.md @@ -143,11 +143,43 @@ data/workspaces/default/ 默认工作目录 | `@upstash/context7-pi@0.1.2` | 查询当前库、框架、SDK 和 API 文档 | 模型先解析库 ID,再按需查询文档;无 Key 可使用公共限额 | | `@narumitw/pi-retry@0.31.0` | 识别瞬时供应商错误和卡住的流 | 复用 Pi 内置重试;默认 180 秒无事件视为停滞,不增加正常请求的模型调用 | | `resources/extensions/searxng-search.ts` | 用户自有 SearXNG 的 `web_search` | 配置 `SEARXNG_URL` 与 `SEARXNG_TOKEN` 后,模型对时效性或明确搜索请求自动调用 | +| `resources/extensions/vision.ts` | 文本主模型(如 DeepSeek)的识图工具 `vision`(双后端) | 派 `vision` 子代理或直接让模型调用工具,返回 OCR/版式/语义文本;后端默认本地 Ollama(`qwen3-vl:8b`),可切 OpenAI 兼容视觉 API | Windows 的 Playwright 不下载独立 Chromium;首次 `setup` 只缓存 MCP 的 Node.js 包,浏览器执行使用系统 Edge。macOS 的 setup 不安装或启用 Playwright;如需浏览器自动化,可在 Web UI 的 MCP 面板中手动添加并配置。 +#### 视觉子代理(vision) + +DeepSeek 等纯文本模型不能接收图片。仓库内置 `vision` 子代理(`.agents/vision.md`):它通过 `vision` 工具调用视觉模型读取图片,把完整 OCR、版式结构与语义描述返回给主模型,主模型基于文本继续推理。视觉后端可插拔,默认本地 Ollama,也支持任意 OpenAI 兼容视觉 API——没有本地部署条件时可直接用云服务。 + +配置入口:WebUI 左下角「模型」面板内的「视觉」标签页(写入 `data/agent/vision.json`),保存后**下次识图请求立即生效**,无需重启;环境变量优先级高于面板配置。 + +**后端一:本地 Ollama(默认,免费私密)** + +- 前置:本机安装 [Ollama](https://ollama.com) 并 `ollama pull qwen3-vl:8b`。 +- 环境变量:`OLLAMA_HOST`(默认 `http://localhost:11434`)、`OLLAMA_VISION_MODEL`(默认 `qwen3-vl:8b`)。 + +**后端二:OpenAI 兼容视觉 API** + +- 设置 `VISION_BACKEND=openai`,并配置 `VISION_OPENAI_BASE_URL`(如 `https://api.openai.com/v1`)、`VISION_OPENAI_API_KEY`、`VISION_OPENAI_MODEL`(如 `gpt-4o-mini`、`glm-4.5v`、`qwen-vl-max`)。 + +**自动转录(WebUI 上传即用)** + +纯文本主模型(如 DeepSeek)无法接收图片,直接在 WebUI 上传会让请求失败(DeepSeek API 返回 HTTP 400)。`vision` 扩展注册了 `before_provider_request` 钩子:请求发出前检测到图片附件时,自动调用配置的视觉后端生成文本描述并替换进消息,主模型直接基于描述继续推理——上传即用,无需手动操作。支持图片的主模型则原样透传,不受影响。 + +描述按**单张图片**做会话级缓存:只有新上传的图片会调用视觉模型,历史图片秒回缓存。每轮请求会把历史图片的描述文本一并注入上下文以保持主模型的记忆——上下文体积会随历史图片数增长,属已知取舍(Ollama 端已显式提升 `num_ctx`,DeepSeek 前缀缓存可摊薄费用)。 + +- 用法:对主模型说“用 vision 子代理看 <图片路径>”即可;也可 `/run vision`(子代理用于主动深度分析多图;上传自动转录已覆盖日常识图)。 +- 主会话直用:重启 pi 后 `vision` 工具在主会话也可用,可对磁盘上的图片主动调用。 +- 单次调用可覆盖后端与模型:工具参数 `backend`、`model`。 + +为什么不让 pi 直接连接 Ollama 视觉模型:Ollama 的 OpenAI 兼容端点(`/v1`)会把 qwen3 系列模型的推理内容放进 `reasoning` 字段、`content` 留空,pi 会判定为空回复。Ollama 后端改走原生 `/api/chat` 并传 `think: false` 关闭思考,实测稳定可靠。 + MCP 服务器可通过 Web UI 左下角的 MCP 按钮可视化配置(写入 `data/agent/mcp.json`):支持 stdio(命令 + 参数)与 HTTP(URL + 请求头 + OAuth/Bearer)两种传输、环境变量键值编辑、工作目录、生命周期与超时设置,另保留原始 JSON 编辑兜底。保存后重启 pi(或 /reload)生效。 +#### 插件与扩展 + +Web UI 左下角的「插件」和「扩展」是两个独立面板:插件面板管理 npm/git 插件包的安装、更新和启停;扩展面板只展示直接加载的 `.ts`/`.js` 扩展文件,不展示插件包内的资源。扩展面板会按项目、内置和应用范围显示扩展状态、来源与路径;未信任项目中的 `.pi/extensions` 会标记为阻止而不会执行。共享扩展放在 `resources/extensions/`,项目扩展放在项目的 `.pi/extensions/`,全局扩展位于 Pi Profile 的 `extensions/` 目录。 + Web UI 右上角的会话信息栏会汇总 Token 使用情况;当模型返回缓存读写数据时,还会显示按 Token 加权计算的缓存命中率:`cacheRead / (input + cacheRead + cacheWrite)`,不计输出 Token。 #### 记忆模式 @@ -350,11 +382,43 @@ Versions are pinned in the platform defaults under `config/`: Windows uses `mcp. | `@upstash/context7-pi@0.1.2` | Current library, framework, SDK, API docs | Resolves a library ID and queries docs when needed; public quota works without a key | | `@narumitw/pi-retry@0.31.0` | Transient provider and stalled-stream classification | Uses Pi's built-in retry path; 180 seconds without events is a stall; no extra normal model calls | | `resources/extensions/searxng-search.ts` | `web_search` against a user-owned SearXNG proxy | After `SEARXNG_URL` and `SEARXNG_TOKEN` are set, the model calls it for current or explicit search requests | +| `resources/extensions/vision.ts` | `vision` — image description for text-only models (e.g. DeepSeek), dual backend | Ask the `vision` subagent or call the tool directly; returns OCR/layout/semantics as text; backend defaults to local Ollama (`qwen3-vl:8b`) and can switch to any OpenAI-compatible vision API | On Windows, Playwright never downloads a standalone Chromium: setup caches only its Node package and browser execution uses system Edge. On macOS, setup does not install or enable Playwright; add it manually through the MCP panel if browser automation is needed. +#### Vision subagent + +Text-only models such as DeepSeek cannot receive image attachments. The repository ships a `vision` subagent (`.agents/vision.md`) that calls a vision model through the `vision` tool and returns a full OCR, layout, and semantic description the main model can reason over. The vision backend is pluggable: local Ollama by default, or any OpenAI-compatible vision API for users who cannot run a local model. + +Configuration: the “Vision” tab inside the “Models” panel in the lower-left Web UI (writes `data/agent/vision.json`). Saved config takes effect on the **next image request** — no restart needed; environment variables take precedence over the panel. + +**Backend 1: local Ollama (default, free and private)** + +- Prerequisite: install [Ollama](https://ollama.com) and run `ollama pull qwen3-vl:8b`. +- Env: `OLLAMA_HOST` (default `http://localhost:11434`), `OLLAMA_VISION_MODEL` (default `qwen3-vl:8b`). + +**Backend 2: OpenAI-compatible vision API** + +- Set `VISION_BACKEND=openai` and configure `VISION_OPENAI_BASE_URL` (e.g. `https://api.openai.com/v1`), `VISION_OPENAI_API_KEY`, `VISION_OPENAI_MODEL` (e.g. `gpt-4o-mini`, `glm-4.5v`, `qwen-vl-max`). + +**Automatic transcription (upload-and-go)** + +A text-only main model such as DeepSeek cannot receive images — uploading one in the Web UI fails the request (DeepSeek API returns HTTP 400). The `vision` extension registers a `before_provider_request` hook: when it detects image attachments, it transcribes them through the configured vision backend and replaces them with text before the request is sent, so the main model keeps reasoning seamlessly. Vision-capable main models pass through untouched. + +Descriptions are cached **per image** for the session: only genuinely new uploads call the vision model, while previously seen images resolve from cache instantly. Every request also re-injects the accumulated image descriptions so the main model keeps its memory of them — a known trade-off where context grows with the number of images (the Ollama backend raises `num_ctx` explicitly, and DeepSeek prefix caching keeps the cost modest). + +- Usage: ask the main model to “use the vision subagent to look at ”, or run `/run vision` (the subagent is for proactive deep analysis of many images; everyday image reading is covered by automatic transcription). +- Main-session use: after restarting pi, the `vision` tool is also available in the main session for images on disk. +- Per-call overrides: tool parameters `backend` and `model`. + +Why not point pi directly at an Ollama vision model: Ollama's OpenAI-compatible `/v1` endpoint moves qwen3-family reasoning into the `reasoning` field with an empty `content`, which pi treats as an empty reply. The Ollama backend uses the native `/api/chat` with `think: false` instead, which works reliably. + MCP servers can be configured visually from the MCP button in the lower-left Web UI (writes `data/agent/mcp.json`): stdio (command + args) or HTTP (URL + headers + OAuth/Bearer) transport, environment-variable row editing, working directory, lifecycle and timeout options, plus raw JSON editing as a fallback. Changes take effect after restarting pi (or /reload). +#### Plugins and extensions + +The lower-left Web UI has separate “Plugins” and “Extensions” panels. The Plugins panel manages npm/git plugin packages, including install, update, enable, and disable actions. The Extensions panel only lists directly loaded `.ts`/`.js` extension files and never lists resources supplied by plugin packages. It groups extensions by project, built-in, and app scope and shows their status, source, and path; extensions in an untrusted project `.pi/extensions` directory are shown as blocked and are not executed. Shared extensions belong in `resources/extensions/`, project extensions in `.pi/extensions/`, and global extensions in the Pi Profile `extensions/` directory. + The Web UI session-info panel in the upper-right summarizes Token usage. When a model reports cache read/write data, it also shows the token-weighted cache hit rate: `cacheRead / (input + cacheRead + cacheWrite)`, excluding output Tokens. #### Memory modes diff --git a/pi-web/app/api/extensions/route.ts b/pi-web/app/api/extensions/route.ts new file mode 100644 index 0000000..97e23b2 --- /dev/null +++ b/pi-web/app/api/extensions/route.ts @@ -0,0 +1,127 @@ +import { existsSync, readdirSync, statSync } from "fs"; +import { basename, dirname, extname, join, relative, resolve } from "path"; +import { DefaultPackageManager, getAgentDir, type ResolvedResource } from "@earendil-works/pi-coding-agent"; +import { getAllowedFileRoots, isExistingFilePathAllowed } from "@/lib/file-access"; +import { getManagedRuntimePaths, isManagedRuntime, createAppSettingsManager } from "@/lib/app-runtime"; +import { getProjectTrustStatus } from "@/lib/project-trust"; +import type { ExtensionInfo, ExtensionsResponse, PluginDiagnostic } from "@/lib/api-types"; + +export const dynamic = "force-dynamic"; + +function extensionName(path: string): string { + const file = basename(path); + const extension = extname(file); + if (/^index\.(ts|js)$/.test(file)) return basename(dirname(path)); + return extension ? file.slice(0, -extension.length) : file; +} + +function extensionFiles(root: string): string[] { + if (!existsSync(root)) return []; + try { + const stats = statSync(root); + if (stats.isFile()) return /\.(?:ts|js)$/.test(root) ? [root] : []; + if (!stats.isDirectory()) return []; + } catch { + return []; + } + + const files: string[] = []; + for (const entry of readdirSync(root, { withFileTypes: true })) { + const path = join(root, entry.name); + if (entry.isDirectory()) files.push(...extensionFiles(path)); + else if (entry.isFile() && /\.(?:ts|js)$/.test(entry.name)) files.push(path); + } + return files; +} + +function infoFromResource(resource: ResolvedResource, scopeOverride?: ExtensionInfo["scope"]): ExtensionInfo { + const baseDir = resource.metadata.baseDir ?? dirname(resource.path); + const rel = relative(baseDir, resource.path); + return { + name: extensionName(resource.path), + path: resource.path, + relativePath: rel && !rel.startsWith("..") ? rel : resource.path, + source: scopeOverride === "builtin" ? "resources" : resource.metadata.source, + scope: scopeOverride ?? (resource.metadata.scope === "project" ? "project" : "global"), + status: resource.enabled ? "enabled" : "disabled", + }; +} + +function blockedInfo(path: string): ExtensionInfo { + return { + name: extensionName(path), + path, + relativePath: relative(dirname(dirname(path)), path), + source: "project", + scope: "project", + status: "blocked", + }; +} + +export async function GET(req: Request) { + let cwd: string | null; + try { + cwd = new URL(req.url).searchParams.get("cwd"); + } catch { + return Response.json({ error: "invalid request URL" }, { status: 400 }); + } + if (!cwd) return Response.json({ error: "cwd required" }, { status: 400 }); + + try { + const allowedRoots = await getAllowedFileRoots(); + if (!isExistingFilePathAllowed(cwd, allowedRoots)) { + return Response.json({ error: "Access denied" }, { status: 403 }); + } + + const agentDir = getAgentDir(); + const trust = getProjectTrustStatus(cwd, agentDir); + const settingsManager = createAppSettingsManager(cwd, agentDir, trust.trusted); + const packageManager = new DefaultPackageManager({ cwd, agentDir, settingsManager }); + const diagnostics: PluginDiagnostic[] = []; + const byPath = new Map(); + + try { + const resolved = await packageManager.resolve(async () => "skip"); + for (const resource of resolved.extensions) { + // Package-provided extensions belong to the plugin panel, not here. + if (resource.metadata.origin !== "top-level") continue; + byPath.set(resource.path, infoFromResource(resource)); + } + } catch (error) { + diagnostics.push({ type: "error", message: error instanceof Error ? error.message : String(error) }); + } + + if (isManagedRuntime()) { + const resourcesRoot = resolve(getManagedRuntimePaths().resourcesDir, "extensions"); + const builtIn = await packageManager.resolveExtensionSources([resourcesRoot], { temporary: true }); + for (const resource of builtIn.extensions) { + byPath.set(resource.path, infoFromResource(resource, "builtin")); + } + } + + const projectExtensionsRoot = join(cwd, ".pi", "extensions"); + if (trust.requiresTrust && !trust.trusted) { + for (const path of extensionFiles(projectExtensionsRoot)) { + byPath.set(path, blockedInfo(path)); + } + if (byPathHasScope(byPath, "project")) { + diagnostics.push({ type: "warning", source: "project", message: "Project extensions are blocked until this project is trusted." }); + } + } + + const scopeOrder: Record = { project: 0, builtin: 1, global: 2 }; + const extensions = [...byPath.values()].sort((a, b) => ( + scopeOrder[a.scope] - scopeOrder[b.scope] || a.name.localeCompare(b.name) || a.path.localeCompare(b.path) + )); + return Response.json({ extensions, diagnostics, projectResourcesLoaded: trust.trusted } satisfies ExtensionsResponse); + } catch (error) { + return Response.json({ error: String(error) }, { status: 500 }); + } +} + +function byPathHasScope(entries: Map, scope: ExtensionInfo["scope"]): boolean { + for (const entry of entries.values()) { + if (entry.scope === scope) return true; + } + return false; +} diff --git a/pi-web/app/api/vision-config/route.ts b/pi-web/app/api/vision-config/route.ts new file mode 100644 index 0000000..72fad96 --- /dev/null +++ b/pi-web/app/api/vision-config/route.ts @@ -0,0 +1,65 @@ +import { NextResponse } from "next/server"; +import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { getAgentDir } from "@earendil-works/pi-coding-agent"; + +export const dynamic = "force-dynamic"; + +interface VisionConfigFile { + backend?: "ollama" | "openai"; + ollama?: { host?: string; model?: string }; + openai?: { baseUrl?: string; apiKey?: string; model?: string }; +} + +function getVisionPath(): string { + return join(getAgentDir(), "vision.json"); +} + +function readVisionJson(): VisionConfigFile { + const path = getVisionPath(); + if (!existsSync(path)) return {}; + try { + const parsed = JSON.parse(readFileSync(path, "utf8")) as VisionConfigFile; + if (!parsed || typeof parsed !== "object") return {}; + return parsed; + } catch { + return {}; + } +} + +function writeVisionJson(data: VisionConfigFile): void { + const path = getVisionPath(); + const dir = dirname(path); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + writeFileSync(path, JSON.stringify(data, null, 2) + "\n", "utf8"); +} + +export function GET() { + return NextResponse.json({ config: readVisionJson(), path: getVisionPath() }); +} + +export async function PUT(req: Request) { + try { + const body = (await req.json()) as VisionConfigFile; + if (!body || typeof body !== "object") { + return NextResponse.json({ error: "body must be an object" }, { status: 400 }); + } + const out: VisionConfigFile = {}; + if (body.backend === "ollama" || body.backend === "openai") out.backend = body.backend; + if (body.ollama && typeof body.ollama === "object") { + out.ollama = {}; + if (typeof body.ollama.host === "string" && body.ollama.host.trim()) out.ollama.host = body.ollama.host.trim(); + if (typeof body.ollama.model === "string" && body.ollama.model.trim()) out.ollama.model = body.ollama.model.trim(); + } + if (body.openai && typeof body.openai === "object") { + out.openai = {}; + if (typeof body.openai.baseUrl === "string" && body.openai.baseUrl.trim()) out.openai.baseUrl = body.openai.baseUrl.trim(); + if (typeof body.openai.apiKey === "string" && body.openai.apiKey.trim()) out.openai.apiKey = body.openai.apiKey.trim(); + if (typeof body.openai.model === "string" && body.openai.model.trim()) out.openai.model = body.openai.model.trim(); + } + writeVisionJson(out); + return NextResponse.json({ success: true, path: getVisionPath() }); + } catch (error) { + return NextResponse.json({ error: String(error) }, { status: 500 }); + } +} diff --git a/pi-web/components/AppShell.tsx b/pi-web/components/AppShell.tsx index d45cdde..8de3a12 100644 --- a/pi-web/components/AppShell.tsx +++ b/pi-web/components/AppShell.tsx @@ -11,6 +11,7 @@ import { ModelsConfig } from "./ModelsConfig"; import { McpConfig } from "./McpConfig"; import { SkillsConfig } from "./SkillsConfig"; import { PluginsConfig } from "./PluginsConfig"; +import { ExtensionsConfig } from "./ExtensionsConfig"; import { ProjectTrustDialog } from "./ProjectTrustDialog"; import { BranchNavigator } from "./BranchNavigator"; import { useTheme } from "@/hooks/useTheme"; @@ -18,7 +19,11 @@ import { useI18n } from "@/hooks/useI18n"; import { useIsMobile } from "@/hooks/useIsMobile"; import { copyText } from "@/lib/clipboard"; import { getFileName } from "@/lib/file-paths"; -import { buildAtMentionText, buildFileAtMentionsText, buildFileLineMentionText } from "@/lib/file-fuzzy"; +import { + buildAtMentionText, + buildFileAtMentionsText, + buildFileLineMentionText, +} from "@/lib/file-fuzzy"; import { getInitialNavigation } from "@/lib/initial-navigation"; import type { SessionInfo, SessionTreeNode } from "@/lib/types"; import type { ProjectTrustStatus } from "@/lib/api-types"; @@ -27,622 +32,888 @@ import type { SessionStatsInfo } from "@/lib/pi-types"; type SessionCopyField = "file" | "id"; type AutoNameStatus = - | { kind: "idle" } - | { kind: "naming" } - | { kind: "success" } - | { kind: "error"; message: string }; + | { kind: "idle" } + | { kind: "naming" } + | { kind: "success" } + | { kind: "error"; message: string }; const TOP_BAR_ICON_BUTTON_SIZE = 36; const LANGUAGE_MENU_WIDTH = 176; export function AppShell() { - const router = useRouter(); - const searchParams = useSearchParams(); - const [initialNavigation] = useState(() => getInitialNavigation(searchParams)); - const { isDark, toggleTheme } = useTheme(); - const { locale, setLocale, t: translate, supportedLocales } = useI18n(); - const isMobile = useIsMobile(); - const [selectedSession, setSelectedSession] = useState(null); - // When user clicks +, we only store the cwd — no fake session id - const [newSessionCwd, setNewSessionCwd] = useState(null); - const [initialCwdStatus, setInitialCwdStatus] = useState<"idle" | "validating" | "ready" | "error">( - () => initialNavigation.requestedCwd ? "validating" : "idle", - ); - const [initialCwdError, setInitialCwdError] = useState(null); - const [refreshKey, setRefreshKey] = useState(0); - const [sessionKey, setSessionKey] = useState(0); - const [explorerRefreshKey, setExplorerRefreshKey] = useState(0); - const [modelsConfigOpen, setModelsConfigOpen] = useState(false); - const [modelsRefreshKey, setModelsRefreshKey] = useState(0); - const [mcpConfigOpen, setMcpConfigOpen] = useState(false); - const [skillsConfigOpen, setSkillsConfigOpen] = useState(false); - const [pluginsConfigOpen, setPluginsConfigOpen] = useState(false); - const [projectTrust, setProjectTrust] = useState(null); - const [projectTrustDialogOpen, setProjectTrustDialogOpen] = useState(false); - const [projectTrustBusy, setProjectTrustBusy] = useState(false); - const [projectTrustError, setProjectTrustError] = useState(null); - const [sidebarOpen, setSidebarOpen] = useState(true); - const [mobileSidebarReady, setMobileSidebarReady] = useState(false); - // On mobile the sidebar is an overlay drawer; hide it by default so the chat - // is visible on load. Runs once the breakpoint resolves after hydration. - useEffect(() => { - if (isMobile) setSidebarOpen(false); - }, [isMobile]); - useEffect(() => { - setMobileSidebarReady(true); - }, []); - const chatInputRef = useRef(null); - const topBarRef = useRef(null); - const languageBtnRef = useRef(null); + const router = useRouter(); + const searchParams = useSearchParams(); + const [initialNavigation] = useState(() => + getInitialNavigation(searchParams), + ); + const { isDark, toggleTheme } = useTheme(); + const { locale, setLocale, t: translate, supportedLocales } = useI18n(); + const isMobile = useIsMobile(); + const [selectedSession, setSelectedSession] = useState( + null, + ); + // When user clicks +, we only store the cwd — no fake session id + const [newSessionCwd, setNewSessionCwd] = useState(null); + const [initialCwdStatus, setInitialCwdStatus] = useState< + "idle" | "validating" | "ready" | "error" + >(() => (initialNavigation.requestedCwd ? "validating" : "idle")); + const [initialCwdError, setInitialCwdError] = useState(null); + const [refreshKey, setRefreshKey] = useState(0); + const [sessionKey, setSessionKey] = useState(0); + const [explorerRefreshKey, setExplorerRefreshKey] = useState(0); + const [modelsConfigOpen, setModelsConfigOpen] = useState(false); + const [modelsRefreshKey, setModelsRefreshKey] = useState(0); + const [mcpConfigOpen, setMcpConfigOpen] = useState(false); + const [skillsConfigOpen, setSkillsConfigOpen] = useState(false); + const [pluginsConfigOpen, setPluginsConfigOpen] = useState(false); + const [extensionsConfigOpen, setExtensionsConfigOpen] = useState(false); + const [projectTrust, setProjectTrust] = useState( + null, + ); + const [projectTrustDialogOpen, setProjectTrustDialogOpen] = useState(false); + const [projectTrustBusy, setProjectTrustBusy] = useState(false); + const [projectTrustError, setProjectTrustError] = useState( + null, + ); + const [sidebarOpen, setSidebarOpen] = useState(true); + const [mobileSidebarReady, setMobileSidebarReady] = useState(false); + // On mobile the sidebar is an overlay drawer; hide it by default so the chat + // is visible on load. Runs once the breakpoint resolves after hydration. + useEffect(() => { + if (isMobile) setSidebarOpen(false); + }, [isMobile]); + useEffect(() => { + setMobileSidebarReady(true); + }, []); + const chatInputRef = useRef(null); + const topBarRef = useRef(null); + const languageBtnRef = useRef(null); - // Branch navigator state — populated by ChatWindow via onBranchDataChange - const [branchTree, setBranchTree] = useState([]); - const [branchActiveLeafId, setBranchActiveLeafId] = useState(null); - const branchLeafChangeFnRef = useRef<((leafId: string | null) => void) | null>(null); + // Branch navigator state — populated by ChatWindow via onBranchDataChange + const [branchTree, setBranchTree] = useState([]); + const [branchActiveLeafId, setBranchActiveLeafId] = useState( + null, + ); + const branchLeafChangeFnRef = useRef< + ((leafId: string | null) => void) | null + >(null); - const handleBranchDataChange = useCallback((tree: SessionTreeNode[], activeLeafId: string | null, onLeafChange: (leafId: string | null) => void) => { - setBranchTree(tree); - setBranchActiveLeafId(activeLeafId); - branchLeafChangeFnRef.current = onLeafChange; - }, []); + const handleBranchDataChange = useCallback( + ( + tree: SessionTreeNode[], + activeLeafId: string | null, + onLeafChange: (leafId: string | null) => void, + ) => { + setBranchTree(tree); + setBranchActiveLeafId(activeLeafId); + branchLeafChangeFnRef.current = onLeafChange; + }, + [], + ); - const handleBranchLeafChange = useCallback((leafId: string | null) => { - branchLeafChangeFnRef.current?.(leafId); - }, []); + const handleBranchLeafChange = useCallback((leafId: string | null) => { + branchLeafChangeFnRef.current?.(leafId); + }, []); - const [systemPrompt, setSystemPrompt] = useState(null); - const systemBtnRef = useRef(null); + const [systemPrompt, setSystemPrompt] = useState(null); + const systemBtnRef = useRef(null); - const handleSystemPromptChange = useCallback((prompt: string | null) => { - setSystemPrompt(prompt); - }, []); + const handleSystemPromptChange = useCallback((prompt: string | null) => { + setSystemPrompt(prompt); + }, []); - // Session stats (tokens + cost) — populated by ChatWindow, displayed in top bar - const [sessionStats, setSessionStats] = useState(null); - const [autoNameStatus, setAutoNameStatus] = useState({ kind: "idle" }); - const autoNameTimerRef = useRef | null>(null); - const activeSessionIdRef = useRef(selectedSession?.id ?? null); - activeSessionIdRef.current = selectedSession?.id ?? null; - const handleSessionStatsChange = useCallback((stats: SessionStatsInfo | null) => { - setSessionStats(stats); - }, []); - const [copiedSessionField, setCopiedSessionField] = useState(null); - const sessionCopyTimerRef = useRef | null>(null); - const handleCopySessionField = useCallback((field: SessionCopyField, value: string) => { - void copyText(value).then(() => { - if (sessionCopyTimerRef.current) clearTimeout(sessionCopyTimerRef.current); - setCopiedSessionField(field); - sessionCopyTimerRef.current = setTimeout(() => setCopiedSessionField(null), 1400); - }); - }, []); + // Session stats (tokens + cost) — populated by ChatWindow, displayed in top bar + const [sessionStats, setSessionStats] = useState( + null, + ); + const [autoNameStatus, setAutoNameStatus] = useState({ + kind: "idle", + }); + const autoNameTimerRef = useRef | null>(null); + const activeSessionIdRef = useRef(selectedSession?.id ?? null); + activeSessionIdRef.current = selectedSession?.id ?? null; + const handleSessionStatsChange = useCallback( + (stats: SessionStatsInfo | null) => { + setSessionStats(stats); + }, + [], + ); + const [copiedSessionField, setCopiedSessionField] = + useState(null); + const sessionCopyTimerRef = useRef | null>( + null, + ); + const handleCopySessionField = useCallback( + (field: SessionCopyField, value: string) => { + void copyText(value).then(() => { + if (sessionCopyTimerRef.current) + clearTimeout(sessionCopyTimerRef.current); + setCopiedSessionField(field); + sessionCopyTimerRef.current = setTimeout( + () => setCopiedSessionField(null), + 1400, + ); + }); + }, + [], + ); - useEffect(() => { - return () => { - if (sessionCopyTimerRef.current) clearTimeout(sessionCopyTimerRef.current); - if (autoNameTimerRef.current) clearTimeout(autoNameTimerRef.current); - }; - }, []); + useEffect(() => { + return () => { + if (sessionCopyTimerRef.current) + clearTimeout(sessionCopyTimerRef.current); + if (autoNameTimerRef.current) clearTimeout(autoNameTimerRef.current); + }; + }, []); - // Context usage — populated by ChatWindow, displayed in top bar - const [contextUsage, setContextUsage] = useState<{ percent: number | null; contextWindow: number; tokens: number | null } | null>(null); - const handleContextUsageChange = useCallback((usage: { percent: number | null; contextWindow: number; tokens: number | null } | null) => { - setContextUsage(usage); - }, []); + // Context usage — populated by ChatWindow, displayed in top bar + const [contextUsage, setContextUsage] = useState<{ + percent: number | null; + contextWindow: number; + tokens: number | null; + } | null>(null); + const handleContextUsageChange = useCallback( + ( + usage: { + percent: number | null; + contextWindow: number; + tokens: number | null; + } | null, + ) => { + setContextUsage(usage); + }, + [], + ); - // Single active panel — only one dropdown open at a time - const [activeTopPanel, setActiveTopPanel] = useState<"branches" | "system" | "session" | "language" | null>(null); - const [topPanelPos, setTopPanelPos] = useState<{ top: number; left: number; width: number } | null>(null); + // Single active panel — only one dropdown open at a time + const [activeTopPanel, setActiveTopPanel] = useState< + "branches" | "system" | "session" | "language" | null + >(null); + const [topPanelPos, setTopPanelPos] = useState<{ + top: number; + left: number; + width: number; + } | null>(null); - const toggleTopPanel = useCallback((panel: "branches" | "system" | "session" | "language") => { - if (isMobile) setSidebarOpen(false); - setActiveTopPanel((cur) => cur === panel ? null : panel); - }, [isMobile]); + const toggleTopPanel = useCallback( + (panel: "branches" | "system" | "session" | "language") => { + if (isMobile) setSidebarOpen(false); + setActiveTopPanel((cur) => (cur === panel ? null : panel)); + }, + [isMobile], + ); - const openSessionStatsPanel = useCallback(() => { - if (isMobile) setSidebarOpen(false); - setActiveTopPanel("session"); - }, [isMobile]); + const openSessionStatsPanel = useCallback(() => { + if (isMobile) setSidebarOpen(false); + setActiveTopPanel("session"); + }, [isMobile]); - const handleSidebarToggle = useCallback(() => { - if (isMobile) setActiveTopPanel(null); - setSidebarOpen((open) => !open); - }, [isMobile]); + const handleSidebarToggle = useCallback(() => { + if (isMobile) setActiveTopPanel(null); + setSidebarOpen((open) => !open); + }, [isMobile]); - useEffect(() => { - if (!activeTopPanel || !topBarRef.current) return; - const update = () => { - const topBarRect = topBarRef.current!.getBoundingClientRect(); - if (activeTopPanel === "language" && !isMobile && languageBtnRef.current) { - const buttonRect = languageBtnRef.current.getBoundingClientRect(); - const width = Math.min(LANGUAGE_MENU_WIDTH, topBarRect.width); - const left = Math.min( - buttonRect.left - 1, - Math.max(topBarRect.left, topBarRect.right - width), - ); - setTopPanelPos({ top: topBarRect.bottom, left, width }); - return; - } - setTopPanelPos({ top: topBarRect.bottom, left: topBarRect.left, width: topBarRect.width }); - }; - update(); - const ro = new ResizeObserver(update); - ro.observe(topBarRef.current); - if (languageBtnRef.current) ro.observe(languageBtnRef.current); - return () => ro.disconnect(); - }, [activeTopPanel, isMobile]); + useEffect(() => { + if (!activeTopPanel || !topBarRef.current) return; + const update = () => { + const topBarRect = topBarRef.current!.getBoundingClientRect(); + if ( + activeTopPanel === "language" && + !isMobile && + languageBtnRef.current + ) { + const buttonRect = languageBtnRef.current.getBoundingClientRect(); + const width = Math.min(LANGUAGE_MENU_WIDTH, topBarRect.width); + const left = Math.min( + buttonRect.left - 1, + Math.max(topBarRect.left, topBarRect.right - width), + ); + setTopPanelPos({ top: topBarRect.bottom, left, width }); + return; + } + setTopPanelPos({ + top: topBarRect.bottom, + left: topBarRect.left, + width: topBarRect.width, + }); + }; + update(); + const ro = new ResizeObserver(update); + ro.observe(topBarRef.current); + if (languageBtnRef.current) ro.observe(languageBtnRef.current); + return () => ro.disconnect(); + }, [activeTopPanel, isMobile]); - // Right panel — file tabs only - const [fileTabs, setFileTabs] = useState([]); - const [activeFileTabId, setActiveFileTabId] = useState(null); - const [rightPanelOpen, setRightPanelOpen] = useState(false); + // Right panel — file tabs only + const [fileTabs, setFileTabs] = useState([]); + const [activeFileTabId, setActiveFileTabId] = useState(null); + const [rightPanelOpen, setRightPanelOpen] = useState(false); - // Same @mention format as the chat input's @ autocomplete, so the agent's - // read tool resolves it the same way (it strips the @ prefix). - const handleAtMention = useCallback((relativePath: string, isDir: boolean) => { - chatInputRef.current?.insertText(buildAtMentionText(relativePath, isDir)); - }, []); + // Same @mention format as the chat input's @ autocomplete, so the agent's + // read tool resolves it the same way (it strips the @ prefix). + const handleAtMention = useCallback( + (relativePath: string, isDir: boolean) => { + chatInputRef.current?.insertText(buildAtMentionText(relativePath, isDir)); + }, + [], + ); - const handleAtMentions = useCallback((relativePaths: string[]) => { - const mentions = buildFileAtMentionsText(relativePaths); - if (mentions) chatInputRef.current?.insertText(mentions); - }, []); + const handleAtMentions = useCallback((relativePaths: string[]) => { + const mentions = buildFileAtMentionsText(relativePaths); + if (mentions) chatInputRef.current?.insertText(mentions); + }, []); - const handleFileLineMention = useCallback((relativePath: string, startLine: number, endLine: number) => { - chatInputRef.current?.insertText(buildFileLineMentionText(relativePath, startLine, endLine)); - }, []); + const handleFileLineMention = useCallback( + (relativePath: string, startLine: number, endLine: number) => { + chatInputRef.current?.insertText( + buildFileLineMentionText(relativePath, startLine, endLine), + ); + }, + [], + ); - const initialSessionId = initialNavigation.sessionId; - const [activeCwd, setActiveCwd] = useState(null); - const activeProjectRootRef = useRef(null); - // True once the initial ?session= URL param has been resolved (or confirmed absent) - const [initialSessionRestored, setInitialSessionRestored] = useState(() => !initialSessionId); - // Suppresses sessionKey bump in handleCwdChange during the initial URL restore - const suppressCwdBumpRef = useRef(false); + const initialSessionId = initialNavigation.sessionId; + const [activeCwd, setActiveCwd] = useState(null); + const activeProjectRootRef = useRef(null); + // True once the initial ?session= URL param has been resolved (or confirmed absent) + const [initialSessionRestored, setInitialSessionRestored] = useState( + () => !initialSessionId, + ); + // Suppresses sessionKey bump in handleCwdChange during the initial URL restore + const suppressCwdBumpRef = useRef(false); - useEffect(() => { - const requestedCwd = initialNavigation.requestedCwd; - if (!requestedCwd) return; + useEffect(() => { + const requestedCwd = initialNavigation.requestedCwd; + if (!requestedCwd) return; - const controller = new AbortController(); - setInitialCwdStatus("validating"); - setInitialCwdError(null); + const controller = new AbortController(); + setInitialCwdStatus("validating"); + setInitialCwdError(null); - void fetch("/api/cwd/validate", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ cwd: requestedCwd }), - signal: controller.signal, - }) - .then(async (response) => { - const data = await response.json().catch(() => ({})) as { cwd?: string; error?: string }; - if (!response.ok || !data.cwd) { - throw new Error(data.error ?? `HTTP ${response.status}`); - } + void fetch("/api/cwd/validate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ cwd: requestedCwd }), + signal: controller.signal, + }) + .then(async (response) => { + const data = (await response.json().catch(() => ({}))) as { + cwd?: string; + error?: string; + }; + if (!response.ok || !data.cwd) { + throw new Error(data.error ?? `HTTP ${response.status}`); + } - // The sidebar will notify us when it adopts this cwd. Avoid remounting - // the just-created empty chat during that initial synchronization. - suppressCwdBumpRef.current = true; - setNewSessionCwd(data.cwd); - setInitialCwdStatus("ready"); - }) - .catch((error: unknown) => { - if (controller.signal.aborted) return; - setInitialCwdError(error instanceof Error ? error.message : String(error)); - setInitialCwdStatus("error"); - }); + // The sidebar will notify us when it adopts this cwd. Avoid remounting + // the just-created empty chat during that initial synchronization. + suppressCwdBumpRef.current = true; + setNewSessionCwd(data.cwd); + setInitialCwdStatus("ready"); + }) + .catch((error: unknown) => { + if (controller.signal.aborted) return; + setInitialCwdError( + error instanceof Error ? error.message : String(error), + ); + setInitialCwdStatus("error"); + }); - return () => controller.abort(); - }, [initialNavigation]); + return () => controller.abort(); + }, [initialNavigation]); - const handleCwdChange = useCallback((cwd: string | null, projectRoot?: string | null) => { - setActiveCwd(cwd); - // Skip if cwd is null (initial mount). - if (!cwd) return; - const newProject = projectRoot ?? cwd; - const currentProject = activeProjectRootRef.current - ?? (selectedSession ? (selectedSession.projectRoot ?? selectedSession.cwd) : null); - activeProjectRootRef.current = newProject; + const handleCwdChange = useCallback( + (cwd: string | null, projectRoot?: string | null) => { + setActiveCwd(cwd); + // Skip if cwd is null (initial mount). + if (!cwd) return; + const newProject = projectRoot ?? cwd; + const currentProject = + activeProjectRootRef.current ?? + (selectedSession + ? (selectedSession.projectRoot ?? selectedSession.cwd) + : null); + activeProjectRootRef.current = newProject; - // Keep the project identity in sync during the initial URL restore without - // remounting the just-created or restored chat. - if (suppressCwdBumpRef.current) { - suppressCwdBumpRef.current = false; - return; - } - // Worktrees of one repo share a project root. Moving the effective cwd - // within the same project (e.g. switching worktree, or clicking a session - // that lives in another worktree) must not close the open session. - if (currentProject === newProject) { - return; - } - // Close any session that belongs to a different project — it no longer - // matches the selected project directory. - setSelectedSession(null); - setNewSessionCwd((prev) => { - if (prev && prev !== cwd) return null; - return prev; - }); - setSessionKey((k) => k + 1); - setBranchTree([]); - setBranchActiveLeafId(null); - setSystemPrompt(null); - setActiveTopPanel(null); - // File tabs are keyed by absolute path, so tabs opened in the previous - // project would otherwise linger after switching to a different project. - // Reached only past the same-project early return above, so worktrees of - // one repo keep their open tabs. Mirror handleCloseFileTab and close the - // now-empty right panel. - setFileTabs([]); - setActiveFileTabId(null); - setRightPanelOpen(false); - router.replace("/", { scroll: false }); - }, [router, selectedSession]); + // Keep the project identity in sync during the initial URL restore without + // remounting the just-created or restored chat. + if (suppressCwdBumpRef.current) { + suppressCwdBumpRef.current = false; + return; + } + // Worktrees of one repo share a project root. Moving the effective cwd + // within the same project (e.g. switching worktree, or clicking a session + // that lives in another worktree) must not close the open session. + if (currentProject === newProject) { + return; + } + // Close any session that belongs to a different project — it no longer + // matches the selected project directory. + setSelectedSession(null); + setNewSessionCwd((prev) => { + if (prev && prev !== cwd) return null; + return prev; + }); + setSessionKey((k) => k + 1); + setBranchTree([]); + setBranchActiveLeafId(null); + setSystemPrompt(null); + setActiveTopPanel(null); + // File tabs are keyed by absolute path, so tabs opened in the previous + // project would otherwise linger after switching to a different project. + // Reached only past the same-project early return above, so worktrees of + // one repo keep their open tabs. Mirror handleCloseFileTab and close the + // now-empty right panel. + setFileTabs([]); + setActiveFileTabId(null); + setRightPanelOpen(false); + router.replace("/", { scroll: false }); + }, + [router, selectedSession], + ); - const handleSelectSession = useCallback((session: SessionInfo, isRestore = false) => { - setNewSessionCwd(null); - setSelectedSession(session); - setSessionKey((k) => k + 1); - setSystemPrompt(null); - setInitialSessionRestored(true); - // On mobile, collapse the overlay drawer so the chat is revealed after pick. - if (isMobile && !isRestore) setSidebarOpen(false); - if (isRestore) { - // Suppress the redundant sessionKey bump that would come from the - // onCwdChange effect firing after setSelectedCwd in the sidebar - suppressCwdBumpRef.current = true; - } - // Skip router.replace when restoring from URL — the param is already correct - // and calling replace in production Next.js triggers a Suspense remount loop - if (!isRestore) { - router.replace(`?session=${encodeURIComponent(session.id)}`, { scroll: false }); - } - }, [router, isMobile]); + const handleSelectSession = useCallback( + (session: SessionInfo, isRestore = false) => { + setNewSessionCwd(null); + setSelectedSession(session); + setSessionKey((k) => k + 1); + setSystemPrompt(null); + setInitialSessionRestored(true); + // On mobile, collapse the overlay drawer so the chat is revealed after pick. + if (isMobile && !isRestore) setSidebarOpen(false); + if (isRestore) { + // Suppress the redundant sessionKey bump that would come from the + // onCwdChange effect firing after setSelectedCwd in the sidebar + suppressCwdBumpRef.current = true; + } + // Skip router.replace when restoring from URL — the param is already correct + // and calling replace in production Next.js triggers a Suspense remount loop + if (!isRestore) { + router.replace(`?session=${encodeURIComponent(session.id)}`, { + scroll: false, + }); + } + }, + [router, isMobile], + ); - const handleNewSession = useCallback((_sessionId: string, cwd: string) => { - setSelectedSession(null); - setNewSessionCwd(cwd); - setSessionKey((k) => k + 1); - setBranchTree([]); - setBranchActiveLeafId(null); - setSystemPrompt(null); - setActiveTopPanel(null); - if (isMobile) setSidebarOpen(false); - router.replace("/", { scroll: false }); - }, [router, isMobile]); + const handleNewSession = useCallback( + (_sessionId: string, cwd: string) => { + setSelectedSession(null); + setNewSessionCwd(cwd); + setSessionKey((k) => k + 1); + setBranchTree([]); + setBranchActiveLeafId(null); + setSystemPrompt(null); + setActiveTopPanel(null); + if (isMobile) setSidebarOpen(false); + router.replace("/", { scroll: false }); + }, + [router, isMobile], + ); - // Global keyboard shortcuts (handles Esc, Ctrl+Alt+N etc.) - useGlobalKeyboardShortcuts({ - onNewSession: (cwd: string) => handleNewSession(`kb-${Date.now()}`, cwd), - activeCwd, - }); + // Global keyboard shortcuts (handles Esc, Ctrl+Alt+N etc.) + useGlobalKeyboardShortcuts({ + onNewSession: (cwd: string) => handleNewSession(`kb-${Date.now()}`, cwd), + activeCwd, + }); - // Client-built transient SessionInfo (new session / fork) lacks the - // server-computed projectRoot, which the same-project check in - // handleCwdChange relies on. Hydrate it from the session list so switching - // worktrees right after creating a session doesn't close the chat. - const hydrateSelectedSession = useCallback((sessionId: string) => { - void fetch("/api/sessions") - .then((r) => (r.ok ? (r.json() as Promise<{ sessions: SessionInfo[] }>) : null)) - .then((d) => { - const full = d?.sessions.find((s) => s.id === sessionId); - if (!full) return; - setSelectedSession((prev) => (prev && prev.id === sessionId && !prev.projectRoot ? full : prev)); - }) - .catch(() => {}); - }, []); + // Client-built transient SessionInfo (new session / fork) lacks the + // server-computed projectRoot, which the same-project check in + // handleCwdChange relies on. Hydrate it from the session list so switching + // worktrees right after creating a session doesn't close the chat. + const hydrateSelectedSession = useCallback((sessionId: string) => { + void fetch("/api/sessions") + .then((r) => + r.ok ? (r.json() as Promise<{ sessions: SessionInfo[] }>) : null, + ) + .then((d) => { + const full = d?.sessions.find((s) => s.id === sessionId); + if (!full) return; + setSelectedSession((prev) => + prev && prev.id === sessionId && !prev.projectRoot ? full : prev, + ); + }) + .catch(() => {}); + }, []); - // Called by ChatWindow when a new session gets its real id from pi - const handleSessionCreated = useCallback((session: SessionInfo) => { - setNewSessionCwd(null); - setSelectedSession(session); - setRefreshKey((k) => k + 1); - hydrateSelectedSession(session.id); - router.replace(`?session=${encodeURIComponent(session.id)}`, { scroll: false }); - }, [router, hydrateSelectedSession]); + // Called by ChatWindow when a new session gets its real id from pi + const handleSessionCreated = useCallback( + (session: SessionInfo) => { + setNewSessionCwd(null); + setSelectedSession(session); + setRefreshKey((k) => k + 1); + hydrateSelectedSession(session.id); + router.replace(`?session=${encodeURIComponent(session.id)}`, { + scroll: false, + }); + }, + [router, hydrateSelectedSession], + ); - const handleAgentEnd = useCallback(() => { - setRefreshKey((k) => k + 1); - setExplorerRefreshKey((k) => k + 1); - }, []); + const handleAgentEnd = useCallback(() => { + setRefreshKey((k) => k + 1); + setExplorerRefreshKey((k) => k + 1); + }, []); - const handleAutoName = useCallback(async () => { - const sessionId = selectedSession?.id; - if (!sessionId || autoNameStatus.kind === "naming") return; - if (autoNameTimerRef.current) clearTimeout(autoNameTimerRef.current); - setActiveTopPanel(null); - setAutoNameStatus({ kind: "naming" }); + const handleAutoName = useCallback(async () => { + const sessionId = selectedSession?.id; + if (!sessionId || autoNameStatus.kind === "naming") return; + if (autoNameTimerRef.current) clearTimeout(autoNameTimerRef.current); + setActiveTopPanel(null); + setAutoNameStatus({ kind: "naming" }); - try { - const response = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/auto-name`, { - method: "POST", - }); - const body = (await response.json().catch(() => ({}))) as { title?: string; error?: string }; - if (!response.ok || !body.title) { - throw new Error(body.error || `HTTP ${response.status}`); - } + try { + const response = await fetch( + `/api/sessions/${encodeURIComponent(sessionId)}/auto-name`, + { + method: "POST", + }, + ); + const body = (await response.json().catch(() => ({}))) as { + title?: string; + error?: string; + }; + if (!response.ok || !body.title) { + throw new Error(body.error || `HTTP ${response.status}`); + } - const title = body.title.trim(); - setRefreshKey((key) => key + 1); - if (activeSessionIdRef.current !== sessionId) return; - setSelectedSession((current) => current?.id === sessionId ? { ...current, name: title } : current); - setSessionStats((current) => current?.sessionId === sessionId ? { ...current, sessionName: title } : current); - setAutoNameStatus({ kind: "success" }); - autoNameTimerRef.current = setTimeout(() => setAutoNameStatus({ kind: "idle" }), 1800); - } catch (error) { - if (activeSessionIdRef.current !== sessionId) return; - const message = error instanceof Error ? error.message : String(error); - setAutoNameStatus({ kind: "error", message }); - autoNameTimerRef.current = setTimeout(() => setAutoNameStatus({ kind: "idle" }), 5000); - } - }, [autoNameStatus.kind, selectedSession?.id]); + const title = body.title.trim(); + setRefreshKey((key) => key + 1); + if (activeSessionIdRef.current !== sessionId) return; + setSelectedSession((current) => + current?.id === sessionId ? { ...current, name: title } : current, + ); + setSessionStats((current) => + current?.sessionId === sessionId + ? { ...current, sessionName: title } + : current, + ); + setAutoNameStatus({ kind: "success" }); + autoNameTimerRef.current = setTimeout( + () => setAutoNameStatus({ kind: "idle" }), + 1800, + ); + } catch (error) { + if (activeSessionIdRef.current !== sessionId) return; + const message = error instanceof Error ? error.message : String(error); + setAutoNameStatus({ kind: "error", message }); + autoNameTimerRef.current = setTimeout( + () => setAutoNameStatus({ kind: "idle" }), + 5000, + ); + } + }, [autoNameStatus.kind, selectedSession?.id]); - useEffect(() => { - if (autoNameTimerRef.current) clearTimeout(autoNameTimerRef.current); - setAutoNameStatus({ kind: "idle" }); - }, [selectedSession?.id]); + useEffect(() => { + if (autoNameTimerRef.current) clearTimeout(autoNameTimerRef.current); + setAutoNameStatus({ kind: "idle" }); + }, [selectedSession?.id]); - const handleExplorerRefresh = useCallback(() => { - setExplorerRefreshKey((k) => k + 1); - }, []); + const handleExplorerRefresh = useCallback(() => { + setExplorerRefreshKey((k) => k + 1); + }, []); - const handleSessionForked = useCallback((newSessionId: string) => { - setRefreshKey((k) => k + 1); - setSessionKey((k) => k + 1); - setNewSessionCwd(null); - setSelectedSession((prev) => ({ - ...(prev ?? { path: "", cwd: "", created: "", modified: "", messageCount: 0, firstMessage: "" }), - id: newSessionId, - })); - hydrateSelectedSession(newSessionId); - router.replace(`?session=${encodeURIComponent(newSessionId)}`, { scroll: false }); - }, [router, hydrateSelectedSession]); + const handleSessionForked = useCallback( + (newSessionId: string) => { + setRefreshKey((k) => k + 1); + setSessionKey((k) => k + 1); + setNewSessionCwd(null); + setSelectedSession((prev) => ({ + ...(prev ?? { + path: "", + cwd: "", + created: "", + modified: "", + messageCount: 0, + firstMessage: "", + }), + id: newSessionId, + })); + hydrateSelectedSession(newSessionId); + router.replace(`?session=${encodeURIComponent(newSessionId)}`, { + scroll: false, + }); + }, + [router, hydrateSelectedSession], + ); - const handleInitialRestoreDone = useCallback(() => { - setInitialSessionRestored(true); - }, []); + const handleInitialRestoreDone = useCallback(() => { + setInitialSessionRestored(true); + }, []); - const handleSessionDeleted = useCallback((sessionId: string) => { - setRefreshKey((k) => k + 1); - if (selectedSession?.id === sessionId) { - const cwd = selectedSession.cwd; - setSelectedSession(null); - setNewSessionCwd(cwd ?? null); - setSessionKey((k) => k + 1); - setBranchTree([]); - setBranchActiveLeafId(null); - setSystemPrompt(null); - setActiveTopPanel(null); - router.replace("/", { scroll: false }); - } - }, [selectedSession, router]); + const handleSessionDeleted = useCallback( + (sessionId: string) => { + setRefreshKey((k) => k + 1); + if (selectedSession?.id === sessionId) { + const cwd = selectedSession.cwd; + setSelectedSession(null); + setNewSessionCwd(cwd ?? null); + setSessionKey((k) => k + 1); + setBranchTree([]); + setBranchActiveLeafId(null); + setSystemPrompt(null); + setActiveTopPanel(null); + router.replace("/", { scroll: false }); + } + }, + [selectedSession, router], + ); - const handleOpenFile = useCallback(( - filePath: string, - fileName: string, - options?: { sourceSessionId?: string | null; modeHint?: "diff" }, - ) => { - const sourceSessionId = options?.sourceSessionId; - const modeHint = options?.modeHint; - const tabId = `file:${filePath}`; - setFileTabs((prev) => { - const existing = prev.find((t) => t.id === tabId); - if (!existing) { - return [...prev, { - id: tabId, - label: fileName, - filePath, - sourceSessionId, - initialDisplayMode: modeHint, - }]; - } - const sourceUnchanged = !sourceSessionId || existing.sourceSessionId === sourceSessionId; - const modeUnchanged = !modeHint || existing.initialDisplayMode === modeHint; - if (sourceUnchanged && modeUnchanged) return prev; - return prev.map((t) => { - if (t.id !== tabId) return t; - const next: Tab = { ...t }; - if (sourceSessionId) next.sourceSessionId = sourceSessionId; - if (modeHint) next.initialDisplayMode = modeHint; - return next; - }); - }); - setActiveFileTabId(tabId); - setRightPanelOpen(true); - // On mobile the file panel is full-screen; close the drawer so it shows. - if (isMobile) setSidebarOpen(false); - }, [isMobile]); + const handleOpenFile = useCallback( + ( + filePath: string, + fileName: string, + options?: { sourceSessionId?: string | null; modeHint?: "diff" }, + ) => { + const sourceSessionId = options?.sourceSessionId; + const modeHint = options?.modeHint; + const tabId = `file:${filePath}`; + setFileTabs((prev) => { + const existing = prev.find((t) => t.id === tabId); + if (!existing) { + return [ + ...prev, + { + id: tabId, + label: fileName, + filePath, + sourceSessionId, + initialDisplayMode: modeHint, + }, + ]; + } + const sourceUnchanged = + !sourceSessionId || existing.sourceSessionId === sourceSessionId; + const modeUnchanged = + !modeHint || existing.initialDisplayMode === modeHint; + if (sourceUnchanged && modeUnchanged) return prev; + return prev.map((t) => { + if (t.id !== tabId) return t; + const next: Tab = { ...t }; + if (sourceSessionId) next.sourceSessionId = sourceSessionId; + if (modeHint) next.initialDisplayMode = modeHint; + return next; + }); + }); + setActiveFileTabId(tabId); + setRightPanelOpen(true); + // On mobile the file panel is full-screen; close the drawer so it shows. + if (isMobile) setSidebarOpen(false); + }, + [isMobile], + ); - const handleOpenLinkedFile = useCallback((filePath: string) => { - handleOpenFile(filePath, getFileName(filePath), { sourceSessionId: selectedSession?.id ?? null }); - }, [handleOpenFile, selectedSession?.id]); + const handleOpenLinkedFile = useCallback( + (filePath: string) => { + handleOpenFile(filePath, getFileName(filePath), { + sourceSessionId: selectedSession?.id ?? null, + }); + }, + [handleOpenFile, selectedSession?.id], + ); - const handleCloseFileTab = useCallback((tabId: string) => { - setFileTabs((prev) => { - const next = prev.filter((t) => t.id !== tabId); - if (next.length === 0) setRightPanelOpen(false); - return next; - }); - setActiveFileTabId((cur) => { - if (cur !== tabId) return cur; - const remaining = fileTabs.filter((t) => t.id !== tabId); - return remaining.length > 0 ? remaining[remaining.length - 1].id : null; - }); - }, [fileTabs]); + const handleCloseFileTab = useCallback( + (tabId: string) => { + setFileTabs((prev) => { + const next = prev.filter((t) => t.id !== tabId); + if (next.length === 0) setRightPanelOpen(false); + return next; + }); + setActiveFileTabId((cur) => { + if (cur !== tabId) return cur; + const remaining = fileTabs.filter((t) => t.id !== tabId); + return remaining.length > 0 ? remaining[remaining.length - 1].id : null; + }); + }, + [fileTabs], + ); - const handleViewFullHistory = useCallback(() => { - if (!selectedSession) return; - window.open( - `/api/sessions/${encodeURIComponent(selectedSession.id)}/export?inline=1`, - "_blank", - "noopener,noreferrer", - ); - }, [selectedSession]); + const handleViewFullHistory = useCallback(() => { + if (!selectedSession) return; + window.open( + `/api/sessions/${encodeURIComponent(selectedSession.id)}/export?inline=1`, + "_blank", + "noopener,noreferrer", + ); + }, [selectedSession]); - // Show chat area if a session is selected, or if we have a cwd to start a new session in - const effectiveNewSessionCwd = newSessionCwd ?? (selectedSession === null && activeCwd ? activeCwd : null); - const showChat = selectedSession !== null || effectiveNewSessionCwd !== null; - const projectTrustCwd = selectedSession?.cwd ?? effectiveNewSessionCwd; - // While restoring initial session from URL, don't show the placeholder - const showPlaceholder = initialSessionRestored && !showChat; + // Show chat area if a session is selected, or if we have a cwd to start a new session in + const effectiveNewSessionCwd = + newSessionCwd ?? (selectedSession === null && activeCwd ? activeCwd : null); + const showChat = selectedSession !== null || effectiveNewSessionCwd !== null; + const projectTrustCwd = selectedSession?.cwd ?? effectiveNewSessionCwd; + // While restoring initial session from URL, don't show the placeholder + const showPlaceholder = initialSessionRestored && !showChat; - useEffect(() => { - setProjectTrust(null); - setProjectTrustDialogOpen(false); - setProjectTrustError(null); - if (!projectTrustCwd) return; + useEffect(() => { + setProjectTrust(null); + setProjectTrustDialogOpen(false); + setProjectTrustError(null); + if (!projectTrustCwd) return; - const controller = new AbortController(); - fetch(`/api/project-trust?cwd=${encodeURIComponent(projectTrustCwd)}`, { - signal: controller.signal, - }) - .then(async (response) => { - const data = await response.json() as ProjectTrustStatus & { error?: string }; - if (!response.ok || data.error) throw new Error(data.error ?? `HTTP ${response.status}`); - setProjectTrust(data); - }) - .catch((error) => { - if (error instanceof DOMException && error.name === "AbortError") return; - console.error("Failed to load project trust:", error); - }); - return () => controller.abort(); - }, [projectTrustCwd]); + const controller = new AbortController(); + fetch(`/api/project-trust?cwd=${encodeURIComponent(projectTrustCwd)}`, { + signal: controller.signal, + }) + .then(async (response) => { + const data = (await response.json()) as ProjectTrustStatus & { + error?: string; + }; + if (!response.ok || data.error) + throw new Error(data.error ?? `HTTP ${response.status}`); + setProjectTrust(data); + }) + .catch((error) => { + if (error instanceof DOMException && error.name === "AbortError") + return; + console.error("Failed to load project trust:", error); + }); + return () => controller.abort(); + }, [projectTrustCwd]); - const handleTrustProject = useCallback(async () => { - if (!projectTrustCwd || projectTrustBusy) return; - setProjectTrustBusy(true); - setProjectTrustError(null); - try { - const response = await fetch("/api/project-trust", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ cwd: projectTrustCwd }), - }); - const data = await response.json() as ProjectTrustStatus & { error?: string }; - if (!response.ok || data.error) throw new Error(data.error ?? `HTTP ${response.status}`); - setProjectTrust(data); - setProjectTrustDialogOpen(false); - setModelsRefreshKey((key) => key + 1); - setSessionKey((key) => key + 1); - } catch (error) { - setProjectTrustError(error instanceof Error ? error.message : String(error)); - } finally { - setProjectTrustBusy(false); - } - }, [projectTrustBusy, projectTrustCwd]); + const handleTrustProject = useCallback(async () => { + if (!projectTrustCwd || projectTrustBusy) return; + setProjectTrustBusy(true); + setProjectTrustError(null); + try { + const response = await fetch("/api/project-trust", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ cwd: projectTrustCwd }), + }); + const data = (await response.json()) as ProjectTrustStatus & { + error?: string; + }; + if (!response.ok || data.error) + throw new Error(data.error ?? `HTTP ${response.status}`); + setProjectTrust(data); + setProjectTrustDialogOpen(false); + setModelsRefreshKey((key) => key + 1); + setSessionKey((key) => key + 1); + } catch (error) { + setProjectTrustError( + error instanceof Error ? error.message : String(error), + ); + } finally { + setProjectTrustBusy(false); + } + }, [projectTrustBusy, projectTrustCwd]); - const activeFileTab = fileTabs.find((t) => t.id === activeFileTabId) ?? null; - const activeCwdName = activeCwd ? getFileName(activeCwd) || activeCwd : null; - const windowTitle = activeCwdName ? `${activeCwdName} - Pi Web` : "Pi Web"; + const activeFileTab = fileTabs.find((t) => t.id === activeFileTabId) ?? null; + const activeCwdName = activeCwd ? getFileName(activeCwd) || activeCwd : null; + const windowTitle = activeCwdName ? `${activeCwdName} - Pi Web` : "Pi Web"; - useEffect(() => { - const syncWindowTitle = () => { - if (document.title !== windowTitle) document.title = windowTitle; - }; + useEffect(() => { + const syncWindowTitle = () => { + if (document.title !== windowTitle) document.title = windowTitle; + }; - syncWindowTitle(); - const observer = new MutationObserver(syncWindowTitle); - observer.observe(document.head, { childList: true, subtree: true, characterData: true }); - return () => observer.disconnect(); - }, [windowTitle]); + syncWindowTitle(); + const observer = new MutationObserver(syncWindowTitle); + observer.observe(document.head, { + childList: true, + subtree: true, + characterData: true, + }); + return () => observer.disconnect(); + }, [windowTitle]); - const sidebarContent = ( - <> - -
- {([ - { - label: translate("common.models"), - onClick: () => setModelsConfigOpen(true), - disabled: false, - icon: ( - - - - - - - - ), - }, - { - label: translate("common.mcp"), - onClick: () => setMcpConfigOpen(true), - disabled: false, - icon: ( - - - - - ), - }, - { - label: translate("common.skills"), - onClick: () => setSkillsConfigOpen(true), - disabled: !activeCwd && !selectedSession?.cwd && !newSessionCwd, - icon: ( - - - - - - ), - }, - { - label: translate("common.plugins"), - onClick: () => setPluginsConfigOpen(true), - disabled: !activeCwd && !selectedSession?.cwd && !newSessionCwd, - icon: ( - - - - - - - ), - }, - ] as { label: string; onClick: () => void; disabled: boolean; icon: React.ReactNode }[]).map(({ label, onClick, disabled, icon }) => ( - - ))} -
- - ); + const sidebarContent = ( + <> + +
+ {( + [ + { + label: translate("common.models"), + onClick: () => setModelsConfigOpen(true), + disabled: false, + icon: ( + + + + + + + + + + + + + ), + }, + { + label: translate("common.mcp"), + onClick: () => setMcpConfigOpen(true), + disabled: false, + icon: ( + + + + + ), + }, + { + label: translate("common.skills"), + onClick: () => setSkillsConfigOpen(true), + disabled: !activeCwd && !selectedSession?.cwd && !newSessionCwd, + icon: ( + + + + + + ), + }, + { + label: translate("common.plugins"), + onClick: () => setPluginsConfigOpen(true), + disabled: !activeCwd && !selectedSession?.cwd && !newSessionCwd, + icon: ( + + + + + + + ), + }, + { + label: translate("common.extensions"), + onClick: () => setExtensionsConfigOpen(true), + disabled: !activeCwd && !selectedSession?.cwd && !newSessionCwd, + icon: ( + + + + + + + + ), + }, + ] as { + label: string; + onClick: () => void; + disabled: boolean; + icon: React.ReactNode; + }[] + ).map(({ label, onClick, disabled, icon }) => ( + + ))} +
+ + ); - return ( - <> - -
- {/* Mobile overlay backdrop */} -
setSidebarOpen(false)} - style={{ - position: "fixed", - inset: 0, - zIndex: 199, - background: "rgba(0,0,0,0.4)", - opacity: sidebarOpen ? 1 : 0, - pointerEvents: sidebarOpen ? "auto" : "none", - transition: "opacity 0.25s ease", - }} - /> +
+ {/* Mobile overlay backdrop */} +
setSidebarOpen(false)} + style={{ + position: "fixed", + inset: 0, + zIndex: 199, + background: "rgba(0,0,0,0.4)", + opacity: sidebarOpen ? 1 : 0, + pointerEvents: sidebarOpen ? "auto" : "none", + transition: "opacity 0.25s ease", + }} + /> - {/* Left sidebar */} -
- {sidebarContent} -
+ {/* Left sidebar */} +
+ {sidebarContent} +
- {/* Center: chat */} -
- {/* Top bar with sidebar toggle */} -
- - - - {showChat && projectTrust?.requiresTrust && !projectTrust.trusted && ( - - )} - {showChat && ( -
- - {(() => { - const hasMessages = Boolean( - selectedSession - && (sessionStats?.userMessages ?? selectedSession.messageCount) > 0, - ); - const disabled = !selectedSession || !hasMessages || autoNameStatus.kind === "naming"; - const isSuccess = autoNameStatus.kind === "success"; - const isError = autoNameStatus.kind === "error"; - const label = autoNameStatus.kind === "naming" - ? translate("title.generating") - : isSuccess - ? translate("title.updated") - : isError - ? translate("title.failed") - : translate("title.generate"); - const title = !selectedSession - ? translate("title.unsaved") - : !hasMessages - ? translate("title.noMessages") - : isError - ? autoNameStatus.message - : translate("title.generateSession"); + {/* Center: chat */} +
+ {/* Top bar with sidebar toggle */} +
+ + + + {showChat && + projectTrust?.requiresTrust && + !projectTrust.trusted && ( + + )} + {showChat && ( +
+ + {(() => { + const hasMessages = Boolean( + selectedSession && + (sessionStats?.userMessages ?? + selectedSession.messageCount) > 0, + ); + const disabled = + !selectedSession || + !hasMessages || + autoNameStatus.kind === "naming"; + const isSuccess = autoNameStatus.kind === "success"; + const isError = autoNameStatus.kind === "error"; + const label = + autoNameStatus.kind === "naming" + ? translate("title.generating") + : isSuccess + ? translate("title.updated") + : isError + ? translate("title.failed") + : translate("title.generate"); + const title = !selectedSession + ? translate("title.unsaved") + : !hasMessages + ? translate("title.noMessages") + : isError + ? autoNameStatus.message + : translate("title.generateSession"); - return ( - - ); - })()} - toggleTopPanel("branches")} - hasSession - /> - -
- )} - {/* Session stats — right-aligned in top bar */} - {showChat && (sessionStats || contextUsage) && (() => { - const tokens = sessionStats?.tokens; - const c = sessionStats?.cost ?? 0; - const fmt = (n: number) => n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${(n / 1000).toFixed(0)}k` : String(n); - const costStr = c > 0 ? (c >= 0.01 ? `$${c.toFixed(2)}` : `<$0.01`) : null; + return ( + + ); + })()} + toggleTopPanel("branches")} + hasSession + /> + +
+ )} + {/* Session stats — right-aligned in top bar */} + {showChat && + (sessionStats || contextUsage) && + (() => { + const tokens = sessionStats?.tokens; + const c = sessionStats?.cost ?? 0; + const fmt = (n: number) => + n >= 1_000_000 + ? `${(n / 1_000_000).toFixed(1)}M` + : n >= 1000 + ? `${(n / 1000).toFixed(0)}k` + : String(n); + const costStr = + c > 0 ? (c >= 0.01 ? `$${c.toFixed(2)}` : `<$0.01`) : null; - let ctxColor = "var(--text-muted)"; - let ctxStr: string | null = null; - if (contextUsage?.contextWindow) { - const pct = contextUsage.percent; - if (pct !== null && pct > 90) ctxColor = "#ef4444"; - else if (pct !== null && pct > 70) ctxColor = "rgba(234,179,8,0.95)"; - ctxStr = pct !== null ? `${pct.toFixed(0)}% / ${fmt(contextUsage.contextWindow)}` : `? / ${fmt(contextUsage.contextWindow)}`; - } + let ctxColor = "var(--text-muted)"; + let ctxStr: string | null = null; + if (contextUsage?.contextWindow) { + const pct = contextUsage.percent; + if (pct !== null && pct > 90) ctxColor = "#ef4444"; + else if (pct !== null && pct > 70) + ctxColor = "rgba(234,179,8,0.95)"; + ctxStr = + pct !== null + ? `${pct.toFixed(0)}% / ${fmt(contextUsage.contextWindow)}` + : `? / ${fmt(contextUsage.contextWindow)}`; + } - const tooltipParts: string[] = []; - if (tokens) { - tooltipParts.push(`in: ${tokens.input.toLocaleString(locale)}`); - tooltipParts.push(`out: ${tokens.output.toLocaleString(locale)}`); - tooltipParts.push(`cache read: ${tokens.cacheRead.toLocaleString(locale)}`); - tooltipParts.push(`cache write: ${tokens.cacheWrite.toLocaleString(locale)}`); - if (c > 0) tooltipParts.push(`cost: $${c.toFixed(4)}`); - } - if (contextUsage?.contextWindow) { - const pct = contextUsage.percent; - tooltipParts.push(`context: ${pct !== null ? pct.toFixed(1) + "%" : "unknown"} of ${contextUsage.contextWindow.toLocaleString()} tokens`); - } - const tooltip = tooltipParts.join(" | "); + const tooltipParts: string[] = []; + if (tokens) { + tooltipParts.push( + `in: ${tokens.input.toLocaleString(locale)}`, + ); + tooltipParts.push( + `out: ${tokens.output.toLocaleString(locale)}`, + ); + tooltipParts.push( + `cache read: ${tokens.cacheRead.toLocaleString(locale)}`, + ); + tooltipParts.push( + `cache write: ${tokens.cacheWrite.toLocaleString(locale)}`, + ); + if (c > 0) tooltipParts.push(`cost: $${c.toFixed(4)}`); + } + if (contextUsage?.contextWindow) { + const pct = contextUsage.percent; + tooltipParts.push( + `context: ${pct !== null ? pct.toFixed(1) + "%" : "unknown"} of ${contextUsage.contextWindow.toLocaleString()} tokens`, + ); + } + const tooltip = tooltipParts.join(" | "); - return ( - - ); - })()} - {/* Top panel dropdown — shared, only one active at a time */} - {activeTopPanel && topPanelPos && ( -
- {activeTopPanel === "language" && ( -
- {supportedLocales.map((plugin) => ( - - ))} -
- )} - {activeTopPanel === "system" && ( -
- {systemPrompt ? ( -
- {systemPrompt} -
- ) : systemPrompt === "" ? ( -
- {translate("system.empty")} -
- ) : ( -
- {translate("system.load")} -
- )} -
- )} - {activeTopPanel === "session" && ( -
- {sessionStats ? (() => { - const sessionRows = [ - ...(sessionStats.sessionName ? [{ label: translate("session.name"), value: sessionStats.sessionName, copyField: null }] : []), - { label: translate("session.file"), value: sessionStats.sessionFile ?? translate("session.inMemory"), copyField: "file" as const }, - { label: translate("session.id"), value: sessionStats.sessionId, copyField: "id" as const }, - ]; - const messageRows = [ - [translate("session.user"), sessionStats.userMessages.toLocaleString(locale)], - [translate("session.assistant"), sessionStats.assistantMessages.toLocaleString(locale)], - [translate("session.toolCalls"), sessionStats.toolCalls.toLocaleString(locale)], - [translate("session.toolResults"), sessionStats.toolResults.toLocaleString(locale)], - [translate("session.total"), sessionStats.totalMessages.toLocaleString(locale)], - ]; - const promptTokens = sessionStats.tokens.input + sessionStats.tokens.cacheRead + sessionStats.tokens.cacheWrite; - const cacheHitRate = promptTokens > 0 && (sessionStats.tokens.cacheRead > 0 || sessionStats.tokens.cacheWrite > 0) - ? `${((sessionStats.tokens.cacheRead / promptTokens) * 100).toFixed(1)}%` - : null; - const tokenRows = [ - [translate("session.input"), sessionStats.tokens.input.toLocaleString(locale)], - [translate("session.output"), sessionStats.tokens.output.toLocaleString(locale)], - ...(sessionStats.tokens.cacheRead > 0 ? [[translate("session.cacheRead"), sessionStats.tokens.cacheRead.toLocaleString(locale)]] : []), - ...(sessionStats.tokens.cacheWrite > 0 ? [[translate("session.cacheWrite"), sessionStats.tokens.cacheWrite.toLocaleString(locale)]] : []), - ...(cacheHitRate ? [[translate("session.cacheHitRate"), cacheHitRate]] : []), - [translate("session.total"), sessionStats.tokens.total.toLocaleString(locale)], - ]; - const ctx = contextUsage ?? sessionStats.contextUsage; - const formatCompact = (n: number) => n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${(n / 1000).toFixed(0)}k` : String(n); - const extraTokenRows = [ - ...(sessionStats.cost > 0 ? [[translate("session.cost"), `$${sessionStats.cost.toFixed(4)}`]] : []), - ...(ctx?.contextWindow ? [[translate("session.context"), `${ctx.percent !== null ? `${ctx.percent.toFixed(1)}%` : "?"} / ${formatCompact(ctx.contextWindow)}`]] : []), - ]; - const section = ( - title: string, - sectionRows: string[][], - valueAlign: "left" | "right" = "left", - compact = false, - ) => ( -
-
{title}
-
- {sectionRows.map(([label, value]) => ( -
-
{label}
-
{value}
-
- ))} -
-
- ); - const copyButton = (field: SessionCopyField, value: string) => { - const copied = copiedSessionField === field; - return ( - - ); - }; - const sessionInfoSection = ( -
-
{translate("session.infoSection")}
-
- {sessionRows.map((row) => ( -
-
{row.label}
-
{row.value}
-
{row.copyField ? copyButton(row.copyField, row.value) : null}
-
- ))} -
-
- ); + return ( + + ); + })()} + {/* Top panel dropdown — shared, only one active at a time */} + {activeTopPanel && topPanelPos && ( +
+ {activeTopPanel === "language" && ( +
+ {supportedLocales.map((plugin) => ( + + ))} +
+ )} + {activeTopPanel === "system" && ( +
+ {systemPrompt ? ( +
+ {systemPrompt} +
+ ) : systemPrompt === "" ? ( +
+ {translate("system.empty")} +
+ ) : ( +
+ {translate("system.load")} +
+ )} +
+ )} + {activeTopPanel === "session" && ( +
+ {sessionStats ? ( + (() => { + const sessionRows = [ + ...(sessionStats.sessionName + ? [ + { + label: translate("session.name"), + value: sessionStats.sessionName, + copyField: null, + }, + ] + : []), + { + label: translate("session.file"), + value: + sessionStats.sessionFile ?? + translate("session.inMemory"), + copyField: "file" as const, + }, + { + label: translate("session.id"), + value: sessionStats.sessionId, + copyField: "id" as const, + }, + ]; + const messageRows = [ + [ + translate("session.user"), + sessionStats.userMessages.toLocaleString(locale), + ], + [ + translate("session.assistant"), + sessionStats.assistantMessages.toLocaleString( + locale, + ), + ], + [ + translate("session.toolCalls"), + sessionStats.toolCalls.toLocaleString(locale), + ], + [ + translate("session.toolResults"), + sessionStats.toolResults.toLocaleString(locale), + ], + [ + translate("session.total"), + sessionStats.totalMessages.toLocaleString(locale), + ], + ]; + const promptTokens = + sessionStats.tokens.input + + sessionStats.tokens.cacheRead + + sessionStats.tokens.cacheWrite; + const cacheHitRate = + promptTokens > 0 && + (sessionStats.tokens.cacheRead > 0 || + sessionStats.tokens.cacheWrite > 0) + ? `${((sessionStats.tokens.cacheRead / promptTokens) * 100).toFixed(1)}%` + : null; + const tokenRows = [ + [ + translate("session.input"), + sessionStats.tokens.input.toLocaleString(locale), + ], + [ + translate("session.output"), + sessionStats.tokens.output.toLocaleString(locale), + ], + ...(sessionStats.tokens.cacheRead > 0 + ? [ + [ + translate("session.cacheRead"), + sessionStats.tokens.cacheRead.toLocaleString( + locale, + ), + ], + ] + : []), + ...(sessionStats.tokens.cacheWrite > 0 + ? [ + [ + translate("session.cacheWrite"), + sessionStats.tokens.cacheWrite.toLocaleString( + locale, + ), + ], + ] + : []), + ...(cacheHitRate + ? [ + [ + translate("session.cacheHitRate"), + cacheHitRate, + ], + ] + : []), + [ + translate("session.total"), + sessionStats.tokens.total.toLocaleString(locale), + ], + ]; + const ctx = contextUsage ?? sessionStats.contextUsage; + const formatCompact = (n: number) => + n >= 1_000_000 + ? `${(n / 1_000_000).toFixed(1)}M` + : n >= 1000 + ? `${(n / 1000).toFixed(0)}k` + : String(n); + const extraTokenRows = [ + ...(sessionStats.cost > 0 + ? [ + [ + translate("session.cost"), + `$${sessionStats.cost.toFixed(4)}`, + ], + ] + : []), + ...(ctx?.contextWindow + ? [ + [ + translate("session.context"), + `${ctx.percent !== null ? `${ctx.percent.toFixed(1)}%` : "?"} / ${formatCompact(ctx.contextWindow)}`, + ], + ] + : []), + ]; + const section = ( + title: string, + sectionRows: string[][], + valueAlign: "left" | "right" = "left", + compact = false, + ) => ( +
+
+ {title} +
+
+ {sectionRows.map(([label, value]) => ( +
+
+ {label} +
+
+ {value} +
+
+ ))} +
+
+ ); + const copyButton = ( + field: SessionCopyField, + value: string, + ) => { + const copied = copiedSessionField === field; + return ( + + ); + }; + const sessionInfoSection = ( +
+
+ {translate("session.infoSection")} +
+
+ {sessionRows.map((row) => ( +
+
+ {row.label} +
+
+ {row.value} +
+
+ {row.copyField + ? copyButton(row.copyField, row.value) + : null} +
+
+ ))} +
+
+ ); - return ( -
- {sessionInfoSection} - {section(translate("session.messages"), messageRows)} - {section(translate("session.tokens"), [...tokenRows, ...extraTokenRows], "right", true)} -
- ); - })() : ( -
- {translate("session.load")} -
- )} -
- )} -
- )} + return ( +
+ {sessionInfoSection} + {section( + translate("session.messages"), + messageRows, + )} + {section( + translate("session.tokens"), + [...tokenRows, ...extraTokenRows], + "right", + true, + )} +
+ ); + })() + ) : ( +
+ {translate("session.load")} +
+ )} +
+ )} +
+ )} +
-
+ {/* Chat content */} +
+ {showChat ? ( + + ) : initialCwdStatus === "validating" ? ( +
+
+ {translate("workspace.opening")} +
+
+ {initialNavigation.requestedCwd} +
+
+ ) : initialCwdStatus === "error" ? ( +
+
+ {translate("workspace.unable")} +
+
+ {initialNavigation.requestedCwd} +
+
+ {initialCwdError} +
+
+ ) : showPlaceholder ? ( + activeCwd ? ( +
+ {translate("workspace.selectSession")} +
+ ) : ( +
+ + + + +
+
+ {translate("workspace.getStarted")} +
+
+ + 1. + + {translate("workspace.selectProject")} +
+ + 2. + + {translate("workspace.addModels")} +
+
+
+ ) + ) : null} +
+
- {/* Chat content */} -
- {showChat ? ( - - ) : initialCwdStatus === "validating" ? ( -
-
{translate("workspace.opening")}
-
- {initialNavigation.requestedCwd} -
-
- ) : initialCwdStatus === "error" ? ( -
-
{translate("workspace.unable")}
-
- {initialNavigation.requestedCwd} -
-
{initialCwdError}
-
- ) : showPlaceholder ? ( - activeCwd ? ( -
- {translate("workspace.selectSession")} -
- ) : ( -
- - - -
-
{translate("workspace.getStarted")}
-
- 1.{translate("workspace.selectProject")}
- 2.{translate("workspace.addModels")} -
-
-
- ) - ) : null} -
-
+ {/* Right panel: file viewer — always mounted, width animated via CSS */} +
+ {/* Right panel tab bar */} +
+
+ +
+
- {/* Right panel: file viewer — always mounted, width animated via CSS */} -
- {/* Right panel tab bar */} -
-
- -
- -
- - {/* File content */} -
- {activeFileTab?.filePath ? ( - setExplorerRefreshKey((k) => k + 1)} - onOpenFile={(filePath) => handleOpenFile( - filePath, - getFileName(filePath), - { sourceSessionId: activeFileTab.sourceSessionId }, - )} - /> - ) : ( -
- {translate("files.noneOpen")} -
- )} -
-
-
- {/* File panel toggle — always visible at top-right */} - - {modelsConfigOpen && { setModelsConfigOpen(false); setModelsRefreshKey((k) => k + 1); }} />} - {mcpConfigOpen && setMcpConfigOpen(false)} />} - {projectTrustDialogOpen && projectTrustCwd && ( - { - if (!projectTrustBusy) setProjectTrustDialogOpen(false); - }} - onConfirm={() => void handleTrustProject()} - /> - )} - {skillsConfigOpen && projectTrustCwd && ( - setSkillsConfigOpen(false)} /> - )} - {pluginsConfigOpen && projectTrustCwd && ( - setPluginsConfigOpen(false)} - onReloaded={() => setSessionKey((k) => k + 1)} - /> - )} - - ); + {/* File content */} +
+ {activeFileTab?.filePath ? ( + setExplorerRefreshKey((k) => k + 1)} + onOpenFile={(filePath) => + handleOpenFile(filePath, getFileName(filePath), { + sourceSessionId: activeFileTab.sourceSessionId, + }) + } + /> + ) : ( +
+ {translate("files.noneOpen")} +
+ )} +
+
+
+ {/* File panel toggle — always visible at top-right */} + + {modelsConfigOpen && ( + { + setModelsConfigOpen(false); + setModelsRefreshKey((k) => k + 1); + }} + /> + )} + {mcpConfigOpen && setMcpConfigOpen(false)} />} + {projectTrustDialogOpen && projectTrustCwd && ( + { + if (!projectTrustBusy) setProjectTrustDialogOpen(false); + }} + onConfirm={() => void handleTrustProject()} + /> + )} + {skillsConfigOpen && projectTrustCwd && ( + setSkillsConfigOpen(false)} + /> + )} + {pluginsConfigOpen && projectTrustCwd && ( + setPluginsConfigOpen(false)} + onReloaded={() => setSessionKey((k) => k + 1)} + /> + )} + {extensionsConfigOpen && projectTrustCwd && ( + setExtensionsConfigOpen(false)} + /> + )} + + ); } diff --git a/pi-web/components/ExtensionsConfig.tsx b/pi-web/components/ExtensionsConfig.tsx new file mode 100644 index 0000000..79e3048 --- /dev/null +++ b/pi-web/components/ExtensionsConfig.tsx @@ -0,0 +1,193 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useIsMobile } from "@/hooks/useIsMobile"; +import { useI18n } from "@/hooks/useI18n"; +import type { ExtensionInfo, ExtensionsResponse } from "@/lib/api-types"; + +function shortenPath(path: string): string { + return path.replace(/^\/(?:Users|home)\/[^/]+/, "~"); +} + +function statusColor(status: ExtensionInfo["status"]): string { + if (status === "enabled") return "var(--accent)"; + if (status === "blocked") return "#d97706"; + return "var(--text-dim)"; +} + +function statusLabel(status: ExtensionInfo["status"], t: ReturnType["t"]): string { + if (status === "enabled") return t("extensions.enabled"); + if (status === "blocked") return t("extensions.blocked"); + return t("extensions.disabled"); +} + +function scopeLabel(scope: ExtensionInfo["scope"], t: ReturnType["t"]): string { + if (scope === "project") return t("extensions.project"); + if (scope === "builtin") return t("extensions.builtin"); + return t("extensions.app"); +} + +function extensionKey(extension: ExtensionInfo): string { + return `${extension.scope}:${extension.path}`; +} + +export function ExtensionsConfig({ cwd, onCloseAction }: { cwd: string; onCloseAction: () => void }) { + const isMobile = useIsMobile(); + const { t } = useI18n(); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [selected, setSelected] = useState(null); + + const loadExtensions = useCallback(async () => { + setLoading(true); + setError(null); + try { + const response = await fetch(`/api/extensions?cwd=${encodeURIComponent(cwd)}`); + const next = (await response.json()) as ExtensionsResponse & { error?: string }; + if (!response.ok || next.error) throw new Error(next.error ?? `HTTP ${response.status}`); + setData(next); + setSelected((current) => ( + current && next.extensions.some((extension) => extensionKey(extension) === current) + ? current + : next.extensions[0] ? extensionKey(next.extensions[0]) : null + )); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + }, [cwd]); + + useEffect(() => { + void loadExtensions(); + }, [loadExtensions]); + + const extensions = useMemo(() => data?.extensions ?? [], [data?.extensions]); + const selectedExtension = extensions.find((extension) => extensionKey(extension) === selected) ?? null; + const enabledCount = extensions.filter((extension) => extension.status === "enabled").length; + const blockedCount = extensions.filter((extension) => extension.status === "blocked").length; + const disabledCount = extensions.filter((extension) => extension.status === "disabled").length; + const hasBlockedProjectExtensions = blockedCount > 0 && !data?.projectResourcesLoaded; + const groupedExtensions = useMemo(() => { + const groups: Array<{ scope: ExtensionInfo["scope"]; extensions: ExtensionInfo[] }> = []; + for (const scope of ["project", "builtin", "global"] as const) { + const scoped = extensions.filter((extension) => extension.scope === scope); + if (scoped.length > 0) groups.push({ scope, extensions: scoped }); + } + return groups; + }, [extensions]); + + return ( +
{ + if (event.target === event.currentTarget) onCloseAction(); + }} + > +
+
+
+ {t("common.extensions")} + + {shortenPath(cwd)} + +
+ +
+ + {hasBlockedProjectExtensions && ( +
+ {t("extensions.projectResourcesBlocked")} +
+ )} + +
+
+
+ {loading ? ( +
{t("i18n.loading")}
+ ) : error ? ( +
{error}
+ ) : groupedExtensions.length === 0 ? ( +
{t("extensions.noExtensions")}
+ ) : ( + groupedExtensions.map((group) => ( +
+
+ {scopeLabel(group.scope, t)} +
+ {group.extensions.map((extension) => { + const key = extensionKey(extension); + const isSelected = selected === key; + return ( +
setSelected(key)} + style={{ display: "flex", alignItems: "center", gap: 7, padding: "8px", borderRadius: 5, cursor: "pointer", background: isSelected ? "var(--bg-selected)" : "none" }} + onMouseEnter={(event) => { if (!isSelected) event.currentTarget.style.background = "var(--bg-hover)"; }} + onMouseLeave={(event) => { if (!isSelected) event.currentTarget.style.background = "none"; }} + > + + + {extension.name} + +
+ ); + })} +
+ )) + )} +
+
+ +
+ {selectedExtension ? ( +
+
+ + {selectedExtension.name} + {scopeLabel(selectedExtension.scope, t)} +
+
+
{t("extensions.status")}
+
{statusLabel(selectedExtension.status, t)}
+
{t("extensions.source")}
+
{selectedExtension.source}
+
{t("extensions.path")}
+
{shortenPath(selectedExtension.path)}
+
+
+ ) : !loading && !error ? ( +
{t("extensions.noExtensions")}
+ ) : null} +
+
+ +
+
+ {data ? `${extensions.length} ${t("extensions.total")} · ${enabledCount} ${t("extensions.enabled")} · ${disabledCount} ${t("extensions.disabled")} · ${blockedCount} ${t("extensions.blocked")}${data.diagnostics.length ? ` · ${data.diagnostics.length} ${t("extensions.diagnostics")}` : ""}` : ""} +
+ + +
+
+
+ ); +} diff --git a/pi-web/components/ModelsConfig.tsx b/pi-web/components/ModelsConfig.tsx index 17156b5..fa2152f 100644 --- a/pi-web/components/ModelsConfig.tsx +++ b/pi-web/components/ModelsConfig.tsx @@ -3,7 +3,11 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { useIsMobile } from "@/hooks/useIsMobile"; import { useI18n } from "@/hooks/useI18n"; -import type { ModelCatalogPreset, ModelCatalogRecommendation } from "@/lib/model-catalog"; +import { VisionConfigContent } from "./VisionConfig"; +import type { + ModelCatalogPreset, + ModelCatalogRecommendation, +} from "@/lib/model-catalog"; import type { DiscoveredModel } from "@/lib/model-discovery"; // Color icons (have their own fill colors — no background needed) import AnthropicIcon from "@lobehub/icons/es/Anthropic/components/Mono"; @@ -37,2000 +41,3704 @@ import OpenCodeIcon from "@lobehub/icons/es/OpenCode/components/Mono"; import XiaomiMiMoIcon from "@lobehub/icons/es/XiaomiMiMo/components/Mono"; import ZAIIcon from "@lobehub/icons/es/ZAI/components/Mono"; -type IconComponent = React.ComponentType<{ size?: number | string; style?: React.CSSProperties }>; +type IconComponent = React.ComponentType<{ + size?: number | string; + style?: React.CSSProperties; +}>; // hasColor=true → Color icon (self-colored SVG, no wrapper) // hasColor=false → Mono icon (rendered with currentColor, inherits theme text color) -const PROVIDER_ICONS: Record = { - "anthropic": { Icon: AnthropicIcon, hasColor: false }, - "openai": { Icon: OpenAIIcon, hasColor: false }, - "openai-codex": { Icon: OpenAIIcon, hasColor: false }, - "google": { Icon: GoogleColorIcon, hasColor: true }, - "google-vertex": { Icon: GoogleColorIcon, hasColor: true }, - "ant-ling": { Icon: AntGroupColorIcon, hasColor: true }, - "deepseek": { Icon: DeepSeekColorIcon, hasColor: true }, - "groq": { Icon: GroqIcon, hasColor: false }, - "mistral": { Icon: MistralColorIcon, hasColor: true }, - "moonshotai": { Icon: MoonshotIcon, hasColor: false }, - "moonshotai-cn": { Icon: MoonshotIcon, hasColor: false }, - "moonshot": { Icon: MoonshotIcon, hasColor: false }, - "minimax": { Icon: MinimaxColorIcon, hasColor: true }, - "minimax-cn": { Icon: MinimaxColorIcon, hasColor: true }, - "fireworks": { Icon: FireworksColorIcon, hasColor: true }, - "huggingface": { Icon: HuggingFaceColorIcon, hasColor: true }, - "cerebras": { Icon: CerebrasColorIcon, hasColor: true }, - "openrouter": { Icon: OpenRouterIcon, hasColor: false }, - "xai": { Icon: XAIIcon, hasColor: false }, - "cloudflare-ai-gateway": { Icon: CloudflareColorIcon, hasColor: true }, - "cloudflare-workers-ai": { Icon: CloudflareColorIcon, hasColor: true }, - "vercel-ai-gateway": { Icon: VercelIcon, hasColor: false }, - "github-copilot": { Icon: GithubCopilotIcon, hasColor: false }, - "amazon-bedrock": { Icon: AwsColorIcon, hasColor: true }, - "azure-openai-responses": { Icon: AzureColorIcon, hasColor: true }, - "kimi-coding": { Icon: KimiColorIcon, hasColor: true }, - "nvidia": { Icon: NvidiaColorIcon, hasColor: true }, - "opencode": { Icon: OpenCodeIcon, hasColor: false }, - "opencode-go": { Icon: OpenCodeIcon, hasColor: false }, - "qwen": { Icon: QwenColorIcon, hasColor: true }, - "xiaomi": { Icon: XiaomiMiMoIcon, hasColor: false }, - "xiaomi-token-plan-ams": { Icon: XiaomiMiMoIcon, hasColor: false }, - "xiaomi-token-plan-cn": { Icon: XiaomiMiMoIcon, hasColor: false }, - "xiaomi-token-plan-sgp": { Icon: XiaomiMiMoIcon, hasColor: false }, - "zai": { Icon: ZAIIcon, hasColor: false }, - "zai-coding-cn": { Icon: ZAIIcon, hasColor: false }, - "zhipu": { Icon: ZhipuColorIcon, hasColor: true }, - "cohere": { Icon: CohereColorIcon, hasColor: true }, - "perplexity": { Icon: PerplexityColorIcon, hasColor: true }, - "together": { Icon: TogetherColorIcon, hasColor: true }, - "grok": { Icon: GrokIcon, hasColor: false }, +const PROVIDER_ICONS: Record< + string, + { Icon: IconComponent; hasColor: boolean } +> = { + anthropic: { Icon: AnthropicIcon, hasColor: false }, + openai: { Icon: OpenAIIcon, hasColor: false }, + "openai-codex": { Icon: OpenAIIcon, hasColor: false }, + google: { Icon: GoogleColorIcon, hasColor: true }, + "google-vertex": { Icon: GoogleColorIcon, hasColor: true }, + "ant-ling": { Icon: AntGroupColorIcon, hasColor: true }, + deepseek: { Icon: DeepSeekColorIcon, hasColor: true }, + groq: { Icon: GroqIcon, hasColor: false }, + mistral: { Icon: MistralColorIcon, hasColor: true }, + moonshotai: { Icon: MoonshotIcon, hasColor: false }, + "moonshotai-cn": { Icon: MoonshotIcon, hasColor: false }, + moonshot: { Icon: MoonshotIcon, hasColor: false }, + minimax: { Icon: MinimaxColorIcon, hasColor: true }, + "minimax-cn": { Icon: MinimaxColorIcon, hasColor: true }, + fireworks: { Icon: FireworksColorIcon, hasColor: true }, + huggingface: { Icon: HuggingFaceColorIcon, hasColor: true }, + cerebras: { Icon: CerebrasColorIcon, hasColor: true }, + openrouter: { Icon: OpenRouterIcon, hasColor: false }, + xai: { Icon: XAIIcon, hasColor: false }, + "cloudflare-ai-gateway": { Icon: CloudflareColorIcon, hasColor: true }, + "cloudflare-workers-ai": { Icon: CloudflareColorIcon, hasColor: true }, + "vercel-ai-gateway": { Icon: VercelIcon, hasColor: false }, + "github-copilot": { Icon: GithubCopilotIcon, hasColor: false }, + "amazon-bedrock": { Icon: AwsColorIcon, hasColor: true }, + "azure-openai-responses": { Icon: AzureColorIcon, hasColor: true }, + "kimi-coding": { Icon: KimiColorIcon, hasColor: true }, + nvidia: { Icon: NvidiaColorIcon, hasColor: true }, + opencode: { Icon: OpenCodeIcon, hasColor: false }, + "opencode-go": { Icon: OpenCodeIcon, hasColor: false }, + qwen: { Icon: QwenColorIcon, hasColor: true }, + xiaomi: { Icon: XiaomiMiMoIcon, hasColor: false }, + "xiaomi-token-plan-ams": { Icon: XiaomiMiMoIcon, hasColor: false }, + "xiaomi-token-plan-cn": { Icon: XiaomiMiMoIcon, hasColor: false }, + "xiaomi-token-plan-sgp": { Icon: XiaomiMiMoIcon, hasColor: false }, + zai: { Icon: ZAIIcon, hasColor: false }, + "zai-coding-cn": { Icon: ZAIIcon, hasColor: false }, + zhipu: { Icon: ZhipuColorIcon, hasColor: true }, + cohere: { Icon: CohereColorIcon, hasColor: true }, + perplexity: { Icon: PerplexityColorIcon, hasColor: true }, + together: { Icon: TogetherColorIcon, hasColor: true }, + grok: { Icon: GrokIcon, hasColor: false }, }; // ── Types ───────────────────────────────────────────────────────────────────── interface OAuthProvider { - id: string; - name: string; - usesCallbackServer: boolean; - loggedIn: boolean; + id: string; + name: string; + usesCallbackServer: boolean; + loggedIn: boolean; } interface ApiKeyProvider { - id: string; - displayName: string; - configured: boolean; - source?: string; - modelCount: number; + id: string; + displayName: string; + configured: boolean; + source?: string; + modelCount: number; } type OAuthLoginState = - | { phase: "idle" } - | { phase: "connecting" } - | { phase: "auth"; url: string; instructions: string | null; token: string } - | { phase: "device_code"; userCode: string; verificationUri: string; intervalSeconds: number | null; expiresInSeconds: number | null } - | { phase: "prompt"; message: string; placeholder: string | null; token: string } - | { phase: "select"; message: string; options: { id: string; label: string }[]; token: string } - | { phase: "progress"; message: string } - | { phase: "success" } - | { phase: "error"; message: string }; + | { phase: "idle" } + | { phase: "connecting" } + | { phase: "auth"; url: string; instructions: string | null; token: string } + | { + phase: "device_code"; + userCode: string; + verificationUri: string; + intervalSeconds: number | null; + expiresInSeconds: number | null; + } + | { + phase: "prompt"; + message: string; + placeholder: string | null; + token: string; + } + | { + phase: "select"; + message: string; + options: { id: string; label: string }[]; + token: string; + } + | { phase: "progress"; message: string } + | { phase: "success" } + | { phase: "error"; message: string }; interface ModelEntry { - id: string; - name?: string; - api?: string; - reasoning?: boolean; - thinkingLevelMap?: Record; - input?: string[]; - contextWindow?: number; - maxTokens?: number; - cost?: { input?: number; output?: number; cacheRead?: number; cacheWrite?: number }; - compat?: Record; + id: string; + name?: string; + api?: string; + reasoning?: boolean; + thinkingLevelMap?: Record; + input?: string[]; + contextWindow?: number; + maxTokens?: number; + cost?: { + input?: number; + output?: number; + cacheRead?: number; + cacheWrite?: number; + }; + compat?: Record; } interface ProviderEntry { - baseUrl?: string; - api?: string; - apiKey?: string; - headers?: Record; - compat?: Record; - models?: ModelEntry[]; - modelOverrides?: Record; + baseUrl?: string; + api?: string; + apiKey?: string; + headers?: Record; + compat?: Record; + models?: ModelEntry[]; + modelOverrides?: Record; } interface ModelsJson { - providers?: Record; + providers?: Record; } type ModelTestState = - | { phase: "idle" } - | { phase: "testing" } - | { phase: "success"; latencyMs?: number; status?: number; responseText?: string } - | { phase: "error"; message: string; latencyMs?: number; status?: number }; + | { phase: "idle" } + | { phase: "testing" } + | { + phase: "success"; + latencyMs?: number; + status?: number; + responseText?: string; + } + | { phase: "error"; message: string; latencyMs?: number; status?: number }; type ModelDiscoveryState = - | { phase: "idle" } - | { phase: "loading" } - | { phase: "success"; models: DiscoveredModel[]; endpoint: string } - | { phase: "error"; message: string }; + | { phase: "idle" } + | { phase: "loading" } + | { phase: "success"; models: DiscoveredModel[]; endpoint: string } + | { phase: "error"; message: string }; type ModelCatalogState = - | { phase: "idle" } - | { phase: "loading" } - | { phase: "success"; recommendation: ModelCatalogRecommendation; appliedCount: number } - | { phase: "error"; message: string }; + | { phase: "idle" } + | { phase: "loading" } + | { + phase: "success"; + recommendation: ModelCatalogRecommendation; + appliedCount: number; + } + | { phase: "error"; message: string }; type Selection = - | { type: "provider"; name: string } - | { type: "model"; providerName: string; index: number } - | { type: "oauth"; providerId: string } - | { type: "apikey"; providerId: string }; + | { type: "provider"; name: string } + | { type: "model"; providerName: string; index: number } + | { type: "oauth"; providerId: string } + | { type: "apikey"; providerId: string }; -const API_OPTIONS = ["openai-completions", "openai-responses", "anthropic-messages", "google-generative-ai"] as const; +const API_OPTIONS = [ + "openai-completions", + "openai-responses", + "anthropic-messages", + "google-generative-ai", +] as const; // ── Form field helpers ──────────────────────────────────────────────────────── -function Field({ label, children }: { label: string; children: React.ReactNode }) { - return ( -
- - {children} -
- ); +function Field({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( +
+ + {children} +
+ ); } const inputStyle = { - padding: "6px 9px", - background: "var(--bg-panel)", - border: "1px solid var(--border)", - borderRadius: 5, - color: "var(--text)", - fontSize: 12, - outline: "none", - width: "100%", - boxSizing: "border-box" as const, + padding: "6px 9px", + background: "var(--bg-panel)", + border: "1px solid var(--border)", + borderRadius: 5, + color: "var(--text)", + fontSize: 12, + outline: "none", + width: "100%", + boxSizing: "border-box" as const, }; -function TextInput({ value, onChange, placeholder, mono }: { value: string; onChange: (v: string) => void; placeholder?: string; mono?: boolean }) { - return onChange(e.target.value)} placeholder={placeholder} - style={{ ...inputStyle, fontFamily: mono ? "var(--font-mono)" : "inherit" }} />; +function TextInput({ + value, + onChange, + placeholder, + mono, +}: { + value: string; + onChange: (v: string) => void; + placeholder?: string; + mono?: boolean; +}) { + return ( + onChange(e.target.value)} + placeholder={placeholder} + style={{ + ...inputStyle, + fontFamily: mono ? "var(--font-mono)" : "inherit", + }} + /> + ); } function SecretTextInput({ - value, - onChange, - placeholder, - mono, - onKeyDown, - autoComplete = "off", - spellCheck = false, - style, + value, + onChange, + placeholder, + mono, + onKeyDown, + autoComplete = "off", + spellCheck = false, + style, }: { - value: string; - onChange: (v: string) => void; - placeholder?: string; - mono?: boolean; - onKeyDown?: React.KeyboardEventHandler; - autoComplete?: string; - spellCheck?: boolean; - style?: React.CSSProperties; + value: string; + onChange: (v: string) => void; + placeholder?: string; + mono?: boolean; + onKeyDown?: React.KeyboardEventHandler; + autoComplete?: string; + spellCheck?: boolean; + style?: React.CSSProperties; }) { - const [visible, setVisible] = useState(false); - const { t } = useI18n(); + const [visible, setVisible] = useState(false); + const { t } = useI18n(); - useEffect(() => { - if (!value) setVisible(false); - }, [value]); + useEffect(() => { + if (!value) setVisible(false); + }, [value]); - return ( -
- onChange(e.target.value)} - onKeyDown={onKeyDown} - placeholder={placeholder} - style={{ ...inputStyle, paddingRight: 34, fontFamily: mono ? "var(--font-mono)" : "inherit" }} - autoComplete={autoComplete} - spellCheck={spellCheck} - /> - -
- ); + return ( +
+ onChange(e.target.value)} + onKeyDown={onKeyDown} + placeholder={placeholder} + style={{ + ...inputStyle, + paddingRight: 34, + fontFamily: mono ? "var(--font-mono)" : "inherit", + }} + autoComplete={autoComplete} + spellCheck={spellCheck} + /> + +
+ ); } -function NumInput({ value, onChange, placeholder }: { value: string; onChange: (v: string) => void; placeholder?: string }) { - return onChange(e.target.value)} placeholder={placeholder} style={inputStyle} />; +function NumInput({ + value, + onChange, + placeholder, +}: { + value: string; + onChange: (v: string) => void; + placeholder?: string; +}) { + return ( + onChange(e.target.value)} + placeholder={placeholder} + style={inputStyle} + /> + ); } -function Select({ value, onChange, options, required }: { value: string; onChange: (v: string) => void; options: readonly string[]; required?: boolean }) { - const { t } = useI18n(); - return ( - - ); +function Select({ + value, + onChange, + options, + required, +}: { + value: string; + onChange: (v: string) => void; + options: readonly string[]; + required?: boolean; +}) { + const { t } = useI18n(); + return ( + + ); } -function Check({ label, checked, onChange }: { label: string; checked: boolean; onChange: (v: boolean) => void }) { - return ( - - ); +function Check({ + label, + checked, + onChange, +}: { + label: string; + checked: boolean; + onChange: (v: boolean) => void; +}) { + return ( + + ); } function SectionTitle({ children }: { children: React.ReactNode }) { - return
{children}
; + return ( +
+ {children} +
+ ); } // ── Provider detail ─────────────────────────────────────────────────────────── -function ProviderDetail({ name, provider, onChange, onRename, onDelete, onAddModels }: { - name: string; provider: ProviderEntry; - onChange: (p: ProviderEntry) => void; onRename: (n: string) => void; onDelete: () => void; - onAddModels: (models: DiscoveredModel[]) => void; +function ProviderDetail({ + name, + provider, + onChange, + onRename, + onDelete, + onAddModels, +}: { + name: string; + provider: ProviderEntry; + onChange: (p: ProviderEntry) => void; + onRename: (n: string) => void; + onDelete: () => void; + onAddModels: (models: DiscoveredModel[]) => void; }) { - const { t } = useI18n(); - const [editingName, setEditingName] = useState(name); - const [discoveryState, setDiscoveryState] = useState({ phase: "idle" }); - const [discoveryQuery, setDiscoveryQuery] = useState(""); - const [selectedModelIds, setSelectedModelIds] = useState([]); - const discoveryRequestIdRef = useRef(0); - const selectShownRef = useRef(null); - useEffect(() => setEditingName(name), [name]); - const set = (k: K, v: ProviderEntry[K]) => onChange({ ...provider, [k]: v }); + const { t } = useI18n(); + const [editingName, setEditingName] = useState(name); + const [discoveryState, setDiscoveryState] = useState({ + phase: "idle", + }); + const [discoveryQuery, setDiscoveryQuery] = useState(""); + const [selectedModelIds, setSelectedModelIds] = useState([]); + const discoveryRequestIdRef = useRef(0); + const selectShownRef = useRef(null); + useEffect(() => setEditingName(name), [name]); + const set = (k: K, v: ProviderEntry[K]) => + onChange({ ...provider, [k]: v }); - useEffect(() => { - if (!provider.api) onChange({ ...provider, api: "openai-completions" }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [provider.api]); + useEffect(() => { + if (!provider.api) onChange({ ...provider, api: "openai-completions" }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [provider.api]); - useEffect(() => { - discoveryRequestIdRef.current += 1; - setDiscoveryState({ phase: "idle" }); - setDiscoveryQuery(""); - setSelectedModelIds([]); - }, [name, provider.baseUrl, provider.api, provider.apiKey]); + useEffect(() => { + discoveryRequestIdRef.current += 1; + setDiscoveryState({ phase: "idle" }); + setDiscoveryQuery(""); + setSelectedModelIds([]); + }, [name, provider.baseUrl, provider.api, provider.apiKey]); - const handleDiscoverModels = useCallback(async () => { - if (!provider.baseUrl?.trim() || discoveryState.phase === "loading") return; - const requestId = ++discoveryRequestIdRef.current; - setDiscoveryState({ phase: "loading" }); - setSelectedModelIds([]); - try { - const res = await fetch("/api/models-config/discover", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ providerName: name, provider: { ...provider, models: undefined } }), - }); - const data = await res.json() as { models?: DiscoveredModel[]; endpoint?: string; error?: string }; - if (requestId !== discoveryRequestIdRef.current) return; - if (!res.ok || data.error || !data.models) { - setDiscoveryState({ phase: "error", message: data.error ?? `HTTP ${res.status}` }); - return; - } - setDiscoveryState({ phase: "success", models: data.models, endpoint: data.endpoint ?? provider.baseUrl }); - } catch (error) { - if (requestId !== discoveryRequestIdRef.current) return; - setDiscoveryState({ phase: "error", message: error instanceof Error ? error.message : String(error) }); - } - }, [discoveryState.phase, name, provider]); + const handleDiscoverModels = useCallback(async () => { + if (!provider.baseUrl?.trim() || discoveryState.phase === "loading") return; + const requestId = ++discoveryRequestIdRef.current; + setDiscoveryState({ phase: "loading" }); + setSelectedModelIds([]); + try { + const res = await fetch("/api/models-config/discover", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + providerName: name, + provider: { ...provider, models: undefined }, + }), + }); + const data = (await res.json()) as { + models?: DiscoveredModel[]; + endpoint?: string; + error?: string; + }; + if (requestId !== discoveryRequestIdRef.current) return; + if (!res.ok || data.error || !data.models) { + setDiscoveryState({ + phase: "error", + message: data.error ?? `HTTP ${res.status}`, + }); + return; + } + setDiscoveryState({ + phase: "success", + models: data.models, + endpoint: data.endpoint ?? provider.baseUrl, + }); + } catch (error) { + if (requestId !== discoveryRequestIdRef.current) return; + setDiscoveryState({ + phase: "error", + message: error instanceof Error ? error.message : String(error), + }); + } + }, [discoveryState.phase, name, provider]); - const existingModelIds = new Set((provider.models ?? []).map((model) => model.id)); - const discoveredModels = discoveryState.phase === "success" ? discoveryState.models : []; - const normalizedDiscoveryQuery = discoveryQuery.trim().toLocaleLowerCase(); - const filteredDiscoveredModels = discoveredModels.filter((model) => !normalizedDiscoveryQuery - || model.id.toLocaleLowerCase().includes(normalizedDiscoveryQuery) - || model.name?.toLocaleLowerCase().includes(normalizedDiscoveryQuery)); - const shownDiscoveredModels = filteredDiscoveredModels.slice(0, 300); - const selectableShownIds = shownDiscoveredModels - .filter((model) => !existingModelIds.has(model.id)) - .map((model) => model.id); - const selectedCount = selectedModelIds.filter((id) => !existingModelIds.has(id)).length; - const allShownSelected = selectableShownIds.length > 0 - && selectableShownIds.every((id) => selectedModelIds.includes(id)); - const someShownSelected = !allShownSelected - && selectableShownIds.some((id) => selectedModelIds.includes(id)); + const existingModelIds = new Set( + (provider.models ?? []).map((model) => model.id), + ); + const discoveredModels = + discoveryState.phase === "success" ? discoveryState.models : []; + const normalizedDiscoveryQuery = discoveryQuery.trim().toLocaleLowerCase(); + const filteredDiscoveredModels = discoveredModels.filter( + (model) => + !normalizedDiscoveryQuery || + model.id.toLocaleLowerCase().includes(normalizedDiscoveryQuery) || + model.name?.toLocaleLowerCase().includes(normalizedDiscoveryQuery), + ); + const shownDiscoveredModels = filteredDiscoveredModels.slice(0, 300); + const selectableShownIds = shownDiscoveredModels + .filter((model) => !existingModelIds.has(model.id)) + .map((model) => model.id); + const selectedCount = selectedModelIds.filter( + (id) => !existingModelIds.has(id), + ).length; + const allShownSelected = + selectableShownIds.length > 0 && + selectableShownIds.every((id) => selectedModelIds.includes(id)); + const someShownSelected = + !allShownSelected && + selectableShownIds.some((id) => selectedModelIds.includes(id)); - useEffect(() => { - if (selectShownRef.current) selectShownRef.current.indeterminate = someShownSelected; - }, [someShownSelected]); + useEffect(() => { + if (selectShownRef.current) + selectShownRef.current.indeterminate = someShownSelected; + }, [someShownSelected]); - const toggleDiscoveredModel = (id: string) => { - setSelectedModelIds((current) => current.includes(id) - ? current.filter((entry) => entry !== id) - : [...current, id]); - }; + const toggleDiscoveredModel = (id: string) => { + setSelectedModelIds((current) => + current.includes(id) + ? current.filter((entry) => entry !== id) + : [...current, id], + ); + }; - const toggleShownModels = () => { - const shownIds = new Set(selectableShownIds); - setSelectedModelIds((current) => allShownSelected - ? current.filter((id) => !shownIds.has(id)) - : Array.from(new Set([...current, ...selectableShownIds]))); - }; + const toggleShownModels = () => { + const shownIds = new Set(selectableShownIds); + setSelectedModelIds((current) => + allShownSelected + ? current.filter((id) => !shownIds.has(id)) + : Array.from(new Set([...current, ...selectableShownIds])), + ); + }; - const addSelectedModels = () => { - if (discoveryState.phase !== "success") return; - const selected = new Set(selectedModelIds); - const additions = discoveryState.models.filter((model) => selected.has(model.id) && !existingModelIds.has(model.id)); - if (additions.length === 0) return; - onAddModels(additions); - setSelectedModelIds([]); - }; + const addSelectedModels = () => { + if (discoveryState.phase !== "success") return; + const selected = new Set(selectedModelIds); + const additions = discoveryState.models.filter( + (model) => selected.has(model.id) && !existingModelIds.has(model.id), + ); + if (additions.length === 0) return; + onAddModels(additions); + setSelectedModelIds([]); + }; - return ( -
-
- {t("i18n.provider")} - -
+ return ( +
+
+ {t("i18n.provider")} + +
- - - {editingName !== name && editingName.trim() && ( - - )} - + + + {editingName !== name && editingName.trim() && ( + + )} + - - set("baseUrl", v || undefined)} - placeholder="https://api.example.com/v1" mono /> - + + set("baseUrl", v || undefined)} + placeholder="https://api.example.com/v1" + mono + /> + - - set("apiKey", v || undefined)} - placeholder="ENV_VAR_NAME, !shell-command, or literal key" mono /> - - Prefix with ! to run a shell command, or use an env var name - - + + set("apiKey", v || undefined)} + placeholder="ENV_VAR_NAME, !shell-command, or literal key" + mono + /> + + Prefix with !{" "} + to run a shell command, or use an env var name + + - - set("api", v)} + options={API_OPTIONS} + required + /> + -
- {discoveryState.phase !== "success" && ( - - )} +
+ {discoveryState.phase !== "success" && ( + + )} - {discoveryState.phase === "error" && ( -
- {discoveryState.message} -
- )} + {discoveryState.phase === "error" && ( +
+ {discoveryState.message} +
+ )} - {discoveryState.phase === "success" && ( - <> - setDiscoveryQuery(event.target.value)} - placeholder={t("models.discoveryFilterPlaceholder", { count: discoveryState.models.length })} - aria-label={t("models.discoveryFilter")} - style={{ ...inputStyle, width: "100%", minWidth: 0 }} - /> + {discoveryState.phase === "success" && ( + <> + setDiscoveryQuery(event.target.value)} + placeholder={t("models.discoveryFilterPlaceholder", { + count: discoveryState.models.length, + })} + aria-label={t("models.discoveryFilter")} + style={{ ...inputStyle, width: "100%", minWidth: 0 }} + /> -
- - {shownDiscoveredModels.length === 0 ? ( -
{t("models.discoveryNoMatches")}
- ) : shownDiscoveredModels.map((model, index) => { - const alreadyAdded = existingModelIds.has(model.id); - const checked = selectedModelIds.includes(model.id); - return ( - - ); - })} -
+
+ + {shownDiscoveredModels.length === 0 ? ( +
+ {t("models.discoveryNoMatches")} +
+ ) : ( + shownDiscoveredModels.map((model, index) => { + const alreadyAdded = existingModelIds.has(model.id); + const checked = selectedModelIds.includes(model.id); + return ( + + ); + }) + )} +
-
- - {filteredDiscoveredModels.length > shownDiscoveredModels.length - ? t("models.discoveryShowing", { shown: shownDiscoveredModels.length, total: filteredDiscoveredModels.length }) - : t("models.discoveryFetched", { count: discoveryState.models.length })} - - -
- - )} -
-
- ); +
+ + {filteredDiscoveredModels.length > shownDiscoveredModels.length + ? t("models.discoveryShowing", { + shown: shownDiscoveredModels.length, + total: filteredDiscoveredModels.length, + }) + : t("models.discoveryFetched", { + count: discoveryState.models.length, + })} + + +
+ + )} +
+
+ ); } // ── ThinkingLevelMap editor ─────────────────────────────────────────────────── -const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const; -type ThinkingLevel = typeof THINKING_LEVELS[number]; +const THINKING_LEVELS = [ + "off", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", +] as const; +type ThinkingLevel = (typeof THINKING_LEVELS)[number]; const LEVEL_COLORS: Record = { - off: "var(--text-dim)", - minimal: "#6b7280", - low: "#60a5fa", - medium: "#a78bfa", - high: "#f472b6", - xhigh: "#fb923c", - max: "#ef4444", + off: "var(--text-dim)", + minimal: "#6b7280", + low: "#60a5fa", + medium: "#a78bfa", + high: "#f472b6", + xhigh: "#fb923c", + max: "#ef4444", }; function ThinkingLevelMapEditor({ - value, - onChange, + value, + onChange, }: { - value: Record | undefined; - onChange: (v: Record | undefined) => void; + value: Record | undefined; + onChange: (v: Record | undefined) => void; }) { - const map = value ?? {}; + const map = value ?? {}; - const setLevel = (level: ThinkingLevel, entry: string | null | "omit") => { - const next = { ...map }; - if (entry === "omit") { - delete next[level]; - } else { - next[level] = entry; - } - onChange(Object.keys(next).length ? next : undefined); - }; + const setLevel = (level: ThinkingLevel, entry: string | null | "omit") => { + const next = { ...map }; + if (entry === "omit") { + delete next[level]; + } else { + next[level] = entry; + } + onChange(Object.keys(next).length ? next : undefined); + }; - return ( -
- {THINKING_LEVELS.map((level) => { - const raw = map[level]; - const state: "omit" | "null" | "string" = - !(level in map) ? "omit" : raw === null ? "null" : "string"; - const strVal = typeof raw === "string" ? raw : ""; - const color = LEVEL_COLORS[level]; + return ( +
+ {THINKING_LEVELS.map((level) => { + const raw = map[level]; + const state: "omit" | "null" | "string" = !(level in map) + ? "omit" + : raw === null + ? "null" + : "string"; + const strVal = typeof raw === "string" ? raw : ""; + const color = LEVEL_COLORS[level]; - const btnBase: React.CSSProperties = { - padding: "4px 10px", - fontSize: 10, - border: "none", - cursor: "pointer", - fontWeight: 400, - transition: "background 0.1s, color 0.1s", - whiteSpace: "nowrap", - background: "var(--bg-panel)", - color: "var(--text-dim)", - }; - const btnActive: React.CSSProperties = { - background: "var(--accent)", - color: "#fff", - fontWeight: 600, - }; - const btnActiveDisabled: React.CSSProperties = { - background: "#ef4444", - color: "#fff", - fontWeight: 600, - }; + const btnBase: React.CSSProperties = { + padding: "4px 10px", + fontSize: 10, + border: "none", + cursor: "pointer", + fontWeight: 400, + transition: "background 0.1s, color 0.1s", + whiteSpace: "nowrap", + background: "var(--bg-panel)", + color: "var(--text-dim)", + }; + const btnActive: React.CSSProperties = { + background: "var(--accent)", + color: "#fff", + fontWeight: 600, + }; + const btnActiveDisabled: React.CSSProperties = { + background: "#ef4444", + color: "#fff", + fontWeight: 600, + }; - return ( -
- {/* Level badge */} -
- - - {level} - -
+ return ( +
+ {/* Level badge */} +
+ + + {level} + +
- {/* Default + Disabled buttons */} -
- - -
+ {/* Default + Disabled buttons */} +
+ + +
- {/* Custom button + input fused */} -
- - setLevel(level, e.target.value)} - onFocus={() => { if (state !== "string") setLevel(level, strVal || level); }} - placeholder={level} - maxLength={10} - style={{ - width: "12ch", - background: state === "string" ? "var(--bg)" : "var(--bg-panel)", - border: "none", - outline: "none", - color: state === "string" ? "var(--text)" : "var(--text-dim)", - fontFamily: "var(--font-mono)", - fontSize: 11, - padding: "4px 7px", - transition: "background 0.1s, color 0.1s", - }} - /> -
-
- ); - })} -
- ); + {/* Custom button + input fused */} +
+ + setLevel(level, e.target.value)} + onFocus={() => { + if (state !== "string") setLevel(level, strVal || level); + }} + placeholder={level} + maxLength={10} + style={{ + width: "12ch", + background: + state === "string" ? "var(--bg)" : "var(--bg-panel)", + border: "none", + outline: "none", + color: state === "string" ? "var(--text)" : "var(--text-dim)", + fontFamily: "var(--font-mono)", + fontSize: 11, + padding: "4px 7px", + transition: "background 0.1s, color 0.1s", + }} + /> +
+
+ ); + })} +
+ ); } // ── Model detail ────────────────────────────────────────────────────────────── const DEEPSEEK_COMPAT = { - thinkingFormat: "deepseek", - requiresReasoningContentOnAssistantMessages: true, + thinkingFormat: "deepseek", + requiresReasoningContentOnAssistantMessages: true, } as const; function hasDeepseekCompat(model: ModelEntry): boolean { - return model.compat?.thinkingFormat === "deepseek"; + return model.compat?.thinkingFormat === "deepseek"; } function setDeepseekCompat(model: ModelEntry, enabled: boolean): ModelEntry { - if (enabled) { - return { ...model, compat: { ...(model.compat ?? {}), ...DEEPSEEK_COMPAT } }; - } - if (!model.compat) return model; - const rest = { ...model.compat }; - delete rest.thinkingFormat; - delete rest.requiresReasoningContentOnAssistantMessages; - return { ...model, compat: Object.keys(rest).length ? rest : undefined }; + if (enabled) { + return { + ...model, + compat: { ...(model.compat ?? {}), ...DEEPSEEK_COMPAT }, + }; + } + if (!model.compat) return model; + const rest = { ...model.compat }; + delete rest.thinkingFormat; + delete rest.requiresReasoningContentOnAssistantMessages; + return { ...model, compat: Object.keys(rest).length ? rest : undefined }; } function fillEmptyModelFields( - model: ModelEntry, - preset: ModelCatalogPreset, + model: ModelEntry, + preset: ModelCatalogPreset, ): { model: ModelEntry; appliedCount: number } { - const next = { ...model }; - let appliedCount = 0; - if (!model.name?.trim() && preset.name) { - next.name = preset.name; - appliedCount += 1; - } - if (model.reasoning === undefined && preset.reasoning === true) { - next.reasoning = true; - appliedCount += 1; - } - if (!model.input?.length && preset.input?.length) { - next.input = [...preset.input]; - appliedCount += 1; - } - if (model.contextWindow === undefined && preset.contextWindow !== undefined) { - next.contextWindow = preset.contextWindow; - appliedCount += 1; - } - if (model.maxTokens === undefined && preset.maxTokens !== undefined) { - next.maxTokens = preset.maxTokens; - appliedCount += 1; - } + const next = { ...model }; + let appliedCount = 0; + if (!model.name?.trim() && preset.name) { + next.name = preset.name; + appliedCount += 1; + } + if (model.reasoning === undefined && preset.reasoning === true) { + next.reasoning = true; + appliedCount += 1; + } + if (!model.input?.length && preset.input?.length) { + next.input = [...preset.input]; + appliedCount += 1; + } + if (model.contextWindow === undefined && preset.contextWindow !== undefined) { + next.contextWindow = preset.contextWindow; + appliedCount += 1; + } + if (model.maxTokens === undefined && preset.maxTokens !== undefined) { + next.maxTokens = preset.maxTokens; + appliedCount += 1; + } - if (preset.cost) { - const cost = { ...(model.cost ?? {}) }; - let costChanged = false; - for (const key of ["input", "output", "cacheRead", "cacheWrite"] as const) { - if (cost[key] === undefined && preset.cost[key] !== undefined) { - cost[key] = preset.cost[key]; - costChanged = true; - appliedCount += 1; - } - } - if (costChanged) next.cost = cost; - } - return { model: next, appliedCount }; + if (preset.cost) { + const cost = { ...(model.cost ?? {}) }; + let costChanged = false; + for (const key of ["input", "output", "cacheRead", "cacheWrite"] as const) { + if (cost[key] === undefined && preset.cost[key] !== undefined) { + cost[key] = preset.cost[key]; + costChanged = true; + appliedCount += 1; + } + } + if (costChanged) next.cost = cost; + } + return { model: next, appliedCount }; } function ModelDetail({ - providerName, - provider, - model, - onChange, - onDelete, + providerName, + provider, + model, + onChange, + onDelete, }: { - providerName: string; - provider: ProviderEntry; - model: ModelEntry; - onChange: (m: ModelEntry) => void; - onDelete: () => void; + providerName: string; + provider: ProviderEntry; + model: ModelEntry; + onChange: (m: ModelEntry) => void; + onDelete: () => void; }) { - const [testState, setTestState] = useState({ phase: "idle" }); - const { t } = useI18n(); - const [catalogState, setCatalogState] = useState({ phase: "idle" }); - const catalogRequestIdRef = useRef(0); - const catalogUndoRef = useRef(null); - const set = (k: K, v: ModelEntry[K]) => onChange({ ...model, [k]: v }); - const costVal = (k: keyof NonNullable) => model.cost?.[k] !== undefined ? String(model.cost[k]) : ""; - const setCost = (k: keyof NonNullable, v: string) => { - const n = parseFloat(v); - onChange({ ...model, cost: { ...(model.cost ?? {}), [k]: isNaN(n) ? undefined : n } }); - }; - const testSummary = (() => { - if (testState.phase === "idle") return null; - if (testState.phase === "testing") return t("i18n.testingModel"); - const meta = [ - testState.latencyMs !== undefined ? `${testState.latencyMs}ms` : null, - testState.status !== undefined ? `HTTP ${testState.status}` : null, - ].filter(Boolean); - if (testState.phase === "success") { - return [t("i18n.connected"), ...meta, testState.responseText || null].filter(Boolean).join(" · "); - } - return [t("i18n.failed"), ...meta, testState.message].filter(Boolean).join(" · "); - })(); + const [testState, setTestState] = useState({ phase: "idle" }); + const { t } = useI18n(); + const [catalogState, setCatalogState] = useState({ + phase: "idle", + }); + const catalogRequestIdRef = useRef(0); + const catalogUndoRef = useRef(null); + const set = (k: K, v: ModelEntry[K]) => + onChange({ ...model, [k]: v }); + const costVal = (k: keyof NonNullable) => + model.cost?.[k] !== undefined ? String(model.cost[k]) : ""; + const setCost = (k: keyof NonNullable, v: string) => { + const n = parseFloat(v); + onChange({ + ...model, + cost: { ...(model.cost ?? {}), [k]: isNaN(n) ? undefined : n }, + }); + }; + const testSummary = (() => { + if (testState.phase === "idle") return null; + if (testState.phase === "testing") return t("i18n.testingModel"); + const meta = [ + testState.latencyMs !== undefined ? `${testState.latencyMs}ms` : null, + testState.status !== undefined ? `HTTP ${testState.status}` : null, + ].filter(Boolean); + if (testState.phase === "success") { + return [t("i18n.connected"), ...meta, testState.responseText || null] + .filter(Boolean) + .join(" · "); + } + return [t("i18n.failed"), ...meta, testState.message] + .filter(Boolean) + .join(" · "); + })(); - useEffect(() => { - setTestState({ phase: "idle" }); - }, [providerName, provider.baseUrl, provider.api, provider.apiKey, model.id, model.api]); + useEffect(() => { + setTestState({ phase: "idle" }); + }, [ + providerName, + provider.baseUrl, + provider.api, + provider.apiKey, + model.id, + model.api, + ]); - useEffect(() => { - catalogRequestIdRef.current += 1; - setCatalogState({ phase: "idle" }); - catalogUndoRef.current = null; - }, [providerName, provider.baseUrl, model.id]); + useEffect(() => { + catalogRequestIdRef.current += 1; + setCatalogState({ phase: "idle" }); + catalogUndoRef.current = null; + }, [providerName, provider.baseUrl, model.id]); - const handleTest = useCallback(async () => { - if (!model.id.trim() || testState.phase === "testing") return; - setTestState({ phase: "testing" }); - try { - const res = await fetch("/api/models-config/test", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ providerName, provider, model }), - }); - const d = await res.json() as { - ok?: boolean; - error?: string; - latencyMs?: number; - status?: number; - responseText?: string; - }; - if (!res.ok || !d.ok) { - setTestState({ - phase: "error", - message: d.error ?? `HTTP ${res.status}`, - latencyMs: d.latencyMs, - status: d.status, - }); - return; - } - setTestState({ - phase: "success", - latencyMs: d.latencyMs, - status: d.status, - responseText: d.responseText, - }); - } catch (e) { - setTestState({ phase: "error", message: e instanceof Error ? e.message : String(e) }); - } - }, [model, provider, providerName, testState.phase]); + const handleTest = useCallback(async () => { + if (!model.id.trim() || testState.phase === "testing") return; + setTestState({ phase: "testing" }); + try { + const res = await fetch("/api/models-config/test", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ providerName, provider, model }), + }); + const d = (await res.json()) as { + ok?: boolean; + error?: string; + latencyMs?: number; + status?: number; + responseText?: string; + }; + if (!res.ok || !d.ok) { + setTestState({ + phase: "error", + message: d.error ?? `HTTP ${res.status}`, + latencyMs: d.latencyMs, + status: d.status, + }); + return; + } + setTestState({ + phase: "success", + latencyMs: d.latencyMs, + status: d.status, + responseText: d.responseText, + }); + } catch (e) { + setTestState({ + phase: "error", + message: e instanceof Error ? e.message : String(e), + }); + } + }, [model, provider, providerName, testState.phase]); - const handleCatalogFill = useCallback(async () => { - const query = model.id.trim(); - if (!query || catalogState.phase === "loading") return; - const requestId = ++catalogRequestIdRef.current; - setCatalogState({ phase: "loading" }); - try { - const params = new URLSearchParams({ q: query, provider: providerName, limit: "50" }); - if (provider.baseUrl?.trim()) params.set("baseUrl", provider.baseUrl.trim()); - const res = await fetch(`/api/models-config/catalog?${params}`); - const data = await res.json() as { recommendation?: ModelCatalogRecommendation; error?: string }; - if (requestId !== catalogRequestIdRef.current) return; - if (!res.ok || data.error || !data.recommendation) { - setCatalogState({ phase: "error", message: data.error ?? `HTTP ${res.status}` }); - return; - } - const filled = fillEmptyModelFields(model, data.recommendation.preset); - if (filled.appliedCount > 0) { - catalogUndoRef.current = model; - onChange(filled.model); - } - setCatalogState({ - phase: "success", - recommendation: data.recommendation, - appliedCount: filled.appliedCount, - }); - } catch (error) { - if (requestId !== catalogRequestIdRef.current) return; - setCatalogState({ phase: "error", message: error instanceof Error ? error.message : String(error) }); - } - }, [catalogState.phase, model, onChange, provider.baseUrl, providerName]); + const handleCatalogFill = useCallback(async () => { + const query = model.id.trim(); + if (!query || catalogState.phase === "loading") return; + const requestId = ++catalogRequestIdRef.current; + setCatalogState({ phase: "loading" }); + try { + const params = new URLSearchParams({ + q: query, + provider: providerName, + limit: "50", + }); + if (provider.baseUrl?.trim()) + params.set("baseUrl", provider.baseUrl.trim()); + const res = await fetch(`/api/models-config/catalog?${params}`); + const data = (await res.json()) as { + recommendation?: ModelCatalogRecommendation; + error?: string; + }; + if (requestId !== catalogRequestIdRef.current) return; + if (!res.ok || data.error || !data.recommendation) { + setCatalogState({ + phase: "error", + message: data.error ?? `HTTP ${res.status}`, + }); + return; + } + const filled = fillEmptyModelFields(model, data.recommendation.preset); + if (filled.appliedCount > 0) { + catalogUndoRef.current = model; + onChange(filled.model); + } + setCatalogState({ + phase: "success", + recommendation: data.recommendation, + appliedCount: filled.appliedCount, + }); + } catch (error) { + if (requestId !== catalogRequestIdRef.current) return; + setCatalogState({ + phase: "error", + message: error instanceof Error ? error.message : String(error), + }); + } + }, [catalogState.phase, model, onChange, provider.baseUrl, providerName]); - const undoCatalogFill = () => { - const previous = catalogUndoRef.current; - if (!previous) return; - catalogUndoRef.current = null; - onChange(previous); - setCatalogState({ phase: "idle" }); - }; + const undoCatalogFill = () => { + const previous = catalogUndoRef.current; + if (!previous) return; + catalogUndoRef.current = null; + onChange(previous); + setCatalogState({ phase: "idle" }); + }; - const catalogResultSummary = (() => { - if (catalogState.phase !== "success") return null; - const { recommendation, appliedCount } = catalogState; - const applied = appliedCount > 0 - ? t("models.catalogFilled", { count: appliedCount }) - : t("models.catalogNoEmptyFields"); - if (recommendation.price.status === "unreliable") { - const price = recommendation.price.reason === "no-exact-match" - ? t("models.catalogNoExactMatch") - : t("models.catalogPriceUnreliable"); - return `${applied} · ${price}`; - } - const price = recommendation.price.method === "provider" - ? t("models.catalogPriceProvider", { provider: recommendation.price.providerName ?? recommendation.price.providerId ?? providerName }) - : recommendation.price.method === "base-url" - ? t("models.catalogPriceBaseUrl", { provider: recommendation.price.providerName ?? recommendation.price.providerId ?? providerName }) - : t("models.catalogPriceConsensus", { - support: recommendation.price.support, - total: recommendation.price.total, - }); - return `${applied} · ${price}`; - })(); - const catalogStatusText = catalogState.phase === "error" - ? catalogState.message - : catalogResultSummary; - const catalogStatusColor = catalogState.phase === "error" - ? "#ef4444" - : catalogState.phase === "success" && catalogState.recommendation.price.status === "unreliable" - ? "#d97706" - : "var(--text-dim)"; + const catalogResultSummary = (() => { + if (catalogState.phase !== "success") return null; + const { recommendation, appliedCount } = catalogState; + const applied = + appliedCount > 0 + ? t("models.catalogFilled", { count: appliedCount }) + : t("models.catalogNoEmptyFields"); + if (recommendation.price.status === "unreliable") { + const price = + recommendation.price.reason === "no-exact-match" + ? t("models.catalogNoExactMatch") + : t("models.catalogPriceUnreliable"); + return `${applied} · ${price}`; + } + const price = + recommendation.price.method === "provider" + ? t("models.catalogPriceProvider", { + provider: + recommendation.price.providerName ?? + recommendation.price.providerId ?? + providerName, + }) + : recommendation.price.method === "base-url" + ? t("models.catalogPriceBaseUrl", { + provider: + recommendation.price.providerName ?? + recommendation.price.providerId ?? + providerName, + }) + : t("models.catalogPriceConsensus", { + support: recommendation.price.support, + total: recommendation.price.total, + }); + return `${applied} · ${price}`; + })(); + const catalogStatusText = + catalogState.phase === "error" + ? catalogState.message + : catalogResultSummary; + const catalogStatusColor = + catalogState.phase === "error" + ? "#ef4444" + : catalogState.phase === "success" && + catalogState.recommendation.price.status === "unreliable" + ? "#d97706" + : "var(--text-dim)"; - return ( -
-
- {t("i18n.model")} -
- {testSummary && ( - - {testSummary} - - )} - - -
-
+ return ( +
+
+ {t("i18n.model")} +
+ {testSummary && ( + + {testSummary} + + )} + + +
+
-
- set("id", v)} placeholder="model-id" mono /> - set("name", v || undefined)} placeholder="Display name" /> -
+
+ + set("id", v)} + placeholder="model-id" + mono + /> + + + set("name", v || undefined)} + placeholder="Display name" + /> + +
-
-
- - - {t("models.catalogSource")} - -
+
+
+ + + {t("models.catalogSource")} + +
-
- - {catalogStatusText} - - {catalogUndoRef.current && ( - - )} -
-
+
+ + {catalogStatusText} + + {catalogUndoRef.current && ( + + )} +
+
- - set("api", v || undefined)} + options={API_OPTIONS} + /> + -
- set("reasoning", v || undefined)} /> - set("input", v ? ["text", "image"] : undefined)} /> -
+
+ set("reasoning", v || undefined)} + /> + set("input", v ? ["text", "image"] : undefined)} + /> +
- {model.reasoning && ( - <> - onChange(setDeepseekCompat(model, v))} - /> -
-
- Thinking level map - {model.thinkingLevelMap && ( - - )} -
- set("thinkingLevelMap", v)} - /> -
- - )} + {model.reasoning && ( + <> + onChange(setDeepseekCompat(model, v))} + /> +
+
+ Thinking level map + {model.thinkingLevelMap && ( + + )} +
+ set("thinkingLevelMap", v)} + /> +
+ + )} -
- - set("contextWindow", v ? parseInt(v) : undefined)} placeholder="128000" /> - - - set("maxTokens", v ? parseInt(v) : undefined)} placeholder="16384" /> - -
+
+ + set("contextWindow", v ? parseInt(v) : undefined)} + placeholder="128000" + /> + + + set("maxTokens", v ? parseInt(v) : undefined)} + placeholder="16384" + /> + +
-
- Cost (per million tokens) -
- {(["input", "output", "cacheRead", "cacheWrite"] as const).map((k) => ( - - setCost(k, v)} placeholder="0" /> - - ))} -
-
-
- ); +
+ Cost (per million tokens) +
+ {(["input", "output", "cacheRead", "cacheWrite"] as const).map( + (k) => ( + + setCost(k, v)} + placeholder="0" + /> + + ), + )} +
+
+
+ ); } // ── OAuth detail ────────────────────────────────────────────────────────────── -function OAuthDetail({ provider, onRefresh }: { provider: OAuthProvider; onRefresh: () => void }) { - const [loginState, setLoginState] = useState({ phase: "idle" }); - const { t } = useI18n(); - const [inputValue, setInputValue] = useState(""); - const eventSourceRef = useRef(null); - const inputRef = useRef(null); +function OAuthDetail({ + provider, + onRefresh, +}: { + provider: OAuthProvider; + onRefresh: () => void; +}) { + const [loginState, setLoginState] = useState({ + phase: "idle", + }); + const { t } = useI18n(); + const [inputValue, setInputValue] = useState(""); + const eventSourceRef = useRef(null); + const inputRef = useRef(null); - useEffect(() => { - if (loginState.phase === "auth" || loginState.phase === "prompt") { - setTimeout(() => inputRef.current?.focus(), 50); - } - }, [loginState.phase]); + useEffect(() => { + if (loginState.phase === "auth" || loginState.phase === "prompt") { + setTimeout(() => inputRef.current?.focus(), 50); + } + }, [loginState.phase]); - // Reset state when provider changes - useEffect(() => { - setLoginState({ phase: "idle" }); - setInputValue(""); - eventSourceRef.current?.close(); - eventSourceRef.current = null; - }, [provider.id]); + // Reset state when provider changes + useEffect(() => { + setLoginState({ phase: "idle" }); + setInputValue(""); + eventSourceRef.current?.close(); + eventSourceRef.current = null; + }, [provider.id]); - useEffect(() => { - return () => { eventSourceRef.current?.close(); }; - }, []); + useEffect(() => { + return () => { + eventSourceRef.current?.close(); + }; + }, []); - const handleLogin = useCallback(() => { - eventSourceRef.current?.close(); - setLoginState({ phase: "connecting" }); - setInputValue(""); + const handleLogin = useCallback(() => { + eventSourceRef.current?.close(); + setLoginState({ phase: "connecting" }); + setInputValue(""); - const es = new EventSource(`/api/auth/login/${encodeURIComponent(provider.id)}`); - eventSourceRef.current = es; + const es = new EventSource( + `/api/auth/login/${encodeURIComponent(provider.id)}`, + ); + eventSourceRef.current = es; - es.onmessage = (e) => { - const data = JSON.parse(e.data) as { - type: string; url?: string; instructions?: string | null; - token?: string; message?: string; placeholder?: string | null; - userCode?: string; verificationUri?: string; intervalSeconds?: number | null; expiresInSeconds?: number | null; - options?: { id: string; label: string }[]; - }; - if (data.type === "auth") { - setLoginState({ phase: "auth", url: data.url!, instructions: data.instructions ?? null, token: data.token! }); - window.open(data.url!, "_blank", "noopener,noreferrer"); - } else if (data.type === "device_code") { - setLoginState({ - phase: "device_code", - userCode: data.userCode!, - verificationUri: data.verificationUri!, - intervalSeconds: data.intervalSeconds ?? null, - expiresInSeconds: data.expiresInSeconds ?? null, - }); - window.open(data.verificationUri!, "_blank", "noopener,noreferrer"); - } else if (data.type === "prompt_request") { - setLoginState({ phase: "prompt", message: data.message!, placeholder: data.placeholder ?? null, token: data.token! }); - } else if (data.type === "select_request") { - setLoginState({ phase: "select", message: data.message!, options: data.options ?? [], token: data.token! }); - } else if (data.type === "progress") { - setLoginState({ phase: "progress", message: data.message! }); - } else if (data.type === "success") { - es.close(); - setLoginState({ phase: "success" }); - onRefresh(); - } else if (data.type === "error") { - es.close(); - setLoginState({ phase: "error", message: data.message! }); - } else if (data.type === "cancelled") { - es.close(); - setLoginState({ phase: "idle" }); - } - }; - es.onerror = () => { - es.close(); - setLoginState((prev) => prev.phase === "success" ? prev : { phase: "error", message: "Connection lost" }); - }; - }, [provider.id, onRefresh]); + es.onmessage = (e) => { + const data = JSON.parse(e.data) as { + type: string; + url?: string; + instructions?: string | null; + token?: string; + message?: string; + placeholder?: string | null; + userCode?: string; + verificationUri?: string; + intervalSeconds?: number | null; + expiresInSeconds?: number | null; + options?: { id: string; label: string }[]; + }; + if (data.type === "auth") { + setLoginState({ + phase: "auth", + url: data.url!, + instructions: data.instructions ?? null, + token: data.token!, + }); + window.open(data.url!, "_blank", "noopener,noreferrer"); + } else if (data.type === "device_code") { + setLoginState({ + phase: "device_code", + userCode: data.userCode!, + verificationUri: data.verificationUri!, + intervalSeconds: data.intervalSeconds ?? null, + expiresInSeconds: data.expiresInSeconds ?? null, + }); + window.open(data.verificationUri!, "_blank", "noopener,noreferrer"); + } else if (data.type === "prompt_request") { + setLoginState({ + phase: "prompt", + message: data.message!, + placeholder: data.placeholder ?? null, + token: data.token!, + }); + } else if (data.type === "select_request") { + setLoginState({ + phase: "select", + message: data.message!, + options: data.options ?? [], + token: data.token!, + }); + } else if (data.type === "progress") { + setLoginState({ phase: "progress", message: data.message! }); + } else if (data.type === "success") { + es.close(); + setLoginState({ phase: "success" }); + onRefresh(); + } else if (data.type === "error") { + es.close(); + setLoginState({ phase: "error", message: data.message! }); + } else if (data.type === "cancelled") { + es.close(); + setLoginState({ phase: "idle" }); + } + }; + es.onerror = () => { + es.close(); + setLoginState((prev) => + prev.phase === "success" + ? prev + : { phase: "error", message: "Connection lost" }, + ); + }; + }, [provider.id, onRefresh]); - const handleLogout = useCallback(async () => { - await fetch(`/api/auth/logout/${encodeURIComponent(provider.id)}`, { method: "POST" }); - setLoginState({ phase: "idle" }); - onRefresh(); - }, [provider.id, onRefresh]); + const handleLogout = useCallback(async () => { + await fetch(`/api/auth/logout/${encodeURIComponent(provider.id)}`, { + method: "POST", + }); + setLoginState({ phase: "idle" }); + onRefresh(); + }, [provider.id, onRefresh]); - const submitCode = useCallback(async (token: string, code: string) => { - if (!code.trim()) return; - setLoginState({ phase: "progress", message: "Verifying…" }); - try { - const res = await fetch(`/api/auth/login/${encodeURIComponent(provider.id)}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token, code: code.trim() }), - }); - if (!res.ok) { - const d = await res.json().catch(() => ({})) as { error?: string }; - setLoginState({ phase: "error", message: d.error ?? `Server error ${res.status}` }); - return; - } - setInputValue(""); - // Success path: SSE stream will emit "success" and update state - } catch (e) { - setLoginState({ phase: "error", message: e instanceof Error ? e.message : "Network error" }); - } - }, [provider.id]); + const submitCode = useCallback( + async (token: string, code: string) => { + if (!code.trim()) return; + setLoginState({ phase: "progress", message: "Verifying…" }); + try { + const res = await fetch( + `/api/auth/login/${encodeURIComponent(provider.id)}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token, code: code.trim() }), + }, + ); + if (!res.ok) { + const d = (await res.json().catch(() => ({}))) as { error?: string }; + setLoginState({ + phase: "error", + message: d.error ?? `Server error ${res.status}`, + }); + return; + } + setInputValue(""); + // Success path: SSE stream will emit "success" and update state + } catch (e) { + setLoginState({ + phase: "error", + message: e instanceof Error ? e.message : "Network error", + }); + } + }, + [provider.id], + ); - const submitSelection = useCallback(async (token: string, value: string) => { - setLoginState({ phase: "progress", message: "Continuing…" }); - try { - const res = await fetch(`/api/auth/login/${encodeURIComponent(provider.id)}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token, code: value }), - }); - if (!res.ok) { - const d = await res.json().catch(() => ({})) as { error?: string }; - setLoginState({ phase: "error", message: d.error ?? `Server error ${res.status}` }); - } - } catch (e) { - setLoginState({ phase: "error", message: e instanceof Error ? e.message : "Network error" }); - } - }, [provider.id]); + const submitSelection = useCallback( + async (token: string, value: string) => { + setLoginState({ phase: "progress", message: "Continuing…" }); + try { + const res = await fetch( + `/api/auth/login/${encodeURIComponent(provider.id)}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token, code: value }), + }, + ); + if (!res.ok) { + const d = (await res.json().catch(() => ({}))) as { error?: string }; + setLoginState({ + phase: "error", + message: d.error ?? `Server error ${res.status}`, + }); + } + } catch (e) { + setLoginState({ + phase: "error", + message: e instanceof Error ? e.message : "Network error", + }); + } + }, + [provider.id], + ); - const isWorking = loginState.phase === "connecting" || loginState.phase === "progress" || - loginState.phase === "auth" || loginState.phase === "device_code" || - loginState.phase === "prompt" || loginState.phase === "select"; + const isWorking = + loginState.phase === "connecting" || + loginState.phase === "progress" || + loginState.phase === "auth" || + loginState.phase === "device_code" || + loginState.phase === "prompt" || + loginState.phase === "select"; - return ( -
-
- {t("i18n.subscription")} -
- - - {provider.loggedIn ? t("i18n.connected") : t("i18n.notConnected")} - -
-
+ return ( +
+
+ {t("i18n.subscription")} +
+ + + {provider.loggedIn ? t("i18n.connected") : t("i18n.notConnected")} + +
+
- {/* Status */} -
- {loginState.phase === "idle" && ( -

- {provider.loggedIn ? "Already connected. You can re-login or disconnect." : `Connect your ${provider.name} account.`} -

- )} - {loginState.phase === "connecting" && ( -

{t("i18n.openingBrowser")}

- )} - {loginState.phase === "select" && ( -
-

- {loginState.message} -

-
- {loginState.options.map((option) => ( - - ))} -
-
- )} - {(loginState.phase === "auth" || loginState.phase === "prompt") && ( -
-

- {loginState.phase === "auth" - ? "Complete sign-in in the browser, then copy the redirect URL from the address bar and paste it below." - : loginState.message} -

- {loginState.phase === "auth" && ( -

- If the browser window did not open,{" "} - - click here to open the login page - - . -

- )} -
- setInputValue(e.target.value)} - onKeyDown={(e) => { if (e.key === "Enter") submitCode(loginState.token, inputValue); }} - placeholder={loginState.phase === "auth" ? "http://localhost:1455/auth/callback?code=…" : (loginState.placeholder ?? "Enter value…")} - style={{ flex: 1, padding: "6px 9px", background: "var(--bg)", border: "1px solid var(--border)", borderRadius: 5, color: "var(--text)", fontSize: 12, outline: "none", fontFamily: "var(--font-mono)", boxSizing: "border-box" }} - /> - -
-
- )} - {loginState.phase === "device_code" && ( -
-

- Open the verification page and enter this code: -

-
- {loginState.userCode} -
-

- - {loginState.verificationUri} - - {loginState.expiresInSeconds ? ` Expires in ${Math.ceil(loginState.expiresInSeconds / 60)} minutes.` : ""} -

-
- )} - {loginState.phase === "progress" && ( -

{loginState.message}

- )} - {loginState.phase === "success" && ( -

{t("i18n.connectedSuccessfully")}

- )} - {loginState.phase === "error" && ( -

{loginState.message}

- )} -
+ {/* Status */} +
+ {loginState.phase === "idle" && ( +

+ {provider.loggedIn + ? "Already connected. You can re-login or disconnect." + : `Connect your ${provider.name} account.`} +

+ )} + {loginState.phase === "connecting" && ( +

+ {t("i18n.openingBrowser")} +

+ )} + {loginState.phase === "select" && ( +
+

+ {loginState.message} +

+
+ {loginState.options.map((option) => ( + + ))} +
+
+ )} + {(loginState.phase === "auth" || loginState.phase === "prompt") && ( +
+

+ {loginState.phase === "auth" + ? "Complete sign-in in the browser, then copy the redirect URL from the address bar and paste it below." + : loginState.message} +

+ {loginState.phase === "auth" && ( +

+ If the browser window did not open,{" "} + + click here to open the login page + + . +

+ )} +
+ setInputValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") + submitCode(loginState.token, inputValue); + }} + placeholder={ + loginState.phase === "auth" + ? "http://localhost:1455/auth/callback?code=…" + : (loginState.placeholder ?? "Enter value…") + } + style={{ + flex: 1, + padding: "6px 9px", + background: "var(--bg)", + border: "1px solid var(--border)", + borderRadius: 5, + color: "var(--text)", + fontSize: 12, + outline: "none", + fontFamily: "var(--font-mono)", + boxSizing: "border-box", + }} + /> + +
+
+ )} + {loginState.phase === "device_code" && ( +
+

+ Open the verification page and enter this code: +

+
+ {loginState.userCode} +
+

+ + {loginState.verificationUri} + + {loginState.expiresInSeconds + ? ` Expires in ${Math.ceil(loginState.expiresInSeconds / 60)} minutes.` + : ""} +

+
+ )} + {loginState.phase === "progress" && ( +

+ {loginState.message} +

+ )} + {loginState.phase === "success" && ( +

+ {t("i18n.connectedSuccessfully")} +

+ )} + {loginState.phase === "error" && ( +

+ {loginState.message} +

+ )} +
- {/* Actions */} -
- {isWorking ? ( - - ) : ( - <> - - {provider.loggedIn && ( - - )} - - )} -
-
- ); + {/* Actions */} +
+ {isWorking ? ( + + ) : ( + <> + + {provider.loggedIn && ( + + )} + + )} +
+
+ ); } // ── API Key detail ──────────────────────────────────────────────────────────── -function ApiKeyDetail({ provider, onRefresh }: { provider: ApiKeyProvider; onRefresh: () => void }) { - const [apiKey, setApiKey] = useState(""); - const [saving, setSaving] = useState(false); - const [removing, setRemoving] = useState(false); - const [error, setError] = useState(null); - const [savedOk, setSavedOk] = useState(false); - const { t } = useI18n(); +function ApiKeyDetail({ + provider, + onRefresh, +}: { + provider: ApiKeyProvider; + onRefresh: () => void; +}) { + const [apiKey, setApiKey] = useState(""); + const [saving, setSaving] = useState(false); + const [removing, setRemoving] = useState(false); + const [error, setError] = useState(null); + const [savedOk, setSavedOk] = useState(false); + const { t } = useI18n(); - // Reset state when provider changes - useEffect(() => { - setApiKey(""); - setError(null); - setSavedOk(false); - }, [provider.id]); + // Reset state when provider changes + useEffect(() => { + setApiKey(""); + setError(null); + setSavedOk(false); + }, [provider.id]); - const handleSave = useCallback(async () => { - if (!apiKey.trim()) return; - setSaving(true); - setError(null); - setSavedOk(false); - try { - const res = await fetch(`/api/auth/api-key/${encodeURIComponent(provider.id)}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ apiKey: apiKey.trim() }), - }); - const d = await res.json() as { success?: boolean; error?: string }; - if (!res.ok || d.error) { - setError(d.error ?? `HTTP ${res.status}`); - } else { - setApiKey(""); - setSavedOk(true); - setTimeout(() => setSavedOk(false), 2000); - onRefresh(); - } - } catch (e) { - setError(String(e)); - } finally { - setSaving(false); - } - }, [apiKey, provider.id, onRefresh]); + const handleSave = useCallback(async () => { + if (!apiKey.trim()) return; + setSaving(true); + setError(null); + setSavedOk(false); + try { + const res = await fetch( + `/api/auth/api-key/${encodeURIComponent(provider.id)}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ apiKey: apiKey.trim() }), + }, + ); + const d = (await res.json()) as { success?: boolean; error?: string }; + if (!res.ok || d.error) { + setError(d.error ?? `HTTP ${res.status}`); + } else { + setApiKey(""); + setSavedOk(true); + setTimeout(() => setSavedOk(false), 2000); + onRefresh(); + } + } catch (e) { + setError(String(e)); + } finally { + setSaving(false); + } + }, [apiKey, provider.id, onRefresh]); - const handleRemove = useCallback(async () => { - setRemoving(true); - setError(null); - try { - const res = await fetch(`/api/auth/api-key/${encodeURIComponent(provider.id)}`, { method: "DELETE" }); - const d = await res.json() as { success?: boolean; error?: string }; - if (!res.ok || d.error) setError(d.error ?? `HTTP ${res.status}`); - else onRefresh(); - } catch (e) { - setError(String(e)); - } finally { - setRemoving(false); - } - }, [provider.id, onRefresh]); + const handleRemove = useCallback(async () => { + setRemoving(true); + setError(null); + try { + const res = await fetch( + `/api/auth/api-key/${encodeURIComponent(provider.id)}`, + { method: "DELETE" }, + ); + const d = (await res.json()) as { success?: boolean; error?: string }; + if (!res.ok || d.error) setError(d.error ?? `HTTP ${res.status}`); + else onRefresh(); + } catch (e) { + setError(String(e)); + } finally { + setRemoving(false); + } + }, [provider.id, onRefresh]); - return ( -
-
- API Key -
- - - {provider.configured ? t("i18n.configured") : t("i18n.notConfigured")} - -
-
+ return ( +
+
+ API Key +
+ + + {provider.configured + ? t("i18n.configured") + : t("i18n.notConfigured")} + +
+
-

- {provider.configured - ? `API key is stored. Enter a new key below to replace it, or disconnect to remove it.` - : `Enter your ${provider.displayName} API key to enable ${provider.modelCount} model${provider.modelCount !== 1 ? "s" : ""}.`} -

+

+ {provider.configured + ? `API key is stored. Enter a new key below to replace it, or disconnect to remove it.` + : `Enter your ${provider.displayName} API key to enable ${provider.modelCount} model${provider.modelCount !== 1 ? "s" : ""}.`} +

- -
- { if (e.key === "Enter" && apiKey.trim()) handleSave(); }} - placeholder={provider.configured ? "Enter new key to replace…" : "sk-…"} - style={{ flex: 1 }} - autoComplete="off" - spellCheck={false} - mono - /> - -
-
+ +
+ { + if (e.key === "Enter" && apiKey.trim()) handleSave(); + }} + placeholder={ + provider.configured ? "Enter new key to replace…" : "sk-…" + } + style={{ flex: 1 }} + autoComplete="off" + spellCheck={false} + mono + /> + +
+
- {error &&

{error}

} + {error && ( +

{error}

+ )} - {provider.configured && ( - - )} -
- ); + {provider.configured && ( + + )} +
+ ); } // ── Provider icon ───────────────────────────────────────────────────────────── function ProviderIcon({ id, size }: { id: string; size: number }) { - const pi = PROVIDER_ICONS[id]; - if (!pi) { - const label = id - .split(/[-_]/) - .filter(Boolean) - .slice(0, 2) - .map((part) => part[0]) - .join("") - .toUpperCase() || "?"; - return ( - - ); - } - // Color icons: self-colored SVG, no wrapper needed - if (pi.hasColor) return ; - // Mono icons: use currentColor so they adapt to light/dark theme - return ; + const pi = PROVIDER_ICONS[id]; + if (!pi) { + const label = + id + .split(/[-_]/) + .filter(Boolean) + .slice(0, 2) + .map((part) => part[0]) + .join("") + .toUpperCase() || "?"; + return ( + + ); + } + // Color icons: self-colored SVG, no wrapper needed + if (pi.hasColor) return ; + // Mono icons: use currentColor so they adapt to light/dark theme + return ; } // ── Add provider picker ─────────────────────────────────────────────────────── interface AddProviderPickerProps { - oauthProviders: OAuthProvider[]; - apiKeyProviders: ApiKeyProvider[]; - onSelectOAuth: (id: string) => void; - onSelectApiKey: (id: string) => void; - onAddCustom: () => void; - onClose: () => void; + oauthProviders: OAuthProvider[]; + apiKeyProviders: ApiKeyProvider[]; + onSelectOAuth: (id: string) => void; + onSelectApiKey: (id: string) => void; + onAddCustom: () => void; + onClose: () => void; } function AddProviderPicker({ - oauthProviders, apiKeyProviders, - onSelectOAuth, onSelectApiKey, onAddCustom, onClose, + oauthProviders, + apiKeyProviders, + onSelectOAuth, + onSelectApiKey, + onAddCustom, + onClose, }: AddProviderPickerProps) { - const [search, setSearch] = useState(""); - const { t } = useI18n(); - const inputRef = useRef(null); + const [search, setSearch] = useState(""); + const { t } = useI18n(); + const inputRef = useRef(null); - useEffect(() => { setTimeout(() => inputRef.current?.focus(), 30); }, []); + useEffect(() => { + setTimeout(() => inputRef.current?.focus(), 30); + }, []); - const q = search.trim().toLowerCase(); + const q = search.trim().toLowerCase(); - const availableOAuth = oauthProviders.filter((p) => !p.loggedIn && (!q || p.name.toLowerCase().includes(q))); - const availableApiKey = apiKeyProviders.filter((p) => !p.configured && (!q || p.displayName.toLowerCase().includes(q) || p.id.toLowerCase().includes(q))); - const showCustom = !q || "custom".includes(q) || "openai-compatible".includes(q) || "anthropic-compatible".includes(q); + const availableOAuth = oauthProviders.filter( + (p) => !p.loggedIn && (!q || p.name.toLowerCase().includes(q)), + ); + const availableApiKey = apiKeyProviders.filter( + (p) => + !p.configured && + (!q || + p.displayName.toLowerCase().includes(q) || + p.id.toLowerCase().includes(q)), + ); + const showCustom = + !q || + "custom".includes(q) || + "openai-compatible".includes(q) || + "anthropic-compatible".includes(q); - const totalCount = availableOAuth.length + availableApiKey.length + (showCustom ? 1 : 0); + const totalCount = + availableOAuth.length + availableApiKey.length + (showCustom ? 1 : 0); - const cardStyle: React.CSSProperties = { - display: "flex", flexDirection: "row", alignItems: "center", gap: 8, - padding: "10px 12px", - background: "var(--bg-panel)", - border: "1px solid var(--border)", - borderRadius: 7, - boxSizing: "border-box", - cursor: "pointer", - minWidth: 0, - textAlign: "left", - transition: "border-color 0.12s, background 0.12s", - width: "100%", - }; + const cardStyle: React.CSSProperties = { + display: "flex", + flexDirection: "row", + alignItems: "center", + gap: 8, + padding: "10px 12px", + background: "var(--bg-panel)", + border: "1px solid var(--border)", + borderRadius: 7, + boxSizing: "border-box", + cursor: "pointer", + minWidth: 0, + textAlign: "left", + transition: "border-color 0.12s, background 0.12s", + width: "100%", + }; + return ( +
{ + if (e.target === e.currentTarget) onClose(); + }} + > +
+ {/* Search */} +
+ + + + + setSearch(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Escape") onClose(); + }} + placeholder={t("i18n.searchProviders")} + style={{ + flex: 1, + background: "none", + border: "none", + outline: "none", + color: "var(--text)", + fontSize: 13, + boxSizing: "border-box", + }} + /> +
+ {/* Card grid */} +
+ {totalCount === 0 ? ( +
+ {t("i18n.noProviders")} +
+ ) : ( +
+ {showCustom && ( +
+ {t("i18n.custom")} +
+ )} + {showCustom && ( + + )} - return ( -
{ if (e.target === e.currentTarget) onClose(); }} - > -
- {/* Search */} -
- - - - setSearch(e.target.value)} - onKeyDown={(e) => { if (e.key === "Escape") onClose(); }} - placeholder={t("i18n.searchProviders")} - style={{ flex: 1, background: "none", border: "none", outline: "none", color: "var(--text)", fontSize: 13, boxSizing: "border-box" }} - /> -
+ {availableOAuth.length > 0 && ( +
+ {t("i18n.subscriptions")} +
+ )} + {availableOAuth.map((p) => ( + + ))} - {/* Card grid */} -
- {totalCount === 0 ? ( -
{t("i18n.noProviders")}
- ) : ( -
- {showCustom && ( -
{t("i18n.custom")}
- )} - {showCustom && ( - - )} - - {availableOAuth.length > 0 && ( -
{t("i18n.subscriptions")}
- )} - {availableOAuth.map((p) => ( - - ))} - - {availableApiKey.length > 0 && ( -
0 ? 6 : 0, fontSize: 10, fontWeight: 600, color: "var(--text-dim)", textTransform: "uppercase", letterSpacing: "0.07em" }}>API Key
- )} - {availableApiKey.map((p) => ( - - ))} - -
- )} -
-
-
- ); + {availableApiKey.length > 0 && ( +
0 ? 6 : 0, + fontSize: 10, + fontWeight: 600, + color: "var(--text-dim)", + textTransform: "uppercase", + letterSpacing: "0.07em", + }} + > + API Key +
+ )} + {availableApiKey.map((p) => ( + + ))} +
+ )} +
+
+
+ ); } // ── Main component ──────────────────────────────────────────────────────────── export function ModelsConfig({ onClose }: { onClose: () => void }) { - const isMobile = useIsMobile(); - const { t } = useI18n(); - const [config, setConfig] = useState({ providers: {} }); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - const [saveError, setSaveError] = useState(null); - const [savedOk, setSavedOk] = useState(false); - const [selection, setSelection] = useState(null); - const [oauthProviders, setOauthProviders] = useState([]); - const [apiKeyProviders, setApiKeyProviders] = useState([]); - const [pickerOpen, setPickerOpen] = useState(false); + const isMobile = useIsMobile(); + const { t } = useI18n(); + const [config, setConfig] = useState({ providers: {} }); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + const [savedOk, setSavedOk] = useState(false); + const [selection, setSelection] = useState(null); + const [oauthProviders, setOauthProviders] = useState([]); + const [apiKeyProviders, setApiKeyProviders] = useState([]); + const [pickerOpen, setPickerOpen] = useState(false); + const [tab, setTab] = useState<"models" | "vision">("models"); - const loadOAuthProviders = useCallback(() => { - fetch("/api/auth/providers") - .then((r) => r.json()) - .then((d: { providers: OAuthProvider[] }) => setOauthProviders(d.providers)) - .catch(() => {}); - }, []); + const loadOAuthProviders = useCallback(() => { + fetch("/api/auth/providers") + .then((r) => r.json()) + .then((d: { providers: OAuthProvider[] }) => + setOauthProviders(d.providers), + ) + .catch(() => {}); + }, []); - const loadApiKeyProviders = useCallback(() => { - fetch("/api/auth/all-providers") - .then((r) => r.json()) - .then((d: { providers: ApiKeyProvider[] }) => setApiKeyProviders(d.providers)) - .catch(() => {}); - }, []); + const loadApiKeyProviders = useCallback(() => { + fetch("/api/auth/all-providers") + .then((r) => r.json()) + .then((d: { providers: ApiKeyProvider[] }) => + setApiKeyProviders(d.providers), + ) + .catch(() => {}); + }, []); - useEffect(() => { - fetch("/api/models-config") - .then((r) => r.json()) - .then((d: ModelsJson) => { - const normalized = d.providers ? d : { ...d, providers: {} }; - setConfig(normalized); - const keys = Object.keys(normalized.providers ?? {}); - if (keys.length > 0) setSelection({ type: "provider", name: keys[0] }); - }) - .catch(() => setConfig({ providers: {} })) - .finally(() => setLoading(false)); - loadOAuthProviders(); - loadApiKeyProviders(); - }, [loadOAuthProviders, loadApiKeyProviders]); + useEffect(() => { + fetch("/api/models-config") + .then((r) => r.json()) + .then((d: ModelsJson) => { + const normalized = d.providers ? d : { ...d, providers: {} }; + setConfig(normalized); + const keys = Object.keys(normalized.providers ?? {}); + if (keys.length > 0) setSelection({ type: "provider", name: keys[0] }); + }) + .catch(() => setConfig({ providers: {} })) + .finally(() => setLoading(false)); + loadOAuthProviders(); + loadApiKeyProviders(); + }, [loadOAuthProviders, loadApiKeyProviders]); - const addCustomProvider = useCallback(() => { - let finalName = "new-provider"; - let n = 1; - while (config.providers?.[finalName]) finalName = `new-provider-${n++}`; - setConfig((prev) => ({ ...prev, providers: { ...(prev.providers ?? {}), [finalName]: { api: "openai-completions" } } })); - setSelection({ type: "provider", name: finalName }); - }, [config.providers]); + const addCustomProvider = useCallback(() => { + let finalName = "new-provider"; + let n = 1; + while (config.providers?.[finalName]) finalName = `new-provider-${n++}`; + setConfig((prev) => ({ + ...prev, + providers: { + ...(prev.providers ?? {}), + [finalName]: { api: "openai-completions" }, + }, + })); + setSelection({ type: "provider", name: finalName }); + }, [config.providers]); - const updateProvider = useCallback((name: string, p: ProviderEntry) => { - setConfig((prev) => ({ ...prev, providers: { ...(prev.providers ?? {}), [name]: p } })); - }, []); + const updateProvider = useCallback((name: string, p: ProviderEntry) => { + setConfig((prev) => ({ + ...prev, + providers: { ...(prev.providers ?? {}), [name]: p }, + })); + }, []); - const renameProvider = useCallback((oldName: string, newName: string) => { - setConfig((prev) => { - const entries = Object.entries(prev.providers ?? {}); - const idx = entries.findIndex(([k]) => k === oldName); - if (idx === -1) return prev; - entries[idx] = [newName, entries[idx][1]]; - return { ...prev, providers: Object.fromEntries(entries) }; - }); - setSelection((prev) => { - if (!prev) return prev; - if (prev.type === "provider" && prev.name === oldName) return { type: "provider", name: newName }; - if (prev.type === "model" && prev.providerName === oldName) return { ...prev, providerName: newName }; - return prev; - }); - }, []); + const renameProvider = useCallback((oldName: string, newName: string) => { + setConfig((prev) => { + const entries = Object.entries(prev.providers ?? {}); + const idx = entries.findIndex(([k]) => k === oldName); + if (idx === -1) return prev; + entries[idx] = [newName, entries[idx][1]]; + return { ...prev, providers: Object.fromEntries(entries) }; + }); + setSelection((prev) => { + if (!prev) return prev; + if (prev.type === "provider" && prev.name === oldName) + return { type: "provider", name: newName }; + if (prev.type === "model" && prev.providerName === oldName) + return { ...prev, providerName: newName }; + return prev; + }); + }, []); - const deleteProvider = useCallback((name: string) => { - setConfig((prev) => { - const providers = { ...(prev.providers ?? {}) }; - delete providers[name]; - return { ...prev, providers }; - }); - setConfig((prev) => { - const remaining = Object.keys(prev.providers ?? {}); - setSelection(remaining.length > 0 ? { type: "provider", name: remaining[0] } : null); - return prev; - }); - }, []); + const deleteProvider = useCallback((name: string) => { + setConfig((prev) => { + const providers = { ...(prev.providers ?? {}) }; + delete providers[name]; + return { ...prev, providers }; + }); + setConfig((prev) => { + const remaining = Object.keys(prev.providers ?? {}); + setSelection( + remaining.length > 0 ? { type: "provider", name: remaining[0] } : null, + ); + return prev; + }); + }, []); - const addModel = useCallback((providerName: string) => { - setConfig((prev) => { - const provider = prev.providers?.[providerName] ?? {}; - const models = [...(provider.models ?? []), { id: "" }]; - return { ...prev, providers: { ...(prev.providers ?? {}), [providerName]: { ...provider, models } } }; - }); - setConfig((prev) => { - const idx = (prev.providers?.[providerName]?.models?.length ?? 1) - 1; - setSelection({ type: "model", providerName, index: idx }); - return prev; - }); - }, []); + const addModel = useCallback((providerName: string) => { + setConfig((prev) => { + const provider = prev.providers?.[providerName] ?? {}; + const models = [...(provider.models ?? []), { id: "" }]; + return { + ...prev, + providers: { + ...(prev.providers ?? {}), + [providerName]: { ...provider, models }, + }, + }; + }); + setConfig((prev) => { + const idx = (prev.providers?.[providerName]?.models?.length ?? 1) - 1; + setSelection({ type: "model", providerName, index: idx }); + return prev; + }); + }, []); - const addDiscoveredModels = useCallback((providerName: string, discovered: DiscoveredModel[]) => { - setConfig((prev) => { - const provider = prev.providers?.[providerName] ?? {}; - const models = [...(provider.models ?? [])]; - const existingIds = new Set(models.map((model) => model.id)); - for (const discoveredModel of discovered) { - if (existingIds.has(discoveredModel.id)) continue; - existingIds.add(discoveredModel.id); - models.push({ id: discoveredModel.id, name: discoveredModel.name }); - } - return { ...prev, providers: { ...(prev.providers ?? {}), [providerName]: { ...provider, models } } }; - }); - }, []); + const addDiscoveredModels = useCallback( + (providerName: string, discovered: DiscoveredModel[]) => { + setConfig((prev) => { + const provider = prev.providers?.[providerName] ?? {}; + const models = [...(provider.models ?? [])]; + const existingIds = new Set(models.map((model) => model.id)); + for (const discoveredModel of discovered) { + if (existingIds.has(discoveredModel.id)) continue; + existingIds.add(discoveredModel.id); + models.push({ id: discoveredModel.id, name: discoveredModel.name }); + } + return { + ...prev, + providers: { + ...(prev.providers ?? {}), + [providerName]: { ...provider, models }, + }, + }; + }); + }, + [], + ); - const updateModel = useCallback((providerName: string, index: number, m: ModelEntry) => { - setConfig((prev) => { - const provider = prev.providers?.[providerName] ?? {}; - const models = [...(provider.models ?? [])]; - models[index] = m; - return { ...prev, providers: { ...(prev.providers ?? {}), [providerName]: { ...provider, models } } }; - }); - }, []); + const updateModel = useCallback( + (providerName: string, index: number, m: ModelEntry) => { + setConfig((prev) => { + const provider = prev.providers?.[providerName] ?? {}; + const models = [...(provider.models ?? [])]; + models[index] = m; + return { + ...prev, + providers: { + ...(prev.providers ?? {}), + [providerName]: { ...provider, models }, + }, + }; + }); + }, + [], + ); - const removeModel = useCallback((providerName: string, index: number) => { - setConfig((prev) => { - const provider = prev.providers?.[providerName] ?? {}; - const models = [...(provider.models ?? [])]; - models.splice(index, 1); - return { ...prev, providers: { ...(prev.providers ?? {}), [providerName]: { ...provider, models: models.length ? models : undefined } } }; - }); - setSelection({ type: "provider", name: providerName }); - }, []); + const removeModel = useCallback((providerName: string, index: number) => { + setConfig((prev) => { + const provider = prev.providers?.[providerName] ?? {}; + const models = [...(provider.models ?? [])]; + models.splice(index, 1); + return { + ...prev, + providers: { + ...(prev.providers ?? {}), + [providerName]: { + ...provider, + models: models.length ? models : undefined, + }, + }, + }; + }); + setSelection({ type: "provider", name: providerName }); + }, []); - const handleSave = useCallback(async () => { - setSaving(true); - setSaveError(null); - setSavedOk(false); - try { - const res = await fetch("/api/models-config", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(config), - }); - const d = await res.json() as { success?: boolean; error?: string }; - if (!res.ok || d.error) setSaveError(d.error ?? `HTTP ${res.status}`); - else { setSavedOk(true); setTimeout(() => setSavedOk(false), 2000); } - } catch (e) { - setSaveError(String(e)); - } finally { - setSaving(false); - } - }, [config]); + const handleSave = useCallback(async () => { + setSaving(true); + setSaveError(null); + setSavedOk(false); + try { + const res = await fetch("/api/models-config", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(config), + }); + const d = (await res.json()) as { success?: boolean; error?: string }; + if (!res.ok || d.error) setSaveError(d.error ?? `HTTP ${res.status}`); + else { + setSavedOk(true); + setTimeout(() => setSavedOk(false), 2000); + } + } catch (e) { + setSaveError(String(e)); + } finally { + setSaving(false); + } + }, [config]); - const providers = Object.entries(config.providers ?? {}); - const activeOAuth = oauthProviders.filter((p) => p.loggedIn); - const activeApiKey = apiKeyProviders.filter((p) => p.configured); + const providers = Object.entries(config.providers ?? {}); + const activeOAuth = oauthProviders.filter((p) => p.loggedIn); + const activeApiKey = apiKeyProviders.filter((p) => p.configured); - // Resolve current detail - const detailContent = (() => { - if (!selection) return null; - if (selection.type === "oauth") { - const p = oauthProviders.find((p) => p.id === selection.providerId); - if (!p) return null; - return ; - } - if (selection.type === "apikey") { - const p = apiKeyProviders.find((p) => p.id === selection.providerId); - if (!p) return null; - return ; - } - if (selection.type === "provider") { - const provider = config.providers?.[selection.name]; - if (!provider) return null; - return ( - updateProvider(selection.name, p)} - onRename={(n) => renameProvider(selection.name, n)} - onDelete={() => deleteProvider(selection.name)} - onAddModels={(models) => addDiscoveredModels(selection.name, models)} - /> - ); - } - const provider = config.providers?.[selection.providerName]; - const model = provider?.models?.[selection.index]; - if (!model) return null; - return ( - updateModel(selection.providerName, selection.index, m)} - onDelete={() => removeModel(selection.providerName, selection.index)} - /> - ); - })(); + // Resolve current detail + const detailContent = (() => { + if (!selection) return null; + if (selection.type === "oauth") { + const p = oauthProviders.find((p) => p.id === selection.providerId); + if (!p) return null; + return ( + + ); + } + if (selection.type === "apikey") { + const p = apiKeyProviders.find((p) => p.id === selection.providerId); + if (!p) return null; + return ( + + ); + } + if (selection.type === "provider") { + const provider = config.providers?.[selection.name]; + if (!provider) return null; + return ( + updateProvider(selection.name, p)} + onRename={(n) => renameProvider(selection.name, n)} + onDelete={() => deleteProvider(selection.name)} + onAddModels={(models) => addDiscoveredModels(selection.name, models)} + /> + ); + } + const provider = config.providers?.[selection.providerName]; + const model = provider?.models?.[selection.index]; + if (!model) return null; + return ( + + updateModel(selection.providerName, selection.index, m) + } + onDelete={() => removeModel(selection.providerName, selection.index)} + /> + ); + })(); - return ( - <> -
{ if (e.target === e.currentTarget) onClose(); }}> -
+ return ( + <> +
{ + if (e.target === e.currentTarget) onClose(); + }} + > +
+ {/* Header */} +
+
+ + {t("common.models")} + + + data/agent/models.json + +
+ +
- {/* Header */} -
-
- {t("common.models")} - data/agent/models.json -
- -
+ {/* Tabs */} +
+ + +
- {/* Body */} -
+ {/* Body */} +
+ {tab === "vision" ? ( + + ) : ( + <> + {/* Left: tree */} +
+
+ {/* Active OAuth subscriptions */} + {activeOAuth.map((p) => { + const isSelected = + selection?.type === "oauth" && + selection.providerId === p.id; + return ( +
+ setSelection({ type: "oauth", providerId: p.id }) + } + style={{ + display: "flex", + alignItems: "center", + gap: 7, + padding: "5px 8px", + borderRadius: 5, + cursor: "pointer", + background: isSelected + ? "var(--bg-selected)" + : "none", + }} + onMouseEnter={(e) => { + if (!isSelected) + e.currentTarget.style.background = + "var(--bg-hover)"; + }} + onMouseLeave={(e) => { + if (!isSelected) + e.currentTarget.style.background = "none"; + }} + > + + + {p.name} + +
+ ); + })} - {/* Left: tree */} -
-
- {/* Active OAuth subscriptions */} - {activeOAuth.map((p) => { - const isSelected = selection?.type === "oauth" && selection.providerId === p.id; - return ( -
setSelection({ type: "oauth", providerId: p.id })} - style={{ display: "flex", alignItems: "center", gap: 7, padding: "5px 8px", borderRadius: 5, cursor: "pointer", background: isSelected ? "var(--bg-selected)" : "none" }} - onMouseEnter={(e) => { if (!isSelected) e.currentTarget.style.background = "var(--bg-hover)"; }} - onMouseLeave={(e) => { if (!isSelected) e.currentTarget.style.background = "none"; }} - > - - {p.name} -
- ); - })} + {/* Active API key providers */} + {activeApiKey.map((p) => { + const isSelected = + selection?.type === "apikey" && + selection.providerId === p.id; + return ( +
+ setSelection({ type: "apikey", providerId: p.id }) + } + style={{ + display: "flex", + alignItems: "center", + gap: 7, + padding: "5px 8px", + borderRadius: 5, + cursor: "pointer", + background: isSelected + ? "var(--bg-selected)" + : "none", + }} + onMouseEnter={(e) => { + if (!isSelected) + e.currentTarget.style.background = + "var(--bg-hover)"; + }} + onMouseLeave={(e) => { + if (!isSelected) + e.currentTarget.style.background = "none"; + }} + > + + + {p.displayName} + +
+ ); + })} - {/* Active API key providers */} - {activeApiKey.map((p) => { - const isSelected = selection?.type === "apikey" && selection.providerId === p.id; - return ( -
setSelection({ type: "apikey", providerId: p.id })} - style={{ display: "flex", alignItems: "center", gap: 7, padding: "5px 8px", borderRadius: 5, cursor: "pointer", background: isSelected ? "var(--bg-selected)" : "none" }} - onMouseEnter={(e) => { if (!isSelected) e.currentTarget.style.background = "var(--bg-hover)"; }} - onMouseLeave={(e) => { if (!isSelected) e.currentTarget.style.background = "none"; }} - > - - {p.displayName} -
- ); - })} + {/* Divider before custom providers, only when there are active managed providers */} + {(activeOAuth.length > 0 || activeApiKey.length > 0) && + providers.length > 0 && ( +
+ )} - {/* Divider before custom providers, only when there are active managed providers */} - {(activeOAuth.length > 0 || activeApiKey.length > 0) && providers.length > 0 && ( -
- )} + {/* Custom providers */} + {loading ? ( +
+ {t("i18n.loading")} +
+ ) : ( + providers.map(([pName, pData]) => { + const isProviderSelected = + selection?.type === "provider" && + selection.name === pName; + const models = pData.models ?? []; + return ( +
+ {/* Provider row */} +
+ setSelection({ type: "provider", name: pName }) + } + style={{ + display: "flex", + alignItems: "center", + gap: 6, + padding: "7px 8px", + borderRadius: 5, + cursor: "pointer", + background: isProviderSelected + ? "var(--bg-selected)" + : "none", + }} + onMouseEnter={(e) => { + if (!isProviderSelected) + e.currentTarget.style.background = + "var(--bg-hover)"; + }} + onMouseLeave={(e) => { + if (!isProviderSelected) + e.currentTarget.style.background = "none"; + }} + > + + + + + + + + + + + + + + {pName} + +
- {/* Custom providers */} - {loading ? ( -
{t("i18n.loading")}
- ) : providers.map(([pName, pData]) => { - const isProviderSelected = selection?.type === "provider" && selection.name === pName; - const models = pData.models ?? []; - return ( -
- {/* Provider row */} -
setSelection({ type: "provider", name: pName })} - style={{ display: "flex", alignItems: "center", gap: 6, padding: "7px 8px", borderRadius: 5, cursor: "pointer", background: isProviderSelected ? "var(--bg-selected)" : "none" }} - onMouseEnter={(e) => { if (!isProviderSelected) e.currentTarget.style.background = "var(--bg-hover)"; }} - onMouseLeave={(e) => { if (!isProviderSelected) e.currentTarget.style.background = "none"; }} - > - - - - - - - - - {pName} - -
+ {/* Model rows */} + {models.map((m, i) => { + const isModelSelected = + selection?.type === "model" && + selection.providerName === pName && + selection.index === i; + return ( +
+ setSelection({ + type: "model", + providerName: pName, + index: i, + }) + } + style={{ + display: "flex", + alignItems: "center", + gap: 6, + padding: "5px 8px 5px 26px", + borderRadius: 5, + cursor: "pointer", + background: isModelSelected + ? "var(--bg-selected)" + : "none", + }} + onMouseEnter={(e) => { + if (!isModelSelected) + e.currentTarget.style.background = + "var(--bg-hover)"; + }} + onMouseLeave={(e) => { + if (!isModelSelected) + e.currentTarget.style.background = "none"; + }} + > + + {m.id || t("i18n.newModel")} + + {m.reasoning && ( + + T + + )} +
+ ); + })} - {/* Model rows */} - {models.map((m, i) => { - const isModelSelected = selection?.type === "model" && selection.providerName === pName && selection.index === i; - return ( -
setSelection({ type: "model", providerName: pName, index: i })} - style={{ display: "flex", alignItems: "center", gap: 6, padding: "5px 8px 5px 26px", borderRadius: 5, cursor: "pointer", background: isModelSelected ? "var(--bg-selected)" : "none" }} - onMouseEnter={(e) => { if (!isModelSelected) e.currentTarget.style.background = "var(--bg-hover)"; }} - onMouseLeave={(e) => { if (!isModelSelected) e.currentTarget.style.background = "none"; }} - > - - {m.id || t("i18n.newModel")} - - {m.reasoning && ( - T - )} -
- ); - })} + {/* Add model button */} +
{ + e.stopPropagation(); + addModel(pName); + }} + style={{ + display: "flex", + alignItems: "center", + gap: 4, + padding: "4px 8px 4px 26px", + borderRadius: 5, + cursor: "pointer", + color: "var(--text-dim)", + }} + onMouseEnter={(e) => { + e.currentTarget.style.color = "var(--accent)"; + e.currentTarget.style.background = + "var(--bg-hover)"; + }} + onMouseLeave={(e) => { + e.currentTarget.style.color = "var(--text-dim)"; + e.currentTarget.style.background = "none"; + }} + > + + + {t("i18n.model")} + +
+
+ ); + }) + )} +
- {/* Add model button */} -
{ e.stopPropagation(); addModel(pName); }} - style={{ display: "flex", alignItems: "center", gap: 4, padding: "4px 8px 4px 26px", borderRadius: 5, cursor: "pointer", color: "var(--text-dim)" }} - onMouseEnter={(e) => { e.currentTarget.style.color = "var(--accent)"; e.currentTarget.style.background = "var(--bg-hover)"; }} - onMouseLeave={(e) => { e.currentTarget.style.color = "var(--text-dim)"; e.currentTarget.style.background = "none"; }} - > - + {t("i18n.model")} -
-
- ); - })} -
+ {/* Add provider */} +
+ +
+
- {/* Add provider */} -
- -
-
+ {/* Right: detail */} +
+ {loading + ? null + : (detailContent ?? ( +
+ {t("i18n.selectProviderModel")} +
+ ))} +
+ + )} +
- {/* Right: detail */} -
- {loading ? null : detailContent ?? ( -
- {t("i18n.selectProviderModel")} -
- )} -
-
- - {/* Footer */} -
- {saveError && {saveError}} - - -
-
-
- {pickerOpen && ( - setSelection({ type: "oauth", providerId: id })} - onSelectApiKey={(id) => setSelection({ type: "apikey", providerId: id })} - onAddCustom={addCustomProvider} - onClose={() => setPickerOpen(false)} - /> - )} - - ); + {/* Footer */} +
+ {saveError && ( + + {saveError} + + )} + + +
+
+
+ {pickerOpen && ( + + setSelection({ type: "oauth", providerId: id }) + } + onSelectApiKey={(id) => + setSelection({ type: "apikey", providerId: id }) + } + onAddCustom={addCustomProvider} + onClose={() => setPickerOpen(false)} + /> + )} + + ); } diff --git a/pi-web/components/VisionConfig.tsx b/pi-web/components/VisionConfig.tsx new file mode 100644 index 0000000..be8d4dd --- /dev/null +++ b/pi-web/components/VisionConfig.tsx @@ -0,0 +1,424 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { useIsMobile } from "@/hooks/useIsMobile"; +import { useI18n } from "@/hooks/useI18n"; + +interface VisionConfigFile { + backend?: "ollama" | "openai"; + ollama?: { host?: string; model?: string }; + openai?: { baseUrl?: string; apiKey?: string; model?: string }; +} + +const inputStyle: React.CSSProperties = { + padding: "6px 9px", + background: "var(--bg-panel)", + border: "1px solid var(--border)", + borderRadius: 5, + color: "var(--text)", + fontSize: 12, + outline: "none", + width: "100%", + boxSizing: "border-box", +}; + +function Field({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( +
+ + {children} +
+ ); +} + +function TextInput({ + value, + onChange, + placeholder, + mono, + type, +}: { + value: string; + onChange: (v: string) => void; + placeholder?: string; + mono?: boolean; + type?: string; +}) { + return ( + onChange(e.target.value)} + placeholder={placeholder} + type={type ?? "text"} + style={{ + ...inputStyle, + fontFamily: mono ? "var(--font-mono)" : "inherit", + }} + /> + ); +} + +const saveButtonStyle = (primary: boolean): React.CSSProperties => ({ + padding: "6px 14px", + borderRadius: 6, + fontSize: 12, + fontWeight: 600, + cursor: "pointer", + border: "1px solid var(--border)", + background: primary ? "var(--accent)" : "var(--bg-panel)", + color: primary ? "#fff" : "var(--text)", +}); + +/** + * Vision backend settings (backend picker + fields + save), without modal + * chrome. Embedded in the Models config panel and reused by the standalone + * modal below. + */ +export function VisionConfigContent() { + const { t } = useI18n(); + const [config, setConfig] = useState({}); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + const [savedOk, setSavedOk] = useState(false); + + const load = useCallback(async () => { + setLoading(true); + setSaveError(null); + try { + const res = await fetch("/api/vision-config"); + const data = (await res.json()) as { + config?: VisionConfigFile; + error?: string; + }; + if (!res.ok || data.error) + throw new Error(data.error ?? `HTTP ${res.status}`); + setConfig(data.config ?? {}); + } catch (e) { + setSaveError(e instanceof Error ? e.message : String(e)); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + const save = useCallback(async () => { + setSaving(true); + setSaveError(null); + setSavedOk(false); + try { + const res = await fetch("/api/vision-config", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(config), + }); + const data = (await res.json()) as { success?: boolean; error?: string }; + if (!res.ok || !data.success) + throw new Error(data.error ?? `HTTP ${res.status}`); + setSavedOk(true); + } catch (e) { + setSaveError(e instanceof Error ? e.message : String(e)); + } finally { + setSaving(false); + } + }, [config]); + + const backend = config.backend ?? "ollama"; + const ollama = config.ollama ?? {}; + const openai = config.openai ?? {}; + + return ( +
+ {loading ? ( +
+ {t("vision.loading")} +
+ ) : ( + <> + {/* Backend picker */} +
+ {(["ollama", "openai"] as const).map((b) => ( + + ))} +
+ + {/* Ollama settings */} + {backend === "ollama" && ( +
+ + + setConfig((prev) => ({ + ...prev, + ollama: { ...prev.ollama, host: v }, + })) + } + placeholder={t("vision.ollama.hostPlaceholder")} + mono + /> + + + + setConfig((prev) => ({ + ...prev, + ollama: { ...prev.ollama, model: v }, + })) + } + placeholder={t("vision.ollama.modelPlaceholder")} + mono + /> + +
+ )} + + {/* OpenAI-compatible settings */} + {backend === "openai" && ( +
+ + + setConfig((prev) => ({ + ...prev, + openai: { ...prev.openai, baseUrl: v }, + })) + } + placeholder={t("vision.openai.baseUrlPlaceholder")} + mono + /> + + + + setConfig((prev) => ({ + ...prev, + openai: { ...prev.openai, apiKey: v }, + })) + } + type="password" + mono + /> + + + + setConfig((prev) => ({ + ...prev, + openai: { ...prev.openai, model: v }, + })) + } + placeholder={t("vision.openai.modelPlaceholder")} + mono + /> + +
+ )} + +
+ {t("vision.effective")} +
+ + )} + + {saveError && ( +
+ {t("vision.loadFailed")}: {saveError} +
+ )} + {savedOk && ( +
+ {t("vision.saved")} +
+ )} + +
+ +
+
+ ); +} + +/** Standalone modal wrapper (kept for backward compatibility). */ +export function VisionConfig({ onClose }: { onClose: () => void }) { + const isMobile = useIsMobile(); + const { t } = useI18n(); + const [configPath, setConfigPath] = useState(""); + + useEffect(() => { + fetch("/api/vision-config") + .then((r) => r.json()) + .then((d: { path?: string }) => setConfigPath(d.path ?? "")) + .catch(() => {}); + }, []); + + return ( +
{ + if (e.target === e.currentTarget) onClose(); + }} + > +
+
+
+ + {t("vision.title")} + + + {configPath || "data/agent/vision.json"} + +
+ +
+ +
+
+ ); +} diff --git a/pi-web/lib/api-types.ts b/pi-web/lib/api-types.ts index 0970cf4..361388c 100644 --- a/pi-web/lib/api-types.ts +++ b/pi-web/lib/api-types.ts @@ -103,3 +103,21 @@ export interface PluginsResponse { diagnostics: PluginDiagnostic[]; projectResourcesLoaded: boolean; } + +export type ExtensionScope = "global" | "project" | "builtin"; +export type ExtensionStatus = "enabled" | "disabled" | "blocked"; + +export interface ExtensionInfo { + name: string; + path: string; + relativePath: string; + source: string; + scope: ExtensionScope; + status: ExtensionStatus; +} + +export interface ExtensionsResponse { + extensions: ExtensionInfo[]; + diagnostics: PluginDiagnostic[]; + projectResourcesLoaded: boolean; +} diff --git a/pi-web/lib/i18n/messages/en.ts b/pi-web/lib/i18n/messages/en.ts index 0df965b..acabe87 100644 --- a/pi-web/lib/i18n/messages/en.ts +++ b/pi-web/lib/i18n/messages/en.ts @@ -11,6 +11,8 @@ export const enLocale: LocalePlugin = { "common.mcp": "MCP", "common.skills": "Skills", "common.plugins": "Plugins", + "common.extensions": "Extensions", + "common.vision": "Vision", "sidebar.hide": "Hide sidebar", "sidebar.show": "Show sidebar", "theme.light": "Switch to light mode", @@ -43,7 +45,7 @@ export const enLocale: LocalePlugin = { "session.output": "Output", "session.cacheRead": "Cache Read", "session.cacheWrite": "Cache Write", - "session.cacheHitRate": "Cache Hit Rate", + "session.cacheHitRate": "Cache Hit Rate (Main Session)", "session.cost": "Cost", "session.context": "Context", "session.copy": "Copy {value}", @@ -259,6 +261,25 @@ export const enLocale: LocalePlugin = { "mcp.noServers": "No MCP servers configured", "mcp.selectServer": "Select a server to edit", "mcp.restartHint": "Saved changes apply after restarting pi (or /reload).", + "vision.title": "Vision config", + "vision.loading": "Loading…", + "vision.backend.ollama": "Local Ollama", + "vision.backend.ollamaHint": "Free and private — needs a local Ollama with a vision model pulled", + "vision.backend.openai": "OpenAI-compatible API", + "vision.backend.openaiHint": "Any OpenAI-compatible vision endpoint (cloud or self-hosted)", + "vision.ollama.host": "Ollama address", + "vision.ollama.hostPlaceholder": "e.g. http://localhost:11434", + "vision.ollama.model": "Vision model", + "vision.ollama.modelPlaceholder": "e.g. qwen3-vl:8b", + "vision.openai.baseUrl": "Base URL", + "vision.openai.baseUrlPlaceholder": "e.g. https://api.openai.com/v1", + "vision.openai.apiKey": "API Key", + "vision.openai.model": "Model", + "vision.openai.modelPlaceholder": "e.g. gpt-4o-mini / glm-4.5v", + "vision.effective": "Saved config takes effect on the next image request — no restart needed. Uploaded images are auto-transcribed into text for text-only main models (e.g. DeepSeek).", + "vision.loadFailed": "Failed to load / save config", + "vision.save": "Save", + "vision.saved": "Saved — takes effect on the next image request", "i18n.close": "Close", "i18n.copy": "Copy", "i18n.copied": "Copied", @@ -389,6 +410,19 @@ export const enLocale: LocalePlugin = { "i18n.configuredVersion": "configured {version}", "i18n.extensions": "Extensions", "i18n.prompts": "Prompts", + "extensions.app": "App", + "extensions.project": "Project", + "extensions.builtin": "Built-in", + "extensions.enabled": "enabled", + "extensions.disabled": "disabled", + "extensions.blocked": "blocked", + "extensions.total": "extensions", + "extensions.status": "Status", + "extensions.source": "Source", + "extensions.path": "Path", + "extensions.noExtensions": "No direct extensions found", + "extensions.projectResourcesBlocked": "Project extensions are blocked until this project is trusted.", + "extensions.diagnostics": "diagnostics", "i18n.themes": "Themes", "i18n.resourceCount": "{count} {label}", "i18n.extensionShort": "ext", diff --git a/pi-web/lib/i18n/messages/zh-CN.ts b/pi-web/lib/i18n/messages/zh-CN.ts index 61d1fe8..ff131c2 100644 --- a/pi-web/lib/i18n/messages/zh-CN.ts +++ b/pi-web/lib/i18n/messages/zh-CN.ts @@ -11,6 +11,8 @@ export const zhCNLocale: LocalePlugin = { "common.mcp": "MCP", "common.skills": "技能", "common.plugins": "插件", + "common.extensions": "扩展", + "common.vision": "视觉", "sidebar.hide": "隐藏侧边栏", "sidebar.show": "显示侧边栏", "theme.light": "切换到浅色模式", @@ -43,7 +45,7 @@ export const zhCNLocale: LocalePlugin = { "session.output": "输出", "session.cacheRead": "缓存读取", "session.cacheWrite": "缓存写入", - "session.cacheHitRate": "缓存命中率", + "session.cacheHitRate": "缓存命中率(主会话)", "session.cost": "费用", "session.context": "上下文", "session.copy": "复制{value}", @@ -259,6 +261,25 @@ export const zhCNLocale: LocalePlugin = { "mcp.noServers": "未配置 MCP 服务器", "mcp.selectServer": "选择一个服务器进行编辑", "mcp.restartHint": "保存的更改在重启 pi(或 /reload)后生效。", + "vision.title": "视觉配置", + "vision.loading": "加载中…", + "vision.backend.ollama": "本地 Ollama", + "vision.backend.ollamaHint": "免费、私密——本机 Ollama 需已拉取视觉模型", + "vision.backend.openai": "OpenAI 兼容 API", + "vision.backend.openaiHint": "任意 OpenAI 兼容的视觉端点(云端或自托管)", + "vision.ollama.host": "Ollama 地址", + "vision.ollama.hostPlaceholder": "如 http://localhost:11434", + "vision.ollama.model": "视觉模型", + "vision.ollama.modelPlaceholder": "如 qwen3-vl:8b", + "vision.openai.baseUrl": "接口地址", + "vision.openai.baseUrlPlaceholder": "如 https://api.openai.com/v1", + "vision.openai.apiKey": "API 密钥", + "vision.openai.model": "模型", + "vision.openai.modelPlaceholder": "如 gpt-4o-mini / glm-4.5v", + "vision.effective": "保存后下次识图请求立即生效,无需重启。上传图片会自动转录为文本,供纯文本主模型(如 DeepSeek)使用。", + "vision.loadFailed": "配置加载/保存失败", + "vision.save": "保存", + "vision.saved": "已保存——下次识图请求生效", "i18n.close": "关闭", "i18n.copy": "复制", "i18n.copied": "已复制", @@ -389,6 +410,19 @@ export const zhCNLocale: LocalePlugin = { "i18n.configuredVersion": "已配置 {version}", "i18n.extensions": "扩展", "i18n.prompts": "提示词", + "extensions.app": "应用", + "extensions.project": "项目", + "extensions.builtin": "内置", + "extensions.enabled": "已启用", + "extensions.disabled": "已禁用", + "extensions.blocked": "已阻止", + "extensions.total": "个扩展", + "extensions.status": "状态", + "extensions.source": "来源", + "extensions.path": "路径", + "extensions.noExtensions": "未找到直接扩展", + "extensions.projectResourcesBlocked": "项目尚未受信任,项目扩展已被阻止。", + "extensions.diagnostics": "条诊断信息", "i18n.themes": "主题", "i18n.resourceCount": "{count}{label}", "i18n.extensionShort": "扩展", diff --git a/resources/extensions/package.json b/resources/extensions/package.json index 6fe9941..dbfccfb 100644 --- a/resources/extensions/package.json +++ b/resources/extensions/package.json @@ -3,7 +3,8 @@ "private": true, "pi": { "extensions": [ - "./searxng-search.ts" + "./searxng-search.ts", + "./vision.ts" ] } } diff --git a/resources/extensions/vision.ts b/resources/extensions/vision.ts new file mode 100644 index 0000000..008b444 --- /dev/null +++ b/resources/extensions/vision.ts @@ -0,0 +1,539 @@ +/** + * Vision extension: describe images for text-only main models (e.g. DeepSeek). + * + * Two interchangeable backends, selected by VISION_BACKEND (default "ollama"): + * - "ollama": local Ollama vision model via the native /api/chat endpoint. + * OLLAMA_HOST (default http://localhost:11434) + * OLLAMA_VISION_MODEL (default qwen3-vl:8b) + * - "openai": any OpenAI-compatible vision API (cloud or self-hosted). + * VISION_OPENAI_BASE_URL e.g. https://api.openai.com/v1 + * VISION_OPENAI_API_KEY + * VISION_OPENAI_MODEL e.g. gpt-4o-mini, glm-4.5v, qwen-vl-max + * + * Why not Ollama's OpenAI-compatible /v1 endpoint: it moves qwen3-family + * reasoning output into the `reasoning` field with an empty `content`, which Pi + * treats as an empty reply. The native /api/chat with `think: false` returns a + * normal textual answer, so the Ollama backend deliberately bypasses /v1. + */ + +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { createHash } from "node:crypto"; +import { readFile, stat } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { Type } from "typebox"; + +const REQUEST_TIMEOUT_MS = 180_000; +const MAX_IMAGE_BYTES = 7 * 1024 * 1024; // Ollama's built-in per-image limit +const DEFAULT_MAX_TOKENS = 4096; // generous: thinking-based APIs spend budget on reasoning first + +const DEFAULT_PROMPT = [ + "请详细描述这张图片,输出要求:", + "1. 完整 OCR:按阅读顺序逐字转录所有可见文字(含 UI 标签、按钮、错误信息、代码、数字、日期),保留版式线索。", + "2. 版式与视觉结构:区域、颜色、形状;UI 截图、图表(坐标轴/数值/趋势)、表格用 markdown 重建、流程图(节点/连线/方向)。", + "3. 语义总结:2-3 句概括图片内容与关键信息。", + "4. 模糊/截断/有歧义处明确说明,不要猜测。", + "5. 多张图片时,分别描述每张,用【图片1】【图片2】…标注。", + "主模型看不到图,完全依赖你的转录,文字务必穷尽。", +].join("\n"); + +const visionParams = Type.Object({ + image_paths: Type.Array( + Type.String({ + description: "图片文件路径(绝对或相对路径),至少一个", + minLength: 1, + }), + { minItems: 1, maxItems: 8 }, + ), + backend: Type.Optional( + Type.Union([Type.Literal("ollama"), Type.Literal("openai")], { + description: + "视觉后端:ollama(本地,默认)或 openai(OpenAI 兼容 API)。默认取 VISION_BACKEND 环境变量", + }), + ), + model: Type.Optional( + Type.String({ + description: + "视觉模型 tag/ID,默认取后端对应环境变量(OLLAMA_VISION_MODEL 或 VISION_OPENAI_MODEL)", + }), + ), + prompt: Type.Optional( + Type.String({ + description: `自定义识图指令,默认:${DEFAULT_PROMPT.split("\n")[0]}`, + }), + ), +}); + +function envOr(name: string, fallback: string): string { + const value = process.env[name]?.trim(); + return value && value.length > 0 ? value : fallback; +} + +function mimeFromPath(filePath: string): string { + const ext = filePath.split(".").pop()?.toLowerCase() ?? ""; + switch (ext) { + case "png": + return "image/png"; + case "jpg": + case "jpeg": + return "image/jpeg"; + case "webp": + return "image/webp"; + case "gif": + return "image/gif"; + case "bmp": + return "image/bmp"; + default: + return "application/octet-stream"; + } +} + +interface LoadedImage { + base64: string; + mime: string; +} + +async function loadImages(imagePaths: string[]): Promise { + const images: LoadedImage[] = []; + for (const filePath of imagePaths) { + const fileStat = await stat(filePath).catch(() => null); + if (!fileStat) throw new Error(`vision: file not found: ${filePath}`); + if (!fileStat.isFile()) + throw new Error(`vision: not a regular file: ${filePath}`); + if (fileStat.size === 0) throw new Error(`vision: empty file: ${filePath}`); + if (fileStat.size > MAX_IMAGE_BYTES) { + throw new Error( + `vision: ${filePath} is ${fileStat.size} bytes, exceeding the ${MAX_IMAGE_BYTES} byte limit. Resize or compress the image first.`, + ); + } + const buffer = await readFile(filePath); + images.push({ + base64: buffer.toString("base64"), + mime: mimeFromPath(filePath), + }); + } + return images; +} + +function requestSignal(signal: AbortSignal | undefined): AbortSignal { + const timeoutSignal = AbortSignal.timeout(REQUEST_TIMEOUT_MS); + return signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal; +} + +async function describeWithOllama( + baseUrl: string, + model: string, + prompt: string, + images: LoadedImage[], + signal: AbortSignal | undefined, +): Promise { + // Ollama defaults to a small num_ctx (4096 on this setup); multi-image + // requests blow past it. Raise explicitly — the model supports 262k. + const numCtx = parseInt(envOr("OLLAMA_NUM_CTX", "16384"), 10) || 16384; + const body = { + model, + messages: [ + { + role: "user", + content: prompt, + images: images.map((img) => img.base64), + }, + ], + stream: false, + think: false, + options: { temperature: 0, num_ctx: numCtx }, + }; + + let response: Response; + try { + response = await fetch(`${baseUrl.replace(/\/+$/, "")}/api/chat`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal: requestSignal(signal), + }); + } catch (error) { + const cause = error instanceof Error ? error.message : String(error); + throw new Error( + `vision: cannot reach Ollama at ${baseUrl} (${cause}). Is Ollama running?`, + ); + } + + if (!response.ok) { + const detail = (await response.text().catch(() => "")).slice(0, 500); + throw new Error( + `vision: Ollama returned HTTP ${response.status}${detail ? `: ${detail}` : ""}`, + ); + } + + const data = (await response.json()) as { message?: { content?: string } }; + const content = data.message?.content?.trim(); + if (!content) { + throw new Error( + `vision: Ollama model ${model} returned an empty response. ` + + "Check `ollama pull qwen3-vl:8b` and that the model supports vision.", + ); + } + return content; +} + +async function describeWithOpenAI( + baseUrl: string, + apiKey: string, + model: string, + prompt: string, + images: LoadedImage[], + signal: AbortSignal | undefined, +): Promise { + const body = { + model, + messages: [ + { + role: "user", + content: [ + { type: "text", text: prompt }, + ...images.map((img) => ({ + type: "image_url", + image_url: { url: `data:${img.mime};base64,${img.base64}` }, + })), + ], + }, + ], + max_tokens: DEFAULT_MAX_TOKENS, + temperature: 0, + }; + + let response: Response; + try { + response = await fetch(`${baseUrl.replace(/\/+$/, "")}/chat/completions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify(body), + signal: requestSignal(signal), + }); + } catch (error) { + const cause = error instanceof Error ? error.message : String(error); + throw new Error(`vision: cannot reach ${baseUrl} (${cause}).`); + } + + if (!response.ok) { + const detail = (await response.text().catch(() => "")).slice(0, 500); + throw new Error( + `vision: API returned HTTP ${response.status}${detail ? `: ${detail}` : ""}. ` + + "Check VISION_OPENAI_BASE_URL / VISION_OPENAI_API_KEY / VISION_OPENAI_MODEL.", + ); + } + + const data = (await response.json()) as { + choices?: Array<{ + message?: { + content?: string; + reasoning_content?: string; + reasoning?: string; + }; + finish_reason?: string; + }>; + }; + const choice = data.choices?.[0]; + const content = choice?.message?.content?.trim(); + if (content) return content; + + const reasoning = + choice?.message?.reasoning_content || choice?.message?.reasoning; + const finish = choice?.finish_reason ?? "unknown"; + if (reasoning) { + throw new Error( + `vision: model ${model} returned only reasoning (finish=${finish}). ` + + "If it is a thinking model, pick a non-thinking vision model or raise max_tokens.", + ); + } + throw new Error( + `vision: model ${model} returned an empty response (finish=${finish}).`, + ); +} + +interface DescribeOptions { + backend?: string; + model?: string; + prompt?: string; + signal?: AbortSignal; +} + +/** + * Optional file-based configuration, edited from the Web UI vision panel + * (writes `$PI_CODING_AGENT_DIR/vision.json`). Environment variables and + * per-call parameters take precedence over this file; every read is fresh, so + * saving the panel takes effect on the next image request without a restart. + */ +interface VisionFileConfig { + backend?: "ollama" | "openai"; + ollama?: { host?: string; model?: string }; + openai?: { baseUrl?: string; apiKey?: string; model?: string }; +} + +const VISION_CONFIG_DEFAULTS: VisionFileConfig = { + backend: "ollama", + ollama: { host: "http://localhost:11434", model: "qwen3-vl:8b" }, + openai: { baseUrl: "", apiKey: "", model: "" }, +}; + +function visionConfigPath(): string { + const agentDir = process.env.PI_CODING_AGENT_DIR?.trim(); + return ( + (agentDir && agentDir.length > 0 + ? agentDir + : join(homedir(), ".pi", "agent")) + "/vision.json" + ); +} + +async function loadVisionFileConfig(): Promise { + try { + const raw = await readFile(visionConfigPath(), "utf8"); + const parsed = JSON.parse(raw) as VisionFileConfig; + return parsed && typeof parsed === "object" ? parsed : {}; + } catch { + return {}; + } +} + +async function describeImages( + images: LoadedImage[], + options: DescribeOptions, +): Promise { + const fileConfig = await loadVisionFileConfig(); + const backend = + options.backend ?? + envOr( + "VISION_BACKEND", + fileConfig.backend ?? VISION_CONFIG_DEFAULTS.backend ?? "ollama", + ); + const prompt = options.prompt ?? DEFAULT_PROMPT; + if (backend === "ollama") { + const baseUrl = envOr( + "OLLAMA_HOST", + fileConfig.ollama?.host ?? + VISION_CONFIG_DEFAULTS.ollama?.host ?? + "http://localhost:11434", + ); + const model = + options.model ?? + envOr( + "OLLAMA_VISION_MODEL", + fileConfig.ollama?.model ?? + VISION_CONFIG_DEFAULTS.ollama?.model ?? + "qwen3-vl:8b", + ); + return describeWithOllama(baseUrl, model, prompt, images, options.signal); + } + const baseUrl = envOr( + "VISION_OPENAI_BASE_URL", + fileConfig.openai?.baseUrl ?? "", + ); + const apiKey = envOr( + "VISION_OPENAI_API_KEY", + fileConfig.openai?.apiKey ?? "", + ); + const model = + options.model ?? + envOr("VISION_OPENAI_MODEL", fileConfig.openai?.model ?? ""); + if (!baseUrl || !apiKey || !model) { + throw new Error( + "vision: the openai backend needs a base URL, API key and model. " + + "Configure them in the Web UI vision panel (lower-left) or via " + + "VISION_OPENAI_BASE_URL / VISION_OPENAI_API_KEY / VISION_OPENAI_MODEL. " + + "Example: https://api.openai.com/v1 + gpt-4o-mini.", + ); + } + return describeWithOpenAI( + baseUrl, + apiKey, + model, + prompt, + images, + options.signal, + ); +} + +/** + * Session-scoped description cache: compaction, session restore, or repeated + * turns replay the same image parts; avoid re-running the vision model each time. + */ +const IMAGE_DESCRIPTION_CACHE = new Map(); + +function dataUrlToLoadedImage(url: string): LoadedImage | null { + const match = /^data:(image\/[a-z0-9.+-]+);base64,(.+)$/i.exec(url); + if (!match) return null; + return { base64: match[2], mime: match[1] }; +} + +function imageCacheKey(image: LoadedImage): string { + // Whole-image hash: PNG/JPG headers repeat for same dimensions, so a short + // prefix would collide across different images of the same size. + return createHash("md5").update(image.base64).digest("hex"); +} + +/** + * Per-image description cache. Every request rebuilds the payload from the + * session file, which keeps the original image parts, so without this cache + * the whole conversation's images would be re-transcribed every turn and + * eventually exceed Ollama's context window. Single-image granularity means + * only genuinely new images hit the vision model. + */ +async function getImageDescription( + image: LoadedImage, + prompt: string, + signal: AbortSignal | undefined, +): Promise { + const key = imageCacheKey(image); + const cached = IMAGE_DESCRIPTION_CACHE.get(key); + if (cached) return cached; + const description = await describeImages([image], { prompt, signal }); + IMAGE_DESCRIPTION_CACHE.set(key, description); + if (IMAGE_DESCRIPTION_CACHE.size > 64) { + const oldest = IMAGE_DESCRIPTION_CACHE.keys().next().value; + if (oldest !== undefined) IMAGE_DESCRIPTION_CACHE.delete(oldest); + } + return description; +} + +function isTextOnlyModel(model: unknown): boolean { + const input = (model as { input?: string[] } | undefined)?.input; + return Array.isArray(input) && !input.includes("image"); +} + +export default function visionExtension(pi: ExtensionAPI) { + // Keep user-message images intact for text-only models so the hook below can + // transcribe them (pi-ai would otherwise replace them with a text placeholder + // before before_provider_request runs). The matching pi-ai patch is applied + // and replayed by scripts/configure-pi-ai-vision.mjs. + process.env.PI_VISION_PASSTHROUGH_IMAGES = "1"; + pi.registerTool({ + name: "vision", + label: "Vision (image description)", + description: + "用视觉模型描述本地图片并返回详细文本(完整 OCR、版式结构、语义总结)。" + + "适用于主模型不支持图片输入(如 DeepSeek)时查看截图/图表/文档/照片。" + + "后端可配置:ollama(默认,本地 qwen3-vl:8b,免费私密)或 openai(任意 OpenAI 兼容视觉 API," + + "设 VISION_BACKEND=openai + VISION_OPENAI_BASE_URL/API_KEY/MODEL)。", + promptSnippet: + "Describe local images using a vision model (Ollama or OpenAI-compatible)", + promptGuidelines: [ + "Use vision when the user asks you to look at an image (screenshot, diagram, chart, document, photo) and the current model cannot receive image attachments.", + "Pass image file paths that exist on disk; the tool reads and encodes them itself.", + "One call can describe up to 8 images; prefer batching related images into a single call.", + "The returned text is the vision model's transcription — relay it faithfully, quoting OCR text verbatim.", + "If the call fails because no vision backend is configured, report the missing environment variables.", + ], + parameters: visionParams, + executionMode: "sequential", + async execute(_toolCallId, params, signal) { + const prompt = params.prompt ?? DEFAULT_PROMPT; + const images = await loadImages(params.image_paths); + const content = await describeImages(images, { + backend: params.backend, + model: params.model, + prompt, + signal, + }); + + return { + content: [{ type: "text", text: content }], + details: { + backend: params.backend ?? envOr("VISION_BACKEND", "ollama"), + model: params.model ?? undefined, + imageCount: images.length, + }, + }; + }, + }); + + // --- Automatic image transcription for text-only main models --- + // Web UI image uploads arrive as base64 image_url parts in the user message. + // A text-only model (e.g. DeepSeek) cannot receive them — DeepSeek rejects + // the request with HTTP 400. This hook transcribes the images through the + // configured vision backend and replaces them with text before the request + // is sent, so the main model keeps working seamlessly. + pi.on("before_provider_request", async (event, ctx) => { + const payload = event.payload as + | { messages?: Array<{ role?: string; content?: unknown }> } + | undefined; + const messages = payload?.messages; + if (!Array.isArray(messages) || messages.length === 0) return; + if (!isTextOnlyModel(ctx.model)) return; // vision-capable models pass through untouched + + // Collect images and replace each with a numbered text placeholder. + const images: LoadedImage[] = []; + let dirty = false; + for (const msg of messages) { + if (msg?.role !== "user" || !Array.isArray(msg.content)) continue; + const newContent: unknown[] = []; + for (const part of msg.content) { + const p = part as { type?: string; image_url?: { url?: string } }; + if (p?.type === "image_url" && typeof p.image_url?.url === "string") { + const img = dataUrlToLoadedImage(p.image_url.url); + if (img) { + images.push(img); + newContent.push({ type: "text", text: `[图片 ${images.length}]` }); + dirty = true; + } else { + newContent.push(part); + } + } else { + newContent.push(part); + } + } + msg.content = newContent; + } + if (!dirty || images.length === 0) return; + + // Transcribe per image: only genuinely new images hit the vision model + // (history resolves from cache), so one request never re-batches the + // whole conversation's images and exceeds Ollama's context window. + const transcribed: string[] = []; + for (let i = 0; i < images.length; i++) { + try { + const desc = await getImageDescription( + images[i], + DEFAULT_PROMPT, + ctx.signal, + ); + transcribed.push(`【图片${i + 1}】\n${desc}`); + } catch (error) { + const cause = error instanceof Error ? error.message : String(error); + transcribed.push(`【图片${i + 1}】\n[图片处理失败:${cause}]`); + } + } + const description = transcribed.join("\n\n"); + + // Place the full transcription on the LAST image-carrying message (the one + // the model is actively processing); earlier image messages reference it. + // Putting it on the first message instead hid it in history. + const targets: Array<{ part: { type?: string; text?: string } }> = []; + for (const msg of messages) { + if (msg?.role !== "user" || !Array.isArray(msg.content)) continue; + for (const part of msg.content) { + const p = part as { type?: string; text?: string }; + if ( + p?.type === "text" && + typeof p.text === "string" && + p.text.startsWith("[图片 ") + ) { + targets.push({ part: p }); + } + } + } + const lastTarget = targets[targets.length - 1]; + if (lastTarget) { + lastTarget.part.text = `[用户上传了 ${images.length} 张图片,以下为视觉模型转录的文本描述]\n\n${description}`; + } + for (const { part } of targets) { + if (part !== lastTarget?.part) { + part.text = "(图片描述见最新消息中的综合转录)"; + } + } + return payload; + }); +} diff --git a/scripts/configure-pi-ai-vision.mjs b/scripts/configure-pi-ai-vision.mjs new file mode 100644 index 0000000..680cd77 --- /dev/null +++ b/scripts/configure-pi-ai-vision.mjs @@ -0,0 +1,122 @@ +/** + * pi-ai vision passthrough patch. + * + * Pi-ai's `transform-messages.js` replaces image parts with a text placeholder + * ("(image omitted: model does not support images)") for text-only models BEFORE + * the before_provider_request hook runs, so the vision extension's automatic + * transcription hook can never see the image. This patch adds an opt-in switch: + * when PI_VISION_PASSTHROUGH_IMAGES=1 (set by the vision extension at load), + * user-message image parts are kept intact so the vision hook can transcribe + * them; without the env var the original placeholder behavior is preserved + * (safe for users without the vision extension). + * + * Idempotent, replayed by run.mjs and install-managed-packages.mjs, mirroring + * configure-pi-memory.mjs. + */ + +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const supportedVersion = "0.83.0"; + +function replaceOnce(source, before, after, label) { + if (source.includes(after)) return source; + + const first = source.indexOf(before); + const last = source.lastIndexOf(before); + if (first < 0 || first !== last) { + throw new Error(`Cannot apply pi-ai vision patch: ${label}`); + } + return source.slice(0, first) + after + source.slice(first + before.length); +} + +export function patchPiAiSource(input) { + let source = input.replaceAll("\r\n", "\n"); + + source = replaceOnce( + source, + ` return messages.map((msg) => { + if (msg.role === "user" && Array.isArray(msg.content)) { + return { + ...msg, + content: replaceImagesWithPlaceholder(msg.content, NON_VISION_USER_IMAGE_PLACEHOLDER), + }; + }`, + ` const keepUserImages = + typeof process !== "undefined" && process.env?.PI_VISION_PASSTHROUGH_IMAGES === "1"; + return messages.map((msg) => { + if (msg.role === "user" && Array.isArray(msg.content) && !keepUserImages) { + return { + ...msg, + content: replaceImagesWithPlaceholder(msg.content, NON_VISION_USER_IMAGE_PLACEHOLDER), + }; + }`, + "downgradeUnsupportedImages user-image passthrough switch", + ); + + return source; +} + +export function isPiAiVisionConfigured(source) { + return source.includes("PI_VISION_PASSTHROUGH_IMAGES === \"1\""); +} + +export function defaultPiAiDir() { + // Managed installs place packages under /npm/node_modules; pi-ai is + // also a direct dependency of pi-web, which wins in dev. Prefer pi-web's copy. + const candidates = [ + resolve(process.cwd(), "pi-web", "node_modules", "@earendil-works", "pi-ai"), + resolve(process.cwd(), "node_modules", "@earendil-works", "pi-ai"), + ]; + for (const candidate of candidates) { + if (existsSync(join(candidate, "package.json"))) return candidate; + } + throw new Error("pi-ai package not found under pi-web/node_modules or node_modules"); +} + +export function configurePiAiVision({ piAiDir = defaultPiAiDir(), quiet = false } = {}) { + const packageJsonPath = join(piAiDir, "package.json"); + const sourcePath = join(piAiDir, "dist", "api", "transform-messages.js"); + + if (!existsSync(packageJsonPath) || !existsSync(sourcePath)) { + return { status: "missing", sourcePath }; + } + + let packageJson; + try { + packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")); + } catch (error) { + throw new Error(`Cannot parse ${packageJsonPath}: ${error.message}`); + } + if (packageJson.version !== supportedVersion) { + throw new Error( + `Unsupported pi-ai version ${packageJson.version ?? "unknown"}; expected ${supportedVersion}.`, + ); + } + + const source = readFileSync(sourcePath, "utf8"); + const patched = patchPiAiSource(source); + if (patched === source) { + if (!quiet) console.log("pi-ai vision passthrough ready."); + return { status: "ready", sourcePath }; + } + + writeFileSync(sourcePath, patched, "utf8"); + if (!quiet) console.log("Configured pi-ai vision passthrough."); + return { status: "patched", sourcePath }; +} + +const invokedPath = process.argv[1] ? resolve(process.argv[1]) : ""; +if (invokedPath === fileURLToPath(import.meta.url)) { + try { + const result = configurePiAiVision(); + if (result.status === "missing") { + console.error("pi-ai is not installed. Run npm run setup first."); + process.exit(1); + } + } catch (error) { + console.error(error.message); + process.exit(1); + } +} diff --git a/scripts/install-managed-packages.mjs b/scripts/install-managed-packages.mjs index 858924d..e6b04ee 100644 --- a/scripts/install-managed-packages.mjs +++ b/scripts/install-managed-packages.mjs @@ -2,6 +2,7 @@ import { spawnSync } from "node:child_process"; import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { configurePiMemory } from "./configure-pi-memory.mjs"; +import { configurePiAiVision } from "./configure-pi-ai-vision.mjs"; import { agentDir, managedEnvironment, rootDir } from "./profile.mjs"; const settings = JSON.parse( @@ -49,3 +50,8 @@ for (const source of packages) { } configurePiMemory({ agentDir }); +try { + configurePiAiVision({ quiet: false }); +} catch (error) { + console.warn(`[vision] pi-ai passthrough patch skipped: ${error.message}`); +} diff --git a/scripts/run.mjs b/scripts/run.mjs index 3b4a00b..b5f4c0b 100644 --- a/scripts/run.mjs +++ b/scripts/run.mjs @@ -1,6 +1,7 @@ import { spawn } from "node:child_process"; import { join } from "node:path"; import { configurePiMemory } from "./configure-pi-memory.mjs"; +import { configurePiAiVision } from "./configure-pi-ai-vision.mjs"; import { managedEnvironment, rootDir } from "./profile.mjs"; import { isLocalPortListening, @@ -26,6 +27,11 @@ const memoryConfiguration = configurePiMemory({ quiet: true }); if (memoryConfiguration.status === "missing") { console.warn("[memory] pi-memory is not installed; run npm run setup to enable managed memory"); } +try { + configurePiAiVision({ quiet: true }); +} catch (error) { + console.warn(`[vision] pi-ai passthrough patch skipped: ${error.message}`); +} if (await isLocalPortListening(30141)) { console.log("[storage] skipped automatic maintenance because Pi Web is already running"); } else {