From 3dec995bec4888e35b61e7280f42c38b62ecc0b4 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Sun, 26 Jul 2026 20:41:22 +0200 Subject: [PATCH 1/6] refactor(schema): drop the four document fields nothing ever read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit document.agent, .preview, .export and .history were declared, defaulted, seeded in every test fixture and written on every edit — and read by nothing. preview.revision in particular was bumped at eight sites and consumed only by tests asserting the bump happened. Removes those fields, the ten schemas behind them (pendingQuestion, preview, exportState, suggestion, agentState, operation, revision, plus the three input schemas with no callers) and their inferred types. timelineOperationSchema STAYS: the review called it dead, but AxcutTimelineOperation has 19 call sites. operationSchema only extended it with suggestion ops nothing dispatches, so that one does go. Old saved projects are unaffected: documentSchema is not strict, so zod strips the now-unknown keys on parse. schema/index.ts 709 -> 604 lines. tsc clean; 95 files / 1114 tests pass. --- .../ai-edition/CursorPreviewLayer.test.tsx | 4 - .../ai-edition/EditorEmptyState.test.tsx | 4 - .../ai-edition/ExportDialog.test.ts | 4 - .../ai-edition/WebcamOverlay.test.tsx | 4 - src/lib/ai-edition/captions/captions.test.ts | 4 - src/lib/ai-edition/document/migrate.ts | 3 - .../ai-edition/document/operations.test.ts | 1 - src/lib/ai-edition/document/operations.ts | 4 - .../ai-edition/document/outputFormat.test.ts | 4 - src/lib/ai-edition/document/timeline.test.ts | 23 +--- src/lib/ai-edition/document/timeline.ts | 12 --- .../ai-edition/document/transcribe.test.ts | 1 - src/lib/ai-edition/document/transcribe.ts | 1 - src/lib/ai-edition/schema/index.test.ts | 1 - src/lib/ai-edition/schema/index.ts | 100 ------------------ src/lib/ai-edition/store/projectStore.test.ts | 4 - src/lib/ai-edition/store/projectStore.ts | 1 - src/lib/ai-edition/store/useTimeline.test.ts | 4 - src/native/browserShim.ts | 4 - src/native/sceneDescription.test.ts | 4 - 20 files changed, 4 insertions(+), 183 deletions(-) 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/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/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.ts b/src/lib/ai-edition/document/migrate.ts index 90154715f..b9c1e4e33 100644 --- a/src/lib/ai-edition/document/migrate.ts +++ b/src/lib/ai-edition/document/migrate.ts @@ -233,9 +233,6 @@ export function migrateProjectDataToAxcutDocument( suggestions: [], lastAppliedOperations: [], }, - preview: { strategy: "seek", revision: 0 }, - export: { preset: "final-balanced", lastJobId: null }, - history: { revisions: [] }, }; return documentSchema.parse(draft); 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..b5f3e5521 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); } diff --git a/src/lib/ai-edition/document/transcribe.test.ts b/src/lib/ai-edition/document/transcribe.test.ts index bde444601..53d78e930 100644 --- a/src/lib/ai-edition/document/transcribe.test.ts +++ b/src/lib/ai-edition/document/transcribe.test.ts @@ -50,7 +50,6 @@ function makeDoc(): AxcutDocument { 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/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..836b6ea8c 100644 --- a/src/lib/ai-edition/store/useTimeline.test.ts +++ b/src/lib/ai-edition/store/useTimeline.test.ts @@ -83,10 +83,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("useTimeline.editClip", () => { 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: [] }, }; } From c829f4f70688c7e21d5dc86a72fcc01e55192e38 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Sun, 26 Jul 2026 20:49:44 +0200 Subject: [PATCH 2/6] refactor(llm): one provider stack, and a real Copilot token swap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit llm-call.ts opened with "no LangChain dependency" and hand-rolled a multi-provider fetch/SSE client — OpenAI-compat, Anthropic, Codex and Copilot dialects, 736 lines — while the live agent loop went through LangChain the whole time (chat-service.ts -> deep-agent/service.ts). Its only two remaining callers were one-shot prompt->text calls: chat compaction and caption translation. Both now use createOpenScreenChatModel(...).invoke(), which already knew every provider. Removes llm-call.ts, codex-session.ts (a bespoke chatgpt.com Responses dialect reachable only from it) and agent-provider-capabilities.ts, whose per-provider reasoning-effort matrix was duplicated inside deep-agent/. The Copilot runtime-token swap is preserved, and this fixes it rather than porting it: chat-model.ts previously used the PAT directly with a comment saying the refresh "can land as a follow-up", so the MAIN agent path was the broken one. It now exchanges the PAT for the short-lived runtime token, honours the base URL that exchange returns, and sends the editor-identifying headers the endpoint requires. Every caller benefits, not just the two being rerouted. extractDelta (MessageContent -> text) is exported from deep-agent/service rather than re-implemented. tsc clean; 93 files / 1082 tests pass (the delta is the two deleted test files, which covered the deleted code). --- .../agent-provider-capabilities.test.ts | 144 ---- .../ai-edition/agent-provider-capabilities.ts | 313 -------- electron/ai-edition/caption-translate.ts | 42 +- electron/ai-edition/chat-service.ts | 37 +- electron/ai-edition/codex-session.test.ts | 184 ----- electron/ai-edition/codex-session.ts | 462 ----------- electron/ai-edition/deep-agent/chat-model.ts | 24 +- electron/ai-edition/deep-agent/service.ts | 4 +- electron/ai-edition/llm-call.ts | 736 ------------------ electron/ai-edition/llm-provider-auth.ts | 6 +- electron/ai-edition/provider-registry.ts | 5 +- 11 files changed, 64 insertions(+), 1893 deletions(-) delete mode 100644 electron/ai-edition/agent-provider-capabilities.test.ts delete mode 100644 electron/ai-edition/agent-provider-capabilities.ts delete mode 100644 electron/ai-edition/codex-session.test.ts delete mode 100644 electron/ai-edition/codex-session.ts delete mode 100644 electron/ai-edition/llm-call.ts 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..81f3639eb 100644 --- a/electron/ai-edition/caption-translate.ts +++ b/electron/ai-edition/caption-translate.ts @@ -11,7 +11,9 @@ // 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 } from "./deep-agent/chat-model"; +import { extractDelta } from "./deep-agent/service"; /** One transcript segment to translate. */ export interface CaptionTranslateSegment { @@ -134,33 +136,29 @@ 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 model = await createOpenScreenChatModel({ + provider: options.provider, + model: options.model, + apiKey: options.apiKey, + baseUrl: options.baseUrl, + reasoningEffort: options.reasoningEffort, + accountId: options.accountId, + }); + const result = await model.invoke( + [ + new SystemMessage(SYSTEM_PROMPT), + new HumanMessage(buildUserPrompt(batch, options.targetLanguage, options.sourceLanguage)), + ], + { signal: options.signal }, ); - if (!result.success) { + reply = extractDelta(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..25b53f5c7 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,24 @@ 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}`), - }, - ); - if (!result.success || !result.content) { + const { createOpenScreenChatModel } = await import("./deep-agent/chat-model"); + const { extractDelta } = await import("./deep-agent/service"); + const chatModel = await createOpenScreenChatModel({ + provider, + model, + apiKey, + baseUrl, + reasoningEffort, + accountId, + }); + const result = await chatModel.invoke([ + new SystemMessage(COMPACTION_SYSTEM_PROMPT), + new HumanMessage(prompt), + ]); + summary = extractDelta(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/chat-model.ts b/electron/ai-edition/deep-agent/chat-model.ts index eb900e8a3..d62d0d754 100644 --- a/electron/ai-edition/deep-agent/chat-model.ts +++ b/electron/ai-edition/deep-agent/chat-model.ts @@ -8,6 +8,12 @@ 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 { buildLangChainReasoningOptions, shouldDisableModelStreamingForToolCalling, @@ -116,14 +122,22 @@ async function createLocalProviderChatModel( }, }); 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..5f0f99b85 100644 --- a/electron/ai-edition/deep-agent/service.ts +++ b/electron/ai-edition/deep-agent/service.ts @@ -297,7 +297,9 @@ export async function invokeOpenScreenAgent(args: InvokeArgs): Promise; -} - -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.", From 5eeb883e2ccc7d31b0d1d6722bfb348c3bb0879c Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Sun, 26 Jul 2026 21:02:20 +0200 Subject: [PATCH 3/6] refactor(store): drop seven timeline actions with no callers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit addClipBefore, addClipAfter, editClip, splitAndInsert, addTrimAt, updateTrimRange and clearClipSelection were defined, memoised and returned from useTimeline, and called by nothing. insertClipAt, setTrimEntries and updateClipSourceRange already cover every UI path. editClip had its own test suite, which is why it looked alive. The live Edit Clip modal does not use it: NewEditorShell.tsx:1316 calls tl.updateClipSourceRange + tl.updateClipCrop. The tests went with the action. Also removes RightPanes' TimelinePaneBody and the RightPaneId type, both exported and unreferenced — FloatingInspector's Facet union replaced the latter. useTimeline.ts 1192 -> 970 lines. tsc clean; 93 files / 1079 tests pass. --- src/components/ai-edition/RightPanes.tsx | 26 --- src/lib/ai-edition/store/useTimeline.test.ts | 65 ------ src/lib/ai-edition/store/useTimeline.ts | 229 ------------------- 3 files changed, 320 deletions(-) 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/lib/ai-edition/store/useTimeline.test.ts b/src/lib/ai-edition/store/useTimeline.test.ts index 836b6ea8c..376c49376 100644 --- a/src/lib/ai-edition/store/useTimeline.test.ts +++ b/src/lib/ai-edition/store/useTimeline.test.ts @@ -85,71 +85,6 @@ const sampleDoc = { legacyEditor: null, }; -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..48e74fb6d 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 @@ -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, From a71ea2ea0e792a508d8710f97779f806a06659a4 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Sun, 26 Jul 2026 22:00:23 +0200 Subject: [PATCH 4/6] =?UTF-8?q?fix(llm):=20finish=20the=20reroute=20?= =?UTF-8?q?=E2=80=94=20Codex=20account=20header,=20one=20model=20per=20run?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on the llm-call.ts removal. The reroute to createOpenScreenChatModel was right, but three things did not survive it. `accountId` was threaded from both new call sites into OpenScreenChatModelConfig and then read by nobody. The deleted llm-call.ts set `chatgpt-account-id` from it and called the header required by chatgpt.com/backend-api, so caption translation and chat compaction were newly broken for Codex accounts — the same bug class the Copilot fix addressed, pointed the other way. tsc cannot see it: an optional field that nothing reads still typechecks. caption-translate built a model per batch. For Copilot that is a PAT -> runtime-token exchange against api.github.com on every iteration; at batch size 40 a 400-segment transcript paid for ten of them. Built once, above the loop, with the construction failure kept in the same result shape. extractDelta is now messageContentToText in chat-model.ts. It was exported from deep-agent/service.ts, whose top level pulls `langchain` and the whole agent-tools graph — a lot to import for ten lines of content flattening. chat-service.ts worked around it with a dynamic import; caption-translate.ts did not. Both now import it statically with nothing dragged along. Also normalizes the provider once at the createOpenScreenChatModel entry point. The deleted agent-provider-capabilities.ts resolved `claude` -> anthropic and `gemini` -> google and had tests for it; the surviving deep-agent copy compares raw strings, so those aliases stopped resolving and normalizeProviderId was left with no callers at all. And the surviving copy was not the equivalent duplicate the deletion assumed: isOpenRouterReasoningModel strips the `anthropic/` slug prefix but not `openai/`, and both vendor matchers are anchored at the start — so openrouter + openai/gpt-5 silently got no reasoning wiring. The deleted file stripped both and its deleted test asserted it. Adds the tests back for the module that survived, plus runtime checks for the account header and the alias routing. --- electron/ai-edition/caption-translate.ts | 35 ++++--- electron/ai-edition/chat-service.ts | 7 +- .../agent-provider-capabilities.test.ts | 96 ++++++++++++++++++ .../deep-agent/agent-provider-capabilities.ts | 18 ++-- .../ai-edition/deep-agent/chat-model.test.ts | 98 +++++++++++++++++++ electron/ai-edition/deep-agent/chat-model.ts | 41 +++++++- electron/ai-edition/deep-agent/service.ts | 30 ++---- 7 files changed, 281 insertions(+), 44 deletions(-) create mode 100644 electron/ai-edition/deep-agent/agent-provider-capabilities.test.ts create mode 100644 electron/ai-edition/deep-agent/chat-model.test.ts diff --git a/electron/ai-edition/caption-translate.ts b/electron/ai-edition/caption-translate.ts index 81f3639eb..02706aff4 100644 --- a/electron/ai-edition/caption-translate.ts +++ b/electron/ai-edition/caption-translate.ts @@ -12,8 +12,7 @@ // and shows the original text, which is the honest fallback for a partial run. import { HumanMessage, SystemMessage } from "@langchain/core/messages"; -import { createOpenScreenChatModel } from "./deep-agent/chat-model"; -import { extractDelta } from "./deep-agent/service"; +import { createOpenScreenChatModel, messageContentToText } from "./deep-agent/chat-model"; /** One transcript segment to translate. */ export interface CaptionTranslateSegment { @@ -129,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." }; @@ -136,14 +157,6 @@ export async function translateCaptionSegments( const batch = segments.slice(i, i + batchSize); let reply = ""; try { - const model = await createOpenScreenChatModel({ - provider: options.provider, - model: options.model, - apiKey: options.apiKey, - baseUrl: options.baseUrl, - reasoningEffort: options.reasoningEffort, - accountId: options.accountId, - }); const result = await model.invoke( [ new SystemMessage(SYSTEM_PROMPT), @@ -151,7 +164,7 @@ export async function translateCaptionSegments( ], { signal: options.signal }, ); - reply = extractDelta(result.content); + reply = messageContentToText(result.content); if (!reply) { return { success: false, diff --git a/electron/ai-edition/chat-service.ts b/electron/ai-edition/chat-service.ts index 25b53f5c7..d714258c6 100644 --- a/electron/ai-edition/chat-service.ts +++ b/electron/ai-edition/chat-service.ts @@ -695,8 +695,9 @@ async function tryCompactSession(opts: { const prompt = buildCompactionPrompt(oldMessages); let summary = ""; try { - const { createOpenScreenChatModel } = await import("./deep-agent/chat-model"); - const { extractDelta } = await import("./deep-agent/service"); + const { createOpenScreenChatModel, messageContentToText } = await import( + "./deep-agent/chat-model" + ); const chatModel = await createOpenScreenChatModel({ provider, model, @@ -709,7 +710,7 @@ async function tryCompactSession(opts: { new SystemMessage(COMPACTION_SYSTEM_PROMPT), new HumanMessage(prompt), ]); - summary = extractDelta(result.content); + summary = messageContentToText(result.content); if (!summary) { return null; } 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 d62d0d754..3d9ddf7a4 100644 --- a/electron/ai-edition/deep-agent/chat-model.ts +++ b/electron/ai-edition/deep-agent/chat-model.ts @@ -14,6 +14,7 @@ import { GITHUB_COPILOT_PLUGIN_VERSION, GITHUB_COPILOT_USER_AGENT, } from "../llm-provider-auth"; +import { normalizeProviderId } from "../provider-registry"; import { buildLangChainReasoningOptions, shouldDisableModelStreamingForToolCalling, @@ -37,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, @@ -114,11 +145,19 @@ 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": { diff --git a/electron/ai-edition/deep-agent/service.ts b/electron/ai-edition/deep-agent/service.ts index 5f0f99b85..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; From d8f48706bf67ddaac4b2cbe45a3c274069f01ced Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Sun, 26 Jul 2026 22:00:42 +0200 Subject: [PATCH 5/6] chore(schema): finish the four-field removal the fixtures missed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit migrate.ts still built `agent: {pendingQuestions, suggestions, lastAppliedOperations}` on every migrated project. zod strips it, so it was harmless — and it was in shipped code, not a fixture. transcribe.test.ts still carried agent/preview/export, including a `preview.playbackRate` that was never in the schema. These survived because tsconfig.json excludes **/*.test.ts(x) and its `include` is ["src", "electron"] — no test file and nothing under tests/ is typechecked, and vitest transpiles without checking. So the gate the deletion leaned on could not see the fixtures it rewrote by hand. Also drops the comments still describing `preview.revision`, a field that no longer exists. (The roadmap-coverage.spec.ts fixture this originally also fixed is gone: release/1.8.0 deleted that spec in the v4-shell e2e retarget.) --- src/lib/ai-edition/document/migrate.ts | 5 ----- src/lib/ai-edition/document/timeline.ts | 7 +++---- src/lib/ai-edition/document/transcribe.test.ts | 3 --- src/lib/ai-edition/store/useTimeline.ts | 4 ++-- 4 files changed, 5 insertions(+), 14 deletions(-) diff --git a/src/lib/ai-edition/document/migrate.ts b/src/lib/ai-edition/document/migrate.ts index b9c1e4e33..c1be6efe5 100644 --- a/src/lib/ai-edition/document/migrate.ts +++ b/src/lib/ai-edition/document/migrate.ts @@ -228,11 +228,6 @@ export function migrateProjectDataToAxcutDocument( annotations: migratedAnnotations, zoomRanges: migratedZoomRanges, legacyEditor, - agent: { - pendingQuestions: [], - suggestions: [], - lastAppliedOperations: [], - }, }; return documentSchema.parse(draft); diff --git a/src/lib/ai-edition/document/timeline.ts b/src/lib/ai-edition/document/timeline.ts index b5f3e5521..d8f85a7ac 100644 --- a/src/lib/ai-edition/document/timeline.ts +++ b/src/lib/ai-edition/document/timeline.ts @@ -537,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 @@ -574,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) { @@ -624,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 53d78e930..b943839eb 100644 --- a/src/lib/ai-edition/document/transcribe.test.ts +++ b/src/lib/ai-edition/document/transcribe.test.ts @@ -47,9 +47,6 @@ function makeDoc(): AxcutDocument { annotations: [], zoomRanges: [], legacyEditor: null, - agent: null, - preview: { revision: 0, playbackRate: 1 }, - export: null, }; } diff --git a/src/lib/ai-edition/store/useTimeline.ts b/src/lib/ai-edition/store/useTimeline.ts index 48e74fb6d..cf824b787 100644 --- a/src/lib/ai-edition/store/useTimeline.ts +++ b/src/lib/ai-edition/store/useTimeline.ts @@ -855,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; From b5a60d6b5bda902102ee0b5aa901748f707ad15f Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Sun, 26 Jul 2026 21:42:02 +0200 Subject: [PATCH 6/6] refactor(editor): drop the write-only showTrimWaveform setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The boolean was declared, defaulted, persisted, migrated and history-tracked across five files and read by nothing. Its only UI was RightPanes' TimelinePaneBody, which was already unreferenced before PR 3 deleted it — so the toggle had no way to be set and no effect if it was. Removes it from EditorSettingsSnapshot, DEFAULT_EDITOR_SETTINGS, LegacyShape, getEditorSettings, EditorSettingsPatch, DEFAULT_EDITOR_APPEARANCE_SETTINGS, ProjectEditorState, the projectPersistence parse branch, EditorState + INITIAL_EDITOR_STATE, and the v2 migration's editor envelope. The i18n keys go with it: settings.timeline.{title,waveform,help} in all 13 locales, not the 10 the note listed — vi, zh-CN and zh-TW were missed. Those three keys were the whole `settings.timeline` namespace and TimelinePaneBody was its only consumer; the separate top-level `timeline` namespace (FloatingInspector, V4Timeline) is untouched. Old projects keep working: migrate spreads input.editor wholesale into legacyEditor, and legacyEditorSchema is z.object({}).passthrough(), so a stored showTrimWaveform rides along inertly instead of failing the parse — and getEditorSettings reads legacyEditor through a loose shape check that ignores unknown keys. Pinned with a test rather than assumed, since tsconfig excludes test files and would not have caught a regression here. tsc clean; biome clean; 95 files / 1101 tests pass. --- src/components/video-editor/editorDefaults.ts | 2 -- .../video-editor/projectPersistence.ts | 5 ----- src/hooks/useEditorHistory.ts | 2 -- src/i18n/locales/ar/settings.json | 5 ----- src/i18n/locales/en/settings.json | 5 ----- src/i18n/locales/es/settings.json | 5 ----- src/i18n/locales/fr/settings.json | 5 ----- src/i18n/locales/it/settings.json | 5 ----- src/i18n/locales/ja-JP/settings.json | 5 ----- src/i18n/locales/ko-KR/settings.json | 5 ----- src/i18n/locales/pt-BR/settings.json | 5 ----- src/i18n/locales/ru/settings.json | 5 ----- src/i18n/locales/tr/settings.json | 5 ----- src/i18n/locales/vi/settings.json | 5 ----- src/i18n/locales/zh-CN/settings.json | 5 ----- src/i18n/locales/zh-TW/settings.json | 5 ----- src/lib/ai-edition/document/migrate.test.ts | 18 +++++++++++++++++- src/lib/ai-edition/document/migrate.ts | 1 - .../ai-edition/store/editorSettings.test.ts | 1 - src/lib/ai-edition/store/editorSettings.ts | 5 ----- 20 files changed, 17 insertions(+), 82 deletions(-) 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/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 c1be6efe5..d8ee6e40d 100644 --- a/src/lib/ai-edition/document/migrate.ts +++ b/src/lib/ai-edition/document/migrate.ts @@ -267,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/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;