diff --git a/README.md b/README.md index 09dd32fa..3f9e8bf4 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ Unlike simple wrappers, Codex Mate acts as a **Local Agent Bridge**: - **OpenAI-Compatible Bridge**: Use Codex with any OpenAI-compatible UI by normalizing the Responses API; the built-in Codex conversion also fills and normalizes Codex fingerprint headers such as `User-Agent`, `Version`, `OpenAI-Beta`, and `Originator` so upstream providers see an official Codex CLI-shaped request. - **Claude Provider Bridge**: Connect Claude Code to OpenAI Chat Completions-compatible providers and Ollama through the built-in local Claude-compatible proxy. - **OpenCode Provider Control**: Manage OpenCode provider/model selection with a CodexMate-owned provider store under `~/.codexmate`, projecting only the active provider into native OpenCode config to avoid polluting or deleting user-owned settings. +- **Provider Health Cleanup**: Probe local Codex and Claude provider routes, surface failed configs in one modal, and bulk-clean selected broken providers without touching healthy or protected entries. - **Skills Marketplace**: A local-first market to share and import skills between different agent apps. - **Prompt File Editor**: Unified editor for global and project-level `CLAUDE.md` and `AGENTS.md` with auto-detection of project paths. - **Task Orchestrator**: Plan and execute complex tasks with dependency tracking. @@ -77,6 +78,7 @@ Unlike simple wrappers, Codex Mate acts as a **Local Agent Bridge**: | **OpenAI Bridge** | ✅ | Convert Codex Responses API to standard OpenAI format and attach/normalize Codex fingerprints in the built-in conversion | | **Claude Provider Bridge** | ✅ | Connect Claude Code to OpenAI Chat Completions-compatible providers and Ollama via the built-in Claude-compatible proxy | | **OpenCode Provider Store** | ✅ | Keep multiple OpenCode providers in `~/.codexmate` while projecting only the selected provider to native OpenCode config | +| **Provider Health Check** | ✅ | Probe local Codex/Claude provider routes, highlight failed configs, and bulk-remove selected broken providers safely | | **Prompt Templates** | ✅ | Reusable prompt plugins with variables | | **Prompt File Editor** | ✅ | Edit global and project-level CLAUDE.md / AGENTS.md with auto-detect and path switching | | **MCP Integration** | ✅ | Expose local tools and resources via MCP stdio | diff --git a/README.vi.md b/README.vi.md index 2eb4c4bd..ddfbf777 100644 --- a/README.vi.md +++ b/README.vi.md @@ -48,6 +48,7 @@ Bạn có bao giờ cảm thấy rối khi phải quản lý nhiều AI agent c Khác với các wrapper đơn giản, Codex Mate hoạt động như một **Agent Bridge cục bộ**: - **Trình duyệt phiên thống nhất**: Tìm kiếm và xuất phiên làm việc từ tất cả tool trong một nơi duy nhất. - **Bridge tương thích OpenAI**: Dùng Codex với bất kỳ UI nào hỗ trợ OpenAI bằng cách chuẩn hóa Responses API. +- **Dọn dẹp provider lỗi**: Kiểm tra route provider cục bộ của Codex/Claude, hiển thị cấu hình lỗi trong một modal và xóa hàng loạt các provider hỏng đã chọn mà không chạm vào mục khỏe mạnh hoặc được bảo vệ. - **Chợ skill cục bộ**: Chia sẻ và nhập skill giữa các app agent khác nhau. - **Điều phối tác vụ**: Lập kế hoạch và thực thi tác vụ phức tạp với theo dõi phụ thuộc. @@ -64,6 +65,7 @@ Khác với các wrapper đơn giản, Codex Mate hoạt động như một **Ag | **Chợ skill cục bộ** | ✅ | Import/export skill giữa các app agent | | **Hàng đợi tác vụ** | ✅ | Thực thi tác vụ theo DAG và xem log | | **Bridge OpenAI** | ✅ | Chuyển đổi Codex Responses API sang định dạng OpenAI chuẩn | +| **Kiểm tra sức khỏe Provider** | ✅ | Kiểm tra route provider Codex/Claude, đánh dấu cấu hình lỗi và xóa hàng loạt các provider hỏng đã chọn một cách an toàn | | **Mẫu Prompt** | ✅ | Plugin prompt tái sử dụng được với hỗ trợ biến | | **Tích hợp MCP** | ✅ | Expose tool và resource cục bộ qua MCP stdio | | **Tự động cập nhật** | ✅ | Cập nhật nhanh qua `codexmate update` | diff --git a/README.zh.md b/README.zh.md index f6bdeec0..f692b554 100644 --- a/README.zh.md +++ b/README.zh.md @@ -57,6 +57,7 @@ - **OpenAI 兼容桥接**:通过归一化 Responses API,让 Codex 能够与任何支持 OpenAI 格式的 UI 配合使用;内建 Codex 转换会补齐并规范化 `User-Agent`、`Version`、`OpenAI-Beta`、`Originator` 等 Codex 指纹,对上游伪装为官方 Codex CLI 请求。 - **Claude Provider 桥接**:通过内建本地 Claude 兼容代理,让 Claude Code 接入 OpenAI Chat Completions 兼容 provider 与 Ollama。 - **OpenCode Provider 控制**:在 `~/.codexmate` 下维护 CodexMate 自有的 OpenCode 多 provider 存储,只将当前选中的 provider 投影到 OpenCode 原生配置,避免污染或误删用户已有配置。 +- **Provider 健康清理**:探测本地 Codex 与 Claude provider 路由,在同一个弹窗里集中展示失败配置,并批量清理已选坏配置,不影响正常或受保护项。 - **Skills 市场**:本地优先的市场,支持在不同的智能体应用之间共享和导入 Skills。 - **提示词文件编辑器**:统一编辑全局和项目级 `CLAUDE.md` 与 `AGENTS.md`,支持项目路径自动检测。 - **任务编排器**:支持带有依赖跟踪的复杂任务规划与执行。 @@ -76,6 +77,7 @@ | **OpenAI 桥接** | ✅ | 将 Codex Responses API 转换为标准 OpenAI 格式,并在内建转换中附加/规范化 Codex 指纹 | | **Claude Provider 桥接** | ✅ | 通过内建 Claude 兼容代理,让 Claude Code 接入 OpenAI Chat Completions 兼容 provider 与 Ollama | | **OpenCode Provider 存储** | ✅ | 在 `~/.codexmate` 中保留多个 OpenCode provider,只将当前选中的 provider 投影到 OpenCode 原生配置 | +| **Provider 健康检查** | ✅ | 探测本地 Codex/Claude provider 路由,标记失败配置,并安全批量移除已选坏配置 | | **提示词模板** | ✅ | 支持变量的可复用提示词插件 | | **提示词文件编辑器** | ✅ | 编辑全局和项目级 CLAUDE.md / AGENTS.md,支持自动检测与路径切换 | | **MCP 集成** | ✅ | 通过 MCP stdio 暴露本地工具与资源 | diff --git a/cli.js b/cli.js index f29e6cd3..78808257 100644 --- a/cli.js +++ b/cli.js @@ -231,6 +231,15 @@ const PROVIDER_CACHE_FILE_GROUPS = Object.freeze({ 'opencode-provider-current-models.json' ] }); +const PROVIDER_CACHE_PROVIDER_FILES = Object.freeze([ + 'codex-providers.json', + 'claude-providers.json', + 'opencode-providers.json' +]); +const PROVIDER_CACHE_CURRENT_MODEL_FILES = Object.freeze([ + 'codex-provider-current-models.json', + 'opencode-provider-current-models.json' +]); const PROVIDER_CACHE_MAX_FILE_BYTES = 256 * 1024; const CODEXMATE_PREFERENCES_FILE = path.join(CODEXMATE_DIR, 'preferences.json'); const CODEXMATE_OPENCODE_DIR = path.join(CODEXMATE_DIR, 'opencode'); @@ -1083,7 +1092,8 @@ function getApiToolConfigWriteTarget(action) { 'restore-claude-dir', 'claude-local-bridge-toggle', 'claude-local-bridge-set-excluded', - 'claude-local-bridge-sync-providers' + 'claude-local-bridge-sync-providers', + 'delete-provider-cache-record' ]); const opencodeWriteActions = new Set([ 'apply-opencode-config', @@ -2877,6 +2887,99 @@ function normalizeProviderCacheProviderMap(rawProviders) { return providers; } +function removeProviderFromProviderCacheContainer(rawProviders, providerName) { + const targetName = typeof providerName === 'string' ? providerName.trim() : ''; + if (!targetName) return { value: rawProviders, changed: false }; + + const matchesProviderName = (name) => String(name || '').trim() === targetName; + if (Array.isArray(rawProviders)) { + const filtered = rawProviders.filter((item) => { + if (!isPlainObject(item)) return true; + const itemName = pickProviderCacheString(item, ['name', 'id', 'provider']); + return !matchesProviderName(itemName); + }); + return { value: filtered, changed: filtered.length !== rawProviders.length }; + } + if (!isPlainObject(rawProviders)) return { value: rawProviders, changed: false }; + + const next = { ...rawProviders }; + let changed = false; + for (const [name, entry] of Object.entries(rawProviders)) { + const entryName = isPlainObject(entry) + ? pickProviderCacheString(entry, ['name', 'id', 'provider']) + : ''; + if (matchesProviderName(name) || matchesProviderName(entryName)) { + delete next[name]; + changed = true; + } + } + return { value: next, changed }; +} + +function resolveProviderCacheDeleteGroups(groups) { + const requested = Array.isArray(groups) ? groups : (groups ? [groups] : []); + const normalized = requested + .map((item) => String(item || '').trim().toLowerCase()) + .filter((item) => Object.prototype.hasOwnProperty.call(PROVIDER_CACHE_FILE_GROUPS, item)); + return normalized.length ? Array.from(new Set(normalized)) : Object.keys(PROVIDER_CACHE_FILE_GROUPS); +} + +function removeProviderFromProviderCacheRecords(providerName, options = {}) { + const targetName = typeof providerName === 'string' ? providerName.trim() : ''; + const summary = { removed: false, providerFiles: [], currentModelFiles: [] }; + if (!targetName) return summary; + + const groups = resolveProviderCacheDeleteGroups(options.groups || options.group); + const providerFiles = groups + .flatMap((group) => PROVIDER_CACHE_FILE_GROUPS[group] || []) + .filter((fileName) => PROVIDER_CACHE_PROVIDER_FILES.includes(fileName)); + const currentModelFiles = groups + .flatMap((group) => PROVIDER_CACHE_FILE_GROUPS[group] || []) + .filter((fileName) => PROVIDER_CACHE_CURRENT_MODEL_FILES.includes(fileName)); + + for (const fileName of Array.from(new Set(providerFiles))) { + const existing = readProviderCacheJsonObject(fileName); + if (!isPlainObject(existing) || !Object.prototype.hasOwnProperty.call(existing, 'providers')) continue; + const removed = removeProviderFromProviderCacheContainer(existing.providers, targetName); + if (!removed.changed) continue; + writeProviderCacheJsonObject(fileName, { + ...existing, + generatedAt: new Date().toISOString(), + providers: removed.value + }); + summary.removed = true; + summary.providerFiles.push(fileName); + } + + for (const fileName of Array.from(new Set(currentModelFiles))) { + const existing = readProviderCacheJsonObject(fileName); + if (!isPlainObject(existing) || !Object.prototype.hasOwnProperty.call(existing, targetName)) continue; + const next = { ...existing }; + delete next[targetName]; + writeProviderCacheJsonObject(fileName, next); + summary.removed = true; + summary.currentModelFiles.push(fileName); + } + return summary; +} + +function deleteProviderCacheRecord(params = {}) { + const name = typeof params.name === 'string' ? params.name.trim() : ''; + if (!name) return { error: '名称不能为空' }; + const group = typeof params.group === 'string' ? params.group.trim().toLowerCase() : ''; + const groups = resolveProviderCacheDeleteGroups(group || params.groups); + const summary = removeProviderFromProviderCacheRecords(name, { groups }); + return { + success: true, + name, + groups, + removed: summary.removed, + providerFiles: summary.providerFiles, + currentModelFiles: summary.currentModelFiles, + records: readProviderCacheRecords() + }; +} + function readClaudeProviderCacheProvider(name) { const targetName = typeof name === 'string' ? name.trim() : ''; if (!targetName) return null; @@ -2953,10 +3056,11 @@ function buildProviderCacheSyncProviders() { function mergeProviderCacheFile(fileName, nextProviders, buildEntry) { const existing = readProviderCacheJsonObject(fileName); const existingProviders = normalizeProviderCacheProviderMap(existing.providers); - const providers = { ...existingProviders }; + const providers = {}; for (const provider of nextProviders) { const previous = isPlainObject(providers[provider.name]) ? providers[provider.name] : {}; - providers[provider.name] = { ...previous, ...buildEntry(provider) }; + const cachedPrevious = isPlainObject(existingProviders[provider.name]) ? existingProviders[provider.name] : previous; + providers[provider.name] = { ...cachedPrevious, ...buildEntry(provider) }; } const next = { ...existing, @@ -2970,9 +3074,13 @@ function mergeProviderCacheFile(fileName, nextProviders, buildEntry) { function mergeProviderCacheCurrentModelsFile(fileName, nextProviders) { const existing = readProviderCacheJsonObject(fileName); - const next = { ...existing }; + const next = {}; for (const provider of nextProviders) { - if (provider.model) next[provider.name] = provider.model; + if (provider.model) { + next[provider.name] = provider.model; + } else if (typeof existing[provider.name] === 'string' && existing[provider.name].trim()) { + next[provider.name] = existing[provider.name]; + } } const displayPath = writeProviderCacheJsonObject(fileName, next); return { path: displayPath, modelCount: Object.keys(next).length }; @@ -3213,6 +3321,7 @@ function performProviderDeletion(name, options = {}) { writeCurrentModels(currentModels); writeConfig(updatedContent.trimEnd() + lineEnding); + removeProviderFromProviderCacheRecords(name); return result; } @@ -12387,6 +12496,9 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser case 'sync-provider-cache-records': result = syncProviderCacheRecords(); break; + case 'delete-provider-cache-record': + result = deleteProviderCacheRecord(params || {}); + break; case 'delete-provider': result = deleteProviderFromConfig(params || {}); break; diff --git a/cli/local-bridge.js b/cli/local-bridge.js index ac15799e..8d7ce140 100644 --- a/cli/local-bridge.js +++ b/cli/local-bridge.js @@ -20,6 +20,14 @@ const { isLoopbackAddress } = require('./openai-bridge'); const { isValidHttpUrl, normalizeBaseUrl, joinApiUrl } = require('../lib/cli-utils'); +const { + buildBuiltinClaudeChatCompletionsRequest, + buildBuiltinClaudeOllamaChatRequest, + buildAnthropicMessageFromChatCompletion, + buildAnthropicMessageFromOllamaChat, + buildAnthropicModelsPayload, + buildAnthropicStreamEvents +} = require('./claude-proxy'); const BUILTIN_PROXY_PROVIDER_NAME = 'codexmate-proxy'; const BUILTIN_LOCAL_PROVIDER_NAME = 'local'; @@ -28,6 +36,52 @@ const CLAUDE_LOCAL_EXCLUDED_KEY = 'claudeLocalExcluded'; const CIRCUIT_BREAKER_THRESHOLD = 3; const CIRCUIT_BREAKER_COOLDOWN_MS = 5 * 60 * 1000; +function normalizeClaudeLocalTargetApi(value) { + const raw = typeof value === 'string' ? value.trim().toLowerCase() : ''; + if (raw === 'chat_completions' || raw === 'chat-completions' || raw === 'chat/completions') return 'chat_completions'; + if (raw === 'ollama') return 'ollama'; + return 'responses'; +} + +function normalizeClaudeLocalSuffix(pathname) { + const suffix = String(pathname || '').replace(/^\/bridge\/claude-local\/?/, '').replace(/^\/+/, ''); + if (!suffix || suffix === 'v1') return ''; + return suffix.replace(/^v1\/?/, ''); +} + +function joinClaudeLocalUpstreamUrl(baseUrl, pathSuffix) { + const suffix = typeof pathSuffix === 'string' ? pathSuffix.replace(/^\/+/, '') : ''; + if (suffix === 'api/tags' || suffix === 'api/chat') { + const normalized = normalizeBaseUrl(baseUrl); + return normalized ? `${normalized}/${suffix}` : ''; + } + return joinApiUrl(baseUrl, suffix); +} + +function buildClaudeLocalAuthHeaders(entry, requestToken, targetApi, expectedToken = '') { + const headers = { 'Content-Type': 'application/json' }; + const trimmedRequestToken = typeof requestToken === 'string' ? requestToken.trim() : ''; + const bridgeToken = typeof expectedToken === 'string' ? expectedToken.trim() : ''; + const upstreamRequestToken = trimmedRequestToken && (!bridgeToken || trimmedRequestToken !== bridgeToken) + ? trimmedRequestToken + : ''; + const token = entry && typeof entry.apiKey === 'string' && entry.apiKey.trim() + ? entry.apiKey.trim() + : upstreamRequestToken; + if (!token) { + if (targetApi === 'responses') headers['anthropic-version'] = '2023-06-01'; + return headers; + } + const bareToken = token.replace(/^Bearer\s+/i, ''); + if (targetApi === 'responses') { + headers['x-api-key'] = bareToken; + headers['anthropic-version'] = '2023-06-01'; + } else { + headers.Authorization = /^Bearer\s+/i.test(token) ? token : `Bearer ${token}`; + } + return headers; +} + function buildUpstreamPool(readConfigFn, openaiBridgeFile, excludedProviders) { let config; try { config = readConfigFn(); } catch (e) { return { error: '读取配置失败' }; } @@ -74,7 +128,13 @@ function buildClaudeUpstreamPool(claudeProvidersFile, excludedProviders) { if (excludedSet.has(name.toLowerCase())) continue; const baseUrl = typeof p.baseUrl === 'string' ? p.baseUrl.trim() : ''; if (!baseUrl || !isValidHttpUrl(normalizeBaseUrl(baseUrl))) continue; - pool.push({ name, baseUrl: normalizeBaseUrl(baseUrl), apiKey: typeof p.apiKey === 'string' ? p.apiKey : '', model: typeof p.model === 'string' ? p.model.trim() : '' }); + pool.push({ + name, + baseUrl: normalizeBaseUrl(baseUrl), + apiKey: typeof p.apiKey === 'string' ? p.apiKey : '', + model: typeof p.model === 'string' ? p.model.trim() : '', + targetApi: normalizeClaudeLocalTargetApi(p.targetApi) + }); } if (pool.length === 0) return { error: '请先添加可用的 Claude 上游提供商' }; return { pool }; @@ -263,7 +323,7 @@ function createLocalBridgeHttpHandler(options = {}) { const pool = poolResult.pool; const { entry } = pickUpstream(pool); - const suffix = (parsedUrl.pathname || '').replace(/^\/bridge\/claude-local\/?/, ''); + const suffix = normalizeClaudeLocalSuffix(parsedUrl.pathname || ''); if (!suffix) { if ((req.method || 'GET').toUpperCase() !== 'GET') { res.writeHead(405, { 'Content-Type': 'application/json; charset=utf-8' }); @@ -271,11 +331,151 @@ function createLocalBridgeHttpHandler(options = {}) { return; } res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }); - res.end(JSON.stringify({ object: 'codexmate.claude_local_bridge', provider: entry.name, model: entry.model || '', status: 'ok', pool: pool.map(p => p.name) })); + res.end(JSON.stringify({ + object: 'codexmate.claude_local_bridge', + provider: entry.name, + model: entry.model || '', + targetApi: entry.targetApi, + status: 'ok', + pool: pool.map(p => p.name), + targetApis: Object.fromEntries(pool.map(p => [p.name, p.targetApi])) + })); + return; + } + + const targetApi = normalizeClaudeLocalTargetApi(entry.targetApi); + const method = (req.method || 'GET').toUpperCase(); + const isMessagesRequest = suffix === 'messages'; + const isModelsRequest = suffix === 'models'; + + if ((targetApi === 'chat_completions' || targetApi === 'ollama') && !isMessagesRequest && !isModelsRequest) { + res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(JSON.stringify({ error: 'Claude local bridge transform providers only support /v1/messages and /v1/models' })); + return; + } + + if (targetApi === 'chat_completions' || targetApi === 'ollama') { + if (isModelsRequest) { + if (method !== 'GET') { + res.writeHead(405, { 'Content-Type': 'application/json; charset=utf-8', Allow: 'GET' }); + res.end(JSON.stringify({ error: 'Method Not Allowed' })); + return; + } + const modelsUrl = joinClaudeLocalUpstreamUrl(entry.baseUrl.replace(/\/+$/, ''), targetApi === 'ollama' ? 'api/tags' : 'models'); + const upstreamResult = await retryTransientRequest(() => proxyRequestJson(modelsUrl, { + method: 'GET', + body: null, + headers: buildClaudeLocalAuthHeaders(entry, token, targetApi, expectedToken), + maxBytes: maxUpstreamBytes, + httpAgent, + httpsAgent + })); + if (!upstreamResult.ok) { + recordFailure(entry.name); + res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(JSON.stringify({ error: `Upstream request failed: ${upstreamResult.error}` })); + return; + } + const parsedModels = parseJsonOrError(upstreamResult.bodyText || '{}'); + if (upstreamResult.status >= 400) { + recordFailure(entry.name); + res.writeHead(upstreamResult.status, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(upstreamResult.bodyText || JSON.stringify({ error: 'Upstream error' })); + return; + } + if (parsedModels.error) { + res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(JSON.stringify({ error: `Upstream parse failed: ${parsedModels.error}` })); + return; + } + recordSuccess(entry.name); + res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(JSON.stringify(buildAnthropicModelsPayload(parsedModels.value))); + return; + } + + if (method !== 'POST') { + res.writeHead(405, { 'Content-Type': 'application/json; charset=utf-8', Allow: 'POST' }); + res.end(JSON.stringify({ error: 'Method Not Allowed' })); + return; + } + const bodyResult = await readRequestBody(req, maxBodySize); + if (bodyResult.error) { + res.writeHead(413, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(JSON.stringify({ error: bodyResult.error })); + return; + } + const parsed = parseJsonOrError(bodyResult.body); + if (parsed.error) { + res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(JSON.stringify({ error: parsed.error })); + return; + } + const requestPayload = parsed.value && typeof parsed.value === 'object' ? parsed.value : {}; + if (entry.model) requestPayload.model = entry.model; + let upstreamBody; + try { + upstreamBody = targetApi === 'ollama' + ? buildBuiltinClaudeOllamaChatRequest(requestPayload) + : buildBuiltinClaudeChatCompletionsRequest(requestPayload); + } catch (e) { + res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(JSON.stringify({ error: e && e.message ? e.message : 'Invalid request payload' })); + return; + } + const upstreamUrl = joinClaudeLocalUpstreamUrl(entry.baseUrl.replace(/\/+$/, ''), targetApi === 'ollama' ? 'api/chat' : 'chat/completions'); + const upstreamResult = await retryTransientRequest(() => proxyRequestJson(upstreamUrl, { + method: 'POST', + body: upstreamBody, + headers: buildClaudeLocalAuthHeaders(entry, token, targetApi, expectedToken), + maxBytes: maxUpstreamBytes, + httpAgent, + httpsAgent + })); + + if (!upstreamResult.ok) { + recordFailure(entry.name); + res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(JSON.stringify({ error: `Upstream request failed: ${upstreamResult.error}` })); + return; + } + if (upstreamResult.status >= 400) { + recordFailure(entry.name); + res.writeHead(upstreamResult.status, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(upstreamResult.bodyText || JSON.stringify({ error: 'Upstream error' })); + return; + } + const parsedUpstream = parseJsonOrError(upstreamResult.bodyText || '{}'); + if (parsedUpstream.error) { + res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(JSON.stringify({ error: `Upstream parse failed: ${parsedUpstream.error}` })); + return; + } + recordSuccess(entry.name); + const anthropicMessage = targetApi === 'ollama' + ? buildAnthropicMessageFromOllamaChat(parsedUpstream.value || {}, requestPayload) + : buildAnthropicMessageFromChatCompletion(parsedUpstream.value || {}, requestPayload); + if (requestPayload.stream === true) { + const events = buildAnthropicStreamEvents(anthropicMessage); + res.writeHead(200, { + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no' + }); + for (const event of events) { + if (event && event.event) res.write(`event: ${event.event}\n`); + res.write(`data: ${JSON.stringify(event && event.data ? event.data : {})}\n\n`); + } + res.end(); + return; + } + res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(JSON.stringify(anthropicMessage)); return; } - // Proxy Anthropic Messages API requests + // Native Anthropic-compatible provider: keep /v1/messages passthrough. const bodyResult = await readRequestBody(req, maxBodySize); if (bodyResult.error) { res.writeHead(413, { 'Content-Type': 'application/json; charset=utf-8' }); @@ -283,26 +483,26 @@ function createLocalBridgeHttpHandler(options = {}) { return; } - let parsedBody; - try { parsedBody = bodyResult.body ? JSON.parse(bodyResult.body) : {}; } catch (_) { parsedBody = {}; } - // Override model to match the selected upstream provider + const hasRequestBody = typeof bodyResult.body === 'string' && bodyResult.body.trim().length > 0; + let parsedBody = null; + if (hasRequestBody) { + const parsed = parseJsonOrError(bodyResult.body); + if (parsed.error) { + res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' }); + res.end(JSON.stringify({ error: parsed.error })); + return; + } + parsedBody = parsed.value; + } if (entry.model && parsedBody && typeof parsedBody === 'object') { parsedBody.model = entry.model; } - const wantsStream = !!(parsedBody && parsedBody.stream); - const bodyToForward = JSON.stringify(parsedBody); + const wantsStream = !!(parsedBody && typeof parsedBody === 'object' && parsedBody.stream); + const bodyToForward = hasRequestBody ? JSON.stringify(parsedBody) : null; const upstreamUrl = joinApiUrl(entry.baseUrl.replace(/\/+$/, ''), suffix); - const headers = { 'Content-Type': 'application/json' }; - if (entry.apiKey) { - headers['x-api-key'] = entry.apiKey.startsWith('Bearer ') ? entry.apiKey.slice(7) : entry.apiKey; - } - if (token && !entry.apiKey) { - headers['x-api-key'] = token.startsWith('Bearer ') ? token.slice(7) : token; - } - headers['anthropic-version'] = '2023-06-01'; + const headers = buildClaudeLocalAuthHeaders(entry, token, targetApi, expectedToken); if (wantsStream) { - // Streaming proxy: pipe upstream SSE directly to client const upstreamResult = await streamClaudeUpstream(upstreamUrl, { method: req.method || 'POST', body: bodyToForward, @@ -324,10 +524,9 @@ function createLocalBridgeHttpHandler(options = {}) { return; } - // Non-streaming proxy const upstreamResult = await retryTransientRequest(() => proxyRequestJson(upstreamUrl, { method: req.method || 'POST', - body: bodyToForward || null, + body: parsedBody || null, headers, maxBytes: maxUpstreamBytes, httpAgent, @@ -432,7 +631,7 @@ function createLocalBridgeHttpHandler(options = {}) { const upstreamResponsesUrl = joinApiUrl(upstreamBase, 'responses'); const upstreamResponsesResult = await retryTransientRequest(() => proxyRequestJson(upstreamResponsesUrl, { method: 'POST', - body: bodyResult.body, + body: responsesRequest, headers: { ...(authHeader ? { Authorization: authHeader } : {}) }, maxBytes: maxUpstreamBytes, httpAgent, @@ -484,7 +683,7 @@ function createLocalBridgeHttpHandler(options = {}) { const chatUrl = joinApiUrl(upstreamBase, 'chat/completions'); const chatResult = await retryTransientRequest(() => proxyRequestJson(chatUrl, { method: 'POST', - body: JSON.stringify(converted.chat), + body: converted.chat, headers: { ...(authHeader ? { Authorization: authHeader } : {}), 'Content-Type': 'application/json' }, maxBytes: maxUpstreamBytes, httpAgent, diff --git a/package-lock.json b/package-lock.json index 00705df7..7edabef1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codexmate", - "version": "0.0.55", + "version": "0.0.56", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codexmate", - "version": "0.0.55", + "version": "0.0.56", "license": "Apache-2.0", "dependencies": { "@iarna/toml": "^2.2.5", diff --git a/package.json b/package.json index 939bde63..6957418d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codexmate", - "version": "0.0.55", + "version": "0.0.56", "description": "Codex/Claude Code/OpenClaw 配置、会话与任务编排 CLI + Web 工具", "main": "cli.js", "bin": { diff --git a/tests/e2e/test-config.js b/tests/e2e/test-config.js index 418d4c9f..f6e7a203 100644 --- a/tests/e2e/test-config.js +++ b/tests/e2e/test-config.js @@ -1501,9 +1501,71 @@ preferred_auth_method = "shadow-key" const deleteProviderEmpty = await api('delete-provider', { name: '' }); assert(deleteProviderEmpty.error, 'delete-provider should fail for empty name'); + const syncedProviderCacheBeforeDelete = await api('sync-provider-cache-records'); + assert(syncedProviderCacheBeforeDelete.success === true, 'provider cache sync before delete should succeed'); + const cacheContainsProvider = (records, providerName) => Array.isArray(records && records.groups) + && records.groups.some((group) => Array.isArray(group && group.files) + && group.files.some((file) => Array.isArray(file && file.providers) + && file.providers.some((provider) => provider && provider.name === providerName))); + assert(cacheContainsProvider(syncedProviderCacheBeforeDelete.records, 'e2e-api'), 'provider cache should contain e2e-api before delete'); + const deleteProviderResult = await api('delete-provider', { name: 'e2e-api' }); assert(deleteProviderResult.success === true, 'delete-provider failed'); + for (let i = 0; i < 3; i += 1) { + const refreshedAfterDelete = await api('get-provider-cache-records'); + assert(!cacheContainsProvider(refreshedAfterDelete.records, 'e2e-api'), 'deleted provider should not reappear in provider cache refresh'); + } + const syncedProviderCacheAfterDelete = await api('sync-provider-cache-records'); + assert(syncedProviderCacheAfterDelete.success === true, 'provider cache sync after delete should succeed'); + assert(!cacheContainsProvider(syncedProviderCacheAfterDelete.records, 'e2e-api'), 'provider cache sync should prune deleted provider instead of resurrecting it'); + + const codexmateDir = path.join(tmpHome, '.codexmate'); + fs.mkdirSync(codexmateDir, { recursive: true }); + const claudeProviderCachePath = path.join(codexmateDir, 'claude-providers.json'); + fs.writeFileSync(claudeProviderCachePath, JSON.stringify({ + version: 1, + providers: { + 'claude-cache-zombie': { + name: 'claude-cache-zombie', + baseUrl: 'https://claude-cache-zombie.example.test', + apiKey: 'sk-cache-zombie', + model: 'claude-cache-zombie-model', + targetApi: 'responses' + }, + 'claude-cache-survivor': { + name: 'claude-cache-survivor', + baseUrl: 'https://claude-cache-survivor.example.test', + apiKey: 'sk-cache-survivor', + model: 'claude-cache-survivor-model', + targetApi: 'responses' + } + } + }, null, 2)); + const claudeCacheConfigsBeforeDelete = await api('get-claude-provider-cache-configs'); + assert( + claudeCacheConfigsBeforeDelete.providers.some(provider => provider.name === 'claude-cache-zombie'), + 'Claude provider-cache config should exist before cache delete' + ); + const deniedClaudeCacheDelete = await api('delete-provider-cache-record', { name: 'claude-cache-zombie', group: 'claude' }); + assert(deniedClaudeCacheDelete.errorCode === 'tool-config-write-disabled', 'Claude provider-cache delete should require claude write permission'); + const enableClaudeWrites = await api('set-tool-config-permission', { target: 'claude', allowWrite: true }); + assert(enableClaudeWrites.success === true, 'set-tool-config-permission(claude) should succeed'); + const deleteClaudeCacheProvider = await api('delete-provider-cache-record', { name: 'claude-cache-zombie', group: 'claude' }); + assert(deleteClaudeCacheProvider.success === true, 'delete-provider-cache-record should succeed for Claude cache'); + assert(deleteClaudeCacheProvider.removed === true, 'delete-provider-cache-record should report removed provider'); + const claudeCacheConfigsAfterDelete = await api('get-claude-provider-cache-configs'); + assert( + !claudeCacheConfigsAfterDelete.providers.some(provider => provider.name === 'claude-cache-zombie'), + 'deleted Claude provider-cache config should not rehydrate after refresh' + ); + assert( + claudeCacheConfigsAfterDelete.providers.some(provider => provider.name === 'claude-cache-survivor'), + 'delete-provider-cache-record should keep unrelated Claude cache configs' + ); + const disableClaudeWritesAfterCacheDelete = await api('set-tool-config-permission', { target: 'claude', allowWrite: false }); + assert(disableClaudeWritesAfterCacheDelete.success === true, 'test-config should restore claude write permission after provider-cache delete e2e'); + const deleteLocalProviderResult = await api('delete-provider', { name: 'local' }); assert(deleteLocalProviderResult.error, 'delete-provider should reject reserved local provider'); diff --git a/tests/e2e/test-web-ui-session-browser.js b/tests/e2e/test-web-ui-session-browser.js index 306edb46..1382308b 100644 --- a/tests/e2e/test-web-ui-session-browser.js +++ b/tests/e2e/test-web-ui-session-browser.js @@ -334,7 +334,7 @@ module.exports = async function testWebUiSessionBrowser(ctx) { assert(vm.activeSessionMessages.length <= 2, 'session browser should not duplicate the initial huge-line preview messages'); assert(vm.activeSessionMessages.every((message) => typeof message.text === 'string' && message.text.length <= 4000), 'session browser should cap huge-line preview text on initial tab entry'); assert(vm.activeSessionDetailClipped === false, 'session browser should fully recover small huge-line sessions on initial tab entry'); - assert(vm.activeSessionVisibleMessages.length === vm.activeSessionMessages.length, 'session browser should render all truncated huge-line preview messages on initial tab entry'); + assert(vm.activeSessionVisibleMessages.length === Math.min(vm.activeSessionMessages.length, vm.sessionPreviewInitialBatchSize), 'session browser should batch initial huge-line preview rendering'); const sampleListSessionId = hydrateZeroSessionId; const sampleListSession = vm.sessionsList.find((item) => item && item.sessionId === sampleListSessionId); @@ -372,7 +372,8 @@ module.exports = async function testWebUiSessionBrowser(ctx) { assert(vm.activeSessionMessages.length >= vm.sessionDetailInitialMessageLimit, 'session browser preview should hydrate at least the initial detail window for huge sessions'); assert(vm.activeSessionDetailClipped === true, 'session browser preview should stay clipped for huge sessions'); - assert(vm.activeSessionVisibleMessages.length === vm.activeSessionMessages.length, 'session browser should render all loaded messages immediately without batching'); + assert(vm.activeSessionVisibleMessages.length <= vm.sessionPreviewInitialBatchSize, 'session browser should batch large detail rendering to avoid DOM jank'); + assert(vm.activeSessionVisibleMessages.length < vm.activeSessionMessages.length, 'session browser should not render the whole large detail window at once'); assert(vm.sessionDetailMessageLimit >= vm.sessionDetailInitialMessageLimit, 'session browser detail limit should be at least the initial window'); assert(vm.activeSessionMessages.length < hotSessionMessageCount, 'session browser should not pull the whole huge session into memory'); @@ -394,5 +395,5 @@ module.exports = async function testWebUiSessionBrowser(ctx) { assert(vm.activeSessionMessages.length > 0, 'session browser should recover huge-line previews through the real UI->API data flow'); assert(vm.activeSessionMessages.length <= 3, 'session browser should not duplicate huge-line fallback preview messages'); assert(vm.activeSessionDetailClipped === false, 'session browser should mark the huge-line fallback preview as fully recovered'); - assert(vm.activeSessionVisibleMessages.length === vm.activeSessionMessages.length, 'session browser should render all recovered huge-line messages without overfetching'); + assert(vm.activeSessionVisibleMessages.length === Math.min(vm.activeSessionMessages.length, vm.sessionPreviewInitialBatchSize), 'session browser should keep recovered huge-line rendering batched'); }; diff --git a/tests/unit/agents-modal-guards.test.mjs b/tests/unit/agents-modal-guards.test.mjs index 7ba4c2fd..2e5dcf2c 100644 --- a/tests/unit/agents-modal-guards.test.mjs +++ b/tests/unit/agents-modal-guards.test.mjs @@ -11,9 +11,18 @@ const { createAgentsMethods } = await import( const { createCodexConfigMethods } = await import( pathToFileURL(path.join(__dirname, '..', '..', 'web-ui', 'modules', 'app.methods.codex-config.mjs')) ); +const { createClaudeConfigMethods } = await import( + pathToFileURL(path.join(__dirname, '..', '..', 'web-ui', 'modules', 'app.methods.claude-config.mjs')) +); const { createI18nMethods } = await import( pathToFileURL(path.join(__dirname, '..', '..', 'web-ui', 'modules', 'i18n.mjs')) ); +const { createRuntimeMethods } = await import( + pathToFileURL(path.join(__dirname, '..', '..', 'web-ui', 'modules', 'app.methods.runtime.mjs')) +); +const { buildSpeedTestIssue } = await import( + pathToFileURL(path.join(__dirname, '..', '..', 'web-ui', 'logic.runtime.mjs')) +); test('closeConfigTemplateModal ignores user close attempts while template apply is busy', () => { const methods = createCodexConfigMethods({ @@ -231,61 +240,103 @@ test('runHealthCheck treats backend error payloads as failures', async () => { }]); }); -test('runHealthCheck skips Claude speed tests when the primary health check already failed', async () => { +test('runHealthCheck checks only the selected Claude config', async () => { const methods = createCodexConfigMethods({ - api: async () => ({ error: 'health failed' }), + api: async (action) => { + throw new Error(`Claude batch health check should not call backend action: ${action}`); + }, getProviderConfigModeMeta() { return null; } }); - let claudeSpeedTestCalls = 0; + const speedTestCalls = []; + const runtimeMethods = createRuntimeMethods({ + api: async (action, payload) => { + assert.strictEqual(action, 'speed-test'); + speedTestCalls.push(payload); + if (payload.targetApi === 'chat_completions') return { ok: false, error: 'timeout' }; + return { ok: true, durationMs: payload.targetApi === 'responses' ? 11 : 22, status: 200 }; + } + }); const context = { ...createI18nMethods(), ...methods, + ...runtimeMethods, lang: 'zh', - providersList: ['alpha'], + providersList: ['codex-provider-should-not-run'], speedResults: {}, speedLoading: {}, + claudeSpeedResults: {}, + claudeSpeedLoading: {}, healthCheckLoading: false, healthCheckResult: { ok: true }, + healthCheckBatchTotal: 99, + healthCheckBatchDone: 99, + healthCheckBatchFailed: 99, configMode: 'claude', + currentClaudeConfig: 'chat', claudeConfigs: { - primary: { - baseUrl: 'https://example.com', - apiKey: 'secret' + anthropic: { + baseUrl: 'https://anthropic.example.com/v1', + apiKey: 'sk-anthropic', + model: 'claude-sonnet-4-6', + targetApi: 'responses' + }, + chat: { + baseUrl: 'https://openai.example.com/v1', + apiKey: 'sk-chat', + model: 'gpt-4.1', + targetApi: 'chat_completions' + }, + ollama: { + baseUrl: 'http://127.0.0.1:11434', + apiKey: '', + model: 'llama3.1:8b', + targetApi: 'ollama' } }, shownMessages: [], showMessage(message, type) { this.shownMessages.push({ message, type }); }, - async runClaudeSpeedTest() { - claudeSpeedTestCalls += 1; - return { ok: false, error: 'timeout' }; - } + buildSpeedTestIssue }; await methods.runHealthCheck.call(context); + const callsByTarget = Object.fromEntries(speedTestCalls.map((payload) => [payload.targetApi, payload])); assert.strictEqual(context.healthCheckLoading, false); - assert.strictEqual(claudeSpeedTestCalls, 1); + assert.strictEqual(context.healthCheckBatchTotal, 1); + assert.strictEqual(context.healthCheckBatchDone, 1); + assert.strictEqual(context.healthCheckBatchFailed, 1); + assert.deepStrictEqual(speedTestCalls.map((payload) => payload.targetApi).sort(), ['chat_completions']); + assert.strictEqual(callsByTarget.chat_completions.apiKey, 'sk-chat'); + assert.strictEqual(context.claudeSpeedResults.ollama, undefined); assert.strictEqual(context.healthCheckResult.ok, false); + assert.deepStrictEqual(context.healthCheckResult.remote.speedTests, { + chat: { ok: false, error: 'timeout' } + }); + assert.deepStrictEqual(context.healthCheckResult.issues, [{ + code: 'remote-speedtest-timeout', + message: '提供商 chat 远程测速超时', + suggestion: '检查网络或 base_url 是否可达' + }]); + assert.deepStrictEqual(context.shownMessages, []); }); -test('runHealthCheck preserves backend remote health result while appending speed test summaries', async () => { +test('runHealthCheck checks only the current Codex route and ignores unselected provider probe failures', async () => { + const apiCalls = []; + let configResolve; + let providersResolve; + const configPromise = new Promise((resolve) => { configResolve = resolve; }); + const providersPromise = new Promise((resolve) => { providersResolve = resolve; }); const methods = createCodexConfigMethods({ - api: async () => ({ - ok: true, - issues: [], - remote: { - type: 'remote-health-check', - provider: 'alpha', - endpoint: 'https://example.com/v1', - statusCode: 200, - ok: true, - message: 'ok' - } - }), + api: async (action, payload) => { + apiCalls.push({ action, payload }); + if (action === 'config-health-check') return configPromise; + if (action === 'providers-health') return providersPromise; + throw new Error(`unexpected action: ${action}`); + }, getProviderConfigModeMeta() { return null; } @@ -294,33 +345,423 @@ test('runHealthCheck preserves backend remote health result while appending spee ...createI18nMethods(), ...methods, lang: 'zh', - providersList: ['alpha', 'beta'], + providersList: [{ name: 'local' }, { name: 'bad' }, { name: 'system', nonDeletable: true }], + currentProvider: 'local', speedResults: {}, speedLoading: {}, healthCheckLoading: false, healthCheckResult: null, + healthCheckFailedProviderSelections: {}, + showHealthCheckModal: false, + healthCheckBatchTotal: 99, + healthCheckBatchDone: 99, + healthCheckBatchFailed: 99, configMode: 'codex', shownMessages: [], showMessage(message, type) { this.shownMessages.push({ message, type }); }, - async runSpeedTest(name) { - return { ok: true, durationMs: name === 'alpha' ? 10 : 20, status: 200 }; + shouldShowProviderDelete(provider) { + return !(provider && provider.nonDeletable); + }, + isToolConfigWriteAllowed() { + return true; + }, + async runSpeedTest() { + throw new Error('Codex settings health check must not run provider-card speed tests'); }, buildSpeedTestIssue() { - return null; + throw new Error('speed test issues should not be built'); + }, + runProvidersHealthCheck() { + throw new Error('runProvidersHealthCheck UI helper should not be used here'); } }; - await methods.runHealthCheck.call(context); + const runPromise = methods.runHealthCheck.call(context); + await Promise.resolve(); + assert.deepStrictEqual(apiCalls, [ + { action: 'config-health-check', payload: { remote: true } }, + { action: 'providers-health', payload: { remote: false } } + ]); + + providersResolve({ + ok: false, + currentProvider: 'local', + summary: { total: 3, green: 1, yellow: 1, red: 1 }, + providers: [ + { provider: 'local', status: 'green', issues: [], remote: { ok: true, statusCode: 200, message: 'ok' } }, + { provider: 'bad', status: 'red', issues: [{ code: 'remote-model-probe-http-error', message: 'bad HTTP 502' }], remote: { ok: false, statusCode: 502, message: 'bad' } }, + { provider: 'system', status: 'yellow', issues: [{ code: 'api-key-missing', message: 'system key missing' }], remote: null } + ] + }); + configResolve({ + ok: false, + issues: [{ code: 'remote-model-probe-http-error', message: 'local HTTP 502' }], + summary: { currentProvider: 'local', currentModel: 'e2e-model' }, + remote: { type: 'remote-health-check', provider: 'local', ok: false, statusCode: 502, message: 'local HTTP 502' } + }); + await runPromise; assert.strictEqual(context.healthCheckLoading, false); + assert.strictEqual(context.showHealthCheckModal, true); + assert.deepStrictEqual(context.providersHealthResult.providers.map((provider) => provider.provider), ['local', 'bad', 'system']); + assert.strictEqual(context.healthCheckBatchTotal, 1); + assert.strictEqual(context.healthCheckBatchDone, 1); + assert.strictEqual(context.healthCheckBatchFailed, 1); + assert.strictEqual(context.healthCheckResult.ok, false); assert.strictEqual(context.healthCheckResult.remote.type, 'remote-health-check'); - assert.strictEqual(context.healthCheckResult.remote.statusCode, 200); + assert.deepStrictEqual(context.healthCheckResult.issues.map((issue) => issue.provider), [undefined]); + assert.deepStrictEqual(context.getHealthCheckFailedProviderItems().map((item) => ({ + name: item.name, + status: item.status, + deletable: item.deletable, + detail: item.detail + })), [ + { name: 'local', status: 'red', deletable: true, detail: '502 · local HTTP 502' } + ]); + assert.deepStrictEqual(context.getSelectableHealthCheckFailedProviderItems().map((item) => item.name), ['local']); + assert.strictEqual(context.areAllHealthCheckFailedProvidersSelected(), false); + context.setAllHealthCheckFailedProviderSelections(true); + assert.deepStrictEqual(context.healthCheckFailedProviderSelections, { 'codex:local': true }); + assert.strictEqual(context.hasHealthCheckFailedProviderSelection(), true); + assert.strictEqual(context.areAllHealthCheckFailedProvidersSelected(), true); + context.setAllHealthCheckFailedProviderSelections(false); + assert.deepStrictEqual( + context.getSelectableHealthCheckFailedProviderItems().filter((item) => item.selected).map((item) => item.name), + [] + ); + assert.strictEqual(context.hasHealthCheckFailedProviderSelection(), false); + assert.deepStrictEqual(context.shownMessages, [{ message: '检查失败', type: 'error' }]); +}); + +test('deleteSelectedHealthCheckFailedProviders deletes only selected deletable failed providers', async () => { + const deleted = []; + const methods = createCodexConfigMethods({ + api: async (action, payload) => { + if (action === 'delete-provider') { + deleted.push(payload.name); + return { success: true }; + } + return { ok: true, issues: [], summary: {}, remote: null }; + }, + getProviderConfigModeMeta() { + return null; + } + }); + const context = { + ...createI18nMethods(), + ...methods, + lang: 'zh', + configMode: 'codex', + providersList: [{ name: 'bad' }, { name: 'system', nonDeletable: true }, { name: 'ok' }], + healthCheckFailedProviderSelections: {}, + healthCheckFailedProviderDeleting: false, + showHealthCheckModal: true, + healthCheckBatchTotal: 3, + healthCheckBatchDone: 3, + healthCheckBatchFailed: 2, + healthCheckResult: { + ok: false, + issues: [ + { provider: 'bad', message: 'bad HTTP 502' }, + { provider: 'system', message: 'system key missing' } + ], + remote: { + type: 'providers-health', + currentProvider: 'ok', + summary: { total: 3, green: 1, yellow: 1, red: 1 }, + providers: [ + { provider: 'ok', status: 'green', issues: [] }, + { provider: 'bad', status: 'red', issues: [{ message: 'bad HTTP 502' }] }, + { provider: 'system', status: 'yellow', issues: [{ message: 'system key missing' }] } + ] + } + }, + shownMessages: [], + showMessage(message, type) { + this.shownMessages.push({ message, type }); + }, + shouldShowProviderDelete(provider) { + return !(provider && provider.nonDeletable); + }, + isToolConfigWriteAllowed() { + return true; + } + }; + + context.setAllHealthCheckFailedProviderSelections(true); + assert.deepStrictEqual(context.healthCheckFailedProviderSelections, { 'codex:bad': true }); + assert.strictEqual(context.areAllHealthCheckFailedProvidersSelected(), true); + + await methods.deleteSelectedHealthCheckFailedProviders.call(context); + + assert.deepStrictEqual(deleted, ['bad']); + assert.deepStrictEqual(context.healthCheckFailedProviderSelections, {}); + assert.deepStrictEqual(context.healthCheckResult.remote.providers.map((provider) => provider.provider), ['ok', 'system']); + assert.deepStrictEqual(context.healthCheckResult.issues.map((issue) => issue.provider), ['system']); + assert.strictEqual(context.healthCheckBatchTotal, 2); + assert.strictEqual(context.healthCheckBatchFailed, 1); + assert.strictEqual(context.showHealthCheckModal, false); + assert.deepStrictEqual(context.shownMessages, [{ message: '已删除 1 个失败提供商', type: 'success' }]); +}); + +test('deleteSelectedHealthCheckFailedProviders bulk-deletes Claude configs and provider-cache refs without per-item confirmation', async () => { + const cacheDeletes = []; + const methods = createCodexConfigMethods({ + api: async (action, payload) => { + if (action === 'delete-provider-cache-record') { + cacheDeletes.push(payload); + return { success: true, removed: true }; + } + throw new Error(`Unexpected backend action for Claude bulk cleanup: ${action}`); + }, + getProviderConfigModeMeta() { + return null; + } + }); + const context = { + ...createI18nMethods(), + ...methods, + lang: 'zh', + configMode: 'claude', + claudeConfigs: { + bad: { name: 'bad', providerCacheRef: 'bad', source: 'provider-cache' }, + worse: { name: 'worse', providerCacheRef: 'worse-cache', source: 'provider-cache' }, + ok: { name: 'ok', providerCacheRef: 'ok-cache', source: 'provider-cache' } + }, + currentClaudeConfig: 'bad', + healthCheckFailedProviderSelections: {}, + healthCheckFailedProviderDeleting: false, + showHealthCheckModal: true, + healthCheckBatchTotal: 3, + healthCheckBatchDone: 3, + healthCheckBatchFailed: 2, + healthCheckResult: { + ok: false, + issues: [ + { provider: 'bad', message: 'bad failed' }, + { providerName: 'worse', message: 'worse failed' } + ], + remote: { + type: 'speed-test', + speedTests: { + bad: { ok: false, error: 'bad failed' }, + worse: { ok: false, error: 'worse failed' }, + ok: { ok: true, durationMs: 12, status: 200 } + } + } + }, + saved: 0, + refreshed: 0, + remembered: [], + applied: [], + shownMessages: [], + showMessage(message, type) { + this.shownMessages.push({ message, type }); + }, + isToolConfigWriteAllowed() { + return true; + }, + saveClaudeConfigs() { + this.saved += 1; + }, + refreshClaudeModelContext() { + this.refreshed += 1; + }, + rememberDeletedClaudeSettingsImport(config) { + this.remembered.push(config && config.providerCacheRef); + }, + async applyCurrentClaudeConfigSilently() { + this.applied.push(this.currentClaudeConfig); + return true; + }, + async requestConfirmDialog() { + throw new Error('bulk failed-provider cleanup must not request per-item confirmation'); + }, + async deleteClaudeConfig() { + throw new Error('bulk failed-provider cleanup must not call deleteClaudeConfig'); + } + }; + + context.setAllHealthCheckFailedProviderSelections(true); + assert.deepStrictEqual(context.healthCheckFailedProviderSelections, { 'claude:bad': true, 'claude:worse': true }); + + await methods.deleteSelectedHealthCheckFailedProviders.call(context); + + assert.deepStrictEqual(cacheDeletes, [ + { name: 'bad', group: 'claude' }, + { name: 'worse-cache', group: 'claude' } + ]); + assert.deepStrictEqual(Object.keys(context.claudeConfigs), ['ok']); + assert.strictEqual(context.currentClaudeConfig, 'ok'); + assert.strictEqual(context.saved, 1); + assert.strictEqual(context.refreshed, 0); + assert.deepStrictEqual(context.remembered, ['bad', 'worse-cache']); + assert.deepStrictEqual(context.applied, ['ok']); + assert.deepStrictEqual(context.healthCheckFailedProviderSelections, {}); + assert.deepStrictEqual(context.healthCheckResult.issues, []); assert.deepStrictEqual(context.healthCheckResult.remote.speedTests, { - alpha: { ok: true, durationMs: 10, status: 200 }, - beta: { ok: true, durationMs: 20, status: 200 } + ok: { ok: true, durationMs: 12, status: 200 } }); + assert.strictEqual(context.healthCheckResult.ok, true); + assert.strictEqual(context.healthCheckBatchTotal, 1); + assert.strictEqual(context.healthCheckBatchDone, 1); + assert.strictEqual(context.healthCheckBatchFailed, 0); + assert.strictEqual(context.showHealthCheckModal, false); + assert.deepStrictEqual(context.shownMessages, [{ message: '已删除 2 个失败提供商', type: 'success' }]); +}); + +test('deleteClaudeConfig prunes provider-cache source before local removal', async () => { + const cacheDeletes = []; + const methods = createClaudeConfigMethods({ + api: async (action, payload) => { + if (action === 'delete-provider-cache-record') { + cacheDeletes.push(payload); + return { success: true, removed: true }; + } + return { success: true }; + } + }); + const context = { + ...createI18nMethods(), + ...methods, + lang: 'zh', + claudeConfigs: { + bad: { name: 'bad', providerCacheRef: 'bad-cache', source: 'provider-cache' }, + ok: { name: 'ok' } + }, + currentClaudeConfig: 'bad', + saved: 0, + refreshed: 0, + remembered: [], + applied: [], + synced: 0, + shownMessages: [], + async requestConfirmDialog() { + return true; + }, + showMessage(message, type) { + this.shownMessages.push({ message, type }); + }, + saveClaudeConfigs() { + this.saved += 1; + }, + refreshClaudeModelContext() { + this.refreshed += 1; + }, + rememberDeletedClaudeSettingsImport(config) { + this.remembered.push(config && config.providerCacheRef); + }, + async applyCurrentClaudeConfigSilently() { + this.applied.push(this.currentClaudeConfig); + return true; + }, + syncClaudeBridgeProviders() { + this.synced += 1; + } + }; + + await methods.deleteClaudeConfig.call(context, 'bad'); + + assert.deepStrictEqual(cacheDeletes, [{ name: 'bad-cache', group: 'claude' }]); + assert.deepStrictEqual(Object.keys(context.claudeConfigs), ['ok']); + assert.strictEqual(context.currentClaudeConfig, 'ok'); + assert.strictEqual(context.saved, 1); + assert.strictEqual(context.refreshed, 0); + assert.deepStrictEqual(context.remembered, ['bad-cache']); + assert.deepStrictEqual(context.applied, ['ok']); + assert.deepStrictEqual(context.shownMessages, [{ message: '操作成功', type: 'success' }]); +}); + +test('deleteClaudeConfig reports provider-cache delete transport failures without local removal', async () => { + const methods = createClaudeConfigMethods({ + api: async (action) => { + if (action === 'delete-provider-cache-record') { + throw new Error('network down'); + } + return { success: true }; + } + }); + const context = { + ...createI18nMethods(), + ...methods, + lang: 'zh', + claudeConfigs: { + bad: { name: 'bad', providerCacheRef: 'bad-cache', source: 'provider-cache' }, + ok: { name: 'ok' } + }, + currentClaudeConfig: 'bad', + saved: 0, + refreshed: 0, + shownMessages: [], + async requestConfirmDialog() { + return true; + }, + showMessage(message, type) { + this.shownMessages.push({ message, type }); + }, + saveClaudeConfigs() { + this.saved += 1; + }, + refreshClaudeModelContext() { + this.refreshed += 1; + } + }; + + await methods.deleteClaudeConfig.call(context, 'bad'); + + assert.deepStrictEqual(Object.keys(context.claudeConfigs), ['bad', 'ok']); + assert.strictEqual(context.currentClaudeConfig, 'bad'); + assert.strictEqual(context.saved, 0); + assert.strictEqual(context.refreshed, 0); + assert.deepStrictEqual(context.shownMessages, [{ message: 'network down', type: 'error' }]); +}); + +test('deleteSelectedHealthCheckFailedProviders requires an explicit selected provider', async () => { + const methods = createCodexConfigMethods({ + api: async () => ({ ok: true, issues: [], summary: {}, remote: null }), + getProviderConfigModeMeta() { + return null; + } + }); + const context = { + ...createI18nMethods(), + ...methods, + lang: 'zh', + configMode: 'codex', + providersList: [{ name: 'bad' }], + healthCheckFailedProviderSelections: {}, + showHealthCheckModal: true, + healthCheckResult: { + ok: false, + issues: [{ provider: 'bad', message: 'bad HTTP 502' }], + remote: { + type: 'providers-health', + providers: [ + { provider: 'bad', status: 'red', issues: [{ message: 'bad HTTP 502' }] } + ] + } + }, + shownMessages: [], + showMessage(message, type) { + this.shownMessages.push({ message, type }); + }, + shouldShowProviderDelete() { + return true; + }, + isToolConfigWriteAllowed() { + return true; + }, + async deleteProvider() { + throw new Error('delete should require explicit selection'); + } + }; + + await methods.deleteSelectedHealthCheckFailedProviders.call(context); + + assert.strictEqual(context.showHealthCheckModal, true); + assert.deepStrictEqual(context.shownMessages, [{ message: '请先选择至少一个失败提供商', type: 'info' }]); }); test('applyCodexConfigDirect keeps the successful apply result when only the refresh fails', async () => { diff --git a/tests/unit/claude-settings-sync.test.mjs b/tests/unit/claude-settings-sync.test.mjs index b51d425c..4ca4dc7d 100644 --- a/tests/unit/claude-settings-sync.test.mjs +++ b/tests/unit/claude-settings-sync.test.mjs @@ -15,6 +15,9 @@ const { createCodexConfigMethods } = await import( const { createClaudeConfigMethods } = await import( pathToFileURL(path.join(__dirname, '..', '..', 'web-ui', 'modules', 'app.methods.claude-config.mjs')) ); +const { createStartupClaudeMethods } = await import( + pathToFileURL(path.join(__dirname, '..', '..', 'web-ui', 'modules', 'app.methods.startup-claude.mjs')) +); const { isLikelyBuiltinClaudeProxySettingsEnv, matchBuiltinClaudeProxyConfigFromSettings @@ -619,6 +622,69 @@ test('refreshClaudeSelectionFromSettings keeps builtin Claude proxy selection wi assert.deepStrictEqual(messages, []); }); +test('refreshClaudeSelectionFromSettings suppresses re-import of deleted Claude settings fingerprints', async () => { + const previousLocalStorage = globalThis.localStorage; + const stored = new Map([[ 'deletedClaudeSettingsImports', JSON.stringify([{ + baseUrl: 'https://zombie.example.com/anthropic', + model: 'claude-opus-4-6', + providerCacheRef: 'claude-cache-zombie', + deletedAt: Date.now() + }]) ]]); + globalThis.localStorage = { + getItem(key) { return stored.has(key) ? stored.get(key) : null; }, + setItem(key, value) { stored.set(key, String(value)); }, + removeItem(key) { stored.delete(key); } + }; + try { + const methods = createStartupClaudeMethods({ + api: async (action) => { + if (action === 'get-claude-settings') { + return { + exists: true, + env: { + ANTHROPIC_API_KEY: 'sk-zombie', + ANTHROPIC_BASE_URL: 'https://zombie.example.com/anthropic/', + ANTHROPIC_MODEL: 'claude-opus-4-6' + } + }; + } + return { success: true }; + } + }); + let ensureCount = 0; + const context = { + ...methods, + claudeConfigs: { + survivor: { + apiKey: 'sk-survivor', + baseUrl: 'https://survivor.example.com/anthropic', + model: 'claude-sonnet-4-6', + hasKey: true + } + }, + currentClaudeConfig: 'survivor', + currentClaudeModel: '', + matchClaudeConfigFromSettings: () => '', + matchBuiltinClaudeProxyConfigFromSettings: () => '', + ensureClaudeConfigFromSettings() { + ensureCount += 1; + throw new Error('deleted Claude settings must not be imported again'); + }, + refreshClaudeModelContext() {}, + resetClaudeModelsState() {}, + showMessage() {} + }; + + await methods.refreshClaudeSelectionFromSettings.call(context, { silent: true }); + + assert.strictEqual(ensureCount, 0); + assert.deepStrictEqual(Object.keys(context.claudeConfigs), ['survivor']); + assert.strictEqual(context.currentClaudeConfig, 'survivor'); + } finally { + globalThis.localStorage = previousLocalStorage; + } +}); + test('builtin Claude proxy settings detection requires loopback URL and generated proxy token shape', () => { assert.strictEqual(isLikelyBuiltinClaudeProxySettingsEnv({ ANTHROPIC_API_KEY: '0123456789abcdef0123456789abcdef0123456789abcdef', @@ -1509,6 +1575,99 @@ test('hydrateClaudeConfigsFromProviderCache restores Claude providers without st } }); +test('hydrateClaudeConfigsFromProviderCache prunes stale cache-backed Claude configs', async () => { + const previousLocalStorage = globalThis.localStorage; + const stored = new Map([['currentClaudeConfig', 'cache-zombie']]); + globalThis.localStorage = { + getItem(key) { return stored.has(key) ? stored.get(key) : null; }, + setItem(key, value) { stored.set(key, String(value)); }, + removeItem(key) { stored.delete(key); } + }; + try { + const methods = createClaudeConfigMethods({ + api: async (action) => { + if (action === 'get-claude-provider-cache-configs') { + return { + providers: [{ + name: 'cache-survivor', + baseUrl: 'https://survivor.example.com/anthropic', + model: 'claude-sonnet-4-6', + targetApi: 'responses', + hasKey: true, + providerCacheRef: 'cache-survivor', + source: 'provider-cache' + }] + }; + } + return { success: true }; + } + }); + const context = { + ...methods, + claudeConfigs: { + 'cache-zombie': { + apiKey: '', + baseUrl: 'https://zombie.example.com/anthropic', + model: 'claude-opus-4-6', + targetApi: 'responses', + hasKey: true, + providerCacheRef: 'cache-zombie', + source: 'provider-cache' + }, + 'manual-provider': { + apiKey: 'sk-manual', + baseUrl: 'https://manual.example.com/anthropic', + model: 'claude-haiku-4-5', + targetApi: 'responses', + hasKey: true + } + }, + currentClaudeConfig: 'cache-zombie', + showMessage() { throw new Error('should stay silent'); }, + syncClaudeBridgeProviders() {}, + refreshClaudeModelContext() {}, + t(key) { return key; } + }; + + const ok = await context.hydrateClaudeConfigsFromProviderCache({ silent: true }); + + assert.strictEqual(ok, true); + assert.strictEqual(context.claudeConfigs['cache-zombie'], undefined); + assert(context.claudeConfigs['cache-survivor'], 'live provider-cache config should remain hydrated'); + assert(context.claudeConfigs['manual-provider'], 'manual Claude config should not be pruned'); + assert.notStrictEqual(context.currentClaudeConfig, 'cache-zombie'); + assert.doesNotMatch(stored.get('claudeConfigs') || '', /cache-zombie/); + assert.match(stored.get('claudeConfigs') || '', /cache-survivor/); + } finally { + globalThis.localStorage = previousLocalStorage; + } +}); + +test('selectClaudeFallbackConfigName prefers applyable Claude configs over placeholder defaults', () => { + const methods = createClaudeConfigMethods({ api: async () => ({ success: true }) }); + const context = { + claudeConfigs: { + '智谱GLM': { + apiKey: '', + baseUrl: 'https://open.bigmodel.cn/api/anthropic', + model: 'glm-4.7', + targetApi: 'responses', + hasKey: false + }, + survivor: { + apiKey: '', + baseUrl: 'https://survivor.example.com/anthropic', + model: 'claude-sonnet-4-6', + providerCacheRef: 'survivor-cache', + source: 'provider-cache', + hasKey: true + } + } + }; + + assert.strictEqual(methods.selectClaudeFallbackConfigName.call(context, ['deleted']), 'survivor'); +}); + test('applyClaudeConfig accepts provider-cache backed Claude providers without browser api key', async () => { const previousLocalStorage = globalThis.localStorage; const stored = new Map(); diff --git a/tests/unit/config-tabs-ui.test.mjs b/tests/unit/config-tabs-ui.test.mjs index c236265a..6055425b 100644 --- a/tests/unit/config-tabs-ui.test.mjs +++ b/tests/unit/config-tabs-ui.test.mjs @@ -126,6 +126,10 @@ test('config template keeps expected config tabs in top and side navigation', () assert.match(styles, /\.task-action-row-right\s*\{[\s\S]*display:\s*flex;[\s\S]*flex-wrap:\s*wrap;/); assert.match(styles, /\.task-runtime-item-actions\s*\{[\s\S]*flex-direction:\s*row;[\s\S]*align-items:\s*center;/); } + for (const styles of [bundledStyles]) { + assert.match(styles, /\.health-failed-provider-main input\s*\{[\s\S]*flex:\s*0 0 auto;[\s\S]*width:\s*13px;[\s\S]*height:\s*13px;[\s\S]*accent-color:\s*var\(--color-brand-dark\);/); + assert.match(styles, /\.health-failed-provider-main > span\s*\{[\s\S]*min-width:\s*0;/); + } const sideGhostTab = sideRail.match(/
\s*<\/div>/)?.[0] || ''; assert.match(sideGhostTab, /class="side-item side-item-ghost"/); assert.match(sideGhostTab, /tabindex="-1"/); @@ -352,7 +356,16 @@ test('config template keeps expected config tabs in top and side navigation', () assert.match(html, / { assert.match(styles, /\.codex-config-grid\s*\{[\s\S]*grid-template-columns:\s*repeat\(auto-fit,\s*minmax\(min\(240px,\s*100%\),\s*1fr\)\);/); assert.match(styles, /\.codex-config-field\s*\{/); assert.match(styles, /\.codex-config-field\s*\{[\s\S]*min-width:\s*0;/); + assert.match(styles, /#panel-config-provider \.card-list > \.card,\s*#panel-config-claude \.card-list > \.card\s*\{[\s\S]*min-height:\s*88px;[\s\S]*padding-top:\s*22px;[\s\S]*padding-bottom:\s*22px;/); + assert.match(styles, /@media \(max-width: 540px\)\s*\{[\s\S]*#panel-config-provider \.card-list > \.card,\s*#panel-config-claude \.card-list > \.card\s*\{[\s\S]*min-height:\s*84px;[\s\S]*padding-top:\s*18px;[\s\S]*padding-bottom:\s*18px;/); + assert.match(styles, /@media \(hover: none\) and \(pointer: coarse\)\s*\{[\s\S]*#panel-config-provider \.card-list,\s*#panel-config-claude \.card-list\s*\{[\s\S]*grid-template-columns:\s*1fr;/); + assert.match(styles, /@media \(hover: none\) and \(pointer: coarse\)\s*\{[\s\S]*#panel-config-provider \.card-list > \.card,\s*#panel-config-claude \.card-list > \.card\s*\{[\s\S]*min-height:\s*96px;[\s\S]*padding-top:\s*24px;[\s\S]*padding-bottom:\s*24px;/); + assert.match(styles, /#panel-config-provider \.card-list > \.card \.card-title,\s*#panel-config-claude \.card-list > \.card \.card-title\s*\{[\s\S]*flex-wrap:\s*wrap;[\s\S]*white-space:\s*normal;[\s\S]*overflow-wrap:\s*anywhere;/); + assert.match(styles, /#panel-config-provider \.card-list > \.card \.card-title > span:first-child,\s*#panel-config-claude \.card-list > \.card \.card-title > span:first-child\s*\{[\s\S]*white-space:\s*normal;[\s\S]*overflow-wrap:\s*anywhere;/); }); test('settings tab header actions keep compact tool buttons inline on wider screens', () => { @@ -660,3 +679,19 @@ test('settings tab header actions keep compact tool buttons inline on wider scre assert.doesNotMatch(styles, /\.market-online-list\s*\{/); assert.doesNotMatch(styles, /\.market-ecosystem-grid\s*\{/); }); + +test('session responsive styles keep mobile toolbar reachable without deprecated wrapping', () => { + const toolbarStyles = readProjectFile('web-ui/styles/sessions-toolbar-trash.css'); + const responsiveStyles = readProjectFile('web-ui/styles/responsive.css'); + + assert.match( + toolbarStyles, + /\.session-toolbar-secondary\s*\{[\s\S]*?justify-content:\s*flex-start;[\s\S]*?flex-wrap:\s*wrap;[\s\S]*?\}/ + ); + assert.match( + toolbarStyles, + /@media \(min-width: 1101px\) \{[\s\S]*?\.session-toolbar-secondary\s*\{[\s\S]*?justify-content:\s*flex-end;[\s\S]*?flex-wrap:\s*nowrap;[\s\S]*?\}/ + ); + assert.doesNotMatch(responsiveStyles, /word-break:\s*break-word/); + assert.match(responsiveStyles, /\.session-preview-title\s*\{[\s\S]*?overflow-wrap:\s*anywhere;[\s\S]*?\}/); +}); diff --git a/tests/unit/local-bridge-claude-target-api.test.mjs b/tests/unit/local-bridge-claude-target-api.test.mjs new file mode 100644 index 00000000..800e81db --- /dev/null +++ b/tests/unit/local-bridge-claude-target-api.test.mjs @@ -0,0 +1,526 @@ +import assert from 'node:assert/strict'; +import http from 'node:http'; +import { once } from 'node:events'; +import { PassThrough } from 'node:stream'; +import { mkdtemp, writeFile, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const test = globalThis.test || (await import('node:test')).default; +const { createLocalBridgeHttpHandler } = require('../../cli/local-bridge.js'); + +function listen(server) { + server.listen(0, '127.0.0.1'); + return once(server, 'listening').then(() => { + const addr = server.address(); + return { port: addr.port, host: addr.address }; + }); +} + +function closeServer(server) { + return new Promise((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())); + }); +} + +async function requestText(url, { method = 'GET', headers = {}, body } = {}) { + return new Promise((resolve, reject) => { + const u = new URL(url); + const req = http.request({ + hostname: u.hostname, + port: u.port, + path: `${u.pathname}${u.search}`, + method, + headers + }, (res) => { + const chunks = []; + res.on('data', (chunk) => chunks.push(chunk)); + res.on('end', () => resolve({ + status: res.statusCode || 0, + headers: res.headers || {}, + text: Buffer.concat(chunks).toString('utf-8') + })); + }); + req.on('error', reject); + if (body !== undefined) { + req.write(typeof body === 'string' ? body : JSON.stringify(body)); + } + req.end(); + }); +} + +async function callHandlerText(handler, path, { method = 'GET', headers = {}, body, remoteAddress = '127.0.0.1' } = {}) { + return new Promise((resolve, reject) => { + const req = new PassThrough(); + req.method = method; + req.url = path; + req.headers = Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value])); + req.socket = { remoteAddress }; + const chunks = []; + const res = { + statusCode: 200, + headers: {}, + headersSent: false, + writeHead(statusCode, responseHeaders = {}) { + this.statusCode = statusCode; + this.headers = { ...this.headers, ...responseHeaders }; + this.headersSent = true; + }, + write(chunk) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))); + }, + end(chunk) { + if (chunk !== undefined) this.write(chunk); + resolve({ status: this.statusCode, headers: this.headers, text: Buffer.concat(chunks).toString('utf-8') }); + } + }; + try { + if (!handler(req, res)) { + resolve({ status: 404, headers: {}, text: 'not handled' }); + return; + } + process.nextTick(() => { + if (body !== undefined) req.end(typeof body === 'string' ? body : JSON.stringify(body)); + else req.end(); + }); + } catch (err) { + reject(err); + } + }); +} + +async function createClaudeBridge({ provider }) { + const tmpDir = await mkdtemp(path.join(os.tmpdir(), 'codexmate-claude-local-bridge-')); + const claudeProvidersFile = path.join(tmpDir, 'claude-providers.json'); + await writeFile(claudeProvidersFile, JSON.stringify({ + providers: { test: provider }, + excludedProviders: [] + }), 'utf-8'); + + const handler = createLocalBridgeHttpHandler({ + readConfigFn: () => ({ model_providers: {} }), + claudeProvidersFile, + expectedToken: 'codexmate', + maxBodySize: 1024 * 1024, + maxUpstreamBytes: 1024 * 1024 + }); + const bridge = http.createServer((req, res) => { + if (!handler(req, res)) { + res.statusCode = 404; + res.end('not handled'); + } + }); + const { port } = await listen(bridge); + return { tmpDir, bridge, handler, baseUrl: `http://127.0.0.1:${port}` }; +} + +async function createCodexBridge({ provider }) { + const handler = createLocalBridgeHttpHandler({ + readConfigFn: () => ({ + model_providers: { + local: { + name: 'local', + base_url: 'http://127.0.0.1:3737/bridge/local/v1', + wire_api: 'responses', + requires_openai_auth: true, + preferred_auth_method: 'codexmate', + codexmate_bridge: 'local' + }, + e2e: provider + } + }), + expectedToken: 'codexmate', + maxBodySize: 1024 * 1024, + maxUpstreamBytes: 1024 * 1024 + }); + const bridge = http.createServer((req, res) => { + if (!handler(req, res)) { + res.statusCode = 404; + res.end('not handled'); + } + }); + const { port } = await listen(bridge); + return { bridge, handler, baseUrl: `http://127.0.0.1:${port}` }; +} + +test('codex local bridge forwards responses probes as JSON objects instead of string bodies', async () => { + let captured = null; + const upstream = http.createServer((req, res) => { + const chunks = []; + req.on('data', (chunk) => chunks.push(chunk)); + req.on('end', () => { + captured = { + method: req.method, + url: req.url, + headers: req.headers, + body: Buffer.concat(chunks).toString('utf-8') + }; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + id: 'resp_codex_local', + object: 'response', + model: 'e2e-model', + output_text: 'ok', + output: [{ type: 'message', content: [{ type: 'output_text', text: 'ok' }] }] + })); + }); + }); + const { port: upstreamPort } = await listen(upstream); + const bridge = await createCodexBridge({ + provider: { + name: 'e2e', + base_url: `http://127.0.0.1:${upstreamPort}/v1`, + wire_api: 'responses', + requires_openai_auth: false, + preferred_auth_method: 'sk-e2e' + } + }); + + try { + const resp = await requestText(`${bridge.baseUrl}/bridge/local/v1/responses`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: 'Bearer codexmate' }, + body: { model: 'e2e-model', input: 'ping', max_output_tokens: 1 } + }); + + assert.equal(resp.status, 200); + assert.equal(captured.method, 'POST'); + assert.equal(captured.url, '/v1/responses'); + const parsed = JSON.parse(captured.body); + assert.equal(typeof parsed, 'object'); + assert.equal(parsed.model, 'e2e-model'); + assert.equal(parsed.input, 'ping'); + assert.notEqual(typeof parsed, 'string'); + assert.equal(JSON.parse(resp.text).id, 'resp_codex_local'); + } finally { + await closeServer(bridge.bridge); + await closeServer(upstream); + } +}); + +test('claude local bridge keeps responses providers on Anthropic /v1/messages without double v1', async () => { + let captured = null; + const upstream = http.createServer((req, res) => { + const chunks = []; + req.on('data', (chunk) => chunks.push(chunk)); + req.on('end', () => { + captured = { + method: req.method, + url: req.url, + headers: req.headers, + body: Buffer.concat(chunks).toString('utf-8') + }; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + id: 'msg_native', + type: 'message', + role: 'assistant', + model: 'native-model', + content: [{ type: 'text', text: 'native ok' }], + stop_reason: 'end_turn', + usage: { input_tokens: 1, output_tokens: 2 } + })); + }); + }); + const { port: upstreamPort } = await listen(upstream); + const bridge = await createClaudeBridge({ + provider: { + baseUrl: `http://127.0.0.1:${upstreamPort}/v1`, + apiKey: 'sk-native', + model: 'native-model', + targetApi: 'responses' + } + }); + + try { + const resp = await requestText(`${bridge.baseUrl}/bridge/claude-local/v1/messages`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: { model: 'claude-from-client', messages: [{ role: 'user', content: 'ping' }] } + }); + + assert.equal(resp.status, 200); + assert.equal(captured.url, '/v1/messages'); + assert.equal(captured.method, 'POST'); + assert.equal(captured.headers['x-api-key'], 'sk-native'); + assert.equal(captured.headers['anthropic-version'], '2023-06-01'); + assert.equal(JSON.parse(captured.body).model, 'native-model'); + assert.equal(JSON.parse(resp.text).id, 'msg_native'); + } finally { + await closeServer(bridge.bridge); + await closeServer(upstream); + await rm(bridge.tmpDir, { recursive: true, force: true }); + } +}); + +test('claude local bridge native passthrough does not synthesize a body for empty GET requests', async () => { + let captured = null; + const upstream = http.createServer((req, res) => { + const chunks = []; + req.on('data', (chunk) => chunks.push(chunk)); + req.on('end', () => { + captured = { + method: req.method, + url: req.url, + headers: req.headers, + body: Buffer.concat(chunks).toString('utf-8') + }; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ data: [{ id: 'native-model' }] })); + }); + }); + const { port: upstreamPort } = await listen(upstream); + const bridge = await createClaudeBridge({ + provider: { + baseUrl: `http://127.0.0.1:${upstreamPort}/v1`, + apiKey: 'sk-native', + model: 'native-model', + targetApi: 'responses' + } + }); + + try { + const resp = await requestText(`${bridge.baseUrl}/bridge/claude-local/v1/models`, { method: 'GET' }); + + assert.equal(resp.status, 200); + assert.equal(captured.method, 'GET'); + assert.equal(captured.url, '/v1/models'); + assert.equal(captured.body, ''); + assert.equal(captured.headers['content-length'], undefined); + assert.equal(captured.headers['x-api-key'], 'sk-native'); + } finally { + await closeServer(bridge.bridge); + await closeServer(upstream); + await rm(bridge.tmpDir, { recursive: true, force: true }); + } +}); + +test('claude local bridge native passthrough returns 400 for invalid JSON bodies', async () => { + let upstreamCalled = false; + const upstream = http.createServer((req, res) => { + upstreamCalled = true; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('{}'); + }); + const { port: upstreamPort } = await listen(upstream); + const bridge = await createClaudeBridge({ + provider: { + baseUrl: `http://127.0.0.1:${upstreamPort}/v1`, + apiKey: 'sk-native', + model: 'native-model', + targetApi: 'responses' + } + }); + + try { + const resp = await requestText(`${bridge.baseUrl}/bridge/claude-local/v1/messages`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{bad json' + }); + + assert.equal(resp.status, 400); + assert.equal(upstreamCalled, false); + assert.match(JSON.parse(resp.text).error, /JSON|Unexpected|invalid/i); + } finally { + await closeServer(bridge.bridge); + await closeServer(upstream); + await rm(bridge.tmpDir, { recursive: true, force: true }); + } +}); + +test('claude local bridge converts chat_completions providers behind /v1/messages', async () => { + let captured = null; + const upstream = http.createServer((req, res) => { + const chunks = []; + req.on('data', (chunk) => chunks.push(chunk)); + req.on('end', () => { + captured = { + method: req.method, + url: req.url, + headers: req.headers, + body: JSON.parse(Buffer.concat(chunks).toString('utf-8') || '{}') + }; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + id: 'chatcmpl_test', + model: 'gpt-test', + choices: [{ index: 0, finish_reason: 'stop', message: { role: 'assistant', content: 'chat ok' } }], + usage: { prompt_tokens: 3, completion_tokens: 4, total_tokens: 7 } + })); + }); + }); + const { port: upstreamPort } = await listen(upstream); + const bridge = await createClaudeBridge({ + provider: { + baseUrl: `http://127.0.0.1:${upstreamPort}/v1`, + apiKey: 'sk-chat', + model: 'gpt-test', + targetApi: 'chat_completions' + } + }); + + try { + const resp = await requestText(`${bridge.baseUrl}/bridge/claude-local/v1/messages`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: { model: 'claude-from-client', max_tokens: 32, messages: [{ role: 'user', content: 'ping' }] } + }); + + assert.equal(resp.status, 200); + assert.equal(captured.url, '/v1/chat/completions'); + assert.equal(captured.headers.authorization, 'Bearer sk-chat'); + assert.equal(captured.body.model, 'gpt-test'); + assert.equal(captured.body.max_tokens, 32); + assert.equal(captured.body.messages.at(-1).content, 'ping'); + const body = JSON.parse(resp.text); + assert.equal(body.type, 'message'); + assert.equal(body.content[0].text, 'chat ok'); + assert.equal(body.usage.input_tokens, 3); + assert.equal(body.usage.output_tokens, 4); + } finally { + await closeServer(bridge.bridge); + await closeServer(upstream); + await rm(bridge.tmpDir, { recursive: true, force: true }); + } +}); + +test('claude local bridge transform providers return 400 for empty message bodies', async () => { + let upstreamCalled = false; + const upstream = http.createServer((req, res) => { + upstreamCalled = true; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('{}'); + }); + const { port: upstreamPort } = await listen(upstream); + const bridge = await createClaudeBridge({ + provider: { + baseUrl: `http://127.0.0.1:${upstreamPort}/v1`, + apiKey: 'sk-chat', + model: 'gpt-test', + targetApi: 'chat_completions' + } + }); + + try { + const resp = await requestText(`${bridge.baseUrl}/bridge/claude-local/v1/messages`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }); + + assert.equal(resp.status, 400); + assert.equal(upstreamCalled, false); + assert.match(JSON.parse(resp.text).error, /empty body/i); + } finally { + await closeServer(bridge.bridge); + await closeServer(upstream); + await rm(bridge.tmpDir, { recursive: true, force: true }); + } +}); + +test('claude local bridge converts ollama providers to /api/chat without injecting /v1', async () => { + let captured = null; + const upstream = http.createServer((req, res) => { + const chunks = []; + req.on('data', (chunk) => chunks.push(chunk)); + req.on('end', () => { + captured = { + method: req.method, + url: req.url, + headers: req.headers, + body: JSON.parse(Buffer.concat(chunks).toString('utf-8') || '{}') + }; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + model: 'llama3.1:8b', + message: { role: 'assistant', content: 'ollama ok' }, + done: true, + prompt_eval_count: 5, + eval_count: 6 + })); + }); + }); + const { port: upstreamPort } = await listen(upstream); + const bridge = await createClaudeBridge({ + provider: { + baseUrl: `http://127.0.0.1:${upstreamPort}`, + apiKey: '', + model: 'llama3.1:8b', + targetApi: 'ollama' + } + }); + + try { + const resp = await requestText(`${bridge.baseUrl}/bridge/claude-local/v1/messages`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: { model: 'claude-from-client', messages: [{ role: 'user', content: 'ping' }] } + }); + + assert.equal(resp.status, 200); + assert.equal(captured.url, '/api/chat'); + assert.equal(captured.headers.authorization, undefined); + assert.equal(captured.body.model, 'llama3.1:8b'); + assert.equal(captured.body.messages.at(-1).content, 'ping'); + const body = JSON.parse(resp.text); + assert.equal(body.type, 'message'); + assert.equal(body.content[0].text, 'ollama ok'); + assert.equal(body.usage.input_tokens, 5); + assert.equal(body.usage.output_tokens, 6); + } finally { + await closeServer(bridge.bridge); + await closeServer(upstream); + await rm(bridge.tmpDir, { recursive: true, force: true }); + } +}); + +test('claude local bridge does not forward the bridge token as upstream auth', async () => { + let captured = null; + const upstream = http.createServer((req, res) => { + const chunks = []; + req.on('data', (chunk) => chunks.push(chunk)); + req.on('end', () => { + captured = { + method: req.method, + url: req.url, + headers: req.headers, + body: JSON.parse(Buffer.concat(chunks).toString('utf-8') || '{}') + }; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + model: 'llama3.1:8b', + message: { role: 'assistant', content: 'ollama ok' }, + done: true + })); + }); + }); + const { port: upstreamPort } = await listen(upstream); + const bridge = await createClaudeBridge({ + provider: { + baseUrl: `http://127.0.0.1:${upstreamPort}`, + apiKey: '', + model: 'llama3.1:8b', + targetApi: 'ollama' + } + }); + + try { + const resp = await callHandlerText(bridge.handler, '/bridge/claude-local/v1/messages', { + method: 'POST', + remoteAddress: '203.0.113.10', + headers: { 'Content-Type': 'application/json', Authorization: 'Bearer codexmate' }, + body: { model: 'claude-from-client', messages: [{ role: 'user', content: 'ping' }] } + }); + + assert.equal(resp.status, 200); + assert.equal(captured.url, '/api/chat'); + assert.equal(captured.headers.authorization, undefined); + assert.equal(captured.body.model, 'llama3.1:8b'); + } finally { + await closeServer(bridge.bridge); + await closeServer(upstream); + await rm(bridge.tmpDir, { recursive: true, force: true }); + } +}); diff --git a/tests/unit/provider-default-names.test.mjs b/tests/unit/provider-default-names.test.mjs new file mode 100644 index 00000000..621dfc6e --- /dev/null +++ b/tests/unit/provider-default-names.test.mjs @@ -0,0 +1,123 @@ +import assert from 'assert'; + +import { createProvidersMethods } from '../../web-ui/modules/app.methods.providers.mjs'; +import { createClaudeConfigMethods } from '../../web-ui/modules/app.methods.claude-config.mjs'; +import { createStartupClaudeMethods } from '../../web-ui/modules/app.methods.startup-claude.mjs'; +import { + nextClaudeConfigName, + nextCodexProviderName, + nextNumericProviderName +} from '../../web-ui/modules/provider-default-names.mjs'; + +test('nextNumericProviderName returns the first free positive numeric name', () => { + assert.strictEqual(nextNumericProviderName(['1', '3', 'foo', '02', 'local']), '4'); + assert.strictEqual(nextNumericProviderName([{ name: '1' }, { name: '2' }, { name: 'custom' }]), '3'); + assert.strictEqual(nextNumericProviderName(['foo', 'local']), '1'); +}); + +test('Codex add-provider modal defaults to an auto-increment numeric provider name', () => { + const methods = createProvidersMethods({ api: async () => ({ success: true }) }); + const context = { + providersList: [ + { name: 'local' }, + { name: '1' }, + { name: '2' }, + { name: 'custom' } + ], + newProvider: { name: 'stale', url: 'https://old.example.test', key: 'sk-old', model: 'old' }, + showAddProviderKey: true, + showAddModal: false + }; + + methods.openAddProviderModal.call(context); + + assert.strictEqual(context.showAddModal, true); + assert.strictEqual(context.showAddProviderKey, false); + assert.deepStrictEqual(context.newProvider, { + name: '3', + url: '', + key: '', + model: '', + useTransform: false + }); + + context.providersList.push({ name: '3' }); + methods.closeAddModal.call(context); + assert.strictEqual(context.showAddModal, false); + assert.strictEqual(context.newProvider.name, '4'); +}); + +test('Claude add-config modal defaults to an auto-increment numeric config name', () => { + const startupMethods = createStartupClaudeMethods({ api: async () => ({}) }); + const claudeMethods = createClaudeConfigMethods({ api: async () => ({}) }); + const context = { + claudeConfigs: { + '智谱GLM': {}, + '1': {}, + '3': {}, + 'custom': {} + }, + newClaudeConfig: { name: 'stale', apiKey: 'sk-old', baseUrl: 'https://old.example.test', model: 'old' }, + showAddClaudeConfigKey: true, + showClaudeConfigModal: false + }; + + startupMethods.openClaudeConfigModal.call(context); + + assert.strictEqual(context.showClaudeConfigModal, true); + assert.strictEqual(context.showAddClaudeConfigKey, false); + assert.deepStrictEqual(context.newClaudeConfig, { + name: '2', + apiKey: '', + externalCredentialType: '', + baseUrl: '', + model: '', + targetApi: 'responses' + }); + + context.claudeConfigs['2'] = {}; + claudeMethods.closeClaudeConfigModal.call(context); + assert.strictEqual(context.showClaudeConfigModal, false); + assert.strictEqual(context.newClaudeConfig.name, '4'); +}); + +test('clone provider/config modals keep manual naming instead of auto-numbering', () => { + const codexMethods = createProvidersMethods({ api: async () => ({ success: true }) }); + const claudeMethods = createClaudeConfigMethods({ api: async () => ({}) }); + + const codexContext = { + newProvider: {}, + showAddProviderKey: true, + showAddModal: false + }; + codexMethods.openCloneProviderModal.call(codexContext, { + name: '1', + url: 'https://codex.example.test/v1', + upstreamUrl: '', + codexmate_bridge: '' + }); + assert.strictEqual(codexContext.showAddModal, true); + assert.strictEqual(codexContext.newProvider.name, ''); + assert.strictEqual(codexContext.newProvider.url, 'https://codex.example.test/v1'); + + const claudeContext = { + claudeConfigs: { '1': {}, '2': {} }, + newClaudeConfig: {}, + showAddClaudeConfigKey: true, + showClaudeConfigModal: false + }; + claudeMethods.openCloneClaudeConfigModal.call(claudeContext, '1', { + apiKey: 'sk-source', + baseUrl: 'https://claude.example.test', + model: 'claude-source', + targetApi: 'responses' + }); + assert.strictEqual(claudeContext.showClaudeConfigModal, true); + assert.strictEqual(claudeContext.newClaudeConfig.name, ''); + assert.strictEqual(claudeContext.newClaudeConfig.baseUrl, 'https://claude.example.test'); +}); + +test('provider default-name helpers read Codex provider lists and Claude config maps', () => { + assert.strictEqual(nextCodexProviderName([{ name: '1' }, { name: '2' }]), '3'); + assert.strictEqual(nextClaudeConfigName({ '1': {}, '2': {}, named: {} }), '3'); +}); diff --git a/tests/unit/provider-share-command.test.mjs b/tests/unit/provider-share-command.test.mjs index 72bbbf9e..48cc736b 100644 --- a/tests/unit/provider-share-command.test.mjs +++ b/tests/unit/provider-share-command.test.mjs @@ -188,6 +188,7 @@ test('tool config write guard maps provider write actions to per-tab permission assert.strictEqual(getApiToolConfigWriteTarget('apply-claude-config'), 'claude'); assert.strictEqual(getApiToolConfigWriteTarget('claude-local-bridge-set-excluded'), 'claude'); assert.strictEqual(getApiToolConfigWriteTarget('claude-local-bridge-sync-providers'), 'claude'); + assert.strictEqual(getApiToolConfigWriteTarget('delete-provider-cache-record'), 'claude'); assert.strictEqual(getApiToolConfigWriteTarget('status'), ''); assert.strictEqual(getApiToolConfigWriteTarget('list-sessions'), ''); }); diff --git a/tests/unit/providers-validation.test.mjs b/tests/unit/providers-validation.test.mjs index 3d42dedf..55e100ea 100644 --- a/tests/unit/providers-validation.test.mjs +++ b/tests/unit/providers-validation.test.mjs @@ -94,7 +94,7 @@ test('addProvider normalizes trimmed values and submits sanitized payload', asyn } }]); assert.strictEqual(context.showAddModal, false); - assert.deepStrictEqual(context.newProvider, { name: '', url: '', key: '', model: '', useTransform: false }); + assert.deepStrictEqual(context.newProvider, { name: '1', url: '', key: '', model: '', useTransform: false }); // c3c9ee5:增删改不再触发 loadAll,改为本地 providersList 增量更新。 assert.deepStrictEqual(loadAllCalls, []); assert.ok( diff --git a/tests/unit/run.mjs b/tests/unit/run.mjs index 8549ba91..610f4529 100644 --- a/tests/unit/run.mjs +++ b/tests/unit/run.mjs @@ -43,6 +43,7 @@ await import(pathToFileURL(path.join(__dirname, 'session-actions-standalone.test await import(pathToFileURL(path.join(__dirname, 'session-resume-command.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'session-header-actions-layout.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'session-browser-timeline-regression.test.mjs'))); +await import(pathToFileURL(path.join(__dirname, 'session-workspace-summary.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'session-detail-preview-fast.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'session-usage.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'session-usage-backend.test.mjs'))); @@ -54,10 +55,12 @@ await import(pathToFileURL(path.join(__dirname, 'provider-share-command.test.mjs await import(pathToFileURL(path.join(__dirname, 'web-ui-preferences.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'provider-cache-records.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'providers-validation.test.mjs'))); +await import(pathToFileURL(path.join(__dirname, 'provider-default-names.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'provider-switch-regression.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'codex-proxy-options.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'builtin-proxy-responses-shim.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'claude-proxy-adapter.test.mjs'))); +await import(pathToFileURL(path.join(__dirname, 'local-bridge-claude-target-api.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'coderabbit-workflows.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'release-changelog.test.mjs'))); await import(pathToFileURL(path.join(__dirname, 'update-version-status.test.mjs'))); diff --git a/tests/unit/session-workspace-summary.test.mjs b/tests/unit/session-workspace-summary.test.mjs new file mode 100644 index 00000000..96a073cc --- /dev/null +++ b/tests/unit/session-workspace-summary.test.mjs @@ -0,0 +1,119 @@ +import assert from 'assert'; +import path from 'path'; +import { fileURLToPath, pathToFileURL } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const { buildSessionWorkspaceSummary, createSessionComputed } = await import( + pathToFileURL(path.join(__dirname, '..', '..', 'web-ui', 'modules', 'app.computed.session.mjs')) +); +const { createSessionActionMethods } = await import( + pathToFileURL(path.join(__dirname, '..', '..', 'web-ui', 'modules', 'app.methods.session-actions.mjs')) +); +const { zh } = await import(pathToFileURL(path.join(__dirname, '..', '..', 'web-ui', 'modules', 'i18n', 'locales', 'zh.mjs'))); +const { zhTw } = await import(pathToFileURL(path.join(__dirname, '..', '..', 'web-ui', 'modules', 'i18n', 'locales', 'zh-tw.mjs'))); +const { ja } = await import(pathToFileURL(path.join(__dirname, '..', '..', 'web-ui', 'modules', 'i18n', 'locales', 'ja.mjs'))); +const { vi } = await import(pathToFileURL(path.join(__dirname, '..', '..', 'web-ui', 'modules', 'i18n', 'locales', 'vi.mjs'))); + +test('buildSessionWorkspaceSummary extracts reusable project memory signals', () => { + const summary = buildSessionWorkspaceSummary( + { source: 'codex', sourceLabel: 'Codex', cwd: '/repo/codexmate' }, + [ + { + normalizedRole: 'user', + timestamp: '2026-06-24T10:00:00.000Z', + text: '优化会话浏览 tab,后续要覆盖真实端到端场景。' + }, + { + normalizedRole: 'assistant', + timestamp: '2026-06-24T10:01:00.000Z', + text: '已修改 README.md、/repo/src/foo.ts、web-ui/partials/index/panel-sessions.html、web-ui/styles/sessions-preview.css、web-ui\\styles\\sessions-toolbar-trash.css 和 C:\\repo\\src\\session-summary.ts。npm run test:unit。https://github.com/SakuraByteCore/codexmate/pull/999。另见 https://example.com/docs. 风险:如果摘要误把噪音当 blocker,需要继续收敛。' + } + ], + { + messagesLabel: 'Messages', + userLabel: 'User', + assistantLabel: 'Assistant', + commandsLabel: 'Commands', + artifactsLabel: 'Artifacts', + risksLabel: 'Risks' + } + ); + + assert.strictEqual(summary.available, true); + assert.strictEqual(summary.messageCount, 2); + assert.deepStrictEqual(summary.roleCounts, { user: 1, assistant: 1, system: 0, other: 0 }); + assert(summary.signals.some(item => item.includes('优化会话浏览 tab'))); + assert(summary.commands.includes('npm run test:unit')); + assert(summary.files.includes('web-ui/partials/index/panel-sessions.html')); + assert(summary.files.includes('web-ui/styles/sessions-preview.css')); + assert(summary.files.includes('web-ui\\styles\\sessions-toolbar-trash.css')); + assert(summary.files.includes('C:\\repo\\src\\session-summary.ts')); + assert(summary.files.includes('README.md')); + assert(summary.files.includes('/repo/src/foo.ts')); + assert(summary.links.includes('https://github.com/SakuraByteCore/codexmate/pull/999')); + assert(summary.links.includes('https://example.com/docs')); + assert(!summary.links.includes('https://example.com/docs.')); + assert(summary.risks.some(item => item.includes('风险'))); + assert(summary.nextSteps.some(item => item.includes('后续'))); + assert.match(summary.briefText, /Session workspace brief|会话工作简报|Messages/); +}); + +test('session workspace locale labels are translated for changed locales', () => { + assert.strictEqual(zh['sessions.workspace.kicker'], '工作记忆'); + assert.strictEqual(zh['sessions.workspace.metric.user'], '用户'); + assert.strictEqual(zh['sessions.workspace.metric.assistant'], '助手'); + + assert.strictEqual(zhTw['sessions.workspace.kicker'], '工作記憶'); + assert.strictEqual(zhTw['sessions.workspace.metric.user'], '使用者'); + assert.strictEqual(zhTw['sessions.workspace.metric.assistant'], '助手'); + + assert.strictEqual(ja['sessions.workspace.kicker'], '作業メモリ'); + assert.strictEqual(ja['sessions.workspace.metric.messages'], 'メッセージ'); + assert.strictEqual(ja['sessions.workspace.metric.user'], 'ユーザー'); + assert.strictEqual(ja['sessions.workspace.metric.assistant'], 'アシスタント'); + assert.strictEqual(ja['sessions.workspace.metric.commands'], 'コマンド'); + assert.strictEqual(ja['sessions.workspace.metric.artifacts'], '成果物'); + + assert.strictEqual(vi['sessions.workspace.kicker'], 'Bộ nhớ công việc'); + assert.strictEqual(vi['sessions.workspace.metric.user'], 'Người dùng'); + assert.strictEqual(vi['sessions.workspace.metric.assistant'], 'Trợ lý'); +}); + +test('activeSessionWorkspaceSummary computed stays empty without an active session', () => { + const computed = createSessionComputed(); + const summary = computed.activeSessionWorkspaceSummary.call({ + mainTab: 'sessions', + activeSession: null, + activeSessionMessages: [], + t(key) { return key; } + }); + + assert.strictEqual(summary.available, false); + assert.strictEqual(summary.messageCount, 0); +}); + +test('copySessionWorkspaceBrief copies the structured brief text', async () => { + const methods = createSessionActionMethods({ api: async () => ({}) }); + const copied = []; + const messages = []; + const context = { + activeSessionWorkspaceSummary: { + briefText: '# Brief\n\n- web-ui/partials/index/panel-sessions.html' + }, + fallbackCopyText(text) { + copied.push(text); + return true; + }, + showMessage(message, type) { + messages.push({ message, type }); + }, + t(key) { return key; } + }; + + await methods.copySessionWorkspaceBrief.call(context); + + assert.deepStrictEqual(copied, ['# Brief\n\n- web-ui/partials/index/panel-sessions.html']); + assert.deepStrictEqual(messages, [{ message: 'sessions.workspace.copy.success', type: 'success' }]); +}); diff --git a/tests/unit/web-ui-behavior-parity.test.mjs b/tests/unit/web-ui-behavior-parity.test.mjs index cb246117..37e9b102 100644 --- a/tests/unit/web-ui-behavior-parity.test.mjs +++ b/tests/unit/web-ui-behavior-parity.test.mjs @@ -465,6 +465,8 @@ test('captured bundled app skeleton only exposes expected data key drift versus 'healthCheckBatchTotal', 'healthCheckBatchDone', 'healthCheckBatchFailed', + 'healthCheckFailedProviderSelections', + 'healthCheckFailedProviderDeleting', 'showHealthCheckModal', 'showCodexBridgePoolModal', 'showClaudeBridgePoolModal', @@ -558,6 +560,13 @@ test('captured bundled app skeleton only exposes expected data key drift versus 'loadWebhookSettings', 'saveWebhookSettings', 'testWebhook', + 'deleteSelectedHealthCheckFailedProviders', + 'setAllHealthCheckFailedProviderSelections', + 'toggleHealthCheckFailedProviderSelection', + 'areAllHealthCheckFailedProvidersSelected', + 'hasHealthCheckFailedProviderSelection', + 'getSelectableHealthCheckFailedProviderItems', + 'getHealthCheckFailedProviderItems', 'toggleWebhookEvent', 'restoreNavStateFromStorage', 'cancelScheduledSessionListViewportFill', @@ -567,6 +576,7 @@ test('captured bundled app skeleton only exposes expected data key drift versus 'findProviderByName', 'getProviderValidation', 'getShareCommandPrefixInvocation', + 'openAddProviderModal', 'invalidateSessionsUsageData', 'isReservedProviderCreationName', 'isSessionLoadNativeDialogEnabled', @@ -640,6 +650,10 @@ test('captured bundled app skeleton only exposes expected data key drift versus 'claudeLocalBridgeCandidateProviders', 'claudeLocalBridgeConfigured', 'syncClaudeBridgeProviders', + 'rememberDeletedClaudeSettingsImport', + 'applyCurrentClaudeConfigSilently', + 'selectClaudeFallbackConfigName', + 'deleteClaudeProviderCacheRef', 'syncProviderCacheRecords', 'toggleAddClaudeConfigKey', 'toggleAddProviderKey', @@ -771,7 +785,8 @@ test('captured bundled app skeleton only exposes expected data key drift versus 'hydrateClaudeConfigsFromProviderCache', 'applyWebUiPreferences', 'loadWebUiPreferences', - 'persistWebUiPreferences' + 'persistWebUiPreferences', + 'copySessionWorkspaceBrief' ); const allowedMissingCurrentMethodKeys = [ 'convertSession', @@ -882,7 +897,8 @@ test('captured bundled app skeleton only exposes expected data key drift versus 'providersHealthTone', 'sessionContextUtilization', 'isLocalProviderDisabled', - 'sessionTimelineProgressPercent' + 'sessionTimelineProgressPercent', + 'activeSessionWorkspaceSummary' ]; const allowedMissingCurrentComputedKeys = [ 'hasLocalAndProxy', diff --git a/web-ui/app.js b/web-ui/app.js index fdaa4aa1..9e2517e4 100644 --- a/web-ui/app.js +++ b/web-ui/app.js @@ -272,6 +272,8 @@ document.addEventListener('DOMContentLoaded', () => { healthCheckBatchTotal: 0, healthCheckBatchDone: 0, healthCheckBatchFailed: 0, + healthCheckFailedProviderSelections: {}, + healthCheckFailedProviderDeleting: false, installPackageManager: 'npm', installCommandAction: 'install', installRegistryPreset: 'npmmirror', diff --git a/web-ui/modules/app.computed.session.mjs b/web-ui/modules/app.computed.session.mjs index 8814118c..78aff3e9 100644 --- a/web-ui/modules/app.computed.session.mjs +++ b/web-ui/modules/app.computed.session.mjs @@ -51,6 +51,190 @@ function formatUsageRangeLabel(range, t) { return '近 7 天'; } +function normalizeWorkspaceText(value) { + if (value == null) { + return ''; + } + return String(value) + .replace(/<[^>]*>/g, ' ') + .replace(/ /gi, ' ') + .replace(/</gi, '<') + .replace(/>/gi, '>') + .replace(/&/gi, '&') + .replace(/\s+/g, ' ') + .trim(); +} + +function trimWorkspaceLine(value, maxLength = 160) { + const text = normalizeWorkspaceText(value); + if (!text) return ''; + const limit = Number.isFinite(Number(maxLength)) ? Math.max(20, Math.floor(Number(maxLength))) : 160; + return text.length > limit ? `${text.slice(0, Math.max(0, limit - 1)).trimEnd()}…` : text; +} + +function pushUniqueWorkspaceItem(items, value, limit = 6, maxLength = 160) { + if (!Array.isArray(items) || items.length >= limit) return; + const text = trimWorkspaceLine(value, maxLength); + if (!text) return; + const key = text.toLowerCase(); + if (items.some(item => String(item || '').toLowerCase() === key)) return; + items.push(text); +} + +function extractWorkspaceLines(text) { + return String(text || '') + .split(/\r?\n|(?:^|\s)(?:[-*•]|\d+[.)])\s+|[。;;]\s*/g) + .map(line => normalizeWorkspaceText(line)) + .filter(Boolean); +} + +function isWorkspaceCommandLine(line) { + return /^\$\s+/.test(line) + || /^(?:npm|pnpm|yarn|bun|node|npx|git|gh|codex|claude|gemini|codebuddy|openclaw|pytest|go test|cargo test|make|docker)\b/i.test(line); +} + +function extractInlineWorkspaceCommands(text) { + const source = normalizeWorkspaceText(text); + if (!source) return []; + const matches = source.match(/(?:^|[^A-Za-z0-9_])(?:\$\s*)?(?:(?:npm|pnpm|yarn|bun|node|npx|git|gh|codex|claude|gemini|codebuddy|openclaw|pytest|make|docker)\s+(?:run\s+)?[A-Za-z0-9:_./@-]+(?:\s+&&\s+(?:npm|pnpm|yarn|bun|node|npx|git|gh|codex|claude|gemini|codebuddy|openclaw|pytest|make|docker)\s+(?:run\s+)?[A-Za-z0-9:_./@-]+)?|go\s+test(?:\s+[A-Za-z0-9:_./@-]+)?|cargo\s+test(?:\s+[A-Za-z0-9:_./@-]+)?)(?=$|\s|[,。;,;])/gi) || []; + return matches.map(match => match.replace(/^[^A-Za-z0-9_$]*\$?\s*/, '').trim()); +} + +function extractWorkspaceArtifacts(line, target) { + if (!target || typeof line !== 'string') return; + const urlMatches = line.match(/https?:\/\/[^\s)\]"'<>,。;、]+/gi) || []; + for (const url of urlMatches) { + pushUniqueWorkspaceItem(target.links, url.replace(/[,。;、.,;:!?]+$/g, ''), 5, 140); + } + const fileMatches = line.match(/(?:[A-Za-z]:[\\/]|\/)?(?:[A-Za-z0-9_.@+-]+[\\/])*[A-Za-z0-9_.@+-]+\.(?:mjs|js|cjs|ts|tsx|jsx|vue|css|scss|html|json|jsonl|md|yml|yaml|toml|lock|py|go|rs|sh|txt)/g) || []; + for (const filePath of fileMatches) { + pushUniqueWorkspaceItem(target.files, filePath, 8, 120); + } +} + +function buildWorkspaceBriefText(summary, labels = {}) { + if (!summary || !summary.available) return ''; + const label = (key, fallback) => (labels && labels[key]) || fallback; + const lines = [ + `# ${label('title', 'Session workspace brief')}`, + '', + `${label('source', 'Source')}: ${summary.sourceLabel || summary.source || '-'}`, + `${label('messages', 'Messages')}: ${summary.messageCount}`, + `${label('path', 'Path')}: ${summary.cwd || '-'}`, + '' + ]; + const sections = [ + ['signals', label('signals', 'Signals'), summary.signals], + ['commands', label('commands', 'Reusable commands'), summary.commands], + ['files', label('files', 'Files'), summary.files], + ['links', label('links', 'Links'), summary.links], + ['risks', label('risks', 'Risks / blockers'), summary.risks], + ['nextSteps', label('nextSteps', 'Next steps'), summary.nextSteps] + ]; + for (const [, sectionTitle, items] of sections) { + if (!Array.isArray(items) || !items.length) continue; + lines.push(`## ${sectionTitle}`); + for (const item of items) { + lines.push(`- ${item}`); + } + lines.push(''); + } + return lines.join('\n').trimEnd(); +} + +export function buildSessionWorkspaceSummary(session, messages, options = {}) { + const safeMessages = Array.isArray(messages) ? messages : []; + const roleCounts = { user: 0, assistant: 0, system: 0, other: 0 }; + const signals = []; + const commands = []; + const files = []; + const links = []; + const risks = []; + const nextSteps = []; + const target = { files, links }; + let firstUser = ''; + let lastUser = ''; + let lastAssistant = ''; + let firstTimestamp = ''; + let lastTimestamp = ''; + + safeMessages.forEach((message) => { + if (!message || typeof message !== 'object') return; + const normalizedRole = String(message.normalizedRole || message.role || '').trim().toLowerCase(); + const role = normalizedRole === 'user' || normalizedRole === 'assistant' || normalizedRole === 'system' + ? normalizedRole + : 'other'; + roleCounts[role] += 1; + const timestamp = normalizeWorkspaceText(message.timestamp || ''); + if (timestamp && !firstTimestamp) firstTimestamp = timestamp; + if (timestamp) lastTimestamp = timestamp; + const text = normalizeWorkspaceText(message.text || message.content || ''); + if (!text) return; + if (role === 'user') { + if (!firstUser) firstUser = text; + lastUser = text; + } else if (role === 'assistant') { + lastAssistant = text; + } + const lines = extractWorkspaceLines(message.text || message.content || text); + for (const command of extractInlineWorkspaceCommands(message.text || message.content || text)) { + pushUniqueWorkspaceItem(commands, command, 6, 140); + } + for (const line of lines) { + extractWorkspaceArtifacts(line, target); + if (isWorkspaceCommandLine(line)) { + pushUniqueWorkspaceItem(commands, line.replace(/^\$\s+/, ''), 6, 140); + } + if (/(?:blocker|blocked|risk|failed|failure|error|timeout|regression|冲突|失败|错误|阻塞|风险|不可合|超时|回归)/i.test(line)) { + pushUniqueWorkspaceItem(risks, line, 5, 150); + } + if (/(?:todo|next|follow[- ]?up|remaining|后续|下一步|待办|继续|剩余)/i.test(line)) { + pushUniqueWorkspaceItem(nextSteps, line, 5, 150); + } + } + }); + + pushUniqueWorkspaceItem(signals, firstUser, 3, 150); + if (lastUser && lastUser !== firstUser) { + pushUniqueWorkspaceItem(signals, lastUser, 3, 150); + } + pushUniqueWorkspaceItem(signals, lastAssistant, 3, 150); + + const source = session && typeof session.source === 'string' ? session.source : ''; + const sourceLabel = session && typeof session.sourceLabel === 'string' ? session.sourceLabel : source; + const cwd = session && typeof session.cwd === 'string' ? session.cwd : ''; + const messageCount = safeMessages.length; + const available = messageCount > 0; + const metrics = [ + { key: 'messages', value: String(messageCount), label: options.messagesLabel || 'Messages' }, + { key: 'user', value: String(roleCounts.user), label: options.userLabel || 'User' }, + { key: 'assistant', value: String(roleCounts.assistant), label: options.assistantLabel || 'Assistant' }, + { key: 'commands', value: String(commands.length), label: options.commandsLabel || 'Commands' }, + { key: 'artifacts', value: String(files.length + links.length), label: options.artifactsLabel || 'Artifacts' }, + { key: 'risks', value: String(risks.length + nextSteps.length), label: options.risksLabel || 'Risks' } + ]; + + const summary = { + available, + source, + sourceLabel, + cwd, + firstTimestamp, + lastTimestamp, + messageCount, + roleCounts, + metrics, + signals, + commands, + files, + links, + risks, + nextSteps + }; + summary.briefText = buildWorkspaceBriefText(summary, options.briefLabels || {}); + return summary; +} + function formatUsageDuration(value, options = {}) { const normalizedLang = typeof options.lang === 'string' ? options.lang.trim().toLowerCase() : ''; const isEn = normalizedLang === 'en'; @@ -267,6 +451,32 @@ export function createSessionComputed() { if (index < 0) return 0; return Math.round(((index + 1) / this.sessionTimelineNodes.length) * 100); }, + activeSessionWorkspaceSummary() { + if (this.mainTab !== 'sessions' || !this.activeSession) { + return buildSessionWorkspaceSummary(null, []); + } + const t = typeof this.t === 'function' ? this.t : null; + return buildSessionWorkspaceSummary(this.activeSession, this.activeSessionMessages, { + messagesLabel: t ? t('sessions.workspace.metric.messages') : 'Messages', + userLabel: t ? t('sessions.workspace.metric.user') : 'User', + assistantLabel: t ? t('sessions.workspace.metric.assistant') : 'Assistant', + commandsLabel: t ? t('sessions.workspace.metric.commands') : 'Commands', + artifactsLabel: t ? t('sessions.workspace.metric.artifacts') : 'Artifacts', + risksLabel: t ? t('sessions.workspace.metric.risks') : 'Risks', + briefLabels: { + title: t ? t('sessions.workspace.copy.title') : 'Session workspace brief', + source: t ? t('sessions.workspace.copy.source') : 'Source', + messages: t ? t('sessions.workspace.metric.messages') : 'Messages', + path: t ? t('sessions.workspace.copy.path') : 'Path', + signals: t ? t('sessions.workspace.signals') : 'Signals', + commands: t ? t('sessions.workspace.commands') : 'Reusable commands', + files: t ? t('sessions.workspace.files') : 'Files', + links: t ? t('sessions.workspace.links') : 'Links', + risks: t ? t('sessions.workspace.risks') : 'Risks / blockers', + nextSteps: t ? t('sessions.workspace.nextSteps') : 'Next steps' + } + }); + }, sessionQueryPlaceholder() { if (this.isSessionQueryEnabled) { return typeof this.t === 'function' diff --git a/web-ui/modules/app.methods.claude-config.mjs b/web-ui/modules/app.methods.claude-config.mjs index 688e18e8..cf3d1387 100644 --- a/web-ui/modules/app.methods.claude-config.mjs +++ b/web-ui/modules/app.methods.claude-config.mjs @@ -1,3 +1,5 @@ +import { nextClaudeConfigName } from './provider-default-names.mjs'; + function normalizeClaudeText(value) { return typeof value === 'string' ? value.trim() : ''; } @@ -6,6 +8,21 @@ function normalizeClaudeBaseUrl(value) { return normalizeClaudeText(value).replace(/\/+$/g, ''); } +const DELETED_CLAUDE_SETTINGS_IMPORTS_KEY = 'deletedClaudeSettingsImports'; + +function buildDeletedClaudeSettingsFingerprint(config = {}) { + const safe = config && typeof config === 'object' ? config : {}; + const baseUrl = normalizeClaudeBaseUrl(safe.baseUrl); + const model = normalizeClaudeText(safe.model); + if (!baseUrl || !model) return null; + return { + baseUrl, + model, + providerCacheRef: normalizeClaudeText(safe.providerCacheRef), + deletedAt: Date.now() + }; +} + function isValidClaudeHttpUrl(value) { if (!value) return false; try { @@ -114,6 +131,46 @@ export function createClaudeConfigMethods(options = {}) { this.syncClaudeBridgeProviders(); }, + rememberDeletedClaudeSettingsImport(config) { + const fingerprint = buildDeletedClaudeSettingsFingerprint(config); + if (!fingerprint) return; + try { + const raw = localStorage.getItem(DELETED_CLAUDE_SETTINGS_IMPORTS_KEY); + const parsed = raw ? JSON.parse(raw) : []; + const entries = Array.isArray(parsed) ? parsed : []; + const deduped = entries.filter((entry) => { + if (!entry || typeof entry !== 'object') return false; + return normalizeClaudeBaseUrl(entry.baseUrl) !== fingerprint.baseUrl + || normalizeClaudeText(entry.model) !== fingerprint.model; + }); + deduped.push(fingerprint); + localStorage.setItem(DELETED_CLAUDE_SETTINGS_IMPORTS_KEY, JSON.stringify(deduped.slice(-50))); + } catch (_) {} + }, + + async applyCurrentClaudeConfigSilently() { + const name = normalizeClaudeText(this.currentClaudeConfig); + if (!name || !this.claudeConfigs || !this.claudeConfigs[name]) return false; + if (typeof this.applyClaudeConfig !== 'function') return false; + return await this.applyClaudeConfig(name, { silent: true }); + }, + + selectClaudeFallbackConfigName(excludedNames = []) { + const configs = this.claudeConfigs && typeof this.claudeConfigs === 'object' ? this.claudeConfigs : {}; + const excluded = new Set((Array.isArray(excludedNames) ? excludedNames : [excludedNames]) + .map((name) => normalizeClaudeText(name)) + .filter(Boolean)); + const names = Object.keys(configs).filter((name) => name && !excluded.has(name)); + const applyable = names.find((name) => { + const config = configs[name] || {}; + return !!(config.apiKey + || config.providerCacheRef + || config.externalCredentialType + || config.targetApi === 'ollama'); + }); + return applyable || names[0] || ''; + }, + async hydrateClaudeConfigsFromProviderCache(options = {}) { const silent = options && options.silent === true; try { @@ -123,10 +180,10 @@ export function createClaudeConfigMethods(options = {}) { return false; } const providers = Array.isArray(res.providers) ? res.providers : []; - if (providers.length === 0) return true; const configs = this.claudeConfigs && typeof this.claudeConfigs === 'object' ? this.claudeConfigs : {}; let changed = false; let firstCachedName = ''; + const liveCacheRefs = new Set(); for (const provider of providers) { if (!provider || typeof provider !== 'object') continue; const name = normalizeClaudeText(provider.name); @@ -134,12 +191,15 @@ export function createClaudeConfigMethods(options = {}) { const model = normalizeClaudeText(provider.model); if (!name || !baseUrl || !model) continue; if (!firstCachedName) firstCachedName = name; + const providerCacheRef = normalizeClaudeText(provider.providerCacheRef) || name; + liveCacheRefs.add(name); + liveCacheRefs.add(providerCacheRef); const cachedConfig = { apiKey: '', baseUrl, model, hasKey: provider.hasKey === true, - providerCacheRef: normalizeClaudeText(provider.providerCacheRef) || name, + providerCacheRef, source: 'provider-cache', targetApi: normalizeClaudeText(provider.targetApi) || 'responses' }; @@ -152,11 +212,26 @@ export function createClaudeConfigMethods(options = {}) { changed = true; } } + for (const [name, existing] of Object.entries(configs)) { + if (!existing || typeof existing !== 'object') continue; + const providerCacheRef = normalizeClaudeText(existing.providerCacheRef); + const source = normalizeClaudeText(existing.source); + const cacheBacked = source === 'provider-cache' || !!providerCacheRef; + if (!cacheBacked) continue; + const ref = providerCacheRef || normalizeClaudeText(name); + if (liveCacheRefs.has(ref) || liveCacheRefs.has(normalizeClaudeText(name))) continue; + delete configs[name]; + changed = true; + } this.claudeConfigs = configs; - if (firstCachedName) { + const current = normalizeClaudeText(this.currentClaudeConfig); + if (current && !configs[current]) { + this.currentClaudeConfig = firstCachedName || Object.keys(configs)[0] || ''; + try { localStorage.setItem('currentClaudeConfig', this.currentClaudeConfig); } catch (_) {} + changed = true; + } else if (firstCachedName) { let savedCurrent = ''; try { savedCurrent = localStorage.getItem('currentClaudeConfig') || ''; } catch (_) {} - const current = normalizeClaudeText(this.currentClaudeConfig); const currentConfig = current && configs[current] ? configs[current] : null; if (!savedCurrent && (!current || (currentConfig && currentConfig.hasKey === false && !currentConfig.providerCacheRef))) { this.currentClaudeConfig = firstCachedName; @@ -323,6 +398,28 @@ export function createClaudeConfigMethods(options = {}) { await this.applyClaudeConfig(name); }, + async deleteClaudeProviderCacheRef(configOrName) { + const config = configOrName && typeof configOrName === 'object' + ? configOrName + : (this.claudeConfigs && this.claudeConfigs[configOrName] ? this.claudeConfigs[configOrName] : null); + const ref = normalizeClaudeText(config && config.providerCacheRef); + const source = normalizeClaudeText(config && config.source); + if (!ref && source !== 'provider-cache') return true; + const name = ref || normalizeClaudeText(config && config.name) || normalizeClaudeText(configOrName); + if (!name) return true; + try { + const res = await api('delete-provider-cache-record', { name, group: 'claude' }); + if (res && res.error) { + this.showMessage(res.error, 'error'); + return false; + } + return true; + } catch (e) { + this.showMessage(e && e.message ? e.message : this.t('toast.operation.fail'), 'error'); + return false; + } + }, + async deleteClaudeConfig(name) { if (Object.keys(this.claudeConfigs).length <= 1) { return this.showMessage(this.t('toast.claude.keepOne'), 'error'); @@ -336,16 +433,30 @@ export function createClaudeConfigMethods(options = {}) { }); if (!confirmed) return; + const config = this.claudeConfigs[name]; + const cacheDeleted = await this.deleteClaudeProviderCacheRef(config || name); + if (!cacheDeleted) return; + + if (typeof this.rememberDeletedClaudeSettingsImport === 'function') { + this.rememberDeletedClaudeSettingsImport(config); + } delete this.claudeConfigs[name]; if (this.currentClaudeConfig === name) { - this.currentClaudeConfig = Object.keys(this.claudeConfigs)[0]; + this.currentClaudeConfig = this.selectClaudeFallbackConfigName(); } this.saveClaudeConfigs(); this.showMessage(this.t('toast.operation.success'), 'success'); - this.refreshClaudeModelContext(); + if (this.currentClaudeConfig) { + await this.applyCurrentClaudeConfigSilently(); + } else { + this.refreshClaudeModelContext(); + } }, - async applyClaudeConfig(name) { + async applyClaudeConfig(name, options = {}) { + const silent = !!(options && options.silent); + const silentSuccess = silent || !!(options && options.silentSuccess); + const silentError = silent || !!(options && options.silentError); this.currentClaudeConfig = name; try { localStorage.setItem('currentClaudeConfig', name || ''); } catch (_) {} this.refreshClaudeModelContext(); @@ -353,8 +464,10 @@ export function createClaudeConfigMethods(options = {}) { if (!config.apiKey && !config.providerCacheRef && config.targetApi !== 'ollama') { if (config.externalCredentialType) { + if (silentError) return false; return this.showMessage(this.t('toast.claude.externalAuth'), 'info'); } + if (silentError) return false; return this.showMessage(this.t('toast.claude.apiKeyRequired'), 'error'); } @@ -362,15 +475,18 @@ export function createClaudeConfigMethods(options = {}) { try { const res = await api('apply-claude-config', { config: { ...config, name } }); if (res.error || res.success === false) { - this.showMessage(res.error || this.t('toast.apply.fail'), 'error'); + if (!silentError) this.showMessage(res.error || this.t('toast.apply.fail'), 'error'); + return false; } else { - if (this._lastAppliedClaudeKey !== _claudeKey2) { + if (!silentSuccess && this._lastAppliedClaudeKey !== _claudeKey2) { this.showMessage(this.t('toast.apply.success'), 'success'); - this._lastAppliedClaudeKey = _claudeKey2; } + this._lastAppliedClaudeKey = _claudeKey2; + return true; } } catch (_) { - this.showMessage(this.t('toast.apply.fail'), 'error'); + if (!silentError) this.showMessage(this.t('toast.apply.fail'), 'error'); + return false; } }, @@ -378,7 +494,7 @@ export function createClaudeConfigMethods(options = {}) { this.showClaudeConfigModal = false; this.showAddClaudeConfigKey = false; this.newClaudeConfig = { - name: '', + name: nextClaudeConfigName(this.claudeConfigs), apiKey: '', externalCredentialType: '', baseUrl: '', diff --git a/web-ui/modules/app.methods.codex-config.mjs b/web-ui/modules/app.methods.codex-config.mjs index 521344b8..cf54cdbd 100644 --- a/web-ui/modules/app.methods.codex-config.mjs +++ b/web-ui/modules/app.methods.codex-config.mjs @@ -24,6 +24,28 @@ function getResponseMessage(response, fallback) { return fallback; } + +function normalizeProviderName(value) { + return typeof value === 'string' ? value.trim() : ''; +} + +function providerIssueDetail(issues) { + if (!Array.isArray(issues) || !issues.length) return ''; + return issues + .map((issue) => issue && (issue.message || issue.code || '')) + .filter(Boolean) + .join(';'); +} + +function remoteProviderDetail(remote) { + if (!remote || typeof remote !== 'object') return ''; + const parts = []; + if (remote.endpoint) parts.push(remote.endpoint); + if (remote.statusCode) parts.push(String(remote.statusCode)); + if (remote.message) parts.push(remote.message); + return parts.join(' · '); +} + export function createCodexConfigMethods(options = {}) { const { api, @@ -303,6 +325,7 @@ export function createCodexConfigMethods(options = {}) { async runHealthCheck(options = {}) { this.healthCheckLoading = true; this.healthCheckResult = null; + this.healthCheckFailedProviderSelections = {}; this.healthCheckBatchTotal = 0; this.healthCheckBatchDone = 0; this.healthCheckBatchFailed = 0; @@ -354,7 +377,13 @@ export function createCodexConfigMethods(options = {}) { } if (this.configMode === 'claude') { - const entries = Object.entries(this.claudeConfigs || {}); + const configs = this.claudeConfigs || {}; + const currentName = typeof this.currentClaudeConfig === 'string' && configs[this.currentClaudeConfig] + ? this.currentClaudeConfig + : Object.keys(configs)[0]; + const entries = currentName && configs[currentName] + ? [[currentName, configs[currentName]]] + : []; this.healthCheckBatchTotal = entries.length; const speedTasks = entries.map(([name, config]) => this.runClaudeSpeedTest(name, config) @@ -395,80 +424,44 @@ export function createCodexConfigMethods(options = {}) { speedTests: results } }; + if (!silent) { + this.showHealthCheckModal = true; + } if (ok && !silent) { - this.showMessage('检查通过', 'success'); + this.showMessage(this.t('toast.check.success'), 'success'); } return; } - const shouldRunSpeedTests = this.configMode === 'codex'; - const speedTimeoutMs = shouldRunSpeedTests ? 3500 : 0; - const providers = shouldRunSpeedTests - ? (this.providersList || []) - .map((provider) => typeof provider === 'string' - ? provider.trim() - : String((provider && provider.name) || '').trim()) - .filter(Boolean) - : []; - const currentProvider = String(this.currentProvider || '').trim(); - const orderedProviders = currentProvider && providers.includes(currentProvider) - ? [currentProvider, ...providers.filter((name) => name !== currentProvider)] - : providers; - this.healthCheckBatchTotal = orderedProviders.length; - - const speedTasks = orderedProviders.map((provider) => this.runSpeedTest(provider, { silent: true, timeoutMs: speedTimeoutMs }) - .then((result) => { - if (!result || result.ok !== true) { - this.healthCheckBatchFailed += 1; - } - return { name: provider, result }; - }) - .catch((err) => { - this.healthCheckBatchFailed += 1; - return { - name: provider, - result: { ok: false, error: err && err.message ? err.message : 'Speed test failed' } - }; - }) - .finally(() => { - this.healthCheckBatchDone += 1; - }) - ); - - const configTask = api('config-health-check', { remote: this.configMode === 'codex' }); - const [res, pairs] = await Promise.all([ - configTask, - Promise.all(speedTasks) + const [configRes, providersRes] = await Promise.all([ + api('config-health-check', { remote: true }), + api('providers-health', { remote: false }) ]); - if (hasResponseError(res)) { + if (providersRes && typeof providersRes === 'object' && !hasResponseError(providersRes)) { + this.providersHealthResult = providersRes; + } + if (hasResponseError(configRes)) { this.healthCheckResult = null; if (!silent) { - this.showMessage(getResponseMessage(res, '检查失败'), 'error'); - } - } else if (res && typeof res === 'object') { - const issues = Array.isArray(res.issues) ? [...res.issues] : []; - let remote = res.remote || null; - if (shouldRunSpeedTests) { - const results = {}; - for (const pair of pairs) { - results[pair.name] = pair.result || null; - const issue = this.buildSpeedTestIssue(pair.name, pair.result); - if (issue) issues.push(issue); - } - remote = remote && typeof remote === 'object' - ? { ...remote, speedTests: results } - : { type: 'speed-test', speedTests: results }; + this.showMessage(getResponseMessage(configRes, this.t('toast.check.fail')), 'error'); } - - const ok = issues.length === 0; + } else if (configRes && typeof configRes === 'object') { + const issues = Array.isArray(configRes.issues) ? [...configRes.issues] : []; + const ok = typeof configRes.ok === 'boolean' ? configRes.ok : issues.length === 0; this.healthCheckResult = { - ...res, + ...configRes, ok, issues, - remote + remote: configRes.remote || null }; - if (ok && !silent) { - this.showMessage('检查通过', 'success'); + this.healthCheckBatchTotal = 1; + this.healthCheckBatchDone = 1; + this.healthCheckBatchFailed = ok ? 0 : 1; + if (!silent) { + this.showHealthCheckModal = true; + } + if (!silent) { + this.showMessage(this.t(ok ? 'toast.check.success' : 'toast.check.fail'), ok ? 'success' : 'error'); } } else { this.healthCheckResult = null; @@ -479,16 +472,252 @@ export function createCodexConfigMethods(options = {}) { } catch (e) { this.healthCheckResult = null; if (!(options && options.silent)) { - this.showMessage('检查失败', 'error'); + this.showMessage(this.t('toast.check.fail'), 'error'); } } finally { this.healthCheckBatchTotal = this.healthCheckBatchTotal || 0; this.healthCheckBatchDone = Math.min(this.healthCheckBatchDone || 0, this.healthCheckBatchTotal || 0); this.healthCheckLoading = false; - if (typeof this.runProvidersHealthCheck === 'function' && this.configMode === 'codex') { - void this.runProvidersHealthCheck({ remote: true }); + } + }, + + getHealthCheckFailedProviderItems() { + const result = this.healthCheckResult && typeof this.healthCheckResult === 'object' + ? this.healthCheckResult + : null; + if (!result) return []; + const remote = result.remote && typeof result.remote === 'object' ? result.remote : null; + const items = new Map(); + const currentMode = this.configMode === 'claude' ? 'claude' : 'codex'; + const pushItem = (name, detail, status = 'red', mode = currentMode) => { + const providerName = normalizeProviderName(name); + if (!providerName) return; + const key = `${mode}:${providerName}`; + const provider = (this.providersList || []).find((item) => item && item.name === providerName) || providerName; + const baseDeletable = mode === 'claude' + ? !!(this.claudeConfigs && this.claudeConfigs[providerName]) + : (typeof this.shouldShowProviderDelete === 'function' ? this.shouldShowProviderDelete(provider) : true); + const writeAllowed = typeof this.isToolConfigWriteAllowed === 'function' + ? this.isToolConfigWriteAllowed(mode) + : true; + const deletable = baseDeletable && writeAllowed; + const blockedReason = !baseDeletable ? 'reserved' : (!writeAllowed ? 'readonly' : ''); + const previous = items.get(key) || {}; + items.set(key, { + key, + mode, + name: providerName, + status: previous.status || status || 'red', + detail: detail || previous.detail || '', + deletable, + blockedReason: previous.blockedReason || blockedReason, + selected: !!(this.healthCheckFailedProviderSelections && this.healthCheckFailedProviderSelections[key]) + }); + }; + + if (remote && remote.type === 'providers-health' && Array.isArray(remote.providers)) { + for (const provider of remote.providers) { + if (!provider || provider.status === 'green') continue; + pushItem(provider.provider, providerIssueDetail(provider.issues) || remoteProviderDetail(provider.remote), provider.status || 'red', 'codex'); } } + + if (remote && remote.type === 'remote-health-check' && (result.ok === false || remote.ok === false)) { + pushItem(remote.provider, remoteProviderDetail(remote), 'red', 'codex'); + } + + if (remote && remote.speedTests && typeof remote.speedTests === 'object') { + for (const [name, speedResult] of Object.entries(remote.speedTests)) { + if (speedResult && speedResult.ok === true) continue; + pushItem(name, speedResult && speedResult.error ? speedResult.error : this.t('config.health.fail'), 'red', this.configMode === 'claude' ? 'claude' : 'codex'); + } + } + + if (Array.isArray(result.issues)) { + for (const issue of result.issues) { + if (!issue || typeof issue !== 'object') continue; + const name = normalizeProviderName(issue.provider || issue.providerName || issue.name); + if (name) pushItem(name, issue.message || issue.code || '', 'red', currentMode); + } + } + + return Array.from(items.values()); + }, + + getSelectableHealthCheckFailedProviderItems() { + return this.getHealthCheckFailedProviderItems().filter((item) => item.deletable); + }, + + hasHealthCheckFailedProviderSelection() { + return this.getSelectableHealthCheckFailedProviderItems().some((item) => item.selected); + }, + + areAllHealthCheckFailedProvidersSelected() { + const selectable = this.getSelectableHealthCheckFailedProviderItems(); + return selectable.length > 0 && selectable.every((item) => item.selected); + }, + + setAllHealthCheckFailedProviderSelections(checked) { + const selectable = this.getSelectableHealthCheckFailedProviderItems(); + const next = { ...(this.healthCheckFailedProviderSelections || {}) }; + for (const item of selectable) { + next[item.key] = !!checked; + } + this.healthCheckFailedProviderSelections = next; + }, + + toggleHealthCheckFailedProviderSelection(item, checked) { + if (!item || !item.key || !item.deletable) return; + this.healthCheckFailedProviderSelections = { + ...(this.healthCheckFailedProviderSelections || {}), + [item.key]: !!checked + }; + }, + + async deleteSelectedHealthCheckFailedProviders() { + const selectedItems = this.getHealthCheckFailedProviderItems() + .filter((item) => item.selected && item.deletable); + if (!selectedItems.length) { + this.showMessage(this.t('toast.health.noFailedProviderSelection'), 'info'); + return; + } + this.healthCheckFailedProviderDeleting = true; + const deleted = []; + try { + const claudeItems = selectedItems.filter((item) => item.mode === 'claude'); + const codexItems = selectedItems.filter((item) => item.mode !== 'claude'); + + if (claudeItems.length) { + const configs = this.claudeConfigs && typeof this.claudeConfigs === 'object' ? this.claudeConfigs : {}; + const names = claudeItems.map((item) => item.name).filter((name) => configs[name]); + if (names.length) { + const deletedCurrentClaude = names.includes(this.currentClaudeConfig); + const remainingNames = Object.keys(configs).filter((name) => !names.includes(name)); + if (remainingNames.length === 0) { + throw new Error(this.t('toast.claude.keepOne')); + } + for (const name of names) { + const config = configs[name]; + if (config && (config.providerCacheRef || config.source === 'provider-cache')) { + if (typeof this.deleteClaudeProviderCacheRef === 'function') { + const ok = await this.deleteClaudeProviderCacheRef(config); + if (!ok) throw new Error(this.t('toast.delete.fail')); + } else { + const cacheName = typeof config.providerCacheRef === 'string' && config.providerCacheRef.trim() + ? config.providerCacheRef.trim() + : name; + const res = await api('delete-provider-cache-record', { name: cacheName, group: 'claude' }); + if (res && res.error) throw new Error(res.error); + } + } + if (typeof this.rememberDeletedClaudeSettingsImport === 'function') { + this.rememberDeletedClaudeSettingsImport(config); + } + delete configs[name]; + deleted.push(name); + } + if (deletedCurrentClaude) { + this.currentClaudeConfig = typeof this.selectClaudeFallbackConfigName === 'function' + ? this.selectClaudeFallbackConfigName(names) + : remainingNames[0]; + } + if (typeof this.saveClaudeConfigs === 'function') { + this.saveClaudeConfigs(); + } + if (deletedCurrentClaude && typeof this.applyCurrentClaudeConfigSilently === 'function') { + await this.applyCurrentClaudeConfigSilently(); + } else if (typeof this.refreshClaudeModelContext === 'function') { + this.refreshClaudeModelContext(); + } + } + } + + for (const item of codexItems) { + const res = await api('delete-provider', { name: item.name }); + if (res && res.error) { + throw new Error(res.error); + } + this.providersList = (this.providersList || []).filter((provider) => provider && provider.name !== item.name); + if (this.currentModels && this.currentModels[item.name]) { + delete this.currentModels[item.name]; + } + if (res && res.switched && res.provider) { + this.currentProvider = res.provider; + if (res.model) this.currentModel = res.model; + this.providersList = (this.providersList || []).map((provider) => ({ + ...provider, + current: provider.name === res.provider + })); + } + deleted.push(item.name); + } + const deletedSet = new Set(deleted); + this.healthCheckFailedProviderSelections = {}; + const result = this.healthCheckResult; + const remote = result && result.remote; + const remainingIssues = Array.isArray(result && result.issues) + ? result.issues.filter((issue) => { + const name = normalizeProviderName(issue && (issue.provider || issue.providerName || issue.name)); + return !name || !deletedSet.has(name); + }) + : []; + if (result && typeof result === 'object') { + if (remote && remote.type === 'providers-health' && Array.isArray(remote.providers)) { + const providers = remote.providers.filter((provider) => !deletedSet.has(provider && provider.provider)); + const summary = { + total: providers.length, + green: providers.filter((p) => p && p.status === 'green').length, + yellow: providers.filter((p) => p && p.status === 'yellow').length, + red: providers.filter((p) => p && p.status === 'red').length + }; + this.healthCheckResult = { + ...result, + ok: remainingIssues.length === 0 && summary.yellow === 0 && summary.red === 0, + remote: { + ...remote, + providers, + summary + }, + issues: remainingIssues + }; + this.healthCheckBatchTotal = summary.total; + this.healthCheckBatchDone = summary.total; + this.healthCheckBatchFailed = summary.yellow + summary.red; + } else if (remote && remote.type === 'speed-test' && remote.speedTests && typeof remote.speedTests === 'object') { + const speedTests = Object.fromEntries( + Object.entries(remote.speedTests).filter(([name]) => !deletedSet.has(normalizeProviderName(name))) + ); + const total = Object.keys(speedTests).length; + const failed = Object.values(speedTests).filter((entry) => !entry || entry.ok !== true).length; + this.healthCheckResult = { + ...result, + ok: remainingIssues.length === 0 && failed === 0, + remote: { + ...remote, + speedTests + }, + issues: remainingIssues + }; + this.healthCheckBatchTotal = total; + this.healthCheckBatchDone = total; + this.healthCheckBatchFailed = failed; + } else { + const removedRemote = remote && remote.type === 'remote-health-check' && deletedSet.has(remote.provider); + this.healthCheckResult = { + ...result, + ok: remainingIssues.length === 0, + remote: removedRemote ? null : remote, + issues: remainingIssues + }; + } + } + this.showMessage(this.t('toast.health.deleteFailedProvidersDone', { count: deleted.length }), 'success'); + this.showHealthCheckModal = false; + } catch (e) { + this.showMessage(e && e.message ? e.message : this.t('toast.delete.fail'), 'error'); + } finally { + this.healthCheckFailedProviderDeleting = false; + } }, async runProvidersHealthCheck(options = {}) { diff --git a/web-ui/modules/app.methods.navigation.mjs b/web-ui/modules/app.methods.navigation.mjs index 28eade8c..d5f9e1bd 100644 --- a/web-ui/modules/app.methods.navigation.mjs +++ b/web-ui/modules/app.methods.navigation.mjs @@ -724,7 +724,10 @@ ? this.activeSessionMessages.length : 0; if (total <= 0) return; - this.sessionPreviewVisibleCount = total; + const initialBatchSize = Number.isFinite(this.sessionPreviewInitialBatchSize) + ? Math.max(1, Math.floor(this.sessionPreviewInitialBatchSize)) + : 12; + this.sessionPreviewVisibleCount = Math.min(total, initialBatchSize); this.invalidateSessionTimelineMeasurementCache(); }, diff --git a/web-ui/modules/app.methods.providers.mjs b/web-ui/modules/app.methods.providers.mjs index c2fb76ea..f212855c 100644 --- a/web-ui/modules/app.methods.providers.mjs +++ b/web-ui/modules/app.methods.providers.mjs @@ -1,3 +1,5 @@ +import { nextCodexProviderName } from './provider-default-names.mjs'; + const PROVIDER_NAME_PATTERN = /^[a-zA-Z0-9._-]+$/; const RESERVED_PROXY_PROVIDER_NAME = 'codexmate-proxy'; const RESERVED_LOCAL_PROVIDER_NAME = 'local'; @@ -327,6 +329,18 @@ export function createProvidersMethods(options = {}) { this.showAddModal = true; }, + openAddProviderModal() { + this.newProvider = { + name: nextCodexProviderName(this.providersList), + url: '', + key: '', + model: '', + useTransform: false + }; + this.showAddProviderKey = false; + this.showAddModal = true; + }, + async openEditModal(provider) { const requestId = Symbol('openEditModal'); this._openEditModalRequestId = requestId; @@ -536,7 +550,7 @@ export function createProvidersMethods(options = {}) { closeAddModal() { this.showAddModal = false; this.showAddProviderKey = false; - this.newProvider = { name: '', url: '', key: '', model: '', useTransform: false }; + this.newProvider = { name: nextCodexProviderName(this.providersList), url: '', key: '', model: '', useTransform: false }; }, toggleAddProviderKey() { diff --git a/web-ui/modules/app.methods.runtime.mjs b/web-ui/modules/app.methods.runtime.mjs index 34bbb103..8fddc8cd 100644 --- a/web-ui/modules/app.methods.runtime.mjs +++ b/web-ui/modules/app.methods.runtime.mjs @@ -154,6 +154,10 @@ export function createRuntimeMethods(options = {}) { const baseUrl = config && typeof config.baseUrl === 'string' ? config.baseUrl.trim() : ''; const apiKey = config && typeof config.apiKey === 'string' ? config.apiKey.trim() : ''; const model = config && typeof config.model === 'string' ? config.model.trim() : ''; + const targetApiRaw = config && typeof config.targetApi === 'string' ? config.targetApi.trim().toLowerCase() : ''; + const targetApi = targetApiRaw === 'ollama' + ? 'ollama' + : (targetApiRaw === 'chat_completions' || targetApiRaw === 'chat-completions' || targetApiRaw === 'chat/completions' ? 'chat_completions' : 'responses'); this.claudeSpeedLoading[name] = true; try { if (!baseUrl) { @@ -161,7 +165,7 @@ export function createRuntimeMethods(options = {}) { this.claudeSpeedResults[name] = res; return res; } - if (!apiKey) { + if (!apiKey && targetApi !== 'ollama') { const res = { ok: false, error: 'Missing API key' }; this.claudeSpeedResults[name] = res; return res; @@ -175,7 +179,8 @@ export function createRuntimeMethods(options = {}) { kind: 'claude', url: baseUrl, apiKey, - model + model, + targetApi }); if (res.error) { this.claudeSpeedResults[name] = { ok: false, error: res.error }; diff --git a/web-ui/modules/app.methods.session-actions.mjs b/web-ui/modules/app.methods.session-actions.mjs index 587f336e..cf74fe3a 100644 --- a/web-ui/modules/app.methods.session-actions.mjs +++ b/web-ui/modules/app.methods.session-actions.mjs @@ -165,6 +165,30 @@ export function createSessionActionMethods(options = {}) { this.showMessage(this.t('toast.copy.fail'), 'error'); }, + async copySessionWorkspaceBrief() { + const summary = this.activeSessionWorkspaceSummary; + const text = summary && typeof summary.briefText === 'string' + ? summary.briefText.trim() + : ''; + if (!text) { + this.showMessage(this.t('sessions.workspace.copy.empty'), 'error'); + return; + } + const ok = this.fallbackCopyText(text); + if (ok) { + this.showMessage(this.t('sessions.workspace.copy.success'), 'success'); + return; + } + try { + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(text); + this.showMessage(this.t('sessions.workspace.copy.success'), 'success'); + return; + } + } catch (_) {} + this.showMessage(this.t('toast.copy.fail'), 'error'); + }, + getSessionExportKey(session) { return `${session.source || 'unknown'}:${session.sessionId || ''}:${session.filePath || ''}`; }, diff --git a/web-ui/modules/app.methods.startup-claude.mjs b/web-ui/modules/app.methods.startup-claude.mjs index 5722144d..ca1ce674 100644 --- a/web-ui/modules/app.methods.startup-claude.mjs +++ b/web-ui/modules/app.methods.startup-claude.mjs @@ -8,6 +8,31 @@ import { normalizeClaudeSettingsEnv, normalizeClaudeValue } from '../logic.mjs'; +import { nextClaudeConfigName } from './provider-default-names.mjs'; + +const DELETED_CLAUDE_SETTINGS_IMPORTS_STORAGE_KEY = 'deletedClaudeSettingsImports'; + +function normalizeDeletedClaudeImportUrl(value) { + return typeof value === 'string' ? value.trim().replace(/\/+$/g, '') : ''; +} + +function shouldSuppressDeletedClaudeSettingsImport(env = {}) { + const normalized = normalizeClaudeSettingsEnv(env); + const baseUrl = normalizeDeletedClaudeImportUrl(normalized.baseUrl); + const model = normalizeClaudeValue(normalized.model); + if (!baseUrl || !model) return false; + try { + const raw = localStorage.getItem(DELETED_CLAUDE_SETTINGS_IMPORTS_STORAGE_KEY); + const parsed = raw ? JSON.parse(raw) : []; + const entries = Array.isArray(parsed) ? parsed : []; + return entries.some((entry) => entry + && typeof entry === 'object' + && normalizeDeletedClaudeImportUrl(entry.baseUrl) === baseUrl + && normalizeClaudeValue(entry.model) === model); + } catch (_) { + return false; + } +} export function createStartupClaudeMethods(options = {}) { const { @@ -250,7 +275,7 @@ export function createStartupClaudeMethods(options = {}) { }, shouldSuppressClaudeSettingsImport(env) { - return isLikelyBuiltinClaudeProxySettingsEnv(env); + return isLikelyBuiltinClaudeProxySettingsEnv(env) || shouldSuppressDeletedClaudeSettingsImport(env); }, findDuplicateClaudeConfigName(config) { @@ -573,6 +598,14 @@ export function createStartupClaudeMethods(options = {}) { }, openClaudeConfigModal() { + this.newClaudeConfig = { + name: nextClaudeConfigName(this.claudeConfigs), + apiKey: '', + externalCredentialType: '', + baseUrl: '', + model: '', + targetApi: 'responses' + }; this.showAddClaudeConfigKey = false; this.showClaudeConfigModal = true; }, diff --git a/web-ui/modules/i18n/locales/en.mjs b/web-ui/modules/i18n/locales/en.mjs index 03bd7266..d4d1f3ab 100644 --- a/web-ui/modules/i18n/locales/en.mjs +++ b/web-ui/modules/i18n/locales/en.mjs @@ -432,6 +432,8 @@ const en = Object.freeze({ 'toast.apply.fail': 'Failed to apply configuration', 'toast.check.success': 'Check passed', 'toast.check.fail': 'Check failed', + 'toast.health.noFailedProviderSelection': 'Select at least one failed provider first', + 'toast.health.deleteFailedProvidersDone': 'Deleted {count} failed provider(s)', 'toast.noChanges': 'No changes detected', 'toast.template.loadFail': 'Failed to load template', 'toast.template.empty': 'Template cannot be empty', @@ -660,6 +662,32 @@ const en = Object.freeze({ 'sessions.preview.rerender': 'Re-render', 'sessions.preview.preparing': 'Preparing session content...', 'sessions.preview.clipped': 'Showing the latest {count} messages only.', + 'sessions.workspace.kicker': 'Work memory', + 'sessions.workspace.title': 'Session reuse summary', + 'sessions.workspace.subtitle': 'Extracted signals, commands, files, links, and risks from {count} loaded messages.', + 'sessions.workspace.copy': 'Copy brief', + 'sessions.workspace.copy.title': 'Session workspace brief', + 'sessions.workspace.copy.source': 'Source', + 'sessions.workspace.copy.path': 'Path', + 'sessions.workspace.copy.empty': 'No session brief to copy yet', + 'sessions.workspace.copy.success': 'Workspace brief copied', + 'sessions.workspace.metric.messages': 'Messages', + 'sessions.workspace.metric.user': 'User', + 'sessions.workspace.metric.assistant': 'Assistant', + 'sessions.workspace.metric.commands': 'Commands', + 'sessions.workspace.metric.artifacts': 'Artifacts', + 'sessions.workspace.metric.risks': 'Risks / todos', + 'sessions.workspace.signals': 'Task signals', + 'sessions.workspace.commands': 'Reusable commands', + 'sessions.workspace.files': 'Related files', + 'sessions.workspace.links': 'Related links', + 'sessions.workspace.risks': 'Risks and todos', + 'sessions.workspace.nextSteps': 'Next steps', + 'sessions.workspace.empty': 'No strong signals yet. Continue with the raw messages below.', + 'sessions.workspace.noneCommands': 'No command signals', + 'sessions.workspace.noneFiles': 'No file signals', + 'sessions.workspace.noneLinks': 'No link signals', + 'sessions.workspace.noneRisks': 'No risks or todos detected', 'sessions.preview.shownCount': 'Shown {shown} / {total}', 'sessions.preview.loadMore': 'Load more (remaining {remain})', 'sessions.preview.loadingMore': 'Loading older messages...', @@ -895,7 +923,18 @@ const en = Object.freeze({ 'config.health.title': 'Config health check', 'config.health.run': 'Run check', 'config.health.running': 'Checking...', - 'config.health.hint': 'Runs availability probes across all providers and refreshes latency badges.', + 'config.health.hint': 'Checks only the currently selected provider; unselected providers do not count as this check\'s errors.', + 'config.health.codexHint': 'Checks the current Codex route provider, endpoint, auth, and model through the local bridge.', + 'config.health.providersSummary': 'Providers: {total} total · {green} ok · {yellow} config issues · {red} failed', + 'config.health.failedProviders.title': 'Failed providers to delete', + 'config.health.failedProviders.selectAria': 'Select failed provider {name} for deletion', + 'config.health.failedProviders.clearSelection': 'Clear', + 'config.health.failedProviders.selectAll': 'Select all', + 'config.health.failedProviders.selectAllAria': 'Select all deletable failed providers', + 'config.health.failedProviders.notDeletable': 'Reserved/system provider; cannot be deleted here.', + 'config.health.failedProviders.writeRequired': 'Enable provider writes in this tab before deleting.', + 'config.health.failedProviders.deleteSelected': 'Remove selected configs', + 'config.health.failedProviders.deleting': 'Deleting...', 'config.health.progress': '{done}/{total} done · {failed} failed', 'config.health.ok': 'Passed', 'config.health.fail': 'Failed', @@ -1301,7 +1340,7 @@ const en = Object.freeze({ 'claude.health.title': 'Config health check', 'claude.health.run': 'Run check', 'claude.health.running': 'Checking...', - 'claude.health.hint': 'Runs availability probes for all Claude configs and refreshes the latency badges.', + 'claude.health.hint': 'Checks only the currently selected Claude config; unselected configs do not count as this check\'s errors.', 'claude.health.progress': '{done}/{total} done · {failed} failed', 'claude.md.title': 'CLAUDE.md', 'claude.md.open': 'Open CLAUDE.md', diff --git a/web-ui/modules/i18n/locales/ja.mjs b/web-ui/modules/i18n/locales/ja.mjs index aa47fae6..61722f15 100644 --- a/web-ui/modules/i18n/locales/ja.mjs +++ b/web-ui/modules/i18n/locales/ja.mjs @@ -438,6 +438,8 @@ const ja = Object.freeze({ 'toast.apply.fail': '設定の適用に失敗しました', 'toast.check.success': 'チェック成功', 'toast.check.fail': 'チェック失敗', + 'toast.health.noFailedProviderSelection': '先に失敗プロバイダーを選択してください', + 'toast.health.deleteFailedProvidersDone': '失敗プロバイダー {count} 件を削除しました', 'toast.noChanges': '変更が検出されませんでした', 'toast.template.loadFail': 'テンプレートの読み込みに失敗しました', 'toast.template.empty': 'テンプレートは空にできません', @@ -649,6 +651,32 @@ const ja = Object.freeze({ 'sessions.preview.rerender': '再レンダリング', 'sessions.preview.preparing': '準備中...', 'sessions.preview.clipped': 'プレビューは一部のみ表示されています', + 'sessions.workspace.kicker': '作業メモリ', + 'sessions.workspace.title': 'セッション再利用サマリー', + 'sessions.workspace.subtitle': '読み込まれた {count} 件のメッセージから手掛かり、コマンド、ファイル、リンク、リスクを抽出しました。', + 'sessions.workspace.copy': 'ブリーフをコピー', + 'sessions.workspace.copy.title': 'セッション作業ブリーフ', + 'sessions.workspace.copy.source': 'ソース', + 'sessions.workspace.copy.path': 'パス', + 'sessions.workspace.copy.empty': 'コピーできるセッション要約はまだありません', + 'sessions.workspace.copy.success': '作業ブリーフをコピーしました', + 'sessions.workspace.metric.messages': 'メッセージ', + 'sessions.workspace.metric.user': 'ユーザー', + 'sessions.workspace.metric.assistant': 'アシスタント', + 'sessions.workspace.metric.commands': 'コマンド', + 'sessions.workspace.metric.artifacts': '成果物', + 'sessions.workspace.metric.risks': 'リスク / TODO', + 'sessions.workspace.signals': 'タスクの手掛かり', + 'sessions.workspace.commands': '再利用可能なコマンド', + 'sessions.workspace.files': '関連ファイル', + 'sessions.workspace.links': '関連リンク', + 'sessions.workspace.risks': 'リスクとTODO', + 'sessions.workspace.nextSteps': '次のステップ', + 'sessions.workspace.empty': '明確な手掛かりはまだありません。下の元メッセージを確認してください。', + 'sessions.workspace.noneCommands': 'コマンドの手掛かりはありません', + 'sessions.workspace.noneFiles': 'ファイルの手掛かりはありません', + 'sessions.workspace.noneLinks': 'リンクの手掛かりはありません', + 'sessions.workspace.noneRisks': 'リスクやTODOは検出されていません', 'sessions.preview.shownCount': '{shown} / {total} 件表示', 'sessions.preview.loadMore': 'さらに読み込む', 'sessions.preview.loadingMore': 'さらに読み込み中...', @@ -884,7 +912,18 @@ const ja = Object.freeze({ 'config.health.title': '設定ヘルスチェック', 'config.health.run': 'チェック実行', 'config.health.running': 'チェック中...', - 'config.health.hint': 'すべてのプロバイダーの可用性を一括プローブし、遅延表示を更新します。', + 'config.health.hint': '現在選択中のプロバイダーのみをチェックします。未選択のプロバイダーのリモート異常は今回のエラーに含めません。', + 'config.health.codexHint': '現在の Codex ルートプロバイダー、endpoint、認証、モデルをローカルブリッジ経由でチェックします。', + 'config.health.providersSummary': 'プロバイダー: 合計 {total} · 正常 {green} · 設定問題 {yellow} · 失敗 {red}', + 'config.health.failedProviders.title': '削除対象の失敗プロバイダー', + 'config.health.failedProviders.selectAria': '失敗プロバイダー {name} を削除対象に選択', + 'config.health.failedProviders.clearSelection': '選択解除', + 'config.health.failedProviders.selectAll': 'すべて選択', + 'config.health.failedProviders.selectAllAria': '削除可能な失敗プロバイダーをすべて選択', + 'config.health.failedProviders.notDeletable': '予約済み/システムプロバイダーのため、ここでは削除できません。', + 'config.health.failedProviders.writeRequired': '削除するには、このタブのプロバイダー書き込みを有効化してください。', + 'config.health.failedProviders.deleteSelected': '選択した設定を削除', + 'config.health.failedProviders.deleting': '削除中...', 'config.health.progress': '完了 {done}/{total} · 失敗 {failed}', 'config.health.ok': 'チェック通過', 'config.health.fail': 'チェック失敗', @@ -1291,7 +1330,7 @@ const ja = Object.freeze({ 'claude.health.title': '設定ヘルスチェック', 'claude.health.run': 'チェック実行', 'claude.health.running': 'チェック中...', - 'claude.health.hint': 'すべての Claude 設定の可用性を一括プローブし、遅延表示を更新します。', + 'claude.health.hint': '現在選択中の Claude 設定のみをチェックします。未選択の設定のリモート異常は今回のエラーに含めません。', 'claude.health.progress': '完了 {done}/{total} · 失敗 {failed}', 'claude.md.title': 'CLAUDE.md', 'claude.md.open': 'CLAUDE.md を開く', diff --git a/web-ui/modules/i18n/locales/vi.mjs b/web-ui/modules/i18n/locales/vi.mjs index 1f68eead..9b4ff029 100644 --- a/web-ui/modules/i18n/locales/vi.mjs +++ b/web-ui/modules/i18n/locales/vi.mjs @@ -402,6 +402,8 @@ const vi = Object.freeze({ 'toast.apply.fail': 'Áp dụng cấu hình thất bại', 'toast.check.success': 'Kiểm tra thành công', 'toast.check.fail': 'Kiểm tra thất bại', + 'toast.health.noFailedProviderSelection': 'Hãy chọn ít nhất một provider lỗi trước', + 'toast.health.deleteFailedProvidersDone': 'Đã xóa {count} provider lỗi', 'toast.noChanges': 'Không phát hiện thay đổi', 'toast.template.loadFail': 'Tải mẫu thất bại', 'toast.template.empty': 'Mẫu không được để trống', @@ -857,6 +859,32 @@ const vi = Object.freeze({ 'sessions.preview.rerender': 'Render lại', 'sessions.preview.preparing': 'Đang chuẩn bị nội dung phiên...', 'sessions.preview.clipped': 'Chỉ hiển thị {count} tin nhắn mới nhất.', + 'sessions.workspace.kicker': 'Bộ nhớ công việc', + 'sessions.workspace.title': 'Tóm tắt tái sử dụng phiên', + 'sessions.workspace.subtitle': 'Trích xuất tín hiệu, lệnh, tệp, liên kết và rủi ro từ {count} tin nhắn đã tải.', + 'sessions.workspace.copy': 'Sao chép tóm tắt', + 'sessions.workspace.copy.title': 'Tóm tắt không gian làm việc phiên', + 'sessions.workspace.copy.source': 'Nguồn', + 'sessions.workspace.copy.path': 'Đường dẫn', + 'sessions.workspace.copy.empty': 'Chưa có tóm tắt phiên để sao chép', + 'sessions.workspace.copy.success': 'Đã sao chép tóm tắt', + 'sessions.workspace.metric.messages': 'Tin nhắn', + 'sessions.workspace.metric.user': 'Người dùng', + 'sessions.workspace.metric.assistant': 'Trợ lý', + 'sessions.workspace.metric.commands': 'Lệnh', + 'sessions.workspace.metric.artifacts': 'Tài liệu', + 'sessions.workspace.metric.risks': 'Rủi ro / todo', + 'sessions.workspace.signals': 'Tín hiệu tác vụ', + 'sessions.workspace.commands': 'Lệnh có thể tái sử dụng', + 'sessions.workspace.files': 'Tệp liên quan', + 'sessions.workspace.links': 'Liên kết liên quan', + 'sessions.workspace.risks': 'Rủi ro và todo', + 'sessions.workspace.nextSteps': 'Bước tiếp theo', + 'sessions.workspace.empty': 'Chưa có tín hiệu rõ ràng. Hãy xem tin nhắn gốc bên dưới.', + 'sessions.workspace.noneCommands': 'Chưa có tín hiệu lệnh', + 'sessions.workspace.noneFiles': 'Chưa có tín hiệu tệp', + 'sessions.workspace.noneLinks': 'Chưa có tín hiệu liên kết', + 'sessions.workspace.noneRisks': 'Chưa phát hiện rủi ro hoặc todo', 'sessions.preview.shownCount': 'Hiển thị {shown} / {total}', 'sessions.preview.loadMore': 'Tải thêm (còn lại {remain})', 'sessions.preview.loadingMore': 'Đang tải tin nhắn cũ hơn...', @@ -1029,7 +1057,18 @@ const vi = Object.freeze({ 'config.health.title': 'Kiểm tra sức khỏe cấu hình', 'config.health.run': 'Chạy kiểm tra', 'config.health.running': 'Đang kiểm tra...', - 'config.health.hint': 'Chạy kiểm tra khả dụng cho tất cả nhà cung cấp và làm mới badge độ trễ.', + 'config.health.hint': 'Chỉ kiểm tra nhà cung cấp đang chọn; lỗi từ nhà cung cấp chưa chọn không tính vào lần kiểm tra này.', + 'config.health.codexHint': 'Kiểm tra provider route Codex hiện tại, endpoint, xác thực và model qua local bridge.', + 'config.health.providersSummary': 'Provider: tổng {total} · OK {green} · lỗi cấu hình {yellow} · thất bại {red}', + 'config.health.failedProviders.title': 'Provider lỗi có thể xóa', + 'config.health.failedProviders.selectAria': 'Chọn provider lỗi {name} để xóa', + 'config.health.failedProviders.clearSelection': 'Bỏ chọn', + 'config.health.failedProviders.selectAll': 'Chọn tất cả', + 'config.health.failedProviders.selectAllAria': 'Chọn tất cả provider lỗi có thể xóa', + 'config.health.failedProviders.notDeletable': 'Provider hệ thống/được giữ lại; không thể xóa tại đây.', + 'config.health.failedProviders.writeRequired': 'Bật quyền ghi provider trong tab này trước khi xóa.', + 'config.health.failedProviders.deleteSelected': 'Xóa cấu hình đã chọn', + 'config.health.failedProviders.deleting': 'Đang xóa...', 'config.health.progress': '{done}/{total} xong · {failed} thất bại', 'config.health.ok': 'Đạt', 'config.health.fail': 'Thất bại', @@ -1278,7 +1317,7 @@ const vi = Object.freeze({ 'claude.health.title': 'Kiểm tra sức khỏe cấu hình', 'claude.health.run': 'Chạy kiểm tra', 'claude.health.running': 'Đang kiểm tra...', - 'claude.health.hint': 'Chạy kiểm tra khả dụng cho tất cả cấu hình Claude và làm mới badge độ trễ.', + 'claude.health.hint': 'Chỉ kiểm tra cấu hình Claude đang chọn; lỗi từ cấu hình chưa chọn không tính vào lần kiểm tra này.', 'claude.health.progress': '{done}/{total} xong · {failed} thất bại', 'claude.md.title': 'CLAUDE.md', 'claude.md.open': 'Mở CLAUDE.md', diff --git a/web-ui/modules/i18n/locales/zh-tw.mjs b/web-ui/modules/i18n/locales/zh-tw.mjs index 6d61f061..8afcd7b5 100644 --- a/web-ui/modules/i18n/locales/zh-tw.mjs +++ b/web-ui/modules/i18n/locales/zh-tw.mjs @@ -436,6 +436,8 @@ const zhTw = Object.freeze({ 'toast.apply.fail': '應用設定失敗', 'toast.check.success': '檢查通過', 'toast.check.fail': '檢查失敗', + 'toast.health.noFailedProviderSelection': '請先選擇至少一個失敗提供商', + 'toast.health.deleteFailedProvidersDone': '已刪除 {count} 個失敗提供商', 'toast.noChanges': '未檢測到改動', 'toast.template.loadFail': '載入模板失敗', 'toast.template.empty': '模板不能為空', @@ -659,6 +661,32 @@ const zhTw = Object.freeze({ 'sessions.preview.rerender': '重新渲染', 'sessions.preview.preparing': '正在準備會話內容...', 'sessions.preview.clipped': '僅展示最近 {count} 條消息。', + 'sessions.workspace.kicker': '工作記憶', + 'sessions.workspace.title': '會話利用摘要', + 'sessions.workspace.subtitle': '從已載入的 {count} 條消息提取線索、命令、文件、連結與風險。', + 'sessions.workspace.copy': '複製工作簡報', + 'sessions.workspace.copy.title': '會話工作簡報', + 'sessions.workspace.copy.source': '來源', + 'sessions.workspace.copy.path': '路徑', + 'sessions.workspace.copy.empty': '暫無可複製的會話摘要', + 'sessions.workspace.copy.success': '已複製工作簡報', + 'sessions.workspace.metric.messages': '消息', + 'sessions.workspace.metric.user': '使用者', + 'sessions.workspace.metric.assistant': '助手', + 'sessions.workspace.metric.commands': '命令', + 'sessions.workspace.metric.artifacts': '材料', + 'sessions.workspace.metric.risks': '風險/待辦', + 'sessions.workspace.signals': '任務線索', + 'sessions.workspace.commands': '可複用命令', + 'sessions.workspace.files': '相關文件', + 'sessions.workspace.links': '相關連結', + 'sessions.workspace.risks': '風險與待辦', + 'sessions.workspace.nextSteps': '下一步', + 'sessions.workspace.empty': '暫無明顯線索,可從下方原始記錄繼續查看。', + 'sessions.workspace.noneCommands': '暫無命令線索', + 'sessions.workspace.noneFiles': '暫無文件線索', + 'sessions.workspace.noneLinks': '暫無連結線索', + 'sessions.workspace.noneRisks': '暫未識別到風險或待辦', 'sessions.preview.shownCount': '已顯示 {shown} / {total} 條', 'sessions.preview.loadMore': '載入更多(剩餘 {remain})', 'sessions.preview.loadingMore': '正在載入更早消息...', @@ -894,7 +922,18 @@ const zhTw = Object.freeze({ 'config.health.title': '設定健康檢查', 'config.health.run': '運行檢查', 'config.health.running': '檢查中...', - 'config.health.hint': '會批量探測所有提供商可用性,並重新整理延遲顯示。', + 'config.health.hint': '僅探測目前選中的提供商;未選提供商的遠端異常不計入本次錯誤。', + 'config.health.codexHint': '透過本機橋接探測目前 Codex 路由 provider、endpoint、鑑權與模型。', + 'config.health.providersSummary': '提供商:共 {total} · 正常 {green} · 設定問題 {yellow} · 失敗 {red}', + 'config.health.failedProviders.title': '待刪除的失敗提供商', + 'config.health.failedProviders.selectAria': '選擇失敗提供商 {name} 進行刪除', + 'config.health.failedProviders.clearSelection': '清空選擇', + 'config.health.failedProviders.selectAll': '全選', + 'config.health.failedProviders.selectAllAria': '全選可刪除的失敗提供商', + 'config.health.failedProviders.notDeletable': '保留/系統提供商,不能在這裡刪除。', + 'config.health.failedProviders.writeRequired': '刪除前請先打開本 tab 的提供商寫入開關。', + 'config.health.failedProviders.deleteSelected': '移除所選設定', + 'config.health.failedProviders.deleting': '刪除中...', 'config.health.progress': '已完成 {done}/{total} · 失敗 {failed}', 'config.health.ok': '檢查通過', 'config.health.fail': '檢查未通過', @@ -1301,7 +1340,7 @@ const zhTw = Object.freeze({ 'claude.health.title': '設定健康檢查', 'claude.health.run': '運行檢查', 'claude.health.running': '檢查中...', - 'claude.health.hint': '會批量探測所有 Claude 設定可用性,並重新整理延遲顯示。', + 'claude.health.hint': '僅探測目前選中的 Claude 設定;未選設定的遠端異常不計入本次錯誤。', 'claude.health.progress': '已完成 {done}/{total} · 失敗 {failed}', 'claude.md.title': 'CLAUDE.md', 'claude.md.open': '打開 CLAUDE.md', diff --git a/web-ui/modules/i18n/locales/zh.mjs b/web-ui/modules/i18n/locales/zh.mjs index b8dce2b2..86578ebd 100644 --- a/web-ui/modules/i18n/locales/zh.mjs +++ b/web-ui/modules/i18n/locales/zh.mjs @@ -436,6 +436,8 @@ const zh = Object.freeze({ 'toast.apply.fail': '应用配置失败', 'toast.check.success': '检查通过', 'toast.check.fail': '检查失败', + 'toast.health.noFailedProviderSelection': '请先选择至少一个失败提供商', + 'toast.health.deleteFailedProvidersDone': '已删除 {count} 个失败提供商', 'toast.noChanges': '未检测到改动', 'toast.template.loadFail': '加载模板失败', 'toast.template.empty': '模板不能为空', @@ -659,6 +661,32 @@ const zh = Object.freeze({ 'sessions.preview.rerender': '重新渲染', 'sessions.preview.preparing': '正在准备会话内容...', 'sessions.preview.clipped': '仅展示最近 {count} 条消息。', + 'sessions.workspace.kicker': '工作记忆', + 'sessions.workspace.title': '会话利用摘要', + 'sessions.workspace.subtitle': '从已加载的 {count} 条消息提取线索、命令、文件、链接与风险。', + 'sessions.workspace.copy': '复制工作简报', + 'sessions.workspace.copy.title': '会话工作简报', + 'sessions.workspace.copy.source': '来源', + 'sessions.workspace.copy.path': '路径', + 'sessions.workspace.copy.empty': '暂无可复制的会话摘要', + 'sessions.workspace.copy.success': '已复制工作简报', + 'sessions.workspace.metric.messages': '消息', + 'sessions.workspace.metric.user': '用户', + 'sessions.workspace.metric.assistant': '助手', + 'sessions.workspace.metric.commands': '命令', + 'sessions.workspace.metric.artifacts': '材料', + 'sessions.workspace.metric.risks': '风险/待办', + 'sessions.workspace.signals': '任务线索', + 'sessions.workspace.commands': '可复用命令', + 'sessions.workspace.files': '相关文件', + 'sessions.workspace.links': '相关链接', + 'sessions.workspace.risks': '风险与待办', + 'sessions.workspace.nextSteps': '下一步', + 'sessions.workspace.empty': '暂无明显线索,可从下方原始记录继续查看。', + 'sessions.workspace.noneCommands': '暂无命令线索', + 'sessions.workspace.noneFiles': '暂无文件线索', + 'sessions.workspace.noneLinks': '暂无链接线索', + 'sessions.workspace.noneRisks': '暂未识别到风险或待办', 'sessions.preview.shownCount': '已显示 {shown} / {total} 条', 'sessions.preview.loadMore': '加载更多(剩余 {remain})', 'sessions.preview.loadingMore': '正在加载更早消息...', @@ -894,7 +922,18 @@ const zh = Object.freeze({ 'config.health.title': '配置健康检查', 'config.health.run': '运行检查', 'config.health.running': '检查中...', - 'config.health.hint': '会批量探测所有提供商可用性,并刷新延迟显示。', + 'config.health.hint': '仅探测当前选中的提供商;未选提供商的远程异常不计入本次错误。', + 'config.health.codexHint': '通过本地桥接探测当前 Codex 路由 provider、endpoint、鉴权和模型。', + 'config.health.providersSummary': '提供商:共 {total} · 正常 {green} · 配置问题 {yellow} · 失败 {red}', + 'config.health.failedProviders.title': '待删除的失败提供商', + 'config.health.failedProviders.selectAria': '选择失败提供商 {name} 进行删除', + 'config.health.failedProviders.clearSelection': '清空选择', + 'config.health.failedProviders.selectAll': '全选', + 'config.health.failedProviders.selectAllAria': '全选可删除的失败提供商', + 'config.health.failedProviders.notDeletable': '保留/系统提供商,不能在这里删除。', + 'config.health.failedProviders.writeRequired': '删除前请先打开本 tab 的提供商写入开关。', + 'config.health.failedProviders.deleteSelected': '移除所选配置', + 'config.health.failedProviders.deleting': '删除中...', 'config.health.progress': '已完成 {done}/{total} · 失败 {failed}', 'config.health.ok': '检查通过', 'config.health.fail': '检查未通过', @@ -1301,7 +1340,7 @@ const zh = Object.freeze({ 'claude.health.title': '配置健康检查', 'claude.health.run': '运行检查', 'claude.health.running': '检查中...', - 'claude.health.hint': '会批量探测所有 Claude 配置可用性,并刷新延迟显示。', + 'claude.health.hint': '仅探测当前选中的 Claude 配置;未选配置的远程异常不计入本次错误。', 'claude.health.progress': '已完成 {done}/{total} · 失败 {failed}', 'claude.md.title': 'CLAUDE.md', 'claude.md.open': '打开 CLAUDE.md', diff --git a/web-ui/modules/provider-default-names.mjs b/web-ui/modules/provider-default-names.mjs new file mode 100644 index 00000000..7ba919c9 --- /dev/null +++ b/web-ui/modules/provider-default-names.mjs @@ -0,0 +1,25 @@ +export function nextNumericProviderName(existingNames) { + const used = new Set(); + const list = Array.isArray(existingNames) ? existingNames : []; + for (const item of list) { + const name = typeof item === 'string' + ? item.trim() + : (item && typeof item.name === 'string' ? item.name.trim() : ''); + if (!/^\d+$/.test(name)) continue; + const value = Number(name); + if (Number.isSafeInteger(value) && value > 0) { + used.add(value); + } + } + let next = 1; + while (used.has(next)) next += 1; + return String(next); +} + +export function nextCodexProviderName(providers) { + return nextNumericProviderName(Array.isArray(providers) ? providers : []); +} + +export function nextClaudeConfigName(configs) { + return nextNumericProviderName(Object.keys(configs && typeof configs === 'object' ? configs : {})); +} diff --git a/web-ui/partials/index/modal-health-check.html b/web-ui/partials/index/modal-health-check.html index b3071ad8..ea66d7b2 100644 --- a/web-ui/partials/index/modal-health-check.html +++ b/web-ui/partials/index/modal-health-check.html @@ -1,9 +1,10 @@