diff --git a/CHANGELOG.md b/CHANGELOG.md index a6d05ad4..df662b9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ ### Fixed +- Dashboard Errors tab now has a real clear action: `DELETE /admin/error-logs` removes current + backup JSONL files and the read cursor so repeated `StreamUpstreamPrematureClose` groups can be cleared from the page instead of only marked read. Anthropic setup defaults now map Opus 4.7 → `gpt-5.5` and Sonnet 4.6 → `gpt-5.4`, and the built-in Anthropic API-key catalog lists Claude Opus 4.7 (`src/logs/error-log.ts`, `src/routes/admin/error-logs.ts`, `shared/hooks/use-error-logs.ts`, `web/src/pages/ErrorsPage.tsx`, `web/src/components/AnthropicSetup.tsx`, `src/auth/api-key-catalog.ts`). - Claude Code 的 `Read` 工具参数里如果 `pages` 传成空字符串或空白字符串,会在 Codex → Anthropic 转换时被自动剔除,避免 GPT-5.5 反复触发 `Read tool validation error: Invalid pages parameter: ""` 并重试隔离工作树;对应单测覆盖流式与非流式两条路径,以及非空 PDF 页码范围保留(`src/translation/codex-to-anthropic.ts`、`tests/unit/translation/codex-to-anthropic-read-pages.test.ts`)。 - Dashboard egress log details now include Codex request `reasoning` and optional `service_tier`, so `/admin/logs` can show the actual reasoning effort sent upstream instead of only `model` / `stream` / `useWebSocket` (`src/routes/shared/proxy-egress-log.ts`, `tests/unit/routes/shared/proxy-egress-log.test.ts`). - implicit-resume 区分"真 missing tool call"与"client 自包含 full replay":`evaluateImplicitResume` 现在新增 `inlineFunctionCallIds` 入参,由 `buildProxySessionContext` 通过 `getInlineFunctionCallIds(codexRequest.input)` 在调用前收集 input 里所有 `function_call` 项的 call_id。当 input 里的 function_call_output 全部能在 input 内找到对应 function_call(典型 Codex CLI `/compact` 或客户端 fallback 自包含 replay 场景),返回 `reason: "self_contained_replay"` 而不是 `missing_tool_calls`,proxy 走正常透传不再触发 payload guard 413。混合场景(部分 inline 部分 storage 都找不到)仍判 `missing_tool_calls` 防真 runaway。新增 4 个 `evaluateImplicitResume`/`getInlineFunctionCallIds`/`isSelfContainedReplay` 单测覆盖纯 inline、混合、空 output、incremental turn 四类(`src/routes/shared/proxy-session-helpers.ts`、`src/routes/shared/proxy-session-context.ts`、`tests/unit/routes/shared/proxy-handler-implicit-resume.test.ts`、`tests/unit/routes/shared/proxy-session-context.test.ts`)。 diff --git a/shared/hooks/use-error-logs.test.ts b/shared/hooks/use-error-logs.test.ts index fee47368..c79883bb 100644 --- a/shared/hooks/use-error-logs.test.ts +++ b/shared/hooks/use-error-logs.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { formatRelativeTime } from "./use-error-logs.js"; +import { clearErrorLogsRequest, formatRelativeTime } from "./use-error-logs.js"; describe("formatRelativeTime", () => { const now = new Date("2026-05-10T12:00:00Z").getTime(); @@ -28,3 +28,18 @@ describe("formatRelativeTime", () => { expect(formatRelativeTime("not-a-date", now)).toBe("not-a-date"); }); }); + +describe("clearErrorLogsRequest", () => { + it("sends a collection DELETE to the error log endpoint", async () => { + const fetchImpl = async ( + input: string, + init: RequestInit, + ): Promise> => { + expect(input).toBe("/admin/error-logs"); + expect(init).toEqual({ method: "DELETE" }); + return { ok: true }; + }; + + await expect(clearErrorLogsRequest(fetchImpl)).resolves.toBe(true); + }); +}); diff --git a/shared/hooks/use-error-logs.ts b/shared/hooks/use-error-logs.ts index 1fcd0cba..edafa00b 100644 --- a/shared/hooks/use-error-logs.ts +++ b/shared/hooks/use-error-logs.ts @@ -30,6 +30,15 @@ export interface ErrorLogCount { const POLL_MS = 30_000; +type ErrorLogsFetch = (input: string, init: RequestInit) => Promise>; + +export async function clearErrorLogsRequest( + fetchImpl: ErrorLogsFetch = (input, init) => fetch(input, init), +): Promise { + const res = await fetchImpl("/admin/error-logs", { method: "DELETE" }); + return res.ok; +} + export function useErrorLogs() { const [groups, setGroups] = useState([]); const [count, setCount] = useState({ total: 0, unread: 0 }); @@ -68,6 +77,19 @@ export function useErrorLogs() { } }, [load]); + const clearAll = useCallback(async () => { + try { + const ok = await clearErrorLogsRequest(); + if (!ok) { + setError("Failed to clear error logs"); + return; + } + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to clear error logs"); + } + }, [load]); + useEffect(() => { void load(); timerRef.current = setInterval(() => void load(), POLL_MS); @@ -76,7 +98,7 @@ export function useErrorLogs() { }; }, [load]); - return { groups, count, loading, error, refresh: load, markAllSeen }; + return { groups, count, loading, error, refresh: load, markAllSeen, clearAll }; } /** diff --git a/shared/i18n/translations.ts b/shared/i18n/translations.ts index 76f310df..aead73b5 100644 --- a/shared/i18n/translations.ts +++ b/shared/i18n/translations.ts @@ -225,6 +225,7 @@ export const translations = { errorsTabDesc: "Uncaught crashes from the proxy backend, Electron main, and the dashboard renderer. Stored locally — nothing leaves this machine.", errorsRefresh: "Refresh", errorsMarkSeen: "Mark all read", + errorsClear: "Clear all", errorsBadge: "errors", errorsBadgeTooltip: "Unread errors — click to view", errorsNone: "No errors recorded.", @@ -634,6 +635,7 @@ export const translations = { errorsTabDesc: "Proxy 后端、Electron 主进程、Dashboard 渲染进程的 uncaught 崩溃。仅本地存储,不上传任何数据。", errorsRefresh: "刷新", errorsMarkSeen: "全部标记已读", + errorsClear: "清空错误", errorsBadge: "条错误", errorsBadgeTooltip: "未读错误 — 点击查看", errorsNone: "暂无错误记录。", diff --git a/src/auth/api-key-catalog.ts b/src/auth/api-key-catalog.ts index 2c9c10cc..21dc7806 100644 --- a/src/auth/api-key-catalog.ts +++ b/src/auth/api-key-catalog.ts @@ -18,7 +18,7 @@ export interface ProviderMeta { } const ANTHROPIC_MODELS: CatalogModel[] = [ - { id: "claude-opus-4-6", displayName: "Claude Opus 4.6" }, + { id: "claude-opus-4-7", displayName: "Claude Opus 4.7" }, { id: "claude-sonnet-4-6", displayName: "Claude Sonnet 4.6" }, { id: "claude-haiku-4-5", displayName: "Claude Haiku 4.5" }, ]; diff --git a/src/logs/error-log.ts b/src/logs/error-log.ts index 118c2567..dfc0fbd4 100644 --- a/src/logs/error-log.ts +++ b/src/logs/error-log.ts @@ -27,6 +27,7 @@ import { readFileSync, renameSync, statSync, + unlinkSync, writeFileSync, } from "fs"; import { resolve } from "path"; @@ -214,6 +215,18 @@ export function readErrorLog(limit?: number): ErrorLogEntry[] { return combined; } +/** Remove all persisted error log entries and the read cursor. */ +export function clearErrorLog(): void { + for (const file of [LOG_FILE, BACKUP_FILE, CURSOR_FILE]) { + try { + const path = resolve(getDataDir(), file); + if (existsSync(path)) unlinkSync(path); + } catch { + // Clearing is best-effort; a failed delete must not break the admin UI. + } + } +} + function firstStackFrame(stack: string | undefined): string { if (!stack) return ""; for (const line of stack.split("\n")) { diff --git a/src/routes/admin/error-logs.ts b/src/routes/admin/error-logs.ts index 0bcd2a9e..820de4d0 100644 --- a/src/routes/admin/error-logs.ts +++ b/src/routes/admin/error-logs.ts @@ -2,6 +2,7 @@ import { Hono } from "hono"; import { z } from "zod"; import { appendErrorLog, + clearErrorLog, groupErrorLog, getUnreadCount, readErrorLog, @@ -59,6 +60,11 @@ export function createErrorLogRoutes(): Hono { return c.json({ ok: true, cursor }); }); + app.delete("/admin/error-logs", (c) => { + clearErrorLog(); + return c.json({ ok: true }); + }); + app.post("/admin/error-logs/report", async (c) => { const raw = await c.req.json().catch(() => null); if (raw === null) { diff --git a/tests/unit/routes/admin/error-logs.test.ts b/tests/unit/routes/admin/error-logs.test.ts index 0fdf07eb..7f131254 100644 --- a/tests/unit/routes/admin/error-logs.test.ts +++ b/tests/unit/routes/admin/error-logs.test.ts @@ -7,7 +7,7 @@ */ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -import { mkdtempSync, existsSync, rmSync, readFileSync } from "fs"; +import { mkdtempSync, existsSync, rmSync, readFileSync, writeFileSync } from "fs"; import { tmpdir } from "os"; import { resolve } from "path"; import { Hono } from "hono"; @@ -183,6 +183,52 @@ describe("POST /admin/error-logs/seen", () => { }); }); +describe("DELETE /admin/error-logs", () => { + it("clears current, rotated backup, and cursor files so grouped logs and counts become empty", async () => { + await appendFew(); + writeFileSync( + resolve(tmpDataDir, "error-log.1.jsonl"), + JSON.stringify({ + ts: "2026-05-01T00:00:00.000Z", + version: "0.0.0-test", + platform: "darwin", + source: "server", + error: { name: "StreamUpstreamPrematureClose", message: "closed early" }, + }) + "\n", + "utf-8", + ); + const { setReadCursor } = await import("@src/logs/error-log.js"); + setReadCursor("2025-01-01T00:00:00.000Z"); + const app = await buildApp(); + + expect(existsSync(resolve(tmpDataDir, "error-log.jsonl"))).toBe(true); + expect(existsSync(resolve(tmpDataDir, "error-log.1.jsonl"))).toBe(true); + expect(existsSync(resolve(tmpDataDir, "error-log.cursor"))).toBe(true); + + const before = await app.request("/admin/error-logs/count"); + expect(((await before.json()) as { total: number; unread: number })).toEqual({ + total: 4, + unread: 4, + }); + + const clearRes = await app.request("/admin/error-logs", { method: "DELETE" }); + expect(clearRes.status).toBe(200); + expect(await clearRes.json()).toEqual({ ok: true }); + expect(existsSync(resolve(tmpDataDir, "error-log.jsonl"))).toBe(false); + expect(existsSync(resolve(tmpDataDir, "error-log.1.jsonl"))).toBe(false); + expect(existsSync(resolve(tmpDataDir, "error-log.cursor"))).toBe(false); + + const grouped = await app.request("/admin/error-logs"); + expect((await grouped.json()) as { groups: unknown[] }).toEqual({ groups: [] }); + + const after = await app.request("/admin/error-logs/count"); + expect(((await after.json()) as { total: number; unread: number })).toEqual({ + total: 0, + unread: 0, + }); + }); +}); + describe("POST /admin/error-logs/report", () => { it("appends a renderer-reported error to the log with sanitized context", async () => { const app = await buildApp(); diff --git a/tests/unit/routes/api-keys.test.ts b/tests/unit/routes/api-keys.test.ts index a40fe57d..de761291 100644 --- a/tests/unit/routes/api-keys.test.ts +++ b/tests/unit/routes/api-keys.test.ts @@ -26,6 +26,23 @@ describe("api key routes", () => { app = createApiKeyRoutes(pool); }); + it("returns current built-in Anthropic catalog defaults", async () => { + const res = await app.request("/auth/api-keys/catalog"); + expect(res.status).toBe(200); + + const body = await res.json() as { + catalog: { + anthropic: { + models: Array<{ id: string; displayName: string }>; + }; + }; + }; + expect(body.catalog.anthropic.models.slice(0, 2)).toEqual([ + { id: "claude-opus-4-7", displayName: "Claude Opus 4.7" }, + { id: "claude-sonnet-4-6", displayName: "Claude Sonnet 4.6" }, + ]); + }); + it("adds one stored entry per selected model and masks returned keys", async () => { const res = await app.request("/auth/api-keys", { method: "POST", diff --git a/tests/unit/web/anthropic-setup.test.ts b/tests/unit/web/anthropic-setup.test.ts new file mode 100644 index 00000000..a262107b --- /dev/null +++ b/tests/unit/web/anthropic-setup.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from "vitest"; +import { + ANTHROPIC_MODEL_PRESETS, + DEFAULT_ANTHROPIC_MODELS, +} from "../../../web/src/components/AnthropicSetup"; + +describe("AnthropicSetup defaults", () => { + it("maps current Claude families to the desired Codex defaults", () => { + expect(DEFAULT_ANTHROPIC_MODELS).toEqual({ + opus: "gpt-5.5", + sonnet: "gpt-5.4", + haiku: "gpt-5.4-mini", + }); + + expect(ANTHROPIC_MODEL_PRESETS.slice(0, 2)).toEqual([ + { label: "gpt-5.5 (Opus 4.7)", value: "gpt-5.5" }, + { label: "gpt-5.4 (Sonnet 4.6)", value: "gpt-5.4" }, + ]); + }); +}); diff --git a/tests/unit/web/errors-page.test.ts b/tests/unit/web/errors-page.test.ts index c62a1e17..babb11db 100644 --- a/tests/unit/web/errors-page.test.ts +++ b/tests/unit/web/errors-page.test.ts @@ -12,4 +12,16 @@ describe("ErrorsPage", () => { expect(source).toContain("group.sample_context"); expect(source).toContain("JSON.stringify(group.sample_context, null, 2)"); }); + + it("wires a clear-all control for persisted error log entries", () => { + const source = readFileSync( + resolve(__dirname, "../../../web/src/pages/ErrorsPage.tsx"), + "utf-8", + ); + + expect(source).toContain("clearAll"); + expect(source).toContain("errorsClear"); + expect(source).toContain("aria-label={t(\"errorsClear\")}"); + expect(source).toContain("onClick={() => void clearAll()}"); + }); }); diff --git a/web/src/components/AnthropicSetup.tsx b/web/src/components/AnthropicSetup.tsx index c1acbdee..1d44e644 100644 --- a/web/src/components/AnthropicSetup.tsx +++ b/web/src/components/AnthropicSetup.tsx @@ -9,9 +9,15 @@ interface AnthropicSetupProps { serviceTier: string | null; } -const PRESETS: Array<{ label: string; value: string }> = [ - { label: "gpt-5.4 (Opus)", value: "gpt-5.4" }, - { label: "gpt-5.3-codex (Sonnet)", value: "gpt-5.3-codex" }, +export const DEFAULT_ANTHROPIC_MODELS = { + opus: "gpt-5.5", + sonnet: "gpt-5.4", + haiku: "gpt-5.4-mini", +}; + +export const ANTHROPIC_MODEL_PRESETS: Array<{ label: string; value: string }> = [ + { label: "gpt-5.5 (Opus 4.7)", value: DEFAULT_ANTHROPIC_MODELS.opus }, + { label: "gpt-5.4 (Sonnet 4.6)", value: DEFAULT_ANTHROPIC_MODELS.sonnet }, { label: "gpt-5.4-mini (Haiku)", value: "gpt-5.4-mini" }, ]; @@ -19,9 +25,9 @@ export function AnthropicSetup({ apiKey, selectedModel, reasoningEffort, service const t = useT(); const origin = typeof window !== "undefined" ? window.location.origin : "http://localhost:8080"; - const [opusModel, setOpusModel] = useState("gpt-5.4"); - const [sonnetModel, setSonnetModel] = useState("gpt-5.3-codex"); - const [haikuModel, setHaikuModel] = useState("gpt-5.4-mini"); + const [opusModel, setOpusModel] = useState(DEFAULT_ANTHROPIC_MODELS.opus); + const [sonnetModel, setSonnetModel] = useState(DEFAULT_ANTHROPIC_MODELS.sonnet); + const [haikuModel, setHaikuModel] = useState(DEFAULT_ANTHROPIC_MODELS.haiku); // Custom model from ApiConfig const customModel = useMemo(() => { @@ -40,7 +46,7 @@ export function AnthropicSetup({ apiKey, selectedModel, reasoningEffort, service .catch(() => {}); }, []); - const presetValues = new Set(PRESETS.map((p) => p.value)); + const presetValues = new Set(ANTHROPIC_MODEL_PRESETS.map((p) => p.value)); const extraModels = allModels.filter((id) => !presetValues.has(id)); const envText = useMemo(() => [ @@ -59,7 +65,7 @@ export function AnthropicSetup({ apiKey, selectedModel, reasoningEffort, service const modelDropdown = (value: string, onChange: (v: string) => void) => (