This commit is contained in:
2026-07-29 11:42:41 +08:00
commit 48b7391365
71 changed files with 28910 additions and 0 deletions

13
.env.example Normal file
View File

@ -0,0 +1,13 @@
# DeepSeek API不配置时应用会自动进入可操作的本地演示模式
DEEPSEEK_API_KEY=
# 可选覆盖项
DEEPSEEK_MODEL=deepseek-v4-flash
DEEPSEEK_BASE_URL=https://api.deepseek.com
DATABASE_PATH=./data/skillloom.db
# 可选:覆盖当前模型的人民币单价(每百万 Token
DEEPSEEK_PRICE_CACHE_HIT_INPUT_CNY_PER_MILLION=0.02
DEEPSEEK_PRICE_CACHE_MISS_INPUT_CNY_PER_MILLION=1
DEEPSEEK_PRICE_OUTPUT_CNY_PER_MILLION=2
DEEPSEEK_PRICE_VERSION=deepseek-2026-04-24

47
.gitignore vendored Normal file
View File

@ -0,0 +1,47 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
!.env.example
# local sqlite data
/data/*.db
/data/*.db-*
/.acceptance/
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

5
AGENTS.md Normal file
View File

@ -0,0 +1,5 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->

1
CLAUDE.md Normal file
View File

@ -0,0 +1 @@
@AGENTS.md

105
README.md Normal file
View File

@ -0,0 +1,105 @@
# Skill Loom
一个可直接运行的单会话 AI Skill 工作台。用户可以组合多个 Skill 发起一轮对话,也可以通过与 AI 对话,按顺序完成五个节点来创建或编辑 Skill。
## 已实现
- 单会话聊天、SQLite 持久化与首轮 AI 主题标题
- 标题编辑、会话重置(重置后标题清空)
- DeepSeek V4 Flash 流式回答
- Skill 多选、拖拽排序、标签移除与 `/` 快速提示
- 每轮由 AI 独立判断实际使用哪些已选 Skill
- 候选 Skill 与实际使用 Skill 的差异化高亮反馈
- 回答中止;中止作为本轮终态处理,并清空本轮 Skill
- SSE 事件持久化、序号去重与断线续传
- Skill 新建、AI 对话编辑、删除与列表自动刷新
- 五节点严格顺序:触发条件、输入参数、执行步骤、输出格式、约束与测试
- 已完成节点可回改,未解锁节点不可跳过
- 右侧节点内容完全只读,只能通过 AI 对话修改
- 所有节点完成后才允许保存
- AI JSON 输出经过严格结构校验、纠错重试和服务端二次约束
- 无 API Key 时自动进入可操作的本地演示模式
## 本地运行
要求 Node.js 24 或更高版本(项目使用 Node 内置的 `node:sqlite`)。
```bash
npm install
```
复制环境变量模板:
```powershell
Copy-Item .env.example .env.local
```
`.env.local` 中设置:
```env
DEEPSEEK_API_KEY=你的_API_Key
DEEPSEEK_MODEL=deepseek-v4-flash
DEEPSEEK_BASE_URL=https://api.deepseek.com
DATABASE_PATH=./data/skillloom.db
```
启动:
```bash
npm run dev
```
访问 `http://localhost:3000`。未配置 `DEEPSEEK_API_KEY` 时也可以完整体验 UI、Skill 创建流程、流式状态、中止和重连。
## 数据与流式设计
SQLite 默认写入 `data/skillloom.db`,包含:
- `skills` / `skill_nodes`
- `conversations` / `messages`
- `chat_runs` / `chat_run_events`
主聊天采用“两步式 Run”
1. `POST /api/chat` 幂等创建回答任务。
2. `GET /api/chat/runs/:id/stream?after=:seq` 订阅 SSE。
每个事件先写入 SQLite 并获得单调递增的 `seq`,浏览器断线后携带最后序号重连,因此不会重复拼接 token。显式中止使用独立接口不会把普通网络断开误判为用户中止。
结构化 AI 输出经过四层保护:
1. DeepSeek JSON Output
2. Zod `.strict()` 结构校验;
3. 校验失败后自动纠错重试一次;
4. 服务端过滤非法 Skill ID并强制 Skill 节点顺序。
## 验证
```bash
npm run lint
npm run build
npm run test:acceptance
```
自动验收会使用独立临时 SQLite 和本地演示模型,覆盖:
- AI 对话按序创建 Skill
- 仅通过 AI 对话编辑 Skill
- 删除 Skill 与列表刷新
- 聊天选择和实际调用 Skill
- 返回是否调用及具体 Skill
- 中止作为终态
- 首轮 AI 主题标题、标题编辑与会话重置
- SSE 断线重连、事件去重和继续生成
## 主要目录
```text
src/
app/api/ API 与 SSE 路由
components/ 聊天、Skill 面板与 Builder UI
lib/db.ts SQLite schema 与数据访问
lib/deepseek.ts DeepSeek、严格 JSON 与提示词
lib/chat-runs.ts 可恢复的聊天 Run
scripts/acceptance.mjs 自动验收
```

21
components.json Normal file
View File

@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}

18
eslint.config.mjs Normal file
View File

@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

7
next.config.ts Normal file
View File

@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

10522
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

53
package.json Normal file
View File

@ -0,0 +1,53 @@
{
"name": "ai-skill-chat",
"version": "0.1.0",
"private": true,
"engines": {
"node": ">=24.0.0"
},
"overrides": {
"postcss": "8.5.23",
"sharp": "0.35.3"
},
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint",
"test:acceptance": "node scripts/acceptance.mjs"
},
"dependencies": {
"@assistant-ui/react": "^0.14.27",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource-variable/manrope": "^5.3.0",
"@fontsource/ibm-plex-mono": "^5.3.0",
"@radix-ui/react-dialog": "^1.1.23",
"@radix-ui/react-popover": "^1.1.23",
"@radix-ui/react-tooltip": "^1.2.16",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.27.0",
"next": "16.2.12",
"radix-ui": "^1.6.7",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"tw-shimmer": "^0.4.12",
"zod": "^4.4.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^26.1.2",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.12",
"tailwindcss": "^4",
"typescript": "^5"
}
}

7086
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

5
pnpm-workspace.yaml Normal file
View File

@ -0,0 +1,5 @@
allowBuilds:
sharp: true
unrs-resolver: true
minimumReleaseAgeExclude:
- '@types/node@26.1.2'

7
postcss.config.mjs Normal file
View File

@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

1
public/file.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
public/globe.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
public/next.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
public/vercel.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
public/window.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

1051
scripts/acceptance.mjs Normal file

File diff suppressed because it is too large Load Diff

120
src/app/api/chat/route.ts Normal file
View File

@ -0,0 +1,120 @@
import { apiError } from "@/lib/api";
import { startChatRun } from "@/lib/chat-runs";
import {
createChatRunWithMessages,
getActiveChatRun,
getConversation,
getConversationContinuation,
getDefaultConversation,
getSkill,
listMessages,
} from "@/lib/db";
import { hasDeepSeekApiKey } from "@/lib/deepseek";
import type { ChatMessage, Skill } from "@/lib/types";
import { createId } from "@/lib/utils";
import { chatRequestSchema } from "@/lib/validation";
export const runtime = "nodejs";
export const maxDuration = 120;
export async function GET() {
try {
const conversation = getDefaultConversation();
const continuation = getConversationContinuation(conversation.id);
return Response.json({
conversation,
messages: listMessages(conversation.id),
activeRun: getActiveChatRun(conversation.id),
retainedSkillIds: continuation.skillIds,
continuation,
apiConfigured: hasDeepSeekApiKey(),
});
} catch (error) {
return apiError(error, "无法读取聊天记录");
}
}
export async function POST(request: Request) {
try {
const input = chatRequestSchema.parse(await request.json());
if (!getConversation(input.conversationId)) {
return Response.json({ error: "会话不存在" }, { status: 404 });
}
const existingMessages = listMessages(input.conversationId);
const continuation = getConversationContinuation(
input.conversationId,
);
const selectedSkills = [...new Set(input.selectedSkillIds)]
.map((id) => getSkill(id))
.filter(
(skill): skill is Skill => skill?.status === "active",
);
const userMessageId = createId("message");
const assistantMessageId = createId("message");
const runId = createId("run");
const now = new Date().toISOString();
const creation = createChatRunWithMessages({
conversationId: input.conversationId,
userMessage: {
id: userMessageId,
role: "user",
content: input.message,
selectedSkillIds: selectedSkills.map((skill) => skill.id),
usedSkillIds: [],
status: "complete",
createdAt: now,
},
assistantMessage: {
id: assistantMessageId,
role: "assistant",
content: "",
selectedSkillIds: selectedSkills.map((skill) => skill.id),
usedSkillIds: [],
status: "streaming",
createdAt: new Date(Date.now() + 1).toISOString(),
},
run: {
id: runId,
clientRequestId: input.clientRequestId,
userMessageId,
assistantMessageId,
},
});
if (creation.outcome === "existing") {
return Response.json({ run: creation.run });
}
if (creation.outcome === "active_conflict") {
return Response.json(
{ error: "当前已有一轮回答正在进行", run: creation.run },
{ status: 409 },
);
}
const run = creation.run;
const history = existingMessages
.filter((message) => message.status !== "error")
.slice(-20)
.map((message) => ({
role: message.role,
content: message.content,
})) satisfies Array<{
role: ChatMessage["role"];
content: string;
}>;
startChatRun({
runId,
conversationId: input.conversationId,
userMessage: input.message,
selectedSkills,
continuation,
history,
});
return Response.json({ run }, { status: 202 });
} catch (error) {
return apiError(error, "无法发送消息");
}
}

View File

@ -0,0 +1,22 @@
import { stopChatRun } from "@/lib/chat-runs";
export const runtime = "nodejs";
export async function POST(
request: Request,
context: RouteContext<"/api/chat/runs/[id]/stop">,
) {
const { id } = await context.params;
const payload = (await request.json().catch(() => null)) as {
visibleContent?: unknown;
} | null;
const visibleContent =
typeof payload?.visibleContent === "string"
? payload.visibleContent.slice(0, 200_000)
: undefined;
const result = await stopChatRun(id, { visibleContent });
if (!result.found) {
return Response.json({ error: "回答任务不存在" }, { status: 404 });
}
return Response.json(result);
}

View File

@ -0,0 +1,51 @@
import { getChatRun, listChatRunEvents } from "@/lib/db";
import { encodeSse, sseResponse } from "@/lib/sse";
export const runtime = "nodejs";
export const maxDuration = 120;
const terminalStatuses = new Set(["complete", "stopped", "error"]);
export async function GET(
request: Request,
context: RouteContext<"/api/chat/runs/[id]/stream">,
) {
const { id } = await context.params;
if (!getChatRun(id)) {
return Response.json({ error: "回答任务不存在" }, { status: 404 });
}
const url = new URL(request.url);
let cursor = Math.max(0, Number(url.searchParams.get("after") ?? "0") || 0);
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
try {
while (!request.signal.aborted) {
const events = listChatRunEvents(id, cursor);
for (const event of events) {
controller.enqueue(encodeSse(event));
cursor = Math.max(cursor, event.seq ?? cursor);
}
const run = getChatRun(id);
if (!run || terminalStatuses.has(run.status)) {
controller.close();
return;
}
await new Promise((resolve) => setTimeout(resolve, 140));
}
} catch {
try {
controller.close();
} catch {
// The browser disconnected between polling cycles.
}
}
},
});
return sseResponse(stream);
}

View File

@ -0,0 +1,32 @@
import { apiError } from "@/lib/api";
import {
getDefaultConversation,
renameConversation,
resetConversation,
} from "@/lib/db";
import { renameConversationSchema } from "@/lib/validation";
export const runtime = "nodejs";
export async function PATCH(request: Request) {
try {
const input = renameConversationSchema.parse(await request.json());
const conversation = getDefaultConversation();
const updated = renameConversation(conversation.id, input.title);
return Response.json({ conversation: updated });
} catch (error) {
return apiError(error, "无法修改会话标题");
}
}
export async function DELETE() {
try {
const conversation = getDefaultConversation();
return Response.json({
conversation: resetConversation(conversation.id),
});
} catch (error) {
return apiError(error, "无法重置会话");
}
}

View File

@ -0,0 +1,96 @@
import { apiError } from "@/lib/api";
import { deleteSkill, getSkill, saveSkill } from "@/lib/db";
import {
getSkillCapabilityViolations,
skillCapabilityErrorMessage,
} from "@/lib/skill-capabilities";
import {
getSkillNodeQualityIssues,
skillNodeQualityErrorMessage,
} from "@/lib/skill-node-quality";
import { saveSkillSchema } from "@/lib/validation";
export const runtime = "nodejs";
export async function GET(
_request: Request,
context: RouteContext<"/api/skills/[id]">,
) {
try {
const { id } = await context.params;
const skill = getSkill(id);
if (!skill) {
return Response.json({ error: "Skill 不存在" }, { status: 404 });
}
return Response.json({ skill });
} catch (error) {
return apiError(error, "无法读取 Skill");
}
}
export async function PUT(
request: Request,
context: RouteContext<"/api/skills/[id]">,
) {
try {
const { id } = await context.params;
if (!getSkill(id)) {
return Response.json({ error: "Skill 不存在" }, { status: 404 });
}
const input = saveSkillSchema.parse({
...(await request.json()),
id,
});
const capabilityViolations = getSkillCapabilityViolations(input);
if (capabilityViolations.length > 0) {
return Response.json(
{ error: skillCapabilityErrorMessage(capabilityViolations) },
{ status: 409 },
);
}
if (
(input.status ?? "active") === "active" &&
input.nodes.some((node) => !node.completed)
) {
return Response.json(
{ error: "五个节点全部完成后才能保存 Skill" },
{ status: 409 },
);
}
if ((input.status ?? "active") === "active") {
const qualityIssues = getSkillNodeQualityIssues(input.nodes);
if (qualityIssues.length > 0) {
return Response.json(
{ error: skillNodeQualityErrorMessage(qualityIssues) },
{ status: 409 },
);
}
}
return Response.json({ skill: saveSkill(input) });
} catch (error) {
return apiError(error, "无法更新 Skill");
}
}
export async function DELETE(
_request: Request,
context: RouteContext<"/api/skills/[id]">,
) {
try {
const { id } = await context.params;
const deleted = deleteSkill(id);
if (!deleted) {
return Response.json({ error: "Skill 不存在" }, { status: 404 });
}
return new Response(null, { status: 204 });
} catch (error) {
return apiError(error, "无法删除 Skill");
}
}

View File

@ -0,0 +1,15 @@
import { stopBuilderRun } from "@/lib/builder-runs";
export const runtime = "nodejs";
export async function POST(
_request: Request,
context: RouteContext<"/api/skills/builder/[id]/stop">,
) {
const { id } = await context.params;
if (!stopBuilderRun(id)) {
return Response.json({ error: "节点任务不存在或已过期" }, { status: 404 });
}
return Response.json({ stopped: true });
}

View File

@ -0,0 +1,60 @@
import { apiError } from "@/lib/api";
import {
getBuilderRunEvents,
getOrStartBuilderRun,
} from "@/lib/builder-runs";
import { encodeSse, sseResponse } from "@/lib/sse";
import { builderRequestSchema } from "@/lib/validation";
export const runtime = "nodejs";
export const maxDuration = 120;
const terminalStatuses = new Set(["complete", "stopped", "error"]);
export async function POST(request: Request) {
try {
const input = builderRequestSchema.parse(await request.json());
const run = getOrStartBuilderRun(input);
let cursor = input.afterSeq;
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
try {
while (!request.signal.aborted) {
const snapshot = getBuilderRunEvents(run.id, cursor);
if (!snapshot) {
controller.enqueue(
encodeSse({ type: "error", message: "节点任务已过期" }),
);
controller.close();
return;
}
for (const event of snapshot.events) {
controller.enqueue(encodeSse(event));
cursor = Math.max(cursor, event.seq ?? cursor);
}
if (terminalStatuses.has(snapshot.status)) {
controller.close();
return;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
} catch {
try {
controller.close();
} catch {
// A disconnected editor can reconnect with the last event seq.
}
}
},
});
return sseResponse(stream);
} catch (error) {
return apiError(error, "无法处理 Skill 对话");
}
}

View File

@ -0,0 +1,15 @@
import { apiError } from "@/lib/api";
import { generateBuilderSuggestions } from "@/lib/deepseek";
import { builderSuggestionsRequestSchema } from "@/lib/validation";
export const runtime = "nodejs";
export const maxDuration = 30;
export async function POST(request: Request) {
try {
const input = builderSuggestionsRequestSchema.parse(await request.json());
return Response.json(await generateBuilderSuggestions(input));
} catch (error) {
return apiError(error, "无法生成节点建议");
}
}

View File

@ -0,0 +1,58 @@
import { apiError } from "@/lib/api";
import { listSkills, saveSkill } from "@/lib/db";
import {
getSkillCapabilityViolations,
skillCapabilityErrorMessage,
} from "@/lib/skill-capabilities";
import {
getSkillNodeQualityIssues,
skillNodeQualityErrorMessage,
} from "@/lib/skill-node-quality";
import { saveSkillSchema } from "@/lib/validation";
export const runtime = "nodejs";
export async function GET() {
try {
return Response.json({ skills: listSkills() });
} catch (error) {
return apiError(error, "无法读取 Skill");
}
}
export async function POST(request: Request) {
try {
const input = saveSkillSchema.parse(await request.json());
const capabilityViolations = getSkillCapabilityViolations(input);
if (capabilityViolations.length > 0) {
return Response.json(
{ error: skillCapabilityErrorMessage(capabilityViolations) },
{ status: 409 },
);
}
if (
(input.status ?? "active") === "active" &&
input.nodes.some((node) => !node.completed)
) {
return Response.json(
{ error: "五个节点全部完成后才能保存 Skill" },
{ status: 409 },
);
}
if ((input.status ?? "active") === "active") {
const qualityIssues = getSkillNodeQualityIssues(input.nodes);
if (qualityIssues.length > 0) {
return Response.json(
{ error: skillNodeQualityErrorMessage(qualityIssues) },
{ status: 409 },
);
}
}
const skill = saveSkill(input);
return Response.json({ skill }, { status: 201 });
} catch (error) {
return apiError(error, "无法保存 Skill");
}
}

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

277
src/app/globals.css Normal file
View File

@ -0,0 +1,277 @@
@import "tailwindcss";
@import "tw-shimmer";
:root {
--radius: 0.625rem;
--background: #fafafa;
--foreground: #18181b;
--card: #ffffff;
--card-foreground: #18181b;
--popover: #ffffff;
--popover-foreground: #18181b;
--primary: #18181b;
--primary-foreground: #fafafa;
--secondary: #f4f4f5;
--secondary-foreground: #27272a;
--muted: #f4f4f5;
--muted-foreground: #71717a;
--accent: #f4f4f5;
--accent-foreground: #18181b;
--destructive: #dc2626;
--border: #e4e4e7;
--input: #e4e4e7;
--ring: #a1a1aa;
--canvas: var(--background);
--surface: #ffffff;
--ink: var(--foreground);
--slate: var(--muted-foreground);
--line: var(--border);
--cobalt: #18181b;
--cobalt-soft: #f4f4f5;
--mint: #16a34a;
--mint-dark: #166534;
--mint-soft: #f0fdf4;
--coral: var(--destructive);
--coral-soft: #fef2f2;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-canvas: var(--canvas);
--color-surface: var(--surface);
--color-ink: var(--ink);
--color-line: var(--line);
--color-cobalt: var(--cobalt);
--color-cobalt-soft: var(--cobalt-soft);
--color-mint: var(--mint);
--color-mint-dark: var(--mint-dark);
--color-mint-soft: var(--mint-soft);
--color-coral: var(--coral);
--color-coral-soft: var(--coral-soft);
--font-sans:
"Manrope Variable", "Noto Sans SC", "PingFang SC", "Microsoft YaHei UI",
sans-serif;
--font-display:
"Manrope Variable", "Noto Sans SC", "PingFang SC", "Microsoft YaHei UI",
sans-serif;
--font-mono: "IBM Plex Mono", "SFMono-Regular", Consolas, monospace;
@keyframes collapsible-down {
from {
height: 0;
}
to {
height: var(
--radix-collapsible-content-height,
var(--collapsible-panel-height, auto)
);
}
}
@keyframes collapsible-up {
from {
height: var(
--radix-collapsible-content-height,
var(--collapsible-panel-height, auto)
);
}
to {
height: 0;
}
}
}
* {
box-sizing: border-box;
}
html,
body {
min-height: 100%;
}
body {
margin: 0;
background: var(--canvas);
color: var(--ink);
font-family:
"Manrope Variable", "Noto Sans SC", "PingFang SC", "Microsoft YaHei UI",
sans-serif;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
button {
cursor: pointer;
}
button:disabled {
cursor: default;
}
::selection {
background: rgba(59, 130, 246, 0.28);
color: inherit;
}
::-webkit-scrollbar {
width: 7px;
height: 7px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
border: 2px solid transparent;
border-radius: 999px;
background: rgba(113, 113, 122, 0.2);
background-clip: padding-box;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(113, 113, 122, 0.34);
background-clip: padding-box;
}
.markdown {
overflow-wrap: anywhere;
}
.markdown > :first-child {
margin-top: 0;
}
.markdown > :last-child {
margin-bottom: 0;
}
.markdown p {
margin: 0.62em 0;
}
.markdown h1,
.markdown h2,
.markdown h3 {
margin: 1.15em 0 0.42em;
font-family:
"Manrope Variable", "Noto Sans SC", "PingFang SC", "Microsoft YaHei UI",
sans-serif;
font-weight: 800;
letter-spacing: -0.025em;
line-height: 1.35;
}
.markdown h1 {
font-size: 1.3em;
}
.markdown h2 {
font-size: 1.16em;
}
.markdown h3 {
font-size: 1.04em;
}
.markdown ul,
.markdown ol {
margin: 0.65em 0;
padding-left: 1.3em;
}
.markdown li {
margin: 0.22em 0;
}
.markdown li::marker {
color: var(--muted-foreground);
}
.markdown blockquote {
margin: 0.8em 0;
border-left: 2px solid var(--border);
border-radius: 0 8px 8px 0;
background: var(--muted);
padding: 0.5em 0.8em;
color: var(--muted-foreground);
}
.markdown code {
border-radius: 5px;
background: #eef1f5;
padding: 0.14em 0.36em;
font-family: "IBM Plex Mono", Consolas, monospace;
font-size: 0.88em;
}
.markdown pre {
max-width: 100%;
overflow-x: auto;
border-radius: 12px;
background: #18181b;
padding: 1em;
color: #eef2fb;
}
.markdown pre code {
background: transparent;
padding: 0;
color: inherit;
}
.markdown table {
display: block;
max-width: 100%;
overflow-x: auto;
border-collapse: collapse;
font-size: 0.92em;
}
.markdown th,
.markdown td {
border: 1px solid var(--line);
padding: 0.45em 0.7em;
text-align: left;
}
.markdown th {
background: var(--canvas);
font-weight: 750;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
@custom-variant data-open (&:where([data-state="open"], [data-open]:not([data-open="false"])));
@custom-variant data-closed (&:where([data-state="closed"], [data-closed]:not([data-closed="false"])));

27
src/app/layout.tsx Normal file
View File

@ -0,0 +1,27 @@
import type { Metadata } from "next";
import "@fontsource-variable/manrope";
import "@fontsource/ibm-plex-mono/500.css";
import "@fontsource/ibm-plex-mono/600.css";
import "./globals.css";
export const metadata: Metadata = {
title: {
default: "Skill Loom · AI Skill 工作台",
template: "%s · Skill Loom",
},
description:
"组合、创建并在单轮 AI 对话中智能调用可复用 Skill 的工作台。",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="zh-CN" className="h-full">
<body className="min-h-full">{children}</body>
</html>
);
}

6
src/app/page.tsx Normal file
View File

@ -0,0 +1,6 @@
import { ChatWorkspace } from "@/components/chat-workspace";
export default function Home() {
return <ChatWorkspace />;
}

View File

@ -0,0 +1,9 @@
import { SkillBuilder } from "@/components/skill-builder";
export default async function EditSkillPage(
props: PageProps<"/skills/[id]">,
) {
const { id } = await props.params;
return <SkillBuilder skillId={id} />;
}

View File

@ -0,0 +1,6 @@
import { SkillBuilder } from "@/components/skill-builder";
export default function NewSkillPage() {
return <SkillBuilder />;
}

View File

@ -0,0 +1,293 @@
"use client";
import { LoaderCircle, Sparkles } from "lucide-react";
import {
AssistantRuntimeProvider,
MessagePrimitive,
ThreadPrimitive,
useAuiState,
useExternalStoreRuntime,
type AppendMessage,
type TextMessagePartProps,
type ThreadMessageLike,
} from "@assistant-ui/react";
import { BuilderComposer } from "@/components/builder-composer";
import { ChatErrorNotice } from "@/components/chat-error-notice";
import { StreamingMarkdown } from "@/components/streaming-markdown";
import { useSmoothFollow } from "@/components/use-smooth-follow";
import {
type BuilderMessage,
type BuilderSuggestionSource,
type SkillNodeKey,
} from "@/lib/types";
import { formatRelativeTime } from "@/lib/utils";
type BuilderMetadata = {
updatedNodeKeys?: SkillNodeKey[];
generationStatus?: string;
};
function getText(message: AppendMessage) {
return message.content
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("")
.trim();
}
function convertBuilderMessage(
message: BuilderMessage,
index: number,
messages: BuilderMessage[],
streaming: boolean,
generationStatus: string,
): ThreadMessageLike {
const running =
message.role === "assistant" &&
streaming &&
index === messages.length - 1;
return {
id: message.id,
role: message.role,
content: message.content,
createdAt: new Date(message.createdAt),
...(message.role === "assistant"
? {
status: running
? ({ type: "running" } as const)
: ({ type: "complete", reason: "stop" } as const),
}
: {}),
metadata: {
custom: {
updatedNodeKeys: message.updatedNodeKeys ?? [message.nodeKey],
generationStatus,
} satisfies BuilderMetadata,
},
};
}
function BuilderMessageTime() {
const createdAt = useAuiState((state) => state.message.createdAt);
return <span>{formatRelativeTime(createdAt.toISOString())}</span>;
}
function BuilderUserText({ text }: TextMessagePartProps) {
return <p className="whitespace-pre-wrap">{text}</p>;
}
function BuilderAssistantText() {
const metadata = useAuiState(
(state) => state.message.metadata.custom as BuilderMetadata,
);
return (
<StreamingMarkdown
pending={
<span className="flex items-center gap-2 text-slate-500">
<LoaderCircle className="size-3.5 animate-spin text-cobalt" />
{metadata.generationStatus || "思考中"}
</span>
}
/>
);
}
function BuilderUserMessage() {
return (
<MessagePrimitive.Root className="group flex justify-end">
<div className="flex min-w-0 max-w-[82%] flex-col items-end">
<div className="rounded-xl rounded-br-sm bg-muted px-2.5 py-1.5 text-xs leading-5 text-foreground">
<MessagePrimitive.Parts components={{ Text: BuilderUserText }} />
</div>
<div className="mt-0.5 px-1 text-[9px] text-muted-foreground/60 opacity-0 transition-opacity group-hover:opacity-100">
<BuilderMessageTime />
</div>
</div>
</MessagePrimitive.Root>
);
}
function BuilderAssistantMessage() {
return (
<MessagePrimitive.Root className="flex">
<div className="min-w-0 w-full max-w-[640px]">
<div className="mb-1 flex flex-wrap items-center gap-1.5 text-[9px] text-muted-foreground/70">
<span className="font-semibold text-foreground/70">Skill </span>
<BuilderMessageTime />
</div>
<div className="text-xs leading-5 text-foreground">
<MessagePrimitive.Parts
components={{ Text: BuilderAssistantText }}
/>
</div>
</div>
</MessagePrimitive.Root>
);
}
function BuilderSuggestions({
suggestions,
source,
onSend,
}: {
suggestions: string[];
source: BuilderSuggestionSource;
onSend: (message: string) => Promise<void>;
}) {
const styles = [
"border-emerald-200 bg-emerald-50/70 text-emerald-800",
"border-sky-200 bg-sky-50/70 text-sky-800",
"border-violet-200 bg-violet-50/70 text-violet-800",
];
if (source === "idle") return null;
if (suggestions.length === 0 && source !== "loading") return null;
const generatedByAi = source === "ai";
return (
<div className="border-t border-border/70 pt-2">
<div
className="mb-1.5 flex items-center gap-1 text-[9px] font-semibold text-muted-foreground"
title={
generatedByAi
? "由 DeepSeek 根据当前节点实时生成"
: "本地示例,用于演示或 AI 服务不可用时降级"
}
>
{source === "loading" ? (
<LoaderCircle className="size-2.5 animate-spin" />
) : (
<Sparkles className="size-2.5" />
)}
{source === "loading"
? "正在生成建议"
: generatedByAi
? "DeepSeek 建议"
: "示例建议"}
</div>
<div className="flex flex-wrap gap-1">
{suggestions.map((suggestion, index) => {
const style = styles[index % styles.length];
return (
<button
key={suggestion}
type="button"
className={`inline-flex min-h-6 items-center gap-1 rounded-md border px-1.5 py-0.5 text-left text-[9px] font-medium leading-3.5 transition-[border-color,background-color] hover:border-current/30 hover:bg-background ${style}`}
onClick={() => void onSend(suggestion)}
>
<span className="font-mono text-[7px] opacity-50">
{index + 1}
</span>
{suggestion}
</button>
);
})}
</div>
</div>
);
}
export function AssistantBuilderChat({
messages,
loading,
streaming,
generationStatus,
suggestions,
suggestionSource,
sendFailure,
onSend,
onStop,
onRetry,
onDismissFailure,
}: {
messages: BuilderMessage[];
loading: boolean;
streaming: boolean;
generationStatus: string;
suggestions: string[];
suggestionSource: BuilderSuggestionSource;
sendFailure: { content: string; detail: string } | null;
onSend: (message: string) => Promise<void>;
onStop: () => void;
onRetry: () => void;
onDismissFailure: () => void;
}) {
const runtime = useExternalStoreRuntime<BuilderMessage>({
messages,
isLoading: loading,
isRunning: streaming,
convertMessage: (message, index) =>
convertBuilderMessage(
message,
index,
messages,
streaming,
generationStatus,
),
onNew: async (message) => {
const text = getText(message);
if (text) await onSend(text);
},
onCancel: async () => onStop(),
});
const latestMessage = messages.at(-1);
const smoothFollow = useSmoothFollow(
streaming,
latestMessage?.id ?? "empty",
);
return (
<AssistantRuntimeProvider runtime={runtime}>
<ThreadPrimitive.Root className="flex min-h-0 flex-1 flex-col">
{loading ? (
<div className="grid h-full place-items-center">
<div className="flex items-center gap-2 text-sm text-slate-500">
<LoaderCircle className="size-4 animate-spin text-cobalt" />
Skill
</div>
</div>
) : (
<>
<ThreadPrimitive.Viewport
{...smoothFollow}
autoScroll={false}
scrollToBottomOnRunStart={false}
className="min-h-0 flex-1 overflow-y-auto px-4 [scrollbar-gutter:stable] sm:px-6"
>
<div className="mx-auto max-w-[660px] space-y-3 py-3">
<ThreadPrimitive.Messages
components={{
UserMessage: BuilderUserMessage,
AssistantMessage: BuilderAssistantMessage,
}}
/>
{!streaming && (
<BuilderSuggestions
suggestions={suggestions}
source={suggestionSource}
onSend={onSend}
/>
)}
</div>
</ThreadPrimitive.Viewport>
<div className="relative z-10 shrink-0 bg-background px-4 pb-2.5 pt-1.5 sm:px-6">
<div className="mx-auto max-w-[660px]">
{sendFailure && (
<ChatErrorNotice
detail={sendFailure.detail}
onRetry={onRetry}
onDismiss={onDismissFailure}
/>
)}
<BuilderComposer isStreaming={streaming} />
</div>
</div>
</>
)}
</ThreadPrimitive.Root>
</AssistantRuntimeProvider>
);
}

View File

@ -0,0 +1,635 @@
"use client";
import {
AlertTriangle,
Check,
CircleStop,
Copy,
LoaderCircle,
Plus,
ReceiptText,
Sparkles,
} from "lucide-react";
import {
AssistantRuntimeProvider,
MessagePrimitive,
ThreadPrimitive,
useAuiState,
useExternalStoreRuntime,
type AppendMessage,
type TextMessagePartProps,
type ThreadMessageLike,
} from "@assistant-ui/react";
import Link from "next/link";
import { useState } from "react";
import { toast } from "sonner";
import { ChatComposer } from "@/components/chat-composer";
import { ChatErrorNotice } from "@/components/chat-error-notice";
import { StreamingMarkdown } from "@/components/streaming-markdown";
import { ToolFallback } from "@/components/tool-fallback";
import { useSmoothFollow } from "@/components/use-smooth-follow";
import {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
} from "@/components/ui/popover";
import type { ChatMessage, Skill } from "@/lib/types";
import { cn, formatRelativeTime } from "@/lib/utils";
type SendFailure = {
content: string;
selectedIds: string[];
detail: string;
};
type ChatMessageMetadata = {
selectedSkillIds?: string[];
usedSkillIds?: string[];
skillDecisionResolved?: boolean;
generationStatus?: string;
errorMessage?: string;
sourceStatus?: ChatMessage["status"];
rawContent?: string;
usage?: ChatMessage["usage"];
};
const exactTokenFormatter = new Intl.NumberFormat("zh-CN");
const compactTokenFormatter = new Intl.NumberFormat("zh-CN", {
notation: "compact",
maximumFractionDigits: 1,
});
function formatCompactTokens(value: number) {
return value < 10_000
? exactTokenFormatter.format(value)
: compactTokenFormatter.format(value);
}
function formatCost(costMicros: number | undefined) {
if (typeof costMicros !== "number") return "费用待配置";
if (costMicros === 0) return "¥0";
const yuan = costMicros / 1_000_000;
if (yuan < 0.0001) return "< ¥0.0001";
if (yuan < 0.01) return `¥${yuan.toFixed(4)}`;
return `¥${yuan.toFixed(3)}`;
}
function formatDuration(durationMs: number | undefined) {
if (typeof durationMs !== "number") return "";
if (durationMs < 1_000) {
return `${Math.max(1, Math.round(durationMs))} 毫秒`;
}
if (durationMs < 10_000) {
return `${(durationMs / 1_000).toFixed(1).replace(/\.0$/, "")}`;
}
const totalSeconds = Math.round(durationMs / 1_000);
if (totalSeconds < 60) return `${totalSeconds}`;
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return seconds > 0 ? `${minutes}${seconds}` : `${minutes} 分钟`;
}
function getText(message: AppendMessage) {
return message.content
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("")
.trim();
}
function toAssistantMessage(
message: ChatMessage,
skills: Skill[],
generationStatus: string,
resolvedSkillIds: string[] | null,
): ThreadMessageLike {
const usedSkills = message.usedSkillIds
.map((id) => skills.find((skill) => skill.id === id))
.filter((skill): skill is Skill => Boolean(skill));
const isRunning = message.status === "streaming";
type AssistantContentPart = Exclude<
ThreadMessageLike["content"],
string
>[number];
const content: AssistantContentPart[] = [];
for (const [position, skill] of usedSkills.entries()) {
content.push({
type: "tool-call",
toolCallId: `skill_${message.id}_${skill.id}`,
toolName: skill.name,
args: {
skillName: skill.name,
description: skill.description,
position: position + 1,
},
...(isRunning
? {}
: {
result: {
used: true,
skillName: skill.name,
},
}),
});
}
if (message.content) {
content.push({ type: "text", text: message.content });
}
const status: ThreadMessageLike["status"] =
message.status === "streaming"
? { type: "running" }
: message.status === "stopped"
? { type: "incomplete", reason: "cancelled" }
: message.status === "error"
? {
type: "incomplete",
reason: "error",
error: message.errorMessage ?? "回答生成失败",
}
: { type: "complete", reason: "stop" };
return {
id: message.id,
role: "assistant",
content,
createdAt: new Date(message.createdAt),
status,
metadata: {
custom: {
selectedSkillIds: message.selectedSkillIds,
usedSkillIds: message.usedSkillIds,
skillDecisionResolved: isRunning
? resolvedSkillIds !== null
: true,
generationStatus,
errorMessage: message.errorMessage,
sourceStatus: message.status,
rawContent: message.content,
usage: message.usage,
} satisfies ChatMessageMetadata,
},
};
}
function convertMessage(
message: ChatMessage,
skills: Skill[],
generationStatus: string,
resolvedSkillIds: string[] | null,
): ThreadMessageLike {
if (message.role === "assistant") {
return toAssistantMessage(
message,
skills,
generationStatus,
resolvedSkillIds,
);
}
return {
id: message.id,
role: "user",
content: message.content,
createdAt: new Date(message.createdAt),
metadata: {
custom: {
selectedSkillIds: message.selectedSkillIds,
sourceStatus: message.status,
} satisfies ChatMessageMetadata,
},
};
}
function UserText({ text }: TextMessagePartProps) {
return <p className="whitespace-pre-wrap">{text}</p>;
}
function AssistantText() {
const metadata = useAuiState(
(state) => state.message.metadata.custom as ChatMessageMetadata,
);
return (
<StreamingMarkdown
pending={
<div className="flex items-center gap-2 text-sm text-slate-500">
<LoaderCircle className="size-4 animate-spin text-cobalt" />
<span>{metadata.generationStatus || "正在思考"}</span>
</div>
}
/>
);
}
function MessageTime() {
const createdAt = useAuiState((state) => state.message.createdAt);
return <span>{formatRelativeTime(createdAt.toISOString())}</span>;
}
function UserMessage() {
return (
<MessagePrimitive.Root className="group flex justify-end">
<div className="flex min-w-0 max-w-[min(560px,86%)] flex-col items-end">
<div className="min-w-0 rounded-3xl rounded-br-lg bg-muted px-4 py-2.5 text-[14px] leading-6 text-foreground">
<MessagePrimitive.Parts components={{ Text: UserText }} />
</div>
<div className="mt-1 px-1 text-[10px] text-muted-foreground/60 opacity-0 transition-opacity group-hover:opacity-100">
<MessageTime />
</div>
</div>
</MessagePrimitive.Root>
);
}
function UsageDetails({
usage,
}: {
usage: NonNullable<ChatMessage["usage"]>;
}) {
const rows = [
["输入 Token", exactTokenFormatter.format(usage.promptTokens)],
["缓存命中", exactTokenFormatter.format(usage.promptCacheHitTokens)],
["缓存未命中", exactTokenFormatter.format(usage.promptCacheMissTokens)],
["输出 Token", exactTokenFormatter.format(usage.completionTokens)],
...(usage.reasoningTokens > 0
? ([
[
"其中推理",
exactTokenFormatter.format(usage.reasoningTokens),
],
] as const)
: []),
["总 Token", exactTokenFormatter.format(usage.totalTokens)],
...(typeof usage.durationMs === "number"
? ([["本轮用时", formatDuration(usage.durationMs)]] as const)
: []),
] as const;
return (
<PopoverContent
align="start"
sideOffset={8}
className="w-[min(320px,calc(100vw-2rem))] rounded-xl p-0 shadow-lg"
>
<PopoverHeader className="border-b border-border px-4 py-3">
<PopoverTitle className="flex items-center gap-2 text-[13px]">
<ReceiptText className="size-3.5 text-muted-foreground" />
</PopoverTitle>
<PopoverDescription className="text-[11px] leading-5">
</PopoverDescription>
</PopoverHeader>
{usage.status === "demo" ? (
<div className="px-4 py-4 text-xs leading-5 text-muted-foreground">
<p> Token </p>
{typeof usage.durationMs === "number" && (
<p className="mt-2 font-mono tabular-nums text-foreground">
{formatDuration(usage.durationMs)}
</p>
)}
</div>
) : usage.status === "unavailable" ? (
<div className="px-4 py-4 text-xs leading-5 text-muted-foreground">
<p></p>
{typeof usage.durationMs === "number" && (
<p className="mt-2 font-mono tabular-nums text-foreground">
{formatDuration(usage.durationMs)}
</p>
)}
</div>
) : (
<>
<dl className="grid grid-cols-[1fr_auto] gap-x-6 gap-y-2 px-4 py-3 text-xs">
{rows.map(([label, value]) => (
<div className="contents" key={label}>
<dt className="text-muted-foreground">{label}</dt>
<dd className="font-mono tabular-nums text-foreground">
{value}
</dd>
</div>
))}
</dl>
<div className="flex items-end justify-between gap-4 border-t border-border bg-muted/45 px-4 py-3">
<div className="min-w-0 text-[10px] leading-4 text-muted-foreground">
<p className="truncate">
{usage.models.join("、") || "模型未记录"}
</p>
<p>
{usage.callCount}
{usage.status === "partial" ? " · 数据可能不完整" : ""}
</p>
</div>
<div className="shrink-0 text-right">
<p className="font-mono text-sm font-semibold tabular-nums text-foreground">
{formatCost(usage.estimatedCostMicros)}
</p>
<p className="text-[9px] text-muted-foreground"></p>
</div>
</div>
</>
)}
</PopoverContent>
);
}
function MessageReceipt() {
const metadata = useAuiState(
(state) => state.message.metadata.custom as ChatMessageMetadata,
);
const [copied, setCopied] = useState(false);
const content = metadata.rawContent?.trim() ?? "";
const isFinished =
metadata.sourceStatus === "complete" ||
metadata.sourceStatus === "stopped" ||
metadata.sourceStatus === "error";
if (!content || !isFinished) return null;
async function copyAnswer() {
try {
await navigator.clipboard.writeText(content);
setCopied(true);
window.setTimeout(() => setCopied(false), 1_800);
} catch {
toast.error("无法复制,请手动选择回答内容");
}
}
const usage = metadata.usage;
const durationLabel = formatDuration(usage?.durationMs);
const usageLabel = usage
? usage.status === "demo"
? ["演示模式", durationLabel, "¥0"].filter(Boolean).join(" · ")
: usage.status === "unavailable"
? ["用量未记录", durationLabel].filter(Boolean).join(" · ")
: [
`${formatCompactTokens(usage.totalTokens)} tokens`,
durationLabel,
`${formatCost(usage.estimatedCostMicros)}`,
]
.filter(Boolean)
.join(" · ")
: "";
return (
<div className="mt-3 flex min-h-7 flex-wrap items-center gap-1 border-t border-border/70 pt-2 text-[10px] text-muted-foreground">
<button
type="button"
onClick={() => void copyAnswer()}
className="inline-flex h-7 items-center gap-1.5 rounded-md px-2 font-medium transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label="复制回答"
>
{copied ? (
<Check className="size-3.5 text-emerald-600" />
) : (
<Copy className="size-3.5" />
)}
{copied ? "已复制" : "复制"}
</button>
{usage && (
<>
<span aria-hidden="true" className="text-border">
/
</span>
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className="inline-flex h-7 items-center gap-1.5 rounded-md px-2 font-mono tabular-nums transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label="查看本轮运行信息"
>
<ReceiptText className="size-3.5" />
{usageLabel}
</button>
</PopoverTrigger>
<UsageDetails usage={usage} />
</Popover>
</>
)}
</div>
);
}
function AssistantMessage() {
const metadata = useAuiState(
(state) => state.message.metadata.custom as ChatMessageMetadata,
);
return (
<MessagePrimitive.Root className="group flex">
<div className="min-w-0 w-full max-w-[680px]">
<div className="mb-2 flex items-center gap-2 text-[10px] text-muted-foreground/65">
<span className="font-semibold text-foreground/70">Skill Loom</span>
<MessageTime />
</div>
<div
className={cn(
"min-w-0 text-[14px] leading-7 text-foreground",
metadata.sourceStatus !== "error" && "w-full",
)}
>
<MessagePrimitive.Parts
components={{
Text: AssistantText,
tools: { Fallback: ToolFallback },
}}
/>
</div>
{metadata.sourceStatus === "error" && (
<div className="w-full rounded-xl border border-coral/20 bg-coral-soft px-4 py-3 text-coral">
<div className="flex items-start gap-2.5">
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
<div>
<p className="text-xs font-bold"></p>
<p className="mt-1 text-xs leading-5 text-coral/80">
{metadata.errorMessage ||
"生成回答时发生错误,请重新发送这条消息。"}
</p>
</div>
</div>
</div>
)}
{metadata.sourceStatus === "stopped" && (
<div className="mt-2 inline-flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground">
<CircleStop className="size-3.5" />
</div>
)}
<MessageReceipt />
</div>
</MessagePrimitive.Root>
);
}
function Welcome({
onOpenSkillPicker,
}: {
onOpenSkillPicker: () => void;
}) {
return (
<div className="flex min-h-full items-center justify-center py-12">
<div className="max-w-[540px] text-center">
<div className="mx-auto grid size-9 place-items-center rounded-xl border border-border bg-background text-foreground shadow-sm">
<Sparkles className="size-4" />
</div>
<h1 className="mt-5 font-display text-[clamp(26px,4vw,38px)] font-semibold leading-[1.12] tracking-[-0.04em] text-foreground">
</h1>
<p className="mx-auto mt-3 max-w-md text-sm leading-6 text-muted-foreground">
</p>
<div className="mt-7 flex flex-wrap justify-center gap-2">
<button
type="button"
onClick={onOpenSkillPicker}
className="inline-flex h-9 items-center gap-2 rounded-lg border border-border bg-background px-3.5 text-xs font-medium text-foreground shadow-xs transition-colors hover:bg-accent"
>
<Plus className="size-3.5" />
Skill
</button>
<Link
href="/skills/new"
className="inline-flex h-9 items-center gap-2 rounded-lg bg-primary px-3.5 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<Sparkles className="size-3.5" />
Skill
</Link>
</div>
</div>
</div>
);
}
export function AssistantChat({
skills,
messages,
selectedIds,
resolvedSkillIds,
loading,
streaming,
stopping,
generationStatus,
sendFailure,
canSend,
onSelectedChange,
onOpenSkillPicker,
onSend,
onStop,
onRetry,
onDismissFailure,
}: {
skills: Skill[];
messages: ChatMessage[];
selectedIds: string[];
resolvedSkillIds: string[] | null;
loading: boolean;
streaming: boolean;
stopping: boolean;
generationStatus: string;
sendFailure: SendFailure | null;
canSend: boolean;
onSelectedChange: (ids: string[]) => void;
onOpenSkillPicker: () => void;
onSend: (message: string) => Promise<void>;
onStop: () => Promise<void>;
onRetry: () => void;
onDismissFailure: () => void;
}) {
const runtime = useExternalStoreRuntime<ChatMessage>({
messages,
isLoading: loading,
isRunning: streaming,
isSendDisabled: !canSend,
convertMessage: (message) =>
convertMessage(
message,
skills,
generationStatus,
resolvedSkillIds,
),
onNew: async (message) => {
const text = getText(message);
if (text) await onSend(text);
},
onCancel: onStop,
});
const latestMessage = messages.at(-1);
const smoothFollow = useSmoothFollow(
streaming,
latestMessage?.id ?? "empty",
);
return (
<AssistantRuntimeProvider runtime={runtime}>
<ThreadPrimitive.Root className="flex min-h-0 flex-1 flex-col">
{loading ? (
<div className="grid h-full place-items-center">
<div className="flex items-center gap-2 text-sm text-slate-500">
<LoaderCircle className="size-4 animate-spin text-cobalt" />
</div>
</div>
) : (
<>
<ThreadPrimitive.Viewport
{...smoothFollow}
autoScroll={false}
scrollToBottomOnRunStart={false}
className="min-h-0 flex-1 overflow-y-auto px-4 [scrollbar-gutter:stable] sm:px-8"
>
{messages.length === 0 ? (
<Welcome onOpenSkillPicker={onOpenSkillPicker} />
) : (
<div className="mx-auto max-w-[704px] space-y-7 py-6">
<ThreadPrimitive.Messages
components={{
UserMessage,
AssistantMessage,
}}
/>
</div>
)}
</ThreadPrimitive.Viewport>
<div className="relative z-10 shrink-0 bg-background px-4 pb-4 pt-3 sm:px-8 sm:pb-6">
<div className="mx-auto max-w-[704px]">
{sendFailure && (
<ChatErrorNotice
detail={sendFailure.detail}
onRetry={onRetry}
onDismiss={onDismissFailure}
/>
)}
<ChatComposer
skills={skills}
selectedIds={selectedIds}
resolvedSkillIds={resolvedSkillIds}
onSelectedChange={onSelectedChange}
onOpenSkillPicker={onOpenSkillPicker}
isStreaming={streaming}
isStopping={stopping}
/>
</div>
</div>
</>
)}
</ThreadPrimitive.Root>
</AssistantRuntimeProvider>
);
}

View File

@ -0,0 +1,30 @@
import { Braces } from "lucide-react";
import { cn } from "@/lib/utils";
export function BrandMark({
compact = false,
className,
}: {
compact?: boolean;
className?: string;
}) {
return (
<div className={cn("flex items-center gap-3", className)}>
<div className="relative grid size-10 place-items-center overflow-hidden rounded-[14px] bg-ink text-white shadow-[0_10px_26px_rgba(18,31,55,0.2)]">
<span className="absolute inset-y-0 left-0 w-1.5 bg-cobalt" />
<Braces className="size-[18px]" strokeWidth={2.2} />
</div>
{!compact && (
<div className="leading-none">
<div className="font-display text-[17px] font-extrabold tracking-[-0.035em] text-ink">
Skill Loom
</div>
<div className="mt-1.5 font-mono text-[9px] font-medium uppercase tracking-[0.22em] text-slate-400">
AI Skill Workspace
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,44 @@
"use client";
import { ArrowUp, CornerDownLeft, Square } from "lucide-react";
import { ComposerPrimitive } from "@assistant-ui/react";
export function BuilderComposer({
isStreaming,
}: {
isStreaming: boolean;
}) {
return (
<ComposerPrimitive.Root className="rounded-lg border border-[#E9BD36] bg-background shadow-[0_2px_8px_rgba(0,0,0,0.06)] transition-[border-color,box-shadow] focus-within:border-[#C99A00] focus-within:shadow-[0_3px_10px_rgba(0,0,0,0.08)]">
<ComposerPrimitive.Input
rows={1}
maxRows={5}
className="block min-h-10 w-full resize-none bg-transparent px-3 pb-0.5 pt-2.5 text-xs leading-5 text-foreground outline-none placeholder:text-muted-foreground/70"
placeholder="描述想补充或修改的内容AI 会判断对应节点…"
aria-label="补充或修改 Skill 内容"
/>
<div className="flex items-center justify-between px-2 pb-2">
<span className="hidden items-center gap-1 text-[8px] text-muted-foreground/70 sm:flex">
<CornerDownLeft className="size-2.5" />
Enter · Shift + Enter
</span>
<span className="sm:hidden" />
{isStreaming ? (
<ComposerPrimitive.Cancel
className="grid size-7 place-items-center rounded-full bg-destructive text-white transition-colors hover:bg-destructive/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive/30 focus-visible:ring-offset-2"
aria-label="中止生成"
>
<Square className="size-3 fill-current" />
</ComposerPrimitive.Cancel>
) : (
<ComposerPrimitive.Send
className="grid size-7 place-items-center rounded-full bg-[#07C160] text-white transition-colors hover:bg-[#06AD56] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/40 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:bg-muted-foreground/35"
aria-label="发送"
>
<ArrowUp className="size-3.5" strokeWidth={2.4} />
</ComposerPrimitive.Send>
)}
</div>
</ComposerPrimitive.Root>
);
}

View File

@ -0,0 +1,335 @@
"use client";
import {
ArrowUpDown,
ArrowUp,
ChevronsRight,
Command,
CornerDownLeft,
LoaderCircle,
Plus,
Square,
X,
} from "lucide-react";
import {
ComposerPrimitive,
useAui,
useAuiState,
} from "@assistant-ui/react";
import {
Fragment,
useId,
useMemo,
useRef,
useState,
type KeyboardEvent,
} from "react";
import type { Skill } from "@/lib/types";
import { cn } from "@/lib/utils";
import { SkillTag } from "@/components/skill-tag";
export function ChatComposer({
skills,
selectedIds,
resolvedSkillIds,
onSelectedChange,
onOpenSkillPicker,
isStreaming,
isStopping,
}: {
skills: Skill[];
selectedIds: string[];
resolvedSkillIds: string[] | null;
onSelectedChange: (ids: string[]) => void;
onOpenSkillPicker: () => void;
isStreaming: boolean;
isStopping: boolean;
}) {
const aui = useAui();
const value = useAuiState((state) => state.composer.text);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const listboxId = useId();
const [activeSuggestionId, setActiveSuggestionId] = useState<string | null>(
null,
);
const slashMatch = value.match(/(?:^|\s)\/([^\s/]*)$/);
const slashQuery = slashMatch?.[1]?.toLowerCase() ?? null;
const availableSuggestions = useMemo(() => {
if (slashQuery === null) return [];
return skills
.filter((skill) => !selectedIds.includes(skill.id))
.filter((skill) =>
`${skill.name} ${skill.description}`
.toLowerCase()
.includes(slashQuery),
)
.slice(0, 6);
}, [skills, selectedIds, slashQuery]);
const matchedSuggestionIndex = availableSuggestions.findIndex(
(skill) => skill.id === activeSuggestionId,
);
const activeSuggestionIndex =
matchedSuggestionIndex === -1 ? 0 : matchedSuggestionIndex;
const selectedSkills = selectedIds
.map((id) => skills.find((skill) => skill.id === id))
.filter((skill): skill is Skill => Boolean(skill));
const routeResolved = isStreaming && resolvedSkillIds !== null;
function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
if (!slashMatch || event.nativeEvent.isComposing) return;
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
closeSlashMenu();
return;
}
if (availableSuggestions.length === 0) return;
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
event.stopPropagation();
const direction = event.key === "ArrowDown" ? 1 : -1;
const nextIndex =
(activeSuggestionIndex + direction + availableSuggestions.length) %
availableSuggestions.length;
setActiveSuggestionId(availableSuggestions[nextIndex].id);
return;
}
if (
(event.key === "Enter" && !event.shiftKey) ||
event.key === "Tab"
) {
event.preventDefault();
event.stopPropagation();
addSkill(
availableSuggestions[
Math.min(activeSuggestionIndex, availableSuggestions.length - 1)
],
);
}
}
function closeSlashMenu() {
setActiveSuggestionId(null);
aui.composer().setText(value.replace(/(?:^|\s)\/([^\s/]*)$/, (match) =>
match.startsWith(" ") ? " " : "",
));
requestAnimationFrame(() => textareaRef.current?.focus());
}
function addSkill(skill: Skill) {
setActiveSuggestionId(null);
onSelectedChange([...selectedIds, skill.id]);
aui.composer().setText(
value.replace(/(?:^|\s)\/([^\s/]*)$/, (match) =>
match.startsWith(" ") ? " " : "",
),
);
requestAnimationFrame(() => textareaRef.current?.focus());
}
return (
<ComposerPrimitive.Root className="relative">
{slashMatch && (
<div
id={listboxId}
role="listbox"
aria-label="可添加的 Skill"
className="absolute bottom-[calc(100%+10px)] left-0 z-20 w-[min(440px,calc(100vw-32px))] overflow-hidden rounded-xl border border-border bg-popover p-1.5 text-popover-foreground shadow-lg"
>
<div className="flex items-center justify-between px-2 pb-2 pt-1">
<span className="text-[11px] font-medium text-muted-foreground">
Skill
</span>
<span className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
<ArrowUpDown className="size-3" />
<span className="text-muted-foreground/45">·</span>
<CornerDownLeft className="size-3" />
</span>
</div>
{availableSuggestions.length > 0 ? (
<div className="space-y-1">
{availableSuggestions.map((skill, index) => (
<button
key={skill.id}
id={`${listboxId}-${skill.id}`}
type="button"
role="option"
aria-selected={index === activeSuggestionIndex}
className={cn(
"flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left hover:bg-accent",
index === activeSuggestionIndex && "bg-accent/70",
)}
onMouseDown={(event) => event.preventDefault()}
onMouseEnter={() => setActiveSuggestionId(skill.id)}
onClick={() => addSkill(skill)}
>
<span className="grid size-7 place-items-center rounded-md bg-muted font-mono text-[10px] font-bold text-foreground">
/
</span>
<span className="min-w-0">
<span className="block truncate text-sm font-medium text-foreground">
{skill.name}
</span>
<span className="mt-0.5 block truncate text-xs text-muted-foreground">
{skill.description}
</span>
</span>
</button>
))}
</div>
) : (
<div className="rounded-lg bg-muted/50 px-3 py-4 text-center text-xs text-muted-foreground">
Skill
</div>
)}
</div>
)}
<div className="overflow-hidden rounded-3xl border border-border/80 bg-muted/35 shadow-[0_1px_2px_rgba(0,0,0,0.03),0_8px_24px_rgba(0,0,0,0.04)] transition-[border-color,box-shadow] focus-within:border-ring focus-within:shadow-[0_1px_2px_rgba(0,0,0,0.03),0_10px_30px_rgba(0,0,0,0.06)]">
{selectedSkills.length > 0 && (
<div className="px-3.5 pt-2.5">
<div className="flex flex-wrap items-center gap-1">
{selectedSkills.map((skill, index) => {
const status = !isStreaming
? "selected"
: !routeResolved
? "checking"
: resolvedSkillIds.includes(skill.id)
? "used"
: "unused";
return (
<Fragment key={skill.id}>
{index > 0 && (
<ChevronsRight
aria-hidden="true"
className={cn(
"mx-0.5 size-3.5 shrink-0 text-muted-foreground/35",
isStreaming &&
!routeResolved &&
"animate-pulse text-amber-500/70",
routeResolved &&
resolvedSkillIds?.includes(skill.id) &&
"text-emerald-500/80",
)}
/>
)}
<SkillTag
name={skill.name}
status={status}
showStatusLabel={false}
className="h-6 px-2 text-[10px]"
onRemove={
isStreaming
? undefined
: () =>
onSelectedChange(
selectedIds.filter((id) => id !== skill.id),
)
}
/>
</Fragment>
);
})}
</div>
</div>
)}
<ComposerPrimitive.Input
ref={textareaRef}
onKeyDown={handleKeyDown}
aria-autocomplete="list"
aria-controls={slashMatch ? listboxId : undefined}
aria-expanded={Boolean(slashMatch)}
aria-activedescendant={
slashMatch && availableSuggestions.length > 0
? `${listboxId}-${
availableSuggestions[
Math.min(
activeSuggestionIndex,
availableSuggestions.length - 1,
)
].id
}`
: undefined
}
rows={1}
maxRows={7}
placeholder="说说你想解决什么,输入 / 添加 Skill…"
className="block min-h-[68px] w-full resize-none bg-transparent px-4 pb-1.5 pt-3.5 text-[15px] leading-6 text-foreground outline-none placeholder:text-muted-foreground/70"
aria-label="聊天输入"
/>
<div className="flex items-center justify-between px-3 pb-3">
<button
type="button"
onClick={onOpenSkillPicker}
disabled={isStreaming}
className="inline-flex h-8 items-center gap-2 rounded-full px-2 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 disabled:opacity-55"
>
<span className="grid size-6 place-items-center rounded-full border border-border bg-background">
<Plus className="size-3.5" />
</span>
Skill
{selectedSkills.length > 0 && (
<span className="grid size-5 place-items-center rounded-full bg-primary text-[10px] text-primary-foreground">
{selectedSkills.length}
</span>
)}
</button>
<div className="flex items-center gap-3">
<span className="hidden items-center gap-1 text-[10px] text-muted-foreground/75 sm:flex">
<Command className="size-3" />
Enter
</span>
{isStreaming ? (
<ComposerPrimitive.Cancel
className="grid size-9 place-items-center rounded-full bg-destructive text-white transition-colors hover:bg-destructive/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive/30 focus-visible:ring-offset-2"
aria-label={
isStopping ? "正在中止本轮回答" : "中止本轮回答"
}
disabled={isStopping}
>
{isStopping ? (
<LoaderCircle className="size-4 animate-spin" />
) : (
<Square className="size-4 fill-current" />
)}
</ComposerPrimitive.Cancel>
) : (
<ComposerPrimitive.Send
className="grid size-9 place-items-center rounded-full bg-primary text-primary-foreground transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:bg-muted-foreground/35"
aria-label="发送消息"
>
<ArrowUp className="size-4.5" strokeWidth={2.4} />
</ComposerPrimitive.Send>
)}
</div>
</div>
</div>
<div className="mt-2 flex items-center justify-center gap-1.5 text-[10px] text-muted-foreground/70">
<span>AI </span>
{slashMatch && (
<>
<span>·</span>
<button
type="button"
className="inline-flex items-center gap-1 hover:text-foreground"
onClick={closeSlashMenu}
>
<X className="size-3" /> Skill
</button>
</>
)}
</div>
</ComposerPrimitive.Root>
);
}

View File

@ -0,0 +1,49 @@
"use client";
import { AlertCircle, RotateCcw, X } from "lucide-react";
import { Button } from "@/components/ui/button";
export function ChatErrorNotice({
detail,
onRetry,
onDismiss,
}: {
detail: string;
onRetry: () => void;
onDismiss: () => void;
}) {
return (
<div
role="alert"
className="mb-3 flex items-start gap-3 rounded-xl border border-destructive/20 bg-destructive/5 px-3.5 py-3 text-destructive"
>
<span className="mt-0.5 grid size-7 shrink-0 place-items-center rounded-lg bg-background">
<AlertCircle className="size-4" />
</span>
<div className="min-w-0 flex-1">
<p className="text-xs font-bold"></p>
<p className="mt-1 break-words text-xs leading-5 text-destructive/80">
{detail}
</p>
<Button
type="button"
variant="outline"
size="sm"
className="mt-2.5 h-8 border-destructive/20 bg-background px-2.5 text-destructive hover:bg-destructive/5"
onClick={onRetry}
>
<RotateCcw className="size-3.5" />
</Button>
</div>
<button
type="button"
onClick={onDismiss}
className="grid size-7 shrink-0 place-items-center rounded-lg text-destructive/60 transition-colors hover:bg-background hover:text-destructive"
aria-label="关闭错误提示"
>
<X className="size-3.5" />
</button>
</div>
);
}

View File

@ -0,0 +1,548 @@
"use client";
import {
CircleDot,
MessageSquareText,
WifiOff,
} from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Toaster, toast } from "sonner";
import type {
ChatMessage,
ChatRun,
Conversation,
Skill,
SseEvent,
} from "@/lib/types";
import { consumeSse } from "@/lib/client-sse";
import { AssistantChat } from "@/components/assistant-chat";
import { ConversationActions } from "@/components/conversation-actions";
import { SkillPicker } from "@/components/skill-picker";
function wait(ms: number, signal: AbortSignal) {
return new Promise<void>((resolve, reject) => {
const timeout = window.setTimeout(resolve, ms);
signal.addEventListener(
"abort",
() => {
window.clearTimeout(timeout);
reject(new DOMException("Aborted", "AbortError"));
},
{ once: true },
);
});
}
export function ChatWorkspace() {
const [skills, setSkills] = useState<Skill[]>([]);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [conversation, setConversation] = useState<Conversation | null>(null);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [pickerOpen, setPickerOpen] = useState(false);
const [loading, setLoading] = useState(true);
const [streaming, setStreaming] = useState(false);
const [generationStatus, setGenerationStatus] = useState("");
const [apiConfigured, setApiConfigured] = useState(false);
const [activeRun, setActiveRun] = useState<ChatRun | null>(null);
const [stopping, setStopping] = useState(false);
const [resolvedSkillIds, setResolvedSkillIds] = useState<string[] | null>(
null,
);
const activeRunRef = useRef<ChatRun | null>(null);
const stopRequestedRef = useRef(false);
const visibleAssistantContentRef = useRef("");
const [sendFailure, setSendFailure] = useState<{
content: string;
selectedIds: string[];
detail: string;
} | null>(null);
const loadSkills = useCallback(async () => {
const response = await fetch("/api/skills", { cache: "no-store" });
if (!response.ok) throw new Error("无法读取 Skill 列表");
const payload = (await response.json()) as { skills: Skill[] };
setSkills(payload.skills);
setSelectedIds((ids) =>
ids.filter((id) => payload.skills.some((skill) => skill.id === id)),
);
}, []);
useEffect(() => {
let cancelled = false;
async function load() {
try {
const [skillsResponse, chatResponse] = await Promise.all([
fetch("/api/skills", { cache: "no-store" }),
fetch("/api/chat", { cache: "no-store" }),
]);
if (!skillsResponse.ok || !chatResponse.ok) {
throw new Error("工作台加载失败");
}
const skillPayload = (await skillsResponse.json()) as {
skills: Skill[];
};
const chatPayload = (await chatResponse.json()) as {
conversation: Conversation;
messages: ChatMessage[];
activeRun: ChatRun | null;
retainedSkillIds: string[];
apiConfigured: boolean;
};
if (cancelled) return;
const hydratedMessages = chatPayload.activeRun
? chatPayload.messages.map((message) =>
message.id === chatPayload.activeRun?.assistantMessageId
? {
...message,
content: "",
usedSkillIds: [],
status: "streaming" as const,
}
: message,
)
: chatPayload.messages;
setSkills(skillPayload.skills);
setConversation(chatPayload.conversation);
setMessages(hydratedMessages);
if (chatPayload.activeRun) {
const activeMessage = chatPayload.messages.find(
(message) =>
message.id === chatPayload.activeRun?.assistantMessageId,
);
setSelectedIds(
(activeMessage?.selectedSkillIds ?? []).filter((id) =>
skillPayload.skills.some((skill) => skill.id === id),
),
);
setResolvedSkillIds(null);
} else {
setSelectedIds(
(chatPayload.retainedSkillIds ?? []).filter((id) =>
skillPayload.skills.some((skill) => skill.id === id),
),
);
}
setApiConfigured(chatPayload.apiConfigured);
activeRunRef.current = chatPayload.activeRun;
setActiveRun(chatPayload.activeRun);
setStreaming(Boolean(chatPayload.activeRun));
if (chatPayload.activeRun) setGenerationStatus("正在恢复本轮回答");
} catch (error) {
if (!cancelled) {
toast.error(error instanceof Error ? error.message : "工作台加载失败");
}
} finally {
if (!cancelled) setLoading(false);
}
}
void load();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
const refresh = () => void loadSkills().catch(() => undefined);
window.addEventListener("focus", refresh);
window.addEventListener("pageshow", refresh);
return () => {
window.removeEventListener("focus", refresh);
window.removeEventListener("pageshow", refresh);
};
}, [loadSkills]);
useEffect(() => {
if (!activeRun) return;
const controller = new AbortController();
let cursor = 0;
let terminal = false;
let retainedForNextTurn: string[] = [];
const updateAssistant = (
updater: (message: ChatMessage) => ChatMessage,
) => {
setMessages((items) =>
items.map((message) =>
message.id === activeRun.assistantMessageId
? updater(message)
: message,
),
);
};
async function followRun() {
for (let attempt = 0; attempt < 12 && !terminal; attempt += 1) {
try {
if (attempt > 0) {
setGenerationStatus(`连接已恢复,正在续传(${attempt}/11`);
}
const response = await fetch(
`/api/chat/runs/${activeRun!.id}/stream?after=${cursor}`,
{
cache: "no-store",
signal: controller.signal,
},
);
await consumeSse(response, (event: SseEvent) => {
if (typeof event.seq === "number") {
cursor = Math.max(cursor, event.seq);
}
if (event.type === "status") setGenerationStatus(event.label);
if (event.type === "skill_usage") {
setResolvedSkillIds(event.skillIds);
updateAssistant((message) => ({
...message,
usedSkillIds: event.skillIds,
}));
}
if (event.type === "skill_retention") {
retainedForNextTurn = event.skillIds;
setSelectedIds(event.skillIds);
}
if (event.type === "token") {
if (stopRequestedRef.current) return;
visibleAssistantContentRef.current += event.token;
updateAssistant((message) => ({
...message,
content: message.content + event.token,
}));
}
if (event.type === "usage") {
updateAssistant((message) => ({
...message,
usage: event.usage,
}));
}
if (event.type === "conversation_title") {
setConversation((current) =>
current ? { ...current, title: event.title } : current,
);
}
if (event.type === "complete") {
if (stopRequestedRef.current) return;
terminal = true;
updateAssistant((message) => ({
...message,
status: "complete",
}));
}
if (event.type === "aborted") {
terminal = true;
updateAssistant((message) => ({
...message,
content: message.content || "本轮回答已中止。",
status: "stopped",
}));
}
if (event.type === "error") {
terminal = true;
updateAssistant((message) => ({
...message,
status: "error",
errorMessage: event.message,
}));
toast.error(event.message);
}
});
if (!terminal) {
if (stopRequestedRef.current) {
setGenerationStatus("正在中止本轮回答");
await wait(160, controller.signal);
} else {
setGenerationStatus("连接短暂中断,正在自动续传");
await wait(
Math.min(700 * 2 ** attempt, 5_000),
controller.signal,
);
}
}
} catch {
if (controller.signal.aborted) return;
setGenerationStatus(
stopRequestedRef.current
? "正在中止本轮回答"
: "连接短暂中断,正在自动续传",
);
try {
await wait(
stopRequestedRef.current
? 160
: Math.min(700 * 2 ** attempt, 5_000),
controller.signal,
);
} catch {
return;
}
if (attempt === 11) {
toast.error("暂时无法恢复连接,刷新页面会从已保存进度继续");
}
}
}
if (terminal) {
stopRequestedRef.current = false;
activeRunRef.current = null;
setStopping(false);
setStreaming(false);
setGenerationStatus("");
setSelectedIds(retainedForNextTurn);
setResolvedSkillIds(null);
setActiveRun(null);
}
}
void followRun();
return () => {
controller.abort();
};
}, [activeRun]);
async function sendMessage(content: string, selectedOverride?: string[]) {
if (!conversation || streaming) return;
stopRequestedRef.current = false;
visibleAssistantContentRef.current = "";
setStopping(false);
const selectedForTurn = [...(selectedOverride ?? selectedIds)];
const userLocalId = `local_user_${crypto.randomUUID()}`;
const assistantLocalId = `local_assistant_${crypto.randomUUID()}`;
const clientRequestId = `client_${crypto.randomUUID()}`;
const now = new Date().toISOString();
const userMessage: ChatMessage = {
id: userLocalId,
role: "user",
content,
selectedSkillIds: selectedForTurn,
usedSkillIds: [],
status: "complete",
createdAt: now,
};
const assistantMessage: ChatMessage = {
id: assistantLocalId,
role: "assistant",
content: "",
selectedSkillIds: selectedForTurn,
usedSkillIds: [],
status: "streaming",
createdAt: new Date(Date.now() + 1).toISOString(),
};
setSendFailure(null);
setResolvedSkillIds(null);
setMessages((items) => [...items, userMessage, assistantMessage]);
setStreaming(true);
setGenerationStatus("正在建立安全连接");
try {
const response = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
conversationId: conversation.id,
message: content,
selectedSkillIds: selectedForTurn,
clientRequestId,
}),
});
const payload = (await response.json().catch(() => null)) as {
run?: ChatRun;
error?: string;
} | null;
if (!response.ok || !payload?.run) {
throw new Error(payload?.error ?? "无法创建回答任务");
}
const run = payload.run;
setMessages((items) =>
items.map((message) => {
if (message.id === userLocalId) {
return { ...message, id: run.userMessageId };
}
if (message.id === assistantLocalId) {
return { ...message, id: run.assistantMessageId };
}
return message;
}),
);
activeRunRef.current = run;
setActiveRun(run);
if (stopRequestedRef.current) {
try {
await requestRunStop(
run.id,
visibleAssistantContentRef.current,
);
} catch (error) {
stopRequestedRef.current = false;
setStopping(false);
toast.error(
error instanceof Error ? error.message : "中止请求失败",
);
}
}
} catch (error) {
const message = error instanceof Error ? error.message : "发送消息失败";
setMessages((items) =>
items.filter(
(item) =>
item.id !== userLocalId && item.id !== assistantLocalId,
),
);
setSelectedIds(selectedForTurn);
setResolvedSkillIds(null);
stopRequestedRef.current = false;
activeRunRef.current = null;
setStopping(false);
setSendFailure({
content,
selectedIds: selectedForTurn,
detail: message,
});
setStreaming(false);
setGenerationStatus("");
}
}
async function requestRunStop(runId: string, visibleContent: string) {
const response = await fetch(`/api/chat/runs/${runId}/stop`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ visibleContent }),
});
if (!response.ok) throw new Error("中止请求失败");
}
async function stopGeneration() {
if (stopRequestedRef.current) return;
stopRequestedRef.current = true;
setStopping(true);
setGenerationStatus("正在中止本轮回答");
const run = activeRunRef.current;
if (!run) return;
try {
await requestRunStop(
run.id,
visibleAssistantContentRef.current,
);
} catch (error) {
stopRequestedRef.current = false;
setStopping(false);
toast.error(error instanceof Error ? error.message : "中止请求失败");
}
}
return (
<div className="flex h-dvh min-h-[620px] flex-col overflow-hidden bg-background text-foreground">
<Toaster position="top-center" richColors closeButton />
<main className="relative flex min-h-0 flex-1 justify-center overflow-hidden">
<section className="relative flex min-h-0 w-full max-w-[800px] flex-col">
<div className="flex min-h-14 shrink-0 items-center justify-between gap-4 px-4 sm:px-8">
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
<MessageSquareText className="size-3.5 shrink-0" />
<span className="truncate font-medium text-foreground">
{conversation?.title || "未命名对话"}
</span>
{messages.length > 0 && (
<span className="shrink-0 text-muted-foreground/70">
{messages.filter((message) => message.role === "user").length}{" "}
</span>
)}
{generationStatus.includes("连接") && (
<WifiOff className="size-3.5 shrink-0 text-amber-500" />
)}
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<div
className="hidden items-center gap-1.5 text-[10px] font-medium text-muted-foreground sm:flex"
title={
apiConfigured ? "DeepSeek V4 Flash" : "本地演示模式"
}
>
<CircleDot
className={
apiConfigured
? "size-2.5 text-emerald-500"
: "size-2.5 text-amber-500"
}
fill="currentColor"
/>
{apiConfigured ? "AI 已连接" : "演示模式"}
</div>
{conversation && (
<ConversationActions
conversation={conversation}
disabled={streaming}
onConversationChange={setConversation}
onReset={(updated) => {
setConversation(updated);
setMessages([]);
setSelectedIds([]);
setResolvedSkillIds(null);
setSendFailure(null);
stopRequestedRef.current = false;
activeRunRef.current = null;
setStopping(false);
setActiveRun(null);
setStreaming(false);
}}
/>
)}
</div>
</div>
<AssistantChat
skills={skills}
messages={messages}
selectedIds={selectedIds}
resolvedSkillIds={resolvedSkillIds}
loading={loading}
streaming={streaming}
stopping={stopping}
generationStatus={generationStatus}
sendFailure={sendFailure}
canSend={Boolean(conversation) && !loading}
onSelectedChange={(ids) => {
setSelectedIds(ids);
if (!streaming) setResolvedSkillIds(null);
}}
onOpenSkillPicker={() => setPickerOpen(true)}
onSend={sendMessage}
onStop={stopGeneration}
onRetry={() =>
void (sendFailure &&
sendMessage(sendFailure.content, sendFailure.selectedIds))
}
onDismissFailure={() => setSendFailure(null)}
/>
</section>
</main>
{pickerOpen && (
<SkillPicker
open
onOpenChange={setPickerOpen}
skills={skills}
selectedIds={selectedIds}
onConfirm={(ids) => {
setSelectedIds(ids);
setResolvedSkillIds(null);
}}
onSkillsChange={setSkills}
/>
)}
</div>
);
}

View File

@ -0,0 +1,196 @@
"use client";
import * as Dialog from "@radix-ui/react-dialog";
import * as Popover from "@radix-ui/react-popover";
import {
Check,
MoreHorizontal,
Pencil,
RotateCcw,
X,
} from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import type { Conversation } from "@/lib/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
export function ConversationActions({
conversation,
disabled,
onConversationChange,
onReset,
}: {
conversation: Conversation;
disabled: boolean;
onConversationChange: (conversation: Conversation) => void;
onReset: (conversation: Conversation) => void;
}) {
const [menuOpen, setMenuOpen] = useState(false);
const [dialogMode, setDialogMode] = useState<"edit" | "reset" | null>(null);
const [title, setTitle] = useState(conversation.title);
const [saving, setSaving] = useState(false);
function openDialog(mode: "edit" | "reset") {
setMenuOpen(false);
setTitle(conversation.title);
setDialogMode(mode);
}
async function updateTitle() {
if (!title.trim() || saving) return;
setSaving(true);
try {
const response = await fetch("/api/conversation", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title }),
});
const payload = (await response.json()) as {
conversation?: Conversation;
error?: string;
};
if (!response.ok || !payload.conversation) {
throw new Error(payload.error ?? "标题修改失败");
}
onConversationChange(payload.conversation);
setDialogMode(null);
toast.success("标题已更新");
} catch (error) {
toast.error(error instanceof Error ? error.message : "标题修改失败");
} finally {
setSaving(false);
}
}
async function reset() {
if (saving) return;
setSaving(true);
try {
const response = await fetch("/api/conversation", {
method: "DELETE",
});
const payload = (await response.json()) as {
conversation?: Conversation;
error?: string;
};
if (!response.ok || !payload.conversation) {
throw new Error(payload.error ?? "会话重置失败");
}
onReset(payload.conversation);
setDialogMode(null);
toast.success("会话已重置");
} catch (error) {
toast.error(error instanceof Error ? error.message : "会话重置失败");
} finally {
setSaving(false);
}
}
return (
<>
<Popover.Root open={menuOpen} onOpenChange={setMenuOpen}>
<Popover.Trigger asChild>
<button
type="button"
disabled={disabled}
className="grid size-8 place-items-center rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-45"
aria-label="会话操作"
>
<MoreHorizontal className="size-4.5" />
</button>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
align="end"
sideOffset={8}
className="z-30 w-44 rounded-lg border border-border bg-popover p-1.5 shadow-md"
>
<button
type="button"
onClick={() => openDialog("edit")}
className="flex h-9 w-full items-center gap-2.5 rounded-md px-2.5 text-left text-xs font-medium text-foreground hover:bg-accent"
>
<Pencil className="size-3.5" />
</button>
<button
type="button"
onClick={() => openDialog("reset")}
className="flex h-9 w-full items-center gap-2.5 rounded-md px-2.5 text-left text-xs font-medium text-destructive hover:bg-destructive/5"
>
<RotateCcw className="size-3.5" />
</button>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
<Dialog.Root
open={dialogMode !== null}
onOpenChange={(open) => !open && setDialogMode(null)}
>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 z-40 bg-black/45" />
<Dialog.Content className="fixed left-1/2 top-1/2 z-50 w-[min(430px,92vw)] -translate-x-1/2 -translate-y-1/2 rounded-xl border border-border bg-background p-5 shadow-xl focus:outline-none">
<div className="flex items-start justify-between gap-4">
<div>
<Dialog.Title className="font-display text-lg font-semibold tracking-[-0.03em] text-foreground">
{dialogMode === "edit" ? "编辑会话标题" : "重置当前会话"}
</Dialog.Title>
<Dialog.Description className="mt-1.5 text-xs leading-5 text-muted-foreground">
{dialogMode === "edit"
? "标题最多 40 个字符,后续不会被自动覆盖。"
: "聊天内容会清空标题也会恢复为空。Skill 库不会受到影响。"}
</Dialog.Description>
</div>
<Dialog.Close asChild>
<button
type="button"
className="grid size-8 shrink-0 place-items-center rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label="关闭"
>
<X className="size-4" />
</button>
</Dialog.Close>
</div>
{dialogMode === "edit" && (
<Input
autoFocus
value={title}
maxLength={40}
onChange={(event) => setTitle(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") void updateTitle();
}}
placeholder="输入会话标题"
className="mt-5 h-11 rounded-lg bg-transparent text-sm font-medium"
/>
)}
<div className="mt-5 flex justify-end gap-2">
<Dialog.Close asChild>
<Button variant="ghost"></Button>
</Dialog.Close>
<Button
variant={dialogMode === "reset" ? "destructive" : "default"}
disabled={saving || (dialogMode === "edit" && !title.trim())}
onClick={() =>
void (dialogMode === "edit" ? updateTitle() : reset())
}
>
{dialogMode === "edit" ? (
<Check className="size-4" />
) : (
<RotateCcw className="size-4" />
)}
{dialogMode === "edit" ? "保存标题" : "确认重置"}
</Button>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
</>
);
}

View File

@ -0,0 +1,609 @@
"use client";
import {
Check,
ChevronLeft,
CircleDot,
LoaderCircle,
Save,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Toaster, toast } from "sonner";
import { AssistantBuilderChat } from "@/components/assistant-builder-chat";
import { SkillProgress } from "@/components/skill-progress";
import { Button } from "@/components/ui/button";
import { consumeSse } from "@/lib/client-sse";
import type {
BuilderMessage,
BuilderSuggestionSource,
Skill,
SkillNode,
SkillNodeKey,
SseEvent,
} from "@/lib/types";
import { SKILL_NODE_DEFINITIONS } from "@/lib/types";
import { cn } from "@/lib/utils";
function createEmptyNodes(): SkillNode[] {
return SKILL_NODE_DEFINITIONS.map((definition) => ({
key: definition.key,
title: definition.title,
description: definition.description,
content: "",
ready: false,
completed: false,
}));
}
function createBuilderMessage(
role: BuilderMessage["role"],
content: string,
nodeKey: SkillNodeKey,
updatedNodeKeys: SkillNodeKey[] = [nodeKey],
): BuilderMessage {
return {
id: `builder_${crypto.randomUUID()}`,
role,
content,
nodeKey,
updatedNodeKeys,
createdAt: new Date().toISOString(),
};
}
export function SkillBuilder({ skillId }: { skillId?: string }) {
const router = useRouter();
const [name, setName] = useState("未命名 Skill");
const [description, setDescription] = useState("");
const [nodes, setNodes] = useState<SkillNode[]>(createEmptyNodes);
const [messages, setMessages] = useState<BuilderMessage[]>([]);
const [loading, setLoading] = useState(Boolean(skillId));
const [streaming, setStreaming] = useState(false);
const [saving, setSaving] = useState(false);
const [status, setStatus] = useState("");
const [suggestions, setSuggestions] = useState<string[]>([]);
const [suggestionSource, setSuggestionSource] =
useState<BuilderSuggestionSource>("loading");
const [apiConfigured, setApiConfigured] = useState(false);
const [sendFailure, setSendFailure] = useState<{
content: string;
detail: string;
} | null>(null);
const connectionAbortRef = useRef<AbortController | null>(null);
const builderRunIdRef = useRef<string | null>(null);
const suggestionRequestRef = useRef(0);
const completedCount = nodes.filter((node) => node.completed).length;
const allComplete = completedCount === nodes.length;
const progress = Math.round((completedCount / nodes.length) * 100);
const requestNodeSuggestions = useCallback(
async (
skillNodes: SkillNode[],
skillName: string,
skillDescription: string,
) => {
if (skillNodes.every((node) => node.completed)) {
setSuggestions([]);
setSuggestionSource("idle");
return;
}
const requestId = suggestionRequestRef.current + 1;
const activeIndex = skillNodes.findIndex((node) => !node.completed);
const fallbackIndex =
activeIndex === -1
? SKILL_NODE_DEFINITIONS.length - 1
: activeIndex;
const expectedNodeKey = SKILL_NODE_DEFINITIONS[fallbackIndex].key;
suggestionRequestRef.current = requestId;
setSuggestions([]);
setSuggestionSource("loading");
try {
const response = await fetch("/api/skills/builder/suggestions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
skillName,
skillDescription,
nodes: skillNodes,
}),
});
const payload = (await response.json().catch(() => null)) as {
nodeKey?: SkillNode["key"];
suggestions?: string[];
source?: Exclude<BuilderSuggestionSource, "idle" | "loading">;
} | null;
if (
!response.ok ||
payload?.nodeKey !== expectedNodeKey ||
!payload.suggestions?.length
) {
throw new Error("节点建议生成失败");
}
if (requestId !== suggestionRequestRef.current) return;
setSuggestions(payload.suggestions);
setSuggestionSource(payload.source ?? "fallback");
} catch {
if (requestId !== suggestionRequestRef.current) return;
setSuggestions([
...SKILL_NODE_DEFINITIONS[fallbackIndex].suggestions,
]);
setSuggestionSource("fallback");
}
},
[],
);
useEffect(() => {
async function load() {
try {
const statusRequest = fetch("/api/chat", { cache: "no-store" });
if (skillId) {
const [skillResponse, chatResponse] = await Promise.all([
fetch(`/api/skills/${skillId}`, { cache: "no-store" }),
statusRequest,
]);
if (!skillResponse.ok) throw new Error("Skill 不存在或已被删除");
const skillPayload = (await skillResponse.json()) as { skill: Skill };
const chatPayload = (await chatResponse.json()) as {
apiConfigured: boolean;
};
setApiConfigured(chatPayload.apiConfigured);
setName(skillPayload.skill.name);
setDescription(skillPayload.skill.description);
setNodes(skillPayload.skill.nodes);
void requestNodeSuggestions(
skillPayload.skill.nodes,
skillPayload.skill.name,
skillPayload.skill.description,
);
setMessages([
createBuilderMessage(
"assistant",
`已载入「${skillPayload.skill.name}」。直接告诉我想补充或修改什么,我会判断应该更新哪些节点。`,
"constraints",
SKILL_NODE_DEFINITIONS.map((node) => node.key),
),
]);
} else {
const chatResponse = await statusRequest;
const chatPayload = (await chatResponse.json()) as {
apiConfigured: boolean;
};
setApiConfigured(chatPayload.apiConfigured);
void requestNodeSuggestions(
createEmptyNodes(),
"未命名 Skill",
"",
);
setMessages([
createBuilderMessage(
"assistant",
`我们从使用时机开始,把想法一步步变成可执行 Skill。\n\n${SKILL_NODE_DEFINITIONS[0].prompt}`,
"trigger",
),
]);
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载失败");
} finally {
setLoading(false);
}
}
void load();
}, [requestNodeSuggestions, skillId]);
useEffect(
() => () => {
connectionAbortRef.current?.abort();
},
[],
);
const currentIndex = useMemo(() => {
const index = nodes.findIndex((node) => !node.completed);
return index === -1 ? nodes.length - 1 : index;
}, [nodes]);
const activeNode = SKILL_NODE_DEFINITIONS[currentIndex].key;
async function sendBuilderMessage(content: string) {
if (streaming) return;
const userMessage = createBuilderMessage("user", content, activeNode, []);
const assistantMessage = createBuilderMessage(
"assistant",
"",
activeNode,
[],
);
const previousMessages = [...messages];
setSendFailure(null);
setMessages((items) => [...items, userMessage, assistantMessage]);
setStreaming(true);
setStatus("思考中");
const clientRequestId = `builder_run_${crypto.randomUUID()}`;
const controller = new AbortController();
connectionAbortRef.current = controller;
builderRunIdRef.current = clientRequestId;
let cursor = 0;
let terminal = false;
try {
for (let attempt = 0; attempt < 10 && !terminal; attempt += 1) {
try {
const response = await fetch("/api/skills/builder", {
method: "POST",
headers: { "Content-Type": "application/json" },
signal: controller.signal,
body: JSON.stringify({
clientRequestId,
afterSeq: cursor,
skillId: skillId ?? null,
message: content,
skillName: name,
skillDescription: description,
nodes,
messages: previousMessages.map((message) => ({
role: message.role,
content: message.content,
})),
}),
});
await consumeSse(response, (event: SseEvent) => {
if (typeof event.seq === "number") {
cursor = Math.max(cursor, event.seq);
}
if (event.type === "status") setStatus(event.label);
if (event.type === "builder_update") {
setName(event.evaluation.skillName);
setDescription(event.evaluation.skillDescription);
setNodes(event.evaluation.nodes);
const suggestionsMatchNode =
event.evaluation.suggestionNodeKey ===
event.evaluation.activeNode;
setSuggestions(
suggestionsMatchNode
? event.evaluation.suggestions
: [
...SKILL_NODE_DEFINITIONS.find(
(definition) =>
definition.key === event.evaluation.activeNode,
)!.suggestions,
],
);
setSuggestionSource(
suggestionsMatchNode
? apiConfigured
? "ai"
: "demo"
: "fallback",
);
setMessages((items) =>
items
.map((message) =>
message.id === assistantMessage.id
? {
...message,
nodeKey:
event.evaluation.updatedNodeKeys[0] ??
event.evaluation.activeNode,
updatedNodeKeys: event.evaluation.updatedNodeKeys,
}
: message,
)
.filter(
(message) =>
event.evaluation.activeNode === activeNode ||
message.id === assistantMessage.id,
),
);
}
if (event.type === "token") {
setMessages((items) =>
items.map((message) =>
message.id === assistantMessage.id
? { ...message, content: message.content + event.token }
: message,
),
);
}
if (event.type === "complete") terminal = true;
if (event.type === "aborted") {
terminal = true;
setMessages((items) =>
items.map((message) =>
message.id === assistantMessage.id
? {
...message,
content: message.content || "这轮节点整理已中止。",
}
: message,
),
);
}
if (event.type === "error") {
terminal = true;
setMessages((items) =>
items.filter(
(message) =>
message.id !== userMessage.id &&
message.id !== assistantMessage.id,
),
);
setSendFailure({ content, detail: event.message });
}
});
if (!terminal) {
setStatus("连接中断,正在恢复");
await new Promise((resolve) =>
setTimeout(resolve, Math.min(600 * 2 ** attempt, 4_000)),
);
}
} catch {
if (controller.signal.aborted) break;
setStatus("连接中断,正在恢复");
await new Promise((resolve) =>
setTimeout(resolve, Math.min(600 * 2 ** attempt, 4_000)),
);
}
}
if (!terminal && !controller.signal.aborted) {
throw new Error("暂时无法恢复节点连接,请重新发送这条消息");
}
} catch (error) {
if (!controller.signal.aborted) {
const message =
error instanceof Error ? error.message : "节点整理失败";
setMessages((items) =>
items.filter(
(item) =>
item.id !== userMessage.id && item.id !== assistantMessage.id,
),
);
setSendFailure({ content, detail: message });
}
} finally {
connectionAbortRef.current = null;
builderRunIdRef.current = null;
setStreaming(false);
setStatus("");
}
}
function stop() {
const id = builderRunIdRef.current;
if (!id) return;
setStatus("正在中止本轮节点整理");
void fetch(`/api/skills/builder/${id}/stop`, { method: "POST" }).catch(
() => {
connectionAbortRef.current?.abort();
setStreaming(false);
setStatus("");
toast.error("中止请求失败");
},
);
}
async function save() {
if (!allComplete || saving) return;
setSaving(true);
try {
const response = await fetch(
skillId ? `/api/skills/${skillId}` : "/api/skills",
{
method: skillId ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name,
description,
nodes,
status: "active",
}),
},
);
const payload = (await response.json().catch(() => null)) as {
skill?: Skill;
error?: string;
} | null;
if (!response.ok) throw new Error(payload?.error ?? "保存失败");
toast.success(skillId ? "Skill 已更新" : "Skill 已创建");
setTimeout(() => router.push("/"), 350);
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
} finally {
setSaving(false);
}
}
return (
<div className="flex h-dvh min-h-[620px] flex-col overflow-hidden bg-[linear-gradient(180deg,#4A9BF7_0%,#C9E1FF_100%)] text-foreground">
<Toaster position="top-center" richColors closeButton />
<div className="mx-auto grid min-h-0 w-full max-w-[1080px] flex-1 grid-cols-1 gap-2.5 px-4 py-2.5 sm:px-5 lg:grid-cols-[minmax(0,1.6fr)_minmax(285px,0.68fr)]">
<main className="flex min-h-0 flex-col overflow-hidden rounded-xl bg-[#FBF9F5] shadow-[0_10px_28px_rgba(27,69,122,0.15)]">
<div className="shrink-0 bg-background px-3.5 py-1.5 sm:px-4">
<div className="mx-auto flex max-w-[660px] items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<h1 className="truncate font-display text-xs font-semibold tracking-[-0.02em] text-foreground">
{name}
</h1>
<span className="rounded-md bg-muted px-1.5 py-0.5 font-mono text-[8px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
{skillId ? "Edit" : "New"}
</span>
</div>
<p className="max-w-[480px] truncate text-[8px] text-muted-foreground">
{description || "通过对话定义一套可复用的工作规范"}
</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<div className="hidden items-center gap-1 text-[8px] font-medium text-muted-foreground sm:flex">
<CircleDot
className={cn(
"size-2.5",
apiConfigured ? "text-emerald-500" : "text-amber-500",
)}
fill="currentColor"
/>
{apiConfigured ? "AI 判断模式" : "本地演示模式"}
</div>
<Button
size="sm"
className="bg-[#07C160] text-white hover:bg-[#06AD56] disabled:bg-muted-foreground/35 lg:hidden"
onClick={() => void save()}
disabled={!allComplete || saving}
title={!allComplete ? "完成全部节点后才能保存" : undefined}
>
{saving ? (
<LoaderCircle className="size-3.5 animate-spin" />
) : (
<Save className="size-3.5" />
)}
</Button>
</div>
</div>
</div>
<div className="shrink-0 border-y border-border bg-background px-3.5 py-1.5 sm:px-4">
<div className="mx-auto flex max-w-[660px] items-center overflow-x-auto">
<span className="mr-1.5 shrink-0 text-[10px] font-semibold text-foreground">
</span>
{nodes.map((node, index) => {
const selected = node.key === activeNode && !node.completed;
return (
<div key={node.key} className="flex shrink-0 items-center">
<div
aria-current={selected ? "step" : undefined}
className="flex items-center gap-1 text-[9px] font-medium text-foreground"
>
<span
className={cn(
"grid size-[18px] place-items-center rounded-full border border-white font-mono text-[8px] font-semibold text-white shadow-sm",
node.completed
? "bg-[#07C160]"
: selected
? "bg-[#FFC300] text-[#4A3800]"
: "bg-[#B3B3B3]",
)}
>
{index + 1}
</span>
<span>{node.title}</span>
</div>
{index < nodes.length - 1 && (
<span
className={cn(
"mx-1 w-3 border-t border-dashed",
node.completed
? "border-emerald-500"
: "border-muted-foreground/35",
)}
/>
)}
</div>
);
})}
</div>
</div>
<AssistantBuilderChat
messages={messages}
loading={loading}
streaming={streaming}
generationStatus={status}
suggestions={allComplete ? [] : suggestions}
suggestionSource={suggestionSource}
sendFailure={sendFailure}
onSend={sendBuilderMessage}
onStop={stop}
onRetry={() =>
void (sendFailure && sendBuilderMessage(sendFailure.content))
}
onDismissFailure={() => setSendFailure(null)}
/>
</main>
<aside className="hidden min-h-0 flex-col overflow-hidden rounded-xl bg-[#F6F8FC] shadow-[0_10px_28px_rgba(27,69,122,0.15)] lg:flex">
<div className="shrink-0 border-b border-border bg-[#F6F8FC] px-3 py-2">
<div className="flex items-end justify-between gap-4">
<div>
<div className="font-display text-[13px] font-bold tracking-[-0.025em] text-foreground">
Skill
</div>
<div className="text-[9px] text-muted-foreground">
{completedCount} / {nodes.length}
</div>
</div>
<span className="font-mono text-base font-semibold text-foreground">
{progress}%
</span>
</div>
<div className="mt-1.5 h-1 overflow-hidden rounded-full bg-black/8">
<div
className="h-full rounded-full bg-[#07C160] transition-[width] duration-500"
style={{ width: `${progress}%` }}
/>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-2.5">
<SkillProgress
nodes={nodes}
activeNode={activeNode}
/>
</div>
<div className="shrink-0 border-t border-border bg-[#F6F8FC] p-2.5">
{allComplete ? (
<div className="mb-1.5 flex items-start gap-1.5 rounded-md border border-emerald-200 bg-emerald-50 px-2 py-1.5 text-[9px] leading-3.5 text-emerald-700">
<span className="grid size-4 shrink-0 place-items-center rounded-full bg-emerald-600 text-white">
<Check className="size-3" />
</span>
<span>
AI
</span>
</div>
) : null}
<div className="flex gap-1.5">
<Button
size="sm"
variant="outline"
className="h-8 px-2.5 text-xs"
onClick={() => router.push("/")}
>
<ChevronLeft className="size-3.5" />
</Button>
<Button
size="sm"
className="h-8 flex-1 bg-[#07C160] text-xs text-white hover:bg-[#06AD56] disabled:bg-muted-foreground/35"
disabled={!allComplete || saving}
onClick={() => void save()}
>
{saving ? (
<LoaderCircle className="size-3.5 animate-spin" />
) : (
<Save className="size-3.5" />
)}
{skillId ? "保存 Skill" : "加入 Skill 库"}
</Button>
</div>
</div>
</aside>
</div>
</div>
);
}

View File

@ -0,0 +1,421 @@
"use client";
import {
DndContext,
PointerSensor,
closestCenter,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
arrayMove,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
Check,
GripVertical,
Pencil,
Plus,
Search,
Trash2,
X,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import type { Skill } from "@/lib/types";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogTitle,
} from "@/components/ui/dialog";
function SortableSkill({
skill,
index,
onRemove,
}: {
skill: Skill;
index: number;
onRemove: () => void;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: skill.id });
return (
<div
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition,
}}
{...attributes}
{...listeners}
aria-label={`拖动 ${skill.name} 调整顺序`}
className={cn(
"group flex w-full min-w-0 cursor-grab touch-none items-center gap-3 rounded-xl border border-border bg-background p-3 outline-none transition-[border-color,box-shadow,background-color] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/30 active:cursor-grabbing",
isDragging &&
"z-20 cursor-grabbing border-foreground/25 bg-background shadow-lg",
)}
>
<span className="grid size-7 shrink-0 place-items-center rounded-lg text-muted-foreground transition-colors group-hover:bg-muted">
<GripVertical className="size-4" />
</span>
<span className="font-mono text-[10px] font-semibold text-muted-foreground">
{String(index + 1).padStart(2, "0")}
</span>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-foreground">{skill.name}</div>
<div className="mt-0.5 truncate text-xs text-muted-foreground">
{skill.description}
</div>
</div>
<button
type="button"
className="grid size-8 place-items-center rounded-lg text-muted-foreground hover:bg-destructive/5 hover:text-destructive"
onPointerDown={(event) => event.stopPropagation()}
onClick={onRemove}
aria-label={`移除 ${skill.name}`}
>
<X className="size-4" />
</button>
</div>
);
}
export function SkillPicker({
open,
onOpenChange,
skills,
selectedIds,
onConfirm,
onSkillsChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
skills: Skill[];
selectedIds: string[];
onConfirm: (ids: string[]) => void;
onSkillsChange: (skills: Skill[]) => void;
}) {
const router = useRouter();
const [workingIds, setWorkingIds] = useState(selectedIds);
const [query, setQuery] = useState("");
const [deletingSkill, setDeletingSkill] = useState<Skill | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
);
const filteredSkills = useMemo(() => {
const needle = query.trim().toLowerCase();
if (!needle) return skills;
return skills.filter((skill) =>
`${skill.name} ${skill.description}`.toLowerCase().includes(needle),
);
}, [query, skills]);
const selectedSkills = workingIds
.map((id) => skills.find((skill) => skill.id === id))
.filter((skill): skill is Skill => Boolean(skill));
function toggleSkill(id: string) {
setWorkingIds((current) =>
current.includes(id)
? current.filter((item) => item !== id)
: [...current, id],
);
}
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (!over || active.id === over.id) return;
setWorkingIds((items) => {
const oldIndex = items.indexOf(String(active.id));
const newIndex = items.indexOf(String(over.id));
if (oldIndex < 0 || newIndex < 0) return items;
return arrayMove(items, oldIndex, newIndex);
});
}
async function handleDelete(skill: Skill) {
try {
const response = await fetch(`/api/skills/${skill.id}`, {
method: "DELETE",
});
if (!response.ok) {
const payload = (await response.json()) as { error?: string };
throw new Error(payload.error ?? "删除失败");
}
setWorkingIds((ids) => ids.filter((id) => id !== skill.id));
onSkillsChange(skills.filter((item) => item.id !== skill.id));
setDeletingSkill(null);
toast.success("Skill 已删除");
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除失败");
}
}
function openBuilder(path: string) {
onOpenChange(false);
router.push(path);
}
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
showCloseButton={false}
className="flex h-[min(720px,90dvh)] w-[min(980px,94vw)] max-w-none flex-col gap-0 overflow-hidden rounded-2xl border-border bg-background p-0 shadow-2xl sm:max-w-[980px]"
>
<div className="flex items-center justify-between border-b border-border px-5 py-4 sm:px-6">
<div>
<DialogTitle className="font-display text-lg font-semibold tracking-[-0.03em] text-foreground">
Skill
</DialogTitle>
<DialogDescription className="mt-1 text-xs">
Skill AI
</DialogDescription>
</div>
<DialogClose asChild>
<button
type="button"
className="grid size-9 place-items-center rounded-lg text-muted-foreground hover:bg-muted"
aria-label="关闭"
>
<X className="size-5" />
</button>
</DialogClose>
</div>
<div className="grid min-h-0 min-w-0 flex-1 grid-cols-1 lg:grid-cols-[minmax(0,1.12fr)_minmax(0,0.88fr)]">
<section className="flex min-h-0 min-w-0 flex-col overflow-hidden border-b border-border bg-background p-4 sm:p-5 lg:border-b-0 lg:border-r">
<div className="flex items-center gap-3">
<label className="flex h-9 min-w-0 flex-1 items-center gap-2.5 rounded-lg border border-input bg-transparent px-3 shadow-xs focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/30">
<Search className="size-4 text-muted-foreground" />
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="搜索名称或用途"
className="min-w-0 flex-1 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground"
/>
</label>
<Button
variant="outline"
onClick={() => openBuilder("/skills/new")}
>
<Plus className="size-4" />
Skill
</Button>
</div>
<div className="mt-5 flex items-center justify-between">
<div className="text-xs font-medium text-muted-foreground">
Skill
</div>
<span className="text-xs text-muted-foreground">
{filteredSkills.length}
</span>
</div>
<div className="mt-3 min-h-0 flex-1 space-y-2 overflow-y-auto pr-1">
{filteredSkills.map((skill) => {
const selected = workingIds.includes(skill.id);
return (
<div
key={skill.id}
className={cn(
"group flex cursor-pointer items-center gap-3 rounded-xl border p-3 transition-colors",
selected
? "border-foreground/20 bg-muted/70"
: "border-border bg-background hover:bg-muted/45",
)}
onClick={() => toggleSkill(skill.id)}
>
<div
className={cn(
"grid size-5 shrink-0 place-items-center rounded-md border",
selected
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-background",
)}
>
{selected && <Check className="size-3.5" />}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-foreground">
{skill.name}
</div>
<div className="mt-1 line-clamp-2 text-xs leading-5 text-muted-foreground">
{skill.description}
</div>
</div>
<div className="flex shrink-0 items-center opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
<button
type="button"
className="grid size-8 place-items-center rounded-lg text-muted-foreground hover:bg-background hover:text-foreground"
onClick={(event) => {
event.stopPropagation();
openBuilder(`/skills/${skill.id}`);
}}
aria-label={`编辑 ${skill.name}`}
>
<Pencil className="size-3.5" />
</button>
<button
type="button"
className="grid size-8 place-items-center rounded-lg text-muted-foreground hover:bg-destructive/5 hover:text-destructive"
onClick={(event) => {
event.stopPropagation();
setDeletingSkill(skill);
}}
aria-label={`删除 ${skill.name}`}
>
<Trash2 className="size-3.5" />
</button>
</div>
</div>
);
})}
{filteredSkills.length === 0 && (
<div className="grid min-h-48 place-items-center rounded-xl border border-dashed border-border bg-muted/30 text-center">
<div>
<div className="text-sm font-medium text-foreground">
Skill
</div>
<p className="mt-1 text-xs text-muted-foreground">
</p>
</div>
</div>
)}
</div>
</section>
<section className="flex min-h-0 min-w-0 flex-col overflow-hidden bg-muted/30 p-4 sm:p-5">
<div className="flex items-baseline justify-between">
<div>
<div className="text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
</div>
<h3 className="mt-1 text-sm font-medium text-foreground">
· {selectedSkills.length}
</h3>
</div>
<span className="text-xs text-muted-foreground"></span>
</div>
<div className="mt-4 min-h-0 flex-1 overflow-y-auto">
{selectedSkills.length > 0 ? (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={workingIds}
strategy={verticalListSortingStrategy}
>
<div className="space-y-2">
{selectedSkills.map((skill, index) => (
<SortableSkill
key={skill.id}
skill={skill}
index={index}
onRemove={() =>
setWorkingIds((ids) =>
ids.filter((id) => id !== skill.id),
)
}
/>
))}
</div>
</SortableContext>
</DndContext>
) : (
<div className="grid h-full min-h-48 place-items-center rounded-xl border border-dashed border-border bg-background/70 px-8 text-center">
<div>
<div className="mx-auto grid size-10 place-items-center rounded-xl bg-muted text-muted-foreground">
<GripVertical className="size-5" />
</div>
<div className="mt-3 text-sm font-medium text-foreground">
</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
Skill
</p>
</div>
</div>
)}
</div>
<div className="mt-5 flex justify-end gap-2 border-t border-border pt-4">
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button
onClick={() => {
onConfirm(workingIds);
onOpenChange(false);
}}
>
<Check className="size-4" />
</Button>
</div>
</section>
</div>
</DialogContent>
</Dialog>
<AlertDialog
open={Boolean(deletingSkill)}
onOpenChange={(isOpen) => !isOpen && setDeletingSkill(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{deletingSkill?.name}</AlertDialogTitle>
<AlertDialogDescription>
Skill
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() =>
deletingSkill && void handleDelete(deletingSkill)
}
>
Skill
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@ -0,0 +1,84 @@
"use client";
import { useEffect, useRef } from "react";
import { Check, Hourglass } from "lucide-react";
import type { SkillNode, SkillNodeKey } from "@/lib/types";
import { cn } from "@/lib/utils";
export function SkillProgress({
nodes,
activeNode,
}: {
nodes: SkillNode[];
activeNode: SkillNodeKey;
}) {
const activeStepRef = useRef<HTMLElement>(null);
const completedCount = nodes.filter((node) => node.completed).length;
const scrollSignal = `${activeNode}:${completedCount}`;
useEffect(() => {
activeStepRef.current?.scrollIntoView({
behavior: "smooth",
block: "center",
});
}, [scrollSignal]);
return (
<div className="space-y-1.5">
{nodes.map((node) => {
const active = node.key === activeNode && !node.completed;
const complete = node.completed;
return (
<section
key={node.key}
ref={node.key === activeNode ? activeStepRef : undefined}
aria-current={active ? "step" : undefined}
className="scroll-my-2"
>
<div className="mb-0.5 flex items-center justify-between gap-3 px-0.5">
<h3 className="text-[10px] font-semibold text-foreground">
{node.title}
</h3>
<span
className={cn(
"inline-flex items-center gap-1 text-[8px] font-medium",
complete
? "text-emerald-700"
: active
? "text-amber-700"
: "text-muted-foreground",
)}
>
{complete && <Check className="size-2.5" />}
{complete ? "已完成" : active ? "进行中" : "未开始"}
</span>
</div>
<div
className={cn(
"flex min-h-9 items-center rounded-md px-2.5 py-1.5 text-[10px] leading-4 transition-colors",
complete && "bg-[#DDF4DF] text-[#173E24]",
active && "justify-center bg-[#FFF0C7] text-amber-900",
!complete &&
!active &&
"justify-center bg-[#E8E8EA] text-muted-foreground",
)}
>
{complete ? (
<p className="whitespace-pre-line">{node.content}</p>
) : active ? (
<Hourglass
className="size-3 animate-pulse"
aria-label="正在构建"
/>
) : (
<span className="text-sm font-semibold">-</span>
)}
</div>
</section>
);
})}
</div>
);
}

View File

@ -0,0 +1,100 @@
"use client";
import {
CheckCircle2,
CircleMinus,
GripVertical,
LoaderCircle,
Sparkles,
X,
} from "lucide-react";
import { cn } from "@/lib/utils";
export function SkillTag({
name,
index,
onRemove,
active = false,
muted = false,
draggable = false,
status = "selected",
showStatusLabel = true,
className,
}: {
name: string;
index?: number;
onRemove?: () => void;
active?: boolean;
muted?: boolean;
draggable?: boolean;
status?: "selected" | "checking" | "used" | "unused";
showStatusLabel?: boolean;
className?: string;
}) {
return (
<span
className={cn(
"inline-flex h-7 max-w-full items-center gap-1.5 rounded-full border px-2.5 text-[11px] font-medium transition-colors",
active
? "border-foreground/15 bg-foreground text-background"
: "border-border bg-background text-foreground/75",
status === "checking" &&
"border-foreground/15 bg-background text-foreground",
status === "used" &&
"border-emerald-200 bg-emerald-50 text-emerald-700",
status === "unused" &&
"border-border bg-background text-muted-foreground opacity-65",
muted && "opacity-55 grayscale-[0.2]",
className,
)}
>
{draggable ? (
<GripVertical className="size-3.5 shrink-0 text-muted-foreground" />
) : active ? (
<Sparkles className="size-3.5 shrink-0" />
) : null}
{typeof index === "number" && (
<span
className={cn(
"font-mono text-[10px]",
active ? "text-background/75" : "text-muted-foreground",
)}
>
{String(index + 1).padStart(2, "0")}
</span>
)}
<span className="truncate">{name}</span>
{showStatusLabel && status === "checking" && (
<span className="ml-0.5 inline-flex items-center gap-1 text-[9px] font-medium">
<LoaderCircle className="size-3 animate-spin" />
</span>
)}
{showStatusLabel && status === "used" && (
<span className="ml-0.5 inline-flex items-center gap-1 text-[9px] font-medium">
<CheckCircle2 className="size-3" />
</span>
)}
{showStatusLabel && status === "unused" && (
<span className="ml-0.5 inline-flex items-center gap-1 text-[9px] font-medium">
<CircleMinus className="size-3" />
</span>
)}
{onRemove && (
<button
type="button"
aria-label={`移除 ${name}`}
className="ml-0.5 grid size-5 shrink-0 place-items-center rounded-full text-current/55 hover:bg-black/5 hover:text-current focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
onClick={(event) => {
event.stopPropagation();
onRemove();
}}
>
<X className="size-3.5" />
</button>
)}
</span>
);
}

View File

@ -0,0 +1,310 @@
"use client";
import { useMessagePartText } from "@assistant-ui/react";
import {
createContext,
useContext,
useEffect,
useRef,
useState,
type ComponentPropsWithoutRef,
type ReactNode,
} from "react";
import ReactMarkdown, {
type Components,
type ExtraProps,
} from "react-markdown";
import remarkGfm from "remark-gfm";
import { cn } from "@/lib/utils";
const graphemeSegmenter = new Intl.Segmenter("zh-CN", {
granularity: "grapheme",
});
const sentenceEnding = /[。!?!?]/;
const phraseEnding = /[,、;:,;:\n]/;
const minimumChunkSize = 6;
const preferredChunkSize = 12;
const maximumChunkSize = 18;
const revealIntervalMs = 10;
const TailRevealContext = createContext({
animate: false,
textLength: 0,
});
function useTailReveal(node: ExtraProps["node"]) {
const { animate, textLength } = useContext(TailRevealContext);
const endOffset = node?.position?.end.offset ?? -1;
return animate && endOffset >= textLength - 1;
}
function StreamingParagraph({
node,
className,
children,
...props
}: ComponentPropsWithoutRef<"p"> & ExtraProps) {
const reveal = useTailReveal(node);
return (
<p
{...props}
key={reveal ? `tail-${node?.position?.end.offset}` : "settled"}
className={cn(className)}
>
{children}
</p>
);
}
function StreamingUnorderedList({
node,
className,
children,
...props
}: ComponentPropsWithoutRef<"ul"> & ExtraProps) {
const reveal = useTailReveal(node);
return (
<ul
{...props}
key={reveal ? `tail-${node?.position?.end.offset}` : "settled"}
className={cn(className)}
>
{children}
</ul>
);
}
function StreamingOrderedList({
node,
className,
children,
...props
}: ComponentPropsWithoutRef<"ol"> & ExtraProps) {
const reveal = useTailReveal(node);
return (
<ol
{...props}
key={reveal ? `tail-${node?.position?.end.offset}` : "settled"}
className={cn(className)}
>
{children}
</ol>
);
}
function StreamingPreformatted({
node,
className,
children,
...props
}: ComponentPropsWithoutRef<"pre"> & ExtraProps) {
const reveal = useTailReveal(node);
return (
<pre
{...props}
key={reveal ? `tail-${node?.position?.end.offset}` : "settled"}
className={cn(className)}
>
{children}
</pre>
);
}
function StreamingHeadingOne({
node,
className,
children,
...props
}: ComponentPropsWithoutRef<"h1"> & ExtraProps) {
const reveal = useTailReveal(node);
return (
<h1
{...props}
key={reveal ? `tail-${node?.position?.end.offset}` : "settled"}
className={cn(className)}
>
{children}
</h1>
);
}
function StreamingHeadingTwo({
node,
className,
children,
...props
}: ComponentPropsWithoutRef<"h2"> & ExtraProps) {
const reveal = useTailReveal(node);
return (
<h2
{...props}
key={reveal ? `tail-${node?.position?.end.offset}` : "settled"}
className={cn(className)}
>
{children}
</h2>
);
}
function StreamingHeadingThree({
node,
className,
children,
...props
}: ComponentPropsWithoutRef<"h3"> & ExtraProps) {
const reveal = useTailReveal(node);
return (
<h3
{...props}
key={reveal ? `tail-${node?.position?.end.offset}` : "settled"}
className={cn(className)}
>
{children}
</h3>
);
}
function StreamingTable({
node,
className,
children,
...props
}: ComponentPropsWithoutRef<"table"> & ExtraProps) {
const reveal = useTailReveal(node);
return (
<table
{...props}
key={reveal ? `tail-${node?.position?.end.offset}` : "settled"}
className={cn(className)}
>
{children}
</table>
);
}
const streamingComponents = {
p: StreamingParagraph,
h1: StreamingHeadingOne,
h2: StreamingHeadingTwo,
h3: StreamingHeadingThree,
ul: StreamingUnorderedList,
ol: StreamingOrderedList,
pre: StreamingPreformatted,
table: StreamingTable,
} satisfies Components;
function nextRevealChunk(backlog: string, sourceRunning: boolean) {
const graphemes = Array.from(
graphemeSegmenter.segment(backlog),
({ segment }) => segment,
);
if (graphemes.length === 0) return "";
if (
sourceRunning &&
graphemes.length < minimumChunkSize &&
!sentenceEnding.test(graphemes.at(-1) ?? "") &&
!phraseEnding.test(graphemes.at(-1) ?? "")
) {
return "";
}
const upperBound = Math.min(maximumChunkSize, graphemes.length);
const lowerBound = Math.min(minimumChunkSize, upperBound);
let chunkSize = Math.min(preferredChunkSize, upperBound);
for (let index = lowerBound - 1; index < upperBound; index += 1) {
const grapheme = graphemes[index];
if (sentenceEnding.test(grapheme)) {
chunkSize = index + 1;
break;
}
if (
phraseEnding.test(grapheme) &&
index + 1 <= preferredChunkSize + 3
) {
chunkSize = index + 1;
break;
}
}
return graphemes.slice(0, chunkSize).join("");
}
function useSegmentReveal() {
const source = useMessagePartText();
const targetRef = useRef(source.text);
const sourceRunningRef = useRef(source.status.type === "running");
const [displayedText, setDisplayedText] = useState(
source.status.type === "running" ? "" : source.text,
);
useEffect(() => {
targetRef.current = source.text;
sourceRunningRef.current = source.status.type === "running";
}, [source.status.type, source.text]);
useEffect(() => {
const interval = window.setInterval(() => {
setDisplayedText((currentText) => {
const targetText = targetRef.current;
if (!targetText.startsWith(currentText)) {
return sourceRunningRef.current ? "" : targetText;
}
if (
window.matchMedia("(prefers-reduced-motion: reduce)").matches
) {
return targetText;
}
const backlog = targetText.slice(currentText.length);
if (!backlog) return currentText;
const chunk = nextRevealChunk(backlog, sourceRunningRef.current);
return chunk ? currentText + chunk : currentText;
});
}, revealIntervalMs);
return () => window.clearInterval(interval);
}, []);
return {
text: displayedText,
status:
displayedText === source.text
? source.status
: ({ type: "running" } as const),
sourceRunning: source.status.type === "running",
};
}
export function StreamingMarkdown({
pending,
className,
}: {
pending: ReactNode;
className?: string;
}) {
const { text, status, sourceRunning } = useSegmentReveal();
const [wasStreamed] = useState(sourceRunning);
const animateTail = wasStreamed || status.type === "running";
if (!text && status.type === "running") return pending;
if (!text) return null;
return (
<TailRevealContext.Provider
value={{ animate: animateTail, textLength: text.length }}
>
<div
className={cn("markdown", className)}
data-stream-status={status.type}
>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={streamingComponents}
>
{text}
</ReactMarkdown>
</div>
</TailRevealContext.Provider>
);
}

View File

@ -0,0 +1,612 @@
"use client";
import { memo, useCallback, useRef, useState } from "react";
import {
AlertCircleIcon,
CheckIcon,
ChevronDownIcon,
LoaderIcon,
XCircleIcon,
} from "lucide-react";
import {
useScrollLock,
useToolCallElapsed,
type ToolApprovalOption,
type ToolCallMessagePart,
type ToolCallMessagePartProps,
type ToolCallMessagePartStatus,
type ToolCallMessagePartComponent,
} from "@assistant-ui/react";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
const ANIMATION_DURATION = 200;
const pressable = "active:scale-[0.98]";
export type ToolFallbackRootProps = Omit<
React.ComponentProps<typeof Collapsible>,
"open" | "onOpenChange"
> & {
open?: boolean;
onOpenChange?: (open: boolean) => void;
defaultOpen?: boolean;
};
function ToolFallbackRoot({
className,
open: controlledOpen,
onOpenChange: controlledOnOpenChange,
defaultOpen = false,
children,
...props
}: ToolFallbackRootProps) {
const collapsibleRef = useRef<HTMLDivElement>(null);
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
const lockScroll = useScrollLock(collapsibleRef, ANIMATION_DURATION);
const isControlled = controlledOpen !== undefined;
const isOpen = isControlled ? controlledOpen : uncontrolledOpen;
const handleOpenChange = useCallback(
(open: boolean) => {
lockScroll();
if (!isControlled) {
setUncontrolledOpen(open);
}
controlledOnOpenChange?.(open);
},
[lockScroll, isControlled, controlledOnOpenChange],
);
return (
<Collapsible
ref={collapsibleRef}
data-slot="tool-fallback-root"
open={isOpen}
onOpenChange={handleOpenChange}
className={cn(
"aui-tool-fallback-root group/tool-fallback-root w-full",
className,
)}
style={
{
"--animation-duration": `${ANIMATION_DURATION}ms`,
} as React.CSSProperties
}
{...props}
>
{children}
</Collapsible>
);
}
type ToolStatus = ToolCallMessagePartStatus["type"];
const statusIconMap: Record<ToolStatus, React.ElementType> = {
running: LoaderIcon,
complete: CheckIcon,
incomplete: XCircleIcon,
"requires-action": AlertCircleIcon,
};
const formatToolDuration = (ms: number) => {
if (ms < 1000) return "<1s";
const seconds = ms / 1000;
if (seconds < 10) return `${(Math.floor(seconds * 10) / 10).toFixed(1)}s`;
if (seconds < 60) return `${Math.floor(seconds)}s`;
return `${Math.floor(seconds / 60)}m ${Math.floor(seconds % 60)}s`;
};
function ToolFallbackDuration({
className,
...props
}: React.ComponentProps<"span">) {
const elapsedMs = useToolCallElapsed();
if (elapsedMs === undefined) return null;
return (
<span
data-slot="tool-fallback-duration"
className={cn(
"aui-tool-fallback-duration text-muted-foreground text-xs tabular-nums",
className,
)}
{...props}
>
{formatToolDuration(elapsedMs)}
</span>
);
}
function ToolFallbackTrigger({
toolName,
status,
className,
...props
}: React.ComponentProps<typeof CollapsibleTrigger> & {
toolName: string;
status?: ToolCallMessagePartStatus;
}) {
const statusType = status?.type ?? "complete";
const isRunning = statusType === "running";
const isCancelled =
status?.type === "incomplete" && status.reason === "cancelled";
const Icon = statusIconMap[statusType];
const label = isCancelled
? "已中止 Skill"
: isRunning
? "正在使用 Skill"
: statusType === "incomplete"
? "Skill 调用失败"
: "已使用 Skill";
return (
<CollapsibleTrigger
data-slot="tool-fallback-trigger"
className={cn(
"aui-tool-fallback-trigger group/trigger text-muted-foreground hover:text-foreground flex w-fit origin-left items-center gap-2 py-1.5 text-sm transition-[color,scale] active:scale-[0.98]",
className,
)}
{...props}
>
<Icon
data-slot="tool-fallback-trigger-icon"
className={cn(
"aui-tool-fallback-trigger-icon size-4 shrink-0",
isCancelled && "text-muted-foreground",
isRunning && "animate-spin [animation-duration:0.6s]",
)}
/>
<span
data-slot="tool-fallback-trigger-label"
className={cn(
"aui-tool-fallback-trigger-label-wrapper relative inline-block text-start leading-none",
isCancelled && "text-muted-foreground line-through",
)}
>
<span>
{label}: <b>{toolName}</b>
</span>
{isRunning && (
<span
aria-hidden
data-slot="tool-fallback-trigger-shimmer"
className="aui-tool-fallback-trigger-shimmer shimmer pointer-events-none absolute inset-0 motion-reduce:animate-none"
>
{label}: <b>{toolName}</b>
</span>
)}
</span>
<ToolFallbackDuration />
<ChevronDownIcon
data-slot="tool-fallback-trigger-chevron"
className={cn(
"aui-tool-fallback-trigger-chevron size-4 shrink-0",
"transition-transform duration-(--animation-duration) ease-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:transition-none",
"-rotate-90",
"group-data-open/trigger:rotate-0",
"group-data-panel-open/trigger:rotate-0",
)}
/>
</CollapsibleTrigger>
);
}
function ToolFallbackContent({
className,
children,
...props
}: React.ComponentProps<typeof CollapsibleContent>) {
return (
<CollapsibleContent
data-slot="tool-fallback-content"
className={cn(
"aui-tool-fallback-content relative overflow-hidden text-sm outline-none",
"group/collapsible-content ease-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:animate-none",
"data-closed:animate-collapsible-up",
"data-open:animate-collapsible-down",
"data-closed:fill-mode-forwards",
"data-closed:pointer-events-none",
"data-open:duration-(--animation-duration)",
"data-closed:duration-(--animation-duration)",
className,
)}
{...props}
>
<div
className={cn(
"flex flex-col gap-2 ps-6 pt-1 pb-2 ease-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:animate-none",
"group-data-open/collapsible-content:animate-in group-data-open/collapsible-content:fade-in-0 group-data-open/collapsible-content:blur-in-[2px] group-data-open/collapsible-content:slide-in-from-top-1",
"group-data-closed/collapsible-content:animate-out group-data-closed/collapsible-content:fade-out-0 group-data-closed/collapsible-content:blur-out-[2px] group-data-closed/collapsible-content:slide-out-to-top-1",
"group-data-closed/collapsible-content:duration-(--animation-duration) group-data-open/collapsible-content:duration-(--animation-duration)",
)}
>
{children}
</div>
</CollapsibleContent>
);
}
function ToolFallbackArgs({
argsText,
className,
...props
}: React.ComponentProps<"div"> & {
argsText?: string;
}) {
if (!argsText) return null;
return (
<div
data-slot="tool-fallback-args"
className={cn("aui-tool-fallback-args", className)}
{...props}
>
<pre className="aui-tool-fallback-args-value bg-muted/50 text-foreground/90 rounded-md p-2.5 text-xs whitespace-pre-wrap">
{argsText}
</pre>
</div>
);
}
function ToolFallbackResult({
result,
className,
...props
}: React.ComponentProps<"div"> & {
result?: unknown;
}) {
if (result === undefined) return null;
return (
<div
data-slot="tool-fallback-result"
className={cn("aui-tool-fallback-result", className)}
{...props}
>
<p className="aui-tool-fallback-result-header text-muted-foreground text-xs font-medium">
</p>
<pre className="aui-tool-fallback-result-content bg-muted/50 text-foreground/90 mt-1 rounded-md p-2.5 text-xs whitespace-pre-wrap">
{typeof result === "string" ? result : JSON.stringify(result, null, 2)}
</pre>
</div>
);
}
function ToolFallbackError({
status,
className,
...props
}: React.ComponentProps<"div"> & {
status?: ToolCallMessagePartStatus;
}) {
if (status?.type !== "incomplete") return null;
const error = status.error;
const errorText = error
? typeof error === "string"
? error
: JSON.stringify(error)
: null;
if (!errorText) return null;
const isCancelled = status.reason === "cancelled";
const headerText = isCancelled ? "中止原因" : "错误";
return (
<div
data-slot="tool-fallback-error"
className={cn("aui-tool-fallback-error", className)}
{...props}
>
<p className="aui-tool-fallback-error-header text-muted-foreground font-semibold">
{headerText}
</p>
<p className="aui-tool-fallback-error-reason text-muted-foreground">
{errorText}
</p>
</div>
);
}
const APPROVED_RESULT = "Approved by user";
const DENIED_RESULT = "User denied tool execution";
const APPROVAL_OPTION_DEFAULT_LABELS: Record<string, string> = {
"allow-once": "Allow",
"allow-always": "Always allow",
"reject-once": "Deny",
"reject-always": "Always deny",
};
const isAllowKind = (kind: string) =>
kind === "allow-once" || kind === "allow-always";
const approvalOptionLabel = (option: ToolApprovalOption) =>
option.label ??
(Object.hasOwn(APPROVAL_OPTION_DEFAULT_LABELS, option.kind)
? APPROVAL_OPTION_DEFAULT_LABELS[option.kind]
: undefined) ??
option.id;
function ToolFallbackApproval({
className,
addResult,
resume,
interrupt,
approval,
respondToApproval,
...props
}: React.ComponentProps<"div"> &
Partial<
Pick<ToolCallMessagePartProps, "addResult" | "resume" | "respondToApproval">
> & {
interrupt?: ToolCallMessagePart["interrupt"];
approval?: ToolCallMessagePart["approval"];
}) {
const [submitted, setSubmitted] = useState(false);
const [confirmingId, setConfirmingId] = useState<string | null>(null);
if (
approval != null &&
(approval.approved !== undefined || approval.resolution !== undefined)
)
return null;
// Custom (`_`-prefixed) kinds cannot be resolved to a boolean by the kit;
// hosts using custom kinds render their own bar. A declared option list is
// a host constraint: the kit never adds an approval path beyond it, but
// always preserves a refusal path.
const declaredOptions = respondToApproval ? approval?.options : undefined;
const options = declaredOptions?.filter((o) =>
Object.hasOwn(APPROVAL_OPTION_DEFAULT_LABELS, o.kind),
);
const respond = (approved: boolean) => {
if (submitted) return;
if (
approval != null &&
approval.approved === undefined &&
respondToApproval
) {
respondToApproval({ approved });
} else if (interrupt) {
resume?.({ approved });
} else {
addResult?.(approved ? APPROVED_RESULT : DENIED_RESULT);
}
setSubmitted(true);
};
const respondWithOption = (option: ToolApprovalOption) => {
if (submitted) return;
respondToApproval?.({ optionId: option.id });
setSubmitted(true);
setConfirmingId(null);
};
const handleOption = (option: ToolApprovalOption) => {
if (option.confirm) {
setConfirmingId(option.id);
} else {
respondWithOption(option);
}
};
const confirming =
confirmingId != null
? options?.find((o) => o.id === confirmingId)
: undefined;
if (confirming) {
const confirmMeta =
typeof confirming.confirm === "object" ? confirming.confirm : undefined;
const confirmDescription =
confirmMeta?.description ?? confirming.description;
return (
<div
data-slot="tool-fallback-approval-confirm"
className={cn(
"aui-tool-fallback-approval-confirm flex flex-col gap-2 pt-1",
className,
)}
{...props}
>
<p className="aui-tool-fallback-approval-confirm-title font-semibold">
{confirmMeta?.title ?? `${approvalOptionLabel(confirming)}?`}
</p>
{confirmDescription && (
<p className="aui-tool-fallback-approval-confirm-description text-muted-foreground">
{confirmDescription}
</p>
)}
{confirming.grants && confirming.grants.length > 0 && (
<ul className="aui-tool-fallback-approval-confirm-grants flex flex-col gap-1">
{confirming.grants.map((grant) => (
<li key={grant}>
<code className="aui-tool-fallback-approval-confirm-grant bg-muted rounded px-1.5 py-0.5 text-xs">
{grant}
</code>
</li>
))}
</ul>
)}
<div className="flex items-center gap-2">
<Button
size="sm"
className={pressable}
onClick={() => respondWithOption(confirming)}
disabled={submitted}
>
Confirm
</Button>
<Button
size="sm"
variant="outline"
className={pressable}
onClick={() => setConfirmingId(null)}
disabled={submitted}
>
Back
</Button>
</div>
</div>
);
}
if (declaredOptions && declaredOptions.length > 0) {
const allowOptions = options?.filter((o) => isAllowKind(o.kind)) ?? [];
const rejectOptions = options?.filter((o) => !isAllowKind(o.kind)) ?? [];
return (
<div
data-slot="tool-fallback-approval"
className={cn(
"aui-tool-fallback-approval flex flex-wrap items-center gap-2 pt-1",
className,
)}
{...props}
>
{[...allowOptions, ...rejectOptions].map((option) => (
<Button
key={option.id}
size="sm"
variant={option === allowOptions[0] ? "default" : "outline"}
className={pressable}
onClick={() => handleOption(option)}
disabled={submitted}
>
{approvalOptionLabel(option)}
</Button>
))}
{rejectOptions.length === 0 && (
<Button
size="sm"
variant="outline"
className={pressable}
onClick={() => respond(false)}
disabled={submitted}
>
Deny
</Button>
)}
</div>
);
}
return (
<div
data-slot="tool-fallback-approval"
className={cn(
"aui-tool-fallback-approval flex items-center gap-2 pt-1",
className,
)}
{...props}
>
<Button
size="sm"
className={pressable}
onClick={() => respond(true)}
disabled={submitted}
>
Allow
</Button>
<Button
size="sm"
variant="outline"
className={pressable}
onClick={() => respond(false)}
disabled={submitted}
>
Deny
</Button>
</div>
);
}
const ToolFallbackImpl: ToolCallMessagePartComponent = ({
toolName,
argsText,
result,
status,
addResult,
resume,
interrupt,
approval,
respondToApproval,
}) => {
const isCancelled =
status?.type === "incomplete" && status.reason === "cancelled";
const isRequiresAction = status?.type === "requires-action";
const [open, setOpen] = useState(isRequiresAction);
const [prevRequiresAction, setPrevRequiresAction] =
useState(isRequiresAction);
if (isRequiresAction !== prevRequiresAction) {
setPrevRequiresAction(isRequiresAction);
if (isRequiresAction) setOpen(true);
}
return (
<ToolFallbackRoot open={open} onOpenChange={setOpen}>
<ToolFallbackTrigger toolName={toolName} status={status} />
<ToolFallbackContent>
<ToolFallbackError status={status} />
<ToolFallbackArgs
argsText={argsText}
className={cn(isCancelled && "opacity-60")}
/>
{isRequiresAction && (
<ToolFallbackApproval
addResult={addResult}
resume={resume}
interrupt={interrupt}
approval={approval}
respondToApproval={respondToApproval}
/>
)}
{!isCancelled && <ToolFallbackResult result={result} />}
</ToolFallbackContent>
</ToolFallbackRoot>
);
};
const ToolFallback = memo(
ToolFallbackImpl,
) as unknown as ToolCallMessagePartComponent & {
Root: typeof ToolFallbackRoot;
Trigger: typeof ToolFallbackTrigger;
Content: typeof ToolFallbackContent;
Args: typeof ToolFallbackArgs;
Result: typeof ToolFallbackResult;
Error: typeof ToolFallbackError;
Approval: typeof ToolFallbackApproval;
};
ToolFallback.displayName = "ToolFallback";
ToolFallback.Root = ToolFallbackRoot;
ToolFallback.Trigger = ToolFallbackTrigger;
ToolFallback.Content = ToolFallbackContent;
ToolFallback.Args = ToolFallbackArgs;
ToolFallback.Result = ToolFallbackResult;
ToolFallback.Error = ToolFallbackError;
ToolFallback.Approval = ToolFallbackApproval;
export {
ToolFallback,
ToolFallbackRoot,
ToolFallbackTrigger,
ToolFallbackContent,
ToolFallbackArgs,
ToolFallbackResult,
ToolFallbackError,
ToolFallbackApproval,
};

View File

@ -0,0 +1,196 @@
"use client"
import * as React from "react"
import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
size?: "default" | "sm"
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"group/alert-dialog-content fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 data-[size=sm]:max-w-xs 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 data-[size=default]:sm:max-w-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn(
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
className
)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn(
"text-lg font-semibold sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className
)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function AlertDialogMedia({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-media"
className={cn(
"mb-2 inline-flex size-16 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8",
className
)}
{...props}
/>
)
}
function AlertDialogAction({
className,
variant = "default",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Action
data-slot="alert-dialog-action"
className={cn(className)}
{...props}
/>
</Button>
)
}
function AlertDialogCancel({
className,
variant = "outline",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Cancel
data-slot="alert-dialog-cancel"
className={cn(className)}
{...props}
/>
</Button>
)
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
}

View File

@ -0,0 +1,64 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@ -0,0 +1,33 @@
"use client"
import { Collapsible as CollapsiblePrimitive } from "radix-ui"
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View File

@ -0,0 +1,158 @@
"use client"
import * as React from "react"
import { XIcon } from "lucide-react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none 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 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }

View File

@ -0,0 +1,89 @@
"use client"
import * as React from "react"
import { Popover as PopoverPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden 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",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="popover-header"
className={cn("flex flex-col gap-1 text-sm", className)}
{...props}
/>
)
}
function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) {
return (
<div
data-slot="popover-title"
className={cn("font-medium", className)}
{...props}
/>
)
}
function PopoverDescription({
className,
...props
}: React.ComponentProps<"p">) {
return (
<p
data-slot="popover-description"
className={cn("text-muted-foreground", className)}
{...props}
/>
)
}
export {
Popover,
PopoverTrigger,
PopoverContent,
PopoverAnchor,
PopoverHeader,
PopoverTitle,
PopoverDescription,
}

View File

@ -0,0 +1,121 @@
"use client";
import {
useCallback,
useEffect,
useRef,
useState,
type KeyboardEventHandler,
type PointerEventHandler,
type UIEventHandler,
type WheelEventHandler,
} from "react";
const bottomThreshold = 32;
export function useSmoothFollow(active: boolean, resetKey: string) {
const [viewport, setViewport] = useState<HTMLDivElement | null>(null);
const followingRef = useRef(true);
const pointerActiveRef = useRef(false);
const animationFrameRef = useRef<number | null>(null);
const scrollToLatest = useCallback(() => {
if (!viewport || !followingRef.current) return;
if (animationFrameRef.current !== null) {
window.cancelAnimationFrame(animationFrameRef.current);
}
animationFrameRef.current = window.requestAnimationFrame(() => {
animationFrameRef.current = null;
const reduceMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches;
viewport.scrollTo({
top: viewport.scrollHeight,
behavior: reduceMotion ? "auto" : "smooth",
});
});
}, [viewport]);
useEffect(() => {
followingRef.current = true;
scrollToLatest();
}, [resetKey, scrollToLatest]);
useEffect(() => {
if (!active) return;
scrollToLatest();
}, [active, scrollToLatest]);
useEffect(() => {
if (!viewport) return;
const observer = new MutationObserver(scrollToLatest);
observer.observe(viewport, {
childList: true,
subtree: true,
characterData: true,
});
return () => observer.disconnect();
}, [scrollToLatest, viewport]);
useEffect(
() => () => {
if (animationFrameRef.current !== null) {
window.cancelAnimationFrame(animationFrameRef.current);
}
},
[],
);
const onScroll = useCallback<UIEventHandler<HTMLDivElement>>((event) => {
const element = event.currentTarget;
const distanceFromBottom =
element.scrollHeight - element.scrollTop - element.clientHeight;
if (distanceFromBottom <= bottomThreshold) {
followingRef.current = true;
} else {
followingRef.current = false;
}
}, []);
const onWheel = useCallback<WheelEventHandler<HTMLDivElement>>((event) => {
if (event.deltaY < 0) {
followingRef.current = false;
}
}, []);
const onPointerDown = useCallback<PointerEventHandler<HTMLDivElement>>(() => {
pointerActiveRef.current = true;
}, []);
const onPointerUp = useCallback<PointerEventHandler<HTMLDivElement>>(() => {
pointerActiveRef.current = false;
}, []);
const onKeyDown = useCallback<KeyboardEventHandler<HTMLDivElement>>(
(event) => {
if (
event.key === "ArrowUp" ||
event.key === "PageUp" ||
event.key === "Home"
) {
followingRef.current = false;
}
},
[],
);
return {
ref: setViewport,
onScroll,
onWheel,
onPointerDown,
onPointerUp,
onPointerCancel: onPointerUp,
onPointerLeave: onPointerUp,
onKeyDown,
};
}

17
src/lib/api.ts Normal file
View File

@ -0,0 +1,17 @@
import { ZodError } from "zod";
export function apiError(error: unknown, fallback = "请求处理失败") {
if (error instanceof ZodError) {
return Response.json(
{
error: "请求数据不完整",
details: error.issues.map((issue) => issue.message),
},
{ status: 400 },
);
}
const message = error instanceof Error ? error.message : fallback;
return Response.json({ error: message }, { status: 500 });
}

129
src/lib/builder-runs.ts Normal file
View File

@ -0,0 +1,129 @@
import { evaluateBuilderTurn, hasDeepSeekApiKey } from "@/lib/deepseek";
import type {
BuilderEvaluation,
SkillNode,
SseEvent,
} from "@/lib/types";
type BuilderRunInput = {
clientRequestId: string;
message: string;
nodes: SkillNode[];
skillName: string;
skillDescription: string;
messages: Array<{ role: "user" | "assistant"; content: string }>;
};
type BuilderRunState = {
id: string;
status: "running" | "complete" | "stopped" | "error";
events: SseEvent[];
controller: AbortController;
};
const globalForBuilderRuns = globalThis as unknown as {
skillLoomBuilderRuns?: Map<string, BuilderRunState>;
};
const builderRuns =
globalForBuilderRuns.skillLoomBuilderRuns ??
new Map<string, BuilderRunState>();
globalForBuilderRuns.skillLoomBuilderRuns = builderRuns;
function emit(run: BuilderRunState, event: SseEvent) {
run.events.push({ ...event, seq: run.events.length + 1 } as SseEvent);
}
function scheduleCleanup(id: string) {
const timeout = setTimeout(() => builderRuns.delete(id), 5 * 60_000);
timeout.unref();
}
export function getOrStartBuilderRun(input: BuilderRunInput) {
const existing = builderRuns.get(input.clientRequestId);
if (existing) return existing;
const run: BuilderRunState = {
id: input.clientRequestId,
status: "running",
events: [],
controller: new AbortController(),
};
builderRuns.set(run.id, run);
emit(run, {
type: "status",
label: hasDeepSeekApiKey()
? "思考中"
: "正在整理回复",
});
void (async () => {
try {
const evaluation = await evaluateBuilderTurn({
message: input.message,
nodes: input.nodes,
skillName: input.skillName,
skillDescription: input.skillDescription,
messages: input.messages,
signal: run.controller.signal,
});
const reply = evaluation.reply;
emit(run, {
type: "builder_update",
evaluation: {
...evaluation,
reply: "",
} satisfies BuilderEvaluation,
});
const chunks = reply.match(/[\s\S]{1,7}/g) ?? [reply];
for (const token of chunks) {
if (run.controller.signal.aborted) {
throw new DOMException("Aborted", "AbortError");
}
emit(run, { type: "token", token });
await new Promise((resolve) => setTimeout(resolve, 18));
}
run.status = "complete";
emit(run, { type: "complete" });
} catch (error) {
const stopped =
run.controller.signal.aborted ||
(error instanceof Error && error.name === "AbortError");
run.status = stopped ? "stopped" : "error";
emit(
run,
stopped
? { type: "aborted" }
: {
type: "error",
message:
error instanceof Error ? error.message : "节点整理失败",
},
);
} finally {
scheduleCleanup(run.id);
}
})();
return run;
}
export function getBuilderRunEvents(id: string, afterSeq: number) {
const run = builderRuns.get(id);
if (!run) return null;
return {
status: run.status,
events: run.events.filter((event) => (event.seq ?? 0) > afterSeq),
};
}
export function stopBuilderRun(id: string) {
const run = builderRuns.get(id);
if (!run) return false;
if (run.status === "running") run.controller.abort();
return true;
}

530
src/lib/chat-runs.ts Normal file
View File

@ -0,0 +1,530 @@
import {
buildSkillExplanationResponse,
buildChatSystemPrompt,
decideSkillContinuation,
decideSkillUsage,
demoChatResponse,
fallbackConversationTitle,
generateConversationTitle,
hasDeepSeekApiKey,
isSkillExplanationRequest,
streamTextCompletion,
} from "@/lib/deepseek";
import {
appendChatRunEvent,
completeChatRunIfNotCancelled,
getConversation,
getChatRun,
getMessage,
isChatRunStopRequested,
requestChatRunStop,
setConversationContinuation,
setConversationTitleIfEmpty,
updateChatRunStatus,
updateMessage,
updateMessageUsage,
} from "@/lib/db";
import {
addModelCallUsage,
createMessageUsage,
finalizeMessageUsage,
} from "@/lib/model-usage";
import type {
ChatMessage,
ChatRunStatus,
ConversationContinuation,
ModelCallUsage,
Skill,
} from "@/lib/types";
type RunningChat = {
controller: AbortController;
timedOut: boolean;
stopContent?: string;
};
const globalForRuns = globalThis as unknown as {
skillLoomRuns?: Map<string, RunningChat>;
};
const runningChats = globalForRuns.skillLoomRuns ?? new Map<string, RunningChat>();
globalForRuns.skillLoomRuns = runningChats;
async function streamDemo(
content: string,
signal: AbortSignal,
onToken: (token: string) => void,
) {
const segments = content.match(/[\s\S]{1,8}/g) ?? [content];
for (const segment of segments) {
if (signal.aborted) throw new DOMException("Aborted", "AbortError");
onToken(segment);
await new Promise((resolve) => setTimeout(resolve, 22));
}
}
async function completeTitle(
runId: string,
titlePromise: Promise<string>,
) {
const run = getChatRun(runId);
if (!run) return "";
const existing = getConversation(run.conversationId);
if (!existing || existing.title) return existing?.title ?? "";
const generatedTitle = await titlePromise;
const title = setConversationTitleIfEmpty(
run.conversationId,
generatedTitle,
);
if (title) {
appendChatRunEvent(runId, { type: "conversation_title", title });
}
return title;
}
async function titleWithin(
titlePromise: Promise<string>,
fallback: string,
timeoutMs: number,
) {
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
titlePromise,
new Promise<string>((resolve) => {
timeout = setTimeout(() => resolve(fallback), timeoutMs);
}),
]);
} finally {
if (timeout) clearTimeout(timeout);
}
}
export function startChatRun(input: {
runId: string;
conversationId: string;
userMessage: string;
selectedSkills: Skill[];
continuation: ConversationContinuation;
history: Array<{ role: ChatMessage["role"]; content: string }>;
}) {
if (runningChats.has(input.runId)) return;
const run = getChatRun(input.runId);
if (!run || !["pending", "running"].includes(run.status)) return;
const createdAtMs = Date.parse(run.createdAt);
const runStartedAt = Number.isFinite(createdAtMs)
? createdAtMs
: Date.now();
const controller = new AbortController();
const state: RunningChat = { controller, timedOut: false };
runningChats.set(input.runId, state);
updateChatRunStatus(input.runId, "running");
appendChatRunEvent(input.runId, {
type: "meta",
runId: input.runId,
userMessageId: run.userMessageId,
assistantMessageId: run.assistantMessageId,
});
appendChatRunEvent(input.runId, {
type: "status",
label: "正在思考",
});
const timeout = setTimeout(() => {
state.timedOut = true;
controller.abort();
}, 110_000);
void (async () => {
let content = "";
let usedSkillIds: string[] = [];
let lastPersistedAt = 0;
let titlePromise: Promise<string> | null = null;
let usage = createMessageUsage(
hasDeepSeekApiKey() ? "provider" : "demo",
);
let usageTerminalStatus:
| Extract<ChatRunStatus, "complete" | "stopped" | "error">
| undefined;
let terminalDurationMs: number | undefined;
const persistUsage = () => {
if (usageTerminalStatus) {
usage = finalizeMessageUsage(
usage,
usageTerminalStatus,
terminalDurationMs,
);
}
updateMessageUsage(run.assistantMessageId, usage);
appendChatRunEvent(input.runId, { type: "usage", usage });
};
const onUsage = (callUsage: ModelCallUsage) => {
usage = addModelCallUsage(usage, callUsage);
persistUsage();
};
const completeUsage = (
status: Extract<ChatRunStatus, "complete" | "stopped" | "error">,
) => {
usageTerminalStatus = status;
terminalDurationMs = Math.max(0, Date.now() - runStartedAt);
persistUsage();
};
const throwIfStopRequested = () => {
if (
controller.signal.aborted ||
isChatRunStopRequested(input.runId)
) {
throw new DOMException("Aborted", "AbortError");
}
};
try {
const isExplanationRequest = isSkillExplanationRequest(
input.userMessage,
);
const skillDecision = isExplanationRequest
? {
skillIds: input.selectedSkills.map((skill) => skill.id),
source: "explicit_explanation" as const,
reason: "用户明确询问已选 Skill 的用途。",
}
: await decideSkillUsage(
input.userMessage,
input.selectedSkills,
{
signal: controller.signal,
history: input.history,
continuation: input.continuation,
onUsage,
},
);
usedSkillIds = skillDecision.skillIds;
const usedSkills = input.selectedSkills.filter((skill) =>
usedSkillIds.includes(skill.id),
);
if (!getConversation(input.conversationId)?.title) {
titlePromise = generateConversationTitle({
userMessage: input.userMessage,
usedSkills,
onUsage,
});
}
appendChatRunEvent(input.runId, {
type: "skill_usage",
skillIds: usedSkillIds,
source: skillDecision.source,
reason: skillDecision.reason,
});
appendChatRunEvent(input.runId, {
type: "status",
label:
usedSkills.length > 0
? `正在应用 ${usedSkills.map((skill) => skill.name).join("、")}`
: "正在思考",
});
const onToken = (token: string) => {
if (isChatRunStopRequested(input.runId)) {
controller.abort();
return;
}
content += token;
appendChatRunEvent(input.runId, { type: "token", token });
const now = Date.now();
if (now - lastPersistedAt > 250) {
lastPersistedAt = now;
updateMessage(run.assistantMessageId, {
content,
usedSkillIds,
status: "streaming",
});
}
};
if (isExplanationRequest) {
await streamDemo(
buildSkillExplanationResponse(usedSkills),
controller.signal,
onToken,
);
} else if (hasDeepSeekApiKey()) {
await streamTextCompletion({
messages: [
{
role: "system",
content: buildChatSystemPrompt(usedSkills),
},
...input.history,
{ role: "user", content: input.userMessage },
],
signal: controller.signal,
onToken,
onUsage,
});
} else {
await streamDemo(
demoChatResponse(input.userMessage, usedSkills),
controller.signal,
onToken,
);
}
throwIfStopRequested();
if (!content.trim()) {
throw new Error("AI 返回了空答案,请重新发送");
}
appendChatRunEvent(input.runId, {
type: "status",
label: "正在确认任务是否需要继续",
});
const continuation = await decideSkillContinuation({
userMessage: input.userMessage,
assistantResponse: content,
usedSkills,
onUsage,
});
throwIfStopRequested();
const persistedContinuation = setConversationContinuation(
input.conversationId,
continuation,
);
appendChatRunEvent(input.runId, {
type: "skill_retention",
state: persistedContinuation.state,
skillIds: persistedContinuation.skillIds,
expectedInput: persistedContinuation.expectedInput,
reason: persistedContinuation.reason,
source: persistedContinuation.source,
});
updateMessage(run.assistantMessageId, {
content,
usedSkillIds,
status: "complete",
});
if (titlePromise) {
appendChatRunEvent(input.runId, {
type: "status",
label: "正在提炼会话主题",
});
await completeTitle(
input.runId,
titleWithin(
titlePromise,
fallbackConversationTitle(input.userMessage, usedSkills),
1_200,
),
);
}
throwIfStopRequested();
completeUsage("complete");
appendChatRunEvent(input.runId, {
type: "complete",
messageId: run.assistantMessageId,
});
if (!completeChatRunIfNotCancelled(input.runId)) {
throw new DOMException("Aborted", "AbortError");
}
} catch (error) {
const stopped =
!state.timedOut &&
(controller.signal.aborted ||
isChatRunStopRequested(input.runId) ||
(error instanceof Error && error.name === "AbortError"));
if (stopped) {
const stoppedContent =
(state.stopContent ?? content) || "本轮回答已中止。";
setConversationContinuation(input.conversationId, {
state: "complete",
skillIds: [],
expectedInput: "",
reason: "本轮回答已中止,不保留 Skill 续接状态。",
source: "deterministic",
});
appendChatRunEvent(input.runId, {
type: "skill_retention",
state: "complete",
skillIds: [],
expectedInput: "",
reason: "本轮回答已中止,不保留 Skill 续接状态。",
source: "deterministic",
});
updateMessage(run.assistantMessageId, {
content: stoppedContent,
usedSkillIds,
status: "stopped",
});
const stoppedSkills = input.selectedSkills.filter((skill) =>
usedSkillIds.includes(skill.id),
);
const fallbackTitle = fallbackConversationTitle(
input.userMessage,
stoppedSkills,
);
await completeTitle(
input.runId,
titleWithin(
titlePromise ??
generateConversationTitle({
userMessage: input.userMessage,
usedSkills: stoppedSkills,
onUsage,
}),
fallbackTitle,
400,
),
);
completeUsage("stopped");
appendChatRunEvent(input.runId, { type: "aborted" });
updateChatRunStatus(input.runId, "stopped");
} else {
setConversationContinuation(input.conversationId, {
state: "complete",
skillIds: [],
expectedInput: "",
reason: "本轮回答失败,不保留 Skill 续接状态。",
source: "deterministic",
});
appendChatRunEvent(input.runId, {
type: "skill_retention",
state: "complete",
skillIds: [],
expectedInput: "",
reason: "本轮回答失败,不保留 Skill 续接状态。",
source: "deterministic",
});
const message = state.timedOut
? "回答超时,已安全结束本轮"
: error instanceof Error
? error.message
: "生成回答时发生错误";
updateMessage(run.assistantMessageId, {
content,
usedSkillIds,
status: "error",
errorMessage: message,
});
completeUsage("error");
appendChatRunEvent(input.runId, { type: "error", message });
updateChatRunStatus(input.runId, "error");
}
} finally {
clearTimeout(timeout);
runningChats.delete(input.runId);
}
})();
}
export async function stopChatRun(
id: string,
options?: { visibleContent?: string },
) {
const run = getChatRun(id);
if (!run) return { found: false, stopped: false, title: "" };
const requestedContent = options?.visibleContent;
const persistStoppedUsage = (message: ChatMessage | null) => {
const createdAtMs = Date.parse(run.createdAt);
const durationMs = Math.max(
0,
Number.isFinite(createdAtMs) ? Date.now() - createdAtMs : 0,
);
const usage = finalizeMessageUsage(
message?.usage ??
createMessageUsage(
hasDeepSeekApiKey() ? "provider" : "demo",
),
"stopped",
durationMs,
);
updateMessageUsage(run.assistantMessageId, usage);
appendChatRunEvent(id, { type: "usage", usage });
};
if (!["pending", "running"].includes(run.status)) {
if (run.status === "complete" && requestedContent !== undefined) {
const assistantMessage = getMessage(run.assistantMessageId);
updateMessage(run.assistantMessageId, {
content: requestedContent || "本轮回答已中止。",
usedSkillIds: assistantMessage?.usedSkillIds ?? [],
status: "stopped",
});
setConversationContinuation(run.conversationId, {
state: "complete",
skillIds: [],
expectedInput: "",
reason: "用户在回答展示完成前请求中止,不保留 Skill 续接状态。",
source: "deterministic",
});
persistStoppedUsage(assistantMessage);
appendChatRunEvent(id, {
type: "skill_retention",
state: "complete",
skillIds: [],
expectedInput: "",
reason: "用户在回答展示完成前请求中止,不保留 Skill 续接状态。",
source: "deterministic",
});
appendChatRunEvent(id, { type: "aborted" });
updateChatRunStatus(id, "stopped");
return { found: true, stopped: true, title: "" };
}
return {
found: true,
stopped: run.status === "stopped",
title: "",
};
}
requestChatRunStop(id);
const active = runningChats.get(id);
if (active) {
active.stopContent = requestedContent;
active.controller.abort();
return { found: true, stopped: true, title: "" };
}
const userMessage = getMessage(run.userMessageId);
const assistantMessage = getMessage(run.assistantMessageId);
updateMessage(run.assistantMessageId, {
content:
requestedContent ||
assistantMessage?.content ||
"本轮回答已中止。",
usedSkillIds: assistantMessage?.usedSkillIds ?? [],
status: "stopped",
});
setConversationContinuation(run.conversationId, {
state: "complete",
skillIds: [],
expectedInput: "",
reason: "本轮回答已中止,不保留 Skill 续接状态。",
source: "deterministic",
});
const title = await completeTitle(
id,
Promise.resolve(
fallbackConversationTitle(userMessage?.content ?? "", []),
),
);
persistStoppedUsage(assistantMessage);
appendChatRunEvent(id, {
type: "skill_retention",
state: "complete",
skillIds: [],
expectedInput: "",
reason: "本轮回答已中止,不保留 Skill 续接状态。",
source: "deterministic",
});
appendChatRunEvent(id, { type: "aborted" });
updateChatRunStatus(id, "stopped");
return { found: true, stopped: true, title };
}

38
src/lib/client-sse.ts Normal file
View File

@ -0,0 +1,38 @@
import type { SseEvent } from "@/lib/types";
export async function consumeSse(
response: Response,
onEvent: (event: SseEvent) => void,
) {
if (!response.ok) {
const payload = (await response.json().catch(() => null)) as {
error?: string;
} | null;
throw new Error(payload?.error ?? `请求失败 (${response.status})`);
}
if (!response.body) throw new Error("服务端没有返回数据流");
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const blocks = buffer.split(/\r?\n\r?\n/);
buffer = blocks.pop() ?? "";
for (const block of blocks) {
const data = block
.split(/\r?\n/)
.filter((line) => line.startsWith("data:"))
.map((line) => line.slice(5).trim())
.join("\n");
if (!data) continue;
onEvent(JSON.parse(data) as SseEvent);
}
}
}

1096
src/lib/db.ts Normal file

File diff suppressed because it is too large Load Diff

1264
src/lib/deepseek.ts Normal file

File diff suppressed because it is too large Load Diff

261
src/lib/model-usage.ts Normal file
View File

@ -0,0 +1,261 @@
import type {
MessageUsage,
ModelCallUsage,
TokenUsage,
} from "@/lib/types";
type DeepSeekPricing = {
cacheHitInputCnyPerMillion: number;
cacheMissInputCnyPerMillion: number;
outputCnyPerMillion: number;
version: string;
};
const ZERO_TOKENS: TokenUsage = {
promptTokens: 0,
promptCacheHitTokens: 0,
promptCacheMissTokens: 0,
completionTokens: 0,
reasoningTokens: 0,
totalTokens: 0,
};
const DEFAULT_DEEPSEEK_PRICING: Record<string, DeepSeekPricing> = {
"deepseek-v4-flash": {
cacheHitInputCnyPerMillion: 0.02,
cacheMissInputCnyPerMillion: 1,
outputCnyPerMillion: 2,
version: "deepseek-2026-04-24",
},
"deepseek-v4-pro": {
cacheHitInputCnyPerMillion: 0.025,
cacheMissInputCnyPerMillion: 3,
outputCnyPerMillion: 6,
version: "deepseek-2026-04-24",
},
};
function finiteNonNegative(value: unknown) {
return typeof value === "number" && Number.isFinite(value) && value >= 0
? value
: 0;
}
function configuredRate(name: string, fallback: number) {
const value = Number(process.env[name]);
return Number.isFinite(value) && value >= 0 ? value : fallback;
}
function getDeepSeekPricing(model: string): DeepSeekPricing | null {
const defaults = DEFAULT_DEEPSEEK_PRICING[model];
if (!defaults) return null;
return {
cacheHitInputCnyPerMillion: configuredRate(
"DEEPSEEK_PRICE_CACHE_HIT_INPUT_CNY_PER_MILLION",
defaults.cacheHitInputCnyPerMillion,
),
cacheMissInputCnyPerMillion: configuredRate(
"DEEPSEEK_PRICE_CACHE_MISS_INPUT_CNY_PER_MILLION",
defaults.cacheMissInputCnyPerMillion,
),
outputCnyPerMillion: configuredRate(
"DEEPSEEK_PRICE_OUTPUT_CNY_PER_MILLION",
defaults.outputCnyPerMillion,
),
version:
process.env.DEEPSEEK_PRICE_VERSION?.trim() || defaults.version,
};
}
export function createMessageUsage(
mode: "provider" | "demo",
): MessageUsage {
return {
...ZERO_TOKENS,
status: mode === "demo" ? "demo" : "unavailable",
models: [],
callCount: 0,
estimatedCostMicros: mode === "demo" ? 0 : undefined,
currency: "CNY",
};
}
export function estimateDeepSeekCallUsage(input: {
model: string;
promptTokens: number;
promptCacheHitTokens?: number;
promptCacheMissTokens?: number;
completionTokens: number;
reasoningTokens?: number;
totalTokens?: number;
}): ModelCallUsage {
const promptTokens = Math.round(finiteNonNegative(input.promptTokens));
const promptCacheHitTokens = Math.min(
promptTokens,
Math.round(finiteNonNegative(input.promptCacheHitTokens)),
);
const promptCacheMissTokens = Math.min(
promptTokens - promptCacheHitTokens,
Math.round(
input.promptCacheMissTokens === undefined
? promptTokens - promptCacheHitTokens
: finiteNonNegative(input.promptCacheMissTokens),
),
);
const completionTokens = Math.round(
finiteNonNegative(input.completionTokens),
);
const reasoningTokens = Math.min(
completionTokens,
Math.round(finiteNonNegative(input.reasoningTokens)),
);
const totalTokens = Math.round(
finiteNonNegative(
input.totalTokens ?? promptTokens + completionTokens,
),
);
const pricing = getDeepSeekPricing(input.model);
const estimatedCostMicros = pricing
? Math.round(
promptCacheHitTokens * pricing.cacheHitInputCnyPerMillion +
promptCacheMissTokens * pricing.cacheMissInputCnyPerMillion +
completionTokens * pricing.outputCnyPerMillion,
)
: undefined;
return {
model: input.model,
promptTokens,
promptCacheHitTokens,
promptCacheMissTokens,
completionTokens,
reasoningTokens,
totalTokens,
estimatedCostMicros,
currency: pricing ? "CNY" : undefined,
pricingVersion: pricing?.version,
};
}
export function addModelCallUsage(
summary: MessageUsage,
call: ModelCallUsage,
): MessageUsage {
const hasComparableCost =
call.currency === "CNY" &&
typeof call.estimatedCostMicros === "number";
const canAggregateCost =
hasComparableCost &&
(summary.callCount === 0 ||
typeof summary.estimatedCostMicros === "number");
const previousCost =
typeof summary.estimatedCostMicros === "number"
? summary.estimatedCostMicros
: 0;
return {
status: "measured",
models: summary.models.includes(call.model)
? summary.models
: [...summary.models, call.model],
callCount: summary.callCount + 1,
promptTokens: summary.promptTokens + call.promptTokens,
promptCacheHitTokens:
summary.promptCacheHitTokens + call.promptCacheHitTokens,
promptCacheMissTokens:
summary.promptCacheMissTokens + call.promptCacheMissTokens,
completionTokens:
summary.completionTokens + call.completionTokens,
reasoningTokens: summary.reasoningTokens + call.reasoningTokens,
totalTokens: summary.totalTokens + call.totalTokens,
estimatedCostMicros: canAggregateCost
? previousCost + call.estimatedCostMicros!
: undefined,
currency: "CNY",
pricingVersion:
summary.pricingVersion &&
summary.pricingVersion !== call.pricingVersion
? "mixed"
: call.pricingVersion ?? summary.pricingVersion,
};
}
export function finalizeMessageUsage(
usage: MessageUsage,
terminalStatus: "complete" | "stopped" | "error",
durationMs?: number,
): MessageUsage {
const finalizedDuration =
typeof durationMs === "number"
? Math.round(finiteNonNegative(durationMs))
: usage.durationMs;
if (usage.status === "demo") {
return { ...usage, durationMs: finalizedDuration };
}
if (usage.callCount === 0) {
return {
...usage,
status: "unavailable",
durationMs: finalizedDuration,
};
}
return {
...usage,
status: terminalStatus === "complete" ? "measured" : "partial",
durationMs: finalizedDuration,
};
}
export function parseMessageUsage(value: unknown) {
if (typeof value !== "string" || !value.trim()) return undefined;
try {
const usage = JSON.parse(value) as Partial<MessageUsage>;
if (
!["measured", "partial", "demo", "unavailable"].includes(
usage.status ?? "",
) ||
!Array.isArray(usage.models)
) {
return undefined;
}
return {
status: usage.status!,
models: usage.models.filter(
(model): model is string => typeof model === "string",
),
callCount: Math.round(finiteNonNegative(usage.callCount)),
promptTokens: Math.round(finiteNonNegative(usage.promptTokens)),
promptCacheHitTokens: Math.round(
finiteNonNegative(usage.promptCacheHitTokens),
),
promptCacheMissTokens: Math.round(
finiteNonNegative(usage.promptCacheMissTokens),
),
completionTokens: Math.round(
finiteNonNegative(usage.completionTokens),
),
reasoningTokens: Math.round(
finiteNonNegative(usage.reasoningTokens),
),
totalTokens: Math.round(finiteNonNegative(usage.totalTokens)),
durationMs:
typeof usage.durationMs === "number"
? Math.round(finiteNonNegative(usage.durationMs))
: undefined,
estimatedCostMicros:
typeof usage.estimatedCostMicros === "number"
? Math.round(finiteNonNegative(usage.estimatedCostMicros))
: undefined,
currency: "CNY" as const,
pricingVersion:
typeof usage.pricingVersion === "string"
? usage.pricingVersion
: undefined,
} satisfies MessageUsage;
} catch {
return undefined;
}
}

View File

@ -0,0 +1,95 @@
export const SKILL_CAPABILITY_BOUNDARY = `能力边界:
- Skill 只能基于当前对话中用户提供的文字、代码片段、数据和上下文进行分析、整理、改写、生成与推理;
- Skill 不能声称会执行或运行脚本、代码、命令和程序;
- Skill 不能声称会调用、请求、连接或对接外部 API、第三方服务
- Skill 不能自行联网搜索、抓取实时数据,也不能读取或修改用户本地文件;
- 如果任务依赖这些外部操作,应要求用户粘贴相关内容或运行结果,再对其进行分析;也可以提供由用户手动执行的步骤、示例代码或检查清单。`;
const unsupportedClaims = [
{
label: "运行脚本、代码或命令",
pattern:
/(?:执行|运行|启动|调用|编译|安装|部署).{0,16}(?:脚本|代码|命令|程序|shell|bash|powershell|python|node(?:\.js)?|npm)/gi,
},
{
label: "调用外部 API 或第三方服务",
pattern:
/(?:调用|请求|访问|连接|对接|集成).{0,16}(?:(?:外部|第三方|远程|在线)\s*)?(?:api|接口|服务)|通过.{0,8}(?:api|接口).{0,12}(?:获取|查询|发送|修改|创建|删除)/gi,
},
{
label: "联网搜索或抓取外部数据",
pattern:
/(?:联网|上网|浏览|搜索|抓取|爬取|下载).{0,20}(?:网页|网站|互联网|网络|在线|实时|最新|数据|资料)/gi,
},
{
label: "读取或修改本地文件和环境",
pattern:
/(?:读取|访问|扫描|修改|写入|删除).{0,16}(?:本地|用户)?(?:文件|目录|文件系统|数据库|运行环境)/gi,
},
] as const;
function isNegated(segment: string, matchIndex: number) {
const prefix = segment.slice(Math.max(0, matchIndex - 16), matchIndex);
return /(?:不|不要|不得|禁止|避免|无需|不能|不会|不可|严禁|不应|不支持|不允许).{0,10}$/.test(
prefix,
);
}
function isUserExecutedOrAdvisory(segment: string, matchIndex: number) {
const prefix = segment.slice(Math.max(0, matchIndex - 28), matchIndex);
return /(?:如何|怎么|怎样|指导用户?|教(?:会)?用户?|帮助用户|要求用户|请用户|让用户|用户手动|手动|审阅|检查|分析|评估).{0,10}$/.test(
prefix,
);
}
export function findUnsupportedSkillCapabilities(value: string) {
const violations = new Set<string>();
const segments = value
.split(/[\n。;!?]+/)
.map((segment) => segment.trim())
.filter(Boolean);
for (const segment of segments) {
for (const claim of unsupportedClaims) {
claim.pattern.lastIndex = 0;
for (
let match = claim.pattern.exec(segment);
match;
match = claim.pattern.exec(segment)
) {
if (
!isNegated(segment, match.index) &&
!isUserExecutedOrAdvisory(segment, match.index)
) {
violations.add(claim.label);
}
}
}
}
return [...violations];
}
export function getSkillCapabilityViolations(input: {
name: string;
description: string;
nodes: Array<{ title: string; content: string }>;
}) {
const fields = [
{ label: "Skill 描述", value: input.description },
...input.nodes.map((node) => ({
label: node.title,
value: node.content,
})),
];
return fields.flatMap((field) =>
findUnsupportedSkillCapabilities(field.value).map(
(violation) => `${field.label}${violation}`,
),
);
}
export function skillCapabilityErrorMessage(violations: string[]) {
return `当前工作台不支持 Skill 执行脚本、调用外部 API、联网取数或访问本地文件。请改为让用户提供相关内容或运行结果后再分析。需修改${violations.join("")}`;
}

View File

@ -0,0 +1,142 @@
export type ConversationTurn = {
role: "user" | "assistant";
content: string;
};
export type DetectedContinuation = {
state: "awaiting_input" | "offer_pending";
expectedInput: string;
reason: string;
};
export type ContinuationReplyKind =
| "accept"
| "decline"
| "answer"
| "new_topic"
| "uncertain";
function normalizeText(value: string) {
return value.replace(/\s+/g, " ").trim();
}
function requestsRequiredInput(response: string) {
return /(?:请|需要你|还需要|为了继续|开始前).{0,28}(?:提供|补充|确认|选择|告诉)|(?:缺少|尚未提供|信息不足).{0,30}[?]?|(?:请问|能否请你).*[?]/.test(
response,
);
}
function makesConcreteOffer(response: string) {
const action =
"(?:根据|为你|帮你|继续|调整|细化|制定|生成|改写|审阅|优化|补充|整理|展开|执行|开始)";
return (
new RegExp(
`(?:需要我|要不要我|是否需要我|是否要我|想让我|要我).{0,100}${action}.{0,80}(?:吗|呢)?[?]?\\s*$`,
).test(response) ||
new RegExp(
`(?:我可以|我能).{0,80}${action}.{0,60}(?:需要吗|要继续吗|可以吗|好吗)[?]?\\s*$`,
).test(response)
);
}
export function isActionableSkillContinuation(assistantResponse: string) {
return detectConversationContinuation(assistantResponse) !== null;
}
function expectedInputFromResponse(response: string) {
const lines = response
.split(/\n+/)
.map((line) => line.trim())
.filter(Boolean);
return (lines.at(-1) ?? response).slice(-240);
}
export function detectConversationContinuation(
assistantResponse: string,
): DetectedContinuation | null {
const normalized = normalizeText(assistantResponse);
if (!normalized) return null;
if (makesConcreteOffer(normalized)) {
return {
state: "offer_pending",
expectedInput: expectedInputFromResponse(assistantResponse),
reason: "assistant_made_concrete_offer",
};
}
if (requestsRequiredInput(normalized)) {
return {
state: "awaiting_input",
expectedInput: expectedInputFromResponse(assistantResponse),
reason: "assistant_requested_required_input",
};
}
return null;
}
export function classifyContinuationReply(
userMessage: string,
): ContinuationReplyKind {
const reply = normalizeText(userMessage);
if (!reply) return "uncertain";
const normalized = reply.replace(/[。!?!?]+$/g, "");
if (
/^(?:不需要|不用|不必|算了|取消|先不用|不用了|不了|否)$/.test(
normalized,
)
) {
return "decline";
}
if (
/(?:换个|另一个|另外|顺便|新问题|改问|不聊这个)|^(?:如何|怎么|为什么|什么是|介绍一下)|^(?:请|请你|帮我|麻烦你|能否).{0,12}(?:写|生成|查询|搜索|查一下|翻译|总结|分析|审阅|优化|排查)/.test(
normalized,
)
) {
return "new_topic";
}
if (
/^(?:需要|要|可以|好|好的|好啊|行|行的|继续|请继续|可以的|没问题|麻烦了|就这样|按这个来)$/.test(
normalized,
)
) {
return "accept";
}
if (
/^(?:(?:每天|每周|每月)\s*)?\d+(?:\.\d+)?\s*(?:分钟|小时|天|周|月|次|个)?|^(?:选|选择)?[A-D一二三四1234]|^(?:我的|目标|经验|时间|预算|格式|平台|语言|受众|日期|截止)/i.test(
normalized,
)
) {
return "answer";
}
return "uncertain";
}
export function isLikelySkillContinuationReply(
userMessage: string,
history: ConversationTurn[],
) {
const reply = normalizeText(userMessage);
if (!reply || reply.length > 120) return false;
const replyKind = classifyContinuationReply(reply);
if (replyKind === "decline" || replyKind === "new_topic") return false;
if (replyKind === "accept" || replyKind === "answer") return true;
const previousAssistant = [...history]
.reverse()
.find((turn) => turn.role === "assistant" && turn.content.trim());
if (!previousAssistant) return false;
const previousResponse = normalizeText(previousAssistant.content);
const wasWaitingForReply =
isActionableSkillContinuation(previousResponse) ||
/[?]\s*$/.test(previousResponse);
return wasWaitingForReply;
}

View File

@ -0,0 +1,108 @@
export type SkillQualityNodeKey =
| "trigger"
| "inputs"
| "steps"
| "output"
| "constraints";
export type SkillNodeQuality = {
complete: boolean;
missing: string[];
};
function normalizedContent(content: string) {
return content.replace(/\s+/g, " ").trim();
}
function hasOrderedSteps(content: string) {
const numberedSteps =
content.match(/(?:^|[\n;。])\s*(?:步骤\s*)?\d+[.、):]/g)?.length ?? 0;
const sequenceMarkers = ["先", "再", "然后", "接着", "最后", "最终"].filter(
(marker) => content.includes(marker),
).length;
const actionSegments = content
.split(/[\n;。]+/)
.filter((segment) =>
/(?:检查|识别|提取|分析|整理|比较|判断|生成|输出|复核|验证|标记)/.test(
segment,
),
).length;
return numberedSteps >= 2 || sequenceMarkers >= 2 || actionSegments >= 2;
}
export function evaluateSkillNodeQuality(
nodeKey: SkillQualityNodeKey,
content: string,
): SkillNodeQuality {
const normalized = normalizedContent(content);
const missing: string[] = [];
if (normalized.length < 8) {
missing.push("内容过短,尚未形成可执行规范");
return { complete: false, missing };
}
switch (nodeKey) {
case "trigger":
if (
!/(?:当|如果|遇到|用户|场景|适用|触发|不处理|不适用|请求)/.test(
normalized,
)
) {
missing.push("需要说明何时触发或适用场景");
}
break;
case "inputs":
if (
!/(?:必填|可选|输入|参数|提供|原文|材料|目标|字段|默认|缺失)/.test(
normalized,
)
) {
missing.push("需要列明执行所需的输入信息");
}
break;
case "steps":
if (!hasOrderedSteps(normalized)) {
missing.push("需要至少两个有明确顺序的执行动作");
}
break;
case "output":
if (
!/(?:输出|结果|格式|结构|标题|字段|表格|Markdown|JSON|列表|篇幅|语气|字数|包含)/i.test(
normalized,
)
) {
missing.push("需要明确结果结构、字段或格式");
}
break;
case "constraints":
if (
!/(?:不得|不能|禁止|必须|至少|至多|缺少|不足|失败|异常|验收|测试|反例|确保|待确认|质量)/.test(
normalized,
)
) {
missing.push("需要至少一条约束、质量标准或验收条件");
}
break;
}
return { complete: missing.length === 0, missing };
}
export function getSkillNodeQualityIssues(
nodes: Array<{
key: SkillQualityNodeKey;
title: string;
content: string;
}>,
) {
return nodes.flatMap((node) => {
const quality = evaluateSkillNodeQuality(node.key, node.content);
return quality.missing.map((missing) => `${node.title}${missing}`);
});
}
export function skillNodeQualityErrorMessage(issues: string[]) {
return `Skill 节点尚未达到可发布标准。需补充:${issues.join("")}`;
}

View File

@ -0,0 +1,60 @@
export type SkillRecommendationNodeKey =
| "trigger"
| "inputs"
| "steps"
| "output"
| "constraints";
export const SKILL_RECOMMENDATION_SCOPE_POLICY = `Skill 创建流程固定且只有以下五个节点,不得增加第六类内容:
1. 触发条件:只描述什么时候使用、典型任务和不适用场景;
2. 输入参数:只描述必填/可选信息、字段、默认值和缺失输入;
3. 执行步骤:只描述 AI 在当前对话内按什么顺序分析、判断、生成和复核;
4. 输出格式:只描述结果的结构、字段、格式、篇幅和语气;
5. 约束与测试:只描述禁止事项、质量标准、失败处理和验收用例。
推荐内容必须只属于 currentNode且能直接作为该节点的答案。不得推荐 Skill 名称、Skill 描述、额外模块、部署方案或其他流程。`;
const nodeLabels: Record<SkillRecommendationNodeKey, RegExp> = {
trigger: /触发条件/,
inputs: /输入参数/,
steps: /执行步骤/,
output: /输出格式/,
constraints: /约束与测试/,
};
const nodeSignals: Record<SkillRecommendationNodeKey, RegExp> = {
trigger:
/(?:当|如果|遇到|用户|场景|适用|触发|不处理|不适用|处理|任务|请求)/,
inputs:
/(?:必填|可选|输入|提供|所需|需要|字段|参数|原文|材料|目标|偏好|默认|缺失)/,
steps:
/(?:先|再|然后|最后|依次|步骤|流程|检查|识别|提取|分析|整理|生成|复核|比较|判断)/,
output:
/(?:输出|结果|格式|结构|标题|字段|表格|Markdown|JSON|列表|篇幅|语气|字数|包含)/i,
constraints:
/(?:不得|不能|禁止|必须|至少|至多|缺少|不足|失败|异常|验收|测试|确保|标记|待确认|边界|质量)/,
};
export function isSuggestionWithinNodeScope(
nodeKey: SkillRecommendationNodeKey,
suggestion: string,
) {
const normalized = suggestion.replace(/\s+/g, " ").trim();
if (!normalized) return false;
for (const [otherKey, label] of Object.entries(nodeLabels) as Array<
[SkillRecommendationNodeKey, RegExp]
>) {
if (otherKey !== nodeKey && label.test(normalized)) return false;
}
if (
/(?:Skill|技能)(?:名称|描述)|额外模块|第六个?节点|新增节点|部署方案/.test(
normalized,
)
) {
return false;
}
return nodeSignals[nodeKey].test(normalized);
}

19
src/lib/sse.ts Normal file
View File

@ -0,0 +1,19 @@
import type { SseEvent } from "@/lib/types";
const encoder = new TextEncoder();
export function encodeSse(event: SseEvent) {
return encoder.encode(`data: ${JSON.stringify(event)}\n\n`);
}
export function sseResponse(stream: ReadableStream<Uint8Array>) {
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
},
});
}

241
src/lib/types.ts Normal file
View File

@ -0,0 +1,241 @@
export const SKILL_NODE_DEFINITIONS = [
{
key: "trigger",
title: "触发条件",
shortTitle: "触发",
description: "什么时候应该使用这个 Skill以及不适用的边界。",
prompt: "先告诉我:什么情况下应该触发这个 Skill可以给一个典型任务示例。",
suggestions: [
"当用户需要把会议记录整理成行动清单时触发",
"当用户需要检查 SQL 的正确性和安全风险时触发",
"当用户需要把零散资料整理成决策备忘录时触发",
],
},
{
key: "inputs",
title: "输入参数",
shortTitle: "输入",
description: "执行所需的信息、字段、格式和默认值。",
prompt: "触发条件清楚了。执行前必须拿到哪些输入?哪些必填,哪些可以有默认值?",
suggestions: [
"必填任务原文和目标,可选受众、语气与篇幅",
"缺少必填信息时先向用户追问",
"未提供可选项时使用简洁、专业的默认风格",
],
},
{
key: "steps",
title: "执行步骤",
shortTitle: "步骤",
description: "AI 应严格遵循的行动顺序与关键判断。",
prompt: "接下来定义执行过程。请按顺序描述能在当前对话内完成的步骤,以及需要做决定的地方。",
suggestions: [
"先校验输入,再提取关键信息,然后生成结果并复核",
"遇到信息冲突时先列出差异,再选择可信来源",
"完成初稿后逐项检查是否满足用户目标",
],
},
{
key: "output",
title: "输出格式",
shortTitle: "输出",
description: "最终交付物的结构、字段、语气和示例。",
prompt: "希望最终结果长什么样?请说明结构、格式、篇幅或语气要求。",
suggestions: [
"输出使用固定的 Markdown 标题和列表结构",
"结果包含摘要、详细内容和下一步建议",
"语言保持简洁专业,正文不超过一千字",
],
},
{
key: "constraints",
title: "约束与测试",
shortTitle: "约束",
description: "禁止事项、质量门槛、失败处理和验收用例。",
prompt: "最后补齐质量边界:有哪些禁止事项、验收标准或必须通过的测试?",
suggestions: [
"不得编造缺失信息,关键输入不足时必须追问",
"只分析用户提供的内容,不运行脚本或调用外部 API",
"至少使用一个正常用例和一个失败用例进行验证",
],
},
] as const;
export type SkillNodeKey = (typeof SKILL_NODE_DEFINITIONS)[number]["key"];
export interface SkillNode {
key: SkillNodeKey;
title: string;
description: string;
content: string;
/** The node content passed AI review, even if an earlier node still blocks it. */
ready?: boolean;
completed: boolean;
updatedAt?: string;
}
export interface Skill {
id: string;
name: string;
description: string;
status: "draft" | "active";
nodes: SkillNode[];
createdAt: string;
updatedAt: string;
}
export interface TokenUsage {
promptTokens: number;
promptCacheHitTokens: number;
promptCacheMissTokens: number;
completionTokens: number;
reasoningTokens: number;
totalTokens: number;
}
export interface ModelCallUsage extends TokenUsage {
model: string;
estimatedCostMicros?: number;
currency?: "CNY";
pricingVersion?: string;
}
export interface MessageUsage extends TokenUsage {
status: "measured" | "partial" | "demo" | "unavailable";
models: string[];
callCount: number;
/** End-to-end server time from accepting the turn to its terminal event. */
durationMs?: number;
estimatedCostMicros?: number;
currency: "CNY";
pricingVersion?: string;
}
export interface ChatMessage {
id: string;
role: "user" | "assistant";
content: string;
selectedSkillIds: string[];
usedSkillIds: string[];
status: "complete" | "streaming" | "stopped" | "error";
errorMessage?: string;
usage?: MessageUsage;
createdAt: string;
}
export interface Conversation {
id: string;
title: string;
createdAt: string;
updatedAt: string;
}
export type ConversationContinuationState =
| "complete"
| "awaiting_input"
| "offer_pending";
export type ConversationContinuationSource =
| "none"
| "deterministic"
| "ai"
| "fallback";
export interface ConversationContinuation {
state: ConversationContinuationState;
skillIds: string[];
expectedInput: string;
reason: string;
source: ConversationContinuationSource;
updatedAt?: string;
}
export interface BuilderMessage {
id: string;
role: "user" | "assistant";
content: string;
nodeKey: SkillNodeKey;
updatedNodeKeys?: SkillNodeKey[];
createdAt: string;
}
export interface BuilderEvaluation {
reply: string;
intent: "provide_spec" | "request_guidance";
skillName: string;
skillDescription: string;
activeNode: SkillNodeKey;
updatedNodeKeys: SkillNodeKey[];
suggestionNodeKey: SkillNodeKey;
nodeQuality: Array<{
key: SkillNodeKey;
passed: boolean;
missing: string[];
}>;
suggestions: string[];
nodes: SkillNode[];
}
export type BuilderSuggestionSource =
| "idle"
| "loading"
| "ai"
| "demo"
| "fallback";
export type ChatRunStatus =
| "pending"
| "running"
| "complete"
| "stopped"
| "error";
export interface ChatRun {
id: string;
conversationId: string;
userMessageId: string;
assistantMessageId: string;
status: ChatRunStatus;
createdAt: string;
updatedAt: string;
}
type SequencedEvent = { seq?: number };
export type SseEvent = SequencedEvent &
(
| {
type: "meta";
runId?: string;
userMessageId: string;
assistantMessageId: string;
}
| {
type: "skill_usage";
skillIds: string[];
source:
| "no_candidates"
| "explicit_explanation"
| "continuation"
| "continuation_declined"
| "ai"
| "keyword_fallback";
reason: string;
}
| {
type: "skill_retention";
state: ConversationContinuationState;
skillIds: string[];
expectedInput: string;
reason: string;
source: ConversationContinuationSource;
}
| { type: "status"; label: string }
| { type: "token"; token: string }
| { type: "usage"; usage: MessageUsage }
| { type: "builder_update"; evaluation: BuilderEvaluation }
| { type: "conversation_title"; title: string }
| { type: "complete"; messageId?: string }
| { type: "aborted" }
| { type: "error"; message: string }
);

45
src/lib/utils.ts Normal file
View File

@ -0,0 +1,45 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function createId(prefix: string) {
return `${prefix}_${crypto.randomUUID().replaceAll("-", "")}`;
}
export function safeJsonArray(value: unknown): string[] {
if (Array.isArray(value)) {
return value.filter((item): item is string => typeof item === "string");
}
if (typeof value !== "string" || !value) return [];
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed)
? parsed.filter((item): item is string => typeof item === "string")
: [];
} catch {
return [];
}
}
export function formatRelativeTime(value: string) {
const date = new Date(value);
const diff = Date.now() - date.getTime();
const minutes = Math.floor(diff / 60_000);
if (minutes < 1) return "刚刚";
if (minutes < 60) return `${minutes} 分钟前`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours} 小时前`;
return new Intl.DateTimeFormat("zh-CN", {
month: "short",
day: "numeric",
}).format(date);
}

143
src/lib/validation.ts Normal file
View File

@ -0,0 +1,143 @@
import { z } from "zod";
import { SKILL_NODE_DEFINITIONS } from "@/lib/types";
const nodeKeys = SKILL_NODE_DEFINITIONS.map((node) => node.key) as [
"trigger",
"inputs",
"steps",
"output",
"constraints",
];
export const skillNodeSchema = z.object({
key: z.enum(nodeKeys),
title: z.string(),
description: z.string(),
content: z.string(),
ready: z.boolean().optional(),
completed: z.boolean(),
updatedAt: z.string().optional(),
});
const skillNodesSchema = z
.array(skillNodeSchema)
.length(SKILL_NODE_DEFINITIONS.length)
.superRefine((nodes, context) => {
const actualKeys = nodes.map((node) => node.key);
const uniqueKeys = new Set(actualKeys);
const missingKeys = nodeKeys.filter((key) => !uniqueKeys.has(key));
if (
uniqueKeys.size !== SKILL_NODE_DEFINITIONS.length ||
missingKeys.length > 0
) {
context.addIssue({
code: "custom",
message:
"Skill 必须且只能包含触发条件、输入参数、执行步骤、输出格式、约束与测试各一个节点",
});
}
});
export const saveSkillSchema = z.object({
id: z.string().min(1).optional(),
name: z.string().trim().min(2).max(40),
description: z.string().trim().min(2).max(180),
status: z.enum(["draft", "active"]).optional(),
nodes: skillNodesSchema,
});
export const chatRequestSchema = z.object({
conversationId: z.string().min(1),
message: z.string().trim().min(1).max(12_000),
selectedSkillIds: z.array(z.string()).max(12).default([]),
clientRequestId: z.string().min(8).max(100),
});
export const builderRequestSchema = z.object({
clientRequestId: z.string().min(8).max(100),
afterSeq: z.number().int().min(0).default(0),
skillId: z.string().nullable().optional(),
message: z.string().trim().min(1).max(8_000),
skillName: z.string().max(40).default("未命名 Skill"),
skillDescription: z.string().max(180).default(""),
nodes: skillNodesSchema,
messages: z
.array(
z.object({
role: z.enum(["user", "assistant"]),
content: z.string().max(8_000),
}),
)
.max(30),
});
export const skillRouterResultSchema = z
.object({
usedSkillIds: z.array(z.string()).max(12),
reason: z.string().trim().min(1).max(400),
})
.strict();
export const skillContinuationResultSchema = z
.object({
state: z.enum(["awaiting_input", "offer_pending", "complete"]),
keepSkillIds: z.array(z.string()).max(12),
expectedInput: z.string().trim().max(240),
reason: z.string().trim().min(1).max(400),
})
.strict();
export const conversationTitleResultSchema = z
.object({
title: z.string().trim().min(2).max(30),
})
.strict();
export const builderModelResultSchema = z
.object({
intent: z.enum(["provide_spec", "request_guidance"]),
skillName: z.string().min(2).max(40),
skillDescription: z.string().min(2).max(180),
updates: z
.array(
z
.object({
nodeKey: z.enum(nodeKeys),
content: z.string().min(1).max(8_000),
completed: z.boolean(),
})
.strict(),
)
.max(nodeKeys.length),
suggestions: z
.array(z.string().trim().min(2).max(80))
.min(2)
.max(4),
suggestionNodeKey: z.enum(nodeKeys),
reply: z.string().min(1).max(2_000),
})
.strict();
export const builderSuggestionsRequestSchema = z
.object({
skillName: z.string().trim().max(40).default("未命名 Skill"),
skillDescription: z.string().trim().max(180).default(""),
nodes: skillNodesSchema,
})
.strict();
export const builderSuggestionsResultSchema = z
.object({
nodeKey: z.enum(nodeKeys),
suggestions: z
.array(z.string().trim().min(2).max(80))
.min(2)
.max(4),
})
.strict();
export const renameConversationSchema = z
.object({
title: z.string().trim().min(1).max(40),
})
.strict();

34
tsconfig.json Normal file
View File

@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}