diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000000000000000000000000000000000000..93372d511cf0a46c3b06bc2353d0b1ab700fe3fa --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,5 @@ +{ + "enabledPlugins": { + "ui-ux-pro-max@ui-ux-pro-max-skill": true + } +} diff --git a/.claude/settings.local.json b/.claude/settings.local.json index ed7c7e9316050a0136ce8a45004eed0ee4c58929..3a5b507858a94ce385073f198676bb0c6644efd7 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,6 +1,23 @@ { "permissions": { "allow": [ + "Bash(pnpm dev:conversation)", + "mcp__ide__getDiagnostics", + "Bash(npx sass:*)", + "Bash(python3:*)", + "Bash(python:*)", + "Bash(grep -r 11 D:/IdeaProjects/farris-x/packages/conversation/src --include=*.vue --include=*.tsx --include=*.ts --include=*.jsx)", + "Bash(grep -r 在线 D:/IdeaProjects/farris-x/packages/conversation --include=*.ts --include=*.tsx --include=*.vue --include=*.scss)", + "Bash(grep -r 在线 D:/IdeaProjects/farris-x/packages/conversation/demo --include=*.ts --include=*.tsx --include=*.vue --include=*.scss)", + "Bash(grep -rl 当前 D:/IdeaProjects/farris-x --include=*.ts --include=*.tsx --include=*.vue --include=*.scss)", + "Bash(grep -r 记录 D:/IdeaProjects/farris-x/packages/conversation/src --include=*.ts --include=*.tsx --include=*.vue --include=*.scss)", + "Bash(grep -r 当前会话 D:/IdeaProjects/farris-x --include=*.ts --include=*.tsx --include=*.vue --include=*.js --include=*.jsx)", + "Bash(grep -r 当前会话 D:/IdeaProjects/farris-vue --include=*.ts --include=*.tsx --include=*.vue --include=*.js --include=*.jsx)", + "Bash(grep -r 当前会话 D:/IdeaProjects/farris-vue/packages/ui-vue --include=*.ts --include=*.tsx --include=*.vue --include=*.js --include=*.jsx)", + "Bash(dir \"D:\\\\IdeaProjects\\\\farris-x\\\\截图.png\")", + "Bash(npx tsc:*)", + "Bash(find /d/IdeaProjects/farris-x/MySpringBootProject -name *.tsx -o -name *.ts)", + "Bash(npm run:*)" "Bash(pnpm:*)", "Bash(find /Users/sagi/source/ubml/farris-x/src -name *.ts -type f)", "Bash(find /Users/sagi/source/ubml/farris-x -name *.ts -path */database/models/*)", diff --git a/.env.development b/.env.development new file mode 100644 index 0000000000000000000000000000000000000000..0e5dc37d2cdd4ebffe661678063c20b1f1b9dc51 --- /dev/null +++ b/.env.development @@ -0,0 +1,2 @@ +# Spring Boot API 地址 +VITE_API_BASE=http://localhost:8080/api diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..35410cacdc5e87f985c93a96520f5e11a5c822e4 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# 默认忽略的文件 +/shelf/ +/workspace.xml +# 基于编辑器的 HTTP 客户端请求 +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/farris-x.iml b/.idea/farris-x.iml new file mode 100644 index 0000000000000000000000000000000000000000..d6ebd4805981b8400db3e3291c74a743fef9a824 --- /dev/null +++ b/.idea/farris-x.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000000000000000000000000000000000000..8d66637cb9fd38ded17c4988b9f70dd425cf2e3c --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000000000000000000000000000000000000..f5bd2dfe7956dc3d496675b22a950390fa8f9f88 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000000000000000000000000000000000000..222f42c3fa2cd4e57df3d6e34eb5da2ab0667af8 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000000000000000000000000000000000000..35eb1ddfbbc029bcab630581847471d7f238ec53 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/docs/superpowers/specs/2026-03-31-springboot-farris-integration-plan.md b/docs/superpowers/specs/2026-03-31-springboot-farris-integration-plan.md new file mode 100644 index 0000000000000000000000000000000000000000..70bb13701f06692bf81e39721865b044389cedc5 --- /dev/null +++ b/docs/superpowers/specs/2026-03-31-springboot-farris-integration-plan.md @@ -0,0 +1,322 @@ +# Spring Boot 项目与 farris-x 前端融合计划 + +## 1. 项目背景 + +### 1.1 目标 +将 Spring Boot 项目(AI Hub)的前端页面融合到 farris-x 项目中: +- 技术栈:使用 farris-x 现有的 React 技术栈 +- 后端接口:直接调用 Spring Boot 项目的 `/api/*` 接口(Spring Boot 项目独立运行) +- 逻辑对齐:确保 farris-x 中各页面的业务逻辑与 Spring Boot 原页面一致 + +### 1.2 已有对照关系 + +| Spring Boot 页面 | farris-x 现有实现 | 本次工作 | +|---|---|---| +| `index.html`(专家中心首页) | `renderAgentHub()` | 调整逻辑,对接 API | +| `detail.html`(专家详情) | `renderAgentDetail()` | 调整逻辑 + 实现版本历史 | +| `chat.html`(聊天页) | conversation 主组件 | 复用已有功能,对接 API | +| `create.html`(创建助手) | ❌ 不存在 | **新增** | +| `my.html`(我的助手) | ❌ 不存在 | **新增** | +| `skill-market.js`(技能市场) | ❌ 不存在 | **新增** | + +### 1.3 不实现的功能 +- 编辑助手功能(不实现) +- Spring Boot 的富文本编辑器(复用 farris-x 现有编辑器) + +--- + +## 2. 数据字段映射 + +由于 Spring Boot 与 farris-x 的字段名不同,需在 farris-x 侧做字段映射。 + +### 2.1 API 响应 → ChatAgent 类型 + +| Spring Boot API 字段 | farris-x ChatAgent 字段 | 说明 | +|---|---|---| +| `id` | `id` | ✅ 直接使用 | +| `name` 或 `title` | `name` | 统一用 `name` | +| `title` | `title` | 专家职称 | +| `category`(分类名) | `category` | ✅ 直接使用 | +| `description` | `description` | ✅ 直接使用 | +| `avatarUrl` | `avatar` | 字段名不同,需映射 | +| `views` | `views` | ✅ 直接使用 | +| `likeCount` | `likes` | 字段名不同,需映射 | +| `forkCount` | `forkCount` | ✅ 直接使用 | +| `author` | `creator` | 字段名不同,需映射 | +| `createTime` | `createdDate` | 字段名不同,需映射 | +| `systemPrompt` | `systemPrompt` | ✅ 直接使用 | +| `skills`(JSON 数组) | `skills: string[]` | 解析 JSON 后直接使用 | +| `demoConversations`(JSON) | 展示用,不存模型 | 解析后展示演示对话 | +| `price` | 用于展示,不存模型 | 价格展示 | +| `status` | 用于展示,不存模型 | 上下架状态 | + +### 2.2 内置技能映射 + +| Spring Boot API 字段 | farris-x AgentBuiltinSkill 字段 | +|---|---| +| `name` | `name` | +| `tag` | `tag` | +| `description` | `description` | +| `icon` | `icon` | + +### 2.3 版本历史映射 + +| Spring Boot API 字段 | 说明 | +|---|---| +| `version` | 版本号,如 v1.0.0 | +| `description` | 版本描述 | +| `createTime` | 创建时间 | + +--- + +## 3. 页面改造计划 + +### 3.1 专家中心(AgentHub) + +**对应文件**:`conversation.component.tsx` 中的 `renderAgentHub()` + +**API 对接**: +- `GET /api/agents/categories` → 加载分类导航 +- `GET /api/agents/list?page=1&size=100` → 加载专家列表 + +**逻辑调整**: +1. 替换 mock 数据为真实 API 调用 +2. 分类选择:点击分类 → 调用 `/api/agents/list?categoryId={id}` +3. 搜索功能:输入关键字 → 调用 `/api/agents/list?keyword={keyword}`(300ms 防抖) +4. 卡片点击 → 打开详情面板(不变) + +**字段映射**:应用上述字段映射规则 + +### 3.2 专家详情(AgentDetail) + +**对应文件**:`conversation.component.tsx` 中的 `renderAgentDetail()` + +**API 对接**: +- `GET /api/agents/detail/{id}` → 加载专家详情 +- `GET /api/agents/{id}/builtin-skills` → 加载内置技能 +- `GET /api/agents/{id}/versions` → **加载版本历史(新增)** +- `GET /api/agents/{id}/similar` → 加载相似助理 + +**Tab 展示**: +- 概览:能力说明 + 演示对话 +- 简介:详细介绍 +- 能力:内置技能列表 +- 版本历史:**新增 Tab**,展示版本列表 +- 相似助理:相似专家卡片列表 + +**版本历史 Tab 实现**: +- 布局:版本badge + 版本描述 + 日期 +- 最新版本显示 "NEW" 标签 + +**"派生聊天"按钮**:点击 → `POST /api/conversation/create` → 跳转到聊天视图 + +### 3.3 聊天页(Chat) + +**对应文件**:`conversation.component.tsx` + +**API 对接**: +- `GET /api/models/list` → 加载模型列表(用于模型选择下拉框) +- `GET /api/conversation/{sessionId}/messages` → 加载历史消息 +- `POST /api/chat/send` → 发送消息 +- `POST /api/files/upload` → 上传文件 + +**复用已有功能**: +- 富文本编辑器(保留) +- 技能标签插入(保留) +- 助手选择(保留,但可能隐藏因为是单专家聊天) +- 深度思考按钮(保留) +- 辅助工具(保留) + +**新增/调整**: +- 模型选择下拉框:从 `/api/models/list` 加载,显示模型名 +- 联网开关、记忆开关(作为运行时配置传递给 `/api/chat/send`) +- 文件上传:`POST /api/files/upload`,上传后显示文件名 +- 记忆级别选择(高/中/低) + +### 3.4 创建助手(Create Agent) + +**新增视图**:`renderCreateAgent()` + +**API 对接**: +- `GET /api/agents/categories` → 加载分类下拉框 +- `POST /api/agents/create` → 提交创建 + +**表单字段**: +1. **基本信息** + - 头像上传(Base64 预览) + - 名称(必填) + - 分类(必填,下拉框) + - 简短描述(必填) + - 详细介绍(选填) + +2. **AI 配置** + - 系统提示词(必填) + +3. **技能配置** + - 已选技能标签展示 + - 点击「添加技能」→ 打开技能市场弹窗 + +4. **演示对话** + - 动态添加 user/assistant 对话项 + - 每项含角色标签 + 文本域 + - 支持删除 + +5. **发布设置** + - 价格(默认 0) + - 状态(下架/上架) + +**提交逻辑**: +- 表单验证 → 序列化数据(含字段映射)→ POST /api/agents/create +- 创建成功 → 弹出提示 → 刷新专家列表 + +**UI 布局**:全屏覆盖聊天内容区,顶部有返回按钮 + +### 3.5 我的助手(My Agents) + +**新增视图**:`renderMyAgents()` + +**API 对接**: +- `GET /api/agents/my?author={author}` → 加载我的助手列表 +- `DELETE /api/agents/{id}` → 删除助手 + +**UI 展示**: +- 助手卡片列表(头像、名称、描述、创建时间、状态标签) +- 「编辑」和「删除」按钮(注:编辑功能本次不实现,按钮先隐藏或显示后提示「功能开发中」) +- 顶部「创建助手」按钮 → 跳转创建助手视图 + +**空状态**:无助手时显示空状态提示 + 创建按钮 + +### 3.6 技能市场弹窗(Skill Market Modal) + +**新增组件**:`renderSkillMarketModal()` + +**数据来源**:使用 Spring Boot 项目中的 Mock 数据(`MySpringBootProject/static/skill-market.js` 中的 `SKILLS_DATA`),或直接硬编码到 farris-x 中 + +**功能**: +- 分类筛选(全部/办公提效/学术研究/编程开发/生活助手) +- 关键字搜索 +- 多选支持 +- 已选技能显示为标签,可移除 +- 确认/取消按钮 + +--- + +## 4. 视图切换逻辑 + +在 `conversation.component.tsx` 中通过状态变量控制视图: + +```typescript +type ViewState = 'chat' | 'agentHub' | 'createAgent' | 'myAgents'; + +const currentView = ref('chat'); + +// 切换方法 +function showAgentHub() { currentView.value = 'agentHub'; } +function showCreateAgent() { currentView.value = 'createAgent'; } +function showMyAgents() { currentView.value = 'myAgents'; } +function showChat() { currentView.value = 'chat'; } +``` + +**导航入口**: +- 左侧导航面板:「召唤专家」→ 专家中心 +- 专家中心内:「创建助手」→ 创建助手视图 +- 左侧导航面板:「我的助手」→ 我的助手视图 +- 详情面板:「派生聊天」→ 聊天视图 + +--- + +## 5. 实施步骤 + +### Phase 1:字段映射与类型定义 +1. 在 `conversation.props.ts` 中补充缺失的字段类型(如 `price`、`status`、`longDescription`、`demoConversations`) +2. 创建 API 响应映射工具函数(将 Spring Boot 响应转为 farris-x 类型) +3. 定义 `AgentVersion` 类型 + +### Phase 2:专家中心改造 +1. 替换 mock 数据为真实 API 调用 +2. 应用字段映射 +3. 测试分类筛选和搜索功能 + +### Phase 3:专家详情改造 +1. 对接详情 API +2. 实现版本历史 Tab +3. 修复"派生聊天"按钮逻辑 + +### Phase 4:聊天页对接 +1. 对接模型列表 API +2. 对接消息加载/发送 API +3. 对接文件上传 API +4. 调整运行时配置(联网、记忆、模型选择) + +### Phase 5:新增创建助手视图 +1. 实现创建助手表单 UI +2. 实现技能市场弹窗 +3. 对接分类加载和创建提交 API + +### Phase 6:新增我的助手视图 +1. 实现我的助手列表 UI +2. 对接我的助手列表和删除 API +3. 对接创建助手跳转 + +### Phase 7:样式与细节调整 +1. 确保各视图样式与 Spring Boot 一致 +2. 优化用户体验(加载状态、空状态、错误处理) + +--- + +## 6. API 接口清单 + +| 页面 | 接口 | 方法 | 说明 | +|---|---|---|---| +| 专家中心 | `/api/agents/categories` | GET | 获取分类列表 | +| 专家中心 | `/api/agents/list` | GET | 获取专家列表(支持分类和关键字筛选) | +| 专家详情 | `/api/agents/detail/{id}` | GET | 获取专家详情 | +| 专家详情 | `/api/agents/{id}/builtin-skills` | GET | 获取内置技能 | +| 专家详情 | `/api/agents/{id}/versions` | GET | 获取版本历史 | +| 专家详情 | `/api/agents/{id}/similar` | GET | 获取相似专家 | +| 聊天页 | `/api/conversation/create` | POST | 创建会话 | +| 聊天页 | `/api/conversation/{sessionId}/messages` | GET | 获取会话消息 | +| 聊天页 | `/api/chat/send` | POST | 发送消息 | +| 聊天页 | `/api/models/list` | GET | 获取模型列表 | +| 聊天页 | `/api/files/upload` | POST | 上传文件 | +| 创建助手 | `/api/agents/create` | POST | 创建专家 | +| 我的助手 | `/api/agents/my` | GET | 获取我的专家 | +| 我的助手 | `/api/agents/{id}` | DELETE | 删除专家 | + +--- + +## 7. 关键差异与注意事项 + +### 7.1 字段名差异 +- `likeCount` ↔ `likes` +- `author` ↔ `creator` +- `avatarUrl` ↔ `avatar` +- `createTime` ↔ `createdDate` + +### 7.2 JSON 解析 +- `skills` 字段是 JSON 字符串,需解析为数组 +- `demoConversations` 字段是 JSON 字符串,需解析后展示 + +### 7.3 编辑功能不实现 +- 我的助手页面中的「编辑」按钮本次不实现功能 +- 可以显示为禁用状态或提示「功能开发中」 + +### 7.4 侧边栏折叠 +- 聊天页的侧边栏折叠功能保留,但导航链接指向内部视图切换 + +--- + +## 8. 文件变更清单 + +| 操作 | 文件路径 | 说明 | +|---|---|---| +| 修改 | `packages/conversation/src/components/conversation/conversation.component.tsx` | 主组件,核心视图逻辑 | +| 修改 | `packages/conversation/src/components/conversation/conversation.props.ts` | 补充字段类型 | +| 修改 | `packages/conversation/src/style.scss` | 新增视图样式 | +| 新增 | `packages/conversation/src/components/conversation/api/agentApi.ts` | API 请求函数 | +| 新增 | `packages/conversation/src/components/conversation/types/mapping.ts` | 字段映射工具 | + +--- + +*文档版本:v1.0* +*创建日期:2026-03-31* diff --git a/packages/conversation/.env.development b/packages/conversation/.env.development new file mode 100644 index 0000000000000000000000000000000000000000..0e5dc37d2cdd4ebffe661678063c20b1f1b9dc51 --- /dev/null +++ b/packages/conversation/.env.development @@ -0,0 +1,2 @@ +# Spring Boot API 地址 +VITE_API_BASE=http://localhost:8080/api diff --git a/packages/conversation/demo/components/workbench/workbench.component.tsx b/packages/conversation/demo/components/workbench/workbench.component.tsx index 47e83fd9050a07a78474bc884894af08ee94a221..28c8a49cab292bac1939cd06a0afbfec4644e5bd 100644 --- a/packages/conversation/demo/components/workbench/workbench.component.tsx +++ b/packages/conversation/demo/components/workbench/workbench.component.tsx @@ -4,10 +4,10 @@ import type { NavActionItem, Message, ConversationData, - HistoryItem, ChatAgent, AssistiveTool, } from '../../..'; +import { getAgentList, getCategories } from '../../../src/components/conversation/api/agentApi'; import { fetchMockChatStream, fetchMockChatSync, applyEnvelopes } from '../../api/fetch-mock-chat'; const NAV_ACTIONS: NavActionItem[] = [ @@ -73,134 +73,154 @@ const demoSessionId = export default defineComponent({ name: 'WorkbenchComponent', setup() { - const conversations = ref([]); - const activeConversationId = ref(null); - const conversationUrlByTitle = ref>({}); - const historyItems = ref([]); - const demoAgents = ref([]); - const sessionId = ref(demoSessionId); + // 历史会话列表(所有会话,包含已关闭标签页的会话) + const conversations = ref([DEFAULT_CONVERSATION]); + // 当前打开的标签页 id 列表 + const openTabIds = ref([DEFAULT_CONVERSATION.id]); + // 当前激活的标签页 id + const activeConversationId = ref(DEFAULT_CONVERSATION.id); + const agents = ref([]); + const agentsLoading = ref(false); + // 分类 ID -> 分类名称 映射 + const categoryIdToName: Record = {}; + + // 加载专家列表 + async function loadAgents() { + agentsLoading.value = true; + try { + // 先加载分类映射 + const categories = await getCategories(); + categories.forEach(cat => { + categoryIdToName[String(cat.id)] = cat.name; + }); + // 加载专家列表 + const result = await getAgentList({ page: 1, size: 100 }); + // 转换为 ChatAgent 格式 + agents.value = result.records.map((item): ChatAgent => { + return { + id: String(item.id), + name: item.name || item.title || '', + title: item.title, + category: categoryIdToName[String(item.categoryId)] || '未分类', + description: item.description, + avatar: item.avatarUrl || undefined, + views: item.views, + likes: item.likeCount, + creator: item.author, + createdDate: item.createTime ? new Date(item.createTime).toLocaleDateString('zh-CN') : undefined, + systemPrompt: item.systemPrompt, + skills: item.skills ? JSON.parse(item.skills) : [], + }; + }); + } catch (error) { + console.error('加载专家列表失败:', error); + // 失败时使用默认数据 + agents.value = DEMO_AGENTS; + } finally { + agentsLoading.value = false; + } + } + + onMounted(() => { + loadAgents(); + }); async function openOrActivateHistory(convTitle: string) { const url = conversationUrlByTitle.value[convTitle]; if (!url) return; const existing = conversations.value.find((c) => c.title === convTitle); if (existing) { + // 如果会话已存在,打开其标签页并激活 + if (!openTabIds.value.includes(existing.id)) { + openTabIds.value = [...openTabIds.value, existing.id]; + } activeConversationId.value = existing.id; - return; - } - const conv = await loadConversationFromJson(url); - conversations.value = [...conversations.value, conv]; - activeConversationId.value = conv.id; - } - - async function loadHistoryIndexAndBootstrap() { - const res = await fetch(CONVERSATION_HISTORY_INDEX_URL); - if (!res.ok) { - throw new Error(`加载会话历史索引失败: ${CONVERSATION_HISTORY_INDEX_URL} (${res.status})`); - } - const data = (await res.json()) as ConversationHistoryIndexFile; - const entries = Array.isArray(data.entries) ? data.entries : []; - - conversationUrlByTitle.value = Object.fromEntries( - entries.map((e) => [e.title, e.conversationUrl]) - ); - - historyItems.value = entries.map((e) => ({ - title: e.title, - onClick: () => { void openOrActivateHistory(e.title); }, - })); - - const defaultTitle = data.defaultTitle ?? entries[0]?.title; - const defaultUrl = defaultTitle ? conversationUrlByTitle.value[defaultTitle] : undefined; - if (defaultUrl) { - const conv = await loadConversationFromJson(defaultUrl); - conversations.value = [conv]; + } else { + // 创建新会话 + const conv = createConversation(convTitle, initialMessages); + conversations.value = [...conversations.value, conv]; + openTabIds.value = [...openTabIds.value, conv.id]; activeConversationId.value = conv.id; } } - onMounted(() => { - void Promise.all([ - loadDemoAgentsFromJson(DEMO_AGENTS_URL).then((list) => { - demoAgents.value = list; - }), - loadHistoryIndexAndBootstrap(), - ]).catch((e) => console.error(e)); - }); - function handleCloseConversation(id: string) { - const idx = conversations.value.findIndex((c) => c.id === id); - if (idx < 0) return; - const nextConversations = conversations.value.filter((c) => c.id !== id); - conversations.value = nextConversations; + // 只从 openTabIds 中移除,不影响 conversations 历史记录 + if (!openTabIds.value.includes(id)) return; + openTabIds.value = openTabIds.value.filter(tabId => tabId !== id); + // 如果关闭的是当前激活的标签页,切换到其他标签页 if (activeConversationId.value === id) { - activeConversationId.value = idx > 0 ? nextConversations[idx - 1].id : (nextConversations[0]?.id ?? null); + if (openTabIds.value.length > 0) { + activeConversationId.value = openTabIds.value[openTabIds.value.length - 1]; + } else { + activeConversationId.value = null; + } } } function handleSwitchConversation(id: string) { - activeConversationId.value = id; + // 如果标签页已打开,切换激活状态 + if (openTabIds.value.includes(id)) { + activeConversationId.value = id; + } else { + // 如果标签页未打开,打开它并激活 + openTabIds.value = [...openTabIds.value, id]; + activeConversationId.value = id; + } } - function handleUserAuthConfirm(payload: { messageId: string; optionId: string; name: string }) { - const activeId = activeConversationId.value; - if (!activeId) return; - const convIdx = conversations.value.findIndex((c) => c.id === activeId); - if (convIdx < 0) return; - const conv = conversations.value[convIdx]; - const userAck: Message = { - id: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, - name: 'Sagi', - role: 'user', - content: { - type: 'text', - text: `【授权反馈】已选择「${payload.name}」(演示:真实环境将上送 command / server.chat.message.agent.confirm)` + function handleCreateDerivedChat(data: { + sessionId: string; + agentId: number; + agentName: string; + agentAvatar?: string; + systemPrompt?: string; + }) { + const newConv: ConversationData = { + id: data.sessionId, + title: data.agentName, + messages: [], + agentId: String(data.agentId), + agentConfig: { + agentId: data.agentId, + name: data.agentName, + avatar: data.agentAvatar || '', + systemPrompt: data.systemPrompt || '', }, - timestamp: Date.now(), - agentId: '' }; - const assistantAck: Message = { - id: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, - name: 'inBuilder', - role: 'assistant', - content: { - type: 'text', - text: `已收到授权选项 \`${payload.optionId}\`:${payload.name}。可继续对话或执行后续任务。` - }, - timestamp: Date.now(), - agentId: '' - }; - const next = [...conversations.value]; - next[convIdx] = { ...conv, messages: [...conv.messages, userAck, assistantAck] }; - conversations.value = next; + conversations.value = [...conversations.value, newConv]; + openTabIds.value = [...openTabIds.value, newConv.id]; + activeConversationId.value = newConv.id; } - async function handleSendMessage(content: string) { - const activeId = activeConversationId.value; - if (!activeId) return; - const idx = conversations.value.findIndex((c) => c.id === activeId); - if (idx < 0) return; - const conv = conversations.value[idx]; - const pending = [...conv.pendingMessages]; - const displayed = [...conv.messages]; + function handleCreateConversation(conv: ConversationData) { + conversations.value = [...conversations.value, conv]; + openTabIds.value = [...openTabIds.value, conv.id]; + activeConversationId.value = conv.id; + } - if (pending.length === 0 && !content.trim()) { - return; - } + function handleSendMessage(content: string, msg?: Message) { + const activeId = activeConversationId.value; + let targetId = activeId; - if (pending.length > 0) { - const [next, ...rest] = pending; - const updated = [...conversations.value]; - updated[idx] = { - ...conv, - messages: [...displayed, next], - pendingMessages: rest, + // 如果没有激活的会话,创建一个新会话 + if (!targetId) { + const newConv: ConversationData = { + id: `conv-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, + title: '新对话', + messages: [], }; - conversations.value = updated; - return; + conversations.value = [...conversations.value, newConv]; + openTabIds.value = [...openTabIds.value, newConv.id]; + targetId = newConv.id; + activeConversationId.value = targetId; } - const newMsg: Message = { + const idx = conversations.value.findIndex((c) => c.id === targetId); + if (idx < 0) return; + const conv = conversations.value[idx]; + // 如果传入了消息对象,直接使用;否则创建 + const newMsg: Message = msg || { id: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, name: 'Sagi', role: 'user', @@ -213,6 +233,17 @@ export default defineComponent({ conversations.value = updated; } + function handleAssistantMessage(msg: Message) { + const activeId = activeConversationId.value; + if (!activeId) return; + const idx = conversations.value.findIndex((c) => c.id === activeId); + if (idx < 0) return; + const conv = conversations.value[idx]; + const updated = [...conversations.value]; + updated[idx] = { ...conv, messages: [...conv.messages, msg] }; + conversations.value = updated; + } + const assistiveTools: AssistiveTool[] = [ { id: 'Translate', @@ -319,11 +350,11 @@ export default defineComponent({ ); diff --git a/packages/conversation/src/components/conversation/api/agentApi.ts b/packages/conversation/src/components/conversation/api/agentApi.ts new file mode 100644 index 0000000000000000000000000000000000000000..a5a8644840e5c1c4e6d29881b0d39adfb8c4a0d1 --- /dev/null +++ b/packages/conversation/src/components/conversation/api/agentApi.ts @@ -0,0 +1,192 @@ +/** + * 专家中心 API 请求函数 + * 对接 Spring Boot 项目的 /api/agents/* 接口 + */ + +import type { + ApiResponse, + AgentCategory, + AgentDetailResponse, + AgentListItem, + AgentBuiltinSkill, + AgentVersion, + PagedResponse, + CreateConversationResponse, + CreateConversationRequest, + ChatMessage, + SendMessageRequest, + SendMessageResponse, + ModelInfo, + UploadFileResponse, + CreateAgentRequest, +} from '../types/agent'; + +const API_BASE = import.meta.env.VITE_API_BASE || '/api'; + +/** + * 通用请求封装 + */ +async function request(url: string, options?: RequestInit): Promise { + const response = await fetch(url, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers, + }, + }); + const result: ApiResponse = await response.json(); + if (result.code !== 200) { + throw new Error(result.msg || '请求失败'); + } + return result.data; +} + +// ==================== 专家相关 ==================== + +/** + * 获取所有分类 + */ +export async function getCategories(): Promise { + return request(`${API_BASE}/agents/categories`); +} + +/** + * 获取专家列表 + * @param page 页码 + * @param size 每页数量 + * @param categoryId 分类ID筛选 + * @param keyword 关键字搜索 + */ +export async function getAgentList(params: { + page?: number; + size?: number; + categoryId?: string; + keyword?: string; +}): Promise> { + const searchParams = new URLSearchParams(); + searchParams.set('page', String(params.page || 1)); + searchParams.set('size', String(params.size || 100)); + if (params.categoryId) { + searchParams.set('categoryId', params.categoryId); + } + if (params.keyword) { + searchParams.set('keyword', params.keyword); + } + return request>(`${API_BASE}/agents/list?${searchParams}`); +} + +/** + * 获取专家详情 + */ +export async function getAgentDetail(id: string): Promise { + return request(`${API_BASE}/agents/detail/${id}`); +} + +/** + * 获取专家内置技能 + */ +export async function getAgentBuiltinSkills(id: string): Promise { + return request(`${API_BASE}/agents/${id}/builtin-skills`); +} + +/** + * 获取专家版本历史 + */ +export async function getAgentVersions(id: string): Promise { + return request(`${API_BASE}/agents/${id}/versions`); +} + +/** + * 获取相似专家 + */ +export async function getAgentSimilar(id: string): Promise { + return request(`${API_BASE}/agents/${id}/similar`); +} + +/** + * 创建专家 + */ +export async function createAgent(data: CreateAgentRequest): Promise { + return request(`${API_BASE}/agents/create`, { + method: 'POST', + body: JSON.stringify(data), + }); +} + +/** + * 获取我的专家列表 + * @param author 作者名 + */ +export async function getMyAgents(author: string): Promise { + return request( + `${API_BASE}/agents/my?author=${encodeURIComponent(author)}` + ); +} + +/** + * 删除专家 + */ +export async function deleteAgent(id: string): Promise { + return request(`${API_BASE}/agents/${id}`, { + method: 'DELETE', + }); +} + +// ==================== 会话相关 ==================== + +/** + * 创建会话 + */ +export async function createConversation(data: CreateConversationRequest): Promise { + return request(`${API_BASE}/conversation/create`, { + method: 'POST', + body: JSON.stringify(data), + }); +} + +/** + * 获取会话消息 + */ +export async function getConversationMessages(sessionId: string): Promise { + return request(`${API_BASE}/conversation/${sessionId}/messages`); +} + +// ==================== 聊天相关 ==================== + +/** + * 发送消息 + */ +export async function sendMessage(data: SendMessageRequest): Promise { + return request(`${API_BASE}/chat/send`, { + method: 'POST', + body: JSON.stringify(data), + }); +} + +// ==================== 模型相关 ==================== + +/** + * 获取模型列表 + */ +export async function getModelList(): Promise { + return request(`${API_BASE}/models/list`); +} + +// ==================== 文件相关 ==================== + +/** + * 上传文件 + */ +export async function uploadFile(file: File): Promise { + const formData = new FormData(); + formData.append('file', file); + const response = await fetch(`${API_BASE}/files/upload`, { + method: 'POST', + body: formData, + }); + const result: ApiResponse = await response.json(); + if (result.code !== 200) { + throw new Error(result.msg || '上传失败'); + } + return result.data; +} diff --git a/packages/conversation/src/components/conversation/conversation.component.tsx b/packages/conversation/src/components/conversation/conversation.component.tsx index e44a74c94bca502a4643c52e72cc6aa08255534e..d21e21435575481432214f9819cc15b87557abe6 100644 --- a/packages/conversation/src/components/conversation/conversation.component.tsx +++ b/packages/conversation/src/components/conversation/conversation.component.tsx @@ -1,12 +1,14 @@ import { defineComponent, ref, computed, onMounted, onUnmounted, watch, nextTick } from "vue"; import { FLayout, FLayoutPane, FSplitter, FSplitterPane, FPopover, FNav, FListView } from "@farris/ui-vue"; -import { conversationProps, ConversationProps, Message, ChatAgent, AssistiveTool, SlotConfig } from "./conversation.props"; +import { conversationProps, ConversationProps, Message, ChatAgent, AssistiveTool, SlotConfig, ModelInfo } from "./conversation.props"; import type { PreviewConfig } from "../app-preview/types"; import type { ContentAreaContext, LayoutStrategy } from "./composition/types"; import type { VNode } from "vue"; import { renderMessageContent } from "../../utils/message-renderers"; import ChatPreview from "../chat-preview/chat-preview.component"; import { renderSlotConfig } from '../../utils/slot-config-renderers'; +import { getModelList, sendMessage as sendMessageApi, uploadFile, getAgentVersions, createConversation as createConversationApi } from "./api/agentApi"; +import type { AgentVersion } from "./types/agent"; /** 紧凑模式布局策略 */ function createCompactLayoutStrategy(): LayoutStrategy { @@ -29,7 +31,7 @@ function createNormalLayoutStrategy( showPreviewPane: () => boolean, renderPreview: () => VNode, chatPreviewPaneWidth: () => number, - chatPreviewPaneRef: { value: unknown } + chatPreviewPaneRef: any ): LayoutStrategy { return { isCompact: false, @@ -73,7 +75,7 @@ function createNormalLayoutStrategy( export default defineComponent({ name: 'Conversation', props: conversationProps, - emits: ['sendMessage', 'closeConversation', 'switchConversation', 'userAuthConfirm'], + emits: ['sendMessage', 'closeConversation', 'switchConversation', 'assistantMessage', 'createConversation'], setup(props: ConversationProps, context) { const chatNavPaneWidth = ref(260); const chatNavPaneCollapsed = ref(props.navPaneCollapsed); @@ -121,6 +123,31 @@ export default defineComponent({ const agentDetailAgent = ref(null); const agentDetailActiveTab = ref('overview'); const agentDetailDescExpanded = ref(true); + const agentVersions = ref([]); + + // 头像加载失败状态追踪 + const avatarLoadFailed = ref>({}); + + // 运行时配置:模型选择、联网搜索、记忆功能 + const selectedModel = ref(null); + const modelList = ref([]); + const enableWebSearch = ref(false); + const memoryEnabled = ref(true); + const memoryLevel = ref<'high' | 'medium' | 'low'>('medium'); + const uploadedFileIds = ref([]); + + // 加载模型列表 + async function loadModelList() { + try { + const models = await getModelList(); + modelList.value = models; + if (models.length > 0 && !selectedModel.value) { + selectedModel.value = models[0]; + } + } catch (error) { + console.error('加载模型列表失败:', error); + } + } const agentDetailTabData = computed(() => { const agent = agentDetailAgent.value; @@ -173,10 +200,20 @@ export default defineComponent({ agentDetailAgent.value = agent; agentDetailActiveTab.value = 'overview'; agentDetailDescExpanded.value = true; + // 加载版本历史 + if (agent.id) { + getAgentVersions(agent.id).then(versions => { + agentVersions.value = versions; + }).catch(error => { + console.error('加载版本历史失败:', error); + agentVersions.value = []; + }); + } } function closeAgentDetail() { agentDetailAgent.value = null; + agentVersions.value = []; } // 消息区域滚动条:鼠标移入或滚动时显示,默认隐藏 @@ -207,8 +244,29 @@ export default defineComponent({ const hoveredInputBarTimerId = ref(null); const inputBarPopoverRef = ref(); const inputBarPopoverHostRef = ref(); + const isSendingMessage = ref(false); const speechInput = ref(false); + const fileInputRef = ref(); + + // 文件上传处理 + function handleFileSelect(event: Event) { + const input = event.target as HTMLInputElement; + const files = input.files; + if (!files || files.length === 0) return; + + Array.from(files).forEach(async (file) => { + try { + const result = await uploadFile(file); + uploadedFileIds.value = [...uploadedFileIds.value, result.fileId]; + console.log('文件上传成功:', result.fileName); + } catch (error) { + console.error('文件上传失败:', error); + } + }); + // 清空 input 以便选择相同文件 + input.value = ''; + } function showMessageScrollbar() { messageScrollbarVisible.value = true; @@ -310,6 +368,8 @@ export default defineComponent({ } if ((isTabMode.value ? displayedMessages.value.length : messages.value.length) > 0) focusInputEditor(); }); + // 加载模型列表 + loadModelList(); }); onUnmounted(() => { @@ -377,6 +437,8 @@ export default defineComponent({ showPreviewPane.value = false; activePreviewConfig.value = null; } + // 确保编辑器中有常驻的 agent 标签 + nextTick(() => ensurePermanentAgentTag()); }, { immediate: true }); watch(showPreviewPane, (visible) => { @@ -435,38 +497,137 @@ export default defineComponent({ showNavPopover.value = false; } - function sendMessage() { + async function sendMessage() { const content = serializeEditorToText(); message.value = content; + if (!content) return; - // 页签模式:允许空内容发送(用于待显示队列逐条Reveal);非页签模式仍要求有输入 - if (isTabMode.value) { - if (editorRef.value) editorRef.value.innerHTML = ''; - if (slotEditorRef.value) displayedSlotConfigs.value = []; - selectedSkills.value = []; - message.value = ''; - context.emit('sendMessage', content); - return; - } + // 正在发送中,禁止重复发送 + if (isSendingMessage.value) return; + isSendingMessage.value = true; + + // 获取当前会话和助手配置 + const activeConv = isTabMode.value + ? props.conversations?.find(c => c.id === props.activeConversationId) + : null; + const sessionId = activeConv?.id || props.conversations?.[0]?.id; + const agentConfig = activeConv?.agentConfig; + + // 构造用户消息 + const userMessage: Message = { + id: `${Date.now()}_${Math.random().toString(16).slice(2)}`, + name: 'Sagi', + role: 'user', + agentId: 'user', + content, + timestamp: Date.now(), + }; - if (!content) return; + // 如果没有 sessionId,先创建会话 + let currentSessionId = sessionId; + let currentAgentConfig = agentConfig; + if (!currentSessionId) { + // 从 agents 中获取第一个助手作为默认 + const defaultAgent = props.agents[0]; + if (!defaultAgent) { + isSendingMessage.value = false; + return; + } + currentAgentConfig = { + agentId: Number(defaultAgent.id), + name: defaultAgent.name, + avatar: defaultAgent.avatar || '', + systemPrompt: defaultAgent.systemPrompt || '', + }; + // 创建新会话 + try { + const convResponse = await createConversationApi({ agentId: Number(defaultAgent.id) }); + currentSessionId = convResponse.sessionId; + // 通知父组件创建了新会话 + context.emit('createConversation', { + id: currentSessionId, + title: currentAgentConfig.name, + messages: [], + agentId: String(currentAgentConfig.agentId), + agentConfig: currentAgentConfig, + }); + } catch (error) { + console.error('创建会话失败:', error); + isSendingMessage.value = false; + return; + } + } else if (!currentAgentConfig) { + // 如果有 sessionId 但没有 agentConfig,使用默认助手(不创建新会话) + const defaultAgent = props.agents[0]; + if (defaultAgent) { + currentAgentConfig = { + agentId: Number(defaultAgent.id), + name: defaultAgent.name, + avatar: defaultAgent.avatar || '', + systemPrompt: defaultAgent.systemPrompt || '', + }; + } + } + // 清空编辑器 if (editorRef.value) editorRef.value.innerHTML = ''; if (slotEditorRef.value) displayedSlotConfigs.value = []; selectedSkills.value = []; message.value = ''; - const id = `msg-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; - const newMsg: Message = { - id, - name: '用户', - role: 'user', - content: { type: 'text', text: content }, - timestamp: Date.now(), - agentId: agentIdRef.value || '', - }; - messages.value = [...messages.value, newMsg]; - context.emit('sendMessage', { id, messages: messages.value }); + // 先添加用户消息到列表 + if (!isTabMode.value) { + messages.value = [...messages.value, userMessage]; + } else { + // 页签模式下通知父组件添加用户消息 + context.emit('sendMessage', content, userMessage); + } + + // 调用 API 发送消息 + if (!currentAgentConfig) { + console.error('发送消息失败: 缺少助手配置'); + isSendingMessage.value = false; + return; + } + try { + const response = await sendMessageApi({ + sessionId: currentSessionId, + message: content, + agentConfig: currentAgentConfig, + runtimeConfig: { + model: selectedModel.value?.modelId || 'gpt-4o', + enableWebSearch: enableWebSearch.value, + memoryEnabled: memoryEnabled.value, + memoryLevel: memoryLevel.value, + }, + fileIds: uploadedFileIds.value, + }); + + // 添加助手回复到消息列表 + const assistantMessage: Message = { + id: `${Date.now()}_${Math.random().toString(16).slice(2)}`, + name: currentAgentConfig.name, + role: 'assistant', + agentId: String(currentAgentConfig.agentId), + content: response.content, + timestamp: Date.now(), + }; + + if (!isTabMode.value) { + messages.value = [...messages.value, assistantMessage]; + } else { + context.emit('assistantMessage', assistantMessage); + } + } catch (error) { + console.error('发送消息失败:', error); + // 可以在这里添加错误提示 + } finally { + isSendingMessage.value = false; + // 清空已上传文件 + uploadedFileIds.value = []; + // 重新确保常驻标签存在 + ensurePermanentAgentTag(); + } } function collapseChatNavPane() { @@ -618,6 +779,52 @@ export default defineComponent({ updateMessageFromEditor(); } + /** 确保编辑器中有常驻的 agent 标签(不可删除) */ + function ensurePermanentAgentTag() { + const editor = editorRef.value; + if (!editor) return; + const agentConfig = activeConversation.value?.agentConfig; + if (!agentConfig) return; + const agentName = agentConfig.name; + // 检查是否已有常驻标签 + const existingTag = editor.querySelector('.f-chat-agent-tag[data-permanent="true"]'); + if (existingTag) { + // 已有标签,检查 agent 名称是否匹配 + const existingAgentName = existingTag.getAttribute('data-agent'); + if (existingAgentName === agentName) { + // 名称相同,不需要更新 + return; + } + // 名称不同,移除旧标签 + existingTag.remove(); + } + // 创建常驻标签(无删除按钮) + const tagEl = document.createElement('span'); + tagEl.className = 'f-chat-agent-tag'; + tagEl.setAttribute('data-agent', agentName); + tagEl.setAttribute('data-permanent', 'true'); + (tagEl as any).contentEditable = 'false'; + const textEl = document.createElement('span'); + textEl.className = 'f-chat-agent-tag-text'; + textEl.textContent = `@${agentName}`; + tagEl.appendChild(textEl); + // 插入到编辑器开头 + if (editor.firstChild) { + editor.insertBefore(tagEl, editor.firstChild); + } else { + editor.appendChild(tagEl); + } + // 在标签后添加空格 + const firstChild = editor.firstChild; + if (firstChild && firstChild.nextSibling) { + const space = document.createTextNode(' '); + editor.insertBefore(space, firstChild.nextSibling); + } else { + const space = document.createTextNode(' '); + editor.appendChild(space); + } + } + function handleAgentDblClick(agent: ChatAgent) { insertAgentMention(agent.name); } @@ -791,37 +998,53 @@ export default defineComponent({
{shouldShowAgentButton.value && !selectedAssistiveTool.value && (
- - -
    - {props.agents.map((item: ChatAgent) => { - return ( -
  • { - selectedAgent.value = item; - hideAgentListPanel(); - }}> -
    - - {item.name} -
    - -
  • - ); - })} -
-
+ {/* 模型选择 */} + {modelList.value.length > 0 && ( + + )} + {/* 联网搜索开关 */} + + {/* 记忆功能开关 */} + + {/* 记忆级别选择 */} + {memoryEnabled.value && ( + + )} {shouldShowAssistiveTool.value && (
)}
+ {agentDetailActiveTab.value !== 'overview' && (agent.systemPrompt || agent.description) && ( @@ -1382,16 +1740,31 @@ export default defineComponent({
相关助理 - 查看更多 › + 查看更多 ›