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",
"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({

View File

@ -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 = {

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 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", "")
@ -162,6 +165,48 @@ class SearchEngine:
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(
self,
query: str,
@ -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", []),

View File

@ -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` 控制覆盖/追加)

View File

@ -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` 控制覆盖/追加)

View File

@ -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`

View File

@ -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()),
})

View File

@ -404,6 +404,7 @@
<FilePreviewPanel
v-if="!isMobileViewport"
/>
<CitationPopover :host-mode="versioningHostMode" />
<div
v-if="!isMobileViewport && (terminalPanelOpen || gitChangesPanelOpen)"
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 QuickDock from './components/chat/quickdock/QuickDock.vue';
import FilePreviewPanel from './components/chat/quickdock/FilePreviewPanel.vue';
import CitationPopover from './components/chat/CitationPopover.vue';
import { useTutorialStore } from './stores/tutorial';
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 reasoningText = (message.reasoning_content || '').trim();

View File

@ -446,6 +446,18 @@ export const lifecycleMethods = {
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.stopRequested = false;

View File

@ -187,6 +187,9 @@
<MinimalBlocks
v-if="hasRenderableAssistantActions(msg.actions || [])"
:actions="msg.actions || []"
:citations="citationsForMessage(msg)"
:citations-final="citationsFinalForMessage(msg)"
enable-citations
:conversation-running="streamingMessage"
:is-latest-message="index === latestMessageIndex"
:icon-style="iconStyleSafe"
@ -308,6 +311,9 @@
<MarkdownRenderer
:content="group.action.content || ''"
:is-streaming="group.action.streaming"
:citations="citationsForMessage(msg)"
:citations-final="citationsFinalForMessage(msg)"
enable-citations
/>
</div>
</div>
@ -533,6 +539,9 @@
<MarkdownRenderer
:content="action.content || ''"
:is-streaming="action.streaming"
:citations="citationsForMessage(msg)"
:citations-final="citationsFinalForMessage(msg)"
enable-citations
/>
</div>
</div>
@ -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
// 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(() => {
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
} 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<HTMLElement | null>(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);
</script>
<style scoped>

View File

@ -121,7 +121,7 @@
: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 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);
import { getRandomLoader } from './loaders/index';
import MarkdownRenderer from './MarkdownRenderer.vue';
import type { CitationAnnotation } from './citationChips';
interface Action {
id?: string;
@ -196,6 +197,9 @@ interface BlockGroup {
const props = defineProps<{
actions: Action[];
citations?: CitationAnnotation[];
citationsFinal?: boolean;
enableCitations?: boolean;
conversationRunning?: boolean;
isLatestMessage?: boolean;
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();
// 行内引用:任务完成时后端带回已用来源,挂到最后一条 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统计关键修复
if (ctx.currentConversationId) {
ctx.updateCurrentContextTokens();

View File

@ -313,6 +313,45 @@ function transformShowFileBlocks(raw: string) {
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
* $$...$$ $...$
@ -532,7 +571,7 @@ const sanitizedSchema: Record<string, any> = {
attributes: {
...(defaultSchema.attributes || {}),
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: [
'ariaDescribedBy', 'ariaLabel', 'ariaLabelledBy',
'dataFootnoteBackref', 'dataFootnoteRef',
@ -719,15 +758,17 @@ export function parseMarkdownSegments(text: string, isStreaming = false): Markdo
return segments;
}
export function renderMarkdownText(text: string, isStreaming = false): string {
export function renderMarkdownText(text: string, isStreaming = false, enableCitations = false): string {
if (!text) return '';
// isStreaming 必须透传:流式期间未闭合的 show_html 需要编码成 data-partial 占位
// js=off 实时渲染 / js=on 显示"渲染中"),否则原始标签文本会直接散落到消息里
const safeText = transformMathBlocks(
transformShowFileBlocks(
// enableCitations仅 assistant 正文开启;用户消息/预览等静态文本里的【cite:】原样显示
const withCustomBlocks = transformShowFileBlocks(
transformShowImageBlocks(transformShowHtmlBlocks(text, isStreaming))
)
);
const safeText = transformMathBlocks(
enableCitations ? transformCitationMarkers(withCustomBlocks, isStreaming) : withCustomBlocks
);
let html = '';

View File

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

View File

@ -47,6 +47,10 @@ export default {
thinking: '思考过程',
thinkingRunning: '正在思考...',
// —— 行内引用citation chip / popover ——
citationSources: '{n} 个来源',
citationOpenSource: '打开来源',
// —— 文件追加append / append_payload ——
targetFile: '目标文件',
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 {
display: block;

View File

@ -33,7 +33,9 @@ def _format_extract_webpage(result_data: Dict[str, Any]) -> str:
content = result_data.get("content") or ""
length = len(content)
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:
return f"{header} 内容为空。"
# 为模型保留完整正文,避免 800 字预览导致上下文缺失