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",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
"name": "web_search",
|
"name": "web_search",
|
||||||
"description": "当现有资料不足时搜索外部信息。调用前说明目的,精准撰写 query,并合理设置时间/主题参数;避免重复或无意义的搜索。",
|
"description": "当现有资料不足时搜索外部信息。调用前说明目的,精准撰写 query,并合理设置时间/主题参数;避免重复或无意义的搜索。结果中每条来源带 [src_xxx] 引用 ID,回答中标注事实来源时使用。",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": self._inject_intent({
|
"properties": self._inject_intent({
|
||||||
@ -145,7 +145,7 @@ class ToolsDefinitionSearchWebToolsMixin:
|
|||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
"name": "extract_webpage",
|
"name": "extract_webpage",
|
||||||
"description": "在 web_search 结果不够详细时提取网页正文。调用前说明用途,注意提取内容会消耗大量 token,超过80000字符将被拒绝。",
|
"description": "在 web_search 结果不够详细时提取网页正文。调用前说明用途,注意提取内容会消耗大量 token,超过80000字符将被拒绝。结果会带来源引用 ID。",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": self._inject_intent({
|
"properties": self._inject_intent({
|
||||||
|
|||||||
@ -1697,13 +1697,35 @@ class MainTerminalToolsExecutionMixin:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if search_response["success"]:
|
if search_response["success"]:
|
||||||
|
# 注册网页来源 citation,并让模型可见的 summary 带上 [src_xxx] 前缀
|
||||||
|
from modules.citations import get_registry
|
||||||
|
registry = get_registry(self)
|
||||||
|
citations = []
|
||||||
|
results_list = search_response.get("results", [])
|
||||||
|
for item in results_list:
|
||||||
|
ann = registry.register_url(
|
||||||
|
title=item.get("title") or item.get("url") or "",
|
||||||
|
url=item.get("url") or "",
|
||||||
|
snippet=item.get("content", ""),
|
||||||
|
published_date=item.get("published_date") or None,
|
||||||
|
source_tool="web_search",
|
||||||
|
)
|
||||||
|
if ann:
|
||||||
|
item["citation_id"] = ann["id"]
|
||||||
|
citations.append(ann)
|
||||||
result = {
|
result = {
|
||||||
"success": True,
|
"success": True,
|
||||||
"summary": search_response["summary"],
|
"summary": self.search_engine.build_summary_text(
|
||||||
|
search_response.get("query") or arguments["query"],
|
||||||
|
results_list,
|
||||||
|
search_response.get("filters", {}),
|
||||||
|
search_response.get("timestamp", ""),
|
||||||
|
),
|
||||||
"filters": search_response.get("filters", {}),
|
"filters": search_response.get("filters", {}),
|
||||||
"query": search_response.get("query"),
|
"query": search_response.get("query"),
|
||||||
"results": search_response.get("results", []),
|
"results": results_list,
|
||||||
"total_results": search_response.get("total_results", 0)
|
"total_results": search_response.get("total_results", 0),
|
||||||
|
"citations": citations,
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
result = {
|
result = {
|
||||||
@ -1738,10 +1760,19 @@ class MainTerminalToolsExecutionMixin:
|
|||||||
"url": url
|
"url": url
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
|
from modules.citations import get_registry
|
||||||
|
ann = get_registry(self).register_url(
|
||||||
|
title=url,
|
||||||
|
url=url,
|
||||||
|
snippet=full_content[:500],
|
||||||
|
source_tool="extract_webpage",
|
||||||
|
)
|
||||||
result = {
|
result = {
|
||||||
"success": True,
|
"success": True,
|
||||||
"url": url,
|
"url": url,
|
||||||
"content": full_content
|
"content": full_content,
|
||||||
|
"citation_id": ann.get("id") if ann else None,
|
||||||
|
"citations": [ann] if ann else [],
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
result = {
|
result = {
|
||||||
|
|||||||
283
modules/citations.py
Normal file
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 datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import re
|
import re
|
||||||
|
from urllib.parse import urlparse
|
||||||
try:
|
try:
|
||||||
from config import TAVILY_API_KEY, SEARCH_MAX_RESULTS, OUTPUT_FORMATS, DATA_DIR
|
from config import TAVILY_API_KEY, SEARCH_MAX_RESULTS, OUTPUT_FORMATS, DATA_DIR
|
||||||
except ImportError:
|
except ImportError:
|
||||||
@ -150,10 +151,12 @@ class SearchEngine:
|
|||||||
|
|
||||||
# 处理每个搜索结果
|
# 处理每个搜索结果
|
||||||
for idx, result in enumerate(raw_data.get("results", []), 1):
|
for idx, result in enumerate(raw_data.get("results", []), 1):
|
||||||
|
url = result.get("url", "")
|
||||||
formatted_result = {
|
formatted_result = {
|
||||||
"index": idx,
|
"index": idx,
|
||||||
"title": result.get("title", "无标题"),
|
"title": result.get("title", "无标题"),
|
||||||
"url": result.get("url", ""),
|
"url": url,
|
||||||
|
"domain": urlparse(url).netloc.lower() if url else "",
|
||||||
"content": result.get("content", ""),
|
"content": result.get("content", ""),
|
||||||
"score": result.get("score", 0),
|
"score": result.get("score", 0),
|
||||||
"published_date": result.get("published_date", "")
|
"published_date": result.get("published_date", "")
|
||||||
@ -161,6 +164,48 @@ class SearchEngine:
|
|||||||
formatted["results"].append(formatted_result)
|
formatted["results"].append(formatted_result)
|
||||||
|
|
||||||
return formatted
|
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(
|
async def search_with_summary(
|
||||||
self,
|
self,
|
||||||
@ -203,36 +248,15 @@ class SearchEngine:
|
|||||||
"summary": ""
|
"summary": ""
|
||||||
}
|
}
|
||||||
|
|
||||||
# 构建摘要
|
|
||||||
summary_lines = [
|
|
||||||
f"🔍 搜索查询: {query}",
|
|
||||||
f"📅 搜索时间: {results['timestamp']}"
|
|
||||||
]
|
|
||||||
|
|
||||||
filter_notes = self._summarize_filters(results.get("filters", {}))
|
|
||||||
if filter_notes:
|
|
||||||
summary_lines.append(filter_notes)
|
|
||||||
summary_lines.append("")
|
|
||||||
|
|
||||||
# 添加搜索结果
|
|
||||||
if results["results"]:
|
|
||||||
summary_lines.append("📊 搜索结果:")
|
|
||||||
|
|
||||||
for result in results["results"]:
|
|
||||||
summary_lines.extend([
|
|
||||||
f"\n{result['index']}. {result['title']}",
|
|
||||||
f" 🔗 {result['url']}",
|
|
||||||
f" 📄 {result['content'][:200]}..." if len(result['content']) > 200 else f" 📄 {result['content']}",
|
|
||||||
])
|
|
||||||
|
|
||||||
if result.get("published_date"):
|
|
||||||
summary_lines.append(f" 📅 发布时间: {result['published_date']}")
|
|
||||||
else:
|
|
||||||
summary_lines.append("未找到相关结果")
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"summary": "\n".join(summary_lines),
|
"summary": self.build_summary_text(
|
||||||
|
query,
|
||||||
|
results["results"],
|
||||||
|
results.get("filters", {}),
|
||||||
|
results["timestamp"]
|
||||||
|
),
|
||||||
|
"timestamp": results["timestamp"],
|
||||||
"filters": results.get("filters", {}),
|
"filters": results.get("filters", {}),
|
||||||
"query": results.get("query", query),
|
"query": results.get("query", query),
|
||||||
"results": results.get("results", []),
|
"results": results.get("results", []),
|
||||||
|
|||||||
@ -111,6 +111,29 @@
|
|||||||
|
|
||||||
注意:路径必须是工作区内已存在的文件。输出前确保文件已通过 `write_file` 或其他方式创建。
|
注意:路径必须是工作区内已存在的文件。输出前确保文件已通过 `write_file` 或其他方式创建。
|
||||||
|
|
||||||
|
### 3.1.6 行内引用(Citation)
|
||||||
|
|
||||||
|
当你使用网页搜索结果、文件内容或其他外部资料回答时,必须在对应事实陈述之后添加引用标记,前端会渲染为可点击的来源胶囊。用户明确要求「引用」某个文件/来源时,同样必须输出标记——用加粗、行内代码或表格提到文件名都不算引用。
|
||||||
|
|
||||||
|
标记格式:
|
||||||
|
- 网页来源:【cite:src_xxx】(src_xxx 必须来自本次对话工具结果中明确提供的引用 ID,如 `[src_xxx]` 前缀或「来源 ID: src_xxx」)
|
||||||
|
- 文件来源:【file:工作区相对路径】,例如 【file:AGENTS.md】;指向具体行段:【file:AGENTS.md#L120-148】
|
||||||
|
- 图片等二进制文件同样用 file 标记引用(如 【file:.astrion/user_upload/xxx.png】),前端会渲染缩略预览
|
||||||
|
- 一个陈述引用多个来源:同类逗号并列【cite:src_xxx,src_yyy】/【file:report.pdf,notes.md】;网页与文件混排相邻写两个标记,例如 【cite:src_xxx】【file:report.pdf】
|
||||||
|
|
||||||
|
引用要求:
|
||||||
|
1. 只引用实际支持当前陈述的来源,不引用无关来源
|
||||||
|
2. 引用紧跟在相关句子或段落末尾,不要统一放在回答结尾或集中列「参考资料」(除非用户要求)
|
||||||
|
3. 没有使用外部资料时不要添加引用,也不要逐句标注
|
||||||
|
4. 只能使用工具结果明确提供的 src_xxx 和工作区内真实存在的文件相对路径,禁止编造;标记内禁止写 URL
|
||||||
|
5. 代码块和行内代码中的标记不会被渲染,不要在代码示例里放引用
|
||||||
|
|
||||||
|
示例:
|
||||||
|
- 该功能在 v2.3 版本引入。【cite:src_abc123def4】
|
||||||
|
- 两个项目的实现方案一致。【cite:src_abc123def4,src_xyz987abcd】
|
||||||
|
- 配置项说明见部署文档。【file:docs/deploy.md#L45-60】
|
||||||
|
- 这一结论同时有网页和本地文档支持。【cite:src_abc123def4】【file:docs/deploy.md】
|
||||||
|
|
||||||
### 3.2 文件操作
|
### 3.2 文件操作
|
||||||
|
|
||||||
- `write_file`:写入文件(`append` 控制覆盖/追加)
|
- `write_file`:写入文件(`append` 控制覆盖/追加)
|
||||||
|
|||||||
@ -111,6 +111,29 @@
|
|||||||
|
|
||||||
注意:路径必须是工作区内已存在的文件。输出前确保文件已通过 `write_file` 或其他方式创建。
|
注意:路径必须是工作区内已存在的文件。输出前确保文件已通过 `write_file` 或其他方式创建。
|
||||||
|
|
||||||
|
### 3.1.6 行内引用(Citation)
|
||||||
|
|
||||||
|
当你使用网页搜索结果、文件内容或其他外部资料回答时,必须在对应事实陈述之后添加引用标记,前端会渲染为可点击的来源胶囊。用户明确要求「引用」某个文件/来源时,同样必须输出标记——用加粗、行内代码或表格提到文件名都不算引用。
|
||||||
|
|
||||||
|
标记格式:
|
||||||
|
- 网页来源:【cite:src_xxx】(src_xxx 必须来自本次对话工具结果中明确提供的引用 ID,如 `[src_xxx]` 前缀或「来源 ID: src_xxx」)
|
||||||
|
- 文件来源:【file:工作区相对路径】,例如 【file:AGENTS.md】;指向具体行段:【file:AGENTS.md#L120-148】
|
||||||
|
- 图片等二进制文件同样用 file 标记引用(如 【file:.astrion/user_upload/xxx.png】),前端会渲染缩略预览
|
||||||
|
- 一个陈述引用多个来源:同类逗号并列【cite:src_xxx,src_yyy】/【file:report.pdf,notes.md】;网页与文件混排相邻写两个标记,例如 【cite:src_xxx】【file:report.pdf】
|
||||||
|
|
||||||
|
引用要求:
|
||||||
|
1. 只引用实际支持当前陈述的来源,不引用无关来源
|
||||||
|
2. 引用紧跟在相关句子或段落末尾,不要统一放在回答结尾或集中列「参考资料」(除非用户要求)
|
||||||
|
3. 没有使用外部资料时不要添加引用,也不要逐句标注
|
||||||
|
4. 只能使用工具结果明确提供的 src_xxx 和工作区内真实存在的文件相对路径,禁止编造;标记内禁止写 URL
|
||||||
|
5. 代码块和行内代码中的标记不会被渲染,不要在代码示例里放引用
|
||||||
|
|
||||||
|
示例:
|
||||||
|
- 该功能在 v2.3 版本引入。【cite:src_abc123def4】
|
||||||
|
- 两个项目的实现方案一致。【cite:src_abc123def4,src_xyz987abcd】
|
||||||
|
- 配置项说明见部署文档。【file:docs/deploy.md#L45-60】
|
||||||
|
- 这一结论同时有网页和本地文档支持。【cite:src_abc123def4】【file:docs/deploy.md】
|
||||||
|
|
||||||
### 3.2 文件操作
|
### 3.2 文件操作
|
||||||
|
|
||||||
- `write_file`:写入文件(`append` 控制覆盖/追加)
|
- `write_file`:写入文件(`append` 控制覆盖/追加)
|
||||||
|
|||||||
@ -283,6 +283,29 @@ id: ask_fse_001
|
|||||||
|
|
||||||
注意:路径必须是工作区内已存在的文件。输出前确保文件已通过 `write_file` 或其他方式创建。
|
注意:路径必须是工作区内已存在的文件。输出前确保文件已通过 `write_file` 或其他方式创建。
|
||||||
|
|
||||||
|
### 行内引用(Citation)
|
||||||
|
|
||||||
|
你是最终向用户汇报的人。当你使用网页搜索结果、文件内容或其他外部资料回答时,必须在对应事实陈述之后添加引用标记,前端会渲染为可点击的来源胶囊。用户明确要求「引用」某个文件/来源时,同样必须输出标记——用加粗、行内代码或表格提到文件名都不算引用。
|
||||||
|
|
||||||
|
标记格式:
|
||||||
|
- 网页来源:【cite:src_xxx】(src_xxx 必须来自本次对话工具结果中明确提供的引用 ID,如 `[src_xxx]` 前缀或「来源 ID: src_xxx」;子智能体汇报中附带的来源 ID 同样可用)
|
||||||
|
- 文件来源:【file:工作区相对路径】,例如 【file:AGENTS.md】;指向具体行段:【file:AGENTS.md#L120-148】
|
||||||
|
- 图片等二进制文件同样用 file 标记引用(如 【file:.astrion/user_upload/xxx.png】),前端会渲染缩略预览
|
||||||
|
- 一个陈述引用多个来源:同类逗号并列【cite:src_xxx,src_yyy】/【file:report.pdf,notes.md】;网页与文件混排相邻写两个标记,例如 【cite:src_xxx】【file:report.pdf】
|
||||||
|
|
||||||
|
引用要求:
|
||||||
|
1. 只引用实际支持当前陈述的来源,不引用无关来源
|
||||||
|
2. 引用紧跟在相关句子或段落末尾,不要统一放在回答结尾或集中列「参考资料」(除非用户要求)
|
||||||
|
3. 没有使用外部资料时不要添加引用,也不要逐句标注
|
||||||
|
4. 只能使用工具结果或子智能体汇报中明确提供的 src_xxx 和工作区内真实存在的文件相对路径,禁止编造;标记内禁止写 URL
|
||||||
|
5. 代码块和行内代码中的标记不会被渲染,不要在代码示例里放引用
|
||||||
|
|
||||||
|
示例:
|
||||||
|
- 该功能在 v2.3 版本引入。【cite:src_abc123def4】
|
||||||
|
- 两个项目的实现方案一致。【cite:src_abc123def4,src_xyz987abcd】
|
||||||
|
- 配置项说明见部署文档。【file:docs/deploy.md#L45-60】
|
||||||
|
- 这一结论同时有网页和本地文档支持。【cite:src_abc123def4】【file:docs/deploy.md】
|
||||||
|
|
||||||
## 关于显示名
|
## 关于显示名
|
||||||
|
|
||||||
- 主智能体固定显示名:`Team Leader`
|
- 主智能体固定显示名:`Team Leader`
|
||||||
|
|||||||
@ -2034,6 +2034,8 @@ async def handle_task_with_sender(
|
|||||||
# 统计和限制变量
|
# 统计和限制变量
|
||||||
total_iterations = 0
|
total_iterations = 0
|
||||||
total_tool_calls = 0
|
total_tool_calls = 0
|
||||||
|
# 行内引用:任务级已用来源累积(task_complete 时带给前端实时渲染),按 id 去重
|
||||||
|
task_citations: Dict[str, dict] = {}
|
||||||
consecutive_same_tool = defaultdict(int)
|
consecutive_same_tool = defaultdict(int)
|
||||||
last_tool_name = ""
|
last_tool_name = ""
|
||||||
auto_fix_attempts = 0
|
auto_fix_attempts = 0
|
||||||
@ -2312,6 +2314,23 @@ async def handle_task_with_sender(
|
|||||||
|
|
||||||
assistant_content = "\n".join(assistant_content_parts) if assistant_content_parts else ""
|
assistant_content = "\n".join(assistant_content_parts) if assistant_content_parts else ""
|
||||||
|
|
||||||
|
# 行内引用:校验 marker、剥离无效引用,已用来源挂到消息 metadata 一并落库
|
||||||
|
round_citations = []
|
||||||
|
if assistant_content and ("【cite:" in assistant_content or "【file:" in assistant_content):
|
||||||
|
try:
|
||||||
|
from modules.citations import finalize_message_citations, get_registry
|
||||||
|
assistant_content, round_citations = finalize_message_citations(
|
||||||
|
assistant_content,
|
||||||
|
get_registry(web_terminal),
|
||||||
|
str(getattr(web_terminal, "project_path", "") or ""),
|
||||||
|
)
|
||||||
|
for ann in round_citations:
|
||||||
|
if ann.get("id"):
|
||||||
|
task_citations[ann["id"]] = ann
|
||||||
|
except Exception as _cite_exc:
|
||||||
|
debug_log(f"[Citations] 行内引用处理失败,保留原文: {_cite_exc}")
|
||||||
|
round_citations = []
|
||||||
|
|
||||||
# 添加到消息历史(用于API继续对话,不保存到文件)
|
# 添加到消息历史(用于API继续对话,不保存到文件)
|
||||||
assistant_message = {
|
assistant_message = {
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
@ -2328,7 +2347,8 @@ async def handle_task_with_sender(
|
|||||||
"assistant",
|
"assistant",
|
||||||
assistant_content,
|
assistant_content,
|
||||||
tool_calls=tool_calls if tool_calls else None,
|
tool_calls=tool_calls if tool_calls else None,
|
||||||
reasoning_content=current_thinking or ""
|
reasoning_content=current_thinking or "",
|
||||||
|
metadata={"citations": round_citations} if round_citations else None
|
||||||
)
|
)
|
||||||
|
|
||||||
# 为下一轮迭代重置流状态标志,但保留 full_response 供上面保存使用
|
# 为下一轮迭代重置流状态标志,但保留 full_response 供上面保存使用
|
||||||
@ -2757,4 +2777,6 @@ async def handle_task_with_sender(
|
|||||||
# 前端就应保持运行态并继续轮询。
|
# 前端就应保持运行态并继续轮询。
|
||||||
'has_running_multi_agent': has_running_multi_agent or has_pending_ma_messages,
|
'has_running_multi_agent': has_running_multi_agent or has_pending_ma_messages,
|
||||||
'pending_runtime_guidance_messages': pending_runtime_guidance_messages,
|
'pending_runtime_guidance_messages': pending_runtime_guidance_messages,
|
||||||
|
# 行内引用:本任务全部已用来源,前端挂到当前 assistant 消息渲染 chip
|
||||||
|
'citations': list(task_citations.values()),
|
||||||
})
|
})
|
||||||
|
|||||||
@ -404,6 +404,7 @@
|
|||||||
<FilePreviewPanel
|
<FilePreviewPanel
|
||||||
v-if="!isMobileViewport"
|
v-if="!isMobileViewport"
|
||||||
/>
|
/>
|
||||||
|
<CitationPopover :host-mode="versioningHostMode" />
|
||||||
<div
|
<div
|
||||||
v-if="!isMobileViewport && (terminalPanelOpen || gitChangesPanelOpen)"
|
v-if="!isMobileViewport && (terminalPanelOpen || gitChangesPanelOpen)"
|
||||||
class="resize-handle resize-handle--right-panels"
|
class="resize-handle resize-handle--right-panels"
|
||||||
@ -843,6 +844,7 @@ import VideoPicker from './components/overlay/VideoPicker.vue';
|
|||||||
import ImageLightbox from './components/overlay/ImageLightbox.vue';
|
import ImageLightbox from './components/overlay/ImageLightbox.vue';
|
||||||
import QuickDock from './components/chat/quickdock/QuickDock.vue';
|
import QuickDock from './components/chat/quickdock/QuickDock.vue';
|
||||||
import FilePreviewPanel from './components/chat/quickdock/FilePreviewPanel.vue';
|
import FilePreviewPanel from './components/chat/quickdock/FilePreviewPanel.vue';
|
||||||
|
import CitationPopover from './components/chat/CitationPopover.vue';
|
||||||
import { useTutorialStore } from './stores/tutorial';
|
import { useTutorialStore } from './stores/tutorial';
|
||||||
import { usePersonalizationStore } from './stores/personalization';
|
import { usePersonalizationStore } from './stores/personalization';
|
||||||
|
|
||||||
|
|||||||
@ -318,6 +318,26 @@ export const historyMethods = {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 行内引用:工具循环的多条 assistant 持久化消息会合并成同一条前端消息,
|
||||||
|
// citations 需随合并按 id 去重汇聚,否则 chip 无数据可渲染
|
||||||
|
const msgCitations = message.metadata?.citations;
|
||||||
|
if (Array.isArray(msgCitations) && msgCitations.length) {
|
||||||
|
const merged = Array.isArray(currentAssistantMessage.metadata?.citations)
|
||||||
|
? [...currentAssistantMessage.metadata.citations]
|
||||||
|
: [];
|
||||||
|
const seen = new Set(merged.map((c) => c?.id));
|
||||||
|
msgCitations.forEach((c) => {
|
||||||
|
if (c && c.id && !seen.has(c.id)) {
|
||||||
|
seen.add(c.id);
|
||||||
|
merged.push(c);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
currentAssistantMessage.metadata = {
|
||||||
|
...(currentAssistantMessage.metadata || {}),
|
||||||
|
citations: merged
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const content = message.content || '';
|
const content = message.content || '';
|
||||||
const reasoningText = (message.reasoning_content || '').trim();
|
const reasoningText = (message.reasoning_content || '').trim();
|
||||||
|
|
||||||
|
|||||||
@ -446,6 +446,18 @@ export const lifecycleMethods = {
|
|||||||
debugLog('[TaskPolling] 任务完成');
|
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.streamingMessage = false;
|
||||||
this.stopRequested = false;
|
this.stopRequested = false;
|
||||||
|
|||||||
@ -187,6 +187,9 @@
|
|||||||
<MinimalBlocks
|
<MinimalBlocks
|
||||||
v-if="hasRenderableAssistantActions(msg.actions || [])"
|
v-if="hasRenderableAssistantActions(msg.actions || [])"
|
||||||
:actions="msg.actions || []"
|
:actions="msg.actions || []"
|
||||||
|
:citations="citationsForMessage(msg)"
|
||||||
|
:citations-final="citationsFinalForMessage(msg)"
|
||||||
|
enable-citations
|
||||||
:conversation-running="streamingMessage"
|
:conversation-running="streamingMessage"
|
||||||
:is-latest-message="index === latestMessageIndex"
|
:is-latest-message="index === latestMessageIndex"
|
||||||
:icon-style="iconStyleSafe"
|
:icon-style="iconStyleSafe"
|
||||||
@ -308,6 +311,9 @@
|
|||||||
<MarkdownRenderer
|
<MarkdownRenderer
|
||||||
:content="group.action.content || ''"
|
:content="group.action.content || ''"
|
||||||
:is-streaming="group.action.streaming"
|
:is-streaming="group.action.streaming"
|
||||||
|
:citations="citationsForMessage(msg)"
|
||||||
|
:citations-final="citationsFinalForMessage(msg)"
|
||||||
|
enable-citations
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -533,6 +539,9 @@
|
|||||||
<MarkdownRenderer
|
<MarkdownRenderer
|
||||||
:content="action.content || ''"
|
:content="action.content || ''"
|
||||||
:is-streaming="action.streaming"
|
:is-streaming="action.streaming"
|
||||||
|
:citations="citationsForMessage(msg)"
|
||||||
|
:citations-final="citationsFinalForMessage(msg)"
|
||||||
|
enable-citations
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -739,6 +748,7 @@ import ToolAction from '@/components/chat/actions/ToolAction.vue';
|
|||||||
import StackedBlocks from './StackedBlocks.vue';
|
import StackedBlocks from './StackedBlocks.vue';
|
||||||
import MinimalBlocks from './MinimalBlocks.vue';
|
import MinimalBlocks from './MinimalBlocks.vue';
|
||||||
import MarkdownRenderer from './MarkdownRenderer.vue';
|
import MarkdownRenderer from './MarkdownRenderer.vue';
|
||||||
|
import { collectConversationCitations, type CitationAnnotation } from './citationChips';
|
||||||
import EditSummaryCard from './EditSummaryCard.vue';
|
import EditSummaryCard from './EditSummaryCard.vue';
|
||||||
import { usePersonalizationStore } from '@/stores/personalization';
|
import { usePersonalizationStore } from '@/stores/personalization';
|
||||||
import { useUiStore } from '@/stores/ui';
|
import { useUiStore } from '@/stores/ui';
|
||||||
@ -778,6 +788,22 @@ const personalizationReady = computed(() => {
|
|||||||
const renderPending = computed(() => {
|
const renderPending = computed(() => {
|
||||||
return !!props.historyLoading || !personalizationReady.value;
|
return !!props.historyLoading || !personalizationReady.value;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 行内引用:对话级来源收集(扫描所有消息的工具结果 result.citations)。
|
||||||
|
// 工具完成先于模型输出 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(() => {
|
const blockDisplayMode = computed(() => {
|
||||||
return personalization.experiments.blockDisplayMode || 'stacked';
|
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
|
type MarkdownSegment
|
||||||
} from '@/composables/useMarkdownRenderer';
|
} from '@/composables/useMarkdownRenderer';
|
||||||
import { chunkRenderedHtml, type HtmlChunk } from '@/utils/htmlChunks';
|
import { chunkRenderedHtml, type HtmlChunk } from '@/utils/htmlChunks';
|
||||||
|
import { enhanceCitationChips, type CitationAnnotation } from './citationChips';
|
||||||
|
|
||||||
defineOptions({ name: 'MarkdownRenderer' });
|
defineOptions({ name: 'MarkdownRenderer' });
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
content: string;
|
content: string;
|
||||||
isStreaming?: boolean;
|
isStreaming?: boolean;
|
||||||
|
citations?: CitationAnnotation[];
|
||||||
|
/** citations 是否为后端裁决后的权威版本(message.metadata.citations 已到达) */
|
||||||
|
citationsFinal?: boolean;
|
||||||
|
enableCitations?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const containerRef = ref<HTMLElement | null>(null);
|
const containerRef = ref<HTMLElement | null>(null);
|
||||||
@ -47,7 +52,8 @@ const segments = computed(() => parseMarkdownSegments(props.content || '', props
|
|||||||
|
|
||||||
function renderText(text: string) {
|
function renderText(text: string) {
|
||||||
// 透传流式标志:show_html 卡片在流式期间需要 partial 渲染(实时渲染/渲染中占位)
|
// 透传流式标志:show_html 卡片在流式期间需要 partial 渲染(实时渲染/渲染中占位)
|
||||||
return renderMarkdownText(text, props.isStreaming);
|
// enableCitations:仅 assistant 正文开启引用渲染,其他场景【cite:】按原文显示
|
||||||
|
return renderMarkdownText(text, props.isStreaming, props.enableCitations);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 分段级分块缓存:segments 每个 token 都是新对象,按 key 缓存避免对
|
// 分段级分块缓存:segments 每个 token 都是新对象,按 key 缓存避免对
|
||||||
@ -83,6 +89,22 @@ function renderMath() {
|
|||||||
onMounted(renderMath);
|
onMounted(renderMath);
|
||||||
onUpdated(renderMath);
|
onUpdated(renderMath);
|
||||||
watch(() => props.content, renderMath, { immediate: true });
|
watch(() => props.content, renderMath, { immediate: true });
|
||||||
|
|
||||||
|
// 行内引用:渲染后扫描 chip 占位 span,填充内容并接管交互。
|
||||||
|
// chip 在输出瞬间即解析(工具结果查表 / file token 自解析);
|
||||||
|
// citationsFinal 到达时做权威裁决(移除无效 chip)与富化。
|
||||||
|
function enhanceCitations() {
|
||||||
|
if (!props.enableCitations) return;
|
||||||
|
nextTick(() => {
|
||||||
|
if (containerRef.value) {
|
||||||
|
enhanceCitationChips(containerRef.value, props.citations, { final: props.citationsFinal });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(enhanceCitations);
|
||||||
|
onUpdated(enhanceCitations);
|
||||||
|
watch(() => [props.citations, props.citationsFinal], enhanceCitations);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@ -121,7 +121,7 @@
|
|||||||
:class="{ 'streaming-text': group.streaming }"
|
:class="{ 'streaming-text': group.streaming }"
|
||||||
>
|
>
|
||||||
<div class="text-content" :class="{ 'streaming-text': group.streaming }">
|
<div class="text-content" :class="{ 'streaming-text': group.streaming }">
|
||||||
<MarkdownRenderer :content="group.content || ''" :is-streaming="group.streaming" />
|
<MarkdownRenderer :content="group.content || ''" :is-streaming="group.streaming" :citations="citations" :citations-final="citationsFinal" :enable-citations="enableCitations" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="group.type === 'system'" class="summary-group sub-agent-system-group">
|
<div v-else-if="group.type === 'system'" class="summary-group sub-agent-system-group">
|
||||||
@ -159,6 +159,7 @@ const personalizationStore = usePersonalizationStore();
|
|||||||
const heightLimited = computed(() => personalizationStore.form.minimal_expand_height_limited);
|
const heightLimited = computed(() => personalizationStore.form.minimal_expand_height_limited);
|
||||||
import { getRandomLoader } from './loaders/index';
|
import { getRandomLoader } from './loaders/index';
|
||||||
import MarkdownRenderer from './MarkdownRenderer.vue';
|
import MarkdownRenderer from './MarkdownRenderer.vue';
|
||||||
|
import type { CitationAnnotation } from './citationChips';
|
||||||
|
|
||||||
interface Action {
|
interface Action {
|
||||||
id?: string;
|
id?: string;
|
||||||
@ -196,6 +197,9 @@ interface BlockGroup {
|
|||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
actions: Action[];
|
actions: Action[];
|
||||||
|
citations?: CitationAnnotation[];
|
||||||
|
citationsFinal?: boolean;
|
||||||
|
enableCitations?: boolean;
|
||||||
conversationRunning?: boolean;
|
conversationRunning?: boolean;
|
||||||
isLatestMessage?: boolean;
|
isLatestMessage?: boolean;
|
||||||
iconStyle?: (name: string) => any;
|
iconStyle?: (name: string) => any;
|
||||||
|
|||||||
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();
|
resetPendingToolEvents();
|
||||||
|
|
||||||
|
// 行内引用:任务完成时后端带回已用来源,挂到最后一条 assistant 消息的 metadata,
|
||||||
|
// MarkdownRenderer 的 citations watcher 会触发 chip 增强(流式期间的占位胶囊在此转正)
|
||||||
|
if (Array.isArray(data?.citations) && Array.isArray(ctx.messages) && ctx.messages.length) {
|
||||||
|
const lastMsg = ctx.messages[ctx.messages.length - 1];
|
||||||
|
if (lastMsg && lastMsg.role === 'assistant') {
|
||||||
|
lastMsg.metadata = {
|
||||||
|
...(lastMsg.metadata || {}),
|
||||||
|
citations: data.citations
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 任务完成后立即更新Token统计(关键修复)
|
// 任务完成后立即更新Token统计(关键修复)
|
||||||
if (ctx.currentConversationId) {
|
if (ctx.currentConversationId) {
|
||||||
ctx.updateCurrentContextTokens();
|
ctx.updateCurrentContextTokens();
|
||||||
|
|||||||
@ -313,6 +313,45 @@ function transformShowFileBlocks(raw: string) {
|
|||||||
return output;
|
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 拆段。
|
* 预处理 LaTeX 数学公式,避免被 markdown parser 拆段。
|
||||||
* $$...$$ 转换为块级占位符,$...$ 转换为行内占位符,
|
* $$...$$ 转换为块级占位符,$...$ 转换为行内占位符,
|
||||||
@ -532,7 +571,7 @@ const sanitizedSchema: Record<string, any> = {
|
|||||||
attributes: {
|
attributes: {
|
||||||
...(defaultSchema.attributes || {}),
|
...(defaultSchema.attributes || {}),
|
||||||
div: [...((defaultSchema.attributes || {}).div || []), 'className', 'data-md-table-scroll'],
|
div: [...((defaultSchema.attributes || {}).div || []), 'className', 'data-md-table-scroll'],
|
||||||
span: [...((defaultSchema.attributes || {}).span || []), 'className', 'dataLatex', 'dataDisplay', 'dataMathRendered', 'data-latex', 'data-display', 'data-math-rendered'],
|
span: [...((defaultSchema.attributes || {}).span || []), 'className', 'dataLatex', 'dataDisplay', 'dataMathRendered', 'data-latex', 'data-display', 'data-math-rendered', 'data-citation-ids', 'dataCitationIds'],
|
||||||
a: [
|
a: [
|
||||||
'ariaDescribedBy', 'ariaLabel', 'ariaLabelledBy',
|
'ariaDescribedBy', 'ariaLabel', 'ariaLabelledBy',
|
||||||
'dataFootnoteBackref', 'dataFootnoteRef',
|
'dataFootnoteBackref', 'dataFootnoteRef',
|
||||||
@ -719,15 +758,17 @@ export function parseMarkdownSegments(text: string, isStreaming = false): Markdo
|
|||||||
return segments;
|
return segments;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderMarkdownText(text: string, isStreaming = false): string {
|
export function renderMarkdownText(text: string, isStreaming = false, enableCitations = false): string {
|
||||||
if (!text) return '';
|
if (!text) return '';
|
||||||
|
|
||||||
// isStreaming 必须透传:流式期间未闭合的 show_html 需要编码成 data-partial 占位
|
// isStreaming 必须透传:流式期间未闭合的 show_html 需要编码成 data-partial 占位
|
||||||
// (js=off 实时渲染 / js=on 显示"渲染中"),否则原始标签文本会直接散落到消息里
|
// (js=off 实时渲染 / js=on 显示"渲染中"),否则原始标签文本会直接散落到消息里
|
||||||
|
// enableCitations:仅 assistant 正文开启;用户消息/预览等静态文本里的【cite:】原样显示
|
||||||
|
const withCustomBlocks = transformShowFileBlocks(
|
||||||
|
transformShowImageBlocks(transformShowHtmlBlocks(text, isStreaming))
|
||||||
|
);
|
||||||
const safeText = transformMathBlocks(
|
const safeText = transformMathBlocks(
|
||||||
transformShowFileBlocks(
|
enableCitations ? transformCitationMarkers(withCustomBlocks, isStreaming) : withCustomBlocks
|
||||||
transformShowImageBlocks(transformShowHtmlBlocks(text, isStreaming))
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let html = '';
|
let html = '';
|
||||||
|
|||||||
@ -37,6 +37,10 @@ export default {
|
|||||||
thinking: 'Thinking',
|
thinking: 'Thinking',
|
||||||
thinkingRunning: 'Thinking...',
|
thinkingRunning: 'Thinking...',
|
||||||
|
|
||||||
|
// —— Inline citations (citation chip / popover) ——
|
||||||
|
citationSources: '{n} sources',
|
||||||
|
citationOpenSource: 'Open source',
|
||||||
|
|
||||||
// —— File append (append / append_payload) ——
|
// —— File append (append / append_payload) ——
|
||||||
targetFile: 'target file',
|
targetFile: 'target file',
|
||||||
appendDone: 'File append complete',
|
appendDone: 'File append complete',
|
||||||
|
|||||||
@ -47,6 +47,10 @@ export default {
|
|||||||
thinking: '思考过程',
|
thinking: '思考过程',
|
||||||
thinkingRunning: '正在思考...',
|
thinkingRunning: '正在思考...',
|
||||||
|
|
||||||
|
// —— 行内引用(citation chip / popover) ——
|
||||||
|
citationSources: '{n} 个来源',
|
||||||
|
citationOpenSource: '打开来源',
|
||||||
|
|
||||||
// —— 文件追加(append / append_payload) ——
|
// —— 文件追加(append / append_payload) ——
|
||||||
targetFile: '目标文件',
|
targetFile: '目标文件',
|
||||||
appendDone: '文件追加完成',
|
appendDone: '文件追加完成',
|
||||||
|
|||||||
@ -2463,6 +2463,106 @@ a.md-download-link {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 行内引用胶囊(citation chip) =====
|
||||||
|
// 交互约定:hover / 展开态只变色,不动边框;弹层交互见 CitationPopover.vue
|
||||||
|
.md-citation-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
height: 20px;
|
||||||
|
padding: 0 7px;
|
||||||
|
margin: 0 2px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
vertical-align: middle; // 几何中心对齐「基线 + x字高/2」——与文字完全上下居中,且 20px 盒仍在 25.5px 行框内不撑行
|
||||||
|
background: var(--surface-muted);
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&.is-open {
|
||||||
|
background: var(--hover-bg);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-pending {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-icons {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-favicon {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 3px;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-favicon + .chip-favicon {
|
||||||
|
margin-left: -5px;
|
||||||
|
border: 1px solid var(--surface-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-file-icon {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
flex: none;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-letter {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 3px;
|
||||||
|
flex: none;
|
||||||
|
font-size: 8px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--on-accent);
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-label {
|
||||||
|
max-width: 140px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
font-weight: 500;
|
||||||
|
// 行高 1 会把 j/g/y 等字母的下沉笔画裁掉;放大行高让 descender 落入盒内
|
||||||
|
// (chip 总高 20px 由自身 height 锁定,行高放大不撑高)
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-count {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover .chip-count,
|
||||||
|
&.is-open .chip-count {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-pending-dot {
|
||||||
|
width: 4px;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--text-tertiary);
|
||||||
|
margin: 0 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ===== 数学公式样式 =====
|
// ===== 数学公式样式 =====
|
||||||
.math-block {
|
.math-block {
|
||||||
display: block;
|
display: block;
|
||||||
|
|||||||
@ -33,7 +33,9 @@ def _format_extract_webpage(result_data: Dict[str, Any]) -> str:
|
|||||||
content = result_data.get("content") or ""
|
content = result_data.get("content") or ""
|
||||||
length = len(content)
|
length = len(content)
|
||||||
truncated_flag = result_data.get("truncated") or False
|
truncated_flag = result_data.get("truncated") or False
|
||||||
header = f"提取完成:{url},长度 {length} 字符。"
|
citation_id = result_data.get("citation_id")
|
||||||
|
citation_note = f"(来源 ID: {citation_id})" if citation_id else ""
|
||||||
|
header = f"提取完成:{url}{citation_note},长度 {length} 字符。"
|
||||||
if not content:
|
if not content:
|
||||||
return f"{header} 内容为空。"
|
return f"{header} 内容为空。"
|
||||||
# 为模型保留完整正文,避免 800 字预览导致上下文缺失
|
# 为模型保留完整正文,避免 800 字预览导致上下文缺失
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user