One shot per chat, fired at the first turn's onFinish: the session's topic (its only typed message) is embedded as an EmbeddingGemma retrieval query; the semantically closest graph propositions are selected under the session's token budget (pure budget, no similarity threshold — the decided doctrine) and delivered as a marker-only steer through the wake pipeline, queuing behind pending questions like every sub-agent result. - contracts: ChatSettings.graphContextTokens (default 1024, max 4096, step 128, 0 = off); GraphContextData + graphContextText; the graph-context JobKind + buildGraphContextSteer (id steer_ctx_<chatId>) - core/graph-context.ts: brute-force cosine top-K, greedy chars/4 fill under budget, most-recent UnderstandingLevel fold, count cap 64, parse-don't-cast at the marker seam - server: embedQuery (role-asymmetric query format, same warm singleton — vector-space parity by construction); the runner (startGraphContext- Injection one-shot gates; runGraphContextJob validates ledger parity); wake branches (chatIdOf/jobIdOf, finalizeJob done, recoverStaleJobs re-deliver or contextRespawn); abortJob kills context jobs SILENTLY (nothing was promised in the lesson); turn-service trigger seam - adapter: data-graph-context flattens to the canonical block (malformed parts have no model face) - UI: the budget slider (home dock + /chat/new draft, the session-data strip); the quiet SystemLine "~537 · 10 propositions"; dock kind "Knowledge context"; model-picker PUT now spreads settings (a model pick can no longer clear the sibling override) Live-smoked end to end on the real embedder + real GLM: seeded ledger (10 propositions), session on "modal logic: possible worlds and the necessity box" — job in the dock, queued behind the prober's question, delivered on the answer, SystemLine in the transcript, wake turn reads the block and continues the probe. Gates: tsc, lint, 459/459, build.
162 lines
5.6 KiB
TypeScript
162 lines
5.6 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useState } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { Conversation, ConversationContent, ConversationEmptyState } from "@/components/ai-elements/conversation";
|
|
import { Composer } from "@/components/composer";
|
|
import { GraphContextControl, effectiveContextTokens } from "@/components/graph-context-control";
|
|
import { PhaseChip } from "@/components/phase-divider";
|
|
import { SidebarToggle } from "@/components/sidebar";
|
|
import { StatsBar } from "@/components/stats-bar";
|
|
import type { ChatSettings } from "@/src/core";
|
|
|
|
/**
|
|
* The sessionless draft view at /chat/new (owner, 2026-09-13): "+ New
|
|
* session" opens THIS, never a created record — a session comes to exist
|
|
* only when the learner sends the first message, and that message becomes
|
|
* the session's title (the exact create-then-redirect contract the home
|
|
* composer established). Visually identical to a fresh chat: same header
|
|
* (title + probe chip), the same empty state, the same composer blob +
|
|
* stats dock. The model picker's empty-chatId branch defers its save and
|
|
* lifts the selection here, so the pre-chat model/thinking choice rides
|
|
* the creation.
|
|
*/
|
|
|
|
const DOCK = "mx-auto w-full max-w-[46rem] px-4";
|
|
|
|
/**
|
|
* The draft's whole behavior as one pure seam: create the session (title =
|
|
* the first message), persist the lifted pre-chat settings, return the push
|
|
* target — /chat/<id>?prompt=<msg> — whose initialPrompt path delivers the
|
|
* message as turn 1. Injected fetch/router so tests pin the contract.
|
|
* Returns null on empty text; rethrows on transport failure (the caller's
|
|
* busy flag resets).
|
|
*/
|
|
export async function startDraftSession(
|
|
text: string,
|
|
settings: ChatSettings | undefined,
|
|
fetchLike: typeof fetch,
|
|
): Promise<string | null> {
|
|
const t = text.trim();
|
|
if (!t) return null;
|
|
const res = await fetchLike("/api/chats", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ title: t }),
|
|
});
|
|
const { id } = (await res.json()) as { id: string };
|
|
if (settings) {
|
|
// Best-effort persist — the selection was made pre-chat.
|
|
await fetchLike(`/api/chats/${id}/settings`, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(settings),
|
|
}).catch(() => {});
|
|
}
|
|
return `/chat/${id}?prompt=${encodeURIComponent(t)}`;
|
|
}
|
|
|
|
/** The pure shell (no router) — the render-test seam: the draft view's
|
|
* visible surface, assembled from the same pieces a fresh chat shows. */
|
|
export function DraftShell({
|
|
busy,
|
|
text,
|
|
onChange,
|
|
onSend,
|
|
settings,
|
|
onSettingsSaved,
|
|
}: {
|
|
busy: boolean;
|
|
text: string;
|
|
onChange: (v: string) => void;
|
|
onSend: () => void;
|
|
settings: ChatSettings | undefined;
|
|
onSettingsSaved: (s: ChatSettings) => void;
|
|
}) {
|
|
const canSend = text.trim().length > 0;
|
|
return (
|
|
<div className="flex h-dvh">
|
|
<div className="flex min-w-0 flex-1 flex-col">
|
|
<header className="relative flex h-10 items-center gap-3 px-3">
|
|
<SidebarToggle />
|
|
<div className="min-w-0 flex-1 truncate font-title text-2xl tracking-tight">New session</div>
|
|
<PhaseChip phase="probe" />
|
|
</header>
|
|
|
|
<div className="relative flex min-h-0 flex-1">
|
|
{/* The pointer-events pattern (953b22b): the sticky wrapper is
|
|
pointer-events-none so nothing here blocks the empty-state
|
|
area; the blob and the stats row re-enable it themselves. */}
|
|
<div className="pointer-events-none sticky bottom-0 mt-auto w-full">
|
|
<Conversation className="h-full min-w-0 flex-1" initial="instant">
|
|
<ConversationContent className="min-h-full gap-0 p-0">
|
|
<ConversationEmptyState
|
|
title="New session"
|
|
description="The teacher will open by probing what you already know."
|
|
/>
|
|
</ConversationContent>
|
|
</Conversation>
|
|
<div className="h-24 bg-gradient-to-t from-background via-background/85 to-transparent" />
|
|
<div className="bg-background pb-4">
|
|
<div className={DOCK}>
|
|
{/* The session-data strip (vision-context-injection.md) — same
|
|
control as the home view, riding the same lifted settings. */}
|
|
<div className="mb-1.5">
|
|
<GraphContextControl
|
|
value={effectiveContextTokens(settings)}
|
|
onChange={(tokens) =>
|
|
onSettingsSaved({ ...settings, graphContextTokens: tokens })
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="pointer-events-auto overflow-hidden rounded-2xl border bg-card shadow-lg">
|
|
<Composer
|
|
busy={busy}
|
|
value={text}
|
|
onChange={onChange}
|
|
onSend={onSend}
|
|
onStop={() => {}}
|
|
canSend={canSend}
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
<StatsBar turns={[]} chatId="" settings={settings} onSettingsSaved={onSettingsSaved} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function NewSession() {
|
|
const router = useRouter();
|
|
const [text, setText] = useState("");
|
|
const [busy, setBusy] = useState(false);
|
|
// The pre-chat model+thinking selection, lifted from the StatsBar's
|
|
// ModelControl (empty chatId → it never PUTs; it hands the choice up).
|
|
const [settings, setSettings] = useState<ChatSettings | undefined>(undefined);
|
|
|
|
const send = useCallback(() => {
|
|
if (!text.trim() || busy) return;
|
|
setBusy(true);
|
|
void startDraftSession(text, settings, fetch)
|
|
.then((target) => {
|
|
if (target) router.push(target);
|
|
else setBusy(false);
|
|
})
|
|
.catch(() => setBusy(false));
|
|
}, [text, busy, settings, router]);
|
|
|
|
return (
|
|
<DraftShell
|
|
busy={busy}
|
|
text={text}
|
|
onChange={setText}
|
|
onSend={send}
|
|
settings={settings}
|
|
onSettingsSaved={setSettings}
|
|
/>
|
|
);
|
|
} |