🎨 1.优化前端渲染功能(html和对话消息格式)
2.优化流式传输,新增流式渲染功能 3.优化正则处理逻辑 4.新增context budget管理系统 5.优化对话消息失败处理逻辑 6.新增前端卡功能(待完整测试)
This commit is contained in:
@@ -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 壳**。
|
||||
|
||||
Reference in New Issue
Block a user