diff --git a/src/backend/api/routes/v1/artifacts.py b/src/backend/api/routes/v1/artifacts.py index 38f5938..9ebba99 100644 --- a/src/backend/api/routes/v1/artifacts.py +++ b/src/backend/api/routes/v1/artifacts.py @@ -70,6 +70,16 @@ def sanitize_chat_preview(content: Optional[str], max_len: int = 200) -> str: return "" text = str(content) + # assistant \u6d88\u606f\u7684 content \u5728\u5e93\u91cc\u662f\u300c\u601d\u80031\u601d\u80032\u6b63\u6587\u300d\u7684\u539f\u59cb\u4e32\uff0c + # \u5361\u7247\u6458\u8981\u53ea\u8981\u6700\u7ec8\u6b63\u6587\u2014\u2014\u53d6\u6700\u540e\u4e00\u4e2a \u4e4b\u540e\u7684\u90e8\u5206\uff0c\u5e76\u5265\u6389\u53ef\u80fd\u6b8b\u7559\u7684 + # \u672a\u95ed\u5408 \u8d77\u59cb\u6bb5\uff08\u622a\u65ad\u573a\u666f\uff09\uff0c\u907f\u514d\u6536\u85cf\u5361\u7247\u5c55\u793a\u601d\u8003\u8fc7\u7a0b/\u6807\u7b7e\uff08\u95ee\u98988\uff09\u3002 + if "" in text: + tail = text.rsplit("", 1)[-1] + # \u5168\u662f\u601d\u8003\u6ca1\u6709\u6b63\u6587\u7684\u6781\u7aef\u60c5\u51b5\uff1a\u9000\u56de\u53bb\u6807\u7b7e\u540e\u7684\u539f\u6587\uff0c\u522b\u8ba9\u6458\u8981\u53d8\u6210\u7a7a\u767d + text = tail if tail.strip() else re.sub(r"", " ", text) + if "" in text: + head = text.split("", 1)[0] + text = head if head.strip() else re.sub(r"", " ", text) text = text.replace("\ufeff", "").replace("\u200b", "") text = re.sub(r"[\x00-\x08\x0b-\x1f\x7f]", "", text) text = re.sub(r"\s+", " ", text).strip() diff --git a/src/backend/api/routes/v1/chats.py b/src/backend/api/routes/v1/chats.py index c7f27d3..26c871b 100644 --- a/src/backend/api/routes/v1/chats.py +++ b/src/backend/api/routes/v1/chats.py @@ -2,6 +2,7 @@ import asyncio import json +import time import uuid from datetime import datetime from typing import Any, Dict, List, Optional @@ -1306,6 +1307,17 @@ async def _stream_sse_response( pending_message_id = f"msg_{uuid.uuid4().hex[:16]}" metadata: dict = {} tool_calls_log: list = [] + # 本轮回答总耗时起点 —— 持久化进 extra_data.duration_ms + _stream_started_monotonic = time.monotonic() + # 结构化 reasoning 思考增量:按到达顺序以 块交错并入 + # full_response 落库(与内联思考模型一致),刷新后思考过程可回放。 + _thinking_parts: list = [] + + def _flush_thinking() -> None: + nonlocal full_response + if _thinking_parts: + full_response += "" + "".join(_thinking_parts) + "" + _thinking_parts.clear() # Per-run workspace state — pin_to_workspace tool reads/writes this. _workspace_mod.init_state() @@ -1316,18 +1328,28 @@ async def _stream_sse_response( ): chunk_type = chunk.get("type") if chunk_type == "thinking": - yield f"data: {json.dumps(build_thinking_event(chunk, chat_id), ensure_ascii=False)}\n\n" + _thinking_evt = build_thinking_event(chunk, chat_id) + if _thinking_evt.get("delta"): + _thinking_parts.append(str(_thinking_evt["delta"])) + yield f"data: {json.dumps(_thinking_evt, ensure_ascii=False)}\n\n" elif chunk_type in {"ai_message", "content"}: delta = chunk.get("delta", "") if delta: + _flush_thinking() full_response += delta yield f"data: {json.dumps({'type': 'content', 'event': 'ai_message', 'delta': delta, 'chat_id': chat_id}, ensure_ascii=False)}\n\n" elif chunk_type == "content_replace": + _thinking_parts.clear() full_response = str(chunk.get("content") or "") event = {**chunk, "chat_id": chat_id} yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n" elif chunk_type == "tool_call": + _flush_thinking() _tc_evt = build_tool_call_event(chunk, chat_id, tool_calls_log) + # 记录该工具卡片出现时正文的累计长度:历史重建按此偏移把 + # 「文本 ↔ 工具卡片」按流式原顺序交错(问题15:刷新后内容与实时不一致)。 + for _tc in tool_calls_log: + _tc.setdefault("content_offset", len(full_response)) yield f"data: {json.dumps(_tc_evt, ensure_ascii=False)}\n\n" elif chunk_type == "tool_result": _tr_evt = build_tool_result_event(chunk, chat_id, tool_calls_log) @@ -1366,6 +1388,7 @@ async def _stream_sse_response( event = {**chunk, "chat_id": chat_id} yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n" elif chunk_type == "meta": + _flush_thinking() pending_message_id = f"msg_{uuid.uuid4().hex[:16]}" # Strict workspace gate: the agent's pin_to_workspace calls # are the SOLE source of user-visible artifacts. We always @@ -1402,6 +1425,7 @@ async def _stream_sse_response( "warnings": metadata.get("warnings", []), "citations": metadata.get("citations", []), "workspace_files": metadata.get("workspace_files", []), + "duration_ms": int((time.monotonic() - _stream_started_monotonic) * 1000), } if metadata.get("ontology_governance"): _persist_extra["ontology_governance"] = metadata["ontology_governance"] diff --git a/src/backend/api/routes/v1/kb.py b/src/backend/api/routes/v1/kb.py index 1c1ee2d..af6ad3f 100644 --- a/src/backend/api/routes/v1/kb.py +++ b/src/backend/api/routes/v1/kb.py @@ -403,6 +403,7 @@ async def list_documents( kb_id: str = Path(..., description="Local or external knowledge collection ID"), page: int = Query(1, ge=1, description="Page number"), page_size: int = Query(20, ge=1, le=100, description="Items per page"), + keyword: Optional[str] = Query(None, description="按标题/文件名搜索(全库范围,再分页)"), user: UserContext = Depends(get_current_user), db: Session = Depends(get_db), ): @@ -416,7 +417,7 @@ async def list_documents( from core.kb.external_provider import is_enabled, list_documents if is_enabled(): - result = list_documents(kb_id, page=page, limit=page_size) + result = list_documents(kb_id, page=page, limit=page_size, keyword=keyword or "") return paginated_response( items=result.get("items", []), page=result.get("page", page), @@ -432,7 +433,7 @@ async def list_documents( message="Access denied", reason="Only the KB space owner can list documents" ) - documents, total = kb_repo.list_documents(kb_id, page, page_size) + documents, total = kb_repo.list_documents(kb_id, page, page_size, keyword=keyword) items = [ { "id": d.document_id, diff --git a/src/backend/core/db/repository/chat.py b/src/backend/core/db/repository/chat.py index b2dffc3..404da33 100644 --- a/src/backend/core/db/repository/chat.py +++ b/src/backend/core/db/repository/chat.py @@ -15,6 +15,30 @@ from sqlalchemy.orm import Session +def _visible_message_text(content: str) -> str: + """assistant 消息 content 的可见正文(剥离 思考段)。 + + 存储格式(多轮工具调用):``思考1可见文本思考2…最终正文``; + 也可能只有裸 ```` 分隔(开标签被服务栈吞掉)。规则与前端 + ``utils/segments.ts`` 的历史重建一致:每个 ```` 之前、上一个 + ```` 之后的内容是思考;其余是可见正文。 + """ + if "" not in content and "" not in content: + return content + parts = content.split("") + out: List[str] = [] + for i, part in enumerate(parts): + if i == len(parts) - 1: + # 最后一段:若有未闭合的 ,其后是(被截断的)思考 + out.append(part.split("", 1)[0]) + else: + idx = part.find("") + if idx >= 0: + out.append(part[:idx]) + # 无开标签 → 整段是思考,丢弃 + return " ".join(x for x in out if x.strip()).strip() + + class ChatSessionRepository: """Repository for chat session operations.""" @@ -135,9 +159,13 @@ def search( } if scope == "all": - # Content-only matching chat_ids (exclude ones already matched by title) + # Content-only matching chat_ids (exclude ones already matched by title). + # SQL ilike 只做粗筛:assistant 消息的 content 里混有 思考文本 + # (代码字段/英文/路径等),不应参与搜索(问题11)。粗筛命中后在 + # Python 层剥掉思考、只对可见正文复核,摘要同样基于可见正文生成。 content_id_set: set[str] = set() - content_rows = ( + content_snippet_source: Dict[str, str] = {} + candidate_rows = ( self.db.query(ChatMessage.chat_id) .join(ChatSession, ChatMessage.chat_id == ChatSession.chat_id) .filter( @@ -148,10 +176,31 @@ def search( .distinct() .all() ) - content_id_set = {row[0] for row in content_rows} - title_id_set + q_lower = query.lower() + for (cid,) in candidate_rows: + if cid in title_id_set: + continue + msgs = ( + self.db.query(ChatMessage.content) + .filter( + ChatMessage.chat_id == cid, + ChatMessage.role.in_(["user", "assistant"]), + ChatMessage.content.ilike(like_pattern), + ) + .order_by(ChatMessage.created_at) + .limit(20) + .all() + ) + for (raw,) in msgs: + visible = _visible_message_text(raw or "") + if q_lower in visible.lower(): + content_id_set.add(cid) + content_snippet_source[cid] = visible + break all_ids = title_id_set | content_id_set else: content_id_set = set() + content_snippet_source = {} all_ids = title_id_set total = len(all_ids) @@ -192,19 +241,10 @@ def search( matched_snippet: Optional[str] = None if match_type == "content": - msg = ( - self.db.query(ChatMessage) - .filter( - ChatMessage.chat_id == s.chat_id, - ChatMessage.role.in_(["user", "assistant"]), - ChatMessage.content.ilike(like_pattern), - ) - .order_by(ChatMessage.created_at) - .first() - ) - if msg and msg.content: - # Center the snippet around the keyword - content = msg.content.replace("\n", " ") + snippet_source = content_snippet_source.get(s.chat_id) + if snippet_source: + # Center the snippet around the keyword(基于剥离思考后的可见正文) + content = snippet_source.replace("\n", " ") lower_content = content.lower() idx = lower_content.find(query.lower()) if idx == -1: diff --git a/src/backend/core/db/repository/kb.py b/src/backend/core/db/repository/kb.py index 2537e19..2500664 100644 --- a/src/backend/core/db/repository/kb.py +++ b/src/backend/core/db/repository/kb.py @@ -115,12 +115,17 @@ def get_document(self, document_id: str) -> Optional[KBDocument]: ) def list_documents( - self, kb_id: str, page: int = 1, page_size: int = 20 + self, kb_id: str, page: int = 1, page_size: int = 20, keyword: Optional[str] = None ) -> tuple[List[KBDocument], int]: - """List documents in a KB space.""" + """List documents in a KB space, optionally filtered by title/filename keyword.""" query = self.db.query(KBDocument).filter( KBDocument.kb_id == kb_id, KBDocument.deleted_at.is_(None) ) + if keyword and keyword.strip(): + like = f"%{keyword.strip()}%" + query = query.filter( + sa.or_(KBDocument.title.ilike(like), KBDocument.filename.ilike(like)) + ) total = query.count() documents = ( diff --git a/src/backend/orchestration/chat_run_executor.py b/src/backend/orchestration/chat_run_executor.py index ed8aaa6..1b90c5a 100644 --- a/src/backend/orchestration/chat_run_executor.py +++ b/src/backend/orchestration/chat_run_executor.py @@ -21,6 +21,7 @@ import contextlib import json import os +import time import uuid from datetime import datetime, timedelta, timezone from typing import Any, AsyncIterator, Awaitable, Callable, Dict, List, Literal, Optional @@ -350,6 +351,8 @@ async def _run_workflow( from core.services.artifact_service import persist_artifacts as _persist_artifacts _update_run_status(run_id, status="running", started_at=_utcnow()) + # 本轮回答总耗时起点 —— 持久化进 extra_data.duration_ms,历史加载后「用时」不再消失 + _run_started_monotonic = time.monotonic() offset_counter = 0 @@ -364,6 +367,17 @@ async def _emit(event: Dict[str, Any]) -> None: metadata: Dict[str, Any] = {} tool_calls_log: list = [] _workspace_mod.init_state() + # 结构化 reasoning 通道(deepseek 系 reasoning_content/reasoning)的思考增量。 + # 过去这些只走 SSE thinking 事件、不落库 → 刷新后思考过程无法回放。现在按 + # 到达顺序以 块交错并入 full_response(与内联思考模型的存储 + # 格式一致),历史重建(buildHistorySegments)即可原位还原思考块。 + _thinking_parts: List[str] = [] + + def _flush_thinking() -> None: + nonlocal full_response + if _thinking_parts: + full_response += "" + "".join(_thinking_parts) + "" + _thinking_parts.clear() try: # First frame: run_started — carries run_id / message_id; the frontend uses these to resume / cancel @@ -441,11 +455,17 @@ async def _emit(event: Dict[str, Any]) -> None: chunk_type = chunk.get("type") if chunk_type == "thinking": - await _emit(build_thinking_event(chunk, chat_id)) + _thinking_evt = build_thinking_event(chunk, chat_id) + # 只累积真实思考增量;进度提示(message)与 structured_reasoning + # 协议标记不落库 + if _thinking_evt.get("delta"): + _thinking_parts.append(str(_thinking_evt["delta"])) + await _emit(_thinking_evt) elif chunk_type in {"ai_message", "content"}: delta = chunk.get("delta", "") if delta: + _flush_thinking() full_response += delta await _emit( { @@ -461,6 +481,7 @@ async def _emit(event: Dict[str, Any]) -> None: # committee revised it, replace the visible/persisted answer # atomically instead of appending a second full answer. replacement = str(chunk.get("content") or "") + _thinking_parts.clear() full_response = replacement await _emit( { @@ -472,7 +493,13 @@ async def _emit(event: Dict[str, Any]) -> None: ) elif chunk_type == "tool_call": - await _emit(build_tool_call_event(chunk, chat_id, tool_calls_log)) + _flush_thinking() + _tc_evt = build_tool_call_event(chunk, chat_id, tool_calls_log) + # 记录该工具卡片出现时正文的累计长度:历史重建按此偏移把 + # 「文本 ↔ 工具卡片」按流式原顺序交错(问题15:刷新后内容与实时不一致)。 + for _tc in tool_calls_log: + _tc.setdefault("content_offset", len(full_response)) + await _emit(_tc_evt) elif chunk_type == "tool_result": await _emit(build_tool_result_event(chunk, chat_id, tool_calls_log)) @@ -599,6 +626,7 @@ async def _emit(event: Dict[str, Any]) -> None: await _emit(ontology_event) elif chunk_type == "meta": + _flush_thinking() # Strict workspace gate: pinned list is the sole source of # user-visible artifacts. See chats.py:_stream_sse_response. _ws_pinned = _workspace_mod.get_pinned() @@ -632,6 +660,7 @@ async def _emit(event: Dict[str, Any]) -> None: "citations": metadata.get("citations", []), "message_id": message_id, "workspace_files": _ws_files, + "duration_ms": int((time.monotonic() - _run_started_monotonic) * 1000), } if metadata.get("ontology_governance"): _persist_extra["ontology_governance"] = metadata["ontology_governance"] diff --git a/src/backend/orchestration/streaming.py b/src/backend/orchestration/streaming.py index 671ef1d..b134461 100644 --- a/src/backend/orchestration/streaming.py +++ b/src/backend/orchestration/streaming.py @@ -133,6 +133,12 @@ def __init__( # Accumulated answer text (for dedup in the -suppression scenario) self._raw_text = "" self._emitted_answer = "" + # 首轮正文累计(enable_thinking 下):轮次结束时若整轮没有 , + # 说明该模型不是"内联思考"形态(结构化 reasoning 通道、或本轮无思考)—— + # 补发 structured_reasoning 标记,让前端把误缓冲成"思考"的正文流回正文区。 + self._first_round_text = "" + self._round_index = 0 + self._structured_marker_sent = False self._in_thinking = False self._reasoning_protocol_emitted = False # True between ModelCallStartEvent and ModelCallEndEvent. While a call is @@ -384,6 +390,8 @@ async def _map_event(self, ev: Any) -> AsyncIterator[Tuple[str, Any]]: # directly, no accumulate+recompute needed (the frontend parses itself), # avoiding an O(n) scan of the full answer on every delta. if self._enable_thinking: + if self._round_index == 0: + self._first_round_text += delta yield ("text_delta", delta) return # Suppression state: may span multiple deltas — accumulate, then strip out the answer after the closing tag. @@ -492,6 +500,21 @@ async def _map_event(self, ev: Any) -> AsyncIterator[Tuple[str, Any]]: "prompt_tokens": int(getattr(ev, "input_tokens", 0) or 0), "completion_tokens": int(getattr(ev, "output_tokens", 0) or 0), }) + # 首轮权威判定:思考模式下整轮正文没有出现 → 该模型不内联思考 + # (结构化 reasoning 通道,或本轮确实没思考)。补发协议标记,前端据此 + # 把误当思考缓冲/展示的正文重归正文区(bug:无思考时正文进思考块)。 + # 只看首轮——混合形态模型工具后省略 属既有启发式管辖,不在此误判。 + if ( + self._enable_thinking + and self._round_index == 0 + and not self._structured_marker_sent + and self._first_round_text + and "" not in self._first_round_text + ): + self._structured_marker_sent = True + yield ("reasoning_protocol", {"structured_reasoning": True}) + self._round_index += 1 + self._first_round_text = "" # New model call round → reset answer accumulation (the next text segment computes deltas from scratch) self._raw_text = "" self._emitted_answer = "" diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 7c82dfe..d0b2e42 100755 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -397,6 +397,13 @@ export default function App() { return rawSend(text); }; + // 编辑重发同样是显式"带我去底部"的意图(点击编辑区按钮会被下面的捕获监听 + // 预置为脱离跟随,这里在真正发送时复位,恢复流式跟随)。 + const editAndResendFollow = (messageIndex: number, newContent: string) => { + userScrolledUpRef.current = false; + return editAndResend(messageIndex, newContent); + }; + // Cross-panel first message: the project-page composer stuffs the message into // chatStore.pendingFirstMessage; after jumping to the chat panel, this effect // auto-sends + clears it once currentChatId matches. @@ -420,8 +427,24 @@ export default function App() { if (isAutoScrollingRef.current) return; userScrolledUpRef.current = distanceFromBottom(content) > SCROLL_FOLLOW_THRESHOLD; }; + // 流式输出时列表每帧都在长高,用户一个滚轮往上(几十 px)还没越过 + // SCROLL_FOLLOW_THRESHOLD 就被 ResizeObserver 拽回底部,表现为"锁死在底部 + // 滚不上去"。滚轮向上 / 触摸拖动是明确的用户意图,直接置位脱离跟随, + // 不等距离阈值。回到底部(scroll 监听)或再次发送会自然复位。 + const handleWheel = (e: WheelEvent) => { + if (e.deltaY < 0) userScrolledUpRef.current = true; + }; + const handleTouchMove = () => { + if (distanceFromBottom(content) > 1) userScrolledUpRef.current = true; + }; content.addEventListener('scroll', handleScroll, { passive: true }); - return () => content.removeEventListener('scroll', handleScroll); + content.addEventListener('wheel', handleWheel, { passive: true }); + content.addEventListener('touchmove', handleTouchMove, { passive: true }); + return () => { + content.removeEventListener('scroll', handleScroll); + content.removeEventListener('wheel', handleWheel); + content.removeEventListener('touchmove', handleTouchMove); + }; }, []); // Chat switch: reset follow state and smooth-scroll to the bottom (keeping the @@ -479,7 +502,9 @@ export default function App() { const handler = (e: MouseEvent) => { const target = e.target as HTMLElement | null; if (!target) return; - if (target.closest('.jx-plan-stepHeader, .jx-plan-stepsToggle, .jx-tcr-header, .jx-trs-head')) { + // .jx-msgActionBtn / .jx-editMessage:点「编辑消息」展开编辑框、点「取消」收起 + // 都会播放高度动画,ResizeObserver 会误判为流式增高而滚到底部 —— 预置脱离跟随。 + if (target.closest('.jx-plan-stepHeader, .jx-plan-stepsToggle, .jx-tcr-header, .jx-trs-head, .jx-msgActionBtn, .jx-editMessage')) { userScrolledUpRef.current = true; } }; @@ -780,7 +805,7 @@ export default function App() { handleFileSelect={handleFileSelect} removeFile={removeFile} regenerate={regenerate} - editAndResend={editAndResend} + editAndResend={editAndResendFollow} inputRef={inputRef} fileInputRef={fileInputRef} chatListRef={chatListRef} diff --git a/src/frontend/src/api.ts b/src/frontend/src/api.ts index 4dbd152..a244200 100644 --- a/src/frontend/src/api.ts +++ b/src/frontend/src/api.ts @@ -833,10 +833,12 @@ export async function getKBDocuments( kbId: string, page = 1, pageSize = 20, + keyword?: string, ): Promise { try { + const kw = keyword?.trim() ? `&keyword=${encodeURIComponent(keyword.trim())}` : ''; const wrapped = await apiRequest( - `/v1/catalog/kb/${kbId}/documents?page=${page}&page_size=${pageSize}`, + `/v1/catalog/kb/${kbId}/documents?page=${page}&page_size=${pageSize}${kw}`, ); const data = unwrapData>(wrapped); const items = Array.isArray(data.items) ? data.items : []; diff --git a/src/frontend/src/components/catalog/CatalogPanel.tsx b/src/frontend/src/components/catalog/CatalogPanel.tsx index b67b4a1..c0a41f9 100644 --- a/src/frontend/src/components/catalog/CatalogPanel.tsx +++ b/src/frontend/src/components/catalog/CatalogPanel.tsx @@ -266,6 +266,19 @@ export function CatalogPanel({ embedded = false }: CatalogPanelProps = {}) { const [wikiPageCount, setWikiPageCount] = useState(0); const [kbDetailView, setKbDetailView] = useState<'documents' | 'wiki'>('documents'); const [kbDocPage, setKbDocPage] = useState(1); + // 后端全库搜索关键词(kbDocQuery 的 300ms 去抖版本)。搜索必须发给后端在全部 + // 分页范围内检索(问题19:过去只在当前页 20 条里前端过滤),关键词变化时回到第 1 页。 + const [kbDocSearch, setKbDocSearch] = useState(''); + useEffect(() => { + const timer = window.setTimeout(() => { + const next = kbDocQuery.trim(); + setKbDocSearch((prev) => { + if (prev !== next) setKbDocPage(1); + return next; + }); + }, 300); + return () => window.clearTimeout(timer); + }, [kbDocQuery]); const [kbDocTotal, setKbDocTotal] = useState(0); const [docStatusFilter, setDocStatusFilter] = useState('all'); const [kbEditorOpen, setKbEditorOpen] = useState(false); @@ -459,7 +472,7 @@ export function CatalogPanel({ embedded = false }: CatalogPanelProps = {}) { setKbDocsLoadingId(kbId); void (async () => { try { - const result: KBDocumentsResponse = await getKBDocuments(kbId, kbDocPage, KB_DOC_PAGE_SIZE); + const result: KBDocumentsResponse = await getKBDocuments(kbId, kbDocPage, KB_DOC_PAGE_SIZE, kbDocSearch); const totalPages = result.total > 0 ? Math.ceil(result.total / result.page_size) : 0; if (result.total > 0 && totalPages > 0 && kbDocPage > totalPages) { setKbDocPage(totalPages); @@ -472,7 +485,7 @@ export function CatalogPanel({ embedded = false }: CatalogPanelProps = {}) { setKbDocsLoadingId(null); } })(); - }, [selectedId, kbDocPage, applyDocumentsResult, setKbDocsLoadingId]); + }, [selectedId, kbDocPage, kbDocSearch, applyDocumentsResult, setKbDocsLoadingId]); const filteredLibraries = useMemo(() => { const query = manageQuery.trim().toLowerCase(); @@ -519,14 +532,14 @@ export function CatalogPanel({ embedded = false }: CatalogPanelProps = {}) { const currentPage = kbDocPage; const timer = window.setInterval(async () => { try { - const result = await getKBDocuments(kbId, currentPage, KB_DOC_PAGE_SIZE); + const result = await getKBDocuments(kbId, currentPage, KB_DOC_PAGE_SIZE, kbDocSearch); applyDocumentsResult(kbId, result.items, result.total); } catch (err) { console.warn('KB 文档轮询失败', err); } }, 5000); return () => window.clearInterval(timer); - }, [selectedId, kbDocPage, hasProcessingDoc, applyDocumentsResult]); + }, [selectedId, kbDocPage, kbDocSearch, hasProcessingDoc, applyDocumentsResult]); const filteredDocuments = useMemo(() => { const query = kbDocQuery.trim().toLowerCase(); diff --git a/src/frontend/src/components/chat/MessageBubble.tsx b/src/frontend/src/components/chat/MessageBubble.tsx index ad3eb16..741e25c 100644 --- a/src/frontend/src/components/chat/MessageBubble.tsx +++ b/src/frontend/src/components/chat/MessageBubble.tsx @@ -47,10 +47,10 @@ function formatMsgTime(ts: number): string { return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; } -/** Format the total generation time (milliseconds) as "用时 X.Xs"; when over 1 minute, use "用时 N分M秒". */ +/** Format the total generation time (milliseconds) as "用时 X.X秒"; when over 1 minute, use "用时 N分M秒". 单位统一用「秒」。 */ function formatDuration(ms?: number): string | null { if (ms == null || !Number.isFinite(ms) || ms < 0) return null; - if (ms < 60_000) return t('用时 {sec}s', { sec: (ms / 1000).toFixed(1) }); + if (ms < 60_000) return t('用时 {sec}秒', { sec: (ms / 1000).toFixed(1) }); const min = Math.floor(ms / 60_000); const sec = Math.round((ms % 60_000) / 1000); return t('用时 {min}分{sec}秒', { min, sec }); @@ -95,7 +95,8 @@ function useLazyExpand(open: boolean) { function useExpandFocus(open: boolean, ref: React.RefObject) { useEffect(() => { if (!open) return; - const id = window.setTimeout(() => ref.current?.focus({ cursor: 'end' }), EXPAND_FOCUS_DELAY_MS); + // preventScroll:原生 focus 会把控件滚入视口,叠加展开动画会把整个列表拽到底部 + const id = window.setTimeout(() => ref.current?.focus({ cursor: 'end', preventScroll: true }), EXPAND_FOCUS_DELAY_MS); return () => window.clearTimeout(id); }, [open, ref]); } diff --git a/src/frontend/src/components/chat/OntologyGovernanceCard.tsx b/src/frontend/src/components/chat/OntologyGovernanceCard.tsx index bea4d05..742913a 100644 --- a/src/frontend/src/components/chat/OntologyGovernanceCard.tsx +++ b/src/frontend/src/components/chat/OntologyGovernanceCard.tsx @@ -54,7 +54,7 @@ export function OntologyGovernanceCard({ governance }: OntologyGovernanceCardPro {t('工具门禁')} {gates.length}{denied ? ` · ${t('{count} 次拦截', { count: denied })}` : ''} {reviewLabel} {typeof review.latency_ms === 'number' && ( - {t('用时 {sec}s', { sec: (review.latency_ms / 1000).toFixed(1) })} + {t('用时 {sec}秒', { sec: (review.latency_ms / 1000).toFixed(1) })} )} diff --git a/src/frontend/src/components/sidebar/SearchModal.tsx b/src/frontend/src/components/sidebar/SearchModal.tsx index 5274050..ed4aded 100644 --- a/src/frontend/src/components/sidebar/SearchModal.tsx +++ b/src/frontend/src/components/sidebar/SearchModal.tsx @@ -3,7 +3,7 @@ import { Modal, Input, Select, Tooltip } from 'antd'; import type { InputRef } from 'antd'; import { t } from '../../i18n'; import { - SearchOutlined, CloseOutlined, EditOutlined, PushpinFilled, + SearchOutlined, CloseOutlined, EditOutlined, } from '@ant-design/icons'; import { useUIStore, useChatStore, useAutomationChatStore } from '../../stores'; import type { HistoryTimeFilter } from '../../stores/uiStore'; @@ -202,9 +202,7 @@ export function SearchModal({ onNewChat, onSelectChat, onSelectSearchResult }: S className={`jx-searchItem${isActive ? ' active' : ''}`} onClick={opts?.onClick ?? (() => void handlePickItem(item))} > - {item.pinned && ( - - )} + {/* 置顶图标不在搜索结果中展示(测试反馈问题10);置顶状态在侧栏已有标识 */}
diff --git a/src/frontend/src/hooks/chatStream.ts b/src/frontend/src/hooks/chatStream.ts index e3aa45c..02b98ac 100644 --- a/src/frontend/src/hooks/chatStream.ts +++ b/src/frontend/src/hooks/chatStream.ts @@ -219,6 +219,31 @@ export async function processChatStream(resp: Response, opts: ChatStreamOptions) // Once a structured reasoning event is observed (e.g. DeepSeek v4 `reasoning_content`), pin // the stripper's phase to body — from then on content is no longer treated as buffered thinking. let structuredReasoning = false; + // 隐式思考段追踪:思考模式下、未见任何 / 标签时,正文流被"假定为 + // 思考"塞进思考段(兼容吞开标签的部署)。结构化 reasoning 模型无思考输出时,这个 + // 假定会把整段正文误关进思考块。记录这些"假定"产生的段索引;一旦出现 + // 证实假定成立就清空;反之收到 structured_reasoning 标记 / 流结束仍无标签时,把 + // 它们重归类回正文。 + const implicitThinkSegIdxs = new Set(); + let sawThinkCloseTag = false; + + const reclassifyImplicitThinking = (): boolean => { + if (sawThinkCloseTag || implicitThinkSegIdxs.size === 0) return false; + for (const i of implicitThinkSegIdxs) { + const seg = segments[i]; + if (seg?.type === 'thinking' && seg.content) { + segments[i] = { type: 'text', content: seg.content }; + } + } + implicitThinkSegIdxs.clear(); + // full 与 thinking 按重归类后的段重建(与 appendTextSeg 的顺序累加语义一致) + full = segments.filter((s) => s.type === 'text').map((s) => s.content || '').join(''); + thinking.length = 0; + for (const s of segments) { + if (s.type === 'thinking' && s.content) thinking.push({ content: s.content, timestamp: Date.now() }); + } + return true; + }; const getPartialTagLen = (text: string, tag: string): number => { for (let len = Math.min(tag.length - 1, text.length); len >= 1; len--) { @@ -336,6 +361,8 @@ export async function processChatStream(resp: Response, opts: ChatStreamOptions) const safeLen = parseBuffer.length - partialLen; if (safeLen > 0) { appendThinkContent(parseBuffer.slice(0, safeLen), true); + // 该思考内容是"假定"出来的(本轮还没见到任何 think 标签)——记录段索引 + if (!sawThinkCloseTag) implicitThinkSegIdxs.add(segments.length - 1); parseBuffer = parseBuffer.slice(safeLen); } break; @@ -343,6 +370,9 @@ export async function processChatStream(resp: Response, opts: ChatStreamOptions) if (closeIdx > 0) appendThinkContent(parseBuffer.slice(0, closeIdx), true); parseBuffer = parseBuffer.slice(closeIdx + 8); thinkingPhaseActive = false; + // 出现真实 :假定成立,此前的隐式思考段确属思考 + sawThinkCloseTag = true; + implicitThinkSegIdxs.clear(); } else { const openIdx = parseBuffer.indexOf(''); const closeIdx = parseBuffer.indexOf(''); @@ -352,6 +382,8 @@ export async function processChatStream(resp: Response, opts: ChatStreamOptions) if (closeIdx >= 0 && (openIdx === -1 || closeIdx < openIdx)) { if (closeIdx > 0) appendThinkContent(parseBuffer.slice(0, closeIdx), true); parseBuffer = parseBuffer.slice(closeIdx + 8); + sawThinkCloseTag = true; + implicitThinkSegIdxs.clear(); continue; } if (openIdx === -1) { @@ -1022,6 +1054,9 @@ export async function processChatStream(resp: Response, opts: ChatStreamOptions) parseBuffer = ''; } thinkingPhaseActive = false; + // 后端首轮判定:整轮没有 → 此前"假定为思考"的正文段全部重归正文 + // (修复:无思考输出时整段回答被关进思考块) + if (reclassifyImplicitThinking()) appendOrUpdate(true); } if (obj.delta) { structuredReasoning = true; @@ -1226,13 +1261,17 @@ export async function processChatStream(resp: Response, opts: ChatStreamOptions) // ── Unified wind-down: whether normal end/abort/exception, the bubble must leave the streaming state ── finalizeRunningTools(); if (parseBuffer) { - if (thinkingPhaseActive) { + if (thinkingPhaseActive && sawThinkCloseTag) { appendThinkContent(parseBuffer, true); } else { + // 整条流从未出现 :残余缓冲是正文,不是思考(结构化 reasoning + // 模型无思考输出的场景;旧行为会把它并进思考块) appendTextSeg(parseBuffer); } parseBuffer = ''; } + // 兜底:老后端/回放流没有首轮协议标记时,流结束仍无任何 think 标签 → 重归类 + reclassifyImplicitThinking(); deferredThinkingText = restoreDeferredThinkingTextFragment(segments, deferredThinkingText); const isMd = /\n|```|\*\*|^\s*#\s/m.test(full); useChatStore.getState().updateStore((prev) => { diff --git a/src/frontend/src/hooks/useChatActions.ts b/src/frontend/src/hooks/useChatActions.ts index c786e59..7aaf1ad 100644 --- a/src/frontend/src/hooks/useChatActions.ts +++ b/src/frontend/src/hooks/useChatActions.ts @@ -2,7 +2,7 @@ import { useRef } from 'react'; import { Modal, message } from 'antd'; import { t } from '../i18n'; import { authFetch } from '../api'; -import { nowId } from '../storage'; +import { nowId, registerDeletedChatId } from '../storage'; import { buildHistorySegments } from '../utils/segments'; import { triggerPdfDownload, toSafeFileName } from '../utils/export'; import { SUMMARY_MAX_ROUNDS } from '../utils/constants'; @@ -52,6 +52,7 @@ export function useChatActions(effectiveApiUrl: string) { if (effectiveApiUrl && backendSessionIds.has(id)) { void authFetch(`${effectiveApiUrl}/v1/chats/${id}`, { method: 'DELETE' }).catch(() => {}); } + registerDeletedChatId(id); removeBackendSessionId(id); removeLoadedMsgId(id); updateStore((prev) => { @@ -95,13 +96,14 @@ export function useChatActions(effectiveApiUrl: string) { if (patch.title !== undefined) backendPatch.title = patch.title; if (patch.pinned !== undefined) backendPatch.pinned = patch.pinned; if (patch.favorite !== undefined) backendPatch.favorite = patch.favorite; - if (patch.businessTopic !== undefined) { + if (patch.businessTopic !== undefined || patch.titleManuallySet !== undefined) { backendPatch.metadata = { - businessTopic: patch.businessTopic, + businessTopic: patch.businessTopic ?? latestChat?.businessTopic ?? '综合咨询', ...(latestChat?.agentId ? { agent_id: latestChat.agentId } : {}), ...(latestChat?.agentName ? { agent_name: latestChat.agentName } : {}), ...(latestChat?.planChat ? { plan_chat: true } : {}), ...(latestChat?.batchChat ? { batch_chat: true } : {}), + ...((patch.titleManuallySet ?? latestChat?.titleManuallySet) ? { title_manually_set: true } : {}), }; } if (Object.keys(backendPatch).length > 0) { @@ -155,8 +157,16 @@ export function useChatActions(effectiveApiUrl: string) { } function commitRenameChat(id: string) { - const nextTitle = editingTitle.trim() || '新对话'; - patchChat(id, { title: nextTitle }); + const nextTitle = editingTitle.trim(); + const original = storeRef.current.chats[id]?.title || ''; + // 输入为空/纯空格 → 保留原名称(问题7:过去落成字面量「新对话」) + if (!nextTitle || nextTitle === original) { + setEditingChatId(null); + setEditingTitle(''); + return; + } + // titleManuallySet:手动重命名后,自动摘要不再覆盖标题(问题13) + patchChat(id, { title: nextTitle, titleManuallySet: true }); setEditingChatId(null); setEditingTitle(''); } @@ -270,6 +280,8 @@ export function useChatActions(effectiveApiUrl: string) { async function generateSummary(chatId: string) { const chat = storeRef.current.chats[chatId]; if (!chat || !effectiveApiUrl) return; + // 用户手动重命名过的会话不再用自动摘要覆盖标题(问题13) + if (chat.titleManuallySet) return; const userMessages = chat.messages.filter(m => m.role === 'user'); const assistantMessages = chat.messages.filter(m => m.role === 'assistant'); if (userMessages.length === 0 || assistantMessages.length === 0) return; diff --git a/src/frontend/src/hooks/useChatInit.ts b/src/frontend/src/hooks/useChatInit.ts index f1d6c63..585b885 100644 --- a/src/frontend/src/hooks/useChatInit.ts +++ b/src/frontend/src/hooks/useChatInit.ts @@ -21,6 +21,13 @@ const effectiveApiUrl = (import.meta.env.VITE_API_BASE_URL as string || '').trim // re-renders but reset on page refresh, exactly like the store marks. const inflightMsgLoads = new Set(); +// Per-chat failed message-load attempts. A non-2xx / network failure used to +// leave the chat stuck on the skeleton until the user switched away and back; +// now we self-retry a few times with backoff (bumpSessionLoadEpoch re-fires +// the lazy-load effect), then give up until the next manual visit. +const msgLoadRetryCounts = new Map(); +const MSG_LOAD_MAX_RETRIES = 3; + // Convert a backend message item (from GET /v1/chats/{id}/messages) into a // frontend ChatMessage. Pure — used by both the preload path (during initial // session fetch) and the lazy-load path (when switching into a never-loaded @@ -50,6 +57,9 @@ function parseHistoryMessage(m: any): ChatMessage { ? { subagentName: tc.subagent_name ?? tc.subagentName } : {}), ...(typeof tc.scope === 'string' ? { scope: tc.scope } : {}), + ...(typeof (tc.content_offset ?? tc.contentOffset) === 'number' + ? { contentOffset: (tc.content_offset ?? tc.contentOffset) } + : {}), })) : undefined; const revisionToolCalls = allToolCalls?.filter((tool) => tool.scope === 'ontology_revision') ?? []; @@ -215,6 +225,8 @@ function parseHistoryMessage(m: any): ChatMessage { ...(histPluginName && { pluginName: histPluginName }), ...(histMentionName && { mentionName: histMentionName }), ...(typeof m.message_id === 'string' && m.message_id && { messageId: m.message_id }), + ...(m.role === 'assistant' && typeof m.metadata?.duration_ms === 'number' && m.metadata.duration_ms >= 0 + && { durationMs: m.metadata.duration_ms }), } as ChatMessage; } @@ -379,9 +391,17 @@ export function useChatInit() { for (const s of items) { const id: string = s.chat_id; const meta = (s.metadata || {}) as any; + // 手动重命名保护:后端已带 title_manually_set 直接用;本地改过名但还没 + // 同步到后端(流式期间改名后刷新)→ 保留本地标题,后续流结束时自动补同步 + const localManual = localSnapshot.chats[id]?.titleManuallySet === true; + const backendManual = meta.title_manually_set === true; + const preservedTitle = !backendManual && localManual && localSnapshot.chats[id]?.title + ? localSnapshot.chats[id].title + : (s.title || '新对话'); chats[id] = { id, - title: s.title || '新对话', + title: preservedTitle, + ...(backendManual || localManual ? { titleManuallySet: true } : {}), createdAt: s.created_at ? new Date(s.created_at).getTime() : Date.now(), updatedAt: s.updated_at ? new Date(s.updated_at).getTime() : Date.now(), messages: [], @@ -447,9 +467,11 @@ export function useChatInit() { const isFreshLogin = typeof window !== 'undefined' && window.sessionStorage.getItem(LOGIN_LANDING_KEY) === '1'; const allChats = { ...chats }; - const targetChatId = isFreshLogin - ? nowId('chat') - : (allChats[prevChatId] ? prevChatId : nowId('chat')); + // 恢复目标:非新登录一律保留原会话 id(问题14/17)。后端已有 → 恢复 + // 历史;后端没有(正在流式输出首条消息、或本地空会话)→ 保留同一 id: + // 空会话渲染出来就是空首页,与生成新 id 的 UX 等价,但指针稳定—— + // 不会把新 id 写回共享 localStorage 去覆盖别的标签页的恢复目标。 + const targetChatId = isFreshLogin ? nowId('chat') : (prevChatId || nowId('chat')); if (isFreshLogin) setPanel('chat'); setCurrentChatId(targetChatId); // Bump epoch so the lazy-load messages effect re-fires even when @@ -612,6 +634,7 @@ export function useChatInit() { const newChatItem: ChatItem = { id: s.chat_id, title: s.title || '新对话', + ...(meta.title_manually_set === true ? { titleManuallySet: true } : {}), createdAt: s.created_at ? new Date(s.created_at).getTime() : Date.now(), updatedAt: s.updated_at ? new Date(s.updated_at).getTime() : Date.now(), messages: [], @@ -704,6 +727,7 @@ export function useChatInit() { useChatStore.getState().setContextCompaction(chatId, contextCompaction); } loaded = true; + msgLoadRetryCounts.delete(chatId); addLoadedMsgId(chatId); updateStore(prev => { const c = prev.chats[chatId]; @@ -737,7 +761,20 @@ export function useChatInit() { } } } catch { - /* fall through — lock released below so the next visit retries */ + // HTTP/网络失败:有限次自动重试(问题16:历史对话长时间停在骨架屏)。 + // 超过上限后放弃,等用户下次切入该会话再试。 + if (!cancelled) { + const attempts = (msgLoadRetryCounts.get(chatId) || 0) + 1; + msgLoadRetryCounts.set(chatId, attempts); + if (attempts <= MSG_LOAD_MAX_RETRIES) { + window.setTimeout(() => { + const st = useChatStore.getState(); + if (st.currentChatId === chatId && !st.loadedMsgIds.has(chatId)) { + st.bumpSessionLoadEpoch(); + } + }, 1500 * attempts); + } + } } finally { inflightMsgLoads.delete(chatId); // Switch-back race: if the user already navigated back to this chat diff --git a/src/frontend/src/hooks/useStreaming.ts b/src/frontend/src/hooks/useStreaming.ts index a7cbc4e..c046b9f 100644 --- a/src/frontend/src/hooks/useStreaming.ts +++ b/src/frontend/src/hooks/useStreaming.ts @@ -106,6 +106,28 @@ export function useStreaming( setUploadedFiles(uploadedFiles.filter((_, i) => i !== index)); } + /** 流式期间用户手动重命名过、但当时后端会话尚未创建(PATCH 被跳过)—— + * 流结束、会话已在后端后补一次同步(问题13)。幂等,多调无害。 */ + function syncManualTitleToBackend(chatId: string) { + const chat = useChatStore.getState().store.chats[chatId]; + if (!chat?.titleManuallySet || !chat.title || !effectiveApiUrl) return; + void authFetch(`${effectiveApiUrl}/v1/chats/${chatId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title: chat.title, + metadata: { + businessTopic: chat.businessTopic || '综合咨询', + ...(chat.agentId ? { agent_id: chat.agentId } : {}), + ...(chat.agentName ? { agent_name: chat.agentName } : {}), + ...(chat.planChat ? { plan_chat: true } : {}), + ...(chat.batchChat ? { batch_chat: true } : {}), + title_manually_set: true, + }, + }), + }).catch(() => { /* 下次流结束还会再试 */ }); + } + async function send(directMessage?: string) { const { input, setInput, sending, addSendingChatId, removeSendingChatId, chatMode, currentChatId, updateStore, addBackendSessionId, addLoadedMsgId, quotedFollowUp, setQuotedFollowUp, activeSkill, setActiveSkill, activePlugin, setActivePlugin, activeMention, setActiveMention } = useChatStore.getState(); const { catalog } = useCatalogStore.getState(); @@ -340,6 +362,7 @@ export function useStreaming( addBackendSessionId(currentChatId); addLoadedMsgId(currentChatId); + syncManualTitleToBackend(currentChatId); setTimeout(() => generateSummary(currentChatId), 500); setTimeout(() => generateClassification(currentChatId), 800); @@ -438,9 +461,11 @@ export function useStreaming( removeSendingChatId(streamChatId); // Clean up activeRun — the SSE has hit [DONE] / errored / been interrupted useChatStore.getState().clearActiveRun(streamChatId); - setUploadedFiles([]); - setUploadingFiles(new Set()); - fileUploadMap.current.clear(); + // NOTE: do NOT clear uploadedFiles / fileUploadMap here. This round's + // attachments were already cleared right after they were assembled + // (before the request), so anything present now was uploaded by the + // user DURING streaming for the next question — wiping it here made + // those attachments silently vanish when the stream ended. } } @@ -463,6 +488,7 @@ export function useStreaming( await processChatStream(response, { chatId, enableThinking, pendingNotice }); useChatStore.getState().addBackendSessionId(chatId); useChatStore.getState().addLoadedMsgId(chatId); + syncManualTitleToBackend(chatId); setTimeout(() => generateSummary(chatId), 500); setTimeout(() => generateClassification(chatId), 800); } @@ -501,7 +527,14 @@ export function useStreaming( /** Edit a user message and regenerate */ async function editAndResend(messageIndex: number, newContent: string) { const { sending, addSendingChatId, removeSendingChatId, currentChatId, truncateMessagesFrom, setEditingMessageTs } = useChatStore.getState(); - if (sending || !newContent.trim()) return; + if (!newContent.trim()) return; + if (sending) { + // 正在流式输出时点「发送」:先停止当前回答再编辑重发(对齐主流产品行为), + // 而不是静默吞掉点击。abort 触发本地 AbortError → 原流的 finally 清理 + // sendingChatIds;等一拍让清理落地后继续。 + abort(currentChatId); + await new Promise((res) => setTimeout(res, 250)); + } const streamChatId = currentChatId; addSendingChatId(streamChatId); setEditingMessageTs(null); @@ -676,14 +709,41 @@ export function useStreaming( // during the active-run round-trip. if (useChatStore.getState().sendingChatIds.has(chatId)) return; - const { addSendingChatId, removeSendingChatId } = useChatStore.getState(); - + // activeRun 在锁外登记:即使本标签页没拿到跟随权,停止按钮也能取消该 run useChatStore.getState().setActiveRun(chatId, { runId: active.run_id, messageId: active.message_id, lastOffset: active.last_event_offset || 0, }); + // ── 跨标签页互斥:同一 run 只允许一个标签页跟随 SSE ── + // 过去复制标签页/多开时两个标签页同时 follow 同一 run,各自用不同的 + // placeholderTs 建气泡,互相覆盖 localStorage,产生重复/半截气泡与 + // "回答无对应问题"(问题17)。Web Locks 随标签页关闭自动释放。 + const runLockName = `hugagent_run_follow_${active.run_id}`; + const activeRun = active; + const doFollowRun = () => followActiveRun(chatId, activeRun, uid); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const locksApi = typeof navigator !== 'undefined' ? (navigator as any).locks : undefined; + if (locksApi?.request) { + await locksApi.request(runLockName, { ifAvailable: true }, async (lock: unknown) => { + if (!lock) return; // 另一个标签页正在跟随该 run + if (useChatStore.getState().sendingChatIds.has(chatId)) return; + await doFollowRun(); + }); + } else { + await doFollowRun(); + } + } + + /** 实际跟随一个后台 run 的 SSE(plan / loop / 普通对话三种分支)。 */ + async function followActiveRun( + chatId: string, + active: NonNullable>>, + uid: string | undefined, + ) { + const { addSendingChatId, removeSendingChatId } = useChatStore.getState(); + // Plan mode: live-replay the plan event stream (plan_step_* / tool_call / tool_result / // plan_complete), fully continuous with the pre-refresh progress. if (active.kind === 'plan_execute' || active.kind === 'plan_generate') { diff --git a/src/frontend/src/i18n/en/chat.ts b/src/frontend/src/i18n/en/chat.ts index d05068c..3d9e976 100644 --- a/src/frontend/src/i18n/en/chat.ts +++ b/src/frontend/src/i18n/en/chat.ts @@ -219,7 +219,7 @@ export const CHAT_DICT: Record = { 'Mermaid 渲染失败': 'Mermaid render failed', '加载图表中...': 'Loading diagram...', '发送后将以该模式在本项目内开始对话;点击取消': 'Send in this mode to start a conversation in this project; click to cancel', - '用时 {sec}s': 'Took {sec}s', + '用时 {sec}秒': 'Took {sec}s', '用时 {min}分{sec}秒': 'Took {min}m {sec}s', '本次回答整体生成耗时': 'Total generation time for this response', '这条消息包含无法显示的旧格式数据,已跳过异常内容。': 'This message contains legacy data that cannot be displayed. The invalid content was skipped.', diff --git a/src/frontend/src/storage.ts b/src/frontend/src/storage.ts index e28677a..6c7bf83 100755 --- a/src/frontend/src/storage.ts +++ b/src/frontend/src/storage.ts @@ -102,11 +102,60 @@ function trimForPersistence(store: ChatStore): ChatStore { return storeMutated ? { ...store, chats: nextChats } : store; } +/** 本标签页本会话里删除过的 chat id:合并写盘时不许它们从磁盘"复活"。 */ +const sessionDeletedChatIds = new Set(); + +export function registerDeletedChatId(id: string) { + sessionDeletedChatIds.add(id); +} + +/** 由 chatStore 注册:返回本标签页正在流式输出的 chat id 集合。 + * 合并写盘时这些会话一律以本内存版本为准(磁盘上可能是别的标签页的旧影子)。 */ +let streamingIdsProvider: (() => Set) | null = null; +export function setStreamingIdsProvider(fn: () => Set) { + streamingIdsProvider = fn; +} + +/** + * 按会话粒度合并两棵聊天树:同一 chat 取 updatedAt 较新者(相同时取 preferred 侧)。 + * 过去写盘是"整棵树全量覆盖"——两个标签页各持一份旧快照互相抹掉对方的新消息/ + * 新会话,切标签页(visibilitychange→flush)就稳定触发(问题17 串台的主因之一)。 + */ +export function mergeChatStores( + preferred: ChatStore, + other: ChatStore, + opts?: { preferAllIds?: Set }, +): ChatStore { + const chats: ChatStore['chats'] = {}; + const ids = new Set([...Object.keys(preferred.chats || {}), ...Object.keys(other.chats || {})]); + for (const id of ids) { + if (sessionDeletedChatIds.has(id)) continue; + const a = preferred.chats[id]; + const b = other.chats[id]; + if (!a) { chats[id] = b; continue; } + if (!b) { chats[id] = a; continue; } + if (opts?.preferAllIds?.has(id)) { chats[id] = a; continue; } + chats[id] = (b.updatedAt || 0) > (a.updatedAt || 0) ? b : a; + } + // order 语义 ≈ 最近使用顺序;合并后按 updatedAt 降序重建, + // 原 order 中未知的 id(已被删除/过滤)自然剔除。 + const order = Object.values(chats) + .sort((x, y) => (y.updatedAt || 0) - (x.updatedAt || 0)) + .map((c) => c.id); + return { chats, order }; +} + function performSave(userId: string, store: ChatStore) { const key = userScopedKey(STORAGE_KEY, userId); if (!key) return; try { - localStorage.setItem(key, JSON.stringify(trimForPersistence(store))); + // 合并写:先读磁盘上(可能来自其他标签页的)最新快照,按会话粒度合并后再写, + // 本内存快照没动过的会话不会覆盖掉其他标签页刚写入的新内容。 + const disk = loadChatStore(userId); + const merged = mergeChatStores(store, disk, { + preferAllIds: streamingIdsProvider ? streamingIdsProvider() : undefined, + }); + localStorage.setItem(key, JSON.stringify(trimForPersistence(merged))); } catch { // ignore quota errors } diff --git a/src/frontend/src/stores/chatStore.ts b/src/frontend/src/stores/chatStore.ts index 0b5c04c..ed84ce8 100644 --- a/src/frontend/src/stores/chatStore.ts +++ b/src/frontend/src/stores/chatStore.ts @@ -1,6 +1,6 @@ import { create } from 'zustand'; import type { ChatItem, ChatMessage, ChatStore as ChatStoreData, ContextCompactionState, PlanProgressState } from '../types'; -import { loadChatStore, saveChatStoreDebounced, flushChatStore, nowId, userScopedKey, purgeLegacyUnscopedKeys } from '../storage'; +import { loadChatStore, saveChatStoreDebounced, flushChatStore, nowId, userScopedKey, purgeLegacyUnscopedKeys, mergeChatStores, registerDeletedChatId, setStreamingIdsProvider, STORAGE_KEY } from '../storage'; import { usePageConfigStore } from './pageConfigStore'; import { usePluginStore } from './pluginStore'; import { t } from '../i18n'; @@ -39,6 +39,14 @@ function loadCurrentChatId(userId: string | null | undefined) { if (typeof window === 'undefined') return nowId('chat'); const key = userScopedKey(CURRENT_CHAT_KEY, userId); if (!key) return nowId('chat'); + // 标签页私有优先:sessionStorage 与本标签页同生共死(浏览器"复制标签页"会带 + // 一份副本,恰好落在同一会话上)。多个标签页各自恢复自己的会话,不再共抢 + // localStorage 里的单一指针互相覆盖(问题17 串台的一环);localStorage 只作为 + // 新开标签页的兜底。 + try { + const tabLocal = window.sessionStorage.getItem(key); + if (tabLocal) return tabLocal; + } catch { /* sessionStorage 不可用时退回 localStorage */ } return window.localStorage.getItem(key) || nowId('chat'); } @@ -46,6 +54,7 @@ function saveCurrentChatId(userId: string | null | undefined, chatId: string) { if (typeof window === 'undefined') return; const key = userScopedKey(CURRENT_CHAT_KEY, userId); if (!key) return; + try { window.sessionStorage.setItem(key, chatId); } catch { /* ignore */ } window.localStorage.setItem(key, chatId); } @@ -264,7 +273,10 @@ interface ChatState { clearForLogout: () => void; } -export const useChatStore = create((set, get) => ({ +export const useChatStore = create((set, get) => { + // 合并写盘时:本标签页正在流式输出的会话一律以本内存版本为准 + setStreamingIdsProvider(() => get().sendingChatIds); + return ({ // currentUserId stays null until hydrateForUser runs after login. While null, // the store is empty and all save helpers no-op — avoids any chance of // writing one user's data under a key that a later user could read. @@ -715,6 +727,7 @@ export const useChatStore = create((set, get) => ({ deleteChat: (id) => { const { store, currentChatId, currentUserId } = get(); + registerDeletedChatId(id); const rest = { ...store.chats }; delete rest[id]; const nextCompactions = { ...get().contextCompactions }; @@ -840,4 +853,27 @@ export const useChatStore = create((set, get) => ({ contextCompactions: {}, }); }, -})); +}); +}); + +// ── 跨标签页同步:另一个标签页写入会话数据时,把它按会话粒度合并进本页内存 ── +// storage 事件只在"其他"标签页触发(写入方不触发),不会自激振荡;合并结果 +// 不回写磁盘(磁盘上已是并集),避免写风暴。本页正在流式输出的会话保留本页版本。 +if (typeof window !== 'undefined') { + window.addEventListener('storage', (e: StorageEvent) => { + const st = useChatStore.getState(); + const uid = st.currentUserId; + if (!uid || !e.newValue) return; + if (e.key !== userScopedKey(STORAGE_KEY, uid)) return; + let incoming: ChatStoreData | null = null; + try { + const parsed = JSON.parse(e.newValue); + if (parsed && typeof parsed === 'object') { + incoming = { chats: parsed.chats || {}, order: parsed.order || [] }; + } + } catch { /* 损坏的快照直接忽略 */ } + if (!incoming) return; + const merged = mergeChatStores(st.store, incoming, { preferAllIds: st.sendingChatIds }); + useChatStore.setState({ store: merged, storeRef: merged }); + }); +} diff --git a/src/frontend/src/styles/search-modal.css b/src/frontend/src/styles/search-modal.css index edf8d15..62382e9 100644 --- a/src/frontend/src/styles/search-modal.css +++ b/src/frontend/src/styles/search-modal.css @@ -300,7 +300,8 @@ width:6px; height:6px; border-radius:50%; - border:1.5px solid var(--color-fill); + /* 常规对话圈点加亮(测试反馈问题10):原 var(--color-fill) 几乎看不见 */ + border:1.5px solid var(--color-text-tertiary); } .jx-searchItemMain{ flex:1; @@ -325,6 +326,9 @@ text-overflow:ellipsis; white-space:nowrap; display:block; + /* 标题与内容摘要之间留出呼吸感(测试反馈问题10) */ + margin-top:2px; + line-height:1.5; } /* 关键词高亮:复用 .jx-searchHighlight(定义在 sidebar.css) */ diff --git a/src/frontend/src/styles/share-preview.css b/src/frontend/src/styles/share-preview.css index e63caa9..fcde2e9 100644 --- a/src/frontend/src/styles/share-preview.css +++ b/src/frontend/src/styles/share-preview.css @@ -40,4 +40,22 @@ .jx-shareMeta { animation: none !important; } + + /* 分享页自身是 height:100vh + overflow-y:auto 的内部滚动容器,全局又锁了 + html/body overflow —— 打印时浏览器按视口高度裁剪,只能印出第一页。 + 打印帧全部放开成文档流,让内容自然分页(问题5)。 */ + html, body, #root { + height: auto !important; + overflow: visible !important; + } + .jx-sharePage { + height: auto !important; + overflow: visible !important; + } + .jx-sharePrintBtn { + display: none !important; + } + .jx-shareMessage { + break-inside: avoid; + } } diff --git a/src/frontend/src/types.ts b/src/frontend/src/types.ts index 2df2b35..a52754c 100755 --- a/src/frontend/src/types.ts +++ b/src/frontend/src/types.ts @@ -178,6 +178,9 @@ export interface ToolCall { subSteps?: SubagentStep[]; subagentName?: string; scope?: 'ontology_revision' | string; + /** 该工具卡片出现时正文(content 累计串)的字符偏移;历史重建按它把文本与 + * 工具卡片交错还原成流式时的原顺序。旧历史没有该字段 → 退回"工具在前文本在后"。 */ + contentOffset?: number; } /** §13 MySpace write confirmation decision (literal counterparts of the backend's _myspace_confirm.DECISION_*). */ @@ -436,6 +439,8 @@ export interface ChatItem { favorite?: boolean; pinned?: boolean; businessTopic?: string; + /** 用户手动重命名过:自动摘要标题不再覆盖;随会话 metadata.title_manually_set 持久化 */ + titleManuallySet?: boolean; /** Sub-agent binding (set when chat is started from a sub-agent) */ agentId?: string; agentName?: string; diff --git a/src/frontend/src/utils/segments.ts b/src/frontend/src/utils/segments.ts index 2a50a1c..aeaf100 100644 --- a/src/frontend/src/utils/segments.ts +++ b/src/frontend/src/utils/segments.ts @@ -14,10 +14,64 @@ import type { ChatMessage, MessageSegment } from '../types'; * - The last segment is the final body text * - If there is no , directly output tool calls + body text */ +/** 把一段正文切成 thinking / text 段(按 划分; 前的可见文本不丢弃)。 */ +function segmentTextSlice(slice: string, segments: MessageSegment[]): string { + const parts = slice.split(''); + let visible = ''; + const pushText = (text: string) => { + const trimmed = text.trim(); + if (!trimmed) return; + visible += (visible ? '\n\n' : '') + trimmed; + const last = segments[segments.length - 1]; + if (last?.type === 'text') last.content = `${last.content}\n\n${trimmed}`; + else segments.push({ type: 'text', content: trimmed }); + }; + parts.forEach((part, idx) => { + const isLast = idx === parts.length - 1; + if (isLast) { pushText(part); return; } + const openTagIdx = part.indexOf(''); + if (openTagIdx >= 0) { + // 之前的内容是上一轮的可见正文——过去这里被直接丢弃(问题15) + pushText(part.slice(0, openTagIdx)); + const thinkContent = part.slice(openTagIdx + 7); + if (thinkContent.trim()) segments.push({ type: 'thinking', content: thinkContent }); + } else if (part.trim()) { + segments.push({ type: 'thinking', content: part }); + } + }); + return visible; +} + export function buildHistorySegments( content: string, toolCalls?: ChatMessage['toolCalls'] ): { segments: MessageSegment[] | undefined; cleanContent: string } { + // ── 新历史(带 contentOffset):按流式原顺序把文本与工具卡片交错还原 ── + // contentOffset = 工具卡片出现时正文累计串的字符偏移。逐段切片,段内再按 + // 拆 thinking / text。刷新后的历史与实时流式展示保持一致(问题15)。 + const hasOffsets = Array.isArray(toolCalls) + && toolCalls.length > 0 + && toolCalls.every((tc) => typeof tc.contentOffset === 'number'); + if (hasOffsets) { + const segments: MessageSegment[] = []; + let cursor = 0; + let visibleAll = ''; + toolCalls!.forEach((tc, i) => { + const off = Math.min(Math.max(tc.contentOffset as number, cursor), content.length); + const visible = segmentTextSlice(content.slice(cursor, off), segments); + if (visible) visibleAll += (visibleAll ? '\n\n' : '') + visible; + cursor = off; + segments.push({ type: 'tool', toolIndex: i }); + }); + const finalVisible = segmentTextSlice(content.slice(cursor), segments); + if (finalVisible) visibleAll += (visibleAll ? '\n\n' : '') + finalVisible; + return { + segments: segments.length > 0 ? segments : undefined, + // cleanContent 维持"最终可见正文"语义:取最后一个文本段;没有则用全部可见文本 + cleanContent: finalVisible || visibleAll, + }; + } + const parts = content.split(''); const toolCount = toolCalls?.length ?? 0;