From 1079fbc079b2d38ab08b2203e7bffd56a158f035 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 19 May 2026 14:07:28 -0700 Subject: [PATCH 1/2] feat(c-threads): real SDK-backed threads + inline title generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace c-threads' hardcoded fake thread array with real LangGraph SDK calls. The threads sidenav now reflects actual server-side threads with LLM-generated titles instead of static placeholders. Backend (graph.py): adds inline generate_title node (Pattern D from spec 2026-05-19-llm-generated-labels-design.md) that writes a 3-5 word title to thread metadata on the first turn. Idempotent and error-swallowing — title is a UX nicety, never a blocker. Frontend (threads.component.ts): drops the hardcoded [{id:'thread-1',...}] array. Constructs a LangGraph SDK Client and calls client.threads.search() on init + after every agent turn, mapping metadata.thread_title → Thread.title with a UUID-slice fallback for brand-new threads. Verified end-to-end: programmatic SDK smoke confirms "What is the capital of Japan?" produces metadata.thread_title = "Capital of Japan", and threads.search returns it. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../angular/src/app/threads.component.ts | 79 ++++++++++++++++--- cockpit/chat/threads/python/src/graph.py | 76 +++++++++++++++--- 2 files changed, 136 insertions(+), 19 deletions(-) diff --git a/cockpit/chat/threads/angular/src/app/threads.component.ts b/cockpit/chat/threads/angular/src/app/threads.component.ts index 222c26502..768429178 100644 --- a/cockpit/chat/threads/angular/src/app/threads.component.ts +++ b/cockpit/chat/threads/angular/src/app/threads.component.ts @@ -1,13 +1,19 @@ // SPDX-License-Identifier: MIT -import { Component, signal } from '@angular/core'; +import { Component, effect, signal } from '@angular/core'; import { ChatComponent, ChatThreadListComponent, type Thread } from '@ngaf/chat'; import { agent } from '@ngaf/langgraph'; import { ExampleChatLayoutComponent } from '@ngaf/example-layouts'; +import { Client } from '@langchain/langgraph-sdk'; import { environment } from '../environments/environment'; /** * ThreadsComponent demonstrates multi-thread conversation management - * with ChatComponent and ChatThreadListComponent in a sidebar. + * backed by real LangGraph SDK thread metadata — NOT hardcoded fake + * threads. On init we fetch all threads via `client.threads.search()` + * and map `metadata.thread_title` (populated by the cap's + * `generate_title` graph node on each thread's first turn) to the + * sidenav's `Thread.title` field. Refreshes after every agent turn so + * newly-titled threads appear without a page reload. */ @Component({ selector: 'app-threads', @@ -15,34 +21,87 @@ import { environment } from '../environments/environment'; imports: [ChatComponent, ChatThreadListComponent, ExampleChatLayoutComponent], template: ` - +

Threads

`, }) export class ThreadsComponent { + /** Writable signal the agent watches — assigning to it switches the + * active thread without forcing a full agent rebuild. */ + protected readonly activeThreadId = signal(null); + protected readonly agent = agent({ apiUrl: environment.langGraphApiUrl, assistantId: environment.streamingAssistantId, + threadId: this.activeThreadId, }); - protected readonly threads = signal([ - { id: 'thread-1', title: 'First Conversation' }, - { id: 'thread-2', title: 'Second Conversation' }, - { id: 'thread-3', title: 'Third Conversation' }, - ]); + /** Loaded from LangGraph SDK; refreshed after every agent turn so new + * threads + LLM-generated titles appear without a page reload. */ + protected readonly threads = signal([]); + + /** LangGraph SDK client. Shares the same apiUrl the agent uses + * (via Angular's proxy.conf in dev / deployment URL in prod). */ + private readonly client = new Client({ apiUrl: environment.langGraphApiUrl }); + + constructor() { + // Initial fetch. + void this.refreshThreads(); - protected readonly activeThreadId = signal('thread-1'); + // Re-fetch whenever the agent stops loading (each turn completes). + // New threads get auto-titled by the graph's generate_title node; + // this surfaces them in the sidenav without a manual refresh. + let wasLoading = false; + effect(() => { + const loading = this.agent.isLoading(); + if (wasLoading && !loading) { + void this.refreshThreads(); + } + wasLoading = loading; + }); + } + + /** Fetch all threads from LangGraph, mapping `metadata.thread_title` + * (set by the cap's `generate_title` graph node) onto `Thread.title` + * for the sidenav. Falls back to a UUID-slice for brand-new threads + * whose first turn hasn't completed yet. */ + protected async refreshThreads(): Promise { + try { + const rows = await this.client.threads.search({ limit: 50 }); + const mapped: Thread[] = rows.map(t => ({ + id: t.thread_id, + title: + typeof (t.metadata as { thread_title?: unknown } | null)?.thread_title === 'string' + ? (t.metadata as { thread_title: string }).thread_title + : t.thread_id.slice(0, 8), + updatedAt: t.updated_at ? Date.parse(t.updated_at) : undefined, + })); + this.threads.set(mapped); + } catch (err) { + // Best-effort — don't crash the UI if the SDK call fails (e.g. + // dev server still booting). Sidenav stays empty until next refresh. + console.warn('[c-threads] threads.search failed', err); + } + } protected onThreadSelected(threadId: string): void { + // switchThread is the LangGraph adapter's canonical thread-switch API + // (resets derived state + reloads server messages for the new thread). + this.agent.switchThread(threadId); this.activeThreadId.set(threadId); } } diff --git a/cockpit/chat/threads/python/src/graph.py b/cockpit/chat/threads/python/src/graph.py index 0d0b03c2d..89dd7c8df 100644 --- a/cockpit/chat/threads/python/src/graph.py +++ b/cockpit/chat/threads/python/src/graph.py @@ -1,23 +1,79 @@ """ Chat Threads Graph -A standard conversational agent. Thread management (creating, switching, -persisting) is handled by the frontend and LangGraph SDK, not the graph itself. +Conversational agent with inline thread-title generation. Each new +thread gets an LLM-generated 3-5 word title written to LangGraph +thread metadata on the first turn (idempotent — subsequent turns skip +the write). The chat-threads frontend reads `metadata.thread_title` +from `client.threads.search()` and displays it in the sidenav. + +Pattern D from spec 2026-05-19-llm-generated-labels-design.md: the +generate_title node lives inline in this file (not extracted to a +shared helper) so a developer reading this cap sees the entire agent +in one place. """ +import os from pathlib import Path -from langgraph.graph import StateGraph, MessagesState, END +from langchain_core.messages import HumanMessage, SystemMessage from langchain_openai import ChatOpenAI -from langchain_core.messages import SystemMessage +from langgraph.graph import StateGraph, MessagesState, END +from langgraph_sdk import get_client PROMPTS_DIR = Path(__file__).parent.parent / "prompts" +# ── generate_title node (inline; matches Pattern D from spec +# 2026-05-19-llm-generated-labels-design.md) ────────────────────────────── -def build_threads_graph(): - """ - Constructs a standard conversational agent. - Threads are managed by the LangGraph SDK on the frontend side. +_TITLE_PROMPT = ( + "In 3-5 words, summarize what the user is asking about. " + "Output ONLY the title — no quotes, no period, no prefix." +) +_TITLE_MODEL = "gpt-5-mini" + + +async def generate_title(state: MessagesState, config) -> dict: + """Background title generation: on the first turn, summarize the user's + intent into 3-5 words and persist to LangGraph thread metadata so the + sidenav shows something meaningful instead of a UUID slice. + + Idempotent — skips when metadata.thread_title already exists. Errors + are swallowed (title is a UX nicety, never a blocker). Runs after the + user-visible turn so it never blocks the response. """ + thread_id = (config.get("configurable") or {}).get("thread_id") + if not thread_id: + return {} + sdk_url = os.environ.get("LANGGRAPH_API_URL", "http://localhost:2024") + try: + client = get_client(url=sdk_url) + thread = await client.threads.get(thread_id) + if (thread.get("metadata") or {}).get("thread_title"): + return {} + first_user = next( + (m for m in state["messages"] if getattr(m, "type", None) == "human"), + None, + ) + if not first_user or not isinstance(first_user.content, str): + return {} + # Skip action-message JSON (those flow as human-role too) + if first_user.content.lstrip().startswith("{"): + return {} + llm = ChatOpenAI(model=_TITLE_MODEL, temperature=0) + response = await llm.ainvoke([ + SystemMessage(content=_TITLE_PROMPT), + HumanMessage(content=first_user.content), + ]) + title = (response.content or "").strip().strip('"').strip("'")[:80] + if title: + await client.threads.update(thread_id, metadata={"thread_title": title}) + except Exception: # noqa: BLE001 — title is a UX nicety; never block + pass + return {} + + +def build_threads_graph(): + """Standard conversational agent + inline title gen on first turn.""" llm = ChatOpenAI(model="gpt-5-mini", streaming=True) async def generate(state: MessagesState) -> dict: @@ -28,8 +84,10 @@ async def generate(state: MessagesState) -> dict: graph = StateGraph(MessagesState) graph.add_node("generate", generate) + graph.add_node("generate_title", generate_title) graph.set_entry_point("generate") - graph.add_edge("generate", END) + graph.add_edge("generate", "generate_title") + graph.add_edge("generate_title", END) return graph.compile() From 9de18ddcfa8ae1d56621b6db2e55e744d99b323f Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 20 May 2026 08:33:12 -0700 Subject: [PATCH 2/2] refactor(c-threads): match demo's ThreadsService + ThreadActionAdapter pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror examples/chat/angular's shell/threads.service.ts wiring: dedicated injectable ThreadsService that owns the SDK Client and exposes refresh/create/delete/rename/archive, plus a ThreadActionAdapter on the component that hooks the framework's right-click menu into the service. Also fixes a latent bug: passing `apiUrl: '/api'` directly to the LangGraph SDK Client fails (SDK requires an absolute URL). Apply the same `window.location.origin + apiUrl` rewrite the streaming transport uses internally (libs/langgraph/src/lib/transport/fetch-stream.transport.ts). Refresh trigger switched from agent.isLoading() to agent.status() (the typed public API), matching demo-shell.component.ts. The service is a near-copy of the demo's because @ngaf/langgraph does not yet expose a shared LangGraphThreadsAdapter — see follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../angular/src/app/threads.component.ts | 113 ++++++++++-------- .../angular/src/app/threads.service.ts | 93 ++++++++++++++ 2 files changed, 154 insertions(+), 52 deletions(-) create mode 100644 cockpit/chat/threads/angular/src/app/threads.service.ts diff --git a/cockpit/chat/threads/angular/src/app/threads.component.ts b/cockpit/chat/threads/angular/src/app/threads.component.ts index 768429178..0b68bfc56 100644 --- a/cockpit/chat/threads/angular/src/app/threads.component.ts +++ b/cockpit/chat/threads/angular/src/app/threads.component.ts @@ -1,19 +1,22 @@ // SPDX-License-Identifier: MIT -import { Component, effect, signal } from '@angular/core'; -import { ChatComponent, ChatThreadListComponent, type Thread } from '@ngaf/chat'; +import { Component, effect, inject, signal } from '@angular/core'; +import { + ChatComponent, + ChatThreadListComponent, + type ThreadActionAdapter, +} from '@ngaf/chat'; import { agent } from '@ngaf/langgraph'; import { ExampleChatLayoutComponent } from '@ngaf/example-layouts'; -import { Client } from '@langchain/langgraph-sdk'; import { environment } from '../environments/environment'; +import { ThreadsService } from './threads.service'; /** * ThreadsComponent demonstrates multi-thread conversation management - * backed by real LangGraph SDK thread metadata — NOT hardcoded fake - * threads. On init we fetch all threads via `client.threads.search()` - * and map `metadata.thread_title` (populated by the cap's - * `generate_title` graph node on each thread's first turn) to the - * sidenav's `Thread.title` field. Refreshes after every agent turn so - * newly-titled threads appear without a page reload. + * backed by the real LangGraph SDK — mirrors the canonical demo's + * shell/threads.service.ts wiring pattern (rename / delete / archive + * action adapter + run-status refresh trigger). LLM-generated titles + * surface via `metadata.thread_title`, written by the cap's + * `generate_title` graph node on each thread's first turn. */ @Component({ selector: 'app-threads', @@ -23,23 +26,32 @@ import { environment } from '../environments/environment';
-

Threads

+
+

Threads

+ +
`, }) export class ThreadsComponent { + protected readonly threadsSvc = inject(ThreadsService); + /** Writable signal the agent watches — assigning to it switches the * active thread without forcing a full agent rebuild. */ protected readonly activeThreadId = signal(null); @@ -48,60 +60,57 @@ export class ThreadsComponent { apiUrl: environment.langGraphApiUrl, assistantId: environment.streamingAssistantId, threadId: this.activeThreadId, + // When the agent auto-creates a thread on first submit, the + // adapter calls back with its id; mirror that into our signal so + // the sidenav highlights it immediately. + onThreadId: (id: string) => this.activeThreadId.set(id), }); - /** Loaded from LangGraph SDK; refreshed after every agent turn so new - * threads + LLM-generated titles appear without a page reload. */ - protected readonly threads = signal([]); - - /** LangGraph SDK client. Shares the same apiUrl the agent uses - * (via Angular's proxy.conf in dev / deployment URL in prod). */ - private readonly client = new Client({ apiUrl: environment.langGraphApiUrl }); + /** Action adapter: framework calls these on rename / delete / archive + * after confirmation. Service handles SDK round-trip + refresh. */ + protected readonly threadActions: ThreadActionAdapter = { + delete: async (id) => { + await this.threadsSvc.delete(id); + if (this.activeThreadId() === id) this.activeThreadId.set(null); + }, + rename: (id, title) => this.threadsSvc.rename(id, title), + archive: async (id) => { + await this.threadsSvc.archive(id); + if (this.activeThreadId() === id) this.activeThreadId.set(null); + }, + unarchive: (id) => this.threadsSvc.unarchive(id), + }; constructor() { // Initial fetch. - void this.refreshThreads(); + void this.threadsSvc.refresh(); - // Re-fetch whenever the agent stops loading (each turn completes). - // New threads get auto-titled by the graph's generate_title node; - // this surfaces them in the sidenav without a manual refresh. - let wasLoading = false; + // Re-fetch when an agent run completes. The graph's generate_title + // node writes metadata.thread_title on the first turn; refreshing + // on the running→idle transition surfaces it in the sidenav + // without a manual reload. + let lastStatus = this.agent.status(); effect(() => { - const loading = this.agent.isLoading(); - if (wasLoading && !loading) { - void this.refreshThreads(); + const status = this.agent.status(); + if (lastStatus === 'running' && status !== 'running') { + void this.threadsSvc.refresh(); } - wasLoading = loading; + lastStatus = status; }); } - /** Fetch all threads from LangGraph, mapping `metadata.thread_title` - * (set by the cap's `generate_title` graph node) onto `Thread.title` - * for the sidenav. Falls back to a UUID-slice for brand-new threads - * whose first turn hasn't completed yet. */ - protected async refreshThreads(): Promise { - try { - const rows = await this.client.threads.search({ limit: 50 }); - const mapped: Thread[] = rows.map(t => ({ - id: t.thread_id, - title: - typeof (t.metadata as { thread_title?: unknown } | null)?.thread_title === 'string' - ? (t.metadata as { thread_title: string }).thread_title - : t.thread_id.slice(0, 8), - updatedAt: t.updated_at ? Date.parse(t.updated_at) : undefined, - })); - this.threads.set(mapped); - } catch (err) { - // Best-effort — don't crash the UI if the SDK call fails (e.g. - // dev server still booting). Sidenav stays empty until next refresh. - console.warn('[c-threads] threads.search failed', err); - } - } - protected onThreadSelected(threadId: string): void { // switchThread is the LangGraph adapter's canonical thread-switch API // (resets derived state + reloads server messages for the new thread). this.agent.switchThread(threadId); this.activeThreadId.set(threadId); } + + protected async onNewThread(): Promise { + const id = await this.threadsSvc.create(); + if (id) { + this.agent.switchThread(id); + this.activeThreadId.set(id); + } + } } diff --git a/cockpit/chat/threads/angular/src/app/threads.service.ts b/cockpit/chat/threads/angular/src/app/threads.service.ts new file mode 100644 index 000000000..362c4d818 --- /dev/null +++ b/cockpit/chat/threads/angular/src/app/threads.service.ts @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: MIT +import { Injectable, signal } from '@angular/core'; +import { Client, type Thread as SdkThread } from '@langchain/langgraph-sdk'; +import type { Thread } from '@ngaf/chat'; +import { environment } from '../environments/environment'; + +/** + * SDK-backed thread store for the c-threads cap. + * + * Mirrors the canonical demo's ThreadsService (examples/chat/angular/ + * src/app/shell/threads.service.ts) — the same pattern is duplicated + * across consumers because we don't yet expose a shared + * `LangGraphThreadsAdapter` from `@ngaf/langgraph`. See the DX notes + * in the PR description for the planned hoist. + * + * Reads `metadata.thread_title` (written by the cap's `generate_title` + * graph node — spec 2026-05-19-llm-generated-labels-design.md), not + * `metadata.title` like the demo. The two backends will be converged + * in a follow-up. + */ + +/** SDK requires an absolute URL; rewrite `/api`-style relative paths + * against `window.location.origin` (matches the streaming transport + * in fetch-stream.transport.ts). */ +function toAbsoluteApiUrl(apiUrl: string): string { + if (apiUrl.startsWith('http://') || apiUrl.startsWith('https://')) return apiUrl; + return typeof window !== 'undefined' ? `${window.location.origin}${apiUrl}` : apiUrl; +} + +@Injectable({ providedIn: 'root' }) +export class ThreadsService { + private readonly client = new Client({ apiUrl: toAbsoluteApiUrl(environment.langGraphApiUrl) }); + + readonly threads = signal([]); + readonly archivedThreads = signal([]); + + async refresh(): Promise { + try { + const list = await this.client.threads.search({ limit: 50 }); + const mapped = list.map((t) => this.toThread(t)); + this.threads.set(mapped.filter((t) => t.status !== 'archived')); + this.archivedThreads.set(mapped.filter((t) => t.status === 'archived')); + } catch { + // Backend may be down; leave signals as-is. + } + } + + async create(): Promise { + try { + const t = await this.client.threads.create(); + await this.refresh(); + return t.thread_id; + } catch { + return null; + } + } + + async delete(threadId: string): Promise { + await this.client.threads.delete(threadId); + await this.refresh(); + } + + async rename(threadId: string, newTitle: string): Promise { + await this.client.threads.update(threadId, { metadata: { thread_title: newTitle } }); + await this.refresh(); + } + + async archive(threadId: string): Promise { + await this.client.threads.update(threadId, { metadata: { archived: true } }); + await this.refresh(); + } + + async unarchive(threadId: string): Promise { + await this.client.threads.update(threadId, { metadata: { archived: false } }); + await this.refresh(); + } + + /** Best-effort title from thread metadata. Falls back to "Untitled" + * for brand-new threads where the generate_title node hasn't run + * yet (matches the demo's convention — easier on the eye than a + * UUID slice). */ + private toThread(t: SdkThread): Thread { + const meta = (t.metadata ?? {}) as { thread_title?: unknown; archived?: unknown }; + const title = meta.thread_title; + const archived = meta.archived === true; + return { + id: t.thread_id, + title: typeof title === 'string' && title.length > 0 ? title : 'Untitled', + status: archived ? 'archived' : 'active', + updatedAt: t.updated_at ? Date.parse(t.updated_at) : undefined, + }; + } +}