diff --git a/CHANGELOG.md b/CHANGELOG.md index 148a3cbb..1f91b782 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,117 @@ --- +## [v0.1.5] - 2026-09-29 + +**主题:AI 助手面板与画布节点视觉升级(BorderBeam 流光描边 + ThinkingOrb 加载球体)、TTS 拖拽与音色选择修复、agentscope 2.0.9 升级与依赖安全下界抬升** + +上一版本:`v0.1.4`。本版本无数据库迁移;前端新增 2 个第三方依赖(`border-beam`、`thinking-orbs`)。 + +### ✨ 新增 + +#### 输入框描边流光(BorderBeam) + +- Agent 生成回复时(`isLoading`),消息输入框外缘出现持续流动的描边光效,停止生成后平滑淡出;`colorVariant="ocean"`、`strength=0.6`,亮/暗主题均适配,不影响输入与响应式布局。 + +#### 加载指示器升级为 ThinkingOrb 球体动画 + +- AI 助手面板的三点加载动画(`LoadingDots` / `StreamingIndicator`)统一替换为 `thinking-orbs` 球体动画,按 Agent 阶段动态映射状态:初始响应=`listening`、深度思考=`searching`、生成输出 / Worker 执行=`working`、Leader 调度=`connecting`。 +- 采用 ` + <文字>` 并排布局(`flex items-center gap-2`,orb 在左),内联场景 `size=20`;`theme` 跟随应用主题,附 `aria-label` 描述状态。 + +#### 画布节点 Agent 操作描边流光 + +- Agent 调用画布工具时,节点卡片的 CSS 脉冲边框升级为 BorderBeam 流光:读取 `reading` / 扫描 `scanning` → ocean、编辑 `updating` → gold、删除 `deleting` → sunset、连线 `connecting` → forest;创建态 `GhostNode` → ocean。 +- 保留原图标徽标、背景色与 `reading` 扫描光带;以「绝对定位覆盖层」方式集成(内联 style 覆盖组件默认的 `position:relative`),不裁剪节点外部工具栏 / 缩放手柄 / 下挂面板。 + +#### orbs-demo 演示页 + +- 新增 `/demo/orbs-demo`:展示 ThinkingOrb 全 9 种状态(`size` 20 内联 / 64 头像级)与画布节点描边效果,含状态切换、速度调节交互,复用生产组件 `NodeEffectOverlay` / `GhostNode` 保证与真实渲染一致。 + +### 🔧 改进 + +- **TTSSkills 音色描述细化**:`generate_tts` 工具定义与 `tts_tools` 技能的音色清单由「仅风格」升级为「按性别分组 + 语气特征」(如女声 `Kore (firm)`、男声 `Charon (informative)`,女 14 / 男 16),Agent 可据角色精准选男/女声;后端对 LLM 可能回传的尾部括注做防御式剥离(`Kore (firm)` → `Kore`),`auto` / 裸名 / 复刻 `voice_` ID 不受影响。 +- **TTS 语音卡拖拽**:AI 助手面板生成的语音拖到画布时正确生成 TTS 节点并保留文本 / 音色 / 风格 / 多说话人配置,不再误建为普通音频节点。 +- 移除已弃用的 `LoadingDots` 组件及其 barrel 导出、孤立的 `nodeEffectPulse` 关键帧。 + +### 🐛 修复 + +- **画布节点类型错误**:TTS 工具生成的音频拖拽到画布被渲染为 `AudioNode` 而非 `TtsNode` —— `TtsTaskCard` 改用 `handleTtsDragStart`(写入 `application/reactflow='tts'`),`dragToCanvas` 新增 tts 节点配置与 TTS 特有字段透传。 + +### 🔒 依赖与安全 + +- **9 个后端依赖安全下界抬升**(PR #327,排除已知漏洞版本):`python-multipart>=0.0.32`、`psycopg2-binary>=2.9.12`、`bcrypt>=5.0.0`、`google-genai>=2.10.0`、`ollama>=0.6.2`、`python-frontmatter>=1.3.0`、`packaging>=26.2`、`cachetools>=7.1.4`、`purgatory>=3.0.1`;`redis` 因 arq 0.28 约束维持 `>=5,<6`。 +- **agentscope 升级 `>=2.0.4.post1 → >=2.0.9`**;**移除 `ripgrep==14.1.0` 锁定**(自 agentscope 2.0.4 起 ripgrep 为可选依赖,本项目不使用内置 Grep 工具)。 +- **不再需要 Rust 工具链**:`dev.py` 移除 Rust 检测、`backend.Dockerfile` 与 README / README_EN / UPGRADE 移除 Rust 1.85+ 要求与安装步骤,本地 / Docker 安装不再触发 cargo 编译。 +- **前端新增依赖**:`border-beam@^1.4.1`、`thinking-orbs@^0.3.2`(均零运行时依赖,peer React>=18)。 + +### ✅ 测试与验证 + +- 前端 `tsc --noEmit`:新增/改动文件(MessageInput、ChatMessage、Single/MultiAgentPanel、NodeEffectOverlay、GhostNode、dragToCanvas、TtsTaskCard、orbs-demo)零类型错误,仅剩既有 `theater/[id]/page.tsx` 的 OnNodeDrag 基线告警。 +- ESLint:新增代码零告警,orbs-demo 页 0 问题。 +- 后端 `tts_gen.py` `py_compile` 通过;音色清单断言(女 14 / 男 16 带语气)+ 括注剥离正则离线校验通过。 +- 两个 i18n locale(zh-CN / en-US)JSON 合法(Node `JSON.parse` 通过)。 + +### 📌 升级说明 + +1. **无需执行数据库迁移**。 +2. 前端新增依赖:`cd frontend && npm install`(安装 border-beam、thinking-orbs)后重启 dev server。 +3. 后端依赖下界抬升属安全加固,`pip install -r requirements.txt` 按新下界解析;agentscope 升到 2.0.9、不再需要 ripgrep / Rust。 +4. **合并提示**:本版本 `requirements.txt` 已并入 main 上 PR #327 的 9 个安全下界,与 main 合并时该文件应干净自动合并(数值一致)。 +5. 后端 Python 改动需重启服务生效;纯前端改动热更即可。 + +### 📁 主要变更文件 + +**后端** + +``` +backend/config.py 版本号 0.1.5(单一来源) +backend/requirements.txt agentscope>=2.0.9、移除 ripgrep、9 依赖安全下界 +backend/services/tool_manager/providers/tts_gen.py 音色性别+语气描述、防御式括注剥离 +backend/skills/{builtin,active}_skills/tts_tools/SKILL.md 音色清单细化 +``` + +**构建 / 脚本 / 文档** + +``` +dev.py 移除 Rust 工具链检测 +deploy/backend.Dockerfile ripgrep 为可选依赖说明,无需 Rust +README.md / README_EN.md 移除 Rust 1.85+ 环境要求 +UPGRADE.md 精简,移除 Rust 安装步骤 +``` + +**前端** + +``` +frontend/package.json / package-lock.json 新增 border-beam、thinking-orbs;版本号 0.1.5 +frontend/src/components/ai-assistant/MessageInput.tsx 输入框 BorderBeam 描边流光 +frontend/src/components/ai-assistant/ChatMessage.tsx StreamingIndicator → ThinkingOrb(listening) +frontend/src/components/ai-assistant/SingleAgentThinkPanel.tsx LoadingDots → ThinkingOrb(searching) +frontend/src/components/ai-assistant/MultiAgentPanel.tsx LoadingDots → ThinkingOrb(connecting/working) +frontend/src/components/ai-assistant/LoadingDots.tsx 删除 +frontend/src/components/ai-assistant/index.ts 移除 LoadingDots 导出 +frontend/src/components/ai-assistant/TtsTaskCard.tsx 拖拽生成 TTS 节点 +frontend/src/lib/dragToCanvas.ts handleTtsDragStart + tts 节点配置 +frontend/src/components/canvas/NodeEffectOverlay.tsx 5 效果脉冲边框 → BorderBeam 流光 +frontend/src/components/canvas/GhostNode.tsx 创建态脉冲边框 → BorderBeam 流光 +frontend/src/app/globals.css 移除孤立 nodeEffectPulse 关键帧 +frontend/src/app/demo/orbs-demo/page.tsx 新增:Orb + 节点描边演示页 +frontend/src/i18n/locales/{zh-CN,en-US}.json 状态文案键 +``` + +**管理端** + +``` +backend/admin/package.json / package-lock.json 版本号 0.1.5 +``` + +**文档** + +``` +CHANGELOG.md 本文件 +``` + +--- + ## [v0.1.4] - 2026-09-28 **主题:Gemini TTS 语音合成节点全栈接入(画布节点 + 音色复刻/扩展音色库/试听 + Agent 技能 + 管理端配置)** diff --git a/README.md b/README.md index 9357ebff..50b12944 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,6 @@ KunFlix 专为**影视广告与短剧创作**打造的开放式AI内容创作生 ### 环境要求 - **Python 3.12+**(AgentScope 2.0 强制要求;旧 3.10 venv 需删除重建) -- **Rust 1.85+**(仅当从源码编译 ripgrep 时需要;首次安装会触发,参见 [UPGRADE.md](./UPGRADE.md)) - Node.js 20+ - Docker 24+(Docker 部署时需要) - PostgreSQL 18(开发与生产统一;本地可用 `docker compose -f deploy/docker-compose.dev.yml up -d` 一键启动 PostgreSQL + Redis) diff --git a/README_EN.md b/README_EN.md index e3e459b0..8ed45470 100644 --- a/README_EN.md +++ b/README_EN.md @@ -129,7 +129,6 @@ KunFlix is an open AI content creation ecosystem designed for **film advertising ### Prerequisites - **Python 3.12+** (required by AgentScope 2.0; old 3.10 venvs must be recreated) -- **Rust 1.85+** (only when compiling ripgrep from source; triggered on first install, see [UPGRADE.md](./UPGRADE.md)) - Node.js 20+ - Docker 24+ (required for Docker deployment) - PostgreSQL 18 (unified for dev & prod; start locally with `docker compose -f deploy/docker-compose.dev.yml up -d` for PostgreSQL + Redis) diff --git a/UPGRADE.md b/UPGRADE.md index 9655b7fb..3f937e2b 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -14,11 +14,10 @@ | 组件 | 旧版本 | 新版本 | 是否必装 | |---|---|---|---| | **Python** | 3.10+ | **3.11+** | 必须 | -| **Rust toolchain** | 不需要 | **stable ≥ 1.85**(含 cargo) | 仅本地首次安装需要(运行时不依赖) | | Node.js | 20+ | 20+ | 不变 | -| AgentScope | `1.0.18` | `>=2.0.0` | 由 `requirements.txt` 锁定 | +| AgentScope | `1.0.18` | `>=2.0.9` | 由 `requirements.txt` 锁定 | -**为什么需要 Rust?** AgentScope 2.0 把 [`Grep` 工具](https://docs.agentscope.io/v2/building-blocks/tool.md) 的实现交给了 PyPI 上的 [`ripgrep`](https://pypi.org/project/ripgrep/) 包;这个包在 Linux / macOS 没有预编译 wheel,必须本地用 cargo 编译。**项目运行时不会用到 Grep 工具**,但 ripgrep 是 agentscope 的硬安装依赖,跳不掉。编译只在首次 `pip install` 时一次性发生,之后 wheel 进入 pip 缓存,重装秒过。 +**不再需要 Rust。** AgentScope 自 2.0.4 起把 [`ripgrep`](https://pypi.org/project/ripgrep/)(内置 [`Grep` 工具](https://docs.agentscope.io/v2/building-blocks/tool.md) 的实现)改为**可选依赖**,2.0.8 起又将其锁定在 `<15.x`。KunFlix 运行时不使用内置 `Grep` 工具,`requirements.txt` 也已移除对 ripgrep 的显式锁定,因此本地 / Docker 安装都不会再触发 cargo 编译,无需安装 Rust 工具链。 --- @@ -29,20 +28,16 @@ # 1. 安装 Python 3.12(如已有 3.11+ 可跳过) winget install -e --id Python.Python.3.12 -# 2. 安装 Rust(如已有 stable ≥ 1.85 可跳过) -winget install -e --id Rustlang.Rustup -rustup default stable - -# 3. 删旧 venv,用 3.12 重建 +# 2. 删旧 venv,用 3.12 重建 Remove-Item -Recurse -Force "$PWD\backend\venv" & "$env:LOCALAPPDATA\Programs\Python\Python312\python.exe" -m venv backend\venv -# 4. 安装依赖(首次会触发 ripgrep cargo 编译,约 3-8 分钟) +# 3. 安装依赖 $env:PYTHONUTF8="1"; $env:PYTHONIOENCODING="utf-8" .\backend\venv\Scripts\python.exe -m pip install --upgrade pip .\backend\venv\Scripts\python.exe -m pip install -r .\backend\requirements.txt -# 5. 验证 +# 4. 验证 .\backend\venv\Scripts\python.exe -c "import agentscope; print(agentscope.__version__)" # 预期:2.0.x ``` @@ -53,75 +48,23 @@ $env:PYTHONUTF8="1"; $env:PYTHONIOENCODING="utf-8" brew install python@3.12 # macOS # Linux: 用包管理器 / pyenv install 3.12 -# 2. 安装 Rust -curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -source "$HOME/.cargo/env" - -# 3. 重建 venv +# 2. 重建 venv rm -rf backend/venv python3.12 -m venv backend/venv -# 4. 安装依赖 +# 3. 安装依赖 backend/venv/bin/pip install --upgrade pip backend/venv/bin/pip install -r backend/requirements.txt -# 5. 验证 +# 4. 验证 backend/venv/bin/python -c "import agentscope; print(agentscope.__version__)" ``` -之后 `python dev.py` 启动开发环境,[dev.py](./dev.py) 会在启动前自动校验 Python 与 cargo 是否就绪,若 venv 仍是 3.10 会主动提示重建。 +之后 `python dev.py` 启动开发环境,[dev.py](./dev.py) 会在启动前自动校验 Python 版本,若 venv 仍是 3.10 会主动提示重建。 --- -### 1.3 国内开发者:镜像加速(强烈推荐) - -不配镜像,rustup 下载和 cargo 拉 crates.io 索引可能慢到 16 KiB/s。配完后 5–20 MiB/s。 - -#### rustup 镜像(环境变量,仅当前 shell 生效) - -```powershell -$env:RUSTUP_DIST_SERVER = "https://rsproxy.cn" -$env:RUSTUP_UPDATE_ROOT = "https://rsproxy.cn/rustup" -rustup update stable -``` - -```bash -export RUSTUP_DIST_SERVER="https://rsproxy.cn" -export RUSTUP_UPDATE_ROOT="https://rsproxy.cn/rustup" -rustup update stable -``` - -#### cargo crates.io 镜像(写入 `~/.cargo/config.toml`,永久生效) - -```toml -[source.crates-io] -replace-with = "rsproxy-sparse" - -[source.rsproxy-sparse] -registry = "sparse+https://rsproxy.cn/index/" - -[net] -git-fetch-with-cli = true -``` - -PowerShell 一行写好: - -```powershell -$cargoDir = "$env:USERPROFILE\.cargo" -New-Item -ItemType Directory -Force -Path $cargoDir | Out-Null -@" -[source.crates-io] -replace-with = "rsproxy-sparse" - -[source.rsproxy-sparse] -registry = "sparse+https://rsproxy.cn/index/" - -[net] -git-fetch-with-cli = true -"@ | Set-Content "$cargoDir\config.toml" -Encoding UTF8 -``` - -#### pip 镜像 +### 1.3 国内开发者:pip 镜像加速(推荐) `requirements.txt` 默认走 `pypi.tuna.tsinghua.edu.cn`(在 [dev.py](./dev.py) 中通过 `pip config` 已配置)。如果你的环境没配,可手动指定: @@ -133,25 +76,18 @@ pip install -r backend/requirements.txt -i https://mirrors.aliyun.com/pypi/simpl ### 1.4 生产 / 测试环境(Docker 路径) -[deploy/backend.Dockerfile](./deploy/backend.Dockerfile) 已经改造为 multi-stage: - -| Stage | 作用 | 是否进运行镜像 | -|---|---|---| -| `rust-builder` | 装 Rust + cargo + 编译 ripgrep wheel | 否(仅产出 `/wheels`) | -| `base` | 纯 Python 3.14 + `pip install --no-index --find-links /wheels` | 是(运行镜像) | - -**好处**:运行镜像不带 Rust toolchain(节省 ~1GB),且 builder 内置 `rsproxy.cn` 镜像,crate 拉取无需翻墙。 +[deploy/backend.Dockerfile](./deploy/backend.Dockerfile) 为**单阶段构建**:基于 `python:3.14-slim`,直接 `pip install -r requirements.txt`。由于 ripgrep 已是 agentscope 可选依赖且项目不使用,镜像内**不再包含 Rust 构建阶段**,构建更快、体积更小。 #### 2c4g 测试 / 生产服务器的部署流程 ```bash cd /opt/kunflix/deploy -# 串行构建(避免 cargo 并发编译爆 CPU/内存) +# 串行构建(避免多镜像并发 build 爆 CPU/内存) sudo bash scripts/update.sh --serial ``` -[deploy/scripts/update.sh](./deploy/scripts/update.sh) 中已有 `--serial` 参数,按 backend → admin → frontend 串行 build,对 2c4g 友好。**首次构建仍会触发 cargo 编译 ripgrep(~5-8 分钟),后续构建命中 BuildKit 缓存秒过。** +[deploy/scripts/update.sh](./deploy/scripts/update.sh) 中已有 `--serial` 参数,按 backend → admin → frontend 串行 build,对 2c4g 友好。 --- @@ -160,13 +96,9 @@ sudo bash scripts/update.sh --serial | 报错关键字 | 根因 | 解决 | |---|---|---| -| `Could not find a version that satisfies agentscope>=2.0.0` | Python < 3.11 | 装 3.11+,删 venv 重建 | -| `Ignored the following versions that require a different python version: 2.0.0 Requires-Python >=3.11` | 同上(pip 静默过滤) | 同上 | -| `feature edition2024 is required` / `not stabilized in this version of Cargo (1.80.x)` | Rust < 1.85 | `rustup update stable` | -| `rustup could not choose a version of cargo to run` | rustup 装了但没设默认 toolchain | `rustup default stable` | -| `Preparing metadata (pyproject.toml) ...` 长时间卡住 | cargo 拉 crates.io 慢 | 配 cargo `rsproxy.cn` 镜像,重新 `pip install` | -| `error: could not create link from rustup.exe to cargo-miri.exe (os error 183)` | rustup self-update 旧 shim 残留(不影响 toolchain 升级) | 忽略;toolchain 已升级 | -| Docker 构建期 OOM | 2c4g 并发跑 cargo 内存不够 | `update.sh --serial`;提前 `docker compose stop frontend admin` 腾资源 | +| `Could not find a version that satisfies agentscope>=2.0.9` | Python < 3.11 | 装 3.11+,删 venv 重建 | +| `Ignored the following versions that require a different python version: ... Requires-Python >=3.11` | 同上(pip 静默过滤) | 同上 | +| Docker 构建期 OOM | 2c4g 并发 build 内存不够 | `update.sh --serial`;提前 `docker compose stop frontend admin` 腾资源 | --- diff --git a/backend/admin/package-lock.json b/backend/admin/package-lock.json index d858c445..51e228a8 100644 --- a/backend/admin/package-lock.json +++ b/backend/admin/package-lock.json @@ -1,12 +1,12 @@ { "name": "admin-dashboard", - "version": "0.1.4", + "version": "0.1.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "admin-dashboard", - "version": "0.1.4", + "version": "0.1.5", "dependencies": { "@hookform/resolvers": "^5.4.0", "@monaco-editor/react": "^4.7.0", diff --git a/backend/admin/package.json b/backend/admin/package.json index 986043e3..cfb8cf20 100644 --- a/backend/admin/package.json +++ b/backend/admin/package.json @@ -1,6 +1,6 @@ { "name": "admin-dashboard", - "version": "0.1.4", + "version": "0.1.5", "private": true, "scripts": { "dev": "next dev -p 3888", diff --git a/backend/config.py b/backend/config.py index ee7d6a85..86a511f5 100644 --- a/backend/config.py +++ b/backend/config.py @@ -9,7 +9,7 @@ class Settings(BaseSettings): PROJECT_NAME: str = "KunFlix" # 后端版本号 —— 全项目版本的单一来源,与 frontend/package.json、 # backend/admin/package.json 及 CHANGELOG.md 保持同步 - VERSION: str = "0.1.4" + VERSION: str = "0.1.5" # --------------------------------------------------------------- # Database diff --git a/backend/requirements.txt b/backend/requirements.txt index 9abae2c4..14867e64 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -9,10 +9,7 @@ aiosqlite>=0.22.1 redis[hiredis]>=5.0.0,<6.0.0 websockets>=16.0 python-dotenv>=1.2.1 -agentscope>=2.0.4.post1 -# ripgrep 15.x 无预编译 Linux wheel 需 Rust 从源码编译(首次构建 30+ min), -# 14.1.0 提供 manylinux wheel,功能上 agentscope 无版本约束,锁定以避免 Rust 工具链 -ripgrep==14.1.0 +agentscope>=2.0.9 openai>=2.36.0 python-multipart>=0.0.32 aiofiles>=25.1.0 diff --git a/backend/services/tool_manager/providers/tts_gen.py b/backend/services/tool_manager/providers/tts_gen.py index dbb59255..7521dc5a 100644 --- a/backend/services/tool_manager/providers/tts_gen.py +++ b/backend/services/tool_manager/providers/tts_gen.py @@ -8,6 +8,7 @@ import json import logging +import re from typing import Any, TYPE_CHECKING from database import safe_commit @@ -24,9 +25,13 @@ # --------------------------------------------------------------------------- TTS_GEN_TOOL_NAME = "generate_tts" -# 按性别分组(供工具描述引导 Agent 按男/女选音色) -_FEMALE_VOICES = [name for name, _tone, g in PREBUILT_VOICES if g == "female"] -_MALE_VOICES = [name for name, _tone, g in PREBUILT_VOICES if g == "male"] +# 按性别分组并附语气特征(供工具描述引导 Agent 按男/女 + 声音特质精准选音色) +# 形如 "Kore (firm)":括号内为音色固有语气,仅用于描述,Agent 只需回传音色名 +_FEMALE_VOICES = [f"{name} ({tone.lower()})" for name, tone, g in PREBUILT_VOICES if g == "female"] +_MALE_VOICES = [f"{name} ({tone.lower()})" for name, tone, g in PREBUILT_VOICES if g == "male"] + +# 防御:LLM 可能把描述里的 "Kore (firm)" 整串回传,剥离尾部括注只保留音色名("auto" 不受影响) +_VOICE_ANNOTATION_RE = re.compile(r"\s*[\((][^\))]*[\))]\s*$") # --------------------------------------------------------------------------- @@ -55,6 +60,8 @@ def _build_tts_gen_tool_def( "Voice for single-speaker mode. Use 'auto' to let the model pick a suitable voice (default). " "Gender is an intrinsic property of each voice and CANNOT be changed via 'style' — " "to get a male or female speaker, choose a voice of that gender. " + "Each entry below is 'Name (characteristic)': pick the name whose characteristic fits the role, " + "but pass ONLY the bare name as the value (e.g. 'Kore', NOT 'Kore (firm)'). " "Female voices: " + ", ".join(_FEMALE_VOICES) + ". " "Male voices: " + ", ".join(_MALE_VOICES) + ". " "You may also pass a persisted replicated voice ID (a string starting with 'voice_') " @@ -75,7 +82,7 @@ def _build_tts_gen_tool_def( "type": "object", "properties": { "speaker": {"type": "string", "description": "Character name, e.g. 'Joe'"}, - "voice": {"type": "string", "description": "Voice for this speaker. Female: '" + "', '".join(_FEMALE_VOICES[:6]) + "', ...; Male: '" + "', '".join(_MALE_VOICES[:6]) + "', ...; or a replicated 'voice_...' ID. Omit to use auto."}, + "voice": {"type": "string", "description": "Voice for this speaker (pass the bare name only). Female e.g. " + ", ".join(_FEMALE_VOICES[:5]) + "; Male e.g. " + ", ".join(_MALE_VOICES[:5]) + "; or a replicated 'voice_...' ID. Omit to use auto."}, "text": {"type": "string", "description": "This speaker's line (verbatim)"}, "style": {"type": "string", "description": "Optional per-turn delivery style"}, }, @@ -125,7 +132,8 @@ async def _execute_tts_gen_tool(args: dict, ctx: "ToolContext") -> str: db = ctx.db text = args.get("text", "") or "" - voice = args.get("voice", "") or "" + # 防御:剥离 LLM 可能回传的尾部括注(如 "Kore (firm)" → "Kore"),"auto" 不受影响 + voice = _VOICE_ANNOTATION_RE.sub("", args.get("voice", "") or "").strip() style = args.get("style", "") or "" speakers_raw = args.get("speakers", []) or [] @@ -141,7 +149,7 @@ async def _execute_tts_gen_tool(args: dict, ctx: "ToolContext") -> str: final_format = tts_cfg.get("output_format") or "wav" speakers = [ - {"speaker": s.get("speaker", ""), "voice": s.get("voice", ""), "text": s.get("text", ""), "style": s.get("style", "")} + {"speaker": s.get("speaker", ""), "voice": _VOICE_ANNOTATION_RE.sub("", s.get("voice", "") or "").strip(), "text": s.get("text", ""), "style": s.get("style", "")} for s in speakers_raw[:2] if isinstance(s, dict) and s.get("speaker") and s.get("text") ] diff --git a/backend/skills/active_skills/tts_tools/SKILL.md b/backend/skills/active_skills/tts_tools/SKILL.md index ee00ed89..eb4f69d1 100644 --- a/backend/skills/active_skills/tts_tools/SKILL.md +++ b/backend/skills/active_skills/tts_tools/SKILL.md @@ -29,7 +29,7 @@ Convert text into natural-sounding speech audio, with fine-grained control over | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `text` | string | Single-speaker mode | The verbatim transcript to speak. Never put stage directions here. Inline vocal tags ``, ``, ``, ``, `` are allowed. | -| `voice` | string | No | Prebuilt voice name. Use `"auto"` (default) to let the model pick a suitable voice. e.g. Kore (firm), Puck (upbeat), Zephyr (bright), Charon (informative), Leda (youthful), Aoede (breezy), Sulafat (warm). | +| `voice` | string | No | Voice for single-speaker mode. Use `"auto"` (default) to let the model pick. **Gender is intrinsic to each voice and cannot be changed via `style`** — to get a male/female speaker, pick a voice of that gender. Each entry is `Name (characteristic)`; pass **only the bare name** as the value (e.g. `Kore`, not `Kore (firm)`). **Female:** Zephyr (bright), Kore (firm), Leda (youthful), Aoede (breezy), Callirrhoe (easy-going), Autonoe (bright), Despina (smooth), Erinome (clear), Laomedeia (upbeat), Achernar (soft), Gacrux (mature), Pulcherrima (forward), Vindemiatrix (gentle), Sulafat (warm). **Male:** Puck (upbeat), Charon (informative), Fenrir (excitable), Orus (firm), Enceladus (breathy), Iapetus (clear), Umbriel (easy-going), Algieba (smooth), Algenib (gravelly), Rasalgethi (informative), Alnilam (firm), Schedar (even), Achird (friendly), Zubenelgenubi (casual), Sadachbia (lively), Sadaltager (knowledgeable). You may also pass a user's persisted replicated voice ID (a string starting with `voice_`). | | `style` | string | No | Sustained turn-level delivery: emotion, pace, tone. e.g. "cheerful and friendly", "whispered urgently", "calm and relaxed". | | `speakers` | object[] | Dialogue mode | Max 2 speakers. Each item: `{speaker, voice, text, style?}` — one item per dialogue turn. Overrides top-level `text`/`voice`/`style`. | @@ -72,6 +72,7 @@ generate_tts( - The speech language follows the input text language automatically — write the text in the target language. - Keep `text` strictly verbatim: anything written there will be read aloud. +- To choose a male or female speaker, select a voice of that gender (gender cannot be set through `style`); pick the characteristic in parentheses that fits the role (e.g. a warm female narrator → `Sulafat (warm)`, an authoritative male narrator → `Charon (informative)`). For a two-person dialogue, pick one male and one female voice for contrast. - When the user does not specify a voice, use `voice="auto"` and let the model choose. - Use different `style` per dialogue turn for more natural conversation. - Speech generation is async — inform the user it will take 10-60 seconds. diff --git a/backend/skills/builtin_skills/tts_tools/SKILL.md b/backend/skills/builtin_skills/tts_tools/SKILL.md index 47058320..eb4f69d1 100644 --- a/backend/skills/builtin_skills/tts_tools/SKILL.md +++ b/backend/skills/builtin_skills/tts_tools/SKILL.md @@ -29,7 +29,7 @@ Convert text into natural-sounding speech audio, with fine-grained control over | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `text` | string | Single-speaker mode | The verbatim transcript to speak. Never put stage directions here. Inline vocal tags ``, ``, ``, ``, `` are allowed. | -| `voice` | string | No | Voice for single-speaker mode. Use `"auto"` (default) to let the model pick. **Gender is intrinsic to each voice and cannot be changed via `style`** — to get a male/female speaker, pick a voice of that gender. Female: Zephyr, Kore, Leda, Aoede, Callirrhoe, Autonoe, Despina, Erinome, Laomedeia, Achernar, Gacrux, Pulcherrima, Vindemiatrix, Sulafat. Male: Puck, Charon, Fenrir, Orus, Enceladus, Iapetus, Umbriel, Algenib, Algieba, Schedar, Achird, Zubenelgenubi, Sadachbia, Sadaltager, Alnilam, Rasalgethi. You may also pass a user's persisted replicated voice ID (a string starting with `voice_`). | +| `voice` | string | No | Voice for single-speaker mode. Use `"auto"` (default) to let the model pick. **Gender is intrinsic to each voice and cannot be changed via `style`** — to get a male/female speaker, pick a voice of that gender. Each entry is `Name (characteristic)`; pass **only the bare name** as the value (e.g. `Kore`, not `Kore (firm)`). **Female:** Zephyr (bright), Kore (firm), Leda (youthful), Aoede (breezy), Callirrhoe (easy-going), Autonoe (bright), Despina (smooth), Erinome (clear), Laomedeia (upbeat), Achernar (soft), Gacrux (mature), Pulcherrima (forward), Vindemiatrix (gentle), Sulafat (warm). **Male:** Puck (upbeat), Charon (informative), Fenrir (excitable), Orus (firm), Enceladus (breathy), Iapetus (clear), Umbriel (easy-going), Algieba (smooth), Algenib (gravelly), Rasalgethi (informative), Alnilam (firm), Schedar (even), Achird (friendly), Zubenelgenubi (casual), Sadachbia (lively), Sadaltager (knowledgeable). You may also pass a user's persisted replicated voice ID (a string starting with `voice_`). | | `style` | string | No | Sustained turn-level delivery: emotion, pace, tone. e.g. "cheerful and friendly", "whispered urgently", "calm and relaxed". | | `speakers` | object[] | Dialogue mode | Max 2 speakers. Each item: `{speaker, voice, text, style?}` — one item per dialogue turn. Overrides top-level `text`/`voice`/`style`. | @@ -72,7 +72,7 @@ generate_tts( - The speech language follows the input text language automatically — write the text in the target language. - Keep `text` strictly verbatim: anything written there will be read aloud. -- To choose a male or female speaker, select a voice of that gender (gender cannot be set through `style`). For a two-person dialogue, pick one male and one female voice for contrast. +- To choose a male or female speaker, select a voice of that gender (gender cannot be set through `style`); pick the characteristic in parentheses that fits the role (e.g. a warm female narrator → `Sulafat (warm)`, an authoritative male narrator → `Charon (informative)`). For a two-person dialogue, pick one male and one female voice for contrast. - When the user does not specify a voice, use `voice="auto"` and let the model choose. - Use different `style` per dialogue turn for more natural conversation. - Speech generation is async — inform the user it will take 10-60 seconds. diff --git a/deploy/backend.Dockerfile b/deploy/backend.Dockerfile index 13a1f902..9964929a 100644 --- a/deploy/backend.Dockerfile +++ b/deploy/backend.Dockerfile @@ -5,7 +5,7 @@ # Build context: project root (.) # docker build -f deploy/backend.Dockerfile -t kunflix-backend .. # -# ripgrep 已锁定 14.1.0(有预编译 manylinux wheel),无需 Rust 工具链。 +# ripgrep 自 agentscope 2.0.4 起为可选依赖,项目未使用内置 Grep 工具,无需安装、无需 Rust 工具链。 # 单阶段构建,体积小、速度快。 # ============================================================================ @@ -80,7 +80,7 @@ ENV PIP_INDEX_URL=${PIP_INDEX_URL} \ PIP_DEFAULT_TIMEOUT=120 \ PIP_RETRIES=3 -# 安装依赖(ripgrep==14.1.0 有预编译 wheel,无需 Rust) +# 安装依赖(ripgrep 为 agentscope 可选依赖,本项目不使用,无需 Rust) COPY backend/requirements.txt /app/requirements.txt RUN --mount=type=cache,target=/root/.cache/pip,sharing=locked \ pip install --upgrade pip \ diff --git a/dev.py b/dev.py index 7ccd5af1..6dfec0b1 100644 --- a/dev.py +++ b/dev.py @@ -1,7 +1,6 @@ import os import platform import re -import shutil import subprocess import sys import threading @@ -24,7 +23,7 @@ # 颜色代码(跨平台可能需要 colorama,这里为了简单只做基本处理或不加) PROCESSES = [] -# AgentScope 2.0 要求 Python >= 3.11;ripgrep 从源码编译需 Rust >= 1.85 +# AgentScope 2.0 要求 Python >= 3.11 _MIN_PYTHON = (3, 11) def log(message, prefix="[SYSTEM]"): @@ -53,21 +52,6 @@ def _check_python_version() -> None: sys.exit(1) -def _check_rust_toolchain() -> None: - """警告级检测:本地首次 pip install 遇上 ripgrep 编译会需 Rust 工具链。 - - 不 fail-fast:部分平台(如 Windows + cp312 wheel 已发布)可能不需本地编译, - 仅提示避免 pip 报错后才定位问题。 - """ - cargo = shutil.which("cargo") - if cargo: - return - log("未检测到 cargo;agentscope 2.0 首次安装可能需本地编译 ripgrep。", "[BACKEND]") - log("若随后 pip install 报错 'feature edition2024 is required',请安装 Rust:", "[BACKEND]") - log(" Windows : winget install -e --id Rustlang.Rustup; rustup default stable", "[BACKEND]") - log(" macOS/Linux: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh", "[BACKEND]") - - def get_python_exec(): """获取虚拟环境中的 python 解释器路径""" if sys.platform == "win32": @@ -96,7 +80,6 @@ def setup_backend(): log("Checking backend environment...", "[BACKEND]") _check_python_version() - _check_rust_toolchain() venv_path = os.path.join(BACKEND_DIR, "venv") python_exec = get_python_exec() diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 577bac40..711a1821 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "frontend", - "version": "0.1.4", + "version": "0.1.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "frontend", - "version": "0.1.4", + "version": "0.1.5", "dependencies": { "@ant-design/nextjs-registry": "^1.3.0", "@e965/xlsx": "^0.20.3", @@ -51,6 +51,7 @@ "@xyflow/react": "^12.10.1", "antd": "^6.4.2", "axios": "^1.16.0", + "border-beam": "^1.4.1", "clsx": "^2.1.1", "dagre": "^0.8.5", "framer-motion": "^12.34.3", @@ -78,6 +79,7 @@ "swr": "^2.4.0", "tailwind-merge": "^3.5.0", "tailwindcss-animate": "^1.0.7", + "thinking-orbs": "^0.3.2", "uuid": "^14.0.0", "zustand": "^5.0.14" }, @@ -7150,6 +7152,16 @@ "node": ">=6.0.0" } }, + "node_modules/border-beam": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/border-beam/-/border-beam-1.4.1.tgz", + "integrity": "sha512-YyugmKjrhkg1PBC1ZKX7JvFo/zYzha6NXOq24VWrnvBYmDRvGCgFS9oxZoKJ+oS9Kn436l1etYGlAdGiWK+3ag==", + "license": "MIT", + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, "node_modules/brace-expansion": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", @@ -16025,6 +16037,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/thinking-orbs": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/thinking-orbs/-/thinking-orbs-0.3.2.tgz", + "integrity": "sha512-QZFeBaPEzqhjiZoXy961EvDgaXk2WgXacF3Rbz+Sj5IfzDzBvTC86vdREUHniBffYibJFKSwbfllxLUdI/Xv6g==", + "license": "MIT", + "peerDependencies": { + "react": ">=18.0.0" + } + }, "node_modules/three": { "version": "0.185.1", "resolved": "https://registry.npmjs.org/three/-/three-0.185.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 51971a13..635bd48f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "frontend", - "version": "0.1.4", + "version": "0.1.5", "private": true, "scripts": { "dev": "next dev -p 3666", @@ -54,6 +54,7 @@ "@xyflow/react": "^12.10.1", "antd": "^6.4.2", "axios": "^1.16.0", + "border-beam": "^1.4.1", "clsx": "^2.1.1", "dagre": "^0.8.5", "framer-motion": "^12.34.3", @@ -81,6 +82,7 @@ "swr": "^2.4.0", "tailwind-merge": "^3.5.0", "tailwindcss-animate": "^1.0.7", + "thinking-orbs": "^0.3.2", "uuid": "^14.0.0", "zustand": "^5.0.14" }, diff --git a/frontend/src/app/demo/orbs-demo/page.tsx b/frontend/src/app/demo/orbs-demo/page.tsx new file mode 100644 index 00000000..077591f2 --- /dev/null +++ b/frontend/src/app/demo/orbs-demo/page.tsx @@ -0,0 +1,200 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import { ThinkingOrb, type OrbState } from 'thinking-orbs'; +import { cn } from '@/lib/utils'; +import { useTheme } from '@/context/ThemeContext'; +import NodeEffectOverlay from '@/components/canvas/NodeEffectOverlay'; +import GhostNode from '@/components/canvas/GhostNode'; +import { useCanvasStore, type NodeEffect } from '@/store/useCanvasStore'; + +/* ── 9 种状态元数据(label = 库枚举值,hint = 中文语义) ─────────────── */ +const ORB_STATES: { state: OrbState; label: string; hint: string }[] = [ + { state: 'working', label: 'working', hint: '工作 / 生成输出' }, + { state: 'searching', label: 'searching', hint: '搜索 / 深度思考' }, + { state: 'solving', label: 'solving', hint: '求解 / 推理' }, + { state: 'listening', label: 'listening', hint: '聆听 / 初始响应' }, + { state: 'connecting', label: 'connecting', hint: '连接 / Leader 调度' }, + { state: 'weaving', label: 'weaving', hint: '编织 / 多智能体协作' }, + { state: 'composing', label: 'composing', hint: '编排 / 组织内容' }, + { state: 'breathing', label: 'breathing', hint: '呼吸 / 待机' }, + { state: 'shaping', label: 'shaping', hint: '塑形 / 结构化' }, +]; + +/* ── 画布节点卡片的 Agent 操作效果(复用 NodeEffectOverlay 的真实状态枚举)── */ +const NODE_EFFECTS: NodeEffect[] = ['reading', 'scanning', 'updating', 'deleting', 'connecting']; + +// GhostNode 仅消费 data.targetNodeType,用最小 props 断言为完整 NodeProps 类型 +const ghostProps = { data: { targetNodeType: 'text' } } as unknown as React.ComponentProps; + +/** + * ThinkingOrb 动画演示页(视觉验收 / 调试用)。 + * 展示全部 9 种 state 在 size=20(内联)与 size=64(头像级)下的形态, + * 并提供状态切换与速度调节交互;theme 跟随应用当前主题。 + */ +export default function OrbsDemoPage() { + const { resolvedTheme } = useTheme(); + const theme = resolvedTheme === 'dark' ? 'dark' : 'light'; + const [selected, setSelected] = useState('working'); + const [speed, setSpeed] = useState(1); + + const selectedMeta = ORB_STATES.find((s) => s.state === selected) ?? ORB_STATES[0]; + + // 为演示卡片在 canvas store 中常驻设置各类节点效果(卸载时清除,避免污染真实画布) + useEffect(() => { + const effects: Record = {}; + NODE_EFFECTS.forEach((e) => { effects[`demo-${e}`] = e; }); + useCanvasStore.getState().setNodeEffects(effects); + return () => { + const s = useCanvasStore.getState(); + NODE_EFFECTS.forEach((e) => s.clearNodeEffect(`demo-${e}`)); + }; + }, []); + + return ( +
+
+ {/* 头部 */} +
+

ThinkingOrb & 节点描边动画演示

+

+ thinking-orbs 的 9 种状态 + 画布节点 BorderBeam 描边效果 · 当前主题:{resolvedTheme} +

+
+ + {/* 交互预览 */} +
+
+ + {/* 内联形态:orb 在左、文字在右 */} +
+
+ + {``} + +
+ + {/* 状态切换按钮 */} +
+ {ORB_STATES.map((s) => ( + + ))} +
+ + {/* 速度调节 */} +
+ + setSpeed(parseFloat(e.target.value))} + className="w-48" + /> + + {speed.toFixed(2)}× + +
+
+ + {/* 全部状态 · size 64(头像级) */} +
+

全部状态 · size 64(头像级)

+
+ {ORB_STATES.map((s) => ( +
+ + {s.label} +
+ ))} +
+
+ + {/* 全部状态 · size 20(内联,与文字并排) */} +
+

+ 全部状态 · size 20(内联,与文字并排) +

+
+ {ORB_STATES.map((s) => ( +
+
+ ))} +
+
+ + {/* 节点卡片描边效果(Agent 操作画布节点,复用生产组件 NodeEffectOverlay / GhostNode)*/} +
+
+

+ 节点卡片描边效果 · Agent 操作画布节点 +

+

+ 复用生产组件 NodeEffectOverlay(BorderBeam 流光 + 图标徽标 + 背景色 + reading 扫描光带);实际使用中为瞬态,工具完成后约 1.5s 淡出。 +

+
+ +
+ {NODE_EFFECTS.map((eff) => ( +
+ +
+
+ + + {eff} +
+ + + +
+
+ ))} +
+ + {/* 创建态 · GhostNode */} +
+

+ 创建态 · GhostNode(create_canvas_node / 媒体生成占位) +

+
+ +
+
+
+
+
+ ); +} diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 1fd32bce..65934446 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -634,11 +634,6 @@ body { } /* ── AI Node Effect Animations ────────────────────────────── */ -@keyframes nodeEffectPulse { - 0%, 100% { opacity: 0.6; } - 50% { opacity: 1; } -} - @keyframes nodeEffectScan { 0% { transform: translateX(-100%); } 100% { transform: translateX(400%); } diff --git a/frontend/src/components/ai-assistant/ChatMessage.tsx b/frontend/src/components/ai-assistant/ChatMessage.tsx index e2fc6c9d..08c26cdd 100644 --- a/frontend/src/components/ai-assistant/ChatMessage.tsx +++ b/frontend/src/components/ai-assistant/ChatMessage.tsx @@ -4,9 +4,10 @@ import React, { useMemo, useState, useEffect, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; -import { motion } from 'framer-motion'; import { Music, Film, Image as ImageIcon, ScrollText, Play, X } from 'lucide-react'; import { cn } from '@/lib/utils'; +import { ThinkingOrb, type OrbState } from 'thinking-orbs'; +import { useTheme } from '@/context/ThemeContext'; import { TypewriterText } from './TypewriterText'; import { CallTimelinePanel } from './CallTimelinePanel'; import { ThinkPanel } from './ThinkPanel'; @@ -371,27 +372,19 @@ function HarnessEventBanner({ events }: { events: HarnessEvent[] }) { ); } -// 平滑流式指示器(用于内容生成中) -function StreamingIndicator() { +// 流式加载指示器:ThinkingOrb 球体(左)+ 状态文字(右)并排 +// state 按 Agent 阶段映射:listening=初始响应 / searching=深度思考 / working=生成输出 +interface StreamingIndicatorProps { + state?: OrbState; + statusText: string; + theme: 'dark' | 'light'; +} + +function StreamingIndicator({ state = 'listening', statusText, theme }: StreamingIndicatorProps) { return ( -
- {/* 波浪动画 */} - {[0, 1, 2].map((i) => ( - - ))} +
+ + {statusText}
); } @@ -559,6 +552,8 @@ function UserAttachmentPreview({ attachments }: { attachments: NodeAttachment[] export function ChatMessage({ message, className, onRetry }: ChatMessageProps) { const { t } = useTranslation(); + const { resolvedTheme } = useTheme(); + const orbTheme = resolvedTheme === 'dark' ? 'dark' : 'light'; const isUser = message.role === 'user'; const isStreaming = message.status === 'streaming'; @@ -721,7 +716,11 @@ export function ChatMessage({ message, className, onRetry }: ChatMessageProps) { {/* 流式输出且无思考内容时显示平滑加载动画 */} {isStreaming && !thinkingContent && !message.multi_agent && !cleanContent && (
- +
)} diff --git a/frontend/src/components/ai-assistant/LoadingDots.tsx b/frontend/src/components/ai-assistant/LoadingDots.tsx deleted file mode 100644 index b30e5614..00000000 --- a/frontend/src/components/ai-assistant/LoadingDots.tsx +++ /dev/null @@ -1,49 +0,0 @@ -'use client'; - -import React from 'react'; -import { cn } from '@/lib/utils'; - -interface LoadingDotsProps { - size?: 'sm' | 'md' | 'lg'; - className?: string; -} - -const sizeMap = { - sm: 'h-1 w-1', - md: 'h-1.5 w-1.5', - lg: 'h-2 w-2', -}; - -const gapMap = { - sm: 'gap-0.5', - md: 'gap-1', - lg: 'gap-1.5', -}; - -export function LoadingDots({ size = 'md', className }: LoadingDotsProps) { - return ( -
- - - -
- ); -} diff --git a/frontend/src/components/ai-assistant/MessageInput.tsx b/frontend/src/components/ai-assistant/MessageInput.tsx index cc439988..39417b63 100644 --- a/frontend/src/components/ai-assistant/MessageInput.tsx +++ b/frontend/src/components/ai-assistant/MessageInput.tsx @@ -35,6 +35,8 @@ import { selectNodesByUpdatedDesc } from '@/store/useCanvasStore'; import { extractNodeAttachment } from '@/lib/nodeAttachmentUtils'; import { NodePickerDropdown, type NodePickerItem } from '@/components/canvas/NodePickerDropdown'; import { useCreditsGuard } from '@/hooks/useCreditsGuard'; +import { BorderBeam } from 'border-beam'; +import { useTheme } from '@/context/ThemeContext'; // ─── Constants ─────────────────────────────────────────────────────────────── const MAX_FILES = 10; @@ -325,6 +327,8 @@ export function MessageInput({ }: MessageInputProps) { const { t } = useTranslation(); const { creditsExhausted, tooltipText } = useCreditsGuard(); + const { resolvedTheme } = useTheme(); + const isDark = resolvedTheme === 'dark'; const resolvedPlaceholder = placeholder ?? t('ai.inputPlaceholder'); const resolvedAgentName = agentName ?? t('ai.title'); const [inputValue, setInputValue] = useState(''); @@ -600,6 +604,14 @@ export function MessageInput({
+ {/* 输入框描边流光:Agent 生成中(isLoading)时外缘持续流动,停止后平滑淡出 */} + {/* 主输入容器 */}
@@ -793,6 +805,7 @@ export function MessageInput({
+ {/* 隐藏文件输入 */} diff --git a/frontend/src/components/ai-assistant/MultiAgentPanel.tsx b/frontend/src/components/ai-assistant/MultiAgentPanel.tsx index 7505f1ee..50291e96 100644 --- a/frontend/src/components/ai-assistant/MultiAgentPanel.tsx +++ b/frontend/src/components/ai-assistant/MultiAgentPanel.tsx @@ -4,7 +4,9 @@ import React, { useState, useEffect, useMemo } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { ChevronDown, ChevronUp, CheckCircle2, Circle, XCircle, Loader2, Wrench, Crown, Image as ImageIcon, Video, Music, MessagesSquare, AlertCircle } from 'lucide-react'; import { cn } from '@/lib/utils'; -import { LoadingDots } from './LoadingDots'; +import { ThinkingOrb } from 'thinking-orbs'; +import { useTheme } from '@/context/ThemeContext'; +import { useTranslation } from 'react-i18next'; import { LazyImage } from './LazyImage'; import type { AgentStep, OrchestrationStyle, ToolCall } from '@/store/useAIAssistantStore'; @@ -115,6 +117,13 @@ export function MultiAgentPanel({ }; }, [steps]); + const { resolvedTheme } = useTheme(); + const isDark = resolvedTheme === 'dark'; + const { t } = useTranslation(); + // 编排阶段动态映射 Orb 状态:Worker 执行中 → working;Leader 调度/起步 → connecting + const isWorkerRunning = steps.some(s => s.status === 'running' && !s.isLeader); + const orbState: 'connecting' | 'working' = isWorkerRunning ? 'working' : 'connecting'; + // 当前执行的步骤 const currentStep = useMemo(() => steps.find(s => s.status === 'running'), @@ -173,6 +182,15 @@ export function MultiAgentPanel({ {/* 标题和状态 */}
+ {isThinking && ( + + )} {(() => { // team_tools 模式专用文案:团队协作:{teamName} @@ -184,7 +202,6 @@ export function MultiAgentPanel({ return isThinking ? doingLabel : progress.isAllDone ? doneLabel : idleLabel; })()} - {isThinking && }
{/* 副标题:team_tools 下展示 Leader + Worker 统计;legacy 下展示当前步骤描述 */} {orchestrationStyle === 'team_tools' && progress.workerTotal > 0 ? ( diff --git a/frontend/src/components/ai-assistant/SingleAgentThinkPanel.tsx b/frontend/src/components/ai-assistant/SingleAgentThinkPanel.tsx index 09d5040a..3da2b92f 100644 --- a/frontend/src/components/ai-assistant/SingleAgentThinkPanel.tsx +++ b/frontend/src/components/ai-assistant/SingleAgentThinkPanel.tsx @@ -4,7 +4,9 @@ import React, { useState, useEffect, useRef } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { ChevronDown, ChevronUp } from 'lucide-react'; import { cn } from '@/lib/utils'; -import { LoadingDots } from './LoadingDots'; +import { ThinkingOrb } from 'thinking-orbs'; +import { useTheme } from '@/context/ThemeContext'; +import { useTranslation } from 'react-i18next'; interface SingleAgentThinkPanelProps { isThinking?: boolean; @@ -33,6 +35,9 @@ export function SingleAgentThinkPanel({ const [isExpanded, setIsExpanded] = useState(false); const [userExpandedManually, setUserExpandedManually] = useState(false); const scrollRef = useRef(null); + const { resolvedTheme } = useTheme(); + const isDark = resolvedTheme === 'dark'; + const { t } = useTranslation(); // 自动展开/折叠逻辑 useEffect(() => { @@ -81,15 +86,23 @@ export function SingleAgentThinkPanel({ isExpanded ? : )} - {/* 标题和状态 */} + {/* 标题和状态:Orb 在左、文字在右 */}
+ {isThinking && ( + + )} {isThinking ? (agentName ? `${agentName} Think...` : 'AI Thinking...') : 'Think complete'} - {isThinking && }
diff --git a/frontend/src/components/ai-assistant/TtsTaskCard.tsx b/frontend/src/components/ai-assistant/TtsTaskCard.tsx index 30c5e514..3c6c8c03 100644 --- a/frontend/src/components/ai-assistant/TtsTaskCard.tsx +++ b/frontend/src/components/ai-assistant/TtsTaskCard.tsx @@ -6,7 +6,7 @@ import { motion } from 'framer-motion'; import { toast } from 'sonner'; import { cn } from '@/lib/utils'; import api from '@/lib/api'; -import { handleAudioDragStart, cleanupDragPreview } from '@/lib/dragToCanvas'; +import { handleTtsDragStart, cleanupDragPreview } from '@/lib/dragToCanvas'; import { useAuth } from '@/context/AuthContext'; import { useCanvasStore } from '@/store/useCanvasStore'; @@ -21,6 +21,21 @@ interface TtsTaskInfo { creditCost?: number; } +interface TtsSpeakerConfig { + speaker: string; + voice?: string; + text: string; + style?: string; +} + +// 拖拽到画布还原为 TTS 节点时需要携带的特有配置 +interface TtsDragMeta { + text?: string; + voice?: string; + style?: string; + speakers?: TtsSpeakerConfig[]; +} + interface TtsTaskStatus { id: string; status: 'pending' | 'processing' | 'completed' | 'failed'; @@ -29,6 +44,9 @@ interface TtsTaskStatus { error_message?: string; model?: string; text?: string; + voice?: string; + style?: string; + speakers?: TtsSpeakerConfig[]; output_format?: string; // 后端扣费不足、余额被兜底扣到 0 时为 true(不持久化,仅响应携带) billing_underpaid?: boolean; @@ -55,16 +73,17 @@ const STATUS_CONFIG: Record(null); const [isDragging, setIsDragging] = useState(false); const onDragStart = useCallback((e: React.DragEvent) => { setIsDragging(true); - dragPreviewRef.current = handleAudioDragStart(e, audioUrl, '语音', ''); - }, [audioUrl]); + dragPreviewRef.current = handleTtsDragStart(e, audioUrl, { name: '语音', ...ttsMeta }); + }, [audioUrl, ttsMeta]); const onDragEnd = useCallback(() => { setIsDragging(false); @@ -123,6 +142,8 @@ export function TtsTaskCard({ task, className }: TtsTaskCardProps) { const [creditCost, setCreditCost] = useState(task.creditCost || 0); const [errorMsg, setErrorMsg] = useState(''); const [model, setModel] = useState(task.model || ''); + // TTS 特有配置(文本/音色/风格/多说话人),拖拽到画布时还原为 TTS 节点 + const [ttsMeta, setTtsMeta] = useState({}); const pollingRef = useRef | null>(null); const mountedRef = useRef(true); @@ -142,6 +163,15 @@ export function TtsTaskCard({ task, className }: TtsTaskCardProps) { data.error_message && setErrorMsg(data.error_message); data.model && setModel(data.model); + // 捕获 TTS 特有配置,供拖拽到画布时还原为 TTS 节点(幂等合并) + setTtsMeta((prev) => ({ + ...prev, + ...(data.text ? { text: data.text } : {}), + ...(data.voice ? { voice: data.voice } : {}), + ...(data.style ? { style: data.style } : {}), + ...(data.speakers ? { speakers: data.speakers } : {}), + })); + // 后端扣费后同步最新余额到 AuthContext,驱动 useCreditsGuard 即时生效 data.remaining_credits != null && updateCredits(data.remaining_credits); @@ -249,7 +279,7 @@ export function TtsTaskCard({ task, className }: TtsTaskCardProps) { {/* Audio player for completed tasks - with drag support */} {status === 'completed' && audioUrl && ( - + )} {/* Error message for failed tasks */} diff --git a/frontend/src/components/ai-assistant/index.ts b/frontend/src/components/ai-assistant/index.ts index 708fedcb..76d47400 100644 --- a/frontend/src/components/ai-assistant/index.ts +++ b/frontend/src/components/ai-assistant/index.ts @@ -1,5 +1,4 @@ // 基础组件 -export { LoadingDots } from './LoadingDots'; export { TypewriterText } from './TypewriterText'; // 状态指示器 diff --git a/frontend/src/components/canvas/GhostNode.tsx b/frontend/src/components/canvas/GhostNode.tsx index 42ca3ed2..fea225cd 100644 --- a/frontend/src/components/canvas/GhostNode.tsx +++ b/frontend/src/components/canvas/GhostNode.tsx @@ -4,6 +4,8 @@ import { memo } from 'react'; import { NodeProps, Node } from '@xyflow/react'; import { FileText, Image, Film, Music, Clapperboard, Sparkles } from 'lucide-react'; import { GhostNodeData } from '@/store/useCanvasStore'; +import { BorderBeam } from 'border-beam'; +import { useTheme } from '@/context/ThemeContext'; const TYPE_CONFIG: Record = { text: { icon: FileText, color: 'text-indigo-400', label: '文本卡' }, @@ -24,8 +26,7 @@ const GHOST_DIMENSIONS: Record = { storyboard: { width: 398, height: 256 }, }; -// Glow color matching NodeEffectOverlay scanning config (blue) -const GLOW_COLOR = 'rgba(59,130,246,0.4)'; +// 背景色调(与 NodeEffectOverlay scanning 一致的蓝) const BG_COLOR = 'rgba(59,130,246,0.06)'; const GhostNode = memo(({ data }: NodeProps>) => { @@ -33,20 +34,25 @@ const GhostNode = memo(({ data }: NodeProps>) => { const config = TYPE_CONFIG[nodeType] || DEFAULT_CONFIG; const Icon = config.icon; const dims = GHOST_DIMENSIONS[nodeType] || { width: 420, height: 300 }; + const { resolvedTheme } = useTheme(); return (
- {/* ── Pulsing border + glow (same as NodeEffectOverlay scanning) ── */} -
+ {/* ── BorderBeam 流光描边(创建中,蓝色 ocean) ── */} + +
+ {/* ── Background tint overlay ── */}
= { reading: { - borderColor: 'border-blue-400', - glowColor: 'rgba(59,130,246,0.5)', + beamVariant: 'ocean', bgColor: 'rgba(59,130,246,0.08)', icon: Eye, label: 'Reading…', }, scanning: { - borderColor: 'border-blue-400', - glowColor: 'rgba(59,130,246,0.4)', + beamVariant: 'ocean', bgColor: 'rgba(59,130,246,0.06)', icon: ScanSearch, label: 'Scanning…', }, updating: { - borderColor: 'border-amber-400', - glowColor: 'rgba(245,158,11,0.5)', + beamVariant: 'gold', bgColor: 'rgba(245,158,11,0.08)', icon: Pencil, label: 'Editing…', }, deleting: { - borderColor: 'border-red-400', - glowColor: 'rgba(239,68,68,0.5)', + beamVariant: 'sunset', bgColor: 'rgba(239,68,68,0.10)', icon: Trash2, label: 'Deleting…', }, connecting: { - borderColor: 'border-green-400', - glowColor: 'rgba(34,197,94,0.5)', + beamVariant: 'forest', bgColor: 'rgba(34,197,94,0.08)', icon: Link2, label: 'Connecting…', @@ -64,6 +60,7 @@ interface NodeEffectOverlayProps { const NodeEffectOverlay = memo(({ nodeId }: NodeEffectOverlayProps) => { const effect = useCanvasStore((state) => state.activeNodeEffects[nodeId]); + const { resolvedTheme } = useTheme(); // Zero cost when no effect if (!effect) return null; @@ -73,14 +70,18 @@ const NodeEffectOverlay = memo(({ nodeId }: NodeEffectOverlayProps) => { return ( <> - {/* ── Pulsing border + glow ────────────────────── */} -
+ {/* ── BorderBeam 流光描边(替代原脉冲边框,色相沿用各状态语义)── */} + +
+ {/* ── Background tint overlay ──────────────────── */}
) => ({ + name: name || '新语音卡', + description: description || '', + audioUrl: audioUrl || '', + // 保留 TTS 特有配置:朗读文本 / 音色 / 语气风格 / 多说话人 + text: text || '', + voice: voice || '', + style: style || '', + ...(speakers ? { speakers } : {}), + }), + }, }; /** @@ -263,6 +276,31 @@ export function handleAudioDragStart( return preview; } +/** + * TTS 语音卡片拖拽开始处理器 + * 生成 'tts' 类型节点(而非普通 audio 节点),并保留文本/音色/风格/多说话人配置 + */ +export function handleTtsDragStart( + event: React.DragEvent, + audioUrl: string, + ttsMeta: { name?: string; text?: string; voice?: string; style?: string; speakers?: unknown[] } = {} +): HTMLElement | null { + setDragData(event, 'tts', { + name: ttsMeta.name || '语音', + audioUrl, + description: '', + text: ttsMeta.text || '', + voice: ttsMeta.voice || '', + style: ttsMeta.style || '', + speakers: ttsMeta.speakers, + }); + + const preview = createAudioDragPreview(ttsMeta.name || '语音'); + event.dataTransfer.setDragImage(preview, preview.offsetWidth / 2, preview.offsetHeight / 2); + + return preview; +} + /** * 文本拖拽开始处理器 */