From 836042b97cbad23a40a15b9933be58e39416e485 Mon Sep 17 00:00:00 2001 From: luckyyzh Date: Wed, 5 Aug 2026 03:24:04 +0800 Subject: [PATCH] feat: add codex fast mode and harden subagent launch --- README.md | 16 + data/local-tools/pi-agent-web-supervisor.ps1 | 33 +- pi-web/app/api/models/route.ts | 235 +- pi-web/components/ChatInput.tsx | 5644 ++++++++++------- pi-web/hooks/useAgentSession.ts | 3697 ++++++----- pi-web/lib/models-cache.ts | 126 +- pi/packages/ai/src/api/simple-options.ts | 1 + pi/packages/ai/src/types.ts | 19 + .../coding-agent/src/core/model-config.ts | 9 + .../src/core/provider-composer.ts | 2 + pi/packages/coding-agent/src/core/sdk.ts | 5 + scripts/profile.mjs | 56 +- 12 files changed, 5950 insertions(+), 3893 deletions(-) diff --git a/README.md b/README.md index 88e3210..7bd1e88 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,12 @@ npm run dev 所有设备相关模型配置都位于被 Git 忽略的 `data/` 或 `.env` 中。 +#### GPT Codex 快速模式 + +选择 `openai-codex` 的 GPT-5.5 或 GPT-5.6 模型后,输入框底部会显示闪电按钮。开启后按钮显示 `Fast mode ON`,并将该模型的 `serviceTier` 设为 `priority`,请求会使用 Codex 的快速档位;关闭后恢复普通档位。该设置写入 `data/agent/models.json`,下次请求立即生效,无需重启;非 GPT Codex 模型不会显示此按钮。 + +快速档位会增加费用:GPT-5.6 通常约为 2 倍,GPT-5.5 的 priority 费率约为 2.5 倍。Web UI 会在按钮提示中明确显示 `service_tier: priority` 和费用倍率。 + ### 可选环境变量 需要可选服务时,复制模板: @@ -147,6 +153,8 @@ data/workspaces/default/ 默认工作目录 Windows 的 Playwright 不下载独立 Chromium;首次 `setup` 只缓存 MCP 的 Node.js 包,浏览器执行使用系统 Edge。macOS 的 setup 不安装或启用 Playwright;如需浏览器自动化,可在 Web UI 的 MCP 面板中手动添加并配置。 +Windows 下的 `pi-subagents` 子进程由受管启动器自动处理:启动时将本地 `pi-web/node_modules/.bin` 加入子进程 PATH,并把 Pi Web 使用的 `pi-coding-agent` 包链接到受管 Profile,使子代理直接解析本地 `dist/cli.js`。这不会修改系统级 PATH,每次项目启动时会自动恢复。 + #### 视觉子代理(vision) DeepSeek 等纯文本模型不能接收图片。仓库内置 `vision` 子代理(`.agents/vision.md`):它通过 `vision` 工具调用视觉模型读取图片,把完整 OCR、版式结构与语义描述返回给主模型,主模型基于文本继续推理。视觉后端可插拔(本地 Ollama 或任意 OpenAI 兼容视觉 API)——仓库**不预设默认后端**,首次使用前需自行选择并配置。 @@ -326,6 +334,12 @@ The repository contains no author model endpoint, API key, OAuth token, or defau Device-specific model configuration remains in ignored `data/` or `.env` files. +#### GPT Codex fast mode + +When an `openai-codex` GPT-5.5 or GPT-5.6 model is selected, the lightning button appears beside the model selector. Enabling it shows `Fast mode ON` and sets that model's `serviceTier` to `priority`, using Codex's faster service tier; disabling it restores the default tier. The setting is written to `data/agent/models.json` and takes effect on the next request without a restart. Non-Codex models do not show the button. + +Fast mode costs more: GPT-5.6 is typically about 2x, while GPT-5.5 priority pricing is about 2.5x. The Web UI tooltip displays `service_tier: priority` and the applicable credit multiplier. + ### Optional environment configuration macOS/Linux: @@ -391,6 +405,8 @@ Versions are pinned in the platform defaults under `config/`: Windows uses `mcp. 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. +On Windows, the managed launcher prepares `pi-subagents` child processes automatically: it prepends the local `pi-web/node_modules/.bin` directory to the child PATH and links the Pi Web `pi-coding-agent` package into the managed profile, allowing subagents to resolve the local `dist/cli.js` directly. This does not modify the system-wide PATH and is recreated on each project launch. + #### 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 or any OpenAI-compatible vision API) — the repository does **not** ship a default backend; pick and configure one before first use. diff --git a/data/local-tools/pi-agent-web-supervisor.ps1 b/data/local-tools/pi-agent-web-supervisor.ps1 index 6a8b133..6c27230 100644 --- a/data/local-tools/pi-agent-web-supervisor.ps1 +++ b/data/local-tools/pi-agent-web-supervisor.ps1 @@ -10,15 +10,16 @@ $supervisorLog = Join-Path $logsDirectory 'pi-web-supervisor.log' $restartRequestPath = Join-Path $projectRoot 'data\agent\restart-request.json' $webUrl = 'http://127.0.0.1:30141/' $restartRequestVersion = 1 +$maxResumeAttempts = 3 New-Item -ItemType Directory -Path $logsDirectory -Force | Out-Null New-Item -ItemType Directory -Path (Split-Path -Parent $restartRequestPath) -Force | Out-Null -Set-Content -LiteralPath $supervisorLog -Value "$(Get-Date -Format o) supervisor started" +Set-Content -LiteralPath $supervisorLog -Value "$(Get-Date -Format o) supervisor started" -Encoding UTF8 function Write-SupervisorLog { param([string]$Message) - Add-Content -LiteralPath $supervisorLog -Value "$(Get-Date -Format o) $Message" + Add-Content -LiteralPath $supervisorLog -Value "$(Get-Date -Format o) $Message" -Encoding UTF8 } function Get-RestartRequest { @@ -72,6 +73,20 @@ function Remove-RestartRequest { Remove-Item -LiteralPath $restartRequestPath -Force -ErrorAction SilentlyContinue } +function Archive-RestartRequest { + param([object]$Request) + + if (-not (Test-Path -LiteralPath $restartRequestPath -PathType Leaf)) { + return + } + + $requestId = (Get-RequestField $Request 'requestId') -replace '[^A-Za-z0-9-]', '_' + $timestamp = Get-Date -Format 'yyyyMMdd-HHmmss' + $archivePath = Join-Path $logsDirectory "failed-restart-request-$timestamp-$requestId.json" + Move-Item -LiteralPath $restartRequestPath -Destination $archivePath -Force + Write-SupervisorLog "archived failed restart request at $archivePath" +} + function Stop-ProcessTree { param([System.Diagnostics.Process]$Process) @@ -149,6 +164,7 @@ $testInstructions } $pendingRequest = Get-RestartRequest +$resumeAttemptCount = 0 while ($true) { $process = $null @@ -176,10 +192,20 @@ while ($true) { try { Resume-AgentSession $pendingRequest $pendingRequest = $null + $resumeAttemptCount = 0 } catch { + $resumeAttemptCount += 1 Write-SupervisorLog "Agent resume failed: $($_.Exception.Message)" - $nextResumeAttempt = (Get-Date).AddSeconds(3) + if ($resumeAttemptCount -ge $maxResumeAttempts) { + Write-SupervisorLog "Agent resume abandoned after $resumeAttemptCount attempts" + Archive-RestartRequest $pendingRequest + $pendingRequest = $null + $resumeAttemptCount = 0 + } + else { + $nextResumeAttempt = (Get-Date).AddSeconds(3) + } } } } @@ -188,6 +214,7 @@ while ($true) { $candidate = Get-RestartRequest if ($candidate) { $pendingRequest = $candidate + $resumeAttemptCount = 0 Write-SupervisorLog "restart request $($candidate.requestId) detected" Stop-ProcessTree $process break diff --git a/pi-web/app/api/models/route.ts b/pi-web/app/api/models/route.ts index fdb059b..46ad72d 100644 --- a/pi-web/app/api/models/route.ts +++ b/pi-web/app/api/models/route.ts @@ -1,123 +1,180 @@ import { stat } from "fs/promises"; import { resolve } from "path"; -import { createAgentSessionServices, getAgentDir, type SettingsManager } from "@earendil-works/pi-coding-agent"; +import { + createAgentSessionServices, + getAgentDir, + type SettingsManager, +} from "@earendil-works/pi-coding-agent"; import { getSupportedThinkingLevels } from "@earendil-works/pi-ai"; -import { loadModelsWithCache, withModelRuntimeError, type ModelsData } from "@/lib/models-cache"; -import { getAllowedFileRoots, isExistingFilePathAllowed } from "@/lib/file-access"; +import { + loadModelsWithCache, + withModelRuntimeError, + type ModelsData, +} from "@/lib/models-cache"; +import { + getAllowedFileRoots, + isExistingFilePathAllowed, +} from "@/lib/file-access"; import { projectTrustReloadOptions } from "@/lib/project-trust"; -import { createAppSettingsManager, getAppResourceLoaderOptions } from "@/lib/app-runtime"; +import { + createAppSettingsManager, + getAppResourceLoaderOptions, +} from "@/lib/app-runtime"; export const dynamic = "force-dynamic"; -const modelNameCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }); +const modelNameCollator = new Intl.Collator(undefined, { + numeric: true, + sensitivity: "base", +}); function compareModelEntries( - a: { id: string; name: string; provider: string }, - b: { id: string; name: string; provider: string } + a: { id: string; name: string; provider: string }, + b: { id: string; name: string; provider: string }, ): number { - return modelNameCollator.compare(a.name || a.id, b.name || b.id) - || modelNameCollator.compare(a.provider, b.provider) - || modelNameCollator.compare(a.id, b.id); + return ( + modelNameCollator.compare(a.name || a.id, b.name || b.id) || + modelNameCollator.compare(a.provider, b.provider) || + modelNameCollator.compare(a.id, b.id) + ); } -const THINKING_SUFFIXES = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]); +const THINKING_SUFFIXES = new Set([ + "off", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", +]); function stripThinkingSuffix(modelRef: string): string { - const trimmed = modelRef.trim(); - const colonIndex = trimmed.lastIndexOf(":"); - if (colonIndex === -1) return trimmed; - const suffix = trimmed.substring(colonIndex + 1); - return THINKING_SUFFIXES.has(suffix) ? trimmed.substring(0, colonIndex) : trimmed; + const trimmed = modelRef.trim(); + const colonIndex = trimmed.lastIndexOf(":"); + if (colonIndex === -1) return trimmed; + const suffix = trimmed.substring(colonIndex + 1); + return THINKING_SUFFIXES.has(suffix) + ? trimmed.substring(0, colonIndex) + : trimmed; } function filterByExactEnabledModels( - available: readonly T[], - enabledModels: string[] | undefined, + available: readonly T[], + enabledModels: string[] | undefined, ): readonly T[] { - if (!enabledModels || enabledModels.length === 0) return available; + if (!enabledModels || enabledModels.length === 0) return available; - const refs = new Set(enabledModels.map(stripThinkingSuffix).filter(Boolean)); - const visible = available.filter((m) => refs.has(`${m.provider}/${m.id}`) || refs.has(m.id)); - return visible.length > 0 ? visible : available; + const refs = new Set(enabledModels.map(stripThinkingSuffix).filter(Boolean)); + const visible = available.filter( + (m) => refs.has(`${m.provider}/${m.id}`) || refs.has(m.id), + ); + return visible.length > 0 ? visible : available; } async function loadModels(cwd: string): Promise { - const nameMap = new Map(); - let modelList: { id: string; name: string; provider: string }[] = []; - let defaultModel: { provider: string; modelId: string } | null = null; - const thinkingLevels: Record = {}; - const thinkingLevelMaps: Record> = {}; + const nameMap = new Map(); + let modelList: { id: string; name: string; provider: string }[] = []; + let defaultModel: { provider: string; modelId: string } | null = null; + const thinkingLevels: Record = {}; + const thinkingLevelMaps: Record> = {}; - const agentDir = getAgentDir(); - // Gate untrusted project extensions: enumerating models still imports and - // runs a repository's .pi/extensions factories, so honor project trust here - // too (see lib/project-trust.ts, #236). - const trustReloadOptions = projectTrustReloadOptions(cwd, agentDir); - const services = await createAgentSessionServices({ - cwd, - agentDir, - settingsManager: createAppSettingsManager(cwd, agentDir), - resourceLoaderOptions: getAppResourceLoaderOptions(), - ...(trustReloadOptions ? { resourceLoaderReloadOptions: trustReloadOptions } : {}), - }); - const available = await services.modelRuntime.getAvailable(); - const modelError = services.modelRuntime.getError(); - const settings: SettingsManager = services.settingsManager; - const enabledModels = settings.getEnabledModels(); - const visible = filterByExactEnabledModels(available, enabledModels); - modelList = visible.map((m: { id: string; name: string; provider: string }) => ({ - id: m.id, - name: m.name, - provider: m.provider, - })).sort(compareModelEntries); - for (const m of visible) { - const key = `${m.provider}:${m.id}`; - nameMap.set(key, m.name); - thinkingLevels[key] = getSupportedThinkingLevels(m); - if (m.thinkingLevelMap) thinkingLevelMaps[key] = m.thinkingLevelMap; - } + const agentDir = getAgentDir(); + // Gate untrusted project extensions: enumerating models still imports and + // runs a repository's .pi/extensions factories, so honor project trust here + // too (see lib/project-trust.ts, #236). + const trustReloadOptions = projectTrustReloadOptions(cwd, agentDir); + const services = await createAgentSessionServices({ + cwd, + agentDir, + settingsManager: createAppSettingsManager(cwd, agentDir), + resourceLoaderOptions: getAppResourceLoaderOptions(), + ...(trustReloadOptions + ? { resourceLoaderReloadOptions: trustReloadOptions } + : {}), + }); + const available = await services.modelRuntime.getAvailable(); + const modelError = services.modelRuntime.getError(); + const settings: SettingsManager = services.settingsManager; + const enabledModels = settings.getEnabledModels(); + const visible = filterByExactEnabledModels(available, enabledModels); + modelList = visible + .map( + (m: { + id: string; + name: string; + provider: string; + serviceTier?: string; + }) => ({ + id: m.id, + name: m.name, + provider: m.provider, + serviceTier: m.serviceTier, + }), + ) + .sort(compareModelEntries); + for (const m of visible) { + const key = `${m.provider}:${m.id}`; + nameMap.set(key, m.name); + thinkingLevels[key] = getSupportedThinkingLevels(m); + if (m.thinkingLevelMap) thinkingLevelMaps[key] = m.thinkingLevelMap; + } - const provider = settings.getDefaultProvider(); - const modelId = settings.getDefaultModel(); - if (provider && modelId && visible.some((m) => m.provider === provider && m.id === modelId)) { - defaultModel = { provider, modelId }; - } + const provider = settings.getDefaultProvider(); + const modelId = settings.getDefaultModel(); + if ( + provider && + modelId && + visible.some((m) => m.provider === provider && m.id === modelId) + ) { + defaultModel = { provider, modelId }; + } - return withModelRuntimeError( - { models: Object.fromEntries(nameMap), modelList, defaultModel, thinkingLevels, thinkingLevelMaps }, - modelError, - ); + return withModelRuntimeError( + { + models: Object.fromEntries(nameMap), + modelList, + defaultModel, + thinkingLevels, + thinkingLevelMaps, + }, + modelError, + ); } const EMPTY_MODELS: ModelsData = { - models: {}, - modelList: [], - defaultModel: null, - thinkingLevels: {}, - thinkingLevelMaps: {}, + models: {}, + modelList: [], + defaultModel: null, + thinkingLevels: {}, + thinkingLevelMaps: {}, }; export async function GET(req: Request) { - const requestedCwd = new URL(req.url).searchParams.get("cwd") || process.cwd(); - const cwd = resolve(requestedCwd); + const requestedCwd = + new URL(req.url).searchParams.get("cwd") || process.cwd(); + const cwd = resolve(requestedCwd); - let cwdStat; - try { - cwdStat = await stat(cwd); - } catch { - return Response.json({ error: `Directory does not exist: ${cwd}` }, { status: 400 }); - } - if (!cwdStat.isDirectory()) { - return Response.json({ error: `Not a directory: ${cwd}` }, { status: 400 }); - } - const allowedRoots = await getAllowedFileRoots(); - if (!isExistingFilePathAllowed(cwd, allowedRoots)) { - return Response.json({ error: "Access denied" }, { status: 403 }); - } + let cwdStat; + try { + cwdStat = await stat(cwd); + } catch { + return Response.json( + { error: `Directory does not exist: ${cwd}` }, + { status: 400 }, + ); + } + if (!cwdStat.isDirectory()) { + return Response.json({ error: `Not a directory: ${cwd}` }, { status: 400 }); + } + const allowedRoots = await getAllowedFileRoots(); + if (!isExistingFilePathAllowed(cwd, allowedRoots)) { + return Response.json({ error: "Access denied" }, { status: 403 }); + } - try { - return Response.json(await loadModelsWithCache(cwd, () => loadModels(cwd))); - } catch { - return Response.json(EMPTY_MODELS); - } + try { + return Response.json(await loadModelsWithCache(cwd, () => loadModels(cwd))); + } catch { + return Response.json(EMPTY_MODELS); + } } diff --git a/pi-web/components/ChatInput.tsx b/pi-web/components/ChatInput.tsx index a77d458..a7302b1 100644 --- a/pi-web/components/ChatInput.tsx +++ b/pi-web/components/ChatInput.tsx @@ -1,2275 +1,3511 @@ "use client"; -import React, { useRef, useState, useCallback, useEffect, useImperativeHandle, forwardRef, KeyboardEvent } from "react"; -import type { BuiltinSlashCommandResult, CompactResultInfo, QueuedMessages, SlashCommandInfo } from "@/hooks/useAgentSession"; -import { clearDraft, getDraft, setDraft, type ChatDraftImage } from "@/lib/draft-store"; +import React, { + useRef, + useState, + useCallback, + useEffect, + useImperativeHandle, + forwardRef, + KeyboardEvent, +} from "react"; +import type { + BuiltinSlashCommandResult, + CompactResultInfo, + QueuedMessages, + SlashCommandInfo, +} from "@/hooks/useAgentSession"; import { - MAX_ATTACHED_IMAGE_BYTES, - MAX_ATTACHED_IMAGES, - compressImageFile, - isBase64ImageWithinLimits, + clearDraft, + getDraft, + setDraft, + type ChatDraftImage, +} from "@/lib/draft-store"; +import { + MAX_ATTACHED_IMAGE_BYTES, + MAX_ATTACHED_IMAGES, + compressImageFile, + isBase64ImageWithinLimits, } from "@/lib/image-attachments"; import { - buildEntriesFromFiles, buildAtInsertText, extractAtQuery, filterFileEntries, - type AtQueryMatch, type FileIndexEntry, + buildEntriesFromFiles, + buildAtInsertText, + extractAtQuery, + filterFileEntries, + type AtQueryMatch, + type FileIndexEntry, } from "@/lib/file-fuzzy"; import { FolderIcon, getFileIcon } from "./FileIcons"; import { useIsMobile } from "@/hooks/useIsMobile"; import { useI18n } from "@/hooks/useI18n"; export interface AttachedImage { - data: string; // base64, no prefix - mimeType: string; - previewUrl: string; // object URL for display + data: string; // base64, no prefix + mimeType: string; + previewUrl: string; // object URL for display } interface ModelOption { - provider: string; - modelId: string; - name: string; + provider: string; + modelId: string; + name: string; + serviceTier?: string; } interface Props { - onSend: (message: string, images?: AttachedImage[]) => void; - onAbort: () => void; - onSteer?: (message: string, images?: AttachedImage[]) => void; - onFollowUp?: (message: string, images?: AttachedImage[]) => void; - onPromptWithStreamingBehavior?: (message: string, behavior: "steer" | "followUp", images?: AttachedImage[]) => void; - isStreaming: boolean; - model?: { provider: string; modelId: string } | null; - isAutoModelSelection?: boolean; - modelNames?: Record; - modelList?: { id: string; name: string; provider: string }[]; - modelError?: string | null; - onModelChange?: (provider: string, modelId: string) => void; - onCompact?: () => void; - onAbortCompaction?: () => void; - isCompacting?: boolean; - compactError?: string | null; - compactResult?: CompactResultInfo | null; - toolPreset?: "none" | "default" | "full"; - onToolPresetChange?: (preset: "none" | "default" | "full") => void; - thinkingLevel?: "auto" | "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; - onThinkingLevelChange?: (level: "auto" | "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max") => void; - availableThinkingLevels?: string[] | null; - thinkingLevelMap?: Record | null; - retryInfo?: { attempt: number; maxAttempts: number; errorMessage?: string } | null; - queuedMessages?: QueuedMessages | null; - inputHistory?: string[]; - onRecallQueue?: () => void; - slashCommands?: SlashCommandInfo[]; - slashCommandsLoading?: boolean; - onLoadSlashCommands?: () => Promise | SlashCommandInfo[]; - onBuiltinCommand?: (message: string) => Promise; - soundEnabled?: boolean; - onSoundToggle?: () => void; - onAudioUnlock?: () => void; - draftKey?: string; - /** Session working directory — enables the @ file autocomplete menu */ - cwd?: string | null; + onSend: (message: string, images?: AttachedImage[]) => void; + onAbort: () => void; + onSteer?: (message: string, images?: AttachedImage[]) => void; + onFollowUp?: (message: string, images?: AttachedImage[]) => void; + onPromptWithStreamingBehavior?: ( + message: string, + behavior: "steer" | "followUp", + images?: AttachedImage[], + ) => void; + isStreaming: boolean; + model?: { provider: string; modelId: string } | null; + isAutoModelSelection?: boolean; + modelNames?: Record; + modelList?: { + id: string; + name: string; + provider: string; + serviceTier?: string; + }[]; + modelError?: string | null; + onModelChange?: (provider: string, modelId: string) => void; + onCompact?: () => void; + onAbortCompaction?: () => void; + isCompacting?: boolean; + compactError?: string | null; + compactResult?: CompactResultInfo | null; + toolPreset?: "none" | "default" | "full"; + onToolPresetChange?: (preset: "none" | "default" | "full") => void; + thinkingLevel?: + | "auto" + | "off" + | "minimal" + | "low" + | "medium" + | "high" + | "xhigh" + | "max"; + onThinkingLevelChange?: ( + level: + | "auto" + | "off" + | "minimal" + | "low" + | "medium" + | "high" + | "xhigh" + | "max", + ) => void; + availableThinkingLevels?: string[] | null; + thinkingLevelMap?: Record | null; + retryInfo?: { + attempt: number; + maxAttempts: number; + errorMessage?: string; + } | null; + queuedMessages?: QueuedMessages | null; + inputHistory?: string[]; + onRecallQueue?: () => void; + slashCommands?: SlashCommandInfo[]; + slashCommandsLoading?: boolean; + onLoadSlashCommands?: () => Promise | SlashCommandInfo[]; + onBuiltinCommand?: (message: string) => Promise; + soundEnabled?: boolean; + onSoundToggle?: () => void; + onAudioUnlock?: () => void; + draftKey?: string; + /** Session working directory — enables the @ file autocomplete menu */ + cwd?: string | null; } export interface ChatInputHandle { - insertText: (text: string) => void; - insertIfEmpty: (text: string) => void; - prependText: (text: string) => void; - addImages: (files: File[]) => void; + insertText: (text: string) => void; + insertIfEmpty: (text: string) => void; + prependText: (text: string) => void; + addImages: (files: File[]) => void; } const TOOL_PRESETS = ["off", "default", "full"] as const; -const TOOL_PRESET_MAP: Record<"off" | "default" | "full", "none" | "default" | "full"> = { off: "none", default: "default", full: "full" }; +const TOOL_PRESET_MAP: Record< + "off" | "default" | "full", + "none" | "default" | "full" +> = { off: "none", default: "default", full: "full" }; const COMPOSITION_END_ENTER_GRACE_MS = 100; const MODEL_FILTER_THRESHOLD = 8; -const MODEL_OPTION_COLLATOR = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }); +const MODEL_OPTION_COLLATOR = new Intl.Collator(undefined, { + numeric: true, + sensitivity: "base", +}); function compareModelOptions(a: ModelOption, b: ModelOption): number { - return MODEL_OPTION_COLLATOR.compare(a.name || a.modelId, b.name || b.modelId) - || MODEL_OPTION_COLLATOR.compare(a.provider, b.provider) - || MODEL_OPTION_COLLATOR.compare(a.modelId, b.modelId); + return ( + MODEL_OPTION_COLLATOR.compare(a.name || a.modelId, b.name || b.modelId) || + MODEL_OPTION_COLLATOR.compare(a.provider, b.provider) || + MODEL_OPTION_COLLATOR.compare(a.modelId, b.modelId) + ); } -export function filterModelOptions(options: ModelOption[], query: string): ModelOption[] { - const normalizedQuery = query.trim().toLocaleLowerCase(); - if (!normalizedQuery) return options; +export function filterModelOptions( + options: ModelOption[], + query: string, +): ModelOption[] { + const normalizedQuery = query.trim().toLocaleLowerCase(); + if (!normalizedQuery) return options; - return options.filter((option) => ( - `${option.name} ${option.modelId}` - .toLocaleLowerCase() - .includes(normalizedQuery) - )); + return options.filter((option) => + `${option.name} ${option.modelId}` + .toLocaleLowerCase() + .includes(normalizedQuery), + ); } -const THINKING_LEVELS = ["auto", "off", "minimal", "low", "medium", "high", "xhigh", "max"] as const; -const THINKING_LEVEL_DESC_KEYS: Record = { - auto: "chat.thinkingUseDefault", off: "chat.thinkingOff", minimal: "chat.thinkingMinimal", low: "chat.thinkingLow", - medium: "chat.thinkingMedium", high: "chat.thinkingHigh", xhigh: "chat.thinkingXhigh", max: "chat.thinkingMax", +const THINKING_LEVELS = [ + "auto", + "off", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", +] as const; +const THINKING_LEVEL_DESC_KEYS: Record< + (typeof THINKING_LEVELS)[number], + string +> = { + auto: "chat.thinkingUseDefault", + off: "chat.thinkingOff", + minimal: "chat.thinkingMinimal", + low: "chat.thinkingLow", + medium: "chat.thinkingMedium", + high: "chat.thinkingHigh", + xhigh: "chat.thinkingXhigh", + max: "chat.thinkingMax", }; function formatTokenCount(tokens: number): string { - if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`; - if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}k`; - return tokens.toLocaleString(); + if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`; + if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}k`; + return tokens.toLocaleString(); } -type SlashCommandPaletteItem = SlashCommandInfo | { - name: string; - description: string; - source: "builtin"; -}; +type SlashCommandPaletteItem = + | SlashCommandInfo + | { + name: string; + description: string; + source: "builtin"; + }; type SlashCommandSource = SlashCommandPaletteItem["source"]; const BUILTIN_SLASH_COMMANDS: SlashCommandPaletteItem[] = [ - { name: "compact", description: "chat.commandCompact", source: "builtin" }, - { name: "reload", description: "chat.commandReload", source: "builtin" }, - { name: "name", description: "chat.commandName", source: "builtin" }, - { name: "session", description: "chat.commandSession", source: "builtin" }, - { name: "copy", description: "chat.commandCopy", source: "builtin" }, + { name: "compact", description: "chat.commandCompact", source: "builtin" }, + { name: "reload", description: "chat.commandReload", source: "builtin" }, + { name: "name", description: "chat.commandName", source: "builtin" }, + { name: "session", description: "chat.commandSession", source: "builtin" }, + { name: "copy", description: "chat.commandCopy", source: "builtin" }, ]; -const SLASH_SOURCES: SlashCommandSource[] = ["builtin", "extension", "prompt", "skill"]; +const SLASH_SOURCES: SlashCommandSource[] = [ + "builtin", + "extension", + "prompt", + "skill", +]; const SLASH_SOURCE_GROUP_LABEL_KEYS: Record = { - builtin: "chat.builtIn", - extension: "chat.extensions", - prompt: "chat.prompts", - skill: "chat.skills", + builtin: "chat.builtIn", + extension: "chat.extensions", + prompt: "chat.prompts", + skill: "chat.skills", }; const SLASH_SOURCE_ORDER: Record = { - builtin: 0, - extension: 1, - prompt: 2, - skill: 3, + builtin: 0, + extension: 1, + prompt: 2, + skill: 3, }; -function slashMatchRank(command: SlashCommandPaletteItem, query: string, t: (key: string) => string): number { - const name = command.name.toLowerCase(); - const description = getSlashDescription(command, t).toLowerCase(); - if (name === query) return 0; - if (name.startsWith(query)) return 1; - if (name.includes(query)) return 2; - if (description.includes(query)) return 3; - return 4; +function slashMatchRank( + command: SlashCommandPaletteItem, + query: string, + t: (key: string) => string, +): number { + const name = command.name.toLowerCase(); + const description = getSlashDescription(command, t).toLowerCase(); + if (name === query) return 0; + if (name.startsWith(query)) return 1; + if (name.includes(query)) return 2; + if (description.includes(query)) return 3; + return 4; } -function getSlashDescription(command: SlashCommandPaletteItem, t: (key: string) => string): string { - return command.source === "builtin" ? t(command.description) : command.description ?? ""; +function getSlashDescription( + command: SlashCommandPaletteItem, + t: (key: string) => string, +): string { + return command.source === "builtin" + ? t(command.description) + : (command.description ?? ""); } function imageToDraftImage(image: AttachedImage): ChatDraftImage { - return { data: image.data, mimeType: image.mimeType }; + return { data: image.data, mimeType: image.mimeType }; } function draftImageToAttachedImage(image: ChatDraftImage): AttachedImage { - return { - ...image, - previewUrl: `data:${image.mimeType};base64,${image.data}`, - }; + return { + ...image, + previewUrl: `data:${image.mimeType};base64,${image.data}`, + }; } -function draftImagesToAttachedImages(images: ChatDraftImage[] | undefined): AttachedImage[] { - return (images ?? []) - .filter(isBase64ImageWithinLimits) - .slice(0, MAX_ATTACHED_IMAGES) - .map(draftImageToAttachedImage); +function draftImagesToAttachedImages( + images: ChatDraftImage[] | undefined, +): AttachedImage[] { + return (images ?? []) + .filter(isBase64ImageWithinLimits) + .slice(0, MAX_ATTACHED_IMAGES) + .map(draftImageToAttachedImage); } function revokeImagePreview(image: AttachedImage): void { - if (image.previewUrl.startsWith("blob:")) { - URL.revokeObjectURL(image.previewUrl); - } + if (image.previewUrl.startsWith("blob:")) { + URL.revokeObjectURL(image.previewUrl); + } } -function QueuedMessageRow({ kind, text }: { kind: "steer" | "follow-up"; text: string }) { - return ( -
- - {kind} - - {text} -
- ); +function QueuedMessageRow({ + kind, + text, +}: { + kind: "steer" | "follow-up"; + text: string; +}) { + return ( +
+ + {kind} + + + {text} + +
+ ); } export function ModelErrorBanner({ error }: { error?: string | null }) { - if (!error) return null; - return ( -
- -
-
Model error
-
{error}
-
-
- ); + if (!error) return null; + return ( +
+ +
+
Model error
+
+ {error} +
+
+
+ ); } -export const ChatInput = forwardRef(function ChatInput({ - onSend, onAbort, onSteer, onFollowUp, isStreaming, model, isAutoModelSelection, modelNames, modelList, modelError, onModelChange, - onCompact, onAbortCompaction, isCompacting, compactError, compactResult, toolPreset, onToolPresetChange, - thinkingLevel, onThinkingLevelChange, availableThinkingLevels, thinkingLevelMap, - retryInfo, queuedMessages, inputHistory = [], onRecallQueue, - slashCommands, slashCommandsLoading, onLoadSlashCommands, - onBuiltinCommand, - soundEnabled, onSoundToggle, onAudioUnlock, - onPromptWithStreamingBehavior, - draftKey, - cwd, -}: Props, ref) { - const { t } = useI18n(); - const isMobile = useIsMobile(); - const [value, setValue] = useState(() => (draftKey ? getDraft(draftKey)?.value ?? "" : "")); - const [modelDropdownOpen, setModelDropdownOpen] = useState(false); - const [modelDropdownRect, setModelDropdownRect] = useState<{ top: number; left: number; width: number } | null>(null); - const [modelFilter, setModelFilter] = useState(""); - const [toolDropdownOpen, setToolDropdownOpen] = useState(false); - const [thinkingDropdownOpen, setThinkingDropdownOpen] = useState(false); - const [controlsMenuOpen, setControlsMenuOpen] = useState(false); - const [attachedImages, setAttachedImages] = useState(() => ( - draftKey ? draftImagesToAttachedImages(getDraft(draftKey)?.images) : [] - )); - const trimmedValue = value.trimStart(); - const bashMode = attachedImages.length === 0 && trimmedValue.startsWith("!"); - const bashExcluded = bashMode && trimmedValue.startsWith("!!"); - const [slashMenuOpen, setSlashMenuOpen] = useState(false); - const [slashActiveIndex, setSlashActiveIndex] = useState(0); - const [atQuery, setAtQuery] = useState(null); - const [atMenuOpen, setAtMenuOpen] = useState(false); - const [atActiveIndex, setAtActiveIndex] = useState(0); - const [historyMenuOpen, setHistoryMenuOpen] = useState(false); - const [historyActiveIndex, setHistoryActiveIndex] = useState(0); - const [fileIndex, setFileIndex] = useState<{ cwd: string; entries: FileIndexEntry[]; truncated: boolean } | null>(null); - const [fileIndexLoading, setFileIndexLoading] = useState(false); - const [atServerResult, setAtServerResult] = useState<{ cwd: string; query: string; matches: FileIndexEntry[] } | null>(null); - - const textareaRef = useRef(null); - const dropdownRef = useRef(null); - const modelDropdownPanelRef = useRef(null); - const toolDropdownRef = useRef(null); - const thinkingDropdownRef = useRef(null); - const controlsMenuRef = useRef(null); - const historyMenuRef = useRef(null); - const fileInputRef = useRef(null); - const isComposingRef = useRef(false); - const lastCompositionEndAtRef = useRef(0); - const slashCommandsRequestedRef = useRef(false); - const slashItemRefs = useRef>([]); - const atItemRefs = useRef>([]); - const historyItemRefs = useRef>([]); - const fileIndexMetaRef = useRef<{ cwd: string; fetchedAt: number } | null>(null); - const fileIndexFetchingRef = useRef(null); - const draftKeyRef = useRef(draftKey); - const valueRef = useRef(value); - const attachedImagesRef = useRef(attachedImages); - const pendingImageCountRef = useRef(0); - valueRef.current = value; - attachedImagesRef.current = attachedImages; - - useImperativeHandle(ref, () => ({ - insertIfEmpty(text: string) { - const ta = textareaRef.current; - const current = ta ? ta.value : value; - if (current.trim()) return; - setValue(text); - setAtQuery(null); - requestAnimationFrame(() => { - if (!ta) return; - ta.focus(); - ta.style.height = "auto"; - ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`; - }); - }, - prependText(text: string) { - if (!text.trim()) return; - const ta = textareaRef.current; - const current = ta ? ta.value : value; - // Mirrors the TUI's queue restore: queued text first, then whatever - // the user already typed, separated by a blank line. - const combined = [text, current].filter((t) => t.trim()).join("\n\n"); - setValue(combined); - setAtQuery(null); - requestAnimationFrame(() => { - if (!ta) return; - ta.focus(); - ta.setSelectionRange(combined.length, combined.length); - ta.style.height = "auto"; - ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`; - }); - }, - insertText(text: string) { - const ta = textareaRef.current; - if (!ta) { - setValue((v) => v + (v ? " " : "") + text); - return; - } - const start = ta.selectionStart ?? ta.value.length; - const end = ta.selectionEnd ?? ta.value.length; - const before = ta.value.slice(0, start); - const after = ta.value.slice(end); - const sep = before.length > 0 && !before.endsWith(" ") ? " " : ""; - const newVal = before + sep + text + after; - setValue(newVal); - setAtQuery(null); - requestAnimationFrame(() => { - if (!ta) return; - const pos = start + sep.length + text.length; - ta.setSelectionRange(pos, pos); - ta.focus(); - ta.style.height = "auto"; - ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`; - }); - }, - addImages(files: File[]) { - processImageFiles(files); - }, - })); - - const processImageFiles = useCallback(async (files: File[]) => { - if (isStreaming) return; - const remaining = Math.max( - 0, - MAX_ATTACHED_IMAGES - attachedImagesRef.current.length - pendingImageCountRef.current, - ); - const imageFiles = files - .filter((f) => f.type.startsWith("image/") && f.size <= MAX_ATTACHED_IMAGE_BYTES) - .slice(0, remaining); - if (!imageFiles.length) return; - pendingImageCountRef.current += imageFiles.length; - try { - const newImages = await Promise.all( - imageFiles.map(async (file) => { - // 大 JPEG 先压缩(长边 1600/q0.85),会话不再内嵌数 MB base64,加载/传输/转录都更快 - const compressed = await compressImageFile(file); - const base64 = await new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => resolve((reader.result as string).split(",")[1]); - reader.onerror = reject; - reader.readAsDataURL(compressed); - }); - return { - data: base64, - mimeType: compressed.type, - previewUrl: URL.createObjectURL(compressed), - }; - }) - ); - setAttachedImages((prev) => { - const accepted = newImages.slice(0, Math.max(0, MAX_ATTACHED_IMAGES - prev.length)); - newImages.slice(accepted.length).forEach(revokeImagePreview); - return [...prev, ...accepted]; - }); - } finally { - pendingImageCountRef.current -= imageFiles.length; - } - }, [isStreaming]); - - const removeImage = useCallback((index: number) => { - setAttachedImages((prev) => { - const next = [...prev]; - const [removed] = next.splice(index, 1); - if (removed) revokeImagePreview(removed); - return next; - }); - }, []); - - const clearImages = useCallback(() => { - setAttachedImages((prev) => { - prev.forEach(revokeImagePreview); - return []; - }); - }, []); - - const clearInput = useCallback(() => { - setValue(""); - setAtQuery(null); - setHistoryMenuOpen(false); - if (draftKey) clearDraft(draftKey); - if (draftKeyRef.current && draftKeyRef.current !== draftKey) clearDraft(draftKeyRef.current); - clearImages(); - if (textareaRef.current) { - textareaRef.current.style.height = "auto"; - } - }, [clearImages, draftKey]); - - useEffect(() => { - if (!draftKey || draftKeyRef.current !== draftKey) return; - setDraft(draftKey, { - value, - images: attachedImages.map(imageToDraftImage), - }); - }, [attachedImages, draftKey, value]); - - useEffect(() => { - const previousDraftKey = draftKeyRef.current; - if (previousDraftKey === draftKey) return; - - if (previousDraftKey) { - setDraft(previousDraftKey, { - value: valueRef.current, - images: attachedImagesRef.current.map(imageToDraftImage), - }); - } - - const draft = draftKey ? getDraft(draftKey) : null; - draftKeyRef.current = draftKey; - setValue(draft?.value ?? ""); - setAtQuery(null); - setHistoryMenuOpen(false); - setAttachedImages((prev) => { - prev.forEach(revokeImagePreview); - return draftImagesToAttachedImages(draft?.images); - }); - }, [draftKey]); - - useEffect(() => { - const ta = textareaRef.current; - if (!ta) return; - ta.style.height = "auto"; - if (value) ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`; - }, [value]); - - useEffect(() => { - return () => { - attachedImagesRef.current.forEach(revokeImagePreview); - }; - }, []); - - const handleSend = useCallback(async () => { - const msg = value.trim(); - if (!msg && !attachedImages.length) return; - if (isStreaming) return; - onAudioUnlock?.(); - if (!attachedImages.length && msg.startsWith("/") && onBuiltinCommand) { - const result = await onBuiltinCommand(msg); - if (result.handled) { - if (!result.error) clearInput(); - return; - } - } - onSend(msg, attachedImages.length ? attachedImages : undefined); - clearInput(); - }, [value, attachedImages, isStreaming, onBuiltinCommand, onSend, clearInput, onAudioUnlock]); - - const slashQuery = value.startsWith("/") && !/\s/.test(value.slice(1)) - ? value.slice(1).toLowerCase() - : null; - - const filteredSlashCommands = (() => { - if (slashQuery === null) return []; - const commands = [...(isStreaming ? [] : BUILTIN_SLASH_COMMANDS), ...(slashCommands ?? [])]; - return [...commands] - .filter((command) => { - const name = command.name.toLowerCase(); - const description = getSlashDescription(command, t).toLowerCase(); - return name.includes(slashQuery) || description.includes(slashQuery); - }) - .sort((a, b) => { - const rankDelta = slashMatchRank(a, slashQuery, t) - slashMatchRank(b, slashQuery, t); - if (rankDelta !== 0) return rankDelta; - return SLASH_SOURCE_ORDER[a.source] - SLASH_SOURCE_ORDER[b.source] - || MODEL_OPTION_COLLATOR.compare(a.name, b.name); - }); - })(); - - const groupedSlashCommands = (() => { - const groups = new Map(); - for (const source of SLASH_SOURCES) { - groups.set(source, { source, items: [] }); - } - filteredSlashCommands.forEach((command, index) => { - groups.get(command.source)?.items.push({ command, index }); - }); - return SLASH_SOURCES - .map((source) => groups.get(source)!) - .filter((group) => group.items.length > 0); - })(); - - const slashCommandCountLabel = filteredSlashCommands.length === 1 - ? t(slashQuery ? "chat.match" : "chat.command") - : t(slashQuery ? "chat.matches" : "chat.commands", { count: filteredSlashCommands.length }); - const hasInputText = Boolean(value.trim()); - const canQueueStreamingMessage = hasInputText && attachedImages.length === 0; - - // ── @ file autocomplete ────────────────────────────────────────────────── - // Recomputed from the text before the caret on every change/caret move. - // Disabled entirely when there is no cwd (new session without a directory). - const updateAtQuery = useCallback((text: string, cursor: number | null) => { - if (!cwd) { - setAtQuery(null); - return; - } - const pos = cursor ?? text.length; - setAtQuery(extractAtQuery(text.slice(0, pos))); - }, [cwd]); - - const atQueryText = atQuery?.query ?? null; - const atLocalMatches: FileIndexEntry[] = React.useMemo(() => ( - atQueryText !== null && fileIndex && fileIndex.cwd === cwd - ? filterFileEntries(fileIndex.entries, atQueryText) - : [] - ), [atQueryText, fileIndex, cwd]); - - // When the client index is truncated (repo larger than the index cap), - // local filtering cannot see deep files, so queries are also ranked - // server-side against the full listing. Local matches render immediately - // and are replaced when the (debounced) server result for the current - // query arrives; stale responses are ignored via the query/cwd tag. - const needsServerSearch = Boolean(atQueryText && fileIndex?.truncated && fileIndex.cwd === cwd); - useEffect(() => { - if (!needsServerSearch || !cwd || !atQueryText) return; - const fetchCwd = cwd; - const query = atQueryText; - const timer = setTimeout(() => { - fetch(`/api/file-index?cwd=${encodeURIComponent(fetchCwd)}&q=${encodeURIComponent(query)}`) - .then((res) => { - if (!res.ok) throw new Error(`file search failed: ${res.status}`); - return res.json() as Promise<{ matches?: FileIndexEntry[] }>; - }) - .then((data) => setAtServerResult({ cwd: fetchCwd, query, matches: data.matches ?? [] })) - .catch(() => { - // Keep showing local matches; the next keystroke retries. - }); - }, 150); - return () => clearTimeout(timer); - }, [needsServerSearch, atQueryText, cwd]); - - const serverResultInUse = needsServerSearch - && atServerResult !== null - && atServerResult.cwd === cwd - && atServerResult.query === atQueryText; - const atMatches: FileIndexEntry[] = serverResultInUse ? atServerResult.matches : atLocalMatches; - - // Open/reset the menu whenever the @token appears or changes (mirrors the - // slash menu: Escape closes it, the next keystroke re-opens it). - const atTokenKey = atQuery === null ? null : `${atQuery.start}:${atQuery.quoted ? 1 : 0}:${atQuery.query}`; - useEffect(() => { - if (atTokenKey === null) { - setAtMenuOpen(false); - setAtActiveIndex(0); - return; - } - setAtMenuOpen(true); - setAtActiveIndex(0); - }, [atTokenKey]); - - // Fetch the file index when the menu opens. The server caches per cwd for - // ~10s, so re-opening refreshes cheaply; while typing nothing refetches. - const atTokenActive = atQuery !== null; - useEffect(() => { - if (!atTokenActive || !cwd) return; - const meta = fileIndexMetaRef.current; - if (meta && meta.cwd === cwd && Date.now() - meta.fetchedAt < 10_000) return; - if (fileIndexFetchingRef.current === cwd) return; - fileIndexFetchingRef.current = cwd; - const fetchCwd = cwd; - setFileIndexLoading(true); - fetch(`/api/file-index?cwd=${encodeURIComponent(fetchCwd)}`) - .then((res) => { - if (!res.ok) throw new Error(`file index failed: ${res.status}`); - return res.json() as Promise<{ files?: string[]; truncated?: boolean }>; - }) - .then((data) => { - setFileIndex({ cwd: fetchCwd, entries: buildEntriesFromFiles(data.files ?? []), truncated: !!data.truncated }); - fileIndexMetaRef.current = { cwd: fetchCwd, fetchedAt: Date.now() }; - }) - .catch(() => { - // Leave any previous index in place; next open retries. - fileIndexMetaRef.current = null; - }) - .finally(() => { - fileIndexFetchingRef.current = null; - setFileIndexLoading(false); - }); - }, [atTokenActive, cwd]); - - const applyAtCompletion = useCallback((entry: FileIndexEntry) => { - if (!atQuery) return; - const ta = textareaRef.current; - const cursor = ta?.selectionStart ?? value.length; - const before = value.slice(0, atQuery.start); - let after = value.slice(cursor); - // Completing inside a quoted token (@"my dir/… with the caret before the - // closing quote): the replacement carries its own closing quote, so drop - // the old one right after the caret (mirrors the TUI's applyCompletion). - if (atQuery.quoted && after.startsWith('"')) { - after = after.slice(1); - } - const insert = buildAtInsertText(entry.path, entry.isDir, atQuery.quoted); - const newValue = before + insert.text + after; - const newPos = before.length + insert.cursorOffset; - setValue(newValue); - // setValue alone does not fire onChange — re-derive the token here. Files - // end with a space (token closes, menu hides); directories end with "/" - // before the caret (token stays open for drill-down into the directory). - setAtQuery(extractAtQuery(newValue.slice(0, newPos))); - requestAnimationFrame(() => { - const el = textareaRef.current; - if (!el) return; - el.focus(); - el.setSelectionRange(newPos, newPos); - el.style.height = "auto"; - el.style.height = `${Math.min(el.scrollHeight, 200)}px`; - }); - }, [atQuery, value]); - - useEffect(() => { - if (atActiveIndex >= atMatches.length) { - setAtActiveIndex(Math.max(0, atMatches.length - 1)); - } - }, [atMatches.length, atActiveIndex]); - - useEffect(() => { - atItemRefs.current.length = atMatches.length; - }, [atMatches.length]); - - useEffect(() => { - if (!atMenuOpen) return; - atItemRefs.current[atActiveIndex]?.scrollIntoView({ block: "nearest", inline: "nearest" }); - }, [atActiveIndex, atMenuOpen]); - - useEffect(() => { - if (historyActiveIndex >= inputHistory.length) { - setHistoryActiveIndex(Math.max(0, inputHistory.length - 1)); - } - }, [inputHistory.length, historyActiveIndex]); - - useEffect(() => { - historyItemRefs.current.length = inputHistory.length; - }, [inputHistory.length]); - - useEffect(() => { - if (!historyMenuOpen) return; - historyItemRefs.current[historyActiveIndex]?.scrollIntoView({ block: "nearest", inline: "nearest" }); - }, [historyActiveIndex, historyMenuOpen]); - - const applyHistoryInput = useCallback((text: string) => { - setValue(text); - setHistoryMenuOpen(false); - setHistoryActiveIndex(0); - setAtQuery(null); - requestAnimationFrame(() => { - const ta = textareaRef.current; - if (!ta) return; - ta.focus(); - ta.setSelectionRange(text.length, text.length); - ta.style.height = "auto"; - ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`; - }); - }, []); - - const applySlashCommand = useCallback((command: SlashCommandPaletteItem) => { - const nextValue = `/${command.name} `; - setValue(nextValue); - setSlashMenuOpen(false); - setSlashActiveIndex(0); - requestAnimationFrame(() => { - const ta = textareaRef.current; - if (!ta) return; - ta.focus(); - ta.setSelectionRange(nextValue.length, nextValue.length); - ta.style.height = "auto"; - ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`; - }); - }, []); - - const sendQueued = useCallback((mode: "steer" | "followup") => { - const msg = value.trim(); - if (!msg && !attachedImages.length) return; - if (attachedImages.length) return; - onAudioUnlock?.(); - const streamingBehavior = mode === "steer" ? "steer" : "followUp"; - if (msg.startsWith("/") && onPromptWithStreamingBehavior) { - onPromptWithStreamingBehavior(msg, streamingBehavior, attachedImages.length ? attachedImages : undefined); - clearInput(); - return; - } - if (mode === "steer" && onSteer) { - onSteer(msg, attachedImages.length ? attachedImages : undefined); - } else if (mode === "followup" && onFollowUp) { - onFollowUp(msg, attachedImages.length ? attachedImages : undefined); - } - clearInput(); - }, [value, attachedImages, onPromptWithStreamingBehavior, onSteer, onFollowUp, clearInput, onAudioUnlock]); - - const getNextSlashIndex = useCallback((direction: "up" | "down" | "left" | "right") => { - const lastIndex = filteredSlashCommands.length - 1; - if (lastIndex < 0) return 0; - - if (direction === "left") return Math.max(0, slashActiveIndex - 1); - if (direction === "right") return Math.min(lastIndex, slashActiveIndex + 1); - - const currentNode = slashItemRefs.current[slashActiveIndex]; - if (!currentNode) { - return direction === "down" - ? Math.min(lastIndex, slashActiveIndex + 1) - : Math.max(0, slashActiveIndex - 1); - } - - const currentRect = currentNode.getBoundingClientRect(); - const currentX = currentRect.left + currentRect.width / 2; - const currentY = currentRect.top + currentRect.height / 2; - let bestIndex = -1; - let bestScore = Number.POSITIVE_INFINITY; - - for (let index = 0; index <= lastIndex; index += 1) { - if (index === slashActiveIndex) continue; - const node = slashItemRefs.current[index]; - if (!node) continue; - const rect = node.getBoundingClientRect(); - const candidateY = rect.top + rect.height / 2; - const verticalDelta = candidateY - currentY; - if (direction === "down" ? verticalDelta <= 4 : verticalDelta >= -4) continue; - - const candidateX = rect.left + rect.width / 2; - const score = Math.abs(verticalDelta) * 1000 + Math.abs(candidateX - currentX); - if (score < bestScore) { - bestIndex = index; - bestScore = score; - } - } - - if (bestIndex >= 0) return bestIndex; - return direction === "down" - ? Math.min(lastIndex, slashActiveIndex + 1) - : Math.max(0, slashActiveIndex - 1); - }, [filteredSlashCommands.length, slashActiveIndex]); - - const handleKeyDown = useCallback( - (e: KeyboardEvent) => { - const nativeEvent = e.nativeEvent; - const recentlyComposed = Date.now() - lastCompositionEndAtRef.current < COMPOSITION_END_ENTER_GRACE_MS; - const isComposing = - isComposingRef.current || - nativeEvent.isComposing || - nativeEvent.keyCode === 229; - - if (e.key === "Enter" && !e.shiftKey && (isComposing || recentlyComposed)) { - if (recentlyComposed) e.preventDefault(); - return; - } - - if (historyMenuOpen && !isComposing) { - if (e.key === "ArrowDown") { - e.preventDefault(); - setHistoryActiveIndex((i) => Math.min(Math.max(0, inputHistory.length - 1), i + 1)); - return; - } - if (e.key === "ArrowUp") { - e.preventDefault(); - setHistoryActiveIndex((i) => Math.max(0, i - 1)); - return; - } - if (e.key === "Escape") { - e.preventDefault(); - setHistoryMenuOpen(false); - return; - } - if ((e.key === "Tab" || (e.key === "Enter" && !e.shiftKey)) && inputHistory[historyActiveIndex]) { - e.preventDefault(); - applyHistoryInput(inputHistory[historyActiveIndex]); - return; - } - } - - if (slashMenuOpen && slashQuery !== null) { - if (e.key === "ArrowDown") { - e.preventDefault(); - setSlashActiveIndex(getNextSlashIndex("down")); - return; - } - if (e.key === "ArrowUp") { - e.preventDefault(); - setSlashActiveIndex(getNextSlashIndex("up")); - return; - } - if (e.key === "ArrowRight") { - e.preventDefault(); - setSlashActiveIndex(getNextSlashIndex("right")); - return; - } - if (e.key === "ArrowLeft") { - e.preventDefault(); - setSlashActiveIndex(getNextSlashIndex("left")); - return; - } - if (e.key === "Escape") { - e.preventDefault(); - setSlashMenuOpen(false); - return; - } - if ((e.key === "Tab" || (e.key === "Enter" && !e.shiftKey)) && filteredSlashCommands[slashActiveIndex]) { - e.preventDefault(); - applySlashCommand(filteredSlashCommands[slashActiveIndex]); - return; - } - } - - // @ file menu — skip while composing so IME candidate navigation - // (arrows/Enter/Tab) is never intercepted. - if (atMenuOpen && atQuery !== null && !isComposing) { - if (e.key === "ArrowDown") { - e.preventDefault(); - setAtActiveIndex((i) => Math.min(Math.max(0, atMatches.length - 1), i + 1)); - return; - } - if (e.key === "ArrowUp") { - e.preventDefault(); - setAtActiveIndex((i) => Math.max(0, i - 1)); - return; - } - if (e.key === "Escape") { - e.preventDefault(); - setAtMenuOpen(false); - return; - } - if ((e.key === "Tab" || (e.key === "Enter" && !e.shiftKey)) && atMatches[atActiveIndex]) { - e.preventDefault(); - applyAtCompletion(atMatches[atActiveIndex]); - return; - } - } - - if (e.key === "ArrowUp" && !isComposing && !isStreaming && inputHistory.length > 0 && value.trim().length === 0) { - e.preventDefault(); - setSlashMenuOpen(false); - setAtMenuOpen(false); - setHistoryActiveIndex(inputHistory.length - 1); - setHistoryMenuOpen(true); - return; - } - - // Esc stops the agent when no slash/@/history menu or IME composition is active. - if (e.key === "Escape" && !isComposing && isStreaming && onAbort) { - e.preventDefault(); - onAbort(); - return; - } - - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - if (isStreaming && (onSteer || onFollowUp)) { - // Default Enter sends as steer if available, else followup - sendQueued(onSteer ? "steer" : "followup"); - } else { - handleSend(); - } - } - }, - [isStreaming, onSteer, onFollowUp, onAbort, slashMenuOpen, slashQuery, filteredSlashCommands, slashActiveIndex, applySlashCommand, sendQueued, handleSend, getNextSlashIndex, atMenuOpen, atQuery, atMatches, atActiveIndex, applyAtCompletion, historyMenuOpen, inputHistory, historyActiveIndex, applyHistoryInput, value] - ); - - const handleInput = useCallback(() => { - const ta = textareaRef.current; - if (!ta) return; - ta.style.height = "auto"; - ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`; - }, []); - - const handlePaste = useCallback((e: React.ClipboardEvent) => { - const items = Array.from(e.clipboardData?.items ?? []); - const imageItems = items.filter((item) => item.type.startsWith("image/")); - if (!imageItems.length) return; - e.preventDefault(); - const files = imageItems.map((item) => item.getAsFile()).filter((f): f is File => f !== null); - processImageFiles(files); - }, [processImageFiles]); - - useEffect(() => { - if (slashQuery === null) { - setSlashMenuOpen(false); - setSlashActiveIndex(0); - slashCommandsRequestedRef.current = false; - return; - } - setSlashMenuOpen(true); - setSlashActiveIndex(0); - if (!slashCommandsRequestedRef.current && onLoadSlashCommands) { - slashCommandsRequestedRef.current = true; - Promise.resolve(onLoadSlashCommands()).catch(() => { - slashCommandsRequestedRef.current = false; - }); - } - }, [slashQuery, onLoadSlashCommands]); - - useEffect(() => { - if (slashActiveIndex >= filteredSlashCommands.length) { - setSlashActiveIndex(Math.max(0, filteredSlashCommands.length - 1)); - } - }, [filteredSlashCommands.length, slashActiveIndex]); - - useEffect(() => { - slashItemRefs.current.length = filteredSlashCommands.length; - }, [filteredSlashCommands.length]); - - useEffect(() => { - if (!slashMenuOpen) return; - slashItemRefs.current[slashActiveIndex]?.scrollIntoView({ block: "nearest", inline: "nearest" }); - }, [slashActiveIndex, slashMenuOpen]); - - // Build model options: prefer modelList (has provider info), fallback to modelNames - const modelOptions: ModelOption[] = (() => { - if (modelList && modelList.length > 0) { - return modelList.map((m) => ({ provider: m.provider, modelId: m.id, name: m.name })).sort(compareModelOptions); - } - return Object.entries(modelNames ?? {}).map(([modelId, name]) => ({ - provider: model?.provider ?? "unknown", - modelId, - name, - })).sort(compareModelOptions); - })(); - const filteredModelOptions = filterModelOptions(modelOptions, modelFilter); - const showModelFilter = modelOptions.length > MODEL_FILTER_THRESHOLD; - - // Group options by provider, preserving insertion order - const modelsByProvider: { provider: string; options: ModelOption[] }[] = []; - for (const opt of filteredModelOptions) { - const group = modelsByProvider.find((g) => g.provider === opt.provider); - if (group) group.options.push(opt); - else modelsByProvider.push({ provider: opt.provider, options: [opt] }); - } - - const displayModelName = model - ? (modelOptions.find((o) => o.modelId === model.modelId && o.provider === model.provider)?.name ?? model.modelId) - : null; - const currentName = displayModelName; - - const compactSavedTokens = compactResult - ? Math.max(0, compactResult.tokensBefore - compactResult.estimatedTokensAfter) - : 0; - const compactResultText = compactResult - ? `${compactResult.reason && compactResult.reason !== "manual" ? `${compactResult.reason[0].toUpperCase()}${compactResult.reason.slice(1)} ` : t("chat.compacted")} ${formatTokenCount(compactResult.tokensBefore)} -> ${formatTokenCount(compactResult.estimatedTokensAfter)} tokens (${t("chat.tokensSaved", { saved: formatTokenCount(compactSavedTokens) })})` - : null; - const thinkingDisplayLabel = (() => { - const lvl = thinkingLevel ?? "auto"; - if (lvl === "auto" || !thinkingLevelMap) return lvl; - return thinkingLevelMap[lvl] ?? lvl; - })(); - const toolPresetLabel = Object.entries(TOOL_PRESET_MAP).find(([, v]) => v === (toolPreset ?? "default"))?.[0] ?? "default"; - - // Close dropdowns on outside click - useEffect(() => { - const handler = (e: MouseEvent) => { - if ( - dropdownRef.current && !dropdownRef.current.contains(e.target as Node) && - modelDropdownPanelRef.current && !modelDropdownPanelRef.current.contains(e.target as Node) - ) { - setModelDropdownOpen(false); - setModelFilter(""); - } - if (toolDropdownRef.current && !toolDropdownRef.current.contains(e.target as Node)) { - setToolDropdownOpen(false); - } - if (thinkingDropdownRef.current && !thinkingDropdownRef.current.contains(e.target as Node)) { - setThinkingDropdownOpen(false); - } - if (controlsMenuRef.current && !controlsMenuRef.current.contains(e.target as Node)) { - setControlsMenuOpen(false); - } - if (historyMenuRef.current && !historyMenuRef.current.contains(e.target as Node) && !textareaRef.current?.contains(e.target as Node)) { - setHistoryMenuOpen(false); - } - }; - document.addEventListener("mousedown", handler); - return () => document.removeEventListener("mousedown", handler); - }, []); - - useEffect(() => { - if (!isMobile) setControlsMenuOpen(false); - }, [isMobile]); - - - - return ( -
- {/* Hidden file input */} - { - const files = Array.from(e.target.files ?? []); - processImageFiles(files); - e.target.value = ""; - }} - /> -
- - {/* Queued steering / follow-up messages (delivered by pi on upcoming turns) */} - {((queuedMessages?.steering.length ?? 0) + (queuedMessages?.followUp.length ?? 0)) > 0 && ( -
-
- - {t("chat.queued", { count: (queuedMessages?.steering.length ?? 0) + (queuedMessages?.followUp.length ?? 0) })} - - {onRecallQueue && ( - - )} -
- {queuedMessages?.steering.map((text, i) => ( - - ))} - {queuedMessages?.followUp.map((text, i) => ( - - ))} -
- )} - {/* Retry banner */} - {retryInfo && ( -
- - - - - {t("chat.retrying", { attempt: retryInfo.attempt, max: retryInfo.maxAttempts })}{retryInfo.errorMessage && — {retryInfo.errorMessage}} -
- )} - {compactResultText && ( -
- - - - {compactResultText} -
- )} - {compactError && ( -
- {compactError} -
- )} - {/* Image previews */} - {attachedImages.length > 0 && ( -
- {attachedImages.map((img, i) => ( -
- {/* eslint-disable-next-line @next/next/no-img-element */} - - -
- ))} -
- )} - - {/* Main input */} -
- {historyMenuOpen && inputHistory.length > 0 && ( -
-
- -
-
- {inputHistory.map((item, index) => { - const active = index === historyActiveIndex; - return ( - - ); - })} -
-
- )} - {slashMenuOpen && slashQuery !== null && ( -
-
- {slashCommandsLoading ? t("chat.loadingCommands") : t("chat.slashCommands", { label: slashCommandCountLabel })} - {t("chat.tabEnter")} -
-
- {!slashCommandsLoading && filteredSlashCommands.length === 0 ? ( -
- {t("chat.noCommands")} -
- ) : ( - groupedSlashCommands.map((group) => ( -
-
- {t(SLASH_SOURCE_GROUP_LABEL_KEYS[group.source])} - {group.items.length} -
-
- {group.items.map(({ command, index }) => { - const active = index === slashActiveIndex; - return ( - - ); - })} -
-
- )) - )} -
-
- )} - {atMenuOpen && atQuery !== null && (() => { - const indexLoading = fileIndexLoading && (!fileIndex || fileIndex.cwd !== cwd); - const matchCountLabel = atMatches.length === 1 ? t("chat.match") : t("chat.matches", { count: atMatches.length }); - // With a truncated index, local results are provisional — the - // debounced server search over the full listing replaces them. - const truncatedHint = fileIndex?.truncated && !serverResultInUse - ? (atQuery.query ? t("chat.searchingAll") : t("chat.indexTruncated")) - : ""; - return ( -
-
- - {indexLoading - ? t("chat.loadingFiles") - : t("chat.files", { label: matchCountLabel, hint: truncatedHint })} - - {t("chat.tabEnter")} -
-
- {!indexLoading && atMatches.length === 0 ? ( -
- {needsServerSearch && !serverResultInUse ? t("chat.searching") : t("chat.noMatchingFiles")} -
- ) : ( - atMatches.map((entry, index) => { - const active = index === atActiveIndex; - const name = entry.path.split("/").pop() ?? entry.path; - const dirPrefix = entry.path.slice(0, entry.path.length - name.length); - return ( - - ); - }) - )} -
-
- ); - })()} -
-