diff --git a/core/main_terminal_parts/tools_definition/search_web_tools.py b/core/main_terminal_parts/tools_definition/search_web_tools.py index a2ebe6dc..d98b089c 100644 --- a/core/main_terminal_parts/tools_definition/search_web_tools.py +++ b/core/main_terminal_parts/tools_definition/search_web_tools.py @@ -92,7 +92,7 @@ class ToolsDefinitionSearchWebToolsMixin: "type": "function", "function": { "name": "web_search", - "description": "当现有资料不足时搜索外部信息。调用前说明目的,精准撰写 query,并合理设置时间/主题参数;避免重复或无意义的搜索。", + "description": "当现有资料不足时搜索外部信息。调用前说明目的,精准撰写 query,并合理设置时间/主题参数;避免重复或无意义的搜索。结果中每条来源带 [src_xxx] 引用 ID,回答中标注事实来源时使用。", "parameters": { "type": "object", "properties": self._inject_intent({ @@ -145,7 +145,7 @@ class ToolsDefinitionSearchWebToolsMixin: "type": "function", "function": { "name": "extract_webpage", - "description": "在 web_search 结果不够详细时提取网页正文。调用前说明用途,注意提取内容会消耗大量 token,超过80000字符将被拒绝。", + "description": "在 web_search 结果不够详细时提取网页正文。调用前说明用途,注意提取内容会消耗大量 token,超过80000字符将被拒绝。结果会带来源引用 ID。", "parameters": { "type": "object", "properties": self._inject_intent({ diff --git a/core/main_terminal_parts/tools_execution.py b/core/main_terminal_parts/tools_execution.py index 83544bd1..b253e2ab 100644 --- a/core/main_terminal_parts/tools_execution.py +++ b/core/main_terminal_parts/tools_execution.py @@ -1697,13 +1697,35 @@ class MainTerminalToolsExecutionMixin: ) if search_response["success"]: + # 注册网页来源 citation,并让模型可见的 summary 带上 [src_xxx] 前缀 + from modules.citations import get_registry + registry = get_registry(self) + citations = [] + results_list = search_response.get("results", []) + for item in results_list: + ann = registry.register_url( + title=item.get("title") or item.get("url") or "", + url=item.get("url") or "", + snippet=item.get("content", ""), + published_date=item.get("published_date") or None, + source_tool="web_search", + ) + if ann: + item["citation_id"] = ann["id"] + citations.append(ann) result = { "success": True, - "summary": search_response["summary"], + "summary": self.search_engine.build_summary_text( + search_response.get("query") or arguments["query"], + results_list, + search_response.get("filters", {}), + search_response.get("timestamp", ""), + ), "filters": search_response.get("filters", {}), "query": search_response.get("query"), - "results": search_response.get("results", []), - "total_results": search_response.get("total_results", 0) + "results": results_list, + "total_results": search_response.get("total_results", 0), + "citations": citations, } else: result = { @@ -1738,10 +1760,19 @@ class MainTerminalToolsExecutionMixin: "url": url } else: + from modules.citations import get_registry + ann = get_registry(self).register_url( + title=url, + url=url, + snippet=full_content[:500], + source_tool="extract_webpage", + ) result = { "success": True, "url": url, - "content": full_content + "content": full_content, + "citation_id": ann.get("id") if ann else None, + "citations": [ann] if ann else [], } except Exception as e: result = { diff --git a/modules/citations.py b/modules/citations.py new file mode 100644 index 00000000..0fcdd544 --- /dev/null +++ b/modules/citations.py @@ -0,0 +1,283 @@ +"""Inline Citations:引用注册表、marker 解析与校验。 + +数据流: + 工具执行(web_search / extract_webpage)→ CitationRegistry.register_url() + → 工具结果文本带 [src_xxx](模型可见) + → 模型输出 【cite:src_xxx】/【file:相对路径】 marker + → assistant 消息落库前 finalize_message_citations() 校验 + 挂载 message.citations + → 前端渲染 citation chip + +设计要点: +- 网页来源用 cite: 前缀 + ID(src_),确定性、同 URL 天然去重; +- 文件来源用 file: 前缀 + 工作区相对路径(可带 #L120-148 / #p7 locator),不经过 registry; +- registry 是会话运行期的临时查找表,不落盘;历史消息靠 message.citations 自包含恢复。 +""" + +from __future__ import annotations + +import hashlib +import os +import re +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple +from urllib.parse import urlparse + +# marker 语法:【cite:src_xxx】/【file:AGENTS.md】/同前缀多来源逗号并列(中英文逗号均可) +CITATION_MARK_RE = re.compile(r"【(cite|file):([^】]+)】") + +# locator 后缀:#L120 / #L120-148 / #L120-L148(GitHub 风格)/ #p7 +_LOCATOR_RE = re.compile(r"^(?P.*?)#(?:L(?P\d+)(?:-L?(?P\d+))?|p(?P\d+))$", re.IGNORECASE) + +_SRC_ID_RE = re.compile(r"^src_[0-9a-f]{10}x*$") + +SNIPPET_MAX_CHARS = 300 + +# 文件 snippet 读取上限(前 64KB 足够覆盖 locator 行段与开头摘要) +_FILE_SNIPPET_READ_BYTES = 65536 + +# 图片扩展名:引用弹层渲染 而不是文本摘要 +_IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".ico", ".avif"} + + +def _read_file_snippet(target: Path, ref: Dict[str, Any]) -> Optional[str]: + """读取文本文件内容片段作为引用预览:有 #L 定位时取对应行段,否则取开头。 + + 二进制文件(含空字节)返回 None。空白压缩为单行,限长 SNIPPET_MAX_CHARS。 + """ + try: + with open(target, "rb") as f: + raw = f.read(_FILE_SNIPPET_READ_BYTES) + except Exception: + return None + if b"\x00" in raw: + return None + text = raw.decode("utf-8", errors="replace") + line_start = ref.get("line_start") + if line_start: + lines = text.splitlines() + start = max(1, int(line_start)) + end = min(len(lines), int(ref.get("line_end") or start)) + snippet = "\n".join(lines[start - 1 : end]) if start <= len(lines) else "" + else: + snippet = text + # 保留换行(弹层 pre-line 渲染),仅压缩行内空白、去空行 + snippet = "\n".join(ln.strip() for ln in snippet.splitlines() if ln.strip()) + return snippet[:SNIPPET_MAX_CHARS] or None + + +def normalize_url(url: str) -> str: + """URL 规范化:小写 scheme/host、去末尾斜杠。用于同来源去重。""" + url = (url or "").strip() + try: + p = urlparse(url) + scheme = (p.scheme or "https").lower() + netloc = p.netloc.lower() + path = p.path.rstrip("/") + query = f"?{p.query}" if p.query else "" + return f"{scheme}://{netloc}{path}{query}" + except Exception: + return url + + +def domain_of(url: str) -> str: + try: + return urlparse(url).netloc.lower().lstrip("www.") + except Exception: + return "" + + +def make_url_citation_id(url: str) -> str: + digest = hashlib.sha1(normalize_url(url).encode("utf-8")).hexdigest()[:10] + return f"src_{digest}" + + +class CitationRegistry: + """会话级网页来源注册表(运行期内存态,不落盘)。""" + + def __init__(self) -> None: + self._by_id: Dict[str, Dict[str, Any]] = {} + self._id_by_url: Dict[str, str] = {} + + def register_url( + self, + *, + title: str = "", + url: str = "", + snippet: str = "", + published_date: Optional[str] = None, + source_tool: Optional[str] = None, + ) -> Dict[str, Any]: + """注册(或按规范化 URL 去重返回已有)一个网页来源 annotation。""" + if not url: + return {} + norm = normalize_url(url) + existing_id = self._id_by_url.get(norm) + if existing_id is not None: + return self._by_id[existing_id] + + cid = make_url_citation_id(url) + # 哈希碰撞兜底(同一 URL 已命中则上面已返回;不同 URL 撞 hash 时加后缀) + while cid in self._by_id and normalize_url(self._by_id[cid].get("url", "")) != norm: + cid += "x" + + ann: Dict[str, Any] = { + "id": cid, + "type": "url_citation", + "title": title or url, + "url": url, + "domain": domain_of(url), + "snippet": (snippet or "")[:SNIPPET_MAX_CHARS], + } + if published_date: + ann["published_date"] = published_date + if source_tool: + ann["source_tool"] = source_tool + self._by_id[cid] = ann + self._id_by_url[norm] = cid + return ann + + def resolve(self, citation_id: str) -> Optional[Dict[str, Any]]: + return self._by_id.get(citation_id) + + +def get_registry(terminal: Any) -> CitationRegistry: + """取 terminal 实例上的会话级 registry(懒创建)。""" + reg = getattr(terminal, "_citation_registry", None) + if reg is None: + reg = CitationRegistry() + terminal._citation_registry = reg + return reg + + +def parse_marker_content(content: str) -> List[str]: + """拆分 marker 内容为引用 token 列表(支持中英文逗号分隔)。""" + parts = re.split(r"[,,]", content or "") + return [p.strip() for p in parts if p.strip()] + + +def extract_citation_refs(text: str) -> List[str]: + """提取正文中全部完整 marker 的引用 token(保持出现顺序,去重)。""" + refs: List[str] = [] + for m in CITATION_MARK_RE.finditer(text or ""): + for token in parse_marker_content(m.group(2)): + if token not in refs: + refs.append(token) + return refs + + +def _parse_file_ref(token: str) -> Optional[Dict[str, Any]]: + """解析文件引用 token(裸相对路径,可带 #L120-148 / #p7 locator)。非法返回 None。""" + body = token.strip() + if not body: + return None + m = _LOCATOR_RE.match(body) + if m: + ref: Dict[str, Any] = {"path": m.group("path")} + if m.group("page"): + ref["page"] = int(m.group("page")) + if m.group("line_start"): + ref["line_start"] = int(m.group("line_start")) + if m.group("line_end"): + ref["line_end"] = int(m.group("line_end")) + return ref + return {"path": body} + + +def _build_file_annotation(ref: Dict[str, Any], token: str, workspace_root: str) -> Optional[Dict[str, Any]]: + """校验文件引用:路径必须落在工作区内且文件存在;富化 file_name/size。 + + annotation id 与 marker 内 token 完全一致,前端按 id 精确匹配。 + """ + # 注意不能用 lstrip("./"):它是字符集语义,会把 .astrion 这类隐藏目录的前导点吃掉 + rel_path = (ref.get("path") or "").strip() + if rel_path.startswith("./"): + rel_path = rel_path[2:] + if not rel_path or rel_path.startswith("/"): + return None + try: + root = Path(workspace_root).resolve() + target = (root / rel_path).resolve() + # 边界检查:必须在工作区内 + if root != target and root not in target.parents: + return None + if not target.is_file(): + return None + stat = target.stat() + except Exception: + return None + + ann: Dict[str, Any] = { + "id": token, + "type": "file_citation", + "file_path": rel_path, + "file_name": target.name, + "size": stat.st_size, + } + snippet: Optional[str] = None + # 图片文件的 snippet 无意义(且 svg 这类文本型图片会把标记源码当摘要), + # 前端弹层按扩展名直接渲染 + if target.suffix.lower() not in _IMAGE_EXTS: + snippet = _read_file_snippet(target, ref) + if snippet: + ann["snippet"] = snippet + if ref.get("page") is not None: + ann["page"] = ref["page"] + if ref.get("line_start") is not None: + ann["line_start"] = ref["line_start"] + if ref.get("line_end") is not None: + ann["line_end"] = ref["line_end"] + return ann + + +def finalize_message_citations( + text: str, + registry: Optional[CitationRegistry], + workspace_root: str, +) -> Tuple[str, List[Dict[str, Any]]]: + """assistant 消息落库前处理:剥离无效 marker,返回 (clean_text, used_annotations)。 + + - src_xxx:registry 查得到才保留,否则剥离 marker; + - file: 引用:路径在工作区内且文件存在才保留; + - 一个 marker 里部分有效时,只保留有效 token 重建 marker;全部无效则整体移除。 + """ + if not text or ("【cite:" not in text and "【file:" not in text): + return text, [] + + used: List[Dict[str, Any]] = [] + seen_ids = set() + + def _keep(ann: Optional[Dict[str, Any]]) -> Optional[str]: + if not ann: + return None + ann_id = ann.get("id") + if ann_id and ann_id not in seen_ids: + seen_ids.add(ann_id) + used.append(ann) + return ann_id + + def _replace(m: re.Match) -> str: + kind = m.group(1) + tokens = parse_marker_content(m.group(2)) + kept: List[str] = [] + for token in tokens: + if kind == "cite": + # 网页来源:src_xxx 且 registry 查得到才保留 + if not _SRC_ID_RE.match(token): + continue + ann = registry.resolve(token) if registry else None + if _keep(ann): + kept.append(token) + else: + # 文件来源:路径在工作区内且存在才保留 + ref = _parse_file_ref(token) + if not ref: + continue + ann = _build_file_annotation(ref, token, workspace_root) + if _keep(ann): + kept.append(token) + if not kept: + return "" + return "【" + kind + ":" + ",".join(kept) + "】" + + clean = CITATION_MARK_RE.sub(_replace, text) + return clean, used diff --git a/modules/search_engine.py b/modules/search_engine.py index b9af6880..80711778 100644 --- a/modules/search_engine.py +++ b/modules/search_engine.py @@ -6,6 +6,7 @@ from typing import Dict, Optional, Any, List from datetime import datetime from pathlib import Path import re +from urllib.parse import urlparse try: from config import TAVILY_API_KEY, SEARCH_MAX_RESULTS, OUTPUT_FORMATS, DATA_DIR except ImportError: @@ -150,10 +151,12 @@ class SearchEngine: # 处理每个搜索结果 for idx, result in enumerate(raw_data.get("results", []), 1): + url = result.get("url", "") formatted_result = { "index": idx, "title": result.get("title", "无标题"), - "url": result.get("url", ""), + "url": url, + "domain": urlparse(url).netloc.lower() if url else "", "content": result.get("content", ""), "score": result.get("score", 0), "published_date": result.get("published_date", "") @@ -161,6 +164,48 @@ class SearchEngine: formatted["results"].append(formatted_result) return formatted + + def build_summary_text( + self, + query: str, + results: List[Dict[str, Any]], + filters: Dict[str, Any], + timestamp: str + ) -> str: + """构建给模型看的搜索摘要文本。 + + 若结果项带 citation_id(tools_execution 注册 citation 后回填), + 标题行会带 [src_xxx] 前缀,供模型在行内引用中使用。 + """ + summary_lines = [ + f"🔍 搜索查询: {query}", + f"📅 搜索时间: {timestamp}" + ] + + filter_notes = self._summarize_filters(filters or {}) + if filter_notes: + summary_lines.append(filter_notes) + summary_lines.append("") + + # 添加搜索结果 + if results: + summary_lines.append("📊 搜索结果:") + + for result in results: + cid = result.get("citation_id") + title_line = f"\n{result['index']}. [{cid}] {result['title']}" if cid else f"\n{result['index']}. {result['title']}" + summary_lines.extend([ + title_line, + f" 🔗 {result['url']}", + f" 📄 {result['content'][:200]}..." if len(result['content']) > 200 else f" 📄 {result['content']}", + ]) + + if result.get("published_date"): + summary_lines.append(f" 📅 发布时间: {result['published_date']}") + else: + summary_lines.append("未找到相关结果") + + return "\n".join(summary_lines) async def search_with_summary( self, @@ -203,36 +248,15 @@ class SearchEngine: "summary": "" } - # 构建摘要 - summary_lines = [ - f"🔍 搜索查询: {query}", - f"📅 搜索时间: {results['timestamp']}" - ] - - filter_notes = self._summarize_filters(results.get("filters", {})) - if filter_notes: - summary_lines.append(filter_notes) - summary_lines.append("") - - # 添加搜索结果 - if results["results"]: - summary_lines.append("📊 搜索结果:") - - for result in results["results"]: - summary_lines.extend([ - f"\n{result['index']}. {result['title']}", - f" 🔗 {result['url']}", - f" 📄 {result['content'][:200]}..." if len(result['content']) > 200 else f" 📄 {result['content']}", - ]) - - if result.get("published_date"): - summary_lines.append(f" 📅 发布时间: {result['published_date']}") - else: - summary_lines.append("未找到相关结果") - return { "success": True, - "summary": "\n".join(summary_lines), + "summary": self.build_summary_text( + query, + results["results"], + results.get("filters", {}), + results["timestamp"] + ), + "timestamp": results["timestamp"], "filters": results.get("filters", {}), "query": results.get("query", query), "results": results.get("results", []), diff --git a/prompts/main_system.txt b/prompts/main_system.txt index e9bb0182..4aeccd1c 100644 --- a/prompts/main_system.txt +++ b/prompts/main_system.txt @@ -111,6 +111,29 @@ 注意:路径必须是工作区内已存在的文件。输出前确保文件已通过 `write_file` 或其他方式创建。 +### 3.1.6 行内引用(Citation) + +当你使用网页搜索结果、文件内容或其他外部资料回答时,必须在对应事实陈述之后添加引用标记,前端会渲染为可点击的来源胶囊。用户明确要求「引用」某个文件/来源时,同样必须输出标记——用加粗、行内代码或表格提到文件名都不算引用。 + +标记格式: +- 网页来源:【cite:src_xxx】(src_xxx 必须来自本次对话工具结果中明确提供的引用 ID,如 `[src_xxx]` 前缀或「来源 ID: src_xxx」) +- 文件来源:【file:工作区相对路径】,例如 【file:AGENTS.md】;指向具体行段:【file:AGENTS.md#L120-148】 +- 图片等二进制文件同样用 file 标记引用(如 【file:.astrion/user_upload/xxx.png】),前端会渲染缩略预览 +- 一个陈述引用多个来源:同类逗号并列【cite:src_xxx,src_yyy】/【file:report.pdf,notes.md】;网页与文件混排相邻写两个标记,例如 【cite:src_xxx】【file:report.pdf】 + +引用要求: +1. 只引用实际支持当前陈述的来源,不引用无关来源 +2. 引用紧跟在相关句子或段落末尾,不要统一放在回答结尾或集中列「参考资料」(除非用户要求) +3. 没有使用外部资料时不要添加引用,也不要逐句标注 +4. 只能使用工具结果明确提供的 src_xxx 和工作区内真实存在的文件相对路径,禁止编造;标记内禁止写 URL +5. 代码块和行内代码中的标记不会被渲染,不要在代码示例里放引用 + +示例: +- 该功能在 v2.3 版本引入。【cite:src_abc123def4】 +- 两个项目的实现方案一致。【cite:src_abc123def4,src_xyz987abcd】 +- 配置项说明见部署文档。【file:docs/deploy.md#L45-60】 +- 这一结论同时有网页和本地文档支持。【cite:src_abc123def4】【file:docs/deploy.md】 + ### 3.2 文件操作 - `write_file`:写入文件(`append` 控制覆盖/追加) diff --git a/prompts/main_system_vl.txt b/prompts/main_system_vl.txt index caa012d7..5f61a9ce 100644 --- a/prompts/main_system_vl.txt +++ b/prompts/main_system_vl.txt @@ -111,6 +111,29 @@ 注意:路径必须是工作区内已存在的文件。输出前确保文件已通过 `write_file` 或其他方式创建。 +### 3.1.6 行内引用(Citation) + +当你使用网页搜索结果、文件内容或其他外部资料回答时,必须在对应事实陈述之后添加引用标记,前端会渲染为可点击的来源胶囊。用户明确要求「引用」某个文件/来源时,同样必须输出标记——用加粗、行内代码或表格提到文件名都不算引用。 + +标记格式: +- 网页来源:【cite:src_xxx】(src_xxx 必须来自本次对话工具结果中明确提供的引用 ID,如 `[src_xxx]` 前缀或「来源 ID: src_xxx」) +- 文件来源:【file:工作区相对路径】,例如 【file:AGENTS.md】;指向具体行段:【file:AGENTS.md#L120-148】 +- 图片等二进制文件同样用 file 标记引用(如 【file:.astrion/user_upload/xxx.png】),前端会渲染缩略预览 +- 一个陈述引用多个来源:同类逗号并列【cite:src_xxx,src_yyy】/【file:report.pdf,notes.md】;网页与文件混排相邻写两个标记,例如 【cite:src_xxx】【file:report.pdf】 + +引用要求: +1. 只引用实际支持当前陈述的来源,不引用无关来源 +2. 引用紧跟在相关句子或段落末尾,不要统一放在回答结尾或集中列「参考资料」(除非用户要求) +3. 没有使用外部资料时不要添加引用,也不要逐句标注 +4. 只能使用工具结果明确提供的 src_xxx 和工作区内真实存在的文件相对路径,禁止编造;标记内禁止写 URL +5. 代码块和行内代码中的标记不会被渲染,不要在代码示例里放引用 + +示例: +- 该功能在 v2.3 版本引入。【cite:src_abc123def4】 +- 两个项目的实现方案一致。【cite:src_abc123def4,src_xyz987abcd】 +- 配置项说明见部署文档。【file:docs/deploy.md#L45-60】 +- 这一结论同时有网页和本地文档支持。【cite:src_abc123def4】【file:docs/deploy.md】 + ### 3.2 文件操作 - `write_file`:写入文件(`append` 控制覆盖/追加) diff --git a/prompts/multi_agent/master.txt b/prompts/multi_agent/master.txt index f43a245d..97fac92c 100644 --- a/prompts/multi_agent/master.txt +++ b/prompts/multi_agent/master.txt @@ -283,6 +283,29 @@ id: ask_fse_001 注意:路径必须是工作区内已存在的文件。输出前确保文件已通过 `write_file` 或其他方式创建。 +### 行内引用(Citation) + +你是最终向用户汇报的人。当你使用网页搜索结果、文件内容或其他外部资料回答时,必须在对应事实陈述之后添加引用标记,前端会渲染为可点击的来源胶囊。用户明确要求「引用」某个文件/来源时,同样必须输出标记——用加粗、行内代码或表格提到文件名都不算引用。 + +标记格式: +- 网页来源:【cite:src_xxx】(src_xxx 必须来自本次对话工具结果中明确提供的引用 ID,如 `[src_xxx]` 前缀或「来源 ID: src_xxx」;子智能体汇报中附带的来源 ID 同样可用) +- 文件来源:【file:工作区相对路径】,例如 【file:AGENTS.md】;指向具体行段:【file:AGENTS.md#L120-148】 +- 图片等二进制文件同样用 file 标记引用(如 【file:.astrion/user_upload/xxx.png】),前端会渲染缩略预览 +- 一个陈述引用多个来源:同类逗号并列【cite:src_xxx,src_yyy】/【file:report.pdf,notes.md】;网页与文件混排相邻写两个标记,例如 【cite:src_xxx】【file:report.pdf】 + +引用要求: +1. 只引用实际支持当前陈述的来源,不引用无关来源 +2. 引用紧跟在相关句子或段落末尾,不要统一放在回答结尾或集中列「参考资料」(除非用户要求) +3. 没有使用外部资料时不要添加引用,也不要逐句标注 +4. 只能使用工具结果或子智能体汇报中明确提供的 src_xxx 和工作区内真实存在的文件相对路径,禁止编造;标记内禁止写 URL +5. 代码块和行内代码中的标记不会被渲染,不要在代码示例里放引用 + +示例: +- 该功能在 v2.3 版本引入。【cite:src_abc123def4】 +- 两个项目的实现方案一致。【cite:src_abc123def4,src_xyz987abcd】 +- 配置项说明见部署文档。【file:docs/deploy.md#L45-60】 +- 这一结论同时有网页和本地文档支持。【cite:src_abc123def4】【file:docs/deploy.md】 + ## 关于显示名 - 主智能体固定显示名:`Team Leader` diff --git a/server/chat_flow_task_main.py b/server/chat_flow_task_main.py index f7a5914d..30945162 100644 --- a/server/chat_flow_task_main.py +++ b/server/chat_flow_task_main.py @@ -2034,6 +2034,8 @@ async def handle_task_with_sender( # 统计和限制变量 total_iterations = 0 total_tool_calls = 0 + # 行内引用:任务级已用来源累积(task_complete 时带给前端实时渲染),按 id 去重 + task_citations: Dict[str, dict] = {} consecutive_same_tool = defaultdict(int) last_tool_name = "" auto_fix_attempts = 0 @@ -2312,6 +2314,23 @@ async def handle_task_with_sender( assistant_content = "\n".join(assistant_content_parts) if assistant_content_parts else "" + # 行内引用:校验 marker、剥离无效引用,已用来源挂到消息 metadata 一并落库 + round_citations = [] + if assistant_content and ("【cite:" in assistant_content or "【file:" in assistant_content): + try: + from modules.citations import finalize_message_citations, get_registry + assistant_content, round_citations = finalize_message_citations( + assistant_content, + get_registry(web_terminal), + str(getattr(web_terminal, "project_path", "") or ""), + ) + for ann in round_citations: + if ann.get("id"): + task_citations[ann["id"]] = ann + except Exception as _cite_exc: + debug_log(f"[Citations] 行内引用处理失败,保留原文: {_cite_exc}") + round_citations = [] + # 添加到消息历史(用于API继续对话,不保存到文件) assistant_message = { "role": "assistant", @@ -2328,7 +2347,8 @@ async def handle_task_with_sender( "assistant", assistant_content, tool_calls=tool_calls if tool_calls else None, - reasoning_content=current_thinking or "" + reasoning_content=current_thinking or "", + metadata={"citations": round_citations} if round_citations else None ) # 为下一轮迭代重置流状态标志,但保留 full_response 供上面保存使用 @@ -2757,4 +2777,6 @@ async def handle_task_with_sender( # 前端就应保持运行态并继续轮询。 'has_running_multi_agent': has_running_multi_agent or has_pending_ma_messages, 'pending_runtime_guidance_messages': pending_runtime_guidance_messages, + # 行内引用:本任务全部已用来源,前端挂到当前 assistant 消息渲染 chip + 'citations': list(task_citations.values()), }) diff --git a/static/src/App.vue b/static/src/App.vue index ad909257..33fc65d3 100644 --- a/static/src/App.vue +++ b/static/src/App.vue @@ -404,6 +404,7 @@ +
c?.id)); + msgCitations.forEach((c) => { + if (c && c.id && !seen.has(c.id)) { + seen.add(c.id); + merged.push(c); + } + }); + currentAssistantMessage.metadata = { + ...(currentAssistantMessage.metadata || {}), + citations: merged + }; + } + const content = message.content || ''; const reasoningText = (message.reasoning_content || '').trim(); diff --git a/static/src/app/methods/taskPolling/lifecycle.ts b/static/src/app/methods/taskPolling/lifecycle.ts index 7b3ac38a..79ac1640 100644 --- a/static/src/app/methods/taskPolling/lifecycle.ts +++ b/static/src/app/methods/taskPolling/lifecycle.ts @@ -446,6 +446,18 @@ export const lifecycleMethods = { debugLog('[TaskPolling] 任务完成'); } + // 行内引用:任务完成事件带回权威来源(轮询通道;socket 通道在 useLegacySocket 同款处理), + // 挂到最后一条 assistant 消息 metadata,MarkdownRenderer watcher 据此做 chip 裁决与富化 + if (Array.isArray(data?.citations) && Array.isArray(this.messages) && this.messages.length) { + const lastMsg = this.messages[this.messages.length - 1]; + if (lastMsg && lastMsg.role === 'assistant') { + lastMsg.metadata = { + ...(lastMsg.metadata || {}), + citations: data.citations + }; + } + } + // 同步处理状态更新 this.streamingMessage = false; this.stopRequested = false; diff --git a/static/src/components/chat/ChatArea.vue b/static/src/components/chat/ChatArea.vue index e6d25d4f..ade1eb42 100644 --- a/static/src/components/chat/ChatArea.vue +++ b/static/src/components/chat/ChatArea.vue @@ -187,6 +187,9 @@
@@ -533,6 +539,9 @@ @@ -739,6 +748,7 @@ import ToolAction from '@/components/chat/actions/ToolAction.vue'; import StackedBlocks from './StackedBlocks.vue'; import MinimalBlocks from './MinimalBlocks.vue'; import MarkdownRenderer from './MarkdownRenderer.vue'; +import { collectConversationCitations, type CitationAnnotation } from './citationChips'; import EditSummaryCard from './EditSummaryCard.vue'; import { usePersonalizationStore } from '@/stores/personalization'; import { useUiStore } from '@/stores/ui'; @@ -778,6 +788,22 @@ const personalizationReady = computed(() => { const renderPending = computed(() => { return !!props.historyLoading || !personalizationReady.value; }); + +// 行内引用:对话级来源收集(扫描所有消息的工具结果 result.citations)。 +// 工具完成先于模型输出 marker,chip 在输出瞬间即可解析渲染,不等任务完成。 +const collectedCitations = computed(() => + collectConversationCitations(props.messages || []) +); +/** 该消息可用的引用来源:权威(metadata.citations,后端裁决富化)优先,否则用收集表 */ +function citationsForMessage(msg: any): CitationAnnotation[] | undefined { + const meta = msg?.metadata?.citations; + if (Array.isArray(meta)) return meta; + return collectedCitations.value.length ? collectedCitations.value : undefined; +} +/** metadata.citations 到达即为权威裁决(含空数组=全部无效),触发无效 chip 移除与富化 */ +function citationsFinalForMessage(msg: any): boolean { + return Array.isArray(msg?.metadata?.citations); +} const blockDisplayMode = computed(() => { return personalization.experiments.blockDisplayMode || 'stacked'; }); diff --git a/static/src/components/chat/CitationPopover.vue b/static/src/components/chat/CitationPopover.vue new file mode 100644 index 00000000..ebc37060 --- /dev/null +++ b/static/src/components/chat/CitationPopover.vue @@ -0,0 +1,564 @@ + + + + + diff --git a/static/src/components/chat/MarkdownRenderer.vue b/static/src/components/chat/MarkdownRenderer.vue index b6421af2..238c1837 100644 --- a/static/src/components/chat/MarkdownRenderer.vue +++ b/static/src/components/chat/MarkdownRenderer.vue @@ -32,12 +32,17 @@ import { type MarkdownSegment } from '@/composables/useMarkdownRenderer'; import { chunkRenderedHtml, type HtmlChunk } from '@/utils/htmlChunks'; +import { enhanceCitationChips, type CitationAnnotation } from './citationChips'; defineOptions({ name: 'MarkdownRenderer' }); const props = defineProps<{ content: string; isStreaming?: boolean; + citations?: CitationAnnotation[]; + /** citations 是否为后端裁决后的权威版本(message.metadata.citations 已到达) */ + citationsFinal?: boolean; + enableCitations?: boolean; }>(); const containerRef = ref(null); @@ -47,7 +52,8 @@ const segments = computed(() => parseMarkdownSegments(props.content || '', props function renderText(text: string) { // 透传流式标志:show_html 卡片在流式期间需要 partial 渲染(实时渲染/渲染中占位) - return renderMarkdownText(text, props.isStreaming); + // enableCitations:仅 assistant 正文开启引用渲染,其他场景【cite:】按原文显示 + return renderMarkdownText(text, props.isStreaming, props.enableCitations); } // 分段级分块缓存:segments 每个 token 都是新对象,按 key 缓存避免对 @@ -83,6 +89,22 @@ function renderMath() { onMounted(renderMath); onUpdated(renderMath); watch(() => props.content, renderMath, { immediate: true }); + +// 行内引用:渲染后扫描 chip 占位 span,填充内容并接管交互。 +// chip 在输出瞬间即解析(工具结果查表 / file token 自解析); +// citationsFinal 到达时做权威裁决(移除无效 chip)与富化。 +function enhanceCitations() { + if (!props.enableCitations) return; + nextTick(() => { + if (containerRef.value) { + enhanceCitationChips(containerRef.value, props.citations, { final: props.citationsFinal }); + } + }); +} + +onMounted(enhanceCitations); +onUpdated(enhanceCitations); +watch(() => [props.citations, props.citationsFinal], enhanceCitations);