Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
96 changes: 82 additions & 14 deletions cockpit/chat/threads/angular/src/app/threads.component.ts
Original file line number Diff line number Diff line change
@@ -1,48 +1,116 @@
// SPDX-License-Identifier: MIT
import { Component, 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 { environment } from '../environments/environment';
import { ThreadsService } from './threads.service';

/**
* ThreadsComponent demonstrates multi-thread conversation management
* with ChatComponent and ChatThreadListComponent in a sidebar.
* 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',
standalone: true,
imports: [ChatComponent, ChatThreadListComponent, ExampleChatLayoutComponent],
template: `
<example-chat-layout sidebarPosition="left" sidebarWidth="w-64">
<chat main [agent]="agent" [threads]="threads()" [activeThreadId]="activeThreadId()" (threadSelected)="onThreadSelected($event)" class="flex-1 min-w-0" />
<chat main
[agent]="agent"
[threads]="threadsSvc.threads()"
[activeThreadId]="activeThreadId() ?? ''"
(threadSelected)="onThreadSelected($event)"
class="flex-1 min-w-0" />
<div sidebar class="p-4 space-y-4"
style="background: var(--ngaf-chat-bg); color: var(--ngaf-chat-text);">
<h3 class="text-xs font-semibold uppercase tracking-wide"
style="color: var(--ngaf-chat-text-muted);">Threads</h3>
<div class="flex items-center justify-between">
<h3 class="text-xs font-semibold uppercase tracking-wide"
style="color: var(--ngaf-chat-text-muted);">Threads</h3>
<button type="button"
class="text-xs underline"
style="color: var(--ngaf-chat-text-muted);"
(click)="onNewThread()">+ New</button>
</div>
<chat-thread-list
[threads]="threads()"
[activeThreadId]="activeThreadId()"
[threads]="threadsSvc.threads()"
[activeThreadId]="activeThreadId() ?? ''"
[actions]="threadActions"
(threadSelected)="onThreadSelected($event)" />
</div>
</example-chat-layout>
`,
})
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<string | null>(null);

protected readonly agent = agent({
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),
});

protected readonly threads = signal<Thread[]>([
{ id: 'thread-1', title: 'First Conversation' },
{ id: 'thread-2', title: 'Second Conversation' },
{ id: 'thread-3', title: 'Third Conversation' },
]);
/** 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.threadsSvc.refresh();

protected readonly activeThreadId = signal('thread-1');
// 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 status = this.agent.status();
if (lastStatus === 'running' && status !== 'running') {
void this.threadsSvc.refresh();
}
lastStatus = status;
});
}

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<void> {
const id = await this.threadsSvc.create();
if (id) {
this.agent.switchThread(id);
this.activeThreadId.set(id);
}
}
}
93 changes: 93 additions & 0 deletions cockpit/chat/threads/angular/src/app/threads.service.ts
Original file line number Diff line number Diff line change
@@ -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<Thread[]>([]);
readonly archivedThreads = signal<Thread[]>([]);

async refresh(): Promise<void> {
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<string | null> {
try {
const t = await this.client.threads.create();
await this.refresh();
return t.thread_id;
} catch {
return null;
}
}

async delete(threadId: string): Promise<void> {
await this.client.threads.delete(threadId);
await this.refresh();
}

async rename(threadId: string, newTitle: string): Promise<void> {
await this.client.threads.update(threadId, { metadata: { thread_title: newTitle } });
await this.refresh();
}

async archive(threadId: string): Promise<void> {
await this.client.threads.update(threadId, { metadata: { archived: true } });
await this.refresh();
}

async unarchive(threadId: string): Promise<void> {
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,
};
}
}
76 changes: 67 additions & 9 deletions cockpit/chat/threads/python/src/graph.py
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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()

Expand Down