🎨 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?
|
*.sw?
|
||||||
uploads
|
uploads
|
||||||
#docs
|
#docs
|
||||||
.claude
|
#.claude
|
||||||
plugs
|
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() {
|
go func() {
|
||||||
if err := service.ServiceGroupApp.AppServiceGroup.ConversationService.RegenerateMessageStream(
|
if err := service.ServiceGroupApp.AppServiceGroup.ConversationService.RegenerateMessageStream(
|
||||||
userID, conversationID, streamChan, doneChan,
|
c.Request.Context(), userID, conversationID, streamChan, doneChan,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
errorChan <- err
|
errorChan <- err
|
||||||
}
|
}
|
||||||
@@ -347,7 +347,7 @@ func (a *ConversationApi) SendMessageStream(c *gin.Context, userID, conversation
|
|||||||
// 启动流式传输
|
// 启动流式传输
|
||||||
go func() {
|
go func() {
|
||||||
err := service.ServiceGroupApp.AppServiceGroup.ConversationService.SendMessageStream(
|
err := service.ServiceGroupApp.AppServiceGroup.ConversationService.SendMessageStream(
|
||||||
userID, conversationID, req, streamChan, doneChan,
|
c.Request.Context(), userID, conversationID, req, streamChan, doneChan,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errorChan <- err
|
errorChan <- err
|
||||||
|
|||||||
@@ -51,6 +51,13 @@ func (a *RegexScriptApi) GetRegexScriptList(c *gin.Context) {
|
|||||||
scopeInt, _ := strconv.Atoi(scope)
|
scopeInt, _ := strconv.Atoi(scope)
|
||||||
req.Scope = &scopeInt
|
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 {
|
if req.Page < 1 {
|
||||||
req.Page = 1
|
req.Page = 1
|
||||||
|
|||||||
@@ -40,5 +40,5 @@ func RunServer() {
|
|||||||
默认MCP Message地址:http://127.0.0.1%s%s
|
默认MCP Message地址:http://127.0.0.1%s%s
|
||||||
默认前端文件运行地址:http://127.0.0.1:8080
|
默认前端文件运行地址:http://127.0.0.1:8080
|
||||||
`, address, address, global.GVA_CONFIG.MCP.SSEPath, address, global.GVA_CONFIG.MCP.MessagePath)
|
`, 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 获取正则脚本列表请求
|
// GetRegexScriptListRequest 获取正则脚本列表请求
|
||||||
type GetRegexScriptListRequest struct {
|
type GetRegexScriptListRequest struct {
|
||||||
Page int `json:"page"`
|
Page int `json:"page"`
|
||||||
PageSize int `json:"pageSize"`
|
PageSize int `json:"pageSize"`
|
||||||
Keyword string `json:"keyword"`
|
Keyword string `json:"keyword"`
|
||||||
Scope *int `json:"scope"`
|
Scope *int `json:"scope"`
|
||||||
|
OwnerCharID *uint `json:"ownerCharId"` // 过滤指定角色的脚本(scope=1时有效)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRegexScriptRequest 测试正则脚本请求
|
// TestRegexScriptRequest 测试正则脚本请求
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package app
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -371,21 +372,15 @@ func (s *ConversationService) SendMessage(userID, conversationID uint, req *requ
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取对话历史(最近10条)
|
// 获取完整对话历史(context 管理由 callAIService 内部处理)
|
||||||
var messages []app.Message
|
var messages []app.Message
|
||||||
err = global.GVA_DB.Where("conversation_id = ?", conversationID).
|
err = global.GVA_DB.Where("conversation_id = ?", conversationID).
|
||||||
Order("created_at DESC").
|
Order("created_at ASC").
|
||||||
Limit(10).
|
|
||||||
Find(&messages).Error
|
Find(&messages).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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 服务获取回复
|
// 调用 AI 服务获取回复
|
||||||
aiResponse, err := s.callAIService(conversation, character, messages)
|
aiResponse, err := s.callAIService(conversation, character, messages)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -527,47 +522,26 @@ func (s *ConversationService) callAIService(conversation app.Conversation, chara
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构建系统提示词(如果预设有系统提示词,则追加到角色卡提示词后)
|
// 构建消息列表(含 context 预算管理)
|
||||||
systemPrompt := s.buildSystemPrompt(character)
|
var presetSysPrompt string
|
||||||
if preset != nil && preset.SystemPrompt != "" {
|
if preset != nil {
|
||||||
systemPrompt = systemPrompt + "\n\n" + preset.SystemPrompt
|
presetSysPrompt = preset.SystemPrompt
|
||||||
global.GVA_LOG.Info("已追加预设的系统提示词")
|
|
||||||
}
|
}
|
||||||
|
wbEngine := &WorldbookEngine{}
|
||||||
|
apiMessages := s.buildAPIMessagesWithContextManagement(
|
||||||
|
messages, character, presetSysPrompt, wbEngine, conversation, &aiConfig, preset,
|
||||||
|
)
|
||||||
|
|
||||||
// 集成世界书触发引擎
|
// 从 apiMessages 中提取 systemPrompt,供 Anthropic 独立参数使用
|
||||||
if conversation.WorldbookEnabled && conversation.WorldbookID != nil {
|
systemPrompt := ""
|
||||||
global.GVA_LOG.Info(fmt.Sprintf("世界书已启用,ID: %d", *conversation.WorldbookID))
|
if len(apiMessages) > 0 && apiMessages[0]["role"] == "system" {
|
||||||
|
systemPrompt = apiMessages[0]["content"]
|
||||||
// 提取消息内容用于扫描
|
|
||||||
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 := s.buildAPIMessages(messages, systemPrompt)
|
|
||||||
|
|
||||||
// 打印发送给AI的完整内容
|
// 打印发送给AI的完整内容
|
||||||
global.GVA_LOG.Info("========== 发送给AI的完整内容 ==========")
|
global.GVA_LOG.Info("========== 发送给AI的完整内容 ==========")
|
||||||
global.GVA_LOG.Info(fmt.Sprintf("系统提示词: %s", systemPrompt))
|
global.GVA_LOG.Info(fmt.Sprintf("系统提示词长度: %d 字符", len(systemPrompt)))
|
||||||
global.GVA_LOG.Info("消息列表:")
|
global.GVA_LOG.Info(fmt.Sprintf("历史消息条数: %d", len(apiMessages)-1))
|
||||||
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("==========================================")
|
global.GVA_LOG.Info("==========================================")
|
||||||
|
|
||||||
// 确定使用的模型:如果用户在设置中指定了AI配置,则使用该配置的默认模型
|
// 确定使用的模型:如果用户在设置中指定了AI配置,则使用该配置的默认模型
|
||||||
@@ -735,7 +709,7 @@ func (s *ConversationService) getWeekdayInChinese(weekday time.Weekday) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SendMessageStream 流式发送消息并获取 AI 回复
|
// 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(streamChan)
|
||||||
defer close(doneChan)
|
defer close(doneChan)
|
||||||
|
|
||||||
@@ -796,21 +770,15 @@ func (s *ConversationService) SendMessageStream(userID, conversationID uint, req
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取对话历史(最近10条)
|
// 获取完整对话历史(context 管理由 buildAPIMessagesWithContextManagement 处理)
|
||||||
var messages []app.Message
|
var messages []app.Message
|
||||||
err = global.GVA_DB.Where("conversation_id = ?", conversationID).
|
err = global.GVA_DB.Where("conversation_id = ?", conversationID).
|
||||||
Order("created_at DESC").
|
Order("created_at ASC").
|
||||||
Limit(10).
|
|
||||||
Find(&messages).Error
|
Find(&messages).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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 配置
|
// 获取 AI 配置
|
||||||
var aiConfig app.AIConfig
|
var aiConfig app.AIConfig
|
||||||
var configID uint
|
var configID uint
|
||||||
@@ -857,42 +825,26 @@ func (s *ConversationService) SendMessageStream(userID, conversationID uint, req
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构建系统提示词(应用预设)
|
// 构建消息列表(含 context 预算管理)
|
||||||
systemPrompt := s.buildSystemPrompt(character)
|
var streamPresetSysPrompt string
|
||||||
if streamPreset != nil && streamPreset.SystemPrompt != "" {
|
if streamPreset != nil {
|
||||||
systemPrompt = systemPrompt + "\n\n" + streamPreset.SystemPrompt
|
streamPresetSysPrompt = streamPreset.SystemPrompt
|
||||||
}
|
}
|
||||||
|
streamWbEngine := &WorldbookEngine{}
|
||||||
|
apiMessages := s.buildAPIMessagesWithContextManagement(
|
||||||
|
messages, character, streamPresetSysPrompt, streamWbEngine, conversation, &aiConfig, streamPreset,
|
||||||
|
)
|
||||||
|
|
||||||
// 集成世界书触发引擎(流式传输)
|
// 从 apiMessages 中提取 systemPrompt,供 Anthropic 独立参数使用
|
||||||
if conversation.WorldbookEnabled && conversation.WorldbookID != nil {
|
systemPrompt := ""
|
||||||
global.GVA_LOG.Info(fmt.Sprintf("[流式传输] 世界书已启用,ID: %d", *conversation.WorldbookID))
|
if len(apiMessages) > 0 && apiMessages[0]["role"] == "system" {
|
||||||
|
systemPrompt = apiMessages[0]["content"]
|
||||||
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 := s.buildAPIMessages(messages, systemPrompt)
|
|
||||||
|
|
||||||
// 打印发送给AI的完整内容(流式传输)
|
// 打印发送给AI的完整内容(流式传输)
|
||||||
global.GVA_LOG.Info("========== [流式传输] 发送给AI的完整内容 ==========")
|
global.GVA_LOG.Info("========== [流式传输] 发送给AI的完整内容 ==========")
|
||||||
global.GVA_LOG.Info(fmt.Sprintf("系统提示词: %s", systemPrompt))
|
global.GVA_LOG.Info(fmt.Sprintf("[流式传输] 系统提示词长度: %d 字符", len(systemPrompt)))
|
||||||
global.GVA_LOG.Info("消息列表:")
|
global.GVA_LOG.Info(fmt.Sprintf("[流式传输] 历史消息条数: %d", len(apiMessages)-1))
|
||||||
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("==========================================")
|
global.GVA_LOG.Info("==========================================")
|
||||||
|
|
||||||
// 确定使用的模型
|
// 确定使用的模型
|
||||||
@@ -910,15 +862,21 @@ func (s *ConversationService) SendMessageStream(userID, conversationID uint, req
|
|||||||
var fullContent string
|
var fullContent string
|
||||||
switch aiConfig.Provider {
|
switch aiConfig.Provider {
|
||||||
case "openai", "custom":
|
case "openai", "custom":
|
||||||
fullContent, err = s.callOpenAIAPIStream(&aiConfig, model, apiMessages, streamPreset, streamChan)
|
fullContent, err = s.callOpenAIAPIStream(ctx, &aiConfig, model, apiMessages, streamPreset, streamChan)
|
||||||
case "anthropic":
|
case "anthropic":
|
||||||
fullContent, err = s.callAnthropicAPIStream(&aiConfig, model, apiMessages, systemPrompt, streamPreset, streamChan)
|
fullContent, err = s.callAnthropicAPIStream(ctx, &aiConfig, model, apiMessages, systemPrompt, streamPreset, streamChan)
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("不支持的 AI 提供商: %s", aiConfig.Provider)
|
return fmt.Errorf("不支持的 AI 提供商: %s", aiConfig.Provider)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
global.GVA_LOG.Error(fmt.Sprintf("========== [流式传输] AI返回错误 ==========\n%v\n==========================================", err))
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -962,8 +920,9 @@ func (s *ConversationService) SendMessageStream(userID, conversationID uint, req
|
|||||||
}
|
}
|
||||||
|
|
||||||
// callOpenAIAPIStream 调用 OpenAI API 流式传输
|
// callOpenAIAPIStream 调用 OpenAI API 流式传输
|
||||||
func (s *ConversationService) callOpenAIAPIStream(config *app.AIConfig, model string, messages []map[string]string, preset *app.AIPreset, streamChan chan string) (string, error) {
|
func (s *ConversationService) callOpenAIAPIStream(ctx context.Context, config *app.AIConfig, model string, messages []map[string]string, preset *app.AIPreset, streamChan chan string) (string, error) {
|
||||||
client := &http.Client{Timeout: 120 * time.Second}
|
// 不设 Timeout:生命周期由调用方传入的 ctx 控制(客户端断连时自动取消)
|
||||||
|
client := &http.Client{}
|
||||||
|
|
||||||
if model == "" {
|
if model == "" {
|
||||||
model = config.DefaultModel
|
model = config.DefaultModel
|
||||||
@@ -1025,7 +984,7 @@ func (s *ConversationService) callOpenAIAPIStream(config *app.AIConfig, model st
|
|||||||
}
|
}
|
||||||
|
|
||||||
endpoint := config.BaseURL + "/chat/completions"
|
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 {
|
if err != nil {
|
||||||
return "", fmt.Errorf("创建请求失败: %v", err)
|
return "", fmt.Errorf("创建请求失败: %v", err)
|
||||||
}
|
}
|
||||||
@@ -1035,6 +994,10 @@ func (s *ConversationService) callOpenAIAPIStream(config *app.AIConfig, model st
|
|||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// 客户端主动断开时 ctx 被取消,不算真正的错误
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
return "", fmt.Errorf("请求失败: %v", err)
|
return "", fmt.Errorf("请求失败: %v", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
@@ -1050,49 +1013,51 @@ func (s *ConversationService) callOpenAIAPIStream(config *app.AIConfig, model st
|
|||||||
|
|
||||||
for {
|
for {
|
||||||
line, err := reader.ReadString('\n')
|
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 != nil {
|
||||||
if err == io.EOF {
|
if err == io.EOF {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
// ctx 被取消(客户端断开)时不算真正的流读取错误
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return fullContent.String(), nil
|
||||||
|
}
|
||||||
return "", fmt.Errorf("读取流失败: %v", err)
|
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
|
return fullContent.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// callAnthropicAPIStream 调用 Anthropic API 流式传输
|
// 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) {
|
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) {
|
||||||
client := &http.Client{Timeout: 120 * time.Second}
|
// 不设 Timeout:生命周期由调用方传入的 ctx 控制(客户端断连时自动取消)
|
||||||
|
client := &http.Client{}
|
||||||
|
|
||||||
if model == "" {
|
if model == "" {
|
||||||
model = config.DefaultModel
|
model = config.DefaultModel
|
||||||
@@ -1152,7 +1117,7 @@ func (s *ConversationService) callAnthropicAPIStream(config *app.AIConfig, model
|
|||||||
}
|
}
|
||||||
|
|
||||||
endpoint := config.BaseURL + "/messages"
|
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 {
|
if err != nil {
|
||||||
return "", fmt.Errorf("创建请求失败: %v", err)
|
return "", fmt.Errorf("创建请求失败: %v", err)
|
||||||
}
|
}
|
||||||
@@ -1163,6 +1128,10 @@ func (s *ConversationService) callAnthropicAPIStream(config *app.AIConfig, model
|
|||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// 客户端主动断开时 ctx 被取消,不算真正的错误
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
return "", fmt.Errorf("请求失败: %v", err)
|
return "", fmt.Errorf("请求失败: %v", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
@@ -1178,38 +1147,39 @@ func (s *ConversationService) callAnthropicAPIStream(config *app.AIConfig, model
|
|||||||
|
|
||||||
for {
|
for {
|
||||||
line, err := reader.ReadString('\n')
|
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 != nil {
|
||||||
if err == io.EOF {
|
if err == io.EOF {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
// ctx 被取消(客户端断开)时不算真正的流读取错误
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return fullContent.String(), nil
|
||||||
|
}
|
||||||
return "", fmt.Errorf("读取流失败: %v", err)
|
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
|
return fullContent.String(), nil
|
||||||
@@ -1243,19 +1213,16 @@ func (s *ConversationService) RegenerateMessage(userID, conversationID uint) (*r
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取删除后的消息历史
|
// 获取删除后的完整消息历史(context 管理由 callAIService 内部处理)
|
||||||
var messages []app.Message
|
var messages []app.Message
|
||||||
err = global.GVA_DB.Where("conversation_id = ?", conversationID).
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if len(messages) == 0 {
|
if len(messages) == 0 {
|
||||||
return nil, errors.New("没有可用的消息历史")
|
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)
|
aiResponse, err := s.callAIService(conversation, character, messages)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1282,7 +1249,7 @@ func (s *ConversationService) RegenerateMessage(userID, conversationID uint) (*r
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RegenerateMessageStream 流式重新生成最后一条 AI 回复
|
// 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(streamChan)
|
||||||
defer close(doneChan)
|
defer close(doneChan)
|
||||||
|
|
||||||
@@ -1312,19 +1279,16 @@ func (s *ConversationService) RegenerateMessageStream(userID, conversationID uin
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取删除后的消息历史
|
// 获取删除后的完整消息历史(context 管理由 buildAPIMessagesWithContextManagement 处理)
|
||||||
var messages []app.Message
|
var messages []app.Message
|
||||||
err = global.GVA_DB.Where("conversation_id = ?", conversationID).
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if len(messages) == 0 {
|
if len(messages) == 0 {
|
||||||
return errors.New("没有可用的消息历史")
|
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 配置
|
// 获取 AI 配置
|
||||||
var aiConfig app.AIConfig
|
var aiConfig app.AIConfig
|
||||||
@@ -1367,11 +1331,21 @@ func (s *ConversationService) RegenerateMessageStream(userID, conversationID uin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
systemPrompt := s.buildSystemPrompt(character)
|
// 构建消息列表(含 context 预算管理)
|
||||||
if preset != nil && preset.SystemPrompt != "" {
|
var regenPresetSysPrompt string
|
||||||
systemPrompt = systemPrompt + "\n\n" + preset.SystemPrompt
|
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
|
model := aiConfig.DefaultModel
|
||||||
if model == "" {
|
if model == "" {
|
||||||
@@ -1384,14 +1358,27 @@ func (s *ConversationService) RegenerateMessageStream(userID, conversationID uin
|
|||||||
var fullContent string
|
var fullContent string
|
||||||
switch aiConfig.Provider {
|
switch aiConfig.Provider {
|
||||||
case "openai", "custom":
|
case "openai", "custom":
|
||||||
fullContent, err = s.callOpenAIAPIStream(&aiConfig, model, apiMessages, preset, streamChan)
|
fullContent, err = s.callOpenAIAPIStream(ctx, &aiConfig, model, apiMessages, preset, streamChan)
|
||||||
case "anthropic":
|
case "anthropic":
|
||||||
fullContent, err = s.callAnthropicAPIStream(&aiConfig, model, apiMessages, systemPrompt, preset, streamChan)
|
fullContent, err = s.callAnthropicAPIStream(ctx, &aiConfig, model, apiMessages, systemPrompt, preset, streamChan)
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("不支持的 AI 提供商: %s", aiConfig.Provider)
|
return fmt.Errorf("不支持的 AI 提供商: %s", aiConfig.Provider)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1437,9 +1424,333 @@ func (s *ConversationService) buildAPIMessages(messages []app.Message, systemPro
|
|||||||
return apiMessages
|
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
|
// callOpenAIAPI 调用 OpenAI API
|
||||||
func (s *ConversationService) callOpenAIAPI(config *app.AIConfig, model string, messages []map[string]string, preset *app.AIPreset) (string, error) {
|
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 == "" {
|
if model == "" {
|
||||||
@@ -1559,7 +1870,7 @@ func (s *ConversationService) callOpenAIAPI(config *app.AIConfig, model string,
|
|||||||
|
|
||||||
// callAnthropicAPI 调用 Anthropic API
|
// callAnthropicAPI 调用 Anthropic API
|
||||||
func (s *ConversationService) callAnthropicAPI(config *app.AIConfig, model string, messages []map[string]string, systemPrompt string, preset *app.AIPreset) (string, error) {
|
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 == "" {
|
if model == "" {
|
||||||
|
|||||||
@@ -69,6 +69,10 @@ func (s *RegexScriptService) GetRegexScriptList(userID uint, req *request.GetReg
|
|||||||
db = db.Where("scope = ?", *req.Scope)
|
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 {
|
if err := db.Count(&total).Error; err != nil {
|
||||||
return nil, 0, err
|
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",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.13.5",
|
"axios": "^1.13.5",
|
||||||
|
"js-yaml": "^4.1.1",
|
||||||
"lucide-react": "^0.344.0",
|
"lucide-react": "^0.344.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
@@ -20,6 +21,7 @@
|
|||||||
"zustand": "^5.0.11"
|
"zustand": "^5.0.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/js-yaml": "^4.0.9",
|
||||||
"@types/react": "^18.3.3",
|
"@types/react": "^18.3.3",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
@@ -1239,6 +1241,13 @@
|
|||||||
"@types/unist": "*"
|
"@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": {
|
"node_modules/@types/mdast": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
|
||||||
@@ -1341,6 +1350,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/asynckit": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||||
@@ -2458,6 +2473,18 @@
|
|||||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/jsesc": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.13.5",
|
"axios": "^1.13.5",
|
||||||
|
"js-yaml": "^4.1.1",
|
||||||
"lucide-react": "^0.344.0",
|
"lucide-react": "^0.344.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
@@ -21,6 +22,7 @@
|
|||||||
"zustand": "^5.0.11"
|
"zustand": "^5.0.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/js-yaml": "^4.0.9",
|
||||||
"@types/react": "^18.3.3",
|
"@types/react": "^18.3.3",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
|||||||
@@ -1,6 +1,54 @@
|
|||||||
import apiClient from './client'
|
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 {
|
export interface Character {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ export interface GetRegexScriptListRequest {
|
|||||||
pageSize?: number
|
pageSize?: number
|
||||||
keyword?: string
|
keyword?: string
|
||||||
scope?: number
|
scope?: number
|
||||||
|
ownerCharId?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RegexScriptListResponse {
|
export interface RegexScriptListResponse {
|
||||||
|
|||||||
@@ -13,12 +13,17 @@ import {
|
|||||||
Waves,
|
Waves,
|
||||||
Zap
|
Zap
|
||||||
} from 'lucide-react'
|
} 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 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 AIConfig, aiConfigApi} from '../api/aiConfig'
|
||||||
import {type Preset, presetApi} from '../api/preset'
|
import {type Preset, presetApi} from '../api/preset'
|
||||||
|
import {type RegexScript, regexAPI} from '../api/regex'
|
||||||
import MessageContent from './MessageContent'
|
import MessageContent from './MessageContent'
|
||||||
|
import StatusBarIframe from './StatusBarIframe'
|
||||||
|
import {useAppStore} from '../store'
|
||||||
|
import {streamSSE} from '../lib/sse'
|
||||||
|
|
||||||
interface ChatAreaProps {
|
interface ChatAreaProps {
|
||||||
conversation: Conversation
|
conversation: Conversation
|
||||||
@@ -26,7 +31,18 @@ interface ChatAreaProps {
|
|||||||
onConversationUpdate: (conversation: Conversation) => void
|
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) {
|
export default function ChatArea({ conversation, character, onConversationUpdate }: ChatAreaProps) {
|
||||||
|
const navigate = useNavigate()
|
||||||
const [messages, setMessages] = useState<Message[]>([])
|
const [messages, setMessages] = useState<Message[]>([])
|
||||||
const [inputValue, setInputValue] = useState('')
|
const [inputValue, setInputValue] = useState('')
|
||||||
const [sending, setSending] = useState(false)
|
const [sending, setSending] = useState(false)
|
||||||
@@ -40,12 +56,29 @@ export default function ChatArea({ conversation, character, onConversationUpdate
|
|||||||
const [presets, setPresets] = useState<Preset[]>([])
|
const [presets, setPresets] = useState<Preset[]>([])
|
||||||
const [selectedPresetId, setSelectedPresetId] = useState<number>()
|
const [selectedPresetId, setSelectedPresetId] = useState<number>()
|
||||||
const [showPresetSelector, setShowPresetSelector] = useState(false)
|
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 messagesEndRef = useRef<HTMLDivElement>(null)
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
const modelSelectorRef = useRef<HTMLDivElement>(null)
|
const modelSelectorRef = useRef<HTMLDivElement>(null)
|
||||||
const presetSelectorRef = useRef<HTMLDivElement>(null)
|
const presetSelectorRef = useRef<HTMLDivElement>(null)
|
||||||
const menuRef = 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(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
const target = event.target as HTMLElement
|
const target = event.target as HTMLElement
|
||||||
@@ -63,70 +96,164 @@ export default function ChatArea({ conversation, character, onConversationUpdate
|
|||||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||||
}, [showModelSelector, showPresetSelector, showMenu])
|
}, [showModelSelector, showPresetSelector, showMenu])
|
||||||
|
|
||||||
|
// ---- 初始化加载 ----
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadMessages()
|
loadMessages()
|
||||||
loadAIConfigs()
|
loadAIConfigs()
|
||||||
loadCurrentConfig()
|
|
||||||
loadPresets()
|
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])
|
}, [conversation.id])
|
||||||
|
|
||||||
|
// ---- 消息滚动 ----
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
}, [messages])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const handleStatusBarAction = (event: CustomEvent) => {
|
const handleStatusBarAction = (event: CustomEvent) => {
|
||||||
const action = event.detail
|
const action = event.detail
|
||||||
if (action && typeof action === 'string' && !sending) {
|
if (action && typeof action === 'string' && !sendingRef.current) {
|
||||||
console.log('[ChatArea] 收到状态栏操作,自动发送消息:', action)
|
console.log('[ChatArea] 收到状态栏操作,自动发送消息:', action)
|
||||||
setInputValue(action)
|
handleSendMessage(action)
|
||||||
// 延迟发送,确保 inputValue 已更新
|
|
||||||
setTimeout(() => {
|
|
||||||
handleSendMessage(action)
|
|
||||||
}, 100)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('sendMessageFromStatusBar', handleStatusBarAction as EventListener)
|
window.addEventListener('sendMessageFromStatusBar', handleStatusBarAction as EventListener)
|
||||||
return () => window.removeEventListener('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 {
|
try {
|
||||||
setLoading(true)
|
if (!silent) setLoading(true)
|
||||||
const response = await conversationApi.getMessageList(conversation.id, { page: 1, pageSize: 100 })
|
const response = await conversationApi.getMessageList(conversation.id, { page: 1, pageSize: 100 })
|
||||||
setMessages(response.data.list || [])
|
setMessages(response.data.list || [])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('加载消息失败:', err)
|
console.error('加载消息失败:', err)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
if (!silent) setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadAIConfigs = async () => {
|
const loadAIConfigs = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await aiConfigApi.getAIConfigList()
|
const response = await aiConfigApi.getAIConfigList()
|
||||||
setAiConfigs(response.data.list.filter(config => config.isActive))
|
setAiConfigs(response.data.list.filter((c: AIConfig) => c.isActive))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('加载 AI 配置失败:', 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 () => {
|
const loadPresets = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await presetApi.getPresetList({ page: 1, pageSize: 100 })
|
const response = await presetApi.getPresetList({ page: 1, pageSize: 100 })
|
||||||
@@ -136,37 +263,36 @@ export default function ChatArea({ conversation, character, onConversationUpdate
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadCurrentPreset = () => {
|
const loadRegexScripts = async () => {
|
||||||
if (conversation.presetId) {
|
try {
|
||||||
setSelectedPresetId(conversation.presetId)
|
// 并行加载:全局脚本(scope=0)+ 当前角色专属脚本(scope=1, ownerCharId=character.id)
|
||||||
return
|
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) => {
|
const handlePresetChange = async (presetId: number | null) => {
|
||||||
try {
|
try {
|
||||||
const settings = conversation.settings
|
const settings = parseSettings(conversation.settings)
|
||||||
? (typeof conversation.settings === 'string' ? JSON.parse(conversation.settings) : { ...conversation.settings })
|
if (presetId === null) delete settings.presetId
|
||||||
: {}
|
else settings.presetId = presetId
|
||||||
if (presetId === null) {
|
|
||||||
delete settings.presetId
|
|
||||||
} else {
|
|
||||||
settings.presetId = presetId
|
|
||||||
}
|
|
||||||
await conversationApi.updateConversationSettings(conversation.id, { settings })
|
await conversationApi.updateConversationSettings(conversation.id, { settings })
|
||||||
setSelectedPresetId(presetId ?? undefined)
|
setSelectedPresetId(presetId ?? undefined)
|
||||||
setShowPresetSelector(false)
|
setShowPresetSelector(false)
|
||||||
@@ -180,14 +306,9 @@ export default function ChatArea({ conversation, character, onConversationUpdate
|
|||||||
|
|
||||||
const handleModelChange = async (configId: number | null) => {
|
const handleModelChange = async (configId: number | null) => {
|
||||||
try {
|
try {
|
||||||
const settings = conversation.settings
|
const settings = parseSettings(conversation.settings)
|
||||||
? (typeof conversation.settings === 'string' ? JSON.parse(conversation.settings) : { ...conversation.settings })
|
if (configId === null) delete settings.aiConfigId
|
||||||
: {}
|
else settings.aiConfigId = configId
|
||||||
if (configId === null) {
|
|
||||||
delete settings.aiConfigId
|
|
||||||
} else {
|
|
||||||
settings.aiConfigId = configId
|
|
||||||
}
|
|
||||||
await conversationApi.updateConversationSettings(conversation.id, { settings })
|
await conversationApi.updateConversationSettings(conversation.id, { settings })
|
||||||
setSelectedConfigId(configId ?? undefined)
|
setSelectedConfigId(configId ?? undefined)
|
||||||
setShowModelSelector(false)
|
setShowModelSelector(false)
|
||||||
@@ -199,101 +320,11 @@ export default function ChatArea({ conversation, character, onConversationUpdate
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 消息操作 ----
|
||||||
const scrollToBottom = () => {
|
const scrollToBottom = () => {
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
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 () => {
|
const handleSend = async () => {
|
||||||
if (!inputValue.trim() || sending) return
|
if (!inputValue.trim() || sending) return
|
||||||
const userMessage = inputValue.trim()
|
const userMessage = inputValue.trim()
|
||||||
@@ -320,12 +351,17 @@ export default function ChatArea({ conversation, character, onConversationUpdate
|
|||||||
if (!hasAssistantMsg) return
|
if (!hasAssistantMsg) return
|
||||||
|
|
||||||
setSending(true)
|
setSending(true)
|
||||||
|
sendingRef.current = true
|
||||||
|
setSendError(null)
|
||||||
|
|
||||||
|
// 记录被移除的最后一条 assistant 消息,失败时可恢复
|
||||||
const lastAssistantIndex = [...messages].map(m => m.role).lastIndexOf('assistant')
|
const lastAssistantIndex = [...messages].map(m => m.role).lastIndexOf('assistant')
|
||||||
|
const removedMsg = lastAssistantIndex !== -1 ? messages[lastAssistantIndex] : null
|
||||||
if (lastAssistantIndex !== -1) {
|
if (lastAssistantIndex !== -1) {
|
||||||
setMessages(prev => prev.filter((_, i) => i !== lastAssistantIndex))
|
setMessages(prev => prev.filter((_, i) => i !== lastAssistantIndex))
|
||||||
}
|
}
|
||||||
|
|
||||||
const tempAIMessage: Message = {
|
const tempAIMsg: Message = {
|
||||||
id: Date.now(),
|
id: Date.now(),
|
||||||
conversationId: conversation.id,
|
conversationId: conversation.id,
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
@@ -336,64 +372,50 @@ export default function ChatArea({ conversation, character, onConversationUpdate
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (streamEnabled) {
|
if (streamEnabled) {
|
||||||
setMessages(prev => [...prev, tempAIMessage])
|
setMessages(prev => [...prev, tempAIMsg])
|
||||||
const response = await fetch(
|
setStreamingMsgId(tempAIMsg.id)
|
||||||
`${import.meta.env.VITE_API_BASE_URL || '/api'}/app/conversation/${conversation.id}/regenerate?stream=true`,
|
let fullContent = ''
|
||||||
{
|
for await (const ev of streamSSE(
|
||||||
method: 'POST',
|
`/app/conversation/${conversation.id}/regenerate?stream=true`,
|
||||||
headers: {
|
'POST'
|
||||||
'Content-Type': 'application/json',
|
)) {
|
||||||
'Authorization': `Bearer ${localStorage.getItem('token')}`,
|
if (ev.event === 'message') {
|
||||||
},
|
fullContent += ev.data
|
||||||
}
|
setMessages(prev =>
|
||||||
)
|
prev.map(m => m.id === tempAIMsg.id ? { ...m, content: fullContent } : m)
|
||||||
if (!response.ok) throw new Error('重新生成失败')
|
)
|
||||||
|
} else if (ev.event === 'done') {
|
||||||
const reader = response.body?.getReader()
|
break
|
||||||
const decoder = new TextDecoder()
|
} else if (ev.event === 'error') {
|
||||||
if (reader) {
|
throw new Error(ev.data)
|
||||||
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 = ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 先原地清除 streaming 标记,再后台静默同步
|
||||||
|
setStreamingMsgId(null)
|
||||||
|
loadMessages(true).then(() => {
|
||||||
|
conversationApi.getConversationById(conversation.id).then(convResp => {
|
||||||
|
onConversationUpdate(convResp.data)
|
||||||
|
}).catch(() => {})
|
||||||
|
}).catch(() => {})
|
||||||
} else {
|
} else {
|
||||||
await conversationApi.regenerateMessage(conversation.id)
|
await conversationApi.regenerateMessage(conversation.id)
|
||||||
await loadMessages()
|
await loadMessages()
|
||||||
|
const convResp = await conversationApi.getConversationById(conversation.id)
|
||||||
|
onConversationUpdate(convResp.data)
|
||||||
}
|
}
|
||||||
const convResp = await conversationApi.getConversationById(conversation.id)
|
} catch (err: unknown) {
|
||||||
onConversationUpdate(convResp.data)
|
const msg = err instanceof Error ? err.message : '重新生成失败,请重试'
|
||||||
} catch (err: any) {
|
|
||||||
console.error('重新生成失败:', err)
|
console.error('重新生成失败:', err)
|
||||||
alert(err.message || '重新生成失败,请重试')
|
setStreamingMsgId(null)
|
||||||
await loadMessages()
|
// 移除临时 AI 消息,恢复原来被删除的 assistant 消息
|
||||||
|
setMessages(prev => {
|
||||||
|
const withoutTemp = prev.filter(m => m.id !== tempAIMsg.id)
|
||||||
|
return removedMsg ? [...withoutTemp, removedMsg] : withoutTemp
|
||||||
|
})
|
||||||
|
setSendError(msg)
|
||||||
} finally {
|
} finally {
|
||||||
setSending(false)
|
setSending(false)
|
||||||
|
sendingRef.current = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -401,7 +423,7 @@ export default function ChatArea({ conversation, character, onConversationUpdate
|
|||||||
if (!confirm('确定要删除这个对话吗?')) return
|
if (!confirm('确定要删除这个对话吗?')) return
|
||||||
try {
|
try {
|
||||||
await conversationApi.deleteConversation(conversation.id)
|
await conversationApi.deleteConversation(conversation.id)
|
||||||
window.location.href = '/chat'
|
navigate('/chat')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('删除对话失败:', err)
|
console.error('删除对话失败:', err)
|
||||||
alert('删除失败')
|
alert('删除失败')
|
||||||
@@ -437,20 +459,52 @@ export default function ChatArea({ conversation, character, onConversationUpdate
|
|||||||
const selectedPreset = presets.find(p => p.id === selectedPresetId)
|
const selectedPreset = presets.find(p => p.id === selectedPresetId)
|
||||||
const lastAssistantMsgId = [...messages].reverse().find(m => m.role === 'assistant')?.id
|
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 (
|
return (
|
||||||
<div className="flex-1 flex flex-col min-w-0">
|
<div className="flex-1 flex flex-col min-w-0">
|
||||||
{/* 顶部工具栏 */}
|
{/* 顶部工具栏 */}
|
||||||
<div className="px-4 py-3 glass border-b border-white/10">
|
<div className="px-4 py-3 glass border-b border-white/10">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
{/* 左侧:标题 */}
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<h2 className="text-base font-semibold truncate">{conversation.title}</h2>
|
<h2 className="text-base font-semibold truncate">{conversation.title}</h2>
|
||||||
<p className="text-xs text-white/50 truncate">与 {character.name} 对话中</p>
|
<p className="text-xs text-white/50 truncate">与 {character.name} 对话中</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 右侧:工具按钮组 */}
|
|
||||||
<div className="flex items-center gap-1 flex-shrink-0">
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
|
|
||||||
{/* 模型选择器 */}
|
{/* 模型选择器 */}
|
||||||
<div className="relative" ref={modelSelectorRef}>
|
<div className="relative" ref={modelSelectorRef}>
|
||||||
<button
|
<button
|
||||||
@@ -557,7 +611,6 @@ export default function ChatArea({ conversation, character, onConversationUpdate
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 分隔线 */}
|
|
||||||
<div className="w-px h-5 bg-white/10 mx-1" />
|
<div className="w-px h-5 bg-white/10 mx-1" />
|
||||||
|
|
||||||
{/* 流式传输切换 */}
|
{/* 流式传输切换 */}
|
||||||
@@ -613,6 +666,20 @@ export default function ChatArea({ conversation, character, onConversationUpdate
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="flex-1 overflow-y-auto px-4 py-6 space-y-5">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@@ -631,11 +698,11 @@ export default function ChatArea({ conversation, character, onConversationUpdate
|
|||||||
<p className="text-white/50 text-sm">发送第一条消息,开始和 {character.name} 对话吧</p>
|
<p className="text-white/50 text-sm">发送第一条消息,开始和 {character.name} 对话吧</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
messages.map((msg) => {
|
messages.map((msg, msgIndex) => {
|
||||||
const isLastAssistant = msg.id === lastAssistantMsgId
|
const isLastAssistant = msg.id === lastAssistantMsgId
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={msg.id}
|
key={getStableKey(msg, msgIndex)}
|
||||||
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'} group`}
|
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'}`}>
|
<div className={`min-w-0 flex flex-col ${msg.role === 'user' ? 'max-w-[70%] items-end' : 'w-[70%] items-start'}`}>
|
||||||
{/* 助手名称 */}
|
|
||||||
{msg.role === 'assistant' && (
|
{msg.role === 'assistant' && (
|
||||||
<span className="text-xs text-white/40 mb-1 ml-1">{character.name}</span>
|
<span className="text-xs text-white/40 mb-1 ml-1">{character.name}</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 消息气泡 */}
|
|
||||||
<div
|
<div
|
||||||
className={`relative px-4 py-3 rounded-2xl ${
|
className={`relative px-4 py-3 rounded-2xl ${
|
||||||
msg.role === 'user'
|
msg.role === 'user'
|
||||||
@@ -669,10 +734,17 @@ export default function ChatArea({ conversation, character, onConversationUpdate
|
|||||||
<MessageContent
|
<MessageContent
|
||||||
content={msg.content}
|
content={msg.content}
|
||||||
role={msg.role as 'user' | 'assistant'}
|
role={msg.role as 'user' | 'assistant'}
|
||||||
|
messageIndex={msgIndex}
|
||||||
|
characterName={character.name}
|
||||||
|
userName={variables.user || user?.username || ''}
|
||||||
|
regexScripts={regexScripts}
|
||||||
|
allMessages={allMessageContents}
|
||||||
onChoiceSelect={(choice) => {
|
onChoiceSelect={(choice) => {
|
||||||
setInputValue(choice)
|
setInputValue(choice)
|
||||||
textareaRef.current?.focus()
|
textareaRef.current?.focus()
|
||||||
}}
|
}}
|
||||||
|
onAction={handleIframeAction}
|
||||||
|
isStreaming={msg.id === streamingMsgId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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" />
|
: <Copy className="w-3.5 h-3.5 text-white/40 hover:text-white/70" />
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
{/* 最后一条 AI 消息显示重新生成按钮 */}
|
|
||||||
{msg.role === 'assistant' && isLastAssistant && (
|
{msg.role === 'assistant' && isLastAssistant && (
|
||||||
<button
|
<button
|
||||||
onClick={handleRegenerateResponse}
|
onClick={handleRegenerateResponse}
|
||||||
@@ -703,14 +774,16 @@ export default function ChatArea({ conversation, character, onConversationUpdate
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 发送中动画(流式时不需要,已有临时消息) */}
|
{/* 发送中动画(非流式模式下显示) */}
|
||||||
{sending && !streamEnabled && (
|
{sending && !streamEnabled && (
|
||||||
<div className="flex justify-start">
|
<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">
|
<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 ref={messagesEndRef} />
|
||||||
</div>
|
</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">
|
<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">
|
<div className="flex items-end gap-2">
|
||||||
<button className="p-2.5 glass-hover rounded-xl cursor-not-allowed opacity-30" title="附件(开发中)" disabled>
|
<button className="p-2.5 glass-hover rounded-xl cursor-not-allowed opacity-30" title="附件(开发中)" disabled>
|
||||||
<Paperclip className="w-5 h-5" />
|
<Paperclip className="w-5 h-5" />
|
||||||
@@ -741,6 +841,7 @@ export default function ChatArea({ conversation, character, onConversationUpdate
|
|||||||
value={inputValue}
|
value={inputValue}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setInputValue(e.target.value)
|
setInputValue(e.target.value)
|
||||||
|
if (sendError) setSendError(null)
|
||||||
if (textareaRef.current) {
|
if (textareaRef.current) {
|
||||||
textareaRef.current.style.height = 'auto'
|
textareaRef.current.style.height = 'auto'
|
||||||
textareaRef.current.style.height = Math.min(textareaRef.current.scrollHeight, 120) + 'px'
|
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;
|
@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 {useNavigate, useSearchParams} from 'react-router-dom'
|
||||||
import Navbar from '../components/Navbar'
|
import Navbar from '../components/Navbar'
|
||||||
import {Book, Code2, Download, Edit, FileJson, FileUp, Image as ImageIcon, Plus, Search, Trash2, X} from 'lucide-react'
|
import {Book, Code2, Download, Edit, FileJson, FileUp, Image as ImageIcon, Layout, Plus, Search, Trash2, X} from 'lucide-react'
|
||||||
import {type Character, characterApi} from '../api/character'
|
import {type Character, characterApi, extractFrontendCard} from '../api/character'
|
||||||
import {type RegexScript, regexScriptApi} from '../api/regex'
|
import {type RegexScript, regexScriptApi} from '../api/regex'
|
||||||
// import {useAppStore} from '../store'
|
// import {useAppStore} from '../store'
|
||||||
|
|
||||||
@@ -37,6 +37,13 @@ export default function CharacterManagePage() {
|
|||||||
const [showRegexScriptEditor, setShowRegexScriptEditor] = useState(false)
|
const [showRegexScriptEditor, setShowRegexScriptEditor] = useState(false)
|
||||||
const [regexScripts, setRegexScripts] = useState<RegexScript[]>([])
|
const [regexScripts, setRegexScripts] = useState<RegexScript[]>([])
|
||||||
const [_editingTab, _setEditingTab] = useState<'basic' | 'worldbook' | 'regex'>('basic')
|
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 [showAddRegexModal, setShowAddRegexModal] = useState(false)
|
||||||
const [newRegexForm, setNewRegexForm] = useState({
|
const [newRegexForm, setNewRegexForm] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
@@ -61,10 +68,24 @@ export default function CharacterManagePage() {
|
|||||||
setShowEditModal(true)
|
setShowEditModal(true)
|
||||||
loadWorldBook(char)
|
loadWorldBook(char)
|
||||||
loadRegexScripts(char.id)
|
loadRegexScripts(char.id)
|
||||||
|
loadFrontendCard(char)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [searchParams, characters])
|
}, [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) => {
|
const loadWorldBook = (character: Character) => {
|
||||||
if (!character.characterBook) {
|
if (!character.characterBook) {
|
||||||
setWorldBookEntries([])
|
setWorldBookEntries([])
|
||||||
@@ -187,6 +208,22 @@ export default function CharacterManagePage() {
|
|||||||
entries: worldBookEntries
|
entries: worldBookEntries
|
||||||
} : null
|
} : 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 = {
|
const updateData = {
|
||||||
name: formData.get('name') as string,
|
name: formData.get('name') as string,
|
||||||
description: formData.get('description') 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),
|
tags: (formData.get('tags') as string).split(',').map(t => t.trim()).filter(Boolean),
|
||||||
isPublic: formData.get('isPublic') === 'on',
|
isPublic: formData.get('isPublic') === 'on',
|
||||||
characterBook: characterBook,
|
characterBook: characterBook,
|
||||||
|
extensions: baseExtensions,
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -354,6 +392,7 @@ export default function CharacterManagePage() {
|
|||||||
setShowEditModal(true)
|
setShowEditModal(true)
|
||||||
loadWorldBook(char)
|
loadWorldBook(char)
|
||||||
loadRegexScripts(char.id)
|
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"
|
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>
|
<p className="text-xs text-white/40 mt-1">公开后其他用户可以在角色广场看到并使用此角色卡</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowWorldBookEditor(true)}
|
onClick={() => setShowWorldBookEditor(true)}
|
||||||
@@ -576,6 +615,18 @@ export default function CharacterManagePage() {
|
|||||||
<Code2 className="w-4 h-4" />
|
<Code2 className="w-4 h-4" />
|
||||||
正则脚本 ({regexScripts.length})
|
正则脚本 ({regexScripts.length})
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -1206,6 +1257,161 @@ export default function CharacterManagePage() {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user