diff --git a/.gitignore b/.gitignore index f2e23c963..a579b2107 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ dist/ tmp/ apps/website/test-results/ apps/website/public/demo/ +cockpit/**/angular/test-results/ # Env .env diff --git a/apps/cockpit/cockpit-e2e-wiring.spec.ts b/apps/cockpit/cockpit-e2e-wiring.spec.ts index d5a71ea46..9822d2784 100644 --- a/apps/cockpit/cockpit-e2e-wiring.spec.ts +++ b/apps/cockpit/cockpit-e2e-wiring.spec.ts @@ -87,7 +87,12 @@ function activeCockpitE2eWiring(): E2eWiring[] { const projectRoot = dirname(projectJsonPath); const globalSetupPath = join(projectRoot, 'e2e/global-setup-impl.ts'); const globalSetup = readFileSync(globalSetupPath, 'utf8'); - const langgraphCwd = parseStringProperty(globalSetup, 'langgraphCwd'); + // langgraph-shaped global-setup uses `langgraphCwd`; ag-ui-shaped + // global-setup (createAgUiGlobalSetup) uses `pythonCwd`. Both name + // the python project's cwd — accept either. + const langgraphCwd = + parseStringProperty(globalSetup, 'langgraphCwd') ?? + parseStringProperty(globalSetup, 'pythonCwd'); // Post-port-registry migration: ports are imported from // cockpit/ports.mjs rather than living as literals in diff --git a/apps/cockpit/scripts/capability-registry.ts b/apps/cockpit/scripts/capability-registry.ts index 3481b266f..55662eab9 100644 --- a/apps/cockpit/scripts/capability-registry.ts +++ b/apps/cockpit/scripts/capability-registry.ts @@ -49,8 +49,9 @@ export const capabilities: readonly Capability[] = [ { id: 'c-debug', product: 'chat', topic: 'debug', angularProject: 'cockpit-chat-debug-angular', port: 4509, pythonPort: 5509, pythonDir: 'cockpit/chat/debug/python', graphName: 'c-debug' }, { id: 'c-theming', product: 'chat', topic: 'theming', angularProject: 'cockpit-chat-theming-angular', port: 4510, pythonPort: 5510, pythonDir: 'cockpit/chat/theming/python', graphName: 'c-theming' }, { id: 'c-a2ui', product: 'chat', topic: 'a2ui', angularProject: 'cockpit-chat-a2ui-angular', port: 4511, pythonPort: 5511, pythonDir: 'cockpit/chat/a2ui/python', graphName: 'c-a2ui' }, - // AG-UI capabilities (in-process FakeAgent; no Python backend, not deployed to LangSmith) - { id: 'ag-ui-streaming', product: 'ag-ui', topic: 'streaming', angularProject: 'cockpit-ag-ui-streaming-angular', port: 4600 }, + // AG-UI capabilities (uvicorn ag-ui-langgraph backend; not deployed to LangSmith) + { id: 'ag-ui-interrupts', product: 'ag-ui', topic: 'interrupts', angularProject: 'cockpit-ag-ui-interrupts-angular', port: 4320, pythonPort: 5320, pythonDir: 'cockpit/ag-ui/interrupts/python' }, + { id: 'ag-ui-streaming', product: 'ag-ui', topic: 'streaming', angularProject: 'cockpit-ag-ui-streaming-angular', port: 4321, pythonPort: 5321, pythonDir: 'cockpit/ag-ui/streaming/python' }, ] as const; export function findCapability(id: string): Capability | undefined { diff --git a/apps/website/content/docs/ag-ui/guides/interrupts.mdx b/apps/website/content/docs/ag-ui/guides/interrupts.mdx new file mode 100644 index 000000000..61106dce2 --- /dev/null +++ b/apps/website/content/docs/ag-ui/guides/interrupts.mdx @@ -0,0 +1,164 @@ +# Interrupts (Human-in-the-Loop) + +Interrupts let your AG-UI agent pause mid-run and hand control to a human. The agent proposes an action, the run freezes, your Angular UI shows an approval dialog, the user decides, and the agent resumes with the human's decision. This guide covers the AG-UI adapter specifics. For the broader conceptual model — lifecycle stages, timeout strategies, typed payloads — see the [LangGraph interrupts guide](/docs/langgraph/guides/interrupts). + +## The Wire Format + +AG-UI interrupts arrive as a `CUSTOM` event with `name: "on_interrupt"`: + +```json +{ + "type": "CUSTOM", + "name": "on_interrupt", + "value": "{\"kind\":\"refund_approval\",\"amount\":47.50,\"customer_id\":\"cus_a8x2k\",\"reason\":\"Duplicate charge\"}" +} +``` + +Two things to note: + +- The `value` is a **JSON string**, not an object. The `ag-ui-langgraph` Python package serializes the interrupt payload via `dump_json_safe` before emitting the event. +- The adapter `JSON.parse`s the string automatically. Consumers always see the structured object — you never need to parse it yourself. + +**Structuring the payload:** Use a `kind` field so `` can match the right interrupt: + +```python +decision = interrupt({ + "kind": "refund_approval", + "amount": amount, + "customer_id": customer_id, + "reason": reason, +}) +``` + +## Reading the Interrupt in Your Component + +`injectAgent()` exposes a `interrupt()` signal that is populated whenever the adapter receives an `on_interrupt` CUSTOM event. Pair it with `` from `@threadplane/chat` to render an approval dialog without manual event wiring: + +```typescript +import { Component } from '@angular/core'; +import { ChatComponent, ChatApprovalCardComponent } from '@threadplane/chat'; +import { injectAgent } from '@threadplane/ag-ui'; +import type { ChatApprovalAction } from '@threadplane/chat'; + +@Component({ + standalone: true, + imports: [ChatComponent, ChatApprovalCardComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + + + `, +}) +export class RefundApprovalComponent { + protected readonly agent = injectAgent(); + + onAction(action: ChatApprovalAction): void { + if (action === 'approve') { + void this.agent.submit({ resume: { approved: true } }); + } else if (action === 'cancel') { + void this.agent.submit({ resume: { approved: false } }); + } + } +} +``` + +`matchKind` filters on `interrupt().value.kind`. The card renders only when the active interrupt matches — other interrupt kinds are ignored. + +## Resuming + +Call `agent.submit({ resume })` with your decision object: + +```typescript +// Approve +void this.agent.submit({ resume: { approved: true } }); + +// Reject +void this.agent.submit({ resume: { approved: false } }); + +// Approve with an edited field +void this.agent.submit({ resume: { approved: true, amount: 35.00 } }); +``` + +Under the hood, `submit({ resume })` calls `runAgent({ forwardedProps: { command: { resume } } })`. The server receives `forwarded_props.command.resume` — the convention the [`ag-ui-langgraph`](https://pypi.org/project/ag-ui-langgraph/) package reads to resume the LangGraph checkpoint. + + +In your LangGraph node, `interrupt({...})` returns the `resume` value directly. You do not need to unwrap `forwarded_props` yourself — `ag-ui-langgraph` does that before resuming the graph. + + +## End-to-End Example + +`cockpit/ag-ui/interrupts` is a complete Angular + Python example: a refund-authorization agent that drafts a refund, pauses for operator approval, and issues (or cancels) based on the decision. + +**Angular component** (`cockpit/ag-ui/interrupts/angular/src/app/interrupts.component.ts`): + +```typescript +import { Component, ChangeDetectionStrategy, signal } from '@angular/core'; +import { + ChatComponent, + ChatApprovalCardComponent, + type ChatApprovalAction, +} from '@threadplane/chat'; +import { injectAgent } from '@threadplane/ag-ui'; + +@Component({ + standalone: true, + imports: [ChatComponent, ChatApprovalCardComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + + + `, +}) +export class InterruptsComponent { + protected readonly agent = injectAgent(); + + protected onAction(action: ChatApprovalAction): void { + if (action === 'approve') { + void this.agent.submit({ resume: { approved: true } }); + } else if (action === 'cancel') { + void this.agent.submit({ resume: { approved: false } }); + } + } +} +``` + +**Python graph** (`cockpit/ag-ui/interrupts/python/src/graph.py`) uses `ag-ui-langgraph` to front a standard LangGraph graph: + +```python +from langgraph.types import interrupt +from ag_ui_langgraph import LangGraphAgent, add_langgraph_fastapi_endpoint + +def request_approval(state): + decision = interrupt({ + "kind": "refund_approval", + "amount": state["amount"], + "customer_id": state["customer_id"], + "reason": state["reason"], + }) + approved = isinstance(decision, dict) and decision.get("approved") + return {"decision_approved": approved} +``` + +The `LangGraphAgent` wrapper handles streaming the `CUSTOM on_interrupt` event and reading `forwarded_props.command.resume` on resume. Refer to [`ag-ui-langgraph` on PyPI](https://pypi.org/project/ag-ui-langgraph/) for installation and configuration. + +## Cross-Adapter Parity + +The consumer Angular code is byte-identical except the `injectAgent` import: + +```diff +- import { injectAgent } from '@threadplane/langgraph'; ++ import { injectAgent } from '@threadplane/ag-ui'; +``` + +``, the `interrupt()` signal, and `submit({ resume })` are part of the runtime-neutral `Agent` contract from `@threadplane/chat`. Switching adapters is a provider change, not a component rewrite. See the [LangGraph interrupts guide](/docs/langgraph/guides/interrupts) for the full HITL pattern including multi-step approvals, typed payloads, and timeout strategies. diff --git a/apps/website/content/docs/chat/api/api-docs.json b/apps/website/content/docs/chat/api/api-docs.json index 650d66fbd..f84ed9dbe 100644 --- a/apps/website/content/docs/chat/api/api-docs.json +++ b/apps/website/content/docs/chat/api/api-docs.json @@ -6968,25 +6968,6 @@ }, "examples": [] }, - { - "name": "provideViews", - "kind": "function", - "description": "", - "signature": "provideViews(registry: ViewRegistry): EnvironmentProviders", - "params": [ - { - "name": "registry", - "type": "ViewRegistry", - "description": "", - "optional": false - } - ], - "returns": { - "type": "EnvironmentProviders", - "description": "" - }, - "examples": [] - }, { "name": "renderMarkdown", "kind": "function", diff --git a/apps/website/content/docs/chat/guides/markdown.mdx b/apps/website/content/docs/chat/guides/markdown.mdx index 077268932..7e49ec3b0 100644 --- a/apps/website/content/docs/chat/guides/markdown.mdx +++ b/apps/website/content/docs/chat/guides/markdown.mdx @@ -128,6 +128,108 @@ export class ChatViewComponent { The markdown styles are scoped to `.chat-md`. Make sure the container element receiving `[innerHTML]` has this class, otherwise the rendered HTML will appear unstyled. +## Streaming Markdown with chat-streaming-md + +`` is the component that renders AI message content token-by-token using the node-based rendering pipeline. It resolves each markdown node type against `MARKDOWN_VIEW_REGISTRY` — a chat-internal DI token exported from `@threadplane/chat`. + +By default the component provides `cacheplaneMarkdownViews` (the full 22-node registry) on its own component injector. You can override this at two levels: + +- **App-wide** — provide a custom registry in your root or feature providers. +- **Per-instance** — pass a `ViewRegistry` via the `[viewRegistry]` input; the component uses that value instead of the DI tree. + +## Overriding Markdown Components + +### App-wide override + +To replace a node-type renderer for every `` in your app, provide a custom `MARKDOWN_VIEW_REGISTRY` in your application config: + +```typescript +import { ApplicationConfig } from '@angular/core'; +import { MARKDOWN_VIEW_REGISTRY, cacheplaneMarkdownViews } from '@threadplane/chat'; +import { overrideViews } from '@threadplane/render'; +import { MyCodeBlockComponent } from './my-code-block.component'; + +export const appConfig: ApplicationConfig = { + providers: [ + { + provide: MARKDOWN_VIEW_REGISTRY, + useValue: overrideViews(cacheplaneMarkdownViews, { + 'code-block': MyCodeBlockComponent, + }), + }, + ], +}; +``` + +`overrideViews(base, overrides)` replaces every key listed in `overrides` and preserves all other entries from `base`. Import it from `@threadplane/render` (chat does not re-export it). + + +Use `overrideViews` when replacing an existing node type. Use `withViews` when adding a brand-new node type that `cacheplaneMarkdownViews` does not yet cover — `withViews` is additive-only and the base registry wins on conflicts. See the [render views API](/docs/render/api/views) for full signatures. + + +### Per-instance override + +Pass a `ViewRegistry` directly to a single `` via its `[viewRegistry]` input. The component uses the provided value and ignores the DI tree for that instance: + +```typescript +import { Component } from '@angular/core'; +import { ChatStreamingMdComponent, cacheplaneMarkdownViews } from '@threadplane/chat'; +import { overrideViews } from '@threadplane/render'; +import { MyCodeBlockComponent } from './my-code-block.component'; + +@Component({ + selector: 'app-custom-chat', + standalone: true, + imports: [ChatStreamingMdComponent], + template: ` + + `, +}) +export class CustomChatComponent { + content = ''; + myRegistry = overrideViews(cacheplaneMarkdownViews, { + 'code-block': MyCodeBlockComponent, + }); +} +``` + +## Node-Type Reference + +`cacheplaneMarkdownViews` covers every node type emitted by `@cacheplane/partial-markdown`. Use these keys when calling `overrideViews` or `withViews`. + + +The most common mistake is providing `'code'` as an override key — it does not match anything in the registry. The correct key for fenced code blocks is `'code-block'`. + + +| Key | Description | +|-----|-------------| +| `'document'` | Root node wrapping the entire parsed document | +| `'paragraph'` | Block-level paragraph (`

`) | +| `'heading'` | Heading element (`

` through `

`) | +| `'blockquote'` | Block-level quotation (`
`) | +| `'list'` | Ordered or unordered list (`
    ` / `