feat: add avatar chat and knowledge workflow

This commit is contained in:
stefanfeng
2026-07-23 17:21:49 +08:00
parent 501f548bcc
commit 2d9f26a6f0
15 changed files with 3635 additions and 0 deletions

View File

@@ -0,0 +1,136 @@
"""
向量化服务:对文档/查询文本生成向量。
优先级:
1. 若配置了环境变量 EMBEDDING_API_URL则调用第三方「OpenAI 兼容」的 /embeddings 接口
(需配置 EMBEDDING_API_KEY、EMBEDDING_MODEL默认 text-embedding-3-small
2. 否则使用本地「哈希 TF 嵌入」兜底,使向量检索在无外部依赖时也能端到端跑通,
且相似文本(共享词汇)会得到更高余弦相似度,便于演示召回效果。
"""
import os
import re
import math
import json
import hashlib
import urllib.request
EMBED_DIM = 256
MODEL = os.getenv("EMBEDDING_MODEL", "mock-hash-embed-v1")
def _tokenize(text):
text = (text or "").lower()
# 英文/数字按词CJK 逐字(中文无空格,需拆到字级才能命中子词)
tokens = re.findall(r"[a-z0-9]+", text)
tokens += re.findall(r"[一-鿿]", text)
return tokens
def _hash_embedding(texts, dim=EMBED_DIM):
vecs = []
for text in texts:
vec = [0.0] * dim
tokens = _tokenize(text)
if not tokens:
tokens = list(text or "")
for tok in tokens:
h = int(hashlib.md5(tok.encode("utf-8")).hexdigest(), 16)
vec[h % dim] += 1.0
norm = math.sqrt(sum(v * v for v in vec))
if norm > 0:
vec = [v / norm for v in vec]
vecs.append(vec)
return vecs
def embed(texts):
"""返回 list[list[float]],与输入顺序一致。"""
if not texts:
return []
api_url = os.getenv("EMBEDDING_API_URL")
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]
return _hash_embedding(texts)
def cosine(a, b):
dot = sum(x * y for x, y in zip(a, b))
na = math.sqrt(sum(x * x for x in a))
nb = math.sqrt(sum(y * y for y in b))
if na == 0 or nb == 0:
return 0.0
return dot / (na * nb)
def chunk_text(text, size=400, overlap=50):
text = (text or "").strip()
if not text:
return []
if len(text) <= size:
return [text]
chunks = []
start = 0
while start < len(text):
end = min(start + size, len(text))
chunks.append(text[start:end])
if end == len(text):
break
start = end - overlap
return chunks
def extract_text(path, ext):
"""抽取文档纯文本;未知格式拒绝,已知格式解析失败时保留占位文本。"""
if ext not in {".txt", ".md", ".docx", ".xlsx", ".pdf", ".doc"}:
raise ValueError(f"unsupported file extension: {ext}")
try:
if ext in {".txt", ".md"}:
with open(path, "r", encoding="utf-8", errors="replace") as f:
return f.read()
if ext == ".docx":
from docx import Document
doc = Document(path)
return "\n".join(p.text for p in doc.paragraphs)
if ext == ".xlsx":
import openpyxl
wb = openpyxl.load_workbook(path, data_only=True, read_only=True)
rows = []
for ws in wb.worksheets:
for row in ws.iter_rows(values_only=True):
cells = [str(c) for c in row if c is not None]
if cells:
rows.append(" ".join(cells))
return "\n".join(rows)
if ext == ".pdf":
try:
from pypdf import PdfReader
except ImportError:
from PyPDF2 import PdfReader
reader = PdfReader(path)
return "\n".join((p.extract_text() or "") for p in reader.pages)
if ext == ".doc":
with open(path, "rb") as f:
raw = f.read().decode("utf-8", errors="ignore")
return re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f]+", " ", raw)
except Exception as e: # 解析失败时回退
print("extract_text failed:", e)
return f"文档:{os.path.basename(path)} 类型 {ext}"

View File

@@ -0,0 +1,105 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
import os
from database import init_db, SessionLocal
from models import Avatar, Authorization, Organization, TokenAccount, TokenPlan
from fastapi.staticfiles import StaticFiles
import routers.avatars
import routers.tokens
import routers.authorizations
import routers.organizations
import routers.knowledge
import routers.huihui_auth
import routers.chat
from responses import ok
app = FastAPI(title="会会数字分身 API", version="1.0.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(routers.avatars.router, prefix="/api")
app.include_router(routers.tokens.router, prefix="/api")
app.include_router(routers.authorizations.router, prefix="/api")
app.include_router(routers.organizations.router, prefix="/api")
app.include_router(routers.knowledge.router, prefix="/api")
app.include_router(routers.huihui_auth.router, prefix="/api")
app.include_router(routers.chat.router, prefix="/api")
UPLOAD_DIR = routers.knowledge.UPLOAD_DIR
os.makedirs(UPLOAD_DIR, exist_ok=True)
app.mount("/api/files", StaticFiles(directory=UPLOAD_DIR), name="knowledge-files")
@app.get("/api/health")
def health():
return ok({"status": "ok"})
def seed():
db = SessionLocal()
try:
if db.query(TokenAccount).first() is None:
db.add(TokenAccount(balance=1250))
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)
if db.query(Avatar).count() == 0:
avatar = Avatar(
name="我的数字分身",
display_name="会会助手",
description="我是您的AI数字分身可以帮您管理日程、回复消息、处理任务。",
emoji="🤖",
status="active",
token_balance=0,
config={
"replyStyle": "professional",
"creativity": 50,
"rigor": 50,
"humor": 30,
"responseLength": "medium",
"systemPrompt": "",
},
)
db.add(avatar)
db.commit()
db.refresh(avatar)
if db.query(Authorization).count() == 0:
auths = [
Authorization(avatar_id=avatar.id, target_type="application", target_name="微信小程序", permissions=["read", "reply"], status="active"),
Authorization(avatar_id=avatar.id, target_type="user", target_name="张三", permissions=["read"], status="active"),
Authorization(avatar_id=avatar.id, target_type="organization", target_name="产品团队", permissions=["read", "edit"], status="inactive"),
]
db.add_all(auths)
if db.query(Organization).count() == 0:
orgs = [
Organization(name="会会增长团队", description="负责会会产品的增长与运营", emoji="🚀", org_type="team", member_count=12),
Organization(name="AI 实验室", description="探索前沿 AI 能力", emoji="💡", org_type="company", member_count=8),
]
db.add_all(orgs)
db.commit()
finally:
db.close()
@app.on_event("startup")
def on_startup():
init_db()
seed()

View File

@@ -0,0 +1,205 @@
import difflib
import os
import re
import string
from typing import Any, Callable
import httpx
from fastapi import APIRouter, Body, Depends, Header, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
import embeddings
from database import get_db
from models import Avatar, KnowledgeChunk, KnowledgeDoc, QAPair, User
from responses import ok, fail
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_SIMILARITY_THRESHOLD = 0.86
class ChatMessage(BaseModel):
role: str = Field(pattern="^(user|assistant)$")
content: str = Field(min_length=1, max_length=MAX_MESSAGE_LENGTH)
class ChatIn(BaseModel):
message: str = Field(min_length=1, max_length=MAX_MESSAGE_LENGTH)
history: list[ChatMessage] = Field(default_factory=list, max_length=MAX_HISTORY_MESSAGES)
def _resolve_user(authorization: str | None, db: Session):
if not authorization:
return None
token = authorization.replace("Bearer ", "", 1).replace("bearer ", "", 1).strip()
return db.query(User).filter(User.app_token == token).first()
def _require_owned_avatar(db: Session, avatar_id: str, authorization: str | None):
avatar = db.query(Avatar).filter(Avatar.id == avatar_id).first()
if not avatar:
raise HTTPException(status_code=404, detail="分身不存在")
user = _resolve_user(authorization, db)
if not user:
raise HTTPException(status_code=401, detail="未登录")
if avatar.owner_id and avatar.owner_id != user.huihui_user_id:
raise HTTPException(status_code=403, detail="无权访问该分身")
return avatar
def _normalize_question(value: str) -> str:
value = (value or "").strip().lower()
value = re.sub(r"\s+", "", value)
return value.translate(str.maketrans("", "", string.punctuation + ",。!?;:、()【】「」‘’“”《》"))
def _match_standard_qa(question: str, qa_pairs: list[Any]):
normalized = _normalize_question(question)
if not normalized:
return None
enabled = [qa for qa in qa_pairs if getattr(qa, "enabled", True)]
for qa in enabled:
if _normalize_question(getattr(qa, "question", "")) == normalized:
return qa
best = None
best_score = 0.0
for qa in enabled:
candidate = _normalize_question(getattr(qa, "question", ""))
if not candidate:
continue
score = difflib.SequenceMatcher(None, normalized, candidate).ratio()
if score > best_score:
best, best_score = qa, score
return best if best_score >= QA_SIMILARITY_THRESHOLD else None
def _config(avatar: Avatar) -> dict:
config = getattr(avatar, "config", None) or {}
return {
"replyStyle": config.get("replyStyle", "professional"),
"creativity": max(0, min(100, int(config.get("creativity", 50)))),
"rigor": max(0, min(100, int(config.get("rigor", 50)))),
"humor": max(0, min(100, int(config.get("humor", 30)))),
"responseLength": config.get("responseLength", "medium"),
"systemPrompt": (config.get("systemPrompt", "") or "").strip(),
}
def _build_prompt(avatar: Avatar, history: list[Any], question: str, knowledge_hits: list[dict]) -> list[dict]:
config = _config(avatar)
knowledge = "\n".join(
f"[{hit.get('filename', '知识库')}] {hit.get('snippet', '')}"
for hit in knowledge_hits
if hit.get("snippet")
)
system = (
"你是用户的专属数字分身。请基于已提供的知识库回答,不要编造事实;"
f"回复风格:{config['replyStyle']};严谨度:{config['rigor']}/100"
f"幽默感:{config['humor']}/100回复长度{config['responseLength']}"
)
if config["systemPrompt"]:
system += f"\n额外系统提示词:{config['systemPrompt']}"
if knowledge:
system += f"\n以下是可参考的知识库内容:\n{knowledge}"
messages = [{"role": "system", "content": system}]
for item in history[-MAX_HISTORY_MESSAGES:]:
messages.append({"role": item.role, "content": item.content} if hasattr(item, "role") else item)
messages.append({"role": "user", "content": question.strip()})
return messages
def _search_knowledge(db: Session, avatar_id: str, question: str, top_k: int = 5) -> list[dict]:
chunks = db.query(KnowledgeChunk).filter(KnowledgeChunk.avatar_id == avatar_id).all()
if not chunks:
return []
qvec = embeddings.embed([question])[0]
scored = []
for chunk in chunks:
try:
vector = __import__("json").loads(chunk.vector)
except Exception:
continue
scored.append((embeddings.cosine(qvec, vector), chunk))
scored.sort(key=lambda item: item[0], reverse=True)
results = []
for score, chunk in scored[: max(1, top_k)]:
doc = db.query(KnowledgeDoc).filter(KnowledgeDoc.id == chunk.doc_id).first()
results.append({
"docId": chunk.doc_id,
"filename": doc.filename if doc else "",
"fileType": doc.file_type if doc else "",
"snippet": chunk.content[:120] + ("" if len(chunk.content) > 120 else ""),
"score": round(score, 4),
})
return results
def _call_qwen(messages: list[dict], temperature: float) -> str:
if not CHAT_API_KEY:
raise RuntimeError("Qwen 模型服务未配置 CHAT_API_KEY")
url = f"{CHAT_API_URL.rstrip('/')}/chat/completions"
payload = {
"model": CHAT_MODEL,
"messages": messages,
"temperature": temperature,
}
try:
response = httpx.post(
url,
headers={"Authorization": f"Bearer {CHAT_API_KEY}"},
json=payload,
timeout=30,
)
response.raise_for_status()
data = response.json()
answer = data.get("choices", [{}])[0].get("message", {}).get("content", "")
except (httpx.HTTPError, ValueError, KeyError, IndexError) as exc:
raise RuntimeError("Qwen 模型服务暂时不可用") from exc
if not isinstance(answer, str) or not answer.strip():
raise RuntimeError("Qwen 模型没有返回有效回答")
return answer.strip()
def _resolve_reply(
db: Session,
avatar: Avatar,
question: str,
history: list[Any],
*,
qa_pairs: list[Any] | None = None,
search_fn: Callable[..., list[dict]] | None = None,
model_client: Callable[..., str] | None = None,
) -> dict:
if qa_pairs is None:
qa_pairs = db.query(QAPair).filter(QAPair.avatar_id == avatar.id).all()
matched = _match_standard_qa(question, qa_pairs)
if matched:
return {"answer": matched.answer, "source": "qa", "references": []}
search_fn = search_fn or (lambda query, avatar_id: _search_knowledge(db, avatar_id, query))
hits = search_fn(question, avatar.id)
messages = _build_prompt(avatar, history, question, hits)
config = _config(avatar)
temperature = 0.2 + config["creativity"] / 100 * 0.6
model_client = model_client or _call_qwen
answer = model_client(messages=messages, temperature=temperature)
return {
"answer": answer,
"source": "knowledge" if hits else "qwen",
"references": hits,
}
@router.post("/avatar/{avatar_id}/chat")
def chat(avatar_id: str, body: ChatIn = Body(...), authorization: str = Header(None), db: Session = Depends(get_db)):
avatar = _require_owned_avatar(db, avatar_id, authorization)
try:
return ok(_resolve_reply(db, avatar, body.message, body.history))
except RuntimeError as exc:
return fail(str(exc), code=502)

View File

@@ -0,0 +1,278 @@
import os
import json
import uuid
from datetime import datetime, timezone
from fastapi import APIRouter, UploadFile, File, Depends, Header, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
from database import get_db
from models import KnowledgeDoc, QAPair, KnowledgeChunk, Avatar, User
from responses import ok, fail
import embeddings
router = APIRouter()
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
UPLOAD_DIR = os.path.join(BASE_DIR, "uploads")
os.makedirs(UPLOAD_DIR, exist_ok=True)
ALLOWED_EXT = {".md", ".txt", ".pdf", ".doc", ".docx", ".xlsx"}
MAX_UPLOAD_BYTES = 10 * 1024 * 1024
class QAIn(BaseModel):
question: str = ""
answer: str = ""
enabled: bool = True
class EnabledIn(BaseModel):
enabled: bool = True
def _resolve_user(authorization: str | None, db: Session):
if not authorization:
return None
token = authorization.replace("Bearer ", "", 1).replace("bearer ", "", 1).strip()
return db.query(User).filter(User.app_token == token).first()
def _require_owned_avatar(db: Session, avatar_id: str, authorization: str | None):
avatar = db.query(Avatar).filter(Avatar.id == avatar_id).first()
if not avatar:
raise HTTPException(status_code=404, detail="avatar not found")
user = _resolve_user(authorization, db)
if not user:
raise HTTPException(status_code=401, detail="未登录")
if avatar.owner_id and avatar.owner_id != user.huihui_user_id:
raise HTTPException(status_code=403, detail="无权访问该分身")
return avatar
# ---------------- Documents ----------------
@router.get("/avatar/{avatar_id}/knowledge/docs")
def list_docs(avatar_id: str, authorization: str = Header(None), db: Session = Depends(get_db)):
_require_owned_avatar(db, avatar_id, authorization)
docs = (
db.query(KnowledgeDoc)
.filter(KnowledgeDoc.avatar_id == avatar_id)
.order_by(KnowledgeDoc.created_at.desc())
.all()
)
return ok([d.to_dict() for d in docs])
@router.post("/avatar/{avatar_id}/knowledge/docs")
async def upload_doc(avatar_id: str, file: UploadFile = File(...), authorization: str = Header(None), db: Session = Depends(get_db)):
_require_owned_avatar(db, avatar_id, authorization)
ext = os.path.splitext(file.filename or "")[1].lower()
if ext not in ALLOWED_EXT:
return fail(f"不支持的文件类型:{ext or ''},仅支持 md/txt/pdf/doc/docx/xlsx", code=400)
avatar_dir = os.path.join(UPLOAD_DIR, avatar_id)
os.makedirs(avatar_dir, exist_ok=True)
stored = f"{uuid.uuid4().hex}{ext}"
path = os.path.join(avatar_dir, stored)
content = await file.read()
if len(content) > MAX_UPLOAD_BYTES:
return fail("文件不能超过 10MB", code=400)
with open(path, "wb") as f:
f.write(content)
doc = KnowledgeDoc(
avatar_id=avatar_id,
filename=file.filename,
file_type=ext.lstrip("."),
file_size=len(content),
file_url=f"/api/files/{avatar_id}/{stored}",
status="parsing",
)
db.add(doc)
db.commit()
db.refresh(doc)
# 向量化:抽取文本 -> 分块 -> 调第三方/本地嵌入 -> 存切片
try:
text = embeddings.extract_text(path, ext)
chunks = embeddings.chunk_text(text)
if chunks:
vectors = embeddings.embed(chunks)
for i, (c, v) in enumerate(zip(chunks, vectors)):
db.add(
KnowledgeChunk(
doc_id=doc.id,
avatar_id=avatar_id,
content=c,
vector=json.dumps(v),
chunk_index=i,
embedding_model=embeddings.MODEL,
)
)
doc.vectorized = True
doc.embedding_model = embeddings.MODEL
doc.chunk_count = len(chunks)
doc.vectorized_at = datetime.now(timezone.utc)
doc.status = "ready"
db.commit()
db.refresh(doc)
except Exception as e:
print("vectorize failed:", e)
doc.status = "ready" # 上传成功但向量化失败,仍可展示
db.commit()
db.refresh(doc)
return ok(doc.to_dict())
@router.delete("/avatar/{avatar_id}/knowledge/docs/{doc_id}")
def delete_doc(avatar_id: str, doc_id: str, authorization: str = Header(None), db: Session = Depends(get_db)):
_require_owned_avatar(db, avatar_id, authorization)
doc = (
db.query(KnowledgeDoc)
.filter(KnowledgeDoc.id == doc_id, KnowledgeDoc.avatar_id == avatar_id)
.first()
)
if not doc:
return fail("文档不存在", code=404)
# 级联删除切片
db.query(KnowledgeChunk).filter(KnowledgeChunk.doc_id == doc_id).delete()
try:
fp = os.path.join(UPLOAD_DIR, avatar_id, os.path.basename(doc.file_url))
if os.path.exists(fp):
os.remove(fp)
except Exception:
pass
db.delete(doc)
db.commit()
return ok({"id": doc_id})
# ---------------- 向量检索 ----------------
@router.get("/avatar/{avatar_id}/knowledge/search")
def search_knowledge(avatar_id: str, q: str = "", top_k: int = 5, authorization: str = Header(None), db: Session = Depends(get_db)):
_require_owned_avatar(db, avatar_id, authorization)
q = (q or "").strip()
if not q:
return ok([])
chunks = (
db.query(KnowledgeChunk)
.filter(KnowledgeChunk.avatar_id == avatar_id)
.all()
)
if not chunks:
return ok([])
qvec = embeddings.embed([q])[0]
scored = []
for c in chunks:
try:
vec = json.loads(c.vector)
except Exception:
continue
scored.append((embeddings.cosine(qvec, vec), c))
scored.sort(key=lambda x: x[0], reverse=True)
results = []
for score, c in scored[: max(1, top_k)]:
doc = db.query(KnowledgeDoc).filter(KnowledgeDoc.id == c.doc_id).first()
snippet = c.content[:120] + ("" if len(c.content) > 120 else "")
results.append(
{
"docId": c.doc_id,
"filename": doc.filename if doc else "",
"fileType": doc.file_type if doc else "",
"snippet": snippet,
"score": round(score, 4),
}
)
return ok(results)
# ---------------- Standard Q&A pairs ----------------
@router.get("/avatar/{avatar_id}/knowledge/qa")
def list_qa(avatar_id: str, authorization: str = Header(None), db: Session = Depends(get_db)):
_require_owned_avatar(db, avatar_id, authorization)
items = (
db.query(QAPair)
.filter(QAPair.avatar_id == avatar_id)
.order_by(QAPair.created_at.desc())
.all()
)
return ok([q.to_dict() for q in items])
@router.post("/avatar/{avatar_id}/knowledge/qa")
def create_qa(avatar_id: str, body: QAIn, authorization: str = Header(None), db: Session = Depends(get_db)):
_require_owned_avatar(db, avatar_id, authorization)
q = QAPair(
avatar_id=avatar_id,
question=body.question,
answer=body.answer,
enabled=body.enabled,
)
db.add(q)
db.commit()
db.refresh(q)
return ok(q.to_dict())
@router.put("/avatar/{avatar_id}/knowledge/qa/{qa_id}")
def update_qa(avatar_id: str, qa_id: str, body: QAIn, authorization: str = Header(None), db: Session = Depends(get_db)):
_require_owned_avatar(db, avatar_id, authorization)
q = (
db.query(QAPair)
.filter(QAPair.id == qa_id, QAPair.avatar_id == avatar_id)
.first()
)
if not q:
return fail("问答对不存在", code=404)
q.question = body.question
q.answer = body.answer
q.enabled = body.enabled
db.commit()
db.refresh(q)
return ok(q.to_dict())
@router.put("/avatar/{avatar_id}/knowledge/qa/{qa_id}/enabled")
def set_qa_enabled(avatar_id: str, qa_id: str, body: EnabledIn, authorization: str = Header(None), db: Session = Depends(get_db)):
_require_owned_avatar(db, avatar_id, authorization)
q = (
db.query(QAPair)
.filter(QAPair.id == qa_id, QAPair.avatar_id == avatar_id)
.first()
)
if not q:
return fail("问答对不存在", code=404)
q.enabled = bool(body.enabled)
db.commit()
db.refresh(q)
return ok(q.to_dict())
@router.delete("/avatar/{avatar_id}/knowledge/qa/{qa_id}")
def delete_qa(avatar_id: str, qa_id: str, authorization: str = Header(None), db: Session = Depends(get_db)):
_require_owned_avatar(db, avatar_id, authorization)
q = (
db.query(QAPair)
.filter(QAPair.id == qa_id, QAPair.avatar_id == avatar_id)
.first()
)
if not q:
return fail("问答对不存在", code=404)
db.delete(q)
db.commit()
return ok({"id": qa_id})
# ---------------- HuiHui user profile (mock; plug real interface via HUIHUI_USER_API) ----------------
@router.get("/user/profile")
def user_profile():
# 接入真实会会接口:设置环境变量 HUIHUI_USER_API 后在此请求并映射字段
api = os.getenv("HUIHUI_USER_API")
if api:
# TODO: 调用会会用户接口,返回 { userId, nickname, avatarUrl }
pass
return ok({
"userId": "hh_10001",
"nickname": "会会用户",
"avatarUrl": "https://api.dicebear.com/7.x/initials/svg?seed=HuiHui&backgroundColor=F97316",
})

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,95 @@
import unittest
from types import SimpleNamespace
from unittest.mock import Mock
from fastapi import HTTPException
from models import Avatar, User
from routers.chat import _build_prompt, _match_standard_qa, _require_owned_avatar, _resolve_reply
class ChatOrchestrationTests(unittest.TestCase):
def setUp(self):
self.avatar = SimpleNamespace(
id="avatar-1",
owner_id="huihui-user-1",
config={
"replyStyle": "professional",
"creativity": 50,
"rigor": 80,
"humor": 20,
"responseLength": "medium",
"systemPrompt": "不要编造政策。",
},
)
self.qa = SimpleNamespace(question="公司地址?", answer="标准地址", enabled=True)
self.disabled_qa = SimpleNamespace(question="公司地址?", answer="错误答案", enabled=False)
def test_enabled_qa_wins_without_calling_model(self):
fake_model = Mock()
result = _resolve_reply(
None,
self.avatar,
" 公司地址? ",
[],
qa_pairs=[self.disabled_qa, self.qa],
search_fn=lambda *_args, **_kwargs: [],
model_client=fake_model,
)
self.assertEqual(result["source"], "qa")
self.assertEqual(result["answer"], "标准地址")
fake_model.assert_not_called()
def test_knowledge_context_is_sent_to_qwen_after_qa_miss(self):
fake_model = Mock(return_value="根据知识库内容回答")
knowledge_hit = {
"filename": "退款.md",
"snippet": "知识库内容:七日内可申请退款。",
"score": 0.92,
}
result = _resolve_reply(
None,
self.avatar,
"退款规则",
[],
qa_pairs=[],
search_fn=lambda *_args, **_kwargs: [knowledge_hit],
model_client=fake_model,
)
self.assertEqual(result["source"], "knowledge")
self.assertIn("知识库内容", fake_model.call_args.kwargs["messages"][0]["content"])
def test_prompt_contains_personality_configuration(self):
messages = _build_prompt(self.avatar, [], "你好", [])
self.assertIn("严谨度", messages[0]["content"])
self.assertIn("不要编造政策", messages[0]["content"])
def test_chat_rejects_avatar_owned_by_another_user(self):
class Query:
def __init__(self, value):
self.value = value
def filter(self, *_args, **_kwargs):
return self
def first(self):
return self.value
self_avatar = self.avatar
class DB:
avatar = self_avatar
def query(self, model):
return Query(
self.avatar if model is Avatar else SimpleNamespace(huihui_user_id="huihui-user-2")
)
db = DB()
with self.assertRaises(HTTPException) as caught:
_require_owned_avatar(db, self.avatar.id, "Bearer other-token")
self.assertEqual(caught.exception.status_code, 403)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,32 @@
import os
import tempfile
import unittest
import embeddings
class TextExtractionTests(unittest.TestCase):
def write_text(self, suffix, content):
handle = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
handle.close()
self.addCleanup(lambda: os.path.exists(handle.name) and os.unlink(handle.name))
with open(handle.name, "w", encoding="utf-8") as stream:
stream.write(content)
return handle.name
def test_extracts_utf8_markdown(self):
path = self.write_text(".md", "# 退款规则\n\n七日内可申请退款。")
self.assertEqual(embeddings.extract_text(path, ".md"), "# 退款规则\n\n七日内可申请退款。")
def test_extracts_utf8_text(self):
path = self.write_text(".txt", "客服热线400-123-4567")
self.assertEqual(embeddings.extract_text(path, ".txt"), "客服热线400-123-4567")
def test_rejects_unsupported_extension(self):
path = self.write_text(".csv", "not supported")
with self.assertRaises(ValueError):
embeddings.extract_text(path, ".csv")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,99 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import {
buildAvatarUpdatePayload,
normalizeAvatarEditForm,
pickAvatarId,
unwrapListData,
} from '../src/utils/avatar-page-data.js'
assert.deepEqual(unwrapListData([{ id: 'a1' }]), [{ id: 'a1' }], 'unwrapListData should return raw arrays')
assert.deepEqual(
unwrapListData({ data: [{ id: 'a2' }], total: 1 }),
[{ id: 'a2' }],
'unwrapListData should unwrap wrapped collection payloads'
)
assert.deepEqual(unwrapListData(null), [], 'unwrapListData should fall back to an empty list')
assert.equal(
pickAvatarId('current-id', [{ id: 'first-id' }]),
'current-id',
'pickAvatarId should prefer current avatar id'
)
assert.equal(
pickAvatarId('', [{ id: 'first-id' }]),
'first-id',
'pickAvatarId should fall back to the first avatar id'
)
assert.equal(pickAvatarId('', []), null, 'pickAvatarId should return null when no avatar exists')
assert.deepEqual(
normalizeAvatarEditForm({
name: '我的分身',
displayName: '会会助手',
description: '描述',
status: 'inactive',
photoUrl: 'https://img.example/avatar.png',
config: { replyStyle: 'friendly', creativity: 72, rigor: 88, humor: 16, responseLength: 'short', systemPrompt: '不要编造', autoReply: false },
}),
{
name: '我的分身',
displayName: '会会助手',
description: '描述',
status: 'inactive',
photoUrl: 'https://img.example/avatar.png',
replyStyle: 'friendly',
creativity: 72,
rigor: 88,
humor: 16,
responseLength: 'short',
systemPrompt: '不要编造',
autoReply: false,
},
'normalizeAvatarEditForm should map API avatars into edit form state'
)
assert.deepEqual(
buildAvatarUpdatePayload({
name: '更新后的分身',
displayName: '更新后的助手',
description: '新描述',
status: 'active',
photoUrl: 'https://img.example/new-avatar.png',
replyStyle: 'casual',
creativity: 65,
rigor: 70,
humor: 25,
responseLength: 'medium',
systemPrompt: '回答简洁',
autoReply: true,
}),
{
name: '更新后的分身',
displayName: '更新后的助手',
description: '新描述',
status: 'active',
photoUrl: 'https://img.example/new-avatar.png',
config: {
replyStyle: 'casual',
creativity: 65,
rigor: 70,
humor: 25,
responseLength: 'medium',
systemPrompt: '回答简洁',
autoReply: true,
},
},
'buildAvatarUpdatePayload should emit API-ready update payloads'
)
const knowledgeView = fs.readFileSync(path.resolve('src/views/KnowledgeManage.vue'), 'utf8')
assert.match(knowledgeView, /文档知识库/, 'knowledge page should expose the document tab')
assert.match(knowledgeView, /标准问答对/, 'knowledge page should expose the QA tab')
assert.match(knowledgeView, /activeTab/, 'knowledge page should switch active tabs')
assert.match(knowledgeView, /accept="\.md,\.txt,\.pdf,\.doc,\.docx,\.xlsx"/, 'knowledge page should accept md and txt')
assert.match(knowledgeView, /table-scroll/, 'knowledge page should use a scrollable table wrapper')
console.log('avatar-page-data tests passed')

View File

@@ -0,0 +1,302 @@
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios'
// API 基址:优先级 window.__APP_CONFIG__.apiBase > 环境变量 > 默认 '/api'
// - 开发/Vite 代理:'/api'(由 vite.config 代理到后端 :8000
// - web-view 内(混合架构):需配置为后端公网地址,例如 'https://geo.99hui.com/api'
// - 同域部署的构建产物:可保持 '/api'
function resolveBaseURL(): string {
const cfg = (window as any).__APP_CONFIG__
if (cfg && cfg.apiBase) return cfg.apiBase as string
const env = import.meta.env.VITE_API_BASE as string | undefined
if (env) return env
return '/api'
}
// 统一认证 token由 uniapp 壳通过 URL 注入,见 utils/uniapp-bridge.ts
let _authToken: string | null = null
export function setAuthToken(token: string | null) {
_authToken = token
}
export function getAuthToken(): string | null {
return _authToken
}
// 创建 axios 实例(复用现有项目模式)
const createRequest = (config?: AxiosRequestConfig): AxiosInstance => {
const request = axios.create({
baseURL: resolveBaseURL(),
timeout: 30000,
...config
})
// 请求拦截器:注入认证头
request.interceptors.request.use((cfg) => {
if (_authToken) {
cfg.headers = cfg.headers || {}
;(cfg.headers as any).Authorization = `Bearer ${_authToken}`
}
return cfg
})
// 响应拦截器
request.interceptors.response.use(
(res) => {
const data = fixDatetimeTZ(res.data)
if (data && data.code && data.code !== 200) {
console.error('API Error:', data.message)
return Promise.reject(new Error(data.message))
}
// 解包标准响应 { code, message, data }
if (data && data.code === 200) {
return data.data !== undefined ? data.data : data
}
return data
},
(err) => {
const msg = err.response?.data?.detail || err.response?.data?.message || err.message || '网络错误'
console.error('Network Error:', msg)
return Promise.reject(err)
}
)
return request
}
// 递归修复时区标识(复用现有项目逻辑)
function fixDatetimeTZ(obj: any): any {
if (typeof obj === 'string') {
return obj.replace(/T(\d{2}:\d{2}:\d{2})\+00:00/g, 'T$1\+08:00')
}
if (Array.isArray(obj)) return obj.map(fixDatetimeTZ)
if (obj && typeof obj === 'object') {
const result: any = {}
for (const k in obj) result[k] = fixDatetimeTZ(obj[k])
return result
}
return obj
}
const request = createRequest()
// ==================== 分身管理 API ====================
export interface Avatar {
id: string
name: string
displayName: string
description: string
photoUrl?: string
status: 'active' | 'inactive' | 'training'
tokenBalance: number
createdAt: string
updatedAt: string
}
// 获取分身列表
export const getAvatarList = (params?: { page?: number; limit?: number }) =>
request.get<{ data: Avatar[]; total: number }>('/avatar', { params })
// 获取分身详情
export const getAvatarDetail = (id: string) =>
request.get<Avatar>(`/avatar/${id}`)
// 创建分身
export const createAvatar = (data: Partial<Avatar>) =>
request.post<Avatar>('/avatar', data)
// 更新分身
export const updateAvatar = (id: string, data: Partial<Avatar>) =>
request.put<Avatar>(`/avatar/${id}`, data)
// 删除分身
export const deleteAvatar = (id: string) =>
request.delete(`/avatar/${id}`)
// ==================== Token 管理 API ====================
// 获取 Token 余额
export const getTokenBalance = () =>
request.get<{ balance: number }>('/token/balance')
// 获取充值套餐
export const getRechargePlans = () =>
request.get<Array<{ id: string; name: string; amount: number; price: number }>>('/token/plans')
// 执行充值
export const chargeToken = (planId: string) =>
request.post<{ balance: number; charged: number }>('/token/charge', { planId })
// ==================== 授权管理 API ====================
export interface Authorization {
id: string
avatarId: string
targetType: 'user' | 'organization' | 'application'
targetId: string
targetName: string
permissions: string[]
status: 'active' | 'inactive'
createdAt: string
}
// 获取授权列表
export const getAuthorizationList = (avatarId: string) =>
request.get<Authorization[]>(`/avatar/${avatarId}/authorizations`)
// 更新授权
export const updateAuthorization = (avatarId: string, data: Partial<Authorization>) =>
request.put(`/avatar/${avatarId}/authorizations`, data)
// ==================== 组织管理 API ====================
export interface Organization {
id: string
name: string
description: string
role: 'admin' | 'member' | 'viewer'
memberCount: number
createdAt: string
}
// 获取组织列表
export const getOrganizationList = (params?: any) =>
request.get<{ data: Organization[]; total: number }>('/organizations', { params })
// 创建组织
export const createOrganization = (data: Partial<Organization>) =>
request.post<Organization>('/organizations', data)
// ==================== 知识库管理 API ====================
export interface KnowledgeDoc {
id: string
avatarId: string
filename: string
fileType: string
fileSize: number
fileUrl: string
status: string
vectorized?: boolean
embeddingModel?: string
chunkCount?: number
createdAt: string
}
export interface QAPair {
id: string
avatarId: string
question: string
answer: string
enabled?: boolean
createdAt: string
updatedAt: string
}
export interface SearchResult {
docId: string
filename: string
fileType: string
snippet: string
score: number
}
// 文档列表
export const getKnowledgeDocs = (avatarId: string) =>
request.get<KnowledgeDoc[]>(`/avatar/${avatarId}/knowledge/docs`)
// 上传文档(支持 md/txt/pdf/doc/docx/xlsx
export const uploadKnowledgeDoc = (avatarId: string, file: File) => {
const form = new FormData()
form.append('file', file)
return request.post<KnowledgeDoc>(`/avatar/${avatarId}/knowledge/docs`, form, {
headers: { 'Content-Type': 'multipart/form-data' }
})
}
// 删除文档
export const deleteKnowledgeDoc = (avatarId: string, docId: string) =>
request.delete(`/avatar/${avatarId}/knowledge/docs/${docId}`)
// 标准问答对列表
export const getQAPairs = (avatarId: string) =>
request.get<QAPair[]>(`/avatar/${avatarId}/knowledge/qa`)
// 创建问答对
export const createQAPair = (avatarId: string, data: { question: string; answer: string; enabled?: boolean }) =>
request.post<QAPair>(`/avatar/${avatarId}/knowledge/qa`, data)
// 更新问答对
export const updateQAPair = (avatarId: string, id: string, data: { question: string; answer: string; enabled?: boolean }) =>
request.put<QAPair>(`/avatar/${avatarId}/knowledge/qa/${id}`, data)
// 切换问答对启用状态
export const setQaEnabled = (avatarId: string, id: string, enabled: boolean) =>
request.put<QAPair>(`/avatar/${avatarId}/knowledge/qa/${id}/enabled`, { enabled })
// 删除问答对
export const deleteQAPair = (avatarId: string, id: string) =>
request.delete(`/avatar/${avatarId}/knowledge/qa/${id}`)
// 文档向量检索
export const searchKnowledge = (avatarId: string, q: string, topK = 5) =>
request.get<SearchResult[]>(`/avatar/${avatarId}/knowledge/search`, {
params: { q, top_k: topK }
})
// ==================== 数字分身聊天 API ====================
export interface ChatMessage {
role: 'user' | 'assistant'
content: string
}
export interface ChatResponse {
answer: string
source: 'qa' | 'knowledge' | 'qwen'
references?: Array<{ docId?: string; filename?: string; fileType?: string; snippet?: string; score?: number }>
}
export const sendAvatarChat = (avatarId: string, payload: { message: string; history?: ChatMessage[] }) =>
request.post<ChatResponse>(`/avatar/${avatarId}/chat`, payload)
// ==================== 会会用户资料 API ====================
export interface UserProfile {
userId: string
nickname: string
avatarUrl: string
}
// 获取会会系统中的用户头像与昵称
export const getUserProfile = () =>
request.get<UserProfile>('/user/profile')
// ==================== 会会短信验证码登录 API ====================
export interface SmsLoginResult {
token: string
user: UserProfile & { huihuiUserId: string; phone: string; createdAt?: string; lastLoginAt?: string }
huihui: { userId: string; nickname: string; avatarUrl: string; token: string }
}
// 发送短信验证码(演示模式会额外返回 devCode / dev 标记)
export const sendSmsCode = (phone: string) =>
request.post<{ sent: boolean; devCode?: string; dev?: boolean }>('/huihui/sms/send', { phone })
// 短信验证码登录
export const loginBySms = (phone: string, code: string) =>
request.post<SmsLoginResult>('/huihui/sms/login', { phone, code })
// 账号密码登录(会会 loginType=password
export const loginByPassword = (account: string, password: string) =>
request.post<SmsLoginResult>('/huihui/pwd/login', { account, password })
// 当前登录用户
export const getCurrentUser = () =>
request.get<UserProfile & { huihuiUserId: string; phone: string }>('/huihui/me')
// 退出登录
export const logoutUser = () =>
request.post('/huihui/logout')
export default request

View File

@@ -0,0 +1,106 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
const routes: RouteRecordRaw[] = [
{
path: '/',
name: 'AvatarCreate',
component: () => import('@/views/AvatarCreate.vue'),
meta: { title: '创建数字分身', requiresAuth: true }
},
{
path: '/avatar/manage',
name: 'AvatarManage',
component: () => import('@/views/AvatarManage.vue'),
meta: { title: '数字分身管理', requiresAuth: true }
},
{
path: '/avatar/edit/:id',
name: 'AvatarEdit',
component: () => import('@/views/AvatarEdit.vue'),
meta: { title: '形象微调编辑', requiresAuth: true }
},
{
path: '/avatar/chat/:id',
name: 'AvatarChat',
component: () => import('@/views/AvatarChat.vue'),
meta: { title: '和分身对话', requiresAuth: true }
},
{
path: '/authorization',
name: 'AuthorizationManage',
component: () => import('@/views/AuthorizationManage.vue'),
meta: { title: '授权管理', requiresAuth: true }
},
{
path: '/token/charge',
name: 'TokenCharge',
component: () => import('@/views/TokenCharge.vue'),
meta: { title: 'Token充值', requiresAuth: true }
},
{
path: '/avatar/card',
name: 'AvatarCard',
component: () => import('@/views/AvatarCard.vue'),
meta: { title: '分身名片', requiresAuth: true }
},
{
path: '/avatar/contacts',
name: 'AvatarContacts',
component: () => import('@/views/AvatarContacts.vue'),
meta: { title: '分身人脉', requiresAuth: true }
},
{
path: '/avatar/projects',
name: 'MyProjects',
component: () => import('@/views/MyProjects.vue'),
meta: { title: '我的项目', requiresAuth: true }
},
{
path: '/avatar/org/create',
name: 'CreateOrg',
component: () => import('@/views/CreateOrg.vue'),
meta: { title: '创建组织', requiresAuth: true }
},
{
path: '/knowledge',
name: 'KnowledgeManage',
component: () => import('@/views/KnowledgeManage.vue'),
meta: { title: '知识库管理', requiresAuth: true }
},
{
path: '/knowledge/qa/create',
name: 'QaPairCreate',
component: () => import('@/views/QaPairEdit.vue'),
meta: { title: '添加问答对', requiresAuth: true }
},
{
path: '/knowledge/qa/:qaId/edit',
name: 'QaPairEdit',
component: () => import('@/views/QaPairEdit.vue'),
meta: { title: '编辑问答对', requiresAuth: true }
},
{
path: '/login/sms',
name: 'SmsLogin',
component: () => import('@/views/SmsLogin.vue'),
meta: { title: '短信验证码登录' }
}
]
const router = createRouter({
// 混合架构下 web-view 内用 hash 模式,原生返回键与深链更稳
history: createWebHashHistory(),
routes
})
router.beforeEach((to, from, next) => {
document.title = to.meta.title as string || '会会数字分身'
if (to.meta.requiresAuth && !localStorage.getItem('hh_app_token')) {
next({ path: '/login/sms', query: { redirect: to.fullPath } })
return
}
next()
})
export default router

View File

@@ -0,0 +1,46 @@
export function unwrapListData(value) {
if (Array.isArray(value)) return value
if (Array.isArray(value?.data)) return value.data
return []
}
export function pickAvatarId(currentAvatarId, avatars) {
return currentAvatarId || avatars?.[0]?.id || null
}
export function normalizeAvatarEditForm(avatar = {}) {
const config = avatar.config || {}
return {
name: avatar.name || '',
displayName: avatar.displayName || '',
description: avatar.description || '',
status: avatar.status || 'active',
photoUrl: avatar.photoUrl || '',
replyStyle: config.replyStyle || 'professional',
creativity: Number.isFinite(config.creativity) ? config.creativity : 50,
rigor: Number.isFinite(config.rigor) ? config.rigor : 50,
humor: Number.isFinite(config.humor) ? config.humor : 30,
responseLength: config.responseLength || 'medium',
systemPrompt: config.systemPrompt || '',
autoReply: config.autoReply !== false,
}
}
export function buildAvatarUpdatePayload(form) {
return {
name: form.name.trim(),
displayName: form.displayName.trim() || form.name.trim(),
description: form.description.trim(),
status: form.status,
photoUrl: form.photoUrl.trim(),
config: {
replyStyle: form.replyStyle,
creativity: Number(form.creativity),
rigor: Number(form.rigor),
humor: Number(form.humor),
responseLength: form.responseLength,
systemPrompt: form.systemPrompt.trim(),
autoReply: !!form.autoReply,
},
}
}

View File

@@ -0,0 +1,154 @@
<template>
<div class="chat-page">
<header class="chat-header">
<button class="back-btn" @click="router.back()"></button>
<div class="avatar-heading">
<div class="avatar-mark">{{ avatar?.emoji || '🤖' }}</div>
<div>
<h1>{{ avatar?.displayName || avatar?.name || '数字分身' }}</h1>
<span class="online-state"> 随时可以和我聊聊</span>
</div>
</div>
<button class="settings-btn" title="编辑分身" @click="router.push(`/avatar/edit/${avatarId}`)"></button>
</header>
<main ref="messageList" class="message-list">
<div v-if="!messages.length" class="welcome-card">
<div class="welcome-icon"></div>
<h2>你好我是{{ avatar?.displayName || '你的数字分身' }}</h2>
<p>我会优先参考标准问答和知识库再结合自己的理解回答你</p>
<div class="starter-list">
<button v-for="starter in starters" :key="starter" @click="sendMessage(starter)">{{ starter }}</button>
</div>
</div>
<article v-for="(message, index) in messages" :key="`${message.role}-${index}`" class="message-row" :class="message.role">
<div v-if="message.role === 'assistant'" class="message-avatar">{{ avatar?.emoji || '🤖' }}</div>
<div class="message-column">
<div class="message-bubble">{{ message.content }}</div>
<div v-if="message.source || message.references?.length" class="message-source">
{{ sourceLabel(message.source) }}
<span v-if="message.references?.length"> · {{ message.references.map((item) => item.filename).filter(Boolean).join('、') }}</span>
</div>
</div>
</article>
<div v-if="sending" class="message-row assistant">
<div class="message-avatar">{{ avatar?.emoji || '🤖' }}</div>
<div class="message-bubble typing"><i></i><i></i><i></i></div>
</div>
<p v-if="errorMessage" class="chat-error">{{ errorMessage }} <button @click="retryLast">重试</button></p>
</main>
<form class="composer" @submit.prevent="sendMessage(inputText)">
<textarea v-model="inputText" rows="1" :disabled="sending" placeholder="输入你想聊的内容…" @keydown.enter.exact.prevent="sendMessage(inputText)"></textarea>
<button class="send-btn" type="submit" :disabled="sending || !inputText.trim()">发送</button>
</form>
</div>
</template>
<script setup lang="ts">
import { nextTick, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getAvatarDetail, sendAvatarChat, type ChatMessage } from '@/api'
import { useAvatarStore } from '@/store/avatar'
type DisplayMessage = ChatMessage & {
source?: 'qa' | 'knowledge' | 'qwen'
references?: Array<{ filename?: string }>
}
const route = useRoute()
const router = useRouter()
const store = useAvatarStore()
const avatarId = String(route.params.id || '')
const avatar = ref<any>(null)
const messages = ref<DisplayMessage[]>([])
const inputText = ref('')
const sending = ref(false)
const errorMessage = ref('')
const lastQuestion = ref('')
const messageList = ref<HTMLElement | null>(null)
const starters = ['介绍一下你自己', '你能帮我做什么?', '请根据我的知识库回答一个问题']
const sourceLabel = (source?: DisplayMessage['source']) => ({
qa: '标准问答对',
knowledge: '参考文件知识库',
qwen: 'Qwen 智能回答'
}[source || ''] || '')
const scrollToBottom = async () => {
await nextTick()
if (messageList.value) messageList.value.scrollTop = messageList.value.scrollHeight
}
const loadAvatar = async () => {
avatar.value = store.avatars.find((item) => String(item.id) === avatarId)
if (!avatar.value) avatar.value = await getAvatarDetail(avatarId)
}
const sendMessage = async (value: string) => {
const question = value.trim()
if (!question || sending.value) return
lastQuestion.value = question
inputText.value = ''
errorMessage.value = ''
messages.value.push({ role: 'user', content: question })
sending.value = true
await scrollToBottom()
try {
const response = await sendAvatarChat(avatarId, {
message: question,
history: messages.value.slice(-10).map(({ role, content }) => ({ role, content }))
})
messages.value.push({ role: 'assistant', content: response.answer, source: response.source, references: response.references })
await scrollToBottom()
} catch (error: any) {
errorMessage.value = error?.message || '暂时无法回答,请稍后重试'
} finally {
sending.value = false
}
}
const retryLast = () => {
if (!lastQuestion.value || sending.value) return
const last = messages.value[messages.value.length - 1]
if (last?.role === 'user') messages.value.pop()
sendMessage(lastQuestion.value)
}
onMounted(loadAvatar)
</script>
<style scoped>
.chat-page { min-height: 100dvh; display: flex; flex-direction: column; background: #FFF8F1; color: #3B2417; }
.chat-header { flex: 0 0 auto; display: flex; align-items: center; gap: 12px; padding: 14px 18px; color: white; background: linear-gradient(135deg, #F97316, #FB923C); box-shadow: 0 5px 18px rgba(249, 115, 22, .2); }
.back-btn, .settings-btn { border: 0; background: transparent; color: white; cursor: pointer; font-size: 25px; padding: 2px 6px; }
.settings-btn { font-size: 20px; margin-left: auto; }
.avatar-heading { display: flex; align-items: center; gap: 10px; }
.avatar-mark { width: 38px; height: 38px; display: grid; place-items: center; border-radius: 13px; background: rgba(255,255,255,.24); font-size: 23px; }
.avatar-heading h1 { margin: 0; font-size: 17px; }
.online-state { display: block; margin-top: 3px; font-size: 11px; opacity: .86; }
.message-list { flex: 1 1 auto; width: min(760px, 100%); box-sizing: border-box; margin: 0 auto; padding: 24px 18px 120px; overflow-y: auto; }
.welcome-card { padding: 28px 20px; text-align: center; background: rgba(255,255,255,.72); border: 1px solid #FFE1C2; border-radius: 22px; box-shadow: 0 10px 28px rgba(181, 99, 35, .08); }
.welcome-icon { color: #F97316; font-size: 30px; }
.welcome-card h2 { margin: 9px 0 8px; font-size: 20px; }
.welcome-card p { margin: 0 auto 20px; max-width: 420px; color: #8B6B58; line-height: 1.6; font-size: 14px; }
.starter-list { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; }
.starter-list button { border: 1px solid #FFD1A8; color: #C15F18; background: #FFF4E8; border-radius: 20px; padding: 8px 12px; cursor: pointer; }
.message-row { display: flex; gap: 9px; margin: 18px 0; align-items: flex-end; }
.message-row.user { justify-content: flex-end; }
.message-avatar { flex: 0 0 auto; width: 30px; height: 30px; display: grid; place-items: center; border-radius: 10px; background: #FFE4C7; }
.message-column { max-width: min(78%, 560px); }
.message-bubble { padding: 12px 14px; white-space: pre-wrap; line-height: 1.6; font-size: 15px; border-radius: 16px 16px 16px 4px; background: white; box-shadow: 0 3px 12px rgba(96, 52, 21, .07); }
.user .message-bubble { color: white; border-radius: 16px 16px 4px 16px; background: #F97316; }
.message-source { margin: 5px 4px 0; font-size: 11px; color: #A77A5B; }
.typing { display: flex; gap: 4px; padding: 14px 16px; }
.typing i { width: 5px; height: 5px; border-radius: 50%; background: #F97316; animation: blink 1s infinite alternate; }
.typing i:nth-child(2) { animation-delay: .2s; }.typing i:nth-child(3) { animation-delay: .4s; }
@keyframes blink { from { opacity: .25; } to { opacity: 1; } }
.chat-error { margin: 4px auto; color: #B42318; font-size: 13px; }.chat-error button { border: 0; background: none; color: #C15F18; cursor: pointer; text-decoration: underline; }
.composer { position: fixed; left: 0; right: 0; bottom: 0; display: flex; gap: 10px; padding: 12px max(18px, calc((100vw - 760px) / 2 + 18px)); background: rgba(255,255,255,.92); border-top: 1px solid #F4DCC7; backdrop-filter: blur(12px); }
.composer textarea { flex: 1; resize: none; min-height: 22px; max-height: 100px; padding: 11px 13px; border: 1px solid #EED8C5; border-radius: 13px; font: inherit; color: #3B2417; outline: none; }.composer textarea:focus { border-color: #F97316; }
.send-btn { align-self: flex-end; padding: 11px 18px; border: 0; border-radius: 12px; color: white; background: #F97316; cursor: pointer; }.send-btn:disabled { opacity: .45; cursor: not-allowed; }
</style>

View File

@@ -0,0 +1,533 @@
<template>
<div class="edit-avatar-page">
<!-- 顶部导航 -->
<header class="page-header">
<button class="back-btn" @click="goBack"></button>
<h1 class="page-title">形象微调编辑</h1>
<button class="save-btn" :disabled="loading || saving" @click="saveChanges">
{{ saving ? '保存中...' : '保存' }}
</button>
</header>
<div v-if="loading" class="status-banner">加载中...</div>
<div v-else-if="errorMsg" class="status-banner error">{{ errorMsg }}</div>
<!-- 头像预览 -->
<section class="photo-section">
<div class="photo-container">
<div class="photo-preview">
<img v-if="formData.photoUrl" :src="formData.photoUrl" alt="" class="photo-image" referrerpolicy="no-referrer" />
<div v-else class="photo-placeholder">🤖</div>
</div>
<span class="photo-hint">可直接修改下方头像链接</span>
</div>
</section>
<!-- 基本信息表单 -->
<section class="form-section">
<h3 class="section-title">基本信息</h3>
<div class="form-item">
<label class="form-label">分身名称</label>
<input
v-model="formData.name"
class="form-input"
placeholder="请输入分身名称"
/>
</div>
<div class="form-item">
<label class="form-label">显示名称</label>
<input
v-model="formData.displayName"
class="form-input"
placeholder="请输入显示名称"
/>
</div>
<div class="form-item">
<label class="form-label">分身描述</label>
<textarea
v-model="formData.description"
class="form-textarea"
placeholder="描述你的数字分身的功能和特点"
rows="3"
></textarea>
</div>
<div class="form-item">
<label class="form-label">头像链接</label>
<input
v-model="formData.photoUrl"
class="form-input"
placeholder="请输入头像图片 URL"
/>
</div>
<div class="form-item">
<label class="form-label">状态</label>
<div class="status-selector">
<button
class="status-option"
:class="{ active: formData.status === 'active' }"
@click="formData.status = 'active'"
>
活跃中
</button>
<button
class="status-option"
:class="{ active: formData.status === 'inactive' }"
@click="formData.status = 'inactive'"
>
未激活
</button>
</div>
</div>
</section>
<!-- 高级设置 -->
<section class="form-section">
<h3 class="section-title">高级设置</h3>
<div class="form-item">
<label class="form-label">回复风格</label>
<select v-model="formData.replyStyle" class="form-select">
<option value="professional">专业正式</option>
<option value="casual">轻松随意</option>
<option value="friendly">友好亲切</option>
</select>
</div>
<div class="form-item">
<div class="slider-head"><label class="form-label">创造力</label><b>{{ formData.creativity }}</b></div>
<input v-model.number="formData.creativity" type="range" min="0" max="100" class="range" />
</div>
<div class="form-item">
<div class="slider-head"><label class="form-label">严谨度</label><b>{{ formData.rigor }}</b></div>
<input v-model.number="formData.rigor" type="range" min="0" max="100" class="range" />
</div>
<div class="form-item">
<div class="slider-head"><label class="form-label">幽默感</label><b>{{ formData.humor }}</b></div>
<input v-model.number="formData.humor" type="range" min="0" max="100" class="range" />
</div>
<div class="form-item">
<label class="form-label">回复长度</label>
<div class="choice-row">
<button v-for="item in responseLengths" :key="item.value" type="button" class="choice-btn" :class="{ active: formData.responseLength === item.value }" @click="formData.responseLength = item.value">{{ item.label }}</button>
</div>
</div>
<div class="form-item">
<label class="form-label">系统提示词</label>
<textarea v-model="formData.systemPrompt" class="form-textarea" rows="4" placeholder="给分身的额外指令,例如语气、禁用内容或回答边界"></textarea>
</div>
<div class="form-item">
<label class="form-label">自动回复</label>
<div class="toggle-container">
<span class="toggle-label">{{ formData.autoReply ? '开启' : '关闭' }}</span>
<button
class="toggle-btn"
:class="{ active: formData.autoReply }"
@click="formData.autoReply = !formData.autoReply"
>
<span class="toggle-dot"></span>
</button>
</div>
</div>
</section>
<!-- 危险操作 -->
<section class="danger-section">
<button class="delete-btn" :disabled="loading || deleting" @click="deleteAvatar">
{{ deleting ? '删除中...' : '删除数字分身' }}
</button>
</section>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { deleteAvatar as apiDeleteAvatar, getAvatarDetail, updateAvatar } from '@/api'
import { useAvatarStore } from '@/store/avatar'
import { buildAvatarUpdatePayload, normalizeAvatarEditForm } from '@/utils/avatar-page-data.js'
const router = useRouter()
const route = useRoute()
const avatarStore = useAvatarStore()
const avatarId = route.params.id as string
// 表单数据
const formData = reactive({
name: '',
displayName: '',
description: '',
status: 'active' as 'active' | 'inactive',
photoUrl: '',
replyStyle: 'professional',
creativity: 50,
rigor: 50,
humor: 30,
responseLength: 'medium',
systemPrompt: '',
autoReply: true
})
const responseLengths = [
{ value: 'short', label: '简短' },
{ value: 'medium', label: '适中' },
{ value: 'long', label: '详细' }
]
const loading = ref(true)
const saving = ref(false)
const deleting = ref(false)
const errorMsg = ref('')
const loadAvatar = async () => {
loading.value = true
errorMsg.value = ''
try {
const avatar: any = await getAvatarDetail(avatarId)
Object.assign(formData, normalizeAvatarEditForm(avatar))
} catch (e: any) {
errorMsg.value = e?.message || '分身加载失败'
} finally {
loading.value = false
}
}
// 保存修改
const saveChanges = async () => {
if (loading.value || saving.value) return
if (!formData.name.trim()) {
errorMsg.value = '请输入分身名称'
return
}
saving.value = true
errorMsg.value = ''
try {
await updateAvatar(avatarId, buildAvatarUpdatePayload(formData))
await avatarStore.loadAvatars()
router.replace('/avatar/manage')
} catch (e: any) {
errorMsg.value = e?.message || '保存失败,请重试'
} finally {
saving.value = false
}
}
// 删除分身
const deleteAvatar = async () => {
if (loading.value || deleting.value) return
if (confirm('确定要删除这个数字分身吗?此操作不可恢复。')) {
deleting.value = true
errorMsg.value = ''
try {
await apiDeleteAvatar(avatarId)
await avatarStore.loadAvatars()
router.replace('/avatar/manage')
} catch (e: any) {
errorMsg.value = e?.message || '删除失败,请重试'
} finally {
deleting.value = false
}
}
}
// 返回
const goBack = () => {
router.back()
}
onMounted(async () => {
await loadAvatar()
})
</script>
<style scoped>
.edit-avatar-page {
min-height: 100vh;
background: #F8F9FA;
padding-bottom: 40px;
}
/* 顶部导航 */
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
background: white;
border-bottom: 1px solid #EDEEF1;
}
.back-btn {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
padding: 4px 8px;
color: #18191C;
}
.page-title {
font-size: 17px;
font-weight: 600;
margin: 0;
color: #18191C;
}
.status-banner {
margin: 16px 20px 0;
padding: 12px 14px;
border-radius: 10px;
background: #FFF7ED;
color: #9A3412;
font-size: 14px;
}
.status-banner.error {
background: #FEF2F2;
color: #B91C1C;
}
.save-btn {
background: #F97316;
color: white;
border: none;
padding: 8px 20px;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
}
/* 头像上传 */
.photo-section {
padding: 30px 20px;
display: flex;
justify-content: center;
}
.photo-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
cursor: pointer;
}
.photo-preview {
width: 100px;
height: 100px;
border-radius: 50%;
background: linear-gradient(135deg, #F97316 0%, #FB923C 100%);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(249, 115, 22, 0.3);
}
.photo-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.photo-placeholder {
font-size: 48px;
}
.photo-hint {
font-size: 13px;
color: #F97316;
font-weight: 500;
}
/* 表单区域 */
.form-section {
padding: 20px;
margin-bottom: 12px;
}
.section-title {
font-size: 16px;
font-weight: 600;
margin: 0 0 16px;
color: #18191C;
}
.form-item {
margin-bottom: 20px;
}
.form-label {
display: block;
font-size: 14px;
font-weight: 500;
color: #18191C;
margin-bottom: 8px;
}
.form-input {
width: 100%;
padding: 12px 16px;
border: 1px solid #EDEEF1;
border-radius: 10px;
font-size: 15px;
color: #18191C;
background: white;
transition: border-color 0.2s;
}
.form-input:focus {
outline: none;
border-color: #F97316;
}
.form-textarea {
width: 100%;
padding: 12px 16px;
border: 1px solid #EDEEF1;
border-radius: 10px;
font-size: 15px;
color: #18191C;
background: white;
resize: vertical;
font-family: inherit;
transition: border-color 0.2s;
}
.form-textarea:focus {
outline: none;
border-color: #F97316;
}
.form-select {
width: 100%;
padding: 12px 16px;
border: 1px solid #EDEEF1;
border-radius: 10px;
font-size: 15px;
color: #18191C;
background: white;
cursor: pointer;
}
.slider-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.slider-head .form-label { margin-bottom: 0; }
.slider-head b { color: #F97316; font-size: 14px; }
.range { width: 100%; accent-color: #F97316; }
.choice-row { display: flex; gap: 8px; }
.choice-btn {
flex: 1; padding: 10px 8px; border: 1px solid #EDEEF1; border-radius: 10px;
background: white; color: #6B7280; cursor: pointer;
}
.choice-btn.active { border-color: #F97316; color: #F97316; background: #FFF0E6; }
/* 状态选择器 */
.status-selector {
display: flex;
gap: 12px;
}
.status-option {
flex: 1;
padding: 12px;
border: 2px solid #EDEEF1;
border-radius: 10px;
background: white;
font-size: 14px;
color: #9398AE;
cursor: pointer;
transition: all 0.2s;
}
.status-option.active {
border-color: #F97316;
color: #F97316;
background: #FFF0E6;
}
/* 开关切换 */
.toggle-container {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: white;
border-radius: 10px;
border: 1px solid #EDEEF1;
}
.toggle-label {
font-size: 15px;
color: #18191C;
}
.toggle-btn {
width: 48px;
height: 28px;
border-radius: 14px;
border: none;
background: #D1D5DB;
cursor: pointer;
position: relative;
transition: background 0.3s;
padding: 0;
}
.toggle-btn.active {
background: #F97316;
}
.toggle-dot {
position: absolute;
top: 2px;
left: 2px;
width: 24px;
height: 24px;
border-radius: 50%;
background: white;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: transform 0.3s;
}
.toggle-btn.active .toggle-dot {
transform: translateX(20px);
}
/* 危险操作 */
.danger-section {
padding: 20px;
}
.delete-btn {
width: 100%;
padding: 14px;
background: white;
color: #EF4444;
border: 2px solid #EF4444;
border-radius: 10px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.delete-btn:hover {
background: #EF4444;
color: white;
}
</style>

View File

@@ -0,0 +1,840 @@
<template>
<div class="avatar-manage-page">
<!-- 顶部导航 -->
<header class="page-header">
<div class="header-left">
<button class="back-btn" @click="goBack"></button>
<h1 class="page-title">数字分身管理</h1>
</div>
<!-- 右上角创建入口 -->
<div class="header-right">
<button class="icon-btn" @click="goCreate" title="创建数字分身"></button>
</div>
</header>
<!-- 用户资料头会会登录账号的头像 / 昵称 -->
<section class="profile-section">
<div class="profile-avatar">
<img v-if="me?.avatarUrl" :src="me.avatarUrl" alt="头像" referrerpolicy="no-referrer" />
<span v-else class="profile-avatar-fallback">🤖</span>
</div>
<div class="profile-meta">
<span class="profile-name">{{ me?.nickname || '会会用户' }}</span>
<span class="profile-sub">{{ me?.phone ? '手机 ' + me.phone : '已登录 · 会会账号' }}</span>
</div>
</section>
<!-- Token 余额条 -->
<section class="token-section">
<div class="token-card">
<div class="token-info">
<span class="token-label">Token 余额</span>
<span class="token-amount">{{ tokenBalance.toLocaleString() }}</span>
</div>
<button class="recharge-btn" @click="goToRecharge">充值</button>
</div>
</section>
<!-- 数字分身列表只放分身相关 -->
<section class="avatar-list-section">
<div class="section-head">
<h3 class="section-title">我的数字分身</h3>
<span class="count-badge">{{ avatars.length }}</span>
</div>
<div v-if="avatars.length" class="avatar-list">
<div class="avatar-card" v-for="a in avatars" :key="a.id">
<div class="avatar-photo">
<img v-if="a.photoUrl" :src="a.photoUrl" alt="" referrerpolicy="no-referrer" class="avatar-img" />
<div v-else class="avatar-placeholder">{{ a.emoji || '🤖' }}</div>
</div>
<div class="avatar-details">
<h2 class="avatar-name">{{ a.displayName || a.name }}</h2>
<p class="avatar-desc">{{ a.description || '暂无描述' }}</p>
<div class="avatar-status">
<span class="status-dot" :class="a.status"></span>
<span class="status-text">{{ statusText(a.status) }}</span>
</div>
</div>
<div class="avatar-actions">
<button class="chat-link" @click="goToChat(a.id)">对话</button>
<button class="edit-link" @click="goToEdit(a.id)">编辑</button>
<button class="del-link" @click="askDelete(a)">删除</button>
</div>
</div>
</div>
<div v-else class="empty-state">
<span class="empty-icon">🤖</span>
<p class="empty-text">还没有数字分身</p>
<button class="empty-create-btn" @click="goCreate">立即创建一个</button>
</div>
</section>
<!-- 分身工具入口 -->
<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>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAvatarStore } from '@/store/avatar'
import { useUserStore } from '@/store/user'
const router = useRouter()
const avatarStore = useAvatarStore()
const userStore = useUserStore()
// 当前登录会会用户的资料(头像 / 昵称)
const me = computed(() => userStore.user)
// 状态(来自 store / 后端)
const tokenBalance = computed(() => avatarStore.tokenBalance)
const avatars = computed(() => avatarStore.avatars)
// 删除确认弹窗状态
const showDelete = ref(false)
const pendingDelete = ref<any>(null)
const deleting = ref(false)
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> = {
'active': '活跃中',
'inactive': '未激活',
'training': '训练中'
}
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()
}
const goToRecharge = () => {
router.push('/token/charge')
}
const goCreate = () => {
router.push('/')
}
const goToKnowledge = () => {
router.push('/knowledge')
}
const goToEdit = (id: string) => {
router.push(`/avatar/edit/${id}`)
}
const goToChat = (id: string) => {
router.push(`/avatar/chat/${id}`)
}
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()
})
</script>
<style scoped>
.avatar-manage-page {
min-height: 100vh;
background: #F8F9FA;
padding-bottom: 80px;
}
/* 顶部导航 */
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
background: linear-gradient(135deg, #F97316 0%, #FB923C 100%);
color: white;
}
.header-left {
display: flex;
align-items: center;
gap: 12px;
}
.back-btn {
background: none;
border: none;
color: white;
font-size: 24px;
cursor: pointer;
padding: 4px;
}
.page-title {
font-size: 18px;
font-weight: 600;
margin: 0;
}
.header-right {
display: flex;
align-items: center;
}
/* 用户资料头 */
.profile-section {
display: flex;
align-items: center;
gap: 14px;
padding: 18px 20px 8px;
background: #F8F9FA;
}
.profile-avatar {
width: 54px;
height: 54px;
border-radius: 50%;
overflow: hidden;
flex-shrink: 0;
background: linear-gradient(135deg, #FFB36B, #FF7A1A);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(249, 115, 22, 0.28);
border: 2px solid #fff;
}
.profile-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.profile-avatar-fallback {
font-size: 28px;
}
.profile-meta {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.profile-name {
font-size: 17px;
font-weight: 700;
color: #18191C;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.profile-sub {
font-size: 12px;
color: #9398AE;
}
.icon-btn {
background: rgba(255, 255, 255, 0.18);
border: none;
color: white;
font-size: 18px;
width: 36px;
height: 36px;
border-radius: 10px;
cursor: pointer;
transition: background 0.2s;
}
.icon-btn:hover {
background: rgba(255, 255, 255, 0.3);
}
/* Token 余额条 */
.token-section {
padding: 16px 20px;
}
.token-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.token-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.token-label {
font-size: 13px;
color: #6B7280;
}
.token-amount {
font-size: 24px;
font-weight: 700;
color: #F97316;
}
.recharge-btn {
padding: 8px 16px;
background: #F97316;
color: white;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.2s;
}
.recharge-btn:hover {
opacity: 0.9;
}
/* 分身列表 */
.avatar-list-section {
padding: 0 20px 8px;
}
.section-head {
display: flex;
align-items: center;
gap: 8px;
margin: 8px 0 12px;
}
.section-title {
font-size: 16px;
font-weight: 600;
margin: 0;
color: #18191C;
}
.count-badge {
font-size: 12px;
font-weight: 600;
color: #F97316;
background: #FFF0E6;
border-radius: 999px;
padding: 2px 10px;
}
.avatar-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.avatar-card {
display: flex;
align-items: center;
gap: 14px;
padding: 16px;
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.avatar-photo {
width: 56px;
height: 56px;
border-radius: 50%;
overflow: hidden;
flex-shrink: 0;
background: #F3F4F6;
display: flex;
align-items: center;
justify-content: center;
}
.avatar-placeholder {
font-size: 28px;
}
.avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatar-details {
flex: 1;
min-width: 0;
}
.avatar-name {
font-size: 16px;
font-weight: 600;
margin: 0 0 4px;
color: #18191C;
}
.avatar-desc {
font-size: 13px;
color: #9398AE;
margin: 0 0 8px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.avatar-status {
display: flex;
align-items: center;
gap: 6px;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.status-dot.active {
background: #22C55E;
}
.status-dot.inactive {
background: #9398AE;
}
.status-dot.training {
background: #F59E0B;
}
.status-text {
font-size: 12px;
color: #9398AE;
}
.avatar-actions {
display: flex;
flex-direction: column;
gap: 8px;
flex-shrink: 0;
}
.chat-link {
padding: 7px 14px;
background: #FFF0E6;
color: #F97316;
border: none;
border-radius: 8px;
font-size: 13px;
cursor: pointer;
}
.edit-link {
padding: 7px 14px;
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: 7px 14px;
background: #FEF2F2;
color: #EF4444;
border: none;
border-radius: 8px;
font-size: 13px;
cursor: pointer;
transition: background 0.2s;
}
.del-link:hover {
background: #FEE2E2;
}
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.empty-icon {
font-size: 48px;
margin-bottom: 12px;
}
.empty-text {
font-size: 14px;
color: #9398AE;
margin: 0 0 16px;
}
.empty-create-btn {
padding: 10px 24px;
background: linear-gradient(135deg, #F97316, #FB923C);
color: white;
border: none;
border-radius: 10px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
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>

View File

@@ -0,0 +1,703 @@
<template>
<div class="knowledge-page">
<!-- 顶部导航 -->
<header class="page-header">
<div class="header-left">
<button class="back-btn" @click="goBack"></button>
<h1 class="page-title">知识库管理</h1>
</div>
</header>
<div v-if="!avatarId" class="empty-state">
<span class="empty-icon">🤖</span>
<p class="empty-text">请先创建数字分身后再管理知识库</p>
</div>
<template v-else>
<div class="tab-switcher" role="tablist" aria-label="知识库类型">
<button class="tab-btn" :class="{ active: activeTab === 'docs' }" role="tab" :aria-selected="activeTab === 'docs'" @click="activeTab = 'docs'">文档知识库 <b>{{ docs.length }}</b></button>
<button class="tab-btn" :class="{ active: activeTab === 'qa' }" role="tab" :aria-selected="activeTab === 'qa'" @click="activeTab = 'qa'">标准问答对 <b>{{ qaPairs.length }}</b></button>
</div>
<section v-if="activeTab === 'docs'" class="knowledge-panel">
<div class="upload-section">
<div class="upload-zone" :class="{ 'drag-over': dragOver }" @click="triggerFile" @dragover.prevent="dragOver = true" @dragleave.prevent="dragOver = false" @drop.prevent="onDrop">
<div class="upload-icon">📥</div>
<p class="upload-title">拖拽文件到此处<span class="upload-link">点击上传</span></p>
<p class="upload-hint">支持 MD / TXT / PDF / DOC / DOCX / XLSX上传后自动向量化</p>
<input ref="fileInput" type="file" accept=".md,.txt,.pdf,.doc,.docx,.xlsx" class="hidden-input" @change="onFileChange" />
</div>
<p v-if="uploading" class="uploading-text">上传并向量化中</p>
<p v-if="uploadError" class="error-text">{{ uploadError }}</p>
</div>
<div class="table-scroll">
<table class="knowledge-table">
<thead><tr><th>文档</th><th>类型</th><th>大小</th><th>状态</th><th>上传时间</th><th>操作</th></tr></thead>
<tbody v-if="docs.length">
<tr v-for="doc in docs" :key="doc.id">
<td><div class="file-cell"><span class="doc-icon">{{ fileEmoji(doc.fileType) }}</span><strong>{{ doc.filename }}</strong></div></td>
<td>{{ doc.fileType.toUpperCase() }}</td>
<td>{{ formatSize(doc.fileSize) }}</td>
<td><span class="status-pill" :class="{ pending: !doc.vectorized }">{{ doc.vectorized ? `已向量化 · ${doc.chunkCount || 0}` : '处理中' }}</span></td>
<td>{{ formatDate(doc.createdAt) }}</td>
<td><button class="table-delete" @click="removeDoc(doc.id)">删除</button></td>
</tr>
</tbody>
</table>
<div v-if="!docs.length" class="table-empty">📂 暂无文档先上传一个知识文件</div>
</div>
<section class="search-section">
<h3 class="section-title">向量检索测试</h3>
<div class="search-bar"><input v-model="query" class="search-input" placeholder="输入检索词,测试文档向量召回" @keyup.enter="doSearch" /><button class="search-btn" :disabled="searching" @click="doSearch">检索</button></div>
<div v-if="searchResults.length" class="search-results"><div class="search-item" v-for="(r, i) in searchResults" :key="i"><div class="search-head"><span class="search-name">{{ r.filename }}</span><span class="search-score">相似度 {{ r.score }}</span></div><p class="search-snippet">{{ r.snippet }}</p></div></div>
<p v-if="searched && !searchResults.length" class="empty-sub-text">未检索到相关内容先上传文档试试</p>
</section>
</section>
<section v-else class="knowledge-panel">
<div class="panel-heading"><div><h3 class="section-title">标准问答对</h3><p>命中后优先使用标准答案不调用 Qwen</p></div><button class="add-qa-btn" @click="goAddQa"> 添加</button></div>
<div class="table-scroll">
<table class="knowledge-table qa-table">
<thead><tr><th>问题</th><th>标准答案</th><th>状态</th><th>更新时间</th><th>操作</th></tr></thead>
<tbody v-if="qaPairs.length">
<tr v-for="qa in qaPairs" :key="qa.id" :class="{ 'qa-disabled': qa.enabled === false }">
<td class="question-cell">{{ qa.question }}</td><td class="answer-cell">{{ qa.answer }}</td>
<td><label class="switch" :title="qa.enabled === false ? '已停用' : '已启用'"><input type="checkbox" :checked="qa.enabled !== false" @change="toggleQa(qa, $event)" /><span class="slider"></span></label></td>
<td>{{ formatDate(qa.updatedAt || qa.createdAt) }}</td>
<td><div class="row-actions"><button class="qa-edit" @click="goEditQa(qa)">编辑</button><button class="qa-del" @click="removeQa(qa.id)">删除</button></div></td>
</tr>
</tbody>
</table>
<div v-if="!qaPairs.length" class="table-empty">💡 暂无问答对添加后分身会优先按此作答</div>
</div>
</section>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useAvatarStore } from '@/store/avatar'
import { pickAvatarId, unwrapListData } from '@/utils/avatar-page-data.js'
import {
getKnowledgeDocs,
uploadKnowledgeDoc,
deleteKnowledgeDoc,
getQAPairs,
deleteQAPair,
searchKnowledge,
setQaEnabled
} from '@/api'
const router = useRouter()
const store = useAvatarStore()
const avatarId = computed(() => pickAvatarId(store.currentAvatarId, store.avatars))
const activeTab = ref<'docs' | 'qa'>('docs')
const docs = ref<any[]>([])
const qaPairs = ref<any[]>([])
const uploading = ref(false)
const uploadError = ref('')
const dragOver = ref(false)
const fileInput = ref<HTMLInputElement | null>(null)
const query = ref('')
const searching = ref(false)
const searched = ref(false)
const searchResults = ref<any[]>([])
const loadDocs = async () => {
if (!avatarId.value) return
try {
const res: any = await getKnowledgeDocs(avatarId.value)
docs.value = unwrapListData(res)
} catch (e) {
console.error(e)
}
}
const loadQA = async () => {
if (!avatarId.value) return
try {
const res: any = await getQAPairs(avatarId.value)
qaPairs.value = unwrapListData(res)
} catch (e) {
console.error(e)
}
}
const triggerFile = () => fileInput.value?.click()
const onFileChange = (e: Event) => {
const f = (e.target as HTMLInputElement).files?.[0]
if (f) doUpload(f)
;(e.target as HTMLInputElement).value = ''
}
const onDrop = (e: DragEvent) => {
dragOver.value = false
const f = e.dataTransfer?.files?.[0]
if (f) doUpload(f)
}
const doUpload = async (file: File) => {
uploadError.value = ''
const ext = '.' + (file.name.split('.').pop() || '').toLowerCase()
if (!['.md', '.txt', '.pdf', '.doc', '.docx', '.xlsx'].includes(ext)) {
uploadError.value = `不支持的类型:${ext},仅支持 md/txt/pdf/doc/docx/xlsx`
return
}
if (!avatarId.value) {
uploadError.value = '请先创建数字分身'
return
}
uploading.value = true
try {
await uploadKnowledgeDoc(avatarId.value, file)
await loadDocs()
} catch (e: any) {
uploadError.value = e?.message || '上传失败'
} finally {
uploading.value = false
}
}
const removeDoc = async (id: string) => {
if (!avatarId.value) return
await deleteKnowledgeDoc(avatarId.value, id)
await loadDocs()
}
const doSearch = async () => {
if (!avatarId.value || !query.value.trim()) return
searching.value = true
searched.value = true
try {
const res: any = await searchKnowledge(avatarId.value, query.value.trim(), 5)
searchResults.value = unwrapListData(res)
} catch (e) {
console.error(e)
searchResults.value = []
} finally {
searching.value = false
}
}
const toggleQa = async (qa: any, e: Event) => {
const enabled = (e.target as HTMLInputElement).checked
qa.enabled = enabled
if (!avatarId.value) return
try {
await setQaEnabled(avatarId.value, qa.id, enabled)
} catch (err) {
qa.enabled = !enabled
;(e.target as HTMLInputElement).checked = !enabled
console.error('切换启用状态失败', err)
}
}
const goAddQa = () => router.push('/knowledge/qa/create')
const goEditQa = (qa: any) => router.push(`/knowledge/qa/${qa.id}/edit`)
const removeQa = async (id: string) => {
if (!avatarId.value) return
await deleteQAPair(avatarId.value, id)
await loadQA()
}
const goBack = () => router.back()
const formatSize = (n: number) => {
if (n < 1024) return n + ' B'
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'
return (n / 1024 / 1024).toFixed(1) + ' MB'
}
const formatDate = (value?: string) => value ? new Date(value).toLocaleDateString('zh-CN') : '-'
const fileEmoji = (t: string) => (['xlsx'].includes(t) ? '📊' : '📄')
onMounted(async () => {
if (!store.avatars.length) {
await store.loadAvatars()
}
await Promise.all([loadDocs(), loadQA()])
})
</script>
<style scoped>
.knowledge-page {
min-height: 100vh;
background: #F8F9FA;
padding-bottom: 80px;
overflow-x: hidden;
}
.tab-switcher {
display: flex;
gap: 8px;
padding: 16px 20px 0;
}
.tab-btn {
flex: 1;
padding: 12px 14px;
border: 1px solid #F4D8BE;
border-radius: 12px 12px 0 0;
background: #FFF8F1;
color: #8B6B58;
font-size: 14px;
cursor: pointer;
}
.tab-btn b { margin-left: 4px; font-size: 12px; color: #B0896C; }
.tab-btn.active { background: white; border-color: #F97316; color: #F97316; font-weight: 700; }
.tab-btn.active b { color: #F97316; }
.knowledge-panel { padding: 0 20px; }
.panel-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 18px 0 12px; }
.panel-heading p { margin: -5px 0 0; color: #9398AE; font-size: 12px; }
.table-scroll { max-height: 420px; overflow: auto; border: 1px solid #F1E1D3; border-radius: 14px; background: white; }
.knowledge-table { width: 100%; min-width: 720px; border-collapse: collapse; text-align: left; font-size: 13px; }
.knowledge-table th { position: sticky; top: 0; z-index: 1; padding: 12px 14px; background: #FFF8F1; color: #8B6B58; font-weight: 600; white-space: nowrap; }
.knowledge-table td { padding: 13px 14px; border-top: 1px solid #F5EEE7; color: #6B7280; vertical-align: middle; }
.knowledge-table tr.qa-disabled { opacity: .55; }
.file-cell { display: flex; align-items: center; gap: 9px; min-width: 190px; color: #27201C; }.file-cell strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.status-pill { display: inline-flex; padding: 4px 8px; border-radius: 999px; color: #15803D; background: #ECFDF3; font-size: 11px; white-space: nowrap; }.status-pill.pending { color: #B45309; background: #FFFBEB; }
.table-delete { border: 0; color: #EF4444; background: #FEF2F2; border-radius: 7px; padding: 6px 10px; cursor: pointer; }
.table-empty { padding: 48px 20px; color: #9398AE; text-align: center; }
.question-cell { min-width: 190px; max-width: 280px; color: #27201C !important; font-weight: 600; }.answer-cell { min-width: 240px; max-width: 360px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.row-actions { display: flex; gap: 6px; white-space: nowrap; }
.page-header {
display: flex;
align-items: center;
padding: 16px 20px;
background: linear-gradient(135deg, #F97316 0%, #FB923C 100%);
color: white;
}
.header-left {
display: flex;
align-items: center;
gap: 12px;
}
.back-btn {
background: none;
border: none;
color: white;
font-size: 24px;
cursor: pointer;
padding: 4px;
}
.page-title {
font-size: 18px;
font-weight: 600;
margin: 0;
}
/* 上传区 */
.upload-section {
padding: 16px 20px;
}
.upload-zone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
padding: 28px 20px;
background: white;
border: 2px dashed #FCD9B6;
border-radius: 12px;
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
}
.upload-zone.drag-over {
border-color: #F97316;
background: #FFF7EF;
}
.upload-icon {
font-size: 32px;
}
.upload-title {
font-size: 14px;
color: #18191C;
margin: 0;
}
.upload-link {
color: #F97316;
font-weight: 600;
}
.upload-hint {
font-size: 12px;
color: #9398AE;
margin: 0;
}
.hidden-input {
display: none;
}
.uploading-text {
font-size: 13px;
color: #F97316;
text-align: center;
margin: 10px 0 0;
}
.error-text {
font-size: 13px;
color: #EF4444;
text-align: center;
margin: 10px 0 0;
}
/* 文档列表 */
.docs-section,
.qa-section,
.search-section {
padding: 0 20px 16px;
}
.section-title {
font-size: 16px;
font-weight: 600;
margin: 0 0 12px;
color: #18191C;
}
.doc-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.doc-item {
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);
}
.doc-icon {
font-size: 22px;
flex-shrink: 0;
}
.doc-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.doc-name {
font-size: 14px;
font-weight: 600;
color: #18191C;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.doc-meta {
font-size: 12px;
color: #9398AE;
}
.badge-vec {
align-self: flex-start;
margin-top: 2px;
font-size: 11px;
color: #16A34A;
background: #ECFDF3;
border-radius: 6px;
padding: 2px 8px;
}
.badge-vec.pending {
color: #D97706;
background: #FFFAEB;
}
.doc-del {
padding: 6px 12px;
background: #FEF2F2;
color: #EF4444;
border: none;
border-radius: 8px;
font-size: 13px;
cursor: pointer;
flex-shrink: 0;
}
/* 检索测试 */
.search-bar {
display: flex;
gap: 10px;
}
.search-input {
flex: 1;
border: 1px solid #E5E7EB;
border-radius: 8px;
padding: 10px 12px;
font-size: 14px;
color: #18191C;
box-sizing: border-box;
}
.search-input:focus {
outline: none;
border-color: #F97316;
}
.search-btn {
width: 72px;
height: 38px;
background: #F97316;
color: white;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
text-align: center;
flex-shrink: 0;
}
.search-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.search-results {
margin-top: 12px;
display: flex;
flex-direction: column;
gap: 10px;
}
.search-item {
padding: 12px 14px;
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.search-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.search-name {
font-size: 13px;
font-weight: 600;
color: #18191C;
}
.search-score {
font-size: 12px;
color: #F97316;
}
.search-snippet {
font-size: 12px;
color: #6B7280;
margin: 0;
line-height: 1.5;
}
/* 问答对 */
.qa-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.qa-head .section-title {
margin: 0;
}
.add-qa-btn {
width: 72px;
height: 38px;
background: #F97316;
color: white;
border: none;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
text-align: center;
flex-shrink: 0;
}
.qa-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.qa-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);
}
.qa-item.qa-disabled {
opacity: 0.55;
}
.qa-content {
flex: 1;
min-width: 0;
}
.qa-question {
font-size: 14px;
font-weight: 600;
color: #18191C;
margin: 0 0 6px;
line-height: 1.4;
}
.qa-answer {
font-size: 13px;
color: #6B7280;
margin: 0;
line-height: 1.5;
}
.qa-actions {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6px;
flex-shrink: 0;
}
.qa-edit {
padding: 5px 12px;
background: #FFF0E6;
color: #F97316;
border: none;
border-radius: 8px;
font-size: 12px;
cursor: pointer;
}
.qa-del {
padding: 5px 12px;
background: #FEF2F2;
color: #EF4444;
border: none;
border-radius: 8px;
font-size: 12px;
cursor: pointer;
}
/* 开关 */
.switch {
position: relative;
display: inline-block;
width: 40px;
height: 22px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
inset: 0;
background: #E5E7EB;
border-radius: 22px;
transition: background 0.2s;
}
.slider::before {
content: "";
position: absolute;
height: 16px;
width: 16px;
left: 3px;
top: 3px;
background: white;
border-radius: 50%;
transition: transform 0.2s;
}
.switch input:checked + .slider {
background: #F97316;
}
.switch input:checked + .slider::before {
transform: translateX(18px);
}
/* 空状态 */
.empty-state,
.empty-sub {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.empty-state {
margin: 16px 20px;
}
.docs-section .empty-sub,
.qa-section .empty-sub {
margin: 16px 0 0;
}
/* 上传区空态无需额外 margin.upload-zone 已占满 section content 宽度 */
.empty-icon,
.empty-sub-icon {
font-size: 44px;
margin-bottom: 12px;
}
.empty-text,
.empty-sub-text {
font-size: 14px;
color: #9398AE;
margin: 0;
text-align: center;
}
</style>