🎨 1.优化前端渲染功能(html和对话消息格式)
2.优化流式传输,新增流式渲染功能 3.优化正则处理逻辑 4.新增context budget管理系统 5.优化对话消息失败处理逻辑 6.新增前端卡功能(待完整测试)
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -24,6 +24,7 @@ dist-ssr
|
||||
*.sw?
|
||||
uploads
|
||||
#docs
|
||||
.claude
|
||||
#.claude
|
||||
plugs
|
||||
sillytavern
|
||||
sillytavern
|
||||
st
|
||||
|
||||
@@ -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/)
|
||||
@@ -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>
|
||||
356
docs/优化方案.md
356
docs/优化方案.md
@@ -1,356 +0,0 @@
|
||||
## SillyTavern 完全兼容优化方案(Go + Gin + Postgres + React)
|
||||
|
||||
> 目标:基于现有 Go + React 系统,构建一个与 SillyTavern(下称 ST)高度兼容的角色卡 / 世界书 / 正则 / 预设 / 对话平台。
|
||||
|
||||
---
|
||||
|
||||
### 1. 总体目标与设计原则
|
||||
|
||||
- **技术栈统一**:所有核心功能(角色卡、世界书、正则、预设、聊天、AI 集成)全部收敛到:
|
||||
- **后端**:`server/` 下的 Go + Gin + PostgreSQL
|
||||
- **前端**:`projects/web-app` 下的 React + TypeScript + Tailwind
|
||||
- **SillyTavern 完全兼容**:
|
||||
- 支持 ST 角色卡 V2/V3(chara_card_v2 / v3)导入导出;
|
||||
- 支持 ST 世界书字段及触发逻辑(keys/secondary_keys/regex/depth/sticky/cooldown/probability/position 等);
|
||||
- 支持 ST Regex Scripts 配置(placement/runOnEdit/markdownOnly/promptOnly/substituteRegex/min/max depth);
|
||||
- 支持 ST 风格预设与 prompt 注入顺序(prompt_order / injection depth/position)。
|
||||
- **单一真相来源(SSOT)**:
|
||||
- **数据库即真相**:Postgres 负责持久化所有 ST 相关实体;
|
||||
- **前端只是 UI**:React 只做编辑/展示,请求都经过 API,不再有“前端内存版预设/世界书”。
|
||||
- **可扩展性**:
|
||||
- 提前为插件系统预留 Hook(onUserInput/onWorldInfoScan/beforePromptBuild/onAssistantDone 等);
|
||||
- 方便接入未来的 WebSocket/SSE 流式聊天、统计系统、市场/分享功能。
|
||||
|
||||
---
|
||||
|
||||
### 2. 当前 Go + React 系统现状(基于现有文档与代码)
|
||||
|
||||
#### 2.1 后端(server/)
|
||||
|
||||
根据 `projects/docs/development-progress.md`:
|
||||
|
||||
- ✅ **用户系统** 已完成(2024-02-26):
|
||||
- 模型:`AppUser`, `AppUserSession`
|
||||
- API:`/app/auth/register`, `/app/auth/login`, `/app/auth/refresh`, `/app/auth/logout`, `/app/auth/userinfo` 等
|
||||
- JWT、会话、用户资料、密码修改均已实现
|
||||
- ✅ **角色卡管理(AICharacter)** 已完成:
|
||||
- 模型:`AICharacter`(完全兼容 ST V2 格式),使用 JSONB 存储 `tags/alternateGreetings/characterBook/extensions` 等复杂结构
|
||||
- API:
|
||||
- `POST /app/character`
|
||||
- `GET /app/character`(分页、搜索、标签筛选)
|
||||
- `GET /app/character/:id`
|
||||
- `PUT /app/character/:id`
|
||||
- `DELETE /app/character/:id`
|
||||
- `POST /app/character/upload`(PNG/JSON 角色卡)
|
||||
- `GET /app/character/:id/export`(导出 JSON)
|
||||
- 工具:`utils/character_card.go`(PNG tEXt chunk / ST V2 工具)
|
||||
- 🚧 **预设管理**:
|
||||
- 前端页面 `/presets` 已完成(导入 JSON、复制、导出、编辑参数)。
|
||||
- 后端预设 API 尚未实现(`development-progress.md` 已给出规划端点)。
|
||||
- 📋 **对话系统 & AI 集成**:
|
||||
- 前端基础 UI 已完成(`/chat` + 侧边栏 + 输入框)。
|
||||
- 后端对话 API、AI 流式集成、世界书/正则逻辑尚在规划阶段。
|
||||
|
||||
#### 2.2 前端(`projects/web-app`)
|
||||
|
||||
- **用户系统前端**:`LoginPage/RegisterPage/ForgotPasswordPage/ProfilePage` 已完成,`api/client.ts + api/auth.ts` 已完成 token 注入与刷新。
|
||||
- **角色卡管理前端**:`CharacterManagePage` + `api/character.ts`
|
||||
- 功能:
|
||||
- 上传 PNG/JSON 角色卡(调用 `/app/character/upload`)
|
||||
- 编辑角色卡核心字段、内嵌 `characterBook`(世界书)字段
|
||||
- 导出 JSON(调用 `/app/character/:id/export`)
|
||||
- 搜索、分页、删除
|
||||
- **预设管理前端**:`PresetManagePage`
|
||||
- 当前为 **前端内存里的假数据** + 文件导入/导出;尚未接入真实后端 `/app/preset`。
|
||||
- **聊天前端**:`ChatPage + Sidebar + ChatArea + CharacterPanel + SettingsPanel`
|
||||
- 已实现基础布局、会话切换、角色选择、背景图/主题色设置等 UI。
|
||||
- 消息发送、历史加载、预设切换、世界书/正则开关还需后端配合。
|
||||
- **AI 配置前端**:`api/aiConfig.ts`(对应 `/app/ai-config` 系列)。
|
||||
|
||||
> 结论:**角色卡链路基本打通**,用户系统成熟;预设/对话/AI/世界书/正则目前主要停留在前端 UI 或规划层面,正好适合作为“集中下沉到 Go 后端”的突破口。
|
||||
|
||||
---
|
||||
|
||||
### 3. 目标架构(SillyTavern 兼容实现)
|
||||
|
||||
#### 3.1 核心领域模型(后端)
|
||||
|
||||
建议在现有 `model/app` 中引入或规范以下模型(部分已存在,可扩展):
|
||||
|
||||
- `AICharacter`(已存在)
|
||||
- ST 角色卡 V2/V3 的标准化版本 + 原始 JSON 存档。
|
||||
- `Worldbook` & `WorldbookEntry`
|
||||
- 支持角色内世界书与全局世界书。
|
||||
- `RegexScript`
|
||||
- 支持 scope:`global | character | preset`。
|
||||
- `Preset` & `PresetPrompt` & `PresetRegexBinding`
|
||||
- 表达 ST preset 的参数、prompt 列表和 regex 绑定。
|
||||
- `Conversation` & `Message`
|
||||
- 对话与消息记录,支持与 `AICharacter`、`Preset` 和 AI 配置关联。
|
||||
|
||||
#### 3.2 运行时 Pipeline
|
||||
|
||||
所有客户端(现阶段只有 React Web,将来可增加其他)统一通过 Go 后端完成以下流程:
|
||||
|
||||
1. **输入正则脚本处理**(global/character/preset)
|
||||
2. **世界书扫描**(keys/secondary_keys/regex/depth/sticky/cooldown 等)
|
||||
3. **Prompt 构建**(角色 system + world info + preset prompts + 历史消息)
|
||||
4. **AI 调用**(OpenAI/Anthropic/custom via `/app/ai-config`)
|
||||
5. **输出正则脚本处理**
|
||||
6. **持久化消息与统计**
|
||||
|
||||
React 前端只负责:
|
||||
|
||||
- 提供管理与编辑界面;
|
||||
- 将用户选择的 preset/worldbook/regex 传给后端;
|
||||
- 使用 SSE/WebSocket 将 AI 流输出展示给用户。
|
||||
|
||||
---
|
||||
|
||||
### 4. 后端详细优化方案(Go + Gin + Postgres)
|
||||
|
||||
#### 4.1 模型与数据库设计(概念级)
|
||||
|
||||
> 不强制你立刻改现有表名与字段;这部分作为“目标状态”,可通过迁移脚本或视图逐步对齐。
|
||||
|
||||
- **AICharacter**(已有)
|
||||
- 新增/规范字段:
|
||||
- `raw_card_json JSONB`:存原始 ST 角色卡,用于无损导出。
|
||||
- `bound_worldbook_ids UUID[]`:角色绑定世界书 ID 列表(可选)。
|
||||
- `bound_regex_ids UUID[]`:角色绑定正则脚本 ID 列表(可选)。
|
||||
- **Worldbook / WorldbookEntry**
|
||||
- Worldbook:`id, name, owner_char_id (nullable), meta JSONB, created_at, updated_at`
|
||||
- WorldbookEntry:包含 ST 全字段:
|
||||
- `uid, keys, secondary_keys, comment, content, constant, disabled, use_regex, case_sensitive, match_whole_words, selective, selective_logic, position, depth, order, probability, sticky, cooldown, delay, group, extra JSONB`
|
||||
- **RegexScript**
|
||||
- `id, name, find_regex, replace_with, trim_string`
|
||||
- `placement INT[]`
|
||||
- `disabled, markdown_only, run_on_edit, prompt_only`
|
||||
- `substitute_regex, min_depth, max_depth`
|
||||
- `scope, owner_char_id, owner_preset_id, raw_json`
|
||||
- **Preset / PresetPrompt / PresetRegexBinding**
|
||||
- 基本采样参数 + prompt 列表 + regex 绑定。
|
||||
- **Conversation / Message**
|
||||
- Conversation:`id, user_id, character_id, preset_id, ai_config_id, title, settings JSONB, created_at, updated_at`
|
||||
- Message:`id, conversation_id, role, content, token_count, created_at`
|
||||
|
||||
#### 4.2 角色卡导入/导出(巩固现有实现)
|
||||
|
||||
> 从 `development-progress.md` 看,`/app/character` 模块已经“完全兼容 ST V2”,这里更多是规范与扩展。
|
||||
|
||||
- **导入 `/app/character/upload` 已具备**:
|
||||
- PNG:使用 `utils/character_card.go` 提取 `chara` tEXt chunk → JSON。
|
||||
- JSON:直接解析,填充 `AICharacter`。
|
||||
- 优化点:
|
||||
- 当角色卡中包含 `character_book` 时:
|
||||
- 自动在 `worldbooks/worldbook_entries` 中创建对应记录;
|
||||
- 将 worldbook.id 写入 `AICharacter.bound_worldbook_ids`(或冗余到 `characterBook` 字段中)。
|
||||
- 当包含 `extensions.regex_scripts` 时:
|
||||
- 自动创建 `RegexScript`(scope=character, owner_char_id=该角色)。
|
||||
- 将脚本 id 写入 `AICharacter.bound_regex_ids` 或 `extensions` 中。
|
||||
- **导出 `/app/character/:id/export`**:
|
||||
- 若 `raw_card_json` 存在,优先以它为基础;将 DB 中新增的信息 patch 回去(例如补上最新的 worldbook/regex 变更)。
|
||||
- 若不存在,则按 ST V2 规范组装 JSON(兼容 V1/V3 的 data 字段)。
|
||||
|
||||
#### 4.3 世界书(World Info)引擎
|
||||
|
||||
1. **世界书激活来源**:
|
||||
- 全局启用列表(per-user 或全局 settings 中的 `active_worldbook_ids`)。
|
||||
- 角色绑定:`AICharacter.bound_worldbook_ids`。
|
||||
- 会话特定选项:`conversation.settings.activeWorldbookIds`(从前端设置面板传入)。
|
||||
2. **触发与过滤算法**:
|
||||
- 遍历所有激活 worldbook 的 entries:
|
||||
- 忽略 `disabled` 的 entry。
|
||||
- 若 `constant=true`:无视关键词匹配,直接进入候选(仍受 sticky/cooldown/delay/probability 控制)。
|
||||
- keys 匹配:
|
||||
- `use_regex=true`:将每个 key 作为正则(考虑 `caseSensitive`、`matchWholeWords` 标志)。
|
||||
- 否则:普通字符串包含匹配(可选大小写敏感)。
|
||||
- 若 `selective=true`:根据 `selectiveLogic` 结合 secondary_keys:
|
||||
- `0=AND`:secondary_keys 至少一个命中;
|
||||
- `1=OR`:主 keys 或 secondary_keys 任一命中;
|
||||
- `2=NOT`:主 keys 命中,但 secondary_keys 不命中。
|
||||
- depth:仅在最近 `entry.depth` 条消息(或 tokens)中搜索关键字。
|
||||
- sticky/cooldown/delay:基于会话级状态(比如 Redis/内存/DB)存储 entry 上次触发时间或 sticky 状态。
|
||||
- probability:按百分比随机决定最终是否注入。
|
||||
3. **注入到 prompt 中**:
|
||||
- 简化版:将所有命中 entry.content 拼接为 `[World Info]` 段,附加在 system prompt 后;
|
||||
- 高级版:按 ST 行为,依据 position/depth/order,放到不同位置(角色描述前/后、历史中等)。
|
||||
|
||||
#### 4.4 正则脚本(Regex Scripts)引擎
|
||||
|
||||
1. **脚本来源**:
|
||||
- Global:`scope='global'` 且 `disabled=false`。
|
||||
- Character:`scope='character' AND owner_char_id=当前角色`。
|
||||
- Preset:`preset_regex_bindings` 中绑定到当前 preset 的脚本。
|
||||
2. **执行阶段(placement)**:
|
||||
- 输入阶段(placement=1):用户消息入库前;
|
||||
- 输出阶段(placement=2):模型输出生成后、入库/返回前;
|
||||
- 世界书阶段(placement=3):扫描 / 注入 world info 内容前;
|
||||
- 推理阶段(placement=4):如有单独 reasoning prompt,可在构建 reasoning 时处理。
|
||||
3. **实现细节(Go)**:
|
||||
- 解析 JS 风格正则 `/pattern/flags`,支持 `i/m/s`。
|
||||
- 替换宏:
|
||||
- `substitute_regex=1` → 用当前用户名称替换 `{{user}}`;
|
||||
- `=2` → 用当前角色名替换 `{{char}}`;
|
||||
- `trim_string`:按行定义需要从文本中删掉的子串。
|
||||
- minDepth/maxDepth:结合消息历史深度决定是否执行脚本。
|
||||
- runOnEdit:在“消息编辑接口”中也调用该脚本集。
|
||||
- markdownOnly/promptOnly:通过标志决定作用在 UI 文本 or prompt 文本。
|
||||
|
||||
#### 4.5 Prompt Pipeline 与对话 API
|
||||
|
||||
将现有/规划中的对话 API 统一收敛到一个 pipeline,例如 `/app/conversation/:id/message`(或 `/conversations/:id/messages`,按统一风格即可):
|
||||
|
||||
1. **接收请求**:
|
||||
|
||||
```json
|
||||
{
|
||||
"content": "用户输入文本",
|
||||
"options": {
|
||||
"presetId": "uuid-xxx",
|
||||
"overrideModel": "gpt-4",
|
||||
"stream": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. **管线步骤**:
|
||||
1. 根据 conversationId 加载:
|
||||
- Conversation、AICharacter、Preset、AI 配置、世界书/正则等上下文;
|
||||
- 合并 settings(会话内 + 用户全局)。
|
||||
2. 输入文本经过 regex 引擎(placement=1)。
|
||||
3. 写入一条 user message 到 DB。
|
||||
4. 从最近 N 条消息构造 world info 触发文本,调用世界书引擎得到 entries。
|
||||
5. 构建 prompt:
|
||||
- system:角色 system + scenario + authorsNote + preset.systemPrompt + world info;
|
||||
- history:最近若干 user/assistant 消息;
|
||||
- preset.prompts:按 depth/position 注入额外 messages。
|
||||
6. 调用 AI 服务(依据 `/app/ai-config` 中 baseUrl/apiKey/model):
|
||||
- 支持流式:通过 SSE/WebSocket 对前端推送 tokens。
|
||||
7. 将完整 assistant 输出经过 regex 引擎(placement=2)。
|
||||
8. 写入一条 assistant message 到 DB。
|
||||
9. 返回响应给前端(同步返回最终内容 + 可选流式过程)。
|
||||
|
||||
---
|
||||
|
||||
### 5. 前端详细优化方案(React `projects/web-app`)
|
||||
|
||||
#### 5.1 角色卡管理(CharacterManagePage)
|
||||
|
||||
目标:完全对齐 ST 角色卡 V2/V3 字段,并为世界书/正则后端拆分打好基础。
|
||||
|
||||
优化点:
|
||||
|
||||
- **字段映射明确化**:
|
||||
- 确保 `Character` 接口与后端模型一致,并在注释中标明 ST 对应字段:
|
||||
- `firstMes` ↔ `first_mes`
|
||||
- `mesExample` ↔ `mes_example`
|
||||
- `systemPrompt` ↔ `system_prompt`
|
||||
- `postHistoryInstructions` ↔ `post_history_instructions`
|
||||
- `characterBook` ↔ `character_book`
|
||||
- `extensions` ↔ `extensions`
|
||||
- **世界书编辑 UI 扩展**:
|
||||
- 目前 `WorldBookEntry` 较简化(keys/content/enabled/insertion_order/position)。
|
||||
- 建议增加更多高级属性编辑项(可以先折叠到“高级设置”中):
|
||||
- `secondary_keys` / `constant` / `use_regex` / `case_sensitive` / `match_whole_words`;
|
||||
- `selective` / `selective_logic`;
|
||||
- `depth` / `order` / `probability` / `sticky` / `cooldown` / `delay` / `group`。
|
||||
- 保存时将这些字段一并放入 `characterBook.entries`,后端负责拆分入 DB。
|
||||
- **导入/导出保持 ST 兼容**:
|
||||
- 当前上传/导出流程基本正确;随着后端增强,无需大改,只要保证前端不破坏 JSON 结构即可。
|
||||
|
||||
#### 5.2 预设管理(PresetManagePage)
|
||||
|
||||
目标:把当前的“前端内存预设”完全改造成**后端驱动的 ST 预设系统**。
|
||||
|
||||
实施步骤:
|
||||
|
||||
1. 后端按 `development-progress.md` 规划实现 `/app/preset` API:
|
||||
|
||||
```text
|
||||
POST /app/preset
|
||||
GET /app/preset
|
||||
GET /app/preset/:id
|
||||
PUT /app/preset/:id
|
||||
DELETE /app/preset/:id
|
||||
POST /app/preset/import
|
||||
GET /app/preset/:id/export
|
||||
```
|
||||
|
||||
2. 前端新建 `src/api/preset.ts` 对应这些端点。
|
||||
3. 将 `PresetManagePage` 中的 `useState` 初始数据删除,改为:
|
||||
- `const { data } = await presetApi.getPresetList()`;
|
||||
- 导入、导出、编辑、删除全部通过后端。
|
||||
4. 预设 JSON 的读写字段与 ST 对齐:
|
||||
- `temperature/top_p/top_k/frequency_penalty/presence_penalty/system_prompt/stop_sequences/...`。
|
||||
|
||||
#### 5.3 聊天与设置面板(ChatPage / SettingsPanel / CharacterPanel)
|
||||
|
||||
目标:把“选 preset / 选世界书 / 选正则”的入口统一放到设置面板,并通过 `conversation.settings` 与后端 pipeline 串联。
|
||||
|
||||
建议:
|
||||
|
||||
- 在 `SettingsPanel` 中增加:
|
||||
- 当前会话使用的 preset 选择器(下拉 list:来自 `/app/preset`)。
|
||||
- 可选的世界书列表(来自未来的 `/app/worldbook`,初期可只展示角色内 worldbook)。
|
||||
- 可选的全局正则脚本列表(来自未来的 `/app/regex`)。
|
||||
- 保存时调用 `/app/conversation/:id/settings`(或 `PUT /app/conversation/:id`):
|
||||
|
||||
```ts
|
||||
settings: {
|
||||
backgroundImage: string
|
||||
themeColor: string
|
||||
presetId?: string
|
||||
activeWorldbookIds?: string[]
|
||||
activeRegexIds?: string[]
|
||||
}
|
||||
```
|
||||
|
||||
- 发送消息时,前端不再参与世界书/正则逻辑,只负责传 `content`,后端从 conversation/preset/character 中解析所有配置。
|
||||
|
||||
---
|
||||
|
||||
### 6. 分阶段落地路线图(仅 Go + React)
|
||||
|
||||
#### 阶段 1:打通所有核心 CRUD 与数据流(短期)
|
||||
|
||||
- 后端:
|
||||
- 巩固 `/app/character` 模块(确保 ST V2/V3 完全兼容)。
|
||||
- 实现 `/app/preset` 模块(CRUD + 导入/导出)。
|
||||
- 设计并实现 `worldbooks/worldbook_entries` 与 `regex_scripts` 的数据结构与基础 API。
|
||||
- 前端:
|
||||
- 改造 `PresetManagePage` 接入真实 API。
|
||||
- 在 `CharacterManagePage` 中补全世界书 entry 字段,保持 ST 兼容。
|
||||
|
||||
#### 阶段 2:实现 Prompt Pipeline 与对话 API(中期)
|
||||
|
||||
- 后端:
|
||||
- 聚合世界书与正则逻辑,形成独立的 `chatPipeline` service。
|
||||
- 在 `/app/conversation/:id/message`(或 `/conversations/:id/messages`)中调用 pipeline,完成一次“完整的 ST 风格对话请求”。
|
||||
- 引入流式输出(SSE 或 WebSocket)。
|
||||
- 前端:
|
||||
- 在 `ChatPage/SettingsPanel` 中增加 preset/worldbook/regex 的选择与设置保存。
|
||||
- 调整 ChatArea 接收流式输出并实时渲染。
|
||||
|
||||
#### 阶段 3:高级特性与插件系统(长期)
|
||||
|
||||
- 后端:
|
||||
- 引入插件概念(`plugins` 表 + manifest + hooks)。
|
||||
- 实现插件执行沙箱(WASM 或 goja),并在 pipeline 中注入插件 hooks。
|
||||
- 增加统计、日志与审计功能。
|
||||
- 前端:
|
||||
- 增加插件管理页面与可视化配置。
|
||||
- 对接统计与调试视图(例如:查看某次回复中哪些 world info/regex/插件生效)。
|
||||
|
||||
---
|
||||
|
||||
### 7. 总结
|
||||
|
||||
- 你当前的 Go + React 系统已经完成:
|
||||
- **用户系统**(认证/资料);
|
||||
- **角色卡管理**(完整 ST V2 兼容导入/导出);
|
||||
- **预设管理与对话 UI 的前端骨架**。
|
||||
- 接下来最重要的三件事是:
|
||||
- **在后端固化 ST 兼容的领域模型与 Prompt Pipeline**;
|
||||
- **让 `/app/conversation` 成为唯一的“对话真相源”,React 只是 UI 壳**。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -42,10 +42,11 @@ type UpdateRegexScriptRequest struct {
|
||||
|
||||
// GetRegexScriptListRequest 获取正则脚本列表请求
|
||||
type GetRegexScriptListRequest struct {
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
Keyword string `json:"keyword"`
|
||||
Scope *int `json:"scope"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
Keyword string `json:"keyword"`
|
||||
Scope *int `json:"scope"`
|
||||
OwnerCharID *uint `json:"ownerCharId"` // 过滤指定角色的脚本(scope=1时有效)
|
||||
}
|
||||
|
||||
// TestRegexScriptRequest 测试正则脚本请求
|
||||
|
||||
@@ -3,6 +3,7 @@ package app
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -371,21 +372,15 @@ func (s *ConversationService) SendMessage(userID, conversationID uint, req *requ
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 获取对话历史(最近10条)
|
||||
// 获取完整对话历史(context 管理由 callAIService 内部处理)
|
||||
var messages []app.Message
|
||||
err = global.GVA_DB.Where("conversation_id = ?", conversationID).
|
||||
Order("created_at DESC").
|
||||
Limit(10).
|
||||
Order("created_at ASC").
|
||||
Find(&messages).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 反转消息顺序(从旧到新)
|
||||
for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 {
|
||||
messages[i], messages[j] = messages[j], messages[i]
|
||||
}
|
||||
|
||||
// 调用 AI 服务获取回复
|
||||
aiResponse, err := s.callAIService(conversation, character, messages)
|
||||
if err != nil {
|
||||
@@ -527,47 +522,26 @@ func (s *ConversationService) callAIService(conversation app.Conversation, chara
|
||||
}
|
||||
}
|
||||
|
||||
// 构建系统提示词(如果预设有系统提示词,则追加到角色卡提示词后)
|
||||
systemPrompt := s.buildSystemPrompt(character)
|
||||
if preset != nil && preset.SystemPrompt != "" {
|
||||
systemPrompt = systemPrompt + "\n\n" + preset.SystemPrompt
|
||||
global.GVA_LOG.Info("已追加预设的系统提示词")
|
||||
// 构建消息列表(含 context 预算管理)
|
||||
var presetSysPrompt string
|
||||
if preset != nil {
|
||||
presetSysPrompt = preset.SystemPrompt
|
||||
}
|
||||
wbEngine := &WorldbookEngine{}
|
||||
apiMessages := s.buildAPIMessagesWithContextManagement(
|
||||
messages, character, presetSysPrompt, wbEngine, conversation, &aiConfig, preset,
|
||||
)
|
||||
|
||||
// 集成世界书触发引擎
|
||||
if conversation.WorldbookEnabled && conversation.WorldbookID != nil {
|
||||
global.GVA_LOG.Info(fmt.Sprintf("世界书已启用,ID: %d", *conversation.WorldbookID))
|
||||
|
||||
// 提取消息内容用于扫描
|
||||
var messageContents []string
|
||||
for _, msg := range messages {
|
||||
messageContents = append(messageContents, msg.Content)
|
||||
}
|
||||
|
||||
// 使用世界书引擎扫描并触发条目
|
||||
engine := &WorldbookEngine{}
|
||||
triggered, err := engine.ScanAndTrigger(*conversation.WorldbookID, messageContents)
|
||||
if err != nil {
|
||||
global.GVA_LOG.Warn(fmt.Sprintf("世界书触发失败: %v", err))
|
||||
} else if len(triggered) > 0 {
|
||||
global.GVA_LOG.Info(fmt.Sprintf("触发了 %d 个世界书条目", len(triggered)))
|
||||
// 将触发的世界书内容注入到系统提示词
|
||||
systemPrompt = engine.BuildPromptWithWorldbook(systemPrompt, triggered)
|
||||
} else {
|
||||
global.GVA_LOG.Info("没有触发任何世界书条目")
|
||||
}
|
||||
// 从 apiMessages 中提取 systemPrompt,供 Anthropic 独立参数使用
|
||||
systemPrompt := ""
|
||||
if len(apiMessages) > 0 && apiMessages[0]["role"] == "system" {
|
||||
systemPrompt = apiMessages[0]["content"]
|
||||
}
|
||||
|
||||
// 构建消息列表
|
||||
apiMessages := s.buildAPIMessages(messages, systemPrompt)
|
||||
|
||||
// 打印发送给AI的完整内容
|
||||
global.GVA_LOG.Info("========== 发送给AI的完整内容 ==========")
|
||||
global.GVA_LOG.Info(fmt.Sprintf("系统提示词: %s", systemPrompt))
|
||||
global.GVA_LOG.Info("消息列表:")
|
||||
for i, msg := range apiMessages {
|
||||
global.GVA_LOG.Info(fmt.Sprintf(" [%d] Role: %s, Content: %s", i, msg["role"], msg["content"]))
|
||||
}
|
||||
global.GVA_LOG.Info(fmt.Sprintf("系统提示词长度: %d 字符", len(systemPrompt)))
|
||||
global.GVA_LOG.Info(fmt.Sprintf("历史消息条数: %d", len(apiMessages)-1))
|
||||
global.GVA_LOG.Info("==========================================")
|
||||
|
||||
// 确定使用的模型:如果用户在设置中指定了AI配置,则使用该配置的默认模型
|
||||
@@ -735,7 +709,7 @@ func (s *ConversationService) getWeekdayInChinese(weekday time.Weekday) string {
|
||||
}
|
||||
|
||||
// SendMessageStream 流式发送消息并获取 AI 回复
|
||||
func (s *ConversationService) SendMessageStream(userID, conversationID uint, req *request.SendMessageRequest, streamChan chan string, doneChan chan bool) error {
|
||||
func (s *ConversationService) SendMessageStream(ctx context.Context, userID, conversationID uint, req *request.SendMessageRequest, streamChan chan string, doneChan chan bool) error {
|
||||
defer close(streamChan)
|
||||
defer close(doneChan)
|
||||
|
||||
@@ -796,21 +770,15 @@ func (s *ConversationService) SendMessageStream(userID, conversationID uint, req
|
||||
return err
|
||||
}
|
||||
|
||||
// 获取对话历史(最近10条)
|
||||
// 获取完整对话历史(context 管理由 buildAPIMessagesWithContextManagement 处理)
|
||||
var messages []app.Message
|
||||
err = global.GVA_DB.Where("conversation_id = ?", conversationID).
|
||||
Order("created_at DESC").
|
||||
Limit(10).
|
||||
Order("created_at ASC").
|
||||
Find(&messages).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 反转消息顺序(从旧到新)
|
||||
for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 {
|
||||
messages[i], messages[j] = messages[j], messages[i]
|
||||
}
|
||||
|
||||
// 获取 AI 配置
|
||||
var aiConfig app.AIConfig
|
||||
var configID uint
|
||||
@@ -857,42 +825,26 @@ func (s *ConversationService) SendMessageStream(userID, conversationID uint, req
|
||||
}
|
||||
}
|
||||
|
||||
// 构建系统提示词(应用预设)
|
||||
systemPrompt := s.buildSystemPrompt(character)
|
||||
if streamPreset != nil && streamPreset.SystemPrompt != "" {
|
||||
systemPrompt = systemPrompt + "\n\n" + streamPreset.SystemPrompt
|
||||
// 构建消息列表(含 context 预算管理)
|
||||
var streamPresetSysPrompt string
|
||||
if streamPreset != nil {
|
||||
streamPresetSysPrompt = streamPreset.SystemPrompt
|
||||
}
|
||||
streamWbEngine := &WorldbookEngine{}
|
||||
apiMessages := s.buildAPIMessagesWithContextManagement(
|
||||
messages, character, streamPresetSysPrompt, streamWbEngine, conversation, &aiConfig, streamPreset,
|
||||
)
|
||||
|
||||
// 集成世界书触发引擎(流式传输)
|
||||
if conversation.WorldbookEnabled && conversation.WorldbookID != nil {
|
||||
global.GVA_LOG.Info(fmt.Sprintf("[流式传输] 世界书已启用,ID: %d", *conversation.WorldbookID))
|
||||
|
||||
var messageContents []string
|
||||
for _, msg := range messages {
|
||||
messageContents = append(messageContents, msg.Content)
|
||||
}
|
||||
|
||||
engine := &WorldbookEngine{}
|
||||
triggeredEntries, wbErr := engine.ScanAndTrigger(*conversation.WorldbookID, messageContents)
|
||||
if wbErr != nil {
|
||||
global.GVA_LOG.Warn(fmt.Sprintf("[流式传输] 世界书触发失败: %v", wbErr))
|
||||
} else if len(triggeredEntries) > 0 {
|
||||
global.GVA_LOG.Info(fmt.Sprintf("[流式传输] 触发了 %d 个世界书条目", len(triggeredEntries)))
|
||||
systemPrompt = engine.BuildPromptWithWorldbook(systemPrompt, triggeredEntries)
|
||||
} else {
|
||||
global.GVA_LOG.Info("[流式传输] 没有触发任何世界书条目")
|
||||
}
|
||||
// 从 apiMessages 中提取 systemPrompt,供 Anthropic 独立参数使用
|
||||
systemPrompt := ""
|
||||
if len(apiMessages) > 0 && apiMessages[0]["role"] == "system" {
|
||||
systemPrompt = apiMessages[0]["content"]
|
||||
}
|
||||
|
||||
apiMessages := s.buildAPIMessages(messages, systemPrompt)
|
||||
|
||||
// 打印发送给AI的完整内容(流式传输)
|
||||
global.GVA_LOG.Info("========== [流式传输] 发送给AI的完整内容 ==========")
|
||||
global.GVA_LOG.Info(fmt.Sprintf("系统提示词: %s", systemPrompt))
|
||||
global.GVA_LOG.Info("消息列表:")
|
||||
for i, msg := range apiMessages {
|
||||
global.GVA_LOG.Info(fmt.Sprintf(" [%d] Role: %s, Content: %s", i, msg["role"], msg["content"]))
|
||||
}
|
||||
global.GVA_LOG.Info(fmt.Sprintf("[流式传输] 系统提示词长度: %d 字符", len(systemPrompt)))
|
||||
global.GVA_LOG.Info(fmt.Sprintf("[流式传输] 历史消息条数: %d", len(apiMessages)-1))
|
||||
global.GVA_LOG.Info("==========================================")
|
||||
|
||||
// 确定使用的模型
|
||||
@@ -910,15 +862,21 @@ func (s *ConversationService) SendMessageStream(userID, conversationID uint, req
|
||||
var fullContent string
|
||||
switch aiConfig.Provider {
|
||||
case "openai", "custom":
|
||||
fullContent, err = s.callOpenAIAPIStream(&aiConfig, model, apiMessages, streamPreset, streamChan)
|
||||
fullContent, err = s.callOpenAIAPIStream(ctx, &aiConfig, model, apiMessages, streamPreset, streamChan)
|
||||
case "anthropic":
|
||||
fullContent, err = s.callAnthropicAPIStream(&aiConfig, model, apiMessages, systemPrompt, streamPreset, streamChan)
|
||||
fullContent, err = s.callAnthropicAPIStream(ctx, &aiConfig, model, apiMessages, systemPrompt, streamPreset, streamChan)
|
||||
default:
|
||||
return fmt.Errorf("不支持的 AI 提供商: %s", aiConfig.Provider)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
global.GVA_LOG.Error(fmt.Sprintf("========== [流式传输] AI返回错误 ==========\n%v\n==========================================", err))
|
||||
// AI 调用失败,回滚已写入的用户消息,避免孤立记录残留在数据库
|
||||
if delErr := global.GVA_DB.Delete(&userMessage).Error; delErr != nil {
|
||||
global.GVA_LOG.Error(fmt.Sprintf("[流式传输] 回滚用户消息失败: %v", delErr))
|
||||
} else {
|
||||
global.GVA_LOG.Info("[流式传输] 已回滚用户消息")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -962,8 +920,9 @@ func (s *ConversationService) SendMessageStream(userID, conversationID uint, req
|
||||
}
|
||||
|
||||
// callOpenAIAPIStream 调用 OpenAI API 流式传输
|
||||
func (s *ConversationService) callOpenAIAPIStream(config *app.AIConfig, model string, messages []map[string]string, preset *app.AIPreset, streamChan chan string) (string, error) {
|
||||
client := &http.Client{Timeout: 120 * time.Second}
|
||||
func (s *ConversationService) callOpenAIAPIStream(ctx context.Context, config *app.AIConfig, model string, messages []map[string]string, preset *app.AIPreset, streamChan chan string) (string, error) {
|
||||
// 不设 Timeout:生命周期由调用方传入的 ctx 控制(客户端断连时自动取消)
|
||||
client := &http.Client{}
|
||||
|
||||
if model == "" {
|
||||
model = config.DefaultModel
|
||||
@@ -1025,7 +984,7 @@ func (s *ConversationService) callOpenAIAPIStream(config *app.AIConfig, model st
|
||||
}
|
||||
|
||||
endpoint := config.BaseURL + "/chat/completions"
|
||||
req, err := http.NewRequest("POST", endpoint, bytes.NewBuffer(bodyBytes))
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewBuffer(bodyBytes))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("创建请求失败: %v", err)
|
||||
}
|
||||
@@ -1035,6 +994,10 @@ func (s *ConversationService) callOpenAIAPIStream(config *app.AIConfig, model st
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
// 客户端主动断开时 ctx 被取消,不算真正的错误
|
||||
if ctx.Err() != nil {
|
||||
return "", nil
|
||||
}
|
||||
return "", fmt.Errorf("请求失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
@@ -1050,49 +1013,51 @@ func (s *ConversationService) callOpenAIAPIStream(config *app.AIConfig, model st
|
||||
|
||||
for {
|
||||
line, err := reader.ReadString('\n')
|
||||
// 先处理本次读到的数据(EOF 时可能仍携带最后一行内容)
|
||||
if line != "" {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed != "" && trimmed != "data: [DONE]" && strings.HasPrefix(trimmed, "data: ") {
|
||||
data := strings.TrimPrefix(trimmed, "data: ")
|
||||
|
||||
var streamResp struct {
|
||||
Choices []struct {
|
||||
Delta struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"delta"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
|
||||
if jsonErr := json.Unmarshal([]byte(data), &streamResp); jsonErr == nil {
|
||||
if len(streamResp.Choices) > 0 {
|
||||
content := streamResp.Choices[0].Delta.Content
|
||||
if content != "" {
|
||||
fullContent.WriteString(content)
|
||||
streamChan <- content
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 再检查读取错误
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
// ctx 被取消(客户端断开)时不算真正的流读取错误
|
||||
if ctx.Err() != nil {
|
||||
return fullContent.String(), nil
|
||||
}
|
||||
return "", fmt.Errorf("读取流失败: %v", err)
|
||||
}
|
||||
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || line == "data: [DONE]" {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(line, "data: ") {
|
||||
data := strings.TrimPrefix(line, "data: ")
|
||||
|
||||
var streamResp struct {
|
||||
Choices []struct {
|
||||
Delta struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"delta"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(data), &streamResp); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if len(streamResp.Choices) > 0 {
|
||||
content := streamResp.Choices[0].Delta.Content
|
||||
if content != "" {
|
||||
fullContent.WriteString(content)
|
||||
streamChan <- content
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fullContent.String(), nil
|
||||
}
|
||||
|
||||
// callAnthropicAPIStream 调用 Anthropic API 流式传输
|
||||
func (s *ConversationService) callAnthropicAPIStream(config *app.AIConfig, model string, messages []map[string]string, systemPrompt string, preset *app.AIPreset, streamChan chan string) (string, error) {
|
||||
client := &http.Client{Timeout: 120 * time.Second}
|
||||
func (s *ConversationService) callAnthropicAPIStream(ctx context.Context, config *app.AIConfig, model string, messages []map[string]string, systemPrompt string, preset *app.AIPreset, streamChan chan string) (string, error) {
|
||||
// 不设 Timeout:生命周期由调用方传入的 ctx 控制(客户端断连时自动取消)
|
||||
client := &http.Client{}
|
||||
|
||||
if model == "" {
|
||||
model = config.DefaultModel
|
||||
@@ -1152,7 +1117,7 @@ func (s *ConversationService) callAnthropicAPIStream(config *app.AIConfig, model
|
||||
}
|
||||
|
||||
endpoint := config.BaseURL + "/messages"
|
||||
req, err := http.NewRequest("POST", endpoint, bytes.NewBuffer(bodyBytes))
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewBuffer(bodyBytes))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("创建请求失败: %v", err)
|
||||
}
|
||||
@@ -1163,6 +1128,10 @@ func (s *ConversationService) callAnthropicAPIStream(config *app.AIConfig, model
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
// 客户端主动断开时 ctx 被取消,不算真正的错误
|
||||
if ctx.Err() != nil {
|
||||
return "", nil
|
||||
}
|
||||
return "", fmt.Errorf("请求失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
@@ -1178,38 +1147,39 @@ func (s *ConversationService) callAnthropicAPIStream(config *app.AIConfig, model
|
||||
|
||||
for {
|
||||
line, err := reader.ReadString('\n')
|
||||
// 先处理本次读到的数据(EOF 时可能仍携带最后一行内容)
|
||||
if line != "" {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed != "" && strings.HasPrefix(trimmed, "data: ") {
|
||||
data := strings.TrimPrefix(trimmed, "data: ")
|
||||
|
||||
var streamResp struct {
|
||||
Type string `json:"type"`
|
||||
Delta struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
} `json:"delta"`
|
||||
}
|
||||
|
||||
if jsonErr := json.Unmarshal([]byte(data), &streamResp); jsonErr == nil {
|
||||
if streamResp.Type == "content_block_delta" && streamResp.Delta.Text != "" {
|
||||
fullContent.WriteString(streamResp.Delta.Text)
|
||||
streamChan <- streamResp.Delta.Text
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 再检查读取错误
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
// ctx 被取消(客户端断开)时不算真正的流读取错误
|
||||
if ctx.Err() != nil {
|
||||
return fullContent.String(), nil
|
||||
}
|
||||
return "", fmt.Errorf("读取流失败: %v", err)
|
||||
}
|
||||
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(line, "data: ") {
|
||||
data := strings.TrimPrefix(line, "data: ")
|
||||
|
||||
var streamResp struct {
|
||||
Type string `json:"type"`
|
||||
Delta struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
} `json:"delta"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(data), &streamResp); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if streamResp.Type == "content_block_delta" && streamResp.Delta.Text != "" {
|
||||
fullContent.WriteString(streamResp.Delta.Text)
|
||||
streamChan <- streamResp.Delta.Text
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fullContent.String(), nil
|
||||
@@ -1243,19 +1213,16 @@ func (s *ConversationService) RegenerateMessage(userID, conversationID uint) (*r
|
||||
})
|
||||
}
|
||||
|
||||
// 获取删除后的消息历史
|
||||
// 获取删除后的完整消息历史(context 管理由 callAIService 内部处理)
|
||||
var messages []app.Message
|
||||
err = global.GVA_DB.Where("conversation_id = ?", conversationID).
|
||||
Order("created_at DESC").Limit(10).Find(&messages).Error
|
||||
Order("created_at ASC").Find(&messages).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(messages) == 0 {
|
||||
return nil, errors.New("没有可用的消息历史")
|
||||
}
|
||||
for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 {
|
||||
messages[i], messages[j] = messages[j], messages[i]
|
||||
}
|
||||
|
||||
aiResponse, err := s.callAIService(conversation, character, messages)
|
||||
if err != nil {
|
||||
@@ -1282,7 +1249,7 @@ func (s *ConversationService) RegenerateMessage(userID, conversationID uint) (*r
|
||||
}
|
||||
|
||||
// RegenerateMessageStream 流式重新生成最后一条 AI 回复
|
||||
func (s *ConversationService) RegenerateMessageStream(userID, conversationID uint, streamChan chan string, doneChan chan bool) error {
|
||||
func (s *ConversationService) RegenerateMessageStream(ctx context.Context, userID, conversationID uint, streamChan chan string, doneChan chan bool) error {
|
||||
defer close(streamChan)
|
||||
defer close(doneChan)
|
||||
|
||||
@@ -1312,19 +1279,16 @@ func (s *ConversationService) RegenerateMessageStream(userID, conversationID uin
|
||||
})
|
||||
}
|
||||
|
||||
// 获取删除后的消息历史
|
||||
// 获取删除后的完整消息历史(context 管理由 buildAPIMessagesWithContextManagement 处理)
|
||||
var messages []app.Message
|
||||
err = global.GVA_DB.Where("conversation_id = ?", conversationID).
|
||||
Order("created_at DESC").Limit(10).Find(&messages).Error
|
||||
Order("created_at ASC").Find(&messages).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(messages) == 0 {
|
||||
return errors.New("没有可用的消息历史")
|
||||
}
|
||||
for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 {
|
||||
messages[i], messages[j] = messages[j], messages[i]
|
||||
}
|
||||
|
||||
// 获取 AI 配置
|
||||
var aiConfig app.AIConfig
|
||||
@@ -1367,11 +1331,21 @@ func (s *ConversationService) RegenerateMessageStream(userID, conversationID uin
|
||||
}
|
||||
}
|
||||
|
||||
systemPrompt := s.buildSystemPrompt(character)
|
||||
if preset != nil && preset.SystemPrompt != "" {
|
||||
systemPrompt = systemPrompt + "\n\n" + preset.SystemPrompt
|
||||
// 构建消息列表(含 context 预算管理)
|
||||
var regenPresetSysPrompt string
|
||||
if preset != nil {
|
||||
regenPresetSysPrompt = preset.SystemPrompt
|
||||
}
|
||||
regenWbEngine := &WorldbookEngine{}
|
||||
apiMessages := s.buildAPIMessagesWithContextManagement(
|
||||
messages, character, regenPresetSysPrompt, regenWbEngine, conversation, &aiConfig, preset,
|
||||
)
|
||||
|
||||
// 从 apiMessages 中提取 systemPrompt,供 Anthropic 独立参数使用
|
||||
systemPrompt := ""
|
||||
if len(apiMessages) > 0 && apiMessages[0]["role"] == "system" {
|
||||
systemPrompt = apiMessages[0]["content"]
|
||||
}
|
||||
apiMessages := s.buildAPIMessages(messages, systemPrompt)
|
||||
|
||||
model := aiConfig.DefaultModel
|
||||
if model == "" {
|
||||
@@ -1384,14 +1358,27 @@ func (s *ConversationService) RegenerateMessageStream(userID, conversationID uin
|
||||
var fullContent string
|
||||
switch aiConfig.Provider {
|
||||
case "openai", "custom":
|
||||
fullContent, err = s.callOpenAIAPIStream(&aiConfig, model, apiMessages, preset, streamChan)
|
||||
fullContent, err = s.callOpenAIAPIStream(ctx, &aiConfig, model, apiMessages, preset, streamChan)
|
||||
case "anthropic":
|
||||
fullContent, err = s.callAnthropicAPIStream(&aiConfig, model, apiMessages, systemPrompt, preset, streamChan)
|
||||
fullContent, err = s.callAnthropicAPIStream(ctx, &aiConfig, model, apiMessages, systemPrompt, preset, streamChan)
|
||||
default:
|
||||
return fmt.Errorf("不支持的 AI 提供商: %s", aiConfig.Provider)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
// AI 调用失败,恢复刚才删除的 assistant 消息,避免数据永久丢失
|
||||
if lastAssistantMsg.ID > 0 {
|
||||
if restoreErr := global.GVA_DB.Unscoped().Model(&lastAssistantMsg).Update("deleted_at", nil).Error; restoreErr != nil {
|
||||
global.GVA_LOG.Error(fmt.Sprintf("[重新生成] 恢复 assistant 消息失败: %v", restoreErr))
|
||||
} else {
|
||||
// 回滚 conversation 统计
|
||||
global.GVA_DB.Model(&conversation).Updates(map[string]interface{}{
|
||||
"message_count": gorm.Expr("message_count + 1"),
|
||||
"token_count": gorm.Expr("token_count + ?", lastAssistantMsg.TokenCount),
|
||||
})
|
||||
global.GVA_LOG.Info("[重新生成] 已恢复 assistant 消息")
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1437,9 +1424,333 @@ func (s *ConversationService) buildAPIMessages(messages []app.Message, systemPro
|
||||
return apiMessages
|
||||
}
|
||||
|
||||
// estimateTokens 粗略估算文本的 token 数(字符数 / 3,适用于中英混合文本)
|
||||
func estimateTokens(text string) int {
|
||||
if text == "" {
|
||||
return 0
|
||||
}
|
||||
// 中文字符约 1 char = 1 token,英文约 4 chars = 1 token
|
||||
// 取中间值 1 char ≈ 0.75 token,即 chars * 4 / 3 的倒数 ≈ chars / 1.5
|
||||
// 保守估算用 chars / 2 防止超出
|
||||
n := len([]rune(text))
|
||||
return (n + 1) / 2
|
||||
}
|
||||
|
||||
// contextConfig 保存从 AIConfig.Settings 中解析出的上下文配置
|
||||
type contextConfig struct {
|
||||
contextLength int // 模型上下文窗口大小(token 数)
|
||||
maxTokens int // 最大输出 token 数
|
||||
}
|
||||
|
||||
// getContextConfig 从 AIConfig 中读取上下文配置,如果没有配置则使用默认值
|
||||
func getContextConfig(aiConfig *app.AIConfig, preset *app.AIPreset) contextConfig {
|
||||
cfg := contextConfig{
|
||||
contextLength: 200000, // 保守默认值
|
||||
maxTokens: 2000,
|
||||
}
|
||||
|
||||
// 从 preset 读取 max_tokens
|
||||
if preset != nil && preset.MaxTokens > 0 {
|
||||
cfg.maxTokens = preset.MaxTokens
|
||||
}
|
||||
|
||||
// 从 AIConfig.Settings 读取 context_length
|
||||
if len(aiConfig.Settings) > 0 {
|
||||
var settings map[string]interface{}
|
||||
if err := json.Unmarshal(aiConfig.Settings, &settings); err == nil {
|
||||
if cl, ok := settings["context_length"].(float64); ok && cl > 0 {
|
||||
cfg.contextLength = int(cl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
// buildContextManagedSystemPrompt 按优先级构建 system prompt,超出 budget 时截断低优先级内容
|
||||
// 优先级(从高到低):
|
||||
// 1. 核心人设(Name/Description/Personality/Scenario/SystemPrompt)
|
||||
// 2. Preset.SystemPrompt
|
||||
// 3. Worldbook 触发条目
|
||||
// 4. CharacterBook 内嵌条目
|
||||
// 5. MesExample(对话示例,最容易被截断)
|
||||
//
|
||||
// 返回构建好的 systemPrompt 以及消耗的 token 数
|
||||
func (s *ConversationService) buildContextManagedSystemPrompt(
|
||||
character app.AICharacter,
|
||||
presetSystemPrompt string,
|
||||
worldbookEngine *WorldbookEngine,
|
||||
conversation app.Conversation,
|
||||
messageContents []string,
|
||||
budget int,
|
||||
) (string, int) {
|
||||
used := 0
|
||||
|
||||
// ── 优先级1:核心人设 ─────────────────────────────────────────────
|
||||
core := fmt.Sprintf("你是 %s。", character.Name)
|
||||
if character.Description != "" {
|
||||
core += fmt.Sprintf("\n\n描述:%s", character.Description)
|
||||
}
|
||||
if character.Personality != "" {
|
||||
core += fmt.Sprintf("\n\n性格:%s", character.Personality)
|
||||
}
|
||||
if character.Scenario != "" {
|
||||
core += fmt.Sprintf("\n\n场景:%s", character.Scenario)
|
||||
}
|
||||
if character.SystemPrompt != "" {
|
||||
core += fmt.Sprintf("\n\n系统提示:%s", character.SystemPrompt)
|
||||
}
|
||||
core += "\n\n请根据以上设定进行角色扮演,保持角色的性格和说话方式。"
|
||||
core = s.applyMacroVariables(core, character)
|
||||
|
||||
coreTokens := estimateTokens(core)
|
||||
if coreTokens >= budget {
|
||||
// 极端情况:核心人设本身就超出 budget,截断到 budget
|
||||
runes := []rune(core)
|
||||
limit := budget * 2
|
||||
if limit > len(runes) {
|
||||
limit = len(runes)
|
||||
}
|
||||
core = string(runes[:limit])
|
||||
global.GVA_LOG.Warn(fmt.Sprintf("[context] 核心人设超出 budget,已截断至 %d chars", limit))
|
||||
return core, budget
|
||||
}
|
||||
used += coreTokens
|
||||
prompt := core
|
||||
|
||||
// ── 优先级2:Preset.SystemPrompt ────────────────────────────────
|
||||
if presetSystemPrompt != "" {
|
||||
tokens := estimateTokens(presetSystemPrompt)
|
||||
if used+tokens <= budget {
|
||||
prompt += "\n\n" + presetSystemPrompt
|
||||
used += tokens
|
||||
} else {
|
||||
// 尝试部分插入
|
||||
remaining := budget - used
|
||||
if remaining > 50 {
|
||||
runes := []rune(presetSystemPrompt)
|
||||
limit := remaining * 2
|
||||
if limit > len(runes) {
|
||||
limit = len(runes)
|
||||
}
|
||||
prompt += "\n\n" + string(runes[:limit])
|
||||
used = budget
|
||||
}
|
||||
global.GVA_LOG.Warn(fmt.Sprintf("[context] Preset.SystemPrompt 因 budget 不足被截断(需要 %d tokens,剩余 %d)", tokens, budget-used))
|
||||
}
|
||||
}
|
||||
|
||||
if used >= budget {
|
||||
return prompt, used
|
||||
}
|
||||
|
||||
// ── 优先级3:世界书触发条目 ──────────────────────────────────────
|
||||
if conversation.WorldbookEnabled && conversation.WorldbookID != nil && worldbookEngine != nil {
|
||||
triggeredEntries, wbErr := worldbookEngine.ScanAndTrigger(*conversation.WorldbookID, messageContents)
|
||||
if wbErr != nil {
|
||||
global.GVA_LOG.Warn(fmt.Sprintf("[context] 世界书触发失败: %v", wbErr))
|
||||
} else if len(triggeredEntries) > 0 {
|
||||
wbHeader := "\n\n[World Information]"
|
||||
wbSection := wbHeader
|
||||
for _, te := range triggeredEntries {
|
||||
if te.Entry == nil || te.Entry.Content == "" {
|
||||
continue
|
||||
}
|
||||
line := fmt.Sprintf("\n- %s", te.Entry.Content)
|
||||
lineTokens := estimateTokens(line)
|
||||
if used+estimateTokens(wbSection)+lineTokens <= budget {
|
||||
wbSection += line
|
||||
used += lineTokens
|
||||
} else {
|
||||
global.GVA_LOG.Warn(fmt.Sprintf("[context] 世界书条目 (id=%d) 因 budget 不足被跳过", te.Entry.ID))
|
||||
break
|
||||
}
|
||||
}
|
||||
if wbSection != wbHeader {
|
||||
prompt += wbSection
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if used >= budget {
|
||||
return prompt, used
|
||||
}
|
||||
|
||||
// ── 优先级4:CharacterBook 内嵌条目 ──────────────────────────────
|
||||
if len(character.CharacterBook) > 0 {
|
||||
var characterBook map[string]interface{}
|
||||
if err := json.Unmarshal(character.CharacterBook, &characterBook); err == nil {
|
||||
if entries, ok := characterBook["entries"].([]interface{}); ok && len(entries) > 0 {
|
||||
cbSection := "\n\n世界设定:"
|
||||
addedAny := false
|
||||
for _, entry := range entries {
|
||||
entryMap, ok := entry.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
enabled := true
|
||||
if enabledVal, ok := entryMap["enabled"].(bool); ok {
|
||||
enabled = enabledVal
|
||||
}
|
||||
if !enabled {
|
||||
continue
|
||||
}
|
||||
content, ok := entryMap["content"].(string)
|
||||
if !ok || content == "" {
|
||||
continue
|
||||
}
|
||||
line := fmt.Sprintf("\n- %s", content)
|
||||
lineTokens := estimateTokens(line)
|
||||
if used+estimateTokens(cbSection)+lineTokens <= budget {
|
||||
cbSection += line
|
||||
used += lineTokens
|
||||
addedAny = true
|
||||
} else {
|
||||
global.GVA_LOG.Warn("[context] CharacterBook 条目因 budget 不足被跳过")
|
||||
break
|
||||
}
|
||||
}
|
||||
if addedAny {
|
||||
prompt += cbSection
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if used >= budget {
|
||||
return prompt, used
|
||||
}
|
||||
|
||||
// ── 优先级5:MesExample(对话示例,最低优先级)──────────────────
|
||||
if character.MesExample != "" {
|
||||
mesTokens := estimateTokens(character.MesExample)
|
||||
prefix := "\n\n对话示例:\n"
|
||||
prefixTokens := estimateTokens(prefix)
|
||||
if used+prefixTokens+mesTokens <= budget {
|
||||
prompt += prefix + character.MesExample
|
||||
used += prefixTokens + mesTokens
|
||||
} else {
|
||||
// 尝试截断 MesExample
|
||||
remaining := budget - used - prefixTokens
|
||||
if remaining > 100 {
|
||||
runes := []rune(character.MesExample)
|
||||
limit := remaining * 2
|
||||
if limit > len(runes) {
|
||||
limit = len(runes)
|
||||
}
|
||||
prompt += prefix + string(runes[:limit])
|
||||
used = budget
|
||||
global.GVA_LOG.Warn(fmt.Sprintf("[context] MesExample 被截断(原始 %d tokens,保留约 %d tokens)", mesTokens, remaining))
|
||||
} else {
|
||||
global.GVA_LOG.Warn("[context] MesExample 因 budget 不足被完全跳过")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return prompt, used
|
||||
}
|
||||
|
||||
// trimMessagesToBudget 从历史消息中按 token 预算选取最近的消息
|
||||
// 优先保留最新的消息,从后往前丢弃旧消息直到 token 数在 budget 内
|
||||
func trimMessagesToBudget(messages []app.Message, budget int) []app.Message {
|
||||
if budget <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// messages 已经是从旧到新的顺序
|
||||
// 从最新消息开始往前累加,直到超出 budget
|
||||
selected := make([]app.Message, 0, len(messages))
|
||||
used := 0
|
||||
|
||||
for i := len(messages) - 1; i >= 0; i-- {
|
||||
msg := messages[i]
|
||||
if msg.Role == "system" {
|
||||
continue
|
||||
}
|
||||
t := estimateTokens(msg.Content)
|
||||
if used+t > budget {
|
||||
global.GVA_LOG.Warn(fmt.Sprintf("[context] 历史消息已截断,保留最近 %d 条(共 %d 条),使用 %d tokens", len(selected), len(messages), used))
|
||||
break
|
||||
}
|
||||
used += t
|
||||
selected = append([]app.Message{msg}, selected...) // 保持时序
|
||||
}
|
||||
|
||||
return selected
|
||||
}
|
||||
|
||||
// buildAPIMessagesWithContextManagement 整合 context 管理,构建最终的 messages 列表
|
||||
// 返回 apiMessages 及各部分 token 统计日志
|
||||
func (s *ConversationService) buildAPIMessagesWithContextManagement(
|
||||
allMessages []app.Message,
|
||||
character app.AICharacter,
|
||||
presetSystemPrompt string,
|
||||
worldbookEngine *WorldbookEngine,
|
||||
conversation app.Conversation,
|
||||
aiConfig *app.AIConfig,
|
||||
preset *app.AIPreset,
|
||||
) []map[string]string {
|
||||
cfg := getContextConfig(aiConfig, preset)
|
||||
|
||||
// 安全边际:为输出保留 max_tokens,另加 200 token 缓冲
|
||||
inputBudget := cfg.contextLength - cfg.maxTokens - 200
|
||||
if inputBudget <= 0 {
|
||||
inputBudget = cfg.contextLength / 2
|
||||
}
|
||||
|
||||
// 为历史消息分配预算:system prompt 最多占用 60% 的 input budget
|
||||
systemBudget := inputBudget * 60 / 100
|
||||
historyBudget := inputBudget - systemBudget
|
||||
|
||||
// 提取消息内容用于世界书扫描
|
||||
var messageContents []string
|
||||
for _, msg := range allMessages {
|
||||
messageContents = append(messageContents, msg.Content)
|
||||
}
|
||||
|
||||
// 构建 system prompt(含 worldbook 注入,按优先级截断)
|
||||
systemPrompt, systemTokens := s.buildContextManagedSystemPrompt(
|
||||
character,
|
||||
presetSystemPrompt,
|
||||
worldbookEngine,
|
||||
conversation,
|
||||
messageContents,
|
||||
systemBudget,
|
||||
)
|
||||
|
||||
// 如果 system prompt 实际用量比预算少,把节省的预算让给历史消息
|
||||
if systemTokens < systemBudget {
|
||||
historyBudget += systemBudget - systemTokens
|
||||
}
|
||||
|
||||
global.GVA_LOG.Info(fmt.Sprintf("[context] 配置:context_length=%d, max_tokens=%d, input_budget=%d, system=%d tokens, history_budget=%d",
|
||||
cfg.contextLength, cfg.maxTokens, inputBudget, systemTokens, historyBudget))
|
||||
|
||||
// 按 token 预算裁剪历史消息
|
||||
trimmedMessages := trimMessagesToBudget(allMessages, historyBudget)
|
||||
|
||||
// 构建最终 messages
|
||||
apiMessages := make([]map[string]string, 0, len(trimmedMessages)+1)
|
||||
apiMessages = append(apiMessages, map[string]string{
|
||||
"role": "system",
|
||||
"content": systemPrompt,
|
||||
})
|
||||
for _, msg := range trimmedMessages {
|
||||
if msg.Role == "system" {
|
||||
continue
|
||||
}
|
||||
apiMessages = append(apiMessages, map[string]string{
|
||||
"role": msg.Role,
|
||||
"content": msg.Content,
|
||||
})
|
||||
}
|
||||
|
||||
return apiMessages
|
||||
}
|
||||
|
||||
// callOpenAIAPI 调用 OpenAI API
|
||||
func (s *ConversationService) callOpenAIAPI(config *app.AIConfig, model string, messages []map[string]string, preset *app.AIPreset) (string, error) {
|
||||
client := &http.Client{Timeout: 120 * time.Second}
|
||||
client := &http.Client{Timeout: 10 * time.Minute}
|
||||
|
||||
// 使用配置的模型或默认模型
|
||||
if model == "" {
|
||||
@@ -1559,7 +1870,7 @@ func (s *ConversationService) callOpenAIAPI(config *app.AIConfig, model string,
|
||||
|
||||
// callAnthropicAPI 调用 Anthropic API
|
||||
func (s *ConversationService) callAnthropicAPI(config *app.AIConfig, model string, messages []map[string]string, systemPrompt string, preset *app.AIPreset) (string, error) {
|
||||
client := &http.Client{Timeout: 120 * time.Second}
|
||||
client := &http.Client{Timeout: 10 * time.Minute}
|
||||
|
||||
// 使用配置的模型或默认模型
|
||||
if model == "" {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
27
web-app/package-lock.json
generated
27
web-app/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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_ui(ST 社区卡常用)
|
||||
const charaCardUi = extensions['chara_card_ui']
|
||||
if (typeof charaCardUi === 'string' && charaCardUi.trim()) {
|
||||
return { html: charaCardUi, enabled: true, position: 'top' }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// ============= 角色卡类型定义 =============
|
||||
|
||||
export interface Character {
|
||||
id: number
|
||||
name: string
|
||||
|
||||
@@ -70,6 +70,7 @@ export interface GetRegexScriptListRequest {
|
||||
pageSize?: number
|
||||
keyword?: string
|
||||
scope?: number
|
||||
ownerCharId?: number
|
||||
}
|
||||
|
||||
export interface RegexScriptListResponse {
|
||||
|
||||
@@ -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)
|
||||
handleSendMessage(action)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('sendMessageFromStatusBar', handleStatusBarAction as EventListener)
|
||||
return () => window.removeEventListener('sendMessageFromStatusBar', handleStatusBarAction as EventListener)
|
||||
}, [sending])
|
||||
}, [handleSendMessage])
|
||||
|
||||
const loadMessages = async () => {
|
||||
/**
|
||||
* 处理来自 iframe 状态栏的操作:
|
||||
* - fillInput / playerAction → 填充输入框(不发送)
|
||||
* - triggerAction → 解析 /send <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
|
||||
const loadRegexScripts = async () => {
|
||||
try {
|
||||
// 并行加载:全局脚本(scope=0)+ 当前角色专属脚本(scope=1, ownerCharId=character.id)
|
||||
const globalScope = 0
|
||||
const charScope = 1
|
||||
const [globalResp, charResp] = await Promise.all([
|
||||
regexAPI.getList({ page: 1, pageSize: 200, scope: globalScope }),
|
||||
regexAPI.getList({ page: 1, pageSize: 200, scope: charScope, ownerCharId: character.id }),
|
||||
])
|
||||
const globalScripts: RegexScript[] = (globalResp.data.list || []).filter((s: RegexScript) => !s.disabled)
|
||||
const charScripts: RegexScript[] = (charResp.data.list || []).filter((s: RegexScript) => !s.disabled)
|
||||
// 合并并按 order 去重(以 id 为主键)
|
||||
const merged = [...globalScripts, ...charScripts]
|
||||
const seen = new Set<number>()
|
||||
setRegexScripts(merged.filter(s => {
|
||||
if (seen.has(s.id)) return false
|
||||
seen.add(s.id)
|
||||
return true
|
||||
}))
|
||||
} catch (err) {
|
||||
console.error('加载正则脚本失败:', err)
|
||||
}
|
||||
if (conversation.settings) {
|
||||
try {
|
||||
const settings = typeof conversation.settings === 'string'
|
||||
? JSON.parse(conversation.settings)
|
||||
: conversation.settings
|
||||
if (settings.presetId) {
|
||||
setSelectedPresetId(settings.presetId)
|
||||
return
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('解析设置失败:', e)
|
||||
}
|
||||
}
|
||||
setSelectedPresetId(undefined)
|
||||
}
|
||||
|
||||
// ---- 设置更新 ----
|
||||
const handlePresetChange = async (presetId: number | null) => {
|
||||
try {
|
||||
const settings = conversation.settings
|
||||
? (typeof conversation.settings === 'string' ? JSON.parse(conversation.settings) : { ...conversation.settings })
|
||||
: {}
|
||||
if (presetId === null) {
|
||||
delete settings.presetId
|
||||
} else {
|
||||
settings.presetId = presetId
|
||||
}
|
||||
const settings = parseSettings(conversation.settings)
|
||||
if (presetId === null) delete settings.presetId
|
||||
else settings.presetId = presetId
|
||||
await conversationApi.updateConversationSettings(conversation.id, { settings })
|
||||
setSelectedPresetId(presetId ?? undefined)
|
||||
setShowPresetSelector(false)
|
||||
@@ -180,14 +306,9 @@ export default function ChatArea({ conversation, character, onConversationUpdate
|
||||
|
||||
const handleModelChange = async (configId: number | null) => {
|
||||
try {
|
||||
const settings = conversation.settings
|
||||
? (typeof conversation.settings === 'string' ? JSON.parse(conversation.settings) : { ...conversation.settings })
|
||||
: {}
|
||||
if (configId === null) {
|
||||
delete settings.aiConfigId
|
||||
} else {
|
||||
settings.aiConfigId = configId
|
||||
}
|
||||
const settings = parseSettings(conversation.settings)
|
||||
if (configId === null) delete settings.aiConfigId
|
||||
else settings.aiConfigId = configId
|
||||
await conversationApi.updateConversationSettings(conversation.id, { settings })
|
||||
setSelectedConfigId(configId ?? undefined)
|
||||
setShowModelSelector(false)
|
||||
@@ -199,101 +320,11 @@ export default function ChatArea({ conversation, character, onConversationUpdate
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 消息操作 ----
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
|
||||
const handleSendMessage = async (message: string) => {
|
||||
if (!message.trim() || sending) return
|
||||
|
||||
const userMessage = message.trim()
|
||||
setSending(true)
|
||||
|
||||
const tempUserMessage: Message = {
|
||||
id: Date.now(),
|
||||
conversationId: conversation.id,
|
||||
role: 'user',
|
||||
content: userMessage,
|
||||
tokenCount: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
setMessages(prev => [...prev, tempUserMessage])
|
||||
|
||||
const tempAIMessage: Message = {
|
||||
id: Date.now() + 1,
|
||||
conversationId: conversation.id,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
tokenCount: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
try {
|
||||
if (streamEnabled) {
|
||||
setMessages(prev => [...prev, tempAIMessage])
|
||||
const response = await fetch(
|
||||
`${import.meta.env.VITE_API_BASE_URL || '/api'}/app/conversation/${conversation.id}/message?stream=true`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||
},
|
||||
body: JSON.stringify({ content: userMessage }),
|
||||
}
|
||||
)
|
||||
if (!response.ok) throw new Error('流式传输失败')
|
||||
|
||||
const reader = response.body?.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
if (reader) {
|
||||
let fullContent = ''
|
||||
let buffer = ''
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() || ''
|
||||
let currentEvent = ''
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event: ')) {
|
||||
currentEvent = line.slice(7).trim()
|
||||
} else if (line.startsWith('data: ')) {
|
||||
const data = line.slice(6).trim()
|
||||
if (currentEvent === 'message') {
|
||||
fullContent += data
|
||||
setMessages(prev =>
|
||||
prev.map(m => m.id === tempAIMessage.id ? { ...m, content: fullContent } : m)
|
||||
)
|
||||
} else if (currentEvent === 'done') {
|
||||
await loadMessages()
|
||||
break
|
||||
} else if (currentEvent === 'error') {
|
||||
throw new Error(data)
|
||||
}
|
||||
currentEvent = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const convResp = await conversationApi.getConversationById(conversation.id)
|
||||
onConversationUpdate(convResp.data)
|
||||
} else {
|
||||
await conversationApi.sendMessage(conversation.id, { content: userMessage })
|
||||
await loadMessages()
|
||||
const convResp = await conversationApi.getConversationById(conversation.id)
|
||||
onConversationUpdate(convResp.data)
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('发送消息失败:', err)
|
||||
alert(err.response?.data?.msg || '发送消息失败,请重试')
|
||||
setMessages(prev => prev.filter(m => m.id !== tempUserMessage.id && m.id !== tempAIMessage.id))
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!inputValue.trim() || sending) return
|
||||
const userMessage = inputValue.trim()
|
||||
@@ -320,12 +351,17 @@ export default function ChatArea({ conversation, character, onConversationUpdate
|
||||
if (!hasAssistantMsg) return
|
||||
|
||||
setSending(true)
|
||||
sendingRef.current = true
|
||||
setSendError(null)
|
||||
|
||||
// 记录被移除的最后一条 assistant 消息,失败时可恢复
|
||||
const lastAssistantIndex = [...messages].map(m => m.role).lastIndexOf('assistant')
|
||||
const removedMsg = lastAssistantIndex !== -1 ? messages[lastAssistantIndex] : null
|
||||
if (lastAssistantIndex !== -1) {
|
||||
setMessages(prev => prev.filter((_, i) => i !== lastAssistantIndex))
|
||||
}
|
||||
|
||||
const tempAIMessage: Message = {
|
||||
const tempAIMsg: Message = {
|
||||
id: Date.now(),
|
||||
conversationId: conversation.id,
|
||||
role: 'assistant',
|
||||
@@ -336,64 +372,50 @@ export default function ChatArea({ conversation, character, onConversationUpdate
|
||||
|
||||
try {
|
||||
if (streamEnabled) {
|
||||
setMessages(prev => [...prev, tempAIMessage])
|
||||
const response = await fetch(
|
||||
`${import.meta.env.VITE_API_BASE_URL || '/api'}/app/conversation/${conversation.id}/regenerate?stream=true`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
||||
},
|
||||
}
|
||||
)
|
||||
if (!response.ok) throw new Error('重新生成失败')
|
||||
|
||||
const reader = response.body?.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
if (reader) {
|
||||
let fullContent = ''
|
||||
let buffer = ''
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() || ''
|
||||
let currentEvent = ''
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event: ')) {
|
||||
currentEvent = line.slice(7).trim()
|
||||
} else if (line.startsWith('data: ')) {
|
||||
const data = line.slice(6).trim()
|
||||
if (currentEvent === 'message') {
|
||||
fullContent += data
|
||||
setMessages(prev =>
|
||||
prev.map(m => m.id === tempAIMessage.id ? { ...m, content: fullContent } : m)
|
||||
)
|
||||
} else if (currentEvent === 'done') {
|
||||
await loadMessages()
|
||||
break
|
||||
} else if (currentEvent === 'error') {
|
||||
throw new Error(data)
|
||||
}
|
||||
currentEvent = ''
|
||||
}
|
||||
}
|
||||
setMessages(prev => [...prev, tempAIMsg])
|
||||
setStreamingMsgId(tempAIMsg.id)
|
||||
let fullContent = ''
|
||||
for await (const ev of streamSSE(
|
||||
`/app/conversation/${conversation.id}/regenerate?stream=true`,
|
||||
'POST'
|
||||
)) {
|
||||
if (ev.event === 'message') {
|
||||
fullContent += ev.data
|
||||
setMessages(prev =>
|
||||
prev.map(m => m.id === tempAIMsg.id ? { ...m, content: fullContent } : m)
|
||||
)
|
||||
} else if (ev.event === 'done') {
|
||||
break
|
||||
} else if (ev.event === 'error') {
|
||||
throw new Error(ev.data)
|
||||
}
|
||||
}
|
||||
// 先原地清除 streaming 标记,再后台静默同步
|
||||
setStreamingMsgId(null)
|
||||
loadMessages(true).then(() => {
|
||||
conversationApi.getConversationById(conversation.id).then(convResp => {
|
||||
onConversationUpdate(convResp.data)
|
||||
}).catch(() => {})
|
||||
}).catch(() => {})
|
||||
} else {
|
||||
await conversationApi.regenerateMessage(conversation.id)
|
||||
await loadMessages()
|
||||
const convResp = await conversationApi.getConversationById(conversation.id)
|
||||
onConversationUpdate(convResp.data)
|
||||
}
|
||||
const convResp = await conversationApi.getConversationById(conversation.id)
|
||||
onConversationUpdate(convResp.data)
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : '重新生成失败,请重试'
|
||||
console.error('重新生成失败:', err)
|
||||
alert(err.message || '重新生成失败,请重试')
|
||||
await loadMessages()
|
||||
setStreamingMsgId(null)
|
||||
// 移除临时 AI 消息,恢复原来被删除的 assistant 消息
|
||||
setMessages(prev => {
|
||||
const withoutTemp = prev.filter(m => m.id !== tempAIMsg.id)
|
||||
return removedMsg ? [...withoutTemp, removedMsg] : withoutTemp
|
||||
})
|
||||
setSendError(msg)
|
||||
} finally {
|
||||
setSending(false)
|
||||
sendingRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,7 +423,7 @@ export default function ChatArea({ conversation, character, onConversationUpdate
|
||||
if (!confirm('确定要删除这个对话吗?')) return
|
||||
try {
|
||||
await conversationApi.deleteConversation(conversation.id)
|
||||
window.location.href = '/chat'
|
||||
navigate('/chat')
|
||||
} catch (err) {
|
||||
console.error('删除对话失败:', err)
|
||||
alert('删除失败')
|
||||
@@ -437,20 +459,52 @@ export default function ChatArea({ conversation, character, onConversationUpdate
|
||||
const selectedPreset = presets.find(p => p.id === selectedPresetId)
|
||||
const lastAssistantMsgId = [...messages].reverse().find(m => m.role === 'assistant')?.id
|
||||
|
||||
/** 从角色卡 extensions 中提取前端卡配置(memo 缓存,避免不必要重计算) */
|
||||
const frontendCard = useMemo(() => extractFrontendCard(character.extensions), [character.id, character.extensions])
|
||||
|
||||
/**
|
||||
* 前端卡用的最新消息内容:始终取消息列表最后一条的原始内容。
|
||||
* 流式期间每个 token 都会变,但前端卡 iframe 本身有防抖(isStreaming=true 时冻结刷新)。
|
||||
*/
|
||||
const latestMessageContent = messages.length > 0 ? messages[messages.length - 1].content : ''
|
||||
const latestMessageIndex = Math.max(0, messages.length - 1)
|
||||
|
||||
/**
|
||||
* 稳定的消息内容数组引用:只有消息数量或内容实际变化时才重建,
|
||||
* 避免每个 SSE token 都产生新数组引用,防止 StatusBarIframe 无限重建。
|
||||
*/
|
||||
const allMessageContents = useMemo(
|
||||
() => messages.map(m => m.content),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[messages.length, messages.map(m => m.content).join('\x00')]
|
||||
)
|
||||
|
||||
/**
|
||||
* 为每条消息分配稳定 key:
|
||||
* - 如果 stableKeyMap 中已有该消息 ID 的 key,直接复用(保持 DOM 节点稳定)
|
||||
* - 否则分配新 key(conversationId + 消息在列表中的位置索引,流式期间不会变)
|
||||
* 这样即使服务端刷新后 msg.id 从 tempId 变为真实 ID,React key 也不变,不会触发重新挂载。
|
||||
*/
|
||||
const getStableKey = useCallback((msg: Message, index: number): string => {
|
||||
if (stableKeyMap.current.has(msg.id)) {
|
||||
return stableKeyMap.current.get(msg.id)!
|
||||
}
|
||||
const key = `${conversation.id}-${index}`
|
||||
stableKeyMap.current.set(msg.id, key)
|
||||
return key
|
||||
}, [conversation.id])
|
||||
|
||||
return (
|
||||
<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'
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
500
web-app/src/components/StatusBarIframe.tsx
Normal file
500
web-app/src/components/StatusBarIframe.tsx
Normal 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
|
||||
/**
|
||||
* 父页面 origin(postMessage 安全检查)。
|
||||
* 默认为 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-origin,iframe 内脚本无法访问父页面 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 → 始终返回 true;prompt → 返回空字符串
|
||||
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 shim:sandbox 无 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
172
web-app/src/lib/regexEngine.ts
Normal file
172
web-app/src/lib/regexEngine.ts
Normal 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 和 namedGroups(ES2018+)
|
||||
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
73
web-app/src/lib/sse.ts
Normal 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(() => {})
|
||||
}
|
||||
}
|
||||
371
web-app/src/lib/textRenderer.ts
Normal file
371
web-app/src/lib/textRenderer.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
/** 正则元字符转义 */
|
||||
function escapeRegexStr(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user