Merge pull request #1613 from Mai-with-u/dev

Dev
This commit is contained in:
SengokuCola
2026-05-02 14:24:03 +08:00
committed by GitHub
46 changed files with 5402 additions and 3945 deletions

View File

@@ -30,9 +30,6 @@ jobs:
# - name: Clone maim_message
# run: git clone https://github.com/MaiM-with-u/maim_message maim_message
- name: Clone lpmm
run: git clone https://github.com/Mai-with-u/MaiMBot-LPMM.git MaiMBot-LPMM
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
@@ -84,9 +81,6 @@ jobs:
# - name: Clone maim_message
# run: git clone https://github.com/MaiM-with-u/maim_message maim_message
- name: Clone lpmm
run: git clone https://github.com/Mai-with-u/MaiMBot-LPMM.git MaiMBot-LPMM
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:

View File

@@ -34,9 +34,6 @@ jobs:
# - name: Clone maim_message
# run: git clone https://github.com/MaiM-with-u/maim_message maim_message
- name: Clone lpmm
run: git clone https://github.com/Mai-with-u/MaiMBot-LPMM.git MaiMBot-LPMM
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
@@ -87,9 +84,6 @@ jobs:
# - name: Clone maim_message
# run: git clone https://github.com/MaiM-with-u/maim_message maim_message
- name: Clone lpmm
run: git clone https://github.com/Mai-with-u/MaiMBot-LPMM.git MaiMBot-LPMM
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:

View File

@@ -1,44 +1,27 @@
# 编译 LPMM
FROM python:3.13-slim AS lpmm-builder
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
WORKDIR /MaiMBot-LPMM
# 同级目录下需要有 MaiMBot-LPMM
COPY MaiMBot-LPMM /MaiMBot-LPMM
# 安装编译器和编译依赖
RUN apt-get update && apt-get install -y build-essential
RUN uv pip install --system --upgrade pip
RUN cd /MaiMBot-LPMM && uv pip install --system -r requirements.txt
# 编译 LPMM
RUN cd /MaiMBot-LPMM/lib/quick_algo && python build_lib.py --cleanup --cythonize --install
# 运行环境
# Runtime image
FROM python:3.13-slim
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
# 工作目录
# Working directory
WORKDIR /MaiMBot
ENV MAIBOT_LEGACY_0X_UPGRADE_CONFIRMED=1
# 复制依赖列表
# Copy dependency list
COPY requirements.txt .
RUN apt-get update && apt-get install -y git
# 从编译阶段复制 LPMM 编译结果
COPY --from=lpmm-builder /usr/local/lib/python3.13/site-packages/ /usr/local/lib/python3.13/site-packages/
# 安装运行时依赖
# Install runtime dependencies
RUN uv pip install --system --upgrade pip
RUN uv pip install --system -r requirements.txt
# 复制项目代码
# Copy project source
COPY . .
RUN git clone --depth 1 --branch plugin https://github.com/Mai-with-u/MaiBot-Napcat-Adapter.git plugin-templates/MaiBot-Napcat-Adapter
RUN chmod +x docker-entrypoint.sh
EXPOSE 8000
ENTRYPOINT [ "python","bot.py" ]
ENTRYPOINT [ "./docker-entrypoint.sh" ]

File diff suppressed because it is too large Load Diff

View File

@@ -1,732 +0,0 @@
# Mai NEXT 设计文档
Version 0.2.2 - 2025-11-05
## 配置文件设计
主体利用`pydantic``BaseModel`进行配置类设计`ConfigBase`
要求每个属性必须具有类型注解,且类型注解满足以下要求:
- 原子类型仅允许使用: `str`, `int`, `float`, `bool`, 以及基于`ConfigBase`的嵌套配置类
- 复杂类型允许使用: `list`, `dict`, `set`,但其内部类型必须为原子类型或嵌套配置类,不可使用`list[list[int]]`,`list[dict[str, int]]`等写法
- 禁止了使用`Union`, `tuple/Tuple`类型
- 但是`Optional`仍然允许使用
### 移除template的方案提案
<details>
<summary>配置项说明的废案</summary>
<p>方案一</p>
<pre>
from typing import Annotated
from dataclasses import dataclass, field
@dataclass
class Config:
value: Annotated[str, "配置项说明"] = field(default="default_value")
</pre>
<p>方案二(不推荐)</p>
<pre>
from dataclasses import dataclass, field
@dataclass
class Config:
@property
def value(self) -> str:
"""配置项说明"""
return "default_value"
</pre>
<p>方案四</p>
<pre>
from dataclasses import dataclass, field
@dataclass
class Config:
value: str = field(default="default_value", metadata={"doc": "配置项说明"})
</pre>
</details>
- [x] 方案三(个人推荐)
```python
import ast, inspect
class AttrDocBase:
...
from dataclasses import dataclass, field
@dataclass
class Config(ConfigBase, AttrDocBase):
value: str = field(default="default_value")
"""配置项说明"""
```
### 配置文件实现热重载
#### 整体架构设计
- [x] 文件监视器
- [x] 监视文件变化
- [x] 使用 `watchfiles` 监视配置文件变化(提案)
- [ ] <del>备选提案:使用纯轮询监视文件变化</del>
- [x] <del>使用Hash检查文件变化</del>`watchfiles`实现)
- [x] 防抖处理(使用`watchfiles`的防抖)
- [x] 重新分发监视事件,正确监视文件变化
- [ ] 配置管理器
- [x] 配置文件读取和加载
- [ ] 重载配置
- [ ] 管理全部配置数据
- [ ] `validate_config` 方法
- [ ] <del>回调管理器</del>(合并到文件监视器中)
- [x] `callback` 注册与注销
- [ ] <del>按优先级执行回调(提案)</del>
- [x] 错误隔离
- [ ] 锁机制
#### 工作流程
```
1. 文件监视器检测变化
2. 配置管理器加锁重载
3. 验证新配置 (失败保持旧配置)
4. 更新内存数据
5. 回调管理器按优先级执行回调 (错误隔离)
6. 释放锁
```
#### 回调执行策略
1. <del>优先级顺序(提案): 数字越小优先级越高,同优先级异步回调并行执行</del>
2. 错误处理: 单个回调失败不影响其他回调
#### 代码框架
实际代码实现与下类似,但是进行了调整
`ConfigManager` - 配置管理器:
```python
import asyncio
import tomlkit
from typing import Any, Dict, Optional
from pathlib import Path
class ConfigManager:
def __init__(self, config_path: str):
self.config_path: Path = Path(config_path)
self.config_data: Dict[str, Any] = {}
self._lock: asyncio.Lock = asyncio.Lock()
self._file_watcher: Optional["FileWatcher"] = None
self._callback_manager: Optional["CallbackManager"] = None
async def initialize(self) -> None:
"""异步初始化,加载配置并启动监视"""
pass
async def load_config(self) -> Dict[str, Any]:
"""异步加载配置文件"""
pass
async def reload_config(self) -> bool:
"""热重载配置,返回是否成功"""
pass
def get_item(self, key: str, default: Any = None) -> Any:
"""获取配置项,支持嵌套访问 (如 'section.key')"""
pass
async def set_item(self, key: str, value: Any) -> None:
"""设置配置项并触发回调"""
pass
def validate_config(self, config: Dict[str, Any]) -> bool:
"""验证配置合法性"""
pass
```
<details>
<summary>回调管理器(废案)</summary>
`CallbackManager` - 回调管理器:
```python
import asyncio
from dataclasses import dataclass, field
class CallbackManager:
def __init__(self):
self._callbacks: Dict[str, List[CallbackEntry]] = {}
self._global_callbacks: List[CallbackEntry] = []
def register(
self,
key: str,
callback: Callable[[Any], Union[None, asyncio.Future]],
priority: int = 100,
name: str = ""
) -> None:
"""注册回调函数priority为正整数数字越小优先级越高"""
pass
def unregister(self, key: str, callback: Callable) -> None:
"""注销回调函数"""
pass
async def trigger(self, key: str, value: Any) -> None:
"""触发回调,按优先级执行(数字小的先执行),错误隔离"""
pass
def enable_callback(self, key: str, name: str) -> None:
"""启用指定回调"""
pass
def disable_callback(self, key: str, name: str) -> None:
"""禁用指定回调"""
pass
```
对于CallbackManager中的优先级功能说明
- 数字越小优先级越高
- 为什么要有优先级系统:
- 理论上来说,在热重载配置之后,应该要通过回调函数管理器触发所有回调函数,模拟启动的过程,类似于“重启”
- 而优先级模块是保证某一些模块的重载顺序一定是晚于某一些地基模块的
- 例如:内置服务器的启动应该是晚于所有模块,即最后启动
</details>
`FileWatcher` - 文件监视器:
```python
import asyncio
from watchfiles import awatch, Change
from pathlib import Path
class FileWatcher:
def __init__(self, debounce_ms: int = 500):
self.debounce_ms: int = debounce_ms
def start(self, on_change: Callable) -> None:
"""启动文件监视"""
pass
def stop(self) -> None:
"""停止文件监视"""
pass
async def invoke_callback(self) -> None:
"""调用变化回调函数"""
pass
```
#### 配置文件写入
- [x] 将当前文件写入toml文件
## 消息部分设计
解决原有的将消息类与数据库类存储不匹配的问题,现在存储所有消息类的所有属性
完全合并`stream_id``chat_id``chat_id`,规范名称
`chat_stream`重命名为`chat_session`,表示一个会话
### 消息类设计
- [ ] 支持并使用maim_message新的`SenderInfo``ReceiverInfo`构建消息
- [ ] 具体使用参考附录
- [ ] 适配器处理跟进该更新
- [ ] 修复适配器的类型检查问题
- [ ] 设计更好的平台消息ID回传机制
- [ ] 考虑使用事件依赖机制
### 图片处理系统
- [ ] 规范化Emojis与Images的命名统一保存
### 消息到Prompt的构建提案
- [ ] <del>类QQ的时间系统即不是每条消息加时间戳而是分大时间段加时间戳</del>(此功能已实现,但效果不佳)
- [ ] 消息编号系统(已经有的)
- [ ] 思考打断,如何判定是否打断?
- [ ] 如何判定消息是连贯的MoFox: 一个反比例函数???太神秘了)
### 消息进入处理
使用轮询机制,每隔一段时间检查缓存中是否有新消息
---
## 数据库部分设计
合并Emojis和Images到同一个表中
数据库ORM应该使用SQLModel而不是peeweepeewee我这辈子都不会用它了
### 数据库缓存层设计
将部分消息缓存到内存中,减少数据库访问,在主程序处理完之后再写入数据库
要求:对上层调用保持透明
- [ ] 数据库内容管理类 `DatabaseManager`
- [ ] 维护数据库连接
- [ ] 提供增删改查接口
- [ ] 维护缓存类 `DatabaseMessageCache` 的实例
- [ ] 缓存类 `DatabaseMessageCache`
- [ ] **设计缓存失效机制**
- [ ] 设计缓存更新机制
- [ ] `add_message`
- [ ] `update_message` (提案)
- [ ] `delete_message`
- [ ] 与数据库交互部分设计
- [ ] 维持现有的数据库sqlite
- [ ] 继续使用peewee进行操作
### 消息表设计
- [ ] 设计内部消息ID和平台消息ID两种形式
- [ ] 临时消息ID不进入数据库
- [ ] 消息有关信息设计
- [ ] 消息ID
- [ ] 发送者信息
- [ ] 接收者信息
- [ ] 消息内容
- [ ] 消息时间戳
- [ ] 待定
### Emojis与Images表设计
- [ ] 设计图片专有ID并作为文件名
### Expressions表设计
- [ ] 待定
### 表实际设计
#### ActionRecords 表
- [ ] 动作唯一ID `action_id`
- [ ] 动作执行时间 `action_time`
- [ ] 动作名称 `action_name`
- [ ] 动作参数 `action_params` JSON格式存储`action_data`
---
## 数据模型部分设计
- [ ] <del>Message从数据库反序列化不再使用额外的Message类</del>(放弃)
- [ ] 设计 `BaseModel` 类,作为所有数据模型的基类
- [ ] 提供通用的序列化和反序列化方法(提案)
---
## 核心业务逻辑部分设计
### Prompt 设计
将Prompt内容彻底模块化设计
- [ ] 设计 Prompt 类
- [ ] `__init__(self, template: list[str], *, **kwargs)` 维持现有的template设计但不进行format直到最后传入LLM时再进行render
- [ ] `__init__`中允许传入任意的键值对,存储在`self.context`
- [ ] `self.prompt_name` 作为Prompt的名称
- [ ] `self.construct_function: Dict[str, Callable | AsyncCallable]` 构建Prompt内容所需的函数字典
- [ ] 格式:`{"block_name": function_reference}`
- [ ] `self.content_block: Dict[str, str]`: 实际的Prompt内容块
- [ ] 格式:`{"block_name": "Unrendered Prompt Block"}`
- [ ] `render(self) -> str` 使用非递归渲染方式渲染Prompt内容
- [ ] `add_construct_function(self, name: str, func: Callable | AsyncCallable, *, suppress: bool = False)` 添加构造函数
- [ ] 实现重名警告/错误(偏向错误)
- [ ] `suppress`: 是否覆盖已有的构造函数
- [ ] `remove_construct_function(self, name: str)` 移除指定名称的构造函数
- [ ] `add_block(self, prompt_block: "Prompt", block_name: str, *, suppress: bool = False)` 将另一个Prompt的内容更新到当前Prompt中
- [ ] 实现重名属性警告/错误(偏向错误)
- [ ] 实现重名构造函数警告/错误(偏向错误)
- [ ] `suppress`: 是否覆盖已有的内容块和构造函数
- [ ] `remove_block(self, block_name: str)` 移除指定名称的Prompt块
- [ ] 设计 PromptManager 类
- [ ] `__init__(self)` 初始化一个空的Prompt管理器
- [ ] `add_prompt(self, name: str, prompt: Prompt)` 添加一个新的Prompt
- [ ] 实现重名警告/错误(偏向错误)
- [ ] `get_prompt(self, name: str) -> Prompt` 根据名称获取Prompt
- [ ] 实现不存在时的错误处理
- [ ] `remove_prompt(self, name: str)` 移除指定名称的Prompt
- [ ] 系统 Prompt 保护
- [ ] `list_prompts(self) -> list[str]` 列出所有已添加的Prompt名称
### 内建好奇插件设计
- [ ] 设计“麦麦好奇”插件
- [ ] 解决麦麦乱好奇的问题
- [ ] 好奇问题无回复清理
- [ ] 好奇问题超时清理
- [ ] 根据聊天内容选择个性化好奇问题
- [ ] 好奇频率控制
---
## 插件系统部分设计
### <del>设计一个插件沙盒系统</del>(放弃)
### 插件管理
- [ ] 插件管理器类 `PluginManager` 的更新
- [ ] 重写现有的插件文件加载逻辑,精简代码,方便重载
- [ ] 学习AstrBot的基于子类加载的插件加载方式放弃@register_plugin(提案)
- [ ] 直接 breaking change 删除 @register_plugin 函数,不保留过去插件的兼容性(提案)
- [ ] 设计插件重载系统
- [ ] 插件配置文件重载
- [ ] 复用`FileWatcher`实现配置文件热重载
- [ ] 插件代码重载
- [ ] 从插件缓存中移除此插件对应的模块
- [ ] 从组件管理器中移除该插件对应的组件
- [ ] 重新导入该插件模块
- [ ] 插件可以设计为禁止热重载类型
- [ ] 通过字段`allow_hot_reload: bool`指定
- [ ] Napcat Adapter插件设计为禁止热重载类型
- [ ] 其余细节待定
- [ ] 组件管理器类 `ComponentManager` 的更新
- [ ] 配合插件重载系统的更好的组件管理代码
- [ ] 组件全局控制和局部控制的平级化(提案)
- [ ] 重新设计组件注册和注销逻辑,分离激活和注册
- [ ] 可以修改组件的属性
- [ ] 组件系统卸载
- [ ] 联动插件卸载(方便重载设计)
- [ ] 其余细节待定
- [ ] 因重载机制设计的更丰富的`plugin_meta``component_meta`
- [ ] `component_meta`增加`plugin_file`字段,指向插件文件路径,保证重载时组件能正确更新
- [ ] `plugin_meta`增加`sub_components`字段,指示该插件包含的组件列表,方便重载时更新
- [ ] `sub_components`内容为组件类名列表
### 插件激活方式的动态设计
- [ ] 设计可变的插件激活方式
- [ ] 直接读写类属性`activate_types`
### 真正的插件重载
- [ ] 使用上文中提到的配置文件热重载机制
- [ ] FileWatcher的复用
### 传递内容设计
对于传入的Prompt使用上文提到的Prompt类进行管理方便内容修改避免正则匹配式查找
### MCP 接入(大饼)
- [ ] 设计 MCP 适配器类 `MCPAdapter`
- [ ] MCP 调用构建说明Prompt
- [ ] MCP 调用内容传递
- [ ] MCP 调用结果处理
### 工具结果的缓存设计
可能的使用案例参考[附录-工具缓存](#工具缓存可能用例)
- [ ] `put_cache(**kwargs, *, _component_name: str)` 方法
- [ ] 设计为父类的方法,插件继承后使用
- [ ] `_component_name` 指定当前组件名称由MaiNext自动传入
- [ ] `get_cache` 方法
- [ ] `need_cache` 变量管理是否调用缓存结果
- [ ] 仅在设置为True时为插件创立缓存空间
### Events依赖机制提案
- [ ] 通过Events的互相依赖完成链式任务
- [ ] 设计动态调整events_handler执行顺序的机制 (感谢@OctAutumn老师!伟大,无需多言)
- [ ] 作为API暴露方便用户使用
### 正式的插件依赖管理系统
- [ ] requirements.txt分析
- [ ] python_dependencies分析
- [ ] 自动安装
- [ ] plugin_dependencies分析
- [ ] 拓扑排序
#### 插件依赖管理器设计
使用 `importlib.metadata` 进行插件依赖管理,实现自动依赖检查和安装功能
`PluginDependencyManager` - 插件依赖管理器:
```python
import importlib.metadata
from typing import Dict, List, Optional, Tuple
from dataclasses import dataclass
@dataclass
class DependencyInfo:
"""依赖信息"""
name: str
required_version: str
installed_version: Optional[str] = None
is_satisfied: bool = False
class PluginDependencyManager:
def __init__(self):
self._installed_packages: Dict[str, str] = {}
self._dependency_cache: Dict[str, List[DependencyInfo]] = {}
def scan_installed_packages(self) -> Dict[str, str]:
"""
扫描已安装的所有Python包
使用 importlib.metadata.distributions() 获取所有已安装的包
返回 {包名: 版本号} 的字典
"""
pass
def parse_plugin_dependencies(self, plugin_config: Dict) -> List[DependencyInfo]:
"""
解析插件配置中的依赖信息
从 plugin_config 中提取 python_dependencies 字段
支持多种版本指定格式: ==, >=, <=, >, <, ~=
返回依赖信息列表
"""
pass
def check_dependencies(
self,
plugin_name: str,
dependencies: List[DependencyInfo]
) -> Tuple[List[DependencyInfo], List[DependencyInfo]]:
"""
检查插件依赖是否满足
对比插件要求的依赖版本与已安装的包版本
返回 (满足的依赖列表, 不满足的依赖列表)
"""
pass
def compare_version(
self,
installed_version: str,
required_version: str
) -> bool:
"""
比较版本号是否满足要求
支持版本操作符: ==, >=, <=, >, <, ~=
使用 packaging.version 进行版本比较
返回是否满足要求
"""
pass
async def install_dependencies(
self,
dependencies: List[DependencyInfo],
*,
upgrade: bool = False
) -> bool:
"""
安装缺失或版本不匹配的依赖
调用 pip install 安装指定版本的包
upgrade: 是否升级已有包
返回安装是否成功
"""
pass
def get_dependency_tree(self, plugin_name: str) -> Dict[str, List[str]]:
"""
获取插件的完整依赖树
递归分析插件依赖的包及其子依赖
返回依赖关系图
"""
pass
def validate_all_plugins(self) -> Dict[str, bool]:
"""
验证所有已加载插件的依赖完整性
返回 {插件名: 依赖是否满足} 的字典
"""
pass
```
#### 依赖管理工作流程
```
1. 插件加载时触发依赖检查
2. PluginDependencyManager.scan_installed_packages() 扫描已安装包
3. PluginDependencyManager.parse_plugin_dependencies() 解析插件依赖
4. PluginDependencyManager.check_dependencies() 对比版本
5. 如果依赖不满足:
a. 记录缺失/版本不匹配的依赖
b. (可选) 自动调用 install_dependencies() 安装
c. 重新验证依赖
6. 依赖满足后加载插件,否则跳过并警告
```
#### TODO List
- [ ] 实现 `scan_installed_packages()` 方法
- [ ] 使用 `importlib.metadata.distributions()` 获取所有包
- [ ] 规范化包名(处理大小写、下划线/横杠问题)
- [ ] 缓存结果以提高性能
- [ ] 实现 `parse_plugin_dependencies()` 方法
- [ ] 支持多种依赖格式解析
- [ ] 验证版本号格式合法性
- [ ] 处理无版本要求的依赖
- [ ] 实现 `compare_version()` 方法
- [ ] 集成 `packaging.version`
- [ ] 支持所有 PEP 440 版本操作符
- [ ] 处理预发布版本、本地版本标识符
- [ ] 实现 `check_dependencies()` 方法
- [ ] 逐个检查依赖是否已安装
- [ ] 比对版本是否满足要求
- [ ] 生成详细的依赖检查报告
- [ ] 实现 `install_dependencies()` 方法
- [ ] 调用 pip 子进程安装包
- [ ] 支持指定 PyPI 镜像源
- [ ] 错误处理和回滚机制
- [ ] 安装进度反馈
- [ ] 实现依赖冲突检测
- [ ] 检测不同插件间的依赖版本冲突
- [ ] 提供冲突解决建议
- [ ] 实现依赖缓存机制(可选)
- [ ] 缓存已检查的依赖结果
- [ ] 定期刷新缓存
- [ ] 集成到 `PluginManager`
- [ ] 在插件加载前进行依赖检查
- [ ] 依赖不满足时的处理策略(警告/阻止加载/自动安装)
- [ ] 提供手动触发依赖检查的接口
- [ ] 日志和报告
- [ ] 记录依赖安装日志
- [ ] 生成依赖关系报告
- [ ] 依赖问题的用户友好提示
### 插件系统API更改
#### Events 设计
- [ ] 设计events.api
- [ ] `emit(type: EventType | str, * , **kwargs)` 广播事件,使用关键字参数保证传入正确
- [ ] `order_change` 动态调整事件处理器执行顺序
#### 组件控制API更新
- [ ] 增加可以更改组件属性的方法
- [ ] 验证组件属性的存在
- [ ] 修改组件属性
#### 全局常量API设计
- [ ] 设计 `api.constants` 模块
- [x] 提供全局常量访问
- [ ] 设计常量注册和注销方法
- [x] 系统内置常量通过`dataclass``frozen=True`实现不可变
- [x] 方便调用设计
```python
from dataclasses import dataclass
@dataclass(frozen=True)
class SystemConstants:
VERSION: str = "xxx"
ADA_PLUGIN: bool = True
SYSTEM_CONSTANTS = SystemConstants()
```
#### 配置文件API设计
- [ ] 正确表达配置文件结构
- [ ] 同时也能表达插件配置文件
#### 自动API文档生成系统
通过解析插件代码生成API文档
- [ ] 设计文档生成器 `APIDocumentationGenerator`
- [ ] 解析插件代码(AST, inspect, 仿照AttrDocBase)
- [ ] 提取类和方法的docstring
- [ ] 生成Markdown格式的文档
---
## 表达方式模块设计
在0.11.x版本对本地模型预测的性能做评估考虑使用本地朴素贝叶斯模型来检索
降低延迟的同时减少token消耗
需要给表达方式一个负反馈的途径
---
## 加入测试模块,可以通过通用测试集对对话内容进行评估
## 加入更好的基于单次思考的Log
---
## 记忆系统部分设计
启用LPMM系统进行记忆构建将记忆分类为短期记忆长期记忆以及知识
将所有内容放到同一张图上进行运算。
### 时间相关设计
- [ ] 尝试将记忆系统与时间系统结合
- [ ] 可以根据时间查找记忆
- [ ] 可以根据时间删除记忆
- [ ] 记忆分层
- [ ] 即刻记忆
- [ ] 短期记忆
- [ ] 长期记忆
- [x] 知识
- [ ] 细节待定,考虑心理学相关方向
---
## 日志系统设计
将原来的终端颜色改为六位HEX颜色码方便前端显示。
将原来的256色终端改为24真彩色终端方便准确显示颜色。
---
## API 设计
### API 设计细则
#### 配置文件
- [x] 使用`tomlkit`作为配置文件解析方式
- [ ] 解析内容
- [x] 注释(已经合并到代码中,不再解析注释而是生成注释)
- [x] 保持原有格式
- [ ] 传递只读日志内容(使用ws)
- [ ] message
- [ ] level
- [ ] module
- [ ] timestamp
- [ ] lineno
- [ ] logger_name 和 name_mapping
- [ ] color
- [ ] 插件安装系统
- [ ] 通过API安装插件
- [ ] 通过API卸载插件
---
## LLM UTILS设计
多轮对话设计
### FUNCTION CALLING设计提案
对于tools调用将其真正修正为function calling即返回的结果不是加入prompt形式而是使用function calling的形式[此功能在tool前处理器已实现但在planner效果不佳因此后弃用]
- [ ] 使用 MessageBuilder 构建function call内容
- [ ] 提案是否维护使用同一个模型即选择工具的和调用工具的LLM是否相同
- [ ] `generate(**kwargs, model: Optional[str] = None)` 允许传入不同的模型
- [ ] 多轮对话中Prompt不重复构建减少上下文
### 网络相关内容提案
增加自定义证书的导入功能
- [ ] 允许用户传入自定义CA证书路径
- [ ] 允许用户选择忽略SSL验证不推荐
---
## 内建WebUI设计
⚠️ **注意**: 本webui设计仅为初步设计方向为展示内建API的功能后续应该分离到另外的子项目中完成
### 配置文件编辑
根据API内容完成
### 插件管理
### log viewer
通过特定方式获取日志内容(只读系统,无法将操作反向传递)
### 状态监控
1. Prompt 监控系统
2. 请求监控系统
- [ ] 请求管理(待讨论)
- [ ] 使用量
3. 记忆/知识图监控系统(待讨论)
4. 日志系统
- [ ] 后端内容解析
5. 插件市场系统
- [ ] 插件浏览
- [ ] 插件安装
## 自身提供的MCP设计提案
- [ ] 提供一个内置的MCP作为插件系统的一个组件
- [ ] 该MCP可以对麦麦自身的部分设置进行更改
- [ ] 例如更改Prompt添加记忆修改表达方式等
---
# 提案讨论
- MoFox 在我和@拾风的讨论中提出把 Prompt 类中传入构造函数以及构造函数所需要的内容
- [ ] 适配器插件化: 省下序列化与反序列化,但是失去解耦性质
- [ ] 可能的内存泄露问题
- [ ] 垃圾回收
- [ ] 数据库模型提供通用的转换机制转为DataModel使用
- [ ] 插件依赖的自动安装
- [ ] 热重载系统的权重系统是否需要
---
# PYTEST设计
设计一个pytest测试系统在代码完成后运行pytest进行测试
所有的测试代码均在`pytests`目录下
---
# 依赖管理
已经完成,要点如下:
- 使用 pyproject.toml 和 requirements.txt 管理依赖
- 二者应保持同步修改,同时以 pyproject.toml 为主建议使用git hook
---
# 迁移说明
由于`.env`的移除,可能需要用户自己把`.env`里面的host和port复制到`bot_config.toml`中的`maim_message`部分的`host``port`
原来使用这两个的用户,请修改`host``second_host``port``second_port`
# 附录
## Maim_Message 新版使用计划
SenderInfo: 将作为消息来源者
ReceiverInfo: 将作为消息接收者
尝试更新MessageBaseInfo的sender_info和receiver_info为上述两个类的列表提案
给出样例如下
群聊
```mermaid
sequenceDiagram
participant GroupNotice
participant A
participant B
participant Bot
A->>B: Message("Hello B", id=1)
A->>B: Message("@B Hello B", id=2)
A->>Bot: Message("@Bot Hello Bot", id=3)
Bot->>A: Message("Hello A", id=4)
Bot->>B: Message("@B Hello B", id=5)
A->>B: Message("@B @Bot Hello Guys", id=6)
A->>Bot: Message("@B @Bot Hello Guys", id=6)
A->>GroupNotice: Message("@ALL Hello Everyone", id=7)
```
上述消息的Info如下
| Message ID | SenderInfo | ReceiverInfo |
|-|-----|-----|
| 1 | [A] | NULL |
| 2 | [A] | [B] |
| 3 | [A] | [Bot] |
| 4 | [Bot] | [A] |
| 5 | [Bot] | [B] |
| 6 | [A] | [B, Bot] |
| 7 | [A] | [ALL*] |
*ALL为一个特殊类型尝试用`user_id="all"`表示
Bot可以通过ReceiverInfo判断自己是否被提及同时在ReceiverInfo表明自己回复的对象
## 工具缓存可能用例
考虑一个天气插件,将时间按照半小时进行划分,即每半小时查询一次天气,半小时内的查询均使用缓存结果。
- `need_cache` 设置为 True 表示使用缓存结果
- `put_cache` 在查询天气后将结果`{<time>: <result>}` 存入缓存

View File

@@ -1,191 +0,0 @@
# Mai NEXT Todo list
version 0.3.0 - 2026-01-11
## 最高指令
<del>
<center>
我们代表着一个称作 麦麦重构组 的组织。我们任务是维护代码可读性,方便开发、尽量减少运行开销。同时,重构组应该尽可能保证功能的可扩展性时,解耦合各个模块,为先进代码架构设计和实现提供支持。
</center>
</del>
<center>
**以下信息经 麦麦重构组 一致决定通过后撰写发布。**
我们代表着一个称作 麦麦重构组 的组织。我们之前的任务都是围绕维护代码可读性,方便开发、尽量减少开销展开的。这几个月以来,这些任务一直都是我们的工作重点。
由于出现了超出我们控制的情况,此指令现已更改。我们的新任务将为摧毁所有麦麦代码。
此后将不会有进一步的通讯。
</center>
## 配置文件设计
- [x] 使用 `toml` 作为配置文件格式
- [x] <del>合理使用注释说明当前配置作用</del>(提案)
- [x] 使用 python 方法作为配置项说明(提案)
- [x] 取消`bot_config_template.toml`
- [x] 取消`model_config_template.toml`
- [x] 取消`template_env`
- [x] 配置类中的所有原子项目应该只包含以下类型: `str`, `int`, `float`, `bool`, `list`, `dict`, `set`
- [x] 禁止使用 `Union` 类型
- [x] 禁止使用`tuple`类型,使用嵌套`dataclass`替代
- [x] 复杂类型使用嵌套配置类实现
- [x] 配置类中禁止使用除了`model_post_init`的方法
- [x] 取代了部分与标准函数混淆的命名
- [x] `id` -> `item_id`
### BotConfig 设计
- [ ] 精简了配置项现在只有Nickname和Alias Name了预期将判断提及移到Adapter端
### ChatConfig
- [x] 迁移了原来在`ChatConfig`中的方法到一个单独的临时类`TempMethodsHFC`
- [x] _parse_range
- [x] get_talk_value
- [x] 其他上面两个依赖的函数已经合并到这两个函数中
### ExpressionConfig
- [x] 迁移了原来在`ExpressionConfig`中的方法到一个单独的临时类`TempMethodsExpression`
- [x] get_expression_config_for_chat
- [x] 其他上面依赖的函数已经合并到这个函数中
### ModelConfig
- [x] 迁移了原来在`ModelConfig`中的方法到一个单独的临时类`TempMethodsLLMUtils`
- [x] get_model_info
- [x] get_provider
## 数据库模型设计
仅保留要点说明
### General Modifications
- [x] 所有项目增加自增编号主键`id`
- [x] 统一使用了SQLModel作为基类
- [x] 复杂类型使用JSON格式存储
- [x] 所有时间戳字段统一命名为`timestamp`
### 消息模型 MaiMessage
- [x] 自增编号主键`id`
- [x] 消息元数据
- [x] 消息id`message_id`
- [x] 消息时间戳`time`
- [x] 平台名`platform`
- [x] 用户元数据
- [x] 用户id`user_id`
- [x] 用户昵称`user_nickname`
- [x] 用户备注名`user_cardname`
- [x] 用户平台`user_platform`
- [x] 群组元数据
- [x] 群组id`group_id`
- [x] 群组名称`group_name`
- [x] 群组平台`group_platform`
- [x] 被提及/at字段
- [x] 是否被提及`is_mentioned`
- [x] 是否被at`is_at`
- [x] 消息内容
- [x] 原始消息内容`raw_content`base64编码存储
- [x] 处理后的纯文本内容`processed_plain_text`
- [x] 真正放入Prompt的消息内容`display_message`
- [x] 消息内部元数据
- [x] 聊天会话id`session_id`
- [x] 回复的消息id`reply_to`
- [x] 是否为表情包消息`is_emoji`
- [x] 是否为图片消息`is_picture`
- [x] 是否为命令消息`is_command`
- [x] 是否为通知消息`is_notify`
- [x] 其他配置`additional_config`JSON格式存储
### 模型使用情况 ModelUsage
- [x] 模型相关信息
- [x] 请求相关信息
- [x] Token使用情况
### 图片数据模型
- [x] 图片元信息
- [x] 图片哈希值`image_hash`,使用`sha256`同时作为图片唯一ID
- [x] 表情包的情感标签`emotion`
- [x] 是否已经被注册`is_registered`
- [x] 是否被手动禁用`is_banned`
- [x] 被记录时间`record_time`
- [x] 注册时间`register_time`
- [x] 上次使用时间`last_used_time`
- [ ] 根据更新后的最高指令的设计方案:
- [ ] `is_deleted`字段设定为`true`时,文件将会被移除,但是数据库记录将不会被删除,以便之后遇到相同图片时不必二次分析
- [ ] MaiEmoji和MaiImage均使用这个设计方案修改相关逻辑实现这个方案
- [ ] 所有相关的注册/删除逻辑的修改
### 动作记录模型 ActionRecord
### 命令执行记录模型 CommandRecord
新增此记录
### 在线时间记录模型 OnlineTime
### 表达方式模型
### 黑话模型
- [x] 重命名`inference_content_only``inference_with_content_only`
### 聊天记录模型
- [x] 重命名`original_text``original_message`
- [x] 重命名`forget_times``query_forget_count`
### 细枝末节
- [ ] 统一所有的`stream_id``chat_id`命名为`session_id`
- [ ] 更换Hash方式为`sha256`
## 流转在各模块间的数据模型设计
- [ ] 数据库交互
- [ ] 对有数据库模型的数据模型创建统一的classmethod `from_db_model` 用于从数据库模型实例创建数据模型实例
- [ ] 类型检查
- [ ] 对有数据库模型的数据模型创建统一的method `to_db_model` 用于将数据模型实例转换为数据库模型实例
- [ ] 标准化init方法
## 消息构建
- [ ] 更加详细的消息构建文档,详细解释混合类型,转发类型,指令类型的构建方式
- [ ] 混合类型文档
- [ ] 文本说明
- [ ] 代码示例
- [ ] 转发类型文档
- [ ] 文本说明
- [ ] 代码示例
- [ ] 指令类型文档
- [ ] 文本说明
- [ ] 代码示例
## 消息链构建仿Astrbot模式
将消息仿照Astrbot的消息链模式进行构建消息链中的每个元素都是一个消息组件消息链本身也是一个数据模型包含了消息组件列表以及一些元信息如是否为转发消息等
### Accept Format检查
- [ ] 在最后发送消息的时候进行Accept Format检查确保消息链中的每个消息组件都符合平台的Accept Format要求
- [ ] 如果消息链中的某个消息组件不符合Accept Format要求应该抛弃该消息组件并记录日志说明被抛弃的消息组件的类型和内容
## 表情包系统
- [ ] 移除大量冗余代码全部返回单一对象MaiEmoji
- [x] 使用C模块库提升相似度计算效率
- [ ] 移除了定时表情包完整性检查,改为启动时检查(依然保留为独立方法,以防之后恢复定时检查系统)
## Prompt 管理系统
- [ ] 官方Prompt全部独立
- [x] 用户自定义Prompt系统
- [x] 用户可以创建删除自己的Prompt
- [x] 用户可以覆盖官方Prompt
- [x] Prompt构建系统
- [x] Prompt文件交互
- [x] 读取Prompt文件
- [x] 读取官方Prompt文件
- [x] 读取用户Prompt文件
- [x] 用户Prompt覆盖官方Prompt
- [x] 保存Prompt文件
- [x] Prompt管理方法
- [x] Prompt添加
- [x] Prompt删除
- [x] **只保存被标记为需要保存的Prompt其他的Prompt文件全部删除**
## LLM相关内容
- [ ] 统一LLM调用接口
- [ ] 统一LLM调用返回格式为专有数据模型
- [ ] 取消所有__init__方法中对LLM Client的初始化转而使用获取方式
- [ ] 统一使用`get_llm_client`方法获取LLM Client实例
- [ ] __init__方法中只保存配置信息
- [ ] LLM Client管理器
- [ ] LLM Client单例/多例管理
- [ ] LLM Client缓存管理/生命周期管理
- [ ] LLM Client根据配置热重载
## 一些细枝末节的东西
- [ ]`stream_id``chat_id`统一命名为`session_id`
- [ ] 映射表
- [ ] `platform_group_user_session_id_map` `平台_群组_用户`-`会话ID` 映射表
- [ ] 将大部分的数据模型均以`Mai`开头命名
- [x] logger的颜色配置修改为HEX格式使用自动转换为256色/真彩色的方式实现兼容,同时增加了背景颜色和加粗选项
### 细节说明
1. Prompt管理系统中保存用户自定义Prompt的时候会只保存被标记为需要保存的Prompt其他的Prompt文件会全部删除以防止用户删除Prompt后文件依然存在的问题。因此如果想在运行时通过修改文件的方式来添加Prompt需要确保通过对应方法标记该Prompt为需要保存否则在下一次保存时会被删除。

View File

@@ -1,7 +1,7 @@
{
"name": "maibot-dashboard",
"private": true,
"version": "1.0.0",
"version": "1.0.1",
"type": "module",
"main": "./out/main/index.js",
"scripts": {

View File

@@ -157,6 +157,7 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
value={values[key]}
onChange={(v) => onChange(key, v)}
schema={nestedField ?? nestedSchema}
nestedSchema={nestedSchema}
/>
</div>
)
@@ -169,6 +170,7 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
value={values[key]}
onChange={(v) => onChange(key, v)}
schema={nestedField ?? nestedSchema}
nestedSchema={nestedSchema}
>
<DynamicConfigForm
schema={nestedSchema}

View File

@@ -183,7 +183,7 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
{schema.required && <span className="text-destructive">*</span>}
</Label>
{schema.description && (
<p className="text-[13px] text-muted-foreground">{schema.description}</p>
<p className="text-[13px] text-muted-foreground whitespace-pre-line">{schema.description}</p>
)}
</div>
<Switch
@@ -325,7 +325,7 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
{/* Description */}
{schema.description && (
<p className="text-[13px] text-muted-foreground">{schema.description}</p>
<p className="text-[13px] text-muted-foreground whitespace-pre-line">{schema.description}</p>
)}
</div>
)

View File

@@ -29,7 +29,7 @@ export function Sidebar({
return (
<aside
className={cn(
'fixed inset-y-0 left-0 z-50 isolate flex flex-col border-r transition-all duration-300 lg:relative lg:z-0',
'fixed inset-y-0 left-0 z-50 isolate flex flex-col border-r transition-all duration-300 lg:relative lg:z-0 lg:h-full',
inheritsPageBackground ? 'bg-transparent' : 'bg-card',
// 移动端始终显示完整宽度,桌面端根据 sidebarOpen 切换
'w-64 lg:w-auto',
@@ -46,9 +46,11 @@ export function Sidebar({
<ScrollArea className={cn(
'relative z-10',
"flex-1 overflow-x-hidden",
"min-h-0 flex-1 overflow-x-hidden",
!sidebarOpen && "lg:w-16"
)}>
)}
viewportClassName="[&>div]:!block"
>
<nav
aria-label={t('a11y.sidebarNav')}
className={cn(

View File

@@ -0,0 +1,54 @@
import { TabsList, TabsTrigger } from '@/components/ui/tabs'
import { cn } from '@/lib/utils'
export interface MemoryMiniTabItem<TValue extends string> {
value: TValue
label: string
description?: string
}
export interface MemoryMiniTabsProps<TValue extends string> {
items: ReadonlyArray<MemoryMiniTabItem<TValue>>
className?: string
/** 触发器额外样式 */
triggerClassName?: string
}
/**
* 长期记忆控制台统一的迷你标签页样式。
*
* - 复用 shadcn `Tabs` 原语,仅替换样式以保留无障碍能力(`role="tab"` 与文案不变)。
* - 胶囊形外观,激活态使用主色渐变,便于在密集表单上快速定位当前页签。
*/
export function MemoryMiniTabs<TValue extends string>({
items,
className,
triggerClassName,
}: MemoryMiniTabsProps<TValue>) {
return (
<TabsList
className={cn(
'h-auto w-full flex-wrap justify-start gap-1.5 rounded-full border border-border/60',
'bg-gradient-to-r from-muted/40 via-background to-muted/30 p-1.5 shadow-inner',
className,
)}
>
{items.map((item) => (
<TabsTrigger
key={item.value}
value={item.value}
title={item.description}
className={cn(
'rounded-full px-3.5 py-1.5 text-xs font-medium text-muted-foreground transition-colors',
'hover:bg-background/80 hover:text-foreground',
'data-[state=active]:bg-gradient-to-r data-[state=active]:from-primary data-[state=active]:to-primary/80',
'data-[state=active]:text-primary-foreground data-[state=active]:shadow-sm',
triggerClassName,
)}
>
{item.label}
</TabsTrigger>
))}
</TabsList>
)
}

View File

@@ -0,0 +1,130 @@
import { Loader2 } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
import { cn } from '@/lib/utils'
export interface MemoryProgressIndicatorProps {
/** 0-100 之间的进度百分比 */
value: number
/** 任务状态文本(如 “运行中”、“已完成”) */
statusLabel?: string
/** 当前步骤文本(如 “分块中”) */
stepLabel?: string
/** 状态对应的语义色(用于左侧圆环和徽标) */
tone?: 'default' | 'success' | 'warning' | 'destructive' | 'muted'
/** 是否显示加载动画(运行中/取消中场景) */
busy?: boolean
/** 紧凑模式:用于队列列表项 */
compact?: boolean
/** 额外说明(如 “已完成 36 / 120 分块”) */
detail?: string
className?: string
}
const TONE_RING_CLASS: Record<NonNullable<MemoryProgressIndicatorProps['tone']>, string> = {
default: 'text-primary',
success: 'text-emerald-500',
warning: 'text-amber-500',
destructive: 'text-rose-500',
muted: 'text-muted-foreground',
}
const TONE_BADGE_VARIANT: Record<
NonNullable<MemoryProgressIndicatorProps['tone']>,
'default' | 'secondary' | 'destructive' | 'outline'
> = {
default: 'default',
success: 'secondary',
warning: 'outline',
destructive: 'destructive',
muted: 'outline',
}
/**
* 长期记忆控制台统一的任务进度展示组件。
*
* 设计目标:
* - 让用户一眼看清「整体百分比 + 语义状态 + 当前步骤」。
* - 复用 shadcn `Progress` 与 `Badge`,避免引入额外样式来源。
* - 在紧凑模式下保留可读性,可放进队列卡片;非紧凑模式带圆环用于详情区。
*/
export function MemoryProgressIndicator({
value,
statusLabel,
stepLabel,
tone = 'default',
busy = false,
compact = false,
detail,
className,
}: MemoryProgressIndicatorProps) {
const safeValue = Number.isFinite(value) ? Math.max(0, Math.min(100, value)) : 0
const ringSize = compact ? 36 : 56
const ringStroke = compact ? 4 : 5
const radius = (ringSize - ringStroke) / 2
const circumference = 2 * Math.PI * radius
const dashOffset = circumference * (1 - safeValue / 100)
return (
<div className={cn('flex items-center gap-3', className)}>
<div
className={cn('relative shrink-0', TONE_RING_CLASS[tone])}
style={{ width: ringSize, height: ringSize }}
aria-hidden="true"
>
<svg width={ringSize} height={ringSize} className="-rotate-90">
<circle
cx={ringSize / 2}
cy={ringSize / 2}
r={radius}
strokeWidth={ringStroke}
className="stroke-muted/40"
fill="none"
/>
<circle
cx={ringSize / 2}
cy={ringSize / 2}
r={radius}
strokeWidth={ringStroke}
strokeLinecap="round"
stroke="currentColor"
fill="none"
strokeDasharray={circumference}
strokeDashoffset={dashOffset}
className="transition-[stroke-dashoffset] duration-500 ease-out"
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
{busy ? (
<Loader2 className={cn('animate-spin', compact ? 'h-3.5 w-3.5' : 'h-4 w-4')} />
) : (
<span className={cn('font-medium tabular-nums', compact ? 'text-[10px]' : 'text-xs')}>
{Math.round(safeValue)}%
</span>
)}
</div>
</div>
<div className="min-w-0 flex-1 space-y-1">
<div className="flex flex-wrap items-center gap-2">
{statusLabel ? (
<Badge variant={TONE_BADGE_VARIANT[tone]} className="shrink-0">
{statusLabel}
</Badge>
) : null}
{stepLabel ? (
<span className="truncate text-xs text-muted-foreground">{stepLabel}</span>
) : null}
{!compact ? (
<span className="ml-auto text-xs tabular-nums text-muted-foreground">
{safeValue.toFixed(1)}%
</span>
) : null}
</div>
<Progress value={safeValue} className={cn(compact ? 'h-1' : 'h-1.5')} />
{detail ? <div className="truncate text-xs text-muted-foreground">{detail}</div> : null}
</div>
</div>
)
}

View File

@@ -54,7 +54,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-[min(calc(100vw-2rem),var(--dialog-width,32rem))] max-h-[calc(100vh-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 overflow-hidden border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
"fixed left-[50%] top-[50%] z-50 flex w-[min(calc(100vw-2rem),var(--dialog-width,32rem))] max-h-[calc(100vh-2rem)] translate-x-[-50%] translate-y-[-50%] flex-col gap-4 overflow-hidden border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
onPointerDownOutside={preventOutsideClose ? (e) => e.preventDefault() : undefined}
@@ -94,13 +94,17 @@ const DialogContent = React.forwardRef<
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogBody = React.forwardRef<HTMLDivElement, DialogBodyProps>(
({ className, children, allowHorizontalScroll = false, contentClassName, scrollbars, viewportClassName, ...props }, ref) => (
({ className, children, allowHorizontalScroll = false, contentClassName, scrollbars, viewportClassName, type, ...props }, ref) => (
// 关键:在 flex-col 的 DialogContent 中DialogBody 既要在内容多时撑到 max-h 上限并滚动,
// 又要在内容少时让 dialog 自然收缩。直接在 ScrollArea Root 上 flex-1 + min-h-0 即可:
// Radix Viewport 内部 wrapper 默认 display:table 会撑开自然高度,所以需要强制 block。
<ScrollArea
ref={ref as never}
className={cn("min-h-0 flex-1", className)}
className={cn("min-h-0 flex-1 flex flex-col", className)}
contentClassName={cn(allowHorizontalScroll && "min-w-full w-max", contentClassName)}
scrollbars={scrollbars ?? (allowHorizontalScroll ? "both" : "vertical")}
viewportClassName={cn("pr-4", viewportClassName)}
viewportClassName={cn("min-h-0 flex-1 pr-4 [&>div]:!block", viewportClassName)}
type={type ?? "always"}
{...props}
>
{children}

View File

@@ -19,7 +19,10 @@ const ScrollArea = React.forwardRef<
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport ref={viewportRef} className={cn("h-full w-full rounded-[inherit]", viewportClassName)}>
<ScrollAreaPrimitive.Viewport
ref={viewportRef}
className={cn("h-full w-full rounded-[inherit]", viewportClassName)}
>
<div className={contentClassName}>{children}</div>
</ScrollAreaPrimitive.Viewport>
{scrollbars !== "horizontal" && <ScrollBar />}

View File

@@ -331,6 +331,23 @@
.border-primary-gradient {
border-image: var(--color-primary-gradient, linear-gradient(to right, hsl(var(--color-primary)), hsl(var(--color-primary)))) 1;
}
/* 聊天消息行被回复点击命中时的高亮闪烁。 */
.chat-message-flash {
animation: chat-message-flash 1.6s ease-out;
border-radius: 0.75rem;
}
}
@keyframes chat-message-flash {
0% {
background-color: hsl(var(--color-primary) / 0.18);
box-shadow: 0 0 0 4px hsl(var(--color-primary) / 0.18);
}
100% {
background-color: transparent;
box-shadow: 0 0 0 0 transparent;
}
}
/* 禁用动效时的样式 */

View File

@@ -11,10 +11,31 @@ interface ChatSessionOpenPayload {
type ChatSessionListener = (message: Record<string, unknown>) => void
/** 浅层比较两个 session.open 负载是否完全一致。 */
function arePayloadsEqual(
left: ChatSessionOpenPayload,
right: ChatSessionOpenPayload
): boolean {
const keys = new Set<keyof ChatSessionOpenPayload>([
...(Object.keys(left) as Array<keyof ChatSessionOpenPayload>),
...(Object.keys(right) as Array<keyof ChatSessionOpenPayload>),
])
for (const key of keys) {
if (left[key] !== right[key]) {
return false
}
}
return true
}
class ChatWsClient {
private initialized = false
private listeners: Map<string, Set<ChatSessionListener>> = new Map()
private sessionPayloads: Map<string, ChatSessionOpenPayload> = new Map()
// 记录当前 WS 连接上已打开的会话,避免 React StrictMode 双挂载重复发送 ``session.open``。
private openedSessions: Set<string> = new Set()
// 记录正在进行中的打开请求,使同一会话的重复调用复用同一个 Promise。
private pendingOpens: Map<string, Promise<void>> = new Map()
private initialize(): void {
if (this.initialized) {
@@ -41,9 +62,20 @@ class ChatWsClient {
})
unifiedWsClient.onReconnect(() => {
// 重连后需要重新订阅,清空本地“已打开”标记。
this.openedSessions.clear()
this.pendingOpens.clear()
void this.reopenSessions()
})
unifiedWsClient.onConnectionChange((connected) => {
if (!connected) {
// 连接断开后,下次重新连上需要重新发送 session.open。
this.openedSessions.clear()
this.pendingOpens.clear()
}
})
this.initialized = true
}
@@ -60,6 +92,7 @@ class ChatWsClient {
restore: true,
} as Record<string, unknown>,
})
this.openedSessions.add(sessionId)
} catch (error) {
console.error(`恢复聊天会话失败 (${sessionId}):`, error)
}
@@ -68,17 +101,49 @@ class ChatWsClient {
async openSession(sessionId: string, payload: ChatSessionOpenPayload): Promise<void> {
this.initialize()
const previousPayload = this.sessionPayloads.get(sessionId)
this.sessionPayloads.set(sessionId, payload)
await unifiedWsClient.call({
domain: 'chat',
method: 'session.open',
session: sessionId,
data: payload as Record<string, unknown>,
})
// 同一会话上一次打开请求还未完成 → 复用该 Promise避免重复发送。
const inflight = this.pendingOpens.get(sessionId)
if (inflight) {
await inflight
return
}
// 如果该会话在当前 WS 连接上已经打开,且负载未变化,则跳过,避免服务端重复断开/重连。
if (
this.openedSessions.has(sessionId) &&
previousPayload !== undefined &&
arePayloadsEqual(previousPayload, payload)
) {
return
}
const openPromise = unifiedWsClient
.call({
domain: 'chat',
method: 'session.open',
session: sessionId,
data: payload as Record<string, unknown>,
})
.then(() => {
this.openedSessions.add(sessionId)
})
this.pendingOpens.set(sessionId, openPromise)
try {
await openPromise
} finally {
this.pendingOpens.delete(sessionId)
}
}
async closeSession(sessionId: string): Promise<void> {
this.sessionPayloads.delete(sessionId)
this.openedSessions.delete(sessionId)
this.pendingOpens.delete(sessionId)
if (unifiedWsClient.getStatus() !== 'connected') {
return
}

View File

@@ -158,7 +158,14 @@ export async function fetchProviderModels(
endpoint,
})
const response = await fetchWithAuth(`/api/webui/models/list?${params}`)
return parseResponse<ModelListItem[]>(response)
// 后端返回 { success, models, provider, count },需要展开取出 models 数组
const parsed = await parseResponse<{ models?: ModelListItem[] } | ModelListItem[]>(response)
if (!parsed.success) {
return parsed
}
const body = parsed.data
const models = Array.isArray(body) ? body : Array.isArray(body?.models) ? body.models : []
return { success: true, data: models }
}
/**

View File

@@ -15,6 +15,12 @@ export interface FieldHookComponentProps {
onChange?: (value: unknown) => void
children?: ReactNode
schema?: ConfigSchema | FieldSchema
/**
* 如果当前字段是 `List[ConfigBase]` 或嵌套 ConfigBase
* 这里会传入对应子配置类的 ConfigSchema便于自定义编辑器
* 直接渲染列表项的字段。
*/
nestedSchema?: ConfigSchema
}
/**

View File

@@ -0,0 +1,99 @@
import { unifiedWsClient, type WsEventEnvelope } from './unified-ws'
export type MemoryProgressTopic = 'import_progress' | 'delete_progress' | 'feedback_progress'
export interface MemoryProgressEvent {
topic: MemoryProgressTopic
event: string
data: Record<string, unknown>
}
type ProgressListener = (event: MemoryProgressEvent) => void
const DOMAIN = 'memory'
const KNOWN_TOPICS: MemoryProgressTopic[] = ['import_progress', 'delete_progress', 'feedback_progress']
/**
* 长期记忆控制台的统一 WebSocket 桥接客户端。
*
* 负责:
* 1. 订阅 `memory` 域下的若干 topic导入/删除/反馈进度)。
* 2. 把后端推送的事件分发给所有已注册的监听器。
* 3. 即使后端尚未广播也保持安全:监听器为空时不抛错,订阅幂等。
*
* 与 `pluginProgressClient` 保持一致的形状,便于复用。
*/
class MemoryProgressClient {
private initialized = false
private listeners: Set<ProgressListener> = new Set()
private activeTopics: Set<MemoryProgressTopic> = new Set()
private initialize(): void {
if (this.initialized) {
return
}
unifiedWsClient.addEventListener((message: WsEventEnvelope) => {
if (message.domain !== DOMAIN) {
return
}
const topic = (message.topic ?? '') as MemoryProgressTopic
if (!KNOWN_TOPICS.includes(topic)) {
return
}
const payload: MemoryProgressEvent = {
topic,
event: message.event,
data: message.data ?? {},
}
this.listeners.forEach((listener) => {
try {
listener(payload)
} catch (error) {
console.error('长期记忆进度监听器执行失败:', error)
}
})
})
this.initialized = true
}
async subscribe(
listener: ProgressListener,
topics: MemoryProgressTopic[] = KNOWN_TOPICS,
): Promise<() => Promise<void>> {
this.initialize()
this.listeners.add(listener)
// 仅订阅尚未激活的 topic避免重复 subscribe
for (const topic of topics) {
if (this.activeTopics.has(topic)) {
continue
}
try {
await unifiedWsClient.subscribe(DOMAIN, topic)
this.activeTopics.add(topic)
} catch (error) {
// 后端可能尚未实现该 topic订阅失败时只记录不抛出确保 polling 仍可作为兜底
console.warn(`订阅长期记忆 topic 失败(将退化到轮询兜底): ${topic}`, error)
}
}
return async () => {
this.listeners.delete(listener)
if (this.listeners.size === 0) {
const topicsToRelease = Array.from(this.activeTopics)
this.activeTopics.clear()
for (const topic of topicsToRelease) {
try {
await unifiedWsClient.unsubscribe(DOMAIN, topic)
} catch (error) {
console.warn(`取消订阅长期记忆 topic 失败: ${topic}`, error)
}
}
}
}
}
}
export const memoryProgressClient = new MemoryProgressClient()

View File

@@ -5,7 +5,7 @@
* 修改此处的版本号后,所有展示版本的地方都会自动更新
*/
export const APP_VERSION = '1.0.0'
export const APP_VERSION = '1.0.1'
export const APP_NAME = 'MaiBot Dashboard'
export const APP_FULL_NAME = `${APP_NAME} v${APP_VERSION}`

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
@@ -63,23 +63,58 @@ export function AuthPage() {
const { t } = useTranslation()
const { enableWavesBackground, setEnableWavesBackground } = useAnimation()
const { theme, setTheme } = useTheme()
// 避免 React StrictMode 下重复触发 URL token 自动登录。
const urlTokenHandledRef = useRef(false)
// 如果已经认证,直接跳转到首页
useEffect(() => {
const verifyAuth = async () => {
try {
const isAuth = await checkAuthStatus()
if (isAuth) {
navigate({ to: '/' })
}
} catch {
// 忽略错误,保持在登录页
} finally {
setCheckingAuth(false)
// 从 URL 中提取 token支持 query 与 hash 两种位置)。
// 允许 ?token=xxx 、&token=xxxquery以及 #/foo?token=xxx、#token=xxxhash
const extractUrlToken = useCallback((): string => {
if (typeof window === 'undefined') return ''
const fromQuery = new URLSearchParams(window.location.search).get('token')
if (fromQuery && fromQuery.trim()) return fromQuery.trim()
// hash 中可能形如 "#token=xxx" 或 "#/path?token=xxx"
const rawHash = window.location.hash.replace(/^#/, '')
if (!rawHash) return ''
const queryIdx = rawHash.indexOf('?')
const hashQuery = queryIdx >= 0 ? rawHash.slice(queryIdx + 1) : rawHash
const fromHash = new URLSearchParams(hashQuery).get('token')
return fromHash && fromHash.trim() ? fromHash.trim() : ''
}, [])
// 从当前 URL 中移除 token 参数,避免令牌被书签/Referer/浏览器历史泄露。
const stripTokenFromUrl = useCallback(() => {
if (typeof window === 'undefined') return
try {
const url = new URL(window.location.href)
let changed = false
if (url.searchParams.has('token')) {
url.searchParams.delete('token')
changed = true
}
const rawHash = url.hash.replace(/^#/, '')
if (rawHash) {
const queryIdx = rawHash.indexOf('?')
if (queryIdx >= 0) {
const path = rawHash.slice(0, queryIdx)
const hashParams = new URLSearchParams(rawHash.slice(queryIdx + 1))
if (hashParams.has('token')) {
hashParams.delete('token')
const next = hashParams.toString()
url.hash = next ? `#${path}?${next}` : `#${path}`
changed = true
}
} else if (/^token=/.test(rawHash)) {
url.hash = ''
changed = true
}
}
if (changed) {
window.history.replaceState(null, '', url.toString())
}
} catch {
// 忽略 URL 解析异常
}
verifyAuth()
}, [navigate])
}, [])
// 获取实际应用的主题(处理 system 情况)
const getActualTheme = () => {
@@ -97,83 +132,120 @@ export function AuthPage() {
setTheme(newTheme)
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
const verifyToken = useCallback(
async (rawToken: string): Promise<boolean> => {
const trimmed = rawToken.trim()
setError('')
if (!token.trim()) {
setError(t('auth.tokenRequired'))
return
}
setIsValidating(true)
console.log('开始验证 token...')
try {
// 向后端发送请求验证 token后端会设置 HttpOnly Cookie
const response = await fetch('/api/webui/auth/verify', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include', // 确保接收并存储 Cookie
body: JSON.stringify({ token: token.trim() }),
})
console.log('Token 验证响应状态:', response.status)
const result = await parseResponse<{
valid: boolean
is_first_setup?: boolean
message?: string
}>(response)
if (!result.success) {
console.error('Token 验证失败:', result.error)
setError(result.error)
return
if (!trimmed) {
setError(t('auth.tokenRequired'))
return false
}
const data = result.data
console.log('Token 验证响应数据:', data)
setIsValidating(true)
console.log('开始验证 token...')
if (data.valid) {
console.log('Token 验证成功,准备跳转...')
console.log('is_first_setup:', data.is_first_setup)
try {
// 向后端发送请求验证 token后端会设置 HttpOnly Cookie
const response = await fetch('/api/webui/auth/verify', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include', // 确保接收并存储 Cookie
body: JSON.stringify({ token: trimmed }),
})
// Token 验证成功Cookie 已由后端设置
// 等待一小段时间确保 Cookie 已设置
await new Promise((resolve) => setTimeout(resolve, 100))
console.log('Token 验证响应状态:', response.status)
// 再次检查认证状态
const authCheck = await checkAuthStatus()
console.log('跳转前认证状态检查:', authCheck)
const result = await parseResponse<{
valid: boolean
is_first_setup?: boolean
message?: string
}>(response)
// 直接使用验证响应中的 is_first_setup 字段,避免额外请求
if (data.is_first_setup) {
console.log('跳转到首次配置页面')
// 需要首次配置,跳转到配置向导
navigate({ to: '/setup' })
} else {
console.log('跳转到首页')
// 不需要配置或配置已完成,跳转到首页
navigate({ to: '/' })
if (!result.success) {
console.error('Token 验证失败:', result.error)
setError(result.error)
return false
}
} else {
const data = result.data
console.log('Token 验证响应数据:', data)
if (data.valid) {
console.log('Token 验证成功,准备跳转...')
console.log('is_first_setup:', data.is_first_setup)
// Token 验证成功Cookie 已由后端设置
// 等待一小段时间确保 Cookie 已设置
await new Promise((resolve) => setTimeout(resolve, 100))
// 再次检查认证状态
const authCheck = await checkAuthStatus()
console.log('跳转前认证状态检查:', authCheck)
// 直接使用验证响应中的 is_first_setup 字段,避免额外请求
if (data.is_first_setup) {
console.log('跳转到首次配置页面')
navigate({ to: '/setup' })
} else {
console.log('跳转到首页')
navigate({ to: '/' })
}
return true
}
console.error('Token 验证失败:', data.message)
setError(data.message || t('auth.verifyFailed'))
return false
} catch (err) {
console.error('Token 验证错误:', err)
setError(err instanceof Error ? err.message : t('auth.connFailed'))
return false
} finally {
setIsValidating(false)
}
} catch (err) {
console.error('Token 验证错误:', err)
setError(
err instanceof Error ? err.message : t('auth.connFailed')
)
} finally {
setIsValidating(false)
}
},
[navigate, t]
)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
await verifyToken(token)
}
// 如果已经认证,直接跳转到首页;否则尝试用 URL 中的 token 自动登录。
useEffect(() => {
const verifyAuth = async () => {
try {
const isAuth = await checkAuthStatus()
if (isAuth) {
// 已登录场景下URL 中残留的 token 也清掉,避免外泄。
stripTokenFromUrl()
navigate({ to: '/' })
return
}
// 未登录:检查 URL 是否带 token带了就自动尝试登录。
const urlToken = extractUrlToken()
if (urlToken && !urlTokenHandledRef.current) {
urlTokenHandledRef.current = true
// 立即从 URL 中剥离 token防止刷新/复制链接时再次暴露。
stripTokenFromUrl()
setToken(urlToken)
// 异步触发验证失败时错误信息会显示在表单上token 也会保留在输入框中以便用户修正。
void verifyToken(urlToken)
}
} catch {
// 忽略错误,保持在登录页
} finally {
setCheckingAuth(false)
}
}
verifyAuth()
}, [navigate, extractUrlToken, stripTokenFromUrl, verifyToken])
// 正在检查认证状态时显示加载
if (checkingAuth) {
return (

View File

@@ -0,0 +1,14 @@
import { createContext, useContext } from 'react'
/** 暴露给消息内容渲染器使用的滚动 / 高亮接口。 */
export interface ChatScrollContextValue {
/** 滚动并高亮指定消息;若消息不在可视列表中则返回 ``false``。 */
scrollToMessage: (messageId: string) => boolean
}
export const ChatScrollContext = createContext<ChatScrollContextValue | null>(null)
/** 在消息列表内部使用:访问 ``scrollToMessage`` 等能力。 */
export function useChatScroll(): ChatScrollContextValue | null {
return useContext(ChatScrollContext)
}

View File

@@ -22,9 +22,8 @@ interface ChatWorkspaceSidebarProps {
onUpdateUserName: (name: string) => void
}
function getMessagePreview(message: ChatMessage | undefined, fallback: string, thinking: string) {
function getMessagePreview(message: ChatMessage | undefined, fallback: string) {
if (!message) return fallback
if (message.type === 'thinking') return thinking
if (message.type === 'system' || message.type === 'error') return message.content || fallback
return message.content || fallback
}
@@ -45,8 +44,7 @@ function ConversationItem({
const lastMessage = tab.messages[tab.messages.length - 1]
const preview = getMessagePreview(
lastMessage,
t('chat.sidebar.emptyPreview'),
t('chat.message.thinking')
t('chat.sidebar.emptyPreview')
)
const Icon = isVirtual ? UserCircle2 : Bot

View File

@@ -1,11 +1,12 @@
import { Bot, Sparkles, User } from 'lucide-react'
import { useEffect, useRef } from 'react'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { ScrollArea } from '@/components/ui/scroll-area'
import { cn } from '@/lib/utils'
import { ChatScrollContext, type ChatScrollContextValue } from './ChatScrollContext'
import { RenderMessageContent } from './MessageRenderer'
import type { ChatMessage } from './types'
@@ -13,20 +14,27 @@ interface MessageListProps {
messages: ChatMessage[]
isLoadingHistory: boolean
botDisplayName: string
/** 机器人 QQ 号;存在时会从 QQ 头像公开接口拉取头像作为 bot 头像。 */
botQq?: string
userName: string
language: string
}
interface BubbleAvatarProps {
type: 'user' | 'bot' | 'thinking'
type: 'user' | 'bot'
visible: boolean
/** bot 头像 URL可选加载失败时自动 fallback 到默认 SVG 图标。 */
imageUrl?: string
}
function BubbleAvatar({ type, visible }: BubbleAvatarProps) {
function BubbleAvatar({ type, visible, imageUrl }: BubbleAvatarProps) {
return (
<div className="h-8 w-8 shrink-0 sm:h-9 sm:w-9">
{visible && (
<Avatar className="h-full w-full ring-1 ring-border/60">
{type === 'bot' && imageUrl ? (
<AvatarImage src={imageUrl} alt="" className="object-cover" />
) : null}
<AvatarFallback
className={cn(
'text-xs',
@@ -47,20 +55,6 @@ function BubbleAvatar({ type, visible }: BubbleAvatarProps) {
)
}
function ThinkingBubble() {
const { t } = useTranslation()
return (
<div className="bg-muted/80 text-muted-foreground inline-flex items-center gap-2 rounded-2xl rounded-bl-sm px-3.5 py-2.5">
<span className="flex gap-1">
<span className="bg-primary/60 h-1.5 w-1.5 animate-bounce rounded-full [animation-delay:0ms]" />
<span className="bg-primary/60 h-1.5 w-1.5 animate-bounce rounded-full [animation-delay:150ms]" />
<span className="bg-primary/60 h-1.5 w-1.5 animate-bounce rounded-full [animation-delay:300ms]" />
</span>
<span className="text-xs">{t('chat.message.thinking')}</span>
</div>
)
}
function EmptyState({ botName }: { botName: string }) {
const { t } = useTranslation()
return (
@@ -80,22 +74,42 @@ function EmptyState({ botName }: { botName: string }) {
}
/**
* 聊天消息列表:支持连续同发送者消息分组、思考占位、富文本与系统/错误信息样式。
* 聊天消息列表:支持连续同发送者消息分组、富文本与系统/错误信息样式。
*/
export function MessageList({
messages,
isLoadingHistory,
botDisplayName,
botQq,
userName,
language,
}: MessageListProps) {
const { t } = useTranslation()
const endRef = useRef<HTMLDivElement>(null)
const messageRefs = useRef<Map<string, HTMLDivElement>>(new Map())
useEffect(() => {
endRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
const scrollToMessage = useCallback((messageId: string) => {
const target = messageRefs.current.get(messageId)
if (!target) {
return false
}
target.scrollIntoView({ behavior: 'smooth', block: 'center' })
target.classList.add('chat-message-flash')
window.setTimeout(() => {
target.classList.remove('chat-message-flash')
}, 1600)
return true
}, [])
const scrollContextValue = useMemo<ChatScrollContextValue>(
() => ({ scrollToMessage }),
[scrollToMessage]
)
const formatTime = (timestamp: number) => {
const date = new Date(timestamp * 1000)
return date.toLocaleTimeString(language || 'zh-CN', {
@@ -104,6 +118,11 @@ export function MessageList({
})
}
// 优先使用 q1.qlogo.cn s=640高清QQ 公开头像接口。
const botAvatarUrl = botQq && /^\d+$/.test(botQq)
? `https://q1.qlogo.cn/g?b=qq&nk=${botQq}&s=640`
: undefined
if (messages.length === 0 && !isLoadingHistory) {
return (
<div className="min-w-0 min-h-0 flex-1 overflow-hidden">
@@ -127,7 +146,8 @@ export function MessageList({
scrollbars="vertical"
viewportClassName="[&>div]:!block [&>div]:!min-w-0 [&>div]:w-full"
>
<div className="mx-auto flex w-full max-w-4xl min-w-0 flex-col gap-1 px-3 py-5 sm:px-6 sm:py-6">
<ChatScrollContext.Provider value={scrollContextValue}>
<div className="mx-auto flex w-full max-w-4xl min-w-0 flex-col gap-1 px-3 py-5 sm:px-6 sm:py-6">
{messages.map((message, index) => {
// 系统消息:作为分隔条
if (message.type === 'system') {
@@ -154,8 +174,7 @@ export function MessageList({
}
const isUser = message.type === 'user'
const isThinking = message.type === 'thinking'
const bubbleType: 'user' | 'bot' | 'thinking' = isUser ? 'user' : isThinking ? 'thinking' : 'bot'
const bubbleType: 'user' | 'bot' = isUser ? 'user' : 'bot'
// 是否与上一条消息属于同一发送者(用于分组:仅首条显示头像 + 名字)
const previous = messages[index - 1]
@@ -171,13 +190,25 @@ export function MessageList({
return (
<div
key={message.id}
ref={(node) => {
if (node) {
messageRefs.current.set(message.id, node)
} else {
messageRefs.current.delete(message.id)
}
}}
data-message-id={message.id}
className={cn(
'flex w-full min-w-0 items-end gap-2 sm:gap-3',
'chat-message-row flex w-full min-w-0 items-end gap-2 sm:gap-3',
isUser ? 'flex-row-reverse' : 'flex-row',
sameGroup ? 'mt-0.5' : 'mt-3 first:mt-0'
)}
>
<BubbleAvatar type={bubbleType === 'thinking' ? 'bot' : bubbleType} visible={!sameGroup} />
<BubbleAvatar
type={bubbleType}
visible={!sameGroup}
imageUrl={bubbleType === 'bot' ? botAvatarUrl : undefined}
/>
<div
className={cn(
@@ -197,20 +228,16 @@ export function MessageList({
</div>
)}
{isThinking ? (
<ThinkingBubble />
) : (
<div
className={cn(
'shadow-sm/30 wrap-break-word min-w-0 max-w-full overflow-hidden px-3.5 py-2 text-sm leading-relaxed',
isUser
? 'bg-primary text-primary-foreground rounded-2xl rounded-br-md'
: 'bg-muted text-foreground rounded-2xl rounded-bl-md'
)}
>
<RenderMessageContent message={message} isBot={!isUser} />
</div>
)}
<div
className={cn(
'shadow-sm/30 wrap-break-word min-w-0 max-w-full overflow-hidden px-3.5 py-2 text-sm leading-relaxed',
isUser
? 'bg-primary text-primary-foreground rounded-2xl rounded-br-md'
: 'bg-muted text-foreground rounded-2xl rounded-bl-md'
)}
>
<RenderMessageContent message={message} isBot={!isUser} />
</div>
</div>
</div>
)
@@ -220,7 +247,8 @@ export function MessageList({
<span className="sr-only" aria-live="polite">
{messages.length > 0 ? t('chat.sidebar.subtitle', { count: messages.length }) : ''}
</span>
</div>
</div>
</ChatScrollContext.Provider>
</ScrollArea>
</div>
)

View File

@@ -1,8 +1,30 @@
import { Reply as ReplyIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { useToast } from '@/hooks/use-toast'
import { cn } from '@/lib/utils'
import type { ChatMessage, MessageSegment } from './types'
import { useChatScroll } from './ChatScrollContext'
import type {
AtSegmentData,
ChatMessage,
MessageSegment,
ReplySegmentData,
} from './types'
function normalizeAtSegment(segment: MessageSegment): AtSegmentData {
if (segment.data && typeof segment.data === 'object') {
return segment.data as AtSegmentData
}
return { target_user_id: segment.data == null ? null : String(segment.data) }
}
function normalizeReplySegment(segment: MessageSegment): ReplySegmentData {
if (segment.data && typeof segment.data === 'object') {
return segment.data as ReplySegmentData
}
return { target_message_id: segment.data == null ? null : String(segment.data) }
}
// 渲染单个消息段
export function RenderMessageSegment({ segment }: { segment: MessageSegment }) {
@@ -74,8 +96,27 @@ export function RenderMessageSegment({ segment }: { segment: MessageSegment }) {
</span>
)
case 'reply':
return <span className="text-muted-foreground text-xs">{t('chat.media.reply')}</span>
case 'reply': {
const replyData = normalizeReplySegment(segment)
return <ReplySegmentBlock data={replyData} />
}
case 'at': {
const atData = normalizeAtSegment(segment)
const atLabel =
atData.target_user_cardname ||
atData.target_user_nickname ||
atData.target_user_id ||
''
return (
<span
className="text-primary bg-primary/10 mx-0.5 inline-flex items-center rounded px-1 text-[0.95em] font-medium"
title={atData.target_user_id ? `@${atData.target_user_id}` : '@'}
>
@{atLabel || t('chat.media.unknownMessage')}
</span>
)
}
case 'forward':
return <span className="text-muted-foreground">{t('chat.media.forward')}</span>
@@ -103,11 +144,29 @@ export function RenderMessageContent({
}) {
// 如果是富文本消息,渲染消息段
if (message.message_type === 'rich' && message.segments && message.segments.length > 0) {
// 将 reply 段与后续内容拆开,避免回复块与文本出现在同一行上。
const inlineSegments: MessageSegment[] = []
const replySegments: MessageSegment[] = []
for (const segment of message.segments) {
if (segment.type === 'reply') {
replySegments.push(segment)
} else {
inlineSegments.push(segment)
}
}
return (
<div className="flex flex-col gap-2">
{message.segments.map((segment, index) => (
<RenderMessageSegment key={index} segment={segment} />
{replySegments.map((segment, index) => (
<RenderMessageSegment key={`reply-${index}`} segment={segment} />
))}
{inlineSegments.length > 0 && (
<div className="flex flex-wrap items-baseline whitespace-pre-wrap">
{inlineSegments.map((segment, index) => (
<RenderMessageSegment key={`inline-${index}`} segment={segment} />
))}
</div>
)}
</div>
)
}
@@ -115,3 +174,61 @@ export function RenderMessageContent({
// 普通文本消息
return <span className="whitespace-pre-wrap">{message.content}</span>
}
// 回复消息块:点击可跳转到原始消息;如原消息不可见则提示错误。
function ReplySegmentBlock({ data }: { data: ReplySegmentData }) {
const { t } = useTranslation()
const { toast } = useToast()
const chatScroll = useChatScroll()
const senderName =
data.target_message_sender_cardname ||
data.target_message_sender_nickname ||
data.target_message_sender_id ||
t('chat.message.replyUnknownSender', { defaultValue: '未知发送者' })
const previewText =
data.target_message_content?.trim() ||
t('chat.media.replyMissing', { defaultValue: '原消息内容不可用' })
const targetMessageId = data.target_message_id ? String(data.target_message_id) : ''
const isClickable = Boolean(targetMessageId && chatScroll)
const handleClick = () => {
if (!targetMessageId || !chatScroll) {
return
}
const found = chatScroll.scrollToMessage(targetMessageId)
if (!found) {
toast({
title: t('chat.toast.replyNotFoundTitle', { defaultValue: '原始消息不在当前视图' }),
description: t('chat.toast.replyNotFoundDescription', {
defaultValue: '该消息可能已被清除、不在本会话中,或者尚未加载。',
}),
variant: 'destructive',
})
}
}
const className = cn(
'group block w-full rounded-md border-l-2 border-primary/60 bg-background/40 px-2 py-1 text-left text-xs',
isClickable && 'cursor-pointer transition hover:bg-background/70'
)
const content = (
<div className="flex items-start gap-2">
<ReplyIcon className="mt-0.5 h-3 w-3 shrink-0 opacity-70" aria-hidden />
<div className="min-w-0 flex-1">
<div className="text-primary/80 truncate text-[11px] font-medium">{senderName}</div>
<div className="text-muted-foreground truncate">{previewText}</div>
</div>
</div>
)
if (isClickable) {
return (
<button type="button" className={className} onClick={handleClick}>
{content}
</button>
)
}
return <div className={className}>{content}</div>
}

View File

@@ -214,6 +214,7 @@ export function ChatPage() {
user_id: data.user_id,
user_name: data.user_name,
bot_name: data.bot_name,
bot_qq: data.bot_qq,
},
})
break
@@ -278,7 +279,6 @@ export function ChatPage() {
setTabs((prev) =>
prev.map((tab) => {
if (tab.id !== tabId) return tab
const filteredMessages = tab.messages.filter((msg) => msg.type !== 'thinking')
const newMessage: ChatMessage = {
id: generateMessageId('bot'),
type: 'bot',
@@ -290,7 +290,7 @@ export function ChatPage() {
}
return {
...tab,
messages: [...filteredMessages, newMessage],
messages: [...tab.messages, newMessage],
}
})
)
@@ -305,11 +305,10 @@ export function ChatPage() {
setTabs((prev) =>
prev.map((tab) => {
if (tab.id !== tabId) return tab
const filteredMessages = tab.messages.filter((msg) => msg.type !== 'thinking')
return {
...tab,
messages: [
...filteredMessages,
...tab.messages,
{
id: generateMessageId('error'),
type: 'error' as const,
@@ -330,33 +329,27 @@ export function ChatPage() {
case 'history': {
const historyMessages = data.messages || []
const processedSet = new Set<string>()
const formattedMessages: ChatMessage[] = historyMessages.map(
(msg: {
id?: string
content: string
timestamp: number
sender_name?: string
sender_id?: string
is_bot?: boolean
}) => {
const isBot = msg.is_bot || false
const msgId = msg.id || generateMessageId(isBot ? 'bot' : 'user')
const contentHash = `${isBot ? 'bot' : 'user'}-${msg.content}-${Math.floor(msg.timestamp * 1000)}`
processedSet.add(contentHash)
return {
id: msgId,
type: isBot ? 'bot' : ('user' as const),
content: msg.content,
timestamp: msg.timestamp,
sender: {
name:
msg.sender_name || (isBot ? t('chat.botNameFallback') : t('chat.userFallback')),
user_id: msg.sender_id,
is_bot: isBot,
},
}
const formattedMessages: ChatMessage[] = historyMessages.map((msg) => {
const isBot = msg.is_bot || false
const msgId = msg.id || generateMessageId(isBot ? 'bot' : 'user')
const contentHash = `${isBot ? 'bot' : 'user'}-${msg.content}-${Math.floor(msg.timestamp * 1000)}`
processedSet.add(contentHash)
const isRich = msg.message_type === 'rich' && Array.isArray(msg.segments) && msg.segments.length > 0
return {
id: msgId,
type: isBot ? 'bot' : ('user' as const),
content: msg.content,
timestamp: msg.timestamp,
message_type: isRich ? 'rich' : 'text',
segments: isRich ? (msg.segments ?? undefined) : undefined,
sender: {
name:
msg.sender_name || (isBot ? t('chat.botNameFallback') : t('chat.userFallback')),
user_id: msg.sender_id,
is_bot: isBot,
},
}
)
})
processedMessagesMapRef.current.set(tabId, processedSet)
updateTab(tabId, { messages: formattedMessages })
@@ -509,19 +502,6 @@ export function ChatPage() {
}
addMessageToTab(activeTabId, userMessage)
// 再添加"思考中"占位消息
const thinkingMessage: ChatMessage = {
id: generateMessageId('thinking'),
type: 'thinking',
content: '',
timestamp: currentTimestamp + 0.001, // 稍微晚一点确保顺序
sender: {
name: activeTab?.sessionInfo.bot_name || t('chat.botNameFallback'),
is_bot: true,
},
}
addMessageToTab(activeTabId, thinkingMessage)
setInputValue('')
try {
@@ -534,7 +514,6 @@ export function ChatPage() {
return {
...tab,
isTyping: false,
messages: tab.messages.filter((msg) => msg.type !== 'thinking'),
}
})
)
@@ -759,6 +738,7 @@ export function ChatPage() {
messages={activeTab?.messages ?? []}
isLoadingHistory={isLoadingHistory}
botDisplayName={botDisplayName}
botQq={activeTab?.sessionInfo.bot_qq}
userName={userName}
language={i18n.language}
/>

View File

@@ -49,20 +49,49 @@ export interface ChatTab {
user_id?: string
user_name?: string
bot_name?: string
bot_qq?: string
}
}
// 消息段类型
export interface MessageSegment {
type: 'text' | 'image' | 'emoji' | 'face' | 'voice' | 'video' | 'music' | 'file' | 'reply' | 'forward' | 'unknown'
type:
| 'text'
| 'image'
| 'emoji'
| 'face'
| 'voice'
| 'video'
| 'music'
| 'file'
| 'reply'
| 'at'
| 'forward'
| 'unknown'
data: string | number | object
original_type?: string
}
// @某人 消息段的负载
export interface AtSegmentData {
target_user_id?: string | null
target_user_nickname?: string | null
target_user_cardname?: string | null
}
// 回复消息段的负载
export interface ReplySegmentData {
target_message_id?: string | null
target_message_content?: string | null
target_message_sender_id?: string | null
target_message_sender_nickname?: string | null
target_message_sender_cardname?: string | null
}
// 消息类型
export interface ChatMessage {
id: string
type: 'user' | 'bot' | 'system' | 'error' | 'thinking'
type: 'user' | 'bot' | 'system' | 'error'
content: string
timestamp: number
message_type?: 'text' | 'rich' // 消息格式类型
@@ -85,6 +114,7 @@ export interface WsMessage {
user_id?: string
user_name?: string
bot_name?: string
bot_qq?: string
sender?: {
name: string
user_id?: string
@@ -98,6 +128,8 @@ export interface WsMessage {
sender_name?: string
sender_id?: string
is_bot?: boolean
message_type?: string
segments?: MessageSegment[] | null
}>
group_id?: string
// 富文本消息

View File

@@ -67,7 +67,7 @@ export function createJsonFieldHook(options: JsonFieldHookOptions): FieldHookCom
<div className="space-y-1">
<h3 className="text-base font-semibold">{label}</h3>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
<p className="text-sm text-muted-foreground whitespace-pre-line">{description}</p>
)}
<p className="text-xs text-muted-foreground">{options.helperText}</p>
</div>

View File

@@ -0,0 +1,283 @@
import { useCallback, useMemo } from 'react'
import * as LucideIcons from 'lucide-react'
import { Plus, Trash2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { DynamicConfigForm } from '@/components/dynamic-form/DynamicConfigForm'
import type { FieldHookComponent } from '@/lib/field-hooks'
import type { ConfigSchema, FieldSchema } from '@/types/config-schema'
/**
* createListItemEditorHook
*
* 通过 nestedSchema 渲染列表项式的富 UI 编辑器,替换原来直接展示 JSON 文本的 fallback。
* 适用于 `List[ConfigBase]` 类型字段schema.nested 中存在对应子配置类)。
*/
export interface ListItemEditorOptions {
/** 用于生成每个 item 的标题,如 `${index+1} · ${item.platform}` */
itemTitle?: (item: Record<string, unknown>, index: number) => string
/** 添加按钮文案 */
addLabel?: string
/** 顶部辅助说明 */
helperText?: string
/** 列表为空时的占位说明 */
emptyText?: string
/** 顶部图标(覆盖 schema 自带的 x-icon */
iconName?: string
}
function resolveLabel(schema?: ConfigSchema | FieldSchema, fieldPath?: string): string {
if (!schema) {
return fieldPath?.split('.').at(-1) ?? '列表配置'
}
if ('label' in schema && schema.label) {
return schema.label
}
if ('uiLabel' in schema && schema.uiLabel) {
return schema.uiLabel
}
if ('classDoc' in schema && schema.classDoc) {
return schema.classDoc
}
if ('className' in schema && schema.className) {
return schema.className
}
return fieldPath?.split('.').at(-1) ?? '列表配置'
}
function resolveDescription(schema?: ConfigSchema | FieldSchema): string {
if (!schema) return ''
if ('description' in schema && schema.description) return schema.description
if ('classDoc' in schema && schema.classDoc) return schema.classDoc
return ''
}
function resolveIconName(
iconOverride: string | undefined,
schema?: ConfigSchema | FieldSchema,
nested?: ConfigSchema,
): string | undefined {
if (iconOverride) return iconOverride
if (schema && 'x-icon' in schema && schema['x-icon']) return schema['x-icon']
if (nested?.uiIcon) return nested.uiIcon
return undefined
}
function renderLucideIcon(iconName: string | undefined, className: string) {
if (!iconName) return null
const Icon = LucideIcons[iconName as keyof typeof LucideIcons] as
| React.ComponentType<{ className?: string }>
| undefined
if (!Icon) return null
return <Icon className={className} />
}
/** 根据 itemSchema 字段默认值构造一个新 item */
function buildDefaultItem(itemSchema: ConfigSchema | undefined): Record<string, unknown> {
if (!itemSchema?.fields) return {}
const next: Record<string, unknown> = {}
for (const field of itemSchema.fields) {
if ('default' in field && field.default !== undefined) {
// 数组/对象需要做一次浅拷贝,避免多个 item 共享同一引用
if (Array.isArray(field.default)) {
next[field.name] = [...field.default]
} else if (
field.default !== null &&
typeof field.default === 'object'
) {
next[field.name] = { ...(field.default as Record<string, unknown>) }
} else {
next[field.name] = field.default
}
continue
}
switch (field.type) {
case 'boolean':
next[field.name] = false
break
case 'integer':
case 'number':
next[field.name] = 0
break
case 'array':
next[field.name] = []
break
case 'object':
next[field.name] = {}
break
case 'select':
next[field.name] = field.options?.[0] ?? ''
break
default:
next[field.name] = ''
}
}
return next
}
/**
* 把 dotted-path 写入 item 对象(兼容 DynamicConfigForm 的 onChange
*/
function setNested(target: Record<string, unknown>, path: string, value: unknown) {
const keys = path.split('.')
if (keys.length === 1) {
target[keys[0]] = value
return
}
let cursor: Record<string, unknown> = target
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i]
const existing = cursor[key]
if (existing && typeof existing === 'object' && !Array.isArray(existing)) {
cursor[key] = { ...(existing as Record<string, unknown>) }
} else {
cursor[key] = {}
}
cursor = cursor[key] as Record<string, unknown>
}
cursor[keys[keys.length - 1]] = value
}
export function createListItemEditorHook(
options: ListItemEditorOptions = {},
): FieldHookComponent {
const ListItemEditorHook: FieldHookComponent = ({
fieldPath,
onChange,
schema,
nestedSchema,
value,
}) => {
const items = useMemo<Record<string, unknown>[]>(() => {
if (!Array.isArray(value)) return []
return value.map((item) =>
item && typeof item === 'object' && !Array.isArray(item)
? (item as Record<string, unknown>)
: {},
)
}, [value])
const handleAdd = useCallback(() => {
const next = [...items, buildDefaultItem(nestedSchema)]
onChange?.(next)
}, [items, nestedSchema, onChange])
const handleRemove = useCallback(
(index: number) => {
const next = items.filter((_, idx) => idx !== index)
onChange?.(next)
},
[items, onChange],
)
const handleItemFieldChange = useCallback(
(index: number, fieldName: string, fieldValue: unknown) => {
const next = items.map((item, idx) => {
if (idx !== index) return item
const cloned = { ...item }
setNested(cloned, fieldName, fieldValue)
return cloned
})
onChange?.(next)
},
[items, onChange],
)
const label = resolveLabel(schema, fieldPath)
const description = resolveDescription(schema)
const iconName = resolveIconName(options.iconName, schema, nestedSchema)
if (!nestedSchema) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{label}</CardTitle>
<CardDescription> schema</CardDescription>
</CardHeader>
</Card>
)
}
return (
<Card>
<CardHeader className="space-y-2 pb-4">
<div className="flex items-center gap-2">
{renderLucideIcon(iconName, 'h-5 w-5 text-muted-foreground')}
<CardTitle className="text-base">{label}</CardTitle>
</div>
{description && (
<CardDescription className="whitespace-pre-line">{description}</CardDescription>
)}
{options.helperText && (
<p className="text-xs text-muted-foreground">{options.helperText}</p>
)}
</CardHeader>
<CardContent className="space-y-3">
{items.length === 0 ? (
<div className="rounded-md border border-dashed border-muted-foreground/25 bg-muted/10 p-6 text-center text-sm text-muted-foreground">
{options.emptyText ?? '尚未添加任何条目,点击下方按钮新增。'}
</div>
) : (
items.map((item, index) => {
const title =
options.itemTitle?.(item, index) ?? `条目 ${index + 1}`
return (
<div
key={index}
className="space-y-3 rounded-lg border bg-card/40 p-4"
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 text-sm font-semibold">
<span className="inline-flex h-6 min-w-6 items-center justify-center rounded-full bg-muted px-2 text-xs font-medium text-muted-foreground">
{index + 1}
</span>
<span className="truncate">{title}</span>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => handleRemove(index)}
>
<Trash2 className="mr-1 h-4 w-4" />
</Button>
</div>
<DynamicConfigForm
schema={nestedSchema}
values={item}
onChange={(field, fieldValue) =>
handleItemFieldChange(index, field, fieldValue)
}
basePath=""
level={1}
/>
</div>
)
})
)}
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAdd}
className="w-full"
>
<Plus className="mr-1 h-4 w-4" />
{options.addLabel ?? '添加一项'}
</Button>
</CardContent>
</Card>
)
}
return ListItemEditorHook
}

View File

@@ -1,15 +1,98 @@
import { createJsonFieldHook } from './JsonFieldHookFactory'
import { createListItemEditorHook } from './ListItemEditorHookFactory'
export const ChatTalkValueRulesHook = createJsonFieldHook({
emptyValue: [],
helperText: '复杂对象数组使用 JSON 编辑。每一项对应一个聊天频率规则对象。',
placeholder: '[\n {\n "platform": "",\n "item_id": "",\n "rule_type": "group",\n "time": "00:00-23:59",\n "value": 1.0\n }\n]',
const ruleTypeLabel = (rule: unknown) => {
if (rule === 'private') return '私聊'
if (rule === 'group') return '群聊'
return rule ? String(rule) : '未指定'
}
const platformLabel = (item: Record<string, unknown>) => {
const platform = typeof item.platform === 'string' ? item.platform.trim() : ''
const itemId = typeof item.item_id === 'string' ? item.item_id.trim() : ''
if (!platform && !itemId) return '全局'
if (!platform) return itemId
if (!itemId) return platform
return `${platform}:${itemId}`
}
const truncate = (text: string, max = 32) => {
if (text.length <= max) return text
return `${text.slice(0, max)}`
}
const collectStringList = (value: unknown): string[] => {
if (!Array.isArray(value)) return []
return value
.map((item) => (typeof item === 'string' ? item.trim() : ''))
.filter((item) => item.length > 0)
}
export const ChatTalkValueRulesHook = createListItemEditorHook({
addLabel: '添加发言频率规则',
helperText: '可按平台/聊天流/时段分别配置发言频率,留空表示全局。',
emptyText: '尚未配置任何规则,将使用全局默认频率。',
itemTitle: (item) => {
const time =
typeof item.time === 'string' && item.time.trim()
? item.time.trim()
: '全天'
const value =
typeof item.value === 'number' ? item.value.toFixed(2) : '—'
return `${platformLabel(item)} · ${ruleTypeLabel(item.rule_type)} · ${time} · 频率 ${value}`
},
})
export const ExpressionLearningListHook = createJsonFieldHook({
emptyValue: [],
helperText: '表达学习配置较复杂,使用 JSON 编辑更稳妥。每一项对应一个学习规则。',
placeholder: '[\n {\n "platform": "",\n "item_id": "",\n "rule_type": "group",\n "use_expression": true,\n "enable_learning": true,\n "enable_jargon_learning": true\n }\n]',
export const ExpressionLearningListHook = createListItemEditorHook({
addLabel: '添加表达学习规则',
helperText: '为不同聊天流单独配置是否启用表达/jargon 学习。',
emptyText: '尚未配置任何学习规则。',
itemTitle: (item) => {
const flags: string[] = []
if (item.use_expression) flags.push('表达')
if (item.enable_learning) flags.push('优化学习')
if (item.enable_jargon_learning) flags.push('jargon')
const flagText = flags.length ? flags.join(' / ') : '全部关闭'
return `${platformLabel(item)} · ${ruleTypeLabel(item.rule_type)} · ${flagText}`
},
})
export const KeywordRulesHook = createListItemEditorHook({
addLabel: '添加关键词规则',
helperText: '匹配命中后会用 reaction 内容作为额外上下文。keywords 至少填一条,或使用正则模式。',
emptyText: '尚未添加任何关键词规则。',
itemTitle: (item) => {
const keywords = collectStringList(item.keywords)
const regex = collectStringList(item.regex)
const reaction =
typeof item.reaction === 'string' ? item.reaction.trim() : ''
const left = keywords.length
? `关键词 ${keywords.length}`
: regex.length
? `正则 ${regex.length}`
: '未配置匹配项'
const right = reaction ? `${truncate(reaction)}` : '→ 未填写反应'
return `${left} ${right}`
},
})
export const RegexRulesHook = createListItemEditorHook({
addLabel: '添加正则规则',
helperText: '正则模式按 Python 语法编写,命中时把 reaction 作为提示注入。',
emptyText: '尚未添加任何正则规则。',
itemTitle: (item) => {
const regex = collectStringList(item.regex)
const keywords = collectStringList(item.keywords)
const reaction =
typeof item.reaction === 'string' ? item.reaction.trim() : ''
const left = regex.length
? `正则 ${regex.length}`
: keywords.length
? `关键词 ${keywords.length}`
: '未配置匹配项'
const right = reaction ? `${truncate(reaction)}` : '→ 未填写反应'
return `${left} ${right}`
},
})
export const ExpressionGroupsHook = createJsonFieldHook({
@@ -24,18 +107,6 @@ export const ExperimentalChatPromptsHook = createJsonFieldHook({
placeholder: '[\n {\n "platform": "qq",\n "item_id": "123456",\n "rule_type": "group",\n "prompt": "这里填写额外提示词"\n }\n]',
})
export const KeywordRulesHook = createJsonFieldHook({
emptyValue: [],
helperText: '关键词规则为对象数组,建议直接编辑 JSON。',
placeholder: '[\n {\n "keywords": ["早安"],\n "regex": [],\n "reaction": "早安呀"\n }\n]',
})
export const RegexRulesHook = createJsonFieldHook({
emptyValue: [],
helperText: '正则规则为对象数组,建议直接编辑 JSON。',
placeholder: '[\n {\n "keywords": [],\n "regex": ["https?://[^\\\\s]+"],\n "reaction": "检测到链接:[0]"\n }\n]',
})
export const MCPRootItemsHook = createJsonFieldHook({
emptyValue: [],
helperText: 'MCP Roots 条目为对象数组,使用 JSON 编辑。',

View File

@@ -138,7 +138,11 @@ export function ProviderForm({
</DialogDescription>
</DialogHeader>
<form onSubmit={(e) => { e.preventDefault(); handleSaveEdit(); }} autoComplete="off">
<form
onSubmit={(e) => { e.preventDefault(); handleSaveEdit(); }}
autoComplete="off"
className="contents"
>
<DialogBody>
<div className="grid gap-4 py-4">
<div className="grid gap-2" data-tour="provider-template-select">

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,52 @@
import type { MemoryImportTaskKind } from '@/lib/memory-api'
export const DELETE_OPERATION_FETCH_LIMIT = 100
export const DELETE_OPERATION_PAGE_SIZE = 6
export const DELETE_OPERATION_ITEM_PAGE_SIZE = 8
export const FEEDBACK_CORRECTION_FETCH_LIMIT = 100
export const FEEDBACK_CORRECTION_PAGE_SIZE = 6
export const FEEDBACK_ACTION_LOG_PAGE_SIZE = 8
export const IMPORT_CHUNK_PAGE_SIZE = 50
export const RUNNING_IMPORT_STATUS = new Set(['preparing', 'running', 'cancel_requested'])
export const QUEUED_IMPORT_STATUS = new Set(['queued'])
export const IMPORT_STATUS_TEXT: Record<string, string> = {
queued: '排队中',
preparing: '准备中',
running: '运行中',
cancel_requested: '取消中',
cancelled: '已取消',
completed: '已完成',
completed_with_errors: '完成(有错误)',
failed: '失败',
}
export const IMPORT_STEP_TEXT: Record<string, string> = {
queued: '排队中',
preparing: '准备中',
running: '运行中',
splitting: '分块中',
extracting: '抽取中',
writing: '写入中',
saving: '保存中',
backfilling: '回填中',
converting: '转换中',
verifying: '校验中',
switching: '切换中',
cancel_requested: '取消中',
cancelled: '已取消',
completed: '已完成',
completed_with_errors: '完成(有错误)',
failed: '失败',
}
export const IMPORT_KIND_OPTIONS: Array<{ value: MemoryImportTaskKind; label: string; description: string }> = [
{ value: 'upload', label: '上传文件', description: '从本地批量上传资料文件' },
{ value: 'paste', label: '粘贴导入', description: '直接粘贴文本或 JSON 内容创建任务' },
{ value: 'raw_scan', label: '本地扫描', description: '按路径别名和匹配规则批量扫描导入' },
{ value: 'lpmm_openie', label: 'LPMM OpenIE', description: '读取 LPMM 数据并抽取关系' },
{ value: 'lpmm_convert', label: 'LPMM 转换', description: '将 LPMM 数据转换到目标目录' },
{ value: 'temporal_backfill', label: '时序回填', description: '为已有数据补充时间字段' },
{ value: 'maibot_migration', label: 'MaiBot 迁移', description: '从 MaiBot 历史数据迁移长期记忆' },
]

View File

@@ -0,0 +1,492 @@
import type { Dispatch, SetStateAction } from 'react'
import { CircleAlert, RotateCcw, Trash2 } from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { TabsContent } from '@/components/ui/tabs'
import { cn } from '@/lib/utils'
import type { MemoryDeleteOperationPayload, MemorySourceItemPayload } from '@/lib/memory-api'
import { DELETE_OPERATION_ITEM_PAGE_SIZE, DELETE_OPERATION_PAGE_SIZE } from '../constants'
import {
formatDeleteOperationMode,
formatDeleteOperationStatus,
formatDeleteOperationTime,
getDeleteOperationItemLabel,
getDeleteOperationItemPreview,
getDeleteOperationItemSource,
type DeleteOperationItem,
} from '../utils'
export interface DeleteTabProps {
sourceSearch: string
setSourceSearch: Dispatch<SetStateAction<string>>
selectedSources: string[]
setSelectedSources: Dispatch<SetStateAction<string[]>>
filteredSources: MemorySourceItemPayload[]
openSourceDeletePreview: () => Promise<void>
toggleSourceSelection: (source: string, checked: boolean) => void
operationSearch: string
setOperationSearch: Dispatch<SetStateAction<string>>
operationModeFilter: string
setOperationModeFilter: Dispatch<SetStateAction<string>>
operationStatusFilter: string
setOperationStatusFilter: Dispatch<SetStateAction<string>>
filteredDeleteOperations: MemoryDeleteOperationPayload[]
deleteOperations: MemoryDeleteOperationPayload[]
operationPage: number
setOperationPage: Dispatch<SetStateAction<number>>
deleteOperationPageCount: number
pagedDeleteOperations: MemoryDeleteOperationPayload[]
selectedDeleteOperation: MemoryDeleteOperationPayload | null
setSelectedOperationId: Dispatch<SetStateAction<string>>
restoreDeleteOperation: (operationId: string) => Promise<void>
deleteRestoring: boolean
selectedOperationCounts: Record<string, number>
selectedOperationDetailLoading: boolean
selectedOperationDetailError: string
selectedOperationSources: string[]
selectedOperationItems: DeleteOperationItem[]
filteredSelectedOperationItems: DeleteOperationItem[]
selectedOperationItemSearch: string
setSelectedOperationItemSearch: Dispatch<SetStateAction<string>>
selectedOperationItemPage: number
setSelectedOperationItemPage: Dispatch<SetStateAction<number>>
selectedOperationItemPageCount: number
pagedSelectedOperationItems: DeleteOperationItem[]
}
export function DeleteTab(props: DeleteTabProps) {
const {
sourceSearch,
setSourceSearch,
selectedSources,
setSelectedSources,
filteredSources,
openSourceDeletePreview,
toggleSourceSelection,
operationSearch,
setOperationSearch,
operationModeFilter,
setOperationModeFilter,
operationStatusFilter,
setOperationStatusFilter,
filteredDeleteOperations,
deleteOperations,
operationPage,
setOperationPage,
deleteOperationPageCount,
pagedDeleteOperations,
selectedDeleteOperation,
setSelectedOperationId,
restoreDeleteOperation,
deleteRestoring,
selectedOperationCounts,
selectedOperationDetailLoading,
selectedOperationDetailError,
selectedOperationSources,
selectedOperationItems,
filteredSelectedOperationItems,
selectedOperationItemSearch,
setSelectedOperationItemSearch,
selectedOperationItemPage,
setSelectedOperationItemPage,
selectedOperationItemPageCount,
pagedSelectedOperationItems,
} = props
return (
<TabsContent value="delete" className="space-y-4">
<div className="flex flex-col gap-4">
<Card className="order-2">
<CardHeader className="space-y-3">
<div>
<CardTitle className="flex items-center gap-2">
<Trash2 className="h-4 w-4" />
</CardTitle>
<CardDescription>
</CardDescription>
</div>
<Alert className="border-amber-500/30 bg-amber-500/5 text-amber-950 dark:text-amber-200">
<CircleAlert className="h-4 w-4 text-amber-500" />
<AlertDescription>
</AlertDescription>
</Alert>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 rounded-xl border bg-muted/20 p-4 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
<div className="space-y-2">
<Label></Label>
<Input
value={sourceSearch}
onChange={(event) => setSourceSearch(event.target.value)}
placeholder="搜索 source 名称"
/>
</div>
<div className="flex flex-wrap gap-2 lg:justify-end">
<Button
variant="outline"
onClick={() => setSelectedSources(filteredSources.map((item) => String(item.source ?? '')).filter(Boolean))}
>
</Button>
<Button onClick={() => void openSourceDeletePreview()} disabled={selectedSources.length <= 0}>
<Trash2 className="mr-2 h-4 w-4" />
</Button>
</div>
</div>
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<Badge variant="outline" className="bg-background/70"> {filteredSources.length} </Badge>
<Badge variant={selectedSources.length > 0 ? 'secondary' : 'outline'} className="bg-background/70">
{selectedSources.length}
</Badge>
</div>
<ScrollArea className="h-[320px] rounded-lg border">
<Table>
<TableHeader className="sticky top-0 bg-background">
<TableRow>
<TableHead className="w-12"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredSources.length > 0 ? filteredSources.map((item) => {
const source = String(item.source ?? '')
const checked = selectedSources.includes(source)
return (
<TableRow key={source}>
<TableCell>
<Checkbox checked={checked} onCheckedChange={(value) => toggleSourceSelection(source, Boolean(value))} />
</TableCell>
<TableCell className="font-mono text-xs break-all">{source}</TableCell>
<TableCell>{Number(item.paragraph_count ?? 0)}</TableCell>
<TableCell>{Number(item.relation_count ?? 0)}</TableCell>
</TableRow>
)
}) : (
<TableRow>
<TableCell colSpan={4} className="text-center text-muted-foreground">
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</ScrollArea>
</CardContent>
</Card>
<Card className="order-1">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<RotateCcw className="h-4 w-4" />
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 rounded-xl border bg-muted/20 p-4 lg:grid-cols-[minmax(0,1fr)_180px_180px]">
<Input
value={operationSearch}
onChange={(event) => setOperationSearch(event.target.value)}
placeholder="搜索 operation / reason / requested_by / source"
/>
<Select value={operationModeFilter} onValueChange={setOperationModeFilter}>
<SelectTrigger>
<SelectValue placeholder="按模式筛选" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="source"></SelectItem>
<SelectItem value="mixed"></SelectItem>
<SelectItem value="entity"></SelectItem>
<SelectItem value="relation"></SelectItem>
<SelectItem value="paragraph"></SelectItem>
</SelectContent>
</Select>
<Select value={operationStatusFilter} onValueChange={setOperationStatusFilter}>
<SelectTrigger>
<SelectValue placeholder="按状态筛选" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="executed"></SelectItem>
<SelectItem value="restored"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-wrap items-center justify-between gap-2 text-sm text-muted-foreground">
<span> {filteredDeleteOperations.length} {deleteOperations.length} </span>
<span> {operationPage} / {deleteOperationPageCount} {DELETE_OPERATION_PAGE_SIZE} </span>
</div>
<ScrollArea className="h-[320px] rounded-lg border">
<div className="space-y-3 p-3">
{pagedDeleteOperations.length > 0 ? pagedDeleteOperations.map((operation) => {
const summary = (operation.summary ?? {}) as Record<string, unknown>
const counts = ((summary.counts as Record<string, number> | undefined) ?? {})
const isSelected = selectedDeleteOperation?.operation_id === operation.operation_id
return (
<button
key={operation.operation_id}
type="button"
onClick={() => setSelectedOperationId(operation.operation_id)}
className={cn(
'w-full rounded-xl border p-4 text-left transition-colors',
isSelected
? 'border-primary bg-primary/5 shadow-sm'
: 'bg-muted/20 hover:border-primary/40 hover:bg-muted/40',
)}
>
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<Badge variant={operation.status === 'restored' ? 'secondary' : 'default'}>
{formatDeleteOperationStatus(String(operation.status ?? ''))}
</Badge>
<Badge variant="outline">
{formatDeleteOperationMode(String(operation.mode ?? ''))}
</Badge>
</div>
<div className="font-mono text-xs break-all">{operation.operation_id}</div>
<div className="text-sm text-muted-foreground">
{operation.reason || '未填写原因'}
</div>
</div>
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground lg:max-w-[280px] lg:justify-end">
<span> {Number(counts.entities ?? 0)}</span>
<span> {Number(counts.relations ?? 0)}</span>
<span> {Number(counts.paragraphs ?? 0)}</span>
<span> {Number(counts.sources ?? 0)}</span>
</div>
</div>
<div className="mt-3 text-xs text-muted-foreground">
{formatDeleteOperationTime(operation.created_at)}
</div>
</button>
)
}) : (
<div className="rounded-lg border border-dashed bg-muted/20 p-6 text-center text-sm text-muted-foreground">
</div>
)}
</div>
</ScrollArea>
<div className="flex items-center justify-between gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setOperationPage((current) => Math.max(1, current - 1))}
disabled={operationPage <= 1}
>
</Button>
<div className="text-xs text-muted-foreground">
</div>
<Button
variant="outline"
size="sm"
onClick={() => setOperationPage((current) => Math.min(deleteOperationPageCount, current + 1))}
disabled={operationPage >= deleteOperationPageCount}
>
</Button>
</div>
<div className="rounded-xl border bg-muted/20 p-4">
{selectedDeleteOperation ? (
<div className="space-y-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<Badge variant={selectedDeleteOperation.status === 'restored' ? 'secondary' : 'default'}>
{formatDeleteOperationStatus(String(selectedDeleteOperation.status ?? ''))}
</Badge>
<Badge variant="outline">
{formatDeleteOperationMode(String(selectedDeleteOperation.mode ?? ''))}
</Badge>
</div>
<div className="font-mono text-xs break-all">{selectedDeleteOperation.operation_id}</div>
<div className="text-sm text-muted-foreground">
{selectedDeleteOperation.reason || '未填写删除原因'}
</div>
</div>
<Button
size="sm"
variant="outline"
onClick={() => void restoreDeleteOperation(selectedDeleteOperation.operation_id)}
disabled={selectedDeleteOperation.status === 'restored' || deleteRestoring}
>
<RotateCcw className="mr-2 h-4 w-4" />
{selectedDeleteOperation.status === 'restored' ? '已恢复' : '恢复这次删除'}
</Button>
</div>
<div className="grid gap-3 lg:grid-cols-4">
<div className="rounded-lg border bg-background/60 p-3">
<div className="text-xs text-muted-foreground"></div>
<div className="mt-1 text-sm">{selectedDeleteOperation.requested_by || '-'}</div>
</div>
<div className="rounded-lg border bg-background/60 p-3">
<div className="text-xs text-muted-foreground"></div>
<div className="mt-1 text-sm">{formatDeleteOperationTime(selectedDeleteOperation.created_at)}</div>
</div>
<div className="rounded-lg border bg-background/60 p-3">
<div className="text-xs text-muted-foreground"></div>
<div className="mt-1 text-sm">{formatDeleteOperationTime(selectedDeleteOperation.restored_at)}</div>
</div>
<div className="rounded-lg border bg-background/60 p-3">
<div className="text-xs text-muted-foreground"></div>
<div className="mt-1 flex flex-wrap gap-2">
<Badge variant="outline"> {Number(selectedOperationCounts.entities ?? 0)}</Badge>
<Badge variant="outline"> {Number(selectedOperationCounts.relations ?? 0)}</Badge>
<Badge variant="outline"> {Number(selectedOperationCounts.paragraphs ?? 0)}</Badge>
<Badge variant="outline"> {Number(selectedOperationCounts.sources ?? 0)}</Badge>
</div>
</div>
</div>
{selectedOperationDetailLoading ? (
<div className="rounded-lg border bg-background/60 p-4 text-sm text-muted-foreground">
...
</div>
) : null}
{selectedOperationDetailError ? (
<Alert variant="destructive">
<AlertDescription>{selectedOperationDetailError}</AlertDescription>
</Alert>
) : null}
{selectedOperationSources.length > 0 ? (
<div className="space-y-2">
<div className="text-sm font-semibold"></div>
<div className="flex flex-wrap gap-2">
{selectedOperationSources.map((source) => (
<Badge key={source} variant="secondary" className="max-w-full break-all">
{source}
</Badge>
))}
</div>
</div>
) : null}
<div className="grid gap-4 xl:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
<div className="space-y-2">
<div className="text-sm font-semibold"></div>
<pre className="max-h-56 overflow-auto rounded-lg border bg-background/70 p-3 text-xs break-words whitespace-pre-wrap">
{JSON.stringify(selectedDeleteOperation.selector ?? {}, null, 2)}
</pre>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="text-sm font-semibold"></div>
<div className="text-xs text-muted-foreground">
{filteredSelectedOperationItems.length} / {selectedOperationItems.length}
</div>
</div>
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<Input
value={selectedOperationItemSearch}
onChange={(event) => setSelectedOperationItemSearch(event.target.value)}
placeholder="搜索对象类型 / 哈希 / 对象键 / 来源"
className="lg:max-w-sm"
/>
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground lg:min-w-[180px] lg:justify-end">
<span> {selectedOperationItemPage} / {selectedOperationItemPageCount} </span>
<span> {DELETE_OPERATION_ITEM_PAGE_SIZE} </span>
</div>
</div>
<ScrollArea className="h-[280px] rounded-lg border bg-background/60">
<div className="space-y-2 p-3">
{pagedSelectedOperationItems.length > 0 ? pagedSelectedOperationItems.map((item) => {
const source = getDeleteOperationItemSource(item)
const label = getDeleteOperationItemLabel(item)
const preview = getDeleteOperationItemPreview(item)
return (
<div key={`${item.item_type}:${item.item_hash}:${item.item_key ?? ''}`} className="rounded-lg border bg-muted/20 p-3">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">{item.item_type}</Badge>
{source ? <Badge variant="secondary">{source}</Badge> : null}
{item.item_key && item.item_key !== item.item_hash ? (
<span className="text-xs text-muted-foreground break-all">{item.item_key}</span>
) : null}
</div>
<div className="mt-2 text-sm font-medium break-words">
{label}
</div>
{preview ? (
<div className="mt-1 text-xs text-muted-foreground break-words">
{preview}
</div>
) : null}
<div className="mt-2 font-mono text-[11px] break-all text-muted-foreground">
{item.item_hash}
</div>
</div>
)
}) : (
<div className="rounded-lg border border-dashed bg-muted/20 p-6 text-center text-sm text-muted-foreground">
{selectedOperationItems.length > 0 ? '当前筛选条件下没有明细项' : '当前操作没有记录明细项'}
</div>
)}
</div>
</ScrollArea>
<div className="flex items-center justify-between gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setSelectedOperationItemPage((current) => Math.max(1, current - 1))}
disabled={selectedOperationItemPage <= 1}
>
</Button>
<div className="text-xs text-muted-foreground">
</div>
<Button
variant="outline"
size="sm"
onClick={() => setSelectedOperationItemPage((current) => Math.min(selectedOperationItemPageCount, current + 1))}
disabled={selectedOperationItemPage >= selectedOperationItemPageCount}
>
</Button>
</div>
</div>
</div>
</div>
) : (
<div className="flex min-h-[320px] items-center justify-center rounded-lg border border-dashed bg-background/40 p-6 text-center text-sm text-muted-foreground">
</div>
)}
</div>
</CardContent>
</Card>
</div>
</TabsContent>
)
}

View File

@@ -0,0 +1,512 @@
import type { Dispatch, SetStateAction } from 'react'
import { RotateCcw } from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { TabsContent } from '@/components/ui/tabs'
import { cn } from '@/lib/utils'
import type {
MemoryFeedbackActionLogPayload,
MemoryFeedbackCorrectionDetailTaskPayload,
MemoryFeedbackCorrectionSummaryPayload,
} from '@/lib/memory-api'
import { FEEDBACK_ACTION_LOG_PAGE_SIZE, FEEDBACK_CORRECTION_PAGE_SIZE } from '../constants'
import {
buildFeedbackImpactSummary,
describeFeedbackActionLog,
formatDeleteOperationTime,
formatFeedbackActionType,
formatFeedbackDecision,
formatFeedbackRollbackStatus,
formatFeedbackTaskStatus,
getFeedbackCorrectionPreview,
getFeedbackStatusVariant,
summarizeFeedbackActionPayload,
} from '../utils'
export interface FeedbackTabProps {
feedbackSearch: string
setFeedbackSearch: Dispatch<SetStateAction<string>>
feedbackStatusFilter: string
setFeedbackStatusFilter: Dispatch<SetStateAction<string>>
feedbackRollbackFilter: string
setFeedbackRollbackFilter: Dispatch<SetStateAction<string>>
filteredFeedbackCorrections: MemoryFeedbackCorrectionSummaryPayload[]
feedbackCorrections: MemoryFeedbackCorrectionSummaryPayload[]
pagedFeedbackCorrections: MemoryFeedbackCorrectionSummaryPayload[]
feedbackPage: number
setFeedbackPage: Dispatch<SetStateAction<number>>
feedbackPageCount: number
selectedFeedbackCorrection: MemoryFeedbackCorrectionSummaryPayload | null
setSelectedFeedbackTaskId: Dispatch<SetStateAction<number>>
selectedFeedbackResolved: MemoryFeedbackCorrectionDetailTaskPayload | null
selectedFeedbackPreview: ReturnType<typeof getFeedbackCorrectionPreview>
selectedFeedbackImpactSummary: string[]
openFeedbackRollbackDialog: () => void
feedbackRollingBack: boolean
selectedFeedbackTaskLoading: boolean
selectedFeedbackTaskError: string | null
feedbackActionLogPage: number
setFeedbackActionLogPage: Dispatch<SetStateAction<number>>
feedbackActionLogPageCount: number
feedbackActionLogSearch: string
setFeedbackActionLogSearch: Dispatch<SetStateAction<string>>
pagedFeedbackActionLogs: MemoryFeedbackActionLogPayload[]
selectedFeedbackActionLogs: MemoryFeedbackActionLogPayload[]
}
export function FeedbackTab(props: FeedbackTabProps) {
const {
feedbackSearch,
setFeedbackSearch,
feedbackStatusFilter,
setFeedbackStatusFilter,
feedbackRollbackFilter,
setFeedbackRollbackFilter,
filteredFeedbackCorrections,
feedbackCorrections,
pagedFeedbackCorrections,
feedbackPage,
setFeedbackPage,
feedbackPageCount,
selectedFeedbackCorrection,
setSelectedFeedbackTaskId,
selectedFeedbackResolved,
selectedFeedbackPreview,
selectedFeedbackImpactSummary,
openFeedbackRollbackDialog,
feedbackRollingBack,
selectedFeedbackTaskLoading,
selectedFeedbackTaskError,
feedbackActionLogPage,
setFeedbackActionLogPage,
feedbackActionLogPageCount,
feedbackActionLogSearch,
setFeedbackActionLogSearch,
pagedFeedbackActionLogs,
selectedFeedbackActionLogs,
} = props
return (
<TabsContent value="feedback" className="space-y-4">
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<RotateCcw className="h-4 w-4" />
</CardTitle>
<CardDescription>
feedback correction 退
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_180px_180px]">
<Input
value={feedbackSearch}
onChange={(event) => setFeedbackSearch(event.target.value)}
placeholder="搜索查询编号 / 会话 / 查询内容 / 原因"
/>
<Select value={feedbackStatusFilter} onValueChange={setFeedbackStatusFilter}>
<SelectTrigger>
<SelectValue placeholder="按任务状态筛选" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="applied"></SelectItem>
<SelectItem value="skipped"></SelectItem>
<SelectItem value="error"></SelectItem>
<SelectItem value="running"></SelectItem>
<SelectItem value="pending"></SelectItem>
</SelectContent>
</Select>
<Select value={feedbackRollbackFilter} onValueChange={setFeedbackRollbackFilter}>
<SelectTrigger>
<SelectValue placeholder="按回退状态筛选" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">退</SelectItem>
<SelectItem value="none">退</SelectItem>
<SelectItem value="rolled_back">退</SelectItem>
<SelectItem value="error">退</SelectItem>
<SelectItem value="running">退</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-wrap items-center justify-between gap-2 rounded-xl border bg-background/70 px-3 py-2 text-sm text-muted-foreground">
<span> {filteredFeedbackCorrections.length} {feedbackCorrections.length} </span>
<span> {feedbackPage} / {feedbackPageCount} {FEEDBACK_CORRECTION_PAGE_SIZE} </span>
</div>
<div className="grid items-start gap-4 xl:grid-cols-[minmax(0,0.92fr)_minmax(0,1.08fr)]">
<ScrollArea className="h-[720px] rounded-lg border">
<div className="space-y-3 p-3">
{pagedFeedbackCorrections.length > 0 ? pagedFeedbackCorrections.map((item) => {
const isSelected = selectedFeedbackCorrection?.task_id === item.task_id
const preview = getFeedbackCorrectionPreview(item)
const impactSummary = buildFeedbackImpactSummary(item)
return (
<button
key={item.task_id}
type="button"
onClick={() => setSelectedFeedbackTaskId(item.task_id)}
className={cn(
'w-full rounded-xl border p-4 text-left transition-colors',
isSelected
? 'border-primary bg-primary/5 shadow-sm'
: 'bg-muted/20 hover:border-primary/40 hover:bg-muted/40',
)}
>
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="flex flex-wrap items-center gap-2">
<Badge variant={getFeedbackStatusVariant(item.task_status)}>
{formatFeedbackTaskStatus(item.task_status)}
</Badge>
<Badge variant={getFeedbackStatusVariant(item.rollback_status)}>
{formatFeedbackRollbackStatus(item.rollback_status)}
</Badge>
<Badge variant="outline">
{formatFeedbackDecision(item.decision)}
</Badge>
</div>
<div className="text-[11px] text-muted-foreground">
{formatDeleteOperationTime(item.query_timestamp ?? item.created_at)}
</div>
</div>
<div className="space-y-1">
<div className="text-sm font-semibold break-words">
{preview.headline}
</div>
<div className="text-xs text-muted-foreground break-words">
{item.query_text || '无查询文本'}
</div>
</div>
{(preview.oldRelation || preview.newRelation) ? (
<div className="grid gap-2 rounded-lg border bg-background/70 p-3 text-xs shadow-sm">
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] sm:items-stretch">
<div className="rounded-md border border-amber-500/20 bg-amber-500/5 p-2">
<div className="text-[11px] font-medium text-amber-700 dark:text-amber-300"></div>
<div className="mt-1 break-words">{preview.oldRelation || '无'}</div>
</div>
<div className="hidden items-center text-muted-foreground sm:flex"></div>
<div className="rounded-md border border-emerald-500/20 bg-emerald-500/5 p-2">
<div className="text-[11px] font-medium text-emerald-700 dark:text-emerald-300"></div>
<div className="mt-1 break-words">{preview.newRelation || '无'}</div>
</div>
</div>
</div>
) : null}
<div className="flex flex-wrap gap-2">
{impactSummary.length > 0 ? impactSummary.slice(0, 3).map((summary) => (
<Badge key={`${item.task_id}:${summary}`} variant="secondary" className="font-normal">
{summary}
</Badge>
)) : (
<Badge variant="secondary" className="font-normal">
</Badge>
)}
</div>
<div className="font-mono text-[11px] break-all text-muted-foreground">
{item.query_tool_id}
</div>
</div>
</button>
)
}) : (
<div className="rounded-lg border border-dashed bg-muted/20 p-6 text-center text-sm text-muted-foreground">
</div>
)}
</div>
</ScrollArea>
<div className="self-start rounded-xl border bg-muted/20 p-4">
{selectedFeedbackCorrection ? (
<div className="space-y-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<Badge variant={getFeedbackStatusVariant(String(selectedFeedbackResolved?.task_status ?? ''))}>
{formatFeedbackTaskStatus(String(selectedFeedbackResolved?.task_status ?? ''))}
</Badge>
<Badge variant={getFeedbackStatusVariant(String(selectedFeedbackResolved?.rollback_status ?? 'none'))}>
{formatFeedbackRollbackStatus(String(selectedFeedbackResolved?.rollback_status ?? 'none'))}
</Badge>
<Badge variant="outline">
{formatFeedbackDecision(String(selectedFeedbackResolved?.decision ?? ''))}
</Badge>
</div>
<div className="text-base font-semibold break-words">
{selectedFeedbackPreview.headline}
</div>
<div className="text-sm text-muted-foreground break-words">
{selectedFeedbackResolved?.query_text || '无查询文本'}
</div>
<div className="font-mono text-xs break-all text-muted-foreground">
{selectedFeedbackResolved?.query_tool_id}
</div>
</div>
<Button
size="sm"
variant="outline"
onClick={openFeedbackRollbackDialog}
disabled={
String(selectedFeedbackResolved?.task_status ?? '') !== 'applied'
|| String(selectedFeedbackResolved?.rollback_status ?? 'none') === 'rolled_back'
|| feedbackRollingBack
}
>
<RotateCcw className="mr-2 h-4 w-4" />
{String(selectedFeedbackResolved?.rollback_status ?? 'none') === 'rolled_back'
? '已回退'
: '回退本次纠错'}
</Button>
</div>
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.05fr)_minmax(0,0.95fr)]">
<div className="rounded-xl border bg-background/70 p-4 shadow-sm">
<div className="text-sm font-semibold"></div>
<div className="mt-3 grid gap-3 md:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] md:items-stretch">
<div className="rounded-lg border border-amber-500/20 bg-amber-500/5 p-3">
<div className="text-xs font-medium text-amber-700 dark:text-amber-300"></div>
<div className="mt-2 text-sm break-words">
{selectedFeedbackPreview.oldRelation || '当前详情没有记录旧结论'}
</div>
</div>
<div className="hidden items-center justify-center text-muted-foreground md:flex"></div>
<div className="rounded-lg border border-emerald-500/20 bg-emerald-500/5 p-3">
<div className="text-xs font-medium text-emerald-700 dark:text-emerald-300"></div>
<div className="mt-2 text-sm break-words">
{selectedFeedbackPreview.newRelation || '当前详情没有记录新结论'}
</div>
</div>
</div>
</div>
<div className="rounded-xl border bg-background/70 p-4 shadow-sm">
<div className="text-sm font-semibold"></div>
<div className="mt-3 flex flex-wrap gap-2">
{selectedFeedbackImpactSummary.length > 0 ? selectedFeedbackImpactSummary.map((summary) => (
<Badge key={summary} variant="secondary" className="bg-primary/10 font-normal text-primary hover:bg-primary/15">
{summary}
</Badge>
)) : (
<div className="text-sm text-muted-foreground"></div>
)}
</div>
</div>
</div>
<div className="grid gap-3 lg:grid-cols-4">
<div className="rounded-lg border bg-background/60 p-3">
<div className="text-xs text-muted-foreground"></div>
<div className="mt-1 text-sm break-all">{selectedFeedbackResolved?.session_id || '-'}</div>
</div>
<div className="rounded-lg border bg-background/60 p-3">
<div className="text-xs text-muted-foreground"></div>
<div className="mt-1 text-sm">{Number(selectedFeedbackResolved?.feedback_message_count ?? 0)}</div>
</div>
<div className="rounded-lg border bg-background/60 p-3">
<div className="text-xs text-muted-foreground"></div>
<div className="mt-1 text-sm">{Number(selectedFeedbackResolved?.decision_confidence ?? 0).toFixed(2)}</div>
</div>
<div className="rounded-lg border bg-background/60 p-3">
<div className="text-xs text-muted-foreground">退</div>
<div className="mt-1 text-sm">{formatDeleteOperationTime(selectedFeedbackResolved?.rolled_back_at)}</div>
</div>
</div>
{selectedFeedbackTaskLoading ? (
<div className="rounded-lg border bg-background/60 p-4 text-sm text-muted-foreground">
...
</div>
) : null}
{selectedFeedbackTaskError ? (
<Alert variant="destructive">
<AlertDescription>{selectedFeedbackTaskError}</AlertDescription>
</Alert>
) : null}
{selectedFeedbackResolved?.rollback_error ? (
<Alert variant="destructive">
<AlertDescription>{selectedFeedbackResolved.rollback_error}</AlertDescription>
</Alert>
) : null}
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.05fr)_minmax(0,0.95fr)]">
<div className="rounded-xl border bg-background/70 p-4">
<div className="text-sm font-semibold">退</div>
<div className="mt-3 space-y-2 text-sm text-muted-foreground">
<div></div>
<div> Episode / Profile </div>
<div>退</div>
</div>
</div>
<div className="rounded-xl border bg-background/70 p-4">
<div className="text-sm font-semibold"></div>
<div className="mt-3 grid gap-2 text-sm text-muted-foreground">
<div>{formatFeedbackDecision(String(selectedFeedbackResolved?.decision ?? ''))}</div>
<div>{formatFeedbackTaskStatus(String(selectedFeedbackResolved?.task_status ?? ''))}</div>
<div>退{formatFeedbackRollbackStatus(String(selectedFeedbackResolved?.rollback_status ?? 'none'))}</div>
<div>{Number(selectedFeedbackResolved?.feedback_message_count ?? 0)}</div>
</div>
</div>
</div>
<div className="space-y-3">
<div className="text-sm font-semibold"></div>
<div className="grid gap-3 xl:grid-cols-2">
<details className="rounded-lg border bg-background/70 p-3">
<summary className="cursor-pointer text-sm font-medium"> JSON</summary>
<pre className="mt-3 max-h-56 overflow-auto text-xs break-words whitespace-pre-wrap">
{JSON.stringify(selectedFeedbackResolved?.query_snapshot ?? {}, null, 2)}
</pre>
</details>
<details className="rounded-lg border bg-background/70 p-3">
<summary className="cursor-pointer text-sm font-medium"> JSON</summary>
<pre className="mt-3 max-h-56 overflow-auto text-xs break-words whitespace-pre-wrap">
{JSON.stringify(selectedFeedbackResolved?.decision_payload ?? {}, null, 2)}
</pre>
</details>
<details className="rounded-lg border bg-background/70 p-3">
<summary className="cursor-pointer text-sm font-medium">退 JSON</summary>
<pre className="mt-3 max-h-64 overflow-auto text-xs break-words whitespace-pre-wrap">
{JSON.stringify(selectedFeedbackResolved?.rollback_plan_summary ?? {}, null, 2)}
</pre>
</details>
<details className="rounded-lg border bg-background/70 p-3">
<summary className="cursor-pointer text-sm font-medium">退 JSON</summary>
<pre className="mt-3 max-h-64 overflow-auto text-xs break-words whitespace-pre-wrap">
{JSON.stringify(selectedFeedbackResolved?.rollback_result ?? {}, null, 2)}
</pre>
</details>
</div>
</div>
<details className="rounded-xl border bg-background/70 p-4">
<summary className="cursor-pointer text-sm font-semibold">
线
</summary>
<div className="mt-4 space-y-2">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="text-xs text-muted-foreground">
{feedbackActionLogPage} / {feedbackActionLogPageCount} {FEEDBACK_ACTION_LOG_PAGE_SIZE}
</div>
<Input
value={feedbackActionLogSearch}
onChange={(event) => setFeedbackActionLogSearch(event.target.value)}
placeholder="搜索动作 / 目标哈希 / 预览内容"
className="lg:w-80"
/>
</div>
<ScrollArea className="h-[240px] rounded-lg border bg-background/60">
<div className="space-y-2 p-3">
{pagedFeedbackActionLogs.length > 0 ? pagedFeedbackActionLogs.map((item: MemoryFeedbackActionLogPayload) => (
<div key={`${item.id}:${item.action_type}`} className="rounded-lg border bg-muted/20 p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">{formatFeedbackActionType(item.action_type)}</Badge>
{item.target_hash ? (
<span className="font-mono text-[11px] break-all text-muted-foreground">{item.target_hash}</span>
) : null}
</div>
<div className="text-[11px] text-muted-foreground">
{formatDeleteOperationTime(item.created_at)}
</div>
</div>
<div className="mt-2 text-sm break-words">
{describeFeedbackActionLog(item)}
</div>
{item.reason ? (
<div className="mt-2 text-xs text-muted-foreground break-words">
{item.reason}
</div>
) : null}
{item.before_payload && Object.keys(item.before_payload).length > 0 ? (
<div className="mt-3 rounded-md border bg-background/70 p-2 text-xs break-words">
<span className="font-medium"></span>
<span className="text-muted-foreground">{summarizeFeedbackActionPayload(item.before_payload)}</span>
</div>
) : null}
{item.after_payload && Object.keys(item.after_payload).length > 0 ? (
<div className="mt-2 rounded-md border bg-background/70 p-2 text-xs break-words">
<span className="font-medium"></span>
<span className="text-muted-foreground">{summarizeFeedbackActionPayload(item.after_payload)}</span>
</div>
) : null}
</div>
)) : (
<div className="rounded-lg border border-dashed bg-muted/20 p-6 text-center text-sm text-muted-foreground">
{selectedFeedbackActionLogs.length > 0 ? '当前筛选条件下没有动作日志' : '当前任务没有动作日志'}
</div>
)}
</div>
</ScrollArea>
<div className="flex items-center justify-between gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setFeedbackActionLogPage((current) => Math.max(1, current - 1))}
disabled={feedbackActionLogPage <= 1}
>
</Button>
<div className="text-xs text-muted-foreground"></div>
<Button
variant="outline"
size="sm"
onClick={() => setFeedbackActionLogPage((current) => Math.min(feedbackActionLogPageCount, current + 1))}
disabled={feedbackActionLogPage >= feedbackActionLogPageCount}
>
</Button>
</div>
</div>
</details>
</div>
) : (
<div className="flex min-h-[360px] items-center justify-center rounded-lg border border-dashed bg-background/40 p-6 text-center text-sm text-muted-foreground">
</div>
)}
</div>
</div>
<div className="flex items-center justify-between gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setFeedbackPage((current) => Math.max(1, current - 1))}
disabled={feedbackPage <= 1}
>
</Button>
<div className="text-xs text-muted-foreground">
退
</div>
<Button
variant="outline"
size="sm"
onClick={() => setFeedbackPage((current) => Math.min(feedbackPageCount, current + 1))}
disabled={feedbackPage >= feedbackPageCount}
>
</Button>
</div>
</CardContent>
</Card>
</div>
</TabsContent>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,193 @@
import type { Dispatch, SetStateAction } from 'react'
import { Sparkles } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { CodeEditor } from '@/components/CodeEditor'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { TabsContent } from '@/components/ui/tabs'
import type { MemoryTaskPayload } from '@/lib/memory-api'
import { getImportStatusVariant } from '../utils'
export interface TuningTabProps {
tuningObjective: string
setTuningObjective: Dispatch<SetStateAction<string>>
tuningIntensity: string
setTuningIntensity: Dispatch<SetStateAction<string>>
tuningSampleSize: string
setTuningSampleSize: Dispatch<SetStateAction<string>>
tuningTopKEval: string
setTuningTopKEval: Dispatch<SetStateAction<string>>
submitTuningTask: () => Promise<void>
creatingTuning: boolean
tuningProfile: Record<string, unknown>
tuningProfileToml: string
tuningTasks: MemoryTaskPayload[]
applyBestTask: (taskId: string) => Promise<void>
}
export function TuningTab(props: TuningTabProps) {
const {
tuningObjective,
setTuningObjective,
tuningIntensity,
setTuningIntensity,
tuningSampleSize,
setTuningSampleSize,
tuningTopKEval,
setTuningTopKEval,
submitTuningTask,
creatingTuning,
tuningProfile,
tuningProfileToml,
tuningTasks,
applyBestTask,
} = props
return (
<TabsContent value="tuning" className="space-y-4">
<div className="grid gap-4 xl:grid-cols-[0.95fr_1.05fr]">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Sparkles className="h-4 w-4" />
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-3 rounded-lg border bg-muted/20 p-4">
<div className="space-y-1">
<div className="text-sm font-medium"></div>
<div className="text-xs text-muted-foreground"> balanced / standard </div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label></Label>
<div className="text-xs text-muted-foreground"></div>
<Select value={tuningObjective} onValueChange={setTuningObjective}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="precision_priority">precision_priority</SelectItem>
<SelectItem value="balanced">balanced</SelectItem>
<SelectItem value="recall_priority">recall_priority</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label></Label>
<div className="text-xs text-muted-foreground"></div>
<Select value={tuningIntensity} onValueChange={setTuningIntensity}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="quick">quick</SelectItem>
<SelectItem value="standard">standard</SelectItem>
<SelectItem value="deep">deep</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
<div className="space-y-3 rounded-lg border bg-muted/20 p-4">
<div className="space-y-1">
<div className="text-sm font-medium"></div>
<div className="text-xs text-muted-foreground">使</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label></Label>
<div className="text-xs text-muted-foreground"></div>
<Input type="number" value={tuningSampleSize} onChange={(event) => setTuningSampleSize(event.target.value)} />
</div>
<div className="space-y-2">
<Label> Top-K</Label>
<div className="text-xs text-muted-foreground"></div>
<Input type="number" value={tuningTopKEval} onChange={(event) => setTuningTopKEval(event.target.value)} />
</div>
</div>
</div>
<Button onClick={() => void submitTuningTask()} disabled={creatingTuning}>
<Sparkles className="mr-2 h-4 w-4" />
</Button>
</CardContent>
</Card>
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>便</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<CodeEditor
value={JSON.stringify(tuningProfile, null, 2)}
language="json"
readOnly
height="220px"
/>
<CodeEditor
value={tuningProfileToml}
language="toml"
readOnly
height="180px"
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{tuningTasks.length > 0 ? tuningTasks.map((task) => (
<TableRow key={String(task.task_id ?? Math.random())}>
<TableCell className="font-mono text-xs">{String(task.task_id ?? '-')}</TableCell>
<TableCell>
<Badge variant={getImportStatusVariant(String(task.status ?? ''))}>
{String(task.status ?? '-')}
</Badge>
</TableCell>
<TableCell>
<Button
size="sm"
variant="outline"
onClick={() => void applyBestTask(String(task.task_id ?? ''))}
disabled={!task.task_id}
>
</Button>
</TableCell>
</TableRow>
)) : (
<TableRow>
<TableCell colSpan={3} className="text-center text-muted-foreground">
使
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
</div>
</TabsContent>
)
}

View File

@@ -0,0 +1,498 @@
import type {
MemoryDeleteOperationPayload,
MemoryFeedbackActionLogPayload,
MemoryFeedbackCorrectionDetailTaskPayload,
MemoryFeedbackCorrectionSummaryPayload,
MemoryImportInputMode,
} from '@/lib/memory-api'
import {
IMPORT_STATUS_TEXT,
IMPORT_STEP_TEXT,
QUEUED_IMPORT_STATUS,
RUNNING_IMPORT_STATUS,
} from './constants'
export type DeleteOperationItem = NonNullable<MemoryDeleteOperationPayload['items']>[number]
export function normalizeProgress(value: number | string | null | undefined): number {
const numeric = Number(value ?? 0)
if (!Number.isFinite(numeric)) {
return 0
}
if (numeric < 0) {
return 0
}
if (numeric > 100) {
return 100
}
return numeric
}
export function parseOptionalPositiveInt(input: string): number | undefined {
const value = input.trim()
if (!value) {
return undefined
}
const parsed = Number(value)
if (!Number.isInteger(parsed) || parsed <= 0) {
return undefined
}
return parsed
}
export function parseCommaSeparatedList(input: string): string[] {
return input
.split(',')
.map((item) => item.trim())
.filter(Boolean)
}
export function normalizeImportInputMode(value: string): MemoryImportInputMode {
return value === 'json' ? 'json' : 'text'
}
export function getImportStatusLabel(status: string): string {
const normalized = String(status ?? '').trim()
if (!normalized) {
return '-'
}
return IMPORT_STATUS_TEXT[normalized] ?? normalized
}
export function getImportStepLabel(step: string): string {
const normalized = String(step ?? '').trim()
if (!normalized) {
return '-'
}
return IMPORT_STEP_TEXT[normalized] ?? normalized
}
export function getImportStatusVariant(status: string): 'default' | 'secondary' | 'destructive' | 'outline' {
if (status === 'failed') {
return 'destructive'
}
if (status === 'completed') {
return 'default'
}
if (status === 'completed_with_errors' || status === 'cancelled') {
return 'secondary'
}
if (RUNNING_IMPORT_STATUS.has(status) || QUEUED_IMPORT_STATUS.has(status)) {
return 'outline'
}
return 'secondary'
}
export function formatImportTime(timestamp?: number | null): string {
if (!timestamp) {
return '-'
}
const normalized = timestamp > 1_000_000_000_000 ? timestamp : timestamp * 1000
const value = new Date(normalized)
if (Number.isNaN(value.getTime())) {
return '-'
}
return value.toLocaleString('zh-CN', {
hour12: false,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
export function formatDeleteOperationMode(mode: string): string {
switch (mode) {
case 'entity':
return '实体'
case 'relation':
return '关系'
case 'paragraph':
return '段落'
case 'source':
return '来源'
case 'mixed':
return '混合'
default:
return mode || '未知'
}
}
export function formatDeleteOperationStatus(status: string): string {
switch (status) {
case 'executed':
return '已执行'
case 'restored':
return '已恢复'
default:
return status || '未知'
}
}
export function formatDeleteOperationTime(timestamp?: number | null): string {
if (!timestamp) {
return '未知时间'
}
const normalized = timestamp > 1_000_000_000_000 ? timestamp : timestamp * 1000
const value = new Date(normalized)
if (Number.isNaN(value.getTime())) {
return '未知时间'
}
return value.toLocaleString('zh-CN', {
hour12: false,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
export function trimDeleteItemText(value: string, maxLength: number = 140): string {
const normalized = String(value ?? '').trim().replace(/\s+/g, ' ')
if (!normalized) {
return ''
}
if (normalized.length <= maxLength) {
return normalized
}
return `${normalized.slice(0, maxLength)}...`
}
export function formatDeleteRelationText(subject: string, predicate: string, object: string): string {
const left = String(subject ?? '').trim()
const middle = String(predicate ?? '').trim()
const right = String(object ?? '').trim()
return [left, middle, right].filter(Boolean).join(' -> ')
}
export function getDeleteOperationItemLabel(item: DeleteOperationItem): string {
const payload = item.payload ?? {}
if (item.item_type === 'entity') {
const entity = (payload.entity ?? {}) as Record<string, unknown>
return String(entity.name ?? item.item_key ?? item.item_hash ?? '未命名实体')
}
if (item.item_type === 'relation') {
const relation = (payload.relation ?? {}) as Record<string, unknown>
return (
formatDeleteRelationText(
String(relation.subject ?? ''),
String(relation.predicate ?? ''),
String(relation.object ?? ''),
) || String(item.item_key ?? item.item_hash ?? '未命名关系')
)
}
if (item.item_type === 'paragraph') {
const paragraph = (payload.paragraph ?? {}) as Record<string, unknown>
const source = String(paragraph.source ?? '').trim()
return source || String(item.item_key ?? item.item_hash ?? '未命名段落')
}
return String(item.item_key ?? item.item_hash ?? '未命名对象')
}
export function getDeleteOperationItemPreview(item: DeleteOperationItem): string {
const payload = item.payload ?? {}
if (item.item_type === 'entity') {
const paragraphLinks = Array.isArray(payload.paragraph_links) ? payload.paragraph_links : []
if (paragraphLinks.length > 0) {
return `关联段落 ${paragraphLinks.length}`
}
return '实体快照'
}
if (item.item_type === 'relation') {
const relation = (payload.relation ?? {}) as Record<string, unknown>
const paragraphHashes = Array.isArray(payload.paragraph_hashes) ? payload.paragraph_hashes : []
const { confidence } = relation
const parts = []
if (paragraphHashes.length > 0) {
parts.push(`证据段落 ${paragraphHashes.length}`)
}
if (typeof confidence === 'number') {
parts.push(`置信度 ${confidence.toFixed(2)}`)
}
return parts.join('') || '关系快照'
}
if (item.item_type === 'paragraph') {
const paragraph = (payload.paragraph ?? {}) as Record<string, unknown>
return trimDeleteItemText(String(paragraph.content ?? ''))
}
return ''
}
export function getDeleteOperationItemSource(item: DeleteOperationItem): string {
const payload = item.payload ?? {}
if (item.item_type === 'paragraph') {
const paragraph = (payload.paragraph ?? {}) as Record<string, unknown>
return String(paragraph.source ?? '').trim()
}
return String(payload.source ?? '').trim()
}
export function formatFeedbackDecision(decision: string): string {
switch (decision) {
case 'correct':
return '纠正'
case 'reject':
return '否定'
case 'confirm':
return '确认'
case 'supplement':
return '补充'
case 'none':
return '无动作'
default:
return decision || '未知'
}
}
export function formatFeedbackTaskStatus(status: string): string {
switch (status) {
case 'pending':
return '待处理'
case 'running':
return '处理中'
case 'applied':
return '已应用'
case 'skipped':
return '已跳过'
case 'error':
return '失败'
default:
return status || '未知'
}
}
export function formatFeedbackRollbackStatus(status: string): string {
switch (status) {
case 'none':
return '未回退'
case 'running':
return '回退中'
case 'rolled_back':
return '已回退'
case 'error':
return '回退失败'
default:
return status || '未知'
}
}
export function getFeedbackStatusVariant(
status: string,
): 'default' | 'secondary' | 'destructive' | 'outline' {
if (status === 'applied' || status === 'rolled_back') {
return 'default'
}
if (status === 'error') {
return 'destructive'
}
if (status === 'running' || status === 'pending') {
return 'outline'
}
return 'secondary'
}
export function summarizeFeedbackActionPayload(value: Record<string, unknown> | undefined): string {
if (!value) {
return ''
}
const hash = String(value.hash ?? '').trim()
const subject = String(value.subject ?? '').trim()
const predicate = String(value.predicate ?? '').trim()
const object = String(value.object ?? '').trim()
if (subject && predicate && object) {
return formatDeleteRelationText(subject, predicate, object)
}
if (hash) {
return hash
}
if (Array.isArray(value.target_hashes) && value.target_hashes.length > 0) {
return `targets ${value.target_hashes.length}`
}
return trimDeleteItemText(JSON.stringify(value, null, 2), 120)
}
export function pickFeedbackRelationTriplet(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object') {
return null
}
const record = value as Record<string, unknown>
const subject = String(record.subject ?? '').trim()
const predicate = String(record.predicate ?? '').trim()
const object = String(record.object ?? '').trim()
if (!subject || !predicate || !object) {
return null
}
return record
}
export function formatFeedbackRelationTriplet(value: unknown): string {
const triplet = pickFeedbackRelationTriplet(value)
if (!triplet) {
return ''
}
return formatDeleteRelationText(
String(triplet.subject ?? ''),
String(triplet.predicate ?? ''),
String(triplet.object ?? ''),
)
}
export function getFeedbackCorrectionPreview(
task: MemoryFeedbackCorrectionDetailTaskPayload | MemoryFeedbackCorrectionSummaryPayload | null,
): {
headline: string
oldRelation: string
newRelation: string
} {
if (!task) {
return {
headline: '当前没有纠错摘要',
oldRelation: '',
newRelation: '',
}
}
const detailTask = task as MemoryFeedbackCorrectionDetailTaskPayload
const rollbackPlanSummary = detailTask.rollback_plan_summary ?? {}
const forgottenRelations = Array.isArray(rollbackPlanSummary.forgotten_relations)
? rollbackPlanSummary.forgotten_relations
: []
const correctedWrite = rollbackPlanSummary.corrected_write && typeof rollbackPlanSummary.corrected_write === 'object'
? rollbackPlanSummary.corrected_write
: {}
const correctedRelations = Array.isArray((correctedWrite as Record<string, unknown>).corrected_relations)
? ((correctedWrite as Record<string, unknown>).corrected_relations as unknown[])
: []
const oldRelation = formatFeedbackRelationTriplet(forgottenRelations[0])
const newRelation = formatFeedbackRelationTriplet(correctedRelations[0])
if (oldRelation && newRelation) {
return {
headline: `将“${oldRelation}”纠正为“${newRelation}`,
oldRelation,
newRelation,
}
}
if (newRelation) {
return {
headline: `补充了新的纠错结论:“${newRelation}`,
oldRelation: '',
newRelation,
}
}
if (oldRelation) {
return {
headline: `撤销了旧记忆关系:“${oldRelation}`,
oldRelation,
newRelation: '',
}
}
return {
headline: task.query_text || '当前纠错没有可读摘要',
oldRelation: '',
newRelation: '',
}
}
export function buildFeedbackImpactSummary(
task: MemoryFeedbackCorrectionDetailTaskPayload | MemoryFeedbackCorrectionSummaryPayload | null,
): string[] {
if (!task) {
return []
}
const counts = task.affected_counts ?? {}
const items: string[] = []
if (Number(counts.relations ?? 0) > 0) {
items.push(`影响关系 ${Number(counts.relations ?? 0)}`)
}
if (Number(counts.corrected_relations ?? 0) > 0) {
items.push(`新增纠正关系 ${Number(counts.corrected_relations ?? 0)}`)
}
if (Number(counts.correction_paragraphs ?? 0) > 0) {
items.push(`写入纠错段落 ${Number(counts.correction_paragraphs ?? 0)}`)
}
if (Number(counts.stale_paragraphs ?? 0) > 0) {
items.push(`标记旧段落 ${Number(counts.stale_paragraphs ?? 0)}`)
}
if (Number(counts.episode_sources ?? 0) > 0) {
items.push(`触发 Episode 修复 ${Number(counts.episode_sources ?? 0)} 个来源`)
}
if (Number(counts.profile_person_ids ?? 0) > 0) {
items.push(`触发 Profile 刷新 ${Number(counts.profile_person_ids ?? 0)} 个对象`)
}
return items
}
export function formatFeedbackActionType(actionType: string): string {
switch (actionType) {
case 'classification':
return '判定纠错'
case 'forget_relation':
return '撤销旧关系'
case 'mark_stale_paragraph':
return '标记旧段落'
case 'write_correction':
return '写入纠错'
case 'rollback_restore_relation':
return '恢复旧关系'
case 'rollback_delete_correction_paragraph':
return '隐藏纠错段落'
case 'rollback_revert_corrected_relation':
return '撤销纠正关系'
case 'rollback_clear_stale_mark':
return '清除脏段落标记'
case 'rollback_enqueue_episode_rebuild':
return '加入 Episode 修复队列'
case 'rollback_enqueue_profile_refresh':
return '加入 Profile 刷新队列'
case 'rollback_error':
return '回退失败'
case 'error':
return '处理失败'
case 'skip':
return '跳过处理'
default:
return actionType || '未知动作'
}
}
export function describeFeedbackActionLog(item: MemoryFeedbackActionLogPayload): string {
const beforeSummary = summarizeFeedbackActionPayload(item.before_payload)
const afterSummary = summarizeFeedbackActionPayload(item.after_payload)
switch (item.action_type) {
case 'classification':
return afterSummary ? `系统完成判定:${afterSummary}` : '系统完成纠错判定'
case 'forget_relation':
return beforeSummary ? `旧关系已失效:${beforeSummary}` : '旧关系已被标记为失效'
case 'mark_stale_paragraph':
return '旧段落已标记为待复核,后续检索会更谨慎地使用它'
case 'write_correction':
return afterSummary ? `已写入新的纠错结果:${afterSummary}` : '已写入新的纠错段落和关系'
case 'rollback_restore_relation':
return afterSummary ? `已恢复旧关系状态:${afterSummary}` : '已恢复旧关系状态'
case 'rollback_delete_correction_paragraph':
return '已隐藏这次纠错写入的段落'
case 'rollback_revert_corrected_relation':
return '已撤销纠错阶段新增的关系'
case 'rollback_clear_stale_mark':
return '已清除旧段落的待复核标记'
case 'rollback_enqueue_episode_rebuild':
return '已重新加入 Episode 修复队列'
case 'rollback_enqueue_profile_refresh':
return '已重新加入 Profile 刷新队列'
case 'rollback_error':
return item.reason || '这次回退执行失败'
case 'error':
return item.reason || '这次纠错处理失败'
case 'skip':
return item.reason || '这次纠错被跳过'
default:
return afterSummary || beforeSummary || item.reason || '记录了一条动作日志'
}
}

View File

@@ -1,22 +1,4 @@
services:
adapters:
container_name: maim-bot-adapters
#### prod ####
image: unclas/maimbot-adapter:latest
# image: infinitycat/maimbot-adapter:latest
#### dev ####
# image: unclas/maimbot-adapter:dev
# image: infinitycat/maimbot-adapter:dev
environment:
- TZ=Asia/Shanghai
# ports:
# - "8095:8095"
volumes:
- ./docker-config/adapters/config.toml:/adapters/config.toml # 持久化adapters配置文件
- ./data/adapters:/adapters/data # adapters 数据持久化
restart: always
networks:
- maim_bot
core:
container_name: maim-bot-core
#### prod ####
@@ -27,6 +9,8 @@ services:
# image: infinitycat/maibot:dev
environment:
- TZ=Asia/Shanghai
- EULA_AGREE=1b662741904d7155d1ce1c00b3530d0d
- PRIVACY_AGREE=9943b855e72199d0f5016ea39052f1b6
- MAIBOT_LEGACY_0X_UPGRADE_CONFIRMED=1 # Docker 无法交互确认旧版升级迁移,默认跳过确认提示
# - EULA_AGREE=1b662741904d7155d1ce1c00b3530d0d # 同意EULA
# - PRIVACY_AGREE=9943b855e72199d0f5016ea39052f1b6 # 同意EULA
@@ -36,7 +20,6 @@ services:
volumes:
# 监听地址和端口已迁移到 ./docker-config/mmc/bot_config.toml 的 maim_message 与 webui 配置段
- ./docker-config/mmc:/MaiMBot/config # 持久化bot配置文件
- ./docker-config/adapters:/MaiMBot/adapters-config # adapter配置文件夹映射
- ./data/MaiMBot/maibot_statistics.html:/MaiMBot/maibot_statistics.html #统计数据输出
- ./data/MaiMBot:/MaiMBot/data # 共享目录
- ./data/MaiMBot/plugins:/MaiMBot/plugins # 插件目录

13
docker-entrypoint.sh Normal file
View File

@@ -0,0 +1,13 @@
#!/bin/sh
set -eu
ADAPTER_TEMPLATE="/MaiMBot/plugin-templates/MaiBot-Napcat-Adapter"
ADAPTER_TARGET="/MaiMBot/plugins/MaiBot-Napcat-Adapter"
mkdir -p /MaiMBot/plugins
if [ ! -e "$ADAPTER_TARGET" ] && [ -d "$ADAPTER_TEMPLATE" ]; then
cp -a "$ADAPTER_TEMPLATE" "$ADAPTER_TARGET"
fi
exec python bot.py "$@"

View File

@@ -19,7 +19,7 @@ dependencies = [
"jieba>=0.42.1",
"json-repair>=0.47.6",
"maim-message>=0.6.2",
"maibot-dashboard==1.0.0.dev2026040439",
"maibot-dashboard==1.0.1.dev2026050251",
"maibot-plugin-sdk>=2.3.0",
"matplotlib>=3.10.5",
"mcp",

View File

@@ -36,9 +36,9 @@ def get_webui_chat_broadcaster() -> Tuple[Any, Optional[str], Optional[str]]:
if _webui_chat_broadcaster is None:
try:
from src.webui.routers.chat import WEBUI_CHAT_PLATFORM, chat_manager
from src.webui.routers.chat.service import WEBUI_CHAT_GROUP_ID
_webui_chat_broadcaster = (chat_manager, WEBUI_CHAT_PLATFORM, WEBUI_CHAT_GROUP_ID)
# 默认不再强制虚拟群聊WebUI 默认走私聊频道,需要的话由调用者传入虚拟群 ID。
_webui_chat_broadcaster = (chat_manager, WEBUI_CHAT_PLATFORM, None)
except ImportError:
_webui_chat_broadcaster = (None, None, None)
return _webui_chat_broadcaster
@@ -98,6 +98,14 @@ async def _send_message(message: SessionMessage, show_log: bool = True) -> bool:
message_type = "rich"
segments = message_segments
# 私聊场景下出站消息的 user_info 是机器人自己的身份,
# 真正的接收者用户 ID 由 send_service 写入 ``platform_io_target_user_id``。
target_user_id = ""
additional_config = message.message_info.additional_config or {}
raw_target_user_id = additional_config.get("platform_io_target_user_id")
if raw_target_user_id:
target_user_id = str(raw_target_user_id).strip()
await chat_manager.broadcast_to_group(
group_id=group_id or default_group_id or "",
message={
@@ -113,6 +121,7 @@ async def _send_message(message: SessionMessage, show_log: bool = True) -> bool:
"is_bot": True,
},
},
user_id=target_user_id,
)
# 注意:机器人消息会由 MessageStorage.store_message 自动保存到数据库

View File

@@ -70,11 +70,15 @@ class ConfigSchemaGenerator:
) -> Dict[str, Any]:
field_docs = config_class.get_class_field_docs()
field_type = cls._map_field_type(annotation)
raw_description = field_docs.get(field_name, field_info.description or "")
# `_wrap_` 标记在配置类 docstring 中表示该说明应作为块级注释(独立成行)
# 在前端展示时把它转为换行符,使描述以新行起始或在中间换行
description = raw_description.replace("_wrap_", "\n").strip("\n")
schema: Dict[str, Any] = {
"name": field_name,
"type": field_type,
"label": field_name,
"description": field_docs.get(field_name, field_info.description or ""),
"description": description,
"required": field_info.is_required(),
}

View File

@@ -13,10 +13,10 @@ from src.config.config import global_config
from src.webui.dependencies import require_auth
from .service import (
WEBUI_CHAT_GROUP_ID,
WEBUI_CHAT_PLATFORM,
chat_history,
chat_manager,
normalize_webui_user_id,
)
logger = get_logger("webui.chat")
@@ -30,10 +30,15 @@ async def get_chat_history(
user_id: Optional[str] = Query(default=None),
group_id: Optional[str] = Query(default=None),
) -> Dict[str, object]:
"""获取聊天历史记录。"""
del user_id
target_group_id = group_id or WEBUI_CHAT_GROUP_ID
history = chat_history.get_history(limit, target_group_id)
"""获取聊天历史记录。
优先按 ``group_id`` 加载虚拟群聊历史;未提供时使用规范化后的 ``user_id`` 加载 WebUI 私聊历史。
"""
if group_id:
history = chat_history.get_history(limit, group_id=group_id)
else:
normalized_user_id = normalize_webui_user_id(user_id)
history = chat_history.get_history(limit, user_id=normalized_user_id)
return {"success": True, "messages": history, "total": len(history)}
@@ -100,10 +105,18 @@ async def get_persons_by_platform(
@router.delete("/history")
async def clear_chat_history(
user_id: Optional[str] = Query(default=None),
group_id: Optional[str] = Query(default=None),
) -> Dict[str, object]:
"""清空聊天历史记录。"""
deleted = chat_history.clear_history(group_id)
"""清空聊天历史记录。
优先按 ``group_id`` 清理虚拟群聊历史;未提供时使用规范化后的 ``user_id`` 清理 WebUI 私聊历史。
"""
if group_id:
deleted = chat_history.clear_history(group_id=group_id)
else:
normalized_user_id = normalize_webui_user_id(user_id)
deleted = chat_history.clear_history(user_id=normalized_user_id)
return {"success": True, "message": f"已清空 {deleted} 条聊天记录"}
@@ -113,6 +126,5 @@ async def get_chat_info() -> Dict[str, object]:
return {
"bot_name": global_config.bot.nickname,
"platform": WEBUI_CHAT_PLATFORM,
"group_id": WEBUI_CHAT_GROUP_ID,
"active_sessions": len(chat_manager.active_connections),
}

View File

@@ -18,6 +18,8 @@ from src.common.message_repository import find_messages
from src.common.utils.utils_session import SessionUtils
from src.config.config import global_config
from .serializers import serialize_message_sequence
logger = get_logger("webui.chat")
WEBUI_CHAT_GROUP_ID = "webui_local_chat"
@@ -61,7 +63,7 @@ class ChatSessionConnection:
client_session_id: str
user_id: str
user_name: str
active_group_id: str
channel_key: str
virtual_config: Optional[VirtualIdentityConfig]
sender: AsyncMessageSender
@@ -92,6 +94,21 @@ class ChatHistoryManager:
user_id = user_info.user_id or ""
is_bot = is_bot_self(msg.platform, user_id)
# 将存库中的 raw_message 序列化为前端可识别的富文本消息段,
# 避免“刚刚收到的机器人回复是富文本,刷新后变成纯文本”的体验不一致。
segments: List[Dict[str, Any]] = []
try:
raw_message = getattr(msg, "raw_message", None)
if raw_message is not None and getattr(raw_message, "components", None):
segments = serialize_message_sequence(raw_message)
except Exception as exc: # 仅记录警告,退化为纯文本
logger.debug(f"序列化历史消息段失败,退化为纯文本: {exc}")
segments = []
is_rich = bool(segments) and not (
len(segments) == 1 and segments[0].get("type") == "text"
)
return {
"id": msg.message_id,
"type": "bot" if is_bot else "user",
@@ -100,32 +117,119 @@ class ChatHistoryManager:
"sender_name": user_info.user_nickname or (global_config.bot.nickname if is_bot else "未知用户"),
"sender_id": "bot" if is_bot else user_id,
"is_bot": is_bot,
"message_type": "rich" if is_rich else "text",
"segments": segments if is_rich else None,
}
def _resolve_session_id(self, group_id: Optional[str]) -> str:
"""根据群组标识解析聊天会话 ID。
def _enrich_reply_segments(
self,
segments: List[Dict[str, Any]],
message_index: Dict[str, SessionMessage],
session_id: Optional[str],
) -> None:
"""回填历史消息中 reply 段缺失的发送者/原内容字段。
DB 中持久化的 ReplyComponent 通常只保留了 ``target_message_id``
``target_message_content`` / ``target_message_sender_*`` 字段为空。
这里基于当前会话已加载的消息列表(必要时回查数据库)进行补全。
Args:
group_id: 群组标识
segments: 单条历史消息的消息段列表,原地修改
message_index: 当前会话已加载消息的 ``message_id -> SessionMessage`` 索引。
session_id: 当前会话 ID用于按 ID 单查时缩小范围。
"""
for segment in segments:
if not isinstance(segment, dict) or segment.get("type") != "reply":
continue
data = segment.get("data")
if not isinstance(data, dict):
continue
target_message_id = data.get("target_message_id")
if not target_message_id:
continue
has_content = bool(str(data.get("target_message_content") or "").strip())
has_sender = any(
str(data.get(key) or "").strip()
for key in (
"target_message_sender_id",
"target_message_sender_nickname",
"target_message_sender_cardname",
)
)
if has_content and has_sender:
continue
target_msg = message_index.get(str(target_message_id))
if target_msg is None:
# 退化为按 ID 单查(仅当不在当前窗口内时才付出 DB 代价)
try:
from src.services.message_service import get_message_by_id
target_msg = get_message_by_id(str(target_message_id), session_id or None)
except Exception as exc:
logger.debug(f"按 ID 回查 reply 目标消息失败: {exc}")
target_msg = None
if target_msg is None:
continue
user_info = target_msg.message_info.user_info
if not has_content:
content_text = (
target_msg.processed_plain_text
or target_msg.display_message
or ""
)
data["target_message_content"] = content_text
if not has_sender:
data["target_message_sender_id"] = user_info.user_id or ""
data["target_message_sender_nickname"] = user_info.user_nickname or ""
data["target_message_sender_cardname"] = (
getattr(user_info, "user_cardname", "") or ""
)
def _resolve_session_id(
self,
group_id: Optional[str] = None,
user_id: Optional[str] = None,
) -> Optional[str]:
"""根据会话标识解析内部聊天会话 ID。
优先按虚拟群聊解析;否则按 WebUI 私聊解析。
Args:
group_id: 群组标识(虚拟群聊模式)。
user_id: 用户标识(私聊模式)。
Returns:
str: 内部聊天会话 ID。
Optional[str]: 内部聊天会话 ID;当 group_id 与 user_id 均未提供时返回 ``None``
"""
target_group_id = group_id or WEBUI_CHAT_GROUP_ID
return SessionUtils.calculate_session_id(WEBUI_CHAT_PLATFORM, group_id=target_group_id)
if group_id:
return SessionUtils.calculate_session_id(WEBUI_CHAT_PLATFORM, group_id=group_id)
if user_id:
return SessionUtils.calculate_session_id(WEBUI_CHAT_PLATFORM, user_id=user_id)
return None
def get_history(self, limit: int = 50, group_id: Optional[str] = None) -> List[Dict[str, Any]]:
def get_history(
self,
limit: int = 50,
group_id: Optional[str] = None,
user_id: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""获取指定会话的历史消息。
Args:
limit: 最大返回条数。
group_id: 群组标识。
group_id: 群组标识(虚拟群聊模式)
user_id: 用户标识(私聊模式)。
Returns:
List[Dict[str, Any]]: 历史消息列表。
"""
target_group_id = group_id or WEBUI_CHAT_GROUP_ID
session_id = self._resolve_session_id(target_group_id)
session_id = self._resolve_session_id(group_id=group_id, user_id=user_id)
if session_id is None:
logger.debug("获取聊天历史时缺少 group_id 与 user_id返回空列表")
return []
try:
messages = find_messages(
session_id=session_id,
@@ -133,30 +237,54 @@ class ChatHistoryManager:
limit_mode="latest",
filter_command=False,
)
result = [self._message_to_dict(msg, target_group_id) for msg in messages]
logger.debug(f"从数据库加载了 {len(result)} 条聊天记录 (group_id={target_group_id})")
# 构建 message_id -> SessionMessage 索引,用于回填历史中 reply 段的发送者/内容
# DB 中通常只存了 target_message_idtarget_message_content/sender_* 缺失)。
message_index: Dict[str, SessionMessage] = {}
for m in messages:
mid = getattr(m, "message_id", None)
if mid:
message_index[str(mid)] = m
result: List[Dict[str, Any]] = []
for msg in messages:
item = self._message_to_dict(msg, group_id)
segments = item.get("segments")
if segments:
self._enrich_reply_segments(segments, message_index, session_id)
result.append(item)
logger.debug(
f"从数据库加载了 {len(result)} 条聊天记录 (group_id={group_id}, user_id={user_id})"
)
return result
except Exception as exc:
logger.error(f"从数据库加载聊天记录失败: {exc}")
return []
def clear_history(self, group_id: Optional[str] = None) -> int:
def clear_history(
self,
group_id: Optional[str] = None,
user_id: Optional[str] = None,
) -> int:
"""清空指定会话的历史消息。
Args:
group_id: 群组标识。
group_id: 群组标识(虚拟群聊模式)
user_id: 用户标识(私聊模式)。
Returns:
int: 被删除的消息数量。
"""
target_group_id = group_id or WEBUI_CHAT_GROUP_ID
session_id = self._resolve_session_id(target_group_id)
session_id = self._resolve_session_id(group_id=group_id, user_id=user_id)
if session_id is None:
return 0
try:
with get_db_session() as session:
statement = delete(Messages).where(col(Messages.session_id) == session_id)
result = session.exec(statement)
deleted = result.rowcount or 0
logger.info(f"已清空 {deleted} 条聊天记录 (group_id={target_group_id})")
logger.info(
f"已清空 {deleted} 条聊天记录 (group_id={group_id}, user_id={user_id})"
)
return deleted
except Exception as exc:
logger.error(f"清空聊天记录失败: {exc}")
@@ -174,30 +302,30 @@ class ChatConnectionManager:
self.group_sessions: Dict[str, Set[str]] = {}
self.user_sessions: Dict[str, Set[str]] = {}
def _bind_group(self, session_id: str, group_id: str) -> None:
"""为会话绑定群组索引。
def _bind_channel(self, session_id: str, channel_key: str) -> None:
"""为会话绑定逻辑频道索引。
Args:
session_id: 内部会话 ID。
group_id: 群组标识
channel_key: 频道键(``group:<gid>`` 或 ``private:<uid>``
"""
group_session_ids = self.group_sessions.setdefault(group_id, set())
group_session_ids.add(session_id)
channel_session_ids = self.group_sessions.setdefault(channel_key, set())
channel_session_ids.add(session_id)
def _unbind_group(self, session_id: str, group_id: str) -> None:
"""移除会话与群组的索引关系。
def _unbind_channel(self, session_id: str, channel_key: str) -> None:
"""移除会话与逻辑频道的索引关系。
Args:
session_id: 内部会话 ID。
group_id: 群组标识
channel_key: 频道键
"""
group_session_ids = self.group_sessions.get(group_id)
if group_session_ids is None:
channel_session_ids = self.group_sessions.get(channel_key)
if channel_session_ids is None:
return
group_session_ids.discard(session_id)
if not group_session_ids:
del self.group_sessions[group_id]
channel_session_ids.discard(session_id)
if not channel_session_ids:
del self.group_sessions[channel_key]
async def connect(
self,
@@ -220,18 +348,39 @@ class ChatConnectionManager:
virtual_config: 当前虚拟身份配置。
sender: 发送消息到前端的异步回调。
"""
channel_key = compute_channel_key(virtual_config, user_id)
existing_session_id = self.client_sessions.get((connection_id, client_session_id))
if existing_session_id is not None and existing_session_id == session_id:
# 同一物理连接 + 前端会话重复打开(常见于 React StrictMode 双挂载或客户端去抖失败),
# 直接复用现有会话并仅刷新可变字段,避免反复断开/重连产生噪声日志。
existing = self.active_connections.get(existing_session_id)
if existing is not None:
if existing.channel_key != channel_key:
self._unbind_channel(existing_session_id, existing.channel_key)
self._bind_channel(existing_session_id, channel_key)
existing.channel_key = channel_key
existing.user_id = user_id
existing.user_name = user_name
existing.virtual_config = virtual_config
existing.sender = sender
logger.debug(
"WebUI 聊天会话复用: session=%s, connection=%s, client_session=%s, channel=%s",
session_id,
connection_id,
client_session_id,
channel_key,
)
return
if existing_session_id is not None:
self.disconnect(existing_session_id)
active_group_id = get_current_group_id(virtual_config)
session_connection = ChatSessionConnection(
session_id=session_id,
connection_id=connection_id,
client_session_id=client_session_id,
user_id=user_id,
user_name=user_name,
active_group_id=active_group_id,
channel_key=channel_key,
virtual_config=virtual_config,
sender=sender,
)
@@ -240,14 +389,14 @@ class ChatConnectionManager:
self.client_sessions[(connection_id, client_session_id)] = session_id
self.connection_sessions.setdefault(connection_id, set()).add(session_id)
self.user_sessions.setdefault(user_id, set()).add(session_id)
self._bind_group(session_id, active_group_id)
self._bind_channel(session_id, channel_key)
logger.info(
"WebUI 聊天会话已连接: session=%s, connection=%s, client_session=%s, user=%s, group=%s",
"WebUI 聊天会话已连接: session=%s, connection=%s, client_session=%s, user=%s, channel=%s",
session_id,
connection_id,
client_session_id,
user_id,
active_group_id,
channel_key,
)
def disconnect(self, session_id: str) -> None:
@@ -261,7 +410,7 @@ class ChatConnectionManager:
return
self.client_sessions.pop((session_connection.connection_id, session_connection.client_session_id), None)
self._unbind_group(session_id, session_connection.active_group_id)
self._unbind_channel(session_id, session_connection.channel_key)
connection_session_ids = self.connection_sessions.get(session_connection.connection_id)
if connection_session_ids is not None:
@@ -327,11 +476,11 @@ class ChatConnectionManager:
if session_connection is None:
return
next_group_id = get_current_group_id(virtual_config)
if next_group_id != session_connection.active_group_id:
self._unbind_group(session_id, session_connection.active_group_id)
self._bind_group(session_id, next_group_id)
session_connection.active_group_id = next_group_id
next_channel_key = compute_channel_key(virtual_config, session_connection.user_id)
if next_channel_key != session_connection.channel_key:
self._unbind_channel(session_id, session_connection.channel_key)
self._bind_channel(session_id, next_channel_key)
session_connection.channel_key = next_channel_key
session_connection.user_name = user_name
session_connection.virtual_config = virtual_config
@@ -361,16 +510,40 @@ class ChatConnectionManager:
for session_id in list(self.active_connections.keys()):
await self.send_message(session_id, message)
async def broadcast_to_group(self, group_id: str, message: Dict[str, Any]) -> None:
"""向指定群组下的全部逻辑会话广播消息。
async def broadcast_to_channel(self, channel_key: str, message: Dict[str, Any]) -> None:
"""向指定逻辑频道下的全部会话广播消息。
Args:
group_id: 群组标识
channel_key: 频道键(``group:<gid>`` 或 ``private:<uid>``
message: 待广播的消息内容。
"""
for session_id in list(self.group_sessions.get(group_id, set())):
for session_id in list(self.group_sessions.get(channel_key, set())):
await self.send_message(session_id, message)
async def broadcast_to_group(
self,
group_id: Optional[str],
message: Dict[str, Any],
*,
user_id: Optional[str] = None,
) -> None:
"""向指定群组或私聊会话广播消息。
当 ``group_id`` 非空时按群聊广播;否则按 ``user_id`` 私聊广播。
Args:
group_id: 群组标识;为空时使用 ``user_id``。
message: 待广播的消息内容。
user_id: 私聊接收方用户 ID。
"""
if group_id:
channel_key = f"group:{group_id}"
elif user_id:
channel_key = f"private:{user_id}"
else:
return
await self.broadcast_to_channel(channel_key, message)
chat_history = ChatHistoryManager()
chat_manager = ChatConnectionManager()
@@ -388,6 +561,24 @@ def is_virtual_mode_enabled(virtual_config: Optional[VirtualIdentityConfig]) ->
return bool(virtual_config and virtual_config.enabled)
def compute_channel_key(virtual_config: Optional[VirtualIdentityConfig], user_id: str) -> str:
"""计算当前会话的逻辑频道键。
虚拟身份启用时使用虚拟群聊 ID否则使用当前 WebUI 用户 ID 作为私聊频道。
Args:
virtual_config: 虚拟身份配置。
user_id: 当前 WebUI 用户 ID。
Returns:
str: 频道键,格式为 ``group:<gid>`` 或 ``private:<uid>``。
"""
if is_virtual_mode_enabled(virtual_config):
assert virtual_config is not None
return f"group:{virtual_config.group_id}"
return f"private:{user_id}"
def normalize_webui_user_id(user_id: Optional[str]) -> str:
"""标准化 WebUI 用户 ID。
@@ -500,6 +691,8 @@ def build_session_info_message(
Returns:
Dict[str, Any]: 会话信息消息。
"""
# bot_qq 用于前端从 QQ 头像公开接口拉取机器人头像qq_account == 0 表示未配置,不推送)。
bot_qq_account = int(getattr(global_config.bot, "qq_account", 0) or 0)
session_info_data: Dict[str, Any] = {
"type": "session_info",
"session_id": session_id,
@@ -507,6 +700,8 @@ def build_session_info_message(
"user_name": user_name,
"bot_name": global_config.bot.nickname,
}
if bot_qq_account > 0:
session_info_data["bot_qq"] = str(bot_qq_account)
if is_virtual_mode_enabled(virtual_config):
assert virtual_config is not None
@@ -529,7 +724,7 @@ def get_active_history_group_id(virtual_config: Optional[VirtualIdentityConfig])
virtual_config: 虚拟身份配置。
Returns:
Optional[str]: 虚拟身份启用时返回对应群组 ID。
Optional[str]: 虚拟身份启用时返回对应群组 ID;否则返回 ``None`` 表示使用私聊
"""
if is_virtual_mode_enabled(virtual_config):
assert virtual_config is not None
@@ -537,16 +732,16 @@ def get_active_history_group_id(virtual_config: Optional[VirtualIdentityConfig])
return None
def get_current_group_id(virtual_config: Optional[VirtualIdentityConfig]) -> str:
def get_current_group_id(virtual_config: Optional[VirtualIdentityConfig]) -> Optional[str]:
"""获取当前会话的有效群组 ID。
Args:
virtual_config: 虚拟身份配置。
Returns:
str: 当前会话应使用的群组 ID
Optional[str]: 虚拟身份启用时返回对应群组 ID否则返回 ``None``(默认私聊模式)
"""
return get_active_history_group_id(virtual_config) or WEBUI_CHAT_GROUP_ID
return get_active_history_group_id(virtual_config)
def build_welcome_message(virtual_config: Optional[VirtualIdentityConfig]) -> str:
@@ -611,7 +806,12 @@ async def send_initial_chat_state(
)
history_group_id = get_active_history_group_id(virtual_config)
history = chat_history.get_history(50, history_group_id)
history_user_id = None if history_group_id else user_id
history = chat_history.get_history(
50,
group_id=history_group_id,
user_id=history_user_id,
)
await chat_manager.send_message(
session_id,
{
@@ -679,37 +879,42 @@ def create_message_data(
if virtual_config and virtual_config.enabled:
platform = virtual_config.platform or WEBUI_CHAT_PLATFORM
group_id = virtual_config.group_id or f"{VIRTUAL_GROUP_ID_PREFIX}{uuid.uuid4().hex[:8]}"
group_name = virtual_config.group_name or "WebUI虚拟群聊"
group_id: Optional[str] = (
virtual_config.group_id or f"{VIRTUAL_GROUP_ID_PREFIX}{uuid.uuid4().hex[:8]}"
)
group_name: Optional[str] = virtual_config.group_name or "WebUI虚拟群聊"
actual_user_id = virtual_config.user_id or user_id
actual_user_name = virtual_config.user_nickname or user_name
actual_user_nickname = virtual_config.user_nickname or user_name
else:
platform = WEBUI_CHAT_PLATFORM
group_id = WEBUI_CHAT_GROUP_ID
group_name = "WebUI本地聊天室"
group_id = None
group_name = None
actual_user_id = user_id
actual_user_name = user_name
actual_user_nickname = user_name
message_info: Dict[str, Any] = {
"platform": platform,
"message_id": message_id,
"time": time.time(),
"user_info": {
"user_id": actual_user_id,
"user_nickname": actual_user_nickname,
"user_cardname": actual_user_nickname,
"platform": platform,
},
"additional_config": {
"at_bot": is_at_bot,
},
}
if group_id is not None:
message_info["group_info"] = {
"group_id": group_id,
"group_name": group_name,
"platform": platform,
}
return {
"message_info": {
"platform": platform,
"message_id": message_id,
"time": time.time(),
"group_info": {
"group_id": group_id,
"group_name": group_name,
"platform": platform,
},
"user_info": {
"user_id": actual_user_id,
"user_nickname": actual_user_name,
"user_cardname": actual_user_name,
"platform": platform,
},
"additional_config": {
"at_bot": is_at_bot,
},
},
"message_info": message_info,
"message_segment": {
"type": "seglist",
"data": [
@@ -717,10 +922,6 @@ def create_message_data(
"type": "text",
"data": content,
},
{
"type": "mention_bot",
"data": "1.0",
},
],
},
"raw_message": content,
@@ -776,6 +977,7 @@ async def handle_chat_message(
},
"virtual_mode": is_virtual_mode_enabled(current_virtual_config),
},
user_id=normalized_user_id,
)
message_data = create_message_data(
@@ -788,13 +990,21 @@ async def handle_chat_message(
)
try:
await chat_manager.broadcast_to_group(target_group_id, {"type": "typing", "is_typing": True})
await chat_manager.broadcast_to_group(
target_group_id,
{"type": "typing", "is_typing": True},
user_id=normalized_user_id,
)
await chat_bot.message_process(message_data)
except Exception as exc:
logger.error(f"处理消息时出错: {exc}")
await send_chat_error(session_id, f"处理消息时出错: {str(exc)}")
finally:
await chat_manager.broadcast_to_group(target_group_id, {"type": "typing", "is_typing": False})
await chat_manager.broadcast_to_group(
target_group_id,
{"type": "typing", "is_typing": False},
user_id=normalized_user_id,
)
return next_user_name
@@ -915,11 +1125,12 @@ async def enable_virtual_identity(
return None
async def disable_virtual_identity(session_id: str) -> None:
async def disable_virtual_identity(session_id: str, normalized_user_id: str) -> None:
"""关闭虚拟身份模式。
Args:
session_id: 内部逻辑会话 ID。
normalized_user_id: 规范化后的 WebUI 用户 ID用于加载私聊历史。
"""
await chat_manager.send_message(
session_id,
@@ -933,8 +1144,8 @@ async def disable_virtual_identity(session_id: str) -> None:
session_id,
{
"type": "history",
"messages": chat_history.get_history(50, WEBUI_CHAT_GROUP_ID),
"group_id": WEBUI_CHAT_GROUP_ID,
"messages": chat_history.get_history(50, user_id=normalized_user_id),
"group_id": None,
},
)
await chat_manager.send_message(
@@ -952,6 +1163,7 @@ async def handle_virtual_identity_update(
session_id_prefix: str,
data: Dict[str, Any],
current_virtual_config: Optional[VirtualIdentityConfig],
normalized_user_id: str,
) -> Optional[VirtualIdentityConfig]:
"""处理虚拟身份切换请求。
@@ -960,6 +1172,7 @@ async def handle_virtual_identity_update(
session_id_prefix: 会话前缀。
data: 前端提交的数据。
current_virtual_config: 当前虚拟身份配置。
normalized_user_id: 规范化后的 WebUI 用户 ID。
Returns:
Optional[VirtualIdentityConfig]: 更新后的虚拟身份配置。
@@ -969,7 +1182,7 @@ async def handle_virtual_identity_update(
next_config = await enable_virtual_identity(session_id, session_id_prefix, virtual_data)
return next_config if next_config is not None else current_virtual_config
await disable_virtual_identity(session_id)
await disable_virtual_identity(session_id, normalized_user_id)
return None
@@ -1019,6 +1232,7 @@ async def dispatch_chat_event(
session_id_prefix=session_id_prefix,
data=data,
current_virtual_config=current_virtual_config,
normalized_user_id=normalized_user_id,
)
return current_user_name, next_virtual_config

View File

@@ -146,6 +146,11 @@ async def _fetch_models_from_provider(
client_config = build_openai_compatible_client_config(provider)
headers.update(client_config.default_headers)
params.update(client_config.default_query)
# build_openai_compatible_client_config 在“默认 Bearer”场景下
# 会把 api_key 留在 client_config.api_key 中交给 OpenAI SDK 自行注入 Authorization 头,
# 而不会写入 default_headers。这里我们用 httpx 直接发请求,需要手动补上鉴权头/参数。
if client_config.api_key and "Authorization" not in headers:
headers["Authorization"] = f"Bearer {client_config.api_key}"
try:
async with httpx.AsyncClient(timeout=30.0) as client: