From 99f2d6669176c92148eff1b5689a27b9bed85474 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 26 May 2026 02:00:58 +0000 Subject: [PATCH 1/3] feat(wiki-compose): add Japanese UI and localized LLM output - Pass contentLocale from the client and resolve via Accept-Language on the API - Append locale instructions to Brief, research, structure, and draft prompts - Localize conflict-resolution copy and structure fallback outlines - Wire wikiCompose i18n keys through the Compose split-screen UI Co-authored-by: Akimasa Sugai --- .../agents/core/composeLocale.test.ts | 41 +++++++ server/api/src/agents/core/composeLocale.ts | 102 ++++++++++++++++ .../api/src/agents/core/types/graphContext.ts | 4 + .../graphs/wikiCompose/nodes/briefDialogue.ts | 6 +- .../wikiCompose/nodes/conflictResolution.ts | 19 ++- .../graphs/wikiCompose/nodes/draftSections.ts | 6 +- .../wikiCompose/nodes/structureDialogue.ts | 17 +-- .../research/nodes/evaluateSufficiency.ts | 6 +- .../subgraphs/research/nodes/planQueries.ts | 6 +- .../subgraphs/research/nodes/refineQueries.ts | 6 +- .../research/nodes/shared/getGraphContext.ts | 7 +- server/api/src/routes/composeSessions.ts | 63 ++++++++-- .../wikiCompose/ActivitySection.tsx | 10 +- .../wikiCompose/BriefQuestionCard.tsx | 16 ++- .../wikiCompose/ConflictResolutionSection.tsx | 16 ++- .../wikiCompose/DialogueSection.tsx | 38 +++--- src/components/wikiCompose/EditorPane.tsx | 14 ++- src/components/wikiCompose/OutlineEditor.tsx | 20 ++-- src/components/wikiCompose/PhaseStepper.tsx | 17 ++- .../wikiCompose/ResearchSection.tsx | 55 +++++---- src/hooks/useWikiComposeSession.test.ts | 7 +- src/hooks/useWikiComposeSession.ts | 46 ++++++-- src/i18n/locales/en/wikiCompose.json | 111 ++++++++++++++++++ src/i18n/locales/ja/wikiCompose.json | 111 ++++++++++++++++++ .../resolveComposeContentLocale.test.ts | 21 ++++ .../resolveComposeContentLocale.ts | 13 ++ src/pages/WikiComposePage.tsx | 28 +++-- 27 files changed, 673 insertions(+), 133 deletions(-) create mode 100644 server/api/src/__tests__/agents/core/composeLocale.test.ts create mode 100644 server/api/src/agents/core/composeLocale.ts create mode 100644 src/lib/wikiCompose/resolveComposeContentLocale.test.ts create mode 100644 src/lib/wikiCompose/resolveComposeContentLocale.ts diff --git a/server/api/src/__tests__/agents/core/composeLocale.test.ts b/server/api/src/__tests__/agents/core/composeLocale.test.ts new file mode 100644 index 00000000..616ef7a8 --- /dev/null +++ b/server/api/src/__tests__/agents/core/composeLocale.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { + composeConflictRationale, + composeContentLocaleInstruction, + normalizeComposeContentLocale, + resolveComposeContentLocale, + stripContentLocaleFromGraphInput, + structureDialogueFallbackOutline, +} from "../../../agents/core/composeLocale.js"; + +describe("composeLocale", () => { + it("normalizes supported locales only", () => { + expect(normalizeComposeContentLocale("ja")).toBe("ja"); + expect(normalizeComposeContentLocale("en")).toBe("en"); + expect(normalizeComposeContentLocale("fr")).toBeNull(); + }); + + it("resolves from input then Accept-Language", () => { + expect(resolveComposeContentLocale({ contentLocale: "en" }, "ja-JP")).toBe("en"); + expect(resolveComposeContentLocale({}, "ja-JP,en;q=0.8")).toBe("ja"); + expect(resolveComposeContentLocale({}, "en-US,en;q=0.9")).toBe("en"); + expect(resolveComposeContentLocale({}, null, "ja")).toBe("ja"); + }); + + it("strips contentLocale from graph input", () => { + expect(stripContentLocaleFromGraphInput({ contentLocale: "ja", chatSeed: { x: 1 } })).toEqual({ + chatSeed: { x: 1 }, + }); + }); + + it("includes Japanese in locale instruction when ja", () => { + expect(composeContentLocaleInstruction("ja")).toMatch(/Japanese/); + expect(composeContentLocaleInstruction("en")).toMatch(/English/); + }); + + it("provides localized conflict rationale and fallback outline", () => { + expect(composeConflictRationale("ja")).toMatch(/却下/); + expect(structureDialogueFallbackOutline("ja")[0]?.heading).toBe("概要"); + expect(structureDialogueFallbackOutline("en")[0]?.heading).toBe("Overview"); + }); +}); diff --git a/server/api/src/agents/core/composeLocale.ts b/server/api/src/agents/core/composeLocale.ts new file mode 100644 index 00000000..75a71da4 --- /dev/null +++ b/server/api/src/agents/core/composeLocale.ts @@ -0,0 +1,102 @@ +/** + * Content locale for Wiki Compose LLM outputs (#950). + * Wiki Compose の生成言語(ユーザー向けテキスト)を表す。 + */ +export type ComposeContentLocale = "ja" | "en"; + +/** + * Normalize an arbitrary value to a supported compose content locale. + * 任意の入力をサポートされる compose 用ロケールに正規化する。 + */ +export function normalizeComposeContentLocale(raw: unknown): ComposeContentLocale | null { + if (raw === "ja" || raw === "en") return raw; + return null; +} + +/** + * Resolve locale from graph run input (`contentLocale`) with a server default. + * graph の run input(`contentLocale`)からロケールを解決する。 + */ +export function resolveComposeContentLocale( + input: unknown, + acceptLanguage: string | undefined | null, + fallback: ComposeContentLocale = "ja", +): ComposeContentLocale { + if (input && typeof input === "object" && "contentLocale" in input) { + const fromInput = normalizeComposeContentLocale( + (input as { contentLocale?: unknown }).contentLocale, + ); + if (fromInput) return fromInput; + } + if (acceptLanguage) { + const primary = acceptLanguage.split(",")[0]?.trim().toLowerCase() ?? ""; + if (primary.startsWith("ja")) return "ja"; + if (primary.startsWith("en")) return "en"; + } + return fallback; +} + +/** + * Strip `contentLocale` before passing input to LangGraph (not a state channel). + * LangGraph に渡す前に `contentLocale` を除去する(state チャネルではない)。 + */ +export function stripContentLocaleFromGraphInput(raw: unknown): unknown { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return raw ?? {}; + const { contentLocale: _removed, ...rest } = raw as Record; + return rest; +} + +/** + * Instruction appended to system prompts so questions, outlines, and drafts + * match the user's UI language. + * 質問・アウトライン・本文が UI 言語と一致するよう system prompt に付与する。 + */ +export function composeContentLocaleInstruction(locale: ComposeContentLocale): string { + if (locale === "ja") { + return ( + "\n\nLanguage: Write all user-facing text (questions, option labels, rationales, " + + "outline headings and intents, evaluation rationales, missing aspects, and article " + + "body) in Japanese. Use clear, natural Japanese suitable for a wiki article." + ); + } + return ( + "\n\nLanguage: Write all user-facing text (questions, option labels, rationales, " + + "outline headings and intents, evaluation rationales, missing aspects, and article " + + "body) in English." + ); +} + +/** + * Localized conflict-resolution rationale shown in the interrupt UI. + * interrupt UI に表示する矛盾解消用の説明文(ロケール別)。 + */ +export function composeConflictRationale(locale: ComposeContentLocale): string { + if (locale === "ja") { + return ( + "却下したソースと採用したソースが混在しています。採用したソースのセットで" + + "アウトライン生成に進んでよいか確認してください。" + ); + } + return ( + "Multiple sources were rejected while others were kept. Confirm you want to proceed " + + "with the approved set before generating the outline." + ); +} + +/** Default outline rows when structure LLM fails (locale-specific headings). */ +export function structureDialogueFallbackOutline( + locale: ComposeContentLocale, +): Array<{ heading: string; depth: 1; intent: string }> { + if (locale === "ja") { + return [ + { heading: "概要", depth: 1, intent: "トピックの簡潔な導入。" }, + { heading: "要点", depth: 1, intent: "主要な事実と背景。" }, + { heading: "参考", depth: 1, intent: "出典と関連情報。" }, + ]; + } + return [ + { heading: "Overview", depth: 1, intent: "Brief introduction to the topic." }, + { heading: "Key points", depth: 1, intent: "Main facts and context." }, + { heading: "References", depth: 1, intent: "Sources and further reading." }, + ]; +} diff --git a/server/api/src/agents/core/types/graphContext.ts b/server/api/src/agents/core/types/graphContext.ts index a17572bd..3eae3e8c 100644 --- a/server/api/src/agents/core/types/graphContext.ts +++ b/server/api/src/agents/core/types/graphContext.ts @@ -10,6 +10,7 @@ * identifiers required by `ZediChatModel` for usage attribution. */ import type { Database, UserTier } from "../../../types/index.js"; +import type { ComposeContentLocale } from "../composeLocale.js"; import type { ExecutionBackend } from "./executionBackend.js"; /** @@ -31,6 +32,8 @@ import type { ExecutionBackend } from "./executionBackend.js"; * Executing user's email; used by `wikiSearchService` to * apply the `note_domain_access` predicate without an * extra DB roundtrip per tool call. + * @property contentLocale 生成テキストの言語(`ja` | `en`)。UI / Accept-Language から解決。 + * Language for LLM-generated user-facing text (`ja` | `en`). */ export interface GraphContext { threadId: string; @@ -43,6 +46,7 @@ export interface GraphContext { db: Database; feature: string; userEmail: string | null; + contentLocale: ComposeContentLocale; } /** diff --git a/server/api/src/agents/graphs/wikiCompose/nodes/briefDialogue.ts b/server/api/src/agents/graphs/wikiCompose/nodes/briefDialogue.ts index 7b5e8df8..263a25f6 100644 --- a/server/api/src/agents/graphs/wikiCompose/nodes/briefDialogue.ts +++ b/server/api/src/agents/graphs/wikiCompose/nodes/briefDialogue.ts @@ -14,6 +14,7 @@ import type { LangGraphRunnableConfig } from "@langchain/langgraph"; import { randomUUID } from "node:crypto"; import { z } from "zod"; +import { composeContentLocaleInstruction } from "../../../core/composeLocale.js"; import { createZediChatModel } from "../../../core/llm/modelFactory.js"; import { resolveComposeModelId } from "../../../core/llm/resolveComposeModelId.js"; import { getGraphContext } from "../../../subgraphs/research/nodes/shared/getGraphContext.js"; @@ -135,7 +136,10 @@ export async function briefDialogue( let briefDegraded = false; try { raw = await structured.invoke([ - { role: "system", content: SYSTEM_PROMPT }, + { + role: "system", + content: SYSTEM_PROMPT + composeContentLocaleInstruction(ctx.contentLocale), + }, { role: "user", content: buildUserPrompt(snapshot.title, snapshot.body, state.chatSeed), diff --git a/server/api/src/agents/graphs/wikiCompose/nodes/conflictResolution.ts b/server/api/src/agents/graphs/wikiCompose/nodes/conflictResolution.ts index 36a27d2d..8fe2b290 100644 --- a/server/api/src/agents/graphs/wikiCompose/nodes/conflictResolution.ts +++ b/server/api/src/agents/graphs/wikiCompose/nodes/conflictResolution.ts @@ -8,18 +8,24 @@ */ import type { LangGraphRunnableConfig } from "@langchain/langgraph"; import { interrupt } from "@langchain/langgraph"; +import { + composeConflictRationale, + type ComposeContentLocale, +} from "../../../core/composeLocale.js"; +import { getGraphContext } from "../../../subgraphs/research/nodes/shared/getGraphContext.js"; import { conflictResumeSchema } from "../resumeSchemas.js"; import type { WikiComposeStateType, WikiComposeStateUpdate } from "../state.js"; import type { ResearchConflictSummary, WikiComposeInterruptPayload } from "../types.js"; import { shouldResolveResearchConflicts } from "../routing.js"; -function buildConflictSummary(state: WikiComposeStateType): ResearchConflictSummary { +function buildConflictSummary( + state: WikiComposeStateType, + locale: ComposeContentLocale, +): ResearchConflictSummary { return { approved: state.approvedResearch.map((s) => ({ id: s.id, title: s.title })), rejected: state.rejectedResearch.map((s) => ({ id: s.id, title: s.title })), - rationale: - "Multiple sources were rejected while others were kept. Confirm you want to proceed " + - "with the approved set before generating the outline.", + rationale: composeConflictRationale(locale), }; } @@ -29,15 +35,16 @@ function buildConflictSummary(state: WikiComposeStateType): ResearchConflictSumm */ export async function conflictResolution( state: WikiComposeStateType, - _config: LangGraphRunnableConfig, + config: LangGraphRunnableConfig, ): Promise { if (!shouldResolveResearchConflicts(state)) { return { phase: "conflict:skipped" }; } + const ctx = getGraphContext(config); const payload: WikiComposeInterruptPayload = { kind: "conflict_resolution", - conflicts: buildConflictSummary(state), + conflicts: buildConflictSummary(state, ctx.contentLocale), }; const resumeValue: unknown = interrupt(payload); conflictResumeSchema.parse(resumeValue); diff --git a/server/api/src/agents/graphs/wikiCompose/nodes/draftSections.ts b/server/api/src/agents/graphs/wikiCompose/nodes/draftSections.ts index 3d0767ac..5895cf80 100644 --- a/server/api/src/agents/graphs/wikiCompose/nodes/draftSections.ts +++ b/server/api/src/agents/graphs/wikiCompose/nodes/draftSections.ts @@ -13,6 +13,7 @@ * paint into the EditorPane. */ import type { LangGraphRunnableConfig } from "@langchain/langgraph"; +import { composeContentLocaleInstruction } from "../../../core/composeLocale.js"; import { createZediChatModel } from "../../../core/llm/modelFactory.js"; import { resolveComposeModelId } from "../../../core/llm/resolveComposeModelId.js"; import { getGraphContext } from "../../../subgraphs/research/nodes/shared/getGraphContext.js"; @@ -158,7 +159,10 @@ export async function draftSections( let body = ""; try { const stream = await model.stream([ - { role: "system", content: SECTION_SYSTEM_PROMPT }, + { + role: "system", + content: SECTION_SYSTEM_PROMPT + composeContentLocaleInstruction(ctx.contentLocale), + }, { role: "user", content: buildSectionPrompt({ diff --git a/server/api/src/agents/graphs/wikiCompose/nodes/structureDialogue.ts b/server/api/src/agents/graphs/wikiCompose/nodes/structureDialogue.ts index 27cd3002..e25af60d 100644 --- a/server/api/src/agents/graphs/wikiCompose/nodes/structureDialogue.ts +++ b/server/api/src/agents/graphs/wikiCompose/nodes/structureDialogue.ts @@ -14,6 +14,10 @@ import type { LangGraphRunnableConfig } from "@langchain/langgraph"; import { randomUUID } from "node:crypto"; import { z } from "zod"; +import { + composeContentLocaleInstruction, + structureDialogueFallbackOutline, +} from "../../../core/composeLocale.js"; import { createZediChatModel } from "../../../core/llm/modelFactory.js"; import { resolveComposeModelId } from "../../../core/llm/resolveComposeModelId.js"; import { getGraphContext } from "../../../subgraphs/research/nodes/shared/getGraphContext.js"; @@ -99,7 +103,10 @@ export async function structureDialogue( let raw: z.input; try { raw = await structured.invoke([ - { role: "system", content: SYSTEM_PROMPT }, + { + role: "system", + content: SYSTEM_PROMPT + composeContentLocaleInstruction(ctx.contentLocale), + }, { role: "user", content: buildUserPrompt(state) }, ]); } catch { @@ -107,13 +114,7 @@ export async function structureDialogue( // edit-rather-than-blank-out when the LLM fails (rare). Heading text // intentionally generic so the user is prompted to rename. // LLM 失敗時は 3 セクションの仮アウトラインを返してフローを止めない。 - raw = { - sections: [ - { heading: "Overview", depth: 1, intent: "Brief introduction to the topic." }, - { heading: "Key points", depth: 1, intent: "Main facts and context." }, - { heading: "References", depth: 1, intent: "Sources and further reading." }, - ], - }; + raw = { sections: structureDialogueFallbackOutline(ctx.contentLocale) }; } const outline: OutlineSection[] = raw.sections.map((s) => ({ diff --git a/server/api/src/agents/subgraphs/research/nodes/evaluateSufficiency.ts b/server/api/src/agents/subgraphs/research/nodes/evaluateSufficiency.ts index 9411f7b2..536be2bb 100644 --- a/server/api/src/agents/subgraphs/research/nodes/evaluateSufficiency.ts +++ b/server/api/src/agents/subgraphs/research/nodes/evaluateSufficiency.ts @@ -10,6 +10,7 @@ */ import type { LangGraphRunnableConfig } from "@langchain/langgraph"; import { z } from "zod"; +import { composeContentLocaleInstruction } from "../../../core/composeLocale.js"; import { createZediChatModel } from "../../../core/llm/modelFactory.js"; import { resolveComposeModelId } from "../../../core/llm/resolveComposeModelId.js"; import { getGraphContext } from "./shared/getGraphContext.js"; @@ -88,7 +89,10 @@ export async function evaluateSufficiency( name: "research_evaluation", }); const parsed = await structured.invoke([ - { role: "system", content: SYSTEM_PROMPT }, + { + role: "system", + content: SYSTEM_PROMPT + composeContentLocaleInstruction(ctx.contentLocale), + }, { role: "user", content: buildUserPrompt(state) }, ]); const evaluation: Evaluation = { diff --git a/server/api/src/agents/subgraphs/research/nodes/planQueries.ts b/server/api/src/agents/subgraphs/research/nodes/planQueries.ts index a760722d..33cba0d2 100644 --- a/server/api/src/agents/subgraphs/research/nodes/planQueries.ts +++ b/server/api/src/agents/subgraphs/research/nodes/planQueries.ts @@ -14,6 +14,7 @@ import type { LangGraphRunnableConfig } from "@langchain/langgraph"; import { randomUUID } from "node:crypto"; import { z } from "zod"; +import { composeContentLocaleInstruction } from "../../../core/composeLocale.js"; import { createZediChatModel } from "../../../core/llm/modelFactory.js"; import { resolveComposeModelId } from "../../../core/llm/resolveComposeModelId.js"; import { getGraphContext } from "./shared/getGraphContext.js"; @@ -112,7 +113,10 @@ export async function planQueries( }); const structured = model.withStructuredOutput(planQueriesSchema, { name: "plan_queries" }); const planned = await structured.invoke([ - { role: "system", content: SYSTEM_PROMPT }, + { + role: "system", + content: SYSTEM_PROMPT + composeContentLocaleInstruction(ctx.contentLocale), + }, { role: "user", content: brief || "(no brief provided; produce 2 broad coverage queries)" }, ]); diff --git a/server/api/src/agents/subgraphs/research/nodes/refineQueries.ts b/server/api/src/agents/subgraphs/research/nodes/refineQueries.ts index c2c9b755..41fe7129 100644 --- a/server/api/src/agents/subgraphs/research/nodes/refineQueries.ts +++ b/server/api/src/agents/subgraphs/research/nodes/refineQueries.ts @@ -8,6 +8,7 @@ */ import type { LangGraphRunnableConfig } from "@langchain/langgraph"; import { randomUUID } from "node:crypto"; +import { composeContentLocaleInstruction } from "../../../core/composeLocale.js"; import { createZediChatModel } from "../../../core/llm/modelFactory.js"; import { resolveComposeModelId } from "../../../core/llm/resolveComposeModelId.js"; import { getGraphContext } from "./shared/getGraphContext.js"; @@ -74,7 +75,10 @@ export async function refineQueries( }); const structured = model.withStructuredOutput(planQueriesSchema, { name: "refine_queries" }); const planned = await structured.invoke([ - { role: "system", content: SYSTEM_PROMPT }, + { + role: "system", + content: SYSTEM_PROMPT + composeContentLocaleInstruction(ctx.contentLocale), + }, { role: "user", content: buildUserPrompt(state) }, ]); const queries: PlannedQuery[] = planned.queries.map((q) => ({ diff --git a/server/api/src/agents/subgraphs/research/nodes/shared/getGraphContext.ts b/server/api/src/agents/subgraphs/research/nodes/shared/getGraphContext.ts index 4f820f31..f2cb56f6 100644 --- a/server/api/src/agents/subgraphs/research/nodes/shared/getGraphContext.ts +++ b/server/api/src/agents/subgraphs/research/nodes/shared/getGraphContext.ts @@ -8,6 +8,7 @@ * で誤って忘れたケースを早期に検出するため throw する。 */ import type { LangGraphRunnableConfig } from "@langchain/langgraph"; +import { normalizeComposeContentLocale } from "../../../../core/composeLocale.js"; import { GRAPH_CONTEXT_CONFIG_KEY, type GraphContext, @@ -44,5 +45,9 @@ export function getGraphContext(config: LangGraphRunnableConfig | undefined): Gr "Check GraphRunner.buildConfig.", ); } - return ctx as GraphContext; + const contentLocale = + normalizeComposeContentLocale(ctx.contentLocale) ?? + normalizeComposeContentLocale((ctx as { locale?: unknown }).locale) ?? + "en"; + return { ...(ctx as GraphContext), contentLocale }; } diff --git a/server/api/src/routes/composeSessions.ts b/server/api/src/routes/composeSessions.ts index 0e1aa642..ea8f877f 100644 --- a/server/api/src/routes/composeSessions.ts +++ b/server/api/src/routes/composeSessions.ts @@ -61,6 +61,11 @@ import { resolveCheckpointerForRun } from "../agents/core/checkpoint/index.js"; import { RESEARCH_GRAPH_ID } from "../agents/subgraphs/research/index.js"; import { WIKI_COMPOSE_GRAPH_ID } from "../agents/graphs/wikiCompose/index.js"; import { WIKI_MAINTENANCE_GRAPH_ID } from "../agents/graphs/wikiMaintenance/index.js"; +import { + resolveComposeContentLocale, + stripContentLocaleFromGraphInput, + type ComposeContentLocale, +} from "../agents/core/composeLocale.js"; import type { AppEnv } from "../types/index.js"; import { persistOutcomeIfStillRunning } from "./composeSessionPersistence.js"; import { loadComposeSessionProjection } from "./composeSessionProjection.js"; @@ -114,6 +119,33 @@ function recursionLimitFor(graphId: string): number | undefined { return undefined; } +/** Build {@link GraphContext} for a compose session run / projection. */ +function buildComposeGraphContext(args: { + sessionId: string; + userId: string; + userEmail: string | null; + pageId: string; + graphId: string; + backend: ReturnType; + tier: Awaited>; + db: AppEnv["Variables"]["db"]; + contentLocale: ComposeContentLocale; +}): import("../agents/core/types/graphContext.js").GraphContext { + return { + threadId: args.sessionId, + sessionId: args.sessionId, + userId: args.userId, + userEmail: args.userEmail, + pageId: args.pageId, + graphId: args.graphId, + backend: args.backend, + tier: args.tier, + db: args.db, + feature: `wiki_compose:${args.graphId}`, + contentLocale: args.contentLocale, + }; +} + const app = new Hono(); /** @@ -218,6 +250,7 @@ app.get("/:pageId/compose-sessions/:id", authRequired, async (c) => { if (!row) throw new HTTPException(404, { message: "Session not found" }); const tier = await getUserTier(userId, db); + const contentLocale = resolveComposeContentLocale(null, c.req.header("accept-language"), "ja"); // Stale / unsupported backend rows must still be readable; skip projection // instead of turning GET into a 500 (CodeRabbit P1 on reload path). @@ -231,8 +264,7 @@ app.get("/:pageId/compose-sessions/:id", authRequired, async (c) => { graphId: row.graphId, status: row.status, phase: row.phase, - context: { - threadId: row.id, + context: buildComposeGraphContext({ sessionId: row.id, userId, userEmail: c.get("userEmail") ?? null, @@ -241,8 +273,8 @@ app.get("/:pageId/compose-sessions/:id", authRequired, async (c) => { backend, tier, db, - feature: `wiki_compose:${row.graphId}`, - }, + contentLocale, + }), }); } catch (err) { if (!(err instanceof UnsupportedBackendError)) { @@ -359,6 +391,12 @@ app.post("/:pageId/compose-sessions/:id/run", authRequired, rateLimit(), async ( // checkpoint 保存・再開を有効化する。テスト / CI では未設定なので `false` // を返し、LangGraph の checkpoint 機構を無効化したまま smoke-test で走る。 const checkpointer = await resolveCheckpointerForRun(); + const contentLocale = resolveComposeContentLocale( + body.input, + c.req.header("accept-language"), + "ja", + ); + const graphInput = stripContentLocaleFromGraphInput(body.input ?? {}); try { await send(startedEvent(id, session.graphId, session.phase)); @@ -369,8 +407,7 @@ app.post("/:pageId/compose-sessions/:id/run", authRequired, rateLimit(), async ( graphId: session.graphId, checkpointer, ...(recursionLimit !== undefined ? { recursionLimit } : {}), - context: { - threadId: id, + context: buildComposeGraphContext({ sessionId: id, userId, userEmail, @@ -379,10 +416,10 @@ app.post("/:pageId/compose-sessions/:id/run", authRequired, rateLimit(), async ( backend: assertSupportedComposeBackend(session.backend), tier, db, - feature: `wiki_compose:${session.graphId}`, - }, + contentLocale, + }), }, - { kind: "input", value: translateGraphInput(session.graphId, body.input ?? {}) }, + { kind: "input", value: translateGraphInput(session.graphId, graphInput) }, ); for await (const raw of events) { @@ -494,6 +531,7 @@ app.patch("/:pageId/compose-sessions/:id/resume", authRequired, rateLimit(), asy // Resume relies on the checkpointer to fetch the suspended thread; production // routes load `PostgresSaver` here, tests/smoke runs get `false`. const checkpointer = await resolveCheckpointerForRun(); + const contentLocale = resolveComposeContentLocale(null, c.req.header("accept-language"), "ja"); let result; try { @@ -503,8 +541,7 @@ app.patch("/:pageId/compose-sessions/:id/resume", authRequired, rateLimit(), asy graphId: session.graphId, checkpointer, ...(recursionLimit !== undefined ? { recursionLimit } : {}), - context: { - threadId: id, + context: buildComposeGraphContext({ sessionId: id, userId, userEmail, @@ -513,8 +550,8 @@ app.patch("/:pageId/compose-sessions/:id/resume", authRequired, rateLimit(), asy backend: assertSupportedComposeBackend(session.backend), tier, db, - feature: `wiki_compose:${session.graphId}`, - }, + contentLocale, + }), }, body.resume, ); diff --git a/src/components/wikiCompose/ActivitySection.tsx b/src/components/wikiCompose/ActivitySection.tsx index 739efe13..a6e2778c 100644 --- a/src/components/wikiCompose/ActivitySection.tsx +++ b/src/components/wikiCompose/ActivitySection.tsx @@ -9,6 +9,7 @@ * when new rows arrive. */ import React, { useEffect, useRef } from "react"; +import { useTranslation } from "react-i18next"; import { ScrollArea } from "@zedi/ui"; import { cn } from "@zedi/ui"; import { Check, Circle, AlertCircle, Loader2 } from "lucide-react"; @@ -34,6 +35,7 @@ function Icon({ status }: { status: ComposeActivity["status"] }) { /** Compact activity timeline. */ export const ActivitySection: React.FC = ({ activity, isStreaming }) => { + const { t } = useTranslation(); const containerRef = useRef(null); // Scroll to the bottom on every new entry so the user sees the latest work. @@ -48,18 +50,20 @@ export const ActivitySection: React.FC = ({ activity, isSt

- Activity + {t("wikiCompose.activity.title")}

{isStreaming ? ( - live + {t("wikiCompose.activity.live")} ) : null}
{activity.length === 0 ? ( -

No activity yet.

+

+ {t("wikiCompose.activity.empty")} +

) : ( activity.map((entry) => (
= ({ answer, onChange, }) => { + const { t } = useTranslation(); const selected = answer?.selectedOptionIds ?? []; const freeText = answer?.freeText ?? ""; @@ -42,7 +44,7 @@ export const BriefQuestionCard: React.FC = ({ {question.question} {question.required ? ( - required + {t("wikiCompose.brief.required")} ) : null} @@ -52,7 +54,11 @@ export const BriefQuestionCard: React.FC = ({ {question.options.length > 0 ? ( -
+
{question.options.map((option) => { const active = selected.includes(option.id); return ( @@ -85,7 +91,11 @@ export const BriefQuestionCard: React.FC = ({ 0 ? "Add a note (optional)…" : "Type your answer…"} + placeholder={ + question.options.length > 0 + ? t("wikiCompose.brief.freeTextWithOptions") + : t("wikiCompose.brief.freeTextOnly") + } data-testid={`brief-freetext-${question.id}`} value={freeText} onChange={(e) => diff --git a/src/components/wikiCompose/ConflictResolutionSection.tsx b/src/components/wikiCompose/ConflictResolutionSection.tsx index 44cb5463..78b1dca7 100644 --- a/src/components/wikiCompose/ConflictResolutionSection.tsx +++ b/src/components/wikiCompose/ConflictResolutionSection.tsx @@ -7,6 +7,7 @@ * acknowledges and resumes with `{ acknowledged: true }`. */ import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; import { Button, Card, CardContent, CardHeader, CardTitle } from "@zedi/ui"; import { AlertTriangle } from "lucide-react"; import type { ResearchConflictSummary } from "@/lib/wikiCompose/types"; @@ -30,22 +31,25 @@ export const ConflictResolutionSection: React.FC isStreaming, onSubmit, }) => { + const { t } = useTranslation(); const [submitting, setSubmitting] = useState(false); return (
- Resolve conflicts + {t("wikiCompose.conflict.title")}
- Research conflicts + {t("wikiCompose.conflict.cardTitle")}

{conflicts.rationale}

-

Approved ({conflicts.approved.length})

+

+ {t("wikiCompose.conflict.approved", { count: conflicts.approved.length })} +

    {conflicts.approved.map((s) => (
  • {s.title}
  • @@ -53,7 +57,9 @@ export const ConflictResolutionSection: React.FC
-

Rejected ({conflicts.rejected.length})

+

+ {t("wikiCompose.conflict.rejected", { count: conflicts.rejected.length })} +

    {conflicts.rejected.map((s) => (
  • {s.title}
  • @@ -73,7 +79,7 @@ export const ConflictResolutionSection: React.FC } }} > - Continue with approved sources + {t("wikiCompose.conflict.continue")} diff --git a/src/components/wikiCompose/DialogueSection.tsx b/src/components/wikiCompose/DialogueSection.tsx index 663e0e2c..87bcd1bc 100644 --- a/src/components/wikiCompose/DialogueSection.tsx +++ b/src/components/wikiCompose/DialogueSection.tsx @@ -10,6 +10,7 @@ * handlers come from the parent (`WikiComposePage` → `useWikiComposeSession`). */ import React, { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; import { Button, Card, CardContent, CardHeader, CardTitle, Slider } from "@zedi/ui"; import { Sparkles, RefreshCw, ArrowRight } from "lucide-react"; import type { @@ -64,6 +65,7 @@ export const DialogueSection: React.FC = ({ onSubmitBrief, onSubmitOutline, }) => { + const { t } = useTranslation(); const [answers, setAnswers] = useState>({}); const [appendToExisting, setAppendToExisting] = useState( Boolean(pageSnapshot?.hasContent), @@ -81,19 +83,19 @@ export const DialogueSection: React.FC = ({

    - Brief + {t("wikiCompose.brief.title")}

    {briefQuestions.length === 0 - ? "No questions — proceed to research" - : `${briefQuestions.length} question${briefQuestions.length > 1 ? "s" : ""}`} + ? t("wikiCompose.brief.noQuestions") + : t("wikiCompose.brief.questionCount", { count: briefQuestions.length })}
    {briefQuestions.length === 0 && isStreaming ? ( - Preparing Brief questions… + {t("wikiCompose.brief.preparing")} ) : null} @@ -111,7 +113,7 @@ export const DialogueSection: React.FC = ({ - Page already has content + {t("wikiCompose.brief.existingContentTitle")} @@ -122,7 +124,7 @@ export const DialogueSection: React.FC = ({ checked={appendToExisting} onChange={() => setAppendToExisting(true)} /> - Append to existing content + {t("wikiCompose.brief.append")} @@ -140,16 +142,16 @@ export const DialogueSection: React.FC = ({ - Research depth + {t("wikiCompose.brief.researchDepthTitle")}
    - 1 (quick) + {t("wikiCompose.brief.researchQuick")} - {maxIterations} iteration{maxIterations > 1 ? "s" : ""} + {t("wikiCompose.brief.iterationCount", { count: maxIterations })} - 5 (deep) + {t("wikiCompose.brief.researchDeep")}
    = ({ ) : ( )} - Start research + {t("wikiCompose.brief.startResearch")}
@@ -195,9 +197,9 @@ export const DialogueSection: React.FC = ({ return (
-

Outline

+

{t("wikiCompose.structure.title")}

- {outlineProposal.length} section{outlineProposal.length === 1 ? "" : "s"} + {t("wikiCompose.structure.sectionCount", { count: outlineProposal.length })}
= ({

- {phase === "completed" ? "Completed" : "Drafting"} + {phase === "completed" + ? t("wikiCompose.draft.completed") + : t("wikiCompose.draft.drafting")}

{phase === "completed" - ? "Article ready. Return to the page to review and save." - : "Sections are being drafted. Watch the editor on the left for live updates."} + ? t("wikiCompose.draft.completedHint") + : t("wikiCompose.draft.draftingHint")}

diff --git a/src/components/wikiCompose/EditorPane.tsx b/src/components/wikiCompose/EditorPane.tsx index eff4789e..ca478cdd 100644 --- a/src/components/wikiCompose/EditorPane.tsx +++ b/src/components/wikiCompose/EditorPane.tsx @@ -11,6 +11,7 @@ * highlighted with a pulsing border so the user sees where to look. */ import React from "react"; +import { useTranslation } from "react-i18next"; import { cn } from "@zedi/ui"; import type { DraftedSection, OutlineSection } from "@/lib/wikiCompose/types"; @@ -33,17 +34,16 @@ export const EditorPane: React.FC = ({ streamingSectionId, completedMarkdown, }) => { + const { t } = useTranslation(); return (
-

{title || "Untitled page"}

+

{title || t("wikiCompose.editor.untitled")}

{outline.length === 0 && !completedMarkdown ? ( -

- The article will appear here once the agent starts drafting. -

+

{t("wikiCompose.editor.emptyHint")}

) : null} {outline.length > 0 ? ( @@ -70,7 +70,7 @@ export const EditorPane: React.FC = ({ )} {body.trim().length === 0 ? (

- {isStreaming ? "Streaming…" : section.intent} + {isStreaming ? t("wikiCompose.editor.streaming") : section.intent}

) : ( // Plain-text rendering of the running buffer. Once the @@ -89,7 +89,9 @@ export const EditorPane: React.FC = ({ {completedMarkdown ? (
-

Final Markdown

+

+ {t("wikiCompose.editor.finalMarkdown")} +

             {completedMarkdown}
           
diff --git a/src/components/wikiCompose/OutlineEditor.tsx b/src/components/wikiCompose/OutlineEditor.tsx index 8d80e2a1..70c4de13 100644 --- a/src/components/wikiCompose/OutlineEditor.tsx +++ b/src/components/wikiCompose/OutlineEditor.tsx @@ -10,6 +10,7 @@ * button. The user submits via the dedicated button at the bottom. */ import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; import { ArrowDown, ArrowUp, Trash2, Plus, Check } from "lucide-react"; import { Button, Card, CardContent, Input, Textarea } from "@zedi/ui"; import { cn } from "@zedi/ui"; @@ -33,6 +34,7 @@ export const OutlineEditor: React.FC = ({ disabled = false, onSubmit, }) => { + const { t } = useTranslation(); const [sections, setSections] = useState(initialSections); const [submitting, setSubmitting] = useState(false); @@ -59,7 +61,7 @@ export const OutlineEditor: React.FC = ({ const add = () => setSections((prev) => [ ...prev, - { id: makeLocalId(), heading: "New section", depth: 1, intent: "" }, + { id: makeLocalId(), heading: t("wikiCompose.structure.newSection"), depth: 1, intent: "" }, ]); const update = (id: string, patch: Partial) => @@ -81,14 +83,14 @@ export const OutlineEditor: React.FC = ({ value={section.heading} data-testid={`outline-heading-${section.id}`} onChange={(e) => update(section.id, { heading: e.target.value })} - placeholder="Section heading" + placeholder={t("wikiCompose.structure.headingPlaceholder")} disabled={disabled} />
diff --git a/src/components/wikiCompose/PhaseStepper.tsx b/src/components/wikiCompose/PhaseStepper.tsx index a6ae8b7c..22d02fc4 100644 --- a/src/components/wikiCompose/PhaseStepper.tsx +++ b/src/components/wikiCompose/PhaseStepper.tsx @@ -10,20 +10,13 @@ * trigger transitions. */ import React from "react"; +import { useTranslation } from "react-i18next"; import { Check, Circle, CircleDashed } from "lucide-react"; import { cn } from "@zedi/ui"; import type { ComposePhase } from "@/hooks/useWikiComposeSession"; const PHASE_ORDER: ComposePhase[] = ["brief", "research", "structure", "draft", "completed"]; -const PHASE_LABEL: Record = { - brief: "Brief", - research: "Research", - structure: "Structure", - draft: "Draft", - completed: "Done", -}; - export interface PhaseStepperProps { /** Current phase. */ phase: ComposePhase; @@ -31,6 +24,7 @@ export interface PhaseStepperProps { /** Render the 5-step phase stepper. */ export const PhaseStepper: React.FC = ({ phase }) => { + const { t } = useTranslation(); // P5 conflict interrupt sits between Research and Structure on the graph, but // the stepper keeps five labels — highlight Research while resolving conflicts. // P5 の conflict interrupt は Research と Structure の間にあるが、 @@ -38,7 +32,10 @@ export const PhaseStepper: React.FC = ({ phase }) => { const stepPhase: ComposePhase = phase === "conflict" ? "research" : phase; const currentIndex = Math.max(0, PHASE_ORDER.indexOf(stepPhase)); return ( -
    +
      {PHASE_ORDER.map((p, i) => { const state = i < currentIndex ? "completed" : i === currentIndex ? "active" : "upcoming"; return ( @@ -60,7 +57,7 @@ export const PhaseStepper: React.FC = ({ phase }) => { ) : ( )} - {PHASE_LABEL[p]} + {t(`wikiCompose.phase.${p}`)} {i < PHASE_ORDER.length - 1 ?
    1. : null} diff --git a/src/components/wikiCompose/ResearchSection.tsx b/src/components/wikiCompose/ResearchSection.tsx index 35cecac9..85ba3b34 100644 --- a/src/components/wikiCompose/ResearchSection.tsx +++ b/src/components/wikiCompose/ResearchSection.tsx @@ -11,6 +11,7 @@ * id sets to `submitResearchApproval`. */ import React, { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; import { ExternalLink, Check } from "lucide-react"; import { Badge, Button, Card, CardContent, CardHeader, CardTitle } from "@zedi/ui"; import { cn } from "@zedi/ui"; @@ -31,18 +32,6 @@ export interface ResearchSectionProps { }) => Promise; } -/** Maps source kind to a short label. */ -function kindLabel(kind: ResearchSource["kind"]): string { - switch (kind) { - case "web": - return "Web"; - case "wiki": - return "Wiki"; - case "fetched": - return "Article"; - } -} - /** Render the research review panel. */ export const ResearchSection: React.FC = ({ batch, @@ -52,9 +41,21 @@ export const ResearchSection: React.FC = ({ isStreaming, onSubmit, }) => { + const { t } = useTranslation(); const [decisions, setDecisions] = useState>({}); const [submitting, setSubmitting] = useState(false); + const kindLabel = (kind: ResearchSource["kind"]): string => { + switch (kind) { + case "web": + return t("wikiCompose.research.web"); + case "wiki": + return t("wikiCompose.research.wiki"); + case "fetched": + return t("wikiCompose.research.article"); + } + }; + // Seed decisions to "approved" by default when sources change so the user // can review and reject rather than starting from scratch. // pendingSources の初期 decision は approved を default とする。 @@ -92,14 +93,17 @@ export const ResearchSection: React.FC = ({ if (approvedSources.length === 0) { return (
      - No research sources were approved. + {t("wikiCompose.research.noneApproved")}
      ); } return (

      - Research {approvedSources.length} approved + {t("wikiCompose.phase.research")}{" "} + + {t("wikiCompose.research.approvedBadge", { count: approvedSources.length })} +

        {approvedSources.map((s) => ( @@ -132,9 +136,7 @@ export const ResearchSection: React.FC = ({ if (pendingSources.length === 0) { return (
        - {isStreaming - ? "Research in progress — sources will appear when ready." - : "No research sources to review."} + {isStreaming ? t("wikiCompose.research.inProgress") : t("wikiCompose.research.empty")}
        ); } @@ -147,11 +149,18 @@ export const ResearchSection: React.FC = ({

        - Research review - {batch ? iter {batch.iteration + 1} : null} + {t("wikiCompose.research.reviewTitle")} + {batch ? ( + + {t("wikiCompose.research.iterationBadge", { count: batch.iteration + 1 })} + + ) : null}

        - {approvedCount} / {pendingSources.length} approved + {t("wikiCompose.research.approvedOf", { + approved: approvedCount, + total: pendingSources.length, + })}
        @@ -201,7 +210,7 @@ export const ResearchSection: React.FC = ({ data-testid={`source-approve-${s.id}`} onClick={() => setDecision(s.id, "approved")} > - Approve + {t("wikiCompose.research.approve")}
@@ -226,7 +235,7 @@ export const ResearchSection: React.FC = ({ disabled={submitting || isStreaming} onClick={handleSubmit} > - Continue with selection + {t("wikiCompose.research.continue")}
diff --git a/src/hooks/useWikiComposeSession.test.ts b/src/hooks/useWikiComposeSession.test.ts index dec5b178..ab89b4fa 100644 --- a/src/hooks/useWikiComposeSession.test.ts +++ b/src/hooks/useWikiComposeSession.test.ts @@ -27,6 +27,10 @@ vi.mock("@/lib/wikiCompose/composeService", () => ({ cancelSession: mocks.cancelSession, })); +vi.mock("@/lib/wikiCompose/resolveComposeContentLocale", () => ({ + resolveComposeContentLocale: () => "ja" as const, +})); + import { useWikiComposeSession } from "./useWikiComposeSession"; import type { ComposeSseEvent } from "@/lib/wikiCompose/types"; @@ -295,7 +299,7 @@ describe("useWikiComposeSession", () => { expect(mocks.runSession).toHaveBeenCalledWith( expect.objectContaining({ - body: undefined, + body: { contentLocale: "ja" }, }), ); }); @@ -376,6 +380,7 @@ describe("useWikiComposeSession", () => { expect(mocks.runSession).toHaveBeenCalledWith( expect.objectContaining({ body: { + contentLocale: "ja", chatSeed: { outline: "- topic", conversationText: "User: seed me", diff --git a/src/hooks/useWikiComposeSession.ts b/src/hooks/useWikiComposeSession.ts index fe8f3169..f3f341f8 100644 --- a/src/hooks/useWikiComposeSession.ts +++ b/src/hooks/useWikiComposeSession.ts @@ -19,7 +19,9 @@ import { resumeSession, runSession, } from "@/lib/wikiCompose/composeService"; +import i18n from "@/i18n"; import type { ComposeExecutionBackend } from "@/lib/wikiCompose/backends"; +import { resolveComposeContentLocale } from "@/lib/wikiCompose/resolveComposeContentLocale"; import type { ComposeNavigationSeed } from "@/lib/wikiCompose/navigation"; import type { BriefAnswer, @@ -224,6 +226,18 @@ function activityId(): string { return `act-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; } +/** Merge graph run input with the active UI content locale. */ +function withContentLocale(input?: Record): Record { + return { ...(input ?? {}), contentLocale: resolveComposeContentLocale() }; +} + +/** Human-readable phase label for activity log entries. */ +function phaseDisplayLabel(phase: string): string { + const key = `wikiCompose.phaseDisplay.${phase}` as const; + const translated = i18n.t(key); + return translated === key ? phase : translated; +} + /** * Map an SSE event into a state update. Returns a partial state to merge. */ @@ -235,7 +249,7 @@ function reduceEvent( case "started": return { activity: appendActivity(prev.activity, { - label: "Run started", + label: i18n.t("wikiCompose.activity.runStarted"), detail: event.graphId, status: "info", }), @@ -244,7 +258,7 @@ function reduceEvent( return { phase: event.phase, activity: appendActivity(prev.activity, { - label: `Phase: ${event.phase}`, + label: i18n.t("wikiCompose.activity.phase", { phase: phaseDisplayLabel(event.phase) }), detail: event.status, status: event.status === "entered" ? "started" : "completed", }), @@ -263,7 +277,7 @@ function reduceEvent( case "tool_start": return { activity: appendActivity(prev.activity, { - label: `Tool: ${event.tool}`, + label: i18n.t("wikiCompose.activity.toolStarted", { tool: event.tool }), detail: event.input ? "running" : undefined, status: "started", }), @@ -271,7 +285,7 @@ function reduceEvent( case "tool_end": return { activity: appendActivity(prev.activity, { - label: `Tool: ${event.tool}`, + label: i18n.t("wikiCompose.activity.toolDone", { tool: event.tool }), detail: event.error ?? (event.outputLength ? `${event.outputLength} chars` : "ok"), status: event.error ? "error" : "completed", }), @@ -279,7 +293,9 @@ function reduceEvent( case "research_iteration": return { activity: appendActivity(prev.activity, { - label: `Research iteration ${event.iteration + 1}`, + label: i18n.t("wikiCompose.activity.researchIteration", { + count: event.iteration + 1, + }), detail: `${event.status} · ${event.queryCount} queries`, status: "info", }), @@ -287,7 +303,9 @@ function reduceEvent( case "research_evaluation": return { activity: appendActivity(prev.activity, { - label: `Sufficiency: ${event.score.toFixed(2)}`, + label: i18n.t("wikiCompose.activity.sufficiency", { + score: event.score.toFixed(2), + }), detail: event.rationale, status: "info", }), @@ -295,7 +313,9 @@ function reduceEvent( case "research_batch": return { activity: appendActivity(prev.activity, { - label: `Research batch (#${event.iteration})`, + label: i18n.t("wikiCompose.activity.researchBatch", { + iteration: event.iteration, + }), detail: `${event.sourceCount} sources · ${event.exitReason}`, status: "completed", }), @@ -306,7 +326,7 @@ function reduceEvent( streamingSectionId: event.sectionId, sectionBuffers: { ...prev.sectionBuffers, [event.sectionId]: "" }, activity: appendActivity(prev.activity, { - label: `Drafting: ${event.heading}`, + label: i18n.t("wikiCompose.activity.drafting", { heading: event.heading }), detail: `${event.index} / ${event.total}`, status: "started", }), @@ -316,7 +336,7 @@ function reduceEvent( streamingSectionId: prev.streamingSectionId === event.sectionId ? null : prev.streamingSectionId, activity: appendActivity(prev.activity, { - label: `Drafted: ${event.heading}`, + label: i18n.t("wikiCompose.activity.drafted", { heading: event.heading }), detail: `${event.index} / ${event.total}`, status: "completed", }), @@ -336,7 +356,7 @@ function reduceEvent( isStreaming: false, status: event.status, activity: appendActivity(prev.activity, { - label: `Run ${event.status}`, + label: i18n.t("wikiCompose.activity.runStatus", { status: event.status }), status: event.status === "completed" ? "completed" : "info", }), }; @@ -344,7 +364,7 @@ function reduceEvent( return { error: event.message, activity: appendActivity(prev.activity, { - label: "Error", + label: i18n.t("wikiCompose.activity.error"), detail: event.message, status: "error", }), @@ -353,7 +373,7 @@ function reduceEvent( // Usage doesn't change UI state directly, but log it for debug. return { activity: appendActivity(prev.activity, { - label: "Usage", + label: i18n.t("wikiCompose.activity.usage"), detail: `in=${event.inputTokens} out=${event.outputTokens} cu=${event.costUnits}`, status: "info", }), @@ -628,7 +648,7 @@ export function useWikiComposeSession( // Interrupted checkpoints require `Command({ resume })`; replaying input // would restart or error, and resume payloads are not stored on the row. if (session.status === "pending" || session.status === "failed") { - await streamRun(session, runInput); + await streamRun(session, withContentLocale(runInput)); } } catch (err) { const message = err instanceof Error ? err.message : String(err); diff --git a/src/i18n/locales/en/wikiCompose.json b/src/i18n/locales/en/wikiCompose.json index 856aade6..5f65de13 100644 --- a/src/i18n/locales/en/wikiCompose.json +++ b/src/i18n/locales/en/wikiCompose.json @@ -1,4 +1,115 @@ { + "page": { + "titleFallback": "Wiki Compose", + "missingPageIdTitle": "Missing page id", + "missingPageIdDescription": "This URL is missing a page id. Use the Compose button on a wiki page to start.", + "back": "Back", + "sessionPrefix": "session:", + "close": "Close", + "cancel": "Cancel", + "startCompose": "Start compose" + }, + "phase": { + "brief": "Brief", + "research": "Research", + "structure": "Structure", + "draft": "Draft", + "completed": "Done", + "progressAria": "Compose phase progress" + }, + "phaseDisplay": { + "brief": "Brief", + "research": "Research", + "structure": "Structure", + "draft": "Draft", + "conflict": "Conflict", + "completed": "Completed" + }, + "brief": { + "title": "Brief", + "noQuestions": "No questions — proceed to research", + "questionCount_one": "{{count}} question", + "questionCount_other": "{{count}} questions", + "preparing": "Preparing Brief questions…", + "existingContentTitle": "Page already has content", + "append": "Append to existing content", + "replace": "Replace existing content", + "researchDepthTitle": "Research depth", + "researchQuick": "1 (quick)", + "researchDeep": "5 (deep)", + "iterationCount_one": "{{count}} iteration", + "iterationCount_other": "{{count}} iterations", + "startResearch": "Start research", + "required": "required", + "answerOptionsAria": "Answer options", + "freeTextWithOptions": "Add a note (optional)…", + "freeTextOnly": "Type your answer…" + }, + "structure": { + "title": "Outline", + "sectionCount_one": "{{count}} section", + "sectionCount_other": "{{count}} sections", + "headingPlaceholder": "Section heading", + "intentPlaceholder": "What should this section cover?", + "toggleDepth": "Toggle depth", + "moveUp": "Move up", + "moveDown": "Move down", + "removeSection": "Remove section", + "newSection": "New section", + "addSection": "Add section", + "approveOutline": "Approve outline" + }, + "draft": { + "drafting": "Drafting", + "completed": "Completed", + "draftingHint": "Sections are being drafted. Watch the editor on the left for live updates.", + "completedHint": "Article ready. Return to the page to review and save." + }, + "research": { + "web": "Web", + "wiki": "Wiki", + "article": "Article", + "noneApproved": "No research sources were approved.", + "approvedBadge": "{{count}} approved", + "inProgress": "Research in progress — sources will appear when ready.", + "empty": "No research sources to review.", + "reviewTitle": "Research review", + "iterationBadge": "iter {{count}}", + "approvedOf": "{{approved}} / {{total}} approved", + "approve": "Approve", + "reject": "Reject", + "continue": "Continue with selection" + }, + "conflict": { + "title": "Resolve conflicts", + "cardTitle": "Research conflicts", + "approved": "Approved ({{count}})", + "rejected": "Rejected ({{count}})", + "continue": "Continue with approved sources" + }, + "editor": { + "untitled": "Untitled page", + "emptyHint": "The article will appear here once the agent starts drafting.", + "streaming": "Streaming…", + "finalMarkdown": "Final Markdown" + }, + "activity": { + "title": "Activity", + "live": "live", + "empty": "No activity yet.", + "runStarted": "Run started", + "phase": "Phase: {{phase}}", + "toolStarted": "Tool: {{tool}}", + "toolDone": "Tool: {{tool}}", + "researchIteration": "Research iteration {{count}}", + "sufficiency": "Sufficiency: {{score}}", + "researchBatch": "Research batch (#{{iteration}})", + "drafting": "Drafting: {{heading}}", + "drafted": "Drafted: {{heading}}", + "runStatus": "Run {{status}}", + "error": "Error", + "usage": "Usage" + }, "backend": { "label": "Execution backend", "zediManaged": "Zedi managed", diff --git a/src/i18n/locales/ja/wikiCompose.json b/src/i18n/locales/ja/wikiCompose.json index d9ebecb6..6398fbec 100644 --- a/src/i18n/locales/ja/wikiCompose.json +++ b/src/i18n/locales/ja/wikiCompose.json @@ -1,4 +1,115 @@ { + "page": { + "titleFallback": "Wiki Compose", + "missingPageIdTitle": "ページ ID がありません", + "missingPageIdDescription": "この URL にはページ ID がありません。Wiki ページの Compose ボタンから開始してください。", + "back": "戻る", + "sessionPrefix": "セッション:", + "close": "閉じる", + "cancel": "キャンセル", + "startCompose": "Compose を開始" + }, + "phase": { + "brief": "ブリーフ", + "research": "調査", + "structure": "構成", + "draft": "執筆", + "completed": "完了", + "progressAria": "Compose フェーズの進捗" + }, + "phaseDisplay": { + "brief": "ブリーフ", + "research": "調査", + "structure": "構成", + "draft": "執筆", + "conflict": "矛盾解消", + "completed": "完了" + }, + "brief": { + "title": "ブリーフ", + "noQuestions": "質問はありません — 調査に進みます", + "questionCount_one": "質問 {{count}} 件", + "questionCount_other": "質問 {{count}} 件", + "preparing": "ブリーフの質問を準備しています…", + "existingContentTitle": "ページに既存の本文があります", + "append": "既存の本文に追記する", + "replace": "既存の本文を置き換える", + "researchDepthTitle": "調査の深さ", + "researchQuick": "1(手早く)", + "researchDeep": "5(深く)", + "iterationCount_one": "{{count}} 回", + "iterationCount_other": "{{count}} 回", + "startResearch": "調査を開始", + "required": "必須", + "answerOptionsAria": "回答の選択肢", + "freeTextWithOptions": "メモを追加(任意)…", + "freeTextOnly": "回答を入力…" + }, + "structure": { + "title": "アウトライン", + "sectionCount_one": "セクション {{count}} 件", + "sectionCount_other": "セクション {{count}} 件", + "headingPlaceholder": "セクション見出し", + "intentPlaceholder": "このセクションで書く内容", + "toggleDepth": "見出しレベルを切り替え", + "moveUp": "上へ", + "moveDown": "下へ", + "removeSection": "セクションを削除", + "newSection": "新しいセクション", + "addSection": "セクションを追加", + "approveOutline": "アウトラインを承認" + }, + "draft": { + "drafting": "執筆中", + "completed": "完了", + "draftingHint": "セクションを執筆しています。左のエディタで進捗を確認できます。", + "completedHint": "記事の下書きができました。ページに戻って内容を確認・保存してください。" + }, + "research": { + "web": "Web", + "wiki": "Wiki", + "article": "記事", + "noneApproved": "採用された調査ソースはありません。", + "approvedBadge": "採用 {{count}} 件", + "inProgress": "調査中です — ソースが揃い次第表示されます。", + "empty": "レビューする調査ソースがありません。", + "reviewTitle": "調査結果の確認", + "iterationBadge": "イテレーション {{count}}", + "approvedOf": "採用 {{approved}} / {{total}}", + "approve": "採用", + "reject": "除外", + "continue": "選択内容で続行" + }, + "conflict": { + "title": "矛盾を解消", + "cardTitle": "調査の矛盾", + "approved": "採用 ({{count}})", + "rejected": "除外 ({{count}})", + "continue": "採用ソースで続行" + }, + "editor": { + "untitled": "無題のページ", + "emptyHint": "エージェントが執筆を始めると、ここに記事が表示されます。", + "streaming": "ストリーミング中…", + "finalMarkdown": "完成 Markdown" + }, + "activity": { + "title": "アクティビティ", + "live": "ライブ", + "empty": "まだアクティビティはありません。", + "runStarted": "実行を開始", + "phase": "フェーズ: {{phase}}", + "toolStarted": "ツール: {{tool}}", + "toolDone": "ツール: {{tool}}", + "researchIteration": "調査イテレーション {{count}}", + "sufficiency": "充足度: {{score}}", + "researchBatch": "調査バッチ (#{{iteration}})", + "drafting": "執筆中: {{heading}}", + "drafted": "執筆完了: {{heading}}", + "runStatus": "実行 {{status}}", + "error": "エラー", + "usage": "使用量" + }, "backend": { "label": "実行バックエンド", "zediManaged": "Zedi 管理", diff --git a/src/lib/wikiCompose/resolveComposeContentLocale.test.ts b/src/lib/wikiCompose/resolveComposeContentLocale.test.ts new file mode 100644 index 00000000..271e275f --- /dev/null +++ b/src/lib/wikiCompose/resolveComposeContentLocale.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it, vi, afterEach } from "vitest"; +import i18n from "@/i18n"; +import { resolveComposeContentLocale } from "./resolveComposeContentLocale"; + +describe("resolveComposeContentLocale", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("returns en when i18n language is English", () => { + vi.spyOn(i18n, "language", "get").mockReturnValue("en"); + expect(resolveComposeContentLocale()).toBe("en"); + }); + + it("returns ja for Japanese and default locales", () => { + vi.spyOn(i18n, "language", "get").mockReturnValue("ja"); + expect(resolveComposeContentLocale()).toBe("ja"); + vi.spyOn(i18n, "language", "get").mockReturnValue("ja-JP"); + expect(resolveComposeContentLocale()).toBe("ja"); + }); +}); diff --git a/src/lib/wikiCompose/resolveComposeContentLocale.ts b/src/lib/wikiCompose/resolveComposeContentLocale.ts new file mode 100644 index 00000000..4ac542e2 --- /dev/null +++ b/src/lib/wikiCompose/resolveComposeContentLocale.ts @@ -0,0 +1,13 @@ +/** + * Map the app UI language to Wiki Compose graph `contentLocale`. + * アプリ UI 言語を Wiki Compose グラフの `contentLocale` に対応づける。 + */ +import i18n from "@/i18n"; + +export type ComposeContentLocale = "ja" | "en"; + +/** Returns `ja` or `en` from the active i18next language. */ +export function resolveComposeContentLocale(): ComposeContentLocale { + const primary = i18n.language?.split("-")[0]?.toLowerCase(); + return primary === "en" ? "en" : "ja"; +} diff --git a/src/pages/WikiComposePage.tsx b/src/pages/WikiComposePage.tsx index e255bdf7..44312022 100644 --- a/src/pages/WikiComposePage.tsx +++ b/src/pages/WikiComposePage.tsx @@ -11,6 +11,7 @@ * and routes user submissions back through the hook's mutator methods. */ import React, { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; import { useLocation, useNavigate, useParams } from "react-router-dom"; import { ArrowLeft, X } from "lucide-react"; import { @@ -40,6 +41,7 @@ function indexById(items: DraftedSection[]): Record { /** Root page for `/notes/:noteId/:pageId/compose[/:sessionId]`. */ const WikiComposePage: React.FC = () => { + const { t } = useTranslation(); const params = useParams<{ noteId: string; pageId: string; sessionId?: string }>(); const navigate = useNavigate(); const location = useLocation(); @@ -148,14 +150,18 @@ const WikiComposePage: React.FC = () => { handleBack(); }; + const phaseLabel = (() => { + const key = `wikiCompose.phaseDisplay.${session.phase}` as const; + const translated = t(key); + return translated === key ? session.phase : translated; + })(); + if (!pageId) { return (
- Missing page id - - This URL is missing a page id. Use the Compose button on a wiki page to start. - + {t("wikiCompose.page.missingPageIdTitle")} + {t("wikiCompose.page.missingPageIdDescription")}
); @@ -175,19 +181,19 @@ const WikiComposePage: React.FC = () => { data-testid="compose-back" > - Back + {t("wikiCompose.page.back")} - {session.pageSnapshot?.title || "Wiki Compose"} + {session.pageSnapshot?.title || t("wikiCompose.page.titleFallback")} - {session.phase} + {phaseLabel}
{session.session ? ( - session: {session.session.id.slice(0, 8)}… + {t("wikiCompose.page.sessionPrefix")} {session.session.id.slice(0, 8)}… ) : null}
@@ -256,7 +264,7 @@ const WikiComposePage: React.FC = () => { onClick={() => void session.start()} disabled={session.isStreaming || !isComposeBackendResolved} > - Start compose + {t("wikiCompose.page.startCompose")} ) : null} From 3dfc533db167b9b4568db534472331fb27ab9e46 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 26 May 2026 03:11:26 +0000 Subject: [PATCH 2/3] fix(wiki-compose): persist contentLocale and fix API typecheck - Store contentLocale in session metadata on first run; reuse on resume/GET - Parse Accept-Language with q-values via invitationService helper - Default getGraphContext fallback to ja; preserve null/undefined in strip helper - Add contentLocale to GraphContext test fixtures and ingest graph routes Co-authored-by: Akimasa Sugai --- .../agents/core/composeLocale.test.ts | 13 ++++++ .../graphs/ingest/ingestPlannerGraph.test.ts | 1 + .../graphs/ingest/planIngestModel.test.ts | 1 + .../wikiCompose/wikiComposeGraph.test.ts | 1 + .../wikiMaintenanceGraph.test.ts | 1 + .../agents/runner/graphRunner.test.ts | 1 + .../research/nodes/planQueries.test.ts | 1 + .../research/researchGraph.interrupt.test.ts | 1 + .../research/researchGraph.loop.test.ts | 1 + .../research/researchGraph.modelGuard.test.ts | 1 + .../research/researchGraph.resume.test.ts | 1 + .../research/tools/webSearch.test.ts | 1 + .../research/tools/wikiSearch.test.ts | 1 + server/api/src/agents/core/composeLocale.ts | 38 +++++++++++++--- .../research/nodes/shared/getGraphContext.ts | 2 +- server/api/src/routes/composeSessions.ts | 43 +++++++++++++++---- server/api/src/routes/ingest.ts | 3 ++ 17 files changed, 96 insertions(+), 15 deletions(-) diff --git a/server/api/src/__tests__/agents/core/composeLocale.test.ts b/server/api/src/__tests__/agents/core/composeLocale.test.ts index 616ef7a8..2e8afa8a 100644 --- a/server/api/src/__tests__/agents/core/composeLocale.test.ts +++ b/server/api/src/__tests__/agents/core/composeLocale.test.ts @@ -3,7 +3,9 @@ import { composeConflictRationale, composeContentLocaleInstruction, normalizeComposeContentLocale, + readContentLocaleFromSessionMetadata, resolveComposeContentLocale, + resolveSessionContentLocale, stripContentLocaleFromGraphInput, structureDialogueFallbackOutline, } from "../../../agents/core/composeLocale.js"; @@ -19,6 +21,7 @@ describe("composeLocale", () => { expect(resolveComposeContentLocale({ contentLocale: "en" }, "ja-JP")).toBe("en"); expect(resolveComposeContentLocale({}, "ja-JP,en;q=0.8")).toBe("ja"); expect(resolveComposeContentLocale({}, "en-US,en;q=0.9")).toBe("en"); + expect(resolveComposeContentLocale({}, "zh-CN,en-US;q=0.9")).toBe("en"); expect(resolveComposeContentLocale({}, null, "ja")).toBe("ja"); }); @@ -26,6 +29,16 @@ describe("composeLocale", () => { expect(stripContentLocaleFromGraphInput({ contentLocale: "ja", chatSeed: { x: 1 } })).toEqual({ chatSeed: { x: 1 }, }); + expect(stripContentLocaleFromGraphInput(null)).toBeNull(); + expect(stripContentLocaleFromGraphInput(undefined)).toBeUndefined(); + }); + + it("persists locale via session metadata resolution", () => { + expect(readContentLocaleFromSessionMetadata({ contentLocale: "en" })).toBe("en"); + expect( + resolveSessionContentLocale({ contentLocale: "en" }, { contentLocale: "ja" }, "ja-JP"), + ).toBe("en"); + expect(resolveSessionContentLocale(null, { contentLocale: "ja" }, "en-US")).toBe("ja"); }); it("includes Japanese in locale instruction when ja", () => { diff --git a/server/api/src/__tests__/agents/graphs/ingest/ingestPlannerGraph.test.ts b/server/api/src/__tests__/agents/graphs/ingest/ingestPlannerGraph.test.ts index 9c0fb11d..a4f0ccaa 100644 --- a/server/api/src/__tests__/agents/graphs/ingest/ingestPlannerGraph.test.ts +++ b/server/api/src/__tests__/agents/graphs/ingest/ingestPlannerGraph.test.ts @@ -71,6 +71,7 @@ function fakeContext(threadId: string): GraphContext { db: {} as Database, feature: "ingest_graph:test", userEmail: null, + contentLocale: "ja", }; } diff --git a/server/api/src/__tests__/agents/graphs/ingest/planIngestModel.test.ts b/server/api/src/__tests__/agents/graphs/ingest/planIngestModel.test.ts index d41c4c61..a2d06849 100644 --- a/server/api/src/__tests__/agents/graphs/ingest/planIngestModel.test.ts +++ b/server/api/src/__tests__/agents/graphs/ingest/planIngestModel.test.ts @@ -48,6 +48,7 @@ describe("planIngest model resolution", () => { sessionId: "t1", userId: "user-1", userEmail: null, + contentLocale: "ja", pageId: "", graphId: "ingest-planner", backend: "user_openai", diff --git a/server/api/src/__tests__/agents/graphs/wikiCompose/wikiComposeGraph.test.ts b/server/api/src/__tests__/agents/graphs/wikiCompose/wikiComposeGraph.test.ts index a242cbaa..46ba64f9 100644 --- a/server/api/src/__tests__/agents/graphs/wikiCompose/wikiComposeGraph.test.ts +++ b/server/api/src/__tests__/agents/graphs/wikiCompose/wikiComposeGraph.test.ts @@ -89,6 +89,7 @@ function fakeContext(threadId: string): GraphContext { db: {} as Database, feature: "wiki_compose:test", userEmail: null, + contentLocale: "ja", }; } diff --git a/server/api/src/__tests__/agents/graphs/wikiMaintenance/wikiMaintenanceGraph.test.ts b/server/api/src/__tests__/agents/graphs/wikiMaintenance/wikiMaintenanceGraph.test.ts index e51657c0..254a4624 100644 --- a/server/api/src/__tests__/agents/graphs/wikiMaintenance/wikiMaintenanceGraph.test.ts +++ b/server/api/src/__tests__/agents/graphs/wikiMaintenance/wikiMaintenanceGraph.test.ts @@ -36,6 +36,7 @@ function fakeContext(threadId: string): GraphContext { db: {} as Database, feature: "wiki_maintenance:test", userEmail: null, + contentLocale: "ja", }; } diff --git a/server/api/src/__tests__/agents/runner/graphRunner.test.ts b/server/api/src/__tests__/agents/runner/graphRunner.test.ts index 3538248a..4c7c970b 100644 --- a/server/api/src/__tests__/agents/runner/graphRunner.test.ts +++ b/server/api/src/__tests__/agents/runner/graphRunner.test.ts @@ -31,6 +31,7 @@ function fakeContext(): GraphContext { db: {} as Database, feature: "test", userEmail: null, + contentLocale: "ja", }; } diff --git a/server/api/src/__tests__/agents/subgraphs/research/nodes/planQueries.test.ts b/server/api/src/__tests__/agents/subgraphs/research/nodes/planQueries.test.ts index 17b89978..d923995e 100644 --- a/server/api/src/__tests__/agents/subgraphs/research/nodes/planQueries.test.ts +++ b/server/api/src/__tests__/agents/subgraphs/research/nodes/planQueries.test.ts @@ -44,6 +44,7 @@ function fakeContext(): GraphContext { db: {} as Database, feature: "wiki_compose:research", userEmail: null, + contentLocale: "ja", }; } diff --git a/server/api/src/__tests__/agents/subgraphs/research/researchGraph.interrupt.test.ts b/server/api/src/__tests__/agents/subgraphs/research/researchGraph.interrupt.test.ts index aadfb110..c0ffdb53 100644 --- a/server/api/src/__tests__/agents/subgraphs/research/researchGraph.interrupt.test.ts +++ b/server/api/src/__tests__/agents/subgraphs/research/researchGraph.interrupt.test.ts @@ -66,6 +66,7 @@ function fakeContext(): GraphContext { db: {} as Database, feature: "wiki_compose:research", userEmail: null, + contentLocale: "ja", }; } diff --git a/server/api/src/__tests__/agents/subgraphs/research/researchGraph.loop.test.ts b/server/api/src/__tests__/agents/subgraphs/research/researchGraph.loop.test.ts index b2e1600c..4b1fcf44 100644 --- a/server/api/src/__tests__/agents/subgraphs/research/researchGraph.loop.test.ts +++ b/server/api/src/__tests__/agents/subgraphs/research/researchGraph.loop.test.ts @@ -81,6 +81,7 @@ function fakeContext(graphId: string): GraphContext { db: {} as Database, feature: "wiki_compose:research", userEmail: null, + contentLocale: "ja", }; } diff --git a/server/api/src/__tests__/agents/subgraphs/research/researchGraph.modelGuard.test.ts b/server/api/src/__tests__/agents/subgraphs/research/researchGraph.modelGuard.test.ts index cc73f668..1c8f7c8b 100644 --- a/server/api/src/__tests__/agents/subgraphs/research/researchGraph.modelGuard.test.ts +++ b/server/api/src/__tests__/agents/subgraphs/research/researchGraph.modelGuard.test.ts @@ -91,6 +91,7 @@ function fakeContext(): GraphContext { db: {} as Database, feature: "wiki_compose:research", userEmail: null, + contentLocale: "ja", }; } diff --git a/server/api/src/__tests__/agents/subgraphs/research/researchGraph.resume.test.ts b/server/api/src/__tests__/agents/subgraphs/research/researchGraph.resume.test.ts index 756245d4..0385ff89 100644 --- a/server/api/src/__tests__/agents/subgraphs/research/researchGraph.resume.test.ts +++ b/server/api/src/__tests__/agents/subgraphs/research/researchGraph.resume.test.ts @@ -66,6 +66,7 @@ function fakeContext(): GraphContext { db: {} as Database, feature: "wiki_compose:research", userEmail: null, + contentLocale: "ja", }; } diff --git a/server/api/src/__tests__/agents/subgraphs/research/tools/webSearch.test.ts b/server/api/src/__tests__/agents/subgraphs/research/tools/webSearch.test.ts index f2cb7c85..98d06208 100644 --- a/server/api/src/__tests__/agents/subgraphs/research/tools/webSearch.test.ts +++ b/server/api/src/__tests__/agents/subgraphs/research/tools/webSearch.test.ts @@ -41,6 +41,7 @@ function ctxConfig(): { configurable: Record } { db: {} as Database, feature: "wiki_compose:research", userEmail: null, + contentLocale: "ja", } satisfies GraphContext, }, }; diff --git a/server/api/src/__tests__/agents/subgraphs/research/tools/wikiSearch.test.ts b/server/api/src/__tests__/agents/subgraphs/research/tools/wikiSearch.test.ts index d696231f..41cd0502 100644 --- a/server/api/src/__tests__/agents/subgraphs/research/tools/wikiSearch.test.ts +++ b/server/api/src/__tests__/agents/subgraphs/research/tools/wikiSearch.test.ts @@ -38,6 +38,7 @@ function ctxConfig(overrides: Partial = {}): { db: {} as Database, feature: "wiki_compose:research", userEmail: "alice@example.com", + contentLocale: "ja", ...overrides, } satisfies GraphContext, }, diff --git a/server/api/src/agents/core/composeLocale.ts b/server/api/src/agents/core/composeLocale.ts index 75a71da4..cefaeb1b 100644 --- a/server/api/src/agents/core/composeLocale.ts +++ b/server/api/src/agents/core/composeLocale.ts @@ -2,6 +2,8 @@ * Content locale for Wiki Compose LLM outputs (#950). * Wiki Compose の生成言語(ユーザー向けテキスト)を表す。 */ +import { resolveLocaleFromAcceptLanguage } from "../../services/invitationService.js"; + export type ComposeContentLocale = "ja" | "en"; /** @@ -28,20 +30,44 @@ export function resolveComposeContentLocale( ); if (fromInput) return fromInput; } - if (acceptLanguage) { - const primary = acceptLanguage.split(",")[0]?.trim().toLowerCase() ?? ""; - if (primary.startsWith("ja")) return "ja"; - if (primary.startsWith("en")) return "en"; - } + const fromHeader = resolveLocaleFromAcceptLanguage(acceptLanguage); + if (fromHeader) return fromHeader; return fallback; } +/** + * Read persisted `contentLocale` from a compose session metadata blob. + * compose セッション metadata に保存された `contentLocale` を読む。 + */ +export function readContentLocaleFromSessionMetadata( + metadata: unknown, +): ComposeContentLocale | null { + if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return null; + return normalizeComposeContentLocale((metadata as Record).contentLocale); +} + +/** + * Resolve locale for a session: metadata (first run) → input → Accept-Language → fallback. + * セッション用ロケール解決: metadata(初回 run で固定)→ input → Accept-Language。 + */ +export function resolveSessionContentLocale( + metadata: unknown, + input: unknown, + acceptLanguage: string | undefined | null, + fallback: ComposeContentLocale = "ja", +): ComposeContentLocale { + const persisted = readContentLocaleFromSessionMetadata(metadata); + if (persisted) return persisted; + return resolveComposeContentLocale(input, acceptLanguage, fallback); +} + /** * Strip `contentLocale` before passing input to LangGraph (not a state channel). * LangGraph に渡す前に `contentLocale` を除去する(state チャネルではない)。 */ export function stripContentLocaleFromGraphInput(raw: unknown): unknown { - if (!raw || typeof raw !== "object" || Array.isArray(raw)) return raw ?? {}; + if (raw === null || raw === undefined) return raw; + if (typeof raw !== "object" || Array.isArray(raw)) return raw; const { contentLocale: _removed, ...rest } = raw as Record; return rest; } diff --git a/server/api/src/agents/subgraphs/research/nodes/shared/getGraphContext.ts b/server/api/src/agents/subgraphs/research/nodes/shared/getGraphContext.ts index f2cb56f6..645aa94e 100644 --- a/server/api/src/agents/subgraphs/research/nodes/shared/getGraphContext.ts +++ b/server/api/src/agents/subgraphs/research/nodes/shared/getGraphContext.ts @@ -48,6 +48,6 @@ export function getGraphContext(config: LangGraphRunnableConfig | undefined): Gr const contentLocale = normalizeComposeContentLocale(ctx.contentLocale) ?? normalizeComposeContentLocale((ctx as { locale?: unknown }).locale) ?? - "en"; + "ja"; return { ...(ctx as GraphContext), contentLocale }; } diff --git a/server/api/src/routes/composeSessions.ts b/server/api/src/routes/composeSessions.ts index ea8f877f..94aaa9ee 100644 --- a/server/api/src/routes/composeSessions.ts +++ b/server/api/src/routes/composeSessions.ts @@ -62,7 +62,8 @@ import { RESEARCH_GRAPH_ID } from "../agents/subgraphs/research/index.js"; import { WIKI_COMPOSE_GRAPH_ID } from "../agents/graphs/wikiCompose/index.js"; import { WIKI_MAINTENANCE_GRAPH_ID } from "../agents/graphs/wikiMaintenance/index.js"; import { - resolveComposeContentLocale, + readContentLocaleFromSessionMetadata, + resolveSessionContentLocale, stripContentLocaleFromGraphInput, type ComposeContentLocale, } from "../agents/core/composeLocale.js"; @@ -250,7 +251,12 @@ app.get("/:pageId/compose-sessions/:id", authRequired, async (c) => { if (!row) throw new HTTPException(404, { message: "Session not found" }); const tier = await getUserTier(userId, db); - const contentLocale = resolveComposeContentLocale(null, c.req.header("accept-language"), "ja"); + const contentLocale = resolveSessionContentLocale( + row.metadata, + null, + c.req.header("accept-language"), + "ja", + ); // Stale / unsupported backend rows must still be readable; skip projection // instead of turning GET into a 500 (CodeRabbit P1 on reload path). @@ -359,6 +365,27 @@ app.post("/:pageId/compose-sessions/:id/run", authRequired, rateLimit(), async ( }); } + const acceptLanguage = c.req.header("accept-language"); + const contentLocale = resolveSessionContentLocale( + claimed.metadata, + body.input, + acceptLanguage, + "ja", + ); + if (!readContentLocaleFromSessionMetadata(claimed.metadata)) { + const baseMeta = + claimed.metadata && typeof claimed.metadata === "object" && !Array.isArray(claimed.metadata) + ? { ...(claimed.metadata as Record) } + : {}; + await db + .update(wikiComposeSessions) + .set({ + metadata: { ...baseMeta, contentLocale }, + updatedAt: new Date(), + }) + .where(and(eq(wikiComposeSessions.id, id), eq(wikiComposeSessions.pageId, pageId))); + } + return streamSSE(c, async (stream) => { const send = async (ev: SseEvent) => { await stream.writeSSE({ event: ev.type, data: JSON.stringify(ev) }); @@ -391,11 +418,6 @@ app.post("/:pageId/compose-sessions/:id/run", authRequired, rateLimit(), async ( // checkpoint 保存・再開を有効化する。テスト / CI では未設定なので `false` // を返し、LangGraph の checkpoint 機構を無効化したまま smoke-test で走る。 const checkpointer = await resolveCheckpointerForRun(); - const contentLocale = resolveComposeContentLocale( - body.input, - c.req.header("accept-language"), - "ja", - ); const graphInput = stripContentLocaleFromGraphInput(body.input ?? {}); try { @@ -531,7 +553,12 @@ app.patch("/:pageId/compose-sessions/:id/resume", authRequired, rateLimit(), asy // Resume relies on the checkpointer to fetch the suspended thread; production // routes load `PostgresSaver` here, tests/smoke runs get `false`. const checkpointer = await resolveCheckpointerForRun(); - const contentLocale = resolveComposeContentLocale(null, c.req.header("accept-language"), "ja"); + const contentLocale = resolveSessionContentLocale( + session.metadata, + null, + c.req.header("accept-language"), + "ja", + ); let result; try { diff --git a/server/api/src/routes/ingest.ts b/server/api/src/routes/ingest.ts index 533b45d0..a5ab93f7 100644 --- a/server/api/src/routes/ingest.ts +++ b/server/api/src/routes/ingest.ts @@ -37,6 +37,7 @@ import { IngestPlanParseError, type CandidatePage, } from "../services/ingestPlanner.js"; +import { resolveComposeContentLocale } from "../agents/core/composeLocale.js"; import { pages } from "../schema/pages.js"; import { pageContents } from "../schema/pageContents.js"; import { recordActivity } from "../services/activityLogService.js"; @@ -519,6 +520,7 @@ app.post("/graph/run", authRequired, rateLimit(), async (c) => { tier, db, feature: "ingest_graph:run", + contentLocale: resolveComposeContentLocale(null, c.req.header("accept-language"), "ja"), }, }, { @@ -619,6 +621,7 @@ app.post("/graph/resume", authRequired, rateLimit(), async (c) => { tier, db, feature: "ingest_graph:resume", + contentLocale: resolveComposeContentLocale(null, c.req.header("accept-language"), "ja"), }, }, body.resume, From 7871c9581e82b9fd59e2d82b0b1c5e21e06c4c40 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 26 May 2026 05:53:54 +0000 Subject: [PATCH 3/3] fix(wiki-compose): atomically persist contentLocale on session claim Combine running status and metadata locale into one update so a failed metadata write cannot strand the session in running without locale data. Co-authored-by: Akimasa Sugai --- server/api/src/routes/composeSessions.ts | 51 ++++++++++--------- .../resolveComposeContentLocale.ts | 9 +++- 2 files changed, 35 insertions(+), 25 deletions(-) diff --git a/server/api/src/routes/composeSessions.ts b/server/api/src/routes/composeSessions.ts index 94aaa9ee..058be247 100644 --- a/server/api/src/routes/composeSessions.ts +++ b/server/api/src/routes/composeSessions.ts @@ -343,11 +343,35 @@ app.post("/:pageId/compose-sessions/:id/run", authRequired, rateLimit(), async ( throw err; } - // Atomically claim the session so concurrent POST /run cannot both pass a - // read-then-write race and double-bill LLM usage. + const acceptLanguage = c.req.header("accept-language"); + const contentLocale = resolveSessionContentLocale( + session.metadata, + body.input, + acceptLanguage, + "ja", + ); + const shouldPersistLocale = !readContentLocaleFromSessionMetadata(session.metadata); + const metadataWithLocale = shouldPersistLocale + ? { + ...(session.metadata && + typeof session.metadata === "object" && + !Array.isArray(session.metadata) + ? { ...(session.metadata as Record) } + : {}), + contentLocale, + } + : undefined; + + // Atomically claim the session (and persist `contentLocale` when needed) so + // concurrent POST /run cannot double-bill and a failed follow-up write cannot + // leave the row stuck in `running` without metadata. const [claimed] = await db .update(wikiComposeSessions) - .set({ status: "running" satisfies WikiComposeSessionStatus, updatedAt: new Date() }) + .set({ + status: "running" satisfies WikiComposeSessionStatus, + updatedAt: new Date(), + ...(metadataWithLocale ? { metadata: metadataWithLocale } : {}), + }) .where( and( eq(wikiComposeSessions.id, id), @@ -365,27 +389,6 @@ app.post("/:pageId/compose-sessions/:id/run", authRequired, rateLimit(), async ( }); } - const acceptLanguage = c.req.header("accept-language"); - const contentLocale = resolveSessionContentLocale( - claimed.metadata, - body.input, - acceptLanguage, - "ja", - ); - if (!readContentLocaleFromSessionMetadata(claimed.metadata)) { - const baseMeta = - claimed.metadata && typeof claimed.metadata === "object" && !Array.isArray(claimed.metadata) - ? { ...(claimed.metadata as Record) } - : {}; - await db - .update(wikiComposeSessions) - .set({ - metadata: { ...baseMeta, contentLocale }, - updatedAt: new Date(), - }) - .where(and(eq(wikiComposeSessions.id, id), eq(wikiComposeSessions.pageId, pageId))); - } - return streamSSE(c, async (stream) => { const send = async (ev: SseEvent) => { await stream.writeSSE({ event: ev.type, data: JSON.stringify(ev) }); diff --git a/src/lib/wikiCompose/resolveComposeContentLocale.ts b/src/lib/wikiCompose/resolveComposeContentLocale.ts index 4ac542e2..194207d8 100644 --- a/src/lib/wikiCompose/resolveComposeContentLocale.ts +++ b/src/lib/wikiCompose/resolveComposeContentLocale.ts @@ -4,9 +4,16 @@ */ import i18n from "@/i18n"; +/** + * Supported Wiki Compose content locales (matches API `contentLocale`). + * Wiki Compose の生成言語(API の `contentLocale` と一致)。 + */ export type ComposeContentLocale = "ja" | "en"; -/** Returns `ja` or `en` from the active i18next language. */ +/** + * Returns `ja` or `en` from the active i18next language. + * 現在の i18next 言語から `ja` または `en` を返す。 + */ export function resolveComposeContentLocale(): ComposeContentLocale { const primary = i18n.language?.split("-")[0]?.toLowerCase(); return primary === "en" ? "en" : "ja";