Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* `composeSessionProjection` unit tests (#950).
*/
Comment thread
coderabbitai[bot] marked this conversation as resolved.
import { describe, expect, it } from "vitest";
import { projectComposeStateValues } from "../../routes/composeSessionProjection.js";

describe("projectComposeStateValues", () => {
it("projects a Brief interrupt from __interrupt__", () => {
const projection = projectComposeStateValues({
__interrupt__: [
{
value: {
kind: "human_review_brief",
questions: [{ id: "q1", question: "Scope?", required: false, options: [] }],
pageSnapshot: { pageId: "p1", title: "T", body: "", hasContent: false },
},
},
],
});
expect(projection.phase).toBe("brief");
expect(projection.briefQuestions).toHaveLength(1);
expect(projection.pageSnapshot).toMatchObject({ title: "T" });
});

it("projects completion markdown from checkpoint values", () => {
const projection = projectComposeStateValues({
phase: "completed",
completion: {
markdown: "## A\n\nBody",
sections: [
{
sectionId: "sec-1",
heading: "A",
body: "Body",
citedSourceIds: [],
completedAt: "2026-01-01T00:00:00.000Z",
},
],
},
});
expect(projection.completedMarkdown).toBe("## A\n\nBody");
expect(projection.draftedSections).toHaveLength(1);
});
});
27 changes: 25 additions & 2 deletions server/api/src/agents/graphs/wikiCompose/nodes/briefDialogue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,11 @@ const SYSTEM_PROMPT =
"make the article unwritable. Most questions should be optional.\n" +
"Respond as JSON only.";

function buildUserPrompt(title: string, body: string): string {
function buildUserPrompt(
title: string,
body: string,
chatSeed?: { outline: string; conversationText: string; userSchema?: string } | null,
): string {
const parts: string[] = [`[Page title]`, title || "(no title yet)"];
if (body.trim()) {
parts.push(
Expand All @@ -83,6 +87,22 @@ function buildUserPrompt(title: string, body: string): string {
} else {
parts.push("", "(Page body is empty.)");
}
if (chatSeed?.outline?.trim()) {
parts.push("", "[User-approved outline from chat]", chatSeed.outline.trim().slice(0, 2000));
}
if (chatSeed?.conversationText?.trim()) {
parts.push(
"",
"[Chat transcript excerpt]",
chatSeed.conversationText.trim().slice(0, 4000),
chatSeed.conversationText.length > 4000
? `\n(…truncated; total ${chatSeed.conversationText.length} chars)`
: "",
);
}
if (chatSeed?.userSchema?.trim()) {
parts.push("", "[User wiki schema]", chatSeed.userSchema.trim().slice(0, 1500));
}
return parts.join("\n");
}

Expand Down Expand Up @@ -120,7 +140,10 @@ export async function briefDialogue(
try {
raw = await structured.invoke([
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: buildUserPrompt(snapshot.title, snapshot.body) },
{
role: "user",
content: buildUserPrompt(snapshot.title, snapshot.body, state.chatSeed),
},
]);
} catch {
// Defensive fallback: if the LLM call fails, emit an empty Brief so the
Expand Down
12 changes: 12 additions & 0 deletions server/api/src/agents/graphs/wikiCompose/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import type {
ApprovedOutline,
BriefQuestion,
BriefResult,
ComposeChatSeed,
ComposeCompletion,
DraftedSection,
OutlineSection,
Expand Down Expand Up @@ -95,6 +96,17 @@ export const WikiComposeState = Annotation.Root({
...BaseState.spec,

// ── Brief phase ───────────────────────────────────────────────────────────
/**
* チャット由来 seed(outline + 会話)。AI Chat / Promote to Wiki からの
* 初回 `POST /run` input でセットする (#950)。
*
* Chat → Compose seed (outline + conversation). Set on the first `POST /run`
* input when the user arrives from AI Chat / Promote to Wiki (#950).
*/
Comment thread
coderabbitai[bot] marked this conversation as resolved.
chatSeed: Annotation<ComposeChatSeed | null>({
reducer: (prev, next) => (next === undefined ? prev : next),
default: () => null,
}),
/** Page snapshot loaded once at session start. */
pageSnapshot: Annotation<PageSnapshot | null>({
reducer: (prev, next) => (next === undefined ? prev : next),
Expand Down
14 changes: 14 additions & 0 deletions server/api/src/agents/graphs/wikiCompose/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,20 @@ import type { Source } from "../../subgraphs/research/types.js";
/** Re-exported here so orchestrator nodes can import everything from one barrel. */
export type { Source };

/**
* チャットから Compose に入ったときの任意 seed (#950)。
* 初回 `POST /run` の input とセッション行 `metadata` に載せる。
*
* Optional chat context seeded when entering Compose from AI Chat (#950).
* Passed on the first graph `input` and stored on the session row metadata.
*/
Comment thread
coderabbitai[bot] marked this conversation as resolved.
export interface ComposeChatSeed {
outline: string;
conversationText: string;
userSchema?: string;
conversationId?: string;
}

/**
* Brief フェーズで Orchestrator が生成する 1 つの構造化質問。
*
Expand Down
174 changes: 174 additions & 0 deletions server/api/src/routes/composeSessionProjection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
/**
* Project LangGraph checkpoint state into Compose UI slices (#950).
*
* `GET /compose-sessions/:id` が interrupted / completed 行を再開するとき、
* チェックポイントから Brief 質問・アウトライン等を復元する。
*
* Maps persisted graph state (including `__interrupt__`) into a JSON shape the
* frontend hook can merge without replaying `POST /run`.
*/
import { GRAPH_CONTEXT_CONFIG_KEY } from "../agents/core/types/graphContext.js";
import type { GraphContext } from "../agents/core/types/graphContext.js";
import { resolveCheckpointerForRun } from "../agents/core/checkpoint/index.js";
import { getRegisteredGraph } from "../agents/registry/graphRegistry.js";
import type { WikiComposeSessionStatus } from "../schema/wikiComposeSessions.js";

/** Wire projection returned by `GET /compose-sessions/:id`. */
export interface ComposeSessionUiProjection {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
phase?: string;
briefQuestions?: unknown[];
pageSnapshot?: unknown;
pendingSources?: unknown[];
latestBatch?: unknown;
approvedSources?: unknown[];
outlineProposal?: unknown[];
draftedSections?: unknown[];
completedMarkdown?: string | null;
}

function phaseFromSessionRow(phase: string, status: WikiComposeSessionStatus): string {
if (status === "completed") return "completed";
if (phase.startsWith("brief")) return "brief";
if (phase.startsWith("research")) return "research";
if (phase.startsWith("structure")) return "structure";
if (phase.startsWith("draft")) return "draft";
return "brief";
}

/**
* Build UI projection from a LangGraph state snapshot (values + interrupts).
*/
export function projectComposeStateValues(
state: Record<string, unknown>,
): ComposeSessionUiProjection {
const projection: ComposeSessionUiProjection = {};

if (Array.isArray(state.briefQuestions)) {
projection.briefQuestions = state.briefQuestions;
}
if (state.pageSnapshot && typeof state.pageSnapshot === "object") {
projection.pageSnapshot = state.pageSnapshot;
}
if (Array.isArray(state.pendingSources)) {
projection.pendingSources = state.pendingSources;
}
if (Array.isArray(state.batches) && state.batches.length > 0) {
projection.latestBatch = state.batches[state.batches.length - 1];
}
if (Array.isArray(state.approvedResearch)) {
projection.approvedSources = state.approvedResearch;
}
if (Array.isArray(state.outlineProposal) && state.outlineProposal.length > 0) {
projection.outlineProposal = state.outlineProposal;
} else {
const approved = state.approvedOutline as { sections?: unknown[] } | undefined;
if (approved?.sections?.length) {
projection.outlineProposal = approved.sections;
}
}

if (Array.isArray(state.draftedSections) && state.draftedSections.length > 0) {
projection.draftedSections = state.draftedSections;
}

const completion = state.completion;
if (completion && typeof completion === "object") {
const c = completion as { markdown?: string; sections?: unknown[] };
if (typeof c.markdown === "string") {
projection.completedMarkdown = c.markdown;
}
if (Array.isArray(c.sections)) {
projection.draftedSections = c.sections;
}
}

const interrupts = state.__interrupt__;
if (Array.isArray(interrupts) && interrupts.length > 0) {
const entry = interrupts[0];
const value =
entry && typeof entry === "object" ? (entry as { value?: unknown }).value : undefined;
if (value && typeof value === "object" && "kind" in value) {
const payload = value as {
kind: string;
questions?: unknown[];
pageSnapshot?: unknown;
batch?: unknown;
pendingSources?: unknown[];
outline?: unknown[];
approvedSources?: unknown[];
};
switch (payload.kind) {
case "human_review_brief":
if (payload.questions) projection.briefQuestions = payload.questions;
if (payload.pageSnapshot) projection.pageSnapshot = payload.pageSnapshot;
projection.phase = "brief";
break;
case "human_review_research":
if (payload.batch) projection.latestBatch = payload.batch;
if (payload.pendingSources) projection.pendingSources = payload.pendingSources;
projection.phase = "research";
break;
case "human_review_outline":
if (payload.outline) projection.outlineProposal = payload.outline;
if (payload.approvedSources) projection.approvedSources = payload.approvedSources;
projection.phase = "structure";
break;
default:
break;
}
}
}

if (typeof state.phase === "string") {
projection.phase = phaseFromSessionRow(state.phase, "interrupted");
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

return projection;
}

/**
* Load checkpoint projection for a compose session row, or `null` when unavailable.
*/
export async function loadComposeSessionProjection(input: {
sessionId: string;
pageId: string;
graphId: string;
status: WikiComposeSessionStatus;
phase: string;
context: GraphContext;
}): Promise<ComposeSessionUiProjection | null> {
if (input.status !== "interrupted" && input.status !== "completed" && input.status !== "failed") {
return null;
}

const checkpointer = await resolveCheckpointerForRun();
if (checkpointer === false) return null;

const registered = getRegisteredGraph(input.graphId);
if (!registered) return null;

const graph = registered.factory({ checkpointer }) as {
getState?: (config: unknown) => Promise<{ values?: Record<string, unknown> } | undefined>;
};
if (typeof graph.getState !== "function") return null;

const config = {
configurable: {
thread_id: input.sessionId,
[GRAPH_CONTEXT_CONFIG_KEY]: input.context,
},
};

try {
const snap = await graph.getState(config);
const values = snap?.values;
if (!values || typeof values !== "object") return null;
const projection = projectComposeStateValues(values);
if (!projection.phase) {
projection.phase = phaseFromSessionRow(input.phase, input.status);
}
return projection;
} catch {
return null;
}
}
24 changes: 23 additions & 1 deletion server/api/src/routes/composeSessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ 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";
import { persistOutcomeIfStillRunning } from "./composeSessionPersistence.js";
import { loadComposeSessionProjection } from "./composeSessionProjection.js";

/**
* Translate the documented `body.input.kind === "additional_research"` shape
Expand Down Expand Up @@ -210,7 +211,28 @@ app.get("/:pageId/compose-sessions/:id", authRequired, async (c) => {
.limit(1);
if (!row) throw new HTTPException(404, { message: "Session not found" });

return c.json({ session: row });
const tier = await getUserTier(userId, db);
const projection = await loadComposeSessionProjection({
sessionId: row.id,
pageId: row.pageId,
graphId: row.graphId,
status: row.status,
phase: row.phase,
context: {
threadId: row.id,
sessionId: row.id,
userId,
userEmail: c.get("userEmail") ?? null,
pageId: row.pageId,
graphId: row.graphId,
backend: assertSupportedBackendP0(row.backend),
tier,
db,
feature: `wiki_compose:${row.graphId}`,
},
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

return c.json({ session: row, projection });
});

// ── POST /:id/run — SSE run ─────────────────────────────────────────────────
Expand Down
23 changes: 11 additions & 12 deletions src/components/ai-chat/PromoteToWikiDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import { callAIService } from "@/lib/aiService";
import { loadAISettings } from "@/lib/aiSettings";
import { useCreatePage } from "@/hooks/usePageQueries";
import { useWikiSchema } from "@/hooks/useWikiSchema";
import type { PendingChatPageGenerationState } from "@/types/chatPageGeneration";
import { navigateToWikiCompose } from "@/lib/wikiCompose/navigation";
import { EntityRow } from "./EntityRow";

/**
Expand Down Expand Up @@ -258,19 +258,18 @@ function PromoteToWikiDialogBody({
if (!firstCreated) throw new Error("no pages created");

const firstEntity = selectedEntities[created.indexOf(firstCreated)];
const pending: PendingChatPageGenerationState = {
outline: `- ${firstEntity.summary}`,
conversationText,
userSchema: schemaData?.content,
conversationId,
};
toast({ title: t("aiChat.notifications.promoteSuccess") });
onClose();
// Issue #889 Phase 3: `/pages/:id` 撤去のため `/notes/:noteId/:pageId` に遷移。
// Issue #889 Phase 3: route to `/notes/:noteId/:pageId` after `/pages/:id`
// was retired.
navigate(`/notes/${firstCreated.noteId}/${firstCreated.id}`, {
state: { pendingChatPageGeneration: pending },
navigateToWikiCompose({
navigate,
noteId: firstCreated.noteId,
pageId: firstCreated.id,
seed: {
outline: `- ${firstEntity.summary}`,
conversationText,
userSchema: schemaData?.content,
conversationId,
},
});
} catch {
toast({ title: t("aiChat.notifications.promoteFailed"), variant: "destructive" });
Expand Down
Loading
Loading