Compare commits

...
Author SHA1 Message Date
stefanfeng 0c6419f37e fix(avatar): restore BOXIM takeover after restart 2026-08-26 13:25:16 +08:00
stefanfeng 730f586784 Merge pull request 'docs(avatar): 配置 digital.99hui.com 生产域名' (#4) from codex/avatar-multi-management-integrated-20260825 into main
Reviewed-on: #4
2026-08-26 10:17:40 +08:00
stefanfeng ef2b1c6dd6 chore(avatar): add WeChat verification file 2026-08-26 09:51:13 +08:00
stefanfeng c693899b12 docs(avatar): set production H5 domain 2026-08-26 09:44:22 +08:00
stefanfeng d274ccb5e2 Merge pull request 'feat: 完成数字分身多分身管理与生产 H5 接入' (#3) from codex/avatar-multi-management-integrated-20260825 into main
Reviewed-on: #3
2026-08-26 09:43:52 +08:00
stefanfeng 5d19992f00 fix(avatar): remove SSO token from router state 2026-08-26 09:25:07 +08:00
stefanfeng 81aec1c63a feat(avatar): support production H5 token SSO 2026-08-26 09:17:41 +08:00
stefanfeng 4029c31ed7 fix(avatar): bundle uni bridge and add favicon 2026-08-25 17:19:30 +08:00
stefanfeng e24e89d326 fix(deploy): serialize model migration and pin nginx 2026-08-25 16:59:15 +08:00
stefanfeng dc34a03357 feat(ai): add dedicated digital avatar model config 2026-08-25 16:50:33 +08:00
stefanfeng 3f7ff9329a fix(avatar): align QA cards to the left 2026-08-25 15:36:49 +08:00
stefanfeng 699bbbde57 feat(avatar): add user token accounting 2026-08-25 13:24:02 +08:00
stefanfeng 7a0199e685 feat(avatar): improve multi-avatar management 2026-08-25 11:44:27 +08:00
stefanfeng 9e86cc64ac Merge pull request 'Codex/avatar integrated 20260819' (#2) from codex/avatar-integrated-20260819 into main
Reviewed-on: #2
2026-08-21 09:31:43 +08:00
47 changed files with 1694 additions and 665 deletions
+52 -4
View File
@@ -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,
+1
View File
@@ -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(
+17
View File
@@ -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("✅ 数据库初始化完成")
+1
View File
@@ -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))
+4
View File
@@ -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]
+3 -1
View File
@@ -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()
+14
View File
@@ -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 <> ''"
)
+25 -11
View File
@@ -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"},
{"id": "3", "name": "专业套餐", "amount": 250_000_000, "price": 1000, "badge": "加赠25%", "desc": "250M Token"},
{"id": "4", "name": "企业套餐", "amount": 2_500_000_000, "price": 10000, "badge": "企业推荐", "desc": "2500M Token"},
]
for spec in plan_specs:
plan = db.query(TokenPlan).filter(TokenPlan.id == spec["id"]).first()
if plan is None:
db.add(TokenPlan(**spec))
else:
for key, value in spec.items():
setattr(plan, key, value)
if db.query(TokenPlan).count() == 0: for user in db.query(User).all():
plans = [ account = db.query(TokenAccount).filter(TokenAccount.user_id == user.id).first()
TokenPlan(id="1", name="新手体验", amount=1000, price=9.9, desc="新手体验"), if account is None:
TokenPlan(id="2", name="热门套餐", amount=5000, price=39.9, badge="热门"), db.add(TokenAccount(
TokenPlan(id="3", name="超值套餐", amount=12000, price=89.9, badge="超值"), user_id=user.id,
TokenPlan(id="4", name="企业推荐", amount=30000, price=199, badge="企业推荐", desc="适合高频使用"), balance=DEFAULT_TOKEN_GRANT,
] total_granted=DEFAULT_TOKEN_GRANT,
db.add_all(plans) total_consumed=0,
))
if db.query(Avatar).count() == 0: if db.query(Avatar).count() == 0:
avatar = Avatar( avatar = Avatar(
@@ -105,6 +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()
+31 -2
View File
@@ -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="")
+158 -27
View File
@@ -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
answer = model_client(messages=messages, temperature=temperature) if model_client is not None:
return { answer = model_client(messages=messages, temperature=temperature)
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)):
return _stream_reply(db, _require_owned_avatar(db, avatar_id, authorization), body.message, body.history) try:
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", "")
user.app_token = uuid.uuid4().hex if not reuse_existing_session or not user.app_token:
user.app_token = uuid.uuid4().hex
user.last_login_at = datetime.now() user.last_login_at = datetime.now()
db.add(user) db.add(user)
db.commit() db.commit()
db.refresh(user) db.refresh(user)
from services.token_billing import get_or_create_account
get_or_create_account(db, user.id)
return ok({ return ok({
"token": user.app_token, "token": user.app_token,
"user": user.to_dict(), "user": user.to_dict(),
@@ -356,7 +421,6 @@ def _issue_session(db: Session, phone: str, info: dict):
"userId": huihui_user_id, "userId": huihui_user_id,
"nickname": info.get("nickname", ""), "nickname": info.get("nickname", ""),
"avatarUrl": info.get("avatarUrl", ""), "avatarUrl": info.get("avatarUrl", ""),
"token": info.get("token", ""),
}, },
}) })
+57 -13
View File
@@ -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
@@ -201,13 +201,20 @@ class TakeoverService:
def _forget_boxim_session(self, user_id: str): def _forget_boxim_session(self, user_id: str):
self._sessions.pop(user_id, None) self._sessions.pop(user_id, None)
def _disable_after_connection_failure( def _record_connection_failure(
self, self,
db: Session, db: Session,
avatar: Avatar, avatar: Avatar,
cursor: TakeoverCursor, cursor: TakeoverCursor,
message: str, message: str,
*,
disable_takeover: bool,
): ):
cursor.last_error = message
cursor.last_polled_at = self.now()
if not disable_takeover:
return
permissions = (avatar.config or {}).get("authorizationPermissions", []) permissions = (avatar.config or {}).get("authorizationPermissions", [])
avatar.config = { avatar.config = {
**(avatar.config or {}), **(avatar.config or {}),
@@ -217,8 +224,6 @@ class TakeoverService:
if permission != TAKEOVER_PERMISSION if permission != TAKEOVER_PERMISSION
], ],
} }
cursor.last_error = message
cursor.last_polled_at = self.now()
tasks = ( tasks = (
db.query(TakeoverReplyTask) db.query(TakeoverReplyTask)
.filter( .filter(
@@ -245,11 +250,12 @@ class TakeoverService:
db.add(cursor) db.add(cursor)
db.flush() db.flush()
if not user or not user.huihui_token: if not user or not user.huihui_token:
self._disable_after_connection_failure( self._record_connection_failure(
db, db,
avatar, avatar,
cursor, cursor,
"请重新登录会会生产账号后再开启主动接管", "请重新登录会会生产账号后再开启主动接管",
disable_takeover=True,
) )
db.commit() db.commit()
return False return False
@@ -268,11 +274,24 @@ class TakeoverService:
if isinstance(exc, BoxIMError) and exc.auth_error: if isinstance(exc, BoxIMError) and exc.auth_error:
self._forget_boxim_session(user.id) self._forget_boxim_session(user.id)
message = "BOXIM 授权已失效,请重新登录会会生产账号" message = "BOXIM 授权已失效,请重新登录会会生产账号"
disable_takeover = True
else: else:
message = f"BOXIM 暂时连接失败:{str(exc)[:160]}" message = f"BOXIM 暂时连接失败:{str(exc)[:160]}"
self._disable_after_connection_failure(db, avatar, cursor, message) disable_takeover = False
self._record_connection_failure(
db,
avatar,
cursor,
message,
disable_takeover=disable_takeover,
)
db.commit() db.commit()
logger.warning("BOXIM sync failed for avatar %s: %s", avatar.id, exc) logger.warning(
"BOXIM sync failed for avatar %s (will_retry=%s): %s",
avatar.id,
not disable_takeover,
exc,
)
return False return False
messages.sort(key=lambda item: (_numeric_id(item.get("id")), item.get("sendTime") or 0)) messages.sort(key=lambda item: (_numeric_id(item.get("id")), item.get("sendTime") or 0))
@@ -520,7 +539,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":
@@ -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,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
@@ -7,7 +7,6 @@ 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
@@ -59,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)
@@ -156,7 +154,7 @@ async def test_different_contacts_generate_without_blocking_each_other(service_c
) )
both_generating = Barrier(2, timeout=2) both_generating = Barrier(2, timeout=2)
def resolve(_db, _avatar, prompt, _history): def resolve(_db, _avatar, prompt, _history, **_kwargs):
both_generating.wait() both_generating.wait()
return {"answer": f"回复{prompt[-1]}"} return {"answer": f"回复{prompt[-1]}"}
@@ -293,5 +291,41 @@ async def test_connection_failure_disables_takeover_and_stops_retrying(service_c
boxim.exchange_access_token.assert_awaited_once_with("prod-huihui-token") boxim.exchange_access_token.assert_awaited_once_with("prod-huihui-token")
@pytest.mark.asyncio
async def test_transient_connection_failure_keeps_takeover_and_recovers(service_context):
session_factory, service, boxim, _ = service_context
boxim.exchange_access_token = AsyncMock(
side_effect=[
BoxIMError("连接超时"),
{"accessToken": "box-token", "accessTokenExpiresIn": 3600},
]
)
await service.poll_messages()
db = session_factory()
try:
avatar = db.query(Avatar).one()
cursor = db.query(TakeoverCursor).one()
assert "takeover" in avatar.config["authorizationPermissions"]
assert cursor.initialized is False
assert "暂时连接失败" in cursor.last_error
finally:
db.close()
await service.poll_messages()
db = session_factory()
try:
avatar = db.query(Avatar).one()
cursor = db.query(TakeoverCursor).one()
assert "takeover" in avatar.config["authorizationPermissions"]
assert cursor.initialized is True
assert cursor.last_error == ""
finally:
db.close()
assert boxim.exchange_access_token.await_count == 2
def test_plain_text_reply_removes_markdown_and_empty_lines(): def test_plain_text_reply_removes_markdown_and_empty_lines():
assert _plain_text_reply("## 建议\n\n**不能自行用药**\n`必要时就医`") == "建议\n不能自行用药\n必要时就医" assert _plain_text_reply("## 建议\n\n**不能自行用药**\n`必要时就医`") == "建议\n不能自行用药\n必要时就医"
@@ -0,0 +1,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()
+3
View File
@@ -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,115 @@
# 数字分身 H5 生产接入与部署
## 1. 接入方式
生产会会在用户已登录后打开以下地址:
```text
https://digital.99hui.com/#/avatar/manage?token=<encodeURIComponent(会会 access token)>
```
测试环境示例:
```text
http://192.168.1.188:8099/#/avatar/manage?token=<encodeURIComponent(token)>
```
兼容参数位于域名查询串的形式,但生产统一使用上面的 hash 路由形式。必须对 token 调用 `encodeURIComponent`,不能拼接用户 ID 代替 token。
免登录流程如下:
1. H5 在页面渲染前读取 `token`,立即通过 `history.replaceState` 从地址栏和浏览器历史中移除。
2. H5 调用 `POST /api/huihui/token/login`,不会把会会 token 当作数字分身接口 token 直接使用。
3. 后端通过会会生产接口 `/im/box/netease` 换取 BOXIM 凭证,再调用 BOXIM `/user/self` 校验用户身份。
4. 后端以返回的 `huihuiUserId` 绑定本地用户,保存会会凭证供 BOXIM 接管功能使用,并签发本系统 `app_token`。
5. 浏览器只保存 `app_token` 和非敏感用户资料。会会原始 token 不返回浏览器存储。
6. token 无效、过期或上游校验失败时清除旧会话并进入登录页,不会沿用上一位用户的缓存身份。
## 2. 生产配置
后端 `.env` 至少配置以下内容,密钥由部署平台注入,禁止提交 Git:
```dotenv
HUIHUI_DEV_MOCK=false
HUIHUI_AUTH_BASE_URL=https://99hui.com/api/usercenter
HUIHUI_PLATFORM_BASE_URL=https://open.99hui.com/api
BOXIM_API_BASE_URL=https://im.99hui.com/api
HUIHUI_APP_ID=<production-app-id>
HUIHUI_ACCESS_ID=<production-access-id>
HUIHUI_ACCESS_SECRET=<production-access-secret>
HUIHUI_CLIENT_CODE=<production-client-code>
BOXIM_TIMEOUT_SECONDS=20
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
server_name digital.99hui.com;
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://avatar-backend:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_read_timeout 300s;
client_max_body_size 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` 返回成功。
9. `https://digital.99hui.com/api/health` 可访问,证书域名和有效期正确,HTTP 自动跳转 HTTPS。
## 6. 回滚
保留上一版前后端镜像标签和发布前数据库/上传文件备份。代码回滚优先切回上一镜像;只有新版本执行了不可逆数据变更时才恢复数据库。恢复前先停止后端写入,恢复后对比用户数、分身数、知识库文档数并完成一次免登录和聊天验收。
+3 -4
View File
@@ -7,10 +7,9 @@
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
/> />
<title>会会数字分身</title> <title>会会数字分身</title>
<!-- uniapp web-view 桥接:加载后全局出现 window.uni.webView,H5 才能与原生壳通信 --> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<script type="text/javascript" src="https://unpkg.com/@dcloudio/uni-webview-js@0.0.10/index.js"></script> <!-- digital.99hui.com 同域部署保持 apiBase 为空,接口使用 /api。
<!-- 混合架构部署配置:web-view 内请把 apiBase 设为后端公网地址(如 'https://geo.99hui.com/api')。 仅跨域部署时才显式配置后端公网地址。 -->
留空则回退为 '/api'(开发态由 Vite 代理到 :8000)。 -->
<script type="text/javascript"> <script type="text/javascript">
window.__APP_CONFIG__ = { apiBase: '' } window.__APP_CONFIG__ = { apiBase: '' }
</script> </script>
+7
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -0,0 +1 @@
8f54097b01d5b0433f8aa5ea70ca930b
+11
View File
@@ -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 -108
View File
@@ -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>
+27 -4
View File
@@ -9,8 +9,8 @@ interface ApiClient {
// API 基址:优先级 window.__APP_CONFIG__.apiBase > 环境变量 > 默认 '/api' // API 基址:优先级 window.__APP_CONFIG__.apiBase > 环境变量 > 默认 '/api'
// - 开发/Vite 代理:'/api'(由 vite.config 代理到后端 :8000) // - 开发/Vite 代理:'/api'(由 vite.config 代理到后端 :8000)
// - web-view 内(混合架构):需配置为后端公网地址,例如 'https://geo.99hui.com/api' // - digital.99hui.com 同域部署:保持 '/api'
// - 同域部署的构建产物:可保持 '/api' // - 仅跨域部署时配置后端公网地址
function resolveBaseURL(): string { function resolveBaseURL(): string {
const cfg = (window as any).__APP_CONFIG__ const cfg = (window as any).__APP_CONFIG__
if (cfg && cfg.apiBase) return cfg.apiBase as string if (cfg && cfg.apiBase) return cfg.apiBase as string
@@ -131,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')
+46 -20
View File
@@ -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()
+24
View File
@@ -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',
+20 -2
View File
@@ -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,
+45 -21
View File
@@ -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
}
}) })
+4
View File
@@ -0,0 +1,4 @@
declare module '@dcloudio/uni-webview-js' {
const uniWebView: unknown
export default uniWebView
}
+5
View File
@@ -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 {
+34 -14
View File
@@ -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()
}) })
+74 -396
View File
@@ -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; }
+8 -4
View File
@@ -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('/knowledge') router.replace({ name: 'AvatarKnowledgeManage', params: { avatarId: avatarId.value } })
} else {
router.replace('/knowledge')
}
} catch (e: any) { } catch (e: any) {
error.value = e?.message || '保存失败' error.value = e?.message || '保存失败'
} finally { } finally {
@@ -117,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()
} }
+9 -1
View File
@@ -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
+16 -2
View File
@@ -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;
+1
View File
@@ -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:
+1
View File
@@ -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
View File
@@ -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
+16 -4
View File
@@ -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>