feat: 完成数字分身多分身管理与生产 H5 接入 #3

Merged
stefanfeng merged 10 commits from codex/avatar-multi-management-integrated-20260825 into main 2026-08-26 09:43:53 +08:00
49 changed files with 1792 additions and 697 deletions
+52 -4
View File
@@ -1,8 +1,11 @@
"""AI模型配置接口"""
from fastapi import APIRouter, Depends, HTTPException
import secrets
from fastapi import APIRouter, Depends, Header, HTTPException
from sqlalchemy import select, update
from app.core.database import get_db
from app.core.config import settings
from app.schemas import ApiResponse, AIModelCreateRequest, AIModelUpdateRequest, AIModelTestRequest
from app.models import AIModelConfig
from app.utils.crypto import encrypt, decrypt
@@ -22,10 +25,15 @@ async def list_models(db=Depends(get_db)):
@router.post("")
async def create_model(req: AIModelCreateRequest, db=Depends(get_db)):
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_name=req.model_name,
provider=req.provider,
usage_scope=req.usage_scope,
api_base_url=req.api_base_url,
api_key_enc=encrypt(req.api_key) if req.api_key else None,
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()
if not model:
raise HTTPException(status_code=404, detail="模型不存在")
if req.is_default:
await db.execute(update(AIModelConfig).where(AIModelConfig.id != model_id).values(is_default=0))
target_scope = req.usage_scope or model.usage_scope
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():
if field == "api_key":
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="更新成功")
@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}")
async def delete_model(model_id: int, db=Depends(get_db)):
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:
return {
"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),
"model_version": m.model_version, "temperature": m.temperature,
"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")
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(
+17
View File
@@ -1,6 +1,7 @@
"""数据库连接管理"""
import asyncio
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy import text
from sqlalchemy.orm import DeclarativeBase
from app.core.config import settings
from app.core.logger import logger
@@ -64,6 +65,22 @@ async def init_db():
VirtualUser, UserPersonality, InteractionRecord,
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("✅ 数据库初始化完成")
+1
View File
@@ -122,6 +122,7 @@ class AIModelConfig(Base):
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
model_name: Mapped[str] = mapped_column(String(64), 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_key_enc: Mapped[str | None] = mapped_column(String(512))
model_version: Mapped[str | None] = mapped_column(String(64))
+4
View File
@@ -154,6 +154,7 @@ class InteractionResponse(BaseModel):
class AIModelCreateRequest(BaseModel):
model_name: str = Field(..., min_length=1, max_length=64)
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_key: Optional[str] = None
model_version: Optional[str] = None
@@ -165,6 +166,8 @@ class AIModelCreateRequest(BaseModel):
class AIModelUpdateRequest(BaseModel):
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_key: Optional[str] = None
model_version: Optional[str] = None
@@ -179,6 +182,7 @@ class AIModelResponse(BaseModel):
id: int
model_name: str
provider: str
usage_scope: str
api_base_url: Optional[str]
has_api_key: bool
model_version: Optional[str]
+3 -1
View File
@@ -28,7 +28,9 @@ class AIService:
async def _get_default_model(self, db: AsyncSession) -> Optional[AIModelConfig]:
result = await db.execute(
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()
+14
View File
@@ -40,8 +40,14 @@ def init_db():
("authorizations", "takeover_mode", "VARCHAR DEFAULT 'immediate'"),
("authorizations", "takeover_delay_seconds", "INTEGER DEFAULT 30"),
("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()
_create_token_indexes()
def _try_add_columns(*cols):
@@ -58,3 +64,11 @@ def _try_add_columns(*cols):
def _normalize_optional_unique_values():
with engine.begin() as conn:
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 <> ''"
)
+26 -16
View File
@@ -51,22 +51,32 @@ def embed(texts):
if api_url:
api_key = os.getenv("EMBEDDING_API_KEY", "")
model = os.getenv("EMBEDDING_MODEL", "text-embedding-3-small")
payload = json.dumps({"input": texts, "model": model}).encode("utf-8")
req = urllib.request.Request(
api_url,
data=payload,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}" if api_key else "",
},
method="POST",
)
with urllib.request.urlopen(req, timeout=30) as resp:
data = json.loads(resp.read().decode("utf-8"))
items = data["data"]
if items and "index" in items[0]:
items = sorted(items, key=lambda x: x["index"])
return [item["embedding"] for item in items]
try:
batch_size = max(1, int(os.getenv("EMBEDDING_BATCH_SIZE", "10")))
except ValueError:
batch_size = 10
embeddings = []
for start in range(0, len(texts), batch_size):
batch = texts[start:start + batch_size]
payload = json.dumps({"input": batch, "model": model}).encode("utf-8")
req = urllib.request.Request(
api_url,
data=payload,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}" if api_key else "",
},
method="POST",
)
with urllib.request.urlopen(req, timeout=30) as resp:
data = json.loads(resp.read().decode("utf-8"))
items = data["data"]
if items and "index" in items[0]:
items = sorted(items, key=lambda x: x["index"])
if len(items) != len(batch):
raise ValueError("embedding response count does not match request")
embeddings.extend(item["embedding"] for item in items)
return embeddings
return _hash_embedding(texts)
+41 -13
View File
@@ -8,7 +8,7 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.interval import IntervalTrigger
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
import routers.avatars
import routers.tokens
@@ -19,6 +19,7 @@ import routers.huihui_auth
import routers.chat
import routers.takeover
from responses import ok
from services.token_billing import DEFAULT_TOKEN_GRANT, release_stale_reservations
logger = logging.getLogger(__name__)
@@ -56,17 +57,29 @@ def health():
def seed():
db = SessionLocal()
try:
if db.query(TokenAccount).first() is None:
db.add(TokenAccount(balance=1250))
plan_specs = [
{"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:
plans = [
TokenPlan(id="1", name="新手体验", amount=1000, price=9.9, desc="新手体验"),
TokenPlan(id="2", name="热门套餐", amount=5000, price=39.9, badge="热门"),
TokenPlan(id="3", name="超值套餐", amount=12000, price=89.9, badge="超值"),
TokenPlan(id="4", name="企业推荐", amount=30000, price=199, badge="企业推荐", desc="适合高频使用"),
]
db.add_all(plans)
for user in db.query(User).all():
account = db.query(TokenAccount).filter(TokenAccount.user_id == user.id).first()
if account is None:
db.add(TokenAccount(
user_id=user.id,
balance=DEFAULT_TOKEN_GRANT,
total_granted=DEFAULT_TOKEN_GRANT,
total_consumed=0,
))
if db.query(Avatar).count() == 0:
avatar = Avatar(
@@ -105,6 +118,7 @@ def seed():
db.add_all(orgs)
db.commit()
release_stale_reservations(db)
finally:
db.close()
@@ -143,14 +157,28 @@ def on_startup():
poll_interval = max(0.5, float(os.getenv("BOXIM_POLL_INTERVAL_SECONDS", "1")))
takeover_scheduler = AsyncIOScheduler()
takeover_scheduler.add_job(
takeover_service.poll_and_process_messages,
takeover_service.poll_messages,
trigger=IntervalTrigger(seconds=poll_interval),
id="takeover_message_poll",
max_instances=1,
coalesce=True,
)
process_interval = max(
0.25, float(os.getenv("TAKEOVER_PROCESS_INTERVAL_SECONDS", "0.5"))
)
takeover_scheduler.add_job(
takeover_service.process_reply_tasks,
trigger=IntervalTrigger(seconds=process_interval),
id="takeover_reply_process",
max_instances=1,
coalesce=True,
)
takeover_scheduler.start()
logger.info("BOXIM takeover scheduler started (interval=%ss)", poll_interval)
logger.info(
"BOXIM takeover scheduler started (poll=%ss, process=%ss)",
poll_interval,
process_interval,
)
except Exception as e:
stop_takeover_scheduler()
logger.warning(f"Failed to initialize takeover scheduler, app will continue without it: {e}")
+31 -2
View File
@@ -1,6 +1,7 @@
import uuid
from sqlalchemy import (
BigInteger,
Boolean,
Column,
DateTime,
@@ -259,14 +260,42 @@ class KnowledgeChunk(Base):
class TokenAccount(Base):
__tablename__ = "token_account"
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):
__tablename__ = "token_plans"
id = Column(String, primary_key=True)
name = Column(String, default="")
amount = Column(Integer, default=0)
amount = Column(BigInteger, default=0)
price = Column(Float, default=0)
badge = Column(String, default="")
desc = Column(String, default="")
+158 -27
View File
@@ -16,12 +16,17 @@ import embeddings
from database import get_db
from models import Avatar, KnowledgeChunk, KnowledgeDoc, QAPair, User
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=["数字分身聊天"])
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_HISTORY_MESSAGES = 10
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
def _call_qwen(messages: list[dict], temperature: float) -> str:
if not CHAT_API_KEY:
def _call_qwen(
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")
url = f"{CHAT_API_URL.rstrip('/')}/chat/completions"
url = f"{model_config.api_base_url}/chat/completions"
payload = {
"model": CHAT_MODEL,
"model": model_config.model,
"messages": messages,
"temperature": temperature,
"max_tokens": model_config.max_tokens,
}
try:
response = httpx.post(
url,
headers={"Authorization": f"Bearer {CHAT_API_KEY}"},
headers={"Authorization": f"Bearer {model_config.api_key}"},
json=payload,
timeout=30,
timeout=model_config.timeout_seconds,
)
response.raise_for_status()
data = response.json()
@@ -302,17 +311,33 @@ def _call_qwen(messages: list[dict], temperature: float) -> str:
raise RuntimeError("Qwen 模型服务暂时不可用") from exc
if not isinstance(answer, str) or not answer.strip():
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 分片原样转为文本增量。"""
if not CHAT_API_KEY:
model_config = model_config or get_chat_model_config()
if not model_config.api_key:
raise RuntimeError("模型服务未配置")
url = f"{CHAT_API_URL.rstrip('/')}/chat/completions"
payload = {"model": CHAT_MODEL, "messages": messages, "temperature": temperature, "stream": True}
url = f"{model_config.api_base_url}/chat/completions"
payload = {
"model": model_config.model,
"messages": messages,
"temperature": temperature,
"max_tokens": model_config.max_tokens,
"stream": True,
"stream_options": {"include_usage": True},
}
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()
for raw_line in response.iter_lines():
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]":
return
try:
delta = json.loads(data).get("choices", [{}])[0].get("delta", {}).get("content")
parsed = json.loads(data)
except (ValueError, IndexError, AttributeError):
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:
yield delta
yield {"content": delta}
except httpx.HTTPError as exc:
raise RuntimeError("模型服务暂时不可用") from exc
@@ -350,6 +379,7 @@ def _resolve_reply(
qa_pairs: list[Any] | None = None,
search_fn: Callable[..., list[dict]] | None = None,
model_client: Callable[..., str] | None = None,
usage_source: str = "chat",
) -> dict:
if qa_pairs is None:
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)
config = _config(avatar)
temperature = min(0.45 if hits else 0.25, 0.2 + config["creativity"] / 100 * 0.6)
model_client = model_client or _call_qwen
answer = model_client(messages=messages, temperature=temperature)
return {
token_usage = None
if model_client is not None:
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,
"source": "knowledge" if hits else "qwen",
"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()
matched = _match_standard_qa(question, qa_pairs)
if matched:
@@ -381,18 +449,63 @@ def _stream_reply(db: Session, avatar: Avatar, question: str, history: list[Any]
source = "knowledge" if references else "qwen"
config = _config(avatar)
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:
source, references = "public", []
def generate():
output_parts = []
provider_usage = None
settled = False
try:
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("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:
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(
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)):
avatar = _require_shared_avatar(db, share_token)
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["source"] = "public"
result.pop("tokenUsage", None)
return ok(result)
except InsufficientTokensError as exc:
return fail(str(exc), code=402)
except RuntimeError as exc:
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)
try:
return ok(_resolve_reply(db, avatar, body.message, body.history))
except InsufficientTokensError as exc:
return fail(str(exc), code=402)
except RuntimeError as exc:
return fail(str(exc), code=502)
@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)):
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")
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 models import Avatar, TakeoverCursor, TakeoverMessage, TakeoverReplyTask, User
from responses import ok, fail
from services.boxim_client import BoxIMClient, BoxIMError
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)
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):
"""调用会会接口,返回 (ok: bool, payload: dict, http_status: int)"""
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:
"""Move one user's avatar-owned data to a replacement Huihui identity."""
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
def _issue_session(db: Session, phone: str, info: dict):
def _issue_session(
db: Session,
phone: str,
info: dict,
*,
reuse_existing_session: bool = False,
):
"""建/链本地用户并签发本系统会话 token"""
huihui_user_id = info.get("userId", "")
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"):
user.avatar_url = info["avatarUrl"]
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()
db.add(user)
db.commit()
db.refresh(user)
from services.token_billing import get_or_create_account
get_or_create_account(db, user.id)
return ok({
"token": user.app_token,
"user": user.to_dict(),
@@ -356,7 +421,6 @@ def _issue_session(db: Session, phone: str, info: dict):
"userId": huihui_user_id,
"nickname": info.get("nickname", ""),
"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 database import get_db
from models import TokenAccount, TokenPlan
from models import TokenAccount, TokenPlan, TokenUsage, User
from responses import ok, fail
from services.token_billing import get_or_create_account
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")
def balance(db: Session = Depends(get_db)):
acc = db.query(TokenAccount).first()
return ok({"balance": acc.balance if acc else 0})
def balance(authorization: str = Header(None), db: Session = Depends(get_db)):
user = _require_user(authorization, db)
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")
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()
return ok([p.to_dict() for p in items])
@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 = db.query(TokenPlan).filter(TokenPlan.id == plan_id).first()
if not plan:
return fail("套餐不存在", 404)
acc = db.query(TokenAccount).first()
if not acc:
acc = TokenAccount(balance=0)
db.add(acc)
db.commit()
db.refresh(acc)
acc = get_or_create_account(db, user.id)
acc.balance += plan.amount
acc.total_granted = int(acc.total_granted or 0) + plan.amount
db.commit()
return ok({"balance": acc.balance, "charged": plan.amount})
@router.get("/token/usage")
def usage(authorization: str = Header(None), db: Session = Depends(get_db)):
user = _require_user(authorization, db)
rows = (
db.query(
TokenUsage.avatar_id,
TokenUsage.source,
func.sum(TokenUsage.prompt_tokens),
func.sum(TokenUsage.completion_tokens),
func.sum(TokenUsage.total_tokens),
func.count(TokenUsage.id),
)
.filter(TokenUsage.user_id == user.id, TokenUsage.status == "completed")
.group_by(TokenUsage.avatar_id, TokenUsage.source)
.all()
)
return ok([
{
"avatarId": avatar_id,
"source": source,
"promptTokens": int(prompt_tokens or 0),
"completionTokens": int(completion_tokens or 0),
"totalTokens": int(total_tokens or 0),
"requestCount": int(request_count or 0),
}
for avatar_id, source, prompt_tokens, completion_tokens, total_tokens, request_count in rows
])
@@ -0,0 +1,93 @@
import logging
import os
import threading
import time
from dataclasses import dataclass
import httpx
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class ChatModelConfig:
api_base_url: str
api_key: str
model: str
max_tokens: int
timeout_seconds: float
source: str
_cache_lock = threading.Lock()
_cached_config: ChatModelConfig | None = None
_cache_expires_at = 0.0
def _environment_config() -> ChatModelConfig:
return ChatModelConfig(
api_base_url=os.getenv(
"CHAT_API_URL", "https://dashscope.aliyuncs.com/compatible-mode/v1"
).rstrip("/"),
api_key=os.getenv("CHAT_API_KEY", ""),
model=os.getenv("CHAT_MODEL", "qwen-plus"),
max_tokens=max(128, int(os.getenv("CHAT_MAX_OUTPUT_TOKENS", "1024"))),
timeout_seconds=max(5.0, float(os.getenv("CHAT_TIMEOUT_SECONDS", "30"))),
source="environment",
)
def _fetch_runtime_config() -> ChatModelConfig | None:
url = os.getenv("CHAT_MODEL_CONFIG_URL", "").strip()
token = os.getenv("AVATAR_MODEL_CONFIG_TOKEN", "").strip()
if not url or not token:
return None
response = httpx.get(
url,
headers={"X-Avatar-Config-Token": token},
timeout=max(2.0, float(os.getenv("CHAT_MODEL_CONFIG_TIMEOUT_SECONDS", "5"))),
)
response.raise_for_status()
payload = response.json().get("data") or {}
api_base_url = str(payload.get("api_base_url") or "").rstrip("/")
api_key = str(payload.get("api_key") or "")
model = str(payload.get("model") or "")
if not api_base_url or not api_key or not model:
raise ValueError("数字分身专用模型配置不完整")
return ChatModelConfig(
api_base_url=api_base_url,
api_key=api_key,
model=model,
max_tokens=max(128, int(payload.get("max_tokens") or 1024)),
timeout_seconds=max(5.0, float(payload.get("timeout_seconds") or 30)),
source="admin",
)
def get_chat_model_config(*, force_refresh: bool = False) -> ChatModelConfig:
global _cached_config, _cache_expires_at
now = time.monotonic()
if not force_refresh and _cached_config is not None and now < _cache_expires_at:
return _cached_config
with _cache_lock:
now = time.monotonic()
if not force_refresh and _cached_config is not None and now < _cache_expires_at:
return _cached_config
try:
config = _fetch_runtime_config() or _environment_config()
except (httpx.HTTPError, ValueError, TypeError) as exc:
logger.warning("读取数字分身专用模型配置失败,暂时使用环境变量配置: %s", exc)
config = _environment_config()
_cached_config = config
ttl = max(5, int(os.getenv("CHAT_MODEL_CONFIG_CACHE_SECONDS", "60")))
_cache_expires_at = now + ttl
return config
def clear_chat_model_config_cache() -> None:
global _cached_config, _cache_expires_at
with _cache_lock:
_cached_config = None
_cache_expires_at = 0.0
@@ -86,24 +86,34 @@ class TakeoverService:
self.reply_delay_seconds = reply_delay_seconds
self.now = now
self._sessions: dict[str, dict] = {}
self._run_lock = asyncio.Lock()
self._poll_lock = asyncio.Lock()
self._process_lock = asyncio.Lock()
async def poll_and_process_messages(self):
"""Run one complete cycle; polling always happens before reply dispatch."""
if self._run_lock.locked():
"""Run one complete cycle for callers that do not use the split scheduler."""
await self.poll_messages()
await self.process_reply_tasks()
async def poll_messages(self):
"""Fetch BOXIM events without blocking reply generation and dispatch."""
if self._poll_lock.locked():
return
async with self._run_lock:
async with self._poll_lock:
self._recover_stuck_tasks()
avatar_ids = self._enabled_avatar_ids()
self._cancel_disabled_tasks(set(avatar_ids))
for avatar_id in avatar_ids:
await self._sync_avatar(avatar_id)
generated = await self._prepare_replies()
if generated:
# Catch a human reply sent while the model was preparing its answer.
for avatar_id in avatar_ids:
await self._sync_avatar(avatar_id)
async def process_reply_tasks(self):
"""Generate and send replies independently from BOXIM's long poll."""
if self._process_lock.locked():
return
async with self._process_lock:
self._recover_stuck_tasks()
avatar_ids = set(self._enabled_avatar_ids())
self._cancel_disabled_tasks(avatar_ids)
await self._prepare_replies()
await self._dispatch_ready_replies()
def _enabled_avatar_ids(self) -> list[str]:
@@ -454,11 +464,19 @@ class TakeoverService:
finally:
db.close()
generated = 0
for task_id in task_ids:
if await asyncio.to_thread(self._generate_reply, task_id):
generated += 1
return generated
if not task_ids:
return 0
# Each conversation owns its task, so unrelated contacts can generate in
# parallel instead of one slow model response delaying every other peer.
semaphore = asyncio.Semaphore(4)
async def generate(task_id: str) -> bool:
async with semaphore:
return await asyncio.to_thread(self._generate_reply, task_id)
results = await asyncio.gather(*(generate(task_id) for task_id in task_ids))
return sum(bool(result) for result in results)
def _generate_reply(self, task_id: str) -> bool:
db = self.session_factory()
@@ -502,7 +520,7 @@ class TakeoverService:
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", ""))
db.refresh(task)
if task.status != "generating":
@@ -548,8 +566,8 @@ class TakeoverService:
finally:
db.close()
for task_id in task_ids:
await self._send_task(task_id)
if task_ids:
await asyncio.gather(*(self._send_task(task_id) for task_id in task_ids))
async def _send_task(self, task_id: str) -> bool:
db = self.session_factory()
@@ -0,0 +1,198 @@
"""User-scoped token accounting for every avatar model request."""
import math
from dataclasses import dataclass
from datetime import datetime, timedelta
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from models import Avatar, TokenAccount, TokenUsage, User
DEFAULT_TOKEN_GRANT = 1_000_000
class InsufficientTokensError(RuntimeError):
pass
@dataclass(frozen=True)
class TokenReservation:
usage_id: str
user_id: str
reserved_tokens: int
def get_or_create_account(db: Session, user_id: str) -> TokenAccount:
account = db.query(TokenAccount).filter(TokenAccount.user_id == user_id).first()
if account:
return account
account = TokenAccount(
user_id=user_id,
balance=DEFAULT_TOKEN_GRANT,
total_granted=DEFAULT_TOKEN_GRANT,
total_consumed=0,
)
db.add(account)
try:
db.commit()
except IntegrityError:
# A concurrent first request may have created the same user account.
db.rollback()
account = db.query(TokenAccount).filter(TokenAccount.user_id == user_id).first()
if account is None:
raise
db.refresh(account)
return account
def avatar_owner_user(db: Session, avatar: Avatar) -> User | None:
owner_id = (avatar.owner_id or "").strip()
if not owner_id:
return None
return db.query(User).filter(User.huihui_user_id == owner_id).first()
def estimate_request_tokens(messages: list[dict], max_output_tokens: int) -> int:
# UTF-8 bytes / 2 deliberately overestimates mixed Chinese/English prompts;
# the unused reservation is returned after provider usage is received.
content_bytes = sum(
len(str(item.get("content", "")).encode("utf-8"))
for item in messages
)
prompt_reserve = max(1, math.ceil(content_bytes / 2) + len(messages) * 6)
return prompt_reserve + max(1, int(max_output_tokens))
def estimate_fallback_usage(messages: list[dict], output: str) -> int:
content_bytes = sum(
len(str(item.get("content", "")).encode("utf-8"))
for item in messages
) + len((output or "").encode("utf-8"))
return max(1, math.ceil(content_bytes / 3) + len(messages) * 4)
def reserve_avatar_tokens(
db: Session,
avatar: Avatar,
source: str,
model: str,
messages: list[dict],
max_output_tokens: int,
) -> TokenReservation:
user = avatar_owner_user(db, avatar)
if not user:
raise InsufficientTokensError("分身尚未关联有效用户,暂时无法使用 Token")
account = get_or_create_account(db, user.id)
reserved = estimate_request_tokens(messages, max_output_tokens)
updated = (
db.query(TokenAccount)
.filter(TokenAccount.id == account.id, TokenAccount.balance >= reserved)
.update(
{TokenAccount.balance: TokenAccount.balance - reserved},
synchronize_session=False,
)
)
if updated != 1:
db.rollback()
raise InsufficientTokensError("Token 余额不足,请充值后继续")
db.refresh(account)
usage = TokenUsage(
user_id=user.id,
avatar_id=avatar.id,
source=source,
model=model,
status="reserved",
reserved_tokens=reserved,
)
db.add(usage)
db.flush()
usage.balance_after = account.balance
db.commit()
return TokenReservation(usage.id, user.id, reserved)
def settle_reservation(
db: Session,
reservation: TokenReservation,
usage: dict | None,
*,
fallback_total: int,
) -> dict:
record = db.query(TokenUsage).filter(TokenUsage.id == reservation.usage_id).first()
if not record or record.status != "reserved":
return {}
provider_usage = usage or {}
prompt_tokens = max(0, int(provider_usage.get("prompt_tokens") or 0))
completion_tokens = max(0, int(provider_usage.get("completion_tokens") or 0))
provider_total = max(
int(provider_usage.get("total_tokens") or 0),
prompt_tokens + completion_tokens,
)
total_tokens = max(1, provider_total or int(fallback_total or 0))
updated = (
db.query(TokenAccount)
.filter(TokenAccount.user_id == reservation.user_id)
.update(
{
TokenAccount.balance: TokenAccount.balance + reservation.reserved_tokens - total_tokens,
TokenAccount.total_consumed: TokenAccount.total_consumed + total_tokens,
},
synchronize_session=False,
)
)
if updated != 1:
raise RuntimeError("Token 账户不存在")
db.expire_all()
account = db.query(TokenAccount).filter(TokenAccount.user_id == reservation.user_id).first()
record.prompt_tokens = prompt_tokens
record.completion_tokens = completion_tokens
record.total_tokens = total_tokens
record.balance_after = account.balance
record.status = "completed"
record.settled_at = datetime.utcnow()
db.commit()
return {
"promptTokens": prompt_tokens,
"completionTokens": completion_tokens,
"totalTokens": total_tokens,
"balance": account.balance,
}
def release_reservation(db: Session, reservation: TokenReservation, reason: str = "") -> None:
record = db.query(TokenUsage).filter(TokenUsage.id == reservation.usage_id).first()
if not record or record.status != "reserved":
return
updated = (
db.query(TokenAccount)
.filter(TokenAccount.user_id == reservation.user_id)
.update(
{TokenAccount.balance: TokenAccount.balance + reservation.reserved_tokens},
synchronize_session=False,
)
)
if updated:
db.expire_all()
account = db.query(TokenAccount).filter(TokenAccount.user_id == reservation.user_id).first()
record = db.query(TokenUsage).filter(TokenUsage.id == reservation.usage_id).first()
record.balance_after = account.balance
record.status = "failed"
record.failure_reason = (reason or "model_request_failed")[:255]
record.settled_at = datetime.utcnow()
db.commit()
def release_stale_reservations(db: Session, older_than_minutes: int = 10) -> int:
cutoff = datetime.utcnow() - timedelta(minutes=older_than_minutes)
stale = db.query(TokenUsage).filter(
TokenUsage.status == "reserved",
TokenUsage.created_at < cutoff,
).all()
for record in stale:
release_reservation(
db,
TokenReservation(record.id, record.user_id, int(record.reserved_tokens or 0)),
"stale_reservation_recovered",
)
return len(stale)
@@ -8,6 +8,8 @@ from models import (
TakeoverCursor,
TakeoverMessage,
TakeoverReplyTask,
TokenAccount,
TokenUsage,
User,
)
@@ -107,6 +109,13 @@ def authorization_context():
db.query(Avatar).filter(Avatar.id.in_(avatar_ids)).delete(
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(
synchronize_session=False
)
@@ -0,0 +1,79 @@
from unittest.mock import Mock, patch
import httpx
from services.chat_model_config import (
clear_chat_model_config_cache,
get_chat_model_config,
)
def setup_function():
clear_chat_model_config_cache()
def teardown_function():
clear_chat_model_config_cache()
def test_admin_runtime_config_takes_priority(monkeypatch):
monkeypatch.setenv("CHAT_MODEL_CONFIG_URL", "http://config.test/runtime")
monkeypatch.setenv("AVATAR_MODEL_CONFIG_TOKEN", "shared-secret")
response = Mock()
response.raise_for_status.return_value = None
response.json.return_value = {
"data": {
"api_base_url": "https://model.test/v1/",
"api_key": "runtime-key",
"model": "avatar-model",
"max_tokens": 2048,
"timeout_seconds": 42,
}
}
with patch("services.chat_model_config.httpx.get", return_value=response) as request:
config = get_chat_model_config()
assert config.source == "admin"
assert config.api_base_url == "https://model.test/v1"
assert config.model == "avatar-model"
assert config.max_tokens == 2048
request.assert_called_once_with(
"http://config.test/runtime",
headers={"X-Avatar-Config-Token": "shared-secret"},
timeout=5.0,
)
def test_runtime_failure_falls_back_to_environment(monkeypatch):
monkeypatch.setenv("CHAT_MODEL_CONFIG_URL", "http://config.test/runtime")
monkeypatch.setenv("AVATAR_MODEL_CONFIG_TOKEN", "shared-secret")
monkeypatch.setenv("CHAT_API_URL", "https://fallback.test/v1/")
monkeypatch.setenv("CHAT_API_KEY", "fallback-key")
monkeypatch.setenv("CHAT_MODEL", "fallback-model")
monkeypatch.setenv("CHAT_MAX_OUTPUT_TOKENS", "1536")
request = httpx.Request("GET", "http://config.test/runtime")
with patch(
"services.chat_model_config.httpx.get",
side_effect=httpx.ConnectError("offline", request=request),
):
config = get_chat_model_config()
assert config.source == "environment"
assert config.api_base_url == "https://fallback.test/v1"
assert config.api_key == "fallback-key"
assert config.model == "fallback-model"
assert config.max_tokens == 1536
def test_runtime_config_is_cached(monkeypatch):
monkeypatch.setenv("CHAT_MODEL_CONFIG_URL", "")
monkeypatch.setenv("CHAT_MODEL", "first-model")
first = get_chat_model_config()
monkeypatch.setenv("CHAT_MODEL", "second-model")
second = get_chat_model_config()
assert first is second
assert second.model == "first-model"
@@ -1,10 +1,26 @@
import json
import os
import tempfile
import unittest
from unittest.mock import patch
import embeddings
class FakeResponse:
def __init__(self, payload):
self.payload = payload
def __enter__(self):
return self
def __exit__(self, *_):
return None
def read(self):
return json.dumps(self.payload).encode("utf-8")
class TextExtractionTests(unittest.TestCase):
def write_text(self, suffix, content):
handle = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
@@ -28,5 +44,33 @@ class TextExtractionTests(unittest.TestCase):
embeddings.extract_text(path, ".csv")
class RemoteEmbeddingTests(unittest.TestCase):
def test_large_input_is_split_into_provider_safe_batches(self):
texts = [f"chunk-{index}" for index in range(14)]
batch_sizes = []
def fake_urlopen(request, timeout):
self.assertEqual(timeout, 30)
payload = json.loads(request.data.decode("utf-8"))
batch_sizes.append(len(payload["input"]))
return FakeResponse({
"data": [
{"index": index, "embedding": [float(text.split("-")[1])]}
for index, text in enumerate(payload["input"])
]
})
with patch.dict(os.environ, {
"EMBEDDING_API_URL": "https://embedding.example/v1/embeddings",
"EMBEDDING_API_KEY": "test-key",
"EMBEDDING_MODEL": "text-embedding-v4",
"EMBEDDING_BATCH_SIZE": "10",
}), patch("embeddings.urllib.request.urlopen", side_effect=fake_urlopen):
result = embeddings.embed(texts)
self.assertEqual(batch_sizes, [10, 4])
self.assertEqual(result, [[float(index)] for index in range(14)])
if __name__ == "__main__":
unittest.main()
@@ -1,6 +1,7 @@
"""Tests for preserving local avatar ownership when Huihui IDs change."""
from datetime import datetime
from unittest.mock import AsyncMock, patch
import pytest
from sqlalchemy import create_engine
@@ -9,7 +10,8 @@ from sqlalchemy.pool import StaticPool
from database import Base
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
@@ -135,3 +137,55 @@ def test_ambiguous_phone_matches_do_not_move_existing_avatars(db):
assert db.query(User).count() == 3
_assert_avatar_data_owner(db, first_avatar.id, "fat-1")
_assert_avatar_data_owner(db, second_avatar.id, "fat-2")
@pytest.mark.asyncio
async def test_token_login_uses_huihui_user_id_and_keeps_upstream_token_server_side(db):
existing = User(
id="existing-local",
huihui_user_id="huihui-user-88",
app_token="existing-app-session",
)
db.add(existing)
db.commit()
client = AsyncMock()
client.exchange_access_token.return_value = {"accessToken": "boxim-token"}
client.get_self.return_value = {
"id": 998877,
"huihuiUserId": "huihui-user-88",
"nickName": "会会用户",
"headImage": "https://cdn.example/avatar.jpg",
}
with patch("routers.huihui_auth._cfg_ready", return_value=True), patch(
"routers.huihui_auth._create_boxim_client", return_value=client
):
response = await token_login({"token": "production-huihui-token"}, db)
assert response["code"] == 200
assert response["data"]["token"] == "existing-app-session"
assert "token" not in response["data"]["huihui"]
user = db.query(User).one()
assert user.huihui_user_id == "huihui-user-88"
assert user.huihui_user_id != "998877"
assert user.huihui_token == "production-huihui-token"
assert user.nickname == "会会用户"
assert user.avatar_url == "https://cdn.example/avatar.jpg"
client.exchange_access_token.assert_awaited_once_with("production-huihui-token")
client.get_self.assert_awaited_once_with("boxim-token")
@pytest.mark.asyncio
async def test_token_login_rejects_expired_huihui_token_without_creating_user(db):
client = AsyncMock()
client.exchange_access_token.side_effect = BoxIMError(
"expired", auth_error=True
)
with patch("routers.huihui_auth._cfg_ready", return_value=True), patch(
"routers.huihui_auth._create_boxim_client", return_value=client
):
response = await token_login({"token": "expired-token"}, db)
assert response["code"] == 401
assert response["message"] == "会会登录凭证无效或已过期"
assert db.query(User).count() == 0
@@ -25,7 +25,8 @@ def test_scheduler_uses_boxim_and_restart_safe_service(
boxim = MagicMock()
mock_boxim_class.return_value = boxim
takeover = MagicMock()
takeover.poll_and_process_messages = AsyncMock()
takeover.poll_messages = AsyncMock()
takeover.process_reply_tasks = AsyncMock()
mock_takeover_class.return_value = takeover
environment = {
@@ -46,14 +47,18 @@ def test_scheduler_uses_boxim_and_restart_safe_service(
assert config["BOXIM_API_BASE_URL"] == "https://im.example/api"
mock_takeover_class.assert_called_once_with(main.SessionLocal, boxim)
scheduler.add_job.assert_called_once()
scheduled_callable = scheduler.add_job.call_args.args[0]
job_options = scheduler.add_job.call_args.kwargs
assert scheduled_callable is takeover.poll_and_process_messages
assert job_options["id"] == "takeover_message_poll"
assert job_options["trigger"].interval.total_seconds() == 1
assert job_options["max_instances"] == 1
assert job_options["coalesce"] is True
assert scheduler.add_job.call_count == 2
poll_call, process_call = scheduler.add_job.call_args_list
assert poll_call.args[0] is takeover.poll_messages
assert poll_call.kwargs["id"] == "takeover_message_poll"
assert poll_call.kwargs["trigger"].interval.total_seconds() == 1
assert poll_call.kwargs["max_instances"] == 1
assert poll_call.kwargs["coalesce"] is True
assert process_call.args[0] is takeover.process_reply_tasks
assert process_call.kwargs["id"] == "takeover_reply_process"
assert process_call.kwargs["trigger"].interval.total_seconds() == 0.5
assert process_call.kwargs["max_instances"] == 1
assert process_call.kwargs["coalesce"] is True
scheduler.start.assert_called_once_with()
main.takeover_scheduler = None
@@ -1,12 +1,12 @@
"""End-to-end service tests for BOXIM takeover timing and human priority."""
from datetime import datetime, timedelta, timezone
from threading import Barrier
from unittest.mock import AsyncMock, patch
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from database import Base
from models import Avatar, TakeoverCursor, TakeoverMessage, TakeoverReplyTask, User
@@ -58,11 +58,10 @@ class FakeBoxIM:
@pytest.fixture
def service_context():
def service_context(tmp_path):
engine = create_engine(
"sqlite://",
f"sqlite:///{tmp_path / 'takeover.db'}",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
session_factory = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
Base.metadata.create_all(engine)
@@ -143,6 +142,39 @@ async def test_incoming_message_is_prepared_then_sent_at_three_seconds(service_c
db.close()
@pytest.mark.asyncio
async def test_different_contacts_generate_without_blocking_each_other(service_context):
session_factory, service, boxim, clock = service_context
await service.poll_and_process_messages()
boxim.messages.extend(
[
{"id": 13, "localId": 31, "sendId": 200, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "联系人甲"},
{"id": 14, "localId": 32, "sendId": 300, "recvId": 100, "sendTime": clock.millis(), "type": 0, "content": "联系人乙"},
]
)
both_generating = Barrier(2, timeout=2)
def resolve(_db, _avatar, prompt, _history, **_kwargs):
both_generating.wait()
return {"answer": f"回复{prompt[-1]}"}
with patch("routers.chat._resolve_reply", side_effect=resolve):
await service.poll_and_process_messages()
clock.advance(3)
await service.process_reply_tasks()
assert {(item["peerId"], item["content"]) for item in boxim.sent} == {
("200", "回复甲"),
("300", "回复乙"),
}
db = session_factory()
try:
assert {task.status for task in db.query(TakeoverReplyTask).all()} == {"sent"}
finally:
db.close()
@pytest.mark.asyncio
async def test_read_receipt_failure_does_not_advance_cursor(service_context):
session_factory, service, boxim, clock = service_context
@@ -0,0 +1,252 @@
import uuid
from concurrent.futures import ThreadPoolExecutor
from threading import Barrier
from unittest.mock import patch
import pytest
from fastapi.testclient import TestClient
from database import SessionLocal
from main import app, seed
from models import Avatar, TokenAccount, TokenPlan, TokenUsage, User
from routers.chat import _resolve_reply, _stream_reply
from services.token_billing import (
DEFAULT_TOKEN_GRANT,
InsufficientTokensError,
get_or_create_account,
release_reservation,
reserve_avatar_tokens,
settle_reservation,
)
client = TestClient(app)
def test_balance_is_user_scoped_and_defaults_to_one_million(authorization_context):
context = authorization_context
owner = client.get("/api/token/balance", headers=context["owner_headers"])
other = client.get("/api/token/balance", headers=context["other_headers"])
assert owner.status_code == 200
assert owner.json()["data"] == {
"balance": DEFAULT_TOKEN_GRANT,
"totalGranted": DEFAULT_TOKEN_GRANT,
"totalConsumed": 0,
}
assert other.json()["data"]["balance"] == DEFAULT_TOKEN_GRANT
assert client.get("/api/token/balance").status_code == 401
def test_seed_synchronizes_requested_recharge_plans():
seed()
db = SessionLocal()
try:
plans = db.query(TokenPlan).order_by(TokenPlan.price.asc()).all()
assert [(plan.price, plan.amount) for plan in plans] == [
(10, 2_000_000),
(100, 20_000_000),
(1000, 250_000_000),
(10000, 2_500_000_000),
]
finally:
db.close()
def test_multiple_avatars_share_owner_balance_and_usage_is_itemized():
suffix = uuid.uuid4().hex
db = SessionLocal()
user = User(id=f"token-user-{suffix}", huihui_user_id=f"token-huihui-{suffix}")
first = Avatar(id=f"token-avatar-a-{suffix}", owner_id=user.huihui_user_id, name="甲")
second = Avatar(id=f"token-avatar-b-{suffix}", owner_id=user.huihui_user_id, name="乙")
db.add_all([user, first, second])
db.commit()
try:
first_reservation = reserve_avatar_tokens(db, first, "chat", "qwen-test", [{"content": "问题一"}], 128)
settle_reservation(
db,
first_reservation,
{"prompt_tokens": 60, "completion_tokens": 40, "total_tokens": 100},
fallback_total=999,
)
second_reservation = reserve_avatar_tokens(db, second, "takeover", "qwen-test", [{"content": "问题二"}], 128)
settle_reservation(
db,
second_reservation,
{"prompt_tokens": 120, "completion_tokens": 80, "total_tokens": 200},
fallback_total=999,
)
account = get_or_create_account(db, user.id)
assert account.balance == DEFAULT_TOKEN_GRANT - 300
assert account.total_consumed == 300
usages = db.query(TokenUsage).filter(TokenUsage.user_id == user.id).order_by(TokenUsage.total_tokens).all()
assert [(row.avatar_id, row.source, row.total_tokens) for row in usages] == [
(first.id, "chat", 100),
(second.id, "takeover", 200),
]
finally:
db.query(TokenUsage).filter(TokenUsage.user_id == user.id).delete(synchronize_session=False)
db.query(TokenAccount).filter(TokenAccount.user_id == user.id).delete(synchronize_session=False)
db.query(Avatar).filter(Avatar.id.in_([first.id, second.id])).delete(synchronize_session=False)
db.query(User).filter(User.id == user.id).delete(synchronize_session=False)
db.commit()
db.close()
def test_concurrent_settlements_do_not_overwrite_each_other():
suffix = uuid.uuid4().hex
db = SessionLocal()
user = User(id=f"concurrent-user-{suffix}", huihui_user_id=f"concurrent-huihui-{suffix}")
avatar = Avatar(id=f"concurrent-avatar-{suffix}", owner_id=user.huihui_user_id, name="并发测试")
db.add_all([user, avatar])
db.commit()
first = reserve_avatar_tokens(db, avatar, "takeover", "qwen-test", [{"content": "甲"}], 128)
second = reserve_avatar_tokens(db, avatar, "takeover", "qwen-test", [{"content": "乙"}], 128)
db.close()
barrier = Barrier(2, timeout=3)
def settle(reservation, total):
thread_db = SessionLocal()
try:
barrier.wait()
settle_reservation(
thread_db,
reservation,
{"prompt_tokens": total - 20, "completion_tokens": 20, "total_tokens": total},
fallback_total=999,
)
finally:
thread_db.close()
with ThreadPoolExecutor(max_workers=2) as pool:
list(pool.map(lambda args: settle(*args), [(first, 100), (second, 200)]))
db = SessionLocal()
try:
account = get_or_create_account(db, user.id)
assert account.balance == DEFAULT_TOKEN_GRANT - 300
assert account.total_consumed == 300
finally:
db.query(TokenUsage).filter(TokenUsage.user_id == user.id).delete(synchronize_session=False)
db.query(TokenAccount).filter(TokenAccount.user_id == user.id).delete(synchronize_session=False)
db.query(Avatar).filter(Avatar.id == avatar.id).delete(synchronize_session=False)
db.query(User).filter(User.id == user.id).delete(synchronize_session=False)
db.commit()
db.close()
def test_failed_model_request_returns_the_full_reservation():
suffix = uuid.uuid4().hex
db = SessionLocal()
user = User(id=f"refund-user-{suffix}", huihui_user_id=f"refund-huihui-{suffix}")
avatar = Avatar(id=f"refund-avatar-{suffix}", owner_id=user.huihui_user_id, name="退款测试")
db.add_all([user, avatar])
db.commit()
try:
reservation = reserve_avatar_tokens(db, avatar, "chat", "qwen-test", [{"content": "问题"}], 128)
release_reservation(db, reservation, "provider error")
account = get_or_create_account(db, user.id)
usage = db.query(TokenUsage).filter(TokenUsage.id == reservation.usage_id).one()
assert account.balance == DEFAULT_TOKEN_GRANT
assert account.total_consumed == 0
assert usage.status == "failed"
finally:
db.query(TokenUsage).filter(TokenUsage.user_id == user.id).delete(synchronize_session=False)
db.query(TokenAccount).filter(TokenAccount.user_id == user.id).delete(synchronize_session=False)
db.query(Avatar).filter(Avatar.id == avatar.id).delete(synchronize_session=False)
db.query(User).filter(User.id == user.id).delete(synchronize_session=False)
db.commit()
db.close()
def test_insufficient_balance_rejects_before_model_usage_is_created():
suffix = uuid.uuid4().hex
db = SessionLocal()
user = User(id=f"empty-user-{suffix}", huihui_user_id=f"empty-huihui-{suffix}")
avatar = Avatar(id=f"empty-avatar-{suffix}", owner_id=user.huihui_user_id, name="余额不足")
db.add_all([user, avatar])
db.commit()
try:
account = get_or_create_account(db, user.id)
account.balance = 1
db.commit()
with pytest.raises(InsufficientTokensError):
reserve_avatar_tokens(db, avatar, "chat", "qwen-test", [{"content": "问题"}], 128)
db.refresh(account)
assert account.balance == 1
assert db.query(TokenUsage).filter(TokenUsage.user_id == user.id).count() == 0
finally:
db.query(TokenUsage).filter(TokenUsage.user_id == user.id).delete(synchronize_session=False)
db.query(TokenAccount).filter(TokenAccount.user_id == user.id).delete(synchronize_session=False)
db.query(Avatar).filter(Avatar.id == avatar.id).delete(synchronize_session=False)
db.query(User).filter(User.id == user.id).delete(synchronize_session=False)
db.commit()
db.close()
def test_chat_settles_from_provider_usage_not_fallback_estimate():
suffix = uuid.uuid4().hex
db = SessionLocal()
user = User(id=f"chat-user-{suffix}", huihui_user_id=f"chat-huihui-{suffix}")
avatar = Avatar(id=f"chat-avatar-{suffix}", owner_id=user.huihui_user_id, name="聊天测试", config={})
db.add_all([user, avatar])
db.commit()
try:
with patch(
"routers.chat._call_qwen",
return_value={
"answer": "测试回答",
"usage": {"prompt_tokens": 80, "completion_tokens": 20, "total_tokens": 100},
},
):
result = _resolve_reply(
db,
avatar,
"测试问题",
[],
qa_pairs=[],
search_fn=lambda *_args: [],
)
assert result["tokenUsage"]["totalTokens"] == 100
assert result["tokenUsage"]["balance"] == DEFAULT_TOKEN_GRANT - 100
finally:
db.query(TokenUsage).filter(TokenUsage.user_id == user.id).delete(synchronize_session=False)
db.query(TokenAccount).filter(TokenAccount.user_id == user.id).delete(synchronize_session=False)
db.query(Avatar).filter(Avatar.id == avatar.id).delete(synchronize_session=False)
db.query(User).filter(User.id == user.id).delete(synchronize_session=False)
db.commit()
db.close()
@pytest.mark.asyncio
async def test_streaming_chat_settles_final_provider_usage():
suffix = uuid.uuid4().hex
db = SessionLocal()
user = User(id=f"stream-user-{suffix}", huihui_user_id=f"stream-huihui-{suffix}")
avatar = Avatar(id=f"stream-avatar-{suffix}", owner_id=user.huihui_user_id, name="流式测试", config={})
db.add_all([user, avatar])
db.commit()
try:
chunks = iter([
{"content": "流式"},
{"content": "回答"},
{"usage": {"prompt_tokens": 90, "completion_tokens": 10, "total_tokens": 100}},
])
with patch("routers.chat._iter_qwen_stream", return_value=chunks):
response = _stream_reply(db, avatar, "测试问题", [])
body = []
async for chunk in response.body_iterator:
body.append(chunk.decode() if isinstance(chunk, bytes) else chunk)
assert "流式" in "".join(body)
account = get_or_create_account(db, user.id)
usage = db.query(TokenUsage).filter(TokenUsage.user_id == user.id).one()
assert account.balance == DEFAULT_TOKEN_GRANT - 100
assert usage.source == "chat_stream"
assert usage.total_tokens == 100
finally:
db.query(TokenUsage).filter(TokenUsage.user_id == user.id).delete(synchronize_session=False)
db.query(TokenAccount).filter(TokenAccount.user_id == user.id).delete(synchronize_session=False)
db.query(Avatar).filter(Avatar.id == avatar.id).delete(synchronize_session=False)
db.query(User).filter(User.id == user.id).delete(synchronize_session=False)
db.commit()
db.close()
+3
View File
@@ -10,6 +10,9 @@ services:
environment:
DATABASE_URL: sqlite:////data/avatar.db
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:
- avatar-data:/data
expose:
@@ -0,0 +1,112 @@
# 数字分身 H5 生产接入与部署
## 1. 接入方式
生产会会在用户已登录后打开以下地址:
```text
https://<数字分身生产域名>/#/avatar/manage?token=<encodeURIComponent(会会 access token)>
```
测试环境示例:
```text
http://192.168.1.188:8099/#/avatar/manage?token=<encodeURIComponent(token)>
```
兼容参数位于域名查询串的形式,但生产统一使用上面的 hash 路由形式。必须对 token 调用 `encodeURIComponent`,不能拼接用户 ID 代替 token。
免登录流程如下:
1. H5 在页面渲染前读取 `token`,立即通过 `history.replaceState` 从地址栏和浏览器历史中移除。
2. H5 调用 `POST /api/huihui/token/login`,不会把会会 token 当作数字分身接口 token 直接使用。
3. 后端通过会会生产接口 `/im/box/netease` 换取 BOXIM 凭证,再调用 BOXIM `/user/self` 校验用户身份。
4. 后端以返回的 `huihuiUserId` 绑定本地用户,保存会会凭证供 BOXIM 接管功能使用,并签发本系统 `app_token`。
5. 浏览器只保存 `app_token` 和非敏感用户资料。会会原始 token 不返回浏览器存储。
6. token 无效、过期或上游校验失败时清除旧会话并进入登录页,不会沿用上一位用户的缓存身份。
## 2. 生产配置
后端 `.env` 至少配置以下内容,密钥由部署平台注入,禁止提交 Git:
```dotenv
HUIHUI_DEV_MOCK=false
HUIHUI_AUTH_BASE_URL=https://99hui.com/api/usercenter
HUIHUI_PLATFORM_BASE_URL=https://open.99hui.com/api
BOXIM_API_BASE_URL=https://im.99hui.com/api
HUIHUI_APP_ID=<production-app-id>
HUIHUI_ACCESS_ID=<production-access-id>
HUIHUI_ACCESS_SECRET=<production-access-secret>
HUIHUI_CLIENT_CODE=<production-client-code>
BOXIM_TIMEOUT_SECONDS=20
DATABASE_URL=sqlite:////data/avatar.db
UPLOAD_DIR=/data/uploads
CHAT_MODEL_CONFIG_URL=http://<huihuisquare-api>/api/ai-models/runtime/digital-avatar
```
如生产 AI 配置中心不可用,还应提供当前项目支持的 `OPENAI_API_KEY`、`OPENAI_BASE_URL`、`CHAT_MODEL` 等兜底配置。`/data` 必须挂载持久卷,数据库与知识库文件不可存放在容器临时层。
## 3. 构建与发布
首次发布前备份数据:
```bash
BACKUP_DIR="backups/$(date +%Y%m%d-%H%M%S)"
mkdir -p "$BACKUP_DIR"
cp /srv/digital-avatar/data/avatar.db "$BACKUP_DIR/"
tar -C /srv/digital-avatar/data -czf "$BACKUP_DIR/uploads.tgz" uploads
```
在发布目录执行:
```bash
git fetch origin
git checkout <已验收的提交SHA>
cd digital-avatar-app
docker compose build --pull avatar-backend avatar-frontend
docker compose up -d avatar-backend avatar-frontend
docker compose ps
curl -fsS http://127.0.0.1:8099/api/health
```
生产编排应把示例中的测试端口改为内网暴露,由统一 HTTPS 网关接入。后端暂时使用 SQLite,必须保持单实例写入;若扩展为多后端实例,应先迁移到 PostgreSQL,并把延迟接管任务改为共享队列。
## 4. 网关要求
必须使用 HTTPS。同域部署时,H5 静态资源与 `/api/` 由同一域名提供,可避免跨域和 Cookie/来源策略问题。Nginx 关键配置示例:
```nginx
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://avatar-backend:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_read_timeout 300s;
client_max_body_size 20m;
}
```
`proxy_buffering off` 用于数字分身 SSE 流式吐字,`client_max_body_size` 用于知识库文件上传。网关和应用日志必须关闭完整 URL 查询参数记录,任何异常日志都不得输出 token、Authorization 或平台密钥。建议同时设置严格的 `Referrer-Policy: no-referrer`。
## 5. 发布验收
1. 已登录会会用户通过带 token 链接打开后直接进入 `/avatar/manage`,不出现登录页或创建新账号页。
2. 页面加载后地址栏中不再包含 `token`,刷新页面仍使用本地 `app_token` 正常访问。
3. 后端用户绑定的是 BOXIM 返回的 `huihuiUserId`,不是 BOXIM 内部 `id`;原有数字分身、独立知识库和 Token 余额均存在。
4. A、B 两个会会用户分别进入时只能看到各自的数字分身与知识库,不会继承上一用户缓存。
5. 使用过期或伪造 token 时进入登录页并显示凭证失效,不得继续访问旧用户数据。
6. 分身聊天 SSE 逐段输出正常,Markdown 正常渲染,知识库优先级和 Token 扣费正常。
7. 开启 BOXIM 主动接管后保持在线,收到消息、三秒回复、已读回执和主人发言暂停均正常。
8. 重建容器后数据库、头像、知识库文档仍存在,`/api/health` 返回成功。
## 6. 回滚
保留上一版前后端镜像标签和发布前数据库/上传文件备份。代码回滚优先切回上一镜像;只有新版本执行了不可逆数据变更时才恢复数据库。恢复前先停止后端写入,恢复后对比用户数、分身数、知识库文档数并完成一次免登录和聊天验收。
+1 -2
View File
@@ -7,8 +7,7 @@
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
/>
<title>会会数字分身</title>
<!-- uniapp web-view 桥接:加载后全局出现 window.uni.webView,H5 才能与原生壳通信 -->
<script type="text/javascript" src="https://unpkg.com/@dcloudio/uni-webview-js@0.0.10/index.js"></script>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<!-- 混合架构部署配置:web-view 内请把 apiBase 设为后端公网地址(如 'https://geo.99hui.com/api')。
留空则回退为 '/api'(开发态由 Vite 代理到 :8000)。 -->
<script type="text/javascript">
+7
View File
@@ -8,6 +8,7 @@
"name": "digital-avatar-app",
"version": "1.0.0",
"dependencies": {
"@dcloudio/uni-webview-js": "^0.0.3",
"axios": "^1.6.0",
"pinia": "^2.1.0",
"vue": "^3.3.0",
@@ -66,6 +67,12 @@
"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": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
+1
View File
@@ -9,6 +9,7 @@
"preview": "vite preview"
},
"dependencies": {
"@dcloudio/uni-webview-js": "^0.0.3",
"axios": "^1.6.0",
"pinia": "^2.1.0",
"vue": "^3.3.0",
+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>
<div id="app">
<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>
</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>
* {
margin: 0;
@@ -81,48 +18,4 @@ onMounted(() => {
background: #F8F9FA;
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>
+25 -2
View File
@@ -131,9 +131,24 @@ export const deleteAvatar = (id: string) =>
// ==================== 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 余额
export const getTokenBalance = () =>
request.get<{ balance: number }>('/token/balance')
request.get<TokenBalance>('/token/balance')
// 获取充值套餐
export const getRechargePlans = () =>
@@ -143,6 +158,10 @@ export const getRechargePlans = () =>
export const chargeToken = (planId: string) =>
request.post<{ balance: number; charged: number }>('/token/charge', { planId })
// 按分身和使用场景汇总 Token 消耗
export const getTokenUsage = () =>
request.get<TokenUsageSummary[]>('/token/usage')
// ==================== 授权管理 API ====================
export type AvatarPermission = 'friend' | 'chat' | 'publish' | 'browse' | 'interact' | 'takeover'
@@ -409,7 +428,7 @@ export const getUserProfile = () =>
export interface SmsLoginResult {
token: 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 标记)
@@ -424,6 +443,10 @@ export const loginBySms = (phone: string, code: string) =>
export const loginByPassword = (account: string, password: string) =>
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 = () =>
request.get<UserProfile & { huihuiUserId: string; phone: string }>('/huihui/me')
+46 -20
View File
@@ -1,14 +1,16 @@
import { createApp } from 'vue'
import uniWebView from '@dcloudio/uni-webview-js'
import App from './App.vue'
import router from './router'
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 { 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)
app.use(router)
app.use(pinia)
// —— 混合架构:在挂载前注入 uniapp 壳传入的认证与会会资料 ——
@@ -16,10 +18,8 @@ const params = getLaunchParams()
const avatarStore = useAvatarStore(pinia)
const userStore = useUserStore(pinia)
// 恢复本地短信登录会话(会会 userId ↔ 本系统用户)
userStore.loadFromStorage()
if (userStore.isLogin && userStore.user) {
setAuthToken(userStore.token)
function syncAvatarProfile() {
if (!userStore.isLogin || !userStore.user) return
avatarStore.setNativeProfile({
userId: (userStore.user as any).huihuiUserId || '',
nickname: userStore.user.nickname || '',
@@ -27,29 +27,55 @@ if (userStore.isLogin && userStore.user) {
})
}
if (params.token) {
setAuthToken(params.token)
}
if (params.userId || params.nickname || params.avatar) {
avatarStore.setNativeProfile({
userId: params.userId || '',
nickname: params.nickname || '',
avatarUrl: params.avatar || ''
})
async function exchangeInjectedToken(token: string) {
stripLaunchToken()
userStore.clearSession()
await userStore.loginByToken(token)
syncAvatarProfile()
}
// 原生 → H5:注册消息处理(壳通过 web-view.evalJS 调用)
onNativeMessage((msg) => {
if (!msg || !msg.type) return
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) {
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 已就绪
UniEvents.ready()
if (params.userId || params.nickname || params.avatar) {
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'),
meta: { title: '授权管理', requiresAuth: true }
},
{
path: '/avatar/:avatarId/authorization',
name: 'AvatarAuthorizationManage',
component: () => import('@/views/AuthorizationManage.vue'),
meta: { title: '授权管理', requiresAuth: true }
},
{
path: '/token/charge',
name: 'TokenCharge',
@@ -81,6 +87,12 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/KnowledgeManage.vue'),
meta: { title: '知识库管理', requiresAuth: true }
},
{
path: '/avatar/:avatarId/knowledge',
name: 'AvatarKnowledgeManage',
component: () => import('@/views/KnowledgeManage.vue'),
meta: { title: '知识库管理', requiresAuth: true }
},
{
path: '/knowledge/qa/create',
name: 'QaPairCreate',
@@ -93,6 +105,18 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/QaPairEdit.vue'),
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',
name: 'SmsLogin',
+20 -2
View File
@@ -1,13 +1,15 @@
import { defineStore } from 'pinia'
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'
export const useAvatarStore = defineStore('avatar', () => {
// 已创建的分身列表(来自后端)
const avatars = ref<any[]>([])
// 全局 Token 余额(来自后端)
// 当前用户所有分身共享的 Token 账户
const tokenBalance = ref<number>(0)
const tokenConsumed = ref<number>(0)
const tokenUsageByAvatar = ref<Record<string, number>>({})
// 当前选中分身 id
const currentAvatarId = ref<string | null>(null)
// 会会用户资料(头像/昵称,来自会会接口)
@@ -29,11 +31,24 @@ export const useAvatarStore = defineStore('avatar', () => {
try {
const res = await getTokenBalance()
tokenBalance.value = (res as any)?.balance ?? 0
tokenConsumed.value = (res as any)?.totalConsumed ?? 0
} catch (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 () => {
// 若已通过 uniapp 壳注入(混合架构),优先保留,不回退到后端 mock
@@ -82,10 +97,13 @@ export const useAvatarStore = defineStore('avatar', () => {
return {
avatars,
tokenBalance,
tokenConsumed,
tokenUsageByAvatar,
currentAvatarId,
userProfile,
loadAvatars,
loadTokenBalance,
loadTokenUsage,
loadUserProfile,
setNativeProfile,
addAvatar,
+45 -21
View File
@@ -1,6 +1,15 @@
import { defineStore } from 'pinia'
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 USER_KEY = 'hh_app_user'
@@ -37,6 +46,23 @@ export const useUserStore = defineStore('smsuser', () => {
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)
const sendCode = async (phone: string) => {
return await sendSmsCode(phone)
@@ -44,26 +70,17 @@ export const useUserStore = defineStore('smsuser', () => {
// 短信登录
const login = async (phone: string, code: string) => {
const res: any = await loginBySms(phone, code)
token.value = res.token
user.value = { ...(res.user || {}), ...(res.huihui || {}) }
isLogin.value = true
setAuthToken(res.token)
persist()
return res
return acceptLogin(await loginBySms(phone, code))
}
// 账号密码登录
const loginByPwd = async (account: string, password: string) => {
const res: any = await loginByPassword(account, password)
token.value = res.token
user.value = { ...(res.user || {}), ...(res.huihui || {}) }
isLogin.value = true
setAuthToken(res.token)
persist()
return res
return acceptLogin(await loginByPassword(account, password))
}
const loginByToken = async (huihuiToken: string) =>
acceptLogin(await loginByHuihuiToken(huihuiToken))
// 退出
const logout = async () => {
try {
@@ -71,12 +88,19 @@ export const useUserStore = defineStore('smsuser', () => {
} catch {
/* 忽略网络错误,本地清除即可 */
}
token.value = ''
user.value = null
isLogin.value = false
setAuthToken(null)
clearLocal()
clearSession()
}
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,
avatars?: AvatarPageRecord[]
): 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 buildAvatarUpdatePayload(form: AvatarEditForm): AvatarUpdatePayload
@@ -8,6 +8,11 @@ export function pickAvatarId(currentAvatarId, avatars) {
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 = {}) {
const config = avatar.config || {}
return {
+34 -14
View File
@@ -1,9 +1,7 @@
// 会会数字分身 H5 ↔ uniapp 原生壳 桥接工具
// 协议详见 uniapp-avatar/README.md
//
// 引入方式:在 index.html 中加载 uniapp web-view bridge:
// <script src="https://unpkg.com/@dcloudio/uni-webview-js@0.0.10/index.js"></script>
// 引入后全局会出现 window.uni.webView,H5 即可用 postMessage 与原生通信。
// uni-webview bridge is bundled by main.ts; no external CDN is required.
const BRIDGE_HANDLER = '__uniBridgeHandle__'
@@ -15,6 +13,16 @@ export interface UniLaunchParams {
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 环境中
export function isInUniWebView(): boolean {
return !!(window as any).uni?.webView
@@ -22,21 +30,33 @@ export function isInUniWebView(): boolean {
// 解析 web-view 加载 URL 时原生注入的参数(token / 会会用户)
export function getLaunchParams(): UniLaunchParams {
const sp = new URLSearchParams(window.location.search)
const params: UniLaunchParams = {}
const token = sp.get('token')
const userId = sp.get('userId')
const nickname = sp.get('nickname')
const avatar = sp.get('avatar')
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
readParams(window.location.search, params)
const hashQueryIndex = window.location.hash.indexOf('?')
if (hashQueryIndex >= 0) {
readParams(window.location.hash.slice(hashQueryIndex + 1), 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)
export function postToNative(message: Record<string, any>): boolean {
if (!isInUniWebView()) return false
@@ -109,7 +109,7 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import {
getAvatarPermissionSettings,
getTakeoverStatus,
@@ -118,12 +118,14 @@ import {
type TakeoverStatus,
} from '@/api'
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>
const router = useRouter()
const route = useRoute()
const avatarStore = useAvatarStore()
const avatarId = computed(() => pickScopedAvatarId(route.params.avatarId, avatarStore.currentAvatarId, avatarStore.avatars))
const permissionItems: Array<{
key: AvatarPermission
@@ -307,7 +309,8 @@ const goCreate = () => router.push('/avatar/create')
onMounted(async () => {
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()
})
+74 -396
View File
@@ -30,6 +30,7 @@
<div class="token-info">
<span class="token-label">Token 余额</span>
<span class="token-amount">{{ tokenBalance.toLocaleString() }}</span>
<span class="token-used">累计使用 {{ tokenConsumed.toLocaleString() }}</span>
</div>
<button class="recharge-btn" @click="goToRecharge">充值</button>
</div>
@@ -44,6 +45,9 @@
<div v-if="avatars.length" class="avatar-list">
<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-photo">
<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-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>
<span class="avatar-token-usage">累计使用 {{ avatarTokenUsage(a.id).toLocaleString() }} Token</span>
</div>
</div>
<div class="avatar-actions">
<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="edit-link" @click="goToEdit(a.id)">编辑</button>
<button class="del-link" @click="askDelete(a)" aria-label="删除分身">删除</button>
</div>
</div>
</div>
@@ -72,88 +77,7 @@
<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>
</template>
@@ -173,20 +97,12 @@ const me = computed(() => userStore.user)
// 状态(来自 store / 后端)
const tokenBalance = computed(() => avatarStore.tokenBalance)
const tokenConsumed = computed(() => avatarStore.tokenConsumed)
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 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 map: Record<string, string> = {
@@ -197,55 +113,6 @@ const statusText = (status: string) => {
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 = () => {
router.back()
@@ -259,8 +126,12 @@ const goCreate = () => {
router.push('/avatar/create')
}
const goToKnowledge = () => {
router.push('/knowledge')
const goToKnowledge = (id: string) => {
router.push({ name: 'AvatarKnowledgeManage', params: { avatarId: id } })
}
const goToAuthorization = (id: string) => {
router.push({ name: 'AvatarAuthorizationManage', params: { avatarId: id } })
}
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(() => {
userStore.loadFromStorage()
avatarStore.loadAvatars()
avatarStore.loadTokenBalance()
avatarStore.loadTokenUsage()
})
</script>
@@ -330,7 +186,7 @@ onMounted(() => {
.avatar-manage-page {
min-height: 100vh;
background: #F8F9FA;
padding-bottom: 80px;
padding-bottom: calc(28px + env(safe-area-inset-bottom));
}
/* 顶部导航 */
@@ -465,6 +321,12 @@ onMounted(() => {
color: #F97316;
}
.token-used {
margin-top: 3px;
color: #A0A5B4;
font-size: 11px;
}
.recharge-btn {
padding: 8px 16px;
background: #F97316;
@@ -516,13 +378,32 @@ onMounted(() => {
}
.avatar-card {
position: relative;
padding: 16px;
background: white;
border: 1px solid #F4E5D9;
border-radius: 18px;
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 {
width: 66px;
@@ -570,6 +451,13 @@ onMounted(() => {
white-space: nowrap;
}
.avatar-token-usage {
display: inline-block;
margin-top: 5px;
color: #A0A5B4;
font-size: 10px;
}
.avatar-status {
display: inline-flex;
align-items: center;
@@ -598,56 +486,39 @@ onMounted(() => {
}
.avatar-actions {
display: flex;
align-items: center;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
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 {
flex: 1;
padding: 10px 8px;
background: linear-gradient(135deg, #F97316, #FB923C);
color: #fff;
border: none;
border-radius: 10px;
font-size: 13px;
cursor: pointer;
}
.chat-link span, .share-link span { margin-right: 3px; }
.share-link { flex: 1; padding: 10px 8px; border: 1px solid #FFD5AF; border-radius: 10px; color: #C15F18; background: #FFF8F1; font-size: 13px; cursor: pointer; }
.knowledge-link { border: 1px solid #F5D7B8; color: #A85418; background: #FFF8F1; }
.authorization-link { border: 1px solid #DCE5F2; color: #47627D; background: #F6F9FC; }
.share-link { border: 1px solid #E5E1DD; color: #675E58; background: #FAF8F6; }
.edit-link {
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;
}
.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; }
/* 空状态 */
.empty-state {
@@ -684,197 +555,4 @@ onMounted(() => {
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>
@@ -79,9 +79,9 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { useAvatarStore } from '@/store/avatar'
import { pickAvatarId, unwrapListData } from '@/utils/avatar-page-data.js'
import { pickScopedAvatarId, unwrapListData } from '@/utils/avatar-page-data.js'
import {
getKnowledgeDocs,
uploadKnowledgeDoc,
@@ -93,9 +93,10 @@ import {
} from '@/api'
const router = useRouter()
const route = useRoute()
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 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) => {
if (!avatarId.value) return
@@ -226,6 +239,7 @@ onMounted(async () => {
if (!store.avatars.length) {
await store.loadAvatars()
}
if (avatarId.value) store.currentAvatarId = avatarId.value
await Promise.all([loadDocs(), loadQA()])
})
</script>
@@ -234,7 +248,7 @@ onMounted(async () => {
.knowledge-page {
min-height: 100vh;
background: #F8F9FA;
padding-bottom: 80px;
padding-bottom: calc(28px + env(safe-area-inset-bottom));
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-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; }
.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-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; }
@@ -493,6 +512,8 @@ onMounted(async () => {
.knowledge-card { display: grid; grid-template-columns: 42px minmax(0, 1fr); align-items: start; gap: 10px; padding: 13px; }
.card-content { grid-column: 2; }
.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; }
.status-pill { order: 2; }
.search-bar { gap: 8px; }
+8 -4
View File
@@ -49,14 +49,14 @@
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
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'
const router = useRouter()
const route = useRoute()
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 isEdit = computed(() => !!qaId.value)
@@ -104,8 +104,11 @@ const save = async () => {
} else {
await createQAPair(avatarId.value, payload)
}
// 保存成功返回知识库管理页
router.replace('/knowledge')
if (route.params.avatarId) {
router.replace({ name: 'AvatarKnowledgeManage', params: { avatarId: avatarId.value } })
} else {
router.replace('/knowledge')
}
} catch (e: any) {
error.value = e?.message || '保存失败'
} finally {
@@ -117,6 +120,7 @@ onMounted(async () => {
if (!store.avatars.length) {
await store.loadAvatars()
}
if (avatarId.value) store.currentAvatarId = avatarId.value
if (isEdit.value) {
await loadForEdit()
}
+9 -1
View File
@@ -138,7 +138,7 @@
</template>
<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 { useUserStore } from '@/store/user'
import { useAvatarStore } from '@/store/avatar'
@@ -161,6 +161,14 @@ const password = ref('')
const loading = ref(false)
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 countdown = ref(60)
let timer: any = null
+16 -2
View File
@@ -13,6 +13,7 @@
<span class="balance-label">当前余额</span>
<span class="balance-amount">{{ currentBalance.toLocaleString() }}</span>
<span class="balance-unit">Token</span>
<span class="balance-used">累计使用 {{ totalConsumed.toLocaleString() }} Token</span>
</div>
</section>
@@ -28,7 +29,7 @@
@click="selectedPlan = plan"
>
<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-price">¥{{ plan.price }}</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 currentBalance = ref<number>(1250)
const currentBalance = ref<number>(0)
const totalConsumed = ref<number>(0)
// 充值套餐
const plans = ref<Array<{
@@ -105,6 +107,7 @@ const loadData = async () => {
try {
const b: any = await getTokenBalance()
currentBalance.value = b?.balance ?? 0
totalConsumed.value = b?.totalConsumed ?? 0
} catch (e) {
console.error('加载余额失败', e)
}
@@ -118,6 +121,10 @@ const loadData = async () => {
// 执行充值(写入后端)
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 () => {
if (!selectedPlan.value || charging.value) return
charging.value = true
@@ -187,6 +194,7 @@ onMounted(() => {
.balance-card {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 8px;
padding: 20px;
@@ -211,6 +219,12 @@ onMounted(() => {
opacity: 0.9;
}
.balance-used {
flex-basis: 100%;
font-size: 12px;
opacity: 0.82;
}
/* 充值套餐 */
.plans-section {
padding: 0 20px 20px;
+1
View File
@@ -17,6 +17,7 @@ services:
- REDIS_PORT=6379
- SECRET_KEY=your-secret-key-change-in-production
- AES_KEY=your-aes-key-32-chars-change-now!
- AVATAR_MODEL_CONFIG_TOKEN=${AVATAR_MODEL_CONFIG_TOKEN:-}
- TZ=Asia/Shanghai
- AVATAR_DB_PATH=/app/avatar.db
volumes:
+1
View File
@@ -94,6 +94,7 @@ CREATE TABLE IF NOT EXISTS `ai_model_configs` (
`id` bigint NOT NULL AUTO_INCREMENT,
`model_name` varchar(64) NOT NULL COMMENT '模型名称',
`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_key_enc` varchar(512) DEFAULT NULL COMMENT '加密API Key',
`model_version` varchar(64) DEFAULT NULL COMMENT '模型版本',
+2 -1
View File
@@ -6,7 +6,8 @@ RUN npm install
COPY . .
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 nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
+16 -4
View File
@@ -15,6 +15,9 @@
<span class="model-title">{{ m.model_name }}</span>
</div>
<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_enabled" type="danger" size="small">禁用</el-tag>
</div>
@@ -49,6 +52,13 @@
<el-option v-for="(l,v) in providerLabels" :key="v" :label="l" :value="v" />
</el-select>
</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-input v-model="form.api_base_url" placeholder="留空使用默认地址" />
</el-form-item>
@@ -131,8 +141,9 @@ const testResult = ref(null)
const testing = ref(false)
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 rules = { model_name: [{ required: true, message: '请输入模型名称' }], provider: [{ required: true }] }
const scopeLabels = { general: '通用业务', digital_avatar: '数字分身专用' }
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() {
const res = await getAIModels()
@@ -155,13 +166,13 @@ function onProviderChange(provider) {
function openCreate() {
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
}
function openEdit(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
}
@@ -236,4 +247,5 @@ onMounted(load)
.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; }
.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>