feat(chat): 行内引用胶囊(inline citations)

- 模型输出 【cite:src_xxx】/【file:相对路径】 标记,渲染为可交互来源胶囊(悬停延迟开/点击固定/滚动收起)
- 后端:modules/citations.py 注册表与落库校验(剥离幻觉 id 与不存在的文件);web_search/extract_webpage 注册来源并在工具结果带 citation 信息;assistant 消息落库时 finalize 并挂载 message.metadata.citations
- 前端:marker 输出瞬间即渲染(工具结果查表 + 文件 token 自解析),task_complete 后权威裁决与富化;弹层支持网页摘要/文件内容片段/图片预览,文件头部点击打开右侧预览面板,宿主机模式可在文件管理器中打开
- prompt:三份系统提示加入引用格式与规则(提到文件名不等于引用;图片等二进制文件同样可引用)
This commit is contained in:
JOJO 2026-09-01 22:31:37 +08:00
parent a3fa5e59c6
commit e6d8f194d2
22 changed files with 1561 additions and 44 deletions

View File

@ -92,7 +92,7 @@ class ToolsDefinitionSearchWebToolsMixin:
"type": "function", "type": "function",
"function": { "function": {
"name": "web_search", "name": "web_search",
"description": "当现有资料不足时搜索外部信息。调用前说明目的,精准撰写 query并合理设置时间/主题参数;避免重复或无意义的搜索。", "description": "当现有资料不足时搜索外部信息。调用前说明目的,精准撰写 query并合理设置时间/主题参数;避免重复或无意义的搜索。结果中每条来源带 [src_xxx] 引用 ID回答中标注事实来源时使用。",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": self._inject_intent({ "properties": self._inject_intent({
@ -145,7 +145,7 @@ class ToolsDefinitionSearchWebToolsMixin:
"type": "function", "type": "function",
"function": { "function": {
"name": "extract_webpage", "name": "extract_webpage",
"description": "在 web_search 结果不够详细时提取网页正文。调用前说明用途,注意提取内容会消耗大量 token超过80000字符将被拒绝。", "description": "在 web_search 结果不够详细时提取网页正文。调用前说明用途,注意提取内容会消耗大量 token超过80000字符将被拒绝。结果会带来源引用 ID。",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": self._inject_intent({ "properties": self._inject_intent({

View File

@ -1697,13 +1697,35 @@ class MainTerminalToolsExecutionMixin:
) )
if search_response["success"]: 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 = { result = {
"success": True, "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", {}), "filters": search_response.get("filters", {}),
"query": search_response.get("query"), "query": search_response.get("query"),
"results": search_response.get("results", []), "results": results_list,
"total_results": search_response.get("total_results", 0) "total_results": search_response.get("total_results", 0),
"citations": citations,
} }
else: else:
result = { result = {
@ -1738,10 +1760,19 @@ class MainTerminalToolsExecutionMixin:
"url": url "url": url
} }
else: 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 = { result = {
"success": True, "success": True,
"url": url, "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: except Exception as e:
result = { result = {

283
modules/citations.py Normal file
View File

@ -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: 前缀 + IDsrc_<sha1(规范化 url) 10 >确定性 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-L148GitHub 风格)/ #p7
_LOCATOR_RE = re.compile(r"^(?P<path>.*?)#(?:L(?P<line_start>\d+)(?:-L?(?P<line_end>\d+))?|p(?P<page>\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
# 图片扩展名:引用弹层渲染 <img> 而不是文本摘要
_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 这类文本型图片会把标记源码当摘要),
# 前端弹层按扩展名直接渲染 <img>
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_xxxregistry 查得到才保留否则剥离 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

View File

@ -6,6 +6,7 @@ from typing import Dict, Optional, Any, List
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
import re import re
from urllib.parse import urlparse
try: try:
from config import TAVILY_API_KEY, SEARCH_MAX_RESULTS, OUTPUT_FORMATS, DATA_DIR from config import TAVILY_API_KEY, SEARCH_MAX_RESULTS, OUTPUT_FORMATS, DATA_DIR
except ImportError: except ImportError:
@ -150,10 +151,12 @@ class SearchEngine:
# 处理每个搜索结果 # 处理每个搜索结果
for idx, result in enumerate(raw_data.get("results", []), 1): for idx, result in enumerate(raw_data.get("results", []), 1):
url = result.get("url", "")
formatted_result = { formatted_result = {
"index": idx, "index": idx,
"title": result.get("title", "无标题"), "title": result.get("title", "无标题"),
"url": result.get("url", ""), "url": url,
"domain": urlparse(url).netloc.lower() if url else "",
"content": result.get("content", ""), "content": result.get("content", ""),
"score": result.get("score", 0), "score": result.get("score", 0),
"published_date": result.get("published_date", "") "published_date": result.get("published_date", "")
@ -162,6 +165,48 @@ class SearchEngine:
return formatted return formatted
def build_summary_text(
self,
query: str,
results: List[Dict[str, Any]],
filters: Dict[str, Any],
timestamp: str
) -> str:
"""构建给模型看的搜索摘要文本。
若结果项带 citation_idtools_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( async def search_with_summary(
self, self,
query: str, query: str,
@ -203,36 +248,15 @@ class SearchEngine:
"summary": "" "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 { return {
"success": True, "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", {}), "filters": results.get("filters", {}),
"query": results.get("query", query), "query": results.get("query", query),
"results": results.get("results", []), "results": results.get("results", []),

View File

@ -111,6 +111,29 @@
注意:路径必须是工作区内已存在的文件。输出前确保文件已通过 `write_file` 或其他方式创建。 注意:路径必须是工作区内已存在的文件。输出前确保文件已通过 `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 文件操作 ### 3.2 文件操作
- `write_file`:写入文件(`append` 控制覆盖/追加) - `write_file`:写入文件(`append` 控制覆盖/追加)

View File

@ -111,6 +111,29 @@
注意:路径必须是工作区内已存在的文件。输出前确保文件已通过 `write_file` 或其他方式创建。 注意:路径必须是工作区内已存在的文件。输出前确保文件已通过 `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 文件操作 ### 3.2 文件操作
- `write_file`:写入文件(`append` 控制覆盖/追加) - `write_file`:写入文件(`append` 控制覆盖/追加)

View File

@ -283,6 +283,29 @@ id: ask_fse_001
注意:路径必须是工作区内已存在的文件。输出前确保文件已通过 `write_file` 或其他方式创建。 注意:路径必须是工作区内已存在的文件。输出前确保文件已通过 `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` - 主智能体固定显示名:`Team Leader`

View File

@ -2034,6 +2034,8 @@ async def handle_task_with_sender(
# 统计和限制变量 # 统计和限制变量
total_iterations = 0 total_iterations = 0
total_tool_calls = 0 total_tool_calls = 0
# 行内引用任务级已用来源累积task_complete 时带给前端实时渲染),按 id 去重
task_citations: Dict[str, dict] = {}
consecutive_same_tool = defaultdict(int) consecutive_same_tool = defaultdict(int)
last_tool_name = "" last_tool_name = ""
auto_fix_attempts = 0 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 "" 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继续对话不保存到文件 # 添加到消息历史用于API继续对话不保存到文件
assistant_message = { assistant_message = {
"role": "assistant", "role": "assistant",
@ -2328,7 +2347,8 @@ async def handle_task_with_sender(
"assistant", "assistant",
assistant_content, assistant_content,
tool_calls=tool_calls if tool_calls else None, 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 供上面保存使用 # 为下一轮迭代重置流状态标志,但保留 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, 'has_running_multi_agent': has_running_multi_agent or has_pending_ma_messages,
'pending_runtime_guidance_messages': pending_runtime_guidance_messages, 'pending_runtime_guidance_messages': pending_runtime_guidance_messages,
# 行内引用:本任务全部已用来源,前端挂到当前 assistant 消息渲染 chip
'citations': list(task_citations.values()),
}) })

View File

@ -404,6 +404,7 @@
<FilePreviewPanel <FilePreviewPanel
v-if="!isMobileViewport" v-if="!isMobileViewport"
/> />
<CitationPopover :host-mode="versioningHostMode" />
<div <div
v-if="!isMobileViewport && (terminalPanelOpen || gitChangesPanelOpen)" v-if="!isMobileViewport && (terminalPanelOpen || gitChangesPanelOpen)"
class="resize-handle resize-handle--right-panels" class="resize-handle resize-handle--right-panels"
@ -843,6 +844,7 @@ import VideoPicker from './components/overlay/VideoPicker.vue';
import ImageLightbox from './components/overlay/ImageLightbox.vue'; import ImageLightbox from './components/overlay/ImageLightbox.vue';
import QuickDock from './components/chat/quickdock/QuickDock.vue'; import QuickDock from './components/chat/quickdock/QuickDock.vue';
import FilePreviewPanel from './components/chat/quickdock/FilePreviewPanel.vue'; import FilePreviewPanel from './components/chat/quickdock/FilePreviewPanel.vue';
import CitationPopover from './components/chat/CitationPopover.vue';
import { useTutorialStore } from './stores/tutorial'; import { useTutorialStore } from './stores/tutorial';
import { usePersonalizationStore } from './stores/personalization'; import { usePersonalizationStore } from './stores/personalization';

View File

@ -318,6 +318,26 @@ export const historyMethods = {
}; };
} }
// 行内引用:工具循环的多条 assistant 持久化消息会合并成同一条前端消息,
// citations 需随合并按 id 去重汇聚,否则 chip 无数据可渲染
const msgCitations = message.metadata?.citations;
if (Array.isArray(msgCitations) && msgCitations.length) {
const merged = Array.isArray(currentAssistantMessage.metadata?.citations)
? [...currentAssistantMessage.metadata.citations]
: [];
const seen = new Set(merged.map((c) => 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 content = message.content || '';
const reasoningText = (message.reasoning_content || '').trim(); const reasoningText = (message.reasoning_content || '').trim();

View File

@ -446,6 +446,18 @@ export const lifecycleMethods = {
debugLog('[TaskPolling] 任务完成'); debugLog('[TaskPolling] 任务完成');
} }
// 行内引用任务完成事件带回权威来源轮询通道socket 通道在 useLegacySocket 同款处理),
// 挂到最后一条 assistant 消息 metadataMarkdownRenderer 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.streamingMessage = false;
this.stopRequested = false; this.stopRequested = false;

View File

@ -187,6 +187,9 @@
<MinimalBlocks <MinimalBlocks
v-if="hasRenderableAssistantActions(msg.actions || [])" v-if="hasRenderableAssistantActions(msg.actions || [])"
:actions="msg.actions || []" :actions="msg.actions || []"
:citations="citationsForMessage(msg)"
:citations-final="citationsFinalForMessage(msg)"
enable-citations
:conversation-running="streamingMessage" :conversation-running="streamingMessage"
:is-latest-message="index === latestMessageIndex" :is-latest-message="index === latestMessageIndex"
:icon-style="iconStyleSafe" :icon-style="iconStyleSafe"
@ -308,6 +311,9 @@
<MarkdownRenderer <MarkdownRenderer
:content="group.action.content || ''" :content="group.action.content || ''"
:is-streaming="group.action.streaming" :is-streaming="group.action.streaming"
:citations="citationsForMessage(msg)"
:citations-final="citationsFinalForMessage(msg)"
enable-citations
/> />
</div> </div>
</div> </div>
@ -533,6 +539,9 @@
<MarkdownRenderer <MarkdownRenderer
:content="action.content || ''" :content="action.content || ''"
:is-streaming="action.streaming" :is-streaming="action.streaming"
:citations="citationsForMessage(msg)"
:citations-final="citationsFinalForMessage(msg)"
enable-citations
/> />
</div> </div>
</div> </div>
@ -739,6 +748,7 @@ import ToolAction from '@/components/chat/actions/ToolAction.vue';
import StackedBlocks from './StackedBlocks.vue'; import StackedBlocks from './StackedBlocks.vue';
import MinimalBlocks from './MinimalBlocks.vue'; import MinimalBlocks from './MinimalBlocks.vue';
import MarkdownRenderer from './MarkdownRenderer.vue'; import MarkdownRenderer from './MarkdownRenderer.vue';
import { collectConversationCitations, type CitationAnnotation } from './citationChips';
import EditSummaryCard from './EditSummaryCard.vue'; import EditSummaryCard from './EditSummaryCard.vue';
import { usePersonalizationStore } from '@/stores/personalization'; import { usePersonalizationStore } from '@/stores/personalization';
import { useUiStore } from '@/stores/ui'; import { useUiStore } from '@/stores/ui';
@ -778,6 +788,22 @@ const personalizationReady = computed(() => {
const renderPending = computed(() => { const renderPending = computed(() => {
return !!props.historyLoading || !personalizationReady.value; return !!props.historyLoading || !personalizationReady.value;
}); });
// result.citations
// markerchip
const collectedCitations = computed<CitationAnnotation[]>(() =>
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(() => { const blockDisplayMode = computed(() => {
return personalization.experiments.blockDisplayMode || 'stacked'; return personalization.experiments.blockDisplayMode || 'stacked';
}); });

View File

@ -0,0 +1,564 @@
<template>
<teleport to="body">
<div
v-if="citationPopover.visible"
ref="popoverEl"
class="citation-popover"
:style="popoverStyle"
@mouseenter="keepCitationPopover"
@mouseleave="leaveCitationPopover"
@click.stop
>
<!-- 单来源完整卡片 -->
<template v-if="single">
<div
class="pop-header"
:class="{ clickable: isFile(single) && canPreviewFile }"
@click="onHeaderClick(single)"
>
<span class="pop-icon" v-html="iconHtml(single)"></span>
<span class="pop-domain">{{ headerText(single) }}</span>
</div>
<div class="pop-scroll">
<div class="pop-body">
<div class="pop-title-row">
<div class="pop-title">{{ single.title || single.file_name || '' }}</div>
<span v-if="locatorText(single)" class="pop-locator">{{ locatorText(single) }}</span>
</div>
<div v-if="single.url" class="pop-url">{{ single.url }}</div>
<div v-if="isImageFile(single)" class="pop-image">
<img :src="fileContentUrl(single)" :alt="single.file_name || ''" loading="lazy" />
</div>
<div v-else-if="displaySnippet(single)" class="pop-snippet">{{ displaySnippet(single) }}</div>
</div>
</div>
<div v-if="hasFooterAction" class="pop-footer">
<a
v-if="!isFile(single) && single.url"
class="pop-open"
:href="single.url"
target="_blank"
rel="noopener"
>{{ t('chat.citationOpenSource') }} </a>
<span
v-else-if="isFile(single) && hostMode"
class="pop-open"
@click="openOnComputer(single)"
>{{ t('quickdock.menuRevealInManager') }}</span>
</div>
</template>
<!-- 多来源列表 -->
<template v-else>
<div class="pop-header">
<span class="pop-domain">{{ t('chat.citationSources', { n: citationPopover.annotations.length }) }}</span>
</div>
<div class="pop-scroll">
<div
v-for="ann in citationPopover.annotations"
:key="ann.id"
class="pop-list-row"
@click="onRowClick(ann)"
>
<span class="pop-list-icon" v-html="iconHtml(ann)"></span>
<span class="pop-list-text">
<span class="pop-list-title">
<span class="pop-list-title-text">{{ ann.title || ann.file_name || '' }}</span>
<span v-if="isFile(ann) && locatorText(ann)" class="pop-locator pop-locator--sm">{{ locatorText(ann) }}</span>
</span>
<span class="pop-list-domain">{{ rowSubText(ann) }}</span>
</span>
</div>
</div>
</template>
</div>
</teleport>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch, nextTick } from 'vue';
import { t } from '@/locales';
import { useQuickDockStore } from '@/stores/quickDock';
import { useUiStore } from '@/stores/ui';
import {
citationPopover,
keepCitationPopover,
leaveCitationPopover,
closeCitationPopover,
type CitationAnnotation,
} from './citationChips';
defineOptions({ name: 'CitationPopover' });
// hostMode footer 宿docker
const props = defineProps<{ hostMode?: boolean }>();
const quickDock = useQuickDockStore();
const uiStore = useUiStore();
const single = computed<CitationAnnotation | null>(() =>
citationPopover.annotations.length === 1 ? citationPopover.annotations[0] : null,
);
// footer==宿
const hasFooterAction = computed(() => {
const s = single.value;
if (!s) return false;
if (isFile(s)) return !!props.hostMode;
return !!s.url;
});
const popoverEl = ref<HTMLElement | null>(null);
const popoverStyle = ref<Record<string, string>>({});
// App.vue
const canPreviewFile = computed(() => !uiStore.isMobileViewport);
function isFile(ann: CitationAnnotation) {
return ann.type === 'file_citation';
}
const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.ico', '.avif']);
function isImageFile(ann: CitationAnnotation): boolean {
if (!isFile(ann)) return false;
const p = (ann.file_path || ann.file_name || '').toLowerCase();
const dot = p.lastIndexOf('.');
return dot >= 0 && IMAGE_EXTS.has(p.slice(dot));
}
/** /api/file/content 的 inline 白名单含 image/,可直接作为 <img> 源 */
function fileContentUrl(ann: CitationAnnotation): string {
return `/api/file/content?path=${encodeURIComponent(ann.file_path || '')}`;
}
function shortDomain(domain?: string) {
return (domain || '').replace(/^www\./, '');
}
function escapeText(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
const FILE_ICON_SVG =
'<svg class="chip-file-icon" viewBox="0 0 16 16" fill="none">' +
'<path d="M4 1.5h5.5L13 5v9.5H4z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/>' +
'<path d="M9.5 1.5V5H13" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>';
function iconHtml(ann: CitationAnnotation): string {
if (isFile(ann)) return FILE_ICON_SVG;
const d = shortDomain(ann.domain);
const letter = (d[0] || '?').toUpperCase();
return (
`<img class="chip-favicon" loading="lazy" alt="" ` +
`src="https://www.google.com/s2/favicons?domain=${encodeURIComponent(d)}&sz=64" ` +
`onerror="this.outerHTML='<span class=&quot;chip-letter&quot;>${letter}</span>'">`
);
}
function headerText(ann: CitationAnnotation): string {
return isFile(ann) ? (ann.file_path || ann.file_name || '') : shortDomain(ann.domain);
}
function locatorText(ann: CitationAnnotation): string {
const bits: string[] = [];
if (ann.page != null) bits.push(`p.${ann.page}`);
if (ann.line_start != null) {
bits.push(
ann.line_end != null && ann.line_end !== ann.line_start
? `L${ann.line_start}${ann.line_end}`
: `L${ann.line_start}`,
);
}
return bits.join(' · ');
}
function rowSubText(ann: CitationAnnotation): string {
//
if (isFile(ann)) return ann.file_path || '';
return shortDomain(ann.domain);
}
function openFile(ann: CitationAnnotation) {
if (!ann.file_path || !canPreviewFile.value) return;
quickDock.openPreview(ann.file_path);
closeCitationPopover();
}
/* ---------- ----------
* 后端落库时已富化 snippet但旧消息富化前持久化与流式期的临时 annotation
* 没有 snippet这里在弹层打开时按 file_path 拉一次内容补齐 */
const snippetCache = ref<Record<string, string>>({});
function displaySnippet(ann: CitationAnnotation): string {
return ann.snippet || snippetCache.value[ann.id] || '';
}
async function maybeFetchSnippet(ann: CitationAnnotation) {
if (!isFile(ann) || isImageFile(ann) || ann.snippet || !ann.file_path || snippetCache.value[ann.id]) return;
const path = ann.file_path;
try {
const resp = await fetch(`/api/file/content?path=${encodeURIComponent(path)}`);
if (!resp.ok) return;
let text = await resp.text();
//
if (text.includes('\x00')) return;
const bad = (text.match(/\uFFFD/g) || []).length;
if (bad > Math.max(4, text.length * 0.005)) return;
if (ann.line_start != null) {
const lines = text.split('\n');
const s = Math.max(1, ann.line_start);
const e = Math.min(lines.length, ann.line_end ?? s);
text = lines.slice(s - 1, e).join('\n');
}
// pre-line
text = text
.split('\n')
.map((l) => l.trim())
.filter(Boolean)
.join('\n')
.slice(0, 300);
if (text) {
snippetCache.value = { ...snippetCache.value, [ann.id]: text };
}
} catch {
/* 读取失败静默:弹层退化为仅路径/定位信息 */
}
}
watch(
() => [citationPopover.visible, single.value?.id] as const,
([visible]) => {
if (visible && single.value) maybeFetchSnippet(single.value);
},
);
/** 文件卡片头部点击 → 复用右侧文件预览面板 */
function onHeaderClick(ann: CitationAnnotation) {
if (isFile(ann)) openFile(ann);
}
/** 在电脑上直接打开:复用 QuickDock 的链路(系统默认应用 = 候选列表第一个) */
async function openOnComputer(ann: CitationAnnotation) {
const path = ann.file_path;
if (!path) return;
try {
const resp = await fetch(`/api/project/file-open-apps?path=${encodeURIComponent(path)}`);
const payload = await resp.json().catch(() => ({}));
if (!resp.ok || !payload?.success) {
throw new Error(payload?.error || t('quickdock.revealDetectAppsFailed'));
}
const apps = Array.isArray(payload?.data?.apps) ? payload.data.apps : [];
if (!apps.length) {
throw new Error(t('quickdock.revealNoAppsFound'));
}
const openResp = await fetch('/api/project/open-file-with-app', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path, app_id: apps[0].id }),
});
const openPayload = await openResp.json().catch(() => ({}));
if (!openResp.ok || !openPayload?.success) {
throw new Error(openPayload?.error || t('quickdock.revealOpenFileFailed'));
}
} catch (err: any) {
uiStore.pushToast({ message: err?.message || t('quickdock.revealOpenFileFailed'), type: 'error' });
return;
}
closeCitationPopover();
}
function onRowClick(ann: CitationAnnotation) {
if (isFile(ann)) {
openFile(ann);
} else if (ann.url) {
window.open(ann.url, '_blank', 'noopener');
closeCitationPopover();
}
}
/** 定位:优先放胶囊下方,空间不足翻到上方;水平方向视口内夹取 */
function positionPopover() {
const anchor = citationPopover.anchor;
const el = popoverEl.value;
if (!anchor || !el) return;
const rect = anchor.getBoundingClientRect();
const width = 320;
const maxHeight = 300;
const height = Math.min(el.offsetHeight || 200, maxHeight);
let left = Math.min(Math.max(8, rect.left), window.innerWidth - width - 8);
let top = rect.bottom + 6;
if (top + height > window.innerHeight - 8) {
top = Math.max(8, rect.top - height - 6);
}
popoverStyle.value = { left: `${left}px`, top: `${top}px` };
}
watch(
() => citationPopover.visible,
async (visible) => {
if (visible) {
await nextTick();
positionPopover();
}
},
);
const onGlobalClick = (e: MouseEvent) => {
if (popoverEl.value && !popoverEl.value.contains(e.target as Node)) {
closeCitationPopover();
}
};
const onKeydown = (e: KeyboardEvent) => {
if (e.key === 'Escape') closeCitationPopover();
};
//
const onScroll = () => closeCitationPopover();
const onResize = () => closeCitationPopover();
onMounted(() => {
document.addEventListener('click', onGlobalClick, true);
document.addEventListener('keydown', onKeydown);
window.addEventListener('scroll', onScroll, true);
window.addEventListener('resize', onResize);
});
onBeforeUnmount(() => {
document.removeEventListener('click', onGlobalClick, true);
document.removeEventListener('keydown', onKeydown);
window.removeEventListener('scroll', onScroll, true);
window.removeEventListener('resize', onResize);
});
</script>
<style scoped>
/* 实体面板:不透明、无 backdrop-filter中性阴影颜色全走语义 token */
.citation-popover {
position: fixed;
z-index: 1000;
width: 320px;
max-height: 300px;
display: flex;
flex-direction: column;
background: var(--surface-card);
border: 1px solid var(--border-default);
border-radius: 10px;
box-shadow: var(--shadow-card);
overflow: hidden;
}
.pop-scroll {
overflow-y: auto;
min-height: 0;
scrollbar-width: none;
}
.pop-scroll::-webkit-scrollbar {
width: 0;
height: 0;
}
.pop-header {
display: flex;
align-items: center;
gap: 6px;
height: 34px;
padding: 0 12px;
flex: none;
border-bottom: 1px solid var(--border-default);
font-size: 11px;
color: var(--text-tertiary);
}
.pop-header.clickable {
cursor: pointer;
}
.pop-header.clickable:hover {
color: var(--text-secondary);
}
.pop-icon {
display: inline-flex;
align-items: center;
flex: none;
}
.pop-domain {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}
.pop-body {
padding: 10px 12px 12px;
}
.pop-title-row {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
}
.pop-title {
font-size: 13.5px;
font-weight: 600;
line-height: 1.4;
color: var(--text-primary);
min-width: 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.pop-url {
font-size: 11.5px;
color: var(--text-tertiary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 8px;
}
.pop-locator {
flex: none;
display: inline-flex;
align-items: center;
height: 20px;
padding: 0 8px;
border-radius: 999px;
background: var(--surface-muted);
font-size: 11px;
color: var(--text-secondary);
}
.pop-snippet {
font-size: 12.5px;
line-height: 1.55;
color: var(--text-secondary);
white-space: pre-line; /* 保留文件内容换行 */
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
}
.pop-image {
margin-top: 6px;
}
.pop-image img {
display: block;
max-width: 100%;
max-height: 180px;
border-radius: 6px;
}
.pop-footer {
flex: none;
height: 38px;
padding: 0 12px;
border-top: 1px solid var(--border-default);
display: flex;
align-items: center;
}
.pop-open {
font-size: 12.5px;
color: var(--accent);
font-weight: 500;
display: inline-flex;
align-items: center;
gap: 4px;
text-decoration: none;
cursor: pointer;
}
.pop-open:hover {
color: var(--accent-hover);
text-decoration: underline;
}
.pop-list-row {
display: flex;
align-items: center;
gap: 8px;
height: 44px;
padding: 0 12px;
cursor: pointer;
}
.pop-list-row:hover {
background: var(--hover-bg);
}
.pop-list-row + .pop-list-row {
border-top: 1px solid var(--border-default);
}
.pop-list-icon {
flex: none;
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
}
.pop-list-icon :deep(.chip-favicon) {
width: 14px;
height: 14px;
}
.pop-list-icon :deep(.chip-file-icon) {
width: 14px;
height: 14px;
color: var(--text-tertiary); /* 显式颜色绑定stroke=currentColor 才能生效 */
}
.pop-list-text {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
}
.pop-list-title {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
font-size: 12.5px;
color: var(--text-primary);
line-height: 1.3;
}
.pop-list-title-text {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 列表行内的小型行号徽章44px 行高内容纳得下) */
.pop-locator--sm {
height: 16px;
padding: 0 6px;
font-size: 10px;
}
.pop-list-domain {
font-size: 11px;
color: var(--text-tertiary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.3;
}
.pop-header :deep(.chip-favicon) {
width: 12px;
height: 12px;
border-radius: 3px;
}
.pop-header :deep(.chip-file-icon) {
width: 12px;
height: 12px;
color: var(--text-tertiary);
}
.pop-header :deep(.chip-letter),
.pop-list-icon :deep(.chip-letter) {
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
border-radius: 3px;
font-size: 9px;
font-weight: 700;
color: var(--on-accent);
background: var(--accent);
}
</style>

View File

@ -32,12 +32,17 @@ import {
type MarkdownSegment type MarkdownSegment
} from '@/composables/useMarkdownRenderer'; } from '@/composables/useMarkdownRenderer';
import { chunkRenderedHtml, type HtmlChunk } from '@/utils/htmlChunks'; import { chunkRenderedHtml, type HtmlChunk } from '@/utils/htmlChunks';
import { enhanceCitationChips, type CitationAnnotation } from './citationChips';
defineOptions({ name: 'MarkdownRenderer' }); defineOptions({ name: 'MarkdownRenderer' });
const props = defineProps<{ const props = defineProps<{
content: string; content: string;
isStreaming?: boolean; isStreaming?: boolean;
citations?: CitationAnnotation[];
/** citations 是否为后端裁决后的权威版本message.metadata.citations 已到达) */
citationsFinal?: boolean;
enableCitations?: boolean;
}>(); }>();
const containerRef = ref<HTMLElement | null>(null); const containerRef = ref<HTMLElement | null>(null);
@ -47,7 +52,8 @@ const segments = computed(() => parseMarkdownSegments(props.content || '', props
function renderText(text: string) { function renderText(text: string) {
// show_html partial / // show_html partial /
return renderMarkdownText(text, props.isStreaming); // enableCitations assistant cite:
return renderMarkdownText(text, props.isStreaming, props.enableCitations);
} }
// segments token key // segments token key
@ -83,6 +89,22 @@ function renderMath() {
onMounted(renderMath); onMounted(renderMath);
onUpdated(renderMath); onUpdated(renderMath);
watch(() => props.content, renderMath, { immediate: true }); 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);
</script> </script>
<style scoped> <style scoped>

View File

@ -121,7 +121,7 @@
:class="{ 'streaming-text': group.streaming }" :class="{ 'streaming-text': group.streaming }"
> >
<div class="text-content" :class="{ 'streaming-text': group.streaming }"> <div class="text-content" :class="{ 'streaming-text': group.streaming }">
<MarkdownRenderer :content="group.content || ''" :is-streaming="group.streaming" /> <MarkdownRenderer :content="group.content || ''" :is-streaming="group.streaming" :citations="citations" :citations-final="citationsFinal" :enable-citations="enableCitations" />
</div> </div>
</div> </div>
<div v-else-if="group.type === 'system'" class="summary-group sub-agent-system-group"> <div v-else-if="group.type === 'system'" class="summary-group sub-agent-system-group">
@ -159,6 +159,7 @@ const personalizationStore = usePersonalizationStore();
const heightLimited = computed(() => personalizationStore.form.minimal_expand_height_limited); const heightLimited = computed(() => personalizationStore.form.minimal_expand_height_limited);
import { getRandomLoader } from './loaders/index'; import { getRandomLoader } from './loaders/index';
import MarkdownRenderer from './MarkdownRenderer.vue'; import MarkdownRenderer from './MarkdownRenderer.vue';
import type { CitationAnnotation } from './citationChips';
interface Action { interface Action {
id?: string; id?: string;
@ -196,6 +197,9 @@ interface BlockGroup {
const props = defineProps<{ const props = defineProps<{
actions: Action[]; actions: Action[];
citations?: CitationAnnotation[];
citationsFinal?: boolean;
enableCitations?: boolean;
conversationRunning?: boolean; conversationRunning?: boolean;
isLatestMessage?: boolean; isLatestMessage?: boolean;
iconStyle?: (name: string) => any; iconStyle?: (name: string) => any;

View File

@ -0,0 +1,275 @@
/**
* Inline Citationschip popover
*
* cite:...marker useMarkdownRenderer
* <span class="md-citation-chip" data-citation-ids="...">
* MarkdownRenderer message.metadata.citations
* chip / / /
*/
import { reactive } from 'vue';
export interface CitationAnnotation {
id: string;
type: 'url_citation' | 'file_citation';
// url 来源
title?: string;
url?: string;
domain?: string;
published_date?: string;
// 文件来源
file_path?: string;
file_name?: string;
size?: number;
// 可选定位
page?: number;
line_start?: number;
line_end?: number;
snippet?: string;
}
/** popover 全局状态(单例;同一时间只存在一个引用弹层) */
export const citationPopover = reactive({
visible: false,
pinned: false,
anchor: null as HTMLElement | null,
annotations: [] as CitationAnnotation[],
});
const HOVER_OPEN_DELAY = 250; // 悬停多久后打开
const HOVER_CLOSE_DELAY = 300; // 移出多久后关闭(留出移到弹层上的余量)
/** chip 元素上挂的当前 annotationsfinal 到达后可被富化替换,触发器事件时读取) */
type ChipWithAnnotations = HTMLElement & { _citationAnnotations?: CitationAnnotation[] };
let openTimer: ReturnType<typeof setTimeout> | undefined;
let closeTimer: ReturnType<typeof setTimeout> | undefined;
function clearPopoverTimers() {
clearTimeout(openTimer);
clearTimeout(closeTimer);
openTimer = undefined;
closeTimer = undefined;
}
export function openCitationPopover(anchor: HTMLElement, annotations: CitationAnnotation[], pinned: boolean) {
closeCitationPopover();
citationPopover.anchor = anchor;
citationPopover.annotations = annotations;
citationPopover.pinned = pinned;
citationPopover.visible = true;
anchor.classList.add('is-open');
}
export function closeCitationPopover() {
clearPopoverTimers();
if (citationPopover.anchor) {
citationPopover.anchor.classList.remove('is-open');
}
citationPopover.visible = false;
citationPopover.pinned = false;
citationPopover.anchor = null;
citationPopover.annotations = [];
}
function schedulePopoverClose() {
clearTimeout(closeTimer);
closeTimer = setTimeout(() => {
if (!citationPopover.pinned) closeCitationPopover();
}, HOVER_CLOSE_DELAY);
}
/** popover 自身 hover 进入时取消关闭CitationPopover 组件调用) */
export function keepCitationPopover() {
clearTimeout(closeTimer);
}
/** popover 自身 hover 离开时按延迟关闭(未固定时) */
export function leaveCitationPopover() {
if (!citationPopover.pinned) schedulePopoverClose();
}
function attachChipTriggers(chip: ChipWithAnnotations) {
// 事件时从元素上读最新 annotationsfinal 富化替换后无需重绑监听器)
const current = () => chip._citationAnnotations || [];
chip.addEventListener('mouseenter', () => {
clearTimeout(closeTimer);
if (citationPopover.anchor === chip && citationPopover.visible) return;
clearTimeout(openTimer);
openTimer = setTimeout(() => openCitationPopover(chip, current(), false), HOVER_OPEN_DELAY);
});
chip.addEventListener('mouseleave', () => {
clearTimeout(openTimer);
if (citationPopover.visible && citationPopover.anchor === chip && !citationPopover.pinned) {
schedulePopoverClose();
}
});
chip.addEventListener('click', (e) => {
e.stopPropagation();
clearPopoverTimers();
if (citationPopover.anchor === chip && citationPopover.visible && citationPopover.pinned) {
closeCitationPopover(); // 再点已固定的弹层 → 收起
} else {
openCitationPopover(chip, current(), true); // 点击 = 固定
}
});
}
/** token docs/x.md#L1-10 annotation
* marker chip */
export function fileAnnotationFromToken(token: string): CitationAnnotation | null {
const raw = (token || '').trim();
if (!raw || /^src_/i.test(raw)) return null;
// 兼容 #L1-10 与 GitHub 风格 #L1-L10 / #p7
const m = raw.match(/^(.*?)#(?:L(\d+)(?:-L?(\d+))?|p(\d+))$/i);
let path = (m ? m[1] : raw).trim();
if (path.startsWith('./')) path = path.slice(2);
if (!path) return null;
const ann: CitationAnnotation = {
id: token,
type: 'file_citation',
file_path: path,
file_name: path.split('/').pop() || path,
};
if (m) {
if (m[4]) ann.page = parseInt(m[4], 10);
if (m[2]) {
ann.line_start = parseInt(m[2], 10);
if (m[3]) ann.line_end = parseInt(m[3], 10);
}
}
return ann;
}
/** 扫描对话消息中的工具结果,收集 web_search / extract_webpage annotation
* marker cite:src_xxx */
export function collectConversationCitations(messages: any[]): CitationAnnotation[] {
const map = new Map<string, CitationAnnotation>();
for (const msg of messages || []) {
for (const action of msg?.actions || []) {
const list = action?.tool?.result?.citations;
if (!Array.isArray(list)) continue;
for (const ann of list) {
if (ann && typeof ann.id === 'string' && !map.has(ann.id)) {
map.set(ann.id, ann);
}
}
}
}
return [...map.values()];
}
function shortDomain(domain?: string): string {
return (domain || '').replace(/^www\./, '');
}
function chipLabel(ann: CitationAnnotation): string {
return ann.type === 'file_citation' ? (ann.file_name || ann.file_path || '') : shortDomain(ann.domain);
}
function faviconHtml(domain: string): string {
const d = shortDomain(domain);
// 站点图标加载失败时退化为域名首字符
const letter = (d[0] || '?').toUpperCase();
return (
`<img class="chip-favicon" loading="lazy" alt="" ` +
`src="https://www.google.com/s2/favicons?domain=${encodeURIComponent(d)}&sz=64" ` +
`onerror="this.outerHTML='<span class=&quot;chip-letter&quot;>${letter}</span>'">`
);
}
const FILE_ICON_SVG =
'<svg class="chip-file-icon" viewBox="0 0 16 16" fill="none">' +
'<path d="M4 1.5h5.5L13 5v9.5H4z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/>' +
'<path d="M9.5 1.5V5H13" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>';
function chipIconHtml(ann: CitationAnnotation): string {
return ann.type === 'file_citation' ? FILE_ICON_SVG : faviconHtml(ann.domain || '');
}
/**
* chip span
*
*
* - cite citations
* file token marker /locator
* - final=truemessage.metadata.citations
* chip marker chip
* popover snippet/sizeDOM
*/
export function enhanceCitationChips(
container: HTMLElement,
citations: CitationAnnotation[] | undefined,
opts: { final?: boolean } = {},
) {
const chips = container.querySelectorAll<ChipWithAnnotations>('.md-citation-chip');
if (!chips.length) return;
const final = !!opts.final;
const map = new Map((citations || []).map((c) => [c.id, c]));
chips.forEach((chip) => {
const ids = (chip.dataset.citationIds || '')
.split(',')
.map((s) => s.trim())
.filter(Boolean);
// 已渲染的 chipfinal 到达时换权威富化数据(供 popover 读取DOM 不动
if (chip.dataset.citeEnhanced === '1' && chip.dataset.citeLoaded === '1') {
if (final) {
const enriched = ids
.map((id) => map.get(id))
.filter((a): a is CitationAnnotation => !!a);
if (enriched.length) chip._citationAnnotations = enriched;
}
return;
}
let annotations = ids
.map((id) => map.get(id))
.filter((a): a is CitationAnnotation => !!a);
// 非 finalfile token 自解析作为临时数据(查表未命中时),让 chip 即时渲染
if (!annotations.length && !final) {
const derived = ids
.map((token) => fileAnnotationFromToken(token))
.filter((a): a is CitationAnnotation => !!a);
if (derived.length) annotations = derived;
}
if (!annotations.length) {
if (final) {
// 权威裁决:后端已剥离该 marker幻觉 id / 文件不存在),移除 chip
chip.remove();
return;
}
// 流式占位:中性胶囊,暂不可交互
if (chip.dataset.citeEnhanced !== '1') {
chip.dataset.citeEnhanced = '1';
chip.classList.add('is-pending');
chip.innerHTML = '<span class="chip-pending-dot"></span>';
}
return;
}
const icons = annotations.slice(0, 2).map(chipIconHtml).join('');
const more =
annotations.length > 1 ? `<span class="chip-count">+${annotations.length - 1}</span>` : '';
chip.innerHTML =
`<span class="chip-icons">${icons}</span>` +
`<span class="chip-label">${escapeText(chipLabel(annotations[0]))}</span>` +
more;
chip.classList.remove('is-pending');
chip.classList.add(annotations[0].type === 'file_citation' ? 'is-file' : 'is-url');
chip.dataset.citeEnhanced = '1';
chip.dataset.citeLoaded = '1';
chip._citationAnnotations = annotations;
attachChipTriggers(chip);
});
}
function escapeText(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}

View File

@ -1802,6 +1802,18 @@ export async function initializeLegacySocket(ctx: any) {
resetPendingToolEvents(); resetPendingToolEvents();
// 行内引用:任务完成时后端带回已用来源,挂到最后一条 assistant 消息的 metadata
// MarkdownRenderer 的 citations watcher 会触发 chip 增强(流式期间的占位胶囊在此转正)
if (Array.isArray(data?.citations) && Array.isArray(ctx.messages) && ctx.messages.length) {
const lastMsg = ctx.messages[ctx.messages.length - 1];
if (lastMsg && lastMsg.role === 'assistant') {
lastMsg.metadata = {
...(lastMsg.metadata || {}),
citations: data.citations
};
}
}
// 任务完成后立即更新Token统计关键修复 // 任务完成后立即更新Token统计关键修复
if (ctx.currentConversationId) { if (ctx.currentConversationId) {
ctx.updateCurrentContextTokens(); ctx.updateCurrentContextTokens();

View File

@ -313,6 +313,45 @@ function transformShowFileBlocks(raw: string) {
return output; return output;
} }
/**
* markercite:src_xxx / file:相对路径 citation chip span
* - marker
* - marker fenced code block segment
* - marker data
*/
function transformCitationMarkers(raw: string, isStreaming: boolean): string {
if (!raw || raw.indexOf('【') === -1) return raw;
// 保护行内代码:`...` 内的 marker 原样展示
const INLINE_CODE_PLACEHOLDER = '__CITE_PROTECT_INLINE_CODE_';
const inlineCodes: string[] = [];
let text = raw.replace(/`[^`\n]+`/g, (match) => {
inlineCodes.push(match);
return `${INLINE_CODE_PLACEHOLDER}${inlineCodes.length - 1}__`;
});
text = text.replace(/【(?:cite|file):([^】]+)】/g, (_m, ids: string) => {
const clean = ids
.split(/[,]/)
.map((s) => s.trim())
.filter(Boolean)
.map((s) => s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;'))
.join(',');
if (!clean) return '';
return `<span class="md-citation-chip" data-citation-ids="${clean}"></span>`;
});
if (isStreaming) {
// 尾部未闭合 marker【cite:... 甚至只输入了一半的【)暂时隐藏
text = text.replace(/【[^】]*$/, '');
}
// 还原行内代码
text = text.replace(new RegExp(`${INLINE_CODE_PLACEHOLDER}(\\d+)__`, 'g'), (_m, i: string) => inlineCodes[Number(i)]);
return text;
}
/** /**
* LaTeX markdown parser * LaTeX markdown parser
* $$...$$ $...$ * $$...$$ $...$
@ -532,7 +571,7 @@ const sanitizedSchema: Record<string, any> = {
attributes: { attributes: {
...(defaultSchema.attributes || {}), ...(defaultSchema.attributes || {}),
div: [...((defaultSchema.attributes || {}).div || []), 'className', 'data-md-table-scroll'], div: [...((defaultSchema.attributes || {}).div || []), 'className', 'data-md-table-scroll'],
span: [...((defaultSchema.attributes || {}).span || []), 'className', 'dataLatex', 'dataDisplay', 'dataMathRendered', 'data-latex', 'data-display', 'data-math-rendered'], span: [...((defaultSchema.attributes || {}).span || []), 'className', 'dataLatex', 'dataDisplay', 'dataMathRendered', 'data-latex', 'data-display', 'data-math-rendered', 'data-citation-ids', 'dataCitationIds'],
a: [ a: [
'ariaDescribedBy', 'ariaLabel', 'ariaLabelledBy', 'ariaDescribedBy', 'ariaLabel', 'ariaLabelledBy',
'dataFootnoteBackref', 'dataFootnoteRef', 'dataFootnoteBackref', 'dataFootnoteRef',
@ -719,15 +758,17 @@ export function parseMarkdownSegments(text: string, isStreaming = false): Markdo
return segments; return segments;
} }
export function renderMarkdownText(text: string, isStreaming = false): string { export function renderMarkdownText(text: string, isStreaming = false, enableCitations = false): string {
if (!text) return ''; if (!text) return '';
// isStreaming 必须透传:流式期间未闭合的 show_html 需要编码成 data-partial 占位 // isStreaming 必须透传:流式期间未闭合的 show_html 需要编码成 data-partial 占位
// js=off 实时渲染 / js=on 显示"渲染中"),否则原始标签文本会直接散落到消息里 // js=off 实时渲染 / js=on 显示"渲染中"),否则原始标签文本会直接散落到消息里
// enableCitations仅 assistant 正文开启;用户消息/预览等静态文本里的【cite:】原样显示
const withCustomBlocks = transformShowFileBlocks(
transformShowImageBlocks(transformShowHtmlBlocks(text, isStreaming))
);
const safeText = transformMathBlocks( const safeText = transformMathBlocks(
transformShowFileBlocks( enableCitations ? transformCitationMarkers(withCustomBlocks, isStreaming) : withCustomBlocks
transformShowImageBlocks(transformShowHtmlBlocks(text, isStreaming))
)
); );
let html = ''; let html = '';

View File

@ -37,6 +37,10 @@ export default {
thinking: 'Thinking', thinking: 'Thinking',
thinkingRunning: 'Thinking...', thinkingRunning: 'Thinking...',
// —— Inline citations (citation chip / popover) ——
citationSources: '{n} sources',
citationOpenSource: 'Open source',
// —— File append (append / append_payload) —— // —— File append (append / append_payload) ——
targetFile: 'target file', targetFile: 'target file',
appendDone: 'File append complete', appendDone: 'File append complete',

View File

@ -47,6 +47,10 @@ export default {
thinking: '思考过程', thinking: '思考过程',
thinkingRunning: '正在思考...', thinkingRunning: '正在思考...',
// —— 行内引用citation chip / popover ——
citationSources: '{n} 个来源',
citationOpenSource: '打开来源',
// —— 文件追加append / append_payload —— // —— 文件追加append / append_payload ——
targetFile: '目标文件', targetFile: '目标文件',
appendDone: '文件追加完成', appendDone: '文件追加完成',

View File

@ -2463,6 +2463,106 @@ a.md-download-link {
} }
} }
// ===== 行内引用胶囊citation chip =====
// 交互约定hover / 展开态只变色不动边框弹层交互见 CitationPopover.vue
.md-citation-chip {
display: inline-flex;
align-items: center;
gap: 4px;
height: 20px;
padding: 0 7px;
margin: 0 2px;
border-radius: 999px;
font-size: 12px;
line-height: 1;
white-space: nowrap;
vertical-align: middle; // 几何中心对齐基线 + x字高/2与文字完全上下居中 20px 盒仍在 25.5px 行框内不撑行
background: var(--surface-muted);
border: 1px solid var(--border-default);
color: var(--text-secondary);
cursor: pointer;
user-select: none;
transition: background 0.15s, color 0.15s;
&:hover,
&.is-open {
background: var(--hover-bg);
color: var(--text-primary);
}
&.is-pending {
cursor: default;
}
.chip-icons {
display: inline-flex;
align-items: center;
flex: none;
}
.chip-favicon {
width: 12px;
height: 12px;
border-radius: 3px;
flex: none;
}
.chip-favicon + .chip-favicon {
margin-left: -5px;
border: 1px solid var(--surface-muted);
}
.chip-file-icon {
width: 12px;
height: 12px;
flex: none;
color: var(--text-tertiary);
}
.chip-letter {
display: inline-flex;
align-items: center;
justify-content: center;
width: 12px;
height: 12px;
border-radius: 3px;
flex: none;
font-size: 8px;
font-weight: 700;
color: var(--on-accent);
background: var(--accent);
}
.chip-label {
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 500;
// 行高 1 会把 j/g/y 等字母的下沉笔画裁掉放大行高让 descender 落入盒内
// chip 总高 20px 由自身 height 锁定行高放大不撑高
line-height: 1.4;
}
.chip-count {
color: var(--text-tertiary);
font-weight: 500;
line-height: 1.4;
}
&:hover .chip-count,
&.is-open .chip-count {
color: var(--text-secondary);
}
.chip-pending-dot {
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--text-tertiary);
margin: 0 4px;
}
}
// ===== 数学公式样式 ===== // ===== 数学公式样式 =====
.math-block { .math-block {
display: block; display: block;

View File

@ -33,7 +33,9 @@ def _format_extract_webpage(result_data: Dict[str, Any]) -> str:
content = result_data.get("content") or "" content = result_data.get("content") or ""
length = len(content) length = len(content)
truncated_flag = result_data.get("truncated") or False truncated_flag = result_data.get("truncated") or False
header = f"提取完成:{url},长度 {length} 字符。" citation_id = result_data.get("citation_id")
citation_note = f"(来源 ID: {citation_id}" if citation_id else ""
header = f"提取完成:{url}{citation_note},长度 {length} 字符。"
if not content: if not content:
return f"{header} 内容为空。" return f"{header} 内容为空。"
# 为模型保留完整正文,避免 800 字预览导致上下文缺失 # 为模型保留完整正文,避免 800 字预览导致上下文缺失