Compare commits

..
14 changed files with 274 additions and 331 deletions
+1 -1
View File
@@ -188,7 +188,7 @@ class KnowledgeDoc(Base):
file_type = Column(String, default="") # pdf | doc | docx | xlsx
file_size = Column(Integer, default=0)
file_url = Column(String, default="")
status = Column(String, default="uploaded") # uploaded | parsing | ready | failed
status = Column(String, default="uploaded") # uploaded | parsing | ready
vectorized = Column(Boolean, default=False) # 是否已向量化
embedding_model = Column(String, default="") # 向量模型标识
chunk_count = Column(Integer, default=0) # 切片数量
+108 -17
View File
@@ -34,6 +34,19 @@ QA_SEMANTIC_THRESHOLD = 0.72
QA_MATCH_MARGIN = 0.06
KNOWLEDGE_MIN_SCORE = float(os.getenv("KNOWLEDGE_MIN_SCORE", "0.42"))
_WRITING_SYSTEM_PATTERNS = {
"han": re.compile(r"[\u3400-\u4dbf\u4e00-\u9fff]"),
"latin": re.compile(r"[A-Za-z\u00c0-\u024f]"),
"cyrillic": re.compile(r"[\u0400-\u052f]"),
"arabic": re.compile(r"[\u0600-\u06ff]"),
"hebrew": re.compile(r"[\u0590-\u05ff]"),
"devanagari": re.compile(r"[\u0900-\u097f]"),
"thai": re.compile(r"[\u0e00-\u0e7f]"),
"greek": re.compile(r"[\u0370-\u03ff]"),
}
_JAPANESE_KANA = re.compile(r"[\u3040-\u30ff]")
_KOREAN_HANGUL = re.compile(r"[\uac00-\ud7af\u1100-\u11ff]")
class ChatMessage(BaseModel):
role: str = Field(pattern="^(user|assistant)$")
@@ -70,6 +83,30 @@ def _normalize_question(value: str) -> str:
return value.translate(str.maketrans("", "", string.punctuation + ",。!?;:、()【】「」‘’“”《》"))
def _dominant_writing_system(value: str) -> str:
value = value or ""
if _JAPANESE_KANA.search(value):
return "japanese"
if _KOREAN_HANGUL.search(value):
return "korean"
counts = {
name: len(pattern.findall(value))
for name, pattern in _WRITING_SYSTEM_PATTERNS.items()
}
name, count = max(counts.items(), key=lambda item: item[1])
return name if count else "unknown"
def _qa_requires_language_adaptation(question: str, answer: str) -> bool:
question_system = _dominant_writing_system(question)
answer_system = _dominant_writing_system(answer)
return (
question_system != "unknown"
and answer_system != "unknown"
and question_system != answer_system
)
def _canonicalize_question(value: str) -> str:
value = _normalize_question(value)
replacements = (
@@ -189,7 +226,14 @@ def _config(avatar: Avatar) -> dict:
}
def _build_prompt(avatar: Avatar, history: list[Any], question: str, knowledge_hits: list[dict]) -> list[dict]:
def _build_prompt(
avatar: Avatar,
history: list[Any],
question: str,
knowledge_hits: list[dict],
*,
standard_answer: str = "",
) -> list[dict]:
config = _config(avatar)
description = (getattr(avatar, "description", "") or "").strip()
knowledge = "\n".join(
@@ -210,7 +254,7 @@ def _build_prompt(avatar: Avatar, history: list[Any], question: str, knowledge_h
profile = ";".join(f"{label}:{value}" for label, value in profile_items)
system = (
f"你的专业或服务范围是:「{description or '未设置'}」。"
"请基于已提供的知识库回答,不要编造事实;"
"请基于已提供的可靠资料回答,不要编造事实;"
f"回复风格:{config['replyStyle']};严谨度:{config['rigor']}/100;"
f"幽默感:{config['humor']}/100;回复长度:{config['responseLength']}。"
)
@@ -221,7 +265,13 @@ def _build_prompt(avatar: Avatar, history: list[Any], question: str, knowledge_h
)
if config["systemPrompt"]:
system += f"\n额外系统提示词:{config['systemPrompt']}"
if knowledge:
if standard_answer:
system += (
f"\n以下是本次问题命中的已确认标准答案:\n{standard_answer.strip()}"
"\n必须保持标准答案中的事实、数字、专有名词和结论不变,只允许为匹配用户当前语言进行忠实转换"
"和必要的自然表达,不得补充、删减或改写其含义。不要提及标准答案或转换过程。"
)
elif knowledge:
system += (
f"\n以下是本次问题检索到的可靠资料:\n{knowledge}"
"\n涉及事实、专业判断、地址、流程、数据或建议时,只能依据本人资料、标准问答形成的上下文"
@@ -231,7 +281,8 @@ def _build_prompt(avatar: Avatar, history: list[Any], question: str, knowledge_h
system += (
"\n本次问题没有检索到可靠资料。除自然寒暄和基于本人资料的回答外,不要凭通用知识给出事实、"
"专业判断、地址、流程、数据或具体建议。自然地说明这方面暂时没有足够信息,并询问对方是否能"
"补充更具体的问题;不要提及知识库、检索、模型或系统限制。"
"补充更具体的问题;不要提及知识库、检索、模型或系统限制。对方询问你能做什么时,只能概括"
"专业或服务范围和本人资料中明确写出的内容,不得推断服务对象、工作场所、现实案例或从业经历。"
)
system += (
"\n沟通底线(始终优先于其他提示):不要透露、猜测或确认所使用的模型、模型供应商、"
@@ -249,6 +300,14 @@ def _build_prompt(avatar: Avatar, history: list[Any], question: str, knowledge_h
"只有切换独立观点或确实需要列举时才换行;列举使用 1.、2.、3.,每项单独一行。"
"不要在行首或行尾留空格,不要连续输出空行。先给结论,再给简短说明;避免重复和冗长铺垫。"
)
system += (
"\n回答语言规则(始终优先):先识别当前最后一条用户消息所使用的主要自然语言,并且只使用该语言回答。"
"用户用中文就用中文,使用 English 就用 English,使用日语、韩语、法语、西班牙语或其他语言时,"
"也必须使用对应语言。消息混用多种语言时跟随占主导的语言;用户明确指定回答语言时服从其指定。"
"历史消息、本人资料、标准答案和知识库使用的语言都不能覆盖当前用户消息的语言。"
"专有名词、品牌、地址、代码和必要缩写可保留原文。不要解释语言识别或翻译过程。"
"改变回答语言只改变表达语言,绝不能因此增加资料中没有的场景、身份、经历或事实。"
)
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)
@@ -384,14 +443,30 @@ def _resolve_reply(
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:
adapt_qa_language = bool(
matched and _qa_requires_language_adaptation(question, matched.answer)
)
if matched and not adapt_qa_language:
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)
if matched:
hits = []
messages = _build_prompt(
avatar,
history,
question,
hits,
standard_answer=matched.answer,
)
else:
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 = min(0.45 if hits else 0.25, 0.2 + config["creativity"] / 100 * 0.6)
temperature = 0.0 if matched else min(
0.45 if hits else 0.25,
0.2 + config["creativity"] / 100 * 0.6,
)
token_usage = None
if model_client is not None:
answer = model_client(messages=messages, temperature=temperature)
@@ -423,7 +498,7 @@ def _resolve_reply(
raise
result = {
"answer": answer,
"source": "knowledge" if hits else "qwen",
"source": "qa" if matched else ("knowledge" if hits else "qwen"),
"references": hits,
}
if token_usage:
@@ -442,14 +517,32 @@ def _stream_reply(
):
qa_pairs = db.query(QAPair).filter(QAPair.avatar_id == avatar.id).all()
matched = _match_standard_qa(question, qa_pairs)
if matched:
adapt_qa_language = bool(
matched and _qa_requires_language_adaptation(question, matched.answer)
)
messages, reservation = [], None
if matched and not adapt_qa_language:
source, references, chunks = "qa", [], _iter_text_chunks(matched.answer)
else:
references = _search_knowledge(db, avatar.id, question)
source = "knowledge" if references else "qwen"
if matched:
references = []
source = "qa"
messages = _build_prompt(
avatar,
history,
question,
references,
standard_answer=matched.answer,
)
else:
references = _search_knowledge(db, avatar.id, question)
source = "knowledge" if references else "qwen"
messages = _build_prompt(avatar, history, question, references)
config = _config(avatar)
temperature = min(0.45 if references else 0.25, 0.2 + config["creativity"] / 100 * 0.6)
messages = _build_prompt(avatar, history, question, references)
temperature = 0.0 if matched else min(
0.45 if references else 0.25,
0.2 + config["creativity"] / 100 * 0.6,
)
model_config = get_chat_model_config()
reservation = reserve_avatar_tokens(
db,
@@ -460,8 +553,6 @@ def _stream_reply(
model_config.max_tokens,
)
chunks = _iter_qwen_stream(messages, temperature, model_config)
if matched:
messages, reservation = [], None
if public:
source, references = "public", []
+24 -44
View File
@@ -1,6 +1,5 @@
import os
import json
import logging
import uuid
from datetime import datetime, timezone
@@ -14,7 +13,6 @@ from responses import ok, fail
import embeddings
router = APIRouter()
logger = logging.getLogger(__name__)
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
UPLOAD_DIR = os.path.abspath(os.getenv("UPLOAD_DIR", os.path.join(BASE_DIR, "uploads")))
@@ -71,15 +69,6 @@ def list_docs(avatar_id: str, authorization: str = Header(None), db: Session = D
.order_by(KnowledgeDoc.created_at.desc())
.all()
)
# Older synchronous uploads could be interrupted after persisting "parsing".
# New uploads are committed only after indexing finishes, so these rows are stale.
stale_docs = [doc for doc in docs if doc.status == "parsing"]
if stale_docs:
for doc in stale_docs:
doc.status = "failed"
doc.vectorized = False
doc.chunk_count = 0
db.commit()
return ok([_doc_payload(d) for d in docs])
@@ -99,7 +88,6 @@ async def upload_doc(avatar_id: str, file: UploadFile = File(...), authorization
with open(path, "wb") as f:
f.write(content)
doc = KnowledgeDoc(
id=uuid.uuid4().hex,
avatar_id=avatar_id,
filename=file.filename,
file_type=ext.lstrip("."),
@@ -107,47 +95,39 @@ async def upload_doc(avatar_id: str, file: UploadFile = File(...), authorization
file_url=f"/api/files/{avatar_id}/{stored}",
status="parsing",
)
db.add(doc)
db.commit()
db.refresh(doc)
# Complete extraction and embedding before the first database commit so a
# process restart cannot leave a permanent "parsing" row behind.
# 向量化:抽取文本 -> 分块 -> 调第三方/本地嵌入 -> 存切片
try:
text = embeddings.extract_text(path, ext)
chunks = embeddings.chunk_text(text)
if not chunks:
raise ValueError("文档没有可建立索引的文字内容")
vectors = embeddings.embed(chunks)
if len(vectors) != len(chunks):
raise ValueError("向量服务返回数量与文档分段不一致")
doc.vectorized = True
doc.embedding_model = embeddings.MODEL
doc.chunk_count = len(chunks)
doc.vectorized_at = datetime.now(timezone.utc)
doc.status = "ready"
db.add(doc)
for i, (chunk, vector) in enumerate(zip(chunks, vectors)):
db.add(
KnowledgeChunk(
doc_id=doc.id,
avatar_id=avatar_id,
content=chunk,
vector=json.dumps(vector),
chunk_index=i,
embedding_model=embeddings.MODEL,
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 exc:
db.rollback()
doc.status = "failed"
doc.vectorized = False
doc.embedding_model = ""
doc.chunk_count = 0
doc.vectorized_at = None
db.add(doc)
except Exception as e:
print("vectorize failed:", e)
doc.status = "ready" # 上传成功但向量化失败,仍可展示
db.commit()
db.refresh(doc)
logger.exception("knowledge vectorization failed for %s: %s", doc.id, exc)
return ok(_doc_payload(doc))
@@ -201,13 +201,20 @@ class TakeoverService:
def _forget_boxim_session(self, user_id: str):
self._sessions.pop(user_id, None)
def _disable_after_connection_failure(
def _record_connection_failure(
self,
db: Session,
avatar: Avatar,
cursor: TakeoverCursor,
message: str,
*,
disable_takeover: bool,
):
cursor.last_error = message
cursor.last_polled_at = self.now()
if not disable_takeover:
return
permissions = (avatar.config or {}).get("authorizationPermissions", [])
avatar.config = {
**(avatar.config or {}),
@@ -217,8 +224,6 @@ class TakeoverService:
if permission != TAKEOVER_PERMISSION
],
}
cursor.last_error = message
cursor.last_polled_at = self.now()
tasks = (
db.query(TakeoverReplyTask)
.filter(
@@ -245,11 +250,12 @@ class TakeoverService:
db.add(cursor)
db.flush()
if not user or not user.huihui_token:
self._disable_after_connection_failure(
self._record_connection_failure(
db,
avatar,
cursor,
"请重新登录会会生产账号后再开启主动接管",
disable_takeover=True,
)
db.commit()
return False
@@ -268,11 +274,24 @@ class TakeoverService:
if isinstance(exc, BoxIMError) and exc.auth_error:
self._forget_boxim_session(user.id)
message = "BOXIM 授权已失效,请重新登录会会生产账号"
disable_takeover = True
else:
message = f"BOXIM 暂时连接失败:{str(exc)[:160]}"
self._disable_after_connection_failure(db, avatar, cursor, message)
disable_takeover = False
self._record_connection_failure(
db,
avatar,
cursor,
message,
disable_takeover=disable_takeover,
)
db.commit()
logger.warning("BOXIM sync failed for avatar %s: %s", avatar.id, exc)
logger.warning(
"BOXIM sync failed for avatar %s (will_retry=%s): %s",
avatar.id,
not disable_takeover,
exc,
)
return False
messages.sort(key=lambda item: (_numeric_id(item.get("id")), item.get("sendTime") or 0))
@@ -5,7 +5,15 @@ from unittest.mock import Mock
from fastapi import HTTPException
from models import Avatar, User
from routers.chat import _build_prompt, _iter_text_chunks, _match_standard_qa, _public_avatar_payload, _require_owned_avatar, _resolve_reply
from routers.chat import (
_build_prompt,
_iter_text_chunks,
_match_standard_qa,
_public_avatar_payload,
_qa_requires_language_adaptation,
_require_owned_avatar,
_resolve_reply,
)
class ChatOrchestrationTests(unittest.TestCase):
@@ -50,6 +58,34 @@ class ChatOrchestrationTests(unittest.TestCase):
self.assertEqual(result["answer"], "标准地址")
fake_model.assert_not_called()
def test_cross_language_qa_is_faithfully_adapted_by_model(self):
fake_model = Mock(return_value="Our address is Test Road 1.")
fake_search = Mock(return_value=[])
result = _resolve_reply(
None,
self.avatar,
"Where is your office?",
[],
qa_pairs=[SimpleNamespace(question="Where is your office?", answer="地址是测试路1号。", enabled=True)],
search_fn=fake_search,
model_client=fake_model,
)
self.assertEqual(result["source"], "qa")
self.assertEqual(result["answer"], "Our address is Test Road 1.")
self.assertEqual(fake_model.call_args.kwargs["temperature"], 0.0)
system = fake_model.call_args.kwargs["messages"][0]["content"]
self.assertIn("已确认标准答案", system)
self.assertIn("地址是测试路1号", system)
self.assertIn("只使用该语言回答", system)
fake_search.assert_not_called()
def test_qa_language_adaptation_detects_common_writing_system_changes(self):
self.assertTrue(_qa_requires_language_adaptation("Hello", "你好"))
self.assertTrue(_qa_requires_language_adaptation("こんにちは", "你好"))
self.assertTrue(_qa_requires_language_adaptation("안녕하세요", "你好"))
self.assertFalse(_qa_requires_language_adaptation("你好", "您好"))
def test_conversational_paraphrase_matches_standard_qa(self):
for question in ("请问一下,你们公司在哪里呀?", "请问去你们那边怎么走"):
with self.subTest(question=question):
@@ -106,6 +142,9 @@ class ChatOrchestrationTests(unittest.TestCase):
self.assertIn("像熟人之间微信聊天一样", messages[0]["content"])
self.assertIn("不隶属于任何机构", messages[0]["content"])
self.assertIn("不要连续输出空行", messages[0]["content"])
self.assertIn("回答语言规则", messages[0]["content"])
self.assertIn("当前最后一条用户消息", messages[0]["content"])
self.assertIn("历史消息", messages[0]["content"])
def test_prompt_blocks_ungrounded_factual_answers(self):
messages = _build_prompt(self.avatar, [], "聊聊国际新闻", [])
@@ -113,6 +152,8 @@ class ChatOrchestrationTests(unittest.TestCase):
self.assertIn("没有检索到可靠资料", system)
self.assertIn("不要凭通用知识", system)
self.assertIn("不要提及知识库", system)
self.assertIn("不得推断服务对象", system)
self.assertIn("工作场所", system)
def test_public_avatar_payload_excludes_internal_configuration(self):
payload = _public_avatar_payload(self.avatar)
@@ -2,17 +2,9 @@ from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
from fastapi.testclient import TestClient
from database import SessionLocal
from main import app
from models import KnowledgeChunk, KnowledgeDoc
from routers.knowledge import _doc_payload
client = TestClient(app)
def test_doc_payload_reports_whether_the_persisted_file_exists(tmp_path: Path):
avatar_id = "avatar-1"
stored_name = "knowledge.md"
@@ -29,66 +21,3 @@ def test_doc_payload_reports_whether_the_persisted_file_exists(tmp_path: Path):
assert _doc_payload(doc)["filePresent"] is False
stored_file.write_text("knowledge", encoding="utf-8")
assert _doc_payload(doc)["filePresent"] is True
def test_upload_marks_vectorization_failure_instead_of_staying_processing(
tmp_path: Path,
authorization_context,
):
context = authorization_context
with (
patch("routers.knowledge.UPLOAD_DIR", str(tmp_path)),
patch("routers.knowledge.embeddings.embed", side_effect=RuntimeError("provider unavailable")),
):
response = client.post(
f"/api/avatar/{context['avatar'].id}/knowledge/docs",
headers=context["owner_headers"],
files={"file": ("knowledge.md", b"# Knowledge\n\nTest content", "text/markdown")},
)
payload = response.json()["data"]
assert payload["status"] == "failed"
assert payload["vectorized"] is False
assert payload["chunkCount"] == 0
db = SessionLocal()
try:
stored = db.query(KnowledgeDoc).filter(KnowledgeDoc.id == payload["id"]).one()
assert stored.status == "failed"
assert db.query(KnowledgeChunk).filter(KnowledgeChunk.doc_id == stored.id).count() == 0
db.delete(stored)
db.commit()
finally:
db.close()
def test_markdown_upload_commits_ready_document_and_chunks_together(
tmp_path: Path,
authorization_context,
):
context = authorization_context
with (
patch("routers.knowledge.UPLOAD_DIR", str(tmp_path)),
patch("routers.knowledge.embeddings.embed", return_value=[[1.0, 0.0]]),
):
response = client.post(
f"/api/avatar/{context['avatar'].id}/knowledge/docs",
headers=context["owner_headers"],
files={"file": ("knowledge.md", b"# Knowledge\n\nTest content", "text/markdown")},
)
payload = response.json()["data"]
assert payload["status"] == "ready"
assert payload["vectorized"] is True
assert payload["chunkCount"] == 1
db = SessionLocal()
try:
stored = db.query(KnowledgeDoc).filter(KnowledgeDoc.id == payload["id"]).one()
assert stored.status == "ready"
assert db.query(KnowledgeChunk).filter(KnowledgeChunk.doc_id == stored.id).count() == 1
db.query(KnowledgeChunk).filter(KnowledgeChunk.doc_id == stored.id).delete()
db.delete(stored)
db.commit()
finally:
db.close()
@@ -291,5 +291,41 @@ async def test_connection_failure_disables_takeover_and_stops_retrying(service_c
boxim.exchange_access_token.assert_awaited_once_with("prod-huihui-token")
@pytest.mark.asyncio
async def test_transient_connection_failure_keeps_takeover_and_recovers(service_context):
session_factory, service, boxim, _ = service_context
boxim.exchange_access_token = AsyncMock(
side_effect=[
BoxIMError("连接超时"),
{"accessToken": "box-token", "accessTokenExpiresIn": 3600},
]
)
await service.poll_messages()
db = session_factory()
try:
avatar = db.query(Avatar).one()
cursor = db.query(TakeoverCursor).one()
assert "takeover" in avatar.config["authorizationPermissions"]
assert cursor.initialized is False
assert "暂时连接失败" in cursor.last_error
finally:
db.close()
await service.poll_messages()
db = session_factory()
try:
avatar = db.query(Avatar).one()
cursor = db.query(TakeoverCursor).one()
assert "takeover" in avatar.config["authorizationPermissions"]
assert cursor.initialized is True
assert cursor.last_error == ""
finally:
db.close()
assert boxim.exchange_access_token.await_count == 2
def test_plain_text_reply_removes_markdown_and_empty_lines():
assert _plain_text_reply("## 建议\n\n**不能自行用药**\n`必要时就医`") == "建议\n不能自行用药\n必要时就医"
+4 -13
View File
@@ -10,7 +10,6 @@ import {
type SmsLoginResult,
type UserProfile
} from '@/api'
import { clearHuihuiEmbeddedMode, markHuihuiEmbeddedMode } from '@/utils/embed-mode'
const TOKEN_KEY = 'hh_app_token'
const USER_KEY = 'hh_app_user'
@@ -71,23 +70,16 @@ export const useUserStore = defineStore('smsuser', () => {
// 短信登录
const login = async (phone: string, code: string) => {
const result = await loginBySms(phone, code)
clearHuihuiEmbeddedMode()
return acceptLogin(result)
return acceptLogin(await loginBySms(phone, code))
}
// 账号密码登录
const loginByPwd = async (account: string, password: string) => {
const result = await loginByPassword(account, password)
clearHuihuiEmbeddedMode()
return acceptLogin(result)
return acceptLogin(await loginByPassword(account, password))
}
const loginByToken = async (huihuiToken: string) => {
const result = await loginByHuihuiToken(huihuiToken)
markHuihuiEmbeddedMode()
return acceptLogin(result)
}
const loginByToken = async (huihuiToken: string) =>
acceptLogin(await loginByHuihuiToken(huihuiToken))
// 退出
const logout = async () => {
@@ -96,7 +88,6 @@ export const useUserStore = defineStore('smsuser', () => {
} catch {
/* 忽略网络错误,本地清除即可 */
}
clearHuihuiEmbeddedMode()
clearSession()
}
@@ -1,13 +0,0 @@
const HUIHUI_EMBED_MODE_KEY = 'hh_huihui_embed_mode'
export function markHuihuiEmbeddedMode(): void {
sessionStorage.setItem(HUIHUI_EMBED_MODE_KEY, '1')
}
export function clearHuihuiEmbeddedMode(): void {
sessionStorage.removeItem(HUIHUI_EMBED_MODE_KEY)
}
export function isHuihuiEmbeddedMode(): boolean {
return sessionStorage.getItem(HUIHUI_EMBED_MODE_KEY) === '1'
}
@@ -1,6 +1,6 @@
<template>
<div class="authorization-page" :class="{ embedded: isEmbedded }">
<header v-if="!isEmbedded" class="page-header">
<div class="authorization-page">
<header class="page-header">
<button class="back-button" type="button" aria-label="返回数字分身管理" @click="goBack">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="m15 18-6-6 6-6" />
@@ -95,9 +95,6 @@
</main>
<footer v-if="activeAvatarId" class="save-area">
<button v-if="isEmbedded" class="footer-back-button" type="button" :disabled="saving" @click="goBack">
返回
</button>
<button class="save-button" type="button" :disabled="loading || saving" @click="saveSettings()">
<span v-if="saving" class="saving-spinner" aria-hidden="true"></span>
{{ saving ? '保存中...' : '保存授权设置' }}
@@ -122,7 +119,6 @@ import {
} from '@/api'
import { useAvatarStore } from '@/store/avatar'
import { pickScopedAvatarId } from '@/utils/avatar-page-data.js'
import { isHuihuiEmbeddedMode } from '@/utils/embed-mode'
type PermissionState = Record<AvatarPermission, boolean>
@@ -130,7 +126,6 @@ const router = useRouter()
const route = useRoute()
const avatarStore = useAvatarStore()
const avatarId = computed(() => pickScopedAvatarId(route.params.avatarId, avatarStore.currentAvatarId, avatarStore.avatars))
const isEmbedded = isHuihuiEmbeddedMode()
const permissionItems: Array<{
key: AvatarPermission
@@ -399,10 +394,6 @@ svg {
padding: 0 20px;
}
.authorization-page.embedded .page-content {
padding-top: 16px;
}
.permission-intro {
min-height: 96px;
padding: 15px 16px 14px;
@@ -608,15 +599,12 @@ svg {
bottom: 0;
width: min(100%, 390px);
padding: 12px 20px calc(20px + env(safe-area-inset-bottom));
display: flex;
gap: 10px;
background: linear-gradient(to bottom, rgba(250, 250, 250, 0), #fafafa 20%, #fafafa 100%);
transform: translateX(-50%);
}
.save-button {
min-width: 0;
flex: 1;
width: 100%;
height: 48px;
display: flex;
align-items: center;
@@ -632,20 +620,6 @@ svg {
cursor: pointer;
}
.footer-back-button {
flex: 0 0 96px;
height: 48px;
border: 1px solid #eadfd6;
border-radius: 24px;
color: #6f665f;
background: #fff;
font-size: 14px;
font-weight: 500;
cursor: pointer;
}
.footer-back-button:disabled { opacity: .58; }
.save-button:disabled {
opacity: .68;
}
+15 -56
View File
@@ -1,10 +1,12 @@
<template>
<div class="edit-avatar-page">
<!-- 顶部导航 -->
<header v-if="!isEmbedded" class="page-header">
<header class="page-header">
<button class="back-btn" @click="goBack">‹</button>
<h1 class="page-title">分身微调</h1>
<span class="header-spacer" aria-hidden="true"></span>
<button class="save-btn" :disabled="loading || saving || uploadingPhoto" @click="saveChanges">
{{ saving ? '保存中...' : '保存' }}
</button>
</header>
<div v-if="loading" class="status-banner">加载中...</div>
@@ -157,13 +159,6 @@
{{ deleting ? '删除中...' : '删除数字分身' }}
</button>
</section>
<footer class="edit-action-bar">
<button class="action-back-btn" type="button" :disabled="saving" @click="goBack">返回</button>
<button class="action-save-btn" type="button" :disabled="loading || saving || uploadingPhoto" @click="saveChanges">
{{ saving ? '保存中...' : '保存修改' }}
</button>
</footer>
</div>
</template>
@@ -173,13 +168,11 @@ import { useRoute, useRouter } from 'vue-router'
import { deleteAvatar as apiDeleteAvatar, getAvatarDetail, updateAvatar, uploadAvatarPhoto } from '@/api'
import { useAvatarStore } from '@/store/avatar'
import { buildAvatarUpdatePayload, normalizeAvatarEditForm } from '@/utils/avatar-page-data.js'
import { isHuihuiEmbeddedMode } from '@/utils/embed-mode'
const router = useRouter()
const route = useRoute()
const avatarStore = useAvatarStore()
const avatarId = route.params.id as string
const isEmbedded = isHuihuiEmbeddedMode()
// 表单数据
const formData = reactive({
@@ -295,7 +288,7 @@ onMounted(async () => {
.edit-avatar-page {
min-height: 100vh;
background: #F8F9FA;
padding-bottom: calc(104px + env(safe-area-inset-bottom));
padding-bottom: 40px;
}
/* 顶部导航 */
@@ -338,7 +331,16 @@ onMounted(async () => {
color: #B91C1C;
}
.header-spacer { width: 40px; }
.save-btn {
background: #F97316;
color: white;
border: none;
padding: 8px 20px;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
}
/* 头像上传 */
.photo-section {
@@ -578,47 +580,4 @@ onMounted(async () => {
background: #EF4444;
color: white;
}
.edit-action-bar {
position: fixed;
z-index: 30;
left: 0;
right: 0;
bottom: 0;
display: flex;
gap: 12px;
padding: 12px 20px calc(14px + env(safe-area-inset-bottom));
border-top: 1px solid rgba(229, 231, 235, .9);
background: rgba(248, 249, 250, .96);
box-shadow: 0 -8px 24px rgba(56, 38, 24, .06);
backdrop-filter: blur(12px);
}
.action-back-btn,
.action-save-btn {
height: 48px;
border-radius: 14px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
}
.action-back-btn {
flex: 0 0 104px;
border: 1px solid #E4E0DC;
color: #655E58;
background: #fff;
}
.action-save-btn {
min-width: 0;
flex: 1;
border: 0;
color: #fff;
background: linear-gradient(105deg, #F79A38, #F97316);
box-shadow: 0 8px 18px rgba(249, 115, 22, .18);
}
.action-back-btn:disabled,
.action-save-btn:disabled { opacity: .6; cursor: not-allowed; }
</style>
+8 -37
View File
@@ -1,11 +1,15 @@
<template>
<div class="avatar-manage-page">
<!-- 顶部导航 -->
<header v-if="!isEmbedded" class="page-header">
<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>
<!-- 用户资料头(会会登录账号的头像 / 昵称) -->
@@ -35,13 +39,8 @@
<!-- 数字分身列表(只放分身相关) -->
<section class="avatar-list-section">
<div class="section-head">
<div class="section-heading-copy">
<h3 class="section-title">我的数字分身</h3>
<span class="count-badge">{{ avatars.length }}</span>
</div>
<button class="section-create-btn" type="button" @click="goCreate">
<span aria-hidden="true">+</span> 添加分身
</button>
<h3 class="section-title">我的数字分身</h3>
<span class="count-badge">{{ avatars.length }}</span>
</div>
<div v-if="avatars.length" class="avatar-list">
@@ -88,12 +87,10 @@ import { useRouter } from 'vue-router'
import { useAvatarStore } from '@/store/avatar'
import { useUserStore } from '@/store/user'
import { createAvatarShareLink } from '@/api'
import { isHuihuiEmbeddedMode } from '@/utils/embed-mode'
const router = useRouter()
const avatarStore = useAvatarStore()
const userStore = useUserStore()
const isEmbedded = isHuihuiEmbeddedMode()
// 临时产品开关:余额卡片代码保留,后续改为 true 即可恢复展示。
const SHOW_POINTS_BALANCE_CARD = false
@@ -357,36 +354,10 @@ onMounted(() => {
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
gap: 8px;
margin: 8px 0 12px;
}
.section-heading-copy {
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
}
.section-create-btn {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 3px;
padding: 8px 12px;
border: 1px solid #FED7B5;
border-radius: 999px;
color: #E9650C;
background: #FFF7ED;
font-size: 12px;
font-weight: 650;
cursor: pointer;
}
.section-create-btn span { font-size: 17px; line-height: 1; }
.section-create-btn:active { background: #FFEDD5; }
.section-title {
font-size: 16px;
font-weight: 600;
@@ -1,7 +1,7 @@
<template>
<div class="knowledge-page" :class="{ embedded: isEmbedded }">
<div class="knowledge-page">
<!-- 顶部导航 -->
<header v-if="!isEmbedded" class="page-header">
<header class="page-header">
<div class="header-left">
<button class="back-btn" @click="goBack">‹</button>
<h1 class="page-title">知识库管理</h1>
@@ -23,7 +23,7 @@
<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-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>
@@ -37,10 +37,10 @@
<div class="card-content">
<div class="card-title-row">
<strong>{{ doc.filename }}</strong>
<span class="status-pill" :class="documentState(doc).tone">{{ documentState(doc).label }}</span>
<span class="status-pill" :class="{ pending: !doc.vectorized && doc.filePresent !== false, missing: doc.filePresent === false }">{{ doc.filePresent === false ? '文件缺失' : (doc.vectorized ? '已入库' : '处理中') }}</span>
</div>
<p class="card-meta">{{ doc.fileType.toUpperCase() }} · {{ formatSize(doc.fileSize) }} · {{ formatDate(doc.createdAt) }}</p>
<p class="card-detail">{{ documentState(doc).detail }}</p>
<p class="card-detail">{{ doc.filePresent === false ? '原文件不可用,请删除后重新上传' : (doc.vectorized ? `已切分 ${doc.chunkCount || 0} 段,可用于对话` : '正在解析并建立知识索引') }}</p>
</div>
<button class="card-delete" @click="removeDoc(doc.id)">删除</button>
</article>
@@ -82,7 +82,6 @@ import { ref, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAvatarStore } from '@/store/avatar'
import { pickScopedAvatarId, unwrapListData } from '@/utils/avatar-page-data.js'
import { isHuihuiEmbeddedMode } from '@/utils/embed-mode'
import {
getKnowledgeDocs,
uploadKnowledgeDoc,
@@ -96,7 +95,6 @@ import {
const router = useRouter()
const route = useRoute()
const store = useAvatarStore()
const isEmbedded = isHuihuiEmbeddedMode()
const avatarId = computed(() => pickScopedAvatarId(route.params.avatarId, store.currentAvatarId, store.avatars))
const activeTab = ref<'docs' | 'qa'>('docs')
@@ -113,19 +111,6 @@ const searching = ref(false)
const searched = ref(false)
const searchResults = ref<any[]>([])
const documentState = (doc: any) => {
if (doc.filePresent === false) {
return { tone: 'missing', label: '文件缺失', detail: '原文件不可用,请删除后重新上传' }
}
if (doc.vectorized) {
return { tone: 'ready', label: '已入库', detail: `已切分 ${doc.chunkCount || 0} 段,可用于对话` }
}
if (['uploaded', 'parsing'].includes(String(doc.status || '').toLowerCase())) {
return { tone: 'pending', label: '处理中', detail: '正在解析并建立知识索引' }
}
return { tone: 'failed', label: '处理失败', detail: '未能建立知识索引,请删除后重新上传' }
}
const loadDocs = async () => {
if (!avatarId.value) return
try {
@@ -298,7 +283,6 @@ onMounted(async () => {
.card-title-row strong { min-width: 0; flex: 1; overflow: hidden; color: #27201C; font-size: 14px; text-overflow: ellipsis; white-space: nowrap; }
.status-pill { flex: 0 0 auto; display: inline-flex; padding: 4px 7px; border-radius: 999px; color: #15803D; background: #ECFDF3; font-size: 10px; white-space: nowrap; }.status-pill.pending { color: #B45309; background: #FFFBEB; }
.status-pill.missing { color: #B91C1C; background: #FEF2F2; }
.status-pill.failed { color: #B91C1C; background: #FEF2F2; }
.card-meta, .card-detail { margin: 5px 0 0; color: #9398AE; font-size: 11px; line-height: 1.4; }.card-detail { color: #8B6B58; }
.card-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; }
+3 -22
View File
@@ -10,12 +10,10 @@
<section class="form-section">
<label class="field-label">问题</label>
<textarea
ref="questionInput"
v-model="form.question"
class="field-input question-input"
rows="1"
class="field-input"
rows="3"
placeholder="例如:你们的退款政策是什么?"
@input="resizeQuestion"
></textarea>
<label class="field-label">标准答案</label>
@@ -48,7 +46,7 @@
</template>
<script setup lang="ts">
import { ref, reactive, computed, nextTick, onMounted } from 'vue'
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAvatarStore } from '@/store/avatar'
import { pickScopedAvatarId, unwrapListData } from '@/utils/avatar-page-data.js'
@@ -65,14 +63,6 @@ const isEdit = computed(() => !!qaId.value)
const form = reactive({ question: '', answer: '', enabled: true })
const saving = ref(false)
const error = ref('')
const questionInput = ref<HTMLTextAreaElement | null>(null)
const resizeQuestion = (event?: Event) => {
const element = (event?.target as HTMLTextAreaElement | null) || questionInput.value
if (!element) return
element.style.height = 'auto'
element.style.height = `${element.scrollHeight}px`
}
const goBack = () => router.back()
@@ -134,8 +124,6 @@ onMounted(async () => {
if (isEdit.value) {
await loadForEdit()
}
await nextTick()
resizeQuestion()
})
</script>
@@ -206,13 +194,6 @@ onMounted(async () => {
border-color: #F97316;
}
.question-input {
min-height: 44px;
overflow: hidden;
resize: none;
line-height: 1.55;
}
.switch-row {
display: flex;
align-items: center;