Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2b02ddf30 |
@@ -188,7 +188,7 @@ class KnowledgeDoc(Base):
|
|||||||
file_type = Column(String, default="") # pdf | doc | docx | xlsx
|
file_type = Column(String, default="") # pdf | doc | docx | xlsx
|
||||||
file_size = Column(Integer, default=0)
|
file_size = Column(Integer, default=0)
|
||||||
file_url = Column(String, default="")
|
file_url = Column(String, default="")
|
||||||
status = Column(String, default="uploaded") # uploaded | parsing | ready
|
status = Column(String, default="uploaded") # uploaded | parsing | ready | failed
|
||||||
vectorized = Column(Boolean, default=False) # 是否已向量化
|
vectorized = Column(Boolean, default=False) # 是否已向量化
|
||||||
embedding_model = Column(String, default="") # 向量模型标识
|
embedding_model = Column(String, default="") # 向量模型标识
|
||||||
chunk_count = Column(Integer, default=0) # 切片数量
|
chunk_count = Column(Integer, default=0) # 切片数量
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
@@ -13,6 +14,7 @@ from responses import ok, fail
|
|||||||
import embeddings
|
import embeddings
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
UPLOAD_DIR = os.path.abspath(os.getenv("UPLOAD_DIR", os.path.join(BASE_DIR, "uploads")))
|
UPLOAD_DIR = os.path.abspath(os.getenv("UPLOAD_DIR", os.path.join(BASE_DIR, "uploads")))
|
||||||
@@ -69,6 +71,15 @@ def list_docs(avatar_id: str, authorization: str = Header(None), db: Session = D
|
|||||||
.order_by(KnowledgeDoc.created_at.desc())
|
.order_by(KnowledgeDoc.created_at.desc())
|
||||||
.all()
|
.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])
|
return ok([_doc_payload(d) for d in docs])
|
||||||
|
|
||||||
|
|
||||||
@@ -88,6 +99,7 @@ async def upload_doc(avatar_id: str, file: UploadFile = File(...), authorization
|
|||||||
with open(path, "wb") as f:
|
with open(path, "wb") as f:
|
||||||
f.write(content)
|
f.write(content)
|
||||||
doc = KnowledgeDoc(
|
doc = KnowledgeDoc(
|
||||||
|
id=uuid.uuid4().hex,
|
||||||
avatar_id=avatar_id,
|
avatar_id=avatar_id,
|
||||||
filename=file.filename,
|
filename=file.filename,
|
||||||
file_type=ext.lstrip("."),
|
file_type=ext.lstrip("."),
|
||||||
@@ -95,39 +107,47 @@ async def upload_doc(avatar_id: str, file: UploadFile = File(...), authorization
|
|||||||
file_url=f"/api/files/{avatar_id}/{stored}",
|
file_url=f"/api/files/{avatar_id}/{stored}",
|
||||||
status="parsing",
|
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:
|
try:
|
||||||
text = embeddings.extract_text(path, ext)
|
text = embeddings.extract_text(path, ext)
|
||||||
chunks = embeddings.chunk_text(text)
|
chunks = embeddings.chunk_text(text)
|
||||||
if chunks:
|
if not chunks:
|
||||||
|
raise ValueError("文档没有可建立索引的文字内容")
|
||||||
vectors = embeddings.embed(chunks)
|
vectors = embeddings.embed(chunks)
|
||||||
for i, (c, v) in enumerate(zip(chunks, vectors)):
|
if len(vectors) != len(chunks):
|
||||||
db.add(
|
raise ValueError("向量服务返回数量与文档分段不一致")
|
||||||
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.vectorized = True
|
||||||
doc.embedding_model = embeddings.MODEL
|
doc.embedding_model = embeddings.MODEL
|
||||||
doc.chunk_count = len(chunks)
|
doc.chunk_count = len(chunks)
|
||||||
doc.vectorized_at = datetime.now(timezone.utc)
|
doc.vectorized_at = datetime.now(timezone.utc)
|
||||||
doc.status = "ready"
|
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,
|
||||||
|
)
|
||||||
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(doc)
|
db.refresh(doc)
|
||||||
except Exception as e:
|
except Exception as exc:
|
||||||
print("vectorize failed:", e)
|
db.rollback()
|
||||||
doc.status = "ready" # 上传成功但向量化失败,仍可展示
|
doc.status = "failed"
|
||||||
|
doc.vectorized = False
|
||||||
|
doc.embedding_model = ""
|
||||||
|
doc.chunk_count = 0
|
||||||
|
doc.vectorized_at = None
|
||||||
|
db.add(doc)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(doc)
|
db.refresh(doc)
|
||||||
|
logger.exception("knowledge vectorization failed for %s: %s", doc.id, exc)
|
||||||
|
|
||||||
return ok(_doc_payload(doc))
|
return ok(_doc_payload(doc))
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,17 @@ from pathlib import Path
|
|||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import patch
|
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
|
from routers.knowledge import _doc_payload
|
||||||
|
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
def test_doc_payload_reports_whether_the_persisted_file_exists(tmp_path: Path):
|
def test_doc_payload_reports_whether_the_persisted_file_exists(tmp_path: Path):
|
||||||
avatar_id = "avatar-1"
|
avatar_id = "avatar-1"
|
||||||
stored_name = "knowledge.md"
|
stored_name = "knowledge.md"
|
||||||
@@ -21,3 +29,66 @@ def test_doc_payload_reports_whether_the_persisted_file_exists(tmp_path: Path):
|
|||||||
assert _doc_payload(doc)["filePresent"] is False
|
assert _doc_payload(doc)["filePresent"] is False
|
||||||
stored_file.write_text("knowledge", encoding="utf-8")
|
stored_file.write_text("knowledge", encoding="utf-8")
|
||||||
assert _doc_payload(doc)["filePresent"] is True
|
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()
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
type SmsLoginResult,
|
type SmsLoginResult,
|
||||||
type UserProfile
|
type UserProfile
|
||||||
} from '@/api'
|
} from '@/api'
|
||||||
|
import { clearHuihuiEmbeddedMode, markHuihuiEmbeddedMode } from '@/utils/embed-mode'
|
||||||
|
|
||||||
const TOKEN_KEY = 'hh_app_token'
|
const TOKEN_KEY = 'hh_app_token'
|
||||||
const USER_KEY = 'hh_app_user'
|
const USER_KEY = 'hh_app_user'
|
||||||
@@ -70,16 +71,23 @@ export const useUserStore = defineStore('smsuser', () => {
|
|||||||
|
|
||||||
// 短信登录
|
// 短信登录
|
||||||
const login = async (phone: string, code: string) => {
|
const login = async (phone: string, code: string) => {
|
||||||
return acceptLogin(await loginBySms(phone, code))
|
const result = await loginBySms(phone, code)
|
||||||
|
clearHuihuiEmbeddedMode()
|
||||||
|
return acceptLogin(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 账号密码登录
|
// 账号密码登录
|
||||||
const loginByPwd = async (account: string, password: string) => {
|
const loginByPwd = async (account: string, password: string) => {
|
||||||
return acceptLogin(await loginByPassword(account, password))
|
const result = await loginByPassword(account, password)
|
||||||
|
clearHuihuiEmbeddedMode()
|
||||||
|
return acceptLogin(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
const loginByToken = async (huihuiToken: string) =>
|
const loginByToken = async (huihuiToken: string) => {
|
||||||
acceptLogin(await loginByHuihuiToken(huihuiToken))
|
const result = await loginByHuihuiToken(huihuiToken)
|
||||||
|
markHuihuiEmbeddedMode()
|
||||||
|
return acceptLogin(result)
|
||||||
|
}
|
||||||
|
|
||||||
// 退出
|
// 退出
|
||||||
const logout = async () => {
|
const logout = async () => {
|
||||||
@@ -88,6 +96,7 @@ export const useUserStore = defineStore('smsuser', () => {
|
|||||||
} catch {
|
} catch {
|
||||||
/* 忽略网络错误,本地清除即可 */
|
/* 忽略网络错误,本地清除即可 */
|
||||||
}
|
}
|
||||||
|
clearHuihuiEmbeddedMode()
|
||||||
clearSession()
|
clearSession()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
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>
|
<template>
|
||||||
<div class="authorization-page">
|
<div class="authorization-page" :class="{ embedded: isEmbedded }">
|
||||||
<header class="page-header">
|
<header v-if="!isEmbedded" class="page-header">
|
||||||
<button class="back-button" type="button" aria-label="返回数字分身管理" @click="goBack">
|
<button class="back-button" type="button" aria-label="返回数字分身管理" @click="goBack">
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
<path d="m15 18-6-6 6-6" />
|
<path d="m15 18-6-6 6-6" />
|
||||||
@@ -95,6 +95,9 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer v-if="activeAvatarId" class="save-area">
|
<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()">
|
<button class="save-button" type="button" :disabled="loading || saving" @click="saveSettings()">
|
||||||
<span v-if="saving" class="saving-spinner" aria-hidden="true"></span>
|
<span v-if="saving" class="saving-spinner" aria-hidden="true"></span>
|
||||||
{{ saving ? '保存中...' : '保存授权设置' }}
|
{{ saving ? '保存中...' : '保存授权设置' }}
|
||||||
@@ -119,6 +122,7 @@ import {
|
|||||||
} from '@/api'
|
} from '@/api'
|
||||||
import { useAvatarStore } from '@/store/avatar'
|
import { useAvatarStore } from '@/store/avatar'
|
||||||
import { pickScopedAvatarId } from '@/utils/avatar-page-data.js'
|
import { pickScopedAvatarId } from '@/utils/avatar-page-data.js'
|
||||||
|
import { isHuihuiEmbeddedMode } from '@/utils/embed-mode'
|
||||||
|
|
||||||
type PermissionState = Record<AvatarPermission, boolean>
|
type PermissionState = Record<AvatarPermission, boolean>
|
||||||
|
|
||||||
@@ -126,6 +130,7 @@ const router = useRouter()
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const avatarStore = useAvatarStore()
|
const avatarStore = useAvatarStore()
|
||||||
const avatarId = computed(() => pickScopedAvatarId(route.params.avatarId, avatarStore.currentAvatarId, avatarStore.avatars))
|
const avatarId = computed(() => pickScopedAvatarId(route.params.avatarId, avatarStore.currentAvatarId, avatarStore.avatars))
|
||||||
|
const isEmbedded = isHuihuiEmbeddedMode()
|
||||||
|
|
||||||
const permissionItems: Array<{
|
const permissionItems: Array<{
|
||||||
key: AvatarPermission
|
key: AvatarPermission
|
||||||
@@ -394,6 +399,10 @@ svg {
|
|||||||
padding: 0 20px;
|
padding: 0 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.authorization-page.embedded .page-content {
|
||||||
|
padding-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.permission-intro {
|
.permission-intro {
|
||||||
min-height: 96px;
|
min-height: 96px;
|
||||||
padding: 15px 16px 14px;
|
padding: 15px 16px 14px;
|
||||||
@@ -599,12 +608,15 @@ svg {
|
|||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: min(100%, 390px);
|
width: min(100%, 390px);
|
||||||
padding: 12px 20px calc(20px + env(safe-area-inset-bottom));
|
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%);
|
background: linear-gradient(to bottom, rgba(250, 250, 250, 0), #fafafa 20%, #fafafa 100%);
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.save-button {
|
.save-button {
|
||||||
width: 100%;
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -620,6 +632,20 @@ svg {
|
|||||||
cursor: pointer;
|
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 {
|
.save-button:disabled {
|
||||||
opacity: .68;
|
opacity: .68;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="edit-avatar-page">
|
<div class="edit-avatar-page">
|
||||||
<!-- 顶部导航 -->
|
<!-- 顶部导航 -->
|
||||||
<header class="page-header">
|
<header v-if="!isEmbedded" class="page-header">
|
||||||
<button class="back-btn" @click="goBack">‹</button>
|
<button class="back-btn" @click="goBack">‹</button>
|
||||||
<h1 class="page-title">分身微调</h1>
|
<h1 class="page-title">分身微调</h1>
|
||||||
<button class="save-btn" :disabled="loading || saving || uploadingPhoto" @click="saveChanges">
|
<span class="header-spacer" aria-hidden="true"></span>
|
||||||
{{ saving ? '保存中...' : '保存' }}
|
|
||||||
</button>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div v-if="loading" class="status-banner">加载中...</div>
|
<div v-if="loading" class="status-banner">加载中...</div>
|
||||||
@@ -159,6 +157,13 @@
|
|||||||
{{ deleting ? '删除中...' : '删除数字分身' }}
|
{{ deleting ? '删除中...' : '删除数字分身' }}
|
||||||
</button>
|
</button>
|
||||||
</section>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -168,11 +173,13 @@ import { useRoute, useRouter } from 'vue-router'
|
|||||||
import { deleteAvatar as apiDeleteAvatar, getAvatarDetail, updateAvatar, uploadAvatarPhoto } from '@/api'
|
import { deleteAvatar as apiDeleteAvatar, getAvatarDetail, updateAvatar, uploadAvatarPhoto } from '@/api'
|
||||||
import { useAvatarStore } from '@/store/avatar'
|
import { useAvatarStore } from '@/store/avatar'
|
||||||
import { buildAvatarUpdatePayload, normalizeAvatarEditForm } from '@/utils/avatar-page-data.js'
|
import { buildAvatarUpdatePayload, normalizeAvatarEditForm } from '@/utils/avatar-page-data.js'
|
||||||
|
import { isHuihuiEmbeddedMode } from '@/utils/embed-mode'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const avatarStore = useAvatarStore()
|
const avatarStore = useAvatarStore()
|
||||||
const avatarId = route.params.id as string
|
const avatarId = route.params.id as string
|
||||||
|
const isEmbedded = isHuihuiEmbeddedMode()
|
||||||
|
|
||||||
// 表单数据
|
// 表单数据
|
||||||
const formData = reactive({
|
const formData = reactive({
|
||||||
@@ -288,7 +295,7 @@ onMounted(async () => {
|
|||||||
.edit-avatar-page {
|
.edit-avatar-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: #F8F9FA;
|
background: #F8F9FA;
|
||||||
padding-bottom: 40px;
|
padding-bottom: calc(104px + env(safe-area-inset-bottom));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 顶部导航 */
|
/* 顶部导航 */
|
||||||
@@ -331,16 +338,7 @@ onMounted(async () => {
|
|||||||
color: #B91C1C;
|
color: #B91C1C;
|
||||||
}
|
}
|
||||||
|
|
||||||
.save-btn {
|
.header-spacer { width: 40px; }
|
||||||
background: #F97316;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
padding: 8px 20px;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 头像上传 */
|
/* 头像上传 */
|
||||||
.photo-section {
|
.photo-section {
|
||||||
@@ -580,4 +578,47 @@ onMounted(async () => {
|
|||||||
background: #EF4444;
|
background: #EF4444;
|
||||||
color: white;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="avatar-manage-page">
|
<div class="avatar-manage-page">
|
||||||
<!-- 顶部导航 -->
|
<!-- 顶部导航 -->
|
||||||
<header class="page-header">
|
<header v-if="!isEmbedded" class="page-header">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<button class="back-btn" @click="goBack">‹</button>
|
<button class="back-btn" @click="goBack">‹</button>
|
||||||
<h1 class="page-title">数字分身管理</h1>
|
<h1 class="page-title">数字分身管理</h1>
|
||||||
</div>
|
</div>
|
||||||
<!-- 右上角创建入口 -->
|
|
||||||
<div class="header-right">
|
|
||||||
<button class="icon-btn" @click="goCreate" title="创建数字分身">➕</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- 用户资料头(会会登录账号的头像 / 昵称) -->
|
<!-- 用户资料头(会会登录账号的头像 / 昵称) -->
|
||||||
@@ -39,9 +35,14 @@
|
|||||||
<!-- 数字分身列表(只放分身相关) -->
|
<!-- 数字分身列表(只放分身相关) -->
|
||||||
<section class="avatar-list-section">
|
<section class="avatar-list-section">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
|
<div class="section-heading-copy">
|
||||||
<h3 class="section-title">我的数字分身</h3>
|
<h3 class="section-title">我的数字分身</h3>
|
||||||
<span class="count-badge">{{ avatars.length }}</span>
|
<span class="count-badge">{{ avatars.length }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<button class="section-create-btn" type="button" @click="goCreate">
|
||||||
|
<span aria-hidden="true">+</span> 添加分身
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="avatars.length" class="avatar-list">
|
<div v-if="avatars.length" class="avatar-list">
|
||||||
<div class="avatar-card" v-for="a in avatars" :key="a.id">
|
<div class="avatar-card" v-for="a in avatars" :key="a.id">
|
||||||
@@ -87,10 +88,12 @@ import { useRouter } from 'vue-router'
|
|||||||
import { useAvatarStore } from '@/store/avatar'
|
import { useAvatarStore } from '@/store/avatar'
|
||||||
import { useUserStore } from '@/store/user'
|
import { useUserStore } from '@/store/user'
|
||||||
import { createAvatarShareLink } from '@/api'
|
import { createAvatarShareLink } from '@/api'
|
||||||
|
import { isHuihuiEmbeddedMode } from '@/utils/embed-mode'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const avatarStore = useAvatarStore()
|
const avatarStore = useAvatarStore()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
const isEmbedded = isHuihuiEmbeddedMode()
|
||||||
|
|
||||||
// 临时产品开关:余额卡片代码保留,后续改为 true 即可恢复展示。
|
// 临时产品开关:余额卡片代码保留,后续改为 true 即可恢复展示。
|
||||||
const SHOW_POINTS_BALANCE_CARD = false
|
const SHOW_POINTS_BALANCE_CARD = false
|
||||||
@@ -354,10 +357,36 @@ onMounted(() => {
|
|||||||
.section-head {
|
.section-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
margin: 8px 0 12px;
|
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 {
|
.section-title {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="knowledge-page">
|
<div class="knowledge-page" :class="{ embedded: isEmbedded }">
|
||||||
<!-- 顶部导航 -->
|
<!-- 顶部导航 -->
|
||||||
<header class="page-header">
|
<header v-if="!isEmbedded" class="page-header">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<button class="back-btn" @click="goBack">‹</button>
|
<button class="back-btn" @click="goBack">‹</button>
|
||||||
<h1 class="page-title">知识库管理</h1>
|
<h1 class="page-title">知识库管理</h1>
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
<div class="upload-section">
|
<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-zone" :class="{ 'drag-over': dragOver }" @click="triggerFile" @dragover.prevent="dragOver = true" @dragleave.prevent="dragOver = false" @drop.prevent="onDrop">
|
||||||
<div class="upload-icon">📥</div>
|
<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>
|
<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" />
|
<input ref="fileInput" type="file" accept=".md,.txt,.pdf,.doc,.docx,.xlsx" class="hidden-input" @change="onFileChange" />
|
||||||
</div>
|
</div>
|
||||||
@@ -37,10 +37,10 @@
|
|||||||
<div class="card-content">
|
<div class="card-content">
|
||||||
<div class="card-title-row">
|
<div class="card-title-row">
|
||||||
<strong>{{ doc.filename }}</strong>
|
<strong>{{ doc.filename }}</strong>
|
||||||
<span class="status-pill" :class="{ pending: !doc.vectorized && doc.filePresent !== false, missing: doc.filePresent === false }">{{ doc.filePresent === false ? '文件缺失' : (doc.vectorized ? '已入库' : '处理中') }}</span>
|
<span class="status-pill" :class="documentState(doc).tone">{{ documentState(doc).label }}</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="card-meta">{{ doc.fileType.toUpperCase() }} · {{ formatSize(doc.fileSize) }} · {{ formatDate(doc.createdAt) }}</p>
|
<p class="card-meta">{{ doc.fileType.toUpperCase() }} · {{ formatSize(doc.fileSize) }} · {{ formatDate(doc.createdAt) }}</p>
|
||||||
<p class="card-detail">{{ doc.filePresent === false ? '原文件不可用,请删除后重新上传' : (doc.vectorized ? `已切分 ${doc.chunkCount || 0} 段,可用于对话` : '正在解析并建立知识索引') }}</p>
|
<p class="card-detail">{{ documentState(doc).detail }}</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="card-delete" @click="removeDoc(doc.id)">删除</button>
|
<button class="card-delete" @click="removeDoc(doc.id)">删除</button>
|
||||||
</article>
|
</article>
|
||||||
@@ -82,6 +82,7 @@ import { ref, onMounted, computed } from 'vue'
|
|||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useAvatarStore } from '@/store/avatar'
|
import { useAvatarStore } from '@/store/avatar'
|
||||||
import { pickScopedAvatarId, unwrapListData } from '@/utils/avatar-page-data.js'
|
import { pickScopedAvatarId, unwrapListData } from '@/utils/avatar-page-data.js'
|
||||||
|
import { isHuihuiEmbeddedMode } from '@/utils/embed-mode'
|
||||||
import {
|
import {
|
||||||
getKnowledgeDocs,
|
getKnowledgeDocs,
|
||||||
uploadKnowledgeDoc,
|
uploadKnowledgeDoc,
|
||||||
@@ -95,6 +96,7 @@ import {
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const store = useAvatarStore()
|
const store = useAvatarStore()
|
||||||
|
const isEmbedded = isHuihuiEmbeddedMode()
|
||||||
|
|
||||||
const avatarId = computed(() => pickScopedAvatarId(route.params.avatarId, store.currentAvatarId, store.avatars))
|
const avatarId = computed(() => pickScopedAvatarId(route.params.avatarId, store.currentAvatarId, store.avatars))
|
||||||
const activeTab = ref<'docs' | 'qa'>('docs')
|
const activeTab = ref<'docs' | 'qa'>('docs')
|
||||||
@@ -111,6 +113,19 @@ const searching = ref(false)
|
|||||||
const searched = ref(false)
|
const searched = ref(false)
|
||||||
const searchResults = ref<any[]>([])
|
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 () => {
|
const loadDocs = async () => {
|
||||||
if (!avatarId.value) return
|
if (!avatarId.value) return
|
||||||
try {
|
try {
|
||||||
@@ -283,6 +298,7 @@ onMounted(async () => {
|
|||||||
.card-title-row strong { min-width: 0; flex: 1; overflow: hidden; color: #27201C; font-size: 14px; text-overflow: ellipsis; white-space: nowrap; }
|
.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 { 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.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-meta, .card-detail { margin: 5px 0 0; color: #9398AE; font-size: 11px; line-height: 1.4; }.card-detail { color: #8B6B58; }
|
||||||
.card-delete { flex: 0 0 auto; align-self: center; border: 0; color: #EF4444; background: #FEF2F2; border-radius: 8px; padding: 7px 9px; font-size: 12px; cursor: pointer; }
|
.card-delete { flex: 0 0 auto; align-self: center; border: 0; color: #EF4444; background: #FEF2F2; border-radius: 8px; padding: 7px 9px; font-size: 12px; cursor: pointer; }
|
||||||
.card-empty { padding: 42px 16px; border: 1px dashed #F1D9C3; border-radius: 16px; color: #9398AE; background: #fff; font-size: 14px; text-align: center; }
|
.card-empty { padding: 42px 16px; border: 1px dashed #F1D9C3; border-radius: 16px; color: #9398AE; background: #fff; font-size: 14px; text-align: center; }
|
||||||
|
|||||||
@@ -10,10 +10,12 @@
|
|||||||
<section class="form-section">
|
<section class="form-section">
|
||||||
<label class="field-label">问题</label>
|
<label class="field-label">问题</label>
|
||||||
<textarea
|
<textarea
|
||||||
|
ref="questionInput"
|
||||||
v-model="form.question"
|
v-model="form.question"
|
||||||
class="field-input"
|
class="field-input question-input"
|
||||||
rows="3"
|
rows="1"
|
||||||
placeholder="例如:你们的退款政策是什么?"
|
placeholder="例如:你们的退款政策是什么?"
|
||||||
|
@input="resizeQuestion"
|
||||||
></textarea>
|
></textarea>
|
||||||
|
|
||||||
<label class="field-label">标准答案</label>
|
<label class="field-label">标准答案</label>
|
||||||
@@ -46,7 +48,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, computed, onMounted } from 'vue'
|
import { ref, reactive, computed, nextTick, onMounted } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { useAvatarStore } from '@/store/avatar'
|
import { useAvatarStore } from '@/store/avatar'
|
||||||
import { pickScopedAvatarId, unwrapListData } from '@/utils/avatar-page-data.js'
|
import { pickScopedAvatarId, unwrapListData } from '@/utils/avatar-page-data.js'
|
||||||
@@ -63,6 +65,14 @@ const isEdit = computed(() => !!qaId.value)
|
|||||||
const form = reactive({ question: '', answer: '', enabled: true })
|
const form = reactive({ question: '', answer: '', enabled: true })
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const error = ref('')
|
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()
|
const goBack = () => router.back()
|
||||||
|
|
||||||
@@ -124,6 +134,8 @@ onMounted(async () => {
|
|||||||
if (isEdit.value) {
|
if (isEdit.value) {
|
||||||
await loadForEdit()
|
await loadForEdit()
|
||||||
}
|
}
|
||||||
|
await nextTick()
|
||||||
|
resizeQuestion()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -194,6 +206,13 @@ onMounted(async () => {
|
|||||||
border-color: #F97316;
|
border-color: #F97316;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.question-input {
|
||||||
|
min-height: 44px;
|
||||||
|
overflow: hidden;
|
||||||
|
resize: none;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
.switch-row {
|
.switch-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
Reference in New Issue
Block a user