Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions e2e/wiki-compose.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,9 +274,7 @@ test.describe("Wiki Compose P2 happy path", () => {

await page.goto(`/notes/${NOTE_ID}/${PAGE_ID}/compose`);

await page.getByTestId("compose-start").click();

// Brief interrupt — question card appears.
// Brief interrupt — question card appears (compose auto-starts from AI settings).
const briefCard = page.getByTestId(`brief-card-${BRIEF_QUESTION_ID}`);
await expect(briefCard).toBeVisible();
await expect(page.getByText("What's the audience for this article?")).toBeVisible();
Expand Down
120 changes: 0 additions & 120 deletions src/components/wikiCompose/ComposeBackendSelector.tsx

This file was deleted.

88 changes: 88 additions & 0 deletions src/hooks/useInitialComposeBackend.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/**
* `useInitialComposeBackend` unit tests (#951).
*/
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react";

const mocks = vi.hoisted(() => ({
loadAISettings: vi.fn(),
fetchUserAiCredentialsStatus: vi.fn(),
}));

vi.mock("@/lib/aiSettings", async () => {
const actual = await vi.importActual<typeof import("@/lib/aiSettings")>("@/lib/aiSettings");
return {
...actual,
loadAISettings: mocks.loadAISettings,
AI_SETTINGS_CHANGED_EVENT: "zedi-ai-settings-changed",
};
});

vi.mock("@/lib/userAiCredentials", () => ({
fetchUserAiCredentialsStatus: mocks.fetchUserAiCredentialsStatus,
}));

import { AI_SETTINGS_CHANGED_EVENT } from "@/lib/aiSettings";
import { DEFAULT_AI_SETTINGS } from "@/types/ai";
import { useInitialComposeBackend } from "./useInitialComposeBackend";

const CREDENTIALS_NONE = {
storageEnabled: false,
providers: [
{ provider: "anthropic" as const, configured: false },
{ provider: "openai" as const, configured: false },
{ provider: "google" as const, configured: false },
],
};

describe("useInitialComposeBackend", () => {
beforeEach(() => {
mocks.loadAISettings.mockReset();
mocks.fetchUserAiCredentialsStatus.mockReset();
mocks.loadAISettings.mockResolvedValue(DEFAULT_AI_SETTINGS);
mocks.fetchUserAiCredentialsStatus.mockResolvedValue(CREDENTIALS_NONE);
});

afterEach(() => {
vi.useRealTimers();
});

it("resolves backend from AI settings on mount", async () => {
const { result } = renderHook(() => useInitialComposeBackend());

await waitFor(() => expect(result.current.isResolved).toBe(true));
expect(result.current.backend).toBe("zedi_managed");
});

it("marks resolved when a settings-changed load finishes after the initial load", async () => {
let resolveInitial: (value: typeof DEFAULT_AI_SETTINGS) => void = () => undefined;
mocks.loadAISettings.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveInitial = resolve;
}),
);

const { result } = renderHook(() => useInitialComposeBackend());
expect(result.current.isResolved).toBe(false);

await act(async () => {
window.dispatchEvent(new CustomEvent(AI_SETTINGS_CHANGED_EVENT));
});

await act(async () => {
resolveInitial(DEFAULT_AI_SETTINGS);
await Promise.resolve();
});

await waitFor(() => expect(result.current.isResolved).toBe(true));
expect(result.current.backend).toBe("zedi_managed");
});

it("skips loading when disabled", async () => {
const { result } = renderHook(() => useInitialComposeBackend({ enabled: false }));

expect(result.current.isResolved).toBe(true);
expect(mocks.loadAISettings).not.toHaveBeenCalled();
});
});
39 changes: 17 additions & 22 deletions src/hooks/useInitialComposeBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* Initializes Wiki Compose backend from AI settings (#951).
* Wiki Compose の backend を設定画面の AI 設定から初期化する。
*/
import { useEffect, useState, type Dispatch, type SetStateAction } from "react";
import { useEffect, useState } from "react";
import { loadAISettings, AI_SETTINGS_CHANGED_EVENT } from "@/lib/aiSettings";
import { fetchUserAiCredentialsStatus } from "@/lib/userAiCredentials";
import { resolveComposeBackendFromAiSettings } from "@/lib/wikiCompose/resolveComposeBackend";
Expand Down Expand Up @@ -35,45 +35,46 @@ export interface UseInitialComposeBackendOptions {

export interface UseInitialComposeBackendResult {
backend: ComposeExecutionBackend;
setBackend: Dispatch<SetStateAction<ComposeExecutionBackend>>;
/** False until the first settings-based resolution finishes (when `enabled`). */
isResolved: boolean;
}

/**
* Returns backend state synced from AI settings until the user changes it or `enabled` is false.
* AI 設定と同期した backend。ユーザーが変更するか `enabled` が false になるまで追従する
* Returns backend state synced from AI settings while `enabled` is true.
* `enabled` が true の間、AI 設定と同期した backend を返す
*/
export function useInitialComposeBackend(
options: UseInitialComposeBackendOptions = {},
): UseInitialComposeBackendResult {
const { enabled = true } = options;
const [backend, setBackend] = useState<ComposeExecutionBackend>("zedi_managed");
const [userOverrode, setUserOverrode] = useState(false);
const [settingsSynced, setSettingsSynced] = useState(!enabled);

const isResolved = !enabled || userOverrode || settingsSynced;
const [isResolved, setIsResolved] = useState(!enabled);

useEffect(() => {
if (!enabled || userOverrode) return;
if (!enabled) {
setIsResolved(true);
return;
}

let cancelled = false;
let loadGeneration = 0;

const applyFromSettings = (markSynced: boolean) => {
const applyFromSettings = () => {
const generation = ++loadGeneration;
void loadComposeBackendFromSettings().then((resolved) => {
if (cancelled || generation !== loadGeneration) return;
setBackend(resolved);
if (markSynced) setSettingsSynced(true);
// Always unblock compose auto-start on the latest successful load.
// 最新の読み込みが成功したら常に resolved にする(設定変更イベントとの競合対策)。
setIsResolved(true);
});
};

applyFromSettings(true);
setIsResolved(false);
applyFromSettings();

const onSettingsChanged = () => {
if (userOverrode) return;
applyFromSettings(false);
applyFromSettings();
};

window.addEventListener(AI_SETTINGS_CHANGED_EVENT, onSettingsChanged);
Expand All @@ -82,13 +83,7 @@ export function useInitialComposeBackend(
loadGeneration += 1;
window.removeEventListener(AI_SETTINGS_CHANGED_EVENT, onSettingsChanged);
};
}, [enabled, userOverrode]);

const setBackendWithOverride: Dispatch<SetStateAction<ComposeExecutionBackend>> = (value) => {
setUserOverrode(true);
setSettingsSynced(true);
setBackend(value);
};
}, [enabled]);

return { backend, setBackend: setBackendWithOverride, isResolved };
return { backend, isResolved };
}
3 changes: 1 addition & 2 deletions src/i18n/locales/en/wikiCompose.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@
"back": "Back",
"sessionPrefix": "session:",
"close": "Close",
"cancel": "Cancel",
"startCompose": "Start compose"
"cancel": "Cancel"
},
"phase": {
"brief": "Brief",
Expand Down
3 changes: 1 addition & 2 deletions src/i18n/locales/ja/wikiCompose.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@
"back": "戻る",
"sessionPrefix": "セッション:",
"close": "閉じる",
"cancel": "キャンセル",
"startCompose": "Compose を開始"
"cancel": "キャンセル"
},
"phase": {
"brief": "ブリーフ",
Expand Down
41 changes: 0 additions & 41 deletions src/lib/wikiCompose/backends.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,49 +16,8 @@ export const COMPOSE_BACKEND_OPTIONS: readonly ComposeExecutionBackend[] = [
"user_google",
] as const;

export type ComposeBackendProvider = "anthropic" | "openai" | "google";

/** UI metadata for each backend option. */
export interface ComposeBackendOptionMeta {
id: ComposeExecutionBackend;
provider: ComposeBackendProvider | null;
labelKey: string;
descriptionKey: string;
}

export const COMPOSE_BACKEND_META: readonly ComposeBackendOptionMeta[] = [
{
id: "zedi_managed",
provider: null,
labelKey: "wikiCompose.backend.zediManaged",
descriptionKey: "wikiCompose.backend.zediManagedDesc",
},
{
id: "user_anthropic",
provider: "anthropic",
labelKey: "wikiCompose.backend.userAnthropic",
descriptionKey: "wikiCompose.backend.userAnthropicDesc",
},
{
id: "user_openai",
provider: "openai",
labelKey: "wikiCompose.backend.userOpenai",
descriptionKey: "wikiCompose.backend.userOpenaiDesc",
},
{
id: "user_google",
provider: "google",
labelKey: "wikiCompose.backend.userGoogle",
descriptionKey: "wikiCompose.backend.userGoogleDesc",
},
];

export function isUserByokComposeBackend(
backend: ComposeExecutionBackend,
): backend is Exclude<ComposeExecutionBackend, "zedi_managed"> {
return backend !== "zedi_managed";
}

export function usesZediCu(backend: ComposeExecutionBackend): boolean {
return backend === "zedi_managed";
}
Loading
Loading