diff --git a/.gitignore b/.gitignore index 32e56ff..4e7cc2f 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ dist-ssr *.sw? uploads #docs -.claude +#.claude plugs -sillytavern \ No newline at end of file +sillytavern +st diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index f21ff5d..0000000 --- a/docs/README.md +++ /dev/null @@ -1,67 +0,0 @@ -# 云酒馆项目文档 - -## 目录 - -- [项目概述](./overview.md) -- [架构设计](./architecture.md) -- [API 文档](./api.md) -- [前端开发指南](./frontend-guide.md) -- [后端开发指南](./backend-guide.md) -- [部署指南](./deployment.md) -- [设计系统](./design-system/) - -## 项目架构 - -``` -云酒馆平台 -├── 前端 (web-app) -│ ├── 公共页面(首页、角色广场、角色详情) -│ ├── 用户系统(登录、注册、用户中心) -│ ├── 管理功能(角色卡管理、预设管理) -│ └── 对话功能(聊天界面、历史记录) -│ -└── 后端 (server) - ├── 用户认证 (JWT) - ├── 角色卡 API - ├── 对话管理 API - ├── 预设管理 API - └── AI 集成 -``` - -## 核心功能 - -### 角色卡系统 -- 支持 PNG 格式角色卡(嵌入 JSON 数据) -- 支持纯 JSON 格式角色卡 -- 角色信息编辑 -- 导入导出功能 - -### 预设系统 -- 支持多种预设格式(SillyTavern、TavernAI 等) -- 参数配置(Temperature、Top P、Top K 等) -- 预设复制和导出 - -### 对话系统 -- 实时消息发送 -- 对话历史管理 -- 多角色对话支持 -- 对话导出功能 - -## 开发规范 - -### 代码风格 -- 前端:ESLint + Prettier -- 后端:ESLint -- 提交信息:Conventional Commits - -### Git 工作流 -- main: 生产环境 -- develop: 开发环境 -- feature/*: 功能分支 -- bugfix/*: 修复分支 - -## 快速链接 - -- [前端 README](../web-app/README.md) -- [后端 README](../server/README.md) -- [设计系统](./design-system/) diff --git a/docs/html/story_renderer_extract.js b/docs/html/story_renderer_extract.js deleted file mode 100644 index 27b49bf..0000000 --- a/docs/html/story_renderer_extract.js +++ /dev/null @@ -1,590 +0,0 @@ - class StoryRenderer { - constructor(dataSourceId) { - this.dataSourceId = dataSourceId; - this.yamlData = null; - this.rootNode = null; // 根节点名称 - this.initElements(); - } - - // 初始化DOM元素引用 - initElements() { - this.elements = { - timeDisplay: document.getElementById('time-display'), - locationDisplay: document.getElementById('location-display'), - charactersContainer: document.getElementById('characters-container'), - actionOwner: document.getElementById('action-owner'), - optionsList: document.getElementById('options-list'), - }; - } - - // 初始化方法 - init() { - try { - // 从script标签中加载YAML数据 - this.loadYamlFromScriptTag(); - - // 如果没有有效数据(显示加载状态的情况),直接返回 - if (!this.yamlData) { - this.setupEventListeners(); - return; - } - - // 找到根节点 - this.findRootNode(); - this.renderAll(); - this.setupEventListeners(); - } catch (error) { - this.handleError(error); - } - } - - // 从script标签加载并解析YAML数据 - loadYamlFromScriptTag() { - const scriptElement = document.getElementById(this.dataSourceId); - if (!scriptElement) { - throw new Error('未找到id为"yaml-data-source"的script标签'); - } - - let yamlContent = scriptElement.textContent.trim(); - - // 检查是否为真正的空内容 - if (!yamlContent) { - // 当YAML内容为空时,设置默认的加载状态但不抛出错误 - this.showLoadingState(); - return; // 直接返回,不抛出错误 - } - - // 如果是"加载中..."文本,也显示加载状态 - if (yamlContent === '加载中...') { - this.showLoadingState(); - return; - } - - // 有内容,尝试解析YAML - try { - this.yamlData = jsyaml.load(yamlContent); - } catch (e) { - // YAML格式错误,应该弹出错误对话框 - throw new Error(`YAML格式错误: ${e.message}`); - } - - if (!this.yamlData || Object.keys(this.yamlData).length === 0) { - // 解析成功但数据为空,这是格式问题 - throw new Error('YAML解析成功但数据为空,请检查YAML格式是否正确'); - } - } - - // 显示加载状态的独立方法 - showLoadingState() { - this.elements.timeDisplay.textContent = '⏰ 加载中...'; - this.elements.locationDisplay.textContent = '📍 加载中...'; - this.elements.actionOwner.textContent = '加载中...'; - this.elements.charactersContainer.innerHTML = this.createEmptyState('数据加载中...'); - this.elements.optionsList.innerHTML = - '
  • 加载选项中...
  • '; - } - - // 查找根节点 - findRootNode() { - const rootNodeNames = Object.keys(this.yamlData); - if (rootNodeNames.length === 0) { - throw new Error('YAML数据中未找到任何根节点'); - } - - this.rootNode = rootNodeNames[0]; - } - - // 格式化节点名称,使其更易读 - formatNodeName(name) { - // 提取emoji后面的文本(如果有emoji) - const emojiMatch = name.match(/^(\p{Emoji}\s*)(.*)$/u); - if (emojiMatch && emojiMatch[2]) { - return emojiMatch[2]; - } - return name; - } - - // 渲染所有内容 - renderAll() { - if (!this.rootNode || !this.yamlData[this.rootNode]) { - throw new Error('未找到有效的根节点数据'); - } - - const rootData = this.yamlData[this.rootNode]; - this.renderHeaderInfo(rootData); - this.renderCharacters(rootData); - this.renderActionOptions(rootData); - } - - // 渲染头部信息(日期和时间和地点) - renderHeaderInfo(rootData) { - // 查找日期时间字段 - const dateTimeField = this.findFieldByKeywords(rootData, ['日期', '时间', 'datetime', 'time']); - // 查找地点字段 - const locationField = this.findFieldByKeywords(rootData, ['地点', '位置', 'location', 'place']); - - // 直接使用包含emoji的值 - this.elements.timeDisplay.textContent = dateTimeField ? rootData[dateTimeField] : '⏰ 时间未知'; - this.elements.locationDisplay.textContent = locationField ? rootData[locationField] : '📍 地点未知'; - } - - // 根据关键词查找字段名 - findFieldByKeywords(data, keywords) { - if (!data || typeof data !== 'object') return null; - - const fields = Object.keys(data); - for (const field of fields) { - for (const keyword of keywords) { - if (field.toLowerCase().includes(keyword.toLowerCase())) { - return field; - } - } - } - return null; - } - - // 渲染角色列表 - renderCharacters(rootData) { - // 查找用户列表字段 - const userListField = this.findFieldByKeywords(rootData, ['用户', '角色', '列表', 'user', 'role', 'list']); - const userList = userListField && Array.isArray(rootData[userListField]) ? rootData[userListField] : []; - - this.elements.charactersContainer.innerHTML = ''; - - if (userList.length === 0) { - this.elements.charactersContainer.innerHTML = this.createEmptyState('没有角色数据'); - return; - } - - // 处理每个用户项 - userList.forEach(userItem => { - // 检查是否有外层包装 - let userData = userItem; - - if (typeof userItem === 'object' && userItem !== null) { - const userField = this.findFieldByKeywords(userItem, ['用户', 'user', '角色', 'role']); - if (userField) { - userData = userItem[userField]; - } - } - - const characterCard = this.createCharacterCard(userData); - if (characterCard) { - this.elements.charactersContainer.appendChild(characterCard); - } - }); - } - - // 创建单个角色卡片 - createCharacterCard(userData) { - if (!userData || typeof userData !== 'object') return null; - - const card = document.createElement('div'); - card.className = - 'bg-dark rounded-xl border border-gray-700/30 p-3.5 shadow-sm card-hover character-card theme-transition'; - - // 查找名字字段 - const nameField = this.findFieldByKeywords(userData, ['名字', '姓名', '名称', 'name']); - const userName = nameField ? userData[nameField] : '👤 未知角色'; - - // 创建标题 - const title = document.createElement('h3'); - title.className = 'font-bold text-lg mb-2 pb-1 border-b border-gray-700/30 theme-transition'; - title.textContent = `${this.formatNodeName(userName)}的状态`; - card.appendChild(title); - - // 创建属性列表 - const attributesList = document.createElement('ul'); - attributesList.className = 'space-y-2 text-sm'; - card.appendChild(attributesList); - - // 处理所有属性 - Object.keys(userData).forEach(key => { - // 跳过已经作为标题使用的名字节点 - if (key === nameField) return; - - // 创建属性项,直接使用包含emoji的值 - const attributeItem = this.createAttributeItem(key, userData[key]); - if (attributeItem) { - attributesList.appendChild(attributeItem); - } - }); - - return card; - } - - // 创建属性项 - createAttributeItem(key, value) { - const item = document.createElement('li'); - - // 处理数组类型 - if (Array.isArray(value)) { - item.innerHTML = `${this.formatNodeName(key)}:`; - - const subList = document.createElement('ul'); - subList.className = 'list-disc list-inside ml-4 mt-1 space-y-1 text-gray-400 theme-transition'; - - value.forEach(itemData => { - if (typeof itemData === 'object' && itemData !== null) { - const subKey = Object.keys(itemData)[0]; - const subValue = itemData[subKey]; - const subItem = document.createElement('li'); - subItem.textContent = subValue; - subList.appendChild(subItem); - } else { - const subItem = document.createElement('li'); - subItem.textContent = itemData; - subList.appendChild(subItem); - } - }); - - item.appendChild(subList); - } - // 处理对象类型 - else if (typeof value === 'object' && value !== null) { - item.innerHTML = `${this.formatNodeName(key)}:`; - - const subList = document.createElement('ul'); - subList.className = 'list-disc list-inside ml-4 mt-1 space-y-1 text-gray-400 theme-transition'; - - Object.keys(value).forEach(subKey => { - const subItem = document.createElement('li'); - subItem.textContent = value[subKey]; - subList.appendChild(subItem); - }); - - item.appendChild(subList); - } - // 处理普通文本值 - else if (value !== null && value !== undefined && value.toString().trim() !== '') { - item.innerHTML = `${this.formatNodeName(key)}: ${value}`; - } - - return item; - } - - // 渲染行动选项 - renderActionOptions(rootData) { - // 查找行动选项字段 - const actionOptionsField = this.findFieldByKeywords(rootData, ['行动', '选项', 'action', 'option']); - const actionOptions = - actionOptionsField && typeof rootData[actionOptionsField] === 'object' ? rootData[actionOptionsField] : {}; - - // 设置行动所有者 - const ownerField = this.findFieldByKeywords(actionOptions, ['名字', '姓名', '所有者', 'owner', 'name']); - this.elements.actionOwner.textContent = ownerField - ? this.formatNodeName(actionOptions[ownerField]) - : '未知角色'; - - // 渲染选项列表 - const optionsField = this.findFieldByKeywords(actionOptions, ['选项', '选择', 'option', 'choice']); - const options = optionsField && Array.isArray(actionOptions[optionsField]) ? actionOptions[optionsField] : []; - - this.elements.optionsList.innerHTML = ''; - - if (options.length === 0) { - this.elements.optionsList.innerHTML = this.createEmptyState('没有可用选项'); - return; - } - - options.forEach(optionText => { - const optionItem = document.createElement('li'); - optionItem.className = - 'pl-2 py-1 border-l-2 border-primary/30 ml-1 hover:border-primary/70 transition-colors text-gray-300 theme-transition'; - optionItem.textContent = optionText; - this.elements.optionsList.appendChild(optionItem); - }); - } - - // 创建空状态提示 - createEmptyState(message) { - return `
    - ${message} -
    `; - } - - // 设置事件监听器 - setupEventListeners() { - const detailsElement = document.querySelector('details'); - const contentElement = this.elements.charactersContainer; - - // 初始化高度为0以实现动画效果 - contentElement.style.maxHeight = '0'; - - // 监听详情展开/折叠事件 - detailsElement.addEventListener('toggle', () => { - if (detailsElement.open) { - // 展开时设置实际高度 - setTimeout(() => { - contentElement.style.maxHeight = contentElement.scrollHeight + 'px'; - }, 10); - } else { - // 折叠时设置高度为0 - contentElement.style.maxHeight = '0'; - } - }); - - // 根据自动折叠设置决定默认状态 - const autoCollapseToggle = document.getElementById('auto-collapse-toggle'); - if (autoCollapseToggle) { - // 从本地存储读取设置,默认为true(折叠) - const savedAutoCollapse = localStorage.getItem('autoCollapse'); - const shouldCollapse = savedAutoCollapse === null ? true : savedAutoCollapse === 'true'; - detailsElement.open = !shouldCollapse; - - // 如果默认展开,需要设置正确的高度 - if (!shouldCollapse) { - setTimeout(() => { - contentElement.style.maxHeight = contentElement.scrollHeight + 'px'; - }, 100); - } - } else { - // 如果没有设置切换开关,默认折叠 - detailsElement.open = false; - } - } - - // 错误处理 - handleError(error) { - console.error('渲染错误:', error); - - // 使用美化的错误弹窗 - showErrorModal(error.message); - - // 在角色状态区域显示错误信息 - this.elements.charactersContainer.innerHTML = ` - - `; - - // 在行动选项区域也显示错误信息 - this.elements.optionsList.innerHTML = ` -
  • -
    - -
    -
    行动选项加载失败
    -
    请检查YAML格式是否正确
    -
    -
    -
  • - `; - } - } - - $(document).ready(function () { - /** - * 获取本楼消息 - * @returns {Object|null} 包含本楼消息信息的对象,失败时返回null - */ - function getCurrentMessage() { - try { - if (typeof getCurrentMessageId !== 'function' || typeof getChatMessages !== 'function') { - return null; - } - - const currentMessageId = getCurrentMessageId(); - if (!currentMessageId && currentMessageId !== 0) return null; - - const messageData = getChatMessages(currentMessageId); - if (!messageData) return null; - - return Array.isArray(messageData) && messageData.length > 0 ? messageData[0] : messageData; - } catch (error) { - console.error('获取消息失败:', error); - return null; - } - } - - function extractMaintext(message) { - if (!message || typeof message !== 'string') return ''; - const match = message.match(/([\s\S]*?)<\/maintext>/i); - return match ? match[1].trim() : ''; - } - - /** - * 从消息中提取Status_block内容 - * @param {string} message 消息文本 - * @returns {string} 提取的YAML状态内容 - */ - function extractStatusBlock(message) { - if (!message || typeof message !== 'string') return ''; - - const match = message.match(/\s*([\s\S]*?)\s*<\/Status_block>/i); - return match ? cleanYamlContent(match[1].trim()) : ''; - } - - /** - * 清理YAML内容,修复常见的格式问题 - * @param {string} yamlContent 原始YAML内容 - * @returns {string} 清理后的YAML内容 - */ - function cleanYamlContent(yamlContent) { - if (!yamlContent) return ''; - - return yamlContent - .split('\n') - .map(line => { - if (line.trim() === '' || !line.trim().match(/^-\s*"/)) return line; - - const match = line.match(/^(\s*-\s*)"(.*)"\s*$/); - if (!match) return line; - - const [, indent, content] = match; - return content.includes('"') || content.includes("'") - ? indent + "'" + content.replace(/'/g, "''") + "'" - : indent + '"' + content + '"'; - }) - .join('\n'); - } - - /** - * 更新YAML数据源 - * @param {string} yamlContent YAML格式的状态内容 - */ - function updateYamlDataSource(yamlContent) { - const yamlScript = document.getElementById('yaml-data-source'); - if (!yamlScript) return; - - // 如果内容为空或无效,设置为加载中状态 - if (!yamlContent || typeof yamlContent !== 'string' || !yamlContent.trim()) { - yamlScript.textContent = ''; // 设置为空,让后续处理显示加载状态 - return; - } - - // 先设置内容,让StoryRenderer能处理格式错误 - yamlScript.textContent = yamlContent; - - // 验证YAML格式,如果有错误会被StoryRenderer捕获并处理 - try { - jsyaml.load(yamlContent); - } catch (error) { - // 尝试修复常见的YAML错误 - const fixedYaml = attemptYamlFix(yamlContent, error); - if (fixedYaml) { - try { - jsyaml.load(fixedYaml); - yamlScript.textContent = fixedYaml; - } catch (e) { - console.error('YAML修复失败:', e.message); - // 修复失败时保留原内容,让StoryRenderer显示具体错误 - } - } - // 如果无法修复,保留原内容,让StoryRenderer显示具体错误 - } - } - - /** - * 尝试修复常见的YAML错误 - * @param {string} yamlContent 有问题的YAML内容 - * @param {Error} error YAML解析错误 - * @returns {string|null} 修复后的YAML或null - */ - function attemptYamlFix(yamlContent, error) { - if (!(error.message.includes('bad indentation') || error.message.includes('quote'))) { - return null; - } - - return yamlContent - .split('\n') - .map(line => { - const match = line.match(/^(\s*-\s*)"(.*)"\s*$/); - if (!match) return line; - - const [, indent, content] = match; - return content.includes('"') - ? indent + "'" + content.replace(/'/g, "''") + "'" - : indent + '"' + content + '"'; - }) - .join('\n'); - } - - /** - * 更新maintext内容 - * @param {string} maintextContent maintext内容 - */ - function updateMaintext(maintextContent) { - try { - const maintextElement = document.getElementById('maintext'); - if (!maintextElement) return; - - // 如果内容为空或无效,设置为加载中状态 - if (!maintextContent || typeof maintextContent !== 'string' || !maintextContent.trim()) { - maintextElement.textContent = ''; - } else { - maintextElement.textContent = maintextContent; - } - - formatMainText(); - } catch (error) { - console.error('更新maintext失败:', error); - // 如果更新失败,直接调用formatMainText,它会处理错误 - formatMainText(); - } - } - - /** - * 重新渲染状态栏 - */ - function reRenderStatusBar() { - try { - const yamlScript = document.getElementById('yaml-data-source'); - if (!yamlScript || !yamlScript.textContent) return; - - const storyRenderer = new StoryRenderer('yaml-data-source'); - storyRenderer.init(); - } catch (error) { - console.error('重新渲染状态栏失败:', error); - // 状态栏渲染失败时,错误处理由StoryRenderer.handleError处理 - // 这里不需要额外处理,因为StoryRenderer的init方法已经有handleError调用 - } - } - - /** - * 根据消息数据渲染整个页面 - * @param {Object} messageData 消息数据对象(格式参考test.json) - */ - function renderPageFromMessage(messageData) { - let actualMessageData = Array.isArray(messageData) && messageData.length > 0 ? messageData[0] : messageData; - - if (!actualMessageData || !actualMessageData.message || typeof actualMessageData.message !== 'string') { - return; - } - - const messageContent = actualMessageData.message; - - // 提取并更新maintext内容 - const maintextContent = extractMaintext(messageContent); - if (maintextContent) { - updateMaintext(maintextContent); - } - - // 提取并更新Status_block内容 - const statusContent = extractStatusBlock(messageContent); - if (statusContent) { - updateYamlDataSource(statusContent); - setTimeout(() => reRenderStatusBar(), 100); - } - } - - // 执行获取操作并处理结果 - try { - const currentMessage = getCurrentMessage(); - if (currentMessage && typeof currentMessage === 'object') { - renderPageFromMessage(currentMessage); - } - } catch (error) { - console.error('获取或渲染消息时出错:', error); - } - - window.statusBlockInitialized = true; - }); - - - - - -pm - \ No newline at end of file diff --git a/docs/优化方案.md b/docs/优化方案.md deleted file mode 100644 index 9bc1f0a..0000000 --- a/docs/优化方案.md +++ /dev/null @@ -1,356 +0,0 @@ -## SillyTavern 完全兼容优化方案(Go + Gin + Postgres + React) - -> 目标:基于现有 Go + React 系统,构建一个与 SillyTavern(下称 ST)高度兼容的角色卡 / 世界书 / 正则 / 预设 / 对话平台。 - ---- - -### 1. 总体目标与设计原则 - -- **技术栈统一**:所有核心功能(角色卡、世界书、正则、预设、聊天、AI 集成)全部收敛到: - - **后端**:`server/` 下的 Go + Gin + PostgreSQL - - **前端**:`projects/web-app` 下的 React + TypeScript + Tailwind -- **SillyTavern 完全兼容**: - - 支持 ST 角色卡 V2/V3(chara_card_v2 / v3)导入导出; - - 支持 ST 世界书字段及触发逻辑(keys/secondary_keys/regex/depth/sticky/cooldown/probability/position 等); - - 支持 ST Regex Scripts 配置(placement/runOnEdit/markdownOnly/promptOnly/substituteRegex/min/max depth); - - 支持 ST 风格预设与 prompt 注入顺序(prompt_order / injection depth/position)。 -- **单一真相来源(SSOT)**: - - **数据库即真相**:Postgres 负责持久化所有 ST 相关实体; - - **前端只是 UI**:React 只做编辑/展示,请求都经过 API,不再有“前端内存版预设/世界书”。 -- **可扩展性**: - - 提前为插件系统预留 Hook(onUserInput/onWorldInfoScan/beforePromptBuild/onAssistantDone 等); - - 方便接入未来的 WebSocket/SSE 流式聊天、统计系统、市场/分享功能。 - ---- - -### 2. 当前 Go + React 系统现状(基于现有文档与代码) - -#### 2.1 后端(server/) - -根据 `projects/docs/development-progress.md`: - -- ✅ **用户系统** 已完成(2024-02-26): - - 模型:`AppUser`, `AppUserSession` - - API:`/app/auth/register`, `/app/auth/login`, `/app/auth/refresh`, `/app/auth/logout`, `/app/auth/userinfo` 等 - - JWT、会话、用户资料、密码修改均已实现 -- ✅ **角色卡管理(AICharacter)** 已完成: - - 模型:`AICharacter`(完全兼容 ST V2 格式),使用 JSONB 存储 `tags/alternateGreetings/characterBook/extensions` 等复杂结构 - - API: - - `POST /app/character` - - `GET /app/character`(分页、搜索、标签筛选) - - `GET /app/character/:id` - - `PUT /app/character/:id` - - `DELETE /app/character/:id` - - `POST /app/character/upload`(PNG/JSON 角色卡) - - `GET /app/character/:id/export`(导出 JSON) - - 工具:`utils/character_card.go`(PNG tEXt chunk / ST V2 工具) -- 🚧 **预设管理**: - - 前端页面 `/presets` 已完成(导入 JSON、复制、导出、编辑参数)。 - - 后端预设 API 尚未实现(`development-progress.md` 已给出规划端点)。 -- 📋 **对话系统 & AI 集成**: - - 前端基础 UI 已完成(`/chat` + 侧边栏 + 输入框)。 - - 后端对话 API、AI 流式集成、世界书/正则逻辑尚在规划阶段。 - -#### 2.2 前端(`projects/web-app`) - -- **用户系统前端**:`LoginPage/RegisterPage/ForgotPasswordPage/ProfilePage` 已完成,`api/client.ts + api/auth.ts` 已完成 token 注入与刷新。 -- **角色卡管理前端**:`CharacterManagePage` + `api/character.ts` - - 功能: - - 上传 PNG/JSON 角色卡(调用 `/app/character/upload`) - - 编辑角色卡核心字段、内嵌 `characterBook`(世界书)字段 - - 导出 JSON(调用 `/app/character/:id/export`) - - 搜索、分页、删除 -- **预设管理前端**:`PresetManagePage` - - 当前为 **前端内存里的假数据** + 文件导入/导出;尚未接入真实后端 `/app/preset`。 -- **聊天前端**:`ChatPage + Sidebar + ChatArea + CharacterPanel + SettingsPanel` - - 已实现基础布局、会话切换、角色选择、背景图/主题色设置等 UI。 - - 消息发送、历史加载、预设切换、世界书/正则开关还需后端配合。 -- **AI 配置前端**:`api/aiConfig.ts`(对应 `/app/ai-config` 系列)。 - -> 结论:**角色卡链路基本打通**,用户系统成熟;预设/对话/AI/世界书/正则目前主要停留在前端 UI 或规划层面,正好适合作为“集中下沉到 Go 后端”的突破口。 - ---- - -### 3. 目标架构(SillyTavern 兼容实现) - -#### 3.1 核心领域模型(后端) - -建议在现有 `model/app` 中引入或规范以下模型(部分已存在,可扩展): - -- `AICharacter`(已存在) - - ST 角色卡 V2/V3 的标准化版本 + 原始 JSON 存档。 -- `Worldbook` & `WorldbookEntry` - - 支持角色内世界书与全局世界书。 -- `RegexScript` - - 支持 scope:`global | character | preset`。 -- `Preset` & `PresetPrompt` & `PresetRegexBinding` - - 表达 ST preset 的参数、prompt 列表和 regex 绑定。 -- `Conversation` & `Message` - - 对话与消息记录,支持与 `AICharacter`、`Preset` 和 AI 配置关联。 - -#### 3.2 运行时 Pipeline - -所有客户端(现阶段只有 React Web,将来可增加其他)统一通过 Go 后端完成以下流程: - -1. **输入正则脚本处理**(global/character/preset) -2. **世界书扫描**(keys/secondary_keys/regex/depth/sticky/cooldown 等) -3. **Prompt 构建**(角色 system + world info + preset prompts + 历史消息) -4. **AI 调用**(OpenAI/Anthropic/custom via `/app/ai-config`) -5. **输出正则脚本处理** -6. **持久化消息与统计** - -React 前端只负责: - -- 提供管理与编辑界面; -- 将用户选择的 preset/worldbook/regex 传给后端; -- 使用 SSE/WebSocket 将 AI 流输出展示给用户。 - ---- - -### 4. 后端详细优化方案(Go + Gin + Postgres) - -#### 4.1 模型与数据库设计(概念级) - -> 不强制你立刻改现有表名与字段;这部分作为“目标状态”,可通过迁移脚本或视图逐步对齐。 - -- **AICharacter**(已有) - - 新增/规范字段: - - `raw_card_json JSONB`:存原始 ST 角色卡,用于无损导出。 - - `bound_worldbook_ids UUID[]`:角色绑定世界书 ID 列表(可选)。 - - `bound_regex_ids UUID[]`:角色绑定正则脚本 ID 列表(可选)。 -- **Worldbook / WorldbookEntry** - - Worldbook:`id, name, owner_char_id (nullable), meta JSONB, created_at, updated_at` - - WorldbookEntry:包含 ST 全字段: - - `uid, keys, secondary_keys, comment, content, constant, disabled, use_regex, case_sensitive, match_whole_words, selective, selective_logic, position, depth, order, probability, sticky, cooldown, delay, group, extra JSONB` -- **RegexScript** - - `id, name, find_regex, replace_with, trim_string` - - `placement INT[]` - - `disabled, markdown_only, run_on_edit, prompt_only` - - `substitute_regex, min_depth, max_depth` - - `scope, owner_char_id, owner_preset_id, raw_json` -- **Preset / PresetPrompt / PresetRegexBinding** - - 基本采样参数 + prompt 列表 + regex 绑定。 -- **Conversation / Message** - - Conversation:`id, user_id, character_id, preset_id, ai_config_id, title, settings JSONB, created_at, updated_at` - - Message:`id, conversation_id, role, content, token_count, created_at` - -#### 4.2 角色卡导入/导出(巩固现有实现) - -> 从 `development-progress.md` 看,`/app/character` 模块已经“完全兼容 ST V2”,这里更多是规范与扩展。 - -- **导入 `/app/character/upload` 已具备**: - - PNG:使用 `utils/character_card.go` 提取 `chara` tEXt chunk → JSON。 - - JSON:直接解析,填充 `AICharacter`。 -- 优化点: - - 当角色卡中包含 `character_book` 时: - - 自动在 `worldbooks/worldbook_entries` 中创建对应记录; - - 将 worldbook.id 写入 `AICharacter.bound_worldbook_ids`(或冗余到 `characterBook` 字段中)。 - - 当包含 `extensions.regex_scripts` 时: - - 自动创建 `RegexScript`(scope=character, owner_char_id=该角色)。 - - 将脚本 id 写入 `AICharacter.bound_regex_ids` 或 `extensions` 中。 -- **导出 `/app/character/:id/export`**: - - 若 `raw_card_json` 存在,优先以它为基础;将 DB 中新增的信息 patch 回去(例如补上最新的 worldbook/regex 变更)。 - - 若不存在,则按 ST V2 规范组装 JSON(兼容 V1/V3 的 data 字段)。 - -#### 4.3 世界书(World Info)引擎 - -1. **世界书激活来源**: - - 全局启用列表(per-user 或全局 settings 中的 `active_worldbook_ids`)。 - - 角色绑定:`AICharacter.bound_worldbook_ids`。 - - 会话特定选项:`conversation.settings.activeWorldbookIds`(从前端设置面板传入)。 -2. **触发与过滤算法**: - - 遍历所有激活 worldbook 的 entries: - - 忽略 `disabled` 的 entry。 - - 若 `constant=true`:无视关键词匹配,直接进入候选(仍受 sticky/cooldown/delay/probability 控制)。 - - keys 匹配: - - `use_regex=true`:将每个 key 作为正则(考虑 `caseSensitive`、`matchWholeWords` 标志)。 - - 否则:普通字符串包含匹配(可选大小写敏感)。 - - 若 `selective=true`:根据 `selectiveLogic` 结合 secondary_keys: - - `0=AND`:secondary_keys 至少一个命中; - - `1=OR`:主 keys 或 secondary_keys 任一命中; - - `2=NOT`:主 keys 命中,但 secondary_keys 不命中。 - - depth:仅在最近 `entry.depth` 条消息(或 tokens)中搜索关键字。 - - sticky/cooldown/delay:基于会话级状态(比如 Redis/内存/DB)存储 entry 上次触发时间或 sticky 状态。 - - probability:按百分比随机决定最终是否注入。 -3. **注入到 prompt 中**: - - 简化版:将所有命中 entry.content 拼接为 `[World Info]` 段,附加在 system prompt 后; - - 高级版:按 ST 行为,依据 position/depth/order,放到不同位置(角色描述前/后、历史中等)。 - -#### 4.4 正则脚本(Regex Scripts)引擎 - -1. **脚本来源**: - - Global:`scope='global'` 且 `disabled=false`。 - - Character:`scope='character' AND owner_char_id=当前角色`。 - - Preset:`preset_regex_bindings` 中绑定到当前 preset 的脚本。 -2. **执行阶段(placement)**: - - 输入阶段(placement=1):用户消息入库前; - - 输出阶段(placement=2):模型输出生成后、入库/返回前; - - 世界书阶段(placement=3):扫描 / 注入 world info 内容前; - - 推理阶段(placement=4):如有单独 reasoning prompt,可在构建 reasoning 时处理。 -3. **实现细节(Go)**: - - 解析 JS 风格正则 `/pattern/flags`,支持 `i/m/s`。 - - 替换宏: - - `substitute_regex=1` → 用当前用户名称替换 `{{user}}`; - - `=2` → 用当前角色名替换 `{{char}}`; - - `trim_string`:按行定义需要从文本中删掉的子串。 - - minDepth/maxDepth:结合消息历史深度决定是否执行脚本。 - - runOnEdit:在“消息编辑接口”中也调用该脚本集。 - - markdownOnly/promptOnly:通过标志决定作用在 UI 文本 or prompt 文本。 - -#### 4.5 Prompt Pipeline 与对话 API - -将现有/规划中的对话 API 统一收敛到一个 pipeline,例如 `/app/conversation/:id/message`(或 `/conversations/:id/messages`,按统一风格即可): - -1. **接收请求**: - -```json -{ - "content": "用户输入文本", - "options": { - "presetId": "uuid-xxx", - "overrideModel": "gpt-4", - "stream": true - } -} -``` - -2. **管线步骤**: - 1. 根据 conversationId 加载: - - Conversation、AICharacter、Preset、AI 配置、世界书/正则等上下文; - - 合并 settings(会话内 + 用户全局)。 - 2. 输入文本经过 regex 引擎(placement=1)。 - 3. 写入一条 user message 到 DB。 - 4. 从最近 N 条消息构造 world info 触发文本,调用世界书引擎得到 entries。 - 5. 构建 prompt: - - system:角色 system + scenario + authorsNote + preset.systemPrompt + world info; - - history:最近若干 user/assistant 消息; - - preset.prompts:按 depth/position 注入额外 messages。 - 6. 调用 AI 服务(依据 `/app/ai-config` 中 baseUrl/apiKey/model): - - 支持流式:通过 SSE/WebSocket 对前端推送 tokens。 - 7. 将完整 assistant 输出经过 regex 引擎(placement=2)。 - 8. 写入一条 assistant message 到 DB。 - 9. 返回响应给前端(同步返回最终内容 + 可选流式过程)。 - ---- - -### 5. 前端详细优化方案(React `projects/web-app`) - -#### 5.1 角色卡管理(CharacterManagePage) - -目标:完全对齐 ST 角色卡 V2/V3 字段,并为世界书/正则后端拆分打好基础。 - -优化点: - -- **字段映射明确化**: - - 确保 `Character` 接口与后端模型一致,并在注释中标明 ST 对应字段: - - `firstMes` ↔ `first_mes` - - `mesExample` ↔ `mes_example` - - `systemPrompt` ↔ `system_prompt` - - `postHistoryInstructions` ↔ `post_history_instructions` - - `characterBook` ↔ `character_book` - - `extensions` ↔ `extensions` -- **世界书编辑 UI 扩展**: - - 目前 `WorldBookEntry` 较简化(keys/content/enabled/insertion_order/position)。 - - 建议增加更多高级属性编辑项(可以先折叠到“高级设置”中): - - `secondary_keys` / `constant` / `use_regex` / `case_sensitive` / `match_whole_words`; - - `selective` / `selective_logic`; - - `depth` / `order` / `probability` / `sticky` / `cooldown` / `delay` / `group`。 - - 保存时将这些字段一并放入 `characterBook.entries`,后端负责拆分入 DB。 -- **导入/导出保持 ST 兼容**: - - 当前上传/导出流程基本正确;随着后端增强,无需大改,只要保证前端不破坏 JSON 结构即可。 - -#### 5.2 预设管理(PresetManagePage) - -目标:把当前的“前端内存预设”完全改造成**后端驱动的 ST 预设系统**。 - -实施步骤: - -1. 后端按 `development-progress.md` 规划实现 `/app/preset` API: - -```text -POST /app/preset -GET /app/preset -GET /app/preset/:id -PUT /app/preset/:id -DELETE /app/preset/:id -POST /app/preset/import -GET /app/preset/:id/export -``` - -2. 前端新建 `src/api/preset.ts` 对应这些端点。 -3. 将 `PresetManagePage` 中的 `useState` 初始数据删除,改为: - - `const { data } = await presetApi.getPresetList()`; - - 导入、导出、编辑、删除全部通过后端。 -4. 预设 JSON 的读写字段与 ST 对齐: - - `temperature/top_p/top_k/frequency_penalty/presence_penalty/system_prompt/stop_sequences/...`。 - -#### 5.3 聊天与设置面板(ChatPage / SettingsPanel / CharacterPanel) - -目标:把“选 preset / 选世界书 / 选正则”的入口统一放到设置面板,并通过 `conversation.settings` 与后端 pipeline 串联。 - -建议: - -- 在 `SettingsPanel` 中增加: - - 当前会话使用的 preset 选择器(下拉 list:来自 `/app/preset`)。 - - 可选的世界书列表(来自未来的 `/app/worldbook`,初期可只展示角色内 worldbook)。 - - 可选的全局正则脚本列表(来自未来的 `/app/regex`)。 -- 保存时调用 `/app/conversation/:id/settings`(或 `PUT /app/conversation/:id`): - -```ts -settings: { - backgroundImage: string - themeColor: string - presetId?: string - activeWorldbookIds?: string[] - activeRegexIds?: string[] -} -``` - -- 发送消息时,前端不再参与世界书/正则逻辑,只负责传 `content`,后端从 conversation/preset/character 中解析所有配置。 - ---- - -### 6. 分阶段落地路线图(仅 Go + React) - -#### 阶段 1:打通所有核心 CRUD 与数据流(短期) - -- 后端: - - 巩固 `/app/character` 模块(确保 ST V2/V3 完全兼容)。 - - 实现 `/app/preset` 模块(CRUD + 导入/导出)。 - - 设计并实现 `worldbooks/worldbook_entries` 与 `regex_scripts` 的数据结构与基础 API。 -- 前端: - - 改造 `PresetManagePage` 接入真实 API。 - - 在 `CharacterManagePage` 中补全世界书 entry 字段,保持 ST 兼容。 - -#### 阶段 2:实现 Prompt Pipeline 与对话 API(中期) - -- 后端: - - 聚合世界书与正则逻辑,形成独立的 `chatPipeline` service。 - - 在 `/app/conversation/:id/message`(或 `/conversations/:id/messages`)中调用 pipeline,完成一次“完整的 ST 风格对话请求”。 - - 引入流式输出(SSE 或 WebSocket)。 -- 前端: - - 在 `ChatPage/SettingsPanel` 中增加 preset/worldbook/regex 的选择与设置保存。 - - 调整 ChatArea 接收流式输出并实时渲染。 - -#### 阶段 3:高级特性与插件系统(长期) - -- 后端: - - 引入插件概念(`plugins` 表 + manifest + hooks)。 - - 实现插件执行沙箱(WASM 或 goja),并在 pipeline 中注入插件 hooks。 - - 增加统计、日志与审计功能。 -- 前端: - - 增加插件管理页面与可视化配置。 - - 对接统计与调试视图(例如:查看某次回复中哪些 world info/regex/插件生效)。 - ---- - -### 7. 总结 - -- 你当前的 Go + React 系统已经完成: - - **用户系统**(认证/资料); - - **角色卡管理**(完整 ST V2 兼容导入/导出); - - **预设管理与对话 UI 的前端骨架**。 -- 接下来最重要的三件事是: - - **在后端固化 ST 兼容的领域模型与 Prompt Pipeline**; - - **让 `/app/conversation` 成为唯一的“对话真相源”,React 只是 UI 壳**。 - diff --git a/server/api/v1/app/conversation.go b/server/api/v1/app/conversation.go index a424890..9a81910 100644 --- a/server/api/v1/app/conversation.go +++ b/server/api/v1/app/conversation.go @@ -255,7 +255,7 @@ func (a *ConversationApi) regenerateMessageStream(c *gin.Context, userID, conver go func() { if err := service.ServiceGroupApp.AppServiceGroup.ConversationService.RegenerateMessageStream( - userID, conversationID, streamChan, doneChan, + c.Request.Context(), userID, conversationID, streamChan, doneChan, ); err != nil { errorChan <- err } @@ -347,7 +347,7 @@ func (a *ConversationApi) SendMessageStream(c *gin.Context, userID, conversation // 启动流式传输 go func() { err := service.ServiceGroupApp.AppServiceGroup.ConversationService.SendMessageStream( - userID, conversationID, req, streamChan, doneChan, + c.Request.Context(), userID, conversationID, req, streamChan, doneChan, ) if err != nil { errorChan <- err diff --git a/server/api/v1/app/regex_script.go b/server/api/v1/app/regex_script.go index 17fc4b3..42152e5 100644 --- a/server/api/v1/app/regex_script.go +++ b/server/api/v1/app/regex_script.go @@ -51,6 +51,13 @@ func (a *RegexScriptApi) GetRegexScriptList(c *gin.Context) { scopeInt, _ := strconv.Atoi(scope) req.Scope = &scopeInt } + if ownerCharID := c.Query("ownerCharId"); ownerCharID != "" { + ownerCharIDUint, err := strconv.ParseUint(ownerCharID, 10, 32) + if err == nil { + v := uint(ownerCharIDUint) + req.OwnerCharID = &v + } + } if req.Page < 1 { req.Page = 1 diff --git a/server/core/server.go b/server/core/server.go index 213ae09..7d5c65c 100644 --- a/server/core/server.go +++ b/server/core/server.go @@ -40,5 +40,5 @@ func RunServer() { 默认MCP Message地址:http://127.0.0.1%s%s 默认前端文件运行地址:http://127.0.0.1:8080 `, address, address, global.GVA_CONFIG.MCP.SSEPath, address, global.GVA_CONFIG.MCP.MessagePath) - initServer(address, Router, 10*time.Minute, 10*time.Minute) + initServer(address, Router, 10*time.Minute, 0) } diff --git a/server/model/app/request/regex_script.go b/server/model/app/request/regex_script.go index c308110..ef33f50 100644 --- a/server/model/app/request/regex_script.go +++ b/server/model/app/request/regex_script.go @@ -42,10 +42,11 @@ type UpdateRegexScriptRequest struct { // GetRegexScriptListRequest 获取正则脚本列表请求 type GetRegexScriptListRequest struct { - Page int `json:"page"` - PageSize int `json:"pageSize"` - Keyword string `json:"keyword"` - Scope *int `json:"scope"` + Page int `json:"page"` + PageSize int `json:"pageSize"` + Keyword string `json:"keyword"` + Scope *int `json:"scope"` + OwnerCharID *uint `json:"ownerCharId"` // 过滤指定角色的脚本(scope=1时有效) } // TestRegexScriptRequest 测试正则脚本请求 diff --git a/server/service/app/conversation.go b/server/service/app/conversation.go index e16e302..a5d07ed 100644 --- a/server/service/app/conversation.go +++ b/server/service/app/conversation.go @@ -3,6 +3,7 @@ package app import ( "bufio" "bytes" + "context" "encoding/json" "errors" "fmt" @@ -371,21 +372,15 @@ func (s *ConversationService) SendMessage(userID, conversationID uint, req *requ return nil, err } - // 获取对话历史(最近10条) + // 获取完整对话历史(context 管理由 callAIService 内部处理) var messages []app.Message err = global.GVA_DB.Where("conversation_id = ?", conversationID). - Order("created_at DESC"). - Limit(10). + Order("created_at ASC"). Find(&messages).Error if err != nil { return nil, err } - // 反转消息顺序(从旧到新) - for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 { - messages[i], messages[j] = messages[j], messages[i] - } - // 调用 AI 服务获取回复 aiResponse, err := s.callAIService(conversation, character, messages) if err != nil { @@ -527,47 +522,26 @@ func (s *ConversationService) callAIService(conversation app.Conversation, chara } } - // 构建系统提示词(如果预设有系统提示词,则追加到角色卡提示词后) - systemPrompt := s.buildSystemPrompt(character) - if preset != nil && preset.SystemPrompt != "" { - systemPrompt = systemPrompt + "\n\n" + preset.SystemPrompt - global.GVA_LOG.Info("已追加预设的系统提示词") + // 构建消息列表(含 context 预算管理) + var presetSysPrompt string + if preset != nil { + presetSysPrompt = preset.SystemPrompt } + wbEngine := &WorldbookEngine{} + apiMessages := s.buildAPIMessagesWithContextManagement( + messages, character, presetSysPrompt, wbEngine, conversation, &aiConfig, preset, + ) - // 集成世界书触发引擎 - if conversation.WorldbookEnabled && conversation.WorldbookID != nil { - global.GVA_LOG.Info(fmt.Sprintf("世界书已启用,ID: %d", *conversation.WorldbookID)) - - // 提取消息内容用于扫描 - var messageContents []string - for _, msg := range messages { - messageContents = append(messageContents, msg.Content) - } - - // 使用世界书引擎扫描并触发条目 - engine := &WorldbookEngine{} - triggered, err := engine.ScanAndTrigger(*conversation.WorldbookID, messageContents) - if err != nil { - global.GVA_LOG.Warn(fmt.Sprintf("世界书触发失败: %v", err)) - } else if len(triggered) > 0 { - global.GVA_LOG.Info(fmt.Sprintf("触发了 %d 个世界书条目", len(triggered))) - // 将触发的世界书内容注入到系统提示词 - systemPrompt = engine.BuildPromptWithWorldbook(systemPrompt, triggered) - } else { - global.GVA_LOG.Info("没有触发任何世界书条目") - } + // 从 apiMessages 中提取 systemPrompt,供 Anthropic 独立参数使用 + systemPrompt := "" + if len(apiMessages) > 0 && apiMessages[0]["role"] == "system" { + systemPrompt = apiMessages[0]["content"] } - // 构建消息列表 - apiMessages := s.buildAPIMessages(messages, systemPrompt) - // 打印发送给AI的完整内容 global.GVA_LOG.Info("========== 发送给AI的完整内容 ==========") - global.GVA_LOG.Info(fmt.Sprintf("系统提示词: %s", systemPrompt)) - global.GVA_LOG.Info("消息列表:") - for i, msg := range apiMessages { - global.GVA_LOG.Info(fmt.Sprintf(" [%d] Role: %s, Content: %s", i, msg["role"], msg["content"])) - } + global.GVA_LOG.Info(fmt.Sprintf("系统提示词长度: %d 字符", len(systemPrompt))) + global.GVA_LOG.Info(fmt.Sprintf("历史消息条数: %d", len(apiMessages)-1)) global.GVA_LOG.Info("==========================================") // 确定使用的模型:如果用户在设置中指定了AI配置,则使用该配置的默认模型 @@ -735,7 +709,7 @@ func (s *ConversationService) getWeekdayInChinese(weekday time.Weekday) string { } // SendMessageStream 流式发送消息并获取 AI 回复 -func (s *ConversationService) SendMessageStream(userID, conversationID uint, req *request.SendMessageRequest, streamChan chan string, doneChan chan bool) error { +func (s *ConversationService) SendMessageStream(ctx context.Context, userID, conversationID uint, req *request.SendMessageRequest, streamChan chan string, doneChan chan bool) error { defer close(streamChan) defer close(doneChan) @@ -796,21 +770,15 @@ func (s *ConversationService) SendMessageStream(userID, conversationID uint, req return err } - // 获取对话历史(最近10条) + // 获取完整对话历史(context 管理由 buildAPIMessagesWithContextManagement 处理) var messages []app.Message err = global.GVA_DB.Where("conversation_id = ?", conversationID). - Order("created_at DESC"). - Limit(10). + Order("created_at ASC"). Find(&messages).Error if err != nil { return err } - // 反转消息顺序(从旧到新) - for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 { - messages[i], messages[j] = messages[j], messages[i] - } - // 获取 AI 配置 var aiConfig app.AIConfig var configID uint @@ -857,42 +825,26 @@ func (s *ConversationService) SendMessageStream(userID, conversationID uint, req } } - // 构建系统提示词(应用预设) - systemPrompt := s.buildSystemPrompt(character) - if streamPreset != nil && streamPreset.SystemPrompt != "" { - systemPrompt = systemPrompt + "\n\n" + streamPreset.SystemPrompt + // 构建消息列表(含 context 预算管理) + var streamPresetSysPrompt string + if streamPreset != nil { + streamPresetSysPrompt = streamPreset.SystemPrompt } + streamWbEngine := &WorldbookEngine{} + apiMessages := s.buildAPIMessagesWithContextManagement( + messages, character, streamPresetSysPrompt, streamWbEngine, conversation, &aiConfig, streamPreset, + ) - // 集成世界书触发引擎(流式传输) - if conversation.WorldbookEnabled && conversation.WorldbookID != nil { - global.GVA_LOG.Info(fmt.Sprintf("[流式传输] 世界书已启用,ID: %d", *conversation.WorldbookID)) - - var messageContents []string - for _, msg := range messages { - messageContents = append(messageContents, msg.Content) - } - - engine := &WorldbookEngine{} - triggeredEntries, wbErr := engine.ScanAndTrigger(*conversation.WorldbookID, messageContents) - if wbErr != nil { - global.GVA_LOG.Warn(fmt.Sprintf("[流式传输] 世界书触发失败: %v", wbErr)) - } else if len(triggeredEntries) > 0 { - global.GVA_LOG.Info(fmt.Sprintf("[流式传输] 触发了 %d 个世界书条目", len(triggeredEntries))) - systemPrompt = engine.BuildPromptWithWorldbook(systemPrompt, triggeredEntries) - } else { - global.GVA_LOG.Info("[流式传输] 没有触发任何世界书条目") - } + // 从 apiMessages 中提取 systemPrompt,供 Anthropic 独立参数使用 + systemPrompt := "" + if len(apiMessages) > 0 && apiMessages[0]["role"] == "system" { + systemPrompt = apiMessages[0]["content"] } - apiMessages := s.buildAPIMessages(messages, systemPrompt) - // 打印发送给AI的完整内容(流式传输) global.GVA_LOG.Info("========== [流式传输] 发送给AI的完整内容 ==========") - global.GVA_LOG.Info(fmt.Sprintf("系统提示词: %s", systemPrompt)) - global.GVA_LOG.Info("消息列表:") - for i, msg := range apiMessages { - global.GVA_LOG.Info(fmt.Sprintf(" [%d] Role: %s, Content: %s", i, msg["role"], msg["content"])) - } + global.GVA_LOG.Info(fmt.Sprintf("[流式传输] 系统提示词长度: %d 字符", len(systemPrompt))) + global.GVA_LOG.Info(fmt.Sprintf("[流式传输] 历史消息条数: %d", len(apiMessages)-1)) global.GVA_LOG.Info("==========================================") // 确定使用的模型 @@ -910,15 +862,21 @@ func (s *ConversationService) SendMessageStream(userID, conversationID uint, req var fullContent string switch aiConfig.Provider { case "openai", "custom": - fullContent, err = s.callOpenAIAPIStream(&aiConfig, model, apiMessages, streamPreset, streamChan) + fullContent, err = s.callOpenAIAPIStream(ctx, &aiConfig, model, apiMessages, streamPreset, streamChan) case "anthropic": - fullContent, err = s.callAnthropicAPIStream(&aiConfig, model, apiMessages, systemPrompt, streamPreset, streamChan) + fullContent, err = s.callAnthropicAPIStream(ctx, &aiConfig, model, apiMessages, systemPrompt, streamPreset, streamChan) default: return fmt.Errorf("不支持的 AI 提供商: %s", aiConfig.Provider) } if err != nil { global.GVA_LOG.Error(fmt.Sprintf("========== [流式传输] AI返回错误 ==========\n%v\n==========================================", err)) + // AI 调用失败,回滚已写入的用户消息,避免孤立记录残留在数据库 + if delErr := global.GVA_DB.Delete(&userMessage).Error; delErr != nil { + global.GVA_LOG.Error(fmt.Sprintf("[流式传输] 回滚用户消息失败: %v", delErr)) + } else { + global.GVA_LOG.Info("[流式传输] 已回滚用户消息") + } return err } @@ -962,8 +920,9 @@ func (s *ConversationService) SendMessageStream(userID, conversationID uint, req } // callOpenAIAPIStream 调用 OpenAI API 流式传输 -func (s *ConversationService) callOpenAIAPIStream(config *app.AIConfig, model string, messages []map[string]string, preset *app.AIPreset, streamChan chan string) (string, error) { - client := &http.Client{Timeout: 120 * time.Second} +func (s *ConversationService) callOpenAIAPIStream(ctx context.Context, config *app.AIConfig, model string, messages []map[string]string, preset *app.AIPreset, streamChan chan string) (string, error) { + // 不设 Timeout:生命周期由调用方传入的 ctx 控制(客户端断连时自动取消) + client := &http.Client{} if model == "" { model = config.DefaultModel @@ -1025,7 +984,7 @@ func (s *ConversationService) callOpenAIAPIStream(config *app.AIConfig, model st } endpoint := config.BaseURL + "/chat/completions" - req, err := http.NewRequest("POST", endpoint, bytes.NewBuffer(bodyBytes)) + req, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewBuffer(bodyBytes)) if err != nil { return "", fmt.Errorf("创建请求失败: %v", err) } @@ -1035,6 +994,10 @@ func (s *ConversationService) callOpenAIAPIStream(config *app.AIConfig, model st resp, err := client.Do(req) if err != nil { + // 客户端主动断开时 ctx 被取消,不算真正的错误 + if ctx.Err() != nil { + return "", nil + } return "", fmt.Errorf("请求失败: %v", err) } defer resp.Body.Close() @@ -1050,49 +1013,51 @@ func (s *ConversationService) callOpenAIAPIStream(config *app.AIConfig, model st for { line, err := reader.ReadString('\n') + // 先处理本次读到的数据(EOF 时可能仍携带最后一行内容) + if line != "" { + trimmed := strings.TrimSpace(line) + if trimmed != "" && trimmed != "data: [DONE]" && strings.HasPrefix(trimmed, "data: ") { + data := strings.TrimPrefix(trimmed, "data: ") + + var streamResp struct { + Choices []struct { + Delta struct { + Content string `json:"content"` + } `json:"delta"` + } `json:"choices"` + } + + if jsonErr := json.Unmarshal([]byte(data), &streamResp); jsonErr == nil { + if len(streamResp.Choices) > 0 { + content := streamResp.Choices[0].Delta.Content + if content != "" { + fullContent.WriteString(content) + streamChan <- content + } + } + } + } + } + // 再检查读取错误 if err != nil { if err == io.EOF { break } + // ctx 被取消(客户端断开)时不算真正的流读取错误 + if ctx.Err() != nil { + return fullContent.String(), nil + } return "", fmt.Errorf("读取流失败: %v", err) } - - line = strings.TrimSpace(line) - if line == "" || line == "data: [DONE]" { - continue - } - - if strings.HasPrefix(line, "data: ") { - data := strings.TrimPrefix(line, "data: ") - - var streamResp struct { - Choices []struct { - Delta struct { - Content string `json:"content"` - } `json:"delta"` - } `json:"choices"` - } - - if err := json.Unmarshal([]byte(data), &streamResp); err != nil { - continue - } - - if len(streamResp.Choices) > 0 { - content := streamResp.Choices[0].Delta.Content - if content != "" { - fullContent.WriteString(content) - streamChan <- content - } - } - } } return fullContent.String(), nil } // callAnthropicAPIStream 调用 Anthropic API 流式传输 -func (s *ConversationService) callAnthropicAPIStream(config *app.AIConfig, model string, messages []map[string]string, systemPrompt string, preset *app.AIPreset, streamChan chan string) (string, error) { - client := &http.Client{Timeout: 120 * time.Second} +func (s *ConversationService) callAnthropicAPIStream(ctx context.Context, config *app.AIConfig, model string, messages []map[string]string, systemPrompt string, preset *app.AIPreset, streamChan chan string) (string, error) { + // 不设 Timeout:生命周期由调用方传入的 ctx 控制(客户端断连时自动取消) + client := &http.Client{} if model == "" { model = config.DefaultModel @@ -1152,7 +1117,7 @@ func (s *ConversationService) callAnthropicAPIStream(config *app.AIConfig, model } endpoint := config.BaseURL + "/messages" - req, err := http.NewRequest("POST", endpoint, bytes.NewBuffer(bodyBytes)) + req, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewBuffer(bodyBytes)) if err != nil { return "", fmt.Errorf("创建请求失败: %v", err) } @@ -1163,6 +1128,10 @@ func (s *ConversationService) callAnthropicAPIStream(config *app.AIConfig, model resp, err := client.Do(req) if err != nil { + // 客户端主动断开时 ctx 被取消,不算真正的错误 + if ctx.Err() != nil { + return "", nil + } return "", fmt.Errorf("请求失败: %v", err) } defer resp.Body.Close() @@ -1178,38 +1147,39 @@ func (s *ConversationService) callAnthropicAPIStream(config *app.AIConfig, model for { line, err := reader.ReadString('\n') + // 先处理本次读到的数据(EOF 时可能仍携带最后一行内容) + if line != "" { + trimmed := strings.TrimSpace(line) + if trimmed != "" && strings.HasPrefix(trimmed, "data: ") { + data := strings.TrimPrefix(trimmed, "data: ") + + var streamResp struct { + Type string `json:"type"` + Delta struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"delta"` + } + + if jsonErr := json.Unmarshal([]byte(data), &streamResp); jsonErr == nil { + if streamResp.Type == "content_block_delta" && streamResp.Delta.Text != "" { + fullContent.WriteString(streamResp.Delta.Text) + streamChan <- streamResp.Delta.Text + } + } + } + } + // 再检查读取错误 if err != nil { if err == io.EOF { break } + // ctx 被取消(客户端断开)时不算真正的流读取错误 + if ctx.Err() != nil { + return fullContent.String(), nil + } return "", fmt.Errorf("读取流失败: %v", err) } - - line = strings.TrimSpace(line) - if line == "" { - continue - } - - if strings.HasPrefix(line, "data: ") { - data := strings.TrimPrefix(line, "data: ") - - var streamResp struct { - Type string `json:"type"` - Delta struct { - Type string `json:"type"` - Text string `json:"text"` - } `json:"delta"` - } - - if err := json.Unmarshal([]byte(data), &streamResp); err != nil { - continue - } - - if streamResp.Type == "content_block_delta" && streamResp.Delta.Text != "" { - fullContent.WriteString(streamResp.Delta.Text) - streamChan <- streamResp.Delta.Text - } - } } return fullContent.String(), nil @@ -1243,19 +1213,16 @@ func (s *ConversationService) RegenerateMessage(userID, conversationID uint) (*r }) } - // 获取删除后的消息历史 + // 获取删除后的完整消息历史(context 管理由 callAIService 内部处理) var messages []app.Message err = global.GVA_DB.Where("conversation_id = ?", conversationID). - Order("created_at DESC").Limit(10).Find(&messages).Error + Order("created_at ASC").Find(&messages).Error if err != nil { return nil, err } if len(messages) == 0 { return nil, errors.New("没有可用的消息历史") } - for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 { - messages[i], messages[j] = messages[j], messages[i] - } aiResponse, err := s.callAIService(conversation, character, messages) if err != nil { @@ -1282,7 +1249,7 @@ func (s *ConversationService) RegenerateMessage(userID, conversationID uint) (*r } // RegenerateMessageStream 流式重新生成最后一条 AI 回复 -func (s *ConversationService) RegenerateMessageStream(userID, conversationID uint, streamChan chan string, doneChan chan bool) error { +func (s *ConversationService) RegenerateMessageStream(ctx context.Context, userID, conversationID uint, streamChan chan string, doneChan chan bool) error { defer close(streamChan) defer close(doneChan) @@ -1312,19 +1279,16 @@ func (s *ConversationService) RegenerateMessageStream(userID, conversationID uin }) } - // 获取删除后的消息历史 + // 获取删除后的完整消息历史(context 管理由 buildAPIMessagesWithContextManagement 处理) var messages []app.Message err = global.GVA_DB.Where("conversation_id = ?", conversationID). - Order("created_at DESC").Limit(10).Find(&messages).Error + Order("created_at ASC").Find(&messages).Error if err != nil { return err } if len(messages) == 0 { return errors.New("没有可用的消息历史") } - for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 { - messages[i], messages[j] = messages[j], messages[i] - } // 获取 AI 配置 var aiConfig app.AIConfig @@ -1367,11 +1331,21 @@ func (s *ConversationService) RegenerateMessageStream(userID, conversationID uin } } - systemPrompt := s.buildSystemPrompt(character) - if preset != nil && preset.SystemPrompt != "" { - systemPrompt = systemPrompt + "\n\n" + preset.SystemPrompt + // 构建消息列表(含 context 预算管理) + var regenPresetSysPrompt string + if preset != nil { + regenPresetSysPrompt = preset.SystemPrompt + } + regenWbEngine := &WorldbookEngine{} + apiMessages := s.buildAPIMessagesWithContextManagement( + messages, character, regenPresetSysPrompt, regenWbEngine, conversation, &aiConfig, preset, + ) + + // 从 apiMessages 中提取 systemPrompt,供 Anthropic 独立参数使用 + systemPrompt := "" + if len(apiMessages) > 0 && apiMessages[0]["role"] == "system" { + systemPrompt = apiMessages[0]["content"] } - apiMessages := s.buildAPIMessages(messages, systemPrompt) model := aiConfig.DefaultModel if model == "" { @@ -1384,14 +1358,27 @@ func (s *ConversationService) RegenerateMessageStream(userID, conversationID uin var fullContent string switch aiConfig.Provider { case "openai", "custom": - fullContent, err = s.callOpenAIAPIStream(&aiConfig, model, apiMessages, preset, streamChan) + fullContent, err = s.callOpenAIAPIStream(ctx, &aiConfig, model, apiMessages, preset, streamChan) case "anthropic": - fullContent, err = s.callAnthropicAPIStream(&aiConfig, model, apiMessages, systemPrompt, preset, streamChan) + fullContent, err = s.callAnthropicAPIStream(ctx, &aiConfig, model, apiMessages, systemPrompt, preset, streamChan) default: return fmt.Errorf("不支持的 AI 提供商: %s", aiConfig.Provider) } if err != nil { + // AI 调用失败,恢复刚才删除的 assistant 消息,避免数据永久丢失 + if lastAssistantMsg.ID > 0 { + if restoreErr := global.GVA_DB.Unscoped().Model(&lastAssistantMsg).Update("deleted_at", nil).Error; restoreErr != nil { + global.GVA_LOG.Error(fmt.Sprintf("[重新生成] 恢复 assistant 消息失败: %v", restoreErr)) + } else { + // 回滚 conversation 统计 + global.GVA_DB.Model(&conversation).Updates(map[string]interface{}{ + "message_count": gorm.Expr("message_count + 1"), + "token_count": gorm.Expr("token_count + ?", lastAssistantMsg.TokenCount), + }) + global.GVA_LOG.Info("[重新生成] 已恢复 assistant 消息") + } + } return err } @@ -1437,9 +1424,333 @@ func (s *ConversationService) buildAPIMessages(messages []app.Message, systemPro return apiMessages } +// estimateTokens 粗略估算文本的 token 数(字符数 / 3,适用于中英混合文本) +func estimateTokens(text string) int { + if text == "" { + return 0 + } + // 中文字符约 1 char = 1 token,英文约 4 chars = 1 token + // 取中间值 1 char ≈ 0.75 token,即 chars * 4 / 3 的倒数 ≈ chars / 1.5 + // 保守估算用 chars / 2 防止超出 + n := len([]rune(text)) + return (n + 1) / 2 +} + +// contextConfig 保存从 AIConfig.Settings 中解析出的上下文配置 +type contextConfig struct { + contextLength int // 模型上下文窗口大小(token 数) + maxTokens int // 最大输出 token 数 +} + +// getContextConfig 从 AIConfig 中读取上下文配置,如果没有配置则使用默认值 +func getContextConfig(aiConfig *app.AIConfig, preset *app.AIPreset) contextConfig { + cfg := contextConfig{ + contextLength: 200000, // 保守默认值 + maxTokens: 2000, + } + + // 从 preset 读取 max_tokens + if preset != nil && preset.MaxTokens > 0 { + cfg.maxTokens = preset.MaxTokens + } + + // 从 AIConfig.Settings 读取 context_length + if len(aiConfig.Settings) > 0 { + var settings map[string]interface{} + if err := json.Unmarshal(aiConfig.Settings, &settings); err == nil { + if cl, ok := settings["context_length"].(float64); ok && cl > 0 { + cfg.contextLength = int(cl) + } + } + } + + return cfg +} + +// buildContextManagedSystemPrompt 按优先级构建 system prompt,超出 budget 时截断低优先级内容 +// 优先级(从高到低): +// 1. 核心人设(Name/Description/Personality/Scenario/SystemPrompt) +// 2. Preset.SystemPrompt +// 3. Worldbook 触发条目 +// 4. CharacterBook 内嵌条目 +// 5. MesExample(对话示例,最容易被截断) +// +// 返回构建好的 systemPrompt 以及消耗的 token 数 +func (s *ConversationService) buildContextManagedSystemPrompt( + character app.AICharacter, + presetSystemPrompt string, + worldbookEngine *WorldbookEngine, + conversation app.Conversation, + messageContents []string, + budget int, +) (string, int) { + used := 0 + + // ── 优先级1:核心人设 ───────────────────────────────────────────── + core := fmt.Sprintf("你是 %s。", character.Name) + if character.Description != "" { + core += fmt.Sprintf("\n\n描述:%s", character.Description) + } + if character.Personality != "" { + core += fmt.Sprintf("\n\n性格:%s", character.Personality) + } + if character.Scenario != "" { + core += fmt.Sprintf("\n\n场景:%s", character.Scenario) + } + if character.SystemPrompt != "" { + core += fmt.Sprintf("\n\n系统提示:%s", character.SystemPrompt) + } + core += "\n\n请根据以上设定进行角色扮演,保持角色的性格和说话方式。" + core = s.applyMacroVariables(core, character) + + coreTokens := estimateTokens(core) + if coreTokens >= budget { + // 极端情况:核心人设本身就超出 budget,截断到 budget + runes := []rune(core) + limit := budget * 2 + if limit > len(runes) { + limit = len(runes) + } + core = string(runes[:limit]) + global.GVA_LOG.Warn(fmt.Sprintf("[context] 核心人设超出 budget,已截断至 %d chars", limit)) + return core, budget + } + used += coreTokens + prompt := core + + // ── 优先级2:Preset.SystemPrompt ──────────────────────────────── + if presetSystemPrompt != "" { + tokens := estimateTokens(presetSystemPrompt) + if used+tokens <= budget { + prompt += "\n\n" + presetSystemPrompt + used += tokens + } else { + // 尝试部分插入 + remaining := budget - used + if remaining > 50 { + runes := []rune(presetSystemPrompt) + limit := remaining * 2 + if limit > len(runes) { + limit = len(runes) + } + prompt += "\n\n" + string(runes[:limit]) + used = budget + } + global.GVA_LOG.Warn(fmt.Sprintf("[context] Preset.SystemPrompt 因 budget 不足被截断(需要 %d tokens,剩余 %d)", tokens, budget-used)) + } + } + + if used >= budget { + return prompt, used + } + + // ── 优先级3:世界书触发条目 ────────────────────────────────────── + if conversation.WorldbookEnabled && conversation.WorldbookID != nil && worldbookEngine != nil { + triggeredEntries, wbErr := worldbookEngine.ScanAndTrigger(*conversation.WorldbookID, messageContents) + if wbErr != nil { + global.GVA_LOG.Warn(fmt.Sprintf("[context] 世界书触发失败: %v", wbErr)) + } else if len(triggeredEntries) > 0 { + wbHeader := "\n\n[World Information]" + wbSection := wbHeader + for _, te := range triggeredEntries { + if te.Entry == nil || te.Entry.Content == "" { + continue + } + line := fmt.Sprintf("\n- %s", te.Entry.Content) + lineTokens := estimateTokens(line) + if used+estimateTokens(wbSection)+lineTokens <= budget { + wbSection += line + used += lineTokens + } else { + global.GVA_LOG.Warn(fmt.Sprintf("[context] 世界书条目 (id=%d) 因 budget 不足被跳过", te.Entry.ID)) + break + } + } + if wbSection != wbHeader { + prompt += wbSection + } + } + } + + if used >= budget { + return prompt, used + } + + // ── 优先级4:CharacterBook 内嵌条目 ────────────────────────────── + if len(character.CharacterBook) > 0 { + var characterBook map[string]interface{} + if err := json.Unmarshal(character.CharacterBook, &characterBook); err == nil { + if entries, ok := characterBook["entries"].([]interface{}); ok && len(entries) > 0 { + cbSection := "\n\n世界设定:" + addedAny := false + for _, entry := range entries { + entryMap, ok := entry.(map[string]interface{}) + if !ok { + continue + } + enabled := true + if enabledVal, ok := entryMap["enabled"].(bool); ok { + enabled = enabledVal + } + if !enabled { + continue + } + content, ok := entryMap["content"].(string) + if !ok || content == "" { + continue + } + line := fmt.Sprintf("\n- %s", content) + lineTokens := estimateTokens(line) + if used+estimateTokens(cbSection)+lineTokens <= budget { + cbSection += line + used += lineTokens + addedAny = true + } else { + global.GVA_LOG.Warn("[context] CharacterBook 条目因 budget 不足被跳过") + break + } + } + if addedAny { + prompt += cbSection + } + } + } + } + + if used >= budget { + return prompt, used + } + + // ── 优先级5:MesExample(对话示例,最低优先级)────────────────── + if character.MesExample != "" { + mesTokens := estimateTokens(character.MesExample) + prefix := "\n\n对话示例:\n" + prefixTokens := estimateTokens(prefix) + if used+prefixTokens+mesTokens <= budget { + prompt += prefix + character.MesExample + used += prefixTokens + mesTokens + } else { + // 尝试截断 MesExample + remaining := budget - used - prefixTokens + if remaining > 100 { + runes := []rune(character.MesExample) + limit := remaining * 2 + if limit > len(runes) { + limit = len(runes) + } + prompt += prefix + string(runes[:limit]) + used = budget + global.GVA_LOG.Warn(fmt.Sprintf("[context] MesExample 被截断(原始 %d tokens,保留约 %d tokens)", mesTokens, remaining)) + } else { + global.GVA_LOG.Warn("[context] MesExample 因 budget 不足被完全跳过") + } + } + } + + return prompt, used +} + +// trimMessagesToBudget 从历史消息中按 token 预算选取最近的消息 +// 优先保留最新的消息,从后往前丢弃旧消息直到 token 数在 budget 内 +func trimMessagesToBudget(messages []app.Message, budget int) []app.Message { + if budget <= 0 { + return nil + } + + // messages 已经是从旧到新的顺序 + // 从最新消息开始往前累加,直到超出 budget + selected := make([]app.Message, 0, len(messages)) + used := 0 + + for i := len(messages) - 1; i >= 0; i-- { + msg := messages[i] + if msg.Role == "system" { + continue + } + t := estimateTokens(msg.Content) + if used+t > budget { + global.GVA_LOG.Warn(fmt.Sprintf("[context] 历史消息已截断,保留最近 %d 条(共 %d 条),使用 %d tokens", len(selected), len(messages), used)) + break + } + used += t + selected = append([]app.Message{msg}, selected...) // 保持时序 + } + + return selected +} + +// buildAPIMessagesWithContextManagement 整合 context 管理,构建最终的 messages 列表 +// 返回 apiMessages 及各部分 token 统计日志 +func (s *ConversationService) buildAPIMessagesWithContextManagement( + allMessages []app.Message, + character app.AICharacter, + presetSystemPrompt string, + worldbookEngine *WorldbookEngine, + conversation app.Conversation, + aiConfig *app.AIConfig, + preset *app.AIPreset, +) []map[string]string { + cfg := getContextConfig(aiConfig, preset) + + // 安全边际:为输出保留 max_tokens,另加 200 token 缓冲 + inputBudget := cfg.contextLength - cfg.maxTokens - 200 + if inputBudget <= 0 { + inputBudget = cfg.contextLength / 2 + } + + // 为历史消息分配预算:system prompt 最多占用 60% 的 input budget + systemBudget := inputBudget * 60 / 100 + historyBudget := inputBudget - systemBudget + + // 提取消息内容用于世界书扫描 + var messageContents []string + for _, msg := range allMessages { + messageContents = append(messageContents, msg.Content) + } + + // 构建 system prompt(含 worldbook 注入,按优先级截断) + systemPrompt, systemTokens := s.buildContextManagedSystemPrompt( + character, + presetSystemPrompt, + worldbookEngine, + conversation, + messageContents, + systemBudget, + ) + + // 如果 system prompt 实际用量比预算少,把节省的预算让给历史消息 + if systemTokens < systemBudget { + historyBudget += systemBudget - systemTokens + } + + global.GVA_LOG.Info(fmt.Sprintf("[context] 配置:context_length=%d, max_tokens=%d, input_budget=%d, system=%d tokens, history_budget=%d", + cfg.contextLength, cfg.maxTokens, inputBudget, systemTokens, historyBudget)) + + // 按 token 预算裁剪历史消息 + trimmedMessages := trimMessagesToBudget(allMessages, historyBudget) + + // 构建最终 messages + apiMessages := make([]map[string]string, 0, len(trimmedMessages)+1) + apiMessages = append(apiMessages, map[string]string{ + "role": "system", + "content": systemPrompt, + }) + for _, msg := range trimmedMessages { + if msg.Role == "system" { + continue + } + apiMessages = append(apiMessages, map[string]string{ + "role": msg.Role, + "content": msg.Content, + }) + } + + return apiMessages +} + // callOpenAIAPI 调用 OpenAI API func (s *ConversationService) callOpenAIAPI(config *app.AIConfig, model string, messages []map[string]string, preset *app.AIPreset) (string, error) { - client := &http.Client{Timeout: 120 * time.Second} + client := &http.Client{Timeout: 10 * time.Minute} // 使用配置的模型或默认模型 if model == "" { @@ -1559,7 +1870,7 @@ func (s *ConversationService) callOpenAIAPI(config *app.AIConfig, model string, // callAnthropicAPI 调用 Anthropic API func (s *ConversationService) callAnthropicAPI(config *app.AIConfig, model string, messages []map[string]string, systemPrompt string, preset *app.AIPreset) (string, error) { - client := &http.Client{Timeout: 120 * time.Second} + client := &http.Client{Timeout: 10 * time.Minute} // 使用配置的模型或默认模型 if model == "" { diff --git a/server/service/app/regex_script.go b/server/service/app/regex_script.go index f149d04..3dd9ce3 100644 --- a/server/service/app/regex_script.go +++ b/server/service/app/regex_script.go @@ -69,6 +69,10 @@ func (s *RegexScriptService) GetRegexScriptList(userID uint, req *request.GetReg db = db.Where("scope = ?", *req.Scope) } + if req.OwnerCharID != nil { + db = db.Where("owner_char_id = ?", *req.OwnerCharID) + } + if err := db.Count(&total).Error; err != nil { return nil, 0, err } diff --git a/web-app/package-lock.json b/web-app/package-lock.json index cf2a684..b8b37fe 100644 --- a/web-app/package-lock.json +++ b/web-app/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "dependencies": { "axios": "^1.13.5", + "js-yaml": "^4.1.1", "lucide-react": "^0.344.0", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -20,6 +21,7 @@ "zustand": "^5.0.11" }, "devDependencies": { + "@types/js-yaml": "^4.0.9", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", @@ -1239,6 +1241,13 @@ "@types/unist": "*" } }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -1341,6 +1350,12 @@ "dev": true, "license": "MIT" }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -2458,6 +2473,18 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", diff --git a/web-app/package.json b/web-app/package.json index 506ef7e..ee84a31 100644 --- a/web-app/package.json +++ b/web-app/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "axios": "^1.13.5", + "js-yaml": "^4.1.1", "lucide-react": "^0.344.0", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -21,6 +22,7 @@ "zustand": "^5.0.11" }, "devDependencies": { + "@types/js-yaml": "^4.0.9", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", diff --git a/web-app/src/api/character.ts b/web-app/src/api/character.ts index 8d9b1f3..16401a0 100644 --- a/web-app/src/api/character.ts +++ b/web-app/src/api/character.ts @@ -1,6 +1,54 @@ import apiClient from './client' -// 类型定义 +// ============= 前端卡类型 ============= + +/** + * 前端卡:存储在角色卡 extensions.frontend_card 中的 HTML/JS 面板。 + * 在聊天界面中固定显示,内容完全由卡作者自定义。 + */ +export interface FrontendCard { + html: string + enabled?: boolean + /** 显示位置:顶部(默认)或底部 */ + position?: 'top' | 'bottom' +} + +/** + * 从角色卡 extensions 中提取前端卡配置。 + * 支持多种常见格式: + * - extensions.frontend_card.html(本平台标准格式) + * - extensions.frontend_card(字符串,直接就是 HTML) + * - extensions.chara_card_ui(字符串,ST 社区常见格式) + */ +export function extractFrontendCard(extensions: Record | null | undefined): FrontendCard | null { + if (!extensions) return null + + // 标准格式:extensions.frontend_card 是对象 + const fc = extensions['frontend_card'] + if (fc) { + if (typeof fc === 'string' && fc.trim()) { + return { html: fc, enabled: true, position: 'top' } + } + if (typeof fc === 'object' && typeof fc.html === 'string' && fc.html.trim()) { + return { + html: fc.html, + enabled: fc.enabled !== false, + position: fc.position === 'bottom' ? 'bottom' : 'top', + } + } + } + + // 兼容格式:extensions.chara_card_ui(ST 社区卡常用) + const charaCardUi = extensions['chara_card_ui'] + if (typeof charaCardUi === 'string' && charaCardUi.trim()) { + return { html: charaCardUi, enabled: true, position: 'top' } + } + + return null +} + +// ============= 角色卡类型定义 ============= + export interface Character { id: number name: string diff --git a/web-app/src/api/regex.ts b/web-app/src/api/regex.ts index 02ef517..7c2cc61 100644 --- a/web-app/src/api/regex.ts +++ b/web-app/src/api/regex.ts @@ -70,6 +70,7 @@ export interface GetRegexScriptListRequest { pageSize?: number keyword?: string scope?: number + ownerCharId?: number } export interface RegexScriptListResponse { diff --git a/web-app/src/components/ChatArea.tsx b/web-app/src/components/ChatArea.tsx index 85e2c0e..ef15a24 100644 --- a/web-app/src/components/ChatArea.tsx +++ b/web-app/src/components/ChatArea.tsx @@ -13,12 +13,17 @@ import { Waves, Zap } from 'lucide-react' -import {useEffect, useRef, useState} from 'react' +import {useCallback, useEffect, useMemo, useRef, useState} from 'react' +import {useNavigate} from 'react-router-dom' import {type Conversation, conversationApi, type Message} from '../api/conversation' -import {type Character} from '../api/character' +import {type Character, extractFrontendCard} from '../api/character' import {type AIConfig, aiConfigApi} from '../api/aiConfig' import {type Preset, presetApi} from '../api/preset' +import {type RegexScript, regexAPI} from '../api/regex' import MessageContent from './MessageContent' +import StatusBarIframe from './StatusBarIframe' +import {useAppStore} from '../store' +import {streamSSE} from '../lib/sse' interface ChatAreaProps { conversation: Conversation @@ -26,7 +31,18 @@ interface ChatAreaProps { onConversationUpdate: (conversation: Conversation) => void } +/** 解析 conversation.settings(兼容 string 与 object 两种形式) */ +function parseSettings(raw: unknown): Record { + if (!raw) return {} + if (typeof raw === 'string') { + try { return JSON.parse(raw) } catch { return {} } + } + if (typeof raw === 'object') return raw as Record + return {} +} + export default function ChatArea({ conversation, character, onConversationUpdate }: ChatAreaProps) { + const navigate = useNavigate() const [messages, setMessages] = useState([]) const [inputValue, setInputValue] = useState('') const [sending, setSending] = useState(false) @@ -40,12 +56,29 @@ export default function ChatArea({ conversation, character, onConversationUpdate const [presets, setPresets] = useState([]) const [selectedPresetId, setSelectedPresetId] = useState() const [showPresetSelector, setShowPresetSelector] = useState(false) + const [regexScripts, setRegexScripts] = useState([]) + /** 当前正在流式输出的消息 ID(流结束后清除为 null) */ + const [streamingMsgId, setStreamingMsgId] = useState(null) + /** 发送/重新生成失败时的错误提示(显示在输入框上方,自动清除) */ + const [sendError, setSendError] = useState(null) + const messagesEndRef = useRef(null) const textareaRef = useRef(null) const modelSelectorRef = useRef(null) const presetSelectorRef = useRef(null) const menuRef = useRef(null) + // 用 ref 跟踪 sending 状态,避免事件监听器的 stale closure 问题 + const sendingRef = useRef(false) + /** + * 稳定 key 映射:tempId → stableKey(字符串) + * 流式期间 key 保持不变,流结束后服务端 ID 替换 tempId 时 key 也不变, + * 防止 React 因 key 变化而卸载/重新挂载消息节点,消除闪屏。 + */ + const stableKeyMap = useRef>(new Map()) + const { user, variables } = useAppStore() + + // ---- click-outside 关闭下拉菜单 ---- useEffect(() => { const handleClickOutside = (event: MouseEvent) => { const target = event.target as HTMLElement @@ -63,70 +96,164 @@ export default function ChatArea({ conversation, character, onConversationUpdate return () => document.removeEventListener('mousedown', handleClickOutside) }, [showModelSelector, showPresetSelector, showMenu]) + // ---- 初始化加载 ---- useEffect(() => { loadMessages() loadAIConfigs() - loadCurrentConfig() loadPresets() - loadCurrentPreset() + loadRegexScripts() + const settings = parseSettings(conversation.settings) + if (settings.aiConfigId) setSelectedConfigId(settings.aiConfigId as number) + // 优先用 conversation.presetId 字段,再降级到 settings.presetId + const presetId = conversation.presetId ?? (settings.presetId as number | undefined) + setSelectedPresetId(presetId) }, [conversation.id]) + // ---- 消息滚动 ---- useEffect(() => { scrollToBottom() }, [messages]) - // 监听状态栏按钮点击事件 + // ---- 状态栏操作监听(用 ref 跟踪 sending,消除 stale closure) ---- + // handleSendMessage 通过 useCallback 保持稳定引用 + const handleSendMessage = useCallback(async (message: string) => { + if (!message.trim() || sendingRef.current) return + + const userMessage = message.trim() + setSending(true) + sendingRef.current = true + setSendError(null) + + const tempUserMsg: Message = { + id: Date.now(), + conversationId: conversation.id, + role: 'user', + content: userMessage, + tokenCount: 0, + createdAt: new Date().toISOString(), + } + setMessages(prev => [...prev, tempUserMsg]) + + const tempAIMsg: Message = { + id: Date.now() + 1, + conversationId: conversation.id, + role: 'assistant', + content: '', + tokenCount: 0, + createdAt: new Date().toISOString(), + } + + try { + if (streamEnabled) { + setMessages(prev => [...prev, tempAIMsg]) + setStreamingMsgId(tempAIMsg.id) + let fullContent = '' + for await (const ev of streamSSE( + `/app/conversation/${conversation.id}/message?stream=true`, + 'POST', + { content: userMessage } + )) { + if (ev.event === 'message') { + fullContent += ev.data + setMessages(prev => + prev.map(m => m.id === tempAIMsg.id ? { ...m, content: fullContent } : m) + ) + } else if (ev.event === 'done') { + break + } else if (ev.event === 'error') { + throw new Error(ev.data) + } + } + // 先原地清除 streaming 标记(内容已完整),不触发任何额外渲染 + setStreamingMsgId(null) + // 后台静默拉取服务端最终数据(同步真实 ID 和 tokenCount),不显示 loading + loadMessages(true).then(() => { + conversationApi.getConversationById(conversation.id).then(convResp => { + onConversationUpdate(convResp.data) + }).catch(() => {}) + }).catch(() => {}) + } else { + await conversationApi.sendMessage(conversation.id, { content: userMessage }) + await loadMessages() + const convResp = await conversationApi.getConversationById(conversation.id) + onConversationUpdate(convResp.data) + } + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : '发送消息失败,请重试' + console.error('发送消息失败:', err) + setStreamingMsgId(null) + // 撤回用户消息到输入框,移除临时消息气泡 + setMessages(prev => prev.filter(m => m.id !== tempUserMsg.id && m.id !== tempAIMsg.id)) + setInputValue(userMessage) + setSendError(msg) + } finally { + setSending(false) + sendingRef.current = false + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [conversation.id, streamEnabled]) + useEffect(() => { const handleStatusBarAction = (event: CustomEvent) => { const action = event.detail - if (action && typeof action === 'string' && !sending) { + if (action && typeof action === 'string' && !sendingRef.current) { console.log('[ChatArea] 收到状态栏操作,自动发送消息:', action) - setInputValue(action) - // 延迟发送,确保 inputValue 已更新 - setTimeout(() => { - handleSendMessage(action) - }, 100) + handleSendMessage(action) } } - window.addEventListener('sendMessageFromStatusBar', handleStatusBarAction as EventListener) return () => window.removeEventListener('sendMessageFromStatusBar', handleStatusBarAction as EventListener) - }, [sending]) + }, [handleSendMessage]) - const loadMessages = async () => { + /** + * 处理来自 iframe 状态栏的操作: + * - fillInput / playerAction → 填充输入框(不发送) + * - triggerAction → 解析 /send 并自动发送 + * 命令格式示例:"/send 我选择休息|/trigger" + */ + const handleIframeAction = useCallback(( + type: 'fillInput' | 'playerAction' | 'triggerAction', + payload: string + ) => { + if (type === 'fillInput' || type === 'playerAction') { + setInputValue(payload) + textareaRef.current?.focus() + } else if (type === 'triggerAction') { + // 解析 ST slash 命令:取第一段 /send 之后的文本 + // 例:" /send 我选择攻击|/trigger " → "我选择攻击" + const sendMatch = payload.match(/\/send\s+([^|]+)/i) + const text = sendMatch ? sendMatch[1].trim() : payload.trim() + if (text) { + handleSendMessage(text) + } + } + }, [handleSendMessage]) + + // ---- 数据加载 ---- + /** + * @param silent - true 时不设置 loading 状态(后台静默刷新,不触发整屏 loading) + */ + const loadMessages = async (silent = false) => { try { - setLoading(true) + if (!silent) setLoading(true) const response = await conversationApi.getMessageList(conversation.id, { page: 1, pageSize: 100 }) setMessages(response.data.list || []) } catch (err) { console.error('加载消息失败:', err) } finally { - setLoading(false) + if (!silent) setLoading(false) } } const loadAIConfigs = async () => { try { const response = await aiConfigApi.getAIConfigList() - setAiConfigs(response.data.list.filter(config => config.isActive)) + setAiConfigs(response.data.list.filter((c: AIConfig) => c.isActive)) } catch (err) { console.error('加载 AI 配置失败:', err) } } - const loadCurrentConfig = () => { - if (conversation.settings) { - try { - const settings = typeof conversation.settings === 'string' - ? JSON.parse(conversation.settings) - : conversation.settings - if (settings.aiConfigId) setSelectedConfigId(settings.aiConfigId) - } catch (e) { - console.error('解析设置失败:', e) - } - } - } - const loadPresets = async () => { try { const response = await presetApi.getPresetList({ page: 1, pageSize: 100 }) @@ -136,37 +263,36 @@ export default function ChatArea({ conversation, character, onConversationUpdate } } - const loadCurrentPreset = () => { - if (conversation.presetId) { - setSelectedPresetId(conversation.presetId) - return + const loadRegexScripts = async () => { + try { + // 并行加载:全局脚本(scope=0)+ 当前角色专属脚本(scope=1, ownerCharId=character.id) + const globalScope = 0 + const charScope = 1 + const [globalResp, charResp] = await Promise.all([ + regexAPI.getList({ page: 1, pageSize: 200, scope: globalScope }), + regexAPI.getList({ page: 1, pageSize: 200, scope: charScope, ownerCharId: character.id }), + ]) + const globalScripts: RegexScript[] = (globalResp.data.list || []).filter((s: RegexScript) => !s.disabled) + const charScripts: RegexScript[] = (charResp.data.list || []).filter((s: RegexScript) => !s.disabled) + // 合并并按 order 去重(以 id 为主键) + const merged = [...globalScripts, ...charScripts] + const seen = new Set() + setRegexScripts(merged.filter(s => { + if (seen.has(s.id)) return false + seen.add(s.id) + return true + })) + } catch (err) { + console.error('加载正则脚本失败:', err) } - if (conversation.settings) { - try { - const settings = typeof conversation.settings === 'string' - ? JSON.parse(conversation.settings) - : conversation.settings - if (settings.presetId) { - setSelectedPresetId(settings.presetId) - return - } - } catch (e) { - console.error('解析设置失败:', e) - } - } - setSelectedPresetId(undefined) } + // ---- 设置更新 ---- const handlePresetChange = async (presetId: number | null) => { try { - const settings = conversation.settings - ? (typeof conversation.settings === 'string' ? JSON.parse(conversation.settings) : { ...conversation.settings }) - : {} - if (presetId === null) { - delete settings.presetId - } else { - settings.presetId = presetId - } + const settings = parseSettings(conversation.settings) + if (presetId === null) delete settings.presetId + else settings.presetId = presetId await conversationApi.updateConversationSettings(conversation.id, { settings }) setSelectedPresetId(presetId ?? undefined) setShowPresetSelector(false) @@ -180,14 +306,9 @@ export default function ChatArea({ conversation, character, onConversationUpdate const handleModelChange = async (configId: number | null) => { try { - const settings = conversation.settings - ? (typeof conversation.settings === 'string' ? JSON.parse(conversation.settings) : { ...conversation.settings }) - : {} - if (configId === null) { - delete settings.aiConfigId - } else { - settings.aiConfigId = configId - } + const settings = parseSettings(conversation.settings) + if (configId === null) delete settings.aiConfigId + else settings.aiConfigId = configId await conversationApi.updateConversationSettings(conversation.id, { settings }) setSelectedConfigId(configId ?? undefined) setShowModelSelector(false) @@ -199,101 +320,11 @@ export default function ChatArea({ conversation, character, onConversationUpdate } } + // ---- 消息操作 ---- const scrollToBottom = () => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) } - const handleSendMessage = async (message: string) => { - if (!message.trim() || sending) return - - const userMessage = message.trim() - setSending(true) - - const tempUserMessage: Message = { - id: Date.now(), - conversationId: conversation.id, - role: 'user', - content: userMessage, - tokenCount: 0, - createdAt: new Date().toISOString(), - } - setMessages(prev => [...prev, tempUserMessage]) - - const tempAIMessage: Message = { - id: Date.now() + 1, - conversationId: conversation.id, - role: 'assistant', - content: '', - tokenCount: 0, - createdAt: new Date().toISOString(), - } - - try { - if (streamEnabled) { - setMessages(prev => [...prev, tempAIMessage]) - const response = await fetch( - `${import.meta.env.VITE_API_BASE_URL || '/api'}/app/conversation/${conversation.id}/message?stream=true`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${localStorage.getItem('token')}`, - }, - body: JSON.stringify({ content: userMessage }), - } - ) - if (!response.ok) throw new Error('流式传输失败') - - const reader = response.body?.getReader() - const decoder = new TextDecoder() - if (reader) { - let fullContent = '' - let buffer = '' - while (true) { - const { done, value } = await reader.read() - if (done) break - buffer += decoder.decode(value, { stream: true }) - const lines = buffer.split('\n') - buffer = lines.pop() || '' - let currentEvent = '' - for (const line of lines) { - if (line.startsWith('event: ')) { - currentEvent = line.slice(7).trim() - } else if (line.startsWith('data: ')) { - const data = line.slice(6).trim() - if (currentEvent === 'message') { - fullContent += data - setMessages(prev => - prev.map(m => m.id === tempAIMessage.id ? { ...m, content: fullContent } : m) - ) - } else if (currentEvent === 'done') { - await loadMessages() - break - } else if (currentEvent === 'error') { - throw new Error(data) - } - currentEvent = '' - } - } - } - } - const convResp = await conversationApi.getConversationById(conversation.id) - onConversationUpdate(convResp.data) - } else { - await conversationApi.sendMessage(conversation.id, { content: userMessage }) - await loadMessages() - const convResp = await conversationApi.getConversationById(conversation.id) - onConversationUpdate(convResp.data) - } - } catch (err: any) { - console.error('发送消息失败:', err) - alert(err.response?.data?.msg || '发送消息失败,请重试') - setMessages(prev => prev.filter(m => m.id !== tempUserMessage.id && m.id !== tempAIMessage.id)) - } finally { - setSending(false) - } - } - const handleSend = async () => { if (!inputValue.trim() || sending) return const userMessage = inputValue.trim() @@ -320,12 +351,17 @@ export default function ChatArea({ conversation, character, onConversationUpdate if (!hasAssistantMsg) return setSending(true) + sendingRef.current = true + setSendError(null) + + // 记录被移除的最后一条 assistant 消息,失败时可恢复 const lastAssistantIndex = [...messages].map(m => m.role).lastIndexOf('assistant') + const removedMsg = lastAssistantIndex !== -1 ? messages[lastAssistantIndex] : null if (lastAssistantIndex !== -1) { setMessages(prev => prev.filter((_, i) => i !== lastAssistantIndex)) } - const tempAIMessage: Message = { + const tempAIMsg: Message = { id: Date.now(), conversationId: conversation.id, role: 'assistant', @@ -336,64 +372,50 @@ export default function ChatArea({ conversation, character, onConversationUpdate try { if (streamEnabled) { - setMessages(prev => [...prev, tempAIMessage]) - const response = await fetch( - `${import.meta.env.VITE_API_BASE_URL || '/api'}/app/conversation/${conversation.id}/regenerate?stream=true`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${localStorage.getItem('token')}`, - }, - } - ) - if (!response.ok) throw new Error('重新生成失败') - - const reader = response.body?.getReader() - const decoder = new TextDecoder() - if (reader) { - let fullContent = '' - let buffer = '' - while (true) { - const { done, value } = await reader.read() - if (done) break - buffer += decoder.decode(value, { stream: true }) - const lines = buffer.split('\n') - buffer = lines.pop() || '' - let currentEvent = '' - for (const line of lines) { - if (line.startsWith('event: ')) { - currentEvent = line.slice(7).trim() - } else if (line.startsWith('data: ')) { - const data = line.slice(6).trim() - if (currentEvent === 'message') { - fullContent += data - setMessages(prev => - prev.map(m => m.id === tempAIMessage.id ? { ...m, content: fullContent } : m) - ) - } else if (currentEvent === 'done') { - await loadMessages() - break - } else if (currentEvent === 'error') { - throw new Error(data) - } - currentEvent = '' - } - } + setMessages(prev => [...prev, tempAIMsg]) + setStreamingMsgId(tempAIMsg.id) + let fullContent = '' + for await (const ev of streamSSE( + `/app/conversation/${conversation.id}/regenerate?stream=true`, + 'POST' + )) { + if (ev.event === 'message') { + fullContent += ev.data + setMessages(prev => + prev.map(m => m.id === tempAIMsg.id ? { ...m, content: fullContent } : m) + ) + } else if (ev.event === 'done') { + break + } else if (ev.event === 'error') { + throw new Error(ev.data) } } + // 先原地清除 streaming 标记,再后台静默同步 + setStreamingMsgId(null) + loadMessages(true).then(() => { + conversationApi.getConversationById(conversation.id).then(convResp => { + onConversationUpdate(convResp.data) + }).catch(() => {}) + }).catch(() => {}) } else { await conversationApi.regenerateMessage(conversation.id) await loadMessages() + const convResp = await conversationApi.getConversationById(conversation.id) + onConversationUpdate(convResp.data) } - const convResp = await conversationApi.getConversationById(conversation.id) - onConversationUpdate(convResp.data) - } catch (err: any) { + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : '重新生成失败,请重试' console.error('重新生成失败:', err) - alert(err.message || '重新生成失败,请重试') - await loadMessages() + setStreamingMsgId(null) + // 移除临时 AI 消息,恢复原来被删除的 assistant 消息 + setMessages(prev => { + const withoutTemp = prev.filter(m => m.id !== tempAIMsg.id) + return removedMsg ? [...withoutTemp, removedMsg] : withoutTemp + }) + setSendError(msg) } finally { setSending(false) + sendingRef.current = false } } @@ -401,7 +423,7 @@ export default function ChatArea({ conversation, character, onConversationUpdate if (!confirm('确定要删除这个对话吗?')) return try { await conversationApi.deleteConversation(conversation.id) - window.location.href = '/chat' + navigate('/chat') } catch (err) { console.error('删除对话失败:', err) alert('删除失败') @@ -437,20 +459,52 @@ export default function ChatArea({ conversation, character, onConversationUpdate const selectedPreset = presets.find(p => p.id === selectedPresetId) const lastAssistantMsgId = [...messages].reverse().find(m => m.role === 'assistant')?.id + /** 从角色卡 extensions 中提取前端卡配置(memo 缓存,避免不必要重计算) */ + const frontendCard = useMemo(() => extractFrontendCard(character.extensions), [character.id, character.extensions]) + + /** + * 前端卡用的最新消息内容:始终取消息列表最后一条的原始内容。 + * 流式期间每个 token 都会变,但前端卡 iframe 本身有防抖(isStreaming=true 时冻结刷新)。 + */ + const latestMessageContent = messages.length > 0 ? messages[messages.length - 1].content : '' + const latestMessageIndex = Math.max(0, messages.length - 1) + + /** + * 稳定的消息内容数组引用:只有消息数量或内容实际变化时才重建, + * 避免每个 SSE token 都产生新数组引用,防止 StatusBarIframe 无限重建。 + */ + const allMessageContents = useMemo( + () => messages.map(m => m.content), + // eslint-disable-next-line react-hooks/exhaustive-deps + [messages.length, messages.map(m => m.content).join('\x00')] + ) + + /** + * 为每条消息分配稳定 key: + * - 如果 stableKeyMap 中已有该消息 ID 的 key,直接复用(保持 DOM 节点稳定) + * - 否则分配新 key(conversationId + 消息在列表中的位置索引,流式期间不会变) + * 这样即使服务端刷新后 msg.id 从 tempId 变为真实 ID,React key 也不变,不会触发重新挂载。 + */ + const getStableKey = useCallback((msg: Message, index: number): string => { + if (stableKeyMap.current.has(msg.id)) { + return stableKeyMap.current.get(msg.id)! + } + const key = `${conversation.id}-${index}` + stableKeyMap.current.set(msg.id, key) + return key + }, [conversation.id]) + return (
    {/* 顶部工具栏 */}
    - {/* 左侧:标题 */}

    {conversation.title}

    与 {character.name} 对话中

    - {/* 右侧:工具按钮组 */}
    - {/* 模型选择器 */}
    + {/* 前端卡(顶部,position='top' 或未设置) */} + {frontendCard && frontendCard.enabled !== false && frontendCard.position !== 'bottom' && ( +
    + +
    + )} + {/* 消息列表 */}
    {loading ? ( @@ -631,11 +698,11 @@ export default function ChatArea({ conversation, character, onConversationUpdate

    发送第一条消息,开始和 {character.name} 对话吧

    ) : ( - messages.map((msg) => { + messages.map((msg, msgIndex) => { const isLastAssistant = msg.id === lastAssistantMsgId return (
    {/* 助手头像 */} @@ -652,12 +719,10 @@ export default function ChatArea({ conversation, character, onConversationUpdate )}
    - {/* 助手名称 */} {msg.role === 'assistant' && ( {character.name} )} - {/* 消息气泡 */}
    { setInputValue(choice) textareaRef.current?.focus() }} + onAction={handleIframeAction} + isStreaming={msg.id === streamingMsgId} />
    @@ -689,7 +761,6 @@ export default function ChatArea({ conversation, character, onConversationUpdate : } - {/* 最后一条 AI 消息显示重新生成按钮 */} {msg.role === 'assistant' && isLastAssistant && (
    - {/* 用户头像占位 */} - {msg.role === 'user' &&
    } + {/* 用户头像 */} + {msg.role === 'user' && ( +
    + )}
    ) }) )} - {/* 发送中动画(流式时不需要,已有临时消息) */} + {/* 发送中动画(非流式模式下显示) */} {sending && !streamEnabled && (
    @@ -728,8 +801,35 @@ export default function ChatArea({ conversation, character, onConversationUpdate
    + {/* 前端卡(底部,position='bottom' 时显示) */} + {frontendCard && frontendCard.enabled !== false && frontendCard.position === 'bottom' && ( +
    + +
    + )} + {/* 输入区域 */}
    + {/* 发送失败错误提示 */} + {sendError && ( +
    + {sendError} + +
    + )}