feat: graph context injection — the session's prior knowledge, steered in at session start (vision-context-injection.md)
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.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ActivityIcon, BotIcon, PenToolIcon, PlayIcon, SearchIcon, SquareIcon, WaypointsIcon } from "lucide-react";
|
||||
import { ActivityIcon, BookOpenIcon, BotIcon, PenToolIcon, PlayIcon, SearchIcon, SquareIcon, WaypointsIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CallUsage, JobKind, JobRecord } from "@/src/core";
|
||||
import { CONTEXT_LIMITS, jobCost } from "@/src/core";
|
||||
@@ -204,6 +204,7 @@ const KIND_ICON: Record<JobKind, typeof SearchIcon> = {
|
||||
research: SearchIcon,
|
||||
demo: PlayIcon,
|
||||
visual: PenToolIcon,
|
||||
"graph-context": BookOpenIcon,
|
||||
};
|
||||
|
||||
/** Abort states per blob id: pending (fired) / failed (backend absent). */
|
||||
@@ -318,7 +319,9 @@ function ActivityBlobRow({
|
||||
? "Demo builder"
|
||||
: blob.jobKind === "visual"
|
||||
? "Figure maker"
|
||||
: "Sub-agent";
|
||||
: blob.jobKind === "graph-context"
|
||||
? "Knowledge context"
|
||||
: "Sub-agent";
|
||||
return (
|
||||
<div
|
||||
data-testid={`activity-blob-${blob.process}`}
|
||||
|
||||
@@ -21,6 +21,9 @@ const KIND_LABEL: Record<JobKind, string> = {
|
||||
research: "Research agent",
|
||||
demo: "Demo agent",
|
||||
visual: "Figure agent",
|
||||
// The injection is server-initiated — it has no tool part, so never a
|
||||
// dispatch card. The entry exists only for the exhaustive record.
|
||||
"graph-context": "Knowledge context",
|
||||
};
|
||||
|
||||
export function DispatchCard({ toolCallId, kind }: { toolCallId: string; kind: JobKind }) {
|
||||
|
||||
+20
-2
@@ -21,7 +21,8 @@ import { SubagentSteerCard } from "@/components/artifacts/steer-card";
|
||||
import { Composer } from "@/components/composer";
|
||||
import { JobsContext } from "@/components/jobs-context";
|
||||
import { StatsBar } from "@/components/stats-bar";
|
||||
import type { ChatSettings } from "@/src/core";
|
||||
import { graphContextOfMessage, type ChatSettings } from "@/src/core";
|
||||
import { formatTokens } from "@/components/usage-format";
|
||||
import { PlanBlock, PlanPopup, latestTree } from "@/components/plan-block";
|
||||
import { PlanMarginalia } from "@/components/plan-window";
|
||||
import { WaypointsIcon } from "lucide-react";
|
||||
@@ -81,6 +82,16 @@ const useIsoLayoutEffect = typeof window === "undefined" ? useEffect : useLayout
|
||||
|
||||
/** A wake-pipeline steer message: user-role delivery whose metadata marks it
|
||||
* as a sub-agent result — rendered as an artifact card, never a bubble. */
|
||||
/** The knowledge-context card's one-liner: the numbers the observability
|
||||
* doctrine wants visible at the seam — tokens and proposition count
|
||||
* (parse-don't-cast; a malformed part still renders a quiet generic line). */
|
||||
function graphContextLine(message: { parts: ReadonlyArray<{ type: string; data?: unknown }> }): string {
|
||||
const data = graphContextOfMessage(message);
|
||||
return data
|
||||
? `Knowledge context — ~${formatTokens(data.tokens)} · ${data.propositions.length} propositions`
|
||||
: "Knowledge context";
|
||||
}
|
||||
|
||||
/** The quiet system line (custom-message scheme rendering): centered, muted,
|
||||
* small — the transcript's voice for events that are not speech (wake
|
||||
* scaffolding, the learner's plan verdict). */
|
||||
@@ -540,7 +551,14 @@ export function Chat({
|
||||
// PROTOCOL" §4). Key fallback: legacy transcripts (pre id-hygiene)
|
||||
// can still hold empty ids — the index keeps keys unique for them.
|
||||
<SubagentSteerCard key={m.id || `m-${mi}`} message={m} />
|
||||
) : m.role === "user" && m.parts.some((p) => p.type === "data-probe-handoff") ? (
|
||||
) : m.role === "user" && m.parts.some((p) => p.type === "data-graph-context") ? (
|
||||
// The prior-knowledge injection's delivery (vision-context-injection.md):
|
||||
// quiet system line, the numbers only — the block itself was for the
|
||||
// model (adapter flattening); the part data stays the audit trail.
|
||||
<SystemLine key={m.id || `m-${mi}`} testId="graph-context">
|
||||
{graphContextLine(m)}
|
||||
</SystemLine>
|
||||
) : m.role === "user" && m.parts.some((p) => p.type === "data-probe-handoff") ? (
|
||||
// The probe→teach handoff wake (custom-message scheme): quiet
|
||||
// system line, never a user bubble — the wake is scaffolding,
|
||||
// the "Planning" divider marks the seam, and the model-facing
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { BookOpenIcon } from "lucide-react";
|
||||
import {
|
||||
GRAPH_CONTEXT_TOKENS_DEFAULT,
|
||||
GRAPH_CONTEXT_TOKENS_MAX,
|
||||
GRAPH_CONTEXT_TOKENS_STEP,
|
||||
} from "@/src/core";
|
||||
import { formatTokens } from "@/components/usage-format";
|
||||
|
||||
/**
|
||||
* The knowledge-context budget control (vision-context-injection.md) — the
|
||||
* "session data" section's one input, shown where sessions START: the home
|
||||
* (graph) view's new-session dock and the /chat/new draft. Input before
|
||||
* output: the panel that will later show what the session contributed to
|
||||
* the graph asks first what the graph will contribute to the session.
|
||||
*
|
||||
* One quiet slider row: the token budget for the prior-knowledge block the
|
||||
* injection steers in at session start. 0 = off; absent settings show the
|
||||
* DEFAULT (1024 — on). The value rides ChatSettings (the same lifted
|
||||
* settings the model picker uses), so it persists pre-chat via the same
|
||||
* deferred PUT. Sessions never show this control — the injection is
|
||||
* one-shot at session start, so mid-session there is nothing to decide.
|
||||
*/
|
||||
export function GraphContextControl({
|
||||
value,
|
||||
onChange,
|
||||
testId = "graph-context-control",
|
||||
}: {
|
||||
value: number;
|
||||
onChange: (tokens: number) => void;
|
||||
testId?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-testid={testId}
|
||||
className="flex items-center gap-2 px-1 text-xxs text-muted-foreground"
|
||||
>
|
||||
<BookOpenIcon className="size-3 shrink-0" />
|
||||
<span className="shrink-0 uppercase tracking-widest">Knowledge context</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={GRAPH_CONTEXT_TOKENS_MAX}
|
||||
step={GRAPH_CONTEXT_TOKENS_STEP}
|
||||
value={value}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
aria-label="Prior-knowledge token budget"
|
||||
className="h-1 flex-1 accent-primary"
|
||||
data-testid="graph-context-slider"
|
||||
/>
|
||||
<span
|
||||
className="w-16 shrink-0 text-right tabular-nums"
|
||||
data-testid="graph-context-value"
|
||||
>
|
||||
{value === 0 ? "off" : `${formatTokens(value)} tokens`}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The control's effective value for a chat's settings — absent = the
|
||||
* default (on), never 0: the decided default is a modest slice of prior
|
||||
* knowledge for every fresh session. */
|
||||
export function effectiveContextTokens(settings: { graphContextTokens?: number } | undefined): number {
|
||||
return settings?.graphContextTokens ?? GRAPH_CONTEXT_TOKENS_DEFAULT;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { CornerDownLeftIcon } from "lucide-react";
|
||||
import { GraphView } from "@/components/graph-view";
|
||||
import { GraphContextControl, effectiveContextTokens } from "@/components/graph-context-control";
|
||||
import { SidebarToggle } from "@/components/sidebar";
|
||||
import { StatsBar } from "@/components/stats-bar";
|
||||
import type { ChatSettings, TurnUsage } from "@/src/core";
|
||||
@@ -71,6 +72,17 @@ export function Home() {
|
||||
px-4) so nothing shifts when a session starts (owner). */}
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 pb-5">
|
||||
<div className="pointer-events-auto mx-auto w-full max-w-[46rem] px-4">
|
||||
{/* The session-data strip (vision-context-injection.md): what the
|
||||
graph will contribute to the session, decided before the session
|
||||
starts. Rides the same lifted settings as the model picker. */}
|
||||
<div className="mb-1.5">
|
||||
<GraphContextControl
|
||||
value={effectiveContextTokens(settings)}
|
||||
onChange={(tokens) =>
|
||||
setSettings((s) => ({ ...s, graphContextTokens: tokens }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<form
|
||||
className="flex items-end gap-1.5 rounded-2xl border bg-card p-1.5 shadow-lg"
|
||||
onSubmit={(e) => {
|
||||
|
||||
@@ -245,7 +245,9 @@ export function ModelControl({
|
||||
effectiveLevel !== undefined && m.supportedLevels.includes(effectiveLevel)
|
||||
? effectiveLevel
|
||||
: undefined;
|
||||
void save({ model: { provider: m.provider, id: m.id }, thinkingLevel: nextLevel });
|
||||
// Replace-whole discipline: spread the current settings — a model pick
|
||||
// must not silently clear a sibling override (graphContextTokens).
|
||||
void save({ ...(settings ?? {}), model: { provider: m.provider, id: m.id }, thinkingLevel: nextLevel });
|
||||
}}
|
||||
className={`flex w-full flex-col gap-0.5 rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-muted ${
|
||||
m.id === effectiveId ? "bg-muted/60" : ""
|
||||
|
||||
@@ -4,6 +4,7 @@ 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";
|
||||
@@ -98,6 +99,16 @@ export function DraftShell({
|
||||
<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}
|
||||
|
||||
Reference in New Issue
Block a user