From 79b76d9fa990a4a38ed21439f2674686ee787177 Mon Sep 17 00:00:00 2001 From: zilin <276161014@qq.com> Date: Thu, 9 Jul 2026 02:41:52 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E5=8F=B0=EF=BC=88AI=20=E5=AE=9E=E6=97=B6?= =?UTF-8?q?=E7=94=9F=E6=88=90=20React=20Demo=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 支持"帮我写一个 Tailwind + framer-motion 的 Dialog 组件"这类请求:模型通过 createDemo 工具流式生成多文件代码,右侧工作台自动打开预览/代码双视图, Demo 随会话持久化,刷新可恢复。 - 预览运行时双实现:Sandpack(浏览器沙箱,默认)与 Apple container(本机 轻量 VM 里跑真 next dev,实验特性,面板右上角图标切换) - constants/workbench.ts、lib/workbench/*:系统提示词、防御式文件规整 (@/ 别名改写、cn() 注入)、zustand 状态 - lib/sandbox/manager.ts、app/api/sandbox/*、sandbox-template/:容器沙箱 的生命周期管理(tar 管道写文件、allowedDevOrigins 跨源修复) - 默认模型切至 MiniMax-M3,chat 请求放宽 maxOutputTokens 以容纳多文件输出 - openspec/changes/add-code-workbench/:运行时选型调研、架构设计、多轮 迭代路线图(v2 全栈沙箱 → v3 agent 化修复循环 → v4 工程能力 → v5 发布分享) Co-Authored-By: Claude Sonnet 5 --- app/api/chat/route.ts | 69 ++- app/api/sandbox/route.ts | 113 +++++ components/assistant-ui/create-demo-tool.tsx | 122 ++++++ components/assistant-ui/tools.tsx | 2 + components/examples/base.tsx | 368 ++++++++-------- components/workbench/container-preview.tsx | 264 +++++++++++ components/workbench/workbench-panel.tsx | 244 +++++++++++ constants/sandbox.ts | 20 + constants/workbench.ts | 62 +++ lib/sandbox/manager.ts | 319 ++++++++++++++ lib/workbench/files.ts | 94 ++++ lib/workbench/store.ts | 37 ++ lib/workbench/types.ts | 19 + openspec/changes/add-code-workbench/design.md | 110 +++++ .../changes/add-code-workbench/proposal.md | 25 ++ .../specs/code-workbench/spec.md | 44 ++ openspec/changes/add-code-workbench/tasks.md | 52 +++ package.json | 1 + pnpm-lock.yaml | 409 +++++++++++++++++- sandbox-template/.dockerignore | 2 + sandbox-template/Dockerfile | 19 + sandbox-template/template/app/globals.css | 1 + sandbox-template/template/app/layout.tsx | 17 + sandbox-template/template/app/page.tsx | 5 + sandbox-template/template/demo/App.tsx | 11 + sandbox-template/template/demo/lib/utils.ts | 6 + sandbox-template/template/next.config.mjs | 18 + sandbox-template/template/package.json | 26 ++ sandbox-template/template/postcss.config.mjs | 5 + sandbox-template/template/tsconfig.json | 21 + 30 files changed, 2323 insertions(+), 182 deletions(-) create mode 100644 app/api/sandbox/route.ts create mode 100644 components/assistant-ui/create-demo-tool.tsx create mode 100644 components/workbench/container-preview.tsx create mode 100644 components/workbench/workbench-panel.tsx create mode 100644 constants/sandbox.ts create mode 100644 constants/workbench.ts create mode 100644 lib/sandbox/manager.ts create mode 100644 lib/workbench/files.ts create mode 100644 lib/workbench/store.ts create mode 100644 lib/workbench/types.ts create mode 100644 openspec/changes/add-code-workbench/design.md create mode 100644 openspec/changes/add-code-workbench/proposal.md create mode 100644 openspec/changes/add-code-workbench/specs/code-workbench/spec.md create mode 100644 openspec/changes/add-code-workbench/tasks.md create mode 100644 sandbox-template/.dockerignore create mode 100644 sandbox-template/Dockerfile create mode 100644 sandbox-template/template/app/globals.css create mode 100644 sandbox-template/template/app/layout.tsx create mode 100644 sandbox-template/template/app/page.tsx create mode 100644 sandbox-template/template/demo/App.tsx create mode 100644 sandbox-template/template/demo/lib/utils.ts create mode 100644 sandbox-template/template/next.config.mjs create mode 100644 sandbox-template/template/package.json create mode 100644 sandbox-template/template/postcss.config.mjs create mode 100644 sandbox-template/template/tsconfig.json diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index 47a20ac3..2129e31e 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -12,7 +12,15 @@ import { resolveAttachmentParts } from "@/lib/chat/resolve-attachments" import { minimaxChatModel } from "@/lib/ai/minimax" import { researchTools } from "@/lib/chat/research-tools" import { isSearchConfigured } from "@/lib/ai/search" -import { RESEARCH_MAX_STEPS, RESEARCH_SYSTEM_PROMPT } from "@/constants/research" +import { + RESEARCH_MAX_STEPS, + RESEARCH_SYSTEM_PROMPT, +} from "@/constants/research" +import { + CHAT_MAX_OUTPUT_TOKENS, + DEMO_MAX_FILES, + WORKBENCH_SYSTEM_PROMPT, +} from "@/constants/workbench" // 深度研究可能多步循环,耗时较长,放宽单次请求时长上限 export const maxDuration = 120 @@ -24,7 +32,13 @@ const getWeather = tool({ }), execute: async ({ location }) => { // Deterministic mock reading (hashed from the city name) - no real weather API/key involved. - const conditions = ["Sunny", "Partly Cloudy", "Cloudy", "Light Rain", "Clear"] + const conditions = [ + "Sunny", + "Partly Cloudy", + "Cloudy", + "Light Rain", + "Clear", + ] const seed = [...location].reduce((acc, c) => acc + c.charCodeAt(0), 0) return { location, @@ -36,18 +50,53 @@ const getWeather = tool({ }, }) +// 生成式 Demo:代码本体通过 args 流式传给前端工作台(Sandpack 预览), +// execute 只回一个轻量确认,避免大段代码在 tool result 里往返一遍。 +const createDemo = tool({ + description: + "创建或整体更新一个可实时预览的 React Demo 项目。用户要求编写/演示 React 组件、页面、动效或 UI Demo 时调用;更新已有 Demo 时输出全部文件的完整最新内容。", + inputSchema: z.object({ + title: z.string().describe("Demo 的简短中文标题;更新已有 Demo 时保持不变"), + files: z + .array( + z.object({ + path: z.string().describe("以 / 开头的文件路径,入口必须是 /App.tsx"), + content: z.string().describe("该文件的完整源码"), + }) + ) + .min(1) + .max(DEMO_MAX_FILES), + dependencies: z + .record(z.string(), z.string()) + .optional() + .describe( + '预装依赖之外需要的 npm 包,如 {"@radix-ui/react-dialog":"latest"}' + ), + }), + execute: async ({ title, files }) => ({ + ok: true, + title, + fileCount: files.length, + note: "Demo 已在用户右侧的代码工作台中打开并展示预览", + }), +}) + const compareTable = tool({ description: "Render a comparison table for two or more items across one or more numeric metrics. Use whenever the user asks to compare things 'in a table' with real numeric data.", inputSchema: z.object({ title: z.string(), unit: z.string().optional(), - columns: z.array(z.string()).describe("Category labels, e.g. country names"), + columns: z + .array(z.string()) + .describe("Category labels, e.g. country names"), series: z.array( z.object({ name: z.string(), - values: z.array(z.number()).describe("One value per column, same order as columns"), - }), + values: z + .array(z.number()) + .describe("One value per column, same order as columns"), + }) ), }), execute: async (input) => input, @@ -71,6 +120,7 @@ export async function POST(req: Request) { const allTools = { getWeather, compareTable, + createDemo, ...(research && searchReady ? researchTools : {}), ...frontendTools(tools ?? {}), } @@ -78,17 +128,22 @@ export async function POST(req: Request) { // MiniMax 不接受 file part:先把附件(PDF→提取文本,其余→占位说明)转换为 text part const resolvedMessages = await resolveAttachmentParts(messages) + // 研究模式保持研究提示词纯净;普通对话注入代码工作台指引 const system = research ? searchReady ? RESEARCH_SYSTEM_PROMPT : "用户开启了深度研究,但服务端未配置搜索服务(SEARCH_API_KEY),请如实告知该功能暂不可用,并基于已有知识尽力回答。" - : undefined + : WORKBENCH_SYSTEM_PROMPT const result = streamText({ model: minimaxChatModel(), system, - messages: await convertToModelMessages(resolvedMessages, { tools: allTools }), + messages: await convertToModelMessages(resolvedMessages, { + tools: allTools, + }), tools: allTools, + // 多文件 Demo 代码量大,MiniMax 默认 max_tokens 不够用,统一放宽 + maxOutputTokens: CHAT_MAX_OUTPUT_TOKENS, // 研究模式允许更多工具轮次;普通对话维持原来的小步数 stopWhen: isStepCount(research && searchReady ? RESEARCH_MAX_STEPS : 5), }) diff --git a/app/api/sandbox/route.ts b/app/api/sandbox/route.ts new file mode 100644 index 00000000..d8501484 --- /dev/null +++ b/app/api/sandbox/route.ts @@ -0,0 +1,113 @@ +import { NextResponse } from "next/server" +import { + applyFiles, + destroySandbox, + ensureSandbox, + getBuildState, + getSandbox, + imageReady, + isAvailable, + probeReady, + sandboxLogs, + sandboxName, + startImageBuild, +} from "@/lib/sandbox/manager" + +// 容器沙箱(Apple container,实验特性)的控制面。 +// GET: 环境探测;POST: ensure / apply / status / destroy / build 五个动作。 + +export const maxDuration = 240 + +export async function GET() { + const available = await isAvailable() + const build = getBuildState() + return NextResponse.json({ + available, + imageReady: available ? await imageReady() : false, + building: build.building, + buildError: build.error, + buildLog: build.log.slice(-2000), + }) +} + +type PostBody = { + action: "ensure" | "apply" | "status" | "destroy" | "build" + artifactId?: string + files?: { path: string; content: string }[] +} + +export async function POST(req: Request) { + let body: PostBody + try { + body = await req.json() + } catch { + return NextResponse.json({ error: "请求体不是合法 JSON" }, { status: 400 }) + } + + try { + switch (body.action) { + case "build": { + const state = startImageBuild() + return NextResponse.json({ + building: state.building, + buildError: state.error, + }) + } + case "ensure": { + if (!body.artifactId) + return NextResponse.json( + { error: "缺少 artifactId" }, + { status: 400 } + ) + if (!(await imageReady())) { + return NextResponse.json({ error: "IMAGE_MISSING" }, { status: 409 }) + } + const info = await ensureSandbox(body.artifactId) + if (body.files?.length) await applyFiles(body.artifactId, body.files) + return NextResponse.json(info) + } + case "apply": { + if (!body.artifactId || !body.files?.length) { + return NextResponse.json( + { error: "缺少 artifactId 或 files" }, + { status: 400 } + ) + } + const result = await applyFiles(body.artifactId, body.files) + return NextResponse.json(result) + } + case "status": { + if (!body.artifactId) + return NextResponse.json( + { error: "缺少 artifactId" }, + { status: 400 } + ) + const info = await getSandbox(sandboxName(body.artifactId)) + if (!info) + return NextResponse.json({ running: false, ready: false, url: null }) + const ready = info.url ? await probeReady(info.url) : false + return NextResponse.json({ + ...info, + ready, + logs: ready ? undefined : await sandboxLogs(info.name, 20), + }) + } + case "destroy": { + if (!body.artifactId) + return NextResponse.json( + { error: "缺少 artifactId" }, + { status: 400 } + ) + await destroySandbox(body.artifactId) + return NextResponse.json({ ok: true }) + } + default: + return NextResponse.json( + { error: `未知 action: ${String(body.action)}` }, + { status: 400 } + ) + } + } catch (err) { + return NextResponse.json({ error: String(err) }, { status: 500 }) + } +} diff --git a/components/assistant-ui/create-demo-tool.tsx b/components/assistant-ui/create-demo-tool.tsx new file mode 100644 index 00000000..000a873b --- /dev/null +++ b/components/assistant-ui/create-demo-tool.tsx @@ -0,0 +1,122 @@ +"use client" + +import { useEffect, useMemo, useRef } from "react" +import { + useAssistantTool, + type ToolCallMessagePartComponent, +} from "@assistant-ui/react" +import { ChevronRightIcon, CodeXmlIcon, Loader2Icon } from "lucide-react" +import { CREATE_DEMO_TOOL_NAME } from "@/constants/workbench" +import { mergeDemoDependencies } from "@/lib/workbench/files" +import { useWorkbench } from "@/lib/workbench/store" +import type { DemoArtifact, DemoFile } from "@/lib/workbench/types" +import { cn } from "@/lib/utils" + +// createDemo 的消息内 UI:一张可点击的 artifact 卡片。 +// 真正的代码/预览在右侧 WorkbenchPanel 展示,这里只负责: +// 1) 把流式 args 持续 upsert 进 workbench store(含历史消息重挂载时的幂等恢复) +// 2) 生成开始时自动打开面板并切到代码视图,结束时切回预览 +// 3) 作为随时可以重新打开某个 Demo 的入口 + +type CreateDemoArgs = { + title?: string + files?: Partial[] + dependencies?: Record +} +type CreateDemoResult = { ok: boolean; title: string; fileCount: number } + +const CreateDemoToolUI: ToolCallMessagePartComponent< + CreateDemoArgs, + CreateDemoResult +> = ({ toolCallId, args, argsText, status }) => { + const running = status.type === "running" + const activeId = useWorkbench((s) => s.activeId) + const panelOpen = useWorkbench((s) => s.open) + const isActive = panelOpen && activeId === toolCallId + + const artifact = useMemo( + () => ({ + id: toolCallId, + title: args?.title?.trim() || "React Demo", + files: (args?.files ?? []).filter( + (f): f is DemoFile => + typeof f?.path === "string" && typeof f?.content === "string" + ), + dependencies: mergeDemoDependencies(args?.dependencies), + status: running ? "streaming" : "complete", + }), + // argsText 是 args 的流式来源,作为稳定的变更信号 + // eslint-disable-next-line react-hooks/exhaustive-deps + [toolCallId, argsText, status.type] + ) + + // 只在"本次会话实时生成"时自动打开面板;历史消息重挂载(status 一开始就是 + // complete)只恢复数据,不打扰用户 + const autoOpenedRef = useRef(false) + useEffect(() => { + const { upsertArtifact, openArtifact, setView } = useWorkbench.getState() + upsertArtifact(artifact) + if (running && !autoOpenedRef.current) { + autoOpenedRef.current = true + openArtifact(artifact.id) + setView("code") + } + if (!running && autoOpenedRef.current) { + autoOpenedRef.current = false + if (useWorkbench.getState().activeId === artifact.id) setView("preview") + } + }, [artifact, running]) + + const fileCount = artifact.files.length + const streamingFile = running ? artifact.files.at(-1)?.path : undefined + + return ( + + ) +} + +export function CreateDemoTool() { + useAssistantTool({ + toolName: CREATE_DEMO_TOOL_NAME, + type: "backend", + display: "standalone", + render: CreateDemoToolUI, + }) + return null +} diff --git a/components/assistant-ui/tools.tsx b/components/assistant-ui/tools.tsx index 1a265e41..396e0825 100644 --- a/components/assistant-ui/tools.tsx +++ b/components/assistant-ui/tools.tsx @@ -3,6 +3,7 @@ import { WeatherTool } from "@/components/assistant-ui/weather-tool" import { NotepadTool } from "@/components/assistant-ui/notepad-tool" import { CompareTableTool } from "@/components/assistant-ui/compare-table-tool" +import { CreateDemoTool } from "@/components/assistant-ui/create-demo-tool" export function AssistantTools() { return ( @@ -10,6 +11,7 @@ export function AssistantTools() { + ) } diff --git a/components/examples/base.tsx b/components/examples/base.tsx index bcf8fb42..333bb408 100644 --- a/components/examples/base.tsx +++ b/components/examples/base.tsx @@ -1,48 +1,48 @@ -"use client"; +"use client" import { ComposerAddAttachment, ComposerAttachments, UserMessageAttachments, -} from "@/components/assistant-ui/attachment"; -import { ComposerPdfInsights } from "@/components/assistant-ui/pdf-insights"; -import { DeepResearchToggle } from "@/components/assistant-ui/deep-research-toggle"; +} from "@/components/assistant-ui/attachment" +import { ComposerPdfInsights } from "@/components/assistant-ui/pdf-insights" +import { DeepResearchToggle } from "@/components/assistant-ui/deep-research-toggle" import { ResearchProgress, RESEARCH_TOOL_NAMES, -} from "@/components/assistant-ui/research-panel"; -import { MarkdownText } from "@/components/assistant-ui/markdown-text"; -import { DotMatrix } from "@/components/assistant-ui/dot-matrix"; -import { MessageTiming } from "@/components/assistant-ui/message-timing"; -import { ToolFallback } from "@/components/assistant-ui/tool-fallback"; +} from "@/components/assistant-ui/research-panel" +import { MarkdownText } from "@/components/assistant-ui/markdown-text" +import { DotMatrix } from "@/components/assistant-ui/dot-matrix" +import { MessageTiming } from "@/components/assistant-ui/message-timing" +import { ToolFallback } from "@/components/assistant-ui/tool-fallback" import { ToolGroupContent, ToolGroupRoot, ToolGroupTrigger, -} from "@/components/assistant-ui/tool-group"; +} from "@/components/assistant-ui/tool-group" import { ThreadList, ThreadListItems, ThreadListNew, ThreadListRoot, -} from "@/components/assistant-ui/thread-list"; -import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; +} from "@/components/assistant-ui/thread-list" +import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button" import { Reasoning, ReasoningContent, ReasoningRoot, ReasoningText, ReasoningTrigger, -} from "@/components/assistant-ui/reasoning"; -import { Button } from "@/components/ui/button"; -import { cn } from "@/lib/utils"; -import icon from "@/public/favicon/icon.svg"; +} from "@/components/assistant-ui/reasoning" +import { Button } from "@/components/ui/button" +import { cn } from "@/lib/utils" +import icon from "@/public/favicon/icon.svg" import { ComposerQuotePreview, QuoteBlock, SelectionToolbar, -} from "@/components/assistant-ui/quote"; -import { ComposerTriggerPopover } from "@/components/assistant-ui/composer-trigger-popover"; -import { DirectiveText } from "@/components/assistant-ui/directive-text"; +} from "@/components/assistant-ui/quote" +import { ComposerTriggerPopover } from "@/components/assistant-ui/composer-trigger-popover" +import { DirectiveText } from "@/components/assistant-ui/directive-text" import { ActionBarMorePrimitive, ActionBarPrimitive, @@ -59,7 +59,7 @@ import { useAui, useAuiState, type Unstable_SlashCommand, -} from "@assistant-ui/react"; +} from "@assistant-ui/react" import { ArrowDownIcon, ArrowUpIcon, @@ -87,22 +87,23 @@ import { SlashIcon, SquareIcon, WrenchIcon, -} from "lucide-react"; +} from "lucide-react" import { LexicalComposerInput, type DirectiveChipProps, -} from "@assistant-ui/react-lexical"; -import Image from "next/image"; -import { useState, type FC, type ReactNode } from "react"; -import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"; +} from "@assistant-ui/react-lexical" +import Image from "next/image" +import { useState, type FC, type ReactNode } from "react" +import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet" import { Tooltip, TooltipContent, TooltipTrigger, -} from "@/components/ui/tooltip"; -import { ModelSelector } from "@/components/assistant-ui/model-selector"; -import { docsModelOptions } from "@/components/docs/assistant/docs-model-options"; -import { DEFAULT_MODEL_ID } from "@/constants/model"; +} from "@/components/ui/tooltip" +import { ModelSelector } from "@/components/assistant-ui/model-selector" +import { WorkbenchPanel } from "@/components/workbench/workbench-panel" +import { docsModelOptions } from "@/components/docs/assistant/docs-model-options" +import { DEFAULT_MODEL_ID } from "@/constants/model" const Logo: FC = () => { return (
@@ -113,20 +114,20 @@ const Logo: FC = () => { /> assistant-ui
- ); -}; + ) +} const Sidebar: FC<{ collapsed?: boolean }> = ({ collapsed }) => { return ( - ); -}; + ) +} const MobileSidebar: FC = () => { return ( @@ -208,9 +209,9 @@ const MobileSidebar: FC = () => { - ); -}; -const models = docsModelOptions(); + ) +} +const models = docsModelOptions() const ModelPicker: FC = () => { return ( { size="sm" className="h-7 rounded-full" /> - ); -}; + ) +} const ThreadTitle: FC = () => { const title = useAuiState( (s) => - s.threads.threadItems.find((t) => t.id === s.threads.mainThreadId) - ?.title, - ); + s.threads.threadItems.find((t) => t.id === s.threads.mainThreadId)?.title + ) return ( {title ?? "New Chat"} - ); -}; + ) +} const Header: FC<{ - sidebarCollapsed: boolean; - onToggleSidebar: () => void; + sidebarCollapsed: boolean + onToggleSidebar: () => void }> = ({ sidebarCollapsed, onToggleSidebar }) => { return (
@@ -263,18 +263,17 @@ const Header: FC<{
- ); -}; + ) +} // Startup exposes a loading placeholder thread; treat it as a new chat so // the composer mounts centered. Loads after startup keep the docked layout. const isNewChatView = (s: AssistantState) => - s.thread.messages.length === 0 && - (!s.thread.isLoading || s.threads.isLoading); + s.thread.messages.length === 0 && (!s.thread.isLoading || s.threads.isLoading) const Thread: FC = () => { - const isEmpty = useAuiState(isNewChatView); + const isEmpty = useAuiState(isNewChatView) return ( { data-slot="aui_thread-viewport" className={cn( "relative flex flex-1 flex-col overflow-x-auto overflow-y-scroll scroll-smooth px-4 pt-4", - isEmpty && "justify-center", + isEmpty && "justify-center" )} > @@ -300,16 +299,16 @@ const Thread: FC = () => { > {({ message }) => { - if (message.composer.isEditing) return ; - if (message.role === "user") return ; - return ; + if (message.composer.isEditing) return + if (message.role === "user") return + return }} @@ -325,36 +324,57 @@ const Thread: FC = () => { - ); -}; + ) +} const ThreadScrollToBottom: FC = () => { return ( - ); -}; + ) +} const ThreadWelcome: FC = () => { return (
-

+

How can I help you today?

- ); -}; + ) +} type SuggestionGroup = { - label: string; - icon: ReactNode; - options: { label: string; prompt: string }[]; -}; + label: string + icon: ReactNode + options: { label: string; prompt: string }[] +} const SUGGESTION_GROUPS: SuggestionGroup[] = [ + { + label: "Demo", + icon: , + options: [ + { + label: "Dialog 弹窗组件", + prompt: + "帮我写一个基于 Tailwind CSS、framer-motion 的 React Dialog 弹窗组件 Demo,带打开/关闭的缩放淡入动画和遮罩", + }, + { + label: "价格卡片", + prompt: + "写一个 SaaS 三档价格卡片 Demo:Tailwind 布局、framer-motion hover 动效、lucide-react 图标,突出中间的推荐档", + }, + { + label: "侧滑抽屉", + prompt: + "写一个从右侧滑出的 Drawer 抽屉组件 Demo,用 framer-motion 的 AnimatePresence 做进出场动画", + }, + ], + }, { label: "Weather", icon: , @@ -417,7 +437,8 @@ const SUGGESTION_GROUPS: SuggestionGroup[] = [ options: [ { label: "React vs Vue vs Svelte", - prompt: "Compare npm weekly downloads of React, Vue, and Svelte in a table", + prompt: + "Compare npm weekly downloads of React, Vue, and Svelte in a table", }, { label: "GDP of US, China, Japan", @@ -448,22 +469,22 @@ const SUGGESTION_GROUPS: SuggestionGroup[] = [ }, ], }, -]; +] const suggestionChipClass = - "aui-thread-welcome-suggestion text-foreground hover:bg-muted border-border/60 h-auto gap-1.5 rounded-full border px-3.5 py-1.5 text-sm font-normal whitespace-nowrap transition-colors [&_svg]:size-4"; + "aui-thread-welcome-suggestion text-foreground hover:bg-muted border-border/60 h-auto gap-1.5 rounded-full border px-3.5 py-1.5 text-sm font-normal whitespace-nowrap transition-colors [&_svg]:size-4" const ThreadSuggestions: FC = () => { - const aui = useAui(); - const [expandedLabel, setExpandedLabel] = useState(null); + const aui = useAui() + const [expandedLabel, setExpandedLabel] = useState(null) const expandedGroup = SUGGESTION_GROUPS.find( - (group) => group.label === expandedLabel, - ); + (group) => group.label === expandedLabel + ) const sendPrompt = (prompt: string) => { - if (aui.thread().getState().isRunning) return; + if (aui.thread().getState().isRunning) return aui.thread().append({ content: [{ type: "text", text: prompt }], runConfig: aui.composer().getState().runConfig, - }); - }; + }) + } return (
@@ -474,11 +495,11 @@ const ThreadSuggestions: FC = () => { variant="ghost" className={cn( suggestionChipClass, - group.label === expandedLabel && "bg-muted", + group.label === expandedLabel && "bg-muted" )} onClick={() => setExpandedLabel( - group.label === expandedLabel ? null : group.label, + group.label === expandedLabel ? null : group.label ) } > @@ -491,7 +512,7 @@ const ThreadSuggestions: FC = () => { {expandedGroup && (
{expandedGroup.options.map((option) => ( @@ -508,8 +529,8 @@ const ThreadSuggestions: FC = () => {
)}
- ); -}; + ) +} const slashCommands: readonly Unstable_SlashCommand[] = [ { id: "summarize", @@ -535,16 +556,16 @@ const slashCommands: readonly Unstable_SlashCommand[] = [ icon: "HelpCircle", execute: () => console.log("[base example] /help invoked"), }, -]; +] const slashIconMap: Record> = { FileText: FileTextIcon, Languages: LanguagesIcon, Globe: GlobeIcon, HelpCircle: HelpCircleIcon, -}; +} function DirectiveChip(props: DirectiveChipProps) { - const { directiveId, directiveType, label } = props; - const showWrench = directiveType !== "command"; + const { directiveId, directiveType, label } = props + const showWrench = directiveType !== "command" return ( {label} - ); + ) } const Composer: FC = () => { - const mention = unstable_useMentionAdapter({ fallbackIcon: WrenchIcon }); + const mention = unstable_useMentionAdapter({ fallbackIcon: WrenchIcon }) const slash = unstable_useSlashCommandAdapter({ commands: slashCommands, iconMap: slashIconMap, fallbackIcon: SlashIcon, - }); + }) return (
@@ -581,7 +602,7 @@ const Composer: FC = () => {
@@ -594,8 +615,8 @@ const Composer: FC = () => { />
- ); -}; + ) +} const ComposerAction: FC = () => { return (
@@ -629,7 +650,7 @@ const ComposerAction: FC = () => { type="button" variant="ghost" size="icon" - className="aui-composer-stop-dictation text-destructive size-7 rounded-full" + className="aui-composer-stop-dictation size-7 rounded-full text-destructive" aria-label="Stop voice input" > @@ -667,29 +688,29 @@ const ComposerAction: FC = () => {
- ); -}; + ) +} const MessageError: FC = () => { return ( - + - ); -}; + ) +} const AssistantWorkingIndicator: FC = () => { - const isEmpty = useAuiState((s) => s.message.content.length === 0); + const isEmpty = useAuiState((s) => s.message.content.length === 0) if (isEmpty) { return ( Connecting - ); + ) } return ( { > {"●"} - ); -}; + ) +} // 工具组渲染:纯研究工具组不再单独展示(由 ResearchProgress 面板统一呈现), // 其余工具组沿用默认的可折叠分组。 const ToolGroupBlock: FC<{ - part: { indices: readonly number[]; status: { type: string } }; - children: ReactNode; + part: { indices: readonly number[]; status: { type: string } } + children: ReactNode }> = ({ part, children }) => { const allResearch = useAuiState((s) => { const content = s.message.content as unknown as { - type: string; - toolName?: string; - }[]; + type: string + toolName?: string + }[] return part.indices.every((i) => { - const p = content[i]; + const p = content[i] return ( p?.type === "tool-call" && !!p.toolName && RESEARCH_TOOL_NAMES.has(p.toolName) - ); - }); - }); - if (allResearch) return null; + ) + }) + }) + if (allResearch) return null return ( {children} - ); -}; + ) +} const AssistantMessage: FC = () => { // reserves space for action bar and compensates with `-mb` for consistent msg spacing // keeps hovered action bar from shifting layout (autohide doesn't support absolute positioning well) // for pt-[n] use -mb-[n + 6] & min-h-[n + 6] to preserve compensation - const ACTION_BAR_PT = "pt-1.5"; - const ACTION_BAR_HEIGHT = `-mb-7.5 min-h-7.5 ${ACTION_BAR_PT}`; + const ACTION_BAR_PT = "pt-1.5" + const ACTION_BAR_HEIGHT = `-mb-7.5 min-h-7.5 ${ACTION_BAR_PT}` return (
{/* grok 风格研究面板:把本条消息的联网检索/深读聚成一个可折叠时间线 */} @@ -761,11 +782,11 @@ const AssistantMessage: FC = () => { {({ part, children }) => { switch (part.type) { case "group-chainOfThought": - return
{children}
; + return
{children}
case "group-tool": - return {children}; + return {children} case "group-reasoning": { - const running = part.status.type === "running"; + const running = part.status.type === "running" return ( @@ -773,23 +794,23 @@ const AssistantMessage: FC = () => { {children} - ); + ) } case "text": - return ; + return case "reasoning": - return ; + return case "tool-call": // 研究工具(webSearch/readUrl)统一由 ResearchProgress 面板展示, // 这里不再单独渲染,避免重复 - if (RESEARCH_TOOL_NAMES.has(part.toolName)) return null; - return part.toolUI ?? ; + if (RESEARCH_TOOL_NAMES.has(part.toolName)) return null + return part.toolUI ?? case "indicator": - return ; + return case "data": - return part.dataRendererUI; + return part.dataRendererUI default: - return null; + return null } }} @@ -803,22 +824,22 @@ const AssistantMessage: FC = () => {
- ); -}; + ) +} const AssistantActionBar: FC = () => { return ( s.message.isCopied}> - + !s.message.isCopied}> - + @@ -840,10 +861,10 @@ const AssistantActionBar: FC = () => { side="bottom" align="start" sideOffset={6} - className="aui-action-bar-more-content bg-popover/95 text-popover-foreground data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=closed]:animate-out data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-xl border p-1.5 shadow-lg backdrop-blur-sm" + className="aui-action-bar-more-content z-50 min-w-[8rem] overflow-hidden rounded-xl border bg-popover/95 p-1.5 text-popover-foreground shadow-lg backdrop-blur-sm data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95" > - + Export as Markdown @@ -852,18 +873,18 @@ const AssistantActionBar: FC = () => { - ); -}; + ) +} const UserMessage: FC = () => { return (
-
+
{(quote) => } @@ -878,8 +899,8 @@ const UserMessage: FC = () => { className="col-span-full col-start-1 row-start-3 -mr-1 justify-end" /> - ); -}; + ) +} const UserActionBar: FC = () => { return ( { - ); -}; + ) +} const EditComposer: FC = () => { return ( { className="mx-auto flex w-full max-w-(--thread-max-width) flex-col px-2" > - +
@@ -927,8 +948,8 @@ const EditComposer: FC = () => { - ); -}; + ) +} const BranchPicker: FC = ({ className, ...rest @@ -937,8 +958,8 @@ const BranchPicker: FC = ({ @@ -956,26 +977,29 @@ const BranchPicker: FC = ({ - ); -}; + ) +} export const Base: FC = () => { - const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + const [sidebarCollapsed, setSidebarCollapsed] = useState(false) return ( -
+
-
+
setSidebarCollapsed(!sidebarCollapsed)} /> -
- -
+
+
+ +
+ +
- ); -}; + ) +} diff --git a/components/workbench/container-preview.tsx b/components/workbench/container-preview.tsx new file mode 100644 index 00000000..21b96ef6 --- /dev/null +++ b/components/workbench/container-preview.tsx @@ -0,0 +1,264 @@ +"use client" + +import { useEffect, useMemo, useReducer, useState } from "react" +import { + ExternalLinkIcon, + HammerIcon, + Loader2Icon, + RefreshCwIcon, + RotateCwIcon, + Trash2Icon, +} from "lucide-react" +import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button" +import { Button } from "@/components/ui/button" +import { + SANDBOX_POLL_INTERVAL_MS, + SANDBOX_POLL_MAX_ATTEMPTS, +} from "@/constants/sandbox" +import { toSandpackFiles } from "@/lib/workbench/files" +import type { DemoArtifact } from "@/lib/workbench/types" + +// 容器沙箱预览(实验):Demo 跑在 Apple container 的轻量 VM 里(真 next dev), +// iframe 直连容器 IP。生命周期:环境检测 → (构建镜像)→ 启动沙箱 → 同步文件 → +// 轮询 next dev 就绪 → 展示。文件再次同步依赖 VM 内 HMR 即时生效。 + +type Phase = + | "checking" + | "unavailable" + | "need-image" + | "building" + | "starting" + | "waiting" + | "ready" + | "error" + +const PHASE_TEXT: Record = { + checking: "检测容器环境…", + unavailable: "未检测到 Apple container", + "need-image": "需要先构建基础镜像", + building: "正在构建基础镜像(首次约 2~5 分钟)…", + starting: "启动沙箱 VM 并同步文件…", + waiting: "等待 next dev 就绪…", + ready: "运行中", + error: "沙箱出错", +} + +async function api(body: Record): Promise { + const res = await fetch("/api/sandbox", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) + const data = (await res.json()) as T & { error?: string } + if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`) + return data +} + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +export function ContainerPreview({ artifact }: { artifact: DemoArtifact }) { + const [phase, setPhase] = useState("checking") + const [url, setUrl] = useState(null) + const [detail, setDetail] = useState("") + const [retryNonce, retry] = useReducer((x: number) => x + 1, 0) + const [iframeNonce, reloadIframe] = useReducer((x: number) => x + 1, 0) + const [syncing, setSyncing] = useState(false) + + const files = useMemo( + () => + Object.entries(toSandpackFiles(artifact.files)).map( + ([path, content]) => ({ + path, + content, + }) + ), + [artifact] + ) + + useEffect(() => { + let cancelled = false + const run = async () => { + try { + setPhase("checking") + setDetail("") + let env = await ( + await fetch("/api/sandbox", { cache: "no-store" }) + ).json() + if (cancelled) return + if (!env.available) { + setPhase("unavailable") + return + } + if (env.building) setPhase("building") + while (!cancelled && env.building) { + await sleep(3000) + env = await ( + await fetch("/api/sandbox", { cache: "no-store" }) + ).json() + setDetail(env.buildLog?.split("\n").filter(Boolean).at(-1) ?? "") + } + if (cancelled) return + if (env.buildError) throw new Error(env.buildError) + if (!env.imageReady) { + setPhase("need-image") + return + } + + setPhase("starting") + const info = await api<{ url: string | null }>({ + action: "ensure", + artifactId: artifact.id, + files, + }) + if (cancelled) return + if (!info.url) throw new Error("沙箱未取得 IP") + + setPhase("waiting") + for (let attempt = 0; attempt < SANDBOX_POLL_MAX_ATTEMPTS; attempt++) { + if (cancelled) return + const status = await api<{ + ready: boolean + url: string | null + logs?: string + }>({ + action: "status", + artifactId: artifact.id, + }) + if (status.ready && status.url) { + setUrl(status.url) + setPhase("ready") + return + } + setDetail(status.logs?.split("\n").filter(Boolean).at(-1) ?? "") + await sleep(SANDBOX_POLL_INTERVAL_MS) + } + throw new Error("等待 next dev 就绪超时") + } catch (err) { + if (!cancelled) { + setPhase("error") + setDetail(String(err)) + } + } + } + void run() + return () => { + cancelled = true + } + // files 由 artifact 派生,无需单列依赖 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [artifact.id, retryNonce]) + + const syncFiles = async () => { + setSyncing(true) + try { + await api({ action: "apply", artifactId: artifact.id, files }) + } catch (err) { + setDetail(String(err)) + } finally { + setSyncing(false) + } + } + + const destroy = async () => { + try { + await api({ action: "destroy", artifactId: artifact.id }) + } finally { + setUrl(null) + retry() + } + } + + const buildImage = async () => { + await api({ action: "build" }) + retry() + } + + if (phase !== "ready") { + return ( +
+ {(phase === "checking" || + phase === "building" || + phase === "starting" || + phase === "waiting") && ( + + )} +

{PHASE_TEXT[phase]}

+ {detail && ( +

+ {detail} +

+ )} + {phase === "unavailable" && ( +

+ 请先安装并启动: + brew install container && container system start +

+ )} + {phase === "need-image" && ( + + )} + {phase === "error" && ( + + )} +
+ ) + } + + return ( +
+
+ + 真 Next.js ·{" "} + {url?.replace("http://", "")} + +
+ + {syncing ? ( + + ) : ( + + )} + + reloadIframe()} + > + + + url && window.open(url, "_blank")} + > + + + + + +
+
+