commit 23717e0ecdc471ebba2b1e2a8e4f2f773c9b7087 Author: Cloud Bot Date: Fri Mar 20 07:33:46 2026 +0000 初始化模版工程 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1ef7535 --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +DATABASE_URL=postgres://postgres:postgres@localhost:5432/vibe_next_template +LOG_DIR=./vibe-next-template + +NOVA_BASE_URL=https://dev-nova-api.betteryeah.com +NOVA_TENANT_ID=tenant_xxx +NOVA_ACCESS_KEY=access_key_xxx + +LLM_BASE_URL=https://dev-llm-server-internal.betteryeah.com +LLM_API_KEY=sk-skills-default-dev-key \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..70fd0d4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,50 @@ +# 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.example + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# vscode +.vscode/* +.idea/* + +# Remote Control 运行时数据 +/remote-control/data/ +!/remote-control/data/.gitkeep + diff --git a/.nova/config.json b/.nova/config.json new file mode 100644 index 0000000..04040fb --- /dev/null +++ b/.nova/config.json @@ -0,0 +1,9 @@ +{ + "agents": [ + { + "agent_id": "d730c266fe6748839d9c93ece8e58b84", + "agent_name": "Agent", + "agent_description": "" + } + ] +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..eaae9df --- /dev/null +++ b/Dockerfile @@ -0,0 +1,34 @@ +FROM node:22-slim AS builder + +WORKDIR /app +ENV CI=true + +COPY .npmrc /root/.npmrc +RUN corepack enable && corepack prepare pnpm@latest --activate + +COPY . . + +RUN pnpm install --frozen-lockfile && pnpm run build && (test -d public || mkdir public) + +FROM node:22-slim AS runner + +WORKDIR /app + +ENV NODE_ENV=production +ENV CI=true + +COPY .npmrc /root/.npmrc +RUN corepack enable && corepack prepare pnpm@latest --activate + +COPY --from=builder /app/package.json ./ +COPY --from=builder /app/pnpm-lock.yaml ./ +COPY --from=builder /app/.next ./.next +COPY --from=builder /app/.nova ./.nova +COPY --from=builder /app/.env ./.env +COPY --from=builder /app/public* ./public + +RUN pnpm install --frozen-lockfile --prod + +EXPOSE 13000 + +CMD ["pnpm", "start"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..51e3a47 --- /dev/null +++ b/README.md @@ -0,0 +1,213 @@ +# vibe-next-template + +Nova Agent 示例模板工程。[Nova](https://nova.betteryeah.com) 是一个创建 Agent 的 SaaS 产品,可以通过 Nova 创建能够操作沙箱、浏览器、文件系统的专业 Agent。本项目基于 Next.js 提供完整的 Agent 聊天前端,包含消息流、事件列表、会话管理与文件上传能力。 + +项目中 `.nova/config.json` 内置了一个示例 Agent,如需基于本模板开发自己的应用,请在 Nova 平台创建 Agent 并替换配置。 + +## 技术栈 + +- Next.js 16(App Router) / React 19 / TypeScript 5 +- Tailwind CSS 4 / Radix UI / Lucide React Icons +- Zustand(状态管理) +- 自定义 WebSocket 客户端(实时通信) +- 自定义 HTTPClient(Fetch 封装,支持拦截器) +- Drizzle ORM + PostgreSQL +- Winston(日志,生产环境按天轮转) +- Shiki(代码高亮)/ React Markdown + Remark GFM + +## 快速开始 + +1. 安装依赖:`pnpm install` +2. `.env`环境变量文件已经初始化完成了,所以设计环境变量的需要在这里修改 +3. (可选)修改 `.nova/config.json`,替换为你自己的 Agent +4. 启动开发服务器:`pnpm dev`(端口 13000) + +## Agent 配置 + +Agent 配置存放在 `.nova/config.json`: + +```json +{ + "agents": [ + { + "agent_id": "70da765f2d42490ca574d72dce4d24fe", + "agent_name": "Nova示例Agent", + "agent_description": "一个Nova的Agent示例,包含Agent具备的通用功能,文件操作、沙箱环境执行、浏览器操作等等。" + } + ] +} +``` + +- `agents` 数组中的第一个 Agent 作为默认使用的 Agent +- 在 Nova 平台创建新 Agent 后,将 `agent_id`、`agent_name`、`agent_description` 替换为新 Agent 的信息 +- 配置通过 `app/api/nova-config.ts` 的 `getDefaultAgentId()` 在服务端读取,启动后缓存 + +## 目录结构 + +``` +.nova/ + config.json # Agent 配置(agent_id, agent_name, agent_description) + +app/ + layout.tsx # 根布局(Geist Sans/Mono 字体) + page.tsx # 主入口,初始化聊天连接 + globals.css # 全局样式 + api/ # Next.js API 路由(BFF 层,代理 Nova 平台 API) + oapi-client.ts # 共享的 Nova OpenAPI HTTPClient 实例 + nova-config.ts # 读取 .nova/config.json,提供 getDefaultAgentId() + info/route.ts # 返回客户端配置:agentId, conversationId, wssUrl, token + chat/event/route.ts # 代理获取聊天事件历史 + chat/stop/route.ts # 停止当前任务 + conversation/route.ts # 会话列表 + file/upload/route.ts # 文件上传 + file/sign/route.ts # 文件签名 URL + health/route.ts # 健康检查 + +components/ + nova-sdk/ # 核心聊天 SDK + types.ts # 核心类型定义(ApiEvent, PlatformConfig, TaskArtifact 等) + websocket.ts # WebSocket 客户端(自动重连、心跳、离线队列) + store/ + useNovaStore.ts # Zustand 全局状态(events, artifacts, ws 状态, loading) + context/ + context.ts # NovaContext 定义(HTTPClient + getArtifactUrl) + Provider.tsx # Context Provider + useNova.ts # useContext hook + hooks/ + useNovaChatLogic.ts # 主编排 hook,组合所有子 hook + useNovaEvents.ts # WebSocket + 历史事件获取 + useNovaService.ts # 从 platformConfig 创建 HTTPClient + useMessageSender.ts # 通过 WebSocket 发送消息 + useEventProcessor.ts # ApiEvent → UI 消息转换 + useBuildConversationConnect.ts # 初始化:获取 agentId、conversationId + useFileUploader.ts # 文件上传逻辑 + useAttachmentHandlers.ts # 附件点击处理 + useMessageScroll.ts # 消息列表自动滚动 + usePanelState.ts # 产物面板开关 + useSize.ts # 响应式尺寸 + nova-chat/ # 聊天主组件 + index.tsx # NovaChat(包裹 Provider、Header、MessageList、Input、TaskPanel) + ChatHeader.tsx # 聊天头部 + ChatInputArea.tsx # 输入区域 + message-list/ # 消息列表 + index.tsx # MessageList + MessageItem.tsx # 单条消息 + AttachmentItem.tsx # 文件附件 + ImageAttachmentItem.tsx # 图片附件 + ToolCallAction.tsx # 工具调用展示 + message-input/ # 消息输入 + index.tsx # 输入组件 + FilePreviewList.tsx # 文件预览列表 + task-panel/ # 产物预览面板 + index.tsx # TaskPanel(侧边栏,50% 宽度) + ArtifactList.tsx # 产物列表 + ArtifactPreview.tsx # 产物预览 + Preview/ # 各类预览组件(Markdown、代码、PPT、工具调用) + ui/ # 基础 UI 组件(Shadcn 风格) + button.tsx, dialog.tsx, scroll-area.tsx, image.tsx, image-preview.tsx + +http/ + index.ts # HTTPClient 类(Fetch 封装) + http.ts # 底层 fetch 包装,支持 onRequest/onResponse/onError 钩子 + type.ts # HTTP 类型定义 + +db/ + index.ts # Drizzle ORM 连接(server-only,全局单例) + +utils/ + logger.ts # Winston 日志(开发→终端,生产→按天文件轮转) + cn.ts # clsx + tailwind-merge 工具函数 +``` + +## 数据流 + +``` +app/page.tsx + │ useBuildConversationConnect() → GET /api/info + │ 获取 agentId(来自 .nova/config.json), conversationId, platformConfig + ▼ +NovaChat 组件 + │ useNovaChatLogic() 编排所有子 hook + ├── useNovaEvents() → WebSocket 连接 + GET /api/chat/event 加载历史 + ├── useMessageSender() → WebSocket 发送消息 + └── useEventProcessor()→ 事件转 UI 消息 + ▼ +Zustand Store (useNovaStore) + │ events[], artifacts[](从 events 自动提取), loading, ws 状态 + ▼ +MessageList / TaskPanel 渲染 +``` + +## API 路由 + +| 路由 | 方法 | 说明 | +|------|------|------| +| `/api/info` | GET | 返回客户端配置:agentId, conversationId, wssUrl, token | +| `/api/chat/event` | GET | 代理 Nova `/chat/event_list`,获取事件历史 | +| `/api/chat/stop` | POST | 停止当前任务 | +| `/api/conversation` | GET | 会话列表 | +| `/api/file/upload` | POST | 上传文件 | +| `/api/file/sign` | POST | 获取文件签名 URL | +| `/api/health` | GET | 健康检查 | + +所有 API 路由通过 `app/api/oapi-client.ts` 中的共享 HTTPClient 代理到 Nova 平台,请求头自动注入 `Tenant-Id` 和 `Authorization`。Agent ID 从 `.nova/config.json` 读取,不再依赖环境变量。 + +## WebSocket 消息格式 + +- 心跳:`{ message_type: 'ping' }` +- 聊天消息:`{ message_type: 'chat', conversation_id, content, ... }` +- 切换会话:`{ message_type: 'switch_conversation', conversation_id }` + +WebSocket 客户端内置自动重连(可配置次数/间隔)、心跳检测、网络状态监听、页面可见性恢复。 + +## 常用命令 + +```bash +pnpm install # 安装依赖 +pnpm dev # 启动开发服务器(端口 13000) +pnpm build # 生产构建 +pnpm start # 生产运行(端口 13000) +pnpm lint # ESLint 检查并自动修复 +pnpm db:generate # 生成 Drizzle 迁移 +pnpm db:migrate # 执行数据库迁移 +pnpm db:studio # 打开 Drizzle Studio GUI +``` + +## 环境变量 + +参考 `.env.example`,在项目根目录创建 `.env.local`: + +| 变量名 | 说明 | +|--------|------| +| `DATABASE_URL` | PostgreSQL 连接地址(Drizzle 使用) | +| `LOG_DIR` | 生产环境日志目录(默认 `./logs`,按天切分) | +| `NOVA_BASE_URL` | Nova OpenAPI 服务地址 | +| `NOVA_TENANT_ID` | 租户 ID(请求头 `Tenant-Id`) | +| `NOVA_ACCESS_KEY` | 鉴权密钥(请求头 `Authorization`) | + +> Agent ID 已从环境变量迁移至 `.nova/config.json`,无需在 `.env.local` 中配置。 + +## 代码约定 + +- **路径别名**:`@/*` → `./*`(tsconfig paths) +- **组件**:PascalCase 文件名,使用 `React.memo()` 优化 +- **Hooks**:camelCase,`use*` 前缀 +- **工具函数**:camelCase +- **常量枚举**:UPPER_SNAKE_CASE(如 `EventType`, `TaskStatus`) +- **样式**:Tailwind CSS 工具类,通过 `cn()` 函数(`utils/cn.ts`)合并类名 +- **回调稳定性**:使用 ahooks 的 `useMemoizedFn()` 或 `useCallback()` +- **异步状态**:`queueMicrotask()` 延迟更新,避免 React 批量更新冲突 +- **Store 去重**:Zustand store 按 `event_id` 去重事件 +- **日志**:开发环境输出到终端,生产环境按天写入 `${LOG_DIR}/app-YYYY-MM-DD.log` +- **数据库**:所有操作通过 Drizzle ORM,`import { db } from '@/db'`,仅限 server 端 +- **UI 错误提示**:使用中文(如 "Chat 不可用,请检查项目 .env 配置") +- **请求头约定**:`X-Locale=zh`, `X-Region=CN` + +## 注意事项 + +- `next.config.ts` 中 `reactStrictMode: false` +- `.next/` 目录为构建产物,不要手动修改 +- 数据库连接使用全局单例模式,`db/index.ts` 标记了 `server-only` +- 聊天初始化流程:先请求 `/api/info` 获取配置 → 再建立 WebSocket → 加载历史事件 +- 产物提取来源:`attachments`, `attachment_files`, `files`, `generated_files`,按文件 key 去重 +- 文件签名通过 `getArtifactUrl()` 懒加载,POST `/file/sign`,失败时 fallback 到原始路径 diff --git a/app/RouteChange.tsx b/app/RouteChange.tsx new file mode 100644 index 0000000..b905dfa --- /dev/null +++ b/app/RouteChange.tsx @@ -0,0 +1,20 @@ +// components/RouterListener.tsx +"use client"; + +import { useEffect } from "react"; +import { usePathname, useSearchParams } from "next/navigation"; + +export default function RouteChange() { + const pathname = usePathname(); + const searchParams = useSearchParams(); + + useEffect(() => { + const url = searchParams.toString() + ? `${pathname}?${searchParams.toString()}` + : pathname; + + window.parent.postMessage({ type: "ROUTE_CHANGE", path: url }, "*"); + }, [pathname, searchParams]); + + return null; +} diff --git a/app/api/chat/event/route.ts b/app/api/chat/event/route.ts new file mode 100644 index 0000000..68fc9fc --- /dev/null +++ b/app/api/chat/event/route.ts @@ -0,0 +1,18 @@ +import { NextRequest } from 'next/server' +import { oapiClient, sendResponse } from '../../oapi-client' +import { getDefaultAgentId } from '../../nova-config' + +export async function GET(req: NextRequest) { + const conversationId = req.nextUrl.searchParams.get('conversation_id') + const pageNo = req.nextUrl.searchParams.get('page_no') + const pageSize = req.nextUrl.searchParams.get('page_size') + + const res = await oapiClient.get('/v1/oapi/super_agent/chat/event_list', { + agent_id: getDefaultAgentId(), + conversation_id: conversationId, + page_no: pageNo, + page_size: pageSize, + }) + + return sendResponse(res) +} diff --git a/app/api/chat/oss_url/route.ts b/app/api/chat/oss_url/route.ts new file mode 100644 index 0000000..b43e010 --- /dev/null +++ b/app/api/chat/oss_url/route.ts @@ -0,0 +1,14 @@ +import { NextRequest } from 'next/server' +import { oapiClient, sendResponse } from '../../oapi-client' + +export async function POST(req: NextRequest) { + const body = req.body ? await req.json() : {} + const { file_path, task_id } = body + + const res = await oapiClient.post('/v1/super_agent/chat/oss_url', { + file_path: file_path, + task_id: task_id, + }) + + return sendResponse(res) +} diff --git a/app/api/chat/stop/route.ts b/app/api/chat/stop/route.ts new file mode 100644 index 0000000..f153f65 --- /dev/null +++ b/app/api/chat/stop/route.ts @@ -0,0 +1,11 @@ +import { NextRequest } from 'next/server' +import { oapiClient, sendResponse } from '../../oapi-client' + +export async function GET(req: NextRequest) { + const conversationId = req.nextUrl.searchParams.get('conversation_id') + const res = await oapiClient.post('/v1/oapi/super_agent/stop_chat', { + conversation_id: conversationId, + }) + + return sendResponse(res) +} diff --git a/app/api/conversation/info/route.ts b/app/api/conversation/info/route.ts new file mode 100644 index 0000000..7464739 --- /dev/null +++ b/app/api/conversation/info/route.ts @@ -0,0 +1,7 @@ +import { oapiClient, sendResponse } from '../../oapi-client' + +export async function POST(req: Request) { + const body = req.body ? await req.json() : {} + const res = await oapiClient.post('/v1/super_agent/chat/get_conversation_info_list', body) + return sendResponse(res) +} diff --git a/app/api/conversation/route.ts b/app/api/conversation/route.ts new file mode 100644 index 0000000..838ff4d --- /dev/null +++ b/app/api/conversation/route.ts @@ -0,0 +1,12 @@ +import { oapiClient, sendResponse } from '../oapi-client' +import { getDefaultAgentId } from '../nova-config' + +export async function GET() { + const res = await oapiClient.get('/v1/oapi/super_agent/chat/conversation_list', { + page_no: 1, + page_size: 10, + agent_id: getDefaultAgentId(), + }) + + return sendResponse(res) +} diff --git a/app/api/file/record/route.ts b/app/api/file/record/route.ts new file mode 100644 index 0000000..7b09d69 --- /dev/null +++ b/app/api/file/record/route.ts @@ -0,0 +1,7 @@ +import { oapiClient, sendResponse } from '../../oapi-client' + +export async function POST(req: Request) { + const body = req.body ? await req.json() : {} + const res = await oapiClient.post('/v1/super_agent/file_upload_record/create', body) + return sendResponse(res) +} diff --git a/app/api/file/sign/route.ts b/app/api/file/sign/route.ts new file mode 100644 index 0000000..f62dc5d --- /dev/null +++ b/app/api/file/sign/route.ts @@ -0,0 +1,12 @@ +import { oapiClient, sendResponse } from '../../oapi-client' + +export async function POST(req: Request) { + const body = req.body ? await req.json() : {} + const key = body.file_path || body.key + const res = await oapiClient.post('v1/oss/sign_url', { + key, + params: body.params, + }) + + return sendResponse(res) +} diff --git a/app/api/file/upload/route.ts b/app/api/file/upload/route.ts new file mode 100644 index 0000000..432bfda --- /dev/null +++ b/app/api/file/upload/route.ts @@ -0,0 +1,8 @@ +import { oapiClient, sendResponse } from '../../oapi-client' + +export async function POST(req: Request) { + const body = await req.formData() + const res = await oapiClient.post('/v1/oapi/super_agent/chat/file_upload', body) + + return sendResponse(res) +} diff --git a/app/api/health/route.ts b/app/api/health/route.ts new file mode 100644 index 0000000..1a9b9e5 --- /dev/null +++ b/app/api/health/route.ts @@ -0,0 +1,24 @@ +import { NextResponse } from 'next/server' + +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET,OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', +} + +export async function GET() { + return NextResponse.json( + { success: true, message: 'ok' }, + { + status: 200, + headers: corsHeaders, + } + ) +} + +export async function OPTIONS() { + return new NextResponse(null, { + status: 204, + headers: corsHeaders, + }) +} diff --git a/app/api/info/route.ts b/app/api/info/route.ts new file mode 100644 index 0000000..a1577be --- /dev/null +++ b/app/api/info/route.ts @@ -0,0 +1,68 @@ +import { NextResponse } from 'next/server' +import { oapiClient } from '../oapi-client' +import { getDefaultAgentId, getDefaultAgentName } from '../nova-config' +import { getProjectId, getUserId } from '@/utils/getAuth' + +const buildWssUrl = () => { + const baseUrl = process.env.NOVA_BASE_URL! + const wssBase = baseUrl.replace('https://', 'wss://').replace('http://', 'ws://') + const authorization = process.env.NOVA_ACCESS_KEY + const tenantId = process.env.NOVA_TENANT_ID + return `${wssBase}/v1/super_agent/chat/completions?Authorization=${authorization}&X-Locale=zh&X-Region=CN&Tenant-Id=${tenantId}` +} + +export async function GET() { + const agentId = getDefaultAgentId() + const agentName = getDefaultAgentName() + + const list = await oapiClient.get('/v1/oapi/super_agent/chat/conversation_list', { + page_no: 1, + page_size: 10, + agent_id: agentId, + }) + + const conversationId = list.data?.[0]?.conversation_id + + if (!conversationId) { + const res = await oapiClient.post('/v1/oapi/super_agent/chat/create_conversation', { + agent_id: agentId, + title: 'new conversation', + conversation_type: 'REACTUS', + external_app_id: getProjectId(), + external_user_id: getUserId(), + }) + + return NextResponse.json( + { + code: 0, + message: 'ok', + data: { + apiBaseUrl: '/api', + agent_id: agentId, + agent_name: agentName, + conversation_id: res.conversation_id, + wssUrl: buildWssUrl(), + token: process.env.NOVA_ACCESS_KEY, + tenantId: process.env.NOVA_TENANT_ID, + }, + }, + { status: 200 } + ) + } + + return NextResponse.json( + { + success: true, + data: { + apiBaseUrl: '/api', + agent_id: agentId, + agent_name: agentName, + conversation_id: conversationId, + wssUrl: buildWssUrl(), + token: process.env.NOVA_ACCESS_KEY, + tenantId: process.env.NOVA_TENANT_ID, + }, + }, + { status: 200 } + ) +} diff --git a/app/api/llm-client.ts b/app/api/llm-client.ts new file mode 100644 index 0000000..1e941ce --- /dev/null +++ b/app/api/llm-client.ts @@ -0,0 +1,6 @@ +import { SkillsClient } from '@/llm' + +export const llmClient = new SkillsClient({ + apiKey: process.env.LLM_API_KEY!, + baseUrl: process.env.LLM_BASE_URL!, +}) \ No newline at end of file diff --git a/app/api/nova-config.ts b/app/api/nova-config.ts new file mode 100644 index 0000000..9de0719 --- /dev/null +++ b/app/api/nova-config.ts @@ -0,0 +1,38 @@ +import { readFileSync } from 'fs' +import { join } from 'path' + +interface NovaAgent { + agent_id: string + agent_name: string + agent_description: string +} + +interface NovaConfig { + agents: NovaAgent[] +} + +let _config: NovaConfig | null = null + +export function getNovaConfig(): NovaConfig { + if (!_config) { + const configPath = join(process.cwd(), '.nova', 'config.json') + _config = JSON.parse(readFileSync(configPath, 'utf-8')) + } + return _config! +} + +export function getDefaultAgentId(): string { + const config = getNovaConfig() + if (!config.agents.length) { + throw new Error('No agents configured in .nova/config.json') + } + return config.agents[0].agent_id +} + +export function getDefaultAgentName(): string { + const config = getNovaConfig() + if (!config.agents.length) { + throw new Error('No agents configured in .nova/config.json') + } + return config.agents[0].agent_name +} \ No newline at end of file diff --git a/app/api/oapi-client.ts b/app/api/oapi-client.ts new file mode 100644 index 0000000..f704948 --- /dev/null +++ b/app/api/oapi-client.ts @@ -0,0 +1,85 @@ +import { NextResponse } from 'next/server'; +import { HTTPClient } from '@/http'; +import { logger } from '@/utils/logger' + +export const oapiClient = new HTTPClient({ + baseURL: process.env.NOVA_BASE_URL, + headers: { + 'Content-Type': 'application/json', + 'Tenant-Id': process.env.NOVA_TENANT_ID, + 'Authorization': process.env.NOVA_ACCESS_KEY, + } +}, { + onRequest: async (config) => { + logger.info('oapi request start', { + method: config.method, + url: config.url, + body: config.body, + query: config.query, + headers: config.headers, + }) + + return config + }, + onResponse: async (response, config) => { + logger.info('oapi response received', { + method: config.method, + url: config.url, + }) + }, + onError: (error, config) => { + logger.error('oapi request error', { + method: config.method, + url: config.url, + error + }) + } +}) + + +export const sendResponse = (res: any) => { + // 兼容 HTTP 层 defaultGetResult 的解包结果: + // 若上游返回 { success: true, data: ... },此处可能拿到 data 本身(含 null) + if (res == null || typeof res !== 'object' || !('success' in res)) { + return NextResponse.json( + { + success: true, + data: res, + }, + { status: 200 } + ) + } + + if (res.success === false) { + logger.error('oapi request failed', { + code: res.code, + message: res.message, + request_id: res.request_id, + }) + + return NextResponse.json( + { + code: res.code, + message: res.message, + request_id: res.request_id, + success: false, + }, + { status: 500 } + ) + } + + logger.info('oapi request success', { + code: res.code, + request_id: res.request_id, + }) + + return NextResponse.json( + { + code: res.code, + request_id: res.request_id, + success: true, + data: res, + }, + { status: 200 } + ) +} \ No newline at end of file diff --git a/app/api/oapi-wrapper-client.ts b/app/api/oapi-wrapper-client.ts new file mode 100644 index 0000000..60c7ec1 --- /dev/null +++ b/app/api/oapi-wrapper-client.ts @@ -0,0 +1,55 @@ +import type { HttpDefine } from '@/http/type' +import { oapiClient } from './oapi-client' + +export type DataWrapped = { data: T } + +async function dataInterceptor(promise: Promise): Promise> { + const result = await promise + return { data: result } +} + +export const oapiDataClient = { + request(config: HttpDefine): Promise> { + return dataInterceptor(oapiClient.request(config)) + }, + + get( + url: string, + query?: Record, + config?: HttpDefine, + ): Promise> { + return dataInterceptor(oapiClient.get(url, query, config)) + }, + + post( + url: string, + body?: Record | FormData, + config?: HttpDefine, + ): Promise> { + return dataInterceptor(oapiClient.post(url, body, config)) + }, + + put( + url: string, + body?: Record | FormData, + config?: HttpDefine, + ): Promise> { + return dataInterceptor(oapiClient.put(url, body, config)) + }, + + patch( + url: string, + body?: Record | FormData, + config?: HttpDefine, + ): Promise> { + return dataInterceptor(oapiClient.patch(url, body, config)) + }, + + delete( + url: string, + query?: Record, + config?: HttpDefine, + ): Promise> { + return dataInterceptor(oapiClient.delete(url, query, config)) + }, +} diff --git a/app/api/oss/upload-sts/route.ts b/app/api/oss/upload-sts/route.ts new file mode 100644 index 0000000..3b21fb1 --- /dev/null +++ b/app/api/oss/upload-sts/route.ts @@ -0,0 +1,6 @@ +import { oapiClient, sendResponse } from '../../oapi-client' + +export async function GET() { + const res = await oapiClient.get('/v1/oss/upload_sts') + return sendResponse(res) +} diff --git a/app/api/plugins/skill/upload/route.ts b/app/api/plugins/skill/upload/route.ts new file mode 100644 index 0000000..8dd7bff --- /dev/null +++ b/app/api/plugins/skill/upload/route.ts @@ -0,0 +1,7 @@ +import { oapiClient, sendResponse } from '../../../oapi-client' + +export async function POST(req: Request) { + const body = await req.formData() + const res = await oapiClient.post('/v1/plugins/skill/upload', body) + return sendResponse(res) +} diff --git a/app/api/remote-control/agent-info/route.ts b/app/api/remote-control/agent-info/route.ts new file mode 100644 index 0000000..f5f80be --- /dev/null +++ b/app/api/remote-control/agent-info/route.ts @@ -0,0 +1,41 @@ +import { NextResponse } from 'next/server' +import { getDefaultAgentId } from '@/app/api/nova-config' + +export async function GET() { + const baseUrl = process.env.NOVA_BASE_URL || '' + const agentId = getDefaultAgentId() + const tenantId = process.env.NOVA_TENANT_ID || '' + + let stats = { + activeConnections: 0, + totalMessages: 0, + averageResponseTime: 0, + } + + try { + const { getStats } = await import('@/remote-control/shared/nova-bridge') + stats = getStats() + } catch { + // nova-bridge 未加载 + } + + let status: 'connected' | 'disconnected' = 'disconnected' + try { + const response = await fetch(`${baseUrl}/health`, { + signal: AbortSignal.timeout(5000), + }) + if (response.ok) { + status = 'connected' + } + } catch { + // 连接失败 + } + + return NextResponse.json({ + baseUrl, + agentId, + tenantId, + status, + ...stats, + }) +} diff --git a/app/api/remote-control/config/route.ts b/app/api/remote-control/config/route.ts new file mode 100644 index 0000000..9112a89 --- /dev/null +++ b/app/api/remote-control/config/route.ts @@ -0,0 +1,109 @@ +import { NextRequest, NextResponse } from 'next/server' +import { ConfigManager, reconnectAllPlatforms } from '@/remote-control/config/manager' + +export async function GET() { + const configManager = ConfigManager.getInstance() + await configManager.ensureLoaded() + const config = configManager.getMasked() + return NextResponse.json(config) +} + +export async function PUT(request: NextRequest) { + try { + const body = await request.json() + const configManager = ConfigManager.getInstance() + await configManager.ensureLoaded() + + // Replace masked values with existing real values so we don't overwrite secrets + const merged = stripMaskedValues(body, configManager.get()) + + const validationError = validateConfig(merged) + if (validationError) { + return NextResponse.json( + { success: false, error: validationError }, + { status: 400 } + ) + } + + // skipEmit: lifecycle is managed explicitly below, avoid double-triggering + await configManager.update(merged, { skipEmit: true }) + + // After saving, explicitly manage bot lifecycle: + // - Stop all disabled bots + // - Reconnect (stop + start) all enabled bots + const config = configManager.get() + const errors: string[] = [] + + await reconnectAllPlatforms(config, errors) + + return NextResponse.json({ + success: true, + message: errors.length > 0 + ? `配置已保存,部分渠道连接异常: ${errors.join('; ')}` + : '配置已保存并生效', + }) + } catch (error) { + return NextResponse.json( + { success: false, error: '保存配置失败' }, + { status: 500 } + ) + } +} + +const MASK_PREFIX = '••••' + +/** + * If the frontend sends back a masked value (e.g. "••••pbYI"), replace it + * with the real value from the current config so we never overwrite secrets. + */ +function stripMaskedValues( + incoming: Record>, + current: Record>, +): Record> { + const result: Record> = {} + for (const platform of Object.keys(incoming)) { + const incomingPlatform = incoming[platform] + const currentPlatform = current[platform] ?? {} + const merged: Record = {} + for (const key of Object.keys(incomingPlatform)) { + const val = incomingPlatform[key] + if (typeof val === 'string' && val.startsWith(MASK_PREFIX)) { + // Keep the real value + merged[key] = currentPlatform[key] ?? '' + } else { + merged[key] = val + } + } + result[platform] = merged + } + return result +} + +function validateConfig(config: Record): string | null { + const discord = config.discord as Record | undefined + const dingtalk = config.dingtalk as Record | undefined + const lark = config.lark as Record | undefined + + if (discord?.enabled && !discord?.botToken) { + return 'Discord Bot Token 不能为空' + } + if (dingtalk?.enabled && (!dingtalk?.clientId || !dingtalk?.clientSecret)) { + return '钉钉 Client ID 和 Client Secret 不能为空' + } + if (lark?.enabled) { + if (!lark?.appId || !lark?.appSecret) { + return '飞书 App ID 和 App Secret 不能为空' + } + } + + const telegram = config.telegram as Record | undefined + const slack = config.slack as Record | undefined + + if (telegram?.enabled && !telegram?.botToken) { + return 'Telegram Bot Token 不能为空' + } + if (slack?.enabled && (!slack?.botToken || !slack?.appToken)) { + return 'Slack Bot Token 和 App Token 不能为空' + } + return null +} diff --git a/app/api/remote-control/logs/route.ts b/app/api/remote-control/logs/route.ts new file mode 100644 index 0000000..0b5a97c --- /dev/null +++ b/app/api/remote-control/logs/route.ts @@ -0,0 +1,33 @@ +import { NextRequest, NextResponse } from 'next/server' +import { BotLogger } from '@/remote-control/shared/logger' + +export async function GET(request: NextRequest) { + const searchParams = request.nextUrl.searchParams + const limit = parseInt(searchParams.get('limit') || '100') + const offset = parseInt(searchParams.get('offset') || '0') + const platform = searchParams.get('platform') as 'discord' | 'dingtalk' | null + const eventType = searchParams.get('eventType') || null + const severity = searchParams.get('severity') as 'info' | 'warning' | 'error' | null + + const { logs, total } = BotLogger.getLogs({ + limit, + offset, + platform: platform || undefined, + eventType: eventType || undefined, + severity: severity || undefined, + }) + + return NextResponse.json({ + logs, + total, + hasMore: offset + logs.length < total, + }) +} + +export async function DELETE() { + BotLogger.clear() + return NextResponse.json({ + success: true, + message: '日志已清空', + }) +} diff --git a/app/api/remote-control/status/route.ts b/app/api/remote-control/status/route.ts new file mode 100644 index 0000000..96fa658 --- /dev/null +++ b/app/api/remote-control/status/route.ts @@ -0,0 +1,71 @@ +import { NextResponse } from 'next/server' +import { ConfigManager } from '@/remote-control/config/manager' + +export async function GET() { + const mgr = ConfigManager.getInstance() + await mgr.ensureLoaded() + const config = mgr.get() + const statuses: Record = {} + + // Discord Bot 状态 + if (config.discord.enabled) { + try { + const discordBot = await import('@/remote-control/bots/discord') + statuses.discord = discordBot.getStatus() + } catch { + statuses.discord = { platform: 'discord', status: 'disconnected', error: 'Bot 模块未加载' } + } + } else { + statuses.discord = { platform: 'discord', status: 'disconnected' } + } + + // 钉钉 Bot 状态 + if (config.dingtalk.enabled) { + try { + const dingtalkBot = await import('@/remote-control/bots/dingtalk') + statuses.dingtalk = dingtalkBot.getStatus() + } catch { + statuses.dingtalk = { platform: 'dingtalk', status: 'disconnected', error: 'Bot 模块未加载' } + } + } else { + statuses.dingtalk = { platform: 'dingtalk', status: 'disconnected' } + } + + // 飞书 Bot 状态 + if (config.lark.enabled) { + try { + const larkBot = await import('@/remote-control/bots/lark') + statuses.lark = larkBot.getStatus() + } catch { + statuses.lark = { platform: 'lark', status: 'disconnected', error: 'Bot 模块未加载' } + } + } else { + statuses.lark = { platform: 'lark', status: 'disconnected' } + } + + // Telegram Bot 状态 + if (config.telegram.enabled) { + try { + const telegramBot = await import('@/remote-control/bots/telegram') + statuses.telegram = telegramBot.getStatus() + } catch { + statuses.telegram = { platform: 'telegram', status: 'disconnected', error: 'Bot 模块未加载' } + } + } else { + statuses.telegram = { platform: 'telegram', status: 'disconnected' } + } + + // Slack Bot 状态 + if (config.slack.enabled) { + try { + const slackBot = await import('@/remote-control/bots/slack') + statuses.slack = slackBot.getStatus() + } catch { + statuses.slack = { platform: 'slack', status: 'disconnected', error: 'Bot 模块未加载' } + } + } else { + statuses.slack = { platform: 'slack', status: 'disconnected' } + } + + return NextResponse.json(statuses) +} diff --git a/app/api/remote-control/test/route.ts b/app/api/remote-control/test/route.ts new file mode 100644 index 0000000..6e33d8f --- /dev/null +++ b/app/api/remote-control/test/route.ts @@ -0,0 +1,147 @@ +import { NextRequest, NextResponse } from 'next/server' +import { ConfigManager } from '@/remote-control/config/manager' + +function sleep(ms: number) { + return new Promise(resolve => setTimeout(resolve, ms)) +} + +/** + * Poll a bot's getStatus() until it leaves 'connecting' state, + * or until timeout (default 10s). Returns the final status. + */ +async function waitForConnection( + getStatus: () => { status: string; error?: string }, + timeoutMs = 10000, + intervalMs = 500, +): Promise<{ status: string; error?: string }> { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + const s = getStatus() + // 'connected' or 'disconnected' (with error) means we have a definitive answer + if (s.status !== 'connecting') return s + await sleep(intervalMs) + } + return getStatus() +} + +export async function POST(request: NextRequest) { + try { + const { platform } = await request.json() + + if (platform !== 'discord' && platform !== 'dingtalk' && platform !== 'lark' && platform !== 'telegram' && platform !== 'slack') { + return NextResponse.json({ success: false, error: '无效的平台' }, { status: 400 }) + } + + const mgr = ConfigManager.getInstance() + await mgr.ensureLoaded() + const config = mgr.get() + + // Reject test for disabled platforms + const platformConfig = config[platform as keyof typeof config] as { enabled: boolean } + if (!platformConfig?.enabled) { + return NextResponse.json({ success: false, error: '该渠道已禁用,请先启用后再测试' }, { status: 400 }) + } + + if (platform === 'discord') { + if (!config.discord.botToken) { + return NextResponse.json({ success: false, error: 'Discord Bot Token 未配置' }) + } + try { + const bot = await import('@/remote-control/bots/discord') + await bot.stopBot() + await bot.startBot(config.discord.botToken) + const status = await waitForConnection(() => bot.getStatus()) + return NextResponse.json({ + success: status.status === 'connected', + status, + error: status.status !== 'connected' ? (status.error || '连接超时,请检查 Token 是否正确') : undefined, + }) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + return NextResponse.json({ success: false, error: `Discord 连接失败: ${msg}` }) + } + } + + if (platform === 'dingtalk') { + if (!config.dingtalk.clientId || !config.dingtalk.clientSecret) { + return NextResponse.json({ success: false, error: '钉钉 Client ID 或 Client Secret 未配置' }) + } + try { + const bot = await import('@/remote-control/bots/dingtalk') + await bot.stopBot() + await bot.startBot(config.dingtalk.clientId, config.dingtalk.clientSecret) + const status = await waitForConnection(() => bot.getStatus()) + return NextResponse.json({ + success: status.status === 'connected', + status, + error: status.status !== 'connected' ? (status.error || '连接超时,请检查凭证是否正确') : undefined, + }) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + return NextResponse.json({ success: false, error: `钉钉连接失败: ${msg}` }) + } + } + + if (platform === 'lark') { + if (!config.lark.appId || !config.lark.appSecret) { + return NextResponse.json({ success: false, error: '飞书 App ID 或 App Secret 未配置' }) + } + try { + const bot = await import('@/remote-control/bots/lark') + await bot.stopBot() + await bot.startBot(config.lark.appId, config.lark.appSecret) + const status = await waitForConnection(() => bot.getStatus()) + return NextResponse.json({ + success: status.status === 'connected', + status, + error: status.status !== 'connected' ? (status.error || '连接超时,请检查凭证是否正确') : undefined, + }) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + return NextResponse.json({ success: false, error: `飞书连接失败: ${msg}` }) + } + } + + if (platform === 'telegram') { + if (!config.telegram.botToken) { + return NextResponse.json({ success: false, error: 'Telegram Bot Token 未配置' }) + } + try { + const bot = await import('@/remote-control/bots/telegram') + await bot.stopBot() + await bot.startBot(config.telegram.botToken) + const status = await waitForConnection(() => bot.getStatus()) + return NextResponse.json({ + success: status.status === 'connected', + status, + error: status.status !== 'connected' ? (status.error || '连接超时,请检查 Token 是否正确') : undefined, + }) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + return NextResponse.json({ success: false, error: `Telegram 连接失败: ${msg}` }) + } + } + + if (platform === 'slack') { + if (!config.slack.botToken || !config.slack.appToken) { + return NextResponse.json({ success: false, error: 'Slack Bot Token 或 App Token 未配置' }) + } + try { + const bot = await import('@/remote-control/bots/slack') + await bot.stopBot() + await bot.startBot(config.slack.botToken, config.slack.appToken) + const status = await waitForConnection(() => bot.getStatus()) + return NextResponse.json({ + success: status.status === 'connected', + status, + error: status.status !== 'connected' ? (status.error || '连接超时,请检查凭证是否正确') : undefined, + }) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + return NextResponse.json({ success: false, error: `Slack 连接失败: ${msg}` }) + } + } + } catch { + return NextResponse.json({ success: false, error: '请求解析失败' }, { status: 400 }) + } +} diff --git a/app/api/team/[teamId]/plugins/route.ts b/app/api/team/[teamId]/plugins/route.ts new file mode 100644 index 0000000..6f19af1 --- /dev/null +++ b/app/api/team/[teamId]/plugins/route.ts @@ -0,0 +1,11 @@ +import { oapiClient, sendResponse } from '@/app/api/oapi-client' + +export async function POST( + req: Request, + { params }: { params: Promise<{ teamId: string }> }, +) { + const body = req.body ? await req.json() : {} + const { teamId } = await params + const res = await oapiClient.post(`/v1/team/${teamId}/plugins`, body) + return sendResponse(res) +} diff --git a/app/api/v1/[...path]/route.ts b/app/api/v1/[...path]/route.ts new file mode 100644 index 0000000..e4723c2 --- /dev/null +++ b/app/api/v1/[...path]/route.ts @@ -0,0 +1,133 @@ +import { NextRequest } from 'next/server' +import { oapiClient, sendResponse } from '../../oapi-client' + +function buildUrl(path: string[]) { + return `/v1/${path.join('/')}` +} + +function buildFullForwardUrl(path: string[], query?: Record) { + const base = process.env.NOVA_BASE_URL || '' + const normalizedBase = base.endsWith('/') ? base.slice(0, -1) : base + const pathname = buildUrl(path) + const url = new URL(`${normalizedBase}${pathname}`) + Object.entries(query || {}).forEach(([key, value]) => { + url.searchParams.set(key, value) + }) + return url.toString() +} + +function logForwardRequest(args: { + method: string + path: string[] + query?: Record + body?: unknown +}) { + const { method, path, query, body } = args + console.log('[api/v1 proxy] forward', { + method, + url: buildFullForwardUrl(path, query), + query: query || {}, + body: body ?? null, + }) +} + +function logForwardResponse(method: string, path: string[], res: unknown) { + const url = buildUrl(path) + try { + console.log('[api/v1 proxy] response', { + method, + url, + raw: JSON.stringify(res, null, 2), + }) + } catch { + console.log('[api/v1 proxy] response', { + method, + url, + raw: res, + }) + } +} + +function normalizeQuery(searchParams: URLSearchParams) { + const query: Record = {} + + searchParams.forEach((value, key) => { + // params[task_id] -> task_id + if (key.startsWith('params[') && key.endsWith(']')) { + const realKey = key.slice(7, -1) + if (realKey) query[realKey] = value + return + } + query[key] = value + }) + + return query +} + +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ path: string[] }> }, +) { + const { path } = await params + const query = normalizeQuery(req.nextUrl.searchParams) + logForwardRequest({ method: 'GET', path, query }) + const res = await oapiClient.get(buildUrl(path), query) + logForwardResponse('GET', path, res) + return sendResponse(res) +} + +export async function POST( + req: Request, + { params }: { params: Promise<{ path: string[] }> }, +) { + const body = req.body ? await req.json() : {} + const { path } = await params + logForwardRequest({ method: 'POST', path, body }) + const res = await oapiClient.post(buildUrl(path), body) + logForwardResponse('POST', path, res) + return sendResponse(res) +} + +export async function PUT( + req: Request, + { params }: { params: Promise<{ path: string[] }> }, +) { + const body = req.body ? await req.json() : {} + const { path } = await params + logForwardRequest({ method: 'PUT', path, body }) + const res = await oapiClient.request({ + url: buildUrl(path), + method: 'put', + body, + }) + logForwardResponse('PUT', path, res) + return sendResponse(res) +} + +export async function PATCH( + req: Request, + { params }: { params: Promise<{ path: string[] }> }, +) { + const body = req.body ? await req.json() : {} + const { path } = await params + logForwardRequest({ method: 'PATCH', path, body }) + const res = await oapiClient.request({ + url: buildUrl(path), + method: 'patch', + body, + }) + logForwardResponse('PATCH', path, res) + return sendResponse(res) +} + +export async function DELETE( + req: NextRequest, + { params }: { params: Promise<{ path: string[] }> }, +) { + const { path } = await params + const query = normalizeQuery(req.nextUrl.searchParams) + logForwardRequest({ method: 'DELETE', path, query }) + const res = await oapiClient.delete(buildUrl(path), query) + logForwardResponse('DELETE', path, res) + return sendResponse(res) +} diff --git a/app/api/websocket/index.ts b/app/api/websocket/index.ts new file mode 100644 index 0000000..9b3cd7a --- /dev/null +++ b/app/api/websocket/index.ts @@ -0,0 +1,570 @@ +/** + * WebSocket 客户端封装 + * + * 提供自动重连、心跳检测、网络状态监听等功能 + */ + +function latest(value: T) { + const ref = { current: value } + return ref +} + +export const ReadyState = { + Connecting: 0, + Open: 1, + Closing: 2, + Closed: 3, +} as const + +export type ReadyState = (typeof ReadyState)[keyof typeof ReadyState] + +export interface Result { + sendMessage: WebSocket['send'] + disconnect: () => void + connect: () => void + readyState: ReadyState + webSocketIns?: WebSocket + clearHeartbeat: () => void + switchConversation: (conversationId: string) => void + cleanup: () => void +} + +export interface HeartbeatOptions { + /** 心跳间隔,默认 20000ms */ + heartbeatInterval?: number + /** 心跳超时,默认 22000ms */ + heartbeatTimeout?: number + /** 心跳消息,默认 { message_type: 'ping' } */ + heartbeatMessage?: string | object | (() => string | object) + /** 心跳响应类型,默认 'pong' */ + heartbeatResponseType?: string +} + +export interface ApiEvent { + event_id: string + event_type?: string + role?: 'user' | 'assistant' | 'system' + content?: { + text?: string + content?: string + [key: string]: unknown + } + created_at?: string + task_id?: string + is_display?: boolean + metadata?: Record + stream?: boolean + event_status?: string + [key: string]: unknown +} + +// WebSocket 事件类型定义 +type WSOpenEvent = Event +interface WSCloseEvent { + code?: number + reason?: string + wasClean?: boolean +} +interface WSMessageEvent { + data: string | ArrayBuffer | Blob +} +type WSErrorEvent = Event + +export interface Options { + /** 重连次数限制,默认 3 */ + reconnectLimit?: number + /** 重连间隔,默认 3000ms */ + reconnectInterval?: number + /** 是否手动连接,默认 false */ + manual?: boolean + /** 连接成功回调 */ + onOpen?: (event: WSOpenEvent, instance: WebSocket) => void + /** 连接关闭回调 */ + onClose?: (event: WSCloseEvent, instance: WebSocket) => void + /** 收到消息回调 */ + onMessage?: (message: ApiEvent, instance: WebSocket) => void + /** 连接错误回调 */ + onError?: (event: WSErrorEvent, instance: WebSocket) => void + /** WebSocket 协议 */ + protocols?: string | string[] + /** 心跳配置,传 false 禁用心跳 */ + heartbeat?: HeartbeatOptions | boolean + /** 是否监听网络状态变化,默认 true */ + enableNetworkListener?: boolean + /** 是否监听文档可见性变化,默认 true */ + enableVisibilityListener?: boolean + /** 重连延迟,默认 300ms */ + reconnectDelay?: number + /** 重连防抖时间,默认 1000ms */ + reconnectDebounce?: number + /** 获取认证 Token */ + getToken?: () => string | undefined + /** 获取租户 ID */ + getTenantId?: () => string | undefined +} + +/** + * 创建 WebSocket 客户端 + */ +export function createWebSocketClient( + socketUrl: string, + options: Options = {}, +): Result { + const { + reconnectLimit = 3, + reconnectInterval = 3 * 1000, + manual = false, + onOpen, + onClose, + onMessage, + onError, + protocols, + heartbeat: enableHeartbeat = true, + enableNetworkListener = true, + enableVisibilityListener = true, + reconnectDelay = 300, + reconnectDebounce = 1000, + getToken, + getTenantId, + } = options + + const heartbeatOptions: HeartbeatOptions = + typeof enableHeartbeat === 'object' ? enableHeartbeat : {} + const { + heartbeatInterval = 20000, + heartbeatTimeout = 22000, + heartbeatMessage = { message_type: 'ping' }, + heartbeatResponseType = 'pong', + } = heartbeatOptions + + // 提前声明函数,避免使用前未定义 + let disconnectFn: () => void = () => { + throw new Error('disconnectFn not initialized') + } + let connectWsFn: () => void = () => { + throw new Error('connectWsFn not initialized') + } + + const onOpenRef = latest(onOpen) + const onCloseRef = latest(onClose) + const onMessageRef = latest(onMessage) + const onErrorRef = latest(onError) + + // 确保 ref 始终指向最新的回调 + if (onMessage) { + onMessageRef.current = onMessage + } + + const reconnectTimesRef = latest(0) + const reconnectTimerRef = latest | undefined>(undefined) + const websocketRef = latest(undefined) + const readyStateRef = latest(ReadyState.Closed) + + // 心跳相关 + const heartbeatTimerRef = latest | undefined>(undefined) + const heartbeatTimeoutTimerRef = latest | undefined>(undefined) + const waitingForPongRef = latest(false) + + // 网络和可见性状态 + const isOnlineRef = latest(typeof navigator !== 'undefined' ? navigator.onLine : true) + const isVisibleRef = latest(typeof document !== 'undefined' ? !document.hidden : true) + + // 重连防抖定时器 + const reconnectDebounceTimerRef = latest | undefined>(undefined) + + // 更新 readyState 的辅助函数 + const setReadyState = (state: ReadyState) => { + readyStateRef.current = state + } + + // 获取当前 readyState + const getReadyState = (): ReadyState => { + if (websocketRef.current) { + const wsState = websocketRef.current.readyState + if (wsState === WebSocket.CONNECTING) return ReadyState.Connecting + if (wsState === WebSocket.OPEN) return ReadyState.Open + if (wsState === WebSocket.CLOSING) return ReadyState.Closing + if (wsState === WebSocket.CLOSED) return ReadyState.Closed + } + return readyStateRef.current + } + + // 清除心跳相关定时器 + const clearHeartbeat = () => { + if (heartbeatTimerRef.current) { + clearTimeout(heartbeatTimerRef.current) + heartbeatTimerRef.current = undefined + } + if (heartbeatTimeoutTimerRef.current) { + clearTimeout(heartbeatTimeoutTimerRef.current) + heartbeatTimeoutTimerRef.current = undefined + } + waitingForPongRef.current = false + } + + // 处理心跳超时 + const handlePingTimeout = () => { + if (!isOnlineRef.current) { + waitingForPongRef.current = false + clearHeartbeat() + return + } + waitingForPongRef.current = false + clearHeartbeat() + disconnectFn() + } + + // 发送心跳 + const sendHeartbeat = () => { + if (!isOnlineRef.current) { + return + } + if (waitingForPongRef.current) { + return + } + if (websocketRef.current && getReadyState() === ReadyState.Open) { + try { + const message = + typeof heartbeatMessage === 'function' + ? heartbeatMessage() + : heartbeatMessage + websocketRef.current.send( + typeof message === 'string' ? message : JSON.stringify(message), + ) + waitingForPongRef.current = true + + heartbeatTimeoutTimerRef.current = setTimeout( + handlePingTimeout, + heartbeatTimeout, + ) + } catch { + clearHeartbeat() + } + } else { + clearHeartbeat() + } + } + + // 处理心跳响应 + const handlePongReceived = () => { + if (!waitingForPongRef.current) { + return + } + waitingForPongRef.current = false + + if (heartbeatTimeoutTimerRef.current) { + clearTimeout(heartbeatTimeoutTimerRef.current) + heartbeatTimeoutTimerRef.current = undefined + } + + heartbeatTimerRef.current = setTimeout(sendHeartbeat, heartbeatInterval) + } + + // 启动心跳 + const startHeartbeat = () => { + if (!enableHeartbeat) return + clearHeartbeat() + heartbeatTimerRef.current = setTimeout(sendHeartbeat, 1000) + } + + // 处理原始消息,检查是否是心跳响应 + const handleRawMessage = (messageData: string): boolean => { + try { + const rawMessage = JSON.parse(messageData) + if ( + rawMessage.data?.message_type === heartbeatResponseType || + rawMessage.message_type === heartbeatResponseType + ) { + handlePongReceived() + return true + } + return false + } catch { + return false + } + } + + const reconnect = () => { + if ( + reconnectTimesRef.current < reconnectLimit && + getReadyState() !== ReadyState.Open + ) { + if (reconnectTimerRef.current) { + clearTimeout(reconnectTimerRef.current) + } + + reconnectTimerRef.current = setTimeout(() => { + connectWsFn() + reconnectTimesRef.current++ + }, reconnectInterval) + } + } + + connectWsFn = () => { + if (reconnectTimerRef.current) { + clearTimeout(reconnectTimerRef.current) + } + + if (websocketRef.current) { + websocketRef.current.close() + } + + // 构建 WebSocket URL + const url = new URL(socketUrl) + + // 添加认证信息 + const token = getToken?.() + const tenantId = getTenantId?.() + + if (token) { + url.searchParams.set('Authorization', token) + } + if (tenantId) { + url.searchParams.set('Tenant-Id', tenantId) + } + + const ws = new WebSocket(url.toString(), protocols) + setReadyState(ReadyState.Connecting) + + ws.onerror = event => { + if (websocketRef.current !== ws) { + return + } + reconnect() + onErrorRef.current?.(event, ws) + setReadyState(ReadyState.Closed) + } + + ws.onopen = event => { + if (websocketRef.current !== ws) { + return + } + onOpenRef.current?.(event, ws) + reconnectTimesRef.current = 0 + setReadyState(ReadyState.Open) + startHeartbeat() + } + + ws.onmessage = (message: WSMessageEvent) => { + if (websocketRef.current !== ws) { + return + } + + const messageData = + typeof message.data === 'string' ? message.data : String(message.data) + + // 先检查是否是心跳响应 + if (enableHeartbeat && handleRawMessage(messageData)) { + return + } + + // 解析消息并触发回调 + try { + const parsedMessage: ApiEvent = JSON.parse(messageData) + onMessageRef.current?.(parsedMessage, ws) + } catch { + // 如果解析失败,尝试作为原始数据传递 + console.warn('[WebSocket] Failed to parse message:', messageData) + } + } + + ws.onclose = event => { + onCloseRef.current?.(event, ws) + clearHeartbeat() + // closed by server + if (websocketRef.current === ws) { + reconnect() + } + // closed by disconnect or closed by server + if (!websocketRef.current || websocketRef.current === ws) { + setReadyState(ReadyState.Closed) + } + } + + websocketRef.current = ws + } + + const sendMessage: WebSocket['send'] = message => { + const currentState = getReadyState() + if (currentState === ReadyState.Open) { + websocketRef.current?.send(message) + } else { + throw new Error('WebSocket disconnected') + } + } + + // 切换会话 + const switchConversation = (conversationId: string) => { + // 检查网络状态 + if (!isOnlineRef.current) { + throw new Error('网络连接异常,无法切换会话') + } + + // 检查 WebSocket 连接状态 + const currentState = getReadyState() + if (!websocketRef.current || currentState !== ReadyState.Open) { + throw new Error('WebSocket 未连接,无法切换会话') + } + + try { + const message = JSON.stringify({ + message_type: 'switch_conversation', + conversation_id: conversationId, + }) + websocketRef.current.send(message) + } catch (error) { + throw new Error(`切换会话失败: ${error}`) + } + } + + const connect = () => { + reconnectTimesRef.current = 0 + connectWsFn() + } + + disconnectFn = () => { + if (reconnectTimerRef.current) { + clearTimeout(reconnectTimerRef.current) + } + if (reconnectDebounceTimerRef.current) { + clearTimeout(reconnectDebounceTimerRef.current) + } + + reconnectTimesRef.current = reconnectLimit + clearHeartbeat() + websocketRef.current?.close(1000, '手动断开') + websocketRef.current = undefined + setReadyState(ReadyState.Closed) + } + + // 处理网络断开 + const handleNetworkOffline = () => { + clearHeartbeat() + const currentState = getReadyState() + if ( + currentState === ReadyState.Open || + currentState === ReadyState.Connecting + ) { + disconnectFn() + } + } + + // 重连函数 - 统一处理重连逻辑 + const attemptReconnect = () => { + // 清除之前的防抖定时器 + if (reconnectDebounceTimerRef.current) { + clearTimeout(reconnectDebounceTimerRef.current) + } + + reconnectDebounceTimerRef.current = setTimeout(() => { + const currentState = getReadyState() + // 已连接或正在连接时跳过 + if ( + currentState === ReadyState.Open || + currentState === ReadyState.Connecting + ) { + return + } + + clearHeartbeat() + + const isClosed = + currentState === ReadyState.Closed || + currentState === ReadyState.Closing + + if (isClosed) { + connect() + } else { + disconnectFn() + setTimeout(() => { + if ( + isOnlineRef.current && + getReadyState() !== ReadyState.Open && + getReadyState() !== ReadyState.Connecting + ) { + connect() + } + }, reconnectDelay) + } + }, reconnectDebounce) + } + + // 网络状态监听 + let cleanupNetworkListener: (() => void) | undefined + + if (enableNetworkListener && typeof window !== 'undefined') { + const handleOnline = () => { + isOnlineRef.current = true + attemptReconnect() + } + + const handleOffline = () => { + isOnlineRef.current = false + handleNetworkOffline() + } + + window.addEventListener('online', handleOnline) + window.addEventListener('offline', handleOffline) + + cleanupNetworkListener = () => { + window.removeEventListener('online', handleOnline) + window.removeEventListener('offline', handleOffline) + } + } + + // 文档可见性监听 + let cleanupVisibilityListener: (() => void) | undefined + + if (enableVisibilityListener && typeof document !== 'undefined') { + const handleVisibilityChange = () => { + const isVisible = !document.hidden + isVisibleRef.current = isVisible + if (isVisible && isOnlineRef.current) { + const currentState = getReadyState() + if ( + currentState === ReadyState.Closed || + currentState === ReadyState.Closing + ) { + attemptReconnect() + } + } + } + + document.addEventListener('visibilitychange', handleVisibilityChange) + + cleanupVisibilityListener = () => { + document.removeEventListener('visibilitychange', handleVisibilityChange) + } + } + + // 自动连接 + if (!manual && socketUrl) { + connect() + } + + // 清理函数 + const cleanup = () => { + disconnectFn() + cleanupNetworkListener?.() + cleanupVisibilityListener?.() + } + + const result: Result = { + sendMessage, + connect, + disconnect: disconnectFn, + get readyState() { + return getReadyState() + }, + get webSocketIns() { + return websocketRef.current + }, + clearHeartbeat, + switchConversation, + cleanup, + } + + return result +} + +export default createWebSocketClient diff --git a/app/favicon.ico b/app/favicon.ico new file mode 100644 index 0000000..de5d764 Binary files /dev/null and b/app/favicon.ico differ diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..840e12d --- /dev/null +++ b/app/globals.css @@ -0,0 +1,594 @@ +@import "tailwindcss"; +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --font-sans: "Avenir Next", "SF Pro Rounded", "SF Pro Display", "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI Variable", sans-serif; + + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --radius-2xl: calc(var(--radius) + 10px); + --radius-3xl: calc(var(--radius) + 16px); + --radius-4xl: calc(var(--radius) + 22px); + + --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); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); + --color-brand: var(--brand); + --color-brand-foreground: var(--brand-foreground); + --color-success: var(--success); + --color-warning: var(--warning); +} + + :root { + --radius: 0.875rem; + + --background: #f5f1ea; + --foreground: #171412; + + --card: #fffdf8; + --card-foreground: #171412; + + --popover: #fffdf8; + --popover-foreground: #171412; + + --primary: #8a6742; + --primary-foreground: #fffaf4; + + --secondary: #ece4d7; + --secondary-foreground: #2e241c; + + --muted: #efe8dd; + --muted-foreground: #7c6f62; + + --accent: #e8dfd1; + --accent-foreground: #4b3724; + + --destructive: #cb5f74; + + --border: #ddd2c2; + --input: #cfbfaa; + --ring: rgba(138, 103, 66, 0.22); + + --brand: #8a6742; + --brand-foreground: #fffaf4; + --success: #5e856b; + --warning: #c98a45; + + --chart-1: #8a6742; + --chart-2: #b79267; + --chart-3: #6d7788; + --chart-4: #5e856b; + --chart-5: #c98253; + + --sidebar: #fffdf8; + --sidebar-foreground: #171412; + --sidebar-primary: #8a6742; + --sidebar-primary-foreground: #fffaf4; + --sidebar-accent: #e8dfd1; + --sidebar-accent-foreground: #2e241c; + --sidebar-border: #ddd2c2; + --sidebar-ring: rgba(138, 103, 66, 0.22); + + --editor-surface: rgba(255, 251, 245, 0.92); + --editor-surface-muted: rgba(239, 232, 221, 0.92); + --editor-border: rgba(124, 111, 98, 0.22); + --editor-border-strong: rgba(124, 111, 98, 0.36); + --editor-shadow: rgba(23, 20, 18, 0.14); + --editor-text: #2e241c; + --editor-text-muted: rgba(46, 36, 28, 0.54); + --editor-accent: #8a6742; + --editor-accent-soft: rgba(138, 103, 66, 0.12); + --editor-danger: #cb5f74; + --editor-danger-soft: rgba(203, 95, 116, 0.12); + + --page-glow-1: rgba(206, 185, 156, 0.32); + --page-glow-2: rgba(170, 142, 96, 0.18); + --page-gradient-top: #fcfaf6; + --page-gradient-bottom: #f5f1ea; + --gradient-subtle-1: #fcfaf6; + --gradient-subtle-2: #f1e8db; + --gradient-subtle-3: #e6dac8; + --gradient-brand-1: #3a2b1e; + --gradient-brand-2: #8a6742; + --gradient-brand-3: #d0b08c; + --text-gradient-1: #171412; + --text-gradient-2: #8a6742; + --text-gradient-3: #c7a173; + --glass-bg: rgba(255, 251, 245, 0.84); + --glass-border: rgba(221, 210, 194, 0.92); + --glow-shadow: rgba(138, 103, 66, 0.2); + --brand-shadow: rgba(138, 103, 66, 0.26); + --terminal-background: #17171d; + --terminal-surface: #121218; + --terminal-border: rgba(255, 255, 255, 0.08); + --terminal-text: #f3f4f6; + --terminal-text-muted: rgba(243, 244, 246, 0.66); + --terminal-prompt: #b79267; +} + +.dark { + --background: #09090d; + --foreground: #f5f7ff; + + --card: #12131a; + --card-foreground: #f5f7ff; + + --popover: #141624; + --popover-foreground: #f5f7ff; + + --primary: #8f7cff; + --primary-foreground: #f8f7ff; + + --secondary: #171826; + --secondary-foreground: #e8ebff; + + --muted: #131422; + --muted-foreground: #9da2bf; + + --accent: #1b1d31; + --accent-foreground: #d9ddff; + + --destructive: #ff7ea8; + + --border: rgba(126, 132, 173, 0.2); + --input: rgba(126, 132, 173, 0.28); + --ring: rgba(143, 124, 255, 0.36); + + --brand: #a18cff; + --brand-foreground: #f8f7ff; + --success: #48d7c2; + --warning: #ffb86b; + + --chart-1: #8f7cff; + --chart-2: #45d4ff; + --chart-3: #5f7cff; + --chart-4: #ff78b8; + --chart-5: #ffb86b; + + --sidebar: #0f1018; + --sidebar-foreground: #f5f7ff; + --sidebar-primary: #8f7cff; + --sidebar-primary-foreground: #f8f7ff; + --sidebar-accent: #1b1d31; + --sidebar-accent-foreground: #f5f7ff; + --sidebar-border: rgba(126, 132, 173, 0.2); + --sidebar-ring: rgba(143, 124, 255, 0.36); + + --editor-surface: rgba(18, 19, 26, 0.92); + --editor-surface-muted: rgba(27, 29, 49, 0.86); + --editor-border: rgba(126, 132, 173, 0.2); + --editor-border-strong: rgba(126, 132, 173, 0.34); + --editor-shadow: rgba(0, 0, 0, 0.32); + --editor-text: #f5f7ff; + --editor-text-muted: rgba(245, 247, 255, 0.58); + --editor-accent: #8f7cff; + --editor-accent-soft: rgba(143, 124, 255, 0.16); + --editor-danger: #ff7ea8; + --editor-danger-soft: rgba(255, 126, 168, 0.16); + + --page-glow-1: rgba(143, 124, 255, 0.2); + --page-glow-2: rgba(69, 212, 255, 0.12); + --page-gradient-top: #151625; + --page-gradient-bottom: #09090d; + --gradient-subtle-1: #191b2d; + --gradient-subtle-2: #10111b; + --gradient-subtle-3: #09090d; + --gradient-brand-1: #6156ff; + --gradient-brand-2: #9a7cff; + --gradient-brand-3: #45d4ff; + --text-gradient-1: #f5f7ff; + --text-gradient-2: #a18cff; + --text-gradient-3: #45d4ff; + --glass-bg: rgba(18, 19, 26, 0.84); + --glass-border: rgba(126, 132, 173, 0.18); + --glow-shadow: rgba(143, 124, 255, 0.22); + --brand-shadow: rgba(143, 124, 255, 0.28); + --terminal-background: #12131a; + --terminal-surface: #0d0f16; + --terminal-border: rgba(255, 255, 255, 0.08); + --terminal-text: #f5f7ff; + --terminal-text-muted: rgba(245, 247, 255, 0.64); + --terminal-prompt: #a18cff; +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + + html { + font-family: "Avenir Next", "SF Pro Rounded", "SF Pro Display", "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI Variable", sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; + } + + body { + @apply bg-background text-foreground; + font-feature-settings: "cv02", "cv03", "cv04", "cv11"; + min-height: 100vh; + background-image: + radial-gradient(circle at top left, var(--page-glow-1), transparent 34%), + radial-gradient(circle at 85% 12%, var(--page-glow-2), transparent 22%), + linear-gradient(180deg, var(--page-gradient-top) 0%, var(--page-gradient-bottom) 100%); + background-attachment: fixed; + } + + ::selection { + background: var(--ring); + color: var(--foreground); + } + + @keyframes artifact-shuffle-in { + 0% { + opacity: 0; + transform: translateY(24px) scale(0.9) rotate(-4deg); + } + 40% { + opacity: 1; + transform: translateY(-6px) scale(1.03) rotate(2deg); + } + 70% { + transform: translateY(3px) scale(0.98) rotate(-1deg); + } + 100% { + opacity: 1; + transform: translateY(0) scale(1) rotate(0deg); + } + } + + .animate-artifact-shuffle-in { + animation-name: artifact-shuffle-in; + animation-duration: 800ms; + animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1); + animation-fill-mode: backwards; + } + + @media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } + } +} + +@layer components { + .card { + @apply rounded-xl bg-card; + border: 1px solid var(--border); + box-shadow: + 0 1px 2px rgba(16, 38, 52, 0.03), + 0 14px 34px rgba(16, 38, 52, 0.06); + } + + .card:hover { + border-color: color-mix(in srgb, var(--primary) 20%, var(--border)); + box-shadow: + 0 1px 2px rgba(16, 38, 52, 0.04), + 0 18px 42px rgba(16, 38, 52, 0.08); + } + + .dark .card { + box-shadow: + 0 1px 2px rgba(0, 0, 0, 0.28), + 0 18px 48px rgba(0, 0, 0, 0.24); + } + + .glow-effect { + box-shadow: 0 14px 32px var(--glow-shadow); + } + + .dark .glow-effect { + box-shadow: 0 14px 38px var(--glow-shadow); + } + + .btn-brand { + @apply bg-primary text-primary-foreground transition-all duration-200; + } + + .btn-brand:hover { + box-shadow: 0 12px 26px var(--brand-shadow); + } + + .surface-panel { + background: color-mix(in srgb, var(--card) 92%, white 8%); + border: 1px solid var(--border); + box-shadow: + 0 1px 2px rgba(16, 38, 52, 0.03), + 0 22px 52px rgba(16, 38, 52, 0.08); + backdrop-filter: blur(14px); + } + + .surface-subtle { + background: color-mix(in srgb, var(--card) 84%, transparent); + border: 1px solid color-mix(in srgb, var(--border) 90%, transparent); + } + + .prose { + @apply text-foreground leading-relaxed; + } + + .prose h1 { @apply mt-6 mb-4 text-2xl font-bold; } + .prose h2 { @apply mt-5 mb-3 text-xl font-bold; } + .prose h3 { @apply mt-4 mb-2 text-lg font-bold; } + .prose p { @apply my-3; } + .prose ul { @apply my-3 list-disc pl-6; } + .prose ol { @apply my-3 list-decimal pl-6; } + .prose li { @apply my-1; } + .prose blockquote { @apply my-4 border-l-4 border-muted pl-4 italic; } + .prose code { @apply rounded bg-muted px-1.5 py-0.5 font-mono text-sm; } + .prose pre { @apply my-4 overflow-x-auto rounded-xl border border-border bg-card p-4; } + .prose pre code { @apply bg-transparent p-0; } + .prose table { @apply my-6 w-full border-collapse text-sm; } + .prose th { @apply border border-border bg-muted/50 px-4 py-2 text-left font-bold; } + .prose td { @apply border border-border px-4 py-2; } + .prose tr:nth-child(even) { @apply bg-muted/20; } +} + +@layer utilities { + .transition-default { + @apply transition-all duration-200 ease-out; + } + + .custom-scrollbar::-webkit-scrollbar { + width: 4px; + height: 4px; + } + + .custom-scrollbar::-webkit-scrollbar-track { + background-color: transparent; + } + + .custom-scrollbar::-webkit-scrollbar-thumb { + background-color: rgba(97, 119, 133, 0.36); + border-radius: 9999px; + } + + .custom-scrollbar::-webkit-scrollbar-thumb:hover { + background-color: rgba(97, 119, 133, 0.5); + } + + .dark .custom-scrollbar::-webkit-scrollbar-thumb { + background-color: rgba(159, 180, 188, 0.24); + border-radius: 9999px; + } + + .dark .custom-scrollbar::-webkit-scrollbar-thumb:hover { + background-color: rgba(159, 180, 188, 0.36); + } + + .bg-gradient-subtle { + background: linear-gradient(180deg, var(--gradient-subtle-1) 0%, var(--gradient-subtle-2) 52%, var(--gradient-subtle-3) 100%); + } + + .dark .bg-gradient-subtle { + background: linear-gradient(180deg, var(--gradient-subtle-1) 0%, var(--gradient-subtle-2) 50%, var(--gradient-subtle-3) 100%); + } + + .bg-gradient-brand { + background: linear-gradient(135deg, var(--gradient-brand-1) 0%, var(--gradient-brand-2) 48%, var(--gradient-brand-3) 100%); + } + + .glass { + @apply backdrop-blur-md; + background: var(--glass-bg); + border: 1px solid var(--glass-border); + } + + .dark .glass { + background: var(--glass-bg); + border: 1px solid var(--glass-border); + } + + .hover-lift { + @apply transition-all duration-200 ease-out; + } + + .hover-lift:hover { + transform: translateY(-2px); + box-shadow: + 0 10px 22px rgba(16, 38, 52, 0.08), + 0 4px 10px rgba(16, 38, 52, 0.04); + } + + .dark .hover-lift:hover { + box-shadow: + 0 8px 16px rgba(0, 0, 0, 0.3), + 0 4px 8px rgba(0, 0, 0, 0.16); + } + + .press-effect { + @apply transition-transform duration-100 ease-out; + } + + .press-effect:active { + transform: scale(0.98); + } + + .focus-ring { + @apply outline-none ring-2 ring-primary/20 ring-offset-2 ring-offset-background; + } + + .text-gradient { + @apply bg-clip-text text-transparent; + background-image: linear-gradient(135deg, var(--text-gradient-1) 0%, var(--text-gradient-2) 48%, var(--text-gradient-3) 100%); + } + + .dark .text-gradient { + background-image: linear-gradient(135deg, var(--text-gradient-1) 0%, var(--text-gradient-2) 46%, var(--text-gradient-3) 100%); + } + + .text-brand { + color: var(--brand); + } + + .shadow-soft { + box-shadow: + 0 4px 12px rgba(16, 38, 52, 0.04), + 0 10px 24px rgba(16, 38, 52, 0.05); + } + + .border-hover { + @apply border border-border transition-colors duration-200; + } + + .border-hover:hover { + border-color: color-mix(in srgb, var(--primary) 30%, var(--border)); + } + + /* ================================================ + * 全局覆盖 slate/zinc 硬编码颜色 → 语义变量 + * slate 色系为冷色调(偏蓝),本项目为暖色调, + * 统一替换确保亮色/深色模式下颜色风格一致 + * ================================================ */ + + /* 背景 */ + .bg-white { + background-color: var(--card); + } + + .bg-slate-50, + .bg-zinc-50, + .bg-gray-50 { + background-color: var(--card); + } + + .bg-slate-100, + .bg-zinc-100 { + background-color: var(--accent); + } + + /* 文字 */ + .text-black, + .text-slate-900, + .text-zinc-900 { + color: var(--foreground); + } + + .text-slate-800, + .text-zinc-800, + .text-slate-700, + .text-zinc-700 { + color: color-mix(in srgb, var(--foreground) 80%, transparent); + } + + .text-slate-600, + .text-zinc-600 { + color: color-mix(in srgb, var(--muted-foreground) 120%, transparent); + } + + .text-slate-500, + .text-zinc-500, + .text-gray-500, + .text-slate-400, + .text-zinc-400, + .text-gray-400 { + color: var(--muted-foreground); + } + + .text-slate-300, + .text-zinc-300 { + color: color-mix(in srgb, var(--muted-foreground) 60%, transparent); + } + + /* 边框 */ + .border-slate-100, + .border-slate-200, + .border-zinc-100, + .border-zinc-200 { + border-color: var(--border); + } + + .border-slate-700, + .border-slate-800, + .border-zinc-700, + .border-zinc-800 { + border-color: var(--border); + } + + /* hover 背景 */ + .hover\:bg-slate-50:hover, + .hover\:bg-slate-100:hover, + .hover\:bg-zinc-50:hover, + .hover\:bg-zinc-100:hover { + background-color: var(--accent); + } + + .editor-floating-panel { + background: var(--editor-surface); + border: 1px solid var(--editor-border); + box-shadow: + 0 18px 44px var(--editor-shadow), + inset 0 1px 0 rgba(255, 255, 255, 0.18); + backdrop-filter: blur(18px); + } + + .editor-floating-panel-soft { + background: var(--editor-surface); + border: 1px solid var(--editor-border); + box-shadow: + 0 12px 34px var(--editor-shadow), + inset 0 1px 0 rgba(255, 255, 255, 0.14); + backdrop-filter: blur(18px); + } + + .editor-toolbar-chip { + background: var(--editor-surface-muted); + border: 1px solid transparent; + color: var(--editor-text); + } + + .editor-toolbar-chip:hover { + background: color-mix(in srgb, var(--editor-accent-soft) 55%, var(--editor-surface-muted)); + } + + .editor-toolbar-chip-active { + background: var(--editor-accent-soft); + color: var(--editor-accent); + } + + .editor-toolbar-divider { + background: var(--editor-border); + } + + .editor-toolbar-danger { + background: var(--editor-danger-soft); + color: var(--editor-danger); + } + + /* 深色背景(终端/代码块)保留不覆盖:bg-slate-900 bg-slate-950 */ +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..d6c1b0b --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,34 @@ +import './globals.css'; + +import type { Metadata } from "next"; +import { Suspense } from "react"; +import { ThemeProvider } from "@/components/provider/Theme/theme-provider"; +import { Toaster } from "@/components/ui/sonner"; +import { AgentationGuard } from "@/components/AgentationGuard"; +import RouteChange from "./RouteChange"; + +export const metadata: Metadata = { + title: 'Nova Chat', + description: 'Generated by create Nova Chat', +}; + +interface RootLayoutProps { + children: React.ReactNode; +} + +export default function RootLayout(props: RootLayoutProps) { + return ( + + + + {props.children} + + + + + + + + + ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..b36246b --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,33 @@ +'use client'; + +import { ImageEditor, ImageEditorHandle } from '@/components/image-editor'; +import { NovaChat } from '@/components/nova-sdk'; +import { useBuildConversationConnect } from '@/components/nova-sdk/hooks'; +import { useImages } from '@/components/nova-sdk/store/useImages'; +import { useRef } from 'react'; + +const ChatWithImageEditor = () => { + const imageEditorRef = useRef(null); + const { conversationId, platformConfig } = useBuildConversationConnect(); + useImages(imageEditorRef) + + return ( +
+
+ {conversationId && ( + + )} +
+
+ +
+
+ ); +}; + +export default ChatWithImageEditor; diff --git a/app/settings/remote-control/page.tsx b/app/settings/remote-control/page.tsx new file mode 100644 index 0000000..e40daf9 --- /dev/null +++ b/app/settings/remote-control/page.tsx @@ -0,0 +1,797 @@ +'use client' + +import Link from 'next/link' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { + ArrowLeft, + Bot, + CheckCircle2, + Eye, + EyeOff, + Info, + Loader2, + RefreshCw, + Save, + Trash2, + XCircle, +} from 'lucide-react' +import { toast } from 'sonner' + +// PLATFORM:TYPE_UNION:START +type Platform = 'discord' | 'dingtalk' | 'lark' | 'telegram' | 'slack' +// PLATFORM:TYPE_UNION:END +type ConnectionStatus = 'connected' | 'disconnected' | 'connecting' + +type CSSVarName = + | '--background' + | '--foreground' + | '--card' + | '--card-foreground' + | '--primary' + | '--primary-foreground' + | '--muted' + | '--muted-foreground' + | '--border' + | '--input' + | '--success' + | '--warning' + | '--destructive' + +const themeVar = (name: CSSVarName) => `var(${name})` + +interface RemoteControlConfig { + // PLATFORM:DINGTALK:CONFIG_INTERFACE:START + dingtalk: { + enabled: boolean + clientId: string + clientSecret: string + } + // PLATFORM:DINGTALK:CONFIG_INTERFACE:END + // PLATFORM:DISCORD:CONFIG_INTERFACE:START + discord: { + enabled: boolean + botToken: string + } + // PLATFORM:DISCORD:CONFIG_INTERFACE:END + // PLATFORM:LARK:CONFIG_INTERFACE:START + lark: { + enabled: boolean + appId: string + appSecret: string + } + // PLATFORM:LARK:CONFIG_INTERFACE:END + // PLATFORM:TELEGRAM:CONFIG_INTERFACE:START + telegram: { + enabled: boolean + botToken: string + } + // PLATFORM:TELEGRAM:CONFIG_INTERFACE:END + // PLATFORM:SLACK:CONFIG_INTERFACE:START + slack: { + enabled: boolean + botToken: string + appToken: string + } + // PLATFORM:SLACK:CONFIG_INTERFACE:END +} + +interface PlatformStatus { + platform?: Platform + status: ConnectionStatus + messagesProcessed?: number + activeSessions?: number + lastConnectedAt?: string + uptime?: number + error?: string +} + +interface LogsResponse { + logs: LogEntry[] +} + +interface LogEntry { + id?: string + timestamp: string + platform: Platform + eventType: string + severity: 'info' | 'warning' | 'error' + details?: string | Record + message?: string +} + +interface AgentInfoResponse { + agentId: string + baseUrl: string + stats?: { + totalMessages?: number + activeConnections?: number + avgResponseTime?: number + } + totalMessages?: number + activeConnections?: number + averageResponseTime?: number +} + +const DEFAULT_CONFIG: RemoteControlConfig = { + // PLATFORM:DINGTALK:DEFAULT_CONFIG:START + dingtalk: { enabled: false, clientId: '', clientSecret: '' }, + // PLATFORM:DINGTALK:DEFAULT_CONFIG:END + // PLATFORM:DISCORD:DEFAULT_CONFIG:START + discord: { enabled: false, botToken: '' }, + // PLATFORM:DISCORD:DEFAULT_CONFIG:END + // PLATFORM:LARK:DEFAULT_CONFIG:START + lark: { enabled: false, appId: '', appSecret: '' }, + // PLATFORM:LARK:DEFAULT_CONFIG:END + // PLATFORM:TELEGRAM:DEFAULT_CONFIG:START + telegram: { enabled: false, botToken: '' }, + // PLATFORM:TELEGRAM:DEFAULT_CONFIG:END + // PLATFORM:SLACK:DEFAULT_CONFIG:START + slack: { enabled: false, botToken: '', appToken: '' }, + // PLATFORM:SLACK:DEFAULT_CONFIG:END +} + +const DEFAULT_STATUS: Record = { + // PLATFORM:DINGTALK:DEFAULT_STATUS:START + dingtalk: { platform: 'dingtalk', status: 'disconnected', messagesProcessed: 0, activeSessions: 0 }, + // PLATFORM:DINGTALK:DEFAULT_STATUS:END + // PLATFORM:DISCORD:DEFAULT_STATUS:START + discord: { platform: 'discord', status: 'disconnected', messagesProcessed: 0, activeSessions: 0 }, + // PLATFORM:DISCORD:DEFAULT_STATUS:END + // PLATFORM:LARK:DEFAULT_STATUS:START + lark: { platform: 'lark', status: 'disconnected', messagesProcessed: 0, activeSessions: 0 }, + // PLATFORM:LARK:DEFAULT_STATUS:END + // PLATFORM:TELEGRAM:DEFAULT_STATUS:START + telegram: { platform: 'telegram', status: 'disconnected', messagesProcessed: 0, activeSessions: 0 }, + // PLATFORM:TELEGRAM:DEFAULT_STATUS:END + // PLATFORM:SLACK:DEFAULT_STATUS:START + slack: { platform: 'slack', status: 'disconnected', messagesProcessed: 0, activeSessions: 0 }, + // PLATFORM:SLACK:DEFAULT_STATUS:END +} + +function statusMeta(status: ConnectionStatus) { + if (status === 'connected') { + return { label: '已连接', dot: 'var(--success)' } + } + if (status === 'connecting') { + return { label: '连接中...', dot: 'var(--warning)' } + } + return { label: '断开连接', dot: 'var(--destructive)' } +} + +function formatDate(value?: string) { + if (!value) return '-' + const date = new Date(value) + if (Number.isNaN(date.getTime())) return value + return date.toLocaleString('zh-CN', { hour12: false }) +} + +function formatDetails(log: LogEntry) { + if (typeof log.details === 'string') return log.details + if (log.message) return log.message + if (log.details) return JSON.stringify(log.details) + return '-' +} + +function formatDuration(seconds?: number) { + if (!seconds || seconds <= 0) return '-' + const h = Math.floor(seconds / 3600) + const m = Math.floor((seconds % 3600) / 60) + const s = seconds % 60 + if (h > 0) return `${h}小时${m}分` + if (m > 0) return `${m}分${s}秒` + return `${s}秒` +} + +export default function RemoteControlPage() { + const [config, setConfig] = useState(DEFAULT_CONFIG) + const [status, setStatus] = useState>(DEFAULT_STATUS) + const [logs, setLogs] = useState([]) + const [agentInfo, setAgentInfo] = useState(null) + // PLATFORM:SHOW_SECRETS:START + const [showSecrets, setShowSecrets] = useState({ + // PLATFORM:DINGTALK:SHOW_SECRETS:START + dingtalkSecret: false, + // PLATFORM:DINGTALK:SHOW_SECRETS:END + // PLATFORM:DISCORD:SHOW_SECRETS:START + discordToken: false, + // PLATFORM:DISCORD:SHOW_SECRETS:END + // PLATFORM:LARK:SHOW_SECRETS:START + larkSecret: false, + // PLATFORM:LARK:SHOW_SECRETS:END + // PLATFORM:TELEGRAM:SHOW_SECRETS:START + telegramToken: false, + // PLATFORM:TELEGRAM:SHOW_SECRETS:END + // PLATFORM:SLACK:SHOW_SECRETS:START + slackBotToken: false, + slackAppToken: false, + // PLATFORM:SLACK:SHOW_SECRETS:END + }) + // PLATFORM:SHOW_SECRETS:END + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + // PLATFORM:TESTING:START + const [testing, setTesting] = useState>({ + // PLATFORM:DINGTALK:TESTING:START + dingtalk: false, + // PLATFORM:DINGTALK:TESTING:END + // PLATFORM:DISCORD:TESTING:START + discord: false, + // PLATFORM:DISCORD:TESTING:END + // PLATFORM:LARK:TESTING:START + lark: false, + // PLATFORM:LARK:TESTING:END + // PLATFORM:TELEGRAM:TESTING:START + telegram: false, + // PLATFORM:TELEGRAM:TESTING:END + // PLATFORM:SLACK:TESTING:START + slack: false, + // PLATFORM:SLACK:TESTING:END + }) + // PLATFORM:TESTING:END + const [refreshingLogs, setRefreshingLogs] = useState(false) + const [clearingLogs, setClearingLogs] = useState(false) + + const loadStatus = useCallback(async () => { + const response = await fetch('/api/remote-control/status', { cache: 'no-store' }) + if (!response.ok) { + throw new Error('状态加载失败') + } + const data = (await response.json()) as Partial> + setStatus({ + // PLATFORM:DINGTALK:LOAD_STATUS:START + dingtalk: { ...DEFAULT_STATUS.dingtalk, ...(data.dingtalk ?? {}) }, + // PLATFORM:DINGTALK:LOAD_STATUS:END + // PLATFORM:DISCORD:LOAD_STATUS:START + discord: { ...DEFAULT_STATUS.discord, ...(data.discord ?? {}) }, + // PLATFORM:DISCORD:LOAD_STATUS:END + // PLATFORM:LARK:LOAD_STATUS:START + lark: { ...DEFAULT_STATUS.lark, ...(data.lark ?? {}) }, + // PLATFORM:LARK:LOAD_STATUS:END + // PLATFORM:TELEGRAM:LOAD_STATUS:START + telegram: { ...DEFAULT_STATUS.telegram, ...(data.telegram ?? {}) }, + // PLATFORM:TELEGRAM:LOAD_STATUS:END + // PLATFORM:SLACK:LOAD_STATUS:START + slack: { ...DEFAULT_STATUS.slack, ...(data.slack ?? {}) }, + // PLATFORM:SLACK:LOAD_STATUS:END + }) + }, []) + + const loadLogs = useCallback(async () => { + const response = await fetch('/api/remote-control/logs?limit=50', { cache: 'no-store' }) + if (!response.ok) { + throw new Error('日志加载失败') + } + const data = (await response.json()) as LogsResponse + setLogs(Array.isArray(data.logs) ? data.logs : []) + }, []) + + const loadAgentInfo = useCallback(async () => { + const response = await fetch('/api/remote-control/agent-info', { cache: 'no-store' }) + if (!response.ok) { + throw new Error('Agent 信息加载失败') + } + const data = (await response.json()) as AgentInfoResponse + setAgentInfo(data) + }, []) + + const loadInitialData = useCallback(async () => { + setLoading(true) + try { + const configResponse = await fetch('/api/remote-control/config', { cache: 'no-store' }) + if (!configResponse.ok) { + throw new Error('配置加载失败') + } + const configData = (await configResponse.json()) as Partial + setConfig({ + // PLATFORM:DINGTALK:LOAD_INITIAL:START + dingtalk: { ...DEFAULT_CONFIG.dingtalk, ...(configData.dingtalk ?? {}) }, + // PLATFORM:DINGTALK:LOAD_INITIAL:END + // PLATFORM:DISCORD:LOAD_INITIAL:START + discord: { ...DEFAULT_CONFIG.discord, ...(configData.discord ?? {}) }, + // PLATFORM:DISCORD:LOAD_INITIAL:END + // PLATFORM:LARK:LOAD_INITIAL:START + lark: { ...DEFAULT_CONFIG.lark, ...(configData.lark ?? {}) }, + // PLATFORM:LARK:LOAD_INITIAL:END + // PLATFORM:TELEGRAM:LOAD_INITIAL:START + telegram: { ...DEFAULT_CONFIG.telegram, ...(configData.telegram ?? {}) }, + // PLATFORM:TELEGRAM:LOAD_INITIAL:END + // PLATFORM:SLACK:LOAD_INITIAL:START + slack: { ...DEFAULT_CONFIG.slack, ...(configData.slack ?? {}) }, + // PLATFORM:SLACK:LOAD_INITIAL:END + }) + await Promise.all([loadStatus(), loadLogs(), loadAgentInfo()]) + } catch { + toast.error('加载远程控制配置失败') + } finally { + setLoading(false) + } + }, [loadAgentInfo, loadLogs, loadStatus]) + + useEffect(() => { + void loadInitialData() + }, [loadInitialData]) + + const saveConfig = async () => { + setSaving(true) + try { + const response = await fetch('/api/remote-control/config', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(config), + }) + const data = (await response.json()) as { success?: boolean; message?: string; error?: string } + if (!response.ok || !data.success) { + throw new Error(data.error || '保存失败') + } + + toast.success(data.message || '配置已保存并生效') + + window.setTimeout(() => { + void Promise.all([loadStatus(), loadLogs(), loadAgentInfo()]) + }, 3000) + } catch (error) { + const message = error instanceof Error ? error.message : '保存失败' + toast.error(message) + } finally { + setSaving(false) + } + } + + const testConnection = async (platform: Platform) => { + setTesting(prev => ({ ...prev, [platform]: true })) + try { + const response = await fetch('/api/remote-control/test', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ platform }), + }) + + const data = (await response.json()) as { success?: boolean; message?: string; error?: string } + if (!response.ok || !data.success) { + throw new Error(data.error || data.message || '连接测试失败') + } + + // PLATFORM:TEST_CONNECTION_SUCCESS:START + const platformNames: Record = { + discord: 'Discord', + dingtalk: '钉钉', + lark: '飞书', + telegram: 'Telegram', + slack: 'Slack', + } + toast.success(`${platformNames[platform]}连接测试成功`) + // PLATFORM:TEST_CONNECTION_SUCCESS:END + await Promise.all([loadStatus(), loadLogs()]) + } catch (error) { + const message = error instanceof Error ? error.message : '连接测试失败' + toast.error(message) + await Promise.all([loadStatus(), loadLogs()]) + } finally { + setTesting(prev => ({ ...prev, [platform]: false })) + } + } + + const refreshLogs = async () => { + setRefreshingLogs(true) + try { + await loadLogs() + toast.success('日志已刷新') + } catch { + toast.error('刷新日志失败') + } finally { + setRefreshingLogs(false) + } + } + + const clearLogs = async () => { + setClearingLogs(true) + try { + const response = await fetch('/api/remote-control/logs', { method: 'DELETE' }) + if (!response.ok) { + throw new Error('清空失败') + } + setLogs([]) + toast.success('日志已清空') + } catch { + toast.error('清空日志失败') + } finally { + setClearingLogs(false) + } + } + + const mergedStats = useMemo(() => { + const totalMessages = agentInfo?.stats?.totalMessages ?? agentInfo?.totalMessages ?? 0 + const activeConnections = agentInfo?.stats?.activeConnections ?? agentInfo?.activeConnections ?? 0 + const avgResponseTime = agentInfo?.stats?.avgResponseTime ?? agentInfo?.averageResponseTime ?? 0 + return { totalMessages, activeConnections, avgResponseTime } + }, [agentInfo]) + + if (loading) { + return ( +
+
+ + 加载远程控制配置中... +
+
+ ) + } + + return ( +
+
+
+
+ + + 返回主页 + +

+ 远程控制配置 +

+
+ +
+ + {/* PLATFORM:DINGTALK:CARD:START */} + setConfig(prev => ({ ...prev, dingtalk: { ...prev.dingtalk, enabled } }))} + fields={[ + { + label: 'Client ID', + type: 'text', + value: config.dingtalk.clientId, + onChange: value => setConfig(prev => ({ ...prev, dingtalk: { ...prev.dingtalk, clientId: value } })), + placeholder: '请输入钉钉 Client ID', + }, + { + label: 'Client Secret', + type: showSecrets.dingtalkSecret ? 'text' : 'password', + value: config.dingtalk.clientSecret, + onChange: value => setConfig(prev => ({ ...prev, dingtalk: { ...prev.dingtalk, clientSecret: value } })), + placeholder: '请输入钉钉 Client Secret', + secretVisible: showSecrets.dingtalkSecret, + onToggleSecret: () => setShowSecrets(prev => ({ ...prev, dingtalkSecret: !prev.dingtalkSecret })), + }, + ]} + onTest={() => void testConnection('dingtalk')} + testing={testing.dingtalk} + docUrl="https://open.dingtalk.com/document/robots/custom-robot-access" + /> + {/* PLATFORM:DINGTALK:CARD:END */} + + {/* PLATFORM:DISCORD:CARD:START */} + setConfig(prev => ({ ...prev, discord: { ...prev.discord, enabled } }))} + fields={[ + { + label: 'Bot Token', + type: showSecrets.discordToken ? 'text' : 'password', + value: config.discord.botToken, + onChange: value => setConfig(prev => ({ ...prev, discord: { ...prev.discord, botToken: value } })), + placeholder: '请输入 Discord Bot Token', + secretVisible: showSecrets.discordToken, + onToggleSecret: () => setShowSecrets(prev => ({ ...prev, discordToken: !prev.discordToken })), + }, + ]} + onTest={() => void testConnection('discord')} + testing={testing.discord} + docUrl="https://docs.discord.com/developers/topics/oauth2#bots" + /> + {/* PLATFORM:DISCORD:CARD:END */} + + {/* PLATFORM:LARK:CARD:START */} + setConfig(prev => ({ ...prev, lark: { ...prev.lark, enabled } }))} + fields={[ + { + label: 'App ID', + type: 'text', + value: config.lark.appId, + onChange: value => setConfig(prev => ({ ...prev, lark: { ...prev.lark, appId: value } })), + placeholder: '请输入飞书 App ID', + }, + { + label: 'App Secret', + type: showSecrets.larkSecret ? 'text' : 'password', + value: config.lark.appSecret, + onChange: value => setConfig(prev => ({ ...prev, lark: { ...prev.lark, appSecret: value } })), + placeholder: '请输入飞书 App Secret', + secretVisible: showSecrets.larkSecret, + onToggleSecret: () => setShowSecrets(prev => ({ ...prev, larkSecret: !prev.larkSecret })), + }, + ]} + onTest={() => void testConnection('lark')} + testing={testing.lark} + docUrl="https://open.feishu.cn/document/home/introduction-to-custom-app-development/self-built-application-development-process" + /> + {/* PLATFORM:LARK:CARD:END */} + + {/* PLATFORM:TELEGRAM:CARD:START */} + setConfig(prev => ({ ...prev, telegram: { ...prev.telegram, enabled } }))} + fields={[ + { + label: 'Bot Token', + type: showSecrets.telegramToken ? 'text' : 'password', + value: config.telegram.botToken, + onChange: value => setConfig(prev => ({ ...prev, telegram: { ...prev.telegram, botToken: value } })), + placeholder: '请输入 Telegram Bot Token (通过 @BotFather 获取)', + secretVisible: showSecrets.telegramToken, + onToggleSecret: () => setShowSecrets(prev => ({ ...prev, telegramToken: !prev.telegramToken })), + }, + ]} + onTest={() => void testConnection('telegram')} + testing={testing.telegram} + docUrl="https://core.telegram.org/bots#how-do-i-create-a-bot" + /> + {/* PLATFORM:TELEGRAM:CARD:END */} + + {/* PLATFORM:SLACK:CARD:START */} + setConfig(prev => ({ ...prev, slack: { ...prev.slack, enabled } }))} + fields={[ + { + label: 'Bot Token (xoxb-)', + type: showSecrets.slackBotToken ? 'text' : 'password', + value: config.slack.botToken, + onChange: value => setConfig(prev => ({ ...prev, slack: { ...prev.slack, botToken: value } })), + placeholder: '请输入 Slack Bot Token (xoxb-...)', + secretVisible: showSecrets.slackBotToken, + onToggleSecret: () => setShowSecrets(prev => ({ ...prev, slackBotToken: !prev.slackBotToken })), + }, + { + label: 'App-Level Token (xapp-)', + type: showSecrets.slackAppToken ? 'text' : 'password', + value: config.slack.appToken, + onChange: value => setConfig(prev => ({ ...prev, slack: { ...prev.slack, appToken: value } })), + placeholder: '请输入 Slack App Token (xapp-...)', + secretVisible: showSecrets.slackAppToken, + onToggleSecret: () => setShowSecrets(prev => ({ ...prev, slackAppToken: !prev.slackAppToken })), + }, + ]} + onTest={() => void testConnection('slack')} + testing={testing.slack} + docUrl="https://api.slack.com/start/quickstart" + /> + {/* PLATFORM:SLACK:CARD:END */} + +
+
+

Bot 事件日志(最近 50 条)

+
+ + +
+
+ +
+ {logs.length === 0 ? ( +
暂无日志数据
+ ) : ( +
+ {logs.map((log, index) => ( +
+ {formatDate(log.timestamp)} + {log.platform} + {log.eventType} + + + + {formatDetails(log)} +
+ ))} +
+ )} +
+
+ +
+

Nova Agent 信息(只读)

+
+
+
Agent ID: {agentInfo?.agentId || '-'}
+
Base URL: {agentInfo?.baseUrl || '-'}
+
+
+
活跃连接数: {mergedStats.activeConnections}
+
消息总数: {mergedStats.totalMessages}
+
平均响应时间: {mergedStats.avgResponseTime} ms
+
+
+
+ +
+ +
+
+
+ ) +} + +interface PlatformField { + label: string + type: 'text' | 'password' + value: string + placeholder: string + onChange: (value: string) => void + secretVisible?: boolean + onToggleSecret?: () => void +} + +interface PlatformCardProps { + title: string + enabled: boolean + onEnabledChange: (enabled: boolean) => void + status: PlatformStatus + fields: PlatformField[] + onTest: () => void + testing: boolean + docUrl: string +} + +function PlatformCard({ title, enabled, onEnabledChange, status, fields, onTest, testing, docUrl }: PlatformCardProps) { + const meta = statusMeta(status.status) + + return ( +
+
+
+

{title}

+ + + +
+
+ +
+ + {meta.label} +
+
+
+ +
+ {fields.map(field => ( + + ))} +
+ +
+
+ 最后连接时间: {formatDate(status.lastConnectedAt)} + 运行时长: {formatDuration(status.uptime)} + 已处理消息数: {status.messagesProcessed ?? 0} + 活跃会话数: {status.activeSessions ?? 0} +
+ +
+ {status.error && ( +
+ + {status.error} +
+ )} + {!status.error && status.status === 'connected' && ( +
+ + 连接状态正常 +
+ )} +
+ ) +} + +function SeverityTag({ severity }: { severity: LogEntry['severity'] }) { + if (severity === 'error') { + return error + } + if (severity === 'warning') { + return warning + } + return info +} diff --git a/app/share/page.tsx b/app/share/page.tsx new file mode 100644 index 0000000..3355ba8 --- /dev/null +++ b/app/share/page.tsx @@ -0,0 +1,32 @@ +'use client' + +import { NovaChat } from '@/components/nova-sdk/nova-chat' +import { NovaState, useBuildConversationConnect } from '@/components/nova-sdk/hooks/useBuildConversationConnect' +import { Loader2 } from 'lucide-react' + +export default function NovaChatPage() { + const { chatEnabled, agentId, conversationId, platformConfig } = useBuildConversationConnect() + + if (chatEnabled === NovaState.Failed) { + return ( +
+

Chat 不可用,请检查项目 .env 配置

+
+ ) + } + + if (!agentId || !conversationId || !platformConfig) { + return ( +
+ +

正在连接中...

+
+ ) + } + + return ( +
+ +
+ ) +} diff --git a/components/AgentationGuard.tsx b/components/AgentationGuard.tsx new file mode 100644 index 0000000..1fed7cb --- /dev/null +++ b/components/AgentationGuard.tsx @@ -0,0 +1,12 @@ +'use client' + +import { Agentation } from 'agentation' + +export function AgentationGuard() { + if (process.env.NODE_ENV !== 'development') { + return null + } + + return +} + diff --git a/components/base/color-picker/index.tsx b/components/base/color-picker/index.tsx new file mode 100644 index 0000000..2aba677 --- /dev/null +++ b/components/base/color-picker/index.tsx @@ -0,0 +1,87 @@ +'use client' + +import * as React from 'react' +import { HexColorPicker } from 'react-colorful' +import { cn } from '@/utils/cn' +import { Input } from '@/components/ui/input' + +interface ColorPickerProps { + color: string + onChange: (color: string) => void + presets?: string[] + className?: string +} + +const defaultPresets = [ + '#171412', + '#2E241C', + '#8A6742', + '#B79267', + '#D0B08C', + '#E8DFD1', + '#FFFDF8', + '#8F7CFF', + '#45D4FF', + '#FF78B8', + '#48D7C2', + '#FFB86B', +] + +export function ColorPicker({ + color, + onChange, + presets = defaultPresets, + className, +}: ColorPickerProps) { + const [inputValue, setInputValue] = React.useState(color) + + React.useEffect(() => { + setInputValue(color) + }, [color]) + + const handleInputChange = (e: React.ChangeEvent) => { + const val = e.target.value + setInputValue(val) + if (/^#[0-9A-F]{3,6}$/i.test(val)) { + onChange(val) + } + } + + return ( +
+ +
+ + +
+ {presets.length > 0 && ( +
+ {presets.map((p) => ( +
+ )} +
+ ) +} diff --git a/components/html-editor/README.md b/components/html-editor/README.md new file mode 100644 index 0000000..2b6e62d --- /dev/null +++ b/components/html-editor/README.md @@ -0,0 +1,292 @@ +# TaskArtifactHtml + +HTML 产物渲染与编辑组件,支持 **文档模式(document)** 和 **网页模式(web)** 两种渲染方式。内容通过 iframe 沙箱加载,编辑态下提供所见即所得的工具栏。 + +--- + +## 快速开始 + +```tsx +import { TaskArtifactHtml } from "@/components/nova-sdk/html-editor"; + + { + // state.canUndo / state.canRedo / state.undo() / state.redo() + console.log(state); + }} +/>; +``` + +> **前置依赖**:组件内部通过 `useNovaKit()` 获取 API 实例来加载远程内容,因此使用前需确保外层已包裹 ``。 + +--- + +## Props + +| 属性 | 类型 | 必填 | 说明 | +| --------------- | ------------------------------------ | ---- | ----------------------------------------------------------------- | +| `taskId` | `string` | ✅ | 任务 ID,用于关联产物与任务,编辑时也用于保存接口 | +| `editable` | `boolean` | ❌ | 是否开启编辑模式。`false` 为只读预览,`true` 显示编辑工具栏 | +| `type` | `'document' \| 'web'` | ✅ | 渲染模式。`document` 适合富文本文档编辑,`web` 适合网页类产物编辑 | +| `taskArtifact` | `TaskArtifact` | ✅ | 产物数据对象,包含文件路径等信息 | +| `onStateChange` | `(state: ArtifactEditState) => void` | ❌ | 编辑状态变化回调,每次 undo/redo 历史变化时触发 | + +### TaskArtifact 类型定义 + +```ts +interface TaskArtifact { + path: string; // 产物文件路径 + file_name: string; // 文件名 + file_type: string; // 文件 MIME 类型 + last_modified?: number; // 最后修改时间戳 + url?: string; // 可选的直接访问 URL + content?: string; // 可选的内联内容 + task_id?: string; // 关联的任务 ID + event_type?: string; // 工具调用事件类型 + tool_name?: string; // 工具名称 + tool_input?: unknown; // 工具输入 + tool_output?: unknown; // 工具输出 +} +``` + +### ArtifactEditState 类型定义 + +当传入 `onStateChange` 后,编辑器内部的历史记录发生变化时会通过该回调通知父组件。父组件可据此实现外部的撤销/重做按钮。 + +```ts +interface ArtifactEditState { + canUndo: boolean; // 是否可以撤销 + canRedo: boolean; // 是否可以重做 + undo: () => void; // 执行撤销 + redo: () => void; // 执行重做 +} +``` + +--- + +## 两种模式对比 + +| 特性 | `document` 模式 | `web` 模式 | +| -------- | ------------------------------------------ | ------------------------------------------------------ | +| 适用场景 | 富文本文档(文章、报告等) | 网页类产物(落地页、网站等) | +| 编辑方式 | 全局 `contentEditable`,支持文本选区工具栏 | 元素级选取与属性编辑 | +| 工具栏 | `toolbar-doc` — 基于文本选区定位 | `toolbar-web` — 基于选中元素定位,更丰富的样式编辑能力 | +| 内部组件 | `TaskHtmlDoc` | `TaskHtmlWeb` | + +--- + +## 使用示例 + +### 只读预览 + +```tsx +// 简单预览,不显示任何编辑工具栏 + +``` + +### 文档编辑模式 + +```tsx +// 开启编辑,用户可以直接在文档中选中文本进行格式编辑 + +``` + +### 网页编辑模式 + +```tsx +// 开启编辑,用户可以点选网页中的元素进行样式调整 + +``` + +### 配合外部撤销/重做按钮 + +```tsx +const [editState, setEditState] = useState(null); + +<> +
+ + +
+ + +; +``` + +--- + +## 内部架构 + +``` +TaskArtifactHtml (入口) +├── type="document" → TaskHtmlDoc +│ ├── editable=false → Html (只读 iframe) +│ └── editable=true +│ └── PPTEditProvider (编辑状态上下文) +│ ├── PPTEditToolBar (toolbar-doc, 文本选区工具栏) +│ └── HtmlWithEditMode (基础编辑层) +│ ├── useIframeMode → HTMLEditor 实例 +│ ├── useDiff → 同步编辑状态到 Context +│ └── Html (iframe 渲染) +│ +└── type="web" → TaskHtmlWeb + ├── editable=false → Html (只读 iframe) + └── editable=true + └── PPTEditProvider + ├── PPTEditToolBar (toolbar-web, 元素工具栏) + └── HtmlWithEditMode (isDoc=false) +``` + +### 核心模块说明 + +#### `Html`(`components/html-render/task-html.tsx`) + +底层 iframe 渲染组件,接受 HTML 字符串作为 `srcDoc` 注入 iframe。配置了 `sandbox="allow-same-origin allow-scripts allow-popups allow-popups-to-escape-sandbox"`,并自动拦截 iframe 内的 `_blank` 链接和 `window.open` 调用,转发至父窗口打开。 + +#### `HtmlWithEditMode`(`mode/baseEdit.tsx`) + +编辑模式的基础封装层,负责: + +- 调用 `useIframeMode` 初始化 `HTMLEditor` 编辑器实例 +- 调用 `useDiff` 将编辑器状态同步到 `PPTEditContext` +- 监听历史记录变化,通过 `onStateChange` 向上传递 `ArtifactEditState`(canUndo/canRedo/undo/redo) +- 内容变更时自动防抖保存(1 秒),通过 `saveMarkdown` 接口持久化到服务端 + +#### `PPTEditProvider`(`context/index.tsx`) + +编辑状态上下文 Provider,维护: + +- `state` — 当前 `useIframeMode` 的返回值(编辑器实例、选中元素、位置等) +- `setState` — 由 `useDiff` hook 调用,将编辑状态同步至上下文 +- `originalSlide` — 原始幻灯片数据的引用(PPT 场景) + +工具栏组件通过 `usePPTEditContext()` 消费该上下文,获取编辑器实例和选中元素信息来渲染对应的编辑工具。 + +--- + +## Hooks + +### `useLoadContent(taskArtifact: TaskArtifact): string` + +通过 `useNovaKit()` 提供的 API 加载产物的远程 HTML 内容。先调用 `api.getArtifactUrl()` 获取签名 URL,再 `fetch` 获取文本内容。 + +### `useIframeMode(id, containerRef, options?, scale?): UseInjectModeReturn` + +核心编辑 hook,负责: + +- 创建 `HTMLEditor` 实例并注入到 iframe 或普通 DOM 容器 +- 管理元素选中状态和位置计算 +- 管理 undo/redo 历史记录 +- 绑定 `Cmd+Z` / `Cmd+Shift+Z` 快捷键 + +返回值: + +```ts +interface UseInjectModeReturn { + editor: HTMLEditor | null; // 编辑器实例(ref 值) + editorIns: HTMLEditor | null; // 编辑器实例(state 值,触发重渲染) + selectedElement: HTMLElement | null; // 当前选中的 DOM 元素 + position: Position | null; // 选中元素在 iframe 内的位置 + tipPosition: Position | null; // 经过缩放换算后用于定位工具栏的位置 + injectScript: (target: HTMLElement) => Promise; // 手动注入编辑器 + canUndo: boolean; + canRedo: boolean; + clearHistory: () => void; + loadSuccess: boolean; // iframe 内容是否加载/注入成功 +} +``` + +### `useDiff(useIframeReturn, isDoc?)` + +区分 document / web 两种模式的差异逻辑,将 `useIframeMode` 的状态同步到 `PPTEditContext`: + +- **document 模式**:使用 `useToolPosition` 基于文本选区末尾计算工具栏位置 +- **web 模式**:基于选中元素的 `getBoundingClientRect` 定位工具栏;失焦后清除状态 + +### `useToolPosition(editor, isDoc): Position | null` + +仅在 document 模式下生效。监听编辑器内的 `mousedown` / `mouseup` / `scroll` / `resize` 事件,根据当前文本选区(`Selection`)末尾位置计算工具栏坐标。 + +--- + +## 服务端接口 + +### `saveMarkdown`(`server/index.ts`) + +编辑模式下内容变更会自动防抖(1 秒)调用此函数保存到服务端: + +```ts +POST / v1 / super_agent / chat / write_file; +Body: { + task_id: string; + path: string; + content: string; +} +``` + +--- + +## 目录结构 + +``` +html-editor/ +├── index.tsx # 入口组件 TaskArtifactHtml +├── README.md # 本文档 +├── types/ +│ └── index.ts # ArtifactEditState 等类型定义 +├── server/ +│ └── index.ts # saveMarkdown 保存接口 +├── mode/ +│ ├── baseEdit.tsx # 编辑模式基础层 HtmlWithEditMode +│ ├── html-doc.tsx # 文档模式 TaskHtmlDoc +│ └── html-web.tsx # 网页模式 TaskHtmlWeb +├── context/ +│ └── index.tsx # PPTEditProvider / usePPTEditContext +├── hooks/ +│ ├── useDiff.ts # doc/web 差异逻辑 +│ ├── useIframeMode.ts # 核心编辑器 hook +│ ├── useLoadContent.ts # 远程内容加载 +│ └── useToolPostion.ts # 文本选区工具栏定位 +├── components/ +│ ├── html-render/ # iframe 渲染组件 +│ ├── toolbar-doc/ # 文档模式工具栏 +│ └── toolbar-web/ # 网页模式工具栏 +├── lib/ # HTMLEditor 核心库 +│ ├── core/ # 编辑器引擎、历史记录管理器 +│ ├── types/ # 内部类型定义 +│ └── config/ # 编辑器配置 +└── assets/ # 图标等静态资源 +``` diff --git a/components/html-editor/assets/images/align-center.svg b/components/html-editor/assets/images/align-center.svg new file mode 100644 index 0000000..d6a6346 --- /dev/null +++ b/components/html-editor/assets/images/align-center.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/components/html-editor/assets/images/align-left.svg b/components/html-editor/assets/images/align-left.svg new file mode 100644 index 0000000..5ba453c --- /dev/null +++ b/components/html-editor/assets/images/align-left.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/components/html-editor/assets/images/align-right.svg b/components/html-editor/assets/images/align-right.svg new file mode 100644 index 0000000..84ce842 --- /dev/null +++ b/components/html-editor/assets/images/align-right.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/components/html-editor/assets/images/blod.svg b/components/html-editor/assets/images/blod.svg new file mode 100644 index 0000000..7625e13 --- /dev/null +++ b/components/html-editor/assets/images/blod.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/components/html-editor/assets/images/delete.svg b/components/html-editor/assets/images/delete.svg new file mode 100644 index 0000000..b511e5e --- /dev/null +++ b/components/html-editor/assets/images/delete.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/components/html-editor/assets/images/dropdown.svg b/components/html-editor/assets/images/dropdown.svg new file mode 100644 index 0000000..b97253a --- /dev/null +++ b/components/html-editor/assets/images/dropdown.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/components/html-editor/assets/images/duplicate.svg b/components/html-editor/assets/images/duplicate.svg new file mode 100644 index 0000000..6fedc47 --- /dev/null +++ b/components/html-editor/assets/images/duplicate.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/components/html-editor/assets/images/image-replace.svg b/components/html-editor/assets/images/image-replace.svg new file mode 100644 index 0000000..2dc0a69 --- /dev/null +++ b/components/html-editor/assets/images/image-replace.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/components/html-editor/assets/images/italic.svg b/components/html-editor/assets/images/italic.svg new file mode 100644 index 0000000..a521b86 --- /dev/null +++ b/components/html-editor/assets/images/italic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/components/html-editor/assets/images/underline.svg b/components/html-editor/assets/images/underline.svg new file mode 100644 index 0000000..269cc78 --- /dev/null +++ b/components/html-editor/assets/images/underline.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/components/html-editor/components/html-render/task-html.tsx b/components/html-editor/components/html-render/task-html.tsx new file mode 100644 index 0000000..93fc576 --- /dev/null +++ b/components/html-editor/components/html-render/task-html.tsx @@ -0,0 +1,126 @@ +import React from 'react'; +import { cn } from '@/utils/cn'; + +interface HtmlProps { + className?: string; + content?: string; +} + +// 注入脚本,拦截 iframe 中的 _blank 链接和 window.open +function injectInterceptScript(iframeWindow: Window) { + try { + // 检查是否可以访问 iframe 的 document(避免跨域错误) + const iframeDocument = iframeWindow.document; + if (!iframeDocument) { + return; + } + + // 保存父窗口的 window.open 引用 + const parentWindowOpen = window.open.bind(window); + + // 重写 iframe 内的 window.open + iframeWindow.open = function (url, target, features) { + return parentWindowOpen(url, target, features); + }; + + // 拦截所有 标签的点击事件 + iframeDocument.addEventListener( + 'click', + (e: MouseEvent) => { + const target = e.target as HTMLElement; + const anchor = target.closest('a'); + + if (anchor && anchor.target === '_blank') { + e.preventDefault(); + const href = anchor.href; + if (href) { + parentWindowOpen(href, '_blank'); + } + } + }, + true, + ); + + // 监听动态添加的元素 + const observer = new MutationObserver(() => { + // 重新绑定 window.open (防止被覆盖) + if (iframeWindow.open !== parentWindowOpen) { + iframeWindow.open = function (url, target, features) { + return parentWindowOpen(url, target, features); + }; + } + }); + + observer.observe(iframeDocument.documentElement, { + childList: true, + subtree: true, + }); + } catch { + // 跨域或沙箱限制,无法注入脚本,静默失败 + // 这种情况下需要依赖 sandbox 属性中的 allow-popups-to-escape-sandbox + } +} + +export const Html = React.forwardRef( + ( + { className, content }: HtmlProps, + _ref: React.ForwardedRef, + ) => { + // 当 iframe 加载完成后注入拦截脚本 + const handleIframeLoad = React.useCallback( + (e: React.SyntheticEvent) => { + const iframe = e.currentTarget; + if (iframe?.contentWindow) { + injectInterceptScript(iframe.contentWindow); + } + }, + [], + ); + + // if (content) { + // // 在 content 中添加 base 标签,确保所有链接在 iframe 内部打开 + // const contentWithBase = content.includes("") + // ? content.replace("", '') + // : content; + + // return ( + //