Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
848657219e | ||
|
|
1bcdcead8d | ||
|
|
434caac056 | ||
|
|
bb9e9da1f3 | ||
|
|
28fcd5373b | ||
|
|
2a01a9946a | ||
|
|
2ce1079bb6 | ||
|
|
6fba6dbaaa | ||
|
|
e71267cf86 | ||
|
|
359e558dbe | ||
|
|
3edf92c7cc | ||
|
|
97c4c73b58 | ||
|
|
08c58fe0e6 | ||
|
|
6b7201e890 | ||
|
|
28553aba15 | ||
|
|
95f91450d0 | ||
|
|
b98a2b9507 | ||
|
|
59350fb41d | ||
|
|
6a4b35c49a | ||
|
|
207bbd02cf | ||
|
|
7cac96356d | ||
|
|
03c32309a8 | ||
|
|
0fc43908ae | ||
|
|
3d999f9472 | ||
|
|
540edb58c4 | ||
|
|
a7eb6ac2a5 | ||
|
|
016bc22c05 | ||
|
|
094f8cd40f | ||
|
|
6794e88d53 | ||
|
|
7884430b3d | ||
|
|
46d42b7d98 | ||
|
|
ef58c5f2d2 | ||
|
|
6e3fe5a616 | ||
|
|
67b6bd1b48 | ||
|
|
0752001d85 | ||
|
|
c37294be17 | ||
|
|
5360cac8ad | ||
|
|
ea932f27fb | ||
|
|
bf0183bef4 | ||
|
|
0c6419f37e | ||
|
|
f768e7648f | ||
|
|
e30ab2b889 | ||
|
|
730f586784 | ||
|
|
ef2b1c6dd6 | ||
|
|
c693899b12 | ||
|
|
d274ccb5e2 | ||
|
|
5d19992f00 | ||
|
|
81aec1c63a | ||
|
|
4029c31ed7 | ||
|
|
e24e89d326 | ||
|
|
dc34a03357 | ||
|
|
3f7ff9329a | ||
|
|
699bbbde57 | ||
|
|
7a0199e685 | ||
|
|
9e86cc64ac |
@@ -1,8 +1,11 @@
|
|||||||
"""AI模型配置接口"""
|
"""AI模型配置接口"""
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
import secrets
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||||
from sqlalchemy import select, update
|
from sqlalchemy import select, update
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
|
from app.core.config import settings
|
||||||
from app.schemas import ApiResponse, AIModelCreateRequest, AIModelUpdateRequest, AIModelTestRequest
|
from app.schemas import ApiResponse, AIModelCreateRequest, AIModelUpdateRequest, AIModelTestRequest
|
||||||
from app.models import AIModelConfig
|
from app.models import AIModelConfig
|
||||||
from app.utils.crypto import encrypt, decrypt
|
from app.utils.crypto import encrypt, decrypt
|
||||||
@@ -22,13 +25,20 @@ async def list_models(db=Depends(get_db)):
|
|||||||
@router.post("")
|
@router.post("")
|
||||||
async def create_model(req: AIModelCreateRequest, db=Depends(get_db)):
|
async def create_model(req: AIModelCreateRequest, db=Depends(get_db)):
|
||||||
if req.is_default:
|
if req.is_default:
|
||||||
await db.execute(update(AIModelConfig).values(is_default=0))
|
await db.execute(
|
||||||
|
update(AIModelConfig)
|
||||||
|
.where(AIModelConfig.usage_scope == req.usage_scope)
|
||||||
|
.values(is_default=0)
|
||||||
|
)
|
||||||
model = AIModelConfig(
|
model = AIModelConfig(
|
||||||
model_name=req.model_name,
|
model_name=req.model_name,
|
||||||
provider=req.provider,
|
provider=req.provider,
|
||||||
|
usage_scope=req.usage_scope,
|
||||||
api_base_url=req.api_base_url,
|
api_base_url=req.api_base_url,
|
||||||
api_key_enc=encrypt(req.api_key) if req.api_key else None,
|
api_key_enc=encrypt(req.api_key) if req.api_key else None,
|
||||||
model_version=req.model_version,
|
model_version=req.model_version,
|
||||||
|
vision_model_version=req.vision_model_version,
|
||||||
|
ocr_model_version=req.ocr_model_version,
|
||||||
temperature=req.temperature,
|
temperature=req.temperature,
|
||||||
max_tokens=req.max_tokens,
|
max_tokens=req.max_tokens,
|
||||||
timeout_seconds=req.timeout_seconds,
|
timeout_seconds=req.timeout_seconds,
|
||||||
@@ -47,8 +57,16 @@ async def update_model(model_id: int, req: AIModelUpdateRequest, db=Depends(get_
|
|||||||
model = result.scalar_one_or_none()
|
model = result.scalar_one_or_none()
|
||||||
if not model:
|
if not model:
|
||||||
raise HTTPException(status_code=404, detail="模型不存在")
|
raise HTTPException(status_code=404, detail="模型不存在")
|
||||||
if req.is_default:
|
target_scope = req.usage_scope or model.usage_scope
|
||||||
await db.execute(update(AIModelConfig).where(AIModelConfig.id != model_id).values(is_default=0))
|
if req.is_default or (req.usage_scope and model.is_default):
|
||||||
|
await db.execute(
|
||||||
|
update(AIModelConfig)
|
||||||
|
.where(
|
||||||
|
AIModelConfig.id != model_id,
|
||||||
|
AIModelConfig.usage_scope == target_scope,
|
||||||
|
)
|
||||||
|
.values(is_default=0)
|
||||||
|
)
|
||||||
for field, val in req.model_dump(exclude_none=True).items():
|
for field, val in req.model_dump(exclude_none=True).items():
|
||||||
if field == "api_key":
|
if field == "api_key":
|
||||||
model.api_key_enc = encrypt(val) if val else None
|
model.api_key_enc = encrypt(val) if val else None
|
||||||
@@ -59,6 +77,39 @@ async def update_model(model_id: int, req: AIModelUpdateRequest, db=Depends(get_
|
|||||||
return ApiResponse(data=_format_model(model), message="更新成功")
|
return ApiResponse(data=_format_model(model), message="更新成功")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/runtime/digital-avatar")
|
||||||
|
async def get_digital_avatar_runtime_model(
|
||||||
|
x_avatar_config_token: str | None = Header(default=None),
|
||||||
|
db=Depends(get_db),
|
||||||
|
):
|
||||||
|
expected = settings.AVATAR_MODEL_CONFIG_TOKEN
|
||||||
|
if not expected:
|
||||||
|
raise HTTPException(status_code=503, detail="数字分身模型配置服务未启用")
|
||||||
|
if not x_avatar_config_token or not secrets.compare_digest(x_avatar_config_token, expected):
|
||||||
|
raise HTTPException(status_code=401, detail="无权读取数字分身模型配置")
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(AIModelConfig).where(
|
||||||
|
AIModelConfig.usage_scope == "digital_avatar",
|
||||||
|
AIModelConfig.is_default == 1,
|
||||||
|
AIModelConfig.is_enabled == 1,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
model = result.scalar_one_or_none()
|
||||||
|
if not model:
|
||||||
|
raise HTTPException(status_code=404, detail="尚未配置启用的数字分身专用模型")
|
||||||
|
return ApiResponse(data={
|
||||||
|
"api_base_url": model.api_base_url or "https://api.openai.com/v1",
|
||||||
|
"api_key": decrypt(model.api_key_enc) if model.api_key_enc else "",
|
||||||
|
"model": model.model_version or model.model_name,
|
||||||
|
"vision_model": model.vision_model_version or "qwen3.6-flash",
|
||||||
|
"ocr_model": model.ocr_model_version or "qwen-vl-ocr",
|
||||||
|
"temperature": model.temperature,
|
||||||
|
"max_tokens": model.max_tokens,
|
||||||
|
"timeout_seconds": model.timeout_seconds,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{model_id}")
|
@router.delete("/{model_id}")
|
||||||
async def delete_model(model_id: int, db=Depends(get_db)):
|
async def delete_model(model_id: int, db=Depends(get_db)):
|
||||||
result = await db.execute(select(AIModelConfig).where(AIModelConfig.id == model_id))
|
result = await db.execute(select(AIModelConfig).where(AIModelConfig.id == model_id))
|
||||||
@@ -79,8 +130,11 @@ async def test_model(req: AIModelTestRequest, db=Depends(get_db)):
|
|||||||
def _format_model(m: AIModelConfig) -> dict:
|
def _format_model(m: AIModelConfig) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": m.id, "model_name": m.model_name, "provider": m.provider,
|
"id": m.id, "model_name": m.model_name, "provider": m.provider,
|
||||||
|
"usage_scope": m.usage_scope,
|
||||||
"api_base_url": m.api_base_url, "has_api_key": bool(m.api_key_enc),
|
"api_base_url": m.api_base_url, "has_api_key": bool(m.api_key_enc),
|
||||||
"model_version": m.model_version, "temperature": m.temperature,
|
"model_version": m.model_version, "temperature": m.temperature,
|
||||||
|
"vision_model_version": m.vision_model_version,
|
||||||
|
"ocr_model_version": m.ocr_model_version,
|
||||||
"max_tokens": m.max_tokens, "timeout_seconds": m.timeout_seconds,
|
"max_tokens": m.max_tokens, "timeout_seconds": m.timeout_seconds,
|
||||||
"is_default": m.is_default, "is_enabled": m.is_enabled,
|
"is_default": m.is_default, "is_enabled": m.is_enabled,
|
||||||
"created_at": m.created_at.isoformat(),
|
"created_at": m.created_at.isoformat(),
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ class Settings(BaseSettings):
|
|||||||
# 安全
|
# 安全
|
||||||
SECRET_KEY: str = os.getenv("SECRET_KEY", "dev-secret-key-change-in-prod")
|
SECRET_KEY: str = os.getenv("SECRET_KEY", "dev-secret-key-change-in-prod")
|
||||||
AES_KEY: str = os.getenv("AES_KEY", "your-aes-key-32-chars-change-now!")
|
AES_KEY: str = os.getenv("AES_KEY", "your-aes-key-32-chars-change-now!")
|
||||||
|
AVATAR_MODEL_CONFIG_TOKEN: str = os.getenv("AVATAR_MODEL_CONFIG_TOKEN", "")
|
||||||
|
|
||||||
# 新闻平台
|
# 新闻平台
|
||||||
NEWS_PLATFORM_BASE_URL: str = os.getenv(
|
NEWS_PLATFORM_BASE_URL: str = os.getenv(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""数据库连接管理"""
|
"""数据库连接管理"""
|
||||||
import asyncio
|
import asyncio
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||||
|
from sqlalchemy import text
|
||||||
from sqlalchemy.orm import DeclarativeBase
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.logger import logger
|
from app.core.logger import logger
|
||||||
@@ -64,6 +65,37 @@ async def init_db():
|
|||||||
VirtualUser, UserPersonality, InteractionRecord,
|
VirtualUser, UserPersonality, InteractionRecord,
|
||||||
PendingReplyTask, TokenStat, AIModelConfig, SystemConfig, LoginLog
|
PendingReplyTask, TokenStat, AIModelConfig, SystemConfig, LoginLog
|
||||||
)
|
)
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.execute(text("SELECT GET_LOCK('ai_model_config_migration', 30)"))
|
||||||
|
try:
|
||||||
|
columns = (
|
||||||
|
(
|
||||||
|
"usage_scope",
|
||||||
|
"ALTER TABLE ai_model_configs ADD COLUMN usage_scope "
|
||||||
|
"VARCHAR(16) NOT NULL DEFAULT 'general' AFTER provider",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"vision_model_version",
|
||||||
|
"ALTER TABLE ai_model_configs ADD COLUMN vision_model_version "
|
||||||
|
"VARCHAR(64) NULL AFTER model_version",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"ocr_model_version",
|
||||||
|
"ALTER TABLE ai_model_configs ADD COLUMN ocr_model_version "
|
||||||
|
"VARCHAR(64) NULL AFTER vision_model_version",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for column_name, ddl in columns:
|
||||||
|
result = await conn.execute(text(
|
||||||
|
"SELECT COUNT(*) FROM information_schema.COLUMNS "
|
||||||
|
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'ai_model_configs' "
|
||||||
|
"AND COLUMN_NAME = :column_name"
|
||||||
|
), {"column_name": column_name})
|
||||||
|
if result.scalar_one() == 0:
|
||||||
|
await conn.execute(text(ddl))
|
||||||
|
logger.info("AI模型配置表已增加 %s 字段", column_name)
|
||||||
|
finally:
|
||||||
|
await conn.execute(text("SELECT RELEASE_LOCK('ai_model_config_migration')"))
|
||||||
logger.info("✅ 数据库模型注册成功")
|
logger.info("✅ 数据库模型注册成功")
|
||||||
logger.info("✅ 数据库初始化完成")
|
logger.info("✅ 数据库初始化完成")
|
||||||
|
|
||||||
|
|||||||
@@ -122,9 +122,12 @@ class AIModelConfig(Base):
|
|||||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
model_name: Mapped[str] = mapped_column(String(64), nullable=False)
|
model_name: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
provider: Mapped[str] = mapped_column(String(32), nullable=False)
|
provider: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||||
|
usage_scope: Mapped[str] = mapped_column(String(16), nullable=False, default="general")
|
||||||
api_base_url: Mapped[str | None] = mapped_column(String(256))
|
api_base_url: Mapped[str | None] = mapped_column(String(256))
|
||||||
api_key_enc: Mapped[str | None] = mapped_column(String(512))
|
api_key_enc: Mapped[str | None] = mapped_column(String(512))
|
||||||
model_version: Mapped[str | None] = mapped_column(String(64))
|
model_version: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
vision_model_version: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
ocr_model_version: Mapped[str | None] = mapped_column(String(64))
|
||||||
temperature: Mapped[float] = mapped_column(Float, default=0.7)
|
temperature: Mapped[float] = mapped_column(Float, default=0.7)
|
||||||
max_tokens: Mapped[int] = mapped_column(Integer, default=1000)
|
max_tokens: Mapped[int] = mapped_column(Integer, default=1000)
|
||||||
timeout_seconds: Mapped[int] = mapped_column(Integer, default=30)
|
timeout_seconds: Mapped[int] = mapped_column(Integer, default=30)
|
||||||
|
|||||||
@@ -154,9 +154,12 @@ class InteractionResponse(BaseModel):
|
|||||||
class AIModelCreateRequest(BaseModel):
|
class AIModelCreateRequest(BaseModel):
|
||||||
model_name: str = Field(..., min_length=1, max_length=64)
|
model_name: str = Field(..., min_length=1, max_length=64)
|
||||||
provider: str = Field(..., pattern="^(openai|zhipu|wenxin|qianwen|local)$")
|
provider: str = Field(..., pattern="^(openai|zhipu|wenxin|qianwen|local)$")
|
||||||
|
usage_scope: str = Field(default="general", pattern="^(general|digital_avatar)$")
|
||||||
api_base_url: Optional[str] = None
|
api_base_url: Optional[str] = None
|
||||||
api_key: Optional[str] = None
|
api_key: Optional[str] = None
|
||||||
model_version: Optional[str] = None
|
model_version: Optional[str] = None
|
||||||
|
vision_model_version: Optional[str] = Field(None, max_length=64)
|
||||||
|
ocr_model_version: Optional[str] = Field(None, max_length=64)
|
||||||
temperature: float = Field(default=0.7, ge=0.0, le=2.0)
|
temperature: float = Field(default=0.7, ge=0.0, le=2.0)
|
||||||
max_tokens: int = Field(default=1000, ge=1, le=32000)
|
max_tokens: int = Field(default=1000, ge=1, le=32000)
|
||||||
timeout_seconds: int = Field(default=30, ge=5, le=300)
|
timeout_seconds: int = Field(default=30, ge=5, le=300)
|
||||||
@@ -165,9 +168,13 @@ class AIModelCreateRequest(BaseModel):
|
|||||||
|
|
||||||
class AIModelUpdateRequest(BaseModel):
|
class AIModelUpdateRequest(BaseModel):
|
||||||
model_name: Optional[str] = None
|
model_name: Optional[str] = None
|
||||||
|
provider: Optional[str] = Field(None, pattern="^(openai|zhipu|wenxin|qianwen|local)$")
|
||||||
|
usage_scope: Optional[str] = Field(None, pattern="^(general|digital_avatar)$")
|
||||||
api_base_url: Optional[str] = None
|
api_base_url: Optional[str] = None
|
||||||
api_key: Optional[str] = None
|
api_key: Optional[str] = None
|
||||||
model_version: Optional[str] = None
|
model_version: Optional[str] = None
|
||||||
|
vision_model_version: Optional[str] = Field(None, max_length=64)
|
||||||
|
ocr_model_version: Optional[str] = Field(None, max_length=64)
|
||||||
temperature: Optional[float] = Field(None, ge=0.0, le=2.0)
|
temperature: Optional[float] = Field(None, ge=0.0, le=2.0)
|
||||||
max_tokens: Optional[int] = Field(None, ge=1, le=32000)
|
max_tokens: Optional[int] = Field(None, ge=1, le=32000)
|
||||||
timeout_seconds: Optional[int] = Field(None, ge=5, le=300)
|
timeout_seconds: Optional[int] = Field(None, ge=5, le=300)
|
||||||
@@ -179,9 +186,12 @@ class AIModelResponse(BaseModel):
|
|||||||
id: int
|
id: int
|
||||||
model_name: str
|
model_name: str
|
||||||
provider: str
|
provider: str
|
||||||
|
usage_scope: str
|
||||||
api_base_url: Optional[str]
|
api_base_url: Optional[str]
|
||||||
has_api_key: bool
|
has_api_key: bool
|
||||||
model_version: Optional[str]
|
model_version: Optional[str]
|
||||||
|
vision_model_version: Optional[str]
|
||||||
|
ocr_model_version: Optional[str]
|
||||||
temperature: float
|
temperature: float
|
||||||
max_tokens: int
|
max_tokens: int
|
||||||
timeout_seconds: int
|
timeout_seconds: int
|
||||||
|
|||||||
@@ -28,7 +28,9 @@ class AIService:
|
|||||||
async def _get_default_model(self, db: AsyncSession) -> Optional[AIModelConfig]:
|
async def _get_default_model(self, db: AsyncSession) -> Optional[AIModelConfig]:
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(AIModelConfig).where(
|
select(AIModelConfig).where(
|
||||||
AIModelConfig.is_default == 1, AIModelConfig.is_enabled == 1
|
AIModelConfig.usage_scope == "general",
|
||||||
|
AIModelConfig.is_default == 1,
|
||||||
|
AIModelConfig.is_enabled == 1,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return result.scalar_one_or_none()
|
return result.scalar_one_or_none()
|
||||||
|
|||||||
@@ -1,16 +1,24 @@
|
|||||||
"""数字分身管理服务层 — 同步连接数字分身应用的 SQLite 数据库"""
|
"""数字分身管理服务层 — 同步连接数字分身应用的 SQLite 数据库"""
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
|
from datetime import datetime, timedelta
|
||||||
from typing import Optional, Tuple
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
from sqlalchemy import create_engine, text
|
from sqlalchemy import create_engine, select, text
|
||||||
from sqlalchemy.orm import sessionmaker, Session
|
from sqlalchemy.orm import sessionmaker, Session
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
from app.core.logger import logger
|
||||||
|
from app.models import UserPersonality, VirtualUser
|
||||||
|
|
||||||
|
|
||||||
_engine = None
|
_engine = None
|
||||||
_SessionLocal: Optional[sessionmaker] = None
|
_SessionLocal: Optional[sessionmaker] = None
|
||||||
|
|
||||||
|
AVATAR_ACCOUNT_PREFIX = "__avatar__:"
|
||||||
|
SQUARE_INTERACTION_PERMISSION = "interact"
|
||||||
|
SQUARE_INTERACTION_ACTIONS = frozenset({"like", "collect", "comment", "reply"})
|
||||||
|
|
||||||
|
|
||||||
def _get_engine_and_session():
|
def _get_engine_and_session():
|
||||||
global _engine, _SessionLocal
|
global _engine, _SessionLocal
|
||||||
@@ -66,6 +74,185 @@ def _get_global_token_balance(db: Session) -> int:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_config(value) -> dict:
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return value
|
||||||
|
if isinstance(value, str):
|
||||||
|
try:
|
||||||
|
decoded = json.loads(value)
|
||||||
|
return decoded if isinstance(decoded, dict) else {}
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
return {}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def is_delegated_avatar_user(user: VirtualUser | None) -> bool:
|
||||||
|
return bool(user and (user.account or "").startswith(AVATAR_ACCOUNT_PREFIX))
|
||||||
|
|
||||||
|
|
||||||
|
def delegated_avatar_id(user: VirtualUser | None) -> str:
|
||||||
|
if not is_delegated_avatar_user(user):
|
||||||
|
return ""
|
||||||
|
return (user.account or "")[len(AVATAR_ACCOUNT_PREFIX):]
|
||||||
|
|
||||||
|
|
||||||
|
def _list_square_interaction_authorizations(db: Session) -> list[dict]:
|
||||||
|
"""读取已明确授权分身参与广场互动的身份与会会令牌。"""
|
||||||
|
rows = db.execute(text("""
|
||||||
|
SELECT
|
||||||
|
a.id AS avatar_id,
|
||||||
|
a.name AS avatar_name,
|
||||||
|
a.display_name AS avatar_display_name,
|
||||||
|
a.description AS avatar_description,
|
||||||
|
a.photo_url AS avatar_photo_url,
|
||||||
|
a.config AS avatar_config,
|
||||||
|
u.huihui_user_id,
|
||||||
|
u.nickname AS owner_nickname,
|
||||||
|
u.avatar_url AS owner_avatar_url,
|
||||||
|
u.huihui_token
|
||||||
|
FROM avatars a
|
||||||
|
JOIN users u ON u.huihui_user_id = a.owner_id
|
||||||
|
WHERE a.status = 'active'
|
||||||
|
""")).fetchall()
|
||||||
|
|
||||||
|
authorized = []
|
||||||
|
for row in rows:
|
||||||
|
config = _decode_config(row.avatar_config)
|
||||||
|
permissions = config.get("authorizationPermissions", [])
|
||||||
|
if not isinstance(permissions, list) or SQUARE_INTERACTION_PERMISSION not in permissions:
|
||||||
|
continue
|
||||||
|
platform_uid = str(row.huihui_user_id or "").strip()
|
||||||
|
token = str(row.huihui_token or "").strip()
|
||||||
|
if not platform_uid or not token:
|
||||||
|
continue
|
||||||
|
authorized.append({
|
||||||
|
"avatar_id": str(row.avatar_id),
|
||||||
|
"avatar_name": row.avatar_display_name or row.avatar_name or row.owner_nickname or "数字分身",
|
||||||
|
"avatar_description": row.avatar_description or "",
|
||||||
|
"avatar_url": _resolve_photo_url(row.avatar_photo_url or row.owner_avatar_url or ""),
|
||||||
|
"config": config,
|
||||||
|
"platform_uid": platform_uid,
|
||||||
|
"token": token,
|
||||||
|
})
|
||||||
|
return authorized
|
||||||
|
|
||||||
|
|
||||||
|
def get_square_interaction_permissions(avatar_id: str) -> frozenset[str]:
|
||||||
|
"""实时复核授权;数据库不可用、令牌失效或撤权时一律拒绝执行。"""
|
||||||
|
avatar_db = get_session()
|
||||||
|
if avatar_db is None:
|
||||||
|
return frozenset()
|
||||||
|
try:
|
||||||
|
authorized_ids = {
|
||||||
|
item["avatar_id"] for item in _list_square_interaction_authorizations(avatar_db)
|
||||||
|
}
|
||||||
|
return SQUARE_INTERACTION_ACTIONS if avatar_id in authorized_ids else frozenset()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f"读取数字分身广场互动授权失败: {exc}")
|
||||||
|
return frozenset()
|
||||||
|
finally:
|
||||||
|
avatar_db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _word_count_range(config: dict) -> tuple[int, int]:
|
||||||
|
ranges = {
|
||||||
|
"short": (10, 35),
|
||||||
|
"medium": (20, 60),
|
||||||
|
"long": (30, 80),
|
||||||
|
}
|
||||||
|
return ranges.get(str(config.get("responseLength") or "medium"), (20, 60))
|
||||||
|
|
||||||
|
|
||||||
|
async def sync_square_interaction_users(db) -> set[str]:
|
||||||
|
"""把已授权分身同步为调度器身份,并刷新其会会会话。"""
|
||||||
|
avatar_db = get_session()
|
||||||
|
if avatar_db is None:
|
||||||
|
logger.warning("数字分身数据库不可用,跳过广场互动授权同步")
|
||||||
|
return set()
|
||||||
|
try:
|
||||||
|
authorized = _list_square_interaction_authorizations(avatar_db)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f"同步数字分身广场互动授权失败: {exc}")
|
||||||
|
return set()
|
||||||
|
finally:
|
||||||
|
avatar_db.close()
|
||||||
|
|
||||||
|
from app.core.redis_client import delete_session, set_session
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(VirtualUser).where(VirtualUser.account.like(f"{AVATAR_ACCOUNT_PREFIX}%"))
|
||||||
|
)
|
||||||
|
existing_users = {delegated_avatar_id(user): user for user in result.scalars().all()}
|
||||||
|
authorized_ids = {item["avatar_id"] for item in authorized}
|
||||||
|
|
||||||
|
for avatar_id, user in existing_users.items():
|
||||||
|
if avatar_id not in authorized_ids:
|
||||||
|
user.is_enabled = 0
|
||||||
|
user.status = 0
|
||||||
|
user.session_token = None
|
||||||
|
user.session_expires_at = None
|
||||||
|
await delete_session(user.id)
|
||||||
|
|
||||||
|
for item in authorized:
|
||||||
|
avatar_id = item["avatar_id"]
|
||||||
|
user = existing_users.get(avatar_id)
|
||||||
|
if user is None:
|
||||||
|
user = VirtualUser(
|
||||||
|
nickname=item["avatar_name"],
|
||||||
|
account=f"{AVATAR_ACCOUNT_PREFIX}{avatar_id}",
|
||||||
|
password_enc="",
|
||||||
|
status=2,
|
||||||
|
is_enabled=1,
|
||||||
|
platform_uid=item["platform_uid"],
|
||||||
|
remark="用户授权的数字分身广场互动身份",
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
expires_at = datetime.now() + timedelta(days=1)
|
||||||
|
user.nickname = item["avatar_name"]
|
||||||
|
user.real_name = item["avatar_name"]
|
||||||
|
user.avatar_url = item["avatar_url"]
|
||||||
|
user.platform_uid = item["platform_uid"]
|
||||||
|
user.session_token = item["token"]
|
||||||
|
user.session_expires_at = expires_at
|
||||||
|
user.last_login_at = datetime.now()
|
||||||
|
user.status = 2
|
||||||
|
user.is_enabled = 1
|
||||||
|
|
||||||
|
config = item["config"]
|
||||||
|
personality_result = await db.execute(
|
||||||
|
select(UserPersonality).where(UserPersonality.user_id == user.id)
|
||||||
|
)
|
||||||
|
personality = personality_result.scalar_one_or_none()
|
||||||
|
word_min, word_max = _word_count_range(config)
|
||||||
|
prompt_parts = [item["avatar_description"], str(config.get("systemPrompt") or "")]
|
||||||
|
style_prompt = "\n".join(part.strip() for part in prompt_parts if part and part.strip())
|
||||||
|
if personality is None:
|
||||||
|
personality = UserPersonality(user_id=user.id)
|
||||||
|
db.add(personality)
|
||||||
|
personality.language_style = str(config.get("replyStyle") or "professional")
|
||||||
|
personality.personality_desc = item["avatar_description"]
|
||||||
|
personality.comment_style_prompt = style_prompt
|
||||||
|
personality.word_count_min = word_min
|
||||||
|
personality.word_count_max = word_max
|
||||||
|
|
||||||
|
await set_session(user.id, {
|
||||||
|
"token": item["token"],
|
||||||
|
"session_id": f"avatar:{avatar_id}",
|
||||||
|
"platform_uid": item["platform_uid"],
|
||||||
|
"org_id": "",
|
||||||
|
"login_time": datetime.now().isoformat(),
|
||||||
|
"nickname": item["avatar_name"],
|
||||||
|
"real_name": item["avatar_name"],
|
||||||
|
"avatar": item["avatar_url"],
|
||||||
|
"delegated_avatar_id": avatar_id,
|
||||||
|
}, expire=86400)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
return authorized_ids
|
||||||
|
|
||||||
|
|
||||||
class AvatarService:
|
class AvatarService:
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ class SchedulerService:
|
|||||||
from app.core.database import AsyncSessionLocal
|
from app.core.database import AsyncSessionLocal
|
||||||
logger.info("⚡ 立即触发互动任务")
|
logger.info("⚡ 立即触发互动任务")
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
|
await self._sync_delegated_avatar_users(session)
|
||||||
try:
|
try:
|
||||||
max_concurrent = int(await self._get_config(session, "max_concurrent_users", "5"))
|
max_concurrent = int(await self._get_config(session, "max_concurrent_users", "5"))
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
@@ -146,7 +147,9 @@ class SchedulerService:
|
|||||||
async def _check_sessions(self):
|
async def _check_sessions(self):
|
||||||
"""定时校验登录状态"""
|
"""定时校验登录状态"""
|
||||||
from app.services.news_service import news_service
|
from app.services.news_service import news_service
|
||||||
|
from app.services.avatar_service import is_delegated_avatar_user
|
||||||
async with AsyncSessionLocal() as db:
|
async with AsyncSessionLocal() as db:
|
||||||
|
await self._sync_delegated_avatar_users(db)
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(VirtualUser).where(VirtualUser.status == 2, VirtualUser.is_enabled == 1)
|
select(VirtualUser).where(VirtualUser.status == 2, VirtualUser.is_enabled == 1)
|
||||||
)
|
)
|
||||||
@@ -154,7 +157,7 @@ class SchedulerService:
|
|||||||
for user in users:
|
for user in users:
|
||||||
try:
|
try:
|
||||||
valid = await news_service.check_session(db, user)
|
valid = await news_service.check_session(db, user)
|
||||||
if not valid:
|
if not valid and not is_delegated_avatar_user(user):
|
||||||
logger.warning(f"用户 {user.account} 会话失效,尝试重登")
|
logger.warning(f"用户 {user.account} 会话失效,尝试重登")
|
||||||
await news_service.login(db, user)
|
await news_service.login(db, user)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -163,6 +166,7 @@ class SchedulerService:
|
|||||||
async def _run_interactions(self):
|
async def _run_interactions(self):
|
||||||
"""执行互动任务"""
|
"""执行互动任务"""
|
||||||
async with AsyncSessionLocal() as db:
|
async with AsyncSessionLocal() as db:
|
||||||
|
await self._sync_delegated_avatar_users(db)
|
||||||
# 检查调度器开关
|
# 检查调度器开关
|
||||||
enabled = await self._get_config(db, "scheduler_enabled", "true")
|
enabled = await self._get_config(db, "scheduler_enabled", "true")
|
||||||
if enabled != "true":
|
if enabled != "true":
|
||||||
@@ -184,8 +188,11 @@ class SchedulerService:
|
|||||||
logger.debug(f"[调度] 当前北京时间 {now_time} 不在互动时段 {start_str}-{end_str}")
|
logger.debug(f"[调度] 当前北京时间 {now_time} 不在互动时段 {start_str}-{end_str}")
|
||||||
return
|
return
|
||||||
|
|
||||||
# 获取最小互动间隔(秒)
|
# 获取互动间隔范围(秒),与调度设置页面字段保持一致
|
||||||
min_interval = int(await self._get_config(db, "interact_min_interval", "300"))
|
min_interval = await self._get_int_config(db, "interact_interval_min", 300)
|
||||||
|
max_interval = await self._get_int_config(db, "interact_interval_max", min_interval)
|
||||||
|
min_interval = max(0, min_interval)
|
||||||
|
max_interval = max(min_interval, max_interval)
|
||||||
|
|
||||||
# 获取最大并发
|
# 获取最大并发
|
||||||
max_concurrent = int(await self._get_config(db, "max_concurrent_users", "5"))
|
max_concurrent = int(await self._get_config(db, "max_concurrent_users", "5"))
|
||||||
@@ -204,7 +211,7 @@ class SchedulerService:
|
|||||||
await self._try_login_users(db)
|
await self._try_login_users(db)
|
||||||
return
|
return
|
||||||
|
|
||||||
# 检查互动间隔:过滤掉最近 min_interval 秒内已互动的用户
|
# 每个用户在其最小/最大间隔内取得稳定随机值,直到下次互动后再变化
|
||||||
now_dt = datetime.now()
|
now_dt = datetime.now()
|
||||||
eligible = []
|
eligible = []
|
||||||
for u in all_users:
|
for u in all_users:
|
||||||
@@ -212,11 +219,17 @@ class SchedulerService:
|
|||||||
eligible.append(u)
|
eligible.append(u)
|
||||||
else:
|
else:
|
||||||
elapsed = (now_dt - u.last_interact_at).total_seconds()
|
elapsed = (now_dt - u.last_interact_at).total_seconds()
|
||||||
if elapsed >= min_interval:
|
interval = random.Random(
|
||||||
|
f"{u.id}:{u.last_interact_at.isoformat()}"
|
||||||
|
).randint(min_interval, max_interval)
|
||||||
|
if elapsed >= interval:
|
||||||
eligible.append(u)
|
eligible.append(u)
|
||||||
|
|
||||||
if not eligible:
|
if not eligible:
|
||||||
logger.debug(f"[调度] 所有 {len(all_users)} 个用户在 {min_interval}s 内已互动,跳过本次")
|
logger.debug(
|
||||||
|
f"[调度] 所有 {len(all_users)} 个用户尚未达到 "
|
||||||
|
f"{min_interval}-{max_interval}s 随机互动间隔,跳过本次"
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# 按最后互动时间升序排序:最久没互动的用户优先
|
# 按最后互动时间升序排序:最久没互动的用户优先
|
||||||
@@ -257,10 +270,12 @@ class SchedulerService:
|
|||||||
async def _try_login_users(self, db):
|
async def _try_login_users(self, db):
|
||||||
"""尝试登录未登录的用户"""
|
"""尝试登录未登录的用户"""
|
||||||
from app.services.news_service import news_service
|
from app.services.news_service import news_service
|
||||||
|
from app.services.avatar_service import AVATAR_ACCOUNT_PREFIX
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(VirtualUser).where(
|
select(VirtualUser).where(
|
||||||
VirtualUser.status.in_([0, 3]),
|
VirtualUser.status.in_([0, 3]),
|
||||||
VirtualUser.is_enabled == 1
|
VirtualUser.is_enabled == 1,
|
||||||
|
~VirtualUser.account.like(f"{AVATAR_ACCOUNT_PREFIX}%"),
|
||||||
).limit(3)
|
).limit(3)
|
||||||
)
|
)
|
||||||
users = result.scalars().all()
|
users = result.scalars().all()
|
||||||
@@ -275,6 +290,11 @@ class SchedulerService:
|
|||||||
"""执行单用户互动 - 基于真实接口"""
|
"""执行单用户互动 - 基于真实接口"""
|
||||||
from app.services.news_service import news_service
|
from app.services.news_service import news_service
|
||||||
from app.services.ai_service import ai_service
|
from app.services.ai_service import ai_service
|
||||||
|
from app.services.avatar_service import (
|
||||||
|
delegated_avatar_id,
|
||||||
|
get_square_interaction_permissions,
|
||||||
|
is_delegated_avatar_user,
|
||||||
|
)
|
||||||
|
|
||||||
async with AsyncSessionLocal() as db:
|
async with AsyncSessionLocal() as db:
|
||||||
try:
|
try:
|
||||||
@@ -289,6 +309,23 @@ class SchedulerService:
|
|||||||
"interactions": [],
|
"interactions": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
allowed_actions = {"like", "collect", "comment", "reply", "forward"}
|
||||||
|
if is_delegated_avatar_user(user):
|
||||||
|
allowed_actions = set(
|
||||||
|
get_square_interaction_permissions(delegated_avatar_id(user))
|
||||||
|
)
|
||||||
|
if not allowed_actions:
|
||||||
|
user.status = 0
|
||||||
|
user.is_enabled = 0
|
||||||
|
await db.commit()
|
||||||
|
return {
|
||||||
|
"user_id": user.id,
|
||||||
|
"account": user.account,
|
||||||
|
"status": "skipped",
|
||||||
|
"reason": "avatar_interaction_not_authorized",
|
||||||
|
"interactions": [],
|
||||||
|
}
|
||||||
|
|
||||||
# 检查今日评论限额
|
# 检查今日评论限额
|
||||||
can_comment = True
|
can_comment = True
|
||||||
if user.today_comment_count >= user.daily_comment_limit:
|
if user.today_comment_count >= user.daily_comment_limit:
|
||||||
@@ -398,14 +435,53 @@ class SchedulerService:
|
|||||||
interactions_done = []
|
interactions_done = []
|
||||||
action_failures = []
|
action_failures = []
|
||||||
|
|
||||||
# ① 先记录阅读(每次必做,模拟真实用户打开文章)
|
done_on_this = today_done.get(news_id, set())
|
||||||
|
wants = {
|
||||||
|
"like": (
|
||||||
|
"like" in allowed_actions
|
||||||
|
and "like" not in done_on_this
|
||||||
|
and random.random() < like_prob
|
||||||
|
),
|
||||||
|
"collect": (
|
||||||
|
"collect" in allowed_actions
|
||||||
|
and "collect" not in done_on_this
|
||||||
|
and random.random() < collect_prob
|
||||||
|
),
|
||||||
|
"forward": (
|
||||||
|
"forward" in allowed_actions
|
||||||
|
and "forward" not in done_on_this
|
||||||
|
and random.random() < forward_prob
|
||||||
|
),
|
||||||
|
"reply": (
|
||||||
|
"reply" in allowed_actions
|
||||||
|
and can_comment
|
||||||
|
and personality is not None
|
||||||
|
and random.random() < reply_prob
|
||||||
|
),
|
||||||
|
"comment": (
|
||||||
|
"comment" in allowed_actions
|
||||||
|
and can_comment
|
||||||
|
and personality is not None
|
||||||
|
and not already_commented_this
|
||||||
|
and random.random() < comment_prob
|
||||||
|
),
|
||||||
|
}
|
||||||
|
if not any(wants.values()):
|
||||||
|
return {
|
||||||
|
"user_id": user.id,
|
||||||
|
"account": user.account,
|
||||||
|
"status": "skipped",
|
||||||
|
"reason": "no_actions_triggered",
|
||||||
|
"interactions": [],
|
||||||
|
"article_id": news_id,
|
||||||
|
"article_title": news_title,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 只有动作命中调度概率后才打开文章
|
||||||
await news_service.read_news(db, user, news_id)
|
await news_service.read_news(db, user, news_id)
|
||||||
|
|
||||||
# 今日已对此文章做过的互动类型
|
|
||||||
done_on_this = today_done.get(news_id, set())
|
|
||||||
|
|
||||||
# ② 点赞(每篇文章每用户每天只点赞一次)
|
# ② 点赞(每篇文章每用户每天只点赞一次)
|
||||||
if "like" not in done_on_this and random.random() < like_prob:
|
if wants["like"]:
|
||||||
success, err = await news_service.like_news(db, user, news_id, org_id=article_org_id, to_user_id=news_author, title=news_title)
|
success, err = await news_service.like_news(db, user, news_id, org_id=article_org_id, to_user_id=news_author, title=news_title)
|
||||||
await self._save_record(db, user, news_id, news_title, "like", None, 0, success, err)
|
await self._save_record(db, user, news_id, news_title, "like", None, 0, success, err)
|
||||||
if success:
|
if success:
|
||||||
@@ -415,16 +491,17 @@ class SchedulerService:
|
|||||||
action_failures.append({"type": "like", "error": err})
|
action_failures.append({"type": "like", "error": err})
|
||||||
|
|
||||||
# ③ 收藏(每篇文章每用户每天只收藏一次)
|
# ③ 收藏(每篇文章每用户每天只收藏一次)
|
||||||
if "collect" not in done_on_this and random.random() < collect_prob:
|
if wants["collect"]:
|
||||||
success, err = await news_service.collect_news(db, user, news_id, org_id=article_org_id, to_user_id=news_author, title=news_title)
|
success, err = await news_service.collect_news(db, user, news_id, org_id=article_org_id, to_user_id=news_author, title=news_title)
|
||||||
await self._save_record(db, user, news_id, news_title, "collect", None, 0, success, err)
|
await self._save_record(db, user, news_id, news_title, "collect", None, 0, success, err)
|
||||||
if success:
|
if success:
|
||||||
interactions_done.append("collect")
|
interactions_done.append("collect")
|
||||||
|
await self._incr_total(db, user_id)
|
||||||
else:
|
else:
|
||||||
action_failures.append({"type": "collect", "error": err})
|
action_failures.append({"type": "collect", "error": err})
|
||||||
|
|
||||||
# ④ 转发(每篇文章每用户每天只转发一次)
|
# ④ 转发(每篇文章每用户每天只转发一次)
|
||||||
if "forward" not in done_on_this and random.random() < forward_prob:
|
if wants["forward"]:
|
||||||
success, err = await news_service.forward_news(db, user, news_id)
|
success, err = await news_service.forward_news(db, user, news_id)
|
||||||
await self._save_record(db, user, news_id, news_title, "forward", None, 0, success, err)
|
await self._save_record(db, user, news_id, news_title, "forward", None, 0, success, err)
|
||||||
if success:
|
if success:
|
||||||
@@ -438,7 +515,7 @@ class SchedulerService:
|
|||||||
style_prompt = personality.comment_style_prompt or ""
|
style_prompt = personality.comment_style_prompt or ""
|
||||||
safe_word_max = min(personality.word_count_max, 80)
|
safe_word_max = min(personality.word_count_max, 80)
|
||||||
|
|
||||||
if random.random() < reply_prob:
|
if wants["reply"]:
|
||||||
reply_actions, reply_failures = await self._run_reply_interaction_chain(
|
reply_actions, reply_failures = await self._run_reply_interaction_chain(
|
||||||
db=db,
|
db=db,
|
||||||
starter=user,
|
starter=user,
|
||||||
@@ -455,7 +532,7 @@ class SchedulerService:
|
|||||||
action_failures.extend(reply_failures)
|
action_failures.extend(reply_failures)
|
||||||
|
|
||||||
# 每篇文章每个用户每天只发一条顶层评论;回复不再要求先评论
|
# 每篇文章每个用户每天只发一条顶层评论;回复不再要求先评论
|
||||||
if not already_commented_this and random.random() < comment_prob:
|
if wants["comment"]:
|
||||||
comment_text, tokens = await ai_service.generate_comment(
|
comment_text, tokens = await ai_service.generate_comment(
|
||||||
db, news_title, news_content,
|
db, news_title, news_content,
|
||||||
style_prompt, personality.word_count_min, safe_word_max
|
style_prompt, personality.word_count_min, safe_word_max
|
||||||
@@ -679,6 +756,7 @@ class SchedulerService:
|
|||||||
|
|
||||||
async with AsyncSessionLocal() as db:
|
async with AsyncSessionLocal() as db:
|
||||||
try:
|
try:
|
||||||
|
await self._sync_delegated_avatar_users(db)
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
await db.execute(
|
await db.execute(
|
||||||
update(PendingReplyTask)
|
update(PendingReplyTask)
|
||||||
@@ -706,6 +784,12 @@ class SchedulerService:
|
|||||||
logger.error(f"待发送回复队列处理异常: {e}")
|
logger.error(f"待发送回复队列处理异常: {e}")
|
||||||
|
|
||||||
async def _process_pending_reply_task(self, db, task: PendingReplyTask, news_service, ai_service):
|
async def _process_pending_reply_task(self, db, task: PendingReplyTask, news_service, ai_service):
|
||||||
|
from app.services.avatar_service import (
|
||||||
|
delegated_avatar_id,
|
||||||
|
get_square_interaction_permissions,
|
||||||
|
is_delegated_avatar_user,
|
||||||
|
)
|
||||||
|
|
||||||
task.status = 1
|
task.status = 1
|
||||||
task.locked_at = datetime.now()
|
task.locked_at = datetime.now()
|
||||||
task.attempts = (task.attempts or 0) + 1
|
task.attempts = (task.attempts or 0) + 1
|
||||||
@@ -716,6 +800,13 @@ class SchedulerService:
|
|||||||
task.status = 3
|
task.status = 3
|
||||||
task.last_error = "用户未登录或已禁用"
|
task.last_error = "用户未登录或已禁用"
|
||||||
return
|
return
|
||||||
|
if (
|
||||||
|
is_delegated_avatar_user(actor)
|
||||||
|
and "reply" not in get_square_interaction_permissions(delegated_avatar_id(actor))
|
||||||
|
):
|
||||||
|
task.status = 3
|
||||||
|
task.last_error = "数字分身广场互动授权已撤销"
|
||||||
|
return
|
||||||
|
|
||||||
reply_result = await self._post_contextual_reply(
|
reply_result = await self._post_contextual_reply(
|
||||||
db=db,
|
db=db,
|
||||||
@@ -858,6 +949,16 @@ class SchedulerService:
|
|||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return default
|
return default
|
||||||
|
|
||||||
|
async def _sync_delegated_avatar_users(self, db):
|
||||||
|
from app.services.avatar_service import sync_square_interaction_users
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await sync_square_interaction_users(db)
|
||||||
|
except Exception as exc:
|
||||||
|
await db.rollback()
|
||||||
|
logger.error(f"数字分身广场互动身份同步异常: {exc}")
|
||||||
|
return set()
|
||||||
|
|
||||||
async def _incr_total(self, db, user_id: int):
|
async def _incr_total(self, db, user_id: int):
|
||||||
await db.execute(
|
await db.execute(
|
||||||
update(VirtualUser).where(VirtualUser.id == user_id).values(
|
update(VirtualUser).where(VirtualUser.id == user_id).values(
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from app.services import avatar_service
|
||||||
|
|
||||||
|
|
||||||
|
class AvatarSquareAuthorizationTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
fd, self.db_path = tempfile.mkstemp(suffix=".db")
|
||||||
|
os.close(fd)
|
||||||
|
connection = sqlite3.connect(self.db_path)
|
||||||
|
connection.executescript("""
|
||||||
|
CREATE TABLE users (
|
||||||
|
huihui_user_id TEXT,
|
||||||
|
nickname TEXT,
|
||||||
|
avatar_url TEXT,
|
||||||
|
huihui_token TEXT
|
||||||
|
);
|
||||||
|
CREATE TABLE avatars (
|
||||||
|
id TEXT,
|
||||||
|
owner_id TEXT,
|
||||||
|
name TEXT,
|
||||||
|
display_name TEXT,
|
||||||
|
description TEXT,
|
||||||
|
photo_url TEXT,
|
||||||
|
config TEXT,
|
||||||
|
status TEXT
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO users VALUES (?, ?, ?, ?)",
|
||||||
|
("huihui-7", "主人", "/owner.jpg", "huihui-token"),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
connection.close()
|
||||||
|
avatar_service._engine = None
|
||||||
|
avatar_service._SessionLocal = None
|
||||||
|
self.path_patch = patch.object(avatar_service.settings, "AVATAR_DB_PATH", self.db_path)
|
||||||
|
self.path_patch.start()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.path_patch.stop()
|
||||||
|
if avatar_service._engine is not None:
|
||||||
|
avatar_service._engine.dispose()
|
||||||
|
avatar_service._engine = None
|
||||||
|
avatar_service._SessionLocal = None
|
||||||
|
os.unlink(self.db_path)
|
||||||
|
|
||||||
|
def _insert_avatar(self, permissions, *, status="active", token=None):
|
||||||
|
connection = sqlite3.connect(self.db_path)
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO avatars VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(
|
||||||
|
"avatar-7",
|
||||||
|
"huihui-7",
|
||||||
|
"avatar",
|
||||||
|
"小会",
|
||||||
|
"语气友好,表达简洁",
|
||||||
|
"/avatar.jpg",
|
||||||
|
json.dumps({
|
||||||
|
"authorizationPermissions": permissions,
|
||||||
|
"replyStyle": "warm",
|
||||||
|
"responseLength": "short",
|
||||||
|
}),
|
||||||
|
status,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if token is not None:
|
||||||
|
connection.execute(
|
||||||
|
"UPDATE users SET huihui_token = ? WHERE huihui_user_id = ?",
|
||||||
|
(token, "huihui-7"),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
def test_interact_permission_exposes_only_requested_square_actions(self):
|
||||||
|
self._insert_avatar(["chat", "interact"])
|
||||||
|
|
||||||
|
permissions = avatar_service.get_square_interaction_permissions("avatar-7")
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
permissions,
|
||||||
|
frozenset({"like", "collect", "comment", "reply"}),
|
||||||
|
)
|
||||||
|
self.assertNotIn("forward", permissions)
|
||||||
|
|
||||||
|
def test_missing_permission_inactive_avatar_or_missing_token_denies_execution(self):
|
||||||
|
scenarios = [
|
||||||
|
(["chat"], "active", "huihui-token"),
|
||||||
|
(["interact"], "inactive", "huihui-token"),
|
||||||
|
(["interact"], "active", ""),
|
||||||
|
]
|
||||||
|
for permissions, status, token in scenarios:
|
||||||
|
with self.subTest(permissions=permissions, status=status, token=token):
|
||||||
|
connection = sqlite3.connect(self.db_path)
|
||||||
|
connection.execute("DELETE FROM avatars")
|
||||||
|
connection.commit()
|
||||||
|
connection.close()
|
||||||
|
self._insert_avatar(permissions, status=status, token=token)
|
||||||
|
self.assertEqual(
|
||||||
|
avatar_service.get_square_interaction_permissions("avatar-7"),
|
||||||
|
frozenset(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_delegated_avatar_identity_is_recognized_without_matching_normal_users(self):
|
||||||
|
delegated = SimpleNamespace(account="__avatar__:avatar-7")
|
||||||
|
normal = SimpleNamespace(account="13800000000")
|
||||||
|
|
||||||
|
self.assertTrue(avatar_service.is_delegated_avatar_user(delegated))
|
||||||
|
self.assertEqual(avatar_service.delegated_avatar_id(delegated), "avatar-7")
|
||||||
|
self.assertFalse(avatar_service.is_delegated_avatar_user(normal))
|
||||||
|
self.assertEqual(avatar_service.delegated_avatar_id(normal), "")
|
||||||
|
|
||||||
|
def test_response_length_maps_to_scheduler_comment_limits(self):
|
||||||
|
self.assertEqual(avatar_service._word_count_range({"responseLength": "short"}), (10, 35))
|
||||||
|
self.assertEqual(avatar_service._word_count_range({"responseLength": "long"}), (30, 80))
|
||||||
|
self.assertEqual(avatar_service._word_count_range({"responseLength": "unknown"}), (20, 60))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -1,16 +1,30 @@
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine, event
|
||||||
from sqlalchemy.orm import sessionmaker, declarative_base, Session
|
from sqlalchemy.orm import sessionmaker, declarative_base, Session
|
||||||
|
|
||||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
DB_FILE = os.path.join(BASE_DIR, "avatar.db")
|
DB_FILE = os.path.join(BASE_DIR, "avatar.db")
|
||||||
DATABASE_URL = os.getenv("DATABASE_URL", f"sqlite:///{DB_FILE}")
|
DATABASE_URL = os.getenv("DATABASE_URL", f"sqlite:///{DB_FILE}")
|
||||||
|
|
||||||
|
IS_SQLITE = DATABASE_URL.startswith("sqlite:")
|
||||||
engine = create_engine(
|
engine = create_engine(
|
||||||
DATABASE_URL,
|
DATABASE_URL,
|
||||||
connect_args={"check_same_thread": False} if DATABASE_URL.startswith("sqlite:") else {},
|
connect_args={"check_same_thread": False, "timeout": 30} if IS_SQLITE else {},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if IS_SQLITE:
|
||||||
|
@event.listens_for(engine, "connect")
|
||||||
|
def _configure_sqlite_connection(dbapi_connection, _connection_record):
|
||||||
|
cursor = dbapi_connection.cursor()
|
||||||
|
try:
|
||||||
|
cursor.execute("PRAGMA synchronous=NORMAL")
|
||||||
|
cursor.execute("PRAGMA busy_timeout=30000")
|
||||||
|
finally:
|
||||||
|
cursor.close()
|
||||||
|
|
||||||
|
|
||||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
|
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
|
||||||
Base = declarative_base()
|
Base = declarative_base()
|
||||||
|
|
||||||
@@ -26,6 +40,10 @@ def get_db():
|
|||||||
def init_db():
|
def init_db():
|
||||||
import models
|
import models
|
||||||
|
|
||||||
|
if IS_SQLITE:
|
||||||
|
with engine.connect() as conn:
|
||||||
|
conn.exec_driver_sql("PRAGMA journal_mode=WAL")
|
||||||
|
conn.commit()
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
# 轻量迁移:为已存在的表补充新列(SQLite 不支持自动 ALTER,逐列尝试)
|
# 轻量迁移:为已存在的表补充新列(SQLite 不支持自动 ALTER,逐列尝试)
|
||||||
@@ -35,13 +53,24 @@ def init_db():
|
|||||||
("knowledge_docs", "embedding_model", "VARCHAR DEFAULT ''"),
|
("knowledge_docs", "embedding_model", "VARCHAR DEFAULT ''"),
|
||||||
("knowledge_docs", "chunk_count", "INTEGER DEFAULT 0"),
|
("knowledge_docs", "chunk_count", "INTEGER DEFAULT 0"),
|
||||||
("knowledge_docs", "vectorized_at", "TIMESTAMP"),
|
("knowledge_docs", "vectorized_at", "TIMESTAMP"),
|
||||||
|
("knowledge_docs", "error_message", "VARCHAR DEFAULT ''"),
|
||||||
|
("knowledge_docs", "index_stage", "VARCHAR DEFAULT ''"),
|
||||||
|
("knowledge_docs", "index_progress", "INTEGER DEFAULT 0"),
|
||||||
("avatars", "owner_id", "VARCHAR DEFAULT ''"),
|
("avatars", "owner_id", "VARCHAR DEFAULT ''"),
|
||||||
("authorizations", "takeover_enabled", "BOOLEAN DEFAULT 0"),
|
("authorizations", "takeover_enabled", "BOOLEAN DEFAULT 0"),
|
||||||
("authorizations", "takeover_mode", "VARCHAR DEFAULT 'immediate'"),
|
("authorizations", "takeover_mode", "VARCHAR DEFAULT 'immediate'"),
|
||||||
("authorizations", "takeover_delay_seconds", "INTEGER DEFAULT 30"),
|
("authorizations", "takeover_delay_seconds", "INTEGER DEFAULT 180"),
|
||||||
("avatars", "share_token", "VARCHAR DEFAULT NULL"),
|
("avatars", "share_token", "VARCHAR DEFAULT NULL"),
|
||||||
|
("token_account", "user_id", "VARCHAR DEFAULT ''"),
|
||||||
|
("token_account", "total_granted", "BIGINT DEFAULT 0"),
|
||||||
|
("token_account", "total_consumed", "BIGINT DEFAULT 0"),
|
||||||
|
("token_account", "created_at", "TIMESTAMP"),
|
||||||
|
("token_account", "updated_at", "TIMESTAMP"),
|
||||||
|
("takeover_messages", "attachment_id", "VARCHAR DEFAULT NULL"),
|
||||||
)
|
)
|
||||||
_normalize_optional_unique_values()
|
_normalize_optional_unique_values()
|
||||||
|
_normalize_takeover_delays()
|
||||||
|
_create_token_indexes()
|
||||||
|
|
||||||
|
|
||||||
def _try_add_columns(*cols):
|
def _try_add_columns(*cols):
|
||||||
@@ -58,3 +87,20 @@ def _try_add_columns(*cols):
|
|||||||
def _normalize_optional_unique_values():
|
def _normalize_optional_unique_values():
|
||||||
with engine.begin() as conn:
|
with engine.begin() as conn:
|
||||||
conn.exec_driver_sql("UPDATE avatars SET share_token = NULL WHERE share_token = ''")
|
conn.exec_driver_sql("UPDATE avatars SET share_token = NULL WHERE share_token = ''")
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_takeover_delays():
|
||||||
|
with engine.begin() as conn:
|
||||||
|
# The old 30-second column default was never wired into the scheduler.
|
||||||
|
conn.exec_driver_sql(
|
||||||
|
"UPDATE authorizations SET takeover_delay_seconds = 180 "
|
||||||
|
"WHERE takeover_delay_seconds IS NULL OR takeover_delay_seconds = 30"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_token_indexes():
|
||||||
|
with engine.begin() as conn:
|
||||||
|
conn.exec_driver_sql(
|
||||||
|
"CREATE UNIQUE INDEX IF NOT EXISTS ux_token_account_user_id "
|
||||||
|
"ON token_account(user_id) WHERE user_id <> ''"
|
||||||
|
)
|
||||||
|
|||||||
@@ -18,6 +18,14 @@ EMBED_DIM = 256
|
|||||||
MODEL = os.getenv("EMBEDDING_MODEL", "mock-hash-embed-v1")
|
MODEL = os.getenv("EMBEDDING_MODEL", "mock-hash-embed-v1")
|
||||||
|
|
||||||
|
|
||||||
|
def _embedding_endpoint(api_url):
|
||||||
|
"""Accept either an OpenAI-compatible base URL or its full endpoint."""
|
||||||
|
api_url = (api_url or "").strip().rstrip("/")
|
||||||
|
if not api_url or api_url.endswith("/embeddings"):
|
||||||
|
return api_url
|
||||||
|
return f"{api_url}/embeddings"
|
||||||
|
|
||||||
|
|
||||||
def _tokenize(text):
|
def _tokenize(text):
|
||||||
text = (text or "").lower()
|
text = (text or "").lower()
|
||||||
# 英文/数字按词,CJK 逐字(中文无空格,需拆到字级才能命中子词)
|
# 英文/数字按词,CJK 逐字(中文无空格,需拆到字级才能命中子词)
|
||||||
@@ -43,11 +51,11 @@ def _hash_embedding(texts, dim=EMBED_DIM):
|
|||||||
return vecs
|
return vecs
|
||||||
|
|
||||||
|
|
||||||
def embed(texts):
|
def embed(texts, on_progress=None):
|
||||||
"""返回 list[list[float]],与输入顺序一致。"""
|
"""返回 list[list[float]],与输入顺序一致。"""
|
||||||
if not texts:
|
if not texts:
|
||||||
return []
|
return []
|
||||||
api_url = os.getenv("EMBEDDING_API_URL")
|
api_url = _embedding_endpoint(os.getenv("EMBEDDING_API_URL"))
|
||||||
if api_url:
|
if api_url:
|
||||||
api_key = os.getenv("EMBEDDING_API_KEY", "")
|
api_key = os.getenv("EMBEDDING_API_KEY", "")
|
||||||
model = os.getenv("EMBEDDING_MODEL", "text-embedding-3-small")
|
model = os.getenv("EMBEDDING_MODEL", "text-embedding-3-small")
|
||||||
@@ -56,6 +64,7 @@ def embed(texts):
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
batch_size = 10
|
batch_size = 10
|
||||||
embeddings = []
|
embeddings = []
|
||||||
|
total = len(texts)
|
||||||
for start in range(0, len(texts), batch_size):
|
for start in range(0, len(texts), batch_size):
|
||||||
batch = texts[start:start + batch_size]
|
batch = texts[start:start + batch_size]
|
||||||
payload = json.dumps({"input": batch, "model": model}).encode("utf-8")
|
payload = json.dumps({"input": batch, "model": model}).encode("utf-8")
|
||||||
@@ -76,8 +85,13 @@ def embed(texts):
|
|||||||
if len(items) != len(batch):
|
if len(items) != len(batch):
|
||||||
raise ValueError("embedding response count does not match request")
|
raise ValueError("embedding response count does not match request")
|
||||||
embeddings.extend(item["embedding"] for item in items)
|
embeddings.extend(item["embedding"] for item in items)
|
||||||
|
if on_progress:
|
||||||
|
on_progress(len(embeddings), total)
|
||||||
return embeddings
|
return embeddings
|
||||||
return _hash_embedding(texts)
|
vectors = _hash_embedding(texts)
|
||||||
|
if on_progress:
|
||||||
|
on_progress(len(vectors), len(texts))
|
||||||
|
return vectors
|
||||||
|
|
||||||
|
|
||||||
def cosine(a, b):
|
def cosine(a, b):
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
|||||||
from apscheduler.triggers.interval import IntervalTrigger
|
from apscheduler.triggers.interval import IntervalTrigger
|
||||||
|
|
||||||
from database import init_db, SessionLocal
|
from database import init_db, SessionLocal
|
||||||
from models import Avatar, Authorization, Organization, TokenAccount, TokenPlan
|
from models import Avatar, Authorization, Organization, TokenAccount, TokenPlan, User
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
import routers.avatars
|
import routers.avatars
|
||||||
import routers.tokens
|
import routers.tokens
|
||||||
@@ -19,10 +19,14 @@ import routers.huihui_auth
|
|||||||
import routers.chat
|
import routers.chat
|
||||||
import routers.takeover
|
import routers.takeover
|
||||||
from responses import ok
|
from responses import ok
|
||||||
|
from services.chat_attachment_service import purge_expired_chat_attachments
|
||||||
|
from services.knowledge_vectorizer import knowledge_vectorizer
|
||||||
|
from services.token_billing import DEFAULT_TOKEN_GRANT, release_stale_reservations
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
takeover_scheduler = None
|
takeover_scheduler = None
|
||||||
|
maintenance_scheduler = None
|
||||||
|
|
||||||
app = FastAPI(title="会会数字分身 API", version="1.0.0")
|
app = FastAPI(title="会会数字分身 API", version="1.0.0")
|
||||||
|
|
||||||
@@ -56,17 +60,29 @@ def health():
|
|||||||
def seed():
|
def seed():
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
if db.query(TokenAccount).first() is None:
|
plan_specs = [
|
||||||
db.add(TokenAccount(balance=1250))
|
{"id": "1", "name": "基础套餐", "amount": 2_000_000, "price": 10, "badge": "", "desc": "2M 积分"},
|
||||||
|
{"id": "2", "name": "标准套餐", "amount": 20_000_000, "price": 100, "badge": "常用", "desc": "20M 积分"},
|
||||||
|
{"id": "3", "name": "专业套餐", "amount": 250_000_000, "price": 1000, "badge": "加赠25%", "desc": "250M 积分"},
|
||||||
|
{"id": "4", "name": "企业套餐", "amount": 2_500_000_000, "price": 10000, "badge": "企业推荐", "desc": "2500M 积分"},
|
||||||
|
]
|
||||||
|
for spec in plan_specs:
|
||||||
|
plan = db.query(TokenPlan).filter(TokenPlan.id == spec["id"]).first()
|
||||||
|
if plan is None:
|
||||||
|
db.add(TokenPlan(**spec))
|
||||||
|
else:
|
||||||
|
for key, value in spec.items():
|
||||||
|
setattr(plan, key, value)
|
||||||
|
|
||||||
if db.query(TokenPlan).count() == 0:
|
for user in db.query(User).all():
|
||||||
plans = [
|
account = db.query(TokenAccount).filter(TokenAccount.user_id == user.id).first()
|
||||||
TokenPlan(id="1", name="新手体验", amount=1000, price=9.9, desc="新手体验"),
|
if account is None:
|
||||||
TokenPlan(id="2", name="热门套餐", amount=5000, price=39.9, badge="热门"),
|
db.add(TokenAccount(
|
||||||
TokenPlan(id="3", name="超值套餐", amount=12000, price=89.9, badge="超值"),
|
user_id=user.id,
|
||||||
TokenPlan(id="4", name="企业推荐", amount=30000, price=199, badge="企业推荐", desc="适合高频使用"),
|
balance=DEFAULT_TOKEN_GRANT,
|
||||||
]
|
total_granted=DEFAULT_TOKEN_GRANT,
|
||||||
db.add_all(plans)
|
total_consumed=0,
|
||||||
|
))
|
||||||
|
|
||||||
if db.query(Avatar).count() == 0:
|
if db.query(Avatar).count() == 0:
|
||||||
avatar = Avatar(
|
avatar = Avatar(
|
||||||
@@ -105,6 +121,7 @@ def seed():
|
|||||||
db.add_all(orgs)
|
db.add_all(orgs)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
release_stale_reservations(db)
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
@@ -115,9 +132,19 @@ def on_startup():
|
|||||||
|
|
||||||
init_db()
|
init_db()
|
||||||
seed()
|
seed()
|
||||||
|
knowledge_vectorizer.start()
|
||||||
|
|
||||||
# Release stale resources when startup is invoked again by a reload/test.
|
# Release stale resources when startup is invoked again by a reload/test.
|
||||||
stop_takeover_scheduler()
|
stop_takeover_scheduler()
|
||||||
|
stop_maintenance_scheduler()
|
||||||
|
try:
|
||||||
|
start_maintenance_scheduler()
|
||||||
|
except Exception as exc:
|
||||||
|
stop_maintenance_scheduler()
|
||||||
|
logger.warning(
|
||||||
|
"Failed to initialize chat attachment cleanup, app will continue: %s",
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
# --- Takeover scheduler ---
|
# --- Takeover scheduler ---
|
||||||
try:
|
try:
|
||||||
@@ -138,7 +165,14 @@ def on_startup():
|
|||||||
boxim_client = BoxIMClient(boxim_config)
|
boxim_client = BoxIMClient(boxim_config)
|
||||||
|
|
||||||
from services.takeover_service import TakeoverService
|
from services.takeover_service import TakeoverService
|
||||||
takeover_service = TakeoverService(SessionLocal, boxim_client)
|
takeover_service = TakeoverService(
|
||||||
|
SessionLocal,
|
||||||
|
boxim_client,
|
||||||
|
poll_concurrency=int(os.getenv("BOXIM_POLL_CONCURRENCY", "8")),
|
||||||
|
max_message_age_seconds=int(
|
||||||
|
os.getenv("BOXIM_MAX_MESSAGE_AGE_SECONDS", "600")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
poll_interval = max(0.5, float(os.getenv("BOXIM_POLL_INTERVAL_SECONDS", "1")))
|
poll_interval = max(0.5, float(os.getenv("BOXIM_POLL_INTERVAL_SECONDS", "1")))
|
||||||
takeover_scheduler = AsyncIOScheduler()
|
takeover_scheduler = AsyncIOScheduler()
|
||||||
@@ -182,6 +216,51 @@ def stop_takeover_scheduler():
|
|||||||
finally:
|
finally:
|
||||||
takeover_scheduler = None
|
takeover_scheduler = None
|
||||||
|
|
||||||
|
|
||||||
|
def purge_expired_chat_attachments_job():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
count = purge_expired_chat_attachments(db)
|
||||||
|
if count:
|
||||||
|
logger.info("Purged %s expired chat image attachment(s)", count)
|
||||||
|
except Exception as exc:
|
||||||
|
db.rollback()
|
||||||
|
logger.warning("Failed to purge expired chat image attachments: %s", exc)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def start_maintenance_scheduler():
|
||||||
|
global maintenance_scheduler
|
||||||
|
|
||||||
|
purge_expired_chat_attachments_job()
|
||||||
|
interval_minutes = max(
|
||||||
|
5, min(1440, int(os.getenv("CHAT_ATTACHMENT_CLEANUP_MINUTES", "60")))
|
||||||
|
)
|
||||||
|
maintenance_scheduler = AsyncIOScheduler()
|
||||||
|
maintenance_scheduler.add_job(
|
||||||
|
purge_expired_chat_attachments_job,
|
||||||
|
trigger=IntervalTrigger(minutes=interval_minutes),
|
||||||
|
id="chat_attachment_cleanup",
|
||||||
|
max_instances=1,
|
||||||
|
coalesce=True,
|
||||||
|
)
|
||||||
|
maintenance_scheduler.start()
|
||||||
|
|
||||||
|
|
||||||
|
def stop_maintenance_scheduler():
|
||||||
|
global maintenance_scheduler
|
||||||
|
|
||||||
|
if maintenance_scheduler is not None:
|
||||||
|
try:
|
||||||
|
if maintenance_scheduler.running:
|
||||||
|
maintenance_scheduler.shutdown(wait=False)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to stop maintenance scheduler cleanly: %s", exc)
|
||||||
|
finally:
|
||||||
|
maintenance_scheduler = None
|
||||||
|
|
||||||
@app.on_event("shutdown")
|
@app.on_event("shutdown")
|
||||||
def on_shutdown():
|
def on_shutdown():
|
||||||
stop_takeover_scheduler()
|
stop_takeover_scheduler()
|
||||||
|
stop_maintenance_scheduler()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
|
BigInteger,
|
||||||
Boolean,
|
Boolean,
|
||||||
Column,
|
Column,
|
||||||
DateTime,
|
DateTime,
|
||||||
@@ -66,7 +67,7 @@ class Authorization(Base):
|
|||||||
status = Column(String, default="active") # active | inactive
|
status = Column(String, default="active") # active | inactive
|
||||||
takeover_enabled = Column(Boolean, default=False) # 是否开启分身接管
|
takeover_enabled = Column(Boolean, default=False) # 是否开启分身接管
|
||||||
takeover_mode = Column(String, default="immediate") # immediate | delayed
|
takeover_mode = Column(String, default="immediate") # immediate | delayed
|
||||||
takeover_delay_seconds = Column(Integer, default=30) # 延迟秒数
|
takeover_delay_seconds = Column(Integer, default=180) # 延迟秒数,默认 3 分钟
|
||||||
created_at = Column(DateTime, server_default=func.now())
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
@@ -119,13 +120,14 @@ class TakeoverMessage(Base):
|
|||||||
direction = Column(String, nullable=False) # incoming | outgoing
|
direction = Column(String, nullable=False) # incoming | outgoing
|
||||||
message_type = Column(Integer, default=0)
|
message_type = Column(Integer, default=0)
|
||||||
content = Column(Text, default="")
|
content = Column(Text, default="")
|
||||||
|
attachment_id = Column(String, nullable=True)
|
||||||
is_avatar = Column(Boolean, default=False)
|
is_avatar = Column(Boolean, default=False)
|
||||||
send_time = Column(DateTime, nullable=False)
|
send_time = Column(DateTime, nullable=False)
|
||||||
created_at = Column(DateTime, server_default=func.now())
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
|
||||||
|
|
||||||
class TakeoverReplyTask(Base):
|
class TakeoverReplyTask(Base):
|
||||||
"""Restart-safe three-second BOXIM reply task."""
|
"""Restart-safe delayed BOXIM reply task."""
|
||||||
|
|
||||||
__tablename__ = "takeover_reply_tasks"
|
__tablename__ = "takeover_reply_tasks"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
@@ -187,7 +189,10 @@ class KnowledgeDoc(Base):
|
|||||||
file_type = Column(String, default="") # pdf | doc | docx | xlsx
|
file_type = Column(String, default="") # pdf | doc | docx | xlsx
|
||||||
file_size = Column(Integer, default=0)
|
file_size = Column(Integer, default=0)
|
||||||
file_url = Column(String, default="")
|
file_url = Column(String, default="")
|
||||||
status = Column(String, default="uploaded") # uploaded | parsing | ready
|
status = Column(String, default="uploaded") # uploaded | parsing | ready | failed
|
||||||
|
error_message = Column(String, default="") # 建立索引失败原因
|
||||||
|
index_stage = Column(String, default="") # queued | extracting | chunking | embedding | ready | failed
|
||||||
|
index_progress = Column(Integer, default=0) # 0-100
|
||||||
vectorized = Column(Boolean, default=False) # 是否已向量化
|
vectorized = Column(Boolean, default=False) # 是否已向量化
|
||||||
embedding_model = Column(String, default="") # 向量模型标识
|
embedding_model = Column(String, default="") # 向量模型标识
|
||||||
chunk_count = Column(Integer, default=0) # 切片数量
|
chunk_count = Column(Integer, default=0) # 切片数量
|
||||||
@@ -203,6 +208,9 @@ class KnowledgeDoc(Base):
|
|||||||
"fileSize": self.file_size,
|
"fileSize": self.file_size,
|
||||||
"fileUrl": self.file_url,
|
"fileUrl": self.file_url,
|
||||||
"status": self.status,
|
"status": self.status,
|
||||||
|
"errorMessage": self.error_message or "",
|
||||||
|
"indexStage": self.index_stage or "",
|
||||||
|
"indexProgress": int(self.index_progress or 0),
|
||||||
"vectorized": bool(self.vectorized),
|
"vectorized": bool(self.vectorized),
|
||||||
"embeddingModel": self.embedding_model,
|
"embeddingModel": self.embedding_model,
|
||||||
"chunkCount": self.chunk_count,
|
"chunkCount": self.chunk_count,
|
||||||
@@ -256,17 +264,83 @@ class KnowledgeChunk(Base):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ChatAttachment(Base):
|
||||||
|
"""Private, avatar-scoped result of one chat image analysis."""
|
||||||
|
|
||||||
|
__tablename__ = "chat_attachments"
|
||||||
|
id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex)
|
||||||
|
avatar_id = Column(String, nullable=False, default="", index=True)
|
||||||
|
uploader_kind = Column(String, default="owner") # owner | public | boxim
|
||||||
|
filename = Column(String, default="")
|
||||||
|
mime_type = Column(String, default="")
|
||||||
|
file_size = Column(Integer, default=0)
|
||||||
|
status = Column(String, default="processing") # processing | ready | failed
|
||||||
|
category = Column(String, default="general_image")
|
||||||
|
summary = Column(Text, default="")
|
||||||
|
extracted_text = Column(Text, default="")
|
||||||
|
structured_data = Column(JSON, default=dict)
|
||||||
|
warning = Column(Text, default="")
|
||||||
|
vision_model = Column(String, default="")
|
||||||
|
ocr_model = Column(String, default="")
|
||||||
|
used_at = Column(DateTime)
|
||||||
|
expires_at = Column(DateTime, nullable=False)
|
||||||
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"avatarId": self.avatar_id,
|
||||||
|
"filename": self.filename,
|
||||||
|
"mimeType": self.mime_type,
|
||||||
|
"fileSize": self.file_size,
|
||||||
|
"status": self.status,
|
||||||
|
"category": self.category,
|
||||||
|
"summary": self.summary,
|
||||||
|
"warning": self.warning,
|
||||||
|
"expiresAt": _iso(self.expires_at),
|
||||||
|
"createdAt": _iso(self.created_at),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class TokenAccount(Base):
|
class TokenAccount(Base):
|
||||||
__tablename__ = "token_account"
|
__tablename__ = "token_account"
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
balance = Column(Integer, default=1250)
|
user_id = Column(String, nullable=False, default="", index=True)
|
||||||
|
balance = Column(BigInteger, default=1_000_000)
|
||||||
|
total_granted = Column(BigInteger, default=1_000_000)
|
||||||
|
total_consumed = Column(BigInteger, default=0)
|
||||||
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class TokenUsage(Base):
|
||||||
|
__tablename__ = "token_usage"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_token_usage_user_created", "user_id", "created_at"),
|
||||||
|
Index("ix_token_usage_avatar_created", "avatar_id", "created_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex)
|
||||||
|
user_id = Column(String, nullable=False, index=True)
|
||||||
|
avatar_id = Column(String, nullable=False, default="", index=True)
|
||||||
|
source = Column(String, nullable=False, default="chat")
|
||||||
|
model = Column(String, default="")
|
||||||
|
status = Column(String, nullable=False, default="reserved")
|
||||||
|
reserved_tokens = Column(BigInteger, default=0)
|
||||||
|
prompt_tokens = Column(BigInteger, default=0)
|
||||||
|
completion_tokens = Column(BigInteger, default=0)
|
||||||
|
total_tokens = Column(BigInteger, default=0)
|
||||||
|
balance_after = Column(BigInteger, default=0)
|
||||||
|
failure_reason = Column(String, default="")
|
||||||
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
settled_at = Column(DateTime)
|
||||||
|
|
||||||
|
|
||||||
class TokenPlan(Base):
|
class TokenPlan(Base):
|
||||||
__tablename__ = "token_plans"
|
__tablename__ = "token_plans"
|
||||||
id = Column(String, primary_key=True)
|
id = Column(String, primary_key=True)
|
||||||
name = Column(String, default="")
|
name = Column(String, default="")
|
||||||
amount = Column(Integer, default=0)
|
amount = Column(BigInteger, default=0)
|
||||||
price = Column(Float, default=0)
|
price = Column(Float, default=0)
|
||||||
badge = Column(String, default="")
|
badge = Column(String, default="")
|
||||||
desc = Column(String, default="")
|
desc = Column(String, default="")
|
||||||
@@ -282,6 +356,47 @@ class TokenPlan(Base):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TokenPaymentOrder(Base):
|
||||||
|
__tablename__ = "token_payment_orders"
|
||||||
|
|
||||||
|
id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex)
|
||||||
|
order_no = Column(String, nullable=False, unique=True, index=True)
|
||||||
|
user_id = Column(String, nullable=False, index=True)
|
||||||
|
plan_id = Column(String, nullable=False)
|
||||||
|
payment_method = Column(String, nullable=False)
|
||||||
|
pay_type = Column(String, nullable=False)
|
||||||
|
pay_way = Column(String, nullable=False)
|
||||||
|
points_amount = Column(BigInteger, nullable=False)
|
||||||
|
price_cents = Column(Integer, nullable=False)
|
||||||
|
status = Column(String, nullable=False, default="pending", index=True)
|
||||||
|
provider_order_id = Column(String, default="")
|
||||||
|
provider_order_no = Column(String, default="")
|
||||||
|
provider_status = Column(String, default="")
|
||||||
|
pay_message = Column(Text, default="")
|
||||||
|
failure_reason = Column(String, default="")
|
||||||
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||||
|
paid_at = Column(DateTime)
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"orderNo": self.order_no,
|
||||||
|
"planId": self.plan_id,
|
||||||
|
"paymentMethod": self.payment_method,
|
||||||
|
"payType": self.pay_type,
|
||||||
|
"payWay": self.pay_way,
|
||||||
|
"pointsAmount": self.points_amount,
|
||||||
|
"price": self.price_cents / 100,
|
||||||
|
"status": self.status,
|
||||||
|
"providerStatus": self.provider_status,
|
||||||
|
"payMessage": self.pay_message,
|
||||||
|
"failureReason": self.failure_reason,
|
||||||
|
"createdAt": _iso(self.created_at),
|
||||||
|
"paidAt": _iso(self.paid_at),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class User(Base):
|
class User(Base):
|
||||||
"""会会用户 ↔ 本地用户体系映射(短信验证码登录落库)"""
|
"""会会用户 ↔ 本地用户体系映射(短信验证码登录落库)"""
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ pydantic
|
|||||||
python-multipart
|
python-multipart
|
||||||
httpx
|
httpx
|
||||||
pypdf
|
pypdf
|
||||||
|
PyMuPDF>=1.24,<2
|
||||||
python-docx
|
python-docx
|
||||||
openpyxl
|
openpyxl
|
||||||
apscheduler>=3.10
|
apscheduler>=3.10
|
||||||
|
Pillow>=10.4
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from fastapi import APIRouter, Body, Depends, Header, HTTPException
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from models import Authorization, TakeoverCursor, TakeoverReplyTask
|
from models import Authorization, Avatar, TakeoverCursor, TakeoverReplyTask
|
||||||
from responses import fail, ok
|
from responses import fail, ok
|
||||||
from routers.avatars import _require_owned_avatar
|
from routers.avatars import _require_owned_avatar
|
||||||
|
|
||||||
@@ -14,6 +14,10 @@ ALLOWED_PERMISSIONS = set(PERMISSION_ORDER)
|
|||||||
AVATAR_PERMISSION_ORDER = PERMISSION_ORDER
|
AVATAR_PERMISSION_ORDER = PERMISSION_ORDER
|
||||||
AVATAR_PERMISSION_KEY = "authorizationPermissions"
|
AVATAR_PERMISSION_KEY = "authorizationPermissions"
|
||||||
DEFAULT_AVATAR_PERMISSIONS = ["friend", "chat"]
|
DEFAULT_AVATAR_PERMISSIONS = ["friend", "chat"]
|
||||||
|
TAKEOVER_DELAY_KEY = "takeoverReplyDelaySeconds"
|
||||||
|
DEFAULT_TAKEOVER_DELAY_SECONDS = 180
|
||||||
|
MIN_TAKEOVER_DELAY_SECONDS = 3
|
||||||
|
MAX_TAKEOVER_DELAY_SECONDS = 86_400
|
||||||
LEGACY_PERMISSION_MAP = {
|
LEGACY_PERMISSION_MAP = {
|
||||||
"read": "browse",
|
"read": "browse",
|
||||||
"reply": "chat",
|
"reply": "chat",
|
||||||
@@ -91,9 +95,62 @@ def _permission_settings_payload(avatar) -> dict:
|
|||||||
return {
|
return {
|
||||||
"avatarId": avatar.id,
|
"avatarId": avatar.id,
|
||||||
"permissions": _stored_avatar_permissions(avatar),
|
"permissions": _stored_avatar_permissions(avatar),
|
||||||
|
"takeoverReplyDelaySeconds": _stored_takeover_delay(avatar),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _stored_takeover_delay(avatar) -> int:
|
||||||
|
raw = (avatar.config or {}).get(TAKEOVER_DELAY_KEY, DEFAULT_TAKEOVER_DELAY_SECONDS)
|
||||||
|
if isinstance(raw, bool):
|
||||||
|
return DEFAULT_TAKEOVER_DELAY_SECONDS
|
||||||
|
try:
|
||||||
|
delay = int(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return DEFAULT_TAKEOVER_DELAY_SECONDS
|
||||||
|
if not MIN_TAKEOVER_DELAY_SECONDS <= delay <= MAX_TAKEOVER_DELAY_SECONDS:
|
||||||
|
return DEFAULT_TAKEOVER_DELAY_SECONDS
|
||||||
|
return delay
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_takeover_delay(value) -> int:
|
||||||
|
if isinstance(value, bool) or not isinstance(value, int):
|
||||||
|
raise ValueError("自动回复等待时间必须是整数秒")
|
||||||
|
if not MIN_TAKEOVER_DELAY_SECONDS <= value <= MAX_TAKEOVER_DELAY_SECONDS:
|
||||||
|
raise ValueError("自动回复等待时间需在 3 秒到 24 小时之间")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _disable_other_takeovers(db: Session, avatar) -> list[str]:
|
||||||
|
disabled_ids = []
|
||||||
|
others = (
|
||||||
|
db.query(Avatar)
|
||||||
|
.filter(Avatar.owner_id == avatar.owner_id, Avatar.id != avatar.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for other in others:
|
||||||
|
permissions = _stored_avatar_permissions(other)
|
||||||
|
if "takeover" not in permissions:
|
||||||
|
continue
|
||||||
|
other.config = {
|
||||||
|
**(other.config or {}),
|
||||||
|
AVATAR_PERMISSION_KEY: [item for item in permissions if item != "takeover"],
|
||||||
|
}
|
||||||
|
disabled_ids.append(other.id)
|
||||||
|
tasks = (
|
||||||
|
db.query(TakeoverReplyTask)
|
||||||
|
.filter(
|
||||||
|
TakeoverReplyTask.avatar_id == other.id,
|
||||||
|
TakeoverReplyTask.status.in_(("pending", "generating", "ready", "sending")),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for task in tasks:
|
||||||
|
task.status = "cancelled"
|
||||||
|
task.cancel_reason = "another_avatar_takeover_enabled"
|
||||||
|
task.locked_at = None
|
||||||
|
return disabled_ids
|
||||||
|
|
||||||
|
|
||||||
def _require_authorization(db: Session, avatar_id: str, authorization_id: str) -> Authorization:
|
def _require_authorization(db: Session, avatar_id: str, authorization_id: str) -> Authorization:
|
||||||
authorization = (
|
authorization = (
|
||||||
db.query(Authorization)
|
db.query(Authorization)
|
||||||
@@ -144,10 +201,19 @@ def update_permission_settings(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
avatar = _require_owned_avatar(db, avatar_id, authorization)
|
avatar = _require_owned_avatar(db, avatar_id, authorization)
|
||||||
if "permissions" not in payload:
|
if "permissions" not in payload and TAKEOVER_DELAY_KEY not in payload:
|
||||||
return fail("缺少 permissions", 400)
|
return fail("缺少授权设置", 400)
|
||||||
try:
|
try:
|
||||||
permissions = _normalize_avatar_permissions(payload["permissions"])
|
permissions = (
|
||||||
|
_normalize_avatar_permissions(payload["permissions"])
|
||||||
|
if "permissions" in payload
|
||||||
|
else _stored_avatar_permissions(avatar)
|
||||||
|
)
|
||||||
|
takeover_delay = (
|
||||||
|
_validate_takeover_delay(payload[TAKEOVER_DELAY_KEY])
|
||||||
|
if TAKEOVER_DELAY_KEY in payload
|
||||||
|
else _stored_takeover_delay(avatar)
|
||||||
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return fail(str(exc), 400)
|
return fail(str(exc), 400)
|
||||||
|
|
||||||
@@ -155,7 +221,9 @@ def update_permission_settings(
|
|||||||
avatar.config = {
|
avatar.config = {
|
||||||
**(avatar.config or {}),
|
**(avatar.config or {}),
|
||||||
AVATAR_PERMISSION_KEY: permissions,
|
AVATAR_PERMISSION_KEY: permissions,
|
||||||
|
TAKEOVER_DELAY_KEY: takeover_delay,
|
||||||
}
|
}
|
||||||
|
disabled_avatar_ids = _disable_other_takeovers(db, avatar) if "takeover" in permissions else []
|
||||||
cursor = db.query(TakeoverCursor).filter(TakeoverCursor.avatar_id == avatar.id).first()
|
cursor = db.query(TakeoverCursor).filter(TakeoverCursor.avatar_id == avatar.id).first()
|
||||||
if cursor and "takeover" in permissions and "takeover" not in previous_permissions:
|
if cursor and "takeover" in permissions and "takeover" not in previous_permissions:
|
||||||
cursor.initialized = False
|
cursor.initialized = False
|
||||||
@@ -179,7 +247,9 @@ def update_permission_settings(
|
|||||||
task.locked_at = None
|
task.locked_at = None
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(avatar)
|
db.refresh(avatar)
|
||||||
return ok(_permission_settings_payload(avatar), "授权设置已保存")
|
response = _permission_settings_payload(avatar)
|
||||||
|
response["disabledAvatarIds"] = disabled_avatar_ids
|
||||||
|
return ok(response, "授权设置已保存")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/avatar/{avatar_id}/authorizations")
|
@router.get("/avatar/{avatar_id}/authorizations")
|
||||||
@@ -240,7 +310,7 @@ def create_auth(
|
|||||||
status="active",
|
status="active",
|
||||||
takeover_enabled=False,
|
takeover_enabled=False,
|
||||||
takeover_mode="immediate",
|
takeover_mode="immediate",
|
||||||
takeover_delay_seconds=30,
|
takeover_delay_seconds=DEFAULT_TAKEOVER_DELAY_SECONDS,
|
||||||
)
|
)
|
||||||
db.add(item)
|
db.add(item)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -6,7 +6,17 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from routers.knowledge import UPLOAD_DIR
|
from routers.knowledge import UPLOAD_DIR
|
||||||
from models import Avatar, KnowledgeDoc, KnowledgeChunk, QAPair, Authorization, User
|
from models import (
|
||||||
|
Authorization,
|
||||||
|
Avatar,
|
||||||
|
KnowledgeChunk,
|
||||||
|
KnowledgeDoc,
|
||||||
|
QAPair,
|
||||||
|
TakeoverCursor,
|
||||||
|
TakeoverMessage,
|
||||||
|
TakeoverReplyTask,
|
||||||
|
User,
|
||||||
|
)
|
||||||
from responses import ok, fail
|
from responses import ok, fail
|
||||||
|
|
||||||
router = APIRouter(tags=["分身"])
|
router = APIRouter(tags=["分身"])
|
||||||
@@ -74,18 +84,21 @@ def list_avatars(page: int = 1, limit: int = 20, authorization: str = Header(Non
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/avatar/{avatar_id}")
|
@router.get("/avatar/{avatar_id}")
|
||||||
def get_avatar(avatar_id: str, db: Session = Depends(get_db)):
|
def get_avatar(
|
||||||
a = db.query(Avatar).filter(Avatar.id == avatar_id).first()
|
avatar_id: str,
|
||||||
if not a:
|
authorization: str = Header(None),
|
||||||
return fail("分身不存在", 404)
|
db: Session = Depends(get_db),
|
||||||
return ok(a.to_dict())
|
):
|
||||||
|
return ok(_require_owned_avatar(db, avatar_id, authorization).to_dict())
|
||||||
|
|
||||||
|
|
||||||
@router.post("/avatar")
|
@router.post("/avatar")
|
||||||
def create_avatar(payload: dict = Body(...), authorization: str = Header(None), db: Session = Depends(get_db)):
|
def create_avatar(payload: dict = Body(...), authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||||
user = _resolve_user(authorization, db)
|
user = _resolve_user(authorization, db)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=401, detail="未登录")
|
||||||
a = Avatar(
|
a = Avatar(
|
||||||
owner_id=user.huihui_user_id if user else "",
|
owner_id=user.huihui_user_id,
|
||||||
name=payload.get("name", "未命名分身"),
|
name=payload.get("name", "未命名分身"),
|
||||||
display_name=payload.get("displayName", "") or payload.get("display_name", ""),
|
display_name=payload.get("displayName", "") or payload.get("display_name", ""),
|
||||||
description=payload.get("description", ""),
|
description=payload.get("description", ""),
|
||||||
@@ -102,10 +115,13 @@ def create_avatar(payload: dict = Body(...), authorization: str = Header(None),
|
|||||||
|
|
||||||
|
|
||||||
@router.put("/avatar/{avatar_id}")
|
@router.put("/avatar/{avatar_id}")
|
||||||
def update_avatar(avatar_id: str, payload: dict = Body(...), db: Session = Depends(get_db)):
|
def update_avatar(
|
||||||
a = db.query(Avatar).filter(Avatar.id == avatar_id).first()
|
avatar_id: str,
|
||||||
if not a:
|
payload: dict = Body(...),
|
||||||
return fail("分身不存在", 404)
|
authorization: str = Header(None),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
a = _require_owned_avatar(db, avatar_id, authorization)
|
||||||
mapping = {
|
mapping = {
|
||||||
"displayName": "display_name",
|
"displayName": "display_name",
|
||||||
"photoUrl": "photo_url",
|
"photoUrl": "photo_url",
|
||||||
@@ -114,22 +130,32 @@ def update_avatar(avatar_id: str, payload: dict = Body(...), db: Session = Depen
|
|||||||
for key in ("name", "displayName", "description", "photoUrl", "emoji", "status", "tokenBalance", "config"):
|
for key in ("name", "displayName", "description", "photoUrl", "emoji", "status", "tokenBalance", "config"):
|
||||||
if key in payload:
|
if key in payload:
|
||||||
col = mapping.get(key, key)
|
col = mapping.get(key, key)
|
||||||
setattr(a, col, payload[key])
|
value = payload[key]
|
||||||
|
if key == "config":
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
return fail("分身配置格式不正确", 400)
|
||||||
|
value = {**(a.config or {}), **value}
|
||||||
|
setattr(a, col, value)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(a)
|
db.refresh(a)
|
||||||
return ok(a.to_dict())
|
return ok(a.to_dict())
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/avatar/{avatar_id}")
|
@router.delete("/avatar/{avatar_id}")
|
||||||
def delete_avatar(avatar_id: str, db: Session = Depends(get_db)):
|
def delete_avatar(
|
||||||
a = db.query(Avatar).filter(Avatar.id == avatar_id).first()
|
avatar_id: str,
|
||||||
if not a:
|
authorization: str = Header(None),
|
||||||
return fail("分身不存在", 404)
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
a = _require_owned_avatar(db, avatar_id, authorization)
|
||||||
# 级联清理关联数据,避免孤儿记录
|
# 级联清理关联数据,避免孤儿记录
|
||||||
db.query(KnowledgeDoc).filter(KnowledgeDoc.avatar_id == avatar_id).delete()
|
db.query(KnowledgeDoc).filter(KnowledgeDoc.avatar_id == avatar_id).delete()
|
||||||
db.query(KnowledgeChunk).filter(KnowledgeChunk.avatar_id == avatar_id).delete()
|
db.query(KnowledgeChunk).filter(KnowledgeChunk.avatar_id == avatar_id).delete()
|
||||||
db.query(QAPair).filter(QAPair.avatar_id == avatar_id).delete()
|
db.query(QAPair).filter(QAPair.avatar_id == avatar_id).delete()
|
||||||
db.query(Authorization).filter(Authorization.avatar_id == avatar_id).delete()
|
db.query(Authorization).filter(Authorization.avatar_id == avatar_id).delete()
|
||||||
|
db.query(TakeoverReplyTask).filter(TakeoverReplyTask.avatar_id == avatar_id).delete()
|
||||||
|
db.query(TakeoverMessage).filter(TakeoverMessage.avatar_id == avatar_id).delete()
|
||||||
|
db.query(TakeoverCursor).filter(TakeoverCursor.avatar_id == avatar_id).delete()
|
||||||
db.delete(a)
|
db.delete(a)
|
||||||
db.commit()
|
db.commit()
|
||||||
return ok({"success": True})
|
return ok({"success": True})
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,7 @@ _CN_TZ = timezone(timedelta(hours=8))
|
|||||||
from database import get_db
|
from database import get_db
|
||||||
from models import Avatar, TakeoverCursor, TakeoverMessage, TakeoverReplyTask, User
|
from models import Avatar, TakeoverCursor, TakeoverMessage, TakeoverReplyTask, User
|
||||||
from responses import ok, fail
|
from responses import ok, fail
|
||||||
|
from services.boxim_client import BoxIMClient, BoxIMError
|
||||||
|
|
||||||
router = APIRouter(tags=["会会账号"])
|
router = APIRouter(tags=["会会账号"])
|
||||||
|
|
||||||
@@ -111,6 +112,19 @@ def _cfg_ready() -> bool:
|
|||||||
return bool(AUTH_BASE_URL and APP_ID and ACCESS_ID and ACCESS_SECRET)
|
return bool(AUTH_BASE_URL and APP_ID and ACCESS_ID and ACCESS_SECRET)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_boxim_client() -> BoxIMClient:
|
||||||
|
return BoxIMClient({
|
||||||
|
"HUIHUI_PLATFORM_BASE_URL": os.getenv(
|
||||||
|
"HUIHUI_PLATFORM_BASE_URL", "https://open.99hui.com/api"
|
||||||
|
),
|
||||||
|
"BOXIM_API_BASE_URL": os.getenv("BOXIM_API_BASE_URL", "https://im.99hui.com/api"),
|
||||||
|
"HUIHUI_APP_ID": APP_ID,
|
||||||
|
"HUIHUI_ACCESS_ID": ACCESS_ID,
|
||||||
|
"HUIHUI_ACCESS_SECRET": ACCESS_SECRET,
|
||||||
|
"BOXIM_TIMEOUT_SECONDS": os.getenv("BOXIM_TIMEOUT_SECONDS", "20"),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
def _call_huihui(path: str, params: dict, as_query: bool = False):
|
def _call_huihui(path: str, params: dict, as_query: bool = False):
|
||||||
"""调用会会接口,返回 (ok: bool, payload: dict, http_status: int)"""
|
"""调用会会接口,返回 (ok: bool, payload: dict, http_status: int)"""
|
||||||
url = f"{AUTH_BASE_URL}{path}"
|
url = f"{AUTH_BASE_URL}{path}"
|
||||||
@@ -281,6 +295,47 @@ def pwd_login(body: dict = Body(...), db: Session = Depends(get_db)):
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/huihui/token/login")
|
||||||
|
async def token_login(body: dict = Body(...), db: Session = Depends(get_db)):
|
||||||
|
"""Validate a production Huihui token through BOXIM and issue an app session."""
|
||||||
|
huihui_token = (body.get("token") or "").strip()
|
||||||
|
if not huihui_token or len(huihui_token) > 8192:
|
||||||
|
return fail("会会登录凭证无效或已过期", 401)
|
||||||
|
if not _cfg_ready():
|
||||||
|
return fail("会会登录服务未配置", 500)
|
||||||
|
|
||||||
|
client = _create_boxim_client()
|
||||||
|
try:
|
||||||
|
token_data = await client.exchange_access_token(huihui_token)
|
||||||
|
profile = await client.get_self(token_data["accessToken"])
|
||||||
|
except BoxIMError as exc:
|
||||||
|
if exc.auth_error:
|
||||||
|
return fail("会会登录凭证无效或已过期", 401)
|
||||||
|
return fail("会会登录服务暂时不可用,请稍后重试", 502)
|
||||||
|
|
||||||
|
# BOXIM's id is its internal IM id. Account ownership must use huihuiUserId.
|
||||||
|
huihui_user_id = str(profile.get("huihuiUserId") or "").strip()
|
||||||
|
if not huihui_user_id:
|
||||||
|
return fail("会会未返回用户标识", 502)
|
||||||
|
|
||||||
|
phone = str(_pick(profile, "mobile", "phone", default="")).strip()
|
||||||
|
nickname = str(_pick(profile, "nickName", "nickname", "name", "userName", default="")).strip()
|
||||||
|
avatar_url = str(
|
||||||
|
_pick(profile, "headImage", "headImageThumb", "avatar", "avatarUrl", default="")
|
||||||
|
).strip()
|
||||||
|
return _issue_session(
|
||||||
|
db,
|
||||||
|
phone,
|
||||||
|
{
|
||||||
|
"userId": huihui_user_id,
|
||||||
|
"nickname": nickname,
|
||||||
|
"avatarUrl": avatar_url,
|
||||||
|
"token": huihui_token,
|
||||||
|
},
|
||||||
|
reuse_existing_session=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _transfer_avatar_ownership(db: Session, old_owner_id: str, new_owner_id: str) -> int:
|
def _transfer_avatar_ownership(db: Session, old_owner_id: str, new_owner_id: str) -> int:
|
||||||
"""Move one user's avatar-owned data to a replacement Huihui identity."""
|
"""Move one user's avatar-owned data to a replacement Huihui identity."""
|
||||||
if not old_owner_id or old_owner_id == new_owner_id:
|
if not old_owner_id or old_owner_id == new_owner_id:
|
||||||
@@ -332,7 +387,13 @@ def _find_or_link_user(db: Session, phone: str, huihui_user_id: str) -> User:
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
def _issue_session(db: Session, phone: str, info: dict):
|
def _issue_session(
|
||||||
|
db: Session,
|
||||||
|
phone: str,
|
||||||
|
info: dict,
|
||||||
|
*,
|
||||||
|
reuse_existing_session: bool = False,
|
||||||
|
):
|
||||||
"""建/链本地用户并签发本系统会话 token"""
|
"""建/链本地用户并签发本系统会话 token"""
|
||||||
huihui_user_id = info.get("userId", "")
|
huihui_user_id = info.get("userId", "")
|
||||||
user = _find_or_link_user(db, phone, huihui_user_id)
|
user = _find_or_link_user(db, phone, huihui_user_id)
|
||||||
@@ -343,12 +404,16 @@ def _issue_session(db: Session, phone: str, info: dict):
|
|||||||
if info.get("avatarUrl"):
|
if info.get("avatarUrl"):
|
||||||
user.avatar_url = info["avatarUrl"]
|
user.avatar_url = info["avatarUrl"]
|
||||||
user.huihui_token = info.get("token", "")
|
user.huihui_token = info.get("token", "")
|
||||||
user.app_token = uuid.uuid4().hex
|
if not reuse_existing_session or not user.app_token:
|
||||||
|
user.app_token = uuid.uuid4().hex
|
||||||
user.last_login_at = datetime.now()
|
user.last_login_at = datetime.now()
|
||||||
db.add(user)
|
db.add(user)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(user)
|
db.refresh(user)
|
||||||
|
|
||||||
|
from services.token_billing import get_or_create_account
|
||||||
|
get_or_create_account(db, user.id)
|
||||||
|
|
||||||
return ok({
|
return ok({
|
||||||
"token": user.app_token,
|
"token": user.app_token,
|
||||||
"user": user.to_dict(),
|
"user": user.to_dict(),
|
||||||
@@ -356,7 +421,6 @@ def _issue_session(db: Session, phone: str, info: dict):
|
|||||||
"userId": huihui_user_id,
|
"userId": huihui_user_id,
|
||||||
"nickname": info.get("nickname", ""),
|
"nickname": info.get("nickname", ""),
|
||||||
"avatarUrl": info.get("avatarUrl", ""),
|
"avatarUrl": info.get("avatarUrl", ""),
|
||||||
"token": info.get("token", ""),
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
|
import shutil
|
||||||
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
from fastapi import APIRouter, UploadFile, File, Depends, Header, HTTPException
|
from fastapi import APIRouter, UploadFile, File, Depends, Header, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
@@ -11,15 +12,19 @@ from database import get_db
|
|||||||
from models import KnowledgeDoc, QAPair, KnowledgeChunk, Avatar, User
|
from models import KnowledgeDoc, QAPair, KnowledgeChunk, Avatar, User
|
||||||
from responses import ok, fail
|
from responses import ok, fail
|
||||||
import embeddings
|
import embeddings
|
||||||
|
from services.knowledge_vectorizer import knowledge_vectorizer
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
UPLOAD_DIR = os.path.abspath(os.getenv("UPLOAD_DIR", os.path.join(BASE_DIR, "uploads")))
|
UPLOAD_DIR = os.path.abspath(os.getenv("UPLOAD_DIR", os.path.join(BASE_DIR, "uploads")))
|
||||||
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
||||||
|
|
||||||
ALLOWED_EXT = {".md", ".txt", ".pdf", ".doc", ".docx", ".xlsx"}
|
ALLOWED_EXT = {".md", ".txt", ".pdf", ".doc", ".docx", ".xlsx"}
|
||||||
MAX_UPLOAD_BYTES = 10 * 1024 * 1024
|
MAX_UPLOAD_BYTES = 50 * 1024 * 1024
|
||||||
|
UPLOAD_CHUNK_BYTES = 1024 * 1024
|
||||||
|
MULTIPART_CHUNK_BYTES = 5 * 1024 * 1024
|
||||||
|
MULTIPART_ROOT = ".multipart"
|
||||||
|
MULTIPART_TTL_SECONDS = 24 * 60 * 60
|
||||||
|
|
||||||
|
|
||||||
class QAIn(BaseModel):
|
class QAIn(BaseModel):
|
||||||
@@ -32,6 +37,74 @@ class EnabledIn(BaseModel):
|
|||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class MultipartUploadIn(BaseModel):
|
||||||
|
filename: str
|
||||||
|
fileSize: int
|
||||||
|
totalChunks: int
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_document(filename: str, file_size: int):
|
||||||
|
ext = os.path.splitext(filename or "")[1].lower()
|
||||||
|
if ext not in ALLOWED_EXT:
|
||||||
|
return None, f"不支持的文件类型:{ext or '空'},仅支持 md/txt/pdf/doc/docx/xlsx"
|
||||||
|
if file_size <= 0:
|
||||||
|
return None, "文件内容不能为空"
|
||||||
|
if file_size > MAX_UPLOAD_BYTES:
|
||||||
|
return None, "文件不能超过 50MB"
|
||||||
|
return ext, ""
|
||||||
|
|
||||||
|
|
||||||
|
def _multipart_dir(avatar_id: str, upload_id: str) -> str:
|
||||||
|
safe_avatar_id = os.path.basename(avatar_id)
|
||||||
|
safe_upload_id = os.path.basename(upload_id)
|
||||||
|
if (
|
||||||
|
safe_avatar_id != avatar_id
|
||||||
|
or safe_upload_id != upload_id
|
||||||
|
or len(upload_id) != 32
|
||||||
|
or any(character not in "0123456789abcdef" for character in upload_id)
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=400, detail="上传标识无效")
|
||||||
|
return os.path.join(UPLOAD_DIR, MULTIPART_ROOT, safe_avatar_id, safe_upload_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _purge_stale_multipart_uploads(avatar_id: str):
|
||||||
|
avatar_upload_root = os.path.join(UPLOAD_DIR, MULTIPART_ROOT, os.path.basename(avatar_id))
|
||||||
|
if not os.path.isdir(avatar_upload_root):
|
||||||
|
return
|
||||||
|
cutoff = time.time() - MULTIPART_TTL_SECONDS
|
||||||
|
for entry in os.scandir(avatar_upload_root):
|
||||||
|
if entry.is_dir(follow_symlinks=False) and entry.stat(follow_symlinks=False).st_mtime < cutoff:
|
||||||
|
shutil.rmtree(entry.path, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_multipart_metadata(avatar_id: str, upload_id: str) -> tuple[str, dict]:
|
||||||
|
upload_dir = _multipart_dir(avatar_id, upload_id)
|
||||||
|
metadata_path = os.path.join(upload_dir, "metadata.json")
|
||||||
|
if not os.path.isfile(metadata_path):
|
||||||
|
raise HTTPException(status_code=404, detail="上传任务不存在或已过期")
|
||||||
|
with open(metadata_path, "r", encoding="utf-8") as stream:
|
||||||
|
return upload_dir, json.load(stream)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_knowledge_doc(db: Session, avatar_id: str, filename: str, ext: str, file_size: int, stored: str):
|
||||||
|
doc = KnowledgeDoc(
|
||||||
|
id=uuid.uuid4().hex,
|
||||||
|
avatar_id=avatar_id,
|
||||||
|
filename=filename,
|
||||||
|
file_type=ext.lstrip("."),
|
||||||
|
file_size=file_size,
|
||||||
|
file_url=f"/api/files/{avatar_id}/{stored}",
|
||||||
|
status="parsing",
|
||||||
|
index_stage="queued",
|
||||||
|
index_progress=0,
|
||||||
|
)
|
||||||
|
db.add(doc)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(doc)
|
||||||
|
knowledge_vectorizer.enqueue(doc.id)
|
||||||
|
return doc
|
||||||
|
|
||||||
|
|
||||||
def _doc_payload(doc: KnowledgeDoc) -> dict:
|
def _doc_payload(doc: KnowledgeDoc) -> dict:
|
||||||
payload = doc.to_dict()
|
payload = doc.to_dict()
|
||||||
stored_name = os.path.basename(doc.file_url or "")
|
stored_name = os.path.basename(doc.file_url or "")
|
||||||
@@ -75,60 +148,172 @@ def list_docs(avatar_id: str, authorization: str = Header(None), db: Session = D
|
|||||||
@router.post("/avatar/{avatar_id}/knowledge/docs")
|
@router.post("/avatar/{avatar_id}/knowledge/docs")
|
||||||
async def upload_doc(avatar_id: str, file: UploadFile = File(...), authorization: str = Header(None), db: Session = Depends(get_db)):
|
async def upload_doc(avatar_id: str, file: UploadFile = File(...), authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||||
_require_owned_avatar(db, avatar_id, authorization)
|
_require_owned_avatar(db, avatar_id, authorization)
|
||||||
ext = os.path.splitext(file.filename or "")[1].lower()
|
ext, validation_error = _validate_document(file.filename or "", 1)
|
||||||
if ext not in ALLOWED_EXT:
|
if validation_error:
|
||||||
return fail(f"不支持的文件类型:{ext or '空'},仅支持 md/txt/pdf/doc/docx/xlsx", code=400)
|
return fail(validation_error, code=400)
|
||||||
avatar_dir = os.path.join(UPLOAD_DIR, avatar_id)
|
avatar_dir = os.path.join(UPLOAD_DIR, avatar_id)
|
||||||
os.makedirs(avatar_dir, exist_ok=True)
|
os.makedirs(avatar_dir, exist_ok=True)
|
||||||
stored = f"{uuid.uuid4().hex}{ext}"
|
stored = f"{uuid.uuid4().hex}{ext}"
|
||||||
path = os.path.join(avatar_dir, stored)
|
path = os.path.join(avatar_dir, stored)
|
||||||
content = await file.read()
|
file_size = 0
|
||||||
if len(content) > MAX_UPLOAD_BYTES:
|
try:
|
||||||
return fail("文件不能超过 10MB", code=400)
|
# Stream large files to disk so a 100MB upload does not occupy 100MB RAM.
|
||||||
with open(path, "wb") as f:
|
with open(path, "wb") as f:
|
||||||
f.write(content)
|
while chunk := await file.read(UPLOAD_CHUNK_BYTES):
|
||||||
doc = KnowledgeDoc(
|
file_size += len(chunk)
|
||||||
avatar_id=avatar_id,
|
if file_size > MAX_UPLOAD_BYTES:
|
||||||
filename=file.filename,
|
raise ValueError("文件不能超过 50MB")
|
||||||
file_type=ext.lstrip("."),
|
f.write(chunk)
|
||||||
file_size=len(content),
|
except ValueError as exc:
|
||||||
file_url=f"/api/files/{avatar_id}/{stored}",
|
if os.path.exists(path):
|
||||||
status="parsing",
|
os.remove(path)
|
||||||
|
return fail(str(exc), code=400)
|
||||||
|
if file_size == 0:
|
||||||
|
if os.path.exists(path):
|
||||||
|
os.remove(path)
|
||||||
|
return fail("文件内容不能为空", code=400)
|
||||||
|
|
||||||
|
doc = _create_knowledge_doc(db, avatar_id, file.filename or stored, ext, file_size, stored)
|
||||||
|
return ok(_doc_payload(doc))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/avatar/{avatar_id}/knowledge/uploads")
|
||||||
|
def create_multipart_upload(
|
||||||
|
avatar_id: str,
|
||||||
|
body: MultipartUploadIn,
|
||||||
|
authorization: str = Header(None),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
_require_owned_avatar(db, avatar_id, authorization)
|
||||||
|
ext, validation_error = _validate_document(body.filename, body.fileSize)
|
||||||
|
if validation_error:
|
||||||
|
return fail(validation_error, code=400)
|
||||||
|
expected_chunks = (body.fileSize + MULTIPART_CHUNK_BYTES - 1) // MULTIPART_CHUNK_BYTES
|
||||||
|
if body.totalChunks != expected_chunks:
|
||||||
|
return fail("文件分片数量不正确", code=400)
|
||||||
|
|
||||||
|
_purge_stale_multipart_uploads(avatar_id)
|
||||||
|
upload_id = uuid.uuid4().hex
|
||||||
|
upload_dir = _multipart_dir(avatar_id, upload_id)
|
||||||
|
os.makedirs(upload_dir, exist_ok=False)
|
||||||
|
metadata = {
|
||||||
|
"filename": body.filename,
|
||||||
|
"fileSize": body.fileSize,
|
||||||
|
"totalChunks": body.totalChunks,
|
||||||
|
"extension": ext,
|
||||||
|
}
|
||||||
|
with open(os.path.join(upload_dir, "metadata.json"), "w", encoding="utf-8") as stream:
|
||||||
|
json.dump(metadata, stream, ensure_ascii=False)
|
||||||
|
return ok({"uploadId": upload_id, "chunkSize": MULTIPART_CHUNK_BYTES})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/avatar/{avatar_id}/knowledge/uploads/{upload_id}/chunks/{chunk_index}")
|
||||||
|
async def upload_multipart_chunk(
|
||||||
|
avatar_id: str,
|
||||||
|
upload_id: str,
|
||||||
|
chunk_index: int,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
authorization: str = Header(None),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
_require_owned_avatar(db, avatar_id, authorization)
|
||||||
|
upload_dir, metadata = _read_multipart_metadata(avatar_id, upload_id)
|
||||||
|
total_chunks = int(metadata["totalChunks"])
|
||||||
|
if chunk_index < 0 or chunk_index >= total_chunks:
|
||||||
|
return fail("文件分片序号不正确", code=400)
|
||||||
|
|
||||||
|
expected_size = min(
|
||||||
|
MULTIPART_CHUNK_BYTES,
|
||||||
|
int(metadata["fileSize"]) - chunk_index * MULTIPART_CHUNK_BYTES,
|
||||||
)
|
)
|
||||||
db.add(doc)
|
part_path = os.path.join(upload_dir, f"{chunk_index}.part")
|
||||||
|
temporary_path = f"{part_path}.uploading"
|
||||||
|
received = 0
|
||||||
|
try:
|
||||||
|
with open(temporary_path, "wb") as stream:
|
||||||
|
while chunk := await file.read(UPLOAD_CHUNK_BYTES):
|
||||||
|
received += len(chunk)
|
||||||
|
if received > expected_size:
|
||||||
|
raise ValueError("文件分片大小不正确")
|
||||||
|
stream.write(chunk)
|
||||||
|
if received != expected_size:
|
||||||
|
raise ValueError("文件分片大小不正确")
|
||||||
|
os.replace(temporary_path, part_path)
|
||||||
|
except ValueError as exc:
|
||||||
|
if os.path.exists(temporary_path):
|
||||||
|
os.remove(temporary_path)
|
||||||
|
return fail(str(exc), code=400)
|
||||||
|
return ok({"chunkIndex": chunk_index, "uploadedBytes": received})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/avatar/{avatar_id}/knowledge/uploads/{upload_id}/complete")
|
||||||
|
def complete_multipart_upload(
|
||||||
|
avatar_id: str,
|
||||||
|
upload_id: str,
|
||||||
|
authorization: str = Header(None),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
_require_owned_avatar(db, avatar_id, authorization)
|
||||||
|
upload_dir, metadata = _read_multipart_metadata(avatar_id, upload_id)
|
||||||
|
total_chunks = int(metadata["totalChunks"])
|
||||||
|
part_paths = [os.path.join(upload_dir, f"{index}.part") for index in range(total_chunks)]
|
||||||
|
if not all(os.path.isfile(path) for path in part_paths):
|
||||||
|
return fail("文件分片尚未上传完整", code=400)
|
||||||
|
if sum(os.path.getsize(path) for path in part_paths) != int(metadata["fileSize"]):
|
||||||
|
return fail("文件分片总大小不正确", code=400)
|
||||||
|
|
||||||
|
avatar_dir = os.path.join(UPLOAD_DIR, avatar_id)
|
||||||
|
os.makedirs(avatar_dir, exist_ok=True)
|
||||||
|
stored = f"{uuid.uuid4().hex}{metadata['extension']}"
|
||||||
|
final_path = os.path.join(avatar_dir, stored)
|
||||||
|
temporary_path = f"{final_path}.assembling"
|
||||||
|
try:
|
||||||
|
with open(temporary_path, "wb") as output:
|
||||||
|
for part_path in part_paths:
|
||||||
|
with open(part_path, "rb") as source:
|
||||||
|
shutil.copyfileobj(source, output, UPLOAD_CHUNK_BYTES)
|
||||||
|
os.replace(temporary_path, final_path)
|
||||||
|
doc = _create_knowledge_doc(
|
||||||
|
db,
|
||||||
|
avatar_id,
|
||||||
|
metadata["filename"],
|
||||||
|
metadata["extension"],
|
||||||
|
int(metadata["fileSize"]),
|
||||||
|
stored,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
if os.path.exists(temporary_path):
|
||||||
|
os.remove(temporary_path)
|
||||||
|
raise
|
||||||
|
shutil.rmtree(upload_dir, ignore_errors=True)
|
||||||
|
return ok(_doc_payload(doc))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/avatar/{avatar_id}/knowledge/docs/{doc_id}/retry")
|
||||||
|
def retry_doc(avatar_id: str, doc_id: str, authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||||
|
_require_owned_avatar(db, avatar_id, authorization)
|
||||||
|
doc = db.query(KnowledgeDoc).filter(
|
||||||
|
KnowledgeDoc.id == doc_id, KnowledgeDoc.avatar_id == avatar_id
|
||||||
|
).first()
|
||||||
|
if not doc:
|
||||||
|
return fail("文档不存在", code=404)
|
||||||
|
if doc.vectorized and doc.status == "ready":
|
||||||
|
return ok(_doc_payload(doc))
|
||||||
|
stored_name = os.path.basename(doc.file_url or "")
|
||||||
|
if not stored_name or not os.path.isfile(os.path.join(UPLOAD_DIR, avatar_id, stored_name)):
|
||||||
|
return fail("原文件不可用,请重新上传", code=400)
|
||||||
|
db.query(KnowledgeChunk).filter(KnowledgeChunk.doc_id == doc.id).delete()
|
||||||
|
doc.status = "parsing"
|
||||||
|
doc.vectorized = False
|
||||||
|
doc.embedding_model = ""
|
||||||
|
doc.chunk_count = 0
|
||||||
|
doc.vectorized_at = None
|
||||||
|
doc.error_message = ""
|
||||||
|
doc.index_stage = "queued"
|
||||||
|
doc.index_progress = 0
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(doc)
|
db.refresh(doc)
|
||||||
|
knowledge_vectorizer.enqueue(doc.id)
|
||||||
# 向量化:抽取文本 -> 分块 -> 调第三方/本地嵌入 -> 存切片
|
|
||||||
try:
|
|
||||||
text = embeddings.extract_text(path, ext)
|
|
||||||
chunks = embeddings.chunk_text(text)
|
|
||||||
if chunks:
|
|
||||||
vectors = embeddings.embed(chunks)
|
|
||||||
for i, (c, v) in enumerate(zip(chunks, vectors)):
|
|
||||||
db.add(
|
|
||||||
KnowledgeChunk(
|
|
||||||
doc_id=doc.id,
|
|
||||||
avatar_id=avatar_id,
|
|
||||||
content=c,
|
|
||||||
vector=json.dumps(v),
|
|
||||||
chunk_index=i,
|
|
||||||
embedding_model=embeddings.MODEL,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
doc.vectorized = True
|
|
||||||
doc.embedding_model = embeddings.MODEL
|
|
||||||
doc.chunk_count = len(chunks)
|
|
||||||
doc.vectorized_at = datetime.now(timezone.utc)
|
|
||||||
doc.status = "ready"
|
|
||||||
db.commit()
|
|
||||||
db.refresh(doc)
|
|
||||||
except Exception as e:
|
|
||||||
print("vectorize failed:", e)
|
|
||||||
doc.status = "ready" # 上传成功但向量化失败,仍可展示
|
|
||||||
db.commit()
|
|
||||||
db.refresh(doc)
|
|
||||||
|
|
||||||
return ok(_doc_payload(doc))
|
return ok(_doc_payload(doc))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,13 +8,25 @@ from sqlalchemy.orm import Session
|
|||||||
from database import get_db
|
from database import get_db
|
||||||
from models import TakeoverCursor, TakeoverReplyTask, User
|
from models import TakeoverCursor, TakeoverReplyTask, User
|
||||||
from responses import fail, ok
|
from responses import fail, ok
|
||||||
from routers.authorizations import _require_authorization
|
from routers.authorizations import (
|
||||||
|
DEFAULT_TAKEOVER_DELAY_SECONDS,
|
||||||
|
MAX_TAKEOVER_DELAY_SECONDS,
|
||||||
|
MIN_TAKEOVER_DELAY_SECONDS,
|
||||||
|
_require_authorization,
|
||||||
|
_stored_takeover_delay,
|
||||||
|
)
|
||||||
from routers.avatars import _require_owned_avatar
|
from routers.avatars import _require_owned_avatar
|
||||||
|
|
||||||
router = APIRouter(tags=["分身接管"])
|
router = APIRouter(tags=["分身接管"])
|
||||||
BOXIM_STATUS_FRESH_SECONDS = 60
|
BOXIM_STATUS_FRESH_SECONDS = 60
|
||||||
|
|
||||||
|
|
||||||
|
def _delay_label(seconds: int) -> str:
|
||||||
|
if seconds % 60 == 0:
|
||||||
|
return f"{seconds // 60} 分钟"
|
||||||
|
return f"{seconds} 秒"
|
||||||
|
|
||||||
|
|
||||||
@router.get("/avatar/{avatar_id}/takeover/status")
|
@router.get("/avatar/{avatar_id}/takeover/status")
|
||||||
def get_takeover_status(
|
def get_takeover_status(
|
||||||
avatar_id: str,
|
avatar_id: str,
|
||||||
@@ -24,6 +36,7 @@ def get_takeover_status(
|
|||||||
avatar = _require_owned_avatar(db, avatar_id, authorization)
|
avatar = _require_owned_avatar(db, avatar_id, authorization)
|
||||||
permissions = (avatar.config or {}).get("authorizationPermissions", [])
|
permissions = (avatar.config or {}).get("authorizationPermissions", [])
|
||||||
enabled = isinstance(permissions, list) and "takeover" in permissions
|
enabled = isinstance(permissions, list) and "takeover" in permissions
|
||||||
|
reply_delay_seconds = _stored_takeover_delay(avatar)
|
||||||
user = db.query(User).filter(User.huihui_user_id == avatar.owner_id).first()
|
user = db.query(User).filter(User.huihui_user_id == avatar.owner_id).first()
|
||||||
cursor = db.query(TakeoverCursor).filter(TakeoverCursor.avatar_id == avatar.id).first()
|
cursor = db.query(TakeoverCursor).filter(TakeoverCursor.avatar_id == avatar.id).first()
|
||||||
pending_count = (
|
pending_count = (
|
||||||
@@ -49,7 +62,10 @@ def get_takeover_status(
|
|||||||
and cursor.last_polled_at
|
and cursor.last_polled_at
|
||||||
>= datetime.utcnow() - timedelta(seconds=BOXIM_STATUS_FRESH_SECONDS)
|
>= datetime.utcnow() - timedelta(seconds=BOXIM_STATUS_FRESH_SECONDS)
|
||||||
):
|
):
|
||||||
status, message = "ready", "BOXIM 已连接,收到私聊消息 3 秒后自动回复"
|
status, message = (
|
||||||
|
"ready",
|
||||||
|
f"BOXIM 已连接,收到私聊消息 {_delay_label(reply_delay_seconds)}后自动回复",
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
status, message = "connecting", "正在连接 BOXIM"
|
status, message = "connecting", "正在连接 BOXIM"
|
||||||
|
|
||||||
@@ -59,6 +75,7 @@ def get_takeover_status(
|
|||||||
"status": status,
|
"status": status,
|
||||||
"message": message,
|
"message": message,
|
||||||
"pendingCount": pending_count,
|
"pendingCount": pending_count,
|
||||||
|
"takeoverReplyDelaySeconds": reply_delay_seconds,
|
||||||
"lastPolledAt": cursor.last_polled_at.isoformat() if cursor and cursor.last_polled_at else None,
|
"lastPolledAt": cursor.last_polled_at.isoformat() if cursor and cursor.last_polled_at else None,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -91,7 +108,7 @@ def update_takeover_config(
|
|||||||
auth = _require_authorization(db, avatar_id, str(auth_id))
|
auth = _require_authorization(db, avatar_id, str(auth_id))
|
||||||
enabled = bool(auth.takeover_enabled)
|
enabled = bool(auth.takeover_enabled)
|
||||||
mode = auth.takeover_mode or "immediate"
|
mode = auth.takeover_mode or "immediate"
|
||||||
delay = auth.takeover_delay_seconds or 30
|
delay = auth.takeover_delay_seconds or DEFAULT_TAKEOVER_DELAY_SECONDS
|
||||||
|
|
||||||
if _has(payload, "takeoverEnabled", "takeover_enabled"):
|
if _has(payload, "takeoverEnabled", "takeover_enabled"):
|
||||||
raw_enabled = _read(payload, "takeoverEnabled", "takeover_enabled")
|
raw_enabled = _read(payload, "takeoverEnabled", "takeover_enabled")
|
||||||
@@ -106,8 +123,12 @@ def update_takeover_config(
|
|||||||
|
|
||||||
if _has(payload, "takeoverDelaySeconds", "takeover_delay_seconds"):
|
if _has(payload, "takeoverDelaySeconds", "takeover_delay_seconds"):
|
||||||
delay = _read(payload, "takeoverDelaySeconds", "takeover_delay_seconds")
|
delay = _read(payload, "takeoverDelaySeconds", "takeover_delay_seconds")
|
||||||
if isinstance(delay, bool) or not isinstance(delay, int) or not 5 <= delay <= 3600:
|
if (
|
||||||
return fail("延迟时间需在 5 到 3600 秒之间", 400)
|
isinstance(delay, bool)
|
||||||
|
or not isinstance(delay, int)
|
||||||
|
or not MIN_TAKEOVER_DELAY_SECONDS <= delay <= MAX_TAKEOVER_DELAY_SECONDS
|
||||||
|
):
|
||||||
|
return fail("延迟时间需在 3 秒到 24 小时之间", 400)
|
||||||
|
|
||||||
if enabled and auth.target_type != "user":
|
if enabled and auth.target_type != "user":
|
||||||
return fail("本期仅支持对会会用户开启单聊接管", 400)
|
return fail("本期仅支持对会会用户开启单聊接管", 400)
|
||||||
|
|||||||
@@ -1,37 +1,346 @@
|
|||||||
from fastapi import APIRouter, Depends, Body
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
|
||||||
|
from urllib.parse import parse_qs
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Body, Depends, Header, HTTPException, Request
|
||||||
|
from sqlalchemy import func
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from database import get_db
|
from database import get_db
|
||||||
from models import TokenAccount, TokenPlan
|
from models import TokenAccount, TokenPaymentOrder, TokenPlan, TokenUsage, User
|
||||||
from responses import ok, fail
|
from responses import fail, ok
|
||||||
|
from services.huihui_payment import HuihuiPaymentClient, HuihuiPaymentError
|
||||||
|
from services.token_billing import DEFAULT_TOKEN_GRANT, get_or_create_account
|
||||||
|
|
||||||
router = APIRouter(tags=["Token"])
|
router = APIRouter(tags=["Token"])
|
||||||
|
|
||||||
|
PAYMENT_METHODS = {"wechat": "WECHAT", "alipay": "ALIPAY"}
|
||||||
|
PAYMENT_SCENES = {"APP", "LITE", "JSAPI"}
|
||||||
|
SUCCESS_STATUSES = {"SUCCESS", "SUCCEEDED", "PAID", "COMPLETED", "TRADE_SUCCESS"}
|
||||||
|
FAILED_STATUSES = {"FAIL", "FAILED", "CLOSED", "CANCELLED", "CANCELED", "EXPIRED"}
|
||||||
|
|
||||||
|
|
||||||
|
def _require_user(authorization: str | None, db: Session) -> User:
|
||||||
|
if not authorization:
|
||||||
|
raise HTTPException(status_code=401, detail="未登录")
|
||||||
|
token = authorization.replace("Bearer ", "", 1).replace("bearer ", "", 1).strip()
|
||||||
|
user = db.query(User).filter(User.app_token == token).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=401, detail="会话无效或已过期")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def _payment_client() -> HuihuiPaymentClient:
|
||||||
|
return HuihuiPaymentClient({
|
||||||
|
"HUIHUI_PAYMENT_BASE_URL": os.getenv(
|
||||||
|
"HUIHUI_PAYMENT_BASE_URL", "https://open.99hui.com/api/payment-v3"
|
||||||
|
),
|
||||||
|
"HUIHUI_APP_ID": os.getenv("HUIHUI_APP_ID", ""),
|
||||||
|
"HUIHUI_ACCESS_ID": os.getenv("HUIHUI_ACCESS_ID", ""),
|
||||||
|
"HUIHUI_ACCESS_SECRET": os.getenv("HUIHUI_ACCESS_SECRET", ""),
|
||||||
|
"HUIHUI_PAYMENT_TIMEOUT_SECONDS": os.getenv("HUIHUI_PAYMENT_TIMEOUT_SECONDS", "30"),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _callback_url(order_no: str) -> str:
|
||||||
|
base = os.getenv(
|
||||||
|
"HUIHUI_PAYMENT_CALLBACK_BASE_URL", "https://digital.99hui.com"
|
||||||
|
).rstrip("/")
|
||||||
|
secret = os.getenv("HUIHUI_PAYMENT_CALLBACK_SECRET", "").strip()
|
||||||
|
if len(secret) < 16:
|
||||||
|
raise HuihuiPaymentError("会会支付回调密钥未配置")
|
||||||
|
signature = hmac.new(secret.encode(), order_no.encode(), hashlib.sha256).hexdigest()
|
||||||
|
return f"{base}/api/token/payment/callback/{order_no}/{signature}"
|
||||||
|
|
||||||
|
|
||||||
|
def _price_cents(price: float) -> int:
|
||||||
|
return int(
|
||||||
|
(Decimal(str(price)) * Decimal("100")).quantize(
|
||||||
|
Decimal("1"), rounding=ROUND_HALF_UP
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _payment_payload(order: TokenPaymentOrder, account: TokenAccount) -> dict:
|
||||||
|
return {**order.to_dict(), "balance": account.balance}
|
||||||
|
|
||||||
|
|
||||||
|
def _nested_payload(value):
|
||||||
|
if isinstance(value, str):
|
||||||
|
text = value.strip()
|
||||||
|
if text[:1] in ("{", "["):
|
||||||
|
try:
|
||||||
|
return _nested_payload(json.loads(text))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return value
|
||||||
|
return value
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [_nested_payload(item) for item in value]
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {key: _nested_payload(item) for key, item in value.items()}
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _find_value(payload, *names):
|
||||||
|
expected = {name.lower() for name in names}
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
for key, value in payload.items():
|
||||||
|
if key.lower() in expected and value not in (None, ""):
|
||||||
|
return value
|
||||||
|
for value in payload.values():
|
||||||
|
found = _find_value(value, *names)
|
||||||
|
if found not in (None, ""):
|
||||||
|
return found
|
||||||
|
elif isinstance(payload, list):
|
||||||
|
for value in payload:
|
||||||
|
found = _find_value(value, *names)
|
||||||
|
if found not in (None, ""):
|
||||||
|
return found
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _callback_amount_cents(payload) -> int | None:
|
||||||
|
value = _find_value(
|
||||||
|
payload,
|
||||||
|
"actualAmt",
|
||||||
|
"payAmt",
|
||||||
|
"masterOrderAmt",
|
||||||
|
"orderAmt",
|
||||||
|
"amount",
|
||||||
|
"totalAmount",
|
||||||
|
)
|
||||||
|
if value in (None, ""):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(
|
||||||
|
(Decimal(str(value)) * Decimal("100")).quantize(
|
||||||
|
Decimal("1"), rounding=ROUND_HALF_UP
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except (InvalidOperation, TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/token/balance")
|
@router.get("/token/balance")
|
||||||
def balance(db: Session = Depends(get_db)):
|
def balance(authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||||
acc = db.query(TokenAccount).first()
|
user = _require_user(authorization, db)
|
||||||
return ok({"balance": acc.balance if acc else 0})
|
acc = get_or_create_account(db, user.id)
|
||||||
|
return ok({
|
||||||
|
"balance": acc.balance,
|
||||||
|
"totalGranted": acc.total_granted,
|
||||||
|
"totalConsumed": acc.total_consumed,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/token/plans")
|
@router.get("/token/plans")
|
||||||
def plans(db: Session = Depends(get_db)):
|
def plans(authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||||
|
_require_user(authorization, db)
|
||||||
items = db.query(TokenPlan).order_by(TokenPlan.price.asc()).all()
|
items = db.query(TokenPlan).order_by(TokenPlan.price.asc()).all()
|
||||||
return ok([p.to_dict() for p in items])
|
return ok([p.to_dict() for p in items])
|
||||||
|
|
||||||
|
|
||||||
|
# 积分只会在会会支付回调确认成功后到账。
|
||||||
@router.post("/token/charge")
|
@router.post("/token/charge")
|
||||||
def charge(payload: dict = Body(...), db: Session = Depends(get_db)):
|
def charge(payload: dict = Body(...), authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||||
plan_id = payload.get("planId")
|
user = _require_user(authorization, db)
|
||||||
plan = db.query(TokenPlan).filter(TokenPlan.id == plan_id).first()
|
plan = db.query(TokenPlan).filter(TokenPlan.id == payload.get("planId")).first()
|
||||||
if not plan:
|
if not plan:
|
||||||
return fail("套餐不存在", 404)
|
return fail("套餐不存在", 404)
|
||||||
acc = db.query(TokenAccount).first()
|
|
||||||
if not acc:
|
payment_method = str(payload.get("paymentMethod") or "").lower()
|
||||||
acc = TokenAccount(balance=0)
|
pay_type = PAYMENT_METHODS.get(payment_method)
|
||||||
db.add(acc)
|
if not pay_type:
|
||||||
db.commit()
|
return fail("请选择正确的支付方式", 400)
|
||||||
db.refresh(acc)
|
pay_way = str(payload.get("payScene") or "APP").upper()
|
||||||
acc.balance += plan.amount
|
if pay_way not in PAYMENT_SCENES:
|
||||||
|
return fail("当前支付场景不受支持", 400)
|
||||||
|
|
||||||
|
cents = _price_cents(plan.price)
|
||||||
|
order = TokenPaymentOrder(
|
||||||
|
order_no=f"AV{datetime.utcnow().strftime('%Y%m%d%H%M%S')}{uuid.uuid4().hex[:12].upper()}",
|
||||||
|
user_id=user.id,
|
||||||
|
plan_id=plan.id,
|
||||||
|
payment_method=payment_method,
|
||||||
|
pay_type=pay_type,
|
||||||
|
pay_way=pay_way,
|
||||||
|
points_amount=plan.amount,
|
||||||
|
price_cents=cents,
|
||||||
|
status="pending",
|
||||||
|
)
|
||||||
|
db.add(order)
|
||||||
db.commit()
|
db.commit()
|
||||||
return ok({"balance": acc.balance, "charged": plan.amount})
|
|
||||||
|
try:
|
||||||
|
callback_url = _callback_url(order.order_no)
|
||||||
|
except HuihuiPaymentError as exc:
|
||||||
|
order.status = "failed"
|
||||||
|
order.failure_reason = str(exc)
|
||||||
|
db.commit()
|
||||||
|
return fail(str(exc), 503)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = _payment_client().create_payment(
|
||||||
|
huihui_token=user.huihui_token,
|
||||||
|
huihui_user_id=user.huihui_user_id,
|
||||||
|
real_name=user.nickname,
|
||||||
|
order_no=order.order_no,
|
||||||
|
amount=f"{cents / 100:.2f}",
|
||||||
|
points_amount=plan.amount,
|
||||||
|
pay_type=pay_type,
|
||||||
|
pay_way=pay_way,
|
||||||
|
callback_url=callback_url,
|
||||||
|
)
|
||||||
|
except HuihuiPaymentError as exc:
|
||||||
|
order.status = "failed"
|
||||||
|
order.failure_reason = str(exc)[:500]
|
||||||
|
db.commit()
|
||||||
|
return fail(str(exc), 502)
|
||||||
|
|
||||||
|
db.refresh(order)
|
||||||
|
if order.status != "paid":
|
||||||
|
order.provider_order_id = str(result.get("orderId") or "")
|
||||||
|
order.provider_order_no = str(result.get("orderNo") or "")
|
||||||
|
order.provider_status = str(result.get("status") or "pending")
|
||||||
|
message = result.get("payMessage") or ""
|
||||||
|
order.pay_message = (
|
||||||
|
json.dumps(message, ensure_ascii=False)
|
||||||
|
if isinstance(message, (dict, list))
|
||||||
|
else str(message)
|
||||||
|
)
|
||||||
|
if order.provider_status.upper() in FAILED_STATUSES:
|
||||||
|
order.status = "failed"
|
||||||
|
order.failure_reason = str(result.get("bankReturnMsg") or "支付下单失败")[:500]
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return ok(_payment_payload(order, get_or_create_account(db, user.id)))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/token/payment/{order_id}")
|
||||||
|
def payment_status(order_id: str, authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||||
|
user = _require_user(authorization, db)
|
||||||
|
order = db.query(TokenPaymentOrder).filter(
|
||||||
|
TokenPaymentOrder.id == order_id,
|
||||||
|
TokenPaymentOrder.user_id == user.id,
|
||||||
|
).first()
|
||||||
|
if not order:
|
||||||
|
return fail("支付订单不存在", 404)
|
||||||
|
return ok(_payment_payload(order, get_or_create_account(db, user.id)))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/token/payment/callback/{order_no}/{callback_signature}")
|
||||||
|
async def payment_callback(
|
||||||
|
order_no: str,
|
||||||
|
callback_signature: str,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
secret = os.getenv("HUIHUI_PAYMENT_CALLBACK_SECRET", "").strip()
|
||||||
|
expected = hmac.new(secret.encode(), order_no.encode(), hashlib.sha256).hexdigest()
|
||||||
|
if len(secret) < 16 or not hmac.compare_digest(callback_signature, expected):
|
||||||
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
|
|
||||||
|
content_type = request.headers.get("content-type", "").lower()
|
||||||
|
if "application/json" in content_type:
|
||||||
|
try:
|
||||||
|
payload = await request.json()
|
||||||
|
except ValueError:
|
||||||
|
return fail("支付回调格式不正确", 400)
|
||||||
|
else:
|
||||||
|
raw = (await request.body()).decode("utf-8", errors="replace")
|
||||||
|
payload = {key: values[-1] for key, values in parse_qs(raw).items()}
|
||||||
|
payload = _nested_payload(payload)
|
||||||
|
|
||||||
|
payload_order_no = str(_find_value(
|
||||||
|
payload,
|
||||||
|
"masterOrderNo",
|
||||||
|
"master_order_no",
|
||||||
|
"orderNo",
|
||||||
|
"order_no",
|
||||||
|
"bizOrderNo",
|
||||||
|
) or "").strip()
|
||||||
|
if payload_order_no and payload_order_no != order_no:
|
||||||
|
return fail("支付回调订单号不匹配", 422)
|
||||||
|
|
||||||
|
order = db.query(TokenPaymentOrder).filter(TokenPaymentOrder.order_no == order_no).first()
|
||||||
|
if not order:
|
||||||
|
return fail("支付订单不存在", 404)
|
||||||
|
if order.status == "paid":
|
||||||
|
return ok({"received": True, "duplicate": True})
|
||||||
|
|
||||||
|
provider_status = str(_find_value(
|
||||||
|
payload, "status", "payStatus", "tradeStatus", "paymentStatus"
|
||||||
|
) or "").upper()
|
||||||
|
order.provider_status = provider_status
|
||||||
|
if provider_status not in SUCCESS_STATUSES:
|
||||||
|
if provider_status in FAILED_STATUSES:
|
||||||
|
order.status = "failed"
|
||||||
|
order.failure_reason = str(
|
||||||
|
_find_value(payload, "message", "errorMsg", "failReason") or "支付失败"
|
||||||
|
)[:500]
|
||||||
|
db.commit()
|
||||||
|
return ok({"received": True, "paid": False})
|
||||||
|
|
||||||
|
paid_cents = _callback_amount_cents(payload)
|
||||||
|
if paid_cents is None or paid_cents != order.price_cents:
|
||||||
|
order.failure_reason = "支付回调金额不匹配"
|
||||||
|
db.commit()
|
||||||
|
return fail("支付金额不匹配", 422)
|
||||||
|
|
||||||
|
updated = db.query(TokenPaymentOrder).filter(
|
||||||
|
TokenPaymentOrder.id == order.id,
|
||||||
|
TokenPaymentOrder.status != "paid",
|
||||||
|
).update({
|
||||||
|
TokenPaymentOrder.status: "paid",
|
||||||
|
TokenPaymentOrder.provider_status: provider_status,
|
||||||
|
TokenPaymentOrder.paid_at: datetime.utcnow(),
|
||||||
|
TokenPaymentOrder.failure_reason: "",
|
||||||
|
}, synchronize_session=False)
|
||||||
|
if updated:
|
||||||
|
account = db.query(TokenAccount).filter(TokenAccount.user_id == order.user_id).first()
|
||||||
|
if account is None:
|
||||||
|
account = TokenAccount(
|
||||||
|
user_id=order.user_id,
|
||||||
|
balance=DEFAULT_TOKEN_GRANT,
|
||||||
|
total_granted=DEFAULT_TOKEN_GRANT,
|
||||||
|
total_consumed=0,
|
||||||
|
)
|
||||||
|
db.add(account)
|
||||||
|
db.flush()
|
||||||
|
account.balance = int(account.balance or 0) + order.points_amount
|
||||||
|
account.total_granted = int(account.total_granted or 0) + order.points_amount
|
||||||
|
db.commit()
|
||||||
|
return ok({"received": True, "paid": True})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/token/usage")
|
||||||
|
def usage(authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||||
|
user = _require_user(authorization, db)
|
||||||
|
rows = (
|
||||||
|
db.query(
|
||||||
|
TokenUsage.avatar_id,
|
||||||
|
TokenUsage.source,
|
||||||
|
func.sum(TokenUsage.prompt_tokens),
|
||||||
|
func.sum(TokenUsage.completion_tokens),
|
||||||
|
func.sum(TokenUsage.total_tokens),
|
||||||
|
func.count(TokenUsage.id),
|
||||||
|
)
|
||||||
|
.filter(TokenUsage.user_id == user.id, TokenUsage.status == "completed")
|
||||||
|
.group_by(TokenUsage.avatar_id, TokenUsage.source)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return ok([
|
||||||
|
{
|
||||||
|
"avatarId": avatar_id,
|
||||||
|
"source": source,
|
||||||
|
"promptTokens": int(prompt_tokens or 0),
|
||||||
|
"completionTokens": int(completion_tokens or 0),
|
||||||
|
"totalTokens": int(total_tokens or 0),
|
||||||
|
"requestCount": int(request_count or 0),
|
||||||
|
}
|
||||||
|
for avatar_id, source, prompt_tokens, completion_tokens, total_tokens, request_count in rows
|
||||||
|
])
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
"""Parse and safely download image payloads from BOXIM private messages."""
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import PurePosixPath
|
||||||
|
from urllib.parse import unquote, urljoin, urlsplit
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
MAX_REDIRECTS = 3
|
||||||
|
|
||||||
|
|
||||||
|
class BoxIMImageError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DownloadedBoxIMImage:
|
||||||
|
content: bytes
|
||||||
|
filename: str
|
||||||
|
mime_type: str
|
||||||
|
source_url: str
|
||||||
|
|
||||||
|
|
||||||
|
def parse_boxim_image_url(content: str, *, base_url: str = "") -> str:
|
||||||
|
try:
|
||||||
|
payload = json.loads(content or "")
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise BoxIMImageError("BOXIM 图片消息格式无效") from exc
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise BoxIMImageError("BOXIM 图片消息格式无效")
|
||||||
|
|
||||||
|
value = payload.get("originUrl") or payload.get("thumbUrl") or payload.get("url")
|
||||||
|
if not isinstance(value, str) or not value.strip():
|
||||||
|
raise BoxIMImageError("BOXIM 图片消息缺少图片地址")
|
||||||
|
value = value.strip()
|
||||||
|
if value.startswith("/"):
|
||||||
|
if not base_url:
|
||||||
|
raise BoxIMImageError("BOXIM 图片地址不完整")
|
||||||
|
value = urljoin(f"{base_url.rstrip('/')}/", value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _configured_hosts(name: str) -> set[str]:
|
||||||
|
return {
|
||||||
|
value.strip().lower().rstrip(".")
|
||||||
|
for value in os.getenv(name, "").split(",")
|
||||||
|
if value.strip()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _host_matches(host: str, configured: set[str]) -> bool:
|
||||||
|
return any(host == value or host.endswith(f".{value}") for value in configured)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolved_addresses(host: str, port: int) -> set[ipaddress.IPv4Address | ipaddress.IPv6Address]:
|
||||||
|
try:
|
||||||
|
return {
|
||||||
|
ipaddress.ip_address(item[4][0])
|
||||||
|
for item in socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
|
||||||
|
}
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
raise BoxIMImageError("BOXIM 图片地址无法解析") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _is_safe_remote_url(url: str) -> None:
|
||||||
|
parsed = urlsplit(url)
|
||||||
|
scheme = parsed.scheme.lower()
|
||||||
|
allow_http = os.getenv("BOXIM_IMAGE_ALLOW_HTTP", "").lower() in {"1", "true", "yes"}
|
||||||
|
if scheme not in ({"https", "http"} if allow_http else {"https"}):
|
||||||
|
raise BoxIMImageError("BOXIM 图片地址必须使用 HTTPS")
|
||||||
|
if parsed.username or parsed.password or not parsed.hostname:
|
||||||
|
raise BoxIMImageError("BOXIM 图片地址无效")
|
||||||
|
|
||||||
|
host = parsed.hostname.lower().rstrip(".")
|
||||||
|
allowed_hosts = _configured_hosts("BOXIM_IMAGE_ALLOWED_HOSTS")
|
||||||
|
if allowed_hosts and not _host_matches(host, allowed_hosts):
|
||||||
|
raise BoxIMImageError("BOXIM 图片地址不在允许的域名范围内")
|
||||||
|
|
||||||
|
private_hosts = _configured_hosts("BOXIM_IMAGE_PRIVATE_HOSTS")
|
||||||
|
try:
|
||||||
|
addresses = {ipaddress.ip_address(host)}
|
||||||
|
except ValueError:
|
||||||
|
addresses = _resolved_addresses(host, parsed.port or (443 if scheme == "https" else 80))
|
||||||
|
if not addresses:
|
||||||
|
raise BoxIMImageError("BOXIM 图片地址无法解析")
|
||||||
|
if _host_matches(host, private_hosts):
|
||||||
|
return
|
||||||
|
if any(not address.is_global for address in addresses):
|
||||||
|
raise BoxIMImageError("BOXIM 图片地址指向受限网络")
|
||||||
|
|
||||||
|
|
||||||
|
def _filename_from_url(url: str) -> str:
|
||||||
|
value = unquote(PurePosixPath(urlsplit(url).path).name).strip()
|
||||||
|
value = value.replace("\x00", "")
|
||||||
|
return (value or "boxim-image")[:255]
|
||||||
|
|
||||||
|
|
||||||
|
def download_boxim_image(
|
||||||
|
content: str,
|
||||||
|
*,
|
||||||
|
base_url: str = "",
|
||||||
|
transport: httpx.BaseTransport | None = None,
|
||||||
|
) -> DownloadedBoxIMImage:
|
||||||
|
"""Download one BOXIM image without redirects or oversized responses escaping checks."""
|
||||||
|
url = parse_boxim_image_url(content, base_url=base_url)
|
||||||
|
max_bytes = max(1024, int(os.getenv("CHAT_IMAGE_MAX_BYTES", str(8 * 1024 * 1024))))
|
||||||
|
timeout = max(1.0, min(float(os.getenv("BOXIM_IMAGE_TIMEOUT_SECONDS", "15")), 60.0))
|
||||||
|
|
||||||
|
with httpx.Client(
|
||||||
|
timeout=timeout,
|
||||||
|
follow_redirects=False,
|
||||||
|
trust_env=False,
|
||||||
|
transport=transport,
|
||||||
|
) as client:
|
||||||
|
for _ in range(MAX_REDIRECTS + 1):
|
||||||
|
_is_safe_remote_url(url)
|
||||||
|
try:
|
||||||
|
with client.stream("GET", url, headers={"Accept": "image/*"}) as response:
|
||||||
|
if response.status_code in {301, 302, 303, 307, 308}:
|
||||||
|
location = response.headers.get("location", "").strip()
|
||||||
|
if not location:
|
||||||
|
raise BoxIMImageError("BOXIM 图片跳转地址无效")
|
||||||
|
url = urljoin(url, location)
|
||||||
|
continue
|
||||||
|
response.raise_for_status()
|
||||||
|
raw_length = response.headers.get("content-length", "")
|
||||||
|
if raw_length.isdigit() and int(raw_length) > max_bytes:
|
||||||
|
raise BoxIMImageError("BOXIM 图片超过大小限制")
|
||||||
|
chunks = bytearray()
|
||||||
|
for chunk in response.iter_bytes():
|
||||||
|
chunks.extend(chunk)
|
||||||
|
if len(chunks) > max_bytes:
|
||||||
|
raise BoxIMImageError("BOXIM 图片超过大小限制")
|
||||||
|
if not chunks:
|
||||||
|
raise BoxIMImageError("BOXIM 图片内容为空")
|
||||||
|
return DownloadedBoxIMImage(
|
||||||
|
content=bytes(chunks),
|
||||||
|
filename=_filename_from_url(url),
|
||||||
|
mime_type=response.headers.get("content-type", "").split(";", 1)[0][:100],
|
||||||
|
source_url=url,
|
||||||
|
)
|
||||||
|
except BoxIMImageError:
|
||||||
|
raise
|
||||||
|
except (httpx.HTTPError, OSError) as exc:
|
||||||
|
raise BoxIMImageError("BOXIM 图片下载失败") from exc
|
||||||
|
raise BoxIMImageError("BOXIM 图片跳转次数过多")
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from models import ChatAttachment
|
||||||
|
|
||||||
|
|
||||||
|
def purge_expired_chat_attachments(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> int:
|
||||||
|
"""Remove expired derived image data; raw image bytes are never persisted."""
|
||||||
|
count = db.query(ChatAttachment).filter(
|
||||||
|
ChatAttachment.expires_at < (now or datetime.utcnow())
|
||||||
|
).delete(synchronize_session=False)
|
||||||
|
if count:
|
||||||
|
db.commit()
|
||||||
|
db.expire_all()
|
||||||
|
return count
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ChatModelConfig:
|
||||||
|
api_base_url: str
|
||||||
|
api_key: str
|
||||||
|
model: str
|
||||||
|
max_tokens: int
|
||||||
|
timeout_seconds: float
|
||||||
|
vision_model: str
|
||||||
|
ocr_model: str
|
||||||
|
vision_max_tokens: int
|
||||||
|
vision_timeout_seconds: float
|
||||||
|
source: str
|
||||||
|
|
||||||
|
|
||||||
|
_cache_lock = threading.Lock()
|
||||||
|
_cached_config: ChatModelConfig | None = None
|
||||||
|
_cache_expires_at = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _environment_config() -> ChatModelConfig:
|
||||||
|
return ChatModelConfig(
|
||||||
|
api_base_url=os.getenv(
|
||||||
|
"CHAT_API_URL", "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
||||||
|
).rstrip("/"),
|
||||||
|
api_key=os.getenv("CHAT_API_KEY", ""),
|
||||||
|
model=os.getenv("CHAT_MODEL", "qwen-plus"),
|
||||||
|
max_tokens=max(128, int(os.getenv("CHAT_MAX_OUTPUT_TOKENS", "1024"))),
|
||||||
|
timeout_seconds=max(5.0, float(os.getenv("CHAT_TIMEOUT_SECONDS", "30"))),
|
||||||
|
vision_model=os.getenv("VISION_MODEL", "qwen3.6-flash"),
|
||||||
|
ocr_model=os.getenv("VISION_OCR_MODEL", "qwen-vl-ocr"),
|
||||||
|
vision_max_tokens=max(256, int(os.getenv("VISION_MAX_OUTPUT_TOKENS", "2048"))),
|
||||||
|
vision_timeout_seconds=max(10.0, float(os.getenv("VISION_TIMEOUT_SECONDS", "90"))),
|
||||||
|
source="environment",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_runtime_config() -> ChatModelConfig | None:
|
||||||
|
url = os.getenv("CHAT_MODEL_CONFIG_URL", "").strip()
|
||||||
|
token = os.getenv("AVATAR_MODEL_CONFIG_TOKEN", "").strip()
|
||||||
|
if not url or not token:
|
||||||
|
return None
|
||||||
|
response = httpx.get(
|
||||||
|
url,
|
||||||
|
headers={"X-Avatar-Config-Token": token},
|
||||||
|
timeout=max(2.0, float(os.getenv("CHAT_MODEL_CONFIG_TIMEOUT_SECONDS", "5"))),
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
payload = response.json().get("data") or {}
|
||||||
|
api_base_url = str(payload.get("api_base_url") or "").rstrip("/")
|
||||||
|
api_key = str(payload.get("api_key") or "")
|
||||||
|
model = str(payload.get("model") or "")
|
||||||
|
if not api_base_url or not api_key or not model:
|
||||||
|
raise ValueError("数字分身专用模型配置不完整")
|
||||||
|
return ChatModelConfig(
|
||||||
|
api_base_url=api_base_url,
|
||||||
|
api_key=api_key,
|
||||||
|
model=model,
|
||||||
|
max_tokens=max(128, int(payload.get("max_tokens") or 1024)),
|
||||||
|
timeout_seconds=max(5.0, float(payload.get("timeout_seconds") or 30)),
|
||||||
|
vision_model=str(
|
||||||
|
payload.get("vision_model")
|
||||||
|
or os.getenv("VISION_MODEL", "qwen3.6-flash")
|
||||||
|
),
|
||||||
|
ocr_model=str(
|
||||||
|
payload.get("ocr_model")
|
||||||
|
or os.getenv("VISION_OCR_MODEL", "qwen-vl-ocr")
|
||||||
|
),
|
||||||
|
vision_max_tokens=max(
|
||||||
|
256, int(os.getenv("VISION_MAX_OUTPUT_TOKENS", "2048"))
|
||||||
|
),
|
||||||
|
vision_timeout_seconds=max(
|
||||||
|
10.0, float(os.getenv("VISION_TIMEOUT_SECONDS", "90"))
|
||||||
|
),
|
||||||
|
source="admin",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_chat_model_config(*, force_refresh: bool = False) -> ChatModelConfig:
|
||||||
|
global _cached_config, _cache_expires_at
|
||||||
|
|
||||||
|
now = time.monotonic()
|
||||||
|
if not force_refresh and _cached_config is not None and now < _cache_expires_at:
|
||||||
|
return _cached_config
|
||||||
|
|
||||||
|
with _cache_lock:
|
||||||
|
now = time.monotonic()
|
||||||
|
if not force_refresh and _cached_config is not None and now < _cache_expires_at:
|
||||||
|
return _cached_config
|
||||||
|
try:
|
||||||
|
config = _fetch_runtime_config() or _environment_config()
|
||||||
|
except (httpx.HTTPError, ValueError, TypeError) as exc:
|
||||||
|
logger.warning("读取数字分身专用模型配置失败,暂时使用环境变量配置: %s", exc)
|
||||||
|
config = _environment_config()
|
||||||
|
_cached_config = config
|
||||||
|
ttl = max(5, int(os.getenv("CHAT_MODEL_CONFIG_CACHE_SECONDS", "60")))
|
||||||
|
_cache_expires_at = now + ttl
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def clear_chat_model_config_cache() -> None:
|
||||||
|
global _cached_config, _cache_expires_at
|
||||||
|
with _cache_lock:
|
||||||
|
_cached_config = None
|
||||||
|
_cache_expires_at = 0.0
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
"""Signed client for Huihui's production payment-v3 service."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import random
|
||||||
|
import string
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
_CN_TZ = timezone(timedelta(hours=8))
|
||||||
|
|
||||||
|
|
||||||
|
class HuihuiPaymentError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class HuihuiPaymentClient:
|
||||||
|
def __init__(self, config: dict):
|
||||||
|
self.base_url = config.get(
|
||||||
|
"HUIHUI_PAYMENT_BASE_URL", "https://open.99hui.com/api/payment-v3"
|
||||||
|
).rstrip("/")
|
||||||
|
self.app_id = config.get("HUIHUI_APP_ID", "")
|
||||||
|
self.access_id = config.get("HUIHUI_ACCESS_ID", "")
|
||||||
|
self.access_secret = config.get("HUIHUI_ACCESS_SECRET", "")
|
||||||
|
self.timeout = float(config.get("HUIHUI_PAYMENT_TIMEOUT_SECONDS", 30))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def configured(self) -> bool:
|
||||||
|
return bool(self.base_url and self.app_id and self.access_id and self.access_secret)
|
||||||
|
|
||||||
|
def _signed_params(self, user_id: str) -> dict:
|
||||||
|
params = {
|
||||||
|
"appId": self.app_id,
|
||||||
|
"accessId": self.access_id,
|
||||||
|
"nonce": "".join(random.choices(string.ascii_lowercase + string.digits, k=12)),
|
||||||
|
"timestamp": datetime.now(_CN_TZ).strftime("%Y%m%d%H%M%S"),
|
||||||
|
"signType": "MD5",
|
||||||
|
"signVersion": "1.0",
|
||||||
|
"userId": user_id,
|
||||||
|
}
|
||||||
|
source = "&".join(
|
||||||
|
f"{key}={params[key]}"
|
||||||
|
for key in sorted(params)
|
||||||
|
if params[key] not in (None, "", [])
|
||||||
|
)
|
||||||
|
source += f"&accessSecret={self.access_secret}"
|
||||||
|
params["signature"] = hashlib.md5(source.encode("utf-8")).hexdigest().upper()
|
||||||
|
return params
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _json(response: httpx.Response) -> dict:
|
||||||
|
try:
|
||||||
|
payload = response.json()
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HuihuiPaymentError("会会支付返回了无效响应") from exc
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise HuihuiPaymentError("会会支付返回格式不正确")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
def create_payment(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
huihui_token: str,
|
||||||
|
huihui_user_id: str,
|
||||||
|
real_name: str,
|
||||||
|
order_no: str,
|
||||||
|
amount: str,
|
||||||
|
points_amount: int,
|
||||||
|
pay_type: str,
|
||||||
|
pay_way: str,
|
||||||
|
callback_url: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if not self.configured:
|
||||||
|
raise HuihuiPaymentError("会会支付服务未配置")
|
||||||
|
if not huihui_token or not huihui_user_id:
|
||||||
|
raise HuihuiPaymentError("当前会会登录凭证无法发起支付")
|
||||||
|
|
||||||
|
now = datetime.now(_CN_TZ)
|
||||||
|
body = {
|
||||||
|
"appId": self.app_id,
|
||||||
|
"callbackUrl": callback_url,
|
||||||
|
"chargeType": 4,
|
||||||
|
"currency": "cny",
|
||||||
|
"description": f"充值 {points_amount} 积分",
|
||||||
|
"expend": {},
|
||||||
|
"masterOrderAmt": amount,
|
||||||
|
"masterOrderNo": order_no,
|
||||||
|
"memberId": huihui_user_id,
|
||||||
|
"orderDesc": "数字分身积分充值",
|
||||||
|
"orderTime": now.isoformat(),
|
||||||
|
"orderTitle": "数字分身积分充值",
|
||||||
|
"payAmt": float(amount),
|
||||||
|
"payType": pay_type,
|
||||||
|
"payWay": pay_way,
|
||||||
|
"realName": real_name or "会会用户",
|
||||||
|
"timeExpire": (now + timedelta(hours=2)).strftime("%Y%m%d%H%M%S"),
|
||||||
|
}
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {huihui_token}",
|
||||||
|
"appId": self.app_id,
|
||||||
|
"windowAppId": self.app_id,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
response = httpx.post(
|
||||||
|
f"{self.base_url}/payment/pay",
|
||||||
|
headers=headers,
|
||||||
|
params=self._signed_params(huihui_user_id),
|
||||||
|
json=body,
|
||||||
|
timeout=self.timeout,
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
raise HuihuiPaymentError("会会支付连接失败,请稍后重试") from exc
|
||||||
|
|
||||||
|
payload = self._json(response)
|
||||||
|
code = payload.get("code")
|
||||||
|
if response.status_code >= 400 or code not in (0, 200, "0", "200"):
|
||||||
|
raise HuihuiPaymentError(payload.get("message") or "会会支付下单失败")
|
||||||
|
data = payload.get("data") or {}
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise HuihuiPaymentError("会会支付未返回订单信息")
|
||||||
|
return data
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
"""Durable, serial knowledge-document indexing for the avatar knowledge base."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import queue
|
||||||
|
import threading
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from database import SessionLocal
|
||||||
|
from models import Avatar, KnowledgeChunk, KnowledgeDoc
|
||||||
|
from services.pdf_ocr_service import extract_scanned_pdf_text
|
||||||
|
import embeddings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
BACKEND_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
UPLOAD_DIR = os.path.abspath(
|
||||||
|
os.getenv("UPLOAD_DIR", os.path.join(BACKEND_DIR, "routers", "uploads"))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeVectorizer:
|
||||||
|
"""Indexes one document at a time so slow providers cannot block uploads."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._queue: queue.Queue[str] = queue.Queue()
|
||||||
|
self._queued: set[str] = set()
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._thread: threading.Thread | None = None
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
if self._thread and self._thread.is_alive():
|
||||||
|
return
|
||||||
|
self._thread = threading.Thread(
|
||||||
|
target=self._run, name="knowledge-vectorizer", daemon=True
|
||||||
|
)
|
||||||
|
self._thread.start()
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
# A process restart must not abandon documents already accepted by upload.
|
||||||
|
for (doc_id,) in db.query(KnowledgeDoc.id).filter(KnowledgeDoc.status == "parsing"):
|
||||||
|
self.enqueue(doc_id)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
def enqueue(self, doc_id: str):
|
||||||
|
with self._lock:
|
||||||
|
if doc_id in self._queued:
|
||||||
|
return
|
||||||
|
self._queued.add(doc_id)
|
||||||
|
self._queue.put(doc_id)
|
||||||
|
|
||||||
|
def _run(self):
|
||||||
|
while True:
|
||||||
|
doc_id = self._queue.get()
|
||||||
|
try:
|
||||||
|
self.vectorize_document(doc_id)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Unexpected knowledge vectorizer failure for %s", doc_id)
|
||||||
|
finally:
|
||||||
|
with self._lock:
|
||||||
|
self._queued.discard(doc_id)
|
||||||
|
self._queue.task_done()
|
||||||
|
|
||||||
|
def vectorize_document(self, doc_id: str):
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
doc = db.get(KnowledgeDoc, doc_id)
|
||||||
|
if not doc or doc.status != "parsing":
|
||||||
|
return
|
||||||
|
|
||||||
|
stored_name = os.path.basename(doc.file_url or "")
|
||||||
|
path = os.path.join(UPLOAD_DIR, doc.avatar_id, stored_name)
|
||||||
|
if not stored_name or not os.path.isfile(path):
|
||||||
|
raise FileNotFoundError("原文件不可用,请重新上传")
|
||||||
|
|
||||||
|
self._set_progress(db, doc, "extracting", 8)
|
||||||
|
text = embeddings.extract_text(path, f".{doc.file_type}")
|
||||||
|
if doc.file_type == "pdf" and not text.strip():
|
||||||
|
avatar = db.get(Avatar, doc.avatar_id)
|
||||||
|
if not avatar:
|
||||||
|
raise ValueError("文档所属分身不存在")
|
||||||
|
|
||||||
|
def ocr_progress(done: int, total: int):
|
||||||
|
percent = 8 + int((done / max(1, total)) * 20)
|
||||||
|
self._set_progress(db, doc, "ocr", min(percent, 28))
|
||||||
|
|
||||||
|
self._set_progress(db, doc, "ocr", 8)
|
||||||
|
text = extract_scanned_pdf_text(
|
||||||
|
db,
|
||||||
|
avatar,
|
||||||
|
path,
|
||||||
|
on_progress=ocr_progress,
|
||||||
|
)
|
||||||
|
self._set_progress(db, doc, "chunking", 29)
|
||||||
|
chunks = embeddings.chunk_text(text)
|
||||||
|
if not chunks:
|
||||||
|
raise ValueError("文档没有可建立索引的文字内容")
|
||||||
|
self._set_progress(db, doc, "embedding", 30)
|
||||||
|
|
||||||
|
def embedding_progress(done: int, total: int):
|
||||||
|
percent = 30 + int((done / max(1, total)) * 65)
|
||||||
|
self._set_progress(db, doc, "embedding", min(percent, 95))
|
||||||
|
|
||||||
|
vectors = embeddings.embed(chunks, on_progress=embedding_progress)
|
||||||
|
if len(vectors) != len(chunks):
|
||||||
|
raise ValueError("向量服务返回数量与文档分段不一致")
|
||||||
|
|
||||||
|
# Commit the document and every chunk together. Chat only sees complete indexes.
|
||||||
|
db.query(KnowledgeChunk).filter(KnowledgeChunk.doc_id == doc.id).delete()
|
||||||
|
db.add_all(
|
||||||
|
[
|
||||||
|
KnowledgeChunk(
|
||||||
|
doc_id=doc.id,
|
||||||
|
avatar_id=doc.avatar_id,
|
||||||
|
content=chunk,
|
||||||
|
vector=json.dumps(vector),
|
||||||
|
chunk_index=index,
|
||||||
|
embedding_model=embeddings.MODEL,
|
||||||
|
)
|
||||||
|
for index, (chunk, vector) in enumerate(zip(chunks, vectors))
|
||||||
|
]
|
||||||
|
)
|
||||||
|
doc.vectorized = True
|
||||||
|
doc.embedding_model = embeddings.MODEL
|
||||||
|
doc.chunk_count = len(chunks)
|
||||||
|
doc.vectorized_at = datetime.now(timezone.utc)
|
||||||
|
doc.status = "ready"
|
||||||
|
doc.error_message = ""
|
||||||
|
doc.index_stage = "ready"
|
||||||
|
doc.index_progress = 100
|
||||||
|
db.commit()
|
||||||
|
logger.info("Knowledge document %s indexed with %s chunks", doc.id, len(chunks))
|
||||||
|
except Exception as exc:
|
||||||
|
db.rollback()
|
||||||
|
failed_doc = db.get(KnowledgeDoc, doc_id)
|
||||||
|
if failed_doc:
|
||||||
|
db.query(KnowledgeChunk).filter(KnowledgeChunk.doc_id == failed_doc.id).delete()
|
||||||
|
failed_doc.status = "failed"
|
||||||
|
failed_doc.vectorized = False
|
||||||
|
failed_doc.embedding_model = ""
|
||||||
|
failed_doc.chunk_count = 0
|
||||||
|
failed_doc.vectorized_at = None
|
||||||
|
failed_doc.error_message = str(exc)[:300] or "建立知识索引失败"
|
||||||
|
failed_doc.index_stage = "failed"
|
||||||
|
failed_doc.index_progress = 0
|
||||||
|
db.commit()
|
||||||
|
logger.exception("Knowledge vectorization failed for %s: %s", doc_id, exc)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _set_progress(db, doc, stage: str, progress: int):
|
||||||
|
doc.index_stage = stage
|
||||||
|
doc.index_progress = progress
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
knowledge_vectorizer = KnowledgeVectorizer()
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
"""OCR fallback for image-only PDF knowledge documents."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from models import Avatar
|
||||||
|
from services.chat_model_config import get_chat_model_config
|
||||||
|
from services.token_billing import (
|
||||||
|
estimate_fallback_usage,
|
||||||
|
release_reservation,
|
||||||
|
reserve_avatar_tokens,
|
||||||
|
settle_reservation,
|
||||||
|
)
|
||||||
|
from services.vision_service import call_vision_model, prepare_image
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
PDF_OCR_PROMPT = (
|
||||||
|
"请逐字转录这一页扫描文档中的全部可见文字和表格,只输出转录内容,不要解释,不要使用 Markdown 代码块。"
|
||||||
|
"保留标题、段落、项目编号、数值和自然换行;看不清的内容写作[无法辨认],不要猜测、纠错或补全。"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _positive_int(name: str, default: int, minimum: int, maximum: int) -> int:
|
||||||
|
try:
|
||||||
|
value = int(os.getenv(name, str(default)))
|
||||||
|
except ValueError:
|
||||||
|
value = default
|
||||||
|
return max(minimum, min(maximum, value))
|
||||||
|
|
||||||
|
|
||||||
|
def extract_scanned_pdf_text(
|
||||||
|
db: Session,
|
||||||
|
avatar: Avatar,
|
||||||
|
path: str,
|
||||||
|
*,
|
||||||
|
on_progress: Callable[[int, int], None] | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Render and OCR an image-only PDF while preserving page order."""
|
||||||
|
try:
|
||||||
|
import pymupdf
|
||||||
|
except ImportError as exc:
|
||||||
|
raise RuntimeError("扫描型 PDF 识别组件未安装") from exc
|
||||||
|
|
||||||
|
max_pages = _positive_int("KNOWLEDGE_PDF_OCR_MAX_PAGES", 80, 1, 300)
|
||||||
|
render_dpi = _positive_int("KNOWLEDGE_PDF_OCR_DPI", 144, 96, 200)
|
||||||
|
max_attempts = _positive_int("KNOWLEDGE_PDF_OCR_ATTEMPTS", 3, 1, 5)
|
||||||
|
model_config = get_chat_model_config()
|
||||||
|
model = model_config.ocr_model or model_config.vision_model
|
||||||
|
if not model_config.api_key or not model:
|
||||||
|
raise RuntimeError("扫描型 PDF 需要配置视觉 OCR 模型")
|
||||||
|
|
||||||
|
texts: list[str] = []
|
||||||
|
with pymupdf.open(path) as document:
|
||||||
|
total_pages = document.page_count
|
||||||
|
if total_pages <= 0:
|
||||||
|
raise ValueError("PDF 没有可识别页面")
|
||||||
|
if total_pages > max_pages:
|
||||||
|
raise ValueError(
|
||||||
|
f"扫描型 PDF 共 {total_pages} 页,超过单次 OCR 上限 {max_pages} 页,请拆分后上传"
|
||||||
|
)
|
||||||
|
|
||||||
|
scale = render_dpi / 72
|
||||||
|
for page_index in range(total_pages):
|
||||||
|
page = document.load_page(page_index)
|
||||||
|
pixmap = page.get_pixmap(
|
||||||
|
matrix=pymupdf.Matrix(scale, scale),
|
||||||
|
colorspace=pymupdf.csRGB,
|
||||||
|
alpha=False,
|
||||||
|
)
|
||||||
|
prepared = prepare_image(pixmap.tobytes("jpeg", jpg_quality=88))
|
||||||
|
estimate_messages = [{
|
||||||
|
"role": "user",
|
||||||
|
"content": f"[扫描 PDF 第 {page_index + 1}/{total_pages} 页]\n{PDF_OCR_PROMPT}",
|
||||||
|
}]
|
||||||
|
reservation = reserve_avatar_tokens(
|
||||||
|
db,
|
||||||
|
avatar,
|
||||||
|
"knowledge_pdf_ocr",
|
||||||
|
model,
|
||||||
|
estimate_messages,
|
||||||
|
model_config.vision_max_tokens,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
result = None
|
||||||
|
for attempt in range(1, max_attempts + 1):
|
||||||
|
try:
|
||||||
|
result = call_vision_model(
|
||||||
|
prepared,
|
||||||
|
model_config,
|
||||||
|
model=model,
|
||||||
|
prompt=PDF_OCR_PROMPT,
|
||||||
|
json_output=False,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
except RuntimeError:
|
||||||
|
if attempt == max_attempts:
|
||||||
|
raise
|
||||||
|
time.sleep(min(4, attempt))
|
||||||
|
content = str((result or {}).get("content") or "").strip()
|
||||||
|
if not content:
|
||||||
|
raise RuntimeError("扫描型 PDF 页面识别结果为空")
|
||||||
|
settle_reservation(
|
||||||
|
db,
|
||||||
|
reservation,
|
||||||
|
(result or {}).get("usage"),
|
||||||
|
fallback_total=estimate_fallback_usage(estimate_messages, content),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
release_reservation(db, reservation, str(exc))
|
||||||
|
raise RuntimeError(
|
||||||
|
f"扫描型 PDF 第 {page_index + 1}/{total_pages} 页识别失败:{exc}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
texts.append(f"[第 {page_index + 1} 页]\n{content}")
|
||||||
|
if on_progress:
|
||||||
|
on_progress(page_index + 1, total_pages)
|
||||||
|
logger.info(
|
||||||
|
"Scanned PDF OCR completed avatar=%s page=%s/%s",
|
||||||
|
avatar.id,
|
||||||
|
page_index + 1,
|
||||||
|
total_pages,
|
||||||
|
)
|
||||||
|
|
||||||
|
return "\n\n".join(texts).strip()
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
import time
|
import time
|
||||||
@@ -13,21 +14,50 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from models import (
|
from models import (
|
||||||
Avatar,
|
Avatar,
|
||||||
|
ChatAttachment,
|
||||||
TakeoverCursor,
|
TakeoverCursor,
|
||||||
TakeoverMessage,
|
TakeoverMessage,
|
||||||
TakeoverReplyTask,
|
TakeoverReplyTask,
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
from services.boxim_client import BoxIMClient, BoxIMError
|
from services.boxim_client import BoxIMClient, BoxIMError
|
||||||
|
from services.boxim_image_service import (
|
||||||
|
BoxIMImageError,
|
||||||
|
download_boxim_image,
|
||||||
|
parse_boxim_image_url,
|
||||||
|
)
|
||||||
|
from services.vision_service import ImageValidationError
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
ACTIVE_TASK_STATUSES = ("pending", "generating", "ready", "sending")
|
ACTIVE_TASK_STATUSES = ("pending", "generating", "ready", "sending")
|
||||||
GENERATABLE_TASK_STATUSES = ("pending",)
|
GENERATABLE_TASK_STATUSES = ("pending",)
|
||||||
MAX_PROMPT_LENGTH = 4000
|
MAX_PROMPT_LENGTH = 4000
|
||||||
MAX_STALE_SECONDS = 120
|
DEFAULT_MAX_MESSAGE_AGE_SECONDS = 600
|
||||||
|
MAX_SEND_OVERDUE_SECONDS = 120
|
||||||
STUCK_LOCK_SECONDS = 90
|
STUCK_LOCK_SECONDS = 90
|
||||||
TAKEOVER_PERMISSION = "takeover"
|
TAKEOVER_PERMISSION = "takeover"
|
||||||
|
TAKEOVER_DELAY_KEY = "takeoverReplyDelaySeconds"
|
||||||
|
DEFAULT_REPLY_DELAY_SECONDS = 180
|
||||||
|
MIN_REPLY_DELAY_SECONDS = 3
|
||||||
|
MAX_REPLY_DELAY_SECONDS = 86_400
|
||||||
|
HUMAN_PAUSE_SECONDS = 600
|
||||||
|
RATE_LIMIT_WINDOW_SECONDS = 300
|
||||||
|
RATE_LIMIT_MAX_REPLIES = 5
|
||||||
|
AVATAR_LOCAL_ID_PREFIX = "880"
|
||||||
|
BOXIM_TEXT_MESSAGE_TYPE = 0
|
||||||
|
BOXIM_IMAGE_MESSAGE_TYPE = 1
|
||||||
|
BOXIM_IMAGE_PROMPT = "请看看这张图片。"
|
||||||
|
BOXIM_IMAGE_UNAVAILABLE_REPLY = "这张图片我暂时没看清,麻烦重新发送一张清晰的原图。"
|
||||||
|
IMAGE_CONTEXT_LOOKBACK_SECONDS = 1800
|
||||||
|
IMAGE_REFERENCE_LOOKBACK_SECONDS = 172_800
|
||||||
|
MAX_RECENT_IMAGE_CONTEXTS = 3
|
||||||
|
|
||||||
|
_IMAGE_REFERENCE_PATTERN = re.compile(
|
||||||
|
r"(?:图片|图像|照片|截图|这张图|刚才.{0,8}图|病例|病历|检查单|检验单|化验单|报告|影像|"
|
||||||
|
r"\b(?:image|photo|picture|screenshot|scan|report)\b)",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _utcnow() -> datetime:
|
def _utcnow() -> datetime:
|
||||||
@@ -70,24 +100,69 @@ def _plain_text_reply(value: str) -> str:
|
|||||||
return "\n".join(line for line in lines if line).strip()
|
return "\n".join(line for line in lines if line).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _avatar_local_id(owner_id: str, trigger_message_id: str) -> str:
|
||||||
|
"""Build a deterministic BOXIM idempotency key that also marks avatar traffic."""
|
||||||
|
digest = hashlib.sha256(f"{owner_id}:{trigger_message_id}".encode("utf-8")).digest()
|
||||||
|
suffix = int.from_bytes(digest[:8], "big") % (10**15)
|
||||||
|
return f"{AVATAR_LOCAL_ID_PREFIX}{suffix:015d}"
|
||||||
|
|
||||||
|
|
||||||
|
def _is_avatar_local_id(value: str | None) -> bool:
|
||||||
|
local_id = str(value or "").strip()
|
||||||
|
return len(local_id) == 18 and local_id.isdigit() and local_id.startswith(AVATAR_LOCAL_ID_PREFIX)
|
||||||
|
|
||||||
|
|
||||||
|
def _configured_reply_delay(avatar: Avatar, fallback: int | None = None) -> int:
|
||||||
|
raw = (avatar.config or {}).get(
|
||||||
|
TAKEOVER_DELAY_KEY,
|
||||||
|
fallback if fallback is not None else DEFAULT_REPLY_DELAY_SECONDS,
|
||||||
|
)
|
||||||
|
if isinstance(raw, bool):
|
||||||
|
return DEFAULT_REPLY_DELAY_SECONDS
|
||||||
|
try:
|
||||||
|
delay = int(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return DEFAULT_REPLY_DELAY_SECONDS
|
||||||
|
if not MIN_REPLY_DELAY_SECONDS <= delay <= MAX_REPLY_DELAY_SECONDS:
|
||||||
|
return DEFAULT_REPLY_DELAY_SECONDS
|
||||||
|
return delay
|
||||||
|
|
||||||
|
|
||||||
|
def _event_prompt(event: TakeoverMessage) -> str:
|
||||||
|
if event.message_type == BOXIM_TEXT_MESSAGE_TYPE:
|
||||||
|
return event.content.strip()
|
||||||
|
if event.message_type == BOXIM_IMAGE_MESSAGE_TYPE:
|
||||||
|
return BOXIM_IMAGE_PROMPT
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _references_recent_image(value: str) -> bool:
|
||||||
|
return bool(_IMAGE_REFERENCE_PATTERN.search(value or ""))
|
||||||
|
|
||||||
|
|
||||||
class TakeoverService:
|
class TakeoverService:
|
||||||
"""Poll BOXIM, prepare replies during the grace period, then send at +3s."""
|
"""Poll BOXIM, honor the owner grace period, then generate and send one reply."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
session_factory: Callable[[], Session],
|
session_factory: Callable[[], Session],
|
||||||
boxim_client: BoxIMClient,
|
boxim_client: BoxIMClient,
|
||||||
*,
|
*,
|
||||||
reply_delay_seconds: int = 3,
|
reply_delay_seconds: int | None = None,
|
||||||
|
poll_concurrency: int = 8,
|
||||||
|
max_message_age_seconds: int = DEFAULT_MAX_MESSAGE_AGE_SECONDS,
|
||||||
now: Callable[[], datetime] = _utcnow,
|
now: Callable[[], datetime] = _utcnow,
|
||||||
):
|
):
|
||||||
self.session_factory = session_factory
|
self.session_factory = session_factory
|
||||||
self.boxim = boxim_client
|
self.boxim = boxim_client
|
||||||
self.reply_delay_seconds = reply_delay_seconds
|
self.reply_delay_seconds = reply_delay_seconds
|
||||||
|
self.poll_concurrency = max(1, min(int(poll_concurrency), 64))
|
||||||
|
self.max_message_age_seconds = max(60, int(max_message_age_seconds))
|
||||||
self.now = now
|
self.now = now
|
||||||
self._sessions: dict[str, dict] = {}
|
self._sessions: dict[str, dict] = {}
|
||||||
self._poll_lock = asyncio.Lock()
|
self._poll_lock = asyncio.Lock()
|
||||||
self._process_lock = asyncio.Lock()
|
self._process_lock = asyncio.Lock()
|
||||||
|
self._persist_lock = asyncio.Lock()
|
||||||
|
|
||||||
async def poll_and_process_messages(self):
|
async def poll_and_process_messages(self):
|
||||||
"""Run one complete cycle for callers that do not use the split scheduler."""
|
"""Run one complete cycle for callers that do not use the split scheduler."""
|
||||||
@@ -102,8 +177,48 @@ class TakeoverService:
|
|||||||
self._recover_stuck_tasks()
|
self._recover_stuck_tasks()
|
||||||
avatar_ids = self._enabled_avatar_ids()
|
avatar_ids = self._enabled_avatar_ids()
|
||||||
self._cancel_disabled_tasks(set(avatar_ids))
|
self._cancel_disabled_tasks(set(avatar_ids))
|
||||||
for avatar_id in avatar_ids:
|
self._ensure_takeover_cursors(avatar_ids)
|
||||||
await self._sync_avatar(avatar_id)
|
semaphore = asyncio.Semaphore(self.poll_concurrency)
|
||||||
|
|
||||||
|
async def sync(avatar_id: str):
|
||||||
|
async with semaphore:
|
||||||
|
return await self._sync_avatar(avatar_id)
|
||||||
|
|
||||||
|
results = await asyncio.gather(
|
||||||
|
*(sync(avatar_id) for avatar_id in avatar_ids),
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
for avatar_id, result in zip(avatar_ids, results):
|
||||||
|
if isinstance(result, Exception):
|
||||||
|
logger.warning("BOXIM poll crashed for avatar %s: %s", avatar_id, result)
|
||||||
|
|
||||||
|
def _ensure_takeover_cursors(self, avatar_ids: list[str]):
|
||||||
|
"""Create durable cursors before concurrent network polling starts."""
|
||||||
|
if not avatar_ids:
|
||||||
|
return
|
||||||
|
db = self.session_factory()
|
||||||
|
try:
|
||||||
|
existing = {
|
||||||
|
row[0]
|
||||||
|
for row in db.query(TakeoverCursor.avatar_id)
|
||||||
|
.filter(TakeoverCursor.avatar_id.in_(avatar_ids))
|
||||||
|
.all()
|
||||||
|
}
|
||||||
|
avatars = (
|
||||||
|
db.query(Avatar.id, Avatar.owner_id)
|
||||||
|
.filter(
|
||||||
|
Avatar.id.in_(
|
||||||
|
[avatar_id for avatar_id in avatar_ids if avatar_id not in existing]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for avatar_id, owner_id in avatars:
|
||||||
|
db.add(TakeoverCursor(avatar_id=avatar_id, owner_id=owner_id))
|
||||||
|
if avatars:
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
async def process_reply_tasks(self):
|
async def process_reply_tasks(self):
|
||||||
"""Generate and send replies independently from BOXIM's long poll."""
|
"""Generate and send replies independently from BOXIM's long poll."""
|
||||||
@@ -119,11 +234,17 @@ class TakeoverService:
|
|||||||
def _enabled_avatar_ids(self) -> list[str]:
|
def _enabled_avatar_ids(self) -> list[str]:
|
||||||
db = self.session_factory()
|
db = self.session_factory()
|
||||||
try:
|
try:
|
||||||
return [
|
avatars = (
|
||||||
avatar.id
|
db.query(Avatar)
|
||||||
for avatar in db.query(Avatar).filter(Avatar.status == "active").all()
|
.filter(Avatar.status == "active")
|
||||||
if _takeover_enabled(avatar)
|
.order_by(Avatar.updated_at.desc(), Avatar.created_at.desc())
|
||||||
]
|
.all()
|
||||||
|
)
|
||||||
|
selected = {}
|
||||||
|
for avatar in avatars:
|
||||||
|
if _takeover_enabled(avatar) and avatar.owner_id not in selected:
|
||||||
|
selected[avatar.owner_id] = avatar.id
|
||||||
|
return list(selected.values())
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
@@ -201,13 +322,20 @@ class TakeoverService:
|
|||||||
def _forget_boxim_session(self, user_id: str):
|
def _forget_boxim_session(self, user_id: str):
|
||||||
self._sessions.pop(user_id, None)
|
self._sessions.pop(user_id, None)
|
||||||
|
|
||||||
def _disable_after_connection_failure(
|
def _record_connection_failure(
|
||||||
self,
|
self,
|
||||||
db: Session,
|
db: Session,
|
||||||
avatar: Avatar,
|
avatar: Avatar,
|
||||||
cursor: TakeoverCursor,
|
cursor: TakeoverCursor,
|
||||||
message: str,
|
message: str,
|
||||||
|
*,
|
||||||
|
disable_takeover: bool,
|
||||||
):
|
):
|
||||||
|
cursor.last_error = message
|
||||||
|
cursor.last_polled_at = self.now()
|
||||||
|
if not disable_takeover:
|
||||||
|
return
|
||||||
|
|
||||||
permissions = (avatar.config or {}).get("authorizationPermissions", [])
|
permissions = (avatar.config or {}).get("authorizationPermissions", [])
|
||||||
avatar.config = {
|
avatar.config = {
|
||||||
**(avatar.config or {}),
|
**(avatar.config or {}),
|
||||||
@@ -217,8 +345,6 @@ class TakeoverService:
|
|||||||
if permission != TAKEOVER_PERMISSION
|
if permission != TAKEOVER_PERMISSION
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
cursor.last_error = message
|
|
||||||
cursor.last_polled_at = self.now()
|
|
||||||
tasks = (
|
tasks = (
|
||||||
db.query(TakeoverReplyTask)
|
db.query(TakeoverReplyTask)
|
||||||
.filter(
|
.filter(
|
||||||
@@ -243,13 +369,17 @@ class TakeoverService:
|
|||||||
if not cursor:
|
if not cursor:
|
||||||
cursor = TakeoverCursor(avatar_id=avatar.id, owner_id=avatar.owner_id)
|
cursor = TakeoverCursor(avatar_id=avatar.id, owner_id=avatar.owner_id)
|
||||||
db.add(cursor)
|
db.add(cursor)
|
||||||
db.flush()
|
db.commit()
|
||||||
|
else:
|
||||||
|
# Release SQLite's read transaction before the long network poll.
|
||||||
|
db.commit()
|
||||||
if not user or not user.huihui_token:
|
if not user or not user.huihui_token:
|
||||||
self._disable_after_connection_failure(
|
self._record_connection_failure(
|
||||||
db,
|
db,
|
||||||
avatar,
|
avatar,
|
||||||
cursor,
|
cursor,
|
||||||
"请重新登录会会生产账号后再开启主动接管",
|
"请重新登录会会生产账号后再开启主动接管",
|
||||||
|
disable_takeover=True,
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return False
|
return False
|
||||||
@@ -268,11 +398,24 @@ class TakeoverService:
|
|||||||
if isinstance(exc, BoxIMError) and exc.auth_error:
|
if isinstance(exc, BoxIMError) and exc.auth_error:
|
||||||
self._forget_boxim_session(user.id)
|
self._forget_boxim_session(user.id)
|
||||||
message = "BOXIM 授权已失效,请重新登录会会生产账号"
|
message = "BOXIM 授权已失效,请重新登录会会生产账号"
|
||||||
|
disable_takeover = True
|
||||||
else:
|
else:
|
||||||
message = f"BOXIM 暂时连接失败:{str(exc)[:160]}"
|
message = f"BOXIM 暂时连接失败:{str(exc)[:160]}"
|
||||||
self._disable_after_connection_failure(db, avatar, cursor, message)
|
disable_takeover = False
|
||||||
|
self._record_connection_failure(
|
||||||
|
db,
|
||||||
|
avatar,
|
||||||
|
cursor,
|
||||||
|
message,
|
||||||
|
disable_takeover=disable_takeover,
|
||||||
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
logger.warning("BOXIM sync failed for avatar %s: %s", avatar.id, exc)
|
logger.warning(
|
||||||
|
"BOXIM sync failed for avatar %s (will_retry=%s): %s",
|
||||||
|
avatar.id,
|
||||||
|
not disable_takeover,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
messages.sort(key=lambda item: (_numeric_id(item.get("id")), item.get("sendTime") or 0))
|
messages.sort(key=lambda item: (_numeric_id(item.get("id")), item.get("sendTime") or 0))
|
||||||
@@ -280,13 +423,6 @@ class TakeoverService:
|
|||||||
max_message_id = _numeric_id(cursor.last_message_id)
|
max_message_id = _numeric_id(cursor.last_message_id)
|
||||||
read_receipts: dict[str, int] = {}
|
read_receipts: dict[str, int] = {}
|
||||||
for message in messages:
|
for message in messages:
|
||||||
self._record_message(
|
|
||||||
db,
|
|
||||||
avatar,
|
|
||||||
cursor.boxim_owner_id,
|
|
||||||
message,
|
|
||||||
schedule_reply=not priming,
|
|
||||||
)
|
|
||||||
message_id = _numeric_id(message.get("id"))
|
message_id = _numeric_id(message.get("id"))
|
||||||
max_message_id = max(max_message_id, message_id)
|
max_message_id = max(max_message_id, message_id)
|
||||||
send_id = str(message.get("sendId") or "")
|
send_id = str(message.get("sendId") or "")
|
||||||
@@ -301,11 +437,22 @@ class TakeoverService:
|
|||||||
session["access_token"], peer_id, message_id
|
session["access_token"], peer_id, message_id
|
||||||
)
|
)
|
||||||
|
|
||||||
cursor.last_message_id = str(max_message_id)
|
# Keep SQLite write transactions short. The read-receipt request above
|
||||||
cursor.initialized = True
|
# can block on the network and must not hold the database write lock.
|
||||||
cursor.last_polled_at = self.now()
|
async with self._persist_lock:
|
||||||
cursor.last_error = ""
|
for message in messages:
|
||||||
db.commit()
|
self._record_message(
|
||||||
|
db,
|
||||||
|
avatar,
|
||||||
|
cursor.boxim_owner_id,
|
||||||
|
message,
|
||||||
|
schedule_reply=not priming,
|
||||||
|
)
|
||||||
|
cursor.last_message_id = str(max_message_id)
|
||||||
|
cursor.initialized = True
|
||||||
|
cursor.last_polled_at = self.now()
|
||||||
|
cursor.last_error = ""
|
||||||
|
db.commit()
|
||||||
return True
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
@@ -350,13 +497,21 @@ class TakeoverService:
|
|||||||
|
|
||||||
now = self.now()
|
now = self.now()
|
||||||
send_time = _boxim_time(message.get("sendTime"), now)
|
send_time = _boxim_time(message.get("sendTime"), now)
|
||||||
is_avatar = False
|
is_avatar = _is_avatar_local_id(local_id)
|
||||||
if direction == "outgoing" and local_id:
|
if not is_avatar and local_id:
|
||||||
is_avatar = bool(
|
is_avatar = bool(
|
||||||
db.query(TakeoverReplyTask)
|
db.query(TakeoverReplyTask)
|
||||||
.filter(
|
.filter(
|
||||||
TakeoverReplyTask.owner_id == avatar.owner_id,
|
|
||||||
TakeoverReplyTask.boxim_local_id == local_id,
|
TakeoverReplyTask.boxim_local_id == local_id,
|
||||||
|
TakeoverReplyTask.status.in_(("ready", "sending", "sent")),
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not is_avatar:
|
||||||
|
is_avatar = bool(
|
||||||
|
db.query(TakeoverReplyTask)
|
||||||
|
.filter(
|
||||||
|
TakeoverReplyTask.boxim_sent_message_id == message_id,
|
||||||
TakeoverReplyTask.status == "sent",
|
TakeoverReplyTask.status == "sent",
|
||||||
)
|
)
|
||||||
.first()
|
.first()
|
||||||
@@ -381,12 +536,96 @@ class TakeoverService:
|
|||||||
if not is_avatar:
|
if not is_avatar:
|
||||||
self._cancel_conversation(db, avatar.owner_id, peer_id, "owner_replied")
|
self._cancel_conversation(db, avatar.owner_id, peer_id, "owner_replied")
|
||||||
return
|
return
|
||||||
if not schedule_reply or event.message_type != 0 or not event.content.strip():
|
if not schedule_reply or event.message_type not in {
|
||||||
|
BOXIM_TEXT_MESSAGE_TYPE,
|
||||||
|
BOXIM_IMAGE_MESSAGE_TYPE,
|
||||||
|
}:
|
||||||
return
|
return
|
||||||
if (now - send_time).total_seconds() > MAX_STALE_SECONDS:
|
if event.message_type == BOXIM_TEXT_MESSAGE_TYPE and not event.content.strip():
|
||||||
|
return
|
||||||
|
if event.message_type == BOXIM_IMAGE_MESSAGE_TYPE:
|
||||||
|
try:
|
||||||
|
parse_boxim_image_url(
|
||||||
|
event.content,
|
||||||
|
base_url=getattr(self.boxim, "im_base_url", ""),
|
||||||
|
)
|
||||||
|
except BoxIMImageError as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Ignored invalid BOXIM image message %s for avatar %s: %s",
|
||||||
|
message_id,
|
||||||
|
avatar.id,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if (now - send_time).total_seconds() > self.max_message_age_seconds:
|
||||||
|
logger.info(
|
||||||
|
"Ignored stale BOXIM message %s for avatar %s (age=%ss)",
|
||||||
|
message_id,
|
||||||
|
avatar.id,
|
||||||
|
int((now - send_time).total_seconds()),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if is_avatar:
|
||||||
|
self._cancel_conversation(db, avatar.owner_id, peer_id, "peer_avatar_message")
|
||||||
|
logger.info(
|
||||||
|
"Skipped BOXIM reply for avatar %s message %s: peer_avatar_message",
|
||||||
|
avatar.id,
|
||||||
|
message_id,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if self._human_pause_active(db, avatar.owner_id, peer_id, now):
|
||||||
|
self._cancel_conversation(db, avatar.owner_id, peer_id, "owner_active")
|
||||||
|
logger.info(
|
||||||
|
"Skipped BOXIM reply for avatar %s message %s: owner_active",
|
||||||
|
avatar.id,
|
||||||
|
message_id,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if self._conversation_rate_limited(db, avatar.owner_id, peer_id, now):
|
||||||
|
self._cancel_conversation(db, avatar.owner_id, peer_id, "rate_limited")
|
||||||
|
logger.info(
|
||||||
|
"Skipped BOXIM reply for avatar %s message %s: rate_limited",
|
||||||
|
avatar.id,
|
||||||
|
message_id,
|
||||||
|
)
|
||||||
return
|
return
|
||||||
self._schedule_reply(db, avatar, event)
|
self._schedule_reply(db, avatar, event)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _human_pause_active(db: Session, owner_id: str, peer_id: str, now: datetime) -> bool:
|
||||||
|
threshold = now - timedelta(seconds=HUMAN_PAUSE_SECONDS)
|
||||||
|
return bool(
|
||||||
|
db.query(TakeoverMessage.id)
|
||||||
|
.filter(
|
||||||
|
TakeoverMessage.owner_id == owner_id,
|
||||||
|
TakeoverMessage.peer_id == peer_id,
|
||||||
|
TakeoverMessage.direction == "outgoing",
|
||||||
|
TakeoverMessage.is_avatar.is_(False),
|
||||||
|
TakeoverMessage.send_time >= threshold,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _conversation_rate_limited(
|
||||||
|
db: Session,
|
||||||
|
owner_id: str,
|
||||||
|
peer_id: str,
|
||||||
|
now: datetime,
|
||||||
|
) -> bool:
|
||||||
|
threshold = now - timedelta(seconds=RATE_LIMIT_WINDOW_SECONDS)
|
||||||
|
return (
|
||||||
|
db.query(TakeoverReplyTask.id)
|
||||||
|
.filter(
|
||||||
|
TakeoverReplyTask.owner_id == owner_id,
|
||||||
|
TakeoverReplyTask.peer_id == peer_id,
|
||||||
|
TakeoverReplyTask.status == "sent",
|
||||||
|
TakeoverReplyTask.sent_at >= threshold,
|
||||||
|
)
|
||||||
|
.count()
|
||||||
|
>= RATE_LIMIT_MAX_REPLIES
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _cancel_conversation(db: Session, owner_id: str, peer_id: str, reason: str):
|
def _cancel_conversation(db: Session, owner_id: str, peer_id: str, reason: str):
|
||||||
tasks = (
|
tasks = (
|
||||||
@@ -424,12 +663,25 @@ class TakeoverService:
|
|||||||
task.status = "cancelled"
|
task.status = "cancelled"
|
||||||
task.cancel_reason = "newer_incoming_message"
|
task.cancel_reason = "newer_incoming_message"
|
||||||
task.locked_at = None
|
task.locked_at = None
|
||||||
prompt_parts.append(event.content.strip())
|
if event.message_type == BOXIM_TEXT_MESSAGE_TYPE:
|
||||||
|
for image_event in self._recent_unhandled_images(
|
||||||
|
db,
|
||||||
|
avatar,
|
||||||
|
event,
|
||||||
|
source_ids,
|
||||||
|
):
|
||||||
|
prompt_parts.append(_event_prompt(image_event))
|
||||||
|
source_ids.append(image_event.boxim_message_id)
|
||||||
|
prompt_parts.append(_event_prompt(event))
|
||||||
source_ids.append(event.boxim_message_id)
|
source_ids.append(event.boxim_message_id)
|
||||||
prompt = "\n".join(part for part in prompt_parts if part).strip()[-MAX_PROMPT_LENGTH:]
|
prompt = "\n".join(part for part in prompt_parts if part).strip()[-MAX_PROMPT_LENGTH:]
|
||||||
due_at = event.send_time + timedelta(seconds=self.reply_delay_seconds)
|
due_at = max(
|
||||||
|
event.send_time
|
||||||
|
+ timedelta(seconds=_configured_reply_delay(avatar, self.reply_delay_seconds)),
|
||||||
|
self.now(),
|
||||||
|
)
|
||||||
task_id = secrets.token_hex(16)
|
task_id = secrets.token_hex(16)
|
||||||
local_id = int(time.time() * 1000) * 1000 + secrets.randbelow(1000)
|
local_id = _avatar_local_id(avatar.owner_id, event.boxim_message_id)
|
||||||
db.add(
|
db.add(
|
||||||
TakeoverReplyTask(
|
TakeoverReplyTask(
|
||||||
id=task_id,
|
id=task_id,
|
||||||
@@ -445,6 +697,68 @@ class TakeoverService:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _recent_unhandled_images(
|
||||||
|
db: Session,
|
||||||
|
avatar: Avatar,
|
||||||
|
event: TakeoverMessage,
|
||||||
|
current_source_ids: list[str],
|
||||||
|
) -> list[TakeoverMessage]:
|
||||||
|
"""Recover missed images, or reuse a referenced image from the last two days."""
|
||||||
|
references_image = _references_recent_image(event.content)
|
||||||
|
lookback_seconds = (
|
||||||
|
IMAGE_REFERENCE_LOOKBACK_SECONDS
|
||||||
|
if references_image
|
||||||
|
else IMAGE_CONTEXT_LOOKBACK_SECONDS
|
||||||
|
)
|
||||||
|
threshold = event.send_time - timedelta(seconds=lookback_seconds)
|
||||||
|
candidates = (
|
||||||
|
db.query(TakeoverMessage)
|
||||||
|
.filter(
|
||||||
|
TakeoverMessage.avatar_id == avatar.id,
|
||||||
|
TakeoverMessage.owner_id == avatar.owner_id,
|
||||||
|
TakeoverMessage.peer_id == event.peer_id,
|
||||||
|
TakeoverMessage.direction == "incoming",
|
||||||
|
TakeoverMessage.message_type == BOXIM_IMAGE_MESSAGE_TYPE,
|
||||||
|
TakeoverMessage.is_avatar.is_(False),
|
||||||
|
TakeoverMessage.send_time >= threshold,
|
||||||
|
TakeoverMessage.send_time <= event.send_time,
|
||||||
|
)
|
||||||
|
.order_by(TakeoverMessage.send_time.desc())
|
||||||
|
.limit(MAX_RECENT_IMAGE_CONTEXTS)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
if not candidates:
|
||||||
|
return []
|
||||||
|
|
||||||
|
current_ids = set(current_source_ids)
|
||||||
|
if references_image:
|
||||||
|
return [
|
||||||
|
image
|
||||||
|
for image in reversed(candidates)
|
||||||
|
if image.boxim_message_id not in current_ids
|
||||||
|
]
|
||||||
|
|
||||||
|
handled_ids = set(current_ids)
|
||||||
|
task_sources = (
|
||||||
|
db.query(TakeoverReplyTask.source_message_ids)
|
||||||
|
.filter(
|
||||||
|
TakeoverReplyTask.avatar_id == avatar.id,
|
||||||
|
TakeoverReplyTask.owner_id == avatar.owner_id,
|
||||||
|
TakeoverReplyTask.peer_id == event.peer_id,
|
||||||
|
TakeoverReplyTask.created_at >= threshold,
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for (source_message_ids,) in task_sources:
|
||||||
|
handled_ids.update(source_message_ids or [])
|
||||||
|
|
||||||
|
return [
|
||||||
|
image
|
||||||
|
for image in reversed(candidates)
|
||||||
|
if image.boxim_message_id not in handled_ids
|
||||||
|
]
|
||||||
|
|
||||||
async def _prepare_replies(self) -> int:
|
async def _prepare_replies(self) -> int:
|
||||||
db = self.session_factory()
|
db = self.session_factory()
|
||||||
try:
|
try:
|
||||||
@@ -455,6 +769,7 @@ class TakeoverService:
|
|||||||
.filter(
|
.filter(
|
||||||
TakeoverReplyTask.status.in_(GENERATABLE_TASK_STATUSES),
|
TakeoverReplyTask.status.in_(GENERATABLE_TASK_STATUSES),
|
||||||
TakeoverReplyTask.response_text == "",
|
TakeoverReplyTask.response_text == "",
|
||||||
|
TakeoverReplyTask.scheduled_at <= self.now(),
|
||||||
)
|
)
|
||||||
.order_by(TakeoverReplyTask.created_at.asc())
|
.order_by(TakeoverReplyTask.created_at.asc())
|
||||||
.limit(10)
|
.limit(10)
|
||||||
@@ -478,6 +793,50 @@ class TakeoverService:
|
|||||||
results = await asyncio.gather(*(generate(task_id) for task_id in task_ids))
|
results = await asyncio.gather(*(generate(task_id) for task_id in task_ids))
|
||||||
return sum(bool(result) for result in results)
|
return sum(bool(result) for result in results)
|
||||||
|
|
||||||
|
def _takeover_image_attachment(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
avatar: Avatar,
|
||||||
|
event: TakeoverMessage,
|
||||||
|
) -> ChatAttachment:
|
||||||
|
now = self.now()
|
||||||
|
if event.attachment_id:
|
||||||
|
cached = db.get(ChatAttachment, event.attachment_id)
|
||||||
|
if cached and cached.status == "ready" and cached.expires_at > now:
|
||||||
|
cached.used_at = now
|
||||||
|
db.commit()
|
||||||
|
return cached
|
||||||
|
|
||||||
|
downloaded = download_boxim_image(
|
||||||
|
event.content,
|
||||||
|
base_url=getattr(
|
||||||
|
self.boxim,
|
||||||
|
"im_base_url",
|
||||||
|
os.getenv("BOXIM_API_BASE_URL", "https://im.99hui.com/api"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
from routers.chat import _analyze_image_bytes
|
||||||
|
|
||||||
|
attachment = _analyze_image_bytes(
|
||||||
|
db,
|
||||||
|
avatar,
|
||||||
|
downloaded.content,
|
||||||
|
filename=downloaded.filename,
|
||||||
|
mime_type=downloaded.mime_type,
|
||||||
|
uploader_kind="boxim",
|
||||||
|
)
|
||||||
|
event.attachment_id = attachment.id
|
||||||
|
attachment.used_at = now
|
||||||
|
db.commit()
|
||||||
|
logger.info(
|
||||||
|
"BOXIM image analyzed message=%s attachment=%s avatar=%s category=%s",
|
||||||
|
event.boxim_message_id,
|
||||||
|
attachment.id,
|
||||||
|
avatar.id,
|
||||||
|
attachment.category,
|
||||||
|
)
|
||||||
|
return attachment
|
||||||
|
|
||||||
def _generate_reply(self, task_id: str) -> bool:
|
def _generate_reply(self, task_id: str) -> bool:
|
||||||
db = self.session_factory()
|
db = self.session_factory()
|
||||||
try:
|
try:
|
||||||
@@ -496,19 +855,59 @@ class TakeoverService:
|
|||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
excluded_ids = set(task.source_message_ids or [])
|
excluded_ids = set(task.source_message_ids or [])
|
||||||
|
source_events = {
|
||||||
|
event.boxim_message_id: event
|
||||||
|
for event in (
|
||||||
|
db.query(TakeoverMessage)
|
||||||
|
.filter(
|
||||||
|
TakeoverMessage.owner_id == task.owner_id,
|
||||||
|
TakeoverMessage.peer_id == task.peer_id,
|
||||||
|
TakeoverMessage.avatar_id == task.avatar_id,
|
||||||
|
TakeoverMessage.boxim_message_id.in_(excluded_ids),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
if excluded_ids
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
}
|
||||||
events = (
|
events = (
|
||||||
db.query(TakeoverMessage)
|
db.query(TakeoverMessage)
|
||||||
.filter(
|
.filter(
|
||||||
TakeoverMessage.owner_id == task.owner_id,
|
TakeoverMessage.owner_id == task.owner_id,
|
||||||
TakeoverMessage.peer_id == task.peer_id,
|
TakeoverMessage.peer_id == task.peer_id,
|
||||||
|
TakeoverMessage.avatar_id == task.avatar_id,
|
||||||
)
|
)
|
||||||
.order_by(TakeoverMessage.send_time.desc())
|
.order_by(TakeoverMessage.send_time.desc())
|
||||||
.limit(30)
|
.limit(30)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
image_attachments = []
|
||||||
|
image_failed = False
|
||||||
|
for message_id in (task.source_message_ids or [])[-3:]:
|
||||||
|
event = source_events.get(message_id)
|
||||||
|
if not event or event.message_type != BOXIM_IMAGE_MESSAGE_TYPE:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
image_attachments.append(
|
||||||
|
self._takeover_image_attachment(db, avatar, event)
|
||||||
|
)
|
||||||
|
except (BoxIMImageError, ImageValidationError) as exc:
|
||||||
|
image_failed = True
|
||||||
|
logger.warning(
|
||||||
|
"BOXIM image unavailable message=%s avatar=%s: %s",
|
||||||
|
event.boxim_message_id,
|
||||||
|
avatar.id,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
history = []
|
history = []
|
||||||
for event in reversed(events):
|
for event in reversed(events):
|
||||||
if event.boxim_message_id in excluded_ids or not event.content.strip():
|
if (
|
||||||
|
event.boxim_message_id in excluded_ids
|
||||||
|
or event.message_type != BOXIM_TEXT_MESSAGE_TYPE
|
||||||
|
or not event.content.strip()
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
if event.direction == "incoming" and event.is_avatar:
|
||||||
continue
|
continue
|
||||||
history.append(
|
history.append(
|
||||||
{
|
{
|
||||||
@@ -518,10 +917,21 @@ class TakeoverService:
|
|||||||
)
|
)
|
||||||
history = history[-10:]
|
history = history[-10:]
|
||||||
|
|
||||||
from routers.chat import _resolve_reply
|
from routers.chat import _attachment_contexts, _resolve_reply
|
||||||
|
|
||||||
result = _resolve_reply(db, avatar, task.prompt, history)
|
image_contexts = _attachment_contexts(image_attachments)
|
||||||
answer = _plain_text_reply(result.get("answer", ""))
|
if image_failed and not image_contexts:
|
||||||
|
answer = BOXIM_IMAGE_UNAVAILABLE_REPLY
|
||||||
|
else:
|
||||||
|
result = _resolve_reply(
|
||||||
|
db,
|
||||||
|
avatar,
|
||||||
|
task.prompt,
|
||||||
|
history,
|
||||||
|
usage_source="takeover",
|
||||||
|
image_contexts=image_contexts,
|
||||||
|
)
|
||||||
|
answer = _plain_text_reply(result.get("answer", ""))
|
||||||
db.refresh(task)
|
db.refresh(task)
|
||||||
if task.status != "generating":
|
if task.status != "generating":
|
||||||
return False
|
return False
|
||||||
@@ -582,11 +992,20 @@ class TakeoverService:
|
|||||||
task.cancel_reason = "takeover_disabled"
|
task.cancel_reason = "takeover_disabled"
|
||||||
db.commit()
|
db.commit()
|
||||||
return False
|
return False
|
||||||
if (self.now() - task.scheduled_at).total_seconds() > MAX_STALE_SECONDS:
|
if (self.now() - task.scheduled_at).total_seconds() > MAX_SEND_OVERDUE_SECONDS:
|
||||||
task.status = "cancelled"
|
task.status = "cancelled"
|
||||||
task.cancel_reason = "stale_reply"
|
task.cancel_reason = "stale_reply"
|
||||||
db.commit()
|
db.commit()
|
||||||
return False
|
return False
|
||||||
|
cursor = (
|
||||||
|
db.query(TakeoverCursor)
|
||||||
|
.filter(TakeoverCursor.avatar_id == task.avatar_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not cursor or not cursor.last_polled_at or cursor.last_polled_at < task.scheduled_at:
|
||||||
|
# Do not race the owner's final seconds of the grace period. A
|
||||||
|
# completed poll at/after the due time must confirm no human reply.
|
||||||
|
return False
|
||||||
user = db.query(User).filter(User.huihui_user_id == task.owner_id).first()
|
user = db.query(User).filter(User.huihui_user_id == task.owner_id).first()
|
||||||
if not user or not user.huihui_token:
|
if not user or not user.huihui_token:
|
||||||
raise BoxIMError("缺少会会登录凭证", auth_error=True)
|
raise BoxIMError("缺少会会登录凭证", auth_error=True)
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
"""User-scoped token accounting for every avatar model request."""
|
||||||
|
|
||||||
|
import math
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from models import Avatar, TokenAccount, TokenUsage, User
|
||||||
|
|
||||||
|
DEFAULT_TOKEN_GRANT = 1_000_000
|
||||||
|
|
||||||
|
|
||||||
|
class InsufficientTokensError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TokenReservation:
|
||||||
|
usage_id: str
|
||||||
|
user_id: str
|
||||||
|
reserved_tokens: int
|
||||||
|
|
||||||
|
|
||||||
|
def get_or_create_account(db: Session, user_id: str) -> TokenAccount:
|
||||||
|
account = db.query(TokenAccount).filter(TokenAccount.user_id == user_id).first()
|
||||||
|
if account:
|
||||||
|
return account
|
||||||
|
account = TokenAccount(
|
||||||
|
user_id=user_id,
|
||||||
|
balance=DEFAULT_TOKEN_GRANT,
|
||||||
|
total_granted=DEFAULT_TOKEN_GRANT,
|
||||||
|
total_consumed=0,
|
||||||
|
)
|
||||||
|
db.add(account)
|
||||||
|
try:
|
||||||
|
db.commit()
|
||||||
|
except IntegrityError:
|
||||||
|
# A concurrent first request may have created the same user account.
|
||||||
|
db.rollback()
|
||||||
|
account = db.query(TokenAccount).filter(TokenAccount.user_id == user_id).first()
|
||||||
|
if account is None:
|
||||||
|
raise
|
||||||
|
db.refresh(account)
|
||||||
|
return account
|
||||||
|
|
||||||
|
|
||||||
|
def avatar_owner_user(db: Session, avatar: Avatar) -> User | None:
|
||||||
|
owner_id = (avatar.owner_id or "").strip()
|
||||||
|
if not owner_id:
|
||||||
|
return None
|
||||||
|
return db.query(User).filter(User.huihui_user_id == owner_id).first()
|
||||||
|
|
||||||
|
|
||||||
|
def estimate_request_tokens(messages: list[dict], max_output_tokens: int) -> int:
|
||||||
|
# UTF-8 bytes / 2 deliberately overestimates mixed Chinese/English prompts;
|
||||||
|
# the unused reservation is returned after provider usage is received.
|
||||||
|
content_bytes = sum(
|
||||||
|
len(str(item.get("content", "")).encode("utf-8"))
|
||||||
|
for item in messages
|
||||||
|
)
|
||||||
|
prompt_reserve = max(1, math.ceil(content_bytes / 2) + len(messages) * 6)
|
||||||
|
return prompt_reserve + max(1, int(max_output_tokens))
|
||||||
|
|
||||||
|
|
||||||
|
def estimate_fallback_usage(messages: list[dict], output: str) -> int:
|
||||||
|
content_bytes = sum(
|
||||||
|
len(str(item.get("content", "")).encode("utf-8"))
|
||||||
|
for item in messages
|
||||||
|
) + len((output or "").encode("utf-8"))
|
||||||
|
return max(1, math.ceil(content_bytes / 3) + len(messages) * 4)
|
||||||
|
|
||||||
|
|
||||||
|
def reserve_avatar_tokens(
|
||||||
|
db: Session,
|
||||||
|
avatar: Avatar,
|
||||||
|
source: str,
|
||||||
|
model: str,
|
||||||
|
messages: list[dict],
|
||||||
|
max_output_tokens: int,
|
||||||
|
*,
|
||||||
|
minimum_reserve_tokens: int = 0,
|
||||||
|
) -> TokenReservation:
|
||||||
|
user = avatar_owner_user(db, avatar)
|
||||||
|
if not user:
|
||||||
|
raise InsufficientTokensError("分身尚未关联有效用户,暂时无法使用积分")
|
||||||
|
account = get_or_create_account(db, user.id)
|
||||||
|
reserved = max(
|
||||||
|
estimate_request_tokens(messages, max_output_tokens),
|
||||||
|
max(0, int(minimum_reserve_tokens or 0)),
|
||||||
|
)
|
||||||
|
updated = (
|
||||||
|
db.query(TokenAccount)
|
||||||
|
.filter(TokenAccount.id == account.id, TokenAccount.balance >= reserved)
|
||||||
|
.update(
|
||||||
|
{TokenAccount.balance: TokenAccount.balance - reserved},
|
||||||
|
synchronize_session=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if updated != 1:
|
||||||
|
db.rollback()
|
||||||
|
raise InsufficientTokensError("积分余额不足,请充值后继续")
|
||||||
|
db.refresh(account)
|
||||||
|
usage = TokenUsage(
|
||||||
|
user_id=user.id,
|
||||||
|
avatar_id=avatar.id,
|
||||||
|
source=source,
|
||||||
|
model=model,
|
||||||
|
status="reserved",
|
||||||
|
reserved_tokens=reserved,
|
||||||
|
)
|
||||||
|
db.add(usage)
|
||||||
|
db.flush()
|
||||||
|
usage.balance_after = account.balance
|
||||||
|
db.commit()
|
||||||
|
return TokenReservation(usage.id, user.id, reserved)
|
||||||
|
|
||||||
|
|
||||||
|
def settle_reservation(
|
||||||
|
db: Session,
|
||||||
|
reservation: TokenReservation,
|
||||||
|
usage: dict | None,
|
||||||
|
*,
|
||||||
|
fallback_total: int,
|
||||||
|
) -> dict:
|
||||||
|
record = db.query(TokenUsage).filter(TokenUsage.id == reservation.usage_id).first()
|
||||||
|
if not record or record.status != "reserved":
|
||||||
|
return {}
|
||||||
|
provider_usage = usage or {}
|
||||||
|
prompt_tokens = max(0, int(provider_usage.get("prompt_tokens") or 0))
|
||||||
|
completion_tokens = max(0, int(provider_usage.get("completion_tokens") or 0))
|
||||||
|
provider_total = max(
|
||||||
|
int(provider_usage.get("total_tokens") or 0),
|
||||||
|
prompt_tokens + completion_tokens,
|
||||||
|
)
|
||||||
|
total_tokens = max(1, provider_total or int(fallback_total or 0))
|
||||||
|
updated = (
|
||||||
|
db.query(TokenAccount)
|
||||||
|
.filter(TokenAccount.user_id == reservation.user_id)
|
||||||
|
.update(
|
||||||
|
{
|
||||||
|
TokenAccount.balance: TokenAccount.balance + reservation.reserved_tokens - total_tokens,
|
||||||
|
TokenAccount.total_consumed: TokenAccount.total_consumed + total_tokens,
|
||||||
|
},
|
||||||
|
synchronize_session=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if updated != 1:
|
||||||
|
raise RuntimeError("积分账户不存在")
|
||||||
|
db.expire_all()
|
||||||
|
account = db.query(TokenAccount).filter(TokenAccount.user_id == reservation.user_id).first()
|
||||||
|
record.prompt_tokens = prompt_tokens
|
||||||
|
record.completion_tokens = completion_tokens
|
||||||
|
record.total_tokens = total_tokens
|
||||||
|
record.balance_after = account.balance
|
||||||
|
record.status = "completed"
|
||||||
|
record.settled_at = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
return {
|
||||||
|
"promptTokens": prompt_tokens,
|
||||||
|
"completionTokens": completion_tokens,
|
||||||
|
"totalTokens": total_tokens,
|
||||||
|
"balance": account.balance,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def release_reservation(db: Session, reservation: TokenReservation, reason: str = "") -> None:
|
||||||
|
record = db.query(TokenUsage).filter(TokenUsage.id == reservation.usage_id).first()
|
||||||
|
if not record or record.status != "reserved":
|
||||||
|
return
|
||||||
|
updated = (
|
||||||
|
db.query(TokenAccount)
|
||||||
|
.filter(TokenAccount.user_id == reservation.user_id)
|
||||||
|
.update(
|
||||||
|
{TokenAccount.balance: TokenAccount.balance + reservation.reserved_tokens},
|
||||||
|
synchronize_session=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if updated:
|
||||||
|
db.expire_all()
|
||||||
|
account = db.query(TokenAccount).filter(TokenAccount.user_id == reservation.user_id).first()
|
||||||
|
record = db.query(TokenUsage).filter(TokenUsage.id == reservation.usage_id).first()
|
||||||
|
record.balance_after = account.balance
|
||||||
|
record.status = "failed"
|
||||||
|
record.failure_reason = (reason or "model_request_failed")[:255]
|
||||||
|
record.settled_at = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def release_stale_reservations(db: Session, older_than_minutes: int = 10) -> int:
|
||||||
|
cutoff = datetime.utcnow() - timedelta(minutes=older_than_minutes)
|
||||||
|
stale = db.query(TokenUsage).filter(
|
||||||
|
TokenUsage.status == "reserved",
|
||||||
|
TokenUsage.created_at < cutoff,
|
||||||
|
).all()
|
||||||
|
for record in stale:
|
||||||
|
release_reservation(
|
||||||
|
db,
|
||||||
|
TokenReservation(record.id, record.user_id, int(record.reserved_tokens or 0)),
|
||||||
|
"stale_reservation_recovered",
|
||||||
|
)
|
||||||
|
return len(stale)
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
"""Private image normalization and OpenAI-compatible vision model calls."""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from PIL import Image, ImageOps, UnidentifiedImageError
|
||||||
|
|
||||||
|
from services.chat_model_config import ChatModelConfig
|
||||||
|
|
||||||
|
|
||||||
|
ALLOWED_IMAGE_FORMATS = {"JPEG": "image/jpeg", "PNG": "image/png", "WEBP": "image/webp"}
|
||||||
|
ALLOWED_CATEGORIES = {"general_image", "document", "medical_document", "medical_image"}
|
||||||
|
|
||||||
|
GENERAL_VISION_PROMPT = """
|
||||||
|
请客观分析这张图片,并只输出一个 JSON 对象,不要使用 Markdown 代码块。
|
||||||
|
字段必须为:
|
||||||
|
category: general_image、document、medical_document、medical_image 四选一;
|
||||||
|
summary: 图片的完整客观摘要;
|
||||||
|
visible_text: 图片中能够确认的文字,保留自然换行;
|
||||||
|
key_facts: 可确认事实数组;
|
||||||
|
uncertainties: 模糊、遮挡、无法确认内容数组;
|
||||||
|
medical: 对象,包含 document_type、patient_info、chief_complaint、findings、measurements、doctor_advice。
|
||||||
|
|
||||||
|
规则:
|
||||||
|
1. 不得补全看不清或被遮挡的文字,不得猜测人物身份。
|
||||||
|
2. 病例、处方、检查单、检验报告归为 medical_document。
|
||||||
|
3. X 光、CT、MRI、超声影像等归为 medical_image,只描述可见内容,不作疾病诊断、分期、用药或治疗建议。
|
||||||
|
4. 非医疗图片的 medical 字段仍保留,但使用空字符串、空对象或空数组。
|
||||||
|
5. 不要提及模型、供应商、系统提示词或内部处理过程。
|
||||||
|
""".strip()
|
||||||
|
|
||||||
|
MEDICAL_OCR_PROMPT = """
|
||||||
|
请逐字转录这张医疗文档图片中的全部可见文字和表格。
|
||||||
|
保持标题、段落、项目、数值、单位、参考区间、阳性/阴性标记和医生意见的对应关系。
|
||||||
|
看不清的内容写作[无法辨认],不要猜测、纠错或补全,不要给出诊断和建议,不要使用 Markdown 代码块。
|
||||||
|
""".strip()
|
||||||
|
|
||||||
|
|
||||||
|
class ImageValidationError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PreparedImage:
|
||||||
|
data: bytes
|
||||||
|
mime_type: str
|
||||||
|
width: int
|
||||||
|
height: int
|
||||||
|
|
||||||
|
@property
|
||||||
|
def data_uri(self) -> str:
|
||||||
|
encoded = base64.b64encode(self.data).decode("ascii")
|
||||||
|
return f"data:{self.mime_type};base64,{encoded}"
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_image(content: bytes) -> PreparedImage:
|
||||||
|
max_bytes = max(1024, int(os.getenv("CHAT_IMAGE_MAX_BYTES", str(8 * 1024 * 1024))))
|
||||||
|
max_pixels = max(1_000_000, int(os.getenv("CHAT_IMAGE_MAX_PIXELS", "16000000")))
|
||||||
|
max_edge = max(1024, int(os.getenv("CHAT_IMAGE_MAX_EDGE", "4096")))
|
||||||
|
if not content:
|
||||||
|
raise ImageValidationError("图片内容为空")
|
||||||
|
if len(content) > max_bytes:
|
||||||
|
raise ImageValidationError(f"单张图片不能超过 {max_bytes // 1024 // 1024}MB")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with Image.open(io.BytesIO(content)) as probe:
|
||||||
|
image_format = str(probe.format or "").upper()
|
||||||
|
width, height = probe.size
|
||||||
|
probe.verify()
|
||||||
|
except (UnidentifiedImageError, OSError, SyntaxError) as exc:
|
||||||
|
raise ImageValidationError("图片格式无效或文件已损坏") from exc
|
||||||
|
|
||||||
|
if image_format not in ALLOWED_IMAGE_FORMATS:
|
||||||
|
raise ImageValidationError("仅支持 JPG、PNG、WebP 图片")
|
||||||
|
if width <= 0 or height <= 0 or width * height > max_pixels:
|
||||||
|
raise ImageValidationError("图片像素过大,请压缩后重新上传")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with Image.open(io.BytesIO(content)) as original:
|
||||||
|
image = ImageOps.exif_transpose(original)
|
||||||
|
image.load()
|
||||||
|
if max(image.size) > max_edge:
|
||||||
|
image.thumbnail((max_edge, max_edge), Image.Resampling.LANCZOS)
|
||||||
|
if image.mode in {"RGBA", "LA"}:
|
||||||
|
canvas = Image.new("RGB", image.size, "white")
|
||||||
|
alpha = image.getchannel("A")
|
||||||
|
canvas.paste(image.convert("RGB"), mask=alpha)
|
||||||
|
image = canvas
|
||||||
|
elif image.mode != "RGB":
|
||||||
|
image = image.convert("RGB")
|
||||||
|
output = io.BytesIO()
|
||||||
|
image.save(output, format="JPEG", quality=92, optimize=True)
|
||||||
|
normalized = output.getvalue()
|
||||||
|
normalized_width, normalized_height = image.size
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
raise ImageValidationError("图片解码失败,请重新选择图片") from exc
|
||||||
|
|
||||||
|
return PreparedImage(
|
||||||
|
data=normalized,
|
||||||
|
mime_type="image/jpeg",
|
||||||
|
width=normalized_width,
|
||||||
|
height=normalized_height,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def call_vision_model(
|
||||||
|
prepared: PreparedImage,
|
||||||
|
model_config: ChatModelConfig,
|
||||||
|
*,
|
||||||
|
model: str,
|
||||||
|
prompt: str,
|
||||||
|
json_output: bool,
|
||||||
|
) -> dict:
|
||||||
|
if not model_config.api_key:
|
||||||
|
raise RuntimeError("视觉模型服务未配置")
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"model": model,
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{"type": "image_url", "image_url": {"url": prepared.data_uri}},
|
||||||
|
{"type": "text", "text": prompt},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"temperature": 0,
|
||||||
|
"max_tokens": model_config.vision_max_tokens,
|
||||||
|
}
|
||||||
|
if json_output:
|
||||||
|
payload["response_format"] = {"type": "json_object"}
|
||||||
|
try:
|
||||||
|
response = httpx.post(
|
||||||
|
f"{model_config.api_base_url}/chat/completions",
|
||||||
|
headers={"Authorization": f"Bearer {model_config.api_key}"},
|
||||||
|
json=payload,
|
||||||
|
timeout=model_config.vision_timeout_seconds,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
content = data.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||||
|
except (httpx.HTTPError, ValueError, KeyError, IndexError) as exc:
|
||||||
|
raise RuntimeError("图片识别服务暂时不可用") from exc
|
||||||
|
if not isinstance(content, str) or not content.strip():
|
||||||
|
raise RuntimeError("图片识别服务没有返回有效结果")
|
||||||
|
return {"content": content.strip(), "usage": data.get("usage") or {}}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_vision_analysis(content: str) -> dict:
|
||||||
|
value = (content or "").strip()
|
||||||
|
fenced = re.match(r"^```(?:json)?\s*(.*?)\s*```$", value, re.DOTALL | re.IGNORECASE)
|
||||||
|
if fenced:
|
||||||
|
value = fenced.group(1).strip()
|
||||||
|
try:
|
||||||
|
payload = json.loads(value)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise RuntimeError("图片识别结果格式无效") from exc
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise RuntimeError("图片识别结果格式无效")
|
||||||
|
|
||||||
|
category = str(payload.get("category") or "general_image").strip().lower()
|
||||||
|
if category not in ALLOWED_CATEGORIES:
|
||||||
|
category = "general_image"
|
||||||
|
medical = payload.get("medical") if isinstance(payload.get("medical"), dict) else {}
|
||||||
|
return {
|
||||||
|
"category": category,
|
||||||
|
"summary": str(payload.get("summary") or "").strip(),
|
||||||
|
"visible_text": str(payload.get("visible_text") or "").strip(),
|
||||||
|
"key_facts": _string_list(payload.get("key_facts")),
|
||||||
|
"uncertainties": _string_list(payload.get("uncertainties")),
|
||||||
|
"medical": medical,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_attachment_warning(analysis: dict, *, ocr_failed: bool = False) -> str:
|
||||||
|
warnings = list(analysis.get("uncertainties") or [])
|
||||||
|
category = analysis.get("category")
|
||||||
|
if ocr_failed:
|
||||||
|
warnings.append("精确文字识别暂时不可用,请人工核对图片原文")
|
||||||
|
if category == "medical_document":
|
||||||
|
warnings.append("病例识别结果仅供辅助,不能替代医生诊断,请核对原始文档")
|
||||||
|
elif category == "medical_image":
|
||||||
|
warnings.append("医学影像仅作客观描述,不能替代影像报告和医生诊断")
|
||||||
|
return ";".join(dict.fromkeys(item for item in warnings if item))
|
||||||
|
|
||||||
|
|
||||||
|
def _string_list(value: Any) -> list[str]:
|
||||||
|
if not isinstance(value, list):
|
||||||
|
return []
|
||||||
|
return [str(item).strip() for item in value if str(item).strip()]
|
||||||
@@ -5,9 +5,13 @@ from database import init_db, SessionLocal
|
|||||||
from models import (
|
from models import (
|
||||||
Authorization,
|
Authorization,
|
||||||
Avatar,
|
Avatar,
|
||||||
|
ChatAttachment,
|
||||||
TakeoverCursor,
|
TakeoverCursor,
|
||||||
TakeoverMessage,
|
TakeoverMessage,
|
||||||
TakeoverReplyTask,
|
TakeoverReplyTask,
|
||||||
|
TokenAccount,
|
||||||
|
TokenPaymentOrder,
|
||||||
|
TokenUsage,
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -92,6 +96,9 @@ def authorization_context():
|
|||||||
finally:
|
finally:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
avatar_ids = [avatar.id, other_avatar.id]
|
avatar_ids = [avatar.id, other_avatar.id]
|
||||||
|
db.query(ChatAttachment).filter(
|
||||||
|
ChatAttachment.avatar_id.in_(avatar_ids)
|
||||||
|
).delete(synchronize_session=False)
|
||||||
db.query(TakeoverReplyTask).filter(
|
db.query(TakeoverReplyTask).filter(
|
||||||
TakeoverReplyTask.avatar_id.in_(avatar_ids)
|
TakeoverReplyTask.avatar_id.in_(avatar_ids)
|
||||||
).delete(synchronize_session=False)
|
).delete(synchronize_session=False)
|
||||||
@@ -107,6 +114,16 @@ def authorization_context():
|
|||||||
db.query(Avatar).filter(Avatar.id.in_(avatar_ids)).delete(
|
db.query(Avatar).filter(Avatar.id.in_(avatar_ids)).delete(
|
||||||
synchronize_session=False
|
synchronize_session=False
|
||||||
)
|
)
|
||||||
|
user_ids = [owner.id, other.id]
|
||||||
|
db.query(TokenPaymentOrder).filter(TokenPaymentOrder.user_id.in_(user_ids)).delete(
|
||||||
|
synchronize_session=False
|
||||||
|
)
|
||||||
|
db.query(TokenUsage).filter(TokenUsage.user_id.in_(user_ids)).delete(
|
||||||
|
synchronize_session=False
|
||||||
|
)
|
||||||
|
db.query(TokenAccount).filter(TokenAccount.user_id.in_(user_ids)).delete(
|
||||||
|
synchronize_session=False
|
||||||
|
)
|
||||||
db.query(User).filter(User.id.in_([owner.id, other.id])).delete(
|
db.query(User).filter(User.id.in_([owner.id, other.id])).delete(
|
||||||
synchronize_session=False
|
synchronize_session=False
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ def test_avatar_permission_settings_default_and_persist(authorization_context):
|
|||||||
assert initial["data"] == {
|
assert initial["data"] == {
|
||||||
"avatarId": context["avatar"].id,
|
"avatarId": context["avatar"].id,
|
||||||
"permissions": ["friend", "chat"],
|
"permissions": ["friend", "chat"],
|
||||||
|
"takeoverReplyDelaySeconds": 180,
|
||||||
}
|
}
|
||||||
|
|
||||||
updated = client.put(
|
updated = client.put(
|
||||||
@@ -115,6 +116,7 @@ def test_avatar_permission_settings_default_and_persist(authorization_context):
|
|||||||
|
|
||||||
reloaded = client.get(endpoint, headers=context["owner_headers"]).json()
|
reloaded = client.get(endpoint, headers=context["owner_headers"]).json()
|
||||||
assert reloaded["data"]["permissions"] == ["friend", "publish", "interact", "takeover"]
|
assert reloaded["data"]["permissions"] == ["friend", "publish", "interact", "takeover"]
|
||||||
|
assert reloaded["data"]["takeoverReplyDelaySeconds"] == 180
|
||||||
|
|
||||||
|
|
||||||
def test_avatar_permission_settings_allow_all_disabled(authorization_context):
|
def test_avatar_permission_settings_allow_all_disabled(authorization_context):
|
||||||
@@ -156,3 +158,57 @@ def test_avatar_permission_settings_validate_owner_and_permissions(authorization
|
|||||||
|
|
||||||
unauthenticated = client.get(endpoint)
|
unauthenticated = client.get(endpoint)
|
||||||
assert unauthenticated.status_code == 401
|
assert unauthenticated.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_takeover_delay_minimum_and_single_active_avatar_per_owner(authorization_context):
|
||||||
|
from database import SessionLocal
|
||||||
|
from models import Avatar
|
||||||
|
|
||||||
|
context = authorization_context
|
||||||
|
endpoint = f"/api/avatar/{context['avatar'].id}/permission-settings"
|
||||||
|
invalid = client.put(
|
||||||
|
endpoint,
|
||||||
|
headers=context["owner_headers"],
|
||||||
|
json={"permissions": ["chat"], "takeoverReplyDelaySeconds": 2},
|
||||||
|
).json()
|
||||||
|
assert invalid["code"] == 400
|
||||||
|
|
||||||
|
second_avatar_id = f"second-{context['suffix']}"
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
db.add(
|
||||||
|
Avatar(
|
||||||
|
id=second_avatar_id,
|
||||||
|
owner_id=context["owner"].huihui_user_id,
|
||||||
|
name="第二个分身",
|
||||||
|
status="active",
|
||||||
|
config={"authorizationPermissions": ["chat", "takeover"]},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
try:
|
||||||
|
updated = client.put(
|
||||||
|
endpoint,
|
||||||
|
headers=context["owner_headers"],
|
||||||
|
json={"permissions": ["chat", "takeover"], "takeoverReplyDelaySeconds": 3},
|
||||||
|
).json()
|
||||||
|
assert updated["code"] == 200
|
||||||
|
assert updated["data"]["takeoverReplyDelaySeconds"] == 3
|
||||||
|
assert updated["data"]["disabledAvatarIds"] == [second_avatar_id]
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
second = db.query(Avatar).filter(Avatar.id == second_avatar_id).one()
|
||||||
|
assert "takeover" not in second.config["authorizationPermissions"]
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
finally:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
db.query(Avatar).filter(Avatar.id == second_avatar_id).delete()
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""Ownership and configuration-isolation tests for digital avatars."""
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from database import SessionLocal
|
||||||
|
from main import app
|
||||||
|
from models import Avatar
|
||||||
|
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def test_avatar_detail_and_update_require_the_owner(authorization_context):
|
||||||
|
context = authorization_context
|
||||||
|
avatar_id = context["avatar"].id
|
||||||
|
|
||||||
|
assert client.get(f"/api/avatar/{avatar_id}").status_code == 401
|
||||||
|
assert client.get(
|
||||||
|
f"/api/avatar/{avatar_id}", headers=context["other_headers"]
|
||||||
|
).status_code == 403
|
||||||
|
|
||||||
|
updated = client.put(
|
||||||
|
f"/api/avatar/{avatar_id}",
|
||||||
|
headers=context["owner_headers"],
|
||||||
|
json={
|
||||||
|
"description": "独立描述",
|
||||||
|
"config": {"replyStyle": "concise"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert updated.status_code == 200
|
||||||
|
assert updated.json()["data"]["description"] == "独立描述"
|
||||||
|
|
||||||
|
forbidden = client.put(
|
||||||
|
f"/api/avatar/{avatar_id}",
|
||||||
|
headers=context["other_headers"],
|
||||||
|
json={"description": "越权修改"},
|
||||||
|
)
|
||||||
|
assert forbidden.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_avatar_config_updates_do_not_erase_takeover_or_knowledge_scope(authorization_context):
|
||||||
|
context = authorization_context
|
||||||
|
avatar_id = context["avatar"].id
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
avatar = db.query(Avatar).filter(Avatar.id == avatar_id).one()
|
||||||
|
avatar.config = {
|
||||||
|
"authorizationPermissions": ["chat", "takeover"],
|
||||||
|
"takeoverReplyDelaySeconds": 180,
|
||||||
|
}
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
response = client.put(
|
||||||
|
f"/api/avatar/{avatar_id}",
|
||||||
|
headers=context["owner_headers"],
|
||||||
|
json={"config": {"replyStyle": "warm", "creativity": 25}},
|
||||||
|
).json()
|
||||||
|
config = response["data"]["config"]
|
||||||
|
assert config["replyStyle"] == "warm"
|
||||||
|
assert config["creativity"] == 25
|
||||||
|
assert config["authorizationPermissions"] == ["chat", "takeover"]
|
||||||
|
assert config["takeoverReplyDelaySeconds"] == 180
|
||||||
|
|
||||||
|
|
||||||
|
def test_avatar_create_and_delete_require_login_and_ownership(authorization_context):
|
||||||
|
context = authorization_context
|
||||||
|
assert client.post("/api/avatar", json={"name": "匿名分身"}).status_code == 401
|
||||||
|
|
||||||
|
created = client.post(
|
||||||
|
"/api/avatar",
|
||||||
|
headers=context["owner_headers"],
|
||||||
|
json={"name": "待删除分身"},
|
||||||
|
)
|
||||||
|
assert created.status_code == 200
|
||||||
|
avatar_id = created.json()["data"]["id"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
assert client.delete(
|
||||||
|
f"/api/avatar/{avatar_id}", headers=context["other_headers"]
|
||||||
|
).status_code == 403
|
||||||
|
deleted = client.delete(
|
||||||
|
f"/api/avatar/{avatar_id}", headers=context["owner_headers"]
|
||||||
|
).json()
|
||||||
|
assert deleted["code"] == 200
|
||||||
|
finally:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
db.query(Avatar).filter(Avatar.id == avatar_id).delete()
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import ipaddress
|
||||||
|
import json
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from services.boxim_image_service import (
|
||||||
|
BoxIMImageError,
|
||||||
|
download_boxim_image,
|
||||||
|
parse_boxim_image_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_boxim_image_prefers_origin_and_supports_relative_url():
|
||||||
|
content = json.dumps({"originUrl": "/files/original.png", "thumbUrl": "/thumb.png"})
|
||||||
|
assert parse_boxim_image_url(content, base_url="https://im.example/api") == (
|
||||||
|
"https://im.example/files/original.png"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_download_boxim_image_streams_public_https(monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"services.boxim_image_service._resolved_addresses",
|
||||||
|
lambda _host, _port: {ipaddress.ip_address("8.8.8.8")},
|
||||||
|
)
|
||||||
|
transport = httpx.MockTransport(
|
||||||
|
lambda request: httpx.Response(
|
||||||
|
200,
|
||||||
|
headers={"content-type": "image/png"},
|
||||||
|
content=b"png-bytes",
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
image = download_boxim_image(
|
||||||
|
json.dumps({"originUrl": "https://cdn.example/case%20photo.png"}),
|
||||||
|
transport=transport,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert image.content == b"png-bytes"
|
||||||
|
assert image.filename == "case photo.png"
|
||||||
|
assert image.mime_type == "image/png"
|
||||||
|
|
||||||
|
|
||||||
|
def test_download_boxim_image_rejects_private_network_url():
|
||||||
|
with pytest.raises(BoxIMImageError, match="受限网络"):
|
||||||
|
download_boxim_image(
|
||||||
|
json.dumps({"originUrl": "https://127.0.0.1/private.png"}),
|
||||||
|
transport=httpx.MockTransport(lambda request: httpx.Response(200, request=request)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_download_boxim_image_stops_oversized_stream(monkeypatch):
|
||||||
|
monkeypatch.setenv("CHAT_IMAGE_MAX_BYTES", "1024")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"services.boxim_image_service._resolved_addresses",
|
||||||
|
lambda _host, _port: {ipaddress.ip_address("8.8.8.8")},
|
||||||
|
)
|
||||||
|
transport = httpx.MockTransport(
|
||||||
|
lambda request: httpx.Response(
|
||||||
|
200,
|
||||||
|
headers={"content-length": "2048"},
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(BoxIMImageError, match="超过大小限制"):
|
||||||
|
download_boxim_image(
|
||||||
|
json.dumps({"originUrl": "https://cdn.example/large.png"}),
|
||||||
|
transport=transport,
|
||||||
|
)
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
import json
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from database import SessionLocal
|
||||||
|
from main import app
|
||||||
|
from models import ChatAttachment
|
||||||
|
from routers.chat import (
|
||||||
|
ChatIn,
|
||||||
|
_answer_denies_available_image,
|
||||||
|
_attachment_contexts,
|
||||||
|
_load_chat_attachments,
|
||||||
|
_resolve_reply,
|
||||||
|
)
|
||||||
|
from services.chat_attachment_service import purge_expired_chat_attachments
|
||||||
|
from services.token_billing import InsufficientTokensError
|
||||||
|
from services.vision_service import PreparedImage
|
||||||
|
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
GENERAL_RESULT = {
|
||||||
|
"content": json.dumps({
|
||||||
|
"category": "general_image",
|
||||||
|
"summary": "一张包含产品路线图的截图",
|
||||||
|
"visible_text": "产品路线图",
|
||||||
|
"key_facts": ["包含三个阶段"],
|
||||||
|
"uncertainties": [],
|
||||||
|
"medical": {},
|
||||||
|
}, ensure_ascii=False),
|
||||||
|
"usage": {"total_tokens": 120},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_owner_can_upload_and_cache_image_analysis(authorization_context):
|
||||||
|
context = authorization_context
|
||||||
|
prepared = PreparedImage(b"jpeg", "image/jpeg", 100, 80)
|
||||||
|
with (
|
||||||
|
patch("routers.chat.prepare_image", return_value=prepared),
|
||||||
|
patch("routers.chat._run_billed_vision_call", return_value=GENERAL_RESULT),
|
||||||
|
):
|
||||||
|
response = client.post(
|
||||||
|
f"/api/avatar/{context['avatar'].id}/chat/images",
|
||||||
|
headers=context["owner_headers"],
|
||||||
|
files={"file": ("roadmap.png", b"image-bytes", "image/png")},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()["data"]
|
||||||
|
assert payload["status"] == "ready"
|
||||||
|
assert payload["category"] == "general_image"
|
||||||
|
assert payload["summary"] == "一张包含产品路线图的截图"
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
stored = db.query(ChatAttachment).filter(ChatAttachment.id == payload["id"]).one()
|
||||||
|
assert stored.avatar_id == context["avatar"].id
|
||||||
|
assert stored.extracted_text == "产品路线图"
|
||||||
|
assert stored.structured_data["key_facts"] == ["包含三个阶段"]
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_owner_cannot_upload_chat_image(authorization_context):
|
||||||
|
context = authorization_context
|
||||||
|
response = client.post(
|
||||||
|
f"/api/avatar/{context['avatar'].id}/chat/images",
|
||||||
|
headers=context["other_headers"],
|
||||||
|
files={"file": ("private.png", b"image-bytes", "image/png")},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_image_upload_preserves_insufficient_points_response(authorization_context):
|
||||||
|
context = authorization_context
|
||||||
|
with patch(
|
||||||
|
"routers.chat._analyze_image_bytes",
|
||||||
|
side_effect=InsufficientTokensError("积分余额不足"),
|
||||||
|
):
|
||||||
|
response = client.post(
|
||||||
|
f"/api/avatar/{context['avatar'].id}/chat/images",
|
||||||
|
headers=context["owner_headers"],
|
||||||
|
files={"file": ("private.png", b"image-bytes", "image/png")},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 402
|
||||||
|
assert response.json()["detail"] == "积分余额不足"
|
||||||
|
|
||||||
|
|
||||||
|
def test_public_share_can_upload_without_exposing_analysis_details(authorization_context):
|
||||||
|
context = authorization_context
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
avatar = db.get(type(context["avatar"]), context["avatar"].id)
|
||||||
|
avatar.share_token = f"share-{context['suffix']}"
|
||||||
|
db.commit()
|
||||||
|
share_token = avatar.share_token
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"routers.chat.prepare_image",
|
||||||
|
return_value=PreparedImage(b"jpeg", "image/jpeg", 100, 80),
|
||||||
|
),
|
||||||
|
patch("routers.chat._run_billed_vision_call", return_value=GENERAL_RESULT),
|
||||||
|
):
|
||||||
|
response = client.post(
|
||||||
|
f"/api/public/avatar/{share_token}/chat/images",
|
||||||
|
files={"file": ("visitor.png", b"image-bytes", "image/png")},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()["data"]
|
||||||
|
assert payload["status"] == "ready"
|
||||||
|
assert "structuredData" not in payload
|
||||||
|
assert "extractedText" not in payload
|
||||||
|
assert "visionModel" not in payload
|
||||||
|
assert "ocrModel" not in payload
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
stored = db.get(ChatAttachment, payload["id"])
|
||||||
|
assert stored.uploader_kind == "public"
|
||||||
|
assert stored.avatar_id == context["avatar"].id
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_medical_document_uses_ocr_result(authorization_context):
|
||||||
|
context = authorization_context
|
||||||
|
general = {
|
||||||
|
"content": json.dumps({
|
||||||
|
"category": "medical_document",
|
||||||
|
"summary": "血常规报告",
|
||||||
|
"visible_text": "初步文字",
|
||||||
|
"key_facts": [],
|
||||||
|
"uncertainties": [],
|
||||||
|
"medical": {"document_type": "检验报告"},
|
||||||
|
}, ensure_ascii=False),
|
||||||
|
"usage": {},
|
||||||
|
}
|
||||||
|
ocr = {"content": "白细胞 11.2 x10^9/L", "usage": {}}
|
||||||
|
with (
|
||||||
|
patch("routers.chat.prepare_image", return_value=PreparedImage(b"jpeg", "image/jpeg", 100, 80)),
|
||||||
|
patch("routers.chat._run_billed_vision_call", side_effect=[general, ocr]) as model,
|
||||||
|
):
|
||||||
|
response = client.post(
|
||||||
|
f"/api/avatar/{context['avatar'].id}/chat/images",
|
||||||
|
headers=context["owner_headers"],
|
||||||
|
files={"file": ("report.jpg", b"image-bytes", "image/jpeg")},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
attachment_id = response.json()["data"]["id"]
|
||||||
|
assert model.call_count == 2
|
||||||
|
assert model.call_args_list[1].kwargs["source"] == "vision_medical_ocr"
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
stored = db.query(ChatAttachment).filter(ChatAttachment.id == attachment_id).one()
|
||||||
|
assert stored.extracted_text == "白细胞 11.2 x10^9/L"
|
||||||
|
assert stored.ocr_model == "qwen-vl-ocr"
|
||||||
|
assert "不能替代医生诊断" in stored.warning
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_attachment_cannot_cross_avatar_boundary(authorization_context):
|
||||||
|
context = authorization_context
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
attachment = ChatAttachment(
|
||||||
|
avatar_id=context["avatar"].id,
|
||||||
|
filename="private.jpg",
|
||||||
|
status="ready",
|
||||||
|
expires_at=datetime.utcnow() + timedelta(hours=1),
|
||||||
|
)
|
||||||
|
db.add(attachment)
|
||||||
|
db.commit()
|
||||||
|
body = ChatIn(message="看看图片", attachmentIds=[attachment.id])
|
||||||
|
with pytest.raises(HTTPException, match="不属于当前分身") as caught:
|
||||||
|
_load_chat_attachments(db, context["other_avatar"].id, body)
|
||||||
|
assert caught.value.status_code == 400
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_expired_attachment_is_removed(authorization_context):
|
||||||
|
context = authorization_context
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
attachment = ChatAttachment(
|
||||||
|
avatar_id=context["avatar"].id,
|
||||||
|
filename="expired.jpg",
|
||||||
|
status="ready",
|
||||||
|
expires_at=datetime.utcnow() - timedelta(seconds=1),
|
||||||
|
)
|
||||||
|
db.add(attachment)
|
||||||
|
db.commit()
|
||||||
|
attachment_id = attachment.id
|
||||||
|
body = ChatIn(message="看看图片", attachmentIds=[attachment_id])
|
||||||
|
with pytest.raises(HTTPException):
|
||||||
|
_load_chat_attachments(db, context["avatar"].id, body)
|
||||||
|
assert db.query(ChatAttachment).filter(ChatAttachment.id == attachment_id).first() is None
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_cleanup_keeps_unexpired_attachment(authorization_context):
|
||||||
|
context = authorization_context
|
||||||
|
now = datetime.utcnow()
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
expired = ChatAttachment(
|
||||||
|
avatar_id=context["avatar"].id,
|
||||||
|
filename="expired.jpg",
|
||||||
|
status="ready",
|
||||||
|
expires_at=now - timedelta(seconds=1),
|
||||||
|
)
|
||||||
|
active = ChatAttachment(
|
||||||
|
avatar_id=context["avatar"].id,
|
||||||
|
filename="active.jpg",
|
||||||
|
status="ready",
|
||||||
|
expires_at=now + timedelta(hours=1),
|
||||||
|
)
|
||||||
|
db.add_all([expired, active])
|
||||||
|
db.commit()
|
||||||
|
expired_id, active_id = expired.id, active.id
|
||||||
|
|
||||||
|
assert purge_expired_chat_attachments(db, now=now) == 1
|
||||||
|
assert db.get(ChatAttachment, expired_id) is None
|
||||||
|
assert db.get(ChatAttachment, active_id) is not None
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_image_context_keeps_standard_answer_authoritative():
|
||||||
|
avatar = SimpleNamespace(
|
||||||
|
id="avatar-vision",
|
||||||
|
name="测试分身",
|
||||||
|
description="产品顾问",
|
||||||
|
config={},
|
||||||
|
)
|
||||||
|
model = Mock(return_value="标准退款期限是七天;图片显示的是商品包装。")
|
||||||
|
result = _resolve_reply(
|
||||||
|
None,
|
||||||
|
avatar,
|
||||||
|
"退款期限是多少?",
|
||||||
|
[],
|
||||||
|
qa_pairs=[SimpleNamespace(question="退款期限是多少?", answer="七天", enabled=True)],
|
||||||
|
search_fn=Mock(return_value=[]),
|
||||||
|
model_client=model,
|
||||||
|
image_contexts=[{
|
||||||
|
"id": "attachment",
|
||||||
|
"filename": "product.jpg",
|
||||||
|
"category": "general_image",
|
||||||
|
"summary": "商品包装",
|
||||||
|
"extractedText": "",
|
||||||
|
"structuredData": {},
|
||||||
|
"warning": "",
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["source"] == "qa"
|
||||||
|
system = model.call_args.kwargs["messages"][0]["content"]
|
||||||
|
assert "已确认标准答案" in system
|
||||||
|
assert "七天" in system
|
||||||
|
assert "商品包装" in system
|
||||||
|
assert "标准答题对中的事实优先级高于图片资料" in system
|
||||||
|
|
||||||
|
|
||||||
|
def test_ready_image_context_never_returns_whole_image_access_denial():
|
||||||
|
avatar = SimpleNamespace(
|
||||||
|
id="avatar-vision",
|
||||||
|
name="测试分身",
|
||||||
|
description="产品顾问",
|
||||||
|
config={},
|
||||||
|
)
|
||||||
|
model = Mock(return_value="抱歉,我无法查看或识别图片,请重新上传。")
|
||||||
|
result = _resolve_reply(
|
||||||
|
None,
|
||||||
|
avatar,
|
||||||
|
"请看看这张图片",
|
||||||
|
[],
|
||||||
|
qa_pairs=[],
|
||||||
|
search_fn=Mock(return_value=[]),
|
||||||
|
model_client=model,
|
||||||
|
image_contexts=[{
|
||||||
|
"id": "attachment",
|
||||||
|
"filename": "report.jpg",
|
||||||
|
"category": "medical_document",
|
||||||
|
"summary": "一份耳鼻喉科门诊记录",
|
||||||
|
"extractedText": "主诉:咽痛三天",
|
||||||
|
"structuredData": {"key_facts": ["主诉为咽痛三天"]},
|
||||||
|
"warning": "请核对原始资料",
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["source"] == "vision"
|
||||||
|
assert "一份耳鼻喉科门诊记录" in result["answer"]
|
||||||
|
assert "主诉为咽痛三天" in result["answer"]
|
||||||
|
assert "无法查看" not in result["answer"]
|
||||||
|
system = model.call_args.kwargs["messages"][0]["content"]
|
||||||
|
assert "当前会话图片已经成功读取" in system
|
||||||
|
assert "禁止声称无法查看" in system
|
||||||
|
|
||||||
|
|
||||||
|
def test_image_denial_detector_allows_uncertain_field_in_ready_image():
|
||||||
|
assert _answer_denies_available_image("我无法查看这张图片") is True
|
||||||
|
assert _answer_denies_available_image("图片中患者姓名无法辨认,主诉为咽痛三天。") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_attachment_context_does_not_expose_internal_fields():
|
||||||
|
row = SimpleNamespace(
|
||||||
|
id="attachment",
|
||||||
|
filename="case.jpg",
|
||||||
|
category="medical_document",
|
||||||
|
summary="门诊病例",
|
||||||
|
extracted_text="主诉:咳嗽",
|
||||||
|
structured_data={"medical": {"chief_complaint": "咳嗽"}},
|
||||||
|
warning="请核对原文",
|
||||||
|
)
|
||||||
|
context = _attachment_contexts([row])[0]
|
||||||
|
assert context["filename"] == "case.jpg"
|
||||||
|
assert "avatar_id" not in context
|
||||||
|
assert "vision_model" not in context
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from services.chat_model_config import (
|
||||||
|
clear_chat_model_config_cache,
|
||||||
|
get_chat_model_config,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def setup_function():
|
||||||
|
clear_chat_model_config_cache()
|
||||||
|
|
||||||
|
|
||||||
|
def teardown_function():
|
||||||
|
clear_chat_model_config_cache()
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_runtime_config_takes_priority(monkeypatch):
|
||||||
|
monkeypatch.setenv("CHAT_MODEL_CONFIG_URL", "http://config.test/runtime")
|
||||||
|
monkeypatch.setenv("AVATAR_MODEL_CONFIG_TOKEN", "shared-secret")
|
||||||
|
response = Mock()
|
||||||
|
response.raise_for_status.return_value = None
|
||||||
|
response.json.return_value = {
|
||||||
|
"data": {
|
||||||
|
"api_base_url": "https://model.test/v1/",
|
||||||
|
"api_key": "runtime-key",
|
||||||
|
"model": "avatar-model",
|
||||||
|
"vision_model": "avatar-vision-model",
|
||||||
|
"ocr_model": "avatar-ocr-model",
|
||||||
|
"max_tokens": 2048,
|
||||||
|
"timeout_seconds": 42,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch("services.chat_model_config.httpx.get", return_value=response) as request:
|
||||||
|
config = get_chat_model_config()
|
||||||
|
|
||||||
|
assert config.source == "admin"
|
||||||
|
assert config.api_base_url == "https://model.test/v1"
|
||||||
|
assert config.model == "avatar-model"
|
||||||
|
assert config.vision_model == "avatar-vision-model"
|
||||||
|
assert config.ocr_model == "avatar-ocr-model"
|
||||||
|
assert config.max_tokens == 2048
|
||||||
|
request.assert_called_once_with(
|
||||||
|
"http://config.test/runtime",
|
||||||
|
headers={"X-Avatar-Config-Token": "shared-secret"},
|
||||||
|
timeout=5.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_failure_falls_back_to_environment(monkeypatch):
|
||||||
|
monkeypatch.setenv("CHAT_MODEL_CONFIG_URL", "http://config.test/runtime")
|
||||||
|
monkeypatch.setenv("AVATAR_MODEL_CONFIG_TOKEN", "shared-secret")
|
||||||
|
monkeypatch.setenv("CHAT_API_URL", "https://fallback.test/v1/")
|
||||||
|
monkeypatch.setenv("CHAT_API_KEY", "fallback-key")
|
||||||
|
monkeypatch.setenv("CHAT_MODEL", "fallback-model")
|
||||||
|
monkeypatch.setenv("VISION_MODEL", "fallback-vision")
|
||||||
|
monkeypatch.setenv("VISION_OCR_MODEL", "fallback-ocr")
|
||||||
|
monkeypatch.setenv("CHAT_MAX_OUTPUT_TOKENS", "1536")
|
||||||
|
|
||||||
|
request = httpx.Request("GET", "http://config.test/runtime")
|
||||||
|
with patch(
|
||||||
|
"services.chat_model_config.httpx.get",
|
||||||
|
side_effect=httpx.ConnectError("offline", request=request),
|
||||||
|
):
|
||||||
|
config = get_chat_model_config()
|
||||||
|
|
||||||
|
assert config.source == "environment"
|
||||||
|
assert config.api_base_url == "https://fallback.test/v1"
|
||||||
|
assert config.api_key == "fallback-key"
|
||||||
|
assert config.model == "fallback-model"
|
||||||
|
assert config.vision_model == "fallback-vision"
|
||||||
|
assert config.ocr_model == "fallback-ocr"
|
||||||
|
assert config.max_tokens == 1536
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_config_is_cached(monkeypatch):
|
||||||
|
monkeypatch.setenv("CHAT_MODEL_CONFIG_URL", "")
|
||||||
|
monkeypatch.setenv("CHAT_MODEL", "first-model")
|
||||||
|
first = get_chat_model_config()
|
||||||
|
monkeypatch.setenv("CHAT_MODEL", "second-model")
|
||||||
|
|
||||||
|
second = get_chat_model_config()
|
||||||
|
|
||||||
|
assert first is second
|
||||||
|
assert second.model == "first-model"
|
||||||
@@ -5,7 +5,16 @@ from unittest.mock import Mock
|
|||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
from models import Avatar, User
|
from models import Avatar, User
|
||||||
from routers.chat import _build_prompt, _iter_text_chunks, _match_standard_qa, _public_avatar_payload, _require_owned_avatar, _resolve_reply
|
from routers.chat import (
|
||||||
|
_build_prompt,
|
||||||
|
_iter_text_chunks,
|
||||||
|
_match_standard_qa,
|
||||||
|
_public_avatar_payload,
|
||||||
|
_qa_requires_language_adaptation,
|
||||||
|
_qa_requires_per_turn_rendering,
|
||||||
|
_require_owned_avatar,
|
||||||
|
_resolve_reply,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ChatOrchestrationTests(unittest.TestCase):
|
class ChatOrchestrationTests(unittest.TestCase):
|
||||||
@@ -50,6 +59,69 @@ class ChatOrchestrationTests(unittest.TestCase):
|
|||||||
self.assertEqual(result["answer"], "标准地址")
|
self.assertEqual(result["answer"], "标准地址")
|
||||||
fake_model.assert_not_called()
|
fake_model.assert_not_called()
|
||||||
|
|
||||||
|
def test_cross_language_qa_is_faithfully_adapted_by_model(self):
|
||||||
|
fake_model = Mock(return_value="Our address is Test Road 1.")
|
||||||
|
fake_search = Mock(return_value=[])
|
||||||
|
result = _resolve_reply(
|
||||||
|
None,
|
||||||
|
self.avatar,
|
||||||
|
"Where is your office?",
|
||||||
|
[],
|
||||||
|
qa_pairs=[SimpleNamespace(question="Where is your office?", answer="地址是测试路1号。", enabled=True)],
|
||||||
|
search_fn=fake_search,
|
||||||
|
model_client=fake_model,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result["source"], "qa")
|
||||||
|
self.assertEqual(result["answer"], "Our address is Test Road 1.")
|
||||||
|
self.assertEqual(fake_model.call_args.kwargs["temperature"], 0.0)
|
||||||
|
system = fake_model.call_args.kwargs["messages"][0]["content"]
|
||||||
|
self.assertIn("已确认标准答案", system)
|
||||||
|
self.assertIn("地址是测试路1号", system)
|
||||||
|
self.assertIn("只使用该语言回答", system)
|
||||||
|
fake_search.assert_not_called()
|
||||||
|
|
||||||
|
def test_qa_language_adaptation_detects_common_writing_system_changes(self):
|
||||||
|
self.assertTrue(_qa_requires_language_adaptation("Hello", "你好"))
|
||||||
|
self.assertTrue(_qa_requires_language_adaptation("こんにちは", "你好"))
|
||||||
|
self.assertTrue(_qa_requires_language_adaptation("안녕하세요", "你好"))
|
||||||
|
self.assertFalse(_qa_requires_language_adaptation("你好", "您好"))
|
||||||
|
|
||||||
|
def test_conversation_qa_is_rendered_for_the_current_turn_language(self):
|
||||||
|
history = [SimpleNamespace(role="user", content="Please answer in English.")]
|
||||||
|
self.assertTrue(_qa_requires_per_turn_rendering("Quelle est votre adresse ?", "Our address is Test Road 1.", history))
|
||||||
|
|
||||||
|
fake_model = Mock(return_value="Notre adresse est Test Road 1.")
|
||||||
|
result = _resolve_reply(
|
||||||
|
None,
|
||||||
|
self.avatar,
|
||||||
|
"Quelle est votre adresse ?",
|
||||||
|
history,
|
||||||
|
qa_pairs=[SimpleNamespace(question="Quelle est votre adresse ?", answer="Our address is Test Road 1.", enabled=True)],
|
||||||
|
search_fn=Mock(),
|
||||||
|
model_client=fake_model,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result["source"], "qa")
|
||||||
|
self.assertEqual(result["answer"], "Notre adresse est Test Road 1.")
|
||||||
|
messages = fake_model.call_args.kwargs["messages"]
|
||||||
|
self.assertEqual(messages[-1], {"role": "user", "content": "Quelle est votre adresse ?"})
|
||||||
|
self.assertEqual(messages[-2]["role"], "system")
|
||||||
|
self.assertIn("本轮语言覆盖指令", messages[-2]["content"])
|
||||||
|
self.assertIn("不要沿用上一轮语言", messages[-2]["content"])
|
||||||
|
|
||||||
|
def test_latest_user_message_has_an_adjacent_language_override(self):
|
||||||
|
history = [
|
||||||
|
SimpleNamespace(role="user", content="请用中文回答"),
|
||||||
|
SimpleNamespace(role="assistant", content="好的,请问有什么可以帮你?"),
|
||||||
|
]
|
||||||
|
messages = _build_prompt(self.avatar, history, "What can you help me with?", [])
|
||||||
|
|
||||||
|
self.assertEqual(messages[-1], {"role": "user", "content": "What can you help me with?"})
|
||||||
|
self.assertEqual(messages[-2]["role"], "system")
|
||||||
|
self.assertIn("最新用户消息", messages[-2]["content"])
|
||||||
|
self.assertIn("立即切换到相同语言", messages[-2]["content"])
|
||||||
|
|
||||||
def test_conversational_paraphrase_matches_standard_qa(self):
|
def test_conversational_paraphrase_matches_standard_qa(self):
|
||||||
for question in ("请问一下,你们公司在哪里呀?", "请问去你们那边怎么走"):
|
for question in ("请问一下,你们公司在哪里呀?", "请问去你们那边怎么走"):
|
||||||
with self.subTest(question=question):
|
with self.subTest(question=question):
|
||||||
@@ -106,6 +178,9 @@ class ChatOrchestrationTests(unittest.TestCase):
|
|||||||
self.assertIn("像熟人之间微信聊天一样", messages[0]["content"])
|
self.assertIn("像熟人之间微信聊天一样", messages[0]["content"])
|
||||||
self.assertIn("不隶属于任何机构", messages[0]["content"])
|
self.assertIn("不隶属于任何机构", messages[0]["content"])
|
||||||
self.assertIn("不要连续输出空行", messages[0]["content"])
|
self.assertIn("不要连续输出空行", messages[0]["content"])
|
||||||
|
self.assertIn("回答语言规则", messages[0]["content"])
|
||||||
|
self.assertIn("当前最后一条用户消息", messages[0]["content"])
|
||||||
|
self.assertIn("历史消息", messages[0]["content"])
|
||||||
|
|
||||||
def test_prompt_blocks_ungrounded_factual_answers(self):
|
def test_prompt_blocks_ungrounded_factual_answers(self):
|
||||||
messages = _build_prompt(self.avatar, [], "聊聊国际新闻", [])
|
messages = _build_prompt(self.avatar, [], "聊聊国际新闻", [])
|
||||||
@@ -113,6 +188,8 @@ class ChatOrchestrationTests(unittest.TestCase):
|
|||||||
self.assertIn("没有检索到可靠资料", system)
|
self.assertIn("没有检索到可靠资料", system)
|
||||||
self.assertIn("不要凭通用知识", system)
|
self.assertIn("不要凭通用知识", system)
|
||||||
self.assertIn("不要提及知识库", system)
|
self.assertIn("不要提及知识库", system)
|
||||||
|
self.assertIn("不得推断服务对象", system)
|
||||||
|
self.assertIn("工作场所", system)
|
||||||
|
|
||||||
def test_public_avatar_payload_excludes_internal_configuration(self):
|
def test_public_avatar_payload_excludes_internal_configuration(self):
|
||||||
payload = _public_avatar_payload(self.avatar)
|
payload = _public_avatar_payload(self.avatar)
|
||||||
|
|||||||
@@ -48,9 +48,12 @@ class RemoteEmbeddingTests(unittest.TestCase):
|
|||||||
def test_large_input_is_split_into_provider_safe_batches(self):
|
def test_large_input_is_split_into_provider_safe_batches(self):
|
||||||
texts = [f"chunk-{index}" for index in range(14)]
|
texts = [f"chunk-{index}" for index in range(14)]
|
||||||
batch_sizes = []
|
batch_sizes = []
|
||||||
|
requested_urls = []
|
||||||
|
progress_updates = []
|
||||||
|
|
||||||
def fake_urlopen(request, timeout):
|
def fake_urlopen(request, timeout):
|
||||||
self.assertEqual(timeout, 30)
|
self.assertEqual(timeout, 30)
|
||||||
|
requested_urls.append(request.full_url)
|
||||||
payload = json.loads(request.data.decode("utf-8"))
|
payload = json.loads(request.data.decode("utf-8"))
|
||||||
batch_sizes.append(len(payload["input"]))
|
batch_sizes.append(len(payload["input"]))
|
||||||
return FakeResponse({
|
return FakeResponse({
|
||||||
@@ -61,15 +64,29 @@ class RemoteEmbeddingTests(unittest.TestCase):
|
|||||||
})
|
})
|
||||||
|
|
||||||
with patch.dict(os.environ, {
|
with patch.dict(os.environ, {
|
||||||
"EMBEDDING_API_URL": "https://embedding.example/v1/embeddings",
|
"EMBEDDING_API_URL": "https://embedding.example/v1",
|
||||||
"EMBEDDING_API_KEY": "test-key",
|
"EMBEDDING_API_KEY": "test-key",
|
||||||
"EMBEDDING_MODEL": "text-embedding-v4",
|
"EMBEDDING_MODEL": "text-embedding-v4",
|
||||||
"EMBEDDING_BATCH_SIZE": "10",
|
"EMBEDDING_BATCH_SIZE": "10",
|
||||||
}), patch("embeddings.urllib.request.urlopen", side_effect=fake_urlopen):
|
}), patch("embeddings.urllib.request.urlopen", side_effect=fake_urlopen):
|
||||||
result = embeddings.embed(texts)
|
result = embeddings.embed(
|
||||||
|
texts,
|
||||||
|
on_progress=lambda completed, total: progress_updates.append((completed, total)),
|
||||||
|
)
|
||||||
|
|
||||||
self.assertEqual(batch_sizes, [10, 4])
|
self.assertEqual(batch_sizes, [10, 4])
|
||||||
|
self.assertEqual(requested_urls, [
|
||||||
|
"https://embedding.example/v1/embeddings",
|
||||||
|
"https://embedding.example/v1/embeddings",
|
||||||
|
])
|
||||||
self.assertEqual(result, [[float(index)] for index in range(14)])
|
self.assertEqual(result, [[float(index)] for index in range(14)])
|
||||||
|
self.assertEqual(progress_updates, [(10, 14), (14, 14)])
|
||||||
|
|
||||||
|
def test_full_embedding_endpoint_is_not_modified(self):
|
||||||
|
self.assertEqual(
|
||||||
|
embeddings._embedding_endpoint("https://embedding.example/v1/embeddings/"),
|
||||||
|
"https://embedding.example/v1/embeddings",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Tests for preserving local avatar ownership when Huihui IDs change."""
|
"""Tests for preserving local avatar ownership when Huihui IDs change."""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
@@ -9,7 +10,8 @@ from sqlalchemy.pool import StaticPool
|
|||||||
|
|
||||||
from database import Base
|
from database import Base
|
||||||
from models import Avatar, TakeoverCursor, TakeoverMessage, TakeoverReplyTask, User
|
from models import Avatar, TakeoverCursor, TakeoverMessage, TakeoverReplyTask, User
|
||||||
from routers.huihui_auth import _issue_session
|
from routers.huihui_auth import _issue_session, token_login
|
||||||
|
from services.boxim_client import BoxIMError
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -135,3 +137,55 @@ def test_ambiguous_phone_matches_do_not_move_existing_avatars(db):
|
|||||||
assert db.query(User).count() == 3
|
assert db.query(User).count() == 3
|
||||||
_assert_avatar_data_owner(db, first_avatar.id, "fat-1")
|
_assert_avatar_data_owner(db, first_avatar.id, "fat-1")
|
||||||
_assert_avatar_data_owner(db, second_avatar.id, "fat-2")
|
_assert_avatar_data_owner(db, second_avatar.id, "fat-2")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_token_login_uses_huihui_user_id_and_keeps_upstream_token_server_side(db):
|
||||||
|
existing = User(
|
||||||
|
id="existing-local",
|
||||||
|
huihui_user_id="huihui-user-88",
|
||||||
|
app_token="existing-app-session",
|
||||||
|
)
|
||||||
|
db.add(existing)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
client = AsyncMock()
|
||||||
|
client.exchange_access_token.return_value = {"accessToken": "boxim-token"}
|
||||||
|
client.get_self.return_value = {
|
||||||
|
"id": 998877,
|
||||||
|
"huihuiUserId": "huihui-user-88",
|
||||||
|
"nickName": "会会用户",
|
||||||
|
"headImage": "https://cdn.example/avatar.jpg",
|
||||||
|
}
|
||||||
|
with patch("routers.huihui_auth._cfg_ready", return_value=True), patch(
|
||||||
|
"routers.huihui_auth._create_boxim_client", return_value=client
|
||||||
|
):
|
||||||
|
response = await token_login({"token": "production-huihui-token"}, db)
|
||||||
|
|
||||||
|
assert response["code"] == 200
|
||||||
|
assert response["data"]["token"] == "existing-app-session"
|
||||||
|
assert "token" not in response["data"]["huihui"]
|
||||||
|
user = db.query(User).one()
|
||||||
|
assert user.huihui_user_id == "huihui-user-88"
|
||||||
|
assert user.huihui_user_id != "998877"
|
||||||
|
assert user.huihui_token == "production-huihui-token"
|
||||||
|
assert user.nickname == "会会用户"
|
||||||
|
assert user.avatar_url == "https://cdn.example/avatar.jpg"
|
||||||
|
client.exchange_access_token.assert_awaited_once_with("production-huihui-token")
|
||||||
|
client.get_self.assert_awaited_once_with("boxim-token")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_token_login_rejects_expired_huihui_token_without_creating_user(db):
|
||||||
|
client = AsyncMock()
|
||||||
|
client.exchange_access_token.side_effect = BoxIMError(
|
||||||
|
"expired", auth_error=True
|
||||||
|
)
|
||||||
|
with patch("routers.huihui_auth._cfg_ready", return_value=True), patch(
|
||||||
|
"routers.huihui_auth._create_boxim_client", return_value=client
|
||||||
|
):
|
||||||
|
response = await token_login({"token": "expired-token"}, db)
|
||||||
|
|
||||||
|
assert response["code"] == 401
|
||||||
|
assert response["message"] == "会会登录凭证无效或已过期"
|
||||||
|
assert db.query(User).count() == 0
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
from services.huihui_payment import HuihuiPaymentClient
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_payment_uses_huihui_payment_v3_contract():
|
||||||
|
client = HuihuiPaymentClient({
|
||||||
|
"HUIHUI_PAYMENT_BASE_URL": "https://open.example/api/payment-v3",
|
||||||
|
"HUIHUI_APP_ID": "app-id",
|
||||||
|
"HUIHUI_ACCESS_ID": "access-id",
|
||||||
|
"HUIHUI_ACCESS_SECRET": "access-secret",
|
||||||
|
})
|
||||||
|
response = Mock()
|
||||||
|
response.status_code = 200
|
||||||
|
response.json.return_value = {
|
||||||
|
"code": 0,
|
||||||
|
"data": {"orderId": "provider-id", "status": "pending", "payMessage": "mock"},
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch("services.huihui_payment.httpx.post", return_value=response) as post:
|
||||||
|
result = client.create_payment(
|
||||||
|
huihui_token="user-token",
|
||||||
|
huihui_user_id="user-id",
|
||||||
|
real_name="测试用户",
|
||||||
|
order_no="AV202608260001",
|
||||||
|
amount="10.00",
|
||||||
|
points_amount=2_000_000,
|
||||||
|
pay_type="WECHAT",
|
||||||
|
pay_way="APP",
|
||||||
|
callback_url="https://digital.example/api/token/payment/callback/secret",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["orderId"] == "provider-id"
|
||||||
|
assert post.call_args.args[0] == "https://open.example/api/payment-v3/payment/pay"
|
||||||
|
assert post.call_args.kwargs["headers"] == {
|
||||||
|
"Authorization": "Bearer user-token",
|
||||||
|
"appId": "app-id",
|
||||||
|
"windowAppId": "app-id",
|
||||||
|
}
|
||||||
|
params = post.call_args.kwargs["params"]
|
||||||
|
assert params["appId"] == "app-id"
|
||||||
|
assert params["accessId"] == "access-id"
|
||||||
|
assert params["userId"] == "user-id"
|
||||||
|
assert params["signature"]
|
||||||
|
assert "accessSecret" not in params
|
||||||
|
body = post.call_args.kwargs["json"]
|
||||||
|
assert body["payType"] == "WECHAT"
|
||||||
|
assert body["payWay"] == "APP"
|
||||||
|
assert body["masterOrderAmt"] == "10.00"
|
||||||
|
assert body["payAmt"] == 10.0
|
||||||
@@ -2,7 +2,16 @@ from pathlib import Path
|
|||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from database import SessionLocal
|
||||||
|
from main import app
|
||||||
|
from models import Avatar, KnowledgeChunk, KnowledgeDoc, QAPair
|
||||||
from routers.knowledge import _doc_payload
|
from routers.knowledge import _doc_payload
|
||||||
|
from services.knowledge_vectorizer import knowledge_vectorizer
|
||||||
|
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
def test_doc_payload_reports_whether_the_persisted_file_exists(tmp_path: Path):
|
def test_doc_payload_reports_whether_the_persisted_file_exists(tmp_path: Path):
|
||||||
@@ -21,3 +30,394 @@ def test_doc_payload_reports_whether_the_persisted_file_exists(tmp_path: Path):
|
|||||||
assert _doc_payload(doc)["filePresent"] is False
|
assert _doc_payload(doc)["filePresent"] is False
|
||||||
stored_file.write_text("knowledge", encoding="utf-8")
|
stored_file.write_text("knowledge", encoding="utf-8")
|
||||||
assert _doc_payload(doc)["filePresent"] is True
|
assert _doc_payload(doc)["filePresent"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_returns_before_background_vectorization(
|
||||||
|
tmp_path: Path,
|
||||||
|
authorization_context,
|
||||||
|
):
|
||||||
|
context = authorization_context
|
||||||
|
with (
|
||||||
|
patch("routers.knowledge.UPLOAD_DIR", str(tmp_path)),
|
||||||
|
patch("routers.knowledge.knowledge_vectorizer.enqueue") as enqueue,
|
||||||
|
):
|
||||||
|
response = client.post(
|
||||||
|
f"/api/avatar/{context['avatar'].id}/knowledge/docs",
|
||||||
|
headers=context["owner_headers"],
|
||||||
|
files={"file": ("knowledge.md", b"# Knowledge\n\nTest content", "text/markdown")},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = response.json()["data"]
|
||||||
|
assert payload["status"] == "parsing"
|
||||||
|
assert payload["vectorized"] is False
|
||||||
|
assert payload["chunkCount"] == 0
|
||||||
|
enqueue.assert_called_once_with(payload["id"])
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
stored = db.query(KnowledgeDoc).filter(KnowledgeDoc.id == payload["id"]).one()
|
||||||
|
assert stored.status == "parsing"
|
||||||
|
assert db.query(KnowledgeChunk).filter(KnowledgeChunk.doc_id == stored.id).count() == 0
|
||||||
|
db.delete(stored)
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_rejects_oversize_file_before_queuing_indexing(
|
||||||
|
tmp_path: Path,
|
||||||
|
authorization_context,
|
||||||
|
):
|
||||||
|
context = authorization_context
|
||||||
|
with (
|
||||||
|
patch("routers.knowledge.UPLOAD_DIR", str(tmp_path)),
|
||||||
|
patch("routers.knowledge.MAX_UPLOAD_BYTES", 4),
|
||||||
|
patch("routers.knowledge.knowledge_vectorizer.enqueue") as enqueue,
|
||||||
|
):
|
||||||
|
response = client.post(
|
||||||
|
f"/api/avatar/{context['avatar'].id}/knowledge/docs",
|
||||||
|
headers=context["owner_headers"],
|
||||||
|
files={"file": ("oversize.md", b"12345", "text/markdown")},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = response.json()
|
||||||
|
assert payload["code"] == 400
|
||||||
|
assert payload["message"] == "文件不能超过 50MB"
|
||||||
|
enqueue.assert_not_called()
|
||||||
|
assert not list((tmp_path / context["avatar"].id).glob("*"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_multipart_upload_reassembles_file_before_queuing_indexing(
|
||||||
|
tmp_path: Path,
|
||||||
|
authorization_context,
|
||||||
|
):
|
||||||
|
context = authorization_context
|
||||||
|
avatar_id = context["avatar"].id
|
||||||
|
content = b"0123456789"
|
||||||
|
with (
|
||||||
|
patch("routers.knowledge.UPLOAD_DIR", str(tmp_path)),
|
||||||
|
patch("routers.knowledge.MULTIPART_CHUNK_BYTES", 4),
|
||||||
|
patch("routers.knowledge.knowledge_vectorizer.enqueue") as enqueue,
|
||||||
|
):
|
||||||
|
created = client.post(
|
||||||
|
f"/api/avatar/{avatar_id}/knowledge/uploads",
|
||||||
|
headers=context["owner_headers"],
|
||||||
|
json={"filename": "large.pdf", "fileSize": len(content), "totalChunks": 3},
|
||||||
|
).json()["data"]
|
||||||
|
|
||||||
|
for index, chunk in enumerate((content[:4], content[4:8], content[8:])):
|
||||||
|
response = client.post(
|
||||||
|
f"/api/avatar/{avatar_id}/knowledge/uploads/{created['uploadId']}/chunks/{index}",
|
||||||
|
headers=context["owner_headers"],
|
||||||
|
files={"file": (f"chunk-{index}", chunk, "application/octet-stream")},
|
||||||
|
)
|
||||||
|
assert response.json()["code"] == 200
|
||||||
|
|
||||||
|
completed = client.post(
|
||||||
|
f"/api/avatar/{avatar_id}/knowledge/uploads/{created['uploadId']}/complete",
|
||||||
|
headers=context["owner_headers"],
|
||||||
|
).json()["data"]
|
||||||
|
|
||||||
|
assert completed["status"] == "parsing"
|
||||||
|
assert completed["fileSize"] == len(content)
|
||||||
|
enqueue.assert_called_once_with(completed["id"])
|
||||||
|
stored_path = tmp_path / avatar_id / Path(completed["fileUrl"]).name
|
||||||
|
assert stored_path.read_bytes() == content
|
||||||
|
assert not (tmp_path / ".multipart" / avatar_id / created["uploadId"]).exists()
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
stored = db.query(KnowledgeDoc).filter(KnowledgeDoc.id == completed["id"]).one()
|
||||||
|
db.delete(stored)
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_multipart_upload_rejects_incomplete_parts(
|
||||||
|
tmp_path: Path,
|
||||||
|
authorization_context,
|
||||||
|
):
|
||||||
|
context = authorization_context
|
||||||
|
avatar_id = context["avatar"].id
|
||||||
|
with (
|
||||||
|
patch("routers.knowledge.UPLOAD_DIR", str(tmp_path)),
|
||||||
|
patch("routers.knowledge.MULTIPART_CHUNK_BYTES", 4),
|
||||||
|
patch("routers.knowledge.knowledge_vectorizer.enqueue") as enqueue,
|
||||||
|
):
|
||||||
|
created = client.post(
|
||||||
|
f"/api/avatar/{avatar_id}/knowledge/uploads",
|
||||||
|
headers=context["owner_headers"],
|
||||||
|
json={"filename": "large.pdf", "fileSize": 6, "totalChunks": 2},
|
||||||
|
).json()["data"]
|
||||||
|
client.post(
|
||||||
|
f"/api/avatar/{avatar_id}/knowledge/uploads/{created['uploadId']}/chunks/0",
|
||||||
|
headers=context["owner_headers"],
|
||||||
|
files={"file": ("chunk-0", b"0123", "application/octet-stream")},
|
||||||
|
)
|
||||||
|
response = client.post(
|
||||||
|
f"/api/avatar/{avatar_id}/knowledge/uploads/{created['uploadId']}/complete",
|
||||||
|
headers=context["owner_headers"],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.json()["code"] == 400
|
||||||
|
assert response.json()["message"] == "文件分片尚未上传完整"
|
||||||
|
enqueue.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_background_vectorizer_commits_ready_document_and_chunks_together(
|
||||||
|
tmp_path: Path,
|
||||||
|
authorization_context,
|
||||||
|
):
|
||||||
|
context = authorization_context
|
||||||
|
with (
|
||||||
|
patch("routers.knowledge.UPLOAD_DIR", str(tmp_path)),
|
||||||
|
patch("routers.knowledge.knowledge_vectorizer.enqueue"),
|
||||||
|
):
|
||||||
|
response = client.post(
|
||||||
|
f"/api/avatar/{context['avatar'].id}/knowledge/docs",
|
||||||
|
headers=context["owner_headers"],
|
||||||
|
files={"file": ("knowledge.md", b"# Knowledge\n\nTest content", "text/markdown")},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = response.json()["data"]
|
||||||
|
assert payload["status"] == "parsing"
|
||||||
|
with (
|
||||||
|
patch("services.knowledge_vectorizer.UPLOAD_DIR", str(tmp_path)),
|
||||||
|
patch("services.knowledge_vectorizer.embeddings.embed", return_value=[[1.0, 0.0]]),
|
||||||
|
):
|
||||||
|
knowledge_vectorizer.vectorize_document(payload["id"])
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
stored = db.query(KnowledgeDoc).filter(KnowledgeDoc.id == payload["id"]).one()
|
||||||
|
assert stored.status == "ready"
|
||||||
|
assert stored.vectorized is True
|
||||||
|
assert stored.chunk_count == 1
|
||||||
|
assert stored.index_stage == "ready"
|
||||||
|
assert stored.index_progress == 100
|
||||||
|
assert db.query(KnowledgeChunk).filter(KnowledgeChunk.doc_id == stored.id).count() == 1
|
||||||
|
db.query(KnowledgeChunk).filter(KnowledgeChunk.doc_id == stored.id).delete()
|
||||||
|
db.delete(stored)
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_background_vectorizer_keeps_failure_reason_for_retry(
|
||||||
|
tmp_path: Path,
|
||||||
|
authorization_context,
|
||||||
|
):
|
||||||
|
context = authorization_context
|
||||||
|
with (
|
||||||
|
patch("routers.knowledge.UPLOAD_DIR", str(tmp_path)),
|
||||||
|
patch("routers.knowledge.knowledge_vectorizer.enqueue"),
|
||||||
|
):
|
||||||
|
response = client.post(
|
||||||
|
f"/api/avatar/{context['avatar'].id}/knowledge/docs",
|
||||||
|
headers=context["owner_headers"],
|
||||||
|
files={"file": ("knowledge.md", b"# Knowledge\n\nTest content", "text/markdown")},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = response.json()["data"]
|
||||||
|
with (
|
||||||
|
patch("services.knowledge_vectorizer.UPLOAD_DIR", str(tmp_path)),
|
||||||
|
patch("services.knowledge_vectorizer.embeddings.embed", side_effect=RuntimeError("provider unavailable")),
|
||||||
|
):
|
||||||
|
knowledge_vectorizer.vectorize_document(payload["id"])
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
stored = db.query(KnowledgeDoc).filter(KnowledgeDoc.id == payload["id"]).one()
|
||||||
|
assert stored.status == "failed"
|
||||||
|
assert stored.error_message == "provider unavailable"
|
||||||
|
assert db.query(KnowledgeChunk).filter(KnowledgeChunk.doc_id == stored.id).count() == 0
|
||||||
|
db.delete(stored)
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_background_vectorizer_uses_ocr_for_image_only_pdf(
|
||||||
|
tmp_path: Path,
|
||||||
|
authorization_context,
|
||||||
|
):
|
||||||
|
context = authorization_context
|
||||||
|
with (
|
||||||
|
patch("routers.knowledge.UPLOAD_DIR", str(tmp_path)),
|
||||||
|
patch("routers.knowledge.knowledge_vectorizer.enqueue"),
|
||||||
|
):
|
||||||
|
response = client.post(
|
||||||
|
f"/api/avatar/{context['avatar'].id}/knowledge/docs",
|
||||||
|
headers=context["owner_headers"],
|
||||||
|
files={"file": ("scanned.pdf", b"image-only-pdf", "application/pdf")},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = response.json()["data"]
|
||||||
|
progress = []
|
||||||
|
with (
|
||||||
|
patch("services.knowledge_vectorizer.UPLOAD_DIR", str(tmp_path)),
|
||||||
|
patch("services.knowledge_vectorizer.embeddings.extract_text", return_value=""),
|
||||||
|
patch(
|
||||||
|
"services.knowledge_vectorizer.extract_scanned_pdf_text",
|
||||||
|
side_effect=lambda _db, _avatar, _path, on_progress: (
|
||||||
|
on_progress(1, 2), on_progress(2, 2), "扫描页文字"
|
||||||
|
)[-1],
|
||||||
|
) as ocr,
|
||||||
|
patch("services.knowledge_vectorizer.embeddings.embed", return_value=[[1.0, 0.0]]),
|
||||||
|
patch.object(knowledge_vectorizer, "_set_progress", wraps=knowledge_vectorizer._set_progress) as set_progress,
|
||||||
|
):
|
||||||
|
knowledge_vectorizer.vectorize_document(payload["id"])
|
||||||
|
progress = [(call.args[2], call.args[3]) for call in set_progress.call_args_list]
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
stored = db.query(KnowledgeDoc).filter(KnowledgeDoc.id == payload["id"]).one()
|
||||||
|
assert stored.status == "ready"
|
||||||
|
assert stored.chunk_count == 1
|
||||||
|
assert ("ocr", 18) in progress
|
||||||
|
assert ("ocr", 28) in progress
|
||||||
|
ocr.assert_called_once()
|
||||||
|
db.query(KnowledgeChunk).filter(KnowledgeChunk.doc_id == stored.id).delete()
|
||||||
|
db.delete(stored)
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_retry_queues_a_failed_document_again(
|
||||||
|
tmp_path: Path,
|
||||||
|
authorization_context,
|
||||||
|
):
|
||||||
|
context = authorization_context
|
||||||
|
document_id = f"retry-doc-{context['suffix']}"
|
||||||
|
avatar_dir = tmp_path / context["avatar"].id
|
||||||
|
avatar_dir.mkdir()
|
||||||
|
(avatar_dir / "retry.md").write_text("retry content", encoding="utf-8")
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
db.add(
|
||||||
|
KnowledgeDoc(
|
||||||
|
id=document_id,
|
||||||
|
avatar_id=context["avatar"].id,
|
||||||
|
filename="retry.md",
|
||||||
|
file_type="md",
|
||||||
|
file_url=f"/api/files/{context['avatar'].id}/retry.md",
|
||||||
|
status="failed",
|
||||||
|
error_message="provider unavailable",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("routers.knowledge.UPLOAD_DIR", str(tmp_path)),
|
||||||
|
patch("routers.knowledge.knowledge_vectorizer.enqueue") as enqueue,
|
||||||
|
):
|
||||||
|
response = client.post(
|
||||||
|
f"/api/avatar/{context['avatar'].id}/knowledge/docs/{document_id}/retry",
|
||||||
|
headers=context["owner_headers"],
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = response.json()["data"]
|
||||||
|
assert payload["status"] == "parsing"
|
||||||
|
assert payload["errorMessage"] == ""
|
||||||
|
enqueue.assert_called_once_with(document_id)
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
db.query(KnowledgeDoc).filter(KnowledgeDoc.id == document_id).delete()
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_each_avatar_has_an_independent_document_and_qa_scope(authorization_context):
|
||||||
|
context = authorization_context
|
||||||
|
first_avatar_id = context["avatar"].id
|
||||||
|
second_avatar_id = f"knowledge-second-{context['suffix']}"
|
||||||
|
first_doc_id = f"knowledge-first-doc-{context['suffix']}"
|
||||||
|
second_doc_id = f"knowledge-second-doc-{context['suffix']}"
|
||||||
|
first_qa_id = f"knowledge-first-qa-{context['suffix']}"
|
||||||
|
second_qa_id = f"knowledge-second-qa-{context['suffix']}"
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
db.add_all(
|
||||||
|
[
|
||||||
|
Avatar(
|
||||||
|
id=second_avatar_id,
|
||||||
|
owner_id=context["owner"].huihui_user_id,
|
||||||
|
name="独立知识库分身",
|
||||||
|
status="active",
|
||||||
|
config={},
|
||||||
|
),
|
||||||
|
KnowledgeDoc(
|
||||||
|
id=first_doc_id,
|
||||||
|
avatar_id=first_avatar_id,
|
||||||
|
filename="first.md",
|
||||||
|
status="ready",
|
||||||
|
vectorized=True,
|
||||||
|
),
|
||||||
|
KnowledgeDoc(
|
||||||
|
id=second_doc_id,
|
||||||
|
avatar_id=second_avatar_id,
|
||||||
|
filename="second.md",
|
||||||
|
status="ready",
|
||||||
|
vectorized=True,
|
||||||
|
),
|
||||||
|
QAPair(
|
||||||
|
id=first_qa_id,
|
||||||
|
avatar_id=first_avatar_id,
|
||||||
|
question="第一个分身问题",
|
||||||
|
answer="第一个分身答案",
|
||||||
|
),
|
||||||
|
QAPair(
|
||||||
|
id=second_qa_id,
|
||||||
|
avatar_id=second_avatar_id,
|
||||||
|
question="第二个分身问题",
|
||||||
|
answer="第二个分身答案",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
try:
|
||||||
|
first_docs = client.get(
|
||||||
|
f"/api/avatar/{first_avatar_id}/knowledge/docs",
|
||||||
|
headers=context["owner_headers"],
|
||||||
|
).json()["data"]
|
||||||
|
second_docs = client.get(
|
||||||
|
f"/api/avatar/{second_avatar_id}/knowledge/docs",
|
||||||
|
headers=context["owner_headers"],
|
||||||
|
).json()["data"]
|
||||||
|
first_qa = client.get(
|
||||||
|
f"/api/avatar/{first_avatar_id}/knowledge/qa",
|
||||||
|
headers=context["owner_headers"],
|
||||||
|
).json()["data"]
|
||||||
|
second_qa = client.get(
|
||||||
|
f"/api/avatar/{second_avatar_id}/knowledge/qa",
|
||||||
|
headers=context["owner_headers"],
|
||||||
|
).json()["data"]
|
||||||
|
|
||||||
|
assert [item["id"] for item in first_docs if item["id"] == first_doc_id] == [first_doc_id]
|
||||||
|
assert second_doc_id not in {item["id"] for item in first_docs}
|
||||||
|
assert [item["id"] for item in second_docs] == [second_doc_id]
|
||||||
|
assert first_qa_id in {item["id"] for item in first_qa}
|
||||||
|
assert second_qa_id not in {item["id"] for item in first_qa}
|
||||||
|
assert [item["id"] for item in second_qa] == [second_qa_id]
|
||||||
|
finally:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
db.query(QAPair).filter(QAPair.id.in_([first_qa_id, second_qa_id])).delete(
|
||||||
|
synchronize_session=False
|
||||||
|
)
|
||||||
|
db.query(KnowledgeDoc).filter(
|
||||||
|
KnowledgeDoc.id.in_([first_doc_id, second_doc_id])
|
||||||
|
).delete(synchronize_session=False)
|
||||||
|
db.query(Avatar).filter(Avatar.id == second_avatar_id).delete()
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from services.pdf_ocr_service import extract_scanned_pdf_text
|
||||||
|
|
||||||
|
|
||||||
|
class FakePixmap:
|
||||||
|
def tobytes(self, *_args, **_kwargs):
|
||||||
|
return b"jpeg-page"
|
||||||
|
|
||||||
|
|
||||||
|
class FakePage:
|
||||||
|
def get_pixmap(self, **_kwargs):
|
||||||
|
return FakePixmap()
|
||||||
|
|
||||||
|
|
||||||
|
class FakeDocument:
|
||||||
|
page_count = 2
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_args):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def load_page(self, _index):
|
||||||
|
return FakePage()
|
||||||
|
|
||||||
|
|
||||||
|
def test_scanned_pdf_ocr_preserves_page_order_and_reports_progress(monkeypatch):
|
||||||
|
fake_pymupdf = SimpleNamespace(
|
||||||
|
open=lambda _path: FakeDocument(),
|
||||||
|
Matrix=lambda x, y: (x, y),
|
||||||
|
csRGB="rgb",
|
||||||
|
)
|
||||||
|
monkeypatch.setitem(__import__("sys").modules, "pymupdf", fake_pymupdf)
|
||||||
|
progress = []
|
||||||
|
reservation = SimpleNamespace()
|
||||||
|
config = SimpleNamespace(
|
||||||
|
api_key="configured",
|
||||||
|
ocr_model="qwen-vl-ocr",
|
||||||
|
vision_model="vision",
|
||||||
|
vision_max_tokens=2048,
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("services.pdf_ocr_service.get_chat_model_config", return_value=config),
|
||||||
|
patch("services.pdf_ocr_service.prepare_image", return_value=SimpleNamespace()),
|
||||||
|
patch(
|
||||||
|
"services.pdf_ocr_service.call_vision_model",
|
||||||
|
side_effect=[
|
||||||
|
{"content": "第一页文字", "usage": {"total_tokens": 10}},
|
||||||
|
{"content": "第二页文字", "usage": {"total_tokens": 12}},
|
||||||
|
],
|
||||||
|
),
|
||||||
|
patch("services.pdf_ocr_service.reserve_avatar_tokens", return_value=reservation) as reserve,
|
||||||
|
patch("services.pdf_ocr_service.settle_reservation") as settle,
|
||||||
|
):
|
||||||
|
text = extract_scanned_pdf_text(
|
||||||
|
MagicMock(),
|
||||||
|
SimpleNamespace(id="avatar-1"),
|
||||||
|
"/tmp/scanned.pdf",
|
||||||
|
on_progress=lambda done, total: progress.append((done, total)),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert text == "[第 1 页]\n第一页文字\n\n[第 2 页]\n第二页文字"
|
||||||
|
assert progress == [(1, 2), (2, 2)]
|
||||||
|
assert reserve.call_count == 2
|
||||||
|
assert settle.call_count == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_scanned_pdf_ocr_releases_tokens_after_retries_fail(monkeypatch):
|
||||||
|
fake_document = FakeDocument()
|
||||||
|
fake_document.page_count = 1
|
||||||
|
fake_pymupdf = SimpleNamespace(
|
||||||
|
open=lambda _path: fake_document,
|
||||||
|
Matrix=lambda x, y: (x, y),
|
||||||
|
csRGB="rgb",
|
||||||
|
)
|
||||||
|
monkeypatch.setitem(__import__("sys").modules, "pymupdf", fake_pymupdf)
|
||||||
|
monkeypatch.setenv("KNOWLEDGE_PDF_OCR_ATTEMPTS", "2")
|
||||||
|
reservation = SimpleNamespace()
|
||||||
|
config = SimpleNamespace(
|
||||||
|
api_key="configured",
|
||||||
|
ocr_model="qwen-vl-ocr",
|
||||||
|
vision_model="vision",
|
||||||
|
vision_max_tokens=2048,
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("services.pdf_ocr_service.get_chat_model_config", return_value=config),
|
||||||
|
patch("services.pdf_ocr_service.prepare_image", return_value=SimpleNamespace()),
|
||||||
|
patch("services.pdf_ocr_service.call_vision_model", side_effect=RuntimeError("timeout")) as call,
|
||||||
|
patch("services.pdf_ocr_service.reserve_avatar_tokens", return_value=reservation),
|
||||||
|
patch("services.pdf_ocr_service.release_reservation") as release,
|
||||||
|
patch("services.pdf_ocr_service.time.sleep"),
|
||||||
|
):
|
||||||
|
with pytest.raises(RuntimeError, match="第 1/1 页识别失败"):
|
||||||
|
extract_scanned_pdf_text(MagicMock(), SimpleNamespace(id="avatar-1"), "/tmp/scanned.pdf")
|
||||||
|
|
||||||
|
assert call.call_count == 2
|
||||||
|
release.assert_called_once()
|
||||||
@@ -13,7 +13,7 @@ def test_authorization_takeover_fields():
|
|||||||
assert hasattr(auth, 'takeover_delay_seconds')
|
assert hasattr(auth, 'takeover_delay_seconds')
|
||||||
assert auth.takeover_enabled == False
|
assert auth.takeover_enabled == False
|
||||||
assert auth.takeover_mode == 'immediate'
|
assert auth.takeover_mode == 'immediate'
|
||||||
assert auth.takeover_delay_seconds == 30
|
assert auth.takeover_delay_seconds == 180
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|||||||
@@ -20,8 +20,9 @@ def test_scheduler_uses_boxim_and_restart_safe_service(
|
|||||||
):
|
):
|
||||||
import main
|
import main
|
||||||
|
|
||||||
|
maintenance_scheduler = MagicMock()
|
||||||
scheduler = MagicMock()
|
scheduler = MagicMock()
|
||||||
mock_scheduler_class.return_value = scheduler
|
mock_scheduler_class.side_effect = [maintenance_scheduler, scheduler]
|
||||||
boxim = MagicMock()
|
boxim = MagicMock()
|
||||||
mock_boxim_class.return_value = boxim
|
mock_boxim_class.return_value = boxim
|
||||||
takeover = MagicMock()
|
takeover = MagicMock()
|
||||||
@@ -45,7 +46,16 @@ def test_scheduler_uses_boxim_and_restart_safe_service(
|
|||||||
config = mock_boxim_class.call_args.args[0]
|
config = mock_boxim_class.call_args.args[0]
|
||||||
assert config["HUIHUI_PLATFORM_BASE_URL"] == "https://open.example/api"
|
assert config["HUIHUI_PLATFORM_BASE_URL"] == "https://open.example/api"
|
||||||
assert config["BOXIM_API_BASE_URL"] == "https://im.example/api"
|
assert config["BOXIM_API_BASE_URL"] == "https://im.example/api"
|
||||||
mock_takeover_class.assert_called_once_with(main.SessionLocal, boxim)
|
mock_takeover_class.assert_called_once_with(
|
||||||
|
main.SessionLocal,
|
||||||
|
boxim,
|
||||||
|
poll_concurrency=8,
|
||||||
|
max_message_age_seconds=600,
|
||||||
|
)
|
||||||
|
|
||||||
|
maintenance_scheduler.add_job.assert_called_once()
|
||||||
|
assert maintenance_scheduler.add_job.call_args.kwargs["id"] == "chat_attachment_cleanup"
|
||||||
|
maintenance_scheduler.start.assert_called_once_with()
|
||||||
|
|
||||||
assert scheduler.add_job.call_count == 2
|
assert scheduler.add_job.call_count == 2
|
||||||
poll_call, process_call = scheduler.add_job.call_args_list
|
poll_call, process_call = scheduler.add_job.call_args_list
|
||||||
@@ -62,6 +72,7 @@ def test_scheduler_uses_boxim_and_restart_safe_service(
|
|||||||
scheduler.start.assert_called_once_with()
|
scheduler.start.assert_called_once_with()
|
||||||
|
|
||||||
main.takeover_scheduler = None
|
main.takeover_scheduler = None
|
||||||
|
main.maintenance_scheduler = None
|
||||||
|
|
||||||
|
|
||||||
@patch("main.AsyncIOScheduler")
|
@patch("main.AsyncIOScheduler")
|
||||||
@@ -73,6 +84,7 @@ def test_scheduler_failure_does_not_stop_the_api(mock_scheduler_class):
|
|||||||
main.on_startup()
|
main.on_startup()
|
||||||
|
|
||||||
assert main.takeover_scheduler is None
|
assert main.takeover_scheduler is None
|
||||||
|
assert main.maintenance_scheduler is None
|
||||||
|
|
||||||
|
|
||||||
def test_shutdown_stops_only_the_scheduler():
|
def test_shutdown_stops_only_the_scheduler():
|
||||||
@@ -80,9 +92,14 @@ def test_shutdown_stops_only_the_scheduler():
|
|||||||
|
|
||||||
scheduler = MagicMock()
|
scheduler = MagicMock()
|
||||||
scheduler.running = True
|
scheduler.running = True
|
||||||
|
maintenance_scheduler = MagicMock()
|
||||||
|
maintenance_scheduler.running = True
|
||||||
main.takeover_scheduler = scheduler
|
main.takeover_scheduler = scheduler
|
||||||
|
main.maintenance_scheduler = maintenance_scheduler
|
||||||
|
|
||||||
main.on_shutdown()
|
main.on_shutdown()
|
||||||
|
|
||||||
scheduler.shutdown.assert_called_once_with(wait=False)
|
scheduler.shutdown.assert_called_once_with(wait=False)
|
||||||
|
maintenance_scheduler.shutdown.assert_called_once_with(wait=False)
|
||||||
assert main.takeover_scheduler is None
|
assert main.takeover_scheduler is None
|
||||||
|
assert main.maintenance_scheduler is None
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"""End-to-end service tests for BOXIM takeover timing and human priority."""
|
"""End-to-end service tests for BOXIM takeover timing and human priority."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from threading import Barrier
|
from threading import Barrier
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
@@ -7,12 +9,17 @@ from unittest.mock import AsyncMock, patch
|
|||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
from sqlalchemy.pool import StaticPool
|
|
||||||
|
|
||||||
from database import Base
|
from database import Base
|
||||||
from models import Avatar, TakeoverCursor, TakeoverMessage, TakeoverReplyTask, User
|
from models import Avatar, ChatAttachment, TakeoverCursor, TakeoverMessage, TakeoverReplyTask, User
|
||||||
from services.boxim_client import BoxIMError
|
from services.boxim_client import BoxIMError
|
||||||
from services.takeover_service import TakeoverService, _plain_text_reply
|
from services.boxim_image_service import DownloadedBoxIMImage
|
||||||
|
from services.takeover_service import (
|
||||||
|
AVATAR_LOCAL_ID_PREFIX,
|
||||||
|
TakeoverService,
|
||||||
|
_avatar_local_id,
|
||||||
|
_plain_text_reply,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Clock:
|
class Clock:
|
||||||
@@ -58,12 +65,54 @@ class FakeBoxIM:
|
|||||||
return {"id": 900 + len(self.sent), "localId": int(local_id)}
|
return {"id": 900 + len(self.sent), "localId": int(local_id)}
|
||||||
|
|
||||||
|
|
||||||
|
class ConcurrentPollingBoxIM(FakeBoxIM):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.active_polls = 0
|
||||||
|
self.peak_active_polls = 0
|
||||||
|
|
||||||
|
async def exchange_access_token(self, huihui_token):
|
||||||
|
return {"accessToken": huihui_token, "accessTokenExpiresIn": 3600}
|
||||||
|
|
||||||
|
async def get_self(self, access_token):
|
||||||
|
return {"id": 100 if access_token == "prod-huihui-token" else 101}
|
||||||
|
|
||||||
|
async def fetch_private_messages(self, access_token, min_id="0"):
|
||||||
|
self.active_polls += 1
|
||||||
|
self.peak_active_polls = max(self.peak_active_polls, self.active_polls)
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
self.active_polls -= 1
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
class ConcurrentMessagePollingBoxIM(ConcurrentPollingBoxIM):
|
||||||
|
async def fetch_private_messages(self, access_token, min_id="0"):
|
||||||
|
await super().fetch_private_messages(access_token, min_id)
|
||||||
|
owner_id = 100 if access_token == "prod-huihui-token" else 101
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": owner_id,
|
||||||
|
"localId": owner_id,
|
||||||
|
"sendId": owner_id + 100,
|
||||||
|
"recvId": owner_id,
|
||||||
|
"sendTime": 1_700_000_000_000,
|
||||||
|
"type": 0,
|
||||||
|
"content": "并发写入测试",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
async def mark_private_messages_read(self, access_token, friend_id, message_id):
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
self.read_receipts.append(
|
||||||
|
{"friendId": str(friend_id), "messageId": str(message_id)}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def service_context():
|
def service_context(tmp_path):
|
||||||
engine = create_engine(
|
engine = create_engine(
|
||||||
"sqlite://",
|
f"sqlite:///{tmp_path / 'takeover.db'}",
|
||||||
connect_args={"check_same_thread": False},
|
connect_args={"check_same_thread": False},
|
||||||
poolclass=StaticPool,
|
|
||||||
)
|
)
|
||||||
session_factory = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
|
session_factory = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
|
||||||
Base.metadata.create_all(engine)
|
Base.metadata.create_all(engine)
|
||||||
@@ -79,7 +128,10 @@ def service_context():
|
|||||||
owner_id=user.huihui_user_id,
|
owner_id=user.huihui_user_id,
|
||||||
name="分身",
|
name="分身",
|
||||||
status="active",
|
status="active",
|
||||||
config={"authorizationPermissions": ["chat", "takeover"]},
|
config={
|
||||||
|
"authorizationPermissions": ["chat", "takeover"],
|
||||||
|
"takeoverReplyDelaySeconds": 3,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
db.add_all([user, avatar])
|
db.add_all([user, avatar])
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -122,8 +174,7 @@ async def test_incoming_message_is_prepared_then_sent_at_three_seconds(service_c
|
|||||||
{"id": 11, "localId": 2, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "你好"}
|
{"id": 11, "localId": 2, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "你好"}
|
||||||
)
|
)
|
||||||
|
|
||||||
with patch("routers.chat._resolve_reply", return_value={"answer": "**你好**\n\n很高兴见到你"}):
|
await service.poll_and_process_messages()
|
||||||
await service.poll_and_process_messages()
|
|
||||||
assert boxim.sent == []
|
assert boxim.sent == []
|
||||||
assert boxim.read_receipts == [{"friendId": "200", "messageId": "11"}]
|
assert boxim.read_receipts == [{"friendId": "200", "messageId": "11"}]
|
||||||
|
|
||||||
@@ -132,7 +183,8 @@ async def test_incoming_message_is_prepared_then_sent_at_three_seconds(service_c
|
|||||||
assert boxim.sent == []
|
assert boxim.sent == []
|
||||||
|
|
||||||
clock.advance(1)
|
clock.advance(1)
|
||||||
await service.poll_and_process_messages()
|
with patch("routers.chat._resolve_reply", return_value={"answer": "**你好**\n\n很高兴见到你"}):
|
||||||
|
await service.poll_and_process_messages()
|
||||||
assert boxim.sent == [{"peerId": "200", "content": "你好\n很高兴见到你", "localId": boxim.sent[0]["localId"]}]
|
assert boxim.sent == [{"peerId": "200", "content": "你好\n很高兴见到你", "localId": boxim.sent[0]["localId"]}]
|
||||||
|
|
||||||
db = session_factory()
|
db = session_factory()
|
||||||
@@ -144,6 +196,453 @@ async def test_incoming_message_is_prepared_then_sent_at_three_seconds(service_c
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_incoming_image_is_analyzed_and_used_in_takeover_reply(service_context):
|
||||||
|
session_factory, service, boxim, clock = service_context
|
||||||
|
await service.poll_and_process_messages()
|
||||||
|
boxim.messages.append(
|
||||||
|
{
|
||||||
|
"id": 111,
|
||||||
|
"localId": 111,
|
||||||
|
"sendId": 200,
|
||||||
|
"recvId": 100,
|
||||||
|
"sendTime": clock.millis(),
|
||||||
|
"type": 1,
|
||||||
|
"content": json.dumps(
|
||||||
|
{
|
||||||
|
"originUrl": "https://cdn.example/case.png",
|
||||||
|
"thumbUrl": "https://cdn.example/case-thumb.png",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
await service.poll_and_process_messages()
|
||||||
|
db = session_factory()
|
||||||
|
try:
|
||||||
|
scheduled = db.query(TakeoverReplyTask).filter_by(trigger_message_id="111").one()
|
||||||
|
assert scheduled.status == "pending"
|
||||||
|
assert scheduled.prompt == "请看看这张图片。"
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
clock.advance(3)
|
||||||
|
|
||||||
|
def analyze(db, avatar, content, **kwargs):
|
||||||
|
assert content == b"image-content"
|
||||||
|
attachment = ChatAttachment(
|
||||||
|
avatar_id=avatar.id,
|
||||||
|
uploader_kind=kwargs["uploader_kind"],
|
||||||
|
filename=kwargs["filename"],
|
||||||
|
mime_type="image/jpeg",
|
||||||
|
file_size=len(content),
|
||||||
|
status="ready",
|
||||||
|
category="medical_document",
|
||||||
|
summary="一张门诊病例",
|
||||||
|
extracted_text="主诉:咳嗽三天",
|
||||||
|
structured_data={"medical": {"chief_complaint": "咳嗽三天"}},
|
||||||
|
warning="请核对原始资料",
|
||||||
|
expires_at=clock.now() + timedelta(hours=24),
|
||||||
|
)
|
||||||
|
db.add(attachment)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(attachment)
|
||||||
|
return attachment
|
||||||
|
|
||||||
|
downloaded = DownloadedBoxIMImage(
|
||||||
|
content=b"image-content",
|
||||||
|
filename="case.png",
|
||||||
|
mime_type="image/png",
|
||||||
|
source_url="https://cdn.example/case.png",
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
patch("services.takeover_service.download_boxim_image", return_value=downloaded),
|
||||||
|
patch("routers.chat._analyze_image_bytes", side_effect=analyze) as analyzer,
|
||||||
|
patch("routers.chat._resolve_reply", return_value={"answer": "这份资料里写的是咳嗽三天。"}) as resolver,
|
||||||
|
):
|
||||||
|
await service.poll_and_process_messages()
|
||||||
|
|
||||||
|
analyzer.assert_called_once()
|
||||||
|
assert resolver.call_args.args[2] == "请看看这张图片。"
|
||||||
|
image_contexts = resolver.call_args.kwargs["image_contexts"]
|
||||||
|
assert image_contexts[0]["summary"] == "一张门诊病例"
|
||||||
|
assert image_contexts[0]["extractedText"] == "主诉:咳嗽三天"
|
||||||
|
assert [item["content"] for item in boxim.sent] == ["这份资料里写的是咳嗽三天。"]
|
||||||
|
|
||||||
|
db = session_factory()
|
||||||
|
try:
|
||||||
|
event = db.query(TakeoverMessage).filter_by(boxim_message_id="111").one()
|
||||||
|
task = db.query(TakeoverReplyTask).filter_by(trigger_message_id="111").one()
|
||||||
|
assert event.attachment_id
|
||||||
|
assert db.get(ChatAttachment, event.attachment_id).uploader_kind == "boxim"
|
||||||
|
assert task.status == "sent"
|
||||||
|
with patch(
|
||||||
|
"services.takeover_service.download_boxim_image",
|
||||||
|
side_effect=AssertionError("cached image must not be downloaded again"),
|
||||||
|
):
|
||||||
|
cached = service._takeover_image_attachment(db, db.get(Avatar, "avatar-1"), event)
|
||||||
|
assert cached.id == event.attachment_id
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_followup_text_recovers_recent_image_recorded_without_task(service_context):
|
||||||
|
session_factory, service, boxim, clock = service_context
|
||||||
|
await service.poll_and_process_messages()
|
||||||
|
image_message = {
|
||||||
|
"id": 113,
|
||||||
|
"localId": 113,
|
||||||
|
"sendId": 200,
|
||||||
|
"recvId": 100,
|
||||||
|
"sendTime": clock.millis(),
|
||||||
|
"type": 1,
|
||||||
|
"content": json.dumps(
|
||||||
|
{
|
||||||
|
"originUrl": "https://cdn.example/case.png",
|
||||||
|
"thumbUrl": "https://cdn.example/case-thumb.png",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
db = session_factory()
|
||||||
|
try:
|
||||||
|
avatar = db.get(Avatar, "avatar-1")
|
||||||
|
service._record_message(db, avatar, "100", image_message, schedule_reply=False)
|
||||||
|
cursor = db.query(TakeoverCursor).one()
|
||||||
|
cursor.last_message_id = "113"
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
clock.advance(60)
|
||||||
|
boxim.messages.extend(
|
||||||
|
[
|
||||||
|
image_message,
|
||||||
|
{
|
||||||
|
"id": 114,
|
||||||
|
"localId": 114,
|
||||||
|
"sendId": 200,
|
||||||
|
"recvId": 100,
|
||||||
|
"sendTime": clock.millis(),
|
||||||
|
"type": 0,
|
||||||
|
"content": "请帮我看看这张图",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
await service.poll_messages()
|
||||||
|
|
||||||
|
db = session_factory()
|
||||||
|
try:
|
||||||
|
task = db.query(TakeoverReplyTask).filter_by(trigger_message_id="114").one()
|
||||||
|
assert task.source_message_ids == ["113", "114"]
|
||||||
|
assert task.prompt == "请看看这张图片。\n请帮我看看这张图"
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
clock.advance(1)
|
||||||
|
boxim.messages.append(
|
||||||
|
{
|
||||||
|
"id": 115,
|
||||||
|
"localId": 115,
|
||||||
|
"sendId": 200,
|
||||||
|
"recvId": 100,
|
||||||
|
"sendTime": clock.millis(),
|
||||||
|
"type": 0,
|
||||||
|
"content": "图里写了什么",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
await service.poll_messages()
|
||||||
|
|
||||||
|
db = session_factory()
|
||||||
|
try:
|
||||||
|
latest = db.query(TakeoverReplyTask).filter_by(trigger_message_id="115").one()
|
||||||
|
assert latest.source_message_ids == ["113", "114", "115"]
|
||||||
|
assert latest.source_message_ids.count("113") == 1
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_explicit_followup_reuses_handled_image_within_two_days(service_context):
|
||||||
|
session_factory, service, boxim, clock = service_context
|
||||||
|
await service.poll_and_process_messages()
|
||||||
|
image_message = {
|
||||||
|
"id": 116,
|
||||||
|
"localId": 116,
|
||||||
|
"sendId": 200,
|
||||||
|
"recvId": 100,
|
||||||
|
"sendTime": clock.millis(),
|
||||||
|
"type": 1,
|
||||||
|
"content": json.dumps({"originUrl": "https://cdn.example/handled-case.png"}),
|
||||||
|
}
|
||||||
|
boxim.messages.append(image_message)
|
||||||
|
await service.poll_messages()
|
||||||
|
|
||||||
|
db = session_factory()
|
||||||
|
try:
|
||||||
|
image_task = db.query(TakeoverReplyTask).filter_by(trigger_message_id="116").one()
|
||||||
|
image_task.status = "sent"
|
||||||
|
image_task.sent_at = clock.now()
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
clock.advance(47 * 60 * 60)
|
||||||
|
boxim.messages.append(
|
||||||
|
{
|
||||||
|
"id": 117,
|
||||||
|
"localId": 117,
|
||||||
|
"sendId": 200,
|
||||||
|
"recvId": 100,
|
||||||
|
"sendTime": clock.millis(),
|
||||||
|
"type": 0,
|
||||||
|
"content": "重新看一下刚才那张病例图片",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
await service.poll_messages()
|
||||||
|
|
||||||
|
db = session_factory()
|
||||||
|
try:
|
||||||
|
task = db.query(TakeoverReplyTask).filter_by(trigger_message_id="117").one()
|
||||||
|
assert task.source_message_ids == ["116", "117"]
|
||||||
|
assert task.prompt == "请看看这张图片。\n重新看一下刚才那张病例图片"
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_invalid_image_message_is_recorded_but_not_scheduled(service_context):
|
||||||
|
session_factory, service, boxim, clock = service_context
|
||||||
|
await service.poll_and_process_messages()
|
||||||
|
boxim.messages.append(
|
||||||
|
{
|
||||||
|
"id": 112,
|
||||||
|
"localId": 112,
|
||||||
|
"sendId": 200,
|
||||||
|
"recvId": 100,
|
||||||
|
"sendTime": clock.millis(),
|
||||||
|
"type": 1,
|
||||||
|
"content": json.dumps({"width": 100, "height": 100}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
await service.poll_and_process_messages()
|
||||||
|
|
||||||
|
db = session_factory()
|
||||||
|
try:
|
||||||
|
assert db.query(TakeoverMessage).filter_by(boxim_message_id="112").one()
|
||||||
|
assert db.query(TakeoverReplyTask).count() == 0
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_default_reply_delay_is_three_minutes(service_context):
|
||||||
|
session_factory, service, boxim, clock = service_context
|
||||||
|
db = session_factory()
|
||||||
|
try:
|
||||||
|
avatar = db.query(Avatar).one()
|
||||||
|
avatar.config = {"authorizationPermissions": ["chat", "takeover"]}
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
await service.poll_and_process_messages()
|
||||||
|
boxim.messages.append(
|
||||||
|
{"id": 12, "localId": 12, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "三分钟后回复"}
|
||||||
|
)
|
||||||
|
await service.poll_and_process_messages()
|
||||||
|
|
||||||
|
db = session_factory()
|
||||||
|
try:
|
||||||
|
task = db.query(TakeoverReplyTask).one()
|
||||||
|
assert task.scheduled_at == clock.now() + timedelta(seconds=180)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
clock.advance(179)
|
||||||
|
await service.process_reply_tasks()
|
||||||
|
assert boxim.sent == []
|
||||||
|
clock.advance(1)
|
||||||
|
with patch("routers.chat._resolve_reply", return_value={"answer": "好的"}):
|
||||||
|
await service.poll_and_process_messages()
|
||||||
|
assert [item["content"] for item in boxim.sent] == ["好的"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_multiple_avatar_owners_are_polled_concurrently(service_context):
|
||||||
|
session_factory, _service, _boxim, clock = service_context
|
||||||
|
db = session_factory()
|
||||||
|
try:
|
||||||
|
db.add_all(
|
||||||
|
[
|
||||||
|
User(
|
||||||
|
id="owner-local-2",
|
||||||
|
huihui_user_id="owner-huihui-2",
|
||||||
|
huihui_token="prod-huihui-token-2",
|
||||||
|
app_token="app-token-2",
|
||||||
|
),
|
||||||
|
Avatar(
|
||||||
|
id="avatar-2",
|
||||||
|
owner_id="owner-huihui-2",
|
||||||
|
name="分身二",
|
||||||
|
status="active",
|
||||||
|
config={"authorizationPermissions": ["chat", "takeover"]},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
boxim = ConcurrentMessagePollingBoxIM()
|
||||||
|
service = TakeoverService(
|
||||||
|
session_factory,
|
||||||
|
boxim,
|
||||||
|
poll_concurrency=2,
|
||||||
|
now=clock.now,
|
||||||
|
)
|
||||||
|
|
||||||
|
await service.poll_messages()
|
||||||
|
|
||||||
|
assert boxim.peak_active_polls == 2
|
||||||
|
db = session_factory()
|
||||||
|
try:
|
||||||
|
assert db.query(TakeoverCursor).filter(TakeoverCursor.initialized.is_(True)).count() == 2
|
||||||
|
assert db.query(TakeoverMessage).count() == 2
|
||||||
|
assert len(boxim.read_receipts) == 2
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delayed_poll_still_schedules_recent_message(service_context):
|
||||||
|
session_factory, service, boxim, clock = service_context
|
||||||
|
await service.poll_messages()
|
||||||
|
delayed_send_time = int(
|
||||||
|
(clock.value - timedelta(seconds=150)).replace(tzinfo=timezone.utc).timestamp()
|
||||||
|
* 1000
|
||||||
|
)
|
||||||
|
boxim.messages.append(
|
||||||
|
{
|
||||||
|
"id": 13,
|
||||||
|
"localId": 13,
|
||||||
|
"sendId": 200,
|
||||||
|
"recvId": 100,
|
||||||
|
"sendTime": delayed_send_time,
|
||||||
|
"type": 0,
|
||||||
|
"content": "排队后仍需回复",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
await service.poll_messages()
|
||||||
|
|
||||||
|
db = session_factory()
|
||||||
|
try:
|
||||||
|
task = db.query(TakeoverReplyTask).one()
|
||||||
|
assert task.status == "pending"
|
||||||
|
assert task.scheduled_at == clock.now()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
with patch("routers.chat._resolve_reply", return_value={"answer": "已经收到"}):
|
||||||
|
await service.process_reply_tasks()
|
||||||
|
assert [item["content"] for item in boxim.sent] == ["已经收到"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_avatar_origin_message_never_schedules_a_reply(service_context):
|
||||||
|
session_factory, service, boxim, clock = service_context
|
||||||
|
await service.poll_and_process_messages()
|
||||||
|
local_id = _avatar_local_id("peer-owner", "peer-trigger")
|
||||||
|
boxim.messages.append(
|
||||||
|
{"id": 15, "localId": local_id, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "另一端分身回复"}
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("routers.chat._resolve_reply") as resolver:
|
||||||
|
await service.poll_and_process_messages()
|
||||||
|
|
||||||
|
resolver.assert_not_called()
|
||||||
|
db = session_factory()
|
||||||
|
try:
|
||||||
|
event = db.query(TakeoverMessage).filter(TakeoverMessage.boxim_message_id == "15").one()
|
||||||
|
assert event.is_avatar is True
|
||||||
|
assert db.query(TakeoverReplyTask).count() == 0
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_peer_avatar_messages_are_excluded_from_later_human_context(service_context):
|
||||||
|
_session_factory, service, boxim, clock = service_context
|
||||||
|
await service.poll_and_process_messages()
|
||||||
|
boxim.messages.append(
|
||||||
|
{
|
||||||
|
"id": 16,
|
||||||
|
"localId": _avatar_local_id("peer-owner", "peer-trigger"),
|
||||||
|
"sendId": 200,
|
||||||
|
"recvId": 100,
|
||||||
|
"sendTime": clock.millis(),
|
||||||
|
"type": 0,
|
||||||
|
"content": "分身生成的夸张长文",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
await service.poll_and_process_messages()
|
||||||
|
|
||||||
|
clock.advance(1)
|
||||||
|
boxim.messages.append(
|
||||||
|
{
|
||||||
|
"id": 17,
|
||||||
|
"localId": 17,
|
||||||
|
"sendId": 200,
|
||||||
|
"recvId": 100,
|
||||||
|
"sendTime": clock.millis(),
|
||||||
|
"type": 0,
|
||||||
|
"content": "真人的新问题",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
await service.poll_and_process_messages()
|
||||||
|
clock.advance(3)
|
||||||
|
with patch("routers.chat._resolve_reply", return_value={"answer": "正常回复"}) as resolver:
|
||||||
|
await service.poll_and_process_messages()
|
||||||
|
|
||||||
|
assert resolver.call_args.args[3] == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_owner_message_pauses_future_takeover_for_ten_minutes(service_context):
|
||||||
|
session_factory, service, boxim, clock = service_context
|
||||||
|
await service.poll_and_process_messages()
|
||||||
|
boxim.messages.append(
|
||||||
|
{"id": 18, "localId": 18, "sendId": 100, "recvId": 200, "sendTime": clock.millis(), "type": 0, "content": "我先来回复"}
|
||||||
|
)
|
||||||
|
await service.poll_and_process_messages()
|
||||||
|
|
||||||
|
clock.advance(30)
|
||||||
|
boxim.messages.append(
|
||||||
|
{"id": 19, "localId": 19, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "收到"}
|
||||||
|
)
|
||||||
|
with patch("routers.chat._resolve_reply") as resolver:
|
||||||
|
await service.poll_and_process_messages()
|
||||||
|
|
||||||
|
resolver.assert_not_called()
|
||||||
|
db = session_factory()
|
||||||
|
try:
|
||||||
|
assert db.query(TakeoverReplyTask).count() == 0
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_avatar_local_id_is_deterministic_and_self_describing():
|
||||||
|
first = _avatar_local_id("owner", "message-1")
|
||||||
|
assert first == _avatar_local_id("owner", "message-1")
|
||||||
|
assert first != _avatar_local_id("owner", "message-2")
|
||||||
|
assert first.startswith(AVATAR_LOCAL_ID_PREFIX)
|
||||||
|
assert len(first) == 18
|
||||||
|
assert first.isdigit()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_different_contacts_generate_without_blocking_each_other(service_context):
|
async def test_different_contacts_generate_without_blocking_each_other(service_context):
|
||||||
session_factory, service, boxim, clock = service_context
|
session_factory, service, boxim, clock = service_context
|
||||||
@@ -156,15 +655,15 @@ async def test_different_contacts_generate_without_blocking_each_other(service_c
|
|||||||
)
|
)
|
||||||
both_generating = Barrier(2, timeout=2)
|
both_generating = Barrier(2, timeout=2)
|
||||||
|
|
||||||
def resolve(_db, _avatar, prompt, _history):
|
def resolve(_db, _avatar, prompt, _history, **_kwargs):
|
||||||
both_generating.wait()
|
both_generating.wait()
|
||||||
return {"answer": f"回复{prompt[-1]}"}
|
return {"answer": f"回复{prompt[-1]}"}
|
||||||
|
|
||||||
with patch("routers.chat._resolve_reply", side_effect=resolve):
|
await service.poll_and_process_messages()
|
||||||
await service.poll_and_process_messages()
|
|
||||||
|
|
||||||
clock.advance(3)
|
clock.advance(3)
|
||||||
await service.process_reply_tasks()
|
with patch("routers.chat._resolve_reply", side_effect=resolve):
|
||||||
|
await service.poll_and_process_messages()
|
||||||
assert {(item["peerId"], item["content"]) for item in boxim.sent} == {
|
assert {(item["peerId"], item["content"]) for item in boxim.sent} == {
|
||||||
("200", "回复甲"),
|
("200", "回复甲"),
|
||||||
("300", "回复乙"),
|
("300", "回复乙"),
|
||||||
@@ -239,6 +738,34 @@ async def test_owner_message_cancels_pending_reply(service_context):
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_owner_message_in_final_second_wins_before_generation(service_context):
|
||||||
|
session_factory, service, boxim, clock = service_context
|
||||||
|
await service.poll_and_process_messages()
|
||||||
|
boxim.messages.append(
|
||||||
|
{"id": 23, "localId": 23, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "还在吗"}
|
||||||
|
)
|
||||||
|
await service.poll_and_process_messages()
|
||||||
|
|
||||||
|
clock.advance(2)
|
||||||
|
boxim.messages.append(
|
||||||
|
{"id": 24, "localId": 24, "sendId": 100, "recvId": 200, "sendTime": clock.millis(), "type": 0, "content": "我来处理"}
|
||||||
|
)
|
||||||
|
clock.advance(1)
|
||||||
|
with patch("routers.chat._resolve_reply") as resolver:
|
||||||
|
await service.poll_and_process_messages()
|
||||||
|
|
||||||
|
resolver.assert_not_called()
|
||||||
|
assert boxim.sent == []
|
||||||
|
db = session_factory()
|
||||||
|
try:
|
||||||
|
task = db.query(TakeoverReplyTask).filter(TakeoverReplyTask.trigger_message_id == "23").one()
|
||||||
|
assert task.status == "cancelled"
|
||||||
|
assert task.cancel_reason == "owner_replied"
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_quick_successive_messages_are_coalesced_into_one_reply(service_context):
|
async def test_quick_successive_messages_are_coalesced_into_one_reply(service_context):
|
||||||
session_factory, service, boxim, clock = service_context
|
session_factory, service, boxim, clock = service_context
|
||||||
@@ -246,19 +773,18 @@ async def test_quick_successive_messages_are_coalesced_into_one_reply(service_co
|
|||||||
boxim.messages.append(
|
boxim.messages.append(
|
||||||
{"id": 31, "localId": 5, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "第一句"}
|
{"id": 31, "localId": 5, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "第一句"}
|
||||||
)
|
)
|
||||||
with patch("routers.chat._resolve_reply", return_value={"answer": "第一版"}):
|
await service.poll_and_process_messages()
|
||||||
await service.poll_and_process_messages()
|
|
||||||
|
|
||||||
clock.advance(1)
|
clock.advance(1)
|
||||||
boxim.messages.append(
|
boxim.messages.append(
|
||||||
{"id": 32, "localId": 6, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "第二句"}
|
{"id": 32, "localId": 6, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "第二句"}
|
||||||
)
|
)
|
||||||
|
await service.poll_and_process_messages()
|
||||||
|
|
||||||
|
clock.advance(3)
|
||||||
with patch("routers.chat._resolve_reply", return_value={"answer": "合并回复"}) as resolver:
|
with patch("routers.chat._resolve_reply", return_value={"answer": "合并回复"}) as resolver:
|
||||||
await service.poll_and_process_messages()
|
await service.poll_and_process_messages()
|
||||||
assert resolver.call_args.args[2] == "第一句\n第二句"
|
assert resolver.call_args.args[2] == "第一句\n第二句"
|
||||||
|
|
||||||
clock.advance(3)
|
|
||||||
await service.poll_and_process_messages()
|
|
||||||
assert [item["content"] for item in boxim.sent] == ["合并回复"]
|
assert [item["content"] for item in boxim.sent] == ["合并回复"]
|
||||||
|
|
||||||
db = session_factory()
|
db = session_factory()
|
||||||
@@ -293,5 +819,41 @@ async def test_connection_failure_disables_takeover_and_stops_retrying(service_c
|
|||||||
boxim.exchange_access_token.assert_awaited_once_with("prod-huihui-token")
|
boxim.exchange_access_token.assert_awaited_once_with("prod-huihui-token")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_transient_connection_failure_keeps_takeover_and_recovers(service_context):
|
||||||
|
session_factory, service, boxim, _ = service_context
|
||||||
|
boxim.exchange_access_token = AsyncMock(
|
||||||
|
side_effect=[
|
||||||
|
BoxIMError("连接超时"),
|
||||||
|
{"accessToken": "box-token", "accessTokenExpiresIn": 3600},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
await service.poll_messages()
|
||||||
|
|
||||||
|
db = session_factory()
|
||||||
|
try:
|
||||||
|
avatar = db.query(Avatar).one()
|
||||||
|
cursor = db.query(TakeoverCursor).one()
|
||||||
|
assert "takeover" in avatar.config["authorizationPermissions"]
|
||||||
|
assert cursor.initialized is False
|
||||||
|
assert "暂时连接失败" in cursor.last_error
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
await service.poll_messages()
|
||||||
|
|
||||||
|
db = session_factory()
|
||||||
|
try:
|
||||||
|
avatar = db.query(Avatar).one()
|
||||||
|
cursor = db.query(TakeoverCursor).one()
|
||||||
|
assert "takeover" in avatar.config["authorizationPermissions"]
|
||||||
|
assert cursor.initialized is True
|
||||||
|
assert cursor.last_error == ""
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
assert boxim.exchange_access_token.await_count == 2
|
||||||
|
|
||||||
|
|
||||||
def test_plain_text_reply_removes_markdown_and_empty_lines():
|
def test_plain_text_reply_removes_markdown_and_empty_lines():
|
||||||
assert _plain_text_reply("## 建议\n\n**不能自行用药**\n`必要时就医`") == "建议\n不能自行用药\n必要时就医"
|
assert _plain_text_reply("## 建议\n\n**不能自行用药**\n`必要时就医`") == "建议\n不能自行用药\n必要时就医"
|
||||||
|
|||||||
@@ -0,0 +1,430 @@
|
|||||||
|
import uuid
|
||||||
|
import os
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from threading import Barrier
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from database import SessionLocal
|
||||||
|
from main import app, seed
|
||||||
|
from models import Avatar, TokenAccount, TokenPaymentOrder, TokenPlan, TokenUsage, User
|
||||||
|
from routers.chat import _resolve_reply, _stream_reply
|
||||||
|
from services.token_billing import (
|
||||||
|
DEFAULT_TOKEN_GRANT,
|
||||||
|
InsufficientTokensError,
|
||||||
|
get_or_create_account,
|
||||||
|
release_reservation,
|
||||||
|
reserve_avatar_tokens,
|
||||||
|
settle_reservation,
|
||||||
|
)
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def _enable_huihui_payment_login(context):
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
user = db.query(User).filter(User.id == context["owner"].id).one()
|
||||||
|
user.huihui_token = f"huihui-payment-{context['suffix']}"
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_balance_is_user_scoped_and_defaults_to_one_million(authorization_context):
|
||||||
|
context = authorization_context
|
||||||
|
owner = client.get("/api/token/balance", headers=context["owner_headers"])
|
||||||
|
other = client.get("/api/token/balance", headers=context["other_headers"])
|
||||||
|
|
||||||
|
assert owner.status_code == 200
|
||||||
|
assert owner.json()["data"] == {
|
||||||
|
"balance": DEFAULT_TOKEN_GRANT,
|
||||||
|
"totalGranted": DEFAULT_TOKEN_GRANT,
|
||||||
|
"totalConsumed": 0,
|
||||||
|
}
|
||||||
|
assert other.json()["data"]["balance"] == DEFAULT_TOKEN_GRANT
|
||||||
|
assert client.get("/api/token/balance").status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_synchronizes_requested_recharge_plans():
|
||||||
|
seed()
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
plans = db.query(TokenPlan).order_by(TokenPlan.price.asc()).all()
|
||||||
|
assert [(plan.price, plan.amount) for plan in plans] == [
|
||||||
|
(10, 2_000_000),
|
||||||
|
(100, 20_000_000),
|
||||||
|
(1000, 250_000_000),
|
||||||
|
(10000, 2_500_000_000),
|
||||||
|
]
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_charge_creates_huihui_order_without_early_points(authorization_context):
|
||||||
|
context = authorization_context
|
||||||
|
_enable_huihui_payment_login(context)
|
||||||
|
payment_client = Mock()
|
||||||
|
payment_client.create_payment.return_value = {
|
||||||
|
"orderId": "huihui-payment-id",
|
||||||
|
"orderNo": "huihui-payment-no",
|
||||||
|
"payMessage": {"mock": "payment-params"},
|
||||||
|
"payType": "WECHAT",
|
||||||
|
"paySubType": "APP",
|
||||||
|
"status": "pending",
|
||||||
|
}
|
||||||
|
env = {
|
||||||
|
"HUIHUI_PAYMENT_CALLBACK_BASE_URL": "https://digital.example",
|
||||||
|
"HUIHUI_PAYMENT_CALLBACK_SECRET": "test-callback-secret-123456",
|
||||||
|
}
|
||||||
|
with patch.dict(os.environ, env), patch("routers.tokens._payment_client", return_value=payment_client):
|
||||||
|
response = client.post(
|
||||||
|
"/api/token/charge",
|
||||||
|
headers=context["owner_headers"],
|
||||||
|
json={"planId": "1", "paymentMethod": "wechat", "payScene": "APP"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
result = response.json()["data"]
|
||||||
|
assert result["status"] == "pending"
|
||||||
|
assert result["payType"] == "WECHAT"
|
||||||
|
assert result["payWay"] == "APP"
|
||||||
|
assert result["balance"] == DEFAULT_TOKEN_GRANT
|
||||||
|
assert payment_client.create_payment.call_args.kwargs["amount"] == "10.00"
|
||||||
|
callback_url = payment_client.create_payment.call_args.kwargs["callback_url"]
|
||||||
|
assert callback_url.startswith("https://digital.example/api/token/payment/callback/AV")
|
||||||
|
assert "test-callback-secret-123456" not in callback_url
|
||||||
|
|
||||||
|
|
||||||
|
def test_success_callback_credits_once_and_status_is_user_scoped(authorization_context):
|
||||||
|
context = authorization_context
|
||||||
|
_enable_huihui_payment_login(context)
|
||||||
|
payment_client = Mock()
|
||||||
|
payment_client.create_payment.return_value = {
|
||||||
|
"orderId": "huihui-payment-id",
|
||||||
|
"orderNo": "huihui-payment-no",
|
||||||
|
"payMessage": "payment-message",
|
||||||
|
"status": "pending",
|
||||||
|
}
|
||||||
|
secret = "test-callback-secret-123456"
|
||||||
|
env = {
|
||||||
|
"HUIHUI_PAYMENT_CALLBACK_BASE_URL": "https://digital.example",
|
||||||
|
"HUIHUI_PAYMENT_CALLBACK_SECRET": secret,
|
||||||
|
}
|
||||||
|
with patch.dict(os.environ, env), patch("routers.tokens._payment_client", return_value=payment_client):
|
||||||
|
created = client.post(
|
||||||
|
"/api/token/charge",
|
||||||
|
headers=context["owner_headers"],
|
||||||
|
json={"planId": "1", "paymentMethod": "alipay", "payScene": "APP"},
|
||||||
|
).json()["data"]
|
||||||
|
callback_body = {
|
||||||
|
"data": {
|
||||||
|
"masterOrderNo": created["orderNo"],
|
||||||
|
"status": "succeeded",
|
||||||
|
"payAmt": "10.00",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
signature = hmac.new(
|
||||||
|
secret.encode(), created["orderNo"].encode(), hashlib.sha256
|
||||||
|
).hexdigest()
|
||||||
|
callback_path = f"/api/token/payment/callback/{created['orderNo']}/{signature}"
|
||||||
|
first = client.post(callback_path, json=callback_body)
|
||||||
|
second = client.post(callback_path, json=callback_body)
|
||||||
|
|
||||||
|
assert first.json()["data"] == {"received": True, "paid": True}
|
||||||
|
assert second.json()["data"] == {"received": True, "duplicate": True}
|
||||||
|
status = client.get(
|
||||||
|
f"/api/token/payment/{created['id']}", headers=context["owner_headers"]
|
||||||
|
).json()["data"]
|
||||||
|
assert status["status"] == "paid"
|
||||||
|
assert status["balance"] == DEFAULT_TOKEN_GRANT + 2_000_000
|
||||||
|
assert client.get(
|
||||||
|
f"/api/token/payment/{created['id']}", headers=context["other_headers"]
|
||||||
|
).json()["code"] == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_callback_amount_mismatch_never_credits_points(authorization_context):
|
||||||
|
context = authorization_context
|
||||||
|
_enable_huihui_payment_login(context)
|
||||||
|
payment_client = Mock()
|
||||||
|
payment_client.create_payment.return_value = {"status": "pending", "payMessage": "mock"}
|
||||||
|
secret = "test-callback-secret-123456"
|
||||||
|
env = {
|
||||||
|
"HUIHUI_PAYMENT_CALLBACK_BASE_URL": "https://digital.example",
|
||||||
|
"HUIHUI_PAYMENT_CALLBACK_SECRET": secret,
|
||||||
|
}
|
||||||
|
with patch.dict(os.environ, env), patch("routers.tokens._payment_client", return_value=payment_client):
|
||||||
|
created = client.post(
|
||||||
|
"/api/token/charge",
|
||||||
|
headers=context["owner_headers"],
|
||||||
|
json={"planId": "1", "paymentMethod": "wechat", "payScene": "APP"},
|
||||||
|
).json()["data"]
|
||||||
|
signature = hmac.new(
|
||||||
|
secret.encode(), created["orderNo"].encode(), hashlib.sha256
|
||||||
|
).hexdigest()
|
||||||
|
callback = client.post(
|
||||||
|
f"/api/token/payment/callback/{created['orderNo']}/{signature}",
|
||||||
|
json={
|
||||||
|
"masterOrderNo": created["orderNo"],
|
||||||
|
"status": "success",
|
||||||
|
"actualAmt": "9.99",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert callback.json()["code"] == 422
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
order = db.query(TokenPaymentOrder).filter(TokenPaymentOrder.id == created["id"]).one()
|
||||||
|
account = get_or_create_account(db, context["owner"].id)
|
||||||
|
assert order.status == "pending"
|
||||||
|
assert account.balance == DEFAULT_TOKEN_GRANT
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_payment_callback_creates_missing_account_in_same_settlement(authorization_context):
|
||||||
|
context = authorization_context
|
||||||
|
_enable_huihui_payment_login(context)
|
||||||
|
payment_client = Mock()
|
||||||
|
payment_client.create_payment.return_value = {"status": "pending", "payMessage": "mock"}
|
||||||
|
secret = "test-callback-secret-123456"
|
||||||
|
env = {
|
||||||
|
"HUIHUI_PAYMENT_CALLBACK_BASE_URL": "https://digital.example",
|
||||||
|
"HUIHUI_PAYMENT_CALLBACK_SECRET": secret,
|
||||||
|
}
|
||||||
|
with patch.dict(os.environ, env), patch("routers.tokens._payment_client", return_value=payment_client):
|
||||||
|
created = client.post(
|
||||||
|
"/api/token/charge",
|
||||||
|
headers=context["owner_headers"],
|
||||||
|
json={"planId": "1", "paymentMethod": "alipay", "payScene": "APP"},
|
||||||
|
).json()["data"]
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
db.query(TokenAccount).filter(TokenAccount.user_id == context["owner"].id).delete()
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
signature = hmac.new(
|
||||||
|
secret.encode(), created["orderNo"].encode(), hashlib.sha256
|
||||||
|
).hexdigest()
|
||||||
|
callback = client.post(
|
||||||
|
f"/api/token/payment/callback/{created['orderNo']}/{signature}",
|
||||||
|
json={
|
||||||
|
"masterOrderNo": created["orderNo"],
|
||||||
|
"status": "success",
|
||||||
|
"payAmt": "10.00",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert callback.json()["data"] == {"received": True, "paid": True}
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
account = db.query(TokenAccount).filter(TokenAccount.user_id == context["owner"].id).one()
|
||||||
|
assert account.balance == DEFAULT_TOKEN_GRANT + 2_000_000
|
||||||
|
assert account.total_granted == DEFAULT_TOKEN_GRANT + 2_000_000
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_multiple_avatars_share_owner_balance_and_usage_is_itemized():
|
||||||
|
suffix = uuid.uuid4().hex
|
||||||
|
db = SessionLocal()
|
||||||
|
user = User(id=f"token-user-{suffix}", huihui_user_id=f"token-huihui-{suffix}")
|
||||||
|
first = Avatar(id=f"token-avatar-a-{suffix}", owner_id=user.huihui_user_id, name="甲")
|
||||||
|
second = Avatar(id=f"token-avatar-b-{suffix}", owner_id=user.huihui_user_id, name="乙")
|
||||||
|
db.add_all([user, first, second])
|
||||||
|
db.commit()
|
||||||
|
try:
|
||||||
|
first_reservation = reserve_avatar_tokens(db, first, "chat", "qwen-test", [{"content": "问题一"}], 128)
|
||||||
|
settle_reservation(
|
||||||
|
db,
|
||||||
|
first_reservation,
|
||||||
|
{"prompt_tokens": 60, "completion_tokens": 40, "total_tokens": 100},
|
||||||
|
fallback_total=999,
|
||||||
|
)
|
||||||
|
second_reservation = reserve_avatar_tokens(db, second, "takeover", "qwen-test", [{"content": "问题二"}], 128)
|
||||||
|
settle_reservation(
|
||||||
|
db,
|
||||||
|
second_reservation,
|
||||||
|
{"prompt_tokens": 120, "completion_tokens": 80, "total_tokens": 200},
|
||||||
|
fallback_total=999,
|
||||||
|
)
|
||||||
|
|
||||||
|
account = get_or_create_account(db, user.id)
|
||||||
|
assert account.balance == DEFAULT_TOKEN_GRANT - 300
|
||||||
|
assert account.total_consumed == 300
|
||||||
|
usages = db.query(TokenUsage).filter(TokenUsage.user_id == user.id).order_by(TokenUsage.total_tokens).all()
|
||||||
|
assert [(row.avatar_id, row.source, row.total_tokens) for row in usages] == [
|
||||||
|
(first.id, "chat", 100),
|
||||||
|
(second.id, "takeover", 200),
|
||||||
|
]
|
||||||
|
finally:
|
||||||
|
db.query(TokenUsage).filter(TokenUsage.user_id == user.id).delete(synchronize_session=False)
|
||||||
|
db.query(TokenAccount).filter(TokenAccount.user_id == user.id).delete(synchronize_session=False)
|
||||||
|
db.query(Avatar).filter(Avatar.id.in_([first.id, second.id])).delete(synchronize_session=False)
|
||||||
|
db.query(User).filter(User.id == user.id).delete(synchronize_session=False)
|
||||||
|
db.commit()
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_concurrent_settlements_do_not_overwrite_each_other():
|
||||||
|
suffix = uuid.uuid4().hex
|
||||||
|
db = SessionLocal()
|
||||||
|
user = User(id=f"concurrent-user-{suffix}", huihui_user_id=f"concurrent-huihui-{suffix}")
|
||||||
|
avatar = Avatar(id=f"concurrent-avatar-{suffix}", owner_id=user.huihui_user_id, name="并发测试")
|
||||||
|
db.add_all([user, avatar])
|
||||||
|
db.commit()
|
||||||
|
first = reserve_avatar_tokens(db, avatar, "takeover", "qwen-test", [{"content": "甲"}], 128)
|
||||||
|
second = reserve_avatar_tokens(db, avatar, "takeover", "qwen-test", [{"content": "乙"}], 128)
|
||||||
|
db.close()
|
||||||
|
barrier = Barrier(2, timeout=3)
|
||||||
|
|
||||||
|
def settle(reservation, total):
|
||||||
|
thread_db = SessionLocal()
|
||||||
|
try:
|
||||||
|
barrier.wait()
|
||||||
|
settle_reservation(
|
||||||
|
thread_db,
|
||||||
|
reservation,
|
||||||
|
{"prompt_tokens": total - 20, "completion_tokens": 20, "total_tokens": total},
|
||||||
|
fallback_total=999,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
thread_db.close()
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||||
|
list(pool.map(lambda args: settle(*args), [(first, 100), (second, 200)]))
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
account = get_or_create_account(db, user.id)
|
||||||
|
assert account.balance == DEFAULT_TOKEN_GRANT - 300
|
||||||
|
assert account.total_consumed == 300
|
||||||
|
finally:
|
||||||
|
db.query(TokenUsage).filter(TokenUsage.user_id == user.id).delete(synchronize_session=False)
|
||||||
|
db.query(TokenAccount).filter(TokenAccount.user_id == user.id).delete(synchronize_session=False)
|
||||||
|
db.query(Avatar).filter(Avatar.id == avatar.id).delete(synchronize_session=False)
|
||||||
|
db.query(User).filter(User.id == user.id).delete(synchronize_session=False)
|
||||||
|
db.commit()
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_model_request_returns_the_full_reservation():
|
||||||
|
suffix = uuid.uuid4().hex
|
||||||
|
db = SessionLocal()
|
||||||
|
user = User(id=f"refund-user-{suffix}", huihui_user_id=f"refund-huihui-{suffix}")
|
||||||
|
avatar = Avatar(id=f"refund-avatar-{suffix}", owner_id=user.huihui_user_id, name="退款测试")
|
||||||
|
db.add_all([user, avatar])
|
||||||
|
db.commit()
|
||||||
|
try:
|
||||||
|
reservation = reserve_avatar_tokens(db, avatar, "chat", "qwen-test", [{"content": "问题"}], 128)
|
||||||
|
release_reservation(db, reservation, "provider error")
|
||||||
|
account = get_or_create_account(db, user.id)
|
||||||
|
usage = db.query(TokenUsage).filter(TokenUsage.id == reservation.usage_id).one()
|
||||||
|
assert account.balance == DEFAULT_TOKEN_GRANT
|
||||||
|
assert account.total_consumed == 0
|
||||||
|
assert usage.status == "failed"
|
||||||
|
finally:
|
||||||
|
db.query(TokenUsage).filter(TokenUsage.user_id == user.id).delete(synchronize_session=False)
|
||||||
|
db.query(TokenAccount).filter(TokenAccount.user_id == user.id).delete(synchronize_session=False)
|
||||||
|
db.query(Avatar).filter(Avatar.id == avatar.id).delete(synchronize_session=False)
|
||||||
|
db.query(User).filter(User.id == user.id).delete(synchronize_session=False)
|
||||||
|
db.commit()
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_insufficient_balance_rejects_before_model_usage_is_created():
|
||||||
|
suffix = uuid.uuid4().hex
|
||||||
|
db = SessionLocal()
|
||||||
|
user = User(id=f"empty-user-{suffix}", huihui_user_id=f"empty-huihui-{suffix}")
|
||||||
|
avatar = Avatar(id=f"empty-avatar-{suffix}", owner_id=user.huihui_user_id, name="余额不足")
|
||||||
|
db.add_all([user, avatar])
|
||||||
|
db.commit()
|
||||||
|
try:
|
||||||
|
account = get_or_create_account(db, user.id)
|
||||||
|
account.balance = 1
|
||||||
|
db.commit()
|
||||||
|
with pytest.raises(InsufficientTokensError):
|
||||||
|
reserve_avatar_tokens(db, avatar, "chat", "qwen-test", [{"content": "问题"}], 128)
|
||||||
|
db.refresh(account)
|
||||||
|
assert account.balance == 1
|
||||||
|
assert db.query(TokenUsage).filter(TokenUsage.user_id == user.id).count() == 0
|
||||||
|
finally:
|
||||||
|
db.query(TokenUsage).filter(TokenUsage.user_id == user.id).delete(synchronize_session=False)
|
||||||
|
db.query(TokenAccount).filter(TokenAccount.user_id == user.id).delete(synchronize_session=False)
|
||||||
|
db.query(Avatar).filter(Avatar.id == avatar.id).delete(synchronize_session=False)
|
||||||
|
db.query(User).filter(User.id == user.id).delete(synchronize_session=False)
|
||||||
|
db.commit()
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_chat_settles_from_provider_usage_not_fallback_estimate():
|
||||||
|
suffix = uuid.uuid4().hex
|
||||||
|
db = SessionLocal()
|
||||||
|
user = User(id=f"chat-user-{suffix}", huihui_user_id=f"chat-huihui-{suffix}")
|
||||||
|
avatar = Avatar(id=f"chat-avatar-{suffix}", owner_id=user.huihui_user_id, name="聊天测试", config={})
|
||||||
|
db.add_all([user, avatar])
|
||||||
|
db.commit()
|
||||||
|
try:
|
||||||
|
with patch(
|
||||||
|
"routers.chat._call_qwen",
|
||||||
|
return_value={
|
||||||
|
"answer": "测试回答",
|
||||||
|
"usage": {"prompt_tokens": 80, "completion_tokens": 20, "total_tokens": 100},
|
||||||
|
},
|
||||||
|
):
|
||||||
|
result = _resolve_reply(
|
||||||
|
db,
|
||||||
|
avatar,
|
||||||
|
"测试问题",
|
||||||
|
[],
|
||||||
|
qa_pairs=[],
|
||||||
|
search_fn=lambda *_args: [],
|
||||||
|
)
|
||||||
|
assert result["tokenUsage"]["totalTokens"] == 100
|
||||||
|
assert result["tokenUsage"]["balance"] == DEFAULT_TOKEN_GRANT - 100
|
||||||
|
finally:
|
||||||
|
db.query(TokenUsage).filter(TokenUsage.user_id == user.id).delete(synchronize_session=False)
|
||||||
|
db.query(TokenAccount).filter(TokenAccount.user_id == user.id).delete(synchronize_session=False)
|
||||||
|
db.query(Avatar).filter(Avatar.id == avatar.id).delete(synchronize_session=False)
|
||||||
|
db.query(User).filter(User.id == user.id).delete(synchronize_session=False)
|
||||||
|
db.commit()
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_streaming_chat_settles_final_provider_usage():
|
||||||
|
suffix = uuid.uuid4().hex
|
||||||
|
db = SessionLocal()
|
||||||
|
user = User(id=f"stream-user-{suffix}", huihui_user_id=f"stream-huihui-{suffix}")
|
||||||
|
avatar = Avatar(id=f"stream-avatar-{suffix}", owner_id=user.huihui_user_id, name="流式测试", config={})
|
||||||
|
db.add_all([user, avatar])
|
||||||
|
db.commit()
|
||||||
|
try:
|
||||||
|
chunks = iter([
|
||||||
|
{"content": "流式"},
|
||||||
|
{"content": "回答"},
|
||||||
|
{"usage": {"prompt_tokens": 90, "completion_tokens": 10, "total_tokens": 100}},
|
||||||
|
])
|
||||||
|
with patch("routers.chat._iter_qwen_stream", return_value=chunks):
|
||||||
|
response = _stream_reply(db, avatar, "测试问题", [])
|
||||||
|
body = []
|
||||||
|
async for chunk in response.body_iterator:
|
||||||
|
body.append(chunk.decode() if isinstance(chunk, bytes) else chunk)
|
||||||
|
assert "流式" in "".join(body)
|
||||||
|
account = get_or_create_account(db, user.id)
|
||||||
|
usage = db.query(TokenUsage).filter(TokenUsage.user_id == user.id).one()
|
||||||
|
assert account.balance == DEFAULT_TOKEN_GRANT - 100
|
||||||
|
assert usage.source == "chat_stream"
|
||||||
|
assert usage.total_tokens == 100
|
||||||
|
finally:
|
||||||
|
db.query(TokenUsage).filter(TokenUsage.user_id == user.id).delete(synchronize_session=False)
|
||||||
|
db.query(TokenAccount).filter(TokenAccount.user_id == user.id).delete(synchronize_session=False)
|
||||||
|
db.query(Avatar).filter(Avatar.id == avatar.id).delete(synchronize_session=False)
|
||||||
|
db.query(User).filter(User.id == user.id).delete(synchronize_session=False)
|
||||||
|
db.commit()
|
||||||
|
db.close()
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import io
|
||||||
|
import json
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from services.chat_model_config import ChatModelConfig
|
||||||
|
from services.vision_service import (
|
||||||
|
ImageValidationError,
|
||||||
|
build_attachment_warning,
|
||||||
|
call_vision_model,
|
||||||
|
parse_vision_analysis,
|
||||||
|
prepare_image,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _image_bytes(fmt="PNG", size=(120, 80)):
|
||||||
|
output = io.BytesIO()
|
||||||
|
Image.new("RGB", size, "#f97316").save(output, format=fmt)
|
||||||
|
return output.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def _config():
|
||||||
|
return ChatModelConfig(
|
||||||
|
api_base_url="https://model.test/v1",
|
||||||
|
api_key="secret-key",
|
||||||
|
model="chat-model",
|
||||||
|
max_tokens=1024,
|
||||||
|
timeout_seconds=30,
|
||||||
|
vision_model="vision-model",
|
||||||
|
ocr_model="ocr-model",
|
||||||
|
vision_max_tokens=2048,
|
||||||
|
vision_timeout_seconds=90,
|
||||||
|
source="test",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_prepare_image_validates_and_reencodes_without_metadata():
|
||||||
|
prepared = prepare_image(_image_bytes())
|
||||||
|
|
||||||
|
assert prepared.mime_type == "image/jpeg"
|
||||||
|
assert prepared.width == 120
|
||||||
|
assert prepared.height == 80
|
||||||
|
with Image.open(io.BytesIO(prepared.data)) as image:
|
||||||
|
assert image.format == "JPEG"
|
||||||
|
assert not image.getexif()
|
||||||
|
|
||||||
|
|
||||||
|
def test_prepare_image_rejects_non_image_content():
|
||||||
|
with pytest.raises(ImageValidationError, match="格式无效"):
|
||||||
|
prepare_image(b"not-an-image")
|
||||||
|
|
||||||
|
|
||||||
|
def test_vision_request_uses_openai_compatible_image_content():
|
||||||
|
response = Mock()
|
||||||
|
response.raise_for_status.return_value = None
|
||||||
|
response.json.return_value = {
|
||||||
|
"choices": [{"message": {"content": '{"category":"general_image"}'}}],
|
||||||
|
"usage": {"total_tokens": 88},
|
||||||
|
}
|
||||||
|
prepared = prepare_image(_image_bytes())
|
||||||
|
|
||||||
|
with patch("services.vision_service.httpx.post", return_value=response) as request:
|
||||||
|
result = call_vision_model(
|
||||||
|
prepared,
|
||||||
|
_config(),
|
||||||
|
model="vision-model",
|
||||||
|
prompt="describe",
|
||||||
|
json_output=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = request.call_args.kwargs["json"]
|
||||||
|
content = payload["messages"][0]["content"]
|
||||||
|
assert payload["model"] == "vision-model"
|
||||||
|
assert payload["response_format"] == {"type": "json_object"}
|
||||||
|
assert content[0]["type"] == "image_url"
|
||||||
|
assert content[0]["image_url"]["url"].startswith("data:image/jpeg;base64,")
|
||||||
|
assert content[1] == {"type": "text", "text": "describe"}
|
||||||
|
assert result["usage"]["total_tokens"] == 88
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_medical_analysis_and_build_warning():
|
||||||
|
analysis = parse_vision_analysis(json.dumps({
|
||||||
|
"category": "medical_document",
|
||||||
|
"summary": "血常规报告",
|
||||||
|
"visible_text": "白细胞 11.2",
|
||||||
|
"key_facts": ["白细胞偏高"],
|
||||||
|
"uncertainties": ["日期模糊"],
|
||||||
|
"medical": {"document_type": "检验报告"},
|
||||||
|
}, ensure_ascii=False))
|
||||||
|
|
||||||
|
assert analysis["category"] == "medical_document"
|
||||||
|
assert analysis["medical"]["document_type"] == "检验报告"
|
||||||
|
warning = build_attachment_warning(analysis, ocr_failed=True)
|
||||||
|
assert "日期模糊" in warning
|
||||||
|
assert "人工核对" in warning
|
||||||
|
assert "不能替代医生诊断" in warning
|
||||||
@@ -10,6 +10,9 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
DATABASE_URL: sqlite:////data/avatar.db
|
DATABASE_URL: sqlite:////data/avatar.db
|
||||||
UPLOAD_DIR: /data/uploads
|
UPLOAD_DIR: /data/uploads
|
||||||
|
CHAT_MODEL_CONFIG_URL: http://host.docker.internal:8000/api/ai-models/runtime/digital-avatar
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
volumes:
|
volumes:
|
||||||
- avatar-data:/data
|
- avatar-data:/data
|
||||||
expose:
|
expose:
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
# 数字分身 H5 生产接入与部署
|
||||||
|
|
||||||
|
## 1. 接入方式
|
||||||
|
|
||||||
|
生产会会在用户已登录后打开以下地址:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://digital.99hui.com/#/avatar/manage?token=<encodeURIComponent(会会 access token)>
|
||||||
|
```
|
||||||
|
|
||||||
|
测试环境示例:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://192.168.1.188:8099/#/avatar/manage?token=<encodeURIComponent(token)>
|
||||||
|
```
|
||||||
|
|
||||||
|
兼容参数位于域名查询串的形式,但生产统一使用上面的 hash 路由形式。必须对 token 调用 `encodeURIComponent`,不能拼接用户 ID 代替 token。
|
||||||
|
|
||||||
|
免登录流程如下:
|
||||||
|
|
||||||
|
1. H5 在页面渲染前读取 `token`,立即通过 `history.replaceState` 从地址栏和浏览器历史中移除。
|
||||||
|
2. H5 调用 `POST /api/huihui/token/login`,不会把会会 token 当作数字分身接口 token 直接使用。
|
||||||
|
3. 后端通过会会生产接口 `/im/box/netease` 换取 BOXIM 凭证,再调用 BOXIM `/user/self` 校验用户身份。
|
||||||
|
4. 后端以返回的 `huihuiUserId` 绑定本地用户,保存会会凭证供 BOXIM 接管功能使用,并签发本系统 `app_token`。
|
||||||
|
5. 浏览器只保存 `app_token` 和非敏感用户资料。会会原始 token 不返回浏览器存储。
|
||||||
|
6. token 无效、过期或上游校验失败时清除旧会话并进入登录页,不会沿用上一位用户的缓存身份。
|
||||||
|
|
||||||
|
## 2. 生产配置
|
||||||
|
|
||||||
|
后端 `.env` 至少配置以下内容,密钥由部署平台注入,禁止提交 Git:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
HUIHUI_DEV_MOCK=false
|
||||||
|
HUIHUI_AUTH_BASE_URL=https://99hui.com/api/usercenter
|
||||||
|
HUIHUI_PLATFORM_BASE_URL=https://open.99hui.com/api
|
||||||
|
BOXIM_API_BASE_URL=https://im.99hui.com/api
|
||||||
|
HUIHUI_APP_ID=<production-app-id>
|
||||||
|
HUIHUI_ACCESS_ID=<production-access-id>
|
||||||
|
HUIHUI_ACCESS_SECRET=<production-access-secret>
|
||||||
|
HUIHUI_CLIENT_CODE=<production-client-code>
|
||||||
|
BOXIM_TIMEOUT_SECONDS=20
|
||||||
|
BOXIM_POLL_CONCURRENCY=8
|
||||||
|
BOXIM_MAX_MESSAGE_AGE_SECONDS=600
|
||||||
|
HUIHUI_PAYMENT_BASE_URL=https://open.99hui.com/api/payment-v3
|
||||||
|
HUIHUI_PAYMENT_CALLBACK_BASE_URL=https://digital.99hui.com
|
||||||
|
HUIHUI_PAYMENT_CALLBACK_SECRET=<至少32位随机密钥>
|
||||||
|
HUIHUI_PAYMENT_TIMEOUT_SECONDS=30
|
||||||
|
|
||||||
|
DATABASE_URL=sqlite:////data/avatar.db
|
||||||
|
UPLOAD_DIR=/data/uploads
|
||||||
|
CHAT_MODEL_CONFIG_URL=http://<huihuisquare-api>/api/ai-models/runtime/digital-avatar
|
||||||
|
EMBEDDING_API_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||||
|
EMBEDDING_API_KEY=<production-embedding-api-key>
|
||||||
|
EMBEDDING_MODEL=text-embedding-v3
|
||||||
|
EMBEDDING_BATCH_SIZE=10
|
||||||
|
|
||||||
|
VISION_MODEL=qwen3.6-flash
|
||||||
|
VISION_OCR_MODEL=qwen-vl-ocr
|
||||||
|
VISION_MAX_OUTPUT_TOKENS=2048
|
||||||
|
VISION_TIMEOUT_SECONDS=90
|
||||||
|
VISION_TOKEN_RESERVE=12000
|
||||||
|
CHAT_IMAGE_MAX_BYTES=8388608
|
||||||
|
CHAT_IMAGE_MAX_PIXELS=16000000
|
||||||
|
CHAT_ATTACHMENT_RETENTION_HOURS=24
|
||||||
|
CHAT_ATTACHMENT_CLEANUP_MINUTES=60
|
||||||
|
```
|
||||||
|
|
||||||
|
如生产 AI 配置中心不可用,还应提供当前项目支持的 `OPENAI_API_KEY`、`OPENAI_BASE_URL`、`CHAT_MODEL` 等兜底配置。`/data` 必须挂载持久卷,数据库与知识库文件不可存放在容器临时层。
|
||||||
|
|
||||||
|
`EMBEDDING_API_URL` 同时支持 OpenAI 兼容基础地址(如上面的 `/v1`)和完整的 `/v1/embeddings` 地址,后端会统一请求 `/embeddings`。发布后必须在后端容器内执行一次最小向量探针,确认返回向量数量和维度,而不能只检查 `/api/health`。
|
||||||
|
|
||||||
|
积分充值使用会会支付体系的 `payment-v3/payment/pay`,渠道值为 `WECHAT` / `ALIPAY`,端内支付场景为 `APP`,微信内 H5 使用 `JSAPI`。`HUIHUI_PAYMENT_CALLBACK_SECRET` 只用于为每笔订单生成 HMAC 回调签名,不会发送到前端或直接出现在回调地址中。支付回调确认状态成功且金额与套餐价格完全一致后才增加积分,重复回调不会重复到账。
|
||||||
|
|
||||||
|
## 3. 构建与发布
|
||||||
|
|
||||||
|
首次发布前备份数据:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BACKUP_DIR="backups/$(date +%Y%m%d-%H%M%S)"
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
cp /srv/digital-avatar/data/avatar.db "$BACKUP_DIR/"
|
||||||
|
tar -C /srv/digital-avatar/data -czf "$BACKUP_DIR/uploads.tgz" uploads
|
||||||
|
```
|
||||||
|
|
||||||
|
在发布目录执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git fetch origin
|
||||||
|
git checkout <已验收的提交SHA>
|
||||||
|
cd digital-avatar-app
|
||||||
|
docker compose build --pull avatar-backend avatar-frontend
|
||||||
|
docker compose up -d avatar-backend avatar-frontend
|
||||||
|
docker compose ps
|
||||||
|
curl -fsS http://127.0.0.1:8099/api/health
|
||||||
|
docker compose exec avatar-backend python -c 'import embeddings; v=embeddings.embed(["部署向量探针"]); print(len(v), len(v[0]))'
|
||||||
|
```
|
||||||
|
|
||||||
|
生产编排应把示例中的测试端口改为内网暴露,由统一 HTTPS 网关接入。后端暂时使用 SQLite,必须保持单实例写入;若扩展为多后端实例,应先迁移到 PostgreSQL,并把延迟接管任务改为共享队列。
|
||||||
|
|
||||||
|
## 4. 网关要求
|
||||||
|
|
||||||
|
必须使用 HTTPS。同域部署时,H5 静态资源与 `/api/` 由同一域名提供,可避免跨域和 Cookie/来源策略问题。Nginx 关键配置示例:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server_name digital.99hui.com;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://avatar-backend:8000;
|
||||||
|
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_buffering off;
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
client_max_body_size 100m;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`proxy_buffering off` 用于数字分身 SSE 流式吐字,`client_max_body_size` 同时用于知识库文件和聊天图片上传。应用只保存图片识别结果,不保存原图;识别结果 24 小时失效,后台默认每小时清理一次。公开分享图片识别会消耗分身所有者积分,生产网关应针对 `/api/public/avatar/*/chat/images` 设置每 IP 和每分享令牌的上传频率限制,防止恶意消耗。
|
||||||
|
|
||||||
|
网关和应用日志必须关闭完整 URL 查询参数记录,任何异常日志都不得输出 token、Authorization、图片 Base64、病例正文或平台密钥。建议同时设置严格的 `Referrer-Policy: no-referrer`。
|
||||||
|
|
||||||
|
## 5. 发布验收
|
||||||
|
|
||||||
|
1. 已登录会会用户通过带 token 链接打开后直接进入 `/avatar/manage`,不出现登录页或创建新账号页。
|
||||||
|
2. 页面加载后地址栏中不再包含 `token`,刷新页面仍使用本地 `app_token` 正常访问。
|
||||||
|
3. 后端用户绑定的是 BOXIM 返回的 `huihuiUserId`,不是 BOXIM 内部 `id`;原有数字分身、独立知识库和积分余额均存在。
|
||||||
|
4. A、B 两个会会用户分别进入时只能看到各自的数字分身与知识库,不会继承上一用户缓存。
|
||||||
|
5. 使用过期或伪造 token 时进入登录页并显示凭证失效,不得继续访问旧用户数据。
|
||||||
|
6. 分身聊天 SSE 逐段输出正常,Markdown 正常渲染,知识库优先级和积分扣费正常。
|
||||||
|
7. 开启 BOXIM 主动接管后保持在线,默认三分钟回复、自定义等待时间、已读回执、分身防回环和主人发言暂停均正常。
|
||||||
|
8. 重建容器后数据库、头像、知识库文档仍存在,`/api/health` 返回成功。
|
||||||
|
9. `https://digital.99hui.com/api/health` 可访问,证书域名和有效期正确,HTTP 自动跳转 HTTPS。
|
||||||
|
10. 微信和支付宝各创建一笔最小套餐订单,未付款时积分不变;支付成功后回调到账一次,重复回调积分不重复增加。
|
||||||
|
11. 私聊和公开分享各上传 JPG、PNG、WebP 图片并完成追问;上传非图片、超过 8MB 或跨分身附件时必须拒绝。
|
||||||
|
12. 病例图片可以提取可见文字并标记待核对内容,医学影像不作确定诊断;视觉与 OCR 调用分别扣减积分。
|
||||||
|
13. 检查服务器上传目录不残留聊天原图,数据库过期图片识别记录在清理周期后删除,日志不出现 Base64 或病例正文。
|
||||||
|
|
||||||
|
## 6. 回滚
|
||||||
|
|
||||||
|
保留上一版前后端镜像标签和发布前数据库/上传文件备份。代码回滚优先切回上一镜像;只有新版本执行了不可逆数据变更时才恢复数据库。恢复前先停止后端写入,恢复后对比用户数、分身数、知识库文档数并完成一次免登录和聊天验收。
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
# 数字分身图片与病例理解详细设计
|
||||||
|
|
||||||
|
## 1. 目标与边界
|
||||||
|
|
||||||
|
本功能让数字分身在私聊和公开分享聊天中接收图片,并围绕图片内容继续使用现有的“标准答题对 -> 分身独立知识库 -> Qwen 兼容模型”链路回答。
|
||||||
|
|
||||||
|
第一期支持 JPEG、PNG、WebP,覆盖以下场景:
|
||||||
|
|
||||||
|
1. 普通照片、截图、图表和界面图片的内容理解。
|
||||||
|
2. 病例、处方、检查单、检验报告等图片文档的文字和表格提取。
|
||||||
|
3. X 光、CT、MRI 等医学影像的客观可见内容描述。
|
||||||
|
|
||||||
|
第一期不把通用视觉模型的输出当作医学诊断,不自动把图片或病例写入知识库,不保存原图供长期访问,也不支持 DICOM 原始影像。
|
||||||
|
|
||||||
|
## 2. 核心原则
|
||||||
|
|
||||||
|
- **资料优先级不变**:标准答题对最高,分身独立知识库其次,图片识别结果属于待核对的会话资料,最后才由模型组织表达。
|
||||||
|
- **病例最小留存**:应用不把原图写入业务存储,上传内容在内存中归一化并调用视觉服务;数据库只保存结构化结果和必要元数据。
|
||||||
|
- **严格隔离**:每条图片记录必须绑定 `avatar_id`,私聊校验分身所有者,公开聊天校验分享令牌对应的分身。
|
||||||
|
- **不确定性显式化**:OCR 看不清、表格列错位、医学影像无法确认时必须指出待核对项,不允许补齐缺失内容。
|
||||||
|
- **可计量**:视觉理解和病例 OCR 分别计入分身所有者的积分消耗,失败时释放预留积分。
|
||||||
|
- **可降级**:OCR 失败但通用视觉结果有效时仍可回答;视觉主调用失败则不进入聊天发送。
|
||||||
|
|
||||||
|
## 3. 总体流程
|
||||||
|
|
||||||
|
```text
|
||||||
|
用户选择图片
|
||||||
|
-> 前端本地预览
|
||||||
|
-> 私聊/公开图片上传接口
|
||||||
|
-> 文件大小、MIME、真实格式、像素数校验
|
||||||
|
-> 自动旋转、缩放、去 EXIF、统一 JPEG
|
||||||
|
-> 通用视觉模型分类并输出结构化 JSON
|
||||||
|
-> 若为病例/检查单,再调用 OCR 模型精确转录
|
||||||
|
-> 保存结构化结果,不持久化原图
|
||||||
|
-> 返回 attachmentId
|
||||||
|
-> 用户发送文字 + attachmentIds
|
||||||
|
-> 标准答题对匹配
|
||||||
|
-> 用文字 + 图片提取结果检索独立知识库
|
||||||
|
-> 把标准答案、知识片段、图片资料注入系统上下文
|
||||||
|
-> Qwen SSE 流式回答
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 模型编排
|
||||||
|
|
||||||
|
### 4.1 通用视觉模型
|
||||||
|
|
||||||
|
默认 `qwen3.6-flash`,可在后台数字分身专用模型配置中修改。输入为归一化后的 Base64 Data URL,要求返回 JSON:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"category": "general_image|document|medical_document|medical_image",
|
||||||
|
"summary": "客观、完整的图片描述",
|
||||||
|
"visible_text": "图片中可确认的文字",
|
||||||
|
"key_facts": ["事实1", "事实2"],
|
||||||
|
"uncertainties": ["无法确认的内容"],
|
||||||
|
"medical": {
|
||||||
|
"document_type": "",
|
||||||
|
"patient_info": {},
|
||||||
|
"chief_complaint": "",
|
||||||
|
"findings": [],
|
||||||
|
"measurements": [],
|
||||||
|
"doctor_advice": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
模型提示词禁止诊断、补全被遮挡文字、猜测患者身份和输出模型信息。
|
||||||
|
|
||||||
|
### 4.2 病例 OCR
|
||||||
|
|
||||||
|
当 `category=medical_document` 时追加调用 `qwen-vl-ocr`,按原布局转录文字和表格。OCR 文本优先替换通用视觉输出中的 `visible_text`,但保留通用视觉模型提供的分类、摘要和不确定项。
|
||||||
|
|
||||||
|
### 4.3 医学影像
|
||||||
|
|
||||||
|
当 `category=medical_image` 时只保存客观描述,不输出疾病结论、分期、用药或治疗方案。聊天提示词必须要求结合正规影像报告和医生意见,并显示“图片识别结果仅供辅助,不能替代医生诊断”。
|
||||||
|
|
||||||
|
## 5. 数据模型
|
||||||
|
|
||||||
|
新增 `chat_attachments`:
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `id` | 不可猜测的附件 ID |
|
||||||
|
| `avatar_id` | 所属数字分身,强制隔离 |
|
||||||
|
| `filename` | 原文件名,去除路径 |
|
||||||
|
| `mime_type` / `file_size` | 上传元数据 |
|
||||||
|
| `status` | `processing / ready / failed` |
|
||||||
|
| `category` | 图片分类 |
|
||||||
|
| `summary` | 通用视觉摘要 |
|
||||||
|
| `extracted_text` | 可确认文字/OCR 结果 |
|
||||||
|
| `structured_data` | 结构化 JSON |
|
||||||
|
| `warning` | 不确定项和医学提示 |
|
||||||
|
| `vision_model` / `ocr_model` | 实际调用模型 |
|
||||||
|
| `created_at` / `used_at` | 创建和最近使用时间 |
|
||||||
|
|
||||||
|
不保存公开原图 URL。应用层不落盘原图;框架上传缓冲在请求结束时关闭,处理结果在 24 小时后自动清理。
|
||||||
|
|
||||||
|
## 6. API 设计
|
||||||
|
|
||||||
|
### 6.1 上传并解析
|
||||||
|
|
||||||
|
- `POST /api/avatar/{avatar_id}/chat/images`
|
||||||
|
- `POST /api/public/avatar/{share_token}/chat/images`
|
||||||
|
- `multipart/form-data: file`
|
||||||
|
|
||||||
|
成功返回:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "attachment-id",
|
||||||
|
"filename": "病例.jpg",
|
||||||
|
"status": "ready",
|
||||||
|
"category": "medical_document",
|
||||||
|
"summary": "门诊检查单",
|
||||||
|
"warning": "部分手写内容需要人工核对"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 聊天
|
||||||
|
|
||||||
|
原聊天接口增加:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "请帮我看看异常指标",
|
||||||
|
"attachmentIds": ["attachment-id"],
|
||||||
|
"history": [
|
||||||
|
{"role": "user", "content": "上一条问题", "attachmentIds": ["attachment-id"]}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
当前消息最多 3 张图,历史最多引用最近 3 个不同附件。后端只读取与当前 `avatar_id` 相同且状态为 `ready` 的记录。
|
||||||
|
|
||||||
|
## 7. 安全与隐私
|
||||||
|
|
||||||
|
- 单图最大 8MB,解码后最大 1600 万像素,最长边归一化到 4096 像素以内。
|
||||||
|
- 使用 Pillow 验证真实图片格式并防止解压炸弹;重新编码时清除 EXIF、GPS 和其他元数据。
|
||||||
|
- 图片不会写入 FastAPI `StaticFiles` 或知识库目录,模型请求和日志不得输出 Base64 内容。
|
||||||
|
- 日志只记录附件 ID、分身 ID、状态、耗时和模型,不记录图片 Base64、OCR 全文、病例内容或 API Key。
|
||||||
|
- 公开分享上传仍消耗分身所有者积分;余额不足时拒绝视觉调用。
|
||||||
|
- 生产环境需要补充用户授权、数据处理协议、存储地域和模型供应商留存策略确认。
|
||||||
|
|
||||||
|
## 8. 前端交互
|
||||||
|
|
||||||
|
- 输入框左侧增加图片按钮,支持相册选择和移动端拍照。
|
||||||
|
- 选择后显示本地缩略图和“正在识别图片”,识别完成前禁止发送。
|
||||||
|
- 用户可删除待发送图片;发送后图片保留在当前会话气泡中,但刷新页面后不恢复原图。
|
||||||
|
- 病例和医学影像在输入区及回答下方显示辅助提示,不使用恐吓式红色告警。
|
||||||
|
- 上传或识别失败时保留文字输入,明确提示重新选择图片,不产生空白消息。
|
||||||
|
|
||||||
|
## 9. 配置
|
||||||
|
|
||||||
|
数字分身专用模型配置新增:
|
||||||
|
|
||||||
|
- `vision_model_version`,默认 `qwen3.6-flash`
|
||||||
|
- `ocr_model_version`,默认 `qwen-vl-ocr`
|
||||||
|
|
||||||
|
环境变量兜底:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
VISION_MODEL=qwen3.6-flash
|
||||||
|
VISION_OCR_MODEL=qwen-vl-ocr
|
||||||
|
VISION_MAX_OUTPUT_TOKENS=2048
|
||||||
|
VISION_TIMEOUT_SECONDS=90
|
||||||
|
VISION_TOKEN_RESERVE=12000
|
||||||
|
CHAT_IMAGE_MAX_BYTES=8388608
|
||||||
|
CHAT_IMAGE_MAX_PIXELS=16000000
|
||||||
|
CHAT_ATTACHMENT_RETENTION_HOURS=24
|
||||||
|
CHAT_ATTACHMENT_CLEANUP_MINUTES=60
|
||||||
|
```
|
||||||
|
|
||||||
|
视觉调用复用数字分身专用配置的 `api_base_url` 和 `api_key`,不额外复制密钥。
|
||||||
|
|
||||||
|
## 10. 验收标准
|
||||||
|
|
||||||
|
1. 普通照片、截图和图表能够返回与图片一致的描述并支持追问。
|
||||||
|
2. 病例图片可以提取标题、患者字段、检查结果、异常指标和医生意见,模糊内容明确标记待核对。
|
||||||
|
3. 上传后服务器业务目录不残留原图,响应和日志不包含 Base64 或完整病例正文。
|
||||||
|
4. A 分身无法引用 B 分身附件;公开分享令牌无法访问其他分身附件。
|
||||||
|
5. 有图片时标准答题对仍作为最高优先级事实,知识库命中次之。
|
||||||
|
6. 视觉与 OCR 积分分别结算,失败调用释放预留积分。
|
||||||
|
7. SSE 打字效果、Markdown、用户头像、公开分享和纯文本聊天均无回归。
|
||||||
|
8. CT、MRI、X 光回答不作确定诊断,并显示人工复核提示。
|
||||||
@@ -7,10 +7,9 @@
|
|||||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
|
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
|
||||||
/>
|
/>
|
||||||
<title>会会数字分身</title>
|
<title>会会数字分身</title>
|
||||||
<!-- uniapp web-view 桥接:加载后全局出现 window.uni.webView,H5 才能与原生壳通信 -->
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<script type="text/javascript" src="https://unpkg.com/@dcloudio/uni-webview-js@0.0.10/index.js"></script>
|
<!-- digital.99hui.com 同域部署保持 apiBase 为空,接口使用 /api。
|
||||||
<!-- 混合架构部署配置:web-view 内请把 apiBase 设为后端公网地址(如 'https://geo.99hui.com/api')。
|
仅跨域部署时才显式配置后端公网地址。 -->
|
||||||
留空则回退为 '/api'(开发态由 Vite 代理到 :8000)。 -->
|
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
window.__APP_CONFIG__ = { apiBase: '' }
|
window.__APP_CONFIG__ = { apiBase: '' }
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ http {
|
|||||||
|
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
# Keep the application gateway aligned with the production edge gateway.
|
||||||
|
# Without this Nginx rejects ordinary PDF uploads with HTTP 413 before
|
||||||
|
# FastAPI can return its user-facing file-size validation message.
|
||||||
|
client_max_body_size 100m;
|
||||||
|
|
||||||
# SPA 兜底(hash 路由下深链接也可正常加载)
|
# SPA 兜底(hash 路由下深链接也可正常加载)
|
||||||
location / {
|
location / {
|
||||||
|
|||||||
Generated
+7
@@ -8,6 +8,7 @@
|
|||||||
"name": "digital-avatar-app",
|
"name": "digital-avatar-app",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dcloudio/uni-webview-js": "^0.0.3",
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
"pinia": "^2.1.0",
|
"pinia": "^2.1.0",
|
||||||
"vue": "^3.3.0",
|
"vue": "^3.3.0",
|
||||||
@@ -66,6 +67,12 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@dcloudio/uni-webview-js": {
|
||||||
|
"version": "0.0.3",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@dcloudio/uni-webview-js/-/uni-webview-js-0.0.3.tgz",
|
||||||
|
"integrity": "sha512-3XDnLXg22QKC/aTLtGim6zbOK+euiDYoRf98BjGDE0lqMm1U4SLFHAvOlAHa0qck91phaceKwo73ScQQgSojQg==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.21.5",
|
"version": "0.21.5",
|
||||||
"resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
|
"resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dcloudio/uni-webview-js": "^0.0.3",
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
"pinia": "^2.1.0",
|
"pinia": "^2.1.0",
|
||||||
"vue": "^3.3.0",
|
"vue": "^3.3.0",
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
8f54097b01d5b0433f8aa5ea70ca930b
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
8f54097b01d5b0433f8aa5ea70ca930b
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="avatar-gradient" x1="8" y1="8" x2="56" y2="56" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#fb923c"/>
|
||||||
|
<stop offset="1" stop-color="#f97316"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="64" height="64" rx="16" fill="url(#avatar-gradient)"/>
|
||||||
|
<circle cx="32" cy="24" r="10" fill="#fff"/>
|
||||||
|
<path d="M15 52c1.8-10 8-15 17-15s15.2 5 17 15" fill="#fff"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 474 B |
@@ -1,73 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
<router-view />
|
<router-view />
|
||||||
<!-- 底部导航栏 -->
|
<!-- 底部菜单暂不展示,多分身能力统一从分身卡片进入。 -->
|
||||||
<nav class="bottom-nav" v-if="showNav">
|
|
||||||
<button
|
|
||||||
class="nav-item"
|
|
||||||
:class="{ active: currentRoute === '/' || currentRoute === '/avatar/manage' }"
|
|
||||||
@click="navigateTo('/avatar/manage')"
|
|
||||||
>
|
|
||||||
<span class="nav-icon">🤖</span>
|
|
||||||
<span class="nav-label">我的分身</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="nav-item"
|
|
||||||
:class="{ active: currentRoute === '/authorization' }"
|
|
||||||
@click="navigateTo('/authorization')"
|
|
||||||
>
|
|
||||||
<span class="nav-icon">🔑</span>
|
|
||||||
<span class="nav-label">授权管理</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="nav-item"
|
|
||||||
:class="{ active: currentRoute === '/token/charge' }"
|
|
||||||
@click="navigateTo('/token/charge')"
|
|
||||||
>
|
|
||||||
<span class="nav-icon">💰</span>
|
|
||||||
<span class="nav-label">Token</span>
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, onMounted, watch } from 'vue'
|
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const route = useRoute()
|
|
||||||
|
|
||||||
const currentRoute = ref<string>(route.path)
|
|
||||||
const showNav = ref<boolean>(shouldShowNav(route.path))
|
|
||||||
|
|
||||||
function shouldShowNav(path: string) {
|
|
||||||
return path !== '/'
|
|
||||||
&& path !== '/authorization'
|
|
||||||
&& path !== '/avatar/create'
|
|
||||||
&& path !== '/login/sms'
|
|
||||||
&& !path.startsWith('/avatar/edit')
|
|
||||||
&& !path.startsWith('/avatar/chat')
|
|
||||||
&& !path.startsWith('/share/')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 监听路由变化
|
|
||||||
watch(() => route.path, (newPath) => {
|
|
||||||
currentRoute.value = newPath
|
|
||||||
showNav.value = shouldShowNav(newPath)
|
|
||||||
})
|
|
||||||
|
|
||||||
// 导航
|
|
||||||
const navigateTo = (path: string) => {
|
|
||||||
router.push(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
currentRoute.value = route.path
|
|
||||||
showNav.value = shouldShowNav(route.path)
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
* {
|
* {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -81,48 +18,4 @@ onMounted(() => {
|
|||||||
background: #F8F9FA;
|
background: #F8F9FA;
|
||||||
padding-bottom: env(safe-area-inset-bottom);
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 底部导航栏 */
|
|
||||||
.bottom-nav {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
display: flex;
|
|
||||||
background: white;
|
|
||||||
border-top: 1px solid #EDEEF1;
|
|
||||||
padding-bottom: env(safe-area-inset-bottom);
|
|
||||||
z-index: 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 8px 0;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-icon {
|
|
||||||
font-size: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-label {
|
|
||||||
font-size: 11px;
|
|
||||||
color: #9398AE;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item.active .nav-label {
|
|
||||||
color: #F97316;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item.active .nav-icon {
|
|
||||||
filter: none;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ interface ApiClient {
|
|||||||
|
|
||||||
// API 基址:优先级 window.__APP_CONFIG__.apiBase > 环境变量 > 默认 '/api'
|
// API 基址:优先级 window.__APP_CONFIG__.apiBase > 环境变量 > 默认 '/api'
|
||||||
// - 开发/Vite 代理:'/api'(由 vite.config 代理到后端 :8000)
|
// - 开发/Vite 代理:'/api'(由 vite.config 代理到后端 :8000)
|
||||||
// - web-view 内(混合架构):需配置为后端公网地址,例如 'https://geo.99hui.com/api'
|
// - digital.99hui.com 同域部署:保持 '/api'
|
||||||
// - 同域部署的构建产物:可保持 '/api'
|
// - 仅跨域部署时配置后端公网地址
|
||||||
function resolveBaseURL(): string {
|
function resolveBaseURL(): string {
|
||||||
const cfg = (window as any).__APP_CONFIG__
|
const cfg = (window as any).__APP_CONFIG__
|
||||||
if (cfg && cfg.apiBase) return cfg.apiBase as string
|
if (cfg && cfg.apiBase) return cfg.apiBase as string
|
||||||
@@ -131,17 +131,58 @@ export const deleteAvatar = (id: string) =>
|
|||||||
|
|
||||||
// ==================== Token 管理 API ====================
|
// ==================== Token 管理 API ====================
|
||||||
|
|
||||||
|
export interface TokenBalance {
|
||||||
|
balance: number
|
||||||
|
totalGranted: number
|
||||||
|
totalConsumed: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenUsageSummary {
|
||||||
|
avatarId: string
|
||||||
|
source: string
|
||||||
|
promptTokens: number
|
||||||
|
completionTokens: number
|
||||||
|
totalTokens: number
|
||||||
|
requestCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenPaymentOrder {
|
||||||
|
id: string
|
||||||
|
orderNo: string
|
||||||
|
planId: string
|
||||||
|
paymentMethod: 'wechat' | 'alipay'
|
||||||
|
payType: 'WECHAT' | 'ALIPAY'
|
||||||
|
payWay: 'APP' | 'LITE' | 'JSAPI'
|
||||||
|
pointsAmount: number
|
||||||
|
price: number
|
||||||
|
status: 'pending' | 'paid' | 'failed'
|
||||||
|
providerStatus: string
|
||||||
|
payMessage: string
|
||||||
|
failureReason: string
|
||||||
|
balance: number
|
||||||
|
}
|
||||||
|
|
||||||
// 获取 Token 余额
|
// 获取 Token 余额
|
||||||
export const getTokenBalance = () =>
|
export const getTokenBalance = () =>
|
||||||
request.get<{ balance: number }>('/token/balance')
|
request.get<TokenBalance>('/token/balance')
|
||||||
|
|
||||||
// 获取充值套餐
|
// 获取充值套餐
|
||||||
export const getRechargePlans = () =>
|
export const getRechargePlans = () =>
|
||||||
request.get<Array<{ id: string; name: string; amount: number; price: number }>>('/token/plans')
|
request.get<Array<{ id: string; name: string; amount: number; price: number }>>('/token/plans')
|
||||||
|
|
||||||
// 执行充值
|
// 创建会会支付订单;支付回调成功后积分到账。
|
||||||
export const chargeToken = (planId: string) =>
|
export const chargeToken = (
|
||||||
request.post<{ balance: number; charged: number }>('/token/charge', { planId })
|
planId: string,
|
||||||
|
paymentMethod: 'wechat' | 'alipay',
|
||||||
|
payScene: 'APP' | 'LITE' | 'JSAPI'
|
||||||
|
) => request.post<TokenPaymentOrder>('/token/charge', { planId, paymentMethod, payScene })
|
||||||
|
|
||||||
|
export const getTokenPaymentStatus = (orderId: string) =>
|
||||||
|
request.get<TokenPaymentOrder>(`/token/payment/${orderId}`)
|
||||||
|
|
||||||
|
// 按分身和使用场景汇总 Token 消耗
|
||||||
|
export const getTokenUsage = () =>
|
||||||
|
request.get<TokenUsageSummary[]>('/token/usage')
|
||||||
|
|
||||||
// ==================== 授权管理 API ====================
|
// ==================== 授权管理 API ====================
|
||||||
|
|
||||||
@@ -150,19 +191,28 @@ export type AvatarPermission = 'friend' | 'chat' | 'publish' | 'browse' | 'inter
|
|||||||
export interface AvatarPermissionSettings {
|
export interface AvatarPermissionSettings {
|
||||||
avatarId: string
|
avatarId: string
|
||||||
permissions: AvatarPermission[]
|
permissions: AvatarPermission[]
|
||||||
|
takeoverReplyDelaySeconds: number
|
||||||
|
disabledAvatarIds?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getAvatarPermissionSettings = (avatarId: string) =>
|
export const getAvatarPermissionSettings = (avatarId: string) =>
|
||||||
request.get<AvatarPermissionSettings>(`/avatar/${avatarId}/permission-settings`)
|
request.get<AvatarPermissionSettings>(`/avatar/${avatarId}/permission-settings`)
|
||||||
|
|
||||||
export const updateAvatarPermissionSettings = (avatarId: string, permissions: AvatarPermission[]) =>
|
export const updateAvatarPermissionSettings = (
|
||||||
request.put<AvatarPermissionSettings>(`/avatar/${avatarId}/permission-settings`, { permissions })
|
avatarId: string,
|
||||||
|
permissions: AvatarPermission[],
|
||||||
|
takeoverReplyDelaySeconds: number
|
||||||
|
) => request.put<AvatarPermissionSettings>(`/avatar/${avatarId}/permission-settings`, {
|
||||||
|
permissions,
|
||||||
|
takeoverReplyDelaySeconds,
|
||||||
|
})
|
||||||
|
|
||||||
export interface TakeoverStatus {
|
export interface TakeoverStatus {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
status: 'disabled' | 'connecting' | 'ready' | 'needs_login' | 'error'
|
status: 'disabled' | 'connecting' | 'ready' | 'needs_login' | 'error'
|
||||||
message: string
|
message: string
|
||||||
pendingCount: number
|
pendingCount: number
|
||||||
|
takeoverReplyDelaySeconds: number
|
||||||
lastPolledAt: string | null
|
lastPolledAt: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,6 +305,9 @@ export interface KnowledgeDoc {
|
|||||||
vectorized?: boolean
|
vectorized?: boolean
|
||||||
embeddingModel?: string
|
embeddingModel?: string
|
||||||
chunkCount?: number
|
chunkCount?: number
|
||||||
|
errorMessage?: string
|
||||||
|
indexStage?: string
|
||||||
|
indexProgress?: number
|
||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,12 +333,83 @@ export interface SearchResult {
|
|||||||
export const getKnowledgeDocs = (avatarId: string) =>
|
export const getKnowledgeDocs = (avatarId: string) =>
|
||||||
request.get<KnowledgeDoc[]>(`/avatar/${avatarId}/knowledge/docs`)
|
request.get<KnowledgeDoc[]>(`/avatar/${avatarId}/knowledge/docs`)
|
||||||
|
|
||||||
// 上传文档(支持 md/txt/pdf/doc/docx/xlsx)
|
const KNOWLEDGE_UPLOAD_CHUNK_SIZE = 5 * 1024 * 1024
|
||||||
export const uploadKnowledgeDoc = (avatarId: string, file: File) => {
|
|
||||||
|
const uploadKnowledgeChunk = async (
|
||||||
|
avatarId: string,
|
||||||
|
uploadId: string,
|
||||||
|
chunkIndex: number,
|
||||||
|
chunk: Blob,
|
||||||
|
onProgress?: (loaded: number) => void
|
||||||
|
) => {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file', chunk, `chunk-${chunkIndex}`)
|
||||||
|
let reportedLoaded = 0
|
||||||
|
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||||||
|
try {
|
||||||
|
await request.post(
|
||||||
|
`/avatar/${avatarId}/knowledge/uploads/${uploadId}/chunks/${chunkIndex}`,
|
||||||
|
form,
|
||||||
|
{
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
timeout: 2 * 60 * 1000,
|
||||||
|
onUploadProgress: (event) => {
|
||||||
|
reportedLoaded = Math.max(reportedLoaded, Math.min(event.loaded, chunk.size))
|
||||||
|
onProgress?.(reportedLoaded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return
|
||||||
|
} catch (error: any) {
|
||||||
|
const status = Number(error?.response?.status || 0)
|
||||||
|
const retryable = !status || status === 408 || status === 429 || status >= 500
|
||||||
|
if (!retryable || attempt === 3) throw error
|
||||||
|
await new Promise((resolve) => window.setTimeout(resolve, attempt * 800))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 大文件拆成 5MB 分片,避免生产代理的请求体限制拦截整个文件。
|
||||||
|
export const uploadKnowledgeDoc = async (
|
||||||
|
avatarId: string,
|
||||||
|
file: File,
|
||||||
|
onUploadProgress?: (loaded: number, total: number) => void
|
||||||
|
) => {
|
||||||
|
if (file.size > KNOWLEDGE_UPLOAD_CHUNK_SIZE) {
|
||||||
|
const totalChunks = Math.ceil(file.size / KNOWLEDGE_UPLOAD_CHUNK_SIZE)
|
||||||
|
const upload: any = await request.post(`/avatar/${avatarId}/knowledge/uploads`, {
|
||||||
|
filename: file.name,
|
||||||
|
fileSize: file.size,
|
||||||
|
totalChunks
|
||||||
|
})
|
||||||
|
let uploadedBytes = 0
|
||||||
|
for (let index = 0; index < totalChunks; index += 1) {
|
||||||
|
const start = index * KNOWLEDGE_UPLOAD_CHUNK_SIZE
|
||||||
|
const chunk = file.slice(start, Math.min(start + KNOWLEDGE_UPLOAD_CHUNK_SIZE, file.size))
|
||||||
|
await uploadKnowledgeChunk(
|
||||||
|
avatarId,
|
||||||
|
upload.uploadId,
|
||||||
|
index,
|
||||||
|
chunk,
|
||||||
|
(chunkLoaded) => onUploadProgress?.(uploadedBytes + chunkLoaded, file.size)
|
||||||
|
)
|
||||||
|
uploadedBytes += chunk.size
|
||||||
|
onUploadProgress?.(uploadedBytes, file.size)
|
||||||
|
}
|
||||||
|
return request.post<KnowledgeDoc>(
|
||||||
|
`/avatar/${avatarId}/knowledge/uploads/${upload.uploadId}/complete`,
|
||||||
|
undefined,
|
||||||
|
{ timeout: 2 * 60 * 1000 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const form = new FormData()
|
const form = new FormData()
|
||||||
form.append('file', file)
|
form.append('file', file)
|
||||||
return request.post<KnowledgeDoc>(`/avatar/${avatarId}/knowledge/docs`, form, {
|
return request.post<KnowledgeDoc>(`/avatar/${avatarId}/knowledge/docs`, form, {
|
||||||
headers: { 'Content-Type': 'multipart/form-data' }
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
// A slow mobile uplink must not be mistaken for a failed upload.
|
||||||
|
timeout: 10 * 60 * 1000,
|
||||||
|
onUploadProgress: (event) => onUploadProgress?.(event.loaded, event.total || file.size)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,6 +417,9 @@ export const uploadKnowledgeDoc = (avatarId: string, file: File) => {
|
|||||||
export const deleteKnowledgeDoc = (avatarId: string, docId: string) =>
|
export const deleteKnowledgeDoc = (avatarId: string, docId: string) =>
|
||||||
request.delete(`/avatar/${avatarId}/knowledge/docs/${docId}`)
|
request.delete(`/avatar/${avatarId}/knowledge/docs/${docId}`)
|
||||||
|
|
||||||
|
export const retryKnowledgeDoc = (avatarId: string, docId: string) =>
|
||||||
|
request.post<KnowledgeDoc>(`/avatar/${avatarId}/knowledge/docs/${docId}/retry`)
|
||||||
|
|
||||||
// 标准问答对列表
|
// 标准问答对列表
|
||||||
export const getQAPairs = (avatarId: string) =>
|
export const getQAPairs = (avatarId: string) =>
|
||||||
request.get<QAPair[]>(`/avatar/${avatarId}/knowledge/qa`)
|
request.get<QAPair[]>(`/avatar/${avatarId}/knowledge/qa`)
|
||||||
@@ -324,15 +451,35 @@ export const searchKnowledge = (avatarId: string, q: string, topK = 5) =>
|
|||||||
export interface ChatMessage {
|
export interface ChatMessage {
|
||||||
role: 'user' | 'assistant'
|
role: 'user' | 'assistant'
|
||||||
content: string
|
content: string
|
||||||
|
attachmentIds?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatResponse {
|
export interface ChatResponse {
|
||||||
answer: string
|
answer: string
|
||||||
source: 'qa' | 'knowledge' | 'qwen'
|
source: 'qa' | 'knowledge' | 'vision' | 'qwen'
|
||||||
references?: Array<{ docId?: string; filename?: string; fileType?: string; snippet?: string; score?: number }>
|
references?: Array<{ docId?: string; filename?: string; fileType?: string; snippet?: string; score?: number }>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const sendAvatarChat = (avatarId: string, payload: { message: string; history?: ChatMessage[] }) =>
|
export interface ChatAttachment {
|
||||||
|
id: string
|
||||||
|
avatarId: string
|
||||||
|
filename: string
|
||||||
|
mimeType: string
|
||||||
|
fileSize: number
|
||||||
|
status: 'processing' | 'ready' | 'failed'
|
||||||
|
category: 'general_image' | 'document' | 'medical_document' | 'medical_image'
|
||||||
|
summary: string
|
||||||
|
warning: string
|
||||||
|
expiresAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatPayload {
|
||||||
|
message: string
|
||||||
|
attachmentIds?: string[]
|
||||||
|
history?: ChatMessage[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sendAvatarChat = (avatarId: string, payload: ChatPayload) =>
|
||||||
request.post<ChatResponse>(`/avatar/${avatarId}/chat`, payload)
|
request.post<ChatResponse>(`/avatar/${avatarId}/chat`, payload)
|
||||||
|
|
||||||
export interface PublicAvatar {
|
export interface PublicAvatar {
|
||||||
@@ -351,19 +498,41 @@ export const createAvatarShareLink = (avatarId: string) =>
|
|||||||
export const getPublicAvatar = (shareToken: string) =>
|
export const getPublicAvatar = (shareToken: string) =>
|
||||||
request.get<PublicAvatar>(`/public/avatar/${shareToken}`)
|
request.get<PublicAvatar>(`/public/avatar/${shareToken}`)
|
||||||
|
|
||||||
export const sendPublicAvatarChat = (shareToken: string, payload: { message: string; history?: ChatMessage[] }) =>
|
export const sendPublicAvatarChat = (shareToken: string, payload: ChatPayload) =>
|
||||||
request.post<ChatResponse>(`/public/avatar/${shareToken}/chat`, payload)
|
request.post<ChatResponse>(`/public/avatar/${shareToken}/chat`, payload)
|
||||||
|
|
||||||
|
const imageForm = (file: File) => {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file', file)
|
||||||
|
return form
|
||||||
|
}
|
||||||
|
|
||||||
|
export const uploadAvatarChatImage = (avatarId: string, file: File) =>
|
||||||
|
request.post<ChatAttachment>(`/avatar/${avatarId}/chat/images`, imageForm(file), {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
timeout: 120000
|
||||||
|
})
|
||||||
|
|
||||||
|
export const uploadPublicAvatarChatImage = (shareToken: string, file: File) =>
|
||||||
|
request.post<ChatAttachment>(`/public/avatar/${shareToken}/chat/images`, imageForm(file), {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
timeout: 120000
|
||||||
|
})
|
||||||
|
|
||||||
type ChatStreamHandlers = {
|
type ChatStreamHandlers = {
|
||||||
onMeta: (meta: Pick<ChatResponse, 'source' | 'references'>) => void
|
onMeta: (meta: Pick<ChatResponse, 'source' | 'references'>) => void
|
||||||
onDelta: (content: string) => void
|
onDelta: (content: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const streamChat = async (path: string, payload: { message: string; history?: ChatMessage[] }, handlers: ChatStreamHandlers) => {
|
const streamChat = async (path: string, payload: ChatPayload, handlers: ChatStreamHandlers) => {
|
||||||
const headers: Record<string, string> = { 'Content-Type': 'application/json', Accept: 'text/event-stream' }
|
const headers: Record<string, string> = { 'Content-Type': 'application/json', Accept: 'text/event-stream' }
|
||||||
if (_authToken) headers.Authorization = `Bearer ${_authToken}`
|
if (_authToken) headers.Authorization = `Bearer ${_authToken}`
|
||||||
const response = await fetch(`${resolveBaseURL()}${path}`, { method: 'POST', headers, body: JSON.stringify(payload) })
|
const response = await fetch(`${resolveBaseURL()}${path}`, { method: 'POST', headers, body: JSON.stringify(payload) })
|
||||||
if (!response.ok || !response.body) throw new Error(`对话请求失败(${response.status})`)
|
if (!response.ok) {
|
||||||
|
const errorBody = await response.json().catch(() => null)
|
||||||
|
throw new Error(errorBody?.detail || errorBody?.message || `对话请求失败(${response.status})`)
|
||||||
|
}
|
||||||
|
if (!response.body) throw new Error('对话响应为空,请稍后重试')
|
||||||
|
|
||||||
const reader = response.body.getReader()
|
const reader = response.body.getReader()
|
||||||
const decoder = new TextDecoder()
|
const decoder = new TextDecoder()
|
||||||
@@ -386,10 +555,10 @@ const streamChat = async (path: string, payload: { message: string; history?: Ch
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const streamAvatarChat = (avatarId: string, payload: { message: string; history?: ChatMessage[] }, handlers: ChatStreamHandlers) =>
|
export const streamAvatarChat = (avatarId: string, payload: ChatPayload, handlers: ChatStreamHandlers) =>
|
||||||
streamChat(`/avatar/${avatarId}/chat/stream`, payload, handlers)
|
streamChat(`/avatar/${avatarId}/chat/stream`, payload, handlers)
|
||||||
|
|
||||||
export const streamPublicAvatarChat = (shareToken: string, payload: { message: string; history?: ChatMessage[] }, handlers: ChatStreamHandlers) =>
|
export const streamPublicAvatarChat = (shareToken: string, payload: ChatPayload, handlers: ChatStreamHandlers) =>
|
||||||
streamChat(`/public/avatar/${shareToken}/chat/stream`, payload, handlers)
|
streamChat(`/public/avatar/${shareToken}/chat/stream`, payload, handlers)
|
||||||
|
|
||||||
// ==================== 会会用户资料 API ====================
|
// ==================== 会会用户资料 API ====================
|
||||||
@@ -409,7 +578,7 @@ export const getUserProfile = () =>
|
|||||||
export interface SmsLoginResult {
|
export interface SmsLoginResult {
|
||||||
token: string
|
token: string
|
||||||
user: UserProfile & { huihuiUserId: string; phone: string; createdAt?: string; lastLoginAt?: string }
|
user: UserProfile & { huihuiUserId: string; phone: string; createdAt?: string; lastLoginAt?: string }
|
||||||
huihui: { userId: string; nickname: string; avatarUrl: string; token: string }
|
huihui: { userId: string; nickname: string; avatarUrl: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 发送短信验证码(演示模式会额外返回 devCode / dev 标记)
|
// 发送短信验证码(演示模式会额外返回 devCode / dev 标记)
|
||||||
@@ -424,6 +593,10 @@ export const loginBySms = (phone: string, code: string) =>
|
|||||||
export const loginByPassword = (account: string, password: string) =>
|
export const loginByPassword = (account: string, password: string) =>
|
||||||
request.post<SmsLoginResult>('/huihui/pwd/login', { account, password })
|
request.post<SmsLoginResult>('/huihui/pwd/login', { account, password })
|
||||||
|
|
||||||
|
// Validate a production Huihui access token and exchange it for an app session.
|
||||||
|
export const loginByHuihuiToken = (token: string) =>
|
||||||
|
request.post<SmsLoginResult>('/huihui/token/login', { token })
|
||||||
|
|
||||||
// 当前登录用户
|
// 当前登录用户
|
||||||
export const getCurrentUser = () =>
|
export const getCurrentUser = () =>
|
||||||
request.get<UserProfile & { huihuiUserId: string; phone: string }>('/huihui/me')
|
request.get<UserProfile & { huihuiUserId: string; phone: string }>('/huihui/me')
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<svg fill="#1677FF" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<title>支付宝</title>
|
||||||
|
<path d="M19.695 15.07c3.426 1.158 4.203 1.22 4.203 1.22V3.846c0-2.124-1.705-3.845-3.81-3.845H3.914C1.808.001.102 1.722.102 3.846v16.31c0 2.123 1.706 3.845 3.813 3.845h16.173c2.105 0 3.81-1.722 3.81-3.845v-.157s-6.19-2.602-9.315-4.119c-2.096 2.602-4.8 4.181-7.607 4.181-4.75 0-6.361-4.19-4.112-6.949.49-.602 1.324-1.175 2.617-1.497 2.025-.502 5.247.313 8.266 1.317a16.796 16.796 0 0 0 1.341-3.302H5.781v-.952h4.799V6.975H4.77v-.953h5.81V3.591s0-.409.411-.409h2.347v2.84h5.744v.951h-5.744v1.704h4.69a19.453 19.453 0 0 1-1.986 5.06c1.424.52 2.702 1.011 3.654 1.333m-13.81-2.032c-.596.06-1.71.325-2.321.869-1.83 1.608-.735 4.55 2.968 4.55 2.151 0 4.301-1.388 5.99-3.61-2.403-1.182-4.438-2.028-6.637-1.809"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 828 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg fill="#07C160" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<title>微信支付</title>
|
||||||
|
<path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178A1.17 1.17 0 0 1 4.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178 1.17 1.17 0 0 1-1.162-1.178c0-.651.52-1.18 1.162-1.18zm5.34 2.867c-1.797-.052-3.746.512-5.28 1.786-1.72 1.428-2.687 3.72-1.78 6.22.942 2.453 3.666 4.229 6.884 4.229.826 0 1.622-.12 2.361-.336a.722.722 0 0 1 .598.082l1.584.926a.272.272 0 0 0 .14.047c.134 0 .24-.111.24-.247 0-.06-.023-.12-.038-.177l-.327-1.233a.582.582 0 0 1-.023-.156.49.49 0 0 1 .201-.398C23.024 18.48 24 16.82 24 14.98c0-3.21-2.931-5.837-6.656-6.088V8.89c-.135-.01-.27-.027-.407-.03zm-2.53 3.274c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.97-.982zm4.844 0c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.969-.982z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -1,14 +1,16 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
|
import uniWebView from '@dcloudio/uni-webview-js'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
import pinia from './store'
|
import pinia from './store'
|
||||||
import { getLaunchParams, onNativeMessage, UniEvents } from '@/utils/uniapp-bridge'
|
import { getLaunchParams, onNativeMessage, stripLaunchToken, UniEvents } from '@/utils/uniapp-bridge'
|
||||||
import { useAvatarStore } from '@/store/avatar'
|
import { useAvatarStore } from '@/store/avatar'
|
||||||
import { useUserStore } from '@/store/user'
|
import { useUserStore } from '@/store/user'
|
||||||
import { setAuthToken } from '@/api'
|
|
||||||
|
// Bundle the bridge locally so the H5 does not depend on an external CDN.
|
||||||
|
;(window as any).uni = (window as any).uni || uniWebView
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
app.use(router)
|
|
||||||
app.use(pinia)
|
app.use(pinia)
|
||||||
|
|
||||||
// —— 混合架构:在挂载前注入 uniapp 壳传入的认证与会会资料 ——
|
// —— 混合架构:在挂载前注入 uniapp 壳传入的认证与会会资料 ——
|
||||||
@@ -16,10 +18,8 @@ const params = getLaunchParams()
|
|||||||
const avatarStore = useAvatarStore(pinia)
|
const avatarStore = useAvatarStore(pinia)
|
||||||
const userStore = useUserStore(pinia)
|
const userStore = useUserStore(pinia)
|
||||||
|
|
||||||
// 恢复本地短信登录会话(会会 userId ↔ 本系统用户)
|
function syncAvatarProfile() {
|
||||||
userStore.loadFromStorage()
|
if (!userStore.isLogin || !userStore.user) return
|
||||||
if (userStore.isLogin && userStore.user) {
|
|
||||||
setAuthToken(userStore.token)
|
|
||||||
avatarStore.setNativeProfile({
|
avatarStore.setNativeProfile({
|
||||||
userId: (userStore.user as any).huihuiUserId || '',
|
userId: (userStore.user as any).huihuiUserId || '',
|
||||||
nickname: userStore.user.nickname || '',
|
nickname: userStore.user.nickname || '',
|
||||||
@@ -27,29 +27,55 @@ if (userStore.isLogin && userStore.user) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (params.token) {
|
async function exchangeInjectedToken(token: string) {
|
||||||
setAuthToken(params.token)
|
stripLaunchToken()
|
||||||
}
|
userStore.clearSession()
|
||||||
if (params.userId || params.nickname || params.avatar) {
|
await userStore.loginByToken(token)
|
||||||
avatarStore.setNativeProfile({
|
syncAvatarProfile()
|
||||||
userId: params.userId || '',
|
|
||||||
nickname: params.nickname || '',
|
|
||||||
avatarUrl: params.avatar || ''
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 原生 → H5:注册消息处理(壳通过 web-view.evalJS 调用)
|
// 原生 → H5:注册消息处理(壳通过 web-view.evalJS 调用)
|
||||||
onNativeMessage((msg) => {
|
onNativeMessage((msg) => {
|
||||||
if (!msg || !msg.type) return
|
if (!msg || !msg.type) return
|
||||||
if (msg.type === 'tokenRefresh' && msg.token) {
|
if (msg.type === 'tokenRefresh' && msg.token) {
|
||||||
setAuthToken(msg.token)
|
void exchangeInjectedToken(msg.token).catch(() => {
|
||||||
|
sessionStorage.setItem('hh_sso_error', '会会登录凭证无效或已过期,请重新进入')
|
||||||
|
void router.replace('/login/sms')
|
||||||
|
})
|
||||||
}
|
}
|
||||||
if (msg.type === 'userUpdate' && msg.user) {
|
if (msg.type === 'userUpdate' && msg.user) {
|
||||||
avatarStore.setNativeProfile(msg.user)
|
avatarStore.setNativeProfile(msg.user)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
app.mount('#app')
|
async function bootstrap() {
|
||||||
|
// A URL token represents the current production user and must override stale storage.
|
||||||
|
if (params.token) {
|
||||||
|
try {
|
||||||
|
await exchangeInjectedToken(params.token)
|
||||||
|
await router.replace(window.location.hash.slice(1) || '/avatar/manage')
|
||||||
|
} catch {
|
||||||
|
sessionStorage.setItem('hh_sso_error', '会会登录凭证无效或已过期,请重新进入')
|
||||||
|
await router.replace('/login/sms')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
userStore.loadFromStorage()
|
||||||
|
syncAvatarProfile()
|
||||||
|
}
|
||||||
|
|
||||||
// 通知原生壳:H5 已就绪
|
if (params.userId || params.nickname || params.avatar) {
|
||||||
UniEvents.ready()
|
avatarStore.setNativeProfile({
|
||||||
|
userId: params.userId || '',
|
||||||
|
nickname: params.nickname || '',
|
||||||
|
avatarUrl: params.avatar || ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
app.use(router)
|
||||||
|
app.mount('#app')
|
||||||
|
|
||||||
|
// 通知原生壳:H5 已就绪
|
||||||
|
UniEvents.ready()
|
||||||
|
}
|
||||||
|
|
||||||
|
void bootstrap()
|
||||||
|
|||||||
@@ -45,11 +45,17 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import('@/views/AuthorizationManage.vue'),
|
component: () => import('@/views/AuthorizationManage.vue'),
|
||||||
meta: { title: '授权管理', requiresAuth: true }
|
meta: { title: '授权管理', requiresAuth: true }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/avatar/:avatarId/authorization',
|
||||||
|
name: 'AvatarAuthorizationManage',
|
||||||
|
component: () => import('@/views/AuthorizationManage.vue'),
|
||||||
|
meta: { title: '授权管理', requiresAuth: true }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/token/charge',
|
path: '/token/charge',
|
||||||
name: 'TokenCharge',
|
name: 'TokenCharge',
|
||||||
component: () => import('@/views/TokenCharge.vue'),
|
component: () => import('@/views/TokenCharge.vue'),
|
||||||
meta: { title: 'Token充值', requiresAuth: true }
|
meta: { title: '积分充值', requiresAuth: true }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/avatar/card',
|
path: '/avatar/card',
|
||||||
@@ -81,6 +87,12 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import('@/views/KnowledgeManage.vue'),
|
component: () => import('@/views/KnowledgeManage.vue'),
|
||||||
meta: { title: '知识库管理', requiresAuth: true }
|
meta: { title: '知识库管理', requiresAuth: true }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/avatar/:avatarId/knowledge',
|
||||||
|
name: 'AvatarKnowledgeManage',
|
||||||
|
component: () => import('@/views/KnowledgeManage.vue'),
|
||||||
|
meta: { title: '知识库管理', requiresAuth: true }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/knowledge/qa/create',
|
path: '/knowledge/qa/create',
|
||||||
name: 'QaPairCreate',
|
name: 'QaPairCreate',
|
||||||
@@ -93,6 +105,18 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import('@/views/QaPairEdit.vue'),
|
component: () => import('@/views/QaPairEdit.vue'),
|
||||||
meta: { title: '编辑问答对', requiresAuth: true }
|
meta: { title: '编辑问答对', requiresAuth: true }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/avatar/:avatarId/knowledge/qa/create',
|
||||||
|
name: 'AvatarQaPairCreate',
|
||||||
|
component: () => import('@/views/QaPairEdit.vue'),
|
||||||
|
meta: { title: '添加问答对', requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/avatar/:avatarId/knowledge/qa/:qaId/edit',
|
||||||
|
name: 'AvatarQaPairEdit',
|
||||||
|
component: () => import('@/views/QaPairEdit.vue'),
|
||||||
|
meta: { title: '编辑问答对', requiresAuth: true }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/login/sms',
|
path: '/login/sms',
|
||||||
name: 'SmsLogin',
|
name: 'SmsLogin',
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { getAvatarList, createAvatar as apiCreate, deleteAvatar as apiDelete, getTokenBalance, getUserProfile } from '@/api'
|
import { getAvatarList, createAvatar as apiCreate, deleteAvatar as apiDelete, getTokenBalance, getTokenUsage, getUserProfile } from '@/api'
|
||||||
import { unwrapListData } from '@/utils/avatar-page-data'
|
import { unwrapListData } from '@/utils/avatar-page-data'
|
||||||
|
|
||||||
export const useAvatarStore = defineStore('avatar', () => {
|
export const useAvatarStore = defineStore('avatar', () => {
|
||||||
// 已创建的分身列表(来自后端)
|
// 已创建的分身列表(来自后端)
|
||||||
const avatars = ref<any[]>([])
|
const avatars = ref<any[]>([])
|
||||||
// 全局 Token 余额(来自后端)
|
// 当前用户所有分身共享的 Token 账户
|
||||||
const tokenBalance = ref<number>(0)
|
const tokenBalance = ref<number>(0)
|
||||||
|
const tokenConsumed = ref<number>(0)
|
||||||
|
const tokenUsageByAvatar = ref<Record<string, number>>({})
|
||||||
// 当前选中分身 id
|
// 当前选中分身 id
|
||||||
const currentAvatarId = ref<string | null>(null)
|
const currentAvatarId = ref<string | null>(null)
|
||||||
// 会会用户资料(头像/昵称,来自会会接口)
|
// 会会用户资料(头像/昵称,来自会会接口)
|
||||||
@@ -29,11 +31,24 @@ export const useAvatarStore = defineStore('avatar', () => {
|
|||||||
try {
|
try {
|
||||||
const res = await getTokenBalance()
|
const res = await getTokenBalance()
|
||||||
tokenBalance.value = (res as any)?.balance ?? 0
|
tokenBalance.value = (res as any)?.balance ?? 0
|
||||||
|
tokenConsumed.value = (res as any)?.totalConsumed ?? 0
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('加载余额失败', e)
|
console.error('加载余额失败', e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loadTokenUsage = async () => {
|
||||||
|
try {
|
||||||
|
const rows = await getTokenUsage()
|
||||||
|
tokenUsageByAvatar.value = rows.reduce<Record<string, number>>((result, row) => {
|
||||||
|
result[row.avatarId] = (result[row.avatarId] || 0) + row.totalTokens
|
||||||
|
return result
|
||||||
|
}, {})
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载 Token 用量失败', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 拉取会会用户资料(头像/昵称)
|
// 拉取会会用户资料(头像/昵称)
|
||||||
const loadUserProfile = async () => {
|
const loadUserProfile = async () => {
|
||||||
// 若已通过 uniapp 壳注入(混合架构),优先保留,不回退到后端 mock
|
// 若已通过 uniapp 壳注入(混合架构),优先保留,不回退到后端 mock
|
||||||
@@ -82,10 +97,13 @@ export const useAvatarStore = defineStore('avatar', () => {
|
|||||||
return {
|
return {
|
||||||
avatars,
|
avatars,
|
||||||
tokenBalance,
|
tokenBalance,
|
||||||
|
tokenConsumed,
|
||||||
|
tokenUsageByAvatar,
|
||||||
currentAvatarId,
|
currentAvatarId,
|
||||||
userProfile,
|
userProfile,
|
||||||
loadAvatars,
|
loadAvatars,
|
||||||
loadTokenBalance,
|
loadTokenBalance,
|
||||||
|
loadTokenUsage,
|
||||||
loadUserProfile,
|
loadUserProfile,
|
||||||
setNativeProfile,
|
setNativeProfile,
|
||||||
addAvatar,
|
addAvatar,
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { setAuthToken, sendSmsCode, loginBySms, loginByPassword, logoutUser, type UserProfile } from '@/api'
|
import {
|
||||||
|
setAuthToken,
|
||||||
|
sendSmsCode,
|
||||||
|
loginBySms,
|
||||||
|
loginByPassword,
|
||||||
|
loginByHuihuiToken,
|
||||||
|
logoutUser,
|
||||||
|
type SmsLoginResult,
|
||||||
|
type UserProfile
|
||||||
|
} from '@/api'
|
||||||
|
import { clearHuihuiEmbeddedMode, markHuihuiEmbeddedMode } from '@/utils/embed-mode'
|
||||||
|
|
||||||
const TOKEN_KEY = 'hh_app_token'
|
const TOKEN_KEY = 'hh_app_token'
|
||||||
const USER_KEY = 'hh_app_user'
|
const USER_KEY = 'hh_app_user'
|
||||||
@@ -37,6 +47,23 @@ export const useUserStore = defineStore('smsuser', () => {
|
|||||||
localStorage.removeItem(USER_KEY)
|
localStorage.removeItem(USER_KEY)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const clearSession = () => {
|
||||||
|
token.value = ''
|
||||||
|
user.value = null
|
||||||
|
isLogin.value = false
|
||||||
|
setAuthToken(null)
|
||||||
|
clearLocal()
|
||||||
|
}
|
||||||
|
|
||||||
|
const acceptLogin = (res: SmsLoginResult) => {
|
||||||
|
token.value = res.token
|
||||||
|
user.value = { ...(res.user || {}), ...(res.huihui || {}) }
|
||||||
|
isLogin.value = true
|
||||||
|
setAuthToken(res.token)
|
||||||
|
persist()
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
// 发送验证码(返回结果,演示模式含 devCode)
|
// 发送验证码(返回结果,演示模式含 devCode)
|
||||||
const sendCode = async (phone: string) => {
|
const sendCode = async (phone: string) => {
|
||||||
return await sendSmsCode(phone)
|
return await sendSmsCode(phone)
|
||||||
@@ -44,24 +71,22 @@ export const useUserStore = defineStore('smsuser', () => {
|
|||||||
|
|
||||||
// 短信登录
|
// 短信登录
|
||||||
const login = async (phone: string, code: string) => {
|
const login = async (phone: string, code: string) => {
|
||||||
const res: any = await loginBySms(phone, code)
|
const result = await loginBySms(phone, code)
|
||||||
token.value = res.token
|
clearHuihuiEmbeddedMode()
|
||||||
user.value = { ...(res.user || {}), ...(res.huihui || {}) }
|
return acceptLogin(result)
|
||||||
isLogin.value = true
|
|
||||||
setAuthToken(res.token)
|
|
||||||
persist()
|
|
||||||
return res
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 账号密码登录
|
// 账号密码登录
|
||||||
const loginByPwd = async (account: string, password: string) => {
|
const loginByPwd = async (account: string, password: string) => {
|
||||||
const res: any = await loginByPassword(account, password)
|
const result = await loginByPassword(account, password)
|
||||||
token.value = res.token
|
clearHuihuiEmbeddedMode()
|
||||||
user.value = { ...(res.user || {}), ...(res.huihui || {}) }
|
return acceptLogin(result)
|
||||||
isLogin.value = true
|
}
|
||||||
setAuthToken(res.token)
|
|
||||||
persist()
|
const loginByToken = async (huihuiToken: string) => {
|
||||||
return res
|
const result = await loginByHuihuiToken(huihuiToken)
|
||||||
|
markHuihuiEmbeddedMode()
|
||||||
|
return acceptLogin(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 退出
|
// 退出
|
||||||
@@ -71,12 +96,20 @@ export const useUserStore = defineStore('smsuser', () => {
|
|||||||
} catch {
|
} catch {
|
||||||
/* 忽略网络错误,本地清除即可 */
|
/* 忽略网络错误,本地清除即可 */
|
||||||
}
|
}
|
||||||
token.value = ''
|
clearHuihuiEmbeddedMode()
|
||||||
user.value = null
|
clearSession()
|
||||||
isLogin.value = false
|
|
||||||
setAuthToken(null)
|
|
||||||
clearLocal()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { token, user, isLogin, loadFromStorage, sendCode, login, loginByPwd, logout }
|
return {
|
||||||
|
token,
|
||||||
|
user,
|
||||||
|
isLogin,
|
||||||
|
loadFromStorage,
|
||||||
|
clearSession,
|
||||||
|
sendCode,
|
||||||
|
login,
|
||||||
|
loginByPwd,
|
||||||
|
loginByToken,
|
||||||
|
logout
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
declare module '@dcloudio/uni-webview-js' {
|
||||||
|
const uniWebView: unknown
|
||||||
|
export default uniWebView
|
||||||
|
}
|
||||||
@@ -41,5 +41,10 @@ export function pickAvatarId(
|
|||||||
currentAvatarId: string | null | undefined,
|
currentAvatarId: string | null | undefined,
|
||||||
avatars?: AvatarPageRecord[]
|
avatars?: AvatarPageRecord[]
|
||||||
): string | null
|
): string | null
|
||||||
|
export function pickScopedAvatarId(
|
||||||
|
routeAvatarId: string | string[] | null | undefined,
|
||||||
|
currentAvatarId: string | null | undefined,
|
||||||
|
avatars?: AvatarPageRecord[]
|
||||||
|
): string | null
|
||||||
export function normalizeAvatarEditForm(avatar?: AvatarPageRecord): AvatarEditForm
|
export function normalizeAvatarEditForm(avatar?: AvatarPageRecord): AvatarEditForm
|
||||||
export function buildAvatarUpdatePayload(form: AvatarEditForm): AvatarUpdatePayload
|
export function buildAvatarUpdatePayload(form: AvatarEditForm): AvatarUpdatePayload
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ export function pickAvatarId(currentAvatarId, avatars) {
|
|||||||
return currentAvatarId || avatars?.[0]?.id || null
|
return currentAvatarId || avatars?.[0]?.id || null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function pickScopedAvatarId(routeAvatarId, currentAvatarId, avatars) {
|
||||||
|
const requested = Array.isArray(routeAvatarId) ? routeAvatarId[0] : routeAvatarId
|
||||||
|
return requested ? String(requested) : pickAvatarId(currentAvatarId, avatars)
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeAvatarEditForm(avatar = {}) {
|
export function normalizeAvatarEditForm(avatar = {}) {
|
||||||
const config = avatar.config || {}
|
const config = avatar.config || {}
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
const HUIHUI_EMBED_MODE_KEY = 'hh_huihui_embed_mode'
|
||||||
|
|
||||||
|
export function markHuihuiEmbeddedMode(): void {
|
||||||
|
sessionStorage.setItem(HUIHUI_EMBED_MODE_KEY, '1')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearHuihuiEmbeddedMode(): void {
|
||||||
|
sessionStorage.removeItem(HUIHUI_EMBED_MODE_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isHuihuiEmbeddedMode(): boolean {
|
||||||
|
return sessionStorage.getItem(HUIHUI_EMBED_MODE_KEY) === '1'
|
||||||
|
}
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
// 会会数字分身 H5 ↔ uniapp 原生壳 桥接工具
|
// 会会数字分身 H5 ↔ uniapp 原生壳 桥接工具
|
||||||
// 协议详见 uniapp-avatar/README.md
|
// 协议详见 uniapp-avatar/README.md
|
||||||
//
|
//
|
||||||
// 引入方式:在 index.html 中加载 uniapp web-view bridge:
|
// uni-webview bridge is bundled by main.ts; no external CDN is required.
|
||||||
// <script src="https://unpkg.com/@dcloudio/uni-webview-js@0.0.10/index.js"></script>
|
|
||||||
// 引入后全局会出现 window.uni.webView,H5 即可用 postMessage 与原生通信。
|
|
||||||
|
|
||||||
const BRIDGE_HANDLER = '__uniBridgeHandle__'
|
const BRIDGE_HANDLER = '__uniBridgeHandle__'
|
||||||
|
const nativeMessageHandlers = new Set<(message: any) => void>()
|
||||||
|
|
||||||
export interface UniLaunchParams {
|
export interface UniLaunchParams {
|
||||||
token?: string
|
token?: string
|
||||||
@@ -15,28 +14,64 @@ export interface UniLaunchParams {
|
|||||||
ts?: string
|
ts?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PARAM_KEYS: (keyof UniLaunchParams)[] = ['token', 'userId', 'nickname', 'avatar', 'ts']
|
||||||
|
|
||||||
|
function readParams(search: string, target: UniLaunchParams): void {
|
||||||
|
const sp = new URLSearchParams(search)
|
||||||
|
for (const key of PARAM_KEYS) {
|
||||||
|
const value = sp.get(key)
|
||||||
|
if (value) target[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 是否运行在 uniapp web-view 环境中
|
// 是否运行在 uniapp web-view 环境中
|
||||||
export function isInUniWebView(): boolean {
|
export function isInUniWebView(): boolean {
|
||||||
return !!(window as any).uni?.webView
|
const runtime = window as any
|
||||||
|
const userAgent = navigator.userAgent
|
||||||
|
const isDCloudApp = Boolean(
|
||||||
|
runtime.plus ||
|
||||||
|
runtime.__dcloud_weex_postMessage ||
|
||||||
|
runtime.__dcloud_weex_ ||
|
||||||
|
/uni-app|Html5Plus/i.test(userAgent)
|
||||||
|
)
|
||||||
|
const isMiniProgram = Boolean(
|
||||||
|
(/miniProgram/i.test(userAgent) && (runtime.wx?.miniProgram || runtime.qq?.miniProgram)) ||
|
||||||
|
runtime.my ||
|
||||||
|
runtime.swan?.webView ||
|
||||||
|
runtime.tt?.miniProgram
|
||||||
|
)
|
||||||
|
return Boolean(runtime.uni?.webView && (isDCloudApp || isMiniProgram))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解析 web-view 加载 URL 时原生注入的参数(token / 会会用户)
|
// 解析 web-view 加载 URL 时原生注入的参数(token / 会会用户)
|
||||||
export function getLaunchParams(): UniLaunchParams {
|
export function getLaunchParams(): UniLaunchParams {
|
||||||
const sp = new URLSearchParams(window.location.search)
|
|
||||||
const params: UniLaunchParams = {}
|
const params: UniLaunchParams = {}
|
||||||
const token = sp.get('token')
|
readParams(window.location.search, params)
|
||||||
const userId = sp.get('userId')
|
const hashQueryIndex = window.location.hash.indexOf('?')
|
||||||
const nickname = sp.get('nickname')
|
if (hashQueryIndex >= 0) {
|
||||||
const avatar = sp.get('avatar')
|
readParams(window.location.hash.slice(hashQueryIndex + 1), params)
|
||||||
const ts = sp.get('ts')
|
}
|
||||||
if (token) params.token = token
|
|
||||||
if (userId) params.userId = userId
|
|
||||||
if (nickname) params.nickname = decodeURIComponent(nickname)
|
|
||||||
if (avatar) params.avatar = decodeURIComponent(avatar)
|
|
||||||
if (ts) params.ts = ts
|
|
||||||
return params
|
return params
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove the one-time login credential before any route is rendered or logged.
|
||||||
|
export function stripLaunchToken(): void {
|
||||||
|
const url = new URL(window.location.href)
|
||||||
|
url.searchParams.delete('token')
|
||||||
|
|
||||||
|
const hash = url.hash.slice(1)
|
||||||
|
const queryIndex = hash.indexOf('?')
|
||||||
|
if (queryIndex >= 0) {
|
||||||
|
const path = hash.slice(0, queryIndex)
|
||||||
|
const hashParams = new URLSearchParams(hash.slice(queryIndex + 1))
|
||||||
|
hashParams.delete('token')
|
||||||
|
const query = hashParams.toString()
|
||||||
|
url.hash = `${path}${query ? `?${query}` : ''}`
|
||||||
|
}
|
||||||
|
|
||||||
|
window.history.replaceState(window.history.state, '', `${url.pathname}${url.search}${url.hash}`)
|
||||||
|
}
|
||||||
|
|
||||||
// H5 → 原生:发送事件(需引入 uniapp web-view bridge)
|
// H5 → 原生:发送事件(需引入 uniapp web-view bridge)
|
||||||
export function postToNative(message: Record<string, any>): boolean {
|
export function postToNative(message: Record<string, any>): boolean {
|
||||||
if (!isInUniWebView()) return false
|
if (!isInUniWebView()) return false
|
||||||
@@ -45,14 +80,18 @@ export function postToNative(message: Record<string, any>): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 原生 → H5:注册消息处理(原生通过 web-view.evalJS 调用 window.__uniBridgeHandle__)
|
// 原生 → H5:注册消息处理(原生通过 web-view.evalJS 调用 window.__uniBridgeHandle__)
|
||||||
export function onNativeMessage(handler: (message: any) => void): void {
|
export function onNativeMessage(handler: (message: any) => void): () => void {
|
||||||
|
nativeMessageHandlers.add(handler)
|
||||||
;(window as any)[BRIDGE_HANDLER] = (message: any) => {
|
;(window as any)[BRIDGE_HANDLER] = (message: any) => {
|
||||||
try {
|
for (const listener of nativeMessageHandlers) {
|
||||||
handler(message)
|
try {
|
||||||
} catch (e) {
|
listener(message)
|
||||||
console.error('[uniBridge] handler error', e)
|
} catch (e) {
|
||||||
|
console.error('[uniBridge] handler error', e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return () => nativeMessageHandlers.delete(handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 便捷事件
|
// 便捷事件
|
||||||
@@ -61,5 +100,6 @@ export const UniEvents = {
|
|||||||
needLogin: () => postToNative({ type: 'needLogin' }),
|
needLogin: () => postToNative({ type: 'needLogin' }),
|
||||||
setTitle: (title: string) => postToNative({ type: 'setTitle', title }),
|
setTitle: (title: string) => postToNative({ type: 'setTitle', title }),
|
||||||
navigate: (path: string) => postToNative({ type: 'navigate', path }),
|
navigate: (path: string) => postToNative({ type: 'navigate', path }),
|
||||||
|
payment: (payment: Record<string, any>) => postToNative({ type: 'payment', payment }),
|
||||||
back: () => postToNative({ type: 'back' })
|
back: () => postToNative({ type: 'back' })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="authorization-page">
|
<div class="authorization-page" :class="{ embedded: isEmbedded }">
|
||||||
<header class="page-header">
|
<header v-if="!isEmbedded" class="page-header">
|
||||||
<button class="back-button" type="button" aria-label="返回数字分身管理" @click="goBack">
|
<button class="back-button" type="button" aria-label="返回数字分身管理" @click="goBack">
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
<path d="m15 18-6-6 6-6" />
|
<path d="m15 18-6-6 6-6" />
|
||||||
@@ -64,7 +64,7 @@
|
|||||||
<span class="permission-copy">
|
<span class="permission-copy">
|
||||||
<strong>{{ item.title }}</strong>
|
<strong>{{ item.title }}</strong>
|
||||||
<small>
|
<small>
|
||||||
{{ item.description }}
|
{{ item.key === 'takeover' ? takeoverDescription : item.description }}
|
||||||
<span
|
<span
|
||||||
v-if="item.key === 'takeover' && takeoverConnectionLabel"
|
v-if="item.key === 'takeover' && takeoverConnectionLabel"
|
||||||
class="connection-state"
|
class="connection-state"
|
||||||
@@ -79,6 +79,33 @@
|
|||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section v-if="permissionState.takeover" class="takeover-delay-card" aria-label="自动回复等待时间">
|
||||||
|
<div class="delay-heading">
|
||||||
|
<div>
|
||||||
|
<strong>自动回复等待时间</strong>
|
||||||
|
<small>等待期间主人发言会取消本次回复,最短 3 秒</small>
|
||||||
|
</div>
|
||||||
|
<span>{{ formattedTakeoverDelay }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="delay-control">
|
||||||
|
<input
|
||||||
|
v-model.number="takeoverDelayValue"
|
||||||
|
type="number"
|
||||||
|
inputmode="numeric"
|
||||||
|
step="1"
|
||||||
|
:min="takeoverDelayUnit === 'minutes' ? 1 : 3"
|
||||||
|
:max="takeoverDelayUnit === 'minutes' ? 1440 : 86400"
|
||||||
|
aria-label="等待时间"
|
||||||
|
:disabled="loading || saving"
|
||||||
|
@blur="normalizeTakeoverDelay"
|
||||||
|
/>
|
||||||
|
<select v-model="takeoverDelayUnit" aria-label="等待时间单位" :disabled="loading || saving">
|
||||||
|
<option value="seconds">秒</option>
|
||||||
|
<option value="minutes">分钟</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<p v-if="errorMessage" class="error-message" role="alert">{{ errorMessage }}</p>
|
<p v-if="errorMessage" class="error-message" role="alert">{{ errorMessage }}</p>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -95,6 +122,9 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer v-if="activeAvatarId" class="save-area">
|
<footer v-if="activeAvatarId" class="save-area">
|
||||||
|
<button v-if="isEmbedded" class="footer-back-button" type="button" :disabled="saving" @click="goBack">
|
||||||
|
返回
|
||||||
|
</button>
|
||||||
<button class="save-button" type="button" :disabled="loading || saving" @click="saveSettings()">
|
<button class="save-button" type="button" :disabled="loading || saving" @click="saveSettings()">
|
||||||
<span v-if="saving" class="saving-spinner" aria-hidden="true"></span>
|
<span v-if="saving" class="saving-spinner" aria-hidden="true"></span>
|
||||||
{{ saving ? '保存中...' : '保存授权设置' }}
|
{{ saving ? '保存中...' : '保存授权设置' }}
|
||||||
@@ -109,7 +139,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import {
|
import {
|
||||||
getAvatarPermissionSettings,
|
getAvatarPermissionSettings,
|
||||||
getTakeoverStatus,
|
getTakeoverStatus,
|
||||||
@@ -118,12 +148,16 @@ import {
|
|||||||
type TakeoverStatus,
|
type TakeoverStatus,
|
||||||
} from '@/api'
|
} from '@/api'
|
||||||
import { useAvatarStore } from '@/store/avatar'
|
import { useAvatarStore } from '@/store/avatar'
|
||||||
import { pickAvatarId } from '@/utils/avatar-page-data.js'
|
import { pickScopedAvatarId } from '@/utils/avatar-page-data.js'
|
||||||
|
import { isHuihuiEmbeddedMode } from '@/utils/embed-mode'
|
||||||
|
|
||||||
type PermissionState = Record<AvatarPermission, boolean>
|
type PermissionState = Record<AvatarPermission, boolean>
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
const avatarStore = useAvatarStore()
|
const avatarStore = useAvatarStore()
|
||||||
|
const avatarId = computed(() => pickScopedAvatarId(route.params.avatarId, avatarStore.currentAvatarId, avatarStore.avatars))
|
||||||
|
const isEmbedded = isHuihuiEmbeddedMode()
|
||||||
|
|
||||||
const permissionItems: Array<{
|
const permissionItems: Array<{
|
||||||
key: AvatarPermission
|
key: AvatarPermission
|
||||||
@@ -158,13 +192,13 @@ const permissionItems: Array<{
|
|||||||
{
|
{
|
||||||
key: 'interact',
|
key: 'interact',
|
||||||
title: '广场互动操作',
|
title: '广场互动操作',
|
||||||
description: '点赞、收藏、评论、回复等操作',
|
description: '允许分身代表你点赞、收藏、评论和回复;时段、间隔与触发概率由广场调度设置统一控制',
|
||||||
tone: 'pink',
|
tone: 'pink',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'takeover',
|
key: 'takeover',
|
||||||
title: '分身主动接管聊天回复',
|
title: '分身主动接管聊天回复',
|
||||||
description: '收到私聊消息 3 秒后回复,主人发言时暂停',
|
description: '收到私聊消息后按设定时间回复,主人发言时暂停',
|
||||||
tone: 'cyan',
|
tone: 'cyan',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -183,6 +217,8 @@ const saving = ref(false)
|
|||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
const toastMessage = ref('')
|
const toastMessage = ref('')
|
||||||
const takeoverStatus = ref<TakeoverStatus | null>(null)
|
const takeoverStatus = ref<TakeoverStatus | null>(null)
|
||||||
|
const takeoverDelayValue = ref(3)
|
||||||
|
const takeoverDelayUnit = ref<'seconds' | 'minutes'>('minutes')
|
||||||
let toastTimer: number | undefined
|
let toastTimer: number | undefined
|
||||||
let takeoverStatusTimer: number | undefined
|
let takeoverStatusTimer: number | undefined
|
||||||
|
|
||||||
@@ -203,6 +239,38 @@ const takeoverConnectionTone = computed(() => {
|
|||||||
return 'connecting'
|
return 'connecting'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const takeoverDelaySeconds = computed(() => {
|
||||||
|
const value = Math.trunc(Number(takeoverDelayValue.value) || 0)
|
||||||
|
return takeoverDelayUnit.value === 'minutes' ? value * 60 : value
|
||||||
|
})
|
||||||
|
|
||||||
|
const formattedTakeoverDelay = computed(() => {
|
||||||
|
const seconds = takeoverDelaySeconds.value
|
||||||
|
if (seconds > 0 && seconds % 60 === 0) return `${seconds / 60} 分钟`
|
||||||
|
return `${seconds} 秒`
|
||||||
|
})
|
||||||
|
|
||||||
|
const takeoverDescription = computed(() =>
|
||||||
|
`收到私聊消息 ${formattedTakeoverDelay.value}后回复,主人发言时暂停`
|
||||||
|
)
|
||||||
|
|
||||||
|
const applyTakeoverDelay = (seconds: number) => {
|
||||||
|
const normalized = Number.isFinite(seconds) && seconds >= 3 ? Math.trunc(seconds) : 180
|
||||||
|
if (normalized % 60 === 0) {
|
||||||
|
takeoverDelayUnit.value = 'minutes'
|
||||||
|
takeoverDelayValue.value = normalized / 60
|
||||||
|
} else {
|
||||||
|
takeoverDelayUnit.value = 'seconds'
|
||||||
|
takeoverDelayValue.value = normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeTakeoverDelay = () => {
|
||||||
|
const min = takeoverDelayUnit.value === 'minutes' ? 1 : 3
|
||||||
|
const max = takeoverDelayUnit.value === 'minutes' ? 1440 : 86400
|
||||||
|
takeoverDelayValue.value = Math.min(max, Math.max(min, Math.trunc(Number(takeoverDelayValue.value) || min)))
|
||||||
|
}
|
||||||
|
|
||||||
const setPermissions = (permissions: AvatarPermission[]) => {
|
const setPermissions = (permissions: AvatarPermission[]) => {
|
||||||
const enabled = new Set(permissions)
|
const enabled = new Set(permissions)
|
||||||
for (const item of permissionItems) permissionState[item.key] = enabled.has(item.key)
|
for (const item of permissionItems) permissionState[item.key] = enabled.has(item.key)
|
||||||
@@ -258,6 +326,7 @@ const loadSettings = async () => {
|
|||||||
try {
|
try {
|
||||||
const settings = await getAvatarPermissionSettings(activeAvatarId.value)
|
const settings = await getAvatarPermissionSettings(activeAvatarId.value)
|
||||||
setPermissions(settings.permissions || [])
|
setPermissions(settings.permissions || [])
|
||||||
|
applyTakeoverDelay(settings.takeoverReplyDelaySeconds || 180)
|
||||||
await loadTakeoverStatus()
|
await loadTakeoverStatus()
|
||||||
scheduleTakeoverStatusRefresh()
|
scheduleTakeoverStatusRefresh()
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -284,8 +353,18 @@ const saveSettings = async (takeoverToggle = false): Promise<boolean> => {
|
|||||||
saving.value = true
|
saving.value = true
|
||||||
errorMessage.value = ''
|
errorMessage.value = ''
|
||||||
try {
|
try {
|
||||||
const settings = await updateAvatarPermissionSettings(activeAvatarId.value, selectedPermissions())
|
normalizeTakeoverDelay()
|
||||||
|
if (takeoverDelaySeconds.value < 3 || takeoverDelaySeconds.value > 86400) {
|
||||||
|
errorMessage.value = '自动回复等待时间需在 3 秒到 24 小时之间'
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const settings = await updateAvatarPermissionSettings(
|
||||||
|
activeAvatarId.value,
|
||||||
|
selectedPermissions(),
|
||||||
|
takeoverDelaySeconds.value,
|
||||||
|
)
|
||||||
setPermissions(settings.permissions || [])
|
setPermissions(settings.permissions || [])
|
||||||
|
applyTakeoverDelay(settings.takeoverReplyDelaySeconds || 180)
|
||||||
await loadTakeoverStatus()
|
await loadTakeoverStatus()
|
||||||
scheduleTakeoverStatusRefresh()
|
scheduleTakeoverStatusRefresh()
|
||||||
if (takeoverToggle) {
|
if (takeoverToggle) {
|
||||||
@@ -307,7 +386,8 @@ const goCreate = () => router.push('/avatar/create')
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (!avatarStore.avatars.length) await avatarStore.loadAvatars()
|
if (!avatarStore.avatars.length) await avatarStore.loadAvatars()
|
||||||
activeAvatarId.value = pickAvatarId(avatarStore.currentAvatarId, avatarStore.avatars) || ''
|
activeAvatarId.value = avatarId.value || ''
|
||||||
|
if (activeAvatarId.value) avatarStore.currentAvatarId = activeAvatarId.value
|
||||||
await loadSettings()
|
await loadSettings()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -391,6 +471,10 @@ svg {
|
|||||||
padding: 0 20px;
|
padding: 0 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.authorization-page.embedded .page-content {
|
||||||
|
padding-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.permission-intro {
|
.permission-intro {
|
||||||
min-height: 96px;
|
min-height: 96px;
|
||||||
padding: 15px 16px 14px;
|
padding: 15px 16px 14px;
|
||||||
@@ -456,6 +540,75 @@ svg {
|
|||||||
min-height: 76px;
|
min-height: 76px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.takeover-delay-card {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid #dff1ef;
|
||||||
|
border-radius: 15px;
|
||||||
|
background: linear-gradient(135deg, #f5fcfb 0%, #fff 100%);
|
||||||
|
box-shadow: 0 8px 24px rgba(53, 166, 162, .06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delay-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delay-heading strong,
|
||||||
|
.delay-heading small {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delay-heading strong {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delay-heading small {
|
||||||
|
margin-top: 5px;
|
||||||
|
color: #8c929f;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delay-heading > span {
|
||||||
|
flex: none;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #258e8a;
|
||||||
|
background: #e8f8f6;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delay-control {
|
||||||
|
margin-top: 14px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 88px;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delay-control input,
|
||||||
|
.delay-control select {
|
||||||
|
min-width: 0;
|
||||||
|
height: 42px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 1px solid #dfe5e8;
|
||||||
|
border-radius: 11px;
|
||||||
|
outline: none;
|
||||||
|
color: #222528;
|
||||||
|
background: #fff;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delay-control input:focus,
|
||||||
|
.delay-control select:focus {
|
||||||
|
border-color: #35a6a2;
|
||||||
|
box-shadow: 0 0 0 3px rgba(53, 166, 162, .1);
|
||||||
|
}
|
||||||
|
|
||||||
.permission-icon {
|
.permission-icon {
|
||||||
width: 34px;
|
width: 34px;
|
||||||
height: 34px;
|
height: 34px;
|
||||||
@@ -596,12 +749,15 @@ svg {
|
|||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: min(100%, 390px);
|
width: min(100%, 390px);
|
||||||
padding: 12px 20px calc(20px + env(safe-area-inset-bottom));
|
padding: 12px 20px calc(20px + env(safe-area-inset-bottom));
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
background: linear-gradient(to bottom, rgba(250, 250, 250, 0), #fafafa 20%, #fafafa 100%);
|
background: linear-gradient(to bottom, rgba(250, 250, 250, 0), #fafafa 20%, #fafafa 100%);
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.save-button {
|
.save-button {
|
||||||
width: 100%;
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -617,6 +773,20 @@ svg {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.footer-back-button {
|
||||||
|
flex: 0 0 96px;
|
||||||
|
height: 48px;
|
||||||
|
border: 1px solid #eadfd6;
|
||||||
|
border-radius: 24px;
|
||||||
|
color: #6f665f;
|
||||||
|
background: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-back-button:disabled { opacity: .58; }
|
||||||
|
|
||||||
.save-button:disabled {
|
.save-button:disabled {
|
||||||
opacity: .68;
|
opacity: .68;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="chat-page">
|
<div class="chat-page" :class="{ 'has-pending-images': pendingImages.length }">
|
||||||
<header class="chat-header">
|
<header class="chat-header">
|
||||||
<button v-if="!isPublic" class="back-btn" @click="router.back()">‹</button>
|
<button v-if="!isPublic" class="back-btn" @click="router.back()">‹</button>
|
||||||
<div class="avatar-heading">
|
<div class="avatar-heading">
|
||||||
@@ -31,6 +31,12 @@
|
|||||||
<span v-else>{{ avatar?.emoji || '🤖' }}</span>
|
<span v-else>{{ avatar?.emoji || '🤖' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="message-column">
|
<div class="message-column">
|
||||||
|
<div v-if="message.attachments?.length" class="message-images">
|
||||||
|
<figure v-for="attachment in message.attachments" :key="attachment.id" class="message-image-card">
|
||||||
|
<img :src="attachment.previewUrl" :alt="attachment.filename" />
|
||||||
|
<figcaption v-if="attachment.warning">{{ attachment.warning }}</figcaption>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
<div class="message-bubble" :class="{ streaming: sending && message.role === 'assistant' && index === messages.length - 1 }">
|
<div class="message-bubble" :class="{ streaming: sending && message.role === 'assistant' && index === messages.length - 1 }">
|
||||||
<template v-if="message.role === 'assistant'">
|
<template v-if="message.role === 'assistant'">
|
||||||
<span
|
<span
|
||||||
@@ -70,24 +76,66 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<form class="composer" @submit.prevent="sendMessage(inputText)">
|
<form class="composer" @submit.prevent="sendMessage(inputText)">
|
||||||
<textarea v-model="inputText" rows="1" :disabled="sending" placeholder="输入你想聊的内容…" @keydown.enter.exact.prevent="sendMessage(inputText)"></textarea>
|
<div v-if="pendingImages.length" class="pending-images">
|
||||||
<button class="send-btn" type="submit" :disabled="sending || !inputText.trim()">发送</button>
|
<div v-for="image in pendingImages" :key="image.localId" class="pending-image" :class="image.status">
|
||||||
|
<img :src="image.previewUrl" :alt="image.filename" />
|
||||||
|
<div class="pending-image-copy">
|
||||||
|
<strong>{{ image.status === 'uploading' ? '正在识别图片…' : image.summary || image.filename }}</strong>
|
||||||
|
<span>{{ image.status === 'uploading' ? '正在提取图片中的可见内容' : categoryLabel(image.category) }}</span>
|
||||||
|
</div>
|
||||||
|
<button type="button" aria-label="移除图片" :disabled="sending" @click="removePendingImage(image.localId)">×</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="hasPendingMedicalImage" class="medical-note">病例与医学影像识别仅供辅助,请以原始资料和医生意见为准。</p>
|
||||||
|
</div>
|
||||||
|
<div class="composer-row">
|
||||||
|
<button class="image-btn" type="button" :disabled="sending || uploadingImage || pendingImages.length >= 3" aria-label="选择图片" @click="imageInput?.click()">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 5.5A2.5 2.5 0 0 1 6.5 3h11A2.5 2.5 0 0 1 20 5.5v13a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 4 18.5v-13Zm2 12.7 3.8-4.2 2.7 2.8 1.7-1.8 3.8 3.2V5.5a.5.5 0 0 0-.5-.5h-11a.5.5 0 0 0-.5.5v12.7Zm8.3-7.8a1.7 1.7 0 1 0 0-3.4 1.7 1.7 0 0 0 0 3.4Z"/></svg>
|
||||||
|
</button>
|
||||||
|
<input ref="imageInput" class="image-input" type="file" accept="image/jpeg,image/png,image/webp" multiple @change="selectImages" />
|
||||||
|
<textarea v-model="inputText" rows="1" :disabled="sending" placeholder="输入问题,或选择一张图片…" @keydown.enter.exact.prevent="sendMessage(inputText)"></textarea>
|
||||||
|
<button class="send-btn" type="submit" :disabled="sending || uploadingImage || (!inputText.trim() && !readyPendingImages.length)">发送</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
|
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { getAvatarDetail, getPublicAvatar, streamAvatarChat, streamPublicAvatarChat, type ChatMessage } from '@/api'
|
import {
|
||||||
|
getAvatarDetail,
|
||||||
|
getPublicAvatar,
|
||||||
|
streamAvatarChat,
|
||||||
|
streamPublicAvatarChat,
|
||||||
|
uploadAvatarChatImage,
|
||||||
|
uploadPublicAvatarChatImage,
|
||||||
|
type ChatAttachment,
|
||||||
|
type ChatMessage
|
||||||
|
} from '@/api'
|
||||||
import { useAvatarStore } from '@/store/avatar'
|
import { useAvatarStore } from '@/store/avatar'
|
||||||
import { useUserStore } from '@/store/user'
|
import { useUserStore } from '@/store/user'
|
||||||
import { renderChatMarkdownCharacters } from '@/utils/chat-markdown.js'
|
import { renderChatMarkdownCharacters } from '@/utils/chat-markdown.js'
|
||||||
|
|
||||||
type DisplayMessage = ChatMessage & {
|
type DisplayMessage = ChatMessage & {
|
||||||
source?: 'qa' | 'knowledge' | 'qwen' | 'public'
|
source?: 'qa' | 'knowledge' | 'vision' | 'qwen' | 'public'
|
||||||
references?: Array<{ filename?: string }>
|
references?: Array<{ filename?: string }>
|
||||||
characters?: string[]
|
characters?: string[]
|
||||||
|
attachments?: MessageAttachment[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type MessageAttachment = {
|
||||||
|
id: string
|
||||||
|
filename: string
|
||||||
|
previewUrl: string
|
||||||
|
category?: ChatAttachment['category']
|
||||||
|
summary?: string
|
||||||
|
warning?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type PendingImage = MessageAttachment & {
|
||||||
|
localId: string
|
||||||
|
attachmentId?: string
|
||||||
|
status: 'uploading' | 'ready'
|
||||||
}
|
}
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -103,8 +151,11 @@ const inputText = ref('')
|
|||||||
const sending = ref(false)
|
const sending = ref(false)
|
||||||
const thinking = ref(false)
|
const thinking = ref(false)
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
const lastQuestion = ref('')
|
const lastRequest = ref<{ question: string; attachments: MessageAttachment[] } | null>(null)
|
||||||
const messageList = ref<HTMLElement | null>(null)
|
const messageList = ref<HTMLElement | null>(null)
|
||||||
|
const imageInput = ref<HTMLInputElement | null>(null)
|
||||||
|
const pendingImages = ref<PendingImage[]>([])
|
||||||
|
const previewUrls = new Set<string>()
|
||||||
let scrollFrame: number | null = null
|
let scrollFrame: number | null = null
|
||||||
|
|
||||||
const userAvatarUrl = computed(() => userStore.user?.avatarUrl || store.userProfile?.avatarUrl || '')
|
const userAvatarUrl = computed(() => userStore.user?.avatarUrl || store.userProfile?.avatarUrl || '')
|
||||||
@@ -115,14 +166,25 @@ const avatarStatus = computed(() => {
|
|||||||
if (status === 'training') return { tone: 'training', label: '知识训练中' }
|
if (status === 'training') return { tone: 'training', label: '知识训练中' }
|
||||||
return { tone: 'active', label: '在线,随时可以和我聊聊' }
|
return { tone: 'active', label: '在线,随时可以和我聊聊' }
|
||||||
})
|
})
|
||||||
|
const readyPendingImages = computed(() => pendingImages.value.filter((image) => image.status === 'ready' && image.attachmentId))
|
||||||
|
const uploadingImage = computed(() => pendingImages.value.some((image) => image.status === 'uploading'))
|
||||||
|
const hasPendingMedicalImage = computed(() => readyPendingImages.value.some((image) => ['medical_document', 'medical_image'].includes(image.category || '')))
|
||||||
|
|
||||||
const sourceLabels: Record<NonNullable<DisplayMessage['source']>, string> = {
|
const sourceLabels: Record<NonNullable<DisplayMessage['source']>, string> = {
|
||||||
qa: '标准问答对',
|
qa: '标准问答对',
|
||||||
knowledge: '参考文件知识库',
|
knowledge: '参考文件知识库',
|
||||||
|
vision: '图片理解',
|
||||||
qwen: '智能回答',
|
qwen: '智能回答',
|
||||||
public: ''
|
public: ''
|
||||||
}
|
}
|
||||||
const sourceLabel = (source?: DisplayMessage['source']) => source ? sourceLabels[source] : ''
|
const sourceLabel = (source?: DisplayMessage['source']) => source ? sourceLabels[source] : ''
|
||||||
|
const categoryLabels: Record<ChatAttachment['category'], string> = {
|
||||||
|
general_image: '图片内容已识别',
|
||||||
|
document: '文档图片已识别',
|
||||||
|
medical_document: '病例文字已提取,请核对原文',
|
||||||
|
medical_image: '医学影像已作客观描述'
|
||||||
|
}
|
||||||
|
const categoryLabel = (category?: ChatAttachment['category']) => category ? categoryLabels[category] : '图片内容已识别'
|
||||||
|
|
||||||
const scrollToBottom = async () => {
|
const scrollToBottom = async () => {
|
||||||
await nextTick()
|
await nextTick()
|
||||||
@@ -214,20 +276,90 @@ const loadAvatar = async () => {
|
|||||||
document.title = avatar.value?.displayName || avatar.value?.name || '会会数字分身'
|
document.title = avatar.value?.displayName || avatar.value?.name || '会会数字分身'
|
||||||
}
|
}
|
||||||
|
|
||||||
const sendMessage = async (value: string) => {
|
const removePendingImage = (localId: string) => {
|
||||||
const question = value.trim()
|
const target = pendingImages.value.find((image) => image.localId === localId)
|
||||||
if (!question || sending.value) return
|
if (target) {
|
||||||
lastQuestion.value = question
|
URL.revokeObjectURL(target.previewUrl)
|
||||||
|
previewUrls.delete(target.previewUrl)
|
||||||
|
}
|
||||||
|
pendingImages.value = pendingImages.value.filter((image) => image.localId !== localId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectImages = async (event: Event) => {
|
||||||
|
const input = event.target as HTMLInputElement
|
||||||
|
const slots = Math.max(0, 3 - pendingImages.value.length)
|
||||||
|
const files = Array.from(input.files || []).slice(0, slots)
|
||||||
|
input.value = ''
|
||||||
|
for (const file of files) {
|
||||||
|
if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type)) {
|
||||||
|
errorMessage.value = '仅支持 JPG、PNG、WebP 图片'
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (file.size > 8 * 1024 * 1024) {
|
||||||
|
errorMessage.value = '单张图片不能超过 8MB'
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const previewUrl = URL.createObjectURL(file)
|
||||||
|
previewUrls.add(previewUrl)
|
||||||
|
const localId = `local-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||||
|
pendingImages.value.push({
|
||||||
|
id: localId,
|
||||||
|
localId,
|
||||||
|
filename: file.name,
|
||||||
|
previewUrl,
|
||||||
|
status: 'uploading'
|
||||||
|
})
|
||||||
|
errorMessage.value = ''
|
||||||
|
try {
|
||||||
|
const result = isPublic
|
||||||
|
? await uploadPublicAvatarChatImage(shareToken, file)
|
||||||
|
: await uploadAvatarChatImage(avatarId.value, file)
|
||||||
|
const pending = pendingImages.value.find((image) => image.localId === localId)
|
||||||
|
if (!pending) continue
|
||||||
|
Object.assign(pending, {
|
||||||
|
id: result.id,
|
||||||
|
attachmentId: result.id,
|
||||||
|
status: 'ready',
|
||||||
|
category: result.category,
|
||||||
|
summary: result.summary,
|
||||||
|
warning: result.warning
|
||||||
|
})
|
||||||
|
} catch (error: any) {
|
||||||
|
removePendingImage(localId)
|
||||||
|
errorMessage.value = error?.response?.data?.detail || error?.message || '图片识别失败,请重新选择图片'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendMessage = async (value: string, retryAttachments?: MessageAttachment[]) => {
|
||||||
|
const selectedAttachments = retryAttachments || readyPendingImages.value.map((image) => ({
|
||||||
|
id: image.attachmentId || image.id,
|
||||||
|
filename: image.filename,
|
||||||
|
previewUrl: image.previewUrl,
|
||||||
|
category: image.category,
|
||||||
|
summary: image.summary,
|
||||||
|
warning: image.warning
|
||||||
|
}))
|
||||||
|
const question = value.trim() || (selectedAttachments.length ? '请帮我看看这张图片。' : '')
|
||||||
|
if (!question || sending.value || (!retryAttachments && uploadingImage.value)) return
|
||||||
|
const history = messages.value.slice(-10).map(({ role, content, attachments }) => ({
|
||||||
|
role,
|
||||||
|
content,
|
||||||
|
attachmentIds: attachments?.map((attachment) => attachment.id) || []
|
||||||
|
}))
|
||||||
|
lastRequest.value = { question, attachments: selectedAttachments }
|
||||||
inputText.value = ''
|
inputText.value = ''
|
||||||
errorMessage.value = ''
|
errorMessage.value = ''
|
||||||
messages.value.push({ role: 'user', content: question })
|
if (!retryAttachments) pendingImages.value = []
|
||||||
|
messages.value.push({ role: 'user', content: question, attachments: selectedAttachments })
|
||||||
sending.value = true
|
sending.value = true
|
||||||
thinking.value = true
|
thinking.value = true
|
||||||
await scrollToBottom()
|
await scrollToBottom()
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
message: question,
|
message: question,
|
||||||
history: messages.value.slice(-10).map(({ role, content }) => ({ role, content }))
|
attachmentIds: selectedAttachments.map((attachment) => attachment.id),
|
||||||
|
history
|
||||||
}
|
}
|
||||||
const streamed = createStreamReply()
|
const streamed = createStreamReply()
|
||||||
const handlers = {
|
const handlers = {
|
||||||
@@ -256,13 +388,17 @@ const sendMessage = async (value: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const retryLast = () => {
|
const retryLast = () => {
|
||||||
if (!lastQuestion.value || sending.value) return
|
if (!lastRequest.value || sending.value) return
|
||||||
const last = messages.value[messages.value.length - 1]
|
while (messages.value[messages.value.length - 1]?.role === 'assistant') messages.value.pop()
|
||||||
if (last?.role === 'user') messages.value.pop()
|
if (messages.value[messages.value.length - 1]?.role === 'user') messages.value.pop()
|
||||||
sendMessage(lastQuestion.value)
|
void sendMessage(lastRequest.value.question, lastRequest.value.attachments)
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(loadAvatar)
|
onMounted(loadAvatar)
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
previewUrls.forEach((url) => URL.revokeObjectURL(url))
|
||||||
|
previewUrls.clear()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -275,6 +411,7 @@ onMounted(loadAvatar)
|
|||||||
.avatar-heading h1 { margin: 0; font-size: 17px; }
|
.avatar-heading h1 { margin: 0; font-size: 17px; }
|
||||||
.online-state { display: flex; align-items: center; gap: 4px; margin-top: 3px; font-size: 11px; opacity: .9; }.online-state i { width: 7px; height: 7px; border-radius: 50%; background: #86EFAC; box-shadow: 0 0 0 2px rgba(255,255,255,.22); }.online-state.training i { background: #FDE68A; }.online-state.inactive i { background: #FDA4AF; }
|
.online-state { display: flex; align-items: center; gap: 4px; margin-top: 3px; font-size: 11px; opacity: .9; }.online-state i { width: 7px; height: 7px; border-radius: 50%; background: #86EFAC; box-shadow: 0 0 0 2px rgba(255,255,255,.22); }.online-state.training i { background: #FDE68A; }.online-state.inactive i { background: #FDA4AF; }
|
||||||
.message-list { min-height: 0; flex: 1 1 auto; width: min(760px, 100%); box-sizing: border-box; margin: 0 auto; padding: 24px 18px 120px; overflow-y: auto; overscroll-behavior: contain; }
|
.message-list { min-height: 0; flex: 1 1 auto; width: min(760px, 100%); box-sizing: border-box; margin: 0 auto; padding: 24px 18px 120px; overflow-y: auto; overscroll-behavior: contain; }
|
||||||
|
.chat-page.has-pending-images .message-list { padding-bottom: min(330px, 42vh); }
|
||||||
.welcome-card { padding: 28px 20px; text-align: center; background: rgba(255,255,255,.72); border: 1px solid #FFE1C2; border-radius: 22px; box-shadow: 0 10px 28px rgba(181, 99, 35, .08); }
|
.welcome-card { padding: 28px 20px; text-align: center; background: rgba(255,255,255,.72); border: 1px solid #FFE1C2; border-radius: 22px; box-shadow: 0 10px 28px rgba(181, 99, 35, .08); }
|
||||||
.welcome-avatar { width: 64px; height: 64px; display: grid; place-items: center; margin: 0 auto 14px; overflow: hidden; border: 3px solid #fff; border-radius: 50%; background: #FFE4C7; box-shadow: 0 7px 16px rgba(181, 99, 35, .18); font-size: 32px; }.welcome-avatar img { width: 100%; height: 100%; object-fit: cover; }
|
.welcome-avatar { width: 64px; height: 64px; display: grid; place-items: center; margin: 0 auto 14px; overflow: hidden; border: 3px solid #fff; border-radius: 50%; background: #FFE4C7; box-shadow: 0 7px 16px rgba(181, 99, 35, .18); font-size: 32px; }.welcome-avatar img { width: 100%; height: 100%; object-fit: cover; }
|
||||||
.welcome-card h2 { margin: 0 0 8px; font-size: 20px; }.welcome-description { max-width: 340px; margin: 0 auto; color: #8B6B58; font-size: 14px; line-height: 1.65; }
|
.welcome-card h2 { margin: 0 0 8px; font-size: 20px; }.welcome-description { max-width: 340px; margin: 0 auto; color: #8B6B58; font-size: 14px; line-height: 1.65; }
|
||||||
@@ -282,6 +419,10 @@ onMounted(loadAvatar)
|
|||||||
.message-row.user { justify-content: flex-end; }
|
.message-row.user { justify-content: flex-end; }
|
||||||
.message-avatar { flex: 0 0 auto; width: 42px; height: 42px; display: grid; place-items: center; overflow: hidden; border: 2px solid rgba(255,255,255,.9); border-radius: 14px; background: #FFE4C7; box-shadow: 0 3px 10px rgba(96, 52, 21, .12); font-size: 16px; }.message-avatar img { width: 100%; height: 100%; object-fit: cover; }.user-message-face { color: #fff; background: #D97706; }
|
.message-avatar { flex: 0 0 auto; width: 42px; height: 42px; display: grid; place-items: center; overflow: hidden; border: 2px solid rgba(255,255,255,.9); border-radius: 14px; background: #FFE4C7; box-shadow: 0 3px 10px rgba(96, 52, 21, .12); font-size: 16px; }.message-avatar img { width: 100%; height: 100%; object-fit: cover; }.user-message-face { color: #fff; background: #D97706; }
|
||||||
.message-column { max-width: min(78%, 560px); }
|
.message-column { max-width: min(78%, 560px); }
|
||||||
|
.message-images { display: grid; grid-template-columns: repeat(2, minmax(0, 150px)); gap: 8px; margin-bottom: 8px; }
|
||||||
|
.message-image-card { margin: 0; overflow: hidden; border: 1px solid #F4D4B8; border-radius: 14px; background: #fff; box-shadow: 0 4px 14px rgba(96, 52, 21, .08); }
|
||||||
|
.message-image-card img { display: block; width: 100%; max-height: 210px; object-fit: cover; }
|
||||||
|
.message-image-card figcaption { padding: 7px 9px; color: #8A5A3B; background: #FFF6ED; font-size: 10px; line-height: 1.45; }
|
||||||
.message-bubble { padding: 12px 14px; white-space: pre-wrap; line-height: 1.6; font-size: 15px; border-radius: 4px 16px 16px 16px; background: white; box-shadow: 0 3px 12px rgba(96, 52, 21, .07); }
|
.message-bubble { padding: 12px 14px; white-space: pre-wrap; line-height: 1.6; font-size: 15px; border-radius: 4px 16px 16px 16px; background: white; box-shadow: 0 3px 12px rgba(96, 52, 21, .07); }
|
||||||
.message-bubble.streaming::after { content: ''; display: inline-block; width: 2px; height: 1.05em; margin-left: 3px; vertical-align: -0.16em; background: currentColor; animation: type-cursor .75s step-end infinite; }
|
.message-bubble.streaming::after { content: ''; display: inline-block; width: 2px; height: 1.05em; margin-left: 3px; vertical-align: -0.16em; background: currentColor; animation: type-cursor .75s step-end infinite; }
|
||||||
.typing-character { display: inline-block; animation: character-in .24s cubic-bezier(.2,.72,.25,1) both; }.typing-character.newline { display: block; height: 0; }
|
.typing-character { display: inline-block; animation: character-in .24s cubic-bezier(.2,.72,.25,1) both; }.typing-character.newline { display: block; height: 0; }
|
||||||
@@ -298,7 +439,27 @@ onMounted(loadAvatar)
|
|||||||
@keyframes type-cursor { 50% { opacity: 0; } }
|
@keyframes type-cursor { 50% { opacity: 0; } }
|
||||||
@keyframes character-in { from { opacity: 0; transform: translateY(3px); } to { opacity: 1; transform: translateY(0); } }
|
@keyframes character-in { from { opacity: 0; transform: translateY(3px); } to { opacity: 1; transform: translateY(0); } }
|
||||||
.chat-error { margin: 4px auto; color: #B42318; font-size: 13px; }.chat-error button { border: 0; background: none; color: #C15F18; cursor: pointer; text-decoration: underline; }
|
.chat-error { margin: 4px auto; color: #B42318; font-size: 13px; }.chat-error button { border: 0; background: none; color: #C15F18; cursor: pointer; text-decoration: underline; }
|
||||||
.composer { position: fixed; left: 0; right: 0; bottom: 0; display: flex; gap: 10px; padding: 12px max(18px, calc((100vw - 760px) / 2 + 18px)); background: rgba(255,255,255,.92); border-top: 1px solid #F4DCC7; backdrop-filter: blur(12px); }
|
.composer { position: fixed; left: 0; right: 0; bottom: 0; display: flex; flex-direction: column; gap: 9px; padding: 10px max(18px, calc((100vw - 760px) / 2 + 18px)) 12px; background: rgba(255,255,255,.94); border-top: 1px solid #F4DCC7; backdrop-filter: blur(14px); }
|
||||||
|
.composer-row { display: flex; align-items: flex-end; gap: 9px; }
|
||||||
.composer textarea { flex: 1; resize: none; min-height: 22px; max-height: 100px; padding: 11px 13px; border: 1px solid #EED8C5; border-radius: 13px; font: inherit; color: #3B2417; outline: none; }.composer textarea:focus { border-color: #F97316; }
|
.composer textarea { flex: 1; resize: none; min-height: 22px; max-height: 100px; padding: 11px 13px; border: 1px solid #EED8C5; border-radius: 13px; font: inherit; color: #3B2417; outline: none; }.composer textarea:focus { border-color: #F97316; }
|
||||||
|
.image-input { display: none; }
|
||||||
|
.image-btn { flex: 0 0 auto; width: 44px; height: 44px; display: grid; place-items: center; border: 1px solid #EED8C5; border-radius: 13px; color: #C65A11; background: #FFF8F1; cursor: pointer; }
|
||||||
|
.image-btn svg { width: 22px; height: 22px; fill: currentColor; }
|
||||||
|
.image-btn:disabled { opacity: .4; cursor: not-allowed; }
|
||||||
|
.pending-images { display: grid; gap: 7px; }
|
||||||
|
.pending-image { display: grid; grid-template-columns: 48px minmax(0, 1fr) 30px; align-items: center; gap: 9px; min-height: 48px; padding: 6px 8px; border: 1px solid #F1D4BB; border-radius: 14px; background: #FFF9F3; }
|
||||||
|
.pending-image img { width: 48px; height: 48px; object-fit: cover; border-radius: 10px; }
|
||||||
|
.pending-image-copy { min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.pending-image-copy strong { overflow: hidden; color: #4B2B19; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.pending-image-copy span { color: #9A7159; font-size: 10px; }
|
||||||
|
.pending-image.uploading strong::after { content: ''; display: inline-block; width: 7px; height: 7px; margin-left: 7px; border: 2px solid #F6B889; border-top-color: #F97316; border-radius: 50%; animation: image-spin .7s linear infinite; }
|
||||||
|
.pending-image > button { width: 28px; height: 28px; border: 0; border-radius: 9px; color: #9A7159; background: #F8E8D9; font-size: 19px; cursor: pointer; }
|
||||||
|
.medical-note { margin: 0; padding: 0 2px; color: #9A5A2E; font-size: 10px; line-height: 1.45; }
|
||||||
.send-btn { align-self: flex-end; padding: 11px 18px; border: 0; border-radius: 12px; color: white; background: #F97316; cursor: pointer; }.send-btn:disabled { opacity: .45; cursor: not-allowed; }
|
.send-btn { align-self: flex-end; padding: 11px 18px; border: 0; border-radius: 12px; color: white; background: #F97316; cursor: pointer; }.send-btn:disabled { opacity: .45; cursor: not-allowed; }
|
||||||
|
@keyframes image-spin { to { transform: rotate(360deg); } }
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.message-images { grid-template-columns: minmax(0, 220px); }
|
||||||
|
.message-column { max-width: 80%; }
|
||||||
|
.send-btn { padding-inline: 14px; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="edit-avatar-page">
|
<div class="edit-avatar-page">
|
||||||
<!-- 顶部导航 -->
|
<!-- 顶部导航 -->
|
||||||
<header class="page-header">
|
<header v-if="!isEmbedded" class="page-header">
|
||||||
<button class="back-btn" @click="goBack">‹</button>
|
<button class="back-btn" @click="goBack">‹</button>
|
||||||
<h1 class="page-title">分身微调</h1>
|
<h1 class="page-title">分身微调</h1>
|
||||||
<button class="save-btn" :disabled="loading || saving || uploadingPhoto" @click="saveChanges">
|
<span class="header-spacer" aria-hidden="true"></span>
|
||||||
{{ saving ? '保存中...' : '保存' }}
|
|
||||||
</button>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div v-if="loading" class="status-banner">加载中...</div>
|
<div v-if="loading" class="status-banner">加载中...</div>
|
||||||
@@ -159,6 +157,13 @@
|
|||||||
{{ deleting ? '删除中...' : '删除数字分身' }}
|
{{ deleting ? '删除中...' : '删除数字分身' }}
|
||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<footer class="edit-action-bar">
|
||||||
|
<button class="action-back-btn" type="button" :disabled="saving" @click="goBack">返回</button>
|
||||||
|
<button class="action-save-btn" type="button" :disabled="loading || saving || uploadingPhoto" @click="saveChanges">
|
||||||
|
{{ saving ? '保存中...' : '保存修改' }}
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -168,11 +173,13 @@ import { useRoute, useRouter } from 'vue-router'
|
|||||||
import { deleteAvatar as apiDeleteAvatar, getAvatarDetail, updateAvatar, uploadAvatarPhoto } from '@/api'
|
import { deleteAvatar as apiDeleteAvatar, getAvatarDetail, updateAvatar, uploadAvatarPhoto } from '@/api'
|
||||||
import { useAvatarStore } from '@/store/avatar'
|
import { useAvatarStore } from '@/store/avatar'
|
||||||
import { buildAvatarUpdatePayload, normalizeAvatarEditForm } from '@/utils/avatar-page-data.js'
|
import { buildAvatarUpdatePayload, normalizeAvatarEditForm } from '@/utils/avatar-page-data.js'
|
||||||
|
import { isHuihuiEmbeddedMode } from '@/utils/embed-mode'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const avatarStore = useAvatarStore()
|
const avatarStore = useAvatarStore()
|
||||||
const avatarId = route.params.id as string
|
const avatarId = route.params.id as string
|
||||||
|
const isEmbedded = isHuihuiEmbeddedMode()
|
||||||
|
|
||||||
// 表单数据
|
// 表单数据
|
||||||
const formData = reactive({
|
const formData = reactive({
|
||||||
@@ -288,7 +295,7 @@ onMounted(async () => {
|
|||||||
.edit-avatar-page {
|
.edit-avatar-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: #F8F9FA;
|
background: #F8F9FA;
|
||||||
padding-bottom: 40px;
|
padding-bottom: calc(104px + env(safe-area-inset-bottom));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 顶部导航 */
|
/* 顶部导航 */
|
||||||
@@ -331,16 +338,7 @@ onMounted(async () => {
|
|||||||
color: #B91C1C;
|
color: #B91C1C;
|
||||||
}
|
}
|
||||||
|
|
||||||
.save-btn {
|
.header-spacer { width: 40px; }
|
||||||
background: #F97316;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
padding: 8px 20px;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 头像上传 */
|
/* 头像上传 */
|
||||||
.photo-section {
|
.photo-section {
|
||||||
@@ -580,4 +578,47 @@ onMounted(async () => {
|
|||||||
background: #EF4444;
|
background: #EF4444;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.edit-action-bar {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 30;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 20px calc(14px + env(safe-area-inset-bottom));
|
||||||
|
border-top: 1px solid rgba(229, 231, 235, .9);
|
||||||
|
background: rgba(248, 249, 250, .96);
|
||||||
|
box-shadow: 0 -8px 24px rgba(56, 38, 24, .06);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-back-btn,
|
||||||
|
.action-save-btn {
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 14px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-back-btn {
|
||||||
|
flex: 0 0 104px;
|
||||||
|
border: 1px solid #E4E0DC;
|
||||||
|
color: #655E58;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-save-btn {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
border: 0;
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(105deg, #F79A38, #F97316);
|
||||||
|
box-shadow: 0 8px 18px rgba(249, 115, 22, .18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-back-btn:disabled,
|
||||||
|
.action-save-btn:disabled { opacity: .6; cursor: not-allowed; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="avatar-manage-page">
|
<div class="avatar-manage-page">
|
||||||
<!-- 顶部导航 -->
|
<!-- 顶部导航 -->
|
||||||
<header class="page-header">
|
<header v-if="!isEmbedded" class="page-header">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<button class="back-btn" @click="goBack">‹</button>
|
<button class="back-btn" @click="goBack">‹</button>
|
||||||
<h1 class="page-title">数字分身管理</h1>
|
<h1 class="page-title">数字分身管理</h1>
|
||||||
</div>
|
</div>
|
||||||
<!-- 右上角创建入口 -->
|
|
||||||
<div class="header-right">
|
|
||||||
<button class="icon-btn" @click="goCreate" title="创建数字分身">➕</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- 用户资料头(会会登录账号的头像 / 昵称) -->
|
<!-- 用户资料头(会会登录账号的头像 / 昵称) -->
|
||||||
@@ -24,12 +20,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Token 余额条 -->
|
<!-- 积分余额条:暂时隐藏,保留完整实现便于后续恢复。 -->
|
||||||
<section class="token-section">
|
<section v-if="SHOW_POINTS_BALANCE_CARD" class="token-section">
|
||||||
<div class="token-card">
|
<div class="token-card">
|
||||||
<div class="token-info">
|
<div class="token-info">
|
||||||
<span class="token-label">Token 余额</span>
|
<span class="token-label">积分余额</span>
|
||||||
<span class="token-amount">{{ tokenBalance.toLocaleString() }}</span>
|
<span class="token-amount">{{ tokenBalance.toLocaleString() }}</span>
|
||||||
|
<span class="token-used">累计使用 {{ tokenConsumed.toLocaleString() }}</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="recharge-btn" @click="goToRecharge">充值</button>
|
<button class="recharge-btn" @click="goToRecharge">充值</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -38,12 +35,20 @@
|
|||||||
<!-- 数字分身列表(只放分身相关) -->
|
<!-- 数字分身列表(只放分身相关) -->
|
||||||
<section class="avatar-list-section">
|
<section class="avatar-list-section">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<h3 class="section-title">我的数字分身</h3>
|
<div class="section-heading-copy">
|
||||||
<span class="count-badge">{{ avatars.length }}</span>
|
<h3 class="section-title">我的数字分身</h3>
|
||||||
|
<span class="count-badge">{{ avatars.length }}</span>
|
||||||
|
</div>
|
||||||
|
<button class="section-create-btn" type="button" @click="goCreate">
|
||||||
|
<span aria-hidden="true">+</span> 添加分身
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="avatars.length" class="avatar-list">
|
<div v-if="avatars.length" class="avatar-list">
|
||||||
<div class="avatar-card" v-for="a in avatars" :key="a.id">
|
<div class="avatar-card" v-for="a in avatars" :key="a.id">
|
||||||
|
<button class="card-edit" @click="goToEdit(a.id)" :aria-label="`编辑${a.displayName || a.name}`">
|
||||||
|
<span aria-hidden="true">✎</span> 编辑
|
||||||
|
</button>
|
||||||
<div class="avatar-card-main">
|
<div class="avatar-card-main">
|
||||||
<div class="avatar-photo">
|
<div class="avatar-photo">
|
||||||
<img v-if="a.photoUrl" :src="a.photoUrl" alt="" referrerpolicy="no-referrer" class="avatar-img" />
|
<img v-if="a.photoUrl" :src="a.photoUrl" alt="" referrerpolicy="no-referrer" class="avatar-img" />
|
||||||
@@ -52,13 +57,14 @@
|
|||||||
<div class="avatar-details">
|
<div class="avatar-details">
|
||||||
<div class="avatar-name-row"><h2 class="avatar-name">{{ a.displayName || a.name }}</h2><span class="avatar-status"><i class="status-dot" :class="a.status"></i>{{ statusText(a.status) }}</span></div>
|
<div class="avatar-name-row"><h2 class="avatar-name">{{ a.displayName || a.name }}</h2><span class="avatar-status"><i class="status-dot" :class="a.status"></i>{{ statusText(a.status) }}</span></div>
|
||||||
<p class="avatar-desc">{{ a.description || '暂无描述' }}</p>
|
<p class="avatar-desc">{{ a.description || '暂无描述' }}</p>
|
||||||
|
<span class="avatar-token-usage">累计使用 {{ avatarTokenUsage(a.id).toLocaleString() }} 积分</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="avatar-actions">
|
<div class="avatar-actions">
|
||||||
<button class="chat-link" @click="goToChat(a.id)"><span>💬</span> 对话</button>
|
<button class="chat-link" @click="goToChat(a.id)"><span>💬</span> 对话</button>
|
||||||
|
<button class="knowledge-link" @click="goToKnowledge(a.id)"><span>📚</span> 知识库</button>
|
||||||
|
<button class="authorization-link" @click="goToAuthorization(a.id)"><span>🔑</span> 授权</button>
|
||||||
<button class="share-link" @click="shareAvatar(a)"><span>↗</span> 分享</button>
|
<button class="share-link" @click="shareAvatar(a)"><span>↗</span> 分享</button>
|
||||||
<button class="edit-link" @click="goToEdit(a.id)">编辑</button>
|
|
||||||
<button class="del-link" @click="askDelete(a)" aria-label="删除分身">删除</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -72,88 +78,7 @@
|
|||||||
|
|
||||||
<p v-if="shareToast" class="share-toast">{{ shareToast }}</p>
|
<p v-if="shareToast" class="share-toast">{{ shareToast }}</p>
|
||||||
|
|
||||||
<!-- 分身工具入口 -->
|
<!-- 分身工具和分身动态暂不展示,入口统一收拢到每张分身卡片。 -->
|
||||||
<section class="tools-section">
|
|
||||||
<h3 class="section-title">分身工具</h3>
|
|
||||||
<div class="tools-grid">
|
|
||||||
<div class="tool-card" @click="goToKnowledge">
|
|
||||||
<div class="tool-icon">📚</div>
|
|
||||||
<div class="tool-info">
|
|
||||||
<span class="tool-name">知识库管理</span>
|
|
||||||
<span class="tool-desc">上传文档与标准问答</span>
|
|
||||||
</div>
|
|
||||||
<span class="tool-arrow">›</span>
|
|
||||||
</div>
|
|
||||||
<div class="tool-card" @click="goToAvatarCard">
|
|
||||||
<div class="tool-icon">🪪</div>
|
|
||||||
<div class="tool-info">
|
|
||||||
<span class="tool-name">分身名片</span>
|
|
||||||
<span class="tool-desc">生成并分享名片</span>
|
|
||||||
</div>
|
|
||||||
<span class="tool-arrow">›</span>
|
|
||||||
</div>
|
|
||||||
<div class="tool-card" @click="goToAvatarContacts">
|
|
||||||
<div class="tool-icon">🤝</div>
|
|
||||||
<div class="tool-info">
|
|
||||||
<span class="tool-name">分身人脉</span>
|
|
||||||
<span class="tool-desc">管理社交关系</span>
|
|
||||||
</div>
|
|
||||||
<span class="tool-arrow">›</span>
|
|
||||||
</div>
|
|
||||||
<div class="tool-card" @click="goToMyProjects">
|
|
||||||
<div class="tool-icon">📁</div>
|
|
||||||
<div class="tool-info">
|
|
||||||
<span class="tool-name">我的项目</span>
|
|
||||||
<span class="tool-desc">查看参与项目</span>
|
|
||||||
</div>
|
|
||||||
<span class="tool-arrow">›</span>
|
|
||||||
</div>
|
|
||||||
<div class="tool-card" @click="goToCreateOrg">
|
|
||||||
<div class="tool-icon">🏢</div>
|
|
||||||
<div class="tool-info">
|
|
||||||
<span class="tool-name">创建组织</span>
|
|
||||||
<span class="tool-desc">新建组织团队</span>
|
|
||||||
</div>
|
|
||||||
<span class="tool-arrow">›</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- 分身动态列表 -->
|
|
||||||
<section class="activities-section">
|
|
||||||
<h3 class="section-title">分身动态</h3>
|
|
||||||
<div class="activity-list" v-if="activities.length > 0">
|
|
||||||
<div class="activity-item" v-for="activity in activities" :key="activity.id">
|
|
||||||
<div class="activity-icon" :class="activity.type">{{ activityIcon(activity.type) }}</div>
|
|
||||||
<div class="activity-content">
|
|
||||||
<p class="activity-text">{{ activity.text }}</p>
|
|
||||||
<span class="activity-time">{{ formatTime(activity.createdAt) }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="empty-state" v-else>
|
|
||||||
<span class="empty-icon">📭</span>
|
|
||||||
<p class="empty-text">暂无动态</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- 删除确认弹窗 -->
|
|
||||||
<div v-if="showDelete" class="modal-mask" @click.self="cancelDelete">
|
|
||||||
<div class="modal">
|
|
||||||
<div class="modal-icon">⚠️</div>
|
|
||||||
<h3 class="modal-title">删除数字分身</h3>
|
|
||||||
<p class="modal-text">
|
|
||||||
确认删除「{{ pendingDelete?.displayName || pendingDelete?.name }}」?<br />
|
|
||||||
其知识库、问答对、授权等关联数据将一并清除,且<b>不可恢复</b>。
|
|
||||||
</p>
|
|
||||||
<div class="modal-actions">
|
|
||||||
<button class="modal-cancel" @click="cancelDelete">取消</button>
|
|
||||||
<button class="modal-confirm" :disabled="deleting" @click="confirmDelete">
|
|
||||||
{{ deleting ? '删除中...' : '确认删除' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -163,30 +88,27 @@ import { useRouter } from 'vue-router'
|
|||||||
import { useAvatarStore } from '@/store/avatar'
|
import { useAvatarStore } from '@/store/avatar'
|
||||||
import { useUserStore } from '@/store/user'
|
import { useUserStore } from '@/store/user'
|
||||||
import { createAvatarShareLink } from '@/api'
|
import { createAvatarShareLink } from '@/api'
|
||||||
|
import { isHuihuiEmbeddedMode } from '@/utils/embed-mode'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const avatarStore = useAvatarStore()
|
const avatarStore = useAvatarStore()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
const isEmbedded = isHuihuiEmbeddedMode()
|
||||||
|
|
||||||
|
// 临时产品开关:余额卡片代码保留,后续改为 true 即可恢复展示。
|
||||||
|
const SHOW_POINTS_BALANCE_CARD = false
|
||||||
|
|
||||||
// 当前登录会会用户的资料(头像 / 昵称)
|
// 当前登录会会用户的资料(头像 / 昵称)
|
||||||
const me = computed(() => userStore.user)
|
const me = computed(() => userStore.user)
|
||||||
|
|
||||||
// 状态(来自 store / 后端)
|
// 状态(来自 store / 后端)
|
||||||
const tokenBalance = computed(() => avatarStore.tokenBalance)
|
const tokenBalance = computed(() => avatarStore.tokenBalance)
|
||||||
|
const tokenConsumed = computed(() => avatarStore.tokenConsumed)
|
||||||
const avatars = computed(() => avatarStore.avatars)
|
const avatars = computed(() => avatarStore.avatars)
|
||||||
|
const avatarTokenUsage = (id: string) => avatarStore.tokenUsageByAvatar[id] || 0
|
||||||
|
|
||||||
// 删除确认弹窗状态
|
|
||||||
const showDelete = ref(false)
|
|
||||||
const pendingDelete = ref<any>(null)
|
|
||||||
const deleting = ref(false)
|
|
||||||
const shareToast = ref('')
|
const shareToast = ref('')
|
||||||
|
|
||||||
const activities = ref<Array<{ id: string; type: string; text: string; createdAt: string }>>([
|
|
||||||
{ id: '1', type: 'create', text: '数字分身创建成功', createdAt: new Date(Date.now() - 86400000).toISOString() },
|
|
||||||
{ id: '2', type: 'edit', text: '更新了分身描述', createdAt: new Date(Date.now() - 43200000).toISOString() },
|
|
||||||
{ id: '3', type: 'authorize', text: '授权微信小程序访问', createdAt: new Date(Date.now() - 3600000).toISOString() }
|
|
||||||
])
|
|
||||||
|
|
||||||
// 状态文本
|
// 状态文本
|
||||||
const statusText = (status: string) => {
|
const statusText = (status: string) => {
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
@@ -197,55 +119,6 @@ const statusText = (status: string) => {
|
|||||||
return map[status] || status
|
return map[status] || status
|
||||||
}
|
}
|
||||||
|
|
||||||
// 活动图标
|
|
||||||
const activityIcon = (type: string) => {
|
|
||||||
const map: Record<string, string> = {
|
|
||||||
'create': '✨',
|
|
||||||
'edit': '✏️',
|
|
||||||
'authorize': '🔑',
|
|
||||||
'interact': '💬'
|
|
||||||
}
|
|
||||||
return map[type] || '📌'
|
|
||||||
}
|
|
||||||
|
|
||||||
// 格式化时间
|
|
||||||
const formatTime = (time: string) => {
|
|
||||||
const date = new Date(time)
|
|
||||||
const now = new Date()
|
|
||||||
const diff = now.getTime() - date.getTime()
|
|
||||||
const minutes = Math.floor(diff / 60000)
|
|
||||||
const hours = Math.floor(diff / 3600000)
|
|
||||||
const days = Math.floor(diff / 86400000)
|
|
||||||
|
|
||||||
if (minutes < 60) return `${minutes}分钟前`
|
|
||||||
if (hours < 24) return `${hours}小时前`
|
|
||||||
return `${days}天前`
|
|
||||||
}
|
|
||||||
|
|
||||||
// 删除流程
|
|
||||||
const askDelete = (a: any) => {
|
|
||||||
pendingDelete.value = a
|
|
||||||
showDelete.value = true
|
|
||||||
}
|
|
||||||
const cancelDelete = () => {
|
|
||||||
if (deleting.value) return
|
|
||||||
showDelete.value = false
|
|
||||||
pendingDelete.value = null
|
|
||||||
}
|
|
||||||
const confirmDelete = async () => {
|
|
||||||
if (!pendingDelete.value || deleting.value) return
|
|
||||||
deleting.value = true
|
|
||||||
try {
|
|
||||||
await avatarStore.removeAvatar(pendingDelete.value.id)
|
|
||||||
showDelete.value = false
|
|
||||||
pendingDelete.value = null
|
|
||||||
} catch (e: any) {
|
|
||||||
alert(e?.message || '删除失败,请稍后重试')
|
|
||||||
} finally {
|
|
||||||
deleting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 导航
|
// 导航
|
||||||
const goBack = () => {
|
const goBack = () => {
|
||||||
router.back()
|
router.back()
|
||||||
@@ -259,8 +132,12 @@ const goCreate = () => {
|
|||||||
router.push('/avatar/create')
|
router.push('/avatar/create')
|
||||||
}
|
}
|
||||||
|
|
||||||
const goToKnowledge = () => {
|
const goToKnowledge = (id: string) => {
|
||||||
router.push('/knowledge')
|
router.push({ name: 'AvatarKnowledgeManage', params: { avatarId: id } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const goToAuthorization = (id: string) => {
|
||||||
|
router.push({ name: 'AvatarAuthorizationManage', params: { avatarId: id } })
|
||||||
}
|
}
|
||||||
|
|
||||||
const goToEdit = (id: string) => {
|
const goToEdit = (id: string) => {
|
||||||
@@ -303,26 +180,11 @@ const shareAvatar = async (avatar: any) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const goToAvatarCard = () => {
|
|
||||||
router.push('/avatar/card')
|
|
||||||
}
|
|
||||||
|
|
||||||
const goToAvatarContacts = () => {
|
|
||||||
router.push('/avatar/contacts')
|
|
||||||
}
|
|
||||||
|
|
||||||
const goToMyProjects = () => {
|
|
||||||
router.push('/avatar/projects')
|
|
||||||
}
|
|
||||||
|
|
||||||
const goToCreateOrg = () => {
|
|
||||||
router.push('/avatar/org/create')
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
userStore.loadFromStorage()
|
userStore.loadFromStorage()
|
||||||
avatarStore.loadAvatars()
|
avatarStore.loadAvatars()
|
||||||
avatarStore.loadTokenBalance()
|
avatarStore.loadTokenBalance()
|
||||||
|
avatarStore.loadTokenUsage()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -330,7 +192,7 @@ onMounted(() => {
|
|||||||
.avatar-manage-page {
|
.avatar-manage-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: #F8F9FA;
|
background: #F8F9FA;
|
||||||
padding-bottom: 80px;
|
padding-bottom: calc(28px + env(safe-area-inset-bottom));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 顶部导航 */
|
/* 顶部导航 */
|
||||||
@@ -433,7 +295,7 @@ onMounted(() => {
|
|||||||
background: rgba(255, 255, 255, 0.3);
|
background: rgba(255, 255, 255, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Token 余额条 */
|
/* 积分余额条 */
|
||||||
.token-section {
|
.token-section {
|
||||||
padding: 16px 20px;
|
padding: 16px 20px;
|
||||||
}
|
}
|
||||||
@@ -465,6 +327,12 @@ onMounted(() => {
|
|||||||
color: #F97316;
|
color: #F97316;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.token-used {
|
||||||
|
margin-top: 3px;
|
||||||
|
color: #A0A5B4;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
.recharge-btn {
|
.recharge-btn {
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
background: #F97316;
|
background: #F97316;
|
||||||
@@ -489,10 +357,36 @@ onMounted(() => {
|
|||||||
.section-head {
|
.section-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
margin: 8px 0 12px;
|
margin: 8px 0 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.section-heading-copy {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-create-btn {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #FED7B5;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #E9650C;
|
||||||
|
background: #FFF7ED;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 650;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-create-btn span { font-size: 17px; line-height: 1; }
|
||||||
|
.section-create-btn:active { background: #FFEDD5; }
|
||||||
|
|
||||||
.section-title {
|
.section-title {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -516,13 +410,32 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.avatar-card {
|
.avatar-card {
|
||||||
|
position: relative;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
background: white;
|
background: white;
|
||||||
border: 1px solid #F4E5D9;
|
border: 1px solid #F4E5D9;
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
box-shadow: 0 8px 22px rgba(112, 62, 22, .07);
|
box-shadow: 0 8px 22px rgba(112, 62, 22, .07);
|
||||||
}
|
}
|
||||||
.avatar-card-main { display: flex; align-items: center; gap: 14px; }
|
.avatar-card-main { display: flex; align-items: center; gap: 14px; padding: 4px 68px 2px 0; }
|
||||||
|
|
||||||
|
.card-edit {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border: 1px solid #E9E2DC;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #6B625C;
|
||||||
|
background: #FAF8F6;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-edit:active { background: #F2EDE8; }
|
||||||
|
|
||||||
.avatar-photo {
|
.avatar-photo {
|
||||||
width: 66px;
|
width: 66px;
|
||||||
@@ -570,6 +483,13 @@ onMounted(() => {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.avatar-token-usage {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 5px;
|
||||||
|
color: #A0A5B4;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.avatar-status {
|
.avatar-status {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -598,56 +518,39 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.avatar-actions {
|
.avatar-actions {
|
||||||
display: flex;
|
display: grid;
|
||||||
align-items: center;
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.avatar-actions button {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 58px;
|
||||||
|
padding: 8px 3px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-actions button span { font-size: 17px; line-height: 1; }
|
||||||
|
|
||||||
.chat-link {
|
.chat-link {
|
||||||
flex: 1;
|
|
||||||
padding: 10px 8px;
|
|
||||||
background: linear-gradient(135deg, #F97316, #FB923C);
|
background: linear-gradient(135deg, #F97316, #FB923C);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 10px;
|
|
||||||
font-size: 13px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
.chat-link span, .share-link span { margin-right: 3px; }
|
.knowledge-link { border: 1px solid #F5D7B8; color: #A85418; background: #FFF8F1; }
|
||||||
.share-link { flex: 1; padding: 10px 8px; border: 1px solid #FFD5AF; border-radius: 10px; color: #C15F18; background: #FFF8F1; font-size: 13px; cursor: pointer; }
|
.authorization-link { border: 1px solid #DCE5F2; color: #47627D; background: #F6F9FC; }
|
||||||
|
.share-link { border: 1px solid #E5E1DD; color: #675E58; background: #FAF8F6; }
|
||||||
|
|
||||||
.edit-link {
|
.share-toast { position: fixed; left: 50%; bottom: 24px; z-index: 300; max-width: calc(100vw - 48px); transform: translateX(-50%); padding: 10px 14px; border-radius: 10px; color: white; background: rgba(39, 32, 28, .88); font-size: 13px; text-align: center; }
|
||||||
padding: 10px 10px;
|
|
||||||
background: #F3F4F6;
|
|
||||||
color: #6B7280;
|
|
||||||
border: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 13px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-link:hover {
|
|
||||||
background: #E5E7EB;
|
|
||||||
}
|
|
||||||
|
|
||||||
.del-link {
|
|
||||||
padding: 10px 2px;
|
|
||||||
background: transparent;
|
|
||||||
color: #B6BCC8;
|
|
||||||
border: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 13px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.share-toast { position: fixed; left: 50%; bottom: 92px; z-index: 300; max-width: calc(100vw - 48px); transform: translateX(-50%); padding: 10px 14px; border-radius: 10px; color: white; background: rgba(39, 32, 28, .88); font-size: 13px; text-align: center; }
|
|
||||||
|
|
||||||
.del-link:hover {
|
|
||||||
background: #FEE2E2;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 空状态 */
|
/* 空状态 */
|
||||||
.empty-state {
|
.empty-state {
|
||||||
@@ -684,197 +587,4 @@ onMounted(() => {
|
|||||||
box-shadow: 0 4px 12px rgba(249, 115, 22, 0.3);
|
box-shadow: 0 4px 12px rgba(249, 115, 22, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 分身工具入口 */
|
|
||||||
.tools-section {
|
|
||||||
padding: 8px 20px 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tools-grid {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-card {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 14px 16px;
|
|
||||||
background: white;
|
|
||||||
border-radius: 12px;
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: transform 0.2s, box-shadow 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-card:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-icon {
|
|
||||||
font-size: 24px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-info {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-name {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #18191C;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-desc {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #9398AE;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-arrow {
|
|
||||||
font-size: 18px;
|
|
||||||
color: #C9CDD2;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 分身动态列表 */
|
|
||||||
.activities-section {
|
|
||||||
padding: 0 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 14px 16px;
|
|
||||||
background: white;
|
|
||||||
border-radius: 12px;
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-icon {
|
|
||||||
font-size: 20px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: #FFF0E6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-content {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-text {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #18191C;
|
|
||||||
margin: 0 0 4px;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-time {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #9398AE;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 删除确认弹窗 */
|
|
||||||
.modal-mask {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.45);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 24px;
|
|
||||||
z-index: 50;
|
|
||||||
animation: fade 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fade { from { opacity: 0; } to { opacity: 1; } }
|
|
||||||
|
|
||||||
.modal {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 320px;
|
|
||||||
background: white;
|
|
||||||
border-radius: 18px;
|
|
||||||
padding: 24px 22px 18px;
|
|
||||||
text-align: center;
|
|
||||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.25);
|
|
||||||
animation: pop 0.25s cubic-bezier(0.16, 1, 0.3, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pop { from { opacity: 0; transform: scale(0.94); } to { opacity: 1; transform: none; } }
|
|
||||||
|
|
||||||
.modal-icon {
|
|
||||||
font-size: 36px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-title {
|
|
||||||
font-size: 17px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #18191C;
|
|
||||||
margin: 0 0 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-text {
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.6;
|
|
||||||
color: #6B7280;
|
|
||||||
margin: 0 0 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-cancel {
|
|
||||||
flex: 1;
|
|
||||||
padding: 12px;
|
|
||||||
background: #F3F4F6;
|
|
||||||
color: #6B7280;
|
|
||||||
border: none;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-confirm {
|
|
||||||
flex: 1;
|
|
||||||
padding: 12px;
|
|
||||||
background: #EF4444;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: opacity 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-confirm:hover {
|
|
||||||
opacity: 0.92;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-confirm:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="knowledge-page">
|
<div class="knowledge-page" :class="{ embedded: isEmbedded }">
|
||||||
<!-- 顶部导航 -->
|
<!-- 顶部导航 -->
|
||||||
<header class="page-header">
|
<header v-if="!isEmbedded" class="page-header">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<button class="back-btn" @click="goBack">‹</button>
|
<button class="back-btn" @click="goBack">‹</button>
|
||||||
<h1 class="page-title">知识库管理</h1>
|
<h1 class="page-title">知识库管理</h1>
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="tab-switcher" role="tablist" aria-label="知识库类型">
|
<div class="tab-switcher" role="tablist" aria-label="知识库类型">
|
||||||
<button class="tab-btn" :class="{ active: activeTab === 'docs' }" role="tab" :aria-selected="activeTab === 'docs'" @click="activeTab = 'docs'">文档知识库 <b>{{ docs.length }}</b></button>
|
<button class="tab-btn" :class="{ active: activeTab === 'docs' }" role="tab" :aria-selected="activeTab === 'docs'" @click="activeTab = 'docs'">文档知识库 <b>{{ displayDocs.length }}</b></button>
|
||||||
<button class="tab-btn" :class="{ active: activeTab === 'qa' }" role="tab" :aria-selected="activeTab === 'qa'" @click="activeTab = 'qa'">标准问答对 <b>{{ qaPairs.length }}</b></button>
|
<button class="tab-btn" :class="{ active: activeTab === 'qa' }" role="tab" :aria-selected="activeTab === 'qa'" @click="activeTab = 'qa'">标准问答对 <b>{{ qaPairs.length }}</b></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -23,26 +23,37 @@
|
|||||||
<div class="upload-section">
|
<div class="upload-section">
|
||||||
<div class="upload-zone" :class="{ 'drag-over': dragOver }" @click="triggerFile" @dragover.prevent="dragOver = true" @dragleave.prevent="dragOver = false" @drop.prevent="onDrop">
|
<div class="upload-zone" :class="{ 'drag-over': dragOver }" @click="triggerFile" @dragover.prevent="dragOver = true" @dragleave.prevent="dragOver = false" @drop.prevent="onDrop">
|
||||||
<div class="upload-icon">📥</div>
|
<div class="upload-icon">📥</div>
|
||||||
<p class="upload-title">拖拽文件到此处,或<span class="upload-link">点击上传</span></p>
|
<p class="upload-title"><span class="upload-link">点击上传</span></p>
|
||||||
<p class="upload-hint">支持 MD / TXT / PDF / DOC / DOCX / XLSX,上传后自动向量化</p>
|
<p class="upload-hint">支持 MD / TXT / PDF / DOC / DOCX / XLSX,上传后自动向量化</p>
|
||||||
<input ref="fileInput" type="file" accept=".md,.txt,.pdf,.doc,.docx,.xlsx" class="hidden-input" @change="onFileChange" />
|
<input ref="fileInput" type="file" multiple accept=".md,.txt,.pdf,.doc,.docx,.xlsx" class="hidden-input" @change="onFileChange" />
|
||||||
</div>
|
</div>
|
||||||
<p v-if="uploading" class="uploading-text">上传并向量化中…</p>
|
<p v-if="uploading" class="uploading-text">{{ pendingUploads.length }} 个文件正在上传</p>
|
||||||
<p v-if="uploadError" class="error-text">{{ uploadError }}</p>
|
<p v-if="uploadError" class="error-text">{{ uploadError }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="docs.length" class="mobile-card-list">
|
<div v-if="displayDocs.length" class="mobile-card-list">
|
||||||
<article v-for="doc in docs" :key="doc.id" class="knowledge-card">
|
<article v-for="doc in displayDocs" :key="doc.id" class="knowledge-card document-card">
|
||||||
<div class="card-icon">{{ fileEmoji(doc.fileType) }}</div>
|
<div class="card-icon">{{ fileEmoji(doc.fileType) }}</div>
|
||||||
<div class="card-content">
|
<div class="card-content">
|
||||||
<div class="card-title-row">
|
<div class="card-title-row">
|
||||||
<strong>{{ doc.filename }}</strong>
|
<strong>{{ doc.filename }}</strong>
|
||||||
<span class="status-pill" :class="{ pending: !doc.vectorized && doc.filePresent !== false, missing: doc.filePresent === false }">{{ doc.filePresent === false ? '文件缺失' : (doc.vectorized ? '已入库' : '处理中') }}</span>
|
<span class="status-pill" :class="documentState(doc).tone">{{ documentState(doc).label }}</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="card-meta">{{ doc.fileType.toUpperCase() }} · {{ formatSize(doc.fileSize) }} · {{ formatDate(doc.createdAt) }}</p>
|
<p class="card-meta">{{ doc.fileType.toUpperCase() }} · {{ formatSize(doc.fileSize) }} · {{ formatDate(doc.createdAt) }}</p>
|
||||||
<p class="card-detail">{{ doc.filePresent === false ? '原文件不可用,请删除后重新上传' : (doc.vectorized ? `已切分 ${doc.chunkCount || 0} 段,可用于对话` : '正在解析并建立知识索引') }}</p>
|
<p class="card-detail">{{ documentState(doc).detail }}</p>
|
||||||
|
<div v-if="documentState(doc).progress !== undefined" class="progress-track" :aria-label="`${documentState(doc).label} ${documentState(doc).progress}%`">
|
||||||
|
<span class="progress-fill" :style="{ width: `${documentState(doc).progress}%` }"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-actions">
|
||||||
|
<button v-if="!doc.localUploading" class="card-delete" @click="removeDoc(doc.id)">{{ doc.localOnly ? '移除' : '删除' }}</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="canRetryDoc(doc)" class="card-retry-area">
|
||||||
|
<span v-if="retryErrors[doc.id]" class="card-retry-error">{{ retryErrors[doc.id] }}</span>
|
||||||
|
<button class="card-retry" :disabled="retryingDocs[doc.id]" @click="retryDoc(doc)">
|
||||||
|
{{ retryingDocs[doc.id] ? '重新索引中…' : '重新索引' }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button class="card-delete" @click="removeDoc(doc.id)">删除</button>
|
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="card-empty">📂 暂无文档,先上传一个知识文件</div>
|
<div v-else class="card-empty">📂 暂无文档,先上传一个知识文件</div>
|
||||||
@@ -78,14 +89,16 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed } from 'vue'
|
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useAvatarStore } from '@/store/avatar'
|
import { useAvatarStore } from '@/store/avatar'
|
||||||
import { pickAvatarId, unwrapListData } from '@/utils/avatar-page-data.js'
|
import { pickScopedAvatarId, unwrapListData } from '@/utils/avatar-page-data.js'
|
||||||
|
import { isHuihuiEmbeddedMode } from '@/utils/embed-mode'
|
||||||
import {
|
import {
|
||||||
getKnowledgeDocs,
|
getKnowledgeDocs,
|
||||||
uploadKnowledgeDoc,
|
uploadKnowledgeDoc,
|
||||||
deleteKnowledgeDoc,
|
deleteKnowledgeDoc,
|
||||||
|
retryKnowledgeDoc,
|
||||||
getQAPairs,
|
getQAPairs,
|
||||||
deleteQAPair,
|
deleteQAPair,
|
||||||
searchKnowledge,
|
searchKnowledge,
|
||||||
@@ -93,28 +106,80 @@ import {
|
|||||||
} from '@/api'
|
} from '@/api'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
const store = useAvatarStore()
|
const store = useAvatarStore()
|
||||||
|
const isEmbedded = isHuihuiEmbeddedMode()
|
||||||
|
|
||||||
const avatarId = computed(() => pickAvatarId(store.currentAvatarId, store.avatars))
|
const avatarId = computed(() => pickScopedAvatarId(route.params.avatarId, store.currentAvatarId, store.avatars))
|
||||||
const activeTab = ref<'docs' | 'qa'>('docs')
|
const activeTab = ref<'docs' | 'qa'>('docs')
|
||||||
|
|
||||||
const docs = ref<any[]>([])
|
const docs = ref<any[]>([])
|
||||||
|
const pendingUploads = ref<any[]>([])
|
||||||
const qaPairs = ref<any[]>([])
|
const qaPairs = ref<any[]>([])
|
||||||
const uploading = ref(false)
|
const uploading = computed(() => pendingUploads.value.some((doc) => doc.localUploading))
|
||||||
const uploadError = ref('')
|
const uploadError = ref('')
|
||||||
const dragOver = ref(false)
|
const dragOver = ref(false)
|
||||||
const fileInput = ref<HTMLInputElement | null>(null)
|
const fileInput = ref<HTMLInputElement | null>(null)
|
||||||
|
const retryingDocs = ref<Record<string, boolean>>({})
|
||||||
|
const retryErrors = ref<Record<string, string>>({})
|
||||||
|
let documentPollingTimer: ReturnType<typeof setInterval> | undefined
|
||||||
|
|
||||||
const query = ref('')
|
const query = ref('')
|
||||||
const searching = ref(false)
|
const searching = ref(false)
|
||||||
const searched = ref(false)
|
const searched = ref(false)
|
||||||
const searchResults = ref<any[]>([])
|
const searchResults = ref<any[]>([])
|
||||||
|
|
||||||
|
const displayDocs = computed(() => [...pendingUploads.value, ...docs.value])
|
||||||
|
|
||||||
|
const documentState = (doc: any) => {
|
||||||
|
if (doc.localUploading) {
|
||||||
|
return { tone: 'pending', label: '上传中', detail: `正在上传 ${doc.uploadProgress || 0}%`, progress: doc.uploadProgress || 0 }
|
||||||
|
}
|
||||||
|
if (doc.localOnly) {
|
||||||
|
return { tone: 'failed', label: '上传失败', detail: doc.errorMessage || '文件未上传成功,请移除后重试' }
|
||||||
|
}
|
||||||
|
if (doc.filePresent === false) {
|
||||||
|
return { tone: 'missing', label: '文件缺失', detail: '原文件不可用,请删除后重新上传' }
|
||||||
|
}
|
||||||
|
if (doc.vectorized) {
|
||||||
|
return { tone: 'ready', label: '已入库', detail: `已切分 ${doc.chunkCount || 0} 段,可用于对话` }
|
||||||
|
}
|
||||||
|
if (['uploaded', 'parsing'].includes(String(doc.status || '').toLowerCase())) {
|
||||||
|
const stage = String(doc.indexStage || 'queued').toLowerCase()
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
queued: '等待处理', extracting: '解析文档', ocr: '扫描件识别', chunking: '切分文本', embedding: '向量化中'
|
||||||
|
}
|
||||||
|
const progress = Math.max(0, Math.min(99, Number(doc.indexProgress || 0)))
|
||||||
|
return { tone: 'pending', label: labels[stage] || '处理中', detail: `${labels[stage] || '正在建立知识索引'} ${progress}%`, progress }
|
||||||
|
}
|
||||||
|
return { tone: 'failed', label: '处理失败', detail: doc.errorMessage || '未能建立知识索引,请重新索引或重新上传' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasPendingDocuments = () => docs.value.some((doc) =>
|
||||||
|
['uploaded', 'parsing'].includes(String(doc.status || '').toLowerCase())
|
||||||
|
)
|
||||||
|
|
||||||
|
const stopDocumentPolling = () => {
|
||||||
|
if (documentPollingTimer) {
|
||||||
|
clearInterval(documentPollingTimer)
|
||||||
|
documentPollingTimer = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDocumentPolling = () => {
|
||||||
|
if (documentPollingTimer || !hasPendingDocuments()) return
|
||||||
|
documentPollingTimer = setInterval(async () => {
|
||||||
|
await loadDocs()
|
||||||
|
if (!hasPendingDocuments()) stopDocumentPolling()
|
||||||
|
}, 2000)
|
||||||
|
}
|
||||||
|
|
||||||
const loadDocs = async () => {
|
const loadDocs = async () => {
|
||||||
if (!avatarId.value) return
|
if (!avatarId.value) return
|
||||||
try {
|
try {
|
||||||
const res: any = await getKnowledgeDocs(avatarId.value)
|
const res: any = await getKnowledgeDocs(avatarId.value)
|
||||||
docs.value = unwrapListData(res)
|
docs.value = unwrapListData(res)
|
||||||
|
startDocumentPolling()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
}
|
}
|
||||||
@@ -133,40 +198,92 @@ const loadQA = async () => {
|
|||||||
const triggerFile = () => fileInput.value?.click()
|
const triggerFile = () => fileInput.value?.click()
|
||||||
|
|
||||||
const onFileChange = (e: Event) => {
|
const onFileChange = (e: Event) => {
|
||||||
const f = (e.target as HTMLInputElement).files?.[0]
|
const files = Array.from((e.target as HTMLInputElement).files || [])
|
||||||
if (f) doUpload(f)
|
if (files.length) uploadFiles(files)
|
||||||
;(e.target as HTMLInputElement).value = ''
|
;(e.target as HTMLInputElement).value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
const onDrop = (e: DragEvent) => {
|
const onDrop = (e: DragEvent) => {
|
||||||
dragOver.value = false
|
dragOver.value = false
|
||||||
const f = e.dataTransfer?.files?.[0]
|
const files = Array.from(e.dataTransfer?.files || [])
|
||||||
if (f) doUpload(f)
|
if (files.length) uploadFiles(files)
|
||||||
}
|
}
|
||||||
|
|
||||||
const doUpload = async (file: File) => {
|
const uploadFiles = (files: File[]) => {
|
||||||
uploadError.value = ''
|
uploadError.value = ''
|
||||||
const ext = '.' + (file.name.split('.').pop() || '').toLowerCase()
|
|
||||||
if (!['.md', '.txt', '.pdf', '.doc', '.docx', '.xlsx'].includes(ext)) {
|
|
||||||
uploadError.value = `不支持的类型:${ext},仅支持 md/txt/pdf/doc/docx/xlsx`
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!avatarId.value) {
|
if (!avatarId.value) {
|
||||||
uploadError.value = '请先创建数字分身'
|
uploadError.value = '请先创建数字分身'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
uploading.value = true
|
for (const file of files) {
|
||||||
|
const ext = '.' + (file.name.split('.').pop() || '').toLowerCase()
|
||||||
|
if (!['.md', '.txt', '.pdf', '.doc', '.docx', '.xlsx'].includes(ext)) {
|
||||||
|
uploadError.value = `不支持的类型:${ext},仅支持 md/txt/pdf/doc/docx/xlsx`
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
void uploadOne(file, ext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadOne = async (file: File, ext: string) => {
|
||||||
|
if (!avatarId.value) return
|
||||||
|
const localId = `upload-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||||
|
const card = {
|
||||||
|
id: localId,
|
||||||
|
filename: file.name,
|
||||||
|
fileType: ext.slice(1),
|
||||||
|
fileSize: file.size,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
localUploading: true,
|
||||||
|
localOnly: true,
|
||||||
|
uploadProgress: 0,
|
||||||
|
errorMessage: ''
|
||||||
|
}
|
||||||
|
pendingUploads.value.unshift(card)
|
||||||
try {
|
try {
|
||||||
await uploadKnowledgeDoc(avatarId.value, file)
|
const created: any = await uploadKnowledgeDoc(avatarId.value, file, (loaded, total) => {
|
||||||
await loadDocs()
|
const current = pendingUploads.value.find((doc) => doc.id === localId)
|
||||||
|
if (current) current.uploadProgress = Math.min(99, Math.round((loaded / Math.max(1, total)) * 100))
|
||||||
|
})
|
||||||
|
pendingUploads.value = pendingUploads.value.filter((doc) => doc.id !== localId)
|
||||||
|
docs.value = [created, ...docs.value.filter((doc) => doc.id !== created.id)]
|
||||||
|
startDocumentPolling()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
uploadError.value = e?.message || '上传失败'
|
const current = pendingUploads.value.find((doc) => doc.id === localId)
|
||||||
|
if (current) {
|
||||||
|
current.localUploading = false
|
||||||
|
current.errorMessage = e?.message || '上传失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const canRetryDoc = (doc: any) =>
|
||||||
|
!doc.localOnly && doc.filePresent !== false && documentState(doc).tone === 'failed'
|
||||||
|
|
||||||
|
const retryDoc = async (doc: any) => {
|
||||||
|
if (!avatarId.value || !canRetryDoc(doc) || retryingDocs.value[doc.id]) return
|
||||||
|
retryingDocs.value = { ...retryingDocs.value, [doc.id]: true }
|
||||||
|
retryErrors.value = { ...retryErrors.value, [doc.id]: '' }
|
||||||
|
try {
|
||||||
|
const updated: any = await retryKnowledgeDoc(avatarId.value, doc.id)
|
||||||
|
Object.assign(doc, updated)
|
||||||
|
startDocumentPolling()
|
||||||
|
} catch (e: any) {
|
||||||
|
retryErrors.value = {
|
||||||
|
...retryErrors.value,
|
||||||
|
[doc.id]: e?.response?.data?.message || e?.response?.data?.detail || e?.message || '重新索引失败'
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
uploading.value = false
|
retryingDocs.value = { ...retryingDocs.value, [doc.id]: false }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeDoc = async (id: string) => {
|
const removeDoc = async (id: string) => {
|
||||||
|
const local = pendingUploads.value.find((doc) => doc.id === id)
|
||||||
|
if (local?.localOnly) {
|
||||||
|
pendingUploads.value = pendingUploads.value.filter((doc) => doc.id !== id)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!avatarId.value) return
|
if (!avatarId.value) return
|
||||||
await deleteKnowledgeDoc(avatarId.value, id)
|
await deleteKnowledgeDoc(avatarId.value, id)
|
||||||
await loadDocs()
|
await loadDocs()
|
||||||
@@ -200,9 +317,21 @@ const toggleQa = async (qa: any, e: Event) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const goAddQa = () => router.push('/knowledge/qa/create')
|
const goAddQa = () => {
|
||||||
|
if (route.params.avatarId && avatarId.value) {
|
||||||
|
router.push({ name: 'AvatarQaPairCreate', params: { avatarId: avatarId.value } })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
router.push('/knowledge/qa/create')
|
||||||
|
}
|
||||||
|
|
||||||
const goEditQa = (qa: any) => router.push(`/knowledge/qa/${qa.id}/edit`)
|
const goEditQa = (qa: any) => {
|
||||||
|
if (route.params.avatarId && avatarId.value) {
|
||||||
|
router.push({ name: 'AvatarQaPairEdit', params: { avatarId: avatarId.value, qaId: qa.id } })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
router.push(`/knowledge/qa/${qa.id}/edit`)
|
||||||
|
}
|
||||||
|
|
||||||
const removeQa = async (id: string) => {
|
const removeQa = async (id: string) => {
|
||||||
if (!avatarId.value) return
|
if (!avatarId.value) return
|
||||||
@@ -226,15 +355,18 @@ onMounted(async () => {
|
|||||||
if (!store.avatars.length) {
|
if (!store.avatars.length) {
|
||||||
await store.loadAvatars()
|
await store.loadAvatars()
|
||||||
}
|
}
|
||||||
|
if (avatarId.value) store.currentAvatarId = avatarId.value
|
||||||
await Promise.all([loadDocs(), loadQA()])
|
await Promise.all([loadDocs(), loadQA()])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onUnmounted(stopDocumentPolling)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.knowledge-page {
|
.knowledge-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: #F8F9FA;
|
background: #F8F9FA;
|
||||||
padding-bottom: 80px;
|
padding-bottom: calc(28px + env(safe-area-inset-bottom));
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,16 +395,31 @@ onMounted(async () => {
|
|||||||
.panel-heading p { margin: -5px 0 0; color: #9398AE; font-size: 12px; }
|
.panel-heading p { margin: -5px 0 0; color: #9398AE; font-size: 12px; }
|
||||||
.mobile-card-list { display: grid; grid-template-columns: minmax(0, 1fr); width: 100%; min-width: 0; gap: 10px; }
|
.mobile-card-list { display: grid; grid-template-columns: minmax(0, 1fr); width: 100%; min-width: 0; gap: 10px; }
|
||||||
.knowledge-card { display: flex; align-items: center; width: 100%; min-width: 0; box-sizing: border-box; gap: 11px; padding: 14px; background: #fff; border: 1px solid #F1E1D3; border-radius: 16px; box-shadow: 0 5px 16px rgba(112, 62, 22, .04); }
|
.knowledge-card { display: flex; align-items: center; width: 100%; min-width: 0; box-sizing: border-box; gap: 11px; padding: 14px; background: #fff; border: 1px solid #F1E1D3; border-radius: 16px; box-shadow: 0 5px 16px rgba(112, 62, 22, .04); }
|
||||||
|
.document-card { display: grid; grid-template-columns: 42px minmax(0, 1fr) auto; align-items: center; }
|
||||||
.card-icon { flex: 0 0 auto; width: 42px; height: 42px; display: grid; place-items: center; border-radius: 13px; background: #FFF3E6; font-size: 22px; }
|
.card-icon { flex: 0 0 auto; width: 42px; height: 42px; display: grid; place-items: center; border-radius: 13px; background: #FFF3E6; font-size: 22px; }
|
||||||
.card-content { min-width: 0; flex: 1; overflow: hidden; }
|
.card-content { min-width: 0; flex: 1; overflow: hidden; }
|
||||||
.card-title-row { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
.card-title-row { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||||
.card-title-row strong { min-width: 0; flex: 1; overflow: hidden; color: #27201C; font-size: 14px; text-overflow: ellipsis; white-space: nowrap; }
|
.card-title-row strong { min-width: 0; flex: 1; overflow: hidden; color: #27201C; font-size: 14px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.status-pill { flex: 0 0 auto; display: inline-flex; padding: 4px 7px; border-radius: 999px; color: #15803D; background: #ECFDF3; font-size: 10px; white-space: nowrap; }.status-pill.pending { color: #B45309; background: #FFFBEB; }
|
.status-pill { flex: 0 0 auto; display: inline-flex; padding: 4px 7px; border-radius: 999px; color: #15803D; background: #ECFDF3; font-size: 10px; white-space: nowrap; }.status-pill.pending { color: #B45309; background: #FFFBEB; }
|
||||||
.status-pill.missing { color: #B91C1C; background: #FEF2F2; }
|
.status-pill.missing { color: #B91C1C; background: #FEF2F2; }
|
||||||
|
.status-pill.failed { color: #B91C1C; background: #FEF2F2; }
|
||||||
.card-meta, .card-detail { margin: 5px 0 0; color: #9398AE; font-size: 11px; line-height: 1.4; }.card-detail { color: #8B6B58; }
|
.card-meta, .card-detail { margin: 5px 0 0; color: #9398AE; font-size: 11px; line-height: 1.4; }.card-detail { color: #8B6B58; }
|
||||||
.card-delete { flex: 0 0 auto; align-self: center; border: 0; color: #EF4444; background: #FEF2F2; border-radius: 8px; padding: 7px 9px; font-size: 12px; cursor: pointer; }
|
.progress-track { width: 100%; height: 4px; margin-top: 8px; overflow: hidden; border-radius: 999px; background: #FDE7D1; }
|
||||||
|
.progress-fill { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg, #FB923C, #F97316); transition: width .25s ease; }
|
||||||
|
.card-actions { flex: 0 0 auto; display: flex; align-items: center; }
|
||||||
|
.card-delete, .card-retry { align-self: center; border: 0; border-radius: 8px; padding: 7px 9px; font-size: 12px; cursor: pointer; white-space: nowrap; }
|
||||||
|
.card-delete { color: #EF4444; background: #FEF2F2; }
|
||||||
|
.card-retry { color: #C15F18; background: #FFF3E6; }
|
||||||
|
.card-retry:disabled { cursor: wait; opacity: .65; }
|
||||||
|
.card-retry-area { grid-column: 1 / -1; display: flex; align-items: center; justify-content: flex-end; gap: 10px; min-width: 0; }
|
||||||
|
.card-retry-error { min-width: 0; overflow: hidden; color: #DC2626; font-size: 11px; line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.card-empty { padding: 42px 16px; border: 1px dashed #F1D9C3; border-radius: 16px; color: #9398AE; background: #fff; font-size: 14px; text-align: center; }
|
.card-empty { padding: 42px 16px; border: 1px dashed #F1D9C3; border-radius: 16px; color: #9398AE; background: #fff; font-size: 14px; text-align: center; }
|
||||||
.qa-card { align-items: stretch; }.qa-card.qa-disabled { opacity: .58; }
|
.qa-card { align-items: stretch; text-align: left; }.qa-card.qa-disabled { opacity: .58; }
|
||||||
|
.qa-card .card-content,
|
||||||
|
.qa-card .qa-question,
|
||||||
|
.qa-card .qa-answer,
|
||||||
|
.qa-card .card-meta,
|
||||||
|
.qa-card .qa-card-actions { text-align: left; }
|
||||||
.qa-card-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }.qa-label { color: #C15F18; font-size: 11px; font-weight: 700; }
|
.qa-card-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }.qa-label { color: #C15F18; font-size: 11px; font-weight: 700; }
|
||||||
.qa-question { display: block; color: #27201C; font-size: 15px; line-height: 1.5; }.qa-answer { display: -webkit-box; margin: 7px 0 0; overflow: hidden; color: #6B7280; font-size: 13px; line-height: 1.55; -webkit-box-orient: vertical; -webkit-line-clamp: 3; }
|
.qa-question { display: block; color: #27201C; font-size: 15px; line-height: 1.5; }.qa-answer { display: -webkit-box; margin: 7px 0 0; overflow: hidden; color: #6B7280; font-size: 13px; line-height: 1.55; -webkit-box-orient: vertical; -webkit-line-clamp: 3; }
|
||||||
.qa-card-actions { display: flex; gap: 8px; margin-top: 11px; }
|
.qa-card-actions { display: flex; gap: 8px; margin-top: 11px; }
|
||||||
@@ -491,8 +638,13 @@ onMounted(async () => {
|
|||||||
@media (max-width: 520px) {
|
@media (max-width: 520px) {
|
||||||
.knowledge-panel { padding: 0 12px; }
|
.knowledge-panel { padding: 0 12px; }
|
||||||
.knowledge-card { display: grid; grid-template-columns: 42px minmax(0, 1fr); align-items: start; gap: 10px; padding: 13px; }
|
.knowledge-card { display: grid; grid-template-columns: 42px minmax(0, 1fr); align-items: start; gap: 10px; padding: 13px; }
|
||||||
|
.document-card { grid-template-columns: 42px minmax(0, 1fr) auto; }
|
||||||
.card-content { grid-column: 2; }
|
.card-content { grid-column: 2; }
|
||||||
.card-delete { grid-column: 2; justify-self: end; margin-top: -2px; }
|
.card-actions { grid-column: 3; grid-row: 1; }
|
||||||
|
.card-delete { justify-self: end; margin-top: -2px; }
|
||||||
|
.card-retry-area { grid-column: 1 / -1; }
|
||||||
|
.qa-card { display: block; }
|
||||||
|
.qa-card .card-content { width: 100%; grid-column: 1; }
|
||||||
.card-title-row { align-items: flex-start; flex-wrap: wrap; gap: 5px 7px; }
|
.card-title-row { align-items: flex-start; flex-wrap: wrap; gap: 5px 7px; }
|
||||||
.status-pill { order: 2; }
|
.status-pill { order: 2; }
|
||||||
.search-bar { gap: 8px; }
|
.search-bar { gap: 8px; }
|
||||||
|
|||||||
@@ -10,10 +10,12 @@
|
|||||||
<section class="form-section">
|
<section class="form-section">
|
||||||
<label class="field-label">问题</label>
|
<label class="field-label">问题</label>
|
||||||
<textarea
|
<textarea
|
||||||
|
ref="questionInput"
|
||||||
v-model="form.question"
|
v-model="form.question"
|
||||||
class="field-input"
|
class="field-input question-input"
|
||||||
rows="3"
|
rows="1"
|
||||||
placeholder="例如:你们的退款政策是什么?"
|
placeholder="例如:你们的退款政策是什么?"
|
||||||
|
@input="resizeQuestion"
|
||||||
></textarea>
|
></textarea>
|
||||||
|
|
||||||
<label class="field-label">标准答案</label>
|
<label class="field-label">标准答案</label>
|
||||||
@@ -46,23 +48,31 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, computed, onMounted } from 'vue'
|
import { ref, reactive, computed, nextTick, onMounted } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { useAvatarStore } from '@/store/avatar'
|
import { useAvatarStore } from '@/store/avatar'
|
||||||
import { pickAvatarId, unwrapListData } from '@/utils/avatar-page-data.js'
|
import { pickScopedAvatarId, unwrapListData } from '@/utils/avatar-page-data.js'
|
||||||
import { getQAPairs, createQAPair, updateQAPair } from '@/api'
|
import { getQAPairs, createQAPair, updateQAPair } from '@/api'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const store = useAvatarStore()
|
const store = useAvatarStore()
|
||||||
|
|
||||||
const avatarId = computed(() => pickAvatarId(store.currentAvatarId, store.avatars))
|
const avatarId = computed(() => pickScopedAvatarId(route.params.avatarId, store.currentAvatarId, store.avatars))
|
||||||
const qaId = computed(() => (route.params.qaId as string) || null)
|
const qaId = computed(() => (route.params.qaId as string) || null)
|
||||||
const isEdit = computed(() => !!qaId.value)
|
const isEdit = computed(() => !!qaId.value)
|
||||||
|
|
||||||
const form = reactive({ question: '', answer: '', enabled: true })
|
const form = reactive({ question: '', answer: '', enabled: true })
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
|
const questionInput = ref<HTMLTextAreaElement | null>(null)
|
||||||
|
|
||||||
|
const resizeQuestion = (event?: Event) => {
|
||||||
|
const element = (event?.target as HTMLTextAreaElement | null) || questionInput.value
|
||||||
|
if (!element) return
|
||||||
|
element.style.height = 'auto'
|
||||||
|
element.style.height = `${element.scrollHeight}px`
|
||||||
|
}
|
||||||
|
|
||||||
const goBack = () => router.back()
|
const goBack = () => router.back()
|
||||||
|
|
||||||
@@ -104,8 +114,11 @@ const save = async () => {
|
|||||||
} else {
|
} else {
|
||||||
await createQAPair(avatarId.value, payload)
|
await createQAPair(avatarId.value, payload)
|
||||||
}
|
}
|
||||||
// 保存成功返回知识库管理页
|
if (route.params.avatarId) {
|
||||||
router.replace('/knowledge')
|
router.replace({ name: 'AvatarKnowledgeManage', params: { avatarId: avatarId.value } })
|
||||||
|
} else {
|
||||||
|
router.replace('/knowledge')
|
||||||
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
error.value = e?.message || '保存失败'
|
error.value = e?.message || '保存失败'
|
||||||
} finally {
|
} finally {
|
||||||
@@ -117,9 +130,12 @@ onMounted(async () => {
|
|||||||
if (!store.avatars.length) {
|
if (!store.avatars.length) {
|
||||||
await store.loadAvatars()
|
await store.loadAvatars()
|
||||||
}
|
}
|
||||||
|
if (avatarId.value) store.currentAvatarId = avatarId.value
|
||||||
if (isEdit.value) {
|
if (isEdit.value) {
|
||||||
await loadForEdit()
|
await loadForEdit()
|
||||||
}
|
}
|
||||||
|
await nextTick()
|
||||||
|
resizeQuestion()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -190,6 +206,13 @@ onMounted(async () => {
|
|||||||
border-color: #F97316;
|
border-color: #F97316;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.question-input {
|
||||||
|
min-height: 44px;
|
||||||
|
overflow: hidden;
|
||||||
|
resize: none;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
.switch-row {
|
.switch-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -138,7 +138,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { reactive, ref, computed, onUnmounted } from 'vue'
|
import { reactive, ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { useUserStore } from '@/store/user'
|
import { useUserStore } from '@/store/user'
|
||||||
import { useAvatarStore } from '@/store/avatar'
|
import { useAvatarStore } from '@/store/avatar'
|
||||||
@@ -161,6 +161,14 @@ const password = ref('')
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const errorMsg = ref('')
|
const errorMsg = ref('')
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const ssoError = sessionStorage.getItem('hh_sso_error')
|
||||||
|
if (ssoError) {
|
||||||
|
errorMsg.value = ssoError
|
||||||
|
sessionStorage.removeItem('hh_sso_error')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const counting = ref(false)
|
const counting = ref(false)
|
||||||
const countdown = ref(60)
|
const countdown = ref(60)
|
||||||
let timer: any = null
|
let timer: any = null
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<!-- 顶部导航 -->
|
<!-- 顶部导航 -->
|
||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
<button class="back-btn" @click="goBack">‹</button>
|
<button class="back-btn" @click="goBack">‹</button>
|
||||||
<h1 class="page-title">Token 充值</h1>
|
<h1 class="page-title">积分充值</h1>
|
||||||
<div class="header-right"></div>
|
<div class="header-right"></div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -12,7 +12,8 @@
|
|||||||
<div class="balance-card">
|
<div class="balance-card">
|
||||||
<span class="balance-label">当前余额</span>
|
<span class="balance-label">当前余额</span>
|
||||||
<span class="balance-amount">{{ currentBalance.toLocaleString() }}</span>
|
<span class="balance-amount">{{ currentBalance.toLocaleString() }}</span>
|
||||||
<span class="balance-unit">Token</span>
|
<span class="balance-unit">积分</span>
|
||||||
|
<span class="balance-used">累计使用 {{ totalConsumed.toLocaleString() }} 积分</span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -28,10 +29,10 @@
|
|||||||
@click="selectedPlan = plan"
|
@click="selectedPlan = plan"
|
||||||
>
|
>
|
||||||
<div class="plan-badge" v-if="plan.badge">{{ plan.badge }}</div>
|
<div class="plan-badge" v-if="plan.badge">{{ plan.badge }}</div>
|
||||||
<div class="plan-amount">{{ plan.amount.toLocaleString() }}</div>
|
<div class="plan-amount">{{ formatTokenAmount(plan.amount) }}</div>
|
||||||
<div class="plan-unit">Token</div>
|
<div class="plan-unit">积分</div>
|
||||||
<div class="plan-price">¥{{ plan.price }}</div>
|
<div class="plan-price">¥{{ plan.price }}</div>
|
||||||
<div class="plan-desc" v-if="plan.desc">{{ plan.desc }}</div>
|
<div class="plan-desc" v-if="plan.desc">{{ formatPointsCopy(plan.desc) }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -45,7 +46,7 @@
|
|||||||
:class="{ selected: paymentMethod === 'wechat' }"
|
:class="{ selected: paymentMethod === 'wechat' }"
|
||||||
@click="paymentMethod = 'wechat'"
|
@click="paymentMethod = 'wechat'"
|
||||||
>
|
>
|
||||||
<span class="payment-icon">💚</span>
|
<span class="payment-icon wechat-icon"><img :src="wechatPayIcon" alt="" /></span>
|
||||||
<span class="payment-name">微信支付</span>
|
<span class="payment-name">微信支付</span>
|
||||||
<span class="payment-check" v-if="paymentMethod === 'wechat'">✓</span>
|
<span class="payment-check" v-if="paymentMethod === 'wechat'">✓</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -54,36 +55,47 @@
|
|||||||
:class="{ selected: paymentMethod === 'alipay' }"
|
:class="{ selected: paymentMethod === 'alipay' }"
|
||||||
@click="paymentMethod = 'alipay'"
|
@click="paymentMethod = 'alipay'"
|
||||||
>
|
>
|
||||||
<span class="payment-icon">💙</span>
|
<span class="payment-icon alipay-icon"><img :src="alipayIcon" alt="" /></span>
|
||||||
<span class="payment-name">支付宝</span>
|
<span class="payment-name">支付宝</span>
|
||||||
<span class="payment-check" v-if="paymentMethod === 'alipay'">✓</span>
|
<span class="payment-check" v-if="paymentMethod === 'alipay'">✓</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p v-if="paymentNotice" class="payment-notice" :class="paymentNoticeTone">{{ paymentNotice }}</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- 充值按钮 -->
|
<!-- 充值按钮 -->
|
||||||
<section class="checkout-section">
|
<section class="checkout-section">
|
||||||
<button
|
<button
|
||||||
class="checkout-btn"
|
class="checkout-btn"
|
||||||
:class="{ disabled: !selectedPlan }"
|
:class="{ disabled: !selectedPlan || charging || Boolean(pendingOrderId) }"
|
||||||
:disabled="!selectedPlan"
|
:disabled="!selectedPlan || charging || Boolean(pendingOrderId)"
|
||||||
@click="doCharge"
|
@click="doCharge"
|
||||||
>
|
>
|
||||||
{{ selectedPlan ? `立即支付 ¥${selectedPlan.price}` : '请选择充值套餐' }}
|
{{ checkoutLabel }}
|
||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { getTokenBalance, getRechargePlans, chargeToken } from '@/api'
|
import {
|
||||||
|
chargeToken,
|
||||||
|
getRechargePlans,
|
||||||
|
getTokenBalance,
|
||||||
|
getTokenPaymentStatus,
|
||||||
|
type TokenPaymentOrder
|
||||||
|
} from '@/api'
|
||||||
|
import { isInUniWebView, onNativeMessage, UniEvents } from '@/utils/uniapp-bridge'
|
||||||
|
import wechatPayIcon from '@/assets/wechat-pay.svg'
|
||||||
|
import alipayIcon from '@/assets/alipay.svg'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
// 当前余额
|
// 当前余额
|
||||||
const currentBalance = ref<number>(1250)
|
const currentBalance = ref<number>(0)
|
||||||
|
const totalConsumed = ref<number>(0)
|
||||||
|
|
||||||
// 充值套餐
|
// 充值套餐
|
||||||
const plans = ref<Array<{
|
const plans = ref<Array<{
|
||||||
@@ -99,12 +111,19 @@ const selectedPlan = ref<any>(null)
|
|||||||
|
|
||||||
// 支付方式
|
// 支付方式
|
||||||
const paymentMethod = ref<'wechat' | 'alipay'>('wechat')
|
const paymentMethod = ref<'wechat' | 'alipay'>('wechat')
|
||||||
|
const paymentNotice = ref('')
|
||||||
|
const paymentNoticeTone = ref<'pending' | 'success' | 'error'>('pending')
|
||||||
|
const pendingOrderId = ref(sessionStorage.getItem('hh_pending_payment_order') || '')
|
||||||
|
let pollTimer: number | undefined
|
||||||
|
let pollDeadline = 0
|
||||||
|
let removeNativeListener: (() => void) | undefined
|
||||||
|
|
||||||
// 从后端加载余额与套餐
|
// 从后端加载余额与套餐
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
const b: any = await getTokenBalance()
|
const b: any = await getTokenBalance()
|
||||||
currentBalance.value = b?.balance ?? 0
|
currentBalance.value = b?.balance ?? 0
|
||||||
|
totalConsumed.value = b?.totalConsumed ?? 0
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('加载余额失败', e)
|
console.error('加载余额失败', e)
|
||||||
}
|
}
|
||||||
@@ -116,18 +135,144 @@ const loadData = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 执行充值(写入后端)
|
// 会会支付订单创建与到账确认
|
||||||
const charging = ref(false)
|
const charging = ref(false)
|
||||||
|
const formatTokenAmount = (amount: number) => {
|
||||||
|
if (amount >= 1_000_000 && amount % 1_000_000 === 0) return `${amount / 1_000_000}M`
|
||||||
|
return amount.toLocaleString()
|
||||||
|
}
|
||||||
|
const formatPointsCopy = (value: string) => value.replace(/\bToken\b/gi, '积分')
|
||||||
|
|
||||||
|
const checkoutLabel = computed(() => {
|
||||||
|
if (!selectedPlan.value) return '请选择充值套餐'
|
||||||
|
if (charging.value) return '正在创建支付订单…'
|
||||||
|
if (pendingOrderId.value) return '等待支付结果…'
|
||||||
|
return `立即支付 ¥${selectedPlan.value.price}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const payScene = () => {
|
||||||
|
if (paymentMethod.value === 'wechat' && /MicroMessenger/i.test(navigator.userAgent)) return 'JSAPI' as const
|
||||||
|
return 'APP' as const
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsePayMessage = (message: string) => {
|
||||||
|
const text = (message || '').trim()
|
||||||
|
if (!text) return null
|
||||||
|
try {
|
||||||
|
return JSON.parse(text)
|
||||||
|
} catch {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const findPaymentUrl = (value: any): string => {
|
||||||
|
if (typeof value === 'string' && /^https?:\/\//i.test(value)) return value
|
||||||
|
if (!value || typeof value !== 'object') return ''
|
||||||
|
for (const key of ['url', 'payUrl', 'mwebUrl', 'mweb_url', 'redirectUrl']) {
|
||||||
|
if (typeof value[key] === 'string' && /^https?:\/\//i.test(value[key])) return value[key]
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const invokeWechatJsapi = (params: Record<string, any>) => new Promise<void>((resolve, reject) => {
|
||||||
|
const invoke = () => {
|
||||||
|
const bridge = (window as any).WeixinJSBridge
|
||||||
|
if (!bridge?.invoke) {
|
||||||
|
reject(new Error('当前微信环境无法拉起支付'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bridge.invoke('getBrandWCPayRequest', params, (result: any) => {
|
||||||
|
const message = String(result?.err_msg || result?.errMsg || '')
|
||||||
|
if (/\bok\b/i.test(message)) resolve()
|
||||||
|
else reject(new Error(/cancel/i.test(message) ? '支付已取消' : '微信支付未完成'))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if ((window as any).WeixinJSBridge) invoke()
|
||||||
|
else document.addEventListener('WeixinJSBridgeReady', invoke, { once: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
const launchPayment = async (order: TokenPaymentOrder) => {
|
||||||
|
const parsed = parsePayMessage(order.payMessage)
|
||||||
|
if (order.payWay === 'JSAPI' && order.paymentMethod === 'wechat' && parsed && typeof parsed === 'object') {
|
||||||
|
await invokeWechatJsapi(parsed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isInUniWebView() && UniEvents.payment({
|
||||||
|
orderId: order.id,
|
||||||
|
orderNo: order.orderNo,
|
||||||
|
paymentMethod: order.paymentMethod,
|
||||||
|
payType: order.payType,
|
||||||
|
payWay: order.payWay,
|
||||||
|
payMessage: order.payMessage,
|
||||||
|
paymentParams: parsed
|
||||||
|
})) return
|
||||||
|
|
||||||
|
const paymentUrl = findPaymentUrl(parsed)
|
||||||
|
if (paymentUrl) {
|
||||||
|
window.location.assign(paymentUrl)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
throw new Error('请在会会 App 内完成支付')
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearPendingOrder = () => {
|
||||||
|
pendingOrderId.value = ''
|
||||||
|
sessionStorage.removeItem('hh_pending_payment_order')
|
||||||
|
if (pollTimer) window.clearTimeout(pollTimer)
|
||||||
|
pollTimer = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const pollPayment = async () => {
|
||||||
|
if (!pendingOrderId.value) return
|
||||||
|
try {
|
||||||
|
const order = await getTokenPaymentStatus(pendingOrderId.value)
|
||||||
|
if (order.status === 'paid') {
|
||||||
|
currentBalance.value = order.balance
|
||||||
|
paymentNoticeTone.value = 'success'
|
||||||
|
paymentNotice.value = `支付成功,${order.pointsAmount.toLocaleString()} 积分已到账`
|
||||||
|
clearPendingOrder()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (order.status === 'failed') {
|
||||||
|
paymentNoticeTone.value = 'error'
|
||||||
|
paymentNotice.value = order.failureReason || '支付未完成,请重新发起'
|
||||||
|
clearPendingOrder()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('查询支付结果失败', error)
|
||||||
|
}
|
||||||
|
if (Date.now() < pollDeadline) pollTimer = window.setTimeout(pollPayment, 2000)
|
||||||
|
else {
|
||||||
|
paymentNoticeTone.value = 'pending'
|
||||||
|
paymentNotice.value = '支付结果仍在确认中,可稍后刷新余额或重新发起'
|
||||||
|
clearPendingOrder()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startPolling = (orderId: string) => {
|
||||||
|
pendingOrderId.value = orderId
|
||||||
|
sessionStorage.setItem('hh_pending_payment_order', orderId)
|
||||||
|
pollDeadline = Date.now() + 2 * 60 * 1000
|
||||||
|
if (pollTimer) window.clearTimeout(pollTimer)
|
||||||
|
void pollPayment()
|
||||||
|
}
|
||||||
|
|
||||||
const doCharge = async () => {
|
const doCharge = async () => {
|
||||||
if (!selectedPlan.value || charging.value) return
|
if (!selectedPlan.value || charging.value) return
|
||||||
charging.value = true
|
charging.value = true
|
||||||
|
paymentNotice.value = ''
|
||||||
try {
|
try {
|
||||||
const methodText = paymentMethod.value === 'wechat' ? '微信支付' : '支付宝'
|
const order = await chargeToken(selectedPlan.value.id, paymentMethod.value, payScene())
|
||||||
const res: any = await chargeToken(selectedPlan.value.id)
|
if (order.status === 'failed') throw new Error(order.failureReason || '支付订单创建失败')
|
||||||
currentBalance.value = res?.balance ?? currentBalance.value
|
paymentNoticeTone.value = 'pending'
|
||||||
alert(`已通过${methodText}成功充值,当前余额:${currentBalance.value} Token`)
|
paymentNotice.value = '支付订单已创建,正在等待付款结果'
|
||||||
} catch (e) {
|
startPolling(order.id)
|
||||||
alert('充值失败,请重试')
|
await launchPayment(order)
|
||||||
|
} catch (error: any) {
|
||||||
|
paymentNoticeTone.value = 'error'
|
||||||
|
paymentNotice.value = error?.message || '充值失败,请重试'
|
||||||
|
clearPendingOrder()
|
||||||
} finally {
|
} finally {
|
||||||
charging.value = false
|
charging.value = false
|
||||||
}
|
}
|
||||||
@@ -139,7 +284,33 @@ const goBack = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadData()
|
void loadData()
|
||||||
|
removeNativeListener = onNativeMessage((message) => {
|
||||||
|
if (message?.type !== 'paymentResult') return
|
||||||
|
if (message.orderId && message.orderId !== pendingOrderId.value) return
|
||||||
|
if (['cancelled', 'canceled'].includes(String(message.status).toLowerCase())) {
|
||||||
|
paymentNoticeTone.value = 'error'
|
||||||
|
paymentNotice.value = '支付已取消'
|
||||||
|
clearPendingOrder()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (String(message.status).toLowerCase() === 'failed') {
|
||||||
|
paymentNoticeTone.value = 'error'
|
||||||
|
paymentNotice.value = message.message || '支付未完成,请重新发起'
|
||||||
|
clearPendingOrder()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void pollPayment()
|
||||||
|
})
|
||||||
|
if (pendingOrderId.value) {
|
||||||
|
paymentNotice.value = '正在确认上一笔支付结果'
|
||||||
|
startPolling(pendingOrderId.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (pollTimer) window.clearTimeout(pollTimer)
|
||||||
|
removeNativeListener?.()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -187,6 +358,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
.balance-card {
|
.balance-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
@@ -211,6 +383,12 @@ onMounted(() => {
|
|||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.balance-used {
|
||||||
|
flex-basis: 100%;
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.82;
|
||||||
|
}
|
||||||
|
|
||||||
/* 充值套餐 */
|
/* 充值套餐 */
|
||||||
.plans-section {
|
.plans-section {
|
||||||
padding: 0 20px 20px;
|
padding: 0 20px 20px;
|
||||||
@@ -319,7 +497,24 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.payment-icon {
|
.payment-icon {
|
||||||
font-size: 24px;
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-icon img {
|
||||||
|
width: 25px;
|
||||||
|
height: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wechat-icon {
|
||||||
|
background: #EAF9F0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alipay-icon {
|
||||||
|
background: #EAF3FF;
|
||||||
}
|
}
|
||||||
|
|
||||||
.payment-name {
|
.payment-name {
|
||||||
@@ -342,6 +537,26 @@ onMounted(() => {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.payment-notice {
|
||||||
|
margin: 12px 2px 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: #9A5B16;
|
||||||
|
background: #FFF5E8;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-notice.success {
|
||||||
|
color: #08783E;
|
||||||
|
background: #EAF9F0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-notice.error {
|
||||||
|
color: #B42318;
|
||||||
|
background: #FFF0EE;
|
||||||
|
}
|
||||||
|
|
||||||
/* 充值按钮 */
|
/* 充值按钮 */
|
||||||
.checkout-section {
|
.checkout-section {
|
||||||
padding: 0 20px;
|
padding: 0 20px;
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ services:
|
|||||||
- REDIS_PORT=6379
|
- REDIS_PORT=6379
|
||||||
- SECRET_KEY=your-secret-key-change-in-production
|
- SECRET_KEY=your-secret-key-change-in-production
|
||||||
- AES_KEY=your-aes-key-32-chars-change-now!
|
- AES_KEY=your-aes-key-32-chars-change-now!
|
||||||
|
- AVATAR_MODEL_CONFIG_TOKEN=${AVATAR_MODEL_CONFIG_TOKEN:-}
|
||||||
- TZ=Asia/Shanghai
|
- TZ=Asia/Shanghai
|
||||||
- AVATAR_DB_PATH=/app/avatar.db
|
- AVATAR_DB_PATH=/app/avatar.db
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ CREATE TABLE IF NOT EXISTS `ai_model_configs` (
|
|||||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||||
`model_name` varchar(64) NOT NULL COMMENT '模型名称',
|
`model_name` varchar(64) NOT NULL COMMENT '模型名称',
|
||||||
`provider` varchar(32) NOT NULL COMMENT 'openai/zhipu/wenxin/qianwen/local',
|
`provider` varchar(32) NOT NULL COMMENT 'openai/zhipu/wenxin/qianwen/local',
|
||||||
|
`usage_scope` varchar(16) NOT NULL DEFAULT 'general' COMMENT '用途:general/digital_avatar',
|
||||||
`api_base_url` varchar(256) DEFAULT NULL COMMENT 'API地址',
|
`api_base_url` varchar(256) DEFAULT NULL COMMENT 'API地址',
|
||||||
`api_key_enc` varchar(512) DEFAULT NULL COMMENT '加密API Key',
|
`api_key_enc` varchar(512) DEFAULT NULL COMMENT '加密API Key',
|
||||||
`model_version` varchar(64) DEFAULT NULL COMMENT '模型版本',
|
`model_version` varchar(64) DEFAULT NULL COMMENT '模型版本',
|
||||||
|
|||||||
+2
-1
@@ -6,7 +6,8 @@ RUN npm install
|
|||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM nginx:alpine
|
# Nginx 1.31 uses syscalls that are blocked by the test server's legacy kernel.
|
||||||
|
FROM nginx:1.28.3-alpine
|
||||||
COPY --from=build /app/dist /usr/share/nginx/html
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|||||||
@@ -15,12 +15,17 @@
|
|||||||
<span class="model-title">{{ m.model_name }}</span>
|
<span class="model-title">{{ m.model_name }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:6px;align-items:center">
|
<div style="display:flex;gap:6px;align-items:center">
|
||||||
|
<el-tag :type="m.usage_scope === 'digital_avatar' ? 'warning' : 'info'" size="small">
|
||||||
|
{{ scopeLabels[m.usage_scope] || '通用业务' }}
|
||||||
|
</el-tag>
|
||||||
<el-tag v-if="m.is_default" type="success" size="small">默认</el-tag>
|
<el-tag v-if="m.is_default" type="success" size="small">默认</el-tag>
|
||||||
<el-tag v-if="!m.is_enabled" type="danger" size="small">禁用</el-tag>
|
<el-tag v-if="!m.is_enabled" type="danger" size="small">禁用</el-tag>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="model-meta">
|
<div class="model-meta">
|
||||||
<span>版本: {{ m.model_version || '--' }}</span>
|
<span>版本: {{ m.model_version || '--' }}</span>
|
||||||
|
<span v-if="m.usage_scope === 'digital_avatar'">视觉: {{ m.vision_model_version || 'qwen3.6-flash' }}</span>
|
||||||
|
<span v-if="m.usage_scope === 'digital_avatar'">病例OCR: {{ m.ocr_model_version || 'qwen-vl-ocr' }}</span>
|
||||||
<span>温度: {{ m.temperature }}</span>
|
<span>温度: {{ m.temperature }}</span>
|
||||||
<span>Max Tokens: {{ m.max_tokens }}</span>
|
<span>Max Tokens: {{ m.max_tokens }}</span>
|
||||||
<span>超时: {{ m.timeout_seconds }}s</span>
|
<span>超时: {{ m.timeout_seconds }}s</span>
|
||||||
@@ -49,6 +54,13 @@
|
|||||||
<el-option v-for="(l,v) in providerLabels" :key="v" :label="l" :value="v" />
|
<el-option v-for="(l,v) in providerLabels" :key="v" :label="l" :value="v" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item label="使用场景" prop="usage_scope">
|
||||||
|
<el-radio-group v-model="form.usage_scope">
|
||||||
|
<el-radio-button value="general">通用业务</el-radio-button>
|
||||||
|
<el-radio-button value="digital_avatar">数字分身专用</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
<div class="scope-tip">数字分身专用模型仅用于分身对话和主动接管回复</div>
|
||||||
|
</el-form-item>
|
||||||
<el-form-item label="API地址">
|
<el-form-item label="API地址">
|
||||||
<el-input v-model="form.api_base_url" placeholder="留空使用默认地址" />
|
<el-input v-model="form.api_base_url" placeholder="留空使用默认地址" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -58,6 +70,15 @@
|
|||||||
<el-form-item label="模型版本">
|
<el-form-item label="模型版本">
|
||||||
<el-input v-model="form.model_version" placeholder="如: gpt-4-turbo, glm-4" />
|
<el-input v-model="form.model_version" placeholder="如: gpt-4-turbo, glm-4" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<template v-if="form.usage_scope === 'digital_avatar'">
|
||||||
|
<el-form-item label="视觉模型">
|
||||||
|
<el-input v-model="form.vision_model_version" placeholder="如: qwen3.6-flash" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="病例OCR模型">
|
||||||
|
<el-input v-model="form.ocr_model_version" placeholder="如: qwen-vl-ocr" />
|
||||||
|
<div class="scope-tip">识别为病例、处方、检查单后自动调用,普通图片不会重复调用。</div>
|
||||||
|
</el-form-item>
|
||||||
|
</template>
|
||||||
<el-row :gutter="16">
|
<el-row :gutter="16">
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-form-item label="温度">
|
<el-form-item label="温度">
|
||||||
@@ -131,8 +152,9 @@ const testResult = ref(null)
|
|||||||
const testing = ref(false)
|
const testing = ref(false)
|
||||||
|
|
||||||
const providerLabels = { openai: 'OpenAI', zhipu: '智谱GLM', wenxin: '文心一言', qianwen: '通义千问', local: '本地模型' }
|
const providerLabels = { openai: 'OpenAI', zhipu: '智谱GLM', wenxin: '文心一言', qianwen: '通义千问', local: '本地模型' }
|
||||||
const form = reactive({ model_name: '', provider: 'openai', api_base_url: '', api_key: '', model_version: '', temperature: 0.7, max_tokens: 1000, timeout_seconds: 30, is_default: 0 })
|
const scopeLabels = { general: '通用业务', digital_avatar: '数字分身专用' }
|
||||||
const rules = { model_name: [{ required: true, message: '请输入模型名称' }], provider: [{ required: true }] }
|
const form = reactive({ model_name: '', provider: 'openai', usage_scope: 'general', api_base_url: '', api_key: '', model_version: '', vision_model_version: 'qwen3.6-flash', ocr_model_version: 'qwen-vl-ocr', temperature: 0.7, max_tokens: 1000, timeout_seconds: 30, is_default: 0 })
|
||||||
|
const rules = { model_name: [{ required: true, message: '请输入模型名称' }], provider: [{ required: true }], usage_scope: [{ required: true }] }
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
const res = await getAIModels()
|
const res = await getAIModels()
|
||||||
@@ -155,13 +177,13 @@ function onProviderChange(provider) {
|
|||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
editModel.value = null
|
editModel.value = null
|
||||||
Object.assign(form, { model_name: '', provider: 'openai', api_base_url: PROVIDER_DEFAULTS.openai.api_base_url, api_key: '', model_version: PROVIDER_DEFAULTS.openai.model_version, temperature: 0.7, max_tokens: 1000, timeout_seconds: 30, is_default: 0 })
|
Object.assign(form, { model_name: '', provider: 'openai', usage_scope: 'general', api_base_url: PROVIDER_DEFAULTS.openai.api_base_url, api_key: '', model_version: PROVIDER_DEFAULTS.openai.model_version, vision_model_version: 'qwen3.6-flash', ocr_model_version: 'qwen-vl-ocr', temperature: 0.7, max_tokens: 1000, timeout_seconds: 30, is_default: 0 })
|
||||||
dialogVisible.value = true
|
dialogVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEdit(m) {
|
function openEdit(m) {
|
||||||
editModel.value = m
|
editModel.value = m
|
||||||
Object.assign(form, { model_name: m.model_name, provider: m.provider, api_base_url: m.api_base_url || '', api_key: '', model_version: m.model_version || '', temperature: m.temperature, max_tokens: m.max_tokens, timeout_seconds: m.timeout_seconds, is_default: m.is_default })
|
Object.assign(form, { model_name: m.model_name, provider: m.provider, usage_scope: m.usage_scope || 'general', api_base_url: m.api_base_url || '', api_key: '', model_version: m.model_version || '', vision_model_version: m.vision_model_version || 'qwen3.6-flash', ocr_model_version: m.ocr_model_version || 'qwen-vl-ocr', temperature: m.temperature, max_tokens: m.max_tokens, timeout_seconds: m.timeout_seconds, is_default: m.is_default })
|
||||||
dialogVisible.value = true
|
dialogVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,4 +258,5 @@ onMounted(load)
|
|||||||
.result-meta { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
|
.result-meta { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
|
||||||
.result-content { background: var(--color-bg); border: 1px solid var(--color-border); border-radius: 8px; padding: 12px; font-size: 13px; line-height: 1.6; white-space: pre-wrap; max-height: 200px; overflow-y: auto; }
|
.result-content { background: var(--color-bg); border: 1px solid var(--color-border); border-radius: 8px; padding: 12px; font-size: 13px; line-height: 1.6; white-space: pre-wrap; max-height: 200px; overflow-y: auto; }
|
||||||
.empty-state { grid-column: 1/-1; padding: 40px; }
|
.empty-state { grid-column: 1/-1; padding: 40px; }
|
||||||
|
.scope-tip { margin-top: 6px; color: var(--color-text-muted); font-size: 12px; line-height: 1.5; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ H5 引入 uniapp web-view bridge 后调用:
|
|||||||
| `needLogin` | — | token 失效,请求壳重新登录 |
|
| `needLogin` | — | token 失效,请求壳重新登录 |
|
||||||
| `setTitle` | `title` | 设置原生导航栏标题 |
|
| `setTitle` | `title` | 设置原生导航栏标题 |
|
||||||
| `navigate` | `path` | 请求原生跳转(打开原生页/新 web-view) |
|
| `navigate` | `path` | 请求原生跳转(打开原生页/新 web-view) |
|
||||||
|
| `payment` | `payment` | 拉起会会原生支付;包含 `orderId/orderNo/payType/payWay/payMessage/paymentParams` |
|
||||||
| `back` | — | 请求原生返回 |
|
| `back` | — | 请求原生返回 |
|
||||||
|
|
||||||
### 2.3 原生 → H5(壳主动推送)
|
### 2.3 原生 → H5(壳主动推送)
|
||||||
@@ -63,9 +64,12 @@ H5 引入 uniapp web-view bridge 后调用:
|
|||||||
| `context` | `platform, version` | 注入运行环境信息 |
|
| `context` | `platform, version` | 注入运行环境信息 |
|
||||||
| `tokenRefresh` | `token` | 登录刷新后下发新 token |
|
| `tokenRefresh` | `token` | 登录刷新后下发新 token |
|
||||||
| `userUpdate` | `user` | 会会资料变更 |
|
| `userUpdate` | `user` | 会会资料变更 |
|
||||||
|
| `paymentResult` | `orderId,status` | 原生支付结束通知;`status` 为 `success/cancelled/failed` |
|
||||||
|
|
||||||
> H5 侧用 `onNativeMessage(cb)` 注册 `window.__uniBridgeHandle__`,见 `digital-avatar-app/src/utils/uniapp-bridge.ts`。
|
> H5 侧用 `onNativeMessage(cb)` 注册 `window.__uniBridgeHandle__`,见 `digital-avatar-app/src/utils/uniapp-bridge.ts`。
|
||||||
|
|
||||||
|
壳收到 `payment` 后应调用会会 App 已有的微信/支付宝支付能力(或 `uni.requestPayment`),把 `payMessage/paymentParams` 原样交给对应渠道。原生 SDK 返回后再发送 `paymentResult`;H5 不以原生返回作为到账依据,只会轮询本地订单,最终由会会服务端支付回调确认并增加积分。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. 项目结构(uni CLI / src 布局,已验证可编译)
|
## 3. 项目结构(uni CLI / src 布局,已验证可编译)
|
||||||
|
|||||||
Reference in New Issue
Block a user