修改了mcp接口,增加了项目创建接口
parent
86b82111df
commit
bd7d4d3dd5
|
|
@ -5,7 +5,8 @@ import asyncio
|
|||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Optional
|
||||
import time
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
|
@ -35,20 +36,6 @@ router = APIRouter()
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
CHAT_HISTORY_MESSAGE_LIMIT = 20
|
||||
# 生成被客户端中断时写入助手消息的统一标记,前端据此识别“已停止生成”
|
||||
INTERRUPTED_RESPONSE_MARKER = "interrupt"
|
||||
|
||||
|
||||
def _is_interrupted_content(content: str) -> bool:
|
||||
"""判断助手消息内容是否带中断标记。
|
||||
|
||||
中断标记要么单独成文(内容为空时),要么以 \\n\\n 追加在内容末尾。
|
||||
"""
|
||||
if not content:
|
||||
return False
|
||||
return content == INTERRUPTED_RESPONSE_MARKER or content.endswith(
|
||||
f"\n\n{INTERRUPTED_RESPONSE_MARKER}"
|
||||
)
|
||||
|
||||
|
||||
class VectorizeRequest(BaseModel):
|
||||
|
|
@ -183,17 +170,32 @@ def _normalize_refs(refs):
|
|||
citation_id = ref.get("citation_id", idx)
|
||||
anchor_text = ref.get("anchor_text") or ""
|
||||
excerpt = ref.get("excerpt") or ""
|
||||
content = ref.get("content") or ""
|
||||
chunk_index = ref.get("chunk_index")
|
||||
hit_terms = ref.get("hit_terms") or []
|
||||
quotes = ref.get("quotes") or []
|
||||
quote_occurrences = ref.get("quote_occurrences") or []
|
||||
else:
|
||||
file_path = ref
|
||||
citation_id = idx
|
||||
anchor_text = ""
|
||||
excerpt = ""
|
||||
content = ""
|
||||
chunk_index = None
|
||||
hit_terms = []
|
||||
quotes = []
|
||||
quote_occurrences = []
|
||||
if file_path:
|
||||
normalized.append({
|
||||
"citation_id": citation_id,
|
||||
"file_path": file_path,
|
||||
"anchor_text": anchor_text,
|
||||
"excerpt": excerpt,
|
||||
"content": content,
|
||||
"chunk_index": chunk_index,
|
||||
"hit_terms": hit_terms,
|
||||
"quotes": quotes,
|
||||
"quote_occurrences": quote_occurrences,
|
||||
})
|
||||
return normalized
|
||||
|
||||
|
|
@ -206,6 +208,11 @@ def _build_reference_items(refs, project_id=None):
|
|||
"file_name": ref["file_path"].rsplit("/", 1)[-1],
|
||||
"anchor_text": ref.get("anchor_text") or "",
|
||||
"excerpt": ref.get("excerpt") or "",
|
||||
"content": ref.get("content") or "",
|
||||
"chunk_index": ref.get("chunk_index"),
|
||||
"hit_terms": ref.get("hit_terms") or [],
|
||||
"quotes": ref.get("quotes") or [],
|
||||
"quote_occurrences": ref.get("quote_occurrences") or [],
|
||||
"project_id": project_id,
|
||||
}
|
||||
for ref in _normalize_refs(refs)
|
||||
|
|
@ -283,9 +290,12 @@ def _compact_cited_refs(answer: str, retrieved_docs):
|
|||
"citation_id": new_id,
|
||||
"file_path": file_path,
|
||||
"anchor_text": doc.get("anchor_text") or "",
|
||||
# 优先使用命中文档的上下文片段(命中点前后扩展),避免分块边界
|
||||
# 的短文本(如文档末尾残余块)导致引用预览内容过少
|
||||
"excerpt": doc.get("content") or doc.get("excerpt") or doc.get("anchor_text") or "",
|
||||
# excerpt 保留精确命中的分块文本;content 保留命中点前后的
|
||||
# 上下文窗口(前端据此标出"命中区间"并支持跳转原文)
|
||||
"excerpt": doc.get("excerpt") or doc.get("anchor_text") or "",
|
||||
"content": doc.get("content") or "",
|
||||
"chunk_index": doc.get("chunk_index"),
|
||||
"hit_terms": doc.get("hit_terms") or [],
|
||||
})
|
||||
old_to_new[old_id] = new_id
|
||||
|
||||
|
|
@ -408,10 +418,19 @@ async def get_session_messages(
|
|||
refs = stored_refs
|
||||
if message.role == "assistant":
|
||||
content, refs = _canonicalize_message_citations(content, stored_refs)
|
||||
thinking_log = []
|
||||
if message.thinking_log:
|
||||
try:
|
||||
thinking_log = json.loads(message.thinking_log) or []
|
||||
except (TypeError, ValueError):
|
||||
thinking_log = []
|
||||
data.append({
|
||||
"id": message.id,
|
||||
"role": message.role,
|
||||
"content": content,
|
||||
"status": message.status or "pending",
|
||||
"duration_ms": message.duration_ms,
|
||||
"thinking_log": thinking_log,
|
||||
"referenced_files": refs,
|
||||
"references": _build_reference_items(refs, session.project_id) if message.role == "assistant" else [],
|
||||
"created_at": message.created_at.isoformat(),
|
||||
|
|
@ -523,6 +542,7 @@ async def send_chat_message(
|
|||
session_id=req.session_id,
|
||||
role="user",
|
||||
content=question,
|
||||
status="completed",
|
||||
)
|
||||
db.add(query_message)
|
||||
await db.flush()
|
||||
|
|
@ -543,19 +563,35 @@ async def send_chat_message(
|
|||
conversation_history = [
|
||||
{"role": m.role, "content": m.content}
|
||||
for m in prev_messages
|
||||
# 被中断的助手消息(含统一中断标记)不参与上下文,避免污染后续模型输入
|
||||
if not (
|
||||
m.role == "assistant"
|
||||
and _is_interrupted_content(m.content or "")
|
||||
)
|
||||
# 被中断的助手消息不参与上下文,避免污染后续模型输入
|
||||
if not (m.role == "assistant" and m.status == "interrupted")
|
||||
]
|
||||
|
||||
start_time = time.monotonic()
|
||||
thinking_log = [
|
||||
{
|
||||
"stage": "retrieval",
|
||||
"message": "正在检索知识库…",
|
||||
"elapsed_ms": 0,
|
||||
}
|
||||
]
|
||||
retrieved_docs = await rag_service.retrieve_documents(
|
||||
db,
|
||||
session.project_id,
|
||||
question,
|
||||
top_k=5,
|
||||
)
|
||||
thinking_log.append({
|
||||
"stage": "retrieved",
|
||||
"message": f"已检索到 {len(retrieved_docs)} 个相关文档",
|
||||
"count": len(retrieved_docs),
|
||||
"elapsed_ms": int((time.monotonic() - start_time) * 1000),
|
||||
})
|
||||
thinking_log.append({
|
||||
"stage": "generate",
|
||||
"message": "正在生成回答…",
|
||||
"elapsed_ms": int((time.monotonic() - start_time) * 1000),
|
||||
})
|
||||
|
||||
assistant_response = await rag_service.generate_response(
|
||||
db,
|
||||
|
|
@ -565,16 +601,29 @@ async def send_chat_message(
|
|||
retrieved_docs,
|
||||
conversation_history,
|
||||
)
|
||||
thinking_log.append({
|
||||
"stage": "align",
|
||||
"message": "正在整理引用…",
|
||||
"elapsed_ms": int((time.monotonic() - start_time) * 1000),
|
||||
})
|
||||
|
||||
# 仅保留实际引用的文档,并把引用编号压缩为从 1 开始的连续序列。
|
||||
assistant_response, cited_refs = _compact_cited_refs(
|
||||
assistant_response, retrieved_docs
|
||||
)
|
||||
if cited_refs:
|
||||
# 路线 B:embedding 对齐,为每个引用回填原文支撑句
|
||||
await rag_service.align_citation_quotes(
|
||||
db, assistant_response, cited_refs
|
||||
)
|
||||
|
||||
assistant_message = ChatMessage(
|
||||
session_id=req.session_id,
|
||||
role="assistant",
|
||||
content=assistant_response,
|
||||
status="completed",
|
||||
duration_ms=int((time.monotonic() - start_time) * 1000),
|
||||
thinking_log=json.dumps(thinking_log, ensure_ascii=False),
|
||||
referenced_files=json.dumps(cited_refs, ensure_ascii=False) if cited_refs else None,
|
||||
)
|
||||
db.add(assistant_message)
|
||||
|
|
@ -636,11 +685,8 @@ async def send_chat_message_stream(
|
|||
conversation_history = [
|
||||
{"role": m.role, "content": m.content}
|
||||
for m in prev_messages
|
||||
# 被中断的助手消息(含统一中断标记)不参与上下文,避免污染后续模型输入
|
||||
if not (
|
||||
m.role == "assistant"
|
||||
and _is_interrupted_content(m.content or "")
|
||||
)
|
||||
# 被中断的助手消息不参与上下文,避免污染后续模型输入
|
||||
if not (m.role == "assistant" and m.status == "interrupted")
|
||||
]
|
||||
|
||||
# 先持久化用户提问和空的助手消息占位行,再执行向量检索。
|
||||
|
|
@ -656,6 +702,7 @@ async def send_chat_message_stream(
|
|||
session_id=req.session_id,
|
||||
role="user",
|
||||
content=question,
|
||||
status="completed",
|
||||
)
|
||||
db.add(query_message)
|
||||
db.add(assistant_message)
|
||||
|
|
@ -665,71 +712,76 @@ async def send_chat_message_stream(
|
|||
user_message_id = query_message.id if req.insert_user_message else None
|
||||
assistant_message_id = assistant_message.id
|
||||
|
||||
async def _mark_interrupted_assistant():
|
||||
"""把中断标记写入仍为空的助手占位行(断开连接时任务可能正被取消)。"""
|
||||
try:
|
||||
msg = await db.get(ChatMessage, assistant_message_id)
|
||||
if msg is not None and not (msg.content or "").strip():
|
||||
msg.content = INTERRUPTED_RESPONSE_MARKER
|
||||
await db.commit()
|
||||
except Exception as save_exc: # noqa: BLE001
|
||||
logger.warning("保存中断状态失败: %s", save_exc)
|
||||
|
||||
try:
|
||||
retrieved_docs = await rag_service.retrieve_documents(
|
||||
db,
|
||||
session.project_id,
|
||||
question,
|
||||
top_k=5,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
# 客户端在向量检索阶段断开(停止生成):任务被取消,占位行需写入中断标记。
|
||||
await _mark_interrupted_assistant()
|
||||
raise
|
||||
except Exception:
|
||||
# 占位行已入库:把失败原因写进助手消息,避免留下永远显示“正在思考”的空行。
|
||||
try:
|
||||
msg = await db.get(ChatMessage, assistant_message_id)
|
||||
if msg is not None:
|
||||
msg.content = "回答生成失败,请稍后重试。"
|
||||
await db.commit()
|
||||
except Exception as save_exc: # noqa: BLE001
|
||||
logger.warning("保存检索失败状态失败: %s", save_exc)
|
||||
raise
|
||||
|
||||
llm_config_id = session.llm_config_id
|
||||
project_id = session.project_id
|
||||
# 生成总耗时起点:从消息占位行入库后开始计时,用于“思考过程”展示
|
||||
start_time = time.monotonic()
|
||||
# 每累计这么多字符就回写一次占位行,平衡「刷新可见性」与「写库频率」
|
||||
FLUSH_EVERY_CHARS = 120
|
||||
|
||||
async def _persist_assistant(parts, *, completed):
|
||||
"""把已生成内容回写到占位的助手消息行。
|
||||
|
||||
completed=True 时附带引用解析;False 表示流式中途的增量回写。
|
||||
"""
|
||||
assistant_response = "".join(parts)
|
||||
cited_refs = []
|
||||
if completed:
|
||||
assistant_response, cited_refs = _compact_cited_refs(
|
||||
assistant_response, retrieved_docs
|
||||
)
|
||||
references = _build_reference_items(cited_refs, project_id)
|
||||
|
||||
msg = await db.get(ChatMessage, assistant_message_id)
|
||||
if msg is not None:
|
||||
msg.content = assistant_response
|
||||
if completed:
|
||||
msg.referenced_files = (
|
||||
json.dumps(cited_refs, ensure_ascii=False) if cited_refs else None
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return assistant_response, references
|
||||
|
||||
async def event_generator():
|
||||
assistant_response_parts = []
|
||||
thinking_log: List[Dict[str, Any]] = []
|
||||
chars_since_flush = 0
|
||||
finished = False
|
||||
|
||||
def _log(
|
||||
stage: str,
|
||||
message: str,
|
||||
*,
|
||||
duration_ms: Optional[int] = None,
|
||||
count: Optional[int] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""记录一个思考过程条目。
|
||||
|
||||
duration_ms 表示该阶段已完成的实际耗时;进行中的阶段不填,
|
||||
由前端显示为进度状态,避免“开始时间点”造成的误导。
|
||||
"""
|
||||
entry = {
|
||||
"stage": stage,
|
||||
"message": message,
|
||||
}
|
||||
if duration_ms is not None:
|
||||
entry["duration_ms"] = duration_ms
|
||||
if count is not None:
|
||||
entry["count"] = count
|
||||
thinking_log.append(entry)
|
||||
return entry
|
||||
|
||||
async def _persist_assistant(parts, *, completed):
|
||||
"""把已生成内容回写到占位的助手消息行。
|
||||
|
||||
completed=True 时附带引用解析与最终状态;False 表示流式中途的增量回写。
|
||||
"""
|
||||
assistant_response = "".join(parts)
|
||||
cited_refs = []
|
||||
if completed:
|
||||
assistant_response, cited_refs = _compact_cited_refs(
|
||||
assistant_response, retrieved_docs
|
||||
)
|
||||
if cited_refs:
|
||||
# 路线 B:embedding 对齐,为每个引用回填原文支撑句
|
||||
await rag_service.align_citation_quotes(
|
||||
db, assistant_response, cited_refs
|
||||
)
|
||||
references = _build_reference_items(cited_refs, project_id)
|
||||
|
||||
msg = await db.get(ChatMessage, assistant_message_id)
|
||||
if msg is not None:
|
||||
msg.content = assistant_response
|
||||
if completed:
|
||||
msg.status = "completed"
|
||||
msg.duration_ms = int((time.monotonic() - start_time) * 1000)
|
||||
msg.thinking_log = (
|
||||
json.dumps(thinking_log, ensure_ascii=False)
|
||||
if thinking_log else None
|
||||
)
|
||||
msg.referenced_files = (
|
||||
json.dumps(cited_refs, ensure_ascii=False) if cited_refs else None
|
||||
)
|
||||
await db.commit()
|
||||
return assistant_response, references
|
||||
|
||||
try:
|
||||
# 先回传两条消息的真实 id,使前端无需刷新即可获得删除入口等能力
|
||||
yield _stream_event("ids", {
|
||||
|
|
@ -738,6 +790,27 @@ async def send_chat_message_stream(
|
|||
"assistant_message_id": assistant_message_id,
|
||||
})
|
||||
|
||||
# 检索阶段放在流内执行,客户端可以在检索过程中实时看到思考进度
|
||||
yield _stream_event("thinking", _log("retrieval", "正在检索知识库…"))
|
||||
retrieval_start = time.monotonic()
|
||||
retrieved_docs = await rag_service.retrieve_documents(
|
||||
db,
|
||||
session.project_id,
|
||||
question,
|
||||
top_k=5,
|
||||
)
|
||||
yield _stream_event(
|
||||
"thinking",
|
||||
_log(
|
||||
"retrieved",
|
||||
f"已检索到 {len(retrieved_docs)} 个相关文档",
|
||||
duration_ms=int((time.monotonic() - retrieval_start) * 1000),
|
||||
count=len(retrieved_docs),
|
||||
),
|
||||
)
|
||||
|
||||
yield _stream_event("thinking", _log("generate", "正在生成回答…"))
|
||||
generate_start = time.monotonic()
|
||||
async for chunk in rag_service.generate_response_stream(
|
||||
db,
|
||||
question,
|
||||
|
|
@ -755,10 +828,30 @@ async def send_chat_message_stream(
|
|||
chars_since_flush = 0
|
||||
await _persist_assistant(assistant_response_parts, completed=False)
|
||||
|
||||
yield _stream_event(
|
||||
"thinking",
|
||||
_log(
|
||||
"generated",
|
||||
"回答生成完成",
|
||||
duration_ms=int((time.monotonic() - generate_start) * 1000),
|
||||
),
|
||||
)
|
||||
|
||||
align_start = time.monotonic()
|
||||
yield _stream_event("thinking", _log("align", "正在整理引用…"))
|
||||
assistant_response, references = await _persist_assistant(
|
||||
assistant_response_parts, completed=True
|
||||
)
|
||||
finished = True
|
||||
yield _stream_event(
|
||||
"thinking",
|
||||
_log(
|
||||
"aligned",
|
||||
"引用整理完成",
|
||||
duration_ms=int((time.monotonic() - align_start) * 1000),
|
||||
),
|
||||
)
|
||||
duration_ms = int((time.monotonic() - start_time) * 1000)
|
||||
|
||||
yield _stream_event("references", references or [])
|
||||
yield _stream_event("done", {
|
||||
|
|
@ -767,18 +860,25 @@ async def send_chat_message_stream(
|
|||
"assistant_message_id": assistant_message_id,
|
||||
"content": assistant_response,
|
||||
"references": references or [],
|
||||
"status": "completed",
|
||||
"duration_ms": duration_ms,
|
||||
"thinking_log": thinking_log,
|
||||
})
|
||||
except (asyncio.CancelledError, GeneratorExit):
|
||||
# 客户端中途断开(如刷新页面):尽力把已生成的部分回写到占位行。
|
||||
# 由于提问与占位行已入库,刷新后至少能看到提问与已生成内容。
|
||||
# 客户端中途断开(停止生成/刷新页面):把已生成内容与中断状态回写占位行。
|
||||
if not finished:
|
||||
if not assistant_response_parts:
|
||||
assistant_response_parts.append(INTERRUPTED_RESPONSE_MARKER)
|
||||
else:
|
||||
assistant_response_parts.append(f"\n\n{INTERRUPTED_RESPONSE_MARKER}")
|
||||
try:
|
||||
await db.rollback()
|
||||
await _persist_assistant(assistant_response_parts, completed=False)
|
||||
msg = await db.get(ChatMessage, assistant_message_id)
|
||||
if msg is not None:
|
||||
msg.content = "".join(assistant_response_parts)
|
||||
msg.status = "interrupted"
|
||||
msg.duration_ms = int((time.monotonic() - start_time) * 1000)
|
||||
msg.thinking_log = (
|
||||
json.dumps(thinking_log, ensure_ascii=False)
|
||||
if thinking_log else None
|
||||
)
|
||||
await db.commit()
|
||||
except Exception as save_exc: # noqa: BLE001
|
||||
logger.warning("保存中断的助手回复失败: %s", save_exc)
|
||||
raise
|
||||
|
|
@ -787,7 +887,16 @@ async def send_chat_message_stream(
|
|||
if not assistant_response_parts:
|
||||
assistant_response_parts.append("回答生成失败,请稍后重试。")
|
||||
try:
|
||||
await _persist_assistant(assistant_response_parts, completed=False)
|
||||
msg = await db.get(ChatMessage, assistant_message_id)
|
||||
if msg is not None:
|
||||
msg.content = "".join(assistant_response_parts)
|
||||
msg.status = "error"
|
||||
msg.duration_ms = int((time.monotonic() - start_time) * 1000)
|
||||
msg.thinking_log = (
|
||||
json.dumps(thinking_log, ensure_ascii=False)
|
||||
if thinking_log else None
|
||||
)
|
||||
await db.commit()
|
||||
except Exception as save_exc: # noqa: BLE001
|
||||
logger.warning("保存失败的助手回复状态失败: %s", save_exc)
|
||||
yield _stream_event("error", {"detail": f"生成对话回复失败: {str(exc)}"})
|
||||
|
|
@ -879,16 +988,14 @@ async def mark_message_interrupted(
|
|||
if msg.role != "assistant":
|
||||
raise HTTPException(status_code=400, detail="仅助手消息可标记为已停止")
|
||||
|
||||
content = msg.content or ""
|
||||
if not content.strip():
|
||||
msg.content = INTERRUPTED_RESPONSE_MARKER
|
||||
elif not _is_interrupted_content(content):
|
||||
msg.content = f"{content.rstrip()}\n\n{INTERRUPTED_RESPONSE_MARKER}"
|
||||
# 只更新状态标志,不再向回复内容里追加标记文本
|
||||
msg.status = "interrupted"
|
||||
await db.commit()
|
||||
|
||||
return success_response(data={
|
||||
"message_id": msg.id,
|
||||
"content": msg.content,
|
||||
"status": msg.status,
|
||||
"duration_ms": msg.duration_ms,
|
||||
})
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,100 @@
|
|||
"""
|
||||
轻量级数据库结构迁移
|
||||
|
||||
项目没有引入 Alembic,新增列通过幂等的 ALTER TABLE 完成:
|
||||
先查询 information_schema 判断列是否已存在,不存在才执行 ALTER,
|
||||
保证多次启动/执行也不会报错或重复加列。
|
||||
"""
|
||||
import logging
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# (表名, 列名, 建列语句)
|
||||
CHAT_MESSAGE_COLUMNS = [
|
||||
(
|
||||
"chat_message",
|
||||
"status",
|
||||
"ALTER TABLE chat_message ADD COLUMN status VARCHAR(32) NOT NULL DEFAULT 'pending' "
|
||||
"COMMENT '消息状态: pending/completed/interrupted/error'",
|
||||
),
|
||||
(
|
||||
"chat_message",
|
||||
"duration_ms",
|
||||
"ALTER TABLE chat_message ADD COLUMN duration_ms INT DEFAULT NULL "
|
||||
"COMMENT '生成耗时(毫秒)'",
|
||||
),
|
||||
(
|
||||
"chat_message",
|
||||
"thinking_log",
|
||||
"ALTER TABLE chat_message ADD COLUMN thinking_log TEXT DEFAULT NULL "
|
||||
"COMMENT '思考过程(JSON数组)'",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
async def _column_exists(conn, table_name: str, column_name: str) -> bool:
|
||||
result = await conn.execute(
|
||||
text(
|
||||
"SELECT COUNT(*) FROM information_schema.COLUMNS "
|
||||
"WHERE TABLE_SCHEMA = DATABASE() "
|
||||
"AND TABLE_NAME = :table_name AND COLUMN_NAME = :column_name"
|
||||
),
|
||||
{"table_name": table_name, "column_name": column_name},
|
||||
)
|
||||
return bool(result.scalar())
|
||||
|
||||
|
||||
async def migrate_schema() -> None:
|
||||
"""为存量数据库补齐新增列,并为历史助手消息回填状态。"""
|
||||
from app.core.database import engine
|
||||
|
||||
added = []
|
||||
async with engine.begin() as conn:
|
||||
for table_name, column_name, ddl in CHAT_MESSAGE_COLUMNS:
|
||||
try:
|
||||
exists = await _column_exists(conn, table_name, column_name)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("检查列 %s.%s 失败,跳过迁移: %s", table_name, column_name, exc)
|
||||
return
|
||||
if not exists:
|
||||
await conn.execute(text(ddl))
|
||||
added.append(f"{table_name}.{column_name}")
|
||||
|
||||
# 历史消息回填:旧的“中断”标记仅存在于 content 末尾,迁移后统一迁移到 status。
|
||||
# 之后不再用内容比对判断中断,status 字段作为唯一依据。
|
||||
await conn.execute(
|
||||
text(
|
||||
"UPDATE chat_message SET status = 'interrupted' "
|
||||
"WHERE role = 'assistant' "
|
||||
"AND (status IS NULL OR status = '' OR status = 'pending') "
|
||||
"AND (content = 'interrupt' OR content LIKE '%\\n\\ninterrupt')"
|
||||
)
|
||||
)
|
||||
# 清理历史“中断”标记文本:迁移后 status 是唯一依据,内容不再混入标记
|
||||
await conn.execute(
|
||||
text(
|
||||
"UPDATE chat_message SET content = '' "
|
||||
"WHERE status = 'interrupted' AND content = 'interrupt'"
|
||||
)
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"UPDATE chat_message SET content = TRIM("
|
||||
"LEFT(content, CHAR_LENGTH(content) - CHAR_LENGTH('\\n\\ninterrupt'))) "
|
||||
"WHERE status = 'interrupted' AND content LIKE '%\\n\\ninterrupt'"
|
||||
)
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"UPDATE chat_message SET status = 'completed' "
|
||||
"WHERE (status IS NULL OR status = '' OR status = 'pending')"
|
||||
)
|
||||
)
|
||||
|
||||
if added:
|
||||
logger.info("数据库迁移完成,新增列: %s", ", ".join(added))
|
||||
else:
|
||||
logger.info("数据库结构已是最新,无需迁移")
|
||||
|
|
@ -7,6 +7,7 @@ from datetime import datetime
|
|||
import hmac
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
import uuid
|
||||
|
||||
from fastapi import HTTPException, Response
|
||||
from sqlalchemy import select
|
||||
|
|
@ -75,7 +76,7 @@ def _ensure_file_not_exists(file_path: Path, path: str) -> None:
|
|||
raise HTTPException(status_code=400, detail=f"文件已存在: {path}")
|
||||
|
||||
if mcp is not None:
|
||||
@mcp.tool(name="list_created_projects", description="Get projects created by the authenticated user.")
|
||||
@mcp.tool(name="list_created_projects", description="获取当前用户创建的项目列表。")
|
||||
async def list_created_projects(keyword: str = "", limit: int = 100) -> List[Dict[str, Any]]:
|
||||
async with AsyncSessionLocal() as db:
|
||||
current_user = await _get_current_user(db)
|
||||
|
|
@ -98,7 +99,73 @@ if mcp is not None:
|
|||
return items[: max(limit, 0)]
|
||||
|
||||
|
||||
@mcp.tool(name="get_project_tree", description="Get the directory tree of a specific project.")
|
||||
@mcp.tool(name="create_project", description="创建新项目(按项目名称创建,创建者自动成为项目管理员)。")
|
||||
async def create_project(name: str, description: str = "") -> Dict[str, Any]:
|
||||
"""按项目名称创建新项目,返回项目 ID 与存储标识。"""
|
||||
name = (name or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="项目名称不能为空")
|
||||
if len(name) > 100:
|
||||
raise HTTPException(status_code=400, detail="项目名称不能超过 100 个字符")
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
current_user = await _get_current_user(db)
|
||||
|
||||
# 生成 UUID 作为存储键
|
||||
storage_key = str(uuid.uuid4())
|
||||
db_project = Project(
|
||||
name=name,
|
||||
description=(description or "").strip() or None,
|
||||
storage_key=storage_key,
|
||||
owner_id=current_user.id,
|
||||
is_public=0,
|
||||
status=1,
|
||||
)
|
||||
db.add(db_project)
|
||||
await db.commit()
|
||||
await db.refresh(db_project)
|
||||
|
||||
# 创建物理文件夹结构,失败则回滚数据库记录
|
||||
try:
|
||||
storage_service.create_project_structure(storage_key)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
await db.delete(db_project)
|
||||
await db.commit()
|
||||
raise HTTPException(status_code=500, detail=f"项目文件夹创建失败: {exc}")
|
||||
|
||||
# 项目创建者自动成为管理员成员
|
||||
db_member = ProjectMember(
|
||||
project_id=db_project.id,
|
||||
user_id=current_user.id,
|
||||
role="admin",
|
||||
)
|
||||
db.add(db_member)
|
||||
await db.commit()
|
||||
|
||||
# 记录操作日志(MCP 无 HTTP 请求对象,跳过 request 字段)
|
||||
try:
|
||||
from app.core.enums import OperationType
|
||||
from app.services.log_service import log_service
|
||||
|
||||
await log_service.log_project_operation(
|
||||
db=db,
|
||||
operation_type=OperationType.CREATE_PROJECT,
|
||||
project_id=db_project.id,
|
||||
user=current_user,
|
||||
detail={"project_name": name, "source": "mcp"},
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
pass
|
||||
|
||||
return {
|
||||
"message": "项目创建成功",
|
||||
"project_id": db_project.id,
|
||||
"name": db_project.name,
|
||||
"storage_key": db_project.storage_key,
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool(name="get_project_tree", description="获取指定项目的目录树。")
|
||||
async def get_project_tree(project_id: int) -> Dict[str, Any]:
|
||||
async with AsyncSessionLocal() as db:
|
||||
current_user = await _get_current_user(db)
|
||||
|
|
@ -114,7 +181,7 @@ if mcp is not None:
|
|||
}
|
||||
|
||||
|
||||
@mcp.tool(name="get_file", description="Read a file from a specific project.")
|
||||
@mcp.tool(name="get_file", description="读取指定项目中的文件内容。")
|
||||
async def get_file(project_id: int, path: str) -> Dict[str, Any]:
|
||||
async with AsyncSessionLocal() as db:
|
||||
current_user = await _get_current_user(db)
|
||||
|
|
@ -125,7 +192,7 @@ if mcp is not None:
|
|||
return {"path": path, "content": content}
|
||||
|
||||
|
||||
@mcp.tool(name="create_file", description="Create a new file in a specific project path.")
|
||||
@mcp.tool(name="create_file", description="在指定项目的路径下创建新文件。")
|
||||
async def create_file(project_id: int, path: str, content: str = "") -> Dict[str, Any]:
|
||||
async with AsyncSessionLocal() as db:
|
||||
current_user = await _get_current_user(db)
|
||||
|
|
@ -150,7 +217,7 @@ if mcp is not None:
|
|||
}
|
||||
|
||||
|
||||
@mcp.tool(name="update_file", description="Update an existing file in a specific project path.")
|
||||
@mcp.tool(name="update_file", description="更新指定项目中已有文件的内容。")
|
||||
async def update_file(project_id: int, path: str, content: str) -> Dict[str, Any]:
|
||||
async with AsyncSessionLocal() as db:
|
||||
current_user = await _get_current_user(db)
|
||||
|
|
@ -174,7 +241,7 @@ if mcp is not None:
|
|||
}
|
||||
|
||||
|
||||
@mcp.tool(name="delete_file", description="Delete an existing file in a specific project path.")
|
||||
@mcp.tool(name="delete_file", description="删除指定项目中的文件。")
|
||||
async def delete_file(project_id: int, path: str) -> Dict[str, Any]:
|
||||
async with AsyncSessionLocal() as db:
|
||||
current_user = await _get_current_user(db)
|
||||
|
|
|
|||
|
|
@ -35,6 +35,9 @@ class ChatMessage(Base):
|
|||
session_id = Column(BigInteger, nullable=False, index=True, comment="会话ID")
|
||||
role = Column(String(32), nullable=False, comment="角色(user/assistant)")
|
||||
content = Column(Text, nullable=False, comment="消息内容")
|
||||
status = Column(String(32), nullable=False, default="pending", comment="消息状态: pending/completed/interrupted/error")
|
||||
duration_ms = Column(Integer, comment="生成耗时(毫秒)")
|
||||
thinking_log = Column(Text, comment="思考过程(JSON数组)")
|
||||
referenced_files = Column(Text, comment="参考文件(JSON数组)")
|
||||
tokens_used = Column(Integer, comment="消耗的token数")
|
||||
is_deleted = Column(Boolean, nullable=False, default=False, comment="是否已删除")
|
||||
|
|
|
|||
|
|
@ -879,6 +879,56 @@ class LLMProviderService:
|
|||
)
|
||||
return vector
|
||||
|
||||
@classmethod
|
||||
async def generate_embeddings(
|
||||
cls,
|
||||
provider: Optional[str],
|
||||
endpoint_url: str,
|
||||
api_key: str,
|
||||
llm_model_name: str,
|
||||
texts: List[str],
|
||||
timeout: int = 60,
|
||||
dimension: Optional[int] = None,
|
||||
) -> List[List[float]]:
|
||||
"""批量生成多条文本向量,尽量在一次请求内完成。"""
|
||||
provider_meta = PROVIDER_MAP.get(provider, PROVIDER_MAP["custom"])
|
||||
endpoint_url = (endpoint_url or provider_meta.get("default_endpoint_url") or "").strip()
|
||||
llm_model_name = (llm_model_name or "").strip()
|
||||
api_key = (api_key or "").strip()
|
||||
inputs = [t[:8000] for t in (texts or []) if t and t.strip()]
|
||||
if not inputs:
|
||||
return []
|
||||
|
||||
if provider == "local":
|
||||
from app.services.local_embedding_service import LocalEmbeddingService
|
||||
|
||||
vectors = await LocalEmbeddingService.generate_embeddings(llm_model_name, inputs)
|
||||
if dimension:
|
||||
for vector in vectors:
|
||||
if len(vector) != int(dimension):
|
||||
raise ValueError(
|
||||
f"模型实际输出 {len(vector)} 维,与配置的 {dimension} 维不一致"
|
||||
)
|
||||
return vectors
|
||||
|
||||
if not endpoint_url:
|
||||
raise ValueError("缺少接口地址,请先选择提供方或手动填写 base_url")
|
||||
if not llm_model_name:
|
||||
raise ValueError("缺少模型名称,请填写 embedding 模型标识")
|
||||
if provider not in {"ollama"} and not api_key:
|
||||
raise ValueError("缺少 API Key,请填写后再调用")
|
||||
|
||||
vectors = await asyncio.to_thread(
|
||||
cls._embeddings_request,
|
||||
endpoint_url,
|
||||
api_key,
|
||||
llm_model_name,
|
||||
inputs,
|
||||
int(timeout or 60),
|
||||
dimension,
|
||||
)
|
||||
return vectors
|
||||
|
||||
@classmethod
|
||||
def _embeddings_request(
|
||||
cls,
|
||||
|
|
|
|||
|
|
@ -88,3 +88,19 @@ class LocalEmbeddingService:
|
|||
async def generate_embedding(cls, model_name: str, text: str) -> List[float]:
|
||||
model_path = cls.resolve_model_path(model_name)
|
||||
return await asyncio.to_thread(cls._encode, model_path, text)
|
||||
|
||||
@classmethod
|
||||
def _encode_batch(cls, model_path: Path, texts: List[str]) -> List[List[float]]:
|
||||
model = cls._get_model(model_path)
|
||||
vectors = model.encode(
|
||||
texts,
|
||||
normalize_embeddings=True,
|
||||
convert_to_numpy=True,
|
||||
show_progress_bar=False,
|
||||
)
|
||||
return [[float(value) for value in vector.tolist()] for vector in vectors]
|
||||
|
||||
@classmethod
|
||||
async def generate_embeddings(cls, model_name: str, texts: List[str]) -> List[List[float]]:
|
||||
model_path = cls.resolve_model_path(model_name)
|
||||
return await asyncio.to_thread(cls._encode_batch, model_path, texts)
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
"""
|
||||
知识库 RAG 服务 - ZVec 向量检索 + 大模型生成
|
||||
"""
|
||||
import re
|
||||
import math
|
||||
import logging
|
||||
from typing import AsyncIterator, List, Dict, Any
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
import jieba
|
||||
|
||||
from app.models.llm_model_config import LLMModelConfig
|
||||
from app.core.config import settings
|
||||
|
|
@ -23,10 +26,55 @@ MAX_CHUNKS_PER_DOCUMENT = 3
|
|||
HISTORY_USER_QUESTION_LIMIT = 3
|
||||
HISTORY_USER_QUESTION_MAX_CHARS = 300
|
||||
|
||||
# 查询关键词提取时过滤的常见提问词/虚词,避免命中高亮时噪声过大
|
||||
QUERY_STOPWORDS = {
|
||||
"的", "了", "吗", "呢", "啊", "哦", "嗯", "吧", "是", "在", "有", "和",
|
||||
"与", "或", "及", "都", "也", "很", "这", "那", "你", "我", "他", "她",
|
||||
"它", "们", "什么", "怎么", "如何", "怎样", "哪些", "哪个", "哪几",
|
||||
"是否", "为什么", "一个", "这个", "那个", "你们", "我们", "他们",
|
||||
"请问", "一下", "可以", "没有", "不是", "就是", "还是", "或者",
|
||||
"包括", "包含", "关于", "对于", "以及", "其中", "请",
|
||||
"what", "which", "who", "whom", "whose", "when", "where", "why",
|
||||
"how", "is", "are", "was", "were", "do", "does", "did", "will",
|
||||
"would", "should", "could", "can", "may", "might", "the", "a",
|
||||
"an", "of", "to", "in", "for", "on", "with", "and", "or", "not",
|
||||
"please", "tell", "show", "list", "about",
|
||||
}
|
||||
|
||||
# 引用支撑句对齐参数
|
||||
CITATION_QUOTE_MIN_SCORE = 0.45
|
||||
CITATION_QUOTE_MAX_PER_REF = 3
|
||||
CITATION_QUOTE_MAX_PER_OCCURRENCE = 1
|
||||
CITATION_CANDIDATES_PER_REF = 4
|
||||
CITATION_CLAIM_MAX_CHARS = 200
|
||||
|
||||
|
||||
class RAGService:
|
||||
"""RAG 知识库检索和生成服务"""
|
||||
|
||||
@staticmethod
|
||||
def _extract_query_terms(query: str, max_terms: int = 8) -> List[str]:
|
||||
"""从用户提问中提取用于命中高亮的关键词(jieba 分词 + 停用词过滤)。"""
|
||||
text = (query or "").strip()
|
||||
if not text:
|
||||
return []
|
||||
terms: List[str] = []
|
||||
seen = set()
|
||||
for word in jieba.cut_for_search(text):
|
||||
word = word.strip()
|
||||
if not word or word in QUERY_STOPWORDS or word.lower() in QUERY_STOPWORDS:
|
||||
continue
|
||||
if len(word) < 2:
|
||||
continue
|
||||
if not re.search(r"[\u4e00-\u9fffA-Za-z0-9]", word):
|
||||
continue
|
||||
if word not in seen:
|
||||
seen.add(word)
|
||||
terms.append(word)
|
||||
if len(terms) >= max_terms:
|
||||
break
|
||||
return terms
|
||||
|
||||
@staticmethod
|
||||
async def retrieve_documents(
|
||||
db: AsyncSession,
|
||||
|
|
@ -41,6 +89,8 @@ class RAGService:
|
|||
if not matched:
|
||||
return []
|
||||
|
||||
hit_terms = RAGService._extract_query_terms(query)
|
||||
|
||||
from app.models.project import Project
|
||||
stmt = select(Project).where(Project.id == project_id)
|
||||
result = await db.execute(stmt)
|
||||
|
|
@ -79,6 +129,7 @@ class RAGService:
|
|||
"score": item.get("score", 0.0),
|
||||
"excerpt": excerpt,
|
||||
"content": snippet,
|
||||
"hit_terms": hit_terms,
|
||||
"_merged_chunks": 1,
|
||||
}
|
||||
except Exception:
|
||||
|
|
@ -90,6 +141,7 @@ class RAGService:
|
|||
"score": item.get("score", 0.0),
|
||||
"excerpt": "",
|
||||
"content": "",
|
||||
"hit_terms": hit_terms,
|
||||
"_merged_chunks": 1,
|
||||
}
|
||||
|
||||
|
|
@ -189,6 +241,192 @@ class RAGService:
|
|||
end = min(len(text), pos + len(anchor) + CHUNK_CONTEXT_WINDOW)
|
||||
return text[start:end]
|
||||
|
||||
@staticmethod
|
||||
def _split_sentences(text: str) -> List[str]:
|
||||
"""按句末标点与换行切分句子,过滤过短片段。"""
|
||||
parts = re.split(r"(?<=[。!?!?;;])\s*|\r?\n+", text or "")
|
||||
sentences = []
|
||||
for part in parts:
|
||||
part = part.strip()
|
||||
if len(part) >= 4:
|
||||
sentences.append(part)
|
||||
return sentences
|
||||
|
||||
@staticmethod
|
||||
def _extract_citation_claims(answer: str) -> Dict[int, List[str]]:
|
||||
"""按出现顺序提取答案中每个 [n] 标记前的一句作为待对齐的论断文本。
|
||||
|
||||
保留全部出现(不做去重),保证列表下标与答案中标记的出现顺序一一对应,
|
||||
供前端按出现位置精确定位每条引用的支撑句。
|
||||
"""
|
||||
claims: Dict[int, List[str]] = {}
|
||||
answer = answer or ""
|
||||
for match in re.finditer(r"\[\d+\]", answer):
|
||||
citation_id = int(match.group()[1:-1])
|
||||
prefix = answer[:match.start()]
|
||||
# 从上一个引用标记或最近的句末标点之后取论断句,支持句中标注
|
||||
delimiter_matches = list(re.finditer(r"[。!?!?;;\n]", prefix))
|
||||
last_delim_end = delimiter_matches[-1].end() if delimiter_matches else 0
|
||||
last_marker_end = prefix.rfind("]") + 1 if "]" in prefix else 0
|
||||
start = max(last_delim_end, last_marker_end)
|
||||
claim = prefix[start:].strip(" \t,,、::")
|
||||
if len(claim) < 4:
|
||||
tail = re.sub(r"\[\d+\]", "", prefix).strip()
|
||||
claim = tail[-CITATION_CLAIM_MAX_CHARS:] if tail else ""
|
||||
if not claim:
|
||||
continue
|
||||
claim = re.sub(r"\[\d+\]", "", claim).strip()[:CITATION_CLAIM_MAX_CHARS]
|
||||
bucket = claims.setdefault(citation_id, [])
|
||||
bucket.append(claim)
|
||||
return claims
|
||||
|
||||
@staticmethod
|
||||
def _sentence_candidates(
|
||||
ref_text: str,
|
||||
claims: List[str],
|
||||
max_candidates: int = CITATION_CANDIDATES_PER_REF,
|
||||
) -> List[str]:
|
||||
"""从引用文本中筛选候选支撑句:优先取与论断词重叠多的句子。"""
|
||||
sentences = RAGService._split_sentences(ref_text)
|
||||
if not sentences:
|
||||
return []
|
||||
|
||||
claim_terms = set()
|
||||
for claim in claims or []:
|
||||
for word in jieba.cut_for_search(claim):
|
||||
word = word.strip()
|
||||
if len(word) >= 2 and word not in QUERY_STOPWORDS:
|
||||
claim_terms.add(word)
|
||||
if not claim_terms:
|
||||
return sentences[:max_candidates]
|
||||
|
||||
scored = []
|
||||
for sentence in sentences:
|
||||
terms = set()
|
||||
for word in jieba.cut_for_search(sentence):
|
||||
word = word.strip()
|
||||
if len(word) >= 2 and word not in QUERY_STOPWORDS:
|
||||
terms.add(word)
|
||||
overlap = len(terms & claim_terms)
|
||||
if overlap > 0:
|
||||
scored.append((overlap, sentence))
|
||||
scored.sort(key=lambda item: item[0], reverse=True)
|
||||
chosen = [sentence for _, sentence in scored[:max_candidates]]
|
||||
# 词重叠为空时兜底取前几句,交给语义对齐判断
|
||||
return chosen or sentences[:max_candidates]
|
||||
|
||||
@staticmethod
|
||||
def _cosine_similarity(a: List[float], b: List[float]) -> float:
|
||||
if not a or not b or len(a) != len(b):
|
||||
return 0.0
|
||||
dot = sum(x * y for x, y in zip(a, b))
|
||||
norm_a = math.sqrt(sum(x * x for x in a))
|
||||
norm_b = math.sqrt(sum(y * y for y in b))
|
||||
if norm_a == 0 or norm_b == 0:
|
||||
return 0.0
|
||||
return dot / (norm_a * norm_b)
|
||||
|
||||
@classmethod
|
||||
async def align_citation_quotes(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
answer: str,
|
||||
refs: List[Dict[str, Any]],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""为引用回填支撑句:答案论断句 ↔ 原文候选句做向量对齐。
|
||||
|
||||
检索命中是语义匹配,答案又是 LLM 重写的,因此无法用词法精确对应。
|
||||
这里把答案中每个 [n] 出现位置前的答案句子与对应引用分块内的句子
|
||||
分别向量化,取相似度最高且超过阈值的原文句子作为「支撑句」。
|
||||
|
||||
同文件多处引用会合并为同一个 citation_id,因此支撑句必须按「出现
|
||||
顺序」逐一回填:ref.quote_occurrences[k] 对应答案中该引用编号的第
|
||||
k 次出现,供前端按出现位置精确展示;ref.quotes 保留聚合结果作兜底。
|
||||
"""
|
||||
if not refs:
|
||||
return refs
|
||||
try:
|
||||
claims_by_id = cls._extract_citation_claims(answer)
|
||||
if not claims_by_id:
|
||||
return refs
|
||||
|
||||
ref_by_id = {
|
||||
int(ref.get("citation_id")): ref
|
||||
for ref in refs
|
||||
if ref.get("citation_id") is not None
|
||||
}
|
||||
if not ref_by_id:
|
||||
return refs
|
||||
|
||||
# 去重后用于向量化,occurrence 级别的对齐仍按下标一一对应
|
||||
all_claims: List[str] = []
|
||||
claim_index: Dict[str, int] = {}
|
||||
for bucket in claims_by_id.values():
|
||||
for claim in bucket:
|
||||
if claim not in claim_index:
|
||||
claim_index[claim] = len(all_claims)
|
||||
all_claims.append(claim)
|
||||
|
||||
candidates_by_id: Dict[int, List[str]] = {}
|
||||
for citation_id, ref in ref_by_id.items():
|
||||
ref_text = ref.get("excerpt") or ref.get("content") or ""
|
||||
candidates = cls._sentence_candidates(
|
||||
ref_text, claims_by_id.get(citation_id, [])
|
||||
)
|
||||
if candidates:
|
||||
candidates_by_id[citation_id] = candidates
|
||||
if not candidates_by_id:
|
||||
return refs
|
||||
|
||||
texts_to_embed: List[str] = list(all_claims)
|
||||
for candidates in candidates_by_id.values():
|
||||
for candidate in candidates:
|
||||
if candidate not in texts_to_embed:
|
||||
texts_to_embed.append(candidate)
|
||||
|
||||
vectors = await zvec_service.generate_embeddings(db, texts_to_embed)
|
||||
if not vectors or len(vectors) != len(texts_to_embed):
|
||||
logger.warning(
|
||||
"Citation quote alignment skipped: embedding unavailable"
|
||||
)
|
||||
return refs
|
||||
|
||||
text_index = {text: idx for idx, text in enumerate(texts_to_embed)}
|
||||
for citation_id, ref in ref_by_id.items():
|
||||
occurrences = []
|
||||
all_quotes = []
|
||||
candidates = candidates_by_id.get(citation_id, [])
|
||||
for claim in claims_by_id.get(citation_id, []):
|
||||
claim_vec = vectors[claim_index[claim]]
|
||||
scored = []
|
||||
for candidate in candidates:
|
||||
score = cls._cosine_similarity(
|
||||
claim_vec, vectors[text_index[candidate]]
|
||||
)
|
||||
if score >= CITATION_QUOTE_MIN_SCORE:
|
||||
scored.append((score, candidate))
|
||||
scored.sort(key=lambda item: item[0], reverse=True)
|
||||
occ_quotes = []
|
||||
for score, candidate_text in scored[:CITATION_QUOTE_MAX_PER_OCCURRENCE]:
|
||||
if not any(q["text"] == candidate_text for q in occ_quotes):
|
||||
occ_quotes.append({
|
||||
"text": candidate_text,
|
||||
"score": round(score, 4),
|
||||
})
|
||||
if not any(q["text"] == candidate_text for q in all_quotes):
|
||||
all_quotes.append({
|
||||
"text": candidate_text,
|
||||
"score": round(score, 4),
|
||||
})
|
||||
occurrences.append({"claim": claim, "quotes": occ_quotes})
|
||||
all_quotes.sort(key=lambda q: q["score"], reverse=True)
|
||||
ref["quote_occurrences"] = occurrences
|
||||
ref["quotes"] = all_quotes[:CITATION_QUOTE_MAX_PER_REF]
|
||||
return refs
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning(f"Citation quote alignment failed: {exc}")
|
||||
return refs
|
||||
|
||||
@staticmethod
|
||||
async def generate_response(
|
||||
db: AsyncSession,
|
||||
|
|
@ -282,10 +520,11 @@ class RAGService:
|
|||
|
||||
回答要求:
|
||||
1. 仅基于知识库内容回答,不要编造。
|
||||
2. 如果使用了某条知识,请在对应句子后标注引用编号,例如 [1] 或 [1][3]。
|
||||
3. 如果没有找到依据,请直接说明未检索到相关内容。
|
||||
4. 只回答消息列表中最后一个用户问题;先前用户问题仅用于理解指代。
|
||||
5. 禁止复述、总结或继续回答先前问题,也不要重复先前助手的答案。
|
||||
2. 每使用一条来自文档的知识,都必须在该句末尾紧跟标注引用编号,格式为半角方括号数字(如 [1]),多个来源连写(如 [1][3])。
|
||||
3. 引用编号必须与「知识库文档内容」中的 [编号] 一一对应,只标注实际引用的文档。
|
||||
4. 如果没有找到依据,请直接说明未检索到相关内容,此时不要标注引用。
|
||||
5. 只回答消息列表中最后一个用户问题;先前用户问题仅用于理解指代。
|
||||
6. 禁止复述、总结或继续回答先前问题,也不要重复先前助手的答案。
|
||||
|
||||
请基于以上知识库内容,用中文回答用户的问题。"""
|
||||
|
||||
|
|
|
|||
|
|
@ -143,21 +143,39 @@ class SearchService:
|
|||
|
||||
try:
|
||||
with self.ix.searcher() as searcher:
|
||||
parser = MultifieldParser(["title", "content"], schema=self.ix.schema)
|
||||
query = parser.parse(keyword)
|
||||
|
||||
filter_query = None
|
||||
if project_id:
|
||||
filter_parser = QueryParser("project_id", schema=self.ix.schema)
|
||||
filter_query = filter_parser.parse(str(project_id))
|
||||
|
||||
# 中文分词后(如"标签表" -> [标签, 表])用 Phrase 做相邻短语匹配:
|
||||
# 只有原文中确实连续出现"标签表"才算命中,避免"标签"与"表"
|
||||
# 分散在不同位置也被匹配出来。whoosh3 修复了 2.7.4 在 Python 3.12
|
||||
# 下 Phrase 查询失效的问题,因此这里使用标准的 Phrase 查询。
|
||||
analyzer = ChineseAnalyzer()
|
||||
tokens = [token.text for token in analyzer(keyword or "") if token.text.strip()]
|
||||
if tokens:
|
||||
from whoosh.query import Or, Phrase
|
||||
|
||||
query = Or([
|
||||
Phrase("title", tokens),
|
||||
Phrase("content", tokens),
|
||||
])
|
||||
else:
|
||||
parser = MultifieldParser(["title", "content"], schema=self.ix.schema)
|
||||
query = parser.parse(keyword)
|
||||
|
||||
results = searcher.search(query, filter=filter_query, limit=limit)
|
||||
results.formatter = HtmlFormatter(tagname="mark", classname="search-highlight", termclass="search-term")
|
||||
results = searcher.search(
|
||||
query, filter=filter_query, limit=limit
|
||||
)
|
||||
results.formatter = HtmlFormatter(
|
||||
tagname="mark", classname="search-highlight", termclass="search-term"
|
||||
)
|
||||
|
||||
search_results = []
|
||||
for hit in results:
|
||||
# 提取原始路径 (去掉 project_id 前缀)
|
||||
full_path = hit.get("path", "")
|
||||
full_path = hit.fields().get("path", "")
|
||||
if ":" in full_path:
|
||||
_, real_path = full_path.split(":", 1)
|
||||
else:
|
||||
|
|
@ -165,14 +183,14 @@ class SearchService:
|
|||
|
||||
# 安全获取高亮
|
||||
try:
|
||||
highlights = hit.highlights("content") or hit.highlights("title") or hit.get("title", "")
|
||||
highlights = hit.highlights("content") or hit.highlights("title") or hit.fields().get("title", "")
|
||||
except:
|
||||
highlights = hit.get("title", "")
|
||||
highlights = hit.fields().get("title", "")
|
||||
|
||||
search_results.append({
|
||||
"project_id": hit.get("project_id"),
|
||||
"project_id": hit.fields().get("project_id"),
|
||||
"path": real_path,
|
||||
"title": hit.get("title"),
|
||||
"title": hit.fields().get("title"),
|
||||
"highlights": highlights,
|
||||
"score": hit.score
|
||||
})
|
||||
|
|
|
|||
|
|
@ -178,6 +178,37 @@ class ZVecService:
|
|||
logger.error(f"Embedding generation failed: {exc}")
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
async def generate_embeddings(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
texts: List[str],
|
||||
config: Optional[LLMModelConfig] = None,
|
||||
) -> Optional[List[List[float]]]:
|
||||
"""批量生成多条文本向量,调用一次模型服务。"""
|
||||
inputs = [t.strip() for t in (texts or []) if t and t.strip()]
|
||||
if not inputs:
|
||||
return []
|
||||
|
||||
config = config or await cls.get_embedding_config(db)
|
||||
if not config:
|
||||
logger.warning("No embedding model configured, skipping vectorization")
|
||||
return None
|
||||
|
||||
try:
|
||||
return await LLMProviderService.generate_embeddings(
|
||||
provider=config.provider,
|
||||
endpoint_url=config.endpoint_url,
|
||||
api_key=config.api_key,
|
||||
llm_model_name=config.llm_model_name,
|
||||
texts=inputs,
|
||||
timeout=config.llm_timeout or 60,
|
||||
dimension=config.embedding_dimension,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(f"Batch embedding generation failed: {exc}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _doc_id(file_path: str, chunk_index: int = 0) -> str:
|
||||
"""生成 ZVec 合法的 doc id。
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||
from contextlib import asynccontextmanager
|
||||
from app.core.config import settings
|
||||
from app.core.redis_client import init_redis, close_redis
|
||||
from app.core.migrations import migrate_schema
|
||||
from app.api.v1 import api_router
|
||||
from app.mcp import create_mcp_http_app, get_mcp_session_manager, MCPHeaderAuthApp
|
||||
|
||||
|
|
@ -21,6 +22,8 @@ except RuntimeError:
|
|||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""应用生命周期管理"""
|
||||
# 启动时补齐数据库新增列(幂等),避免存量库缺少新字段
|
||||
await migrate_schema()
|
||||
# 启动时初始化 Redis
|
||||
await init_redis()
|
||||
if mcp_session_manager is not None:
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ uvicorn>=0.31.1
|
|||
uvloop==0.22.1
|
||||
watchfiles==1.1.1
|
||||
websockets==15.0.1
|
||||
Whoosh==2.7.4
|
||||
whoosh3==3.33.1
|
||||
markdown==3.5.2
|
||||
sentence-transformers>=3.0,<6
|
||||
weasyprint==61.2
|
||||
|
|
|
|||
|
|
@ -267,6 +267,9 @@ CREATE TABLE IF NOT EXISTS `chat_message` (
|
|||
`session_id` BIGINT NOT NULL COMMENT '会话ID',
|
||||
`role` VARCHAR(32) NOT NULL COMMENT '角色(user/assistant)',
|
||||
`content` TEXT NOT NULL COMMENT '消息内容',
|
||||
`status` VARCHAR(32) NOT NULL DEFAULT 'pending' COMMENT '消息状态: pending/completed/interrupted/error',
|
||||
`duration_ms` INT DEFAULT NULL COMMENT '生成耗时(毫秒)',
|
||||
`thinking_log` TEXT DEFAULT NULL COMMENT '思考过程(JSON数组)',
|
||||
`referenced_files` TEXT DEFAULT NULL COMMENT '参考文件(JSON数组)',
|
||||
`tokens_used` INT DEFAULT NULL COMMENT '消耗的token数',
|
||||
`is_deleted` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否已删除',
|
||||
|
|
@ -314,7 +317,7 @@ INSERT INTO `system_menus` (`id`, `parent_id`, `menu_name`, `menu_code`, `menu_t
|
|||
(4, 1, '编辑项目', 'project:edit', 3, NULL, NULL, 3, 'project:edit'),
|
||||
(5, 1, '删除项目', 'project:delete', 3, NULL, NULL, 4, 'project:delete'),
|
||||
(10, 0, '知识库管理', 'knowledge', 1, NULL, 'FileTextOutlined', 2, NULL),
|
||||
(11, 10, '我的知识库', 'knowledge:view', 2, '/chat', NULL, 1, 'knowledge:view'),
|
||||
(11, 10, '我的知识库', 'knowledge:view', 2, '/chat', 'CommentOutlined', 1, 'knowledge:view'),
|
||||
(12, 10, '编辑知识库', 'knowledge:edit', 3, NULL, NULL, 2, 'knowledge:edit'),
|
||||
(13, 10, '删除知识库', 'knowledge:delete', 3, NULL, NULL, 3, 'knowledge:delete'),
|
||||
(20, 0, '系统管理', 'system', 1, '/system', 'SettingOutlined', 3, NULL),
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ from app.models.role import Role
|
|||
from app.models.menu import SystemMenu
|
||||
from app.models.project import Project, ProjectMember
|
||||
from app.core.security import get_password_hash
|
||||
from app.core.migrations import migrate_schema
|
||||
|
||||
|
||||
async def init_tables():
|
||||
|
|
@ -31,6 +32,9 @@ async def init_tables():
|
|||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
print("✓ 数据库表创建成功")
|
||||
print("正在执行数据库结构迁移...")
|
||||
await migrate_schema()
|
||||
print("✓ 数据库结构迁移完成")
|
||||
|
||||
|
||||
async def init_roles():
|
||||
|
|
@ -213,7 +217,7 @@ async def init_menus():
|
|||
menu_type=1,
|
||||
path="/knowledge",
|
||||
component="MyKnowledge",
|
||||
icon="ReadOutlined",
|
||||
icon="CommentOutlined",
|
||||
sort_order=1,
|
||||
visible=1,
|
||||
status=1
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from app.api.v1.chat import _canonicalize_message_citations, _compact_cited_refs
|
||||
from app.services.rag_service import RAGService
|
||||
|
|
@ -44,6 +44,9 @@ class ChatCitationTest(unittest.TestCase):
|
|||
"file_path": "内容导航.md",
|
||||
"anchor_text": "",
|
||||
"excerpt": "",
|
||||
"content": "",
|
||||
"chunk_index": None,
|
||||
"hit_terms": [],
|
||||
}])
|
||||
|
||||
def test_used_references_follow_first_appearance_order(self):
|
||||
|
|
@ -91,5 +94,91 @@ class RAGConversationContextTest(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertIn("禁止复述、总结或继续回答先前问题", system_prompt)
|
||||
|
||||
|
||||
class CitationQuoteExtractionTest(unittest.TestCase):
|
||||
def test_split_sentences(self):
|
||||
self.assertEqual(
|
||||
RAGService._split_sentences("第一句。第二句!第三句?\n第四句;第五句话。"),
|
||||
["第一句。", "第二句!", "第三句?", "第四句;", "第五句话。"],
|
||||
)
|
||||
|
||||
def test_extract_citation_claims(self):
|
||||
answer = (
|
||||
"土星拥有众多卫星[1]。泰坦是其中最大的一颗[1]。\n"
|
||||
"关键参数:直径约 120536 公里[1]。\n"
|
||||
"该产品型号为 X200[1],电池容量 5000mAh[1]。"
|
||||
)
|
||||
claims = RAGService._extract_citation_claims(answer)
|
||||
self.assertEqual(claims[1], [
|
||||
"土星拥有众多卫星",
|
||||
"泰坦是其中最大的一颗",
|
||||
"关键参数:直径约 120536 公里",
|
||||
"该产品型号为 X200",
|
||||
"电池容量 5000mAh",
|
||||
])
|
||||
|
||||
def test_sentence_candidates_prefer_term_overlap(self):
|
||||
candidates = RAGService._sentence_candidates(
|
||||
"土星是太阳系第二大行星。\n土卫六又称泰坦,是土星最大的卫星。\n"
|
||||
"木星拥有最多卫星。",
|
||||
["泰坦是最大的卫星"],
|
||||
max_candidates=3,
|
||||
)
|
||||
self.assertIn("土卫六又称泰坦,是土星最大的卫星。", candidates[:1])
|
||||
|
||||
def test_cosine_similarity(self):
|
||||
self.assertAlmostEqual(RAGService._cosine_similarity([1.0, 0.0], [1.0, 0.0]), 1.0)
|
||||
self.assertAlmostEqual(RAGService._cosine_similarity([1.0, 0.0], [0.0, 1.0]), 0.0)
|
||||
self.assertEqual(RAGService._cosine_similarity([], [1.0]), 0.0)
|
||||
|
||||
|
||||
class CitationQuoteAlignmentTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_align_citation_quotes_picks_similar_sentence(self):
|
||||
refs = [{
|
||||
"citation_id": 1,
|
||||
"file_path": "产品资料.md",
|
||||
"anchor_text": "型号 X200",
|
||||
"excerpt": "型号 X200 支持 5G。\n电池容量为 5000mAh。\n屏幕尺寸 6.7 英寸。",
|
||||
"content": "",
|
||||
}]
|
||||
answer = "该产品型号为 X200[1],电池续航出色[1]。"
|
||||
|
||||
async def fake_embeddings(db, texts, config=None):
|
||||
return [
|
||||
[1.0, 0.0, 0.0] if ("电池" in text or "5000mAh" in text) else [0.0, 1.0, 0.0]
|
||||
for text in texts
|
||||
]
|
||||
|
||||
with patch("app.services.rag_service.zvec_service") as mock_zvec:
|
||||
mock_zvec.generate_embeddings = fake_embeddings
|
||||
result = await RAGService.align_citation_quotes(None, answer, refs)
|
||||
|
||||
self.assertIn("quotes", result[0])
|
||||
self.assertTrue(any(
|
||||
"5000mAh" in q["text"] or "电池" in q["text"]
|
||||
for q in result[0]["quotes"]
|
||||
))
|
||||
# 按出现顺序回填:第一处对应型号,第二处对应电池
|
||||
occurrences = result[0]["quote_occurrences"]
|
||||
self.assertEqual(len(occurrences), 2)
|
||||
self.assertEqual(occurrences[0]["claim"], "该产品型号为 X200")
|
||||
self.assertEqual(occurrences[1]["claim"], "电池续航出色")
|
||||
self.assertTrue(any("电池" in q["text"] for q in occurrences[1]["quotes"]))
|
||||
|
||||
async def test_align_citation_quotes_falls_back_when_embedding_unavailable(self):
|
||||
refs = [{
|
||||
"citation_id": 1,
|
||||
"file_path": "a.md",
|
||||
"excerpt": "某句支撑内容。",
|
||||
"content": "",
|
||||
}]
|
||||
|
||||
with patch("app.services.rag_service.zvec_service") as mock_zvec:
|
||||
mock_zvec.generate_embeddings = AsyncMock(return_value=None)
|
||||
result = await RAGService.align_citation_quotes(None, "这是论断[1]。", refs)
|
||||
|
||||
self.assertEqual(result, refs)
|
||||
self.assertNotIn("quotes", result[0])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,72 @@
|
|||
import asyncio
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from whoosh import index
|
||||
from whoosh.fields import Schema, TEXT, ID
|
||||
|
||||
from app.services.search_service import SearchService, ChineseAnalyzer
|
||||
|
||||
|
||||
class SearchServiceTest(unittest.TestCase):
|
||||
def _build_service(self):
|
||||
tmp = tempfile.mkdtemp()
|
||||
schema = Schema(
|
||||
project_id=ID(stored=True),
|
||||
path=ID(unique=True, stored=True),
|
||||
title=TEXT(stored=True, analyzer=ChineseAnalyzer()),
|
||||
content=TEXT(stored=True, analyzer=ChineseAnalyzer()),
|
||||
)
|
||||
ix = index.create_in(tmp, schema)
|
||||
writer = ix.writer()
|
||||
writer.add_document(
|
||||
project_id="1",
|
||||
path="1:标签表.md",
|
||||
title="标签表.md",
|
||||
content="本文档介绍标签表的使用方法。",
|
||||
)
|
||||
writer.add_document(
|
||||
project_id="1",
|
||||
path="1:标签分类.md",
|
||||
title="标签分类.md",
|
||||
content="文档按标签进行分类,分类表存储记录。",
|
||||
)
|
||||
writer.add_document(
|
||||
project_id="1",
|
||||
path="1:标签说明.md",
|
||||
title="标签说明.md",
|
||||
content="标签用于内容分类。",
|
||||
)
|
||||
writer.add_document(
|
||||
project_id="1",
|
||||
path="1:配置表.md",
|
||||
title="配置表.md",
|
||||
content="该表用于系统配置。",
|
||||
)
|
||||
writer.commit()
|
||||
|
||||
service = SearchService()
|
||||
service.ix = ix
|
||||
return service
|
||||
|
||||
def test_phrase_keyword_matches_only_exact_content(self):
|
||||
"""搜索「标签表」只应命中原文确实包含该短语的文件,
|
||||
而不是「标签」+「表」分散出现在不同位置的文件。"""
|
||||
service = self._build_service()
|
||||
result = asyncio.run(service.search("标签表", "1"))
|
||||
self.assertEqual([item["path"] for item in result], ["标签表.md"])
|
||||
|
||||
def test_single_token_search_returns_all_matches(self):
|
||||
service = self._build_service()
|
||||
result = asyncio.run(service.search("标签", "1"))
|
||||
paths = {item["path"] for item in result}
|
||||
self.assertEqual(paths, {"标签表.md", "标签分类.md", "标签说明.md"})
|
||||
|
||||
def test_no_match_returns_empty(self):
|
||||
service = self._build_service()
|
||||
result = asyncio.run(service.search("接口文档", "1"))
|
||||
self.assertEqual(result, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -71,6 +71,7 @@ export const sendChatMessageStream = async (sessionId, message, handlers = {}) =
|
|||
|
||||
if (event === 'ids') handlers.onIds?.(data)
|
||||
if (event === 'references') handlers.onReferences?.(data || [])
|
||||
if (event === 'thinking') handlers.onThinking?.(data)
|
||||
if (event === 'chunk') handlers.onChunk?.(data?.content || '')
|
||||
if (event === 'title') handlers.onTitle?.(data)
|
||||
if (event === 'done') {
|
||||
|
|
|
|||
|
|
@ -113,7 +113,15 @@ const LargeMarkdownViewer = forwardRef(function LargeMarkdownViewer(
|
|||
|
||||
useImperativeHandle(ref, () => ({
|
||||
scrollToTop: () => scrollToIndex(0),
|
||||
}), [])
|
||||
// 引用跳转/搜索定位:找到包含关键词的文档块并滚动到该块
|
||||
scrollToKeyword: (keyword) => {
|
||||
if (!keyword) return
|
||||
const index = markdownBlocks.findIndex((block) => block.includes(keyword))
|
||||
if (index >= 0) {
|
||||
scrollToIndex(index)
|
||||
}
|
||||
},
|
||||
}), [markdownBlocks])
|
||||
|
||||
const handleTocNavigate = (link) => {
|
||||
const index = hrefToIndex.get(link)
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
RocketOutlined,
|
||||
ReadOutlined,
|
||||
BookOutlined,
|
||||
CommentOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { message } from 'antd'
|
||||
import { getUserMenus } from '@/api/menu'
|
||||
|
|
@ -42,6 +43,7 @@ const iconMap = {
|
|||
ProjectOutlined: <ProjectOutlined />,
|
||||
ReadOutlined: <ReadOutlined />,
|
||||
BookOutlined: <BookOutlined />,
|
||||
CommentOutlined: <CommentOutlined />,
|
||||
}
|
||||
|
||||
const builtInMenuMetaMap = {
|
||||
|
|
@ -63,7 +65,7 @@ const builtInMenuMetaMap = {
|
|||
},
|
||||
'knowledge:my': {
|
||||
path: '/chat',
|
||||
icon: 'ReadOutlined',
|
||||
icon: 'CommentOutlined',
|
||||
},
|
||||
'system:users': {
|
||||
path: '/system/users',
|
||||
|
|
@ -129,7 +131,7 @@ const resolveBuiltInMenuMeta = (item) => {
|
|||
if (item.path === '/knowledge' || item.path === '/knowledge/my' || item.menu_name === '我的知识库') {
|
||||
return {
|
||||
path: '/chat',
|
||||
icon: 'ReadOutlined',
|
||||
icon: 'CommentOutlined',
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -65,11 +65,24 @@ body {
|
|||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* 知识库引用跳转定位(mark.js 生成),样式与搜索高亮保持一致 */
|
||||
.citation-highlight {
|
||||
background-color: #ffd54f !important;
|
||||
color: black !important;
|
||||
font-weight: bold;
|
||||
padding: 0 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* 从知识库引用跳转定位到的段落,短暂强调后淡出 */
|
||||
.search-highlight.cited-highlight-flash {
|
||||
animation: cited-flash 2.4s ease-out;
|
||||
}
|
||||
|
||||
.citation-highlight.cited-highlight-flash {
|
||||
animation: cited-flash 2.4s ease-out;
|
||||
}
|
||||
|
||||
@keyframes cited-flash {
|
||||
0%, 30% {
|
||||
background-color: #ff9800 !important;
|
||||
|
|
|
|||
|
|
@ -234,7 +234,7 @@
|
|||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 18px 20px 20px;
|
||||
padding: 18px 32px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
|
|
@ -250,37 +250,20 @@
|
|||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.chat-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-avatar.user {
|
||||
background: #1677ff;
|
||||
}
|
||||
|
||||
.chat-avatar.assistant {
|
||||
background: color-mix(in srgb, #1677ff 10%, var(--card-bg));
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.chat-message-bubble {
|
||||
max-width: 100%;
|
||||
width: fit-content;
|
||||
padding: 14px 16px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--card-bg);
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text-color);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.chat-message-bubble.user {
|
||||
background: #1677ff;
|
||||
color: #fff;
|
||||
border-color: #1677ff;
|
||||
background: color-mix(in srgb, var(--text-color) 8%, transparent);
|
||||
border-radius: 10px;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
.chat-markdown {
|
||||
|
|
@ -405,13 +388,13 @@
|
|||
}
|
||||
|
||||
.chat-citation {
|
||||
font-size: 0.72em;
|
||||
font-size: 0.6em;
|
||||
line-height: 0;
|
||||
vertical-align: super;
|
||||
color: #1677ff;
|
||||
font-weight: 600;
|
||||
margin: 0 3px;
|
||||
padding: 0 1px;
|
||||
margin: 0 2px;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
|
@ -437,56 +420,90 @@
|
|||
}
|
||||
}
|
||||
|
||||
.chat-thinking {
|
||||
.chat-thinking-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chat-thinking-panel.active {
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
.chat-thinking-timer {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 14px;
|
||||
line-height: 1.75;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.chat-thinking-text {
|
||||
animation: chat-thinking-pulse 1.6s ease-in-out infinite;
|
||||
.chat-thinking-log {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding-left: 12px;
|
||||
margin-left: 2px;
|
||||
border-left: 2px solid color-mix(in srgb, var(--border-color) 70%, transparent);
|
||||
}
|
||||
|
||||
@keyframes chat-thinking-pulse {
|
||||
0%, 100% { opacity: 0.55; }
|
||||
50% { opacity: 1; }
|
||||
.chat-thinking-log-item {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.chat-thinking-dots {
|
||||
.chat-thinking-log-time {
|
||||
font-size: 12px;
|
||||
color: color-mix(in srgb, var(--text-color-secondary) 75%, transparent);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chat-thinking-log-final {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.chat-thinking-panel.collapsed {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.chat-thinking-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
gap: 6px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 2px 0;
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.chat-thinking-dots i {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
display: inline-block;
|
||||
animation: chat-thinking-bounce 1.2s ease-in-out infinite;
|
||||
.chat-thinking-toggle:hover {
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.chat-thinking-dots i:nth-child(2) {
|
||||
animation-delay: 0.18s;
|
||||
.chat-thinking-chevron {
|
||||
font-size: 10px;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.chat-thinking-dots i:nth-child(3) {
|
||||
animation-delay: 0.36s;
|
||||
.chat-thinking-badge {
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: #d4380d;
|
||||
background: color-mix(in srgb, #d4380d 10%, transparent);
|
||||
padding: 0 6px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
@keyframes chat-thinking-bounce {
|
||||
0%, 80%, 100% {
|
||||
transform: translateY(0);
|
||||
opacity: 0.4;
|
||||
}
|
||||
40% {
|
||||
transform: translateY(-4px);
|
||||
opacity: 1;
|
||||
}
|
||||
.chat-thinking-duration {
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
.chat-message-column {
|
||||
|
|
@ -545,23 +562,67 @@
|
|||
}
|
||||
|
||||
.chat-references {
|
||||
margin-top: 12px;
|
||||
margin-top: 14px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px dashed var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.chat-references-label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
.chat-references-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-references-label,
|
||||
.chat-references-count {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.chat-reference-link {
|
||||
cursor: pointer;
|
||||
.chat-references-label {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chat-reference-link:hover {
|
||||
opacity: 0.82;
|
||||
.chat-references-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-reference-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
max-width: 100%;
|
||||
padding: 3px 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--border-color) 6%, transparent);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.chat-reference-chip:hover {
|
||||
border-color: color-mix(in srgb, #1677ff 45%, var(--border-color));
|
||||
background: color-mix(in srgb, #1677ff 8%, transparent);
|
||||
}
|
||||
|
||||
.chat-reference-number {
|
||||
color: #1677ff;
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-reference-file {
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.chat-message-error {
|
||||
|
|
@ -583,9 +644,16 @@
|
|||
max-width: 460px;
|
||||
}
|
||||
|
||||
.chat-citation-preview-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.chat-citation-preview-file {
|
||||
display: block;
|
||||
word-break: break-all;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.chat-citation-preview-content {
|
||||
|
|
@ -599,6 +667,53 @@
|
|||
background: color-mix(in srgb, var(--border-color) 10%, transparent);
|
||||
}
|
||||
|
||||
.chat-citation-quotes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-citation-quote-row {
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.chat-citation-claim {
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: var(--text-color);
|
||||
background: color-mix(in srgb, #1677ff 8%, transparent);
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.chat-citation-quote {
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: var(--text-color);
|
||||
border-left: 3px solid #52c41a;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.chat-citation-quote-open {
|
||||
color: #1677ff;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
margin-left: 6px;
|
||||
vertical-align: -1px;
|
||||
}
|
||||
|
||||
.chat-citation-quote-open:hover {
|
||||
color: #0958d9;
|
||||
}
|
||||
|
||||
.chat-citation-fallback {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.chat-citation-preview-content > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
|
@ -618,6 +733,13 @@
|
|||
font-size: 0.92em;
|
||||
}
|
||||
|
||||
.chat-citation-preview-content .chat-chunk-band {
|
||||
border-radius: 3px;
|
||||
background: color-mix(in srgb, #1677ff 14%, transparent);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, #1677ff 14%, transparent);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.chat-citation-popover .ant-popover-inner {
|
||||
max-width: min(520px, calc(100vw - 40px));
|
||||
}
|
||||
|
|
@ -869,6 +991,11 @@ body.dark .chat-search-highlight {
|
|||
color: #ffe58f;
|
||||
}
|
||||
|
||||
body.dark .chat-citation-preview-content .chat-chunk-band {
|
||||
background: color-mix(in srgb, #1677ff 26%, transparent);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, #1677ff 26%, transparent);
|
||||
}
|
||||
|
||||
body.dark .chat-composer-shell {
|
||||
border-color: rgba(22, 119, 255, 0.45);
|
||||
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.25);
|
||||
|
|
|
|||
|
|
@ -32,64 +32,145 @@ import {
|
|||
CheckOutlined,
|
||||
StopOutlined,
|
||||
ReloadOutlined,
|
||||
LinkOutlined,
|
||||
LoadingOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import Highlighter from 'react-highlight-words'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import rehypeHighlight from 'rehype-highlight'
|
||||
import rehypeRaw from 'rehype-raw'
|
||||
import 'highlight.js/styles/github.css'
|
||||
import { createChatSession, deleteChatMessage, deleteChatSession, getChatMessages, getChatSessions, markMessageInterrupted, searchChatMessages, sendChatMessageStream, updateChatSessionTitle } from '@/api/chat'
|
||||
import { getMyProjects } from '@/api/project'
|
||||
import { getLLMModelConfigs } from '@/api/llmModelConfigs'
|
||||
import useUserStore from '@/stores/userStore'
|
||||
import './Chat.css'
|
||||
|
||||
const { TextArea } = Input
|
||||
const { Text } = Typography
|
||||
|
||||
const CITATION_RE = /\[(\d+)\]/g
|
||||
// 与后端 INTERRUPTED_RESPONSE_MARKER 保持一致,用于识别“已停止生成”的消息
|
||||
const STOPPED_MARKER = 'interrupt'
|
||||
const STOPPED_SUFFIX = `\n\n${STOPPED_MARKER}`
|
||||
|
||||
function isStoppedContent(content) {
|
||||
return content === STOPPED_MARKER || content.endsWith(STOPPED_SUFFIX)
|
||||
function formatDuration(ms) {
|
||||
if (ms == null) return ''
|
||||
const totalSeconds = Math.max(0, ms) / 1000
|
||||
if (totalSeconds < 60) return `${totalSeconds.toFixed(1)} 秒`
|
||||
const minutes = Math.floor(totalSeconds / 60)
|
||||
const seconds = Math.round(totalSeconds % 60)
|
||||
return `${minutes} 分 ${seconds} 秒`
|
||||
}
|
||||
|
||||
function stripStoppedContent(content) {
|
||||
if (content === STOPPED_MARKER) return ''
|
||||
if (content.endsWith(STOPPED_SUFFIX)) {
|
||||
return content.slice(0, -STOPPED_SUFFIX.length).replace(/\s+$/g, '')
|
||||
function useElapsedTimer(startedAt, active) {
|
||||
const [now, setNow] = useState(Date.now())
|
||||
useEffect(() => {
|
||||
if (!active) return undefined
|
||||
setNow(Date.now())
|
||||
const timer = window.setInterval(() => setNow(Date.now()), 500)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [active, startedAt])
|
||||
return active ? Math.max(0, now - (startedAt || now)) : null
|
||||
}
|
||||
|
||||
function ThinkingPanel({ active, log = [], startedAt, durationMs, status, visible, onToggle }) {
|
||||
const elapsed = useElapsedTimer(startedAt, active)
|
||||
if (active) {
|
||||
return (
|
||||
<div className="chat-thinking-panel active">
|
||||
<div className="chat-thinking-timer">
|
||||
<LoadingOutlined spin /> 思考中 · {formatDuration(elapsed)}
|
||||
</div>
|
||||
{log.length > 0 && (
|
||||
<div className="chat-thinking-log">
|
||||
{log.map((entry, index) => (
|
||||
<div key={index} className="chat-thinking-log-item">
|
||||
<span>{entry.message}</span>
|
||||
{entry.duration_ms != null && (
|
||||
<span className="chat-thinking-log-time">用时 {formatDuration(entry.duration_ms)}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return content
|
||||
|
||||
const hasLog = log.length > 0
|
||||
const hasDuration = durationMs != null
|
||||
if (!hasLog && !hasDuration) return null
|
||||
return (
|
||||
<div className="chat-thinking-panel collapsed">
|
||||
<button
|
||||
type="button"
|
||||
className="chat-thinking-toggle"
|
||||
onClick={onToggle}
|
||||
aria-expanded={visible}
|
||||
>
|
||||
<DownOutlined rotate={visible ? 180 : 0} className="chat-thinking-chevron" />
|
||||
<span>思考过程</span>
|
||||
{status === 'interrupted' && <span className="chat-thinking-badge">已停止</span>}
|
||||
{hasDuration && <span className="chat-thinking-duration">· {formatDuration(durationMs)}</span>}
|
||||
</button>
|
||||
{visible && (
|
||||
<div className="chat-thinking-log">
|
||||
{log.map((entry, index) => (
|
||||
<div key={index} className="chat-thinking-log-item">
|
||||
<span>{entry.message}</span>
|
||||
{entry.duration_ms != null && (
|
||||
<span className="chat-thinking-log-time">用时 {formatDuration(entry.duration_ms)}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{hasDuration && (
|
||||
<div className="chat-thinking-log-item chat-thinking-log-final">
|
||||
{status === 'interrupted' ? '已停止' : (status === 'error' ? '生成失败' : '完成')} · 用时 {formatDuration(durationMs)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// rehype 插件:将正文中的 [n] 引用编号转换为上角标 <sup>,便于与正文区分。
|
||||
// 跳过 code/pre 节点,避免破坏代码块中的方括号内容。
|
||||
function rehypeCitationSup() {
|
||||
// 按引用编号统计出现次数,使同一编号的多处引用可以精确区分
|
||||
const occurrenceCounters = {}
|
||||
const walk = (node, inCode) => {
|
||||
if (!node.children) return
|
||||
const nextChildren = []
|
||||
for (const child of node.children) {
|
||||
const childInCode = inCode || child.tagName === 'code' || child.tagName === 'pre'
|
||||
if (child.type === 'text' && !childInCode && CITATION_RE.test(child.value)) {
|
||||
if (child.type === 'text' && CITATION_RE.test(child.value)) {
|
||||
CITATION_RE.lastIndex = 0
|
||||
let lastIndex = 0
|
||||
let lastPush = 0
|
||||
let match
|
||||
while ((match = CITATION_RE.exec(child.value)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
nextChildren.push({ type: 'text', value: child.value.slice(lastIndex, match.index) })
|
||||
const citationId = match[1]
|
||||
// 即使标记位于代码块内也要计数,保持与后端按文本全文计数的顺序一致
|
||||
const occIndex = occurrenceCounters[citationId] = (occurrenceCounters[citationId] ?? -1) + 1
|
||||
if (!childInCode) {
|
||||
if (match.index > lastPush) {
|
||||
nextChildren.push({ type: 'text', value: child.value.slice(lastPush, match.index) })
|
||||
}
|
||||
nextChildren.push({
|
||||
type: 'element',
|
||||
tagName: 'sup',
|
||||
properties: {
|
||||
className: ['chat-citation'],
|
||||
'data-citation-id': citationId,
|
||||
'data-citation-occ': occIndex,
|
||||
},
|
||||
children: [{ type: 'text', value: `[${citationId}]` }],
|
||||
})
|
||||
lastPush = match.index + match[0].length
|
||||
}
|
||||
nextChildren.push({
|
||||
type: 'element',
|
||||
tagName: 'sup',
|
||||
properties: { className: ['chat-citation'], 'data-citation-id': match[1] },
|
||||
children: [{ type: 'text', value: `[${match[1]}]` }],
|
||||
})
|
||||
lastIndex = match.index + match[0].length
|
||||
}
|
||||
if (lastIndex < child.value.length) {
|
||||
nextChildren.push({ type: 'text', value: child.value.slice(lastIndex) })
|
||||
if (childInCode) {
|
||||
nextChildren.push(child)
|
||||
} else if (lastPush < child.value.length) {
|
||||
nextChildren.push({ type: 'text', value: child.value.slice(lastPush) })
|
||||
}
|
||||
} else {
|
||||
if (child.type === 'element') walk(child, childInCode)
|
||||
|
|
@ -143,15 +224,46 @@ function stripMarkdown(text) {
|
|||
.trim()
|
||||
}
|
||||
|
||||
function buildDocumentPreviewUrl(projectId, filePath) {
|
||||
if (!projectId || !filePath) return ''
|
||||
return `/projects/${projectId}/docs?file=${encodeURIComponent(filePath)}`
|
||||
// 去掉 markdown 语法标记,使关键词能与渲染后的纯文本精确匹配(用于原文定位)
|
||||
function stripMarkdownForHighlight(text) {
|
||||
return String(text || '')
|
||||
.replace(/```[\s\S]*?```/g, ' ')
|
||||
.replace(/`([^`]*)`/g, '$1')
|
||||
.replace(/\*\*([^*]+)\*\*/g, '$1')
|
||||
.replace(/\*([^*]+)\*/g, '$1')
|
||||
.replace(/__([^_]+)__/g, '$1')
|
||||
.replace(/_([^_]+)_/g, '$1')
|
||||
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
|
||||
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
|
||||
.replace(/^#{1,6}\s+/gm, '')
|
||||
.replace(/^\s*[-*+]\s+/gm, '')
|
||||
.replace(/^\s*\d+\.\s+/gm, '')
|
||||
.replace(/^\s*>\s+/gm, '')
|
||||
.replace(/~~([^~]+)~~/g, '$1')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function openDocument(ref, projectId) {
|
||||
function buildDocumentPreviewUrl(projectId, filePath, highlight) {
|
||||
if (!projectId || !filePath) return ''
|
||||
const params = new URLSearchParams()
|
||||
params.set('file', filePath)
|
||||
if (highlight) params.set('hl', highlight)
|
||||
return `/projects/${projectId}/docs?${params.toString()}`
|
||||
}
|
||||
|
||||
// 跳转原文时优先用「支撑句原文」作为定位关键词,文档页据此高亮并滚动到命中位置
|
||||
function getDocumentJumpKeyword(ref, occData) {
|
||||
const quoteText = (occData?.quotes?.[0]?.text || ref?.quotes?.[0]?.text || '').trim()
|
||||
if (quoteText) return stripMarkdownForHighlight(quoteText)
|
||||
const anchor = (ref?.anchor_text || '').trim()
|
||||
return anchor ? stripMarkdownForHighlight(anchor).slice(0, 32) : ''
|
||||
}
|
||||
|
||||
function openDocument(ref, projectId, occData) {
|
||||
const previewUrl = buildDocumentPreviewUrl(
|
||||
ref?.project_id || projectId,
|
||||
ref?.file_path
|
||||
ref?.file_path,
|
||||
getDocumentJumpKeyword(ref, occData)
|
||||
)
|
||||
if (previewUrl) window.open(previewUrl, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
|
@ -160,6 +272,27 @@ function getReferenceExcerpt(ref) {
|
|||
return (ref?.excerpt || ref?.anchor_text || '').trim()
|
||||
}
|
||||
|
||||
function getReferenceContext(ref) {
|
||||
return (ref?.content || ref?.excerpt || ref?.anchor_text || '').trim()
|
||||
}
|
||||
|
||||
function escapeAngleBrackets(value) {
|
||||
return String(value ?? '').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
// 渲染引用片段:命中的分块区间用「证据条」标出(chat-chunk-band)。
|
||||
// 提问关键词不再高亮——检索是语义匹配,提问词与原文命中并无字面对应。
|
||||
function highlightCitation(text, chunkText) {
|
||||
const escaped = escapeAngleBrackets(text)
|
||||
const chunk = escapeAngleBrackets(chunkText)
|
||||
// CommonMark 会在空行处断开段落,跨多段的分块不能整体包 <mark>,否则标签错位
|
||||
if (!chunk || /\r?\n\s*\r?\n/.test(chunk) || !escaped.includes(chunk)) {
|
||||
return escaped
|
||||
}
|
||||
const idx = escaped.indexOf(chunk)
|
||||
return `${escaped.slice(0, idx)}<mark class="chat-chunk-band">${chunk}</mark>${escaped.slice(idx + chunk.length)}`
|
||||
}
|
||||
|
||||
function loadPendingStops() {
|
||||
try {
|
||||
return JSON.parse(sessionStorage.getItem('nex-chat-pending-stops') || '{}')
|
||||
|
|
@ -189,6 +322,43 @@ function SearchHighlight({ text, keyword }) {
|
|||
)
|
||||
}
|
||||
|
||||
function CitationMarkdown({ children }) {
|
||||
return (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeHighlight, rehypeRaw]}
|
||||
components={{
|
||||
mark: ({ children, node: _node, ...props }) => <mark {...props}>{children}</mark>,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ReactMarkdown>
|
||||
)
|
||||
}
|
||||
|
||||
function ReferenceCard({ reference, projectId }) {
|
||||
return (
|
||||
<span
|
||||
className="chat-reference-chip"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
title="打开原文"
|
||||
onClick={() => openDocument(reference, projectId)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
openDocument(reference, projectId)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="chat-reference-number">[{reference.citation_id}]</span>
|
||||
<span className="chat-reference-file" title={reference.file_path || reference.file_name}>
|
||||
{reference.file_name || reference.file_path}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
async function copyText(text) {
|
||||
const value = text || ''
|
||||
try {
|
||||
|
|
@ -254,14 +424,16 @@ function MessageActions({ content, onCopy, onDelete, onRegenerate }) {
|
|||
function Chat() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const { user: currentUser } = useUserStore()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const [sessions, setSessions] = useState([])
|
||||
const [projects, setProjects] = useState([])
|
||||
const [models, setModels] = useState([])
|
||||
const [currentSession, setCurrentSession] = useState(null)
|
||||
const [messages, setMessages] = useState([])
|
||||
const [loadingSessions, setLoadingSessions] = useState(false)
|
||||
// 刷新直达已有会话时,首帧即为加载态,避免闪现「新建对话」页
|
||||
const [loadingSessions, setLoadingSessions] = useState(
|
||||
() => /[?&](session_id|sessionId)=/.test(window.location.search)
|
||||
)
|
||||
const [loadingMessages, setLoadingMessages] = useState(false)
|
||||
const [sending, setSending] = useState(false)
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
|
|
@ -292,6 +464,10 @@ function Chat() {
|
|||
const value = searchParams.get('session_id') || searchParams.get('sessionId')
|
||||
return value ? Number(value) : null
|
||||
}, [searchParams])
|
||||
// 刷新/直达已有会话时,会话与消息尚未加载完成前不要闪现「新建对话」页
|
||||
const openingExistingSession = Boolean(
|
||||
sessionId && !newMode && !currentSession && (loadingSessions || loadingMessages)
|
||||
)
|
||||
|
||||
const currentProject = useMemo(
|
||||
() => projects.find((item) => item.id === newProjectId),
|
||||
|
|
@ -316,19 +492,6 @@ function Chat() {
|
|||
}
|
||||
}
|
||||
|
||||
// 与全站侧边栏/个人资料一致的用户头像:avatar 字段是相对路径时转换为 API 端点
|
||||
const userAvatarUrl = useMemo(() => {
|
||||
const avatar = currentUser?.avatar
|
||||
if (!avatar) return null
|
||||
if (avatar.startsWith('http')) return avatar
|
||||
const parts = avatar.split('/')
|
||||
if (parts.length >= 3) {
|
||||
return `/api/v1/auth/avatar/${parts[0]}/${parts[2]}`
|
||||
}
|
||||
return null
|
||||
}, [currentUser])
|
||||
const userDisplayName = currentUser?.nickname || currentUser?.username
|
||||
|
||||
const groupedSessions = useMemo(() => {
|
||||
const groups = []
|
||||
const groupMap = new Map()
|
||||
|
|
@ -410,22 +573,29 @@ function Chat() {
|
|||
const res = await getChatMessages(id)
|
||||
const rawMessages = res.data || []
|
||||
const nextMessages = rawMessages.map((item) => {
|
||||
const contentStopped = item.role === 'assistant' && isStoppedContent(item.content || '')
|
||||
return contentStopped
|
||||
? { ...item, stopped: true, status: 'done' }
|
||||
: item
|
||||
// 后端返回 completed/interrupted/error/pending,前端统一映射为展示状态
|
||||
let status = item.status || 'pending'
|
||||
if (status === 'completed') status = 'done'
|
||||
if (item.role !== 'assistant') status = 'done'
|
||||
return {
|
||||
...item,
|
||||
status,
|
||||
durationMs: item.duration_ms ?? null,
|
||||
thinkingLog: Array.isArray(item.thinking_log) ? item.thinking_log : [],
|
||||
thinkingVisible: false,
|
||||
}
|
||||
})
|
||||
|
||||
// 停止后后端写入中断标记需要一点时间:若该会话存在未确认的停止,
|
||||
// 且最后一条助手消息仍为空,则把它标记为“已停止”,避免显示“正在思考”。
|
||||
// 停止后后端写入中断状态需要一点时间:若该会话存在未确认的停止,
|
||||
// 且最后一条助手消息仍为空,则把它标记为“已停止”,避免显示“思考中”。
|
||||
if (pendingStopsRef.current[String(id)]) {
|
||||
const lastAssistantIndex = [...nextMessages].reverse().findIndex((m) => m.role === 'assistant')
|
||||
if (lastAssistantIndex !== -1) {
|
||||
const idx = nextMessages.length - 1 - lastAssistantIndex
|
||||
const lastAssistant = nextMessages[idx]
|
||||
if (!(lastAssistant.content || '').trim()) {
|
||||
nextMessages[idx] = { ...lastAssistant, stopped: true, status: 'done' }
|
||||
// 后端中断标记尚未写入:保留待确认标记,下次加载继续兜底
|
||||
nextMessages[idx] = { ...lastAssistant, status: 'interrupted' }
|
||||
// 后端中断状态尚未写入:保留待确认标记,下次加载继续兜底
|
||||
} else {
|
||||
clearPendingStop(id)
|
||||
}
|
||||
|
|
@ -647,6 +817,10 @@ function Chat() {
|
|||
references: [],
|
||||
status: 'thinking',
|
||||
referencesVisible: false,
|
||||
thinkingLog: [],
|
||||
thinkingStartedAt: Date.now(),
|
||||
durationMs: null,
|
||||
thinkingVisible: false,
|
||||
created_at: new Date().toISOString(),
|
||||
}
|
||||
const baseMessages = options.initialMessages ?? messages
|
||||
|
|
@ -687,6 +861,17 @@ function Chat() {
|
|||
item.id === assistantMsgId ? { ...item, references: refs || [] } : item
|
||||
)))
|
||||
},
|
||||
onThinking: (entry) => {
|
||||
setMessages((prev) => prev.map((item) => (
|
||||
item.id === assistantMsgId
|
||||
? {
|
||||
...item,
|
||||
thinkingLog: [...(item.thinkingLog || []), entry],
|
||||
thinkingStartedAt: item.thinkingStartedAt || Date.now(),
|
||||
}
|
||||
: item
|
||||
)))
|
||||
},
|
||||
onChunk: (chunk) => {
|
||||
setMessages((prev) => prev.map((item) => (
|
||||
item.id === assistantMsgId ? { ...item, content: `${item.content}${chunk}`, status: 'streaming' } : item
|
||||
|
|
@ -710,6 +895,11 @@ function Chat() {
|
|||
...item,
|
||||
content: data?.content ?? item.content,
|
||||
status: 'done',
|
||||
durationMs: data?.duration_ms ?? item.durationMs,
|
||||
thinkingLog: Array.isArray(data?.thinking_log) && data.thinking_log.length > 0
|
||||
? data.thinking_log
|
||||
: item.thinkingLog,
|
||||
thinkingVisible: false,
|
||||
referencesVisible: true,
|
||||
}
|
||||
: item
|
||||
|
|
@ -738,7 +928,7 @@ function Chat() {
|
|||
await markMessageInterrupted(realId)
|
||||
setMessages((prev) => prev.map((item) => (
|
||||
item.id === assistantMessage.id || item.id === realId
|
||||
? { ...item, id: realId, stopped: true, status: 'done' }
|
||||
? { ...item, id: realId, status: 'interrupted' }
|
||||
: item
|
||||
)))
|
||||
}
|
||||
|
|
@ -752,9 +942,12 @@ function Chat() {
|
|||
item.id === assistantMsgId
|
||||
? {
|
||||
...item,
|
||||
status: aborted ? 'done' : 'error',
|
||||
stopped: aborted,
|
||||
status: aborted ? 'interrupted' : 'error',
|
||||
error: aborted ? undefined : (error.message || '回答生成失败,请稍后重试'),
|
||||
durationMs: aborted
|
||||
? Date.now() - (item.thinkingStartedAt || Date.now())
|
||||
: item.durationMs,
|
||||
thinkingVisible: false,
|
||||
referencesVisible: true,
|
||||
}
|
||||
: item
|
||||
|
|
@ -792,6 +985,12 @@ function Chat() {
|
|||
})
|
||||
}
|
||||
|
||||
const toggleThinking = (id) => {
|
||||
setMessages((prev) => prev.map((item) => (
|
||||
item.id === id ? { ...item, thinkingVisible: !item.thinkingVisible } : item
|
||||
)))
|
||||
}
|
||||
|
||||
const handleSearch = async () => {
|
||||
const keyword = searchKeyword.trim()
|
||||
if (!keyword) {
|
||||
|
|
@ -978,14 +1177,16 @@ function Chat() {
|
|||
const isUser = item.role === 'user'
|
||||
const refs = item.references || []
|
||||
const rawContent = item.content || ''
|
||||
// 后端会在被中断的内容中追加统一标记,展示时剥离,仅保留“已停止生成”状态
|
||||
const isStopped = !isUser && (item.stopped === true || isStoppedContent(rawContent))
|
||||
const displayContent = isStopped ? stripStoppedContent(rawContent) : rawContent
|
||||
const displayContent = rawContent
|
||||
// 生成是否完成/被中断由后端 status 字段决定,不再比对回复内容
|
||||
const status = item.status || 'pending'
|
||||
const isInterrupted = !isUser && status === 'interrupted'
|
||||
const isError = !isUser && status === 'error'
|
||||
const isLive = !isUser && (status === 'thinking' || status === 'streaming' || status === 'pending')
|
||||
const hasContent = Boolean(displayContent.trim())
|
||||
// 仅真正等待生成的消息(status=thinking 或刷新后无状态的空占位)显示「思考中」
|
||||
const isThinking = !isUser && !hasContent && (item.status === 'thinking' || item.status === undefined)
|
||||
const isStreaming = !isUser && item.status === 'streaming'
|
||||
const isError = !isUser && item.status === 'error'
|
||||
const isThinking = !isUser && !hasContent && isLive
|
||||
const isStreaming = !isUser && status === 'streaming'
|
||||
const thinkingLog = Array.isArray(item.thinkingLog) ? item.thinkingLog : []
|
||||
const showReferences = !isUser && item.referencesVisible !== false && refs.length > 0
|
||||
const canDelete = typeof item.id === 'number' || /^\d+$/.test(String(item.id))
|
||||
const projectId = currentSession?.project_id
|
||||
|
|
@ -1001,7 +1202,6 @@ function Chat() {
|
|||
}}
|
||||
className={`chat-message-row ${isUser ? 'user' : 'assistant'}`}
|
||||
>
|
||||
{!isUser && <Avatar className="chat-avatar assistant" icon={<RobotOutlined />} />}
|
||||
<div className="chat-message-column">
|
||||
<div className={`chat-message-bubble ${isUser ? 'user' : 'assistant'}`}>
|
||||
{isUser ? (
|
||||
|
|
@ -1012,29 +1212,46 @@ function Chat() {
|
|||
<div className="chat-message-error">
|
||||
{item.error || '回答生成失败,请稍后重试'}
|
||||
</div>
|
||||
) : isStopped && !hasContent ? (
|
||||
) : isInterrupted && !hasContent ? (
|
||||
<div className="chat-message-stopped">已停止生成</div>
|
||||
) : isThinking ? (
|
||||
<div className="chat-thinking">
|
||||
<span className="chat-thinking-text">正在思考</span>
|
||||
<span className="chat-thinking-dots">
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</span>
|
||||
</div>
|
||||
<ThinkingPanel active log={thinkingLog} startedAt={item.thinkingStartedAt} />
|
||||
) : (
|
||||
<div className={`chat-markdown${isStreaming ? ' streaming' : ''}`}>
|
||||
<>
|
||||
{isStreaming && (
|
||||
<ThinkingPanel active log={thinkingLog} startedAt={item.thinkingStartedAt} />
|
||||
)}
|
||||
{!isLive && (thinkingLog.length > 0 || item.durationMs != null) && (
|
||||
<ThinkingPanel
|
||||
log={thinkingLog}
|
||||
durationMs={item.durationMs}
|
||||
status={isInterrupted ? 'interrupted' : (isError ? 'error' : 'done')}
|
||||
visible={item.thinkingVisible}
|
||||
onToggle={() => toggleThinking(item.id)}
|
||||
/>
|
||||
)}
|
||||
<div className={`chat-markdown${isStreaming ? ' streaming' : ''}`}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeHighlight, rehypeCitationSup]}
|
||||
components={{
|
||||
sup: ({ children, ...props }) => {
|
||||
const citationId = Number(props['data-citation-id'])
|
||||
const occIndex = Number(props['data-citation-occ'] ?? 0)
|
||||
const ref = refs.find((itemRef) => Number(itemRef.citation_id) === citationId)
|
||||
if (!ref) {
|
||||
return <sup {...props}>{children}</sup>
|
||||
}
|
||||
const occData = Array.isArray(ref?.quote_occurrences)
|
||||
? ref.quote_occurrences[occIndex]
|
||||
: null
|
||||
const quotes = occData?.quotes?.length
|
||||
? occData.quotes
|
||||
: (Array.isArray(ref?.quotes) ? ref.quotes : [])
|
||||
const claim = occData?.claim || ''
|
||||
const excerpt = getReferenceExcerpt(ref)
|
||||
const context = getReferenceContext(ref)
|
||||
const hasBand = Boolean(ref?.content && excerpt && context.includes(excerpt))
|
||||
return (
|
||||
<Popover
|
||||
trigger="hover"
|
||||
|
|
@ -1042,16 +1259,40 @@ function Chat() {
|
|||
overlayClassName="chat-citation-popover"
|
||||
content={(
|
||||
<div className="chat-citation-preview">
|
||||
<Text type="secondary" className="chat-citation-preview-file">
|
||||
{ref.file_name || ref.file_path}
|
||||
</Text>
|
||||
<div className="chat-citation-preview-head">
|
||||
<Text type="secondary" className="chat-citation-preview-file" ellipsis={{ tooltip: ref.file_path || ref.file_name }}>
|
||||
{ref.file_name || ref.file_path}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="chat-citation-preview-content">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeHighlight]}
|
||||
>
|
||||
{getReferenceExcerpt(ref) || '暂无引用片段'}
|
||||
</ReactMarkdown>
|
||||
{quotes.length > 0 ? (
|
||||
<div className="chat-citation-quotes">
|
||||
{claim && (
|
||||
<div className="chat-citation-claim">回答:{claim}</div>
|
||||
)}
|
||||
{quotes.map((quote, quoteIdx) => (
|
||||
<div key={quoteIdx} className="chat-citation-quote-row">
|
||||
<span className="chat-citation-quote">原文:{quote.text}</span>
|
||||
<LinkOutlined
|
||||
className="chat-citation-quote-open"
|
||||
title="查看原文"
|
||||
onClick={() => openDocument(ref, projectId, occData)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="chat-citation-fallback">
|
||||
<CitationMarkdown>
|
||||
{highlightCitation(context, hasBand ? excerpt : '') || '暂无引用片段'}
|
||||
</CitationMarkdown>
|
||||
<LinkOutlined
|
||||
className="chat-citation-quote-open"
|
||||
title="查看原文"
|
||||
onClick={() => openDocument(ref, projectId, occData)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -1069,48 +1310,44 @@ function Chat() {
|
|||
{item.error || '回答生成中断,请稍后重试'}
|
||||
</div>
|
||||
)}
|
||||
{isStopped && hasContent && (
|
||||
{isInterrupted && hasContent && (
|
||||
<div className="chat-message-stopped">已停止生成</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{showReferences && (
|
||||
<div className="chat-references">
|
||||
<Text type="secondary" className="chat-references-label">关联文档</Text>
|
||||
<Space wrap>
|
||||
<div className="chat-references-head">
|
||||
<Text type="secondary" className="chat-references-label">引用来源</Text>
|
||||
<Text type="secondary" className="chat-references-count">
|
||||
{refs.length} 个来源
|
||||
</Text>
|
||||
</div>
|
||||
<div className="chat-references-list">
|
||||
{refs.map((ref) => {
|
||||
return (
|
||||
<Tag
|
||||
<ReferenceCard
|
||||
key={`${ref.citation_id}-${ref.file_path || ref.file_name}`}
|
||||
color="blue"
|
||||
className="chat-reference-link"
|
||||
onClick={() => {
|
||||
openDocument(ref, projectId)
|
||||
}}
|
||||
>
|
||||
[{ref.citation_id}] {ref.file_name}
|
||||
</Tag>
|
||||
reference={ref}
|
||||
projectId={projectId}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{(isUser || (!isThinking && !isStreaming)) && (
|
||||
{(isUser || !isLive) && (
|
||||
<MessageActions
|
||||
content={displayContent}
|
||||
onDelete={canDelete ? () => handleDeleteMessage(item) : undefined}
|
||||
onRegenerate={!isUser && (isStopped || isError) ? () => handleRegenerate(item) : undefined}
|
||||
onRegenerate={!isUser && (isInterrupted || isError) ? () => handleRegenerate(item) : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{isUser && (
|
||||
<Avatar className="chat-avatar user" src={userAvatarUrl} style={{ backgroundColor: '#1677ff' }}>
|
||||
{userDisplayName?.[0]?.toUpperCase() || 'U'}
|
||||
</Avatar>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
|
@ -1308,7 +1545,11 @@ function Chat() {
|
|||
</aside>
|
||||
|
||||
<main className="chat-main-panel">
|
||||
{currentSession && !newMode ? renderChatShell() : renderNewShell()}
|
||||
{openingExistingSession ? (
|
||||
<div className="chat-panel-loading">
|
||||
<Spin />
|
||||
</div>
|
||||
) : currentSession && !newMode ? renderChatShell() : renderNewShell()}
|
||||
</main>
|
||||
|
||||
<Modal
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import rehypeSlug from 'rehype-slug'
|
|||
import rehypeHighlight from 'rehype-highlight'
|
||||
import 'highlight.js/styles/github.css'
|
||||
import Highlighter from 'react-highlight-words'
|
||||
import Mark from 'mark.js'
|
||||
import GithubSlugger from 'github-slugger'
|
||||
import { getProjectTree, getFileContent, getDocumentUrl, getExportPdfUrl } from '@/api/file'
|
||||
import { gitPull, gitPush, getGitRepos } from '@/api/project'
|
||||
|
|
@ -62,6 +63,8 @@ function DocumentPage() {
|
|||
|
||||
// 搜索相关状态
|
||||
const [searchKeyword, setSearchKeyword] = useState('')
|
||||
// 知识库引用跳转专用:只高亮定位,不触发全文搜索、不过滤文件树
|
||||
const [highlightKeyword, setHighlightKeyword] = useState('')
|
||||
const [matchedFilePaths, setMatchedFilePaths] = useState(new Set())
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
const [modeSwitchValue, setModeSwitchValue] = useState('view')
|
||||
|
|
@ -200,6 +203,14 @@ function DocumentPage() {
|
|||
const fileParam = searchParams.get('file')
|
||||
const selectedParam = searchParams.get('selected')
|
||||
const keywordParam = searchParams.get('keyword')
|
||||
const highlightParam = searchParams.get('hl')
|
||||
|
||||
// 引用跳转:仅设置高亮关键词,交由渲染层高亮并滚动到命中位置
|
||||
if (highlightParam && highlightParam !== highlightKeyword) {
|
||||
setHighlightKeyword(highlightParam)
|
||||
} else if (!highlightParam && highlightKeyword) {
|
||||
setHighlightKeyword('')
|
||||
}
|
||||
|
||||
// 处理搜索
|
||||
if (keywordParam && keywordParam !== searchKeyword) {
|
||||
|
|
@ -232,7 +243,23 @@ function DocumentPage() {
|
|||
|
||||
// 处理搜索
|
||||
const handleSearch = async (value) => {
|
||||
// 先更新搜索状态,任何清理逻辑失败都不能阻断搜索
|
||||
setSearchKeyword(value)
|
||||
setHighlightKeyword('')
|
||||
try {
|
||||
// 清理引用跳转的 mark.js 高亮
|
||||
if (contentRef.current) {
|
||||
new Mark(contentRef.current).unmark({ className: 'citation-highlight' })
|
||||
}
|
||||
// 手动搜索时移除引用跳转定位参数,避免后续导航重新触发旧高亮
|
||||
if (searchParams.get('hl')) {
|
||||
const nextParams = new URLSearchParams(searchParams)
|
||||
nextParams.delete('hl')
|
||||
setSearchParams(nextParams, { replace: true })
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('清理引用高亮失败:', error)
|
||||
}
|
||||
if (!value.trim()) {
|
||||
setMatchedFilePaths(new Set())
|
||||
return
|
||||
|
|
@ -454,7 +481,7 @@ function DocumentPage() {
|
|||
}
|
||||
}, [markdownContent, isLargeMarkdown])
|
||||
|
||||
// 从知识库引用跳转而来时(URL 带 keyword),文档加载完成后滚动到第一个高亮处
|
||||
// 搜索关键词命中后,文档加载完成滚动到第一个高亮处
|
||||
useEffect(() => {
|
||||
if (loading || !searchKeyword || !markdownContent) return
|
||||
if (viewMode !== 'markdown') return
|
||||
|
|
@ -478,6 +505,61 @@ function DocumentPage() {
|
|||
}
|
||||
}, [loading, markdownContent, searchKeyword, viewMode])
|
||||
|
||||
// 知识库引用跳转(URL 带 hl):用 mark.js 跨文本节点高亮并滚动定位。
|
||||
// 引文含 markdown 语法时,渲染后的 DOM 文本与原文不一致,且关键词可能
|
||||
// 跨行内元素边界,逐节点高亮匹配不上,因此统一走 mark.js。
|
||||
useEffect(() => {
|
||||
if (!highlightKeyword || loading || !markdownContent || viewMode !== 'markdown') {
|
||||
// hl 被清除或文档未就绪时同步清理 mark.js 残留
|
||||
if (!isLargeMarkdown && contentRef.current) {
|
||||
try {
|
||||
new Mark(contentRef.current).unmark({ className: 'citation-highlight' })
|
||||
} catch (error) {
|
||||
console.warn('清理引用高亮失败:', error)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 超大文档走虚拟滚动,定位到包含关键词的文档块
|
||||
if (isLargeMarkdown) {
|
||||
largeMarkdownRef.current?.scrollToKeyword?.(highlightKeyword)
|
||||
return
|
||||
}
|
||||
|
||||
const container = contentRef.current
|
||||
if (!container) return
|
||||
|
||||
let canceled = false
|
||||
const timer = window.setTimeout(() => {
|
||||
if (canceled) return
|
||||
try {
|
||||
const instance = new Mark(container)
|
||||
instance.unmark({ className: 'citation-highlight' })
|
||||
instance.mark(highlightKeyword, {
|
||||
className: 'citation-highlight',
|
||||
separateWordSearch: false,
|
||||
acrossElements: true,
|
||||
done: () => {
|
||||
const target = container.querySelector('.citation-highlight')
|
||||
if (target) {
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
target.classList.add('cited-highlight-flash')
|
||||
window.setTimeout(() => target.classList.remove('cited-highlight-flash'), 2400)
|
||||
}
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn('引用高亮失败:', error)
|
||||
}
|
||||
}, 260)
|
||||
|
||||
return () => {
|
||||
canceled = true
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
}, [loading, highlightKeyword, markdownContent, viewMode, isLargeMarkdown])
|
||||
|
||||
// 处理菜单点击
|
||||
const handleMenuClick = ({ key }) => {
|
||||
const node = findNodeByKey(fileTree, key)
|
||||
|
|
|
|||
Loading…
Reference in New Issue