diff --git a/.gitignore b/.gitignore index 073122f4c..a6e080d7d 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ .vscode/ .idea/ +node_modules/ +.next/ *.out *.log coverage.out @@ -24,3 +26,4 @@ CLAUDE.md *test*.py *.db .codex +pnpm-lock.yaml diff --git a/.resource/images/examples/agui-copilotkit.png b/.resource/images/examples/agui-copilotkit.png new file mode 100644 index 000000000..b177d275d --- /dev/null +++ b/.resource/images/examples/agui-copilotkit.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8e11382990a08da9d2a66f121244530ac3eb3a18664c0d8866f668229976d02e +size 93710 diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index bee2193d5..645fc9a00 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -24,6 +24,7 @@ nav: - Planner: planner.md - Event: event.md - Debug: debugserver.md + - AG-UI: agui.md - Observability: observability.md - A2A: a2a.md - Ecosystem: ecosystem.md @@ -61,6 +62,7 @@ plugins: - Graph: graph.md - Event: event.md - 调试: debugserver.md + - AG-UI: agui.md - 可观测: observability.md - A2A: a2a.md - 生态: ecosystem.md diff --git a/docs/mkdocs/assets/img/agui/copilotkit.png b/docs/mkdocs/assets/img/agui/copilotkit.png new file mode 100644 index 000000000..b177d275d --- /dev/null +++ b/docs/mkdocs/assets/img/agui/copilotkit.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8e11382990a08da9d2a66f121244530ac3eb3a18664c0d8866f668229976d02e +size 93710 diff --git a/docs/mkdocs/en/agui.md b/docs/mkdocs/en/agui.md new file mode 100644 index 000000000..472813230 --- /dev/null +++ b/docs/mkdocs/en/agui.md @@ -0,0 +1,131 @@ +# AG-UI Guide + +The AG-UI (Agent-User Interaction) protocol is maintained by the open-source [AG-UI Protocol](https://github.com/ag-ui-protocol/ag-ui) project. It enables agents built in different languages, frameworks, and execution environments to deliver their runtime outputs to user interfaces through a unified event stream. The protocol tolerates loosely matched payloads and supports transports such as SSE and WebSocket. + +`tRPC-Agent-Go` ships with native AG-UI integration. It provides an SSE server implementation by default, while also allowing you to swap in a custom `service.Service` to use transports like WebSocket and to extend the event translation logic. + +## Getting Started + +Assuming you already have an agent, you can expose it via the AG-UI protocol with just a few lines of code: + +```go +import ( + "net/http" + + "trpc.group/trpc-go/trpc-agent-go/runner" + "trpc.group/trpc-go/trpc-agent-go/server/agui" +) + +// Create the agent. +agent := newAgent() +// Build the Runner that will execute the agent. +runner := runner.NewRunner(agent.Info().Name, agent) +// Create the AG-UI server and mount it on an HTTP route. +server, err := agui.New(runner, agui.WithPath("/agui")) +if err != nil { + log.Fatalf("create agui server failed: %v", err) +} +// Start the HTTP listener. +if err := http.ListenAndServe("127.0.0.1:8080", server.Handler()); err != nil { + log.Fatalf("server stopped with error: %v", err) +} +``` + +A complete version of this example lives in [examples/agui/server/default](https://github.com/trpc-group/trpc-agent-go/tree/main/examples/agui/server/default). + +For an in-depth guide to Runners, refer to the [runner](./runner.md) documentation. + +On the client side you can pair the server with frameworks that understand the AG-UI protocol, such as [CopilotKit](https://github.com/CopilotKit/CopilotKit). It provides React/Next.js components with built-in SSE subscriptions. The sample at [examples/agui/client/copilotkit](https://github.com/trpc-group/trpc-agent-go/tree/main/examples/agui/client/copilotkit) builds a web UI that communicates with the agent through AG-UI, as shown below. + +![copilotkit](../assets/img/agui/copilotkit.png) + +## Advanced Usage + +### Custom transport + +The AG-UI specification does not enforce a transport. The framework uses SSE by default, but you can implement the `service.Service` interface to switch to WebSocket or any other transport: + +```go +import ( + "trpc.group/trpc-go/trpc-agent-go/runner" + "trpc.group/trpc-go/trpc-agent-go/server/agui" +) + +type wsService struct{} + +func (s *wsService) Handler() http.Handler { /* Register WebSocket and stream events. */ } + +runner := runner.NewRunner(agent.Info().Name, agent) +server, _ := agui.New(runner, agui.WithService(&wsService{})) +``` + +### Custom translator + +`translator.New` converts internal events into the standard AG-UI events. To enrich the stream while keeping the default behaviour, implement `translator.Translator` and use the AG-UI `Custom` event type to carry extra data: + +```go +import ( + aguievents "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/core/events" + agentevent "trpc.group/trpc-go/trpc-agent-go/event" + "trpc.group/trpc-go/trpc-agent-go/runner" + "trpc.group/trpc-go/trpc-agent-go/server/agui" + "trpc.group/trpc-go/trpc-agent-go/server/agui/adapter" + aguirunner "trpc.group/trpc-go/trpc-agent-go/server/agui/runner" + "trpc.group/trpc-go/trpc-agent-go/server/agui/translator" +) + +type customTranslator struct { + inner translator.Translator +} + +func (t *customTranslator) Translate(evt *agentevent.Event) ([]aguievents.Event, error) { + out, err := t.inner.Translate(evt) + if err != nil { + return nil, err + } + if payload := buildCustomPayload(evt); payload != nil { + out = append(out, aguievents.NewCustomEvent("trace.metadata", aguievents.WithValue(payload))) + } + return out, nil +} + +func buildCustomPayload(evt *agentevent.Event) map[string]any { + if evt == nil || evt.Response == nil { + return nil + } + return map[string]any{ + "object": evt.Response.Object, + "timestamp": evt.Response.Timestamp, + } +} + +factory := func(input *adapter.RunAgentInput) translator.Translator { + return &customTranslator{inner: translator.New(input.ThreadID, input.RunID)} +} + +runner := runner.NewRunner(agent.Info().Name, agent) +server, _ := agui.New(runner, agui.WithAGUIRunnerOptions(aguirunner.WithTranslatorFactory(factory))) +``` + +### Custom `UserIDResolver` + +By default every request maps to the fixed user ID `"user"`. Implement a custom `UserIDResolver` if you need to derive the user from the `RunAgentInput`: + +```go +import ( + "trpc.group/trpc-go/trpc-agent-go/runner" + "trpc.group/trpc-go/trpc-agent-go/server/agui" + "trpc.group/trpc-go/trpc-agent-go/server/agui/adapter" + aguirunner "trpc.group/trpc-go/trpc-agent-go/server/agui/runner" +) + +resolver := func(ctx context.Context, input *adapter.RunAgentInput) (string, error) { + if user, ok := input.ForwardedProps["userId"].(string); ok && user != "" { + return user, nil + } + return "anonymous", nil +} + +runner := runner.NewRunner(agent.Info().Name, agent) +server, _ := agui.New(runner, agui.WithAGUIRunnerOptions(aguirunner.WithUserIDResolver(resolver))) +``` diff --git a/docs/mkdocs/zh/agui.md b/docs/mkdocs/zh/agui.md new file mode 100644 index 000000000..6e03fabf9 --- /dev/null +++ b/docs/mkdocs/zh/agui.md @@ -0,0 +1,130 @@ +# AG-UI 使用指南 + +AG-UI(Agent-User Interaction)协议由开源社区 [AG-UI Protocol](https://github.com/ag-ui-protocol/ag-ui) 维护,旨在让不同语言、不同框架、不同执行环境的 Agent,都能够通过统一的事件流把执行过程中产生的内容传递给用户界面,允许松散的事件格式匹配,支持 SSE 和 WebSocket 等多种通信协议。 + +`tRPC-Agent-Go` 接入了 AG-UI 协议,默认提供 SSE 服务端实现,也支持通过自定义 `service.Service` 切换到 WebSocket 等通信协议,并扩展事件翻译逻辑。 + +## 快速上手 + +假设你已实现一个 Agent,可以按如下方式接入 AG-UI 协议并启动服务: + +```go +import ( + "net/http" + + "trpc.group/trpc-go/trpc-agent-go/runner" + "trpc.group/trpc-go/trpc-agent-go/server/agui" +) + +// 创建 Agent +agent := newAgent() +// 创建 Runner +runner := runner.NewRunner(agent.Info().Name, agent) +// 创建 AG-UI 服务,指定 HTTP 路由 +server, err := agui.New(runner, agui.WithPath("/agui")) +if err != nil { + log.Fatalf("create agui server failed: %v", err) +} +// 启动 HTTP 服务 +if err := http.ListenAndServe("127.0.0.1:8080", server.Handler()); err != nil { + log.Fatalf("server stopped with error: %v", err) +} +``` + +完整代码示例参见 [examples/agui/server/default](https://github.com/trpc-group/trpc-agent-go/tree/main/examples/agui/server/default)。 + +Runner 全面的使用方法参见 [runner](./runner.md)。 + +在前端侧,可以配合 [CopilotKit](https://github.com/CopilotKit/CopilotKit) 等支持 AG-UI 协议的客户端框架,它提供 React/Next.js 组件并内置 SSE 订阅能力。[examples/agui/client/copilotkit](https://github.com/trpc-group/trpc-agent-go/tree/main/examples/agui/client/copilotkit) 使用 CopilotKit 搭建了 Web UI 界面,通过 AG-UI 协议与 Agent 通信,效果如下图所示。 + +![copilotkit](../assets/img/agui/copilotkit.png) + +## 进阶用法 + +### 自定义通信协议 + +AG-UI 协议未强制规定通信协议,框架使用 SSE 作为 AG-UI 的默认通信协议,如果希望改用 WebSocket 等其他协议,可以实现 `service.Service` 接口: + +```go +import ( + "trpc.group/trpc-go/trpc-agent-go/runner" + "trpc.group/trpc-go/trpc-agent-go/server/agui" +) + +type wsService struct{} + +func (s *wsService) Handler() http.Handler { /* 注册 WebSocket 并写入事件 */ } + +runner := runner.NewRunner(agent.Info().Name, agent) +server, _ := agui.New(runner, agui.WithService(&wsService{})) +``` + +### 自定义 Translator + +默认的 `translator.New` 会把内部事件翻译成协议里定义的标准事件集。若想在保留默认行为的基础上追加自定义信息,可以实现 `translator.Translator` 接口,并借助 AG-UI 的 `Custom` 事件类型携带扩展数据: + +```go +import ( + aguievents "github.com/ag-ui-protocol/ag-ui/sdks/community/go/pkg/core/events" + "trpc.group/trpc-go/trpc-agent-go/runner" + "trpc.group/trpc-go/trpc-agent-go/server/agui" + "trpc.group/trpc-go/trpc-agent-go/server/agui/adapter" + aguirunner "trpc.group/trpc-go/trpc-agent-go/server/agui/runner" + "trpc.group/trpc-go/trpc-agent-go/server/agui/translator" +) + +type customTranslator struct { + inner translator.Translator +} + +func (t *customTranslator) Translate(event *event.Event) ([]aguievents.Event, error) { + out, err := t.inner.Translate(event) + if err != nil { + return nil, err + } + if payload := buildCustomPayload(event); payload != nil { + out = append(out, aguievents.NewCustomEvent("trace.metadata", aguievents.WithValue(payload))) + } + return out, nil +} + +func buildCustomPayload(event *event.Event) map[string]any { + if event == nil || event.Response == nil { + return nil + } + return map[string]any{ + "object": event.Response.Object, + "timestamp": event.Response.Timestamp, + } +} + +factory := func(input *adapter.RunAgentInput) translator.Translator { + return &customTranslator{inner: translator.New(input.ThreadID, input.RunID)} +} + +runner := runner.NewRunner(agent.Info().Name, agent) +server, _ := agui.New(runner, agui.WithAGUIRunnerOptions(aguirunner.WithTranslatorFactory(factory))) +``` + +### 自定义 `UserIDResolver` + +默认所有请求都会归到固定的 `"user"` 用户 ID,可以通过自定义 `UserIDResolver` 从 `RunAgentInput` 中提取 `UserID`: + +```go +import ( + "trpc.group/trpc-go/trpc-agent-go/runner" + "trpc.group/trpc-go/trpc-agent-go/server/agui" + "trpc.group/trpc-go/trpc-agent-go/server/agui/adapter" + aguirunner "trpc.group/trpc-go/trpc-agent-go/server/agui/runner" +) + +resolver := func(ctx context.Context, input *adapter.RunAgentInput) (string, error) { + if user, ok := input.ForwardedProps["userId"].(string); ok && user != "" { + return user, nil + } + return "anonymous", nil +} + +runner := runner.NewRunner(agent.Info().Name, agent) +server, _ := agui.New(runner, agui.WithAGUIRunnerOptions(aguirunner.WithUserIDResolver(resolver))) +``` diff --git a/examples/agui/README.md b/examples/agui/README.md new file mode 100644 index 000000000..12a66ccea --- /dev/null +++ b/examples/agui/README.md @@ -0,0 +1,26 @@ +# AG-UI Examples + +This folder collects runnable demos that showcase how to integrate the `tRPC-Agent-Go` AG-UI server and various clients. + +- [`client/`](client/) – Client-side samples. +- [`server/`](server/) – Server-side samples. + +## Quick Start + +1. Start the default AG-UI server: + +```bash +go run ./server/default +``` + +2. In another terminal start the CopilotKit client: + +```bash +cd ./client/copilotkit +pnpm install +pnpm dev +``` + +3. Ask a question such as `Calculate 2*(10+11)` and watch the live event stream in the terminal. A full transcript example is documented in [`client/copilotkit/README.md`](client/copilotkit/README.md). + +See the individual README files under `client/` and `server/` for more background and configuration options. diff --git a/examples/agui/client/README.md b/examples/agui/client/README.md new file mode 100644 index 000000000..daa8bb79d --- /dev/null +++ b/examples/agui/client/README.md @@ -0,0 +1,8 @@ +# AG-UI Clients + +Runnable client front-ends that consume the AG-UI SSE stream exposed by the example servers. + +## Available Clients + +- [copilotkit/](copilotkit/) – Next.js web chat built with CopilotKit that renders AG-UI responses in the browser. +- [raw/](raw/) – Minimal Go terminal client that prints every SSE event for inspection. diff --git a/examples/agui/client/copilotkit/README.md b/examples/agui/client/copilotkit/README.md new file mode 100644 index 000000000..25a3053fc --- /dev/null +++ b/examples/agui/client/copilotkit/README.md @@ -0,0 +1,18 @@ +# CopilotKit Front-End for the AG-UI Server + +This example shows how to pair the Go-based AG-UI server with a React front-end built on [CopilotKit](https://docs.copilotkit.ai/). The UI streams Server-Sent Events from the AG-UI endpoint using the `@ag-ui/client` HTTP agent and renders an assistant sidebar provided by CopilotKit. + +## Start the CopilotKit client + +```bash +pnpm install # or npm install +pnpm dev # or npm run dev +``` + +Available environment variables before `pnpm dev`: + +- `AG_UI_ENDPOINT`: override the AG-UI endpoint URL (defaults to `http://127.0.0.1:8080/agui`). + +Open `http://localhost:3000` and start chatting with the full-screen assistant UI. The input shows the placeholder `Calculate 2*(10+11)`, first explain the idea, then calculate, and finally give the conclusion.`—press Enter to run that scenario or type your own request. Tool calls and their results appear inline inside the chat transcript. + +![agui-copilotkit](../../../../.resource/images/examples/agui-copilotkit.png) \ No newline at end of file diff --git a/examples/agui/client/copilotkit/app/api/copilotkit/route.ts b/examples/agui/client/copilotkit/app/api/copilotkit/route.ts new file mode 100644 index 000000000..40ab972db --- /dev/null +++ b/examples/agui/client/copilotkit/app/api/copilotkit/route.ts @@ -0,0 +1,40 @@ +// +// Tencent is pleased to support the open source community by making trpc-agent-go available. +// +// Copyright (C) 2025 Tencent. All rights reserved. +// +// trpc-agent-go is licensed under the Apache License Version 2.0. +// +// + +import { NextRequest } from "next/server"; +import { + CopilotRuntime, + ExperimentalEmptyAdapter, + copilotRuntimeNextJSAppRouterEndpoint, +} from "@copilotkit/runtime"; +import { HttpAgent } from "@ag-ui/client"; + +const runtime = new CopilotRuntime({ + agents: { + "agui-demo": new HttpAgent({ + agentId: "agui-demo", + description: "AG-UI agent hosted by the Go evaluation server", + threadId: "demo-thread", + url: process.env.AG_UI_ENDPOINT ?? "http://127.0.0.1:8080/agui", + headers: process.env.AG_UI_TOKEN + ? { Authorization: `Bearer ${process.env.AG_UI_TOKEN}` } + : undefined, + }), + }, +}); + +const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({ + runtime, + serviceAdapter: new ExperimentalEmptyAdapter(), + endpoint: "/api/copilotkit", +}); + +export async function POST(request: NextRequest) { + return handleRequest(request); +} diff --git a/examples/agui/client/copilotkit/app/globals.css b/examples/agui/client/copilotkit/app/globals.css new file mode 100644 index 000000000..ca45f4f74 --- /dev/null +++ b/examples/agui/client/copilotkit/app/globals.css @@ -0,0 +1,197 @@ +/* +// Tencent is pleased to support the open source community by making trpc-agent-go available. +// +// Copyright (C) 2025 Tencent. All rights reserved. +// +// trpc-agent-go is licensed under the Apache License Version 2.0. +// +*/ + +:root { + color-scheme: dark; + --copilot-kit-background-color: #141414; + --copilot-kit-primary-color: #024a76; + --copilot-kit-contrast-color: #ffffff; + --copilot-kit-secondary-contrast-color: #ffffff; + --copilot-kit-separator-color: rgba(255, 255, 255, 0.12); +} + +html, +body { + margin: 0; + font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: #141414; + color: #ffffff; + min-height: 100vh; + height: 100%; + overflow: hidden; +} + +main.agui-chat { + min-height: 100vh; + height: 100vh; + display: flex; + align-items: stretch; + justify-content: center; + padding: 0; + box-sizing: border-box; +} + +.agui-chat__panel { + flex: 1; + margin: 0; + background: #141414; + border: 0; + border-radius: 0; + box-shadow: none; + min-height: 100vh; +} + +.copilotKitChat, +.copilotKitMessages, +.copilotKitMessagesContainer, +.copilotKitMessagesFooter { + background: #141414 !important; +} + +.tool-message { + margin: 0.75rem 0 0.75rem 1.5rem; + padding: 0.75rem 0.95rem; + border-radius: 12px; + background: #303030; + border-left: 3px solid #4c6f8e; + color: #ffffff; + font-size: 0.95rem; + display: inline-block; + max-width: min(680px, 80%); + width: fit-content; +} + +.tool-message__label { + display: block; + font-size: 0.85rem; + letter-spacing: 0.01em; + text-transform: uppercase; + color: rgba(129, 212, 250, 0.9); + margin-bottom: 0.5rem; +} + +.tool-message__body { + margin: 0; + font-size: 0.85rem; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; + font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; + color: #bae6fd; + background: transparent; +} + +@media (max-width: 768px) { + .agui-chat__panel { + border-radius: 0; + border-width: 0; + } + + .tool-message { + margin: 0.75rem 0; + max-width: 100%; + } +} + + +.copilotKitMessage { + color: #ffffff; + padding: 0.9rem 1.1rem; + border-radius: 18px; + max-width: min(720px, 85%); + line-height: 1.6; + box-shadow: 0 14px 28px rgba(0, 0, 0, 0.22); +} + +.agui-chat .copilotKitMessage.copilotKitUserMessage { + background: #024a76; + margin-left: auto; + margin-right: 1.5rem; + color: #ffffff; +} + +.agui-chat .copilotKitMessage.copilotKitAssistantMessage { + background: #303030; + margin-right: auto; + margin-left: 1.5rem; + padding: 1rem 1.2rem; + max-width: min(760px, 90%); + color: #ffffff; +} + +.agui-chat .copilotKitMessage.copilotKitAssistantMessage * { + color: inherit; +} + +.agui-chat .copilotKitMessageControls { + margin-top: 0.75rem; + display: flex; + gap: 0.35rem; +} + +.agui-chat .copilotKitMessageControlButton { + background: rgba(255, 255, 255, 0.1); + border: none; + border-radius: 999px; + color: #ffffff; + width: 30px; + height: 30px; + display: flex; + align-items: center; + justify-content: center; +} + +.agui-chat .copilotKitMessageControlButton:hover { + background: rgba(255, 255, 255, 0.22); +} + +.copilotKitInputContainer { + background: #141414; + border-top: 1px solid rgba(255, 255, 255, 0.06); + padding: 1rem 1.5rem; + display: flex; + justify-content: center; +} + +.copilotKitInput { + background: #1d1d1d; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 18px; + padding: 0.5rem 0.75rem; + display: flex; + align-items: stretch; + gap: 0.75rem; + width: min(720px, 95%); + box-sizing: border-box; +} + +.copilotKitInput textarea { + flex: 1; + background: transparent; + border: none; + color: #ffffff; + font-size: 0.95rem; + line-height: 1.6; + font-family: inherit; + padding: 0.35rem 0.1rem; + align-self: stretch; + min-height: 1.8rem; +} + +.copilotKitInput textarea::placeholder { + color: rgba(255, 255, 255, 0.5); +} + +.tool-message__label { + color: #ffffff; +} + +.tool-message__body { + color: #ffffff; +} diff --git a/examples/agui/client/copilotkit/app/layout.tsx b/examples/agui/client/copilotkit/app/layout.tsx new file mode 100644 index 000000000..2b576c711 --- /dev/null +++ b/examples/agui/client/copilotkit/app/layout.tsx @@ -0,0 +1,35 @@ +// +// Tencent is pleased to support the open source community by making trpc-agent-go available. +// +// Copyright (C) 2025 Tencent. All rights reserved. +// +// trpc-agent-go is licensed under the Apache License Version 2.0. +// +// + +import type { Metadata } from "next"; +import { CopilotKit } from "@copilotkit/react-core"; + +import "@copilotkit/react-ui/styles.css"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "AG-UI CopilotKit Demo", + description: "Minimal CopilotKit front-end that streams AG-UI events from a Go agent server.", +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + + {children} + + + + ); +} diff --git a/examples/agui/client/copilotkit/app/page.tsx b/examples/agui/client/copilotkit/app/page.tsx new file mode 100644 index 000000000..afa4a1ff1 --- /dev/null +++ b/examples/agui/client/copilotkit/app/page.tsx @@ -0,0 +1,275 @@ +// +// Tencent is pleased to support the open source community by making trpc-agent-go available. +// +// Copyright (C) 2025 Tencent. All rights reserved. +// +// trpc-agent-go is licensed under the Apache License Version 2.0. +// +// + +"use client"; + +import { Fragment, useLayoutEffect, useRef, useState } from "react"; +import type { InputProps, RenderMessageProps } from "@copilotkit/react-ui"; +import { + AssistantMessage as DefaultAssistantMessage, + CopilotChat, + ImageRenderer as DefaultImageRenderer, + UserMessage as DefaultUserMessage, + useChatContext, +} from "@copilotkit/react-ui"; + +const DEFAULT_PROMPT = "Calculate 2*(10+11), first explain the idea, then calculate, and finally give the conclusion."; + +const PromptInput = ({ + inProgress, + onSend, + isVisible = false, + onStop, + hideStopButton = false, +}: InputProps) => { + const context = useChatContext(); + const textareaRef = useRef(null); + const [text, setText] = useState(""); + const [isComposing, setIsComposing] = useState(false); + + const adjustHeight = () => { + const textarea = textareaRef.current; + if (!textarea) { + return; + } + const styles = window.getComputedStyle(textarea); + const lineHeight = parseFloat(styles.lineHeight || "20"); + const paddingTop = parseFloat(styles.paddingTop || "0"); + const paddingBottom = parseFloat(styles.paddingBottom || "0"); + const baseHeight = lineHeight + paddingTop + paddingBottom; + + textarea.style.height = "auto"; + const value = textarea.value; + if (value.trim() === "") { + textarea.style.height = `${baseHeight}px`; + textarea.style.overflowY = "hidden"; + return; + } + + textarea.style.height = `${Math.max(textarea.scrollHeight, baseHeight)}px`; + textarea.style.overflowY = "auto"; + }; + + useLayoutEffect(() => { + adjustHeight(); + }, [text]); + + useLayoutEffect(() => { + adjustHeight(); + }, [isVisible]); + + useLayoutEffect(() => { + if (textareaRef.current) { + textareaRef.current.focus(); + // ensure consistent initial height after focus + adjustHeight(); + } + }, []); + + const handleDivClick = (event: React.MouseEvent) => { + const target = event.target as HTMLElement; + if (target.closest("button")) return; + if (target.tagName === "TEXTAREA") return; + textareaRef.current?.focus(); + }; + + const send = () => { + if (inProgress) { + return; + } + const trimmed = text.trim(); + const payload = trimmed.length > 0 ? text : DEFAULT_PROMPT; + onSend(payload); + setText(""); + textareaRef.current?.focus(); + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "Enter" && !event.shiftKey && !isComposing) { + event.preventDefault(); + if (inProgress && !hideStopButton) { + onStop?.(); + } else { + send(); + } + } + }; + + return ( +
+
+