🎨 1.优化前端渲染功能(html和对话消息格式)

2.优化流式传输,新增流式渲染功能
3.优化正则处理逻辑
4.新增context budget管理系统
5.优化对话消息失败处理逻辑
6.新增前端卡功能(待完整测试)
This commit is contained in:
2026-03-13 15:58:33 +08:00
parent c267b6c76e
commit 4cecfd6589
22 changed files with 2492 additions and 2164 deletions

3
.gitignore vendored
View File

@@ -24,6 +24,7 @@ dist-ssr
*.sw?
uploads
#docs
.claude
#.claude
plugs
sillytavern
st

View File

@@ -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/)

View File

@@ -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 =
'<li class="text-gray-400"><div class="flex items-center"><div class="animate-spin rounded-full h-4 w-4 border-t-2 border-b-2 border-primary mr-2"></div>加载选项中...</div></li>';
}
// 查找根节点
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 = `<span class="font-medium text-primary">${this.formatNodeName(key)}:</span>`;
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 = `<span class="font-medium text-primary">${this.formatNodeName(key)}:</span>`;
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 = `<span class="font-medium text-primary">${this.formatNodeName(key)}:</span> ${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 `<div class="text-center py-4 text-gray-500 theme-transition">
<i class="fa fa-info-circle mr-1"></i>${message}
</div>`;
}
// 设置事件监听器
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 = `
<div class="bg-red-900/20 border border-red-800/30 text-red-400 px-4 py-3 rounded relative" role="alert">
<strong class="font-bold">状态栏渲染失败: </strong>
<span class="block sm:inline">${error.message}</span>
</div>
`;
// 在行动选项区域也显示错误信息
this.elements.optionsList.innerHTML = `
<li class="text-red-400 bg-red-900/20 border border-red-800/30 rounded p-3 theme-transition">
<div class="flex items-start">
<i class="fa fa-exclamation-triangle mr-2 mt-1"></i>
<div>
<div class="font-semibold mb-1">行动选项加载失败</div>
<div class="text-sm text-red-300">请检查YAML格式是否正确</div>
</div>
</div>
</li>
`;
}
}
$(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(/<maintext>([\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(/<Status_block>\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;
});
</script>
pm
</body></html>

View File

@@ -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/V3chara_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不再有“前端内存版预设/世界书”。
- **可扩展性**
- 提前为插件系统预留 HookonUserInput/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 壳**。

View File

@@ -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

View File

@@ -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

View File

@@ -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)
}

View File

@@ -46,6 +46,7 @@ type GetRegexScriptListRequest struct {
PageSize int `json:"pageSize"`
Keyword string `json:"keyword"`
Scope *int `json:"scope"`
OwnerCharID *uint `json:"ownerCharId"` // 过滤指定角色的脚本scope=1时有效
}
// TestRegexScriptRequest 测试正则脚本请求

View File

@@ -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)
// 从 apiMessages 中提取 systemPrompt供 Anthropic 独立参数使用
systemPrompt := ""
if len(apiMessages) > 0 && apiMessages[0]["role"] == "system" {
systemPrompt = apiMessages[0]["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 := 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)
// 从 apiMessages 中提取 systemPrompt供 Anthropic 独立参数使用
systemPrompt := ""
if len(apiMessages) > 0 && apiMessages[0]["role"] == "system" {
systemPrompt = apiMessages[0]["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 := 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,20 +1013,11 @@ func (s *ConversationService) callOpenAIAPIStream(config *app.AIConfig, model st
for {
line, err := reader.ReadString('\n')
if err != nil {
if err == io.EOF {
break
}
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: ")
// 先处理本次读到的数据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 {
@@ -1073,10 +1027,7 @@ func (s *ConversationService) callOpenAIAPIStream(config *app.AIConfig, model st
} `json:"choices"`
}
if err := json.Unmarshal([]byte(data), &streamResp); err != nil {
continue
}
if jsonErr := json.Unmarshal([]byte(data), &streamResp); jsonErr == nil {
if len(streamResp.Choices) > 0 {
content := streamResp.Choices[0].Delta.Content
if content != "" {
@@ -1086,13 +1037,27 @@ func (s *ConversationService) callOpenAIAPIStream(config *app.AIConfig, model st
}
}
}
}
// 再检查读取错误
if err != nil {
if err == io.EOF {
break
}
// ctx 被取消(客户端断开)时不算真正的流读取错误
if ctx.Err() != nil {
return fullContent.String(), nil
}
return "", fmt.Errorf("读取流失败: %v", err)
}
}
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,20 +1147,11 @@ func (s *ConversationService) callAnthropicAPIStream(config *app.AIConfig, model
for {
line, err := reader.ReadString('\n')
if err != nil {
if err == io.EOF {
break
}
return "", fmt.Errorf("读取流失败: %v", err)
}
line = strings.TrimSpace(line)
if line == "" {
continue
}
if strings.HasPrefix(line, "data: ") {
data := strings.TrimPrefix(line, "data: ")
// 先处理本次读到的数据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"`
@@ -1201,16 +1161,26 @@ func (s *ConversationService) callAnthropicAPIStream(config *app.AIConfig, model
} `json:"delta"`
}
if err := json.Unmarshal([]byte(data), &streamResp); err != nil {
continue
}
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)
}
}
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
// ── 优先级2Preset.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
}
// ── 优先级4CharacterBook 内嵌条目 ──────────────────────────────
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
}
// ── 优先级5MesExample对话示例最低优先级──────────────────
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 == "" {

View File

@@ -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
}

View File

@@ -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",

View File

@@ -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",

View File

@@ -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<string, any> | 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_uiST 社区卡常用)
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

View File

@@ -70,6 +70,7 @@ export interface GetRegexScriptListRequest {
pageSize?: number
keyword?: string
scope?: number
ownerCharId?: number
}
export interface RegexScriptListResponse {

View File

@@ -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<string, unknown> {
if (!raw) return {}
if (typeof raw === 'string') {
try { return JSON.parse(raw) } catch { return {} }
}
if (typeof raw === 'object') return raw as Record<string, unknown>
return {}
}
export default function ChatArea({ conversation, character, onConversationUpdate }: ChatAreaProps) {
const navigate = useNavigate()
const [messages, setMessages] = useState<Message[]>([])
const [inputValue, setInputValue] = useState('')
const [sending, setSending] = useState(false)
@@ -40,12 +56,29 @@ export default function ChatArea({ conversation, character, onConversationUpdate
const [presets, setPresets] = useState<Preset[]>([])
const [selectedPresetId, setSelectedPresetId] = useState<number>()
const [showPresetSelector, setShowPresetSelector] = useState(false)
const [regexScripts, setRegexScripts] = useState<RegexScript[]>([])
/** 当前正在流式输出的消息 ID流结束后清除为 null */
const [streamingMsgId, setStreamingMsgId] = useState<number | null>(null)
/** 发送/重新生成失败时的错误提示(显示在输入框上方,自动清除) */
const [sendError, setSendError] = useState<string | null>(null)
const messagesEndRef = useRef<HTMLDivElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const modelSelectorRef = useRef<HTMLDivElement>(null)
const presetSelectorRef = useRef<HTMLDivElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
// 用 ref 跟踪 sending 状态,避免事件监听器的 stale closure 问题
const sendingRef = useRef(false)
/**
* 稳定 key 映射tempId → stableKey字符串
* 流式期间 key 保持不变,流结束后服务端 ID 替换 tempId 时 key 也不变,
* 防止 React 因 key 变化而卸载/重新挂载消息节点,消除闪屏。
*/
const stableKeyMap = useRef<Map<number, string>>(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)
}
}
window.addEventListener('sendMessageFromStatusBar', handleStatusBarAction as EventListener)
return () => window.removeEventListener('sendMessageFromStatusBar', handleStatusBarAction as EventListener)
}, [sending])
}, [handleSendMessage])
const loadMessages = async () => {
/**
* 处理来自 iframe 状态栏的操作:
* - fillInput / playerAction → 填充输入框(不发送)
* - triggerAction → 解析 /send <text> 并自动发送
* 命令格式示例:"/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
}
if (conversation.settings) {
const loadRegexScripts = async () => {
try {
const settings = typeof conversation.settings === 'string'
? JSON.parse(conversation.settings)
: conversation.settings
if (settings.presetId) {
setSelectedPresetId(settings.presetId)
return
// 并行加载全局脚本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<number>()
setRegexScripts(merged.filter(s => {
if (seen.has(s.id)) return false
seen.add(s.id)
return true
}))
} catch (err) {
console.error('加载正则脚本失败:', err)
}
} 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) {
setMessages(prev => [...prev, tempAIMsg])
setStreamingMsgId(tempAIMsg.id)
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
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 === tempAIMessage.id ? { ...m, content: fullContent } : m)
prev.map(m => m.id === tempAIMsg.id ? { ...m, content: fullContent } : m)
)
} else if (currentEvent === 'done') {
await loadMessages()
} else if (ev.event === 'done') {
break
} else if (currentEvent === 'error') {
throw new Error(data)
}
currentEvent = ''
}
}
} 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)
} 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 节点稳定)
* - 否则分配新 keyconversationId + 消息在列表中的位置索引,流式期间不会变)
* 这样即使服务端刷新后 msg.id 从 tempId 变为真实 IDReact 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 (
<div className="flex-1 flex flex-col min-w-0">
{/* 顶部工具栏 */}
<div className="px-4 py-3 glass border-b border-white/10">
<div className="flex items-center justify-between gap-3">
{/* 左侧:标题 */}
<div className="min-w-0 flex-1">
<h2 className="text-base font-semibold truncate">{conversation.title}</h2>
<p className="text-xs text-white/50 truncate"> {character.name} </p>
</div>
{/* 右侧:工具按钮组 */}
<div className="flex items-center gap-1 flex-shrink-0">
{/* 模型选择器 */}
<div className="relative" ref={modelSelectorRef}>
<button
@@ -557,7 +611,6 @@ export default function ChatArea({ conversation, character, onConversationUpdate
)}
</div>
{/* 分隔线 */}
<div className="w-px h-5 bg-white/10 mx-1" />
{/* 流式传输切换 */}
@@ -613,6 +666,20 @@ export default function ChatArea({ conversation, character, onConversationUpdate
</div>
</div>
{/* 前端卡顶部position='top' 或未设置) */}
{frontendCard && frontendCard.enabled !== false && frontendCard.position !== 'bottom' && (
<div className="px-4 pt-3 flex-shrink-0">
<StatusBarIframe
rawMessage={latestMessageContent}
allMessages={allMessageContents}
messageIndex={latestMessageIndex}
htmlContent={frontendCard.html}
minHeight={150}
onAction={handleIframeAction}
/>
</div>
)}
{/* 消息列表 */}
<div className="flex-1 overflow-y-auto px-4 py-6 space-y-5">
{loading ? (
@@ -631,11 +698,11 @@ export default function ChatArea({ conversation, character, onConversationUpdate
<p className="text-white/50 text-sm"> {character.name} </p>
</div>
) : (
messages.map((msg) => {
messages.map((msg, msgIndex) => {
const isLastAssistant = msg.id === lastAssistantMsgId
return (
<div
key={msg.id}
key={getStableKey(msg, msgIndex)}
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'} group`}
>
{/* 助手头像 */}
@@ -652,12 +719,10 @@ export default function ChatArea({ conversation, character, onConversationUpdate
)}
<div className={`min-w-0 flex flex-col ${msg.role === 'user' ? 'max-w-[70%] items-end' : 'w-[70%] items-start'}`}>
{/* 助手名称 */}
{msg.role === 'assistant' && (
<span className="text-xs text-white/40 mb-1 ml-1">{character.name}</span>
)}
{/* 消息气泡 */}
<div
className={`relative px-4 py-3 rounded-2xl ${
msg.role === 'user'
@@ -669,10 +734,17 @@ export default function ChatArea({ conversation, character, onConversationUpdate
<MessageContent
content={msg.content}
role={msg.role as 'user' | 'assistant'}
messageIndex={msgIndex}
characterName={character.name}
userName={variables.user || user?.username || ''}
regexScripts={regexScripts}
allMessages={allMessageContents}
onChoiceSelect={(choice) => {
setInputValue(choice)
textareaRef.current?.focus()
}}
onAction={handleIframeAction}
isStreaming={msg.id === streamingMsgId}
/>
</div>
@@ -689,7 +761,6 @@ export default function ChatArea({ conversation, character, onConversationUpdate
: <Copy className="w-3.5 h-3.5 text-white/40 hover:text-white/70" />
}
</button>
{/* 最后一条 AI 消息显示重新生成按钮 */}
{msg.role === 'assistant' && isLastAssistant && (
<button
onClick={handleRegenerateResponse}
@@ -703,14 +774,16 @@ export default function ChatArea({ conversation, character, onConversationUpdate
</div>
</div>
{/* 用户头像占位 */}
{msg.role === 'user' && <div className="flex-shrink-0 ml-2.5 mt-1 w-8 h-8 rounded-full bg-white/10 flex items-center justify-center text-xs"></div>}
{/* 用户头像 */}
{msg.role === 'user' && (
<div className="flex-shrink-0 ml-2.5 mt-1 w-8 h-8 rounded-full bg-white/10 flex items-center justify-center text-xs"></div>
)}
</div>
)
})
)}
{/* 发送中动画(流式时不需要,已有临时消息 */}
{/* 发送中动画(流式模式下显示 */}
{sending && !streamEnabled && (
<div className="flex justify-start">
<div className="flex-shrink-0 mr-2.5 mt-1 w-8 h-8 rounded-full bg-primary/30 flex items-center justify-center text-xs">
@@ -728,8 +801,35 @@ export default function ChatArea({ conversation, character, onConversationUpdate
<div ref={messagesEndRef} />
</div>
{/* 前端卡底部position='bottom' 时显示) */}
{frontendCard && frontendCard.enabled !== false && frontendCard.position === 'bottom' && (
<div className="px-4 pb-2 flex-shrink-0">
<StatusBarIframe
rawMessage={latestMessageContent}
allMessages={allMessageContents}
messageIndex={latestMessageIndex}
htmlContent={frontendCard.html}
minHeight={150}
onAction={handleIframeAction}
/>
</div>
)}
{/* 输入区域 */}
<div className="px-4 pb-4 pt-2 glass border-t border-white/10">
{/* 发送失败错误提示 */}
{sendError && (
<div className="flex items-center justify-between gap-2 mb-2 px-3 py-2 rounded-lg bg-red-500/10 border border-red-500/20 text-xs text-red-400">
<span className="flex-1 truncate">{sendError}</span>
<button
onClick={() => setSendError(null)}
className="flex-shrink-0 hover:text-red-300 transition-colors cursor-pointer"
title="关闭"
>
</button>
</div>
)}
<div className="flex items-end gap-2">
<button className="p-2.5 glass-hover rounded-xl cursor-not-allowed opacity-30" title="附件(开发中)" disabled>
<Paperclip className="w-5 h-5" />
@@ -741,6 +841,7 @@ export default function ChatArea({ conversation, character, onConversationUpdate
value={inputValue}
onChange={(e) => {
setInputValue(e.target.value)
if (sendError) setSendError(null)
if (textareaRef.current) {
textareaRef.current.style.height = 'auto'
textareaRef.current.style.height = Math.min(textareaRef.current.scrollHeight, 120) + 'px'

View File

@@ -1,740 +1,234 @@
import {useEffect, useRef, useState} from 'react'
import {Code, Eye, Play} from 'lucide-react'
/**
* MessageContent 组件
*
* 渲染单条消息内容,接入 textRenderer 和 regexEngine 完成完整渲染管线:
* 1. 调用 regexEngine.processAIOutput需要从外部传入 regexScripts
* 2. 调用 textRenderer.parseRawMessage 提取特殊块statusYaml、choices 等)
* 3. 调用 textRenderer.renderMessageHtml 生成可渲染 HTML
* 4. 若有 html-code-block 或 statusYaml → 渲染 <StatusBarIframe>
* 5. 普通文本 → dangerouslySetInnerHTML
*
* 组件仅负责渲染不持有业务状态消息列表、AI 配置等)。
*/
import { useMemo, useState } from 'react'
import { Code, Eye } from 'lucide-react'
import {
parseRawMessage,
renderMessageHtml,
type Choice,
} from '../lib/textRenderer'
import { processAIOutput, processUserInput } from '../lib/regexEngine'
import type { RegexScript } from '../api/regex'
import StatusBarIframe from './StatusBarIframe'
// ============= Props =============
interface MessageContentProps {
/** 消息原始内容 */
content: string
/** 消息角色 */
role: 'user' | 'assistant'
/** 消息在对话中的索引(用于正则 depth 过滤) */
messageIndex?: number
/** 角色名称(变量替换 {{char}} */
characterName?: string
/** 用户名称(变量替换 {{user}} */
userName?: string
/** 已启用的正则脚本列表(由父组件加载并传入) */
regexScripts?: RegexScript[]
/** 所有消息的原始内容(用于 iframe shim 注入 __CHAT_MESSAGES__ */
allMessages?: string[]
/** 选择项被点击时的回调 */
onChoiceSelect?: (choice: string) => void
/**
* iframe 内操作回调(透传给 StatusBarIframe
* - type='fillInput' → 仅填充输入框
* - type='playerAction' → 仅填充输入框
* - type='triggerAction'→ 填充并发送
*/
onAction?: (type: 'fillInput' | 'playerAction' | 'triggerAction', payload: string) => void
/**
* 是否处于流式传输中。
* true → 跳过完整渲染管线,直接显示原始文本(低延迟,实时打字效果)
* false → 运行完整渲染管线正则、iframe、状态栏等
*/
isStreaming?: boolean
}
interface Choice {
label: string
text: string
}
// ============= 组件 =============
// 解析并高亮对白的组件
function DialogueText({ text }: { text: string }) {
// 匹配各种引号格式的对白
const dialogueRegex = /([""「『])(.*?)([""」』])|(")(.*?)(")|(')(.*?)(')/g
const parts: JSX.Element[] = []
let lastIndex = 0
let match
let key = 0
while ((match = dialogueRegex.exec(text)) !== null) {
// 添加对白之前的文本
if (match.index > lastIndex) {
parts.push(
<span key={`text-${key++}`} className="text-white/80">
{text.substring(lastIndex, match.index)}
</span>
)
}
// 提取对白内容(处理不同的引号组)
const dialogue = match[2] || match[5] || match[8]
const openQuote = match[1] || match[4] || match[7]
const closeQuote = match[3] || match[6] || match[9]
// 添加高亮的对白
parts.push(
<span key={`dialogue-${key++}`} className="inline-block">
<span className="text-primary/60">{openQuote}</span>
<span className="text-primary font-medium px-0.5">{dialogue}</span>
<span className="text-primary/60">{closeQuote}</span>
</span>
)
lastIndex = match.index + match[0].length
}
// 添加剩余的文本
if (lastIndex < text.length) {
parts.push(
<span key={`text-${key++}`} className="text-white/80">
{text.substring(lastIndex)}
</span>
)
}
return <>{parts.length > 0 ? parts : <span className="text-white/80">{text}</span>}</>
}
// 解析选择项的函数
function parseChoices(content: string): { choices: Choice[]; cleanContent: string } {
// 匹配 <choice>...</choice> 或 [choice]...[/choice] 格式
const choiceRegex = /(?:<choice>|\\[choice\\])([\s\S]*?)(?:<\/choice>|\\[\/choice\\])/i
const match = content.match(choiceRegex)
if (!match) {
// 尝试匹配纯文本格式的选项列表
// 匹配格式: A: xxx\nB: xxx\nC: xxx 或 A. xxx\nB. xxx
const textChoiceRegex = /^([A-E])[.、:]\s*(.+?)(?=\n[A-E][.、:]|\n*$)/gm
const choices: Choice[] = []
let textMatch
while ((textMatch = textChoiceRegex.exec(content)) !== null) {
choices.push({
label: textMatch[1],
text: textMatch[2].trim()
})
}
// 如果找到了至少2个选项认为是有效的选择列表
if (choices.length >= 2) {
// 移除选项列表,保留其他内容
const cleanContent = content.replace(textChoiceRegex, '').trim()
return { choices, cleanContent }
}
// 如果没有纯文本格式尝试从HTML中提取选择项
const htmlChoiceRegex = /<p>\s*(\d+|[A-Z])\s*[.、:]\s*([^<]+)/gi
const htmlChoices: Choice[] = []
let htmlMatch
while ((htmlMatch = htmlChoiceRegex.exec(content)) !== null) {
htmlChoices.push({
label: htmlMatch[1],
text: htmlMatch[2].trim()
})
}
if (htmlChoices.length > 0) {
return { choices: htmlChoices, cleanContent: content }
}
return { choices: [], cleanContent: content }
}
const choiceBlock = match[1]
const choices: Choice[] = []
// 匹配 A. text, B. text 等格式
const optionRegex = /^([A-Z])[.、:]\s*(.+)$/gm
let optionMatch
while ((optionMatch = optionRegex.exec(choiceBlock)) !== null) {
choices.push({
label: optionMatch[1],
text: optionMatch[2].trim()
})
}
// 移除选择块,返回清理后的内容
const cleanContent = content.replace(choiceRegex, '').trim()
return { choices, cleanContent }
}
// 提取 <maintext> 内容
function extractMaintext(content: string): { maintext: string; cleanContent: string } {
const maintextRegex = /<maintext>([\s\S]*?)<\/maintext>/i
const match = content.match(maintextRegex)
if (!match) {
return { maintext: '', cleanContent: content }
}
const maintext = match[1].trim()
const cleanContent = content.replace(maintextRegex, '').trim()
return { maintext, cleanContent }
}
// 提取 <Status_block> YAML 数据
function extractStatusBlock(content: string): { statusYaml: string; cleanContent: string } {
const statusRegex = /<Status_block>\s*([\s\S]*?)\s*<\/Status_block>/i
const match = content.match(statusRegex)
if (!match) {
return { statusYaml: '', cleanContent: content }
}
const statusYaml = match[1].trim()
const cleanContent = content.replace(statusRegex, '').trim()
return { statusYaml, cleanContent }
}
// 解析状态面板数据JSON 格式 - 保留兼容性)
function parseStatusPanel(content: string): { status: any; cleanContent: string } {
const statusRegex = /<status_current_variable>([\s\S]*?)<\/status_current_variable>/i
const match = content.match(statusRegex)
if (!match) {
return { status: null, cleanContent: content }
}
try {
const statusData = JSON.parse(match[1].trim())
const cleanContent = content.replace(statusRegex, '').trim()
return { status: statusData, cleanContent }
} catch (e) {
console.error('解析状态面板失败:', e)
return { status: null, cleanContent: content }
}
}
export default function MessageContent({ content, role, onChoiceSelect }: MessageContentProps) {
export default function MessageContent({
content,
role,
messageIndex = 0,
characterName = '',
userName = '',
regexScripts = [],
allMessages = [],
onChoiceSelect,
onAction,
isStreaming = false,
}: MessageContentProps) {
const [showRaw, setShowRaw] = useState(false)
const [hasHtml, setHasHtml] = useState(false)
const [hasScript, setHasScript] = useState(false)
const [allowScript, setAllowScript] = useState(false)
const [choices, setChoices] = useState<Choice[]>([])
const [displayContent, setDisplayContent] = useState(content)
const [statusPanel, setStatusPanel] = useState<any>(null)
const [statusYaml, setStatusYaml] = useState<string>('')
const [maintext, setMaintext] = useState<string>('')
const iframeRef = useRef<HTMLIFrameElement>(null)
const statusIframeRef = useRef<HTMLIFrameElement>(null)
useEffect(() => {
console.log('[MessageContent] 原始内容:', content)
// ---- 渲染管线纯计算memo 缓存) ----
// 必须在所有条件 return 之前调用,遵守 Rules of Hooks。
// 流式期间 isStreaming=true 时结果不会被使用,但 hook 仍需执行以保持调用顺序一致。
const rendered = useMemo(() => {
// Step 1: 执行正则脚本
const afterRegex =
role === 'assistant'
? processAIOutput(content, regexScripts, messageIndex)
: processUserInput(content, regexScripts, messageIndex)
let processedContent = content
// Step 2: 解析特殊块
const parsed = parseRawMessage(afterRegex)
// 提取 <maintext> 内容
const { maintext: extractedMaintext, cleanContent: contentAfterMaintext } = extractMaintext(processedContent)
if (extractedMaintext) {
console.log('[MessageContent] 提取到 maintext:', extractedMaintext)
setMaintext(extractedMaintext)
processedContent = contentAfterMaintext
}
// 提取 <Status_block> YAML 数据
const { statusYaml: extractedYaml, cleanContent: contentAfterStatus } = extractStatusBlock(processedContent)
if (extractedYaml) {
console.log('[MessageContent] 提取到 Status_block YAML:', extractedYaml)
setStatusYaml(extractedYaml)
processedContent = contentAfterStatus
}
// 解析状态面板JSON 格式 - 保留兼容性)
const { status, cleanContent: contentAfterStatusPanel } = parseStatusPanel(processedContent)
if (status) {
console.log('[MessageContent] 状态面板:', status)
setStatusPanel(status)
processedContent = contentAfterStatusPanel
}
// 解析选择项
const { choices: parsedChoices, cleanContent } = parseChoices(contentAfterStatus)
setChoices(parsedChoices)
console.log('[MessageContent] 选择项:', parsedChoices)
// 直接使用清理后的内容,不再进行脚本输出清理
const finalContent = cleanContent
console.log('[MessageContent] 最终内容:', finalContent)
// 检测是否包含 HTML 标签或代码块
const htmlRegex = /<[^>]+>/g
const codeBlockRegex = /```[\s\S]*?```/g
const scriptRegex = /<script[\s\S]*?<\/script>/gi
const hasCodeBlocks = codeBlockRegex.test(finalContent)
const hasHtmlTags = htmlRegex.test(finalContent)
const hasScriptContent = scriptRegex.test(finalContent)
console.log('[MessageContent] hasCodeBlocks:', hasCodeBlocks, 'hasHtmlTags:', hasHtmlTags, 'hasScript:', hasScriptContent)
let renderedContent = finalContent
// 如果包含 HTML 或代码块,进行混合渲染处理
if (hasHtmlTags || hasCodeBlocks) {
// 步骤1: 先处理代码块,将其转换为 HTML 或保护起来
const codeBlockPlaceholders: { [key: string]: string } = {}
let codeBlockIndex = 0
// 处理 ```html 代码块
renderedContent = renderedContent.replace(/```html\s*([\s\S]*?)```/gi, (_match, code) => {
const placeholder = `__CODEBLOCK_${codeBlockIndex}__`
codeBlockPlaceholders[placeholder] = code.trim()
codeBlockIndex++
return placeholder
// Step 3a: 渲染主 bodyText → HTML含 htmlBlocks
const { html: bodyHtml, htmlBlocks } = renderMessageHtml(parsed.bodyText, role, {
index: messageIndex,
characterName,
userName,
})
// 处理其他代码块
renderedContent = renderedContent.replace(/```(\w*)\s*([\s\S]*?)```/gi, (_match, lang, code) => {
const trimmedCode = code.trim()
const placeholder = `__CODEBLOCK_${codeBlockIndex}__`
// Step 3b: 若有 maintext单独渲染
const { html: maintextHtml } = parsed.maintext
? renderMessageHtml(parsed.maintext, 'assistant', { characterName, userName })
: { html: '' }
// 如果包含 HTML 标签,直接渲染
if (/<[^>]+>/.test(trimmedCode)) {
codeBlockPlaceholders[placeholder] = trimmedCode
} else {
// 否则转换为 <pre><code>
const escaped = trimmedCode
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
const langLabel = lang ? `<span style="font-size:11px;color:rgba(255,255,255,0.4);display:block;margin-bottom:4px;">${lang}</span>` : ''
codeBlockPlaceholders[placeholder] = `<pre style="background:rgba(0,0,0,0.35);padding:10px 12px;border-radius:8px;overflow-x:auto;font-size:13px;line-height:1.5;margin:8px 0;">${langLabel}<code>${escaped}</code></pre>`
return { parsed, bodyHtml, htmlBlocks, maintextHtml }
}, [content, role, messageIndex, characterName, userName, regexScripts])
const { parsed, bodyHtml, htmlBlocks, maintextHtml } = rendered
// ---- 流式传输中:所有 Hook 已执行完毕,现在可以提前返回 ----
// 跳过完整渲染管线,直接显示原始文本(低延迟,实时打字效果)。
// 流结束后isStreaming=false才切换到完整渲染路径。
if (role === 'assistant' && isStreaming) {
return (
<div className="text-sm leading-relaxed whitespace-pre-wrap break-words overflow-wrap-anywhere text-white/90">
{content}
<span className="inline-block w-0.5 h-4 bg-white/70 ml-0.5 align-middle animate-pulse" />
</div>
)
}
codeBlockIndex++
return placeholder
})
const hasVisualExtras =
parsed.statusYaml ||
parsed.statusJson ||
parsed.hasScript ||
htmlBlocks.length > 0
// 步骤2: 保护现有的 HTML 标签(不包括代码块占位符)
const htmlPlaceholders: { [key: string]: string } = {}
let htmlIndex = 0
renderedContent = renderedContent.replace(/<[^>]+>/g, (match) => {
const placeholder = `__HTML_${htmlIndex}__`
htmlPlaceholders[placeholder] = match
htmlIndex++
return placeholder
})
// 步骤3: 处理纯文本部分 - 换行和对白高亮
// 将换行转换为 <br>
renderedContent = renderedContent.replace(/\n/g, '<br>')
// 高亮对白(只匹配单行内的引号)
renderedContent = renderedContent.replace(/([""「『])([^""」』\n<]*?)([""」』])|(")(.*?)(")|(')([^'\n<]*?)(')/g, (_match, q1, t1, q2, q3, t2, q4, q5, t3, q6) => {
const quote1 = q1 || q3 || q5
const text = t1 || t2 || t3
const quote2 = q2 || q4 || q6
if (!text) return _match
return `<span style="color:rgba(139,92,246,0.6)">${quote1}</span><span style="color:rgb(139,92,246);font-weight:500;padding:0 2px">${text}</span><span style="color:rgba(139,92,246,0.6)">${quote2}</span>`
})
// 步骤4: 恢复 HTML 标签
Object.keys(htmlPlaceholders).forEach(placeholder => {
renderedContent = renderedContent.replace(placeholder, htmlPlaceholders[placeholder])
})
// 步骤5: 恢复代码块
Object.keys(codeBlockPlaceholders).forEach(placeholder => {
renderedContent = renderedContent.replace(placeholder, codeBlockPlaceholders[placeholder])
})
}
setDisplayContent(renderedContent)
setHasHtml(hasHtmlTags || hasCodeBlocks)
setHasScript(hasScriptContent)
// 如果有 HTML 内容或脚本,自动启用
if (hasHtmlTags || hasCodeBlocks || hasScriptContent) {
setAllowScript(true)
console.log('[MessageContent] 自动启用脚本')
}
}, [content])
const renderInIframe = () => {
if (!iframeRef.current) return
const iframe = iframeRef.current
const doc = iframe.contentDocument || iframe.contentWindow?.document
if (doc) {
doc.open()
doc.write(`
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
html, body {
margin: 0;
padding: 0;
width: 100% !important;
height: auto;
overflow-x: hidden;
}
body {
padding: 16px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: transparent;
color: rgba(255, 255, 255, 0.9);
font-size: 14px;
line-height: 1.6;
}
* {
box-sizing: border-box;
max-width: 100% !important;
}
img {
height: auto;
width: auto !important;
}
/* 强制覆盖任何固定宽度 */
div, p, span, section, article {
max-width: 100% !important;
}
[style*="width"] {
width: auto !important;
max-width: 100% !important;
}
</style>
<script>
// 监听来自父页面的消息(用于更新状态栏)
window.addEventListener('message', function(event) {
if (event.data.type === 'updateStatus') {
// 更新状态栏中的变量值
Object.keys(event.data.variables || {}).forEach(function(key) {
var elements = document.querySelectorAll('[data-var="' + key + '"]');
elements.forEach(function(el) {
el.textContent = event.data.variables[key];
});
});
}
});
// 向父页面发送消息的辅助函数
function sendToParent(type, data) {
window.parent.postMessage({ type: type, data: data }, '*');
}
// 用户操作触发函数(供状态栏按钮调用)
function onPlayerAction(action) {
sendToParent('playerAction', { action: action });
}
</script>
</head>
<body>
${displayContent}
</body>
</html>
`)
doc.close()
// 自动调整iframe高度
setTimeout(() => {
if (doc.body) {
const height = doc.body.scrollHeight
iframe.style.height = `${Math.max(height + 32, 100)}px`
}
}, 150)
}
}
// 渲染状态栏 iframe使用 doc.write 动态注入 YAML 数据)
const renderStatusBar = () => {
if (!statusIframeRef.current || !statusYaml) return
console.log('[MessageContent] 开始渲染状态栏YAML 数据长度:', statusYaml.length)
const iframe = statusIframeRef.current
const doc = iframe.contentDocument || iframe.contentWindow?.document
if (doc) {
// 使用 doc.write() 分步注入,避免模板字符串的转义问题
doc.open()
// 1. 写入 HTML 头部
doc.write(`
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script src="https://cdn.jsdelivr.net/npm/js-yaml@4.1.0/dist/js-yaml.min.js"><\/script>
<script src="https://cdn.tailwindcss.com"><\/script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
body {
margin: 0;
padding: 16px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: transparent;
color: rgba(255, 255, 255, 0.9);
font-size: 14px;
}
.status-block {
background: rgba(0, 0, 0, 0.3);
border-radius: 12px;
padding: 16px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.status-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.status-title {
font-size: 16px;
font-weight: 600;
color: rgba(157, 124, 245, 1);
}
.status-info {
font-size: 13px;
color: rgba(255, 255, 255, 0.7);
margin: 4px 0;
}
.character-card {
background: rgba(0, 0, 0, 0.2);
border-radius: 8px;
padding: 12px;
margin: 8px 0;
border: 1px solid rgba(255, 255, 255, 0.05);
}
.character-name {
font-weight: 600;
font-size: 15px;
margin-bottom: 8px;
color: rgba(157, 124, 245, 1);
}
.attribute {
margin: 4px 0;
font-size: 13px;
line-height: 1.5;
}
.attribute-key {
color: rgba(157, 124, 245, 0.8);
font-weight: 500;
}
.option-item {
padding: 8px 12px;
margin: 4px 0;
background: rgba(157, 124, 245, 0.1);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
border-left: 2px solid rgba(157, 124, 245, 0.3);
}
.option-item:hover {
background: rgba(157, 124, 245, 0.2);
border-left-color: rgba(157, 124, 245, 0.7);
}
</style>
</head>
<body>
`)
// 2. 写入 YAML 数据(使用 textContent 方式,避免 HTML 转义)
// 注意:我们需要在 doc.close() 之前写入所有内容
const yamlScriptContent = `<script id="yaml-data-source" type="text/yaml">${statusYaml.replace(/</g, '\\x3C').replace(/>/g, '\\x3E')}<\/script>`
doc.write(yamlScriptContent)
// 3. 写入渲染脚本
doc.write(`
<script>
// 向父页面发送消息
function sendToParent(type, data) {
window.parent.postMessage({ type: type, data: data }, '*');
}
// 用户操作触发函数
function onPlayerAction(action) {
console.log('[StatusBar] 发送操作:', action);
sendToParent('playerAction', { action: action });
}
// 立即执行渲染
(function() {
try {
const yamlScript = document.getElementById('yaml-data-source');
if (!yamlScript || !yamlScript.textContent.trim()) {
document.body.innerHTML = '<div style="padding:20px;text-align:center;color:rgba(255,255,255,0.5);">状态栏数据加载中...</div>';
return;
}
const yamlData = jsyaml.load(yamlScript.textContent);
if (!yamlData || Object.keys(yamlData).length === 0) {
document.body.innerHTML = '<div style="padding:20px;text-align:center;color:rgba(255,255,255,0.5);">状态栏数据为空</div>';
return;
}
// 渲染状态栏
const rootKey = Object.keys(yamlData)[0];
const data = yamlData[rootKey];
let html = '<div class="status-block">';
// 渲染标题
html += '<div class="status-header">';
html += '<div class="status-title">' + rootKey + '</div>';
html += '</div>';
// 渲染基本信息(日期、地点等)
Object.entries(data).forEach(([key, value]) => {
if (key.includes('日期') || key.includes('时间') || key.includes('地点') || key.includes('位置')) {
html += '<div class="status-info">' + value + '</div>';
}
});
// 渲染用户列表
if (data['用户列表'] && Array.isArray(data['用户列表'])) {
html += '<div style="margin-top:12px;">';
data['用户列表'].forEach(userItem => {
const userData = userItem['用户'] || userItem;
if (typeof userData === 'object') {
html += '<div class="character-card">';
const userName = userData['名字'] || '未知角色';
html += '<div class="character-name">' + userName + '</div>';
Object.entries(userData).forEach(([k, v]) => {
if (k !== '名字') {
if (Array.isArray(v)) {
html += '<div class="attribute"><span class="attribute-key">' + k + ':</span></div>';
v.forEach(item => {
if (typeof item === 'object') {
Object.values(item).forEach(val => {
html += '<div style="margin-left:12px;font-size:12px;">' + val + '</div>';
});
} else {
html += '<div style="margin-left:12px;font-size:12px;">' + item + '</div>';
}
});
} else {
html += '<div class="attribute"><span class="attribute-key">' + k + ':</span> ' + v + '</div>';
}
}
});
html += '</div>';
}
});
html += '</div>';
}
// 渲染行动选项
if (data['行动选项'] && typeof data['行动选项'] === 'object') {
const actionOptions = data['行动选项'];
html += '<div style="margin-top:12px;">';
html += '<div style="font-weight:600;margin-bottom:8px;color:rgba(157,124,245,1);">';
html += (actionOptions['名字'] || '角色') + ' 的行动选项';
html += '</div>';
if (actionOptions['选项'] && Array.isArray(actionOptions['选项'])) {
actionOptions['选项'].forEach(option => {
const escapedOption = option.replace(/'/g, "\\\\'").replace(/"/g, '&quot;');
html += '<div class="option-item" onclick="onPlayerAction(\\'' + escapedOption + '\\')">' + option + '</div>';
});
}
html += '</div>';
}
html += '</div>';
document.body.innerHTML = html;
console.log('[StatusBar] 渲染成功');
} catch (error) {
console.error('[StatusBar] 渲染失败:', error);
document.body.innerHTML = '<div style="padding:20px;text-align:center;color:rgba(255,100,100,0.8);">状态栏渲染失败: ' + error.message + '</div>';
}
})();
<\/script>
</body>
</html>
`)
doc.close()
console.log('[MessageContent] YAML 数据已注入,长度:', statusYaml.length)
// 4. 自动调整 iframe 高度
setTimeout(() => {
if (doc.body) {
const height = doc.body.scrollHeight
iframe.style.height = `${Math.max(height + 32, 200)}px`
console.log('[MessageContent] iframe 高度已调整:', iframe.style.height)
}
}, 300)
}
}
useEffect(() => {
if (allowScript && hasScript && iframeRef.current) {
// 延迟渲染确保 iframe 已挂载
setTimeout(() => {
renderInIframe()
}, 50)
}
}, [allowScript, hasScript, displayContent])
useEffect(() => {
if (statusYaml && statusIframeRef.current) {
// 延迟渲染确保 iframe 已挂载
setTimeout(() => {
renderStatusBar()
}, 50)
}
}, [statusYaml])
// 如果是用户消息,直接显示纯文本
// ---- 用户消息:直接纯文本 ----
if (role === 'user') {
return <p className="text-sm leading-relaxed whitespace-pre-wrap break-words overflow-wrap-anywhere">{content}</p>
return (
<p className="text-sm leading-relaxed whitespace-pre-wrap break-words overflow-wrap-anywhere">
{content}
</p>
)
}
// AI消息 - 支持多种渲染模式
// ---- AI 消息 ----
return (
<div className="space-y-2">
{/* 控制按钮 */}
{(hasHtml || hasScript) && (
{/* 源码/渲染切换按钮 */}
{(parsed.hasHtml || hasVisualExtras) && (
<div className="flex items-center gap-2 mb-2">
<button
onClick={() => setShowRaw(!showRaw)}
onClick={() => setShowRaw(v => !v)}
className="flex items-center gap-1 px-2 py-1 text-xs glass-hover rounded cursor-pointer"
title={showRaw ? '显示渲染' : '显示源码'}
>
{showRaw ? <Eye className="w-3 h-3" /> : <Code className="w-3 h-3" />}
{showRaw ? '渲染' : '源码'}
</button>
{hasScript && (
<button
onClick={() => {
setAllowScript(!allowScript)
}}
className={`flex items-center gap-1 px-2 py-1 text-xs rounded cursor-pointer ${
allowScript ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'
}`}
title={allowScript ? '禁用脚本' : '启用脚本'}
>
<Play className="w-3 h-3" />
{allowScript ? '禁用脚本' : '启用脚本'}
</button>
)}
</div>
)}
{/* 内容渲染 */}
{/* 内容 */}
{showRaw ? (
/* 原始源码 */
<pre className="text-xs bg-black/30 p-3 rounded-lg overflow-x-auto">
<code>{content}</code>
</pre>
) : hasScript && allowScript ? (
// 有脚本时使用 iframe 渲染
<div className="w-full border border-white/10 rounded-lg bg-black/20 overflow-hidden">
<iframe
ref={iframeRef}
className="w-full"
sandbox="allow-scripts allow-same-origin"
style={{ minHeight: '100px', border: 'none' }}
/>
</div>
) : hasHtml ? (
// 有 HTML 内容时直接渲染HTML 已经在原位置)
<div
className="prose prose-invert max-w-none text-sm leading-relaxed break-words overflow-wrap-anywhere"
dangerouslySetInnerHTML={{ __html: displayContent }}
/>
) : (
// 纯文本内容 - 使用 DialogueText 高亮对白,保持换行
<div className="text-sm leading-relaxed break-words overflow-wrap-anywhere">
{displayContent.split('\n').map((line, index) => (
<div key={index} className="min-h-[1.5em]">
{line ? <DialogueText text={line} /> : <br />}
</div>
))}
</div>
<>
{/* maintext 优先作为主要叙事内容(始终显示) */}
{parsed.maintext && (
<div
className="text-sm leading-relaxed break-words overflow-wrap-anywhere message-body"
dangerouslySetInnerHTML={{ __html: maintextHtml }}
/>
)}
{/* Sudachi 选择按钮 */}
{choices.length > 0 && onChoiceSelect && (
{/* bodyText 渲染区(仅当没有 maintext 时显示,避免重复) */}
{!parsed.maintext && parsed.bodyText && (
<div
className="text-sm leading-relaxed break-words overflow-wrap-anywhere message-body"
dangerouslySetInnerHTML={{ __html: bodyHtml }}
/>
)}
{/* HTML code blocks → StatusBarIframe */}
{htmlBlocks.map((html, i) => (
<StatusBarIframe
key={`html-block-${i}`}
rawMessage={content}
allMessages={allMessages}
messageIndex={messageIndex}
htmlContent={html}
statusYaml={parsed.statusYaml}
minHeight={150}
onAction={onAction}
/>
))}
{/* YAML 状态栏(无 html-code-block 时单独显示) */}
{parsed.statusYaml && htmlBlocks.length === 0 && (
<StatusBarIframe
rawMessage={content}
allMessages={allMessages}
messageIndex={messageIndex}
statusYaml={parsed.statusYaml}
minHeight={200}
onAction={onAction}
/>
)}
{/* 旧版 JSON 状态面板(兼容) */}
{parsed.statusJson && !parsed.statusYaml && (
<JsonStatusPanel data={parsed.statusJson} />
)}
</>
)}
{/* 选择项按钮 */}
{parsed.choices.length > 0 && onChoiceSelect && (
<ChoiceList choices={parsed.choices} onSelect={onChoiceSelect} />
)}
</div>
)
}
// ============= 子组件 =============
/** 选择项按钮列表 */
function ChoiceList({
choices,
onSelect,
}: {
choices: Choice[]
onSelect: (text: string) => void
}) {
return (
<div className="mt-4 p-4 glass rounded-lg border border-primary/20">
<div className="text-sm font-medium text-primary mb-3"></div>
<div className="space-y-2">
{choices.map((choice) => (
{choices.map(choice => (
<button
key={choice.label}
onClick={() => onChoiceSelect(choice.text)}
onClick={() => onSelect(choice.text)}
className="w-full text-left px-4 py-3 glass-hover rounded-lg border border-white/10 hover:border-primary/50 transition-all group"
>
<span className="text-primary font-bold mr-2">{choice.label}.</span>
@@ -743,60 +237,35 @@ export default function MessageContent({ content, role, onChoiceSelect }: Messag
))}
</div>
</div>
)}
)
}
{/* 状态面板JSON 格式 - 保留兼容 */}
{statusPanel && (
/** 旧版 JSON 状态面板(向后兼容) */
function JsonStatusPanel({ data }: { data: Record<string, unknown> }) {
return (
<div className="mt-4 p-4 glass rounded-lg border border-secondary/20">
<div className="text-sm font-medium text-secondary mb-3"></div>
<div className="space-y-3">
{Object.entries(statusPanel).map(([category, data]: [string, any]) => (
{Object.entries(data).map(([category, val]) => (
<div key={category} className="space-y-1">
<div className="text-xs font-semibold text-secondary/80">{category}</div>
<div className="pl-3 space-y-1">
{typeof data === 'object' && data !== null ? (
Object.entries(data).map(([key, value]: [string, any]) => (
<div key={key} className="flex justify-between text-xs">
<span className="text-white/60">{key}:</span>
{typeof val === 'object' && val !== null ? (
Object.entries(val as Record<string, unknown>).map(([k, v]) => (
<div key={k} className="flex justify-between text-xs">
<span className="text-white/60">{k}:</span>
<span className="text-white/90 font-medium">
{typeof value === 'object' ? JSON.stringify(value) : String(value)}
{typeof v === 'object' ? JSON.stringify(v) : String(v)}
</span>
</div>
))
) : (
<div className="text-xs text-white/90">{String(data)}</div>
<div className="text-xs text-white/90">{String(val)}</div>
)}
</div>
</div>
))}
</div>
</div>
)}
{/* 状态栏YAML 格式 - SillyTavern 兼容) */}
{statusYaml && (
<div className="mt-4 w-full border border-white/10 rounded-lg bg-black/20 overflow-hidden">
<iframe
ref={statusIframeRef}
className="w-full"
sandbox="allow-scripts allow-same-origin"
style={{ minHeight: '200px', border: 'none' }}
/>
</div>
)}
{/* maintext 内容(如果提取到) */}
{maintext && !statusYaml && (
<div className="mt-4 p-4 glass rounded-lg border border-primary/20">
<div className="text-sm leading-relaxed break-words overflow-wrap-anywhere">
{maintext.split('\n').map((line, index) => (
<div key={index} className="min-h-[1.5em]">
{line ? <DialogueText text={line} /> : <br />}
</div>
))}
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,500 @@
/**
* StatusBarIframe 组件
*
* 负责渲染消息中嵌入的 HTML 内容(包含 <script> 的完整 HTML、html-code-block、或 YAML 状态栏)。
* 内部使用 sandbox iframe + shim 脚本,与父页面通过 postMessage 桥接。
*
* 安全说明:
* - sandbox 属性仅保留 allow-scripts去除 allow-same-origin防止 iframe 访问父页面 storage/cookie
* - postMessage 使用 '*' 作为 targetOrigin沙箱 iframe origin 为 'null',指定具体 origin 会被浏览器丢弃)
* - 父页面通过 event.source === iframe.contentWindow 校验消息来源
*
* 对应文档第 5 节HTML 状态栏渲染器)与重构文档 8.4 节。
*/
import { useEffect, useRef, useState } from 'react'
import { load as parseYaml } from 'js-yaml'
// ============= 类型定义 =============
interface StatusBarIframeProps {
/** 当前消息原始内容(用于 shim 注入 __RAW_MESSAGE__ */
rawMessage: string
/** 所有消息原始内容(用于 shim 注入 __CHAT_MESSAGES__ */
allMessages?: string[]
/** 当前消息在对话中的索引 */
messageIndex?: number
/** 要渲染的 HTML 内容(来自 html-code-block 或直接 HTML
* 若同时提供 statusYaml则以 HTML 为主YAML 注入作兜底 */
htmlContent?: string
/** 状态栏 YAML 数据(来自 <Status_block>,不存在则为空字符串) */
statusYaml?: string
/** iframe 最小高度px默认 200 */
minHeight?: number
/**
* 父页面 originpostMessage 安全检查)。
* 默认为 window.location.origin运行时获取而非硬编码 '*'。
*/
parentOrigin?: string
/**
* iframe 内操作回调。
* - type='fillInput' → 仅填充输入框sendToChatBox
* - type='playerAction' → 仅填充输入框onPlayerAction / options-list 点击)
* - type='triggerAction'→ 填充并发送triggerSlash命令格式如 /send text|/trigger
*/
onAction?: (type: 'fillInput' | 'playerAction' | 'triggerAction', payload: string) => void
}
// ============= shim 脚本模板 =============
/**
* 生成注入到 iframe 的 shim 脚本
* 提供 ST 兼容 API + 与宿主页桥接
*
* 注意:由于 sandbox 去掉了 allow-same-originiframe 内脚本无法访问父页面 DOM / storage
* 与父页通信必须通过 postMessage。
*/
function buildShimScript(
rawMessage: string,
allMessages: string[],
currentId: number
): string {
// Escape </script (the HTML-spec pattern that closes a script element) to prevent
// the inline <script> tag from being prematurely terminated by message content.
// Per spec, </script is closed by </script followed by whitespace, '/' or '>'.
// Replacing '</script' with '<\/script' is safe: JS treats \/ as / inside strings.
const escapeForScript = (s: string) => s.replace(/<\/script/gi, '<\\/script')
const rawJson = escapeForScript(JSON.stringify(rawMessage))
const allJson = escapeForScript(JSON.stringify(allMessages))
return `
(function() {
var __RAW_MESSAGE__ = ${rawJson};
var __CHAT_MESSAGES__ = ${allJson};
var __CURRENT_ID__ = ${currentId};
// ST 兼容 API
window.getCurrentMessageId = function() { return __CURRENT_ID__; };
window.getChatMessages = function(id) {
var idx = (id !== undefined && id !== null) ? id : __CURRENT_ID__;
return [{ message: __CHAT_MESSAGES__[idx] !== undefined ? __CHAT_MESSAGES__[idx] : __RAW_MESSAGE__ }];
};
// 沙箱内禁用模态对话框sandbox 不允许 allow-modals直接覆盖避免报错
// alert → 控制台输出confirm → 始终返回 trueprompt → 返回空字符串
window.alert = function(msg) { console.log('[sandbox alert]', msg); };
window.confirm = function(msg) { console.log('[sandbox confirm]', msg); return true; };
window.prompt = function(msg, def) { console.log('[sandbox prompt]', msg); return (def !== undefined ? String(def) : ''); };
// localStorage shimsandbox 无 allow-same-origin直接访问 localStorage 会抛 DOMException。
// 用内存 Map 模拟,让卡片的主题/模式偏好设置代码正常运行(数据不跨页面持久化)。
(function() {
var _store = {};
var _ls = {
getItem: function(k) { return Object.prototype.hasOwnProperty.call(_store, k) ? _store[k] : null; },
setItem: function(k, v) { _store[k] = String(v); },
removeItem: function(k) { delete _store[k]; },
clear: function() { _store = {}; },
key: function(i) { return Object.keys(_store)[i] || null; },
get length() { return Object.keys(_store).length; }
};
try { window.localStorage; } catch(e) { Object.defineProperty(window, 'localStorage', { value: _ls, writable: false }); return; }
// 若访问 localStorage 不抛错则直接替换(通常在 sandbox 无 allow-same-origin 时会抛)
try { window.localStorage.getItem('__probe__'); } catch(e) {
Object.defineProperty(window, 'localStorage', { value: _ls, writable: false });
}
})();
// 向父页面发送消息
// 沙箱 iframe 的 origin 是 'null',必须使用 '*' 作为 targetOrigin
// 否则浏览器会静默丢弃消息。安全性由父页面用 event.source 校验保证。
function sendToParent(type, data) {
window.parent.postMessage({ type: type, data: data }, '*');
}
// 上报内容尺寸(父页面据此调整 iframe 大小)
function reportSize() {
var body = document.body;
var html = document.documentElement;
if (!body || !html) return;
// body 是 inline-block其 offsetWidth/scrollWidth 反映真实内容宽度
// html 的 scrollHeight 反映完整文档高度
var width = Math.max(body.offsetWidth, body.scrollWidth);
var height = Math.max(html.scrollHeight, html.offsetHeight, body.scrollHeight, body.offsetHeight);
sendToParent('resize', { width: width, height: height });
}
// ST 兼容triggerSlash → 发送到父页面并触发发送(例:/send text|/trigger
window.triggerSlash = function(command) {
sendToParent('triggerAction', { command: command });
};
// ST 兼容sendToChatBox → 只填充输入框,不发送
window.sendToChatBox = function(text) {
sendToParent('fillInput', { text: text });
};
// 用户操作触发(供状态栏按钮调用)
window.onPlayerAction = function(action) {
sendToParent('playerAction', { action: action });
};
// DOMContentLoaded 后统一执行:注入 YAML 兜底数据 + 绑定 options-list + 上报尺寸
document.addEventListener('DOMContentLoaded', function() {
// 注入 Status_block YAML
// - 若 yaml-data-source 元素不存在,创建并追加到 <head>
// - 若存在但内容为空(卡片模板占位),填入 YAML 内容
var statusMatch = __RAW_MESSAGE__.match(/<Status_block>([\\s\\S]*?)<\\/Status_block>/i);
if (statusMatch) {
var yamlContent = statusMatch[1].trim();
var existing = document.getElementById('yaml-data-source');
if (!existing) {
var s = document.createElement('script');
s.id = 'yaml-data-source';
s.type = 'text/yaml';
s.textContent = yamlContent;
document.head && document.head.appendChild(s);
} else if (!existing.textContent || !existing.textContent.trim()) {
// 元素存在但为空(卡片模板占位),填入数据
existing.textContent = yamlContent;
}
}
// 注入 maintext若元素不存在则创建若存在但为空则填入
var maintextMatch = __RAW_MESSAGE__.match(/<maintext>([\\s\\S]*?)<\\/maintext>/i);
if (maintextMatch) {
var maintextContent = maintextMatch[1].trim();
var existingMaintext = document.getElementById('maintext');
if (!existingMaintext) {
var d = document.createElement('div');
d.id = 'maintext';
d.style.display = 'none';
d.textContent = maintextContent;
document.body && document.body.appendChild(d);
} else if (!existingMaintext.textContent || existingMaintext.textContent.trim() === '加载中...') {
existingMaintext.textContent = maintextContent;
}
}
// options-list 点击兜底
var optionsList = document.getElementById('options-list');
if (optionsList) {
optionsList.addEventListener('click', function(event) {
var target = event.target;
if (target && target.textContent) {
sendToParent('playerAction', { action: target.textContent.trim() });
}
}, true);
}
// 首次上报尺寸
reportSize();
// 图片/字体等异步资源加载完成后再次上报
window.addEventListener('load', reportSize);
// ResizeObserver 监听内容变化(脚本动态修改 DOM 时也能及时上报)
if (window.ResizeObserver && document.body) {
var ro = new ResizeObserver(function() { reportSize(); });
ro.observe(document.body);
}
});
})();
`
}
// ============= 构建 iframe srcdoc =============
/**
* 构建完整的 HTML 文档字符串(用于 iframe.srcdoc
*/
function buildIframeDoc(content: string, shimScript: string, statusYaml: string): string {
const isFullDoc = /^\s*<!DOCTYPE/i.test(content) || /^\s*<html/i.test(content)
const baseStyle = `
<style>
html {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
margin: 0;
padding: 16px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: transparent;
color: rgba(255, 255, 255, 0.9);
font-size: 14px;
line-height: 1.6;
box-sizing: border-box;
/* display: inline-block 使 body 宽度由内容决定而非受 iframe 视口约束,
这样 scrollWidth 才能反映真实内容宽度 */
display: inline-block;
min-width: 100vw;
}
* { box-sizing: border-box; }
img { height: auto; }
</style>
`
// YAML 数据内联 script 标签(兜底)
const yamlDataTag = statusYaml
? `<script id="yaml-data-source" type="text/yaml">${statusYaml.replace(/<\/script>/gi, '<\\/script>')}<\/script>`
: ''
// shim 内联(避免 CDN 依赖)
const inlineScripts = `<script>${shimScript}<\/script>`
if (isFullDoc) {
// 全文档:在 </head> 前注入 baseStyle + yaml data + shim
return content.replace(
/(<\/head>)/i,
`${baseStyle}${yamlDataTag}${inlineScripts}$1`
)
}
const bodyContent = /^\s*<body/i.test(content)
? content
: `<body>${content}</body>`
return `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
${baseStyle}
${yamlDataTag}
${inlineScripts}
</head>
${bodyContent}
</html>`
}
// ============= 组件 =============
export default function StatusBarIframe({
rawMessage,
allMessages = [],
messageIndex = 0,
htmlContent = '',
statusYaml = '',
minHeight = 200,
parentOrigin,
onAction,
}: StatusBarIframeProps) {
const iframeRef = useRef<HTMLIFrameElement>(null)
const [iframeSize, setIframeSize] = useState<{ width: number; height: number } | null>(null)
/**
* 冻结 allMessages组件挂载时取快照之后不再跟随父组件更新。
* 原因:每当对话新增消息时父组件的 allMessages 引用会重建,若历史消息的 iframe
* 跟随更新,会触发 srcdoc 重置 + setIframeSize(null),导致已渲染的 HTML 内容消失后重建(闪烁)。
* 历史消息的 HTML 渲染不依赖后续新增消息的内容,冻结快照即可。
*/
const frozenAllMessages = useRef<string[]>(allMessages)
useEffect(() => {
if (!iframeRef.current) return
// parentOrigin is kept as a prop for future use (e.g. if we add non-sandboxed iframes)
// For the current sandboxed iframe, postMessage uses '*' — see buildShimScript.
const shim = buildShimScript(rawMessage, frozenAllMessages.current, messageIndex)
const content = htmlContent || buildDefaultYamlRenderer(statusYaml)
const doc = buildIframeDoc(content, shim, statusYaml)
const iframe = iframeRef.current
iframe.srcdoc = doc
// 重置尺寸(避免切换内容时残留旧尺寸)
setIframeSize(null)
}, [rawMessage, messageIndex, htmlContent, statusYaml, minHeight, parentOrigin])
// 监听 iframe 内通过 postMessage 上报的消息
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
// 安全检查:只接受来自本 iframe 的消息srcdoc 的 origin 是 'null',用 source 做身份校验)
if (event.source !== iframeRef.current?.contentWindow) return
const msg = event.data
if (!msg) return
if (msg.type === 'resize') {
const { width, height } = msg.data as { width: number; height: number }
if (typeof width === 'number' && typeof height === 'number') {
setIframeSize({
width: Math.max(width, 0),
height: Math.max(height, minHeight),
})
}
return
}
// 操作类消息:转发给父组件
if (onAction) {
if (msg.type === 'fillInput') {
const text = (msg.data as { text?: string })?.text ?? ''
onAction('fillInput', text)
} else if (msg.type === 'playerAction') {
const action = (msg.data as { action?: string })?.action ?? ''
onAction('playerAction', action)
} else if (msg.type === 'triggerAction') {
const command = (msg.data as { command?: string })?.command ?? ''
onAction('triggerAction', command)
}
}
}
window.addEventListener('message', handleMessage)
return () => window.removeEventListener('message', handleMessage)
}, [minHeight, parentOrigin, onAction])
// 计算实际宽高:有内容尺寸时用内容尺寸,否则用 minHeight / 撑满父容器
const frameHeight = iframeSize ? iframeSize.height : minHeight
// 宽度:若内容比父容器宽则横向滚动,否则撑满父容器
const frameWidth = iframeSize ? iframeSize.width : undefined
return (
<div
className="border border-white/10 rounded-lg bg-black/20 overflow-auto"
style={{ maxWidth: '100%' }}
>
<iframe
ref={iframeRef}
// 去掉 allow-same-origin防止 iframe 内代码访问父页面 localStorage / cookie
sandbox="allow-scripts"
style={{
width: frameWidth ? `${frameWidth}px` : '100%',
height: `${frameHeight}px`,
border: 'none',
display: 'block',
minWidth: '100%',
}}
title="status-bar"
/>
</div>
)
}
// ============= 默认 YAML 渲染器模板 =============
/**
* 当没有提供自定义 HTML 时,使用内置 YAML 状态栏渲染器。
* 在父页面中使用 js-yaml 解析 YAML再将渲染好的 HTML 注入 iframe。
*/
function buildDefaultYamlRenderer(statusYaml: string): string {
const base = `
<body>
<style>
.status-block { background: rgba(0,0,0,0.3); border-radius: 12px; padding: 16px; border: 1px solid rgba(255,255,255,0.1); }
.status-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; padding-bottom: 12px; border-bottom: 1px solid rgba(255,255,255,0.1); }
.status-title { font-size: 16px; font-weight: 600; color: rgba(157,124,245,1); }
.status-info { font-size: 13px; color: rgba(255,255,255,0.7); margin: 4px 0; }
.character-card { background: rgba(0,0,0,0.2); border-radius: 8px; padding: 12px; margin: 8px 0; border: 1px solid rgba(255,255,255,0.05); }
.character-name { font-weight: 600; font-size: 15px; margin-bottom: 8px; color: rgba(157,124,245,1); }
.attribute { margin: 4px 0; font-size: 13px; line-height: 1.5; }
.attribute-key { color: rgba(157,124,245,0.8); font-weight: 500; }
.option-item { padding: 8px 12px; margin: 4px 0; background: rgba(157,124,245,0.1); border-radius: 6px; cursor: pointer; transition: all 0.2s; border-left: 2px solid rgba(157,124,245,0.3); }
.option-item:hover { background: rgba(157,124,245,0.2); border-left-color: rgba(157,124,245,0.7); }
.loading { padding: 20px; text-align: center; color: rgba(255,255,255,0.5); }
</style>
`
const emptyHtml = `${base}
<div class="loading">状态栏数据为空</div>
</body>`
if (!statusYaml || !statusYaml.trim()) return emptyHtml
let data: unknown
try {
data = parseYaml(statusYaml)
} catch (e: any) {
return `${base}
<div class="loading" style="color:rgba(255,100,100,0.8)">YAML 解析失败: ${String(
e?.message ?? e
)}</div>
</body>`
}
if (!data || typeof data !== 'object') {
return emptyHtml
}
const escHtml = (s: unknown) =>
String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
const obj = data as Record<string, unknown>
const rootKey = Object.keys(obj)[0]
if (!rootKey) return emptyHtml
const block = obj[rootKey] as any
let html = `${base}
<div class="status-block">
<div class="status-header"><div class="status-title">${escHtml(rootKey)}</div></div>
`
const appendBlock = (key: string, value: any) => {
if (typeof value === 'string' || typeof value === 'number') {
html += ` <div class="status-info"><span class="attribute-key">${escHtml(
key
)}:</span> ${escHtml(value)}</div>\n`
} else if (Array.isArray(value)) {
html += ` <div style="margin-top:8px"><div class="attribute-key">${escHtml(key)}</div>\n`
value.forEach((item) => {
if (item && typeof item === 'object') {
const rec = item as Record<string, any>
const name = rec['名字'] ?? rec['name'] ?? ''
html += ' <div class="character-card">\n'
if (name) {
html += ` <div class="character-name">${escHtml(name)}</div>\n`
}
Object.entries(rec).forEach(([k, v]) => {
if (k === '名字' || k === 'name') return
html += ` <div class="attribute"><span class="attribute-key">${escHtml(
k
)}:</span> ${escHtml(String(v))}</div>\n`
})
html += ' </div>\n'
} else {
html += ` <div class="attribute">${escHtml(String(item))}</div>\n`
}
})
html += ' </div>\n'
} else if (value && typeof value === 'object') {
const rec = value as Record<string, any>
const options = rec['选项']
if (Array.isArray(options)) {
const name = rec['名字'] ?? key
html +=
' <div style="margin-top:12px"><div style="font-weight:600;margin-bottom:8px;color:rgba(157,124,245,1)">'
html += `${escHtml(String(name))} 的行动选项</div>\n`
options.forEach((opt) => {
html += ` <div class="option-item" onclick="window.onPlayerAction && window.onPlayerAction(${escHtml(
JSON.stringify(opt)
)})">${escHtml(opt)}</div>\n`
})
html += ' </div>\n'
} else {
html += ' <div class="character-card">\n'
html += ` <div class="character-name">${escHtml(key)}</div>\n`
Object.entries(rec).forEach(([k, v]) => {
html += ` <div class="attribute"><span class="attribute-key">${escHtml(
k
)}:</span> ${escHtml(String(v))}</div>\n`
})
html += ' </div>\n'
}
}
}
if (block && typeof block === 'object') {
Object.entries(block as Record<string, any>).forEach(([k, v]) => appendBlock(k, v))
}
html += ' </div>\n</body>'
return html
}

View File

@@ -25,3 +25,50 @@
@apply transition-all duration-200 hover:bg-white/10 hover:border-white/20;
}
}
/* ============= 消息渲染样式 ============= */
/* 引号美化 */
.message-body .quote-open,
.message-body .quote-close {
color: rgba(139, 92, 246, 0.6);
}
.message-body .quote-content {
color: rgb(139, 92, 246);
font-weight: 500;
padding: 0 2px;
}
/* 动作文本美化 *动作* */
.message-body .action-text {
color: rgba(167, 139, 250, 0.85);
font-style: italic;
}
/* 代码块样式 */
.message-body .code-block {
background: rgba(0, 0, 0, 0.35);
padding: 10px 12px;
border-radius: 8px;
overflow-x: auto;
font-size: 13px;
line-height: 1.5;
margin: 8px 0;
position: relative;
}
.message-body .code-lang-label {
font-size: 11px;
color: rgba(255, 255, 255, 0.4);
display: block;
margin-bottom: 4px;
font-family: ui-monospace, 'Cascadia Code', monospace;
}
.message-body .code-block code {
color: rgba(255, 255, 255, 0.9);
font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, monospace;
}
/* html-code-block 默认隐藏(由 StatusBarIframe 处理) */
.message-body .html-code-block {
display: none;
}

View File

@@ -0,0 +1,172 @@
/**
* 正则脚本执行引擎Regex Engine
*
* 对应 ST 中 extensions/regex/engine.js 的前端执行逻辑。
* 从后端加载的 RegexScript 列表,在前端对消息文本执行正则替换。
*
* placement 枚举(与后端 model/app/regex_script.go 保持一致):
* 0 = USER_INPUT — 对用户输入执行
* 1 = AI_OUTPUT — 对 AI 输出执行
* 2 = WORLD_INFO — 世界书注入(前端暂不处理)
* 3 = DISPLAY — 仅展示层(前端暂不处理)
*
* 注意:本模块是纯函数,不持有任何状态,调用方负责传入脚本列表。
*/
import type { RegexScript } from '../api/regex'
export const REGEX_PLACEMENT = {
USER_INPUT: 0,
AI_OUTPUT: 1,
WORLD_INFO: 2,
DISPLAY: 3,
} as const
export type RegexPlacement = (typeof REGEX_PLACEMENT)[keyof typeof REGEX_PLACEMENT]
// ============= 核心函数 =============
/**
* 对文本执行所有符合条件的正则脚本
*
* @param text 原始文本
* @param placement 执行阶段USER_INPUT | AI_OUTPUT
* @param scripts 正则脚本列表(应已过滤出 enabled 的)
* @param depth 消息深度(用于 minDepth/maxDepth 过滤,可选)
* @returns 处理后的文本
*/
export function applyRegexScripts(
text: string,
placement: RegexPlacement,
scripts: RegexScript[],
depth?: number
): string {
if (!text || scripts.length === 0) return text
let result = text
// 按 order 字段排序(升序)
const sorted = [...scripts].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
for (const script of sorted) {
// 跳过禁用的脚本
if (script.disabled) continue
// 检查 placement
if (!matchesPlacement(script, placement)) continue
// 检查深度(仅在 depth 已知时)
if (typeof depth === 'number') {
if (
script.minDepth !== undefined &&
script.minDepth !== null &&
script.minDepth >= 0 &&
depth < script.minDepth
) {
continue
}
if (
script.maxDepth !== undefined &&
script.maxDepth !== null &&
script.maxDepth >= 0 &&
depth > script.maxDepth
) {
continue
}
}
result = runSingleScript(script, result)
}
return result
}
/**
* 执行单条正则脚本
*/
export function runSingleScript(script: RegexScript, text: string): string {
if (!script.findRegex || !text) return text
let findPattern = script.findRegex
// substituteRegex: 1=RAW 替换宏2=ESCAPED 转义后替换(简化处理:暂跳过宏替换)
// TODO: 若需要支持宏变量替换可在此扩展
const regex = buildRegex(findPattern)
if (!regex) return text
const replaceWith = script.replaceWith ?? ''
try {
let result = text.replace(regex, (match, ...args) => {
// 支持 $1, $2... 捕获组
let replacement = replaceWith
// ST 兼容:{{match}} → $0
.replace(/\{\{match\}\}/gi, match)
// 替换捕获组 $1 $2...
const groups = args.slice(0, args.length - 3) // 去掉 offset、input 和 namedGroupsES2018+
groups.forEach((group, i) => {
replacement = replacement.replace(
new RegExp(`\\$${i + 1}`, 'g'),
group ?? ''
)
})
// trimStrings 处理
if (script.trimStrings && script.trimStrings.length > 0) {
for (const trim of script.trimStrings) {
if (trim) replacement = replacement.split(trim).join('')
}
}
return replacement
})
return result
} catch (err) {
console.warn(`[regexEngine] 脚本 "${script.name}" 执行失败:`, err)
return text
}
}
// ============= 辅助函数 =============
/**
* 解析正则字符串(支持 /pattern/flags 格式和普通字符串)
*/
function buildRegex(pattern: string): RegExp | null {
try {
// 检测 /pattern/flags 格式
const slashMatch = pattern.match(/^\/(.+)\/([gimsuy]*)$/)
if (slashMatch) {
return new RegExp(slashMatch[1], slashMatch[2])
}
// 普通字符串,默认 global
return new RegExp(pattern, 'g')
} catch (err) {
console.warn(`[regexEngine] 无效正则: "${pattern}"`, err)
return null
}
}
/**
* 判断脚本是否应该在指定 placement 执行
* RegexScript.placement 是数字(后端 enum不是数组
*/
function matchesPlacement(script: RegexScript, placement: RegexPlacement): boolean {
// 后端存的是单个数字
return script.placement === placement
}
// ============= 便捷包装 =============
/** 对 AI 输出文本执行正则脚本(最常用) */
export function processAIOutput(text: string, scripts: RegexScript[], depth?: number): string {
return applyRegexScripts(text, REGEX_PLACEMENT.AI_OUTPUT, scripts, depth)
}
/** 对用户输入文本执行正则脚本 */
export function processUserInput(text: string, scripts: RegexScript[], depth?: number): string {
return applyRegexScripts(text, REGEX_PLACEMENT.USER_INPUT, scripts, depth)
}

73
web-app/src/lib/sse.ts Normal file
View File

@@ -0,0 +1,73 @@
/**
* SSE 流式传输工具
*
* 将重复的 SSE 读取逻辑抽取为一个通用异步生成器,
* 供 ChatArea 中的 handleSendMessage 和 handleRegenerateResponse 共用。
*/
const API_BASE = import.meta.env.VITE_API_BASE_URL || '/api'
/** 单条 SSE 事件 */
export interface SSEEvent {
event: string
data: string
}
/**
* 发起 SSE 请求并以异步迭代方式逐条产出事件。
* 调用方可使用 `for await (const ev of streamSSE(...))` 消费。
*
* @param path 相对 API 路径(不含 base如 `/app/conversation/1/message`
* @param method HTTP 方法,默认 POST
* @param body 请求体(可选)
*/
export async function* streamSSE(
path: string,
method: 'POST' | 'GET' = 'POST',
body?: Record<string, unknown>
): AsyncGenerator<SSEEvent> {
const token = localStorage.getItem('token')
const response = await fetch(`${API_BASE}${path}`, {
method,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
})
if (!response.ok) {
throw new Error(`SSE 请求失败: ${response.status} ${response.statusText}`)
}
const reader = response.body?.getReader()
if (!reader) throw new Error('无法获取响应流')
const decoder = new TextDecoder()
let buffer = ''
let currentEvent = ''
try {
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() ?? ''
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()
yield { event: currentEvent, data }
currentEvent = ''
}
}
}
} finally {
reader.cancel().catch(() => {})
}
}

View File

@@ -0,0 +1,371 @@
/**
* 文本渲染引擎Text Renderer
*
* 按照文档描述的 10 步管线,将 LLM 原始文本转换为可直接 dangerouslySetInnerHTML 渲染的 HTML 字符串。
* 这是一个纯函数模块,不依赖任何 React 状态,便于测试和复用。
*
* 渲染管线(与 ST text-decorator.js 对齐):
* 1. 正则脚本处理(由调用方在外部完成,传入 processedText
* 2. 变量替换 {{user}} / {{char}} / ...
* 3. 清理控制标签 <phase>, <maintext>, <!-- ... -->
* 4. 规范行间空白
* 5. 抽取 fenced code blocks用占位符替换暂存
* 6. HTML 转义(防 XSS
* 7. 引号美化
* 8. 动作文本美化 *动作*
* 9. \n → <br>
* 10. 恢复代码块html 块 → html-code-block / 其余 → pre>code
*/
// ============= 类型定义 =============
export interface RenderOptions {
/** 消息在对话中的索引(用于正则 depth 过滤等,暂留扩展) */
index?: number
/** 当前角色名称(用于变量替换 {{char}} */
characterName?: string
/** 当前用户名称(用于变量替换 {{user}} */
userName?: string
/** 额外的自定义变量key → value */
customVars?: Record<string, string>
}
/** 解析后的消息结构(在渲染前从原始内容中提取) */
export interface ParsedMessage {
/** 提取 <maintext>...</maintext> 后的正文(如不存在则为空字符串) */
maintext: string
/** 提取 <Status_block>...</Status_block> 后的 YAML 字符串(如不存在则为空字符串) */
statusYaml: string
/** 提取旧版 <status_current_variable>...</status_current_variable> 的 JSON 对象(兼容) */
statusJson: Record<string, unknown> | null
/** 解析出的选择项列表 */
choices: Choice[]
/** 去掉所有特殊块后、待渲染的主体文本 */
bodyText: string
/** 主体文本是否含有 <script> 标签 */
hasScript: boolean
/** 主体文本是否含有 HTML 标签或代码块 */
hasHtml: boolean
}
export interface Choice {
label: string
text: string
}
// ============= 解析函数(提取特殊块) =============
/** 从原始内容中一次性解析出所有特殊区域 */
export function parseRawMessage(raw: string): ParsedMessage {
let content = raw
// 1. 提取 <maintext>
let maintext = ''
content = content.replace(/<maintext>([\s\S]*?)<\/maintext>/i, (_, inner) => {
maintext = inner.trim()
return ''
})
// 2. 提取 <Status_block> (YAML)
let statusYaml = ''
content = content.replace(/<Status_block>\s*([\s\S]*?)\s*<\/Status_block>/i, (_, inner) => {
statusYaml = inner.trim()
return ''
})
// 3. 提取 <status_current_variable> (JSON兼容旧版)
let statusJson: Record<string, unknown> | null = null
content = content.replace(/<status_current_variable>([\s\S]*?)<\/status_current_variable>/i, (_, inner) => {
try {
statusJson = JSON.parse(inner.trim())
} catch {
// ignore malformed JSON
}
return ''
})
// 4. 提取 choices
const { choices, cleanContent } = extractChoices(content)
content = cleanContent
// 5. 检测 script / html在原始内容上检测而非剥离特殊块后的 bodyText
const hasScript = /<script[\s\S]*?<\/script>/i.test(raw)
const hasHtml = /<[a-zA-Z][^>]*>/.test(raw) || /```[\s\S]*?```/.test(raw)
return {
maintext,
statusYaml,
statusJson,
choices,
bodyText: content.trim(),
hasScript,
hasHtml,
}
}
/** 提取 <choice>...</choice> 或 [choice]...[/choice] 选择项 */
function extractChoices(content: string): { choices: Choice[]; cleanContent: string } {
const tagRegex = /(?:<choice>|\[choice\])([\s\S]*?)(?:<\/choice>|\[\/choice\])/i
const tagMatch = content.match(tagRegex)
if (tagMatch) {
const block = tagMatch[1]
const choices = parseChoiceBlock(block)
const cleanContent = content.replace(tagRegex, '').trim()
return { choices, cleanContent }
}
// 尝试纯文本格式 "A. xxx\nB. xxx"(至少 2 项才认为是选择列表)
const textChoiceRegex = /^([A-E])[.、:]\s*(.+?)(?=\n[A-E][.、:]|\n*$)/gm
const choices: Choice[] = []
let m: RegExpExecArray | null
while ((m = textChoiceRegex.exec(content)) !== null) {
choices.push({ label: m[1], text: m[2].trim() })
}
if (choices.length >= 2) {
const cleanContent = content.replace(textChoiceRegex, '').trim()
return { choices, cleanContent }
}
return { choices: [], cleanContent: content }
}
function parseChoiceBlock(block: string): Choice[] {
const choices: Choice[] = []
const optionRegex = /^([A-Z])[.、:]\s*(.+)$/gm
let m: RegExpExecArray | null
while ((m = optionRegex.exec(block)) !== null) {
choices.push({ label: m[1], text: m[2].trim() })
}
return choices
}
// ============= 文本渲染管线 =============
/** renderMessageHtml 的返回值 */
export interface RenderResult {
/** 可安全用于 dangerouslySetInnerHTML 的 HTML 字符串html-code-block 已被移除) */
html: string
/** 从 fenced code blocks 中提取的 HTML 片段列表(供 StatusBarIframe 渲染) */
htmlBlocks: string[]
}
/**
* 将原始消息正文bodyText渲染为 HTML 字符串。
* 调用前应先通过 parseRawMessage() 拿到 bodyText。
*
* @param rawText 待渲染的原始文本bodyText非完整原始消息
* @param role 消息角色
* @param options 渲染选项
* @returns RenderResult — html 字符串 + htmlBlocks 列表
*/
export function renderMessageHtml(
rawText: string,
role: 'user' | 'assistant',
options: RenderOptions = {}
): RenderResult {
// 用户消息:不做 HTML 渲染,只做基本转义 + 换行
if (role === 'user') {
return { html: escapeHtml(rawText).replace(/\n/g, '<br>'), htmlBlocks: [] }
}
let text = rawText
// Step 2: 变量替换
text = substituteVariables(text, options)
// Step 3: 清理控制性标签/区域
text = cleanControlTags(text)
// Step 4: 规范行间空白
text = normalizeWhitespace(text)
// Step 5: 抽取 fenced code blocks用占位符替换
const { text: textWithPlaceholders, blocks } = extractCodeBlocks(text)
text = textWithPlaceholders
// Step 6: HTML 转义主体文本(代码块已抽出,不会被误转义)
text = escapeHtml(text)
// Step 7: 引号美化
text = beautifyQuotes(text)
// Step 8: 动作文本美化 *动作*
text = beautifyActions(text)
// Step 9: 换行 → <br>
text = text.replace(/\n/g, '<br>')
// Step 10: 恢复代码块html 块替换为空占位,收集到 htmlBlocks
const { html, htmlBlocks } = restoreCodeBlocks(text, blocks)
return { html, htmlBlocks }
}
// ============= 各步骤实现 =============
/** Step 2: 变量替换 */
function substituteVariables(text: string, options: RenderOptions): string {
const vars: Record<string, string> = {
user: options.userName ?? '',
char: options.characterName ?? '',
...options.customVars,
}
// 替换用户自定义变量
for (const [key, value] of Object.entries(vars)) {
text = text.replace(new RegExp(`\\{\\{${escapeRegexStr(key)}\\}\\}`, 'gi'), value)
}
// 时间变量
const now = new Date()
text = text.replace(/\{\{time\}\}/gi, now.toLocaleTimeString('en-US', { hour12: false }))
text = text.replace(/\{\{time_12h\}\}/gi, now.toLocaleTimeString('en-US', { hour12: true }))
text = text.replace(/\{\{date\}\}/gi, now.toLocaleDateString('en-CA'))
text = text.replace(/\{\{datetime\}\}/gi, now.toLocaleString())
// 随机数
text = text.replace(/\{\{random:(\d+)-(\d+)\}\}/gi, (_, min, max) =>
String(Math.floor(Math.random() * (Number(max) - Number(min) + 1) + Number(min)))
)
text = text.replace(/\{\{random\}\}/gi, () => String(Math.floor(Math.random() * 100)))
// pick
text = text.replace(/\{\{pick:([^}]+)\}\}/gi, (_, options) => {
const list = options.split('|')
return list[Math.floor(Math.random() * list.length)]
})
// 特殊字符
text = text.replace(/\{\{newline\}\}/gi, '\n')
return text
}
/** Step 3: 清理控制性标签 */
function cleanControlTags(text: string): string {
// <phase ...> / </phase>
text = text.replace(/<phase[^>]*>/gi, '')
text = text.replace(/<\/phase>/gi, '')
// <!-- consider: ... --> 和普通 HTML 注释
text = text.replace(/<!--[\s\S]*?-->/g, '')
return text
}
/** Step 4: 规范行间空白 */
function normalizeWhitespace(text: string): string {
// 统一换行符
text = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
// 去除每行尾部空白
text = text.replace(/[ \t]+\n/g, '\n')
// 压缩连续 ≥3 个空行为 2 个
text = text.replace(/\n{3,}/g, '\n\n')
return text.trim()
}
// ---- 代码块处理 ----
interface CodeBlock {
lang: string
code: string
isHtml: boolean
}
const PLACEHOLDER_PREFIX = '\x00CODEBLOCK\x00'
/**
* Step 5: 提取 fenced code blocks返回带占位符的文本和代码块字典
* 支持:```html, ```text, ``` (自动检测是否是 HTML)
*/
function extractCodeBlocks(text: string): { text: string; blocks: Map<string, CodeBlock> } {
const blocks = new Map<string, CodeBlock>()
let idx = 0
const replaced = text.replace(/```([^\n`]*)\n?([\s\S]*?)```/g, (_, lang: string, code: string) => {
const trimmedLang = lang.trim().toLowerCase()
const trimmedCode = code.trim()
const key = `${PLACEHOLDER_PREFIX}${idx++}${PLACEHOLDER_PREFIX}`
// 判断是否是 HTML明确声明 html/text或内容含有 HTML 标签
const isHtml =
trimmedLang === 'html' ||
trimmedLang === 'text' ||
(trimmedLang === '' && /<[a-zA-Z][^>]*>/.test(trimmedCode))
blocks.set(key, { lang: trimmedLang, code: trimmedCode, isHtml })
return key
})
return { text: replaced, blocks }
}
/** Step 10: 恢复代码块html 块直接收集到 htmlBlocks 列表(不插入主 HTML */
function restoreCodeBlocks(
text: string,
blocks: Map<string, CodeBlock>
): { html: string; htmlBlocks: string[] } {
const htmlBlocks: string[] = []
for (const [key, block] of blocks) {
if (block.isHtml) {
// HTML 块:收集到 htmlBlocks主文本中用空字符串替换不污染主渲染区
htmlBlocks.push(block.code)
text = text.split(key).join('')
} else {
// 其他语言:普通代码块,插入主 HTML
const escapedCode = escapeHtml(block.code)
const langLabel = block.lang
? `<span class="code-lang-label">${escapeHtml(block.lang)}</span>`
: ''
const html = `<pre class="code-block">${langLabel}<code class="language-${escapeHtml(block.lang) || 'text'}">${escapedCode}</code></pre>`
text = text.split(key).join(html)
}
}
return { html: text, htmlBlocks }
}
// ---- 美化函数 ----
/** Step 7: 引号美化(英文/中文引号) */
function beautifyQuotes(text: string): string {
// 不在 HTML 标签内的引号对才做高亮
// 用正则匹配:中文书名号「」『』 / 英文 "" / 直双引号 ""
return text.replace(
/([""「『])((?:(?!["」』]).)*?)([""」』])/g,
(_, open, content, close) => {
return (
`<span class="quote-open">${open}</span>` +
`<span class="quote-content">${content}</span>` +
`<span class="quote-close">${close}</span>`
)
}
)
}
/** Step 8: 动作文本美化 *动作* → <span class="action-text">*动作*</span> */
function beautifyActions(text: string): string {
// 匹配 *内容*,但不跨越换行(已转成 <br> 之前,\n 还在)
return text.replace(/\*([^\n*]+)\*/g, (_, inner) => {
return `<span class="action-text">*${inner}*</span>`
})
}
// ============= 工具函数(公开导出) =============
/** HTML 转义(防 XSS */
export function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}
/** 正则元字符转义 */
function escapeRegexStr(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}

View File

@@ -1,8 +1,8 @@
import {useEffect, useState} from 'react'
import {useEffect, useRef, useState} from 'react'
import {useNavigate, useSearchParams} from 'react-router-dom'
import Navbar from '../components/Navbar'
import {Book, Code2, Download, Edit, FileJson, FileUp, Image as ImageIcon, Plus, Search, Trash2, X} from 'lucide-react'
import {type Character, characterApi} from '../api/character'
import {Book, Code2, Download, Edit, FileJson, FileUp, Image as ImageIcon, Layout, Plus, Search, Trash2, X} from 'lucide-react'
import {type Character, characterApi, extractFrontendCard} from '../api/character'
import {type RegexScript, regexScriptApi} from '../api/regex'
// import {useAppStore} from '../store'
@@ -37,6 +37,13 @@ export default function CharacterManagePage() {
const [showRegexScriptEditor, setShowRegexScriptEditor] = useState(false)
const [regexScripts, setRegexScripts] = useState<RegexScript[]>([])
const [_editingTab, _setEditingTab] = useState<'basic' | 'worldbook' | 'regex'>('basic')
// 前端卡编辑器
const [showFrontendCardEditor, setShowFrontendCardEditor] = useState(false)
const [frontendCardHtml, setFrontendCardHtml] = useState('')
const [frontendCardEnabled, setFrontendCardEnabled] = useState(true)
const [frontendCardPosition, setFrontendCardPosition] = useState<'top' | 'bottom'>('top')
const [showFrontendCardPreview, setShowFrontendCardPreview] = useState(false)
const frontendCardPreviewRef = useRef<HTMLIFrameElement>(null)
const [showAddRegexModal, setShowAddRegexModal] = useState(false)
const [newRegexForm, setNewRegexForm] = useState({
name: '',
@@ -61,10 +68,24 @@ export default function CharacterManagePage() {
setShowEditModal(true)
loadWorldBook(char)
loadRegexScripts(char.id)
loadFrontendCard(char)
}
}
}, [searchParams, characters])
const loadFrontendCard = (character: Character) => {
const fc = extractFrontendCard(character.extensions)
if (fc) {
setFrontendCardHtml(fc.html)
setFrontendCardEnabled(fc.enabled !== false)
setFrontendCardPosition(fc.position === 'bottom' ? 'bottom' : 'top')
} else {
setFrontendCardHtml('')
setFrontendCardEnabled(true)
setFrontendCardPosition('top')
}
}
const loadWorldBook = (character: Character) => {
if (!character.characterBook) {
setWorldBookEntries([])
@@ -187,6 +208,22 @@ export default function CharacterManagePage() {
entries: worldBookEntries
} : null
// 构建 extensions保留原有字段写入/清除前端卡
const baseExtensions: Record<string, any> = {
...(selectedCharacter.extensions || {}),
}
if (frontendCardHtml.trim()) {
baseExtensions['frontend_card'] = {
html: frontendCardHtml,
enabled: frontendCardEnabled,
position: frontendCardPosition,
}
} else {
// HTML 清空则删除前端卡
delete baseExtensions['frontend_card']
delete baseExtensions['chara_card_ui']
}
const updateData = {
name: formData.get('name') as string,
description: formData.get('description') as string,
@@ -198,6 +235,7 @@ export default function CharacterManagePage() {
tags: (formData.get('tags') as string).split(',').map(t => t.trim()).filter(Boolean),
isPublic: formData.get('isPublic') === 'on',
characterBook: characterBook,
extensions: baseExtensions,
}
try {
@@ -354,6 +392,7 @@ export default function CharacterManagePage() {
setShowEditModal(true)
loadWorldBook(char)
loadRegexScripts(char.id)
loadFrontendCard(char)
}}
className="flex-1 px-4 py-2 glass-hover rounded-lg text-sm cursor-pointer flex items-center justify-center gap-2"
>
@@ -559,7 +598,7 @@ export default function CharacterManagePage() {
<p className="text-xs text-white/40 mt-1">广使</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-3 gap-3">
<button
type="button"
onClick={() => setShowWorldBookEditor(true)}
@@ -576,6 +615,18 @@ export default function CharacterManagePage() {
<Code2 className="w-4 h-4" />
({regexScripts.length})
</button>
<button
type="button"
onClick={() => setShowFrontendCardEditor(true)}
className={`px-4 py-3 rounded-xl text-sm cursor-pointer flex items-center justify-center gap-2 ${
frontendCardHtml.trim()
? 'bg-primary/20 border border-primary/40 text-primary'
: 'glass-hover'
}`}
>
<Layout className="w-4 h-4" />
{frontendCardHtml.trim() ? ' ✓' : ''}
</button>
</div>
</form>
@@ -1206,6 +1257,161 @@ export default function CharacterManagePage() {
</div>
</div>
)}
{/* 前端卡编辑器弹窗 */}
{showFrontendCardEditor && selectedCharacter && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="glass rounded-3xl p-8 max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-2xl font-bold flex items-center gap-2">
<Layout className="w-6 h-6 text-primary" />
</h2>
<p className="text-sm text-white/50 mt-1">
extensions.frontend_card HTML
</p>
</div>
<button
onClick={() => { setShowFrontendCardEditor(false); setShowFrontendCardPreview(false) }}
className="p-2 glass-hover rounded-lg cursor-pointer"
>
<X className="w-5 h-5" />
</button>
</div>
{/* 控制栏 */}
<div className="flex items-center gap-4 mb-4 flex-shrink-0">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={frontendCardEnabled}
onChange={(e) => setFrontendCardEnabled(e.target.checked)}
className="w-4 h-4 cursor-pointer"
/>
<span className="text-sm text-white/80"></span>
</label>
<div className="flex items-center gap-2">
<span className="text-sm text-white/60"></span>
<select
value={frontendCardPosition}
onChange={(e) => setFrontendCardPosition(e.target.value as 'top' | 'bottom')}
className="px-3 py-1.5 glass rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
>
<option value="top"></option>
<option value="bottom"></option>
</select>
</div>
<div className="ml-auto flex items-center gap-2">
<button
onClick={() => setShowFrontendCardPreview(v => !v)}
className={`px-4 py-2 rounded-lg text-sm cursor-pointer transition-all ${
showFrontendCardPreview
? 'bg-primary/30 text-primary border border-primary/40'
: 'glass-hover'
}`}
>
{showFrontendCardPreview ? '▶ 隐藏预览' : '▶ 预览'}
</button>
<button
onClick={() => {
setFrontendCardHtml('')
setFrontendCardEnabled(true)
setFrontendCardPosition('top')
}}
className="px-4 py-2 glass-hover rounded-lg text-sm text-red-400 cursor-pointer"
>
</button>
</div>
</div>
{/* 编辑区 + 预览区 */}
<div className={`flex-1 overflow-hidden flex gap-4 min-h-0 ${showFrontendCardPreview ? 'flex-row' : 'flex-col'}`}>
{/* 代码编辑器 */}
<div className={`flex flex-col ${showFrontendCardPreview ? 'w-1/2' : 'flex-1'} min-h-0`}>
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-white/50">HTML / CSS / JS</span>
<span className="text-xs text-white/30">{frontendCardHtml.length} </span>
</div>
<textarea
value={frontendCardHtml}
onChange={(e) => setFrontendCardHtml(e.target.value)}
placeholder={`<!DOCTYPE html>
<html>
<head>
<style>
body { background: transparent; color: white; font-family: sans-serif; }
</style>
</head>
<body>
<div id="status-panel">...</div>
<script>
// 可以使用 ST 兼容 API
// getCurrentMessageId() → 当前消息索引
// getChatMessages(id) → [{ message: "..." }]
// window.onPlayerAction(text) → 触发玩家行动
document.addEventListener('DOMContentLoaded', function() {
var msg = getChatMessages(getCurrentMessageId());
document.getElementById('status-panel').textContent = msg[0]?.message || '';
});
<\/script>
</body>
</html>`}
className="flex-1 w-full px-4 py-3 glass rounded-xl text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary/50 resize-none overflow-y-auto"
style={{ minHeight: 0 }}
spellCheck={false}
/>
</div>
{/* 预览区iframe */}
{showFrontendCardPreview && (
<div className="w-1/2 flex flex-col min-h-0">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-white/50"> iframe</span>
<button
onClick={() => {
if (frontendCardPreviewRef.current) {
frontendCardPreviewRef.current.srcdoc = frontendCardHtml
}
}}
className="text-xs px-2 py-1 glass-hover rounded cursor-pointer text-primary"
>
</button>
</div>
<div className="flex-1 border border-white/10 rounded-xl overflow-hidden bg-black/20">
<iframe
ref={frontendCardPreviewRef}
srcDoc={frontendCardHtml}
sandbox="allow-scripts"
className="w-full h-full"
style={{ border: 'none', minHeight: '300px' }}
title="前端卡预览"
/>
</div>
</div>
)}
</div>
{/* 底部说明 + 保存按钮 */}
<div className="flex items-start justify-between gap-4 mt-4 flex-shrink-0">
<div className="text-xs text-white/30 flex-1">
<p> <code className="text-primary/70">sandbox="allow-scripts"</code> iframe HTML/CSS/JS</p>
<p className="mt-0.5">访 ST API<code className="text-primary/70">getCurrentMessageId()</code><code className="text-primary/70">getChatMessages(id)</code><code className="text-primary/70">window.onPlayerAction(text)</code></p>
</div>
<button
onClick={() => setShowFrontendCardEditor(false)}
className="px-6 py-3 bg-gradient-to-r from-primary to-secondary rounded-xl font-medium hover:opacity-90 transition-opacity cursor-pointer flex-shrink-0"
>
</button>
</div>
</div>
</div>
)}
</div>
)
}