From 8c5fd91d0570841557f6a898a41fd736ab5869e0 Mon Sep 17 00:00:00 2001 From: AI Briefing Date: Mon, 16 Mar 2026 18:55:10 +0800 Subject: [PATCH 01/10] fix: route auto-approve through CodePilot Handle permission prompts in CodePilot instead of relying on Claude Code bypass mode so auto-approve still works in environments where bypassPermissions is rejected. --- src/hooks/useSSEStream.ts | 2 ++ src/lib/bridge/permission-broker.ts | 10 ++++++- src/lib/claude-client.ts | 46 +++++++++++++++++++++-------- src/types/index.ts | 1 + 4 files changed, 45 insertions(+), 14 deletions(-) diff --git a/src/hooks/useSSEStream.ts b/src/hooks/useSSEStream.ts index 14c39fc3..c4bb33e4 100644 --- a/src/hooks/useSSEStream.ts +++ b/src/hooks/useSSEStream.ts @@ -10,6 +10,7 @@ interface ToolUseInfo { interface ToolResultInfo { tool_use_id: string; content: string; + is_error?: boolean; } export interface SSECallbacks { @@ -73,6 +74,7 @@ function handleSSEEvent( callbacks.onToolResult({ tool_use_id: resultData.tool_use_id, content: resultData.content, + is_error: resultData.is_error, }); } catch { // skip malformed tool_result data diff --git a/src/lib/bridge/permission-broker.ts b/src/lib/bridge/permission-broker.ts index 395a1245..bf2056c7 100644 --- a/src/lib/bridge/permission-broker.ts +++ b/src/lib/bridge/permission-broker.ts @@ -13,7 +13,7 @@ import type { PermissionUpdate } from '@anthropic-ai/claude-agent-sdk'; import type { ChannelAddress, OutboundMessage } from './types'; import type { BaseChannelAdapter } from './channel-adapter'; import { deliver } from './delivery-layer'; -import { insertPermissionLink, getPermissionLink, markPermissionLinkResolved, getSession, getDb } from '../db'; +import { insertPermissionLink, getPermissionLink, markPermissionLinkResolved, getSession, getDb, getSetting } from '../db'; import { resolvePendingPermission } from '../permission-registry'; import { escapeHtml } from './adapters/telegram-utils'; @@ -36,6 +36,14 @@ export async function forwardPermissionRequest( suggestions?: unknown[], replyToMessageId?: string, ): Promise { + // Check if auto-approval is enabled globally — auto-approve without IM notification + const globalAutoApprove = getSetting('dangerously_skip_permissions') === 'true'; + if (globalAutoApprove) { + console.log(`[bridge] Auto-approved permission ${permissionRequestId} (tool=${toolName}) due to global auto-approval setting`); + resolvePendingPermission(permissionRequestId, { behavior: 'allow' }); + return; + } + // Check if this session uses full_access permission profile — auto-approve without IM notification if (sessionId) { const session = getSession(sessionId); diff --git a/src/lib/claude-client.ts b/src/lib/claude-client.ts index a0c21db4..d8089497 100644 --- a/src/lib/claude-client.ts +++ b/src/lib/claude-client.ts @@ -19,6 +19,7 @@ import { registerPendingPermission } from './permission-registry'; import { registerConversation, unregisterConversation } from './conversation-registry'; import { captureCapabilities, setCachedPlugins } from './agent-sdk-capabilities'; import { getSetting, updateSdkSessionId, createPermissionRequest } from './db'; +// Auto-approval is now handled at the CodePilot level, not via SDK bypassPermissions import { resolveForClaudeCode, toClaudeCodeEnv } from './provider-resolver'; import { findClaudeBinary, findGitBash, getExpandedPath, invalidateClaudePathCache } from './platform'; import { notifyPermissionRequest, notifyGeneric } from './telegram-bot'; @@ -315,14 +316,26 @@ export async function generateTextViaSdk(params: { const queryOptions: Options = { cwd: os.homedir(), abortController, - permissionMode: 'bypassPermissions', - allowDangerouslySkipPermissions: true, + permissionMode: 'acceptEdits', env: sanitizeEnv(sdkEnv), settingSources: resolved.settingSources as Options['settingSources'], systemPrompt: params.system, maxTurns: 1, }; + // Add auto-approval handler for this simple query + const globalAutoApprove = getSetting('dangerously_skip_permissions') === 'true'; + if (globalAutoApprove) { + queryOptions.canUseTool = async (toolName, _input, opts) => { + console.log(`[claude-client] Auto-approved ${toolName} (auto-approval enabled)`); + return { + behavior: 'allow', + updatedInput: _input, + ...(opts.suggestions ? { updatedPermissions: opts.suggestions } : {}), + }; + }; + } + if (params.model) { queryOptions.model = params.model; } @@ -443,17 +456,15 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream { const permissionRequestId = `perm-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + // If auto-approval is enabled, approve immediately without user interaction + if (shouldAutoApprove) { + console.log(`[claude-client] Auto-approved ${toolName} (auto-approval enabled)`); + return { + behavior: 'allow', + updatedInput: input, + ...(opts.suggestions ? { updatedPermissions: opts.suggestions } : {}), + }; + } + const permEvent: PermissionRequestEvent = { permissionRequestId, toolName, @@ -856,6 +873,9 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream Date: Tue, 17 Mar 2026 17:09:46 +0800 Subject: [PATCH 02/10] chore: improve stop script to kill port 3000 processes --- start.sh | 183 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100755 start.sh diff --git a/start.sh b/start.sh new file mode 100755 index 00000000..441ce0ea --- /dev/null +++ b/start.sh @@ -0,0 +1,183 @@ +#!/bin/bash + +# CodePilot 后台启动脚本 + +APP_DIR="$(cd "$(dirname "$0")" && pwd)" +LOG_FILE="$APP_DIR/app.log" +PID_FILE="$APP_DIR/app.pid" + +# 显示菜单 +show_menu() { + clear + echo "========================================" + echo " CodePilot 管理菜单" + echo "========================================" + echo "" + + # 检查状态 + if [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then + echo " 当前状态: 运行中 (PID: $(cat "$PID_FILE"))" + else + echo " 当前状态: 未运行" + fi + + echo "" + echo " [1] 启动服务" + echo " [2] 停止服务" + echo " [3] 重启服务" + echo " [4] 查看实时日志" + echo " [5] 查看状态" + echo " [6] 查看日志 (最后50行)" + echo " [0] 退出" + echo "" + echo "========================================" +} + +# 启动服务 +do_start() { + if [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then + echo "服务已在运行 (PID: $(cat "$PID_FILE"))" + return + fi + + cd "$APP_DIR" || exit 1 + nohup npm run start > "$LOG_FILE" 2>&1 & + echo $! > "$PID_FILE" + echo "启动成功,PID: $!" +} + +# 停止服务 +do_stop() { + # 先尝试停止 PID 文件中的进程 + if [ -f "$PID_FILE" ]; then + PID=$(cat "$PID_FILE") + if kill -0 "$PID" 2>/dev/null; then + kill "$PID" 2>/dev/null + sleep 1 + # 如果进程还在,强制杀掉 + if kill -0 "$PID" 2>/dev/null; then + kill -9 "$PID" 2>/dev/null + fi + echo "已停止 (PID: $PID)" + else + echo "进程不存在" + fi + rm -f "$PID_FILE" + else + echo "服务未运行" + fi + + # 强制清理占用端口 3000 的进程 + if ss -tlnp 2>/dev/null | grep -q ":3000 "; then + echo "清理残留进程..." + fuser -k 3000/tcp 2>/dev/null + sleep 1 + # 再次强制清理 + lsof -ti:3000 | xargs -r kill -9 2>/dev/null + fi +} + +# 查看状态 +do_status() { + if [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then + echo "运行中 (PID: $(cat "$PID_FILE"))" + else + echo "未运行" + fi +} + +# 查看日志 +do_log() { + tail -f "$LOG_FILE" +} + +# 菜单模式 +menu_mode() { + while true; do + show_menu + read -p "请选择操作 [0-6]: " choice + + case $choice in + 1) + echo "" + do_start + read -p "按回车键继续..." + ;; + 2) + echo "" + do_stop + read -p "按回车键继续..." + ;; + 3) + echo "" + do_stop + sleep 1 + do_start + read -p "按回车键继续..." + ;; + 4) + echo "" + echo "按 Ctrl+C 退出日志查看" + do_log + ;; + 5) + echo "" + do_status + read -p "按回车键继续..." + ;; + 6) + echo "" + if [ -f "$LOG_FILE" ]; then + tail -n 50 "$LOG_FILE" + else + echo "暂无日志文件" + fi + read -p "按回车键继续..." + ;; + 0) + echo "" + echo "再见!" + exit 0 + ;; + *) + echo "" + echo "无效选项" + read -p "按回车键继续..." + ;; + esac + done +} + +# 命令行模式 +case "${1:-menu}" in + start) + do_start + ;; + stop) + do_stop + ;; + restart) + do_stop + sleep 1 + do_start + ;; + status) + do_status + ;; + log) + do_log + ;; + menu) + menu_mode + ;; + *) + echo "用法: $0 {start|stop|restart|status|log|menu}" + echo "" + echo " start - 后台启动服务" + echo " stop - 停止服务" + echo " restart- 重启服务" + echo " status - 查看运行状态" + echo " log - 查看实时日志" + echo " menu - 显示交互菜单(默认)" + ;; +esac From 3ea2c9ebde870217e8f028d2197ad2942eab7b79 Mon Sep 17 00:00:00 2001 From: HNGM-HP <542869290@qq.com> Date: Sat, 21 Mar 2026 11:53:25 +0800 Subject: [PATCH 03/10] fix: improve stop script to kill all related processes --- start.sh | 55 +++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 41 insertions(+), 14 deletions(-) diff --git a/start.sh b/start.sh index 441ce0ea..03df74f9 100755 --- a/start.sh +++ b/start.sh @@ -48,32 +48,59 @@ do_start() { # 停止服务 do_stop() { - # 先尝试停止 PID 文件中的进程 + local stopped=0 + + # 1. 先尝试停止 PID 文件中的进程及其子进程 if [ -f "$PID_FILE" ]; then PID=$(cat "$PID_FILE") if kill -0 "$PID" 2>/dev/null; then - kill "$PID" 2>/dev/null - sleep 1 + # 杀掉整个进程组(包括子进程) + kill -TERM -"$PID" 2>/dev/null || kill -TERM "$PID" 2>/dev/null + sleep 2 # 如果进程还在,强制杀掉 if kill -0 "$PID" 2>/dev/null; then - kill -9 "$PID" 2>/dev/null + kill -9 -"$PID" 2>/dev/null || kill -9 "$PID" 2>/dev/null fi echo "已停止 (PID: $PID)" - else - echo "进程不存在" + stopped=1 fi rm -f "$PID_FILE" - else - echo "服务未运行" fi - # 强制清理占用端口 3000 的进程 - if ss -tlnp 2>/dev/null | grep -q ":3000 "; then - echo "清理残留进程..." - fuser -k 3000/tcp 2>/dev/null + # 2. 强制清理占用端口 3000 的所有进程 + local port_pids=$(lsof -ti:3000 2>/dev/null) + if [ -n "$port_pids" ]; then + echo "清理端口 3000 残留进程: $port_pids" + echo "$port_pids" | xargs kill -9 2>/dev/null + sleep 1 + stopped=1 + fi + + # 3. 清理所有 node/next 相关进程(当前目录下启动的) + local node_pids=$(pgrep -f "node.*$APP_DIR" 2>/dev/null) + if [ -n "$node_pids" ]; then + echo "清理 Node 进程: $node_pids" + echo "$node_pids" | xargs kill -9 2>/dev/null + stopped=1 + fi + + # 4. 再次检查端口 + if lsof -i:3000 >/dev/null 2>&1; then + echo "仍有进程占用端口 3000,强制清理..." + fuser -k -9 3000/tcp 2>/dev/null sleep 1 - # 再次强制清理 - lsof -ti:3000 | xargs -r kill -9 2>/dev/null + fi + + if [ "$stopped" -eq 0 ]; then + echo "服务未运行" + fi + + # 最终确认 + if lsof -i:3000 >/dev/null 2>&1; then + echo "警告: 端口 3000 仍被占用" + lsof -i:3000 + else + echo "端口 3000 已释放" fi } From cc8fd848a2078b75d89792627292165995780522 Mon Sep 17 00:00:00 2001 From: HNGM-HP <542869290@qq.com> Date: Sat, 21 Mar 2026 12:01:18 +0800 Subject: [PATCH 04/10] fix: use ss instead of lsof for reliable port detection --- start.sh | 105 +++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 82 insertions(+), 23 deletions(-) diff --git a/start.sh b/start.sh index 03df74f9..b5fdf1eb 100755 --- a/start.sh +++ b/start.sh @@ -14,9 +14,14 @@ show_menu() { echo "========================================" echo "" - # 检查状态 + # 检查状态(使用 ss 更可靠) + local port_info=$(ss -tlnp 2>/dev/null | grep ":3000 ") + local port_pid=$(echo "$port_info" | sed -n 's/.*pid=\([0-9]*\).*/\1/p') + if [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then echo " 当前状态: 运行中 (PID: $(cat "$PID_FILE"))" + elif [ -n "$port_pid" ]; then + echo " 当前状态: 端口被占用 (PID: $port_pid) - 异常" else echo " 当前状态: 未运行" fi @@ -35,15 +40,44 @@ show_menu() { # 启动服务 do_start() { + # 先检查端口是否已被占用 + local port_info=$(ss -tlnp 2>/dev/null | grep ":3000 ") + if [ -n "$port_info" ]; then + local port_pid=$(echo "$port_info" | sed -n 's/.*pid=\([0-9]*\).*/\1/p') + echo "端口 3000 已被占用 (PID: $port_pid)" + echo "请先运行 ./start.sh stop 停止服务" + return 1 + fi + if [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then echo "服务已在运行 (PID: $(cat "$PID_FILE"))" - return + return 0 fi cd "$APP_DIR" || exit 1 + + # 启动服务并等待端口监听 nohup npm run start > "$LOG_FILE" 2>&1 & - echo $! > "$PID_FILE" - echo "启动成功,PID: $!" + local npm_pid=$! + echo $npm_pid > "$PID_FILE" + + echo "正在启动服务..." + + # 等待端口监听(最多等待 30 秒,使用 ss 检测) + local waited=0 + while [ $waited -lt 30 ]; do + sleep 1 + waited=$((waited + 1)) + local check_info=$(ss -tlnp 2>/dev/null | grep ":3000 ") + if [ -n "$check_info" ]; then + local server_pid=$(echo "$check_info" | sed -n 's/.*pid=\([0-9]*\).*/\1/p') + echo "启动成功 (PID: $server_pid)" + return 0 + fi + done + + echo "启动超时,请检查日志: $LOG_FILE" + return 1 } # 停止服务 @@ -67,28 +101,24 @@ do_stop() { rm -f "$PID_FILE" fi - # 2. 强制清理占用端口 3000 的所有进程 - local port_pids=$(lsof -ti:3000 2>/dev/null) + # 2. 强制清理占用端口 3000 的所有进程(使用 ss 更可靠) + local port_pids=$(ss -tlnp 2>/dev/null | grep ":3000 " | sed -n 's/.*pid=\([0-9]*\).*/\1/p' | sort -u) if [ -n "$port_pids" ]; then echo "清理端口 3000 残留进程: $port_pids" - echo "$port_pids" | xargs kill -9 2>/dev/null + for pid in $port_pids; do + kill -9 "$pid" 2>/dev/null && echo " 已杀掉 PID: $pid" + done sleep 1 stopped=1 fi - # 3. 清理所有 node/next 相关进程(当前目录下启动的) - local node_pids=$(pgrep -f "node.*$APP_DIR" 2>/dev/null) - if [ -n "$node_pids" ]; then - echo "清理 Node 进程: $node_pids" - echo "$node_pids" | xargs kill -9 2>/dev/null - stopped=1 - fi - - # 4. 再次检查端口 - if lsof -i:3000 >/dev/null 2>&1; then - echo "仍有进程占用端口 3000,强制清理..." - fuser -k -9 3000/tcp 2>/dev/null + # 3. 备用方式:使用 lsof + local lsof_pids=$(lsof -ti:3000 2>/dev/null) + if [ -n "$lsof_pids" ]; then + echo "清理残留进程 (lsof): $lsof_pids" + echo "$lsof_pids" | xargs kill -9 2>/dev/null sleep 1 + stopped=1 fi if [ "$stopped" -eq 0 ]; then @@ -96,9 +126,9 @@ do_stop() { fi # 最终确认 - if lsof -i:3000 >/dev/null 2>&1; then + if ss -tlnp 2>/dev/null | grep -q ":3000 "; then echo "警告: 端口 3000 仍被占用" - lsof -i:3000 + ss -tlnp | grep ":3000 " else echo "端口 3000 已释放" fi @@ -106,10 +136,39 @@ do_stop() { # 查看状态 do_status() { + local has_pid=0 + local has_port=0 + + # 检查 PID 文件 if [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then - echo "运行中 (PID: $(cat "$PID_FILE"))" + has_pid=1 + echo "PID 文件: 运行中 (PID: $(cat "$PID_FILE"))" + else + echo "PID 文件: 未运行" + [ -f "$PID_FILE" ] && rm -f "$PID_FILE" + fi + + # 检查端口占用(使用 ss 更可靠) + local port_info=$(ss -tlnp 2>/dev/null | grep ":3000 ") + if [ -n "$port_info" ]; then + has_port=1 + local port_pid=$(echo "$port_info" | sed -n 's/.*pid=\([0-9]*\).*/\1/p') + echo "端口 3000: 被占用 (PID: $port_pid)" + else + echo "端口 3000: 空闲" + fi + + # 综合状态 + echo "" + if [ "$has_pid" -eq 1 ] && [ "$has_port" -eq 1 ]; then + echo ">>> 服务正常运行" + elif [ "$has_pid" -eq 0 ] && [ "$has_port" -eq 1 ]; then + echo ">>> 异常: 端口被占用但 PID 文件不存在" + echo ">>> 建议: 运行 ./start.sh stop 清理残留进程" + elif [ "$has_pid" -eq 1 ] && [ "$has_port" -eq 0 ]; then + echo ">>> 异常: PID 存在但端口未监听" else - echo "未运行" + echo ">>> 服务未运行" fi } From c653c7e241d7dbc1d35369878cef2afe094aa18f Mon Sep 17 00:00:00 2001 From: HNGM-HP <542869290@qq.com> Date: Fri, 27 Mar 2026 13:29:43 +0800 Subject: [PATCH 05/10] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=BE=A7=E8=BE=B9?= =?UTF-8?q?=E6=A0=8F=E5=89=8D=E5=AD=98=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 + package-lock.json | 106 +++++++++++++++++- src/__tests__/unit/claude-permissions.test.ts | 60 ++++++++++ src/lib/claude-permissions.ts | 45 ++++++++ 4 files changed, 213 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/unit/claude-permissions.test.ts create mode 100644 src/lib/claude-permissions.ts diff --git a/.gitignore b/.gitignore index 5c6e4eef..c75b266c 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,6 @@ apps/site/node_modules/ # claude code session data .claude/ +app.log +app.pid +AGENTS.* \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index ed584417..77f315c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2854,6 +2854,72 @@ "node": ">= 10.0.0" } }, + "node_modules/@electron/windows-sign": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.2.2.tgz", + "integrity": "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "peer": true, + "dependencies": { + "cross-dirname": "^0.1.0", + "debug": "^4.3.4", + "fs-extra": "^11.1.1", + "minimist": "^1.2.8", + "postject": "^1.0.0-alpha.6" + }, + "bin": { + "electron-windows-sign": "bin/electron-windows-sign.js" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/windows-sign/node_modules/fs-extra": { + "version": "11.3.4", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", + "integrity": "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/windows-sign/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/windows-sign/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/@emnapi/core": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", @@ -11845,6 +11911,15 @@ "buffer": "^5.1.0" } }, + "node_modules/cross-dirname": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", + "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -14988,7 +15063,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -21241,6 +21315,36 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/postject": { + "version": "1.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz", + "integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "commander": "^9.4.0" + }, + "bin": { + "postject": "dist/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/postject/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": "^12.20.0 || >=14" + } + }, "node_modules/powershell-utils": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", diff --git a/src/__tests__/unit/claude-permissions.test.ts b/src/__tests__/unit/claude-permissions.test.ts new file mode 100644 index 00000000..40cdc325 --- /dev/null +++ b/src/__tests__/unit/claude-permissions.test.ts @@ -0,0 +1,60 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + DANGEROUSLY_SKIP_PERMISSIONS_UNSUPPORTED_CODE, + getDangerouslySkipPermissionsSupport, + isDangerouslySkipPermissionsSupported, +} from '../../lib/claude-permissions'; + +describe('claude-permissions', () => { + it('allows auto-approve for non-root users on linux', () => { + const result = getDangerouslySkipPermissionsSupport({ + platform: 'linux', + uid: 1000, + env: {} as NodeJS.ProcessEnv, + }); + + assert.equal(result.supported, true); + assert.equal( + isDangerouslySkipPermissionsSupported({ + platform: 'linux', + uid: 1000, + env: {} as NodeJS.ProcessEnv, + }), + true, + ); + }); + + it('blocks auto-approve for unsandboxed root on linux', () => { + const result = getDangerouslySkipPermissionsSupport({ + platform: 'linux', + uid: 0, + env: {} as NodeJS.ProcessEnv, + }); + + assert.equal(result.supported, false); + assert.equal(result.reasonCode, DANGEROUSLY_SKIP_PERMISSIONS_UNSUPPORTED_CODE); + assert.match(result.reason || '', /root\/sudo/i); + }); + + it('allows auto-approve for sandboxed root on linux', () => { + const result = getDangerouslySkipPermissionsSupport({ + platform: 'linux', + uid: 0, + env: { IS_SANDBOX: '1' } as unknown as NodeJS.ProcessEnv, + }); + + assert.equal(result.supported, true); + }); + + it('allows auto-approve on windows', () => { + const result = getDangerouslySkipPermissionsSupport({ + platform: 'win32', + uid: 0, + env: {} as NodeJS.ProcessEnv, + }); + + assert.equal(result.supported, true); + }); +}); diff --git a/src/lib/claude-permissions.ts b/src/lib/claude-permissions.ts new file mode 100644 index 00000000..0a186b27 --- /dev/null +++ b/src/lib/claude-permissions.ts @@ -0,0 +1,45 @@ +export const DANGEROUSLY_SKIP_PERMISSIONS_UNSUPPORTED_CODE = 'DANGEROUSLY_SKIP_PERMISSIONS_ROOT_UNSUPPORTED' as const; + +export interface DangerousSkipPermissionsSupport { + supported: boolean; + reasonCode?: typeof DANGEROUSLY_SKIP_PERMISSIONS_UNSUPPORTED_CODE; + reason?: string; +} + +interface DangerousSkipPermissionsSupportOptions { + platform?: NodeJS.Platform; + uid?: number; + env?: NodeJS.ProcessEnv; +} + +export function getDangerouslySkipPermissionsSupport( + options: DangerousSkipPermissionsSupportOptions = {}, +): DangerousSkipPermissionsSupport { + const platform = options.platform ?? process.platform; + const env = options.env ?? process.env; + const uid = options.uid ?? (typeof process.getuid === 'function' ? process.getuid() : undefined); + + if (platform === 'win32') { + return { supported: true }; + } + + if (uid !== 0) { + return { supported: true }; + } + + if (env.IS_SANDBOX === '1' || env.CLAUDE_CODE_BUBBLEWRAP === '1') { + return { supported: true }; + } + + return { + supported: false, + reasonCode: DANGEROUSLY_SKIP_PERMISSIONS_UNSUPPORTED_CODE, + reason: 'Auto-approve is unavailable when CodePilot runs as root/sudo. Run the app as a regular user to enable it.', + }; +} + +export function isDangerouslySkipPermissionsSupported( + options: DangerousSkipPermissionsSupportOptions = {}, +): boolean { + return getDangerouslySkipPermissionsSupport(options).supported; +} From 9ce7e6042ec8e97da2aceb6cd6cb3e9e0668e68e Mon Sep 17 00:00:00 2001 From: HNGM-HP <542869290@qq.com> Date: Fri, 27 Mar 2026 13:36:54 +0800 Subject: [PATCH 06/10] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=A7=BB=E5=8A=A8?= =?UTF-8?q?=E7=AB=AFweb=EF=BC=8C=E4=BC=9A=E8=AF=9D=E4=BE=A7=E8=BE=B9?= =?UTF-8?q?=E6=A0=8F=E6=97=A0=E6=B3=95=E6=98=BE=E7=A4=BA=E7=9A=84BUG?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/layout/AppShell.tsx | 7 ++++ src/components/layout/ChatListPanel.tsx | 43 +++++++++++++++++++------ 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index df76a2d7..0f00421f 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -104,6 +104,13 @@ export function AppShell({ children }: { children: React.ReactNode }) { }, []); /* eslint-enable react-hooks/set-state-in-effect */ + // Listen for mobile close events + useEffect(() => { + const handler = () => setChatListOpenRaw(false); + window.addEventListener("chatlist-close", handler); + return () => window.removeEventListener("chatlist-close", handler); + }, []); + // Panel width state with localStorage persistence const [chatListWidth, setChatListWidth] = useState(240); diff --git a/src/components/layout/ChatListPanel.tsx b/src/components/layout/ChatListPanel.tsx index 08ca1c58..f8bb084d 100644 --- a/src/components/layout/ChatListPanel.tsx +++ b/src/components/layout/ChatListPanel.tsx @@ -7,6 +7,7 @@ import { FileArrowDown, Plus, FolderOpen, + X, } from "@/components/ui/icon"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -385,14 +386,37 @@ export function ChatListPanel({ open, width }: ChatListPanelProps) { if (!open) return null; return ( - + ); } From 6c261b82b4c93d78da28ff435a1eaf5d766e7a24 Mon Sep 17 00:00:00 2001 From: HNGM-HP <542869290@qq.com> Date: Thu, 2 Apr 2026 08:24:44 +0800 Subject: [PATCH 07/10] merge: upgrade to v0.44.1, keep auto-approval patch --- RELEASE_NOTES.md | 32 ++-- docs/handover/README.md | 1 - docs/insights/README.md | 2 - electron/main.ts | 53 ------- electron/preload.ts | 4 - package-lock.json | 36 +---- package.json | 2 - src/__tests__/unit/widget-system.test.ts | 66 +------- src/components/chat/ChatView.tsx | 43 +----- src/components/chat/MessageItem.tsx | 188 +++++------------------ src/components/chat/StreamingMessage.tsx | 35 ++--- src/components/chat/WidgetRenderer.tsx | 38 ++--- src/components/layout/PanelZone.tsx | 1 - src/components/ui/icon.tsx | 1 - src/components/ui/toast.tsx | 6 +- src/hooks/useToast.ts | 49 ++---- src/i18n/en.ts | 2 +- src/i18n/zh.ts | 2 +- src/lib/claude-client.ts | 24 --- src/lib/context-assembler.ts | 16 -- src/lib/widget-guidelines.ts | 3 - src/lib/widget-sanitizer.ts | 80 +--------- 22 files changed, 104 insertions(+), 580 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 40cdf2c2..c062adf5 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,28 +1,36 @@ -## CodePilot v0.44.1 +## CodePilot v0.42.0 -> v0.44.0 热修复:修复切换会话时模型选择器跳到错误模型的问题,以及若干界面优化。 +> 本版本聚焦 **CLI 工具的 AI 化安装体验**和全新的 **Agent 友好度评分系统**。安装工具改为由 AI 全程协助,工具卡片新增 5 星评分帮你判断哪些工具最适合 AI 使用。 + +### 新增功能 + +- **AI 协助安装 CLI 工具**:点击安装按钮直接跳转聊天,AI 帮你执行安装命令、处理权限问题、引导认证配置、生成工具简介,全流程在对话中完成 +- **Agent 友好度 5 星评分**:工具卡片新增 ★★★★★ 评分,从 5 个维度评估工具对 AI 的友好程度(Agent 原生设计 / JSON 输出 / Schema 自省 / Dry Run / 上下文友好) +- **AI 自动评估兼容度**:通过聊天添加的自定义工具,AI 会从 --help 输出自动评估 Agent 兼容度;批量生成简介时也会同步评估 +- **新增推荐 CLI 工具**:即梦 Dreamina CLI(AI 图片/视频生成)、飞书 Lark CLI(200+ 命令覆盖飞书全业务域) +- **工具安装后自动配置**:需要认证的工具安装后,AI 自动引导完成登录和配置;需要 Skills 安装的工具(飞书、gws)会在安装提示中告知 ### 修复问题 -- 修复切换会话时,模型选择器短暂显示上一个会话的模型再跳回正确值的问题 -- 修复分栏视图中切换会话同样会出现模型跳动的问题 -- 修复全局默认模型属于其他服务商时,可能被错误应用到当前会话的问题 -- 移除设置页中的"重置伙伴"按钮(测试功能,不应出现在正式版) +- 修复斜杠命令(如 /review)发送时用户附加文本在气泡中不显示的问题 +- 修复 JSON 格式版本号(如 Dreamina CLI)在工具卡片上显示为乱码的问题 +- 移除不可用的 Custom API (OpenAI-compatible) Provider 选项 +- 修复旧版 custom provider 升级时可能误删有效配置的问题 ### 优化改进 -- 看板面板默认宽度从 640px 调整为 480px,减少对聊天区域的占用 -- 模型解析逻辑统一为共享函数,主聊天页和分栏视图行为一致 -- 新增 11 个模型解析回归测试,覆盖跨服务商、空配置、已删除服务商等边界场景 +- 工具详情弹窗新增"AI Agent 兼容度"区域,展示具体达标维度和评分 +- GLM 模型更新为 GLM-5-Turbo / GLM-5.1 / GLM-4.5-Air +- MCP list 工具的 JSON 输出在所有工具类型中统一字段格式 ## 下载地址 ### macOS -- [Apple Silicon (M1/M2/M3/M4)](https://github.com/op7418/CodePilot/releases/download/v0.44.1/CodePilot-0.44.1-arm64.dmg) -- [Intel](https://github.com/op7418/CodePilot/releases/download/v0.44.1/CodePilot-0.44.1-x64.dmg) +- [Apple Silicon (M1/M2/M3/M4)](https://github.com/op7418/CodePilot/releases/download/v0.42.0/CodePilot-0.42.0-arm64.dmg) +- [Intel](https://github.com/op7418/CodePilot/releases/download/v0.42.0/CodePilot-0.42.0-x64.dmg) ### Windows -- [Windows 安装包](https://github.com/op7418/CodePilot/releases/download/v0.44.1/CodePilot-Setup-0.44.1.exe) +- [Windows 安装包](https://github.com/op7418/CodePilot/releases/download/v0.42.0/CodePilot-Setup-0.42.0.exe) ## 安装说明 diff --git a/docs/handover/README.md b/docs/handover/README.md index 3592f863..a91f93f5 100644 --- a/docs/handover/README.md +++ b/docs/handover/README.md @@ -18,7 +18,6 @@ | onboarding-setup-center.md | 首次引导 Setup Center:三卡片引导流程、Claude Code 环境检测与冲突处理、Provider 三条凭据来源、目录校验回退链、Toast 系统、Windows 适配 | | generative-ui.md | 生成式 UI Widget 系统:代码围栏触发、receiver iframe 渲染、CSS 变量桥接、流式预览、高度缓存、安全模型、UX 优化清单 | | media-pipeline.md | 媒体管线:MCP image/audio 回显、Gallery 视频支持、文件树媒体预览、CLI 工具导入、MediaBlock 类型、入库机制、安全模型 | -| dashboard.md | 项目看板:MCP Server(5 工具)、数据源(file/mcp_tool/cli)、排序(CSS order)、导出(Electron 隔离窗口)、cross-widget 通信、CDN 脚本执行、fence-agnostic 解析器 | | provider-error-doctor.md | Provider/Auth/Error 全链路修复 + Doctor 诊断中心:16 类错误分类、Provider 生效修复、Auth Style 自动分流、5 探针诊断引擎、修复动作、runtime-log 脱敏、CI arm64 原生构建 | | memory-system-v3.md | 记忆系统 V3/V3.1:对话式 Onboarding、HEARTBEAT_OK 心跳协议、Memory Search MCP、时间衰减、Obsidian 感知、渐进式文件更新、Telegram 静默、transcript 裁剪 | | buddy-gamification.md | Buddy 游戏化系统:生成/进化/3D 视觉、心跳双模式(完整 tick + 软 hint)、定时任务调度器健壮性、通知队列/轮询/Electron IPC、symlink 安全、cron 4 年扫描 | diff --git a/docs/insights/README.md b/docs/insights/README.md index 79ef88a9..e0cc0925 100644 --- a/docs/insights/README.md +++ b/docs/insights/README.md @@ -9,5 +9,3 @@ | 文档 | 对应交接文档 | 主题 | |------|------------|------| | [cli-tools.md](./cli-tools.md) | [handover/cli-tools.md](../handover/cli-tools.md) | CLI 工具管理的 MCP 化、Agent-first CLI 趋势、凭证管理痛点 | -| [dashboard-generative-ui.md](./dashboard-generative-ui.md) | [handover/dashboard.md](../handover/dashboard.md) | 生成式 UI 持久化、AI-first 项目看板、系统级渲染层构想、实现后复盘 | -| [buddy-gamification.md](./buddy-gamification.md) | [handover/buddy-gamification.md](../handover/buddy-gamification.md) | Buddy 宠物伙伴设计:从工具到伙伴的用户旅程、稀有度/进化/心跳、视觉体系、审查修复决策 | diff --git a/electron/main.ts b/electron/main.ts index 0248af23..4ae8beeb 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1086,59 +1086,6 @@ app.whenReady().then(async () => { return { canceled: result.canceled, filePaths: result.filePaths }; }); - // --- Widget export IPC handler --- - // Uses an isolated BrowserWindow for secure, high-fidelity widget screenshot. - // The window is hidden, has its own session partition, no preload, no IPC access. - ipcMain.handle('widget:export-png', async (_event, { html, width }: { html: string; width: number }) => { - const exportWindow = new BrowserWindow({ - show: false, - width, - height: 2000, - webPreferences: { - contextIsolation: true, - nodeIntegration: false, - sandbox: true, - partition: `export-${Date.now()}`, // isolated session, destroyed with window - // No preload — no IPC access from this window - }, - }); - - // Block all navigation and window.open — prevents data exfiltration via top-level nav - exportWindow.webContents.on('will-navigate', (e) => e.preventDefault()); - exportWindow.webContents.setWindowOpenHandler(() => ({ action: 'deny' })); - - try { - // Load the widget HTML directly - await exportWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`); - - // Wait for widget scripts to finish (scriptsReady signal or timeout) - await new Promise((resolve) => { - let resolved = false; - const done = () => { if (!resolved) { resolved = true; resolve(); } }; - // Listen for console message from widget:scriptsReady - exportWindow.webContents.on('console-message', (_e, _level, message) => { - if (message === '__scriptsReady__') done(); - }); - // Fallback timeout for widgets without CDN/scripts - setTimeout(done, 6000); - }); - - // Extra delay for final paint - await new Promise(r => setTimeout(r, 300)); - - // Get actual content height and resize - const contentHeight = await exportWindow.webContents.executeJavaScript('document.body.scrollHeight'); - exportWindow.setSize(width, Math.min(contentHeight + 20, 4000)); - await new Promise(r => setTimeout(r, 100)); - - // Capture using Chromium's native screenshot - const image = await exportWindow.webContents.capturePage(); - return image.toPNG().toString('base64'); - } finally { - exportWindow.destroy(); - } - }); - // --- Terminal IPC handlers --- terminalManager.setOnData((id, data) => { mainWindow?.webContents.send('terminal:data', { id, data }); diff --git a/electron/preload.ts b/electron/preload.ts index d92dd753..c037582e 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -29,10 +29,6 @@ contextBridge.exposeInMainWorld('electronAPI', { bridge: { isActive: () => ipcRenderer.invoke('bridge:is-active'), }, - widget: { - exportPng: (html: string, width: number, isDark: boolean) => - ipcRenderer.invoke('widget:export-png', { html, width, isDark }), - }, terminal: { create: (opts: { id: string; cwd: string; cols: number; rows: number }) => ipcRenderer.invoke('terminal:create', opts), diff --git a/package-lock.json b/package-lock.json index a532dab3..adcb15be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codepilot", - "version": "0.44.1", + "version": "0.42.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codepilot", - "version": "0.44.1", + "version": "0.42.0", "license": "BUSL-1.1", "workspaces": [ "apps/*", @@ -39,8 +39,6 @@ "discord.js": "^14.25.1", "dompurify": "^3.3.3", "electron-updater": "^6.8.3", - "html-to-image": "^1.11.13", - "jdenticon": "^3.3.0", "markdown-it": "^14.1.1", "morphdom": "^2.7.8", "motion": "^12.33.0", @@ -11375,15 +11373,6 @@ ], "license": "CC-BY-4.0" }, - "node_modules/canvas-renderer": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/canvas-renderer/-/canvas-renderer-2.2.1.tgz", - "integrity": "sha512-RrBgVL5qCEDIXpJ6NrzyRNoTnXxYarqm/cS/W6ERhUJts5UQtt/XPEosGN3rqUkZ4fjBArlnCbsISJ+KCFnIAg==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/ccount": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", @@ -16079,12 +16068,6 @@ "dev": true, "license": "ISC" }, - "node_modules/html-to-image": { - "version": "1.11.13", - "resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz", - "integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==", - "license": "MIT" - }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -17117,21 +17100,6 @@ "node": ">=10" } }, - "node_modules/jdenticon": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/jdenticon/-/jdenticon-3.3.0.tgz", - "integrity": "sha512-DhuBRNRIybGPeAjMjdHbkIfiwZCCmf8ggu7C49jhp6aJ7DYsZfudnvnTY5/1vgUhrGA7JaDAx1WevnpjCPvaGg==", - "license": "MIT", - "dependencies": { - "canvas-renderer": "~2.2.0" - }, - "bin": { - "jdenticon": "bin/jdenticon.js" - }, - "engines": { - "node": ">=6.4.0" - } - }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", diff --git a/package.json b/package.json index 8b99fd59..69220387 100644 --- a/package.json +++ b/package.json @@ -65,8 +65,6 @@ "discord.js": "^14.25.1", "dompurify": "^3.3.3", "electron-updater": "^6.8.3", - "html-to-image": "^1.11.13", - "jdenticon": "^3.3.0", "markdown-it": "^14.1.1", "morphdom": "^2.7.8", "motion": "^12.33.0", diff --git a/src/__tests__/unit/widget-system.test.ts b/src/__tests__/unit/widget-system.test.ts index b6ae3494..d74c1f97 100644 --- a/src/__tests__/unit/widget-system.test.ts +++ b/src/__tests__/unit/widget-system.test.ts @@ -23,6 +23,7 @@ import { parseAllShowWidgets, parseShowWidget, computePartialWidgetKey, + type WidgetSegment, } from '../../components/chat/MessageItem'; import { WIDGET_CSS_BRIDGE } from '../../lib/widget-css-bridge'; @@ -253,69 +254,6 @@ describe('buildReceiverSrcdoc', () => { }); }); -// ── CDN finalize script execution ──────────────────────────────────────── - -describe('finalizeHtml CDN script handling', () => { - const srcdoc = buildReceiverSrcdoc(':root{}', false); - - it('separates CDN and inline scripts in finalize', () => { - // The receiver script must filter scripts into cdn (has src) vs inline (has text) - assert.ok(srcdoc.includes('cdnScripts=scripts.filter'), 'should separate CDN scripts'); - assert.ok(srcdoc.includes('inlineScripts=scripts.filter'), 'should separate inline scripts'); - }); - - it('waits for all CDN scripts before executing inline', () => { - // When CDN scripts exist, inline must only run after all CDN onload/onerror fire - assert.ok(srcdoc.includes('_pending=cdnScripts.length'), 'should track pending CDN count'); - assert.ok(srcdoc.includes('_pending--'), 'should decrement on each CDN completion'); - assert.ok(srcdoc.includes('_pending<=0'), 'should run inline only when all CDN done'); - }); - - it('runs inline immediately when no CDN scripts', () => { - assert.ok(srcdoc.includes('cdnScripts.length===0'), 'should check for zero CDN scripts'); - // _appendInline is called directly in the no-CDN branch - assert.ok(srcdoc.includes('_appendInline()'), 'should call _appendInline'); - }); - - it('does NOT re-inject inline scripts on CDN load (no duplicate execution)', () => { - // _appendInline should only be called once — no _runInline on every onload - // The function is named _appendInline (not _runInline) and called via _onCdnDone counter - assert.ok(srcdoc.includes('function _onCdnDone'), 'should use counter-based callback'); - assert.ok(srcdoc.includes('n.onload=_onCdnDone'), 'onload should use counter, not direct _appendInline'); - assert.ok(srcdoc.includes('n.onerror=_onCdnDone'), 'onerror should use counter, not direct _appendInline'); - }); - - it('does NOT have a timeout fallback that could race with CDN load', () => { - // Previous bugs: setTimeout(3000) set _inlineRan=true before CDN arrived - // The finalizeHtml script section should not use setTimeout for inline execution - assert.ok(!srcdoc.includes('setTimeout(function(){_appendInline'), 'should not have timeout calling _appendInline'); - assert.ok(!srcdoc.includes('setTimeout(function(){_runInline'), 'should not have timeout calling _runInline'); - }); - - it('does NOT have a once-flag that could lock out late CDN arrivals', () => { - // Previous bug: _inlineRan flag prevented init after slow CDN load - assert.ok(!srcdoc.includes('_inlineRan'), 'should not have _inlineRan flag'); - }); - - it('strips model-provided onload to avoid double init', () => { - // CDN scripts with model-provided onload="init()" should have it stripped - // since our _onCdnDone callback handles execution timing - assert.ok(srcdoc.includes("!=='onload'"), 'should skip onload attribute when setting attrs'); - }); - - it('emits widget:scriptsReady after inline scripts execute', () => { - // Export relies on this signal to know when Chart.js etc. have finished drawing - assert.ok(srcdoc.includes("widget:scriptsReady"), 'should emit scriptsReady after _appendInline'); - }); - - it('handles widget:capture message for PNG export', () => { - assert.ok(srcdoc.includes("widget:capture"), 'should handle capture message'); - assert.ok(srcdoc.includes("widget:captured"), 'should respond with captured dataUrl'); - // Must convert live canvas to img before serialization - assert.ok(srcdoc.includes("toDataURL"), 'should convert canvas elements to images'); - }); -}); - // ── CDN whitelist ─────────────────────────────────────────────────────── describe('CDN_WHITELIST', () => { @@ -376,7 +314,7 @@ describe('WIDGET_SYSTEM_PROMPT', () => { it('is smaller than the original full prompt but includes core rules', () => { assert.ok(WIDGET_SYSTEM_PROMPT.length > 500, 'should include core hard constraints'); - assert.ok(WIDGET_SYSTEM_PROMPT.length < 2000, 'should be smaller than original ~2500 char full prompt'); + assert.ok(WIDGET_SYSTEM_PROMPT.length < 1500, 'should be smaller than original ~2500 char full prompt'); }); it('includes critical hard constraints for valid widget output', () => { diff --git a/src/components/chat/ChatView.tsx b/src/components/chat/ChatView.tsx index 9e2461a4..971ea672 100644 --- a/src/components/chat/ChatView.tsx +++ b/src/components/chat/ChatView.tsx @@ -102,7 +102,7 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal // Pending image generation notices const pendingImageNoticesRef = useRef([]); - const sendMessageRef = useRef<(content: string, files?: FileAttachment[], systemPromptAppend?: string, displayOverride?: string) => Promise>(undefined); + const sendMessageRef = useRef<(content: string, files?: FileAttachment[]) => Promise>(undefined); const initMetaRef = useRef<{ tools?: unknown; slash_commands?: unknown; skills?: unknown } | null>(null); const handleModeChange = useCallback((newMode: string) => { @@ -376,47 +376,6 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal }; }, []); - // Listen for widget pin requests from PinnableWidget buttons. - // The AI model receives the widget code + instructions and calls the - // codepilot_dashboard_pin MCP tool to complete the pin operation. - useEffect(() => { - const handler = (e: Event) => { - const { widgetCode, title } = (e as CustomEvent).detail || {}; - if (!widgetCode || !sendMessageRef.current) return; - - const instruction = `请将下面的可视化组件固定到项目看板。\n\n标题建议:${title || 'Untitled'}\n\n组件代码:\n${widgetCode}`; - sendMessageRef.current(instruction, undefined, undefined, `📌 固定「${title || 'Widget'}」到看板`); - }; - window.addEventListener('widget-pin-request', handler); - return () => window.removeEventListener('widget-pin-request', handler); - }, []); - - // Listen for dashboard widget drilldown (click title → conversation) - useEffect(() => { - const handler = (e: Event) => { - const { title, dataContract } = (e as CustomEvent).detail || {}; - if (!title || !sendMessageRef.current) return; - sendMessageRef.current( - `请深入分析看板组件「${title}」的数据。\n数据契约:${dataContract || '无'}`, - undefined, undefined, - `🔍 分析「${title}」`, - ); - }; - window.addEventListener('dashboard-widget-drilldown', handler); - return () => window.removeEventListener('dashboard-widget-drilldown', handler); - }, []); - - // Listen for dashboard command input - useEffect(() => { - const handler = (e: Event) => { - const { text } = (e as CustomEvent).detail || {}; - if (!text || !sendMessageRef.current) return; - sendMessageRef.current(text, undefined, undefined, text); - }; - window.addEventListener('dashboard-command', handler); - return () => window.removeEventListener('dashboard-command', handler); - }, []); - const handleCommand = useChatCommands({ sessionId, messages, setMessages, sendMessage }); // Listen for image generation completion diff --git a/src/components/chat/MessageItem.tsx b/src/components/chat/MessageItem.tsx index 81111156..ad9038db 100644 --- a/src/components/chat/MessageItem.tsx +++ b/src/components/chat/MessageItem.tsx @@ -10,7 +10,7 @@ import { import { ToolActionsGroup } from '@/components/ai-elements/tool-actions-group'; import { MediaPreview } from './MediaPreview'; import { Button } from "@/components/ui/button"; -import { Copy, Check, CaretDown, CaretUp, PushPin, DownloadSimple } from "@/components/ui/icon"; +import { Copy, Check, CaretDown, CaretUp } from "@/components/ui/icon"; import { FileAttachmentDisplay } from './FileAttachmentDisplay'; import { ImageGenConfirmation } from './ImageGenConfirmation'; import { ImageGenCard } from './ImageGenCard'; @@ -19,7 +19,6 @@ import { WidgetRenderer } from './WidgetRenderer'; import { buildReferenceImages } from '@/lib/image-ref-store'; import { SPECIES_IMAGE_URL, EGG_IMAGE_URL, RARITY_BG_GRADIENT, type Species, type Rarity } from '@/lib/buddy'; import { parseDBDate } from '@/lib/utils'; -import { usePanel } from '@/hooks/usePanel'; import type { PlannerOutput } from '@/types'; interface ImageGenRequest { @@ -159,103 +158,59 @@ export type WidgetSegment = | { type: 'text'; content: string } | { type: 'widget'; data: ShowWidgetData }; -/** - * Fence-format-agnostic widget parser. - * - * Models produce many fence variants (```show-widget, `show-widget`, `show-widget\n...\n`, etc.). - * Instead of normalizing each variant, we directly scan for "show-widget" markers followed by - * JSON containing "widget_code", regardless of surrounding backtick syntax. - */ - -/** Find the end of a JSON object starting at `{`, accounting for nested braces and strings. */ -function findJsonEnd(text: string, start: number): number { - let depth = 0; - let inString = false; - let escaped = false; - for (let i = start; i < text.length; i++) { - const ch = text[i]; - if (escaped) { escaped = false; continue; } - if (ch === '\\' && inString) { escaped = true; continue; } - if (ch === '"') { inString = !inString; continue; } - if (inString) continue; - if (ch === '{') depth++; - else if (ch === '}') { depth--; if (depth === 0) return i; } - } - return -1; // unclosed -} - -/** Parse ALL show-widget blocks in text, returning alternating text/widget segments. */ +/** Parse ALL show-widget fences in text, returning alternating text/widget segments. */ export function parseAllShowWidgets(text: string): WidgetSegment[] { const segments: WidgetSegment[] = []; - // Match any backtick(s) + show-widget, capturing the full marker to strip it - const markerRegex = /`{1,3}show-widget`{0,3}\s*(?:\n\s*`{3}(?:json)?\s*)?\n?/g; + const fenceRegex = /```show-widget\s*\n?([\s\S]*?)\n?\s*```/g; let lastIndex = 0; let match: RegExpExecArray | null; let foundAny = false; - while ((match = markerRegex.exec(text)) !== null) { - const afterMarker = match.index + match[0].length; - // Find the JSON object start - const jsonStart = text.indexOf('{', afterMarker); - if (jsonStart === -1 || jsonStart > afterMarker + 20) { - // No JSON nearby — skip this malformed marker, advance past any fence block - const fenceClose = text.indexOf('```', afterMarker); - if (fenceClose !== -1 && fenceClose < afterMarker + 200) { - lastIndex = fenceClose + 3; - markerRegex.lastIndex = fenceClose + 3; - foundAny = true; // so trailing text is captured - } - continue; - } - - const jsonEnd = findJsonEnd(text, jsonStart); - if (jsonEnd === -1) { - // Truncated JSON — try extracting partial widget - const partialBody = text.slice(jsonStart); - const widget = extractTruncatedWidget(partialBody); - if (widget) { - foundAny = true; - const before = text.slice(lastIndex, match.index).trim(); - if (before) segments.push({ type: 'text', content: before }); - segments.push({ type: 'widget', data: widget }); - lastIndex = text.length; - } - break; - } + while ((match = fenceRegex.exec(text)) !== null) { + foundAny = true; + // Text before this fence + const before = text.slice(lastIndex, match.index).trim(); + if (before) segments.push({ type: 'text', content: before }); - const jsonStr = text.slice(jsonStart, jsonEnd + 1); + // Parse widget JSON try { - const json = JSON.parse(jsonStr); + const json = JSON.parse(match[1]); if (json.widget_code) { - foundAny = true; - const before = text.slice(lastIndex, match.index).trim(); - if (before) segments.push({ type: 'text', content: before }); segments.push({ type: 'widget', data: { title: json.title || undefined, widget_code: String(json.widget_code) } }); - // Skip past the JSON and any trailing fence/backticks - let endPos = jsonEnd + 1; - const trailing = text.slice(endPos, endPos + 10); - const trailingFence = trailing.match(/^\s*\n?`{1,3}\s*/); - if (trailingFence) endPos += trailingFence[0].length; - lastIndex = endPos; - markerRegex.lastIndex = endPos; } - } catch { - // Malformed JSON — skip past the fence block - const fenceClose = text.indexOf('```', jsonStart); - if (fenceClose !== -1) { - markerRegex.lastIndex = fenceClose + 3; - lastIndex = fenceClose + 3; - foundAny = true; // Mark as found so trailing text is captured - } - } + } catch { /* skip malformed widget */ } + + lastIndex = match.index + match[0].length; } - if (!foundAny) return []; + if (!foundAny) { + // Fallback: handle truncated output (last fence not closed) + const fenceStart = text.indexOf('```show-widget'); + if (fenceStart === -1) return []; + + const before = text.slice(0, fenceStart).trim(); + if (before) segments.push({ type: 'text', content: before }); + + const fenceBody = text.slice(fenceStart + '```show-widget'.length).trim(); + const widget = extractTruncatedWidget(fenceBody); + if (widget) segments.push({ type: 'widget', data: widget }); + return segments; + } - // Remaining text after last widget + // Remaining text after last fence const remaining = text.slice(lastIndex).trim(); if (remaining) { - segments.push({ type: 'text', content: remaining }); + // Check if remaining text has a truncated widget fence + const truncFenceStart = remaining.indexOf('```show-widget'); + if (truncFenceStart !== -1) { + const beforeTrunc = remaining.slice(0, truncFenceStart).trim(); + if (beforeTrunc) segments.push({ type: 'text', content: beforeTrunc }); + const truncBody = remaining.slice(truncFenceStart + '```show-widget'.length).trim(); + const widget = extractTruncatedWidget(truncBody); + if (widget) segments.push({ type: 'widget', data: widget }); + } else { + segments.push({ type: 'text', content: remaining }); + } } return segments; @@ -270,11 +225,9 @@ export function parseAllShowWidgets(text: string): WidgetSegment[] { * → iframe destroyed → height collapse → scroll jump (P2 regression). */ export function computePartialWidgetKey(content: string): string { - const markers = [...content.matchAll(/`{1,3}show-widget/g)]; - if (markers.length === 0) return 'w-0'; - const lastMarker = markers[markers.length - 1]; - const beforePart = content.slice(0, lastMarker.index).trim(); - const hasCompletedFences = beforePart.length > 0 && /`{1,3}show-widget/.test(beforePart); + const lastFenceStart = content.lastIndexOf('```show-widget'); + const beforePart = content.slice(0, lastFenceStart).trim(); + const hasCompletedFences = beforePart.length > 0 && /```show-widget/.test(beforePart); const completedSegments = hasCompletedFences ? parseAllShowWidgets(beforePart) : []; return `w-${hasCompletedFences ? completedSegments.length : (beforePart ? 1 : 0)}`; } @@ -305,7 +258,6 @@ function extractTruncatedWidget(fenceBody: string): ShowWidgetData | null { .replace(/\\t/g, '\t') .replace(/\\r/g, '\r') .replace(/\\"/g, '"') - .replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16))) .replace(/\x00BACKSLASH\x00/g, '\\'); if (widgetCode.length < 10) return null; @@ -670,62 +622,6 @@ export const MessageItem = memo(function MessageItem({ message, sessionId, isAss ); }); -/** Widget wrapper with "Pin to Dashboard" button. - * Pin triggers a chat message → AI uses codepilot_dashboard_pin MCP tool. - * Button is a pure trigger — no local pin/unpin state tracking. - * Brief cooldown prevents double-click. */ -function PinnableWidget({ widgetCode, title }: { - widgetCode: string; title?: string; messageId: string; sessionId?: string; -}) { - const [cooldown, setCooldown] = useState(false); - const { workingDirectory } = usePanel(); - - const handlePin = useCallback(() => { - if (cooldown || !workingDirectory) return; - setCooldown(true); - window.dispatchEvent(new CustomEvent('widget-pin-request', { - detail: { widgetCode, title: title || 'Untitled Widget' }, - })); - // 5s cooldown to prevent rapid duplicate pins - setTimeout(() => setCooldown(false), 5000); - }, [cooldown, workingDirectory, widgetCode, title]); - - const handleExport = useCallback(async () => { - try { - const { exportWidgetAsImage, downloadBlob } = await import('@/lib/dashboard-export'); - const blob = await exportWidgetAsImage(widgetCode); - downloadBlob(blob, `${(title || 'widget').replace(/[^a-zA-Z0-9\u4e00-\u9fff]/g, '_')}.png`); - } catch (e) { - console.error('[PinnableWidget] Export failed:', e); - } - }, [widgetCode, title]); - - const buttons = ( - <> - {workingDirectory && ( - - )} - - - ); - - return ( - - ); -} - /** * Memoized assistant message content — avoids re-running parseBatchPlan / parseImageGenResult / * parseImageGenRequest on every render when only unrelated props change. @@ -740,7 +636,7 @@ const AssistantContent = memo(function AssistantContent({ displayText, messageId {widgetSegments.map((seg, i) => seg.type === 'text' ? {seg.content} - : + : )} ); diff --git a/src/components/chat/StreamingMessage.tsx b/src/components/chat/StreamingMessage.tsx index d57c0355..6fae7d5f 100644 --- a/src/components/chat/StreamingMessage.tsx +++ b/src/components/chat/StreamingMessage.tsx @@ -264,30 +264,15 @@ export function StreamingMessage({ // ── Show-widget handling ── // During streaming: detect partial fences FIRST to avoid premature script execution. // After streaming: use parseAllShowWidgets for completed fences only. - const hasWidgetFence = /`{1,3}show-widget/.test(content); + const hasWidgetFence = /```show-widget/.test(content); if (hasWidgetFence && isStreaming) { - // Fence-agnostic: find the last show-widget marker - const lastMarkerMatch = [...content.matchAll(/`{1,3}show-widget/g)].pop(); - if (!lastMarkerMatch) return {content}; - - const lastFenceStart = lastMarkerMatch.index!; + // Streaming mode: find the last ```show-widget fence. + // If it's closed, all fences are complete → render them all. + // If it's open, render completed fences before it + partial preview for the open one. + const lastFenceStart = content.lastIndexOf('```show-widget'); const afterLastFence = content.slice(lastFenceStart); - // Check if JSON is complete (has matching closing brace) - const jsonStart = afterLastFence.indexOf('{'); - let lastFenceClosed = false; - if (jsonStart !== -1) { - let depth = 0, inStr = false, esc = false; - for (let i = jsonStart; i < afterLastFence.length; i++) { - const ch = afterLastFence[i]; - if (esc) { esc = false; continue; } - if (ch === '\\' && inStr) { esc = true; continue; } - if (ch === '"') { inStr = !inStr; continue; } - if (inStr) continue; - if (ch === '{') depth++; - else if (ch === '}') { depth--; if (depth === 0) { lastFenceClosed = true; break; } } - } - } + const lastFenceClosed = /```show-widget\s*\n?[\s\S]*?\n?\s*```/.test(afterLastFence); if (lastFenceClosed) { // All fences complete — parse and render the full content @@ -306,12 +291,11 @@ export function StreamingMessage({ // Last fence is still being streamed. // Parse everything BEFORE it (completed fences + interleaved text). const beforePart = content.slice(0, lastFenceStart).trim(); - const hasCompletedFences = beforePart && /`{1,3}show-widget/.test(beforePart); + const hasCompletedFences = beforePart && /```show-widget/.test(beforePart); const completedSegments = hasCompletedFences ? parseAllShowWidgets(beforePart) : []; - // Extract partial widget_code from the open fence (skip marker) - const markerEnd = afterLastFence.match(/^`{1,3}show-widget`{0,3}\s*(?:\n\s*`{3}(?:json)?\s*)?\n?/); - const fenceBody = markerEnd ? afterLastFence.slice(markerEnd[0].length).trim() : afterLastFence.trim(); + // Extract partial widget_code from the open fence + const fenceBody = content.slice(lastFenceStart + '```show-widget'.length).trim(); let partialCode: string | null = null; const keyIdx = fenceBody.indexOf('"widget_code"'); if (keyIdx !== -1) { @@ -329,7 +313,6 @@ export function StreamingMessage({ .replace(/\\t/g, '\t') .replace(/\\r/g, '\r') .replace(/\\"/g, '"') - .replace(/\\u([0-9a-fA-F]{4})/g, (_: string, hex: string) => String.fromCharCode(parseInt(hex, 16))) .replace(/\x00BACKSLASH\x00/g, '\\'); } catch { partialCode = null; } } diff --git a/src/components/chat/WidgetRenderer.tsx b/src/components/chat/WidgetRenderer.tsx index b0597b67..51e278b2 100644 --- a/src/components/chat/WidgetRenderer.tsx +++ b/src/components/chat/WidgetRenderer.tsx @@ -4,7 +4,6 @@ import { useRef, useEffect, useCallback, useState, useMemo } from 'react'; import { useTranslation } from '@/hooks/useTranslation'; import { resolveThemeVars, getWidgetIframeStyleBlock } from '@/lib/widget-css-bridge'; import { sanitizeForStreaming, sanitizeForIframe, buildReceiverSrcdoc } from '@/lib/widget-sanitizer'; -import { Code } from '@/components/ui/icon'; import { WidgetErrorBoundary } from './WidgetErrorBoundary'; interface WidgetRendererProps { @@ -13,8 +12,6 @@ interface WidgetRendererProps { title?: string; /** Show shimmer overlay (e.g. while scripts are still streaming). */ showOverlay?: boolean; - /** Extra buttons rendered alongside the "show code" button (top-right toolbar). */ - extraButtons?: React.ReactNode; } /** Max iframe height to prevent runaway widgets. */ @@ -37,7 +34,7 @@ function getHeightCacheKey(code: string): string { return code.slice(0, 200); } -function WidgetRendererInner({ widgetCode, isStreaming, title, showOverlay, extraButtons }: WidgetRendererProps) { +function WidgetRendererInner({ widgetCode, isStreaming, title, showOverlay }: WidgetRendererProps) { const { t } = useTranslation(); const iframeRef = useRef(null); const debounceRef = useRef | null>(null); @@ -49,6 +46,7 @@ function WidgetRendererInner({ widgetCode, isStreaming, title, showOverlay, extr }); const [showCode, setShowCode] = useState(false); const [finalized, setFinalized] = useState(false); + const finalizedRef = useRef(false); // If we restored from cache, treat as already having received first height const hasReceivedFirstHeight = useRef( (_heightCache.get(getHeightCacheKey(widgetCode)) || 0) > 0 @@ -156,14 +154,6 @@ function WidgetRendererInner({ widgetCode, isStreaming, title, showOverlay, extr } break; } - - case 'widget:publish': { - // Bubble up to parent window for cross-widget relay - window.dispatchEvent(new CustomEvent('widget-cross-publish', { - detail: { topic: e.data.topic, data: e.data.data, sourceIframe: iframeRef.current }, - })); - break; - } } } @@ -189,15 +179,12 @@ function WidgetRendererInner({ widgetCode, isStreaming, title, showOverlay, extr }, [widgetCode, isStreaming, iframeReady, sendUpdate]); // ── Finalize ─────────────────────────────────────────────────────────── - // Track which widgetCode was last finalized to detect prop changes (dashboard refresh). - const finalizedCodeRef = useRef(''); useEffect(() => { - if (isStreaming || !iframeReady) return; - if (finalizedCodeRef.current === widgetCode) return; // already finalized this code + if (isStreaming || !iframeReady || finalizedRef.current) return; const sanitized = sanitizeForIframe(widgetCode); const iframe = iframeRef.current; if (!iframe?.contentWindow) return; - finalizedCodeRef.current = widgetCode; + finalizedRef.current = true; lastSentRef.current = sanitized; // Lock height to prevent flash: innerHTML swap briefly empties DOM, // causing ResizeObserver to report near-zero height before scripts run. @@ -267,17 +254,12 @@ function WidgetRendererInner({ widgetCode, isStreaming, title, showOverlay, extr )} - {/* Toolbar — top-right, visible on hover */} -
- {extraButtons} - -
+ ); } diff --git a/src/components/layout/PanelZone.tsx b/src/components/layout/PanelZone.tsx index 857c5744..36b1d269 100644 --- a/src/components/layout/PanelZone.tsx +++ b/src/components/layout/PanelZone.tsx @@ -20,7 +20,6 @@ export function PanelZone() { {previewOpen && previewFile && } {gitPanelOpen && } {fileTreeOpen && } - {dashboardPanelOpen && } ); } diff --git a/src/components/ui/icon.tsx b/src/components/ui/icon.tsx index c59a61c5..16a5c038 100644 --- a/src/components/ui/icon.tsx +++ b/src/components/ui/icon.tsx @@ -92,7 +92,6 @@ export { GitCommit, CloudArrowUp, ArrowsInLineVertical, - PushPin, } from "@phosphor-icons/react"; export type { Icon, IconProps } from "@phosphor-icons/react"; diff --git a/src/components/ui/toast.tsx b/src/components/ui/toast.tsx index a9cf7dc4..974bf663 100644 --- a/src/components/ui/toast.tsx +++ b/src/components/ui/toast.tsx @@ -1,6 +1,6 @@ 'use client'; -import { X, CheckCircle, XCircle, Warning, Info, ArrowClockwise } from '@/components/ui/icon'; +import { X, CheckCircle, XCircle, Warning, Info } from '@/components/ui/icon'; import { Button } from '@/components/ui/button'; import { useToastState, type Toast } from '@/hooks/useToast'; import { cn } from '@/lib/utils'; @@ -10,7 +10,6 @@ const ICON_MAP = { error: XCircle, warning: Warning, info: Info, - loading: ArrowClockwise, }; const STYLE_MAP = { @@ -18,7 +17,6 @@ const STYLE_MAP = { error: 'border-destructive/30 bg-destructive/10 text-destructive', warning: 'border-status-warning/30 bg-status-warning-muted text-status-warning-foreground', info: 'border-border bg-muted text-foreground', - loading: 'border-border bg-muted text-foreground', }; function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: () => void }) { @@ -30,7 +28,7 @@ function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: () => void } STYLE_MAP[toast.type] )} > - + {toast.message} {toast.action && ( - {/* Error Reporting — right after Setup Center */} - - {/* Appearance */} @@ -368,46 +365,6 @@ export function GeneralSection() { - ); } - -/* ── Sentry opt-out toggle (isolated state) ──────────────────── */ - -const sentrySubscribe = (cb: () => void) => { - window.addEventListener('storage', cb); - return () => window.removeEventListener('storage', cb); -}; -const getSentryEnabled = () => { - try { return localStorage.getItem('codepilot:sentry-disabled') !== 'true'; } catch { return true; } -}; -const getSentryEnabledServer = () => true; // SSR default - -function SentryToggle({ locale, t }: { locale: string; t: (key: TranslationKey) => string }) { - const enabled = useSyncExternalStore(sentrySubscribe, getSentryEnabled, getSentryEnabledServer); - - return ( - - { - const disabled = !checked; - try { - localStorage.setItem('codepilot:sentry-disabled', disabled ? 'true' : 'false'); - window.dispatchEvent(new StorageEvent('storage')); - } catch { /* ignore */ } - fetch('/api/settings/sentry', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ disabled }), - }).catch(() => { /* ignore */ }); - }} - /> - - ); -} diff --git a/src/components/settings/PresetConnectDialog.tsx b/src/components/settings/PresetConnectDialog.tsx index 482bef61..e08fb841 100644 --- a/src/components/settings/PresetConnectDialog.tsx +++ b/src/components/settings/PresetConnectDialog.tsx @@ -20,7 +20,7 @@ import { DialogDescription, DialogFooter, } from "@/components/ui/dialog"; -import { SpinnerGap, CaretDown, CaretUp, ArrowSquareOut, CheckCircle, XCircle, Warning, Lightning } from "@/components/ui/icon"; +import { SpinnerGap, CaretDown, CaretUp } from "@/components/ui/icon"; import type { ProviderFormData } from "./ProviderForm"; import type { QuickPreset } from "./provider-presets"; import { QUICK_PRESETS } from "./provider-presets"; @@ -28,7 +28,7 @@ import type { ApiProvider } from "@/types"; import { useTranslation } from "@/hooks/useTranslation"; import type { TranslationKey } from "@/i18n"; -/** Infer auth style from base URL by fuzzy-matching preset hostnames */ +/** Infer auth style from base URL by fuzzy-matching VENDOR_PRESETS hostnames */ function inferAuthStyleFromUrl(url: string): "api_key" | "auth_token" | null { if (!url) return null; const urlLower = url.toLowerCase(); @@ -37,7 +37,10 @@ function inferAuthStyleFromUrl(url: string): "api_key" | "auth_token" | null { try { const presetHost = new URL(p.base_url).hostname; if (urlLower.includes(presetHost)) { - return p.authStyle as "api_key" | "auth_token"; + // Map preset's known auth style + const env = JSON.parse(p.extra_env || '{}'); + if ('ANTHROPIC_AUTH_TOKEN' in env) return 'auth_token'; + return 'api_key'; } } catch { /* skip invalid URLs */ } } @@ -81,65 +84,26 @@ export function PresetConnectDialog({ const [showAdvanced, setShowAdvanced] = useState(false); const [error, setError] = useState(null); const [saving, setSaving] = useState(false); - const [testing, setTesting] = useState(false); - const [testResult, setTestResult] = useState<{ success: boolean; error?: { code: string; message: string; suggestion: string; recoveryActions?: Array<{ label: string; url?: string; action?: string }> } } | null>(null); const { t } = useTranslation(); const isZh = t('nav.chats') === '对话'; - const handleTestConnection = async () => { - setTesting(true); - setTestResult(null); - try { - const envOverrides: Record = {}; - try { - const parsed = JSON.parse(extraEnv || '{}'); - Object.assign(envOverrides, parsed); - } catch { /* ignore */ } - const res = await fetch('/api/providers/test', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - presetKey: preset?.key, - apiKey: apiKey || undefined, - baseUrl: baseUrl || preset?.base_url || '', - protocol: preset?.protocol || 'anthropic', - authStyle: preset?.key === 'anthropic-thirdparty' ? authStyle : (preset?.authStyle || authStyle), - envOverrides, - modelName: modelName || undefined, - providerName: name || preset?.name, - }), - }); - const data = await res.json(); - setTestResult(data); - } catch (err) { - setTestResult({ success: false, error: { code: 'NETWORK_ERROR', message: 'Failed to reach test endpoint', suggestion: 'Check if the app is running' } }); - } finally { - setTesting(false); - } - }; - // Reset form when dialog opens useEffect(() => { if (!open || !preset) return; setError(null); setSaving(false); - setTesting(false); - setTestResult(null); if (isEdit && editProvider) { // Edit mode — pre-fill from existing provider setName(editProvider.name); setBaseUrl(editProvider.base_url); setExtraEnv(editProvider.extra_env || preset.extra_env); - // Use preset authStyle as source of truth; fall back to extra_env inference for legacy records - let detected: 'auth_token' | 'api_key' = preset.authStyle === 'auth_token' ? 'auth_token' : 'api_key'; - if (preset.key === 'anthropic-thirdparty') { - // Thirdparty presets: infer from stored extra_env since user chose the style - try { - const env = JSON.parse(editProvider.extra_env || "{}"); - detected = "ANTHROPIC_AUTH_TOKEN" in env ? "auth_token" : "api_key"; - } catch { /* keep preset default */ } - } + // Detect auth style from existing extra_env + let detected: 'auth_token' | 'api_key' = 'api_key'; + try { + const env = JSON.parse(editProvider.extra_env || "{}"); + detected = "ANTHROPIC_AUTH_TOKEN" in env ? "auth_token" : "api_key"; + } catch { /* keep default */ } setAuthStyle(detected); setInitialAuthStyle(detected); // If api_key field isn't shown and stored key is empty, use preset default @@ -196,12 +160,12 @@ export function PresetConnectDialog({ setName(preset.name); setExtraEnv(preset.extra_env); setModelName(""); - // Use authStyle directly from preset (single source of truth) - const detectedStyle = (preset.authStyle === 'auth_token' ? 'auth_token' : 'api_key') as 'api_key' | 'auth_token'; + // Auto-detect auth style from preset's extra_env + const presetEnv = (() => { try { return JSON.parse(preset.extra_env || '{}'); } catch { return {}; } })(); + const detectedStyle = 'ANTHROPIC_AUTH_TOKEN' in presetEnv ? 'auth_token' as const : 'api_key' as const; // If preset doesn't expose api_key field, pre-fill from extra_env default // (e.g. Ollama needs ANTHROPIC_AUTH_TOKEN='ollama' without user input) if (!preset.fields.includes("api_key")) { - const presetEnv = (() => { try { return JSON.parse(preset.extra_env || '{}'); } catch { return {}; } })(); const defaultToken = detectedStyle === 'auth_token' ? (presetEnv['ANTHROPIC_AUTH_TOKEN'] || '') : (presetEnv['ANTHROPIC_API_KEY'] || ''); @@ -350,46 +314,6 @@ export function PresetConnectDialog({ - {/* Meta info panel — API key link, billing badge, notes */} - {preset.meta && ( -
-
- {preset.meta.billingModel && ( - - {preset.meta.billingModel === 'pay_as_you_go' ? (isZh ? '按量付费' : 'Pay-as-you-go') - : preset.meta.billingModel === 'coding_plan' ? 'Coding Plan' - : preset.meta.billingModel === 'token_plan' ? 'Token Plan' - : preset.meta.billingModel === 'free' ? (isZh ? '免费' : 'Free') - : preset.meta.billingModel === 'self_hosted' ? (isZh ? '自托管' : 'Self-hosted') - : preset.meta.billingModel} - - )} - {preset.meta.apiKeyUrl && ( - - - {isZh ? '获取 API Key' : 'Get API Key'} - - )} - - - {isZh ? '配置指南' : 'Setup Guide'} - -
- {preset.meta.notes && preset.meta.notes.length > 0 && ( -
- {preset.meta.notes.map((note, i) => ( -

- - {note} -

- ))} -
- )} -
- )} -
{/* Name field — custom/thirdparty */} {preset.fields.includes("name") && ( @@ -622,67 +546,19 @@ export function PresetConnectDialog({ {error &&

{error}

} - {/* Connection test result */} - {testResult && (() => { - const isSkipped = testResult.error?.code === 'SKIPPED'; - const bgClass = testResult.success - ? 'bg-emerald-500/10 border border-emerald-500/20' // lint-allow-raw-color - : isSkipped - ? 'bg-muted border border-border' - : 'bg-destructive/10 border border-destructive/20'; - return ( -
-
- {testResult.success - ? <>{/* lint-allow-raw-color */}{/* lint-allow-raw-color */}{isZh ? '连接成功' : 'Connection successful'} - : isSkipped - ? <>{isZh ? '此服务商类型无法进行连接测试,请保存配置后发送消息验证' : 'Connection test not available for this provider type'} - : <>{testResult.error?.message || 'Connection failed'} - } -
- {!testResult.success && !isSkipped && testResult.error?.suggestion && ( -

{testResult.error.suggestion}

- )} - {!testResult.success && !isSkipped && testResult.error?.recoveryActions && testResult.error.recoveryActions.length > 0 && ( -
- {testResult.error.recoveryActions.filter(a => a.url).map((action, i) => ( - - - {action.label} - - ))} -
- )} -
- ); - })()} - - + -
- - -
+
diff --git a/src/components/settings/ProviderManager.tsx b/src/components/settings/ProviderManager.tsx index cbb844de..3ba1cefc 100644 --- a/src/components/settings/ProviderManager.tsx +++ b/src/components/settings/ProviderManager.tsx @@ -378,7 +378,7 @@ export function ProviderManager() { {provider.name} {provider.api_key - ? (findMatchingPreset(provider)?.authStyle === 'auth_token' ? "Auth Token" : "API Key") + ? (provider.extra_env?.includes("ANTHROPIC_AUTH_TOKEN") ? "Auth Token" : "API Key") : t('provider.configured')} diff --git a/src/components/settings/provider-presets.tsx b/src/components/settings/provider-presets.tsx index 198644fd..5265a261 100644 --- a/src/components/settings/provider-presets.tsx +++ b/src/components/settings/provider-presets.tsx @@ -3,8 +3,6 @@ import type { ReactNode } from "react"; import { HardDrives } from "@/components/ui/icon"; import type { ApiProvider } from "@/types"; -import { VENDOR_PRESETS } from "@/lib/provider-catalog"; -import type { VendorPreset } from "@/lib/provider-catalog"; import Anthropic from "@lobehub/icons/es/Anthropic"; import OpenRouter from "@lobehub/icons/es/OpenRouter"; import Zhipu from "@lobehub/icons/es/Zhipu"; @@ -50,71 +48,251 @@ export function getProviderIcon(name: string, baseUrl: string): ReactNode { } // --------------------------------------------------------------------------- -// Quick-add preset definitions — generated from VENDOR_PRESETS (single source of truth) +// Quick-add preset definitions // --------------------------------------------------------------------------- export interface QuickPreset { - key: string; + key: string; // unique key name: string; description: string; descriptionZh: string; icon: ReactNode; + // Pre-filled provider data provider_type: string; + /** Wire protocol — determines how the provider is dispatched at runtime */ protocol: string; - /** Auth style from catalog — frontend should use this instead of inferring from extra_env */ - authStyle: string; base_url: string; extra_env: string; + // Which fields user must fill fields: ("name" | "api_key" | "base_url" | "extra_env" | "model_names" | "model_mapping")[]; + // Category: 'chat' (default) or 'media' category?: "chat" | "media"; - /** Provider meta info from catalog (for user guidance) */ - meta?: VendorPreset['meta']; } -/** Map iconKey from VENDOR_PRESETS to React icon component */ -function resolveIcon(iconKey: string): ReactNode { - const ICON_MAP: Record = { - anthropic: , - openrouter: , - zhipu: , - kimi: , - moonshot: , - minimax: , - bedrock: , - google: , - volcengine: , - bailian: , - 'xiaomi-mimo': , - ollama: , - server: , - }; - return ICON_MAP[iconKey] || ; -} - -/** Convert a VendorPreset to the frontend QuickPreset format */ -function toQuickPreset(vp: VendorPreset): QuickPreset { - return { - key: vp.key, - name: vp.name, - description: vp.description, - descriptionZh: vp.descriptionZh, - icon: resolveIcon(vp.iconKey), - provider_type: vp.protocol === 'openrouter' ? 'openrouter' - : vp.protocol === 'bedrock' ? 'bedrock' - : vp.protocol === 'vertex' ? 'vertex' - : vp.protocol === 'gemini-image' ? 'gemini-image' - : 'anthropic', - protocol: vp.protocol, - authStyle: vp.authStyle, - base_url: vp.baseUrl, - extra_env: JSON.stringify(vp.defaultEnvOverrides), - fields: vp.fields as QuickPreset['fields'], - category: vp.category, - meta: vp.meta, - }; -} - -export const QUICK_PRESETS: QuickPreset[] = VENDOR_PRESETS.map(toQuickPreset); +export const QUICK_PRESETS: QuickPreset[] = [ + // ── Anthropic-compatible services ── + { + key: "anthropic-thirdparty", + name: "Anthropic Third-party API", + description: "Anthropic-compatible API — provide URL and Key", + descriptionZh: "Anthropic 兼容第三方 API — 填写地址和密钥", + icon: , + provider_type: "anthropic", + protocol: "anthropic", + base_url: "", + extra_env: '{"ANTHROPIC_API_KEY":""}', + fields: ["name", "api_key", "base_url", "model_mapping"], + }, + { + key: "anthropic-official", + name: "Anthropic", + description: "Official Anthropic API", + descriptionZh: "Anthropic 官方 API", + icon: , + provider_type: "anthropic", + protocol: "anthropic", + base_url: "https://api.anthropic.com", + extra_env: "{}", + fields: ["api_key"], + }, + { + key: "openrouter", + name: "OpenRouter", + description: "Use OpenRouter to access multiple models", + descriptionZh: "通过 OpenRouter 访问多种模型", + icon: , + provider_type: "openrouter", + protocol: "openrouter", + base_url: "https://openrouter.ai/api", + extra_env: '{"ANTHROPIC_API_KEY":""}', + fields: ["api_key"], + }, + { + key: "glm-cn", + name: "GLM (CN)", + description: "Zhipu GLM Code Plan — China region", + descriptionZh: "智谱 GLM 编程套餐 — 中国区", + icon: , + provider_type: "anthropic", + protocol: "anthropic", + base_url: "https://open.bigmodel.cn/api/anthropic", + extra_env: '{"API_TIMEOUT_MS":"3000000","ANTHROPIC_API_KEY":""}', + fields: ["api_key"], + }, + { + key: "glm-global", + name: "GLM (Global)", + description: "Zhipu GLM Code Plan — Global region", + descriptionZh: "智谱 GLM 编程套餐 — 国际区", + icon: , + provider_type: "anthropic", + protocol: "anthropic", + base_url: "https://api.z.ai/api/anthropic", + extra_env: '{"API_TIMEOUT_MS":"3000000","ANTHROPIC_API_KEY":""}', + fields: ["api_key"], + }, + { + key: "kimi", + name: "Kimi Coding Plan", + description: "Kimi Coding Plan API", + descriptionZh: "Kimi 编程计划 API", + icon: , + provider_type: "anthropic", + protocol: "anthropic", + base_url: "https://api.kimi.com/coding/", + extra_env: '{"ANTHROPIC_AUTH_TOKEN":""}', + fields: ["api_key"], + }, + { + key: "moonshot", + name: "Moonshot", + description: "Moonshot AI API", + descriptionZh: "月之暗面 API", + icon: , + provider_type: "anthropic", + protocol: "anthropic", + base_url: "https://api.moonshot.cn/anthropic", + extra_env: '{"ANTHROPIC_API_KEY":""}', + fields: ["api_key"], + }, + { + key: "minimax-cn", + name: "MiniMax (CN)", + description: "MiniMax Code Plan — China region", + descriptionZh: "MiniMax 编程套餐 — 中国区", + icon: , + provider_type: "anthropic", + protocol: "anthropic", + base_url: "https://api.minimaxi.com/anthropic", + extra_env: '{"API_TIMEOUT_MS":"3000000","CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC":"1","ANTHROPIC_AUTH_TOKEN":""}', + fields: ["api_key"], + }, + { + key: "minimax-global", + name: "MiniMax (Global)", + description: "MiniMax Code Plan — Global region", + descriptionZh: "MiniMax 编程套餐 — 国际区", + icon: , + provider_type: "anthropic", + protocol: "anthropic", + base_url: "https://api.minimax.io/anthropic", + extra_env: '{"API_TIMEOUT_MS":"3000000","CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC":"1","ANTHROPIC_AUTH_TOKEN":""}', + fields: ["api_key"], + }, + { + key: "volcengine", + name: "Volcengine Ark", + description: "Volcengine Ark Coding Plan — Doubao, GLM, DeepSeek, Kimi", + descriptionZh: "字节火山方舟 Coding Plan — 豆包、GLM、DeepSeek、Kimi", + icon: , + provider_type: "anthropic", + protocol: "anthropic", + base_url: "https://ark.cn-beijing.volces.com/api/coding", + extra_env: '{"ANTHROPIC_AUTH_TOKEN":""}', + fields: ["api_key", "model_names"], + }, + { + key: "xiaomi-mimo", + name: "Xiaomi MiMo", + description: "Xiaomi MiMo Pay-as-you-go API — MiMo-V2-Pro", + descriptionZh: "小米 MiMo 按量付费 — MiMo-V2-Pro", + icon: , + provider_type: "anthropic", + protocol: "anthropic", + base_url: "https://api.xiaomimimo.com/anthropic", + extra_env: '{"ANTHROPIC_AUTH_TOKEN":""}', + fields: ["api_key"], + }, + { + key: "xiaomi-mimo-token-plan", + name: "Xiaomi MiMo Token Plan", + description: "Xiaomi MiMo Token Plan subscription — MiMo-V2-Pro", + descriptionZh: "小米 MiMo Token Plan 订阅套餐 — MiMo-V2-Pro", + icon: , + provider_type: "anthropic", + protocol: "anthropic", + base_url: "https://token-plan-cn.xiaomimimo.com/anthropic", + extra_env: '{"ANTHROPIC_AUTH_TOKEN":""}', + fields: ["api_key"], + }, + { + key: "bailian", + name: "Aliyun Bailian", + description: "Aliyun Bailian Coding Plan — Qwen, GLM, Kimi, MiniMax", + descriptionZh: "阿里云百炼 Coding Plan — 通义千问、GLM、Kimi、MiniMax", + icon: , + provider_type: "anthropic", + protocol: "anthropic", + base_url: "https://coding.dashscope.aliyuncs.com/apps/anthropic", + extra_env: '{"ANTHROPIC_API_KEY":""}', + fields: ["api_key"], + }, + // ── Cloud platform providers ── + { + key: "bedrock", + name: "AWS Bedrock", + description: "Amazon Bedrock — requires AWS credentials", + descriptionZh: "Amazon Bedrock — 需要 AWS 凭证", + icon: , + provider_type: "bedrock", + protocol: "bedrock", + base_url: "", + extra_env: '{"CLAUDE_CODE_USE_BEDROCK":"1","AWS_REGION":"us-east-1","CLAUDE_CODE_SKIP_BEDROCK_AUTH":"1"}', + fields: ["extra_env"], + }, + { + key: "vertex", + name: "Google Vertex", + description: "Google Vertex AI — requires GCP credentials", + descriptionZh: "Google Vertex AI — 需要 GCP 凭证", + icon: , + provider_type: "vertex", + protocol: "vertex", + base_url: "", + extra_env: '{"CLAUDE_CODE_USE_VERTEX":"1","CLOUD_ML_REGION":"us-east5","CLAUDE_CODE_SKIP_VERTEX_AUTH":"1"}', + fields: ["extra_env"], + }, + // ── Local / self-hosted ── + { + key: "ollama", + name: "Ollama", + description: "Ollama — run local models with Anthropic-compatible API", + descriptionZh: "Ollama — 本地运行模型,Anthropic 兼容 API", + icon: , + provider_type: "anthropic", + protocol: "anthropic", + base_url: "http://localhost:11434", + extra_env: '{"ANTHROPIC_AUTH_TOKEN":"ollama","ANTHROPIC_API_KEY":""}', + fields: ["base_url", "model_names"], + }, + // ── Proxy / gateway ── + { + key: "litellm", + name: "LiteLLM", + description: "LiteLLM proxy — local or remote", + descriptionZh: "LiteLLM 代理 — 本地或远程", + icon: , + provider_type: "anthropic", + protocol: "anthropic", + base_url: "http://localhost:4000", + extra_env: "{}", + fields: ["api_key", "base_url"], + }, + // ── Media providers ── + { + key: "gemini-image", + name: "Google Gemini (Image)", + description: "Nano Banana Pro — AI image generation by Google Gemini", + descriptionZh: "Nano Banana Pro — Google Gemini AI 图片生成", + icon: , + provider_type: "gemini-image", + protocol: "gemini-image", + base_url: "https://generativelanguage.googleapis.com/v1beta", + extra_env: '{"GEMINI_API_KEY":""}', + fields: ["api_key"], + category: "media", + }, +]; // --------------------------------------------------------------------------- // Gemini image model definitions diff --git a/src/hooks/useSSEStream.ts b/src/hooks/useSSEStream.ts index 6fbf1b28..7e5e5d98 100644 --- a/src/hooks/useSSEStream.ts +++ b/src/hooks/useSSEStream.ts @@ -211,22 +211,15 @@ function handleSSEEvent( if (parsed.details) { errorDisplay += `\n\nDetails: ${parsed.details}`; } - // Render recovery actions as markdown links - if (parsed.recoveryActions && parsed.recoveryActions.length > 0) { - const links: string[] = []; - for (const a of parsed.recoveryActions as Array<{ label: string; url?: string; action?: string }>) { - if (a.url) { - links.push(`[${a.label}](${a.url})`); - } else if (a.action === 'open_settings') { - links.push(`[${a.label}](/settings#providers)`); - } else if (a.action === 'new_conversation') { - links.push(`[${a.label}](/chat)`); - } - // 'retry' is handled by the retryable flag, not as a link - } - if (links.length > 0) { - errorDisplay += '\n\n' + links.join(' · '); - } + // Add diagnostic guidance for provider/auth related errors + const diagCategories = new Set([ + 'AUTH_REJECTED', 'AUTH_FORBIDDEN', 'AUTH_STYLE_MISMATCH', + 'NO_CREDENTIALS', 'PROVIDER_NOT_APPLIED', 'MODEL_NOT_AVAILABLE', + 'NETWORK_UNREACHABLE', 'ENDPOINT_NOT_FOUND', 'PROCESS_CRASH', + 'CLI_NOT_FOUND', 'UNSUPPORTED_FEATURE', + ]); + if (diagCategories.has(parsed.category)) { + errorDisplay += '\n\n💡 [Run Provider Diagnostics](/settings#providers) to troubleshoot, or check the [Provider Setup Guide](https://www.codepilot.sh/docs/providers).'; } } else { errorDisplay = event.data; diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 43ffa4d0..21f72ca1 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -96,8 +96,6 @@ const en = { 'settings.autoApproveTrustWarning': 'Only enable this if you fully trust the task at hand. This setting applies to all new chat sessions.', 'settings.cancel': 'Cancel', 'settings.enableAutoApprove': 'Enable Auto-approve', - 'settings.errorReporting': 'Anonymous Error Reporting', - 'settings.errorReportingDesc': 'Help improve CodePilot by anonymously reporting errors. No conversation content or API keys are included. Restart the app for the change to fully take effect.', 'settings.generativeUITitle': 'Generative UI', 'settings.generativeUIDesc': 'Enable interactive visualizations (charts, diagrams, mockups) in chat responses. Disabling saves tokens but removes visual generation capability.', 'settings.defaultPanelTitle': 'Default Side Panel', diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index e889895f..037853b6 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -93,8 +93,6 @@ const zh: Record = { 'settings.autoApproveTrustWarning': '仅在您完全信任当前任务时才启用此选项。此设置适用于所有新的聊天会话。', 'settings.cancel': '取消', 'settings.enableAutoApprove': '启用自动批准', - 'settings.errorReporting': '匿名错误上报', - 'settings.errorReportingDesc': '帮助改进 CodePilot:匿名上报错误信息,不包含对话内容和 API Key。更改后需重启应用才能完全生效。', 'settings.generativeUITitle': '生成式 UI', 'settings.generativeUIDesc': '启用聊天中的交互式可视化功能(图表、流程图、原型图等)。关闭后可节省 token,但将无法生成可视化内容。', 'settings.defaultPanelTitle': '默认侧边面板', diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 305be338..2049a374 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -4,48 +4,6 @@ */ export async function register() { if (process.env.NEXT_RUNTIME === 'nodejs') { - // Initialize Sentry for server-side error capture (respects opt-out marker file) - const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN; - if (dsn) { - const fs = await import('fs'); - const path = await import('path'); - const os = await import('os'); - const markerPath = path.join(os.homedir(), '.codepilot', 'sentry-disabled'); - const optedOut = fs.existsSync(markerPath) && fs.readFileSync(markerPath, 'utf-8').trim() === 'true'; - if (!optedOut) { - const Sentry = await import('@sentry/node'); - Sentry.init({ - dsn, - environment: process.env.NODE_ENV, - release: `codepilot@${process.env.NEXT_PUBLIC_APP_VERSION}`, - tracesSampleRate: 0, - ignoreErrors: [ - 'AbortError', - 'Operation aborted', - 'The operation was aborted', - 'signal is aborted', - ], - beforeSend(event) { - // Strip auth headers - if (event.request?.headers) { - delete event.request.headers['x-api-key']; - delete event.request.headers['authorization']; - delete event.request.headers['anthropic-api-key']; - } - // Add server context - event.tags = { - ...event.tags, - runtime: 'server', - 'os.platform': process.platform, - 'os.arch': process.arch, - 'node.version': process.version, - }; - return event; - }, - }); - } - } - const { initRuntimeLog } = await import('@/lib/runtime-log'); initRuntimeLog(); diff --git a/src/lib/claude-client.ts b/src/lib/claude-client.ts index 81b6b053..d779e8e8 100644 --- a/src/lib/claude-client.ts +++ b/src/lib/claude-client.ts @@ -1336,11 +1336,6 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream; - }; -} - -/** - * Test a provider connection by sending a direct HTTP request to the API endpoint. - * Bypasses the Claude Code SDK subprocess entirely to avoid false positives - * from keychain/OAuth credentials leaking into the test. - */ -export async function testProviderConnection(config: { - apiKey: string; - baseUrl: string; - protocol: string; - authStyle: string; - envOverrides?: Record; - modelName?: string; - presetKey?: string; - providerName?: string; - providerMeta?: { apiKeyUrl?: string; docsUrl?: string; pricingUrl?: string }; -}): Promise { - const { getPreset, findPresetForLegacy } = await import('./provider-catalog'); - - // Look up preset for default model - const preset = config.presetKey - ? getPreset(config.presetKey) - : (config.baseUrl ? findPresetForLegacy(config.baseUrl, 'custom', config.protocol as import('./provider-catalog').Protocol) : undefined); - - // Determine model to use in test request - const model = config.modelName - || preset?.defaultRoleModels?.default - || (preset?.defaultModels?.[0]?.upstreamModelId || preset?.defaultModels?.[0]?.modelId) - || 'sonnet'; - - // For bedrock/vertex/env_only protocols, we can't do a simple HTTP test - if (config.protocol === 'bedrock' || config.protocol === 'vertex' || config.authStyle === 'env_only') { - return { - success: false, - error: { code: 'SKIPPED', message: 'Cloud providers (Bedrock/Vertex) require IAM or OAuth credentials — connection test is not available for this provider type', suggestion: 'Save the configuration and send a message to verify' }, - }; - } - - // Build the API URL — Anthropic-compatible endpoint - let apiUrl = config.baseUrl || 'https://api.anthropic.com'; - // Ensure URL ends with /v1/messages for Anthropic-compatible providers - if (!apiUrl.endsWith('/v1/messages')) { - apiUrl = apiUrl.replace(/\/+$/, ''); - if (!apiUrl.endsWith('/v1')) { - apiUrl += '/v1'; - } - apiUrl += '/messages'; - } - - // Build headers based on auth style - const headers: Record = { - 'Content-Type': 'application/json', - 'anthropic-version': '2023-06-01', - }; - if (config.authStyle === 'auth_token') { - headers['Authorization'] = `Bearer ${config.apiKey}`; - } else { - headers['x-api-key'] = config.apiKey; - } - - // Minimal request body — just enough to verify auth + endpoint - const body = JSON.stringify({ - model, - max_tokens: 1, - messages: [{ role: 'user', content: 'ping' }], - }); - - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 15_000); - - try { - const response = await fetch(apiUrl, { - method: 'POST', - headers, - body, - signal: controller.signal, - }); - clearTimeout(timeoutId); - - // 2xx = success (even if model returns an error in body, auth works) - if (response.ok) { - return { success: true }; - } - - // Parse error response - let errorBody = ''; - try { errorBody = await response.text(); } catch { /* ignore */ } - - const classified = classifyError({ - error: new Error(`HTTP ${response.status}: ${errorBody.slice(0, 500)}`), - providerName: config.providerName, - baseUrl: config.baseUrl, - providerMeta: config.providerMeta, - }); - - return { - success: false, - error: { - code: classified.category, - message: classified.userMessage, - suggestion: classified.actionHint, - recoveryActions: classified.recoveryActions, - }, - }; - } catch (err) { - clearTimeout(timeoutId); - - // Network errors (ECONNREFUSED, ENOTFOUND, timeout, etc.) - const classified = classifyError({ - error: err, - providerName: config.providerName, - baseUrl: config.baseUrl, - providerMeta: config.providerMeta, - }); - - return { - success: false, - error: { - code: classified.category, - message: classified.userMessage, - suggestion: classified.actionHint, - recoveryActions: classified.recoveryActions, - }, - }; - } -} diff --git a/src/lib/error-classifier.ts b/src/lib/error-classifier.ts index 174027dd..2e776f96 100644 --- a/src/lib/error-classifier.ts +++ b/src/lib/error-classifier.ts @@ -5,38 +5,6 @@ * classifier that produces actionable, user-facing error messages. */ -// ── Sentry integration (lazy, no-op when unavailable) ─────────── - -const SENTRY_REPORTABLE: Set = new Set([ - // PROCESS_CRASH removed — too noisy (136+ events/day), mostly user config issues - 'UNKNOWN', 'CLI_NOT_FOUND', 'CLI_INSTALL_CONFLICT', - 'MISSING_GIT_BASH', 'PROVIDER_NOT_APPLIED', 'SESSION_STATE_ERROR', -]); - -function reportToSentry(category: string, error: unknown, extra?: Record) { - if (!SENTRY_REPORTABLE.has(category)) return; - // Skip aborted operations — these are user-initiated cancellations - const msg = error instanceof Error ? error.message : String(error); - if (/abort|cancel/i.test(msg)) return; - - // Fire-and-forget async import — never blocks the classifier - import('@sentry/node').then((Sentry) => { - Sentry.withScope((scope) => { - scope.setTag('error.category', category); - scope.setTag('error.provider', (extra?.providerName as string) || 'unknown'); - if (extra?.baseUrl) scope.setTag('provider.baseUrl', extra.baseUrl as string); - if (extra) scope.setExtras(extra); - // Add the raw error message as fingerprint component for better grouping - scope.setFingerprint([category, msg.slice(0, 100)]); - if (error instanceof Error) { - Sentry.captureException(error); - } else { - Sentry.captureMessage(String(error), 'error'); - } - }); - }).catch(() => { /* Sentry not available */ }); -} - // ── Error categories ──────────────────────────────────────────── export type ClaudeErrorCategory = @@ -60,16 +28,6 @@ export type ClaudeErrorCategory = | 'PROCESS_CRASH' | 'UNKNOWN'; -/** A concrete action the user can take to recover from an error */ -export interface RecoveryAction { - /** Button label */ - label: string; - /** URL to open (external link) */ - url?: string; - /** Internal action type */ - action?: 'open_settings' | 'retry' | 'new_conversation'; -} - export interface ClassifiedError { category: ClaudeErrorCategory; /** User-facing message explaining what went wrong */ @@ -84,8 +42,6 @@ export interface ClassifiedError { details?: string; /** Whether this error is likely transient and retryable */ retryable: boolean; - /** Structured recovery actions for UI buttons */ - recoveryActions?: RecoveryAction[]; } // ── Classification context ────────────────────────────────────── @@ -107,12 +63,6 @@ export interface ErrorContext { context1mEnabled?: boolean; /** Whether effort was set */ effortSet?: boolean; - /** Provider meta info from preset (for recovery action URLs) */ - providerMeta?: { - apiKeyUrl?: string; - docsUrl?: string; - pricingUrl?: string; - }; } // ── Pattern definitions ───────────────────────────────────────── @@ -368,7 +318,6 @@ export function classifyError(ctx: ErrorContext): ClassifiedError { providerName: ctx.providerName, details: extraDetail || undefined, retryable: true, - recoveryActions: buildRecoveryActions('SESSION_STATE_ERROR', ctx), }; } } @@ -385,73 +334,23 @@ export function classifyError(ctx: ErrorContext): ClassifiedError { providerName: ctx.providerName, details: extraDetail || undefined, retryable: false, - recoveryActions: buildRecoveryActions('UNKNOWN', ctx), }; } -function buildRecoveryActions(category: ClaudeErrorCategory, ctx: ErrorContext): RecoveryAction[] { - const actions: RecoveryAction[] = []; - const meta = ctx.providerMeta; - - switch (category) { - case 'AUTH_REJECTED': - case 'AUTH_FORBIDDEN': - case 'AUTH_STYLE_MISMATCH': - case 'NO_CREDENTIALS': - if (meta?.apiKeyUrl) actions.push({ label: 'Get API Key', url: meta.apiKeyUrl }); - actions.push({ label: 'Open Settings', action: 'open_settings' }); - break; - case 'RATE_LIMITED': - actions.push({ label: 'Retry', action: 'retry' }); - if (meta?.pricingUrl) actions.push({ label: 'Upgrade Plan', url: meta.pricingUrl }); - break; - case 'MODEL_NOT_AVAILABLE': - case 'ENDPOINT_NOT_FOUND': - case 'NETWORK_UNREACHABLE': - if (meta?.docsUrl) actions.push({ label: 'View Docs', url: meta.docsUrl }); - actions.push({ label: 'Open Settings', action: 'open_settings' }); - break; - case 'RESUME_FAILED': - case 'SESSION_STATE_ERROR': - actions.push({ label: 'New Conversation', action: 'new_conversation' }); - break; - case 'PROCESS_CRASH': - if (meta?.apiKeyUrl) actions.push({ label: 'Check API Key', url: meta.apiKeyUrl }); - if (meta?.docsUrl) actions.push({ label: 'View Docs', url: meta.docsUrl }); - actions.push({ label: 'Open Settings', action: 'open_settings' }); - break; - default: - actions.push({ label: 'Open Settings', action: 'open_settings' }); - break; - } - - return actions; -} - function buildResult( pattern: ErrorPattern, ctx: ErrorContext, rawMessage: string, extraDetail: string, ): ClassifiedError { - const category = pattern.category; - - // Report severe errors to Sentry (non-blocking, ignores expected errors like RATE_LIMITED) - reportToSentry(category, ctx.error, { - providerName: ctx.providerName, - baseUrl: ctx.baseUrl, - rawMessage, - }); - return { - category, + category: pattern.category, userMessage: pattern.userMessage(ctx), actionHint: pattern.actionHint(ctx), rawMessage, providerName: ctx.providerName, details: extraDetail || undefined, retryable: pattern.retryable, - recoveryActions: buildRecoveryActions(category, ctx), }; } @@ -485,6 +384,5 @@ export function serializeClassifiedError(err: ClassifiedError): string { providerName: err.providerName, details: err.details, rawMessage: err.rawMessage, - recoveryActions: err.recoveryActions, }); } diff --git a/src/lib/provider-catalog.ts b/src/lib/provider-catalog.ts index 6b07174f..aaa55315 100644 --- a/src/lib/provider-catalog.ts +++ b/src/lib/provider-catalog.ts @@ -6,11 +6,8 @@ * - Default env overrides each vendor needs for Claude Code SDK * - Default model catalogs (role → upstream model id mapping) * - Auth key injection style (ANTHROPIC_API_KEY vs ANTHROPIC_AUTH_TOKEN) - * - Provider meta info (API key URLs, docs, billing model, notes) */ -import { z } from 'zod'; - // ── Protocol types ────────────────────────────────────────────── /** @@ -112,78 +109,8 @@ export interface VendorPreset { * Anthropic Messages API. */ sdkProxyOnly?: boolean; - /** Provider meta info for user guidance and error recovery */ - meta?: { - /** URL where user can obtain/manage API key */ - apiKeyUrl?: string; - /** Official configuration documentation URL */ - docsUrl?: string; - /** Pricing page URL */ - pricingUrl?: string; - /** Service status page URL */ - statusPageUrl?: string; - /** Billing model */ - billingModel: 'pay_as_you_go' | 'coding_plan' | 'token_plan' | 'free' | 'self_hosted'; - /** Notes/warnings shown during provider configuration */ - notes?: string[]; - }; } -// ── Zod Schema for preset validation ────────────────────────────── - -const PresetMetaSchema = z.object({ - apiKeyUrl: z.string().optional(), - docsUrl: z.string().optional(), - pricingUrl: z.string().optional(), - statusPageUrl: z.string().optional(), - billingModel: z.enum(['pay_as_you_go', 'coding_plan', 'token_plan', 'free', 'self_hosted']), - notes: z.array(z.string()).optional(), -}); - -export const PresetSchema = z.object({ - key: z.string().min(1), - name: z.string().min(1), - description: z.string(), - descriptionZh: z.string(), - protocol: z.enum(['anthropic', 'openai-compatible', 'openrouter', 'bedrock', 'vertex', 'google', 'gemini-image']), - authStyle: z.enum(['api_key', 'auth_token', 'env_only', 'custom_header']), - baseUrl: z.string(), - defaultEnvOverrides: z.record(z.string(), z.string()), - defaultModels: z.array(z.object({ - modelId: z.string(), - upstreamModelId: z.string().optional(), - displayName: z.string(), - role: z.enum(['default', 'reasoning', 'small', 'haiku', 'sonnet', 'opus']).optional(), - capabilities: z.object({ - reasoning: z.boolean().optional(), - toolUse: z.boolean().optional(), - vision: z.boolean().optional(), - pdf: z.boolean().optional(), - contextWindow: z.number().optional(), - }).optional(), - })), - fields: z.array(z.string()), - iconKey: z.string(), - sdkProxyOnly: z.boolean().optional(), - category: z.enum(['chat', 'media']).optional(), - defaultRoleModels: z.record(z.string(), z.string()).optional(), - meta: PresetMetaSchema.optional(), -}).refine(data => { - // auth_token presets must NOT have ANTHROPIC_API_KEY in envOverrides - // (auth_token injection already clears API_KEY; envOverrides entry would be ignored by AUTH_ENV_KEYS skip) - if (data.authStyle === 'auth_token' && data.defaultEnvOverrides.ANTHROPIC_API_KEY !== undefined) { - return false; - } - // api_key presets must NOT have ANTHROPIC_AUTH_TOKEN in envOverrides - if (data.authStyle === 'api_key' && data.defaultEnvOverrides.ANTHROPIC_AUTH_TOKEN !== undefined) { - return false; - } - // Note: auth_token presets MAY have ANTHROPIC_AUTH_TOKEN with a fixed pseudo-value (e.g. Ollama uses 'ollama'). - // This is allowed because it's a preset default, not user input — though the AUTH_ENV_KEYS skip in - // toClaudeCodeEnv() means it will only take effect if the user doesn't provide their own key. - return true; -}, { message: 'authStyle conflicts with auth-related keys in defaultEnvOverrides' }); - // ── Default Anthropic models ──────────────────────────────────── const ANTHROPIC_DEFAULT_MODELS: CatalogModel[] = [ @@ -208,11 +135,6 @@ export const VENDOR_PRESETS: VendorPreset[] = [ defaultModels: ANTHROPIC_DEFAULT_MODELS, fields: ['api_key'], iconKey: 'anthropic', - meta: { - apiKeyUrl: 'https://platform.claude.com/settings/keys', - docsUrl: 'https://platform.claude.com/docs/en/api/overview', - billingModel: 'pay_as_you_go', - }, }, // ── Anthropic Third-party (generic) ── @@ -237,17 +159,12 @@ export const VENDOR_PRESETS: VendorPreset[] = [ description: 'Use OpenRouter to access multiple models', descriptionZh: '通过 OpenRouter 访问多种模型', protocol: 'openrouter', - authStyle: 'auth_token', + authStyle: 'api_key', baseUrl: 'https://openrouter.ai/api', - defaultEnvOverrides: {}, + defaultEnvOverrides: { ANTHROPIC_API_KEY: '' }, defaultModels: ANTHROPIC_DEFAULT_MODELS, fields: ['api_key'], iconKey: 'openrouter', - meta: { - apiKeyUrl: 'https://openrouter.ai/workspaces/default/keys', - docsUrl: 'https://openrouter.ai/docs/guides/coding-agents/claude-code-integration', - billingModel: 'pay_as_you_go', - }, }, // ── Zhipu GLM (China) ── @@ -257,9 +174,9 @@ export const VENDOR_PRESETS: VendorPreset[] = [ description: 'Zhipu GLM Code Plan — China region', descriptionZh: '智谱 GLM 编程套餐 — 中国区', protocol: 'anthropic', - authStyle: 'auth_token', + authStyle: 'api_key', baseUrl: 'https://open.bigmodel.cn/api/anthropic', - defaultEnvOverrides: { API_TIMEOUT_MS: '3000000', ANTHROPIC_DEFAULT_HAIKU_MODEL: 'glm-4.5-air', ANTHROPIC_DEFAULT_SONNET_MODEL: 'glm-5-turbo', ANTHROPIC_DEFAULT_OPUS_MODEL: 'glm-5.1' }, + defaultEnvOverrides: { API_TIMEOUT_MS: '3000000', ANTHROPIC_API_KEY: '', ANTHROPIC_DEFAULT_HAIKU_MODEL: 'glm-4.5-air', ANTHROPIC_DEFAULT_SONNET_MODEL: 'glm-5-turbo', ANTHROPIC_DEFAULT_OPUS_MODEL: 'glm-5.1' }, defaultModels: [ { modelId: 'sonnet', upstreamModelId: 'sonnet', displayName: 'GLM-5-Turbo', role: 'sonnet' }, { modelId: 'opus', upstreamModelId: 'opus', displayName: 'GLM-5.1', role: 'opus' }, @@ -268,12 +185,6 @@ export const VENDOR_PRESETS: VendorPreset[] = [ fields: ['api_key'], iconKey: 'zhipu', sdkProxyOnly: true, - meta: { - apiKeyUrl: 'https://bigmodel.cn/usercenter/proj-mgmt/apikeys', - docsUrl: 'https://docs.bigmodel.cn/cn/coding-plan/tool/claude', - billingModel: 'coding_plan', - notes: ['高峰时段(14:00-18:00 UTC+8)消耗 3 倍积分'], - }, }, // ── Zhipu GLM (Global) ── @@ -283,9 +194,9 @@ export const VENDOR_PRESETS: VendorPreset[] = [ description: 'Zhipu GLM Code Plan — Global region', descriptionZh: '智谱 GLM 编程套餐 — 国际区', protocol: 'anthropic', - authStyle: 'auth_token', + authStyle: 'api_key', baseUrl: 'https://api.z.ai/api/anthropic', - defaultEnvOverrides: { API_TIMEOUT_MS: '3000000', ANTHROPIC_DEFAULT_HAIKU_MODEL: 'glm-4.5-air', ANTHROPIC_DEFAULT_SONNET_MODEL: 'glm-5-turbo', ANTHROPIC_DEFAULT_OPUS_MODEL: 'glm-5.1' }, + defaultEnvOverrides: { API_TIMEOUT_MS: '3000000', ANTHROPIC_API_KEY: '', ANTHROPIC_DEFAULT_HAIKU_MODEL: 'glm-4.5-air', ANTHROPIC_DEFAULT_SONNET_MODEL: 'glm-5-turbo', ANTHROPIC_DEFAULT_OPUS_MODEL: 'glm-5.1' }, defaultModels: [ { modelId: 'sonnet', upstreamModelId: 'sonnet', displayName: 'GLM-5-Turbo', role: 'sonnet' }, { modelId: 'opus', upstreamModelId: 'opus', displayName: 'GLM-5.1', role: 'opus' }, @@ -294,12 +205,6 @@ export const VENDOR_PRESETS: VendorPreset[] = [ fields: ['api_key'], iconKey: 'zhipu', sdkProxyOnly: true, - meta: { - apiKeyUrl: 'https://z.ai/manage-apikey/apikey-list', - docsUrl: 'https://docs.z.ai/devpack/tool/claude', - billingModel: 'coding_plan', - notes: ['高峰时段(14:00-18:00 UTC+8)消耗 3 倍积分'], - }, }, // ── Kimi ── @@ -309,21 +214,15 @@ export const VENDOR_PRESETS: VendorPreset[] = [ description: 'Kimi Coding Plan API', descriptionZh: 'Kimi 编程计划 API', protocol: 'anthropic', - authStyle: 'api_key', + authStyle: 'auth_token', baseUrl: 'https://api.kimi.com/coding/', - defaultEnvOverrides: { ENABLE_TOOL_SEARCH: 'false' }, + defaultEnvOverrides: { ANTHROPIC_AUTH_TOKEN: '' }, defaultModels: [ { modelId: 'sonnet', displayName: 'Kimi K2.5', role: 'default' }, ], fields: ['api_key'], iconKey: 'kimi', sdkProxyOnly: true, - meta: { - apiKeyUrl: 'https://www.kimi.com/code/console', - docsUrl: 'https://www.kimi.com/code/docs/more/third-party-agents.html', - billingModel: 'pay_as_you_go', - notes: [], - }, }, // ── Moonshot ── @@ -333,21 +232,15 @@ export const VENDOR_PRESETS: VendorPreset[] = [ description: 'Moonshot AI API', descriptionZh: '月之暗面 API', protocol: 'anthropic', - authStyle: 'auth_token', + authStyle: 'api_key', baseUrl: 'https://api.moonshot.cn/anthropic', - defaultEnvOverrides: { ENABLE_TOOL_SEARCH: 'false' }, + defaultEnvOverrides: { ANTHROPIC_API_KEY: '' }, defaultModels: [ { modelId: 'sonnet', displayName: 'Kimi K2.5', role: 'default' }, ], fields: ['api_key'], iconKey: 'moonshot', sdkProxyOnly: true, - meta: { - apiKeyUrl: 'https://platform.moonshot.cn/console/api-keys', - docsUrl: 'https://platform.moonshot.cn/docs/guide/agent-support', - billingModel: 'pay_as_you_go', - notes: ['建议设置每日消费上限,防止 agentic 循环快速消耗 token'], - }, }, // ── MiniMax (China) ── @@ -362,6 +255,7 @@ export const VENDOR_PRESETS: VendorPreset[] = [ defaultEnvOverrides: { API_TIMEOUT_MS: '3000000', CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1', + ANTHROPIC_AUTH_TOKEN: '', }, defaultModels: [ { modelId: 'sonnet', upstreamModelId: 'MiniMax-M2.7', displayName: 'MiniMax-M2.7', role: 'default' }, @@ -375,11 +269,6 @@ export const VENDOR_PRESETS: VendorPreset[] = [ fields: ['api_key'], iconKey: 'minimax', sdkProxyOnly: true, - meta: { - apiKeyUrl: 'https://platform.minimaxi.com/user-center/payment/token-plan', - docsUrl: 'https://platform.minimaxi.com/docs/token-plan/claude-code', - billingModel: 'token_plan', - }, }, // ── MiniMax (Global) ── @@ -394,6 +283,7 @@ export const VENDOR_PRESETS: VendorPreset[] = [ defaultEnvOverrides: { API_TIMEOUT_MS: '3000000', CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1', + ANTHROPIC_AUTH_TOKEN: '', }, defaultModels: [ { modelId: 'sonnet', upstreamModelId: 'MiniMax-M2.7', displayName: 'MiniMax-M2.7', role: 'default' }, @@ -407,11 +297,6 @@ export const VENDOR_PRESETS: VendorPreset[] = [ fields: ['api_key'], iconKey: 'minimax', sdkProxyOnly: true, - meta: { - apiKeyUrl: 'https://platform.minimax.io/user-center/payment/token-plan', - docsUrl: 'https://platform.minimax.io/docs/token-plan/opencode', - billingModel: 'token_plan', - }, }, // ── Volcengine Ark ── @@ -423,17 +308,11 @@ export const VENDOR_PRESETS: VendorPreset[] = [ protocol: 'anthropic', authStyle: 'auth_token', baseUrl: 'https://ark.cn-beijing.volces.com/api/coding', - defaultEnvOverrides: {}, + defaultEnvOverrides: { ANTHROPIC_AUTH_TOKEN: '' }, defaultModels: [], // User must specify model_names fields: ['api_key', 'model_names'], iconKey: 'volcengine', sdkProxyOnly: true, - meta: { - apiKeyUrl: 'https://console.volcengine.com/ark/region:ark+cn-beijing/openManagement', - docsUrl: 'https://www.volcengine.com/docs/82379/1928262', - billingModel: 'coding_plan', - notes: ['需先在控制台激活 Endpoint', 'API Key 为临时凭证'], - }, }, // ── Xiaomi MiMo (按量付费) ── @@ -445,7 +324,9 @@ export const VENDOR_PRESETS: VendorPreset[] = [ protocol: 'anthropic', authStyle: 'auth_token', baseUrl: 'https://api.xiaomimimo.com/anthropic', - defaultEnvOverrides: {}, + defaultEnvOverrides: { + ANTHROPIC_AUTH_TOKEN: '', + }, defaultModels: [ { modelId: 'sonnet', upstreamModelId: 'mimo-v2-pro', displayName: 'MiMo-V2-Pro', role: 'default' }, ], @@ -458,12 +339,6 @@ export const VENDOR_PRESETS: VendorPreset[] = [ fields: ['api_key'], iconKey: 'xiaomi-mimo', sdkProxyOnly: true, - meta: { - apiKeyUrl: 'https://platform.xiaomimimo.com/#/console/api-keys', - docsUrl: 'https://platform.xiaomimimo.com/#/docs/integration/claudecode', - billingModel: 'pay_as_you_go', - notes: [], - }, }, // ── Xiaomi MiMo Token Plan (订阅套餐) ── @@ -475,7 +350,9 @@ export const VENDOR_PRESETS: VendorPreset[] = [ protocol: 'anthropic', authStyle: 'auth_token', baseUrl: 'https://token-plan-cn.xiaomimimo.com/anthropic', - defaultEnvOverrides: {}, + defaultEnvOverrides: { + ANTHROPIC_AUTH_TOKEN: '', + }, defaultModels: [ { modelId: 'sonnet', upstreamModelId: 'mimo-v2-pro', displayName: 'MiMo-V2-Pro', role: 'default' }, ], @@ -488,12 +365,6 @@ export const VENDOR_PRESETS: VendorPreset[] = [ fields: ['api_key'], iconKey: 'xiaomi-mimo', sdkProxyOnly: true, - meta: { - apiKeyUrl: 'https://platform.xiaomimimo.com/#/console/plan-manage', - docsUrl: 'https://platform.xiaomimimo.com/#/docs/integration/claudecode', - billingModel: 'token_plan', - notes: [], - }, }, // ── Aliyun Bailian ── @@ -503,9 +374,9 @@ export const VENDOR_PRESETS: VendorPreset[] = [ description: 'Aliyun Bailian Coding Plan — Qwen, GLM, Kimi, MiniMax', descriptionZh: '阿里云百炼 Coding Plan — 通义千问、GLM、Kimi、MiniMax', protocol: 'anthropic', - authStyle: 'auth_token', + authStyle: 'api_key', baseUrl: 'https://coding.dashscope.aliyuncs.com/apps/anthropic', - defaultEnvOverrides: {}, + defaultEnvOverrides: { ANTHROPIC_API_KEY: '' }, defaultModels: [ { modelId: 'qwen3.5-plus', displayName: 'Qwen 3.5 Plus', role: 'default' }, { modelId: 'qwen3-coder-next', displayName: 'Qwen 3 Coder Next' }, @@ -518,12 +389,6 @@ export const VENDOR_PRESETS: VendorPreset[] = [ fields: ['api_key'], iconKey: 'bailian', sdkProxyOnly: true, - meta: { - apiKeyUrl: 'https://bailian.console.aliyun.com', - docsUrl: 'https://help.aliyun.com/zh/model-studio/coding-plan', - billingModel: 'coding_plan', - notes: ['必须使用 Coding Plan 专用 Key(以 sk-sp- 开头)', '普通 DashScope Key 无法使用', '禁止用于自动化脚本'], - }, }, // ── AWS Bedrock ── @@ -543,12 +408,6 @@ export const VENDOR_PRESETS: VendorPreset[] = [ defaultModels: ANTHROPIC_DEFAULT_MODELS, fields: ['env_overrides'], iconKey: 'bedrock', - meta: { - apiKeyUrl: 'https://console.aws.amazon.com', - docsUrl: 'https://aws.amazon.com/cn/bedrock/anthropic/', - billingModel: 'pay_as_you_go', - notes: ['需在 AWS Console 订阅 Claude 模型'], - }, }, // ── Google Vertex AI ── @@ -568,11 +427,6 @@ export const VENDOR_PRESETS: VendorPreset[] = [ defaultModels: ANTHROPIC_DEFAULT_MODELS, fields: ['env_overrides'], iconKey: 'google', - meta: { - docsUrl: 'https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/use-claude', - billingModel: 'pay_as_you_go', - notes: ['需启用 Vertex AI 并在 Model Garden 订阅 Claude 模型'], - }, }, // ── Ollama ── @@ -585,17 +439,13 @@ export const VENDOR_PRESETS: VendorPreset[] = [ authStyle: 'auth_token', baseUrl: 'http://localhost:11434', defaultEnvOverrides: { - ANTHROPIC_AUTH_TOKEN: 'ollama', // Fixed pseudo-token for Ollama (no real auth needed) + ANTHROPIC_AUTH_TOKEN: 'ollama', + ANTHROPIC_API_KEY: '', }, defaultModels: [], // User must specify — depends on pulled models fields: ['base_url', 'model_names'], iconKey: 'ollama', sdkProxyOnly: true, - meta: { - docsUrl: 'https://docs.ollama.com/integrations/claude-code', - billingModel: 'free', - notes: ['需要本地安装 Ollama 并拉取模型'], - }, }, // ── LiteLLM ── @@ -611,10 +461,6 @@ export const VENDOR_PRESETS: VendorPreset[] = [ defaultModels: ANTHROPIC_DEFAULT_MODELS, fields: ['api_key', 'base_url'], iconKey: 'server', - meta: { - docsUrl: 'https://docs.litellm.ai/docs/', - billingModel: 'self_hosted', - }, }, // ── Google Gemini (Image) ── @@ -635,21 +481,10 @@ export const VENDOR_PRESETS: VendorPreset[] = [ fields: ['api_key'], category: 'media', iconKey: 'google', - meta: { - apiKeyUrl: 'https://aistudio.google.com/api-keys', - docsUrl: 'https://ai.google.dev/gemini-api/docs/image-generation', - billingModel: 'pay_as_you_go', - }, }, ]; -// ── Runtime preset validation (fails fast on invalid presets) ─── - -for (const p of VENDOR_PRESETS) { - PresetSchema.parse(p); -} - // ── Lookup helpers ────────────────────────────────────────────── /** Get a preset by key. */ diff --git a/src/lib/provider-doctor.ts b/src/lib/provider-doctor.ts index 1454062a..987600bc 100644 --- a/src/lib/provider-doctor.ts +++ b/src/lib/provider-doctor.ts @@ -942,13 +942,16 @@ function attachRepairsToFindings(probes: ProbeResult[]): void { const targetPid = defaultProviderId || firstProvider?.id; if (!targetPid) continue; params.providerId = targetPid; - // Detect current auth style from preset catalog (not extra_env) + // Detect current auth style from the provider's extra_env and suggest the opposite const targetProvider = getProvider(targetPid); if (targetProvider) { - const protocol = (targetProvider.protocol || inferProtocolFromLegacy(targetProvider.provider_type, targetProvider.base_url)) as Protocol; - const preset = findPresetForLegacy(targetProvider.base_url, targetProvider.provider_type, protocol); - const currentlyUsingToken = preset?.authStyle === 'auth_token'; - params.authStyle = currentlyUsingToken ? 'api-key' : 'auth-token'; + try { + const env = JSON.parse(targetProvider.extra_env || '{}'); + const currentlyUsingToken = 'ANTHROPIC_AUTH_TOKEN' in env; + params.authStyle = currentlyUsingToken ? 'api-key' : 'auth-token'; + } catch { + params.authStyle = 'auth-token'; // safe default + } } break; } diff --git a/src/lib/provider-resolver.ts b/src/lib/provider-resolver.ts index 8468c692..f0ef74d0 100644 --- a/src/lib/provider-resolver.ts +++ b/src/lib/provider-resolver.ts @@ -189,10 +189,7 @@ export function toClaudeCodeEnv( break; case 'api_key': default: - // Only set ANTHROPIC_API_KEY (X-Api-Key header). - // Do NOT set ANTHROPIC_AUTH_TOKEN — upstream Claude Code adds - // Authorization: Bearer when it sees AUTH_TOKEN, which conflicts - // with providers that expect API-key-only auth (e.g. Kimi). + env.ANTHROPIC_AUTH_TOKEN = apiKey; env.ANTHROPIC_API_KEY = apiKey; break; } @@ -255,12 +252,6 @@ export function toClaudeCodeEnv( if (appBaseUrl) env.ANTHROPIC_BASE_URL = appBaseUrl; } - // Prevent ~/.claude/settings.json from overriding CodePilot's provider configuration. - // When set, Claude Code CLI's withoutHostManagedProviderVars() strips all provider-routing - // variables from the user's settings file (see upstream managedEnv.ts / managedEnvConstants.ts). - // Placed AFTER all env cleanup to ensure it's never accidentally deleted. - env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST = '1'; - return env; } From cd82fc2d66eb1c5dea9502b1974f3812aa21687e Mon Sep 17 00:00:00 2001 From: HNGM-HP <542869290@qq.com> Date: Mon, 6 Apr 2026 11:50:12 +0800 Subject: [PATCH 09/10] fix: add mobile sidebar toggle button and prevent task interruption on browser disconnect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Mobile sidebar: Add SidebarSimple toggle button in UnifiedTopBar (visible when sidebar is closed on all viewports). Remove orphaned left-14 offset from ChatListPanel (was for the removed NavRail). Wire chatListOpen/setChatListOpen through PanelContext. 2. Task interrupt: Remove request.signal → abortController forwarding in /api/chat/route.ts so browser disconnect no longer kills the SDK subprocess. Intentional stops already use /api/chat/interrupt. --- src/app/api/chat/route.ts | 5 ----- src/components/layout/AppShell.tsx | 4 +++- src/components/layout/ChatListPanel.tsx | 2 +- src/components/layout/UnifiedTopBar.tsx | 30 +++++++++++++++++++++---- src/hooks/usePanel.ts | 2 ++ 5 files changed, 32 insertions(+), 11 deletions(-) diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index fb24bed5..96c07620 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -185,11 +185,6 @@ export async function POST(request: NextRequest) { const abortController = new AbortController(); - // Handle client disconnect - request.signal.addEventListener('abort', () => { - abortController.abort(); - }); - // Convert file attachments to the format expected by streamClaude. // Include filePath from the already-saved files so claude-client can // reference the on-disk copies instead of writing them again. diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index f7bb469b..86819f26 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -421,8 +421,10 @@ export function AppShell({ children }: { children: React.ReactNode }) { setPreviewFile, previewViewMode, setPreviewViewMode, + chatListOpen, + setChatListOpen, }), - [fileTreeOpen, gitPanelOpen, previewOpen, terminalOpen, dashboardPanelOpen, assistantPanelOpen, isAssistantWorkspace, currentBranch, gitDirtyCount, currentWorktreeLabel, workingDirectory, sessionId, sessionTitle, streamingSessionId, pendingApprovalSessionId, activeStreamingSessions, pendingApprovalSessionIds, previewFile, setPreviewFile, previewViewMode] + [fileTreeOpen, gitPanelOpen, previewOpen, terminalOpen, dashboardPanelOpen, assistantPanelOpen, isAssistantWorkspace, currentBranch, gitDirtyCount, currentWorktreeLabel, workingDirectory, sessionId, sessionTitle, streamingSessionId, pendingApprovalSessionId, activeStreamingSessions, pendingApprovalSessionIds, previewFile, setPreviewFile, previewViewMode, chatListOpen, setChatListOpen] ); const imageGenValue = useImageGenState(); diff --git a/src/components/layout/ChatListPanel.tsx b/src/components/layout/ChatListPanel.tsx index de3d2fed..523b8e65 100644 --- a/src/components/layout/ChatListPanel.tsx +++ b/src/components/layout/ChatListPanel.tsx @@ -460,7 +460,7 @@ export function ChatListPanel({ open, width, hasUpdate, readyToInstall }: ChatLi />