Files
socweb/langgraph/src/agent/writer-agent/index.ts
T
gongzhiyongandClaude Sonnet 4.6 ac388b3c07 feat: add langgraph/ — LangGraph.js gen-ui server with CI/CD to Azure Container App
## Azure Resources Created
- ACR: socsocacr.azurecr.io (Operation, southeastasia)
- Container App Environment: soc-cae (Operation, southeastasia)
- Container App: soc-langgraph (Operation, southeastasia, port 2024)
  - Scale: 0-2 replicas, 0.5 vCPU / 1GB
  - Registry: socsocacr.azurecr.io (system-assigned managed identity)
- OIDC: oidc-msi-8ac6 granted Contributor on Operation resource group

## What's included
- Full langgraphjs-gen-ui-examples codebase under langgraph/
- Dockerfile: node:20-slim + pnpm, exposes port 2024
- .dockerignore, .gitignore
- GitHub Actions: .github/workflows/deploy-langgraph.yml
  - Trigger: push to main, paths langgraph/**
  - Uses az acr build (cloud build, no local Docker needed)
  - Deploys to soc-langgraph Container App

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 04:01:06 +08:00

168 lines
4.7 KiB
TypeScript

import {
Annotation,
START,
StateGraph,
type LangGraphRunnableConfig,
} from "@langchain/langgraph";
import { ChatAnthropic } from "@langchain/anthropic";
import { typedUi } from "@langchain/langgraph-sdk/react-ui/server";
import {
isAIMessage,
isBaseMessage,
type AIMessageChunk,
type BaseMessageLike,
} from "@langchain/core/messages";
import { v4 as uuidv4 } from "uuid";
import { z } from "zod";
import { findToolCall } from "../find-tool-call";
import { GenerativeUIAnnotation } from "../types";
import type ComponentMap from "../../agent-uis/index";
const MODEL_NAME = "claude-3-5-sonnet-latest";
const WriterAnnotation = Annotation.Root({
messages: GenerativeUIAnnotation.spec.messages,
ui: GenerativeUIAnnotation.spec.ui,
context: Annotation<{ writer?: { selected?: string } } | undefined>(),
});
type WriterState = typeof WriterAnnotation.State;
type WriterUpdate = Promise<typeof WriterAnnotation.Update>;
async function prepare(
state: WriterState,
config: LangGraphRunnableConfig,
): WriterUpdate {
const ui = typedUi<typeof ComponentMap>(config);
const model = new ChatAnthropic({ model: MODEL_NAME });
// create an initial draft of the document
const CreateTextDocumentTool = z.object({
title: z.string(),
description: z.string(),
});
const initStream = await model
.bindTools([
{
name: "draft_text_document",
description:
"Prepare a text document for the user with a short title and short description for browsing purposes. " +
"Can be also used when creating a new version of the document.",
schema: CreateTextDocumentTool,
} as const,
])
.stream([
...(state.context?.writer?.selected
? [
{
type: "system" as const,
content: state.context.writer?.selected
? `Selected text in question: ${state.context.writer?.selected}`
: "",
},
]
: []),
...state.messages,
]);
const id = uuidv4();
let message: AIMessageChunk | undefined;
for await (const chunk of initStream) {
message = message?.concat(chunk) ?? chunk;
const tool = message.tool_calls?.find(
findToolCall("draft_text_document")<typeof CreateTextDocumentTool>,
)?.args;
if (tool) {
ui.push(
{ id, name: "writer", props: { ...tool, isGenerating: true } },
{ message, merge: true },
);
}
}
return { messages: message ? [message] : [] };
}
async function writer(
state: WriterState,
config: LangGraphRunnableConfig,
): WriterUpdate {
const ui = typedUi<typeof ComponentMap>(config);
const lastMessage = state.messages.at(-1);
const lastUi = state.ui.findLast(
(i) => i.name === "writer" && i.metadata.message_id === lastMessage?.id,
);
if (!lastUi || !lastMessage) return {};
const { id } = lastUi;
const contentStream = await new ChatAnthropic({ model: MODEL_NAME })
.withConfig({ tags: ["nostream"] }) // do not stream to the UI
.stream([
{
role: "system",
content:
"Write a text document based on the user's request. " +
"Only output the content, do not ask any additional questions." +
(state.context?.writer?.selected
? `\n\nSelected text in question: ${state.context.writer?.selected}`
: ""),
},
...state.messages.slice(0, -1),
]);
let contentMessage: AIMessageChunk | undefined;
for await (const chunk of contentStream) {
contentMessage = contentMessage?.concat(chunk) ?? chunk;
const content = contentMessage?.text ?? "";
ui.push(
{ id, name: "writer", props: { content, isGenerating: true } },
{ message: lastMessage, merge: true },
);
}
ui.push(
{ id, name: "writer", props: { isGenerating: false } },
{ message: lastMessage, merge: true },
);
return { messages: [] };
}
async function suggestions(state: WriterState): WriterUpdate {
const messages: BaseMessageLike[] = state.messages.slice();
const lastMessage = messages.at(-1);
if (!isBaseMessage(lastMessage) || !isAIMessage(lastMessage)) {
return {};
}
for (const tool of lastMessage.tool_calls ?? []) {
if (!tool.id) continue;
messages.push({ type: "tool", content: "Finished", tool_call_id: tool.id });
}
const model = new ChatAnthropic({ model: MODEL_NAME });
const finish = await model.invoke(messages);
messages.push(finish);
return { messages: messages };
}
export const graph = new StateGraph(WriterAnnotation)
.addNode("prepare", prepare)
.addNode("writer", writer)
.addNode("suggestions", suggestions)
.addEdge(START, "prepare")
.addEdge("prepare", "writer")
.addEdge("writer", "suggestions")
.compile();