From 7de1b5c085098fcfc60f99a08d4490f840989804 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 13:04:15 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20wiki=20compose=20P2=20=E2=80=94=20full?= =?UTF-8?q?=20orchestrator=20graph=20+=20split-screen=20UI=20(#950)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend (server/api/src/agents/graphs/wikiCompose/): - wikiComposeGraph orchestrator that walks Brief → Research → Structure → Draft → Completed in one LangGraph. State extends ResearchLoopState as a strict superset so the P1 research loop composes inline; interrupts at the three HITL points (brief / research / outline) halt the same thread_id. - new nodes: briefDialogue (0..7 structured questions), humanReviewBrief, structureDialogue (3..10 section outline), humanReviewOutline, draftSections (sequential per-section LLM streaming via model.stream), completed (markdown assembly + citation collation). - resume payload validators (briefResumeSchema, outlineResumeSchema) reject malformed payloads at the node boundary. - new SSE events compose_phase / compose_section so the frontend can drive the phase stepper + per-section streaming without inspecting state. - composeSessions route bumps recursion limit to 120 for the orchestrator (Brief + Research up to 5x6 nodes + Structure + up to 10 draft sections). Frontend (src/): - /notes/:noteId/:pageId/compose and /compose/:sessionId routes mount the new WikiComposePage (split-screen: left EditorPane with live section preview, right ComposePanel with PhaseStepper + Brief/Research/Outline sections + Activity timeline). Mobile uses vertical split. - useWikiComposeSession hook owns the SSE wiring and state machine. - composeService provides REST + SSE clients with a spec-compliant SSE parser (handles multi-chunk records, comments, multi-line data, aborts). - WikiGeneratorButton gains a composeHref mode that navigates to /compose and stays visible on pages that already have content (Compose supports append). Legacy inline-generation path unchanged when composeHref is absent. Tests: - vitest: orchestrator wiring + 3 interrupt points pinned with MemorySaver; SSE custom-event mapper extensions; SSE parser edge cases; hook state reductions for Brief / Draft / submitBrief flows; PhaseStepper a11y. - playwright (e2e/wiki-compose.spec.ts): full happy-path with mocked SSE routes — Compose entry → Brief submit → research approval → outline approval → completed Draft → back to /notes. Issue: otomatty/zedi#950 --- e2e/wiki-compose.spec.ts | 314 +++++++++++ .../wikiCompose/wikiComposeGraph.test.ts | 288 ++++++++++ .../__tests__/agents/runner/sseMapper.test.ts | 44 ++ server/api/src/agents/core/types/sseEvents.ts | 45 +- .../src/agents/graphs/wikiCompose/index.ts | 37 ++ .../graphs/wikiCompose/nodes/briefDialogue.ts | 150 ++++++ .../graphs/wikiCompose/nodes/completed.ts | 66 +++ .../graphs/wikiCompose/nodes/draftSections.ts | 239 +++++++++ .../wikiCompose/nodes/humanReviewBrief.ts | 91 ++++ .../wikiCompose/nodes/humanReviewOutline.ts | 46 ++ .../agents/graphs/wikiCompose/nodes/index.ts | 12 + .../wikiCompose/nodes/shared/dispatch.ts | 41 ++ .../nodes/shared/loadPageSnapshot.ts | 54 ++ .../wikiCompose/nodes/structureDialogue.ts | 135 +++++ .../graphs/wikiCompose/resumeSchemas.ts | 66 +++ .../src/agents/graphs/wikiCompose/state.ts | 195 +++++++ .../src/agents/graphs/wikiCompose/types.ts | 212 ++++++++ .../graphs/wikiCompose/wikiComposeGraph.ts | 141 +++++ server/api/src/agents/index.ts | 24 + server/api/src/agents/runner/sseMapper.ts | 40 ++ server/api/src/app.ts | 5 +- server/api/src/routes/composeSessions.ts | 10 +- src/App.tsx | 9 + src/components/editor/WikiGeneratorButton.tsx | 46 +- src/components/note/PageEditorContent.tsx | 25 +- .../wikiCompose/ActivitySection.tsx | 89 ++++ .../wikiCompose/BriefQuestionCard.tsx | 102 ++++ src/components/wikiCompose/ComposePanel.tsx | 110 ++++ .../wikiCompose/DialogueSection.tsx | 238 +++++++++ src/components/wikiCompose/EditorPane.tsx | 100 ++++ src/components/wikiCompose/OutlineEditor.tsx | 170 ++++++ .../wikiCompose/PhaseStepper.test.tsx | 25 + src/components/wikiCompose/PhaseStepper.tsx | 66 +++ .../wikiCompose/ResearchSection.tsx | 234 +++++++++ src/hooks/useWikiComposeSession.test.ts | 160 ++++++ src/hooks/useWikiComposeSession.ts | 493 ++++++++++++++++++ src/lib/wikiCompose/composeService.test.ts | 133 +++++ src/lib/wikiCompose/composeService.ts | 257 +++++++++ src/lib/wikiCompose/types.ts | 188 +++++++ src/pages/NotePageView.tsx | 6 + src/pages/WikiComposePage.tsx | 186 +++++++ 41 files changed, 4878 insertions(+), 14 deletions(-) create mode 100644 e2e/wiki-compose.spec.ts create mode 100644 server/api/src/__tests__/agents/graphs/wikiCompose/wikiComposeGraph.test.ts create mode 100644 server/api/src/agents/graphs/wikiCompose/index.ts create mode 100644 server/api/src/agents/graphs/wikiCompose/nodes/briefDialogue.ts create mode 100644 server/api/src/agents/graphs/wikiCompose/nodes/completed.ts create mode 100644 server/api/src/agents/graphs/wikiCompose/nodes/draftSections.ts create mode 100644 server/api/src/agents/graphs/wikiCompose/nodes/humanReviewBrief.ts create mode 100644 server/api/src/agents/graphs/wikiCompose/nodes/humanReviewOutline.ts create mode 100644 server/api/src/agents/graphs/wikiCompose/nodes/index.ts create mode 100644 server/api/src/agents/graphs/wikiCompose/nodes/shared/dispatch.ts create mode 100644 server/api/src/agents/graphs/wikiCompose/nodes/shared/loadPageSnapshot.ts create mode 100644 server/api/src/agents/graphs/wikiCompose/nodes/structureDialogue.ts create mode 100644 server/api/src/agents/graphs/wikiCompose/resumeSchemas.ts create mode 100644 server/api/src/agents/graphs/wikiCompose/state.ts create mode 100644 server/api/src/agents/graphs/wikiCompose/types.ts create mode 100644 server/api/src/agents/graphs/wikiCompose/wikiComposeGraph.ts create mode 100644 src/components/wikiCompose/ActivitySection.tsx create mode 100644 src/components/wikiCompose/BriefQuestionCard.tsx create mode 100644 src/components/wikiCompose/ComposePanel.tsx create mode 100644 src/components/wikiCompose/DialogueSection.tsx create mode 100644 src/components/wikiCompose/EditorPane.tsx create mode 100644 src/components/wikiCompose/OutlineEditor.tsx create mode 100644 src/components/wikiCompose/PhaseStepper.test.tsx create mode 100644 src/components/wikiCompose/PhaseStepper.tsx create mode 100644 src/components/wikiCompose/ResearchSection.tsx create mode 100644 src/hooks/useWikiComposeSession.test.ts create mode 100644 src/hooks/useWikiComposeSession.ts create mode 100644 src/lib/wikiCompose/composeService.test.ts create mode 100644 src/lib/wikiCompose/composeService.ts create mode 100644 src/lib/wikiCompose/types.ts create mode 100644 src/pages/WikiComposePage.tsx diff --git a/e2e/wiki-compose.spec.ts b/e2e/wiki-compose.spec.ts new file mode 100644 index 00000000..8f7d696f --- /dev/null +++ b/e2e/wiki-compose.spec.ts @@ -0,0 +1,314 @@ +/** + * Wiki Compose P2 happy-path E2E (issue #950). + * + * Compose の入口 → brief → 調査確認 → 構成 → 執筆 → 完了の流れを Playwright で + * 検証する。実 LLM / 実 API は使わず、`page.route` で `/api/pages/.../compose-sessions` + * 系を全てモックして wire 形式 (SSE) を再生する。 + * + * Drives the Compose split-screen UI through every interrupt point using a + * fully mocked SSE stream. Pins both the wire contract (the UI consumes the + * SSE shapes correctly) and the user-facing happy path without depending on + * a running API backend with real LLM access. + */ +import { test, expect } from "./auth-mock"; +import type { Page, Route } from "@playwright/test"; + +const NOTE_ID = "11111111-1111-4111-8111-111111111111"; +const PAGE_ID = "22222222-2222-4222-8222-222222222222"; +const SESSION_ID = "33333333-3333-4333-8333-333333333333"; + +const PAGE_SNAPSHOT = { + pageId: PAGE_ID, + title: "Photosynthesis", + body: "", + hasContent: false, +}; + +const BRIEF_QUESTION_ID = "qid-1"; +const BRIEF_OPTION_ID = "oid-1"; +const SOURCE_ID = "src:demo"; +const SECTION_ID = "sec-overview"; + +/** + * Encode a sequence of SSE-formatted events as a Uint8Array body. Each event + * gets `event:` + `data:` lines and a blank-line terminator. + */ +function sseBody(events: Array<{ type: string; payload: unknown }>): Uint8Array { + const parts = events.map( + ({ type, payload }) => `event: ${type}\ndata: ${JSON.stringify(payload)}\n\n`, + ); + return new TextEncoder().encode(parts.join("")); +} + +let runCount = 0; + +/** Per-run event sequences served by the mocked SSE endpoint. */ +function eventsForRun(n: number): Array<{ type: string; payload: unknown }> { + // Run 1: initial run → halt at Brief interrupt. + // Run 2: after Brief resume → halt at Research interrupt. + // Run 3: after Research resume → halt at Outline interrupt. + // Run 4: after Outline resume → stream Draft and complete. + switch (n) { + case 1: + return [ + { + type: "started", + payload: { type: "started", sessionId: SESSION_ID, graphId: "wiki-compose" }, + }, + { + type: "compose_phase", + payload: { type: "compose_phase", phase: "brief", status: "entered" }, + }, + { + type: "interrupt", + payload: { + type: "interrupt", + payload: { + kind: "human_review_brief", + questions: [ + { + id: BRIEF_QUESTION_ID, + question: "What's the audience for this article?", + rationale: "Helps the agent calibrate depth.", + required: false, + options: [ + { id: BRIEF_OPTION_ID, label: "General readers" }, + { id: "oid-2", label: "Specialists" }, + ], + }, + ], + pageSnapshot: PAGE_SNAPSHOT, + }, + }, + }, + { type: "done", payload: { type: "done", status: "interrupted" } }, + ]; + case 2: + return [ + { + type: "started", + payload: { type: "started", sessionId: SESSION_ID, graphId: "wiki-compose" }, + }, + { + type: "compose_phase", + payload: { type: "compose_phase", phase: "research", status: "entered" }, + }, + { + type: "interrupt", + payload: { + type: "interrupt", + payload: { + kind: "human_review_research", + batch: { + id: "batch-1", + iteration: 0, + queries: [], + sources: [], + evaluation: null, + createdAt: new Date().toISOString(), + }, + pendingSources: [ + { + id: SOURCE_ID, + kind: "web", + title: "Photosynthesis — Britannica", + url: "https://example.com/photosynthesis", + snippet: "Photosynthesis converts light energy…", + }, + ], + }, + }, + }, + { type: "done", payload: { type: "done", status: "interrupted" } }, + ]; + case 3: + return [ + { + type: "started", + payload: { type: "started", sessionId: SESSION_ID, graphId: "wiki-compose" }, + }, + { + type: "compose_phase", + payload: { type: "compose_phase", phase: "structure", status: "entered" }, + }, + { + type: "interrupt", + payload: { + type: "interrupt", + payload: { + kind: "human_review_outline", + outline: [ + { + id: SECTION_ID, + heading: "Overview", + depth: 1, + intent: "Brief introduction", + }, + ], + approvedSources: [ + { + id: SOURCE_ID, + kind: "web", + title: "Photosynthesis — Britannica", + url: "https://example.com/photosynthesis", + snippet: "Photosynthesis converts light energy…", + }, + ], + }, + }, + }, + { type: "done", payload: { type: "done", status: "interrupted" } }, + ]; + case 4: + return [ + { + type: "started", + payload: { type: "started", sessionId: SESSION_ID, graphId: "wiki-compose" }, + }, + { + type: "compose_phase", + payload: { type: "compose_phase", phase: "draft", status: "entered" }, + }, + { + type: "compose_section", + payload: { + type: "compose_section", + sectionId: SECTION_ID, + heading: "Overview", + status: "started", + index: 1, + total: 1, + }, + }, + { type: "token", payload: { type: "token", node: "draft_sections", content: "Photo" } }, + { + type: "token", + payload: { type: "token", node: "draft_sections", content: "synthesis." }, + }, + { + type: "compose_section", + payload: { + type: "compose_section", + sectionId: SECTION_ID, + heading: "Overview", + status: "completed", + index: 1, + total: 1, + }, + }, + { + type: "compose_phase", + payload: { type: "compose_phase", phase: "completed", status: "entered" }, + }, + { type: "done", payload: { type: "done", status: "completed" } }, + ]; + default: + return [{ type: "done", payload: { type: "done", status: "completed" } }]; + } +} + +/** Install the Compose API mocks (create / get / run / resume / cancel). */ +async function installComposeMocks(page: Page): Promise { + runCount = 0; + + // POST /compose-sessions — create. + await page.route(`**/api/pages/${PAGE_ID}/compose-sessions`, async (route: Route) => { + if (route.request().method() === "POST") { + await route.fulfill({ + status: 201, + contentType: "application/json", + body: JSON.stringify({ + session: { + id: SESSION_ID, + pageId: PAGE_ID, + userId: "user-1", + graphId: "wiki-compose", + backend: "zedi_managed", + phase: "init", + status: "pending", + metadata: null, + lastError: null, + closedAt: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + }), + }); + return; + } + await route.fallback(); + }); + + // POST /compose-sessions/:id/run — SSE. + await page.route( + `**/api/pages/${PAGE_ID}/compose-sessions/${SESSION_ID}/run`, + async (route: Route) => { + runCount += 1; + const body = sseBody(eventsForRun(runCount)); + await route.fulfill({ + status: 200, + headers: { "content-type": "text/event-stream", "cache-control": "no-cache" }, + body: Buffer.from(body), + }); + }, + ); + + // PATCH /compose-sessions/:id/resume. + await page.route( + `**/api/pages/${PAGE_ID}/compose-sessions/${SESSION_ID}/resume`, + async (route: Route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ status: "interrupted", output: null }), + }); + }, + ); +} + +test.describe("Wiki Compose P2 happy path", () => { + test.setTimeout(60_000); + + test("walks Brief → Research → Outline → Draft → Completed", async ({ page }) => { + await installComposeMocks(page); + + await page.goto(`/notes/${NOTE_ID}/${PAGE_ID}/compose`); + + // Brief interrupt — question card appears. + 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(); + + // Pick an option and submit. + await page.getByTestId(`brief-option-${BRIEF_OPTION_ID}`).click(); + await page.getByTestId("submit-brief").click(); + + // Research interrupt — source review card appears. + const sourceRow = page.getByTestId(`source-row-${SOURCE_ID}`); + await expect(sourceRow).toBeVisible({ timeout: 10000 }); + + // Approve all sources and continue. + await page.getByTestId("research-submit").click(); + + // Outline interrupt — outline row appears. + const outlineRow = page.getByTestId(`outline-row-${SECTION_ID}`); + await expect(outlineRow).toBeVisible({ timeout: 10000 }); + + // Approve outline and continue. + await page.getByTestId("outline-submit").click(); + + // Draft phase — phase stepper advances to completed and the editor pane + // renders the streamed body. + await expect(page.getByTestId("phase-step-completed")).toHaveAttribute("aria-current", "step", { + timeout: 10000, + }); + await expect(page.getByTestId(`editor-section-${SECTION_ID}`)).toContainText( + "Photosynthesis.", + { timeout: 10000 }, + ); + + // Back button returns to the page. + await page.getByTestId("compose-back").click(); + await expect(page).toHaveURL(`/notes/${NOTE_ID}/${PAGE_ID}`); + }); +}); diff --git a/server/api/src/__tests__/agents/graphs/wikiCompose/wikiComposeGraph.test.ts b/server/api/src/__tests__/agents/graphs/wikiCompose/wikiComposeGraph.test.ts new file mode 100644 index 00000000..bd2d6c0c --- /dev/null +++ b/server/api/src/__tests__/agents/graphs/wikiCompose/wikiComposeGraph.test.ts @@ -0,0 +1,288 @@ +/** + * Wiki Compose orchestrator graph (#950) — wiring + interrupt tests. + * + * 受け入れ条件 #1 / #6 / 技術 #1: + * - `wikiComposeGraph` が P1 subgraph を組み込んでいる (channels 共有で表現) + * - Brief → research → outline → draft の happy path が動く + * - 各 interrupt 位置で halt し、resume で次フェーズに進む + * + * Mocks every LLM-backed node so the test pins the graph wiring rather than + * model quality. MemorySaver is used as a checkpointer so interrupts can + * resume on the same thread id. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { + briefDialogue, + structureDialogue, + draftSections, + planQueries, + webSearch, + wikiSearch, + fetchArticles, + evaluateSufficiency, + refineQueries, + compileBatch, +} = vi.hoisted(() => ({ + briefDialogue: vi.fn(), + structureDialogue: vi.fn(), + draftSections: vi.fn(), + planQueries: vi.fn(), + webSearch: vi.fn(), + wikiSearch: vi.fn(), + fetchArticles: vi.fn(), + evaluateSufficiency: vi.fn(), + refineQueries: vi.fn(), + compileBatch: vi.fn(), +})); + +// Real nodes preserved: humanReviewBrief, humanReviewOutline, completed, +// humanReviewResearch (interrupts must be exercised, not mocked away). +// Real interrupt/projection nodes are kept; only LLM-backed nodes are mocked. +vi.mock("../../../../agents/graphs/wikiCompose/nodes/index.js", async () => { + const real = await vi.importActual< + typeof import("../../../../agents/graphs/wikiCompose/nodes/index.js") + >("../../../../agents/graphs/wikiCompose/nodes/index.js"); + return { + ...real, + briefDialogue, + structureDialogue, + draftSections, + }; +}); + +vi.mock("../../../../agents/subgraphs/research/nodes/index.js", async () => { + const real = await vi.importActual< + typeof import("../../../../agents/subgraphs/research/nodes/index.js") + >("../../../../agents/subgraphs/research/nodes/index.js"); + return { + ...real, + planQueries, + webSearch, + wikiSearch, + fetchArticles, + evaluateSufficiency, + refineQueries, + compileBatch, + }; +}); + +import { GraphRunner } from "../../../../agents/runner/graphRunner.js"; +import { __resetRegistryForTests } from "../../../../agents/registry/graphRegistry.js"; +import { + WIKI_COMPOSE_GRAPH_ID, + registerWikiComposeGraph, +} from "../../../../agents/graphs/wikiCompose/index.js"; +import type { GraphContext } from "../../../../agents/core/types/graphContext.js"; +import type { Database } from "../../../../types/index.js"; +import { MemorySaver } from "@langchain/langgraph"; + +function fakeContext(threadId: string): GraphContext { + return { + threadId, + sessionId: threadId, + userId: "user-1", + pageId: "page-1", + graphId: WIKI_COMPOSE_GRAPH_ID, + backend: "zedi_managed", + tier: "free", + db: {} as Database, + feature: "wiki_compose:test", + userEmail: null, + }; +} + +function defaultMocks() { + briefDialogue.mockImplementation(async () => ({ + briefQuestions: [ + { + id: "q-1", + question: "What scope?", + options: [ + { id: "opt-a", label: "broad" }, + { id: "opt-b", label: "narrow" }, + ], + required: false, + }, + ], + pageSnapshot: { pageId: "page-1", title: "Hello", body: "", hasContent: false }, + phase: "brief:await_user", + })); + + planQueries.mockImplementation(async () => ({ + queries: [{ id: "q1", query: "topic", channels: ["web"] }], + maxIterations: 3, + iteration: 0, + lastEvaluation: null, + exitReason: null, + phase: "research:plan", + })); + webSearch.mockImplementation(async () => ({ + pendingSources: [{ id: "src:abc", kind: "web", title: "A", url: "https://a/" }], + })); + wikiSearch.mockImplementation(async () => ({ pendingSources: [] })); + fetchArticles.mockImplementation(async () => ({ pendingSources: [] })); + evaluateSufficiency.mockImplementation(async (state: { iteration: number }) => ({ + lastEvaluation: { score: 0.9, rationale: "ok", missingAspects: [] }, + iteration: state.iteration + 1, + phase: "research:evaluated", + })); + compileBatch.mockImplementation( + async (state: { + iteration: number; + queries: unknown[]; + pendingSources: unknown[]; + lastEvaluation: unknown; + }) => ({ + batches: [ + { + id: "batch-1", + iteration: state.iteration, + queries: state.queries, + sources: state.pendingSources, + evaluation: state.lastEvaluation, + createdAt: "2026-01-01T00:00:00.000Z", + }, + ], + exitReason: "score_threshold", + phase: "research:compile", + }), + ); + + structureDialogue.mockImplementation(async () => ({ + outlineProposal: [ + { id: "sec-1", heading: "Overview", depth: 1, intent: "intro" }, + { id: "sec-2", heading: "Details", depth: 1, intent: "deep dive" }, + ], + phase: "structure:await_user", + })); + + draftSections.mockImplementation(async () => ({ + draftedSections: [ + { + sectionId: "sec-1", + heading: "Overview", + body: "Body 1 [#1]", + citedSourceIds: ["src:abc"], + completedAt: "2026-01-01T00:00:01.000Z", + }, + { + sectionId: "sec-2", + heading: "Details", + body: "Body 2", + citedSourceIds: [], + completedAt: "2026-01-01T00:00:02.000Z", + }, + ], + phase: "draft:completed", + })); +} + +describe("wikiComposeGraph — orchestrator wiring", () => { + beforeEach(() => { + __resetRegistryForTests(); + registerWikiComposeGraph(); + briefDialogue.mockReset(); + structureDialogue.mockReset(); + draftSections.mockReset(); + planQueries.mockReset(); + webSearch.mockReset(); + wikiSearch.mockReset(); + fetchArticles.mockReset(); + evaluateSufficiency.mockReset(); + refineQueries.mockReset(); + compileBatch.mockReset(); + defaultMocks(); + }); + afterEach(() => { + __resetRegistryForTests(); + }); + + it("halts at the Brief interrupt on first run", async () => { + const runner = new GraphRunner(); + const result = await runner.invoke( + { + graphId: WIKI_COMPOSE_GRAPH_ID, + context: fakeContext("thread-brief"), + checkpointer: new MemorySaver(), + recursionLimit: 120, + }, + { kind: "input", value: { messages: [{ role: "user", content: "title: Hello" }] } }, + ); + + expect(result.status).toBe("interrupted"); + expect(briefDialogue).toHaveBeenCalledTimes(1); + // Should not have advanced past Brief before user resumes. + // Brief 確定前に research が走らないことを担保する。 + expect(planQueries).not.toHaveBeenCalled(); + }); + + it("advances to the research interrupt after Brief resume", async () => { + const checkpointer = new MemorySaver(); + const runner = new GraphRunner(); + const ctx = fakeContext("thread-research"); + + await runner.invoke( + { graphId: WIKI_COMPOSE_GRAPH_ID, context: ctx, checkpointer, recursionLimit: 120 }, + { kind: "input", value: { messages: [{ role: "user", content: "title: Hello" }] } }, + ); + + const resumed = await runner.resume( + { graphId: WIKI_COMPOSE_GRAPH_ID, context: ctx, checkpointer, recursionLimit: 120 }, + { answers: [], appendToExisting: false }, + ); + + expect(resumed.status).toBe("interrupted"); + expect(planQueries).toHaveBeenCalledTimes(1); + expect(compileBatch).toHaveBeenCalledTimes(1); + // Structure has not started yet — outline must wait for research approval. + // research 承認前に structure_dialogue が呼ばれないことを担保。 + expect(structureDialogue).not.toHaveBeenCalled(); + }); + + it("reaches Draft after research and outline resumes", async () => { + const checkpointer = new MemorySaver(); + const runner = new GraphRunner(); + const ctx = fakeContext("thread-draft"); + + // 1. Initial run halts at human_review_brief. + await runner.invoke( + { graphId: WIKI_COMPOSE_GRAPH_ID, context: ctx, checkpointer, recursionLimit: 120 }, + { kind: "input", value: { messages: [{ role: "user", content: "title: Hello" }] } }, + ); + // 2. Brief resume → halts at human_review_research. + await runner.resume( + { graphId: WIKI_COMPOSE_GRAPH_ID, context: ctx, checkpointer, recursionLimit: 120 }, + { answers: [], appendToExisting: false }, + ); + // 3. Research resume → halts at human_review_outline. + const outlineHalt = await runner.resume( + { graphId: WIKI_COMPOSE_GRAPH_ID, context: ctx, checkpointer, recursionLimit: 120 }, + { approvedSourceIds: ["src:abc"] }, + ); + expect(outlineHalt.status).toBe("interrupted"); + expect(structureDialogue).toHaveBeenCalledTimes(1); + expect(draftSections).not.toHaveBeenCalled(); + + // 4. Outline resume → runs Draft → completed. + const finalRun = await runner.resume( + { graphId: WIKI_COMPOSE_GRAPH_ID, context: ctx, checkpointer, recursionLimit: 120 }, + { + sections: [ + { id: "sec-1", heading: "Overview", depth: 1, intent: "intro" }, + { id: "sec-2", heading: "Details", depth: 1, intent: "deep dive" }, + ], + }, + ); + expect(finalRun.status).toBe("completed"); + expect(draftSections).toHaveBeenCalledTimes(1); + + const finalState = finalRun.output as { + completion?: { markdown?: string; sections?: unknown[] }; + }; + expect(finalState.completion).toBeTruthy(); + expect(finalState.completion?.sections).toHaveLength(2); + expect(finalState.completion?.markdown).toMatch(/Overview/); + expect(finalState.completion?.markdown).toMatch(/Details/); + }); +}); diff --git a/server/api/src/__tests__/agents/runner/sseMapper.test.ts b/server/api/src/__tests__/agents/runner/sseMapper.test.ts index ed6d6170..b8fdcac4 100644 --- a/server/api/src/__tests__/agents/runner/sseMapper.test.ts +++ b/server/api/src/__tests__/agents/runner/sseMapper.test.ts @@ -127,4 +127,48 @@ describe("mapLangGraphEvent", () => { it("returns an empty array for unrecognised events", () => { expect(mapLangGraphEvent({ event: "on_unknown_event" })).toEqual([]); }); + + it("maps on_custom_event compose_phase to a typed compose_phase event", () => { + const ev: LangGraphRuntimeEvent = { + event: "on_custom_event", + name: "compose_phase", + data: { phase: "structure", status: "entered" }, + }; + expect(mapLangGraphEvent(ev)).toEqual([ + { type: "compose_phase", phase: "structure", status: "entered" }, + ]); + }); + + it("drops compose_phase with an unknown phase value", () => { + const ev: LangGraphRuntimeEvent = { + event: "on_custom_event", + name: "compose_phase", + data: { phase: "bogus", status: "entered" }, + }; + expect(mapLangGraphEvent(ev)).toEqual([]); + }); + + it("maps on_custom_event compose_section to a typed compose_section event", () => { + const ev: LangGraphRuntimeEvent = { + event: "on_custom_event", + name: "compose_section", + data: { + sectionId: "sec-1", + heading: "Overview", + status: "started", + index: 1, + total: 3, + }, + }; + expect(mapLangGraphEvent(ev)).toEqual([ + { + type: "compose_section", + sectionId: "sec-1", + heading: "Overview", + status: "started", + index: 1, + total: 3, + }, + ]); + }); }); diff --git a/server/api/src/agents/core/types/sseEvents.ts b/server/api/src/agents/core/types/sseEvents.ts index 8af30f3d..6983f45b 100644 --- a/server/api/src/agents/core/types/sseEvents.ts +++ b/server/api/src/agents/core/types/sseEvents.ts @@ -167,6 +167,45 @@ export interface SseResearchBatchEvent { exitReason: "score_threshold" | "max_iterations"; } +/** + * Wiki Compose 全体グラフ (#950) のフェーズ進捗通知。Brief → Research → Structure + * → Draft → Completed の遷移時に 1 件ずつ発火し、フロントの PhaseStepper の + * 進行表示に使う。`status` フィールドで「開始」「完了」を区別する。 + * + * Orchestrator phase transition event. Emitted on enter / exit of each + * top-level phase so the frontend phase stepper can advance without + * inspecting state. + */ +export interface SseComposePhaseEvent { + type: "compose_phase"; + /** Phase name (matches state.phase). */ + phase: "brief" | "research" | "structure" | "draft" | "completed"; + /** Lifecycle hint within the phase. */ + status: "entered" | "completed"; +} + +/** + * Wiki Compose 全体グラフ (#950) のセクション ドラフト進捗通知。`draftSections` + * が 1 セクションを書き始める前 / 書き終わった後にそれぞれ 1 件発火する。 + * + * Per-section draft progress event. Emitted by `draft_sections` at the start + * and end of each section so the editor pane can highlight the section that + * is currently streaming. + */ +export interface SseComposeSectionEvent { + type: "compose_section"; + /** Matches `OutlineSection.id`. */ + sectionId: string; + /** Final / running heading. */ + heading: string; + /** Lifecycle: `started` before streaming, `completed` after the body is finalised. */ + status: "started" | "completed"; + /** 1-based index of this section within the outline. */ + index: number; + /** Total number of sections in the outline. */ + total: number; +} + /** * Wire-level SSE union. */ @@ -182,7 +221,9 @@ export type SseEvent = | SseErrorEvent | SseResearchIterationEvent | SseResearchEvaluationEvent - | SseResearchBatchEvent; + | SseResearchBatchEvent + | SseComposePhaseEvent + | SseComposeSectionEvent; /** * SSE event 名(`event:` 行に流す名前)。`SseEvent["type"]` と同値だが、 @@ -204,4 +245,6 @@ export const SSE_EVENT_NAMES = { researchIteration: "research_iteration", researchEvaluation: "research_evaluation", researchBatch: "research_batch", + composePhase: "compose_phase", + composeSection: "compose_section", } as const satisfies Record; diff --git a/server/api/src/agents/graphs/wikiCompose/index.ts b/server/api/src/agents/graphs/wikiCompose/index.ts new file mode 100644 index 00000000..108a0512 --- /dev/null +++ b/server/api/src/agents/graphs/wikiCompose/index.ts @@ -0,0 +1,37 @@ +/** + * Wiki Compose orchestrator graph (#950) — public barrel. + * + * 全体グラフの外向け window。`app.ts` / `agents/index.ts` からこのファイル経由で + * `WIKI_COMPOSE_GRAPH_ID` と `registerWikiComposeGraph` を引く。直接ノードを + * import したいテストは `./nodes/index.js` を見る。 + */ +export { + WIKI_COMPOSE_GRAPH_ID, + WIKI_COMPOSE_GRAPH_VERSION, + registerWikiComposeGraph, +} from "./wikiComposeGraph.js"; +export { + WikiComposeState, + type WikiComposeStateType, + type WikiComposeStateUpdate, +} from "./state.js"; +export type { + BriefAnswer, + BriefOption, + BriefQuestion, + BriefResult, + BriefResumeInput, + ApprovedOutline, + ComposeCompletion, + DraftedSection, + OutlineResumeInput, + OutlineSection, + PageSnapshot, + WikiComposeInterruptPayload, +} from "./types.js"; +export { + briefResumeSchema, + type BriefResumeParsed, + outlineResumeSchema, + type OutlineResumeParsed, +} from "./resumeSchemas.js"; diff --git a/server/api/src/agents/graphs/wikiCompose/nodes/briefDialogue.ts b/server/api/src/agents/graphs/wikiCompose/nodes/briefDialogue.ts new file mode 100644 index 00000000..be4a5ec6 --- /dev/null +++ b/server/api/src/agents/graphs/wikiCompose/nodes/briefDialogue.ts @@ -0,0 +1,150 @@ +/** + * `brief_dialogue` — Wiki Compose orchestrator entry node (#950). + * + * Brief フェーズの最初のノード。ページタイトル + 既存本文プレビューから、 + * 0〜7 件の構造化質問を Orchestrator LLM に生成させる。`compose_phase` SSE を + * `entered` で発火し、生成後は `briefQuestions` を state に書き、`phase` を + * `brief:await_user` にして次の `human_review_brief` interrupt に進む。 + * + * Brief never opens a free-form chat — it always emits the question cards + * that the frontend renders (the user fills them in and resumes). The node + * also loads the page snapshot exactly once at session start so downstream + * phases can read it without re-querying. + */ +import type { LangGraphRunnableConfig } from "@langchain/langgraph"; +import { randomUUID } from "node:crypto"; +import { z } from "zod"; +import { createZediChatModel } from "../../../core/llm/modelFactory.js"; +import { getGraphContext } from "../../../subgraphs/research/nodes/shared/getGraphContext.js"; +import { loadPageSnapshot } from "./shared/loadPageSnapshot.js"; +import { dispatchComposePhase } from "./shared/dispatch.js"; +import type { WikiComposeStateType, WikiComposeStateUpdate } from "../state.js"; +import type { BriefQuestion } from "../types.js"; + +const ORCHESTRATOR_MODEL_ENV = "WIKI_COMPOSE_ORCHESTRATOR_MODEL_ID"; +const ORCHESTRATOR_MODEL_FALLBACK = "claude-3-5-haiku"; + +function getOrchestratorModelId(): string { + return process.env[ORCHESTRATOR_MODEL_ENV]?.trim() || ORCHESTRATOR_MODEL_FALLBACK; +} + +/** + * Schema for the LLM's structured output. The Orchestrator is told it MAY + * return zero questions when the title is unambiguous (e.g. a single specific + * proper noun with existing content). Hard cap at 7 to keep the UI scannable. + */ +export const briefQuestionsSchema = z.object({ + questions: z + .array( + z.object({ + question: z.string().min(1).max(200), + rationale: z.string().max(200).optional(), + options: z + .array( + z.object({ + label: z.string().min(1).max(80), + hint: z.string().max(160).optional(), + }), + ) + .max(6) + .default([]), + required: z.boolean().default(false), + }), + ) + .min(0) + .max(7), +}); + +const SYSTEM_PROMPT = + "You are the orchestrator for Wiki Compose, an AI agent that helps a user " + + "co-author a wiki article. Given a page title (and optional existing body), " + + "decide what Brief questions (if any) you need to ask before research. " + + "Constraints:\n" + + "1. Output 0..7 questions. Prefer FEWER questions; only ask what is needed " + + "to disambiguate scope, audience, or depth.\n" + + "2. Each question MUST be answerable via option chips when reasonable " + + "(2..6 options). Free-text is always allowed on top, so don't add a " + + "trailing 'other' option.\n" + + "3. If the existing body is non-empty, you may include a question that " + + "asks whether to append or replace.\n" + + "4. Mark a question 'required: true' ONLY when leaving it unanswered would " + + "make the article unwritable. Most questions should be optional.\n" + + "Respond as JSON only."; + +function buildUserPrompt(title: string, body: string): string { + const parts: string[] = [`[Page title]`, title || "(no title yet)"]; + if (body.trim()) { + parts.push( + "", + "[Existing body excerpt — first ~600 chars]", + body.slice(0, 600), + body.length > 600 ? `\n(…truncated; total ${body.length} chars)` : "", + ); + } else { + parts.push("", "(Page body is empty.)"); + } + return parts.join("\n"); +} + +/** + * `brief_dialogue` node — generates the Brief question cards and stamps the + * `pageSnapshot` into state. + */ +export async function briefDialogue( + state: WikiComposeStateType, + config: LangGraphRunnableConfig, +): Promise { + const ctx = getGraphContext(config); + + await dispatchComposePhase({ phase: "brief", status: "entered" }, config); + + // Load the snapshot once. Subsequent phases read from state, never the DB. + // セッション開始時に 1 度だけ読み、以後は state を参照する。 + const snapshot = state.pageSnapshot ?? (await loadPageSnapshot(ctx.db, ctx.pageId)); + + const model = await createZediChatModel({ + modelId: getOrchestratorModelId(), + userId: ctx.userId, + tier: ctx.tier, + db: ctx.db, + feature: `${ctx.feature}:brief`, + backend: ctx.backend, + temperature: 0.3, + maxTokens: 1024, + }); + const structured = model.withStructuredOutput(briefQuestionsSchema, { name: "brief_dialogue" }); + + // `structured.invoke` returns the zod input type (pre-default), so we + // accept it as-is and apply fallbacks at the projection step below. + let raw: z.input; + try { + raw = await structured.invoke([ + { role: "system", content: SYSTEM_PROMPT }, + { role: "user", content: buildUserPrompt(snapshot.title, snapshot.body) }, + ]); + } catch { + // Defensive fallback: if the LLM call fails, emit an empty Brief so the + // user can still proceed straight to research. The orchestrator must not + // become unstartable just because of a transient model error. + // LLM 失敗時は Brief 0 件で先へ進ませる安全策。 + raw = { questions: [] }; + } + + const briefQuestions: BriefQuestion[] = raw.questions.map((q) => ({ + id: randomUUID(), + question: q.question, + rationale: q.rationale, + options: (q.options ?? []).map((o) => ({ + id: randomUUID(), + label: o.label, + hint: o.hint, + })), + required: Boolean(q.required), + })); + + return { + pageSnapshot: snapshot, + briefQuestions, + phase: "brief:await_user", + }; +} diff --git a/server/api/src/agents/graphs/wikiCompose/nodes/completed.ts b/server/api/src/agents/graphs/wikiCompose/nodes/completed.ts new file mode 100644 index 00000000..ab535017 --- /dev/null +++ b/server/api/src/agents/graphs/wikiCompose/nodes/completed.ts @@ -0,0 +1,66 @@ +/** + * `completed` — Wiki Compose terminal node (#950). + * + * Draft フェーズ後の最終ノード。`draftedSections` を `approvedOutline` の順に + * 並べ替えて Markdown を組み立て、`completion` に書き込む。citation source は + * `approvedResearch` から実際に引用された分だけ抽出する。`compose_phase` SSE + * を `completed` で発火し、ストリームを終了する。 + * + * Pure projection node. Sequences `draftedSections` by `approvedOutline` + * order, concatenates them with `## heading` lines, and collates the cited + * sources for the final compose output. No LLM call. + */ +import type { LangGraphRunnableConfig } from "@langchain/langgraph"; +import { dispatchComposePhase } from "./shared/dispatch.js"; +import type { WikiComposeStateType, WikiComposeStateUpdate } from "../state.js"; +import type { ComposeCompletion, DraftedSection, Source } from "../types.js"; + +/** `completed` node — final projection. */ +export async function completed( + state: WikiComposeStateType, + config: LangGraphRunnableConfig, +): Promise { + const outline = state.approvedOutline?.sections ?? []; + const draftById = new Map(); + for (const d of state.draftedSections) draftById.set(d.sectionId, d); + + // Walk the outline so the final order matches the user's approved layout + // even if `draftedSections` was filled in a different order (mid-flight + // re-draft, etc.). + // ユーザー承認済みアウトラインの順に並べる。 + const ordered: DraftedSection[] = []; + for (const section of outline) { + const drafted = draftById.get(section.id); + if (drafted) ordered.push(drafted); + } + + const lines: string[] = []; + for (const section of outline) { + const drafted = draftById.get(section.id); + if (!drafted) continue; + const prefix = "#".repeat(Math.min(3, Math.max(2, section.depth + 1))); + lines.push(`${prefix} ${section.heading}`); + lines.push(""); + lines.push(drafted.body); + lines.push(""); + } + const markdown = lines.join("\n").trim() + "\n"; + + const citedIds = new Set(); + for (const d of ordered) for (const id of d.citedSourceIds) citedIds.add(id); + const citedSources: Source[] = state.approvedResearch.filter((s) => citedIds.has(s.id)); + + const completion: ComposeCompletion = { + markdown, + sections: ordered, + citedSources, + completedAt: new Date().toISOString(), + }; + + await dispatchComposePhase({ phase: "completed", status: "entered" }, config); + + return { + completion, + phase: "completed", + }; +} diff --git a/server/api/src/agents/graphs/wikiCompose/nodes/draftSections.ts b/server/api/src/agents/graphs/wikiCompose/nodes/draftSections.ts new file mode 100644 index 00000000..52e1976d --- /dev/null +++ b/server/api/src/agents/graphs/wikiCompose/nodes/draftSections.ts @@ -0,0 +1,239 @@ +/** + * `draft_sections` — Wiki Compose Draft phase node (#950). + * + * 承認済みアウトラインの各セクションを LLM ストリーミングで本文化する。 + * セクションごとに `compose_section { status: "started" }` を発火し、LLM の + * `streamEvents` 経由でトークンが SSE `token` イベントとして流れる + * (`sseMapper.mapChatModelStream` が拾う)。1 セクション完了ごとに + * `compose_section { status: "completed" }` を出し、`draftedSections` に追記する。 + * + * Sequential per-section streaming: each section is streamed as a single + * `stream()` call so the SSE wire produces a `token` event per chunk under + * the `draft_sections` node label, which the frontend uses to incrementally + * paint into the EditorPane. + */ +import type { LangGraphRunnableConfig } from "@langchain/langgraph"; +import { createZediChatModel } from "../../../core/llm/modelFactory.js"; +import { getGraphContext } from "../../../subgraphs/research/nodes/shared/getGraphContext.js"; +import { dispatchComposePhase, dispatchComposeSection } from "./shared/dispatch.js"; +import type { WikiComposeStateType, WikiComposeStateUpdate } from "../state.js"; +import type { DraftedSection, OutlineSection, Source } from "../types.js"; + +const DRAFT_MODEL_ENV = "WIKI_COMPOSE_DRAFT_MODEL_ID"; +const DRAFT_MODEL_FALLBACK = "claude-3-5-sonnet"; + +function getDraftModelId(): string { + return process.env[DRAFT_MODEL_ENV]?.trim() || DRAFT_MODEL_FALLBACK; +} + +const SECTION_SYSTEM_PROMPT = + "You are a co-author writing one section of a wiki article. Constraints:\n" + + "1. Output Markdown body ONLY — do NOT repeat the heading line.\n" + + "2. Stay focused on the section's intent. Do not introduce content that " + + "belongs to a sibling section.\n" + + "3. Cite sources inline as `[#N]` referring to the numbered approved " + + "research list. Only cite sources that genuinely support the claim.\n" + + "4. Aim for ~250–500 words. Use sub-headings only when depth=2 is " + + "specified for sub-sections within the same draft pass.\n" + + "5. Plain Markdown; no HTML, no YAML frontmatter."; + +function numberedSourceList(sources: Source[], allowedIds?: string[]): string[] { + const allow = allowedIds && allowedIds.length > 0 ? new Set(allowedIds) : null; + return sources + .filter((s) => !allow || allow.has(s.id)) + .map((s, i) => { + const tag = s.kind.toUpperCase(); + const url = s.finalUrl ?? s.url ?? ""; + const blurb = s.excerpt ?? s.snippet ?? ""; + const tail = blurb ? `\n ${blurb.slice(0, 240)}` : ""; + return `[#${i + 1}] (${tag}) ${s.title}${url ? ` — ${url}` : ""}${tail}`; + }); +} + +function buildSectionPrompt(args: { + pageTitle: string; + section: OutlineSection; + outline: OutlineSection[]; + briefSummary: string; + sources: Source[]; +}): string { + const { pageTitle, section, outline, briefSummary, sources } = args; + const outlineList = outline.map((s) => { + const indent = " ".repeat(Math.max(0, s.depth - 1)); + const marker = s.id === section.id ? "→" : "•"; + return `${indent}${marker} ${s.heading} — ${s.intent}`; + }); + const sourceLines = numberedSourceList(sources, section.sourceIds); + return [ + `[Page title]`, + pageTitle, + "", + "[Brief summary]", + briefSummary, + "", + "[Full outline — '→' marks the section you are writing]", + ...outlineList, + "", + "[Section to write]", + `heading: ${section.heading}`, + `depth: ${section.depth}`, + `intent: ${section.intent}`, + "", + `[Approved sources (${sourceLines.length})]`, + ...(sourceLines.length > 0 ? sourceLines : ["(no sources — write conservatively)"]), + ].join("\n"); +} + +/** + * Sum the chunks of a streamed chat result into a single string. We rely on + * the LangGraph runtime to also emit each chunk as an `on_chat_model_stream` + * event so the SSE mapper produces `token` events the frontend reads. + * + * ストリーミングの最終結果を 1 本の文字列にまとめる。途中チャンクは + * runtime が `on_chat_model_stream` event として吐くので、SSE には別経路で + * `token` event が流れる。 + */ +function chunkContent(chunk: unknown): string { + if (!chunk || typeof chunk !== "object") return ""; + const content = (chunk as { content?: unknown }).content; + if (typeof content === "string") return content; + if (Array.isArray(content)) { + return content + .map((part) => { + if (typeof part === "string") return part; + if ( + part && + typeof part === "object" && + typeof (part as { text?: unknown }).text === "string" + ) { + return (part as { text: string }).text; + } + return ""; + }) + .join(""); + } + return ""; +} + +/** `draft_sections` node — sequential per-section LLM streaming. */ +export async function draftSections( + state: WikiComposeStateType, + config: LangGraphRunnableConfig, +): Promise { + const ctx = getGraphContext(config); + + await dispatchComposePhase({ phase: "draft", status: "entered" }, config); + + const outline = state.approvedOutline?.sections ?? []; + if (outline.length === 0) { + // Defensive: humanReviewOutline already rejects empty arrays, but if we + // somehow arrive here with nothing to write, skip Draft cleanly. + // 通常は到達不能だが防御。空アウトラインなら Draft をスキップ。 + return { draftedSections: [], phase: "draft:completed" }; + } + + const model = await createZediChatModel({ + modelId: getDraftModelId(), + userId: ctx.userId, + tier: ctx.tier, + db: ctx.db, + feature: `${ctx.feature}:draft`, + backend: ctx.backend, + temperature: 0.6, + maxTokens: 2048, + }); + + const pageTitle = state.pageSnapshot?.title ?? "(untitled)"; + const briefSummary = state.brief?.summary ?? "(no brief)"; + const drafted: DraftedSection[] = []; + + for (let i = 0; i < outline.length; i++) { + const section = outline[i] as OutlineSection; + await dispatchComposeSection( + { + sectionId: section.id, + heading: section.heading, + status: "started", + index: i + 1, + total: outline.length, + }, + config, + ); + + let body = ""; + try { + const stream = await model.stream([ + { role: "system", content: SECTION_SYSTEM_PROMPT }, + { + role: "user", + content: buildSectionPrompt({ + pageTitle, + section, + outline, + briefSummary, + sources: state.approvedResearch, + }), + }, + ]); + for await (const chunk of stream) { + body += chunkContent(chunk); + } + } catch (err) { + // Per-section failure must not abort the whole Draft. Surface the + // failure as an inline note inside the section body so the user sees + // what happened without losing earlier sections. + // セクション 1 件の失敗で Draft 全体を止めない。エラーは本文に追記。 + const message = err instanceof Error ? err.message : String(err); + body = body || `*(Section draft failed: ${message})*`; + } + + const citedIds = collectCitedSourceIds(body, state.approvedResearch, section.sourceIds); + drafted.push({ + sectionId: section.id, + heading: section.heading, + body: body.trim(), + citedSourceIds: citedIds, + completedAt: new Date().toISOString(), + }); + + await dispatchComposeSection( + { + sectionId: section.id, + heading: section.heading, + status: "completed", + index: i + 1, + total: outline.length, + }, + config, + ); + } + + return { + draftedSections: drafted, + phase: "draft:completed", + }; +} + +/** + * Best-effort extraction of cited source ids from `[#N]` markers in the body. + * Maps each `[#N]` back to the corresponding source by 1-based index over the + * allowed-source subset. + * + * 本文中の `[#N]` 形式の引用マーカーから citedSourceIds を抽出する。 + */ +function collectCitedSourceIds( + body: string, + sources: Source[], + allowedIds: string[] | undefined, +): string[] { + const allow = allowedIds && allowedIds.length > 0 ? new Set(allowedIds) : null; + const candidates = sources.filter((s) => !allow || allow.has(s.id)); + const matches = new Set(); + for (const m of body.matchAll(/\[#(\d+)\]/g)) { + const n = Number(m[1]); + if (!Number.isFinite(n) || n < 1 || n > candidates.length) continue; + const candidate = candidates[n - 1]; + if (candidate) matches.add(candidate.id); + } + return Array.from(matches); +} diff --git a/server/api/src/agents/graphs/wikiCompose/nodes/humanReviewBrief.ts b/server/api/src/agents/graphs/wikiCompose/nodes/humanReviewBrief.ts new file mode 100644 index 00000000..cde00cfe --- /dev/null +++ b/server/api/src/agents/graphs/wikiCompose/nodes/humanReviewBrief.ts @@ -0,0 +1,91 @@ +/** + * `human_review_brief` — Wiki Compose Brief interrupt node (#950). + * + * Brief 質問群を `interrupt(value)` でユーザーに渡し、`PATCH .../resume` の + * 結果を `briefResumeSchema` で検証して `brief` を state に確定する。 + * 既存本文ありで「追記」を選んだ場合は `appendToExisting=true` が立ち、Draft + * フェーズがそれを読んで挙動を切り替える。`researchMaxIterations` (1..5) が + * 指定されていれば、後段の Research subgraph に渡るようミラーする。 + * + * Halts the graph at the Brief interrupt and projects the user's answers into + * `state.brief`. The resume payload's `researchMaxIterations` (when present) + * is mirrored to `state.researchMaxIterations` so the research subgraph node + * picks it up via its own state slot when invoked. + */ +import type { LangGraphRunnableConfig } from "@langchain/langgraph"; +import { interrupt } from "@langchain/langgraph"; +import { briefResumeSchema } from "../resumeSchemas.js"; +import type { WikiComposeStateType, WikiComposeStateUpdate } from "../state.js"; +import type { + BriefAnswer, + BriefResult, + PageSnapshot, + WikiComposeInterruptPayload, +} from "../types.js"; + +const EMPTY_SNAPSHOT: PageSnapshot = { pageId: "", title: "", body: "", hasContent: false }; + +/** + * Build the natural-language Brief summary that downstream nodes embed in + * their prompts. Keeps the structure stable so prompt-snapshot tests don't + * churn on LLM upgrades. + * + * Brief 確定回答を Markdown で要約する。後段プロンプトに渡す書式を 1 箇所に集約する。 + */ +function summariseBrief(answers: BriefAnswer[], questions: Map): string { + if (answers.length === 0) return "(no brief provided)"; + const lines: string[] = []; + for (const a of answers) { + const q = questions.get(a.questionId) ?? "(unknown question)"; + const parts: string[] = []; + if (a.selectedOptionIds.length > 0) parts.push(`selected=${a.selectedOptionIds.join(", ")}`); + if (a.freeText && a.freeText.trim()) parts.push(`note=${a.freeText.trim()}`); + lines.push(`- ${q} → ${parts.join(" | ") || "(no answer)"}`); + } + return lines.join("\n"); +} + +/** + * `human_review_brief` node — interrupt + resume projection. + */ +export async function humanReviewBrief( + state: WikiComposeStateType, + _config: LangGraphRunnableConfig, +): Promise { + const payload: WikiComposeInterruptPayload = { + kind: "human_review_brief", + questions: state.briefQuestions, + pageSnapshot: state.pageSnapshot ?? EMPTY_SNAPSHOT, + }; + const resumeValue: unknown = interrupt(payload); + const parsed = briefResumeSchema.parse(resumeValue); + + // Index questions by id so we can produce a stable, readable summary. + // 質問テキストを id → text で引けるよう、ループの外で 1 度だけ Map 化する。 + const questionMap = new Map(); + for (const q of state.briefQuestions) questionMap.set(q.id, q.question); + + const answers: BriefAnswer[] = parsed.answers.map((a) => ({ + questionId: a.questionId, + selectedOptionIds: a.selectedOptionIds, + ...(a.freeText !== undefined ? { freeText: a.freeText } : {}), + })); + + const brief: BriefResult = { + answers, + summary: summariseBrief(answers, questionMap), + appendToExisting: Boolean(parsed.appendToExisting), + }; + + const update: WikiComposeStateUpdate = { + brief, + phase: "brief:completed", + }; + if (parsed.researchMaxIterations !== undefined) { + // Mirror onto the canonical research subgraph channel name so the + // composed research node picks it up via shared state. + // research subgraph と共有する `maxIterations` チャネルに反映する。 + update.maxIterations = parsed.researchMaxIterations; + } + return update; +} diff --git a/server/api/src/agents/graphs/wikiCompose/nodes/humanReviewOutline.ts b/server/api/src/agents/graphs/wikiCompose/nodes/humanReviewOutline.ts new file mode 100644 index 00000000..919d921c --- /dev/null +++ b/server/api/src/agents/graphs/wikiCompose/nodes/humanReviewOutline.ts @@ -0,0 +1,46 @@ +/** + * `human_review_outline` — Wiki Compose Structure interrupt node (#950). + * + * Orchestrator が提案したアウトラインを `interrupt(value)` でユーザーに渡し、 + * `outlineResumeSchema` で検証して `approvedOutline` を state に確定する。 + * ユーザーは並び替え・タイトル変更・depth 変更・サブセクション削除が可能 + * (フロントの outline editor で全部行う)。承認後は Draft フェーズへ。 + * + * Halts at the outline interrupt and projects the user-edited outline back + * into state. Validation throws on empty outlines so Draft cannot be entered + * with nothing to write. + */ +import type { LangGraphRunnableConfig } from "@langchain/langgraph"; +import { interrupt } from "@langchain/langgraph"; +import { outlineResumeSchema } from "../resumeSchemas.js"; +import type { WikiComposeStateType, WikiComposeStateUpdate } from "../state.js"; +import type { ApprovedOutline, WikiComposeInterruptPayload } from "../types.js"; + +/** `human_review_outline` node — interrupt + resume projection. */ +export async function humanReviewOutline( + state: WikiComposeStateType, + _config: LangGraphRunnableConfig, +): Promise { + const payload: WikiComposeInterruptPayload = { + kind: "human_review_outline", + outline: state.outlineProposal, + approvedSources: state.approvedResearch, + }; + const resumeValue: unknown = interrupt(payload); + const parsed = outlineResumeSchema.parse(resumeValue); + + const approvedOutline: ApprovedOutline = { + sections: parsed.sections.map((s) => ({ + id: s.id, + heading: s.heading, + depth: s.depth, + intent: s.intent, + ...(s.sourceIds !== undefined ? { sourceIds: s.sourceIds } : {}), + })), + }; + + return { + approvedOutline, + phase: "structure:completed", + }; +} diff --git a/server/api/src/agents/graphs/wikiCompose/nodes/index.ts b/server/api/src/agents/graphs/wikiCompose/nodes/index.ts new file mode 100644 index 00000000..55ca4185 --- /dev/null +++ b/server/api/src/agents/graphs/wikiCompose/nodes/index.ts @@ -0,0 +1,12 @@ +/** + * Barrel for Wiki Compose orchestrator graph nodes (#950). + * + * `wikiComposeGraph.ts` から個別ファイルを import せずに済むようまとめる。 + * テストでも単一の mock point として使う。 + */ +export { briefDialogue } from "./briefDialogue.js"; +export { humanReviewBrief } from "./humanReviewBrief.js"; +export { structureDialogue } from "./structureDialogue.js"; +export { humanReviewOutline } from "./humanReviewOutline.js"; +export { draftSections } from "./draftSections.js"; +export { completed } from "./completed.js"; diff --git a/server/api/src/agents/graphs/wikiCompose/nodes/shared/dispatch.ts b/server/api/src/agents/graphs/wikiCompose/nodes/shared/dispatch.ts new file mode 100644 index 00000000..9ee4f584 --- /dev/null +++ b/server/api/src/agents/graphs/wikiCompose/nodes/shared/dispatch.ts @@ -0,0 +1,41 @@ +/** + * Typed wrappers over `dispatchCustomEvent` for the Wiki Compose orchestrator + * graph (#950). + * + * `dispatchCustomEvent` を経由して `compose_phase` / `compose_section` の + * custom event を発火する薄いラッパ。`sseMapper.mapCustomEvent` がペイロード + * shape を検証するため、ここでは型付きで dispatch するだけで良い。 + */ +import { dispatchCustomEvent } from "@langchain/core/callbacks/dispatch"; +import type { LangGraphRunnableConfig } from "@langchain/langgraph"; + +/** Payload shape for `compose_phase` custom events. */ +export interface ComposePhasePayload { + phase: "brief" | "research" | "structure" | "draft" | "completed"; + status: "entered" | "completed"; +} + +/** Payload shape for `compose_section` custom events. */ +export interface ComposeSectionPayload { + sectionId: string; + heading: string; + status: "started" | "completed"; + index: number; + total: number; +} + +/** Dispatch a `compose_phase` SSE custom event. */ +export async function dispatchComposePhase( + payload: ComposePhasePayload, + config: LangGraphRunnableConfig, +): Promise { + await dispatchCustomEvent("compose_phase", payload, config); +} + +/** Dispatch a `compose_section` SSE custom event. */ +export async function dispatchComposeSection( + payload: ComposeSectionPayload, + config: LangGraphRunnableConfig, +): Promise { + await dispatchCustomEvent("compose_section", payload, config); +} diff --git a/server/api/src/agents/graphs/wikiCompose/nodes/shared/loadPageSnapshot.ts b/server/api/src/agents/graphs/wikiCompose/nodes/shared/loadPageSnapshot.ts new file mode 100644 index 00000000..2cb365f2 --- /dev/null +++ b/server/api/src/agents/graphs/wikiCompose/nodes/shared/loadPageSnapshot.ts @@ -0,0 +1,54 @@ +/** + * Loads a {@link PageSnapshot} for the Wiki Compose orchestrator graph (#950). + * + * `briefDialogue` ノードが session 開始時に 1 度だけ呼ぶ。`pages` / `page_versions` + * テーブルから現在のタイトル・本文を取得し、Brief 質問生成 / 追記モード判定に + * 利用できる軽量レコードを返す。失敗時は空タイトル + 空本文の安全な fallback + * を返す(Brief 自体は無タイトルでも 0 件質問で進めるよう設計してある)。 + * + * Reads the target page's current title + body so Brief can suggest informed + * questions and Draft can know whether to append vs replace. Falls back to a + * zero-content snapshot when the page row cannot be loaded (rare; the route + * layer already verified view access before invoking the graph). + */ +import { eq } from "drizzle-orm"; +import { pages } from "../../../../../schema/pages.js"; +import type { Database } from "../../../../../types/index.js"; +import type { PageSnapshot } from "../../types.js"; + +/** + * Fetch a page snapshot. The function is intentionally narrow — it only reads + * the fields the orchestrator nodes need, so it doesn't drag the full page + * accessor service into the agent runtime. + */ +export async function loadPageSnapshot(db: Database, pageId: string): Promise { + try { + const [row] = await db + .select({ id: pages.id, title: pages.title, contentPreview: pages.contentPreview }) + .from(pages) + .where(eq(pages.id, pageId)) + .limit(1); + if (!row) return emptySnapshot(pageId); + // `pages.content_preview` holds the latest persisted markdown-like preview + // of the body (the live document lives in Hocuspocus). For the orchestrator + // it's enough to know whether content exists and surface a short excerpt; + // we don't need the full Yjs binary. + // `pages.content_preview` は本文のプレビュー文字列を保持している(実体は + // Hocuspocus)。Brief / Draft の判断には十分なので、ここで読む。 + const body = typeof row.contentPreview === "string" ? row.contentPreview : ""; + return { + pageId: row.id, + title: row.title ?? "", + body, + hasContent: body.trim().length > 0, + }; + } catch { + // Defence in depth: a transient DB error must not crash the whole graph. + // The Brief node tolerates an empty snapshot (it just asks broader questions). + return emptySnapshot(pageId); + } +} + +function emptySnapshot(pageId: string): PageSnapshot { + return { pageId, title: "", body: "", hasContent: false }; +} diff --git a/server/api/src/agents/graphs/wikiCompose/nodes/structureDialogue.ts b/server/api/src/agents/graphs/wikiCompose/nodes/structureDialogue.ts new file mode 100644 index 00000000..b7f5947b --- /dev/null +++ b/server/api/src/agents/graphs/wikiCompose/nodes/structureDialogue.ts @@ -0,0 +1,135 @@ +/** + * `structure_dialogue` — Wiki Compose Structure phase node (#950). + * + * Brief 確定回答と採用調査ソースを材料に、3〜10 セクションのアウトライン + * 案を Orchestrator LLM に生成させる。Draft フェーズが書きやすい粒度 + * (= 各セクションが独立して 1 LLM 呼びぶんに収まる)を狙う。生成後は + * `outlineProposal` に置き、`compose_phase: { phase: "structure", status: "entered" }` + * を発火して `human_review_outline` interrupt に進む。 + * + * Builds an outline proposal that the user can edit before Draft. The + * prompt is intentionally narrow on shape (heading + intent) so the + * frontend's drag-and-drop editor has stable rows to work with. + */ +import type { LangGraphRunnableConfig } from "@langchain/langgraph"; +import { randomUUID } from "node:crypto"; +import { z } from "zod"; +import { createZediChatModel } from "../../../core/llm/modelFactory.js"; +import { getGraphContext } from "../../../subgraphs/research/nodes/shared/getGraphContext.js"; +import { dispatchComposePhase } from "./shared/dispatch.js"; +import type { WikiComposeStateType, WikiComposeStateUpdate } from "../state.js"; +import type { OutlineSection } from "../types.js"; + +const ORCHESTRATOR_MODEL_ENV = "WIKI_COMPOSE_ORCHESTRATOR_MODEL_ID"; +const ORCHESTRATOR_MODEL_FALLBACK = "claude-3-5-haiku"; + +function getOrchestratorModelId(): string { + return process.env[ORCHESTRATOR_MODEL_ENV]?.trim() || ORCHESTRATOR_MODEL_FALLBACK; +} + +/** + * Structured output schema. 3..10 sections, depth 1..3, each with a short + * intent so the user can spot redundant or off-topic items at a glance. + */ +export const outlineProposalSchema = z.object({ + sections: z + .array( + z.object({ + heading: z.string().min(1).max(120), + depth: z.number().int().min(1).max(3).default(1), + intent: z.string().min(1).max(280), + }), + ) + .min(3) + .max(10), +}); + +const SYSTEM_PROMPT = + "You are the orchestrator for Wiki Compose. Produce a section outline for " + + "the wiki page based on the Brief answers and the approved research " + + "sources. Constraints:\n" + + "1. 3..10 sections. Each MUST be writable in a single ~600-word pass.\n" + + "2. Use depth=1 for top-level h2 sections, depth=2 for h3 sub-sections.\n" + + "3. Each section MUST include a one-sentence `intent` describing what to " + + "cover. The user reads this to decide whether to keep / reorder / drop.\n" + + "4. Do not include 'Introduction' or 'Conclusion' boilerplate unless the " + + "topic genuinely benefits from one.\n" + + "Output JSON only."; + +function buildUserPrompt(state: WikiComposeStateType): string { + const title = state.pageSnapshot?.title ?? "(untitled)"; + const briefSummary = state.brief?.summary ?? "(no brief provided)"; + const sources = state.approvedResearch.slice(0, 20).map((s, i) => { + const kind = s.kind.toUpperCase(); + return `[${i + 1}] (${kind}) ${s.title}`; + }); + const sourceBlock = sources.length > 0 ? sources.join("\n") : "(no approved research sources)"; + return [ + `[Page title]`, + title, + "", + "[Brief summary]", + briefSummary, + "", + `[Approved research sources: ${state.approvedResearch.length}]`, + sourceBlock, + ].join("\n"); +} + +/** `structure_dialogue` node — proposes the outline. */ +export async function structureDialogue( + state: WikiComposeStateType, + config: LangGraphRunnableConfig, +): Promise { + const ctx = getGraphContext(config); + + await dispatchComposePhase({ phase: "structure", status: "entered" }, config); + + const model = await createZediChatModel({ + modelId: getOrchestratorModelId(), + userId: ctx.userId, + tier: ctx.tier, + db: ctx.db, + feature: `${ctx.feature}:structure`, + backend: ctx.backend, + temperature: 0.4, + maxTokens: 2048, + }); + const structured = model.withStructuredOutput(outlineProposalSchema, { + name: "structure_dialogue", + }); + + // `structured.invoke` returns the zod input type (pre-default); we apply + // fallbacks (`depth ?? 1`) at the projection step below. + let raw: z.input; + try { + raw = await structured.invoke([ + { role: "system", content: SYSTEM_PROMPT }, + { role: "user", content: buildUserPrompt(state) }, + ]); + } catch { + // Defensive fallback: emit a minimal 3-section outline so the user can + // 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." }, + ], + }; + } + + const outline: OutlineSection[] = raw.sections.map((s) => ({ + id: randomUUID(), + heading: s.heading, + depth: s.depth ?? 1, + intent: s.intent, + })); + + return { + outlineProposal: outline, + phase: "structure:await_user", + }; +} diff --git a/server/api/src/agents/graphs/wikiCompose/resumeSchemas.ts b/server/api/src/agents/graphs/wikiCompose/resumeSchemas.ts new file mode 100644 index 00000000..8ac9fe12 --- /dev/null +++ b/server/api/src/agents/graphs/wikiCompose/resumeSchemas.ts @@ -0,0 +1,66 @@ +/** + * Resume payload validators for the Wiki Compose orchestrator graph (#950). + * + * 各 interrupt 点で `PATCH /api/pages/:pageId/compose-sessions/:id/resume` が + * 受け取る `body.resume` の shape を zod で検証する。Brief / Outline それぞれ + * 専用のスキーマを持つ(Research は subgraph 側の `researchResumeSchema` を流用)。 + * + * Validates the resume payload submitted via the resume endpoint at each + * orchestrator interrupt point. The route layer hands `body.resume` to the + * graph and these schemas catch malformed payloads before they pollute state. + */ +import { z } from "zod"; + +/** + * Resume payload for `human_review_brief`. + * + * - `answers` — 必須。空配列でも可(Brief をスキップしたケース)。 + * - `appendToExisting` — 本文ありページで「追記」を選んだ場合 true。 + * - `researchMaxIterations` — Brief 内で 1..5 にユーザーが調整した場合のみ。 + * + * Validates the resume payload at the Brief interrupt. `answers` is required + * even when empty (the user may explicitly skip Brief by submitting an empty + * array). Default for `appendToExisting` is `false` (replace-mode is the + * historical Wiki Compose behaviour); `researchMaxIterations` is clamped to + * 1..5 by the schema so the graph never sees an out-of-range value. + */ +export const briefResumeSchema = z.object({ + answers: z + .array( + z.object({ + questionId: z.string().min(1), + selectedOptionIds: z.array(z.string().min(1)).default([]), + freeText: z.string().optional(), + }), + ) + .default([]), + appendToExisting: z.boolean().optional().default(false), + researchMaxIterations: z.number().int().min(1).max(5).optional(), +}); + +export type BriefResumeParsed = z.infer; + +/** + * Resume payload for `human_review_outline`. + * + * - `sections` — 確定アウトライン。空配列は許容しない(最低 1 セクションは必要)。 + * + * Validates the resume payload at the outline interrupt. The user must + * approve at least one section — an empty outline is rejected so Draft does + * not try to render an article with no sections. + */ +export const outlineResumeSchema = z.object({ + sections: z + .array( + z.object({ + id: z.string().min(1), + heading: z.string().min(1), + depth: z.number().int().min(1).max(3), + intent: z.string().default(""), + sourceIds: z.array(z.string().min(1)).optional(), + }), + ) + .min(1), +}); + +export type OutlineResumeParsed = z.infer; diff --git a/server/api/src/agents/graphs/wikiCompose/state.ts b/server/api/src/agents/graphs/wikiCompose/state.ts new file mode 100644 index 00000000..03e793d6 --- /dev/null +++ b/server/api/src/agents/graphs/wikiCompose/state.ts @@ -0,0 +1,195 @@ +/** + * `WikiComposeState` — orchestrator-level LangGraph state for Wiki Compose + * P2 (#950). + * + * Wiki Compose 全体グラフの state。`BaseState` (messages / phase / pageId / + * userId) を継承しつつ、`ResearchLoopState` の channel 群 (`iteration` / + * `pendingSources` / `approvedResearch` 等) を superset として保持することで、 + * 既存の `researchLoopSubgraph` をそのまま **subgraph as node** として組み込み、 + * state を自動的に共有させる。Brief / Structure / Draft の各フェーズ専用の + * フィールド (`briefQuestions`, `brief`, `outlineProposal`, `approvedOutline`, + * `draftedSections`, `completion`) を追加で持つ。 + * + * Extends both `BaseState` and the research subgraph's channels so the compiled + * research graph composes as a regular node (LangGraph maps state automatically + * when channel names + reducers match). Each phase writes only to its own + * slice; reducers are last-write-wins for scalars and id-keyed merge for arrays. + * + * Issue: otomatty/zedi#950 + */ +import { Annotation } from "@langchain/langgraph"; +import { BaseState } from "../../core/state/baseState.js"; +import type { + AdditionalResearchRequest, + Evaluation, + ExitReason, + PlannedQuery, + ResearchBatch, + Source, +} from "../../subgraphs/research/types.js"; +import type { + ApprovedOutline, + BriefQuestion, + BriefResult, + ComposeCompletion, + DraftedSection, + OutlineSection, + PageSnapshot, +} from "./types.js"; + +/** + * `pendingSources` 用 reducer。id 単位で dedup し、後勝ちで上書きする。 + * Source merge by id with last-write-wins; mirrors the research subgraph. + */ +function mergeSourcesById(prev: Source[], next: Source[] | undefined): Source[] { + if (!next || next.length === 0) return prev; + const order: string[] = []; + const map = new Map(); + for (const s of prev) { + if (!map.has(s.id)) order.push(s.id); + map.set(s.id, s); + } + for (const s of next) { + if (!map.has(s.id)) order.push(s.id); + map.set(s.id, s); + } + return order.map((id) => map.get(id) as Source); +} + +/** + * `draftedSections` 用 reducer。`sectionId` 単位で last-write-wins し、 + * 同じセクションを再ドラフトしたときに重複行が増えないようにする。 + * + * Merge drafted sections by `sectionId`. + */ +function mergeSectionsById( + prev: DraftedSection[], + next: DraftedSection[] | undefined, +): DraftedSection[] { + if (!next || next.length === 0) return prev; + const order: string[] = []; + const map = new Map(); + for (const s of prev) { + if (!map.has(s.sectionId)) order.push(s.sectionId); + map.set(s.sectionId, s); + } + for (const s of next) { + if (!map.has(s.sectionId)) order.push(s.sectionId); + map.set(s.sectionId, s); + } + return order.map((id) => map.get(id) as DraftedSection); +} + +/** + * Wiki Compose orchestrator state schema. + * + * Channel groups: + * 1. `BaseState` — messages, phase, pageId, userId. + * 2. Research mirror — superset of `ResearchLoopState` channels so the + * compiled research subgraph composes as a node and state flows through. + * 3. Brief — `pageSnapshot`, `briefQuestions`, `brief`. + * 4. Structure — `outlineProposal`, `approvedOutline`. + * 5. Draft / completion — `draftedSections`, `completion`. + */ +export const WikiComposeState = Annotation.Root({ + ...BaseState.spec, + + // ── Brief phase ─────────────────────────────────────────────────────────── + /** Page snapshot loaded once at session start. */ + pageSnapshot: Annotation({ + reducer: (prev, next) => (next === undefined ? prev : next), + default: () => null, + }), + /** Brief 質問群(0..7)。`briefDialogue` が一度だけ全置換する。 */ + briefQuestions: Annotation({ + reducer: (_prev, next) => next, + default: () => [], + }), + /** Brief 確定結果。`humanReviewBrief` が resume payload を投影する。 */ + brief: Annotation({ + reducer: (prev, next) => (next === undefined ? prev : next), + default: () => null, + }), + + // ── Research mirror (matches ResearchLoopState exactly) ────────────────── + /** 現在のループ回数(research subgraph が書く)。 */ + iteration: Annotation({ + reducer: (_prev, next) => next, + default: () => 0, + }), + /** ループ上限(Brief で 1..5 にユーザー設定可、デフォルト 3)。 */ + maxIterations: Annotation({ + reducer: (prev, next) => next ?? prev, + default: () => 3, + }), + /** Research subgraph 内の直近クエリ。 */ + queries: Annotation({ + reducer: (_prev, next) => next, + default: () => [], + }), + /** 蓄積調査ソース(research subgraph が書く)。 */ + pendingSources: Annotation({ + reducer: mergeSourcesById, + default: () => [], + }), + /** 直近の評価。 */ + lastEvaluation: Annotation({ + reducer: (_prev, next) => next, + default: () => null, + }), + /** 終了理由。 */ + exitReason: Annotation({ + reducer: (_prev, next) => next, + default: () => null, + }), + /** 各ループの compile_batch スナップショット。 */ + batches: Annotation({ + reducer: (prev, next) => (next === undefined ? prev : [...prev, ...next]), + default: () => [], + }), + /** 採用ソース。`human_review_research` が resume 値から projection する。 */ + approvedResearch: Annotation({ + reducer: (_prev, next) => next, + default: () => [], + }), + /** 除外ソース。 */ + rejectedResearch: Annotation({ + reducer: (_prev, next) => next, + default: () => [], + }), + /** 追加調査リクエスト(route 経由で投入)。 */ + additionalRequest: Annotation({ + reducer: (prev, next) => (next === undefined ? prev : next), + default: () => null, + }), + + // ── Structure phase ────────────────────────────────────────────────────── + /** Orchestrator が提案する初期アウトライン。 */ + outlineProposal: Annotation({ + reducer: (_prev, next) => next, + default: () => [], + }), + /** ユーザー承認後の確定アウトライン。 */ + approvedOutline: Annotation({ + reducer: (prev, next) => (next === undefined ? prev : next), + default: () => null, + }), + + // ── Draft / completion ─────────────────────────────────────────────────── + /** 確定済みセクション本文。`draftSections` が 1 セクションずつ append する。 */ + draftedSections: Annotation({ + reducer: mergeSectionsById, + default: () => [], + }), + /** 完了サマリ。`completed` ノードが書く。 */ + completion: Annotation({ + reducer: (prev, next) => (next === undefined ? prev : next), + default: () => null, + }), +}); + +/** `WikiComposeState.State` のショートカット。 */ +export type WikiComposeStateType = typeof WikiComposeState.State; + +/** `WikiComposeState.Update` のショートカット。ノードの戻り値型。 */ +export type WikiComposeStateUpdate = typeof WikiComposeState.Update; diff --git a/server/api/src/agents/graphs/wikiCompose/types.ts b/server/api/src/agents/graphs/wikiCompose/types.ts new file mode 100644 index 00000000..91ec2c83 --- /dev/null +++ b/server/api/src/agents/graphs/wikiCompose/types.ts @@ -0,0 +1,212 @@ +/** + * Shared value types for the Wiki Compose orchestrator graph (#950 / P2). + * + * Wiki Compose 全体グラフが扱う値型。 + * `WikiComposeState` (state.ts) と各ノードが参照する。 + * + * Pure data types referenced by `WikiComposeState` and the orchestrator nodes. + * Kept separate from `Annotation.Root` so non-LangGraph modules (frontend + * wire types, vitest fixtures) can import without pulling LangGraph runtime. + */ + +import type { Source } from "../../subgraphs/research/types.js"; + +/** Re-exported here so orchestrator nodes can import everything from one barrel. */ +export type { Source }; + +/** + * Brief フェーズで Orchestrator が生成する 1 つの構造化質問。 + * + * One structured Brief question. Brief never opens a free-form chat; the + * frontend renders this as a question card with selectable options + an + * optional free-text addendum. `0..7` questions are emitted (the Orchestrator + * decides; `0` means "skip Brief entirely"). + */ +export interface BriefQuestion { + /** Stable uuid. */ + id: string; + /** Question text shown to the user. */ + question: string; + /** + * Optional rationale shown as helper text. Surfaced so the user understands + * why each question matters. + */ + rationale?: string; + /** + * Answer choices (option chips). When empty, the UI renders a single + * free-text input. The frontend always allows a free-text addendum on top + * of any chip selection. + */ + options: BriefOption[]; + /** Whether the user MUST answer this question to proceed. */ + required: boolean; +} + +/** One selectable option chip in a {@link BriefQuestion}. */ +export interface BriefOption { + /** Stable id within the question (used by the resume payload). */ + id: string; + /** Display label. */ + label: string; + /** Optional follow-up hint shown when this option is selected. */ + hint?: string; +} + +/** + * User's reply to a single Brief question. Resume payload value. + * + * Brief 質問への 1 件分の回答(resume payload の単位)。 + */ +export interface BriefAnswer { + /** Question id this answer responds to. */ + questionId: string; + /** Selected option ids (may be empty when only free-text is provided). */ + selectedOptionIds: string[]; + /** Optional free-text addendum (always allowed). */ + freeText?: string; +} + +/** + * Aggregated Brief result projected into state after the user resumes. + * + * Brief 完了時に state に投影される確定回答セット。`structureDialogue` と + * `researchPhase` がプロンプト構築時にここを読む。 + */ +export interface BriefResult { + /** Question/answer pairs in their original order. */ + answers: BriefAnswer[]; + /** + * Free-form natural-language summary derived from the answers. Used by + * downstream nodes so they do not have to re-traverse the Q&A pairs. + */ + summary: string; + /** + * Optional addition mode flag — populated when the page already has body + * content and the user chose "append" instead of "replace". The draft + * phase reads this to decide whether to write into a fresh document or to + * merge with the existing body. + */ + appendToExisting: boolean; +} + +/** + * Page snapshot loaded at session start. Used by Brief to surface the + * current page state and by Draft to know whether to append vs replace. + * + * セッション開始時に読み込むページのスナップショット。Brief / Draft が参照する。 + */ +export interface PageSnapshot { + pageId: string; + /** Wiki page title. */ + title: string; + /** Existing body markdown (may be empty). */ + body: string; + /** True when `body.trim().length > 0`. */ + hasContent: boolean; +} + +/** + * Structure フェーズで生成された 1 つのアウトライン項目。 + * + * Single outline node. The orchestrator emits a flat or 1-level nested list; + * the frontend supports drag-and-drop reordering before approval. + */ +export interface OutlineSection { + /** Stable uuid. */ + id: string; + /** Section heading text (without `# ` prefix). */ + heading: string; + /** Heading depth (1 = top-level h2, 2 = h3, …; the page title itself is h1). */ + depth: number; + /** + * Short description / what to cover. Surfaced as helper text in the outline + * editor and consumed by the draft node as the section brief. + */ + intent: string; + /** + * Optional list of source ids (from `approvedResearch`) that the user + * marked as relevant for this section. Populated post-approval via the + * outline resume payload. + */ + sourceIds?: string[]; +} + +/** + * Outline approved by the user via the `human_review_outline` interrupt. + * + * `humanReviewOutline` が resume payload を投影して作る。Draft フェーズが + * 各セクションを順に LLM ストリームで書き起こす。 + */ +export interface ApprovedOutline { + /** Final ordered sections (after user edits). */ + sections: OutlineSection[]; +} + +/** + * Section draft result. One per outline section, filled in by + * `draft_sections` as it streams. + * + * 確定済みの 1 セクション分本文。各セクションは LLM トークンストリームで + * 書き起こされ、確定後に本配列へ append される。 + */ +export interface DraftedSection { + /** Matches {@link OutlineSection.id}. */ + sectionId: string; + /** Final heading (may differ if user renamed mid-flight). */ + heading: string; + /** Final markdown body for the section (excluding the heading). */ + body: string; + /** Source ids cited in this section (subset of `approvedResearch`). */ + citedSourceIds: string[]; + /** ISO timestamp when the section completed. */ + completedAt: string; +} + +/** + * Final compose output stamped onto state at the `completed` node. + * + * 完了時のサマリ。フロントは `/notes/:noteId/:pageId` に戻るときの最終本文を + * ここから読む。 + */ +export interface ComposeCompletion { + /** Final markdown body (sections joined). */ + markdown: string; + /** Sections in their final order. */ + sections: DraftedSection[]; + /** Approved sources collated for citation export. */ + citedSources: Source[]; + /** ISO timestamp at completion. */ + completedAt: string; +} + +/** + * Discriminated union of the values emitted by the orchestrator's interrupt + * nodes. Surfaces on the wire as `SseInterruptEvent.payload`. + * + * 各 interrupt ノードが `interrupt(value)` で渡すペイロード。フロントは + * `kind` で分岐して UI を出し分ける。 + */ +export type WikiComposeInterruptPayload = + | { kind: "human_review_brief"; questions: BriefQuestion[]; pageSnapshot: PageSnapshot } + | { kind: "human_review_research"; batchId: string | null; pendingSources: Source[] } + | { kind: "human_review_outline"; outline: OutlineSection[]; approvedSources: Source[] }; + +/** + * Resume payloads expected at each interrupt point. Each is validated at the + * node boundary via the matching zod schema in `resumeSchemas.ts`. + * + * 各 interrupt 点の resume payload TS 型。実体は zod で検証する。 + */ +export interface BriefResumeInput { + answers: BriefAnswer[]; + /** True when the user chose "append to existing body" (U2). */ + appendToExisting?: boolean; + /** Optional override for the research loop's max iterations (1..5). */ + researchMaxIterations?: number; +} + +/** Resume payload for the outline interrupt. */ +export interface OutlineResumeInput { + /** Final outline (reordered / edited by the user). */ + sections: OutlineSection[]; +} diff --git a/server/api/src/agents/graphs/wikiCompose/wikiComposeGraph.ts b/server/api/src/agents/graphs/wikiCompose/wikiComposeGraph.ts new file mode 100644 index 00000000..c23b3d51 --- /dev/null +++ b/server/api/src/agents/graphs/wikiCompose/wikiComposeGraph.ts @@ -0,0 +1,141 @@ +/** + * Wiki Compose P2 — `wikiComposeGraph` orchestrator (issue #950). + * + * Brief → Research → Structure → Draft → Completed の全体フローを担う + * LangGraph オーケストレータ。`researchLoopSubgraph` (#949 / P1) を **subgraph + * as node** として組み込み、ループ内 interrupt + * (`human_review_research`) は親グラフから見ても通常の interrupt として伝播する + * (状態は `WikiComposeState` の superset 設計により自動共有)。 + * + * Top-level orchestrator. The research subgraph composes as a node so a + * single PostgresSaver thread services Brief → Research → Outline → Draft. + * Each interrupt halts the same `thread_id` and resumes through the same + * `PATCH /resume` route. + * + * Pipeline: + * + * ``` + * START + * → brief_dialogue + * → human_review_brief [interrupt #1] + * → research_subgraph (= researchLoopSubgraph) + * └── plan → search → fetch → eval → … → human_review_research [interrupt #2] + * → structure_dialogue + * → human_review_outline [interrupt #3] + * → draft_sections + * → completed + * → END + * ``` + */ +import { END, START, StateGraph } from "@langchain/langgraph"; +import { WikiComposeState } from "./state.js"; +import { + registerGraph, + type GraphFactory, + type GraphFactoryInput, + type CompiledGraphLike, +} from "../../registry/graphRegistry.js"; +import { + planQueries, + webSearch, + wikiSearch, + fetchArticles, + evaluateSufficiency, + refineQueries, + compileBatch, + humanReviewResearch, +} from "../../subgraphs/research/nodes/index.js"; +import { + briefDialogue, + humanReviewBrief, + structureDialogue, + humanReviewOutline, + draftSections, + completed, +} from "./nodes/index.js"; +import { shouldRefine } from "../../subgraphs/research/researchGraph.js"; + +/** Registered graph id. */ +export const WIKI_COMPOSE_GRAPH_ID = "wiki-compose" as const; +/** Registered graph version. Bump when behaviour changes meaningfully. */ +export const WIKI_COMPOSE_GRAPH_VERSION = "1.0.0"; + +/** + * Inlined research nodes vs separate subgraph: we inline the research nodes + * at the orchestrator level so the parent state's `iteration` / `queries` / + * `pendingSources` channels are written directly and the interrupt at + * `human_review_research` halts the parent thread_id without a translation + * layer. This is equivalent to subgraph-as-node composition since the + * orchestrator state is a strict superset of `ResearchLoopState` (see + * `state.ts`). + * + * 研究ノードは orchestrator state 上に直接配置する。state を superset 設計に + * したので state は自動共有され、interrupt は親 thread_id で halt する。 + */ +const factory: GraphFactory = ({ checkpointer }: GraphFactoryInput): CompiledGraphLike => { + const builder = new StateGraph(WikiComposeState) + // Brief phase + .addNode("brief_dialogue", briefDialogue) + .addNode("human_review_brief", humanReviewBrief) + // Research phase (inlined research subgraph nodes, sharing state) + .addNode("plan_queries", planQueries) + .addNode("web_search", webSearch) + .addNode("wiki_search", wikiSearch) + .addNode("fetch_articles", fetchArticles) + .addNode("evaluate_sufficiency", evaluateSufficiency) + .addNode("refine_queries", refineQueries) + .addNode("compile_batch", compileBatch) + .addNode("human_review_research", humanReviewResearch) + // Structure phase + .addNode("structure_dialogue", structureDialogue) + .addNode("human_review_outline", humanReviewOutline) + // Draft + completion + .addNode("draft_sections", draftSections) + .addNode("completed", completed) + // Edges + .addEdge(START, "brief_dialogue") + .addEdge("brief_dialogue", "human_review_brief") + .addEdge("human_review_brief", "plan_queries") + // Research loop (mirrors researchLoopSubgraph wiring). + .addEdge("plan_queries", "web_search") + .addEdge("plan_queries", "wiki_search") + .addEdge("web_search", "fetch_articles") + .addEdge("wiki_search", "fetch_articles") + .addEdge("fetch_articles", "evaluate_sufficiency") + .addConditionalEdges("evaluate_sufficiency", shouldRefine, { + refine: "refine_queries", + compile: "compile_batch", + }) + .addEdge("refine_queries", "web_search") + .addEdge("refine_queries", "wiki_search") + .addEdge("compile_batch", "human_review_research") + .addEdge("human_review_research", "structure_dialogue") + // Structure phase. + .addEdge("structure_dialogue", "human_review_outline") + .addEdge("human_review_outline", "draft_sections") + // Draft + completion. + .addEdge("draft_sections", "completed") + .addEdge("completed", END); + + return checkpointer ? builder.compile({ checkpointer }) : builder.compile(); +}; + +/** + * Register the Wiki Compose orchestrator graph. Idempotent. + * + * `app.ts` から `registerResearchLoopGraph()` と並べて呼ぶ。再登録は registry が + * 上書きで吸収する。 + */ +export function registerWikiComposeGraph(): void { + registerGraph({ + id: WIKI_COMPOSE_GRAPH_ID, + version: WIKI_COMPOSE_GRAPH_VERSION, + phase: "orchestrator", + description: + "Wiki Compose P2: full orchestrator. Brief → research → structure → draft → completed. " + + "Embeds the P1 research loop in-place via shared state (orchestrator state is a strict " + + "superset of ResearchLoopState). Three interrupt points: human_review_brief, " + + "human_review_research, human_review_outline.", + factory, + }); +} diff --git a/server/api/src/agents/index.ts b/server/api/src/agents/index.ts index f5747a37..496db34f 100644 --- a/server/api/src/agents/index.ts +++ b/server/api/src/agents/index.ts @@ -86,3 +86,27 @@ export { type ResearchResumeParsed, type HumanReviewInterruptPayload, } from "./subgraphs/research/index.js"; +export { + WIKI_COMPOSE_GRAPH_ID, + WIKI_COMPOSE_GRAPH_VERSION, + registerWikiComposeGraph, + WikiComposeState, + type WikiComposeStateType, + type WikiComposeStateUpdate, + briefResumeSchema, + type BriefResumeParsed, + outlineResumeSchema, + type OutlineResumeParsed, + type BriefAnswer, + type BriefOption, + type BriefQuestion, + type BriefResult, + type BriefResumeInput, + type ApprovedOutline, + type ComposeCompletion, + type DraftedSection, + type OutlineResumeInput, + type OutlineSection, + type PageSnapshot, + type WikiComposeInterruptPayload, +} from "./graphs/wikiCompose/index.js"; diff --git a/server/api/src/agents/runner/sseMapper.ts b/server/api/src/agents/runner/sseMapper.ts index 986db56f..18211704 100644 --- a/server/api/src/agents/runner/sseMapper.ts +++ b/server/api/src/agents/runner/sseMapper.ts @@ -11,6 +11,8 @@ * file only describes the shape transformation so unit tests can pin it. */ import type { + SseComposePhaseEvent, + SseComposeSectionEvent, SseEvent, SseResearchBatchEvent, SseResearchEvaluationEvent, @@ -208,6 +210,10 @@ function mapCustomEvent(event: LangGraphRuntimeEvent): SseEvent[] { return mapResearchEvaluation(data); case "research_batch": return mapResearchBatch(data); + case "compose_phase": + return mapComposePhase(data); + case "compose_section": + return mapComposeSection(data); default: // Unknown custom event names are dropped silently; emitting them as `status` // would risk leaking implementation detail to the wire. @@ -236,6 +242,40 @@ function mapResearchEvaluation(data: Record): SseResearchEvalua return [{ type: "research_evaluation", iteration, score, rationale, missingAspectsCount }]; } +function mapComposePhase(data: Record): SseComposePhaseEvent[] { + const phase = data.phase; + const status = data.status; + if ( + phase !== "brief" && + phase !== "research" && + phase !== "structure" && + phase !== "draft" && + phase !== "completed" + ) { + return []; + } + if (status !== "entered" && status !== "completed") return []; + return [{ type: "compose_phase", phase, status }]; +} + +function mapComposeSection(data: Record): SseComposeSectionEvent[] { + const sectionId = typeof data.sectionId === "string" ? data.sectionId : null; + const heading = typeof data.heading === "string" ? data.heading : null; + const status = data.status === "started" || data.status === "completed" ? data.status : null; + const index = typeof data.index === "number" ? data.index : null; + const total = typeof data.total === "number" ? data.total : null; + if ( + sectionId === null || + heading === null || + status === null || + index === null || + total === null + ) { + return []; + } + return [{ type: "compose_section", sectionId, heading, status, index, total }]; +} + function mapResearchBatch(data: Record): SseResearchBatchEvent[] { const batchId = typeof data.batchId === "string" ? data.batchId : null; const iteration = typeof data.iteration === "number" ? data.iteration : null; diff --git a/server/api/src/app.ts b/server/api/src/app.ts index 03fc3424..8c5cb282 100644 --- a/server/api/src/app.ts +++ b/server/api/src/app.ts @@ -46,6 +46,7 @@ import internalRoutes from "./routes/internal.js"; import composeSessionRoutes from "./routes/composeSessions.js"; import { registerStubGraph } from "./agents/registry/stubGraph.js"; import { registerResearchLoopGraph } from "./agents/subgraphs/research/index.js"; +import { registerWikiComposeGraph } from "./agents/graphs/wikiCompose/index.js"; /** * Creates and configures the Hono API app (routes, CORS, etc.). @@ -55,11 +56,13 @@ export function createApp(): Hono { // Wiki Compose graphs を registry に登録する。いずれも idempotent。 // - `wiki-compose-stub` — P0 smoke test (#948) // - `wiki-compose-research` — P1 自律調査ループ (#949) + // - `wiki-compose` — P2 全体オーケストレータ (#950) // - // Register all Wiki Compose graphs. Both calls are idempotent across hot + // Register all Wiki Compose graphs. Calls are idempotent across hot // reloads (registry uses `Map#set` so the latest registration wins). registerStubGraph(); registerResearchLoopGraph(); + registerWikiComposeGraph(); const app = new Hono(); const wildcard = isWildcardCors(); diff --git a/server/api/src/routes/composeSessions.ts b/server/api/src/routes/composeSessions.ts index e4b53de5..7d458a25 100644 --- a/server/api/src/routes/composeSessions.ts +++ b/server/api/src/routes/composeSessions.ts @@ -58,6 +58,7 @@ import { SSE_EVENT_NAMES, type SseEvent } from "../agents/core/types/sseEvents.j import { GRAPH_CONTEXT_CONFIG_KEY } from "../agents/core/types/graphContext.js"; 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 type { AppEnv } from "../types/index.js"; /** @@ -94,14 +95,17 @@ function translateGraphInput(graphId: string, raw: unknown): unknown { /** * Per-graph recursion limit. LangGraph's default of 25 is enough for the stub * graph but tight for `wiki-compose-research`, which runs up to ~5 iterations - * × ~6 nodes ≈ 30 node executions. We bump it for that graph only rather than - * raising the global default at `graphRunner.ts:147`. + * × ~6 nodes ≈ 30 node executions. The full `wiki-compose` orchestrator (#950) + * adds Brief + Structure + Draft (up to ~10 sections × 1 node) on top of the + * inlined research loop, so it needs a larger budget still. * * 調査ループは最大 5 イテレーション × 約 6 ノード = ~30 node 実行になり得るため、 - * 既定の 25 では不足する。該当 graph だけ 60 に引き上げる。 + * 既定の 25 では不足する。orchestrator (`wiki-compose`) は更に Brief / Structure / + * Draft フェーズ + 最大 10 セクションを足すので 120 に引き上げる。 */ function recursionLimitFor(graphId: string): number | undefined { if (graphId === RESEARCH_GRAPH_ID) return 60; + if (graphId === WIKI_COMPOSE_GRAPH_ID) return 120; return undefined; } diff --git a/src/App.tsx b/src/App.tsx index 82cacb58..62d46104 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -32,6 +32,7 @@ import SearchResults from "./pages/SearchResults"; import NotFound from "./pages/NotFound"; import NoteView from "./pages/NoteView"; import NotePageView from "./pages/NotePageView"; +import WikiComposePage from "./pages/WikiComposePage"; import NoteSettings from "./pages/NoteSettings"; import GeneralSection from "./pages/NoteSettings/sections/GeneralSection"; import VisibilitySection from "./pages/NoteSettings/sections/VisibilitySection"; @@ -290,6 +291,14 @@ const App = () => ( } /> } /> + {/* Wiki Compose split-screen UI (issue #950). + `/notes/:noteId/:pageId/compose` で新規セッション、 + `/notes/:noteId/:pageId/compose/:sessionId` で再開。 */} + } /> + } + /> {/* Legacy path — redirect `/notes/:noteId/pages/:pageId` to the shorter `/notes/:noteId/:pageId`. 旧パス `/notes/:noteId/pages/:pageId` を短縮形にリダイレクト。 */} diff --git a/src/components/editor/WikiGeneratorButton.tsx b/src/components/editor/WikiGeneratorButton.tsx index c26a825b..5998109b 100644 --- a/src/components/editor/WikiGeneratorButton.tsx +++ b/src/components/editor/WikiGeneratorButton.tsx @@ -17,16 +17,40 @@ import { isAIConfigured } from "@/lib/aiSettings"; interface WikiGeneratorButtonProps { title: string; hasContent: boolean; - /** 生成を開始するコールバック */ + /** + * インラインWiki生成(旧 useWikiGenerator)のコールバック。`composeHref` を + * 渡したときは Compose 画面に遷移するため呼ばれない。Inline generation + * callback (legacy path); skipped when `composeHref` is provided. + */ onGenerate: () => void; /** 現在の生成ステータス */ status: WikiGeneratorStatus; disabled?: boolean; + /** + * Wiki Compose 画面の遷移先 URL。指定時はクリックで navigate し、本文ありでも + * ボタンを表示する (Compose は追記モードをサポートするため、issue #950 U2)。 + * + * When provided, the button navigates to the Wiki Compose split-screen UI + * instead of calling `onGenerate`, and visibility no longer requires + * `hasContent === false` (Compose supports the append-mode flow per #950 U2). + */ + composeHref?: string; } /** - * Wiki 生成ボタン。タイトルがあり、本文が未入力のときだけ表示する。 - * Wiki generation button shown only when the note has a title and no body yet. + * Wiki 生成ボタン。 + * + * - `composeHref` 未指定(旧経路): タイトルがあり本文が未入力のときだけ表示し、 + * クリックで `onGenerate` を呼ぶ。 + * - `composeHref` 指定(新経路, #950): タイトルがあれば本文有無に関わらず表示し、 + * クリックで Compose 画面に navigate する。 + * + * Wiki generation button. + * + * - Without `composeHref` (legacy): shows only when there is a title and no + * body content; click invokes the inline `onGenerate` callback. + * - With `composeHref` (issue #950): shows whenever there is a title (Compose + * handles append vs replace internally); click navigates to the Compose UI. */ export const WikiGeneratorButton: React.FC = ({ title, @@ -34,17 +58,27 @@ export const WikiGeneratorButton: React.FC = ({ onGenerate, status, disabled = false, + composeHref, }) => { const navigate = useNavigate(); const location = useLocation(); const [showNotConfiguredDialog, setShowNotConfiguredDialog] = React.useState(false); - // タイトルがない、または本文がある場合はボタンを非表示 - const shouldShowButton = title.trim() !== "" && !hasContent; + // タイトルがない場合は常に非表示。 + // Compose 経路では本文ありでも表示する (#950 U2: append default)。 + // 旧経路では本文ありなら非表示 (inline generation はページを上書きするため)。 + const hasTitle = title.trim() !== ""; + const shouldShowButton = composeHref ? hasTitle : hasTitle && !hasContent; const isGenerating = status === "generating"; const handleClick = async () => { + // Compose 経路: 認可チェック不要(Compose 画面で実行する)。 + // Compose path: no AI-config check; the Compose UI handles it server-side. + if (composeHref) { + navigate(composeHref); + return; + } // AI が利用可能か確認(api_server モードでは API キー不要)。 // Check AI availability (no API key required in api_server mode). const configured = await isAIConfigured(); @@ -87,7 +121,7 @@ export const WikiGeneratorButton: React.FC = ({ -

AIでWikipedia風の解説を生成

+

{composeHref ? "AI と対話しながら Wiki を作成" : "AIでWikipedia風の解説を生成"}

diff --git a/src/components/note/PageEditorContent.tsx b/src/components/note/PageEditorContent.tsx index 3673572d..0bc45896 100644 --- a/src/components/note/PageEditorContent.tsx +++ b/src/components/note/PageEditorContent.tsx @@ -124,6 +124,12 @@ interface PageEditorContentProps { * Trailing control rendered beside the floating Wiki Link input bar. */ bottomBarTrailingAction?: React.ReactNode; + /** + * Wiki Compose 画面 (`/compose`) への遷移先 URL。指定すると `WikiGeneratorButton` + * が Compose 画面に遷移する経路を取り、本文ありでも表示される (#950 U2)。 + * Pass-through to the WikiGeneratorButton's `composeHref`. + */ + wikiComposeHref?: string; } /** @@ -157,6 +163,7 @@ export const PageEditorContent: React.FC = ({ pageActionHubRef, pageNoteId = null, bottomBarTrailingAction, + wikiComposeHref, }) => { const isEditorReadOnly = isReadOnly ?? isWikiGenerating; const hasContent = useMemo(() => isContentNotEmpty(content), [content]); @@ -198,13 +205,25 @@ export const PageEditorContent: React.FC = ({ onEnterMoveToContent={!isEditorReadOnly ? focusContent : undefined} /> - {wikiStatus && onGenerateWiki && ( + {/* Wiki 生成ボタンの表示条件: + - 旧経路: `wikiStatus` + `onGenerateWiki` 両方ある場合(インライン生成) + - 新経路: `wikiComposeHref` がある場合(Compose 画面に遷移、#950) + いずれも `WikiGeneratorButton` 自身がタイトル / 本文条件で更に + フィルタする。 + + Show the Wiki generation button when either: + - legacy: both `wikiStatus` + `onGenerateWiki` are supplied + (inline generation), or + - new: `wikiComposeHref` is supplied (navigate to Compose, #950). + `WikiGeneratorButton` itself filters on title/content state. */} + {((wikiStatus && onGenerateWiki) || wikiComposeHref) && (
undefined)} + status={wikiStatus ?? "idle"} + composeHref={wikiComposeHref} />
)} diff --git a/src/components/wikiCompose/ActivitySection.tsx b/src/components/wikiCompose/ActivitySection.tsx new file mode 100644 index 00000000..739efe13 --- /dev/null +++ b/src/components/wikiCompose/ActivitySection.tsx @@ -0,0 +1,89 @@ +/** + * `ActivitySection` — agent activity timeline (#950). + * + * Compose 右ペイン下部のアクティビティタイムライン。SSE で来るツール呼び出し / + * 調査イテレーション / フェーズ遷移を時系列で表示し、エージェントが何を + * しているかを可視化する。Compose 中盤の「無音」を避けるための重要な UI。 + * + * Read-only timeline. Newest entries at the bottom. Auto-scrolls into view + * when new rows arrive. + */ +import React, { useEffect, useRef } from "react"; +import { ScrollArea } from "@zedi/ui"; +import { cn } from "@zedi/ui"; +import { Check, Circle, AlertCircle, Loader2 } from "lucide-react"; +import type { ComposeActivity } from "@/hooks/useWikiComposeSession"; + +export interface ActivitySectionProps { + activity: ComposeActivity[]; + isStreaming: boolean; +} + +function Icon({ status }: { status: ComposeActivity["status"] }) { + switch (status) { + case "started": + return ; + case "completed": + return ; + case "error": + return ; + default: + return ; + } +} + +/** Compact activity timeline. */ +export const ActivitySection: React.FC = ({ activity, isStreaming }) => { + const containerRef = useRef(null); + + // Scroll to the bottom on every new entry so the user sees the latest work. + // 新規イベント到着時に末尾までスクロール。 + useEffect(() => { + const el = containerRef.current; + if (!el) return; + el.scrollTop = el.scrollHeight; + }, [activity]); + + return ( +
+
+

+ Activity +

+ {isStreaming ? ( + + live + + ) : null} +
+ +
+ {activity.length === 0 ? ( +

No activity yet.

+ ) : ( + activity.map((entry) => ( +
+
+ +
+
+
{entry.label}
+ {entry.detail ? ( +
{entry.detail}
+ ) : null} +
+
+ )) + )} +
+
+
+ ); +}; diff --git a/src/components/wikiCompose/BriefQuestionCard.tsx b/src/components/wikiCompose/BriefQuestionCard.tsx new file mode 100644 index 00000000..f2da3a0b --- /dev/null +++ b/src/components/wikiCompose/BriefQuestionCard.tsx @@ -0,0 +1,102 @@ +/** + * `BriefQuestionCard` — one structured Brief question (#950). + * + * Brief フェーズで Orchestrator が生成した 1 件の質問カード。チップ式選択肢 + + * 任意のフリーテキストを統合した入出力 UI。`required` の質問は未回答だと + * `Submit` ボタンが無効化される(親側で判定)。 + * + * Renders one question with optional answer chips and a free-text addendum + * box. Multi-select is supported; the parent owns the answer state. + */ +import React from "react"; +import { cn } from "@zedi/ui"; +import { Badge, Card, CardContent, CardHeader, CardTitle, Input } from "@zedi/ui"; +import type { BriefAnswer, BriefQuestion } from "@/lib/wikiCompose/types"; + +export interface BriefQuestionCardProps { + question: BriefQuestion; + answer: BriefAnswer | null; + onChange: (next: BriefAnswer) => void; +} + +/** Toggles a single option id in the current selection. */ +function toggleOption(selected: string[], optionId: string): string[] { + return selected.includes(optionId) + ? selected.filter((id) => id !== optionId) + : [...selected, optionId]; +} + +/** Render one Brief question card. */ +export const BriefQuestionCard: React.FC = ({ + question, + answer, + onChange, +}) => { + const selected = answer?.selectedOptionIds ?? []; + const freeText = answer?.freeText ?? ""; + + return ( + + + + {question.question} + {question.required ? ( + + required + + ) : null} + + {question.rationale ? ( +

{question.rationale}

+ ) : null} +
+ + {question.options.length > 0 ? ( +
+ {question.options.map((option) => { + const active = selected.includes(option.id); + return ( + + ); + })} +
+ ) : null} + + 0 ? "Add a note (optional)…" : "Type your answer…"} + data-testid={`brief-freetext-${question.id}`} + value={freeText} + onChange={(e) => + onChange({ + questionId: question.id, + selectedOptionIds: selected, + freeText: e.target.value || undefined, + }) + } + /> +
+
+ ); +}; diff --git a/src/components/wikiCompose/ComposePanel.tsx b/src/components/wikiCompose/ComposePanel.tsx new file mode 100644 index 00000000..5ec7f692 --- /dev/null +++ b/src/components/wikiCompose/ComposePanel.tsx @@ -0,0 +1,110 @@ +/** + * `ComposePanel` — right pane of the Wiki Compose split view (#950). + * + * 分割画面の右ペイン。`PhaseStepper` (top), Dialogue / Research セクション + * (middle), ActivitySection (bottom) を 1 つのスクロール可能カラムに積む。 + * フェーズに応じて DialogueSection と ResearchSection の表示を出し分ける。 + * + * Stacks the stepper + phase-specific dialogue panel + activity log. The + * actual interaction logic lives in each section component; this is just a + * layout wrapper. + */ +import React from "react"; +import { PhaseStepper } from "./PhaseStepper"; +import { DialogueSection } from "./DialogueSection"; +import { ResearchSection } from "./ResearchSection"; +import { ActivitySection } from "./ActivitySection"; +import type { + BriefAnswer, + BriefQuestion, + OutlineSection, + PageSnapshot, + ResearchBatch, + ResearchSource, +} from "@/lib/wikiCompose/types"; +import type { ComposeActivity, ComposePhase } from "@/hooks/useWikiComposeSession"; + +export interface ComposePanelProps { + phase: ComposePhase; + isStreaming: boolean; + + briefQuestions: BriefQuestion[]; + pageSnapshot: PageSnapshot | null; + + latestBatch: ResearchBatch | null; + pendingSources: ResearchSource[]; + approvedSources: ResearchSource[]; + + outlineProposal: OutlineSection[]; + + activity: ComposeActivity[]; + + onSubmitBrief: (input: { + answers: BriefAnswer[]; + appendToExisting?: boolean; + researchMaxIterations?: number; + }) => Promise; + onSubmitResearchApproval: (input: { + approvedSourceIds: string[]; + rejectedSourceIds?: string[]; + note?: string; + }) => Promise; + onSubmitOutline: (input: { sections: OutlineSection[] }) => Promise; +} + +/** Right pane container. */ +export const ComposePanel: React.FC = (props) => { + const { + phase, + isStreaming, + briefQuestions, + pageSnapshot, + latestBatch, + pendingSources, + approvedSources, + outlineProposal, + activity, + onSubmitBrief, + onSubmitResearchApproval, + onSubmitOutline, + } = props; + + return ( + + ); +}; diff --git a/src/components/wikiCompose/DialogueSection.tsx b/src/components/wikiCompose/DialogueSection.tsx new file mode 100644 index 00000000..663e0e2c --- /dev/null +++ b/src/components/wikiCompose/DialogueSection.tsx @@ -0,0 +1,238 @@ +/** + * `DialogueSection` — Brief / Structure interaction panel (#950). + * + * Compose 画面右ペインの「対話」セクション。フェーズに応じて Brief の質問カード + * 群、Structure のアウトラインエディタ、Draft 中のセクション進捗を出し分ける。 + * Compose は free-form chat ではないため、各フェーズの UI は専用フォーム形式。 + * + * Pure presentational shell that routes between the BriefQuestionCard list, + * OutlineEditor, and the section progress view based on `phase`. Submit + * handlers come from the parent (`WikiComposePage` → `useWikiComposeSession`). + */ +import React, { useMemo, useState } from "react"; +import { Button, Card, CardContent, CardHeader, CardTitle, Slider } from "@zedi/ui"; +import { Sparkles, RefreshCw, ArrowRight } from "lucide-react"; +import type { + BriefAnswer, + BriefQuestion, + OutlineSection, + PageSnapshot, +} from "@/lib/wikiCompose/types"; +import { BriefQuestionCard } from "./BriefQuestionCard"; +import { OutlineEditor } from "./OutlineEditor"; +import type { ComposePhase } from "@/hooks/useWikiComposeSession"; + +export interface DialogueSectionProps { + phase: ComposePhase; + briefQuestions: BriefQuestion[]; + pageSnapshot: PageSnapshot | null; + outlineProposal: OutlineSection[]; + isStreaming: boolean; + /** Brief submission. */ + onSubmitBrief: (input: { + answers: BriefAnswer[]; + appendToExisting?: boolean; + researchMaxIterations?: number; + }) => Promise; + /** Structure submission. */ + onSubmitOutline: (input: { sections: OutlineSection[] }) => Promise; +} + +/** Whether all required questions have at least one answer. */ +function allRequiredAnswered( + questions: BriefQuestion[], + answers: Record, +): boolean { + return questions + .filter((q) => q.required) + .every((q) => { + const a = answers[q.id]; + if (!a) return false; + const hasOption = (a.selectedOptionIds ?? []).length > 0; + const hasText = Boolean(a.freeText && a.freeText.trim().length > 0); + return hasOption || hasText; + }); +} + +/** Container for Brief / Structure / Draft dialogue UIs. */ +export const DialogueSection: React.FC = ({ + phase, + briefQuestions, + pageSnapshot, + outlineProposal, + isStreaming, + onSubmitBrief, + onSubmitOutline, +}) => { + const [answers, setAnswers] = useState>({}); + const [appendToExisting, setAppendToExisting] = useState( + Boolean(pageSnapshot?.hasContent), + ); + const [maxIterations, setMaxIterations] = useState(3); + const [submitting, setSubmitting] = useState(false); + + const canSubmitBrief = useMemo( + () => allRequiredAnswered(briefQuestions, answers), + [briefQuestions, answers], + ); + + if (phase === "brief") { + return ( +
+
+

+ Brief +

+ + {briefQuestions.length === 0 + ? "No questions — proceed to research" + : `${briefQuestions.length} question${briefQuestions.length > 1 ? "s" : ""}`} + +
+ + {briefQuestions.length === 0 && isStreaming ? ( + + + Preparing Brief questions… + + + ) : null} + + {briefQuestions.map((q) => ( + setAnswers((prev) => ({ ...prev, [q.id]: next }))} + /> + ))} + + {pageSnapshot?.hasContent ? ( + + + + Page already has content + + + + + + + + ) : null} + + + + + Research depth + + + +
+ 1 (quick) + + {maxIterations} iteration{maxIterations > 1 ? "s" : ""} + + 5 (deep) +
+ setMaxIterations(v[0] ?? 3)} + /> +
+
+ +
+ +
+
+ ); + } + + if (phase === "structure") { + return ( +
+
+

Outline

+ + {outlineProposal.length} section{outlineProposal.length === 1 ? "" : "s"} + +
+ { + setSubmitting(true); + try { + await onSubmitOutline({ sections }); + } finally { + setSubmitting(false); + } + }} + /> +
+ ); + } + + if (phase === "draft" || phase === "completed") { + return ( +
+
+

+ {phase === "completed" ? "Completed" : "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."} +

+
+
+ ); + } + + // research phase — handled in ResearchSection; nothing to render here. + return null; +}; diff --git a/src/components/wikiCompose/EditorPane.tsx b/src/components/wikiCompose/EditorPane.tsx new file mode 100644 index 00000000..eff4789e --- /dev/null +++ b/src/components/wikiCompose/EditorPane.tsx @@ -0,0 +1,100 @@ +/** + * `EditorPane` — left pane of the Wiki Compose split view (#950). + * + * 分割画面の左ペイン。タイトル + Tiptap ベースのエディタを表示する想定だが、 + * Compose 中の draft 進捗を確認できるよう、本実装ではセクション本文を + * Markdown プレビューとして直接描画する MVP に絞る。確定後 (`phase === "completed"`) + * は完成 Markdown を一括表示し、ユーザーはノートに戻ってから Tiptap で確定する。 + * + * Read-only preview of the streaming/drafted content. Each outline section + * gets its own `## heading` block. The currently-streaming section is + * highlighted with a pulsing border so the user sees where to look. + */ +import React from "react"; +import { cn } from "@zedi/ui"; +import type { DraftedSection, OutlineSection } from "@/lib/wikiCompose/types"; + +export interface EditorPaneProps { + title: string; + outline: OutlineSection[]; + draftedSections: Record; + sectionBuffers: Record; + streamingSectionId: string | null; + /** Markdown preview to render when the run completes. */ + completedMarkdown: string | null; +} + +/** Render the left preview pane. */ +export const EditorPane: React.FC = ({ + title, + outline, + draftedSections, + sectionBuffers, + streamingSectionId, + completedMarkdown, +}) => { + return ( +
+

{title || "Untitled page"}

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

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

+ ) : null} + + {outline.length > 0 ? ( +
+ {outline.map((section) => { + const drafted = draftedSections[section.id]; + const buffer = sectionBuffers[section.id] ?? ""; + const isStreaming = streamingSectionId === section.id; + const body = drafted?.body ?? buffer; + return ( +
+ {section.depth === 1 ? ( +

{section.heading}

+ ) : ( +

{section.heading}

+ )} + {body.trim().length === 0 ? ( +

+ {isStreaming ? "Streaming…" : section.intent} +

+ ) : ( + // Plain-text rendering of the running buffer. Once the + // section finalises we still render as
 to preserve
+                  // formatting; a future iteration can mount Tiptap here.
+                  // 進行中はバッファをそのまま 
 で出す(フォーマット保持)。
+                  
+                    {body}
+                  
+ )} +
+ ); + })} +
+ ) : null} + + {completedMarkdown ? ( +
+

Final Markdown

+
+            {completedMarkdown}
+          
+
+ ) : null} +
+ ); +}; diff --git a/src/components/wikiCompose/OutlineEditor.tsx b/src/components/wikiCompose/OutlineEditor.tsx new file mode 100644 index 00000000..8d80e2a1 --- /dev/null +++ b/src/components/wikiCompose/OutlineEditor.tsx @@ -0,0 +1,170 @@ +/** + * `OutlineEditor` — editable outline list for the Structure phase (#950). + * + * Orchestrator が提案したアウトラインをユーザーが編集 (並び替え / リネーム / + * depth 変更 / 削除) するための軽量 UI。ドラッグ&ドロップは将来対応とし、 + * 当面は上下矢印ボタンで順序入れ替えする。 + * + * Minimal accessible outline editor. Each section row has heading + intent + * inputs, depth toggle (h2 ↔ h3), move-up / move-down buttons, and a delete + * button. The user submits via the dedicated button at the bottom. + */ +import React, { useState } from "react"; +import { ArrowDown, ArrowUp, Trash2, Plus, Check } from "lucide-react"; +import { Button, Card, CardContent, Input, Textarea } from "@zedi/ui"; +import { cn } from "@zedi/ui"; +import type { OutlineSection } from "@/lib/wikiCompose/types"; + +let nextLocalId = 0; +function makeLocalId(): string { + nextLocalId += 1; + return `local-${nextLocalId}-${Date.now()}`; +} + +export interface OutlineEditorProps { + initialSections: OutlineSection[]; + disabled?: boolean; + onSubmit: (sections: OutlineSection[]) => Promise; +} + +/** Render an editable outline. */ +export const OutlineEditor: React.FC = ({ + initialSections, + disabled = false, + onSubmit, +}) => { + const [sections, setSections] = useState(initialSections); + const [submitting, setSubmitting] = useState(false); + + React.useEffect(() => { + setSections(initialSections); + }, [initialSections]); + + const move = (index: number, direction: -1 | 1) => { + setSections((prev) => { + const next = [...prev]; + const target = index + direction; + if (target < 0 || target >= next.length) return prev; + const item = next[index]; + const other = next[target]; + if (!item || !other) return prev; + next[index] = other; + next[target] = item; + return next; + }); + }; + + const remove = (id: string) => setSections((prev) => prev.filter((s) => s.id !== id)); + + const add = () => + setSections((prev) => [ + ...prev, + { id: makeLocalId(), heading: "New section", depth: 1, intent: "" }, + ]); + + const update = (id: string, patch: Partial) => + setSections((prev) => prev.map((s) => (s.id === id ? { ...s, ...patch } : s))); + + const isSubmittable = sections.length > 0 && sections.every((s) => s.heading.trim().length > 0); + + return ( +
+ {sections.map((section, i) => ( + 1 && "ml-6")} + > + +
+ update(section.id, { heading: e.target.value })} + placeholder="Section heading" + disabled={disabled} + /> + + + + +
+