diff --git a/apps/website/content/docs/agent/api/api-docs.json b/apps/website/content/docs/agent/api/api-docs.json index 4e95aea37..39d960c6c 100644 --- a/apps/website/content/docs/agent/api/api-docs.json +++ b/apps/website/content/docs/agent/api/api-docs.json @@ -234,6 +234,172 @@ } ] }, + { + "name": "LangGraphThreadsAdapter", + "kind": "class", + "description": "SDK-backed thread store. Wraps `client.threads.*` and maps SDK\nthreads to the framework's Thread type for direct use with\n`` / ``.\n\nConsumers wire the framework's `ThreadActionAdapter` to instance\nmethods (rename/delete/archive/pin/...) so the right-click menu\nround-trips through the LangGraph SDK without per-app boilerplate.", + "params": [], + "examples": [ + "```ts\nconst svc = inject(LangGraphThreadsAdapter);\nconst actions: ThreadActionAdapter = {\n rename: (id, t) => svc.rename(id, t),\n delete: (id) => svc.delete(id),\n};\n```" + ], + "properties": [ + { + "name": "archivedThreads", + "type": "Signal", + "description": "Threads whose `metadata.archived === true`.", + "optional": false + }, + { + "name": "threads", + "type": "Signal", + "description": "Active (non-archived) threads, sorted with pinned first.", + "optional": false + } + ], + "methods": [ + { + "name": "archive", + "signature": "archive(threadId: string)", + "description": "", + "params": [ + { + "name": "threadId", + "type": "string", + "description": "", + "optional": false + } + ] + }, + { + "name": "create", + "signature": "create(metadata: Record)", + "description": "", + "params": [ + { + "name": "metadata", + "type": "Record", + "description": "", + "optional": false + } + ] + }, + { + "name": "delete", + "signature": "delete(threadId: string)", + "description": "", + "params": [ + { + "name": "threadId", + "type": "string", + "description": "", + "optional": false + } + ] + }, + { + "name": "moveToProject", + "signature": "moveToProject(threadId: string, projectId: string | null)", + "description": "", + "params": [ + { + "name": "threadId", + "type": "string", + "description": "", + "optional": false + }, + { + "name": "projectId", + "type": "string | null", + "description": "", + "optional": false + } + ] + }, + { + "name": "pin", + "signature": "pin(threadId: string)", + "description": "", + "params": [ + { + "name": "threadId", + "type": "string", + "description": "", + "optional": false + } + ] + }, + { + "name": "refresh", + "signature": "refresh()", + "description": "Fetch the latest thread list from the server. Failures are\n logged via `console.error` (not swallowed silently — silent\n catches have masked prod issues in the past).", + "params": [] + }, + { + "name": "rename", + "signature": "rename(threadId: string, newTitle: string)", + "description": "", + "params": [ + { + "name": "threadId", + "type": "string", + "description": "", + "optional": false + }, + { + "name": "newTitle", + "type": "string", + "description": "", + "optional": false + } + ] + }, + { + "name": "reorderPinned", + "signature": "reorderPinned(threadId: string, beforeId: string | null)", + "description": "Re-stamp `metadata.pinnedOrder = 0,1,2,...` for the pinned slice\n to reflect the new ordering.", + "params": [ + { + "name": "threadId", + "type": "string", + "description": "", + "optional": false + }, + { + "name": "beforeId", + "type": "string | null", + "description": "", + "optional": false + } + ] + }, + { + "name": "unarchive", + "signature": "unarchive(threadId: string)", + "description": "", + "params": [ + { + "name": "threadId", + "type": "string", + "description": "", + "optional": false + } + ] + }, + { + "name": "unpin", + "signature": "unpin(threadId: string)", + "description": "", + "params": [ + { + "name": "threadId", + "type": "string", + "description": "", + "optional": false + } + ] + } + ] + }, { "name": "MockAgentTransport", "kind": "class", @@ -1265,6 +1431,32 @@ ], "examples": [] }, + { + "name": "LangGraphThreadsConfig", + "kind": "interface", + "description": "Configuration consumed by LangGraphThreadsAdapter. Provide\nvia LANGGRAPH_THREADS_CONFIG (typically in app.config.ts):\n\n```ts\nproviders: [\n { provide: LANGGRAPH_THREADS_CONFIG, useValue: {\n apiUrl: environment.langGraphApiUrl,\n titleMetadataKey: 'thread_title',\n }},\n],\n```", + "properties": [ + { + "name": "apiUrl", + "type": "string", + "description": "Base URL for the LangGraph Platform API. Accepts both absolute\n URLs and relative `/api`-style paths.", + "optional": false + }, + { + "name": "titleFallback", + "type": "string", + "description": "Fallback label for threads whose title hasn't been written yet\n (e.g. created but never sent). Defaults to `'Untitled'`.", + "optional": true + }, + { + "name": "titleMetadataKey", + "type": "string", + "description": "Metadata key the backend writes the thread title to. Two\n conventions exist in the wild:\n - `'title'` — legacy / canonical demo\n - `'thread_title'` — spec 2026-05-19-llm-generated-labels-design\n Defaults to `'thread_title'`.", + "optional": true + } + ], + "examples": [] + }, { "name": "MockLangGraphAgent", "kind": "interface", @@ -1786,6 +1978,27 @@ "```typescript\n// In a component field initializer\nconst chat = agent({\n assistantId: 'chat_agent',\n apiUrl: 'http://localhost:2024',\n threadId: signal(this.savedThreadId),\n onThreadId: (id) => localStorage.setItem('threadId', id),\n});\n\n// Access signals in template\n// chat.messages(), chat.status(), chat.error()\n```" ] }, + { + "name": "createLangGraphClient", + "kind": "function", + "description": "Construct a LangGraph SDK Client that accepts both absolute URLs\n(`http://localhost:2024`) and relative `/api`-style paths that get\nproxied by middleware in production. The SDK itself rejects\nrelative URLs, so this helper rewrites them against\n`window.location.origin` when running in the browser.\n\nSingle source of truth for the absolute-URL rewrite — the streaming\ntransport (`fetch-stream.transport.ts`) and the threads adapter\n(`LangGraphThreadsAdapter`) both go through here.", + "signature": "createLangGraphClient(apiUrl: string): Client<>", + "params": [ + { + "name": "apiUrl", + "type": "string", + "description": "", + "optional": false + } + ], + "returns": { + "type": "Client<>", + "description": "" + }, + "examples": [ + "```ts\nconst client = createLangGraphClient(environment.langGraphApiUrl);\nconst threads = await client.threads.search({ limit: 50 });\n```" + ] + }, { "name": "extractCitations", "kind": "function", @@ -1842,5 +2055,82 @@ "description": "" }, "examples": [] + }, + { + "name": "refreshOnRunEnd", + "kind": "function", + "description": "Call `fn` whenever the agent's status transitions out of `'running'`\n(i.e. when a run completes — success, error, or interrupt). Useful\nfor refreshing thread lists, telemetry, or any other state that\nlags the agent.\n\nMust be called within an injection context (constructor or\n`runInInjectionContext`) — uses Angular's `effect` under the hood.", + "signature": "refreshOnRunEnd(agent: LangGraphAgent<>, fn: object): void", + "params": [ + { + "name": "agent", + "type": "LangGraphAgent<>", + "description": "", + "optional": false + }, + { + "name": "fn", + "type": "object", + "description": "", + "optional": false + } + ], + "returns": { + "type": "void", + "description": "" + }, + "examples": [ + "```ts\nconstructor() {\n refreshOnRunEnd(this.agent, () => this.threads.refresh());\n}\n```" + ] + }, + { + "name": "refreshOnTransition", + "kind": "function", + "description": "Call `fn` whenever any of the watched signals transitions from a\ntruthy \"active\" value to a non-active value. Generic version of\nrefreshOnRunEnd for callers tracking custom state machines.\n\nMust be called within an injection context.", + "signature": "refreshOnTransition(watch: Signal, isActive: object, fn: object): void", + "params": [ + { + "name": "watch", + "type": "Signal", + "description": "", + "optional": false + }, + { + "name": "isActive", + "type": "object", + "description": "", + "optional": false + }, + { + "name": "fn", + "type": "object", + "description": "", + "optional": false + } + ], + "returns": { + "type": "void", + "description": "" + }, + "examples": [] + }, + { + "name": "toAbsoluteApiUrl", + "kind": "function", + "description": "Exported separately so non-Client callers (e.g. raw fetch) can\n share the same normalization logic.", + "signature": "toAbsoluteApiUrl(apiUrl: string): string", + "params": [ + { + "name": "apiUrl", + "type": "string", + "description": "", + "optional": false + } + ], + "returns": { + "type": "string", + "description": "" + }, + "examples": [] } ] \ No newline at end of file diff --git a/cockpit/chat/threads/angular/src/app/app.config.ts b/cockpit/chat/threads/angular/src/app/app.config.ts index be14b0243..6995bdf9c 100644 --- a/cockpit/chat/threads/angular/src/app/app.config.ts +++ b/cockpit/chat/threads/angular/src/app/app.config.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: MIT import { ApplicationConfig } from '@angular/core'; -import { provideAgent } from '@ngaf/langgraph'; +import { provideAgent, LANGGRAPH_THREADS_CONFIG } from '@ngaf/langgraph'; import { provideChat } from '@ngaf/chat'; import { environment } from '../environments/environment'; @@ -8,5 +8,14 @@ export const appConfig: ApplicationConfig = { providers: [ provideAgent({ apiUrl: environment.langGraphApiUrl }), provideChat({}), + // c-threads' Python graph writes the LLM-generated title to + // metadata.thread_title (per spec 2026-05-19-llm-generated-labels-design). + { + provide: LANGGRAPH_THREADS_CONFIG, + useValue: { + apiUrl: environment.langGraphApiUrl, + titleMetadataKey: 'thread_title', + }, + }, ], }; diff --git a/cockpit/chat/threads/angular/src/app/threads.component.ts b/cockpit/chat/threads/angular/src/app/threads.component.ts index 0b68bfc56..0a57bec87 100644 --- a/cockpit/chat/threads/angular/src/app/threads.component.ts +++ b/cockpit/chat/threads/angular/src/app/threads.component.ts @@ -1,22 +1,22 @@ // SPDX-License-Identifier: MIT -import { Component, effect, inject, signal } from '@angular/core'; +import { Component, inject, signal } from '@angular/core'; import { ChatComponent, ChatThreadListComponent, type ThreadActionAdapter, } from '@ngaf/chat'; -import { agent } from '@ngaf/langgraph'; +import { agent, LangGraphThreadsAdapter, refreshOnRunEnd } from '@ngaf/langgraph'; import { ExampleChatLayoutComponent } from '@ngaf/example-layouts'; import { environment } from '../environments/environment'; -import { ThreadsService } from './threads.service'; /** * ThreadsComponent demonstrates multi-thread conversation management - * 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. + * backed by the real LangGraph SDK. Consumes the shared + * LangGraphThreadsAdapter from `@ngaf/langgraph` — same service the + * canonical demo uses — configured for the `metadata.thread_title` + * key that this cap's `generate_title` graph node writes (spec + * 2026-05-19-llm-generated-labels-design). See app.config.ts for the + * LANGGRAPH_THREADS_CONFIG provider. */ @Component({ selector: 'app-threads', @@ -50,7 +50,7 @@ import { ThreadsService } from './threads.service'; `, }) export class ThreadsComponent { - protected readonly threadsSvc = inject(ThreadsService); + protected readonly threadsSvc = inject(LangGraphThreadsAdapter); /** Writable signal the agent watches — assigning to it switches the * active thread without forcing a full agent rebuild. */ @@ -67,7 +67,7 @@ export class ThreadsComponent { }); /** Action adapter: framework calls these on rename / delete / archive - * after confirmation. Service handles SDK round-trip + refresh. */ + * after confirmation. Adapter handles SDK round-trip + refresh. */ protected readonly threadActions: ThreadActionAdapter = { delete: async (id) => { await this.threadsSvc.delete(id); @@ -89,14 +89,7 @@ export class ThreadsComponent { // 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 status = this.agent.status(); - if (lastStatus === 'running' && status !== 'running') { - void this.threadsSvc.refresh(); - } - lastStatus = status; - }); + refreshOnRunEnd(this.agent, () => this.threadsSvc.refresh()); } protected onThreadSelected(threadId: string): void { diff --git a/cockpit/chat/threads/angular/src/app/threads.service.ts b/cockpit/chat/threads/angular/src/app/threads.service.ts deleted file mode 100644 index 362c4d818..000000000 --- a/cockpit/chat/threads/angular/src/app/threads.service.ts +++ /dev/null @@ -1,93 +0,0 @@ -// 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, - }; - } -} diff --git a/examples/chat/angular/src/app/app.config.ts b/examples/chat/angular/src/app/app.config.ts index aad34b5e9..88080f779 100644 --- a/examples/chat/angular/src/app/app.config.ts +++ b/examples/chat/angular/src/app/app.config.ts @@ -2,6 +2,7 @@ import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZonelessChangeDetection } from '@angular/core'; import { provideRouter, withComponentInputBinding } from '@angular/router'; import { provideNgafTelemetry } from '@ngaf/telemetry/browser'; +import { LANGGRAPH_THREADS_CONFIG } from '@ngaf/langgraph'; import { routes } from './app.routes'; import { environment } from '../environments/environment'; @@ -11,5 +12,15 @@ export const appConfig: ApplicationConfig = { provideZonelessChangeDetection(), provideRouter(routes, withComponentInputBinding()), provideNgafTelemetry(environment.telemetry), + // Configure the shared LangGraphThreadsAdapter. The canonical + // demo's Python graph writes the title to `metadata.title` (the + // legacy spelling — c-threads writes `metadata.thread_title`). + { + provide: LANGGRAPH_THREADS_CONFIG, + useValue: { + apiUrl: environment.langGraphApiUrl, + titleMetadataKey: 'title', + }, + }, ], }; diff --git a/examples/chat/angular/src/app/shell/demo-shell.component.ts b/examples/chat/angular/src/app/shell/demo-shell.component.ts index 75c703ddb..27ed3b4bc 100644 --- a/examples/chat/angular/src/app/shell/demo-shell.component.ts +++ b/examples/chat/angular/src/app/shell/demo-shell.component.ts @@ -12,7 +12,7 @@ import { import { Router, RouterOutlet, NavigationEnd } from '@angular/router'; import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { filter, map, startWith } from 'rxjs/operators'; -import { agent } from '@ngaf/langgraph'; +import { agent, LangGraphThreadsAdapter, refreshOnRunEnd } from '@ngaf/langgraph'; import { NgafTelemetryService } from '@ngaf/telemetry/browser'; import { ChatInterruptPanelComponent, @@ -29,7 +29,6 @@ import { type ProjectActionAdapter, } from '@ngaf/chat'; import { PalettePersistence } from './palette-persistence.service'; -import { ThreadsService } from './threads.service'; import { ProjectsService } from './projects.service'; import { DEMO_AGENT } from './shell-tokens'; import { createCanonicalDemoRuntimeTelemetrySink } from './runtime-telemetry'; @@ -68,7 +67,7 @@ export class DemoShell { private readonly router = inject(Router); private readonly persistence = inject(PalettePersistence); private readonly document = inject(DOCUMENT); - protected readonly threadsSvc = inject(ThreadsService); + protected readonly threadsSvc = inject(LangGraphThreadsAdapter); protected readonly projectsSvc = inject(ProjectsService); private readonly telemetry = inject(NgafTelemetryService); @@ -114,14 +113,7 @@ export class DemoShell { // metadata.title on the first user message via _maybe_write_thread_title; // a refresh after run-end picks up the new title in the drawer without // needing a manual thread switch or reload. - let lastStatus = this.agent.status(); - effect(() => { - const status = this.agent.status(); - if (lastStatus === 'running' && status !== 'running') { - void this.threadsSvc.refresh(); - } - lastStatus = status; - }); + refreshOnRunEnd(this.agent, () => this.threadsSvc.refresh()); if (typeof window !== 'undefined') { const onResize = () => this.viewportWidth.set(window.innerWidth); @@ -438,7 +430,7 @@ export class DemoShell { /** Create a new thread via the backend and switch to it. */ protected async onNewThread(): Promise { const sel = this.selectedProjectId(); - const id = await this.threadsSvc.create(sel ?? undefined); + const id = await this.threadsSvc.create(sel ? { projectId: sel } : {}); if (id) { this.threadIdSignal.set(id); this.persistence.write('threadId', id); diff --git a/examples/chat/angular/src/app/shell/threads.service.ts b/examples/chat/angular/src/app/shell/threads.service.ts deleted file mode 100644 index 15311bfd0..000000000 --- a/examples/chat/angular/src/app/shell/threads.service.ts +++ /dev/null @@ -1,138 +0,0 @@ -// 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'; - -const API_URL = environment.langGraphApiUrl; - -@Injectable({ providedIn: 'root' }) -export class ThreadsService { - private readonly client = new Client({ apiUrl: API_URL }); - - 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') - .sort((a, b) => { - const aPinned = a.pinned === true; - const bPinned = b.pinned === true; - if (aPinned !== bPinned) return Number(bPinned) - Number(aPinned); - if (aPinned && bPinned) { - const aOrd = typeof a.pinnedOrder === 'number' ? a.pinnedOrder : Infinity; - const bOrd = typeof b.pinnedOrder === 'number' ? b.pinnedOrder : Infinity; - return aOrd - bOrd; - } - return 0; - }), - ); - this.archivedThreads.set(mapped.filter((t) => t.status === 'archived')); - } catch { - // Backend may be down; leave signals as-is. - } - } - - async create(projectId?: string): Promise { - try { - const t = await this.client.threads.create({ - metadata: projectId !== undefined ? { projectId } : {}, - }); - 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: { 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(); - } - - async moveToProject(threadId: string, projectId: string | null): Promise { - await this.client.threads.update(threadId, { metadata: { projectId } }); - await this.refresh(); - } - - async pin(threadId: string): Promise { - await this.client.threads.update(threadId, { metadata: { pinned: true } }); - await this.refresh(); - } - - async unpin(threadId: string): Promise { - await this.client.threads.update(threadId, { metadata: { pinned: false } }); - await this.refresh(); - } - - async reorderPinned(threadId: string, beforeId: string | null): Promise { - const current = this.threads().filter((t) => t.pinned === true); - const moved = current.find((t) => t.id === threadId); - if (!moved) return; - const rest = current.filter((t) => t.id !== threadId); - const next: Thread[] = []; - for (const t of rest) { - if (t.id === beforeId) next.push(moved); - next.push(t); - } - if (beforeId === null) next.push(moved); - - // Re-stamp metadata.pinnedOrder = 0,1,2,... in the desired order. - await Promise.all( - next.map((t, idx) => - this.client.threads.update(t.id, { metadata: { pinnedOrder: idx } }), - ), - ); - await this.refresh(); - } - - /** Best-effort title from thread metadata. - * - * The backend writes `metadata.title` from the first user message in a - * thread (see `_maybe_write_thread_title` in the Python graph). Threads - * created but never sent (e.g. via "+ New chat" then abandoned) have - * no title, so we fall back to "Untitled" — easier on the eye than - * the raw `Thread 019e1e98` id prefix, and consistent with how other - * chat apps surface drafts. - */ - private toThread(t: SdkThread): Thread { - const meta = (t.metadata ?? {}) as { title?: unknown; archived?: unknown; pinned?: unknown; projectId?: unknown; pinnedOrder?: unknown }; - const customTitle = meta.title; - const archived = meta.archived === true; - const pinned = meta.pinned === true; - const projectId = typeof meta.projectId === 'string' && meta.projectId.length > 0 - ? meta.projectId - : null; - const pinnedOrder = typeof meta.pinnedOrder === 'number' ? meta.pinnedOrder : undefined; - return { - id: t.thread_id, - title: typeof customTitle === 'string' && customTitle.length > 0 - ? customTitle - : 'Untitled', - status: archived ? 'archived' : 'active', - pinned, - projectId, - pinnedOrder, - }; - } -} diff --git a/libs/langgraph/src/lib/client/create-langgraph-client.ts b/libs/langgraph/src/lib/client/create-langgraph-client.ts new file mode 100644 index 000000000..34e8e8664 --- /dev/null +++ b/libs/langgraph/src/lib/client/create-langgraph-client.ts @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MIT +import { Client } from '@langchain/langgraph-sdk'; + +/** + * Construct a LangGraph SDK Client that accepts both absolute URLs + * (`http://localhost:2024`) and relative `/api`-style paths that get + * proxied by middleware in production. The SDK itself rejects + * relative URLs, so this helper rewrites them against + * `window.location.origin` when running in the browser. + * + * Single source of truth for the absolute-URL rewrite — the streaming + * transport (`fetch-stream.transport.ts`) and the threads adapter + * (`LangGraphThreadsAdapter`) both go through here. + * + * @example + * ```ts + * const client = createLangGraphClient(environment.langGraphApiUrl); + * const threads = await client.threads.search({ limit: 50 }); + * ``` + */ +export function createLangGraphClient(apiUrl: string): Client { + return new Client({ apiUrl: toAbsoluteApiUrl(apiUrl) }); +} + +/** Exported separately so non-Client callers (e.g. raw fetch) can + * share the same normalization logic. */ +export function toAbsoluteApiUrl(apiUrl: string): string { + if (apiUrl.startsWith('http://') || apiUrl.startsWith('https://')) return apiUrl; + return typeof window !== 'undefined' ? `${window.location.origin}${apiUrl}` : apiUrl; +} diff --git a/libs/langgraph/src/lib/threads/refresh-on.ts b/libs/langgraph/src/lib/threads/refresh-on.ts new file mode 100644 index 000000000..519d9a7a3 --- /dev/null +++ b/libs/langgraph/src/lib/threads/refresh-on.ts @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: MIT +import { effect, type Signal } from '@angular/core'; +import type { LangGraphAgent } from '../agent.types'; + +/** + * Call `fn` whenever the agent's status transitions out of `'running'` + * (i.e. when a run completes — success, error, or interrupt). Useful + * for refreshing thread lists, telemetry, or any other state that + * lags the agent. + * + * Must be called within an injection context (constructor or + * `runInInjectionContext`) — uses Angular's `effect` under the hood. + * + * @example + * ```ts + * constructor() { + * refreshOnRunEnd(this.agent, () => this.threads.refresh()); + * } + * ``` + */ +export function refreshOnRunEnd(agent: LangGraphAgent, fn: () => void | Promise): void { + let lastStatus = agent.status(); + effect(() => { + const status = agent.status(); + if (lastStatus === 'running' && status !== 'running') { + void fn(); + } + lastStatus = status; + }); +} + +/** + * Call `fn` whenever any of the watched signals transitions from a + * truthy "active" value to a non-active value. Generic version of + * {@link refreshOnRunEnd} for callers tracking custom state machines. + * + * Must be called within an injection context. + */ +export function refreshOnTransition( + watch: Signal, + isActive: (v: T) => boolean, + fn: () => void | Promise, +): void { + let lastActive = isActive(watch()); + effect(() => { + const active = isActive(watch()); + if (lastActive && !active) void fn(); + lastActive = active; + }); +} diff --git a/libs/langgraph/src/lib/threads/threads-adapter.spec.ts b/libs/langgraph/src/lib/threads/threads-adapter.spec.ts new file mode 100644 index 000000000..46c465327 --- /dev/null +++ b/libs/langgraph/src/lib/threads/threads-adapter.spec.ts @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: MIT +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { + LangGraphThreadsAdapter, + LANGGRAPH_THREADS_CONFIG, + LANGGRAPH_CLIENT, +} from './threads-adapter'; +import type { Client } from '@langchain/langgraph-sdk'; + +function mockClient(searchReturn: unknown[] = []): { + client: Client; + search: ReturnType; + update: ReturnType; + del: ReturnType; + create: ReturnType; +} { + const search = vi.fn().mockResolvedValue(searchReturn); + const update = vi.fn().mockResolvedValue(undefined); + const del = vi.fn().mockResolvedValue(undefined); + const create = vi.fn().mockResolvedValue({ thread_id: 'new-thread' }); + return { + client: { threads: { search, update, delete: del, create } } as unknown as Client, + search, update, del, create, + }; +} + +function configure(client: Client, titleKey = 'thread_title'): LangGraphThreadsAdapter { + TestBed.configureTestingModule({ + providers: [ + { provide: LANGGRAPH_THREADS_CONFIG, useValue: { apiUrl: 'http://x', titleMetadataKey: titleKey } }, + { provide: LANGGRAPH_CLIENT, useValue: client }, + ], + }); + return TestBed.inject(LangGraphThreadsAdapter); +} + +describe('LangGraphThreadsAdapter', () => { + beforeEach(() => TestBed.resetTestingModule()); + + it('maps SDK threads through the configured title metadata key', async () => { + const { client } = mockClient([ + { + thread_id: 't1', + updated_at: '2026-05-20T00:00:00Z', + metadata: { thread_title: 'Capital of Japan' }, + }, + ]); + const svc = configure(client); + await svc.refresh(); + expect(svc.threads()).toEqual([ + expect.objectContaining({ id: 't1', title: 'Capital of Japan', status: 'active', pinned: false }), + ]); + }); + + it('honours an alternate title key (demo writes metadata.title)', async () => { + const { client } = mockClient([ + { thread_id: 't1', metadata: { title: 'Hello' } }, + ]); + const svc = configure(client, 'title'); + await svc.refresh(); + expect(svc.threads()[0].title).toBe('Hello'); + }); + + it('falls back to "Untitled" when title metadata is missing', async () => { + const { client } = mockClient([{ thread_id: 't1', metadata: {} }]); + const svc = configure(client); + await svc.refresh(); + expect(svc.threads()[0].title).toBe('Untitled'); + }); + + it('partitions archived threads into archivedThreads()', async () => { + const { client } = mockClient([ + { thread_id: 'a', metadata: {} }, + { thread_id: 'b', metadata: { archived: true } }, + ]); + const svc = configure(client); + await svc.refresh(); + expect(svc.threads().map(t => t.id)).toEqual(['a']); + expect(svc.archivedThreads().map(t => t.id)).toEqual(['b']); + }); + + it('sorts pinned threads first (with pinnedOrder secondary sort)', async () => { + const { client } = mockClient([ + { thread_id: 'unp', metadata: {} }, + { thread_id: 'p2', metadata: { pinned: true, pinnedOrder: 1 } }, + { thread_id: 'p1', metadata: { pinned: true, pinnedOrder: 0 } }, + ]); + const svc = configure(client); + await svc.refresh(); + expect(svc.threads().map(t => t.id)).toEqual(['p1', 'p2', 'unp']); + }); + + it('rename() writes the configured title key', async () => { + const m = mockClient(); + const svc = configure(m.client, 'thread_title'); + await svc.rename('t1', 'New title'); + expect(m.update).toHaveBeenCalledWith('t1', { metadata: { thread_title: 'New title' } }); + }); + + it('logs but does not throw when refresh() fails', async () => { + const search = vi.fn().mockRejectedValue(new Error('boom')); + const client = { threads: { search } } as unknown as Client; + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const svc = configure(client); + await expect(svc.refresh()).resolves.toBeUndefined(); + expect(errSpy).toHaveBeenCalledWith( + '[LangGraphThreadsAdapter.refresh] failed:', + expect.objectContaining({ message: 'boom' }), + ); + errSpy.mockRestore(); + }); +}); diff --git a/libs/langgraph/src/lib/threads/threads-adapter.ts b/libs/langgraph/src/lib/threads/threads-adapter.ts new file mode 100644 index 000000000..d96a95f29 --- /dev/null +++ b/libs/langgraph/src/lib/threads/threads-adapter.ts @@ -0,0 +1,194 @@ +// SPDX-License-Identifier: MIT +import { Injectable, InjectionToken, inject, signal, type Signal, type WritableSignal } from '@angular/core'; +import type { Client, Thread as SdkThread } from '@langchain/langgraph-sdk'; +import type { Thread } from '@ngaf/chat'; +import { createLangGraphClient } from '../client/create-langgraph-client'; + +/** + * Configuration consumed by {@link LangGraphThreadsAdapter}. Provide + * via {@link LANGGRAPH_THREADS_CONFIG} (typically in app.config.ts): + * + * ```ts + * providers: [ + * { provide: LANGGRAPH_THREADS_CONFIG, useValue: { + * apiUrl: environment.langGraphApiUrl, + * titleMetadataKey: 'thread_title', + * }}, + * ], + * ``` + */ +export interface LangGraphThreadsConfig { + /** Base URL for the LangGraph Platform API. Accepts both absolute + * URLs and relative `/api`-style paths. */ + apiUrl: string; + /** Metadata key the backend writes the thread title to. Two + * conventions exist in the wild: + * - `'title'` — legacy / canonical demo + * - `'thread_title'` — spec 2026-05-19-llm-generated-labels-design + * Defaults to `'thread_title'`. */ + titleMetadataKey?: string; + /** Fallback label for threads whose title hasn't been written yet + * (e.g. created but never sent). Defaults to `'Untitled'`. */ + titleFallback?: string; +} + +export const LANGGRAPH_THREADS_CONFIG = new InjectionToken( + 'LANGGRAPH_THREADS_CONFIG', +); + +/** Optional adapter clients can pass an explicit Client (e.g. for + * testing). When omitted, the adapter constructs one via + * {@link createLangGraphClient}. */ +export const LANGGRAPH_CLIENT = new InjectionToken('LANGGRAPH_CLIENT'); + +/** + * SDK-backed thread store. Wraps `client.threads.*` and maps SDK + * threads to the framework's {@link Thread} type for direct use with + * `` / ``. + * + * Consumers wire the framework's `ThreadActionAdapter` to instance + * methods (rename/delete/archive/pin/...) so the right-click menu + * round-trips through the LangGraph SDK without per-app boilerplate. + * + * @example + * ```ts + * const svc = inject(LangGraphThreadsAdapter); + * const actions: ThreadActionAdapter = { + * rename: (id, t) => svc.rename(id, t), + * delete: (id) => svc.delete(id), + * }; + * ``` + */ +@Injectable({ providedIn: 'root' }) +export class LangGraphThreadsAdapter { + private readonly config = inject(LANGGRAPH_THREADS_CONFIG); + private readonly client: Client = inject(LANGGRAPH_CLIENT, { optional: true }) + ?? createLangGraphClient(this.config.apiUrl); + + private readonly titleKey: string = this.config.titleMetadataKey ?? 'thread_title'; + private readonly fallback: string = this.config.titleFallback ?? 'Untitled'; + + private readonly _threads: WritableSignal = signal([]); + private readonly _archived: WritableSignal = signal([]); + + /** Active (non-archived) threads, sorted with pinned first. */ + readonly threads: Signal = this._threads.asReadonly(); + /** Threads whose `metadata.archived === true`. */ + readonly archivedThreads: Signal = this._archived.asReadonly(); + + /** Fetch the latest thread list from the server. Failures are + * logged via `console.error` (not swallowed silently — silent + * catches have masked prod issues in the past). */ + 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') + .sort((a, b) => { + const aP = a.pinned === true; + const bP = b.pinned === true; + if (aP !== bP) return Number(bP) - Number(aP); + if (aP && bP) { + const aO = typeof a['pinnedOrder'] === 'number' ? (a['pinnedOrder'] as number) : Infinity; + const bO = typeof b['pinnedOrder'] === 'number' ? (b['pinnedOrder'] as number) : Infinity; + return aO - bO; + } + return 0; + }), + ); + this._archived.set(mapped.filter((t) => t.status === 'archived')); + } catch (e) { + console.error('[LangGraphThreadsAdapter.refresh] failed:', e); + } + } + + async create(metadata: Record = {}): Promise { + try { + const t = await this.client.threads.create({ metadata }); + await this.refresh(); + return t.thread_id; + } catch (e) { + console.error('[LangGraphThreadsAdapter.create] failed:', e); + 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: { [this.titleKey]: 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(); + } + + async pin(threadId: string): Promise { + await this.client.threads.update(threadId, { metadata: { pinned: true } }); + await this.refresh(); + } + + async unpin(threadId: string): Promise { + await this.client.threads.update(threadId, { metadata: { pinned: false } }); + await this.refresh(); + } + + async moveToProject(threadId: string, projectId: string | null): Promise { + await this.client.threads.update(threadId, { metadata: { projectId } }); + await this.refresh(); + } + + /** Re-stamp `metadata.pinnedOrder = 0,1,2,...` for the pinned slice + * to reflect the new ordering. */ + async reorderPinned(threadId: string, beforeId: string | null): Promise { + const current = this._threads().filter((t) => t.pinned === true); + const moved = current.find((t) => t.id === threadId); + if (!moved) return; + const rest = current.filter((t) => t.id !== threadId); + const next: Thread[] = []; + for (const t of rest) { + if (t.id === beforeId) next.push(moved); + next.push(t); + } + if (beforeId === null) next.push(moved); + + await Promise.all( + next.map((t, idx) => + this.client.threads.update(t.id, { metadata: { pinnedOrder: idx } }), + ), + ); + await this.refresh(); + } + + private toThread(t: SdkThread): Thread { + const meta = (t.metadata ?? {}) as Record; + const rawTitle = meta[this.titleKey]; + const archived = meta['archived'] === true; + const pinned = meta['pinned'] === true; + const projectId = typeof meta['projectId'] === 'string' && (meta['projectId'] as string).length > 0 + ? (meta['projectId'] as string) + : null; + const pinnedOrder = typeof meta['pinnedOrder'] === 'number' ? (meta['pinnedOrder'] as number) : undefined; + return { + id: t.thread_id, + title: typeof rawTitle === 'string' && rawTitle.length > 0 ? rawTitle : this.fallback, + status: archived ? 'archived' : 'active', + pinned, + projectId, + pinnedOrder, + updatedAt: t.updated_at ? Date.parse(t.updated_at) : undefined, + }; + } +} diff --git a/libs/langgraph/src/lib/transport/fetch-stream.transport.ts b/libs/langgraph/src/lib/transport/fetch-stream.transport.ts index 506701849..3e0762b7a 100644 --- a/libs/langgraph/src/lib/transport/fetch-stream.transport.ts +++ b/libs/langgraph/src/lib/transport/fetch-stream.transport.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT -import { Client } from '@langchain/langgraph-sdk'; -import type { StreamMode, ThreadState } from '@langchain/langgraph-sdk'; +import type { Client, StreamMode, ThreadState } from '@langchain/langgraph-sdk'; import type { AgentQueueEntry, AgentTransport, LangGraphSubmitOptions, StreamEvent } from '../agent.types'; +import { createLangGraphClient } from '../client/create-langgraph-client'; /** * Production transport that connects to a LangGraph Platform API via HTTP and SSE. @@ -26,15 +26,10 @@ export class FetchStreamTransport implements AgentTransport { * @param onThreadId - Optional callback invoked when a new thread is created */ constructor(apiUrl: string, onThreadId?: (id: string) => void) { - // Normalize relative paths (e.g. '/api') to absolute URLs. - // The LangGraph SDK Client requires an absolute URL, but production - // environments use relative paths that are proxied by Vercel middleware. - const absoluteUrl = apiUrl.startsWith('http://') || apiUrl.startsWith('https://') - ? apiUrl - : typeof window !== 'undefined' - ? `${window.location.origin}${apiUrl}` - : apiUrl; - this.client = new Client({ apiUrl: absoluteUrl }); + // createLangGraphClient handles the absolute-URL normalization + // required by the SDK when `apiUrl` is a relative `/api`-style + // path proxied by middleware in production. + this.client = createLangGraphClient(apiUrl); this.onThreadId = onThreadId; } diff --git a/libs/langgraph/src/public-api.ts b/libs/langgraph/src/public-api.ts index a7d24c066..efccdd297 100644 --- a/libs/langgraph/src/public-api.ts +++ b/libs/langgraph/src/public-api.ts @@ -46,3 +46,19 @@ export type { MockLangGraphAgent } from './lib/testing/mock-langgraph-agent'; // Citation normalizer — useful for advanced consumers building custom adapters // or bridging non-LangGraph message shapes into ngaf Citation[]. export { extractCitations } from './lib/internals/extract-citations'; + +// SDK Client helper — handles the SDK's absolute-URL requirement so +// `/api`-style relative paths work in browser contexts. +export { createLangGraphClient, toAbsoluteApiUrl } from './lib/client/create-langgraph-client'; + +// SDK-backed thread store — drop-in replacement for the +// hand-rolled ThreadsService that consumers used to duplicate. +export { + LangGraphThreadsAdapter, + LANGGRAPH_THREADS_CONFIG, + LANGGRAPH_CLIENT, +} from './lib/threads/threads-adapter'; +export type { LangGraphThreadsConfig } from './lib/threads/threads-adapter'; + +// Lifecycle helper for hooking refreshes onto agent state transitions. +export { refreshOnRunEnd, refreshOnTransition } from './lib/threads/refresh-on';