demo
This commit is contained in:
13
.env.example
Normal file
13
.env.example
Normal 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
47
.gitignore
vendored
Normal 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
5
AGENTS.md
Normal 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 -->
|
||||||
105
README.md
Normal file
105
README.md
Normal 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
21
components.json
Normal 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
18
eslint.config.mjs
Normal 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
7
next.config.ts
Normal 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
10522
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
53
package.json
Normal file
53
package.json
Normal 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
7086
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
5
pnpm-workspace.yaml
Normal file
5
pnpm-workspace.yaml
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
allowBuilds:
|
||||||
|
sharp: true
|
||||||
|
unrs-resolver: true
|
||||||
|
minimumReleaseAgeExclude:
|
||||||
|
- '@types/node@26.1.2'
|
||||||
7
postcss.config.mjs
Normal file
7
postcss.config.mjs
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
1
public/file.svg
Normal file
1
public/file.svg
Normal 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
1
public/globe.svg
Normal 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
1
public/next.svg
Normal 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
1
public/vercel.svg
Normal 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
1
public/window.svg
Normal 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
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
120
src/app/api/chat/route.ts
Normal 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, "无法发送消息");
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/app/api/chat/runs/[id]/stop/route.ts
Normal file
22
src/app/api/chat/runs/[id]/stop/route.ts
Normal 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);
|
||||||
|
}
|
||||||
51
src/app/api/chat/runs/[id]/stream/route.ts
Normal file
51
src/app/api/chat/runs/[id]/stream/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
|
||||||
32
src/app/api/conversation/route.ts
Normal file
32
src/app/api/conversation/route.ts
Normal 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, "无法重置会话");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
96
src/app/api/skills/[id]/route.ts
Normal file
96
src/app/api/skills/[id]/route.ts
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/app/api/skills/builder/[id]/stop/route.ts
Normal file
15
src/app/api/skills/builder/[id]/stop/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
|
||||||
60
src/app/api/skills/builder/route.ts
Normal file
60
src/app/api/skills/builder/route.ts
Normal 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 对话");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
15
src/app/api/skills/builder/suggestions/route.ts
Normal file
15
src/app/api/skills/builder/suggestions/route.ts
Normal 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, "无法生成节点建议");
|
||||||
|
}
|
||||||
|
}
|
||||||
58
src/app/api/skills/route.ts
Normal file
58
src/app/api/skills/route.ts
Normal 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
BIN
src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
277
src/app/globals.css
Normal file
277
src/app/globals.css
Normal 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
27
src/app/layout.tsx
Normal 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
6
src/app/page.tsx
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { ChatWorkspace } from "@/components/chat-workspace";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return <ChatWorkspace />;
|
||||||
|
}
|
||||||
|
|
||||||
9
src/app/skills/[id]/page.tsx
Normal file
9
src/app/skills/[id]/page.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
|
|
||||||
6
src/app/skills/new/page.tsx
Normal file
6
src/app/skills/new/page.tsx
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { SkillBuilder } from "@/components/skill-builder";
|
||||||
|
|
||||||
|
export default function NewSkillPage() {
|
||||||
|
return <SkillBuilder />;
|
||||||
|
}
|
||||||
|
|
||||||
293
src/components/assistant-builder-chat.tsx
Normal file
293
src/components/assistant-builder-chat.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
635
src/components/assistant-chat.tsx
Normal file
635
src/components/assistant-chat.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
src/components/brand-mark.tsx
Normal file
30
src/components/brand-mark.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
44
src/components/builder-composer.tsx
Normal file
44
src/components/builder-composer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
335
src/components/chat-composer.tsx
Normal file
335
src/components/chat-composer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
src/components/chat-error-notice.tsx
Normal file
49
src/components/chat-error-notice.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
548
src/components/chat-workspace.tsx
Normal file
548
src/components/chat-workspace.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
196
src/components/conversation-actions.tsx
Normal file
196
src/components/conversation-actions.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
609
src/components/skill-builder.tsx
Normal file
609
src/components/skill-builder.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
421
src/components/skill-picker.tsx
Normal file
421
src/components/skill-picker.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
src/components/skill-progress.tsx
Normal file
84
src/components/skill-progress.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
100
src/components/skill-tag.tsx
Normal file
100
src/components/skill-tag.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
310
src/components/streaming-markdown.tsx
Normal file
310
src/components/streaming-markdown.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
612
src/components/tool-fallback.tsx
Normal file
612
src/components/tool-fallback.tsx
Normal 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,
|
||||||
|
};
|
||||||
196
src/components/ui/alert-dialog.tsx
Normal file
196
src/components/ui/alert-dialog.tsx
Normal 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,
|
||||||
|
}
|
||||||
64
src/components/ui/button.tsx
Normal file
64
src/components/ui/button.tsx
Normal 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 }
|
||||||
33
src/components/ui/collapsible.tsx
Normal file
33
src/components/ui/collapsible.tsx
Normal 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 }
|
||||||
158
src/components/ui/dialog.tsx
Normal file
158
src/components/ui/dialog.tsx
Normal 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,
|
||||||
|
}
|
||||||
21
src/components/ui/input.tsx
Normal file
21
src/components/ui/input.tsx
Normal 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 }
|
||||||
89
src/components/ui/popover.tsx
Normal file
89
src/components/ui/popover.tsx
Normal 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,
|
||||||
|
}
|
||||||
121
src/components/use-smooth-follow.ts
Normal file
121
src/components/use-smooth-follow.ts
Normal 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
17
src/lib/api.ts
Normal 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
129
src/lib/builder-runs.ts
Normal 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
530
src/lib/chat-runs.ts
Normal 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
38
src/lib/client-sse.ts
Normal 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
1096
src/lib/db.ts
Normal file
File diff suppressed because it is too large
Load Diff
1264
src/lib/deepseek.ts
Normal file
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
261
src/lib/model-usage.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
95
src/lib/skill-capabilities.ts
Normal file
95
src/lib/skill-capabilities.ts
Normal 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(";")}`;
|
||||||
|
}
|
||||||
142
src/lib/skill-continuation.ts
Normal file
142
src/lib/skill-continuation.ts
Normal 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;
|
||||||
|
}
|
||||||
108
src/lib/skill-node-quality.ts
Normal file
108
src/lib/skill-node-quality.ts
Normal 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(";")}`;
|
||||||
|
}
|
||||||
60
src/lib/skill-recommendation-scope.ts
Normal file
60
src/lib/skill-recommendation-scope.ts
Normal 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
19
src/lib/sse.ts
Normal 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
241
src/lib/types.ts
Normal 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
45
src/lib/utils.ts
Normal 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
143
src/lib/validation.ts
Normal 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
34
tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user