31 KiB
数字分身接管聊天功能 — 实现计划
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 实现数字分身接管主人在会会平台上的聊天,支持立即接管和延迟接管两种模式,优先使用知识库生成回复。
Architecture: 在数字分身应用后端新增 takeover 模块,包含消息监听器、接管决策器、回复执行器。通过轮询会会平台推送消息接口获取未读消息,调用现有分身聊天接口生成回复,通过盒子 IM 接口以主人身份发送。
Tech Stack: FastAPI (Python), SQLite, Redis, HTTPX, APScheduler, Vue 3 (frontend)
全局约束
- 数据库:SQLite (avatar.db),使用
_try_add_columns模式添加新列 - 响应格式:统一使用
ok()/fail()fromresponses.py - 认证:
Authorization: Bearer <app_token>header - 盒子 IM 接口前缀:
/api/im/box/*(通过会会平台网关) - 签名机制:复用
news_service.py中的_build_form/_make_sign模式 - YAGNI:本期只做单聊接管,群聊接管标记为后续扩展
- TDD:每个功能模块先写测试再实现
文件清单
新建文件
| 文件 | 职责 |
|---|---|
digital-avatar-app/backend/routers/takeover.py |
接管配置 API + 内部处理接口 |
digital-avatar-app/backend/services/takeover_service.py |
接管核心逻辑(监听、决策、回复) |
digital-avatar-app/backend/services/boxim_client.py |
盒子 IM 客户端(获取凭证、发送消息) |
digital-avatar-app/backend/requirements.txt |
新增 redis、apscheduler 依赖 |
digital-avatar-app/src/api/index.ts |
新增 takeover 相关 API 函数 |
digital-avatar-app/src/views/AuthorizationManage.vue |
添加接管开关 UI |
修改文件
| 文件 | 修改内容 |
|---|---|
digital-avatar-app/backend/models.py |
Authorization 表新增 takeover 字段 |
digital-avatar-app/backend/database.py |
_try_add_columns 添加 takeover 列 |
digital-avatar-app/backend/main.py |
注册 takeover router,启动定时任务 |
digital-avatar-app/backend/requirements.txt |
添加 redis, apscheduler |
任务分解
Task 1: 扩展 Authorization 数据模型
Files:
- Modify:
digital-avatar-app/backend/models.py - Modify:
digital-avatar-app/backend/database.py - Test:
digital-avatar-app/backend/tests/test_takeover_model.py
Interfaces:
-
新增字段:
takeover_enabled,takeover_mode,takeover_delay_seconds -
Authorization.to_dict()返回新增字段 -
Step 1: Write the failing test
# digital-avatar-app/backend/tests/test_takeover_model.py
from database import SessionLocal
from models import Authorization
def test_authorization_takeover_fields():
db = SessionLocal()
try:
auth = db.query(Authorization).first()
assert auth is not None
# 检查新字段存在且有默认值
assert hasattr(auth, 'takeover_enabled')
assert hasattr(auth, 'takeover_mode')
assert hasattr(auth, 'takeover_delay_seconds')
assert auth.takeover_enabled == False
assert auth.takeover_mode == 'immediate'
assert auth.takeover_delay_seconds == 30
finally:
db.close()
def test_authorization_to_dict_includes_takeover():
db = SessionLocal()
try:
auth = db.query(Authorization).first()
d = auth.to_dict()
assert 'takeover_enabled' in d
assert 'takeover_mode' in d
assert 'takeover_delay_seconds' in d
finally:
db.close()
- Step 2: Run test to verify it fails
cd digital-avatar-app/backend
python -m pytest tests/test_takeover_model.py -v
Expected: FAIL with "no such column: takeover_enabled"
- Step 3: Update Authorization model
# digital-avatar-app/backend/models.py
# 在 Authorization 类中新增字段(在 created_at 之前)
class Authorization(Base):
# ... 现有字段 ...
takeover_enabled = Column(Boolean, default=False) # 是否开启分身接管
takeover_mode = Column(String, default="immediate") # immediate | delayed
takeover_delay_seconds = Column(Integer, default=30) # 延迟秒数
created_at = Column(DateTime, server_default=func.now())
def to_dict(self):
return {
# ... 现有字段 ...
"takeoverEnabled": self.takeover_enabled,
"takeoverMode": self.takeover_mode,
"takeoverDelaySeconds": self.takeover_delay_seconds,
"createdAt": _iso(self.created_at),
}
- Step 4: Add column migration
# digital-avatar-app/backend/database.py
# 在 _try_add_columns 调用中新增:
_try_add_columns(
# ... 现有列 ...
("authorizations", "takeover_enabled", "BOOLEAN DEFAULT 0"),
("authorizations", "takeover_mode", "VARCHAR DEFAULT 'immediate'"),
("authorizations", "takeover_delay_seconds", "INTEGER DEFAULT 30"),
)
- Step 5: Run test to verify it passes
cd digital-avatar-app/backend
python -m pytest tests/test_takeover_model.py -v
Expected: PASS
- Step 6: Commit
cd /Users/yqq/Works/AiProject/huihuiSquare
git add digital-avatar-app/backend/models.py digital-avatar-app/backend/database.py digital-avatar-app/backend/tests/test_takeover_model.py
git commit -m "feat: add takeover fields to Authorization model"
Task 2: 盒子 IM 客户端
Files:
- Create:
digital-avatar-app/backend/services/boxim_client.py - Test:
digital-avatar-app/backend/tests/test_boxim_client.py
Interfaces:
BoxIMClient.get_credentials(user_id: str) -> dict— 获取网易云信凭证BoxIMClient.send_p2p_message(from_accid: str, to_accid: str, content: str) -> bool— 发送单聊消息
Dependencies:
-
会会平台签名机制(复用 news_service 中的模式)
-
HTTPX for async HTTP calls
-
平台配置从环境变量读取
-
Step 1: Write the failing test
# digital-avatar-app/backend/tests/test_boxim_client.py
import pytest
from unittest.mock import AsyncMock, patch
from services.boxim_client import BoxIMClient
@pytest.fixture
def mock_config():
return {
"HUIHUI_IM_BASE_URL": "http://192.168.1.200:60040",
"HUIHUI_APP_ID": "test_app",
"HUIHUI_ACCESS_ID": "test_access",
"HUIHUI_ACCESS_SECRET": "test_secret",
}
@patch("services.boxim_client.httpx.AsyncClient")
@pytest.mark.asyncio
async def test_get_credentials(mock_client_class, mock_config):
mock_response = AsyncMock()
mock_response.json.return_value = {"code": 200, "data": {"accid": "user123", "token": "tok_xyz"}}
mock_client_class.return_value.__aenter__.return_value.post.return_value = mock_response
client = BoxIMClient(mock_config)
result = await client.get_credentials("user123")
assert result["accid"] == "user123"
assert result["token"] == "tok_xyz"
@patch("services.boxim_client.httpx.AsyncClient")
@pytest.mark.asyncio
async def test_send_p2p_message_success(mock_client_class, mock_config):
mock_response = AsyncMock()
mock_response.json.return_value = {"code": 200}
mock_client_class.return_value.__aenter__.return_value.post.return_value = mock_response
client = BoxIMClient(mock_config)
result = await client.send_p2p_message("owner_acc", "target_acc", "Hello")
assert result is True
- Step 2: Run test to verify it fails
cd digital-avatar-app/backend
python -m pytest tests/test_boxim_client.py -v
Expected: FAIL with "ModuleNotFoundError: No module named 'services.boxim_client'"
- Step 3: Write minimal implementation
# digital-avatar-app/backend/services/boxim_client.py
"""盒子 IM 客户端 — 封装网易云信 IM 接口调用"""
import hashlib
import random
import string
from datetime import datetime
from typing import Optional
import httpx
class BoxIMClient:
"""盒子 IM 客户端,通过会会平台网关调用网易云信 IM"""
def __init__(self, config: dict):
self.base_url = config.get("HUIHUI_IM_BASE_URL", "http://192.168.1.200:60040")
self.app_id = config.get("HUIHUI_APP_ID", "")
self.access_id = config.get("HUIHUI_ACCESS_ID", "")
self.access_secret = config.get("HUIHUI_ACCESS_SECRET", "")
def _build_sign_params(self, extra: dict) -> dict:
"""构建带签名的请求参数"""
nonce = "".join(random.choices(string.ascii_lowercase + string.digits, k=12))
timestamp = datetime.now().strftime("%Y%m%d%H%M%S") # 24小时制
params = {
"appId": self.app_id,
"accessId": self.access_id,
"nonce": nonce,
"timestamp": timestamp,
**extra,
}
# 计算签名
keys = sorted(params.keys())
sign_parts = []
for k in keys:
v = params.get(k)
if v and v != "" and v != []:
sign_parts.append(f"{k}={v}")
sign_str = "&".join(sign_parts) + f"&accessSecret={self.access_secret}"
signature = hashlib.md5(sign_str.encode("utf-8")).hexdigest().upper()
params["signature"] = signature
params["signType"] = "MD5"
params["signVersion"] = "1.0"
return params
async def get_credentials(self, user_id: str) -> Optional[dict]:
"""获取用户的网易云信 IM 凭证 (accid, token)"""
params = self._build_sign_params({"userId": user_id})
async with httpx.AsyncClient(timeout=10) as client:
r = await client.post(
f"{self.base_url}/box/netease",
params=params,
)
data = r.json()
if data.get("code") in (0, 200):
return data.get("data", {})
return None
async def send_p2p_message(
self, from_accid: str, to_accid: str, content: str
) -> bool:
"""发送单聊消息(文本)"""
params = self._build_sign_params({
"from": from_accid,
"to": to_accid,
"msgType": "text",
"content": content,
})
async with httpx.AsyncClient(timeout=10) as client:
r = await client.post(
f"{self.base_url}/box/message/send/p2p",
params=params,
)
data = r.json()
return data.get("code") in (0, 200)
- Step 4: Add test dependencies
cd digital-avatar-app/backend
pip install pytest pytest-asyncio httpx
- Step 5: Run test to verify it passes
cd digital-avatar-app/backend
python -m pytest tests/test_boxim_client.py -v
Expected: PASS
- Step 6: Commit
git add digital-avatar-app/backend/services/boxim_client.py digital-avatar-app/backend/tests/test_boxim_client.py
git commit -m "feat: add Box IM client for Netease Yunxin integration"
Task 3: 接管服务核心逻辑
Files:
- Create:
digital-avatar-app/backend/services/takeover_service.py - Test:
digital-avatar-app/backend/tests/test_takeover_service.py
Interfaces:
TakeoverService.check_takeover_enabled(owner_huihui_id: str, from_user_id: str) -> Authorization | NoneTakeoverService.generate_reply(avatar_id: str, message: str) -> strTakeoverService.execute_takeover(auth: Authorization, message: dict) -> boolTakeoverService.process_delayed_queue()— 处理延迟队列中到期的消息
Dependencies:
-
BoxIMClientfrom Task 2 -
Existing chat API:
POST /api/avatar/{id}/chat -
Redis for delayed queue
-
Authorizationmodel from Task 1 -
Step 1: Write the failing test
# digital-avatar-app/backend/tests/test_takeover_service.py
import pytest
from unittest.mock import AsyncMock, patch, MagicMock
from services.takeover_service import TakeoverService
@pytest.fixture
def mock_db():
db = MagicMock()
return db
@pytest.fixture
def mock_boxim():
client = AsyncMock()
client.get_credentials.return_value = {"accid": "owner_acc", "token": "tok"}
client.send_p2p_message.return_value = True
return client
@pytest.fixture
def mock_auth():
auth = MagicMock()
auth.takeover_enabled = True
auth.takeover_mode = "immediate"
auth.takeover_delay_seconds = 30
auth.avatar_id = "avatar_123"
auth.target_id = "target_user_123"
return auth
def test_check_takeover_enabled_returns_auth_when_enabled(mock_db, mock_auth):
mock_db.query.return_value.filter.return_value.filter.return_value.first.return_value = mock_auth
service = TakeoverService(mock_db, None)
result = service.check_takeover_enabled("owner_123", "target_123")
assert result == mock_auth
def test_check_takeover_enabled_returns_none_when_disabled(mock_db):
disabled_auth = MagicMock()
disabled_auth.takeover_enabled = False
mock_db.query.return_value.filter.return_value.filter.return_value.first.return_value = disabled_auth
service = TakeoverService(mock_db, None)
result = service.check_takeover_enabled("owner_123", "target_123")
assert result is None
- Step 2: Run test to verify it fails
cd digital-avatar-app/backend
python -m pytest tests/test_takeover_service.py -v
Expected: FAIL
- Step 3: Write minimal implementation
# digital-avatar-app/backend/services/takeover_service.py
"""分身接管聊天服务 — 消息监听、决策、回复执行"""
import json
import logging
from typing import Optional
from sqlalchemy.orm import Session
import httpx
from models import Authorization, Avatar
from services.boxim_client import BoxIMClient
logger = logging.getLogger(__name__)
class TakeoverService:
"""分身接管服务"""
def __init__(self, db: Session, boxim_client: BoxIMClient, redis_client=None):
self.db = db
self.boxim = boxim_client
self.redis = redis_client
self._chat_api_base = "http://localhost:8000/api" # 分身应用自己的 API
def check_takeover_enabled(
self, owner_huihui_id: str, from_user_id: str
) -> Optional[Authorization]:
"""检查是否有接管授权"""
auth = (
self.db.query(Authorization)
.filter(Authorization.target_id == from_user_id)
.filter(Authorization.takeover_enabled == True)
.first()
)
if auth and auth.takeover_enabled:
return auth
return None
async def generate_reply(self, avatar_id: str, message: str) -> str:
"""调用分身聊天接口生成回复"""
try:
async with httpx.AsyncClient(timeout=30) as client:
r = await client.post(
f"{self._chat_api_base}/avatar/{avatar_id}/chat",
json={"message": message, "history": []},
)
data = r.json()
if data.get("code") in (0, 200):
return data.get("data", {}).get("answer", "")
logger.warning(f"分身聊天接口返回异常: {data}")
return ""
except Exception as e:
logger.error(f"调用分身聊天接口失败: {e}")
return ""
async def execute_takeover(self, auth: Authorization, message: dict) -> bool:
"""执行接管:生成回复并以主人身份发送"""
try:
# 1. 获取主人的 IM 凭证
owner_huihui_id = auth.owner_id if hasattr(auth, 'owner_id') else ""
credentials = await self.boxim.get_credentials(owner_huihui_id)
if not credentials:
logger.warning(f"无法获取主人 IM 凭证: {owner_huihui_id}")
return False
# 2. 生成回复
reply = await self.generate_reply(auth.avatar_id, message.get("content", ""))
if not reply:
logger.warning("分身未生成回复")
return False
# 3. 发送消息
success = await self.boxim.send_p2p_message(
from_accid=credentials["accid"],
to_accid=message.get("from_accid", ""),
content=reply,
)
if success:
logger.info(f"分身接管回复成功: {reply[:50]}...")
return success
except Exception as e:
logger.error(f"执行接管失败: {e}")
return False
def enqueue_delayed_message(self, auth: Authorization, message: dict):
"""将消息写入 Redis 延迟队列"""
if not self.redis:
logger.warning("Redis 未配置,降级为立即接管")
return
key = f"takeover:delayed:{auth.target_id}:{message.get('msg_id', '')}"
value = json.dumps({
"avatar_id": auth.avatar_id,
"from_accid": message.get("from_accid", ""),
"content": message.get("content", ""),
"owner_huihui_id": auth.owner_id if hasattr(auth, 'owner_id') else "",
})
self.redis.setex(key, auth.takeover_delay_seconds + 10, value)
logger.info(f"消息写入延迟队列: {key}")
async def process_delayed_queue(self):
"""处理延迟队列中到期的消息"""
if not self.redis:
return
# Redis 会自动过期,这里不需要主动处理
# 实际使用时可以用 Redis keyspace notifications 或定期扫描
pass
- Step 4: Run test to verify it passes
cd digital-avatar-app/backend
python -m pytest tests/test_takeover_service.py -v
Expected: PASS
- Step 5: Commit
git add digital-avatar-app/backend/services/takeover_service.py digital-avatar-app/backend/tests/test_takeover_service.py
git commit -m "feat: add takeover service core logic"
Task 4: 接管配置 API
Files:
- Create:
digital-avatar-app/backend/routers/takeover.py - Modify:
digital-avatar-app/backend/main.py
Interfaces:
PUT /api/avatar/{avatar_id}/authorizations/takeover— 更新接管配置- 请求体:
{ authorization_id, takeover_enabled, takeover_mode, takeover_delay_seconds }
Dependencies:
-
Authorizationmodel from Task 1 -
ok()/fail()fromresponses.py -
Step 1: Write the failing test
# digital-avatar-app/backend/tests/test_takeover_api.py
from fastapi.testclient import TestClient
from main import app
from database import SessionLocal, Base, engine
from models import Authorization, Avatar
def setup_test_db():
Base.metadata.create_all(bind=engine)
db = SessionLocal()
avatar = Avatar(name="test", status="active", config={})
db.add(avatar)
db.commit()
auth = Authorization(avatar_id=avatar.id, target_id="user1", target_name="测试用户")
db.add(auth)
db.commit()
return db, auth.id
def test_update_takeover_config():
db, auth_id = setup_test_db()
client = TestClient(app)
response = client.put(
f"/api/avatar/test_auth/avatar_id/authorizations/takeover",
json={
"authorization_id": auth_id,
"takeover_enabled": True,
"takeover_mode": "delayed",
"takeover_delay_seconds": 60,
},
)
assert response.status_code == 200
data = response.json()
assert data["code"] == 200
# 验证数据库已更新
auth = db.query(Authorization).filter(Authorization.id == auth_id).first()
assert auth.takeover_enabled == True
assert auth.takeover_mode == "delayed"
assert auth.takeover_delay_seconds == 60
- Step 2: Run test to verify it fails
cd digital-avatar-app/backend
python -m pytest tests/test_takeover_api.py -v
Expected: FAIL with 404 (endpoint not found)
- Step 3: Write minimal implementation
# digital-avatar-app/backend/routers/takeover.py
"""分身接管配置 API"""
from fastapi import APIRouter, Depends, Body
from sqlalchemy.orm import Session
from database import get_db
from models import Authorization
from responses import ok, fail
router = APIRouter(tags=["分身接管"])
@router.put("/avatar/{avatar_id}/authorizations/takeover")
def update_takeover_config(
avatar_id: str,
payload: dict = Body(...),
db: Session = Depends(get_db),
):
"""更新分身接管配置"""
auth_id = payload.get("authorization_id")
if not auth_id:
return fail("缺少 authorization_id", 400)
auth = db.query(Authorization).filter(Authorization.id == auth_id).first()
if not auth:
return fail("授权不存在", 404)
# 更新接管配置
if "takeover_enabled" in payload:
auth.takeover_enabled = payload["takeover_enabled"]
if "takeover_mode" in payload:
mode = payload["takeover_mode"]
if mode not in ("immediate", "delayed"):
return fail("takeover_mode 必须是 immediate 或 delayed", 400)
auth.takeover_mode = mode
if "takeover_delay_seconds" in payload:
delay = payload["takeover_delay_seconds"]
if not isinstance(delay, int) or delay < 5:
return fail("takeover_delay_seconds 必须 >= 5", 400)
auth.takeover_delay_seconds = delay
db.commit()
return ok(auth.to_dict())
- Step 4: Register router in main.py
# digital-avatar-app/backend/main.py
# 在 import 部分添加:
import routers.takeover
# 在 router 注册部分添加:
app.include_router(routers.takeover.router, prefix="/api")
- Step 5: Run test to verify it passes
cd digital-avatar-app/backend
python -m pytest tests/test_takeover_api.py -v
Expected: PASS
- Step 6: Commit
git add digital-avatar-app/backend/routers/takeover.py digital-avatar-app/backend/main.py digital-avatar-app/backend/tests/test_takeover_api.py
git commit -m "feat: add takeover config API endpoint"
Task 5: 定时消息监听任务
Files:
- Modify:
digital-avatar-app/backend/main.py - Modify:
digital-avatar-app/backend/requirements.txt
Interfaces:
- APScheduler 定时任务:每 10 秒轮询一次未读消息
- 调用
TakeoverService.check_takeover_enabled判断是否接管 - 根据模式调用立即执行或延迟队列
Dependencies:
-
TakeoverServicefrom Task 3 -
BoxIMClientfrom Task 2 -
APSchedulerfor scheduling -
redisfor delayed queue -
Step 1: Add dependencies
# digital-avatar-app/backend/requirements.txt
# 新增:
redis>=5.0
apscheduler>=3.10
- Step 2: Add scheduler setup in main.py
# digital-avatar-app/backend/main.py
# 在 import 部分添加:
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.interval import IntervalTrigger
import redis as redis_lib
import os
# 在 on_startup 函数中添加:
@app.on_event("startup")
def on_startup():
init_db()
seed()
# 初始化 Redis(可选)
redis_client = None
redis_url = os.getenv("REDIS_URL", "")
if redis_url:
try:
redis_client = redis_lib.from_url(redis_url)
redis_client.ping()
except Exception:
logger.warning("Redis 连接失败,延迟接管将降级为立即接管")
# 初始化 Box IM 客户端
boxim_config = {
"HUIHUI_IM_BASE_URL": os.getenv("HUIHUI_IM_BASE_URL", "http://192.168.1.200:60040"),
"HUIHUI_APP_ID": os.getenv("HUIHUI_APP_ID", ""),
"HUIHUI_ACCESS_ID": os.getenv("HUIHUI_ACCESS_ID", ""),
"HUIHUI_ACCESS_SECRET": os.getenv("HUIHUI_ACCESS_SECRET", ""),
}
boxim_client = BoxIMClient(boxim_config)
# 初始化接管服务
from services.takeover_service import TakeoverService
takeover_service = TakeoverService(SessionLocal(), boxim_client, redis_client)
# 启动定时任务
scheduler = BackgroundScheduler()
scheduler.add_job(
takeover_service.poll_and_process_messages,
trigger=IntervalTrigger(seconds=10),
id="takeover_message_poll",
)
scheduler.start()
- Step 3: Add poll method to TakeoverService
# digital-avatar-app/backend/services/takeover_service.py
# 在 TakeoverService 类中新增:
async def poll_and_process_messages(self):
"""定时轮询未读消息并处理"""
# 这里简化处理,实际需要调用会会平台的消息推送接口
# 伪代码:
# messages = await self.fetch_unread_messages()
# for msg in messages:
# await self.process_message(msg)
pass
async def process_message(self, message: dict):
"""处理单条消息"""
owner_id = message.get("owner_huihui_id", "")
from_id = message.get("from_accid", "")
auth = self.check_takeover_enabled(owner_id, from_id)
if not auth:
return
if auth.takeover_mode == "immediate":
await self.execute_takeover(auth, message)
else:
self.enqueue_delayed_message(auth, message)
- Step 4: Commit
git add digital-avatar-app/backend/main.py digital-avatar-app/backend/requirements.txt digital-avatar-app/backend/services/takeover_service.py
git commit -m "feat: add scheduled message polling for takeover"
Task 6: 前端授权管理 UI 增强
Files:
- Modify:
digital-avatar-app/src/views/AuthorizationManage.vue - Modify:
digital-avatar-app/src/api/index.ts
Interfaces:
- 授权卡片新增接管开关和配置
- 开关开启时显示模式选择和延迟秒数输入
- 调用
PUT /api/avatar/{id}/authorizations/takeover保存配置
Dependencies:
-
Existing authorization API functions
-
Takeover config API from Task 4
-
Step 1: Add API function
// digital-avatar-app/src/api/index.ts
// 新增:
export const updateTakeoverConfig = (avatarId: string, data: {
authorizationId: string
takeoverEnabled: boolean
takeoverMode?: string
takeoverDelaySeconds?: number
}) => {
return api.put(`/api/avatar/${avatarId}/authorizations/takeover`, data)
}
- Step 2: Update AuthorizationManage.vue
<!-- digital-avatar-app/src/views/AuthorizationManage.vue -->
<!-- 在授权卡片的权限标签后添加接管配置 -->
<div v-if="auth.permissions?.includes('takeover') || auth.takeoverEnabled" class="takeover-config">
<div class="takeover-header">
<span class="takeover-label">分身接管聊天</span>
<button class="toggle-btn" :class="{ active: auth.takeoverEnabled }" @click="toggleTakeover(auth)">
<span class="toggle-dot"></span>
</button>
</div>
<div v-if="auth.takeoverEnabled" class="takeover-options">
<div class="option-row">
<label>接管模式</label>
<select v-model="auth.takeoverMode" @change="saveTakeoverConfig(auth)">
<option value="immediate">立即接管</option>
<option value="delayed">延迟接管</option>
</select>
</div>
<div v-if="auth.takeoverMode === 'delayed'" class="option-row">
<label>延迟秒数</label>
<input type="number" v-model.number="auth.takeoverDelaySeconds"
min="5" max="300" @change="saveTakeoverConfig(auth)" />
</div>
</div>
</div>
- Step 3: Add methods to AuthorizationManage.vue
<script setup lang="ts">
// 新增 import
import { updateTakeoverConfig } from '@/api'
// 新增方法
const toggleTakeover = async (auth: any) => {
auth.takeoverEnabled = !auth.takeoverEnabled
await saveTakeoverConfig(auth)
}
const saveTakeoverConfig = async (auth: any) => {
try {
await updateTakeoverConfig(currentAvatarId.value, {
authorizationId: auth.id,
takeoverEnabled: auth.takeoverEnabled,
takeoverMode: auth.takeoverMode || 'immediate',
takeoverDelaySeconds: auth.takeoverDelaySeconds || 30,
})
} catch (e) {
console.error('保存接管配置失败', e)
}
}
</script>
- Step 4: Add styles
<style scoped>
.takeover-config {
margin-top: 12px;
padding: 10px;
background: rgba(88, 166, 255, 0.05);
border-radius: 8px;
}
.takeover-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.takeover-label {
font-size: 13px;
font-weight: 600;
}
.takeover-options {
display: flex;
flex-direction: column;
gap: 8px;
}
.option-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
}
.option-row label {
width: 70px;
flex-shrink: 0;
color: var(--color-text-muted);
}
.option-row select, .option-row input {
flex: 1;
padding: 4px 8px;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-bg);
color: var(--color-text);
}
</style>
- Step 5: Commit
git add digital-avatar-app/src/views/AuthorizationManage.vue digital-avatar-app/src/api/index.ts
git commit -m "feat: add takeover config UI to authorization management"
Task 7: 集成测试和部署
Files:
- Modify:
docker-compose.yml(digital-avatar-app) - Create:
digital-avatar-app/.env.example
Interfaces:
-
添加 Redis 容器(可选,用于延迟队列)
-
添加环境变量配置
-
Step 1: Add Redis to docker-compose
# digital-avatar-app/docker-compose.yml
services:
avatar-backend:
# ... 现有配置 ...
environment:
- REDIS_URL=redis://avatar-redis:6379
- HUIHUI_IM_BASE_URL=http://192.168.1.200:60040
- HUIHUI_APP_ID=${HUIHUI_APP_ID}
- HUIHUI_ACCESS_ID=${HUIHUI_ACCESS_ID}
- HUIHUI_ACCESS_SECRET=${HUIHUI_ACCESS_SECRET}
depends_on:
- avatar-redis
avatar-redis:
image: redis:7-alpine
container_name: avatar-redis
restart: unless-stopped
volumes:
- redis_data:/data
networks:
- avatar-net
- Step 2: Create .env.example
# digital-avatar-app/.env.example
HUIHUI_IM_BASE_URL=http://192.168.1.200:60040
HUIHUI_APP_ID=your_app_id
HUIHUI_ACCESS_ID=your_access_id
HUIHUI_ACCESS_SECRET=your_access_secret
REDIS_URL=redis://avatar-redis:6379
- Step 3: Manual testing checklist
## 测试清单
### 功能测试
- [ ] 授权管理页面显示接管开关
- [ ] 开关开启后显示模式选择
- [ ] 延迟模式显示延迟秒数输入
- [ ] 保存配置后刷新页面配置保留
- [ ] 立即接管:模拟消息到达后分身立即回复
- [ ] 延迟接管:N 秒内主人回复则不分身接管
- [ ] 延迟接管:超时后分身自动回复
### 边界测试
- [ ] 主人有多个分身时使用第一个 active 分身
- [ ] 知识库为空时使用 Qwen 兜底
- [ ] IM 接口异常时记录日志不崩溃
- [ ] Redis 不可用时降级为立即接管
### 性能测试
- [ ] 轮询间隔 10 秒时 CPU 占用正常
- [ ] 延迟队列消息过期自动清理
- Step 4: Commit
git add digital-avatar-app/docker-compose.yml digital-avatar-app/.env.example
git commit -m "feat: add Redis for takeover delayed queue and env config"
执行顺序
- Task 1 → Task 2 → Task 3 → Task 4 → Task 5 → Task 6 → Task 7
- 每个 Task 完成后运行测试验证
- Task 5 和 Task 6 可以并行(后端定时任务和前端 UI 无依赖)
风险与缓解
| 风险 | 缓解措施 |
|---|---|
| 会会平台 IM 接口文档不完整 | 先做接口探索,用 Postman 手动验证 |
| 盒子 IM 替换云信的 API 差异 | 保留云信客户端作为 fallback |
| Redis 延迟队列复杂性 | 第一期可简化为内存队列,Redis 作为优化 |
| 分身聊天接口响应慢 | 设置超时,失败时不阻塞轮询 |