feat: 完成数字分身多分身管理与生产 H5 接入 #3
@@ -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,10 +25,15 @@ 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,
|
||||||
@@ -47,8 +55,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 +75,37 @@ 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,
|
||||||
|
"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,6 +126,7 @@ 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,
|
||||||
"max_tokens": m.max_tokens, "timeout_seconds": m.timeout_seconds,
|
"max_tokens": m.max_tokens, "timeout_seconds": m.timeout_seconds,
|
||||||
|
|||||||
@@ -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,22 @@ 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_usage_scope_migration', 30)"))
|
||||||
|
try:
|
||||||
|
result = await conn.execute(text(
|
||||||
|
"SELECT COUNT(*) FROM information_schema.COLUMNS "
|
||||||
|
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'ai_model_configs' "
|
||||||
|
"AND COLUMN_NAME = 'usage_scope'"
|
||||||
|
))
|
||||||
|
if result.scalar_one() == 0:
|
||||||
|
await conn.execute(text(
|
||||||
|
"ALTER TABLE ai_model_configs ADD COLUMN usage_scope "
|
||||||
|
"VARCHAR(16) NOT NULL DEFAULT 'general' AFTER provider"
|
||||||
|
))
|
||||||
|
logger.info("AI模型配置表已增加 usage_scope 字段")
|
||||||
|
finally:
|
||||||
|
await conn.execute(text("SELECT RELEASE_LOCK('ai_model_usage_scope_migration')"))
|
||||||
logger.info("✅ 数据库模型注册成功")
|
logger.info("✅ 数据库模型注册成功")
|
||||||
logger.info("✅ 数据库初始化完成")
|
logger.info("✅ 数据库初始化完成")
|
||||||
|
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ 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))
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ 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
|
||||||
@@ -165,6 +166,8 @@ 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
|
||||||
@@ -179,6 +182,7 @@ 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]
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -40,8 +40,14 @@ def init_db():
|
|||||||
("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 30"),
|
||||||
("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"),
|
||||||
)
|
)
|
||||||
_normalize_optional_unique_values()
|
_normalize_optional_unique_values()
|
||||||
|
_create_token_indexes()
|
||||||
|
|
||||||
|
|
||||||
def _try_add_columns(*cols):
|
def _try_add_columns(*cols):
|
||||||
@@ -58,3 +64,11 @@ 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 _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 <> ''"
|
||||||
|
)
|
||||||
|
|||||||
@@ -51,7 +51,14 @@ def embed(texts):
|
|||||||
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")
|
||||||
payload = json.dumps({"input": texts, "model": model}).encode("utf-8")
|
try:
|
||||||
|
batch_size = max(1, int(os.getenv("EMBEDDING_BATCH_SIZE", "10")))
|
||||||
|
except ValueError:
|
||||||
|
batch_size = 10
|
||||||
|
embeddings = []
|
||||||
|
for start in range(0, len(texts), batch_size):
|
||||||
|
batch = texts[start:start + batch_size]
|
||||||
|
payload = json.dumps({"input": batch, "model": model}).encode("utf-8")
|
||||||
req = urllib.request.Request(
|
req = urllib.request.Request(
|
||||||
api_url,
|
api_url,
|
||||||
data=payload,
|
data=payload,
|
||||||
@@ -66,7 +73,10 @@ def embed(texts):
|
|||||||
items = data["data"]
|
items = data["data"]
|
||||||
if items and "index" in items[0]:
|
if items and "index" in items[0]:
|
||||||
items = sorted(items, key=lambda x: x["index"])
|
items = sorted(items, key=lambda x: x["index"])
|
||||||
return [item["embedding"] for item in items]
|
if len(items) != len(batch):
|
||||||
|
raise ValueError("embedding response count does not match request")
|
||||||
|
embeddings.extend(item["embedding"] for item in items)
|
||||||
|
return embeddings
|
||||||
return _hash_embedding(texts)
|
return _hash_embedding(texts)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,6 +19,7 @@ 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.token_billing import DEFAULT_TOKEN_GRANT, release_stale_reservations
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -56,17 +57,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 Token"},
|
||||||
|
{"id": "2", "name": "标准套餐", "amount": 20_000_000, "price": 100, "badge": "常用", "desc": "20M Token"},
|
||||||
if db.query(TokenPlan).count() == 0:
|
{"id": "3", "name": "专业套餐", "amount": 250_000_000, "price": 1000, "badge": "加赠25%", "desc": "250M Token"},
|
||||||
plans = [
|
{"id": "4", "name": "企业套餐", "amount": 2_500_000_000, "price": 10000, "badge": "企业推荐", "desc": "2500M Token"},
|
||||||
TokenPlan(id="1", name="新手体验", amount=1000, price=9.9, desc="新手体验"),
|
|
||||||
TokenPlan(id="2", name="热门套餐", amount=5000, price=39.9, badge="热门"),
|
|
||||||
TokenPlan(id="3", name="超值套餐", amount=12000, price=89.9, badge="超值"),
|
|
||||||
TokenPlan(id="4", name="企业推荐", amount=30000, price=199, badge="企业推荐", desc="适合高频使用"),
|
|
||||||
]
|
]
|
||||||
db.add_all(plans)
|
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)
|
||||||
|
|
||||||
|
for user in db.query(User).all():
|
||||||
|
account = db.query(TokenAccount).filter(TokenAccount.user_id == user.id).first()
|
||||||
|
if account is None:
|
||||||
|
db.add(TokenAccount(
|
||||||
|
user_id=user.id,
|
||||||
|
balance=DEFAULT_TOKEN_GRANT,
|
||||||
|
total_granted=DEFAULT_TOKEN_GRANT,
|
||||||
|
total_consumed=0,
|
||||||
|
))
|
||||||
|
|
||||||
if db.query(Avatar).count() == 0:
|
if db.query(Avatar).count() == 0:
|
||||||
avatar = Avatar(
|
avatar = Avatar(
|
||||||
@@ -105,6 +118,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()
|
||||||
|
|
||||||
@@ -143,14 +157,28 @@ def on_startup():
|
|||||||
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()
|
||||||
takeover_scheduler.add_job(
|
takeover_scheduler.add_job(
|
||||||
takeover_service.poll_and_process_messages,
|
takeover_service.poll_messages,
|
||||||
trigger=IntervalTrigger(seconds=poll_interval),
|
trigger=IntervalTrigger(seconds=poll_interval),
|
||||||
id="takeover_message_poll",
|
id="takeover_message_poll",
|
||||||
max_instances=1,
|
max_instances=1,
|
||||||
coalesce=True,
|
coalesce=True,
|
||||||
)
|
)
|
||||||
|
process_interval = max(
|
||||||
|
0.25, float(os.getenv("TAKEOVER_PROCESS_INTERVAL_SECONDS", "0.5"))
|
||||||
|
)
|
||||||
|
takeover_scheduler.add_job(
|
||||||
|
takeover_service.process_reply_tasks,
|
||||||
|
trigger=IntervalTrigger(seconds=process_interval),
|
||||||
|
id="takeover_reply_process",
|
||||||
|
max_instances=1,
|
||||||
|
coalesce=True,
|
||||||
|
)
|
||||||
takeover_scheduler.start()
|
takeover_scheduler.start()
|
||||||
logger.info("BOXIM takeover scheduler started (interval=%ss)", poll_interval)
|
logger.info(
|
||||||
|
"BOXIM takeover scheduler started (poll=%ss, process=%ss)",
|
||||||
|
poll_interval,
|
||||||
|
process_interval,
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
stop_takeover_scheduler()
|
stop_takeover_scheduler()
|
||||||
logger.warning(f"Failed to initialize takeover scheduler, app will continue without it: {e}")
|
logger.warning(f"Failed to initialize takeover scheduler, app will continue without it: {e}")
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
|
BigInteger,
|
||||||
Boolean,
|
Boolean,
|
||||||
Column,
|
Column,
|
||||||
DateTime,
|
DateTime,
|
||||||
@@ -259,14 +260,42 @@ class KnowledgeChunk(Base):
|
|||||||
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="")
|
||||||
|
|||||||
@@ -16,12 +16,17 @@ import embeddings
|
|||||||
from database import get_db
|
from database import get_db
|
||||||
from models import Avatar, KnowledgeChunk, KnowledgeDoc, QAPair, User
|
from models import Avatar, KnowledgeChunk, KnowledgeDoc, QAPair, User
|
||||||
from responses import ok, fail
|
from responses import ok, fail
|
||||||
|
from services.token_billing import (
|
||||||
|
InsufficientTokensError,
|
||||||
|
estimate_fallback_usage,
|
||||||
|
release_reservation,
|
||||||
|
reserve_avatar_tokens,
|
||||||
|
settle_reservation,
|
||||||
|
)
|
||||||
|
from services.chat_model_config import ChatModelConfig, get_chat_model_config
|
||||||
|
|
||||||
router = APIRouter(tags=["数字分身聊天"])
|
router = APIRouter(tags=["数字分身聊天"])
|
||||||
|
|
||||||
CHAT_API_URL = os.getenv("CHAT_API_URL", "https://dashscope.aliyuncs.com/compatible-mode/v1")
|
|
||||||
CHAT_API_KEY = os.getenv("CHAT_API_KEY", "")
|
|
||||||
CHAT_MODEL = os.getenv("CHAT_MODEL", "qwen-plus")
|
|
||||||
MAX_MESSAGE_LENGTH = 4000
|
MAX_MESSAGE_LENGTH = 4000
|
||||||
MAX_HISTORY_MESSAGES = 10
|
MAX_HISTORY_MESSAGES = 10
|
||||||
QA_LEXICAL_THRESHOLD = 0.72
|
QA_LEXICAL_THRESHOLD = 0.72
|
||||||
@@ -279,21 +284,25 @@ def _search_knowledge(db: Session, avatar_id: str, question: str, top_k: int = 5
|
|||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
def _call_qwen(messages: list[dict], temperature: float) -> str:
|
def _call_qwen(
|
||||||
if not CHAT_API_KEY:
|
messages: list[dict], temperature: float, model_config: ChatModelConfig | None = None
|
||||||
|
) -> dict:
|
||||||
|
model_config = model_config or get_chat_model_config()
|
||||||
|
if not model_config.api_key:
|
||||||
raise RuntimeError("Qwen 模型服务未配置 CHAT_API_KEY")
|
raise RuntimeError("Qwen 模型服务未配置 CHAT_API_KEY")
|
||||||
url = f"{CHAT_API_URL.rstrip('/')}/chat/completions"
|
url = f"{model_config.api_base_url}/chat/completions"
|
||||||
payload = {
|
payload = {
|
||||||
"model": CHAT_MODEL,
|
"model": model_config.model,
|
||||||
"messages": messages,
|
"messages": messages,
|
||||||
"temperature": temperature,
|
"temperature": temperature,
|
||||||
|
"max_tokens": model_config.max_tokens,
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
response = httpx.post(
|
response = httpx.post(
|
||||||
url,
|
url,
|
||||||
headers={"Authorization": f"Bearer {CHAT_API_KEY}"},
|
headers={"Authorization": f"Bearer {model_config.api_key}"},
|
||||||
json=payload,
|
json=payload,
|
||||||
timeout=30,
|
timeout=model_config.timeout_seconds,
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
data = response.json()
|
data = response.json()
|
||||||
@@ -302,17 +311,33 @@ def _call_qwen(messages: list[dict], temperature: float) -> str:
|
|||||||
raise RuntimeError("Qwen 模型服务暂时不可用") from exc
|
raise RuntimeError("Qwen 模型服务暂时不可用") from exc
|
||||||
if not isinstance(answer, str) or not answer.strip():
|
if not isinstance(answer, str) or not answer.strip():
|
||||||
raise RuntimeError("Qwen 模型没有返回有效回答")
|
raise RuntimeError("Qwen 模型没有返回有效回答")
|
||||||
return answer.strip()
|
return {"answer": answer.strip(), "usage": data.get("usage") or {}}
|
||||||
|
|
||||||
|
|
||||||
def _iter_qwen_stream(messages: list[dict], temperature: float):
|
def _iter_qwen_stream(
|
||||||
|
messages: list[dict], temperature: float, model_config: ChatModelConfig | None = None
|
||||||
|
):
|
||||||
"""将 OpenAI 兼容接口的 SSE 分片原样转为文本增量。"""
|
"""将 OpenAI 兼容接口的 SSE 分片原样转为文本增量。"""
|
||||||
if not CHAT_API_KEY:
|
model_config = model_config or get_chat_model_config()
|
||||||
|
if not model_config.api_key:
|
||||||
raise RuntimeError("模型服务未配置")
|
raise RuntimeError("模型服务未配置")
|
||||||
url = f"{CHAT_API_URL.rstrip('/')}/chat/completions"
|
url = f"{model_config.api_base_url}/chat/completions"
|
||||||
payload = {"model": CHAT_MODEL, "messages": messages, "temperature": temperature, "stream": True}
|
payload = {
|
||||||
|
"model": model_config.model,
|
||||||
|
"messages": messages,
|
||||||
|
"temperature": temperature,
|
||||||
|
"max_tokens": model_config.max_tokens,
|
||||||
|
"stream": True,
|
||||||
|
"stream_options": {"include_usage": True},
|
||||||
|
}
|
||||||
try:
|
try:
|
||||||
with httpx.stream("POST", url, headers={"Authorization": f"Bearer {CHAT_API_KEY}"}, json=payload, timeout=45) as response:
|
with httpx.stream(
|
||||||
|
"POST",
|
||||||
|
url,
|
||||||
|
headers={"Authorization": f"Bearer {model_config.api_key}"},
|
||||||
|
json=payload,
|
||||||
|
timeout=max(45, model_config.timeout_seconds),
|
||||||
|
) as response:
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
for raw_line in response.iter_lines():
|
for raw_line in response.iter_lines():
|
||||||
line = raw_line.decode() if isinstance(raw_line, bytes) else raw_line
|
line = raw_line.decode() if isinstance(raw_line, bytes) else raw_line
|
||||||
@@ -322,11 +347,15 @@ def _iter_qwen_stream(messages: list[dict], temperature: float):
|
|||||||
if data == "[DONE]":
|
if data == "[DONE]":
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
delta = json.loads(data).get("choices", [{}])[0].get("delta", {}).get("content")
|
parsed = json.loads(data)
|
||||||
except (ValueError, IndexError, AttributeError):
|
except (ValueError, IndexError, AttributeError):
|
||||||
continue
|
continue
|
||||||
|
if parsed.get("usage"):
|
||||||
|
yield {"usage": parsed["usage"]}
|
||||||
|
choices = parsed.get("choices") or []
|
||||||
|
delta = choices[0].get("delta", {}).get("content") if choices else None
|
||||||
if delta:
|
if delta:
|
||||||
yield delta
|
yield {"content": delta}
|
||||||
except httpx.HTTPError as exc:
|
except httpx.HTTPError as exc:
|
||||||
raise RuntimeError("模型服务暂时不可用") from exc
|
raise RuntimeError("模型服务暂时不可用") from exc
|
||||||
|
|
||||||
@@ -350,6 +379,7 @@ def _resolve_reply(
|
|||||||
qa_pairs: list[Any] | None = None,
|
qa_pairs: list[Any] | None = None,
|
||||||
search_fn: Callable[..., list[dict]] | None = None,
|
search_fn: Callable[..., list[dict]] | None = None,
|
||||||
model_client: Callable[..., str] | None = None,
|
model_client: Callable[..., str] | None = None,
|
||||||
|
usage_source: str = "chat",
|
||||||
) -> dict:
|
) -> dict:
|
||||||
if qa_pairs is None:
|
if qa_pairs is None:
|
||||||
qa_pairs = db.query(QAPair).filter(QAPair.avatar_id == avatar.id).all()
|
qa_pairs = db.query(QAPair).filter(QAPair.avatar_id == avatar.id).all()
|
||||||
@@ -362,16 +392,54 @@ def _resolve_reply(
|
|||||||
messages = _build_prompt(avatar, history, question, hits)
|
messages = _build_prompt(avatar, history, question, hits)
|
||||||
config = _config(avatar)
|
config = _config(avatar)
|
||||||
temperature = min(0.45 if hits else 0.25, 0.2 + config["creativity"] / 100 * 0.6)
|
temperature = min(0.45 if hits else 0.25, 0.2 + config["creativity"] / 100 * 0.6)
|
||||||
model_client = model_client or _call_qwen
|
token_usage = None
|
||||||
|
if model_client is not None:
|
||||||
answer = model_client(messages=messages, temperature=temperature)
|
answer = model_client(messages=messages, temperature=temperature)
|
||||||
return {
|
else:
|
||||||
|
model_config = get_chat_model_config()
|
||||||
|
reservation = reserve_avatar_tokens(
|
||||||
|
db,
|
||||||
|
avatar,
|
||||||
|
usage_source,
|
||||||
|
model_config.model,
|
||||||
|
messages,
|
||||||
|
model_config.max_tokens,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
model_result = _call_qwen(
|
||||||
|
messages=messages,
|
||||||
|
temperature=temperature,
|
||||||
|
model_config=model_config,
|
||||||
|
)
|
||||||
|
answer = model_result["answer"]
|
||||||
|
token_usage = settle_reservation(
|
||||||
|
db,
|
||||||
|
reservation,
|
||||||
|
model_result.get("usage"),
|
||||||
|
fallback_total=estimate_fallback_usage(messages, answer),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
release_reservation(db, reservation, str(exc))
|
||||||
|
raise
|
||||||
|
result = {
|
||||||
"answer": answer,
|
"answer": answer,
|
||||||
"source": "knowledge" if hits else "qwen",
|
"source": "knowledge" if hits else "qwen",
|
||||||
"references": hits,
|
"references": hits,
|
||||||
}
|
}
|
||||||
|
if token_usage:
|
||||||
|
result["tokenUsage"] = token_usage
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _stream_reply(db: Session, avatar: Avatar, question: str, history: list[Any], *, public: bool = False):
|
def _stream_reply(
|
||||||
|
db: Session,
|
||||||
|
avatar: Avatar,
|
||||||
|
question: str,
|
||||||
|
history: list[Any],
|
||||||
|
*,
|
||||||
|
public: bool = False,
|
||||||
|
usage_source: str = "chat_stream",
|
||||||
|
):
|
||||||
qa_pairs = db.query(QAPair).filter(QAPair.avatar_id == avatar.id).all()
|
qa_pairs = db.query(QAPair).filter(QAPair.avatar_id == avatar.id).all()
|
||||||
matched = _match_standard_qa(question, qa_pairs)
|
matched = _match_standard_qa(question, qa_pairs)
|
||||||
if matched:
|
if matched:
|
||||||
@@ -381,18 +449,63 @@ def _stream_reply(db: Session, avatar: Avatar, question: str, history: list[Any]
|
|||||||
source = "knowledge" if references else "qwen"
|
source = "knowledge" if references else "qwen"
|
||||||
config = _config(avatar)
|
config = _config(avatar)
|
||||||
temperature = min(0.45 if references else 0.25, 0.2 + config["creativity"] / 100 * 0.6)
|
temperature = min(0.45 if references else 0.25, 0.2 + config["creativity"] / 100 * 0.6)
|
||||||
chunks = _iter_qwen_stream(_build_prompt(avatar, history, question, references), temperature)
|
messages = _build_prompt(avatar, history, question, references)
|
||||||
|
model_config = get_chat_model_config()
|
||||||
|
reservation = reserve_avatar_tokens(
|
||||||
|
db,
|
||||||
|
avatar,
|
||||||
|
usage_source,
|
||||||
|
model_config.model,
|
||||||
|
messages,
|
||||||
|
model_config.max_tokens,
|
||||||
|
)
|
||||||
|
chunks = _iter_qwen_stream(messages, temperature, model_config)
|
||||||
|
if matched:
|
||||||
|
messages, reservation = [], None
|
||||||
if public:
|
if public:
|
||||||
source, references = "public", []
|
source, references = "public", []
|
||||||
|
|
||||||
def generate():
|
def generate():
|
||||||
|
output_parts = []
|
||||||
|
provider_usage = None
|
||||||
|
settled = False
|
||||||
try:
|
try:
|
||||||
yield _sse("meta", {"source": source, "references": references})
|
yield _sse("meta", {"source": source, "references": references})
|
||||||
for content in chunks:
|
for chunk in chunks:
|
||||||
|
if reservation is None:
|
||||||
|
content = chunk
|
||||||
|
else:
|
||||||
|
provider_usage = chunk.get("usage") or provider_usage
|
||||||
|
content = chunk.get("content")
|
||||||
|
if not content:
|
||||||
|
continue
|
||||||
|
output_parts.append(content)
|
||||||
yield _sse("delta", {"content": content})
|
yield _sse("delta", {"content": content})
|
||||||
yield _sse("done", {})
|
token_usage = None
|
||||||
|
if reservation is not None:
|
||||||
|
answer = "".join(output_parts)
|
||||||
|
token_usage = settle_reservation(
|
||||||
|
db,
|
||||||
|
reservation,
|
||||||
|
provider_usage,
|
||||||
|
fallback_total=estimate_fallback_usage(messages, answer),
|
||||||
|
)
|
||||||
|
settled = True
|
||||||
|
yield _sse("done", {} if public else {"tokenUsage": token_usage})
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
yield _sse("error", {"message": str(exc)})
|
yield _sse("error", {"message": str(exc)})
|
||||||
|
finally:
|
||||||
|
if reservation is not None and not settled:
|
||||||
|
answer = "".join(output_parts)
|
||||||
|
if answer:
|
||||||
|
settle_reservation(
|
||||||
|
db,
|
||||||
|
reservation,
|
||||||
|
provider_usage,
|
||||||
|
fallback_total=estimate_fallback_usage(messages, answer),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
release_reservation(db, reservation, "stream_ended_without_output")
|
||||||
|
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
generate(),
|
generate(),
|
||||||
@@ -441,11 +554,14 @@ def get_shared_avatar(share_token: str, db: Session = Depends(get_db)):
|
|||||||
def public_chat(share_token: str, body: ChatIn = Body(...), db: Session = Depends(get_db)):
|
def public_chat(share_token: str, body: ChatIn = Body(...), db: Session = Depends(get_db)):
|
||||||
avatar = _require_shared_avatar(db, share_token)
|
avatar = _require_shared_avatar(db, share_token)
|
||||||
try:
|
try:
|
||||||
result = _resolve_reply(db, avatar, body.message, body.history)
|
result = _resolve_reply(db, avatar, body.message, body.history, usage_source="public_chat")
|
||||||
# 公开访客无需获知知识文件名、检索分数或内部答复来源。
|
# 公开访客无需获知知识文件名、检索分数或内部答复来源。
|
||||||
result["references"] = []
|
result["references"] = []
|
||||||
result["source"] = "public"
|
result["source"] = "public"
|
||||||
|
result.pop("tokenUsage", None)
|
||||||
return ok(result)
|
return ok(result)
|
||||||
|
except InsufficientTokensError as exc:
|
||||||
|
return fail(str(exc), code=402)
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
return fail(str(exc), code=502)
|
return fail(str(exc), code=502)
|
||||||
|
|
||||||
@@ -455,15 +571,30 @@ def chat(avatar_id: str, body: ChatIn = Body(...), authorization: str = Header(N
|
|||||||
avatar = _require_owned_avatar(db, avatar_id, authorization)
|
avatar = _require_owned_avatar(db, avatar_id, authorization)
|
||||||
try:
|
try:
|
||||||
return ok(_resolve_reply(db, avatar, body.message, body.history))
|
return ok(_resolve_reply(db, avatar, body.message, body.history))
|
||||||
|
except InsufficientTokensError as exc:
|
||||||
|
return fail(str(exc), code=402)
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
return fail(str(exc), code=502)
|
return fail(str(exc), code=502)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/avatar/{avatar_id}/chat/stream")
|
@router.post("/avatar/{avatar_id}/chat/stream")
|
||||||
def chat_stream(avatar_id: str, body: ChatIn = Body(...), authorization: str = Header(None), db: Session = Depends(get_db)):
|
def chat_stream(avatar_id: str, body: ChatIn = Body(...), authorization: str = Header(None), db: Session = Depends(get_db)):
|
||||||
|
try:
|
||||||
return _stream_reply(db, _require_owned_avatar(db, avatar_id, authorization), body.message, body.history)
|
return _stream_reply(db, _require_owned_avatar(db, avatar_id, authorization), body.message, body.history)
|
||||||
|
except InsufficientTokensError as exc:
|
||||||
|
raise HTTPException(status_code=402, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
@router.post("/public/avatar/{share_token}/chat/stream")
|
@router.post("/public/avatar/{share_token}/chat/stream")
|
||||||
def public_chat_stream(share_token: str, body: ChatIn = Body(...), db: Session = Depends(get_db)):
|
def public_chat_stream(share_token: str, body: ChatIn = Body(...), db: Session = Depends(get_db)):
|
||||||
return _stream_reply(db, _require_shared_avatar(db, share_token), body.message, body.history, public=True)
|
try:
|
||||||
|
return _stream_reply(
|
||||||
|
db,
|
||||||
|
_require_shared_avatar(db, share_token),
|
||||||
|
body.message,
|
||||||
|
body.history,
|
||||||
|
public=True,
|
||||||
|
usage_source="public_chat_stream",
|
||||||
|
)
|
||||||
|
except InsufficientTokensError as exc:
|
||||||
|
raise HTTPException(status_code=402, detail=str(exc)) from exc
|
||||||
|
|||||||
@@ -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", "")
|
||||||
|
if not reuse_existing_session or not user.app_token:
|
||||||
user.app_token = uuid.uuid4().hex
|
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,37 +1,81 @@
|
|||||||
from fastapi import APIRouter, Depends, Body
|
from fastapi import APIRouter, Depends, Body, Header, HTTPException
|
||||||
|
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, TokenPlan, TokenUsage, User
|
||||||
from responses import ok, fail
|
from responses import ok, fail
|
||||||
|
from services.token_billing import get_or_create_account
|
||||||
|
|
||||||
router = APIRouter(tags=["Token"])
|
router = APIRouter(tags=["Token"])
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
@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)):
|
||||||
|
user = _require_user(authorization, db)
|
||||||
plan_id = payload.get("planId")
|
plan_id = payload.get("planId")
|
||||||
plan = db.query(TokenPlan).filter(TokenPlan.id == plan_id).first()
|
plan = db.query(TokenPlan).filter(TokenPlan.id == plan_id).first()
|
||||||
if not plan:
|
if not plan:
|
||||||
return fail("套餐不存在", 404)
|
return fail("套餐不存在", 404)
|
||||||
acc = db.query(TokenAccount).first()
|
acc = get_or_create_account(db, user.id)
|
||||||
if not acc:
|
|
||||||
acc = TokenAccount(balance=0)
|
|
||||||
db.add(acc)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(acc)
|
|
||||||
acc.balance += plan.amount
|
acc.balance += plan.amount
|
||||||
|
acc.total_granted = int(acc.total_granted or 0) + plan.amount
|
||||||
db.commit()
|
db.commit()
|
||||||
return ok({"balance": acc.balance, "charged": plan.amount})
|
return ok({"balance": acc.balance, "charged": plan.amount})
|
||||||
|
|
||||||
|
|
||||||
|
@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,93 @@
|
|||||||
|
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
|
||||||
|
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"))),
|
||||||
|
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)),
|
||||||
|
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
|
||||||
@@ -86,24 +86,34 @@ class TakeoverService:
|
|||||||
self.reply_delay_seconds = reply_delay_seconds
|
self.reply_delay_seconds = reply_delay_seconds
|
||||||
self.now = now
|
self.now = now
|
||||||
self._sessions: dict[str, dict] = {}
|
self._sessions: dict[str, dict] = {}
|
||||||
self._run_lock = asyncio.Lock()
|
self._poll_lock = asyncio.Lock()
|
||||||
|
self._process_lock = asyncio.Lock()
|
||||||
|
|
||||||
async def poll_and_process_messages(self):
|
async def poll_and_process_messages(self):
|
||||||
"""Run one complete cycle; polling always happens before reply dispatch."""
|
"""Run one complete cycle for callers that do not use the split scheduler."""
|
||||||
if self._run_lock.locked():
|
await self.poll_messages()
|
||||||
|
await self.process_reply_tasks()
|
||||||
|
|
||||||
|
async def poll_messages(self):
|
||||||
|
"""Fetch BOXIM events without blocking reply generation and dispatch."""
|
||||||
|
if self._poll_lock.locked():
|
||||||
return
|
return
|
||||||
async with self._run_lock:
|
async with self._poll_lock:
|
||||||
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:
|
for avatar_id in avatar_ids:
|
||||||
await self._sync_avatar(avatar_id)
|
await self._sync_avatar(avatar_id)
|
||||||
|
|
||||||
generated = await self._prepare_replies()
|
async def process_reply_tasks(self):
|
||||||
if generated:
|
"""Generate and send replies independently from BOXIM's long poll."""
|
||||||
# Catch a human reply sent while the model was preparing its answer.
|
if self._process_lock.locked():
|
||||||
for avatar_id in avatar_ids:
|
return
|
||||||
await self._sync_avatar(avatar_id)
|
async with self._process_lock:
|
||||||
|
self._recover_stuck_tasks()
|
||||||
|
avatar_ids = set(self._enabled_avatar_ids())
|
||||||
|
self._cancel_disabled_tasks(avatar_ids)
|
||||||
|
await self._prepare_replies()
|
||||||
await self._dispatch_ready_replies()
|
await self._dispatch_ready_replies()
|
||||||
|
|
||||||
def _enabled_avatar_ids(self) -> list[str]:
|
def _enabled_avatar_ids(self) -> list[str]:
|
||||||
@@ -454,11 +464,19 @@ class TakeoverService:
|
|||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
generated = 0
|
if not task_ids:
|
||||||
for task_id in task_ids:
|
return 0
|
||||||
if await asyncio.to_thread(self._generate_reply, task_id):
|
|
||||||
generated += 1
|
# Each conversation owns its task, so unrelated contacts can generate in
|
||||||
return generated
|
# parallel instead of one slow model response delaying every other peer.
|
||||||
|
semaphore = asyncio.Semaphore(4)
|
||||||
|
|
||||||
|
async def generate(task_id: str) -> bool:
|
||||||
|
async with semaphore:
|
||||||
|
return await asyncio.to_thread(self._generate_reply, task_id)
|
||||||
|
|
||||||
|
results = await asyncio.gather(*(generate(task_id) for task_id in task_ids))
|
||||||
|
return sum(bool(result) for result in results)
|
||||||
|
|
||||||
def _generate_reply(self, task_id: str) -> bool:
|
def _generate_reply(self, task_id: str) -> bool:
|
||||||
db = self.session_factory()
|
db = self.session_factory()
|
||||||
@@ -502,7 +520,7 @@ class TakeoverService:
|
|||||||
|
|
||||||
from routers.chat import _resolve_reply
|
from routers.chat import _resolve_reply
|
||||||
|
|
||||||
result = _resolve_reply(db, avatar, task.prompt, history)
|
result = _resolve_reply(db, avatar, task.prompt, history, usage_source="takeover")
|
||||||
answer = _plain_text_reply(result.get("answer", ""))
|
answer = _plain_text_reply(result.get("answer", ""))
|
||||||
db.refresh(task)
|
db.refresh(task)
|
||||||
if task.status != "generating":
|
if task.status != "generating":
|
||||||
@@ -548,8 +566,8 @@ class TakeoverService:
|
|||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
for task_id in task_ids:
|
if task_ids:
|
||||||
await self._send_task(task_id)
|
await asyncio.gather(*(self._send_task(task_id) for task_id in task_ids))
|
||||||
|
|
||||||
async def _send_task(self, task_id: str) -> bool:
|
async def _send_task(self, task_id: str) -> bool:
|
||||||
db = self.session_factory()
|
db = self.session_factory()
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
"""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,
|
||||||
|
) -> TokenReservation:
|
||||||
|
user = avatar_owner_user(db, avatar)
|
||||||
|
if not user:
|
||||||
|
raise InsufficientTokensError("分身尚未关联有效用户,暂时无法使用 Token")
|
||||||
|
account = get_or_create_account(db, user.id)
|
||||||
|
reserved = estimate_request_tokens(messages, max_output_tokens)
|
||||||
|
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("Token 余额不足,请充值后继续")
|
||||||
|
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("Token 账户不存在")
|
||||||
|
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)
|
||||||
@@ -8,6 +8,8 @@ from models import (
|
|||||||
TakeoverCursor,
|
TakeoverCursor,
|
||||||
TakeoverMessage,
|
TakeoverMessage,
|
||||||
TakeoverReplyTask,
|
TakeoverReplyTask,
|
||||||
|
TokenAccount,
|
||||||
|
TokenUsage,
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -107,6 +109,13 @@ 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(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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
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",
|
||||||
|
"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.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("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.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"
|
||||||
@@ -1,10 +1,26 @@
|
|||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
import embeddings
|
import embeddings
|
||||||
|
|
||||||
|
|
||||||
|
class FakeResponse:
|
||||||
|
def __init__(self, payload):
|
||||||
|
self.payload = payload
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def read(self):
|
||||||
|
return json.dumps(self.payload).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
class TextExtractionTests(unittest.TestCase):
|
class TextExtractionTests(unittest.TestCase):
|
||||||
def write_text(self, suffix, content):
|
def write_text(self, suffix, content):
|
||||||
handle = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
|
handle = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
|
||||||
@@ -28,5 +44,33 @@ class TextExtractionTests(unittest.TestCase):
|
|||||||
embeddings.extract_text(path, ".csv")
|
embeddings.extract_text(path, ".csv")
|
||||||
|
|
||||||
|
|
||||||
|
class RemoteEmbeddingTests(unittest.TestCase):
|
||||||
|
def test_large_input_is_split_into_provider_safe_batches(self):
|
||||||
|
texts = [f"chunk-{index}" for index in range(14)]
|
||||||
|
batch_sizes = []
|
||||||
|
|
||||||
|
def fake_urlopen(request, timeout):
|
||||||
|
self.assertEqual(timeout, 30)
|
||||||
|
payload = json.loads(request.data.decode("utf-8"))
|
||||||
|
batch_sizes.append(len(payload["input"]))
|
||||||
|
return FakeResponse({
|
||||||
|
"data": [
|
||||||
|
{"index": index, "embedding": [float(text.split("-")[1])]}
|
||||||
|
for index, text in enumerate(payload["input"])
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {
|
||||||
|
"EMBEDDING_API_URL": "https://embedding.example/v1/embeddings",
|
||||||
|
"EMBEDDING_API_KEY": "test-key",
|
||||||
|
"EMBEDDING_MODEL": "text-embedding-v4",
|
||||||
|
"EMBEDDING_BATCH_SIZE": "10",
|
||||||
|
}), patch("embeddings.urllib.request.urlopen", side_effect=fake_urlopen):
|
||||||
|
result = embeddings.embed(texts)
|
||||||
|
|
||||||
|
self.assertEqual(batch_sizes, [10, 4])
|
||||||
|
self.assertEqual(result, [[float(index)] for index in range(14)])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.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
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ def test_scheduler_uses_boxim_and_restart_safe_service(
|
|||||||
boxim = MagicMock()
|
boxim = MagicMock()
|
||||||
mock_boxim_class.return_value = boxim
|
mock_boxim_class.return_value = boxim
|
||||||
takeover = MagicMock()
|
takeover = MagicMock()
|
||||||
takeover.poll_and_process_messages = AsyncMock()
|
takeover.poll_messages = AsyncMock()
|
||||||
|
takeover.process_reply_tasks = AsyncMock()
|
||||||
mock_takeover_class.return_value = takeover
|
mock_takeover_class.return_value = takeover
|
||||||
|
|
||||||
environment = {
|
environment = {
|
||||||
@@ -46,14 +47,18 @@ def test_scheduler_uses_boxim_and_restart_safe_service(
|
|||||||
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)
|
||||||
|
|
||||||
scheduler.add_job.assert_called_once()
|
assert scheduler.add_job.call_count == 2
|
||||||
scheduled_callable = scheduler.add_job.call_args.args[0]
|
poll_call, process_call = scheduler.add_job.call_args_list
|
||||||
job_options = scheduler.add_job.call_args.kwargs
|
assert poll_call.args[0] is takeover.poll_messages
|
||||||
assert scheduled_callable is takeover.poll_and_process_messages
|
assert poll_call.kwargs["id"] == "takeover_message_poll"
|
||||||
assert job_options["id"] == "takeover_message_poll"
|
assert poll_call.kwargs["trigger"].interval.total_seconds() == 1
|
||||||
assert job_options["trigger"].interval.total_seconds() == 1
|
assert poll_call.kwargs["max_instances"] == 1
|
||||||
assert job_options["max_instances"] == 1
|
assert poll_call.kwargs["coalesce"] is True
|
||||||
assert job_options["coalesce"] is True
|
assert process_call.args[0] is takeover.process_reply_tasks
|
||||||
|
assert process_call.kwargs["id"] == "takeover_reply_process"
|
||||||
|
assert process_call.kwargs["trigger"].interval.total_seconds() == 0.5
|
||||||
|
assert process_call.kwargs["max_instances"] == 1
|
||||||
|
assert process_call.kwargs["coalesce"] is True
|
||||||
scheduler.start.assert_called_once_with()
|
scheduler.start.assert_called_once_with()
|
||||||
|
|
||||||
main.takeover_scheduler = None
|
main.takeover_scheduler = None
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
"""End-to-end service tests for BOXIM takeover timing and human priority."""
|
"""End-to-end service tests for BOXIM takeover timing and human priority."""
|
||||||
|
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from threading import Barrier
|
||||||
from unittest.mock import AsyncMock, patch
|
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, TakeoverCursor, TakeoverMessage, TakeoverReplyTask, User
|
||||||
@@ -58,11 +58,10 @@ class FakeBoxIM:
|
|||||||
|
|
||||||
|
|
||||||
@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)
|
||||||
@@ -143,6 +142,39 @@ async def test_incoming_message_is_prepared_then_sent_at_three_seconds(service_c
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_different_contacts_generate_without_blocking_each_other(service_context):
|
||||||
|
session_factory, service, boxim, clock = service_context
|
||||||
|
await service.poll_and_process_messages()
|
||||||
|
boxim.messages.extend(
|
||||||
|
[
|
||||||
|
{"id": 13, "localId": 31, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "联系人甲"},
|
||||||
|
{"id": 14, "localId": 32, "sendId": 300, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "联系人乙"},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
both_generating = Barrier(2, timeout=2)
|
||||||
|
|
||||||
|
def resolve(_db, _avatar, prompt, _history, **_kwargs):
|
||||||
|
both_generating.wait()
|
||||||
|
return {"answer": f"回复{prompt[-1]}"}
|
||||||
|
|
||||||
|
with patch("routers.chat._resolve_reply", side_effect=resolve):
|
||||||
|
await service.poll_and_process_messages()
|
||||||
|
|
||||||
|
clock.advance(3)
|
||||||
|
await service.process_reply_tasks()
|
||||||
|
assert {(item["peerId"], item["content"]) for item in boxim.sent} == {
|
||||||
|
("200", "回复甲"),
|
||||||
|
("300", "回复乙"),
|
||||||
|
}
|
||||||
|
|
||||||
|
db = session_factory()
|
||||||
|
try:
|
||||||
|
assert {task.status for task in db.query(TakeoverReplyTask).all()} == {"sent"}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_read_receipt_failure_does_not_advance_cursor(service_context):
|
async def test_read_receipt_failure_does_not_advance_cursor(service_context):
|
||||||
session_factory, service, boxim, clock = service_context
|
session_factory, service, boxim, clock = service_context
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
import uuid
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from threading import Barrier
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from database import SessionLocal
|
||||||
|
from main import app, seed
|
||||||
|
from models import Avatar, TokenAccount, 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 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_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()
|
||||||
@@ -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,112 @@
|
|||||||
|
# 数字分身 H5 生产接入与部署
|
||||||
|
|
||||||
|
## 1. 接入方式
|
||||||
|
|
||||||
|
生产会会在用户已登录后打开以下地址:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://<数字分身生产域名>/#/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
|
||||||
|
|
||||||
|
DATABASE_URL=sqlite:////data/avatar.db
|
||||||
|
UPLOAD_DIR=/data/uploads
|
||||||
|
CHAT_MODEL_CONFIG_URL=http://<huihuisquare-api>/api/ai-models/runtime/digital-avatar
|
||||||
|
```
|
||||||
|
|
||||||
|
如生产 AI 配置中心不可用,还应提供当前项目支持的 `OPENAI_API_KEY`、`OPENAI_BASE_URL`、`CHAT_MODEL` 等兜底配置。`/data` 必须挂载持久卷,数据库与知识库文件不可存放在容器临时层。
|
||||||
|
|
||||||
|
## 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
|
||||||
|
```
|
||||||
|
|
||||||
|
生产编排应把示例中的测试端口改为内网暴露,由统一 HTTPS 网关接入。后端暂时使用 SQLite,必须保持单实例写入;若扩展为多后端实例,应先迁移到 PostgreSQL,并把延迟接管任务改为共享队列。
|
||||||
|
|
||||||
|
## 4. 网关要求
|
||||||
|
|
||||||
|
必须使用 HTTPS。同域部署时,H5 静态资源与 `/api/` 由同一域名提供,可避免跨域和 Cookie/来源策略问题。Nginx 关键配置示例:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
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 20m;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`proxy_buffering off` 用于数字分身 SSE 流式吐字,`client_max_body_size` 用于知识库文件上传。网关和应用日志必须关闭完整 URL 查询参数记录,任何异常日志都不得输出 token、Authorization 或平台密钥。建议同时设置严格的 `Referrer-Policy: no-referrer`。
|
||||||
|
|
||||||
|
## 5. 发布验收
|
||||||
|
|
||||||
|
1. 已登录会会用户通过带 token 链接打开后直接进入 `/avatar/manage`,不出现登录页或创建新账号页。
|
||||||
|
2. 页面加载后地址栏中不再包含 `token`,刷新页面仍使用本地 `app_token` 正常访问。
|
||||||
|
3. 后端用户绑定的是 BOXIM 返回的 `huihuiUserId`,不是 BOXIM 内部 `id`;原有数字分身、独立知识库和 Token 余额均存在。
|
||||||
|
4. A、B 两个会会用户分别进入时只能看到各自的数字分身与知识库,不会继承上一用户缓存。
|
||||||
|
5. 使用过期或伪造 token 时进入登录页并显示凭证失效,不得继续访问旧用户数据。
|
||||||
|
6. 分身聊天 SSE 逐段输出正常,Markdown 正常渲染,知识库优先级和 Token 扣费正常。
|
||||||
|
7. 开启 BOXIM 主动接管后保持在线,收到消息、三秒回复、已读回执和主人发言暂停均正常。
|
||||||
|
8. 重建容器后数据库、头像、知识库文档仍存在,`/api/health` 返回成功。
|
||||||
|
|
||||||
|
## 6. 回滚
|
||||||
|
|
||||||
|
保留上一版前后端镜像标签和发布前数据库/上传文件备份。代码回滚优先切回上一镜像;只有新版本执行了不可逆数据变更时才恢复数据库。恢复前先停止后端写入,恢复后对比用户数、分身数、知识库文档数并完成一次免登录和聊天验收。
|
||||||
@@ -7,8 +7,7 @@
|
|||||||
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>
|
|
||||||
<!-- 混合架构部署配置:web-view 内请把 apiBase 设为后端公网地址(如 'https://geo.99hui.com/api')。
|
<!-- 混合架构部署配置:web-view 内请把 apiBase 设为后端公网地址(如 'https://geo.99hui.com/api')。
|
||||||
留空则回退为 '/api'(开发态由 Vite 代理到 :8000)。 -->
|
留空则回退为 '/api'(开发态由 Vite 代理到 :8000)。 -->
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
|
|||||||
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,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>
|
||||||
|
|||||||
@@ -131,9 +131,24 @@ 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
|
||||||
|
}
|
||||||
|
|
||||||
// 获取 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 = () =>
|
||||||
@@ -143,6 +158,10 @@ export const getRechargePlans = () =>
|
|||||||
export const chargeToken = (planId: string) =>
|
export const chargeToken = (planId: string) =>
|
||||||
request.post<{ balance: number; charged: number }>('/token/charge', { planId })
|
request.post<{ balance: number; charged: number }>('/token/charge', { planId })
|
||||||
|
|
||||||
|
// 按分身和使用场景汇总 Token 消耗
|
||||||
|
export const getTokenUsage = () =>
|
||||||
|
request.get<TokenUsageSummary[]>('/token/usage')
|
||||||
|
|
||||||
// ==================== 授权管理 API ====================
|
// ==================== 授权管理 API ====================
|
||||||
|
|
||||||
export type AvatarPermission = 'friend' | 'chat' | 'publish' | 'browse' | 'interact' | 'takeover'
|
export type AvatarPermission = 'friend' | 'chat' | 'publish' | 'browse' | 'interact' | 'takeover'
|
||||||
@@ -409,7 +428,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 +443,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')
|
||||||
|
|||||||
@@ -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,6 +45,12 @@ 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',
|
||||||
@@ -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,15 @@
|
|||||||
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'
|
||||||
|
|
||||||
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 +46,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,26 +70,17 @@ 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)
|
return acceptLogin(await loginBySms(phone, code))
|
||||||
token.value = res.token
|
|
||||||
user.value = { ...(res.user || {}), ...(res.huihui || {}) }
|
|
||||||
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)
|
return acceptLogin(await loginByPassword(account, password))
|
||||||
token.value = res.token
|
|
||||||
user.value = { ...(res.user || {}), ...(res.huihui || {}) }
|
|
||||||
isLogin.value = true
|
|
||||||
setAuthToken(res.token)
|
|
||||||
persist()
|
|
||||||
return res
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loginByToken = async (huihuiToken: string) =>
|
||||||
|
acceptLogin(await loginByHuihuiToken(huihuiToken))
|
||||||
|
|
||||||
// 退出
|
// 退出
|
||||||
const logout = async () => {
|
const logout = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -71,12 +88,19 @@ export const useUserStore = defineStore('smsuser', () => {
|
|||||||
} catch {
|
} catch {
|
||||||
/* 忽略网络错误,本地清除即可 */
|
/* 忽略网络错误,本地清除即可 */
|
||||||
}
|
}
|
||||||
token.value = ''
|
clearSession()
|
||||||
user.value = null
|
|
||||||
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 {
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
// 会会数字分身 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__'
|
||||||
|
|
||||||
@@ -15,6 +13,16 @@ 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
|
return !!(window as any).uni?.webView
|
||||||
@@ -22,21 +30,33 @@ export function isInUniWebView(): boolean {
|
|||||||
|
|
||||||
// 解析 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
|
||||||
|
|||||||
@@ -109,7 +109,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 +118,14 @@ 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'
|
||||||
|
|
||||||
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 permissionItems: Array<{
|
const permissionItems: Array<{
|
||||||
key: AvatarPermission
|
key: AvatarPermission
|
||||||
@@ -307,7 +309,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()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
<div class="token-info">
|
<div class="token-info">
|
||||||
<span class="token-label">Token 余额</span>
|
<span class="token-label">Token 余额</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>
|
||||||
@@ -44,6 +45,9 @@
|
|||||||
|
|
||||||
<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 +56,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() }} Token</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 +77,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>
|
||||||
|
|
||||||
@@ -173,20 +97,12 @@ 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 +113,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 +126,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 +174,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 +186,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));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 顶部导航 */
|
/* 顶部导航 */
|
||||||
@@ -465,6 +321,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;
|
||||||
@@ -516,13 +378,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 +451,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 +486,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 +555,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>
|
||||||
|
|||||||
@@ -79,9 +79,9 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed } from 'vue'
|
import { ref, onMounted, 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 {
|
import {
|
||||||
getKnowledgeDocs,
|
getKnowledgeDocs,
|
||||||
uploadKnowledgeDoc,
|
uploadKnowledgeDoc,
|
||||||
@@ -93,9 +93,10 @@ import {
|
|||||||
} from '@/api'
|
} from '@/api'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
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 activeTab = ref<'docs' | 'qa'>('docs')
|
const activeTab = ref<'docs' | 'qa'>('docs')
|
||||||
|
|
||||||
const docs = ref<any[]>([])
|
const docs = ref<any[]>([])
|
||||||
@@ -200,9 +201,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,6 +239,7 @@ 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()])
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -234,7 +248,7 @@ onMounted(async () => {
|
|||||||
.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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,7 +286,12 @@ onMounted(async () => {
|
|||||||
.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; }
|
.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; }
|
||||||
.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; }
|
||||||
@@ -493,6 +512,8 @@ onMounted(async () => {
|
|||||||
.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; }
|
||||||
.card-content { grid-column: 2; }
|
.card-content { grid-column: 2; }
|
||||||
.card-delete { grid-column: 2; justify-self: end; margin-top: -2px; }
|
.card-delete { grid-column: 2; justify-self: end; margin-top: -2px; }
|
||||||
|
.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; }
|
||||||
|
|||||||
@@ -49,14 +49,14 @@
|
|||||||
import { ref, reactive, computed, onMounted } from 'vue'
|
import { ref, reactive, computed, 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)
|
||||||
|
|
||||||
@@ -104,8 +104,11 @@ const save = async () => {
|
|||||||
} else {
|
} else {
|
||||||
await createQAPair(avatarId.value, payload)
|
await createQAPair(avatarId.value, payload)
|
||||||
}
|
}
|
||||||
// 保存成功返回知识库管理页
|
if (route.params.avatarId) {
|
||||||
|
router.replace({ name: 'AvatarKnowledgeManage', params: { avatarId: avatarId.value } })
|
||||||
|
} else {
|
||||||
router.replace('/knowledge')
|
router.replace('/knowledge')
|
||||||
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
error.value = e?.message || '保存失败'
|
error.value = e?.message || '保存失败'
|
||||||
} finally {
|
} finally {
|
||||||
@@ -117,6 +120,7 @@ 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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
<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">Token</span>
|
||||||
|
<span class="balance-used">累计使用 {{ totalConsumed.toLocaleString() }} Token</span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -28,7 +29,7 @@
|
|||||||
@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">Token</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">{{ plan.desc }}</div>
|
||||||
@@ -83,7 +84,8 @@ import { getTokenBalance, getRechargePlans, chargeToken } from '@/api'
|
|||||||
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<{
|
||||||
@@ -105,6 +107,7 @@ 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)
|
||||||
}
|
}
|
||||||
@@ -118,6 +121,10 @@ 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 doCharge = async () => {
|
const doCharge = async () => {
|
||||||
if (!selectedPlan.value || charging.value) return
|
if (!selectedPlan.value || charging.value) return
|
||||||
charging.value = true
|
charging.value = true
|
||||||
@@ -187,6 +194,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 +219,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;
|
||||||
|
|||||||
@@ -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,6 +15,9 @@
|
|||||||
<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>
|
||||||
@@ -49,6 +52,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>
|
||||||
@@ -131,8 +141,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: '', 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 +166,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, 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 || '', 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 +247,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>
|
||||||
|
|||||||
Reference in New Issue
Block a user