diff --git a/electron/ai-edition/agent-provider-capabilities.test.ts b/electron/ai-edition/agent-provider-capabilities.test.ts deleted file mode 100644 index b422f8210..000000000 --- a/electron/ai-edition/agent-provider-capabilities.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { getReasoningCallOptions, getReasoningCapability } from "./agent-provider-capabilities"; - -describe("getReasoningCapability", () => { - it("returns the 6-step Codex effort range for openai-oauth", () => { - const cap = getReasoningCapability("openai-oauth", "gpt-5"); - expect(cap.supported).toBe(true); - expect(cap.strategy).toBe("custom-openai-account"); - expect(cap.efforts).toEqual(["none", "minimal", "low", "medium", "high", "xhigh"]); - }); - - it("turns off reasoning for non-reasoning OpenAI models", () => { - expect(getReasoningCapability("openai", "gpt-4o").supported).toBe(false); - expect(getReasoningCapability("openai", "gpt-3.5-turbo").supported).toBe(false); - }); - - it("matches o-series and gpt-5* OpenAI reasoning models", () => { - expect(getReasoningCapability("openai", "o3-mini").supported).toBe(true); - expect(getReasoningCapability("openai", "o4-mini").supported).toBe(true); - expect(getReasoningCapability("openai", "gpt-5").supported).toBe(true); - expect(getReasoningCapability("openai", "gpt-5-mini").supported).toBe(true); - }); - - it("Anthropic haiku/sonnet/opus 4.x reasoning models use thinking strategy", () => { - const cap = getReasoningCapability("anthropic", "claude-sonnet-4-5"); - expect(cap.strategy).toBe("anthropic-thinking"); - expect(cap.efforts).toContain("xhigh"); - }); - - it("Anthropic 3.x stays unsupported", () => { - expect(getReasoningCapability("anthropic", "claude-3-haiku-20240307").supported).toBe(false); - }); - - it("Google Gemini 2.5/3.x models use thinking strategy", () => { - expect(getReasoningCapability("google", "gemini-2.5-pro").supported).toBe(true); - expect(getReasoningCapability("google", "gemini-3-flash-preview").supported).toBe(true); - expect(getReasoningCapability("google", "gemini-1.5-pro").supported).toBe(false); - }); - - it("OpenRouter only supports reasoning for known reasoning-capable slug prefixes", () => { - expect(getReasoningCapability("openrouter", "anthropic/claude-3.5-sonnet").supported).toBe( - false, - ); - expect(getReasoningCapability("openrouter", "anthropic/claude-sonnet-4-5").supported).toBe( - true, - ); - expect(getReasoningCapability("openrouter", "openai/gpt-5").supported).toBe(true); - expect(getReasoningCapability("openrouter", "deepseek/deepseek-r1").supported).toBe(true); - }); - - it("aliases `claude` → `anthropic` and `gemini` → `google` for capability lookups", () => { - expect(getReasoningCapability("claude", "claude-sonnet-4-5").supported).toBe(true); - expect(getReasoningCapability("gemini", "gemini-2.5-flash").supported).toBe(true); - }); - - it("returns unsupported for unknown providers", () => { - expect(getReasoningCapability("nope", "x").supported).toBe(false); - expect(getReasoningCapability("").supported).toBe(false); - }); -}); - -describe("getReasoningCallOptions", () => { - it("returns strategy=none when capability is unsupported", () => { - expect(getReasoningCallOptions("mistral", "mistral-large-latest", "high").strategy).toBe( - "none", - ); - }); - - it("openai-responses strategy maps effort to `{reasoning:{effort}}` and turns on Responses API", () => { - const opt = getReasoningCallOptions("openai", "o4-mini", "high"); - expect(opt.strategy).toBe("openai-responses"); - expect(opt.useResponsesApi).toBe(true); - expect(opt.extraBody).toEqual({ reasoning: { effort: "high" } }); - }); - - it("openai-responses collapses `xhigh` to `high` (OpenAI has no xhigh tier)", () => { - const opt = getReasoningCallOptions("openai", "o4-mini", "xhigh"); - expect(opt.effort).toBe("high"); - }); - - it("openai-responses collapses `minimal` to `low`", () => { - const opt = getReasoningCallOptions("openai", "o4-mini", "minimal"); - expect(opt.effort).toBe("low"); - }); - - it("Anthropic adaptive-thinking mode for opus/sonnet 4.6/4.7", () => { - const opt = getReasoningCallOptions("anthropic", "claude-opus-4-6", "high"); - expect(opt.strategy).toBe("anthropic-thinking"); - expect(opt.requestBodyPatch?.thinking).toEqual({ - type: "adaptive", - display: "summarized", - }); - expect(opt.requestBodyPatch?.outputConfig).toEqual({ effort: "high" }); - }); - - it("Anthropic legacy-thinking mode (budget_tokens) for older 4.x models", () => { - const opt = getReasoningCallOptions("anthropic", "claude-sonnet-4-5", "xhigh"); - expect(opt.requestBodyPatch?.thinking).toMatchObject({ - type: "enabled", - budget_tokens: 16_000, - }); - }); - - it("OpenRouter uses `modelKwargs`-style reasoning + include_reasoning", () => { - const opt = getReasoningCallOptions("openrouter", "openai/gpt-5", "medium"); - expect(opt.strategy).toBe("openrouter-reasoning"); - expect(opt.extraBody).toEqual({ - reasoning: { effort: "medium" }, - include_reasoning: true, - }); - }); - - it("Google Gemini wires thinking_level + thinking_budget through thinkingConfig + extra_body.google", () => { - const opt = getReasoningCallOptions("google", "gemini-3-flash-preview", "high"); - expect(opt.strategy).toBe("google-thinking"); - expect(opt.extraBody).toEqual({ - thinkingConfig: { - includeThoughts: true, - thinkingLevel: "HIGH", - thinkingBudget: 8_192, - }, - google: { - thinking_config: { - include_thoughts: true, - thinking_budget: 8_192, - }, - }, - }); - }); - - it("Codex effort round-trips through `reasoning_effort` and keeps the Responses-API flag", () => { - const opt = getReasoningCallOptions("openai-oauth", "gpt-5", "minimal"); - expect(opt.strategy).toBe("custom-openai-account"); - expect(opt.effort).toBe("minimal"); - expect(opt.useResponsesApi).toBe(true); - expect(opt.extraBody).toEqual({ reasoning_effort: "minimal" }); - }); - - it("returns strategy with no body bits when effort is `none` (caller can still send it)", () => { - const opt = getReasoningCallOptions("openai", "o4-mini", "none"); - expect(opt.strategy).toBe("openai-responses"); - expect(opt.extraBody).toBeUndefined(); - }); -}); diff --git a/electron/ai-edition/agent-provider-capabilities.ts b/electron/ai-edition/agent-provider-capabilities.ts deleted file mode 100644 index b3b1996ae..000000000 --- a/electron/ai-edition/agent-provider-capabilities.ts +++ /dev/null @@ -1,313 +0,0 @@ -// Per-provider reasoning-effort capabilities. Mirrors axcut's -// apps/server/src/llm/agent-provider-capabilities.ts. We don't use LangChain -// here — direct fetch in llm-call.ts — so the export shape is the bits that -// actually differ in the HTTP wire layer: `extraBody`, `extraHeaders`, plus -// the chosen `strategy` so call sites can branch. -// -// Each provider's reasoning surface area is different: -// - openai / openai-compatible: only o*-series + gpt-5*; wire `reasoning.effort` -// via the Responses API (`useResponsesApi = true`). -// - anthropic: claude 3.7+ + claude-(opus|sonnet|haiku)-4*; wire `thinking` -// block with adaptive mode for opus/sonnet 4.6/4.7, budget_tokens for the -// rest. Effort maps to budget tiers; `xhigh` raises the budget. -// - openrouter: passthrough — wire `reasoning` modelKwargs + include_reasoning -// for any reasoning-capable model slug. -// - google (Gemini): wire `thinkingLevel` + `thinkingBudget` in -// `extra_body.google.thinking_config`. -// - openai-oauth (Codex): full 6-step effort range via the Responses API. - -import { normalizeProviderId, type ReasoningEffort } from "./provider-registry"; - -export type ReasoningStrategy = - | "openai-responses" - | "anthropic-thinking" - | "minimax-thinking" - | "openrouter-reasoning" - | "google-thinking" - | "custom-openai-account" - | "none"; - -export interface ReasoningCapability { - supported: boolean; - strategy: ReasoningStrategy; - /** Effort list the provider actually accepts. */ - efforts: readonly ReasoningEffort[]; - defaultEffort: ReasoningEffort; -} - -export interface ReasoningCallOptions { - strategy: ReasoningStrategy; - /** Request body bits to merge into the outgoing fetch body. */ - extraBody?: Record; - /** Request body bits to merge into the outgoing fetch body. Anthropic's - * thinking block goes in `body.thinking` (not `body.reasoning`). */ - requestBodyPatch?: { - thinking?: Record; - outputConfig?: Record; - }; - /** Headers to add beyond Authorization (e.g. chatgpt-account-id). */ - extraHeaders?: Record; - /** True when the provider wants the Responses-API endpoint instead of - * the OpenAI chat-completions path. */ - useResponsesApi?: boolean; - /** Effort actually sent downstream after strategy normalization. */ - effort?: ReasoningEffort; -} - -const OPENAI_REASONING_EFFORTS: readonly ReasoningEffort[] = ["none", "low", "medium", "high"]; -const ANTHROPIC_REASONING_EFFORTS: readonly ReasoningEffort[] = [ - "none", - "low", - "medium", - "high", - "xhigh", -]; -const OPENROUTER_REASONING_EFFORTS: readonly ReasoningEffort[] = ["none", "low", "medium", "high"]; -const GOOGLE_REASONING_EFFORTS: readonly ReasoningEffort[] = ["none", "low", "medium", "high"]; -const CODEX_REASONING_EFFORTS: readonly ReasoningEffort[] = [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh", -]; - -export function getReasoningCapability(provider: string, model?: string): ReasoningCapability { - const id = normalizeProviderId(provider); - if (!id) return { supported: false, strategy: "none", efforts: ["none"], defaultEffort: "none" }; - if (id === "openai-oauth") { - return { - supported: true, - strategy: "custom-openai-account", - efforts: CODEX_REASONING_EFFORTS, - defaultEffort: "medium", - }; - } - if ((id === "openai" || id === "openai-compatible") && isOpenAIReasoningModel(model)) { - return { - supported: true, - strategy: "openai-responses", - efforts: OPENAI_REASONING_EFFORTS, - defaultEffort: "medium", - }; - } - if (id === "anthropic" && isAnthropicReasoningModel(model)) { - return { - supported: true, - strategy: "anthropic-thinking", - efforts: ANTHROPIC_REASONING_EFFORTS, - defaultEffort: "medium", - }; - } - if (id === "openrouter" && isOpenRouterReasoningModel(model)) { - return { - supported: true, - strategy: "openrouter-reasoning", - efforts: OPENROUTER_REASONING_EFFORTS, - defaultEffort: "medium", - }; - } - if (id === "google" && isGeminiThinkingModel(model)) { - return { - supported: true, - strategy: "google-thinking", - efforts: GOOGLE_REASONING_EFFORTS, - defaultEffort: "medium", - }; - } - if (id === "minimax" || id === "minimax-token-plan") { - // MiniMax's thinking block is binary — `{type: "adaptive"}` (on) or - // `{type: "disabled"}` (off, ignored on M2.x which is always-on) — not - // the OpenAI `reasoning.effort` field, and no budget_tokens tiers like - // native Anthropic. - return { - supported: true, - strategy: "minimax-thinking", - efforts: ANTHROPIC_REASONING_EFFORTS, - defaultEffort: "medium", - }; - } - return { supported: false, strategy: "none", efforts: ["none"], defaultEffort: "none" }; -} - -/** - * Build the request-side wiring for a given provider/model/effort. Returned - * options are merged into `callLlm`'s outgoing body/headers; an empty object - * means "no reasoning wire-up" and is safe to apply unconditionally. - */ -export function getReasoningCallOptions( - provider: string, - model: string | undefined, - selected: ReasoningEffort | undefined, -): ReasoningCallOptions { - const cap = getReasoningCapability(provider, model); - if (!cap.supported) { - return { strategy: "none" }; - } - const effort = normalizeEffortForCapability(selected, cap); - if (!effort || effort === "none") { - return { strategy: cap.strategy }; - } - switch (cap.strategy) { - case "openai-responses": - return { - strategy: cap.strategy, - effort, - extraBody: { reasoning: { effort: toOpenAIEffort(effort) } }, - useResponsesApi: true, - }; - case "anthropic-thinking": { - const adaptive = isAnthropicAdaptiveThinkingModel(model); - return { - strategy: cap.strategy, - effort, - requestBodyPatch: adaptive - ? { - thinking: { type: "adaptive", display: "summarized" }, - outputConfig: { effort: toAnthropicEffort(effort) }, - } - : { - thinking: { - type: "enabled", - budget_tokens: toAnthropicBudgetTokens(effort), - display: "summarized", - }, - }, - }; - } - case "minimax-thinking": - return { - strategy: cap.strategy, - effort, - requestBodyPatch: { thinking: { type: "adaptive" } }, - }; - case "openrouter-reasoning": - return { - strategy: cap.strategy, - effort, - extraBody: { - reasoning: { effort: toOpenAIEffort(effort) }, - include_reasoning: true, - }, - }; - case "google-thinking": - return { - strategy: cap.strategy, - effort, - extraBody: { - thinkingConfig: { - includeThoughts: true, - thinkingLevel: toGoogleThinkingLevel(effort), - thinkingBudget: toGoogleThinkingBudget(effort), - }, - google: { - thinking_config: { - include_thoughts: true, - thinking_budget: toGoogleThinkingBudget(effort), - }, - }, - }, - }; - case "custom-openai-account": - // Codex speaks its own Responses dialect; the chat path handles the - // effort directly. `useResponsesApi` is set so the Codex transport - // can branch on it. - return { - strategy: cap.strategy, - effort, - extraBody: { reasoning_effort: effort }, - useResponsesApi: true, - }; - default: - return { strategy: cap.strategy }; - } -} - -function normalizeEffortForCapability( - selected: ReasoningEffort | undefined, - cap: ReasoningCapability, -): ReasoningEffort | undefined { - if (!cap.supported) return undefined; - if (!selected) return cap.defaultEffort; - if (cap.efforts.includes(selected)) return selected; - if (selected === "minimal" && cap.efforts.includes("low")) return "low"; - if (selected === "xhigh" && cap.efforts.includes("high")) return "high"; - return cap.defaultEffort; -} - -function isOpenAIReasoningModel(model?: string): boolean { - const normalized = normalizeModelName(model); - return /^(o\d|o\d-|o\d\.|gpt-5|gpt-5-|gpt-5\.)/.test(normalized); -} - -function isAnthropicReasoningModel(model?: string): boolean { - const normalized = normalizeModelName(model); - return ( - /^claude-(opus|sonnet|haiku)-4/.test(normalized) || - /^claude-3-7/.test(normalized) || - normalized.includes("claude-3.7") - ); -} - -function isAnthropicAdaptiveThinkingModel(model?: string): boolean { - const normalized = normalizeModelName(model); - return ( - /^claude-(opus|sonnet)-4-[67]/.test(normalized) || - /^claude-(opus|sonnet)-4\.(6|7)/.test(normalized) - ); -} - -function isGeminiThinkingModel(model?: string): boolean { - const normalized = normalizeModelName(model); - return normalized.startsWith("gemini-2.5") || normalized.startsWith("gemini-3"); -} - -function isOpenRouterReasoningModel(model?: string): boolean { - const normalized = normalizeModelName(model); - return ( - isOpenAIReasoningModel(normalized.replace(/^openai\//, "")) || - isAnthropicReasoningModel(normalized.replace(/^anthropic\//, "")) || - normalized.includes("/deepseek-r1") || - normalized.includes("deepseek/deepseek-r1") || - (normalized.includes("/qwen") && normalized.includes("thinking")) || - normalized.includes("/grok-4") || - normalized.includes("grok-4") - ); -} - -function toOpenAIEffort(effort: ReasoningEffort): "low" | "medium" | "high" { - if (effort === "high" || effort === "xhigh") return "high"; - if (effort === "medium") return "medium"; - return "low"; -} - -function toAnthropicEffort(effort: ReasoningEffort): "low" | "medium" | "high" | "xhigh" { - if (effort === "xhigh") return "xhigh"; - if (effort === "high") return "high"; - if (effort === "medium") return "medium"; - return "low"; -} - -function toAnthropicBudgetTokens(effort: ReasoningEffort): number { - if (effort === "xhigh") return 16_000; - if (effort === "high") return 10_000; - if (effort === "medium") return 4_000; - return 1_024; -} - -function toGoogleThinkingLevel(effort: ReasoningEffort): "LOW" | "MEDIUM" | "HIGH" { - if (effort === "high" || effort === "xhigh") return "HIGH"; - if (effort === "medium") return "MEDIUM"; - return "LOW"; -} - -function toGoogleThinkingBudget(effort: ReasoningEffort): number { - if (effort === "high" || effort === "xhigh") return 8_192; - if (effort === "medium") return 4_096; - return 1_024; -} - -function normalizeModelName(model?: string): string { - return model?.trim().toLowerCase() ?? ""; -} diff --git a/electron/ai-edition/caption-translate.ts b/electron/ai-edition/caption-translate.ts index 03eeb05f8..02706aff4 100644 --- a/electron/ai-edition/caption-translate.ts +++ b/electron/ai-edition/caption-translate.ts @@ -11,7 +11,8 @@ // be re-keyed exactly; anything it fails to return is simply left untranslated // and shows the original text, which is the honest fallback for a partial run. -import { streamLlm } from "./llm-call"; +import { HumanMessage, SystemMessage } from "@langchain/core/messages"; +import { createOpenScreenChatModel, messageContentToText } from "./deep-agent/chat-model"; /** One transcript segment to translate. */ export interface CaptionTranslateSegment { @@ -127,6 +128,28 @@ export async function translateCaptionSegments( const out: Record = {}; let done = 0; + // Built once, not per batch: for Copilot this call swaps the PAT for a + // runtime token over the network, and a long transcript is a lot of + // batches. ponytail: the Copilot runtime token is short-lived, so a + // translation running longer than its TTL would need a rebuild here. + let model: Awaited>; + try { + model = await createOpenScreenChatModel({ + provider: options.provider, + model: options.model, + apiKey: options.apiKey, + baseUrl: options.baseUrl, + reasoningEffort: options.reasoningEffort, + accountId: options.accountId, + }); + } catch (error) { + return { + success: false, + segments: out, + error: error instanceof Error ? error.message : String(error), + }; + } + for (let i = 0; i < segments.length; i += batchSize) { if (options.signal?.aborted) { return { success: false, segments: out, error: "Translation cancelled." }; @@ -134,33 +157,21 @@ export async function translateCaptionSegments( const batch = segments.slice(i, i + batchSize); let reply = ""; try { - const result = await streamLlm( - { - provider: options.provider, - model: options.model, - apiKey: options.apiKey, - baseUrl: options.baseUrl, - reasoningEffort: options.reasoningEffort, - accountId: options.accountId, - signal: options.signal, - messages: [ - { role: "system", content: SYSTEM_PROMPT }, - { - role: "user", - content: buildUserPrompt(batch, options.targetLanguage, options.sourceLanguage), - }, - ], - }, - { onTextDelta: (delta) => (reply = `${reply}${delta}`) }, + const result = await model.invoke( + [ + new SystemMessage(SYSTEM_PROMPT), + new HumanMessage(buildUserPrompt(batch, options.targetLanguage, options.sourceLanguage)), + ], + { signal: options.signal }, ); - if (!result.success) { + reply = messageContentToText(result.content); + if (!reply) { return { success: false, segments: out, - error: result.error ?? "The model did not return a translation.", + error: "The model did not return a translation.", }; } - reply = result.content ?? reply; } catch (error) { return { success: false, diff --git a/electron/ai-edition/chat-service.ts b/electron/ai-edition/chat-service.ts index 7c290c273..d714258c6 100644 --- a/electron/ai-edition/chat-service.ts +++ b/electron/ai-edition/chat-service.ts @@ -6,6 +6,7 @@ // ChatEventSink. import { randomUUID } from "node:crypto"; +import { HumanMessage, SystemMessage } from "@langchain/core/messages"; import { type AxcutTimelineOperation, applyTimelineOperation, @@ -29,7 +30,6 @@ import { shouldCompact, } from "./chat-compaction"; import type { DocumentService } from "./document-service"; -import { streamLlm } from "./llm-call"; import type { LlmConfigStore } from "./llm-config-store"; import { PROVIDER_DEFINITIONS } from "./provider-registry"; @@ -695,27 +695,25 @@ async function tryCompactSession(opts: { const prompt = buildCompactionPrompt(oldMessages); let summary = ""; try { - const result = await streamLlm( - { - provider, - model, - apiKey, - baseUrl, - reasoningEffort, - accountId, - messages: [ - { role: "system", content: COMPACTION_SYSTEM_PROMPT }, - { role: "user", content: prompt }, - ], - }, - { - onTextDelta: (d) => (summary = `${summary}${d}`), - }, + const { createOpenScreenChatModel, messageContentToText } = await import( + "./deep-agent/chat-model" ); - if (!result.success || !result.content) { + const chatModel = await createOpenScreenChatModel({ + provider, + model, + apiKey, + baseUrl, + reasoningEffort, + accountId, + }); + const result = await chatModel.invoke([ + new SystemMessage(COMPACTION_SYSTEM_PROMPT), + new HumanMessage(prompt), + ]); + summary = messageContentToText(result.content); + if (!summary) { return null; } - summary = result.content; } catch { // ponytail: a failed summarize must not break the chat turn — leave // the session as-is and let the next turn try again. diff --git a/electron/ai-edition/codex-session.test.ts b/electron/ai-edition/codex-session.test.ts deleted file mode 100644 index c8ea2b5a3..000000000 --- a/electron/ai-edition/codex-session.test.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - buildCodexUserAgent, - consumeCodexStream, - parseCodexSSE, - toCodexInput, - toCodexReasoningPayload, -} from "./codex-session"; -import { parseAnthropicEvents } from "./llm-call"; - -// ponytail: helpers for faking SSE ReadableStreams in jsdom. -function sseStream(events: Array>): ReadableStream { - const encoder = new TextEncoder(); - const lines = events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join(""); - return new ReadableStream({ - start(controller) { - controller.enqueue(encoder.encode(lines)); - controller.close(); - }, - }); -} - -describe("parseCodexSSE", () => { - it("yields the parsed JSON for each `data:` chunk", async () => { - const events = [ - { type: "response.created", id: "r1" }, - { type: "response.delta", x: 2 }, - ]; - const out: Array> = []; - for await (const e of parseCodexSSE(sseStream(events))) out.push(e); - expect(out).toEqual(events); - }); - - it("skips `[DONE]` and malformed lines", async () => { - const encoder = new TextEncoder(); - const stream = new ReadableStream({ - start(controller) { - controller.enqueue( - encoder.encode('data: {"type":"a"}\n\ndata: [DONE]\n\ndata: {not json}\n\n'), - ); - controller.close(); - }, - }); - const out: Array> = []; - for await (const e of parseCodexSSE(stream)) out.push(e); - expect(out).toEqual([{ type: "a" }]); - }); -}); - -describe("consumeCodexStream", () => { - it("yields text deltas for response.output_text.delta", async () => { - const stream = sseStream([ - { type: "response.output_text.delta", delta: "Hello" }, - { type: "response.output_text.delta", delta: " world" }, - { type: "response.completed", response: { id: "r1" } }, - ]); - const kinds: Array = []; - let text = ""; - for await (const ev of consumeCodexStream(stream)) { - kinds.push(ev.kind); - if (ev.kind === "text") text += ev.delta; - } - expect(kinds).toContain("text"); - expect(text).toBe("Hello world"); - }); - - it("collects tool-call argument deltas + emits response_id", async () => { - const stream = sseStream([ - { - type: "response.output_item.added", - item: { type: "function_call", call_id: "c1", name: "addTrim" }, - }, - { type: "response.function_call_arguments.delta", call_id: "c1", delta: '{"a":' }, - { type: "response.function_call_arguments.delta", call_id: "c1", delta: "1}" }, - { - type: "response.output_item.done", - item: { type: "function_call", call_id: "c1", name: "addTrim" }, - }, - { type: "response.completed", response: { id: "r42" } }, - ]); - // ponytail: callers accumulate the chunked argument strings themselves, - // same as the OpenAI stream consumption path. The parser only ships - // per-chunk deltas; recomposing the full args is the consumer's job. - const accumulated = new Map(); - let responseId = ""; - let sawAny = false; - for await (const ev of consumeCodexStream(stream)) { - if (ev.kind === "tool") { - sawAny = true; - accumulated.set(ev.delta.id, `${accumulated.get(ev.delta.id) ?? ""}${ev.delta.args}`); - } else if (ev.kind === "response_id") { - responseId = ev.id; - } - } - expect(sawAny).toBe(true); - expect(accumulated.get("c1")).toBe('{"a":1}'); - expect(responseId).toBe("r42"); - }); - - it("throws on response.failed", async () => { - const stream = sseStream([{ type: "response.failed", error: { message: "oops" } }]); - await expect(async () => { - for await (const _ of consumeCodexStream(stream)) { - /* noop */ - } - }).rejects.toThrow(/oops/); - }); -}); - -describe("toCodexInput", () => { - it("converts tool messages and assistant tool-calls", () => { - const input = toCodexInput([ - { role: "system", content: "ignore me" }, - { role: "user", content: "cut it" }, - { - role: "assistant", - content: "", - toolCalls: [{ id: "c1", name: "addTrim", arguments: '{"a":1}' }], - }, - { role: "tool", content: '{"ok":true}', toolCallId: "c1" }, - ]); - expect(input).toEqual([ - { role: "user", content: "cut it" }, - { - role: "assistant", - content: [{ type: "tool_call", name: "addTrim", arguments: '{"a":1}', call_id: "c1" }], - }, - { type: "tool_result", role: "tool", tool_call_id: "c1", content: '{"ok":true}' }, - ]); - }); -}); - -describe("toCodexReasoningPayload", () => { - it("omits reasoning on `none`", () => { - expect(toCodexReasoningPayload("gpt-5", "none")).toEqual({}); - }); - - it("collapses minimal → low", () => { - expect(toCodexReasoningPayload("gpt-5", "minimal")).toEqual({ - reasoning: { effort: "low", summary: "auto" }, - }); - }); - - it("collapses xhigh → high", () => { - expect(toCodexReasoningPayload("gpt-5", "xhigh")).toEqual({ - reasoning: { effort: "high", summary: "auto" }, - }); - }); - - it("passes through other efforts unchanged", () => { - expect(toCodexReasoningPayload("gpt-5", "medium").reasoning).toEqual({ - effort: "medium", - summary: "auto", - }); - }); -}); - -describe("parseAnthropicEvents", () => { - it("preserves the `event:` name alongside the JSON payload", async () => { - const encoder = new TextEncoder(); - const stream = new ReadableStream({ - start(controller) { - controller.enqueue( - encoder.encode( - 'event: message_start\ndata: {"type":"message_start","message":{"id":"m1"}}\n\nevent: content_block_delta\ndata: {"type":"content_block_delta","delta":{"type":"text_delta","text":"hi"}}\n\n', - ), - ); - controller.close(); - }, - }); - const types: string[] = []; - for await (const ev of parseAnthropicEvents(stream)) types.push(ev.type); - expect(types).toContain("message_start"); - expect(types).toContain("content_block_delta"); - }); -}); - -describe("buildCodexUserAgent", () => { - it("starts with the codex_cli_rs originator and a platform triple", () => { - const ua = buildCodexUserAgent(); - expect(ua).toMatch(/^codex_cli_rs\//); - expect(ua).toMatch(/\(win32|linux|darwin /); - }); -}); diff --git a/electron/ai-edition/codex-session.ts b/electron/ai-edition/codex-session.ts deleted file mode 100644 index 4ce143a68..000000000 --- a/electron/ai-edition/codex-session.ts +++ /dev/null @@ -1,462 +0,0 @@ -// Codex (ChatGPT OAuth) account-session helpers and the SSE body/parsers for -// the `/codex/responses` endpoint. Mirrors axcut's -// apps/server/src/llm/provider-runtime/openai-account.ts but adapted for -// OpenScreen's direct-fetch call path (no LangChain). -// -// Auth (beginCodexDeviceAuth / completeCodexDeviceAuth) and the runtime -// bearer exchange live in `llm-provider-auth.ts`; this module handles -// per-session identity (request id, window id, installation id) and the -// streaming response parser. - -import { randomUUID } from "node:crypto"; -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { - beginCodexDeviceAuth, - type CodexDeviceChallenge, - type CodexTokens, - completeCodexDeviceAuth, - extractChatgptAccountId, -} from "./llm-provider-auth"; - -export const CODEX_RESPONSES_PATH = "/codex/responses"; -export const CODEX_MODELS_PATH = "/codex/models"; -export const CODEX_DEFAULT_MODEL = "gpt-5.4"; - -const CODEX_ORIGINATOR = "codex_cli_rs"; -const CODEX_INSTALLATION_ID_FILENAME = "codex_installation_id"; - -export const CODEX_REASONING_EFFORT_OPTIONS = [ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh", -] as const; -export type CodexReasoningEffort = (typeof CODEX_REASONING_EFFORT_OPTIONS)[number]; - -export interface CodexIdentity { - requestId: string; - windowId: string; - installationId: string; - clientMetadata: Record; -} - -export interface CodexSession { - accessToken: string; - refreshToken?: string; - expiresAt?: number; - accountId?: string; - sessionId: string; - identity: CodexIdentity; -} - -export type CodexEvent = Record; - -/** - * Begin + complete the full Codex device flow, returning a session ready for - * call time. Installs the persistent installation id on first run. - */ -export async function beginCodexSession( - userDataPath: string, -): Promise<{ challenge: CodexDeviceChallenge; complete: () => Promise }> { - const identity = await ensureIdentity(userDataPath); - const challenge = await beginCodexDeviceAuth(); - const complete = async (): Promise => { - const tokens = await completeCodexDeviceAuth({ - intervalMs: challenge.intervalMs, - expiresAt: challenge.expiresAt, - deviceAuthId: challenge.deviceAuthId, - userCode: challenge.userCode, - }); - return { - accessToken: tokens.accessToken, - refreshToken: tokens.refreshToken, - expiresAt: tokens.expiresAt, - accountId: tokens.accountId, - sessionId: randomUUID(), - identity, - }; - }; - return { challenge, complete }; -} - -/** - * Load (or generate) the persistent Codex installation id, plus a fresh - * per-call request id and a session-scoped window id. - */ -export async function ensureIdentity(userDataPath: string): Promise { - const installationId = await ensureInstallationId(userDataPath); - return { - installationId, - windowId: randomUUID(), - requestId: randomUUID(), - clientMetadata: { - name: "OpenScreen", - version: "1.6.0", - }, - }; -} - -async function ensureInstallationId(userDataPath: string): Promise { - const dir = path.join(userDataPath, "codex"); - await fs.mkdir(dir, { recursive: true }); - const file = path.join(dir, CODEX_INSTALLATION_ID_FILENAME); - try { - const existing = (await fs.readFile(file, "utf8")).trim(); - if (existing) return existing; - } catch { - // first run, mint a new one - } - const id = randomUUID(); - await fs.writeFile(file, id, { encoding: "utf8", mode: 0o600 }); - return id; -} - -/** - * Refresh the Codex tokens. Returns the new tokens + expiry. The caller is - * responsible for writing them back through `LlmConfigStore.setCredential`. - */ -export async function refreshCodexSession( - refreshToken: string, -): Promise> { - const CODEX_ISSUER = "https://auth.openai.com"; - const CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"; - const body = new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: refreshToken, - client_id: CODEX_CLIENT_ID, - }); - const res = await fetch(`${CODEX_ISSUER}/oauth/token`, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: body.toString(), - }); - if (!res.ok) { - const detail = await res.text(); - throw new Error( - detail ? `Codex refresh failed: ${detail}` : `Codex refresh failed: HTTP ${res.status}`, - ); - } - const json = (await res.json()) as { - access_token?: string; - refresh_token?: string; - expires_in?: number; - }; - if (!json.access_token) throw new Error("Codex refresh returned no access_token."); - return { - accessToken: json.access_token, - refreshToken: json.refresh_token, - expiresAt: json.expires_in ? Date.now() + json.expires_in * 1000 : undefined, - accountId: extractChatgptAccountId(json.access_token), - }; -} - -// --- Reason-effort wire-up ------------------------------------------------ - -/** - * Build the Codex `reasoning` payload. Mirrors `toCodexReasoningPayload` in - * axcut: `none` → omitted; `minimal → low`; `xhigh → high`; gpt-5+ models - * get `summary: 'auto'` so the server returns a small reasoning summary. - */ -export function toCodexReasoningPayload( - modelId: string, - effort: CodexReasoningEffort, -): Record { - if (effort === "none") return {}; - const collapsed: CodexReasoningEffort = - effort === "minimal" ? "low" : effort === "xhigh" ? "high" : effort; - const isGpt5 = /^gpt-5/i.test(modelId); - return { - reasoning: { - effort: collapsed, - ...(isGpt5 ? { summary: "auto" } : {}), - }, - }; -} - -// --- Identity / user-agent ------------------------------------------------ - -export function buildCodexUserAgent(): string { - const term = detectTerminal(); - return `${CODEX_ORIGINATOR}/1.6.0 (${os.platform()} ${os.release()}; ${os.arch()}) ${term}`; -} - -function detectTerminal(): string { - const termProgram = process.env.TERM_PROGRAM; - const termProgramVersion = process.env.TERM_PROGRAM_VERSION; - if (termProgram) return termProgramVersion ? `${termProgram}/${termProgramVersion}` : termProgram; - if (process.env.WT_SESSION) return "WindowsTerminal"; - if (process.env.WEZTERM_VERSION) return `WezTerm/${process.env.WEZTERM_VERSION}`; - if (process.env.TERM) return process.env.TERM; - return "unknown"; -} - -// --- Body shaping --------------------------------------------------------- - -/** - * Convert OpenScreen `ChatMessage` history into the Codex `input` array. - * System prompt becomes `instructions` (set separately by the caller). - */ -export function toCodexInput( - messages: Array<{ - role: string; - content: string; - toolCalls?: Array<{ id: string; name: string; arguments: string }>; - toolCallId?: string; - }>, -): Array> { - const input: Array> = []; - for (const m of messages) { - // System becomes `instructions` on the body, not part of `input`. - if (m.role === "system") continue; - if (m.role === "tool") { - input.push({ - type: "tool_result", - role: "tool", - tool_call_id: m.toolCallId ?? "", - content: m.content, - }); - continue; - } - if (m.role === "assistant" && m.toolCalls && m.toolCalls.length > 0) { - input.push({ - role: "assistant", - content: m.toolCalls.map((call) => ({ - type: "tool_call", - name: call.name, - arguments: call.arguments, - call_id: call.id, - })), - }); - continue; - } - input.push({ role: m.role, content: m.content }); - } - return input; -} - -/** - * Convert OpenScreen tool specs (OpenAI `function` shape) into the Codex - * `tools` array. Strict-by-default to match the server's expectations. - */ -export function toCodexTools( - tools: Array<{ name: string; description: string; parameters: Record }>, -): Array> { - return tools.map((t) => ({ - type: "function", - name: t.name, - description: t.description, - parameters: t.parameters, - strict: true, - })); -} - -// --- SSE ----------------------------------------------------------------- - -/** - * Parse a Codex/ChatGPT SSE stream into a sequence of decoded JSON events. - * Accepts an SSE body in `data: …\n\n` chunks. Skips malformed payloads, - * terminator `[DONE]`, and lines without a `data:` prefix. - */ -export async function* parseCodexSSE(body: ReadableStream): AsyncGenerator { - const reader = body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - while (true) { - const { done, value } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - let idx = buffer.indexOf("\n\n"); - while (idx !== -1) { - const chunk = buffer.slice(0, idx); - buffer = buffer.slice(idx + 2); - const dataLines = chunk - .split("\n") - .filter((l) => l.startsWith("data:")) - .map((l) => l.slice(5).trim()); - if (dataLines.length > 0) { - const data = dataLines.join("\n").trim(); - if (data && data !== "[DONE]") { - try { - yield JSON.parse(data) as CodexEvent; - } catch { - /* skip malformed */ - } - } - } - idx = buffer.indexOf("\n\n"); - } - } -} - -export interface CodexToolCallDelta { - id: string; - name: string; - /** JSON arguments; may be partial until `response.output_item.done`. */ - args: string; -} - -/** - * Walk a Codex SSE event stream and yield: - * - `{ kind: "text", delta }` for `response.output_text.delta` - * - `{ kind: "tool", delta }` for tool-call argument deltas - * - `{ kind: "done" }` on `response.completed` - * - throws on `response.failed` / `error` - * - * Tool-call ids are de-duplicated across `output_item.added` and - * `function_call_arguments.delta` events using the `call_id` ↔ `item_id` - * aliases that Codex sends. - */ -export async function* consumeCodexStream( - body: ReadableStream, -): AsyncGenerator< - | { kind: "text"; delta: string } - | { kind: "tool"; delta: CodexToolCallDelta } - | { kind: "response_id"; id: string } - | { kind: "done" } -> { - const aliases = new Map(); - const streamedArgs = new Map(); - const toolCalls = new Map(); - - for await (const event of parseCodexSSE(body)) { - const type = typeof event.type === "string" ? event.type : undefined; - if (!type) continue; - - switch (type) { - case "response.output_text.delta": { - if (typeof event.delta === "string") yield { kind: "text", delta: event.delta }; - break; - } - case "response.output_item.added": - case "response.output_item.done": { - const item = readItem(event); - if (!item) break; - const tc = extractToolCall(item, toolCalls.size); - if (!tc) break; - toolCalls.set(tc.id, tc); - recordAlias(item, tc.id, aliases); - if (type === "response.output_item.done") { - const streamed = streamedArgs.get(tc.id) ?? ""; - const missing = unstreamedArgs(tc.args, streamed); - if (missing) { - streamedArgs.set(tc.id, `${streamed}${missing}`); - tc.args = `${toolCalls.get(tc.id)?.args ?? ""}${missing}`; - yield { kind: "tool", delta: { id: tc.id, name: tc.name, args: missing } }; - } - } else { - yield { kind: "tool", delta: { id: tc.id, name: tc.name, args: "" } }; - } - break; - } - case "response.function_call_arguments.delta": { - const id = resolveToolCallEventId(event, aliases); - if (!id || typeof event.delta !== "string") break; - const tc = toolCalls.get(id); - if (!tc) break; - tc.args = `${tc.args}${event.delta}`; - streamedArgs.set(id, `${streamedArgs.get(id) ?? ""}${event.delta}`); - yield { kind: "tool", delta: { id, name: tc.name, args: event.delta } }; - break; - } - case "response.function_call_arguments.done": { - const id = resolveToolCallEventId(event, aliases); - if (!id) break; - const tc = toolCalls.get(id); - if (!tc) break; - const finalArgs = readString(event.arguments) ?? tc.args; - const streamed = streamedArgs.get(id) ?? ""; - const missing = unstreamedArgs(finalArgs, streamed); - if (missing) { - tc.args = `${tc.args}${missing}`; - yield { kind: "tool", delta: { id, name: tc.name, args: missing } }; - } - break; - } - case "response.completed": { - const resp = (event.response ?? {}) as Record; - const id = readString(resp.id); - if (id) yield { kind: "response_id", id }; - yield { kind: "done" }; - return; - } - case "response.failed": - throw new Error(extractSseError(event) ?? "Codex response failed."); - case "error": - throw new Error(extractSseError(event) ?? "Codex stream error."); - } - } -} - -// --- Tiny helpers -------------------------------------------------------- - -function readItem(event: CodexEvent): Record | undefined { - const item = event.item ?? event.output_item; - return item && typeof item === "object" ? (item as Record) : undefined; -} - -function readString(value: unknown): string | undefined { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; -} - -function extractToolCall( - item: Record | undefined, - index: number, -): CodexToolCallDelta | undefined { - if (!item || item.type !== "function_call") return undefined; - const name = readString(item.name); - if (!name) return undefined; - const id = readString(item.call_id) || readString(item.id) || `codex-tool-${index + 1}`; - const args = - typeof item.arguments === "string" ? item.arguments : JSON.stringify(item.arguments ?? {}); - return { id, name, args }; -} - -function recordAlias( - item: Record, - toolCallId: string, - aliases: Map, -): void { - for (const candidate of [item.call_id, item.id]) { - const alias = readString(candidate); - if (alias) aliases.set(alias, toolCallId); - } -} - -function resolveToolCallEventId( - event: CodexEvent, - aliases: Map, -): string | undefined { - const callId = readString(event.call_id); - if (callId) return aliases.get(callId) ?? callId; - const itemId = readString(event.item_id); - return itemId ? (aliases.get(itemId) ?? itemId) : undefined; -} - -function unstreamedArgs(finalArgs: string, streamed: string): string { - if (!finalArgs) return ""; - if (!streamed) return finalArgs; - if (finalArgs.startsWith(streamed)) return finalArgs.slice(streamed.length); - return ""; -} - -function extractSseError(event: CodexEvent): string | undefined { - const direct = readString(event.message) || readString(event.detail) || readString(event.code); - if (direct) return direct; - const nested = (event as { error?: unknown }).error; - if (typeof nested === "string") return nested.trim() || undefined; - if (nested && typeof nested === "object") { - const rec = nested as Record; - const m = readString(rec.message) || readString(rec.detail) || readString(rec.code); - if (m) return m; - } - try { - const s = JSON.stringify(event); - return s && s !== "{}" ? s : undefined; - } catch { - return undefined; - } -} diff --git a/electron/ai-edition/deep-agent/agent-provider-capabilities.test.ts b/electron/ai-edition/deep-agent/agent-provider-capabilities.test.ts new file mode 100644 index 000000000..5d5090595 --- /dev/null +++ b/electron/ai-edition/deep-agent/agent-provider-capabilities.test.ts @@ -0,0 +1,96 @@ +// Covers the reasoning-effort matrix that survived the llm-call.ts deletion. +// The deleted top-level agent-provider-capabilities.ts had the only tests for +// this logic; these keep the per-provider wiring pinned. + +import { describe, expect, it } from "vitest"; +import { + buildLangChainReasoningOptions, + getReasoningCapability, + normalizeReasoningEffortForCapability, +} from "./agent-provider-capabilities"; + +describe("getReasoningCapability", () => { + it("gives openai-oauth the full 6-step Codex effort range", () => { + const cap = getReasoningCapability("openai-oauth", "gpt-5"); + expect(cap.supported).toBe(true); + expect(cap.strategy).toBe("custom-openai-account"); + expect(cap.efforts).toEqual(["none", "minimal", "low", "medium", "high", "xhigh"]); + }); + + it("turns reasoning off for non-reasoning OpenAI models", () => { + expect(getReasoningCapability("openai", "gpt-4o").supported).toBe(false); + expect(getReasoningCapability("openai", "o3-mini").supported).toBe(true); + expect(getReasoningCapability("openai", "gpt-5-mini").supported).toBe(true); + }); + + it("uses the thinking strategy for Anthropic 4.x and not for 3.x", () => { + expect(getReasoningCapability("anthropic", "claude-sonnet-4-5").strategy).toBe( + "anthropic-thinking", + ); + expect(getReasoningCapability("anthropic", "claude-3-haiku-20240307").supported).toBe(false); + }); + + it("treats MiniMax thinking as binary, and Gemini 2.5+ as thinking-capable", () => { + expect(getReasoningCapability("minimax", "MiniMax-M3").strategy).toBe("minimax-thinking"); + expect(getReasoningCapability("google", "gemini-2.5-pro").supported).toBe(true); + expect(getReasoningCapability("google", "gemini-1.5-pro").supported).toBe(false); + }); + + it("only supports OpenRouter reasoning for reasoning-capable slugs", () => { + expect(getReasoningCapability("openrouter", "anthropic/claude-3.5-sonnet").supported).toBe( + false, + ); + expect(getReasoningCapability("openrouter", "openai/gpt-5").supported).toBe(true); + }); + + it("returns unsupported for unknown providers", () => { + expect(getReasoningCapability("nope", "x").supported).toBe(false); + expect(getReasoningCapability("").supported).toBe(false); + }); +}); + +describe("normalizeReasoningEffortForCapability", () => { + it("collapses efforts the provider does not expose", () => { + const openai = getReasoningCapability("openai", "o4-mini"); + expect(normalizeReasoningEffortForCapability("minimal", openai)).toBe("low"); + expect(normalizeReasoningEffortForCapability("xhigh", openai)).toBe("high"); + }); + + it("falls back to the default effort when none is selected", () => { + const openai = getReasoningCapability("openai", "o4-mini"); + expect(normalizeReasoningEffortForCapability(undefined, openai)).toBe("medium"); + }); +}); + +describe("buildLangChainReasoningOptions", () => { + it("wires OpenAI effort through the Responses API", () => { + expect(buildLangChainReasoningOptions("openai", "o4-mini", "high")).toEqual({ + reasoning: { effort: "high" }, + useResponsesApi: true, + }); + }); + + it("wires OpenRouter reasoning through modelKwargs", () => { + expect(buildLangChainReasoningOptions("openrouter", "openai/gpt-5", "medium")).toEqual({ + modelKwargs: { + reasoning: { effort: "medium" }, + include_reasoning: true, + }, + }); + }); + + it("wires Gemini thinking through thinkingConfig", () => { + const opts = buildLangChainReasoningOptions("google", "gemini-2.5-pro", "high"); + expect(opts.thinkingConfig).toMatchObject({ includeThoughts: true, thinkingLevel: "HIGH" }); + }); + + it("emits an Anthropic thinking block", () => { + const opts = buildLangChainReasoningOptions("anthropic", "claude-sonnet-4-5", "xhigh"); + expect(opts.thinking).toBeDefined(); + }); + + it("returns nothing when the effort is none or the provider has no reasoning", () => { + expect(buildLangChainReasoningOptions("openai", "o4-mini", "none")).toEqual({}); + expect(buildLangChainReasoningOptions("mistral", "mistral-large-latest", "high")).toEqual({}); + }); +}); diff --git a/electron/ai-edition/deep-agent/agent-provider-capabilities.ts b/electron/ai-edition/deep-agent/agent-provider-capabilities.ts index 21467db95..b1e419fe8 100644 --- a/electron/ai-edition/deep-agent/agent-provider-capabilities.ts +++ b/electron/ai-edition/deep-agent/agent-provider-capabilities.ts @@ -54,6 +54,8 @@ const OPENROUTER_REASONING_EFFORTS: readonly AgentReasoningEffort[] = [ ]; const GOOGLE_REASONING_EFFORTS: readonly AgentReasoningEffort[] = ["none", "low", "medium", "high"]; +// Every branch here compares against canonical registry ids — +// createOpenScreenChatModel normalizes the provider before calling in. export function getReasoningCapability(provider: string, model?: string): ReasoningCapability { const def: ProviderDefinition | undefined = getProviderDefinition(provider); const normalizedModel = normalizeModelName(model); @@ -215,16 +217,18 @@ function isGeminiThinkingModel(model: string): boolean { } function isOpenRouterReasoningModel(model: string): boolean { - if (isOpenAIReasoningModel(model)) return true; - if ( - model.startsWith("anthropic/") && - isAnthropicReasoningModel(model.slice("anthropic/".length)) - ) { - return true; - } + // OpenRouter slugs are `vendor/model`, and both vendor matchers are + // anchored at the start — so the prefix has to come off first or + // `openai/gpt-5` never matches. + if (isOpenAIReasoningModel(stripVendorPrefix(model, "openai/"))) return true; + if (isAnthropicReasoningModel(stripVendorPrefix(model, "anthropic/"))) return true; return /deepseek-r1/i.test(model) || /qwen.*thinking/i.test(model) || /grok-4/i.test(model); } +function stripVendorPrefix(model: string, prefix: string): string { + return model.startsWith(prefix) ? model.slice(prefix.length) : model; +} + function toOpenAIReasoningEffort(effort: AgentReasoningEffort): "low" | "medium" | "high" { if (effort === "high" || effort === "xhigh") return "high"; if (effort === "medium") return "medium"; diff --git a/electron/ai-edition/deep-agent/chat-model.test.ts b/electron/ai-edition/deep-agent/chat-model.test.ts new file mode 100644 index 000000000..0efcaf20e --- /dev/null +++ b/electron/ai-edition/deep-agent/chat-model.test.ts @@ -0,0 +1,98 @@ +// The Codex account header and the provider-alias normalization are both +// invisible to tsc (an optional field that nothing reads still typechecks), +// so they get a runtime check. + +import { describe, expect, it, vi } from "vitest"; +import { createOpenScreenChatModel, messageContentToText } from "./chat-model"; + +vi.mock("../llm-provider-auth", () => ({ + exchangeGithubCopilotRuntimeToken: vi.fn(async () => ({ + token: "runtime-token", + expiresAt: Date.now() + 60_000, + baseUrl: "https://api.business.githubcopilot.com", + })), + GITHUB_COPILOT_USER_AGENT: "GitHubCopilotChat/0.26.7", + GITHUB_COPILOT_EDITOR_VERSION: "vscode/1.96.2", + GITHUB_COPILOT_PLUGIN_VERSION: "copilot-chat/0.26.7", +})); + +/** ChatOpenAI keeps the `configuration` bag it was constructed with on + * `clientConfig`; that is where the base URL and default headers land. */ +function clientConfig(model: unknown): Record { + return (model as { clientConfig?: Record }).clientConfig ?? {}; +} + +describe("createOpenScreenChatModel — openai-oauth (Codex)", () => { + it("sends chatgpt-account-id, which the gateway requires for an OAuth token", async () => { + const model = await createOpenScreenChatModel({ + provider: "openai-oauth", + model: "gpt-5", + apiKey: "oauth-access-token", + accountId: "acct_123", + }); + expect(clientConfig(model).defaultHeaders).toMatchObject({ + "chatgpt-account-id": "acct_123", + }); + }); + + it("omits the header entirely when there is no account id", async () => { + const model = await createOpenScreenChatModel({ + provider: "openai-oauth", + model: "gpt-5", + apiKey: "oauth-access-token", + }); + expect(clientConfig(model).defaultHeaders).toBeUndefined(); + }); +}); + +describe("createOpenScreenChatModel — copilot-proxy", () => { + it("uses the exchanged runtime token and the base URL it names", async () => { + const model = await createOpenScreenChatModel({ + provider: "copilot-proxy", + model: "gpt-4.1", + apiKey: "github-pat", + }); + expect(clientConfig(model).baseURL).toBe("https://api.business.githubcopilot.com"); + expect(clientConfig(model).defaultHeaders).toMatchObject({ + "Editor-Version": "vscode/1.96.2", + "Openai-Intent": "copilot-gpt-chat-completions", + }); + }); +}); + +describe("createOpenScreenChatModel — provider aliases", () => { + it("routes the `claude` alias to the Anthropic SDK, not the OpenAI fallback", async () => { + const model = await createOpenScreenChatModel({ + provider: "claude", + model: "claude-sonnet-4-5", + apiKey: "sk-ant-test", + }); + expect(model.constructor.name).toBe("ChatAnthropic"); + }); + + it("routes the `gemini` alias to the Google OpenAI-compat base URL", async () => { + const model = await createOpenScreenChatModel({ + provider: "gemini", + model: "gemini-2.5-pro", + apiKey: "test-key", + }); + expect(clientConfig(model).baseURL).toBe( + "https://generativelanguage.googleapis.com/v1beta/openai", + ); + }); +}); + +describe("messageContentToText", () => { + it("passes a plain string through", () => { + expect(messageContentToText("hello")).toBe("hello"); + }); + + it("concatenates the text parts of a content array and skips non-text", () => { + expect(messageContentToText(["a", { type: "text", text: "b" }, { type: "image" }])).toBe("ab"); + }); + + it("returns an empty string for anything else", () => { + expect(messageContentToText(null)).toBe(""); + expect(messageContentToText(42)).toBe(""); + }); +}); diff --git a/electron/ai-edition/deep-agent/chat-model.ts b/electron/ai-edition/deep-agent/chat-model.ts index eb900e8a3..3d9ddf7a4 100644 --- a/electron/ai-edition/deep-agent/chat-model.ts +++ b/electron/ai-edition/deep-agent/chat-model.ts @@ -8,6 +8,13 @@ import { ChatAnthropic } from "@langchain/anthropic"; import type { BaseChatModel } from "@langchain/core/language_models/chat_models"; import { ChatMistralAI } from "@langchain/mistralai"; import { ChatOpenAI } from "@langchain/openai"; +import { + exchangeGithubCopilotRuntimeToken, + GITHUB_COPILOT_EDITOR_VERSION, + GITHUB_COPILOT_PLUGIN_VERSION, + GITHUB_COPILOT_USER_AGENT, +} from "../llm-provider-auth"; +import { normalizeProviderId } from "../provider-registry"; import { buildLangChainReasoningOptions, shouldDisableModelStreamingForToolCalling, @@ -31,9 +38,39 @@ export function resolveOpenAIChatApiKey(provider: string, apiKey?: string): stri return provider === "openai-compatible" ? OPENAI_COMPATIBLE_NO_AUTH_API_KEY : undefined; } +/** Flattens LangChain MessageContent (a string, or an array of text and + * non-text parts) down to plain text. Lives here rather than in + * deep-agent/service.ts so the one-shot prompt→text callers can reach it + * without dragging the agent tool graph in behind it. */ +export function messageContentToText(content: unknown): string { + if (typeof content === "string") return content; + if (Array.isArray(content)) { + let total = ""; + for (const part of content) { + if (typeof part === "string") { + total += part; + } else if (part && typeof part === "object") { + const text = (part as { text?: unknown }).text; + if (typeof text === "string") total += text; + } + } + return total; + } + return ""; +} + export async function createOpenScreenChatModel( - config: OpenScreenChatModelConfig, + input: OpenScreenChatModelConfig, ): Promise { + // Canonicalise once, here: stored configs can still carry historical + // aliases (`claude`, `gemini`, `anthropic-proxy`), and every provider + // comparison below — and in agent-provider-capabilities — is an exact + // match against a registry id. + const config: OpenScreenChatModelConfig = { + ...input, + provider: normalizeProviderId(input.provider) ?? input.provider, + }; + const reasoningOptions = buildLangChainReasoningOptions( config.provider, config.model, @@ -108,22 +145,38 @@ async function createLocalProviderChatModel( // ChatCodexOAuth class for this; for v1 we fall back to a generic // ChatOpenAI with the chatgpt.com/backend-api base URL. Streaming // + tool calls work on the gateway, so this is enough for the chat. + // `chatgpt-account-id` is not optional — the gateway rejects an + // OAuth token without the account it was minted for. The rest of + // the Codex header set (originator, x-codex-window-id, the + // OpenAI-Beta Responses opt-in) belongs to the Responses dialect + // this path does not speak; add it with a real ChatCodexOAuth. return new ChatOpenAI({ apiKey: config.apiKey, model: config.model, configuration: { baseURL: config.baseUrl || "https://chatgpt.com/backend-api", + ...(config.accountId + ? { defaultHeaders: { "chatgpt-account-id": config.accountId } } + : {}), }, }); case "copilot-proxy": { - // ponytail: GitHub Copilot runs through its own runtime-token swap; - // for v1 we just hit the public Copilot base URL with the PAT. The - // runtime-token refresh from axcut can land as a follow-up. + // Copilot does not accept the PAT directly: it is exchanged for a + // short-lived runtime token, which also names the base URL to use. + // The editor-identifying headers are part of the contract — the + // endpoint rejects requests without them. + const runtime = await exchangeGithubCopilotRuntimeToken(config.apiKey ?? ""); return new ChatOpenAI({ - apiKey: config.apiKey, + apiKey: runtime.token, model: config.model, configuration: { - baseURL: config.baseUrl || "https://api.individual.githubcopilot.com", + baseURL: config.baseUrl || runtime.baseUrl || "https://api.individual.githubcopilot.com", + defaultHeaders: { + "User-Agent": GITHUB_COPILOT_USER_AGENT, + "Editor-Version": GITHUB_COPILOT_EDITOR_VERSION, + "Editor-Plugin-Version": GITHUB_COPILOT_PLUGIN_VERSION, + "Openai-Intent": "copilot-gpt-chat-completions", + }, }, }); } diff --git a/electron/ai-edition/deep-agent/service.ts b/electron/ai-edition/deep-agent/service.ts index b2af0a5b6..c8db1016c 100644 --- a/electron/ai-edition/deep-agent/service.ts +++ b/electron/ai-edition/deep-agent/service.ts @@ -28,7 +28,11 @@ import { setTrimArgs, setZoomArgs, } from "../agent-tools"; -import { createOpenScreenChatModel, type OpenScreenChatModelConfig } from "./chat-model"; +import { + createOpenScreenChatModel, + messageContentToText, + type OpenScreenChatModelConfig, +} from "./chat-model"; export interface OpenScreenAgentSink { text: (delta: string) => void; @@ -229,7 +233,7 @@ export async function invokeOpenScreenAgent(args: InvokeArgs): Promise | undefined; if (chunk) chatModelChunks.push(chunk); const content = chunk?.content; - const delta = extractDelta(content); + const delta = messageContentToText(content); if (delta) { sink.text(delta); finalText += delta; @@ -253,7 +257,8 @@ export async function invokeOpenScreenAgent(args: InvokeArgs): Promise | undefined): string | undefined { if (!data) return undefined; const error = data.error; diff --git a/electron/ai-edition/llm-call.ts b/electron/ai-edition/llm-call.ts deleted file mode 100644 index 459487d37..000000000 --- a/electron/ai-edition/llm-call.ts +++ /dev/null @@ -1,736 +0,0 @@ -// Real LLM call via fetch — no LangChain dependency. Supports the -// OpenAI-compatible `/chat/completions` endpoint (OpenAI, Mistral, -// OpenRouter, openai-compatible, Gemini via OpenAI-compat), Anthropic's -// `/v1/messages`, the Codex (ChatGPT OAuth) Responses path, and GitHub -// Copilot's runtime-token chat path. -// -// Reasoning-effort transport is per-provider (see -// ./agent-provider-capabilities). Streaming uses SSE on every provider: -// - OpenAI-compat: parse `data: {…}` deltas; `stream: true` in the body -// - Anthropic: `message_start`/`content_block_*`/`message_delta`/`message_stop` -// - Codex: bespoke chatgpt.com dialect, parsed via `consumeCodexStream` -// -// The non-streaming `callLlm` runs `streamLlm` under the hood and discards -// the deltas. Wire-callers should prefer `streamLlm` for live UX. - -import { getReasoningCallOptions } from "./agent-provider-capabilities"; -import { - buildCodexUserAgent, - CODEX_RESPONSES_PATH, - type CodexToolCallDelta, - consumeCodexStream, - toCodexInput, - toCodexReasoningPayload, - toCodexTools, -} from "./codex-session"; -import { exchangeGithubCopilotRuntimeToken, OPENAI_ACCOUNT_BASE_URL } from "./llm-provider-auth"; -import { - getProviderDefinition, - normalizeProviderId, - PROVIDER_DEFINITIONS, - type ProviderDefinition, -} from "./provider-registry"; - -export interface LlmToolSpec { - name: string; - description: string; - parameters: Record; -} - -export interface LlmToolCall { - id: string; - name: string; - /** Raw JSON string of the arguments, exactly as the provider returned it. */ - arguments: string; -} - -export type ChatMessage = - | { role: "system" | "user"; content: string } - | { role: "assistant"; content: string; toolCalls?: LlmToolCall[] } - | { role: "tool"; content: string; toolCallId: string }; - -export interface CallLlmOptions { - provider: string; - /** Provider model id (e.g. `gpt-4o`). Empty string falls back to the - * provider's `defaultModel`. */ - model: string; - /** Bearer credential string (env var, API key, OAuth access token, or - * GitHub PAT for Copilot). */ - apiKey: string; - baseUrl?: string; - reasoningEffort?: string; - messages: ChatMessage[]; - tools?: LlmToolSpec[]; - /** OAuth account id (Codex only). When present the call sets the - * `chatgpt-account-id` header required by chatgpt.com/backend-api. */ - accountId?: string; - /** Codex session id (chatgpt.com/backend-api requires a stable per-session - * window id). Mints a fresh uuid if not provided. */ - sessionId?: string; - /** Persistent installation id (Codex only). */ - installationId?: string; - /** Aborts the in-flight stream. */ - signal?: AbortSignal; -} - -export interface CallLlmResult { - success: boolean; - content?: string; - toolCalls?: LlmToolCall[]; - error?: string; -} - -export interface LlmStreamCallbacks { - onTextDelta?: (delta: string) => void; - onToolCall?: (call: LlmToolCall) => void; - /** Called once with the response id (Codex / Responses API). */ - onResponseId?: (id: string) => void; -} - -interface OpenAiToolCall { - id?: string; - type?: string; - function?: { name?: string; arguments?: string }; - /** Stream-mode delta items carry an `index` and partial fields. */ - index?: number; -} - -function resolveProvider(rawId: string): ProviderDefinition | undefined { - const id = normalizeProviderId(rawId) ?? rawId; - return getProviderDefinition(id); -} - -function toOpenAiMessage(message: ChatMessage): Record { - if (message.role === "tool") { - return { role: "tool", tool_call_id: message.toolCallId, content: message.content }; - } - if (message.role === "assistant" && message.toolCalls?.length) { - return { - role: "assistant", - content: message.content || null, - tool_calls: message.toolCalls.map((call) => ({ - id: call.id, - type: "function", - function: { name: call.name, arguments: call.arguments }, - })), - }; - } - return { role: message.role, content: message.content }; -} - -function defaultBaseUrl(providerId: string): string | undefined { - return PROVIDER_DEFINITIONS.find((p) => p.id === providerId)?.baseUrl; -} - -function isOauth(providerId: string): boolean { - return providerId === "openai-oauth"; -} - -function isPat(providerId: string): boolean { - return providerId === "copilot-proxy"; -} - -// ─── Public API ───────────────────────────────────────────────────────── - -export async function callLlm(opts: CallLlmOptions): Promise { - let text = ""; - const toolCalls: LlmToolCall[] = []; - const cb: LlmStreamCallbacks = { - onTextDelta: (d) => (text = `${text}${d}`), - onToolCall: (call) => toolCalls.push(call), - }; - const result = await streamLlm(opts, cb); - if (!result.success) return result; - return { - success: true, - content: text, - toolCalls: toolCalls.length ? toolCalls : undefined, - }; -} - -/** - * Streaming entrypoint. `callbacks.onTextDelta` fires for every text delta; - * `callbacks.onToolCall` fires once per complete tool call. Returns a - * `CallLlmResult` indicating overall success/failure and any tool calls - * observed. - */ -export async function streamLlm( - opts: CallLlmOptions, - callbacks: LlmStreamCallbacks = {}, -): Promise { - const def = resolveProvider(opts.provider); - if (!def) { - return { success: false, error: `Unknown provider: ${opts.provider}` }; - } - if (!opts.apiKey && def.authKind === "api-key") { - return { success: false, error: `Missing API key for ${def.label}` }; - } - if (def.authKind === "oauth-device") { - if (!isOauth(def.id)) { - return { success: false, error: `Provider "${def.label}" uses OAuth — not implemented.` }; - } - return streamCodex(opts, callbacks); - } - if (def.authKind === "pat") { - if (!isPat(def.id)) { - return { - success: false, - error: `Provider "${def.label}" uses a personal access token — not implemented.`, - }; - } - return streamCopilot(opts, callbacks); - } - if (def.wireProtocol === "anthropic") return streamAnthropic(opts, callbacks); - return streamOpenAiCompatible(opts, callbacks); -} - -// ─── OpenAI-compatible ────────────────────────────────────────────────── - -async function streamOpenAiCompatible( - opts: CallLlmOptions, - cb: LlmStreamCallbacks, -): Promise { - const def = getProviderDefinition(opts.provider); - const baseUrl = (opts.baseUrl || def?.baseUrl || defaultBaseUrl(opts.provider) || "").replace( - /\/+$/, - "", - ); - const url = `${baseUrl}/chat/completions`; - - const body: Record = { - model: opts.model || def?.defaultModel, - messages: opts.messages.map(toOpenAiMessage), - stream: true, - stream_options: { include_usage: false }, - }; - if (opts.tools?.length) { - body.tools = opts.tools.map((tool) => ({ - type: "function", - function: { - name: tool.name, - description: tool.description, - parameters: tool.parameters, - }, - })); - } - const reasoning = getReasoningCallOptions( - opts.provider, - opts.model, - opts.reasoningEffort as never, - ); - if (reasoning.extraBody) Object.assign(body, reasoning.extraBody); - - const res = await fetch(url, { - method: "POST", - headers: { "Content-Type": "application/json", Authorization: `Bearer ${opts.apiKey}` }, - body: JSON.stringify(body), - signal: opts.signal, - }); - if (!res.ok || !res.body) { - // ponytail: include a sample of the upstream body so callers can see - // whether MiniMax (or any other anthropic-shaped provider) is - // returning a non-streaming error JSON even when stream:true was sent. - let bodySample = ""; - if (res.body) { - try { - bodySample = (await new Response(res.body).text()).slice(0, 1024); - } catch { - /* ignore */ - } - } - const suffix = bodySample ? ` body=${bodySample}` : ""; - return { success: false, error: `Upstream ${res.status} ${res.statusText}${suffix}` }; - } - - const toolCalls: OpenAiToolCall[] = []; - let text = ""; - for await (const chunk of parseSse(res.body)) { - const choice = (chunk.choices?.[0] ?? {}) as Record; - const delta = (choice.delta ?? {}) as Record; - if (typeof delta.content === "string" && delta.content.length > 0) { - text += delta.content; - cb.onTextDelta?.(delta.content); - } - const tcDelta = (delta.tool_calls ?? []) as OpenAiToolCall[]; - for (const d of tcDelta) { - accumulateOpenAiToolCall(toolCalls, d); - } - } - const finalCalls: LlmToolCall[] = toolCalls - .filter((t) => t.function?.name) - .map((t, i) => ({ - id: t.id ?? `call_${i}`, - name: t.function?.name ?? "", - arguments: t.function?.arguments ?? "{}", - })); - for (const c of finalCalls) cb.onToolCall?.(c); - if (!text && finalCalls.length === 0) { - return { success: false, error: "Empty response from model." }; - } - return { - success: true, - content: text, - toolCalls: finalCalls.length ? finalCalls : undefined, - }; -} - -function accumulateOpenAiToolCall(acc: OpenAiToolCall[], incoming: OpenAiToolCall): void { - const idx = typeof incoming.index === "number" ? incoming.index : acc.length; - while (acc.length <= idx) acc.push({}); - const cur = acc[idx]!; - if (incoming.id) cur.id = incoming.id; - if (incoming.type) cur.type = incoming.type; - if (incoming.function?.name) { - cur.function = { ...(cur.function ?? {}), name: incoming.function.name }; - } - if (typeof incoming.function?.arguments === "string") { - const prev = cur.function?.arguments ?? ""; - cur.function = { ...(cur.function ?? {}), arguments: `${prev}${incoming.function.arguments}` }; - } -} - -// ─── Anthropic ────────────────────────────────────────────────────────── - -async function streamAnthropic( - opts: CallLlmOptions, - cb: LlmStreamCallbacks, -): Promise { - const def = getProviderDefinition(opts.provider) ?? getProviderDefinition("anthropic"); - const baseUrl = (opts.baseUrl || def?.baseUrl || "https://api.anthropic.com").replace(/\/+$/, ""); - const systemMessage = opts.messages.find((m) => m.role === "system")?.content; - const conversation = opts.messages.filter((m) => m.role !== "system"); - - const body: Record = { - model: opts.model || def?.defaultModel || "claude-haiku-4-5", - max_tokens: 8192, - messages: conversation.map(toAnthropicMessage), - stream: true, - }; - if (systemMessage) body.system = systemMessage; - if (opts.tools?.length) { - body.tools = opts.tools.map((tool) => ({ - name: tool.name, - description: tool.description, - input_schema: tool.parameters, - })); - } - - const reasoning = getReasoningCallOptions( - opts.provider, - opts.model, - opts.reasoningEffort as never, - ); - if (reasoning.requestBodyPatch) { - if (reasoning.requestBodyPatch.thinking) body.thinking = reasoning.requestBodyPatch.thinking; - if (reasoning.requestBodyPatch.outputConfig) { - body.output_config = reasoning.requestBodyPatch.outputConfig; - } - } - if (reasoning.extraBody) Object.assign(body, reasoning.extraBody); - - const res = await fetch(`${baseUrl}/v1/messages`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "x-api-key": opts.apiKey, - "anthropic-version": "2023-06-01", - }, - body: JSON.stringify(body), - signal: opts.signal, - }); - if (!res.ok || !res.body) { - // ponytail: include a sample of the upstream body so callers can see - // whether MiniMax (or any other anthropic-shaped provider) is - // returning a non-streaming error JSON even when stream:true was sent. - let bodySample = ""; - if (res.body) { - try { - bodySample = (await new Response(res.body).text()).slice(0, 1024); - } catch { - /* ignore */ - } - } - const suffix = bodySample ? ` body=${bodySample}` : ""; - return { success: false, error: `Upstream ${res.status} ${res.statusText}${suffix}` }; - } - - let text = ""; - const toolCalls: LlmToolCall[] = []; - const inFlight = new Map(); - const eventTypes = new Set(); - let lastEventSample: Record | undefined; - - for await (const event of parseAnthropicEvents(res.body)) { - eventTypes.add(event.type); - lastEventSample = event as Record; - if (event.type === "content_block_start") { - const block = (event.content_block ?? {}) as Record; - if (block.type === "tool_use") { - const toolId = typeof block.id === "string" ? block.id : ""; - inFlight.set(toolId, { - name: typeof block.name === "string" ? block.name : "", - args: "", - index: toolCalls.length, - }); - } - } else if (event.type === "content_block_delta") { - const delta = (event.delta ?? {}) as Record; - if (delta.type === "text_delta" && typeof delta.text === "string") { - text += delta.text; - cb.onTextDelta?.(delta.text); - } else if (delta.type === "input_json_delta" && typeof delta.partial_json === "string") { - const toolId = - typeof event.index === "number" ? findToolUseIdByIndex(inFlight, event.index) : undefined; - if (toolId) { - const entry = inFlight.get(toolId); - if (entry) { - entry.args += delta.partial_json; - } - } - } - } else if (event.type === "content_block_stop") { - const idx = typeof event.index === "number" ? event.index : -1; - for (const [toolId, entry] of inFlight) { - if (entry.index === idx) { - const call: LlmToolCall = { id: toolId, name: entry.name, arguments: entry.args }; - toolCalls.push(call); - cb.onToolCall?.(call); - inFlight.delete(toolId); - } - } - } - } - - if (!text && toolCalls.length === 0) { - // ponytail: include the event types that arrived + a sample of the - // last one so the caller can tell whether the SSE stream was - // genuinely empty, used a different event vocabulary, or carried - // a non-text payload (e.g. thinking blocks, refusals). - const types = Array.from(eventTypes).join(","); - const lastSample = lastEventSample - ? JSON.stringify(lastEventSample).slice(0, 1024) - : "(no events parsed)"; - return { - success: false, - error: `Empty response from Anthropic. events=[${types}] last=${lastSample}`, - }; - } - return { - success: true, - content: text, - toolCalls: toolCalls.length ? toolCalls : undefined, - }; -} - -function findToolUseIdByIndex( - inFlight: Map, - idx: number, -): string | undefined { - for (const [id, entry] of inFlight) if (entry.index === idx) return id; - return undefined; -} - -function toAnthropicMessage(message: ChatMessage): Record { - if (message.role === "tool") { - return { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: message.toolCallId, - content: message.content, - }, - ], - }; - } - if (message.role === "assistant" && message.toolCalls?.length) { - const blocks: unknown[] = []; - if (message.content) blocks.push({ type: "text", text: message.content }); - for (const call of message.toolCalls) { - let input: unknown = {}; - try { - input = call.arguments ? JSON.parse(call.arguments) : {}; - } catch { - input = {}; - } - blocks.push({ type: "tool_use", id: call.id, name: call.name, input }); - } - return { role: "assistant", content: blocks }; - } - return { role: message.role, content: message.content }; -} - -// ─── Codex ────────────────────────────────────────────────────────────── - -async function streamCodex(opts: CallLlmOptions, cb: LlmStreamCallbacks): Promise { - const def = getProviderDefinition("openai-oauth"); - const model = opts.model || def?.defaultModel || "gpt-5.4"; - const baseUrl = (opts.baseUrl || OPENAI_ACCOUNT_BASE_URL).replace(/\/+$/, ""); - const url = `${baseUrl}${CODEX_RESPONSES_PATH}`; - - const headers = buildCodexHeaders(opts, model); - const body = buildCodexBody(opts, model); - - const res = await fetch(url, { - method: "POST", - headers, - body: JSON.stringify(body), - signal: opts.signal, - }); - if (!res.ok || !res.body) { - const text = await res.text().catch(() => ""); - return { - success: false, - error: text - ? `${res.status} ${res.statusText}: ${text.slice(0, 200)}` - : `Upstream ${res.status}`, - }; - } - - let text = ""; - const toolCalls: LlmToolCall[] = []; - const seen = new Set(); - try { - for await (const event of consumeCodexStream(res.body)) { - if (event.kind === "text") { - text += event.delta; - cb.onTextDelta?.(event.delta); - } else if (event.kind === "tool") { - const tc: CodexToolCallDelta = event.delta; - if (!seen.has(tc.id)) { - seen.add(tc.id); - toolCalls.push({ id: tc.id, name: tc.name, arguments: "" }); - } - const slot = toolCalls.find((c) => c.id === tc.id)!; - slot.name = tc.name || slot.name; - slot.arguments = `${slot.arguments}${tc.args}`; - } else if (event.kind === "response_id") { - cb.onResponseId?.(event.id); - } else if (event.kind === "done") { - break; - } - } - } catch (err) { - return { success: false, error: err instanceof Error ? err.message : String(err) }; - } - - for (const c of toolCalls) cb.onToolCall?.(c); - if (!text && toolCalls.length === 0) { - return { success: false, error: "Empty response from Codex." }; - } - return { - success: true, - content: text, - toolCalls: toolCalls.length ? toolCalls : undefined, - }; -} - -function buildCodexHeaders(opts: CallLlmOptions, _model: string): Record { - const headers: Record = { - "Content-Type": "application/json", - Authorization: `Bearer ${opts.apiKey}`, - Accept: "text/event-stream", - originator: "codex_cli_rs", - "OpenAI-Beta": "responses=experimental", - "User-Agent": opts.sessionId - ? `${buildCodexUserAgent()} session/${opts.sessionId}` - : buildCodexUserAgent(), - "x-client-request-id": cryptoRandomUuid(), - "x-codex-window-id": opts.sessionId ?? cryptoRandomUuid(), - ...(opts.installationId ? { "x-codex-installation-id": opts.installationId } : {}), - }; - if (opts.accountId) headers["chatgpt-account-id"] = opts.accountId; - if (opts.sessionId) headers["session_id"] = opts.sessionId; - return headers; -} - -function buildCodexBody(opts: CallLlmOptions, model: string): Record { - const conversation = opts.messages.filter((m) => m.role !== "system"); - const system = opts.messages.find((m) => m.role === "system")?.content; - - const reasoningPayload = toCodexReasoningPayload( - model, - ((opts.reasoningEffort as never) ?? "medium") as never, - ); - - const body: Record = { - model, - store: false, - stream: true, - input: toCodexInput(conversation), - include: ["reasoning.encrypted_content"], - parallel_tool_calls: true, - tool_choice: "auto", - ...(reasoningPayload ?? {}), - ...(opts.sessionId ? { session_id: opts.sessionId } : {}), - }; - if (system) body.instructions = system; - if (opts.tools?.length) body.tools = toCodexTools(opts.tools); - return body; -} - -// ponytail: minimalist UUID — keeps the Codex identity headers non-uniform without pulling crypto.randomUUID into renderer. -function cryptoRandomUuid(): string { - if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID(); - // Math.random fallback is acceptable here; the field is just a request id. - return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { - const r = (Math.random() * 16) | 0; - const v = c === "x" ? r : (r & 0x3) | 0x8; - return v.toString(16); - }); -} - -// ─── Copilot ──────────────────────────────────────────────────────────── - -async function streamCopilot(opts: CallLlmOptions, cb: LlmStreamCallbacks): Promise { - const def = getProviderDefinition("copilot-proxy"); - let runtime: { token: string; baseUrl: string }; - try { - runtime = await exchangeGithubCopilotRuntimeToken(opts.apiKey); - } catch (err) { - return { success: false, error: err instanceof Error ? err.message : String(err) }; - } - - const baseUrl = (opts.baseUrl || runtime.baseUrl || def?.baseUrl || "").replace(/\/+$/, ""); - const body: Record = { - model: opts.model || def?.defaultModel, - messages: opts.messages.map(toOpenAiMessage), - stream: true, - }; - if (opts.tools?.length) { - body.tools = opts.tools.map((tool) => ({ - type: "function", - function: { - name: tool.name, - description: tool.description, - parameters: tool.parameters, - }, - })); - } - const reasoning = getReasoningCallOptions( - "copilot-proxy", - opts.model, - opts.reasoningEffort as never, - ); - if (reasoning.extraBody) Object.assign(body, reasoning.extraBody); - - const headers: Record = { - "Content-Type": "application/json", - Authorization: `Bearer ${runtime.token}`, - Accept: "text/event-stream", - "User-Agent": "GitHubCopilotChat/0.26.7", - "Editor-Version": "vscode/1.96.2", - "Editor-Plugin-Version": "copilot-chat/0.26.7", - "Openai-Intent": "copilot-gpt-chat-completions", - }; - if (opts.reasoningEffort && opts.reasoningEffort !== "none") { - headers["X-Initiator"] = "user"; - } - - const res = await fetch(`${baseUrl}/chat/completions`, { - method: "POST", - headers, - body: JSON.stringify(body), - signal: opts.signal, - }); - if (!res.ok || !res.body) { - return { success: false, error: `Upstream ${res.status} ${res.statusText}` }; - } - - let text = ""; - const toolCalls: OpenAiToolCall[] = []; - for await (const chunk of parseSse(res.body)) { - const choice = (chunk.choices?.[0] ?? {}) as Record; - const delta = (choice.delta ?? {}) as Record; - if (typeof delta.content === "string" && delta.content.length > 0) { - text += delta.content; - cb.onTextDelta?.(delta.content); - } - for (const d of (delta.tool_calls ?? []) as OpenAiToolCall[]) - accumulateOpenAiToolCall(toolCalls, d); - } - const finalCalls: LlmToolCall[] = toolCalls - .filter((t) => t.function?.name) - .map((t, i) => ({ - id: t.id ?? `call_${i}`, - name: t.function?.name ?? "", - arguments: t.function?.arguments ?? "{}", - })); - for (const c of finalCalls) cb.onToolCall?.(c); - if (!text && finalCalls.length === 0) { - return { success: false, error: "Empty response from Copilot." }; - } - return { - success: true, - content: text, - toolCalls: finalCalls.length ? finalCalls : undefined, - }; -} - -// ─── Generic SSE / Anthropic event parser ──────────────────────────────── - -async function* parseSse( - body: ReadableStream, -): AsyncGenerator<{ choices?: Array>; [k: string]: unknown }> { - const reader = body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - while (true) { - const { done, value } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - let idx = buffer.indexOf("\n\n"); - while (idx !== -1) { - const chunk = buffer.slice(0, idx); - buffer = buffer.slice(idx + 2); - const data = chunk - .split("\n") - .filter((l) => l.startsWith("data:")) - .map((l) => l.slice(5).trim()) - .join("\n") - .trim(); - if (data && data !== "[DONE]") { - try { - yield JSON.parse(data) as Record; - } catch { - /* skip malformed */ - } - } - idx = buffer.indexOf("\n\n"); - } - } -} - -export async function* parseAnthropicEvents( - body: ReadableStream, -): AsyncGenerator<{ type: string; [k: string]: unknown }> { - const reader = body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - while (true) { - const { done, value } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - let idx = buffer.indexOf("\n\n"); - while (idx !== -1) { - const chunk = buffer.slice(0, idx); - buffer = buffer.slice(idx + 2); - const eventLine = chunk.split("\n").find((l) => l.startsWith("event:")); - const dataLine = chunk - .split("\n") - .filter((l) => l.startsWith("data:")) - .map((l) => l.slice(5).trim()) - .join("\n"); - const eventName = eventLine ? eventLine.slice(6).trim() : ""; - if (dataLine) { - try { - const parsed = JSON.parse(dataLine) as Record; - yield { ...parsed, type: eventName || parsed.type || "message" } as never; - } catch { - /* skip */ - } - } - idx = buffer.indexOf("\n\n"); - } - } -} diff --git a/electron/ai-edition/llm-provider-auth.ts b/electron/ai-edition/llm-provider-auth.ts index 5cf30698b..b9d1e0952 100644 --- a/electron/ai-edition/llm-provider-auth.ts +++ b/electron/ai-edition/llm-provider-auth.ts @@ -55,9 +55,9 @@ const GITHUB_DEVICE_CLIENT_ID = "Iv1.b507a08c87ecfe98"; const GITHUB_DEVICE_SCOPE = "read:user"; const GITHUB_DEVICE_POLLING_SAFETY_MARGIN_MS = 3_000; const COPILOT_TOKEN_ENDPOINT = "https://api.github.com/copilot_internal/v2/token"; -const GITHUB_COPILOT_USER_AGENT = "GitHubCopilotChat/0.26.7"; -const GITHUB_COPILOT_EDITOR_VERSION = "vscode/1.96.2"; -const GITHUB_COPILOT_PLUGIN_VERSION = "copilot-chat/0.26.7"; +export const GITHUB_COPILOT_USER_AGENT = "GitHubCopilotChat/0.26.7"; +export const GITHUB_COPILOT_EDITOR_VERSION = "vscode/1.96.2"; +export const GITHUB_COPILOT_PLUGIN_VERSION = "copilot-chat/0.26.7"; /** * Begin the Codex (ChatGPT) device flow. Returns the user code and the diff --git a/electron/ai-edition/provider-registry.ts b/electron/ai-edition/provider-registry.ts index 262e9d712..c8fb047e2 100644 --- a/electron/ai-edition/provider-registry.ts +++ b/electron/ai-edition/provider-registry.ts @@ -116,9 +116,8 @@ export const PROVIDER_DEFINITIONS: ProviderDefinition[] = [ // ponytail: docs (platform.minimax.io) tell you to set ANTHROPIC_BASE_URL // to https://api.minimax.io/anthropic, with the real `/messages` path // at /anthropic/v1/messages — same convention as api.anthropic.com. - // Every consumer of this baseUrl (the raw-fetch path in llm-call.ts - // and the @anthropic-ai/sdk-backed ChatAnthropic in chat-model.ts) - // appends `/v1/messages` itself, so this must NOT include `/v1`. + // The @anthropic-ai/sdk-backed ChatAnthropic in chat-model.ts appends + // `/v1/messages` itself, so this must NOT include `/v1`. baseUrl: "https://api.minimax.io/anthropic", envKeys: ["MINIMAX_API_KEY"], setupHint: "Use MINIMAX_API_KEY or paste a MiniMax API key.", diff --git a/src/components/ai-edition/CursorPreviewLayer.test.tsx b/src/components/ai-edition/CursorPreviewLayer.test.tsx index 37b08a0ef..d906d1508 100644 --- a/src/components/ai-edition/CursorPreviewLayer.test.tsx +++ b/src/components/ai-edition/CursorPreviewLayer.test.tsx @@ -107,10 +107,6 @@ const SAMPLE_DOC = { annotations: [], zoomRanges: [], legacyEditor: null, - agent: { pendingQuestions: [], suggestions: [], lastAppliedOperations: [] }, - preview: { strategy: "seek", revision: 0 }, - export: { preset: "final-balanced", lastJobId: null }, - history: { revisions: [] }, }; function renderWithVideo(): { host: HTMLDivElement; video: HTMLVideoElement } { diff --git a/src/components/ai-edition/EditorEmptyState.test.tsx b/src/components/ai-edition/EditorEmptyState.test.tsx index 0fc7b7ff3..86e319c52 100644 --- a/src/components/ai-edition/EditorEmptyState.test.tsx +++ b/src/components/ai-edition/EditorEmptyState.test.tsx @@ -42,10 +42,6 @@ const sampleDoc = vi.hoisted(() => ({ annotations: [], zoomRanges: [], legacyEditor: null, - agent: { pendingQuestions: [], suggestions: [], lastAppliedOperations: [] }, - preview: { strategy: "seek", revision: 0 }, - export: { preset: "final-balanced", lastJobId: null }, - history: { revisions: [] }, })); function setupElectronApi() { diff --git a/src/components/ai-edition/ExportDialog.test.ts b/src/components/ai-edition/ExportDialog.test.ts index 7ea6f137a..560440033 100644 --- a/src/components/ai-edition/ExportDialog.test.ts +++ b/src/components/ai-edition/ExportDialog.test.ts @@ -53,10 +53,6 @@ function doc(assets: AxcutAsset[], clips: AxcutClip[]): AxcutDocument { annotations: [], zoomRanges: [], legacyEditor: null, - agent: { pendingQuestions: [], suggestions: [], lastAppliedOperations: [] }, - preview: { strategy: "seek", revision: 0 }, - export: { preset: "final-balanced", lastJobId: null }, - history: { revisions: [] }, }; } diff --git a/src/components/ai-edition/RightPanes.tsx b/src/components/ai-edition/RightPanes.tsx index 3f9c27654..30e243952 100644 --- a/src/components/ai-edition/RightPanes.tsx +++ b/src/components/ai-edition/RightPanes.tsx @@ -6,7 +6,6 @@ // self-sufficient). import { - Crop as CropIcon, FileText, HelpCircle, Layout as LayoutIcon, @@ -60,14 +59,6 @@ import { resolveImageWallpaperUrl, WALLPAPER_PATHS, WALLPAPER_THUMB_PATHS } from import { isNativeCompositorActive, setNativeParam, subscribeNativeCompositor } from "@/native"; import styles from "./NewEditorShell.module.css"; -export type RightPaneId = - | "background" - | "transcript" - | "effects" - | "layout" - | "cursor" - | "timeline"; - interface PaneProps { title: string; icon: ReactNode; @@ -1711,23 +1702,6 @@ export function CursorPane() { // ─── Timeline (trim waveform) ────────────────────────────────────── -export function TimelinePaneBody() { - const ts = useScopedT("settings"); - const { settings, set, hasDocument } = useEditorSettings(); - return ( - } helpText={ts("timeline.help")}> -
- {ts("timeline.waveform")} - void set({ showTrimWaveform: v })} - /> -
-
- ); -} - // ─── primitives ─────────────────────────────────────────────────── /** La pilule on/off des panneaux — exportée pour que l'inspecteur V4 l'emploie au lieu d'une diff --git a/src/components/ai-edition/WebcamOverlay.test.tsx b/src/components/ai-edition/WebcamOverlay.test.tsx index 6c003e413..08e573f03 100644 --- a/src/components/ai-edition/WebcamOverlay.test.tsx +++ b/src/components/ai-edition/WebcamOverlay.test.tsx @@ -73,10 +73,6 @@ function makeDocument(): AxcutDocument { annotations: [], zoomRanges: [], legacyEditor: null, - agent: { pendingQuestions: [], suggestions: [], lastAppliedOperations: [] }, - preview: { strategy: "seek", revision: 0 }, - export: { preset: "final-balanced", lastJobId: null }, - history: { revisions: [] }, }; } diff --git a/src/components/video-editor/editorDefaults.ts b/src/components/video-editor/editorDefaults.ts index eaff15c14..a74acd020 100644 --- a/src/components/video-editor/editorDefaults.ts +++ b/src/components/video-editor/editorDefaults.ts @@ -35,13 +35,11 @@ export const DEFAULT_EDITOR_APPEARANCE_SETTINGS: { showBlur: boolean; motionBlurAmount: number; borderRadius: number; - showTrimWaveform: boolean; } = { shadowIntensity: 0, showBlur: false, motionBlurAmount: 0, borderRadius: 0, - showTrimWaveform: true, }; export const DEFAULT_EDITOR_LAYOUT_SETTINGS: { diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index dbb1fc937..627402844 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -70,7 +70,6 @@ export interface ProjectEditorState { wallpaper: string; shadowIntensity: number; showBlur: boolean; - showTrimWaveform: boolean; motionBlurAmount: number; borderRadius: number; padding: number; @@ -484,10 +483,6 @@ export function normalizeProjectEditor(editor: Partial): Pro typeof editor.showBlur === "boolean" ? editor.showBlur : DEFAULT_EDITOR_APPEARANCE_SETTINGS.showBlur, - showTrimWaveform: - typeof editor.showTrimWaveform === "boolean" - ? editor.showTrimWaveform - : DEFAULT_EDITOR_APPEARANCE_SETTINGS.showTrimWaveform, motionBlurAmount: isFiniteNumber(editor.motionBlurAmount) ? clamp(editor.motionBlurAmount, 0, 1) : typeof (editor as { motionBlurEnabled?: unknown }).motionBlurEnabled === "boolean" diff --git a/src/hooks/useEditorHistory.ts b/src/hooks/useEditorHistory.ts index 2b105e175..ce8c9e9c7 100644 --- a/src/hooks/useEditorHistory.ts +++ b/src/hooks/useEditorHistory.ts @@ -40,7 +40,6 @@ export interface EditorState { wallpaper: string; shadowIntensity: number; showBlur: boolean; - showTrimWaveform: boolean; motionBlurAmount: number; borderRadius: number; padding: number; @@ -65,7 +64,6 @@ export const INITIAL_EDITOR_STATE: EditorState = { wallpaper: DEFAULT_EDITOR_LAYOUT_SETTINGS.wallpaper, shadowIntensity: DEFAULT_EDITOR_APPEARANCE_SETTINGS.shadowIntensity, showBlur: DEFAULT_EDITOR_APPEARANCE_SETTINGS.showBlur, - showTrimWaveform: DEFAULT_EDITOR_APPEARANCE_SETTINGS.showTrimWaveform, motionBlurAmount: DEFAULT_EDITOR_APPEARANCE_SETTINGS.motionBlurAmount, borderRadius: DEFAULT_EDITOR_APPEARANCE_SETTINGS.borderRadius, padding: DEFAULT_EDITOR_LAYOUT_SETTINGS.padding, diff --git a/src/i18n/locales/ar/settings.json b/src/i18n/locales/ar/settings.json index 80cbe3653..b2c0a67cb 100644 --- a/src/i18n/locales/ar/settings.json +++ b/src/i18n/locales/ar/settings.json @@ -241,11 +241,6 @@ "language": { "title": "اللغة" }, - "timeline": { - "title": "المخطط الزمني", - "waveform": "عرض الموجة الصوتية على مسار القطع", - "help": "خيارات عرض المخطط الزمني، مثل إظهار الموجة الصوتية على مسار القص." - }, "facets": { "captions": "الترجمة", "transcript": "النص" diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 49acd3a99..172d3658b 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -241,11 +241,6 @@ "language": { "title": "Language" }, - "timeline": { - "title": "Timeline", - "waveform": "Show Audio Waveform on Trim Track", - "help": "Timeline display options, like showing the audio waveform on the trim track." - }, "facets": { "captions": "Captions", "transcript": "Transcript" diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json index 2c2937495..fa9ecbdee 100644 --- a/src/i18n/locales/es/settings.json +++ b/src/i18n/locales/es/settings.json @@ -241,11 +241,6 @@ "language": { "title": "Idioma" }, - "timeline": { - "title": "Línea de tiempo", - "waveform": "Mostrar forma de onda en pista de recorte", - "help": "Opciones de visualización de la línea de tiempo, como mostrar la forma de onda en la pista de recorte." - }, "facets": { "captions": "Subtítulos", "transcript": "Transcripción" diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json index b3b47083e..34e167664 100644 --- a/src/i18n/locales/fr/settings.json +++ b/src/i18n/locales/fr/settings.json @@ -241,11 +241,6 @@ "language": { "title": "Langue" }, - "timeline": { - "title": "Montage", - "waveform": "Afficher la forme d'onde sur la piste de découpe", - "help": "Options d'affichage de la timeline, comme la forme d'onde audio sur la piste de découpe." - }, "facets": { "captions": "Sous-titres", "transcript": "Transcription" diff --git a/src/i18n/locales/it/settings.json b/src/i18n/locales/it/settings.json index 7d48d6087..5ba1f263e 100644 --- a/src/i18n/locales/it/settings.json +++ b/src/i18n/locales/it/settings.json @@ -241,11 +241,6 @@ "language": { "title": "Lingua" }, - "timeline": { - "title": "Timeline", - "waveform": "Mostra la forma d'onda sulla traccia di ritaglio", - "help": "Opzioni di visualizzazione della timeline, come mostrare la forma d'onda audio sulla traccia di taglio." - }, "facets": { "captions": "Sottotitoli", "transcript": "Trascrizione" diff --git a/src/i18n/locales/ja-JP/settings.json b/src/i18n/locales/ja-JP/settings.json index ca22dc146..9c92a3c04 100644 --- a/src/i18n/locales/ja-JP/settings.json +++ b/src/i18n/locales/ja-JP/settings.json @@ -241,11 +241,6 @@ "language": { "title": "言語" }, - "timeline": { - "title": "タイムライン", - "waveform": "トリムトラックにオーディオ波形を表示", - "help": "タイムラインの表示オプション(トリムトラックに音声波形を表示するなど)。" - }, "facets": { "captions": "字幕", "transcript": "文字起こし" diff --git a/src/i18n/locales/ko-KR/settings.json b/src/i18n/locales/ko-KR/settings.json index 176884cec..2ccaffbf6 100644 --- a/src/i18n/locales/ko-KR/settings.json +++ b/src/i18n/locales/ko-KR/settings.json @@ -241,11 +241,6 @@ "language": { "title": "언어" }, - "timeline": { - "title": "타임라인", - "waveform": "트림 트랙에 오디오 파형 표시", - "help": "타임라인 표시 옵션(예: 트림 트랙에 오디오 파형 표시)." - }, "facets": { "captions": "자막", "transcript": "대본" diff --git a/src/i18n/locales/pt-BR/settings.json b/src/i18n/locales/pt-BR/settings.json index 79fbedb38..d33935dc4 100644 --- a/src/i18n/locales/pt-BR/settings.json +++ b/src/i18n/locales/pt-BR/settings.json @@ -241,11 +241,6 @@ "language": { "title": "Idioma" }, - "timeline": { - "title": "Linha do Tempo", - "waveform": "Mostrar Forma de Onda de Áudio na Faixa de Corte", - "help": "Opções de exibição da linha do tempo, como mostrar a forma de onda de áudio na trilha de corte." - }, "facets": { "captions": "Legendas", "transcript": "Transcrição" diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json index a987a2c98..5363f9907 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -241,11 +241,6 @@ "language": { "title": "Язык" }, - "timeline": { - "title": "Шкала времени", - "waveform": "Показывать форму волны на треке обрезки", - "help": "Параметры отображения таймлайна, например показ звуковой волны на дорожке обрезки." - }, "facets": { "captions": "Субтитры", "transcript": "Транскрипт" diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json index 3cd10664a..36b7fbade 100644 --- a/src/i18n/locales/tr/settings.json +++ b/src/i18n/locales/tr/settings.json @@ -241,11 +241,6 @@ "language": { "title": "Dil" }, - "timeline": { - "title": "Zaman Tüneli", - "waveform": "Kırpma Parçasında Ses Dalgasını Göster", - "help": "Zaman çizelgesi görüntüleme seçenekleri; örneğin kırpma parçasında ses dalga formunu göstermek." - }, "facets": { "captions": "Altyazılar", "transcript": "Metin Dökümü" diff --git a/src/i18n/locales/vi/settings.json b/src/i18n/locales/vi/settings.json index 56ccba317..a7fc09974 100644 --- a/src/i18n/locales/vi/settings.json +++ b/src/i18n/locales/vi/settings.json @@ -241,11 +241,6 @@ "language": { "title": "Ngôn ngữ" }, - "timeline": { - "title": "Dòng thời gian", - "waveform": "Hiển thị dạng sóng âm thanh trên rãnh cắt", - "help": "Tùy chọn hiển thị dòng thời gian, chẳng hạn hiện dạng sóng âm thanh trên rãnh cắt." - }, "facets": { "captions": "Phụ đề", "transcript": "Bản ghi lời thoại" diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index 14f939696..d1d152bd0 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -241,11 +241,6 @@ "language": { "title": "语言" }, - "timeline": { - "title": "时间轴", - "waveform": "在剪辑轨道上显示音频波形", - "help": "时间轴显示选项,例如在修剪轨道上显示音频波形。" - }, "facets": { "captions": "字幕", "transcript": "转录文本" diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index 2e44c0192..a2e949cbd 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -242,11 +242,6 @@ "language": { "title": "語言" }, - "timeline": { - "title": "時間軸", - "waveform": "在剪輯軌道上顯示音訊波形", - "help": "時間軸顯示選項,例如在修剪軌道上顯示音訊波形。" - }, "facets": { "captions": "字幕", "transcript": "逐字稿" diff --git a/src/lib/ai-edition/captions/captions.test.ts b/src/lib/ai-edition/captions/captions.test.ts index 2b6f37530..96a83473a 100644 --- a/src/lib/ai-edition/captions/captions.test.ts +++ b/src/lib/ai-edition/captions/captions.test.ts @@ -85,10 +85,6 @@ function doc(overrides: Partial = {}): AxcutDocument { annotations: [], zoomRanges: [], legacyEditor: null, - agent: { pendingQuestions: [], suggestions: [], lastAppliedOperations: [] }, - preview: { strategy: "seek", revision: 0 }, - export: { preset: "final-balanced", lastJobId: null }, - history: { revisions: [] }, ...overrides, } as AxcutDocument; } diff --git a/src/lib/ai-edition/document/migrate.test.ts b/src/lib/ai-edition/document/migrate.test.ts index 02884beb1..2b801de15 100644 --- a/src/lib/ai-edition/document/migrate.test.ts +++ b/src/lib/ai-edition/document/migrate.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import type { EditorProjectData } from "@/components/video-editor/projectPersistence"; +import { getEditorSettings } from "@/lib/ai-edition/store/editorSettings"; import { migrateAxcutDocumentToProjectData, migrateProjectDataToAxcutDocument } from "./migrate"; function makeV2Project(overrides: Partial = {}): EditorProjectData { @@ -10,7 +11,6 @@ function makeV2Project(overrides: Partial = {}): EditorProjec wallpaper: "/wallpapers/wallpaper1.jpg", shadowIntensity: 0, showBlur: false, - showTrimWaveform: true, motionBlurAmount: 0, borderRadius: 0, padding: 50, @@ -170,6 +170,22 @@ describe("migrateProjectDataToAxcutDocument", () => { }); }); + it("keeps migrating a project saved with the retired showTrimWaveform key", () => { + const v2 = makeV2Project(); + // Projects saved before the setting was removed still carry it. + const stored = { + ...v2, + editor: { ...v2.editor, showTrimWaveform: false }, + } as EditorProjectData; + const doc = migrateProjectDataToAxcutDocument(stored); + // legacyEditorSchema is a passthrough object, so the retired key rides + // along inertly rather than failing the parse — and nothing reads it. + expect(doc.legacyEditor).toMatchObject({ showTrimWaveform: false }); + expect(getEditorSettings(doc)).toEqual( + getEditorSettings(migrateProjectDataToAxcutDocument(v2)), + ); + }); + it("handles missing media by creating an empty document", () => { const doc = migrateProjectDataToAxcutDocument({ version: 2, diff --git a/src/lib/ai-edition/document/migrate.ts b/src/lib/ai-edition/document/migrate.ts index 90154715f..d8ee6e40d 100644 --- a/src/lib/ai-edition/document/migrate.ts +++ b/src/lib/ai-edition/document/migrate.ts @@ -228,14 +228,6 @@ export function migrateProjectDataToAxcutDocument( annotations: migratedAnnotations, zoomRanges: migratedZoomRanges, legacyEditor, - agent: { - pendingQuestions: [], - suggestions: [], - lastAppliedOperations: [], - }, - preview: { strategy: "seek", revision: 0 }, - export: { preset: "final-balanced", lastJobId: null }, - history: { revisions: [] }, }; return documentSchema.parse(draft); @@ -275,7 +267,6 @@ export function migrateAxcutDocumentToProjectData(input: AxcutDocument): EditorP wallpaper: "", shadowIntensity: 0, showBlur: false, - showTrimWaveform: true, motionBlurAmount: 0, borderRadius: 0, padding: 50, diff --git a/src/lib/ai-edition/document/operations.test.ts b/src/lib/ai-edition/document/operations.test.ts index 0f0c5be37..1364bc311 100644 --- a/src/lib/ai-edition/document/operations.test.ts +++ b/src/lib/ai-edition/document/operations.test.ts @@ -56,7 +56,6 @@ describe("applyTimelineOperation.add_trim_range", () => { endSec: 8, origin: "user", }); - expect(next.preview.revision).toBe(1); }); it("normalises reversed bounds (end < start)", () => { diff --git a/src/lib/ai-edition/document/operations.ts b/src/lib/ai-edition/document/operations.ts index c0c372f3d..8f51b5533 100644 --- a/src/lib/ai-edition/document/operations.ts +++ b/src/lib/ai-edition/document/operations.ts @@ -211,7 +211,6 @@ export function applyTimelineOperation( })), ], }, - preview: { ...document.preview, revision: document.preview.revision + 1 }, }; return { document: next, @@ -232,7 +231,6 @@ export function applyTimelineOperation( return { ...s, startSec: lo, endSec: hi, reason: op.reason ?? s.reason }; }), }, - preview: { ...document.preview, revision: document.preview.revision + 1 }, }; if (!found) return { document, summary: `unknown trim ${op.trimId}` }; return { @@ -247,7 +245,6 @@ export function applyTimelineOperation( ...document.timeline, trimRanges: document.timeline.trimRanges.filter((s) => s.id !== op.trimId), }, - preview: { ...document.preview, revision: document.preview.revision + 1 }, }; return { document: next, @@ -271,7 +268,6 @@ export function applyTimelineOperation( const base = setClipSourceRange(document, op.clipId, op.sourceStartSec, op.sourceEndSec); const finalDoc: AxcutDocument = { ...base, - preview: { ...base.preview, revision: base.preview.revision + 1 }, }; return { document: finalDoc, diff --git a/src/lib/ai-edition/document/outputFormat.test.ts b/src/lib/ai-edition/document/outputFormat.test.ts index 1355d84c4..9c16552c6 100644 --- a/src/lib/ai-edition/document/outputFormat.test.ts +++ b/src/lib/ai-edition/document/outputFormat.test.ts @@ -63,10 +63,6 @@ function doc(assets: AxcutAsset[], clips: AxcutClip[]): AxcutDocument { annotations: [], zoomRanges: [], legacyEditor: null, - agent: { pendingQuestions: [], suggestions: [], lastAppliedOperations: [] }, - preview: { strategy: "seek", revision: 0 }, - export: { preset: "final-balanced", lastJobId: null }, - history: { revisions: [] }, } as AxcutDocument; } diff --git a/src/lib/ai-edition/document/timeline.test.ts b/src/lib/ai-edition/document/timeline.test.ts index 8fb9b9dfd..de92ceaff 100644 --- a/src/lib/ai-edition/document/timeline.test.ts +++ b/src/lib/ai-edition/document/timeline.test.ts @@ -51,10 +51,6 @@ function makeDoc(overrides: Partial = {}): AxcutDocument { annotations: [], zoomRanges: [], legacyEditor: null, - agent: { pendingQuestions: [], suggestions: [], lastAppliedOperations: [] }, - preview: { strategy: "seek", revision: 0 }, - export: { preset: "final-balanced", lastJobId: null }, - history: { revisions: [] }, ...overrides, }; } @@ -169,7 +165,6 @@ describe("timeline pure functions", () => { startSec: 20, endSec: 30, }); - expect(updated.preview.revision).toBe(1); }); it("throws when there is no primary asset", () => { @@ -380,7 +375,7 @@ describe("duplicateClip / moveClip", () => { expect(new Set(ids).size).toBe(ids.length); }); - it("duplicateClip inserts the copy immediately after the original and bumps preview.revision", () => { + it("duplicateClip inserts the copy immediately after the original", () => { const doc = makeDoc({ timeline: { ...makeDoc().timeline, @@ -394,10 +389,9 @@ describe("duplicateClip / moveClip", () => { expect(next.timeline.clips.map((c) => c.id)[1]).not.toBe("clip_b"); expect(next.timeline.clips[0].id).toBe("clip_a"); expect(next.timeline.clips[2].id).toBe("clip_b"); - expect(next.preview.revision).toBe(doc.preview.revision + 1); }); - it("moveClip reorders clips and bumps preview.revision", () => { + it("moveClip reorders clips", () => { const doc = makeDoc({ timeline: { ...makeDoc().timeline, @@ -409,7 +403,6 @@ describe("duplicateClip / moveClip", () => { }); const next = moveClip(doc, "clip_a", 1); expect(next.timeline.clips.map((c) => c.id)).toEqual(["clip_b", "clip_a"]); - expect(next.preview.revision).toBe(doc.preview.revision + 1); }); it("throws for an unknown clip id", () => { @@ -689,12 +682,6 @@ describe("setClipSourceRange — the one shared clip-trim mutator", () => { expect(untouched.timeline.clips.map((c) => c.id)).toEqual(["clip_a", "clip_b"]); expect(untouched.timeline.clips[0]).toMatchObject({ sourceEndSec: 10, timelineEndSec: 10 }); }); - - it("does not bump preview.revision (the caller owns that)", () => { - const before = doc(); - const next = setClipSourceRange(before, "clip_a", 0, 4); - expect(next.preview.revision).toBe(before.preview.revision); - }); }); describe("removeRegion — the one shared region-delete mutator", () => { @@ -737,7 +724,7 @@ describe("removeRegion — the one shared region-delete mutator", () => { ).toHaveLength(0); }); - it("is a no-op for an unknown id and never bumps preview.revision", () => { + it("is a no-op for an unknown id", () => { const doc = makeDoc({ zoomRanges: [ makeZoom({ id: "z1", startMs: 0, endMs: 2000 }), @@ -745,7 +732,6 @@ describe("removeRegion — the one shared region-delete mutator", () => { }); const next = removeRegion(doc, "zoom", "nope"); expect(next.zoomRanges.map((z) => z.id)).toEqual(["z1"]); - expect(next.preview.revision).toBe(doc.preview.revision); }); }); @@ -795,11 +781,10 @@ describe("removeClip — delete a clip, close the gap, drop its pills", () => { expect(next.zoomRanges[0]).toMatchObject({ startMs: 2000, endMs: 4000 }); }); - it("is a no-op for an unknown clip and never bumps preview.revision", () => { + it("is a no-op for an unknown clip", () => { const before = doc(); const next = removeClip(before, "clip_missing"); expect(next.timeline.clips.map((c) => c.id)).toEqual(["clip_a", "clip_b"]); expect(next).toBe(before); - expect(next.preview.revision).toBe(before.preview.revision); }); }); diff --git a/src/lib/ai-edition/document/timeline.ts b/src/lib/ai-edition/document/timeline.ts index 3bce9ac49..d8f85a7ac 100644 --- a/src/lib/ai-edition/document/timeline.ts +++ b/src/lib/ai-edition/document/timeline.ts @@ -460,10 +460,6 @@ export function replaceTimeline( trimRanges, gaps: [], }, - preview: { - ...document.preview, - revision: document.preview.revision + 1, - }, }; return reanchorRegions(next, clips); } @@ -499,10 +495,6 @@ export function moveClip( ...document.timeline, clips: newClips, }, - preview: { - ...document.preview, - revision: document.preview.revision + 1, - }, }; return rederiveRegionMs(next, newClips); } @@ -536,10 +528,6 @@ export function duplicateClip( ...document.timeline, clips: newClips, }, - preview: { - ...document.preview, - revision: document.preview.revision + 1, - }, }; return rederiveRegionMs(updatedDoc, newClips); } @@ -549,8 +537,7 @@ export function duplicateClip( * clip's Edit modal, the renderer op dispatcher, and the LLM's `setClipRange` tool all * perform. Extracted here (like `moveClip` / `duplicateClip`) so the recipe lives in one * place instead of being re-derived per façade, which is what let the three drift apart - * (stale width, un-clamped pills). Pure; does NOT touch `preview.revision` — a caller that - * needs a preview refresh bumps it itself, so this stays a plain document→document transform. + * (stale width, un-clamped pills). Pure — a plain document→document transform. * * Recipe: clamp + order the range, zero the clip's timeline extent so `resequenceClips` * recomputes its RAW length from the new source window (a raw clip's timeline length equals @@ -586,7 +573,7 @@ export function setClipSourceRange( * `setClipSourceRange`) so the recipe lives in one place: a `trim` is a plain filter on the * source-time cut list; every other kind is a pill (`dropPillById` removes every region that * renders as the same pill as `id`, per the merge rule). Speed / camera-fullscreen live under - * `legacyEditor`. An id that matches nothing is a no-op. Pure; does NOT touch `preview.revision`. + * `legacyEditor`. An id that matches nothing is a no-op. Pure. */ export function removeRegion(document: AxcutDocument, kind: RegionKind, id: string): AxcutDocument { switch (kind) { @@ -636,7 +623,7 @@ export function removeRegion(document: AxcutDocument, kind: RegionKind, id: stri * re-laid back-to-back (`resequenceClips`) and every anchored pill's derived ms is refreshed * against the new layout (`rederiveRegionMs`) — pills anchored to the removed clip drop out, * exactly like `setClipSourceRange`. Shared by the store's delete-clip action and the LLM's - * `removeClip` tool. An unknown `clipId` is a no-op. Pure; does NOT touch `preview.revision`. + * `removeClip` tool. An unknown `clipId` is a no-op. Pure. */ export function removeClip(document: AxcutDocument, clipId: string): AxcutDocument { const oldClips = document.timeline.clips; diff --git a/src/lib/ai-edition/document/transcribe.test.ts b/src/lib/ai-edition/document/transcribe.test.ts index bde444601..b943839eb 100644 --- a/src/lib/ai-edition/document/transcribe.test.ts +++ b/src/lib/ai-edition/document/transcribe.test.ts @@ -47,10 +47,6 @@ function makeDoc(): AxcutDocument { annotations: [], zoomRanges: [], legacyEditor: null, - agent: null, - preview: { revision: 0, playbackRate: 1 }, - export: null, - history: { revisions: [] }, }; } diff --git a/src/lib/ai-edition/document/transcribe.ts b/src/lib/ai-edition/document/transcribe.ts index 30a6abff6..460828b60 100644 --- a/src/lib/ai-edition/document/transcribe.ts +++ b/src/lib/ai-edition/document/transcribe.ts @@ -152,6 +152,5 @@ export function withTranscript( transcript: document.project.primaryAssetId === transcript.assetId ? transcript : document.transcript, transcripts, - preview: { ...document.preview, revision: document.preview.revision + 1 }, }; } diff --git a/src/lib/ai-edition/schema/index.test.ts b/src/lib/ai-edition/schema/index.test.ts index 4b1fbfede..4b416a8b2 100644 --- a/src/lib/ai-edition/schema/index.test.ts +++ b/src/lib/ai-edition/schema/index.test.ts @@ -41,7 +41,6 @@ describe("axcut-schema v5", () => { expect(doc.zoomRanges).toEqual([]); expect(doc.transcripts).toEqual([]); expect(doc.legacyEditor).toBeNull(); - expect(doc.history.revisions).toEqual([]); }); it("ensureDocument rejects garbage", () => { diff --git a/src/lib/ai-edition/schema/index.ts b/src/lib/ai-edition/schema/index.ts index 3c1ab6835..990b32557 100644 --- a/src/lib/ai-edition/schema/index.ts +++ b/src/lib/ai-edition/schema/index.ts @@ -212,24 +212,6 @@ export const timelineSchema = z.preprocess( }), ); -export const pendingQuestionSchema = z.object({ - id: z.string().min(1), - question: z.string().min(1), - reason: z.string().default(""), - startWordId: z.string().optional(), - endWordId: z.string().optional(), -}); - -export const previewSchema = z.object({ - strategy: z.enum(["seek", "mse-proxy"]).default("seek"), - revision: z.number().int().nonnegative().default(0), -}); - -export const exportStateSchema = z.object({ - preset: z.enum(["preview-low", "final-balanced", "final-high"]).default("final-balanced"), - lastJobId: z.string().nullable().default(null), -}); - export const timelineOperationSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal("replace_timeline"), @@ -302,51 +284,6 @@ export const timelineOperationSchema = z.discriminatedUnion("type", [ }), ]); -export const suggestionSchema = z.object({ - id: z.string().min(1), - status: z.enum(["pending", "approved", "rejected"]).default("pending"), - category: z - .enum(["cut_candidate", "style", "delivery", "topic_focus", "clarification"]) - .default("cut_candidate"), - suggestion: z.string().min(1), - reason: z.string().default(""), - startWordId: z.string().optional(), - endWordId: z.string().optional(), - startSec: z.number().nonnegative().optional(), - endSec: z.number().nonnegative().optional(), - proposedOperation: timelineOperationSchema.optional(), -}); - -export const agentStateSchema = z.object({ - baseIntent: z.string().optional(), - pendingQuestions: z.array(pendingQuestionSchema).default([]), - suggestions: z.array(suggestionSchema).default([]), - lastAppliedOperations: z.array(z.string()).default([]), - lastReasoningSummary: z.string().optional(), -}); - -export const operationSchema = z.discriminatedUnion("type", [ - ...timelineOperationSchema.options, - z.object({ - type: z.literal("approve_suggestion"), - reason: z.string().default(""), - suggestionId: z.string().min(1), - }), - z.object({ - type: z.literal("reject_suggestion"), - reason: z.string().default(""), - suggestionId: z.string().min(1), - }), -]); - -export const revisionSchema = z.object({ - id: z.string().min(1), - createdAt: isoDateSchema, - author: z.enum(["system", "agent", "user"]), - summary: z.string().min(1), - operations: z.array(operationSchema).default([]), -}); - // OpenScreen additions to the axcut document. Mirrors src/components/video-editor/types.ts // (AnnotationRegion / ZoomRegion) — duplicated here so the schema package has no // dependency on the React editor module. @@ -496,18 +433,6 @@ const documentSchemaShape = z.object({ annotations: z.array(annotationRegionSchema).default([]), zoomRanges: z.array(zoomRegionSchema).default([]), legacyEditor: legacyEditorSchema.nullable().default(null), - agent: agentStateSchema.default({ - pendingQuestions: [], - suggestions: [], - lastAppliedOperations: [], - }), - preview: previewSchema.default({ strategy: "seek", revision: 0 }), - export: exportStateSchema.default({ preset: "final-balanced", lastJobId: null }), - history: z - .object({ - revisions: z.array(revisionSchema).default([]), - }) - .default({ revisions: [] }), }); // P4 — v3 documents carried a single project-level `cameraTrack` (one project @@ -629,20 +554,6 @@ export const transcriptLanguageSchema = z.enum([ "zh", ]); -export const transcribeInputSchema = z.object({ - language: transcriptLanguageSchema.default("auto"), -}); - -export const exportInputSchema = z.object({ - preset: exportStateSchema.shape.preset.default("final-balanced"), -}); - -export const applyOperationInputSchema = z.object({ - operation: operationSchema, - sessionId: z.string().min(1).optional(), - conversationMessage: z.string().min(1).optional(), -}); - export type AxcutWord = z.infer; export type AxcutTranscriptSegment = z.infer; export type AxcutTranscript = z.infer; @@ -652,11 +563,7 @@ export type AxcutClipCropRegion = z.infer; export type AxcutGap = z.infer; export type AxcutTrimRange = z.infer; export type AxcutTimeline = z.infer; -export type AxcutSuggestion = z.infer; -export type AxcutAgentState = z.infer; export type AxcutTimelineOperation = z.infer; -export type AxcutOperation = z.infer; -export type AxcutRevision = z.infer; export type AxcutAnnotationRegion = z.infer; export type AxcutZoomRegion = z.infer; export type AxcutCameraTrack = z.infer; @@ -666,9 +573,6 @@ export type AxcutDocumentInput = z.input; export type CreateProjectInput = z.infer; export type AddAssetInput = z.infer; export type ChatInput = z.infer; -export type TranscribeInput = z.infer; -export type ExportInput = z.infer; -export type ApplyOperationInput = z.infer; export function createEmptyDocument( input: CreateProjectInput & { projectId: string; createdAt?: string }, @@ -696,10 +600,6 @@ export function createEmptyDocument( annotations: [], zoomRanges: [], legacyEditor: null, - agent: { pendingQuestions: [], suggestions: [], lastAppliedOperations: [] }, - preview: { strategy: "seek", revision: 0 }, - export: { preset: "final-balanced", lastJobId: null }, - history: { revisions: [] }, }); } diff --git a/src/lib/ai-edition/store/editorSettings.test.ts b/src/lib/ai-edition/store/editorSettings.test.ts index 0f72d7e93..f7af90df9 100644 --- a/src/lib/ai-edition/store/editorSettings.test.ts +++ b/src/lib/ai-edition/store/editorSettings.test.ts @@ -28,7 +28,6 @@ describe("getEditorSettings", () => { expect(snap.aspectRatio).toBe("16:9"); expect(snap.shadowIntensity).toBe(0); expect(snap.showBlur).toBe(false); - expect(snap.showTrimWaveform).toBe(true); expect(snap.webcamLayoutPreset).toBe(DEFAULT_WEBCAM_LAYOUT_PRESET); expect(snap.webcamMaskShape).toBe(DEFAULT_WEBCAM_MASK_SHAPE); expect(snap.cursor.size).toBe(DEFAULT_CURSOR_SIZE); diff --git a/src/lib/ai-edition/store/editorSettings.ts b/src/lib/ai-edition/store/editorSettings.ts index b4fa74c54..131505b5a 100644 --- a/src/lib/ai-edition/store/editorSettings.ts +++ b/src/lib/ai-edition/store/editorSettings.ts @@ -43,7 +43,6 @@ export interface EditorSettingsSnapshot { aspectRatio: AspectRatio; shadowIntensity: number; showBlur: boolean; - showTrimWaveform: boolean; motionBlurAmount: number; borderRadius: number; padding: number; @@ -65,7 +64,6 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettingsSnapshot = { aspectRatio: "16:9", shadowIntensity: 0, showBlur: false, - showTrimWaveform: true, motionBlurAmount: 0, borderRadius: 0, padding: 50, @@ -93,7 +91,6 @@ interface LegacyShape { aspectRatio?: AspectRatio; shadowIntensity?: number; showBlur?: boolean; - showTrimWaveform?: boolean; motionBlurAmount?: number; borderRadius?: number; padding?: number; @@ -145,7 +142,6 @@ export function getEditorSettings(doc: AxcutDocument | null | undefined): Editor aspectRatio: legacy?.aspectRatio ?? DEFAULT_EDITOR_SETTINGS.aspectRatio, shadowIntensity: num(legacy?.shadowIntensity, DEFAULT_EDITOR_SETTINGS.shadowIntensity), showBlur: bool(legacy?.showBlur, DEFAULT_EDITOR_SETTINGS.showBlur), - showTrimWaveform: bool(legacy?.showTrimWaveform, DEFAULT_EDITOR_SETTINGS.showTrimWaveform), motionBlurAmount: num(legacy?.motionBlurAmount, DEFAULT_EDITOR_SETTINGS.motionBlurAmount), borderRadius: num(legacy?.borderRadius, DEFAULT_EDITOR_SETTINGS.borderRadius), padding: num(legacy?.padding, DEFAULT_EDITOR_SETTINGS.padding), @@ -170,7 +166,6 @@ export interface EditorSettingsPatch { aspectRatio?: AspectRatio; shadowIntensity?: number; showBlur?: boolean; - showTrimWaveform?: boolean; motionBlurAmount?: number; borderRadius?: number; padding?: number; diff --git a/src/lib/ai-edition/store/projectStore.test.ts b/src/lib/ai-edition/store/projectStore.test.ts index 7503af5f5..c4fb4fbce 100644 --- a/src/lib/ai-edition/store/projectStore.test.ts +++ b/src/lib/ai-edition/store/projectStore.test.ts @@ -53,10 +53,6 @@ const sampleDoc = { annotations: [], zoomRanges: [], legacyEditor: null, - agent: { pendingQuestions: [], suggestions: [], lastAppliedOperations: [] }, - preview: { strategy: "seek", revision: 0 }, - export: { preset: "final-balanced", lastJobId: null }, - history: { revisions: [] }, }; describe("useProjectStore", () => { diff --git a/src/lib/ai-edition/store/projectStore.ts b/src/lib/ai-edition/store/projectStore.ts index ad30a4a65..e67d2c798 100644 --- a/src/lib/ai-edition/store/projectStore.ts +++ b/src/lib/ai-edition/store/projectStore.ts @@ -256,7 +256,6 @@ export const useProjectStore = create((set, get) => ({ ...doc, transcript: doc.project.primaryAssetId === transcript.assetId ? transcript : doc.transcript, transcripts, - preview: { ...doc.preview, revision: doc.preview.revision + 1 }, }; await get().saveDocument(next); }, diff --git a/src/lib/ai-edition/store/useTimeline.test.ts b/src/lib/ai-edition/store/useTimeline.test.ts index afce4da3f..376c49376 100644 --- a/src/lib/ai-edition/store/useTimeline.test.ts +++ b/src/lib/ai-edition/store/useTimeline.test.ts @@ -83,77 +83,8 @@ const sampleDoc = { annotations: [], zoomRanges: [], legacyEditor: null, - agent: { pendingQuestions: [], suggestions: [], lastAppliedOperations: [] }, - preview: { strategy: "seek", revision: 0 }, - export: { preset: "final-balanced", lastJobId: null }, - history: { revisions: [] }, }; -describe("useTimeline.editClip", () => { - beforeEach(() => { - useProjectStore.getState().clear(); - for (const mock of Object.values(bridgeMocks)) mock.mockReset(); - bridgeMocks.save.mockImplementation(async (doc: typeof sampleDoc) => ({ - success: true, - document: doc, - })); - useProjectStore.setState({ - projectId: "proj_test", - document: sampleDoc, - revision: 1, - status: "ready", - error: null, - }); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - it("updates the target clip in place and persists via the store", async () => { - const { result } = renderHook(() => useTimeline()); - await act(async () => { - await result.current.editClip("clip_a", { - sourceStartSec: 1, - sourceEndSec: 8, - timelineStartSec: 2, - timelineEndSec: 9, - }); - }); - const doc = useProjectStore.getState().document; - const updated = doc?.timeline.clips[0]; - expect(updated).toMatchObject({ - id: "clip_a", - sourceStartSec: 1, - sourceEndSec: 8, - timelineStartSec: 2, - timelineEndSec: 9, - }); - expect(bridgeMocks.save).toHaveBeenCalledTimes(1); - }); - - it("clamps end >= start when the user types them out of order", async () => { - const { result } = renderHook(() => useTimeline()); - await act(async () => { - await result.current.editClip("clip_a", { - sourceStartSec: 7, - sourceEndSec: 2, - }); - }); - const updated = useProjectStore.getState().document?.timeline.clips[0]; - expect(updated?.sourceStartSec).toBe(2); - expect(updated?.sourceEndSec).toBe(7); - }); - - it("no-ops when the clip id is unknown", async () => { - const { result } = renderHook(() => useTimeline()); - await act(async () => { - await result.current.editClip("clip_missing", { sourceStartSec: 1 }); - }); - expect(bridgeMocks.save).not.toHaveBeenCalled(); - }); -}); - describe("useTimeline.insertClipAt background duration probe", () => { beforeEach(() => { useProjectStore.getState().clear(); diff --git a/src/lib/ai-edition/store/useTimeline.ts b/src/lib/ai-edition/store/useTimeline.ts index c9af6aa4b..cf824b787 100644 --- a/src/lib/ai-edition/store/useTimeline.ts +++ b/src/lib/ai-edition/store/useTimeline.ts @@ -226,34 +226,6 @@ export function useTimeline() { await saveDocument(next); }, [document, currentTimeSec, saveDocument]); - // T15 — add a trim at a specific (assetId, sourceStartSec, sourceEndSec). - // Used by the place-trim mode in TimelinePane where the cursor lands - // inside a specific clip's source range, not just at currentTimeSec. - const addTrimAt = useCallback( - async (assetId: string, sourceStartSec: number, sourceEndSec: number) => { - if (!document) return; - const next: AxcutDocument = { - ...document, - timeline: { - ...document.timeline, - trimRanges: [ - ...document.timeline.trimRanges, - { - id: createId("trim"), - assetId, - startSec: sourceStartSec, - endSec: sourceEndSec, - reason: "manual", - origin: "user" as const, - }, - ], - }, - }; - await saveDocument(next); - }, - [document, saveDocument], - ); - const addAnnotation = useCallback(async () => { if (!document) return; const timeMs = Math.round(currentTimeSec * 1000); @@ -343,25 +315,6 @@ export function useTimeline() { await saveDocument(next); }, [document, currentTimeSec, saveDocument]); - const updateTrimRange = useCallback( - async (trimId: string, startSec: number, endSec: number) => { - if (!document) return; - const s = finiteSec(startSec); - const e = finiteSec(endSec); - const next: AxcutDocument = { - ...document, - timeline: { - ...document.timeline, - trimRanges: document.timeline.trimRanges.map((r) => - r.id === trimId ? { ...r, startSec: Math.min(s, e), endSec: Math.max(s, e) } : r, - ), - }, - }; - await saveDocument(next); - }, - [document, saveDocument], - ); - // Like updateTrimRange but also re-attaches the trim to a (possibly // different) asset — needed when a trim is dragged across a clip boundary // onto a clip backed by another asset. Callers resolve the timeline span to @@ -741,114 +694,6 @@ export function useTimeline() { setMultiSelection([]); }, []); - const addClipBefore = useCallback( - async (assetId: string) => { - if (!document) return; - const asset = document.assets.find((a) => a.id === assetId); - if (!asset) return; - const duration = asset.durationSec ?? PLACEHOLDER_DURATION_SEC; - const newClip: AxcutDocument["timeline"]["clips"][number] = { - id: createId("clip"), - assetId, - sourceStartSec: 0, - sourceEndSec: duration, - timelineStartSec: 0, - timelineEndSec: duration, - wordRefs: [], - origin: "user", - reason: "Inserted before all clips", - }; - const shifted = document.timeline.clips.map((c) => ({ - ...c, - timelineStartSec: c.timelineStartSec + duration, - timelineEndSec: c.timelineEndSec + duration, - })); - const next: AxcutDocument = { - ...document, - timeline: { - ...document.timeline, - clips: [newClip, ...shifted], - }, - }; - await saveDocument(next); - }, - [document, saveDocument], - ); - - const addClipAfter = useCallback( - async (assetId: string) => { - if (!document) return; - const asset = document.assets.find((a) => a.id === assetId); - if (!asset) return; - const duration = asset.durationSec ?? PLACEHOLDER_DURATION_SEC; - const lastEnd = document.timeline.clips.at(-1)?.timelineEndSec ?? 0; - const newClip: AxcutDocument["timeline"]["clips"][number] = { - id: createId("clip"), - assetId, - sourceStartSec: 0, - sourceEndSec: duration, - timelineStartSec: lastEnd, - timelineEndSec: lastEnd + duration, - wordRefs: [], - origin: "user", - reason: "Inserted after all clips", - }; - const next: AxcutDocument = { - ...document, - timeline: { - ...document.timeline, - clips: [...document.timeline.clips, newClip], - }, - }; - await saveDocument(next); - }, - [document, saveDocument], - ); - - const editClip = useCallback( - async ( - clipId: string, - patch: Partial< - Pick< - AxcutDocument["timeline"]["clips"][number], - "sourceStartSec" | "sourceEndSec" | "timelineStartSec" | "timelineEndSec" - > - >, - ) => { - if (!document) return; - // ponytail: clamp negative values and keep end >= start so the schema - // refine doesn't reject the save. Swap when end < start instead of - // throwing — a user typing into a number input is expected to be - // able to type in any order. - const next: AxcutDocument["timeline"]["clips"][number] = { - ...(document.timeline.clips.find((c) => c.id === clipId) as - | AxcutDocument["timeline"]["clips"][number] - | undefined), - } as AxcutDocument["timeline"]["clips"][number]; - if (!next?.id) return; - const sStart = finiteSec(patch.sourceStartSec ?? next.sourceStartSec); - const sEnd = finiteSec(patch.sourceEndSec ?? next.sourceEndSec ?? 0); - const tStart = finiteSec(patch.timelineStartSec ?? next.timelineStartSec); - const tEnd = finiteSec(patch.timelineEndSec ?? next.timelineEndSec); - const updated: AxcutDocument["timeline"]["clips"][number] = { - ...next, - sourceStartSec: Math.min(sStart, sEnd), - sourceEndSec: Math.max(sStart, sEnd), - timelineStartSec: Math.min(tStart, tEnd), - timelineEndSec: Math.max(tStart, tEnd), - }; - const nextDoc: AxcutDocument = { - ...document, - timeline: { - ...document.timeline, - clips: document.timeline.clips.map((c) => (c.id === clipId ? updated : c)), - }, - }; - await saveDocument(nextDoc); - }, - [document, saveDocument], - ); - // Axcut-consistent clip trim: only the source range is user-editable (the // Edit Clip dialog's draggable track). Changing it changes the clip's // effective duration, so every clip is resequenced back-to-back afterward — @@ -883,72 +728,6 @@ export function useTimeline() { [document, saveDocument], ); - const splitAndInsert = useCallback( - async (assetId: string, splitTimeSec: number) => { - if (!document) return; - const asset = document.assets.find((a) => a.id === assetId); - if (!asset) return; - const duration = asset.durationSec ?? PLACEHOLDER_DURATION_SEC; - const targetIdx = document.timeline.clips.findIndex( - (c) => c.timelineStartSec <= splitTimeSec && c.timelineEndSec >= splitTimeSec, - ); - if (targetIdx === -1) { - await addClipAfter(assetId); - return; - } - const target = document.timeline.clips[targetIdx]; - const left = { - id: createId("clip"), - assetId: target.assetId, - sourceStartSec: target.sourceStartSec, - sourceEndSec: splitTimeSec, - timelineStartSec: target.timelineStartSec, - timelineEndSec: splitTimeSec, - wordRefs: [] as string[], - origin: "user" as const, - reason: "Split left", - }; - const insert = { - id: createId("clip"), - assetId, - sourceStartSec: 0, - sourceEndSec: duration, - timelineStartSec: splitTimeSec, - timelineEndSec: splitTimeSec + duration, - wordRefs: [] as string[], - origin: "user" as const, - reason: "Inserted between splits", - }; - const right = { - id: createId("clip"), - assetId: target.assetId, - sourceStartSec: target.sourceStartSec + splitTimeSec - target.timelineStartSec, - sourceEndSec: target.sourceEndSec, - timelineStartSec: splitTimeSec + duration, - timelineEndSec: target.timelineEndSec + duration, - wordRefs: [] as string[], - origin: "user" as const, - reason: "Split right", - }; - const oldClips = document.timeline.clips; - const nextClips: AxcutDocument["timeline"]["clips"] = [ - ...oldClips.slice(0, targetIdx), - left, - insert, - right as (typeof document.timeline.clips)[number], - ...oldClips.slice(targetIdx + 1), - ]; - const newClips = resequenceClips(nextClips); - const next: AxcutDocument = { - ...document, - timeline: { ...document.timeline, clips: newClips }, - }; - const finalDoc = rederiveRegionMs(next, newClips); - await saveDocument(finalDoc); - }, - [document, saveDocument, addClipAfter], - ); - // Background probe: read the asset's actual duration and patch the // freshly-inserted clip to use it. Trims if the clip has already been // trimmed (sourceEndSec != PLACEHOLDER_DURATION_SEC) so we never stomp @@ -1076,8 +855,8 @@ export function useTimeline() { // Reorder a clip to a new index, then resequence timeline positions. // Delegates to the shared document/timeline.ts implementation — the same // function the agent tool-executor uses for "move_clip" ops — so both - // paths bump preview.revision consistently instead of maintaining two - // copies of the splice/resequence logic that could drift. + // paths stay in step instead of maintaining two copies of the + // splice/resequence logic that could drift. const moveClip = useCallback( async (clipId: string, toIndex: number) => { if (!document) return; @@ -1115,7 +894,6 @@ export function useTimeline() { ); const selectClip = useCallback((id: string) => setClipSelection(id), []); - const clearClipSelection = useCallback(() => setClipSelection(null), []); const speedRegions = hasDoc ? (((document.legacyEditor as Record | null)?.speedRegions as Array<{ @@ -1150,7 +928,6 @@ export function useTimeline() { addZoom, addZoomsBulk, addTrim, - addTrimAt, addAnnotation, addSpeed, addCameraFullscreen, @@ -1158,19 +935,13 @@ export function useTimeline() { removeRegions, selectRegion, clearSelection, - addClipBefore, - addClipAfter, - editClip, updateClipSourceRange, updateClipCrop, - splitAndInsert, insertClipAt, moveClip, duplicateClip, removeClip, selectClip, - clearClipSelection, - updateTrimRange, updateTrim, setTrimEntries, updateZoomSpan, diff --git a/src/native/browserShim.ts b/src/native/browserShim.ts index fbe2106eb..c86d512fe 100644 --- a/src/native/browserShim.ts +++ b/src/native/browserShim.ts @@ -353,10 +353,6 @@ function createShimBridgeClient() { annotations: [], zoomRanges: [], legacyEditor: null, - agent: { pendingQuestions: [], suggestions: [], lastAppliedOperations: [] }, - preview: { strategy: "seek", revision: 0 }, - export: { preset: "final-balanced", lastJobId: null }, - history: { revisions: [] }, }; documentsByProject[doc.project.id] = doc; projectOrder.unshift(doc.project.id); diff --git a/src/native/sceneDescription.test.ts b/src/native/sceneDescription.test.ts index 602ecc80c..e6218ff3d 100644 --- a/src/native/sceneDescription.test.ts +++ b/src/native/sceneDescription.test.ts @@ -85,10 +85,6 @@ function makeDoc( annotations: overrides.annotations ?? [], zoomRanges: overrides.zoomRanges ?? [], legacyEditor: overrides.legacyEditor ?? null, - agent: { pendingQuestions: [], suggestions: [], lastAppliedOperations: [] }, - preview: { strategy: "seek", revision: 0 }, - export: { preset: "final-balanced", lastJobId: null }, - history: { revisions: [] }, }; }