"use client"
import {
useEffect,
useState,
type CSSProperties,
type ComponentProps,
type ReactNode,
} from "react"
import { motion, useReducedMotion } from "motion/react"
import { cva } from "class-variance-authority"
import { MessageSquare } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"
import { cn } from "@/lib/utils"
import {
AgentChatProvider,
useAgentChat,
useAgentContext,
} from "./agent-chat-context"
import { AgentChatBody } from "./agent-chat-body"
import { useAgentChatPosition } from "./use-agent-chat-position"
import type { AgentChatMode } from "./types"
import type { AgentChatLabels } from "./config"
const agentChatTriggerVariants = cva("", {
variants: {
variant: {
fab: "fixed right-4 bottom-4 z-40 size-12 rounded-full shadow-lg",
button: "",
},
},
})
function AgentChatTrigger({
className,
variant = "fab",
...props
}: Omit<ComponentProps<typeof Button>, "variant"> & {
variant?: "fab" | "button"
}) {
const { open, setOpen, mode, labels } = useAgentContext()
if (variant === "fab" && (mode !== "floating" || open)) return null
return (
<Button
data-slot="agent-chat-trigger"
data-state={open ? "open" : "closed"}
data-mode={mode}
className={cn(agentChatTriggerVariants({ variant }), className)}
variant={variant === "fab" ? "default" : "outline"}
size={variant === "fab" ? "icon" : "sm"}
aria-label={open ? labels.closeTrigger : labels.openTrigger}
aria-expanded={open}
{...props}
onClick={(event) => {
props.onClick?.(event)
if (!event.defaultPrevented) setOpen(!open)
}}
>
<MessageSquare />
{variant === "button" ? labels.title : null}
</Button>
)
}
/** 从工作区发起一个上下文问题,仍使用 Provider 中的同一段会话。 */
function AgentChatSuggestion({
question,
children,
className,
...props
}: ComponentProps<typeof Button> & { question: string; children?: ReactNode }) {
const { chat, setOpen, updateScroll } = useAgentContext()
const busy = chat.status === "streaming" || chat.status === "submitted"
return (
<Button
data-slot="agent-chat-suggestion"
variant="link"
className={className}
{...props}
disabled={busy || props.disabled}
onClick={(event) => {
props.onClick?.(event)
if (event.defaultPrevented) return
setOpen(true)
updateScroll({ follow: true })
void chat.sendMessage({ text: question })
}}
>
{children ?? question}
</Button>
)
}
function AgentChatPanel({
className,
id,
draggable = true,
}: Pick<ComponentProps<"aside">, "className" | "id"> & {
/**
* 桌面浮动模式下是否允许拖动面板;小于 768px 的 Sheet 始终不可拖动
* @default true
*/
draggable?: boolean
}) {
const { mode, open, setOpen, title, labels } = useAgentContext()
const [mobile, setMobile] = useState(false)
const [width, setWidth] = useState(400)
const reduced = useReducedMotion()
const movable = mode === "floating" && !mobile && draggable
const { panelRef, position, headerProps, onMoveKeyDown, reset } =
useAgentChatPosition({
enabled: movable && open,
active: mode === "floating" && !mobile && open,
})
useEffect(() => {
const query = window.matchMedia("(max-width: 767px)")
const update = () => setMobile(query.matches)
update()
query.addEventListener("change", update)
return () => query.removeEventListener("change", update)
}, [])
if (!open) return null
if (mobile)
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent
id={id}
data-slot="agent-chat-panel"
data-mode={mode}
data-state="open"
side="bottom"
showCloseButton={false}
className={cn(
"h-[92dvh]! max-h-[92dvh] gap-0 rounded-t-2xl motion-reduce:transition-none",
className,
)}
>
<SheetTitle className="sr-only">{title}</SheetTitle>
<AgentChatBody />
</SheetContent>
</Sheet>
)
return (
<motion.aside
ref={panelRef}
data-draggable={movable || undefined}
id={id}
data-slot="agent-chat-panel"
data-state="open"
aria-label={title}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault()
setOpen(false)
}
}}
data-mode={mode}
initial={reduced ? false : { opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: reduced ? 0 : 0.2 }}
style={
{
"--agent-width": `${width}px`,
...(mode === "floating" && position
? {
left: position.x,
top: position.y,
right: "auto",
bottom: "auto",
}
: {}),
} as CSSProperties
}
className={cn(
"flex min-h-0 flex-col bg-background text-foreground data-[dragging]:cursor-grabbing data-[dragging]:select-none",
mode === "embedded"
? "relative h-full w-(--agent-width) shrink-0 border-l border-edge"
: "fixed right-4 bottom-4 z-40 h-[min(600px,calc(100dvh-2rem))] w-[400px] max-w-[calc(100vw-2rem)] overflow-hidden rounded-2xl border border-edge shadow-xl",
className,
)}
>
{mode === "embedded" ? (
<div
data-slot="agent-chat-resize-handle"
role="separator"
aria-label={labels.resize}
aria-orientation="vertical"
aria-valuemin={320}
aria-valuemax={640}
aria-valuenow={width}
tabIndex={0}
className="absolute inset-y-0 -left-1 w-2 cursor-col-resize touch-none rounded select-none focus-visible:bg-primary/20 focus-visible:outline-none"
onKeyDown={(event) => {
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
event.preventDefault()
setWidth((value) =>
Math.max(
320,
Math.min(640, value + (event.key === "ArrowLeft" ? 16 : -16)),
),
)
}
}}
onPointerDown={(event) => {
event.currentTarget.setPointerCapture(event.pointerId)
event.preventDefault()
}}
onPointerMove={(event) => {
if (event.currentTarget.hasPointerCapture(event.pointerId))
setWidth(
Math.max(
320,
Math.min(
640,
event.currentTarget.parentElement!.getBoundingClientRect()
.right - event.clientX,
),
),
)
}}
onPointerUp={(event) => {
event.currentTarget.releasePointerCapture(event.pointerId)
}}
/>
) : null}
<AgentChatBody
draggable={movable}
headerProps={headerProps}
onMoveKeyDown={onMoveKeyDown}
onResetPosition={reset}
/>
</motion.aside>
)
}
export {
AgentChatProvider,
AgentChatPanel,
AgentChatTrigger,
AgentChatSuggestion,
useAgentChat,
agentChatTriggerVariants,
}
export type { AgentChatMode, AgentChatLabels }
"use client"
import {
ArrowUpRight,
Command,
PanelRight,
SquareMousePointer,
} from "lucide-react"
import { SegmentedControl } from "@/components/ui/segmented-control/segmented-control"
import {
AgentChatPanel,
AgentChatProvider,
AgentChatTrigger,
AgentChatSuggestion,
useAgentChat,
type AgentChatMode,
} from "@/components/agent-chat/agent-chat"
import {
initialMessages,
scenarios,
} from "@/components/agent-chat/demo-data"
import { DEFAULT_LABELS as labels } from "@/components/agent-chat/config"
function AgentChatWorkspace() {
const { mode, setMode, setOpen } = useAgentChat()
return (
<div
data-slot="agent-chat-workspace"
className="flex h-dvh min-h-0 flex-col overflow-hidden bg-background text-foreground"
>
<a href="#agent-workspace" className="sr-only focus:not-sr-only">
{labels.skip}
</a>
<header className="flex h-16 shrink-0 items-center justify-between gap-3 border-b border-edge px-5">
<span className="flex items-center gap-2 text-sm font-medium">
<Command className="size-5" />
{labels.workspace}
<span className="hidden text-muted-foreground sm:inline">
{labels.workspacePath}
</span>
</span>
<AgentChatTrigger variant="button" />
</header>
<div className="flex min-h-0 flex-1">
<main
id="agent-workspace"
className="min-w-0 flex-1 overflow-y-auto p-5 md:p-10"
>
<div className="mx-auto flex max-w-2xl flex-col gap-8 py-4 md:py-10">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="size-1.5 rounded-full bg-primary" />
{labels.demoWorkspace}
</div>
<div>
<p className="mb-3 text-xs tracking-widest text-muted-foreground">
{labels.eyebrow}
</p>
<h1 className="text-3xl font-medium tracking-tight md:text-4xl">
{labels.workspaceTitle}
</h1>
<p className="mt-4 max-w-md text-sm leading-7 text-muted-foreground">
{labels.workspaceDescription}
</p>
</div>
<div className="flex flex-col gap-3">
{scenarios.map(({ icon: Icon, label, question, description }) => (
<article
key={label}
className="flex items-start gap-3 rounded-xl border border-edge p-4"
>
<Icon className="mt-1 size-5 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<h2 className="text-sm font-medium">{label}</h2>
<p className="mt-1 text-xs leading-6 text-muted-foreground">
{description}
</p>
<AgentChatSuggestion
question={question}
className="mt-1 h-auto max-w-full justify-start p-0 text-left whitespace-normal"
>
{question}
<ArrowUpRight
data-icon="inline-end"
className="shrink-0"
/>
</AgentChatSuggestion>
</div>
</article>
))}
</div>
{/* 布局切换用方条:选中哪一项就是当前布局,不用再单独写一行「当前:…」 */}
<SegmentedControl
label={labels.switchLayout}
size="sm"
className="max-w-sm"
options={[
{
value: "embedded",
label: labels.embeddedLayout,
icon: <PanelRight />,
},
{
value: "floating",
label: labels.floatingLayout,
icon: <SquareMousePointer />,
},
]}
value={mode}
onValueChange={(value) => {
setMode(value as AgentChatMode)
setOpen(true)
}}
/>
<p className="text-xs leading-6 text-muted-foreground">
{labels.demoNote}
</p>
</div>
</main>
<AgentChatPanel />
</div>
<AgentChatTrigger />
</div>
)
}
export default function AgentPage() {
return (
<AgentChatProvider
defaultMode="embedded"
defaultOpen
initialMessages={initialMessages}
>
<AgentChatWorkspace />
</AgentChatProvider>
)
}
"use client"
import { useLayoutEffect, useRef, useState, type ComponentProps } from "react"
import {
ArrowDown,
ArrowUp,
Bot,
GripHorizontal,
PanelRight,
Plus,
Square,
X,
} from "lucide-react"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea"
import { cn } from "@/lib/utils"
import { GlyphField } from "@/components/ui/glyph-field/glyph-field"
import { TextReveal } from "@/components/ui/text-animate/text-animate"
import { AgentChatMessage } from "./chat-message"
import { useAgentContext } from "./agent-chat-context"
function AgentChatBody({
draggable = false,
headerProps,
onMoveKeyDown,
onResetPosition,
}: {
draggable?: boolean
headerProps?: ComponentProps<"header">
onMoveKeyDown?: ComponentProps<"button">["onKeyDown"]
onResetPosition?: () => void
}) {
const {
labels,
chat,
title,
mode,
toggleMode,
setOpen,
draft,
setDraft,
suggestions,
scroll,
updateScroll,
} = useAgentContext()
const viewport = useRef<HTMLDivElement>(null)
const input = useRef<HTMLTextAreaElement>(null)
const [following, setFollowing] = useState(scroll.current.follow)
const busy = chat.status === "streaming" || chat.status === "submitted"
// 切换外壳或关闭再打开时恢复滚动;流式更新仅在用户位于底部时跟随。
useLayoutEffect(() => {
const node = viewport.current
if (node)
node.scrollTop = scroll.current.follow
? node.scrollHeight
: scroll.current.top
}, [mode, scroll])
useLayoutEffect(() => {
const node = viewport.current
if (node && scroll.current.follow) node.scrollTop = node.scrollHeight
}, [chat.messages, scroll])
useLayoutEffect(() => {
if (!input.current) return
input.current.style.height = "auto"
input.current.style.height = `${Math.min(input.current.scrollHeight, 160)}px`
}, [draft])
const send = (text: string) => {
if (!text.trim() || busy) return
updateScroll({ follow: true })
setFollowing(true)
setDraft("")
void chat.sendMessage({ text: text.trim() })
}
return (
<div
data-slot="agent-chat-body"
data-state={busy ? "streaming" : "idle"}
className="contents"
>
<header
data-slot="agent-chat-header"
{...headerProps}
className={cn(
"flex shrink-0 items-center gap-2 border-b border-edge px-3 py-3",
draggable &&
"cursor-grab touch-none select-none data-[dragging]:cursor-grabbing",
)}
>
{draggable ? (
<Button
data-slot="agent-chat-move-handle"
variant="ghost"
size="icon-sm"
aria-label={labels.moveWindow}
className="cursor-grab in-data-[dragging]:cursor-grabbing"
onKeyDown={onMoveKeyDown}
onClick={(event) => {
if (event.detail === 0) onResetPosition?.()
}}
>
<GripHorizontal aria-hidden="true" />
</Button>
) : null}
<Avatar className="size-8">
<AvatarFallback>
<Bot className="size-4" />
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<h2 className="truncate text-sm font-medium">{title}</h2>
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span
className={cn(
"size-1.5 rounded-full",
busy
? "bg-primary motion-safe:animate-pulse"
: "bg-muted-foreground",
)}
/>
{busy ? labels.thinking : labels.idle}
</p>
</div>
<Button
variant="ghost"
size="icon-sm"
aria-label={labels.newChat}
disabled={busy}
onClick={() => {
chat.setMessages([])
chat.clearError()
setDraft("")
updateScroll({ top: 0, follow: true })
}}
>
<Plus />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={mode === "embedded" ? labels.floatMode : labels.embedMode}
aria-pressed={mode === "embedded"}
onClick={toggleMode}
>
<PanelRight />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={labels.closePanel}
onClick={() => setOpen(false)}
>
<X />
</Button>
</header>
<div
ref={viewport}
role="log"
aria-label={labels.history}
aria-live="polite"
aria-relevant="additions"
className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-5"
onScroll={(event) => {
const node = event.currentTarget
const follow =
node.scrollHeight - node.scrollTop - node.clientHeight < 48
updateScroll({ top: node.scrollTop, follow })
setFollowing(follow)
}}
>
{chat.messages.length ? (
<div className="flex flex-col gap-7">
{chat.messages.map((message, index) => (
<AgentChatMessage
key={message.id}
message={message}
live={busy && index === chat.messages.length - 1}
busy={busy}
regenerate={() => {
updateScroll({ follow: true })
void chat.regenerate({ messageId: message.id })
}}
/>
))}
</div>
) : (
<div className="relative isolate flex h-full flex-col justify-center gap-5 py-6">
{/* 空状态的背景:只铺在四周的字符矩阵,中间留白给文字 */}
<GlyphField
fade="center"
tone="primary"
density={0.45}
fontSize={12}
speed={0.02}
className="absolute -inset-4 -z-10 min-h-0 bg-transparent opacity-20"
/>
<Bot className="size-8 text-primary" />
<div>
<p className="text-lg font-medium tracking-tight">
<TextReveal by="word" trigger="mount" stagger={60}>
{labels.welcome}
</TextReveal>
</p>
<p className="mt-2 text-sm leading-7 text-muted-foreground">
{labels.welcomeDescription}
</p>
</div>
<div className="flex flex-col gap-2">
{suggestions.map((question) => (
<Button
key={question}
variant="outline"
className="h-auto justify-start bg-card py-3 text-left whitespace-normal hover:bg-muted dark:bg-card dark:hover:bg-muted"
onClick={() => send(question)}
>
{question}
<ArrowUp
data-icon="inline-end"
className="ml-auto shrink-0"
/>
</Button>
))}
</div>
</div>
)}
</div>
{!following && chat.messages.length > 0 ? (
<Button
variant="secondary"
size="sm"
className="mx-auto mb-2"
onClick={() => {
updateScroll({ follow: true })
setFollowing(true)
if (viewport.current)
viewport.current.scrollTop = viewport.current.scrollHeight
}}
>
<ArrowDown />
{labels.latest}
</Button>
) : null}
{chat.error ? (
<div role="alert" className="px-4 pb-2 text-sm text-destructive">
{labels.error}
<Button
variant="ghost"
size="sm"
disabled={busy}
onClick={() => {
void chat.regenerate()
}}
>
{labels.retry}
</Button>
</div>
) : null}
<p className="sr-only" role="status" aria-live="polite">
{busy
? labels.generating
: chat.messages.length
? labels.updated
: labels.ready}
</p>
<form
className="flex shrink-0 flex-col gap-2 border-t border-edge p-3 pb-[max(0.75rem,env(safe-area-inset-bottom))]"
onSubmit={(event) => {
event.preventDefault()
send(draft)
}}
>
<Textarea
name="message"
autoComplete="off"
ref={input}
aria-label={labels.input}
placeholder={labels.placeholder}
value={draft}
maxLength={4000}
className="max-h-40 min-h-16 resize-none"
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (
event.key === "Enter" &&
!event.shiftKey &&
!event.nativeEvent.isComposing
) {
event.preventDefault()
send(draft)
}
}}
/>
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-muted-foreground">{labels.newline}</p>
{busy ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
void chat.stop()
}}
>
<Square />
{labels.stop}
</Button>
) : (
<Button
type="submit"
size="icon-sm"
aria-label={labels.send}
disabled={!draft.trim()}
>
<ArrowUp />
</Button>
)}
</div>
</form>
</div>
)
}
export { AgentChatBody }
"use client"
import { useLayoutEffect, useRef, useState, type ComponentProps } from "react"
type Position = { x: number; y: number }
const STORAGE_KEY = "travinsy:agent-chat:position"
const INTERACTIVE =
"button:not([data-slot='agent-chat-move-handle']), a, input, textarea, select, [role='button']"
function constrain(position: Position, panel: HTMLElement): Position {
return {
x: Math.max(0, Math.min(window.innerWidth - panel.offsetWidth, position.x)),
// 高度不足时仍保留整个标题栏,桌面常规尺寸下将整个窗口收回视口。
y: Math.max(
0,
Math.min(
window.innerHeight - Math.min(panel.offsetHeight, window.innerHeight),
position.y,
),
),
}
}
function defaultPosition(panel: HTMLElement): Position {
return constrain(
{
x: window.innerWidth - panel.offsetWidth - 16,
y: window.innerHeight - panel.offsetHeight - 16,
},
panel,
)
}
function writePosition(panel: HTMLElement, position: Position) {
panel.style.left = `${position.x}px`
panel.style.top = `${position.y}px`
panel.style.right = "auto"
panel.style.bottom = "auto"
}
function storePosition(position: Position | null) {
try {
if (position) localStorage.setItem(STORAGE_KEY, JSON.stringify(position))
else localStorage.removeItem(STORAGE_KEY)
} catch {
// 浏览器禁止存储时,窗口在当前会话中仍可正常移动。
}
}
function useAgentChatPosition({
enabled,
active,
}: {
enabled: boolean
active: boolean
}) {
const panelRef = useRef<HTMLElement>(null)
const current = useRef<Position | null>(null)
const restored = useRef(false)
const [position, setPosition] = useState<Position | null>(null)
const drag = useRef<{
pointerId: number
handle: HTMLElement
start: Position
origin: Position
} | null>(null)
function commit(next: Position | null) {
current.current = next
setPosition(next)
storePosition(next)
}
function finish() {
const session = drag.current
if (!session) return
drag.current = null
panelRef.current?.removeAttribute("data-dragging")
session.handle.removeAttribute("data-dragging")
if (session.handle.hasPointerCapture(session.pointerId))
session.handle.releasePointerCapture(session.pointerId)
commit(current.current)
}
useLayoutEffect(() => {
if (!active) return
const panel = panelRef.current
if (!panel) return
if (!restored.current) {
restored.current = true
try {
const saved: unknown = JSON.parse(
localStorage.getItem(STORAGE_KEY) ?? "null",
)
if (
saved &&
typeof saved === "object" &&
"x" in saved &&
"y" in saved &&
typeof saved.x === "number" &&
Number.isFinite(saved.x) &&
typeof saved.y === "number" &&
Number.isFinite(saved.y)
)
current.current = { x: saved.x, y: saved.y }
} catch {
// 损坏或不可读的缓存回退到默认右下角。
}
}
const update = () => {
const next = current.current ? constrain(current.current, panel) : null
writePosition(panel, next ?? defaultPosition(panel))
if (next) {
current.current = next
setPosition(next)
storePosition(next)
}
}
update()
window.addEventListener("resize", update)
const observer = new ResizeObserver(update)
observer.observe(panel)
return () => {
window.removeEventListener("resize", update)
observer.disconnect()
for (const property of ["left", "top", "right", "bottom"])
panel.style.removeProperty(property)
}
}, [active])
useLayoutEffect(() => {
if (!enabled) finish()
return () => finish()
// finish 只读取 ref;流式消息更新不能解除正在进行的指针捕获。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled])
function reset() {
const panel = panelRef.current
if (!enabled || !panel) return
finish()
// 复位直接定位,无动画,同样适用于 prefers-reduced-motion。
writePosition(panel, defaultPosition(panel))
commit(null)
}
const headerProps: ComponentProps<"header"> = enabled
? {
onPointerDown(event) {
if (
!event.isPrimary ||
event.button !== 0 ||
drag.current ||
(event.target as Element).closest(INTERACTIVE)
)
return
const panel = panelRef.current
if (!panel) return
event.preventDefault()
if (
(event.target as Element).closest(
"[data-slot='agent-chat-move-handle']",
)
)
(event.target as Element).closest<HTMLElement>("button")?.focus()
const origin = current.current ?? defaultPosition(panel)
drag.current = {
pointerId: event.pointerId,
handle: event.currentTarget,
start: { x: event.clientX, y: event.clientY },
origin,
}
current.current = origin
event.currentTarget.setPointerCapture(event.pointerId)
event.currentTarget.setAttribute("data-dragging", "true")
panel.setAttribute("data-dragging", "true")
},
onPointerMove(event) {
const session = drag.current
const panel = panelRef.current
if (!session || !panel || session.pointerId !== event.pointerId)
return
const next = constrain(
{
x: session.origin.x + event.clientX - session.start.x,
y: session.origin.y + event.clientY - session.start.y,
},
panel,
)
current.current = next
// 不提交 React state,避免每帧重渲染消息和输入框。
writePosition(panel, next)
},
onPointerUp(event) {
if (drag.current?.pointerId === event.pointerId) finish()
},
onPointerCancel(event) {
if (drag.current?.pointerId === event.pointerId) finish()
},
onLostPointerCapture(event) {
if (drag.current?.pointerId === event.pointerId) finish()
},
onDoubleClick(event) {
if (!(event.target as Element).closest(INTERACTIVE)) reset()
},
}
: {}
function onMoveKeyDown(event: React.KeyboardEvent<HTMLButtonElement>) {
if (!enabled || event.altKey || event.ctrlKey || event.metaKey) return
if (event.key === "Home") {
event.preventDefault()
reset()
return
}
const direction = {
ArrowLeft: [-1, 0],
ArrowRight: [1, 0],
ArrowUp: [0, -1],
ArrowDown: [0, 1],
}[event.key]
const panel = panelRef.current
if (!direction || !panel) return
event.preventDefault()
finish()
const start = current.current ?? defaultPosition(panel)
const step = event.shiftKey ? 64 : 16
const next = constrain(
{ x: start.x + direction[0] * step, y: start.y + direction[1] * step },
panel,
)
writePosition(panel, next)
commit(next)
}
return { panelRef, position, headerProps, onMoveKeyDown, reset }
}
export { useAgentChatPosition }
"use client"
import {
createContext,
useContext,
useRef,
useState,
type ReactNode,
} from "react"
import { useChat } from "@ai-sdk/react"
import type { ChatTransport, UIMessage } from "ai"
import { useControllableState } from "@/hooks/use-controllable-state"
import {
DEFAULT_LABELS,
agentSuggestions,
type AgentChatLabels,
} from "./config"
import type { AgentChatMode } from "./types"
import { createMockTransport } from "./mock-transport"
type ChatState = ReturnType<typeof useChat<UIMessage>>
type AgentContextValue = {
mode: AgentChatMode
setMode: (mode: AgentChatMode) => void
toggleMode: () => void
open: boolean
setOpen: (open: boolean) => void
chat: ChatState
title: string
labels: AgentChatLabels
suggestions: string[]
draft: string
setDraft: (draft: string) => void
scroll: { current: { top: number; follow: boolean } }
updateScroll: (value: Partial<{ top: number; follow: boolean }>) => void
}
const AgentContext = createContext<AgentContextValue | null>(null)
function useAgentContext() {
const context = useContext(AgentContext)
if (!context) throw new Error("请在 AgentChatProvider 内使用助手组件")
return context
}
function useAgentChat() {
const { mode, setMode, toggleMode, open, setOpen } = useAgentContext()
return { mode, setMode, toggleMode, open, setOpen }
}
function AgentChatProvider({
children,
mode: controlledMode,
onModeChange,
defaultMode = "floating",
open: controlledOpen,
onOpenChange,
defaultOpen = false,
transport,
suggestions = agentSuggestions,
title,
labels: labelOverrides,
initialMessages,
}: {
children: ReactNode
mode?: AgentChatMode
onModeChange?: (mode: AgentChatMode) => void
defaultMode?: AgentChatMode
open?: boolean
onOpenChange?: (open: boolean) => void
labels?: Partial<AgentChatLabels>
defaultOpen?: boolean
transport?: ChatTransport<UIMessage>
suggestions?: string[]
title?: string
initialMessages?: UIMessage[]
}) {
const [mode, setMode] = useControllableState({
prop: controlledMode,
defaultProp: defaultMode,
onChange: onModeChange,
})
const [open, setOpen] = useControllableState({
prop: controlledOpen,
defaultProp: defaultOpen,
onChange: onOpenChange,
})
const labels = { ...DEFAULT_LABELS, ...labelOverrides }
const [draft, setDraft] = useState("")
const [mock] = useState(createMockTransport)
const scroll = useRef({ top: 0, follow: true })
const updateScroll = (value: Partial<{ top: number; follow: boolean }>) => {
scroll.current = { ...scroll.current, ...value }
}
const chat = useChat<UIMessage>({
transport: transport ?? mock,
messages: initialMessages,
})
return (
<AgentContext.Provider
value={{
mode,
setMode,
toggleMode: () =>
setMode((value) => (value === "embedded" ? "floating" : "embedded")),
open,
setOpen,
chat,
title: title ?? labels.title,
labels,
suggestions,
draft,
setDraft,
scroll,
updateScroll,
}}
>
{children}
</AgentContext.Provider>
)
}
export { AgentChatProvider, useAgentChat, useAgentContext }
"use client"
import { AgentChatChart } from "./chat-charts"
import { AgentChatMarkdown } from "./chat-markdown"
import { useAgentContext } from "./agent-chat-context"
import type { ChatChart } from "./types"
import { useEffect, useState } from "react"
import { isToolUIPart, getToolName, type UIMessage } from "ai"
import {
Brain,
Check,
Copy,
ExternalLink,
LoaderCircle,
RotateCcw,
ThumbsDown,
ThumbsUp,
X,
} from "lucide-react"
import { Bubble, BubbleContent } from "@/components/ui/bubble"
import { Button } from "@/components/ui/button"
import { CodeBlock, CodeBlockContent } from "@/components/ui/code-block"
import { Message, MessageContent, MessageFooter } from "@/components/ui/message"
import {
Trace,
TraceCollapsible,
TraceCollapsibleContent,
TraceCollapsibleTrigger,
TraceIcon,
TraceLine,
TraceTitle,
} from "@/components/ui/trace"
type MessagePart = UIMessage["parts"][number]
/** 是否属于分析过程里的一行:分析摘要、工具调用、可打开的来源(图表块不算)。 */
function isTracePart(part: MessagePart) {
if (part.type === "reasoning" || isToolUIPart(part)) return true
return part.type === "source-url" && safeSource(part.url) !== undefined
}
function messageText(message: UIMessage) {
return message.parts
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("")
}
function AgentChatReasoning({
text,
running,
interrupted,
}: {
text: string
running: boolean
interrupted: boolean
}) {
const { labels } = useAgentContext()
const [seconds, setSeconds] = useState(0)
useEffect(() => {
if (!running) return
const started = Date.now()
const timer = setInterval(
() => setSeconds((Date.now() - started) / 1000),
100,
)
return () => clearInterval(timer)
}, [running])
return (
<TraceCollapsible data-slot="agent-chat-reasoning">
<TraceCollapsibleTrigger>
<TraceLine status={running ? "running" : undefined}>
<TraceIcon>
{running ? (
<LoaderCircle className="animate-spin motion-reduce:animate-none" />
) : (
<Brain />
)}
</TraceIcon>
<TraceTitle>
{running
? labels.thinking
: interrupted
? labels.thinkingStopped
: seconds === 0
? labels.reasoningSummary
: labels.thought.replace(
"{seconds}",
new Intl.NumberFormat(undefined, {
maximumFractionDigits: 1,
}).format(seconds),
)}
</TraceTitle>
</TraceLine>
</TraceCollapsibleTrigger>
<TraceCollapsibleContent>
<p className="text-sm leading-7">{text || labels.preparing}</p>
</TraceCollapsibleContent>
</TraceCollapsible>
)
}
function safeSource(url: string) {
try {
const parsed = new URL(url)
return ["https:", "http:"].includes(parsed.protocol)
? parsed.href
: undefined
} catch {
return undefined
}
}
/** 回答里的图表块:流式接口以 `data-chart` 数据块下发。 */
function messageCharts(message: UIMessage) {
return message.parts.flatMap((part, index) =>
part.type === "data-chart"
? [{ id: part.id ?? String(index), chart: part.data as ChatChart }]
: [],
)
}
function AgentChatMessage({
message,
live,
busy,
regenerate,
}: {
message: UIMessage
live: boolean
busy: boolean
regenerate: () => void
}) {
const { labels } = useAgentContext()
const [feedback, setFeedback] = useState<"up" | "down" | null>(null)
const [notice, setNotice] = useState("")
const text = messageText(message)
const charts = messageCharts(message)
if (message.role === "user")
return (
<Message data-slot="agent-chat-message" data-role="user" align="end">
<MessageContent>
<Bubble align="end" variant="muted">
<BubbleContent>{text}</BubbleContent>
</Bubble>
</MessageContent>
</Message>
)
return (
<Message
data-slot="agent-chat-message"
data-role="assistant"
data-state={live ? "streaming" : "idle"}
>
<MessageContent className="gap-4">
{message.parts.some(isTracePart) ? (
<Trace>
{message.parts.map((part, index) => {
if (part.type === "reasoning")
return (
<AgentChatReasoning
key={index}
text={part.text}
running={live && part.state === "streaming"}
interrupted={!live && part.state === "streaming"}
/>
)
if (isToolUIPart(part)) {
const done = part.state === "output-available"
const failed =
part.state === "output-error" ||
part.state === "output-denied"
const running = live && !done && !failed
const label = `${running ? labels.running : done ? labels.completed : failed ? labels.failed : labels.stopped} · ${getToolName(part)}`
return (
<TraceCollapsible key={part.toolCallId}>
<TraceCollapsibleTrigger>
<TraceLine
status={
running ? "running" : failed ? "error" : undefined
}
>
<TraceIcon>
{running ? (
<LoaderCircle className="animate-spin motion-reduce:animate-none" />
) : done ? (
<Check />
) : (
<X />
)}
</TraceIcon>
<TraceTitle>{label}</TraceTitle>
</TraceLine>
</TraceCollapsibleTrigger>
<TraceCollapsibleContent>
<div className="flex flex-col gap-2">
<p className="text-xs">{labels.toolInput}</p>
<CodeBlock size="sm">
<CodeBlockContent wrap>
{JSON.stringify(part.input ?? {}, null, 2)}
</CodeBlockContent>
</CodeBlock>
<p className="text-xs">{labels.toolOutput}</p>
<CodeBlock
size="sm"
variant={failed ? "destructive" : "default"}
>
<CodeBlockContent wrap>
{part.state === "output-error"
? part.errorText
: done
? JSON.stringify(part.output, null, 2)
: part.state === "output-denied"
? labels.denied
: running
? labels.waiting
: labels.incomplete}
</CodeBlockContent>
</CodeBlock>
</div>
</TraceCollapsibleContent>
</TraceCollapsible>
)
}
if (part.type === "source-url") {
const href = safeSource(part.url)
return href ? (
<a
key={part.sourceId}
href={href}
target="_blank"
rel="noopener noreferrer"
>
<TraceLine>
<TraceIcon>
<ExternalLink />
</TraceIcon>
<TraceTitle>
{part.title ?? new URL(href).hostname}
<span className="sr-only">{labels.newTab}</span>
</TraceTitle>
</TraceLine>
</a>
) : null
}
return null
})}
</Trace>
) : null}
{charts.map(({ id, chart }) => (
<AgentChatChart key={id} chart={chart} />
))}
{text ? (
<Bubble variant="ghost">
<BubbleContent>
<AgentChatMarkdown text={text} />
</BubbleContent>
</Bubble>
) : null}
{!live ? (
<MessageFooter className="flex-wrap gap-1">
<Button
variant="ghost"
size="icon-sm"
aria-label={labels.copy}
disabled={!text}
onClick={async () => {
try {
await navigator.clipboard.writeText(text)
setNotice(labels.copied)
} catch {
setNotice(labels.copyFailed)
}
}}
>
{notice === labels.copied ? <Check /> : <Copy />}
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={labels.useful}
aria-pressed={feedback === "up"}
onClick={() => {
setFeedback(feedback === "up" ? null : "up")
setNotice(labels.feedback)
}}
>
<ThumbsUp />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={labels.notUseful}
aria-pressed={feedback === "down"}
onClick={() => {
setFeedback(feedback === "down" ? null : "down")
setNotice(labels.feedback)
}}
>
<ThumbsDown />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={labels.regenerate}
disabled={busy}
onClick={regenerate}
>
<RotateCcw />
</Button>
<span role="status" className="text-xs">
{notice}
</span>
</MessageFooter>
) : null}
</MessageContent>
</Message>
)
}
export { AgentChatMessage, messageText }
"use client"
import {
ChartCard,
ChartCardContent,
ChartCardDescription,
ChartCardHeader,
ChartCardTitle,
ChartMetric,
ChartMetrics,
} from "@/components/ui/chart-card/chart-card"
import { MiniChart, MiniRing } from "@/components/ui/mini-chart/mini-chart"
import type { ChatChart } from "./types"
/**
* 回答里的图表卡片。刻意做得紧凑:内边距 12px、图表只有 96px 高,
* 宽度跟随助手面板(嵌入约 400px、浮动窗口更窄),和折叠的分析过程放在一起不会把消息撑得很长。
*/
function AgentChatChart({ chart }: { chart: ChatChart }) {
return (
<ChartCard data-slot="agent-chat-chart" data-kind={chart.kind}>
{/* 头部在面板里始终上下排布,所以覆盖掉卡片默认的 sm 以上左右排布 */}
<ChartCardHeader className="gap-0.5 p-3 sm:flex-col sm:items-stretch">
<ChartCardTitle>{chart.title}</ChartCardTitle>
{chart.description ? (
<ChartCardDescription className="text-xs">
{chart.description}
</ChartCardDescription>
) : null}
</ChartCardHeader>
<ChartCardContent className="flex flex-col gap-3 p-3">
{chart.kind === "series" ? (
<>
{chart.metrics?.length ? (
<ChartMetrics>
{chart.metrics.map((metric) => (
<ChartMetric
key={metric.label}
label={metric.label}
value={metric.value}
tone={metric.tone}
/>
))}
</ChartMetrics>
) : null}
<MiniChart
type={chart.type ?? "area"}
data={chart.data}
tone={chart.tone}
format={chart.format}
highlight={chart.highlight}
seriesLabel={chart.seriesLabel}
label={chart.label}
showAxis
className="h-24"
/>
</>
) : (
<ul aria-label={chart.label} className="grid grid-cols-3 gap-2">
{chart.items.map((item) => (
<li
key={item.label}
className="flex min-w-0 flex-col items-center gap-1.5"
>
<MiniRing
value={item.value}
max={item.max}
tone={item.tone}
arc={270}
size={64}
thickness={9}
label={`${item.label} ${item.display}`}
>
<span className="text-xs font-semibold">{item.display}</span>
</MiniRing>
<span className="max-w-full truncate text-xs text-muted-foreground">
{item.label}
</span>
</li>
))}
</ul>
)}
</ChartCardContent>
</ChartCard>
)
}
export { AgentChatChart }
import type { ReactNode } from "react"
// 仅解析段落、列表和行内代码;React 自动转义文本,不解释 HTML。
function inline(text: string): ReactNode {
return text.split(/(`[^`]+`)/g).map((part, index) =>
part.startsWith("`") && part.endsWith("`") ? (
<code
key={index}
className="rounded bg-muted px-1 py-0.5 font-mono text-xs"
>
{part.slice(1, -1)}
</code>
) : (
part
),
)
}
function AgentChatMarkdown({ text }: { text: string }) {
return (
<div
data-slot="agent-chat-markdown"
className="flex flex-col gap-3 leading-7"
>
{text.split(/\n\n+/).map((paragraph, index) => {
const lines = paragraph.split("\n")
if (lines.every((line) => /^[-*] /.test(line)))
return (
<ul key={index} className="flex list-disc flex-col gap-1 pl-5">
{lines.map((line, i) => (
<li key={i}>{inline(line.slice(2))}</li>
))}
</ul>
)
if (lines.every((line) => /^\d+\. /.test(line)))
return (
<ol key={index} className="flex list-decimal flex-col gap-1 pl-5">
{lines.map((line, i) => (
<li key={i}>{inline(line.replace(/^\d+\. /, ""))}</li>
))}
</ol>
)
return (
<p key={index} className="whitespace-pre-wrap">
{inline(paragraph)}
</p>
)
})}
</div>
)
}
export { AgentChatMarkdown }
import type { ChatTransport, UIMessage, UIMessageChunk } from "ai"
import { scripts, fallback } from "./scripts"
function createMockTransport(): ChatTransport<UIMessage> {
return {
async sendMessages({ messages, abortSignal }) {
const question =
messages
.findLast((message) => message.role === "user")
?.parts.filter((part) => part.type === "text")
.map((part) => part.text)
.join("") ?? ""
const script =
scripts.find((item) => item.match.test(question)) ?? fallback
const queue: { chunk: UIMessageChunk; delay: number }[] = []
const push = (chunk: UIMessageChunk, delay = 80) =>
queue.push({ chunk, delay })
push({ type: "start", messageId: crypto.randomUUID() }, 0)
push({ type: "start-step" }, 0)
push({ type: "reasoning-start", id: "reasoning" })
for (const delta of script.reasoning.match(/.{1,8}/gu) ?? [])
push({ type: "reasoning-delta", id: "reasoning", delta }, 130)
push({ type: "reasoning-end", id: "reasoning" })
for (const [index, tool] of script.tools.entries()) {
const toolCallId = `tool-${index}`
push({
type: "tool-input-available",
toolCallId,
toolName: tool.name,
input: tool.input,
dynamic: true,
})
push(
tool.error
? {
type: "tool-output-error",
toolCallId,
errorText: tool.error,
dynamic: true,
}
: {
type: "tool-output-available",
toolCallId,
output: tool.output,
dynamic: true,
},
800,
)
}
if (script.chart) push({ type: "data-chart", data: script.chart }, 400)
push({ type: "source-url", sourceId: "reference", ...script.source })
push({ type: "text-start", id: "answer" })
for (const delta of script.answer.match(/.{1,6}|\n/gu) ?? [])
push({ type: "text-delta", id: "answer", delta }, 35)
push({ type: "text-end", id: "answer" })
push({ type: "finish-step" }, 0)
push({ type: "finish", finishReason: "stop" }, 0)
let timer: ReturnType<typeof setTimeout> | undefined
let finish: (() => void) | undefined
let ended = false
const cleanup = () => {
ended = true
clearTimeout(timer)
finish?.()
abortSignal?.removeEventListener("abort", abort)
}
let controller: ReadableStreamDefaultController<UIMessageChunk>
const abort = () => {
if (ended) return
cleanup()
controller.error(new DOMException("生成已停止", "AbortError"))
}
return new ReadableStream<UIMessageChunk>({
start(value) {
controller = value
abortSignal?.addEventListener("abort", abort, { once: true })
if (abortSignal?.aborted) abort()
},
async pull(value) {
if (ended) return
const next = queue.shift()
if (!next) {
cleanup()
value.close()
return
}
await new Promise<void>((resolve) => {
finish = resolve
timer = setTimeout(resolve, next.delay)
})
if (!ended) value.enqueue(next.chunk)
},
cancel() {
cleanup()
},
})
},
async reconnectToStream() {
return null
},
}
}
export { createMockTransport }
import type { ChatChart } from "./types"
type Script = {
match: RegExp
reasoning: string
tools: {
name: string
input: Record<string, string>
output?: unknown
error?: string
}[]
source: { title: string; url: string }
/** 工具返回后出现在分析过程与回答之间的图表,尺寸克制,保持消息紧凑 */
chart?: ChatChart
answer: string
}
// 所有数据均为演示数据,不会查询或操作真实服务。reasoning 是预写的分析摘要。
const scripts: Script[] = [
{
match: /API|可用率|监控/i,
reasoning: "先对比可用率与错误分布,再核对最近一次发布,缩小排查范围。",
tools: [
{
name: "查询可用率",
input: { service: "api-gateway", window: "15m" },
output: {
availability: "97.8%",
baseline: "99.95%",
status: "演示数据",
},
},
{
name: "关联错误与发布",
input: { service: "api-gateway", groupBy: "upstream" },
output: {
upstream: "orders",
errors: "连接池超时",
deployment: "v2.8.1",
correlation: "发布后 3 分钟开始升高",
},
},
],
source: {
title: "Prometheus 查询基础",
url: "https://prometheus.io/docs/prometheus/latest/querying/basics/",
},
chart: {
kind: "series",
title: "api-gateway 可用率",
description: "近 15 分钟 · 演示数据",
label:
"api-gateway 近 15 分钟可用率走势,14:05 发布后从 99.9% 降到 97.8%",
seriesLabel: "可用率",
type: "area",
tone: "trend",
format: { style: "percent", digits: 2 },
metrics: [
{ label: "当前", value: "97.80%", tone: "rose" },
{ label: "基线", value: "99.95%", tone: "green" },
{ label: "发布", value: "14:05 v2.8.1" },
],
data: [
99.96, 99.95, 99.97, 99.95, 99.94, 99.2, 98.4, 97.9, 97.6, 97.7, 97.9,
97.8, 97.7, 97.8, 97.8,
].map((value, index) => ({
label: `14:${String(index).padStart(2, "0")}`,
value,
})),
},
answer:
"可用率下降与 `orders` 上游的连接池超时相关。演示窗口内可用率从 99.95% 降到 97.8%,错误集中在 `v2.8.1` 发布之后。\n\n- 先对照发布前后的连接池配置与慢查询。\n- 检查等待连接数、请求并发和数据库延迟。\n- 若错误持续扩大,按你的发布流程评估回滚。\n\n这些是模拟观测结果,尚未执行任何变更。",
},
{
match: /vllm|显存|推理|GPU/i,
reasoning:
"先读取 GPU 显存快照,再对照 KV cache 与并发配置,区分预分配与实际压力。",
tools: [
{
name: "读取 GPU 快照",
input: {
command:
"nvidia-smi --query-gpu=memory.used,memory.total --format=csv",
},
output: {
gpu: "A100 80GB",
used: "72 GiB",
utilization: "61%",
status: "演示数据",
},
},
{
name: "检查 vLLM 配置",
input: {
service: "inference",
metrics: "kv_cache_usage,requests_waiting",
},
output: {
gpu_memory_utilization: 0.9,
kv_cache_usage: "84%",
requests_waiting: 12,
max_model_len: 32768,
},
},
],
source: {
title: "vLLM 性能与调优",
url: "https://docs.vllm.ai/en/latest/configuration/optimization/",
},
chart: {
kind: "rings",
title: "A100 80GB 资源占用",
description: "显存、KV cache 与算力 · 演示数据",
label: "显存已用 90%,KV cache 已用 84%,GPU 利用率 61%",
items: [
{ label: "显存 72/80G", value: 90, display: "90%", tone: "blue" },
{ label: "KV cache", value: 84, display: "84%", tone: "amber" },
{ label: "GPU 利用率", value: 61, display: "61%", tone: "green" },
],
},
answer:
"当前演示服务使用约 `72 / 80 GiB` 显存。`gpu_memory_utilization=0.9` 会预留大部分显存,因此高占用本身不等于泄漏。\n\n- KV cache 已用 84%,同时有 12 个请求排队,建议观察峰值并发。\n- 若长上下文并非必需,可评估降低 `max_model_len`。\n- 调整前记录吞吐、首 token 延迟与 OOM 次数,并做压测对比。\n\n我只展示了模拟检查结果,没有修改推理服务。",
},
{
match: /pnpm|CI|install|前端/i,
reasoning:
"先查看 CI 安装日志,再检查 lockfile 和包管理器版本是否与本地一致。",
tools: [
{
name: "读取 CI 安装日志",
input: {
job: "frontend-build",
step: "pnpm install --frozen-lockfile",
},
error:
"ERR_PNPM_OUTDATED_LOCKFILE:package.json 与 pnpm-lock.yaml 不一致(模拟失败)",
},
{
name: "核对包管理器",
input: { files: "package.json, pnpm-lock.yaml" },
output: {
local: "pnpm 10",
ci: "pnpm 9",
lockfile: "缺少新增依赖项",
status: "演示数据",
},
},
],
source: { title: "pnpm install 文档", url: "https://pnpm.io/cli/install" },
chart: {
kind: "series",
title: "frontend-build 各步骤耗时",
description: "上一次成功运行 · 单位:秒 · 演示数据",
label: "上一次成功运行的各步骤耗时,安装依赖最长,为 94 秒",
seriesLabel: "耗时(秒)",
type: "bar",
tone: "blue",
highlight: "max",
format: { digits: 0 },
metrics: [
{ label: "本次安装", value: "失败 · 3 秒", tone: "rose" },
{ label: "上次总耗时", value: "238 秒" },
],
data: [
{ label: "安装", value: 94 },
{ label: "构建", value: 71 },
{ label: "类型", value: 38 },
{ label: "测试", value: 26 },
{ label: "检查", value: 9 },
],
},
answer:
"这次模拟失败来自锁文件不同步:`--frozen-lockfile` 检测到依赖声明发生变化,CI 与本地 pnpm 版本也不一致。\n\n- 在 `packageManager` 中固定团队使用的 pnpm 版本。\n- 使用相同版本在本地运行 `pnpm install`,检查并提交锁文件差异。\n- 让 CI 使用固定版本,然后重新运行安装任务。\n\n保留 `--frozen-lockfile`,有助于保证安装可复现。",
},
]
const fallback: Script = {
match: /.*/,
reasoning: "先明确问题范围,检查已有上下文,再整理可验证的下一步。",
tools: [
{
name: "读取工作区摘要",
input: { scope: "demo" },
output: {
services: ["应用监控", "模型部署", "前端工程"],
mode: "离线演示",
},
},
{
name: "检查上下文",
input: { source: "当前问题" },
output: {
missing: ["环境", "错误日志", "发生时间"],
status: "需要更多信息",
},
},
],
source: { title: "AI SDK 文档", url: "https://ai-sdk.dev/docs/introduction" },
answer:
"我可以帮你排查应用监控、模型部署和前端工程问题。当前使用离线演示数据。\n\n- 请补充问题发生的环境与时间。\n- 提供经过脱敏的错误日志或配置片段。\n- 说明预期行为与实际结果,便于逐项验证。",
}
export { scripts, fallback }
const DEFAULT_LABELS = {
title: "AI 助手",
closeTrigger: "关闭 AI 助手",
openTrigger: "打开 AI 助手",
thinking: "思考中",
idle: "空闲 · 随时为你解答",
newChat: "新建对话",
floatMode: "切换为浮动模式",
embedMode: "切换为嵌入模式",
closePanel: "关闭助手面板",
history: "聊天记录",
welcome: "一起找到问题的线索",
welcomeDescription: "从一次异常、一个部署问题,或一段构建日志开始。",
latest: "回到最新消息",
error: "生成失败,请检查连接后重试。",
retry: "重试",
generating: "助手正在生成回答",
updated: "回答已更新,可在聊天记录中阅读",
ready: "助手已就绪",
input: "向 AI 助手提问",
placeholder: "描述你的问题…",
newline: "Shift + Enter 换行",
stop: "停止",
send: "发送问题",
moveWindow: "移动助手窗口",
resize: "调整助手面板宽度",
copy: "复制回答",
copied: "回答已复制",
copyFailed: "复制失败,请手动选择文本复制",
useful: "回答有用",
notUseful: "回答无用",
feedback: "反馈已更新,仅保存在当前页面",
regenerate: "重新生成回答",
reasoningSummary: "分析摘要",
thinkingStopped: "思考已停止",
preparing: "正在准备分析摘要…",
running: "运行中",
completed: "已完成",
failed: "失败",
stopped: "已停止",
toolInput: "输入",
toolOutput: "输出",
denied: "调用被拒绝",
waiting: "等待工具返回…",
incomplete: "调用未完成",
newTab: "(在新标签页打开)",
thought: "已思考 {seconds} 秒",
workspace: "工作台",
workspacePath: "/ 智能协作",
demoWorkspace: "离线演示工作区",
eyebrow: "从问题到线索",
workspaceTitle: "让排查有迹可循。",
workspaceDescription:
"把助手放在工作流旁边。查看分析摘要、展开工具结果,再决定下一步。",
embeddedLayout: "嵌入布局",
floatingLayout: "浮动窗口",
switchLayout: "切换布局",
demoNote: "示例数据不连接真实服务。反馈仅保存在当前页面。",
skip: "跳到工作区",
}
type AgentChatLabels = { [K in keyof typeof DEFAULT_LABELS]: string }
const agentSuggestions = [
"为什么 API 可用率下降了?",
"帮我检查 vLLM 推理服务的显存占用",
"pnpm install 在 CI 里失败",
]
export { DEFAULT_LABELS, agentSuggestions }
export type { AgentChatLabels }
import type { ChartMetricProps } from "@/components/ui/chart-card/chart-card"
import type {
MiniChartProps,
MiniRingProps,
} from "@/components/ui/mini-chart/mini-chart"
type AgentChatMode = "embedded" | "floating"
/** 图表块的公共字段:卡片标题、说明与读屏名称。 */
type ChatChartBase = {
title: string
description?: string
/** 读屏名称,说清楚这张图表示什么 */
label: string
}
/**
* 助手回答里的图表块,以 `data-chart` 数据块的形式出现在消息里。
* 内容必须能被 JSON 序列化(不放函数),这样既能来自流式接口,也能随历史消息保存。
*/
type ChatChart =
| (ChatChartBase & {
/** 走势 / 分布:面积、折线、阶梯线或柱图,上方可带一行指标 */
kind: "series"
type?: MiniChartProps["type"]
data: { label: string; value: number }[]
seriesLabel: string
tone?: MiniChartProps["tone"]
format?: MiniChartProps["format"]
highlight?: MiniChartProps["highlight"]
metrics?: {
label: string
value: string
tone?: ChartMetricProps["tone"]
}[]
})
| (ChatChartBase & {
/** 几项占用率并排:每项一个进度环 */
kind: "rings"
items: {
label: string
value: number
max?: number
/** 环中央的文字,如「90%」 */
display: string
tone?: MiniRingProps["tone"]
}[]
})
export type { AgentChatMode, ChatChart }
import type { UIMessage } from "ai"
import { Activity, Layers, Server } from "lucide-react"
const initialMessages: UIMessage[] = [
{
id: "welcome-question",
role: "user",
parts: [{ type: "text", text: "这个工作区可以帮我做什么?" }],
},
{
id: "welcome-answer",
role: "assistant",
parts: [
{
type: "reasoning",
text: "根据工作区提供的三个演示场景,整理可以验证的问题方向。",
state: "done",
},
{
type: "dynamic-tool",
toolName: "读取工作区摘要",
toolCallId: "welcome-tool",
state: "output-available",
input: { scope: "demo" },
output: {
areas: ["应用监控", "模型部署", "前端工程"],
mode: "离线演示",
},
},
{
type: "text",
text: "我已经准备好了。你可以从以下问题开始:\n\n- 为什么 API 可用率下降了?\n- 帮我检查 vLLM 推理服务的显存占用。\n- pnpm install 在 CI 里失败。\n\n点击左侧的场景,或直接输入问题。我会逐步展示检查过程、工具结果与参考来源。",
},
],
},
]
const scenarios = [
{
icon: Activity,
label: "应用监控",
question: "为什么 API 可用率下降了?",
description: "关联错误、上游与最近一次发布",
},
{
icon: Server,
label: "模型部署",
question: "帮我检查 vLLM 推理服务的显存占用",
description: "查看 GPU、KV cache 与并发配置",
},
{
icon: Layers,
label: "前端工程",
question: "pnpm install 在 CI 里失败",
description: "从构建日志定位依赖安装问题",
},
]
export { initialMessages, scenarios }
"use client"
import * as React from "react"
type UseControllableStateParams<T> = {
/** 受控值;传入后组件不再维护内部状态 */
prop?: T
/** 非受控时的初始值 */
defaultProp: T
/** 值变化时回调(受控与非受控都会触发) */
onChange?: (value: T) => void
}
/**
* 同时支持受控与非受控的状态 hook。
* 约定见 component-conventions 技能第 4 节:open/defaultOpen/onOpenChange 等成对 props 都用它实现。
*/
function useControllableState<T>({
prop,
defaultProp,
onChange,
}: UseControllableStateParams<T>) {
const [uncontrolled, setUncontrolled] = React.useState(defaultProp)
const isControlled = prop !== undefined
const value = isControlled ? prop : uncontrolled
// 用 ref 保存最新的回调和值,保证 setValue 引用稳定
const onChangeRef = React.useRef(onChange)
const valueRef = React.useRef(value)
React.useEffect(() => {
onChangeRef.current = onChange
valueRef.current = value
})
const setValue = React.useCallback(
(next: React.SetStateAction<T>) => {
const resolved =
typeof next === "function"
? (next as (prev: T) => T)(valueRef.current)
: next
if (Object.is(resolved, valueRef.current)) return
valueRef.current = resolved
if (!isControlled) setUncontrolled(resolved)
onChangeRef.current?.(resolved)
},
[isControlled],
)
return [value, setValue] as const
}
export { useControllableState }
这个 AI 助手面板提炼自我的一个企业监控平台项目,保留「在工作区旁边排查问题」的交互,去掉认证、业务接口与监控数据依赖。默认使用离线模拟数据,不需要密钥或服务端。
- 两种布局:右侧嵌入面板、右下角浮动窗口;聊天记录、输入草稿和滚动位置在切换时保留。
- 可检查的过程:分析摘要、运行中的工具、成功与失败结果,以及可打开的参考来源,按顺序排成紧凑的
Trace(shadcn 的trace),一行一步;每一步的输入与输出可折叠查看。 - 回答里的图表:工具返回后,回答上方会出现一张紧凑的图表卡片:可用率走势(
MiniChart面积图加指标行)、GPU 占用(三个MiniRing进度环)、CI 各步骤耗时(MiniChart柱图)。图表以data-chart数据块的形式在消息里流式出现,宽度跟随面板,高度只有 96px 左右,不会把消息撑长。 - 流式演示:应用监控、vLLM 显存、CI 安装失败三个剧本,其他问题使用通用剧本。所有 reasoning 均为预写的演示摘要,不是真实模型的隐藏思维。
- 对话操作:复制、点赞/点踩、重新生成、新建对话、停止生成。反馈仅保存在当前页面,不会上报。
- 窄屏适配:小于 768px 时,两种模式都使用底部 Sheet,带焦点管理和 Escape 关闭;嵌入面板支持用鼠标或触摸拖动、用方向键调整宽度。
用到的本站组件
除输入框、按钮、气泡、Sheet 等 shadcn 原语外,区块里这几处直接用了本站自己的组件:
| 位置 | 组件 | 说明 |
|---|---|---|
| 回答里的图表 | chart-card、mini-chart | ChartCard 外壳 + ChartMetrics 指标行,图形用 MiniChart(面积、柱)与 MiniRing(进度环) |
| 空状态的背景与标题 | glyph-field、text-animate | 四周淡淡的字符矩阵,欢迎语用 TextReveal 逐词显现 |
| 示例工作区的布局切换 | segmented-control | 嵌入布局 / 浮动窗口,选中项就是当前布局 |
安装
pnpm dlx shadcn@latest add https://yiui.dev/r/agent-chat-01.json会安装 app/agent/page.tsx 页面,以及 components/agent-chat/ 下的组件与数据文件。共享 hook 安装到 hooks/use-controllable-state.ts。shadcn 依赖来自官方 registry,本站组件 chart-card、mini-chart(图表及其 recharts 依赖)、glyph-field、text-animate、segmented-control(及它们依赖的 flow-diagram 色调)一并安装;npm 依赖为 ai、@ai-sdk/react、motion、lucide-react、class-variance-authority。实现基于 AI SDK 7 与 React SDK 4。
用法
"use client"
import {
AgentChatPanel,
AgentChatProvider,
AgentChatTrigger,
} from "@/components/agent-chat/agent-chat"
export default function Workspace() {
return (
<AgentChatProvider defaultMode="embedded" defaultOpen>
<div className="flex h-dvh flex-col">
<header className="p-4">
<AgentChatTrigger variant="button" />
</header>
<div className="flex min-h-0 flex-1">
<main className="min-w-0 flex-1 overflow-auto p-6">你的工作区</main>
<AgentChatPanel />
</div>
<AgentChatTrigger />
</div>
</AgentChatProvider>
)
}Provider 内只创建一个 useChat 实例,也只挂载一个面板。将面板放在与 main 同级的 flex 容器中;嵌入时默认宽 400px,调整范围 320–640px,父容器需要有明确高度与 min-h-0。
示例
受控布局与自定义文案
"use client"
import { useState } from "react"
import {
AgentChatPanel,
AgentChatProvider,
AgentChatTrigger,
type AgentChatMode,
} from "@/components/agent-chat/agent-chat"
export function Workspace() {
const [mode, setMode] = useState<AgentChatMode>("embedded")
const [open, setOpen] = useState(true)
return (
<AgentChatProvider
mode={mode}
onModeChange={setMode}
open={open}
onOpenChange={setOpen}
labels={{ title: "工程助手", welcome: "今天要解决什么问题?" }}
>
<div className="flex h-dvh flex-col">
<header>
<AgentChatTrigger variant="button" />
</header>
<div className="flex min-h-0 flex-1">
<main className="min-w-0 flex-1">你的工作区</main>
<AgentChatPanel />
</div>
<AgentChatTrigger />
</div>
</AgentChatProvider>
)
}预览页包含嵌入、浮动、预填记录和从工作区发起问题的组合示例。
API
AgentChatProvider
初始布局模式
类型"embedded" | "floating""floating"初始是否展开
类型booleanfalse聊天传输层;不传则使用内置 mock transport
类型ChatTransport<UIMessage>建议问题;不传时显示三个示例问题,传 [] 隐藏
string[]面板标题
类型string"AI 助手"可选的初始记录,演示页用它预填一轮对话
类型UIMessage[]工作区内容
类型ReactNode初始化与持久化
default* 与 initialMessages 只用于初始化。更换会话或 transport 配置时,可通过 Provider 的 React key 重新创建实例。会话状态仅在当前 Provider 生命周期内保存,刷新后重置;浮动窗口位置单独保存在 localStorage(travinsy:agent-chat:position),刷新或切换布局后仍会恢复。读取时以及窗口尺寸变化时,都会把位置约束在视口内。存储不可用时仍可在当前页面移动。
labels
labels 接收 Partial<AgentChatLabels>,覆盖 config.ts 中 DEFAULT_LABELS 的任意几项(面板标题、输入框占位、按钮名称、状态文案等),例如 labels={{ title: "工程助手", welcome: "今天要解决什么问题?" }}。thought 支持 {seconds} 占位符;moveWindow(默认 "移动助手窗口")用作浮动窗口拖动手柄的可访问名称;完整键列表见 config.ts。
AgentChatPanel
桌面浮动模式下是否允许拖动面板;小于 768px 的 Sheet 始终不可拖动
类型boolean | undefinedtruedraggable?: boolean 默认 true,允许桌面浮动模式拖动;传 false 禁用指针和键盘移动。小于 768px 的 Sheet 自动禁用拖动。标题栏支持鼠标与触控拖动,操作按钮不会触发拖动;双击标题栏复位到默认右下角。data-draggable 表示可移动,拖动期间面板与标题栏设置 data-dragging="true"。
触发器、建议问题与 Hook
| 导出 | API |
|---|---|
AgentChatPanel | { className?: string; id?: string; draggable?: boolean };关闭时不渲染;桌面嵌入为 aside |
AgentChatTrigger | { variant?: "fab" | "button"; className?: string };默认 fab,只在浮动模式关闭时显示 |
useAgentChat() | { mode, setMode, toggleMode, open, setOpen };必须在 Provider 内调用 |
AgentChatSuggestion | { question: string; children?: ReactNode; className?: string };从外部工作区打开面板并发送问题,生成期间禁用 |
AgentChatMode | "embedded" | "floating" |
Enter 发送,Shift + Enter 换行,中文输入法选词时不会误发送。生成中点击「停止」调用 stop();已收到的内容保留,未完成的工具标记为「已停止」。阅读旧消息时不会强制滚动到底部。
可访问性
- 标题栏与反馈按钮均有中文可访问名称,输入框有标签。
- 分析过程里每一步的状态写在标题文字里(运行中 / 已完成 / 失败 / 已停止),图标仅装饰;展开按钮暴露
aria-expanded。 - 图表卡片里的图是
role="img",名称来自剧本里的label;进度环是role="meter";关键数字同时写在指标行或环中央的文字里,不只靠图形。 - 聊天记录通过
aria-live="polite"播报新增内容;按钮暴露aria-expanded、aria-pressed,面板通过data-mode、data-state暴露状态。 Enter发送,Shift + Enter换行;输入法组合期间不会误发。调整面板宽度可用左右方向键。- 浮动模式下,Tab 聚焦「移动助手窗口」手柄,方向键每次移动 16px,Shift + 方向键移动 64px;Home、Enter 或空格复位到默认右下角。双击标题栏也可复位。移动始终限制在视口内;窄屏或
draggable={false}时不显示手柄。 - 窄屏 Sheet 提供焦点管理、焦点回到触发区域和 Escape 关闭。
- 动效遵循
prefers-reduced-motion,来源链接标注新标签页。
注意事项
文件结构
components/agent-chat/
├── agent-chat.tsx # 对外入口、面板和触发器
├── agent-chat-context.tsx # Provider 与会话状态
├── use-agent-chat-position.ts # 指针/键盘移动、视口约束与位置持久化
├── agent-chat-body.tsx # 标题栏、滚动区、输入框
├── chat-message.tsx # 消息气泡、分析过程 Trace、回复正文与反馈
├── chat-charts.tsx # 回答里的图表卡片(迷你图、进度环)
├── chat-markdown.tsx # 安全的简单 Markdown
├── config.ts # DEFAULT_LABELS 与建议问题
├── types.ts # 模式类型与图表块类型 ChatChart
├── mock-transport.ts # 可取消的流
├── scripts.ts # 预置剧本
└── demo-data.ts # 页面默认记录与场景
hooks/use-controllable-state.ts
app/agent/page.tsxMock transport 设计
mock-transport.ts 按最后一条用户消息匹配正则,构造标准 UIMessageChunk 队列。每次 pull 只输出一个片段:reasoning → 多个 dynamic tool → data-chart(图表)→ source-url → text → finish。延迟定时器受 AbortSignal 和流取消控制,不会执行真实命令或请求业务服务;reconnectToStream 返回 null。
chat-message.tsx 同时接受动态工具与 tool-* 静态工具消息。图表块的结构见 types.ts 的 ChatChart(series 走势 / 柱图、rings 进度环),必须可被 JSON 序列化,真实后端用 type: "data-chart" 的数据块下发即可。Markdown 仅支持段落、列表与行内代码,不执行 HTML;来源 URL 仅允许 HTTP(S)。
接入真实后端
客户端只需替换 transport;保持 useChat 在 Provider 中:
"use client"
import { useState } from "react"
import { DefaultChatTransport } from "ai"
import {
AgentChatPanel,
AgentChatProvider,
AgentChatTrigger,
} from "@/components/agent-chat/agent-chat"
export default function Workspace() {
const [transport] = useState(
() => new DefaultChatTransport({ api: "/api/chat" }),
)
return (
<AgentChatProvider transport={transport} defaultOpen>
<main>你的工作区</main>
<AgentChatPanel />
<AgentChatTrigger />
</AgentChatProvider>
)
}下面的服务端示例应放在支持服务端运行的应用或独立 API 服务中。本站是纯静态导出,不部署这个 POST Route Handler。 跨域后端可以给 api 配置完整 URL,并在后端配置 CORS。真实应用还需实现认证、输入校验与工具权限控制。
import {
convertToModelMessages,
stepCountIs,
streamText,
tool,
type UIMessage,
} from "ai"
import { z } from "zod"
export async function POST(request: Request) {
const { messages }: { messages: UIMessage[] } = await request.json()
const result = streamText({
// 使用你配置的模型或 AI Gateway;密钥仅在服务端配置。
model: "openai/gpt-5-mini",
messages: await convertToModelMessages(messages),
abortSignal: request.signal,
stopWhen: stepCountIs(3),
tools: {
readMetrics: tool({
description: "读取授权范围内的监控摘要",
inputSchema: z.object({ service: z.string() }),
execute: async ({ service }) => {
// 替换为带权限检查的真实只读查询。
return { service, availability: "99.95%", demo: true }
},
}),
},
})
return result.toUIMessageStreamResponse({
originalMessages: messages,
sendReasoning: true,
sendSources: true,
})
}sendReasoning 仅在模型提供可公开的 reasoning 内容时才有数据;不支持时可以省略该部分。工具 input-streaming / input-available 映射为运行中,output-available 映射为完成,output-error / output-denied 映射为失败;停止时未结束的调用显示为已停止。模型或检索工具提供的 source-url 映射为来源链接,不会自动生成引用。服务端应只发送适合向用户公开的分析摘要与工具输出。
参考:AI SDK UI。