Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 62 additions & 7 deletions app/api/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -71,24 +120,30 @@ export async function POST(req: Request) {
const allTools = {
getWeather,
compareTable,
createDemo,
...(research && searchReady ? researchTools : {}),
...frontendTools(tools ?? {}),
}

// 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),
})
Expand Down
113 changes: 113 additions & 0 deletions app/api/sandbox/route.ts
Original file line number Diff line number Diff line change
@@ -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 })
}
}
122 changes: 122 additions & 0 deletions components/assistant-ui/create-demo-tool.tsx
Original file line number Diff line number Diff line change
@@ -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<DemoFile>[]
dependencies?: Record<string, string>
}
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<DemoArtifact>(
() => ({
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 (
<button
type="button"
onClick={() => useWorkbench.getState().openArtifact(toolCallId)}
className={cn(
"group my-2 flex w-full max-w-md items-center gap-3 rounded-xl border p-3 text-left transition-colors",
"bg-card hover:bg-accent/50",
isActive && "border-ring/40 bg-accent/30"
)}
>
<div
className={cn(
"flex size-9 shrink-0 items-center justify-center rounded-lg border bg-muted text-muted-foreground",
running && "animate-pulse"
)}
>
<CodeXmlIcon className="size-4.5" />
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">
{artifact.title === "React Demo" && running
? "正在生成 Demo…"
: artifact.title}
</div>
<div className="truncate font-mono text-xs text-muted-foreground">
{running
? streamingFile
? `正在编写 ${streamingFile}`
: "正在思考文件结构…"
: `${fileCount} 个文件 · 点击在工作台查看`}
</div>
</div>
{running ? (
<Loader2Icon className="size-4 shrink-0 animate-spin text-muted-foreground" />
) : (
<ChevronRightIcon className="size-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
)}
</button>
)
}

export function CreateDemoTool() {
useAssistantTool({
toolName: CREATE_DEMO_TOOL_NAME,
type: "backend",
display: "standalone",
render: CreateDemoToolUI,
})
return null
}
2 changes: 2 additions & 0 deletions components/assistant-ui/tools.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@
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 (
<>
<WeatherTool />
<NotepadTool />
<CompareTableTool />
<CreateDemoTool />
</>
)
}
Loading
Loading