chore: import deployable mai-bot source tree
This commit is contained in:
49
.coderabbit.yaml
Normal file
49
.coderabbit.yaml
Normal file
@@ -0,0 +1,49 @@
|
||||
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
|
||||
|
||||
language: "zh-CN"
|
||||
|
||||
reviews:
|
||||
profile: "chill"
|
||||
request_changes_workflow: false
|
||||
high_level_summary: true
|
||||
high_level_summary_placeholder: "@coderabbitai summary"
|
||||
poem: false
|
||||
review_status: true
|
||||
commit_status: true
|
||||
collapse_walkthrough: false
|
||||
auto_review:
|
||||
enabled: true
|
||||
drafts: false
|
||||
base_branches:
|
||||
- "main"
|
||||
- "dev"
|
||||
path_filters:
|
||||
- "!logs/**"
|
||||
- "!data/**"
|
||||
- "!depends-data/**"
|
||||
- "!dashboard/dist-electron/**"
|
||||
- "!dashboard/node_modules/**"
|
||||
- "!**/*.log"
|
||||
- "!**/*.jsonl"
|
||||
- "!**/*.db"
|
||||
- "!**/*.db-shm"
|
||||
- "!**/*.db-wal"
|
||||
- "!**/*.bak"
|
||||
path_instructions:
|
||||
- path: "src/**/*.py"
|
||||
instructions: |
|
||||
本项目使用 Ruff 进行代码检查与格式化,行宽限制为 120 字符,字符串使用双引号。
|
||||
请重点关注以下方面:
|
||||
- 异步代码的正确性(async/await 使用是否合理)
|
||||
- 异常处理是否覆盖了边界情况
|
||||
- import 顺序需遵循项目规范:标准库/第三方库在前,本地模块在后;本地同级模块使用相对导入,跨目录使用以 `from src` 开头的绝对导入
|
||||
- 避免硬编码的敏感信息(API Key、密码等)
|
||||
- path: "plugins/**/*.py"
|
||||
instructions: |
|
||||
插件目录,请关注插件接口的规范使用以及与核心模块的依赖隔离性。
|
||||
- path: "*.toml"
|
||||
instructions: |
|
||||
配置文件,请检查字段合法性和格式规范,注意不要泄露敏感默认值。
|
||||
|
||||
chat:
|
||||
auto_reply: true
|
||||
31
.devcontainer/devcontainer.json
Normal file
31
.devcontainer/devcontainer.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "MaiBot-DevContainer",
|
||||
"image": "mcr.microsoft.com/devcontainers/python:1-3.12-bullseye",
|
||||
"features": {
|
||||
"ghcr.io/rocker-org/devcontainer-features/apt-packages:1": {
|
||||
"packages": [
|
||||
"tmux"
|
||||
]
|
||||
},
|
||||
"ghcr.io/devcontainers/features/github-cli:1": {}
|
||||
},
|
||||
"forwardPorts": [
|
||||
"8000:8000"
|
||||
],
|
||||
"postCreateCommand": "pip3 install --user -r requirements.txt",
|
||||
"customizations": {
|
||||
"jetbrains": {
|
||||
"backend": "PyCharm"
|
||||
},
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"tamasfe.even-better-toml",
|
||||
"njpwerner.autodocstring",
|
||||
"ms-python.python",
|
||||
"KevinRose.vsc-python-indent",
|
||||
"ms-python.vscode-pylance",
|
||||
"ms-python.autopep8"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
25
.dockerignore
Normal file
25
.dockerignore
Normal file
@@ -0,0 +1,25 @@
|
||||
.git
|
||||
__pycache__
|
||||
*.pyo
|
||||
*.pyd
|
||||
.DS_Store
|
||||
mongodb
|
||||
napcat
|
||||
docs/
|
||||
.github/
|
||||
# test
|
||||
.env
|
||||
.venv/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
.tmp_*/
|
||||
node_modules/
|
||||
dashboard/node_modules/
|
||||
data/
|
||||
logs/
|
||||
temp/
|
||||
tmp/
|
||||
mai_knowledge/
|
||||
depends-data/
|
||||
!depends-data/
|
||||
!depends-data/char_frequency.json
|
||||
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
181
.gitea/workflows/release-offline.yml
Normal file
181
.gitea/workflows/release-offline.yml
Normal file
@@ -0,0 +1,181 @@
|
||||
name: offline-release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-upload:
|
||||
runs-on: local-build
|
||||
steps:
|
||||
- name: Prepare local worktree
|
||||
env:
|
||||
MAIBOT_REPO_URL: https://git.lecspace.com/${{ gitea.repository }}.git
|
||||
MAIBOT_GIT_REPO_URL: ${{ secrets.MAIBOT_GIT_REPO_URL }}
|
||||
MAIBOT_REPO_SHA: ${{ gitea.sha }}
|
||||
MAIBOT_GITEA_USER: ${{ secrets.MAIBOT_GITEA_USER }}
|
||||
MAIBOT_GITEA_TOKEN: ${{ secrets.MAIBOT_GITEA_TOKEN }}
|
||||
shell: powershell
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
function Add-GitHubEnv {
|
||||
param([string]$Line)
|
||||
$utf8NoBom = [System.Text.UTF8Encoding]::new($false)
|
||||
[System.IO.File]::AppendAllText($env:GITHUB_ENV, $Line + [Environment]::NewLine, $utf8NoBom)
|
||||
}
|
||||
|
||||
$worktreeRoot = Join-Path ([System.IO.Path]::GetTempPath()) "maibot-actions"
|
||||
$worktree = Join-Path $worktreeRoot $env:MAIBOT_REPO_SHA
|
||||
$repoUrl = if ([string]::IsNullOrWhiteSpace($env:MAIBOT_GIT_REPO_URL)) { $env:MAIBOT_REPO_URL } else { $env:MAIBOT_GIT_REPO_URL }
|
||||
|
||||
if (Test-Path $worktree) {
|
||||
Remove-Item -LiteralPath $worktree -Recurse -Force
|
||||
}
|
||||
New-Item -ItemType Directory -Force -Path $worktreeRoot | Out-Null
|
||||
|
||||
$gitArgs = @()
|
||||
if (-not [string]::IsNullOrWhiteSpace($env:MAIBOT_GITEA_TOKEN)) {
|
||||
$giteaUser = $env:MAIBOT_GITEA_USER
|
||||
if ([string]::IsNullOrWhiteSpace($giteaUser)) { $giteaUser = "Losita" }
|
||||
$basicToken = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(("{0}:{1}" -f $giteaUser, $env:MAIBOT_GITEA_TOKEN)))
|
||||
$gitArgs += @("-c", ("http.extraHeader=Authorization: Basic {0}" -f $basicToken))
|
||||
}
|
||||
|
||||
& git @gitArgs clone --no-checkout $repoUrl $worktree
|
||||
if ($LASTEXITCODE -ne 0) { throw "source clone failed." }
|
||||
|
||||
& git -C $worktree checkout --force $env:MAIBOT_REPO_SHA
|
||||
if ($LASTEXITCODE -ne 0) { throw "source checkout failed." }
|
||||
|
||||
& git -C $worktree clean -dffx
|
||||
if ($LASTEXITCODE -ne 0) { throw "source cleanup failed." }
|
||||
|
||||
$appTag = (& git -C $worktree rev-parse --short=12 HEAD).Trim()
|
||||
Add-GitHubEnv "APP_TAG=$appTag"
|
||||
Add-GitHubEnv "MAIBOT_WORKTREE=$worktree"
|
||||
|
||||
- name: Build release archive
|
||||
shell: powershell
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
function Add-GitHubEnv {
|
||||
param([string]$Line)
|
||||
$utf8NoBom = [System.Text.UTF8Encoding]::new($false)
|
||||
[System.IO.File]::AppendAllText($env:GITHUB_ENV, $Line + [Environment]::NewLine, $utf8NoBom)
|
||||
}
|
||||
|
||||
Set-Location $env:MAIBOT_WORKTREE
|
||||
$archiveDir = Join-Path ([System.IO.Path]::GetTempPath()) "maibot-release"
|
||||
New-Item -ItemType Directory -Force -Path $archiveDir | Out-Null
|
||||
$archivePath = Join-Path $archiveDir ("mai-bot-{0}.tgz" -f $env:APP_TAG)
|
||||
if (Test-Path $archivePath) {
|
||||
Remove-Item -LiteralPath $archivePath -Force
|
||||
}
|
||||
|
||||
& git archive --format=tar.gz --output=$archivePath HEAD
|
||||
if ($LASTEXITCODE -ne 0) { throw "release archive failed." }
|
||||
|
||||
Add-GitHubEnv "RELEASE_ARCHIVE=$archivePath"
|
||||
|
||||
- name: Upload release to server
|
||||
env:
|
||||
MAIBOT_RELEASE_HOST: ${{ secrets.MAIBOT_RELEASE_HOST }}
|
||||
MAIBOT_RELEASE_USER: ${{ secrets.MAIBOT_RELEASE_USER }}
|
||||
MAIBOT_RELEASE_PORT: ${{ secrets.MAIBOT_RELEASE_PORT }}
|
||||
MAIBOT_RELEASE_ROOT: ${{ secrets.MAIBOT_RELEASE_ROOT }}
|
||||
MAIBOT_SSH_KEY: ${{ secrets.MAIBOT_SSH_KEY }}
|
||||
shell: powershell
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
$hostName = $env:MAIBOT_RELEASE_HOST
|
||||
if ([string]::IsNullOrWhiteSpace($hostName)) { $hostName = "192.140.166.210" }
|
||||
$userName = $env:MAIBOT_RELEASE_USER
|
||||
if ([string]::IsNullOrWhiteSpace($userName)) { $userName = "root" }
|
||||
$port = $env:MAIBOT_RELEASE_PORT
|
||||
if ([string]::IsNullOrWhiteSpace($port)) { $port = "22" }
|
||||
$releaseRoot = $env:MAIBOT_RELEASE_ROOT
|
||||
if ([string]::IsNullOrWhiteSpace($releaseRoot)) { $releaseRoot = "/srv/maibot/releases" }
|
||||
if ($releaseRoot -notmatch '^/srv/maibot/releases(/.*)?$') { throw "release root must stay under /srv/maibot/releases." }
|
||||
|
||||
$remote = "{0}@{1}" -f $userName, $hostName
|
||||
$remoteArchive = ("{0}/{1}.tgz" -f $releaseRoot.TrimEnd('/'), $env:APP_TAG)
|
||||
$sshArgs = @("-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=30", "-p", $port)
|
||||
$scpArgs = @("-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=30", "-P", $port)
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($env:MAIBOT_SSH_KEY)) {
|
||||
$keyPath = Join-Path ([System.IO.Path]::GetTempPath()) ("maibot-release-{0}.key" -f $env:APP_TAG)
|
||||
$env:MAIBOT_SSH_KEY.Replace("`r`n", "`n") | Out-File -FilePath $keyPath -Encoding ascii -NoNewline
|
||||
if ([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([System.Runtime.InteropServices.OSPlatform]::Windows)) {
|
||||
& icacls $keyPath /inheritance:r /grant:r "$($env:USERNAME):(R)" | Out-Null
|
||||
} else {
|
||||
& chmod 600 $keyPath
|
||||
}
|
||||
$sshArgs += @("-i", $keyPath)
|
||||
$scpArgs += @("-i", $keyPath)
|
||||
}
|
||||
|
||||
& ssh @sshArgs $remote "mkdir -p '$releaseRoot'"
|
||||
if ($LASTEXITCODE -ne 0) { throw "remote release root prepare failed." }
|
||||
|
||||
& scp @scpArgs $env:RELEASE_ARCHIVE ("{0}:{1}" -f $remote, $remoteArchive)
|
||||
if ($LASTEXITCODE -ne 0) { throw "release upload failed." }
|
||||
|
||||
- name: Cleanup worktree
|
||||
if: ${{ always() }}
|
||||
shell: powershell
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
$worktreeRoot = Join-Path ([System.IO.Path]::GetTempPath()) "maibot-actions"
|
||||
$expectedPrefix = $worktreeRoot.TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) + [System.IO.Path]::DirectorySeparatorChar
|
||||
if (-not [string]::IsNullOrWhiteSpace($env:MAIBOT_WORKTREE) -and $env:MAIBOT_WORKTREE.StartsWith($expectedPrefix, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
Remove-Item -LiteralPath $env:MAIBOT_WORKTREE -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
deploy:
|
||||
runs-on: build-host
|
||||
needs: build-upload
|
||||
steps:
|
||||
- name: Deploy release
|
||||
env:
|
||||
MAIBOT_REPO_SHA: ${{ gitea.sha }}
|
||||
MAIBOT_RELEASE_ROOT: ${{ secrets.MAIBOT_RELEASE_ROOT }}
|
||||
MAIBOT_RUNTIME_ROOT: ${{ secrets.MAIBOT_RUNTIME_ROOT }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
app_tag="${MAIBOT_REPO_SHA:0:12}"
|
||||
release_root="${MAIBOT_RELEASE_ROOT:-/srv/maibot/releases}"
|
||||
runtime_root="${MAIBOT_RUNTIME_ROOT:-/root/maibot-offline}"
|
||||
|
||||
case "$release_root" in
|
||||
/srv/maibot/releases|/srv/maibot/releases/*) ;;
|
||||
*)
|
||||
echo "release root must stay under /srv/maibot/releases" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$runtime_root" in
|
||||
/root/maibot-offline|/root/maibot-offline/*) ;;
|
||||
*)
|
||||
echo "runtime root must stay under /root/maibot-offline" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
archive="${release_root}/${app_tag}.tgz"
|
||||
release_dir="${release_root}/${app_tag}"
|
||||
|
||||
test -f "$archive"
|
||||
rm -rf "$release_dir"
|
||||
mkdir -p "$release_dir"
|
||||
tar -xzf "$archive" -C "$release_dir"
|
||||
chmod +x "$release_dir/deploy/server-maibot/activate-release.sh"
|
||||
MAIBOT_RUNTIME_ROOT="$runtime_root" "$release_dir/deploy/server-maibot/activate-release.sh" "$release_dir"
|
||||
rm -f "$archive"
|
||||
43
.gitignore
vendored
Normal file
43
.gitignore
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
# Python caches and virtualenvs
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.venv/
|
||||
env/
|
||||
venv/
|
||||
|
||||
# Runtime state
|
||||
data/
|
||||
logs/
|
||||
plugins/
|
||||
docker-config/
|
||||
config/*.toml
|
||||
config/*.bak*
|
||||
!config/README.md
|
||||
|
||||
# Frontend local outputs
|
||||
dashboard/.vite/
|
||||
dashboard/node_modules/
|
||||
dashboard/dist/
|
||||
dashboard/dist-ssr/
|
||||
|
||||
# Local environment and editor files
|
||||
.env
|
||||
.env.*
|
||||
.idea/
|
||||
.DS_Store
|
||||
*.local
|
||||
|
||||
# Local deployment artifacts
|
||||
*.log
|
||||
*.pem
|
||||
*.key
|
||||
*.tar
|
||||
*.tgz
|
||||
*.zip
|
||||
acme/
|
||||
backups/
|
||||
bin/
|
||||
_staging/
|
||||
10
.pre-commit-config.yaml
Normal file
10
.pre-commit-config.yaml
Normal file
@@ -0,0 +1,10 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.9.10
|
||||
hooks:
|
||||
# Run the linter.
|
||||
- id: ruff
|
||||
args: [ --fix ]
|
||||
# Run the formatter.
|
||||
- id: ruff-format
|
||||
59
AGENTS.md
Normal file
59
AGENTS.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# 代码规范
|
||||
# import 规范
|
||||
在从外部库进行导入时候,请遵循以下顺序:
|
||||
1. 对于标准库和第三方库的导入,请按照如下顺序:
|
||||
- 需要使用`from ... import ...`语法的导入放在前面。
|
||||
- 直接使用`import ...`语法的导入放在后面。
|
||||
- 对于使用`from ... import ...`导入的多个项,请**在保证不会引起import错误的前提下**,按照**字母顺序**排列。
|
||||
- 对于使用`import ...`导入的多个项,请**在保证不会引起import错误的前提下**,按照**字母顺序**排列。
|
||||
2. 对于本地模块的导入,请按照如下顺序:
|
||||
- 对于同一个文件夹下的模块导入,使用相对导入,排列顺序按照**不发生import错误的前提下**,随便排列。
|
||||
- 对于不同文件夹下的模块导入,使用绝对导入。这些导入应该以`from src`开头,并且按照**不发生import错误的前提下**,尽量使得第二层的文件夹名称相同的导入放在一起;第二层文件夹名称排列随机。
|
||||
3. 标准库和第三方库的导入应该放在本地模块导入的前面。
|
||||
4. 各个导入块之间应该使用一个空行进行分隔。
|
||||
5. 对于现有的代码,如果导入顺序不符合上述规范,在重构代码时应该调整导入顺序以符合规范。
|
||||
|
||||
## 注释规范
|
||||
1. 尽量保持良好的注释
|
||||
2. 如果原来的代码中有注释,则重构的时候,除非这部分代码被删除,否则相同功能的代码应该保留注释(可以对注释进行修改以保持准确性,但不应该删除注释)。
|
||||
3. 如果原来的代码中没有注释,则重构的时候,如果某个功能块的代码较长或者逻辑较为复杂,则应该添加注释来解释这部分代码的功能和逻辑。
|
||||
## 类型注解规范
|
||||
1. 重构代码时,如果原来的代码中有类型注解,则相同功能的代码应该保留类型注解(可以对类型注解进行修改以保持准确性,但不应该删除类型注解)。
|
||||
2. 重构代码时,如果原来的代码中没有类型注解,则重构的时候,如果某个函数的功能较为复杂或者参数较多,则应该添加类型注解来提高代码的可读性和可维护性。(对于简单的变量,可以不添加类型注解)
|
||||
3. 对于参数化泛型,应该使用`typing`模块中的类型注解来指定参数化泛型的类型。
|
||||
- 例如,使用`List[int]`来表示一个包含整数的列表,使用`Dict[str, Any]`来表示一个键为字符串,值为任意类型的字典。
|
||||
## 变量规范
|
||||
1. 当确定某个变量/实例是某种类型的时候(优先按照类型注解确定,除非你分析出类型注解是错误的),可以不必使用`or`进行fallback。
|
||||
- 例如,`bot_nickname = (global_config.bot.nickname or "").strip()` 可以改为 `bot_nickname = global_config.bot.nickname.strip()`,前提是我们确定`global_config.bot.nickname`一定是一个字符串。
|
||||
## 类属性使用规范
|
||||
1. 应该尽量减少使用getattr和setattr方法,除非是在对一个动态类进行处理或者使用Monkeypatch完成Pytest
|
||||
2. 在重构代码时,如果遇到getattr和setattr,应该尝试检查这个类实例是否有这个属性,如果有,则直接替换为类属性访问写法。
|
||||
- 举例:`v = getattr(instance, "value", "")` 在检查到`instance`有`value`属性后应该改为`v = instance.value`
|
||||
|
||||
# 运行/调试/构建/测试/依赖
|
||||
优先使用uv
|
||||
依赖项以 pyproject.toml 为准,要同步更新requirements.txt
|
||||
|
||||
# 语言规范
|
||||
项目的首选语言为简体中文,无论是注释语言,日志展示语言,还是 WebUI 展示语言都首要以简体中文为首要实现目标
|
||||
|
||||
# 配置文件修改
|
||||
如果你需要改动配置文件,不需要修改实际的bot_config.toml或者model_config.toml,只需要修改配置文件模版,并新增一个版本号即可,也不必要为配置改动创建测试文件。
|
||||
|
||||
|
||||
# 关于 A_memorix 修改
|
||||
如果修改涉及 `src/A_memorix`,请先阅读 `src/A_memorix/MODIFICATION_POLICY.md`。
|
||||
|
||||
# prompt模板、
|
||||
涉及对prompt模板的修改,要同步修改英文和日文的文件,对齐到中文
|
||||
|
||||
默认原则:
|
||||
1. `src/A_memorix` 的实现层改动应优先遵守 `src/A_memorix/MODIFICATION_POLICY.md` 中的归属约束。
|
||||
2. 不要提交无边界的 `ruff`、格式化、导入整理或大面积实现整理。
|
||||
3. 本地实验目录或依赖其运行的测试,除非明确说明并确认,否则不要进入共享历史。
|
||||
|
||||
# maibot插件开发文档
|
||||
https://github.com/Mai-with-u/maibot-plugin-sdk/blob/main/docs/guide.md
|
||||
|
||||
# 如何提交maibot插件
|
||||
https://github.com/Mai-with-u/plugin-repo/blob/main/CONTRIBUTING.md
|
||||
120
CODE_OF_CONDUCT.md
Normal file
120
CODE_OF_CONDUCT.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# 贡献者契约行为准则
|
||||
|
||||
## 我们的承诺
|
||||
|
||||
作为成员、贡献者和维护者,我们承诺为每个人提供友好、安全和受欢迎的环境,无论年龄、体型、身体或精神上的残疾、民族、性别特征、性别认同和表达、经验水平、教育、社会经济地位、国籍、个人外貌、种族、宗教或性取向如何。
|
||||
|
||||
我们承诺以有助于建立开放、友好、多元化、包容和健康社区的方式行事和互动。
|
||||
|
||||
## 我们的标准
|
||||
|
||||
有助于为我们的社区创造积极环境的行为示例包括:
|
||||
|
||||
* 表现出对其他人的同理心和善意
|
||||
* 尊重不同的意见、观点和经验
|
||||
* 优雅地给出和接受建设性反馈
|
||||
* 承担责任,为我们的错误向受影响的人道歉,并从中学习经验
|
||||
* 专注于不仅对我们个人,而且对整个社区最有利的事情
|
||||
* 使用友善和包容的语言
|
||||
* 专业地讨论技术问题,避免人身攻击
|
||||
|
||||
不可接受的行为示例包括:
|
||||
|
||||
* 使用性暗示的语言或图像,以及任何形式的性关注或性挑逗
|
||||
* 恶意评论、侮辱或贬损性评论,以及人身攻击或政治攻击
|
||||
* 公开或私下的骚扰
|
||||
* 未经明确许可,发布他人的私人信息,如物理地址或电子邮件地址
|
||||
* 在专业环境中合理认为不当的其他行为
|
||||
* 故意传播错误信息或误导性内容
|
||||
* 恶意破坏项目资源或社区讨论
|
||||
|
||||
## 执行责任
|
||||
|
||||
社区维护者负责澄清和执行我们可接受行为的标准,并会对他们认为不当、威胁、冒犯或有害的任何行为采取适当和公平的纠正措施。
|
||||
|
||||
社区维护者有权删除、编辑或拒绝与本行为准则不符的评论、提交、代码、wiki编辑、问题和其他贡献,并会在适当时传达审核决定的原因。
|
||||
|
||||
## 适用范围
|
||||
|
||||
本行为准则适用于所有社区空间,包括但不限于:
|
||||
|
||||
* GitHub 仓库及相关讨论区
|
||||
* Issue 和 Pull Request 讨论
|
||||
* 项目相关的在线论坛、聊天室和社交媒体
|
||||
* 项目官方活动和会议
|
||||
* 代表项目或社区的任何其他场合
|
||||
|
||||
当个人代表项目或其社区时,本行为准则也适用于公共空间。代表的示例包括使用官方电子邮件地址、通过官方社交媒体账户发布信息,或在在线或线下活动中担任指定代表。
|
||||
|
||||
## 特定于MaiBot项目的指导原则
|
||||
|
||||
### 技术讨论原则
|
||||
* 保持技术讨论的专业性和建设性
|
||||
* 在提出问题前,请先查看现有文档和已有的issues
|
||||
* 提供清晰、详细的错误报告和功能请求
|
||||
* 尊重不同的技术选择和实现方案
|
||||
|
||||
### AI/LLM相关内容规范
|
||||
* 讨论AI技术应当负责任和伦理
|
||||
* 不得分享或讨论可能造成伤害的AI应用
|
||||
* 尊重数据隐私和用户权益
|
||||
* 遵守相关法律法规和平台政策
|
||||
|
||||
### 多语言支持
|
||||
* 主要使用中文进行交流,但欢迎其他语言的贡献者
|
||||
* 对非中文母语用户保持耐心和友善
|
||||
* 在必要时提供翻译帮助
|
||||
|
||||
## 报告机制
|
||||
|
||||
如果您遇到或目睹违反行为准则的行为,请通过以下方式报告:
|
||||
|
||||
1. **GitHub Issues**: 对于公开的违规行为,可以在相关issue中直接指出
|
||||
2. **私下联系**: 可以通过GitHub私信联系项目维护者
|
||||
|
||||
所有报告都将得到及时和公正的处理。我们承诺保护报告者的隐私和安全。
|
||||
|
||||
## 执行措施
|
||||
|
||||
社区维护者将遵循以下社区影响指导原则来确定违反本行为准则的后果:
|
||||
|
||||
### 1. 更正
|
||||
**社区影响**: 使用不当语言或其他被认为在社区中不专业或不受欢迎的行为。
|
||||
|
||||
**后果**: 由社区维护者私下发出书面警告,提供关于违规性质的明确说明和行为不当的原因解释。可能会要求公开道歉。
|
||||
|
||||
### 2. 警告
|
||||
**社区影响**: 通过单个事件或一系列行为违规。
|
||||
|
||||
**后果**: 警告并说明继续违规的后果。在规定的时间内,不得与相关人员互动,包括主动与执行行为准则的人员互动。这包括避免在社区空间以及外部渠道(如社交媒体)中的互动。违反这些条款可能导致临时或永久禁令。
|
||||
|
||||
### 3. 临时禁令
|
||||
**社区影响**: 严重违反社区标准,包括持续的不当行为。
|
||||
|
||||
**后果**: 在规定的时间内临时禁止与社区进行任何形式的互动或公开交流。在此期间,不允许与相关人员进行公开或私下互动,包括主动与执行行为准则的人员互动。违反这些条款可能导致永久禁令。
|
||||
|
||||
### 4. 永久禁令
|
||||
**社区影响**: 表现出违反社区标准的模式,包括持续的不当行为、对个人的骚扰,或对某类个人的攻击或贬低。
|
||||
|
||||
**后果**: 永久禁止在社区内进行任何形式的公开互动。
|
||||
|
||||
## 归属
|
||||
|
||||
本行为准则改编自[贡献者契约](https://www.contributor-covenant.org/),版本2.1,可在 https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 获得。
|
||||
|
||||
社区影响指导原则的灵感来自[Mozilla 的行为准则执行阶梯](https://github.com/mozilla/diversity)。
|
||||
|
||||
有关本行为准则的常见问题解答,请参见 https://www.contributor-covenant.org/faq。翻译版本可在 https://www.contributor-covenant.org/translations 获得。
|
||||
|
||||
## 联系方式
|
||||
|
||||
如果您对本行为准则有任何疑问或建议,请通过以下方式联系我们:
|
||||
|
||||
* 在GitHub上创建issue进行讨论
|
||||
* 联系项目维护者
|
||||
|
||||
---
|
||||
|
||||
**感谢您帮助我们建设一个友好、包容的开源社区!**
|
||||
|
||||
*最后更新时间: 2025年6月21日*
|
||||
27
Dockerfile
Normal file
27
Dockerfile
Normal file
@@ -0,0 +1,27 @@
|
||||
# Runtime image
|
||||
FROM python:3.13-slim
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
||||
|
||||
# Working directory
|
||||
WORKDIR /MaiMBot
|
||||
|
||||
ENV MAIBOT_LEGACY_0X_UPGRADE_CONFIRMED=1
|
||||
ENV PATH="/MaiMBot/.venv/bin:${PATH}"
|
||||
|
||||
# Copy dependency metadata
|
||||
COPY pyproject.toml uv.lock ./
|
||||
|
||||
RUN apt-get update && apt-get install -y git
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN uv sync --frozen --no-dev --no-install-project
|
||||
|
||||
# Copy project source
|
||||
COPY . .
|
||||
|
||||
RUN git clone --depth 1 --branch main https://github.com/Mai-with-u/MaiBot-Napcat-Adapter.git plugin-templates/MaiBot-Napcat-Adapter
|
||||
RUN chmod +x docker-entrypoint.sh
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
ENTRYPOINT [ "./docker-entrypoint.sh" ]
|
||||
134
EULA.md
Normal file
134
EULA.md
Normal file
@@ -0,0 +1,134 @@
|
||||
# **MaiBot最终用户许可协议**
|
||||
|
||||
**版本:V1.2**
|
||||
**更新日期:2025年12月01日**
|
||||
**生效日期:2025年12月01日**
|
||||
**适用的MaiBot版本号:所有版本**
|
||||
|
||||
**2025© MaiBot项目团队**
|
||||
|
||||
---
|
||||
|
||||
## 一、一般条款
|
||||
|
||||
**1.1** MaiBot项目(包括MaiBot的源代码、可执行文件、文档,以及其它在本协议中所列出的文件)(以下简称“本项目”)是由开发者及贡献者(以下简称“项目团队”)共同维护,为用户提供自动回复功能的机器人代码项目。以下最终用户许可协议(EULA,以下简称“本协议”)是用户(以下简称“您”)与项目团队之间关于使用本项目所订立的合同条件。
|
||||
|
||||
**1.2** 在运行或使用本项目之前,您**必须阅读并同意本协议的所有条款**。未成年人或其它无/不完全民事行为能力责任人请**在监护人的陪同下**阅读并同意本协议。如果您不同意,则不得运行或使用本项目。在这种情况下,您应立即从您的设备上卸载或删除本项目及其所有副本。
|
||||
|
||||
## 二、许可授权
|
||||
|
||||
### 源代码许可
|
||||
|
||||
**2.1** 您**了解**本项目的源代码是基于GPLv3(GNU通用公共许可证第三版)开源协议发布的。您**可以自由使用、修改、分发**本项目的源代码,但**必须遵守**GPLv3许可证的要求。详细内容请参阅项目仓库中的LICENSE文件。
|
||||
|
||||
**2.2** 您**了解**本项目的源代码中可能包含第三方开源代码,这些代码的许可证可能与GPLv3许可证不同。您**同意**在使用这些代码时**遵守**相应的许可证要求.
|
||||
|
||||
### 输入输出内容授权
|
||||
|
||||
**2.4** 您**了解**本项目是使用您的配置信息、提交的指令(以下简称“输入内容”)和生成的内容(以下简称“输出内容”)构建请求发送到第三方生成回复的机器人项目。
|
||||
**2.4** 您**授权**本项目使用您的输入和输出内容按照项目的隐私政策用于以下行为:
|
||||
|
||||
- 调用第三方API生成回复;
|
||||
- 调用第三方API用于构建本项目专用的存储于您使用的数据库中的知识库和记忆库;
|
||||
- 调用第三方开发的插件系统功能;
|
||||
- 收集并记录本项目专用的存储于您使用的设备中的日志;
|
||||
|
||||
**2.4** 您**了解**本项目的源代码中包含第三方API的调用代码,这些API的使用可能受到第三方的服务条款和隐私政策的约束。在使用这些API时,您**必须遵守**相应的服务条款。
|
||||
|
||||
**2.5** 项目团队**不对**第三方API的服务质量、稳定性、准确性、安全性负责,亦**不对**第三方API的服务变更、终止、限制等行为负责。
|
||||
|
||||
## 三、用户行为
|
||||
|
||||
**3.1** 您**了解**本项目会将您的配置信息、输入指令和生成内容发送到第三方,您**不应**在输入指令和生成内容中包含以下内容:
|
||||
|
||||
- 涉及任何国家或地区秘密、商业秘密或其他可能会对国家或地区安全或者公共利益造成不利影响的数据;
|
||||
- 涉及个人隐私、个人信息或其他敏感信息的数据;
|
||||
- 任何侵犯他人合法权益的内容;
|
||||
- 任何违反国家或地区法律法规、政策规定的内容;
|
||||
|
||||
**3.2** 您**不应**将本项目用于以下用途:
|
||||
|
||||
- 违反任何国家或地区法律法规、政策规定的行为;
|
||||
|
||||
**3.3** 您**应当**自行确保您被存储在本项目的知识库、记忆库和日志中的输入和输出内容的合法性与合规性以及存储行为的合法性与合规性。您需**自行承担**由此产生的任何法律责任。
|
||||
|
||||
**3.4** 对于第三方插件的使用,您**不应**:
|
||||
|
||||
- 安装、使用任何来源不明或未经验证的第三方插件;
|
||||
- 使用任何违反法律法规、政策规定或第三方平台规则的第三方插件;
|
||||
|
||||
**3.5** 您**应当**自行确保您安装和使用的第三方插件的合法性与合规性以及安装和使用行为的合法性与合规性。您需**自行承担**由此产生的任何法律责任。
|
||||
|
||||
**3.6** 由于本项目会将您的输入指令和生成内容发送到第三方,当您将本项目用于第三方交流环境(如与除您以外的人私聊、群聊、论坛、直播等)时,您**应当**事先明确告知其他交流参与者本项目的使用情况,包括但不限于:
|
||||
|
||||
- 本项目的输出内容是由人工智能生成的;
|
||||
- 本项目会将交流内容发送到第三方;
|
||||
- 本项目的隐私政策和用户行为要求;
|
||||
|
||||
您需**自行承担**由此产生的任何后果和法律责任。
|
||||
|
||||
**3.7** 项目团队**不鼓励**也**不支持**将本项目用于商业用途,但若您确实需要将本项目用于商业用途,您**应当**标明项目地址(如“本项目由MaiBot(<https://github.com/Mai-with-u/MaiBot>)驱动”),并**自行承担**由此产生的任何法律责任。
|
||||
|
||||
## 四、免责条款
|
||||
|
||||
**4.1** 本项目的输出内容依赖第三方API,**不受**项目团队控制,亦**不代表**项目团队的观点。
|
||||
|
||||
**4.2** 除本协议条目2.4提到的隐私政策之外,项目团队**不会**对您提供任何形式的担保,亦**不对**使用本项目的造成的任何直接或间接后果负责。
|
||||
|
||||
**4.3** 关于第三方插件,项目团队**声明**:
|
||||
|
||||
- 项目团队**不对**任何第三方插件的功能、安全性、稳定性、合规性或适用性提供任何形式的保证或担保;
|
||||
- 项目团队**不对**因使用第三方插件而产生的任何直接或间接后果承担责任;
|
||||
- 项目团队**不对**第三方插件的质量问题、技术支持、bug修复等事宜负责。如有相关问题,应**直接联系插件开发者**;
|
||||
|
||||
## 五、其他条款
|
||||
|
||||
**5.1** 项目团队有权**随时修改本协议的条款**,但**无义务**通知您。修改后的协议将在本项目的新版本中推送,您应定期检查本协议的最新版本。
|
||||
|
||||
**5.2** 项目团队**保留**本协议的最终解释权。
|
||||
|
||||
## 附录:其他重要须知
|
||||
|
||||
### 一、风险提示
|
||||
|
||||
**1.1** 隐私安全风险
|
||||
|
||||
- 本项目会将您的配置信息、输入指令和生成内容发送到第三方API,而这些API的服务质量、稳定性、准确性、安全性不受项目团队控制。
|
||||
- 本项目会收集您的输入和输出内容,用于构建本项目专用的知识库和记忆库,以提高回复的准确性和连贯性。
|
||||
|
||||
**因此,为了保障您的隐私信息安全,请注意以下事项:**
|
||||
|
||||
- 避免在涉及个人隐私、个人信息或其他敏感信息的环境中使用本项目;
|
||||
- 避免在不可信的环境中使用本项目;
|
||||
|
||||
**1.2** 精神健康风险
|
||||
|
||||
本项目仅为工具型机器人,不具备情感交互能力。建议用户:
|
||||
|
||||
- 避免过度依赖AI回复处理现实问题或情绪困扰;
|
||||
- 如感到心理不适,请及时寻求专业心理咨询服务;
|
||||
- 如遇心理困扰,请寻求专业帮助(全国心理援助热线:12355);
|
||||
|
||||
**1.3** 第三方插件风险
|
||||
|
||||
本项目的插件系统允许加载第三方开发的插件,这可能带来以下风险:
|
||||
|
||||
- **安全风险**:第三方插件可能包含恶意代码、安全漏洞或未知的安全威胁;
|
||||
- **稳定性风险**:插件可能导致系统崩溃、性能下降或功能异常;
|
||||
- **隐私风险**:插件可能收集、传输或泄露您的个人信息和数据;
|
||||
- **合规风险**:插件的功能或行为可能违反相关法律法规或平台规则;
|
||||
- **兼容性风险**:插件可能与主程序或其他插件产生冲突;
|
||||
|
||||
**因此,在使用第三方插件时,请务必:**
|
||||
|
||||
- 仅从可信来源获取和安装插件;
|
||||
- 在安装前仔细了解插件的功能、权限和开发者信息;
|
||||
- 定期检查和更新已安装的插件;
|
||||
- 如发现插件异常行为,请立即停止使用并卸载;
|
||||
|
||||
### 二、其他
|
||||
|
||||
**2.1** 争议解决
|
||||
|
||||
- 本协议适用中国法律,争议提交相关地区法院管辖;
|
||||
- 若因GPLv3许可产生纠纷,以许可证官方解释为准。
|
||||
684
LICENSE
684
LICENSE
@@ -1,18 +1,674 @@
|
||||
MIT License
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (c) 2026 Losita
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
||||
associated documentation files (the "Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
|
||||
following conditions:
|
||||
Preamble
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial
|
||||
portions of the Software.
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
|
||||
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
|
||||
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
|
||||
USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
|
||||
28
PRIVACY.md
Normal file
28
PRIVACY.md
Normal file
@@ -0,0 +1,28 @@
|
||||
### MaiBot用户隐私条款
|
||||
**版本:V1.1**
|
||||
**更新日期:2025年7月10日**
|
||||
**生效日期:2025年3月18日**
|
||||
**适用的MaiBot版本号:所有版本**
|
||||
|
||||
**2025© MaiBot项目团队**
|
||||
|
||||
MaiBot项目团队(以下简称项目团队)**尊重并保护**用户(以下简称您)的隐私。若您选择使用MaiBot项目(以下简称本项目),则您需同意本项目按照以下隐私条款处理您的输入和输出内容:
|
||||
|
||||
**1.1** 本项目**会**收集您的输入和输出内容并发送到第三方API,用于生成新的输出内容。因此您的输入和输出内容**会**同时受到本项目和第三方API的隐私政策约束。
|
||||
|
||||
**1.2** 本项目**会**收集您的输入和输出内容,用于构建本项目专用的仅存储在您使用的数据库中的知识库和记忆库,以提高回复的准确性和连贯性。
|
||||
|
||||
**1.3** 本项目**会**收集您的输入和输出内容,用于生成仅存储于您部署或使用的设备中的不会上传至互联网的日志。但当您向项目团队反馈问题时,项目团队可能需要您提供日志文件以帮助解决问题。
|
||||
|
||||
**1.4** 本项目可能**会**收集部分统计信息(如使用频率、基础指令类型)以改进服务,您可在[bot_config.toml]中随时关闭此功能**。
|
||||
|
||||
**1.5** 关于第三方插件的隐私处理:
|
||||
- 本项目包含插件系统,允许加载第三方开发者开发的插件;
|
||||
- **第三方插件可能会**收集、处理、存储或传输您的数据,这些行为**完全由插件开发者控制**,与项目团队无关;
|
||||
- 项目团队**无法监控或控制**第三方插件的数据处理行为,亦**无法保证**第三方插件的隐私安全性;
|
||||
- 第三方插件的隐私政策**由插件开发者负责制定和执行**,您应直接向插件开发者了解其隐私处理方式;
|
||||
- 您使用第三方插件时,**需自行评估**插件的隐私风险并**自行承担**相关后果;
|
||||
|
||||
**1.6** 由于您的自身行为或不可抗力等情形,导致上述可能涉及您隐私或您认为是私人信息的内容发生被泄露、批漏,或被第三方获取、使用、转让等情形的,均由您**自行承担**不利后果,我们对此**不承担**任何责任。**特别地,因使用第三方插件而导致的任何隐私泄露或数据安全问题,项目团队概不负责。**
|
||||
|
||||
**1.7** 项目团队保留在未来更新隐私条款的权利,但没有义务通知您。若您不同意更新后的隐私条款,您应立即停止使用本项目。
|
||||
216
README.md
216
README.md
@@ -1,3 +1,215 @@
|
||||
# mai-bot
|
||||
<a id="-双语--bilingual"></a>
|
||||
|
||||
forked from https://github.com/Mai-with-u/MaiBot
|
||||
<div align="center">
|
||||
|
||||
<!-- Language Switcher -->
|
||||
<a href="#-双语--bilingual">双语 / Bilingual</a> | <a href="docs/README_CN.md">中文</a> | <a href="#english">English</a>
|
||||
|
||||
<br>
|
||||
<br>
|
||||
|
||||
<h1>麦麦 MaiBot <sub><small>MaiSaka</small></sub></h1>
|
||||
<sub><sup>An interactive agent based on large language models.</sup></sub>
|
||||
|
||||
<!-- Badges Row -->
|
||||
<p>
|
||||
<img src="https://img.shields.io/badge/Python-3.10+-blue" alt="Python Version">
|
||||
<img src="https://img.shields.io/github/license/Mai-with-u/MaiBot?label=License" alt="License">
|
||||
<img src="https://img.shields.io/badge/Status-In%20Development-yellow" alt="Status">
|
||||
<img src="https://img.shields.io/github/contributors/Mai-with-u/MaiBot.svg?style=flat&label=Contributors" alt="Contributors">
|
||||
<img src="https://img.shields.io/github/forks/Mai-with-u/MaiBot.svg?style=flat&label=Forks" alt="Forks">
|
||||
<img src="https://img.shields.io/github/stars/Mai-with-u/MaiBot?style=flat&label=Stars" alt="Stars">
|
||||
<a href="https://deepwiki.com/DrSmoothl/MaiBot"><img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki"></a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<br>
|
||||
|
||||
<!-- Mascot on the Right (Float) -->
|
||||
<img src="depends-data/maimai-v2.png" align="right" width="40%" alt="MaiBot Character" style="margin-left: 20px; margin-bottom: 20px;">
|
||||
|
||||
<a id="english"></a>
|
||||
|
||||
## 介绍
|
||||
<sub><sup>Introduction</sup></sub>
|
||||
|
||||
麦麦 MaiSaka 是一个基于大语言模型的可交互智能体。
|
||||
<sub><sup>MaiSaka is an interactive agent based on large language models.</sup></sub>
|
||||
|
||||
MaiSaka 不仅仅是一个机器人,不仅仅是一个可以帮你完成任务的“有帮助的助手”,她还是一个致力于了解你,并以真实人类的风格进行交互的数字生命。她不追求完美,不追求高效,但追求亲切和真实。
|
||||
<sub><sup>MaiSaka is more than just a bot, and more than a "helpful assistant" that completes tasks. She is a digital life form that tries to understand you and interact in a genuinely human style. She does not pursue perfection or efficiency above all else. She pursues warmth and authenticity.</sup></sub>
|
||||
|
||||
- 💭 **没有人喜欢 GPT 的语言风格**:麦麦使用了更加自然、贴合人类对话习惯的交互方式,不是长篇大论或者 markdown 格式的分点,而是或长或短的闲谈。
|
||||
<sub><sup><strong>No one likes GPT-sounding dialogue</strong>: MaiSaka uses a more natural conversational style. Instead of long-winded markdown-heavy replies, she chats in a way that feels casual, varied, and human.</sup></sub>
|
||||
- 🎭 **不再是傻乎乎的一问一答**:懂得在合适的时间说话,把握聊天中的气氛,在合适的时候开口,在合适的时候闭嘴。
|
||||
<sub><sup><strong>No longer stuck in rigid Q&A</strong>: She knows when to speak, how to read the room, when to join a conversation, and when to stay quiet.</sup></sub>
|
||||
- 🧠 **麦麦·成为人类**:在多人对话中,麦麦会模仿其他人的说话风格,还会自主理解新词或者小圈子里的黑话,不断进化。
|
||||
<sub><sup><strong>MaiSaka becoming human</strong>: In group conversations, MaiSaka imitates how people around her speak, learns new slang and in-group language, and keeps evolving.</sup></sub>
|
||||
- ❤️ **永远都在更加了解你**:基于心理学中人格理论,麦麦会不断积累对于你的了解,不论是你的信息、喜恶或是行为风格,她都记在心里。
|
||||
<sub><sup><strong>Always learning more about you</strong>: Inspired by personality theory in psychology, MaiSaka gradually builds an understanding of your preferences, traits, habits, and behavior style.</sup></sub>
|
||||
- 🔌 **插件系统**:提供强大的 API 和事件系统,拥有无限扩展可能。
|
||||
<sub><sup><strong>Plugin system</strong>: Provides powerful APIs and an event system with virtually unlimited room for extension.</sup></sub>
|
||||
|
||||
### 快速导航
|
||||
<sub><sup>Quick Navigation</sup></sub>
|
||||
|
||||
<p>
|
||||
<a href="https://www.bilibili.com/video/BV1amAneGE3P">🌟 演示视频 <sub>Demo Video</sub></a> |
|
||||
<a href="#-更新和安装--updates-and-installation">📦 快速入门 <sub>Quick Start</sub></a> |
|
||||
<a href="#-部署教程--deployment-guide">📃 核心文档 <sub>Core Documentation</sub></a> |
|
||||
<a href="#-讨论与社区--discussion-and-community">💬 加入社区 <sub>Join Community</sub></a>
|
||||
</p>
|
||||
|
||||
<!-- Clear float to ensure subsequent content starts below the image area if text is short -->
|
||||
<br clear="both">
|
||||
|
||||
<div align="center">
|
||||
<br>
|
||||
<a href="https://www.bilibili.com/video/BV1amAneGE3P" target="_blank">
|
||||
<picture>
|
||||
<source media="(max-width: 600px)" srcset="depends-data/video.png" width="100%">
|
||||
<img src="depends-data/video.png" width="60%" alt="麦麦演示视频" style="border-radius: 10px; box-shadow: 0 4px 8px rgba(0,0,0,0.1);">
|
||||
</picture>
|
||||
<br>
|
||||
<small>前往观看麦麦演示视频 / Watch the MaiSaka demo video</small>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
<a id="-更新和安装--updates-and-installation"></a>
|
||||
|
||||
## 🔥 更新和安装
|
||||
<sub><sup>Updates and Installation</sup></sub>
|
||||
|
||||
> **最新版本: v1.0.0** ([📄 更新日志](changelogs/changelog.md))
|
||||
> <sub><sup><strong>Latest Version: v1.0.0</strong> (<a href="changelogs/changelog.md">📄 Changelog</a>)</sup></sub>
|
||||
|
||||
- **下载**:前往 [Release](https://github.com/MaiM-with-u/MaiBot/releases/) 页面下载最新版本。
|
||||
<sub><sup><strong>Download</strong>: Visit the <a href="https://github.com/MaiM-with-u/MaiBot/releases/">Release</a> page to get the latest version.</sup></sub>
|
||||
- **启动器**:[Mailauncher](https://github.com/MaiM-with-u/mailauncher/releases/)(仅支持 MacOS,早期开发中)。
|
||||
<sub><sup><strong>Launcher</strong>: <a href="https://github.com/MaiM-with-u/mailauncher/releases/">Mailauncher</a> (MacOS only, still in early development).</sup></sub>
|
||||
|
||||
| 分支 / Branch | 说明 / Description |
|
||||
| :--- | :--- |
|
||||
| `main` | ✅ **稳定发布版本(推荐)**<br><sub><sup>Stable release (recommended)</sup></sub> |
|
||||
| `dev` | 🚧 开发测试版本,包含新功能,可能不稳定<br><sub><sup>Development testing branch with new features, may be unstable</sup></sub> |
|
||||
|
||||
<a id="-部署教程--deployment-guide"></a>
|
||||
|
||||
### 📚 部署教程
|
||||
<sub><sup>Deployment Guide</sup></sub>
|
||||
|
||||
👉 **[🚀 最新版本部署教程](https://docs.mai-mai.org/manual/deployment/mmc_deploy_windows.html)**
|
||||
<sub><sup>Latest Deployment Guide</sup></sub>
|
||||
|
||||
---
|
||||
|
||||
<a id="-讨论与社区--discussion-and-community"></a>
|
||||
|
||||
## 💬 讨论与社区
|
||||
<sub><sup>Discussion and Community</sup></sub>
|
||||
|
||||
我们欢迎所有对 MaiBot 感兴趣的朋友加入!
|
||||
<sub><sup>We welcome everyone interested in MaiBot to join us.</sup></sub>
|
||||
|
||||
| 类别 / Category | 群组 / Group | 说明 / Description |
|
||||
| :--- | :--- | :--- |
|
||||
| **技术交流**<br><sub><sup>Technical</sup></sub> | [麦麦脑电图](https://qm.qq.com/q/RzmCiRtHEW)<br><sub><sup>MaiBrain EEG</sup></sub> | 技术交流 / 答疑<br><sub><sup>Technical discussion / Q&A</sup></sub> |
|
||||
| **技术交流**<br><sub><sup>Technical</sup></sub> | [麦麦大脑磁共振](https://qm.qq.com/q/VQ3XZrWgMs)<br><sub><sup>MaiBrain MRI</sup></sub> | 技术交流 / 答疑<br><sub><sup>Technical discussion / Q&A</sup></sub> |
|
||||
| **技术交流**<br><sub><sup>Technical</sup></sub> | [麦麦要当 VTB](https://qm.qq.com/q/wGePTl1UyY)<br><sub><sup>Mai Wants to Be a VTuber</sup></sub> | 技术交流 / 答疑<br><sub><sup>Technical discussion / Q&A</sup></sub> |
|
||||
| **闲聊吹水**<br><sub><sup>Casual Chat</sup></sub> | [麦麦之闲聊群](https://qm.qq.com/q/JxvHZnxyec)<br><sub><sup>Mai Casual Chat Group</sup></sub> | 仅限闲聊,不答疑<br><sub><sup>Casual chat only, no support</sup></sub> |
|
||||
| **插件开发**<br><sub><sup>Plugin Development</sup></sub> | [插件开发群](https://qm.qq.com/q/1036092828)<br><sub><sup>Plugin Dev Group</sup></sub> | 进阶开发与测试<br><sub><sup>Advanced development and testing</sup></sub> |
|
||||
|
||||
---
|
||||
|
||||
## 📚 文档
|
||||
<sub><sup>Documentation</sup></sub>
|
||||
|
||||
> [!NOTE]
|
||||
> 部分内容可能更新不够及时,请注意版本对应。
|
||||
> <sub><sup>Some content may not be updated promptly, so please pay attention to version compatibility.</sup></sub>
|
||||
|
||||
- **[📚 核心 Wiki 文档](https://docs.mai-mai.org)**:最全面的文档中心,了解麦麦的一切。
|
||||
<sub><sup><strong><a href="https://docs.mai-mai.org">📚 Core Wiki Documentation</a></strong>: The most comprehensive documentation hub for everything about MaiSaka.</sup></sub>
|
||||
|
||||
### 🧩 衍生项目
|
||||
<sub><sup>Related Projects</sup></sub>
|
||||
|
||||
- **[Amaidesu](https://github.com/MaiM-with-u/Amaidesu)**:让麦麦在 B 站开播。
|
||||
<sub><sup>Let MaiSaka stream on Bilibili.</sup></sub>
|
||||
- **[MoFox_Bot](https://github.com/MoFox-Studio/MoFox-Core)**:基于 MaiCore 0.10.0 的增强型 Fork,更稳定更有趣。
|
||||
<sub><sup>An enhanced fork based on MaiCore 0.10.0, with improved stability and more fun features.</sup></sub>
|
||||
- **[MaiCraft](https://github.com/MaiM-with-u/Maicraft)**:让麦麦陪你玩 Minecraft(暂时停止维护中)。
|
||||
<sub><sup>Let MaiSaka accompany you in Minecraft (currently paused).</sup></sub>
|
||||
|
||||
---
|
||||
|
||||
## 💡 设计理念
|
||||
<sub><sup>Design Philosophy</sup></sub>
|
||||
|
||||
> **千石可乐说:**
|
||||
> <sub><sup><strong>SengokuCola says:</strong></sup></sub>
|
||||
> - 这个项目最初只是为了给牛牛 bot 添加一点额外的功能,但是功能越写越多,最后决定重写。其目的是为了创造一个活跃在 QQ 群聊的“生命体”。目的并不是为了写一个功能齐全的机器人,而是一个尽可能让人感知到真实的类人存在。
|
||||
> <sub><sup>This project originally started as a few extra features for the NiuNiu bot, but it kept growing until a full rewrite became inevitable. The goal was to create a "life form" active in QQ group chats, not a feature-complete bot, but something as human-like and real-feeling as possible.</sup></sub>
|
||||
> - 程序的功能设计理念基于一个核心的原则:“最像而不是好”。
|
||||
> <sub><sup>The core design principle is: "more lifelike, not merely better."</sup></sub>
|
||||
> - 如果人类真的需要一个 AI 来陪伴自己,并不是所有人都需要一个完美的,能解决所有问题的“helpful assistant”,而是一个会犯错的,拥有自己感知和想法的“生命形式”。
|
||||
> <sub><sup>If people truly want AI companionship, not everyone needs a perfect "helpful assistant" that solves every problem. Some people may want a life form that can make mistakes and has its own perceptions and thoughts.</sup></sub>
|
||||
|
||||
> **xxxxx 说:**
|
||||
> <sub><sup><strong>xxxxx says:</strong></sup></sub>
|
||||
> *Code is open, but the soul is yours.*
|
||||
|
||||
---
|
||||
|
||||
## 🙋 贡献和致谢
|
||||
<sub><sup>Contributing and Acknowledgments</sup></sub>
|
||||
|
||||
欢迎参与贡献!请先阅读 [贡献指南](docs/CONTRIBUTE.md)。
|
||||
<sub><sup>Contributions are welcome. Please read the <a href="docs/CONTRIBUTE.md">Contribution Guide</a> first.</sup></sub>
|
||||
|
||||
### 🌟 贡献者
|
||||
<sub><sup>Contributors</sup></sub>
|
||||
|
||||
<a href="https://github.com/MaiM-with-u/MaiBot/graphs/contributors">
|
||||
<img alt="contributors" src="https://contrib.rocks/image?repo=MaiM-with-u/MaiBot" />
|
||||
</a>
|
||||
|
||||
### 🤝 开源项目友链
|
||||
<sub><sup>Open Source Friends</sup></sub>
|
||||
|
||||
- **[AstrBot](https://github.com/AstrBotDevs/AstrBot)**: 优秀的LLM Agent项目
|
||||
|
||||
### ❤️ 特别致谢
|
||||
<sub><sup>Special Thanks</sup></sub>
|
||||
|
||||
- **[萨卡班甲鱼](https://en.wikipedia.org/wiki/Sacabambaspis)**:千石可乐很喜欢的生物。
|
||||
<sub><sup><strong><a href="https://en.wikipedia.org/wiki/Sacabambaspis">Sacabambaspis</a></strong>: SengokuCola's favorite creature.</sup></sub>
|
||||
- **[略nd](https://space.bilibili.com/1344099355)**:为麦麦绘制早期的精美人设。
|
||||
<sub><sup>Drew MaiSaka's beautiful early character design.</sup></sub>
|
||||
- **[NapCat](https://github.com/NapNeko/NapCatQQ)**:现代化的基于 NTQQ 的 Bot 协议实现。
|
||||
<sub><sup>A modern NTQQ-based bot protocol implementation.</sup></sub>
|
||||
|
||||
---
|
||||
|
||||
## 📊 仓库状态
|
||||
<sub><sup>Repository Status</sup></sub>
|
||||
|
||||

|
||||
|
||||
### Star 趋势
|
||||
<sub><sup>Star History</sup></sub>
|
||||
|
||||
[](https://starchart.cc/MaiM-with-u/MaiBot)
|
||||
|
||||
---
|
||||
|
||||
## 📌 注意事项 & License
|
||||
<sub><sup>Notice & License</sup></sub>
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 使用前请阅读 [用户协议 (EULA)](EULA.md) 和 [隐私协议](PRIVACY.md)。AI 生成内容请仔细甄别。
|
||||
> <sub><sup>Please read the <a href="EULA.md">End User License Agreement (EULA)</a> and <a href="PRIVACY.md">Privacy Policy</a> before use. Please evaluate AI-generated content carefully.</sup></sub>
|
||||
|
||||
**License**: GPL-3.0
|
||||
|
||||
407
bot.py
Normal file
407
bot.py
Normal file
@@ -0,0 +1,407 @@
|
||||
# raise RuntimeError("System Not Ready")
|
||||
from pathlib import Path
|
||||
from rich.traceback import install
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import os
|
||||
import platform
|
||||
# import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import traceback
|
||||
|
||||
from src.common.i18n import set_locale, t, tn
|
||||
from src.common.logger import get_logger, initialize_logging, shutdown_logging
|
||||
from src.config.legacy_upgrade_confirmation import require_legacy_upgrade_confirmation
|
||||
|
||||
# 设置工作目录为脚本所在目录
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
os.chdir(script_dir)
|
||||
set_locale(os.getenv("MAIBOT_LOCALE", "zh-CN"))
|
||||
|
||||
# 检查是否是 Worker 进程,只在 Worker 进程中输出详细的初始化信息
|
||||
# Runner 进程只需要基本的日志功能,不需要详细的初始化日志
|
||||
is_worker = os.environ.get("MAIBOT_WORKER_PROCESS") == "1"
|
||||
initialize_logging(verbose=is_worker)
|
||||
install(extra_lines=3)
|
||||
logger = get_logger("main")
|
||||
|
||||
# 定义重启退出码
|
||||
RESTART_EXIT_CODE = 42
|
||||
# print("-----------------------------------------")
|
||||
# print("\n\n\n\n\n")
|
||||
# print(t("startup.dev_branch_warning"))
|
||||
# print("\n\n\n\n\n")
|
||||
# print("-----------------------------------------")
|
||||
|
||||
|
||||
def run_runner_process():
|
||||
"""
|
||||
Runner 进程逻辑:作为守护进程运行,负责启动和监控 Worker 进程。
|
||||
处理重启请求 (退出码 42) 和 Ctrl+C 信号。
|
||||
"""
|
||||
script_file = sys.argv[0]
|
||||
python_executable = sys.executable
|
||||
|
||||
# 设置环境变量,标记子进程为 Worker 进程
|
||||
env = os.environ.copy()
|
||||
env["MAIBOT_WORKER_PROCESS"] = "1"
|
||||
|
||||
while True:
|
||||
logger.info(t("startup.launching_script", script_file=script_file))
|
||||
logger.info(t("startup.compiling_shaders"))
|
||||
|
||||
# 启动子进程 (Worker)
|
||||
# 使用 sys.executable 确保使用相同的 Python 解释器
|
||||
cmd = [python_executable, script_file] + sys.argv[1:]
|
||||
|
||||
process = subprocess.Popen(cmd, env=env)
|
||||
|
||||
try:
|
||||
# 等待子进程结束
|
||||
return_code = process.wait()
|
||||
|
||||
if return_code == RESTART_EXIT_CODE:
|
||||
logger.info(t("startup.restart_requested", exit_code=RESTART_EXIT_CODE))
|
||||
time.sleep(1) # 稍作等待
|
||||
continue
|
||||
else:
|
||||
logger.info(t("startup.program_exited", return_code=return_code))
|
||||
sys.exit(return_code)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
# 向子进程发送终止信号
|
||||
if process.poll() is None:
|
||||
# 在 Windows 上,Ctrl+C 通常已经发送给了子进程(如果它们共享控制台)
|
||||
# 但为了保险,我们可以尝试 terminate
|
||||
try:
|
||||
process.terminate()
|
||||
process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning(t("startup.child_process_force_kill"))
|
||||
process.kill()
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
# 检查是否是 Worker 进程
|
||||
# 如果没有设置 MAIBOT_WORKER_PROCESS 环境变量,说明是直接运行的脚本,
|
||||
# 此时应该作为 Runner 运行。
|
||||
if os.environ.get("MAIBOT_WORKER_PROCESS") != "1":
|
||||
if __name__ == "__main__":
|
||||
require_legacy_upgrade_confirmation(Path(script_dir))
|
||||
run_runner_process()
|
||||
# 如果作为模块导入,不执行 Runner 逻辑,但也不应该执行下面的 Worker 逻辑
|
||||
sys.exit(0)
|
||||
|
||||
# 以下是 Worker 进程的逻辑
|
||||
|
||||
# 最早期初始化日志系统,确保所有后续模块都使用正确的日志格式
|
||||
# 注意:Runner 进程已经在第 37 行初始化了日志系统,但 Worker 进程是独立进程,需要重新初始化
|
||||
# 由于 Runner 和 Worker 是不同进程,它们有独立的内存空间,所以都会初始化一次
|
||||
# 这是正常的,但为了避免重复的初始化日志,我们在 initialize_logging() 中添加了防重复机制
|
||||
# 不过由于是不同进程,每个进程仍会初始化一次,这是预期的行为
|
||||
|
||||
require_legacy_upgrade_confirmation(Path(script_dir))
|
||||
|
||||
logger.info(t("startup.worker_dir_set", script_dir=script_dir))
|
||||
|
||||
from src.main import MainSystem # noqa
|
||||
from src.manager.async_task_manager import async_task_manager # noqa
|
||||
|
||||
|
||||
# logger = get_logger("main")
|
||||
|
||||
|
||||
# install(extra_lines=3)
|
||||
|
||||
# 设置工作目录为脚本所在目录
|
||||
# script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
# os.chdir(script_dir)
|
||||
confirm_logger = get_logger("confirm")
|
||||
# 获取没有加载env时的环境变量
|
||||
env_mask = {key: os.getenv(key) for key in os.environ}
|
||||
|
||||
uvicorn_server = None
|
||||
driver = None
|
||||
app = None
|
||||
loop = None
|
||||
|
||||
|
||||
def print_opensource_notice():
|
||||
"""打印开源项目提示,防止倒卖"""
|
||||
from colorama import init, Fore, Style
|
||||
|
||||
init()
|
||||
|
||||
notice_lines = [
|
||||
"",
|
||||
f"{Fore.CYAN}{'═' * 70}{Style.RESET_ALL}",
|
||||
f"{Fore.GREEN}{t('startup.opensource_title')}{Style.RESET_ALL}",
|
||||
f"{Fore.CYAN}{'─' * 70}{Style.RESET_ALL}",
|
||||
f"{Fore.YELLOW}{t('startup.opensource_free_notice')}{Style.RESET_ALL}",
|
||||
f"{Fore.WHITE}{t('startup.opensource_scamming_notice')}{Style.RESET_ALL}",
|
||||
"",
|
||||
f"{Fore.WHITE}{t('startup.opensource_repo')}{Fore.BLUE}{t('startup.opensource_repo_value')} {Style.RESET_ALL}",
|
||||
f"{Fore.WHITE}{t('startup.opensource_docs')}{Fore.BLUE}{t('startup.opensource_docs_value')} {Style.RESET_ALL}",
|
||||
f"{Fore.WHITE}{t('startup.opensource_group')}{Fore.BLUE}{t('startup.opensource_group_value')}{Style.RESET_ALL}",
|
||||
f"{Fore.CYAN}{'─' * 70}{Style.RESET_ALL}",
|
||||
f"{Fore.RED} ⚠ {t('startup.opensource_resale_warning').strip()}{Style.RESET_ALL}",
|
||||
f"{Fore.CYAN}{'═' * 70}{Style.RESET_ALL}",
|
||||
"",
|
||||
]
|
||||
|
||||
for line in notice_lines:
|
||||
print(line)
|
||||
|
||||
|
||||
def easter_egg():
|
||||
# 彩蛋
|
||||
from colorama import init, Fore
|
||||
|
||||
init()
|
||||
text = t("startup.easter_egg")
|
||||
rainbow_colors = [Fore.RED, Fore.YELLOW, Fore.GREEN, Fore.CYAN, Fore.BLUE, Fore.MAGENTA]
|
||||
rainbow_text = ""
|
||||
for i, char in enumerate(text):
|
||||
rainbow_text += rainbow_colors[i % len(rainbow_colors)] + char
|
||||
print(rainbow_text)
|
||||
|
||||
|
||||
async def graceful_shutdown(): # sourcery skip: use-named-expression
|
||||
try:
|
||||
logger.info(t("startup.shutdown_started"))
|
||||
|
||||
# 关闭 WebUI 服务器
|
||||
# try:
|
||||
# from src.webui.webui_server import get_webui_server
|
||||
|
||||
# webui_server = get_webui_server()
|
||||
# if webui_server and webui_server._server:
|
||||
# await webui_server.shutdown()
|
||||
# except Exception as e:
|
||||
# logger.warning(f"关闭 WebUI 服务器时出错: {e}")
|
||||
|
||||
from src.core.event_bus import event_bus
|
||||
from src.core.types import EventType
|
||||
|
||||
# 触发 ON_STOP 事件
|
||||
await event_bus.emit(event_type=EventType.ON_STOP)
|
||||
|
||||
# 停止新版本插件运行时
|
||||
from src.plugin_runtime.integration import get_plugin_runtime_manager
|
||||
|
||||
await get_plugin_runtime_manager().stop()
|
||||
|
||||
# 停止所有异步任务
|
||||
await async_task_manager.stop_and_wait_all_tasks()
|
||||
|
||||
# 获取所有剩余任务,排除当前任务
|
||||
remaining_tasks = [task for task in asyncio.all_tasks() if task is not asyncio.current_task()]
|
||||
|
||||
if remaining_tasks:
|
||||
logger.info(tn("startup.remaining_tasks_cancelling", len(remaining_tasks)))
|
||||
|
||||
# 取消所有剩余任务
|
||||
for task in remaining_tasks:
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
|
||||
# 等待所有任务完成,设置超时
|
||||
try:
|
||||
await asyncio.wait_for(asyncio.gather(*remaining_tasks, return_exceptions=True), timeout=15.0)
|
||||
logger.info(t("startup.remaining_tasks_cancelled"))
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(t("startup.remaining_tasks_cancel_timeout"))
|
||||
except Exception as e:
|
||||
logger.error(t("startup.remaining_tasks_cancel_error", error=e))
|
||||
|
||||
logger.info(t("startup.shutdown_completed"))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(t("startup.shutdown_failed", error=e), exc_info=True)
|
||||
|
||||
|
||||
def _calculate_file_hash(file_path: Path, file_type: str) -> str:
|
||||
"""计算文件的MD5哈希值"""
|
||||
if not file_path.exists():
|
||||
logger.error(t("startup.file_not_found", file_type=file_type))
|
||||
raise FileNotFoundError(t("startup.file_not_found", file_type=file_type))
|
||||
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
return hashlib.md5(content.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _check_agreement_status(file_hash: str, confirm_file: Path, env_var: str) -> tuple[bool, bool]:
|
||||
"""检查协议确认状态
|
||||
|
||||
Returns:
|
||||
tuple[bool, bool]: (已确认, 未更新)
|
||||
"""
|
||||
# 检查环境变量确认
|
||||
if file_hash == os.getenv(env_var):
|
||||
return True, False
|
||||
|
||||
# 检查确认文件
|
||||
if confirm_file.exists():
|
||||
with open(confirm_file, "r", encoding="utf-8") as f:
|
||||
confirmed_content = f.read()
|
||||
if file_hash == confirmed_content:
|
||||
return True, False
|
||||
|
||||
return False, True
|
||||
|
||||
|
||||
def _prompt_user_confirmation(eula_hash: str, privacy_hash: str) -> None:
|
||||
"""提示用户确认协议"""
|
||||
confirm_logger.critical(t("startup.agreement_reconfirm"))
|
||||
confirm_logger.critical(
|
||||
t(
|
||||
"startup.agreement_confirm_prompt",
|
||||
eula_hash=eula_hash,
|
||||
privacy_hash=privacy_hash,
|
||||
)
|
||||
)
|
||||
|
||||
while True:
|
||||
user_input = input().strip().lower()
|
||||
if user_input in ["同意", "confirmed"]:
|
||||
return
|
||||
confirm_logger.critical(t("startup.agreement_confirm_retry"))
|
||||
|
||||
|
||||
def _save_confirmations(eula_updated: bool, privacy_updated: bool, eula_hash: str, privacy_hash: str) -> None:
|
||||
"""保存用户确认结果"""
|
||||
if eula_updated:
|
||||
logger.info(
|
||||
t(
|
||||
"startup.agreement_updated",
|
||||
agreement_name=t("startup.eula_name"),
|
||||
file_hash=eula_hash,
|
||||
)
|
||||
)
|
||||
Path("eula.confirmed").write_text(eula_hash, encoding="utf-8")
|
||||
|
||||
if privacy_updated:
|
||||
logger.info(
|
||||
t(
|
||||
"startup.agreement_updated",
|
||||
agreement_name=t("startup.privacy_name"),
|
||||
file_hash=privacy_hash,
|
||||
)
|
||||
)
|
||||
Path("privacy.confirmed").write_text(privacy_hash, encoding="utf-8")
|
||||
|
||||
|
||||
def check_eula():
|
||||
"""检查EULA和隐私条款确认状态"""
|
||||
# 计算文件哈希值
|
||||
eula_hash = _calculate_file_hash(Path("EULA.md"), "EULA.md")
|
||||
privacy_hash = _calculate_file_hash(Path("PRIVACY.md"), "PRIVACY.md")
|
||||
|
||||
# 检查确认状态
|
||||
eula_confirmed, eula_updated = _check_agreement_status(eula_hash, Path("eula.confirmed"), "EULA_AGREE")
|
||||
privacy_confirmed, privacy_updated = _check_agreement_status(
|
||||
privacy_hash, Path("privacy.confirmed"), "PRIVACY_AGREE"
|
||||
)
|
||||
|
||||
# 早期返回:如果都已确认且未更新
|
||||
if eula_confirmed and privacy_confirmed:
|
||||
return
|
||||
|
||||
# 如果有更新,需要重新确认
|
||||
if eula_updated or privacy_updated:
|
||||
_prompt_user_confirmation(eula_hash, privacy_hash)
|
||||
_save_confirmations(eula_updated, privacy_updated, eula_hash, privacy_hash)
|
||||
|
||||
|
||||
def raw_main():
|
||||
# 利用 TZ 环境变量设定程序工作的时区
|
||||
if platform.system().lower() != "windows":
|
||||
time.tzset() # type: ignore
|
||||
|
||||
# 打印开源提示(防止倒卖)
|
||||
print_opensource_notice()
|
||||
|
||||
check_eula()
|
||||
logger.info(t("startup.eula_privacy_checked"))
|
||||
|
||||
easter_egg()
|
||||
|
||||
# 返回MainSystem实例
|
||||
return MainSystem()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
exit_code = 0 # 用于记录程序最终的退出状态
|
||||
try:
|
||||
# 获取MainSystem实例
|
||||
main_system = raw_main()
|
||||
|
||||
# 创建事件循环
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
|
||||
# 初始化 WebSocket 日志推送
|
||||
from src.common.logger import initialize_ws_handler
|
||||
|
||||
initialize_ws_handler(loop)
|
||||
|
||||
try:
|
||||
# 执行初始化和任务调度
|
||||
loop.run_until_complete(main_system.initialize())
|
||||
# Schedule tasks returns a future that runs forever.
|
||||
# We can run console_input_loop concurrently.
|
||||
main_tasks = loop.create_task(main_system.schedule_tasks())
|
||||
loop.run_until_complete(main_tasks)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
logger.warning(t("startup.interrupt_received"))
|
||||
|
||||
# 取消主任务
|
||||
if "main_tasks" in locals() and main_tasks and not main_tasks.done():
|
||||
main_tasks.cancel()
|
||||
try:
|
||||
loop.run_until_complete(main_tasks)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
# 执行优雅关闭
|
||||
if loop and not loop.is_closed():
|
||||
try:
|
||||
loop.run_until_complete(graceful_shutdown())
|
||||
except Exception as ge:
|
||||
logger.error(t("startup.graceful_shutdown_error", error=ge))
|
||||
# 新增:检测外部请求关闭
|
||||
|
||||
except SystemExit as e:
|
||||
# 捕获 SystemExit (例如 sys.exit()) 并保留退出代码
|
||||
if isinstance(e.code, int):
|
||||
exit_code = e.code
|
||||
else:
|
||||
exit_code = 1 if e.code else 0
|
||||
if exit_code == RESTART_EXIT_CODE:
|
||||
logger.info(t("startup.restart_signal_received"))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(t("startup.main_error", error=f"{str(e)} {str(traceback.format_exc())}"))
|
||||
exit_code = 1 # 标记发生错误
|
||||
finally:
|
||||
# 确保 loop 在任何情况下都尝试关闭(如果存在且未关闭)
|
||||
if "loop" in locals() and loop and not loop.is_closed():
|
||||
loop.close()
|
||||
print(t("startup.event_loop_closed"))
|
||||
|
||||
# 关闭日志系统,释放文件句柄
|
||||
try:
|
||||
shutdown_logging()
|
||||
except Exception as e:
|
||||
print(t("startup.logging_shutdown_error", error=e))
|
||||
|
||||
print(t("startup.prepare_exit"))
|
||||
|
||||
# 使用 os._exit() 强制退出,避免被阻塞
|
||||
# 由于已经在 graceful_shutdown() 中完成了所有清理工作,这是安全的
|
||||
os._exit(exit_code)
|
||||
1437
changelogs/changelog.md
Normal file
1437
changelogs/changelog.md
Normal file
File diff suppressed because it is too large
Load Diff
127
code_scripts/generate_database_datamodel_py.py
Normal file
127
code_scripts/generate_database_datamodel_py.py
Normal file
@@ -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)
|
||||
535
code_scripts/migrate_expression_jargon_db.py
Normal file
535
code_scripts/migrate_expression_jargon_db.py
Normal file
@@ -0,0 +1,535 @@
|
||||
from argparse import ArgumentParser, Namespace
|
||||
from collections.abc import Iterable
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from sys import path as sys_path
|
||||
from typing import Any, Optional
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlmodel import Session, SQLModel, create_engine, delete
|
||||
|
||||
ROOT_PATH = Path(__file__).resolve().parent.parent
|
||||
if str(ROOT_PATH) not in sys_path:
|
||||
sys_path.insert(0, str(ROOT_PATH))
|
||||
|
||||
from src.common.database.database_model import Expression, Jargon, ModifiedBy # noqa: E402
|
||||
|
||||
|
||||
def build_argument_parser() -> ArgumentParser:
|
||||
"""构建命令行参数解析器。"""
|
||||
parser = ArgumentParser(
|
||||
description="将旧版 expression/jargon 数据迁移到新版 expressions/jargons 数据库。"
|
||||
)
|
||||
parser.add_argument("--source-db", dest="source_db", help="旧版 SQLite 数据库路径")
|
||||
parser.add_argument("--target-db", dest="target_db", help="新版 SQLite 数据库路径")
|
||||
parser.add_argument(
|
||||
"--clear-target",
|
||||
dest="clear_target",
|
||||
action="store_true",
|
||||
help="迁移前清空目标库中的 expressions 和 jargons 表",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def prompt_path(prompt_text: str, current_value: Optional[str] = None) -> Path:
|
||||
"""读取数据库路径输入。"""
|
||||
while True:
|
||||
suffix = f" [{current_value}]" if current_value else ""
|
||||
raw_text = input(f"{prompt_text}{suffix}: ").strip()
|
||||
value = raw_text or current_value or ""
|
||||
if not value:
|
||||
print("路径不能为空,请重新输入。")
|
||||
continue
|
||||
return Path(value).expanduser().resolve()
|
||||
|
||||
|
||||
def prompt_yes_no(prompt_text: str, default: bool = False) -> bool:
|
||||
"""读取是否确认输入。"""
|
||||
default_hint = "Y/n" if default else "y/N"
|
||||
raw_text = input(f"{prompt_text} [{default_hint}]: ").strip().lower()
|
||||
if not raw_text:
|
||||
return default
|
||||
return raw_text in {"y", "yes"}
|
||||
|
||||
|
||||
def ensure_sqlite_file(path: Path, should_exist: bool) -> None:
|
||||
"""校验 SQLite 文件路径。"""
|
||||
if should_exist and not path.is_file():
|
||||
raise FileNotFoundError(f"数据库文件不存在:{path}")
|
||||
if not should_exist:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def connect_sqlite(path: Path) -> sqlite3.Connection:
|
||||
"""创建 SQLite 连接。"""
|
||||
connection = sqlite3.connect(path)
|
||||
connection.row_factory = sqlite3.Row
|
||||
return connection
|
||||
|
||||
|
||||
def table_exists(connection: sqlite3.Connection, table_name: str) -> bool:
|
||||
"""检查表是否存在。"""
|
||||
result = connection.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1",
|
||||
(table_name,),
|
||||
).fetchone()
|
||||
return result is not None
|
||||
|
||||
|
||||
def resolve_source_table_name(connection: sqlite3.Connection, candidates: list[str]) -> str:
|
||||
"""从候选表名中解析实际存在的表名。"""
|
||||
for table_name in candidates:
|
||||
if table_exists(connection, table_name):
|
||||
return table_name
|
||||
raise ValueError(f"未找到候选表:{', '.join(candidates)}")
|
||||
|
||||
|
||||
def get_table_columns(connection: sqlite3.Connection, table_name: str) -> set[str]:
|
||||
"""获取表字段名集合。"""
|
||||
rows = connection.execute(f"PRAGMA table_info('{table_name}')").fetchall()
|
||||
return {str(row["name"]) for row in rows}
|
||||
|
||||
|
||||
def get_table_nullable_map(connection: sqlite3.Connection, table_name: str) -> dict[str, bool]:
|
||||
"""获取表字段是否允许 NULL 的映射。"""
|
||||
rows = connection.execute(f"PRAGMA table_info('{table_name}')").fetchall()
|
||||
return {str(row["name"]): not bool(row["notnull"]) for row in rows}
|
||||
|
||||
|
||||
def load_rows(connection: sqlite3.Connection, table_name: str) -> list[sqlite3.Row]:
|
||||
"""读取整张表的数据。"""
|
||||
return connection.execute(f"SELECT * FROM {table_name}").fetchall()
|
||||
|
||||
|
||||
def normalize_optional_text(raw_value: Any) -> Optional[str]:
|
||||
"""标准化可空文本字段。"""
|
||||
if raw_value is None:
|
||||
return None
|
||||
return str(raw_value)
|
||||
|
||||
|
||||
def ensure_nullable_compatibility(
|
||||
table_name: str,
|
||||
column_name: str,
|
||||
row_id: Any,
|
||||
value: Any,
|
||||
nullable_map: dict[str, bool],
|
||||
) -> None:
|
||||
"""检查待迁移值是否与目标表可空约束兼容。"""
|
||||
if value is None and not nullable_map.get(column_name, True):
|
||||
raise ValueError(
|
||||
f"目标表 {table_name}.{column_name} 不允许 NULL,但源记录 id={row_id} 的该字段为 NULL。"
|
||||
)
|
||||
|
||||
|
||||
def normalize_string_list(raw_value: Any) -> list[str]:
|
||||
"""将旧库中的 JSON/文本字段标准化为字符串列表。"""
|
||||
if raw_value is None:
|
||||
return []
|
||||
if isinstance(raw_value, list):
|
||||
return [str(item).strip() for item in raw_value if str(item).strip()]
|
||||
if isinstance(raw_value, str):
|
||||
raw_text = raw_value.strip()
|
||||
if not raw_text:
|
||||
return []
|
||||
try:
|
||||
parsed = json.loads(raw_text)
|
||||
except json.JSONDecodeError:
|
||||
return [raw_text]
|
||||
if isinstance(parsed, list):
|
||||
return [str(item).strip() for item in parsed if str(item).strip()]
|
||||
if isinstance(parsed, str):
|
||||
parsed_text = parsed.strip()
|
||||
return [parsed_text] if parsed_text else []
|
||||
if parsed is None:
|
||||
return []
|
||||
return [str(parsed).strip()]
|
||||
return [str(raw_value).strip()]
|
||||
|
||||
|
||||
def normalize_modified_by(raw_value: Any) -> Optional[ModifiedBy]:
|
||||
"""标准化审核来源字段。"""
|
||||
if raw_value is None:
|
||||
return None
|
||||
|
||||
normalized_raw_value = raw_value
|
||||
if isinstance(raw_value, str):
|
||||
raw_text = raw_value.strip()
|
||||
if raw_text.startswith('"') and raw_text.endswith('"'):
|
||||
try:
|
||||
normalized_raw_value = json.loads(raw_text)
|
||||
except json.JSONDecodeError:
|
||||
normalized_raw_value = raw_text
|
||||
else:
|
||||
normalized_raw_value = raw_text
|
||||
|
||||
value = str(normalized_raw_value).strip().lower()
|
||||
if value in {"", "none", "null"}:
|
||||
return None
|
||||
if value in {ModifiedBy.AI.value, ModifiedBy.AI.name.lower()}:
|
||||
return ModifiedBy.AI
|
||||
if value in {ModifiedBy.USER.value, ModifiedBy.USER.name.lower()}:
|
||||
return ModifiedBy.USER
|
||||
return None
|
||||
|
||||
|
||||
def parse_optional_bool(raw_value: Any) -> Optional[bool]:
|
||||
"""解析可空布尔值,兼容整数和字符串。"""
|
||||
if raw_value is None:
|
||||
return None
|
||||
if isinstance(raw_value, bool):
|
||||
return raw_value
|
||||
if isinstance(raw_value, int):
|
||||
return bool(raw_value)
|
||||
if isinstance(raw_value, float):
|
||||
return bool(int(raw_value))
|
||||
|
||||
value = str(raw_value).strip().lower()
|
||||
if value in {"", "none", "null"}:
|
||||
return None
|
||||
if value in {"1", "true", "t", "yes", "y"}:
|
||||
return True
|
||||
if value in {"0", "false", "f", "no", "n"}:
|
||||
return False
|
||||
raise ValueError(f"无法解析布尔值:{raw_value}")
|
||||
|
||||
|
||||
def parse_bool(raw_value: Any, default: bool = False) -> bool:
|
||||
"""解析非空布尔值。"""
|
||||
parsed = parse_optional_bool(raw_value)
|
||||
return default if parsed is None else parsed
|
||||
|
||||
|
||||
def timestamp_to_datetime(raw_value: Any, fallback_now: bool) -> Optional[datetime]:
|
||||
"""将旧库中的 Unix 时间戳转换为 datetime。"""
|
||||
if raw_value is None or raw_value == "":
|
||||
return datetime.now() if fallback_now else None
|
||||
if isinstance(raw_value, datetime):
|
||||
return raw_value
|
||||
try:
|
||||
return datetime.fromtimestamp(float(raw_value))
|
||||
except (TypeError, ValueError, OSError, OverflowError):
|
||||
return datetime.now() if fallback_now else None
|
||||
|
||||
|
||||
def build_session_id_dict(raw_chat_id: Any, fallback_count: int) -> str:
|
||||
"""将旧版 jargon.chat_id 转换为新版 session_id_dict。"""
|
||||
if raw_chat_id is None:
|
||||
return json.dumps({}, ensure_ascii=False)
|
||||
|
||||
if isinstance(raw_chat_id, str):
|
||||
raw_text = raw_chat_id.strip()
|
||||
else:
|
||||
raw_text = str(raw_chat_id).strip()
|
||||
|
||||
if not raw_text:
|
||||
return json.dumps({}, ensure_ascii=False)
|
||||
|
||||
try:
|
||||
parsed = json.loads(raw_text)
|
||||
except json.JSONDecodeError:
|
||||
return json.dumps({raw_text: max(fallback_count, 1)}, ensure_ascii=False)
|
||||
|
||||
if isinstance(parsed, str):
|
||||
parsed_text = parsed.strip()
|
||||
session_counts = {parsed_text: max(fallback_count, 1)} if parsed_text else {}
|
||||
return json.dumps(session_counts, ensure_ascii=False)
|
||||
|
||||
if not isinstance(parsed, list):
|
||||
return json.dumps({}, ensure_ascii=False)
|
||||
|
||||
session_counts: dict[str, int] = {}
|
||||
for item in parsed:
|
||||
if not isinstance(item, list) or not item:
|
||||
continue
|
||||
session_id = str(item[0]).strip()
|
||||
if not session_id:
|
||||
continue
|
||||
item_count = 1
|
||||
if len(item) > 1:
|
||||
try:
|
||||
item_count = int(item[1])
|
||||
except (TypeError, ValueError):
|
||||
item_count = 1
|
||||
session_counts[session_id] = max(item_count, 1)
|
||||
|
||||
return json.dumps(session_counts, ensure_ascii=False)
|
||||
|
||||
|
||||
def create_target_engine(target_db_path: Path):
|
||||
"""创建目标数据库引擎。"""
|
||||
return create_engine(
|
||||
f"sqlite:///{target_db_path.as_posix()}",
|
||||
echo=False,
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
|
||||
|
||||
def clear_target_tables(session: Session) -> None:
|
||||
"""清空目标表。"""
|
||||
session.exec(delete(Expression))
|
||||
session.exec(delete(Jargon))
|
||||
|
||||
|
||||
def migrate_expressions(
|
||||
old_rows: Iterable[sqlite3.Row],
|
||||
target_session: Session,
|
||||
expression_columns: set[str],
|
||||
) -> int:
|
||||
"""迁移 expression 数据。"""
|
||||
migrated_count = 0
|
||||
modified_by_ai_count = 0
|
||||
modified_by_user_count = 0
|
||||
modified_by_null_count = 0
|
||||
unknown_modified_by_values: dict[str, int] = {}
|
||||
for row in old_rows:
|
||||
create_time = timestamp_to_datetime(row["create_date"] if "create_date" in expression_columns else None, True)
|
||||
last_active_time = timestamp_to_datetime(
|
||||
row["last_active_time"] if "last_active_time" in expression_columns else None,
|
||||
True,
|
||||
)
|
||||
content_list = normalize_string_list(row["content_list"] if "content_list" in expression_columns else None)
|
||||
raw_modified_by = row["modified_by"] if "modified_by" in expression_columns else None
|
||||
modified_by = normalize_modified_by(raw_modified_by)
|
||||
if modified_by == ModifiedBy.AI:
|
||||
modified_by_ai_count += 1
|
||||
elif modified_by == ModifiedBy.USER:
|
||||
modified_by_user_count += 1
|
||||
else:
|
||||
modified_by_null_count += 1
|
||||
if raw_modified_by not in (None, "", "null", "NULL", "None"):
|
||||
unknown_key = str(raw_modified_by)
|
||||
unknown_modified_by_values[unknown_key] = unknown_modified_by_values.get(unknown_key, 0) + 1
|
||||
|
||||
target_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO expressions (
|
||||
id,
|
||||
situation,
|
||||
style,
|
||||
content_list,
|
||||
count,
|
||||
last_active_time,
|
||||
create_time,
|
||||
session_id,
|
||||
checked,
|
||||
rejected,
|
||||
modified_by
|
||||
) VALUES (
|
||||
:id,
|
||||
:situation,
|
||||
:style,
|
||||
:content_list,
|
||||
:count,
|
||||
:last_active_time,
|
||||
:create_time,
|
||||
:session_id,
|
||||
:checked,
|
||||
:rejected,
|
||||
:modified_by
|
||||
)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"id": int(row["id"]) if row["id"] is not None else None,
|
||||
"situation": str(row["situation"]).strip(),
|
||||
"style": str(row["style"]).strip(),
|
||||
"content_list": json.dumps(content_list, ensure_ascii=False),
|
||||
"count": int(row["count"]) if "count" in expression_columns and row["count"] is not None else 1,
|
||||
"last_active_time": last_active_time or datetime.now(),
|
||||
"create_time": create_time or datetime.now(),
|
||||
"session_id": str(row["chat_id"]).strip() if "chat_id" in expression_columns and row["chat_id"] else None,
|
||||
"checked": parse_bool(row["checked"] if "checked" in expression_columns else None, default=False),
|
||||
"rejected": parse_bool(row["rejected"] if "rejected" in expression_columns else None, default=False),
|
||||
"modified_by": modified_by.name if modified_by is not None else None,
|
||||
},
|
||||
)
|
||||
migrated_count += 1
|
||||
|
||||
print(
|
||||
"Expression modified_by 迁移统计:"
|
||||
f" AI={modified_by_ai_count}, USER={modified_by_user_count}, NULL={modified_by_null_count}"
|
||||
)
|
||||
if unknown_modified_by_values:
|
||||
preview_items = list(unknown_modified_by_values.items())[:10]
|
||||
preview_text = ", ".join(f"{value!r} x{count}" for value, count in preview_items)
|
||||
print(f"警告:以下旧 modified_by 值未识别,已按 NULL 迁移:{preview_text}")
|
||||
return migrated_count
|
||||
|
||||
|
||||
def migrate_jargons(
|
||||
old_rows: Iterable[sqlite3.Row],
|
||||
target_session: Session,
|
||||
jargon_columns: set[str],
|
||||
jargon_nullable_map: dict[str, bool],
|
||||
) -> int:
|
||||
"""迁移 jargon 数据。"""
|
||||
migrated_count = 0
|
||||
coerced_meaning_null_count = 0
|
||||
for row in old_rows:
|
||||
count = int(row["count"]) if "count" in jargon_columns and row["count"] is not None else 0
|
||||
raw_content_value = row["raw_content"] if "raw_content" in jargon_columns else None
|
||||
raw_content_list = normalize_string_list(raw_content_value)
|
||||
meaning_value = normalize_optional_text(row["meaning"] if "meaning" in jargon_columns else None)
|
||||
is_jargon_value = parse_optional_bool(row["is_jargon"] if "is_jargon" in jargon_columns else None)
|
||||
inference_content_key = (
|
||||
"inference_content_only"
|
||||
if "inference_content_only" in jargon_columns
|
||||
else "inference_with_content_only"
|
||||
if "inference_with_content_only" in jargon_columns
|
||||
else None
|
||||
)
|
||||
|
||||
ensure_nullable_compatibility("jargons", "is_jargon", row["id"], is_jargon_value, jargon_nullable_map)
|
||||
|
||||
if meaning_value is None and not jargon_nullable_map.get("meaning", True):
|
||||
meaning_value = ""
|
||||
coerced_meaning_null_count += 1
|
||||
|
||||
# 显式执行 SQL,避免 ORM 在 None 场景下回填模型默认值。
|
||||
target_session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO jargons (
|
||||
id,
|
||||
content,
|
||||
raw_content,
|
||||
meaning,
|
||||
session_id_dict,
|
||||
count,
|
||||
is_jargon,
|
||||
is_complete,
|
||||
is_global,
|
||||
last_inference_count,
|
||||
inference_with_context,
|
||||
inference_with_content_only
|
||||
) VALUES (
|
||||
:id,
|
||||
:content,
|
||||
:raw_content,
|
||||
:meaning,
|
||||
:session_id_dict,
|
||||
:count,
|
||||
:is_jargon,
|
||||
:is_complete,
|
||||
:is_global,
|
||||
:last_inference_count,
|
||||
:inference_with_context,
|
||||
:inference_with_content_only
|
||||
)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"id": int(row["id"]) if row["id"] is not None else None,
|
||||
"content": str(row["content"]).strip(),
|
||||
"raw_content": json.dumps(raw_content_list, ensure_ascii=False) if raw_content_value is not None else None,
|
||||
"meaning": meaning_value,
|
||||
"session_id_dict": build_session_id_dict(
|
||||
row["chat_id"] if "chat_id" in jargon_columns else None,
|
||||
fallback_count=count,
|
||||
),
|
||||
"count": count,
|
||||
"is_jargon": is_jargon_value,
|
||||
"is_complete": parse_bool(row["is_complete"] if "is_complete" in jargon_columns else None, default=False),
|
||||
"is_global": parse_bool(row["is_global"] if "is_global" in jargon_columns else None, default=False),
|
||||
"last_inference_count": (
|
||||
int(row["last_inference_count"])
|
||||
if "last_inference_count" in jargon_columns and row["last_inference_count"] is not None
|
||||
else 0
|
||||
),
|
||||
"inference_with_context": (
|
||||
str(row["inference_with_context"])
|
||||
if "inference_with_context" in jargon_columns and row["inference_with_context"] is not None
|
||||
else None
|
||||
),
|
||||
"inference_with_content_only": (
|
||||
str(row[inference_content_key])
|
||||
if inference_content_key and row[inference_content_key] is not None
|
||||
else None
|
||||
),
|
||||
},
|
||||
)
|
||||
migrated_count += 1
|
||||
|
||||
if coerced_meaning_null_count > 0:
|
||||
print(
|
||||
f"警告:目标表 jargons.meaning 不允许 NULL,已将 {coerced_meaning_null_count} 条旧记录的 NULL meaning 转为空字符串。"
|
||||
)
|
||||
return migrated_count
|
||||
|
||||
|
||||
def confirm_target_replacement(target_db_path: Path, clear_target: bool) -> bool:
|
||||
"""确认是否写入目标数据库。"""
|
||||
if clear_target:
|
||||
return prompt_yes_no(f"将清空目标库中的 expressions/jargons 后再迁移,确认继续吗?\n目标库:{target_db_path}")
|
||||
return prompt_yes_no(f"将写入目标库,若主键冲突会导致迁移失败,确认继续吗?\n目标库:{target_db_path}")
|
||||
|
||||
|
||||
def parse_arguments() -> Namespace:
|
||||
"""解析参数。"""
|
||||
return build_argument_parser().parse_args()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""脚本入口。"""
|
||||
args = parse_arguments()
|
||||
|
||||
print("旧版 expression/jargon -> 新版 expressions/jargons 迁移工具")
|
||||
source_db_path = prompt_path("请输入旧版数据库路径", args.source_db)
|
||||
target_db_path = prompt_path("请输入新版数据库路径", args.target_db)
|
||||
clear_target = args.clear_target or prompt_yes_no("迁移前是否清空目标库中的 expressions 和 jargons 表?", False)
|
||||
|
||||
if source_db_path == target_db_path:
|
||||
raise ValueError("旧版数据库路径和新版数据库路径不能相同。")
|
||||
|
||||
ensure_sqlite_file(source_db_path, should_exist=True)
|
||||
ensure_sqlite_file(target_db_path, should_exist=False)
|
||||
|
||||
print(f"旧库:{source_db_path}")
|
||||
print(f"新库:{target_db_path}")
|
||||
print(f"清空目标表:{'是' if clear_target else '否'}")
|
||||
|
||||
if not confirm_target_replacement(target_db_path, clear_target):
|
||||
print("已取消迁移。")
|
||||
return
|
||||
|
||||
source_connection = connect_sqlite(source_db_path)
|
||||
try:
|
||||
expression_table_name = resolve_source_table_name(source_connection, ["expression", "expressions"])
|
||||
jargon_table_name = resolve_source_table_name(source_connection, ["jargon", "jargons"])
|
||||
expression_columns = get_table_columns(source_connection, expression_table_name)
|
||||
jargon_columns = get_table_columns(source_connection, jargon_table_name)
|
||||
expression_rows = load_rows(source_connection, expression_table_name)
|
||||
jargon_rows = load_rows(source_connection, jargon_table_name)
|
||||
finally:
|
||||
source_connection.close()
|
||||
|
||||
target_engine = create_target_engine(target_db_path)
|
||||
SQLModel.metadata.create_all(target_engine)
|
||||
|
||||
target_sqlite_connection = connect_sqlite(target_db_path)
|
||||
try:
|
||||
jargon_nullable_map = get_table_nullable_map(target_sqlite_connection, "jargons")
|
||||
finally:
|
||||
target_sqlite_connection.close()
|
||||
|
||||
with Session(target_engine) as target_session:
|
||||
if clear_target:
|
||||
clear_target_tables(target_session)
|
||||
target_session.commit()
|
||||
|
||||
expression_count = migrate_expressions(expression_rows, target_session, expression_columns)
|
||||
jargon_count = migrate_jargons(jargon_rows, target_session, jargon_columns, jargon_nullable_map)
|
||||
target_session.commit()
|
||||
|
||||
print("迁移完成。")
|
||||
print(f"已迁移 expression 记录:{expression_count}")
|
||||
print(f"已迁移 jargon 记录:{jargon_count}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
8
config/README.md
Normal file
8
config/README.md
Normal file
@@ -0,0 +1,8 @@
|
||||
Runtime config files are intentionally not committed here.
|
||||
|
||||
Create these files locally or on the server under the runtime-mounted config directory:
|
||||
|
||||
- `bot_config.toml`
|
||||
- `model_config.toml`
|
||||
|
||||
The server deployment mounts `docker-config/mmc` into `/MaiMBot/config`, so production secrets and bot-specific settings stay outside Git.
|
||||
22
crowdin.yml
Normal file
22
crowdin.yml
Normal file
@@ -0,0 +1,22 @@
|
||||
project_id_env: CROWDIN_PROJECT_ID
|
||||
api_token_env: CROWDIN_PERSONAL_TOKEN
|
||||
base_path: .
|
||||
base_url: "https://api.crowdin.com"
|
||||
preserve_hierarchy: true
|
||||
|
||||
export_languages:
|
||||
- en-US
|
||||
- ja
|
||||
- ko
|
||||
files:
|
||||
- source: /locales/zh-CN/*.json
|
||||
translation: /locales/%locale%/%original_file_name%
|
||||
|
||||
- source: /prompts/zh-CN/**/*.prompt
|
||||
translation: /prompts/%locale%/**/%original_file_name%
|
||||
|
||||
- source: /dashboard/src/i18n/locales/zh.json
|
||||
translation: /dashboard/src/i18n/locales/%two_letters_code%.json
|
||||
languages_mapping:
|
||||
two_letters_code:
|
||||
en-US: en
|
||||
8
dashboard/.prettierrc
Normal file
8
dashboard/.prettierrc
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 100,
|
||||
"plugins": ["prettier-plugin-tailwindcss"]
|
||||
}
|
||||
661
dashboard/LICENSE
Normal file
661
dashboard/LICENSE
Normal file
@@ -0,0 +1,661 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
377
dashboard/README.md
Normal file
377
dashboard/README.md
Normal file
@@ -0,0 +1,377 @@
|
||||
# MaiBot Dashboard
|
||||
|
||||
> MaiBot 的现代化 Web 管理面板 - 基于 React 19 + TypeScript + Vite 构建
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://react.dev/)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](https://vitejs.dev/)
|
||||
[](https://tailwindcss.com/)
|
||||
|
||||
</div>
|
||||
|
||||
## 📖 项目简介
|
||||
|
||||
MaiBot Dashboard 是 MaiBot 聊天机器人的 Web 管理界面,提供了直观的配置管理、实时监控、插件管理、资源管理等功能。通过自动解析后端配置类,动态生成表单,实现了配置的可视化编辑。
|
||||
|
||||
<div align="center">
|
||||
<img src="docs/main.png" alt="MaiBot Dashboard 界面预览" width="800" />
|
||||
</div>
|
||||
|
||||
### ✨ 核心特性
|
||||
|
||||
- 🎨 **现代化 UI** - 基于 shadcn/ui 组件库,支持亮色/暗色主题切换
|
||||
- ⚡ **高性能** - 使用 Vite 7.2 构建,React 19 最新特性
|
||||
- 🔐 **安全认证** - Token 认证机制,支持自定义和自动生成 Token
|
||||
- 📝 **智能配置** - 自动解析 Python dataclass,生成配置表单
|
||||
- 🎯 **类型安全** - 完整的 TypeScript 类型定义
|
||||
- 🔄 **实时更新** - WebSocket 实时日志流、配置自动保存
|
||||
- 📱 **响应式设计** - 完美适配桌面和移动设备
|
||||
- 💬 **本地对话** - 直接在 WebUI 与麦麦对话,无需外部平台
|
||||
|
||||
## 🎯 功能模块
|
||||
|
||||
### 📊 仪表盘(首页)
|
||||
- **实时统计** - 总请求数、Token 消耗、费用统计、在线时长
|
||||
- **模型统计** - 各模型的使用次数、费用、平均响应时间
|
||||
- **趋势图表** - 每小时请求量、Token 消耗、费用趋势折线图
|
||||
- **模型分布** - 饼图展示模型使用占比
|
||||
- **最近活动** - 实时刷新的请求活动列表
|
||||
|
||||
### 💬 本地聊天室
|
||||
- **WebSocket 实时通信** - 与麦麦直接对话
|
||||
- **消息历史** - 自动加载 SQLite 存储的历史消息
|
||||
- **连接状态** - 实时显示 WebSocket 连接状态
|
||||
- **自定义昵称** - 可自定义用户身份
|
||||
- **移动端适配** - 完整的响应式聊天界面
|
||||
|
||||
### ⚙️ 配置管理
|
||||
|
||||
#### 麦麦主程序配置
|
||||
- **分组展示** - 配置项按功能分组(基础设置、功能开关等)
|
||||
- **智能表单** - 根据配置类型自动生成对应控件
|
||||
- **自动保存** - 2秒防抖自动保存,无需手动操作
|
||||
- **一键重启** - 保存并重启麦麦,使配置生效
|
||||
|
||||
#### AI 模型厂商配置
|
||||
- **提供商管理** - 添加、编辑、删除 API 提供商
|
||||
- **模板选择** - 预设常用厂商模板(OpenAI、DeepSeek、硅基流动等)
|
||||
- **连接测试** - ⚡ 测试提供商连接状态和 API Key 有效性
|
||||
- **批量操作** - 批量删除、批量测试所有提供商
|
||||
- **搜索过滤** - 按名称、URL、类型快速筛选
|
||||
|
||||
#### 模型管理与分配
|
||||
- **模型列表** - 管理可用的模型配置
|
||||
- **使用状态** - 显示模型是否被任务使用
|
||||
- **任务分配** - 为不同功能分配模型(回复、工具调用、VLM 等)
|
||||
- **参数调整** - 温度、最大 Token 等参数配置
|
||||
- **新手引导** - 交互式引导教程
|
||||
|
||||
#### 适配器配置
|
||||
- **NapCat 配置** - 管理 QQ 机器人适配器
|
||||
- **Docker 支持** - 支持容器模式配置
|
||||
- **配置导入导出** - 跨环境迁移配置
|
||||
|
||||
### 📋 实时日志
|
||||
- **WebSocket 流式传输** - 实时接收后端日志
|
||||
- **虚拟滚动** - 高性能处理大量日志
|
||||
- **多级过滤** - 按日志级别(DEBUG/INFO/WARNING/ERROR)过滤
|
||||
- **模块过滤** - 按日志来源模块筛选
|
||||
- **时间范围** - 日期选择器筛选日志
|
||||
- **搜索高亮** - 关键字搜索并高亮显示
|
||||
- **字号调整** - 自定义日志显示字号和行间距
|
||||
- **日志导出** - 导出过滤后的日志
|
||||
|
||||
### 🔌 插件管理
|
||||
- **插件市场** - 浏览和搜索可用插件
|
||||
- **分类筛选** - 按类别、状态筛选插件
|
||||
- **一键安装** - 自动处理依赖并安装插件
|
||||
- **版本兼容** - 检查插件与 MaiBot 版本兼容性
|
||||
- **进度显示** - WebSocket 实时显示安装进度
|
||||
- **插件统计** - 下载量、更新时间等信息
|
||||
- **卸载更新** - 管理已安装插件
|
||||
|
||||
### 👤 人物关系管理
|
||||
- **人物列表** - 查看所有已知用户信息
|
||||
- **详情编辑** - 编辑用户昵称、备注等信息
|
||||
- **关系统计** - 查看消息数、互动频率等统计
|
||||
- **批量操作** - 批量删除用户记录
|
||||
|
||||
### 📦 资源管理
|
||||
|
||||
#### 表情包管理
|
||||
- **预览管理** - 图片/GIF 预览
|
||||
- **分类过滤** - 按注册状态、描述筛选
|
||||
- **编辑标签** - 修改表情包描述和属性
|
||||
- **批量禁用** - 启用/禁用表情包
|
||||
|
||||
#### 表达方式管理
|
||||
- **表达列表** - 查看麦麦学习的表达方式
|
||||
- **来源追踪** - 记录表达来源群组和用户
|
||||
- **编辑创建** - 手动添加或编辑表达
|
||||
|
||||
#### 知识图谱
|
||||
- **可视化展示** - ReactFlow 交互式图谱
|
||||
- **节点搜索** - 搜索实体和关系
|
||||
- **布局算法** - 自动布局优化
|
||||
- **详情查看** - 点击节点查看详细信息
|
||||
|
||||
### ⚙️ 系统设置
|
||||
- **主题切换** - 亮色/暗色/跟随系统
|
||||
- **动画控制** - 开启/关闭界面动画
|
||||
- **Token 管理** - 查看、复制、重新生成认证 Token
|
||||
- **版本信息** - 查看前端和后端版本
|
||||
|
||||
## 🏗️ 技术架构
|
||||
|
||||
### 前端技术栈
|
||||
|
||||
```
|
||||
React 19.2.0 # UI 框架
|
||||
├── TypeScript 5.9 # 类型系统
|
||||
├── Vite 7.2 # 构建工具
|
||||
├── TanStack Router # 路由管理
|
||||
├── TanStack Virtual # 虚拟滚动
|
||||
├── Jotai # 状态管理
|
||||
├── Tailwind CSS 4.2 # 样式框架
|
||||
├── ReactFlow # 知识图谱可视化
|
||||
├── Recharts # 数据图表
|
||||
└── shadcn/ui # 组件库
|
||||
├── Radix UI # 无障碍组件
|
||||
└── lucide-react # 图标库
|
||||
```
|
||||
|
||||
### 后端集成
|
||||
|
||||
```
|
||||
FastAPI # Python 后端框架
|
||||
├── WebSocket # 实时日志、聊天
|
||||
├── config_schema.py # 配置架构生成器
|
||||
├── config_routes.py # 配置管理 API
|
||||
├── model_routes.py # 模型管理 API
|
||||
├── chat_routes.py # 本地聊天 API
|
||||
├── plugin_routes.py # 插件管理 API
|
||||
├── person_routes.py # 人物管理 API
|
||||
├── emoji_routes.py # 表情包管理 API
|
||||
├── expression_routes.py # 表达管理 API
|
||||
├── knowledge_routes.py # 知识图谱 API
|
||||
├── logs_routes.py # 日志 API
|
||||
└── tomlkit # TOML 文件处理
|
||||
```
|
||||
|
||||
## 📁 项目结构
|
||||
|
||||
```
|
||||
MaiBot-Dashboard/
|
||||
├── src/
|
||||
│ ├── components/ # 组件目录
|
||||
│ │ ├── ui/ # shadcn/ui 组件
|
||||
│ │ ├── layout.tsx # 布局组件(侧边栏+导航)
|
||||
│ │ ├── tour/ # 新手引导组件
|
||||
│ │ ├── plugin-stats.tsx # 插件统计组件
|
||||
│ │ ├── RestartingOverlay.tsx # 重启遮罩
|
||||
│ │ └── use-theme.tsx # 主题管理
|
||||
│ ├── routes/ # 路由页面
|
||||
│ │ ├── index.tsx # 仪表盘首页
|
||||
│ │ ├── auth.tsx # 登录页
|
||||
│ │ ├── chat.tsx # 本地聊天室
|
||||
│ │ ├── logs.tsx # 日志查看
|
||||
│ │ ├── plugins.tsx # 插件管理
|
||||
│ │ ├── person.tsx # 人物管理
|
||||
│ │ ├── settings.tsx # 系统设置
|
||||
│ │ ├── config/ # 配置管理页面
|
||||
│ │ │ ├── bot.tsx # 麦麦主程序配置
|
||||
│ │ │ ├── modelProvider.tsx # 模型提供商
|
||||
│ │ │ ├── model.tsx # 模型管理
|
||||
│ │ │ └── adapter.tsx # 适配器配置
|
||||
│ │ └── resource/ # 资源管理页面
|
||||
│ │ ├── emoji.tsx # 表情包管理
|
||||
│ │ ├── expression.tsx # 表达方式管理
|
||||
│ │ └── knowledge-graph.tsx # 知识图谱
|
||||
│ ├── lib/ # 工具库
|
||||
│ │ ├── config-api.ts # 配置 API 客户端
|
||||
│ │ ├── plugin-api.ts # 插件 API 客户端
|
||||
│ │ ├── person-api.ts # 人物 API 客户端
|
||||
│ │ ├── expression-api.ts # 表达 API 客户端
|
||||
│ │ ├── log-websocket.ts # 日志 WebSocket
|
||||
│ │ ├── fetch-with-auth.ts # 认证请求封装
|
||||
│ │ └── utils.ts # 通用工具函数
|
||||
│ ├── types/ # 类型定义
|
||||
│ │ ├── config-schema.ts # 配置架构类型
|
||||
│ │ ├── plugin.ts # 插件类型
|
||||
│ │ ├── person.ts # 人物类型
|
||||
│ │ └── expression.ts # 表达类型
|
||||
│ ├── hooks/ # React Hooks
|
||||
│ │ ├── use-auth.ts # 认证逻辑
|
||||
│ │ ├── use-animation.ts # 动画控制
|
||||
│ │ └── use-toast.ts # 消息提示
|
||||
│ ├── store/ # 全局状态
|
||||
│ │ └── auth.ts # 认证状态
|
||||
│ ├── router.tsx # 路由配置
|
||||
│ ├── main.tsx # 应用入口
|
||||
│ └── index.css # 全局样式
|
||||
├── public/ # 静态资源
|
||||
├── vite.config.ts # Vite 配置
|
||||
├── tailwind.config.js # Tailwind v4 兼容占位配置
|
||||
├── tsconfig.json # TypeScript 配置
|
||||
└── package.json # 依赖管理
|
||||
```
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 环境要求
|
||||
|
||||
- Node.js >= 18.0.0
|
||||
- Bun >= 1.0.0 (推荐) 或 npm/yarn/pnpm
|
||||
|
||||
### 安装依赖
|
||||
|
||||
```bash
|
||||
# 使用 Bun(推荐)
|
||||
bun install
|
||||
|
||||
# 或使用 npm
|
||||
npm install
|
||||
```
|
||||
|
||||
### 开发模式
|
||||
|
||||
```bash
|
||||
# 启动开发服务器 (默认端口: 7999)
|
||||
bun run dev
|
||||
|
||||
# 或
|
||||
npm run dev
|
||||
```
|
||||
|
||||
访问 http://localhost:7999 查看应用。
|
||||
|
||||
### 生产构建
|
||||
|
||||
```bash
|
||||
# 构建生产版本
|
||||
bun run build
|
||||
|
||||
# 预览生产构建
|
||||
bun run preview
|
||||
```
|
||||
|
||||
构建产物会输出到 `dist/` 目录,由 MaiBot 后端静态服务。
|
||||
|
||||
### 代码格式化
|
||||
|
||||
```bash
|
||||
# 格式化代码
|
||||
bun run format
|
||||
```
|
||||
|
||||
## 🔧 开发配置
|
||||
|
||||
### Vite 代理配置
|
||||
|
||||
开发模式下,Vite 会将 API 请求代理到后端:
|
||||
|
||||
```typescript
|
||||
// vite.config.ts
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:8001',
|
||||
changeOrigin: true,
|
||||
ws: true, // WebSocket 支持
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
### 环境变量
|
||||
|
||||
开发环境默认使用 `http://localhost:7999`,生产环境使用相对路径。
|
||||
|
||||
## 📸 界面预览
|
||||
|
||||
### 仪表盘
|
||||
实时统计、模型使用分布、趋势图表
|
||||
|
||||
### 本地聊天
|
||||
直接与麦麦对话,消息实时同步
|
||||
|
||||
### 配置管理
|
||||
分组配置项,自动生成表单,自动保存
|
||||
|
||||
### 模型提供商
|
||||
一键测试连接状态,模板快速添加
|
||||
|
||||
### 日志查看
|
||||
实时日志流,多级过滤,虚拟滚动
|
||||
|
||||
## 📦 依赖说明
|
||||
|
||||
### 核心依赖
|
||||
|
||||
| 包名 | 版本 | 用途 |
|
||||
|------|------|------|
|
||||
| react | ^19.2.0 | UI 框架 |
|
||||
| react-dom | ^19.2.0 | React DOM 渲染 |
|
||||
| typescript | ~5.9.3 | 类型系统 |
|
||||
| vite | ^7.2.2 | 构建工具 |
|
||||
| @tanstack/react-router | ^1.136.1 | 路由管理 |
|
||||
| @tanstack/react-virtual | ^3.x | 虚拟滚动 |
|
||||
| jotai | ^2.15.1 | 状态管理 |
|
||||
| axios | ^1.13.2 | HTTP 客户端 |
|
||||
| recharts | ^2.x | 数据图表 |
|
||||
| reactflow | ^11.x | 知识图谱可视化 |
|
||||
| dagre | ^0.8.x | 图布局算法 |
|
||||
|
||||
### UI 组件库
|
||||
|
||||
| 包名 | 版本 | 用途 |
|
||||
|------|------|------|
|
||||
| @radix-ui/react-* | ^1.x | 无障碍组件基础 |
|
||||
| lucide-react | ^0.553.0 | 图标库 |
|
||||
| tailwindcss | ^4.2.1 | CSS 框架 |
|
||||
| class-variance-authority | ^0.7.1 | 类名管理 |
|
||||
| tailwind-merge | ^3.4.0 | Tailwind 类合并 |
|
||||
| date-fns | ^3.x | 日期处理 |
|
||||
|
||||
|
||||
## 🤝 贡献指南
|
||||
|
||||
1. Fork 本仓库
|
||||
2. 创建特性分支 (`git checkout -b feature/AmazingFeature`)
|
||||
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
|
||||
4. 推送到分支 (`git push origin feature/AmazingFeature`)
|
||||
5. 开启 Pull Request
|
||||
|
||||
### 代码规范
|
||||
|
||||
- 使用 TypeScript 严格模式
|
||||
- 遵循 ESLint 规则
|
||||
- 使用 Prettier 格式化代码
|
||||
- 组件使用函数式编写
|
||||
- 优先使用 Hooks
|
||||
- 响应式设计优先(移动端适配)
|
||||
|
||||
## 📄 开源协议
|
||||
|
||||
本项目基于 GPLv3 协议开源,详见 [LICENSE](./LICENSE) 文件。
|
||||
|
||||
## 👥 作者
|
||||
|
||||
**MotricSeven** - [GitHub](https://github.com/DrSmoothl)
|
||||
|
||||
## 🙏 致谢
|
||||
|
||||
- [React](https://react.dev/) - UI 框架
|
||||
- [shadcn/ui](https://ui.shadcn.com/) - 组件库
|
||||
- [Radix UI](https://www.radix-ui.com/) - 无障碍组件
|
||||
- [TanStack Router](https://tanstack.com/router) - 路由解决方案
|
||||
- [TanStack Virtual](https://tanstack.com/virtual) - 虚拟滚动
|
||||
- [Tailwind CSS](https://tailwindcss.com/) - CSS 框架
|
||||
- [ReactFlow](https://reactflow.dev/) - 流程图/知识图谱
|
||||
- [Recharts](https://recharts.org/) - React 图表库
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
Made with ❤️ by MotricSeven and Mai-with-u
|
||||
</div>
|
||||
2502
dashboard/bun.lock
Normal file
2502
dashboard/bun.lock
Normal file
File diff suppressed because it is too large
Load Diff
6
dashboard/bunfig.toml
Normal file
6
dashboard/bunfig.toml
Normal file
@@ -0,0 +1,6 @@
|
||||
[install]
|
||||
registry = "https://mirrors.cloud.tencent.com/npm/"
|
||||
linker = "hoisted"
|
||||
|
||||
[install.cache]
|
||||
disableManifest = true
|
||||
20
dashboard/components.json
Normal file
20
dashboard/components.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "src/components",
|
||||
"utils": "src/lib/utils",
|
||||
"ui": "src/components/ui",
|
||||
"lib": "src/lib",
|
||||
"hooks": "src/hooks"
|
||||
}
|
||||
}
|
||||
12
dashboard/docs/Caddyfile.docker.example
Normal file
12
dashboard/docs/Caddyfile.docker.example
Normal file
@@ -0,0 +1,12 @@
|
||||
maibot.example.com {
|
||||
encode zstd gzip
|
||||
|
||||
header {
|
||||
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||
X-Content-Type-Options "nosniff"
|
||||
X-Frame-Options "SAMEORIGIN"
|
||||
Referrer-Policy "strict-origin-when-cross-origin"
|
||||
}
|
||||
|
||||
reverse_proxy core:8001
|
||||
}
|
||||
12
dashboard/docs/Caddyfile.host.example
Normal file
12
dashboard/docs/Caddyfile.host.example
Normal file
@@ -0,0 +1,12 @@
|
||||
maibot.example.com {
|
||||
encode zstd gzip
|
||||
|
||||
header {
|
||||
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||
X-Content-Type-Options "nosniff"
|
||||
X-Frame-Options "SAMEORIGIN"
|
||||
Referrer-Policy "strict-origin-when-cross-origin"
|
||||
}
|
||||
|
||||
reverse_proxy 127.0.0.1:8001
|
||||
}
|
||||
BIN
dashboard/docs/main.png
Normal file
BIN
dashboard/docs/main.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 413 KiB |
203
dashboard/docs/webui-tls-ssl-compose.md
Normal file
203
dashboard/docs/webui-tls-ssl-compose.md
Normal file
@@ -0,0 +1,203 @@
|
||||
# MaiBot WebUI Compose TLS/SSL 教程
|
||||
|
||||
本文档专门说明 Docker Compose 部署下如何通过 Caddy 为 MaiBot WebUI 提供 HTTPS。
|
||||
|
||||
## 1. 目标结构
|
||||
|
||||
启用后,网络结构应为:
|
||||
|
||||
```text
|
||||
浏览器
|
||||
-> https://maibot.example.com
|
||||
-> Caddy 容器 :80/:443
|
||||
-> core 容器 :8001
|
||||
-> MaiBot WebUI
|
||||
```
|
||||
|
||||
这意味着:
|
||||
|
||||
1. core 不再直接对公网暴露 8001
|
||||
2. Caddy 统一接管 80 和 443
|
||||
3. Caddy 通过 Docker 网络访问 core:8001
|
||||
|
||||
## 2. 仓库里已经补了什么
|
||||
|
||||
本仓库已补充以下内容:
|
||||
|
||||
1. 根目录 docker-compose.yml 中新增了默认注释的 Caddy 示例块
|
||||
2. 根目录 docker-compose.yml 中新增了默认注释的 Caddy 数据卷定义
|
||||
3. dashboard/docs/Caddyfile.docker.example 提供了 Docker Compose 专用配置模板
|
||||
4. dashboard/docs/Caddyfile.host.example 提供了非 Docker 宿主机专用配置模板
|
||||
|
||||
## 3. 需要手动注释或启用的段落
|
||||
|
||||
本文档按默认保持注释状态进行说明,下面明确列出需要操作的段落。
|
||||
|
||||
### 3.1 需要注释掉的现有段落
|
||||
|
||||
启用 Caddy 以后,请注释掉根目录 docker-compose.yml 中 core 服务下这一段端口映射:
|
||||
|
||||
```yaml
|
||||
ports:
|
||||
- "18001:8001"
|
||||
```
|
||||
|
||||
原因很简单:
|
||||
|
||||
1. 这段会把 WebUI 的明文 HTTP 直接暴露到宿主机
|
||||
2. 启用 HTTPS 以后,应由 Caddy 对外暴露 80 和 443
|
||||
3. 避免出现“HTTPS 入口和 HTTP 入口同时暴露”的混乱状态
|
||||
|
||||
### 3.2 需要取消注释并启用的段落
|
||||
|
||||
启用时,需要在根目录 docker-compose.yml 中取消注释这两部分:
|
||||
|
||||
1. caddy 服务块
|
||||
2. volumes 里的 caddy_data 和 caddy_config
|
||||
|
||||
## 4. 启用前需要准备什么
|
||||
|
||||
1. 域名已经解析到服务器公网 IP
|
||||
2. 宿主机的 80 和 443 未被占用
|
||||
3. 防火墙和云安全组已放行 80 和 443
|
||||
4. WebUI 当前可以通过 compose 正常启动
|
||||
5. 已准备修改 dashboard/docs/Caddyfile.docker.example 里的域名
|
||||
|
||||
## 5. Caddy 配置文件如何写
|
||||
|
||||
Docker Compose 模式请使用:dashboard/docs/Caddyfile.docker.example
|
||||
|
||||
非 Docker 宿主机模式请使用:dashboard/docs/Caddyfile.host.example
|
||||
|
||||
最小可用配置如下:
|
||||
|
||||
```caddyfile
|
||||
maibot.example.com {
|
||||
reverse_proxy core:8001
|
||||
}
|
||||
```
|
||||
|
||||
建议至少做这两处修改:
|
||||
|
||||
1. 把 maibot.example.com 改成实际使用的域名
|
||||
2. 如果有额外安全要求,再按需增加 header 配置
|
||||
|
||||
## 6. compose 启用步骤
|
||||
|
||||
### 6.1 修改 WebUI 配置
|
||||
|
||||
先在 config/bot_config.toml 中确认:
|
||||
|
||||
```toml
|
||||
[webui]
|
||||
mode = "production"
|
||||
secure_cookie = true
|
||||
trust_xff = true
|
||||
```
|
||||
|
||||
trusted_proxies 的建议值取决于实际网络环境。
|
||||
|
||||
如果 Caddy 和 core 在同一个 Docker 网络里,建议先按实际来源地址或网段填写。不要为了省事直接把范围开得过大。
|
||||
|
||||
### 6.2 修改 Caddyfile
|
||||
|
||||
编辑 dashboard/docs/Caddyfile.docker.example,将域名替换为真实值。
|
||||
|
||||
### 6.3 修改 compose
|
||||
|
||||
1. 注释掉 core 服务里对外暴露 WebUI 的 ports 段
|
||||
2. 取消注释 caddy 服务块
|
||||
3. 取消注释底部 volumes 里的 caddy_data 和 caddy_config
|
||||
|
||||
### 6.4 启动服务
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### 6.5 查看日志
|
||||
|
||||
```bash
|
||||
docker compose logs -f caddy
|
||||
docker compose logs -f core
|
||||
```
|
||||
|
||||
## 7. Let's Encrypt 申请与续期
|
||||
|
||||
### 7.1 证书申请触发条件
|
||||
|
||||
Caddy 容器启动后,满足以下条件时会自动申请证书:
|
||||
|
||||
1. 域名已解析到当前服务器
|
||||
2. 80 和 443 对公网开放
|
||||
3. Caddy 能成功接收到针对该域名的请求
|
||||
|
||||
### 7.2 自动续期说明
|
||||
|
||||
Caddy 会自动续期,通常不需要编写 crontab,也不需要手工执行 certbot。
|
||||
|
||||
只需要确保:
|
||||
|
||||
1. caddy_data 卷被持久化
|
||||
2. 容器会长期运行
|
||||
3. 域名长期指向同一台服务器或新服务器已同步迁移数据
|
||||
4. 80 和 443 没被防火墙阻断
|
||||
|
||||
### 7.3 续期检查建议
|
||||
|
||||
建议定期执行:
|
||||
|
||||
```bash
|
||||
docker compose logs --tail=200 caddy
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
重点关注:
|
||||
|
||||
1. ACME 申请失败
|
||||
2. 证书续期失败
|
||||
3. 端口绑定失败
|
||||
4. 域名解析不一致
|
||||
|
||||
## 8. 常见错误与排查
|
||||
|
||||
### 8.1 证书申请失败
|
||||
|
||||
优先检查:
|
||||
|
||||
1. 域名是否指向服务器公网 IP
|
||||
2. 是否已经开启 CDN 代理但未正确放通验证流量
|
||||
3. 80 和 443 是否被云厂商安全组拦截
|
||||
4. 宿主机是否还有别的程序占用了 80 或 443
|
||||
|
||||
### 8.2 登录失败
|
||||
|
||||
优先检查:
|
||||
|
||||
1. webui.secure_cookie 是否已启用
|
||||
2. 请求是否真正走 https:// 域名
|
||||
3. 代理是否正确传递了 X-Forwarded-Proto
|
||||
|
||||
### 8.3 WebSocket 连接失败
|
||||
|
||||
优先检查:
|
||||
|
||||
1. Caddy 是否已正确反向代理到 core:8001
|
||||
2. 页面是否通过 HTTPS 打开
|
||||
3. 浏览器开发者工具里是否出现混合内容报错
|
||||
|
||||
## 9. 迁移建议
|
||||
|
||||
如果当前已经在使用:
|
||||
|
||||
```yaml
|
||||
ports:
|
||||
- "18001:8001"
|
||||
```
|
||||
|
||||
那说明当前还是“宿主机明文 HTTP 暴露 WebUI”模式。迁移到 HTTPS 时建议:
|
||||
|
||||
1. 先准备好域名
|
||||
2. 先改好 Caddyfile
|
||||
3. 再切换 compose 暴露方式
|
||||
4. 切换后直接以 https://域名 访问,不再继续使用 http://服务器IP:18001
|
||||
465
dashboard/docs/webui-tls-ssl.md
Normal file
465
dashboard/docs/webui-tls-ssl.md
Normal file
@@ -0,0 +1,465 @@
|
||||
# MaiBot WebUI TLS/SSL 配置指南
|
||||
|
||||
本文档基于当前仓库实现整理,目标是让 WebUI 通过 HTTPS 提供访问能力,并保持登录、Cookie、WebSocket 和 Let's Encrypt 续期正常工作。
|
||||
|
||||
## 1. 先说结论
|
||||
|
||||
MaiBot 当前最合适的 TLS/SSL 方案是让反向代理终止 HTTPS,然后把请求转发到 WebUI 的 HTTP 服务。
|
||||
|
||||
推荐顺序如下:
|
||||
|
||||
1. Caddy 反向代理 + Let's Encrypt 自动签发与续期
|
||||
2. 宝塔面板反向代理 + Let's Encrypt
|
||||
3. 1Panel 反向代理 + Let's Encrypt
|
||||
4. 不建议直接让 WebUI 自己监听 HTTPS,当前仓库没有现成的 WebUI 原生 TLS 配置入口
|
||||
|
||||
## 2. 当前项目的部署特征
|
||||
|
||||
当前仓库里,WebUI 的前后端是同源部署思路:
|
||||
|
||||
1. 后端是独立的 FastAPI WebUI 服务,默认监听 127.0.0.1:8001
|
||||
2. 前端构建产物由这个 FastAPI 服务直接托管
|
||||
3. 浏览器生产模式下默认按同源访问 API
|
||||
4. 页面如果通过 HTTPS 打开,前端会自动把 WebSocket 协议切到 WSS
|
||||
|
||||
这意味着最稳妥的方式是:
|
||||
|
||||
1. MaiBot WebUI 继续在本机或容器内网跑 HTTP
|
||||
2. 让 Caddy、宝塔 Nginx 或 1Panel OpenResty 对外暴露 443
|
||||
3. 由代理把所有请求和 WebSocket 都转发到 WebUI
|
||||
|
||||
## 3. 配置前的准备工作
|
||||
|
||||
正式启用 HTTPS 之前,先确认下面几项:
|
||||
|
||||
1. 已准备一个已经解析到服务器公网 IP 的域名,例如 maibot.example.com
|
||||
2. 80 和 443 端口可以从公网访问
|
||||
3. 服务器没有其他程序占用 80 和 443
|
||||
4. WebUI 可以在本机正常打开,例如 http://127.0.0.1:8001
|
||||
|
||||
如果采用 Docker Compose 部署,还要确认:
|
||||
|
||||
1. 容器已经能正常启动
|
||||
2. 根目录的 docker-compose.yml 当前可以正常运行
|
||||
3. HTTPS 入口将统一由反向代理接管
|
||||
|
||||
## 4. WebUI 自身配置
|
||||
|
||||
无论采用 Caddy、宝塔还是 1Panel,都建议先把 WebUI 配成生产模式。
|
||||
|
||||
修改 config/bot_config.toml 里的 webui 配置段,建议值如下:
|
||||
|
||||
```toml
|
||||
[webui]
|
||||
enabled = true
|
||||
mode = "production"
|
||||
anti_crawler_mode = "loose"
|
||||
allowed_ips = "127.0.0.1"
|
||||
trusted_proxies = "127.0.0.1"
|
||||
trust_xff = true
|
||||
secure_cookie = true
|
||||
enable_paragraph_content = false
|
||||
```
|
||||
|
||||
各项的意义:
|
||||
|
||||
1. mode = "production"
|
||||
让 WebUI 按生产环境运行,并倾向启用更严格的安全行为。
|
||||
2. secure_cookie = true
|
||||
让登录 Cookie 仅在 HTTPS 下传输。
|
||||
3. trust_xff = true
|
||||
允许从反向代理传入的 X-Forwarded-For 获取真实来源 IP。
|
||||
4. trusted_proxies = "127.0.0.1"
|
||||
表示只有来自本机反向代理的 X-Forwarded-For 才被信任。
|
||||
|
||||
注意:
|
||||
|
||||
1. 如果使用 Docker 内部的反向代理,trusted_proxies 不应固定写 127.0.0.1,而应填写反向代理容器到 MaiBot 的实际来源地址或所在网段。
|
||||
2. 如果尚未切换到 HTTPS,不要提前开启 secure_cookie = true,否则可能出现登录 Cookie 不生效或握手异常的问题。
|
||||
|
||||
## 5. 直接部署方式如何配置 TLS/SSL
|
||||
|
||||
这里的“直接部署”指的是:
|
||||
|
||||
1. MaiBot 直接跑在宿主机上
|
||||
2. WebUI 监听本机 127.0.0.1:8001
|
||||
3. 宿主机安装 Caddy
|
||||
4. 由 Caddy 负责申请证书和 HTTPS 反代
|
||||
|
||||
### 5.1 推荐的网络结构
|
||||
|
||||
```text
|
||||
浏览器
|
||||
-> https://maibot.example.com
|
||||
-> Caddy :443
|
||||
-> 127.0.0.1:8001
|
||||
-> MaiBot WebUI
|
||||
```
|
||||
|
||||
### 5.2 宿主机直装 Caddy
|
||||
|
||||
以 Debian 或 Ubuntu 为例,参考步骤如下:
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
|
||||
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
|
||||
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
|
||||
sudo apt update
|
||||
sudo apt install -y caddy
|
||||
```
|
||||
|
||||
macOS Homebrew 参考:
|
||||
|
||||
```bash
|
||||
brew install caddy
|
||||
```
|
||||
|
||||
### 5.3 Caddyfile 示例
|
||||
|
||||
仓库已提供两份可复制的示例文件,请按部署方式选择:
|
||||
|
||||
1. 非 Docker 宿主机部署:dashboard/docs/Caddyfile.host.example
|
||||
2. Docker Compose 部署:dashboard/docs/Caddyfile.docker.example
|
||||
|
||||
宿主机直连部署可使用以下最简配置:
|
||||
|
||||
```caddyfile
|
||||
maibot.example.com {
|
||||
reverse_proxy 127.0.0.1:8001
|
||||
}
|
||||
```
|
||||
|
||||
如需显式添加安全头,可以使用增强版:
|
||||
|
||||
```caddyfile
|
||||
maibot.example.com {
|
||||
encode zstd gzip
|
||||
|
||||
header {
|
||||
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||
X-Content-Type-Options "nosniff"
|
||||
X-Frame-Options "SAMEORIGIN"
|
||||
Referrer-Policy "strict-origin-when-cross-origin"
|
||||
}
|
||||
|
||||
reverse_proxy 127.0.0.1:8001
|
||||
}
|
||||
```
|
||||
|
||||
非 Docker 直接部署建议直接从 dashboard/docs/Caddyfile.host.example 开始修改域名并投入使用。
|
||||
|
||||
### 5.4 HSTS 是否启用
|
||||
|
||||
可以启用,而且当前推荐由反向代理统一下发 HSTS 响应头,而不是让 WebUI 自己在 FastAPI 层单独处理。
|
||||
|
||||
当前仓库提供的两份 Caddy 示例都已经带了 HSTS:
|
||||
|
||||
1. dashboard/docs/Caddyfile.host.example
|
||||
2. dashboard/docs/Caddyfile.docker.example
|
||||
|
||||
示例配置中的这一行就是 HSTS:
|
||||
|
||||
```caddyfile
|
||||
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||
```
|
||||
|
||||
这行配置的含义如下:
|
||||
|
||||
1. max-age=31536000
|
||||
浏览器在 1 年内记住该站点只能使用 HTTPS。
|
||||
2. includeSubDomains
|
||||
所有子域名也必须强制使用 HTTPS。
|
||||
3. preload
|
||||
表示该域名计划提交到浏览器内置的 HSTS preload 列表。
|
||||
|
||||
HSTS 建议按下面的节奏启用:
|
||||
|
||||
1. 初次上线 HTTPS 时,可以先使用不带 preload 的版本。
|
||||
2. 确认主域名和所有相关子域名都长期稳定支持 HTTPS 后,再考虑是否加入 preload。
|
||||
3. 如果无法确认所有子域名都支持 HTTPS,不要轻易保留 includeSubDomains。
|
||||
|
||||
更稳妥的起步版本如下:
|
||||
|
||||
```caddyfile
|
||||
Strict-Transport-Security "max-age=31536000"
|
||||
```
|
||||
|
||||
如果所有子域名都已经稳定支持 HTTPS,可以使用:
|
||||
|
||||
```caddyfile
|
||||
Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||
```
|
||||
|
||||
只有在满足下面条件时,才建议使用 preload:
|
||||
|
||||
1. 主域名始终可通过 HTTPS 访问。
|
||||
2. 所有子域名都始终可通过 HTTPS 访问。
|
||||
3. 已明确理解 preload 是长期约束,而不是临时开关。
|
||||
|
||||
HSTS 的风险点主要有这些:
|
||||
|
||||
1. 一旦浏览器记住该域名只能用 HTTPS,后续临时切回 HTTP 会直接失败。
|
||||
2. 如果开启 includeSubDomains,而某个子域名并没有部署 HTTPS,该子域名会被浏览器直接拦截。
|
||||
3. 如果开启 preload 并提交到浏览器列表,撤销成本会比较高,生效和移除都不是即时的。
|
||||
|
||||
因此,本文档里的 Caddy 示例更适合作为“完整增强版示例”参考。首次部署时,建议先按实际域名情况,将 HSTS 调整成更合适的版本后再正式上线。
|
||||
|
||||
### 5.5 启动与验证
|
||||
|
||||
```bash
|
||||
sudo caddy validate --config /etc/caddy/Caddyfile
|
||||
sudo systemctl restart caddy
|
||||
sudo systemctl status caddy
|
||||
```
|
||||
|
||||
检查项:
|
||||
|
||||
1. 浏览器访问 https://maibot.example.com 能正常打开登录页
|
||||
2. 登录后 Cookie 正常写入
|
||||
3. 日志页和聊天页的 WebSocket 可以正常连接
|
||||
4. 证书是 Let's Encrypt 或所选颁发机构签发的有效证书
|
||||
|
||||
### 5.6 直接部署方式的 Let's Encrypt 申请与续期
|
||||
|
||||
Caddy 默认会自动处理证书签发和续期,前提如下:
|
||||
|
||||
1. 域名已正确解析到服务器
|
||||
2. 80 和 443 可从公网访问
|
||||
3. 没有 CDN、WAF 或安全组拦截 ACME 验证请求
|
||||
|
||||
Caddy 的自动续期通常无需手工干预,只需确保:
|
||||
|
||||
1. 保持 Caddy 常驻运行
|
||||
2. 不要阻断 80 和 443
|
||||
3. 定期关注 Caddy 日志是否存在 ACME 失败记录
|
||||
|
||||
常用检查命令:
|
||||
|
||||
```bash
|
||||
sudo journalctl -u caddy -n 200 --no-pager
|
||||
sudo journalctl -u caddy -f
|
||||
```
|
||||
|
||||
如果续期失败,优先检查:
|
||||
|
||||
1. 域名是否仍然解析到当前服务器
|
||||
2. 80 和 443 是否被防火墙、面板或云安全组拦截
|
||||
3. 是否存在另一个程序抢占了 80 或 443
|
||||
|
||||
## 6. 宝塔面板如何配置 SSL
|
||||
|
||||
宝塔适合已经习惯图形化管理 Nginx 站点的部署方式。思路仍然是:由宝塔的站点反向代理到 MaiBot WebUI。
|
||||
|
||||
### 6.1 推荐网络结构
|
||||
|
||||
```text
|
||||
浏览器
|
||||
-> 宝塔站点 HTTPS
|
||||
-> 宝塔 Nginx/OpenResty 反向代理
|
||||
-> 127.0.0.1:8001
|
||||
-> MaiBot WebUI
|
||||
```
|
||||
|
||||
### 6.2 宝塔站点创建步骤
|
||||
|
||||
1. 登录宝塔面板。
|
||||
2. 进入网站。
|
||||
3. 添加站点。
|
||||
4. 域名填写实际使用的 WebUI 域名,例如 maibot.example.com。
|
||||
5. PHP 版本可以选纯静态或关闭运行环境,重点是站点存在即可。
|
||||
|
||||
### 6.3 反向代理配置步骤
|
||||
|
||||
1. 进入对应站点。
|
||||
2. 打开反向代理。
|
||||
3. 新增反向代理。
|
||||
4. 目标 URL 填写 http://127.0.0.1:8001。
|
||||
5. 发送域名通常保持目标域或原域名即可。
|
||||
|
||||
如果使用的是宝塔站点配置文件,也可以手动补这一段:
|
||||
|
||||
```nginx
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
}
|
||||
```
|
||||
|
||||
如果宝塔环境没有现成的 connection_upgrade 变量,可以改成:
|
||||
|
||||
```nginx
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
```
|
||||
|
||||
### 6.4 宝塔中申请 Let's Encrypt 证书
|
||||
|
||||
1. 进入站点设置。
|
||||
2. 打开 SSL。
|
||||
3. 选择 Let's Encrypt。
|
||||
4. 勾选对应域名。
|
||||
5. 申请证书。
|
||||
6. 开启强制 HTTPS。
|
||||
|
||||
### 6.5 宝塔中续期证书
|
||||
|
||||
宝塔一般会自动续期,但仍然需要检查:
|
||||
|
||||
1. 面板计划任务是否正常运行
|
||||
2. 80 端口是否在验证时可达
|
||||
3. 域名解析是否未被改动
|
||||
|
||||
建议定期查看:
|
||||
|
||||
1. 宝塔站点 SSL 到期时间
|
||||
2. 宝塔计划任务执行日志
|
||||
3. 站点错误日志和 Nginx 错误日志
|
||||
|
||||
### 6.6 宝塔模式下 WebUI 配置建议
|
||||
|
||||
建议保持:
|
||||
|
||||
```toml
|
||||
[webui]
|
||||
mode = "production"
|
||||
secure_cookie = true
|
||||
trust_xff = true
|
||||
trusted_proxies = "127.0.0.1"
|
||||
```
|
||||
|
||||
如果宝塔和 MaiBot 不在同一台机器上,trusted_proxies 需要换成宝塔所在服务器到 MaiBot 的来源地址。
|
||||
|
||||
## 7. 1Panel 如何配置 SSL
|
||||
|
||||
1Panel 的逻辑和宝塔类似,本质上也是由面板管理的网关或站点反向代理到 MaiBot WebUI。
|
||||
|
||||
### 7.1 推荐网络结构
|
||||
|
||||
```text
|
||||
浏览器
|
||||
-> 1Panel 网站/反向代理 HTTPS
|
||||
-> OpenResty/Nginx 反向代理
|
||||
-> 127.0.0.1:8001 或 core:8001
|
||||
-> MaiBot WebUI
|
||||
```
|
||||
|
||||
### 7.2 1Panel 配置步骤
|
||||
|
||||
1. 登录 1Panel。
|
||||
2. 打开网站或反向代理管理。
|
||||
3. 新建网站,域名填 maibot.example.com。
|
||||
4. 添加反向代理规则,目标地址指向 http://127.0.0.1:8001。
|
||||
5. 开启 WebSocket 支持。
|
||||
6. 保存并重载站点配置。
|
||||
|
||||
如果是在 Docker 环境里通过 1Panel 管理容器,目标地址也可以填写容器服务名,例如 http://core:8001,但前提是 1Panel 管理的网关容器与 MaiBot 在同一个 Docker 网络内。
|
||||
|
||||
### 7.3 在 1Panel 申请 Let's Encrypt 证书
|
||||
|
||||
1. 打开证书管理。
|
||||
2. 选择 Let's Encrypt。
|
||||
3. 绑定域名。
|
||||
4. 选择 HTTP-01 或面板默认验证方式。
|
||||
5. 完成签发后,把证书绑定到对应网站。
|
||||
6. 启用 HTTPS。
|
||||
|
||||
### 7.4 1Panel 中续期证书
|
||||
|
||||
1Panel 通常会自动续期,但需要确认:
|
||||
|
||||
1. 自动续期开关处于启用状态
|
||||
2. 面板的任务调度正常
|
||||
3. 80 和 443 端口验证时不被拦截
|
||||
4. 域名始终指向正确服务器
|
||||
|
||||
### 7.5 1Panel 模式下的反代头
|
||||
|
||||
请确认面板生成的配置会向后端传递:
|
||||
|
||||
1. Host
|
||||
2. X-Real-IP
|
||||
3. X-Forwarded-For
|
||||
4. X-Forwarded-Proto
|
||||
5. Upgrade
|
||||
6. Connection
|
||||
|
||||
缺少 X-Forwarded-Proto 时,WebUI 可能误判为 HTTP,进而影响 secure cookie 与登录行为。
|
||||
|
||||
## 8. Docker Compose 下如何配置 TLS/SSL
|
||||
|
||||
根目录 docker-compose.yml 已补充默认注释的 Caddy 示例块,用于容器化部署时启用 HTTPS。
|
||||
|
||||
Docker 模式下请使用:dashboard/docs/Caddyfile.docker.example
|
||||
|
||||
非 Docker 宿主机模式下请使用:dashboard/docs/Caddyfile.host.example
|
||||
|
||||
详细步骤请看另一份专项文档:dashboard/docs/webui-tls-ssl-compose.md
|
||||
|
||||
这里只先给结论:
|
||||
|
||||
1. 启用 Caddy 反向代理时,不应再把 core 的 8001 直接映射到公网
|
||||
2. 应由 Caddy 容器暴露 80 和 443
|
||||
3. Caddy 通过容器网络访问 core:8001
|
||||
|
||||
## 9. 常见问题
|
||||
|
||||
### 9.1 开了 HTTPS 后无法登录
|
||||
|
||||
优先检查:
|
||||
|
||||
1. webui.secure_cookie 是否在 HTTPS 环境下开启
|
||||
2. 代理是否正确传递 X-Forwarded-Proto
|
||||
3. 浏览器访问的是否确实是 https:// 域名而不是 http:// IP
|
||||
4. Cookie 是否被浏览器策略、扩展或跨站配置拦截
|
||||
|
||||
### 9.2 页面能打开,但日志页或聊天页 WebSocket 失败
|
||||
|
||||
优先检查:
|
||||
|
||||
1. 代理是否支持 WebSocket Upgrade
|
||||
2. 是否使用了 HTTPS 页面去连接 ws:// 明文地址
|
||||
3. Caddy、Nginx、宝塔、1Panel 是否有单独的 WebSocket 开关或升级头配置
|
||||
|
||||
### 9.3 Let's Encrypt 申请失败
|
||||
|
||||
优先检查:
|
||||
|
||||
1. 域名解析是否正确
|
||||
2. 80 端口是否可访问
|
||||
3. 是否开启了 CDN 代理但没有正确放通验证流量
|
||||
4. 面板或防火墙是否拦截 ACME 请求
|
||||
|
||||
### 9.4 是否能直接用 IP 申请 Let's Encrypt
|
||||
|
||||
不能。Let's Encrypt 只为域名签发公开可信证书,不为裸 IP 签发。
|
||||
|
||||
### 9.5 内网环境如何测试 HTTPS
|
||||
|
||||
可以使用 Caddy 的 tls internal 进行测试,但客户端必须手工信任内部 CA 根证书。正式对外服务仍建议使用有效公网域名和 Let's Encrypt。
|
||||
|
||||
## 10. 推荐实践
|
||||
|
||||
普通 Linux 服务器部署的推荐顺序如下:
|
||||
|
||||
1. 宿主机直装 Caddy
|
||||
2. WebUI 绑定 127.0.0.1:8001
|
||||
3. 域名指向服务器
|
||||
4. 用 Caddy 反代并自动管理 Let's Encrypt
|
||||
|
||||
如果已经使用面板管理服务器,则:
|
||||
|
||||
1. 宝塔用户直接用宝塔反向代理和 Let's Encrypt
|
||||
2. 1Panel 用户直接用 1Panel 网站或网关反代和证书管理
|
||||
|
||||
如果采用 Docker Compose 部署,则:
|
||||
|
||||
1. 使用根目录 compose 中提供的默认注释 Caddy 示例块
|
||||
2. 注释掉 core 服务里直接暴露 WebUI 的端口映射
|
||||
3. 由 Caddy 统一对外暴露 80 和 443
|
||||
137
dashboard/electron.vite.config.ts
Normal file
137
dashboard/electron.vite.config.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { defineConfig } from 'electron-vite'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
main: {
|
||||
build: {
|
||||
target: 'node18',
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: path.resolve(__dirname, 'electron/main/index.ts'),
|
||||
},
|
||||
output: {
|
||||
format: 'cjs',
|
||||
},
|
||||
external: ['electron', 'electron-store'],
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
},
|
||||
preload: {
|
||||
build: {
|
||||
target: 'node18',
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: path.resolve(__dirname, 'electron/preload/index.ts'),
|
||||
},
|
||||
output: {
|
||||
entryFileNames: '[name].js',
|
||||
format: 'cjs',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
renderer: {
|
||||
root: '.',
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
plugins: [tailwindcss(), react()],
|
||||
server: {
|
||||
port: 7999,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:8001',
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
cookieDomainRewrite: '',
|
||||
cookiePathRewrite: '/',
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: path.resolve(__dirname, 'index.html'),
|
||||
output: {
|
||||
manualChunks: {
|
||||
'react-vendor': ['react', 'react-dom', 'react/jsx-runtime'],
|
||||
|
||||
router: ['@tanstack/react-router', '@tanstack/react-virtual'],
|
||||
|
||||
radix: [
|
||||
'@radix-ui/react-dialog',
|
||||
'@radix-ui/react-select',
|
||||
'@radix-ui/react-checkbox',
|
||||
'@radix-ui/react-label',
|
||||
'@radix-ui/react-slot',
|
||||
'@radix-ui/react-toast',
|
||||
'@radix-ui/react-tooltip',
|
||||
'@radix-ui/react-alert-dialog',
|
||||
'@radix-ui/react-avatar',
|
||||
'@radix-ui/react-collapsible',
|
||||
'@radix-ui/react-context-menu',
|
||||
'@radix-ui/react-popover',
|
||||
'@radix-ui/react-progress',
|
||||
'@radix-ui/react-scroll-area',
|
||||
'@radix-ui/react-separator',
|
||||
'@radix-ui/react-slider',
|
||||
'@radix-ui/react-switch',
|
||||
'@radix-ui/react-tabs',
|
||||
],
|
||||
|
||||
icons: ['lucide-react'],
|
||||
|
||||
charts: ['recharts'],
|
||||
|
||||
codemirror: [
|
||||
'@uiw/react-codemirror',
|
||||
'@codemirror/lang-javascript',
|
||||
'@codemirror/lang-json',
|
||||
'@codemirror/lang-python',
|
||||
'@codemirror/lint',
|
||||
'@codemirror/theme-one-dark',
|
||||
],
|
||||
|
||||
reactflow: ['reactflow', 'dagre'],
|
||||
|
||||
markdown: [
|
||||
'react-markdown',
|
||||
'remark-gfm',
|
||||
'remark-math',
|
||||
'rehype-katex',
|
||||
'katex',
|
||||
],
|
||||
|
||||
uppy: [
|
||||
'@uppy/core',
|
||||
'@uppy/dashboard',
|
||||
'@uppy/react',
|
||||
'@uppy/xhr-upload',
|
||||
],
|
||||
|
||||
dnd: ['@dnd-kit/core', '@dnd-kit/sortable', '@dnd-kit/utilities'],
|
||||
|
||||
utils: [
|
||||
'date-fns',
|
||||
'clsx',
|
||||
'tailwind-merge',
|
||||
'class-variance-authority',
|
||||
'axios',
|
||||
],
|
||||
|
||||
misc: ['react-joyride', 'react-day-picker', 'cmdk'],
|
||||
},
|
||||
},
|
||||
},
|
||||
chunkSizeWarningLimit: 500,
|
||||
},
|
||||
},
|
||||
})
|
||||
203
dashboard/electron/main/index.ts
Normal file
203
dashboard/electron/main/index.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import { app, BrowserWindow, ipcMain, protocol, session } from 'electron'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
import { registerAppProtocol } from './protocol'
|
||||
import {
|
||||
addBackend,
|
||||
getActiveBackend,
|
||||
getBackends,
|
||||
getWindowBounds,
|
||||
isFirstLaunch,
|
||||
markFirstLaunchComplete,
|
||||
removeBackend,
|
||||
setActiveBackend,
|
||||
setWindowBounds,
|
||||
updateBackend,
|
||||
} from './store'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
|
||||
/**
|
||||
* Register app:// custom protocol BEFORE app.whenReady()
|
||||
* This is critical for electron-vite to work correctly
|
||||
*/
|
||||
function registerAppScheme() {
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{
|
||||
scheme: 'app',
|
||||
privileges: {
|
||||
corsEnabled: true,
|
||||
secure: true,
|
||||
allowServiceWorkers: true,
|
||||
standard: true,
|
||||
supportFetchAPI: true,
|
||||
stream: true,
|
||||
},
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
* Register all IPC handlers for window control and store CRUD
|
||||
*/
|
||||
function registerIpcHandlers() {
|
||||
// ── Window control ───────────────────────────────────────────────────────
|
||||
ipcMain.handle('electron:minimize-window', () => mainWindow?.minimize())
|
||||
ipcMain.handle('electron:maximize-window', () => {
|
||||
if (mainWindow?.isMaximized()) mainWindow.unmaximize()
|
||||
else mainWindow?.maximize()
|
||||
})
|
||||
ipcMain.handle('electron:close-window', () => mainWindow?.close())
|
||||
ipcMain.handle('electron:is-maximized', () => mainWindow?.isMaximized() ?? false)
|
||||
|
||||
// ── Backend CRUD ─────────────────────────────────────────────────────────
|
||||
ipcMain.handle('electron:get-backends', () => getBackends())
|
||||
ipcMain.handle('electron:add-backend', (_e, conn) => addBackend(conn))
|
||||
ipcMain.handle('electron:update-backend', (_e, id, patch) => updateBackend(id, patch))
|
||||
ipcMain.handle('electron:remove-backend', (_e, id) => removeBackend(id))
|
||||
ipcMain.handle('electron:set-active-backend', (_e, id) => {
|
||||
setActiveBackend(id)
|
||||
const backend = getActiveBackend()
|
||||
mainWindow?.webContents.send('electron:backend-changed', backend)
|
||||
})
|
||||
ipcMain.handle('electron:get-active-backend', () => getActiveBackend())
|
||||
ipcMain.handle('electron:get-active-url', () => getActiveBackend()?.url ?? null)
|
||||
|
||||
// ── App state ────────────────────────────────────────────────────────────
|
||||
ipcMain.handle('electron:is-first-launch', () => isFirstLaunch())
|
||||
ipcMain.handle('electron:mark-first-launch-complete', () => markFirstLaunchComplete())
|
||||
ipcMain.handle('electron:get-app-version', () => app.getVersion())
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the main application window
|
||||
*/
|
||||
function createWindow() {
|
||||
const isMac = process.platform === 'darwin'
|
||||
|
||||
// Restore window bounds from store
|
||||
const bounds = getWindowBounds()
|
||||
|
||||
mainWindow = new BrowserWindow({
|
||||
x: bounds.x,
|
||||
y: bounds.y,
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
minWidth: 800,
|
||||
minHeight: 600,
|
||||
// macOS: hide native title bar but keep traffic light buttons
|
||||
...(isMac
|
||||
? {
|
||||
titleBarStyle: 'hidden' as const,
|
||||
trafficLightPosition: { x: 12, y: 8 },
|
||||
}
|
||||
: {}),
|
||||
// Windows/Linux: overlay title bar (custom title bar integrated)
|
||||
...(!isMac
|
||||
? {
|
||||
titleBarOverlay: {
|
||||
color: '#00000000',
|
||||
symbolColor: '#ffffff',
|
||||
height: 32,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, '../preload/index.js'),
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
sandbox: true,
|
||||
},
|
||||
})
|
||||
|
||||
// Load the app using app:// protocol
|
||||
// electron-vite will handle serving the renderer from app://host/index.html
|
||||
if (process.env.ELECTRON_RENDERER_URL) {
|
||||
// Development: load from electron-vite dev server
|
||||
mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL)
|
||||
} else {
|
||||
// Production: load from bundled renderer
|
||||
mainWindow.loadURL('app://host/index.html')
|
||||
}
|
||||
|
||||
// Persist window size/position on close
|
||||
mainWindow.on('close', () => {
|
||||
if (mainWindow) {
|
||||
const { x, y, width, height } = mainWindow.getBounds()
|
||||
setWindowBounds({ x, y, width, height })
|
||||
}
|
||||
})
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
mainWindow = null
|
||||
})
|
||||
|
||||
// Push maximize/unmaximize events to renderer
|
||||
mainWindow.on('maximize', () => {
|
||||
mainWindow?.webContents.send('electron:window-maximized')
|
||||
})
|
||||
mainWindow.on('unmaximize', () => {
|
||||
mainWindow?.webContents.send('electron:window-unmaximized')
|
||||
})
|
||||
|
||||
// 窗口获得焦点时确保焦点传递到 webContents,支持屏幕阅读器正确工作
|
||||
mainWindow.on('focus', () => {
|
||||
mainWindow?.webContents.focus()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* App event: when app is ready
|
||||
*/
|
||||
app.whenReady().then(() => {
|
||||
// 确保 Chromium a11y tree 始终激活(供屏幕阅读器使用)
|
||||
app.setAccessibilitySupportEnabled(true)
|
||||
|
||||
registerAppProtocol()
|
||||
|
||||
// Set Content Security Policy
|
||||
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
|
||||
callback({
|
||||
responseHeaders: {
|
||||
...details.responseHeaders,
|
||||
'Content-Security-Policy': [
|
||||
"default-src 'self' app:; " +
|
||||
"script-src 'self' 'unsafe-inline' app:; " +
|
||||
"style-src 'self' 'unsafe-inline' app:; " +
|
||||
"img-src 'self' app: data: blob:; " +
|
||||
"font-src 'self' app: data:; " +
|
||||
"connect-src 'self' app: ws: wss: http: https:; " +
|
||||
"worker-src 'self' blob:;"
|
||||
],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
registerIpcHandlers()
|
||||
createWindow()
|
||||
})
|
||||
|
||||
/**
|
||||
* App event: when all windows are closed (non-macOS behavior)
|
||||
*/
|
||||
app.on('window-all-closed', () => {
|
||||
// On macOS, applications typically stay open until the user quits
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit()
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* App event: when app is activated (macOS)
|
||||
*/
|
||||
app.on('activate', () => {
|
||||
if (mainWindow === null) {
|
||||
createWindow()
|
||||
}
|
||||
})
|
||||
|
||||
registerAppScheme()
|
||||
89
dashboard/electron/main/protocol.ts
Normal file
89
dashboard/electron/main/protocol.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { net, protocol } from 'electron'
|
||||
import { readFile } from 'fs/promises'
|
||||
import { dirname, extname, join } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
import { getActiveBackend } from './store'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = dirname(__filename)
|
||||
|
||||
const MIME_TYPES: Record<string, string> = {
|
||||
'.html': 'text/html',
|
||||
'.js': 'application/javascript',
|
||||
'.mjs': 'application/javascript',
|
||||
'.cjs': 'application/javascript',
|
||||
'.css': 'text/css',
|
||||
'.json': 'application/json',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.ico': 'image/x-icon',
|
||||
'.woff': 'font/woff',
|
||||
'.woff2': 'font/woff2',
|
||||
'.ttf': 'font/ttf',
|
||||
'.txt': 'text/plain',
|
||||
'.webp': 'image/webp',
|
||||
}
|
||||
|
||||
export function registerAppProtocol(): void {
|
||||
protocol.handle('app', async (request) => {
|
||||
const url = new URL(request.url)
|
||||
const pathname = url.pathname
|
||||
|
||||
if (pathname.startsWith('/api/')) {
|
||||
const backend = getActiveBackend()
|
||||
const targetUrl = backend
|
||||
? `${backend.url.replace(/\/$/, '')}${pathname}${url.search}`
|
||||
: null
|
||||
|
||||
if (!targetUrl) {
|
||||
return new Response(JSON.stringify({ error: 'No backend configured' }), {
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
const headers = new Headers(request.headers)
|
||||
headers.delete('host')
|
||||
|
||||
return net.fetch(targetUrl, {
|
||||
method: request.method,
|
||||
headers,
|
||||
body: ['GET', 'HEAD'].includes(request.method) ? undefined : request.body,
|
||||
duplex: 'half',
|
||||
})
|
||||
}
|
||||
|
||||
// Dev mode: renderer is served by vite dev server, not app:// protocol
|
||||
if (process.env.ELECTRON_RENDERER_URL) {
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
|
||||
const rendererDir = join(__dirname, '../renderer')
|
||||
const safePath = decodeURIComponent(pathname)
|
||||
.replace(/\.\./g, '')
|
||||
.replace(/^\/+/, '')
|
||||
|
||||
const resolvedPath = safePath === '' ? 'index.html' : safePath
|
||||
const filePath = resolvedPath.endsWith('/')
|
||||
? join(rendererDir, resolvedPath, 'index.html')
|
||||
: join(rendererDir, resolvedPath)
|
||||
|
||||
const tryReadFile = async (path: string) => {
|
||||
const ext = extname(path)
|
||||
const mimeType = MIME_TYPES[ext] ?? 'application/octet-stream'
|
||||
const data = await readFile(path)
|
||||
return new Response(data, { headers: { 'Content-Type': mimeType } })
|
||||
}
|
||||
|
||||
try {
|
||||
return await tryReadFile(filePath)
|
||||
} catch {
|
||||
const indexPath = join(rendererDir, 'index.html')
|
||||
return tryReadFile(indexPath)
|
||||
}
|
||||
})
|
||||
}
|
||||
215
dashboard/electron/main/store.ts
Normal file
215
dashboard/electron/main/store.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
|
||||
import Store, { type Schema } from 'electron-store'
|
||||
|
||||
/**
|
||||
* Backend connection data model
|
||||
*/
|
||||
export interface BackendConnection {
|
||||
id: string
|
||||
name: string
|
||||
url: string
|
||||
isDefault: boolean
|
||||
lastConnected?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Application settings data model
|
||||
*/
|
||||
export interface AppSettings {
|
||||
backends: BackendConnection[]
|
||||
activeBackendId: string | null
|
||||
windowBounds: {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
firstLaunchComplete: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON Schema for validating store contents
|
||||
*/
|
||||
const SCHEMA: Schema<AppSettings> = {
|
||||
backends: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
name: { type: 'string' },
|
||||
url: { type: 'string' },
|
||||
isDefault: { type: 'boolean' },
|
||||
lastConnected: { type: 'number' },
|
||||
},
|
||||
required: ['id', 'name', 'url', 'isDefault'],
|
||||
},
|
||||
},
|
||||
activeBackendId: { type: ['string', 'null'] },
|
||||
windowBounds: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
x: { type: 'number' },
|
||||
y: { type: 'number' },
|
||||
width: { type: 'number' },
|
||||
height: { type: 'number' },
|
||||
},
|
||||
required: ['x', 'y', 'width', 'height'],
|
||||
},
|
||||
firstLaunchComplete: { type: 'boolean' },
|
||||
}
|
||||
|
||||
/**
|
||||
* Default settings
|
||||
*/
|
||||
const DEFAULTS: AppSettings = {
|
||||
backends: [],
|
||||
activeBackendId: null,
|
||||
windowBounds: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 1280,
|
||||
height: 800,
|
||||
},
|
||||
firstLaunchComplete: false,
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize electron-store with encryption and schema validation
|
||||
*/
|
||||
const store = new Store<AppSettings>({
|
||||
schema: SCHEMA,
|
||||
defaults: DEFAULTS,
|
||||
encryptionKey: process.env.MAIBOT_STORE_KEY,
|
||||
})
|
||||
|
||||
/**
|
||||
* Get all backends
|
||||
*/
|
||||
export function getBackends(): BackendConnection[] {
|
||||
return store.get('backends', [])
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new backend connection
|
||||
* Generates UUID for new backend
|
||||
*/
|
||||
export function addBackend(
|
||||
conn: Omit<BackendConnection, 'id'>,
|
||||
): BackendConnection {
|
||||
const newBackend: BackendConnection = {
|
||||
...conn,
|
||||
id: randomUUID(),
|
||||
}
|
||||
|
||||
const backends = getBackends()
|
||||
backends.push(newBackend)
|
||||
store.set('backends', backends)
|
||||
|
||||
return newBackend
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing backend connection
|
||||
*/
|
||||
export function updateBackend(
|
||||
id: string,
|
||||
patch: Partial<Omit<BackendConnection, 'id'>>,
|
||||
): void {
|
||||
const backends = getBackends()
|
||||
const index = backends.findIndex((b) => b.id === id)
|
||||
|
||||
if (index === -1) {
|
||||
throw new Error(`Backend with id ${id} not found`)
|
||||
}
|
||||
|
||||
backends[index] = {
|
||||
...backends[index],
|
||||
...patch,
|
||||
}
|
||||
|
||||
store.set('backends', backends)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a backend connection by id
|
||||
*/
|
||||
export function removeBackend(id: string): void {
|
||||
const backends = getBackends()
|
||||
const filtered = backends.filter((b) => b.id !== id)
|
||||
|
||||
store.set('backends', filtered)
|
||||
|
||||
// Clear active backend if it was the removed one
|
||||
if (store.get('activeBackendId') === id) {
|
||||
store.set('activeBackendId', null)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the active backend
|
||||
*/
|
||||
export function setActiveBackend(id: string): void {
|
||||
const backends = getBackends()
|
||||
|
||||
if (!backends.find((b) => b.id === id)) {
|
||||
throw new Error(`Backend with id ${id} not found`)
|
||||
}
|
||||
|
||||
store.set('activeBackendId', id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the currently active backend connection
|
||||
*/
|
||||
export function getActiveBackend(): BackendConnection | null {
|
||||
const activeId = store.get('activeBackendId')
|
||||
|
||||
if (!activeId) {
|
||||
return null
|
||||
}
|
||||
|
||||
const backends = getBackends()
|
||||
return backends.find((b) => b.id === activeId) || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get window bounds
|
||||
*/
|
||||
export function getWindowBounds(): AppSettings['windowBounds'] {
|
||||
return store.get('windowBounds', DEFAULTS.windowBounds)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set window bounds
|
||||
*/
|
||||
export function setWindowBounds(bounds: AppSettings['windowBounds']): void {
|
||||
store.set('windowBounds', bounds)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is the first launch
|
||||
*/
|
||||
export function isFirstLaunch(): boolean {
|
||||
return !store.get('firstLaunchComplete', false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark first launch as complete
|
||||
*/
|
||||
export function markFirstLaunchComplete(): void {
|
||||
store.set('firstLaunchComplete', true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get complete app settings
|
||||
*/
|
||||
export function getSettings(): AppSettings {
|
||||
return {
|
||||
backends: getBackends(),
|
||||
activeBackendId: store.get('activeBackendId', null),
|
||||
windowBounds: getWindowBounds(),
|
||||
firstLaunchComplete: store.get('firstLaunchComplete', false),
|
||||
}
|
||||
}
|
||||
56
dashboard/electron/preload/index.ts
Normal file
56
dashboard/electron/preload/index.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
|
||||
// Write __RUNTIME__ tag into the isolated world so renderer can detect Electron
|
||||
contextBridge.exposeInMainWorld('__RUNTIME__', {
|
||||
kind: 'electron' as const,
|
||||
versions: process.versions as unknown as Record<string, string>,
|
||||
source: 'tag' as const,
|
||||
})
|
||||
|
||||
// Expose the full ElectronAPI surface to the renderer process
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
// ── Platform detection ──────────────────────────────────────────────────
|
||||
getPlatform: () => process.platform,
|
||||
|
||||
// ── Window control ──────────────────────────────────────────────────────
|
||||
minimizeWindow: () => ipcRenderer.invoke('electron:minimize-window'),
|
||||
maximizeWindow: () => ipcRenderer.invoke('electron:maximize-window'),
|
||||
closeWindow: () => ipcRenderer.invoke('electron:close-window'),
|
||||
isMaximized: () => ipcRenderer.invoke('electron:is-maximized'),
|
||||
|
||||
// ── Window event listeners ───────────────────────────────────────────────
|
||||
onWindowMaximized: (callback: () => void) => {
|
||||
const listener = () => callback()
|
||||
ipcRenderer.on('electron:window-maximized', listener)
|
||||
return () => ipcRenderer.removeListener('electron:window-maximized', listener)
|
||||
},
|
||||
onWindowUnmaximized: (callback: () => void) => {
|
||||
const listener = () => callback()
|
||||
ipcRenderer.on('electron:window-unmaximized', listener)
|
||||
return () => ipcRenderer.removeListener('electron:window-unmaximized', listener)
|
||||
},
|
||||
|
||||
// ── Backend CRUD ─────────────────────────────────────────────────────────
|
||||
getBackends: () => ipcRenderer.invoke('electron:get-backends'),
|
||||
addBackend: (conn: object) => ipcRenderer.invoke('electron:add-backend', conn),
|
||||
updateBackend: (id: string, patch: object) =>
|
||||
ipcRenderer.invoke('electron:update-backend', id, patch),
|
||||
removeBackend: (id: string) => ipcRenderer.invoke('electron:remove-backend', id),
|
||||
setActiveBackend: (id: string) =>
|
||||
ipcRenderer.invoke('electron:set-active-backend', id),
|
||||
getActiveBackend: () => ipcRenderer.invoke('electron:get-active-backend'),
|
||||
getActiveBackendUrl: () => ipcRenderer.invoke('electron:get-active-url'),
|
||||
|
||||
// ── App state ───────────────────────────────────────────────────────────
|
||||
isFirstLaunch: () => ipcRenderer.invoke('electron:is-first-launch'),
|
||||
markFirstLaunchComplete: () =>
|
||||
ipcRenderer.invoke('electron:mark-first-launch-complete'),
|
||||
getAppVersion: () => ipcRenderer.invoke('electron:get-app-version'),
|
||||
|
||||
// ── Backend event listener ──────────────────────────────────────────────
|
||||
onBackendChanged: (callback: (backend: { id: string; name: string; url: string; isDefault: boolean; lastConnected?: number } | null) => void) => {
|
||||
const listener = (_event: unknown, backend: { id: string; name: string; url: string; isDefault: boolean; lastConnected?: number } | null) => callback(backend)
|
||||
ipcRenderer.on('electron:backend-changed', listener)
|
||||
return () => ipcRenderer.removeListener('electron:backend-changed', listener)
|
||||
},
|
||||
})
|
||||
0
dashboard/electron/resources/.gitkeep
Normal file
0
dashboard/electron/resources/.gitkeep
Normal file
44
dashboard/eslint.config.js
Normal file
44
dashboard/eslint.config.js
Normal file
@@ -0,0 +1,44 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import jsxA11y from 'eslint-plugin-jsx-a11y'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist', 'out'] },
|
||||
jsxA11y.flatConfigs.recommended,
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
// 将所有 React Hooks 推荐规则降级为警告
|
||||
...Object.keys(reactHooks.configs.recommended.rules).reduce((acc, key) => {
|
||||
acc[key] = 'warn'
|
||||
return acc
|
||||
}, {}),
|
||||
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
|
||||
// 关闭或降级其他规则
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
'@typescript-eslint/no-unused-vars': 'warn',
|
||||
// jsx-a11y: 降级为 warn 避免阻塞构建,后续 Task 17 逐步修复
|
||||
'jsx-a11y/anchor-ambiguous-text': 'warn',
|
||||
'jsx-a11y/no-autofocus': 'warn',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.d.ts'],
|
||||
rules: {
|
||||
// Ambient global declarations use `var` in TypeScript declaration files.
|
||||
'no-var': 'off',
|
||||
},
|
||||
}
|
||||
)
|
||||
30
dashboard/index.html
Normal file
30
dashboard/index.html
Normal file
@@ -0,0 +1,30 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN" translate="no">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="google" content="notranslate" />
|
||||
<meta http-equiv="content-language" content="zh-CN" />
|
||||
<!-- 防止搜索引擎索引 -->
|
||||
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet" />
|
||||
<meta name="googlebot" content="noindex, nofollow" />
|
||||
<meta name="bingbot" content="noindex, nofollow" />
|
||||
<link rel="icon" type="image/x-icon" href="/maimai.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<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>
|
||||
<body>
|
||||
<div id="root" class="notranslate"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
17778
dashboard/package-lock.json
generated
Normal file
17778
dashboard/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
192
dashboard/package.json
Normal file
192
dashboard/package.json
Normal file
@@ -0,0 +1,192 @@
|
||||
{
|
||||
"name": "maibot-dashboard",
|
||||
"private": true,
|
||||
"version": "1.0.10",
|
||||
"type": "module",
|
||||
"main": "./out/main/index.js",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"format": "prettier --write \"src/**/*.{ts,tsx,css}\"",
|
||||
"test": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"electron:dev": "electron-vite dev",
|
||||
"electron:build": "electron-vite build",
|
||||
"electron:preview": "electron-vite preview",
|
||||
"electron:dist": "electron-vite build && electron-builder",
|
||||
"electron:dist:mac": "electron-vite build && electron-builder --mac",
|
||||
"electron:dist:win": "electron-vite build && electron-builder --win",
|
||||
"electron:dist:linux": "electron-vite build && electron-builder --linux"
|
||||
},
|
||||
"build": {
|
||||
"appId": "org.maibot.dashboard",
|
||||
"productName": "MaiBot Dashboard",
|
||||
"directories": {
|
||||
"output": "dist-electron",
|
||||
"buildResources": "electron/resources"
|
||||
},
|
||||
"files": [
|
||||
"out/**/*",
|
||||
"package.json"
|
||||
],
|
||||
"mac": {
|
||||
"category": "public.app-category.utilities",
|
||||
"target": [
|
||||
{
|
||||
"target": "dmg",
|
||||
"arch": [
|
||||
"x64",
|
||||
"arm64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"icon": "electron/resources/icon.icns",
|
||||
"darkModeSupport": true
|
||||
},
|
||||
"win": {
|
||||
"target": [
|
||||
{
|
||||
"target": "nsis",
|
||||
"arch": [
|
||||
"x64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"icon": "electron/resources/icon.ico"
|
||||
},
|
||||
"linux": {
|
||||
"target": [
|
||||
{
|
||||
"target": "AppImage",
|
||||
"arch": [
|
||||
"x64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"icon": "electron/resources/icon.png",
|
||||
"category": "Utility"
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"createDesktopShortcut": true,
|
||||
"createStartMenuShortcut": true
|
||||
},
|
||||
"dmg": {
|
||||
"contents": [
|
||||
{
|
||||
"x": 130,
|
||||
"y": 220
|
||||
},
|
||||
{
|
||||
"x": 410,
|
||||
"y": 220,
|
||||
"type": "link",
|
||||
"path": "/Applications"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/lang-javascript": "^6.2.4",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@codemirror/lang-python": "^6.2.1",
|
||||
"@codemirror/legacy-modes": "^6.5.2",
|
||||
"@codemirror/lint": "^6.9.2",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toast": "^1.2.15",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@react-spring/web": "10.0.3",
|
||||
"@tanstack/react-router": "^1.140.0",
|
||||
"@tanstack/react-virtual": "^3.13.13",
|
||||
"@tanstack/router-devtools": "^1.140.0",
|
||||
"@types/dagre": "^0.7.53",
|
||||
"@uiw/react-codemirror": "^4.25.3",
|
||||
"@uppy/core": "^5.2.0",
|
||||
"@uppy/dashboard": "^5.1.0",
|
||||
"@uppy/react": "^5.1.1",
|
||||
"@uppy/xhr-upload": "^5.1.1",
|
||||
"@use-gesture/react": "^10.3.1",
|
||||
"axios": "^1.13.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"dagre": "^0.8.5",
|
||||
"date-fns": "^4.1.0",
|
||||
"html-to-image": "^1.11.13",
|
||||
"i18next": "^25.8.13",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"idb": "^8.0.3",
|
||||
"katex": "^0.16.27",
|
||||
"lucide-react": "^0.556.0",
|
||||
"motion": "^12.38.0",
|
||||
"react": "^19.2.1",
|
||||
"react-day-picker": "^9.12.0",
|
||||
"react-dom": "^19.2.1",
|
||||
"react-i18next": "^16.5.4",
|
||||
"react-joyride": "3.0.0-7",
|
||||
"react-markdown": "^10.1.0",
|
||||
"reactflow": "^11.11.4",
|
||||
"recharts": "3.5.1",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-math": "^6.0.0",
|
||||
"smol-toml": "^1.5.2",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.2.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/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
"@vitest/ui": "^4.0.18",
|
||||
"electron": "^40.6.1",
|
||||
"electron-builder": "^26.8.1",
|
||||
"electron-store": "11.0.2",
|
||||
"electron-vite": "^5.0.0",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"jsdom": "^28.1.0",
|
||||
"prettier": "^3.7.4",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.49.0",
|
||||
"vite": "^7.2.7",
|
||||
"vitest": "^4.0.18",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2"
|
||||
}
|
||||
}
|
||||
BIN
dashboard/public/fonts/JetBrainsMono-Medium.ttf
Normal file
BIN
dashboard/public/fonts/JetBrainsMono-Medium.ttf
Normal file
Binary file not shown.
BIN
dashboard/public/maimai.ico
Normal file
BIN
dashboard/public/maimai.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 116 KiB |
406
dashboard/scripts/a_memorix_electron_validate.cjs
Normal file
406
dashboard/scripts/a_memorix_electron_validate.cjs
Normal file
@@ -0,0 +1,406 @@
|
||||
const { app, BrowserWindow } = require('electron')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const DASHBOARD_URL = process.env.MAIBOT_DASHBOARD_URL || 'http://127.0.0.1:7999'
|
||||
const OUTPUT_DIR = process.env.MAIBOT_UI_SNAPSHOT_DIR
|
||||
|| path.resolve(__dirname, '..', '..', 'tmp', 'ui-snapshots', 'a_memorix-electron')
|
||||
const TOKEN_PATH = process.env.MAIBOT_WEBUI_TOKEN_PATH
|
||||
|| path.resolve(__dirname, '..', '..', 'data', 'webui.json')
|
||||
const sampleStamp = String(Date.now())
|
||||
const sampleSource = process.env.MAIBOT_UI_SAMPLE_SOURCE || `webui-demo:a_memorix-json-${sampleStamp}`
|
||||
const sampleName = process.env.MAIBOT_UI_SAMPLE_NAME || `webui-json-validation-${sampleStamp}.json`
|
||||
|
||||
const DEFAULT_SAMPLE = {
|
||||
paragraphs: [
|
||||
{
|
||||
content: 'Alice 在杭州西湖与 Bob 讨论 A_Memorix 的前端接入与 embedding 调优方案。',
|
||||
source: sampleSource,
|
||||
entities: ['Alice', 'Bob', '杭州西湖', 'A_Memorix'],
|
||||
relations: [
|
||||
{ subject: 'Alice', predicate: '在', object: '杭州西湖' },
|
||||
{ subject: 'Alice', predicate: '讨论', object: 'A_Memorix' },
|
||||
{ subject: 'Bob', predicate: '讨论', object: 'A_Memorix' },
|
||||
{ subject: 'Bob', predicate: '负责', object: 'embedding 调优' },
|
||||
],
|
||||
knowledge_type: 'factual',
|
||||
},
|
||||
],
|
||||
entities: ['Alice', 'Bob', '杭州西湖', 'A_Memorix', 'embedding 调优'],
|
||||
relations: [{ subject: 'Alice', predicate: '认识', object: 'Bob' }],
|
||||
}
|
||||
|
||||
function loadSampleJson() {
|
||||
const customPath = String(process.env.MAIBOT_UI_IMPORT_JSON_PATH || '').trim()
|
||||
if (!customPath) {
|
||||
return JSON.stringify(DEFAULT_SAMPLE, null, 2)
|
||||
}
|
||||
return fs.readFileSync(customPath, 'utf8')
|
||||
}
|
||||
|
||||
const sampleJson = loadSampleJson()
|
||||
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true })
|
||||
|
||||
function wait(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
async function exec(win, code) {
|
||||
return win.webContents.executeJavaScript(code, true)
|
||||
}
|
||||
|
||||
async function waitFor(win, predicateCode, label, timeout = 30000, interval = 300) {
|
||||
const start = Date.now()
|
||||
while (Date.now() - start < timeout) {
|
||||
try {
|
||||
const ok = await exec(win, predicateCode)
|
||||
if (ok) {
|
||||
return ok
|
||||
}
|
||||
} catch {
|
||||
// keep polling
|
||||
}
|
||||
await wait(interval)
|
||||
}
|
||||
throw new Error(`Timeout waiting for ${label}`)
|
||||
}
|
||||
|
||||
async function sendClick(win, x, y) {
|
||||
win.webContents.sendInputEvent({ type: 'mouseMove', x, y, movementX: 0, movementY: 0 })
|
||||
win.webContents.sendInputEvent({ type: 'mouseDown', x, y, button: 'left', clickCount: 1 })
|
||||
win.webContents.sendInputEvent({ type: 'mouseUp', x, y, button: 'left', clickCount: 1 })
|
||||
}
|
||||
|
||||
async function capture(win, name) {
|
||||
const image = await win.webContents.capturePage()
|
||||
fs.writeFileSync(path.join(OUTPUT_DIR, name), image.toPNG())
|
||||
const text = await exec(win, 'document.body ? document.body.innerText : ""')
|
||||
fs.writeFileSync(path.join(OUTPUT_DIR, name.replace(/\.png$/, '.txt')), text || '')
|
||||
}
|
||||
|
||||
async function getJson(win, relativePath) {
|
||||
return exec(
|
||||
win,
|
||||
`fetch(${JSON.stringify(relativePath)}, { credentials: 'include' }).then((r) => r.json())`,
|
||||
)
|
||||
}
|
||||
|
||||
async function setSessionCookie(win) {
|
||||
const raw = fs.readFileSync(TOKEN_PATH, 'utf8')
|
||||
const config = JSON.parse(raw)
|
||||
const token = String(config.access_token || '').trim()
|
||||
if (!token) {
|
||||
throw new Error(`No access token found in ${TOKEN_PATH}`)
|
||||
}
|
||||
const payload = await exec(
|
||||
win,
|
||||
`fetch('/api/webui/auth/verify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ token: ${JSON.stringify(token)} }),
|
||||
}).then(async (response) => ({
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
body: await response.json(),
|
||||
}))`,
|
||||
)
|
||||
if (!payload?.ok || !payload?.body?.valid) {
|
||||
throw new Error(`Failed to authenticate WebUI token via /auth/verify: ${JSON.stringify(payload)}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function openImportTab(win) {
|
||||
await exec(win, `(() => {
|
||||
const tab = Array.from(document.querySelectorAll('[role="tab"]')).find((el) => (el.textContent || '').trim() === '导入')
|
||||
if (!tab) return false
|
||||
tab.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true, pointerId: 1, button: 0, pointerType: 'mouse', isPrimary: true }))
|
||||
tab.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, button: 0 }))
|
||||
tab.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true, button: 0 }))
|
||||
tab.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, button: 0 }))
|
||||
return true
|
||||
})()`)
|
||||
await waitFor(
|
||||
win,
|
||||
`document.body && document.body.innerText.includes('粘贴导入') && document.body.innerText.includes('创建导入任务')`,
|
||||
'import panel',
|
||||
)
|
||||
}
|
||||
|
||||
async function setJsonMode(win) {
|
||||
const trigger = await exec(win, `(() => {
|
||||
const label = Array.from(document.querySelectorAll('label')).find((node) => (node.textContent || '').includes('输入模式'))
|
||||
const root = label?.closest('div')?.parentElement || label?.parentElement
|
||||
const button = root?.querySelector('button')
|
||||
if (!button) return null
|
||||
const rect = button.getBoundingClientRect()
|
||||
return { x: Math.round(rect.left + rect.width / 2), y: Math.round(rect.top + rect.height / 2) }
|
||||
})()`)
|
||||
if (!trigger) {
|
||||
throw new Error('select trigger not found')
|
||||
}
|
||||
await sendClick(win, trigger.x, trigger.y)
|
||||
await waitFor(win, `document.querySelectorAll('[role="option"]').length > 0`, 'select options', 5000, 200)
|
||||
|
||||
const option = await exec(win, `(() => {
|
||||
const item = Array.from(document.querySelectorAll('[role="option"]')).find((el) => (el.textContent || '').trim() === 'json')
|
||||
if (!item) return null
|
||||
const rect = item.getBoundingClientRect()
|
||||
return { x: Math.round(rect.left + rect.width / 2), y: Math.round(rect.top + rect.height / 2) }
|
||||
})()`)
|
||||
if (!option) {
|
||||
throw new Error('json option not found')
|
||||
}
|
||||
await sendClick(win, option.x, option.y)
|
||||
await waitFor(
|
||||
win,
|
||||
`(() => {
|
||||
const label = Array.from(document.querySelectorAll('label')).find((node) => (node.textContent || '').includes('输入模式'))
|
||||
const root = label?.closest('div')?.parentElement || label?.parentElement
|
||||
const button = root?.querySelector('button')
|
||||
return (button?.textContent || '').trim() === 'json'
|
||||
})()`,
|
||||
'json mode selected',
|
||||
8000,
|
||||
300,
|
||||
)
|
||||
}
|
||||
|
||||
async function typeIntoLabeled(win, labelText, selector, text) {
|
||||
const rect = await exec(win, `(() => {
|
||||
const label = Array.from(document.querySelectorAll('label')).find((node) => (node.textContent || '').includes(${JSON.stringify(labelText)}))
|
||||
const root = label?.closest('div')?.parentElement || label?.parentElement
|
||||
const el = root?.querySelector(${JSON.stringify(selector)})
|
||||
if (!el) return null
|
||||
const r = el.getBoundingClientRect()
|
||||
return { x: Math.round(r.left + 20), y: Math.round(r.top + 20) }
|
||||
})()`)
|
||||
if (!rect) {
|
||||
throw new Error(`field not found: ${labelText}`)
|
||||
}
|
||||
await sendClick(win, rect.x, rect.y)
|
||||
await wait(150)
|
||||
await win.webContents.insertText(text)
|
||||
await wait(250)
|
||||
}
|
||||
|
||||
async function clickButton(win, text) {
|
||||
const ok = await exec(win, `(() => {
|
||||
const target = Array.from(document.querySelectorAll('button')).find((el) => (el.textContent || '').includes(${JSON.stringify(text)}))
|
||||
if (!target) return false
|
||||
target.scrollIntoView({ block: 'center' })
|
||||
target.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true, pointerId: 1, button: 0, pointerType: 'mouse', isPrimary: true }))
|
||||
target.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, button: 0 }))
|
||||
target.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true, button: 0 }))
|
||||
target.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, button: 0 }))
|
||||
return true
|
||||
})()`)
|
||||
if (!ok) {
|
||||
throw new Error(`button not found: ${text}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function clickTab(win, text) {
|
||||
const ok = await exec(win, `(() => {
|
||||
const target = Array.from(document.querySelectorAll('[role="tab"]')).find((el) => (el.textContent || '').includes(${JSON.stringify(text)}))
|
||||
if (!target) return false
|
||||
target.scrollIntoView({ block: 'center' })
|
||||
target.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true, pointerId: 1, button: 0, pointerType: 'mouse', isPrimary: true }))
|
||||
target.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, button: 0 }))
|
||||
target.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true, button: 0 }))
|
||||
target.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, button: 0 }))
|
||||
return true
|
||||
})()`)
|
||||
if (!ok) {
|
||||
throw new Error(`tab not found: ${text}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function clickGraphElement(win, selector, index = 0) {
|
||||
const rect = await exec(win, `(() => {
|
||||
const targets = Array.from(document.querySelectorAll(${JSON.stringify(selector)}))
|
||||
const target = targets[${index}]
|
||||
if (!target) return null
|
||||
target.scrollIntoView({ block: 'center', inline: 'center' })
|
||||
const r = target.getBoundingClientRect()
|
||||
return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) }
|
||||
})()`)
|
||||
if (!rect) {
|
||||
throw new Error(`graph element not found: ${selector}[${index}]`)
|
||||
}
|
||||
await sendClick(win, rect.x, rect.y)
|
||||
}
|
||||
|
||||
async function capturePluginFilterState(win) {
|
||||
await win.loadURL(`${DASHBOARD_URL}/plugin-config`)
|
||||
await waitFor(
|
||||
win,
|
||||
`document.body && document.body.innerText.includes('插件配置') && document.querySelector('input[placeholder="搜索插件..."]')`,
|
||||
'plugin config page',
|
||||
30000,
|
||||
400,
|
||||
)
|
||||
await exec(win, `(() => {
|
||||
const input = document.querySelector('input[placeholder="搜索插件..."]')
|
||||
if (!input) return false
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set
|
||||
setter?.call(input, 'memorix')
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
return true
|
||||
})()`)
|
||||
await wait(500)
|
||||
await capture(win, '01-plugin-config-filtered.png')
|
||||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
const win = new BrowserWindow({
|
||||
width: 1600,
|
||||
height: 1200,
|
||||
show: false,
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
})
|
||||
|
||||
await win.loadURL(`${DASHBOARD_URL}/auth`)
|
||||
await waitFor(win, `document.readyState === 'complete'`, 'auth page')
|
||||
await capture(win, '00-auth-login.png')
|
||||
await setSessionCookie(win)
|
||||
|
||||
await capturePluginFilterState(win)
|
||||
|
||||
await win.loadURL(`${DASHBOARD_URL}/resource/knowledge-base`)
|
||||
await waitFor(
|
||||
win,
|
||||
`document.body && document.body.innerText.includes('运行时自检') && document.body.innerText.includes('刷新数据')`,
|
||||
'memory console ready',
|
||||
30000,
|
||||
500,
|
||||
)
|
||||
await capture(win, '02-memory-console-before-import.png')
|
||||
|
||||
const beforeGraph = await getJson(win, '/api/webui/memory/graph?limit=120')
|
||||
const beforeTasks = await getJson(win, '/api/webui/memory/import/tasks?limit=20')
|
||||
const knownTaskIds = new Set(
|
||||
Array.isArray(beforeTasks.items)
|
||||
? beforeTasks.items.map((item) => String(item.task_id || item.taskId || ''))
|
||||
: [],
|
||||
)
|
||||
|
||||
await openImportTab(win)
|
||||
await setJsonMode(win)
|
||||
await typeIntoLabeled(win, '名称', 'input', sampleName)
|
||||
await typeIntoLabeled(win, '粘贴内容', 'textarea', sampleJson)
|
||||
await capture(win, '03-memory-import-json-filled.png')
|
||||
|
||||
await clickButton(win, '创建导入任务')
|
||||
|
||||
let taskId = null
|
||||
let taskStatus = null
|
||||
const start = Date.now()
|
||||
while (Date.now() - start < 120000) {
|
||||
const payload = await getJson(win, '/api/webui/memory/import/tasks?limit=20')
|
||||
fs.writeFileSync(path.join(OUTPUT_DIR, 'tasks-last.json'), JSON.stringify(payload, null, 2))
|
||||
const items = Array.isArray(payload.items) ? payload.items : []
|
||||
const task = items.find((item) => !knownTaskIds.has(String(item.task_id || item.taskId || '')))
|
||||
if (task) {
|
||||
taskId = task.task_id || task.taskId || null
|
||||
taskStatus = task.status || null
|
||||
if (['completed', 'failed', 'cancelled'].includes(String(taskStatus))) {
|
||||
break
|
||||
}
|
||||
}
|
||||
await wait(1500)
|
||||
}
|
||||
|
||||
if (!taskId) {
|
||||
throw new Error('new json import task not observed')
|
||||
}
|
||||
|
||||
const detail = await getJson(
|
||||
win,
|
||||
`/api/webui/memory/import/tasks/${encodeURIComponent(taskId)}?include_chunks=true`,
|
||||
)
|
||||
fs.writeFileSync(path.join(OUTPUT_DIR, 'task-detail.json'), JSON.stringify(detail, null, 2))
|
||||
fs.writeFileSync(
|
||||
path.join(OUTPUT_DIR, 'task-status.txt'),
|
||||
`taskId=${taskId}\nstatus=${taskStatus}\nsource=${sampleSource}\n`,
|
||||
)
|
||||
|
||||
await clickButton(win, '刷新数据')
|
||||
await wait(2000)
|
||||
await capture(win, '04-memory-console-after-import.png')
|
||||
|
||||
await win.loadURL(`${DASHBOARD_URL}/resource/knowledge-graph`)
|
||||
await waitFor(
|
||||
win,
|
||||
`document.body && document.body.innerText.includes('长期记忆图谱') && document.body.innerText.includes('实体关系图') && document.body.innerText.includes('证据视图')`,
|
||||
'graph page ready',
|
||||
30000,
|
||||
400,
|
||||
)
|
||||
await wait(3000)
|
||||
const afterGraph = await getJson(win, '/api/webui/memory/graph?limit=120')
|
||||
fs.writeFileSync(path.join(OUTPUT_DIR, 'graph-after.json'), JSON.stringify(afterGraph, null, 2))
|
||||
await capture(win, '05-memory-graph-after-import.png')
|
||||
|
||||
if (Array.isArray(afterGraph.nodes) && afterGraph.nodes.length > 0) {
|
||||
await clickGraphElement(win, '.react-flow__node', 0)
|
||||
await waitFor(win, `document.body && document.body.innerText.includes('实体详情')`, 'node detail dialog', 10000, 250)
|
||||
await capture(win, '06-memory-node-detail.png')
|
||||
try {
|
||||
await clickButton(win, '切到证据视图')
|
||||
await waitFor(
|
||||
win,
|
||||
`document.body && document.body.innerText.includes('证据视图') && document.querySelectorAll('.react-flow__node').length > 0`,
|
||||
'evidence graph after node click',
|
||||
10000,
|
||||
250,
|
||||
)
|
||||
await capture(win, '07-memory-evidence-view.png')
|
||||
} catch (error) {
|
||||
fs.writeFileSync(path.join(OUTPUT_DIR, '07-memory-evidence-view-error.txt'), String(error?.stack || error))
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(afterGraph.edges) && afterGraph.edges.length > 0) {
|
||||
try {
|
||||
await clickTab(win, '实体关系图')
|
||||
await wait(800)
|
||||
await clickGraphElement(win, '.react-flow__edge', 0)
|
||||
await waitFor(win, `document.body && document.body.innerText.includes('关系详情')`, 'edge detail dialog', 10000, 250)
|
||||
await capture(win, '08-memory-edge-detail.png')
|
||||
} catch (error) {
|
||||
fs.writeFileSync(path.join(OUTPUT_DIR, '08-memory-edge-detail-error.txt'), String(error?.stack || error))
|
||||
}
|
||||
}
|
||||
|
||||
const summary = {
|
||||
before: {
|
||||
nodes: beforeGraph.total_nodes,
|
||||
edges: beforeGraph.total_edges,
|
||||
},
|
||||
after: {
|
||||
nodes: afterGraph.total_nodes,
|
||||
edges: afterGraph.total_edges,
|
||||
},
|
||||
taskId,
|
||||
taskStatus,
|
||||
source: sampleSource,
|
||||
inputMode: detail?.task?.files?.[0]?.input_mode || null,
|
||||
strategyType: detail?.task?.files?.[0]?.detected_strategy_type || null,
|
||||
fileStatus: detail?.task?.files?.[0]?.status || null,
|
||||
outputDir: OUTPUT_DIR,
|
||||
}
|
||||
fs.writeFileSync(path.join(OUTPUT_DIR, 'validation-summary.json'), JSON.stringify(summary, null, 2))
|
||||
console.log(JSON.stringify(summary, null, 2))
|
||||
|
||||
await win.close()
|
||||
app.quit()
|
||||
}).catch((error) => {
|
||||
console.error(error)
|
||||
app.exit(1)
|
||||
})
|
||||
BIN
dashboard/src/assets/maimai.ico
Normal file
BIN
dashboard/src/assets/maimai.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
1
dashboard/src/assets/react.svg
Normal file
1
dashboard/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
54
dashboard/src/components/CodeEditor.tsx
Normal file
54
dashboard/src/components/CodeEditor.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { lazy, Suspense } from 'react'
|
||||
|
||||
export type Language = 'python' | 'json' | 'toml' | 'css' | 'text'
|
||||
|
||||
export interface CodeEditorProps {
|
||||
value: string
|
||||
|
||||
onChange?: (value: string) => void
|
||||
language?: Language
|
||||
readOnly?: boolean
|
||||
height?: string
|
||||
minHeight?: string
|
||||
maxHeight?: string
|
||||
placeholder?: string
|
||||
theme?: 'light' | 'dark'
|
||||
className?: string
|
||||
}
|
||||
|
||||
const CodeEditorImpl = lazy(() => import('./CodeEditorImpl'))
|
||||
|
||||
function CodeEditorFallback({
|
||||
height,
|
||||
minHeight,
|
||||
maxHeight,
|
||||
className = '',
|
||||
}: Pick<CodeEditorProps, 'height' | 'minHeight' | 'maxHeight' | 'className'>) {
|
||||
return (
|
||||
<div
|
||||
className={`bg-muted animate-pulse rounded-md border ${className}`}
|
||||
style={{ height, minHeight, maxHeight }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function CodeEditor(props: CodeEditorProps) {
|
||||
const { height = '400px', minHeight, maxHeight, className = '' } = props
|
||||
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<CodeEditorFallback
|
||||
height={height}
|
||||
minHeight={minHeight}
|
||||
maxHeight={maxHeight}
|
||||
className={className}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<CodeEditorImpl {...props} />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
export default CodeEditor
|
||||
105
dashboard/src/components/CodeEditorImpl.tsx
Normal file
105
dashboard/src/components/CodeEditorImpl.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { css } from '@codemirror/lang-css'
|
||||
import { json, jsonParseLinter } from '@codemirror/lang-json'
|
||||
import { python } from '@codemirror/lang-python'
|
||||
import { StreamLanguage } from '@codemirror/language'
|
||||
import { toml as tomlMode } from '@codemirror/legacy-modes/mode/toml'
|
||||
import { linter } from '@codemirror/lint'
|
||||
import { oneDark } from '@codemirror/theme-one-dark'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import CodeMirror from '@uiw/react-codemirror'
|
||||
|
||||
import { useTheme } from '@/components/use-theme'
|
||||
|
||||
import type { CodeEditorProps, Language } from './CodeEditor'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const languageExtensions: Record<Language, any[]> = {
|
||||
python: [python()],
|
||||
json: [json(), linter(jsonParseLinter())],
|
||||
toml: [StreamLanguage.define(tomlMode)],
|
||||
css: [css()],
|
||||
text: [],
|
||||
}
|
||||
|
||||
export default function CodeEditorImpl({
|
||||
value,
|
||||
onChange,
|
||||
language = 'text',
|
||||
readOnly = false,
|
||||
height = '400px',
|
||||
minHeight,
|
||||
maxHeight,
|
||||
placeholder,
|
||||
theme,
|
||||
className = '',
|
||||
}: CodeEditorProps) {
|
||||
const { resolvedTheme } = useTheme()
|
||||
|
||||
const extensions = [
|
||||
...(languageExtensions[language] || []),
|
||||
EditorView.lineWrapping,
|
||||
// 应用 JetBrains Mono 字体
|
||||
EditorView.theme({
|
||||
'&': {
|
||||
fontFamily: '"JetBrains Mono", "Fira Code", "Consolas", "Monaco", monospace',
|
||||
},
|
||||
'.cm-content': {
|
||||
fontFamily: '"JetBrains Mono", "Fira Code", "Consolas", "Monaco", monospace',
|
||||
},
|
||||
'.cm-gutters': {
|
||||
fontFamily: '"JetBrains Mono", "Fira Code", "Consolas", "Monaco", monospace',
|
||||
},
|
||||
'.cm-scroller': {
|
||||
fontFamily: '"JetBrains Mono", "Fira Code", "Consolas", "Monaco", monospace',
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
if (readOnly) {
|
||||
extensions.push(EditorView.editable.of(false))
|
||||
}
|
||||
|
||||
// 如果外部传了 theme prop 则使用,否则从 context 自动获取
|
||||
const effectiveTheme = theme ?? resolvedTheme
|
||||
|
||||
return (
|
||||
<div className={`custom-scrollbar overflow-hidden rounded-md border ${className}`}>
|
||||
<CodeMirror
|
||||
value={value}
|
||||
height={height}
|
||||
minHeight={minHeight}
|
||||
maxHeight={maxHeight}
|
||||
theme={effectiveTheme === 'dark' ? oneDark : undefined}
|
||||
extensions={extensions}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
basicSetup={{
|
||||
lineNumbers: true,
|
||||
highlightActiveLineGutter: true,
|
||||
highlightSpecialChars: true,
|
||||
history: true,
|
||||
foldGutter: true,
|
||||
drawSelection: true,
|
||||
dropCursor: true,
|
||||
allowMultipleSelections: true,
|
||||
indentOnInput: true,
|
||||
syntaxHighlighting: true,
|
||||
bracketMatching: true,
|
||||
closeBrackets: true,
|
||||
autocompletion: true,
|
||||
rectangularSelection: true,
|
||||
crosshairCursor: true,
|
||||
highlightActiveLine: true,
|
||||
highlightSelectionMatches: true,
|
||||
closeBracketsKeymap: true,
|
||||
defaultKeymap: true,
|
||||
searchKeymap: true,
|
||||
historyKeymap: true,
|
||||
foldKeymap: true,
|
||||
completionKeymap: true,
|
||||
lintKeymap: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
525
dashboard/src/components/ListFieldEditor.tsx
Normal file
525
dashboard/src/components/ListFieldEditor.tsx
Normal file
@@ -0,0 +1,525 @@
|
||||
/**
|
||||
* ListFieldEditor - 动态数组字段编辑器
|
||||
*
|
||||
* 支持功能:
|
||||
* - 字符串数组 (string[])
|
||||
* - 数字数组 (number[])
|
||||
* - 对象数组 (object[]) - 根据 item_fields 定义渲染
|
||||
* - 拖拽排序
|
||||
* - 动态增删项
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react'
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from '@dnd-kit/core'
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Slider } from '@/components/ui/slider'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { GripVertical, Plus, Trash2, AlertCircle } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// ============ 类型定义 ============
|
||||
|
||||
export interface ItemFieldDefinition {
|
||||
/** 字段类型: "string" | "number" | "boolean" | "select" */
|
||||
type: string
|
||||
label?: string
|
||||
placeholder?: string
|
||||
default?: unknown
|
||||
/** select 类型的选项 */
|
||||
choices?: unknown[]
|
||||
/** slider 类型的最小值 */
|
||||
min?: number
|
||||
/** slider 类型的最大值 */
|
||||
max?: number
|
||||
/** slider 类型的步进 */
|
||||
step?: number
|
||||
}
|
||||
|
||||
export interface ListFieldEditorProps {
|
||||
/** 当前值 */
|
||||
value: unknown[] | unknown
|
||||
/** 值变化回调 */
|
||||
onChange: (value: unknown[]) => void
|
||||
/** 数组元素类型: "string" | "number" | "object" */
|
||||
itemType?: string
|
||||
/** 当 itemType="object" 时的字段定义 */
|
||||
itemFields?: Record<string, ItemFieldDefinition>
|
||||
/** 最小元素数量 */
|
||||
minItems?: number
|
||||
/** 最大元素数量 */
|
||||
maxItems?: number
|
||||
/** 是否禁用 */
|
||||
disabled?: boolean
|
||||
/** 新项的占位符文字 */
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
// ============ 可排序项组件 ============
|
||||
|
||||
interface SortableItemProps {
|
||||
id: string
|
||||
index: number
|
||||
itemType: string
|
||||
itemFields?: Record<string, ItemFieldDefinition>
|
||||
value: unknown
|
||||
onChange: (value: unknown) => void
|
||||
onRemove: () => void
|
||||
disabled?: boolean
|
||||
canRemove: boolean
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
function SortableItem({
|
||||
id,
|
||||
index,
|
||||
itemType,
|
||||
itemFields,
|
||||
value,
|
||||
onChange,
|
||||
onRemove,
|
||||
disabled,
|
||||
canRemove,
|
||||
placeholder,
|
||||
}: SortableItemProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id, disabled })
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
'flex items-start gap-2 group',
|
||||
isDragging && 'opacity-50 z-50'
|
||||
)}
|
||||
>
|
||||
{/* 拖拽手柄 */}
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex-shrink-0 p-2 cursor-grab active:cursor-grabbing',
|
||||
'text-muted-foreground hover:text-foreground transition-colors',
|
||||
'opacity-0 group-hover:opacity-100 focus:opacity-100',
|
||||
disabled && 'cursor-not-allowed opacity-30'
|
||||
)}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{/* 内容区域 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{itemType === 'object' && itemFields ? (
|
||||
<ObjectItemEditor
|
||||
value={value as Record<string, unknown>}
|
||||
onChange={onChange}
|
||||
fields={itemFields}
|
||||
disabled={disabled}
|
||||
/>
|
||||
) : itemType === 'number' ? (
|
||||
<Input
|
||||
type="number"
|
||||
value={value as number ?? ''}
|
||||
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
|
||||
placeholder={placeholder ?? `第 ${index + 1} 项`}
|
||||
disabled={disabled}
|
||||
className="font-mono"
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
type="text"
|
||||
value={value as string ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder ?? `第 ${index + 1} 项`}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 删除按钮 */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onRemove}
|
||||
disabled={disabled || !canRemove}
|
||||
className={cn(
|
||||
'flex-shrink-0 text-muted-foreground hover:text-destructive',
|
||||
'opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity'
|
||||
)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============ 对象项编辑器 ============
|
||||
|
||||
interface ObjectItemEditorProps {
|
||||
value: Record<string, unknown>
|
||||
onChange: (value: Record<string, unknown>) => void
|
||||
fields: Record<string, ItemFieldDefinition>
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
function ObjectItemEditor({
|
||||
value,
|
||||
onChange,
|
||||
fields,
|
||||
disabled,
|
||||
}: ObjectItemEditorProps) {
|
||||
const handleFieldChange = useCallback(
|
||||
(fieldName: string, fieldValue: unknown) => {
|
||||
onChange({
|
||||
...value,
|
||||
[fieldName]: fieldValue,
|
||||
})
|
||||
},
|
||||
[value, onChange]
|
||||
)
|
||||
|
||||
const renderField = (fieldName: string, fieldDef: ItemFieldDefinition) => {
|
||||
const fieldValue = value?.[fieldName]
|
||||
|
||||
// boolean / switch
|
||||
if (fieldDef.type === 'boolean' || fieldDef.type === 'switch') {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
{fieldDef.label ?? fieldName}
|
||||
</Label>
|
||||
<Switch
|
||||
checked={Boolean(fieldValue ?? fieldDef.default)}
|
||||
onCheckedChange={(checked) => handleFieldChange(fieldName, checked)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// slider (number with min/max)
|
||||
if (fieldDef.type === 'slider' || (fieldDef.type === 'number' && fieldDef.min != null && fieldDef.max != null)) {
|
||||
const numValue = (fieldValue as number) ?? (fieldDef.default as number) ?? fieldDef.min ?? 0
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
{fieldDef.label ?? fieldName}
|
||||
</Label>
|
||||
<span className="text-xs text-muted-foreground">{numValue}</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[numValue]}
|
||||
onValueChange={(v) => handleFieldChange(fieldName, v[0])}
|
||||
min={fieldDef.min ?? 0}
|
||||
max={fieldDef.max ?? 100}
|
||||
step={fieldDef.step ?? 1}
|
||||
disabled={disabled}
|
||||
className="py-1"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// select
|
||||
if (fieldDef.type === 'select' && fieldDef.choices) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
{fieldDef.label ?? fieldName}
|
||||
</Label>
|
||||
<Select
|
||||
value={String(fieldValue ?? fieldDef.default ?? '')}
|
||||
onValueChange={(v) => handleFieldChange(fieldName, v)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue placeholder={fieldDef.placeholder ?? '请选择'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{fieldDef.choices.map((choice) => (
|
||||
<SelectItem key={String(choice)} value={String(choice)}>
|
||||
{String(choice)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// number
|
||||
if (fieldDef.type === 'number') {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
{fieldDef.label ?? fieldName}
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={(fieldValue as number) ?? fieldDef.default ?? ''}
|
||||
onChange={(e) =>
|
||||
handleFieldChange(fieldName, parseFloat(e.target.value) || 0)
|
||||
}
|
||||
placeholder={fieldDef.placeholder}
|
||||
disabled={disabled}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// string (default)
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
{fieldDef.label ?? fieldName}
|
||||
</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={(fieldValue as string) ?? fieldDef.default ?? ''}
|
||||
onChange={(e) => handleFieldChange(fieldName, e.target.value)}
|
||||
placeholder={fieldDef.placeholder}
|
||||
disabled={disabled}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="p-3 space-y-2 bg-muted/30">
|
||||
{Object.entries(fields).map(([fieldName, fieldDef]) => (
|
||||
<div key={fieldName}>
|
||||
{renderField(fieldName, fieldDef)}
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// ============ 主组件 ============
|
||||
|
||||
export function ListFieldEditor({
|
||||
value,
|
||||
onChange,
|
||||
itemType = 'string',
|
||||
itemFields,
|
||||
minItems,
|
||||
maxItems,
|
||||
disabled,
|
||||
placeholder,
|
||||
}: ListFieldEditorProps) {
|
||||
// 确保 value 是数组
|
||||
const items: unknown[] = useMemo(() => {
|
||||
if (Array.isArray(value)) return value
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
// 尝试解析逗号分隔的字符串
|
||||
return value.split(',').map((s: string) => s.trim())
|
||||
}
|
||||
return []
|
||||
}, [value])
|
||||
|
||||
// 为每个项生成稳定的 ID
|
||||
const [itemIds] = useState(() => new Map<number, string>())
|
||||
const getItemId = useCallback(
|
||||
(index: number) => {
|
||||
if (!itemIds.has(index)) {
|
||||
itemIds.set(index, `item-${Date.now()}-${index}-${Math.random().toString(36).slice(2)}`)
|
||||
}
|
||||
return itemIds.get(index)!
|
||||
},
|
||||
[itemIds]
|
||||
)
|
||||
|
||||
// 同步 itemIds
|
||||
const sortableIds = useMemo(() => {
|
||||
// 清理多余的 ID
|
||||
const newIds: string[] = []
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
newIds.push(getItemId(i))
|
||||
}
|
||||
return newIds
|
||||
}, [items.length, getItemId])
|
||||
|
||||
// DnD 传感器配置
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
)
|
||||
|
||||
// 拖拽结束处理
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
if (over && active.id !== over.id) {
|
||||
const oldIndex = sortableIds.indexOf(active.id as string)
|
||||
const newIndex = sortableIds.indexOf(over.id as string)
|
||||
const newItems = arrayMove(items, oldIndex, newIndex)
|
||||
onChange(newItems)
|
||||
}
|
||||
},
|
||||
[items, sortableIds, onChange]
|
||||
)
|
||||
|
||||
// 添加新项
|
||||
const handleAddItem = useCallback(() => {
|
||||
if (maxItems != null && items.length >= maxItems) return
|
||||
|
||||
let newItem: unknown
|
||||
if (itemType === 'object' && itemFields) {
|
||||
// 创建包含默认值的对象
|
||||
newItem = Object.fromEntries(
|
||||
Object.entries(itemFields).map(([k, v]) => [k, v.default ?? ''])
|
||||
)
|
||||
} else if (itemType === 'number') {
|
||||
newItem = 0
|
||||
} else {
|
||||
newItem = ''
|
||||
}
|
||||
|
||||
onChange([...items, newItem])
|
||||
}, [items, maxItems, itemType, itemFields, onChange])
|
||||
|
||||
// 修改项
|
||||
const handleItemChange = useCallback(
|
||||
(index: number, newValue: unknown) => {
|
||||
const newItems = [...items]
|
||||
newItems[index] = newValue
|
||||
onChange(newItems)
|
||||
},
|
||||
[items, onChange]
|
||||
)
|
||||
|
||||
// 删除项
|
||||
const handleRemoveItem = useCallback(
|
||||
(index: number) => {
|
||||
if (minItems != null && items.length <= minItems) return
|
||||
const newItems = items.filter((_: unknown, i: number) => i !== index)
|
||||
// 清理 itemIds 映射
|
||||
itemIds.delete(index)
|
||||
onChange(newItems)
|
||||
},
|
||||
[items, minItems, itemIds, onChange]
|
||||
)
|
||||
|
||||
const canAdd = maxItems == null || items.length < maxItems
|
||||
const canRemove = minItems == null || items.length > minItems
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* 列表项 */}
|
||||
{items.length === 0 ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground py-4 justify-center border border-dashed rounded-md">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<span>暂无数据,点击下方按钮添加</span>
|
||||
</div>
|
||||
) : (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={sortableIds}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{items.map((item: unknown, index: number) => (
|
||||
<SortableItem
|
||||
key={sortableIds[index]}
|
||||
id={sortableIds[index]}
|
||||
index={index}
|
||||
itemType={itemType}
|
||||
itemFields={itemFields}
|
||||
value={item}
|
||||
onChange={(newValue) => handleItemChange(index, newValue)}
|
||||
onRemove={() => handleRemoveItem(index)}
|
||||
disabled={disabled}
|
||||
canRemove={canRemove}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
|
||||
{/* 添加按钮 */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddItem}
|
||||
disabled={disabled || !canAdd}
|
||||
className="w-full"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
添加项目
|
||||
{maxItems !== undefined && (
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
({items.length}/{maxItems})
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* 限制提示 */}
|
||||
{(minItems != null || maxItems != null) && (minItems !== null || maxItems !== null) && (
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
{minItems != null && maxItems != null
|
||||
? `允许 ${minItems} - ${maxItems} 项`
|
||||
: minItems != null
|
||||
? `至少 ${minItems} 项`
|
||||
: `最多 ${maxItems} 项`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ListFieldEditor
|
||||
189
dashboard/src/components/RestartingOverlay.legacy.tsx
Normal file
189
dashboard/src/components/RestartingOverlay.legacy.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Loader2, CheckCircle2, AlertCircle } from 'lucide-react'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
|
||||
/**
|
||||
* @deprecated 请使用新的 RestartOverlay 组件
|
||||
* import { RestartOverlay } from '@/components/restart-overlay'
|
||||
*/
|
||||
interface RestartingOverlayProps {
|
||||
onRestartComplete?: () => void
|
||||
onRestartFailed?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated 请使用新的 RestartOverlay 组件
|
||||
* import { RestartOverlay } from '@/components/restart-overlay'
|
||||
*/
|
||||
export function RestartingOverlay({ onRestartComplete, onRestartFailed }: RestartingOverlayProps) {
|
||||
const [progress, setProgress] = useState(0)
|
||||
const [status, setStatus] = useState<'restarting' | 'checking' | 'success' | 'failed'>('restarting')
|
||||
const [elapsedTime, setElapsedTime] = useState(0)
|
||||
const [checkAttempts, setCheckAttempts] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
// 进度条动画
|
||||
const progressInterval = setInterval(() => {
|
||||
setProgress((prev) => {
|
||||
if (prev >= 90) return prev
|
||||
return prev + 1
|
||||
})
|
||||
}, 200)
|
||||
|
||||
// 计时器
|
||||
const timerInterval = setInterval(() => {
|
||||
setElapsedTime((prev) => prev + 1)
|
||||
}, 1000)
|
||||
|
||||
// 等待3秒后开始检查状态(给后端重启时间)
|
||||
const initialDelay = setTimeout(() => {
|
||||
setStatus('checking')
|
||||
startHealthCheck()
|
||||
}, 3000)
|
||||
|
||||
return () => {
|
||||
clearInterval(progressInterval)
|
||||
clearInterval(timerInterval)
|
||||
clearTimeout(initialDelay)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const startHealthCheck = () => {
|
||||
const maxAttempts = 60 // 最多尝试60次(约2分钟)
|
||||
|
||||
const checkHealth = async () => {
|
||||
try {
|
||||
setCheckAttempts((prev) => prev + 1)
|
||||
|
||||
const response = await fetch('/api/webui/system/status', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
signal: AbortSignal.timeout(3000), // 3秒超时
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
// 重启成功
|
||||
setProgress(100)
|
||||
setStatus('success')
|
||||
setTimeout(() => {
|
||||
onRestartComplete?.()
|
||||
}, 1500)
|
||||
} else {
|
||||
throw new Error('Status check failed')
|
||||
}
|
||||
} catch {
|
||||
// 继续尝试
|
||||
if (checkAttempts < maxAttempts) {
|
||||
setTimeout(checkHealth, 2000) // 2秒后重试
|
||||
} else {
|
||||
// 超过最大尝试次数
|
||||
setStatus('failed')
|
||||
onRestartFailed?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
checkHealth()
|
||||
}
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = seconds % 60
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-background/95 backdrop-blur-sm z-50 flex items-center justify-center">
|
||||
<div className="max-w-md w-full mx-4 space-y-8">
|
||||
{/* 图标和状态 */}
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
{status === 'restarting' && (
|
||||
<>
|
||||
<Loader2 className="h-16 w-16 text-primary animate-spin" />
|
||||
<h2 className="text-2xl font-bold">正在重启麦麦</h2>
|
||||
<p className="text-muted-foreground text-center">
|
||||
请稍候,麦麦正在重启中...
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === 'checking' && (
|
||||
<>
|
||||
<Loader2 className="h-16 w-16 text-primary animate-spin" />
|
||||
<h2 className="text-2xl font-bold">检查服务状态</h2>
|
||||
<p className="text-muted-foreground text-center">
|
||||
等待服务恢复... (尝试 {checkAttempts}/60)
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === 'success' && (
|
||||
<>
|
||||
<CheckCircle2 className="h-16 w-16 text-green-500" />
|
||||
<h2 className="text-2xl font-bold">重启成功</h2>
|
||||
<p className="text-muted-foreground text-center">
|
||||
正在跳转到登录页面...
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === 'failed' && (
|
||||
<>
|
||||
<AlertCircle className="h-16 w-16 text-destructive" />
|
||||
<h2 className="text-2xl font-bold">重启超时</h2>
|
||||
<p className="text-muted-foreground text-center">
|
||||
服务未能在预期时间内恢复,请手动检查或刷新页面
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 进度条 */}
|
||||
{status !== 'failed' && (
|
||||
<div className="space-y-2">
|
||||
<Progress value={progress} className="h-2" />
|
||||
<div className="flex justify-between text-sm text-muted-foreground">
|
||||
<span>{progress}%</span>
|
||||
<span>已用时: {formatTime(elapsedTime)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 提示信息 */}
|
||||
<div className="bg-muted/50 rounded-lg p-4 space-y-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{status === 'restarting' && '🔄 配置已保存,正在重启主程序...'}
|
||||
{status === 'checking' && '⏳ 正在等待服务恢复,请勿关闭页面...'}
|
||||
{status === 'success' && '✅ 配置已生效,服务运行正常'}
|
||||
{status === 'failed' && '⚠️ 如果长时间无响应,请尝试手动重启'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 失败时的操作按钮 */}
|
||||
{status === 'failed' && (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="flex-1 px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
|
||||
>
|
||||
刷新页面
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setStatus('checking')
|
||||
setCheckAttempts(0)
|
||||
startHealthCheck()
|
||||
}}
|
||||
className="flex-1 px-4 py-2 bg-secondary text-secondary-foreground rounded-md hover:bg-secondary/90"
|
||||
>
|
||||
重试检测
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
54
dashboard/src/components/animation-provider.tsx
Normal file
54
dashboard/src/components/animation-provider.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { AnimationContext } from '@/lib/animation-context'
|
||||
|
||||
type AnimationProviderProps = {
|
||||
children: ReactNode
|
||||
defaultEnabled?: boolean
|
||||
defaultWavesEnabled?: boolean
|
||||
storageKey?: string
|
||||
wavesStorageKey?: string
|
||||
}
|
||||
|
||||
export function AnimationProvider({
|
||||
children,
|
||||
defaultEnabled = true,
|
||||
defaultWavesEnabled = true,
|
||||
storageKey = 'enable-animations',
|
||||
wavesStorageKey = 'enable-waves-background',
|
||||
}: AnimationProviderProps) {
|
||||
const [enableAnimations, setEnableAnimations] = useState<boolean>(() => {
|
||||
const stored = localStorage.getItem(storageKey)
|
||||
return stored !== null ? stored === 'true' : defaultEnabled
|
||||
})
|
||||
|
||||
const [enableWavesBackground, setEnableWavesBackground] = useState<boolean>(() => {
|
||||
const stored = localStorage.getItem(wavesStorageKey)
|
||||
return stored !== null ? stored === 'true' : defaultWavesEnabled
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement
|
||||
|
||||
if (enableAnimations) {
|
||||
root.classList.remove('no-animations')
|
||||
} else {
|
||||
root.classList.add('no-animations')
|
||||
}
|
||||
|
||||
localStorage.setItem(storageKey, String(enableAnimations))
|
||||
}, [enableAnimations, storageKey])
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(wavesStorageKey, String(enableWavesBackground))
|
||||
}, [enableWavesBackground, wavesStorageKey])
|
||||
|
||||
const value = {
|
||||
enableAnimations,
|
||||
setEnableAnimations,
|
||||
enableWavesBackground,
|
||||
setEnableWavesBackground,
|
||||
}
|
||||
|
||||
return <AnimationContext value={value}>{children}</AnimationContext>
|
||||
}
|
||||
64
dashboard/src/components/asset-provider.tsx
Normal file
64
dashboard/src/components/asset-provider.tsx
Normal file
@@ -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 value={value}>{children}</AssetStoreContext>
|
||||
}
|
||||
|
||||
export function useAssetStore() {
|
||||
const context = useContext(AssetStoreContext)
|
||||
if (!context) {
|
||||
throw new Error('useAssetStore must be used within AssetStoreProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
101
dashboard/src/components/back-to-top.tsx
Normal file
101
dashboard/src/components/back-to-top.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { ArrowUp } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
export function BackToTop() {
|
||||
const [progress, setProgress] = useState(0)
|
||||
const [visible, setVisible] = useState(false)
|
||||
const scrollerRef = useRef<HTMLElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = (e: Event) => {
|
||||
const target = e.target as HTMLElement
|
||||
|
||||
// 简单的启发式:如果是主要滚动容器(通常高度较大)
|
||||
// 我们假设页面中主要的滚动区域是高度最大的那个,或者就是当前触发滚动的这个
|
||||
// 只要它有足够的滚动空间
|
||||
if (target.scrollHeight > target.clientHeight + 100) {
|
||||
scrollerRef.current = target
|
||||
|
||||
const scrollTop = target.scrollTop
|
||||
const height = target.scrollHeight - target.clientHeight
|
||||
const scrolled = height > 0 ? (scrollTop / height) * 100 : 0
|
||||
|
||||
setProgress(scrolled)
|
||||
setVisible(scrollTop > 300)
|
||||
}
|
||||
}
|
||||
|
||||
// 使用捕获阶段监听所有滚动事件,因为 scroll 事件不冒泡
|
||||
window.addEventListener('scroll', handleScroll, { capture: true, passive: true })
|
||||
return () => window.removeEventListener('scroll', handleScroll, { capture: true })
|
||||
}, [])
|
||||
|
||||
const scrollToTop = () => {
|
||||
scrollerRef.current?.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
// SVG 环形进度条参数
|
||||
const radius = 18
|
||||
const circumference = 2 * Math.PI * radius
|
||||
const strokeDashoffset = circumference - (progress / 100) * circumference
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"fixed bottom-24 right-8 z-50 transition-all duration-500 ease-in-out transform",
|
||||
visible ? "translate-x-0 opacity-100" : "translate-x-32 opacity-0 pointer-events-none"
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"relative h-12 w-12 rounded-full shadow-xl",
|
||||
"bg-background/80 backdrop-blur-md border-border/50",
|
||||
"hover:bg-accent hover:scale-110 hover:shadow-2xl hover:border-primary/50",
|
||||
"transition-all duration-300",
|
||||
"group"
|
||||
)}
|
||||
onClick={scrollToTop}
|
||||
aria-label="回到顶部"
|
||||
>
|
||||
{/* 进度环背景 */}
|
||||
<svg className="absolute inset-0 h-full w-full -rotate-90 transform p-1" viewBox="0 0 44 44">
|
||||
<circle
|
||||
className="text-muted-foreground/10"
|
||||
strokeWidth="3"
|
||||
stroke="currentColor"
|
||||
fill="transparent"
|
||||
r={radius}
|
||||
cx="22"
|
||||
cy="22"
|
||||
/>
|
||||
{/* 进度环 */}
|
||||
<circle
|
||||
className="text-primary transition-all duration-100 ease-out"
|
||||
strokeWidth="3"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
strokeLinecap="round"
|
||||
stroke="currentColor"
|
||||
fill="transparent"
|
||||
r={radius}
|
||||
cx="22"
|
||||
cy="22"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* 图标 */}
|
||||
<ArrowUp
|
||||
className="h-5 w-5 text-primary transition-transform duration-300 group-hover:-translate-y-1 group-hover:scale-110"
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
|
||||
{/* 内部发光效果 (仅在 dark 模式下明显) */}
|
||||
<div className="absolute inset-0 rounded-full bg-primary/5 opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
267
dashboard/src/components/background-effects-controls.tsx
Normal file
267
dashboard/src/components/background-effects-controls.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
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'
|
||||
|
||||
function hslToHex(hsl: string): string {
|
||||
if (!hsl) return '#000000'
|
||||
|
||||
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
|
||||
let g = 0
|
||||
let b = 0
|
||||
|
||||
if (h >= 0 && h < 60) {
|
||||
r = c
|
||||
g = x
|
||||
} else if (h >= 60 && h < 120) {
|
||||
r = x
|
||||
g = c
|
||||
} else if (h >= 120 && h < 180) {
|
||||
g = c
|
||||
b = x
|
||||
} else if (h >= 180 && h < 240) {
|
||||
g = x
|
||||
b = c
|
||||
} else if (h >= 240 && h < 300) {
|
||||
r = x
|
||||
b = c
|
||||
} else if (h >= 300 && h < 360) {
|
||||
r = c
|
||||
b = x
|
||||
}
|
||||
|
||||
const toHex = (value: number) => {
|
||||
const hex = Math.round((value + m) * 255).toString(16)
|
||||
return hex.length === 1 ? `0${hex}` : hex
|
||||
}
|
||||
|
||||
return `#${toHex(r)}${toHex(g)}${toHex(b)}`
|
||||
}
|
||||
|
||||
type BackgroundEffectsControlsProps = {
|
||||
effects: BackgroundEffects
|
||||
onChange: (effects: BackgroundEffects) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function BackgroundEffectsControls({
|
||||
effects,
|
||||
onChange,
|
||||
disabled = false,
|
||||
}: BackgroundEffectsControlsProps) {
|
||||
const handleValueChange = (key: keyof BackgroundEffects, value: number) => {
|
||||
if (disabled) return
|
||||
|
||||
onChange({
|
||||
...effects,
|
||||
[key]: value,
|
||||
})
|
||||
}
|
||||
|
||||
const handleColorChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (disabled) return
|
||||
|
||||
const hex = e.target.value
|
||||
const hsl = hexToHSL(hex)
|
||||
onChange({
|
||||
...effects,
|
||||
overlayColor: hsl,
|
||||
})
|
||||
}
|
||||
|
||||
const handlePositionChange = (value: string) => {
|
||||
if (disabled) return
|
||||
|
||||
onChange({
|
||||
...effects,
|
||||
position: value as BackgroundEffects['position'],
|
||||
})
|
||||
}
|
||||
|
||||
const handleGradientChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (disabled) return
|
||||
|
||||
onChange({
|
||||
...effects,
|
||||
gradientOverlay: e.target.value,
|
||||
})
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
if (disabled) return
|
||||
onChange(defaultBackgroundEffects)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={disabled ? 'space-y-6 opacity-50' : 'space-y-6'}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium">背景效果调节</h3>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleReset}
|
||||
disabled={disabled}
|
||||
className="h-8 px-2 text-xs"
|
||||
>
|
||||
<RotateCcw className="mr-2 h-3.5 w-3.5" />
|
||||
重置默认
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6">
|
||||
<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}
|
||||
disabled={disabled}
|
||||
onValueChange={(vals) => handleValueChange('blur', vals[0])}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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}
|
||||
disabled={disabled}
|
||||
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
|
||||
disabled={disabled}
|
||||
className="flex-1 font-mono uppercase"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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}
|
||||
disabled={disabled}
|
||||
onValueChange={(vals) => handleValueChange('overlayOpacity', vals[0] / 100)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label>背景位置 (Position)</Label>
|
||||
<Select value={effects.position} onValueChange={handlePositionChange} disabled={disabled}>
|
||||
<SelectTrigger disabled={disabled}>
|
||||
<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>
|
||||
|
||||
<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}
|
||||
disabled={disabled}
|
||||
onValueChange={(vals) => handleValueChange('brightness', vals[0])}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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}
|
||||
disabled={disabled}
|
||||
onValueChange={(vals) => handleValueChange('contrast', vals[0])}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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}
|
||||
disabled={disabled}
|
||||
onValueChange={(vals) => handleValueChange('saturate', vals[0])}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label>CSS 渐变叠加 (Gradient Overlay)</Label>
|
||||
<Input
|
||||
value={effects.gradientOverlay || ''}
|
||||
onChange={handleGradientChange}
|
||||
disabled={disabled}
|
||||
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>
|
||||
)
|
||||
}
|
||||
196
dashboard/src/components/background-layer.tsx
Normal file
196
dashboard/src/components/background-layer.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
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 getAutoOverlayOpacity(layerId: string): number {
|
||||
switch (layerId) {
|
||||
case 'page':
|
||||
return 0.62
|
||||
case 'header':
|
||||
return 0.72
|
||||
case 'sidebar':
|
||||
return 0.78
|
||||
case 'card':
|
||||
return 0.82
|
||||
case 'dialog':
|
||||
return 0.88
|
||||
default:
|
||||
return 0.68
|
||||
}
|
||||
}
|
||||
|
||||
function getAutoGradientOverlay(layerId: string): string | undefined {
|
||||
if (layerId !== 'page') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return 'linear-gradient(to bottom, hsl(var(--background) / 0.82), hsl(var(--background) / 0.52) 28%, hsl(var(--background) / 0.7) 100%)'
|
||||
}
|
||||
|
||||
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
|
||||
const hasExplicitOverlay = overlayOpacity > 0
|
||||
const effectiveOverlayOpacity = hasExplicitOverlay ? overlayOpacity : getAutoOverlayOpacity(layerId)
|
||||
const effectiveOverlayColor = hasExplicitOverlay
|
||||
? `hsl(${overlayColor} / ${effectiveOverlayOpacity})`
|
||||
: `hsl(var(--background) / ${effectiveOverlayOpacity})`
|
||||
const effectiveGradientOverlay = gradientOverlay || getAutoGradientOverlay(layerId)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={layerId}
|
||||
data-background-layer={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()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{effectiveOverlayOpacity > 0 && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
zIndex: 1,
|
||||
backgroundColor: effectiveOverlayColor,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{effectiveGradientOverlay && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
zIndex: 2,
|
||||
background: effectiveGradientOverlay,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
284
dashboard/src/components/background-uploader.tsx
Normal file
284
dashboard/src/components/background-uploader.tsx
Normal file
@@ -0,0 +1,284 @@
|
||||
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
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function BackgroundUploader({ assetId, onAssetSelect, className, disabled = false }: 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) => {
|
||||
if (disabled) return
|
||||
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 (disabled || !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 (disabled) return
|
||||
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 (disabled) return
|
||||
|
||||
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
|
||||
handleFile(e.dataTransfer.files[0])
|
||||
}
|
||||
}
|
||||
|
||||
const handleClear = () => {
|
||||
if (disabled) return
|
||||
onAssetSelect(undefined)
|
||||
setPreviewUrl(undefined)
|
||||
setAssetType(undefined)
|
||||
setError(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-4", disabled && 'opacity-50', 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",
|
||||
disabled && 'pointer-events-none',
|
||||
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}
|
||||
disabled={disabled}
|
||||
>
|
||||
<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()}
|
||||
disabled={disabled}
|
||||
>
|
||||
选择文件
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept="image/*,video/mp4,video/webm"
|
||||
onChange={(e) => {
|
||||
if (disabled) return
|
||||
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}
|
||||
disabled={disabled}
|
||||
onChange={(e) => setUrlInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleUrlUpload()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleUrlUpload}
|
||||
disabled={disabled || !urlInput || isLoading}
|
||||
>
|
||||
{isLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : '获取'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
83
dashboard/src/components/component-css-editor.tsx
Normal file
83
dashboard/src/components/component-css-editor.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
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
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 组件级 CSS 编辑器
|
||||
* 提供 CSS 代码编辑、语法高亮和安全过滤警告功能
|
||||
*/
|
||||
export function ComponentCSSEditor({
|
||||
componentId,
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
height = '200px',
|
||||
disabled = false,
|
||||
}: ComponentCSSEditorProps) {
|
||||
// 实时计算 CSS 警告
|
||||
const { warnings } = sanitizeCSS(value)
|
||||
|
||||
return (
|
||||
<div className={disabled ? 'space-y-2 opacity-50' : '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={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={disabled ? undefined : onChange}
|
||||
language="css"
|
||||
readOnly={disabled}
|
||||
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>
|
||||
)
|
||||
}
|
||||
462
dashboard/src/components/dynamic-form/DynamicConfigForm.tsx
Normal file
462
dashboard/src/components/dynamic-form/DynamicConfigForm.tsx
Normal file
@@ -0,0 +1,462 @@
|
||||
import * as React from 'react'
|
||||
import * as LucideIcons from 'lucide-react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { fieldHooks, type FieldHookRegistry } from '@/lib/field-hooks'
|
||||
import type { ConfigSchema, FieldSchema } from '@/types/config-schema'
|
||||
|
||||
import { DynamicField } from './DynamicField'
|
||||
|
||||
export interface DynamicConfigFormProps {
|
||||
schema: ConfigSchema
|
||||
values: Record<string, unknown>
|
||||
onChange: (field: string, value: unknown) => void
|
||||
basePath?: string
|
||||
hooks?: FieldHookRegistry
|
||||
/** 嵌套层级:0 = tab 内容层,1 = section 内容层,2+ = 更深嵌套 */
|
||||
level?: number
|
||||
advancedVisible?: boolean
|
||||
sectionColumns?: 1 | 2
|
||||
}
|
||||
|
||||
function buildFieldPath(basePath: string, fieldName: string) {
|
||||
return basePath ? `${basePath}.${fieldName}` : fieldName
|
||||
}
|
||||
|
||||
function resolveSectionTitle(schema: ConfigSchema) {
|
||||
return schema.uiLabel || schema.classDoc || schema.className
|
||||
}
|
||||
|
||||
function SectionIcon({ iconName }: { iconName?: string }) {
|
||||
if (!iconName) return null
|
||||
const IconComponent = LucideIcons[iconName as keyof typeof LucideIcons] as
|
||||
| React.ComponentType<{ className?: string }>
|
||||
| undefined
|
||||
if (!IconComponent) return null
|
||||
return <IconComponent className="h-5 w-5 text-muted-foreground" />
|
||||
}
|
||||
|
||||
export function AdvancedSettingsButton({
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant={active ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={onClick}
|
||||
>
|
||||
高级设置
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function DynamicConfigSection({
|
||||
advancedVisible,
|
||||
basePath,
|
||||
hooks,
|
||||
level,
|
||||
nestedSchema,
|
||||
onChange,
|
||||
sectionKey,
|
||||
sectionTitle,
|
||||
values,
|
||||
}: {
|
||||
advancedVisible: boolean
|
||||
basePath: string
|
||||
hooks: FieldHookRegistry
|
||||
level: number
|
||||
nestedSchema: ConfigSchema
|
||||
onChange: (field: string, value: unknown) => void
|
||||
sectionKey: string
|
||||
sectionTitle: string
|
||||
values: Record<string, unknown>
|
||||
}) {
|
||||
return (
|
||||
<Card className="min-w-0">
|
||||
<CardHeader className="border-b border-border/50 pb-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<SectionIcon iconName={nestedSchema.uiIcon} />
|
||||
<CardTitle className="text-lg text-primary">{sectionTitle}</CardTitle>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4">
|
||||
<DynamicConfigForm
|
||||
schema={nestedSchema}
|
||||
values={values}
|
||||
onChange={(field, value) => onChange(`${sectionKey}.${field}`, value)}
|
||||
basePath={basePath}
|
||||
hooks={hooks}
|
||||
level={level}
|
||||
advancedVisible={advancedVisible}
|
||||
sectionColumns={1}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* DynamicConfigForm - 动态配置表单组件
|
||||
*
|
||||
* 根据 ConfigSchema 渲染表单字段,支持:
|
||||
* 1. Hook 系统:通过 FieldHookRegistry 自定义字段渲染
|
||||
* - replace 模式:完全替换默认渲染
|
||||
* - wrapper 模式:包装默认渲染(通过 children 传递)
|
||||
* 2. 嵌套 schema:递归渲染 schema.nested 中的子配置
|
||||
* 3. 高级设置:由栏目标题右侧按钮控制显示
|
||||
*/
|
||||
export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
|
||||
schema,
|
||||
values,
|
||||
onChange,
|
||||
basePath = '',
|
||||
hooks = fieldHooks,
|
||||
level = 0,
|
||||
advancedVisible,
|
||||
sectionColumns = 1,
|
||||
}) => {
|
||||
const resolvedAdvancedVisible = advancedVisible ?? false
|
||||
|
||||
const fieldMap = React.useMemo(
|
||||
() => new Map(schema.fields.map((field) => [field.name, field])),
|
||||
[schema.fields],
|
||||
)
|
||||
|
||||
const renderField = (field: FieldSchema) => {
|
||||
const fieldPath = buildFieldPath(basePath, field.name)
|
||||
const nestedSchema = schema.nested?.[field.name]
|
||||
|
||||
if (hooks.has(fieldPath)) {
|
||||
const hookEntry = hooks.get(fieldPath)
|
||||
if (!hookEntry) return null
|
||||
if (hookEntry.type === 'hidden') return null
|
||||
|
||||
const HookComponent = hookEntry.component
|
||||
|
||||
if (hookEntry.type === 'replace') {
|
||||
return (
|
||||
<HookComponent
|
||||
fieldPath={fieldPath}
|
||||
value={values[field.name]}
|
||||
onChange={(v) => onChange(field.name, v)}
|
||||
onParentChange={onChange}
|
||||
schema={field}
|
||||
nestedSchema={nestedSchema}
|
||||
parentValues={values}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<HookComponent
|
||||
fieldPath={fieldPath}
|
||||
value={values[field.name]}
|
||||
onChange={(v) => onChange(field.name, v)}
|
||||
onParentChange={onChange}
|
||||
schema={field}
|
||||
nestedSchema={nestedSchema}
|
||||
parentValues={values}
|
||||
>
|
||||
<DynamicField
|
||||
schema={field}
|
||||
value={values[field.name]}
|
||||
onChange={(v) => onChange(field.name, v)}
|
||||
fieldPath={fieldPath}
|
||||
/>
|
||||
</HookComponent>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DynamicField
|
||||
schema={field}
|
||||
value={values[field.name]}
|
||||
onChange={(v) => onChange(field.name, v)}
|
||||
fieldPath={fieldPath}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const shouldRenderFieldInline = (field: FieldSchema) => {
|
||||
const fieldPath = buildFieldPath(basePath, field.name)
|
||||
if (hooks.get(fieldPath)?.type === 'hidden') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!schema.nested?.[field.name]) {
|
||||
return true
|
||||
}
|
||||
|
||||
return hooks.get(fieldPath)?.type === 'replace'
|
||||
}
|
||||
|
||||
const schemaHasVisibleContent = React.useCallback(
|
||||
(targetSchema: ConfigSchema, targetBasePath: string): boolean => {
|
||||
const targetFields = targetSchema.fields ?? []
|
||||
const hasVisibleInlineField = targetFields.some((field) => {
|
||||
const fieldPath = buildFieldPath(targetBasePath, field.name)
|
||||
const hookEntry = hooks.get(fieldPath)
|
||||
|
||||
if (hookEntry?.type === 'hidden') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (targetSchema.nested?.[field.name] && hookEntry?.type !== 'replace') {
|
||||
return false
|
||||
}
|
||||
|
||||
return resolvedAdvancedVisible || !field.advanced
|
||||
})
|
||||
|
||||
if (hasVisibleInlineField) {
|
||||
return true
|
||||
}
|
||||
|
||||
return Object.entries(targetSchema.nested ?? {}).some(([key, nestedSchema]) => {
|
||||
const nestedField = targetFields.find((field) => field.name === key)
|
||||
const nestedFieldPath = buildFieldPath(targetBasePath, key)
|
||||
const hookEntry = hooks.get(nestedFieldPath)
|
||||
|
||||
if (hookEntry?.type === 'hidden') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (nestedField?.advanced && !resolvedAdvancedVisible) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (hookEntry?.type === 'replace') {
|
||||
return true
|
||||
}
|
||||
|
||||
return schemaHasVisibleContent(nestedSchema, nestedFieldPath)
|
||||
})
|
||||
},
|
||||
[hooks, resolvedAdvancedVisible],
|
||||
)
|
||||
|
||||
const inlineFields = schema.fields.filter(shouldRenderFieldInline)
|
||||
const inlineNestedFieldNames = new Set(
|
||||
inlineFields
|
||||
.filter((field) => Boolean(schema.nested?.[field.name]))
|
||||
.map((field) => field.name),
|
||||
)
|
||||
const normalFields = inlineFields.filter((field) => !field.advanced)
|
||||
const advancedFields = inlineFields.filter((field) => field.advanced)
|
||||
const visibleFields = resolvedAdvancedVisible
|
||||
? [...normalFields, ...advancedFields]
|
||||
: normalFields
|
||||
|
||||
const groupFieldsByRow = (fields: FieldSchema[]) => {
|
||||
const rows: FieldSchema[][] = []
|
||||
let currentRow: FieldSchema[] = []
|
||||
let currentRowKey: string | undefined
|
||||
|
||||
for (const field of fields) {
|
||||
const rowKey = field['x-row']
|
||||
if (rowKey && rowKey === currentRowKey) {
|
||||
currentRow.push(field)
|
||||
continue
|
||||
}
|
||||
|
||||
if (currentRow.length > 0) {
|
||||
rows.push(currentRow)
|
||||
}
|
||||
|
||||
currentRow = [field]
|
||||
currentRowKey = rowKey
|
||||
}
|
||||
|
||||
if (currentRow.length > 0) {
|
||||
rows.push(currentRow)
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
const renderRows = (rows: FieldSchema[][]) => (
|
||||
<>
|
||||
{rows.map((row) => (
|
||||
row.length > 1 ? (
|
||||
<div
|
||||
key={row.map((field) => field.name).join('|')}
|
||||
className="grid min-w-0 gap-4 py-1 md:grid-cols-[repeat(var(--field-row-count),minmax(0,1fr))]"
|
||||
style={{ '--field-row-count': row.length } as React.CSSProperties}
|
||||
>
|
||||
{row.map((field) => (
|
||||
<div key={field.name} className="min-w-0">{renderField(field)}</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div key={row[0].name} className="min-w-0 py-1">{renderField(row[0])}</div>
|
||||
)
|
||||
))}
|
||||
</>
|
||||
)
|
||||
|
||||
const renderFieldList = (fields: FieldSchema[]) => (
|
||||
<>
|
||||
{groupFieldsByRow(fields).map((row, index) => (
|
||||
<React.Fragment key={row.map((field) => field.name).join('|')}>
|
||||
{index > 0 && <Separator className="my-2 bg-border/50" />}
|
||||
{renderRows([row])}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="min-w-0 space-y-6">
|
||||
{visibleFields.length > 0 && (
|
||||
<div>
|
||||
{renderFieldList(visibleFields)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{schema.nested &&
|
||||
(() => {
|
||||
const nestedSections = Object.entries(schema.nested)
|
||||
.filter(([key]) => !inlineNestedFieldNames.has(key))
|
||||
.map(([key, nestedSchema]) => {
|
||||
const nestedField = fieldMap.get(key)
|
||||
const nestedFieldPath = buildFieldPath(basePath, key)
|
||||
|
||||
if (hooks.has(nestedFieldPath)) {
|
||||
const hookEntry = hooks.get(nestedFieldPath)
|
||||
if (!hookEntry) return null
|
||||
if (hookEntry.type === 'hidden') return null
|
||||
if (nestedField?.advanced && !resolvedAdvancedVisible) return null
|
||||
if (
|
||||
hookEntry.type !== 'replace' &&
|
||||
nestedSchema &&
|
||||
!schemaHasVisibleContent(nestedSchema, nestedFieldPath)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const HookComponent = hookEntry.component
|
||||
if (hookEntry.type === 'replace') {
|
||||
return (
|
||||
<div key={key} className="min-w-0">
|
||||
<HookComponent
|
||||
fieldPath={nestedFieldPath}
|
||||
value={values[key]}
|
||||
onChange={(v) => onChange(key, v)}
|
||||
onParentChange={onChange}
|
||||
schema={nestedField ?? nestedSchema}
|
||||
nestedSchema={nestedSchema}
|
||||
parentValues={values}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={key} className="min-w-0">
|
||||
<HookComponent
|
||||
fieldPath={nestedFieldPath}
|
||||
value={values[key]}
|
||||
onChange={(v) => onChange(key, v)}
|
||||
onParentChange={onChange}
|
||||
schema={nestedField ?? nestedSchema}
|
||||
nestedSchema={nestedSchema}
|
||||
parentValues={values}
|
||||
>
|
||||
<DynamicConfigForm
|
||||
schema={nestedSchema}
|
||||
values={(values[key] as Record<string, unknown>) || {}}
|
||||
onChange={(field, value) => onChange(`${key}.${field}`, value)}
|
||||
basePath={nestedFieldPath}
|
||||
hooks={hooks}
|
||||
level={level + 1}
|
||||
advancedVisible={resolvedAdvancedVisible}
|
||||
sectionColumns={1}
|
||||
/>
|
||||
</HookComponent>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const sectionTitle = resolveSectionTitle(nestedSchema)
|
||||
if (!schemaHasVisibleContent(nestedSchema, nestedFieldPath)) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (level === 0) {
|
||||
return (
|
||||
<DynamicConfigSection
|
||||
key={key}
|
||||
advancedVisible={resolvedAdvancedVisible}
|
||||
nestedSchema={nestedSchema}
|
||||
values={(values[key] as Record<string, unknown>) || {}}
|
||||
onChange={onChange}
|
||||
basePath={nestedFieldPath}
|
||||
hooks={hooks}
|
||||
level={level + 1}
|
||||
sectionKey={key}
|
||||
sectionTitle={sectionTitle}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card key={key} className="min-w-0 border-border/70 bg-muted/20 shadow-none">
|
||||
<CardHeader className="border-b border-border/50 px-4 py-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<SectionIcon iconName={nestedSchema.uiIcon} />
|
||||
<CardTitle className="text-sm text-primary">{sectionTitle}</CardTitle>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-4 pt-4">
|
||||
<DynamicConfigForm
|
||||
schema={nestedSchema}
|
||||
values={(values[key] as Record<string, unknown>) || {}}
|
||||
onChange={(field, value) => onChange(`${key}.${field}`, value)}
|
||||
basePath={nestedFieldPath}
|
||||
hooks={hooks}
|
||||
level={level + 1}
|
||||
advancedVisible={resolvedAdvancedVisible}
|
||||
sectionColumns={1}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})
|
||||
|
||||
const visibleNestedSections = nestedSections.filter(
|
||||
(section): section is React.ReactElement => Boolean(section),
|
||||
)
|
||||
|
||||
if (level === 0 && sectionColumns === 2 && visibleNestedSections.length > 1) {
|
||||
return (
|
||||
<div className="grid min-w-0 gap-4 md:grid-cols-2">
|
||||
{visibleNestedSections}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return visibleNestedSections
|
||||
})()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
487
dashboard/src/components/dynamic-form/DynamicField.tsx
Normal file
487
dashboard/src/components/dynamic-form/DynamicField.tsx
Normal file
@@ -0,0 +1,487 @@
|
||||
import * as React from "react"
|
||||
import * as LucideIcons from "lucide-react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { KeyValueEditor } from "@/components/ui/key-value-editor"
|
||||
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 {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { resolveFieldLabel } from "@/lib/config-label"
|
||||
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 { i18n } = useTranslation()
|
||||
const fieldLabel = resolveFieldLabel(schema, i18n.language)
|
||||
const isNumericField = schema.type === 'integer' || schema.type === 'number'
|
||||
|
||||
const parseNumericValue = (rawValue: unknown, fallbackValue: unknown = 0) => {
|
||||
if (typeof rawValue === 'number' && Number.isFinite(rawValue)) {
|
||||
return rawValue
|
||||
}
|
||||
|
||||
if (typeof rawValue === 'string') {
|
||||
const parsedValue = parseFloat(rawValue)
|
||||
if (Number.isFinite(parsedValue)) {
|
||||
return schema.type === 'integer' ? Math.trunc(parsedValue) : parsedValue
|
||||
}
|
||||
}
|
||||
|
||||
if (fallbackValue !== rawValue) {
|
||||
return parseNumericValue(fallbackValue, 0)
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
const renderPrimitiveArrayEditor = () => {
|
||||
const itemType = schema.items?.type ?? 'string'
|
||||
const arrayValue = Array.isArray(value)
|
||||
? value
|
||||
: Array.isArray(schema.default)
|
||||
? schema.default
|
||||
: []
|
||||
|
||||
const textareaValue = arrayValue.map((item) => String(item ?? '')).join('\n')
|
||||
|
||||
return (
|
||||
<Textarea
|
||||
value={textareaValue}
|
||||
onChange={(e) => {
|
||||
const nextItems = e.target.value
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
.map((line) => {
|
||||
if (itemType === 'integer') {
|
||||
return parseInt(line, 10) || 0
|
||||
}
|
||||
if (itemType === 'number') {
|
||||
return parseFloat(line) || 0
|
||||
}
|
||||
if (itemType === 'boolean') {
|
||||
return line === 'true'
|
||||
}
|
||||
return line
|
||||
})
|
||||
onChange(nextItems)
|
||||
}}
|
||||
rows={Math.max(4, arrayValue.length || 4)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const renderObjectEditor = () => {
|
||||
const objectValue =
|
||||
value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {}
|
||||
|
||||
return (
|
||||
<KeyValueEditor
|
||||
value={objectValue}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染字段图标
|
||||
*/
|
||||
const renderIcon = () => {
|
||||
if (!schema['x-icon']) return null
|
||||
|
||||
const IconComponent = LucideIcons[schema['x-icon'] as keyof typeof LucideIcons] as React.ComponentType<{ className?: string }> | undefined
|
||||
if (!IconComponent) return null
|
||||
|
||||
return <IconComponent className="h-4 w-4" />
|
||||
}
|
||||
|
||||
const optionDescriptions = schema['x-option-descriptions'] ?? {}
|
||||
const hasOptionDescriptions = Object.keys(optionDescriptions).length > 0
|
||||
const descriptionDisplay = schema['x-description-display'] ?? 'label-hover'
|
||||
const fieldDescription = schema.description
|
||||
const inlineDescription = descriptionDisplay === 'inline' && !hasOptionDescriptions ? fieldDescription : ''
|
||||
|
||||
const renderDescriptionTooltip = (trigger: React.ReactElement, side: 'top' | 'right' = 'top') => {
|
||||
if (!fieldDescription) return trigger
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={150}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{trigger}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side={side}
|
||||
align="start"
|
||||
className="max-w-80 whitespace-pre-line bg-background text-foreground border shadow-lg"
|
||||
>
|
||||
{fieldDescription}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const renderFieldHeader = () => (
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
|
||||
{(() => {
|
||||
const label = (
|
||||
<Label
|
||||
className={cn(
|
||||
"inline-flex min-w-0 items-center gap-1.5 text-[15px] leading-6",
|
||||
descriptionDisplay === 'label-hover' && fieldDescription && "cursor-help",
|
||||
schema.advanced
|
||||
? "text-sky-700 dark:text-sky-300"
|
||||
: "text-foreground",
|
||||
)}
|
||||
>
|
||||
{renderIcon()}
|
||||
<span className="break-words">{fieldLabel}</span>
|
||||
{schema.required && <span className="text-destructive">*</span>}
|
||||
</Label>
|
||||
)
|
||||
|
||||
return descriptionDisplay === 'label-hover'
|
||||
? renderDescriptionTooltip(label)
|
||||
: label
|
||||
})()}
|
||||
{descriptionDisplay === 'icon' && fieldDescription && (
|
||||
renderDescriptionTooltip(
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${fieldLabel} 说明`}
|
||||
className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<LucideIcons.CircleAlert className="h-4 w-4" />
|
||||
</button>,
|
||||
'right',
|
||||
)
|
||||
)}
|
||||
{inlineDescription && (
|
||||
<span className="text-[13px] leading-6 text-muted-foreground whitespace-pre-line">
|
||||
{inlineDescription}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
/**
|
||||
* 根据 x-widget 或 type 选择并渲染对应的输入组件
|
||||
*/
|
||||
const renderInputComponent = () => {
|
||||
const widget = schema['x-widget']
|
||||
const type = schema.type
|
||||
const resolvedWidget =
|
||||
isNumericField && (widget === 'input' || widget === 'number' || !widget)
|
||||
? 'number'
|
||||
: widget
|
||||
|
||||
// x-widget 优先
|
||||
if (resolvedWidget) {
|
||||
switch (resolvedWidget) {
|
||||
case 'slider':
|
||||
return renderSlider()
|
||||
case 'input':
|
||||
return renderTextInput()
|
||||
case 'number':
|
||||
return renderNumberInput()
|
||||
case 'password':
|
||||
return renderTextInput('password')
|
||||
case 'switch':
|
||||
return renderSwitch()
|
||||
case 'textarea':
|
||||
return renderTextarea()
|
||||
case 'select':
|
||||
return renderSelect()
|
||||
case 'custom':
|
||||
if (type === 'array' && schema.items && schema.items.type !== 'object') {
|
||||
return renderPrimitiveArrayEditor()
|
||||
}
|
||||
if (type === 'object') {
|
||||
return renderObjectEditor()
|
||||
}
|
||||
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':
|
||||
if (!schema.items || schema.items.type === '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">
|
||||
Complex array requires Hook
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return renderPrimitiveArrayEditor()
|
||||
case 'object':
|
||||
return renderObjectEditor()
|
||||
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 (
|
||||
<div className="flex min-w-0 items-center justify-between gap-4 py-2">
|
||||
<div className="min-w-0 pr-4">
|
||||
{renderFieldHeader()}
|
||||
</div>
|
||||
<Switch
|
||||
checked={checked}
|
||||
onCheckedChange={(checked) => onChange(checked)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染 Slider 组件(用于 number 类型 + x-widget: slider)
|
||||
*/
|
||||
const renderSlider = () => {
|
||||
const numValue = parseNumericValue(value, schema.default)
|
||||
const min = schema.minValue ?? 0
|
||||
const max = schema.maxValue ?? 100
|
||||
const step = schema.step ?? 1
|
||||
|
||||
return (
|
||||
<div className="min-w-0 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 = parseNumericValue(value, schema.default)
|
||||
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) => {
|
||||
const nextValue = schema.type === 'integer'
|
||||
? parseInt(e.target.value, 10)
|
||||
: parseFloat(e.target.value)
|
||||
onChange(Number.isFinite(nextValue) ? nextValue : 0)
|
||||
}}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染 Input[type="text"] 组件(用于 string 类型)
|
||||
*/
|
||||
const renderTextInput = (type: 'password' | 'text' = 'text') => {
|
||||
const strValue =
|
||||
typeof value === 'string'
|
||||
? value
|
||||
: value === null || value === undefined
|
||||
? String(schema.default ?? '')
|
||||
: String(value)
|
||||
return (
|
||||
<Input
|
||||
type={type}
|
||||
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 ?? '')
|
||||
const minHeight = typeof schema['x-textarea-min-height'] === 'number'
|
||||
? schema['x-textarea-min-height']
|
||||
: undefined
|
||||
const rows = typeof schema['x-textarea-rows'] === 'number'
|
||||
? schema['x-textarea-rows']
|
||||
: 4
|
||||
|
||||
return (
|
||||
<Textarea
|
||||
value={strValue}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
rows={rows}
|
||||
minHeight={minHeight}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染 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 ${fieldLabel}`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{hasOptionDescriptions ? (
|
||||
<TooltipProvider delayDuration={150}>
|
||||
{options.map((option) => {
|
||||
const description = optionDescriptions[option]
|
||||
return description ? (
|
||||
<Tooltip key={option}>
|
||||
<TooltipTrigger asChild>
|
||||
<SelectItem value={option} title={description}>
|
||||
{option}
|
||||
</SelectItem>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
className="max-w-72 bg-background text-foreground border shadow-lg"
|
||||
>
|
||||
{description}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<SelectItem key={option} value={option}>
|
||||
{option}
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
options.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{option}
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
// 判断当前字段是否为 Switch/Boolean 类型(独立处理布局)
|
||||
const isBoolean =
|
||||
schema['x-widget'] === 'switch' ||
|
||||
(!schema['x-widget'] && schema.type === 'boolean')
|
||||
const supportsInlineRight =
|
||||
schema['x-layout'] === 'inline-right' &&
|
||||
['input', 'number', 'password', 'select', undefined].includes(schema['x-widget']) &&
|
||||
['string', 'number', 'integer', 'select'].includes(schema.type)
|
||||
|
||||
// Switch/Boolean 字段自带完整布局,直接返回
|
||||
if (isBoolean) {
|
||||
return renderInputComponent()
|
||||
}
|
||||
|
||||
if (supportsInlineRight) {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col gap-2 py-2 sm:flex-row sm:items-center"
|
||||
style={{ '--field-input-width': schema['x-input-width'] ?? '12rem' } as React.CSSProperties}
|
||||
>
|
||||
<div className="min-w-0 sm:shrink-0">
|
||||
{renderFieldHeader()}
|
||||
</div>
|
||||
<div className="min-w-20 flex-1 sm:ml-auto sm:max-w-[var(--field-input-width)]">
|
||||
{renderInputComponent()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-w-0 space-y-2">
|
||||
{renderFieldHeader()}
|
||||
|
||||
{/* Input component */}
|
||||
{renderInputComponent()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
126
dashboard/src/components/dynamic-form/README.md
Normal file
126
dashboard/src/components/dynamic-form/README.md
Normal file
@@ -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,427 @@
|
||||
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('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')
|
||||
})
|
||||
|
||||
it('renders nested Hook component with full field path', async () => {
|
||||
const NestedHookComponent: React.FC<FieldHookComponentProps> = ({ fieldPath, onChange }) => {
|
||||
return (
|
||||
<button onClick={() => onChange?.([{ enabled: true }])}>
|
||||
{fieldPath}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const hooks = new FieldHookRegistry()
|
||||
hooks.register('mcp.servers', NestedHookComponent, 'replace')
|
||||
|
||||
const schema: ConfigSchema = {
|
||||
className: 'RootConfig',
|
||||
classDoc: 'Root configuration',
|
||||
fields: [],
|
||||
nested: {
|
||||
mcp: {
|
||||
className: 'MCPConfig',
|
||||
classDoc: 'MCP 配置',
|
||||
fields: [
|
||||
{
|
||||
name: 'enable',
|
||||
type: 'boolean',
|
||||
label: '启用 MCP',
|
||||
description: '是否启用 MCP',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'servers',
|
||||
type: 'array',
|
||||
label: '服务器列表',
|
||||
description: '复杂对象数组',
|
||||
required: false,
|
||||
items: {
|
||||
type: 'object',
|
||||
},
|
||||
},
|
||||
],
|
||||
nested: {
|
||||
servers: {
|
||||
className: 'MCPServerItemConfig',
|
||||
classDoc: 'MCP 服务器项',
|
||||
fields: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
const values = {
|
||||
mcp: {
|
||||
enable: true,
|
||||
servers: [],
|
||||
},
|
||||
}
|
||||
const onChange = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<DynamicConfigForm schema={schema} values={values} onChange={onChange} hooks={hooks} />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'mcp.servers' }))
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('mcp.servers', [{ enabled: true }])
|
||||
})
|
||||
})
|
||||
|
||||
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('Sub configuration')).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,475 @@
|
||||
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()
|
||||
})
|
||||
|
||||
it('renders number Input when x-widget is input but type is integer', () => {
|
||||
const schema: FieldSchema = {
|
||||
name: 'test_integer_input_widget',
|
||||
type: 'integer',
|
||||
label: 'Test Integer Input Widget',
|
||||
description: 'A numeric field rendered as input',
|
||||
required: false,
|
||||
'x-widget': 'input',
|
||||
default: 0,
|
||||
}
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(<DynamicField schema={schema} value={2} onChange={onChange} />)
|
||||
|
||||
const input = screen.getByRole('spinbutton')
|
||||
expect(input).toBeInTheDocument()
|
||||
expect(input).toHaveValue(2)
|
||||
})
|
||||
|
||||
it('parses string values for numeric input widgets', () => {
|
||||
const schema: FieldSchema = {
|
||||
name: 'test_string_number_input_widget',
|
||||
type: 'integer',
|
||||
label: 'Test String Number Input Widget',
|
||||
description: 'A numeric field with legacy string value',
|
||||
required: false,
|
||||
'x-widget': 'input',
|
||||
default: 0,
|
||||
}
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(<DynamicField schema={schema} value="2" onChange={onChange} />)
|
||||
|
||||
expect(screen.getByRole('spinbutton')).toHaveValue(2)
|
||||
})
|
||||
})
|
||||
|
||||
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 textarea editor for primitive array type', () => {
|
||||
const schema: FieldSchema = {
|
||||
name: 'test_array',
|
||||
type: 'array',
|
||||
label: 'Test Array',
|
||||
description: 'A test array',
|
||||
required: false,
|
||||
items: {
|
||||
type: 'string',
|
||||
},
|
||||
}
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(<DynamicField schema={schema} value={['a', 'b']} onChange={onChange} />)
|
||||
|
||||
expect(screen.getByRole('textbox')).toHaveValue('a\nb')
|
||||
})
|
||||
|
||||
it('renders key-value editor 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={{ foo: 'bar' }} onChange={onChange} />)
|
||||
|
||||
expect(screen.getByText('可视化编辑')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('foo')).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()
|
||||
})
|
||||
|
||||
it('triggers numeric onChange for input widget with integer type', async () => {
|
||||
const schema: FieldSchema = {
|
||||
name: 'test_integer_input_widget_change',
|
||||
type: 'integer',
|
||||
label: 'Test Integer Input Widget Change',
|
||||
description: 'A numeric field rendered as input',
|
||||
required: false,
|
||||
'x-widget': 'input',
|
||||
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, '5')
|
||||
expect(onChange).toHaveBeenLastCalledWith(5)
|
||||
})
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
it('parses string values for slider widgets', () => {
|
||||
const schema: FieldSchema = {
|
||||
name: 'test_slider_string_value',
|
||||
type: 'number',
|
||||
label: 'Test Slider String Value',
|
||||
description: 'A slider with legacy string value',
|
||||
required: false,
|
||||
'x-widget': 'slider',
|
||||
minValue: 0,
|
||||
maxValue: 10,
|
||||
default: 0,
|
||||
}
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(<DynamicField schema={schema} value="2.5" onChange={onChange} />)
|
||||
|
||||
expect(screen.getByText('2.5')).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()
|
||||
})
|
||||
})
|
||||
})
|
||||
2
dashboard/src/components/dynamic-form/index.ts
Normal file
2
dashboard/src/components/dynamic-form/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { DynamicConfigForm } from './DynamicConfigForm'
|
||||
export { DynamicField } from './DynamicField'
|
||||
245
dashboard/src/components/electron/BackendManager.tsx
Normal file
245
dashboard/src/components/electron/BackendManager.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
import { useState } from 'react'
|
||||
import { Check, Loader2, Pencil, Plus, Server, Trash2 } from 'lucide-react'
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useBackendConnections } from '@/hooks/useBackendConnections'
|
||||
import { isElectron } from '@/lib/runtime'
|
||||
import type { BackendConnection } from '@/types/electron'
|
||||
|
||||
export interface BackendManagerProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function BackendManager({ open, onOpenChange }: BackendManagerProps) {
|
||||
const {
|
||||
activeId,
|
||||
addBackend,
|
||||
backends,
|
||||
loading,
|
||||
removeBackend,
|
||||
switchBackend,
|
||||
updateBackend,
|
||||
} = useBackendConnections()
|
||||
|
||||
const [editConn, setEditConn] = useState<Partial<BackendConnection> | null>(null)
|
||||
const [deleteConn, setDeleteConn] = useState<BackendConnection | null>(null)
|
||||
|
||||
if (!isElectron()) return null
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!editConn?.name || !editConn?.url) return
|
||||
const urlPattern = /^https?:\/\//
|
||||
if (!urlPattern.test(editConn.url)) return
|
||||
|
||||
if (editConn.id) {
|
||||
await updateBackend(editConn.id, editConn)
|
||||
} else {
|
||||
await addBackend({
|
||||
name: editConn.name,
|
||||
url: editConn.url,
|
||||
isDefault: editConn.isDefault ?? false,
|
||||
})
|
||||
}
|
||||
setEditConn(null)
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteConn) return
|
||||
if (deleteConn.id === activeId) return
|
||||
await removeBackend(deleteConn.id)
|
||||
setDeleteConn(null)
|
||||
}
|
||||
|
||||
const handleSwitch = async (id: string) => {
|
||||
if (id === activeId) return
|
||||
await switchBackend(id)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md sm:max-w-106.25">
|
||||
<DialogHeader>
|
||||
<DialogTitle>后端连接管理</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<DialogBody className="pr-4">
|
||||
<div className="flex flex-col gap-3 py-4">
|
||||
{backends.map((backend) => {
|
||||
const isActive = backend.id === activeId
|
||||
return (
|
||||
<div
|
||||
key={backend.id}
|
||||
className={`flex items-center justify-between rounded-lg border p-3 transition-colors ${
|
||||
isActive ? 'border-blue-500 bg-blue-500/10' : 'border-border'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-1 items-center gap-3 overflow-hidden">
|
||||
<div className="shrink-0">
|
||||
{isActive ? (
|
||||
<Check className="h-5 w-5 text-blue-500" />
|
||||
) : (
|
||||
<div className="h-3 w-3 rounded-full bg-muted-foreground/30 ml-1" title="未知状态" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col overflow-hidden">
|
||||
<span className="truncate font-medium leading-none">
|
||||
{backend.name}
|
||||
</span>
|
||||
<span className="truncate text-xs text-muted-foreground mt-1">
|
||||
{backend.url}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 ml-2">
|
||||
{!isActive && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => handleSwitch(backend.id)}
|
||||
title="切换到此后端"
|
||||
>
|
||||
<Server className="h-4 w-4" />
|
||||
<span className="sr-only">切换到此后端</span>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setEditConn(backend)}
|
||||
title="编辑"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
<span className="sr-only">编辑</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setDeleteConn(backend)}
|
||||
disabled={isActive}
|
||||
title={isActive ? '无法删除活跃后端' : '删除'}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span className="sr-only">删除</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</DialogBody>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end pt-4 border-t">
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => setEditConn({ name: '', url: 'http://', isDefault: false })}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
添加新连接
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Edit/Add Dialog */}
|
||||
<Dialog open={!!editConn} onOpenChange={(open) => !open && setEditConn(null)}>
|
||||
<DialogContent className="sm:max-w-106.25" confirmOnEnter>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editConn?.id ? '编辑连接' : '添加连接'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">名称</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={editConn?.name || ''}
|
||||
onChange={(e) =>
|
||||
setEditConn((prev) => (prev ? { ...prev, name: e.target.value } : null))
|
||||
}
|
||||
placeholder="我的服务器"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="url">URL</Label>
|
||||
<Input
|
||||
id="url"
|
||||
value={editConn?.url || ''}
|
||||
onChange={(e) =>
|
||||
setEditConn((prev) => (prev ? { ...prev, url: e.target.value } : null))
|
||||
}
|
||||
placeholder="http://192.168.1.100:8001"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="outline" onClick={() => setEditConn(null)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={
|
||||
!editConn?.name ||
|
||||
!editConn?.url ||
|
||||
!/^https?:\/\//.test(editConn.url)
|
||||
}
|
||||
data-dialog-action="confirm"
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<AlertDialog open={!!deleteConn} onOpenChange={(open) => !open && setDeleteConn(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>删除连接</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确定要删除 {deleteConn?.name} 吗?此操作不可撤销。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
256
dashboard/src/components/electron/BackendSetupWizard.tsx
Normal file
256
dashboard/src/components/electron/BackendSetupWizard.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
ArrowRight,
|
||||
Bot,
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
XCircle,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
import { isElectron } from '@/lib/runtime'
|
||||
|
||||
interface BackendSetupWizardProps {
|
||||
open: boolean
|
||||
}
|
||||
|
||||
type TestStatus = 'idle' | 'loading' | 'success' | 'error'
|
||||
|
||||
/**
|
||||
* First-launch backend setup wizard for Electron environment.
|
||||
* Full-screen modal that guides users to configure their first backend connection.
|
||||
* Cannot be dismissed until configuration is complete.
|
||||
*/
|
||||
export function BackendSetupWizard({ open }: BackendSetupWizardProps) {
|
||||
const [name, setName] = useState('')
|
||||
const [url, setUrl] = useState('')
|
||||
const [testStatus, setTestStatus] = useState<TestStatus>('idle')
|
||||
const [testError, setTestError] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
// Validation errors
|
||||
const [nameError, setNameError] = useState('')
|
||||
const [urlError, setUrlError] = useState('')
|
||||
|
||||
// Only render in Electron environment
|
||||
if (!isElectron()) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!open) {
|
||||
return null
|
||||
}
|
||||
|
||||
const validateName = (value: string): boolean => {
|
||||
if (!value.trim()) {
|
||||
setNameError('后端名称不能为空')
|
||||
return false
|
||||
}
|
||||
setNameError('')
|
||||
return true
|
||||
}
|
||||
|
||||
const validateUrl = (value: string): boolean => {
|
||||
if (!value.trim()) {
|
||||
setUrlError('后端地址不能为空')
|
||||
return false
|
||||
}
|
||||
if (!/^https?:\/\/.+/.test(value)) {
|
||||
setUrlError('地址必须以 http:// 或 https:// 开头')
|
||||
return false
|
||||
}
|
||||
if (value.endsWith('/')) {
|
||||
setUrlError('地址末尾不能包含 /')
|
||||
return false
|
||||
}
|
||||
setUrlError('')
|
||||
return true
|
||||
}
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
if (!validateUrl(url)) return
|
||||
|
||||
setTestStatus('loading')
|
||||
setTestError('')
|
||||
|
||||
try {
|
||||
const response = await fetch(`${url}/api/webui/system/health`, {
|
||||
method: 'GET',
|
||||
signal: AbortSignal.timeout(10000),
|
||||
})
|
||||
if (response.ok) {
|
||||
setTestStatus('success')
|
||||
} else {
|
||||
setTestStatus('error')
|
||||
setTestError(`服务器返回状态码 ${response.status}`)
|
||||
}
|
||||
} catch (err) {
|
||||
setTestStatus('error')
|
||||
if (err instanceof DOMException && err.name === 'TimeoutError') {
|
||||
setTestError('连接超时,请检查地址是否正确')
|
||||
} else if (err instanceof TypeError) {
|
||||
setTestError('无法连接到服务器,请检查地址和网络')
|
||||
} else {
|
||||
setTestError(err instanceof Error ? err.message : '未知错误')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleFinish = async () => {
|
||||
const isNameValid = validateName(name)
|
||||
const isUrlValid = validateUrl(url)
|
||||
if (!isNameValid || !isUrlValid) return
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const newBackend = await window.electronAPI!.addBackend({
|
||||
name: name.trim(),
|
||||
url: url.trim(),
|
||||
isDefault: true,
|
||||
})
|
||||
await window.electronAPI!.setActiveBackend(newBackend.id)
|
||||
await window.electronAPI!.markFirstLaunchComplete()
|
||||
window.location.reload()
|
||||
} catch (err) {
|
||||
setIsSubmitting(false)
|
||||
setTestStatus('error')
|
||||
setTestError(
|
||||
err instanceof Error ? err.message : '保存配置失败,请重试'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const isFormValid = name.trim() !== '' && /^https?:\/\/.+/.test(url) && !url.endsWith('/')
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background">
|
||||
{/* Background decoration */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute left-1/4 top-1/4 h-64 w-64 md:h-96 md:w-96 rounded-full bg-primary/5 blur-3xl" />
|
||||
<div className="absolute right-1/4 bottom-1/4 h-64 w-64 md:h-96 md:w-96 rounded-full bg-secondary/5 blur-3xl" />
|
||||
</div>
|
||||
|
||||
<Card className="relative z-10 max-w-md w-full mx-4 shadow-lg">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10">
|
||||
<Bot className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl">欢迎使用 MaiBot</CardTitle>
|
||||
<CardDescription>
|
||||
配置您的第一个后端连接以开始使用
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
{/* Backend name field */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="backend-name">
|
||||
后端名称 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="backend-name"
|
||||
placeholder="例如:本地服务器"
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value)
|
||||
if (nameError) validateName(e.target.value)
|
||||
}}
|
||||
onBlur={() => validateName(name)}
|
||||
/>
|
||||
{nameError && (
|
||||
<p className="text-sm text-destructive">{nameError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Backend URL field */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="backend-url">
|
||||
后端地址 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="backend-url"
|
||||
placeholder="例如:http://192.168.1.100:8001"
|
||||
value={url}
|
||||
onChange={(e) => {
|
||||
setUrl(e.target.value)
|
||||
if (urlError) validateUrl(e.target.value)
|
||||
// Reset test status when URL changes
|
||||
if (testStatus !== 'idle') {
|
||||
setTestStatus('idle')
|
||||
setTestError('')
|
||||
}
|
||||
}}
|
||||
onBlur={() => validateUrl(url)}
|
||||
/>
|
||||
{urlError && (
|
||||
<p className="text-sm text-destructive">{urlError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Test connection */}
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleTestConnection}
|
||||
disabled={testStatus === 'loading' || !url.trim()}
|
||||
className="w-full"
|
||||
>
|
||||
{testStatus === 'loading' ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
测试连接中...
|
||||
</>
|
||||
) : (
|
||||
'测试连接'
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{testStatus === 'success' && (
|
||||
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
连接成功
|
||||
</div>
|
||||
)}
|
||||
|
||||
{testStatus === 'error' && (
|
||||
<div className="flex items-start gap-2 text-sm text-destructive">
|
||||
<XCircle className="h-4 w-4 mt-0.5 shrink-0" />
|
||||
<span>{testError || '无法连接'}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Submit button */}
|
||||
<Button
|
||||
onClick={handleFinish}
|
||||
disabled={!isFormValid || isSubmitting}
|
||||
className="w-full"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
配置中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
开始使用
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
67
dashboard/src/components/electron/TitleBar.tsx
Normal file
67
dashboard/src/components/electron/TitleBar.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Copy, Minus, Square, X } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { useWindowControls } from '@/hooks/useWindowControls'
|
||||
import { getPlatform, isElectron } from '@/lib/runtime'
|
||||
|
||||
const dragStyle = { WebkitAppRegion: 'drag' } as React.CSSProperties & { WebkitAppRegion: string }
|
||||
const noDragStyle = { WebkitAppRegion: 'no-drag' } as React.CSSProperties & { WebkitAppRegion: string }
|
||||
|
||||
export function TitleBar() {
|
||||
const { close, isMaximized, minimize, toggleMaximize } = useWindowControls()
|
||||
const isMac = useMemo(() => getPlatform() === 'darwin', [])
|
||||
|
||||
if (!isElectron()) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center justify-between border-b border-border bg-background select-none ${isMac ? 'h-7' : 'h-8'}`}
|
||||
style={dragStyle}
|
||||
>
|
||||
{/* macOS traffic light padding */}
|
||||
{isMac && <div className="h-full w-[78px]" style={noDragStyle} />}
|
||||
|
||||
{/* Title / Drag area */}
|
||||
<div className="flex flex-1 items-center justify-center text-xs font-semibold text-foreground/80">
|
||||
MaiBot
|
||||
</div>
|
||||
|
||||
{/* Windows / Linux Controls */}
|
||||
{!isMac && (
|
||||
<div className="flex h-full items-center" style={noDragStyle}>
|
||||
<button
|
||||
className="flex h-8 w-11 items-center justify-center hover:bg-accent hover:text-accent-foreground"
|
||||
onClick={minimize}
|
||||
tabIndex={-1}
|
||||
type="button"
|
||||
aria-label="最小化"
|
||||
>
|
||||
<Minus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
className="flex h-8 w-11 items-center justify-center hover:bg-accent hover:text-accent-foreground"
|
||||
onClick={toggleMaximize}
|
||||
tabIndex={-1}
|
||||
type="button"
|
||||
aria-label={isMaximized ? "还原窗口" : "最大化"}
|
||||
>
|
||||
{isMaximized ? (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Square className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="flex h-8 w-11 items-center justify-center hover:bg-destructive hover:text-destructive-foreground"
|
||||
onClick={close}
|
||||
tabIndex={-1}
|
||||
type="button"
|
||||
aria-label="关闭窗口"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
123
dashboard/src/components/emoji-thumbnail.tsx
Normal file
123
dashboard/src/components/emoji-thumbnail.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* 表情包缩略图组件
|
||||
*
|
||||
* 特性:
|
||||
* - 自动处理 202 响应(缩略图生成中)
|
||||
* - 显示 Skeleton 占位符
|
||||
* - 自动重试加载
|
||||
* - 加载失败显示占位图标
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { ImageIcon } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface EmojiThumbnailProps {
|
||||
src: string
|
||||
alt?: string
|
||||
className?: string
|
||||
/** 最大重试次数 */
|
||||
maxRetries?: number
|
||||
/** 重试间隔(毫秒) */
|
||||
retryInterval?: number
|
||||
}
|
||||
|
||||
type LoadingState = 'loading' | 'loaded' | 'generating' | 'error'
|
||||
|
||||
export function EmojiThumbnail({
|
||||
src,
|
||||
alt = '表情包',
|
||||
className,
|
||||
maxRetries = 5,
|
||||
retryInterval = 1500,
|
||||
}: EmojiThumbnailProps) {
|
||||
const [state, setState] = useState<LoadingState>('loading')
|
||||
const [retryCount, setRetryCount] = useState(0)
|
||||
const [imageSrc, setImageSrc] = useState<string | null>(null)
|
||||
const [currentSrc, setCurrentSrc] = useState(src)
|
||||
|
||||
// 当 src 变化时重置状态
|
||||
if (src !== currentSrc) {
|
||||
setState('loading')
|
||||
setRetryCount(0)
|
||||
setImageSrc(null)
|
||||
setCurrentSrc(src)
|
||||
}
|
||||
|
||||
const loadImage = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch(src, {
|
||||
credentials: 'include', // 携带 Cookie
|
||||
})
|
||||
|
||||
if (response.status === 202) {
|
||||
// 缩略图正在生成中
|
||||
setState('generating')
|
||||
|
||||
if (retryCount < maxRetries) {
|
||||
// 延迟后重试
|
||||
setTimeout(() => {
|
||||
setRetryCount(prev => prev + 1)
|
||||
}, retryInterval)
|
||||
} else {
|
||||
// 超过最大重试次数,显示错误
|
||||
setState('error')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
setState('error')
|
||||
return
|
||||
}
|
||||
|
||||
// 成功获取图片
|
||||
const blob = await response.blob()
|
||||
const objectUrl = URL.createObjectURL(blob)
|
||||
setImageSrc(objectUrl)
|
||||
setState('loaded')
|
||||
} catch (error) {
|
||||
console.error('加载缩略图失败:', error)
|
||||
setState('error')
|
||||
}
|
||||
}, [src, retryCount, maxRetries, retryInterval])
|
||||
|
||||
useEffect(() => {
|
||||
loadImage()
|
||||
}, [loadImage])
|
||||
|
||||
// 清理 Object URL
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (imageSrc) {
|
||||
URL.revokeObjectURL(imageSrc)
|
||||
}
|
||||
}
|
||||
}, [imageSrc])
|
||||
|
||||
// 加载中或生成中显示 Skeleton
|
||||
if (state === 'loading' || state === 'generating') {
|
||||
return (
|
||||
<Skeleton className={cn('w-full h-full', className)} />
|
||||
)
|
||||
}
|
||||
|
||||
// 加载失败显示占位图标
|
||||
if (state === 'error' || !imageSrc) {
|
||||
return (
|
||||
<div className={cn('w-full h-full flex items-center justify-center bg-muted', className)}>
|
||||
<ImageIcon className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 加载成功显示图片
|
||||
return (
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt={alt}
|
||||
className={cn('w-full h-full object-contain', className)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
310
dashboard/src/components/error-boundary.tsx
Normal file
310
dashboard/src/components/error-boundary.tsx
Normal file
@@ -0,0 +1,310 @@
|
||||
import { Component } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { ErrorInfo, ReactNode } from 'react'
|
||||
import { AlertTriangle, RefreshCw, Home, ChevronDown, ChevronUp, Copy, Check, Bug } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
||||
import { useState } from 'react'
|
||||
|
||||
interface Props {
|
||||
children: ReactNode
|
||||
fallback?: ReactNode
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean
|
||||
error: Error | null
|
||||
errorInfo: ErrorInfo | null
|
||||
}
|
||||
|
||||
// 解析堆栈信息为结构化数据
|
||||
interface StackFrame {
|
||||
functionName: string
|
||||
fileName: string
|
||||
lineNumber: string
|
||||
columnNumber: string
|
||||
raw: string
|
||||
}
|
||||
|
||||
function parseStackTrace(stack: string): StackFrame[] {
|
||||
const lines = stack.split('\n').slice(1) // 跳过第一行(错误消息)
|
||||
const frames: StackFrame[] = []
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed.startsWith('at ')) continue
|
||||
|
||||
// 匹配格式: at functionName (fileName:line:column) 或 at fileName:line:column
|
||||
const match = trimmed.match(/at\s+(?:(.+?)\s+\()?(.+?):(\d+):(\d+)\)?$/)
|
||||
if (match) {
|
||||
frames.push({
|
||||
functionName: match[1] || '<anonymous>',
|
||||
fileName: match[2],
|
||||
lineNumber: match[3],
|
||||
columnNumber: match[4],
|
||||
raw: trimmed,
|
||||
})
|
||||
} else {
|
||||
frames.push({
|
||||
functionName: '<unknown>',
|
||||
fileName: '',
|
||||
lineNumber: '',
|
||||
columnNumber: '',
|
||||
raw: trimmed,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return frames
|
||||
}
|
||||
|
||||
// 错误详情展示组件(函数组件,用于使用 hooks)
|
||||
function ErrorDetails({ error, errorInfo }: { error: Error; errorInfo: ErrorInfo | null }) {
|
||||
const [isStackOpen, setIsStackOpen] = useState(true)
|
||||
const [isComponentStackOpen, setIsComponentStackOpen] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const { t } = useTranslation()
|
||||
|
||||
const stackFrames = error.stack ? parseStackTrace(error.stack) : []
|
||||
|
||||
const copyErrorInfo = async () => {
|
||||
const errorText = `
|
||||
Error: ${error.name}
|
||||
Message: ${error.message}
|
||||
|
||||
Stack Trace:
|
||||
${error.stack || 'No stack trace available'}
|
||||
|
||||
Component Stack:
|
||||
${errorInfo?.componentStack || 'No component stack available'}
|
||||
|
||||
URL: ${window.location.href}
|
||||
User Agent: ${navigator.userAgent}
|
||||
Time: ${new Date().toISOString()}
|
||||
`.trim()
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(errorText)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 错误消息 */}
|
||||
<Alert variant="destructive" className="border-red-500/50 bg-red-500/10">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription className="font-mono text-sm">
|
||||
<span className="font-semibold">{error.name}:</span> {error.message}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{/* 堆栈跟踪 */}
|
||||
{stackFrames.length > 0 && (
|
||||
<Collapsible open={isStackOpen} onOpenChange={setIsStackOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" className="w-full justify-between p-3 h-auto">
|
||||
<span className="font-semibold text-sm flex items-center gap-2">
|
||||
<Bug className="h-4 w-4" />
|
||||
Stack Trace ({stackFrames.length} frames)
|
||||
</span>
|
||||
{isStackOpen ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<ScrollArea className="h-[280px] rounded-md border bg-muted/30">
|
||||
<div className="p-3 space-y-1">
|
||||
{stackFrames.map((frame, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="font-mono text-xs p-2 rounded hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-muted-foreground w-6 text-right flex-shrink-0">
|
||||
{index + 1}.
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-primary font-medium">
|
||||
{frame.functionName}
|
||||
</span>
|
||||
{frame.fileName && (
|
||||
<div className="text-muted-foreground mt-0.5 break-all">
|
||||
{frame.fileName}
|
||||
{frame.lineNumber && (
|
||||
<span className="text-yellow-600 dark:text-yellow-400">
|
||||
:{frame.lineNumber}:{frame.columnNumber}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
|
||||
{/* 组件堆栈 */}
|
||||
{errorInfo?.componentStack && (
|
||||
<Collapsible open={isComponentStackOpen} onOpenChange={setIsComponentStackOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" className="w-full justify-between p-3 h-auto">
|
||||
<span className="font-semibold text-sm flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
Component Stack
|
||||
</span>
|
||||
{isComponentStackOpen ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<ScrollArea className="h-[200px] rounded-md border bg-muted/30">
|
||||
<pre className="p-3 font-mono text-xs whitespace-pre-wrap text-muted-foreground">
|
||||
{errorInfo.componentStack}
|
||||
</pre>
|
||||
</ScrollArea>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
|
||||
{/* 复制按钮 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={copyErrorInfo}
|
||||
className="w-full"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="mr-2 h-4 w-4 text-green-500" />
|
||||
{t('errorBoundary.copiedToClipboard')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
{t('errorBoundary.copyError')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 错误回退 UI
|
||||
function ErrorFallback({
|
||||
error,
|
||||
errorInfo,
|
||||
}: {
|
||||
error: Error
|
||||
errorInfo: ErrorInfo | null
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const handleGoHome = () => {
|
||||
window.location.href = '/'
|
||||
}
|
||||
|
||||
const handleRefresh = () => {
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
||||
<Card className="w-full max-w-2xl shadow-lg">
|
||||
<CardHeader className="text-center pb-2">
|
||||
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/30 mb-4">
|
||||
<AlertTriangle className="h-8 w-8 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl font-bold">{t('errorBoundary.title')}</CardTitle>
|
||||
<CardDescription className="text-base mt-2">
|
||||
{t('errorBoundary.description')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
<ErrorDetails error={error} errorInfo={errorInfo} />
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex flex-col sm:flex-row gap-2 pt-2">
|
||||
<Button onClick={handleRefresh} className="flex-1">
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
{t('errorBoundary.refreshPage')}
|
||||
</Button>
|
||||
<Button onClick={handleGoHome} variant="outline" className="flex-1">
|
||||
<Home className="mr-2 h-4 w-4" />
|
||||
{t('errorBoundary.goHome')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 提示信息 */}
|
||||
<p className="text-xs text-center text-muted-foreground pt-2">
|
||||
{t('errorBoundary.footer')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 错误边界类组件
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
this.state = {
|
||||
hasError: false,
|
||||
error: null,
|
||||
errorInfo: null,
|
||||
}
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): Partial<State> {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error('ErrorBoundary caught an error:', error, errorInfo)
|
||||
this.setState({ errorInfo })
|
||||
}
|
||||
|
||||
handleReset = () => {
|
||||
this.setState({
|
||||
hasError: false,
|
||||
error: null,
|
||||
errorInfo: null,
|
||||
})
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError && this.state.error) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback
|
||||
}
|
||||
|
||||
return (
|
||||
<ErrorFallback
|
||||
error={this.state.error}
|
||||
errorInfo={this.state.errorInfo}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
// 路由级别的错误边界组件(用于 TanStack Router)
|
||||
export function RouteErrorBoundary({ error }: { error: Error }) {
|
||||
return (
|
||||
<ErrorFallback
|
||||
error={error}
|
||||
errorInfo={null}
|
||||
/>
|
||||
)
|
||||
}
|
||||
1726
dashboard/src/components/expression-reviewer.tsx
Normal file
1726
dashboard/src/components/expression-reviewer.tsx
Normal file
File diff suppressed because it is too large
Load Diff
61
dashboard/src/components/http-warning-banner.tsx
Normal file
61
dashboard/src/components/http-warning-banner.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { AlertTriangle, X } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
/**
|
||||
* HTTP 警告横幅组件
|
||||
* 当用户通过 HTTP 访问时显示安全警告
|
||||
*/
|
||||
export function HttpWarningBanner() {
|
||||
const { t } = useTranslation()
|
||||
// 直接计算初始状态,避免 effect 中调用 setState
|
||||
const isHttp = window.location.protocol === 'http:'
|
||||
const hostname = window.location.hostname.toLowerCase()
|
||||
const isLocalhost = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1'
|
||||
const dismissed = sessionStorage.getItem('http-warning-dismissed') === 'true'
|
||||
|
||||
// 本地访问(localhost/127.0.0.1)不显示警告
|
||||
const [isVisible, setIsVisible] = useState(isHttp && !isLocalhost && !dismissed)
|
||||
const [isDismissed, setIsDismissed] = useState(false)
|
||||
|
||||
const handleDismiss = () => {
|
||||
setIsDismissed(true)
|
||||
setIsVisible(false)
|
||||
sessionStorage.setItem('http-warning-dismissed', 'true')
|
||||
}
|
||||
|
||||
if (!isVisible || isDismissed) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative bg-amber-500/10 border-b border-amber-500/20 backdrop-blur-sm">
|
||||
<div className="container mx-auto px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-600 dark:text-amber-500 flex-shrink-0" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-amber-900 dark:text-amber-100">
|
||||
<span className="font-semibold">{t('httpWarning.title')}</span>
|
||||
{t('httpWarning.message')}
|
||||
</p>
|
||||
<p className="text-xs text-amber-800 dark:text-amber-200 mt-1">
|
||||
{t('httpWarning.description')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleDismiss}
|
||||
className="h-8 w-8 text-amber-700 hover:text-amber-900 dark:text-amber-400 dark:hover:text-amber-200 flex-shrink-0"
|
||||
aria-label={t('httpWarning.dismiss')}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
13
dashboard/src/components/index.ts
Normal file
13
dashboard/src/components/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export { CodeEditor } from './CodeEditor'
|
||||
export type { Language } from './CodeEditor'
|
||||
|
||||
// 重启遮罩层
|
||||
export { RestartOverlay } from './restart-overlay'
|
||||
// 兼容旧版本
|
||||
export { RestartingOverlay } from './RestartingOverlay.legacy'
|
||||
|
||||
// 列表编辑器
|
||||
export { ListFieldEditor } from './ListFieldEditor'
|
||||
|
||||
// Markdown 渲染器
|
||||
export { MarkdownRenderer } from './markdown-renderer'
|
||||
278
dashboard/src/components/layout/Header.tsx
Normal file
278
dashboard/src/components/layout/Header.tsx
Normal file
@@ -0,0 +1,278 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import {
|
||||
BookOpen,
|
||||
ChevronLeft,
|
||||
Globe,
|
||||
LogOut,
|
||||
Menu,
|
||||
MessageSquare,
|
||||
Moon,
|
||||
Search,
|
||||
Server,
|
||||
SlidersHorizontal,
|
||||
Sun,
|
||||
} from 'lucide-react'
|
||||
import { LayoutGroup, motion } from 'motion/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { BackgroundLayer } from '@/components/background-layer'
|
||||
import { BackendManager } from '@/components/electron/BackendManager'
|
||||
import { SearchDialog } from '@/components/search-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { ShortcutKbd } from '@/components/ui/kbd'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { toggleThemeWithTransition } from '@/components/use-theme'
|
||||
import { useBackground } from '@/hooks/use-background'
|
||||
import { logout } from '@/lib/fetch-with-auth'
|
||||
import { isElectron } from '@/lib/runtime'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { WorkspaceMode } from './types'
|
||||
|
||||
const LANGUAGE_CODES = ['zh', 'en', 'ja', 'ko'] as const
|
||||
const LANGUAGE_NAMES: Record<(typeof LANGUAGE_CODES)[number], string> = {
|
||||
zh: '中文',
|
||||
en: 'English',
|
||||
ja: '日本語',
|
||||
ko: '한국어',
|
||||
}
|
||||
|
||||
interface HeaderProps {
|
||||
sidebarOpen: boolean
|
||||
mobileMenuOpen: boolean
|
||||
searchOpen: boolean
|
||||
actualTheme: 'light' | 'dark'
|
||||
onSidebarToggle: () => void
|
||||
onMobileMenuToggle: () => void
|
||||
onSearchOpenChange: (open: boolean) => void
|
||||
onThemeChange: (theme: 'light' | 'dark' | 'system') => void
|
||||
workspaceMode: WorkspaceMode
|
||||
}
|
||||
|
||||
export function Header({
|
||||
sidebarOpen,
|
||||
mobileMenuOpen,
|
||||
searchOpen,
|
||||
actualTheme,
|
||||
onSidebarToggle,
|
||||
onMobileMenuToggle,
|
||||
onSearchOpenChange,
|
||||
onThemeChange,
|
||||
workspaceMode,
|
||||
}: HeaderProps) {
|
||||
const { t, i18n: i18nInstance } = useTranslation()
|
||||
const currentLang = i18nInstance.language || 'zh'
|
||||
const { config: headerBg, inheritedFrom } = useBackground('header')
|
||||
const inheritsPageBackground = inheritedFrom === 'page'
|
||||
const [backendManagerOpen, setBackendManagerOpen] = useState(false)
|
||||
const [activeBackendName, setActiveBackendName] = useState<string>('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!isElectron()) return
|
||||
window.electronAPI!.getActiveBackend().then((b) => {
|
||||
setActiveBackendName(b?.name ?? t('header.notConnected'))
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout()
|
||||
}
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
'sticky top-0 isolate z-10 flex h-16 min-w-0 items-center justify-between gap-2 border-b px-3 backdrop-blur-md sm:px-4',
|
||||
inheritsPageBackground ? 'bg-transparent' : 'bg-card/80'
|
||||
)}
|
||||
>
|
||||
{!inheritsPageBackground && <BackgroundLayer config={headerBg} layerId="header" />}
|
||||
<div className="relative z-10 flex min-w-0 shrink-0 items-center gap-2 sm:gap-4">
|
||||
{/* 移动端菜单按钮 */}
|
||||
<button
|
||||
onClick={onMobileMenuToggle}
|
||||
aria-label={t('a11y.closeMenu')}
|
||||
aria-expanded={mobileMenuOpen}
|
||||
className={cn(
|
||||
'hover:bg-accent rounded-lg p-2 lg:hidden',
|
||||
workspaceMode === 'chat' && 'hidden'
|
||||
)}
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
{/* 桌面端侧边栏收起/展开按钮 */}
|
||||
<button
|
||||
onClick={onSidebarToggle}
|
||||
aria-label={sidebarOpen ? t('header.collapseSidebar') : t('header.expandSidebar')}
|
||||
aria-expanded={sidebarOpen}
|
||||
className={cn(
|
||||
'hover:bg-accent hidden rounded-lg p-2 lg:block',
|
||||
workspaceMode === 'chat' && 'lg:hidden'
|
||||
)}
|
||||
>
|
||||
<ChevronLeft
|
||||
className={cn('h-5 w-5 transition-transform', !sidebarOpen && 'rotate-180')}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 flex min-w-0 flex-1 items-center justify-end gap-1 sm:gap-2">
|
||||
{/* 工作区切换:复用 Tabs 组件 + Motion 动画指示器 */}
|
||||
<LayoutGroup id="workspace-switcher">
|
||||
<Tabs value={workspaceMode} aria-label={t('workspace.switcherLabel')}>
|
||||
<TabsList className="bg-background/60 relative h-9 gap-0.5 border p-1 shadow-sm backdrop-blur">
|
||||
<TabsTrigger
|
||||
asChild
|
||||
value="settings"
|
||||
className="relative h-7 gap-1.5 bg-transparent px-2.5 text-xs font-medium data-[state=active]:bg-transparent data-[state=active]:text-primary-foreground data-[state=active]:shadow-none"
|
||||
>
|
||||
<Link to="/">
|
||||
{workspaceMode === 'settings' && (
|
||||
<motion.span
|
||||
layoutId="workspace-tab-pill"
|
||||
className="bg-primary absolute inset-0 -z-10 rounded-md shadow-sm"
|
||||
transition={{ type: 'spring', stiffness: 480, damping: 38, mass: 0.6 }}
|
||||
/>
|
||||
)}
|
||||
<SlidersHorizontal className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">{t('workspace.settings')}</span>
|
||||
</Link>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
asChild
|
||||
value="chat"
|
||||
className="relative h-7 gap-1.5 bg-transparent px-2.5 text-xs font-medium data-[state=active]:bg-transparent data-[state=active]:text-primary-foreground data-[state=active]:shadow-none"
|
||||
>
|
||||
<Link to="/chat">
|
||||
{workspaceMode === 'chat' && (
|
||||
<motion.span
|
||||
layoutId="workspace-tab-pill"
|
||||
className="bg-primary absolute inset-0 -z-10 rounded-md shadow-sm"
|
||||
transition={{ type: 'spring', stiffness: 480, damping: 38, mass: 0.6 }}
|
||||
/>
|
||||
)}
|
||||
<MessageSquare className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">{t('workspace.chat')}</span>
|
||||
</Link>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</LayoutGroup>
|
||||
|
||||
<div className="bg-border hidden h-6 w-px sm:block" />
|
||||
{/* 后端切换按钮(仅 Electron) */}
|
||||
{isElectron() && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
onClick={() => setBackendManagerOpen(true)}
|
||||
title={t('header.toggleConnection')}
|
||||
>
|
||||
<Server className="h-4 w-4" />
|
||||
<span className="text-muted-foreground hidden max-w-25 truncate text-xs sm:inline">
|
||||
{activeBackendName}
|
||||
</span>
|
||||
</Button>
|
||||
<BackendManager open={backendManagerOpen} onOpenChange={setBackendManagerOpen} />
|
||||
<div className="bg-border h-6 w-px" />
|
||||
</>
|
||||
)}
|
||||
{/* 搜索框 */}
|
||||
<button
|
||||
onClick={() => onSearchOpenChange(true)}
|
||||
aria-label={t('header.searchPlaceholder')}
|
||||
className="bg-background/50 hover:bg-accent/50 relative hidden h-9 w-64 items-center rounded-md border pr-16 pl-9 text-left transition-colors md:flex"
|
||||
>
|
||||
<Search
|
||||
className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="text-muted-foreground text-sm">{t('header.searchPlaceholder')}</span>
|
||||
<ShortcutKbd
|
||||
size="sm"
|
||||
className="absolute top-1/2 right-2 -translate-y-1/2"
|
||||
keys={['mod', 'k']}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* 搜索对话框 */}
|
||||
<SearchDialog open={searchOpen} onOpenChange={onSearchOpenChange} />
|
||||
|
||||
{/* 麦麦文档链接 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => window.open('https://docs.mai-mai.org', '_blank')}
|
||||
className="hidden gap-2 sm:inline-flex"
|
||||
title={t('header.viewDocs')}
|
||||
>
|
||||
<BookOpen className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{t('header.docs')}</span>
|
||||
</Button>
|
||||
|
||||
{/* 语言切换 */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="gap-2 px-2 sm:px-3">
|
||||
<Globe className="h-4 w-4" />
|
||||
<span className="hidden text-xs sm:inline">
|
||||
{LANGUAGE_NAMES[currentLang.split('-')[0] as 'zh' | 'en' | 'ja' | 'ko'] ??
|
||||
currentLang}
|
||||
</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{LANGUAGE_CODES.map((code) => (
|
||||
<DropdownMenuItem
|
||||
key={code}
|
||||
onClick={() => i18nInstance.changeLanguage(code)}
|
||||
className={cn(
|
||||
'cursor-pointer',
|
||||
currentLang.split('-')[0] === code && 'text-primary font-semibold'
|
||||
)}
|
||||
>
|
||||
{currentLang.split('-')[0] === code && <span className="mr-2">✓</span>}
|
||||
{LANGUAGE_NAMES[code]}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* 主题切换按钮 */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
const newTheme = actualTheme === 'dark' ? 'light' : 'dark'
|
||||
toggleThemeWithTransition(newTheme, onThemeChange, e)
|
||||
}}
|
||||
aria-label={actualTheme === 'dark' ? t('header.switchToLight') : t('header.switchToDark')}
|
||||
className="hover:bg-accent rounded-lg p-2"
|
||||
>
|
||||
{actualTheme === 'dark' ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
|
||||
</button>
|
||||
|
||||
{/* 分隔线 */}
|
||||
<div className="bg-border hidden h-6 w-px sm:block" />
|
||||
|
||||
{/* 登出按钮 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleLogout}
|
||||
className="gap-2 px-2 sm:px-3"
|
||||
title={t('header.logout')}
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{t('header.logoutLabel')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
239
dashboard/src/components/layout/Layout.tsx
Normal file
239
dashboard/src/components/layout/Layout.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useRouter, useRouterState } from '@tanstack/react-router'
|
||||
import { AnimatePresence, motion } from 'motion/react'
|
||||
|
||||
import { BackgroundLayer } from '@/components/background-layer'
|
||||
import { BackToTop } from '@/components/back-to-top'
|
||||
import { HttpWarningBanner } from '@/components/http-warning-banner'
|
||||
import { SkipNav } from '@/components/ui/skip-nav'
|
||||
import { useAnnounce } from '@/components/ui/announcer'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { useTheme } from '@/components/use-theme'
|
||||
import { useAuthGuard } from '@/hooks/use-auth'
|
||||
import { useBackground } from '@/hooks/use-background'
|
||||
|
||||
import { TitleBar } from '@/components/electron/TitleBar'
|
||||
import { matchesShortcut } from '@/lib/keyboard'
|
||||
import { isElectron } from '@/lib/runtime'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { menuSections } from './constants'
|
||||
import { Header } from './Header'
|
||||
import { Sidebar } from './Sidebar'
|
||||
import type { LayoutProps } from './types'
|
||||
|
||||
export function Layout({ children }: LayoutProps) {
|
||||
const { t } = useTranslation()
|
||||
const { checking } = useAuthGuard() // 检查认证状态
|
||||
const router = useRouter()
|
||||
const pathname = useRouterState({ select: (state) => state.location.pathname })
|
||||
const announce = useAnnounce()
|
||||
const workspaceMode = pathname.startsWith('/chat') ? 'chat' : 'settings'
|
||||
const isChatWorkspace = workspaceMode === 'chat'
|
||||
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true)
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
const [searchOpen, setSearchOpen] = useState(false)
|
||||
const [tooltipsEnabled, setTooltipsEnabled] = useState(false) // 控制 tooltip 启用状态
|
||||
const { theme, setTheme } = useTheme()
|
||||
|
||||
// 侧边栏状态变化时,延迟启用/禁用 tooltip
|
||||
useEffect(() => {
|
||||
if (sidebarOpen) {
|
||||
// 侧边栏展开时,立即禁用 tooltip
|
||||
setTooltipsEnabled(false)
|
||||
} else {
|
||||
// 侧边栏收起时,等待动画完成后再启用 tooltip
|
||||
const timer = setTimeout(() => {
|
||||
setTooltipsEnabled(true)
|
||||
}, 350) // 稍大于 CSS transition duration (300ms)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [sidebarOpen])
|
||||
|
||||
// 搜索快捷键监听(Cmd/Ctrl + K)
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (matchesShortcut(e, ['mod', 'k'])) {
|
||||
e.preventDefault()
|
||||
setSearchOpen(true)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [])
|
||||
// 路由变更:焦点管理 + 屏幕阅读器播报 + document.title 更新
|
||||
useEffect(() => {
|
||||
// 构建 路径 -> 页面标题 的映射表(以当前语言 t() 翻译)
|
||||
const pathToLabel: Record<string, string> = {}
|
||||
for (const section of menuSections) {
|
||||
for (const item of section.items) {
|
||||
pathToLabel[item.path] = t(item.label)
|
||||
}
|
||||
}
|
||||
pathToLabel['/chat'] = t('workspace.chat')
|
||||
|
||||
return router.subscribe('onResolved', () => {
|
||||
const pageTitle = pathToLabel[router.state.location.pathname] ?? 'MaiBot Dashboard'
|
||||
const fullTitle =
|
||||
pageTitle === 'MaiBot Dashboard' ? 'MaiBot Dashboard' : `${pageTitle} — MaiBot Dashboard`
|
||||
|
||||
// 更新 document.title
|
||||
document.title = fullTitle
|
||||
|
||||
// 屏幕阅读器朗读导航结果
|
||||
announce(t('a11y.navigatedTo', { page: pageTitle }), 'polite')
|
||||
|
||||
// 将焦点移到主内容区(仅当焦点不在其内部时)
|
||||
const mainEl = document.getElementById('main-content')
|
||||
if (mainEl && !mainEl.contains(document.activeElement)) {
|
||||
// requestAnimationFrame 确保 DOM 已渲染完成
|
||||
requestAnimationFrame(() => {
|
||||
mainEl.focus({ preventScroll: true })
|
||||
})
|
||||
}
|
||||
})
|
||||
}, [router, announce, t])
|
||||
|
||||
// 获取实际应用的主题(处理 system 情况)
|
||||
const getActualTheme = () => {
|
||||
if (theme === 'system') {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
}
|
||||
return theme
|
||||
}
|
||||
|
||||
const actualTheme = getActualTheme()
|
||||
const { config: pageBg } = useBackground('page')
|
||||
|
||||
// 认证检查中,显示加载状态
|
||||
if (checking) {
|
||||
return (
|
||||
<div className="bg-background flex h-screen items-center justify-center">
|
||||
<div className="text-muted-foreground">{t('layout.verifyingLogin')}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<SkipNav />
|
||||
{isElectron() && <TitleBar />}
|
||||
<div className={cn('relative isolate flex h-screen overflow-hidden', isElectron() && 'pt-8')}>
|
||||
<BackgroundLayer config={pageBg} layerId="page" />
|
||||
<div className="relative z-10 flex h-full w-full overflow-hidden">
|
||||
{/* Sidebar:仅在设置工作区显示,伴随滑入/滑出动画 */}
|
||||
<AnimatePresence initial={false}>
|
||||
{!isChatWorkspace && (
|
||||
<motion.div
|
||||
key="settings-sidebar"
|
||||
className="relative z-40 hidden shrink-0 lg:block"
|
||||
initial={{ width: 0, opacity: 0 }}
|
||||
animate={{ width: sidebarOpen ? 208 : 64, opacity: 1 }}
|
||||
exit={{ width: 0, opacity: 0 }}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 320,
|
||||
damping: 36,
|
||||
mass: 0.7,
|
||||
opacity: { duration: 0.2 },
|
||||
}}
|
||||
style={{ overflow: 'hidden' }}
|
||||
>
|
||||
<Sidebar
|
||||
sidebarOpen={sidebarOpen}
|
||||
mobileMenuOpen={mobileMenuOpen}
|
||||
tooltipsEnabled={tooltipsEnabled}
|
||||
onMobileMenuClose={() => setMobileMenuOpen(false)}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 移动端 Sidebar 走自己的 fixed 定位,通过 mobileMenuOpen 控制显隐 */}
|
||||
{!isChatWorkspace && (
|
||||
<div className="lg:hidden">
|
||||
<Sidebar
|
||||
sidebarOpen={sidebarOpen}
|
||||
mobileMenuOpen={mobileMenuOpen}
|
||||
tooltipsEnabled={tooltipsEnabled}
|
||||
onMobileMenuClose={() => setMobileMenuOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile overlay */}
|
||||
<AnimatePresence>
|
||||
{!isChatWorkspace && mobileMenuOpen && (
|
||||
<motion.div
|
||||
aria-hidden="true"
|
||||
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.18 }}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{/* Main content */}
|
||||
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
{/* HTTP 安全警告横幅 */}
|
||||
<HttpWarningBanner />
|
||||
|
||||
{/* Topbar */}
|
||||
<Header
|
||||
sidebarOpen={sidebarOpen}
|
||||
mobileMenuOpen={mobileMenuOpen}
|
||||
searchOpen={searchOpen}
|
||||
actualTheme={actualTheme}
|
||||
onSidebarToggle={() => setSidebarOpen(!sidebarOpen)}
|
||||
onMobileMenuToggle={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
onSearchOpenChange={setSearchOpen}
|
||||
onThemeChange={setTheme}
|
||||
workspaceMode={workspaceMode}
|
||||
/>
|
||||
|
||||
{/* Page content */}
|
||||
<main
|
||||
id="main-content"
|
||||
tabIndex={-1}
|
||||
className={cn(
|
||||
'relative isolate flex-1 overflow-hidden outline-none',
|
||||
isChatWorkspace
|
||||
? 'bg-transparent'
|
||||
: pageBg.type === 'none'
|
||||
? 'bg-background'
|
||||
: 'bg-transparent'
|
||||
)}
|
||||
>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.div
|
||||
key={workspaceMode}
|
||||
className="relative z-10 h-full min-w-0"
|
||||
initial={{ opacity: 0, x: isChatWorkspace ? 32 : -32, filter: 'blur(6px)' }}
|
||||
animate={{ opacity: 1, x: 0, filter: 'blur(0px)' }}
|
||||
exit={{ opacity: 0, x: isChatWorkspace ? -32 : 32, filter: 'blur(6px)' }}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 320,
|
||||
damping: 34,
|
||||
mass: 0.7,
|
||||
opacity: { duration: 0.18 },
|
||||
filter: { duration: 0.22 },
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</main>
|
||||
|
||||
{/* Back to Top Button */}
|
||||
{!isChatWorkspace && <BackToTop />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
102
dashboard/src/components/layout/LogoArea.tsx
Normal file
102
dashboard/src/components/layout/LogoArea.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { getDashboardVersionStatus, type DashboardVersionStatus } from '@/lib/system-api'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { APP_VERSION, formatVersion } from '@/lib/version'
|
||||
|
||||
interface LogoAreaProps {
|
||||
sidebarOpen: boolean
|
||||
}
|
||||
|
||||
export function LogoArea({ sidebarOpen }: LogoAreaProps) {
|
||||
const [versionStatus, setVersionStatus] = useState<DashboardVersionStatus | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true
|
||||
|
||||
const loadVersionStatus = async () => {
|
||||
try {
|
||||
const status = await getDashboardVersionStatus(APP_VERSION)
|
||||
if (mounted) {
|
||||
setVersionStatus(status)
|
||||
}
|
||||
} catch (error) {
|
||||
console.debug('检查 WebUI 版本更新失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
void loadVersionStatus()
|
||||
|
||||
return () => {
|
||||
mounted = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const hasUpdate = versionStatus?.has_update === true && Boolean(versionStatus.latest_version)
|
||||
|
||||
return (
|
||||
<div className="flex h-20 items-center border-b px-4">
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex items-center justify-center flex-1 transition-all overflow-hidden',
|
||||
// 移动端始终完整显示,桌面端根据 sidebarOpen 切换
|
||||
'lg:flex-1',
|
||||
!sidebarOpen && 'lg:flex-none lg:w-8'
|
||||
)}
|
||||
>
|
||||
{/* 移动端始终显示完整 Logo,桌面端根据 sidebarOpen 切换 */}
|
||||
<div className={cn(
|
||||
"flex min-w-0 flex-col items-start justify-center gap-1",
|
||||
!sidebarOpen && "lg:hidden"
|
||||
)}>
|
||||
<span className="max-w-full truncate whitespace-nowrap text-xl font-bold text-primary-gradient">
|
||||
MaiBot WebUI
|
||||
</span>
|
||||
<div className="flex max-w-full items-center gap-2 overflow-hidden">
|
||||
<span className="shrink-0 whitespace-nowrap text-sm font-semibold text-primary/70">
|
||||
{formatVersion()}
|
||||
</span>
|
||||
{hasUpdate && (
|
||||
<a
|
||||
href={versionStatus?.pypi_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(
|
||||
"inline-flex h-5 min-w-0 items-center rounded-md border border-amber-400/50 px-2",
|
||||
"bg-amber-400/10 text-[11px] font-semibold text-amber-700",
|
||||
"transition-colors hover:bg-amber-400/20 dark:text-amber-300"
|
||||
)}
|
||||
>
|
||||
<span className="truncate">有更新 v{versionStatus?.latest_version}</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
{false && hasUpdate && (
|
||||
<a
|
||||
href={versionStatus?.pypi_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(
|
||||
"inline-flex h-5 items-center rounded-md border border-amber-400/50 px-2",
|
||||
"bg-amber-400/10 text-[11px] font-semibold text-amber-700",
|
||||
"transition-colors hover:bg-amber-400/20 dark:text-amber-300"
|
||||
)}
|
||||
>
|
||||
有更新 v{versionStatus?.latest_version}
|
||||
</a>
|
||||
)}
|
||||
<div className="hidden">
|
||||
<span className="font-bold text-xl text-primary-gradient whitespace-nowrap">MaiBot WebUI</span>
|
||||
<span className="text-base font-semibold text-primary/70 whitespace-nowrap">
|
||||
{formatVersion()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* 折叠时的 Logo - 仅桌面端显示 */}
|
||||
{!sidebarOpen && (
|
||||
<span className="hidden lg:block font-bold text-primary-gradient text-2xl">M</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
81
dashboard/src/components/layout/NavItem.tsx
Normal file
81
dashboard/src/components/layout/NavItem.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Link, useMatchRoute } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import type { MenuItem } from './types'
|
||||
|
||||
interface NavItemProps {
|
||||
item: MenuItem
|
||||
sidebarOpen: boolean
|
||||
tooltipsEnabled: boolean
|
||||
onMobileMenuClose: () => void
|
||||
}
|
||||
|
||||
export function NavItem({ item, sidebarOpen, tooltipsEnabled, onMobileMenuClose }: NavItemProps) {
|
||||
const { t } = useTranslation()
|
||||
const matchRoute = useMatchRoute()
|
||||
const isActive = matchRoute({ to: item.path })
|
||||
const Icon = item.icon
|
||||
|
||||
const menuItemContent = (
|
||||
<>
|
||||
{/* 左侧高亮条 */}
|
||||
{isActive && (
|
||||
<div className="absolute left-0 top-1/2 h-8 w-1 -translate-y-1/2 rounded-r-full bg-primary transition-opacity duration-300" />
|
||||
)}
|
||||
<div className={cn(
|
||||
'flex items-center transition-all duration-300',
|
||||
sidebarOpen ? 'gap-3' : 'gap-3 lg:gap-0'
|
||||
)}>
|
||||
<Icon
|
||||
className={cn(
|
||||
'h-5 w-5 flex-shrink-0',
|
||||
isActive && 'text-primary'
|
||||
)}
|
||||
strokeWidth={2}
|
||||
fill="none"
|
||||
/>
|
||||
<span className={cn(
|
||||
'text-sm font-medium whitespace-nowrap transition-all duration-300',
|
||||
isActive && 'font-semibold',
|
||||
sidebarOpen
|
||||
? 'opacity-100 max-w-[200px]'
|
||||
: 'opacity-100 max-w-[200px] lg:opacity-0 lg:max-w-0 lg:overflow-hidden'
|
||||
)}>
|
||||
{t(item.label)}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<li className="relative">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
to={item.path}
|
||||
data-tour={item.tourId}
|
||||
className={cn(
|
||||
'relative flex items-center rounded-lg py-2 transition-all duration-300',
|
||||
'hover:bg-accent hover:text-accent-foreground',
|
||||
isActive
|
||||
? 'bg-accent text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
sidebarOpen ? 'px-3' : 'px-3 lg:px-0 lg:justify-center lg:w-12 lg:mx-auto'
|
||||
)}
|
||||
onClick={onMobileMenuClose}
|
||||
>
|
||||
{menuItemContent}
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
{tooltipsEnabled && (
|
||||
<TooltipContent side="right" className="hidden lg:block">
|
||||
<p>{t(item.label)}</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
103
dashboard/src/components/layout/Sidebar.tsx
Normal file
103
dashboard/src/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useBackground } from '@/hooks/use-background'
|
||||
import { BackgroundLayer } from '@/components/background-layer'
|
||||
|
||||
import { LogoArea } from './LogoArea'
|
||||
import { NavItem } from './NavItem'
|
||||
import { menuSections } from './constants'
|
||||
|
||||
interface SidebarProps {
|
||||
sidebarOpen: boolean
|
||||
mobileMenuOpen: boolean
|
||||
tooltipsEnabled: boolean
|
||||
onMobileMenuClose: () => void
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
sidebarOpen,
|
||||
mobileMenuOpen,
|
||||
tooltipsEnabled,
|
||||
onMobileMenuClose
|
||||
}: SidebarProps) {
|
||||
const { t } = useTranslation()
|
||||
const { config: sidebarBg, inheritedFrom } = useBackground('sidebar')
|
||||
const inheritsPageBackground = inheritedFrom === 'page'
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
'fixed inset-y-0 left-0 z-50 isolate flex flex-col border-r transition-all duration-300 lg:relative lg:z-0 lg:h-full',
|
||||
inheritsPageBackground ? 'bg-transparent' : 'bg-card',
|
||||
// 移动端始终显示完整宽度,桌面端根据 sidebarOpen 切换
|
||||
'w-52 lg:w-auto',
|
||||
sidebarOpen ? 'lg:w-52' : 'lg:w-16',
|
||||
mobileMenuOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
|
||||
)}
|
||||
>
|
||||
{!inheritsPageBackground && <BackgroundLayer config={sidebarBg} layerId="sidebar" />}
|
||||
|
||||
{/* Logo 区域 */}
|
||||
<div className="relative z-10">
|
||||
<LogoArea sidebarOpen={sidebarOpen} />
|
||||
</div>
|
||||
|
||||
<ScrollArea className={cn(
|
||||
'relative z-10',
|
||||
"min-h-0 flex-1 overflow-x-hidden",
|
||||
!sidebarOpen && "lg:w-16"
|
||||
)}
|
||||
viewportClassName="[&>div]:!block"
|
||||
>
|
||||
<nav
|
||||
aria-label={t('a11y.sidebarNav')}
|
||||
className={cn(
|
||||
"p-4",
|
||||
!sidebarOpen && "lg:p-2 lg:w-16"
|
||||
)}>
|
||||
<ul className={cn(
|
||||
// 移动端始终使用正常间距,桌面端根据 sidebarOpen 切换
|
||||
"space-y-6",
|
||||
!sidebarOpen && "lg:space-y-3 lg:w-full"
|
||||
)}>
|
||||
{menuSections.map((section, sectionIndex) => (
|
||||
<li key={section.title}>
|
||||
{/* 块标题 - 移动端始终可见,桌面端根据 sidebarOpen 切换 */}
|
||||
<div className={cn(
|
||||
"px-3 h-[1.25rem]",
|
||||
// 移动端始终显示,桌面端根据状态切换
|
||||
"mb-2",
|
||||
!sidebarOpen && "lg:mb-1 lg:invisible"
|
||||
)}>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground/60 whitespace-nowrap">
|
||||
{t(section.title)}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* 分割线 - 仅在桌面端折叠时显示 */}
|
||||
{!sidebarOpen && sectionIndex > 0 && (
|
||||
<div className="hidden lg:block mb-2 border-t border-border" />
|
||||
)}
|
||||
|
||||
{/* 菜单项列表 */}
|
||||
<ul className="space-y-1">
|
||||
{section.items.map((item) => (
|
||||
<NavItem
|
||||
key={item.path}
|
||||
item={item}
|
||||
sidebarOpen={sidebarOpen}
|
||||
tooltipsEnabled={tooltipsEnabled}
|
||||
onMobileMenuClose={onMobileMenuClose}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
</ScrollArea>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
46
dashboard/src/components/layout/constants.ts
Normal file
46
dashboard/src/components/layout/constants.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Activity, Boxes, BrainCircuit, Database, FileSearch, FileText, Hash, Home, MessageSquare, Network, Package, ScrollText, Settings, Sliders, Smile } from 'lucide-react'
|
||||
|
||||
import type { MenuSection } from './types'
|
||||
|
||||
export const menuSections: MenuSection[] = [
|
||||
{
|
||||
title: 'sidebar.groups.overview',
|
||||
items: [
|
||||
{ icon: Home, label: 'sidebar.menu.home', path: '/', searchDescription: 'search.items.homeDesc' },
|
||||
{ icon: Activity, label: 'sidebar.menu.maisakaMonitor', path: '/planner-monitor' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'sidebar.groups.botConfig',
|
||||
items: [
|
||||
{ icon: FileText, label: 'sidebar.menu.botMainConfig', path: '/config/bot', searchDescription: 'search.items.botConfigDesc' },
|
||||
{ icon: Boxes, label: 'sidebar.menu.modelManagement', path: '/config/model', searchDescription: 'search.items.modelDesc', tourId: 'sidebar-model-management' },
|
||||
{ icon: ScrollText, label: 'sidebar.menu.promptManagement', path: '/config/prompts' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'sidebar.groups.botResources',
|
||||
items: [
|
||||
{ icon: Smile, label: 'sidebar.menu.emojiManagement', path: '/resource/emoji', searchDescription: 'search.items.emojiDesc' },
|
||||
{ icon: MessageSquare, label: 'sidebar.menu.expressionManagement', path: '/resource/expression', searchDescription: 'search.items.expressionDesc' },
|
||||
{ icon: Hash, label: 'sidebar.menu.slangManagement', path: '/resource/jargon', searchDescription: 'search.items.jargonDesc' },
|
||||
{ icon: Database, label: 'sidebar.menu.knowledgeBase', path: '/resource/knowledge-base' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'sidebar.groups.extensionsMonitor',
|
||||
items: [
|
||||
{ icon: Sliders, label: 'sidebar.menu.pluginConfig', path: '/plugin-config' },
|
||||
{ icon: Package, label: 'sidebar.menu.pluginMarket', path: '/plugins', searchDescription: 'search.items.pluginsDesc' },
|
||||
{ icon: Network, label: 'sidebar.menu.mcpSettings', path: '/mcp-settings' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'sidebar.groups.system',
|
||||
items: [
|
||||
{ icon: FileSearch, label: 'sidebar.menu.logViewer', path: '/logs', searchDescription: 'search.items.logsDesc' },
|
||||
{ icon: BrainCircuit, label: 'sidebar.menu.reasoningProcess', path: '/reasoning-process', searchDescription: 'search.items.reasoningProcessDesc' },
|
||||
{ icon: Settings, label: 'sidebar.menu.settings', path: '/settings', searchDescription: 'search.items.settingsDesc' },
|
||||
],
|
||||
},
|
||||
]
|
||||
2
dashboard/src/components/layout/index.ts
Normal file
2
dashboard/src/components/layout/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Layout } from './Layout'
|
||||
export type { LayoutProps, MenuItem, MenuSection } from './types'
|
||||
21
dashboard/src/components/layout/types.ts
Normal file
21
dashboard/src/components/layout/types.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { ComponentType, ReactNode } from 'react'
|
||||
import type { LucideProps } from 'lucide-react'
|
||||
|
||||
export interface LayoutProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export type WorkspaceMode = 'settings' | 'chat'
|
||||
|
||||
export interface MenuItem {
|
||||
icon: ComponentType<LucideProps>
|
||||
label: string
|
||||
path: string
|
||||
searchDescription?: string
|
||||
tourId?: string
|
||||
}
|
||||
|
||||
export interface MenuSection {
|
||||
title: string
|
||||
items: MenuItem[]
|
||||
}
|
||||
134
dashboard/src/components/markdown-renderer.tsx
Normal file
134
dashboard/src/components/markdown-renderer.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkMath from 'remark-math'
|
||||
import rehypeKatex from 'rehype-katex'
|
||||
import 'katex/dist/katex.min.css'
|
||||
import type { ComponentPropsWithoutRef } from 'react'
|
||||
|
||||
interface MarkdownRendererProps {
|
||||
content: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function MarkdownRenderer({ content, className = '' }: MarkdownRendererProps) {
|
||||
return (
|
||||
<div className={`prose prose-sm dark:prose-invert max-w-none ${className}`}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkMath]}
|
||||
rehypePlugins={[rehypeKatex]}
|
||||
components={{
|
||||
// 自定义代码块样式
|
||||
code({ inline, className, children, ...props }: ComponentPropsWithoutRef<'code'> & { inline?: boolean }) {
|
||||
return inline ? (
|
||||
<code className="bg-muted px-1.5 py-0.5 rounded text-sm font-mono" {...props}>
|
||||
{children}
|
||||
</code>
|
||||
) : (
|
||||
<code className={`${className} block bg-muted p-4 rounded-lg overflow-x-auto`} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
},
|
||||
// 自定义表格样式
|
||||
table({ children, ...props }) {
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="border-collapse border border-border" {...props}>
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
th({ children, ...props }) {
|
||||
return (
|
||||
<th className="border border-border bg-muted px-4 py-2 text-left font-semibold" {...props}>
|
||||
{children}
|
||||
</th>
|
||||
)
|
||||
},
|
||||
td({ children, ...props }) {
|
||||
return (
|
||||
<td className="border border-border px-4 py-2" {...props}>
|
||||
{children}
|
||||
</td>
|
||||
)
|
||||
},
|
||||
// 自定义链接样式
|
||||
a({ children, ...props }) {
|
||||
return (
|
||||
<a className="text-primary hover:underline" target="_blank" rel="noopener noreferrer" {...props}>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
},
|
||||
// 自定义引用块样式
|
||||
blockquote({ children, ...props }) {
|
||||
return (
|
||||
<blockquote className="border-l-4 border-primary pl-4 italic text-muted-foreground" {...props}>
|
||||
{children}
|
||||
</blockquote>
|
||||
)
|
||||
},
|
||||
// 自定义标题样式
|
||||
h1({ children, ...props }) {
|
||||
return (
|
||||
<h1 className="text-3xl font-bold mt-6 mb-4" {...props}>
|
||||
{children}
|
||||
</h1>
|
||||
)
|
||||
},
|
||||
h2({ children, ...props }) {
|
||||
return (
|
||||
<h2 className="text-2xl font-bold mt-5 mb-3" {...props}>
|
||||
{children}
|
||||
</h2>
|
||||
)
|
||||
},
|
||||
h3({ children, ...props }) {
|
||||
return (
|
||||
<h3 className="text-xl font-bold mt-4 mb-2" {...props}>
|
||||
{children}
|
||||
</h3>
|
||||
)
|
||||
},
|
||||
h4({ children, ...props }) {
|
||||
return (
|
||||
<h4 className="text-lg font-semibold mt-3 mb-2" {...props}>
|
||||
{children}
|
||||
</h4>
|
||||
)
|
||||
},
|
||||
// 自定义列表样式
|
||||
ul({ children, ...props }) {
|
||||
return (
|
||||
<ul className="list-disc list-inside space-y-1 my-2" {...props}>
|
||||
{children}
|
||||
</ul>
|
||||
)
|
||||
},
|
||||
ol({ children, ...props }) {
|
||||
return (
|
||||
<ol className="list-decimal list-inside space-y-1 my-2" {...props}>
|
||||
{children}
|
||||
</ol>
|
||||
)
|
||||
},
|
||||
// 自定义段落样式
|
||||
p({ children, ...props }) {
|
||||
return (
|
||||
<p className="my-2 leading-relaxed" {...props}>
|
||||
{children}
|
||||
</p>
|
||||
)
|
||||
},
|
||||
// 自定义分隔线样式
|
||||
hr({ ...props }) {
|
||||
return <hr className="my-4 border-border" {...props} />
|
||||
},
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
311
dashboard/src/components/memory/MemoryConfigEditor.tsx
Normal file
311
dashboard/src/components/memory/MemoryConfigEditor.tsx
Normal file
@@ -0,0 +1,311 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { ListFieldEditor } from '@/components/ListFieldEditor'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import type { ConfigFieldSchema, PluginConfigSchema } from '@/lib/plugin-api'
|
||||
|
||||
interface MemoryConfigEditorProps {
|
||||
schema: PluginConfigSchema
|
||||
config: Record<string, unknown>
|
||||
onChange: (nextConfig: Record<string, unknown>) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
function getNestedRecord(config: Record<string, unknown>, path: string): Record<string, unknown> | undefined {
|
||||
const parts = path.split('.').filter(Boolean)
|
||||
let current: unknown = config
|
||||
|
||||
for (const part of parts) {
|
||||
if (!current || typeof current !== 'object' || Array.isArray(current)) {
|
||||
return undefined
|
||||
}
|
||||
current = (current as Record<string, unknown>)[part]
|
||||
}
|
||||
|
||||
if (!current || typeof current !== 'object' || Array.isArray(current)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return current as Record<string, unknown>
|
||||
}
|
||||
|
||||
function setNestedField(
|
||||
config: Record<string, unknown>,
|
||||
path: string,
|
||||
fieldName: string,
|
||||
value: unknown,
|
||||
): Record<string, unknown> {
|
||||
const parts = path.split('.').filter(Boolean)
|
||||
const nextConfig: Record<string, unknown> = { ...config }
|
||||
let target = nextConfig
|
||||
let source: Record<string, unknown> | undefined = config
|
||||
|
||||
for (const part of parts) {
|
||||
const sourceValue: unknown = source?.[part]
|
||||
const nextValue =
|
||||
sourceValue && typeof sourceValue === 'object' && !Array.isArray(sourceValue)
|
||||
? { ...(sourceValue as Record<string, unknown>) }
|
||||
: {}
|
||||
target[part] = nextValue
|
||||
target = nextValue
|
||||
source =
|
||||
sourceValue && typeof sourceValue === 'object' && !Array.isArray(sourceValue)
|
||||
? (sourceValue as Record<string, unknown>)
|
||||
: undefined
|
||||
}
|
||||
|
||||
target[fieldName] = value
|
||||
return nextConfig
|
||||
}
|
||||
|
||||
function FieldRenderer({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
field: ConfigFieldSchema
|
||||
value: unknown
|
||||
onChange: (value: unknown) => void
|
||||
disabled?: boolean
|
||||
}) {
|
||||
const [jsonDraft, setJsonDraft] = useState(
|
||||
typeof value === 'string' ? String(value) : JSON.stringify(value ?? field.default ?? {}, null, 2),
|
||||
)
|
||||
|
||||
switch (field.ui_type) {
|
||||
case 'switch':
|
||||
return (
|
||||
<div className="flex items-center justify-between rounded-lg border bg-background px-4 py-3">
|
||||
<div className="space-y-1">
|
||||
<Label>{field.label}</Label>
|
||||
{field.hint && <p className="text-xs text-muted-foreground">{field.hint}</p>}
|
||||
</div>
|
||||
<Switch
|
||||
checked={Boolean(value ?? field.default)}
|
||||
onCheckedChange={onChange}
|
||||
disabled={disabled || field.disabled}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'number':
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label>{field.label}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={String(value ?? field.default ?? '')}
|
||||
onChange={(event) => onChange(Number(event.target.value))}
|
||||
min={field.min}
|
||||
max={field.max}
|
||||
step={field.step ?? 1}
|
||||
disabled={disabled || field.disabled}
|
||||
placeholder={field.placeholder}
|
||||
/>
|
||||
{field.hint && <p className="text-xs text-muted-foreground">{field.hint}</p>}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'select':
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label>{field.label}</Label>
|
||||
<Select
|
||||
value={String(value ?? field.default ?? '')}
|
||||
onValueChange={onChange}
|
||||
disabled={disabled || field.disabled}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={field.placeholder ?? '请选择'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(field.choices ?? []).map((choice) => (
|
||||
<SelectItem key={String(choice)} value={String(choice)}>
|
||||
{String(choice)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{field.hint && <p className="text-xs text-muted-foreground">{field.hint}</p>}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'textarea':
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label>{field.label}</Label>
|
||||
<Textarea
|
||||
value={String(value ?? field.default ?? '')}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
rows={field.rows ?? 4}
|
||||
placeholder={field.placeholder}
|
||||
disabled={disabled || field.disabled}
|
||||
/>
|
||||
{field.hint && <p className="text-xs text-muted-foreground">{field.hint}</p>}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'list':
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label>{field.label}</Label>
|
||||
<ListFieldEditor
|
||||
value={Array.isArray(value) ? value : (Array.isArray(field.default) ? field.default : [])}
|
||||
onChange={onChange as (value: unknown[]) => void}
|
||||
itemType={field.item_type}
|
||||
itemFields={field.item_fields}
|
||||
minItems={field.min_items}
|
||||
maxItems={field.max_items}
|
||||
placeholder={field.placeholder}
|
||||
disabled={disabled || field.disabled}
|
||||
/>
|
||||
{field.hint && <p className="text-xs text-muted-foreground">{field.hint}</p>}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'json':
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label>{field.label}</Label>
|
||||
<Textarea
|
||||
value={jsonDraft}
|
||||
rows={field.rows ?? 6}
|
||||
disabled={disabled || field.disabled}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value
|
||||
setJsonDraft(nextValue)
|
||||
try {
|
||||
onChange(JSON.parse(nextValue))
|
||||
} catch {
|
||||
// keep draft until valid JSON
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{field.hint && <p className="text-xs text-muted-foreground">{field.hint}</p>}
|
||||
</div>
|
||||
)
|
||||
|
||||
default:
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label>{field.label}</Label>
|
||||
<Input
|
||||
value={String(value ?? field.default ?? '')}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
disabled={disabled || field.disabled}
|
||||
placeholder={field.placeholder}
|
||||
/>
|
||||
{field.hint && <p className="text-xs text-muted-foreground">{field.hint}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function SectionCard({
|
||||
sectionName,
|
||||
schema,
|
||||
config,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
sectionName: string
|
||||
schema: PluginConfigSchema
|
||||
config: Record<string, unknown>
|
||||
onChange: (nextConfig: Record<string, unknown>) => void
|
||||
disabled?: boolean
|
||||
}) {
|
||||
const section = schema.sections[sectionName]
|
||||
if (!section) {
|
||||
return null
|
||||
}
|
||||
|
||||
const sectionValues = getNestedRecord(config, sectionName) ?? {}
|
||||
const orderedFields = Object.values(section.fields).sort((left, right) => left.order - right.order)
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{section.title}</CardTitle>
|
||||
{section.description && <CardDescription>{section.description}</CardDescription>}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{orderedFields.map((field) => (
|
||||
<FieldRenderer
|
||||
key={`${sectionName}.${field.name}`}
|
||||
field={field}
|
||||
value={sectionValues[field.name]}
|
||||
disabled={disabled}
|
||||
onChange={(value) => onChange(setNestedField(config, sectionName, field.name, value))}
|
||||
/>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export function MemoryConfigEditor({ schema, config, onChange, disabled }: MemoryConfigEditorProps) {
|
||||
const tabs = useMemo(
|
||||
() => [...(schema.layout.tabs ?? [])].sort((left, right) => left.order - right.order),
|
||||
[schema.layout.tabs],
|
||||
)
|
||||
|
||||
if (tabs.length === 0) {
|
||||
const orderedSections = Object.keys(schema.sections).sort(
|
||||
(left, right) => (schema.sections[left]?.order ?? 0) - (schema.sections[right]?.order ?? 0),
|
||||
)
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{orderedSections.map((sectionName) => (
|
||||
<SectionCard
|
||||
key={sectionName}
|
||||
sectionName={sectionName}
|
||||
schema={schema}
|
||||
config={config}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs defaultValue={tabs[0]?.id} className="space-y-4">
|
||||
<TabsList className="h-auto flex-wrap justify-start">
|
||||
{tabs.map((tab) => (
|
||||
<TabsTrigger key={tab.id} value={tab.id}>
|
||||
{tab.title}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{tabs.map((tab) => (
|
||||
<TabsContent key={tab.id} value={tab.id} className="space-y-4">
|
||||
{tab.sections.map((sectionName) => (
|
||||
<SectionCard
|
||||
key={sectionName}
|
||||
sectionName={sectionName}
|
||||
schema={schema}
|
||||
config={config}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
281
dashboard/src/components/memory/MemoryDeleteDialog.tsx
Normal file
281
dashboard/src/components/memory/MemoryDeleteDialog.tsx
Normal file
@@ -0,0 +1,281 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { AlertTriangle, RotateCcw, Search, Trash2 } from 'lucide-react'
|
||||
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import type {
|
||||
MemoryDeleteExecutePayload,
|
||||
MemoryDeletePreviewItemPayload,
|
||||
MemoryDeletePreviewPayload,
|
||||
} from '@/lib/memory-api'
|
||||
|
||||
const DELETE_PREVIEW_PAGE_SIZE = 8
|
||||
|
||||
function formatMode(mode: string): string {
|
||||
switch (mode) {
|
||||
case 'entity':
|
||||
return '实体删除'
|
||||
case 'relation':
|
||||
return '关系删除'
|
||||
case 'paragraph':
|
||||
return '段落删除'
|
||||
case 'source':
|
||||
return '来源删除'
|
||||
case 'mixed':
|
||||
return '混合删除'
|
||||
default:
|
||||
return mode || '删除'
|
||||
}
|
||||
}
|
||||
|
||||
function formatCountLabel(label: string, value: number): string {
|
||||
return `${label} ${value}`
|
||||
}
|
||||
|
||||
function PreviewItemList({ items }: { items: MemoryDeletePreviewItemPayload[] }) {
|
||||
if (items.length <= 0) {
|
||||
return <p className="text-sm text-muted-foreground">当前预览没有可展示的明细项。</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{items.slice(0, 16).map((item) => (
|
||||
<div key={`${item.item_type}:${item.item_hash}:${item.item_key ?? ''}`} className="rounded-lg border bg-muted/30 p-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline">{item.item_type}</Badge>
|
||||
{item.source ? <Badge variant="secondary">{item.source}</Badge> : null}
|
||||
</div>
|
||||
<div className="mt-2 text-sm font-medium break-words">{item.label || item.item_key || item.item_hash}</div>
|
||||
{item.preview ? <div className="mt-1 text-xs text-muted-foreground break-words">{item.preview}</div> : null}
|
||||
<code className="mt-2 block break-all text-[11px] text-muted-foreground">{item.item_hash || item.item_key}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface MemoryDeleteDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
title: string
|
||||
description?: string
|
||||
preview: MemoryDeletePreviewPayload | null
|
||||
result: MemoryDeleteExecutePayload | null
|
||||
loadingPreview?: boolean
|
||||
executing?: boolean
|
||||
restoring?: boolean
|
||||
error?: string | null
|
||||
onExecute: () => void
|
||||
onRestore?: () => void
|
||||
}
|
||||
|
||||
export function MemoryDeleteDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
preview,
|
||||
result,
|
||||
loadingPreview = false,
|
||||
executing = false,
|
||||
restoring = false,
|
||||
error,
|
||||
onExecute,
|
||||
onRestore,
|
||||
}: MemoryDeleteDialogProps) {
|
||||
const [itemSearch, setItemSearch] = useState('')
|
||||
const [itemPage, setItemPage] = useState(1)
|
||||
const counts = preview?.counts ?? result?.counts ?? {}
|
||||
const previewSources = Array.isArray(preview?.sources) ? preview.sources : []
|
||||
const previewItems = Array.isArray(preview?.items) ? preview.items : []
|
||||
const filteredPreviewItems = useMemo(() => {
|
||||
const keyword = itemSearch.trim().toLowerCase()
|
||||
if (!keyword) {
|
||||
return previewItems
|
||||
}
|
||||
return previewItems.filter((item) =>
|
||||
[
|
||||
item.item_type,
|
||||
item.item_hash,
|
||||
item.item_key,
|
||||
item.label,
|
||||
item.preview,
|
||||
item.source,
|
||||
]
|
||||
.map((value) => String(value ?? '').toLowerCase())
|
||||
.some((value) => value.includes(keyword)),
|
||||
)
|
||||
}, [itemSearch, previewItems])
|
||||
const itemPageCount = Math.max(1, Math.ceil(filteredPreviewItems.length / DELETE_PREVIEW_PAGE_SIZE))
|
||||
const pagedPreviewItems = useMemo(() => {
|
||||
const start = (itemPage - 1) * DELETE_PREVIEW_PAGE_SIZE
|
||||
return filteredPreviewItems.slice(start, start + DELETE_PREVIEW_PAGE_SIZE)
|
||||
}, [filteredPreviewItems, itemPage])
|
||||
const countBadges = [
|
||||
{ key: 'entities', label: '实体', value: Number(counts.entities ?? 0) },
|
||||
{ key: 'relations', label: '关系', value: Number(counts.relations ?? 0) },
|
||||
{ key: 'paragraphs', label: '段落', value: Number(counts.paragraphs ?? 0) },
|
||||
{ key: 'sources', label: '来源', value: Number(counts.sources ?? 0) },
|
||||
].filter((item) => item.value > 0)
|
||||
|
||||
useEffect(() => {
|
||||
setItemSearch('')
|
||||
setItemPage(1)
|
||||
}, [preview?.mode, preview?.item_count, open])
|
||||
|
||||
useEffect(() => {
|
||||
setItemPage(1)
|
||||
}, [itemSearch])
|
||||
|
||||
useEffect(() => {
|
||||
if (itemPage > itemPageCount) {
|
||||
setItemPage(itemPageCount)
|
||||
}
|
||||
}, [itemPage, itemPageCount])
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl max-h-[85vh] grid grid-rows-[auto_1fr_auto]" confirmOnEnter>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-500" />
|
||||
{title}
|
||||
</DialogTitle>
|
||||
{description ? <DialogDescription>{description}</DialogDescription> : null}
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody className="space-y-4 overflow-y-auto">
|
||||
{loadingPreview ? (
|
||||
<div className="rounded-lg border bg-muted/30 p-4 text-sm text-muted-foreground">正在生成删除预览...</div>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{preview ? (
|
||||
<>
|
||||
<div className="rounded-xl border bg-muted/30 p-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge>{formatMode(preview.mode)}</Badge>
|
||||
<Badge variant="secondary">{formatCountLabel('预览项', Number(preview.item_count ?? previewItems.length))}</Badge>
|
||||
{countBadges.map((item) => (
|
||||
<Badge key={item.key} variant="outline">
|
||||
{formatCountLabel(item.label, item.value)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
{previewSources.length > 0 ? (
|
||||
<div className="mt-3 text-sm text-muted-foreground break-words">
|
||||
关联来源:{previewSources.join('、')}
|
||||
</div>
|
||||
) : null}
|
||||
{preview.matched_source_count ? (
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
命中来源 {preview.matched_source_count}
|
||||
{preview.requested_source_count ? ` / 请求来源 ${preview.requested_source_count}` : ''}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-semibold">本次将删除的对象摘要</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
命中 {filteredPreviewItems.length} / {previewItems.length} 项
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 md:min-w-[300px]">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={itemSearch}
|
||||
onChange={(event) => setItemSearch(event.target.value)}
|
||||
placeholder="搜索类型 / hash / item_key / source"
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>第 {itemPage} / {itemPageCount} 页</span>
|
||||
<span>每页 {DELETE_PREVIEW_PAGE_SIZE} 项</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea className="h-[320px] rounded-lg border bg-background/60">
|
||||
<div className="p-3">
|
||||
<PreviewItemList items={pagedPreviewItems} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setItemPage((current) => Math.max(1, current - 1))}
|
||||
disabled={itemPage <= 1}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
支持按对象类型、hash、item_key、source 和预览内容检索
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setItemPage((current) => Math.min(itemPageCount, current + 1))}
|
||||
disabled={itemPage >= itemPageCount}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{result?.success ? (
|
||||
<Alert>
|
||||
<AlertDescription className="space-y-1">
|
||||
<div>删除执行成功,操作 ID:<code>{result.operation_id}</code></div>
|
||||
<div>
|
||||
实际删除:实体 {result.deleted_entity_count},关系 {result.deleted_relation_count},段落 {result.deleted_paragraph_count},来源 {result.deleted_source_count}
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
关闭
|
||||
</Button>
|
||||
{result?.success && onRestore ? (
|
||||
<Button variant="outline" onClick={onRestore} disabled={restoring}>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
{restoring ? '恢复中...' : '恢复本次删除'}
|
||||
</Button>
|
||||
) : null}
|
||||
{!result?.success ? (
|
||||
<Button data-dialog-action="confirm" variant="destructive" onClick={onExecute} disabled={loadingPreview || executing || !preview}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{executing ? '执行中...' : '确认删除'}
|
||||
</Button>
|
||||
) : null}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
518
dashboard/src/components/memory/MemoryEpisodeManager.tsx
Normal file
518
dashboard/src/components/memory/MemoryEpisodeManager.tsx
Normal file
@@ -0,0 +1,518 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ChevronDown, Loader2, Play, RefreshCw, RotateCcw, Search } from 'lucide-react'
|
||||
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { useToast } from '@/hooks/use-toast'
|
||||
import {
|
||||
getMemoryEpisode,
|
||||
getMemoryEpisodes,
|
||||
getMemoryEpisodeStatus,
|
||||
processMemoryEpisodePending,
|
||||
rebuildMemoryEpisodes,
|
||||
type MemoryEpisodeDetailPayload,
|
||||
type MemoryEpisodeItemPayload,
|
||||
type MemoryEpisodeParagraphPayload,
|
||||
type MemoryEpisodeStatusPayload,
|
||||
} from '@/lib/memory-api'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function formatMemoryTime(timestamp?: number | null): string {
|
||||
if (!timestamp) {
|
||||
return '-'
|
||||
}
|
||||
const normalized = timestamp > 1_000_000_000_000 ? timestamp : timestamp * 1000
|
||||
const value = new Date(normalized)
|
||||
if (Number.isNaN(value.getTime())) {
|
||||
return '-'
|
||||
}
|
||||
return value.toLocaleString('zh-CN', {
|
||||
hour12: false,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function parseOptionalNumber(value: string): number | undefined {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) {
|
||||
return undefined
|
||||
}
|
||||
const parsed = Number(trimmed)
|
||||
return Number.isFinite(parsed) ? parsed : undefined
|
||||
}
|
||||
|
||||
function parsePositiveInt(value: string, fallback: number): number {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
return fallback
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
function getEpisodeId(item: MemoryEpisodeItemPayload | null | undefined): string {
|
||||
return String(item?.episode_id ?? item?.id ?? '')
|
||||
}
|
||||
|
||||
function getEpisodeTitle(item: MemoryEpisodeItemPayload): string {
|
||||
return String(item.title ?? item.summary ?? item.content ?? getEpisodeId(item) ?? '未命名 Episode')
|
||||
}
|
||||
|
||||
function getEpisodeParagraphs(
|
||||
item: MemoryEpisodeItemPayload | MemoryEpisodeDetailPayload['episode'] | null | undefined,
|
||||
): MemoryEpisodeParagraphPayload[] {
|
||||
const paragraphs = item?.paragraphs
|
||||
return Array.isArray(paragraphs) ? paragraphs : []
|
||||
}
|
||||
|
||||
function getStatusCount(status: MemoryEpisodeStatusPayload | null, key: string): number {
|
||||
const counts = status?.counts
|
||||
if (counts && typeof counts[key] === 'number') {
|
||||
return counts[key]
|
||||
}
|
||||
const value = status?.[key]
|
||||
return typeof value === 'number' ? value : 0
|
||||
}
|
||||
|
||||
export function MemoryEpisodeManager() {
|
||||
const { toast } = useToast()
|
||||
const [query, setQuery] = useState('')
|
||||
const [source, setSource] = useState('')
|
||||
const [platform, setPlatform] = useState('')
|
||||
const [userId, setUserId] = useState('')
|
||||
const [personId, setPersonId] = useState('')
|
||||
const [showAdvancedPersonId, setShowAdvancedPersonId] = useState(false)
|
||||
const [showRawEpisodePayload, setShowRawEpisodePayload] = useState(false)
|
||||
const [timeStart, setTimeStart] = useState('')
|
||||
const [timeEnd, setTimeEnd] = useState('')
|
||||
const [limit, setLimit] = useState('20')
|
||||
const [items, setItems] = useState<MemoryEpisodeItemPayload[]>([])
|
||||
const [status, setStatus] = useState<MemoryEpisodeStatusPayload | null>(null)
|
||||
const [selectedId, setSelectedId] = useState('')
|
||||
const [detail, setDetail] = useState<MemoryEpisodeDetailPayload | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [detailLoading, setDetailLoading] = useState(false)
|
||||
const [actionLoading, setActionLoading] = useState(false)
|
||||
const [rebuildSource, setRebuildSource] = useState('')
|
||||
const [rebuildSources, setRebuildSources] = useState('')
|
||||
const [rebuildAll, setRebuildAll] = useState(false)
|
||||
const [pendingLimit, setPendingLimit] = useState('20')
|
||||
const [pendingMaxRetry, setPendingMaxRetry] = useState('3')
|
||||
const initialLoadedRef = useRef(false)
|
||||
|
||||
const selectedEpisode = useMemo(() => detail?.episode ?? items.find((item) => getEpisodeId(item) === selectedId), [detail?.episode, items, selectedId])
|
||||
const selectedEpisodeParagraphs = useMemo(() => getEpisodeParagraphs(selectedEpisode), [selectedEpisode])
|
||||
const failedItems = Array.isArray(status?.failed) ? status.failed : []
|
||||
|
||||
const loadStatus = useCallback(async () => {
|
||||
const payload = await getMemoryEpisodeStatus(parsePositiveInt(limit, 20))
|
||||
setStatus(payload)
|
||||
}, [limit])
|
||||
|
||||
const loadEpisodes = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const directPersonId = showAdvancedPersonId ? personId.trim() : ''
|
||||
const [listPayload] = await Promise.all([
|
||||
getMemoryEpisodes({
|
||||
query: query.trim(),
|
||||
source: source.trim(),
|
||||
platform: platform.trim(),
|
||||
userId: userId.trim(),
|
||||
personId: directPersonId,
|
||||
limit: parsePositiveInt(limit, 20),
|
||||
timeStart: parseOptionalNumber(timeStart),
|
||||
timeEnd: parseOptionalNumber(timeEnd),
|
||||
}),
|
||||
loadStatus(),
|
||||
])
|
||||
const nextItems = listPayload.items ?? []
|
||||
setItems(nextItems)
|
||||
if (!selectedId && nextItems.length > 0) {
|
||||
setSelectedId(getEpisodeId(nextItems[0]))
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '加载情节记忆失败',
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [limit, loadStatus, personId, platform, query, selectedId, showAdvancedPersonId, source, timeEnd, timeStart, toast, userId])
|
||||
|
||||
const loadDetail = useCallback(async (episodeId: string) => {
|
||||
if (!episodeId) {
|
||||
setDetail(null)
|
||||
return
|
||||
}
|
||||
setDetailLoading(true)
|
||||
try {
|
||||
const payload = await getMemoryEpisode(episodeId)
|
||||
setDetail(payload)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '加载 Episode 详情失败',
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setDetailLoading(false)
|
||||
}
|
||||
}, [toast])
|
||||
|
||||
useEffect(() => {
|
||||
if (initialLoadedRef.current) {
|
||||
return
|
||||
}
|
||||
initialLoadedRef.current = true
|
||||
void loadEpisodes()
|
||||
}, [loadEpisodes])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedId) {
|
||||
void loadDetail(selectedId)
|
||||
}
|
||||
}, [loadDetail, selectedId])
|
||||
|
||||
const submitRebuild = useCallback(async () => {
|
||||
if (rebuildAll && !window.confirm('确认重建全部可用来源的 Episode?这个操作可能耗时较长。')) {
|
||||
return
|
||||
}
|
||||
const sources = rebuildSources
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
setActionLoading(true)
|
||||
try {
|
||||
const payload = await rebuildMemoryEpisodes({
|
||||
source: rebuildSource.trim(),
|
||||
sources,
|
||||
all: rebuildAll,
|
||||
})
|
||||
toast({
|
||||
title: payload.success ? 'Episode 重建已提交' : 'Episode 重建失败',
|
||||
description: String(payload.detail ?? payload.error ?? `影响来源 ${payload.rebuilt ?? 0} 个`),
|
||||
variant: payload.success ? 'default' : 'destructive',
|
||||
})
|
||||
await loadEpisodes()
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Episode 重建失败',
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setActionLoading(false)
|
||||
}
|
||||
}, [loadEpisodes, rebuildAll, rebuildSource, rebuildSources, toast])
|
||||
|
||||
const submitProcessPending = useCallback(async () => {
|
||||
setActionLoading(true)
|
||||
try {
|
||||
const payload = await processMemoryEpisodePending({
|
||||
limit: parsePositiveInt(pendingLimit, 20),
|
||||
max_retry: parsePositiveInt(pendingMaxRetry, 3),
|
||||
})
|
||||
toast({
|
||||
title: payload.success ? '已处理待生成 Episode' : '处理待生成 Episode 失败',
|
||||
description: String(payload.detail ?? payload.error ?? `已处理 ${payload.processed ?? 0} 项`),
|
||||
variant: payload.success ? 'default' : 'destructive',
|
||||
})
|
||||
await loadEpisodes()
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '处理待生成 Episode 失败',
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setActionLoading(false)
|
||||
}
|
||||
}, [loadEpisodes, pendingLimit, pendingMaxRetry, toast])
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 xl:grid-cols-4">
|
||||
{[
|
||||
{ label: '待处理队列', value: Number(status?.pending_queue ?? 0) },
|
||||
{ label: '待重建', value: getStatusCount(status, 'pending') },
|
||||
{ label: '运行中', value: getStatusCount(status, 'running') },
|
||||
{ label: '失败来源', value: failedItems.length || getStatusCount(status, 'failed') },
|
||||
].map((item) => (
|
||||
<Card key={item.label}>
|
||||
<CardHeader className="pb-3">
|
||||
<CardDescription>{item.label}</CardDescription>
|
||||
<CardTitle className="text-2xl">{item.value}</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Search className="h-4 w-4" />
|
||||
Episode 查询
|
||||
</CardTitle>
|
||||
<CardDescription>按平台账号、来源和时间范围查看情节记忆构建结果;person_id 查询放在高级入口。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="episode-platform">平台</Label>
|
||||
<Input
|
||||
id="episode-platform"
|
||||
value={platform}
|
||||
onChange={(event) => setPlatform(event.target.value)}
|
||||
placeholder="例如 qq、telegram、webui"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="episode-user-id">用户账号</Label>
|
||||
<Input id="episode-user-id" value={userId} onChange={(event) => setUserId(event.target.value)} placeholder="输入平台侧 user_id" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="episode-query">关键词</Label>
|
||||
<Input id="episode-query" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索摘要或内容" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="episode-source">来源</Label>
|
||||
<Input id="episode-source" value={source} onChange={(event) => setSource(event.target.value)} placeholder="chat_summary:..." />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="episode-limit">数量</Label>
|
||||
<Input id="episode-limit" type="number" value={limit} onChange={(event) => setLimit(event.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="episode-time-start">开始时间戳</Label>
|
||||
<Input id="episode-time-start" value={timeStart} onChange={(event) => setTimeStart(event.target.value)} placeholder="可选" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="episode-time-end">结束时间戳</Label>
|
||||
<Input id="episode-time-end" value={timeEnd} onChange={(event) => setTimeEnd(event.target.value)} placeholder="可选" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Collapsible open={showAdvancedPersonId} onOpenChange={setShowAdvancedPersonId} className="rounded-lg border bg-muted/10">
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" className="flex h-10 w-full justify-between px-3">
|
||||
<span>高级查询</span>
|
||||
<ChevronDown className={cn('h-4 w-4 transition-transform', showAdvancedPersonId && 'rotate-180')} />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-2 border-t px-3 py-3">
|
||||
<Label htmlFor="episode-person">person_id</Label>
|
||||
<Input
|
||||
id="episode-person"
|
||||
value={personId}
|
||||
onChange={(event) => setPersonId(event.target.value)}
|
||||
placeholder="调试或后台管理时直接输入"
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
<Button onClick={() => void loadEpisodes()} disabled={loading}>
|
||||
<RefreshCw className={cn('mr-2 h-4 w-4', loading && 'animate-spin')} />
|
||||
刷新 Episode
|
||||
</Button>
|
||||
|
||||
<ScrollArea className="h-[420px] rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-background">
|
||||
<TableRow>
|
||||
<TableHead>Episode</TableHead>
|
||||
<TableHead>来源</TableHead>
|
||||
<TableHead>更新时间</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.length > 0 ? items.map((item) => {
|
||||
const episodeId = getEpisodeId(item)
|
||||
return (
|
||||
<TableRow
|
||||
key={episodeId || getEpisodeTitle(item)}
|
||||
className={cn('cursor-pointer', selectedId === episodeId && 'bg-muted/60')}
|
||||
onClick={() => setSelectedId(episodeId)}
|
||||
>
|
||||
<TableCell>
|
||||
<div className="max-w-[280px] truncate font-medium">{getEpisodeTitle(item)}</div>
|
||||
{item.person_name || item.person_id ? (
|
||||
<div className="max-w-[280px] truncate text-xs text-muted-foreground">
|
||||
{String(item.person_name || item.person_id)}
|
||||
{item.person_name && item.person_id ? <span className="font-mono"> · {String(item.person_id)}</span> : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="font-mono text-[11px] text-muted-foreground break-all">{episodeId || '-'}</div>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[180px] truncate">{String(item.source ?? '-')}</TableCell>
|
||||
<TableCell>{formatMemoryTime(item.updated_at ?? item.created_at)}</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
}) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="text-center text-muted-foreground">
|
||||
{loading ? '正在加载 Episode...' : '没有匹配的 Episode'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Episode 详情</CardTitle>
|
||||
<CardDescription>查看情节摘要、原始字段和关联段落。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{detailLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
正在加载详情
|
||||
</div>
|
||||
) : selectedEpisode ? (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="outline">{getEpisodeId(selectedEpisode) || '无 ID'}</Badge>
|
||||
{selectedEpisode.source ? <Badge variant="secondary">{String(selectedEpisode.source)}</Badge> : null}
|
||||
{selectedEpisode.person_name ? <Badge>{String(selectedEpisode.person_name)}</Badge> : null}
|
||||
{selectedEpisode.person_id ? <Badge variant="outline">{String(selectedEpisode.person_id)}</Badge> : null}
|
||||
</div>
|
||||
<Textarea value={String(selectedEpisode.summary ?? selectedEpisode.content ?? '')} readOnly className="min-h-[120px]" />
|
||||
<Collapsible open={showRawEpisodePayload} onOpenChange={setShowRawEpisodePayload} className="rounded-lg border bg-muted/10">
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" className="flex h-10 w-full justify-between px-3">
|
||||
<span>原始响应 JSON</span>
|
||||
<ChevronDown className={cn('h-4 w-4 transition-transform', showRawEpisodePayload && 'rotate-180')} />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="border-t">
|
||||
<pre className="max-h-56 overflow-auto p-3 text-xs break-words whitespace-pre-wrap">
|
||||
{JSON.stringify(selectedEpisode, null, 2)}
|
||||
</pre>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">关联段落</div>
|
||||
{selectedEpisodeParagraphs.length > 0 ? (
|
||||
<ScrollArea className="h-[220px] rounded-lg border bg-background/60">
|
||||
<div className="space-y-2 p-3">
|
||||
{selectedEpisodeParagraphs.map((paragraph, index) => (
|
||||
<div key={String(paragraph.hash ?? index)} className="rounded-lg border bg-muted/20 p-3">
|
||||
<div className="font-mono text-[11px] text-muted-foreground break-all">{String(paragraph.hash ?? '-')}</div>
|
||||
<div className="mt-2 text-sm break-words">{String(paragraph.preview ?? paragraph.content ?? '')}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
) : (
|
||||
<div className="rounded-lg border border-dashed bg-muted/20 p-4 text-sm text-muted-foreground">当前详情没有段落明细。</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="rounded-lg border border-dashed bg-muted/20 p-6 text-center text-sm text-muted-foreground">选择一个 Episode 查看详情。</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
Episode 运维
|
||||
</CardTitle>
|
||||
<CardDescription>重新生成指定来源的情景记忆,或处理后台尚未生成的 Episode 任务。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
{failedItems.length > 0 ? (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
最近失败来源:{failedItems.slice(0, 3).map((item) => String(item.source ?? item.id ?? item.error ?? '未知')).join('、')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-3 rounded-lg border bg-muted/10 p-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium">重新生成来源 Episode</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
适用于导入内容变化、反馈纠错后,需要用来源下的段落替换旧 Episode 的场景。
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="episode-rebuild-source">来源 ID</Label>
|
||||
<Input
|
||||
id="episode-rebuild-source"
|
||||
value={rebuildSource}
|
||||
onChange={(event) => setRebuildSource(event.target.value)}
|
||||
placeholder="例如 chat_summary:test-webui:coffee"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="episode-rebuild-sources">多个来源 ID</Label>
|
||||
<Input
|
||||
id="episode-rebuild-sources"
|
||||
value={rebuildSources}
|
||||
onChange={(event) => setRebuildSources(event.target.value)}
|
||||
placeholder="用英文逗号分隔多个来源"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={rebuildAll} onChange={(event) => setRebuildAll(event.target.checked)} />
|
||||
重新生成全部可用来源
|
||||
</label>
|
||||
<Button onClick={() => void submitRebuild()} disabled={actionLoading}>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
重新生成 Episode
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 rounded-lg border bg-muted/10 p-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium">处理待生成任务</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
适用于后台已有待生成段落时,手动推进这些段落生成 Episode。
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-[1fr_1fr_auto] md:items-end">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="episode-pending-limit">本次处理上限</Label>
|
||||
<Input id="episode-pending-limit" type="number" value={pendingLimit} onChange={(event) => setPendingLimit(event.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="episode-pending-retry">失败重试上限</Label>
|
||||
<Input id="episode-pending-retry" type="number" value={pendingMaxRetry} onChange={(event) => setPendingMaxRetry(event.target.value)} />
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => void submitProcessPending()} disabled={actionLoading}>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
处理待生成任务
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
325
dashboard/src/components/memory/MemoryMaintenanceManager.tsx
Normal file
325
dashboard/src/components/memory/MemoryMaintenanceManager.tsx
Normal file
@@ -0,0 +1,325 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Lock, RefreshCw, RotateCcw, Shield, Snowflake } from 'lucide-react'
|
||||
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { useToast } from '@/hooks/use-toast'
|
||||
import {
|
||||
freezeMemory,
|
||||
getMemoryRecycleBin,
|
||||
protectMemory,
|
||||
reinforceMemory,
|
||||
restoreMaintainedMemory,
|
||||
type MemoryMaintenanceActionPayload,
|
||||
type MemoryMaintenanceItemPayload,
|
||||
} from '@/lib/memory-api'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type MaintenanceAction = 'reinforce' | 'freeze' | 'protect' | 'restore'
|
||||
|
||||
function formatMemoryTime(timestamp?: number | null): string {
|
||||
if (!timestamp) {
|
||||
return '-'
|
||||
}
|
||||
const normalized = timestamp > 1_000_000_000_000 ? timestamp : timestamp * 1000
|
||||
const value = new Date(normalized)
|
||||
if (Number.isNaN(value.getTime())) {
|
||||
return '-'
|
||||
}
|
||||
return value.toLocaleString('zh-CN', {
|
||||
hour12: false,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function parsePositiveInt(value: string, fallback: number): number {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
return fallback
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
function parseOptionalHours(value: string): number | undefined {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) {
|
||||
return undefined
|
||||
}
|
||||
const parsed = Number(trimmed)
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined
|
||||
}
|
||||
|
||||
function getRelationTarget(item: MemoryMaintenanceItemPayload): string {
|
||||
return String(item.hash ?? item.relation_hash ?? '')
|
||||
}
|
||||
|
||||
function getRelationText(item: MemoryMaintenanceItemPayload): string {
|
||||
const direct = String(item.text ?? '').trim()
|
||||
if (direct) {
|
||||
return direct
|
||||
}
|
||||
return [item.subject, item.predicate, item.object].map((value) => String(value ?? '').trim()).filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
function getActionLabel(action: MaintenanceAction): string {
|
||||
switch (action) {
|
||||
case 'reinforce':
|
||||
return '强化'
|
||||
case 'freeze':
|
||||
return '冻结'
|
||||
case 'protect':
|
||||
return '保护'
|
||||
case 'restore':
|
||||
return '恢复'
|
||||
default:
|
||||
return action
|
||||
}
|
||||
}
|
||||
|
||||
export function MemoryMaintenanceManager() {
|
||||
const { toast } = useToast()
|
||||
const [target, setTarget] = useState('')
|
||||
const [action, setAction] = useState<MaintenanceAction>('reinforce')
|
||||
const [protectHours, setProtectHours] = useState('')
|
||||
const [recycleLimit, setRecycleLimit] = useState('50')
|
||||
const [items, setItems] = useState<MemoryMaintenanceItemPayload[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [actionLoading, setActionLoading] = useState(false)
|
||||
const [itemSearch, setItemSearch] = useState('')
|
||||
const initialLoadedRef = useRef(false)
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
const keyword = itemSearch.trim().toLowerCase()
|
||||
if (!keyword) {
|
||||
return items
|
||||
}
|
||||
return items.filter((item) =>
|
||||
[
|
||||
getRelationTarget(item),
|
||||
getRelationText(item),
|
||||
item.source,
|
||||
item.subject,
|
||||
item.predicate,
|
||||
item.object,
|
||||
].some((value) => String(value ?? '').toLowerCase().includes(keyword)),
|
||||
)
|
||||
}, [itemSearch, items])
|
||||
|
||||
const loadRecycleBin = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const payload = await getMemoryRecycleBin(parsePositiveInt(recycleLimit, 50))
|
||||
setItems(payload.items ?? [])
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '加载记忆回收站失败',
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [recycleLimit, toast])
|
||||
|
||||
useEffect(() => {
|
||||
if (initialLoadedRef.current) {
|
||||
return
|
||||
}
|
||||
initialLoadedRef.current = true
|
||||
void loadRecycleBin()
|
||||
}, [loadRecycleBin])
|
||||
|
||||
const runAction = useCallback(async (nextAction: MaintenanceAction, nextTarget: string) => {
|
||||
const cleanTarget = nextTarget.trim()
|
||||
if (!cleanTarget) {
|
||||
toast({
|
||||
title: '缺少维护目标',
|
||||
description: '请输入关系 hash 或查询文本。',
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
if (nextAction === 'freeze' && !window.confirm('确认冻结命中的记忆关系?冻结后关系会从活跃图谱中移除。')) {
|
||||
return
|
||||
}
|
||||
if (nextAction === 'restore' && !window.confirm('确认恢复命中的记忆关系?')) {
|
||||
return
|
||||
}
|
||||
|
||||
setActionLoading(true)
|
||||
try {
|
||||
let payload: MemoryMaintenanceActionPayload
|
||||
if (nextAction === 'reinforce') {
|
||||
payload = await reinforceMemory(cleanTarget)
|
||||
} else if (nextAction === 'freeze') {
|
||||
payload = await freezeMemory(cleanTarget)
|
||||
} else if (nextAction === 'protect') {
|
||||
payload = await protectMemory(cleanTarget, parseOptionalHours(protectHours))
|
||||
} else {
|
||||
payload = await restoreMaintainedMemory(cleanTarget)
|
||||
}
|
||||
toast({
|
||||
title: payload.success ? `记忆${getActionLabel(nextAction)}完成` : `记忆${getActionLabel(nextAction)}失败`,
|
||||
description: String(payload.detail ?? payload.error ?? ''),
|
||||
variant: payload.success ? 'default' : 'destructive',
|
||||
})
|
||||
await loadRecycleBin()
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: `记忆${getActionLabel(nextAction)}失败`,
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setActionLoading(false)
|
||||
}
|
||||
}, [loadRecycleBin, protectHours, toast])
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="h-4 w-4" />
|
||||
记忆维护操作
|
||||
</CardTitle>
|
||||
<CardDescription>对关系 hash 或查询文本命中的长期记忆执行强化、冻结、保护和恢复。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
维护目标沿用后端解析规则:优先匹配关系 hash,也可以输入查询文本让后端解析命中的关系。
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maintenance-target">维护目标</Label>
|
||||
<Input id="maintenance-target" value={target} onChange={(event) => setTarget(event.target.value)} placeholder="relation hash 或查询文本" />
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>动作</Label>
|
||||
<Select value={action} onValueChange={(value) => setAction(value as MaintenanceAction)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="reinforce">强化</SelectItem>
|
||||
<SelectItem value="freeze">冻结</SelectItem>
|
||||
<SelectItem value="protect">保护</SelectItem>
|
||||
<SelectItem value="restore">恢复</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maintenance-hours">保护时长(小时)</Label>
|
||||
<Input
|
||||
id="maintenance-hours"
|
||||
type="number"
|
||||
value={protectHours}
|
||||
onChange={(event) => setProtectHours(event.target.value)}
|
||||
placeholder="空值表示永久保护"
|
||||
disabled={action !== 'protect'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={() => void runAction(action, target)} disabled={actionLoading}>
|
||||
{action === 'reinforce' ? <Lock className="mr-2 h-4 w-4" /> : null}
|
||||
{action === 'freeze' ? <Snowflake className="mr-2 h-4 w-4" /> : null}
|
||||
{action === 'protect' ? <Shield className="mr-2 h-4 w-4" /> : null}
|
||||
{action === 'restore' ? <RotateCcw className="mr-2 h-4 w-4" /> : null}
|
||||
执行{getActionLabel(action)}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
记忆回收站
|
||||
</CardTitle>
|
||||
<CardDescription>查看已删除关系,并支持按行恢复。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_140px_auto] md:items-end">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maintenance-search">筛选</Label>
|
||||
<Input id="maintenance-search" value={itemSearch} onChange={(event) => setItemSearch(event.target.value)} placeholder="按 hash、主体、谓词、来源筛选" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maintenance-limit">数量</Label>
|
||||
<Input id="maintenance-limit" type="number" value={recycleLimit} onChange={(event) => setRecycleLimit(event.target.value)} />
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => void loadRecycleBin()} disabled={loading}>
|
||||
<RefreshCw className={cn('mr-2 h-4 w-4', loading && 'animate-spin')} />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 text-sm text-muted-foreground">
|
||||
<Badge variant="outline">已加载 {items.length} 条</Badge>
|
||||
<Badge variant="secondary">当前命中 {filteredItems.length} 条</Badge>
|
||||
</div>
|
||||
<ScrollArea className="h-[520px] rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-background">
|
||||
<TableRow>
|
||||
<TableHead>关系</TableHead>
|
||||
<TableHead>删除时间</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredItems.length > 0 ? filteredItems.map((item, index) => {
|
||||
const rowTarget = getRelationTarget(item)
|
||||
return (
|
||||
<TableRow key={`${rowTarget}:${index}`}>
|
||||
<TableCell>
|
||||
<div className="font-medium break-words">{getRelationText(item) || '-'}</div>
|
||||
<div className="mt-1 font-mono text-[11px] text-muted-foreground break-all">{rowTarget || '-'}</div>
|
||||
{item.source ? <Badge variant="outline" className="mt-2">{String(item.source)}</Badge> : null}
|
||||
</TableCell>
|
||||
<TableCell>{formatMemoryTime(item.deleted_at ?? item.updated_at)}</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => void runAction('restore', rowTarget)}
|
||||
disabled={!rowTarget || actionLoading}
|
||||
>
|
||||
恢复
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
}) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="text-center text-muted-foreground">
|
||||
{loading ? '正在加载回收站...' : '回收站没有可展示的关系'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
54
dashboard/src/components/memory/MemoryMiniTabs.tsx
Normal file
54
dashboard/src/components/memory/MemoryMiniTabs.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface MemoryMiniTabItem<TValue extends string> {
|
||||
value: TValue
|
||||
label: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface MemoryMiniTabsProps<TValue extends string> {
|
||||
items: ReadonlyArray<MemoryMiniTabItem<TValue>>
|
||||
className?: string
|
||||
/** 触发器额外样式 */
|
||||
triggerClassName?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 长期记忆控制台统一的迷你标签页样式。
|
||||
*
|
||||
* - 复用 shadcn `Tabs` 原语,仅替换样式以保留无障碍能力(`role="tab"` 与文案不变)。
|
||||
* - 胶囊形外观,激活态使用主色渐变,便于在密集表单上快速定位当前页签。
|
||||
*/
|
||||
export function MemoryMiniTabs<TValue extends string>({
|
||||
items,
|
||||
className,
|
||||
triggerClassName,
|
||||
}: MemoryMiniTabsProps<TValue>) {
|
||||
return (
|
||||
<TabsList
|
||||
className={cn(
|
||||
'h-auto w-full flex-wrap justify-start gap-1.5 rounded-full border border-border/60',
|
||||
'bg-gradient-to-r from-muted/40 via-background to-muted/30 p-1.5 shadow-inner',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<TabsTrigger
|
||||
key={item.value}
|
||||
value={item.value}
|
||||
title={item.description}
|
||||
className={cn(
|
||||
'rounded-full px-3.5 py-1.5 text-xs font-medium text-muted-foreground transition-colors',
|
||||
'hover:bg-background/80 hover:text-foreground',
|
||||
'data-[state=active]:bg-gradient-to-r data-[state=active]:from-primary data-[state=active]:to-primary/80',
|
||||
'data-[state=active]:text-primary-foreground data-[state=active]:shadow-sm',
|
||||
triggerClassName,
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
)
|
||||
}
|
||||
482
dashboard/src/components/memory/MemoryProfileManager.tsx
Normal file
482
dashboard/src/components/memory/MemoryProfileManager.tsx
Normal file
@@ -0,0 +1,482 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ChevronDown, Loader2, RefreshCw, Save, Search, Trash2 } from 'lucide-react'
|
||||
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { useToast } from '@/hooks/use-toast'
|
||||
import {
|
||||
deleteMemoryProfileOverride,
|
||||
getMemoryProfiles,
|
||||
queryMemoryProfile,
|
||||
searchMemoryProfiles,
|
||||
setMemoryProfileOverride,
|
||||
type MemoryProfileItemPayload,
|
||||
type MemoryProfileQueryPayload,
|
||||
} from '@/lib/memory-api'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function formatMemoryTime(timestamp?: number | null): string {
|
||||
if (!timestamp) {
|
||||
return '-'
|
||||
}
|
||||
const normalized = timestamp > 1_000_000_000_000 ? timestamp : timestamp * 1000
|
||||
const value = new Date(normalized)
|
||||
if (Number.isNaN(value.getTime())) {
|
||||
return '-'
|
||||
}
|
||||
return value.toLocaleString('zh-CN', {
|
||||
hour12: false,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function parsePositiveInt(value: string, fallback: number): number {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
return fallback
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
function stringifyOverride(value: MemoryProfileItemPayload['manual_override']): string {
|
||||
if (!value) {
|
||||
return ''
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
const text = value.override_text ?? value.text
|
||||
if (typeof text === 'string') {
|
||||
return text
|
||||
}
|
||||
return JSON.stringify(value, null, 2)
|
||||
}
|
||||
|
||||
function resolveProfileText(queryResult: MemoryProfileQueryPayload | null, selectedProfile: MemoryProfileItemPayload | null): string {
|
||||
if (typeof queryResult?.profile_text === 'string') {
|
||||
return queryResult.profile_text
|
||||
}
|
||||
const queryProfile = queryResult?.profile
|
||||
if (queryProfile && typeof queryProfile === 'object' && typeof queryProfile.profile_text === 'string') {
|
||||
return queryProfile.profile_text
|
||||
}
|
||||
return selectedProfile?.profile_text ?? ''
|
||||
}
|
||||
|
||||
export function MemoryProfileManager() {
|
||||
const { toast } = useToast()
|
||||
const [profiles, setProfiles] = useState<MemoryProfileItemPayload[]>([])
|
||||
const [profileListMode, setProfileListMode] = useState<'library' | 'search'>('library')
|
||||
const [selectedPersonId, setSelectedPersonId] = useState('')
|
||||
const [queryPersonId, setQueryPersonId] = useState('')
|
||||
const [queryKeyword, setQueryKeyword] = useState('')
|
||||
const [queryPlatform, setQueryPlatform] = useState('')
|
||||
const [queryUserId, setQueryUserId] = useState('')
|
||||
const [queryLimit, setQueryLimit] = useState('12')
|
||||
const [forceRefresh, setForceRefresh] = useState(false)
|
||||
const [showAdvancedPersonId, setShowAdvancedPersonId] = useState(false)
|
||||
const [showRawProfilePayload, setShowRawProfilePayload] = useState(false)
|
||||
const [overrideText, setOverrideText] = useState('')
|
||||
const [queryResult, setQueryResult] = useState<MemoryProfileQueryPayload | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [querying, setQuerying] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const initialLoadedRef = useRef(false)
|
||||
|
||||
const selectedProfile = useMemo(
|
||||
() => profiles.find((item) => item.person_id === selectedPersonId) ?? null,
|
||||
[profiles, selectedPersonId],
|
||||
)
|
||||
const profileText = resolveProfileText(queryResult, selectedProfile)
|
||||
const selectedDisplayName = selectedProfile?.person_name || selectedPersonId || String(queryResult?.person_id ?? '未选择')
|
||||
|
||||
const loadProfiles = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const payload = await getMemoryProfiles(80)
|
||||
const nextItems = payload.items ?? []
|
||||
setProfiles(nextItems)
|
||||
setProfileListMode('library')
|
||||
if (!selectedPersonId && nextItems.length > 0) {
|
||||
setSelectedPersonId(nextItems[0].person_id)
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '加载人物画像失败',
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [selectedPersonId, toast])
|
||||
|
||||
useEffect(() => {
|
||||
if (initialLoadedRef.current) {
|
||||
return
|
||||
}
|
||||
initialLoadedRef.current = true
|
||||
void loadProfiles()
|
||||
}, [loadProfiles])
|
||||
|
||||
useEffect(() => {
|
||||
setOverrideText(stringifyOverride(selectedProfile?.manual_override))
|
||||
}, [selectedProfile])
|
||||
|
||||
const submitQuery = useCallback(async () => {
|
||||
const directPersonId = showAdvancedPersonId ? queryPersonId.trim() : ''
|
||||
const cleanKeyword = queryKeyword.trim()
|
||||
const cleanPlatform = queryPlatform.trim()
|
||||
const cleanUserId = queryUserId.trim()
|
||||
const hasAccountLocator = Boolean(cleanPlatform && cleanUserId)
|
||||
if (!directPersonId && !cleanKeyword && !hasAccountLocator) {
|
||||
toast({
|
||||
title: '请输入查询条件',
|
||||
description: '用户账号、关键词、或高级 person_id 至少填写一种。',
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
setQuerying(true)
|
||||
try {
|
||||
if (!directPersonId && !hasAccountLocator) {
|
||||
const searchPayload = await searchMemoryProfiles({
|
||||
personKeyword: cleanKeyword,
|
||||
limit: 80,
|
||||
})
|
||||
const nextItems = searchPayload.items ?? []
|
||||
setProfiles(nextItems)
|
||||
setProfileListMode('search')
|
||||
setQueryResult(null)
|
||||
setSelectedPersonId(nextItems[0]?.person_id ?? '')
|
||||
toast({
|
||||
title: '人物画像检索完成',
|
||||
description: `命中 ${nextItems.length} 个画像。`,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const payload = await queryMemoryProfile({
|
||||
personId: directPersonId,
|
||||
personKeyword: cleanKeyword,
|
||||
platform: cleanPlatform,
|
||||
userId: cleanUserId,
|
||||
limit: parsePositiveInt(queryLimit, 12),
|
||||
forceRefresh,
|
||||
})
|
||||
if (payload.success === false) {
|
||||
throw new Error(String(payload.error ?? '人物画像查询失败'))
|
||||
}
|
||||
setQueryResult(payload)
|
||||
const nextPersonId = String(payload.person_id ?? payload.profile?.person_id ?? directPersonId ?? '')
|
||||
const searchPayload = await searchMemoryProfiles({
|
||||
personId: nextPersonId || directPersonId,
|
||||
personKeyword: cleanKeyword,
|
||||
platform: cleanPlatform,
|
||||
userId: cleanUserId,
|
||||
limit: 80,
|
||||
})
|
||||
const nextItems = searchPayload.items ?? []
|
||||
setProfiles(nextItems)
|
||||
setProfileListMode('search')
|
||||
if (nextPersonId) {
|
||||
setSelectedPersonId(nextPersonId)
|
||||
setQueryPersonId(nextPersonId)
|
||||
} else if (nextItems.length > 0) {
|
||||
setSelectedPersonId(nextItems[0].person_id)
|
||||
}
|
||||
toast({
|
||||
title: '人物画像查询完成',
|
||||
description: forceRefresh ? '已请求强制刷新画像。' : '已获取画像结果。',
|
||||
})
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '人物画像查询失败',
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setQuerying(false)
|
||||
}
|
||||
}, [forceRefresh, queryKeyword, queryLimit, queryPersonId, queryPlatform, queryUserId, showAdvancedPersonId, toast])
|
||||
|
||||
const saveOverride = useCallback(async () => {
|
||||
const personId = selectedPersonId || queryPersonId.trim()
|
||||
if (!personId) {
|
||||
toast({
|
||||
title: '缺少人物 ID',
|
||||
description: '请选择或输入一个 person_id 后再保存 override。',
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
await setMemoryProfileOverride({
|
||||
person_id: personId,
|
||||
override_text: overrideText,
|
||||
updated_by: 'knowledge_base',
|
||||
source: 'webui',
|
||||
})
|
||||
toast({ title: '人物画像 override 已保存' })
|
||||
await loadProfiles()
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '保存人物画像 override 失败',
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [loadProfiles, overrideText, queryPersonId, selectedPersonId, toast])
|
||||
|
||||
const deleteOverride = useCallback(async () => {
|
||||
const personId = selectedPersonId || queryPersonId.trim()
|
||||
if (!personId) {
|
||||
return
|
||||
}
|
||||
if (!window.confirm(`确认删除 ${personId} 的人物画像 override?`)) {
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
await deleteMemoryProfileOverride(personId)
|
||||
setOverrideText('')
|
||||
toast({ title: '人物画像 override 已删除' })
|
||||
await loadProfiles()
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '删除人物画像 override 失败',
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [loadProfiles, queryPersonId, selectedPersonId, toast])
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Search className="h-4 w-4" />
|
||||
人物画像查询
|
||||
</CardTitle>
|
||||
<CardDescription>按平台账号定位人物画像,可用关键词辅助检索;person_id 查询放在高级入口。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="profile-platform">平台</Label>
|
||||
<Input
|
||||
id="profile-platform"
|
||||
value={queryPlatform}
|
||||
onChange={(event) => setQueryPlatform(event.target.value)}
|
||||
placeholder="例如 qq、telegram、webui"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="profile-user-id">用户账号</Label>
|
||||
<Input
|
||||
id="profile-user-id"
|
||||
value={queryUserId}
|
||||
onChange={(event) => setQueryUserId(event.target.value)}
|
||||
placeholder="输入平台侧 user_id"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="profile-keyword">人物关键词</Label>
|
||||
<Input id="profile-keyword" value={queryKeyword} onChange={(event) => setQueryKeyword(event.target.value)} placeholder="可选" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="profile-limit">证据数量</Label>
|
||||
<Input id="profile-limit" type="number" value={queryLimit} onChange={(event) => setQueryLimit(event.target.value)} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 self-end pb-2">
|
||||
<Checkbox
|
||||
id="profile-force-refresh"
|
||||
checked={forceRefresh}
|
||||
onCheckedChange={(value) => setForceRefresh(Boolean(value))}
|
||||
/>
|
||||
<Label htmlFor="profile-force-refresh" className="text-sm font-normal">
|
||||
强制刷新画像
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Collapsible open={showAdvancedPersonId} onOpenChange={setShowAdvancedPersonId} className="rounded-lg border bg-muted/10">
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" className="flex h-10 w-full justify-between px-3">
|
||||
<span>高级查询</span>
|
||||
<ChevronDown className={cn('h-4 w-4 transition-transform', showAdvancedPersonId && 'rotate-180')} />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-2 border-t px-3 py-3">
|
||||
<Label htmlFor="profile-person-id">person_id</Label>
|
||||
<Input
|
||||
id="profile-person-id"
|
||||
value={queryPersonId}
|
||||
onChange={(event) => setQueryPersonId(event.target.value)}
|
||||
placeholder="调试或后台管理时直接输入"
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{selectedPersonId || queryPersonId ? (
|
||||
<div className="rounded-lg border bg-muted/20 px-3 py-2 text-sm">
|
||||
<div className="text-muted-foreground">当前定位 person_id</div>
|
||||
<div className="mt-1 break-all font-mono text-xs">{selectedPersonId || queryPersonId}</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button onClick={() => void submitQuery()} disabled={querying}>
|
||||
<Search className="mr-2 h-4 w-4" />
|
||||
查询人物画像
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => void loadProfiles()} disabled={loading}>
|
||||
<RefreshCw className={cn('mr-2 h-4 w-4', loading && 'animate-spin')} />
|
||||
查看画像库
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-muted/10 px-3 py-2">
|
||||
<div className="text-sm font-medium">{profileListMode === 'search' ? '检索结果' : '画像库'}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{profileListMode === 'search'
|
||||
? '根据当前平台账号、关键词或 person_id 筛选出的画像候选。'
|
||||
: '系统中已生成的最新人物画像快照,按更新时间排序。'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="h-[520px] rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-background">
|
||||
<TableRow>
|
||||
<TableHead>人物</TableHead>
|
||||
<TableHead>版本</TableHead>
|
||||
<TableHead>更新时间</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{profiles.length > 0 ? profiles.map((item) => (
|
||||
<TableRow
|
||||
key={item.person_id}
|
||||
className={cn('cursor-pointer', selectedPersonId === item.person_id && 'bg-muted/60')}
|
||||
onClick={() => setSelectedPersonId(item.person_id)}
|
||||
>
|
||||
<TableCell>
|
||||
<div className="font-medium break-all">{item.person_name || item.person_id}</div>
|
||||
{item.person_name ? <div className="mt-0.5 font-mono text-xs text-muted-foreground break-all">{item.person_id}</div> : null}
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{item.has_manual_override ? <Badge variant="secondary">手动 override</Badge> : null}
|
||||
{item.source_note ? <Badge variant="outline">{item.source_note}</Badge> : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{Number(item.profile_version ?? 0)}</TableCell>
|
||||
<TableCell>{formatMemoryTime(item.updated_at)}</TableCell>
|
||||
</TableRow>
|
||||
)) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="text-center text-muted-foreground">
|
||||
{loading ? '正在加载人物画像...' : profileListMode === 'search' ? '没有匹配的人物画像' : '还没有人物画像快照'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>画像详情</CardTitle>
|
||||
<CardDescription>展示当前快照、查询结果和原始响应。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{querying ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
正在查询人物画像
|
||||
</div>
|
||||
) : null}
|
||||
{selectedProfile || queryResult ? (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="outline">{selectedPersonId || String(queryResult?.person_id ?? '未选择')}</Badge>
|
||||
{selectedProfile?.expires_at ? <Badge variant="secondary">过期时间 {formatMemoryTime(selectedProfile.expires_at)}</Badge> : null}
|
||||
</div>
|
||||
<Textarea value={profileText} readOnly className="min-h-[180px]" placeholder="当前没有画像文本" />
|
||||
<Collapsible open={showRawProfilePayload} onOpenChange={setShowRawProfilePayload} className="rounded-lg border bg-muted/10">
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" className="flex h-10 w-full justify-between px-3">
|
||||
<span>原始响应 JSON</span>
|
||||
<ChevronDown className={cn('h-4 w-4 transition-transform', showRawProfilePayload && 'rotate-180')} />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="border-t">
|
||||
<pre className="max-h-72 overflow-auto p-3 text-xs break-words whitespace-pre-wrap">
|
||||
{JSON.stringify(queryResult ?? selectedProfile ?? {}, null, 2)}
|
||||
</pre>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</>
|
||||
) : (
|
||||
<div className="rounded-lg border border-dashed bg-muted/20 p-6 text-center text-sm text-muted-foreground">
|
||||
选择一个人物或执行查询后查看详情。
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>手动 Override</CardTitle>
|
||||
<CardDescription>用人工画像覆盖自动生成结果;留空保存表示清空文本但保留 override 记录。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{!selectedPersonId && !queryPersonId.trim() ? (
|
||||
<Alert>
|
||||
<AlertDescription>请选择或输入 person_id 后再编辑 override。</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
{selectedDisplayName ? <div className="text-sm text-muted-foreground">当前编辑对象:{selectedDisplayName}</div> : null}
|
||||
<Textarea
|
||||
value={overrideText}
|
||||
onChange={(event) => setOverrideText(event.target.value)}
|
||||
className="min-h-[180px]"
|
||||
placeholder="输入希望固定使用的人物画像文本"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button onClick={() => void saveOverride()} disabled={saving}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
保存 override
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => void deleteOverride()} disabled={saving || (!selectedPersonId && !queryPersonId.trim())}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
删除 override
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
130
dashboard/src/components/memory/MemoryProgressIndicator.tsx
Normal file
130
dashboard/src/components/memory/MemoryProgressIndicator.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface MemoryProgressIndicatorProps {
|
||||
/** 0-100 之间的进度百分比 */
|
||||
value: number
|
||||
/** 任务状态文本(如 “运行中”、“已完成”) */
|
||||
statusLabel?: string
|
||||
/** 当前步骤文本(如 “分块中”) */
|
||||
stepLabel?: string
|
||||
/** 状态对应的语义色(用于左侧圆环和徽标) */
|
||||
tone?: 'default' | 'success' | 'warning' | 'destructive' | 'muted'
|
||||
/** 是否显示加载动画(运行中/取消中场景) */
|
||||
busy?: boolean
|
||||
/** 紧凑模式:用于队列列表项 */
|
||||
compact?: boolean
|
||||
/** 额外说明(如 “已完成 36 / 120 分块”) */
|
||||
detail?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
const TONE_RING_CLASS: Record<NonNullable<MemoryProgressIndicatorProps['tone']>, string> = {
|
||||
default: 'text-primary',
|
||||
success: 'text-emerald-500',
|
||||
warning: 'text-amber-500',
|
||||
destructive: 'text-rose-500',
|
||||
muted: 'text-muted-foreground',
|
||||
}
|
||||
|
||||
const TONE_BADGE_VARIANT: Record<
|
||||
NonNullable<MemoryProgressIndicatorProps['tone']>,
|
||||
'default' | 'secondary' | 'destructive' | 'outline'
|
||||
> = {
|
||||
default: 'default',
|
||||
success: 'secondary',
|
||||
warning: 'outline',
|
||||
destructive: 'destructive',
|
||||
muted: 'outline',
|
||||
}
|
||||
|
||||
/**
|
||||
* 长期记忆控制台统一的任务进度展示组件。
|
||||
*
|
||||
* 设计目标:
|
||||
* - 让用户一眼看清「整体百分比 + 语义状态 + 当前步骤」。
|
||||
* - 复用 shadcn `Progress` 与 `Badge`,避免引入额外样式来源。
|
||||
* - 在紧凑模式下保留可读性,可放进队列卡片;非紧凑模式带圆环用于详情区。
|
||||
*/
|
||||
export function MemoryProgressIndicator({
|
||||
value,
|
||||
statusLabel,
|
||||
stepLabel,
|
||||
tone = 'default',
|
||||
busy = false,
|
||||
compact = false,
|
||||
detail,
|
||||
className,
|
||||
}: MemoryProgressIndicatorProps) {
|
||||
const safeValue = Number.isFinite(value) ? Math.max(0, Math.min(100, value)) : 0
|
||||
const ringSize = compact ? 36 : 56
|
||||
const ringStroke = compact ? 4 : 5
|
||||
const radius = (ringSize - ringStroke) / 2
|
||||
const circumference = 2 * Math.PI * radius
|
||||
const dashOffset = circumference * (1 - safeValue / 100)
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center gap-3', className)}>
|
||||
<div
|
||||
className={cn('relative shrink-0', TONE_RING_CLASS[tone])}
|
||||
style={{ width: ringSize, height: ringSize }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg width={ringSize} height={ringSize} className="-rotate-90">
|
||||
<circle
|
||||
cx={ringSize / 2}
|
||||
cy={ringSize / 2}
|
||||
r={radius}
|
||||
strokeWidth={ringStroke}
|
||||
className="stroke-muted/40"
|
||||
fill="none"
|
||||
/>
|
||||
<circle
|
||||
cx={ringSize / 2}
|
||||
cy={ringSize / 2}
|
||||
r={radius}
|
||||
strokeWidth={ringStroke}
|
||||
strokeLinecap="round"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={dashOffset}
|
||||
className="transition-[stroke-dashoffset] duration-500 ease-out"
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
{busy ? (
|
||||
<Loader2 className={cn('animate-spin', compact ? 'h-3.5 w-3.5' : 'h-4 w-4')} />
|
||||
) : (
|
||||
<span className={cn('font-medium tabular-nums', compact ? 'text-[10px]' : 'text-xs')}>
|
||||
{Math.round(safeValue)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{statusLabel ? (
|
||||
<Badge variant={TONE_BADGE_VARIANT[tone]} className="shrink-0">
|
||||
{statusLabel}
|
||||
</Badge>
|
||||
) : null}
|
||||
{stepLabel ? (
|
||||
<span className="truncate text-xs text-muted-foreground">{stepLabel}</span>
|
||||
) : null}
|
||||
{!compact ? (
|
||||
<span className="ml-auto text-xs tabular-nums text-muted-foreground">
|
||||
{safeValue.toFixed(1)}%
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<Progress value={safeValue} className={cn(compact ? 'h-1' : 'h-1.5')} />
|
||||
{detail ? <div className="truncate text-xs text-muted-foreground">{detail}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
303
dashboard/src/components/plugin-stats.tsx
Normal file
303
dashboard/src/components/plugin-stats.tsx
Normal file
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* 插件统计组件
|
||||
* 显示点赞、点踩、评分和下载量
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { ThumbsUp, ThumbsDown, Star, Download } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { useToast } from '@/hooks/use-toast'
|
||||
import {
|
||||
getPluginStats,
|
||||
likePlugin,
|
||||
dislikePlugin,
|
||||
ratePlugin,
|
||||
type PluginStatsData,
|
||||
} from '@/lib/plugin-stats'
|
||||
|
||||
interface PluginStatsProps {
|
||||
pluginId: string
|
||||
compact?: boolean // 紧凑模式(只显示数字)
|
||||
}
|
||||
|
||||
export function PluginStats({ pluginId, compact = false }: PluginStatsProps) {
|
||||
const [stats, setStats] = useState<PluginStatsData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [userRating, setUserRating] = useState(0)
|
||||
const [userComment, setUserComment] = useState('')
|
||||
const [isRatingDialogOpen, setIsRatingDialogOpen] = useState(false)
|
||||
const { toast } = useToast()
|
||||
|
||||
// 加载统计数据
|
||||
const loadStats = async () => {
|
||||
setLoading(true)
|
||||
const data = await getPluginStats(pluginId)
|
||||
if (data) {
|
||||
setStats(data)
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadStats()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pluginId])
|
||||
|
||||
// 处理点赞
|
||||
const handleLike = async () => {
|
||||
const result = await likePlugin(pluginId)
|
||||
|
||||
if (result.success) {
|
||||
toast({ title: '已点赞', description: '感谢你的支持!' })
|
||||
loadStats() // 重新加载统计数据
|
||||
} else {
|
||||
toast({
|
||||
title: '点赞失败',
|
||||
description: result.error || '未知错误',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 处理点踩
|
||||
const handleDislike = async () => {
|
||||
const result = await dislikePlugin(pluginId)
|
||||
|
||||
if (result.success) {
|
||||
toast({ title: '已反馈', description: '感谢你的反馈!' })
|
||||
loadStats()
|
||||
} else {
|
||||
toast({
|
||||
title: '操作失败',
|
||||
description: result.error || '未知错误',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 提交评分
|
||||
const handleSubmitRating = async () => {
|
||||
if (userRating === 0) {
|
||||
toast({
|
||||
title: '请选择评分',
|
||||
description: '至少选择 1 颗星',
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const result = await ratePlugin(pluginId, userRating, userComment || undefined)
|
||||
|
||||
if (result.success) {
|
||||
toast({ title: '评分成功', description: '感谢你的评价!' })
|
||||
setIsRatingDialogOpen(false)
|
||||
setUserRating(0)
|
||||
setUserComment('')
|
||||
loadStats()
|
||||
} else {
|
||||
toast({
|
||||
title: '评分失败',
|
||||
description: result.error || '未知错误',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<Download className="h-4 w-4" />
|
||||
<span>-</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Star className="h-4 w-4" />
|
||||
<span>-</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!stats) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 紧凑模式
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1" title={`下载量: ${stats.downloads.toLocaleString()}`}>
|
||||
<Download className="h-4 w-4" />
|
||||
<span>{stats.downloads.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1" title={`评分: ${stats.rating.toFixed(1)} (${stats.rating_count} 条评价)`}>
|
||||
<Star className="h-4 w-4 fill-yellow-400 text-yellow-400" />
|
||||
<span>{stats.rating.toFixed(1)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1" title={`点赞数: ${stats.likes}`}>
|
||||
<ThumbsUp className="h-4 w-4" />
|
||||
<span>{stats.likes}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 完整模式
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 统计数字 */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
<div className="flex flex-col items-center p-3 rounded-lg border bg-card">
|
||||
<Download className="h-5 w-5 text-muted-foreground mb-1" />
|
||||
<span className="text-2xl font-bold">{stats.downloads.toLocaleString()}</span>
|
||||
<span className="text-xs text-muted-foreground">下载量</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center p-3 rounded-lg border bg-card">
|
||||
<Star className="h-5 w-5 text-yellow-400 mb-1 fill-yellow-400" />
|
||||
<span className="text-2xl font-bold">{stats.rating.toFixed(1)}</span>
|
||||
<span className="text-xs text-muted-foreground">{stats.rating_count} 条评价</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center p-3 rounded-lg border bg-card">
|
||||
<ThumbsUp className="h-5 w-5 text-green-500 mb-1" />
|
||||
<span className="text-2xl font-bold">{stats.likes}</span>
|
||||
<span className="text-xs text-muted-foreground">点赞</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center p-3 rounded-lg border bg-card">
|
||||
<ThumbsDown className="h-5 w-5 text-red-500 mb-1" />
|
||||
<span className="text-2xl font-bold">{stats.dislikes}</span>
|
||||
<span className="text-xs text-muted-foreground">点踩</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleLike}>
|
||||
<ThumbsUp className="h-4 w-4 mr-1" />
|
||||
点赞
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" size="sm" onClick={handleDislike}>
|
||||
<ThumbsDown className="h-4 w-4 mr-1" />
|
||||
点踩
|
||||
</Button>
|
||||
|
||||
<Dialog open={isRatingDialogOpen} onOpenChange={setIsRatingDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="default" size="sm">
|
||||
<Star className="h-4 w-4 mr-1" />
|
||||
评分
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>为插件评分</DialogTitle>
|
||||
<DialogDescription>分享你的使用体验,帮助其他用户</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* 星级评分 */}
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="flex gap-2">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<button
|
||||
key={star}
|
||||
onClick={() => setUserRating(star)}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<Star
|
||||
className={`h-8 w-8 transition-colors ${
|
||||
star <= userRating
|
||||
? 'fill-yellow-400 text-yellow-400'
|
||||
: 'text-muted-foreground hover:text-yellow-300'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{userRating === 0 && '点击星星进行评分'}
|
||||
{userRating === 1 && '很差'}
|
||||
{userRating === 2 && '一般'}
|
||||
{userRating === 3 && '还行'}
|
||||
{userRating === 4 && '不错'}
|
||||
{userRating === 5 && '非常好'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 评论 */}
|
||||
<div>
|
||||
<label htmlFor="plugin-rating-comment" className="text-sm font-medium mb-2 block">评论(可选)</label>
|
||||
<Textarea
|
||||
value={userComment}
|
||||
id="plugin-rating-comment"
|
||||
onChange={(e) => setUserComment(e.target.value)}
|
||||
placeholder="分享你的使用体验..."
|
||||
rows={4}
|
||||
maxLength={500}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground mt-1 text-right">
|
||||
{userComment.length} / 500
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsRatingDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleSubmitRating} disabled={userRating === 0}>
|
||||
提交评分
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{/* 最近评价 */}
|
||||
{stats.recent_ratings && stats.recent_ratings.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-semibold">最近评价</h4>
|
||||
<div className="space-y-3">
|
||||
{stats.recent_ratings.map((rating, index) => (
|
||||
<div key={index} className="p-3 rounded-lg border bg-muted/50">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex gap-1">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<Star
|
||||
key={star}
|
||||
className={`h-3 w-3 ${
|
||||
star <= rating.rating
|
||||
? 'fill-yellow-400 text-yellow-400'
|
||||
: 'text-muted-foreground'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(rating.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
{rating.comment && (
|
||||
<p className="text-sm text-muted-foreground">{rating.comment}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
416
dashboard/src/components/restart-overlay.tsx
Normal file
416
dashboard/src/components/restart-overlay.tsx
Normal file
@@ -0,0 +1,416 @@
|
||||
/**
|
||||
* 重启遮罩层组件
|
||||
*
|
||||
* 用于显示重启进度和状态,阻止用户操作
|
||||
*
|
||||
* 使用方式 1: 配合 RestartProvider(推荐)
|
||||
* <RestartProvider>
|
||||
* <App />
|
||||
* <RestartOverlay />
|
||||
* </RestartProvider>
|
||||
*
|
||||
* 使用方式 2: 独立使用
|
||||
* <RestartOverlay
|
||||
* visible={true}
|
||||
* onComplete={() => navigate('/auth')}
|
||||
* />
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
} from 'lucide-react'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useRestart, type RestartStatus, type RestartContextValue } from '@/lib/restart-context'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Hook 用于安全获取 restart context
|
||||
function useSafeRestart(): RestartContextValue | null {
|
||||
try {
|
||||
return useRestart()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 类型定义 ============
|
||||
|
||||
interface RestartOverlayProps {
|
||||
/** 是否可见(仅独立模式使用) */
|
||||
visible?: boolean
|
||||
/** 重启完成回调 */
|
||||
onComplete?: () => void
|
||||
/** 重启失败回调 */
|
||||
onFailed?: () => void
|
||||
/** 自定义标题 */
|
||||
title?: string
|
||||
/** 自定义描述 */
|
||||
description?: string
|
||||
/** 是否显示背景动画 */
|
||||
showAnimation?: boolean
|
||||
/** 自定义类名 */
|
||||
className?: string
|
||||
}
|
||||
|
||||
// ============ 状态配置 ============
|
||||
|
||||
interface StatusConfig {
|
||||
icon: React.ReactNode
|
||||
title: string
|
||||
description: string
|
||||
tip: string
|
||||
}
|
||||
|
||||
const getStatusConfig = (
|
||||
status: RestartStatus,
|
||||
checkAttempts: number,
|
||||
maxAttempts: number,
|
||||
t: (key: string, opts?: Record<string, unknown>) => string,
|
||||
customTitle?: string,
|
||||
customDescription?: string
|
||||
): StatusConfig => {
|
||||
const configs: Record<RestartStatus, StatusConfig> = {
|
||||
idle: {
|
||||
icon: null,
|
||||
title: '',
|
||||
description: '',
|
||||
tip: '',
|
||||
},
|
||||
requesting: {
|
||||
icon: <Loader2 className="h-16 w-16 text-primary animate-spin" />,
|
||||
title: customTitle ?? t('restart.preparing'),
|
||||
description: customDescription ?? t('restart.preparingDesc'),
|
||||
tip: t('restart.preparingTip'),
|
||||
},
|
||||
restarting: {
|
||||
icon: <Loader2 className="h-16 w-16 text-primary animate-spin" />,
|
||||
title: customTitle ?? t('restart.restarting'),
|
||||
description: customDescription ?? t('restart.restartingDesc'),
|
||||
tip: t('restart.restartingTip'),
|
||||
},
|
||||
checking: {
|
||||
icon: <Loader2 className="h-16 w-16 text-primary animate-spin" />,
|
||||
title: t('restart.checking'),
|
||||
description: t('restart.checkingDesc', { current: checkAttempts, max: maxAttempts }),
|
||||
tip: t('restart.checkingTip'),
|
||||
},
|
||||
success: {
|
||||
icon: <CheckCircle2 className="h-16 w-16 text-green-500" />,
|
||||
title: t('restart.success'),
|
||||
description: t('restart.successDesc'),
|
||||
tip: t('restart.successTip'),
|
||||
},
|
||||
failed: {
|
||||
icon: <AlertCircle className="h-16 w-16 text-destructive" />,
|
||||
title: t('restart.failed'),
|
||||
description: t('restart.failedDesc'),
|
||||
tip: t('restart.failedTip'),
|
||||
},
|
||||
}
|
||||
return configs[status]
|
||||
}
|
||||
|
||||
// ============ 主组件(配合 Provider) ============
|
||||
|
||||
export function RestartOverlay({
|
||||
visible,
|
||||
onComplete,
|
||||
onFailed,
|
||||
title,
|
||||
description,
|
||||
showAnimation = true,
|
||||
className,
|
||||
}: RestartOverlayProps) {
|
||||
// 尝试使用 context(可能不存在)
|
||||
const contextValue = useSafeRestart()
|
||||
|
||||
// 如果有 context,使用 context 状态;否则使用 props
|
||||
const isVisible = contextValue ? contextValue.isRestarting : visible
|
||||
|
||||
if (!isVisible) return null
|
||||
|
||||
if (contextValue) {
|
||||
return (
|
||||
<RestartOverlayContent
|
||||
state={contextValue.state}
|
||||
onRetry={contextValue.retryHealthCheck}
|
||||
onComplete={onComplete}
|
||||
onFailed={onFailed}
|
||||
title={title}
|
||||
description={description}
|
||||
showAnimation={showAnimation}
|
||||
className={className}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// 独立模式
|
||||
return (
|
||||
<StandaloneRestartOverlay
|
||||
onComplete={onComplete}
|
||||
onFailed={onFailed}
|
||||
title={title}
|
||||
description={description}
|
||||
showAnimation={showAnimation}
|
||||
className={className}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// ============ 内容组件 ============
|
||||
|
||||
interface RestartOverlayContentProps {
|
||||
state: {
|
||||
status: RestartStatus
|
||||
progress: number
|
||||
elapsedTime: number
|
||||
checkAttempts: number
|
||||
maxAttempts: number
|
||||
error?: string
|
||||
}
|
||||
onRetry: () => void
|
||||
onComplete?: () => void
|
||||
onFailed?: () => void
|
||||
title?: string
|
||||
description?: string
|
||||
showAnimation?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
function RestartOverlayContent({
|
||||
state,
|
||||
onRetry,
|
||||
onComplete,
|
||||
onFailed,
|
||||
title,
|
||||
description,
|
||||
showAnimation,
|
||||
className,
|
||||
}: RestartOverlayContentProps) {
|
||||
const { status, progress, elapsedTime, checkAttempts, maxAttempts } = state
|
||||
const { t } = useTranslation()
|
||||
|
||||
// 回调处理
|
||||
useEffect(() => {
|
||||
if (status === 'success' && onComplete) {
|
||||
onComplete()
|
||||
} else if (status === 'failed' && onFailed) {
|
||||
onFailed()
|
||||
}
|
||||
}, [status, onComplete, onFailed])
|
||||
|
||||
const config = getStatusConfig(
|
||||
status,
|
||||
checkAttempts,
|
||||
maxAttempts,
|
||||
t,
|
||||
title,
|
||||
description
|
||||
)
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = seconds % 60
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-0 bg-background/95 backdrop-blur-sm z-50 flex items-center justify-center',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* 背景动画 */}
|
||||
{showAnimation && <BackgroundAnimation />}
|
||||
|
||||
<div className="max-w-md w-full mx-4 space-y-8 relative z-10">
|
||||
{/* 图标和状态 */}
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<div className="relative">
|
||||
{config.icon}
|
||||
{/* 脉冲动画 */}
|
||||
{(status === 'restarting' || status === 'checking') && (
|
||||
<div className="absolute inset-0 rounded-full bg-primary/20 animate-ping" />
|
||||
)}
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold">{config.title}</h2>
|
||||
<p className="text-muted-foreground text-center">{config.description}</p>
|
||||
</div>
|
||||
|
||||
{/* 进度条 */}
|
||||
{status !== 'failed' && status !== 'idle' && (
|
||||
<div className="space-y-2">
|
||||
<Progress value={progress} className="h-2" />
|
||||
<div className="flex justify-between text-sm text-muted-foreground">
|
||||
<span>{progress}%</span>
|
||||
<span>{t('restart.elapsed')} {formatTime(elapsedTime)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 提示信息 */}
|
||||
<div className="bg-muted/50 rounded-lg p-4">
|
||||
<p className="text-sm text-muted-foreground">{config.tip}</p>
|
||||
</div>
|
||||
|
||||
{/* 失败时的操作按钮 */}
|
||||
{status === 'failed' && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => window.location.reload()}
|
||||
variant="default"
|
||||
className="flex-1"
|
||||
>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
{t('restart.refreshPage')}
|
||||
</Button>
|
||||
<Button onClick={onRetry} variant="secondary" className="flex-1">
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
{t('restart.retryCheck')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============ 独立模式组件 ============
|
||||
|
||||
interface StandaloneRestartOverlayProps {
|
||||
onComplete?: () => void
|
||||
onFailed?: () => void
|
||||
title?: string
|
||||
description?: string
|
||||
showAnimation?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
function StandaloneRestartOverlay({
|
||||
onComplete,
|
||||
onFailed,
|
||||
title,
|
||||
description,
|
||||
showAnimation,
|
||||
className,
|
||||
}: StandaloneRestartOverlayProps) {
|
||||
const [state, setState] = useState({
|
||||
status: 'restarting' as RestartStatus,
|
||||
progress: 0,
|
||||
elapsedTime: 0,
|
||||
checkAttempts: 0,
|
||||
maxAttempts: 60,
|
||||
})
|
||||
|
||||
const startHealthCheck = useCallback(() => {
|
||||
let attempts = 0
|
||||
const maxAttempts = 60
|
||||
|
||||
const check = async () => {
|
||||
attempts++
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
status: 'checking',
|
||||
checkAttempts: attempts,
|
||||
}))
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/webui/system/status', {
|
||||
method: 'GET',
|
||||
signal: AbortSignal.timeout(3000),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
setState((prev) => ({ ...prev, status: 'success', progress: 100 }))
|
||||
setTimeout(() => {
|
||||
onComplete?.()
|
||||
window.location.href = '/auth'
|
||||
}, 1500)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// 继续重试
|
||||
}
|
||||
|
||||
if (attempts >= maxAttempts) {
|
||||
setState((prev) => ({ ...prev, status: 'failed' }))
|
||||
onFailed?.()
|
||||
} else {
|
||||
setTimeout(check, 2000)
|
||||
}
|
||||
}
|
||||
|
||||
check()
|
||||
}, [onComplete, onFailed])
|
||||
|
||||
useEffect(() => {
|
||||
// 进度条动画
|
||||
const progressInterval = setInterval(() => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
progress: prev.progress >= 90 ? prev.progress : prev.progress + 1,
|
||||
}))
|
||||
}, 200)
|
||||
|
||||
// 计时器
|
||||
const timerInterval = setInterval(() => {
|
||||
setState((prev) => ({ ...prev, elapsedTime: prev.elapsedTime + 1 }))
|
||||
}, 1000)
|
||||
|
||||
// 3秒后开始健康检查
|
||||
const initialDelay = setTimeout(() => {
|
||||
startHealthCheck()
|
||||
}, 3000)
|
||||
|
||||
return () => {
|
||||
clearInterval(progressInterval)
|
||||
clearInterval(timerInterval)
|
||||
clearTimeout(initialDelay)
|
||||
}
|
||||
}, [startHealthCheck])
|
||||
|
||||
return (
|
||||
<RestartOverlayContent
|
||||
state={state}
|
||||
onRetry={startHealthCheck}
|
||||
onComplete={onComplete}
|
||||
onFailed={onFailed}
|
||||
title={title}
|
||||
description={description}
|
||||
showAnimation={showAnimation}
|
||||
className={className}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// ============ 背景动画 ============
|
||||
|
||||
function BackgroundAnimation() {
|
||||
return (
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
{/* 渐变圆环 */}
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px]">
|
||||
<div className="absolute inset-0 rounded-full border border-primary/10 animate-[ping_3s_ease-in-out_infinite]" />
|
||||
<div className="absolute inset-8 rounded-full border border-primary/10 animate-[ping_3s_ease-in-out_infinite_0.5s]" />
|
||||
<div className="absolute inset-16 rounded-full border border-primary/10 animate-[ping_3s_ease-in-out_infinite_1s]" />
|
||||
</div>
|
||||
|
||||
{/* 浮动粒子 */}
|
||||
<div className="absolute top-1/4 left-1/4 w-2 h-2 bg-primary/20 rounded-full animate-bounce" />
|
||||
<div className="absolute top-3/4 right-1/4 w-3 h-3 bg-primary/15 rounded-full animate-bounce delay-150" />
|
||||
<div className="absolute top-1/2 right-1/3 w-2 h-2 bg-primary/20 rounded-full animate-bounce delay-300" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============ 导出旧组件(兼容性) ============
|
||||
|
||||
// 如需使用旧版组件,请直接导入:
|
||||
// import { RestartingOverlay } from '@/components/RestartingOverlay.legacy'
|
||||
349
dashboard/src/components/search-dialog.tsx
Normal file
349
dashboard/src/components/search-dialog.tsx
Normal file
@@ -0,0 +1,349 @@
|
||||
import { useState, useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { FileText, Search, SlidersHorizontal } from 'lucide-react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { LucideProps } from 'lucide-react'
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { ShortcutKbd } from '@/components/ui/kbd'
|
||||
import { menuSections } from '@/components/layout/constants'
|
||||
import { registeredRoutePaths } from '@/router'
|
||||
import { getBotConfigSchema, getModelConfigSchema } from '@/lib/config-api'
|
||||
import { getAllLocalizedText, resolveFieldLabel } from '@/lib/config-label'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { ConfigSchema, FieldSchema } from '@/types/config-schema'
|
||||
|
||||
interface SearchDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
interface SearchItem {
|
||||
id: string
|
||||
icon: React.ComponentType<LucideProps>
|
||||
title: string
|
||||
description: string
|
||||
path: string
|
||||
category: string
|
||||
keywords: string
|
||||
}
|
||||
|
||||
function resolveSchemaTitle(schema: ConfigSchema, fallback: string) {
|
||||
return schema.uiLabel || schema.classDoc || schema.className || fallback
|
||||
}
|
||||
|
||||
function unwrapConfigSchema(payload: unknown): ConfigSchema | null {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
if ('fields' in payload) {
|
||||
return payload as ConfigSchema
|
||||
}
|
||||
|
||||
if ('schema' in payload) {
|
||||
const schema = (payload as { schema?: unknown }).schema
|
||||
if (schema && typeof schema === 'object' && 'fields' in schema) {
|
||||
return schema as ConfigSchema
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function getModelConfigPath(_fieldPath: string) {
|
||||
return '/config/model'
|
||||
}
|
||||
|
||||
function buildFieldSearchText(field: FieldSchema, fieldPath: string, sectionTitle: string, language?: string) {
|
||||
const options = field.options?.join(' ') ?? ''
|
||||
const optionDescriptions = field['x-option-descriptions']
|
||||
? Object.entries(field['x-option-descriptions'])
|
||||
.map(([key, value]) => `${key} ${value}`)
|
||||
.join(' ')
|
||||
: ''
|
||||
|
||||
return [
|
||||
resolveFieldLabel(field, language),
|
||||
...getAllLocalizedText(field.label),
|
||||
field.name,
|
||||
fieldPath,
|
||||
field.description,
|
||||
sectionTitle,
|
||||
field.type,
|
||||
options,
|
||||
optionDescriptions,
|
||||
].join(' ')
|
||||
}
|
||||
|
||||
function collectConfigFields(
|
||||
schema: ConfigSchema,
|
||||
sourceLabel: string,
|
||||
basePath: string,
|
||||
routePath: (fieldPath: string) => string,
|
||||
language?: string,
|
||||
): SearchItem[] {
|
||||
const items: SearchItem[] = []
|
||||
|
||||
const walk = (currentSchema: ConfigSchema, pathPrefix: string, sectionTrail: string[]) => {
|
||||
const sectionTitle = resolveSchemaTitle(currentSchema, sourceLabel)
|
||||
const nextTrail = [...sectionTrail, sectionTitle].filter(Boolean)
|
||||
|
||||
for (const field of currentSchema.fields) {
|
||||
const fieldPath = pathPrefix ? `${pathPrefix}.${field.name}` : field.name
|
||||
const nestedSchema = currentSchema.nested?.[field.name]
|
||||
const fieldTitle = resolveFieldLabel(field, language)
|
||||
const description = field.description || nextTrail.join(' / ') || fieldPath
|
||||
const fullPath = basePath ? `${basePath}.${fieldPath}` : fieldPath
|
||||
const route = routePath(fullPath)
|
||||
|
||||
items.push({
|
||||
id: `config:${sourceLabel}:${fullPath}`,
|
||||
icon: sourceLabel === '模型配置' ? SlidersHorizontal : FileText,
|
||||
title: fieldTitle,
|
||||
description: `${sourceLabel} / ${nextTrail.join(' / ')} / ${fullPath} · ${description}`,
|
||||
path: route,
|
||||
category: '配置项',
|
||||
keywords: buildFieldSearchText(field, fullPath, nextTrail.join(' / '), language),
|
||||
})
|
||||
|
||||
if (nestedSchema) {
|
||||
walk(nestedSchema, fieldPath, nextTrail)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(schema, '', [])
|
||||
return items
|
||||
}
|
||||
|
||||
export function SearchDialog({ open, onOpenChange }: SearchDialogProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const [configSearchItems, setConfigSearchItems] = useState<SearchItem[]>([])
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const navigate = useNavigate()
|
||||
const { i18n, t } = useTranslation()
|
||||
|
||||
useEffect(() => {
|
||||
setConfigSearchItems([])
|
||||
}, [i18n.language])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
|
||||
const frameId = window.requestAnimationFrame(() => {
|
||||
inputRef.current?.focus()
|
||||
})
|
||||
|
||||
return () => window.cancelAnimationFrame(frameId)
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || configSearchItems.length > 0) {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
const loadConfigSearchItems = async () => {
|
||||
const [botSchemaResult, modelSchemaResult] = await Promise.all([
|
||||
getBotConfigSchema(),
|
||||
getModelConfigSchema(),
|
||||
])
|
||||
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextItems: SearchItem[] = []
|
||||
if (botSchemaResult.success) {
|
||||
const botSchema = unwrapConfigSchema(botSchemaResult.data)
|
||||
if (botSchema) {
|
||||
nextItems.push(...collectConfigFields(
|
||||
botSchema,
|
||||
'Bot 配置',
|
||||
'',
|
||||
() => '/config/bot',
|
||||
i18n.language,
|
||||
))
|
||||
}
|
||||
}
|
||||
if (modelSchemaResult.success) {
|
||||
const modelSchema = unwrapConfigSchema(modelSchemaResult.data)
|
||||
if (modelSchema) {
|
||||
nextItems.push(...collectConfigFields(
|
||||
modelSchema,
|
||||
'模型配置',
|
||||
'',
|
||||
getModelConfigPath,
|
||||
i18n.language,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
setConfigSearchItems(nextItems)
|
||||
}
|
||||
|
||||
loadConfigSearchItems().catch(() => {
|
||||
if (!cancelled) {
|
||||
setConfigSearchItems([])
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [configSearchItems.length, i18n.language, open])
|
||||
|
||||
const searchItems: SearchItem[] = useMemo(
|
||||
() =>
|
||||
menuSections.flatMap((section) =>
|
||||
section.items
|
||||
.filter((item) => registeredRoutePaths.has(item.path))
|
||||
.map((item) => ({
|
||||
id: `route:${item.path}`,
|
||||
icon: item.icon,
|
||||
title: t(item.label),
|
||||
description: item.searchDescription ? t(item.searchDescription) : item.path,
|
||||
path: item.path,
|
||||
category: t(section.title),
|
||||
keywords: [
|
||||
t(item.label),
|
||||
item.path,
|
||||
item.searchDescription ? t(item.searchDescription) : '',
|
||||
t(section.title),
|
||||
].join(' '),
|
||||
}))
|
||||
),
|
||||
[t]
|
||||
)
|
||||
|
||||
// 过滤搜索结果
|
||||
const normalizedQuery = searchQuery.trim().toLowerCase()
|
||||
const filteredItems = (normalizedQuery ? [...searchItems, ...configSearchItems] : searchItems)
|
||||
.filter((item) => item.keywords.toLowerCase().includes(normalizedQuery))
|
||||
.slice(0, 80)
|
||||
|
||||
// 导航到页面
|
||||
const handleNavigate = useCallback((path: string) => {
|
||||
navigate({ to: path })
|
||||
onOpenChange(false)
|
||||
// 在导航后重置状态
|
||||
setSearchQuery('')
|
||||
setSelectedIndex(0)
|
||||
}, [navigate, onOpenChange])
|
||||
|
||||
// 键盘导航
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
if (filteredItems.length === 0) return
|
||||
setSelectedIndex((prev) => (prev + 1) % filteredItems.length)
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
if (filteredItems.length === 0) return
|
||||
setSelectedIndex((prev) => (prev - 1 + filteredItems.length) % filteredItems.length)
|
||||
} else if (e.key === 'Enter' && filteredItems[selectedIndex]) {
|
||||
e.preventDefault()
|
||||
handleNavigate(filteredItems[selectedIndex].path)
|
||||
}
|
||||
},
|
||||
[filteredItems, selectedIndex, handleNavigate]
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl p-0 gap-0" confirmOnEnter>
|
||||
<DialogHeader className="px-4 pt-4 pb-0">
|
||||
<DialogTitle className="sr-only">{t('search.title')}</DialogTitle>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value)
|
||||
setSelectedIndex(0)
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={t('search.placeholder')}
|
||||
className="h-12 pl-11 text-base border-0 focus-visible:ring-0 shadow-none"
|
||||
/>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="border-t">
|
||||
<DialogBody className="h-100" viewportClassName="px-0">
|
||||
{filteredItems.length > 0 ? (
|
||||
<div className="p-2">
|
||||
{filteredItems.map((item, index) => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => handleNavigate(item.path)}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-left transition-colors',
|
||||
index === selectedIndex
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'hover:bg-accent/50'
|
||||
)}
|
||||
>
|
||||
<Icon className="h-5 w-5 shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm">{item.title}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{item.description}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground px-2 py-1 bg-muted rounded">
|
||||
{item.category}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Search className="h-12 w-12 text-muted-foreground/50 mb-4" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{searchQuery ? t('search.noResults') : t('search.startSearch')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</DialogBody>
|
||||
</div>
|
||||
|
||||
<div className="border-t px-4 py-3 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="flex items-center gap-1">
|
||||
<ShortcutKbd size="sm" keys={['up']} />
|
||||
<ShortcutKbd size="sm" keys={['down']} />
|
||||
{t('search.navigate')}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<ShortcutKbd size="sm" keys={['enter']} />
|
||||
{t('search.select')}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<ShortcutKbd size="sm" keys={['esc']} />
|
||||
{t('search.close')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
685
dashboard/src/components/share-pack-dialog.tsx
Normal file
685
dashboard/src/components/share-pack-dialog.tsx
Normal file
@@ -0,0 +1,685 @@
|
||||
/**
|
||||
* 分享 Pack 对话框
|
||||
*
|
||||
* 允许用户将当前配置导出并分享到 Pack 市场
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Package,
|
||||
Share2,
|
||||
Server,
|
||||
Layers,
|
||||
ListChecks,
|
||||
Tag,
|
||||
Loader2,
|
||||
Check,
|
||||
Info,
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { toast } from '@/hooks/use-toast'
|
||||
import {
|
||||
createPack,
|
||||
exportCurrentConfigAsPack,
|
||||
type PackProvider,
|
||||
type PackModel,
|
||||
type PackTaskConfigs,
|
||||
} from '@/lib/pack-api'
|
||||
|
||||
// 任务类型名称映射
|
||||
const TASK_TYPE_NAMES: Record<string, string> = {
|
||||
utils: '通用工具',
|
||||
utils_small: '轻量工具',
|
||||
tool_use: '工具调用',
|
||||
replyer: '回复生成',
|
||||
planner: '规划推理',
|
||||
vlm: '视觉模型',
|
||||
voice: '语音处理',
|
||||
embedding: '向量嵌入',
|
||||
lpmm_entity_extract: '实体提取',
|
||||
lpmm_rdf_build: 'RDF构建',
|
||||
lpmm_qa: '问答模型',
|
||||
}
|
||||
|
||||
// 预设标签
|
||||
const PRESET_TAGS = [
|
||||
'官方推荐',
|
||||
'性价比',
|
||||
'高性能',
|
||||
'免费模型',
|
||||
'国内可用',
|
||||
'海外模型',
|
||||
'OpenAI',
|
||||
'Claude',
|
||||
'Gemini',
|
||||
'国产模型',
|
||||
'多模态',
|
||||
'轻量级',
|
||||
]
|
||||
|
||||
interface SharePackDialogProps {
|
||||
trigger?: React.ReactNode
|
||||
}
|
||||
|
||||
export function SharePackDialog({ trigger }: SharePackDialogProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [step, setStep] = useState(1)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
// 配置数据
|
||||
const [providers, setProviders] = useState<PackProvider[]>([])
|
||||
const [models, setModels] = useState<PackModel[]>([])
|
||||
const [taskConfig, setTaskConfig] = useState<PackTaskConfigs>({})
|
||||
|
||||
// 选择状态
|
||||
const [selectedProviders, setSelectedProviders] = useState<Set<string>>(new Set())
|
||||
const [selectedModels, setSelectedModels] = useState<Set<string>>(new Set())
|
||||
const [selectedTasks, setSelectedTasks] = useState<Set<string>>(new Set())
|
||||
|
||||
// Pack 信息
|
||||
const [packName, setPackName] = useState('')
|
||||
const [packDescription, setPackDescription] = useState('')
|
||||
const [packAuthor, setPackAuthor] = useState('')
|
||||
const [packTags, setPackTags] = useState<string[]>([])
|
||||
|
||||
// 加载当前配置
|
||||
useEffect(() => {
|
||||
if (open && step === 1) {
|
||||
loadCurrentConfig()
|
||||
}
|
||||
}, [open, step])
|
||||
|
||||
const loadCurrentConfig = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const config = await exportCurrentConfigAsPack({
|
||||
name: '',
|
||||
description: '',
|
||||
author: '',
|
||||
})
|
||||
|
||||
setProviders(config.providers)
|
||||
setModels(config.models)
|
||||
setTaskConfig(config.task_config)
|
||||
|
||||
// 默认全选
|
||||
setSelectedProviders(new Set(config.providers.map(p => p.name)))
|
||||
setSelectedModels(new Set(config.models.map(m => m.name)))
|
||||
setSelectedTasks(new Set(Object.keys(config.task_config)))
|
||||
} catch (error) {
|
||||
console.error('加载配置失败:', error)
|
||||
toast({ title: '加载当前配置失败', variant: 'destructive' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 切换选择
|
||||
const toggleProvider = (name: string) => {
|
||||
const newSet = new Set(selectedProviders)
|
||||
const newModels = new Set(selectedModels)
|
||||
const newTasks = new Set(selectedTasks)
|
||||
|
||||
if (newSet.has(name)) {
|
||||
// 取消选择提供商
|
||||
newSet.delete(name)
|
||||
|
||||
// 取消选择该提供商下的所有模型
|
||||
const providerModels = models.filter(m => m.api_provider === name)
|
||||
providerModels.forEach(m => newModels.delete(m.name))
|
||||
|
||||
// 检查任务配置,如果任务使用的所有模型都被取消选择了,也取消选择该任务
|
||||
Object.entries(taskConfig).forEach(([key, config]) => {
|
||||
if (config.model_list) {
|
||||
const hasSelectedModel = config.model_list.some((modelName: string) => newModels.has(modelName))
|
||||
if (!hasSelectedModel) {
|
||||
newTasks.delete(key)
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// 选择提供商
|
||||
newSet.add(name)
|
||||
|
||||
// 自动选择该提供商下的所有模型
|
||||
const providerModels = models.filter(m => m.api_provider === name)
|
||||
providerModels.forEach(m => newModels.add(m.name))
|
||||
|
||||
// 自动选择使用这些模型的任务
|
||||
Object.entries(taskConfig).forEach(([key, config]) => {
|
||||
if (config.model_list) {
|
||||
const hasProviderModel = config.model_list.some((modelName: string) => {
|
||||
const model = models.find(m => m.name === modelName)
|
||||
return model && model.api_provider === name
|
||||
})
|
||||
if (hasProviderModel) {
|
||||
newTasks.add(key)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
setSelectedProviders(newSet)
|
||||
setSelectedModels(newModels)
|
||||
setSelectedTasks(newTasks)
|
||||
}
|
||||
|
||||
const toggleModel = (name: string) => {
|
||||
const newModels = new Set(selectedModels)
|
||||
const newTasks = new Set(selectedTasks)
|
||||
|
||||
if (newModels.has(name)) {
|
||||
// 取消选择模型
|
||||
newModels.delete(name)
|
||||
|
||||
// 检查任务配置,如果任务使用的所有模型都被取消选择了,也取消选择该任务
|
||||
Object.entries(taskConfig).forEach(([key, config]) => {
|
||||
if (config.model_list) {
|
||||
const hasSelectedModel = config.model_list.some((modelName: string) => newModels.has(modelName))
|
||||
if (!hasSelectedModel) {
|
||||
newTasks.delete(key)
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// 选择模型
|
||||
newModels.add(name)
|
||||
|
||||
// 自动选择使用这个模型的任务
|
||||
Object.entries(taskConfig).forEach(([key, config]) => {
|
||||
if (config.model_list && config.model_list.includes(name)) {
|
||||
newTasks.add(key)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
setSelectedModels(newModels)
|
||||
setSelectedTasks(newTasks)
|
||||
}
|
||||
|
||||
const toggleTask = (key: string) => {
|
||||
const newSet = new Set(selectedTasks)
|
||||
if (newSet.has(key)) {
|
||||
newSet.delete(key)
|
||||
} else {
|
||||
newSet.add(key)
|
||||
}
|
||||
setSelectedTasks(newSet)
|
||||
}
|
||||
|
||||
const toggleTag = (tag: string) => {
|
||||
if (packTags.includes(tag)) {
|
||||
setPackTags(packTags.filter(t => t !== tag))
|
||||
} else if (packTags.length < 5) {
|
||||
setPackTags([...packTags, tag])
|
||||
} else {
|
||||
toast({ title: '最多选择 5 个标签', variant: 'destructive' })
|
||||
}
|
||||
}
|
||||
|
||||
// 全选/取消全选
|
||||
const selectAllProviders = () => {
|
||||
if (selectedProviders.size === providers.length) {
|
||||
setSelectedProviders(new Set())
|
||||
} else {
|
||||
setSelectedProviders(new Set(providers.map(p => p.name)))
|
||||
}
|
||||
}
|
||||
|
||||
const selectAllModels = () => {
|
||||
if (selectedModels.size === models.length) {
|
||||
setSelectedModels(new Set())
|
||||
} else {
|
||||
setSelectedModels(new Set(models.map(m => m.name)))
|
||||
}
|
||||
}
|
||||
|
||||
const selectAllTasks = () => {
|
||||
const taskKeys = Object.keys(taskConfig)
|
||||
if (selectedTasks.size === taskKeys.length) {
|
||||
setSelectedTasks(new Set())
|
||||
} else {
|
||||
setSelectedTasks(new Set(taskKeys))
|
||||
}
|
||||
}
|
||||
|
||||
// 提交
|
||||
const handleSubmit = async () => {
|
||||
// 验证
|
||||
if (!packName.trim()) {
|
||||
toast({ title: '请输入模板名称', variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
if (!packDescription.trim()) {
|
||||
toast({ title: '请输入模板描述', variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
if (!packAuthor.trim()) {
|
||||
toast({ title: '请输入作者名称', variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
if (selectedProviders.size === 0 && selectedModels.size === 0 && selectedTasks.size === 0) {
|
||||
toast({ title: '请至少选择一项配置', variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
// 过滤选中的配置
|
||||
const selectedProviderConfigs = providers.filter(p => selectedProviders.has(p.name))
|
||||
const selectedModelConfigs = models.filter(m => selectedModels.has(m.name))
|
||||
const selectedTaskConfigs: PackTaskConfigs = {}
|
||||
for (const [key, config] of Object.entries(taskConfig)) {
|
||||
if (selectedTasks.has(key)) {
|
||||
selectedTaskConfigs[key as keyof PackTaskConfigs] = config
|
||||
}
|
||||
}
|
||||
|
||||
await createPack({
|
||||
name: packName.trim(),
|
||||
description: packDescription.trim(),
|
||||
author: packAuthor.trim(),
|
||||
tags: packTags,
|
||||
providers: selectedProviderConfigs,
|
||||
models: selectedModelConfigs,
|
||||
task_config: selectedTaskConfigs,
|
||||
})
|
||||
|
||||
toast({ title: '模板已提交审核,审核通过后将显示在市场中' })
|
||||
setOpen(false)
|
||||
resetForm()
|
||||
} catch (error) {
|
||||
console.error('提交失败:', error)
|
||||
toast({ title: error instanceof Error ? error.message : '提交失败', variant: 'destructive' })
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
setStep(1)
|
||||
setPackName('')
|
||||
setPackDescription('')
|
||||
setPackAuthor('')
|
||||
setPackTags([])
|
||||
setSelectedProviders(new Set())
|
||||
setSelectedModels(new Set())
|
||||
setSelectedTasks(new Set())
|
||||
}
|
||||
|
||||
const totalSteps = 2
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger || (
|
||||
<Button variant="outline">
|
||||
<Share2 className="w-4 h-4 mr-2" />
|
||||
分享配置
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent className="max-w-2xl flex flex-col" confirmOnEnter>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Package className="w-5 h-5" />
|
||||
分享配置模板
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
步骤 {step} / {totalSteps}:
|
||||
{step === 1 && '选择要分享的配置'}
|
||||
{step === 2 && '填写模板信息'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody>
|
||||
{loading ? (
|
||||
<div className="py-8 text-center">
|
||||
<Loader2 className="w-8 h-8 mx-auto animate-spin text-primary" />
|
||||
<p className="mt-4 text-muted-foreground">正在加载当前配置...</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 步骤 1: 选择配置 */}
|
||||
{step === 1 && (
|
||||
<div className="space-y-4">
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>安全提示</AlertTitle>
|
||||
<AlertDescription>
|
||||
分享的配置将<strong>不包含</strong> API Key,其他用户需要自行配置。
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Tabs defaultValue="providers" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="providers">
|
||||
<Server className="w-4 h-4 mr-2" />
|
||||
API 提供商
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
{selectedProviders.size}/{providers.length}
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="models">
|
||||
<Layers className="w-4 h-4 mr-2" />
|
||||
模型配置
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
{selectedModels.size}/{models.length}
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="tasks">
|
||||
<ListChecks className="w-4 h-4 mr-2" />
|
||||
任务配置
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
{selectedTasks.size}/{Object.keys(taskConfig).length}
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 提供商选择 */}
|
||||
<TabsContent value="providers" className="space-y-2 mt-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-end">
|
||||
<Button variant="ghost" size="sm" onClick={selectAllProviders}>
|
||||
{selectedProviders.size === providers.length ? '取消全选' : '全选'}
|
||||
</Button>
|
||||
</div>
|
||||
{providers.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-2">
|
||||
暂无提供商配置
|
||||
</p>
|
||||
) : (
|
||||
providers.map(provider => (
|
||||
<div
|
||||
key={provider.name}
|
||||
className="flex items-center space-x-2 p-2 rounded hover:bg-muted"
|
||||
>
|
||||
<Checkbox
|
||||
id={`provider-${provider.name}`}
|
||||
checked={selectedProviders.has(provider.name)}
|
||||
onCheckedChange={() => toggleProvider(provider.name)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`provider-${provider.name}`}
|
||||
className="flex-1 cursor-pointer"
|
||||
>
|
||||
<span className="font-medium">{provider.name}</span>
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
{provider.base_url}
|
||||
</span>
|
||||
</Label>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{provider.client_type}
|
||||
</Badge>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* 模型选择 */}
|
||||
<TabsContent value="models" className="space-y-2 mt-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-end">
|
||||
<Button variant="ghost" size="sm" onClick={selectAllModels}>
|
||||
{selectedModels.size === models.length ? '取消全选' : '全选'}
|
||||
</Button>
|
||||
</div>
|
||||
{models.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-2">
|
||||
暂无模型配置
|
||||
</p>
|
||||
) : (
|
||||
models.map(model => (
|
||||
<div
|
||||
key={model.name}
|
||||
className="flex items-center space-x-2 p-2 rounded hover:bg-muted"
|
||||
>
|
||||
<Checkbox
|
||||
id={`model-${model.name}`}
|
||||
checked={selectedModels.has(model.name)}
|
||||
onCheckedChange={() => toggleModel(model.name)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`model-${model.name}`}
|
||||
className="flex-1 cursor-pointer"
|
||||
>
|
||||
<span className="font-medium">{model.name}</span>
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
{model.model_identifier}
|
||||
</span>
|
||||
</Label>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{model.api_provider}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* 任务配置选择 */}
|
||||
<TabsContent value="tasks" className="space-y-2 mt-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-end">
|
||||
<Button variant="ghost" size="sm" onClick={selectAllTasks}>
|
||||
{selectedTasks.size === Object.keys(taskConfig).length ? '取消全选' : '全选'}
|
||||
</Button>
|
||||
</div>
|
||||
{Object.keys(taskConfig).length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-2">
|
||||
暂无任务配置
|
||||
</p>
|
||||
) : (
|
||||
Object.entries(taskConfig).map(([key, config]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="space-y-2 p-2 rounded hover:bg-muted"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`task-${key}`}
|
||||
checked={selectedTasks.has(key)}
|
||||
onCheckedChange={() => toggleTask(key)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`task-${key}`}
|
||||
className="flex-1 cursor-pointer"
|
||||
>
|
||||
<span className="font-medium">
|
||||
{TASK_TYPE_NAMES[key] || key}
|
||||
</span>
|
||||
</Label>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{config.model_list.length} 个模型
|
||||
</Badge>
|
||||
</div>
|
||||
{config.model_list && config.model_list.length > 0 && (
|
||||
<div className="ml-6 flex flex-wrap gap-1">
|
||||
{config.model_list.map((modelName: string) => {
|
||||
const model = models.find(m => m.name === modelName)
|
||||
const isSelected = selectedModels.has(modelName)
|
||||
return (
|
||||
<Badge
|
||||
key={modelName}
|
||||
variant={isSelected ? "default" : "outline"}
|
||||
className="text-xs cursor-pointer hover:opacity-80 transition-opacity"
|
||||
onClick={() => toggleModel(modelName)}
|
||||
>
|
||||
{modelName}
|
||||
{model && (
|
||||
<span className="ml-1 opacity-70">
|
||||
({model.api_provider})
|
||||
</span>
|
||||
)}
|
||||
</Badge>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 步骤 2: 填写信息 */}
|
||||
{step === 2 && (
|
||||
<div className="space-y-4">
|
||||
{/* 选择摘要 */}
|
||||
<div className="flex gap-4 text-sm p-3 bg-muted rounded-lg">
|
||||
<span className="flex items-center gap-1">
|
||||
<Server className="w-4 h-4" />
|
||||
{selectedProviders.size} 个提供商
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Layers className="w-4 h-4" />
|
||||
{selectedModels.size} 个模型
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<ListChecks className="w-4 h-4" />
|
||||
{selectedTasks.size} 个任务
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="pack-name">模板名称 *</Label>
|
||||
<Input
|
||||
id="pack-name"
|
||||
placeholder="例如:高性价比国产模型配置"
|
||||
value={packName}
|
||||
onChange={e => setPackName(e.target.value)}
|
||||
maxLength={50}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{packName.length}/50
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="pack-description">模板描述 *</Label>
|
||||
<Textarea
|
||||
id="pack-description"
|
||||
placeholder="详细描述这个配置模板的特点、适用场景等..."
|
||||
value={packDescription}
|
||||
onChange={e => setPackDescription(e.target.value)}
|
||||
rows={4}
|
||||
maxLength={500}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{packDescription.length}/500
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="pack-author">作者名称 *</Label>
|
||||
<Input
|
||||
id="pack-author"
|
||||
placeholder="你的昵称或 ID"
|
||||
value={packAuthor}
|
||||
onChange={e => setPackAuthor(e.target.value)}
|
||||
maxLength={30}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>标签(可选,最多 5 个)</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{PRESET_TAGS.map(tag => (
|
||||
<Badge
|
||||
key={tag}
|
||||
variant={packTags.includes(tag) ? 'default' : 'outline'}
|
||||
className="cursor-pointer transition-colors"
|
||||
onClick={() => toggleTag(tag)}
|
||||
>
|
||||
{packTags.includes(tag) && <Check className="w-3 h-3 mr-1" />}
|
||||
<Tag className="w-3 h-3 mr-1" />
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>审核说明</AlertTitle>
|
||||
<AlertDescription>
|
||||
提交后需要经过审核才能在市场中展示。审核通常在 1-3 个工作日内完成。
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter className="flex justify-between pt-4 border-t">
|
||||
<div>
|
||||
{step > 1 && (
|
||||
<Button variant="outline" onClick={() => setStep(step - 1)} disabled={submitting}>
|
||||
上一步
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
resetForm()
|
||||
}}
|
||||
disabled={submitting}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
{step < totalSteps ? (
|
||||
<Button
|
||||
data-dialog-action="confirm"
|
||||
onClick={() => setStep(step + 1)}
|
||||
disabled={
|
||||
loading ||
|
||||
(selectedProviders.size === 0 && selectedModels.size === 0 && selectedTasks.size === 0)
|
||||
}
|
||||
>
|
||||
下一步
|
||||
</Button>
|
||||
) : (
|
||||
<Button data-dialog-action="confirm" onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||
提交审核
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
8
dashboard/src/components/survey/index.ts
Normal file
8
dashboard/src/components/survey/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* 问卷组件导出
|
||||
*/
|
||||
|
||||
export { SurveyRenderer } from './survey-renderer'
|
||||
export { SurveyQuestion } from './survey-question'
|
||||
export { SurveyResults } from './survey-results'
|
||||
export type { SurveyRendererProps } from './survey-renderer'
|
||||
247
dashboard/src/components/survey/survey-question.tsx
Normal file
247
dashboard/src/components/survey/survey-question.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* 单个问题渲染组件
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Slider } from '@/components/ui/slider'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Star } from 'lucide-react'
|
||||
import type { SurveyQuestion as SurveyQuestionType } from '@/types/survey'
|
||||
|
||||
interface SurveyQuestionProps {
|
||||
question: SurveyQuestionType
|
||||
value: string | string[] | number | undefined
|
||||
onChange: (value: string | string[] | number) => void
|
||||
error?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function SurveyQuestion({
|
||||
question,
|
||||
value,
|
||||
onChange,
|
||||
error,
|
||||
disabled = false
|
||||
}: SurveyQuestionProps) {
|
||||
const [hoverRating, setHoverRating] = useState<number | null>(null)
|
||||
|
||||
// 如果问题设置了只读,则禁用输入
|
||||
const isDisabled = disabled || question.readOnly
|
||||
|
||||
const renderQuestion = () => {
|
||||
switch (question.type) {
|
||||
case 'single':
|
||||
return (
|
||||
<RadioGroup
|
||||
value={value as string || ''}
|
||||
onValueChange={onChange}
|
||||
disabled={isDisabled}
|
||||
className="space-y-2"
|
||||
>
|
||||
{question.options?.map((option) => (
|
||||
<div key={option.id} className="flex items-center space-x-2">
|
||||
<RadioGroupItem value={option.value} id={`${question.id}-${option.id}`} />
|
||||
<Label
|
||||
htmlFor={`${question.id}-${option.id}`}
|
||||
className="cursor-pointer font-normal"
|
||||
>
|
||||
{option.label}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
)
|
||||
|
||||
case 'multiple': {
|
||||
const selectedValues = (value as string[]) || []
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{question.options?.map((option) => (
|
||||
<div key={option.id} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`${question.id}-${option.id}`}
|
||||
checked={selectedValues.includes(option.value)}
|
||||
disabled={isDisabled || (
|
||||
question.maxSelections !== undefined &&
|
||||
selectedValues.length >= question.maxSelections &&
|
||||
!selectedValues.includes(option.value)
|
||||
)}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
onChange([...selectedValues, option.value])
|
||||
} else {
|
||||
onChange(selectedValues.filter(v => v !== option.value))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`${question.id}-${option.id}`}
|
||||
className="cursor-pointer font-normal"
|
||||
>
|
||||
{option.label}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
{question.maxSelections && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
最多选择 {question.maxSelections} 项
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
case 'text':
|
||||
return (
|
||||
<Input
|
||||
value={value as string || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={question.placeholder || '请输入...'}
|
||||
disabled={isDisabled}
|
||||
readOnly={question.readOnly}
|
||||
maxLength={question.maxLength}
|
||||
className={cn(question.readOnly && "bg-muted cursor-not-allowed")}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'textarea':
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Textarea
|
||||
value={value as string || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={question.placeholder || '请输入...'}
|
||||
disabled={isDisabled}
|
||||
readOnly={question.readOnly}
|
||||
maxLength={question.maxLength}
|
||||
rows={4}
|
||||
className={cn(question.readOnly && "bg-muted cursor-not-allowed")}
|
||||
/>
|
||||
{question.maxLength && (
|
||||
<p className="text-xs text-muted-foreground text-right">
|
||||
{(value as string || '').length} / {question.maxLength}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'rating': {
|
||||
const ratingValue = (value as number) || 0
|
||||
const displayRating = hoverRating !== null ? hoverRating : ratingValue
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<button
|
||||
key={star}
|
||||
type="button"
|
||||
disabled={isDisabled}
|
||||
className={cn(
|
||||
"p-1 transition-colors focus:outline-none focus:ring-2 focus:ring-ring rounded",
|
||||
isDisabled && "cursor-not-allowed opacity-50"
|
||||
)}
|
||||
onMouseEnter={() => !isDisabled && setHoverRating(star)}
|
||||
onMouseLeave={() => setHoverRating(null)}
|
||||
onClick={() => !isDisabled && onChange(star)}
|
||||
>
|
||||
<Star
|
||||
className={cn(
|
||||
"h-6 w-6 transition-colors",
|
||||
star <= displayRating
|
||||
? "fill-yellow-400 text-yellow-400"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
{ratingValue > 0 && (
|
||||
<span className="ml-2 text-sm text-muted-foreground">
|
||||
{ratingValue} / 5
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
case 'scale': {
|
||||
const min = question.min ?? 1
|
||||
const max = question.max ?? 10
|
||||
const step = question.step ?? 1
|
||||
const scaleValue = (value as number) ?? min
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Slider
|
||||
value={[scaleValue]}
|
||||
onValueChange={([val]) => onChange(val)}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>{question.minLabel || min}</span>
|
||||
<span className="font-medium text-foreground">{scaleValue}</span>
|
||||
<span>{question.maxLabel || max}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
case 'dropdown':
|
||||
return (
|
||||
<Select
|
||||
value={value as string || ''}
|
||||
onValueChange={onChange}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={question.placeholder || '请选择...'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{question.options?.map((option) => (
|
||||
<SelectItem key={option.id} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
|
||||
default:
|
||||
return <div className="text-muted-foreground">不支持的问题类型</div>
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-base font-medium">
|
||||
{question.title}
|
||||
{question.required && (
|
||||
<span className="text-destructive ml-1">*</span>
|
||||
)}
|
||||
</Label>
|
||||
{question.description && (
|
||||
<p className="text-sm text-muted-foreground">{question.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{renderQuestion()}
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
407
dashboard/src/components/survey/survey-renderer.tsx
Normal file
407
dashboard/src/components/survey/survey-renderer.tsx
Normal file
@@ -0,0 +1,407 @@
|
||||
/**
|
||||
* 问卷渲染器组件
|
||||
* 读取 JSON 配置并展示问卷界面
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Loader2, CheckCircle2, AlertCircle, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { SurveyQuestion } from './survey-question'
|
||||
import { submitSurvey, checkUserSubmission } from '@/lib/survey-api'
|
||||
import type { SurveyConfig, QuestionAnswer } from '@/types/survey'
|
||||
|
||||
export interface SurveyRendererProps {
|
||||
/** 问卷配置 */
|
||||
config: SurveyConfig
|
||||
/** 初始答案(用于预填充,如自动填写版本号) */
|
||||
initialAnswers?: QuestionAnswer[]
|
||||
/** 提交成功回调 */
|
||||
onSubmitSuccess?: (submissionId: string) => void
|
||||
/** 提交失败回调 */
|
||||
onSubmitError?: (error: string) => void
|
||||
/** 是否显示进度条 */
|
||||
showProgress?: boolean
|
||||
/** 是否分页显示(每页一题) */
|
||||
paginateQuestions?: boolean
|
||||
/** 自定义类名 */
|
||||
className?: string
|
||||
}
|
||||
|
||||
type AnswerMap = Record<string, string | string[] | number | undefined>
|
||||
|
||||
export function SurveyRenderer({
|
||||
config,
|
||||
initialAnswers,
|
||||
onSubmitSuccess,
|
||||
onSubmitError,
|
||||
showProgress = true,
|
||||
paginateQuestions = false,
|
||||
className
|
||||
}: SurveyRendererProps) {
|
||||
// 将 initialAnswers 转换为 AnswerMap
|
||||
const getInitialAnswerMap = useCallback((): AnswerMap => {
|
||||
if (!initialAnswers || initialAnswers.length === 0) return {}
|
||||
return initialAnswers.reduce((acc, answer) => {
|
||||
acc[answer.questionId] = answer.value
|
||||
return acc
|
||||
}, {} as AnswerMap)
|
||||
}, [initialAnswers])
|
||||
|
||||
const [answers, setAnswers] = useState<AnswerMap>(() => getInitialAnswerMap())
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [currentPage, setCurrentPage] = useState(0)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [isSubmitted, setIsSubmitted] = useState(false)
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
const [submissionId, setSubmissionId] = useState<string | null>(null)
|
||||
const [hasAlreadySubmitted, setHasAlreadySubmitted] = useState(false)
|
||||
const [isCheckingSubmission, setIsCheckingSubmission] = useState(true)
|
||||
|
||||
// 当 initialAnswers 变化时更新答案(合并而非替换)
|
||||
useEffect(() => {
|
||||
if (initialAnswers && initialAnswers.length > 0) {
|
||||
setAnswers(prev => ({
|
||||
...prev,
|
||||
...getInitialAnswerMap()
|
||||
}))
|
||||
}
|
||||
}, [initialAnswers, getInitialAnswerMap])
|
||||
|
||||
// 检查是否已提交过
|
||||
useEffect(() => {
|
||||
const checkSubmission = async () => {
|
||||
if (!config.settings?.allowMultiple) {
|
||||
const result = await checkUserSubmission(config.id)
|
||||
if (result.success && result.hasSubmitted) {
|
||||
setHasAlreadySubmitted(true)
|
||||
}
|
||||
}
|
||||
setIsCheckingSubmission(false)
|
||||
}
|
||||
checkSubmission()
|
||||
}, [config.id, config.settings?.allowMultiple])
|
||||
|
||||
// 检查问卷是否在有效期内
|
||||
const isWithinTimeRange = useCallback(() => {
|
||||
const now = new Date()
|
||||
if (config.settings?.startTime && new Date(config.settings.startTime) > now) {
|
||||
return false
|
||||
}
|
||||
if (config.settings?.endTime && new Date(config.settings.endTime) < now) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}, [config.settings?.startTime, config.settings?.endTime])
|
||||
|
||||
// 计算进度
|
||||
const answeredCount = config.questions.filter(q => {
|
||||
const answer = answers[q.id]
|
||||
if (answer === undefined || answer === null) return false
|
||||
if (Array.isArray(answer)) return answer.length > 0
|
||||
if (typeof answer === 'string') return answer.trim() !== ''
|
||||
return true
|
||||
}).length
|
||||
|
||||
const progress = (answeredCount / config.questions.length) * 100
|
||||
|
||||
// 更新答案
|
||||
const handleAnswerChange = useCallback((questionId: string, value: string | string[] | number) => {
|
||||
setAnswers(prev => ({ ...prev, [questionId]: value }))
|
||||
// 清除该问题的错误
|
||||
setErrors(prev => {
|
||||
const newErrors = { ...prev }
|
||||
delete newErrors[questionId]
|
||||
return newErrors
|
||||
})
|
||||
}, [])
|
||||
|
||||
// 验证答案
|
||||
const validateAnswers = useCallback(() => {
|
||||
const newErrors: Record<string, string> = {}
|
||||
|
||||
for (const question of config.questions) {
|
||||
if (question.required) {
|
||||
const answer = answers[question.id]
|
||||
|
||||
if (answer === undefined || answer === null) {
|
||||
newErrors[question.id] = '此题为必填项'
|
||||
continue
|
||||
}
|
||||
|
||||
if (Array.isArray(answer) && answer.length === 0) {
|
||||
newErrors[question.id] = '请至少选择一项'
|
||||
continue
|
||||
}
|
||||
|
||||
if (typeof answer === 'string' && answer.trim() === '') {
|
||||
newErrors[question.id] = '此题为必填项'
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// 文本长度验证
|
||||
if (question.minLength && typeof answers[question.id] === 'string') {
|
||||
const text = answers[question.id] as string
|
||||
if (text.length < question.minLength) {
|
||||
newErrors[question.id] = `至少需要 ${question.minLength} 个字符`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setErrors(newErrors)
|
||||
return Object.keys(newErrors).length === 0
|
||||
}, [config.questions, answers])
|
||||
|
||||
// 提交问卷
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!validateAnswers()) {
|
||||
// 如果是分页模式,跳转到第一个有错误的问题
|
||||
if (paginateQuestions) {
|
||||
const firstErrorIndex = config.questions.findIndex(q => errors[q.id])
|
||||
if (firstErrorIndex >= 0) {
|
||||
setCurrentPage(firstErrorIndex)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
setSubmitError(null)
|
||||
|
||||
try {
|
||||
// 构建答案列表
|
||||
const answerList: QuestionAnswer[] = config.questions
|
||||
.filter(q => answers[q.id] !== undefined)
|
||||
.map(q => ({
|
||||
questionId: q.id,
|
||||
value: answers[q.id]!
|
||||
}))
|
||||
|
||||
const result = await submitSurvey(
|
||||
config.id,
|
||||
config.version,
|
||||
answerList,
|
||||
{ allowMultiple: config.settings?.allowMultiple }
|
||||
)
|
||||
|
||||
if (result.success && result.submissionId) {
|
||||
setIsSubmitted(true)
|
||||
setSubmissionId(result.submissionId)
|
||||
onSubmitSuccess?.(result.submissionId)
|
||||
} else {
|
||||
const error = result.error || '提交失败'
|
||||
setSubmitError(error)
|
||||
onSubmitError?.(error)
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : '提交失败'
|
||||
setSubmitError(errorMsg)
|
||||
onSubmitError?.(errorMsg)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}, [validateAnswers, paginateQuestions, config, answers, errors, onSubmitSuccess, onSubmitError])
|
||||
|
||||
// 分页导航
|
||||
const goToPage = useCallback((page: number) => {
|
||||
if (page >= 0 && page < config.questions.length) {
|
||||
setCurrentPage(page)
|
||||
}
|
||||
}, [config.questions.length])
|
||||
|
||||
// 检查中
|
||||
if (isCheckingSubmission) {
|
||||
return (
|
||||
<Card className={cn("w-full max-w-2xl mx-auto", className)}>
|
||||
<CardContent className="py-12 flex items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// 已提交过
|
||||
if (hasAlreadySubmitted && !config.settings?.allowMultiple) {
|
||||
return (
|
||||
<Card className={cn("w-full max-w-2xl mx-auto", className)}>
|
||||
<CardHeader>
|
||||
<CardTitle>{config.title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="py-8">
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
你已经提交过这份问卷了,感谢参与!
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// 不在有效期内
|
||||
if (!isWithinTimeRange()) {
|
||||
return (
|
||||
<Card className={cn("w-full max-w-2xl mx-auto", className)}>
|
||||
<CardHeader>
|
||||
<CardTitle>{config.title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="py-8">
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
问卷不在有效期内
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// 提交成功
|
||||
if (isSubmitted) {
|
||||
return (
|
||||
<Card className={cn("w-full max-w-2xl mx-auto", className)}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-green-600">
|
||||
<CheckCircle2 className="h-6 w-6" />
|
||||
提交成功
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="py-8">
|
||||
<p className="text-center text-muted-foreground">
|
||||
{config.settings?.thankYouMessage || '感谢你的参与!'}
|
||||
</p>
|
||||
{submissionId && (
|
||||
<p className="text-center text-xs text-muted-foreground mt-4">
|
||||
提交编号:{submissionId}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// 问卷展示
|
||||
const questionsToShow = paginateQuestions
|
||||
? [config.questions[currentPage]]
|
||||
: config.questions
|
||||
|
||||
return (
|
||||
<div className={cn("h-full flex flex-col", className)}>
|
||||
{/* 问卷头部 */}
|
||||
<div className="rounded-lg border bg-card p-4 sm:p-6 mb-4 shrink-0">
|
||||
<h2 className="text-xl font-semibold">{config.title}</h2>
|
||||
{config.description && (
|
||||
<p className="text-muted-foreground mt-1 text-sm">{config.description}</p>
|
||||
)}
|
||||
{showProgress && (
|
||||
<div className="space-y-1 pt-3">
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>进度</span>
|
||||
<span>{answeredCount} / {config.questions.length}</span>
|
||||
</div>
|
||||
<Progress value={progress} className="h-2" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 问卷内容 - 可滚动区域 */}
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
<div className="space-y-4 pr-4">
|
||||
{questionsToShow.map((question, index) => (
|
||||
<div
|
||||
key={question.id}
|
||||
className={cn(
|
||||
"p-4 rounded-lg border bg-card",
|
||||
errors[question.id] ? "border-destructive bg-destructive/5" : "border-border"
|
||||
)}
|
||||
>
|
||||
{paginateQuestions && (
|
||||
<div className="text-xs text-muted-foreground mb-2">
|
||||
问题 {currentPage + 1} / {config.questions.length}
|
||||
</div>
|
||||
)}
|
||||
{!paginateQuestions && (
|
||||
<div className="text-xs text-muted-foreground mb-2">
|
||||
{index + 1}.
|
||||
</div>
|
||||
)}
|
||||
<SurveyQuestion
|
||||
question={question}
|
||||
value={answers[question.id]}
|
||||
onChange={(value) => handleAnswerChange(question.id, value)}
|
||||
error={errors[question.id]}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{submitError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{submitError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 提交按钮区域 */}
|
||||
<div className="flex justify-between items-center py-4">
|
||||
{paginateQuestions ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => goToPage(currentPage - 1)}
|
||||
disabled={currentPage === 0 || isSubmitting}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
上一题
|
||||
</Button>
|
||||
|
||||
{currentPage === config.questions.length - 1 ? (
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
提交问卷
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => goToPage(currentPage + 1)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
下一题
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{Object.keys(errors).length > 0 && (
|
||||
<span className="text-destructive">
|
||||
还有 {Object.keys(errors).length} 个必填项未完成
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
size="lg"
|
||||
>
|
||||
{isSubmitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
提交问卷
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
292
dashboard/src/components/survey/survey-results.tsx
Normal file
292
dashboard/src/components/survey/survey-results.tsx
Normal file
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* 问卷结果查看组件
|
||||
* 展示问卷统计数据和用户提交记录
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Loader2, Users, FileText, Clock, Star, BarChart3 } from 'lucide-react'
|
||||
import { getSurveyStats, getUserSubmissions } from '@/lib/survey-api'
|
||||
import type { SurveyConfig, SurveyStats, StoredSubmission } from '@/types/survey'
|
||||
|
||||
interface SurveyResultsProps {
|
||||
/** 问卷配置 */
|
||||
config: SurveyConfig
|
||||
/** 是否显示用户提交记录 */
|
||||
showUserSubmissions?: boolean
|
||||
/** 自定义类名 */
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function SurveyResults({
|
||||
config,
|
||||
showUserSubmissions = true,
|
||||
className
|
||||
}: SurveyResultsProps) {
|
||||
const [stats, setStats] = useState<SurveyStats | null>(null)
|
||||
const [userSubmissions, setUserSubmissions] = useState<StoredSubmission[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
// 获取统计数据
|
||||
const statsResult = await getSurveyStats(config.id)
|
||||
if (statsResult.success && statsResult.stats) {
|
||||
setStats(statsResult.stats)
|
||||
}
|
||||
|
||||
// 获取用户提交记录
|
||||
if (showUserSubmissions) {
|
||||
const submissionsResult = await getUserSubmissions(config.id)
|
||||
if (submissionsResult.success && submissionsResult.submissions) {
|
||||
setUserSubmissions(submissionsResult.submissions)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载数据失败')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchData()
|
||||
}, [config.id, showUserSubmissions])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className={cn("w-full max-w-3xl mx-auto", className)}>
|
||||
<CardContent className="py-12 flex items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card className={cn("w-full max-w-3xl mx-auto", className)}>
|
||||
<CardContent className="py-12 text-center text-muted-foreground">
|
||||
{error}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={cn("w-full max-w-3xl mx-auto", className)}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<BarChart3 className="h-5 w-5" />
|
||||
{config.title} - 统计结果
|
||||
</CardTitle>
|
||||
{config.description && (
|
||||
<CardDescription>{config.description}</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
{/* 概览统计 */}
|
||||
<div className="grid grid-cols-3 gap-4 mb-6">
|
||||
<div className="p-4 rounded-lg bg-muted/50 text-center">
|
||||
<div className="flex items-center justify-center gap-2 text-muted-foreground mb-1">
|
||||
<FileText className="h-4 w-4" />
|
||||
<span className="text-sm">总提交数</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold">
|
||||
{stats?.totalSubmissions || 0}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-lg bg-muted/50 text-center">
|
||||
<div className="flex items-center justify-center gap-2 text-muted-foreground mb-1">
|
||||
<Users className="h-4 w-4" />
|
||||
<span className="text-sm">独立用户</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold">
|
||||
{stats?.uniqueUsers || 0}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-lg bg-muted/50 text-center">
|
||||
<div className="flex items-center justify-center gap-2 text-muted-foreground mb-1">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span className="text-sm">最后提交</span>
|
||||
</div>
|
||||
<div className="text-sm font-medium">
|
||||
{stats?.lastSubmissionAt
|
||||
? new Date(stats.lastSubmissionAt).toLocaleDateString()
|
||||
: '-'
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="stats" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="stats">问题统计</TabsTrigger>
|
||||
{showUserSubmissions && (
|
||||
<TabsTrigger value="submissions">我的提交</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="stats" className="mt-4">
|
||||
<ScrollArea className="max-h-[60vh]">
|
||||
<div className="space-y-6 pr-4">
|
||||
{config.questions.map((question, index) => {
|
||||
const qStats = stats?.questionStats[question.id]
|
||||
|
||||
return (
|
||||
<div key={question.id} className="p-4 rounded-lg border">
|
||||
<div className="text-xs text-muted-foreground mb-1">
|
||||
问题 {index + 1}
|
||||
</div>
|
||||
<div className="font-medium mb-3">{question.title}</div>
|
||||
|
||||
{qStats ? (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
回答人数:{qStats.answered}
|
||||
</div>
|
||||
|
||||
{/* 选择题统计 */}
|
||||
{qStats.optionCounts && question.options && (
|
||||
<div className="space-y-2">
|
||||
{question.options.map(option => {
|
||||
const count = qStats.optionCounts?.[option.value] || 0
|
||||
const percentage = qStats.answered > 0
|
||||
? (count / qStats.answered) * 100
|
||||
: 0
|
||||
|
||||
return (
|
||||
<div key={option.id} className="space-y-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>{option.label}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{count} ({percentage.toFixed(1)}%)
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={percentage} className="h-2" />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 评分/量表统计 */}
|
||||
{qStats.average !== undefined && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Star className="h-4 w-4 text-yellow-400" />
|
||||
<span className="text-sm">
|
||||
平均分:{qStats.average.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 文本答案样本 */}
|
||||
{qStats.sampleAnswers && qStats.sampleAnswers.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
部分回答:
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{qStats.sampleAnswers.map((answer, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-sm p-2 bg-muted/50 rounded text-muted-foreground"
|
||||
>
|
||||
"{answer}"
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
暂无数据
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
{showUserSubmissions && (
|
||||
<TabsContent value="submissions" className="mt-4">
|
||||
<ScrollArea className="max-h-[60vh]">
|
||||
{userSubmissions.length === 0 ? (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
你还没有提交过这份问卷
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 pr-4">
|
||||
{userSubmissions.map((submission) => (
|
||||
<div key={submission.id} className="p-4 rounded-lg border">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<Badge variant="outline">
|
||||
{new Date(submission.submittedAt).toLocaleString()}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
ID: {submission.id}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{submission.answers.map((answer) => {
|
||||
const question = config.questions.find(
|
||||
q => q.id === answer.questionId
|
||||
)
|
||||
|
||||
if (!question) return null
|
||||
|
||||
// 格式化答案显示
|
||||
let displayValue: string
|
||||
if (Array.isArray(answer.value)) {
|
||||
const labels = answer.value.map(v => {
|
||||
const opt = question.options?.find(o => o.value === v)
|
||||
return opt?.label || v
|
||||
})
|
||||
displayValue = labels.join('、')
|
||||
} else if (typeof answer.value === 'number') {
|
||||
displayValue = answer.value.toString()
|
||||
} else {
|
||||
const opt = question.options?.find(
|
||||
o => o.value === answer.value
|
||||
)
|
||||
displayValue = opt?.label || answer.value
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={answer.questionId} className="text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{question.title}:
|
||||
</span>
|
||||
<span>{displayValue}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
97
dashboard/src/components/theme-provider.tsx
Normal file
97
dashboard/src/components/theme-provider.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
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 ThemeProviderProps = {
|
||||
children: ReactNode
|
||||
defaultTheme?: Theme
|
||||
storageKey?: string
|
||||
}
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = 'system',
|
||||
storageKey: _storageKey,
|
||||
}: ThemeProviderProps) {
|
||||
const [themeMode, setThemeMode] = useState<Theme>(() => {
|
||||
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(() => {
|
||||
migrateOldKeys()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const handleChange = () => {
|
||||
if (themeMode === 'system') {
|
||||
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 (
|
||||
<ThemeProviderContext value={value}>
|
||||
{children}
|
||||
</ThemeProviderContext>
|
||||
)
|
||||
}
|
||||
5
dashboard/src/components/tour/index.ts
Normal file
5
dashboard/src/components/tour/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { TourProvider } from './tour-provider'
|
||||
export { TourRenderer } from './tour-renderer'
|
||||
export { useTour } from './use-tour'
|
||||
export { TourContext } from './tour-context'
|
||||
export type { TourId, TourState, TourContextType } from './types'
|
||||
4
dashboard/src/components/tour/tour-context.ts
Normal file
4
dashboard/src/components/tour/tour-context.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { createContext } from 'react'
|
||||
import type { TourContextType } from './types'
|
||||
|
||||
export const TourContext = createContext<TourContextType | null>(null)
|
||||
177
dashboard/src/components/tour/tour-provider.tsx
Normal file
177
dashboard/src/components/tour/tour-provider.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import { useState, useCallback, type ReactNode } from 'react'
|
||||
import type { Step, CallBackProps, Status } from 'react-joyride'
|
||||
import { TourContext } from './tour-context'
|
||||
import type { TourId, TourState } from './types'
|
||||
|
||||
const COMPLETED_TOURS_KEY = 'maibot-completed-tours'
|
||||
|
||||
// 从 localStorage 读取已完成的 Tours
|
||||
function getCompletedTours(): Set<TourId> {
|
||||
try {
|
||||
const stored = localStorage.getItem(COMPLETED_TOURS_KEY)
|
||||
return stored ? new Set(JSON.parse(stored)) : new Set()
|
||||
} catch {
|
||||
return new Set()
|
||||
}
|
||||
}
|
||||
|
||||
// 保存已完成的 Tours 到 localStorage
|
||||
function saveCompletedTours(tours: Set<TourId>) {
|
||||
localStorage.setItem(COMPLETED_TOURS_KEY, JSON.stringify([...tours]))
|
||||
}
|
||||
|
||||
export function TourProvider({ children }: { children: ReactNode }) {
|
||||
const [state, setState] = useState<TourState>({
|
||||
activeTourId: null,
|
||||
stepIndex: 0,
|
||||
isRunning: false,
|
||||
})
|
||||
|
||||
// 使用 useState 存储 tours(Map 对象是可变的,可以直接修改)
|
||||
const [tours] = useState<Map<TourId, Step[]>>(() => new Map())
|
||||
const [completedTours, setCompletedTours] = useState<Set<TourId>>(getCompletedTours)
|
||||
// 用于强制重新渲染的计数器
|
||||
const [, forceUpdate] = useState(0)
|
||||
|
||||
const registerTour = useCallback((tourId: TourId, steps: Step[]) => {
|
||||
tours.set(tourId, steps)
|
||||
// 强制更新以确保 context 消费者能获取到最新数据
|
||||
forceUpdate(n => n + 1)
|
||||
}, [tours])
|
||||
|
||||
const unregisterTour = useCallback((tourId: TourId) => {
|
||||
tours.delete(tourId)
|
||||
// 如果正在运行的 Tour 被注销,停止它
|
||||
setState(prev => {
|
||||
if (prev.activeTourId === tourId) {
|
||||
return { ...prev, activeTourId: null, isRunning: false, stepIndex: 0 }
|
||||
}
|
||||
return prev
|
||||
})
|
||||
}, [tours])
|
||||
|
||||
const startTour = useCallback((tourId: TourId, startIndex = 0) => {
|
||||
if (tours.has(tourId)) {
|
||||
setState({
|
||||
activeTourId: tourId,
|
||||
stepIndex: startIndex,
|
||||
isRunning: true,
|
||||
})
|
||||
}
|
||||
}, [tours])
|
||||
|
||||
const stopTour = useCallback(() => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isRunning: false,
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const goToStep = useCallback((index: number) => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
stepIndex: index,
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const nextStep = useCallback(() => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
stepIndex: prev.stepIndex + 1,
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const prevStep = useCallback(() => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
stepIndex: Math.max(0, prev.stepIndex - 1),
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const getCurrentSteps = useCallback((): Step[] => {
|
||||
if (!state.activeTourId) return []
|
||||
return tours.get(state.activeTourId) || []
|
||||
}, [state.activeTourId, tours])
|
||||
|
||||
const markTourCompleted = useCallback((tourId: TourId) => {
|
||||
setCompletedTours(prev => {
|
||||
const next = new Set(prev)
|
||||
next.add(tourId)
|
||||
saveCompletedTours(next)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleJoyrideCallback = useCallback((data: CallBackProps) => {
|
||||
const { action, index, status, type } = data
|
||||
const finishedStatuses: Status[] = ['finished', 'skipped']
|
||||
|
||||
// 处理关闭按钮点击
|
||||
if (action === 'close') {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isRunning: false,
|
||||
stepIndex: 0,
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
if (finishedStatuses.includes(status)) {
|
||||
// Tour 完成或跳过
|
||||
setState(prev => {
|
||||
if (status === 'finished' && prev.activeTourId) {
|
||||
// 使用 setTimeout 避免在 setState 中调用另一个 setState
|
||||
setTimeout(() => markTourCompleted(prev.activeTourId!), 0)
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
isRunning: false,
|
||||
stepIndex: 0,
|
||||
}
|
||||
})
|
||||
} else if (type === 'step:after') {
|
||||
// 步骤切换后更新索引
|
||||
if (action === 'next') {
|
||||
setState(prev => ({ ...prev, stepIndex: index + 1 }))
|
||||
} else if (action === 'prev') {
|
||||
setState(prev => ({ ...prev, stepIndex: index - 1 }))
|
||||
}
|
||||
}
|
||||
}, [markTourCompleted])
|
||||
|
||||
const isTourCompleted = useCallback((tourId: TourId): boolean => {
|
||||
return completedTours.has(tourId)
|
||||
}, [completedTours])
|
||||
|
||||
const resetTourCompleted = useCallback((tourId: TourId) => {
|
||||
setCompletedTours(prev => {
|
||||
const next = new Set(prev)
|
||||
next.delete(tourId)
|
||||
saveCompletedTours(next)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<TourContext
|
||||
value={{
|
||||
state,
|
||||
tours,
|
||||
registerTour,
|
||||
unregisterTour,
|
||||
startTour,
|
||||
stopTour,
|
||||
goToStep,
|
||||
nextStep,
|
||||
prevStep,
|
||||
getCurrentSteps,
|
||||
handleJoyrideCallback,
|
||||
isTourCompleted,
|
||||
markTourCompleted,
|
||||
resetTourCompleted,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</TourContext>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user