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:
parent
a3fa5e59c6
commit
e6d8f194d2
@ -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({
|
||||
|
||||
@ -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
283
modules/citations.py
Normal 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: 前缀 + ID(src_<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-L148(GitHub 风格)/ #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_xxx:registry 查得到才保留,否则剥离 marker;
|
||||
- file: 引用:路径在工作区内且文件存在才保留;
|
||||
- 一个 marker 里部分有效时,只保留有效 token 重建 marker;全部无效则整体移除。
|
||||
"""
|
||||
if not text or ("【cite:" not in text and "【file:" not in text):
|
||||
return text, []
|
||||
|
||||
used: List[Dict[str, Any]] = []
|
||||
seen_ids = set()
|
||||
|
||||
def _keep(ann: Optional[Dict[str, Any]]) -> Optional[str]:
|
||||
if not ann:
|
||||
return None
|
||||
ann_id = ann.get("id")
|
||||
if ann_id and ann_id not in seen_ids:
|
||||
seen_ids.add(ann_id)
|
||||
used.append(ann)
|
||||
return ann_id
|
||||
|
||||
def _replace(m: re.Match) -> str:
|
||||
kind = m.group(1)
|
||||
tokens = parse_marker_content(m.group(2))
|
||||
kept: List[str] = []
|
||||
for token in tokens:
|
||||
if kind == "cite":
|
||||
# 网页来源:src_xxx 且 registry 查得到才保留
|
||||
if not _SRC_ID_RE.match(token):
|
||||
continue
|
||||
ann = registry.resolve(token) if registry else None
|
||||
if _keep(ann):
|
||||
kept.append(token)
|
||||
else:
|
||||
# 文件来源:路径在工作区内且存在才保留
|
||||
ref = _parse_file_ref(token)
|
||||
if not ref:
|
||||
continue
|
||||
ann = _build_file_annotation(ref, token, workspace_root)
|
||||
if _keep(ann):
|
||||
kept.append(token)
|
||||
if not kept:
|
||||
return ""
|
||||
return "【" + kind + ":" + ",".join(kept) + "】"
|
||||
|
||||
clean = CITATION_MARK_RE.sub(_replace, text)
|
||||
return clean, used
|
||||
@ -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_id(tools_execution 注册 citation 后回填),
|
||||
标题行会带 [src_xxx] 前缀,供模型在行内引用中使用。
|
||||
"""
|
||||
summary_lines = [
|
||||
f"🔍 搜索查询: {query}",
|
||||
f"📅 搜索时间: {timestamp}"
|
||||
]
|
||||
|
||||
filter_notes = self._summarize_filters(filters or {})
|
||||
if filter_notes:
|
||||
summary_lines.append(filter_notes)
|
||||
summary_lines.append("")
|
||||
|
||||
# 添加搜索结果
|
||||
if results:
|
||||
summary_lines.append("📊 搜索结果:")
|
||||
|
||||
for result in results:
|
||||
cid = result.get("citation_id")
|
||||
title_line = f"\n{result['index']}. [{cid}] {result['title']}" if cid else f"\n{result['index']}. {result['title']}"
|
||||
summary_lines.extend([
|
||||
title_line,
|
||||
f" 🔗 {result['url']}",
|
||||
f" 📄 {result['content'][:200]}..." if len(result['content']) > 200 else f" 📄 {result['content']}",
|
||||
])
|
||||
|
||||
if result.get("published_date"):
|
||||
summary_lines.append(f" 📅 发布时间: {result['published_date']}")
|
||||
else:
|
||||
summary_lines.append("未找到相关结果")
|
||||
|
||||
return "\n".join(summary_lines)
|
||||
|
||||
async def search_with_summary(
|
||||
self,
|
||||
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", []),
|
||||
|
||||
@ -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` 控制覆盖/追加)
|
||||
|
||||
@ -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` 控制覆盖/追加)
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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()),
|
||||
})
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -446,6 +446,18 @@ export const lifecycleMethods = {
|
||||
debugLog('[TaskPolling] 任务完成');
|
||||
}
|
||||
|
||||
// 行内引用:任务完成事件带回权威来源(轮询通道;socket 通道在 useLegacySocket 同款处理),
|
||||
// 挂到最后一条 assistant 消息 metadata,MarkdownRenderer watcher 据此做 chip 裁决与富化
|
||||
if (Array.isArray(data?.citations) && Array.isArray(this.messages) && this.messages.length) {
|
||||
const lastMsg = this.messages[this.messages.length - 1];
|
||||
if (lastMsg && lastMsg.role === 'assistant') {
|
||||
lastMsg.metadata = {
|
||||
...(lastMsg.metadata || {}),
|
||||
citations: data.citations
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 同步处理状态更新
|
||||
this.streamingMessage = false;
|
||||
this.stopRequested = false;
|
||||
|
||||
@ -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)。
|
||||
// 工具完成先于模型输出 marker,chip 在输出瞬间即可解析渲染,不等任务完成。
|
||||
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';
|
||||
});
|
||||
|
||||
564
static/src/components/chat/CitationPopover.vue
Normal file
564
static/src/components/chat/CitationPopover.vue
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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="chip-letter">${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>
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
275
static/src/components/chat/citationChips.ts
Normal file
275
static/src/components/chat/citationChips.ts
Normal file
@ -0,0 +1,275 @@
|
||||
/**
|
||||
* 行内引用(Inline Citations):chip 增强与 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 元素上挂的当前 annotations(final 到达后可被富化替换,触发器事件时读取) */
|
||||
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) {
|
||||
// 事件时从元素上读最新 annotations(final 富化替换后无需重绑监听器)
|
||||
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="chip-letter">${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=true,message.metadata.citations 到达):仅以权威表为准,
|
||||
* 查不到的 chip 移除(后端已剥离无效 marker);已渲染 chip 用富化数据
|
||||
* 替换引用(popover 可读 snippet/size),DOM 不重建。
|
||||
*/
|
||||
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);
|
||||
|
||||
// 已渲染的 chip:final 到达时换权威富化数据(供 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);
|
||||
|
||||
// 非 final:file 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
@ -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();
|
||||
|
||||
@ -313,6 +313,45 @@ function transformShowFileBlocks(raw: string) {
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* 预处理行内引用 marker(【cite: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, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>'))
|
||||
.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 = '';
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -47,6 +47,10 @@ export default {
|
||||
thinking: '思考过程',
|
||||
thinkingRunning: '正在思考...',
|
||||
|
||||
// —— 行内引用(citation chip / popover) ——
|
||||
citationSources: '{n} 个来源',
|
||||
citationOpenSource: '打开来源',
|
||||
|
||||
// —— 文件追加(append / append_payload) ——
|
||||
targetFile: '目标文件',
|
||||
appendDone: '文件追加完成',
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 字预览导致上下文缺失
|
||||
|
||||
Loading…
Reference in New Issue
Block a user