mirror of https://github.com/Mai-with-u/MaiBot.git
Merge 7ef21c516a into b63b7a7fb9
commit
bc832a4d23
|
|
@ -4,7 +4,7 @@ on: [pull_request]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
conflict-check:
|
conflict-check:
|
||||||
runs-on: [self-hosted, Windows, X64]
|
runs-on: ubuntu-24.04
|
||||||
outputs:
|
outputs:
|
||||||
conflict: ${{ steps.check-conflicts.outputs.conflict }}
|
conflict: ${{ steps.check-conflicts.outputs.conflict }}
|
||||||
steps:
|
steps:
|
||||||
|
|
@ -25,7 +25,7 @@ jobs:
|
||||||
}
|
}
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
labeler:
|
labeler:
|
||||||
runs-on: [self-hosted, Windows, X64]
|
runs-on: ubuntu-24.04
|
||||||
needs: conflict-check
|
needs: conflict-check
|
||||||
if: needs.conflict-check.outputs.conflict == 'true'
|
if: needs.conflict-check.outputs.conflict == 'true'
|
||||||
steps:
|
steps:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
name: Publish WebUI Dist
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- dev
|
||||||
|
- r-dev
|
||||||
|
paths:
|
||||||
|
- "dashboard/**"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-publish:
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
environment: webui
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: oven-sh/setup-bun@v2
|
||||||
|
with:
|
||||||
|
bun-version: "1.2.0"
|
||||||
|
|
||||||
|
- name: Build dashboard
|
||||||
|
working-directory: dashboard
|
||||||
|
run: |
|
||||||
|
bun install
|
||||||
|
bun run build
|
||||||
|
|
||||||
|
- name: Prepare dist package
|
||||||
|
run: |
|
||||||
|
rm -rf .webui_dist_pkg
|
||||||
|
mkdir -p .webui_dist_pkg/maibot_dashboard/dist
|
||||||
|
BASE_VERSION=$(python -c "import json; print(json.load(open('dashboard/package.json'))['version'])")
|
||||||
|
if [ "${GITHUB_REF_NAME}" = "main" ]; then
|
||||||
|
WEBUI_VERSION="${BASE_VERSION}"
|
||||||
|
else
|
||||||
|
TODAY=$(date -u +%Y%m%d)
|
||||||
|
WEBUI_VERSION="${BASE_VERSION}.dev${TODAY}${GITHUB_RUN_NUMBER}"
|
||||||
|
fi
|
||||||
|
cat > .webui_dist_pkg/pyproject.toml <<EOF
|
||||||
|
[project]
|
||||||
|
name = "maibot-dashboard"
|
||||||
|
version = "${WEBUI_VERSION}"
|
||||||
|
description = "MaiBot WebUI static assets"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=80.9.0", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[tool.setuptools]
|
||||||
|
include-package-data = true
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["."]
|
||||||
|
include = ["maibot_dashboard"]
|
||||||
|
exclude = ["maibot_dashboard.dist*"]
|
||||||
|
|
||||||
|
[tool.setuptools.package-data]
|
||||||
|
maibot_dashboard = ["dist/**"]
|
||||||
|
EOF
|
||||||
|
cat > .webui_dist_pkg/README.md <<'EOF'
|
||||||
|
# MaiBot WebUI Dist
|
||||||
|
|
||||||
|
该包仅包含 MaiBot WebUI 的前端构建产物(dist)。
|
||||||
|
EOF
|
||||||
|
cat > .webui_dist_pkg/maibot_dashboard/__init__.py <<'EOF'
|
||||||
|
from .resources import get_dist_path
|
||||||
|
|
||||||
|
__all__ = ["get_dist_path"]
|
||||||
|
EOF
|
||||||
|
cat > .webui_dist_pkg/maibot_dashboard/resources.py <<'EOF'
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
def get_dist_path() -> Path:
|
||||||
|
return Path(__file__).parent / "dist"
|
||||||
|
EOF
|
||||||
|
cp -a dashboard/dist/. .webui_dist_pkg/maibot_dashboard/dist/
|
||||||
|
|
||||||
|
- name: Setup Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.11"
|
||||||
|
|
||||||
|
- name: Build and publish
|
||||||
|
working-directory: .webui_dist_pkg
|
||||||
|
env:
|
||||||
|
PYPI_API_TOKEN: ${{ secrets.PYPI_API_TOKEN }}
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade build twine
|
||||||
|
python -m build
|
||||||
|
python -m twine upload -u __token__ -p "$PYPI_API_TOKEN" dist/*
|
||||||
|
|
@ -2,7 +2,7 @@ name: Ruff PR Check
|
||||||
on: [ pull_request ]
|
on: [ pull_request ]
|
||||||
jobs:
|
jobs:
|
||||||
ruff:
|
ruff:
|
||||||
runs-on: [self-hosted, Windows, X64]
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
|
|
|
||||||
|
|
@ -8,17 +8,12 @@ on:
|
||||||
# - dev-refactor # 例如:匹配所有以 feature/ 开头的分支
|
# - dev-refactor # 例如:匹配所有以 feature/ 开头的分支
|
||||||
# # 添加你希望触发此 workflow 的其他分支
|
# # 添加你希望触发此 workflow 的其他分支
|
||||||
workflow_dispatch: # 允许手动触发工作流
|
workflow_dispatch: # 允许手动触发工作流
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
- dev
|
|
||||||
- dev-refactor
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
ruff:
|
ruff:
|
||||||
runs-on: [self-hosted, Windows, X64]
|
runs-on: ubuntu-24.04
|
||||||
# 关键修改:添加条件判断
|
# 关键修改:添加条件判断
|
||||||
# 确保只有在 event_name 是 'push' 且不是由 Pull Request 引起的 push 时才运行
|
# 确保只有在 event_name 是 'push' 且不是由 Pull Request 引起的 push 时才运行
|
||||||
if: github.event_name == 'push' && !startsWith(github.ref, 'refs/pull/')
|
if: github.event_name == 'push' && !startsWith(github.ref, 'refs/pull/')
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ queue_update.txt
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
.cursor
|
.cursor
|
||||||
|
start_all.bat
|
||||||
config/bot_config_dev.toml
|
config/bot_config_dev.toml
|
||||||
config/bot_config.toml
|
config/bot_config.toml
|
||||||
config/bot_config.toml.bak
|
config/bot_config.toml.bak
|
||||||
|
|
@ -91,7 +92,6 @@ develop-eggs/
|
||||||
downloads/
|
downloads/
|
||||||
eggs/
|
eggs/
|
||||||
.eggs/
|
.eggs/
|
||||||
lib/
|
|
||||||
lib64/
|
lib64/
|
||||||
parts/
|
parts/
|
||||||
sdist/
|
sdist/
|
||||||
|
|
@ -353,3 +353,4 @@ interested_rates.txt
|
||||||
MaiBot.code-workspace
|
MaiBot.code-workspace
|
||||||
*.lock
|
*.lock
|
||||||
actionlint
|
actionlint
|
||||||
|
.sisyphus/
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
# import 规范
|
||||||
|
在从外部库进行导入时候,请遵循以下顺序:
|
||||||
|
1. 对于标准库和第三方库的导入,请按照如下顺序:
|
||||||
|
- 需要使用`from ... import ...`语法的导入放在前面。
|
||||||
|
- 直接使用`import ...`语法的导入放在后面。
|
||||||
|
- 对于使用`from ... import ...`导入的多个项,请**在保证不会引起import错误的前提下**,按照**字母顺序**排列。
|
||||||
|
- 对于使用`import ...`导入的多个项,请**在保证不会引起import错误的前提下**,按照**字母顺序**排列。
|
||||||
|
2. 对于本地模块的导入,请按照如下顺序:
|
||||||
|
- 对于同一个文件夹下的模块导入,使用相对导入,排列顺序按照**不发生import错误的前提下**,随便排列。
|
||||||
|
- 对于不同文件夹下的模块导入,使用绝对导入。这些导入应该以`from src`开头,并且按照**不发生import错误的前提下**,尽量使得第二层的文件夹名称相同的导入放在一起;第二层文件夹名称排列随机。
|
||||||
|
3. 标准库和第三方库的导入应该放在本地模块导入的前面。
|
||||||
|
4. 各个导入块之间应该使用一个空行进行分隔。
|
||||||
20
bot.py
20
bot.py
|
|
@ -1,3 +1,4 @@
|
||||||
|
# raise RuntimeError("System Not Ready")
|
||||||
import asyncio
|
import asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
import os
|
import os
|
||||||
|
|
@ -43,6 +44,11 @@ logger = get_logger("main")
|
||||||
|
|
||||||
# 定义重启退出码
|
# 定义重启退出码
|
||||||
RESTART_EXIT_CODE = 42
|
RESTART_EXIT_CODE = 42
|
||||||
|
print("-----------------------------------------")
|
||||||
|
print("\n\n\n\n\n")
|
||||||
|
print("警告:Dev进入不稳定开发状态,任何插件与WebUI均可能无法正常工作!")
|
||||||
|
print("\n\n\n\n\n")
|
||||||
|
print("-----------------------------------------")
|
||||||
|
|
||||||
|
|
||||||
def run_runner_process():
|
def run_runner_process():
|
||||||
|
|
@ -180,14 +186,14 @@ async def graceful_shutdown(): # sourcery skip: use-named-expression
|
||||||
logger.info("正在优雅关闭麦麦...")
|
logger.info("正在优雅关闭麦麦...")
|
||||||
|
|
||||||
# 关闭 WebUI 服务器
|
# 关闭 WebUI 服务器
|
||||||
try:
|
# try:
|
||||||
from src.webui.webui_server import get_webui_server
|
# from src.webui.webui_server import get_webui_server
|
||||||
|
|
||||||
webui_server = get_webui_server()
|
# webui_server = get_webui_server()
|
||||||
if webui_server and webui_server._server:
|
# if webui_server and webui_server._server:
|
||||||
await webui_server.shutdown()
|
# await webui_server.shutdown()
|
||||||
except Exception as e:
|
# except Exception as e:
|
||||||
logger.warning(f"关闭 WebUI 服务器时出错: {e}")
|
# logger.warning(f"关闭 WebUI 服务器时出错: {e}")
|
||||||
|
|
||||||
from src.plugin_system.core.events_manager import events_manager
|
from src.plugin_system.core.events_manager import events_manager
|
||||||
from src.plugin_system.base.component_types import EventType
|
from src.plugin_system.base.component_types import EventType
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,732 @@
|
||||||
|
# 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而不是peewee(墨:peewee我这辈子都不会用它了)
|
||||||
|
### 数据库缓存层设计
|
||||||
|
将部分消息缓存到内存中,减少数据库访问,在主程序处理完之后再写入数据库
|
||||||
|
|
||||||
|
要求:对上层调用保持透明
|
||||||
|
- [ ] 数据库内容管理类 `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>}` 存入缓存
|
||||||
|
|
@ -0,0 +1,191 @@
|
||||||
|
# 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为需要保存,否则在下一次保存时会被删除。
|
||||||
|
|
@ -0,0 +1,127 @@
|
||||||
|
from pathlib import Path
|
||||||
|
import ast
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
base_file_path = Path(__file__).parent.parent.absolute().resolve() / "src" / "common" / "database" / "database_model.py"
|
||||||
|
target_file_path = (
|
||||||
|
Path(__file__).parent.parent.absolute().resolve() / "src" / "common" / "database" / "database_datamodel.py"
|
||||||
|
)
|
||||||
|
|
||||||
|
with open(base_file_path, "r", encoding="utf-8") as f:
|
||||||
|
source_text = f.read()
|
||||||
|
source_lines = source_text.splitlines()
|
||||||
|
|
||||||
|
try:
|
||||||
|
tree = ast.parse(source_text)
|
||||||
|
except SyntaxError as e:
|
||||||
|
raise e
|
||||||
|
|
||||||
|
code_lines = [
|
||||||
|
"from typing import Optional",
|
||||||
|
"from pydantic import BaseModel",
|
||||||
|
"from datetime import datetime",
|
||||||
|
"from .database_model import ModelUser, ImageType",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def src(node):
|
||||||
|
seg = ast.get_source_segment(source_text, node)
|
||||||
|
return seg if seg is not None else ast.unparse(node)
|
||||||
|
|
||||||
|
|
||||||
|
for node in tree.body:
|
||||||
|
if not isinstance(node, ast.ClassDef):
|
||||||
|
continue
|
||||||
|
# 判断是否 SQLModel 且 table=True
|
||||||
|
has_sqlmodel = any(
|
||||||
|
(isinstance(b, ast.Name) and b.id == "SQLModel") or (isinstance(b, ast.Attribute) and b.attr == "SQLModel")
|
||||||
|
for b in node.bases
|
||||||
|
)
|
||||||
|
has_table_kw = any(
|
||||||
|
(kw.arg == "table" and isinstance(kw.value, ast.Constant) and kw.value.value is True) for kw in node.keywords
|
||||||
|
)
|
||||||
|
if not (has_sqlmodel and has_table_kw):
|
||||||
|
continue
|
||||||
|
|
||||||
|
class_name = node.name
|
||||||
|
code_lines.append("")
|
||||||
|
code_lines.append(f"class {class_name}(BaseModel):")
|
||||||
|
|
||||||
|
fields_added = 0
|
||||||
|
for item in node.body:
|
||||||
|
# 跳过 __tablename__ 等
|
||||||
|
if isinstance(item, ast.Assign):
|
||||||
|
if len(item.targets) != 1 or not isinstance(item.targets[0], ast.Name):
|
||||||
|
continue
|
||||||
|
name = item.targets[0].id
|
||||||
|
if name == "__tablename__":
|
||||||
|
continue
|
||||||
|
value_src = src(item.value)
|
||||||
|
line = f" {name} = {value_src}"
|
||||||
|
fields_added += 1
|
||||||
|
lineno = getattr(item, "lineno", None)
|
||||||
|
elif isinstance(item, ast.AnnAssign):
|
||||||
|
# 注解赋值
|
||||||
|
if not isinstance(item.target, ast.Name):
|
||||||
|
continue
|
||||||
|
name = item.target.id
|
||||||
|
ann = src(item.annotation) if item.annotation is not None else None
|
||||||
|
if item.value is None:
|
||||||
|
line = f" {name}: {ann}" if ann else f" {name}"
|
||||||
|
elif isinstance(item.value, ast.Call) and (
|
||||||
|
(isinstance(item.value.func, ast.Name) and item.value.func.id == "Field")
|
||||||
|
or (isinstance(item.value.func, ast.Attribute) and item.value.func.attr == "Field")
|
||||||
|
):
|
||||||
|
default_kw = next((kw for kw in item.value.keywords if kw.arg == "default"), None)
|
||||||
|
if default_kw is None:
|
||||||
|
# 没有 default,保留类型但不赋值
|
||||||
|
line = f" {name}: {ann}" if ann else f" {name}"
|
||||||
|
else:
|
||||||
|
default_src = src(default_kw.value)
|
||||||
|
line = f" {name}: {ann} = {default_src}"
|
||||||
|
else:
|
||||||
|
value_src = src(item.value)
|
||||||
|
line = f" {name}: {ann} = {value_src}" if ann else f" {name} = {value_src}"
|
||||||
|
fields_added += 1
|
||||||
|
lineno = getattr(item, "lineno", None)
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 提取同一行的行内注释作为字段说明(如果存在)
|
||||||
|
comment = None
|
||||||
|
if lineno is not None:
|
||||||
|
src_line = source_lines[lineno - 1]
|
||||||
|
if "#" in src_line:
|
||||||
|
# 取第一个 #
|
||||||
|
comment = src_line.split("#", 1)[1].strip()
|
||||||
|
# 避免三引号冲突
|
||||||
|
comment = comment.replace('"""', '\\"""')
|
||||||
|
|
||||||
|
code_lines.append(line)
|
||||||
|
if comment:
|
||||||
|
code_lines.append(f' """{comment}"""')
|
||||||
|
else:
|
||||||
|
print(f"Warning: No comment found for field '{name}' in class '{class_name}'.")
|
||||||
|
|
||||||
|
if fields_added == 0:
|
||||||
|
code_lines.append(" pass")
|
||||||
|
|
||||||
|
with open(target_file_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write("\n".join(code_lines) + "\n")
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(["ruff", "format", str(target_file_path)], capture_output=True, text=True)
|
||||||
|
except FileNotFoundError:
|
||||||
|
print("ruff 未找到,请安装 ruff 并确保其在 PATH 中(例如:pip install ruff)", file=sys.stderr)
|
||||||
|
sys.exit(127)
|
||||||
|
|
||||||
|
# 输出 ruff 的 stdout/stderr
|
||||||
|
if result.stdout:
|
||||||
|
print(result.stdout, end="")
|
||||||
|
if result.stderr:
|
||||||
|
print(result.stderr, file=sys.stderr, end="")
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
print(f"ruff 检查失败,退出码:{result.returncode}", file=sys.stderr)
|
||||||
|
sys.exit(result.returncode)
|
||||||
|
|
@ -11,6 +11,17 @@
|
||||||
<link rel="icon" type="image/x-icon" href="/maimai.ico" />
|
<link rel="icon" type="image/x-icon" href="/maimai.ico" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>MaiBot Dashboard</title>
|
<title>MaiBot Dashboard</title>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
const mode = localStorage.getItem('maibot-theme-mode')
|
||||||
|
|| localStorage.getItem('ui-theme')
|
||||||
|
|| localStorage.getItem('maibot-ui-theme');
|
||||||
|
const theme = mode === 'system' || !mode
|
||||||
|
? window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||||
|
: mode;
|
||||||
|
document.documentElement.classList.add(theme);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root" class="notranslate"></div>
|
<div id="root" class="notranslate"></div>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,16 +1,19 @@
|
||||||
{
|
{
|
||||||
"name": "maibot-dashboard",
|
"name": "maibot-dashboard",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.11.6",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"format": "prettier --write \"src/**/*.{ts,tsx,css}\""
|
"format": "prettier --write \"src/**/*.{ts,tsx,css}\"",
|
||||||
|
"test": "vitest",
|
||||||
|
"test:ui": "vitest --ui"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@codemirror/lang-css": "^6.3.1",
|
||||||
"@codemirror/lang-javascript": "^6.2.4",
|
"@codemirror/lang-javascript": "^6.2.4",
|
||||||
"@codemirror/lang-json": "^6.0.2",
|
"@codemirror/lang-json": "^6.0.2",
|
||||||
"@codemirror/lang-python": "^6.2.1",
|
"@codemirror/lang-python": "^6.2.1",
|
||||||
|
|
@ -57,6 +60,7 @@
|
||||||
"dagre": "^0.8.5",
|
"dagre": "^0.8.5",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"html-to-image": "^1.11.13",
|
"html-to-image": "^1.11.13",
|
||||||
|
"idb": "^8.0.3",
|
||||||
"jotai": "^2.16.0",
|
"jotai": "^2.16.0",
|
||||||
"katex": "^0.16.27",
|
"katex": "^0.16.27",
|
||||||
"lucide-react": "^0.556.0",
|
"lucide-react": "^0.556.0",
|
||||||
|
|
@ -75,21 +79,28 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@testing-library/dom": "^10.4.1",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/node": "^24.10.2",
|
"@types/node": "^24.10.2",
|
||||||
"@types/react": "^19.2.7",
|
"@types/react": "^19.2.7",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^5.1.2",
|
"@vitejs/plugin-react": "^5.1.2",
|
||||||
|
"@vitest/ui": "^4.0.18",
|
||||||
"autoprefixer": "^10.4.22",
|
"autoprefixer": "^10.4.22",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "^9.39.1",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.24",
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
|
"jsdom": "^28.1.0",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"prettier": "^3.7.4",
|
"prettier": "^3.7.4",
|
||||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||||
"tailwindcss": "^3",
|
"tailwindcss": "^3",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.49.0",
|
"typescript-eslint": "^8.49.0",
|
||||||
"vite": "^7.2.7"
|
"vite": "^7.2.7",
|
||||||
|
"vitest": "^4.0.18"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,20 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import CodeMirror from '@uiw/react-codemirror'
|
import CodeMirror from '@uiw/react-codemirror'
|
||||||
import { python } from '@codemirror/lang-python'
|
import { css } from '@codemirror/lang-css'
|
||||||
import { json, jsonParseLinter } from '@codemirror/lang-json'
|
import { json, jsonParseLinter } from '@codemirror/lang-json'
|
||||||
|
import { python } from '@codemirror/lang-python'
|
||||||
import { oneDark } from '@codemirror/theme-one-dark'
|
import { oneDark } from '@codemirror/theme-one-dark'
|
||||||
import { EditorView } from '@codemirror/view'
|
import { EditorView } from '@codemirror/view'
|
||||||
import { StreamLanguage } from '@codemirror/language'
|
import { StreamLanguage } from '@codemirror/language'
|
||||||
import { toml as tomlMode } from '@codemirror/legacy-modes/mode/toml'
|
import { toml as tomlMode } from '@codemirror/legacy-modes/mode/toml'
|
||||||
|
|
||||||
export type Language = 'python' | 'json' | 'toml' | 'text'
|
import { useTheme } from '@/components/use-theme'
|
||||||
|
|
||||||
|
export type Language = 'python' | 'json' | 'toml' | 'css' | 'text'
|
||||||
|
|
||||||
interface CodeEditorProps {
|
interface CodeEditorProps {
|
||||||
value: string
|
value: string
|
||||||
|
|
||||||
onChange?: (value: string) => void
|
onChange?: (value: string) => void
|
||||||
language?: Language
|
language?: Language
|
||||||
readOnly?: boolean
|
readOnly?: boolean
|
||||||
|
|
@ -27,6 +31,7 @@ const languageExtensions: Record<Language, any[]> = {
|
||||||
python: [python()],
|
python: [python()],
|
||||||
json: [json(), jsonParseLinter()],
|
json: [json(), jsonParseLinter()],
|
||||||
toml: [StreamLanguage.define(tomlMode)],
|
toml: [StreamLanguage.define(tomlMode)],
|
||||||
|
css: [css()],
|
||||||
text: [],
|
text: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -39,10 +44,11 @@ export function CodeEditor({
|
||||||
minHeight,
|
minHeight,
|
||||||
maxHeight,
|
maxHeight,
|
||||||
placeholder,
|
placeholder,
|
||||||
theme = 'dark',
|
theme,
|
||||||
className = '',
|
className = '',
|
||||||
}: CodeEditorProps) {
|
}: CodeEditorProps) {
|
||||||
const [mounted, setMounted] = useState(false)
|
const [mounted, setMounted] = useState(false)
|
||||||
|
const { resolvedTheme } = useTheme()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true)
|
setMounted(true)
|
||||||
|
|
@ -81,6 +87,9 @@ export function CodeEditor({
|
||||||
extensions.push(EditorView.editable.of(false))
|
extensions.push(EditorView.editable.of(false))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 如果外部传了 theme prop 则使用,否则从 context 自动获取
|
||||||
|
const effectiveTheme = theme ?? resolvedTheme
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`rounded-md overflow-hidden border custom-scrollbar ${className}`}>
|
<div className={`rounded-md overflow-hidden border custom-scrollbar ${className}`}>
|
||||||
<CodeMirror
|
<CodeMirror
|
||||||
|
|
@ -88,7 +97,7 @@ export function CodeEditor({
|
||||||
height={height}
|
height={height}
|
||||||
minHeight={minHeight}
|
minHeight={minHeight}
|
||||||
maxHeight={maxHeight}
|
maxHeight={maxHeight}
|
||||||
theme={theme === 'dark' ? oneDark : undefined}
|
theme={effectiveTheme === 'dark' ? oneDark : undefined}
|
||||||
extensions={extensions}
|
extensions={extensions}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { createContext, useContext, useEffect, useMemo, useRef } from 'react'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
import { getAsset } from '@/lib/asset-store'
|
||||||
|
|
||||||
|
type AssetStoreContextType = {
|
||||||
|
getAssetUrl: (assetId: string) => Promise<string | undefined>
|
||||||
|
}
|
||||||
|
|
||||||
|
const AssetStoreContext = createContext<AssetStoreContextType | null>(null)
|
||||||
|
|
||||||
|
type AssetStoreProviderProps = {
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AssetStoreProvider({ children }: AssetStoreProviderProps) {
|
||||||
|
const urlCache = useRef<Map<string, string>>(new Map())
|
||||||
|
|
||||||
|
const getAssetUrl = async (assetId: string): Promise<string | undefined> => {
|
||||||
|
// Check cache first
|
||||||
|
const cached = urlCache.current.get(assetId)
|
||||||
|
if (cached) {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch from IndexedDB
|
||||||
|
const record = await getAsset(assetId)
|
||||||
|
if (!record) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create blob URL and cache it
|
||||||
|
const url = URL.createObjectURL(record.blob)
|
||||||
|
urlCache.current.set(assetId, url)
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({
|
||||||
|
getAssetUrl,
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Cleanup: revoke all blob URLs on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
urlCache.current.forEach((url) => {
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
})
|
||||||
|
urlCache.current.clear()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return <AssetStoreContext.Provider value={value}>{children}</AssetStoreContext.Provider>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAssetStore() {
|
||||||
|
const context = useContext(AssetStoreContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useAssetStore must be used within AssetStoreProvider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,292 @@
|
||||||
|
import { RotateCcw } from 'lucide-react'
|
||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { Slider } from '@/components/ui/slider'
|
||||||
|
import { hexToHSL } from '@/lib/theme/palette'
|
||||||
|
import {
|
||||||
|
type BackgroundEffects,
|
||||||
|
defaultBackgroundEffects,
|
||||||
|
} from '@/lib/theme/tokens'
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helper Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 HSL 字符串转换为 HEX 格式
|
||||||
|
* (从 settings.tsx 移植)
|
||||||
|
*/
|
||||||
|
function hslToHex(hsl: string): string {
|
||||||
|
if (!hsl) return '#000000'
|
||||||
|
|
||||||
|
// 解析 "221.2 83.2% 53.3%" 格式
|
||||||
|
const parts = hsl.split(' ').filter(Boolean)
|
||||||
|
if (parts.length < 3) return '#000000'
|
||||||
|
|
||||||
|
const h = parseFloat(parts[0])
|
||||||
|
const s = parseFloat(parts[1].replace('%', ''))
|
||||||
|
const l = parseFloat(parts[2].replace('%', ''))
|
||||||
|
|
||||||
|
const sDecimal = s / 100
|
||||||
|
const lDecimal = l / 100
|
||||||
|
|
||||||
|
const c = (1 - Math.abs(2 * lDecimal - 1)) * sDecimal
|
||||||
|
const x = c * (1 - Math.abs(((h / 60) % 2) - 1))
|
||||||
|
const m = lDecimal - c / 2
|
||||||
|
|
||||||
|
let r = 0,
|
||||||
|
g = 0,
|
||||||
|
b = 0
|
||||||
|
|
||||||
|
if (h >= 0 && h < 60) {
|
||||||
|
r = c
|
||||||
|
g = x
|
||||||
|
b = 0
|
||||||
|
} else if (h >= 60 && h < 120) {
|
||||||
|
r = x
|
||||||
|
g = c
|
||||||
|
b = 0
|
||||||
|
} else if (h >= 120 && h < 180) {
|
||||||
|
r = 0
|
||||||
|
g = c
|
||||||
|
b = x
|
||||||
|
} else if (h >= 180 && h < 240) {
|
||||||
|
r = 0
|
||||||
|
g = x
|
||||||
|
b = c
|
||||||
|
} else if (h >= 240 && h < 300) {
|
||||||
|
r = x
|
||||||
|
g = 0
|
||||||
|
b = c
|
||||||
|
} else if (h >= 300 && h < 360) {
|
||||||
|
r = c
|
||||||
|
g = 0
|
||||||
|
b = x
|
||||||
|
}
|
||||||
|
|
||||||
|
const toHex = (n: number) => {
|
||||||
|
const hex = Math.round((n + m) * 255).toString(16)
|
||||||
|
return hex.length === 1 ? '0' + hex : hex
|
||||||
|
}
|
||||||
|
|
||||||
|
return `#${toHex(r)}${toHex(g)}${toHex(b)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Component
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
type BackgroundEffectsControlsProps = {
|
||||||
|
effects: BackgroundEffects
|
||||||
|
onChange: (effects: BackgroundEffects) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BackgroundEffectsControls({
|
||||||
|
effects,
|
||||||
|
onChange,
|
||||||
|
}: BackgroundEffectsControlsProps) {
|
||||||
|
// 处理数值变更
|
||||||
|
const handleValueChange = (key: keyof BackgroundEffects, value: number) => {
|
||||||
|
onChange({
|
||||||
|
...effects,
|
||||||
|
[key]: value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理颜色变更
|
||||||
|
const handleColorChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const hex = e.target.value
|
||||||
|
const hsl = hexToHSL(hex)
|
||||||
|
onChange({
|
||||||
|
...effects,
|
||||||
|
overlayColor: hsl,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理位置变更
|
||||||
|
const handlePositionChange = (value: string) => {
|
||||||
|
onChange({
|
||||||
|
...effects,
|
||||||
|
position: value as BackgroundEffects['position'],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理渐变变更
|
||||||
|
const handleGradientChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
onChange({
|
||||||
|
...effects,
|
||||||
|
gradientOverlay: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置为默认值
|
||||||
|
const handleReset = () => {
|
||||||
|
onChange(defaultBackgroundEffects)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-medium">背景效果调节</h3>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleReset}
|
||||||
|
className="h-8 px-2 text-xs"
|
||||||
|
>
|
||||||
|
<RotateCcw className="mr-2 h-3.5 w-3.5" />
|
||||||
|
重置默认
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6">
|
||||||
|
{/* 1. Blur (模糊) */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>模糊程度 (Blur)</Label>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{effects.blur}px
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
value={[effects.blur]}
|
||||||
|
min={0}
|
||||||
|
max={50}
|
||||||
|
step={1}
|
||||||
|
onValueChange={(vals) => handleValueChange('blur', vals[0])}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 2. Overlay Color (遮罩颜色) */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label>遮罩颜色 (Overlay Color)</Label>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-9 w-9 overflow-hidden rounded-md border shadow-sm">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={hslToHex(effects.overlayColor)}
|
||||||
|
onChange={handleColorChange}
|
||||||
|
className="h-[150%] w-[150%] -translate-x-1/4 -translate-y-1/4 cursor-pointer border-0 p-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
value={hslToHex(effects.overlayColor)}
|
||||||
|
readOnly
|
||||||
|
className="flex-1 font-mono uppercase"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 3. Overlay Opacity (遮罩不透明度) */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>遮罩不透明度 (Opacity)</Label>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{Math.round(effects.overlayOpacity * 100)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
value={[effects.overlayOpacity * 100]}
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={1}
|
||||||
|
onValueChange={(vals) =>
|
||||||
|
handleValueChange('overlayOpacity', vals[0] / 100)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 4. Position (位置) */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label>背景位置 (Position)</Label>
|
||||||
|
<Select value={effects.position} onValueChange={handlePositionChange}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="选择位置" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="cover">覆盖 (Cover)</SelectItem>
|
||||||
|
<SelectItem value="contain">包含 (Contain)</SelectItem>
|
||||||
|
<SelectItem value="center">居中 (Center)</SelectItem>
|
||||||
|
<SelectItem value="stretch">拉伸 (Stretch)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 5. Brightness (亮度) */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>亮度 (Brightness)</Label>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{effects.brightness}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
value={[effects.brightness]}
|
||||||
|
min={0}
|
||||||
|
max={200}
|
||||||
|
step={1}
|
||||||
|
onValueChange={(vals) => handleValueChange('brightness', vals[0])}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 6. Contrast (对比度) */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>对比度 (Contrast)</Label>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{effects.contrast}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
value={[effects.contrast]}
|
||||||
|
min={0}
|
||||||
|
max={200}
|
||||||
|
step={1}
|
||||||
|
onValueChange={(vals) => handleValueChange('contrast', vals[0])}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 7. Saturate (饱和度) */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>饱和度 (Saturate)</Label>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{effects.saturate}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
value={[effects.saturate]}
|
||||||
|
min={0}
|
||||||
|
max={200}
|
||||||
|
step={1}
|
||||||
|
onValueChange={(vals) => handleValueChange('saturate', vals[0])}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 8. Gradient Overlay (渐变叠加) */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label>CSS 渐变叠加 (Gradient Overlay)</Label>
|
||||||
|
<Input
|
||||||
|
value={effects.gradientOverlay || ''}
|
||||||
|
onChange={handleGradientChange}
|
||||||
|
placeholder="e.g. linear-gradient(to bottom, transparent, black)"
|
||||||
|
className="font-mono text-xs"
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
可选:输入有效的 CSS gradient 字符串
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,164 @@
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
import { useAssetStore } from '@/components/asset-provider'
|
||||||
|
import type { BackgroundConfig } from '@/lib/theme/tokens'
|
||||||
|
|
||||||
|
type BackgroundLayerProps = {
|
||||||
|
config: BackgroundConfig
|
||||||
|
layerId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFilterString(effects: BackgroundConfig['effects']): string {
|
||||||
|
const parts: string[] = []
|
||||||
|
if (effects.blur > 0) parts.push(`blur(${effects.blur}px)`)
|
||||||
|
if (effects.brightness !== 100) parts.push(`brightness(${effects.brightness}%)`)
|
||||||
|
if (effects.contrast !== 100) parts.push(`contrast(${effects.contrast}%)`)
|
||||||
|
if (effects.saturate !== 100) parts.push(`saturate(${effects.saturate}%)`)
|
||||||
|
return parts.join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBackgroundSize(position: BackgroundConfig['effects']['position']): string {
|
||||||
|
switch (position) {
|
||||||
|
case 'cover':
|
||||||
|
return 'cover'
|
||||||
|
case 'contain':
|
||||||
|
return 'contain'
|
||||||
|
case 'center':
|
||||||
|
return 'auto'
|
||||||
|
case 'stretch':
|
||||||
|
return '100% 100%'
|
||||||
|
default:
|
||||||
|
return 'cover'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getObjectFit(position: BackgroundConfig['effects']['position']): React.CSSProperties['objectFit'] {
|
||||||
|
switch (position) {
|
||||||
|
case 'cover':
|
||||||
|
return 'cover'
|
||||||
|
case 'contain':
|
||||||
|
return 'contain'
|
||||||
|
case 'center':
|
||||||
|
return 'none'
|
||||||
|
case 'stretch':
|
||||||
|
return 'fill'
|
||||||
|
default:
|
||||||
|
return 'cover'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BackgroundLayer({ config, layerId }: BackgroundLayerProps) {
|
||||||
|
const { getAssetUrl } = useAssetStore()
|
||||||
|
const [blobUrl, setBlobUrl] = useState<string | undefined>()
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!config.assetId) {
|
||||||
|
setBlobUrl(undefined)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
getAssetUrl(config.assetId).then(setBlobUrl)
|
||||||
|
}, [config.assetId, getAssetUrl])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (config.type !== 'video' || !videoRef.current) return
|
||||||
|
|
||||||
|
const mq = window.matchMedia('(prefers-reduced-motion: reduce)')
|
||||||
|
const apply = () => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
if (mq.matches) {
|
||||||
|
videoRef.current.pause()
|
||||||
|
} else {
|
||||||
|
videoRef.current.play().catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
apply()
|
||||||
|
mq.addEventListener('change', apply)
|
||||||
|
return () => mq.removeEventListener('change', apply)
|
||||||
|
}, [config.type])
|
||||||
|
|
||||||
|
if (config.type === 'none') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterString = buildFilterString(config.effects)
|
||||||
|
const { overlayColor, overlayOpacity, gradientOverlay } = config.effects
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={layerId}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{config.type === 'image' && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 0,
|
||||||
|
backgroundImage: blobUrl ? `url(${blobUrl})` : undefined,
|
||||||
|
backgroundSize: getBackgroundSize(config.effects.position),
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
filter: filterString || undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{config.type === 'video' && blobUrl && (
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
src={blobUrl}
|
||||||
|
autoPlay
|
||||||
|
muted
|
||||||
|
loop
|
||||||
|
playsInline
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 0,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
objectFit: getObjectFit(config.effects.position),
|
||||||
|
filter: filterString || undefined,
|
||||||
|
}}
|
||||||
|
onError={() => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.pause()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{overlayOpacity > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 1,
|
||||||
|
backgroundColor: `hsl(${overlayColor} / ${overlayOpacity})`,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{gradientOverlay && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 2,
|
||||||
|
background: gradientOverlay,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,273 @@
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { Link, Loader2, Trash2, Upload } from 'lucide-react'
|
||||||
|
|
||||||
|
import { useAssetStore } from '@/components/asset-provider'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { addAsset, getAsset } from '@/lib/asset-store'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
type BackgroundUploaderProps = {
|
||||||
|
assetId?: string
|
||||||
|
onAssetSelect: (id: string | undefined) => void
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BackgroundUploader({ assetId, onAssetSelect, className }: BackgroundUploaderProps) {
|
||||||
|
const { getAssetUrl } = useAssetStore()
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [dragActive, setDragActive] = useState(false)
|
||||||
|
const [previewUrl, setPreviewUrl] = useState<string | undefined>(undefined)
|
||||||
|
const [assetType, setAssetType] = useState<'image' | 'video' | undefined>(undefined)
|
||||||
|
const [urlInput, setUrlInput] = useState('')
|
||||||
|
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
// 加载预览
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
|
||||||
|
const loadPreview = async () => {
|
||||||
|
if (!assetId) {
|
||||||
|
setPreviewUrl(undefined)
|
||||||
|
setAssetType(undefined)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = await getAssetUrl(assetId)
|
||||||
|
const record = await getAsset(assetId)
|
||||||
|
|
||||||
|
if (active) {
|
||||||
|
if (url && record) {
|
||||||
|
setPreviewUrl(url)
|
||||||
|
setAssetType(record.type)
|
||||||
|
} else {
|
||||||
|
// 如果找不到资源,可能是被删除了
|
||||||
|
onAssetSelect(undefined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load asset preview:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadPreview()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
}
|
||||||
|
}, [assetId, getAssetUrl, onAssetSelect])
|
||||||
|
|
||||||
|
const handleFile = async (file: File) => {
|
||||||
|
setError(null)
|
||||||
|
setIsLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 验证文件类型
|
||||||
|
if (!file.type.startsWith('image/') && !file.type.startsWith('video/')) {
|
||||||
|
throw new Error('不支持的文件类型。请上传图片或视频。')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证文件大小 (例如限制 50MB)
|
||||||
|
if (file.size > 50 * 1024 * 1024) {
|
||||||
|
throw new Error('文件过大。请上传小于 50MB 的文件。')
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = await addAsset(file)
|
||||||
|
onAssetSelect(id)
|
||||||
|
setUrlInput('') // 清空 URL 输入框
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '上传失败')
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUrlUpload = async () => {
|
||||||
|
if (!urlInput) return
|
||||||
|
|
||||||
|
setError(null)
|
||||||
|
setIsLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(urlInput)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`下载失败: ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob()
|
||||||
|
|
||||||
|
// 尝试从 Content-Type 或 URL 推断文件名和类型
|
||||||
|
const contentType = response.headers.get('content-type') || ''
|
||||||
|
const urlFilename = urlInput.split('/').pop() || 'downloaded-file'
|
||||||
|
const filename = urlFilename.includes('.') ? urlFilename : `${urlFilename}.${contentType.split('/')[1] || 'bin'}`
|
||||||
|
|
||||||
|
const file = new File([blob], filename, { type: contentType })
|
||||||
|
await handleFile(file)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '从 URL 上传失败')
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拖拽处理
|
||||||
|
const handleDrag = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
if (e.type === 'dragenter' || e.type === 'dragover') {
|
||||||
|
setDragActive(true)
|
||||||
|
} else if (e.type === 'dragleave') {
|
||||||
|
setDragActive(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
setDragActive(false)
|
||||||
|
|
||||||
|
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
|
||||||
|
handleFile(e.dataTransfer.files[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClear = () => {
|
||||||
|
onAssetSelect(undefined)
|
||||||
|
setPreviewUrl(undefined)
|
||||||
|
setAssetType(undefined)
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("space-y-4", className)}>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>背景资源</Label>
|
||||||
|
|
||||||
|
{/* 预览区域 / 上传区域 */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative flex min-h-[200px] flex-col items-center justify-center rounded-lg border-2 border-dashed p-4 transition-colors",
|
||||||
|
dragActive ? "border-primary bg-primary/5" : "border-muted-foreground/25",
|
||||||
|
error ? "border-destructive/50 bg-destructive/5" : "",
|
||||||
|
assetId ? "border-solid" : ""
|
||||||
|
)}
|
||||||
|
onDragEnter={handleDrag}
|
||||||
|
onDragLeave={handleDrag}
|
||||||
|
onDragOver={handleDrag}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin" />
|
||||||
|
<p className="text-sm">处理中...</p>
|
||||||
|
</div>
|
||||||
|
) : assetId && previewUrl ? (
|
||||||
|
<div className="relative h-full w-full">
|
||||||
|
{assetType === 'video' ? (
|
||||||
|
<video
|
||||||
|
src={previewUrl}
|
||||||
|
className="h-full max-h-[300px] w-full rounded-md object-contain"
|
||||||
|
controls={false}
|
||||||
|
muted
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
src={previewUrl}
|
||||||
|
alt="Background preview"
|
||||||
|
className="h-full max-h-[300px] w-full rounded-md object-contain"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="absolute right-2 top-2 flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 shadow-sm"
|
||||||
|
onClick={handleClear}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="absolute bottom-2 left-2 rounded bg-black/50 px-2 py-1 text-xs text-white backdrop-blur">
|
||||||
|
{assetType === 'video' ? '视频' : '图片'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center gap-4 text-center">
|
||||||
|
<div className="rounded-full bg-muted p-4">
|
||||||
|
<Upload className="h-8 w-8 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="font-medium">点击或拖拽上传</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
支持 JPG, PNG, GIF, MP4, WebM
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
选择文件
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
className="hidden"
|
||||||
|
accept="image/*,video/mp4,video/webm"
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.files?.[0]) {
|
||||||
|
handleFile(e.target.files[0])
|
||||||
|
}
|
||||||
|
// 重置 value,允许重复选择同一文件
|
||||||
|
e.target.value = ''
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* URL 上传 */}
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-xs text-muted-foreground">或从 URL 获取</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Link className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="https://example.com/image.jpg"
|
||||||
|
className="pl-9"
|
||||||
|
value={urlInput}
|
||||||
|
onChange={(e) => setUrlInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
handleUrlUpload()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={handleUrlUpload}
|
||||||
|
disabled={!urlInput || isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : '获取'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
import { AlertTriangle, Trash2 } from 'lucide-react'
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { CodeEditor } from '@/components/CodeEditor'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { sanitizeCSS } from '@/lib/theme/sanitizer'
|
||||||
|
|
||||||
|
export type ComponentCSSEditorProps = {
|
||||||
|
/** 组件唯一标识符 */
|
||||||
|
componentId: string
|
||||||
|
/** 当前 CSS 内容 */
|
||||||
|
value: string
|
||||||
|
/** CSS 内容变更回调 */
|
||||||
|
onChange: (css: string) => void
|
||||||
|
/** 编辑器标签文字 */
|
||||||
|
label?: string
|
||||||
|
/** 编辑器高度,默认 200px */
|
||||||
|
height?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 组件级 CSS 编辑器
|
||||||
|
* 提供 CSS 代码编辑、语法高亮和安全过滤警告功能
|
||||||
|
*/
|
||||||
|
export function ComponentCSSEditor({
|
||||||
|
componentId,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
label,
|
||||||
|
height = '200px',
|
||||||
|
}: ComponentCSSEditorProps) {
|
||||||
|
// 实时计算 CSS 警告
|
||||||
|
const { warnings } = sanitizeCSS(value)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-sm font-medium">
|
||||||
|
{label || '自定义 CSS'}
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onChange('')}
|
||||||
|
disabled={!value}
|
||||||
|
className="h-7 px-2 text-xs text-muted-foreground hover:text-destructive"
|
||||||
|
title="清除所有 CSS"
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
清除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border bg-card overflow-hidden">
|
||||||
|
<CodeEditor
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
language="css"
|
||||||
|
height={height}
|
||||||
|
placeholder={`/* 为 ${componentId} 组件编写自定义 CSS */\n\n/* 示例: */\n/* .custom-class { background: red; } */`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{warnings.length > 0 && (
|
||||||
|
<div className="border-t border-yellow-200 dark:border-yellow-800 bg-yellow-50 dark:bg-yellow-950/30 p-3">
|
||||||
|
<div className="flex items-center gap-2 text-yellow-800 dark:text-yellow-200 text-xs font-medium mb-1">
|
||||||
|
<AlertTriangle className="h-3.5 w-3.5" />
|
||||||
|
检测到不安全的 CSS 规则:
|
||||||
|
</div>
|
||||||
|
<ul className="text-[10px] sm:text-xs text-yellow-700 dark:text-yellow-300 space-y-0.5 ml-5 list-disc">
|
||||||
|
{warnings.map((w, i) => (
|
||||||
|
<li key={i}>{w}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,114 @@
|
||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import type { ConfigSchema, FieldSchema } from '@/types/config-schema'
|
||||||
|
import { fieldHooks, type FieldHookRegistry } from '@/lib/field-hooks'
|
||||||
|
|
||||||
|
import { DynamicField } from './DynamicField'
|
||||||
|
|
||||||
|
export interface DynamicConfigFormProps {
|
||||||
|
schema: ConfigSchema
|
||||||
|
values: Record<string, unknown>
|
||||||
|
onChange: (field: string, value: unknown) => void
|
||||||
|
hooks?: FieldHookRegistry
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DynamicConfigForm - 动态配置表单组件
|
||||||
|
*
|
||||||
|
* 根据 ConfigSchema 渲染表单字段,支持:
|
||||||
|
* 1. Hook 系统:通过 FieldHookRegistry 自定义字段渲染
|
||||||
|
* - replace 模式:完全替换默认渲染
|
||||||
|
* - wrapper 模式:包装默认渲染(通过 children 传递)
|
||||||
|
* 2. 嵌套 schema:递归渲染 schema.nested 中的子配置
|
||||||
|
* 3. 默认渲染:使用 DynamicField 组件
|
||||||
|
*/
|
||||||
|
export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
|
||||||
|
schema,
|
||||||
|
values,
|
||||||
|
onChange,
|
||||||
|
hooks = fieldHooks, // 默认使用全局单例
|
||||||
|
}) => {
|
||||||
|
/**
|
||||||
|
* 渲染单个字段
|
||||||
|
* 检查是否有注册的 Hook,根据 Hook 类型选择渲染方式
|
||||||
|
*/
|
||||||
|
const renderField = (field: FieldSchema) => {
|
||||||
|
const fieldPath = field.name
|
||||||
|
|
||||||
|
// 检查是否有注册的 Hook
|
||||||
|
if (hooks.has(fieldPath)) {
|
||||||
|
const hookEntry = hooks.get(fieldPath)
|
||||||
|
if (!hookEntry) return null // Type guard(理论上不会发生)
|
||||||
|
|
||||||
|
const HookComponent = hookEntry.component
|
||||||
|
|
||||||
|
if (hookEntry.type === 'replace') {
|
||||||
|
// replace 模式:完全替换默认渲染
|
||||||
|
return (
|
||||||
|
<HookComponent
|
||||||
|
fieldPath={fieldPath}
|
||||||
|
value={values[field.name]}
|
||||||
|
onChange={(v) => onChange(field.name, v)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// wrapper 模式:包装默认渲染
|
||||||
|
return (
|
||||||
|
<HookComponent
|
||||||
|
fieldPath={fieldPath}
|
||||||
|
value={values[field.name]}
|
||||||
|
onChange={(v) => onChange(field.name, v)}
|
||||||
|
>
|
||||||
|
<DynamicField
|
||||||
|
schema={field}
|
||||||
|
value={values[field.name]}
|
||||||
|
onChange={(v) => onChange(field.name, v)}
|
||||||
|
fieldPath={fieldPath}
|
||||||
|
/>
|
||||||
|
</HookComponent>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 无 Hook,使用默认渲染
|
||||||
|
return (
|
||||||
|
<DynamicField
|
||||||
|
schema={field}
|
||||||
|
value={values[field.name]}
|
||||||
|
onChange={(v) => onChange(field.name, v)}
|
||||||
|
fieldPath={fieldPath}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 渲染顶层字段 */}
|
||||||
|
{schema.fields.map((field) => (
|
||||||
|
<div key={field.name}>{renderField(field)}</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 渲染嵌套 schema */}
|
||||||
|
{schema.nested &&
|
||||||
|
Object.entries(schema.nested).map(([key, nestedSchema]) => (
|
||||||
|
<div key={key} className="mt-6 space-y-4">
|
||||||
|
{/* 嵌套 schema 标题 */}
|
||||||
|
<div className="border-b pb-2">
|
||||||
|
<h3 className="text-lg font-semibold">{nestedSchema.className}</h3>
|
||||||
|
{nestedSchema.classDoc && (
|
||||||
|
<p className="text-sm text-muted-foreground">{nestedSchema.classDoc}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 递归渲染嵌套表单 */}
|
||||||
|
<DynamicConfigForm
|
||||||
|
schema={nestedSchema}
|
||||||
|
values={(values[key] as Record<string, unknown>) || {}}
|
||||||
|
onChange={(field, value) => onChange(`${key}.${field}`, value)}
|
||||||
|
hooks={hooks}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,246 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import * as LucideIcons from "lucide-react"
|
||||||
|
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
|
import { Slider } from "@/components/ui/slider"
|
||||||
|
import { Switch } from "@/components/ui/switch"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import type { FieldSchema } from "@/types/config-schema"
|
||||||
|
|
||||||
|
export interface DynamicFieldProps {
|
||||||
|
schema: FieldSchema
|
||||||
|
value: unknown
|
||||||
|
onChange: (value: unknown) => void
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
fieldPath?: string // 用于 Hook 系统(未来使用)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DynamicField - 根据字段类型和 x-widget 渲染对应的 shadcn/ui 组件
|
||||||
|
*
|
||||||
|
* 渲染逻辑:
|
||||||
|
* 1. x-widget 优先:如果 schema 有 x-widget,使用对应组件
|
||||||
|
* 2. type 回退:如果没有 x-widget,根据 type 选择默认组件
|
||||||
|
*/
|
||||||
|
export const DynamicField: React.FC<DynamicFieldProps> = ({
|
||||||
|
schema,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}) => {
|
||||||
|
/**
|
||||||
|
* 渲染字段图标
|
||||||
|
*/
|
||||||
|
const renderIcon = () => {
|
||||||
|
if (!schema['x-icon']) return null
|
||||||
|
|
||||||
|
const IconComponent = (LucideIcons as any)[schema['x-icon']]
|
||||||
|
if (!IconComponent) return null
|
||||||
|
|
||||||
|
return <IconComponent className="h-4 w-4" />
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据 x-widget 或 type 选择并渲染对应的输入组件
|
||||||
|
*/
|
||||||
|
const renderInputComponent = () => {
|
||||||
|
const widget = schema['x-widget']
|
||||||
|
const type = schema.type
|
||||||
|
|
||||||
|
// x-widget 优先
|
||||||
|
if (widget) {
|
||||||
|
switch (widget) {
|
||||||
|
case 'slider':
|
||||||
|
return renderSlider()
|
||||||
|
case 'switch':
|
||||||
|
return renderSwitch()
|
||||||
|
case 'textarea':
|
||||||
|
return renderTextarea()
|
||||||
|
case 'select':
|
||||||
|
return renderSelect()
|
||||||
|
case 'custom':
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-dashed border-muted-foreground/25 bg-muted/10 p-4 text-center text-sm text-muted-foreground">
|
||||||
|
Custom field requires Hook
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
// 未知的 x-widget,回退到 type
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// type 回退
|
||||||
|
switch (type) {
|
||||||
|
case 'boolean':
|
||||||
|
return renderSwitch()
|
||||||
|
case 'number':
|
||||||
|
case 'integer':
|
||||||
|
return renderNumberInput()
|
||||||
|
case 'string':
|
||||||
|
return renderTextInput()
|
||||||
|
case 'select':
|
||||||
|
return renderSelect()
|
||||||
|
case 'array':
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-dashed border-muted-foreground/25 bg-muted/10 p-4 text-center text-sm text-muted-foreground">
|
||||||
|
Array fields not yet supported
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
case 'object':
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-dashed border-muted-foreground/25 bg-muted/10 p-4 text-center text-sm text-muted-foreground">
|
||||||
|
Object fields not yet supported
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
case 'textarea':
|
||||||
|
return renderTextarea()
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-dashed border-muted-foreground/25 bg-muted/10 p-4 text-center text-sm text-muted-foreground">
|
||||||
|
Unknown field type: {type}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 渲染 Switch 组件(用于 boolean 类型)
|
||||||
|
*/
|
||||||
|
const renderSwitch = () => {
|
||||||
|
const checked = Boolean(value)
|
||||||
|
return (
|
||||||
|
<Switch
|
||||||
|
checked={checked}
|
||||||
|
onCheckedChange={(checked) => onChange(checked)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 渲染 Slider 组件(用于 number 类型 + x-widget: slider)
|
||||||
|
*/
|
||||||
|
const renderSlider = () => {
|
||||||
|
const numValue = typeof value === 'number' ? value : (schema.default as number ?? 0)
|
||||||
|
const min = schema.minValue ?? 0
|
||||||
|
const max = schema.maxValue ?? 100
|
||||||
|
const step = schema.step ?? 1
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Slider
|
||||||
|
value={[numValue]}
|
||||||
|
onValueChange={(values) => onChange(values[0])}
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
step={step}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between text-xs text-muted-foreground">
|
||||||
|
<span>{min}</span>
|
||||||
|
<span className="font-medium text-foreground">{numValue}</span>
|
||||||
|
<span>{max}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 渲染 Input[type="number"] 组件(用于 number/integer 类型)
|
||||||
|
*/
|
||||||
|
const renderNumberInput = () => {
|
||||||
|
const numValue = typeof value === 'number' ? value : (schema.default as number ?? 0)
|
||||||
|
const min = schema.minValue
|
||||||
|
const max = schema.maxValue
|
||||||
|
const step = schema.step ?? (schema.type === 'integer' ? 1 : 0.1)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={numValue}
|
||||||
|
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
step={step}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 渲染 Input[type="text"] 组件(用于 string 类型)
|
||||||
|
*/
|
||||||
|
const renderTextInput = () => {
|
||||||
|
const strValue = typeof value === 'string' ? value : (schema.default as string ?? '')
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={strValue}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 渲染 Textarea 组件(用于 textarea 类型或 x-widget: textarea)
|
||||||
|
*/
|
||||||
|
const renderTextarea = () => {
|
||||||
|
const strValue = typeof value === 'string' ? value : (schema.default as string ?? '')
|
||||||
|
return (
|
||||||
|
<Textarea
|
||||||
|
value={strValue}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 渲染 Select 组件(用于 select 类型或 x-widget: select)
|
||||||
|
*/
|
||||||
|
const renderSelect = () => {
|
||||||
|
const strValue = typeof value === 'string' ? value : (schema.default as string ?? '')
|
||||||
|
const options = schema.options ?? []
|
||||||
|
|
||||||
|
if (options.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-dashed border-muted-foreground/25 bg-muted/10 p-4 text-center text-sm text-muted-foreground">
|
||||||
|
No options available for select
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select value={strValue} onValueChange={(val) => onChange(val)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder={`Select ${schema.label}`} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{options.map((option) => (
|
||||||
|
<SelectItem key={option} value={option}>
|
||||||
|
{option}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* Label with icon */}
|
||||||
|
<Label className="text-sm font-medium flex items-center gap-2">
|
||||||
|
{renderIcon()}
|
||||||
|
{schema.label}
|
||||||
|
{schema.required && <span className="text-destructive">*</span>}
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
{/* Input component */}
|
||||||
|
{renderInputComponent()}
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
{schema.description && (
|
||||||
|
<p className="text-sm text-muted-foreground">{schema.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,126 @@
|
||||||
|
# Dynamic Config Form System
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
The Dynamic Config Form system is a schema-driven UI component designed to automatically generate configuration forms based on backend Pydantic models. It supports rich metadata for UI customization and a flexible Hook system for complex fields.
|
||||||
|
|
||||||
|
### Core Components
|
||||||
|
- **DynamicConfigForm**: The main component that takes a `ConfigSchema` and renders the entire form.
|
||||||
|
- **DynamicField**: A lower-level component that renders individual fields based on their type and UI metadata.
|
||||||
|
- **FieldHookRegistry**: A registry for custom React components that can replace or wrap default field rendering.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
To use the dynamic form in your page:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { DynamicConfigForm } from '@/components/dynamic-form'
|
||||||
|
import { fieldHooks } from '@/lib/field-hooks'
|
||||||
|
|
||||||
|
// Example usage in a component
|
||||||
|
export function ConfigPage() {
|
||||||
|
const [config, setConfig] = useState({})
|
||||||
|
const schema = useConfigSchema() // Fetch from API
|
||||||
|
|
||||||
|
const handleChange = (fieldPath: string, value: unknown) => {
|
||||||
|
// fieldPath can be nested, e.g., 'section.subfield'
|
||||||
|
updateConfigAt(fieldPath, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DynamicConfigForm
|
||||||
|
schema={schema}
|
||||||
|
values={config}
|
||||||
|
onChange={handleChange}
|
||||||
|
hooks={fieldHooks}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Adding UI Metadata (Backend)
|
||||||
|
You can customize how fields are rendered by adding `json_schema_extra` to your Pydantic `Field` definitions.
|
||||||
|
|
||||||
|
### Supported Metadata
|
||||||
|
- `x-widget`: Specifies the UI component to use.
|
||||||
|
- `slider`: A range slider (requires `ge`, `le`, and `step`).
|
||||||
|
- `switch`: A toggle switch (for booleans).
|
||||||
|
- `textarea`: A multi-line text input.
|
||||||
|
- `select`: A dropdown menu (for `Literal` or enum types).
|
||||||
|
- `custom`: Indicates that this field requires a Hook for rendering.
|
||||||
|
- `x-icon`: A Lucide icon name (e.g., `MessageSquare`, `Settings`).
|
||||||
|
- `step`: Incremental step for sliders or number inputs.
|
||||||
|
|
||||||
|
### Example
|
||||||
|
```python
|
||||||
|
class ChatConfig(ConfigBase):
|
||||||
|
talk_value: float = Field(
|
||||||
|
default=0.5,
|
||||||
|
ge=0.0,
|
||||||
|
le=1.0,
|
||||||
|
json_schema_extra={
|
||||||
|
"x-widget": "slider",
|
||||||
|
"x-icon": "MessageSquare",
|
||||||
|
"step": 0.1
|
||||||
|
}
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Creating Hook Components
|
||||||
|
Hooks allow you to provide custom UI for complex configuration sections or fields.
|
||||||
|
|
||||||
|
### FieldHookComponent Interface
|
||||||
|
A Hook component receives the following props:
|
||||||
|
- `fieldPath`: The full path to the field.
|
||||||
|
- `value`: The current value of the field/section.
|
||||||
|
- `onChange`: Callback to update the value.
|
||||||
|
- `children`: (Only for `wrapper` hooks) The default field renderer.
|
||||||
|
|
||||||
|
### Implementation Example
|
||||||
|
```typescript
|
||||||
|
import type { FieldHookComponent } from '@/lib/field-hooks'
|
||||||
|
|
||||||
|
export const CustomSectionHook: FieldHookComponent = ({
|
||||||
|
fieldPath,
|
||||||
|
value,
|
||||||
|
onChange
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="custom-section">
|
||||||
|
<h3>Custom UI</h3>
|
||||||
|
<input
|
||||||
|
value={value.some_prop}
|
||||||
|
onChange={(e) => onChange({ ...value, some_prop: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Registering Hooks
|
||||||
|
Register hooks in your component's lifecycle:
|
||||||
|
```typescript
|
||||||
|
useEffect(() => {
|
||||||
|
fieldHooks.register('chat', ChatSectionHook, 'replace')
|
||||||
|
return () => fieldHooks.unregister('chat')
|
||||||
|
}, [])
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### DynamicConfigForm
|
||||||
|
| Prop | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `schema` | `ConfigSchema` | The schema generated by the backend. |
|
||||||
|
| `values` | `Record<string, any>` | Current configuration values. |
|
||||||
|
| `onChange` | `(field: string, value: any) => void` | Change handler. |
|
||||||
|
| `hooks` | `FieldHookRegistry` | Optional custom hook registry. |
|
||||||
|
|
||||||
|
### FieldHookRegistry
|
||||||
|
- `register(path, component, type)`: Register a hook.
|
||||||
|
- `get(path)`: Retrieve a registered hook.
|
||||||
|
- `has(path)`: Check if a hook exists.
|
||||||
|
- `unregister(path)`: Remove a hook.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
- **Hook not rendering**: Ensure the registration path matches the schema field name exactly (e.g., `chat` vs `Chat`).
|
||||||
|
- **Field missing**: Check if the field is present in the `ConfigSchema` returned by the backend.
|
||||||
|
- **TypeScript errors**: Ensure your Hook implements the `FieldHookComponent` type.
|
||||||
|
|
@ -0,0 +1,363 @@
|
||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { screen } from '@testing-library/dom'
|
||||||
|
import { render } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
|
||||||
|
import { DynamicConfigForm } from '../DynamicConfigForm'
|
||||||
|
import { FieldHookRegistry } from '@/lib/field-hooks'
|
||||||
|
import type { ConfigSchema } from '@/types/config-schema'
|
||||||
|
import type { FieldHookComponentProps } from '@/lib/field-hooks'
|
||||||
|
|
||||||
|
describe('DynamicConfigForm', () => {
|
||||||
|
describe('basic rendering', () => {
|
||||||
|
it('renders simple fields', () => {
|
||||||
|
const schema: ConfigSchema = {
|
||||||
|
className: 'TestConfig',
|
||||||
|
classDoc: 'Test configuration',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'field1',
|
||||||
|
type: 'string',
|
||||||
|
label: 'Field 1',
|
||||||
|
description: 'First field',
|
||||||
|
required: false,
|
||||||
|
default: 'value1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'field2',
|
||||||
|
type: 'boolean',
|
||||||
|
label: 'Field 2',
|
||||||
|
description: 'Second field',
|
||||||
|
required: false,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
const values = { field1: 'value1', field2: false }
|
||||||
|
const onChange = vi.fn()
|
||||||
|
|
||||||
|
render(<DynamicConfigForm schema={schema} values={values} onChange={onChange} />)
|
||||||
|
|
||||||
|
expect(screen.getByText('Field 1')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Field 2')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('First field')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Second field')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders nested schema', () => {
|
||||||
|
const schema: ConfigSchema = {
|
||||||
|
className: 'MainConfig',
|
||||||
|
classDoc: 'Main configuration',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'top_field',
|
||||||
|
type: 'string',
|
||||||
|
label: 'Top Field',
|
||||||
|
description: 'Top level field',
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
nested: {
|
||||||
|
sub_config: {
|
||||||
|
className: 'SubConfig',
|
||||||
|
classDoc: 'Sub configuration',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'nested_field',
|
||||||
|
type: 'number',
|
||||||
|
label: 'Nested Field',
|
||||||
|
description: 'Nested field',
|
||||||
|
required: false,
|
||||||
|
default: 42,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const values = {
|
||||||
|
top_field: 'top',
|
||||||
|
sub_config: {
|
||||||
|
nested_field: 42,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const onChange = vi.fn()
|
||||||
|
|
||||||
|
render(<DynamicConfigForm schema={schema} values={values} onChange={onChange} />)
|
||||||
|
|
||||||
|
expect(screen.getByText('Top Field')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('SubConfig')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Sub configuration')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Nested Field')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Hook system', () => {
|
||||||
|
it('renders Hook component in replace mode', () => {
|
||||||
|
const TestHookComponent: React.FC<FieldHookComponentProps> = ({ fieldPath, value }) => {
|
||||||
|
return <div data-testid="hook-component">Hook: {fieldPath} = {String(value)}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
const hooks = new FieldHookRegistry()
|
||||||
|
hooks.register('hooked_field', TestHookComponent, 'replace')
|
||||||
|
|
||||||
|
const schema: ConfigSchema = {
|
||||||
|
className: 'TestConfig',
|
||||||
|
classDoc: 'Test configuration',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'hooked_field',
|
||||||
|
type: 'string',
|
||||||
|
label: 'Hooked Field',
|
||||||
|
description: 'A field with hook',
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'normal_field',
|
||||||
|
type: 'string',
|
||||||
|
label: 'Normal Field',
|
||||||
|
description: 'A normal field',
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
const values = { hooked_field: 'test', normal_field: 'normal' }
|
||||||
|
const onChange = vi.fn()
|
||||||
|
|
||||||
|
render(<DynamicConfigForm schema={schema} values={values} onChange={onChange} hooks={hooks} />)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('hook-component')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Hook: hooked_field = test')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('Hooked Field')).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Normal Field')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders Hook component in wrapper mode', () => {
|
||||||
|
const WrapperHookComponent: React.FC<FieldHookComponentProps> = ({ fieldPath, children }) => {
|
||||||
|
return (
|
||||||
|
<div data-testid="wrapper-hook">
|
||||||
|
<div>Wrapper for: {fieldPath}</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hooks = new FieldHookRegistry()
|
||||||
|
hooks.register('wrapped_field', WrapperHookComponent, 'wrapper')
|
||||||
|
|
||||||
|
const schema: ConfigSchema = {
|
||||||
|
className: 'TestConfig',
|
||||||
|
classDoc: 'Test configuration',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'wrapped_field',
|
||||||
|
type: 'string',
|
||||||
|
label: 'Wrapped Field',
|
||||||
|
description: 'A wrapped field',
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
const values = { wrapped_field: 'test' }
|
||||||
|
const onChange = vi.fn()
|
||||||
|
|
||||||
|
render(<DynamicConfigForm schema={schema} values={values} onChange={onChange} hooks={hooks} />)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('wrapper-hook')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Wrapper for: wrapped_field')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Wrapped Field')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes correct props to Hook component', () => {
|
||||||
|
const TestHookComponent: React.FC<FieldHookComponentProps> = ({ fieldPath, value, onChange }) => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div data-testid="field-path">{fieldPath}</div>
|
||||||
|
<div data-testid="field-value">{String(value)}</div>
|
||||||
|
<button onClick={() => onChange?.('new_value')}>Change</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hooks = new FieldHookRegistry()
|
||||||
|
hooks.register('test_field', TestHookComponent, 'replace')
|
||||||
|
|
||||||
|
const schema: ConfigSchema = {
|
||||||
|
className: 'TestConfig',
|
||||||
|
classDoc: 'Test configuration',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'test_field',
|
||||||
|
type: 'string',
|
||||||
|
label: 'Test Field',
|
||||||
|
description: 'A test field',
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
const values = { test_field: 'original' }
|
||||||
|
const onChange = vi.fn()
|
||||||
|
|
||||||
|
render(<DynamicConfigForm schema={schema} values={values} onChange={onChange} hooks={hooks} />)
|
||||||
|
|
||||||
|
expect(screen.getByTestId('field-path')).toHaveTextContent('test_field')
|
||||||
|
expect(screen.getByTestId('field-value')).toHaveTextContent('original')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('onChange propagation', () => {
|
||||||
|
it('propagates onChange from simple field', async () => {
|
||||||
|
const schema: ConfigSchema = {
|
||||||
|
className: 'TestConfig',
|
||||||
|
classDoc: 'Test configuration',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'test_field',
|
||||||
|
type: 'string',
|
||||||
|
label: 'Test Field',
|
||||||
|
description: 'A test field',
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
const values = { test_field: '' }
|
||||||
|
const onChange = vi.fn()
|
||||||
|
|
||||||
|
render(<DynamicConfigForm schema={schema} values={values} onChange={onChange} />)
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox')
|
||||||
|
input.focus()
|
||||||
|
await userEvent.keyboard('Hello')
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenCalledTimes(5)
|
||||||
|
expect(onChange.mock.calls.every(call => call[0] === 'test_field')).toBe(true)
|
||||||
|
expect(onChange).toHaveBeenNthCalledWith(1, 'test_field', 'H')
|
||||||
|
expect(onChange).toHaveBeenNthCalledWith(5, 'test_field', 'o')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('propagates onChange from nested field with correct path', async () => {
|
||||||
|
const schema: ConfigSchema = {
|
||||||
|
className: 'MainConfig',
|
||||||
|
classDoc: 'Main configuration',
|
||||||
|
fields: [],
|
||||||
|
nested: {
|
||||||
|
sub_config: {
|
||||||
|
className: 'SubConfig',
|
||||||
|
classDoc: 'Sub configuration',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'nested_field',
|
||||||
|
type: 'string',
|
||||||
|
label: 'Nested Field',
|
||||||
|
description: 'Nested field',
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const values = {
|
||||||
|
sub_config: {
|
||||||
|
nested_field: '',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const onChange = vi.fn()
|
||||||
|
|
||||||
|
render(<DynamicConfigForm schema={schema} values={values} onChange={onChange} />)
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox')
|
||||||
|
input.focus()
|
||||||
|
await userEvent.keyboard('Test')
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenCalledTimes(4)
|
||||||
|
expect(onChange.mock.calls.every(call => call[0] === 'sub_config.nested_field')).toBe(true)
|
||||||
|
expect(onChange).toHaveBeenNthCalledWith(1, 'sub_config.nested_field', 'T')
|
||||||
|
expect(onChange).toHaveBeenNthCalledWith(4, 'sub_config.nested_field', 't')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('propagates onChange from Hook component', async () => {
|
||||||
|
const TestHookComponent: React.FC<FieldHookComponentProps> = ({ onChange }) => {
|
||||||
|
return <button onClick={() => onChange?.('hook_value')}>Set Value</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
const hooks = new FieldHookRegistry()
|
||||||
|
hooks.register('hooked_field', TestHookComponent, 'replace')
|
||||||
|
|
||||||
|
const schema: ConfigSchema = {
|
||||||
|
className: 'TestConfig',
|
||||||
|
classDoc: 'Test configuration',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'hooked_field',
|
||||||
|
type: 'string',
|
||||||
|
label: 'Hooked Field',
|
||||||
|
description: 'A hooked field',
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
const values = { hooked_field: '' }
|
||||||
|
const onChange = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
|
||||||
|
render(<DynamicConfigForm schema={schema} values={values} onChange={onChange} hooks={hooks} />)
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button'))
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenCalledWith('hooked_field', 'hook_value')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
it('renders with empty nested values', () => {
|
||||||
|
const schema: ConfigSchema = {
|
||||||
|
className: 'MainConfig',
|
||||||
|
classDoc: 'Main configuration',
|
||||||
|
fields: [],
|
||||||
|
nested: {
|
||||||
|
sub_config: {
|
||||||
|
className: 'SubConfig',
|
||||||
|
classDoc: 'Sub configuration',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'nested_field',
|
||||||
|
type: 'string',
|
||||||
|
label: 'Nested Field',
|
||||||
|
description: 'Nested field',
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const values = {}
|
||||||
|
const onChange = vi.fn()
|
||||||
|
|
||||||
|
render(<DynamicConfigForm schema={schema} values={values} onChange={onChange} />)
|
||||||
|
|
||||||
|
expect(screen.getByText('SubConfig')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Nested Field')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses default hook registry when not provided', () => {
|
||||||
|
const schema: ConfigSchema = {
|
||||||
|
className: 'TestConfig',
|
||||||
|
classDoc: 'Test configuration',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'test_field',
|
||||||
|
type: 'string',
|
||||||
|
label: 'Test Field',
|
||||||
|
description: 'A test field',
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
const values = { test_field: 'test' }
|
||||||
|
const onChange = vi.fn()
|
||||||
|
|
||||||
|
render(<DynamicConfigForm schema={schema} values={values} onChange={onChange} />)
|
||||||
|
|
||||||
|
expect(screen.getByText('Test Field')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,395 @@
|
||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { screen } from '@testing-library/dom'
|
||||||
|
import { render } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
|
||||||
|
import { DynamicField } from '../DynamicField'
|
||||||
|
import type { FieldSchema } from '@/types/config-schema'
|
||||||
|
|
||||||
|
describe('DynamicField', () => {
|
||||||
|
describe('x-widget priority', () => {
|
||||||
|
it('renders Slider when x-widget is slider', () => {
|
||||||
|
const schema: FieldSchema = {
|
||||||
|
name: 'test_slider',
|
||||||
|
type: 'number',
|
||||||
|
label: 'Test Slider',
|
||||||
|
description: 'A test slider',
|
||||||
|
required: false,
|
||||||
|
'x-widget': 'slider',
|
||||||
|
minValue: 0,
|
||||||
|
maxValue: 100,
|
||||||
|
default: 50,
|
||||||
|
}
|
||||||
|
const onChange = vi.fn()
|
||||||
|
|
||||||
|
render(<DynamicField schema={schema} value={50} onChange={onChange} />)
|
||||||
|
|
||||||
|
expect(screen.getByText('Test Slider')).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('slider')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('50')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders Switch when x-widget is switch', () => {
|
||||||
|
const schema: FieldSchema = {
|
||||||
|
name: 'test_switch',
|
||||||
|
type: 'boolean',
|
||||||
|
label: 'Test Switch',
|
||||||
|
description: 'A test switch',
|
||||||
|
required: false,
|
||||||
|
'x-widget': 'switch',
|
||||||
|
default: false,
|
||||||
|
}
|
||||||
|
const onChange = vi.fn()
|
||||||
|
|
||||||
|
render(<DynamicField schema={schema} value={false} onChange={onChange} />)
|
||||||
|
|
||||||
|
expect(screen.getByText('Test Switch')).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('switch')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders Textarea when x-widget is textarea', () => {
|
||||||
|
const schema: FieldSchema = {
|
||||||
|
name: 'test_textarea',
|
||||||
|
type: 'string',
|
||||||
|
label: 'Test Textarea',
|
||||||
|
description: 'A test textarea',
|
||||||
|
required: false,
|
||||||
|
'x-widget': 'textarea',
|
||||||
|
default: 'Hello',
|
||||||
|
}
|
||||||
|
const onChange = vi.fn()
|
||||||
|
|
||||||
|
render(<DynamicField schema={schema} value="Hello" onChange={onChange} />)
|
||||||
|
|
||||||
|
expect(screen.getByText('Test Textarea')).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('textbox')).toHaveValue('Hello')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders Select when x-widget is select', () => {
|
||||||
|
const schema: FieldSchema = {
|
||||||
|
name: 'test_select',
|
||||||
|
type: 'string',
|
||||||
|
label: 'Test Select',
|
||||||
|
description: 'A test select',
|
||||||
|
required: false,
|
||||||
|
'x-widget': 'select',
|
||||||
|
options: ['Option 1', 'Option 2', 'Option 3'],
|
||||||
|
default: 'Option 1',
|
||||||
|
}
|
||||||
|
const onChange = vi.fn()
|
||||||
|
|
||||||
|
render(<DynamicField schema={schema} value="Option 1" onChange={onChange} />)
|
||||||
|
|
||||||
|
expect(screen.getByText('Test Select')).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('combobox')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders placeholder for custom widget', () => {
|
||||||
|
const schema: FieldSchema = {
|
||||||
|
name: 'test_custom',
|
||||||
|
type: 'string',
|
||||||
|
label: 'Test Custom',
|
||||||
|
description: 'A test custom field',
|
||||||
|
required: false,
|
||||||
|
'x-widget': 'custom',
|
||||||
|
}
|
||||||
|
const onChange = vi.fn()
|
||||||
|
|
||||||
|
render(<DynamicField schema={schema} value="" onChange={onChange} />)
|
||||||
|
|
||||||
|
expect(screen.getByText('Custom field requires Hook')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('type fallback', () => {
|
||||||
|
it('renders Input for string type', () => {
|
||||||
|
const schema: FieldSchema = {
|
||||||
|
name: 'test_string',
|
||||||
|
type: 'string',
|
||||||
|
label: 'Test String',
|
||||||
|
description: 'A test string',
|
||||||
|
required: false,
|
||||||
|
default: 'Hello',
|
||||||
|
}
|
||||||
|
const onChange = vi.fn()
|
||||||
|
|
||||||
|
render(<DynamicField schema={schema} value="Hello" onChange={onChange} />)
|
||||||
|
|
||||||
|
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('textbox')).toHaveValue('Hello')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders Switch for boolean type', () => {
|
||||||
|
const schema: FieldSchema = {
|
||||||
|
name: 'test_bool',
|
||||||
|
type: 'boolean',
|
||||||
|
label: 'Test Boolean',
|
||||||
|
description: 'A test boolean',
|
||||||
|
required: false,
|
||||||
|
default: true,
|
||||||
|
}
|
||||||
|
const onChange = vi.fn()
|
||||||
|
|
||||||
|
render(<DynamicField schema={schema} value={true} onChange={onChange} />)
|
||||||
|
|
||||||
|
expect(screen.getByRole('switch')).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('switch')).toBeChecked()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders number Input for number type', () => {
|
||||||
|
const schema: FieldSchema = {
|
||||||
|
name: 'test_number',
|
||||||
|
type: 'number',
|
||||||
|
label: 'Test Number',
|
||||||
|
description: 'A test number',
|
||||||
|
required: false,
|
||||||
|
default: 42,
|
||||||
|
}
|
||||||
|
const onChange = vi.fn()
|
||||||
|
|
||||||
|
render(<DynamicField schema={schema} value={42} onChange={onChange} />)
|
||||||
|
|
||||||
|
const input = screen.getByRole('spinbutton')
|
||||||
|
expect(input).toBeInTheDocument()
|
||||||
|
expect(input).toHaveValue(42)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders number Input for integer type', () => {
|
||||||
|
const schema: FieldSchema = {
|
||||||
|
name: 'test_integer',
|
||||||
|
type: 'integer',
|
||||||
|
label: 'Test Integer',
|
||||||
|
description: 'A test integer',
|
||||||
|
required: false,
|
||||||
|
default: 10,
|
||||||
|
}
|
||||||
|
const onChange = vi.fn()
|
||||||
|
|
||||||
|
render(<DynamicField schema={schema} value={10} onChange={onChange} />)
|
||||||
|
|
||||||
|
const input = screen.getByRole('spinbutton')
|
||||||
|
expect(input).toBeInTheDocument()
|
||||||
|
expect(input).toHaveValue(10)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders Textarea for textarea type', () => {
|
||||||
|
const schema: FieldSchema = {
|
||||||
|
name: 'test_textarea_type',
|
||||||
|
type: 'textarea',
|
||||||
|
label: 'Test Textarea Type',
|
||||||
|
description: 'A test textarea type',
|
||||||
|
required: false,
|
||||||
|
default: 'Long text',
|
||||||
|
}
|
||||||
|
const onChange = vi.fn()
|
||||||
|
|
||||||
|
render(<DynamicField schema={schema} value="Long text" onChange={onChange} />)
|
||||||
|
|
||||||
|
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('textbox')).toHaveValue('Long text')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders Select for select type', () => {
|
||||||
|
const schema: FieldSchema = {
|
||||||
|
name: 'test_select_type',
|
||||||
|
type: 'select',
|
||||||
|
label: 'Test Select Type',
|
||||||
|
description: 'A test select type',
|
||||||
|
required: false,
|
||||||
|
options: ['A', 'B', 'C'],
|
||||||
|
default: 'A',
|
||||||
|
}
|
||||||
|
const onChange = vi.fn()
|
||||||
|
|
||||||
|
render(<DynamicField schema={schema} value="A" onChange={onChange} />)
|
||||||
|
|
||||||
|
expect(screen.getByRole('combobox')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders placeholder for array type', () => {
|
||||||
|
const schema: FieldSchema = {
|
||||||
|
name: 'test_array',
|
||||||
|
type: 'array',
|
||||||
|
label: 'Test Array',
|
||||||
|
description: 'A test array',
|
||||||
|
required: false,
|
||||||
|
}
|
||||||
|
const onChange = vi.fn()
|
||||||
|
|
||||||
|
render(<DynamicField schema={schema} value={[]} onChange={onChange} />)
|
||||||
|
|
||||||
|
expect(screen.getByText('Array fields not yet supported')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders placeholder for object type', () => {
|
||||||
|
const schema: FieldSchema = {
|
||||||
|
name: 'test_object',
|
||||||
|
type: 'object',
|
||||||
|
label: 'Test Object',
|
||||||
|
description: 'A test object',
|
||||||
|
required: false,
|
||||||
|
}
|
||||||
|
const onChange = vi.fn()
|
||||||
|
|
||||||
|
render(<DynamicField schema={schema} value={{}} onChange={onChange} />)
|
||||||
|
|
||||||
|
expect(screen.getByText('Object fields not yet supported')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('onChange events', () => {
|
||||||
|
it('triggers onChange for Switch', async () => {
|
||||||
|
const schema: FieldSchema = {
|
||||||
|
name: 'test_switch',
|
||||||
|
type: 'boolean',
|
||||||
|
label: 'Test Switch',
|
||||||
|
description: 'A test switch',
|
||||||
|
required: false,
|
||||||
|
default: false,
|
||||||
|
}
|
||||||
|
const onChange = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
|
||||||
|
render(<DynamicField schema={schema} value={false} onChange={onChange} />)
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('switch'))
|
||||||
|
expect(onChange).toHaveBeenCalledWith(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('triggers onChange for Input', async () => {
|
||||||
|
const schema: FieldSchema = {
|
||||||
|
name: 'test_input',
|
||||||
|
type: 'string',
|
||||||
|
label: 'Test Input',
|
||||||
|
description: 'A test input',
|
||||||
|
required: false,
|
||||||
|
default: '',
|
||||||
|
}
|
||||||
|
const onChange = vi.fn()
|
||||||
|
|
||||||
|
render(<DynamicField schema={schema} value="" onChange={onChange} />)
|
||||||
|
|
||||||
|
const input = screen.getByRole('textbox')
|
||||||
|
input.focus()
|
||||||
|
await userEvent.keyboard('Hello')
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenCalledTimes(5)
|
||||||
|
expect(onChange).toHaveBeenNthCalledWith(1, 'H')
|
||||||
|
expect(onChange).toHaveBeenNthCalledWith(2, 'e')
|
||||||
|
expect(onChange).toHaveBeenNthCalledWith(3, 'l')
|
||||||
|
expect(onChange).toHaveBeenNthCalledWith(4, 'l')
|
||||||
|
expect(onChange).toHaveBeenNthCalledWith(5, 'o')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('triggers onChange for number Input', async () => {
|
||||||
|
const schema: FieldSchema = {
|
||||||
|
name: 'test_number',
|
||||||
|
type: 'number',
|
||||||
|
label: 'Test Number',
|
||||||
|
description: 'A test number',
|
||||||
|
required: false,
|
||||||
|
default: 0,
|
||||||
|
}
|
||||||
|
const onChange = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
|
||||||
|
render(<DynamicField schema={schema} value={0} onChange={onChange} />)
|
||||||
|
|
||||||
|
const input = screen.getByRole('spinbutton')
|
||||||
|
await user.clear(input)
|
||||||
|
await user.type(input, '123')
|
||||||
|
expect(onChange).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('visual features', () => {
|
||||||
|
it('renders label with icon', () => {
|
||||||
|
const schema: FieldSchema = {
|
||||||
|
name: 'test_icon',
|
||||||
|
type: 'string',
|
||||||
|
label: 'Test Icon',
|
||||||
|
description: 'A test with icon',
|
||||||
|
required: false,
|
||||||
|
'x-icon': 'Settings',
|
||||||
|
}
|
||||||
|
const onChange = vi.fn()
|
||||||
|
|
||||||
|
render(<DynamicField schema={schema} value="" onChange={onChange} />)
|
||||||
|
|
||||||
|
expect(screen.getByText('Test Icon')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders required indicator', () => {
|
||||||
|
const schema: FieldSchema = {
|
||||||
|
name: 'test_required',
|
||||||
|
type: 'string',
|
||||||
|
label: 'Test Required',
|
||||||
|
description: 'A required field',
|
||||||
|
required: true,
|
||||||
|
}
|
||||||
|
const onChange = vi.fn()
|
||||||
|
|
||||||
|
render(<DynamicField schema={schema} value="" onChange={onChange} />)
|
||||||
|
|
||||||
|
expect(screen.getByText('*')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders description', () => {
|
||||||
|
const schema: FieldSchema = {
|
||||||
|
name: 'test_desc',
|
||||||
|
type: 'string',
|
||||||
|
label: 'Test Description',
|
||||||
|
description: 'This is a description',
|
||||||
|
required: false,
|
||||||
|
}
|
||||||
|
const onChange = vi.fn()
|
||||||
|
|
||||||
|
render(<DynamicField schema={schema} value="" onChange={onChange} />)
|
||||||
|
|
||||||
|
expect(screen.getByText('This is a description')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('slider features', () => {
|
||||||
|
it('renders slider with min/max/step', () => {
|
||||||
|
const schema: FieldSchema = {
|
||||||
|
name: 'test_slider_props',
|
||||||
|
type: 'number',
|
||||||
|
label: 'Test Slider Props',
|
||||||
|
description: 'A slider with props',
|
||||||
|
required: false,
|
||||||
|
'x-widget': 'slider',
|
||||||
|
minValue: 10,
|
||||||
|
maxValue: 50,
|
||||||
|
step: 5,
|
||||||
|
default: 25,
|
||||||
|
}
|
||||||
|
const onChange = vi.fn()
|
||||||
|
|
||||||
|
render(<DynamicField schema={schema} value={25} onChange={onChange} />)
|
||||||
|
|
||||||
|
expect(screen.getByText('10')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('50')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('25')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('select features', () => {
|
||||||
|
it('renders placeholder when no options', () => {
|
||||||
|
const schema: FieldSchema = {
|
||||||
|
name: 'test_select_no_options',
|
||||||
|
type: 'string',
|
||||||
|
label: 'Test Select No Options',
|
||||||
|
description: 'A select with no options',
|
||||||
|
required: false,
|
||||||
|
'x-widget': 'select',
|
||||||
|
}
|
||||||
|
const onChange = vi.fn()
|
||||||
|
|
||||||
|
render(<DynamicField schema={schema} value="" onChange={onChange} />)
|
||||||
|
|
||||||
|
expect(screen.getByText('No options available for select')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { DynamicConfigForm } from './DynamicConfigForm'
|
||||||
|
export { DynamicField } from './DynamicField'
|
||||||
|
|
@ -20,6 +20,9 @@ import { cn } from '@/lib/utils'
|
||||||
import { formatVersion } from '@/lib/version'
|
import { formatVersion } from '@/lib/version'
|
||||||
import type { ReactNode, ComponentType } from 'react'
|
import type { ReactNode, ComponentType } from 'react'
|
||||||
import type { LucideProps } from 'lucide-react'
|
import type { LucideProps } from 'lucide-react'
|
||||||
|
import { BackgroundLayer } from '@/components/background-layer'
|
||||||
|
|
||||||
|
import { useBackground } from '@/hooks/use-background'
|
||||||
|
|
||||||
interface LayoutProps {
|
interface LayoutProps {
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
|
|
@ -140,6 +143,10 @@ export function Layout({ children }: LayoutProps) {
|
||||||
|
|
||||||
const actualTheme = getActualTheme()
|
const actualTheme = getActualTheme()
|
||||||
|
|
||||||
|
const pageBg = useBackground('page')
|
||||||
|
const sidebarBg = useBackground('sidebar')
|
||||||
|
const headerBg = useBackground('header')
|
||||||
|
|
||||||
// 登出处理
|
// 登出处理
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
await logout()
|
await logout()
|
||||||
|
|
@ -158,6 +165,7 @@ export function Layout({ children }: LayoutProps) {
|
||||||
mobileMenuOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
|
mobileMenuOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
<BackgroundLayer config={sidebarBg} layerId="sidebar" />
|
||||||
{/* Logo 区域 */}
|
{/* Logo 区域 */}
|
||||||
<div className="flex h-16 items-center border-b px-4">
|
<div className="flex h-16 items-center border-b px-4">
|
||||||
<div
|
<div
|
||||||
|
|
@ -306,6 +314,7 @@ export function Layout({ children }: LayoutProps) {
|
||||||
|
|
||||||
{/* Topbar */}
|
{/* Topbar */}
|
||||||
<header className="flex h-16 items-center justify-between border-b bg-card/80 backdrop-blur-md px-4 sticky top-0 z-10">
|
<header className="flex h-16 items-center justify-between border-b bg-card/80 backdrop-blur-md px-4 sticky top-0 z-10">
|
||||||
|
<BackgroundLayer config={headerBg} layerId="header" />
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{/* 移动端菜单按钮 */}
|
{/* 移动端菜单按钮 */}
|
||||||
<button
|
<button
|
||||||
|
|
@ -398,7 +407,10 @@ export function Layout({ children }: LayoutProps) {
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Page content */}
|
{/* Page content */}
|
||||||
<main className="flex-1 overflow-hidden bg-background">{children}</main>
|
<main className="relative flex-1 overflow-hidden bg-background">
|
||||||
|
<BackgroundLayer config={pageBg} layerId="page" />
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
|
||||||
{/* Back to Top Button */}
|
{/* Back to Top Button */}
|
||||||
<BackToTop />
|
<BackToTop />
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,16 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
import { ThemeProviderContext } from '@/lib/theme-context'
|
import { ThemeProviderContext } from '@/lib/theme-context'
|
||||||
|
import type { UserThemeConfig } from '@/lib/theme/tokens'
|
||||||
|
import {
|
||||||
|
THEME_STORAGE_KEYS,
|
||||||
|
loadThemeConfig,
|
||||||
|
migrateOldKeys,
|
||||||
|
resetThemeToDefault,
|
||||||
|
saveThemePartial,
|
||||||
|
} from '@/lib/theme/storage'
|
||||||
|
import { applyThemePipeline, removeCustomCSS } from '@/lib/theme/pipeline'
|
||||||
|
|
||||||
type Theme = 'dark' | 'light' | 'system'
|
type Theme = 'dark' | 'light' | 'system'
|
||||||
|
|
||||||
|
|
@ -13,126 +23,74 @@ type ThemeProviderProps = {
|
||||||
export function ThemeProvider({
|
export function ThemeProvider({
|
||||||
children,
|
children,
|
||||||
defaultTheme = 'system',
|
defaultTheme = 'system',
|
||||||
storageKey = 'ui-theme',
|
storageKey: _storageKey,
|
||||||
...props
|
|
||||||
}: ThemeProviderProps) {
|
}: ThemeProviderProps) {
|
||||||
const [theme, setTheme] = useState<Theme>(
|
const [themeMode, setThemeMode] = useState<Theme>(() => {
|
||||||
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
|
const saved = localStorage.getItem(THEME_STORAGE_KEYS.MODE) as Theme | null
|
||||||
)
|
return saved || defaultTheme
|
||||||
|
})
|
||||||
|
const [themeConfig, setThemeConfig] = useState<UserThemeConfig>(() => loadThemeConfig())
|
||||||
|
const [systemThemeTick, setSystemThemeTick] = useState(0)
|
||||||
|
|
||||||
|
const resolvedTheme = useMemo<'dark' | 'light'>(() => {
|
||||||
|
if (themeMode !== 'system') return themeMode
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||||
|
}, [themeMode, systemThemeTick])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = window.document.documentElement
|
migrateOldKeys()
|
||||||
|
|
||||||
root.classList.remove('light', 'dark')
|
|
||||||
|
|
||||||
if (theme === 'system') {
|
|
||||||
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches
|
|
||||||
? 'dark'
|
|
||||||
: 'light'
|
|
||||||
|
|
||||||
root.classList.add(systemTheme)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
root.classList.add(theme)
|
|
||||||
}, [theme])
|
|
||||||
|
|
||||||
// 应用保存的主题色
|
|
||||||
useEffect(() => {
|
|
||||||
const savedAccentColor = localStorage.getItem('accent-color')
|
|
||||||
if (savedAccentColor) {
|
|
||||||
const root = document.documentElement
|
|
||||||
const colors = {
|
|
||||||
blue: {
|
|
||||||
hsl: '221.2 83.2% 53.3%',
|
|
||||||
darkHsl: '217.2 91.2% 59.8%',
|
|
||||||
gradient: null
|
|
||||||
},
|
|
||||||
purple: {
|
|
||||||
hsl: '271 91% 65%',
|
|
||||||
darkHsl: '270 95% 75%',
|
|
||||||
gradient: null
|
|
||||||
},
|
|
||||||
green: {
|
|
||||||
hsl: '142 71% 45%',
|
|
||||||
darkHsl: '142 76% 36%',
|
|
||||||
gradient: null
|
|
||||||
},
|
|
||||||
orange: {
|
|
||||||
hsl: '25 95% 53%',
|
|
||||||
darkHsl: '20 90% 48%',
|
|
||||||
gradient: null
|
|
||||||
},
|
|
||||||
pink: {
|
|
||||||
hsl: '330 81% 60%',
|
|
||||||
darkHsl: '330 85% 70%',
|
|
||||||
gradient: null
|
|
||||||
},
|
|
||||||
red: {
|
|
||||||
hsl: '0 84% 60%',
|
|
||||||
darkHsl: '0 90% 70%',
|
|
||||||
gradient: null
|
|
||||||
},
|
|
||||||
|
|
||||||
// 渐变色
|
|
||||||
'gradient-sunset': {
|
|
||||||
hsl: '15 95% 60%',
|
|
||||||
darkHsl: '15 95% 65%',
|
|
||||||
gradient: 'linear-gradient(135deg, hsl(25 95% 53%) 0%, hsl(330 81% 60%) 100%)'
|
|
||||||
},
|
|
||||||
'gradient-ocean': {
|
|
||||||
hsl: '200 90% 55%',
|
|
||||||
darkHsl: '200 90% 60%',
|
|
||||||
gradient: 'linear-gradient(135deg, hsl(221.2 83.2% 53.3%) 0%, hsl(189 94% 43%) 100%)'
|
|
||||||
},
|
|
||||||
'gradient-forest': {
|
|
||||||
hsl: '150 70% 45%',
|
|
||||||
darkHsl: '150 75% 40%',
|
|
||||||
gradient: 'linear-gradient(135deg, hsl(142 71% 45%) 0%, hsl(158 64% 52%) 100%)'
|
|
||||||
},
|
|
||||||
'gradient-aurora': {
|
|
||||||
hsl: '310 85% 65%',
|
|
||||||
darkHsl: '310 90% 70%',
|
|
||||||
gradient: 'linear-gradient(135deg, hsl(271 91% 65%) 0%, hsl(330 81% 60%) 100%)'
|
|
||||||
},
|
|
||||||
'gradient-fire': {
|
|
||||||
hsl: '15 95% 55%',
|
|
||||||
darkHsl: '15 95% 60%',
|
|
||||||
gradient: 'linear-gradient(135deg, hsl(0 84% 60%) 0%, hsl(25 95% 53%) 100%)'
|
|
||||||
},
|
|
||||||
'gradient-twilight': {
|
|
||||||
hsl: '250 90% 60%',
|
|
||||||
darkHsl: '250 95% 65%',
|
|
||||||
gradient: 'linear-gradient(135deg, hsl(239 84% 67%) 0%, hsl(271 91% 65%) 100%)'
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedColor = colors[savedAccentColor as keyof typeof colors]
|
|
||||||
if (selectedColor) {
|
|
||||||
root.style.setProperty('--primary', selectedColor.hsl)
|
|
||||||
|
|
||||||
// 设置渐变(如果有)
|
|
||||||
if (selectedColor.gradient) {
|
|
||||||
root.style.setProperty('--primary-gradient', selectedColor.gradient)
|
|
||||||
root.classList.add('has-gradient')
|
|
||||||
} else {
|
|
||||||
root.style.removeProperty('--primary-gradient')
|
|
||||||
root.classList.remove('has-gradient')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const value = {
|
useEffect(() => {
|
||||||
theme,
|
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||||
setTheme: (theme: Theme) => {
|
const handleChange = () => {
|
||||||
localStorage.setItem(storageKey, theme)
|
if (themeMode === 'system') {
|
||||||
setTheme(theme)
|
setSystemThemeTick((prev) => prev + 1)
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
|
mediaQuery.addEventListener('change', handleChange)
|
||||||
|
return () => mediaQuery.removeEventListener('change', handleChange)
|
||||||
|
}, [themeMode])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = document.documentElement
|
||||||
|
root.classList.remove('light', 'dark')
|
||||||
|
root.classList.add(resolvedTheme)
|
||||||
|
|
||||||
|
const isDark = resolvedTheme === 'dark'
|
||||||
|
applyThemePipeline(themeConfig, isDark)
|
||||||
|
}, [resolvedTheme, themeConfig])
|
||||||
|
|
||||||
|
const setTheme = useCallback((mode: Theme) => {
|
||||||
|
localStorage.setItem(THEME_STORAGE_KEYS.MODE, mode)
|
||||||
|
setThemeMode(mode)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const updateThemeConfig = useCallback((partial: Partial<UserThemeConfig>) => {
|
||||||
|
saveThemePartial(partial)
|
||||||
|
setThemeConfig((prev) => ({ ...prev, ...partial }))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const resetTheme = useCallback(() => {
|
||||||
|
resetThemeToDefault()
|
||||||
|
removeCustomCSS()
|
||||||
|
setThemeConfig(loadThemeConfig())
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({
|
||||||
|
theme: themeMode,
|
||||||
|
resolvedTheme,
|
||||||
|
setTheme,
|
||||||
|
themeConfig,
|
||||||
|
updateThemeConfig,
|
||||||
|
resetTheme,
|
||||||
|
}),
|
||||||
|
[themeMode, resolvedTheme, setTheme, themeConfig, updateThemeConfig, resetTheme],
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProviderContext.Provider {...props} value={value}>
|
<ThemeProviderContext.Provider value={value}>
|
||||||
{children}
|
{children}
|
||||||
</ThemeProviderContext.Provider>
|
</ThemeProviderContext.Provider>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,10 @@ import { useTour } from './use-tour'
|
||||||
const joyrideStyles = {
|
const joyrideStyles = {
|
||||||
options: {
|
options: {
|
||||||
zIndex: 10000,
|
zIndex: 10000,
|
||||||
primaryColor: 'hsl(var(--primary))',
|
primaryColor: 'hsl(var(--color-primary))',
|
||||||
textColor: 'hsl(var(--foreground))',
|
textColor: 'hsl(var(--color-foreground))',
|
||||||
backgroundColor: 'hsl(var(--background))',
|
backgroundColor: 'hsl(var(--color-background))',
|
||||||
arrowColor: 'hsl(var(--background))',
|
arrowColor: 'hsl(var(--color-background))',
|
||||||
overlayColor: 'rgba(0, 0, 0, 0.5)',
|
overlayColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
},
|
},
|
||||||
tooltip: {
|
tooltip: {
|
||||||
|
|
@ -30,23 +30,23 @@ const joyrideStyles = {
|
||||||
padding: '0.5rem 0',
|
padding: '0.5rem 0',
|
||||||
},
|
},
|
||||||
buttonNext: {
|
buttonNext: {
|
||||||
backgroundColor: 'hsl(var(--primary))',
|
backgroundColor: 'hsl(var(--color-primary))',
|
||||||
color: 'hsl(var(--primary-foreground))',
|
color: 'hsl(var(--color-primary-foreground))',
|
||||||
borderRadius: 'calc(var(--radius) - 2px)',
|
borderRadius: 'calc(var(--radius) - 2px)',
|
||||||
fontSize: '0.875rem',
|
fontSize: '0.875rem',
|
||||||
padding: '0.5rem 1rem',
|
padding: '0.5rem 1rem',
|
||||||
},
|
},
|
||||||
buttonBack: {
|
buttonBack: {
|
||||||
color: 'hsl(var(--muted-foreground))',
|
color: 'hsl(var(--color-muted-foreground))',
|
||||||
fontSize: '0.875rem',
|
fontSize: '0.875rem',
|
||||||
marginRight: '0.5rem',
|
marginRight: '0.5rem',
|
||||||
},
|
},
|
||||||
buttonSkip: {
|
buttonSkip: {
|
||||||
color: 'hsl(var(--muted-foreground))',
|
color: 'hsl(var(--color-muted-foreground))',
|
||||||
fontSize: '0.875rem',
|
fontSize: '0.875rem',
|
||||||
},
|
},
|
||||||
buttonClose: {
|
buttonClose: {
|
||||||
color: 'hsl(var(--muted-foreground))',
|
color: 'hsl(var(--color-muted-foreground))',
|
||||||
},
|
},
|
||||||
spotlight: {
|
spotlight: {
|
||||||
borderRadius: 'var(--radius)',
|
borderRadius: 'var(--radius)',
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
import type { ComponentPropsWithoutRef, ElementRef } from 'react'
|
||||||
|
import { forwardRef } from 'react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
import { BackgroundLayer } from '@/components/background-layer'
|
||||||
|
import { Card } from '@/components/ui/card'
|
||||||
|
|
||||||
|
import { useBackground } from '@/hooks/use-background'
|
||||||
|
|
||||||
|
type CardWithBackgroundProps = ComponentPropsWithoutRef<typeof Card>
|
||||||
|
|
||||||
|
export const CardWithBackground = forwardRef<
|
||||||
|
ElementRef<typeof Card>,
|
||||||
|
CardWithBackgroundProps
|
||||||
|
>(({ className, children, ...props }, ref) => {
|
||||||
|
const bg = useBackground('card')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card ref={ref} className={cn('relative', className)} {...props}>
|
||||||
|
<BackgroundLayer config={bg} layerId="card" />
|
||||||
|
{children}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
CardWithBackground.displayName = 'CardWithBackground'
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
import type { ComponentPropsWithoutRef, ElementRef } from 'react'
|
||||||
|
import { forwardRef } from 'react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
import { BackgroundLayer } from '@/components/background-layer'
|
||||||
|
import { DialogContent } from '@/components/ui/dialog'
|
||||||
|
|
||||||
|
import { useBackground } from '@/hooks/use-background'
|
||||||
|
|
||||||
|
type DialogContentWithBackgroundProps = ComponentPropsWithoutRef<typeof DialogContent>
|
||||||
|
|
||||||
|
export const DialogContentWithBackground = forwardRef<
|
||||||
|
ElementRef<typeof DialogContent>,
|
||||||
|
DialogContentWithBackgroundProps
|
||||||
|
>(({ className, children, ...props }, ref) => {
|
||||||
|
const bg = useBackground('dialog')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogContent ref={ref} className={cn('relative', className)} {...props}>
|
||||||
|
<BackgroundLayer config={bg} layerId="dialog" />
|
||||||
|
{children}
|
||||||
|
</DialogContent>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
DialogContentWithBackground.displayName = 'DialogContentWithBackground'
|
||||||
|
|
@ -354,7 +354,7 @@ export function WavesBackground() {
|
||||||
left: 0,
|
left: 0,
|
||||||
width: '0.5rem',
|
width: '0.5rem',
|
||||||
height: '0.5rem',
|
height: '0.5rem',
|
||||||
background: 'hsl(var(--primary) / 0.3)',
|
background: 'hsl(var(--color-primary) / 0.3)',
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
transform: 'translate3d(calc(var(--x, -0.5rem) - 50%), calc(var(--y, 50%) - 50%), 0)',
|
transform: 'translate3d(calc(var(--x, -0.5rem) - 50%), calc(var(--y, 50%) - 50%), 0)',
|
||||||
willChange: 'transform',
|
willChange: 'transform',
|
||||||
|
|
@ -372,7 +372,7 @@ export function WavesBackground() {
|
||||||
<style>{`
|
<style>{`
|
||||||
path {
|
path {
|
||||||
fill: none;
|
fill: none;
|
||||||
stroke: hsl(var(--primary) / 0.20);
|
stroke: hsl(var(--color-primary) / 0.20);
|
||||||
stroke-width: 1px;
|
stroke-width: 1px;
|
||||||
}
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { useTheme } from '@/components/use-theme'
|
||||||
|
|
||||||
|
import type { BackgroundConfig } from '@/lib/theme/tokens'
|
||||||
|
import { defaultBackgroundConfig } from '@/lib/theme/tokens'
|
||||||
|
|
||||||
|
type BackgroundLayerId = 'page' | 'sidebar' | 'header' | 'card' | 'dialog'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指定层级的背景配置
|
||||||
|
* 处理继承逻辑:如果 inherit 为 true,返回页面级别配置
|
||||||
|
* @param layerId - 背景层级标识
|
||||||
|
* @returns 对应层级的背景配置
|
||||||
|
*/
|
||||||
|
export function useBackground(layerId: BackgroundLayerId): BackgroundConfig {
|
||||||
|
const { themeConfig } = useTheme()
|
||||||
|
const bgMap = themeConfig.backgroundConfig ?? {}
|
||||||
|
|
||||||
|
const config = bgMap[layerId] ?? defaultBackgroundConfig
|
||||||
|
|
||||||
|
// 处理继承逻辑:非 page 层级且 inherit 为 true,返回 page 配置
|
||||||
|
if (layerId !== 'page' && config.inherit) {
|
||||||
|
return bgMap.page ?? defaultBackgroundConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
@ -13,60 +13,183 @@
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--background: 0 0% 100%;
|
/* Color Tokens */
|
||||||
--foreground: 222.2 84% 4.9%;
|
--color-primary: 221.2 83.2% 53.3%;
|
||||||
--card: 0 0% 100%;
|
--color-primary-foreground: 210 40% 98%;
|
||||||
--card-foreground: 222.2 84% 4.9%;
|
--color-primary-gradient: none;
|
||||||
--popover: 0 0% 100%;
|
--color-secondary: 210 40% 96.1%;
|
||||||
--popover-foreground: 222.2 84% 4.9%;
|
--color-secondary-foreground: 222.2 47.4% 11.2%;
|
||||||
--primary: 221.2 83.2% 53.3%;
|
--color-muted: 210 40% 96.1%;
|
||||||
--primary-foreground: 210 40% 98%;
|
--color-muted-foreground: 215.4 16.3% 46.9%;
|
||||||
--primary-gradient: none; /* 默认无渐变 */
|
--color-accent: 210 40% 96.1%;
|
||||||
--secondary: 210 40% 96.1%;
|
--color-accent-foreground: 222.2 47.4% 11.2%;
|
||||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
--color-destructive: 0 84.2% 60.2%;
|
||||||
--muted: 210 40% 96.1%;
|
--color-destructive-foreground: 210 40% 98%;
|
||||||
--muted-foreground: 215.4 16.3% 46.9%;
|
--color-background: 0 0% 100%;
|
||||||
--accent: 210 40% 96.1%;
|
--color-foreground: 222.2 84% 4.9%;
|
||||||
--accent-foreground: 222.2 47.4% 11.2%;
|
--color-card: 0 0% 100%;
|
||||||
--destructive: 0 84.2% 60.2%;
|
--color-card-foreground: 222.2 84% 4.9%;
|
||||||
--destructive-foreground: 210 40% 98%;
|
--color-popover: 0 0% 100%;
|
||||||
--border: 214.3 31.8% 91.4%;
|
--color-popover-foreground: 222.2 84% 4.9%;
|
||||||
--input: 214.3 31.8% 91.4%;
|
--color-border: 214.3 31.8% 91.4%;
|
||||||
--ring: 221.2 83.2% 53.3%;
|
--color-input: 214.3 31.8% 91.4%;
|
||||||
--radius: 0.5rem;
|
--color-ring: 221.2 83.2% 53.3%;
|
||||||
--chart-1: 221.2 83.2% 53.3%;
|
--color-chart-1: 221.2 83.2% 53.3%;
|
||||||
--chart-2: 160 60% 45%;
|
--color-chart-2: 160 60% 45%;
|
||||||
--chart-3: 30 80% 55%;
|
--color-chart-3: 30 80% 55%;
|
||||||
--chart-4: 280 65% 60%;
|
--color-chart-4: 280 65% 60%;
|
||||||
--chart-5: 340 75% 55%;
|
--color-chart-5: 340 75% 55%;
|
||||||
|
|
||||||
|
/* Typography Tokens */
|
||||||
|
--typography-font-family-base: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
--typography-font-family-code: "JetBrains Mono", "Monaco", "Courier New", monospace;
|
||||||
|
--typography-font-size-xs: 0.75rem;
|
||||||
|
--typography-font-size-sm: 0.875rem;
|
||||||
|
--typography-font-size-base: 1rem;
|
||||||
|
--typography-font-size-lg: 1.125rem;
|
||||||
|
--typography-font-size-xl: 1.25rem;
|
||||||
|
--typography-font-size-2xl: 1.5rem;
|
||||||
|
--typography-font-weight-normal: 400;
|
||||||
|
--typography-font-weight-medium: 500;
|
||||||
|
--typography-font-weight-semibold: 600;
|
||||||
|
--typography-font-weight-bold: 700;
|
||||||
|
--typography-line-height-tight: 1.2;
|
||||||
|
--typography-line-height-normal: 1.5;
|
||||||
|
--typography-line-height-relaxed: 1.75;
|
||||||
|
--typography-letter-spacing-tight: -0.02em;
|
||||||
|
--typography-letter-spacing-normal: 0em;
|
||||||
|
--typography-letter-spacing-wide: 0.02em;
|
||||||
|
|
||||||
|
/* Visual Tokens */
|
||||||
|
--visual-radius-sm: 0.25rem;
|
||||||
|
--visual-radius-md: 0.375rem;
|
||||||
|
--visual-radius-lg: 0.5rem;
|
||||||
|
--visual-radius-xl: 0.75rem;
|
||||||
|
--visual-radius-full: 9999px;
|
||||||
|
--visual-shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||||
|
--visual-shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||||
|
--visual-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||||
|
--visual-shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
|
||||||
|
--visual-blur-sm: 4px;
|
||||||
|
--visual-blur-md: 12px;
|
||||||
|
--visual-blur-lg: 24px;
|
||||||
|
--visual-opacity-disabled: 0.5;
|
||||||
|
--visual-opacity-hover: 0.8;
|
||||||
|
--visual-opacity-overlay: 0.75;
|
||||||
|
|
||||||
|
/* Layout Tokens */
|
||||||
|
--layout-space-unit: 0.25rem;
|
||||||
|
--layout-space-xs: 0.5rem;
|
||||||
|
--layout-space-sm: 0.75rem;
|
||||||
|
--layout-space-md: 1rem;
|
||||||
|
--layout-space-lg: 1.5rem;
|
||||||
|
--layout-space-xl: 2rem;
|
||||||
|
--layout-space-2xl: 3rem;
|
||||||
|
--layout-sidebar-width: 16rem;
|
||||||
|
--layout-header-height: 3.5rem;
|
||||||
|
--layout-max-content-width: 1280px;
|
||||||
|
|
||||||
|
/* Animation Tokens */
|
||||||
|
--animation-anim-duration-fast: 150ms;
|
||||||
|
--animation-anim-duration-normal: 300ms;
|
||||||
|
--animation-anim-duration-slow: 500ms;
|
||||||
|
--animation-anim-easing-default: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
--animation-anim-easing-in: cubic-bezier(0.4, 0, 1, 1);
|
||||||
|
--animation-anim-easing-out: cubic-bezier(0, 0, 0.2, 1);
|
||||||
|
--animation-anim-easing-in-out: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
--animation-transition-colors: color 300ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
--animation-transition-transform: transform 300ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
--animation-transition-opacity: opacity 300ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
|
||||||
|
/* Legacy Aliases (backward compatibility) */
|
||||||
|
--background: var(--color-background);
|
||||||
|
--foreground: var(--color-foreground);
|
||||||
|
--card: var(--color-card);
|
||||||
|
--card-foreground: var(--color-card-foreground);
|
||||||
|
--popover: var(--color-popover);
|
||||||
|
--popover-foreground: var(--color-popover-foreground);
|
||||||
|
--primary: var(--color-primary);
|
||||||
|
--primary-foreground: var(--color-primary-foreground);
|
||||||
|
--primary-gradient: var(--color-primary-gradient);
|
||||||
|
--secondary: var(--color-secondary);
|
||||||
|
--secondary-foreground: var(--color-secondary-foreground);
|
||||||
|
--muted: var(--color-muted);
|
||||||
|
--muted-foreground: var(--color-muted-foreground);
|
||||||
|
--accent: var(--color-accent);
|
||||||
|
--accent-foreground: var(--color-accent-foreground);
|
||||||
|
--destructive: var(--color-destructive);
|
||||||
|
--destructive-foreground: var(--color-destructive-foreground);
|
||||||
|
--border: var(--color-border);
|
||||||
|
--input: var(--color-input);
|
||||||
|
--ring: var(--color-ring);
|
||||||
|
--radius: var(--visual-radius-lg);
|
||||||
|
--chart-1: var(--color-chart-1);
|
||||||
|
--chart-2: var(--color-chart-2);
|
||||||
|
--chart-3: var(--color-chart-3);
|
||||||
|
--chart-4: var(--color-chart-4);
|
||||||
|
--chart-5: var(--color-chart-5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: 222.2 84% 4.9%;
|
/* Color Tokens */
|
||||||
--foreground: 210 40% 98%;
|
--color-primary: 217.2 91.2% 59.8%;
|
||||||
--card: 222.2 84% 4.9%;
|
--color-primary-foreground: 210 40% 98%;
|
||||||
--card-foreground: 210 40% 98%;
|
--color-primary-gradient: none;
|
||||||
--popover: 222.2 84% 4.9%;
|
--color-secondary: 217.2 32.6% 17.5%;
|
||||||
--popover-foreground: 210 40% 98%;
|
--color-secondary-foreground: 210 40% 98%;
|
||||||
--primary: 217.2 91.2% 59.8%;
|
--color-muted: 217.2 32.6% 17.5%;
|
||||||
--primary-foreground: 210 40% 98%;
|
--color-muted-foreground: 215 20.2% 65.1%;
|
||||||
--primary-gradient: none; /* 默认无渐变 */
|
--color-accent: 217.2 32.6% 17.5%;
|
||||||
--secondary: 217.2 32.6% 17.5%;
|
--color-accent-foreground: 210 40% 98%;
|
||||||
--secondary-foreground: 210 40% 98%;
|
--color-destructive: 0 62.8% 30.6%;
|
||||||
--muted: 217.2 32.6% 17.5%;
|
--color-destructive-foreground: 210 40% 98%;
|
||||||
--muted-foreground: 215 20.2% 65.1%;
|
--color-background: 222.2 84% 4.9%;
|
||||||
--accent: 217.2 32.6% 17.5%;
|
--color-foreground: 210 40% 98%;
|
||||||
--accent-foreground: 210 40% 98%;
|
--color-card: 222.2 84% 4.9%;
|
||||||
--destructive: 0 62.8% 30.6%;
|
--color-card-foreground: 210 40% 98%;
|
||||||
--destructive-foreground: 210 40% 98%;
|
--color-popover: 222.2 84% 4.9%;
|
||||||
--border: 217.2 32.6% 17.5%;
|
--color-popover-foreground: 210 40% 98%;
|
||||||
--input: 217.2 32.6% 17.5%;
|
--color-border: 217.2 32.6% 17.5%;
|
||||||
--ring: 224.3 76.3% 48%;
|
--color-input: 217.2 32.6% 17.5%;
|
||||||
--chart-1: 217.2 91.2% 59.8%;
|
--color-ring: 224.3 76.3% 48%;
|
||||||
--chart-2: 160 60% 50%;
|
--color-chart-1: 217.2 91.2% 59.8%;
|
||||||
--chart-3: 30 80% 60%;
|
--color-chart-2: 160 60% 50%;
|
||||||
--chart-4: 280 65% 65%;
|
--color-chart-3: 30 80% 60%;
|
||||||
--chart-5: 340 75% 60%;
|
--color-chart-4: 280 65% 65%;
|
||||||
|
--color-chart-5: 340 75% 60%;
|
||||||
|
|
||||||
|
/* Visual Tokens (dark mode shadows) */
|
||||||
|
--visual-shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.25);
|
||||||
|
--visual-shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.3);
|
||||||
|
--visual-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.4);
|
||||||
|
--visual-shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.5);
|
||||||
|
|
||||||
|
/* Legacy Aliases (backward compatibility) */
|
||||||
|
--background: var(--color-background);
|
||||||
|
--foreground: var(--color-foreground);
|
||||||
|
--card: var(--color-card);
|
||||||
|
--card-foreground: var(--color-card-foreground);
|
||||||
|
--popover: var(--color-popover);
|
||||||
|
--popover-foreground: var(--color-popover-foreground);
|
||||||
|
--primary: var(--color-primary);
|
||||||
|
--primary-foreground: var(--color-primary-foreground);
|
||||||
|
--primary-gradient: var(--color-primary-gradient);
|
||||||
|
--secondary: var(--color-secondary);
|
||||||
|
--secondary-foreground: var(--color-secondary-foreground);
|
||||||
|
--muted: var(--color-muted);
|
||||||
|
--muted-foreground: var(--color-muted-foreground);
|
||||||
|
--accent: var(--color-accent);
|
||||||
|
--accent-foreground: var(--color-accent-foreground);
|
||||||
|
--destructive: var(--color-destructive);
|
||||||
|
--destructive-foreground: var(--color-destructive-foreground);
|
||||||
|
--border: var(--color-border);
|
||||||
|
--input: var(--color-input);
|
||||||
|
--ring: var(--color-ring);
|
||||||
|
--chart-1: var(--color-chart-1);
|
||||||
|
--chart-2: var(--color-chart-2);
|
||||||
|
--chart-3: var(--color-chart-3);
|
||||||
|
--chart-4: var(--color-chart-4);
|
||||||
|
--chart-5: var(--color-chart-5);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -92,28 +215,24 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
/* 渐变色背景工具类 */
|
|
||||||
.bg-primary-gradient {
|
.bg-primary-gradient {
|
||||||
background: var(--primary-gradient, hsl(var(--primary)));
|
background: var(--color-primary-gradient, hsl(var(--color-primary)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 渐变色文字工具类 - 默认使用普通文字颜色 */
|
|
||||||
.text-primary-gradient {
|
.text-primary-gradient {
|
||||||
color: hsl(var(--primary));
|
color: hsl(var(--color-primary));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 当应用了 has-gradient 类时,使用渐变文字效果 */
|
|
||||||
.has-gradient .text-primary-gradient {
|
.has-gradient .text-primary-gradient {
|
||||||
background: var(--primary-gradient);
|
background: var(--color-primary-gradient);
|
||||||
-webkit-background-clip: text;
|
-webkit-background-clip: text;
|
||||||
background-clip: text;
|
background-clip: text;
|
||||||
-webkit-text-fill-color: transparent;
|
-webkit-text-fill-color: transparent;
|
||||||
color: transparent;
|
color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 渐变色边框工具类 */
|
|
||||||
.border-primary-gradient {
|
.border-primary-gradient {
|
||||||
border-image: var(--primary-gradient, linear-gradient(to right, hsl(var(--primary)), hsl(var(--primary)))) 1;
|
border-image: var(--color-primary-gradient, linear-gradient(to right, hsl(var(--color-primary)), hsl(var(--color-primary)))) 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -170,10 +289,9 @@
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 自定义滚动条样式 */
|
|
||||||
.custom-scrollbar {
|
.custom-scrollbar {
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: hsl(var(--border)) transparent;
|
scrollbar-color: hsl(var(--color-border)) transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-scrollbar::-webkit-scrollbar {
|
.custom-scrollbar::-webkit-scrollbar {
|
||||||
|
|
@ -187,14 +305,14 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||||
background: hsl(var(--border));
|
background: hsl(var(--color-border));
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
border: 2px solid transparent;
|
border: 2px solid transparent;
|
||||||
background-clip: content-box;
|
background-clip: content-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
background: hsl(var(--muted-foreground) / 0.5);
|
background: hsl(var(--color-muted-foreground) / 0.5);
|
||||||
background-clip: content-box;
|
background-clip: content-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,253 @@
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
import { FieldHookRegistry } from '../field-hooks'
|
||||||
|
import type { FieldHookComponent } from '../field-hooks'
|
||||||
|
|
||||||
|
describe('FieldHookRegistry', () => {
|
||||||
|
let registry: FieldHookRegistry
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
registry = new FieldHookRegistry()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('register', () => {
|
||||||
|
it('registers a hook with replace type', () => {
|
||||||
|
const component: FieldHookComponent = () => null
|
||||||
|
|
||||||
|
registry.register('test.field', component, 'replace')
|
||||||
|
|
||||||
|
expect(registry.has('test.field')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('registers a hook with wrapper type', () => {
|
||||||
|
const component: FieldHookComponent = () => null
|
||||||
|
|
||||||
|
registry.register('test.field', component, 'wrapper')
|
||||||
|
|
||||||
|
expect(registry.has('test.field')).toBe(true)
|
||||||
|
const entry = registry.get('test.field')
|
||||||
|
expect(entry?.type).toBe('wrapper')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defaults to replace type when not specified', () => {
|
||||||
|
const component: FieldHookComponent = () => null
|
||||||
|
|
||||||
|
registry.register('test.field', component)
|
||||||
|
|
||||||
|
const entry = registry.get('test.field')
|
||||||
|
expect(entry?.type).toBe('replace')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('overwrites existing hook for same field path', () => {
|
||||||
|
const component1: FieldHookComponent = () => null
|
||||||
|
const component2: FieldHookComponent = () => null
|
||||||
|
|
||||||
|
registry.register('test.field', component1, 'replace')
|
||||||
|
registry.register('test.field', component2, 'wrapper')
|
||||||
|
|
||||||
|
const entry = registry.get('test.field')
|
||||||
|
expect(entry?.component).toBe(component2)
|
||||||
|
expect(entry?.type).toBe('wrapper')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('get', () => {
|
||||||
|
it('returns hook entry for registered field path', () => {
|
||||||
|
const component: FieldHookComponent = () => null
|
||||||
|
|
||||||
|
registry.register('test.field', component, 'replace')
|
||||||
|
|
||||||
|
const entry = registry.get('test.field')
|
||||||
|
expect(entry).toBeDefined()
|
||||||
|
expect(entry?.component).toBe(component)
|
||||||
|
expect(entry?.type).toBe('replace')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns undefined for unregistered field path', () => {
|
||||||
|
const entry = registry.get('nonexistent.field')
|
||||||
|
expect(entry).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns correct entry for nested field paths', () => {
|
||||||
|
const component: FieldHookComponent = () => null
|
||||||
|
|
||||||
|
registry.register('config.section.field', component, 'wrapper')
|
||||||
|
|
||||||
|
const entry = registry.get('config.section.field')
|
||||||
|
expect(entry).toBeDefined()
|
||||||
|
expect(entry?.type).toBe('wrapper')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('has', () => {
|
||||||
|
it('returns true for registered field path', () => {
|
||||||
|
const component: FieldHookComponent = () => null
|
||||||
|
|
||||||
|
registry.register('test.field', component)
|
||||||
|
|
||||||
|
expect(registry.has('test.field')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false for unregistered field path', () => {
|
||||||
|
expect(registry.has('nonexistent.field')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false after unregistering', () => {
|
||||||
|
const component: FieldHookComponent = () => null
|
||||||
|
|
||||||
|
registry.register('test.field', component)
|
||||||
|
registry.unregister('test.field')
|
||||||
|
|
||||||
|
expect(registry.has('test.field')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('unregister', () => {
|
||||||
|
it('removes a registered hook', () => {
|
||||||
|
const component: FieldHookComponent = () => null
|
||||||
|
|
||||||
|
registry.register('test.field', component)
|
||||||
|
expect(registry.has('test.field')).toBe(true)
|
||||||
|
|
||||||
|
registry.unregister('test.field')
|
||||||
|
expect(registry.has('test.field')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not throw when unregistering non-existent hook', () => {
|
||||||
|
expect(() => registry.unregister('nonexistent.field')).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('only removes specified hook, not others', () => {
|
||||||
|
const component1: FieldHookComponent = () => null
|
||||||
|
const component2: FieldHookComponent = () => null
|
||||||
|
|
||||||
|
registry.register('field1', component1)
|
||||||
|
registry.register('field2', component2)
|
||||||
|
|
||||||
|
registry.unregister('field1')
|
||||||
|
|
||||||
|
expect(registry.has('field1')).toBe(false)
|
||||||
|
expect(registry.has('field2')).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('clear', () => {
|
||||||
|
it('removes all registered hooks', () => {
|
||||||
|
const component1: FieldHookComponent = () => null
|
||||||
|
const component2: FieldHookComponent = () => null
|
||||||
|
const component3: FieldHookComponent = () => null
|
||||||
|
|
||||||
|
registry.register('field1', component1)
|
||||||
|
registry.register('field2', component2)
|
||||||
|
registry.register('field3', component3)
|
||||||
|
|
||||||
|
expect(registry.getAllPaths()).toHaveLength(3)
|
||||||
|
|
||||||
|
registry.clear()
|
||||||
|
|
||||||
|
expect(registry.getAllPaths()).toHaveLength(0)
|
||||||
|
expect(registry.has('field1')).toBe(false)
|
||||||
|
expect(registry.has('field2')).toBe(false)
|
||||||
|
expect(registry.has('field3')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('works correctly on empty registry', () => {
|
||||||
|
expect(() => registry.clear()).not.toThrow()
|
||||||
|
expect(registry.getAllPaths()).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getAllPaths', () => {
|
||||||
|
it('returns empty array when no hooks registered', () => {
|
||||||
|
expect(registry.getAllPaths()).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns all registered field paths', () => {
|
||||||
|
const component: FieldHookComponent = () => null
|
||||||
|
|
||||||
|
registry.register('field1', component)
|
||||||
|
registry.register('field2', component)
|
||||||
|
registry.register('field3', component)
|
||||||
|
|
||||||
|
const paths = registry.getAllPaths()
|
||||||
|
expect(paths).toHaveLength(3)
|
||||||
|
expect(paths).toContain('field1')
|
||||||
|
expect(paths).toContain('field2')
|
||||||
|
expect(paths).toContain('field3')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns updated paths after unregister', () => {
|
||||||
|
const component: FieldHookComponent = () => null
|
||||||
|
|
||||||
|
registry.register('field1', component)
|
||||||
|
registry.register('field2', component)
|
||||||
|
registry.register('field3', component)
|
||||||
|
|
||||||
|
registry.unregister('field2')
|
||||||
|
|
||||||
|
const paths = registry.getAllPaths()
|
||||||
|
expect(paths).toHaveLength(2)
|
||||||
|
expect(paths).toContain('field1')
|
||||||
|
expect(paths).toContain('field3')
|
||||||
|
expect(paths).not.toContain('field2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles nested field paths correctly', () => {
|
||||||
|
const component: FieldHookComponent = () => null
|
||||||
|
|
||||||
|
registry.register('config.chat.enabled', component)
|
||||||
|
registry.register('config.chat.model', component)
|
||||||
|
registry.register('config.api.key', component)
|
||||||
|
|
||||||
|
const paths = registry.getAllPaths()
|
||||||
|
expect(paths).toHaveLength(3)
|
||||||
|
expect(paths).toContain('config.chat.enabled')
|
||||||
|
expect(paths).toContain('config.chat.model')
|
||||||
|
expect(paths).toContain('config.api.key')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('integration scenarios', () => {
|
||||||
|
it('supports full lifecycle of multiple hooks', () => {
|
||||||
|
const replaceComponent: FieldHookComponent = () => null
|
||||||
|
const wrapperComponent: FieldHookComponent = () => null
|
||||||
|
|
||||||
|
registry.register('field1', replaceComponent, 'replace')
|
||||||
|
registry.register('field2', wrapperComponent, 'wrapper')
|
||||||
|
|
||||||
|
expect(registry.getAllPaths()).toHaveLength(2)
|
||||||
|
|
||||||
|
const entry1 = registry.get('field1')
|
||||||
|
expect(entry1?.type).toBe('replace')
|
||||||
|
expect(entry1?.component).toBe(replaceComponent)
|
||||||
|
|
||||||
|
const entry2 = registry.get('field2')
|
||||||
|
expect(entry2?.type).toBe('wrapper')
|
||||||
|
expect(entry2?.component).toBe(wrapperComponent)
|
||||||
|
|
||||||
|
registry.unregister('field1')
|
||||||
|
expect(registry.getAllPaths()).toHaveLength(1)
|
||||||
|
expect(registry.has('field2')).toBe(true)
|
||||||
|
|
||||||
|
registry.clear()
|
||||||
|
expect(registry.getAllPaths()).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles rapid register/unregister cycles', () => {
|
||||||
|
const component: FieldHookComponent = () => null
|
||||||
|
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
registry.register(`field${i}`, component)
|
||||||
|
}
|
||||||
|
expect(registry.getAllPaths()).toHaveLength(100)
|
||||||
|
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
registry.unregister(`field${i}`)
|
||||||
|
}
|
||||||
|
expect(registry.getAllPaths()).toHaveLength(50)
|
||||||
|
|
||||||
|
registry.clear()
|
||||||
|
expect(registry.getAllPaths()).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
/**
|
||||||
|
* 适配器配置API客户端
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { fetchWithAuth, getAuthHeaders } from '@/lib/fetch-with-auth'
|
||||||
|
|
||||||
|
const API_BASE = '/api/webui/config'
|
||||||
|
|
||||||
|
export interface AdapterConfigPath {
|
||||||
|
path: string
|
||||||
|
lastModified?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfigPathResponse {
|
||||||
|
success: boolean
|
||||||
|
path?: string
|
||||||
|
lastModified?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfigContentResponse {
|
||||||
|
success: boolean
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfigMessageResponse {
|
||||||
|
success: boolean
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取保存的适配器配置文件路径
|
||||||
|
*/
|
||||||
|
export async function getSavedConfigPath(): Promise<AdapterConfigPath | null> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/adapter-config/path`)
|
||||||
|
const data: ConfigPathResponse = await response.json()
|
||||||
|
|
||||||
|
if (!data.success || !data.path) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
path: data.path,
|
||||||
|
lastModified: data.lastModified,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存适配器配置文件路径偏好设置
|
||||||
|
*/
|
||||||
|
export async function saveConfigPath(path: string): Promise<void> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/adapter-config/path`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify({ path }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const data: ConfigMessageResponse = await response.json()
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.message || '保存路径失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从指定路径读取适配器配置文件
|
||||||
|
*/
|
||||||
|
export async function loadConfigFromPath(path: string): Promise<string> {
|
||||||
|
const response = await fetchWithAuth(
|
||||||
|
`${API_BASE}/adapter-config?path=${encodeURIComponent(path)}`
|
||||||
|
)
|
||||||
|
const data: ConfigContentResponse = await response.json()
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error('读取配置文件失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.content
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存适配器配置到指定路径
|
||||||
|
*/
|
||||||
|
export async function saveConfigToPath(path: string, content: string): Promise<void> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/adapter-config`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify({ path, content }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const data: ConfigMessageResponse = await response.json()
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.message || '保存配置失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { createContext } from 'react'
|
||||||
|
|
||||||
|
export type AnimationSettings = {
|
||||||
|
enableAnimations: boolean
|
||||||
|
enableWavesBackground: boolean
|
||||||
|
setEnableAnimations: (enable: boolean) => void
|
||||||
|
setEnableWavesBackground: (enable: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AnimationContext = createContext<AnimationSettings | undefined>(undefined)
|
||||||
|
|
@ -0,0 +1,136 @@
|
||||||
|
import { fetchWithAuth } from './fetch-with-auth'
|
||||||
|
|
||||||
|
export interface TimeFootprintData {
|
||||||
|
total_online_hours: number
|
||||||
|
first_message_time: string | null
|
||||||
|
first_message_user: string | null
|
||||||
|
first_message_content: string | null
|
||||||
|
busiest_day: string | null
|
||||||
|
busiest_day_count: number
|
||||||
|
hourly_distribution: number[]
|
||||||
|
midnight_chat_count: number
|
||||||
|
is_night_owl: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SocialNetworkData {
|
||||||
|
total_groups: number
|
||||||
|
top_groups: Array<{
|
||||||
|
group_id: string
|
||||||
|
group_name: string
|
||||||
|
message_count: number
|
||||||
|
is_webui?: boolean
|
||||||
|
}>
|
||||||
|
top_users: Array<{
|
||||||
|
user_id: string
|
||||||
|
user_nickname: string
|
||||||
|
message_count: number
|
||||||
|
is_webui?: boolean
|
||||||
|
}>
|
||||||
|
at_count: number
|
||||||
|
mentioned_count: number
|
||||||
|
longest_companion_user: string | null
|
||||||
|
longest_companion_days: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrainPowerData {
|
||||||
|
total_tokens: number
|
||||||
|
total_cost: number
|
||||||
|
favorite_model: string | null
|
||||||
|
favorite_model_count: number
|
||||||
|
model_distribution: Array<{
|
||||||
|
model: string
|
||||||
|
count: number
|
||||||
|
tokens: number
|
||||||
|
cost: number
|
||||||
|
}>
|
||||||
|
top_reply_models: Array<{
|
||||||
|
model: string
|
||||||
|
count: number
|
||||||
|
}>
|
||||||
|
most_expensive_cost: number
|
||||||
|
most_expensive_time: string | null
|
||||||
|
top_token_consumers: Array<{
|
||||||
|
user_id: string
|
||||||
|
cost: number
|
||||||
|
tokens: number
|
||||||
|
}>
|
||||||
|
silence_rate: number
|
||||||
|
total_actions: number
|
||||||
|
no_reply_count: number
|
||||||
|
avg_interest_value: number
|
||||||
|
max_interest_value: number
|
||||||
|
max_interest_time: string | null
|
||||||
|
avg_reasoning_length: number
|
||||||
|
max_reasoning_length: number
|
||||||
|
max_reasoning_time: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExpressionVibeData {
|
||||||
|
top_emoji: {
|
||||||
|
id: number
|
||||||
|
path: string
|
||||||
|
description: string
|
||||||
|
usage_count: number
|
||||||
|
hash: string
|
||||||
|
} | null
|
||||||
|
top_emojis: Array<{
|
||||||
|
id: number
|
||||||
|
path: string
|
||||||
|
description: string
|
||||||
|
usage_count: number
|
||||||
|
hash: string
|
||||||
|
}>
|
||||||
|
top_expressions: Array<{
|
||||||
|
style: string
|
||||||
|
count: number
|
||||||
|
}>
|
||||||
|
rejected_expression_count: number
|
||||||
|
checked_expression_count: number
|
||||||
|
total_expressions: number
|
||||||
|
action_types: Array<{
|
||||||
|
action: string
|
||||||
|
count: number
|
||||||
|
}>
|
||||||
|
image_processed_count: number
|
||||||
|
late_night_reply: {
|
||||||
|
time: string
|
||||||
|
content: string
|
||||||
|
} | null
|
||||||
|
favorite_reply: {
|
||||||
|
content: string
|
||||||
|
count: number
|
||||||
|
} | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AchievementData {
|
||||||
|
new_jargon_count: number
|
||||||
|
sample_jargons: Array<{
|
||||||
|
content: string
|
||||||
|
meaning: string
|
||||||
|
count: number
|
||||||
|
}>
|
||||||
|
total_messages: number
|
||||||
|
total_replies: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnnualReportData {
|
||||||
|
year: number
|
||||||
|
bot_name: string
|
||||||
|
generated_at: string
|
||||||
|
time_footprint: TimeFootprintData
|
||||||
|
social_network: SocialNetworkData
|
||||||
|
brain_power: BrainPowerData
|
||||||
|
expression_vibe: ExpressionVibeData
|
||||||
|
achievements: AchievementData
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAnnualReport(year: number = 2025): Promise<AnnualReportData> {
|
||||||
|
const response = await fetchWithAuth(`/api/webui/annual-report/full?year=${year}`)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '获取年度报告失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
const apiClient = axios.create({
|
||||||
|
baseURL: import.meta.env.DEV ? 'http://localhost:8000' : '',
|
||||||
|
timeout: 10000,
|
||||||
|
})
|
||||||
|
|
||||||
|
export default apiClient
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
/**
|
||||||
|
* IndexedDB 资源存储模块
|
||||||
|
* 使用 idb 库封装所有 IndexedDB 操作,用于存储图片和视频资源
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { openDB, type IDBPDatabase } from 'idb'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 资源记录的类型定义
|
||||||
|
*/
|
||||||
|
export type AssetRecord = {
|
||||||
|
/** 资源唯一标识符 (UUID v4) */
|
||||||
|
id: string
|
||||||
|
/** 文件名 */
|
||||||
|
filename: string
|
||||||
|
/** 资源类型 */
|
||||||
|
type: 'image' | 'video'
|
||||||
|
/** MIME 类型 */
|
||||||
|
mimeType: string
|
||||||
|
/** 文件内容 */
|
||||||
|
blob: Blob
|
||||||
|
/** 文件大小(字节) */
|
||||||
|
size: number
|
||||||
|
/** 创建时间戳 */
|
||||||
|
createdAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// 常量定义
|
||||||
|
const DB_NAME = 'maibot-assets'
|
||||||
|
const STORE_NAME = 'assets'
|
||||||
|
const DB_VERSION = 1
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打开或创建资源数据库
|
||||||
|
* 初始化 IndexedDB 数据库,如需要则创建 object store
|
||||||
|
*
|
||||||
|
* @returns 打开的数据库实例
|
||||||
|
*/
|
||||||
|
export async function openAssetDB(): Promise<IDBPDatabase<unknown>> {
|
||||||
|
return openDB(DB_NAME, DB_VERSION, {
|
||||||
|
upgrade(db) {
|
||||||
|
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||||
|
db.createObjectStore(STORE_NAME, { keyPath: 'id' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 存储文件到 IndexedDB
|
||||||
|
* 根据文件 MIME 类型自动判断资源类型,使用 UUID v4 作为资源 ID
|
||||||
|
*
|
||||||
|
* @param file - 要存储的文件
|
||||||
|
* @returns 生成的资源 ID (UUID v4)
|
||||||
|
*/
|
||||||
|
export async function addAsset(file: File): Promise<string> {
|
||||||
|
const db = await openAssetDB()
|
||||||
|
const id = crypto.randomUUID()
|
||||||
|
|
||||||
|
// 根据 file.type 判断资源类型
|
||||||
|
const type: 'image' | 'video' = file.type.startsWith('video/') ? 'video' : 'image'
|
||||||
|
|
||||||
|
const asset: AssetRecord = {
|
||||||
|
id,
|
||||||
|
filename: file.name,
|
||||||
|
type,
|
||||||
|
mimeType: file.type,
|
||||||
|
blob: file,
|
||||||
|
size: file.size,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.add(STORE_NAME, asset)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指定 ID 的资源记录
|
||||||
|
* 如果资源不存在,返回 undefined
|
||||||
|
*
|
||||||
|
* @param id - 资源 ID
|
||||||
|
* @returns 资源记录或 undefined
|
||||||
|
*/
|
||||||
|
export async function getAsset(id: string): Promise<AssetRecord | undefined> {
|
||||||
|
const db = await openAssetDB()
|
||||||
|
return (await db.get(STORE_NAME, id)) as AssetRecord | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除指定 ID 的资源
|
||||||
|
* 如果资源不存在,该操作不会抛出错误
|
||||||
|
*
|
||||||
|
* @param id - 资源 ID
|
||||||
|
*/
|
||||||
|
export async function deleteAsset(id: string): Promise<void> {
|
||||||
|
const db = await openAssetDB()
|
||||||
|
await db.delete(STORE_NAME, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有资源记录列表
|
||||||
|
* 返回按创建时间倒序排列的资源列表
|
||||||
|
*
|
||||||
|
* @returns 资源记录数组
|
||||||
|
*/
|
||||||
|
export async function listAssets(): Promise<AssetRecord[]> {
|
||||||
|
const db = await openAssetDB()
|
||||||
|
const assets = (await db.getAll(STORE_NAME)) as AssetRecord[]
|
||||||
|
// 按创建时间倒序排列(最新的在前)
|
||||||
|
return assets.sort((a, b) => b.createdAt - a.createdAt)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,269 @@
|
||||||
|
/**
|
||||||
|
* 配置API客户端
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { fetchWithAuth } from '@/lib/fetch-with-auth'
|
||||||
|
import type {
|
||||||
|
ConfigSchema,
|
||||||
|
ConfigSchemaResponse,
|
||||||
|
ConfigDataResponse,
|
||||||
|
ConfigUpdateResponse,
|
||||||
|
} from '@/types/config-schema'
|
||||||
|
|
||||||
|
const API_BASE = '/api/webui/config'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取麦麦主程序配置架构
|
||||||
|
*/
|
||||||
|
export async function getBotConfigSchema(): Promise<ConfigSchema> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/schema/bot`)
|
||||||
|
const data: ConfigSchemaResponse = await response.json()
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error('获取配置架构失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.schema
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取模型配置架构
|
||||||
|
*/
|
||||||
|
export async function getModelConfigSchema(): Promise<ConfigSchema> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/schema/model`)
|
||||||
|
const data: ConfigSchemaResponse = await response.json()
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error('获取模型配置架构失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.schema
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指定配置节的架构
|
||||||
|
*/
|
||||||
|
export async function getConfigSectionSchema(sectionName: string): Promise<ConfigSchema> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/schema/section/${sectionName}`)
|
||||||
|
const data: ConfigSchemaResponse = await response.json()
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(`获取配置节 ${sectionName} 架构失败`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.schema
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取麦麦主程序配置数据
|
||||||
|
*/
|
||||||
|
export async function getBotConfig(): Promise<Record<string, unknown>> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/bot`)
|
||||||
|
const data: ConfigDataResponse = await response.json()
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error('获取配置数据失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.config
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取模型配置数据
|
||||||
|
*/
|
||||||
|
export async function getModelConfig(): Promise<Record<string, unknown>> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/model`)
|
||||||
|
const data: ConfigDataResponse = await response.json()
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error('获取模型配置数据失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.config
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新麦麦主程序配置
|
||||||
|
*/
|
||||||
|
export async function updateBotConfig(config: Record<string, unknown>): Promise<void> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/bot`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(config),
|
||||||
|
})
|
||||||
|
|
||||||
|
const data: ConfigUpdateResponse = await response.json()
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.message || '保存配置失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取麦麦主程序配置的原始 TOML 内容
|
||||||
|
*/
|
||||||
|
export async function getBotConfigRaw(): Promise<string> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/bot/raw`)
|
||||||
|
const data: { success: boolean; content: string } = await response.json()
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error('获取配置源代码失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.content
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新麦麦主程序配置(原始 TOML 内容)
|
||||||
|
*/
|
||||||
|
export async function updateBotConfigRaw(rawContent: string): Promise<void> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/bot/raw`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ raw_content: rawContent }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const data: ConfigUpdateResponse = await response.json()
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.message || '保存配置失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新模型配置
|
||||||
|
*/
|
||||||
|
export async function updateModelConfig(config: Record<string, unknown>): Promise<void> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/model`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(config),
|
||||||
|
})
|
||||||
|
|
||||||
|
const data: ConfigUpdateResponse = await response.json()
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.message || '保存配置失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新麦麦主程序配置的指定节
|
||||||
|
*/
|
||||||
|
export async function updateBotConfigSection(
|
||||||
|
sectionName: string,
|
||||||
|
sectionData: unknown
|
||||||
|
): Promise<void> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/bot/section/${sectionName}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(sectionData),
|
||||||
|
})
|
||||||
|
|
||||||
|
const data: ConfigUpdateResponse = await response.json()
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.message || `保存配置节 ${sectionName} 失败`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新模型配置的指定节
|
||||||
|
*/
|
||||||
|
export async function updateModelConfigSection(
|
||||||
|
sectionName: string,
|
||||||
|
sectionData: unknown
|
||||||
|
): Promise<void> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/model/section/${sectionName}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(sectionData),
|
||||||
|
})
|
||||||
|
|
||||||
|
const data: ConfigUpdateResponse = await response.json()
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.message || `保存配置节 ${sectionName} 失败`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模型信息
|
||||||
|
*/
|
||||||
|
export interface ModelListItem {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
owned_by?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取模型列表响应
|
||||||
|
*/
|
||||||
|
export interface FetchModelsResponse {
|
||||||
|
success: boolean
|
||||||
|
models: ModelListItem[]
|
||||||
|
provider?: string
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指定提供商的可用模型列表
|
||||||
|
* @param providerName 提供商名称(在 model_config.toml 中配置的名称)
|
||||||
|
* @param parser 响应解析器类型 ('openai' | 'gemini')
|
||||||
|
* @param endpoint 获取模型列表的端点(默认 '/models')
|
||||||
|
*/
|
||||||
|
export async function fetchProviderModels(
|
||||||
|
providerName: string,
|
||||||
|
parser: 'openai' | 'gemini' = 'openai',
|
||||||
|
endpoint: string = '/models'
|
||||||
|
): Promise<ModelListItem[]> {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
provider_name: providerName,
|
||||||
|
parser,
|
||||||
|
endpoint,
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await fetchWithAuth(`/api/webui/models/list?${params}`)
|
||||||
|
|
||||||
|
// 处理非 2xx 响应
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}))
|
||||||
|
throw new Error(errorData.detail || `获取模型列表失败 (${response.status})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: FetchModelsResponse = await response.json()
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error('获取模型列表失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.models
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试提供商连接结果
|
||||||
|
*/
|
||||||
|
export interface TestConnectionResult {
|
||||||
|
network_ok: boolean
|
||||||
|
api_key_valid: boolean | null
|
||||||
|
latency_ms: number | null
|
||||||
|
error: string | null
|
||||||
|
http_status: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试提供商连接状态(通过提供商名称)
|
||||||
|
* @param providerName 提供商名称
|
||||||
|
*/
|
||||||
|
export async function testProviderConnection(providerName: string): Promise<TestConnectionResult> {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
provider_name: providerName,
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await fetchWithAuth(`/api/webui/models/test-connection-by-name?${params}`, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
|
||||||
|
// 处理非 2xx 响应
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}))
|
||||||
|
throw new Error(errorData.detail || `测试连接失败 (${response.status})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,284 @@
|
||||||
|
/**
|
||||||
|
* 表情包管理 API 客户端
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { fetchWithAuth } from '@/lib/fetch-with-auth'
|
||||||
|
import type {
|
||||||
|
EmojiListResponse,
|
||||||
|
EmojiDetailResponse,
|
||||||
|
EmojiUpdateRequest,
|
||||||
|
EmojiUpdateResponse,
|
||||||
|
EmojiDeleteResponse,
|
||||||
|
EmojiStatsResponse,
|
||||||
|
} from '@/types/emoji'
|
||||||
|
|
||||||
|
const API_BASE = '/api/webui/emoji'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取表情包列表
|
||||||
|
*/
|
||||||
|
export async function getEmojiList(params: {
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
search?: string
|
||||||
|
is_registered?: boolean
|
||||||
|
is_banned?: boolean
|
||||||
|
format?: string
|
||||||
|
sort_by?: string
|
||||||
|
sort_order?: 'asc' | 'desc'
|
||||||
|
}): Promise<EmojiListResponse> {
|
||||||
|
const query = new URLSearchParams()
|
||||||
|
if (params.page) query.append('page', params.page.toString())
|
||||||
|
if (params.page_size) query.append('page_size', params.page_size.toString())
|
||||||
|
if (params.search) query.append('search', params.search)
|
||||||
|
if (params.is_registered !== undefined) query.append('is_registered', params.is_registered.toString())
|
||||||
|
if (params.is_banned !== undefined) query.append('is_banned', params.is_banned.toString())
|
||||||
|
if (params.format) query.append('format', params.format)
|
||||||
|
if (params.sort_by) query.append('sort_by', params.sort_by)
|
||||||
|
if (params.sort_order) query.append('sort_order', params.sort_order)
|
||||||
|
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/list?${query}`, {
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`获取表情包列表失败: ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取表情包详情
|
||||||
|
*/
|
||||||
|
export async function getEmojiDetail(id: number): Promise<EmojiDetailResponse> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/${id}`, {
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`获取表情包详情失败: ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新表情包信息
|
||||||
|
*/
|
||||||
|
export async function updateEmoji(
|
||||||
|
id: number,
|
||||||
|
data: EmojiUpdateRequest
|
||||||
|
): Promise<EmojiUpdateResponse> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`更新表情包失败: ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除表情包
|
||||||
|
*/
|
||||||
|
export async function deleteEmoji(id: number): Promise<EmojiDeleteResponse> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`删除表情包失败: ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取表情包统计数据
|
||||||
|
*/
|
||||||
|
export async function getEmojiStats(): Promise<EmojiStatsResponse> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/stats/summary`, {
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`获取统计数据失败: ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册表情包
|
||||||
|
*/
|
||||||
|
export async function registerEmoji(id: number): Promise<EmojiUpdateResponse> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/${id}/register`, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`注册表情包失败: ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 封禁表情包
|
||||||
|
*/
|
||||||
|
export async function banEmoji(id: number): Promise<EmojiUpdateResponse> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/${id}/ban`, {
|
||||||
|
method: 'POST',
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`封禁表情包失败: ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取表情包缩略图 URL
|
||||||
|
* 注意:使用 HttpOnly Cookie 进行认证,浏览器会自动携带
|
||||||
|
* @param id 表情包 ID
|
||||||
|
* @param original 是否获取原图(默认返回压缩后的缩略图)
|
||||||
|
*/
|
||||||
|
export function getEmojiThumbnailUrl(id: number, original: boolean = false): string {
|
||||||
|
if (original) {
|
||||||
|
return `${API_BASE}/${id}/thumbnail?original=true`
|
||||||
|
}
|
||||||
|
return `${API_BASE}/${id}/thumbnail`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取表情包原图 URL
|
||||||
|
*/
|
||||||
|
export function getEmojiOriginalUrl(id: number): string {
|
||||||
|
return `${API_BASE}/${id}/thumbnail?original=true`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量删除表情包
|
||||||
|
*/
|
||||||
|
export async function batchDeleteEmojis(emojiIds: number[]): Promise<{
|
||||||
|
success: boolean
|
||||||
|
message: string
|
||||||
|
deleted_count: number
|
||||||
|
failed_count: number
|
||||||
|
failed_ids: number[]
|
||||||
|
}> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/batch/delete`, {
|
||||||
|
method: 'POST',
|
||||||
|
|
||||||
|
body: JSON.stringify({ emoji_ids: emojiIds }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '批量删除失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取表情包上传 URL(供 Uppy 使用)
|
||||||
|
*/
|
||||||
|
export function getEmojiUploadUrl(): string {
|
||||||
|
return `${API_BASE}/upload`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取批量上传 URL
|
||||||
|
*/
|
||||||
|
export function getEmojiBatchUploadUrl(): string {
|
||||||
|
return `${API_BASE}/batch/upload`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 缩略图缓存管理 API ====================
|
||||||
|
|
||||||
|
export interface ThumbnailCacheStatsResponse {
|
||||||
|
success: boolean
|
||||||
|
cache_dir: string
|
||||||
|
total_count: number
|
||||||
|
total_size_mb: number
|
||||||
|
emoji_count: number
|
||||||
|
coverage_percent: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThumbnailCleanupResponse {
|
||||||
|
success: boolean
|
||||||
|
message: string
|
||||||
|
cleaned_count: number
|
||||||
|
kept_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThumbnailPreheatResponse {
|
||||||
|
success: boolean
|
||||||
|
message: string
|
||||||
|
generated_count: number
|
||||||
|
skipped_count: number
|
||||||
|
failed_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取缩略图缓存统计信息
|
||||||
|
*/
|
||||||
|
export async function getThumbnailCacheStats(): Promise<ThumbnailCacheStatsResponse> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/thumbnail-cache/stats`, {})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`获取缩略图缓存统计失败: ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理孤立的缩略图缓存
|
||||||
|
*/
|
||||||
|
export async function cleanupThumbnailCache(): Promise<ThumbnailCleanupResponse> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/thumbnail-cache/cleanup`, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`清理缩略图缓存失败: ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预热缩略图缓存
|
||||||
|
* @param limit 最多预热数量 (1-1000)
|
||||||
|
*/
|
||||||
|
export async function preheatThumbnailCache(limit: number = 100): Promise<ThumbnailPreheatResponse> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/thumbnail-cache/preheat?limit=${limit}`, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`预热缩略图缓存失败: ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清空所有缩略图缓存
|
||||||
|
*/
|
||||||
|
export async function clearAllThumbnailCache(): Promise<ThumbnailCleanupResponse> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/thumbnail-cache/clear`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`清空缩略图缓存失败: ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,236 @@
|
||||||
|
/**
|
||||||
|
* 表达方式管理 API
|
||||||
|
*/
|
||||||
|
import { fetchWithAuth } from '@/lib/fetch-with-auth'
|
||||||
|
import type {
|
||||||
|
ExpressionListResponse,
|
||||||
|
ExpressionDetailResponse,
|
||||||
|
ExpressionCreateRequest,
|
||||||
|
ExpressionCreateResponse,
|
||||||
|
ExpressionUpdateRequest,
|
||||||
|
ExpressionUpdateResponse,
|
||||||
|
ExpressionDeleteResponse,
|
||||||
|
ExpressionStatsResponse,
|
||||||
|
ChatListResponse,
|
||||||
|
ReviewStats,
|
||||||
|
ReviewListResponse,
|
||||||
|
BatchReviewItem,
|
||||||
|
BatchReviewResponse,
|
||||||
|
} from '@/types/expression'
|
||||||
|
|
||||||
|
const API_BASE = '/api/webui/expression'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取聊天列表
|
||||||
|
*/
|
||||||
|
export async function getChatList(): Promise<ChatListResponse> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/chats`, {
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '获取聊天列表失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取表达方式列表
|
||||||
|
*/
|
||||||
|
export async function getExpressionList(params: {
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
search?: string
|
||||||
|
chat_id?: string
|
||||||
|
}): Promise<ExpressionListResponse> {
|
||||||
|
const queryParams = new URLSearchParams()
|
||||||
|
|
||||||
|
if (params.page) queryParams.append('page', params.page.toString())
|
||||||
|
if (params.page_size) queryParams.append('page_size', params.page_size.toString())
|
||||||
|
if (params.search) queryParams.append('search', params.search)
|
||||||
|
if (params.chat_id) queryParams.append('chat_id', params.chat_id)
|
||||||
|
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/list?${queryParams}`, {
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '获取表达方式列表失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取表达方式详细信息
|
||||||
|
*/
|
||||||
|
export async function getExpressionDetail(expressionId: number): Promise<ExpressionDetailResponse> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/${expressionId}`, {
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '获取表达方式详情失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建表达方式
|
||||||
|
*/
|
||||||
|
export async function createExpression(
|
||||||
|
data: ExpressionCreateRequest
|
||||||
|
): Promise<ExpressionCreateResponse> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/`, {
|
||||||
|
method: 'POST',
|
||||||
|
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '创建表达方式失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新表达方式(增量更新)
|
||||||
|
*/
|
||||||
|
export async function updateExpression(
|
||||||
|
expressionId: number,
|
||||||
|
data: ExpressionUpdateRequest
|
||||||
|
): Promise<ExpressionUpdateResponse> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/${expressionId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '更新表达方式失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除表达方式
|
||||||
|
*/
|
||||||
|
export async function deleteExpression(expressionId: number): Promise<ExpressionDeleteResponse> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/${expressionId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '删除表达方式失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量删除表达方式
|
||||||
|
*/
|
||||||
|
export async function batchDeleteExpressions(expressionIds: number[]): Promise<ExpressionDeleteResponse> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/batch/delete`, {
|
||||||
|
method: 'POST',
|
||||||
|
|
||||||
|
body: JSON.stringify({ ids: expressionIds }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '批量删除表达方式失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取表达方式统计数据
|
||||||
|
*/
|
||||||
|
export async function getExpressionStats(): Promise<ExpressionStatsResponse> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/stats/summary`, {
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '获取统计数据失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 审核相关 API ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取审核统计数据
|
||||||
|
*/
|
||||||
|
export async function getReviewStats(): Promise<ReviewStats> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/review/stats`)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '获取审核统计失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取审核列表
|
||||||
|
*/
|
||||||
|
export async function getReviewList(params: {
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
filter_type?: 'unchecked' | 'passed' | 'rejected' | 'all'
|
||||||
|
search?: string
|
||||||
|
chat_id?: string
|
||||||
|
}): Promise<ReviewListResponse> {
|
||||||
|
const queryParams = new URLSearchParams()
|
||||||
|
|
||||||
|
if (params.page) queryParams.append('page', params.page.toString())
|
||||||
|
if (params.page_size) queryParams.append('page_size', params.page_size.toString())
|
||||||
|
if (params.filter_type) queryParams.append('filter_type', params.filter_type)
|
||||||
|
if (params.search) queryParams.append('search', params.search)
|
||||||
|
if (params.chat_id) queryParams.append('chat_id', params.chat_id)
|
||||||
|
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/review/list?${queryParams}`)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '获取审核列表失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量审核表达方式
|
||||||
|
*/
|
||||||
|
export async function batchReviewExpressions(
|
||||||
|
items: BatchReviewItem[]
|
||||||
|
): Promise<BatchReviewResponse> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/review/batch`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ items }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '批量审核失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
// 带自动认证处理的 fetch 封装
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 增强的 fetch 函数,自动处理 401 错误并跳转到登录页
|
||||||
|
* 使用 HttpOnly Cookie 进行认证,自动携带 credentials
|
||||||
|
*
|
||||||
|
* 对于 FormData 请求,不自动设置 Content-Type,让浏览器自动设置 multipart/form-data
|
||||||
|
*/
|
||||||
|
export async function fetchWithAuth(
|
||||||
|
input: RequestInfo | URL,
|
||||||
|
init?: RequestInit
|
||||||
|
): Promise<Response> {
|
||||||
|
// 检查是否是 FormData 请求
|
||||||
|
const isFormData = init?.body instanceof FormData
|
||||||
|
|
||||||
|
// 构建 headers,对于 FormData 不设置 Content-Type
|
||||||
|
const headers: HeadersInit = isFormData
|
||||||
|
? { ...init?.headers }
|
||||||
|
: { 'Content-Type': 'application/json', ...init?.headers }
|
||||||
|
|
||||||
|
// 合并默认配置,确保携带 Cookie
|
||||||
|
const config: RequestInit = {
|
||||||
|
...init,
|
||||||
|
credentials: 'include', // 确保携带 Cookie
|
||||||
|
headers,
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(input, config)
|
||||||
|
|
||||||
|
// 检测 401 未授权错误
|
||||||
|
if (response.status === 401) {
|
||||||
|
// 跳转到登录页
|
||||||
|
window.location.href = '/auth'
|
||||||
|
|
||||||
|
// 抛出错误以便调用者可以处理
|
||||||
|
throw new Error('认证失败,请重新登录')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取带认证的请求配置
|
||||||
|
* 现在使用 Cookie 认证,不再需要手动设置 Authorization header
|
||||||
|
*/
|
||||||
|
export function getAuthHeaders(): HeadersInit {
|
||||||
|
return {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调用登出接口并跳转到登录页
|
||||||
|
*/
|
||||||
|
export async function logout(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await fetch('/api/webui/auth/logout', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('登出请求失败:', error)
|
||||||
|
}
|
||||||
|
// 无论成功与否都跳转到登录页
|
||||||
|
window.location.href = '/auth'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查当前认证状态
|
||||||
|
*/
|
||||||
|
export async function checkAuthStatus(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/webui/auth/check', {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
const data = await response.json()
|
||||||
|
return data.authenticated === true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook type for field-level customization
|
||||||
|
*/
|
||||||
|
export type FieldHookType = 'replace' | 'wrapper'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props passed to a FieldHookComponent
|
||||||
|
*/
|
||||||
|
export interface FieldHookComponentProps {
|
||||||
|
fieldPath: string
|
||||||
|
value: unknown
|
||||||
|
onChange?: (value: unknown) => void
|
||||||
|
children?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A React component that can be registered as a field hook
|
||||||
|
*/
|
||||||
|
export type FieldHookComponent = React.FC<FieldHookComponentProps>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registry entry for a field hook
|
||||||
|
*/
|
||||||
|
interface FieldHookEntry {
|
||||||
|
component: FieldHookComponent
|
||||||
|
type: FieldHookType
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registry for managing field-level hooks
|
||||||
|
* Supports two types of hooks:
|
||||||
|
* - replace: Completely replaces the default field renderer
|
||||||
|
* - wrapper: Wraps the default field renderer with additional functionality
|
||||||
|
*/
|
||||||
|
export class FieldHookRegistry {
|
||||||
|
private hooks: Map<string, FieldHookEntry> = new Map()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a hook for a specific field path
|
||||||
|
* @param fieldPath The field path (e.g., 'chat.talk_value')
|
||||||
|
* @param component The React component to register
|
||||||
|
* @param type The hook type ('replace' or 'wrapper')
|
||||||
|
*/
|
||||||
|
register(
|
||||||
|
fieldPath: string,
|
||||||
|
component: FieldHookComponent,
|
||||||
|
type: FieldHookType = 'replace'
|
||||||
|
): void {
|
||||||
|
this.hooks.set(fieldPath, { component, type })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a registered hook for a specific field path
|
||||||
|
* @param fieldPath The field path to look up
|
||||||
|
* @returns The hook entry if found, undefined otherwise
|
||||||
|
*/
|
||||||
|
get(fieldPath: string): FieldHookEntry | undefined {
|
||||||
|
return this.hooks.get(fieldPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a hook is registered for a specific field path
|
||||||
|
* @param fieldPath The field path to check
|
||||||
|
* @returns True if a hook is registered, false otherwise
|
||||||
|
*/
|
||||||
|
has(fieldPath: string): boolean {
|
||||||
|
return this.hooks.has(fieldPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unregister a hook for a specific field path
|
||||||
|
* @param fieldPath The field path to unregister
|
||||||
|
*/
|
||||||
|
unregister(fieldPath: string): void {
|
||||||
|
this.hooks.delete(fieldPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all registered hooks
|
||||||
|
*/
|
||||||
|
clear(): void {
|
||||||
|
this.hooks.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all registered field paths
|
||||||
|
* @returns Array of registered field paths
|
||||||
|
*/
|
||||||
|
getAllPaths(): string[] {
|
||||||
|
return Array.from(this.hooks.keys())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Singleton instance of the field hook registry
|
||||||
|
*/
|
||||||
|
export const fieldHooks = new FieldHookRegistry()
|
||||||
|
|
@ -0,0 +1,188 @@
|
||||||
|
/**
|
||||||
|
* 黑话(俚语)管理 API
|
||||||
|
*/
|
||||||
|
import { fetchWithAuth } from '@/lib/fetch-with-auth'
|
||||||
|
import type {
|
||||||
|
JargonListResponse,
|
||||||
|
JargonDetailResponse,
|
||||||
|
JargonCreateRequest,
|
||||||
|
JargonCreateResponse,
|
||||||
|
JargonUpdateRequest,
|
||||||
|
JargonUpdateResponse,
|
||||||
|
JargonDeleteResponse,
|
||||||
|
JargonStatsResponse,
|
||||||
|
JargonChatListResponse,
|
||||||
|
} from '@/types/jargon'
|
||||||
|
|
||||||
|
const API_BASE = '/api/webui/jargon'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取聊天列表(有黑话记录的聊天)
|
||||||
|
*/
|
||||||
|
export async function getJargonChatList(): Promise<JargonChatListResponse> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/chats`, {})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '获取聊天列表失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取黑话列表
|
||||||
|
*/
|
||||||
|
export async function getJargonList(params: {
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
search?: string
|
||||||
|
chat_id?: string
|
||||||
|
is_jargon?: boolean | null
|
||||||
|
is_global?: boolean
|
||||||
|
}): Promise<JargonListResponse> {
|
||||||
|
const queryParams = new URLSearchParams()
|
||||||
|
|
||||||
|
if (params.page) queryParams.append('page', params.page.toString())
|
||||||
|
if (params.page_size) queryParams.append('page_size', params.page_size.toString())
|
||||||
|
if (params.search) queryParams.append('search', params.search)
|
||||||
|
if (params.chat_id) queryParams.append('chat_id', params.chat_id)
|
||||||
|
if (params.is_jargon !== undefined && params.is_jargon !== null) {
|
||||||
|
queryParams.append('is_jargon', params.is_jargon.toString())
|
||||||
|
}
|
||||||
|
if (params.is_global !== undefined) {
|
||||||
|
queryParams.append('is_global', params.is_global.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/list?${queryParams}`, {})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '获取黑话列表失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取黑话详细信息
|
||||||
|
*/
|
||||||
|
export async function getJargonDetail(jargonId: number): Promise<JargonDetailResponse> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/${jargonId}`, {})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '获取黑话详情失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建黑话
|
||||||
|
*/
|
||||||
|
export async function createJargon(
|
||||||
|
data: JargonCreateRequest
|
||||||
|
): Promise<JargonCreateResponse> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '创建黑话失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新黑话(增量更新)
|
||||||
|
*/
|
||||||
|
export async function updateJargon(
|
||||||
|
jargonId: number,
|
||||||
|
data: JargonUpdateRequest
|
||||||
|
): Promise<JargonUpdateResponse> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/${jargonId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '更新黑话失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除黑话
|
||||||
|
*/
|
||||||
|
export async function deleteJargon(jargonId: number): Promise<JargonDeleteResponse> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/${jargonId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '删除黑话失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量删除黑话
|
||||||
|
*/
|
||||||
|
export async function batchDeleteJargons(jargonIds: number[]): Promise<JargonDeleteResponse> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/batch/delete`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ ids: jargonIds }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '批量删除黑话失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取黑话统计数据
|
||||||
|
*/
|
||||||
|
export async function getJargonStats(): Promise<JargonStatsResponse> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/stats/summary`, {})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '获取黑话统计失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量设置黑话状态
|
||||||
|
*/
|
||||||
|
export async function batchSetJargonStatus(
|
||||||
|
jargonIds: number[],
|
||||||
|
isJargon: boolean
|
||||||
|
): Promise<JargonUpdateResponse> {
|
||||||
|
const queryParams = new URLSearchParams()
|
||||||
|
jargonIds.forEach(id => queryParams.append('ids', id.toString()))
|
||||||
|
queryParams.append('is_jargon', isJargon.toString())
|
||||||
|
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/batch/set-jargon?${queryParams}`, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '批量设置黑话状态失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
/**
|
||||||
|
* 知识库 API
|
||||||
|
*/
|
||||||
|
|
||||||
|
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api/webui'
|
||||||
|
|
||||||
|
export interface KnowledgeNode {
|
||||||
|
id: string
|
||||||
|
type: 'entity' | 'paragraph'
|
||||||
|
content: string
|
||||||
|
create_time?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KnowledgeEdge {
|
||||||
|
source: string
|
||||||
|
target: string
|
||||||
|
weight: number
|
||||||
|
create_time?: number
|
||||||
|
update_time?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KnowledgeGraph {
|
||||||
|
nodes: KnowledgeNode[]
|
||||||
|
edges: KnowledgeEdge[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KnowledgeStats {
|
||||||
|
total_nodes: number
|
||||||
|
total_edges: number
|
||||||
|
entity_nodes: number
|
||||||
|
paragraph_nodes: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取知识图谱数据
|
||||||
|
*/
|
||||||
|
export async function getKnowledgeGraph(limit: number = 100, nodeType: 'all' | 'entity' | 'paragraph' = 'all'): Promise<KnowledgeGraph> {
|
||||||
|
const url = `${API_BASE_URL}/knowledge/graph?limit=${limit}&node_type=${nodeType}`
|
||||||
|
|
||||||
|
const response = await fetch(url)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`获取知识图谱失败: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取知识图谱统计信息
|
||||||
|
*/
|
||||||
|
export async function getKnowledgeStats(): Promise<KnowledgeStats> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/knowledge/stats`)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('获取知识图谱统计信息失败')
|
||||||
|
}
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 搜索知识节点
|
||||||
|
*/
|
||||||
|
export async function searchKnowledgeNode(query: string): Promise<KnowledgeNode[]> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/knowledge/search?query=${encodeURIComponent(query)}`)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('搜索知识节点失败')
|
||||||
|
}
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,326 @@
|
||||||
|
/**
|
||||||
|
* 全局日志 WebSocket 管理器
|
||||||
|
* 确保整个应用只有一个 WebSocket 连接
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { fetchWithAuth, checkAuthStatus } from './fetch-with-auth'
|
||||||
|
import { getSetting } from './settings-manager'
|
||||||
|
|
||||||
|
export interface LogEntry {
|
||||||
|
id: string
|
||||||
|
timestamp: string
|
||||||
|
level: 'DEBUG' | 'INFO' | 'WARNING' | 'ERROR' | 'CRITICAL'
|
||||||
|
module: string
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type LogCallback = (log: LogEntry) => void
|
||||||
|
type ConnectionCallback = (connected: boolean) => void
|
||||||
|
|
||||||
|
class LogWebSocketManager {
|
||||||
|
private ws: WebSocket | null = null
|
||||||
|
private reconnectTimeout: number | null = null
|
||||||
|
private reconnectAttempts = 0
|
||||||
|
private heartbeatInterval: number | null = null
|
||||||
|
|
||||||
|
// 订阅者
|
||||||
|
private logCallbacks: Set<LogCallback> = new Set()
|
||||||
|
private connectionCallbacks: Set<ConnectionCallback> = new Set()
|
||||||
|
|
||||||
|
private isConnected = false
|
||||||
|
|
||||||
|
// 日志缓存 - 保存所有接收到的日志
|
||||||
|
private logCache: LogEntry[] = []
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取最大缓存大小(从设置读取)
|
||||||
|
*/
|
||||||
|
private getMaxCacheSize(): number {
|
||||||
|
return getSetting('logCacheSize')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取最大重连次数(从设置读取)
|
||||||
|
*/
|
||||||
|
private getMaxReconnectAttempts(): number {
|
||||||
|
return getSetting('wsMaxReconnectAttempts')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取重连间隔(从设置读取)
|
||||||
|
*/
|
||||||
|
private getReconnectInterval(): number {
|
||||||
|
return getSetting('wsReconnectInterval')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 WebSocket URL
|
||||||
|
*/
|
||||||
|
private getWebSocketUrl(token?: string): string {
|
||||||
|
let baseUrl: string
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
// 开发模式:连接到 WebUI 后端服务器
|
||||||
|
baseUrl = 'ws://127.0.0.1:8001/ws/logs'
|
||||||
|
} else {
|
||||||
|
// 生产模式:使用当前页面的 host
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
|
const host = window.location.host
|
||||||
|
baseUrl = `${protocol}//${host}/ws/logs`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果有 token,添加到 URL 参数
|
||||||
|
if (token) {
|
||||||
|
return `${baseUrl}?token=${encodeURIComponent(token)}`
|
||||||
|
}
|
||||||
|
return baseUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 WebSocket 临时认证 token
|
||||||
|
*/
|
||||||
|
private async getWsToken(): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
// 使用相对路径,让前端代理处理请求,避免 CORS 问题
|
||||||
|
const response = await fetchWithAuth('/api/webui/ws-token', {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include', // 携带 Cookie
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error('获取 WebSocket token 失败:', response.status)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
if (data.success && data.token) {
|
||||||
|
return data.token
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取 WebSocket token 失败:', error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 连接 WebSocket(会先检查登录状态)
|
||||||
|
*/
|
||||||
|
async connect() {
|
||||||
|
if (this.ws?.readyState === WebSocket.OPEN || this.ws?.readyState === WebSocket.CONNECTING) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否在登录页面
|
||||||
|
if (window.location.pathname === '/auth') {
|
||||||
|
console.log('📡 在登录页面,跳过 WebSocket 连接')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查登录状态,避免未登录时尝试连接
|
||||||
|
const isAuthenticated = await checkAuthStatus()
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
console.log('📡 未登录,跳过 WebSocket 连接')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先获取临时认证 token
|
||||||
|
const wsToken = await this.getWsToken()
|
||||||
|
if (!wsToken) {
|
||||||
|
console.log('📡 无法获取 WebSocket token,跳过连接')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const wsUrl = this.getWebSocketUrl(wsToken)
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.ws = new WebSocket(wsUrl)
|
||||||
|
|
||||||
|
this.ws.onopen = () => {
|
||||||
|
this.isConnected = true
|
||||||
|
this.reconnectAttempts = 0
|
||||||
|
this.notifyConnection(true)
|
||||||
|
this.startHeartbeat()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
// 忽略心跳响应
|
||||||
|
if (event.data === 'pong') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const log: LogEntry = JSON.parse(event.data)
|
||||||
|
this.notifyLog(log)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('解析日志消息失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ws.onerror = (error) => {
|
||||||
|
console.error('❌ WebSocket 错误:', error)
|
||||||
|
this.isConnected = false
|
||||||
|
this.notifyConnection(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ws.onclose = () => {
|
||||||
|
this.isConnected = false
|
||||||
|
this.notifyConnection(false)
|
||||||
|
this.stopHeartbeat()
|
||||||
|
this.attemptReconnect()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('创建 WebSocket 连接失败:', error)
|
||||||
|
this.attemptReconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 尝试重连
|
||||||
|
*/
|
||||||
|
private attemptReconnect() {
|
||||||
|
const maxAttempts = this.getMaxReconnectAttempts()
|
||||||
|
if (this.reconnectAttempts >= maxAttempts) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.reconnectAttempts += 1
|
||||||
|
const baseInterval = this.getReconnectInterval()
|
||||||
|
const delay = Math.min(baseInterval * this.reconnectAttempts, 30000)
|
||||||
|
|
||||||
|
this.reconnectTimeout = window.setTimeout(() => {
|
||||||
|
this.connect() // connect 是 async 但这里不需要 await,它内部会处理错误
|
||||||
|
}, delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动心跳
|
||||||
|
*/
|
||||||
|
private startHeartbeat() {
|
||||||
|
this.heartbeatInterval = window.setInterval(() => {
|
||||||
|
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||||
|
this.ws.send('ping')
|
||||||
|
}
|
||||||
|
}, 30000) // 每30秒发送一次心跳
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止心跳
|
||||||
|
*/
|
||||||
|
private stopHeartbeat() {
|
||||||
|
if (this.heartbeatInterval !== null) {
|
||||||
|
clearInterval(this.heartbeatInterval)
|
||||||
|
this.heartbeatInterval = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 断开连接
|
||||||
|
*/
|
||||||
|
disconnect() {
|
||||||
|
if (this.reconnectTimeout !== null) {
|
||||||
|
clearTimeout(this.reconnectTimeout)
|
||||||
|
this.reconnectTimeout = null
|
||||||
|
}
|
||||||
|
|
||||||
|
this.stopHeartbeat()
|
||||||
|
|
||||||
|
if (this.ws) {
|
||||||
|
this.ws.close()
|
||||||
|
this.ws = null
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isConnected = false
|
||||||
|
this.reconnectAttempts = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 订阅日志消息
|
||||||
|
*/
|
||||||
|
onLog(callback: LogCallback) {
|
||||||
|
this.logCallbacks.add(callback)
|
||||||
|
return () => this.logCallbacks.delete(callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 订阅连接状态
|
||||||
|
*/
|
||||||
|
onConnectionChange(callback: ConnectionCallback) {
|
||||||
|
this.connectionCallbacks.add(callback)
|
||||||
|
// 立即通知当前状态
|
||||||
|
callback(this.isConnected)
|
||||||
|
return () => this.connectionCallbacks.delete(callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通知所有订阅者新日志
|
||||||
|
*/
|
||||||
|
private notifyLog(log: LogEntry) {
|
||||||
|
// 检查是否已存在(通过 id 去重)
|
||||||
|
const exists = this.logCache.some(existingLog => existingLog.id === log.id)
|
||||||
|
|
||||||
|
if (!exists) {
|
||||||
|
// 添加到缓存
|
||||||
|
this.logCache.push(log)
|
||||||
|
|
||||||
|
// 限制缓存大小(动态读取配置)
|
||||||
|
const maxCacheSize = this.getMaxCacheSize()
|
||||||
|
if (this.logCache.length > maxCacheSize) {
|
||||||
|
this.logCache = this.logCache.slice(-maxCacheSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只有新日志才通知订阅者
|
||||||
|
this.logCallbacks.forEach(callback => {
|
||||||
|
try {
|
||||||
|
callback(log)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('日志回调执行失败:', error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通知所有订阅者连接状态变化
|
||||||
|
*/
|
||||||
|
private notifyConnection(connected: boolean) {
|
||||||
|
this.connectionCallbacks.forEach(callback => {
|
||||||
|
try {
|
||||||
|
callback(connected)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('连接状态回调执行失败:', error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取缓存的所有日志
|
||||||
|
*/
|
||||||
|
getAllLogs(): LogEntry[] {
|
||||||
|
return [...this.logCache]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清空日志缓存
|
||||||
|
*/
|
||||||
|
clearLogs() {
|
||||||
|
this.logCache = []
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前连接状态
|
||||||
|
*/
|
||||||
|
getConnectionStatus(): boolean {
|
||||||
|
return this.isConnected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出单例
|
||||||
|
export const logWebSocket = new LogWebSocketManager()
|
||||||
|
|
||||||
|
// 自动连接(应用启动时)
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
// 延迟一下确保页面加载完成
|
||||||
|
setTimeout(() => {
|
||||||
|
logWebSocket.connect()
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,570 @@
|
||||||
|
/**
|
||||||
|
* 模型配置 Pack API
|
||||||
|
*
|
||||||
|
* 与 Cloudflare Workers Pack 服务交互
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { fetchWithAuth } from './fetch-with-auth'
|
||||||
|
|
||||||
|
// ============ 类型定义 ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提供商配置(分享时不含 api_key)
|
||||||
|
*/
|
||||||
|
export interface PackProvider {
|
||||||
|
name: string
|
||||||
|
base_url: string
|
||||||
|
client_type: 'openai' | 'gemini'
|
||||||
|
max_retry?: number
|
||||||
|
timeout?: number
|
||||||
|
retry_interval?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模型配置
|
||||||
|
*/
|
||||||
|
export interface PackModel {
|
||||||
|
model_identifier: string
|
||||||
|
name: string
|
||||||
|
api_provider: string
|
||||||
|
price_in: number
|
||||||
|
price_out: number
|
||||||
|
temperature?: number
|
||||||
|
max_tokens?: number
|
||||||
|
force_stream_mode?: boolean
|
||||||
|
extra_params?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单个任务配置
|
||||||
|
*/
|
||||||
|
export interface PackTaskConfig {
|
||||||
|
model_list: string[]
|
||||||
|
temperature?: number
|
||||||
|
max_tokens?: number
|
||||||
|
slow_threshold?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 所有任务配置
|
||||||
|
*/
|
||||||
|
export interface PackTaskConfigs {
|
||||||
|
utils?: PackTaskConfig
|
||||||
|
utils_small?: PackTaskConfig
|
||||||
|
tool_use?: PackTaskConfig
|
||||||
|
replyer?: PackTaskConfig
|
||||||
|
planner?: PackTaskConfig
|
||||||
|
vlm?: PackTaskConfig
|
||||||
|
voice?: PackTaskConfig
|
||||||
|
embedding?: PackTaskConfig
|
||||||
|
lpmm_entity_extract?: PackTaskConfig
|
||||||
|
lpmm_rdf_build?: PackTaskConfig
|
||||||
|
lpmm_qa?: PackTaskConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pack 列表项
|
||||||
|
*/
|
||||||
|
export interface PackListItem {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
author: string
|
||||||
|
version: string
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
status: 'pending' | 'approved' | 'rejected'
|
||||||
|
reject_reason?: string
|
||||||
|
downloads: number
|
||||||
|
likes: number
|
||||||
|
tags?: string[]
|
||||||
|
provider_count: number
|
||||||
|
model_count: number
|
||||||
|
task_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 完整的 Pack 数据
|
||||||
|
*/
|
||||||
|
export interface ModelPack extends Omit<PackListItem, 'provider_count' | 'model_count' | 'task_count'> {
|
||||||
|
providers: PackProvider[]
|
||||||
|
models: PackModel[]
|
||||||
|
task_config: PackTaskConfigs
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pack 列表响应
|
||||||
|
*/
|
||||||
|
export interface ListPacksResponse {
|
||||||
|
packs: PackListItem[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
page_size: number
|
||||||
|
total_pages: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用 Pack 时的选项
|
||||||
|
*/
|
||||||
|
export interface ApplyPackOptions {
|
||||||
|
apply_providers: boolean
|
||||||
|
apply_models: boolean
|
||||||
|
apply_task_config: boolean
|
||||||
|
task_mode: 'replace' | 'append'
|
||||||
|
selected_providers?: string[]
|
||||||
|
selected_models?: string[]
|
||||||
|
selected_tasks?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用 Pack 时的冲突检测结果
|
||||||
|
*/
|
||||||
|
export interface ApplyPackConflicts {
|
||||||
|
existing_providers: Array<{
|
||||||
|
pack_provider: PackProvider
|
||||||
|
local_providers: Array<{ // 改为数组,支持多个匹配
|
||||||
|
name: string
|
||||||
|
base_url: string
|
||||||
|
}>
|
||||||
|
}>
|
||||||
|
new_providers: PackProvider[]
|
||||||
|
conflicting_models: Array<{
|
||||||
|
pack_model: string
|
||||||
|
local_model: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ API 配置 ============
|
||||||
|
|
||||||
|
// Pack 服务基础 URL(Cloudflare Workers)
|
||||||
|
const PACK_SERVICE_URL = 'https://maibot-plugin-stats.maibot-webui.workers.dev'
|
||||||
|
|
||||||
|
// ============ API 函数 ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 Pack 列表
|
||||||
|
*/
|
||||||
|
export async function listPacks(params?: {
|
||||||
|
status?: 'pending' | 'approved' | 'rejected' | 'all'
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
search?: string
|
||||||
|
sort_by?: 'created_at' | 'downloads' | 'likes'
|
||||||
|
sort_order?: 'asc' | 'desc'
|
||||||
|
}): Promise<ListPacksResponse> {
|
||||||
|
const searchParams = new URLSearchParams()
|
||||||
|
if (params?.status) searchParams.set('status', params.status)
|
||||||
|
if (params?.page) searchParams.set('page', params.page.toString())
|
||||||
|
if (params?.page_size) searchParams.set('page_size', params.page_size.toString())
|
||||||
|
if (params?.search) searchParams.set('search', params.search)
|
||||||
|
if (params?.sort_by) searchParams.set('sort_by', params.sort_by)
|
||||||
|
if (params?.sort_order) searchParams.set('sort_order', params.sort_order)
|
||||||
|
|
||||||
|
const response = await fetch(`${PACK_SERVICE_URL}/pack?${searchParams.toString()}`)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`获取 Pack 列表失败: ${response.status}`)
|
||||||
|
}
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取单个 Pack 详情
|
||||||
|
*/
|
||||||
|
export async function getPack(packId: string): Promise<ModelPack> {
|
||||||
|
const response = await fetch(`${PACK_SERVICE_URL}/pack/${packId}`)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`获取 Pack 失败: ${response.status}`)
|
||||||
|
}
|
||||||
|
const data = await response.json()
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.error || '获取 Pack 失败')
|
||||||
|
}
|
||||||
|
return data.pack
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建新 Pack
|
||||||
|
*/
|
||||||
|
export async function createPack(pack: {
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
author: string
|
||||||
|
tags?: string[]
|
||||||
|
providers: PackProvider[]
|
||||||
|
models: PackModel[]
|
||||||
|
task_config: PackTaskConfigs
|
||||||
|
}): Promise<{ pack_id: string; message: string }> {
|
||||||
|
const response = await fetch(`${PACK_SERVICE_URL}/pack`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(pack),
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.error || '创建 Pack 失败')
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录 Pack 下载
|
||||||
|
*/
|
||||||
|
export async function recordPackDownload(packId: string, userId?: string): Promise<void> {
|
||||||
|
await fetch(`${PACK_SERVICE_URL}/pack/download`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ pack_id: packId, user_id: userId }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 点赞/取消点赞 Pack
|
||||||
|
*/
|
||||||
|
export async function togglePackLike(packId: string, userId: string): Promise<{ likes: number; liked: boolean }> {
|
||||||
|
const response = await fetch(`${PACK_SERVICE_URL}/pack/like`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ pack_id: packId, user_id: userId }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.error || '点赞失败')
|
||||||
|
}
|
||||||
|
return { likes: data.likes, liked: data.liked }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否已点赞
|
||||||
|
*/
|
||||||
|
export async function checkPackLike(packId: string, userId: string): Promise<boolean> {
|
||||||
|
const response = await fetch(
|
||||||
|
`${PACK_SERVICE_URL}/pack/like/check?pack_id=${packId}&user_id=${userId}`
|
||||||
|
)
|
||||||
|
const data = await response.json()
|
||||||
|
return data.liked || false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 本地应用 Pack 相关 ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检测应用 Pack 时的冲突
|
||||||
|
*/
|
||||||
|
export async function detectPackConflicts(
|
||||||
|
pack: ModelPack
|
||||||
|
): Promise<ApplyPackConflicts> {
|
||||||
|
// 获取当前配置
|
||||||
|
const response = await fetchWithAuth('/api/webui/config/model')
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('获取当前模型配置失败')
|
||||||
|
}
|
||||||
|
const responseData = await response.json()
|
||||||
|
const currentConfig = responseData.config || responseData
|
||||||
|
|
||||||
|
console.log('=== Pack Conflict Detection ===')
|
||||||
|
console.log('Pack providers:', pack.providers)
|
||||||
|
console.log('Local providers:', currentConfig.api_providers)
|
||||||
|
|
||||||
|
const conflicts: ApplyPackConflicts = {
|
||||||
|
existing_providers: [],
|
||||||
|
new_providers: [],
|
||||||
|
conflicting_models: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测提供商冲突
|
||||||
|
const localProviders = currentConfig.api_providers || []
|
||||||
|
for (const packProvider of pack.providers) {
|
||||||
|
console.log(`\nChecking pack provider: ${packProvider.name}`)
|
||||||
|
console.log(` Pack URL: ${packProvider.base_url}`)
|
||||||
|
console.log(` Normalized: ${normalizeUrl(packProvider.base_url)}`)
|
||||||
|
|
||||||
|
// 按 URL 匹配 - 找出所有匹配的本地提供商
|
||||||
|
const matchedProviders = localProviders.filter(
|
||||||
|
(p: { base_url: string; name: string }) => {
|
||||||
|
const localNormalized = normalizeUrl(p.base_url)
|
||||||
|
const packNormalized = normalizeUrl(packProvider.base_url)
|
||||||
|
console.log(` Comparing with local "${p.name}": ${p.base_url}`)
|
||||||
|
console.log(` Local normalized: ${localNormalized}`)
|
||||||
|
console.log(` Match: ${localNormalized === packNormalized}`)
|
||||||
|
return localNormalized === packNormalized
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (matchedProviders.length > 0) {
|
||||||
|
console.log(` ✓ Matched with ${matchedProviders.length} local provider(s):`, matchedProviders.map((p: {name: string}) => p.name).join(', '))
|
||||||
|
conflicts.existing_providers.push({
|
||||||
|
pack_provider: packProvider,
|
||||||
|
local_providers: matchedProviders.map((p: { name: string; base_url: string }) => ({
|
||||||
|
name: p.name,
|
||||||
|
base_url: p.base_url,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
console.log(` ✗ No match found - will need API key`)
|
||||||
|
conflicts.new_providers.push(packProvider)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测模型名称冲突
|
||||||
|
const localModels = currentConfig.models || []
|
||||||
|
console.log('\n=== Model Conflict Detection ===')
|
||||||
|
for (const packModel of pack.models) {
|
||||||
|
const conflictModel = localModels.find(
|
||||||
|
(m: { name: string }) => m.name === packModel.name
|
||||||
|
)
|
||||||
|
if (conflictModel) {
|
||||||
|
console.log(`Model conflict: ${packModel.name}`)
|
||||||
|
conflicts.conflicting_models.push({
|
||||||
|
pack_model: packModel.name,
|
||||||
|
local_model: conflictModel.name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n=== Detection Summary ===')
|
||||||
|
console.log(`Existing providers: ${conflicts.existing_providers.length}`)
|
||||||
|
console.log(`New providers: ${conflicts.new_providers.length}`)
|
||||||
|
console.log(`Conflicting models: ${conflicts.conflicting_models.length}`)
|
||||||
|
console.log('===========================\n')
|
||||||
|
|
||||||
|
return conflicts
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用 Pack 到本地配置
|
||||||
|
*/
|
||||||
|
export async function applyPack(
|
||||||
|
pack: ModelPack,
|
||||||
|
options: ApplyPackOptions,
|
||||||
|
providerMapping: Record<string, string>, // pack_provider_name -> local_provider_name
|
||||||
|
newProviderApiKeys: Record<string, string>, // provider_name -> api_key
|
||||||
|
): Promise<void> {
|
||||||
|
// 获取当前配置
|
||||||
|
const response = await fetchWithAuth('/api/webui/config/model')
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('获取当前模型配置失败')
|
||||||
|
}
|
||||||
|
const responseData = await response.json()
|
||||||
|
const currentConfig = responseData.config || responseData
|
||||||
|
|
||||||
|
// 1. 处理提供商
|
||||||
|
if (options.apply_providers) {
|
||||||
|
const providersToApply = options.selected_providers
|
||||||
|
? pack.providers.filter(p => options.selected_providers!.includes(p.name))
|
||||||
|
: pack.providers
|
||||||
|
|
||||||
|
for (const packProvider of providersToApply) {
|
||||||
|
// 检查是否映射到已有提供商
|
||||||
|
if (providerMapping[packProvider.name]) {
|
||||||
|
// 使用已有提供商,不需要添加
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加新提供商
|
||||||
|
const apiKey = newProviderApiKeys[packProvider.name]
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new Error(`提供商 "${packProvider.name}" 缺少 API Key`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const newProvider = {
|
||||||
|
...packProvider,
|
||||||
|
api_key: apiKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否已存在同名提供商
|
||||||
|
const existingIndex = currentConfig.api_providers.findIndex(
|
||||||
|
(p: { name: string }) => p.name === packProvider.name
|
||||||
|
)
|
||||||
|
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
// 覆盖
|
||||||
|
currentConfig.api_providers[existingIndex] = newProvider
|
||||||
|
} else {
|
||||||
|
// 添加
|
||||||
|
currentConfig.api_providers.push(newProvider)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 处理模型
|
||||||
|
if (options.apply_models) {
|
||||||
|
const modelsToApply = options.selected_models
|
||||||
|
? pack.models.filter(m => options.selected_models!.includes(m.name))
|
||||||
|
: pack.models
|
||||||
|
|
||||||
|
for (const packModel of modelsToApply) {
|
||||||
|
// 映射提供商名称
|
||||||
|
const actualProvider = providerMapping[packModel.api_provider] || packModel.api_provider
|
||||||
|
|
||||||
|
const newModel = {
|
||||||
|
...packModel,
|
||||||
|
api_provider: actualProvider,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否已存在同名模型
|
||||||
|
const existingIndex = currentConfig.models.findIndex(
|
||||||
|
(m: { name: string }) => m.name === packModel.name
|
||||||
|
)
|
||||||
|
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
// 覆盖
|
||||||
|
currentConfig.models[existingIndex] = newModel
|
||||||
|
} else {
|
||||||
|
// 添加
|
||||||
|
currentConfig.models.push(newModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 处理任务配置
|
||||||
|
if (options.apply_task_config) {
|
||||||
|
const taskKeys = options.selected_tasks || Object.keys(pack.task_config)
|
||||||
|
|
||||||
|
for (const taskKey of taskKeys) {
|
||||||
|
const packTaskConfig = pack.task_config[taskKey as keyof PackTaskConfigs]
|
||||||
|
if (!packTaskConfig) continue
|
||||||
|
|
||||||
|
// 映射模型名称(如果模型名称被跳过,则从任务列表中移除)
|
||||||
|
const appliedModelNames = new Set(
|
||||||
|
options.selected_models || pack.models.map(m => m.name)
|
||||||
|
)
|
||||||
|
const filteredModelList = packTaskConfig.model_list.filter(
|
||||||
|
name => appliedModelNames.has(name)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (filteredModelList.length === 0) continue
|
||||||
|
|
||||||
|
const newTaskConfig = {
|
||||||
|
...packTaskConfig,
|
||||||
|
model_list: filteredModelList,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.task_mode === 'replace') {
|
||||||
|
// 替换模式
|
||||||
|
currentConfig.model_task_config[taskKey] = newTaskConfig
|
||||||
|
} else {
|
||||||
|
// 追加模式
|
||||||
|
const existingConfig = currentConfig.model_task_config[taskKey]
|
||||||
|
if (existingConfig) {
|
||||||
|
// 合并模型列表(去重)
|
||||||
|
const mergedList = [...new Set([
|
||||||
|
...existingConfig.model_list,
|
||||||
|
...filteredModelList,
|
||||||
|
])]
|
||||||
|
currentConfig.model_task_config[taskKey] = {
|
||||||
|
...existingConfig,
|
||||||
|
model_list: mergedList,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
currentConfig.model_task_config[taskKey] = newTaskConfig
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存配置
|
||||||
|
const saveResponse = await fetchWithAuth('/api/webui/config/model', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(currentConfig),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!saveResponse.ok) {
|
||||||
|
throw new Error('保存配置失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从当前配置导出 Pack
|
||||||
|
*/
|
||||||
|
export async function exportCurrentConfigAsPack(params: {
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
author: string
|
||||||
|
tags?: string[]
|
||||||
|
selectedProviders?: string[]
|
||||||
|
selectedModels?: string[]
|
||||||
|
selectedTasks?: string[]
|
||||||
|
}): Promise<{
|
||||||
|
providers: PackProvider[]
|
||||||
|
models: PackModel[]
|
||||||
|
task_config: PackTaskConfigs
|
||||||
|
}> {
|
||||||
|
// 获取当前配置
|
||||||
|
const response = await fetchWithAuth('/api/webui/config/model')
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('获取当前模型配置失败')
|
||||||
|
}
|
||||||
|
const responseData = await response.json()
|
||||||
|
|
||||||
|
// API 返回的格式是 { success: true, config: {...} }
|
||||||
|
if (!responseData.success || !responseData.config) {
|
||||||
|
throw new Error('获取配置失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentConfig = responseData.config
|
||||||
|
|
||||||
|
// 过滤提供商(移除 api_key)
|
||||||
|
let providers: PackProvider[] = (currentConfig.api_providers || []).map(
|
||||||
|
(p: { name: string; base_url: string; client_type: string; max_retry?: number; timeout?: number; retry_interval?: number }) => ({
|
||||||
|
name: p.name,
|
||||||
|
base_url: p.base_url,
|
||||||
|
client_type: p.client_type,
|
||||||
|
max_retry: p.max_retry,
|
||||||
|
timeout: p.timeout,
|
||||||
|
retry_interval: p.retry_interval,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
if (params.selectedProviders) {
|
||||||
|
providers = providers.filter(p => params.selectedProviders!.includes(p.name))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 过滤模型
|
||||||
|
let models: PackModel[] = currentConfig.models || []
|
||||||
|
if (params.selectedModels) {
|
||||||
|
models = models.filter(m => params.selectedModels!.includes(m.name))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 过滤任务配置
|
||||||
|
const task_config: PackTaskConfigs = {}
|
||||||
|
const allTasks = currentConfig.model_task_config || {}
|
||||||
|
const taskKeys = params.selectedTasks || Object.keys(allTasks)
|
||||||
|
|
||||||
|
for (const key of taskKeys) {
|
||||||
|
if (allTasks[key]) {
|
||||||
|
task_config[key as keyof PackTaskConfigs] = allTasks[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { providers, models, task_config }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 辅助函数 ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标准化 URL 用于比较
|
||||||
|
*/
|
||||||
|
function normalizeUrl(url: string): string {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url)
|
||||||
|
// 移除末尾斜杠,统一小写
|
||||||
|
return `${parsed.protocol}//${parsed.host}${parsed.pathname}`.replace(/\/$/, '').toLowerCase()
|
||||||
|
} catch {
|
||||||
|
return url.toLowerCase().replace(/\/$/, '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成用户 ID(用于统计)
|
||||||
|
*/
|
||||||
|
export function getPackUserId(): string {
|
||||||
|
const storageKey = 'maibot_pack_user_id'
|
||||||
|
let userId = localStorage.getItem(storageKey)
|
||||||
|
if (!userId) {
|
||||||
|
userId = 'pack_user_' + Math.random().toString(36).substring(2, 15)
|
||||||
|
localStorage.setItem(storageKey, userId)
|
||||||
|
}
|
||||||
|
return userId
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,138 @@
|
||||||
|
/**
|
||||||
|
* 人物信息管理 API
|
||||||
|
*/
|
||||||
|
import { fetchWithAuth, getAuthHeaders } from '@/lib/fetch-with-auth'
|
||||||
|
import type {
|
||||||
|
PersonListResponse,
|
||||||
|
PersonDetailResponse,
|
||||||
|
PersonUpdateRequest,
|
||||||
|
PersonUpdateResponse,
|
||||||
|
PersonDeleteResponse,
|
||||||
|
PersonStatsResponse,
|
||||||
|
} from '@/types/person'
|
||||||
|
|
||||||
|
const API_BASE = '/api/webui/person'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取人物信息列表
|
||||||
|
*/
|
||||||
|
export async function getPersonList(params: {
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
search?: string
|
||||||
|
is_known?: boolean
|
||||||
|
platform?: string
|
||||||
|
}): Promise<PersonListResponse> {
|
||||||
|
const queryParams = new URLSearchParams()
|
||||||
|
|
||||||
|
if (params.page) queryParams.append('page', params.page.toString())
|
||||||
|
if (params.page_size) queryParams.append('page_size', params.page_size.toString())
|
||||||
|
if (params.search) queryParams.append('search', params.search)
|
||||||
|
if (params.is_known !== undefined) queryParams.append('is_known', params.is_known.toString())
|
||||||
|
if (params.platform) queryParams.append('platform', params.platform)
|
||||||
|
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/list?${queryParams}`, {
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '获取人物列表失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取人物详细信息
|
||||||
|
*/
|
||||||
|
export async function getPersonDetail(personId: string): Promise<PersonDetailResponse> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/${personId}`, {
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '获取人物详情失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新人物信息(增量更新)
|
||||||
|
*/
|
||||||
|
export async function updatePerson(
|
||||||
|
personId: string,
|
||||||
|
data: PersonUpdateRequest
|
||||||
|
): Promise<PersonUpdateResponse> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/${personId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '更新人物信息失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除人物信息
|
||||||
|
*/
|
||||||
|
export async function deletePerson(personId: string): Promise<PersonDeleteResponse> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/${personId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '删除人物信息失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取人物统计数据
|
||||||
|
*/
|
||||||
|
export async function getPersonStats(): Promise<PersonStatsResponse> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/stats/summary`, {
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '获取统计数据失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量删除人物信息
|
||||||
|
*/
|
||||||
|
export async function batchDeletePersons(personIds: string[]): Promise<{
|
||||||
|
success: boolean
|
||||||
|
message: string
|
||||||
|
deleted_count: number
|
||||||
|
failed_count: number
|
||||||
|
failed_ids: string[]
|
||||||
|
}> {
|
||||||
|
const response = await fetchWithAuth(`${API_BASE}/batch/delete`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify({ person_ids: personIds }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '批量删除失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,201 @@
|
||||||
|
import { fetchWithAuth } from './fetch-with-auth'
|
||||||
|
|
||||||
|
// ========== 新的优化接口 ==========
|
||||||
|
|
||||||
|
export interface ChatSummary {
|
||||||
|
chat_id: string
|
||||||
|
plan_count: number
|
||||||
|
latest_timestamp: number
|
||||||
|
latest_filename: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlannerOverview {
|
||||||
|
total_chats: number
|
||||||
|
total_plans: number
|
||||||
|
chats: ChatSummary[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlanLogSummary {
|
||||||
|
chat_id: string
|
||||||
|
timestamp: number
|
||||||
|
filename: string
|
||||||
|
action_count: number
|
||||||
|
action_types: string[] // 动作类型列表
|
||||||
|
total_plan_ms: number
|
||||||
|
llm_duration_ms: number
|
||||||
|
reasoning_preview: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlanLogDetail {
|
||||||
|
type: string
|
||||||
|
chat_id: string
|
||||||
|
timestamp: number
|
||||||
|
prompt: string
|
||||||
|
reasoning: string
|
||||||
|
raw_output: string
|
||||||
|
actions: any[]
|
||||||
|
timing: {
|
||||||
|
prompt_build_ms: number
|
||||||
|
llm_duration_ms: number
|
||||||
|
total_plan_ms: number
|
||||||
|
loop_start_time: number
|
||||||
|
}
|
||||||
|
extra: any
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedChatLogs {
|
||||||
|
data: PlanLogSummary[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
page_size: number
|
||||||
|
chat_id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取规划器总览 - 轻量级,只统计文件数量
|
||||||
|
*/
|
||||||
|
export async function getPlannerOverview(): Promise<PlannerOverview> {
|
||||||
|
const response = await fetchWithAuth('/api/planner/overview')
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指定聊天的规划日志列表(分页)
|
||||||
|
*/
|
||||||
|
export async function getChatLogs(chatId: string, page = 1, pageSize = 20, search?: string): Promise<PaginatedChatLogs> {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: page.toString(),
|
||||||
|
page_size: pageSize.toString()
|
||||||
|
})
|
||||||
|
if (search) {
|
||||||
|
params.append('search', search)
|
||||||
|
}
|
||||||
|
const response = await fetchWithAuth(`/api/planner/chat/${chatId}/logs?${params}`)
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取规划日志详情 - 按需加载
|
||||||
|
*/
|
||||||
|
export async function getLogDetail(chatId: string, filename: string): Promise<PlanLogDetail> {
|
||||||
|
const response = await fetchWithAuth(`/api/planner/log/${chatId}/${filename}`)
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 兼容旧接口 ==========
|
||||||
|
|
||||||
|
export interface PlannerStats {
|
||||||
|
total_chats: number
|
||||||
|
total_plans: number
|
||||||
|
avg_plan_time_ms: number
|
||||||
|
avg_llm_time_ms: number
|
||||||
|
recent_plans: PlanLogSummary[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedPlanLogs {
|
||||||
|
data: PlanLogSummary[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
page_size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPlannerStats(): Promise<PlannerStats> {
|
||||||
|
const response = await fetchWithAuth('/api/planner/stats')
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAllLogs(page = 1, pageSize = 20): Promise<PaginatedPlanLogs> {
|
||||||
|
const response = await fetchWithAuth(`/api/planner/all-logs?page=${page}&page_size=${pageSize}`)
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getChatList(): Promise<string[]> {
|
||||||
|
const response = await fetchWithAuth('/api/planner/chats')
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 回复器接口 ==========
|
||||||
|
|
||||||
|
export interface ReplierChatSummary {
|
||||||
|
chat_id: string
|
||||||
|
reply_count: number
|
||||||
|
latest_timestamp: number
|
||||||
|
latest_filename: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReplierOverview {
|
||||||
|
total_chats: number
|
||||||
|
total_replies: number
|
||||||
|
chats: ReplierChatSummary[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReplyLogSummary {
|
||||||
|
chat_id: string
|
||||||
|
timestamp: number
|
||||||
|
filename: string
|
||||||
|
model: string
|
||||||
|
success: boolean
|
||||||
|
llm_ms: number
|
||||||
|
overall_ms: number
|
||||||
|
output_preview: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReplyLogDetail {
|
||||||
|
type: string
|
||||||
|
chat_id: string
|
||||||
|
timestamp: number
|
||||||
|
prompt: string
|
||||||
|
output: string
|
||||||
|
processed_output: string[]
|
||||||
|
model: string
|
||||||
|
reasoning: string
|
||||||
|
think_level: number
|
||||||
|
timing: {
|
||||||
|
prompt_ms: number
|
||||||
|
overall_ms: number
|
||||||
|
timing_logs: string[]
|
||||||
|
llm_ms: number
|
||||||
|
almost_zero: string
|
||||||
|
}
|
||||||
|
error: string | null
|
||||||
|
success: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedReplyLogs {
|
||||||
|
data: ReplyLogSummary[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
page_size: number
|
||||||
|
chat_id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取回复器总览 - 轻量级,只统计文件数量
|
||||||
|
*/
|
||||||
|
export async function getReplierOverview(): Promise<ReplierOverview> {
|
||||||
|
const response = await fetchWithAuth('/api/replier/overview')
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指定聊天的回复日志列表(分页)
|
||||||
|
*/
|
||||||
|
export async function getReplyChatLogs(chatId: string, page = 1, pageSize = 20, search?: string): Promise<PaginatedReplyLogs> {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: page.toString(),
|
||||||
|
page_size: pageSize.toString()
|
||||||
|
})
|
||||||
|
if (search) {
|
||||||
|
params.append('search', search)
|
||||||
|
}
|
||||||
|
const response = await fetchWithAuth(`/api/replier/chat/${chatId}/logs?${params}`)
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取回复日志详情 - 按需加载
|
||||||
|
*/
|
||||||
|
export async function getReplyLogDetail(chatId: string, filename: string): Promise<ReplyLogDetail> {
|
||||||
|
const response = await fetchWithAuth(`/api/replier/log/${chatId}/${filename}`)
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,722 @@
|
||||||
|
import { fetchWithAuth, getAuthHeaders } from '@/lib/fetch-with-auth'
|
||||||
|
import type { PluginInfo } from '@/types/plugin'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Git 安装状态
|
||||||
|
*/
|
||||||
|
export interface GitStatus {
|
||||||
|
installed: boolean
|
||||||
|
version?: string
|
||||||
|
path?: string
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 麦麦版本信息
|
||||||
|
*/
|
||||||
|
export interface MaimaiVersion {
|
||||||
|
version: string
|
||||||
|
version_major: number
|
||||||
|
version_minor: number
|
||||||
|
version_patch: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 已安装插件信息
|
||||||
|
*/
|
||||||
|
export interface InstalledPlugin {
|
||||||
|
id: string
|
||||||
|
manifest: {
|
||||||
|
manifest_version: number
|
||||||
|
name: string
|
||||||
|
version: string
|
||||||
|
description: string
|
||||||
|
author: {
|
||||||
|
name: string
|
||||||
|
url?: string
|
||||||
|
}
|
||||||
|
license: string
|
||||||
|
host_application: {
|
||||||
|
min_version: string
|
||||||
|
max_version?: string
|
||||||
|
}
|
||||||
|
homepage_url?: string
|
||||||
|
repository_url?: string
|
||||||
|
keywords?: string[]
|
||||||
|
categories?: string[]
|
||||||
|
[key: string]: unknown // 允许其他字段
|
||||||
|
}
|
||||||
|
path: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 插件加载进度
|
||||||
|
*/
|
||||||
|
export interface PluginLoadProgress {
|
||||||
|
operation: 'idle' | 'fetch' | 'install' | 'uninstall' | 'update'
|
||||||
|
stage: 'idle' | 'loading' | 'success' | 'error'
|
||||||
|
progress: number // 0-100
|
||||||
|
message: string
|
||||||
|
error?: string
|
||||||
|
plugin_id?: string
|
||||||
|
total_plugins: number
|
||||||
|
loaded_plugins: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 插件仓库配置
|
||||||
|
*/
|
||||||
|
const PLUGIN_REPO_OWNER = 'Mai-with-u'
|
||||||
|
const PLUGIN_REPO_NAME = 'plugin-repo'
|
||||||
|
const PLUGIN_REPO_BRANCH = 'main'
|
||||||
|
const PLUGIN_DETAILS_FILE = 'plugin_details.json'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 插件列表 API 响应类型(只包含我们需要的字段)
|
||||||
|
*/
|
||||||
|
interface PluginApiResponse {
|
||||||
|
id: string
|
||||||
|
manifest: {
|
||||||
|
manifest_version: number
|
||||||
|
name: string
|
||||||
|
version: string
|
||||||
|
description: string
|
||||||
|
author: {
|
||||||
|
name: string
|
||||||
|
url?: string
|
||||||
|
}
|
||||||
|
license: string
|
||||||
|
host_application: {
|
||||||
|
min_version: string
|
||||||
|
max_version?: string
|
||||||
|
}
|
||||||
|
homepage_url?: string
|
||||||
|
repository_url?: string
|
||||||
|
keywords: string[]
|
||||||
|
categories?: string[]
|
||||||
|
default_locale: string
|
||||||
|
locales_path?: string
|
||||||
|
}
|
||||||
|
// 可能还有其他字段,但我们不关心
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从远程获取插件列表(通过后端代理避免 CORS)
|
||||||
|
*/
|
||||||
|
export async function fetchPluginList(): Promise<PluginInfo[]> {
|
||||||
|
try {
|
||||||
|
// 通过后端 API 获取 Raw 文件
|
||||||
|
const response = await fetchWithAuth('/api/webui/plugins/fetch-raw', {
|
||||||
|
method: 'POST',
|
||||||
|
|
||||||
|
body: JSON.stringify({
|
||||||
|
owner: PLUGIN_REPO_OWNER,
|
||||||
|
repo: PLUGIN_REPO_NAME,
|
||||||
|
branch: PLUGIN_REPO_BRANCH,
|
||||||
|
file_path: PLUGIN_DETAILS_FILE
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
// 检查后端返回的结果
|
||||||
|
if (!result.success || !result.data) {
|
||||||
|
throw new Error(result.error || '获取插件列表失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: PluginApiResponse[] = JSON.parse(result.data)
|
||||||
|
|
||||||
|
// 转换为 PluginInfo 格式,并过滤掉无效数据
|
||||||
|
const pluginList = data
|
||||||
|
.filter(item => {
|
||||||
|
// 验证必需字段
|
||||||
|
if (!item?.id || !item?.manifest) {
|
||||||
|
console.warn('跳过无效插件数据:', item)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!item.manifest.name || !item.manifest.version) {
|
||||||
|
console.warn('跳过缺少必需字段的插件:', item.id)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
manifest: {
|
||||||
|
manifest_version: item.manifest.manifest_version || 1,
|
||||||
|
name: item.manifest.name,
|
||||||
|
version: item.manifest.version,
|
||||||
|
description: item.manifest.description || '',
|
||||||
|
author: item.manifest.author || { name: 'Unknown' },
|
||||||
|
license: item.manifest.license || 'Unknown',
|
||||||
|
host_application: item.manifest.host_application || { min_version: '0.0.0' },
|
||||||
|
homepage_url: item.manifest.homepage_url,
|
||||||
|
repository_url: item.manifest.repository_url,
|
||||||
|
keywords: item.manifest.keywords || [],
|
||||||
|
categories: item.manifest.categories || [],
|
||||||
|
default_locale: item.manifest.default_locale || 'zh-CN',
|
||||||
|
locales_path: item.manifest.locales_path,
|
||||||
|
},
|
||||||
|
// 默认值,这些信息可能需要从其他 API 获取
|
||||||
|
downloads: 0,
|
||||||
|
rating: 0,
|
||||||
|
review_count: 0,
|
||||||
|
installed: false,
|
||||||
|
published_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
return pluginList
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch plugin list:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查本机 Git 安装状态
|
||||||
|
*/
|
||||||
|
export async function checkGitStatus(): Promise<GitStatus> {
|
||||||
|
try {
|
||||||
|
const response = await fetchWithAuth('/api/webui/plugins/git-status')
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to check Git status:', error)
|
||||||
|
// 返回未安装状态
|
||||||
|
return {
|
||||||
|
installed: false,
|
||||||
|
error: '无法检测 Git 安装状态'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取麦麦版本信息
|
||||||
|
*/
|
||||||
|
export async function getMaimaiVersion(): Promise<MaimaiVersion> {
|
||||||
|
try {
|
||||||
|
const response = await fetchWithAuth('/api/webui/plugins/version')
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get Maimai version:', error)
|
||||||
|
// 返回默认版本
|
||||||
|
return {
|
||||||
|
version: '0.0.0',
|
||||||
|
version_major: 0,
|
||||||
|
version_minor: 0,
|
||||||
|
version_patch: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 比较版本号
|
||||||
|
*
|
||||||
|
* @param pluginMinVersion 插件要求的最小版本
|
||||||
|
* @param pluginMaxVersion 插件要求的最大版本(可选)
|
||||||
|
* @param maimaiVersion 麦麦当前版本
|
||||||
|
* @returns true 表示兼容,false 表示不兼容
|
||||||
|
*/
|
||||||
|
export function isPluginCompatible(
|
||||||
|
pluginMinVersion: string,
|
||||||
|
pluginMaxVersion: string | undefined,
|
||||||
|
maimaiVersion: MaimaiVersion
|
||||||
|
): boolean {
|
||||||
|
// 解析插件最小版本
|
||||||
|
const minParts = pluginMinVersion.split('.').map(p => parseInt(p) || 0)
|
||||||
|
const minMajor = minParts[0] || 0
|
||||||
|
const minMinor = minParts[1] || 0
|
||||||
|
const minPatch = minParts[2] || 0
|
||||||
|
|
||||||
|
// 检查最小版本
|
||||||
|
if (maimaiVersion.version_major < minMajor) return false
|
||||||
|
if (maimaiVersion.version_major === minMajor && maimaiVersion.version_minor < minMinor) return false
|
||||||
|
if (maimaiVersion.version_major === minMajor &&
|
||||||
|
maimaiVersion.version_minor === minMinor &&
|
||||||
|
maimaiVersion.version_patch < minPatch) return false
|
||||||
|
|
||||||
|
// 检查最大版本(如果有)
|
||||||
|
if (pluginMaxVersion) {
|
||||||
|
const maxParts = pluginMaxVersion.split('.').map(p => parseInt(p) || 0)
|
||||||
|
const maxMajor = maxParts[0] || 0
|
||||||
|
const maxMinor = maxParts[1] || 0
|
||||||
|
const maxPatch = maxParts[2] || 0
|
||||||
|
|
||||||
|
if (maimaiVersion.version_major > maxMajor) return false
|
||||||
|
if (maimaiVersion.version_major === maxMajor && maimaiVersion.version_minor > maxMinor) return false
|
||||||
|
if (maimaiVersion.version_major === maxMajor &&
|
||||||
|
maimaiVersion.version_minor === maxMinor &&
|
||||||
|
maimaiVersion.version_patch > maxPatch) return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 WebSocket 临时认证 token
|
||||||
|
*/
|
||||||
|
async function getWsToken(): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetchWithAuth('/api/webui/ws-token')
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error('获取 WebSocket token 失败:', response.status)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const data = await response.json()
|
||||||
|
if (data.success && data.token) {
|
||||||
|
return data.token
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取 WebSocket token 失败:', error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 连接插件加载进度 WebSocket
|
||||||
|
*
|
||||||
|
* 使用临时 token 进行认证,异步获取 token 后连接
|
||||||
|
*/
|
||||||
|
export async function connectPluginProgressWebSocket(
|
||||||
|
onProgress: (progress: PluginLoadProgress) => void,
|
||||||
|
onError?: (error: Event) => void
|
||||||
|
): Promise<WebSocket | null> {
|
||||||
|
// 先获取临时 token
|
||||||
|
const wsToken = await getWsToken()
|
||||||
|
if (!wsToken) {
|
||||||
|
console.warn('无法获取 WebSocket token,可能未登录')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
|
const host = window.location.host
|
||||||
|
const wsUrl = `${protocol}//${host}/api/webui/ws/plugin-progress?token=${encodeURIComponent(wsToken)}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ws = new WebSocket(wsUrl)
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
console.log('Plugin progress WebSocket connected')
|
||||||
|
// 发送心跳
|
||||||
|
const heartbeat = setInterval(() => {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send('ping')
|
||||||
|
} else {
|
||||||
|
clearInterval(heartbeat)
|
||||||
|
}
|
||||||
|
}, 30000)
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
// 忽略心跳响应
|
||||||
|
if (event.data === 'pong') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = JSON.parse(event.data) as PluginLoadProgress
|
||||||
|
onProgress(data)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to parse progress data:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onerror = (error) => {
|
||||||
|
console.error('Plugin progress WebSocket error:', error)
|
||||||
|
onError?.(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
console.log('Plugin progress WebSocket disconnected')
|
||||||
|
}
|
||||||
|
|
||||||
|
return ws
|
||||||
|
} catch (error) {
|
||||||
|
console.error('创建 WebSocket 连接失败:', error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取已安装插件列表
|
||||||
|
*/
|
||||||
|
export async function getInstalledPlugins(): Promise<InstalledPlugin[]> {
|
||||||
|
try {
|
||||||
|
const response = await fetchWithAuth('/api/webui/plugins/installed', {
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.message || '获取已安装插件列表失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.plugins || []
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get installed plugins:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查插件是否已安装
|
||||||
|
*/
|
||||||
|
export function checkPluginInstalled(pluginId: string, installedPlugins: InstalledPlugin[]): boolean {
|
||||||
|
return installedPlugins.some(p => p.id === pluginId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取已安装插件的版本
|
||||||
|
*/
|
||||||
|
export function getInstalledPluginVersion(pluginId: string, installedPlugins: InstalledPlugin[]): string | undefined {
|
||||||
|
const plugin = installedPlugins.find(p => p.id === pluginId)
|
||||||
|
if (!plugin) return undefined
|
||||||
|
|
||||||
|
// 兼容两种格式:新格式有 manifest,旧格式直接有 version
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
return plugin.manifest?.version || (plugin as any).version
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安装插件
|
||||||
|
*/
|
||||||
|
export async function installPlugin(pluginId: string, repositoryUrl: string, branch: string = 'main'): Promise<{ success: boolean; message: string }> {
|
||||||
|
const response = await fetchWithAuth('/api/webui/plugins/install', {
|
||||||
|
method: 'POST',
|
||||||
|
|
||||||
|
body: JSON.stringify({
|
||||||
|
plugin_id: pluginId,
|
||||||
|
repository_url: repositoryUrl,
|
||||||
|
branch: branch
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '安装失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 卸载插件
|
||||||
|
*/
|
||||||
|
export async function uninstallPlugin(pluginId: string): Promise<{ success: boolean; message: string }> {
|
||||||
|
const response = await fetchWithAuth('/api/webui/plugins/uninstall', {
|
||||||
|
method: 'POST',
|
||||||
|
|
||||||
|
body: JSON.stringify({
|
||||||
|
plugin_id: pluginId
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '卸载失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新插件
|
||||||
|
*/
|
||||||
|
export async function updatePlugin(pluginId: string, repositoryUrl: string, branch: string = 'main'): Promise<{ success: boolean; message: string; old_version: string; new_version: string }> {
|
||||||
|
const response = await fetchWithAuth('/api/webui/plugins/update', {
|
||||||
|
method: 'POST',
|
||||||
|
|
||||||
|
body: JSON.stringify({
|
||||||
|
plugin_id: pluginId,
|
||||||
|
repository_url: repositoryUrl,
|
||||||
|
branch: branch
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '更新失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ============ 插件配置管理 ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列表项字段定义(用于 object 类型的数组项)
|
||||||
|
*/
|
||||||
|
export interface ItemFieldDefinition {
|
||||||
|
type: string
|
||||||
|
label?: string
|
||||||
|
placeholder?: string
|
||||||
|
default?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 配置字段定义
|
||||||
|
*/
|
||||||
|
export interface ConfigFieldSchema {
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
default: unknown
|
||||||
|
description: string
|
||||||
|
example?: string
|
||||||
|
required: boolean
|
||||||
|
choices?: unknown[]
|
||||||
|
min?: number
|
||||||
|
max?: number
|
||||||
|
step?: number
|
||||||
|
pattern?: string
|
||||||
|
max_length?: number
|
||||||
|
label: string
|
||||||
|
placeholder?: string
|
||||||
|
hint?: string
|
||||||
|
icon?: string
|
||||||
|
hidden: boolean
|
||||||
|
disabled: boolean
|
||||||
|
order: number
|
||||||
|
input_type?: string
|
||||||
|
ui_type: string
|
||||||
|
rows?: number
|
||||||
|
group?: string
|
||||||
|
depends_on?: string
|
||||||
|
depends_value?: unknown
|
||||||
|
// 列表类型专用
|
||||||
|
item_type?: string // "string" | "number" | "object"
|
||||||
|
item_fields?: Record<string, ItemFieldDefinition>
|
||||||
|
min_items?: number
|
||||||
|
max_items?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 配置节定义
|
||||||
|
*/
|
||||||
|
export interface ConfigSectionSchema {
|
||||||
|
name: string
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
icon?: string
|
||||||
|
collapsed: boolean
|
||||||
|
order: number
|
||||||
|
fields: Record<string, ConfigFieldSchema>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 配置标签页定义
|
||||||
|
*/
|
||||||
|
export interface ConfigTabSchema {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
sections: string[]
|
||||||
|
icon?: string
|
||||||
|
order: number
|
||||||
|
badge?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 配置布局定义
|
||||||
|
*/
|
||||||
|
export interface ConfigLayoutSchema {
|
||||||
|
type: 'auto' | 'tabs' | 'pages'
|
||||||
|
tabs: ConfigTabSchema[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 插件配置 Schema
|
||||||
|
*/
|
||||||
|
export interface PluginConfigSchema {
|
||||||
|
plugin_id: string
|
||||||
|
plugin_info: {
|
||||||
|
name: string
|
||||||
|
version: string
|
||||||
|
description: string
|
||||||
|
author: string
|
||||||
|
}
|
||||||
|
sections: Record<string, ConfigSectionSchema>
|
||||||
|
layout: ConfigLayoutSchema
|
||||||
|
_note?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取插件配置 Schema
|
||||||
|
*/
|
||||||
|
export async function getPluginConfigSchema(pluginId: string): Promise<PluginConfigSchema> {
|
||||||
|
const response = await fetchWithAuth(`/api/webui/plugins/config/${pluginId}/schema`, {
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text()
|
||||||
|
try {
|
||||||
|
const error = JSON.parse(text)
|
||||||
|
throw new Error(error.detail || '获取配置 Schema 失败')
|
||||||
|
} catch {
|
||||||
|
throw new Error(`获取配置 Schema 失败 (${response.status})`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.message || '获取配置 Schema 失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.schema
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取插件当前配置值
|
||||||
|
*/
|
||||||
|
export async function getPluginConfig(pluginId: string): Promise<Record<string, unknown>> {
|
||||||
|
const response = await fetchWithAuth(`/api/webui/plugins/config/${pluginId}`, {
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text()
|
||||||
|
try {
|
||||||
|
const error = JSON.parse(text)
|
||||||
|
throw new Error(error.detail || '获取配置失败')
|
||||||
|
} catch {
|
||||||
|
throw new Error(`获取配置失败 (${response.status})`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.message || '获取配置失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.config
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取插件原始 TOML 配置
|
||||||
|
*/
|
||||||
|
export async function getPluginConfigRaw(pluginId: string): Promise<string> {
|
||||||
|
const response = await fetchWithAuth(`/api/webui/plugins/config/${pluginId}/raw`, {
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text()
|
||||||
|
try {
|
||||||
|
const error = JSON.parse(text)
|
||||||
|
throw new Error(error.detail || '获取配置失败')
|
||||||
|
} catch {
|
||||||
|
throw new Error(`获取配置失败 (${response.status})`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.message || '获取配置失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.config
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新插件配置
|
||||||
|
*/
|
||||||
|
export async function updatePluginConfig(
|
||||||
|
pluginId: string,
|
||||||
|
config: Record<string, unknown>
|
||||||
|
): Promise<{ success: boolean; message: string; note?: string }> {
|
||||||
|
const response = await fetchWithAuth(`/api/webui/plugins/config/${pluginId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify({ config })
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '保存配置失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新插件原始 TOML 配置
|
||||||
|
*/
|
||||||
|
export async function updatePluginConfigRaw(
|
||||||
|
pluginId: string,
|
||||||
|
configToml: string
|
||||||
|
): Promise<{ success: boolean; message: string; note?: string }> {
|
||||||
|
const response = await fetchWithAuth(`/api/webui/plugins/config/${pluginId}/raw`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify({ config: configToml })
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '保存配置失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重置插件配置为默认值
|
||||||
|
*/
|
||||||
|
export async function resetPluginConfig(
|
||||||
|
pluginId: string
|
||||||
|
): Promise<{ success: boolean; message: string; backup?: string }> {
|
||||||
|
const response = await fetchWithAuth(`/api/webui/plugins/config/${pluginId}/reset`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '重置配置失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换插件启用状态
|
||||||
|
*/
|
||||||
|
export async function togglePlugin(
|
||||||
|
pluginId: string
|
||||||
|
): Promise<{ success: boolean; enabled: boolean; message: string; note?: string }> {
|
||||||
|
const response = await fetchWithAuth(`/api/webui/plugins/config/${pluginId}/toggle`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '切换状态失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,244 @@
|
||||||
|
/**
|
||||||
|
* 插件统计 API 客户端
|
||||||
|
* 用于与 Cloudflare Workers 统计服务交互
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 配置统计服务 API 地址(所有用户共享的云端统计服务)
|
||||||
|
const STATS_API_BASE_URL = 'https://maibot-plugin-stats.maibot-webui.workers.dev'
|
||||||
|
|
||||||
|
export interface PluginStatsData {
|
||||||
|
plugin_id: string
|
||||||
|
likes: number
|
||||||
|
dislikes: number
|
||||||
|
downloads: number
|
||||||
|
rating: number
|
||||||
|
rating_count: number
|
||||||
|
recent_ratings?: Array<{
|
||||||
|
user_id: string
|
||||||
|
rating: number
|
||||||
|
comment?: string
|
||||||
|
created_at: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatsResponse {
|
||||||
|
success: boolean
|
||||||
|
error?: string
|
||||||
|
remaining?: number
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取插件统计数据
|
||||||
|
*/
|
||||||
|
export async function getPluginStats(pluginId: string): Promise<PluginStatsData | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${STATS_API_BASE_URL}/stats/${pluginId}`)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error('Failed to fetch plugin stats:', response.statusText)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching plugin stats:', error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 点赞插件
|
||||||
|
*/
|
||||||
|
export async function likePlugin(pluginId: string, userId?: string): Promise<StatsResponse> {
|
||||||
|
try {
|
||||||
|
const finalUserId = userId || getUserId()
|
||||||
|
|
||||||
|
const response = await fetch(`${STATS_API_BASE_URL}/stats/like`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ plugin_id: pluginId, user_id: finalUserId }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (response.status === 429) {
|
||||||
|
return { success: false, error: '操作过于频繁,请稍后再试' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return { success: false, error: data.error || '点赞失败' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, ...data }
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error liking plugin:', error)
|
||||||
|
return { success: false, error: '网络错误' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 点踩插件
|
||||||
|
*/
|
||||||
|
export async function dislikePlugin(pluginId: string, userId?: string): Promise<StatsResponse> {
|
||||||
|
try {
|
||||||
|
const finalUserId = userId || getUserId()
|
||||||
|
|
||||||
|
const response = await fetch(`${STATS_API_BASE_URL}/stats/dislike`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ plugin_id: pluginId, user_id: finalUserId }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (response.status === 429) {
|
||||||
|
return { success: false, error: '操作过于频繁,请稍后再试' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return { success: false, error: data.error || '点踩失败' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, ...data }
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error disliking plugin:', error)
|
||||||
|
return { success: false, error: '网络错误' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 评分插件
|
||||||
|
*/
|
||||||
|
export async function ratePlugin(
|
||||||
|
pluginId: string,
|
||||||
|
rating: number,
|
||||||
|
comment?: string,
|
||||||
|
userId?: string
|
||||||
|
): Promise<StatsResponse> {
|
||||||
|
if (rating < 1 || rating > 5) {
|
||||||
|
return { success: false, error: '评分必须在 1-5 之间' }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const finalUserId = userId || getUserId()
|
||||||
|
|
||||||
|
const response = await fetch(`${STATS_API_BASE_URL}/stats/rate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ plugin_id: pluginId, rating, comment, user_id: finalUserId }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (response.status === 429) {
|
||||||
|
return { success: false, error: '每天最多评分 3 次' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return { success: false, error: data.error || '评分失败' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, ...data }
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error rating plugin:', error)
|
||||||
|
return { success: false, error: '网络错误' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录插件下载
|
||||||
|
*/
|
||||||
|
export async function recordPluginDownload(pluginId: string): Promise<StatsResponse> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${STATS_API_BASE_URL}/stats/download`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ plugin_id: pluginId }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (response.status === 429) {
|
||||||
|
// 下载统计被限流时静默失败,不影响用户体验
|
||||||
|
console.warn('Download recording rate limited')
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error('Failed to record download:', data.error)
|
||||||
|
return { success: false, error: data.error }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, ...data }
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error recording download:', error)
|
||||||
|
return { success: false, error: '网络错误' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成用户指纹(基于浏览器特征)
|
||||||
|
* 用于在未登录时识别用户,防止重复投票
|
||||||
|
*/
|
||||||
|
export function generateUserFingerprint(): string {
|
||||||
|
const nav = navigator as Navigator & { deviceMemory?: number }
|
||||||
|
const features = [
|
||||||
|
navigator.userAgent,
|
||||||
|
navigator.language,
|
||||||
|
navigator.languages?.join(',') || '',
|
||||||
|
navigator.platform,
|
||||||
|
navigator.hardwareConcurrency || 0,
|
||||||
|
screen.width,
|
||||||
|
screen.height,
|
||||||
|
screen.colorDepth,
|
||||||
|
screen.pixelDepth,
|
||||||
|
new Date().getTimezoneOffset(),
|
||||||
|
Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
navigator.maxTouchPoints || 0,
|
||||||
|
nav.deviceMemory || 0,
|
||||||
|
].join('|')
|
||||||
|
|
||||||
|
// 简单哈希函数
|
||||||
|
let hash = 0
|
||||||
|
for (let i = 0; i < features.length; i++) {
|
||||||
|
const char = features.charCodeAt(i)
|
||||||
|
hash = ((hash << 5) - hash) + char
|
||||||
|
hash = hash & hash // Convert to 32bit integer
|
||||||
|
}
|
||||||
|
|
||||||
|
return `fp_${Math.abs(hash).toString(36)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成或获取用户 UUID
|
||||||
|
* 存储在 localStorage 中持久化
|
||||||
|
*/
|
||||||
|
export function getUserId(): string {
|
||||||
|
const STORAGE_KEY = 'maibot_user_id'
|
||||||
|
|
||||||
|
// 尝试从 localStorage 获取
|
||||||
|
let userId = localStorage.getItem(STORAGE_KEY)
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
// 生成新的 UUID
|
||||||
|
const fingerprint = generateUserFingerprint()
|
||||||
|
const timestamp = Date.now().toString(36)
|
||||||
|
const random = Math.random().toString(36).substring(2, 15)
|
||||||
|
|
||||||
|
userId = `${fingerprint}_${timestamp}_${random}`
|
||||||
|
|
||||||
|
// 存储到 localStorage
|
||||||
|
localStorage.setItem(STORAGE_KEY, userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return userId
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,350 @@
|
||||||
|
/**
|
||||||
|
* 重启管理 Context
|
||||||
|
*
|
||||||
|
* 提供全局的重启状态管理和触发能力
|
||||||
|
* 使用方式:
|
||||||
|
* const { triggerRestart, isRestarting } = useRestart()
|
||||||
|
* triggerRestart() // 触发重启
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useState,
|
||||||
|
useCallback,
|
||||||
|
useRef,
|
||||||
|
type ReactNode,
|
||||||
|
} from 'react'
|
||||||
|
import { restartMaiBot } from './system-api'
|
||||||
|
|
||||||
|
// ============ 类型定义 ============
|
||||||
|
|
||||||
|
export type RestartStatus =
|
||||||
|
| 'idle'
|
||||||
|
| 'requesting'
|
||||||
|
| 'restarting'
|
||||||
|
| 'checking'
|
||||||
|
| 'success'
|
||||||
|
| 'failed'
|
||||||
|
|
||||||
|
export interface RestartState {
|
||||||
|
status: RestartStatus
|
||||||
|
progress: number
|
||||||
|
elapsedTime: number
|
||||||
|
checkAttempts: number
|
||||||
|
maxAttempts: number
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RestartContextValue {
|
||||||
|
/** 当前重启状态 */
|
||||||
|
state: RestartState
|
||||||
|
/** 是否正在重启中(任何非 idle 状态) */
|
||||||
|
isRestarting: boolean
|
||||||
|
/** 触发重启 */
|
||||||
|
triggerRestart: (options?: TriggerRestartOptions) => Promise<void>
|
||||||
|
/** 重置状态(用于失败后重试) */
|
||||||
|
resetState: () => void
|
||||||
|
/** 手动开始健康检查(用于重试) */
|
||||||
|
retryHealthCheck: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TriggerRestartOptions {
|
||||||
|
/** 重启前延迟(毫秒),用于显示提示 */
|
||||||
|
delay?: number
|
||||||
|
/** 自定义重启消息 */
|
||||||
|
message?: string
|
||||||
|
/** 跳过 API 调用(用于后端已触发重启的情况) */
|
||||||
|
skipApiCall?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 配置常量 ============
|
||||||
|
|
||||||
|
const CONFIG = {
|
||||||
|
/** 初始等待时间(毫秒),给后端重启时间 */
|
||||||
|
INITIAL_DELAY: 3000,
|
||||||
|
/** 健康检查间隔(毫秒) */
|
||||||
|
CHECK_INTERVAL: 2000,
|
||||||
|
/** 健康检查超时(毫秒) */
|
||||||
|
CHECK_TIMEOUT: 3000,
|
||||||
|
/** 最大检查次数 */
|
||||||
|
MAX_ATTEMPTS: 60,
|
||||||
|
/** 进度条更新间隔(毫秒) */
|
||||||
|
PROGRESS_INTERVAL: 200,
|
||||||
|
/** 成功后跳转延迟(毫秒) */
|
||||||
|
SUCCESS_REDIRECT_DELAY: 1500,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
// ============ Context ============
|
||||||
|
|
||||||
|
const RestartContext = createContext<RestartContextValue | null>(null)
|
||||||
|
|
||||||
|
// ============ Provider ============
|
||||||
|
|
||||||
|
interface RestartProviderProps {
|
||||||
|
children: ReactNode
|
||||||
|
/** 重启成功后的回调 */
|
||||||
|
onRestartComplete?: () => void
|
||||||
|
/** 重启失败后的回调 */
|
||||||
|
onRestartFailed?: (error: string) => void
|
||||||
|
/** 自定义健康检查 URL */
|
||||||
|
healthCheckUrl?: string
|
||||||
|
/** 自定义最大尝试次数 */
|
||||||
|
maxAttempts?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RestartProvider({
|
||||||
|
children,
|
||||||
|
onRestartComplete,
|
||||||
|
onRestartFailed,
|
||||||
|
healthCheckUrl = '/api/webui/system/status',
|
||||||
|
maxAttempts = CONFIG.MAX_ATTEMPTS,
|
||||||
|
}: RestartProviderProps) {
|
||||||
|
const [state, setState] = useState<RestartState>({
|
||||||
|
status: 'idle',
|
||||||
|
progress: 0,
|
||||||
|
elapsedTime: 0,
|
||||||
|
checkAttempts: 0,
|
||||||
|
maxAttempts,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 使用 useRef 存储定时器引用,避免闭包陷阱
|
||||||
|
const timersRef = useRef<{
|
||||||
|
progress?: ReturnType<typeof setInterval>
|
||||||
|
elapsed?: ReturnType<typeof setInterval>
|
||||||
|
check?: ReturnType<typeof setTimeout>
|
||||||
|
}>({})
|
||||||
|
|
||||||
|
// 清理所有定时器
|
||||||
|
const clearAllTimers = useCallback(() => {
|
||||||
|
const timers = timersRef.current
|
||||||
|
if (timers.progress) {
|
||||||
|
clearInterval(timers.progress)
|
||||||
|
timers.progress = undefined
|
||||||
|
}
|
||||||
|
if (timers.elapsed) {
|
||||||
|
clearInterval(timers.elapsed)
|
||||||
|
timers.elapsed = undefined
|
||||||
|
}
|
||||||
|
if (timers.check) {
|
||||||
|
clearTimeout(timers.check)
|
||||||
|
timers.check = undefined
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 重置状态
|
||||||
|
const resetState = useCallback(() => {
|
||||||
|
clearAllTimers()
|
||||||
|
setState({
|
||||||
|
status: 'idle',
|
||||||
|
progress: 0,
|
||||||
|
elapsedTime: 0,
|
||||||
|
checkAttempts: 0,
|
||||||
|
maxAttempts,
|
||||||
|
})
|
||||||
|
}, [clearAllTimers, maxAttempts])
|
||||||
|
|
||||||
|
// 健康检查
|
||||||
|
const checkHealth = useCallback(
|
||||||
|
async (): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeoutId = setTimeout(
|
||||||
|
() => controller.abort(),
|
||||||
|
CONFIG.CHECK_TIMEOUT
|
||||||
|
)
|
||||||
|
|
||||||
|
const response = await fetch(healthCheckUrl, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
return response.ok
|
||||||
|
} catch {
|
||||||
|
// 网络错误、超时等都视为服务不可用,这是正常的
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[healthCheckUrl]
|
||||||
|
)
|
||||||
|
|
||||||
|
// 开始健康检查循环
|
||||||
|
const startHealthCheck = useCallback(() => {
|
||||||
|
let currentAttempt = 0
|
||||||
|
|
||||||
|
const doCheck = async () => {
|
||||||
|
currentAttempt++
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
status: 'checking',
|
||||||
|
checkAttempts: currentAttempt,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const isHealthy = await checkHealth()
|
||||||
|
|
||||||
|
if (isHealthy) {
|
||||||
|
// 成功
|
||||||
|
clearAllTimers()
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
status: 'success',
|
||||||
|
progress: 100,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// 延迟后跳转
|
||||||
|
setTimeout(() => {
|
||||||
|
onRestartComplete?.()
|
||||||
|
// 默认跳转到 auth 页面
|
||||||
|
window.location.href = '/auth'
|
||||||
|
}, CONFIG.SUCCESS_REDIRECT_DELAY)
|
||||||
|
} else if (currentAttempt >= maxAttempts) {
|
||||||
|
// 失败
|
||||||
|
clearAllTimers()
|
||||||
|
const error = `健康检查超时 (${currentAttempt}/${maxAttempts})`
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
status: 'failed',
|
||||||
|
error,
|
||||||
|
}))
|
||||||
|
onRestartFailed?.(error)
|
||||||
|
} else {
|
||||||
|
// 继续检查
|
||||||
|
const checkTimer = setTimeout(doCheck, CONFIG.CHECK_INTERVAL)
|
||||||
|
timersRef.current.check = checkTimer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
doCheck()
|
||||||
|
}, [checkHealth, clearAllTimers, maxAttempts, onRestartComplete, onRestartFailed])
|
||||||
|
|
||||||
|
// 重试健康检查
|
||||||
|
const retryHealthCheck = useCallback(() => {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
status: 'checking',
|
||||||
|
checkAttempts: 0,
|
||||||
|
error: undefined,
|
||||||
|
}))
|
||||||
|
startHealthCheck()
|
||||||
|
}, [startHealthCheck])
|
||||||
|
|
||||||
|
// 触发重启
|
||||||
|
const triggerRestart = useCallback(
|
||||||
|
async (options?: TriggerRestartOptions) => {
|
||||||
|
const { delay = 0, skipApiCall = false } = options ?? {}
|
||||||
|
|
||||||
|
// 已经在重启中,忽略
|
||||||
|
if (state.status !== 'idle' && state.status !== 'failed') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置状态
|
||||||
|
clearAllTimers()
|
||||||
|
setState({
|
||||||
|
status: 'requesting',
|
||||||
|
progress: 0,
|
||||||
|
elapsedTime: 0,
|
||||||
|
checkAttempts: 0,
|
||||||
|
maxAttempts,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 可选延迟
|
||||||
|
if (delay > 0) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用重启 API
|
||||||
|
if (!skipApiCall) {
|
||||||
|
try {
|
||||||
|
setState((prev) => ({ ...prev, status: 'restarting' }))
|
||||||
|
// 重启 API 可能不返回响应(服务立即关闭)
|
||||||
|
await Promise.race([
|
||||||
|
restartMaiBot(),
|
||||||
|
// 5秒超时,超时也视为成功(服务已关闭)
|
||||||
|
new Promise((resolve) => setTimeout(resolve, 5000)),
|
||||||
|
])
|
||||||
|
} catch {
|
||||||
|
// API 调用失败也是正常的(服务已关闭)
|
||||||
|
// 继续进行健康检查
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setState((prev) => ({ ...prev, status: 'restarting' }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动进度条动画
|
||||||
|
const progressTimer = setInterval(() => {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
progress: prev.progress >= 90 ? prev.progress : prev.progress + 1,
|
||||||
|
}))
|
||||||
|
}, CONFIG.PROGRESS_INTERVAL)
|
||||||
|
|
||||||
|
// 启动计时器
|
||||||
|
const elapsedTimer = setInterval(() => {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
elapsedTime: prev.elapsedTime + 1,
|
||||||
|
}))
|
||||||
|
}, 1000)
|
||||||
|
|
||||||
|
timersRef.current.progress = progressTimer
|
||||||
|
timersRef.current.elapsed = elapsedTimer
|
||||||
|
|
||||||
|
// 延迟后开始健康检查
|
||||||
|
setTimeout(() => {
|
||||||
|
startHealthCheck()
|
||||||
|
}, CONFIG.INITIAL_DELAY)
|
||||||
|
},
|
||||||
|
[state.status, clearAllTimers, maxAttempts, startHealthCheck]
|
||||||
|
)
|
||||||
|
|
||||||
|
const contextValue: RestartContextValue = {
|
||||||
|
state,
|
||||||
|
isRestarting: state.status !== 'idle',
|
||||||
|
triggerRestart,
|
||||||
|
resetState,
|
||||||
|
retryHealthCheck,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RestartContext.Provider value={contextValue}>
|
||||||
|
{children}
|
||||||
|
</RestartContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Hook ============
|
||||||
|
|
||||||
|
export function useRestart(): RestartContextValue {
|
||||||
|
const context = useContext(RestartContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useRestart must be used within a RestartProvider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 便捷 Hook(无需 Provider) ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 独立的重启 Hook,不依赖 Provider
|
||||||
|
* 适用于只需要触发重启,不需要全局状态的场景
|
||||||
|
*/
|
||||||
|
export function useRestartAction() {
|
||||||
|
const [isRestarting, setIsRestarting] = useState(false)
|
||||||
|
|
||||||
|
const triggerRestart = useCallback(async () => {
|
||||||
|
if (isRestarting) return
|
||||||
|
|
||||||
|
setIsRestarting(true)
|
||||||
|
try {
|
||||||
|
await restartMaiBot()
|
||||||
|
} catch {
|
||||||
|
// 忽略错误,服务可能已关闭
|
||||||
|
}
|
||||||
|
}, [isRestarting])
|
||||||
|
|
||||||
|
return { isRestarting, triggerRestart }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,284 @@
|
||||||
|
/**
|
||||||
|
* 前端设置管理器
|
||||||
|
* 统一管理所有前端 localStorage 设置
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 所有设置的 key 定义
|
||||||
|
export const STORAGE_KEYS = {
|
||||||
|
// 外观设置
|
||||||
|
/** @deprecated 使用新的主题系统 — 见 @/lib/theme/storage.ts 的 THEME_STORAGE_KEYS.MODE */
|
||||||
|
THEME: 'maibot-ui-theme',
|
||||||
|
/** @deprecated 使用新的主题系统 — 见 @/lib/theme/storage.ts 的 THEME_STORAGE_KEYS.ACCENT */
|
||||||
|
ACCENT_COLOR: 'accent-color',
|
||||||
|
ENABLE_ANIMATIONS: 'maibot-animations',
|
||||||
|
ENABLE_WAVES_BACKGROUND: 'maibot-waves-background',
|
||||||
|
|
||||||
|
// 性能与存储设置
|
||||||
|
LOG_CACHE_SIZE: 'maibot-log-cache-size',
|
||||||
|
LOG_AUTO_SCROLL: 'maibot-log-auto-scroll',
|
||||||
|
LOG_FONT_SIZE: 'maibot-log-font-size',
|
||||||
|
LOG_LINE_SPACING: 'maibot-log-line-spacing',
|
||||||
|
DATA_SYNC_INTERVAL: 'maibot-data-sync-interval',
|
||||||
|
WS_RECONNECT_INTERVAL: 'maibot-ws-reconnect-interval',
|
||||||
|
WS_MAX_RECONNECT_ATTEMPTS: 'maibot-ws-max-reconnect-attempts',
|
||||||
|
|
||||||
|
// 用户数据
|
||||||
|
// 注意:ACCESS_TOKEN 已弃用,现在使用 HttpOnly Cookie 存储认证信息
|
||||||
|
// 保留此常量仅用于向后兼容和清理旧数据
|
||||||
|
ACCESS_TOKEN: 'access-token',
|
||||||
|
COMPLETED_TOURS: 'maibot-completed-tours',
|
||||||
|
CHAT_USER_ID: 'maibot_webui_user_id',
|
||||||
|
CHAT_USER_NAME: 'maibot_webui_user_name',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
// 默认设置值
|
||||||
|
export const DEFAULT_SETTINGS = {
|
||||||
|
// 外观
|
||||||
|
theme: 'system' as 'light' | 'dark' | 'system',
|
||||||
|
accentColor: 'blue',
|
||||||
|
enableAnimations: true,
|
||||||
|
enableWavesBackground: true,
|
||||||
|
|
||||||
|
// 性能与存储
|
||||||
|
logCacheSize: 1000,
|
||||||
|
logAutoScroll: true,
|
||||||
|
logFontSize: 'xs' as 'xs' | 'sm' | 'base',
|
||||||
|
logLineSpacing: 4,
|
||||||
|
dataSyncInterval: 30, // 秒
|
||||||
|
wsReconnectInterval: 3000, // 毫秒
|
||||||
|
wsMaxReconnectAttempts: 10,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置类型定义
|
||||||
|
export type Settings = typeof DEFAULT_SETTINGS
|
||||||
|
|
||||||
|
// 可导出的设置(不包含敏感信息)
|
||||||
|
export type ExportableSettings = Omit<Settings, never> & {
|
||||||
|
completedTours?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取单个设置值
|
||||||
|
*/
|
||||||
|
export function getSetting<K extends keyof Settings>(key: K): Settings[K] {
|
||||||
|
const storageKey = getStorageKey(key)
|
||||||
|
const stored = localStorage.getItem(storageKey)
|
||||||
|
|
||||||
|
if (stored === null) {
|
||||||
|
return DEFAULT_SETTINGS[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据默认值类型进行转换
|
||||||
|
const defaultValue = DEFAULT_SETTINGS[key]
|
||||||
|
|
||||||
|
if (typeof defaultValue === 'boolean') {
|
||||||
|
return (stored === 'true') as Settings[K]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof defaultValue === 'number') {
|
||||||
|
const num = parseFloat(stored)
|
||||||
|
return (isNaN(num) ? defaultValue : num) as Settings[K]
|
||||||
|
}
|
||||||
|
|
||||||
|
return stored as Settings[K]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置单个值
|
||||||
|
*/
|
||||||
|
export function setSetting<K extends keyof Settings>(key: K, value: Settings[K]): void {
|
||||||
|
const storageKey = getStorageKey(key)
|
||||||
|
localStorage.setItem(storageKey, String(value))
|
||||||
|
|
||||||
|
// 触发自定义事件,通知其他组件设置已更新
|
||||||
|
window.dispatchEvent(new CustomEvent('maibot-settings-change', {
|
||||||
|
detail: { key, value }
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有设置
|
||||||
|
*/
|
||||||
|
export function getAllSettings(): Settings {
|
||||||
|
return {
|
||||||
|
theme: getSetting('theme'),
|
||||||
|
accentColor: getSetting('accentColor'),
|
||||||
|
enableAnimations: getSetting('enableAnimations'),
|
||||||
|
enableWavesBackground: getSetting('enableWavesBackground'),
|
||||||
|
logCacheSize: getSetting('logCacheSize'),
|
||||||
|
logAutoScroll: getSetting('logAutoScroll'),
|
||||||
|
logFontSize: getSetting('logFontSize'),
|
||||||
|
logLineSpacing: getSetting('logLineSpacing'),
|
||||||
|
dataSyncInterval: getSetting('dataSyncInterval'),
|
||||||
|
wsReconnectInterval: getSetting('wsReconnectInterval'),
|
||||||
|
wsMaxReconnectAttempts: getSetting('wsMaxReconnectAttempts'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出设置(用于备份)
|
||||||
|
*/
|
||||||
|
export function exportSettings(): ExportableSettings {
|
||||||
|
const settings = getAllSettings()
|
||||||
|
|
||||||
|
// 添加已完成的引导
|
||||||
|
const completedToursStr = localStorage.getItem(STORAGE_KEYS.COMPLETED_TOURS)
|
||||||
|
const completedTours = completedToursStr ? JSON.parse(completedToursStr) : []
|
||||||
|
|
||||||
|
return {
|
||||||
|
...settings,
|
||||||
|
completedTours,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导入设置
|
||||||
|
*/
|
||||||
|
export function importSettings(settings: Partial<ExportableSettings>): { success: boolean; imported: string[]; skipped: string[] } {
|
||||||
|
const imported: string[] = []
|
||||||
|
const skipped: string[] = []
|
||||||
|
|
||||||
|
// 验证并导入每个设置
|
||||||
|
for (const [key, value] of Object.entries(settings)) {
|
||||||
|
if (key === 'completedTours') {
|
||||||
|
// 特殊处理已完成的引导
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
localStorage.setItem(STORAGE_KEYS.COMPLETED_TOURS, JSON.stringify(value))
|
||||||
|
imported.push('completedTours')
|
||||||
|
} else {
|
||||||
|
skipped.push('completedTours')
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key in DEFAULT_SETTINGS) {
|
||||||
|
const settingKey = key as keyof Settings
|
||||||
|
const defaultValue = DEFAULT_SETTINGS[settingKey]
|
||||||
|
|
||||||
|
// 类型验证
|
||||||
|
if (typeof value === typeof defaultValue) {
|
||||||
|
// 额外验证
|
||||||
|
if (settingKey === 'theme' && !['light', 'dark', 'system'].includes(value as string)) {
|
||||||
|
skipped.push(key)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (settingKey === 'logFontSize' && !['xs', 'sm', 'base'].includes(value as string)) {
|
||||||
|
skipped.push(key)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
setSetting(settingKey, value as Settings[typeof settingKey])
|
||||||
|
imported.push(key)
|
||||||
|
} else {
|
||||||
|
skipped.push(key)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
skipped.push(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: imported.length > 0,
|
||||||
|
imported,
|
||||||
|
skipped,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重置所有设置为默认值
|
||||||
|
*/
|
||||||
|
export function resetAllSettings(): void {
|
||||||
|
for (const key of Object.keys(DEFAULT_SETTINGS) as (keyof Settings)[]) {
|
||||||
|
setSetting(key, DEFAULT_SETTINGS[key])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除已完成的引导
|
||||||
|
localStorage.removeItem(STORAGE_KEYS.COMPLETED_TOURS)
|
||||||
|
|
||||||
|
// 触发全局事件
|
||||||
|
window.dispatchEvent(new CustomEvent('maibot-settings-reset'))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除所有本地缓存
|
||||||
|
* 注意:认证信息现在存储在 HttpOnly Cookie 中,不受此函数影响
|
||||||
|
*/
|
||||||
|
export function clearLocalCache(): { clearedKeys: string[]; preservedKeys: string[] } {
|
||||||
|
const clearedKeys: string[] = []
|
||||||
|
const preservedKeys: string[] = []
|
||||||
|
|
||||||
|
// 遍历所有 localStorage 项
|
||||||
|
const keysToRemove: string[] = []
|
||||||
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
|
const key = localStorage.key(i)
|
||||||
|
if (key) {
|
||||||
|
if (key.startsWith('maibot') || key.startsWith('accent-color') || key === 'access-token') {
|
||||||
|
keysToRemove.push(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除需要清除的 key
|
||||||
|
for (const key of keysToRemove) {
|
||||||
|
localStorage.removeItem(key)
|
||||||
|
clearedKeys.push(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { clearedKeys, preservedKeys }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取本地存储使用情况
|
||||||
|
*/
|
||||||
|
export function getStorageUsage(): { used: number; items: number; details: { key: string; size: number }[] } {
|
||||||
|
let totalSize = 0
|
||||||
|
const details: { key: string; size: number }[] = []
|
||||||
|
|
||||||
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
|
const key = localStorage.key(i)
|
||||||
|
if (key) {
|
||||||
|
const value = localStorage.getItem(key) || ''
|
||||||
|
const size = (key.length + value.length) * 2 // UTF-16 编码,每个字符 2 字节
|
||||||
|
totalSize += size
|
||||||
|
details.push({ key, size })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按大小排序
|
||||||
|
details.sort((a, b) => b.size - a.size)
|
||||||
|
|
||||||
|
return {
|
||||||
|
used: totalSize,
|
||||||
|
items: localStorage.length,
|
||||||
|
details,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化字节大小
|
||||||
|
*/
|
||||||
|
export function formatBytes(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 B'
|
||||||
|
const k = 1024
|
||||||
|
const sizes = ['B', 'KB', 'MB']
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 内部辅助函数:获取 localStorage key
|
||||||
|
function getStorageKey(settingKey: keyof Settings): string {
|
||||||
|
const keyMap: Record<keyof Settings, string> = {
|
||||||
|
theme: STORAGE_KEYS.THEME,
|
||||||
|
accentColor: STORAGE_KEYS.ACCENT_COLOR,
|
||||||
|
enableAnimations: STORAGE_KEYS.ENABLE_ANIMATIONS,
|
||||||
|
enableWavesBackground: STORAGE_KEYS.ENABLE_WAVES_BACKGROUND,
|
||||||
|
logCacheSize: STORAGE_KEYS.LOG_CACHE_SIZE,
|
||||||
|
logAutoScroll: STORAGE_KEYS.LOG_AUTO_SCROLL,
|
||||||
|
logFontSize: STORAGE_KEYS.LOG_FONT_SIZE,
|
||||||
|
logLineSpacing: STORAGE_KEYS.LOG_LINE_SPACING,
|
||||||
|
dataSyncInterval: STORAGE_KEYS.DATA_SYNC_INTERVAL,
|
||||||
|
wsReconnectInterval: STORAGE_KEYS.WS_RECONNECT_INTERVAL,
|
||||||
|
wsMaxReconnectAttempts: STORAGE_KEYS.WS_MAX_RECONNECT_ATTEMPTS,
|
||||||
|
}
|
||||||
|
return keyMap[settingKey]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,176 @@
|
||||||
|
/**
|
||||||
|
* 问卷调查 API 客户端
|
||||||
|
* 用于与 Cloudflare Workers 问卷服务交互
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
SurveySubmission,
|
||||||
|
StoredSubmission,
|
||||||
|
SurveyStats,
|
||||||
|
SurveySubmitResponse,
|
||||||
|
SurveyStatsResponse,
|
||||||
|
UserSubmissionsResponse,
|
||||||
|
QuestionAnswer
|
||||||
|
} from '@/types/survey'
|
||||||
|
|
||||||
|
// 配置统计服务 API 地址
|
||||||
|
const STATS_API_BASE_URL = 'https://maibot-plugin-stats.maibot-webui.workers.dev'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成或获取用户ID
|
||||||
|
*/
|
||||||
|
export function getUserId(): string {
|
||||||
|
const storageKey = 'maibot_user_id'
|
||||||
|
let userId = localStorage.getItem(storageKey)
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
// 生成新的用户ID: fp_{fingerprint}_{timestamp}_{random}
|
||||||
|
const fingerprint = Math.random().toString(36).substring(2, 10)
|
||||||
|
const timestamp = Date.now().toString(36)
|
||||||
|
const random = Math.random().toString(36).substring(2, 10)
|
||||||
|
userId = `fp_${fingerprint}_${timestamp}_${random}`
|
||||||
|
localStorage.setItem(storageKey, userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return userId
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提交问卷
|
||||||
|
*/
|
||||||
|
export async function submitSurvey(
|
||||||
|
surveyId: string,
|
||||||
|
surveyVersion: string,
|
||||||
|
answers: QuestionAnswer[],
|
||||||
|
options?: {
|
||||||
|
allowMultiple?: boolean
|
||||||
|
userId?: string
|
||||||
|
}
|
||||||
|
): Promise<SurveySubmitResponse> {
|
||||||
|
try {
|
||||||
|
const userId = options?.userId || getUserId()
|
||||||
|
|
||||||
|
const submission: SurveySubmission & { allowMultiple?: boolean } = {
|
||||||
|
surveyId,
|
||||||
|
surveyVersion,
|
||||||
|
userId,
|
||||||
|
answers,
|
||||||
|
submittedAt: new Date().toISOString(),
|
||||||
|
allowMultiple: options?.allowMultiple,
|
||||||
|
metadata: {
|
||||||
|
userAgent: navigator.userAgent,
|
||||||
|
language: navigator.language
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${STATS_API_BASE_URL}/survey/submit`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(submission),
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (response.status === 429) {
|
||||||
|
return { success: false, error: '提交过于频繁,请稍后再试' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 409) {
|
||||||
|
return { success: false, error: data.error || '你已经提交过这份问卷了' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return { success: false, error: data.error || '提交失败' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
submissionId: data.submissionId,
|
||||||
|
message: data.message
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error submitting survey:', error)
|
||||||
|
return { success: false, error: '网络错误' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取问卷统计数据
|
||||||
|
*/
|
||||||
|
export async function getSurveyStats(surveyId: string): Promise<SurveyStatsResponse> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${STATS_API_BASE_URL}/survey/stats/${surveyId}`)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
return { success: false, error: data.error || '获取统计数据失败' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
return { success: true, stats: data.stats as SurveyStats }
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching survey stats:', error)
|
||||||
|
return { success: false, error: '网络错误' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户提交记录
|
||||||
|
*/
|
||||||
|
export async function getUserSubmissions(
|
||||||
|
surveyId?: string,
|
||||||
|
userId?: string
|
||||||
|
): Promise<UserSubmissionsResponse> {
|
||||||
|
try {
|
||||||
|
const finalUserId = userId || getUserId()
|
||||||
|
const params = new URLSearchParams({ user_id: finalUserId })
|
||||||
|
|
||||||
|
if (surveyId) {
|
||||||
|
params.append('survey_id', surveyId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${STATS_API_BASE_URL}/survey/submissions?${params}`)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
return { success: false, error: data.error || '获取提交记录失败' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
return { success: true, submissions: data.submissions as StoredSubmission[] }
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching user submissions:', error)
|
||||||
|
return { success: false, error: '网络错误' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查用户是否已提交问卷
|
||||||
|
*/
|
||||||
|
export async function checkUserSubmission(
|
||||||
|
surveyId: string,
|
||||||
|
userId?: string
|
||||||
|
): Promise<{ success: boolean; hasSubmitted?: boolean; error?: string }> {
|
||||||
|
try {
|
||||||
|
const finalUserId = userId || getUserId()
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
user_id: finalUserId,
|
||||||
|
survey_id: surveyId
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await fetch(`${STATS_API_BASE_URL}/survey/check?${params}`)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
return { success: false, error: data.error || '检查失败' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
return { success: true, hasSubmitted: data.hasSubmitted }
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking submission:', error)
|
||||||
|
return { success: false, error: '网络错误' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { fetchWithAuth, getAuthHeaders } from './fetch-with-auth'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 系统控制 API
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重启麦麦主程序
|
||||||
|
*/
|
||||||
|
export async function restartMaiBot(): Promise<{ success: boolean; message: string }> {
|
||||||
|
const response = await fetchWithAuth('/api/webui/system/restart', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '重启失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查麦麦运行状态
|
||||||
|
*/
|
||||||
|
export async function getMaiBotStatus(): Promise<{
|
||||||
|
running: boolean
|
||||||
|
uptime: number
|
||||||
|
version: string
|
||||||
|
start_time: string
|
||||||
|
}> {
|
||||||
|
const response = await fetchWithAuth('/api/webui/system/status', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || '获取状态失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { createContext } from 'react'
|
||||||
|
|
||||||
|
import type { UserThemeConfig } from './theme/tokens'
|
||||||
|
|
||||||
|
type Theme = 'dark' | 'light' | 'system'
|
||||||
|
|
||||||
|
export type ThemeProviderState = {
|
||||||
|
theme: Theme
|
||||||
|
resolvedTheme: 'dark' | 'light'
|
||||||
|
setTheme: (theme: Theme) => void
|
||||||
|
themeConfig: UserThemeConfig
|
||||||
|
updateThemeConfig: (partial: Partial<UserThemeConfig>) => void
|
||||||
|
resetTheme: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: ThemeProviderState = {
|
||||||
|
theme: 'system',
|
||||||
|
resolvedTheme: 'light',
|
||||||
|
setTheme: () => null,
|
||||||
|
themeConfig: {
|
||||||
|
selectedPreset: 'light',
|
||||||
|
accentColor: '',
|
||||||
|
tokenOverrides: {},
|
||||||
|
customCSS: '',
|
||||||
|
},
|
||||||
|
updateThemeConfig: () => null,
|
||||||
|
resetTheme: () => null,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
|
||||||
|
|
@ -0,0 +1,203 @@
|
||||||
|
import type { ColorTokens } from './tokens'
|
||||||
|
|
||||||
|
type HSL = {
|
||||||
|
h: number
|
||||||
|
s: number
|
||||||
|
l: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const clamp = (value: number, min: number, max: number): number => {
|
||||||
|
if (value < min) return min
|
||||||
|
if (value > max) return max
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
const roundToTenth = (value: number): number => Math.round(value * 10) / 10
|
||||||
|
|
||||||
|
const wrapHue = (value: number): number => ((value % 360) + 360) % 360
|
||||||
|
|
||||||
|
export const parseHSL = (hslStr: string): HSL => {
|
||||||
|
const cleaned = hslStr
|
||||||
|
.trim()
|
||||||
|
.replace(/^hsl\(/i, '')
|
||||||
|
.replace(/\)$/i, '')
|
||||||
|
.replace(/,/g, ' ')
|
||||||
|
const parts = cleaned.split(/\s+/).filter(Boolean)
|
||||||
|
const rawH = parts[0] ?? '0'
|
||||||
|
const rawS = parts[1] ?? '0%'
|
||||||
|
const rawL = parts[2] ?? '0%'
|
||||||
|
|
||||||
|
const h = Number.parseFloat(rawH)
|
||||||
|
const s = Number.parseFloat(rawS.replace('%', ''))
|
||||||
|
const l = Number.parseFloat(rawL.replace('%', ''))
|
||||||
|
|
||||||
|
return {
|
||||||
|
h: Number.isNaN(h) ? 0 : h,
|
||||||
|
s: Number.isNaN(s) ? 0 : s,
|
||||||
|
l: Number.isNaN(l) ? 0 : l,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formatHSL = (h: number, s: number, l: number): string => {
|
||||||
|
const safeH = roundToTenth(wrapHue(h))
|
||||||
|
const safeS = roundToTenth(clamp(s, 0, 100))
|
||||||
|
const safeL = roundToTenth(clamp(l, 0, 100))
|
||||||
|
return `${safeH} ${safeS}% ${safeL}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const hexToHSL = (hex: string): string => {
|
||||||
|
let cleaned = hex.trim().replace('#', '')
|
||||||
|
if (cleaned.length === 3) {
|
||||||
|
cleaned = cleaned
|
||||||
|
.split('')
|
||||||
|
.map((char) => `${char}${char}`)
|
||||||
|
.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cleaned.length !== 6) {
|
||||||
|
return formatHSL(0, 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const r = Number.parseInt(cleaned.slice(0, 2), 16) / 255
|
||||||
|
const g = Number.parseInt(cleaned.slice(2, 4), 16) / 255
|
||||||
|
const b = Number.parseInt(cleaned.slice(4, 6), 16) / 255
|
||||||
|
|
||||||
|
const max = Math.max(r, g, b)
|
||||||
|
const min = Math.min(r, g, b)
|
||||||
|
const delta = max - min
|
||||||
|
const l = (max + min) / 2
|
||||||
|
|
||||||
|
let h = 0
|
||||||
|
let s = 0
|
||||||
|
|
||||||
|
if (delta !== 0) {
|
||||||
|
s = l > 0.5 ? delta / (2 - max - min) : delta / (max + min)
|
||||||
|
switch (max) {
|
||||||
|
case r:
|
||||||
|
h = (g - b) / delta + (g < b ? 6 : 0)
|
||||||
|
break
|
||||||
|
case g:
|
||||||
|
h = (b - r) / delta + 2
|
||||||
|
break
|
||||||
|
case b:
|
||||||
|
h = (r - g) / delta + 4
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
h *= 60
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatHSL(h, s * 100, l * 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const adjustLightness = (hsl: string, amount: number): string => {
|
||||||
|
const { h, s, l } = parseHSL(hsl)
|
||||||
|
return formatHSL(h, s, l + amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const adjustSaturation = (hsl: string, amount: number): string => {
|
||||||
|
const { h, s, l } = parseHSL(hsl)
|
||||||
|
return formatHSL(h, s + amount, l)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const rotateHue = (hsl: string, degrees: number): string => {
|
||||||
|
const { h, s, l } = parseHSL(hsl)
|
||||||
|
return formatHSL(h + degrees, s, l)
|
||||||
|
}
|
||||||
|
|
||||||
|
const setLightness = (hsl: string, lightness: number): string => {
|
||||||
|
const { h, s } = parseHSL(hsl)
|
||||||
|
return formatHSL(h, s, lightness)
|
||||||
|
}
|
||||||
|
|
||||||
|
const setSaturation = (hsl: string, saturation: number): string => {
|
||||||
|
const { h, l } = parseHSL(hsl)
|
||||||
|
return formatHSL(h, saturation, l)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getReadableForeground = (hsl: string): string => {
|
||||||
|
const { h, s, l } = parseHSL(hsl)
|
||||||
|
const neutralSaturation = clamp(s * 0.15, 6, 20)
|
||||||
|
return l > 60
|
||||||
|
? formatHSL(h, neutralSaturation, 10)
|
||||||
|
: formatHSL(h, neutralSaturation, 96)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const generatePalette = (accentHSL: string, isDark: boolean): ColorTokens => {
|
||||||
|
const accent = parseHSL(accentHSL)
|
||||||
|
const primary = formatHSL(accent.h, accent.s, accent.l)
|
||||||
|
|
||||||
|
const background = isDark ? '222.2 84% 4.9%' : '0 0% 100%'
|
||||||
|
const foreground = isDark ? '210 40% 98%' : '222.2 84% 4.9%'
|
||||||
|
|
||||||
|
const secondary = formatHSL(
|
||||||
|
accent.h,
|
||||||
|
clamp(accent.s * 0.35, 8, 40),
|
||||||
|
isDark ? 17.5 : 96,
|
||||||
|
)
|
||||||
|
|
||||||
|
const muted = formatHSL(
|
||||||
|
accent.h,
|
||||||
|
clamp(accent.s * 0.12, 2, 18),
|
||||||
|
isDark ? 17.5 : 96,
|
||||||
|
)
|
||||||
|
|
||||||
|
const accentVariant = formatHSL(
|
||||||
|
accent.h + 35,
|
||||||
|
clamp(accent.s * 0.6, 20, 85),
|
||||||
|
isDark ? clamp(accent.l * 0.6 + 8, 25, 60) : clamp(accent.l * 0.8 + 14, 40, 75),
|
||||||
|
)
|
||||||
|
|
||||||
|
const destructive = formatHSL(
|
||||||
|
0,
|
||||||
|
clamp(accent.s, 60, 90),
|
||||||
|
isDark ? 30.6 : 60.2,
|
||||||
|
)
|
||||||
|
|
||||||
|
const border = formatHSL(
|
||||||
|
accent.h,
|
||||||
|
clamp(accent.s * 0.2, 5, 25),
|
||||||
|
isDark ? 17.5 : 91.4,
|
||||||
|
)
|
||||||
|
|
||||||
|
const mutedForeground = setSaturation(
|
||||||
|
setLightness(muted, isDark ? 65.1 : 46.9),
|
||||||
|
clamp(accent.s * 0.2, 10, 30),
|
||||||
|
)
|
||||||
|
|
||||||
|
const chartBase = formatHSL(accent.h, accent.s, accent.l)
|
||||||
|
const chartSteps = [0, 72, 144, 216, 288]
|
||||||
|
const charts = chartSteps.map((step) => rotateHue(chartBase, step))
|
||||||
|
|
||||||
|
const card = adjustLightness(background, isDark ? 2 : -1)
|
||||||
|
const popover = adjustLightness(background, isDark ? 3 : -0.5)
|
||||||
|
|
||||||
|
return {
|
||||||
|
primary,
|
||||||
|
'primary-foreground': getReadableForeground(primary),
|
||||||
|
'primary-gradient': 'none',
|
||||||
|
secondary,
|
||||||
|
'secondary-foreground': getReadableForeground(secondary),
|
||||||
|
muted,
|
||||||
|
'muted-foreground': mutedForeground,
|
||||||
|
accent: accentVariant,
|
||||||
|
'accent-foreground': getReadableForeground(accentVariant),
|
||||||
|
destructive,
|
||||||
|
'destructive-foreground': getReadableForeground(destructive),
|
||||||
|
background,
|
||||||
|
foreground,
|
||||||
|
card,
|
||||||
|
'card-foreground': foreground,
|
||||||
|
popover,
|
||||||
|
'popover-foreground': foreground,
|
||||||
|
border,
|
||||||
|
input: border,
|
||||||
|
ring: primary,
|
||||||
|
'chart-1': charts[0],
|
||||||
|
'chart-2': charts[1],
|
||||||
|
'chart-3': charts[2],
|
||||||
|
'chart-4': charts[3],
|
||||||
|
'chart-5': charts[4],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,194 @@
|
||||||
|
import type { ThemeTokens, UserThemeConfig } from './tokens'
|
||||||
|
|
||||||
|
import { generatePalette } from './palette'
|
||||||
|
import { getPresetById } from './presets'
|
||||||
|
import { sanitizeCSS } from './sanitizer'
|
||||||
|
import { defaultDarkTokens, defaultLightTokens, tokenToCSSVarName } from './tokens'
|
||||||
|
|
||||||
|
const CUSTOM_CSS_ID = 'maibot-custom-css'
|
||||||
|
const COMPONENT_CSS_ID_PREFIX = 'maibot-bg-css-'
|
||||||
|
const COMPONENT_IDS = ['page', 'sidebar', 'header', 'card', 'dialog'] as const
|
||||||
|
|
||||||
|
const mergeTokens = (base: ThemeTokens, overrides: Partial<ThemeTokens>): ThemeTokens => {
|
||||||
|
return {
|
||||||
|
color: {
|
||||||
|
...base.color,
|
||||||
|
...(overrides.color ?? {}),
|
||||||
|
},
|
||||||
|
typography: {
|
||||||
|
...base.typography,
|
||||||
|
...(overrides.typography ?? {}),
|
||||||
|
},
|
||||||
|
visual: {
|
||||||
|
...base.visual,
|
||||||
|
...(overrides.visual ?? {}),
|
||||||
|
},
|
||||||
|
layout: {
|
||||||
|
...base.layout,
|
||||||
|
...(overrides.layout ?? {}),
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
...base.animation,
|
||||||
|
...(overrides.animation ?? {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildTokens = (config: UserThemeConfig, isDark: boolean): ThemeTokens => {
|
||||||
|
const baseTokens = isDark ? defaultDarkTokens : defaultLightTokens
|
||||||
|
let mergedTokens = mergeTokens(baseTokens, {})
|
||||||
|
|
||||||
|
if (config.accentColor) {
|
||||||
|
const paletteTokens = generatePalette(config.accentColor, isDark)
|
||||||
|
mergedTokens = mergeTokens(mergedTokens, { color: paletteTokens })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.selectedPreset) {
|
||||||
|
const preset = getPresetById(config.selectedPreset)
|
||||||
|
if (preset?.tokens) {
|
||||||
|
mergedTokens = mergeTokens(mergedTokens, preset.tokens)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.tokenOverrides) {
|
||||||
|
mergedTokens = mergeTokens(mergedTokens, config.tokenOverrides)
|
||||||
|
}
|
||||||
|
|
||||||
|
return mergedTokens
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getComputedTokens(config: UserThemeConfig, isDark: boolean): ThemeTokens {
|
||||||
|
return buildTokens(config, isDark)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function injectTokensAsCSS(tokens: ThemeTokens, target: HTMLElement): void {
|
||||||
|
Object.entries(tokens.color).forEach(([key, value]) => {
|
||||||
|
target.style.setProperty(tokenToCSSVarName('color', key), String(value))
|
||||||
|
})
|
||||||
|
|
||||||
|
Object.entries(tokens.typography).forEach(([key, value]) => {
|
||||||
|
target.style.setProperty(tokenToCSSVarName('typography', key), String(value))
|
||||||
|
})
|
||||||
|
|
||||||
|
Object.entries(tokens.visual).forEach(([key, value]) => {
|
||||||
|
target.style.setProperty(tokenToCSSVarName('visual', key), String(value))
|
||||||
|
})
|
||||||
|
|
||||||
|
Object.entries(tokens.layout).forEach(([key, value]) => {
|
||||||
|
target.style.setProperty(tokenToCSSVarName('layout', key), String(value))
|
||||||
|
})
|
||||||
|
|
||||||
|
Object.entries(tokens.animation).forEach(([key, value]) => {
|
||||||
|
target.style.setProperty(tokenToCSSVarName('animation', key), String(value))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function injectCustomCSS(css: string): void {
|
||||||
|
if (css.trim().length === 0) {
|
||||||
|
removeCustomCSS()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = document.getElementById(CUSTOM_CSS_ID)
|
||||||
|
if (existing) {
|
||||||
|
existing.textContent = css
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const style = document.createElement('style')
|
||||||
|
style.id = CUSTOM_CSS_ID
|
||||||
|
style.textContent = css
|
||||||
|
document.head.appendChild(style)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeCustomCSS(): void {
|
||||||
|
const existing = document.getElementById(CUSTOM_CSS_ID)
|
||||||
|
if (existing) {
|
||||||
|
existing.remove()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为指定组件注入自定义 CSS
|
||||||
|
* 使用独立的 style 标签,CSS 经过 sanitize 处理
|
||||||
|
* @param css - 要注入的 CSS 字符串
|
||||||
|
* @param componentId - 组件标识符 (page/sidebar/header/card/dialog)
|
||||||
|
*/
|
||||||
|
export function injectComponentCSS(css: string, componentId: string): void {
|
||||||
|
const styleId = `${COMPONENT_CSS_ID_PREFIX}${componentId}`
|
||||||
|
|
||||||
|
if (css.trim().length === 0) {
|
||||||
|
removeComponentCSS(componentId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitized = sanitizeCSS(css)
|
||||||
|
const sanitizedCss = sanitized.css
|
||||||
|
|
||||||
|
if (sanitizedCss.trim().length === 0) {
|
||||||
|
removeComponentCSS(componentId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = document.getElementById(styleId)
|
||||||
|
if (existing) {
|
||||||
|
existing.textContent = sanitizedCss
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const style = document.createElement('style')
|
||||||
|
style.id = styleId
|
||||||
|
style.textContent = sanitizedCss
|
||||||
|
document.head.appendChild(style)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移除指定组件的自定义 CSS
|
||||||
|
*/
|
||||||
|
export function removeComponentCSS(componentId: string): void {
|
||||||
|
const styleId = `${COMPONENT_CSS_ID_PREFIX}${componentId}`
|
||||||
|
document.getElementById(styleId)?.remove()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移除所有组件的自定义 CSS
|
||||||
|
*/
|
||||||
|
export function removeAllComponentCSS(): void {
|
||||||
|
COMPONENT_IDS.forEach(removeComponentCSS)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyThemePipeline(config: UserThemeConfig, isDark: boolean): void {
|
||||||
|
const root = document.documentElement
|
||||||
|
const tokens = buildTokens(config, isDark)
|
||||||
|
injectTokensAsCSS(tokens, root)
|
||||||
|
if (config.customCSS) {
|
||||||
|
const sanitized = sanitizeCSS(config.customCSS)
|
||||||
|
if (sanitized.css.trim().length > 0) {
|
||||||
|
injectCustomCSS(sanitized.css)
|
||||||
|
} else {
|
||||||
|
removeCustomCSS()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
removeCustomCSS()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 应用组件级 CSS(注入顺序在全局 CSS 之后)
|
||||||
|
if (config.backgroundConfig) {
|
||||||
|
const { page, sidebar, header, card, dialog } = config.backgroundConfig
|
||||||
|
;[
|
||||||
|
['page', page],
|
||||||
|
['sidebar', sidebar],
|
||||||
|
['header', header],
|
||||||
|
['card', card],
|
||||||
|
['dialog', dialog],
|
||||||
|
].forEach(([id, cfg]) => {
|
||||||
|
if (cfg && typeof cfg === 'object' && 'customCSS' in cfg && cfg.customCSS) {
|
||||||
|
injectComponentCSS(cfg.customCSS, id as string)
|
||||||
|
} else {
|
||||||
|
removeComponentCSS(id as string)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
removeAllComponentCSS()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
/**
|
||||||
|
* Theme Presets 定义
|
||||||
|
* 提供内置的亮色和暗色主题预设
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
defaultDarkTokens,
|
||||||
|
defaultLightTokens,
|
||||||
|
} from './tokens'
|
||||||
|
import type { ThemePreset } from './tokens'
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Default Light Preset
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const defaultLightPreset: ThemePreset = {
|
||||||
|
id: 'light',
|
||||||
|
name: '默认亮色',
|
||||||
|
description: '默认亮色主题',
|
||||||
|
tokens: defaultLightTokens,
|
||||||
|
isDark: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Default Dark Preset
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const defaultDarkPreset: ThemePreset = {
|
||||||
|
id: 'dark',
|
||||||
|
name: '默认暗色',
|
||||||
|
description: '默认暗色主题',
|
||||||
|
tokens: defaultDarkTokens,
|
||||||
|
isDark: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Built-in Presets Collection
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const builtInPresets: ThemePreset[] = [
|
||||||
|
defaultLightPreset,
|
||||||
|
defaultDarkPreset,
|
||||||
|
]
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Default Preset ID
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const DEFAULT_PRESET_ID = 'light'
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Preset Utility Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据 ID 获取预设
|
||||||
|
* @param id - 预设 ID
|
||||||
|
* @returns 对应的预设,如果不存在则返回 undefined
|
||||||
|
*/
|
||||||
|
export function getPresetById(id: string): ThemePreset | undefined {
|
||||||
|
return builtInPresets.find((preset) => preset.id === id)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
/**
|
||||||
|
* CSS 安全过滤器 - 用于过滤用户自定义 CSS 中的危险内容
|
||||||
|
* 防范外部资源加载和 XSS 注入
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface SanitizeResult {
|
||||||
|
css: string
|
||||||
|
warnings: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 过滤规则:基于正则表达式的危险模式检测
|
||||||
|
* 与匹配的危险模式相关的警告消息
|
||||||
|
*/
|
||||||
|
interface FilterRule {
|
||||||
|
pattern: RegExp
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 定义所有过滤规则
|
||||||
|
*/
|
||||||
|
const filterRules: FilterRule[] = [
|
||||||
|
{
|
||||||
|
pattern: /@import\s+(?:url\()?['"]?(?:https?:|\/\/)?[^)'"]+['"]?\)?[;]?/gi,
|
||||||
|
message: '移除 @import 语句(禁止加载外部资源)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pattern: /url\s*\(\s*(?:https?:|\/\/|data:|javascript:)[^)]*\)/gi,
|
||||||
|
message: '移除 url() 调用(禁止外部请求)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pattern: /javascript:/gi,
|
||||||
|
message: '移除 javascript: 协议(XSS 防护)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pattern: /expression\s*\(\s*[^)]*\)/gi,
|
||||||
|
message: '移除 expression() 函数(IE 遗留 XSS 向量)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pattern: /-moz-binding\s*:\s*[^;]+/gi,
|
||||||
|
message: '移除 -moz-binding 属性(Firefox XSS 向量)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pattern: /behavior\s*:\s*[^;]+/gi,
|
||||||
|
message: '移除 behavior: 属性(IE HTC)',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将原始 CSS 按行分割并跟踪行号
|
||||||
|
*/
|
||||||
|
function splitCSSByLines(css: string): string[] {
|
||||||
|
return css.split(/\r?\n/)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在 CSS 中查找模式匹配的行号
|
||||||
|
*/
|
||||||
|
function findMatchingLineNumbers(css: string, pattern: RegExp): number[] {
|
||||||
|
const lines = splitCSSByLines(css)
|
||||||
|
const matchingLines: number[] = []
|
||||||
|
|
||||||
|
lines.forEach((line, index) => {
|
||||||
|
if (pattern.test(line)) {
|
||||||
|
matchingLines.push(index + 1) // 行号从 1 开始
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return matchingLines
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 过滤 CSS 中的危险内容
|
||||||
|
* @param rawCSS 原始 CSS 字符串
|
||||||
|
* @returns 包含过滤后的 CSS 和警告列表的对象
|
||||||
|
*/
|
||||||
|
export function sanitizeCSS(rawCSS: string): SanitizeResult {
|
||||||
|
let sanitizedCSS = rawCSS
|
||||||
|
const warnings: string[] = []
|
||||||
|
|
||||||
|
// 应用所有过滤规则
|
||||||
|
filterRules.forEach((rule) => {
|
||||||
|
const lineNumbers = findMatchingLineNumbers(sanitizedCSS, rule.pattern)
|
||||||
|
|
||||||
|
// 对每个匹配的行生成警告
|
||||||
|
lineNumbers.forEach((lineNum) => {
|
||||||
|
warnings.push(`Line ${lineNum}: ${rule.message}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 从 CSS 中移除匹配内容
|
||||||
|
sanitizedCSS = sanitizedCSS.replace(rule.pattern, '')
|
||||||
|
})
|
||||||
|
|
||||||
|
// 清理多余的空白行
|
||||||
|
sanitizedCSS = sanitizedCSS.replace(/\n\s*\n/g, '\n').trim()
|
||||||
|
|
||||||
|
return {
|
||||||
|
css: sanitizedCSS,
|
||||||
|
warnings,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 快速检查 CSS 是否包含危险模式
|
||||||
|
* @param css CSS 字符串
|
||||||
|
* @returns 如果包含危险模式返回 true,否则返回 false
|
||||||
|
*/
|
||||||
|
export function isCSSSafe(css: string): boolean {
|
||||||
|
return !filterRules.some((rule) => rule.pattern.test(css))
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,225 @@
|
||||||
|
/**
|
||||||
|
* 主题配置的 localStorage 存储管理模块
|
||||||
|
* 统一处理主题相关的存储操作,包括加载、保存、导出、导入和迁移旧 key
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { BackgroundConfigMap, UserThemeConfig } from './tokens'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主题存储 key 定义
|
||||||
|
* 统一使用 'maibot-theme-*' 前缀,替代现有的 'ui-theme'、'maibot-ui-theme' 和 'accent-color'
|
||||||
|
*/
|
||||||
|
export const THEME_STORAGE_KEYS = {
|
||||||
|
MODE: 'maibot-theme-mode',
|
||||||
|
PRESET: 'maibot-theme-preset',
|
||||||
|
ACCENT: 'maibot-theme-accent',
|
||||||
|
OVERRIDES: 'maibot-theme-overrides',
|
||||||
|
CUSTOM_CSS: 'maibot-theme-custom-css',
|
||||||
|
BACKGROUND_CONFIG: 'maibot-theme-background',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 默认主题配置
|
||||||
|
*/
|
||||||
|
const DEFAULT_THEME_CONFIG: UserThemeConfig = {
|
||||||
|
selectedPreset: 'light',
|
||||||
|
accentColor: 'blue',
|
||||||
|
tokenOverrides: {},
|
||||||
|
customCSS: '',
|
||||||
|
backgroundConfig: {} as BackgroundConfigMap,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 localStorage 加载完整主题配置
|
||||||
|
* 缺失值使用合理默认值
|
||||||
|
*
|
||||||
|
* @returns 加载的主题配置对象
|
||||||
|
*/
|
||||||
|
export function loadThemeConfig(): UserThemeConfig {
|
||||||
|
const preset = localStorage.getItem(THEME_STORAGE_KEYS.PRESET)
|
||||||
|
const accent = localStorage.getItem(THEME_STORAGE_KEYS.ACCENT)
|
||||||
|
const overridesStr = localStorage.getItem(THEME_STORAGE_KEYS.OVERRIDES)
|
||||||
|
const customCSS = localStorage.getItem(THEME_STORAGE_KEYS.CUSTOM_CSS)
|
||||||
|
|
||||||
|
// 解析 tokenOverrides JSON
|
||||||
|
let tokenOverrides = {}
|
||||||
|
if (overridesStr) {
|
||||||
|
try {
|
||||||
|
tokenOverrides = JSON.parse(overridesStr)
|
||||||
|
} catch {
|
||||||
|
// JSON 解析失败,使用空对象
|
||||||
|
tokenOverrides = {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载 backgroundConfig
|
||||||
|
const backgroundConfigStr = localStorage.getItem(THEME_STORAGE_KEYS.BACKGROUND_CONFIG)
|
||||||
|
let backgroundConfig: BackgroundConfigMap = {}
|
||||||
|
if (backgroundConfigStr) {
|
||||||
|
try {
|
||||||
|
backgroundConfig = JSON.parse(backgroundConfigStr)
|
||||||
|
} catch {
|
||||||
|
backgroundConfig = {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
selectedPreset: preset || DEFAULT_THEME_CONFIG.selectedPreset,
|
||||||
|
accentColor: accent || DEFAULT_THEME_CONFIG.accentColor,
|
||||||
|
tokenOverrides,
|
||||||
|
customCSS: customCSS || DEFAULT_THEME_CONFIG.customCSS,
|
||||||
|
backgroundConfig,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存完整主题配置到 localStorage
|
||||||
|
*
|
||||||
|
* @param config - 要保存的主题配置
|
||||||
|
*/
|
||||||
|
export function saveThemeConfig(config: UserThemeConfig): void {
|
||||||
|
localStorage.setItem(THEME_STORAGE_KEYS.PRESET, config.selectedPreset)
|
||||||
|
localStorage.setItem(THEME_STORAGE_KEYS.ACCENT, config.accentColor)
|
||||||
|
localStorage.setItem(THEME_STORAGE_KEYS.OVERRIDES, JSON.stringify(config.tokenOverrides))
|
||||||
|
localStorage.setItem(THEME_STORAGE_KEYS.CUSTOM_CSS, config.customCSS)
|
||||||
|
if (config.backgroundConfig) {
|
||||||
|
localStorage.setItem(THEME_STORAGE_KEYS.BACKGROUND_CONFIG, JSON.stringify(config.backgroundConfig))
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(THEME_STORAGE_KEYS.BACKGROUND_CONFIG)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 部分更新主题配置
|
||||||
|
* 先加载现有配置,合并部分更新,再保存
|
||||||
|
*
|
||||||
|
* @param partial - 部分主题配置更新
|
||||||
|
*/
|
||||||
|
export function saveThemePartial(partial: Partial<UserThemeConfig>): void {
|
||||||
|
const current = loadThemeConfig()
|
||||||
|
const updated: UserThemeConfig = {
|
||||||
|
...current,
|
||||||
|
...partial,
|
||||||
|
}
|
||||||
|
saveThemeConfig(updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出主题配置为美化格式的 JSON 字符串
|
||||||
|
*
|
||||||
|
* @returns 格式化的 JSON 字符串
|
||||||
|
*/
|
||||||
|
export function exportThemeJSON(): string {
|
||||||
|
const config = loadThemeConfig()
|
||||||
|
return JSON.stringify(config, null, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 JSON 字符串导入主题配置
|
||||||
|
* 包含基础的格式和字段校验
|
||||||
|
*
|
||||||
|
* @param json - JSON 字符串
|
||||||
|
* @returns 导入结果,包含成功状态和错误列表
|
||||||
|
*/
|
||||||
|
export function importThemeJSON(
|
||||||
|
json: string,
|
||||||
|
): { success: boolean; errors: string[] } {
|
||||||
|
const errors: string[] = []
|
||||||
|
|
||||||
|
// JSON 格式校验
|
||||||
|
let config: unknown
|
||||||
|
try {
|
||||||
|
config = JSON.parse(json)
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
errors: [`Invalid JSON format: ${error instanceof Error ? error.message : 'Unknown error'}`],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 基本对象类型校验
|
||||||
|
if (typeof config !== 'object' || config === null) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
errors: ['Configuration must be a JSON object'],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const configObj = config as Record<string, unknown>
|
||||||
|
|
||||||
|
// 必要字段存在性校验
|
||||||
|
if (typeof configObj.selectedPreset !== 'string') {
|
||||||
|
errors.push('selectedPreset must be a string')
|
||||||
|
}
|
||||||
|
if (typeof configObj.accentColor !== 'string') {
|
||||||
|
errors.push('accentColor must be a string')
|
||||||
|
}
|
||||||
|
if (typeof configObj.customCSS !== 'string') {
|
||||||
|
errors.push('customCSS must be a string')
|
||||||
|
}
|
||||||
|
if (configObj.tokenOverrides !== undefined && typeof configObj.tokenOverrides !== 'object') {
|
||||||
|
errors.push('tokenOverrides must be an object')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
return { success: false, errors }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验通过,保存配置
|
||||||
|
const validConfig: UserThemeConfig = {
|
||||||
|
selectedPreset: configObj.selectedPreset as string,
|
||||||
|
accentColor: configObj.accentColor as string,
|
||||||
|
tokenOverrides: (configObj.tokenOverrides as Partial<any>) || {},
|
||||||
|
customCSS: configObj.customCSS as string,
|
||||||
|
backgroundConfig: (configObj.backgroundConfig as BackgroundConfigMap) ?? {},
|
||||||
|
}
|
||||||
|
|
||||||
|
saveThemeConfig(validConfig)
|
||||||
|
return { success: true, errors: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重置主题配置为默认值
|
||||||
|
* 删除所有 THEME_STORAGE_KEYS 对应的 localStorage 项
|
||||||
|
*/
|
||||||
|
export function resetThemeToDefault(): void {
|
||||||
|
Object.values(THEME_STORAGE_KEYS).forEach((key) => {
|
||||||
|
localStorage.removeItem(key)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 迁移旧的 localStorage key 到新 key
|
||||||
|
* 处理:
|
||||||
|
* - 'ui-theme' 或 'maibot-ui-theme' → 'maibot-theme-mode'
|
||||||
|
* - 'accent-color' → 'maibot-theme-accent'
|
||||||
|
* 迁移完成后删除旧 key,避免重复迁移
|
||||||
|
*/
|
||||||
|
export function migrateOldKeys(): void {
|
||||||
|
// 迁移主题模式
|
||||||
|
// 优先使用 'ui-theme'(因为 ThemeProvider 默认使用它)
|
||||||
|
const uiTheme = localStorage.getItem('ui-theme')
|
||||||
|
const maiTheme = localStorage.getItem('maibot-ui-theme')
|
||||||
|
const newMode = localStorage.getItem(THEME_STORAGE_KEYS.MODE)
|
||||||
|
|
||||||
|
if (!newMode) {
|
||||||
|
if (uiTheme) {
|
||||||
|
localStorage.setItem(THEME_STORAGE_KEYS.MODE, uiTheme)
|
||||||
|
} else if (maiTheme) {
|
||||||
|
localStorage.setItem(THEME_STORAGE_KEYS.MODE, maiTheme)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 迁移强调色
|
||||||
|
const accentColor = localStorage.getItem('accent-color')
|
||||||
|
const newAccent = localStorage.getItem(THEME_STORAGE_KEYS.ACCENT)
|
||||||
|
|
||||||
|
if (accentColor && !newAccent) {
|
||||||
|
localStorage.setItem(THEME_STORAGE_KEYS.ACCENT, accentColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除旧 key
|
||||||
|
localStorage.removeItem('ui-theme')
|
||||||
|
localStorage.removeItem('maibot-ui-theme')
|
||||||
|
localStorage.removeItem('accent-color')
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,401 @@
|
||||||
|
/**
|
||||||
|
* Design Token Schema 定义
|
||||||
|
* 集中管理所有设计令牌(颜色、排版、间距、阴影、动画等)
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Color Tokens 类型定义
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type ColorTokens = {
|
||||||
|
primary: string
|
||||||
|
'primary-foreground': string
|
||||||
|
'primary-gradient': string
|
||||||
|
secondary: string
|
||||||
|
'secondary-foreground': string
|
||||||
|
muted: string
|
||||||
|
'muted-foreground': string
|
||||||
|
accent: string
|
||||||
|
'accent-foreground': string
|
||||||
|
destructive: string
|
||||||
|
'destructive-foreground': string
|
||||||
|
background: string
|
||||||
|
foreground: string
|
||||||
|
card: string
|
||||||
|
'card-foreground': string
|
||||||
|
popover: string
|
||||||
|
'popover-foreground': string
|
||||||
|
border: string
|
||||||
|
input: string
|
||||||
|
ring: string
|
||||||
|
'chart-1': string
|
||||||
|
'chart-2': string
|
||||||
|
'chart-3': string
|
||||||
|
'chart-4': string
|
||||||
|
'chart-5': string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Typography Tokens 类型定义
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type TypographyTokens = {
|
||||||
|
'font-family-base': string
|
||||||
|
'font-family-code': string
|
||||||
|
'font-size-xs': string
|
||||||
|
'font-size-sm': string
|
||||||
|
'font-size-base': string
|
||||||
|
'font-size-lg': string
|
||||||
|
'font-size-xl': string
|
||||||
|
'font-size-2xl': string
|
||||||
|
'font-weight-normal': number
|
||||||
|
'font-weight-medium': number
|
||||||
|
'font-weight-semibold': number
|
||||||
|
'font-weight-bold': number
|
||||||
|
'line-height-tight': number
|
||||||
|
'line-height-normal': number
|
||||||
|
'line-height-relaxed': number
|
||||||
|
'letter-spacing-tight': string
|
||||||
|
'letter-spacing-normal': string
|
||||||
|
'letter-spacing-wide': string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Visual Tokens 类型定义
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type VisualTokens = {
|
||||||
|
'radius-sm': string
|
||||||
|
'radius-md': string
|
||||||
|
'radius-lg': string
|
||||||
|
'radius-xl': string
|
||||||
|
'radius-full': string
|
||||||
|
'shadow-sm': string
|
||||||
|
'shadow-md': string
|
||||||
|
'shadow-lg': string
|
||||||
|
'shadow-xl': string
|
||||||
|
'blur-sm': string
|
||||||
|
'blur-md': string
|
||||||
|
'blur-lg': string
|
||||||
|
'opacity-disabled': number
|
||||||
|
'opacity-hover': number
|
||||||
|
'opacity-overlay': number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Layout Tokens 类型定义
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type LayoutTokens = {
|
||||||
|
'space-unit': string
|
||||||
|
'space-xs': string
|
||||||
|
'space-sm': string
|
||||||
|
'space-md': string
|
||||||
|
'space-lg': string
|
||||||
|
'space-xl': string
|
||||||
|
'space-2xl': string
|
||||||
|
'sidebar-width': string
|
||||||
|
'header-height': string
|
||||||
|
'max-content-width': string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Animation Tokens 类型定义
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type AnimationTokens = {
|
||||||
|
'anim-duration-fast': string
|
||||||
|
'anim-duration-normal': string
|
||||||
|
'anim-duration-slow': string
|
||||||
|
'anim-easing-default': string
|
||||||
|
'anim-easing-in': string
|
||||||
|
'anim-easing-out': string
|
||||||
|
'anim-easing-in-out': string
|
||||||
|
'transition-colors': string
|
||||||
|
'transition-transform': string
|
||||||
|
'transition-opacity': string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Aggregated Theme Tokens
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type ThemeTokens = {
|
||||||
|
color: ColorTokens
|
||||||
|
typography: TypographyTokens
|
||||||
|
visual: VisualTokens
|
||||||
|
layout: LayoutTokens
|
||||||
|
animation: AnimationTokens
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Theme Preset & Config Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type ThemePreset = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
tokens: ThemeTokens
|
||||||
|
isDark: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UserThemeConfig = {
|
||||||
|
selectedPreset: string
|
||||||
|
accentColor: string
|
||||||
|
tokenOverrides: Partial<ThemeTokens>
|
||||||
|
customCSS: string
|
||||||
|
backgroundConfig?: BackgroundConfigMap
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Default Light Tokens (from index.css :root)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const defaultLightTokens: ThemeTokens = {
|
||||||
|
color: {
|
||||||
|
primary: '221.2 83.2% 53.3%',
|
||||||
|
'primary-foreground': '210 40% 98%',
|
||||||
|
'primary-gradient': 'none',
|
||||||
|
secondary: '210 40% 96.1%',
|
||||||
|
'secondary-foreground': '222.2 47.4% 11.2%',
|
||||||
|
muted: '210 40% 96.1%',
|
||||||
|
'muted-foreground': '215.4 16.3% 46.9%',
|
||||||
|
accent: '210 40% 96.1%',
|
||||||
|
'accent-foreground': '222.2 47.4% 11.2%',
|
||||||
|
destructive: '0 84.2% 60.2%',
|
||||||
|
'destructive-foreground': '210 40% 98%',
|
||||||
|
background: '0 0% 100%',
|
||||||
|
foreground: '222.2 84% 4.9%',
|
||||||
|
card: '0 0% 100%',
|
||||||
|
'card-foreground': '222.2 84% 4.9%',
|
||||||
|
popover: '0 0% 100%',
|
||||||
|
'popover-foreground': '222.2 84% 4.9%',
|
||||||
|
border: '214.3 31.8% 91.4%',
|
||||||
|
input: '214.3 31.8% 91.4%',
|
||||||
|
ring: '221.2 83.2% 53.3%',
|
||||||
|
'chart-1': '221.2 83.2% 53.3%',
|
||||||
|
'chart-2': '160 60% 45%',
|
||||||
|
'chart-3': '30 80% 55%',
|
||||||
|
'chart-4': '280 65% 60%',
|
||||||
|
'chart-5': '340 75% 55%',
|
||||||
|
},
|
||||||
|
typography: {
|
||||||
|
'font-family-base': '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
|
||||||
|
'font-family-code': '"JetBrains Mono", "Monaco", "Courier New", monospace',
|
||||||
|
'font-size-xs': '0.75rem',
|
||||||
|
'font-size-sm': '0.875rem',
|
||||||
|
'font-size-base': '1rem',
|
||||||
|
'font-size-lg': '1.125rem',
|
||||||
|
'font-size-xl': '1.25rem',
|
||||||
|
'font-size-2xl': '1.5rem',
|
||||||
|
'font-weight-normal': 400,
|
||||||
|
'font-weight-medium': 500,
|
||||||
|
'font-weight-semibold': 600,
|
||||||
|
'font-weight-bold': 700,
|
||||||
|
'line-height-tight': 1.2,
|
||||||
|
'line-height-normal': 1.5,
|
||||||
|
'line-height-relaxed': 1.75,
|
||||||
|
'letter-spacing-tight': '-0.02em',
|
||||||
|
'letter-spacing-normal': '0em',
|
||||||
|
'letter-spacing-wide': '0.02em',
|
||||||
|
},
|
||||||
|
visual: {
|
||||||
|
'radius-sm': '0.25rem',
|
||||||
|
'radius-md': '0.375rem',
|
||||||
|
'radius-lg': '0.5rem',
|
||||||
|
'radius-xl': '0.75rem',
|
||||||
|
'radius-full': '9999px',
|
||||||
|
'shadow-sm': '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
|
||||||
|
'shadow-md': '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
|
||||||
|
'shadow-lg': '0 10px 15px -3px rgba(0, 0, 0, 0.1)',
|
||||||
|
'shadow-xl': '0 20px 25px -5px rgba(0, 0, 0, 0.1)',
|
||||||
|
'blur-sm': '4px',
|
||||||
|
'blur-md': '12px',
|
||||||
|
'blur-lg': '24px',
|
||||||
|
'opacity-disabled': 0.5,
|
||||||
|
'opacity-hover': 0.8,
|
||||||
|
'opacity-overlay': 0.75,
|
||||||
|
},
|
||||||
|
layout: {
|
||||||
|
'space-unit': '0.25rem',
|
||||||
|
'space-xs': '0.5rem',
|
||||||
|
'space-sm': '0.75rem',
|
||||||
|
'space-md': '1rem',
|
||||||
|
'space-lg': '1.5rem',
|
||||||
|
'space-xl': '2rem',
|
||||||
|
'space-2xl': '3rem',
|
||||||
|
'sidebar-width': '16rem',
|
||||||
|
'header-height': '3.5rem',
|
||||||
|
'max-content-width': '1280px',
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
'anim-duration-fast': '150ms',
|
||||||
|
'anim-duration-normal': '300ms',
|
||||||
|
'anim-duration-slow': '500ms',
|
||||||
|
'anim-easing-default': 'cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
'anim-easing-in': 'cubic-bezier(0.4, 0, 1, 1)',
|
||||||
|
'anim-easing-out': 'cubic-bezier(0, 0, 0.2, 1)',
|
||||||
|
'anim-easing-in-out': 'cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
'transition-colors': 'color 300ms cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
'transition-transform': 'transform 300ms cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
'transition-opacity': 'opacity 300ms cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Default Dark Tokens (from index.css .dark)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const defaultDarkTokens: ThemeTokens = {
|
||||||
|
color: {
|
||||||
|
primary: '217.2 91.2% 59.8%',
|
||||||
|
'primary-foreground': '210 40% 98%',
|
||||||
|
'primary-gradient': 'none',
|
||||||
|
secondary: '217.2 32.6% 17.5%',
|
||||||
|
'secondary-foreground': '210 40% 98%',
|
||||||
|
muted: '217.2 32.6% 17.5%',
|
||||||
|
'muted-foreground': '215 20.2% 65.1%',
|
||||||
|
accent: '217.2 32.6% 17.5%',
|
||||||
|
'accent-foreground': '210 40% 98%',
|
||||||
|
destructive: '0 62.8% 30.6%',
|
||||||
|
'destructive-foreground': '210 40% 98%',
|
||||||
|
background: '222.2 84% 4.9%',
|
||||||
|
foreground: '210 40% 98%',
|
||||||
|
card: '222.2 84% 4.9%',
|
||||||
|
'card-foreground': '210 40% 98%',
|
||||||
|
popover: '222.2 84% 4.9%',
|
||||||
|
'popover-foreground': '210 40% 98%',
|
||||||
|
border: '217.2 32.6% 17.5%',
|
||||||
|
input: '217.2 32.6% 17.5%',
|
||||||
|
ring: '224.3 76.3% 48%',
|
||||||
|
'chart-1': '217.2 91.2% 59.8%',
|
||||||
|
'chart-2': '160 60% 50%',
|
||||||
|
'chart-3': '30 80% 60%',
|
||||||
|
'chart-4': '280 65% 65%',
|
||||||
|
'chart-5': '340 75% 60%',
|
||||||
|
},
|
||||||
|
typography: {
|
||||||
|
'font-family-base': '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
|
||||||
|
'font-family-code': '"JetBrains Mono", "Monaco", "Courier New", monospace',
|
||||||
|
'font-size-xs': '0.75rem',
|
||||||
|
'font-size-sm': '0.875rem',
|
||||||
|
'font-size-base': '1rem',
|
||||||
|
'font-size-lg': '1.125rem',
|
||||||
|
'font-size-xl': '1.25rem',
|
||||||
|
'font-size-2xl': '1.5rem',
|
||||||
|
'font-weight-normal': 400,
|
||||||
|
'font-weight-medium': 500,
|
||||||
|
'font-weight-semibold': 600,
|
||||||
|
'font-weight-bold': 700,
|
||||||
|
'line-height-tight': 1.2,
|
||||||
|
'line-height-normal': 1.5,
|
||||||
|
'line-height-relaxed': 1.75,
|
||||||
|
'letter-spacing-tight': '-0.02em',
|
||||||
|
'letter-spacing-normal': '0em',
|
||||||
|
'letter-spacing-wide': '0.02em',
|
||||||
|
},
|
||||||
|
visual: {
|
||||||
|
'radius-sm': '0.25rem',
|
||||||
|
'radius-md': '0.375rem',
|
||||||
|
'radius-lg': '0.5rem',
|
||||||
|
'radius-xl': '0.75rem',
|
||||||
|
'radius-full': '9999px',
|
||||||
|
'shadow-sm': '0 1px 2px 0 rgba(0, 0, 0, 0.25)',
|
||||||
|
'shadow-md': '0 4px 6px -1px rgba(0, 0, 0, 0.3)',
|
||||||
|
'shadow-lg': '0 10px 15px -3px rgba(0, 0, 0, 0.4)',
|
||||||
|
'shadow-xl': '0 20px 25px -5px rgba(0, 0, 0, 0.5)',
|
||||||
|
'blur-sm': '4px',
|
||||||
|
'blur-md': '12px',
|
||||||
|
'blur-lg': '24px',
|
||||||
|
'opacity-disabled': 0.5,
|
||||||
|
'opacity-hover': 0.8,
|
||||||
|
'opacity-overlay': 0.75,
|
||||||
|
},
|
||||||
|
layout: {
|
||||||
|
'space-unit': '0.25rem',
|
||||||
|
'space-xs': '0.5rem',
|
||||||
|
'space-sm': '0.75rem',
|
||||||
|
'space-md': '1rem',
|
||||||
|
'space-lg': '1.5rem',
|
||||||
|
'space-xl': '2rem',
|
||||||
|
'space-2xl': '3rem',
|
||||||
|
'sidebar-width': '16rem',
|
||||||
|
'header-height': '3.5rem',
|
||||||
|
'max-content-width': '1280px',
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
'anim-duration-fast': '150ms',
|
||||||
|
'anim-duration-normal': '300ms',
|
||||||
|
'anim-duration-slow': '500ms',
|
||||||
|
'anim-easing-default': 'cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
'anim-easing-in': 'cubic-bezier(0.4, 0, 1, 1)',
|
||||||
|
'anim-easing-out': 'cubic-bezier(0, 0, 0.2, 1)',
|
||||||
|
'anim-easing-in-out': 'cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
'transition-colors': 'color 300ms cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
'transition-transform': 'transform 300ms cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
'transition-opacity': 'opacity 300ms cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Token Utility Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 Token 类别和 key 转换为 CSS 变量名
|
||||||
|
* @example tokenToCSSVarName('color', 'primary') => '--color-primary'
|
||||||
|
*/
|
||||||
|
export function tokenToCSSVarName(
|
||||||
|
category: keyof ThemeTokens | 'color' | 'typography' | 'visual' | 'layout' | 'animation',
|
||||||
|
key: string,
|
||||||
|
): string {
|
||||||
|
return `--${category}-${key}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Background Config Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type BackgroundEffects = {
|
||||||
|
blur: number // px, 0-50
|
||||||
|
overlayColor: string // HSL string,如 '0 0% 0%'
|
||||||
|
overlayOpacity: number // 0-1
|
||||||
|
position: 'cover' | 'contain' | 'center' | 'stretch'
|
||||||
|
brightness: number // 0-200, default 100
|
||||||
|
contrast: number // 0-200, default 100
|
||||||
|
saturate: number // 0-200, default 100
|
||||||
|
gradientOverlay?: string // CSS gradient string(可选)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BackgroundConfig = {
|
||||||
|
type: 'none' | 'image' | 'video'
|
||||||
|
assetId?: string // IndexedDB asset ID
|
||||||
|
inherit?: boolean // true = 继承页面背景
|
||||||
|
effects: BackgroundEffects
|
||||||
|
customCSS: string // 组件级自定义 CSS
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BackgroundConfigMap = {
|
||||||
|
page?: BackgroundConfig
|
||||||
|
sidebar?: BackgroundConfig
|
||||||
|
header?: BackgroundConfig
|
||||||
|
card?: BackgroundConfig
|
||||||
|
dialog?: BackgroundConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultBackgroundEffects: BackgroundEffects = {
|
||||||
|
blur: 0,
|
||||||
|
overlayColor: '0 0% 0%',
|
||||||
|
overlayOpacity: 0,
|
||||||
|
position: 'cover',
|
||||||
|
brightness: 100,
|
||||||
|
contrast: 100,
|
||||||
|
saturate: 100,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultBackgroundConfig: BackgroundConfig = {
|
||||||
|
type: 'none',
|
||||||
|
effects: defaultBackgroundEffects,
|
||||||
|
customCSS: '',
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
/**
|
||||||
|
* Token 验证规则和状态
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface TokenValidationRule {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
validate: (token: string) => boolean
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenValidationResult {
|
||||||
|
isValid: boolean
|
||||||
|
rules: Array<{
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
passed: boolean
|
||||||
|
description: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token 验证规则定义
|
||||||
|
export const TOKEN_VALIDATION_RULES: TokenValidationRule[] = [
|
||||||
|
{
|
||||||
|
id: 'minLength',
|
||||||
|
label: '长度至少 10 位',
|
||||||
|
description: 'Token 长度必须大于等于 10 个字符',
|
||||||
|
validate: (token: string) => token.length >= 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hasUppercase',
|
||||||
|
label: '包含大写字母',
|
||||||
|
description: '至少包含一个大写字母 (A-Z)',
|
||||||
|
validate: (token: string) => /[A-Z]/.test(token),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hasLowercase',
|
||||||
|
label: '包含小写字母',
|
||||||
|
description: '至少包含一个小写字母 (a-z)',
|
||||||
|
validate: (token: string) => /[a-z]/.test(token),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hasSpecialChar',
|
||||||
|
label: '包含特殊符号',
|
||||||
|
description: '至少包含一个特殊符号 (!@#$%^&*()_+-=[]{}|;:,.<>?/)',
|
||||||
|
validate: (token: string) => /[!@#$%^&*()_+\-=[\]{}|;:,.<>?/]/.test(token),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 Token 并返回详细结果
|
||||||
|
*/
|
||||||
|
export function validateToken(token: string): TokenValidationResult {
|
||||||
|
const rules = TOKEN_VALIDATION_RULES.map((rule) => ({
|
||||||
|
id: rule.id,
|
||||||
|
label: rule.label,
|
||||||
|
description: rule.description,
|
||||||
|
passed: rule.validate(token),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const isValid = rules.every((rule) => rule.passed)
|
||||||
|
|
||||||
|
return {
|
||||||
|
isValid,
|
||||||
|
rules,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取验证失败的规则
|
||||||
|
*/
|
||||||
|
export function getFailedRules(token: string): string[] {
|
||||||
|
const result = validateToken(token)
|
||||||
|
return result.rules.filter((rule) => !rule.passed).map((rule) => rule.label)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查 Token 是否完全有效
|
||||||
|
*/
|
||||||
|
export function isTokenValid(token: string): boolean {
|
||||||
|
return validateToken(token).isValid
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { clsx, type ClassValue } from 'clsx'
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
/**
|
||||||
|
* MaiBot Dashboard 版本管理
|
||||||
|
*
|
||||||
|
* 这是唯一需要修改版本号的地方
|
||||||
|
* 修改此处的版本号后,所有展示版本的地方都会自动更新
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const APP_VERSION = '1.0.0'
|
||||||
|
export const APP_NAME = 'MaiBot Dashboard'
|
||||||
|
export const APP_FULL_NAME = `${APP_NAME} v${APP_VERSION}`
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取版本信息
|
||||||
|
*/
|
||||||
|
export const getVersionInfo = () => ({
|
||||||
|
version: APP_VERSION,
|
||||||
|
name: APP_NAME,
|
||||||
|
fullName: APP_FULL_NAME,
|
||||||
|
buildDate: import.meta.env.VITE_BUILD_DATE || new Date().toISOString().split('T')[0],
|
||||||
|
buildEnv: import.meta.env.MODE,
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化版本显示
|
||||||
|
*/
|
||||||
|
export const formatVersion = (prefix = 'v') => `${prefix}${APP_VERSION}`
|
||||||
|
|
@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client'
|
||||||
import { RouterProvider } from '@tanstack/react-router'
|
import { RouterProvider } from '@tanstack/react-router'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import { router } from './router'
|
import { router } from './router'
|
||||||
|
import { AssetStoreProvider } from './components/asset-provider'
|
||||||
import { ThemeProvider } from './components/theme-provider'
|
import { ThemeProvider } from './components/theme-provider'
|
||||||
import { AnimationProvider } from './components/animation-provider'
|
import { AnimationProvider } from './components/animation-provider'
|
||||||
import { TourProvider, TourRenderer } from './components/tour'
|
import { TourProvider, TourRenderer } from './components/tour'
|
||||||
|
|
@ -12,15 +13,17 @@ import { ErrorBoundary } from './components/error-boundary'
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<ThemeProvider defaultTheme="system">
|
<AssetStoreProvider>
|
||||||
<AnimationProvider>
|
<ThemeProvider defaultTheme="system">
|
||||||
<TourProvider>
|
<AnimationProvider>
|
||||||
<RouterProvider router={router} />
|
<TourProvider>
|
||||||
<TourRenderer />
|
<RouterProvider router={router} />
|
||||||
<Toaster />
|
<TourRenderer />
|
||||||
</TourProvider>
|
<Toaster />
|
||||||
</AnimationProvider>
|
</TourProvider>
|
||||||
</ThemeProvider>
|
</AnimationProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</AssetStoreProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</StrictMode>
|
</StrictMode>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -341,7 +341,7 @@ export function AnnualReportPage() {
|
||||||
contentStyle={{ borderRadius: '8px', border: 'none', boxShadow: '0 4px 12px rgba(0,0,0,0.1)' }}
|
contentStyle={{ borderRadius: '8px', border: 'none', boxShadow: '0 4px 12px rgba(0,0,0,0.1)' }}
|
||||||
cursor={{ fill: 'transparent' }}
|
cursor={{ fill: 'transparent' }}
|
||||||
/>
|
/>
|
||||||
<Bar dataKey="count" fill="hsl(var(--primary))" radius={[4, 4, 0, 0]} />
|
<Bar dataKey="count" fill="hsl(var(--color-primary))" radius={[4, 4, 0, 0]} />
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
import {
|
import {
|
||||||
BotInfoSection,
|
BotInfoSection,
|
||||||
PersonalitySection,
|
PersonalitySection,
|
||||||
ChatSection,
|
|
||||||
DreamSection,
|
DreamSection,
|
||||||
LPMMSection,
|
LPMMSection,
|
||||||
LogSection,
|
LogSection,
|
||||||
|
|
@ -69,6 +68,11 @@ import { useAutoSave, useConfigAutoSave } from './bot/hooks'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
// 导入动态表单和 Hook 系统
|
||||||
|
import { DynamicConfigForm } from '@/components/dynamic-form'
|
||||||
|
import { fieldHooks } from '@/lib/field-hooks'
|
||||||
|
import { ChatSectionHook } from '@/routes/config/bot/hooks'
|
||||||
|
|
||||||
// ==================== 常量定义 ====================
|
// ==================== 常量定义 ====================
|
||||||
/** Toast 显示前的延迟时间 (毫秒) */
|
/** Toast 显示前的延迟时间 (毫秒) */
|
||||||
const TOAST_DISPLAY_DELAY = 500
|
const TOAST_DISPLAY_DELAY = 500
|
||||||
|
|
@ -308,6 +312,13 @@ function BotConfigPageContent() {
|
||||||
loadConfig()
|
loadConfig()
|
||||||
}, [loadConfig])
|
}, [loadConfig])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fieldHooks.register('chat', ChatSectionHook, 'replace')
|
||||||
|
return () => {
|
||||||
|
fieldHooks.unregister('chat')
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
// 使用模块化的 useAutoSave hook
|
// 使用模块化的 useAutoSave hook
|
||||||
const { triggerAutoSave, cancelPendingAutoSave } = useAutoSave(
|
const { triggerAutoSave, cancelPendingAutoSave } = useAutoSave(
|
||||||
initialLoadRef.current,
|
initialLoadRef.current,
|
||||||
|
|
@ -613,7 +624,6 @@ function BotConfigPageContent() {
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
language="toml"
|
language="toml"
|
||||||
theme="dark"
|
|
||||||
height="calc(100vh - 280px)"
|
height="calc(100vh - 280px)"
|
||||||
minHeight="500px"
|
minHeight="500px"
|
||||||
placeholder="TOML 配置内容"
|
placeholder="TOML 配置内容"
|
||||||
|
|
@ -652,7 +662,24 @@ function BotConfigPageContent() {
|
||||||
|
|
||||||
{/* 聊天配置 */}
|
{/* 聊天配置 */}
|
||||||
<TabsContent value="chat" className="space-y-4">
|
<TabsContent value="chat" className="space-y-4">
|
||||||
{chatConfig && <ChatSection config={chatConfig} onChange={setChatConfig} />}
|
{chatConfig && (
|
||||||
|
<DynamicConfigForm
|
||||||
|
schema={{
|
||||||
|
className: 'ChatConfig',
|
||||||
|
classDoc: '聊天配置',
|
||||||
|
fields: [],
|
||||||
|
nested: {},
|
||||||
|
}}
|
||||||
|
values={{ chat: chatConfig }}
|
||||||
|
onChange={(field, value) => {
|
||||||
|
if (field === 'chat') {
|
||||||
|
setChatConfig(value as ChatConfig)
|
||||||
|
setHasUnsavedChanges(true)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
hooks={fieldHooks}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* 表达配置 */}
|
{/* 表达配置 */}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
import type { FieldHookComponent } from '@/lib/field-hooks'
|
||||||
|
import { BotInfoSection } from '../sections/BotInfoSection'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BotInfoSection as a Field Hook Component
|
||||||
|
* This component replaces the entire 'bot' nested config section rendering
|
||||||
|
*/
|
||||||
|
export const BotInfoSectionHook: FieldHookComponent = ({ value, onChange }) => {
|
||||||
|
return (
|
||||||
|
<BotInfoSection
|
||||||
|
config={value as any}
|
||||||
|
onChange={(newConfig) => onChange?.(newConfig)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,617 @@
|
||||||
|
import React, { useState, useEffect, useMemo } from 'react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
import { Slider } from '@/components/ui/slider'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from '@/components/ui/alert-dialog'
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@/components/ui/popover'
|
||||||
|
import { Plus, Trash2, Eye, Clock } from 'lucide-react'
|
||||||
|
import type { FieldHookComponent } from '@/lib/field-hooks'
|
||||||
|
import type { ChatConfig } from '../types'
|
||||||
|
|
||||||
|
// 时间选择组件
|
||||||
|
const TimeRangePicker = React.memo(function TimeRangePicker({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
}) {
|
||||||
|
// 解析初始值
|
||||||
|
const parsedValue = useMemo(() => {
|
||||||
|
const parts = value.split('-')
|
||||||
|
if (parts.length === 2) {
|
||||||
|
const [start, end] = parts
|
||||||
|
const [sh, sm] = start.split(':')
|
||||||
|
const [eh, em] = end.split(':')
|
||||||
|
return {
|
||||||
|
startHour: sh ? sh.padStart(2, '0') : '00',
|
||||||
|
startMinute: sm ? sm.padStart(2, '0') : '00',
|
||||||
|
endHour: eh ? eh.padStart(2, '0') : '23',
|
||||||
|
endMinute: em ? em.padStart(2, '0') : '59',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
startHour: '00',
|
||||||
|
startMinute: '00',
|
||||||
|
endHour: '23',
|
||||||
|
endMinute: '59',
|
||||||
|
}
|
||||||
|
}, [value])
|
||||||
|
|
||||||
|
const [startHour, setStartHour] = useState(parsedValue.startHour)
|
||||||
|
const [startMinute, setStartMinute] = useState(parsedValue.startMinute)
|
||||||
|
const [endHour, setEndHour] = useState(parsedValue.endHour)
|
||||||
|
const [endMinute, setEndMinute] = useState(parsedValue.endMinute)
|
||||||
|
|
||||||
|
// 当value变化时同步状态
|
||||||
|
useEffect(() => {
|
||||||
|
setStartHour(parsedValue.startHour)
|
||||||
|
setStartMinute(parsedValue.startMinute)
|
||||||
|
setEndHour(parsedValue.endHour)
|
||||||
|
setEndMinute(parsedValue.endMinute)
|
||||||
|
}, [parsedValue])
|
||||||
|
|
||||||
|
const updateTime = (
|
||||||
|
newStartHour: string,
|
||||||
|
newStartMinute: string,
|
||||||
|
newEndHour: string,
|
||||||
|
newEndMinute: string
|
||||||
|
) => {
|
||||||
|
const newValue = `${newStartHour}:${newStartMinute}-${newEndHour}:${newEndMinute}`
|
||||||
|
onChange(newValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="outline" className="w-full justify-start font-mono text-sm">
|
||||||
|
<Clock className="h-4 w-4 mr-2" />
|
||||||
|
{value || '选择时间段'}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-72 sm:w-80">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-sm mb-3">开始时间</h4>
|
||||||
|
<div className="grid grid-cols-2 gap-2 sm:gap-3">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">小时</Label>
|
||||||
|
<Select
|
||||||
|
value={startHour}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
setStartHour(v)
|
||||||
|
updateTime(v, startMinute, endHour, endMinute)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{Array.from({ length: 24 }, (_, i) => i).map((h) => (
|
||||||
|
<SelectItem key={h} value={h.toString().padStart(2, '0')}>
|
||||||
|
{h.toString().padStart(2, '0')}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">分钟</Label>
|
||||||
|
<Select
|
||||||
|
value={startMinute}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
setStartMinute(v)
|
||||||
|
updateTime(startHour, v, endHour, endMinute)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{Array.from({ length: 60 }, (_, i) => i).map((m) => (
|
||||||
|
<SelectItem key={m} value={m.toString().padStart(2, '0')}>
|
||||||
|
{m.toString().padStart(2, '0')}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-sm mb-3">结束时间</h4>
|
||||||
|
<div className="grid grid-cols-2 gap-2 sm:gap-3">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">小时</Label>
|
||||||
|
<Select
|
||||||
|
value={endHour}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
setEndHour(v)
|
||||||
|
updateTime(startHour, startMinute, v, endMinute)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{Array.from({ length: 24 }, (_, i) => i).map((h) => (
|
||||||
|
<SelectItem key={h} value={h.toString().padStart(2, '0')}>
|
||||||
|
{h.toString().padStart(2, '0')}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">分钟</Label>
|
||||||
|
<Select
|
||||||
|
value={endMinute}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
setEndMinute(v)
|
||||||
|
updateTime(startHour, startMinute, endHour, v)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{Array.from({ length: 60 }, (_, i) => i).map((m) => (
|
||||||
|
<SelectItem key={m} value={m.toString().padStart(2, '0')}>
|
||||||
|
{m.toString().padStart(2, '0')}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 预览窗口组件
|
||||||
|
const RulePreview = React.memo(function RulePreview({ rule }: { rule: { target: string; time: string; value: number } }) {
|
||||||
|
const previewText = `{ target = "${rule.target}", time = "${rule.time}", value = ${rule.value.toFixed(1)} }`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<Eye className="h-4 w-4 mr-1" />
|
||||||
|
预览
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-80 sm:w-96">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="font-medium text-sm">配置预览</h4>
|
||||||
|
<div className="rounded-md bg-muted p-3 font-mono text-xs break-all">
|
||||||
|
{previewText}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
这是保存到 bot_config.toml 文件中的格式
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ChatSection as a Field Hook Component
|
||||||
|
* This component replaces the entire 'chat' nested config section rendering
|
||||||
|
*/
|
||||||
|
export const ChatSectionHook: FieldHookComponent = ({ value, onChange }) => {
|
||||||
|
// Cast value to ChatConfig (assuming it's the entire chat config object)
|
||||||
|
const config = value as ChatConfig
|
||||||
|
|
||||||
|
// Helper to update config
|
||||||
|
const updateConfig = (updates: Partial<ChatConfig>) => {
|
||||||
|
if (onChange) {
|
||||||
|
onChange({ ...config, ...updates })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加发言频率规则
|
||||||
|
const addTalkValueRule = () => {
|
||||||
|
updateConfig({
|
||||||
|
talk_value_rules: [
|
||||||
|
...config.talk_value_rules,
|
||||||
|
{ target: '', time: '00:00-23:59', value: 1.0 },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除发言频率规则
|
||||||
|
const removeTalkValueRule = (index: number) => {
|
||||||
|
updateConfig({
|
||||||
|
talk_value_rules: config.talk_value_rules.filter((_, i) => i !== index),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新发言频率规则
|
||||||
|
const updateTalkValueRule = (
|
||||||
|
index: number,
|
||||||
|
field: 'target' | 'time' | 'value',
|
||||||
|
value: string | number
|
||||||
|
) => {
|
||||||
|
const newRules = [...config.talk_value_rules]
|
||||||
|
newRules[index] = {
|
||||||
|
...newRules[index],
|
||||||
|
[field]: value,
|
||||||
|
}
|
||||||
|
updateConfig({
|
||||||
|
talk_value_rules: newRules,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border bg-card p-4 sm:p-6 space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold mb-4">聊天设置</h3>
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="talk_value">聊天频率(基础值)</Label>
|
||||||
|
<Input
|
||||||
|
id="talk_value"
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
min="0"
|
||||||
|
max="1"
|
||||||
|
value={config.talk_value}
|
||||||
|
onChange={(e) => updateConfig({ talk_value: parseFloat(e.target.value) })}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">越小越沉默,范围 0-1</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="think_mode">思考模式</Label>
|
||||||
|
<Select
|
||||||
|
value={config.think_mode || 'classic'}
|
||||||
|
onValueChange={(value) => updateConfig({ think_mode: value as 'classic' | 'deep' | 'dynamic' })}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="think_mode">
|
||||||
|
<SelectValue placeholder="选择思考模式" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="classic">经典模式 - 浅度思考和回复</SelectItem>
|
||||||
|
<SelectItem value="deep">深度模式 - 进行深度思考和回复</SelectItem>
|
||||||
|
<SelectItem value="dynamic">动态模式 - 自动选择思考深度</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
控制麦麦的思考深度。经典模式回复快但简单;深度模式更深入但较慢;动态模式根据情况自动选择
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Switch
|
||||||
|
id="mentioned_bot_reply"
|
||||||
|
checked={config.mentioned_bot_reply}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
updateConfig({ mentioned_bot_reply: checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="mentioned_bot_reply" className="cursor-pointer">
|
||||||
|
启用提及必回复
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="max_context_size">上下文长度</Label>
|
||||||
|
<Input
|
||||||
|
id="max_context_size"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={config.max_context_size}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateConfig({ max_context_size: parseInt(e.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="planner_smooth">规划器平滑</Label>
|
||||||
|
<Input
|
||||||
|
id="planner_smooth"
|
||||||
|
type="number"
|
||||||
|
step="1"
|
||||||
|
min="0"
|
||||||
|
value={config.planner_smooth}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateConfig({ planner_smooth: parseFloat(e.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
增大数值会减小 planner 负荷,推荐 1-5,0 为关闭
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="plan_reply_log_max_per_chat">每个聊天流最大日志数量</Label>
|
||||||
|
<Input
|
||||||
|
id="plan_reply_log_max_per_chat"
|
||||||
|
type="number"
|
||||||
|
step="1"
|
||||||
|
min="100"
|
||||||
|
value={config.plan_reply_log_max_per_chat ?? 1024}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateConfig({ plan_reply_log_max_per_chat: parseInt(e.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
每个聊天流保存的 Plan/Reply 日志最大数量,超过此数量时会自动删除最老的日志
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Switch
|
||||||
|
id="llm_quote"
|
||||||
|
checked={config.llm_quote ?? false}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
updateConfig({ llm_quote: checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="llm_quote" className="cursor-pointer">
|
||||||
|
启用 LLM 控制引用
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground -mt-2 ml-10">
|
||||||
|
启用后,LLM 可以决定是否在回复时引用消息
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Switch
|
||||||
|
id="enable_talk_value_rules"
|
||||||
|
checked={config.enable_talk_value_rules}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
updateConfig({ enable_talk_value_rules: checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="enable_talk_value_rules" className="cursor-pointer">
|
||||||
|
启用动态发言频率规则
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 动态发言频率规则配置 */}
|
||||||
|
{config.enable_talk_value_rules && (
|
||||||
|
<div className="border-t pt-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-base font-semibold">动态发言频率规则</h4>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
按时段或聊天流ID调整发言频率,优先匹配具体聊天,再匹配全局规则
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={addTalkValueRule} size="sm">
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
添加规则
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{config.talk_value_rules && config.talk_value_rules.length > 0 ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{config.talk_value_rules.map((rule, index) => (
|
||||||
|
<div key={index} className="rounded-lg border p-4 bg-muted/50 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">
|
||||||
|
规则 #{index + 1}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<RulePreview rule={rule} />
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm">
|
||||||
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
确定要删除规则 #{index + 1} 吗?此操作无法撤销。
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={() => removeTalkValueRule(index)}>
|
||||||
|
删除
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 配置类型选择 */}
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-xs font-medium">配置类型</Label>
|
||||||
|
<Select
|
||||||
|
value={rule.target === '' ? 'global' : 'specific'}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (value === 'global') {
|
||||||
|
updateTalkValueRule(index, 'target', '')
|
||||||
|
} else {
|
||||||
|
updateTalkValueRule(index, 'target', 'qq::group')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="global">全局配置</SelectItem>
|
||||||
|
<SelectItem value="specific">详细配置</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 详细配置选项 - 只在非全局时显示 */}
|
||||||
|
{rule.target !== '' && (() => {
|
||||||
|
const parts = rule.target.split(':')
|
||||||
|
const platform = parts[0] || 'qq'
|
||||||
|
const chatId = parts[1] || ''
|
||||||
|
const chatType = parts[2] || 'group'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 p-3 sm:p-4 rounded-lg bg-muted/50">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-xs font-medium">平台</Label>
|
||||||
|
<Select
|
||||||
|
value={platform}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
updateTalkValueRule(index, 'target', `${value}:${chatId}:${chatType}`)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="qq">QQ</SelectItem>
|
||||||
|
<SelectItem value="wx">微信</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-xs font-medium">群 ID</Label>
|
||||||
|
<Input
|
||||||
|
value={chatId}
|
||||||
|
onChange={(e) => {
|
||||||
|
updateTalkValueRule(index, 'target', `${platform}:${e.target.value}:${chatType}`)
|
||||||
|
}}
|
||||||
|
placeholder="输入群 ID"
|
||||||
|
className="font-mono text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-xs font-medium">类型</Label>
|
||||||
|
<Select
|
||||||
|
value={chatType}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
updateTalkValueRule(index, 'target', `${platform}:${chatId}:${value}`)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="group">群组(group)</SelectItem>
|
||||||
|
<SelectItem value="private">私聊(private)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
当前聊天流 ID:{rule.target || '(未设置)'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* 时间段选择器 */}
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-xs font-medium">时间段 (Time)</Label>
|
||||||
|
<TimeRangePicker
|
||||||
|
value={rule.time}
|
||||||
|
onChange={(v) => updateTalkValueRule(index, 'time', v)}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
支持跨夜区间,例如 23:00-02:00
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 发言频率滑块 */}
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor={`rule-value-${index}`} className="text-xs font-medium">
|
||||||
|
发言频率值 (Value)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id={`rule-value-${index}`}
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0.01"
|
||||||
|
max="1"
|
||||||
|
value={rule.value}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = parseFloat(e.target.value)
|
||||||
|
if (!isNaN(val)) {
|
||||||
|
updateTalkValueRule(index, 'value', Math.max(0.01, Math.min(1, val)))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-20 h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
value={[rule.value]}
|
||||||
|
onValueChange={(values) =>
|
||||||
|
updateTalkValueRule(index, 'value', values[0])
|
||||||
|
}
|
||||||
|
min={0.01}
|
||||||
|
max={1}
|
||||||
|
step={0.01}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between text-xs text-muted-foreground">
|
||||||
|
<span>0.01 (极少发言)</span>
|
||||||
|
<span>0.5</span>
|
||||||
|
<span>1.0 (正常)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
<p className="text-sm">暂无规则,点击"添加规则"按钮创建</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-4 p-4 bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||||
|
<h5 className="text-sm font-semibold text-blue-900 dark:text-blue-100 mb-2">
|
||||||
|
📝 规则说明
|
||||||
|
</h5>
|
||||||
|
<ul className="text-xs text-blue-800 dark:text-blue-200 space-y-1">
|
||||||
|
<li>• <strong>Target 为空</strong>:全局规则,对所有聊天生效</li>
|
||||||
|
<li>• <strong>Target 指定</strong>:仅对特定聊天流生效(格式:platform:id:type)</li>
|
||||||
|
<li>• <strong>优先级</strong>:先匹配具体聊天流规则,再匹配全局规则</li>
|
||||||
|
<li>• <strong>时间支持跨夜</strong>:例如 23:00-02:00 表示晚上11点到次日凌晨2点</li>
|
||||||
|
<li>• <strong>数值范围</strong>:建议 0-1,0 表示完全沉默,1 表示正常发言</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
import type { FieldHookComponent } from '@/lib/field-hooks'
|
||||||
|
import { DebugSection } from '../sections/DebugSection'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DebugSection as a Field Hook Component
|
||||||
|
* This component replaces the entire 'debug' nested config section rendering
|
||||||
|
*/
|
||||||
|
export const DebugSectionHook: FieldHookComponent = ({ value, onChange }) => {
|
||||||
|
return (
|
||||||
|
<DebugSection
|
||||||
|
config={value as any}
|
||||||
|
onChange={(newConfig) => onChange?.(newConfig)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
import type { FieldHookComponent } from '@/lib/field-hooks'
|
||||||
|
import { ExpressionSection } from '../sections/ExpressionSection'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ExpressionSection as a Field Hook Component
|
||||||
|
* This component replaces the entire 'expression' nested config section rendering
|
||||||
|
*/
|
||||||
|
export const ExpressionSectionHook: FieldHookComponent = ({ value, onChange }) => {
|
||||||
|
return (
|
||||||
|
<ExpressionSection
|
||||||
|
config={value as any}
|
||||||
|
onChange={(newConfig) => onChange?.(newConfig)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
import type { FieldHookComponent } from '@/lib/field-hooks'
|
||||||
|
import { PersonalitySection } from '../sections/PersonalitySection'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PersonalitySection as a Field Hook Component
|
||||||
|
* This component replaces the entire 'personality' nested config section rendering
|
||||||
|
*/
|
||||||
|
export const PersonalitySectionHook: FieldHookComponent = ({ value, onChange }) => {
|
||||||
|
return (
|
||||||
|
<PersonalitySection
|
||||||
|
config={value as any}
|
||||||
|
onChange={(newConfig) => onChange?.(newConfig)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -4,3 +4,8 @@
|
||||||
|
|
||||||
export { useAutoSave, useConfigAutoSave } from './useAutoSave'
|
export { useAutoSave, useConfigAutoSave } from './useAutoSave'
|
||||||
export type { UseAutoSaveOptions, UseAutoSaveReturn, AutoSaveState } from './useAutoSave'
|
export type { UseAutoSaveOptions, UseAutoSaveReturn, AutoSaveState } from './useAutoSave'
|
||||||
|
export { ChatSectionHook } from './ChatSectionHook'
|
||||||
|
export { PersonalitySectionHook } from './PersonalitySectionHook'
|
||||||
|
export { DebugSectionHook } from './DebugSectionHook'
|
||||||
|
export { ExpressionSectionHook } from './ExpressionSectionHook'
|
||||||
|
export { BotInfoSectionHook } from './BotInfoSectionHook'
|
||||||
|
|
|
||||||
|
|
@ -58,9 +58,13 @@ import { SharePackDialog } from '@/components/share-pack-dialog'
|
||||||
|
|
||||||
// 导入模块化的类型定义和组件
|
// 导入模块化的类型定义和组件
|
||||||
import type { ModelInfo, ProviderConfig, ModelTaskConfig, TaskConfig } from './model/types'
|
import type { ModelInfo, ProviderConfig, ModelTaskConfig, TaskConfig } from './model/types'
|
||||||
import { TaskConfigCard, Pagination, ModelTable, ModelCardList } from './model/components'
|
import { Pagination, ModelTable, ModelCardList } from './model/components'
|
||||||
import { useModelTour, useModelFetcher, useModelAutoSave } from './model/hooks'
|
import { useModelTour, useModelFetcher, useModelAutoSave } from './model/hooks'
|
||||||
|
|
||||||
|
// 导入动态表单和 Hook 系统
|
||||||
|
import { DynamicConfigForm } from '@/components/dynamic-form'
|
||||||
|
import { fieldHooks } from '@/lib/field-hooks'
|
||||||
|
|
||||||
// 主导出组件:包装 RestartProvider
|
// 主导出组件:包装 RestartProvider
|
||||||
export function ModelConfigPage() {
|
export function ModelConfigPage() {
|
||||||
return (
|
return (
|
||||||
|
|
@ -75,7 +79,6 @@ function ModelConfigPageContent() {
|
||||||
const [models, setModels] = useState<ModelInfo[]>([])
|
const [models, setModels] = useState<ModelInfo[]>([])
|
||||||
const [providers, setProviders] = useState<string[]>([])
|
const [providers, setProviders] = useState<string[]>([])
|
||||||
const [providerConfigs, setProviderConfigs] = useState<ProviderConfig[]>([])
|
const [providerConfigs, setProviderConfigs] = useState<ProviderConfig[]>([])
|
||||||
const [modelNames, setModelNames] = useState<string[]>([])
|
|
||||||
const [taskConfig, setTaskConfig] = useState<ModelTaskConfig | null>(null)
|
const [taskConfig, setTaskConfig] = useState<ModelTaskConfig | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
@ -179,7 +182,6 @@ function ModelConfigPageContent() {
|
||||||
const config = await getModelConfig()
|
const config = await getModelConfig()
|
||||||
const modelList = (config.models as ModelInfo[]) || []
|
const modelList = (config.models as ModelInfo[]) || []
|
||||||
setModels(modelList)
|
setModels(modelList)
|
||||||
setModelNames(modelList.map((m) => m.name))
|
|
||||||
|
|
||||||
const providerList = (config.api_providers as ProviderConfig[]) || []
|
const providerList = (config.api_providers as ProviderConfig[]) || []
|
||||||
setProviders(providerList.map((p) => p.name))
|
setProviders(providerList.map((p) => p.name))
|
||||||
|
|
@ -429,8 +431,6 @@ function ModelConfigPageContent() {
|
||||||
}
|
}
|
||||||
|
|
||||||
setModels(newModels)
|
setModels(newModels)
|
||||||
// 立即更新模型名称列表
|
|
||||||
setModelNames(newModels.map((m) => m.name))
|
|
||||||
|
|
||||||
// 如果模型名称发生变化,更新任务配置中对该模型的引用
|
// 如果模型名称发生变化,更新任务配置中对该模型的引用
|
||||||
if (oldModelName && oldModelName !== modelToSave.name && taskConfig) {
|
if (oldModelName && oldModelName !== modelToSave.name && taskConfig) {
|
||||||
|
|
@ -488,8 +488,6 @@ function ModelConfigPageContent() {
|
||||||
if (deletingIndex !== null) {
|
if (deletingIndex !== null) {
|
||||||
const newModels = models.filter((_, i) => i !== deletingIndex)
|
const newModels = models.filter((_, i) => i !== deletingIndex)
|
||||||
setModels(newModels)
|
setModels(newModels)
|
||||||
// 立即更新模型名称列表
|
|
||||||
setModelNames(newModels.map((m) => m.name))
|
|
||||||
// 重新检查任务配置问题
|
// 重新检查任务配置问题
|
||||||
checkTaskConfigIssues(taskConfig, newModels)
|
checkTaskConfigIssues(taskConfig, newModels)
|
||||||
toast({
|
toast({
|
||||||
|
|
@ -542,8 +540,6 @@ function ModelConfigPageContent() {
|
||||||
const deletedCount = selectedModels.size
|
const deletedCount = selectedModels.size
|
||||||
const newModels = models.filter((_, index) => !selectedModels.has(index))
|
const newModels = models.filter((_, index) => !selectedModels.has(index))
|
||||||
setModels(newModels)
|
setModels(newModels)
|
||||||
// 立即更新模型名称列表
|
|
||||||
setModelNames(newModels.map((m) => m.name))
|
|
||||||
// 重新检查任务配置问题
|
// 重新检查任务配置问题
|
||||||
checkTaskConfigIssues(taskConfig, newModels)
|
checkTaskConfigIssues(taskConfig, newModels)
|
||||||
setSelectedModels(new Set())
|
setSelectedModels(new Set())
|
||||||
|
|
@ -554,53 +550,6 @@ function ModelConfigPageContent() {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新任务配置
|
|
||||||
const updateTaskConfig = (
|
|
||||||
taskName: keyof ModelTaskConfig,
|
|
||||||
field: keyof TaskConfig,
|
|
||||||
value: string[] | number | string
|
|
||||||
) => {
|
|
||||||
if (!taskConfig) return
|
|
||||||
|
|
||||||
// 检测 embedding 模型列表变化
|
|
||||||
if (taskName === 'embedding' && field === 'model_list' && Array.isArray(value)) {
|
|
||||||
const previousModels = previousEmbeddingModelsRef.current
|
|
||||||
const newModels = value as string[]
|
|
||||||
|
|
||||||
// 判断是否有变化(添加、删除或替换)
|
|
||||||
const hasChanges =
|
|
||||||
previousModels.length !== newModels.length ||
|
|
||||||
previousModels.some(model => !newModels.includes(model)) ||
|
|
||||||
newModels.some(model => !previousModels.includes(model))
|
|
||||||
|
|
||||||
if (hasChanges && previousModels.length > 0) {
|
|
||||||
// 存储待更新的配置
|
|
||||||
pendingEmbeddingUpdateRef.current = { field, value }
|
|
||||||
// 显示警告对话框
|
|
||||||
setEmbeddingWarningOpen(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 正常更新配置
|
|
||||||
const newTaskConfig = {
|
|
||||||
...taskConfig,
|
|
||||||
[taskName]: {
|
|
||||||
...taskConfig[taskName],
|
|
||||||
[field]: value,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
setTaskConfig(newTaskConfig)
|
|
||||||
|
|
||||||
// 重新检查任务配置问题
|
|
||||||
checkTaskConfigIssues(newTaskConfig, models)
|
|
||||||
|
|
||||||
// 如果是 embedding 模型列表,更新 ref
|
|
||||||
if (taskName === 'embedding' && field === 'model_list' && Array.isArray(value)) {
|
|
||||||
previousEmbeddingModelsRef.current = [...(value as string[])]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 确认更新嵌入模型
|
// 确认更新嵌入模型
|
||||||
const handleConfirmEmbeddingChange = () => {
|
const handleConfirmEmbeddingChange = () => {
|
||||||
if (!taskConfig || !pendingEmbeddingUpdateRef.current) return
|
if (!taskConfig || !pendingEmbeddingUpdateRef.current) return
|
||||||
|
|
@ -918,101 +867,22 @@ function ModelConfigPageContent() {
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{taskConfig && (
|
{taskConfig && (
|
||||||
<div className="grid gap-4 sm:gap-6">
|
<DynamicConfigForm
|
||||||
{/* Utils 任务 */}
|
schema={{
|
||||||
<TaskConfigCard
|
className: 'TaskConfig',
|
||||||
title="组件模型 (utils)"
|
classDoc: '任务配置',
|
||||||
description="用于表情包、取名、关系、情绪变化等组件"
|
fields: [],
|
||||||
taskConfig={taskConfig.utils}
|
nested: {},
|
||||||
modelNames={modelNames}
|
}}
|
||||||
onChange={(field, value) => updateTaskConfig('utils', field, value)}
|
values={{ taskConfig }}
|
||||||
dataTour="task-model-select"
|
onChange={(field, value) => {
|
||||||
/>
|
if (field === 'taskConfig') {
|
||||||
|
setTaskConfig(value as ModelTaskConfig)
|
||||||
{/* Tool Use 任务 */}
|
setHasUnsavedChanges(true)
|
||||||
<TaskConfigCard
|
}
|
||||||
title="工具调用模型 (tool_use)"
|
}}
|
||||||
description="需要使用支持工具调用的模型"
|
hooks={fieldHooks}
|
||||||
taskConfig={taskConfig.tool_use}
|
/>
|
||||||
modelNames={modelNames}
|
|
||||||
onChange={(field, value) => updateTaskConfig('tool_use', field, value)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Replyer 任务 */}
|
|
||||||
<TaskConfigCard
|
|
||||||
title="首要回复模型 (replyer)"
|
|
||||||
description="用于表达器和表达方式学习"
|
|
||||||
taskConfig={taskConfig.replyer}
|
|
||||||
modelNames={modelNames}
|
|
||||||
onChange={(field, value) => updateTaskConfig('replyer', field, value)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Planner 任务 */}
|
|
||||||
<TaskConfigCard
|
|
||||||
title="决策模型 (planner)"
|
|
||||||
description="负责决定麦麦该什么时候回复"
|
|
||||||
taskConfig={taskConfig.planner}
|
|
||||||
modelNames={modelNames}
|
|
||||||
onChange={(field, value) => updateTaskConfig('planner', field, value)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* VLM 任务 */}
|
|
||||||
<TaskConfigCard
|
|
||||||
title="图像识别模型 (vlm)"
|
|
||||||
description="视觉语言模型"
|
|
||||||
taskConfig={taskConfig.vlm}
|
|
||||||
modelNames={modelNames}
|
|
||||||
onChange={(field, value) => updateTaskConfig('vlm', field, value)}
|
|
||||||
hideTemperature
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Voice 任务 */}
|
|
||||||
<TaskConfigCard
|
|
||||||
title="语音识别模型 (voice)"
|
|
||||||
description="语音转文字"
|
|
||||||
taskConfig={taskConfig.voice}
|
|
||||||
modelNames={modelNames}
|
|
||||||
onChange={(field, value) => updateTaskConfig('voice', field, value)}
|
|
||||||
hideTemperature
|
|
||||||
hideMaxTokens
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Embedding 任务 */}
|
|
||||||
<TaskConfigCard
|
|
||||||
title="嵌入模型 (embedding)"
|
|
||||||
description="用于向量化"
|
|
||||||
taskConfig={taskConfig.embedding}
|
|
||||||
modelNames={modelNames}
|
|
||||||
onChange={(field, value) => updateTaskConfig('embedding', field, value)}
|
|
||||||
hideTemperature
|
|
||||||
hideMaxTokens
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* LPMM 相关任务 */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h3 className="text-lg font-semibold">LPMM 知识库模型</h3>
|
|
||||||
|
|
||||||
<TaskConfigCard
|
|
||||||
title="实体提取模型 (lpmm_entity_extract)"
|
|
||||||
description="从文本中提取实体"
|
|
||||||
taskConfig={taskConfig.lpmm_entity_extract}
|
|
||||||
modelNames={modelNames}
|
|
||||||
onChange={(field, value) =>
|
|
||||||
updateTaskConfig('lpmm_entity_extract', field, value)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TaskConfigCard
|
|
||||||
title="RDF 构建模型 (lpmm_rdf_build)"
|
|
||||||
description="构建知识图谱"
|
|
||||||
taskConfig={taskConfig.lpmm_rdf_build}
|
|
||||||
modelNames={modelNames}
|
|
||||||
onChange={(field, value) =>
|
|
||||||
updateTaskConfig('lpmm_rdf_build', field, value)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
|
||||||
|
|
@ -403,15 +403,15 @@ function IndexPageContent() {
|
||||||
const chartConfig = {
|
const chartConfig = {
|
||||||
requests: {
|
requests: {
|
||||||
label: '请求数',
|
label: '请求数',
|
||||||
color: 'hsl(var(--chart-1))',
|
color: 'hsl(var(--color-chart-1))',
|
||||||
},
|
},
|
||||||
cost: {
|
cost: {
|
||||||
label: '花费(¥)',
|
label: '花费(¥)',
|
||||||
color: 'hsl(var(--chart-2))',
|
color: 'hsl(var(--color-chart-2))',
|
||||||
},
|
},
|
||||||
tokens: {
|
tokens: {
|
||||||
label: 'Tokens',
|
label: 'Tokens',
|
||||||
color: 'hsl(var(--chart-3))',
|
color: 'hsl(var(--color-chart-3))',
|
||||||
},
|
},
|
||||||
} satisfies ChartConfig
|
} satisfies ChartConfig
|
||||||
|
|
||||||
|
|
@ -738,17 +738,17 @@ function IndexPageContent() {
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<ChartContainer config={chartConfig} className="h-[300px] sm:h-[400px] w-full aspect-auto">
|
<ChartContainer config={chartConfig} className="h-[300px] sm:h-[400px] w-full aspect-auto">
|
||||||
<LineChart data={hourly_data}>
|
<LineChart data={hourly_data}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--muted-foreground) / 0.2)" />
|
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--color-muted-foreground) / 0.2)" />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="timestamp"
|
dataKey="timestamp"
|
||||||
tickFormatter={(value) => formatDateTime(value)}
|
tickFormatter={(value) => formatDateTime(value)}
|
||||||
angle={-45}
|
angle={-45}
|
||||||
textAnchor="end"
|
textAnchor="end"
|
||||||
height={60}
|
height={60}
|
||||||
stroke="hsl(var(--muted-foreground))"
|
stroke="hsl(var(--color-muted-foreground))"
|
||||||
tick={{ fill: 'hsl(var(--muted-foreground))' }}
|
tick={{ fill: 'hsl(var(--color-muted-foreground))' }}
|
||||||
/>
|
/>
|
||||||
<YAxis stroke="hsl(var(--muted-foreground))" tick={{ fill: 'hsl(var(--muted-foreground))' }} />
|
<YAxis stroke="hsl(var(--color-muted-foreground))" tick={{ fill: 'hsl(var(--color-muted-foreground))' }} />
|
||||||
<ChartTooltip
|
<ChartTooltip
|
||||||
content={<ChartTooltipContent labelFormatter={(value) => formatDateTime(value as string)} />}
|
content={<ChartTooltipContent labelFormatter={(value) => formatDateTime(value as string)} />}
|
||||||
/>
|
/>
|
||||||
|
|
@ -772,17 +772,17 @@ function IndexPageContent() {
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<ChartContainer config={chartConfig} className="h-[250px] sm:h-[300px] w-full aspect-auto">
|
<ChartContainer config={chartConfig} className="h-[250px] sm:h-[300px] w-full aspect-auto">
|
||||||
<BarChart data={hourly_data}>
|
<BarChart data={hourly_data}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--muted-foreground) / 0.2)" />
|
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--color-muted-foreground) / 0.2)" />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="timestamp"
|
dataKey="timestamp"
|
||||||
tickFormatter={(value) => formatDateTime(value)}
|
tickFormatter={(value) => formatDateTime(value)}
|
||||||
angle={-45}
|
angle={-45}
|
||||||
textAnchor="end"
|
textAnchor="end"
|
||||||
height={60}
|
height={60}
|
||||||
stroke="hsl(var(--muted-foreground))"
|
stroke="hsl(var(--color-muted-foreground))"
|
||||||
tick={{ fill: 'hsl(var(--muted-foreground))' }}
|
tick={{ fill: 'hsl(var(--color-muted-foreground))' }}
|
||||||
/>
|
/>
|
||||||
<YAxis stroke="hsl(var(--muted-foreground))" tick={{ fill: 'hsl(var(--muted-foreground))' }} />
|
<YAxis stroke="hsl(var(--color-muted-foreground))" tick={{ fill: 'hsl(var(--color-muted-foreground))' }} />
|
||||||
<ChartTooltip
|
<ChartTooltip
|
||||||
content={<ChartTooltipContent labelFormatter={(value) => formatDateTime(value as string)} />}
|
content={<ChartTooltipContent labelFormatter={(value) => formatDateTime(value as string)} />}
|
||||||
/>
|
/>
|
||||||
|
|
@ -800,17 +800,17 @@ function IndexPageContent() {
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<ChartContainer config={chartConfig} className="h-[250px] sm:h-[300px] w-full aspect-auto">
|
<ChartContainer config={chartConfig} className="h-[250px] sm:h-[300px] w-full aspect-auto">
|
||||||
<BarChart data={hourly_data}>
|
<BarChart data={hourly_data}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--muted-foreground) / 0.2)" />
|
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--color-muted-foreground) / 0.2)" />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="timestamp"
|
dataKey="timestamp"
|
||||||
tickFormatter={(value) => formatDateTime(value)}
|
tickFormatter={(value) => formatDateTime(value)}
|
||||||
angle={-45}
|
angle={-45}
|
||||||
textAnchor="end"
|
textAnchor="end"
|
||||||
height={60}
|
height={60}
|
||||||
stroke="hsl(var(--muted-foreground))"
|
stroke="hsl(var(--color-muted-foreground))"
|
||||||
tick={{ fill: 'hsl(var(--muted-foreground))' }}
|
tick={{ fill: 'hsl(var(--color-muted-foreground))' }}
|
||||||
/>
|
/>
|
||||||
<YAxis stroke="hsl(var(--muted-foreground))" tick={{ fill: 'hsl(var(--muted-foreground))' }} />
|
<YAxis stroke="hsl(var(--color-muted-foreground))" tick={{ fill: 'hsl(var(--color-muted-foreground))' }} />
|
||||||
<ChartTooltip
|
<ChartTooltip
|
||||||
content={<ChartTooltipContent labelFormatter={(value) => formatDateTime(value as string)} />}
|
content={<ChartTooltipContent labelFormatter={(value) => formatDateTime(value as string)} />}
|
||||||
/>
|
/>
|
||||||
|
|
@ -889,7 +889,7 @@ function IndexPageContent() {
|
||||||
<div
|
<div
|
||||||
className="w-3 h-3 rounded-full ml-2 flex-shrink-0"
|
className="w-3 h-3 rounded-full ml-2 flex-shrink-0"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: `hsl(var(--chart-${(index % 5) + 1}))`,
|
backgroundColor: `hsl(var(--color-chart-${(index % 5) + 1}))`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -992,28 +992,28 @@ function IndexPageContent() {
|
||||||
config={{
|
config={{
|
||||||
requests: {
|
requests: {
|
||||||
label: '请求数',
|
label: '请求数',
|
||||||
color: 'hsl(var(--chart-1))',
|
color: 'hsl(var(--color-chart-1))',
|
||||||
},
|
},
|
||||||
cost: {
|
cost: {
|
||||||
label: '花费(¥)',
|
label: '花费(¥)',
|
||||||
color: 'hsl(var(--chart-2))',
|
color: 'hsl(var(--color-chart-2))',
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
className="h-[400px] sm:h-[500px] w-full aspect-auto"
|
className="h-[400px] sm:h-[500px] w-full aspect-auto"
|
||||||
>
|
>
|
||||||
<BarChart data={daily_data}>
|
<BarChart data={daily_data}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--muted-foreground) / 0.2)" />
|
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--color-muted-foreground) / 0.2)" />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="timestamp"
|
dataKey="timestamp"
|
||||||
tickFormatter={(value) => {
|
tickFormatter={(value) => {
|
||||||
const date = new Date(value)
|
const date = new Date(value)
|
||||||
return `${date.getMonth() + 1}/${date.getDate()}`
|
return `${date.getMonth() + 1}/${date.getDate()}`
|
||||||
}}
|
}}
|
||||||
stroke="hsl(var(--muted-foreground))"
|
stroke="hsl(var(--color-muted-foreground))"
|
||||||
tick={{ fill: 'hsl(var(--muted-foreground))' }}
|
tick={{ fill: 'hsl(var(--color-muted-foreground))' }}
|
||||||
/>
|
/>
|
||||||
<YAxis yAxisId="left" stroke="hsl(var(--muted-foreground))" tick={{ fill: 'hsl(var(--muted-foreground))' }} />
|
<YAxis yAxisId="left" stroke="hsl(var(--color-muted-foreground))" tick={{ fill: 'hsl(var(--color-muted-foreground))' }} />
|
||||||
<YAxis yAxisId="right" orientation="right" stroke="hsl(var(--muted-foreground))" tick={{ fill: 'hsl(var(--muted-foreground))' }} />
|
<YAxis yAxisId="right" orientation="right" stroke="hsl(var(--color-muted-foreground))" tick={{ fill: 'hsl(var(--color-muted-foreground))' }} />
|
||||||
<ChartTooltip
|
<ChartTooltip
|
||||||
content={
|
content={
|
||||||
<ChartTooltipContent
|
<ChartTooltipContent
|
||||||
|
|
|
||||||
|
|
@ -602,20 +602,19 @@ function PluginConfigEditor({ plugin, onBack }: PluginConfigEditorProps) {
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
<CodeEditor
|
<CodeEditor
|
||||||
value={sourceCode}
|
value={sourceCode}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
setSourceCode(value)
|
setSourceCode(value)
|
||||||
if (hasTomlError) {
|
if (hasTomlError) {
|
||||||
setHasTomlError(false)
|
setHasTomlError(false)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
language="toml"
|
language="toml"
|
||||||
theme="dark"
|
height="calc(100vh - 350px)"
|
||||||
height="calc(100vh - 350px)"
|
minHeight="500px"
|
||||||
minHeight="500px"
|
placeholder="TOML 配置内容"
|
||||||
placeholder="TOML 配置内容"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -16,29 +16,29 @@
|
||||||
|
|
||||||
/* 拖放区域样式 */
|
/* 拖放区域样式 */
|
||||||
.uppy-Dashboard-AddFiles {
|
.uppy-Dashboard-AddFiles {
|
||||||
border: 2px dashed hsl(var(--border)) !important;
|
border: 2px dashed hsl(var(--color-border)) !important;
|
||||||
border-radius: 0.5rem !important;
|
border-radius: 0.5rem !important;
|
||||||
background: hsl(var(--muted) / 0.3) !important;
|
background: hsl(var(--color-muted) / 0.3) !important;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.uppy-Dashboard-AddFiles:hover {
|
.uppy-Dashboard-AddFiles:hover {
|
||||||
border-color: hsl(var(--primary)) !important;
|
border-color: hsl(var(--color-primary)) !important;
|
||||||
background: hsl(var(--muted) / 0.5) !important;
|
background: hsl(var(--color-muted) / 0.5) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.uppy-Dashboard-AddFiles-title {
|
.uppy-Dashboard-AddFiles-title {
|
||||||
color: hsl(var(--foreground)) !important;
|
color: hsl(var(--color-foreground)) !important;
|
||||||
font-weight: 500 !important;
|
font-weight: 500 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.uppy-Dashboard-AddFiles-info {
|
.uppy-Dashboard-AddFiles-info {
|
||||||
color: hsl(var(--muted-foreground)) !important;
|
color: hsl(var(--color-muted-foreground)) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 按钮样式 */
|
/* 按钮样式 */
|
||||||
.uppy-Dashboard-browse {
|
.uppy-Dashboard-browse {
|
||||||
color: hsl(var(--primary)) !important;
|
color: hsl(var(--color-primary)) !important;
|
||||||
font-weight: 500 !important;
|
font-weight: 500 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -52,63 +52,63 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.uppy-Dashboard-Item {
|
.uppy-Dashboard-Item {
|
||||||
border-bottom-color: hsl(var(--border)) !important;
|
border-bottom-color: hsl(var(--color-border)) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.uppy-Dashboard-Item-name {
|
.uppy-Dashboard-Item-name {
|
||||||
color: hsl(var(--foreground)) !important;
|
color: hsl(var(--color-foreground)) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.uppy-Dashboard-Item-status {
|
.uppy-Dashboard-Item-status {
|
||||||
color: hsl(var(--muted-foreground)) !important;
|
color: hsl(var(--color-muted-foreground)) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 进度条样式 */
|
/* 进度条样式 */
|
||||||
.uppy-StatusBar {
|
.uppy-StatusBar {
|
||||||
background: hsl(var(--muted)) !important;
|
background: hsl(var(--color-muted)) !important;
|
||||||
border-top: 1px solid hsl(var(--border)) !important;
|
border-top: 1px solid hsl(var(--color-border)) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.uppy-StatusBar-progress {
|
.uppy-StatusBar-progress {
|
||||||
background: hsl(var(--primary)) !important;
|
background: hsl(var(--color-primary)) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.uppy-StatusBar-content {
|
.uppy-StatusBar-content {
|
||||||
color: hsl(var(--foreground)) !important;
|
color: hsl(var(--color-foreground)) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.uppy-StatusBar-actionBtn--upload {
|
.uppy-StatusBar-actionBtn--upload {
|
||||||
background: hsl(var(--primary)) !important;
|
background: hsl(var(--color-primary)) !important;
|
||||||
color: hsl(var(--primary-foreground)) !important;
|
color: hsl(var(--color-primary-foreground)) !important;
|
||||||
border-radius: 0.375rem !important;
|
border-radius: 0.375rem !important;
|
||||||
font-weight: 500 !important;
|
font-weight: 500 !important;
|
||||||
padding: 0.5rem 1rem !important;
|
padding: 0.5rem 1rem !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.uppy-StatusBar-actionBtn--upload:hover {
|
.uppy-StatusBar-actionBtn--upload:hover {
|
||||||
background: hsl(var(--primary) / 0.9) !important;
|
background: hsl(var(--color-primary) / 0.9) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Note 提示文字样式 */
|
/* Note 提示文字样式 */
|
||||||
.uppy-Dashboard-note {
|
.uppy-Dashboard-note {
|
||||||
color: hsl(var(--muted-foreground)) !important;
|
color: hsl(var(--color-muted-foreground)) !important;
|
||||||
font-size: 0.75rem !important;
|
font-size: 0.75rem !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 暗色主题适配 */
|
/* 暗色主题适配 */
|
||||||
[data-uppy-theme="dark"] .uppy-Dashboard-AddFiles,
|
[data-uppy-theme="dark"] .uppy-Dashboard-AddFiles,
|
||||||
.dark .uppy-Dashboard-AddFiles {
|
.dark .uppy-Dashboard-AddFiles {
|
||||||
background: hsl(var(--muted) / 0.2) !important;
|
background: hsl(var(--color-muted) / 0.2) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-uppy-theme="dark"] .uppy-Dashboard-AddFiles-title,
|
[data-uppy-theme="dark"] .uppy-Dashboard-AddFiles-title,
|
||||||
.dark .uppy-Dashboard-AddFiles-title {
|
.dark .uppy-Dashboard-AddFiles-title {
|
||||||
color: hsl(var(--foreground)) !important;
|
color: hsl(var(--color-foreground)) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-uppy-theme="dark"] .uppy-StatusBar,
|
[data-uppy-theme="dark"] .uppy-StatusBar,
|
||||||
.dark .uppy-StatusBar {
|
.dark .uppy-StatusBar {
|
||||||
background: hsl(var(--muted) / 0.5) !important;
|
background: hsl(var(--color-muted) / 0.5) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 移除 Uppy 自带的边框和阴影 */
|
/* 移除 Uppy 自带的边框和阴影 */
|
||||||
|
|
@ -124,7 +124,7 @@
|
||||||
|
|
||||||
/* 删除按钮样式 */
|
/* 删除按钮样式 */
|
||||||
.uppy-Dashboard-Item-action--remove {
|
.uppy-Dashboard-Item-action--remove {
|
||||||
color: hsl(var(--destructive)) !important;
|
color: hsl(var(--color-destructive)) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.uppy-Dashboard-Item-action--remove:hover {
|
.uppy-Dashboard-Item-action--remove:hover {
|
||||||
|
|
@ -137,7 +137,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.uppy-Dashboard-Item.is-error .uppy-Dashboard-Item-progress {
|
.uppy-Dashboard-Item.is-error .uppy-Dashboard-Item-progress {
|
||||||
color: hsl(var(--destructive)) !important;
|
color: hsl(var(--color-destructive)) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 滚动条样式 */
|
/* 滚动条样式 */
|
||||||
|
|
@ -150,10 +150,10 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.uppy-Dashboard-files::-webkit-scrollbar-thumb {
|
.uppy-Dashboard-files::-webkit-scrollbar-thumb {
|
||||||
background: hsl(var(--muted-foreground) / 0.3);
|
background: hsl(var(--color-muted-foreground) / 0.3);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.uppy-Dashboard-files::-webkit-scrollbar-thumb:hover {
|
.uppy-Dashboard-files::-webkit-scrollbar-thumb:hover {
|
||||||
background: hsl(var(--muted-foreground) / 0.5);
|
background: hsl(var(--color-muted-foreground) / 0.5);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
import '@testing-library/jest-dom/vitest'
|
||||||
|
|
||||||
|
globalThis.ResizeObserver = class ResizeObserver {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
|
writable: true,
|
||||||
|
value: (query: string) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: () => {},
|
||||||
|
removeListener: () => {},
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
dispatchEvent: () => {},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
@ -12,6 +12,8 @@ export type FieldType =
|
||||||
| 'object'
|
| 'object'
|
||||||
| 'textarea'
|
| 'textarea'
|
||||||
|
|
||||||
|
export type XWidgetType = 'slider' | 'select' | 'textarea' | 'switch' | 'custom'
|
||||||
|
|
||||||
export interface FieldSchema {
|
export interface FieldSchema {
|
||||||
name: string
|
name: string
|
||||||
type: FieldType
|
type: FieldType
|
||||||
|
|
@ -26,6 +28,9 @@ export interface FieldSchema {
|
||||||
type: string
|
type: string
|
||||||
}
|
}
|
||||||
properties?: ConfigSchema
|
properties?: ConfigSchema
|
||||||
|
'x-widget'?: XWidgetType
|
||||||
|
'x-icon'?: string
|
||||||
|
step?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConfigSchema {
|
export interface ConfigSchema {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
declare module '*.css'
|
||||||
|
|
@ -5,40 +5,61 @@ export default {
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
border: 'hsl(var(--border))',
|
border: 'hsl(var(--color-border))',
|
||||||
input: 'hsl(var(--input))',
|
input: 'hsl(var(--color-input))',
|
||||||
ring: 'hsl(var(--ring))',
|
ring: 'hsl(var(--color-ring))',
|
||||||
background: 'hsl(var(--background))',
|
background: 'hsl(var(--color-background))',
|
||||||
foreground: 'hsl(var(--foreground))',
|
foreground: 'hsl(var(--color-foreground))',
|
||||||
primary: {
|
primary: {
|
||||||
DEFAULT: 'hsl(var(--primary))',
|
DEFAULT: 'hsl(var(--color-primary))',
|
||||||
foreground: 'hsl(var(--primary-foreground))',
|
foreground: 'hsl(var(--color-primary-foreground))',
|
||||||
},
|
},
|
||||||
secondary: {
|
secondary: {
|
||||||
DEFAULT: 'hsl(var(--secondary))',
|
DEFAULT: 'hsl(var(--color-secondary))',
|
||||||
foreground: 'hsl(var(--secondary-foreground))',
|
foreground: 'hsl(var(--color-secondary-foreground))',
|
||||||
},
|
},
|
||||||
muted: {
|
muted: {
|
||||||
DEFAULT: 'hsl(var(--muted))',
|
DEFAULT: 'hsl(var(--color-muted))',
|
||||||
foreground: 'hsl(var(--muted-foreground))',
|
foreground: 'hsl(var(--color-muted-foreground))',
|
||||||
},
|
},
|
||||||
accent: {
|
accent: {
|
||||||
DEFAULT: 'hsl(var(--accent))',
|
DEFAULT: 'hsl(var(--color-accent))',
|
||||||
foreground: 'hsl(var(--accent-foreground))',
|
foreground: 'hsl(var(--color-accent-foreground))',
|
||||||
},
|
},
|
||||||
card: {
|
card: {
|
||||||
DEFAULT: 'hsl(var(--card))',
|
DEFAULT: 'hsl(var(--color-card))',
|
||||||
foreground: 'hsl(var(--card-foreground))',
|
foreground: 'hsl(var(--color-card-foreground))',
|
||||||
},
|
},
|
||||||
popover: {
|
popover: {
|
||||||
DEFAULT: 'hsl(var(--popover))',
|
DEFAULT: 'hsl(var(--color-popover))',
|
||||||
foreground: 'hsl(var(--popover-foreground))',
|
foreground: 'hsl(var(--color-popover-foreground))',
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: 'hsl(var(--color-destructive))',
|
||||||
|
foreground: 'hsl(var(--color-destructive-foreground))',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
borderRadius: {
|
borderRadius: {
|
||||||
lg: 'var(--radius)',
|
lg: 'var(--visual-radius-lg)',
|
||||||
md: 'calc(var(--radius) - 2px)',
|
md: 'var(--visual-radius-md)',
|
||||||
sm: 'calc(var(--radius) - 4px)',
|
sm: 'var(--visual-radius-sm)',
|
||||||
|
xl: 'var(--visual-radius-xl)',
|
||||||
|
full: 'var(--visual-radius-full)',
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: 'var(--typography-font-family-base)',
|
||||||
|
mono: 'var(--typography-font-family-code)',
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
sm: 'var(--visual-shadow-sm)',
|
||||||
|
md: 'var(--visual-shadow-md)',
|
||||||
|
lg: 'var(--visual-shadow-lg)',
|
||||||
|
xl: 'var(--visual-shadow-xl)',
|
||||||
|
},
|
||||||
|
transitionDuration: {
|
||||||
|
fast: 'var(--animation-anim-duration-fast)',
|
||||||
|
normal: 'var(--animation-anim-duration-normal)',
|
||||||
|
slow: 'var(--animation-anim-duration-slow)',
|
||||||
},
|
},
|
||||||
keyframes: {
|
keyframes: {
|
||||||
'slide-in-from-right': {
|
'slide-in-from-right': {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
"files": [],
|
"files": [],
|
||||||
"references": [
|
"references": [
|
||||||
{ "path": "./tsconfig.app.json" },
|
{ "path": "./tsconfig.app.json" },
|
||||||
{ "path": "./tsconfig.node.json" }
|
{ "path": "./tsconfig.node.json" },
|
||||||
|
{ "path": "./tsconfig.vitest.json" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.app.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"types": ["vite/client", "vitest/globals", "@testing-library/jest-dom"]
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
|
|
@ -23,6 +23,9 @@ export default defineConfig({
|
||||||
'@': path.resolve(__dirname, './src'),
|
'@': path.resolve(__dirname, './src'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
optimizeDeps: {
|
||||||
|
include: ['react', 'react-dom'],
|
||||||
|
},
|
||||||
build: {
|
build: {
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
output: {
|
output: {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
/// <reference types="vitest" />
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'jsdom',
|
||||||
|
setupFiles: './src/test/setup.ts',
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
@ -1,2 +1 @@
|
||||||
"""Core helpers for MCP Bridge Plugin."""
|
"""Core helpers for MCP Bridge Plugin."""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -167,4 +167,3 @@ def legacy_servers_list_to_claude_config(servers_list_json: str) -> str:
|
||||||
if not mcp_servers:
|
if not mcp_servers:
|
||||||
return ""
|
return ""
|
||||||
return json.dumps({"mcpServers": mcp_servers}, ensure_ascii=False, indent=2)
|
return json.dumps({"mcpServers": mcp_servers}, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -22,21 +22,24 @@ from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from src.common.logger import get_logger
|
from src.common.logger import get_logger
|
||||||
|
|
||||||
logger = get_logger("mcp_tool_chain")
|
logger = get_logger("mcp_tool_chain")
|
||||||
except ImportError:
|
except ImportError:
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger("mcp_tool_chain")
|
logger = logging.getLogger("mcp_tool_chain")
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ToolChainStep:
|
class ToolChainStep:
|
||||||
"""工具链步骤"""
|
"""工具链步骤"""
|
||||||
|
|
||||||
tool_name: str # 要调用的工具名(如 mcp_server_tool)
|
tool_name: str # 要调用的工具名(如 mcp_server_tool)
|
||||||
args_template: Dict[str, Any] = field(default_factory=dict) # 参数模板,支持变量替换
|
args_template: Dict[str, Any] = field(default_factory=dict) # 参数模板,支持变量替换
|
||||||
output_key: str = "" # 输出存储的键名,供后续步骤引用
|
output_key: str = "" # 输出存储的键名,供后续步骤引用
|
||||||
description: str = "" # 步骤描述
|
description: str = "" # 步骤描述
|
||||||
optional: bool = False # 是否可选(失败时继续执行)
|
optional: bool = False # 是否可选(失败时继续执行)
|
||||||
|
|
||||||
def to_dict(self) -> Dict[str, Any]:
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"tool_name": self.tool_name,
|
"tool_name": self.tool_name,
|
||||||
|
|
@ -45,7 +48,7 @@ class ToolChainStep:
|
||||||
"description": self.description,
|
"description": self.description,
|
||||||
"optional": self.optional,
|
"optional": self.optional,
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, data: Dict[str, Any]) -> "ToolChainStep":
|
def from_dict(cls, data: Dict[str, Any]) -> "ToolChainStep":
|
||||||
return cls(
|
return cls(
|
||||||
|
|
@ -60,12 +63,13 @@ class ToolChainStep:
|
||||||
@dataclass
|
@dataclass
|
||||||
class ToolChainDefinition:
|
class ToolChainDefinition:
|
||||||
"""工具链定义"""
|
"""工具链定义"""
|
||||||
|
|
||||||
name: str # 工具链名称(将作为组合工具的名称)
|
name: str # 工具链名称(将作为组合工具的名称)
|
||||||
description: str # 工具链描述(供 LLM 理解)
|
description: str # 工具链描述(供 LLM 理解)
|
||||||
steps: List[ToolChainStep] = field(default_factory=list) # 执行步骤
|
steps: List[ToolChainStep] = field(default_factory=list) # 执行步骤
|
||||||
input_params: Dict[str, str] = field(default_factory=dict) # 输入参数定义 {参数名: 描述}
|
input_params: Dict[str, str] = field(default_factory=dict) # 输入参数定义 {参数名: 描述}
|
||||||
enabled: bool = True # 是否启用
|
enabled: bool = True # 是否启用
|
||||||
|
|
||||||
def to_dict(self) -> Dict[str, Any]:
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"name": self.name,
|
"name": self.name,
|
||||||
|
|
@ -74,7 +78,7 @@ class ToolChainDefinition:
|
||||||
"input_params": self.input_params,
|
"input_params": self.input_params,
|
||||||
"enabled": self.enabled,
|
"enabled": self.enabled,
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, data: Dict[str, Any]) -> "ToolChainDefinition":
|
def from_dict(cls, data: Dict[str, Any]) -> "ToolChainDefinition":
|
||||||
steps = [ToolChainStep.from_dict(s) for s in data.get("steps", [])]
|
steps = [ToolChainStep.from_dict(s) for s in data.get("steps", [])]
|
||||||
|
|
@ -90,12 +94,13 @@ class ToolChainDefinition:
|
||||||
@dataclass
|
@dataclass
|
||||||
class ChainExecutionResult:
|
class ChainExecutionResult:
|
||||||
"""工具链执行结果"""
|
"""工具链执行结果"""
|
||||||
|
|
||||||
success: bool
|
success: bool
|
||||||
final_output: str # 最终输出(最后一个步骤的结果)
|
final_output: str # 最终输出(最后一个步骤的结果)
|
||||||
step_results: List[Dict[str, Any]] = field(default_factory=list) # 每个步骤的结果
|
step_results: List[Dict[str, Any]] = field(default_factory=list) # 每个步骤的结果
|
||||||
error: str = ""
|
error: str = ""
|
||||||
total_duration_ms: float = 0.0
|
total_duration_ms: float = 0.0
|
||||||
|
|
||||||
def to_summary(self) -> str:
|
def to_summary(self) -> str:
|
||||||
"""生成执行摘要"""
|
"""生成执行摘要"""
|
||||||
lines = []
|
lines = []
|
||||||
|
|
@ -103,7 +108,7 @@ class ChainExecutionResult:
|
||||||
status = "✅" if step.get("success") else "❌"
|
status = "✅" if step.get("success") else "❌"
|
||||||
tool = step.get("tool_name", "unknown")
|
tool = step.get("tool_name", "unknown")
|
||||||
duration = step.get("duration_ms", 0)
|
duration = step.get("duration_ms", 0)
|
||||||
lines.append(f"{status} 步骤{i+1}: {tool} ({duration:.0f}ms)")
|
lines.append(f"{status} 步骤{i + 1}: {tool} ({duration:.0f}ms)")
|
||||||
if not step.get("success") and step.get("error"):
|
if not step.get("success") and step.get("error"):
|
||||||
lines.append(f" 错误: {step['error'][:50]}")
|
lines.append(f" 错误: {step['error'][:50]}")
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
@ -111,49 +116,49 @@ class ChainExecutionResult:
|
||||||
|
|
||||||
class ToolChainExecutor:
|
class ToolChainExecutor:
|
||||||
"""工具链执行器"""
|
"""工具链执行器"""
|
||||||
|
|
||||||
# 变量替换模式: ${step.output_key} 或 ${input.param_name} 或 ${prev}
|
# 变量替换模式: ${step.output_key} 或 ${input.param_name} 或 ${prev}
|
||||||
VAR_PATTERN = re.compile(r'\$\{([^}]+)\}')
|
VAR_PATTERN = re.compile(r"\$\{([^}]+)\}")
|
||||||
|
|
||||||
def __init__(self, mcp_manager):
|
def __init__(self, mcp_manager):
|
||||||
self._mcp_manager = mcp_manager
|
self._mcp_manager = mcp_manager
|
||||||
|
|
||||||
def _resolve_tool_key(self, tool_name: str) -> Optional[str]:
|
def _resolve_tool_key(self, tool_name: str) -> Optional[str]:
|
||||||
"""解析工具名,返回有效的 tool_key
|
"""解析工具名,返回有效的 tool_key
|
||||||
|
|
||||||
支持:
|
支持:
|
||||||
- 直接使用 tool_key(如 mcp_server_tool)
|
- 直接使用 tool_key(如 mcp_server_tool)
|
||||||
- 使用注册后的工具名(会自动转换 - 和 . 为 _)
|
- 使用注册后的工具名(会自动转换 - 和 . 为 _)
|
||||||
"""
|
"""
|
||||||
all_tools = self._mcp_manager.all_tools
|
all_tools = self._mcp_manager.all_tools
|
||||||
|
|
||||||
# 直接匹配
|
# 直接匹配
|
||||||
if tool_name in all_tools:
|
if tool_name in all_tools:
|
||||||
return tool_name
|
return tool_name
|
||||||
|
|
||||||
# 尝试转换后匹配(用户可能使用了注册后的名称)
|
# 尝试转换后匹配(用户可能使用了注册后的名称)
|
||||||
normalized = tool_name.replace("-", "_").replace(".", "_")
|
normalized = tool_name.replace("-", "_").replace(".", "_")
|
||||||
if normalized in all_tools:
|
if normalized in all_tools:
|
||||||
return normalized
|
return normalized
|
||||||
|
|
||||||
# 尝试查找包含该名称的工具
|
# 尝试查找包含该名称的工具
|
||||||
for key in all_tools.keys():
|
for key in all_tools.keys():
|
||||||
if key.endswith(f"_{tool_name}") or key.endswith(f"_{normalized}"):
|
if key.endswith(f"_{tool_name}") or key.endswith(f"_{normalized}"):
|
||||||
return key
|
return key
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def execute(
|
async def execute(
|
||||||
self,
|
self,
|
||||||
chain: ToolChainDefinition,
|
chain: ToolChainDefinition,
|
||||||
input_args: Dict[str, Any],
|
input_args: Dict[str, Any],
|
||||||
) -> ChainExecutionResult:
|
) -> ChainExecutionResult:
|
||||||
"""执行工具链
|
"""执行工具链
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
chain: 工具链定义
|
chain: 工具链定义
|
||||||
input_args: 用户输入的参数
|
input_args: 用户输入的参数
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
ChainExecutionResult: 执行结果
|
ChainExecutionResult: 执行结果
|
||||||
"""
|
"""
|
||||||
|
|
@ -164,15 +169,15 @@ class ToolChainExecutor:
|
||||||
"step": {}, # 各步骤输出,按 output_key 存储
|
"step": {}, # 各步骤输出,按 output_key 存储
|
||||||
"prev": "", # 上一步的输出
|
"prev": "", # 上一步的输出
|
||||||
}
|
}
|
||||||
|
|
||||||
final_output = ""
|
final_output = ""
|
||||||
|
|
||||||
# 验证必需的输入参数
|
# 验证必需的输入参数
|
||||||
missing_params = []
|
missing_params = []
|
||||||
for param_name in chain.input_params.keys():
|
for param_name in chain.input_params.keys():
|
||||||
if param_name not in context["input"]:
|
if param_name not in context["input"]:
|
||||||
missing_params.append(param_name)
|
missing_params.append(param_name)
|
||||||
|
|
||||||
if missing_params:
|
if missing_params:
|
||||||
return ChainExecutionResult(
|
return ChainExecutionResult(
|
||||||
success=False,
|
success=False,
|
||||||
|
|
@ -180,7 +185,7 @@ class ToolChainExecutor:
|
||||||
error=f"缺少必需参数: {', '.join(missing_params)}",
|
error=f"缺少必需参数: {', '.join(missing_params)}",
|
||||||
total_duration_ms=(time.time() - start_time) * 1000,
|
total_duration_ms=(time.time() - start_time) * 1000,
|
||||||
)
|
)
|
||||||
|
|
||||||
for i, step in enumerate(chain.steps):
|
for i, step in enumerate(chain.steps):
|
||||||
step_start = time.time()
|
step_start = time.time()
|
||||||
step_result = {
|
step_result = {
|
||||||
|
|
@ -191,96 +196,96 @@ class ToolChainExecutor:
|
||||||
"error": "",
|
"error": "",
|
||||||
"duration_ms": 0,
|
"duration_ms": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 替换参数中的变量
|
# 替换参数中的变量
|
||||||
resolved_args = self._resolve_args(step.args_template, context)
|
resolved_args = self._resolve_args(step.args_template, context)
|
||||||
step_result["resolved_args"] = resolved_args
|
step_result["resolved_args"] = resolved_args
|
||||||
|
|
||||||
# 解析工具名
|
# 解析工具名
|
||||||
tool_key = self._resolve_tool_key(step.tool_name)
|
tool_key = self._resolve_tool_key(step.tool_name)
|
||||||
if not tool_key:
|
if not tool_key:
|
||||||
step_result["error"] = f"工具 {step.tool_name} 不存在"
|
step_result["error"] = f"工具 {step.tool_name} 不存在"
|
||||||
logger.warning(f"工具链步骤 {i+1}: 工具 {step.tool_name} 不存在")
|
logger.warning(f"工具链步骤 {i + 1}: 工具 {step.tool_name} 不存在")
|
||||||
|
|
||||||
if not step.optional:
|
if not step.optional:
|
||||||
step_results.append(step_result)
|
step_results.append(step_result)
|
||||||
return ChainExecutionResult(
|
return ChainExecutionResult(
|
||||||
success=False,
|
success=False,
|
||||||
final_output="",
|
final_output="",
|
||||||
step_results=step_results,
|
step_results=step_results,
|
||||||
error=f"步骤 {i+1}: 工具 {step.tool_name} 不存在",
|
error=f"步骤 {i + 1}: 工具 {step.tool_name} 不存在",
|
||||||
total_duration_ms=(time.time() - start_time) * 1000,
|
total_duration_ms=(time.time() - start_time) * 1000,
|
||||||
)
|
)
|
||||||
step_results.append(step_result)
|
step_results.append(step_result)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
logger.debug(f"工具链步骤 {i+1}: 调用 {tool_key},参数: {resolved_args}")
|
logger.debug(f"工具链步骤 {i + 1}: 调用 {tool_key},参数: {resolved_args}")
|
||||||
|
|
||||||
# 调用工具
|
# 调用工具
|
||||||
result = await self._mcp_manager.call_tool(tool_key, resolved_args)
|
result = await self._mcp_manager.call_tool(tool_key, resolved_args)
|
||||||
|
|
||||||
step_duration = (time.time() - step_start) * 1000
|
step_duration = (time.time() - step_start) * 1000
|
||||||
step_result["duration_ms"] = step_duration
|
step_result["duration_ms"] = step_duration
|
||||||
|
|
||||||
if result.success:
|
if result.success:
|
||||||
step_result["success"] = True
|
step_result["success"] = True
|
||||||
# 确保 content 不为 None
|
# 确保 content 不为 None
|
||||||
content = result.content if result.content is not None else ""
|
content = result.content if result.content is not None else ""
|
||||||
step_result["output"] = content
|
step_result["output"] = content
|
||||||
|
|
||||||
# 更新上下文
|
# 更新上下文
|
||||||
context["prev"] = content
|
context["prev"] = content
|
||||||
if step.output_key:
|
if step.output_key:
|
||||||
context["step"][step.output_key] = content
|
context["step"][step.output_key] = content
|
||||||
|
|
||||||
final_output = content
|
final_output = content
|
||||||
content_preview = content[:100] if content else "(空)"
|
content_preview = content[:100] if content else "(空)"
|
||||||
logger.debug(f"工具链步骤 {i+1} 成功: {content_preview}...")
|
logger.debug(f"工具链步骤 {i + 1} 成功: {content_preview}...")
|
||||||
else:
|
else:
|
||||||
step_result["error"] = result.error or "未知错误"
|
step_result["error"] = result.error or "未知错误"
|
||||||
logger.warning(f"工具链步骤 {i+1} 失败: {result.error}")
|
logger.warning(f"工具链步骤 {i + 1} 失败: {result.error}")
|
||||||
|
|
||||||
if not step.optional:
|
if not step.optional:
|
||||||
step_results.append(step_result)
|
step_results.append(step_result)
|
||||||
return ChainExecutionResult(
|
return ChainExecutionResult(
|
||||||
success=False,
|
success=False,
|
||||||
final_output="",
|
final_output="",
|
||||||
step_results=step_results,
|
step_results=step_results,
|
||||||
error=f"步骤 {i+1} ({step.tool_name}) 失败: {result.error}",
|
error=f"步骤 {i + 1} ({step.tool_name}) 失败: {result.error}",
|
||||||
total_duration_ms=(time.time() - start_time) * 1000,
|
total_duration_ms=(time.time() - start_time) * 1000,
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
step_duration = (time.time() - step_start) * 1000
|
step_duration = (time.time() - step_start) * 1000
|
||||||
step_result["duration_ms"] = step_duration
|
step_result["duration_ms"] = step_duration
|
||||||
step_result["error"] = str(e)
|
step_result["error"] = str(e)
|
||||||
logger.error(f"工具链步骤 {i+1} 异常: {e}")
|
logger.error(f"工具链步骤 {i + 1} 异常: {e}")
|
||||||
|
|
||||||
if not step.optional:
|
if not step.optional:
|
||||||
step_results.append(step_result)
|
step_results.append(step_result)
|
||||||
return ChainExecutionResult(
|
return ChainExecutionResult(
|
||||||
success=False,
|
success=False,
|
||||||
final_output="",
|
final_output="",
|
||||||
step_results=step_results,
|
step_results=step_results,
|
||||||
error=f"步骤 {i+1} ({step.tool_name}) 异常: {e}",
|
error=f"步骤 {i + 1} ({step.tool_name}) 异常: {e}",
|
||||||
total_duration_ms=(time.time() - start_time) * 1000,
|
total_duration_ms=(time.time() - start_time) * 1000,
|
||||||
)
|
)
|
||||||
|
|
||||||
step_results.append(step_result)
|
step_results.append(step_result)
|
||||||
|
|
||||||
total_duration = (time.time() - start_time) * 1000
|
total_duration = (time.time() - start_time) * 1000
|
||||||
|
|
||||||
return ChainExecutionResult(
|
return ChainExecutionResult(
|
||||||
success=True,
|
success=True,
|
||||||
final_output=final_output,
|
final_output=final_output,
|
||||||
step_results=step_results,
|
step_results=step_results,
|
||||||
total_duration_ms=total_duration,
|
total_duration_ms=total_duration,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _resolve_args(self, args_template: Dict[str, Any], context: Dict[str, Any]) -> Dict[str, Any]:
|
def _resolve_args(self, args_template: Dict[str, Any], context: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""解析参数模板,替换变量
|
"""解析参数模板,替换变量
|
||||||
|
|
||||||
支持的变量格式:
|
支持的变量格式:
|
||||||
- ${input.param_name}: 用户输入的参数
|
- ${input.param_name}: 用户输入的参数
|
||||||
- ${step.output_key}: 某个步骤的输出
|
- ${step.output_key}: 某个步骤的输出
|
||||||
|
|
@ -288,50 +293,48 @@ class ToolChainExecutor:
|
||||||
- ${prev.field}: 上一步输出(JSON)的某个字段
|
- ${prev.field}: 上一步输出(JSON)的某个字段
|
||||||
"""
|
"""
|
||||||
resolved = {}
|
resolved = {}
|
||||||
|
|
||||||
for key, value in args_template.items():
|
for key, value in args_template.items():
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
resolved[key] = self._substitute_vars(value, context)
|
resolved[key] = self._substitute_vars(value, context)
|
||||||
elif isinstance(value, dict):
|
elif isinstance(value, dict):
|
||||||
resolved[key] = self._resolve_args(value, context)
|
resolved[key] = self._resolve_args(value, context)
|
||||||
elif isinstance(value, list):
|
elif isinstance(value, list):
|
||||||
resolved[key] = [
|
resolved[key] = [self._substitute_vars(v, context) if isinstance(v, str) else v for v in value]
|
||||||
self._substitute_vars(v, context) if isinstance(v, str) else v
|
|
||||||
for v in value
|
|
||||||
]
|
|
||||||
else:
|
else:
|
||||||
resolved[key] = value
|
resolved[key] = value
|
||||||
|
|
||||||
return resolved
|
return resolved
|
||||||
|
|
||||||
def _substitute_vars(self, template: str, context: Dict[str, Any]) -> str:
|
def _substitute_vars(self, template: str, context: Dict[str, Any]) -> str:
|
||||||
"""替换字符串中的变量"""
|
"""替换字符串中的变量"""
|
||||||
|
|
||||||
def replacer(match):
|
def replacer(match):
|
||||||
var_path = match.group(1)
|
var_path = match.group(1)
|
||||||
return self._get_var_value(var_path, context)
|
return self._get_var_value(var_path, context)
|
||||||
|
|
||||||
return self.VAR_PATTERN.sub(replacer, template)
|
return self.VAR_PATTERN.sub(replacer, template)
|
||||||
|
|
||||||
def _get_var_value(self, var_path: str, context: Dict[str, Any]) -> str:
|
def _get_var_value(self, var_path: str, context: Dict[str, Any]) -> str:
|
||||||
"""获取变量值
|
"""获取变量值
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
var_path: 变量路径,如 "input.query", "step.search_result", "prev", "prev.id"
|
var_path: 变量路径,如 "input.query", "step.search_result", "prev", "prev.id"
|
||||||
context: 上下文
|
context: 上下文
|
||||||
"""
|
"""
|
||||||
parts = self._parse_var_path(var_path)
|
parts = self._parse_var_path(var_path)
|
||||||
|
|
||||||
if not parts:
|
if not parts:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
# 获取根对象
|
# 获取根对象
|
||||||
root = parts[0]
|
root = parts[0]
|
||||||
if root not in context:
|
if root not in context:
|
||||||
logger.warning(f"变量 {var_path} 的根 '{root}' 不存在")
|
logger.warning(f"变量 {var_path} 的根 '{root}' 不存在")
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
value = context[root]
|
value = context[root]
|
||||||
|
|
||||||
# 遍历路径
|
# 遍历路径
|
||||||
for part in parts[1:]:
|
for part in parts[1:]:
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
|
|
@ -349,7 +352,7 @@ class ToolChainExecutor:
|
||||||
value = ""
|
value = ""
|
||||||
else:
|
else:
|
||||||
value = ""
|
value = ""
|
||||||
|
|
||||||
# 确保返回字符串
|
# 确保返回字符串
|
||||||
if isinstance(value, (dict, list)):
|
if isinstance(value, (dict, list)):
|
||||||
return json.dumps(value, ensure_ascii=False)
|
return json.dumps(value, ensure_ascii=False)
|
||||||
|
|
@ -448,39 +451,39 @@ class ToolChainExecutor:
|
||||||
|
|
||||||
class ToolChainManager:
|
class ToolChainManager:
|
||||||
"""工具链管理器"""
|
"""工具链管理器"""
|
||||||
|
|
||||||
_instance: Optional["ToolChainManager"] = None
|
_instance: Optional["ToolChainManager"] = None
|
||||||
|
|
||||||
def __new__(cls):
|
def __new__(cls):
|
||||||
if cls._instance is None:
|
if cls._instance is None:
|
||||||
cls._instance = super().__new__(cls)
|
cls._instance = super().__new__(cls)
|
||||||
cls._instance._initialized = False
|
cls._instance._initialized = False
|
||||||
return cls._instance
|
return cls._instance
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
if self._initialized:
|
if self._initialized:
|
||||||
return
|
return
|
||||||
self._initialized = True
|
self._initialized = True
|
||||||
self._chains: Dict[str, ToolChainDefinition] = {}
|
self._chains: Dict[str, ToolChainDefinition] = {}
|
||||||
self._executor: Optional[ToolChainExecutor] = None
|
self._executor: Optional[ToolChainExecutor] = None
|
||||||
|
|
||||||
def set_executor(self, mcp_manager) -> None:
|
def set_executor(self, mcp_manager) -> None:
|
||||||
"""设置执行器"""
|
"""设置执行器"""
|
||||||
self._executor = ToolChainExecutor(mcp_manager)
|
self._executor = ToolChainExecutor(mcp_manager)
|
||||||
|
|
||||||
def add_chain(self, chain: ToolChainDefinition) -> bool:
|
def add_chain(self, chain: ToolChainDefinition) -> bool:
|
||||||
"""添加工具链"""
|
"""添加工具链"""
|
||||||
if not chain.name:
|
if not chain.name:
|
||||||
logger.error("工具链名称不能为空")
|
logger.error("工具链名称不能为空")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if chain.name in self._chains:
|
if chain.name in self._chains:
|
||||||
logger.warning(f"工具链 {chain.name} 已存在,将被覆盖")
|
logger.warning(f"工具链 {chain.name} 已存在,将被覆盖")
|
||||||
|
|
||||||
self._chains[chain.name] = chain
|
self._chains[chain.name] = chain
|
||||||
logger.info(f"已添加工具链: {chain.name} ({len(chain.steps)} 个步骤)")
|
logger.info(f"已添加工具链: {chain.name} ({len(chain.steps)} 个步骤)")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def remove_chain(self, name: str) -> bool:
|
def remove_chain(self, name: str) -> bool:
|
||||||
"""移除工具链"""
|
"""移除工具链"""
|
||||||
if name in self._chains:
|
if name in self._chains:
|
||||||
|
|
@ -488,19 +491,19 @@ class ToolChainManager:
|
||||||
logger.info(f"已移除工具链: {name}")
|
logger.info(f"已移除工具链: {name}")
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def get_chain(self, name: str) -> Optional[ToolChainDefinition]:
|
def get_chain(self, name: str) -> Optional[ToolChainDefinition]:
|
||||||
"""获取工具链"""
|
"""获取工具链"""
|
||||||
return self._chains.get(name)
|
return self._chains.get(name)
|
||||||
|
|
||||||
def get_all_chains(self) -> Dict[str, ToolChainDefinition]:
|
def get_all_chains(self) -> Dict[str, ToolChainDefinition]:
|
||||||
"""获取所有工具链"""
|
"""获取所有工具链"""
|
||||||
return self._chains.copy()
|
return self._chains.copy()
|
||||||
|
|
||||||
def get_enabled_chains(self) -> Dict[str, ToolChainDefinition]:
|
def get_enabled_chains(self) -> Dict[str, ToolChainDefinition]:
|
||||||
"""获取所有启用的工具链"""
|
"""获取所有启用的工具链"""
|
||||||
return {name: chain for name, chain in self._chains.items() if chain.enabled}
|
return {name: chain for name, chain in self._chains.items() if chain.enabled}
|
||||||
|
|
||||||
async def execute_chain(
|
async def execute_chain(
|
||||||
self,
|
self,
|
||||||
chain_name: str,
|
chain_name: str,
|
||||||
|
|
@ -514,64 +517,64 @@ class ToolChainManager:
|
||||||
final_output="",
|
final_output="",
|
||||||
error=f"工具链 {chain_name} 不存在",
|
error=f"工具链 {chain_name} 不存在",
|
||||||
)
|
)
|
||||||
|
|
||||||
if not chain.enabled:
|
if not chain.enabled:
|
||||||
return ChainExecutionResult(
|
return ChainExecutionResult(
|
||||||
success=False,
|
success=False,
|
||||||
final_output="",
|
final_output="",
|
||||||
error=f"工具链 {chain_name} 已禁用",
|
error=f"工具链 {chain_name} 已禁用",
|
||||||
)
|
)
|
||||||
|
|
||||||
if not self._executor:
|
if not self._executor:
|
||||||
return ChainExecutionResult(
|
return ChainExecutionResult(
|
||||||
success=False,
|
success=False,
|
||||||
final_output="",
|
final_output="",
|
||||||
error="工具链执行器未初始化",
|
error="工具链执行器未初始化",
|
||||||
)
|
)
|
||||||
|
|
||||||
return await self._executor.execute(chain, input_args)
|
return await self._executor.execute(chain, input_args)
|
||||||
|
|
||||||
def load_from_json(self, json_str: str) -> Tuple[int, List[str]]:
|
def load_from_json(self, json_str: str) -> Tuple[int, List[str]]:
|
||||||
"""从 JSON 字符串加载工具链配置
|
"""从 JSON 字符串加载工具链配置
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(成功加载数量, 错误列表)
|
(成功加载数量, 错误列表)
|
||||||
"""
|
"""
|
||||||
errors = []
|
errors = []
|
||||||
loaded = 0
|
loaded = 0
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = json.loads(json_str) if json_str.strip() else []
|
data = json.loads(json_str) if json_str.strip() else []
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
return 0, [f"JSON 解析失败: {e}"]
|
return 0, [f"JSON 解析失败: {e}"]
|
||||||
|
|
||||||
if not isinstance(data, list):
|
if not isinstance(data, list):
|
||||||
data = [data]
|
data = [data]
|
||||||
|
|
||||||
for i, item in enumerate(data):
|
for i, item in enumerate(data):
|
||||||
try:
|
try:
|
||||||
chain = ToolChainDefinition.from_dict(item)
|
chain = ToolChainDefinition.from_dict(item)
|
||||||
if not chain.name:
|
if not chain.name:
|
||||||
errors.append(f"第 {i+1} 个工具链缺少名称")
|
errors.append(f"第 {i + 1} 个工具链缺少名称")
|
||||||
continue
|
continue
|
||||||
if not chain.steps:
|
if not chain.steps:
|
||||||
errors.append(f"工具链 {chain.name} 没有步骤")
|
errors.append(f"工具链 {chain.name} 没有步骤")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
self.add_chain(chain)
|
self.add_chain(chain)
|
||||||
loaded += 1
|
loaded += 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
errors.append(f"第 {i+1} 个工具链解析失败: {e}")
|
errors.append(f"第 {i + 1} 个工具链解析失败: {e}")
|
||||||
|
|
||||||
return loaded, errors
|
return loaded, errors
|
||||||
|
|
||||||
def export_to_json(self, pretty: bool = True) -> str:
|
def export_to_json(self, pretty: bool = True) -> str:
|
||||||
"""导出所有工具链为 JSON"""
|
"""导出所有工具链为 JSON"""
|
||||||
chains_data = [chain.to_dict() for chain in self._chains.values()]
|
chains_data = [chain.to_dict() for chain in self._chains.values()]
|
||||||
if pretty:
|
if pretty:
|
||||||
return json.dumps(chains_data, ensure_ascii=False, indent=2)
|
return json.dumps(chains_data, ensure_ascii=False, indent=2)
|
||||||
return json.dumps(chains_data, ensure_ascii=False)
|
return json.dumps(chains_data, ensure_ascii=False)
|
||||||
|
|
||||||
def clear(self) -> None:
|
def clear(self) -> None:
|
||||||
"""清空所有工具链"""
|
"""清空所有工具链"""
|
||||||
self._chains.clear()
|
self._chains.clear()
|
||||||
|
|
|
||||||
|
|
@ -238,7 +238,7 @@ class TestCommand(BaseCommand):
|
||||||
chat_stream=self.message.chat_stream,
|
chat_stream=self.message.chat_stream,
|
||||||
reply_reason=reply_reason,
|
reply_reason=reply_reason,
|
||||||
enable_chinese_typo=False,
|
enable_chinese_typo=False,
|
||||||
extra_info=f"{reply_reason}用于测试bot的功能是否正常。请你按设定的人设表达一句\"测试正常\"",
|
extra_info=f'{reply_reason}用于测试bot的功能是否正常。请你按设定的人设表达一句"测试正常"',
|
||||||
)
|
)
|
||||||
if result_status:
|
if result_status:
|
||||||
# 发送生成的回复
|
# 发送生成的回复
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
{action_name}
|
||||||
|
动作描述:{action_description}
|
||||||
|
使用条件{parallel_text}:
|
||||||
|
{action_require}
|
||||||
|
{{"action":"{action_name}",{action_parameters}, "target_message_id":"消息id(m+数字)"}}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
{action_name}
|
||||||
|
动作描述:{action_description}
|
||||||
|
使用条件:
|
||||||
|
{action_require}
|
||||||
|
{{
|
||||||
|
"action": "{action_name}",{action_parameters},
|
||||||
|
"target_message_id":"触发action的消息id",
|
||||||
|
"reason":"触发action的原因"
|
||||||
|
}}
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
{time_block}
|
||||||
|
{name_block}
|
||||||
|
{chat_context_description},以下是具体的聊天内容
|
||||||
|
|
||||||
|
**聊天内容**
|
||||||
|
{chat_content_block}
|
||||||
|
|
||||||
|
**动作记录**
|
||||||
|
{actions_before_now_block}
|
||||||
|
|
||||||
|
**可用的action**
|
||||||
|
reply
|
||||||
|
动作描述:
|
||||||
|
进行回复,你可以自然的顺着正在进行的聊天内容进行回复或自然的提出一个问题
|
||||||
|
{{
|
||||||
|
"action": "reply",
|
||||||
|
"target_message_id":"想要回复的消息id",
|
||||||
|
"reason":"回复的原因"
|
||||||
|
}}
|
||||||
|
|
||||||
|
wait
|
||||||
|
动作描述:
|
||||||
|
暂时不再发言,等待指定时间。适用于以下情况:
|
||||||
|
- 你已经表达清楚一轮,想给对方留出空间
|
||||||
|
- 你感觉对方的话还没说完,或者自己刚刚发了好几条连续消息
|
||||||
|
- 你想要等待一定时间来让对方把话说完,或者等待对方反应
|
||||||
|
- 你想保持安静,专注"听"而不是马上回复
|
||||||
|
请你根据上下文来判断要等待多久,请你灵活判断:
|
||||||
|
- 如果你们交流间隔时间很短,聊的很频繁,不宜等待太久
|
||||||
|
- 如果你们交流间隔时间很长,聊的很少,可以等待较长时间
|
||||||
|
{{
|
||||||
|
"action": "wait",
|
||||||
|
"target_message_id":"想要作为这次等待依据的消息id(通常是对方的最新消息)",
|
||||||
|
"wait_seconds": 等待的秒数(必填,例如:5 表示等待5秒),
|
||||||
|
"reason":"选择等待的原因"
|
||||||
|
}}
|
||||||
|
|
||||||
|
complete_talk
|
||||||
|
动作描述:
|
||||||
|
当前聊天暂时结束了,对方离开,没有更多话题了
|
||||||
|
你可以使用该动作来暂时休息,等待对方有新发言再继续:
|
||||||
|
- 多次wait之后,对方迟迟不回复消息才用
|
||||||
|
- 如果对方只是短暂不回复,应该使用wait而不是complete_talk
|
||||||
|
- 聊天内容显示当前聊天已经结束或者没有新内容时候,选择complete_talk
|
||||||
|
选择此动作后,将不再继续循环思考,直到收到对方的新消息
|
||||||
|
{{
|
||||||
|
"action": "complete_talk",
|
||||||
|
"target_message_id":"触发完成对话的消息id(通常是对方的最新消息)",
|
||||||
|
"reason":"选择完成对话的原因"
|
||||||
|
}}
|
||||||
|
|
||||||
|
{action_options_text}
|
||||||
|
|
||||||
|
请选择合适的action,并说明触发action的消息id和选择该action的原因。消息id格式:m+数字
|
||||||
|
先输出你的选择思考理由,再输出你选择的action,理由是一段平文本,不要分点,精简。
|
||||||
|
**动作选择要求**
|
||||||
|
请你根据聊天内容,用户的最新消息和以下标准选择合适的动作:
|
||||||
|
{plan_style}
|
||||||
|
{moderation_prompt}
|
||||||
|
|
||||||
|
请选择所有符合使用要求的action,动作用json格式输出,如果输出多个json,每个json都要单独用```json包裹,你可以重复使用同一个动作或不同动作:
|
||||||
|
**示例**
|
||||||
|
// 理由文本
|
||||||
|
```json
|
||||||
|
{{
|
||||||
|
"action":"动作名",
|
||||||
|
"target_message_id":"触发动作的消息id",
|
||||||
|
//对应参数
|
||||||
|
}}
|
||||||
|
```
|
||||||
|
```json
|
||||||
|
{{
|
||||||
|
"action":"动作名",
|
||||||
|
"target_message_id":"触发动作的消息id",
|
||||||
|
//对应参数
|
||||||
|
}}
|
||||||
|
```
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
你正在qq群里聊天,下面是群里正在聊的内容:
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
正在群里聊天
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue