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"?think>", " ", text)
+ if "" in text:
+ head = text.split("", 1)[0]
+ text = head if head.strip() else re.sub(r"?think>", " ", 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;