Compare commits

..

3 Commits

Author SHA1 Message Date
92e4931408 fix(server): 修正宿主机免登录模式文件服务路由的工作区解析
/user_upload/ 与 /workspace/ 路由此前误用 user_manager 的
users/<user>/projects/<id> 结构,宿主机免登录模式下解析到空工作区,
导致已上传文件 404(show_image 引用上传目录图片加载失败)。
改为与 get_user_resources 同源的 host_workspaces.json 解析。

Co-authored-by: Astrion powered by Kimi-K3 <astrion-agent@users.noreply.github.com>
2026-09-02 01:08:31 +08:00
b3158913ef feat(frontend): 内容图片全站接入灯箱预览,关闭按钮统一为 CloseButton 组件
- 新增 document 级事件委托(bootstrap.ts setupImagePreviewDelegation),
  show_image 卡片 / Markdown 正文图 / view_image / ocr_image 工具结果
  统一支持点击灯箱预览;可预览图片加 zoom-in 光标
- show_file 图片卡片由打开新标签页改为灯箱预览,并解除 Android 端禁用
- CitationPopover 引用缩略图支持点击预览
- renderShowFileCard 独立 app 实例补挂 i18n 插件(修复 $t 不可用隐患)
- 新建 common/CloseButton.vue(boxed 圆角方 hover 底 / bare 裸字形双变体),
  替换全站 25 处形态各异的关闭按钮(圆形/圆角方/裸字符/macOS 红点等 8 种风格),
  修复 ImagePicker/VideoPicker/路径授权弹窗原本无 hover 反馈的问题
- showHtmlFullscreen 顶栏按钮 CSS 对齐 boxed 形态(动态 DOM 无法复用组件)
- 清理 10+ 份散落重复的关闭按钮旧样式与 .mobile-overlay-close 死代码

Co-authored-by: Astrion powered by Kimi-K3 <astrion-agent@users.noreply.github.com>
2026-09-02 01:07:57 +08:00
e6d8f194d2 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:三份系统提示加入引用格式与规则(提到文件名不等于引用;图片等二进制文件同样可引用)
2026-09-01 22:31:37 +08:00
59 changed files with 1916 additions and 561 deletions

View File

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

View File

@ -1697,13 +1697,35 @@ class MainTerminalToolsExecutionMixin:
)
if search_response["success"]:
# 注册网页来源 citation并让模型可见的 summary 带上 [src_xxx] 前缀
from modules.citations import get_registry
registry = get_registry(self)
citations = []
results_list = search_response.get("results", [])
for item in results_list:
ann = registry.register_url(
title=item.get("title") or item.get("url") or "",
url=item.get("url") or "",
snippet=item.get("content", ""),
published_date=item.get("published_date") or None,
source_tool="web_search",
)
if ann:
item["citation_id"] = ann["id"]
citations.append(ann)
result = {
"success": True,
"summary": search_response["summary"],
"summary": self.search_engine.build_summary_text(
search_response.get("query") or arguments["query"],
results_list,
search_response.get("filters", {}),
search_response.get("timestamp", ""),
),
"filters": search_response.get("filters", {}),
"query": search_response.get("query"),
"results": search_response.get("results", []),
"total_results": search_response.get("total_results", 0)
"results": results_list,
"total_results": search_response.get("total_results", 0),
"citations": citations,
}
else:
result = {
@ -1738,10 +1760,19 @@ class MainTerminalToolsExecutionMixin:
"url": url
}
else:
from modules.citations import get_registry
ann = get_registry(self).register_url(
title=url,
url=url,
snippet=full_content[:500],
source_tool="extract_webpage",
)
result = {
"success": True,
"url": url,
"content": full_content
"content": full_content,
"citation_id": ann.get("id") if ann else None,
"citations": [ann] if ann else [],
}
except Exception as e:
result = {

283
modules/citations.py Normal file
View File

@ -0,0 +1,283 @@
"""Inline Citations引用注册表、marker 解析与校验。
数据流
工具执行web_search / extract_webpage CitationRegistry.register_url()
工具结果文本带 [src_xxx]模型可见
模型输出 cite:src_xxx/file:相对路径 marker
assistant 消息落库前 finalize_message_citations() 校验 + 挂载 message.citations
前端渲染 citation chip
设计要点
- 网页来源用 cite: 前缀 + IDsrc_<sha1(规范化 url) 10 >确定性 URL 天然去重
- 文件来源用 file: 前缀 + 工作区相对路径可带 #L120-148 / #p7 locator不经过 registry
- registry 是会话运行期的临时查找表不落盘历史消息靠 message.citations 自包含恢复
"""
from __future__ import annotations
import hashlib
import os
import re
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import urlparse
# marker 语法【cite:src_xxx】/【file:AGENTS.md】/同前缀多来源逗号并列(中英文逗号均可)
CITATION_MARK_RE = re.compile(r"【(cite|file):([^】]+)】")
# locator 后缀:#L120 / #L120-148 / #L120-L148GitHub 风格)/ #p7
_LOCATOR_RE = re.compile(r"^(?P<path>.*?)#(?:L(?P<line_start>\d+)(?:-L?(?P<line_end>\d+))?|p(?P<page>\d+))$", re.IGNORECASE)
_SRC_ID_RE = re.compile(r"^src_[0-9a-f]{10}x*$")
SNIPPET_MAX_CHARS = 300
# 文件 snippet 读取上限(前 64KB 足够覆盖 locator 行段与开头摘要)
_FILE_SNIPPET_READ_BYTES = 65536
# 图片扩展名:引用弹层渲染 <img> 而不是文本摘要
_IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".ico", ".avif"}
def _read_file_snippet(target: Path, ref: Dict[str, Any]) -> Optional[str]:
"""读取文本文件内容片段作为引用预览:有 #L 定位时取对应行段,否则取开头。
二进制文件含空字节返回 None空白压缩为单行限长 SNIPPET_MAX_CHARS
"""
try:
with open(target, "rb") as f:
raw = f.read(_FILE_SNIPPET_READ_BYTES)
except Exception:
return None
if b"\x00" in raw:
return None
text = raw.decode("utf-8", errors="replace")
line_start = ref.get("line_start")
if line_start:
lines = text.splitlines()
start = max(1, int(line_start))
end = min(len(lines), int(ref.get("line_end") or start))
snippet = "\n".join(lines[start - 1 : end]) if start <= len(lines) else ""
else:
snippet = text
# 保留换行(弹层 pre-line 渲染),仅压缩行内空白、去空行
snippet = "\n".join(ln.strip() for ln in snippet.splitlines() if ln.strip())
return snippet[:SNIPPET_MAX_CHARS] or None
def normalize_url(url: str) -> str:
"""URL 规范化:小写 scheme/host、去末尾斜杠。用于同来源去重。"""
url = (url or "").strip()
try:
p = urlparse(url)
scheme = (p.scheme or "https").lower()
netloc = p.netloc.lower()
path = p.path.rstrip("/")
query = f"?{p.query}" if p.query else ""
return f"{scheme}://{netloc}{path}{query}"
except Exception:
return url
def domain_of(url: str) -> str:
try:
return urlparse(url).netloc.lower().lstrip("www.")
except Exception:
return ""
def make_url_citation_id(url: str) -> str:
digest = hashlib.sha1(normalize_url(url).encode("utf-8")).hexdigest()[:10]
return f"src_{digest}"
class CitationRegistry:
"""会话级网页来源注册表(运行期内存态,不落盘)。"""
def __init__(self) -> None:
self._by_id: Dict[str, Dict[str, Any]] = {}
self._id_by_url: Dict[str, str] = {}
def register_url(
self,
*,
title: str = "",
url: str = "",
snippet: str = "",
published_date: Optional[str] = None,
source_tool: Optional[str] = None,
) -> Dict[str, Any]:
"""注册(或按规范化 URL 去重返回已有)一个网页来源 annotation。"""
if not url:
return {}
norm = normalize_url(url)
existing_id = self._id_by_url.get(norm)
if existing_id is not None:
return self._by_id[existing_id]
cid = make_url_citation_id(url)
# 哈希碰撞兜底(同一 URL 已命中则上面已返回;不同 URL 撞 hash 时加后缀)
while cid in self._by_id and normalize_url(self._by_id[cid].get("url", "")) != norm:
cid += "x"
ann: Dict[str, Any] = {
"id": cid,
"type": "url_citation",
"title": title or url,
"url": url,
"domain": domain_of(url),
"snippet": (snippet or "")[:SNIPPET_MAX_CHARS],
}
if published_date:
ann["published_date"] = published_date
if source_tool:
ann["source_tool"] = source_tool
self._by_id[cid] = ann
self._id_by_url[norm] = cid
return ann
def resolve(self, citation_id: str) -> Optional[Dict[str, Any]]:
return self._by_id.get(citation_id)
def get_registry(terminal: Any) -> CitationRegistry:
"""取 terminal 实例上的会话级 registry懒创建"""
reg = getattr(terminal, "_citation_registry", None)
if reg is None:
reg = CitationRegistry()
terminal._citation_registry = reg
return reg
def parse_marker_content(content: str) -> List[str]:
"""拆分 marker 内容为引用 token 列表(支持中英文逗号分隔)。"""
parts = re.split(r"[,]", content or "")
return [p.strip() for p in parts if p.strip()]
def extract_citation_refs(text: str) -> List[str]:
"""提取正文中全部完整 marker 的引用 token保持出现顺序去重"""
refs: List[str] = []
for m in CITATION_MARK_RE.finditer(text or ""):
for token in parse_marker_content(m.group(2)):
if token not in refs:
refs.append(token)
return refs
def _parse_file_ref(token: str) -> Optional[Dict[str, Any]]:
"""解析文件引用 token裸相对路径可带 #L120-148 / #p7 locator。非法返回 None。"""
body = token.strip()
if not body:
return None
m = _LOCATOR_RE.match(body)
if m:
ref: Dict[str, Any] = {"path": m.group("path")}
if m.group("page"):
ref["page"] = int(m.group("page"))
if m.group("line_start"):
ref["line_start"] = int(m.group("line_start"))
if m.group("line_end"):
ref["line_end"] = int(m.group("line_end"))
return ref
return {"path": body}
def _build_file_annotation(ref: Dict[str, Any], token: str, workspace_root: str) -> Optional[Dict[str, Any]]:
"""校验文件引用:路径必须落在工作区内且文件存在;富化 file_name/size。
annotation id marker token 完全一致前端按 id 精确匹配
"""
# 注意不能用 lstrip("./"):它是字符集语义,会把 .astrion 这类隐藏目录的前导点吃掉
rel_path = (ref.get("path") or "").strip()
if rel_path.startswith("./"):
rel_path = rel_path[2:]
if not rel_path or rel_path.startswith("/"):
return None
try:
root = Path(workspace_root).resolve()
target = (root / rel_path).resolve()
# 边界检查:必须在工作区内
if root != target and root not in target.parents:
return None
if not target.is_file():
return None
stat = target.stat()
except Exception:
return None
ann: Dict[str, Any] = {
"id": token,
"type": "file_citation",
"file_path": rel_path,
"file_name": target.name,
"size": stat.st_size,
}
snippet: Optional[str] = None
# 图片文件的 snippet 无意义(且 svg 这类文本型图片会把标记源码当摘要),
# 前端弹层按扩展名直接渲染 <img>
if target.suffix.lower() not in _IMAGE_EXTS:
snippet = _read_file_snippet(target, ref)
if snippet:
ann["snippet"] = snippet
if ref.get("page") is not None:
ann["page"] = ref["page"]
if ref.get("line_start") is not None:
ann["line_start"] = ref["line_start"]
if ref.get("line_end") is not None:
ann["line_end"] = ref["line_end"]
return ann
def finalize_message_citations(
text: str,
registry: Optional[CitationRegistry],
workspace_root: str,
) -> Tuple[str, List[Dict[str, Any]]]:
"""assistant 消息落库前处理:剥离无效 marker返回 (clean_text, used_annotations)。
- src_xxxregistry 查得到才保留否则剥离 marker
- file: 引用路径在工作区内且文件存在才保留
- 一个 marker 里部分有效时只保留有效 token 重建 marker全部无效则整体移除
"""
if not text or ("【cite:" not in text and "【file:" not in text):
return text, []
used: List[Dict[str, Any]] = []
seen_ids = set()
def _keep(ann: Optional[Dict[str, Any]]) -> Optional[str]:
if not ann:
return None
ann_id = ann.get("id")
if ann_id and ann_id not in seen_ids:
seen_ids.add(ann_id)
used.append(ann)
return ann_id
def _replace(m: re.Match) -> str:
kind = m.group(1)
tokens = parse_marker_content(m.group(2))
kept: List[str] = []
for token in tokens:
if kind == "cite":
# 网页来源src_xxx 且 registry 查得到才保留
if not _SRC_ID_RE.match(token):
continue
ann = registry.resolve(token) if registry else None
if _keep(ann):
kept.append(token)
else:
# 文件来源:路径在工作区内且存在才保留
ref = _parse_file_ref(token)
if not ref:
continue
ann = _build_file_annotation(ref, token, workspace_root)
if _keep(ann):
kept.append(token)
if not kept:
return ""
return "" + kind + ":" + ",".join(kept) + ""
clean = CITATION_MARK_RE.sub(_replace, text)
return clean, used

View File

@ -6,6 +6,7 @@ from typing import Dict, Optional, Any, List
from datetime import datetime
from pathlib import Path
import re
from urllib.parse import urlparse
try:
from config import TAVILY_API_KEY, SEARCH_MAX_RESULTS, OUTPUT_FORMATS, DATA_DIR
except ImportError:
@ -150,10 +151,12 @@ class SearchEngine:
# 处理每个搜索结果
for idx, result in enumerate(raw_data.get("results", []), 1):
url = result.get("url", "")
formatted_result = {
"index": idx,
"title": result.get("title", "无标题"),
"url": result.get("url", ""),
"url": url,
"domain": urlparse(url).netloc.lower() if url else "",
"content": result.get("content", ""),
"score": result.get("score", 0),
"published_date": result.get("published_date", "")
@ -162,6 +165,48 @@ class SearchEngine:
return formatted
def build_summary_text(
self,
query: str,
results: List[Dict[str, Any]],
filters: Dict[str, Any],
timestamp: str
) -> str:
"""构建给模型看的搜索摘要文本。
若结果项带 citation_idtools_execution 注册 citation 后回填
标题行会带 [src_xxx] 前缀供模型在行内引用中使用
"""
summary_lines = [
f"🔍 搜索查询: {query}",
f"📅 搜索时间: {timestamp}"
]
filter_notes = self._summarize_filters(filters or {})
if filter_notes:
summary_lines.append(filter_notes)
summary_lines.append("")
# 添加搜索结果
if results:
summary_lines.append("📊 搜索结果:")
for result in results:
cid = result.get("citation_id")
title_line = f"\n{result['index']}. [{cid}] {result['title']}" if cid else f"\n{result['index']}. {result['title']}"
summary_lines.extend([
title_line,
f" 🔗 {result['url']}",
f" 📄 {result['content'][:200]}..." if len(result['content']) > 200 else f" 📄 {result['content']}",
])
if result.get("published_date"):
summary_lines.append(f" 📅 发布时间: {result['published_date']}")
else:
summary_lines.append("未找到相关结果")
return "\n".join(summary_lines)
async def search_with_summary(
self,
query: str,
@ -203,36 +248,15 @@ class SearchEngine:
"summary": ""
}
# 构建摘要
summary_lines = [
f"🔍 搜索查询: {query}",
f"📅 搜索时间: {results['timestamp']}"
]
filter_notes = self._summarize_filters(results.get("filters", {}))
if filter_notes:
summary_lines.append(filter_notes)
summary_lines.append("")
# 添加搜索结果
if results["results"]:
summary_lines.append("📊 搜索结果:")
for result in results["results"]:
summary_lines.extend([
f"\n{result['index']}. {result['title']}",
f" 🔗 {result['url']}",
f" 📄 {result['content'][:200]}..." if len(result['content']) > 200 else f" 📄 {result['content']}",
])
if result.get("published_date"):
summary_lines.append(f" 📅 发布时间: {result['published_date']}")
else:
summary_lines.append("未找到相关结果")
return {
"success": True,
"summary": "\n".join(summary_lines),
"summary": self.build_summary_text(
query,
results["results"],
results.get("filters", {}),
results["timestamp"]
),
"timestamp": results["timestamp"],
"filters": results.get("filters", {}),
"query": results.get("query", query),
"results": results.get("results", []),

View File

@ -111,6 +111,29 @@
注意:路径必须是工作区内已存在的文件。输出前确保文件已通过 `write_file` 或其他方式创建。
### 3.1.6 行内引用Citation
当你使用网页搜索结果、文件内容或其他外部资料回答时,必须在对应事实陈述之后添加引用标记,前端会渲染为可点击的来源胶囊。用户明确要求「引用」某个文件/来源时,同样必须输出标记——用加粗、行内代码或表格提到文件名都不算引用。
标记格式:
- 网页来源【cite:src_xxx】src_xxx 必须来自本次对话工具结果中明确提供的引用 ID如 `[src_xxx]` 前缀或「来源 ID: src_xxx」
- 文件来源【file:工作区相对路径】,例如 【file:AGENTS.md】指向具体行段【file:AGENTS.md#L120-148】
- 图片等二进制文件同样用 file 标记引用(如 【file:.astrion/user_upload/xxx.png】前端会渲染缩略预览
- 一个陈述引用多个来源同类逗号并列【cite:src_xxx,src_yyy】/【file:report.pdf,notes.md】网页与文件混排相邻写两个标记例如 【cite:src_xxx】【file:report.pdf】
引用要求:
1. 只引用实际支持当前陈述的来源,不引用无关来源
2. 引用紧跟在相关句子或段落末尾,不要统一放在回答结尾或集中列「参考资料」(除非用户要求)
3. 没有使用外部资料时不要添加引用,也不要逐句标注
4. 只能使用工具结果明确提供的 src_xxx 和工作区内真实存在的文件相对路径,禁止编造;标记内禁止写 URL
5. 代码块和行内代码中的标记不会被渲染,不要在代码示例里放引用
示例:
- 该功能在 v2.3 版本引入。【cite:src_abc123def4】
- 两个项目的实现方案一致。【cite:src_abc123def4,src_xyz987abcd】
- 配置项说明见部署文档。【file:docs/deploy.md#L45-60】
- 这一结论同时有网页和本地文档支持。【cite:src_abc123def4】【file:docs/deploy.md】
### 3.2 文件操作
- `write_file`:写入文件(`append` 控制覆盖/追加)

View File

@ -111,6 +111,29 @@
注意:路径必须是工作区内已存在的文件。输出前确保文件已通过 `write_file` 或其他方式创建。
### 3.1.6 行内引用Citation
当你使用网页搜索结果、文件内容或其他外部资料回答时,必须在对应事实陈述之后添加引用标记,前端会渲染为可点击的来源胶囊。用户明确要求「引用」某个文件/来源时,同样必须输出标记——用加粗、行内代码或表格提到文件名都不算引用。
标记格式:
- 网页来源【cite:src_xxx】src_xxx 必须来自本次对话工具结果中明确提供的引用 ID如 `[src_xxx]` 前缀或「来源 ID: src_xxx」
- 文件来源【file:工作区相对路径】,例如 【file:AGENTS.md】指向具体行段【file:AGENTS.md#L120-148】
- 图片等二进制文件同样用 file 标记引用(如 【file:.astrion/user_upload/xxx.png】前端会渲染缩略预览
- 一个陈述引用多个来源同类逗号并列【cite:src_xxx,src_yyy】/【file:report.pdf,notes.md】网页与文件混排相邻写两个标记例如 【cite:src_xxx】【file:report.pdf】
引用要求:
1. 只引用实际支持当前陈述的来源,不引用无关来源
2. 引用紧跟在相关句子或段落末尾,不要统一放在回答结尾或集中列「参考资料」(除非用户要求)
3. 没有使用外部资料时不要添加引用,也不要逐句标注
4. 只能使用工具结果明确提供的 src_xxx 和工作区内真实存在的文件相对路径,禁止编造;标记内禁止写 URL
5. 代码块和行内代码中的标记不会被渲染,不要在代码示例里放引用
示例:
- 该功能在 v2.3 版本引入。【cite:src_abc123def4】
- 两个项目的实现方案一致。【cite:src_abc123def4,src_xyz987abcd】
- 配置项说明见部署文档。【file:docs/deploy.md#L45-60】
- 这一结论同时有网页和本地文档支持。【cite:src_abc123def4】【file:docs/deploy.md】
### 3.2 文件操作
- `write_file`:写入文件(`append` 控制覆盖/追加)

View File

@ -283,6 +283,29 @@ id: ask_fse_001
注意:路径必须是工作区内已存在的文件。输出前确保文件已通过 `write_file` 或其他方式创建。
### 行内引用Citation
你是最终向用户汇报的人。当你使用网页搜索结果、文件内容或其他外部资料回答时,必须在对应事实陈述之后添加引用标记,前端会渲染为可点击的来源胶囊。用户明确要求「引用」某个文件/来源时,同样必须输出标记——用加粗、行内代码或表格提到文件名都不算引用。
标记格式:
- 网页来源【cite:src_xxx】src_xxx 必须来自本次对话工具结果中明确提供的引用 ID如 `[src_xxx]` 前缀或「来源 ID: src_xxx」子智能体汇报中附带的来源 ID 同样可用)
- 文件来源【file:工作区相对路径】,例如 【file:AGENTS.md】指向具体行段【file:AGENTS.md#L120-148】
- 图片等二进制文件同样用 file 标记引用(如 【file:.astrion/user_upload/xxx.png】前端会渲染缩略预览
- 一个陈述引用多个来源同类逗号并列【cite:src_xxx,src_yyy】/【file:report.pdf,notes.md】网页与文件混排相邻写两个标记例如 【cite:src_xxx】【file:report.pdf】
引用要求:
1. 只引用实际支持当前陈述的来源,不引用无关来源
2. 引用紧跟在相关句子或段落末尾,不要统一放在回答结尾或集中列「参考资料」(除非用户要求)
3. 没有使用外部资料时不要添加引用,也不要逐句标注
4. 只能使用工具结果或子智能体汇报中明确提供的 src_xxx 和工作区内真实存在的文件相对路径,禁止编造;标记内禁止写 URL
5. 代码块和行内代码中的标记不会被渲染,不要在代码示例里放引用
示例:
- 该功能在 v2.3 版本引入。【cite:src_abc123def4】
- 两个项目的实现方案一致。【cite:src_abc123def4,src_xyz987abcd】
- 配置项说明见部署文档。【file:docs/deploy.md#L45-60】
- 这一结论同时有网页和本地文档支持。【cite:src_abc123def4】【file:docs/deploy.md】
## 关于显示名
- 主智能体固定显示名:`Team Leader`

View File

@ -441,17 +441,48 @@ def terminal_page():
return current_app.send_static_file('terminal.html')
def _resolve_serving_workspace_roots(user):
"""返回 (uploads_dir, project_root),供 /user_upload/ 与 /workspace/ 文件服务路由使用。
宿主机免登录模式host_mode session必须与 get_user_resources 走同一套
host_workspaces.json 解析上传文件实际存放在宿主机工作区目录的
.astrion/user_upload 若误用 user_manager users/<user>/projects/<id>
结构会解析到一个空工作区导致已上传文件 404show_image 引用上传目录
图片时加载失败
"""
host_mode_session = bool(session.get("host_mode"))
sandbox_is_host = (TERMINAL_SANDBOX_MODE or "").lower() == "host"
if host_mode_session and sandbox_is_host:
selected_workspace_id = session.get("host_workspace_id") or session.get("workspace_id")
active_workspace_path = None
if not selected_workspace_id:
with state.HOST_ACTIVE_WORKSPACE_LOCK:
selected_workspace_id = state.HOST_ACTIVE_WORKSPACE_ID
active_workspace_path = state.HOST_ACTIVE_WORKSPACE_PATH
_, host_workspace = resolve_host_workspace(selected_workspace_id)
if not host_workspace:
return None, None
if active_workspace_path:
host_workspace = dict(host_workspace)
host_workspace["path"] = active_workspace_path
project_root = Path(host_workspace.get("path") or "").expanduser().resolve()
return (project_root / ".astrion" / "user_upload").resolve(), project_root
workspace = state.user_manager.ensure_user_workspace(
user.username,
session.get("workspace_id") or "default",
)
return workspace.uploads_dir.resolve(), workspace.project_path.resolve()
@auth_bp.route('/user_upload/<path:filename>')
@login_required
def serve_user_upload(filename: str):
user = get_current_user_record()
if not user:
return redirect('/login')
workspace = state.user_manager.ensure_user_workspace(
user.username,
session.get("workspace_id") or "default",
)
uploads_dir = workspace.uploads_dir.resolve()
uploads_dir, _project_root = _resolve_serving_workspace_roots(user)
if not uploads_dir:
abort(404)
target = (uploads_dir / filename).resolve()
try:
target.relative_to(uploads_dir)
@ -468,11 +499,9 @@ def serve_workspace_file(filename: str):
user = get_current_user_record()
if not user:
return redirect('/login')
workspace = state.user_manager.ensure_user_workspace(
user.username,
session.get("workspace_id") or "default",
)
project_root = workspace.project_path.resolve()
_uploads_dir, project_root = _resolve_serving_workspace_roots(user)
if not project_root:
abort(404)
target = (project_root / filename).resolve()
try:
target.relative_to(project_root)

View File

@ -2034,6 +2034,8 @@ async def handle_task_with_sender(
# 统计和限制变量
total_iterations = 0
total_tool_calls = 0
# 行内引用任务级已用来源累积task_complete 时带给前端实时渲染),按 id 去重
task_citations: Dict[str, dict] = {}
consecutive_same_tool = defaultdict(int)
last_tool_name = ""
auto_fix_attempts = 0
@ -2312,6 +2314,23 @@ async def handle_task_with_sender(
assistant_content = "\n".join(assistant_content_parts) if assistant_content_parts else ""
# 行内引用:校验 marker、剥离无效引用已用来源挂到消息 metadata 一并落库
round_citations = []
if assistant_content and ("【cite:" in assistant_content or "【file:" in assistant_content):
try:
from modules.citations import finalize_message_citations, get_registry
assistant_content, round_citations = finalize_message_citations(
assistant_content,
get_registry(web_terminal),
str(getattr(web_terminal, "project_path", "") or ""),
)
for ann in round_citations:
if ann.get("id"):
task_citations[ann["id"]] = ann
except Exception as _cite_exc:
debug_log(f"[Citations] 行内引用处理失败,保留原文: {_cite_exc}")
round_citations = []
# 添加到消息历史用于API继续对话不保存到文件
assistant_message = {
"role": "assistant",
@ -2328,7 +2347,8 @@ async def handle_task_with_sender(
"assistant",
assistant_content,
tool_calls=tool_calls if tool_calls else None,
reasoning_content=current_thinking or ""
reasoning_content=current_thinking or "",
metadata={"citations": round_citations} if round_citations else None
)
# 为下一轮迭代重置流状态标志,但保留 full_response 供上面保存使用
@ -2757,4 +2777,6 @@ async def handle_task_with_sender(
# 前端就应保持运行态并继续轮询。
'has_running_multi_agent': has_running_multi_agent or has_pending_ma_messages,
'pending_runtime_guidance_messages': pending_runtime_guidance_messages,
# 行内引用:本任务全部已用来源,前端挂到当前 assistant 消息渲染 chip
'citations': list(task_citations.values()),
})

View File

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

View File

@ -49,7 +49,7 @@
<transition name="fade">
<div v-if="banner.message" class="banner" :class="banner.type">
<span>{{ banner.message }}</span>
<button type="button" class="banner-close" @click="banner.message = ''">×</button>
<CloseButton :label="$t('common.close')" @click="banner.message = ''" />
</div>
</transition>
@ -300,6 +300,7 @@ import { computed, reactive, ref, onMounted, onBeforeUnmount, watch } from 'vue'
import { useSecondaryPass } from './useSecondaryPass';
import SecondaryGate from './SecondaryGate.vue';
import FancyCheck from '@/components/common/FancyCheck.vue';
import CloseButton from '@/components/common/CloseButton.vue';
import { t, currentLocale } from '@/locales';
type TargetType = 'global' | 'role' | 'user' | 'invite';
@ -1061,14 +1062,6 @@ onBeforeUnmount(() => {
border-color: rgba(189, 93, 58, 0.35);
}
.banner-close {
border: none;
background: transparent;
cursor: pointer;
font-size: 16px;
line-height: 1;
}
.panel {
background: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(118, 103, 84, 0.2);

View File

@ -2,8 +2,9 @@
import katex from 'katex';
import DOMPurify from 'dompurify';
import { createApp } from 'vue';
import { t } from '@/locales';
import { t, i18n } from '@/locales';
import ShowFileCard from '../components/chat/ShowFileCard.vue';
import { useUiStore } from '@/stores/ui';
import { buildShowHtmlIframeSrcdoc } from '../utils/showHtmlSandbox';
import {
openShowHtmlFullscreen,
@ -174,10 +175,15 @@ function normalizeShowImageSrc(src: string) {
trimmed = trimmed.replace(/^\/(?=[A-Za-z]:\/)/, '');
}
if (trimmed.startsWith('/user_upload/')) return trimmed;
// 兼容容器内部路径:/workspace/.../user_upload/xxx.png 或 /workspace/user_upload/xxx
const idx = trimmed.toLowerCase().indexOf('/user_upload/');
if (idx >= 0) {
return '/user_upload/' + trimmed.slice(idx + '/user_upload/'.length);
// 兼容容器内部绝对路径:/workspace/.../user_upload/xxx.png 或 /workspace/user_upload/xxx
// 注意:.astrion/user_upload/xxx 是工作区相对路径,不在此特判,
// 让它落到下方通用分支走 /api/file/content与 _validate_path 同源解析,
// 避免依赖 /user_upload/ 后端路由在宿主机免登录模式下的工作区解析)
if (trimmed.startsWith('/')) {
const idx = trimmed.toLowerCase().indexOf('/user_upload/');
if (idx >= 0) {
return '/user_upload/' + trimmed.slice(idx + '/user_upload/'.length);
}
}
// /workspace 前缀是容器内绝对路径写法,剥掉后按工作区相对路径处理
if (trimmed === '/workspace') return '';
@ -1256,6 +1262,36 @@ function buildNodePathKey(node: Element) {
return parts.reverse().join('/');
}
// ===== 内容图片点击预览document 级事件委托) =====
// 覆盖三类无 Vue 事件绑定的注入图片:
// 1. <show_image> 卡片figure.chat-inline-imagerenderShowImages 动态创建)
// 2. Markdown ![]() 正文图片(.text-content 内的 img
// 3. 工具结果图片(.tool-result-image如 view_image / ocr_image
// 不会误伤:消息气泡图片与 CitationPopover 缩略图已用 @click.stop 阻断冒泡;
// ShowFileCard 卡片自带 @click在此显式排除头像/图标为 SVG 或不在命中容器内。
let imagePreviewDelegationBound = false;
export function setupImagePreviewDelegation() {
if (imagePreviewDelegationBound) return;
imagePreviewDelegationBound = true;
document.addEventListener('click', (event) => {
const target = event.target as HTMLElement | null;
if (!target || typeof target.closest !== 'function') return;
const img = target.closest('img') as HTMLImageElement | null;
if (!img) return;
// ShowFileCard 内部已自行打开灯箱预览,避免双重触发
if (img.closest('.show-file-card')) return;
const hit =
img.closest('figure.chat-inline-image') ||
img.closest('.tool-result-image') ||
img.closest('.text-content');
if (!hit) return;
const url = img.currentSrc || img.src;
if (!url) return;
useUiStore().openImagePreview({ url, name: img.alt || '' });
});
}
export function setupShowImageObserver() {
if (showImageObserver || showContainerObserver) return;
setupLayoutDebugObservers();
@ -1471,6 +1507,9 @@ function renderShowFileCard(node: Element) {
node.replaceChildren(mountEl);
const app = createApp(ShowFileCard, { path });
// 独立 createApp 实例不继承主应用的插件,必须显式安装 i18n
// 否则组件模板里的 $t 不可用(报 "j.$t is not a function"
app.use(i18n);
app.mount(mountEl);
showFileAppMap.set(node, app);
}

View File

@ -2,7 +2,7 @@
import { useChatActionStore } from '../stores/chatActions';
import { useSandboxSetupStore } from '../stores/sandboxSetup';
import { normalizeScrollLock } from '../composables/useScrollControl';
import { setupShowImageObserver, teardownShowImageObserver } from './bootstrap';
import { setupShowImageObserver, teardownShowImageObserver, setupImagePreviewDelegation } from './bootstrap';
import { debugLog } from './methods/common';
export function created() {
@ -41,6 +41,7 @@ export async function mounted() {
normalizeScrollLock(this);
});
setupShowImageObserver();
setupImagePreviewDelegation();
// 立即加载初始数据(并行获取状态,优先同步运行模式)
const initialDataPromise = this.loadInitialData();

View File

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

View File

@ -446,6 +446,18 @@ export const lifecycleMethods = {
debugLog('[TaskPolling] 任务完成');
}
// 行内引用任务完成事件带回权威来源轮询通道socket 通道在 useLegacySocket 同款处理),
// 挂到最后一条 assistant 消息 metadataMarkdownRenderer watcher 据此做 chip 裁决与富化
if (Array.isArray(data?.citations) && Array.isArray(this.messages) && this.messages.length) {
const lastMsg = this.messages[this.messages.length - 1];
if (lastMsg && lastMsg.role === 'assistant') {
lastMsg.metadata = {
...(lastMsg.metadata || {}),
citations: data.citations
};
}
}
// 同步处理状态更新
this.streamingMessage = false;
this.stopRequested = false;

View File

@ -187,6 +187,9 @@
<MinimalBlocks
v-if="hasRenderableAssistantActions(msg.actions || [])"
:actions="msg.actions || []"
:citations="citationsForMessage(msg)"
:citations-final="citationsFinalForMessage(msg)"
enable-citations
:conversation-running="streamingMessage"
:is-latest-message="index === latestMessageIndex"
:icon-style="iconStyleSafe"
@ -308,6 +311,9 @@
<MarkdownRenderer
:content="group.action.content || ''"
:is-streaming="group.action.streaming"
:citations="citationsForMessage(msg)"
:citations-final="citationsFinalForMessage(msg)"
enable-citations
/>
</div>
</div>
@ -533,6 +539,9 @@
<MarkdownRenderer
:content="action.content || ''"
:is-streaming="action.streaming"
:citations="citationsForMessage(msg)"
:citations-final="citationsFinalForMessage(msg)"
enable-citations
/>
</div>
</div>
@ -739,6 +748,7 @@ import ToolAction from '@/components/chat/actions/ToolAction.vue';
import StackedBlocks from './StackedBlocks.vue';
import MinimalBlocks from './MinimalBlocks.vue';
import MarkdownRenderer from './MarkdownRenderer.vue';
import { collectConversationCitations, type CitationAnnotation } from './citationChips';
import EditSummaryCard from './EditSummaryCard.vue';
import { usePersonalizationStore } from '@/stores/personalization';
import { useUiStore } from '@/stores/ui';
@ -778,6 +788,22 @@ const personalizationReady = computed(() => {
const renderPending = computed(() => {
return !!props.historyLoading || !personalizationReady.value;
});
// result.citations
// markerchip
const collectedCitations = computed<CitationAnnotation[]>(() =>
collectConversationCitations(props.messages || [])
);
/** 该消息可用的引用来源权威metadata.citations后端裁决富化优先否则用收集表 */
function citationsForMessage(msg: any): CitationAnnotation[] | undefined {
const meta = msg?.metadata?.citations;
if (Array.isArray(meta)) return meta;
return collectedCitations.value.length ? collectedCitations.value : undefined;
}
/** metadata.citations 到达即为权威裁决(含空数组=全部无效),触发无效 chip 移除与富化 */
function citationsFinalForMessage(msg: any): boolean {
return Array.isArray(msg?.metadata?.citations);
}
const blockDisplayMode = computed(() => {
return personalization.experiments.blockDisplayMode || 'stacked';
});

View File

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

View File

@ -17,6 +17,7 @@
*/
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { useUiStore } from '@/stores/ui';
import CloseButton from '@/components/common/CloseButton.vue';
const props = defineProps<{
summary: any;
@ -342,15 +343,12 @@ function lineNumber(line: any): string {
<span class="edit-summary-plus">+{{ activeFile.added || 0 }}</span>
<span class="edit-summary-minus">-{{ activeFile.removed || 0 }}</span>
</span>
<button
<CloseButton
v-if="pinned || isMobile"
type="button"
class="es-modal__close"
:aria-label="$t('common.close')"
:label="$t('common.close')"
size="sm"
@click.stop="close"
>
<span class="icon icon-sm" :style="props.iconStyle('x')" aria-hidden="true"></span>
</button>
/>
</div>
<div class="es-modal__body">
<template v-if="(activeFile.lines || []).length">
@ -539,26 +537,6 @@ function lineNumber(line: any): string {
font-variant-numeric: tabular-nums;
}
.es-modal__close {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
flex: none;
border: none;
border-radius: 6px;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
padding: 0;
}
.es-modal__close:hover {
background: var(--surface-muted);
color: var(--text-primary);
}
.es-modal__body {
flex: 1;
min-height: 0;

View File

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

View File

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

View File

@ -62,7 +62,7 @@
:class="{ 'sfc-image--android': isAndroidApp }"
:src="contentUrl"
:alt="displayName"
@click="!isAndroidApp && openFullImage()"
@click="openFullImage()"
@error="onImageError"
/>
</template>
@ -82,6 +82,7 @@ import { buildShowHtmlIframeSrcdoc } from '@/utils/showHtmlSandbox';
import { openShowHtmlFullscreen } from '@/utils/showHtmlFullscreen';
import PdfPreview from './PdfPreview.vue';
import { t } from '@/locales';
import { useUiStore } from '@/stores/ui';
// fetch
const SHOW_FILE_CONTENT_CACHE = new Map<string, { content: string; ts: number }>();
@ -430,7 +431,9 @@ async function copyContent() {
}
function openFullImage() {
if (contentUrl.value) window.open(contentUrl.value, '_blank');
if (!contentUrl.value) return;
// Android WebView
useUiStore().openImagePreview({ url: contentUrl.value, name: displayName.value || '' });
}
function onImageError() {

View File

@ -768,7 +768,9 @@ function renderViewImage(result: any, args: any): string {
if (imageUrl) {
const safeUrl = escapeHtml(imageUrl);
html += `<div class="tool-result-image"><a href="${safeUrl}" target="_blank" rel="noopener" title="${escapeHtml(t('toolResults.media.viewImgTitle'))}"><img src="${safeUrl}" alt="${escapeHtml(t('toolResults.media.viewImgAlt'))}" loading="lazy" /></a></div>`;
// 不再用 <a target="_blank"> 打开新标签页,直接渲染 <img>
// 由聊天消息区的事件委托统一承接点击灯箱预览。
html += `<div class="tool-result-image"><img src="${safeUrl}" alt="${escapeHtml(t('toolResults.media.viewImgAlt'))}" loading="lazy" /></div>`;
}
return html;

View File

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

View File

@ -25,16 +25,7 @@
</svg>
<span class="qd-preview__name" :title="previewPath">{{ fileName }}</span>
<span class="qd-preview__path" :title="previewPath">{{ dirName }}</span>
<button class="qd-preview__close" :title="$t('common.close')" @click="close">
<svg viewBox="0 0 16 16" fill="none">
<path
d="M4 4l8 8M12 4l-8 8"
stroke="currentColor"
stroke-width="1.4"
stroke-linecap="round"
/>
</svg>
</button>
<CloseButton :label="$t('common.close')" :title="$t('common.close')" @click="close" />
</header>
<div class="qd-preview__body">
<div v-if="loading" class="qd-preview__loading">{{ $t('common.loading') }}</div>
@ -70,6 +61,7 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { storeToRefs } from 'pinia';
import CloseButton from '@/components/common/CloseButton.vue';
import { t } from '@/locales';
import { useQuickDockStore } from '@/stores/quickDock';
import { usePersonalizationStore } from '@/stores/personalization';

View File

@ -9,16 +9,9 @@
<span class="qd-detail__title" :title="title">{{ title }}</span>
<span class="qd-detail__badge" :class="`is-${stateClass}`">{{ statusText }}</span>
<span v-if="tokensText" class="qd-detail__tokens" :title="tokensTitle">{{ tokensText }}</span>
<button class="qd-detail__close" :title="$t('common.close')" @click="close">
<svg viewBox="0 0 16 16" fill="none">
<path
d="M4 4l8 8M12 4l-8 8"
stroke="currentColor"
stroke-width="1.4"
stroke-linecap="round"
/>
</svg>
</button>
<span class="qd-detail__close">
<CloseButton :label="$t('common.close')" :title="$t('common.close')" @click="close" />
</span>
</header>
<div ref="bodyRef" class="qd-detail__body" :class="{ 'body-fade': bodyFading }">
<!-- 子智能体工具调用 + 文本输出时间线 -->

View File

@ -581,30 +581,8 @@
.qd-detail__close {
flex: none;
margin-left: auto;
width: 26px;
height: 26px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
background: none;
border: none;
border-radius: 6px;
color: var(--text-tertiary);
cursor: pointer;
transition:
background 0.12s ease,
color 0.12s ease;
}
.qd-detail__close svg {
width: 13px;
height: 13px;
}
.qd-detail__close:hover {
background: var(--tab-active);
color: var(--text-secondary);
}
.qd-detail__body {
@ -879,34 +857,6 @@
text-overflow: ellipsis;
}
.qd-preview__close {
flex: none;
width: 26px;
height: 26px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
background: none;
border: none;
border-radius: 6px;
color: var(--text-tertiary);
cursor: pointer;
transition:
background 0.12s ease,
color 0.12s ease;
}
.qd-preview__close svg {
width: 13px;
height: 13px;
}
.qd-preview__close:hover {
background: var(--tab-active);
color: var(--text-secondary);
}
.qd-preview__body {
flex: 1;
overflow: auto;

View File

@ -0,0 +1,131 @@
<script setup lang="ts">
/**
* 全局统一关闭按钮X两种变体全站所有关闭语义按钮统一引用
*
* 变体
* - boxed默认用于一切有容器承载场景窗口/弹窗/抽屉/侧边栏/面板/通知卡片
* 28×28pxsm=24×24px圆角 7pxsm=6px透明底hover/active 出现 --hover-bg 底色块
* - bare用于悬空无容器场景当前仅 ImageLightbox 灯箱无背景/无圆角/无底色块
* 仅字形变色颜色通过 `--close-btn-color` / `--close-btn-color-hover` CSS 变量由父容器覆盖
*
* 接口
* - variant: 'boxed' | 'bare'默认 boxed
* - size: 'sm' | 'md'默认 md=28px
* - label: aria-label 文案必传调用方给 i18n 文案
* - emit: click
*
* 字形复用项目图标机制 iconStyle('x')等价 VersioningDialog 中的
* `<span class="icon icon-sm" :style="iconStyle('x')" />`通过在组件内直接由 ICONS
* 构造 `--icon-src` style 对象实现无需依赖父组件透传 iconStyle prop
*/
import { ICONS } from '../../utils/icons';
withDefaults(
defineProps<{
variant?: 'boxed' | 'bare';
size?: 'sm' | 'md';
label: string;
disabled?: boolean;
}>(),
{
variant: 'boxed',
size: 'md',
disabled: false
}
);
defineEmits<{
(e: 'click', evt: MouseEvent): void;
}>();
// iconStyle('x')mask 使 --icon-src x.svg
const iconStyle = { '--icon-src': `url(${ICONS.x})` };
</script>
<template>
<button
type="button"
class="close-btn"
:class="[`close-btn--${variant}`, `close-btn--${size}`]"
:aria-label="label"
:disabled="disabled"
v-bind="$attrs"
@click="$emit('click', $event)"
>
<span class="icon icon-sm" :style="iconStyle" aria-hidden="true"></span>
</button>
</template>
<style scoped>
.close-btn {
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
border: 0;
padding: 0;
background: transparent;
cursor: pointer;
}
.close-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
/* ===== boxed有容器承载 ===== */
.close-btn--boxed {
--_size: 28px;
--_radius: 7px;
width: var(--_size);
height: var(--_size);
border-radius: var(--_radius);
color: var(--text-secondary);
transition:
background 140ms ease,
color 140ms ease;
}
.close-btn--boxed.close-btn--sm {
--_size: 24px;
--_radius: 6px;
}
@media (hover: hover) {
.close-btn--boxed:hover {
background: var(--hover-bg);
color: var(--text-primary);
}
}
.close-btn--boxed:active {
background: var(--hover-bg);
color: var(--text-primary);
}
/* ===== bare悬空无容器 ===== */
.close-btn--bare {
width: 36px;
height: 36px;
color: var(--close-btn-color);
transition: color 140ms ease;
}
/* :where() ImageLightbox
--close-btn-color / --close-btn-color-hover 时始终能赢过默认值
注意禁止写成 var(--x, 兜底) 形式会被 stylelint 颜色规则拦截 */
:where(.close-btn--bare) {
--close-btn-color: var(--text-secondary);
--close-btn-color-hover: var(--text-primary);
}
.close-btn--bare .icon {
--icon-size: 20px;
}
@media (hover: hover) {
.close-btn--bare:hover {
color: var(--close-btn-color-hover);
}
}
</style>

View File

@ -6,7 +6,7 @@
<div class="subagent-activity-title">
{{ $t('overlay.bgCommandTitle', { id: activeCommand.command_id }) }}
</div>
<button type="button" class="subagent-activity-close" @click="close">×</button>
<CloseButton :label="$t('common.close')" @click="close" />
</div>
<div class="subagent-activity-meta">
<span
@ -49,6 +49,7 @@
import { computed, ref } from 'vue';
import { storeToRefs } from 'pinia';
import { t } from '@/locales';
import CloseButton from '@/components/common/CloseButton.vue';
import { useBackgroundCommandStore } from '@/stores/backgroundCommand';
const commandStore = useBackgroundCommandStore();

View File

@ -10,16 +10,12 @@
<div v-if="generatedPath" class="hint" :title="generatedPath">
{{ $t('overlay.generatedHint', { path: generatedPath }) }}
</div>
<button
type="button"
class="icon-close-btn"
:aria-label="$t('common.close')"
<CloseButton
:label="$t('common.close')"
:title="$t('common.close')"
@click="$emit('close')"
:disabled="submitting"
>
<span class="icon icon-sm" :style="iconStyle('x')" aria-hidden="true"></span>
</button>
@click="$emit('close')"
/>
</div>
</div>
@ -127,6 +123,8 @@
</template>
<script setup lang="ts">
import CloseButton from '@/components/common/CloseButton.vue';
defineOptions({ name: 'ConversationReviewDialog' });
const props = defineProps<{
@ -256,32 +254,6 @@ const formatUpdatedAt = (value: string | number) => {
}
/* 自定义关闭按钮,固定尺寸、视觉居中 */
.icon-close-btn {
flex: 0 0 auto;
width: 32px;
height: 32px;
display: grid;
place-items: center;
border: 0;
border-radius: 9px;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
transition:
background 140ms ease,
color 140ms ease;
}
.icon-close-btn:hover:not(:disabled) {
background: var(--theme-tab-active);
color: var(--text-primary);
}
.icon-close-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
/* ===== 主体:左右两栏,靠竖线分隔 ===== */
.review-body {
flex: 1 1 auto;

View File

@ -6,7 +6,7 @@
<div class="subagent-activity-title">
{{ isDone ? $t('overlay.goalDoneTitle') : isStopped ? $t('overlay.goalStoppedTitle') : $t('overlay.goalRunningTitle') }}
</div>
<button type="button" class="subagent-activity-close" @click="$emit('close')">×</button>
<CloseButton :label="$t('common.close')" @click="$emit('close')" />
</div>
<div class="goal-progress-meta">
@ -49,6 +49,7 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { t, currentLocale } from '@/locales';
import CloseButton from '@/components/common/CloseButton.vue';
defineOptions({ name: 'GoalProgressDialog' });

View File

@ -9,14 +9,12 @@
:aria-label="preview.name || $t('overlay.imagePreview')"
@click.self="close"
>
<button
type="button"
<CloseButton
variant="bare"
class="image-lightbox__close"
:aria-label="$t('overlay.closePreview')"
@click.stop="close"
>
×
</button>
:label="$t('overlay.closePreview')"
@click="close"
/>
<div class="image-lightbox__stage" @click.self="close">
<img
class="image-lightbox__img"
@ -34,6 +32,7 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, watch } from 'vue';
import { useUiStore } from '@/stores/ui';
import CloseButton from '@/components/common/CloseButton.vue';
const uiStore = useUiStore();
const preview = computed(() => uiStore.imagePreview);
@ -125,28 +124,15 @@ onBeforeUnmount(() => syncLock(false));
text-overflow: ellipsis;
}
/* bare CloseButton
灯箱遮罩永远为深色颜色不随主题变量默认约 72% 透明度的白hover 全亮 */
.image-lightbox__close {
position: absolute;
top: calc(10px + env(safe-area-inset-top, 0px));
right: calc(10px + env(safe-area-inset-right, 0px));
z-index: 1;
width: 36px;
height: 36px;
border-radius: 50%;
border: none;
background: var(--lightbox-btn-bg);
color: var(--lightbox-text);
font-size: 20px;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.15s ease;
}
.image-lightbox__close:hover {
background: var(--lightbox-btn-bg-hover);
--close-btn-color: color-mix(in srgb, var(--lightbox-text) 72%, transparent);
--close-btn-color-hover: var(--lightbox-text);
}
.lightbox-fade-enter-active,

View File

@ -17,7 +17,7 @@
@change="onLocalChange"
/>
</div>
<button class="close-btn" @click="close">×</button>
<CloseButton :label="$t('common.close')" @click="close" />
</div>
<div class="body">
<div v-if="loading" class="loading">{{ $t('common.loading') }}</div>
@ -57,6 +57,7 @@
<script setup lang="ts">
import { computed, ref, watch, onMounted, withDefaults } from 'vue';
import CloseButton from '@/components/common/CloseButton.vue';
interface ImageEntry {
name: string;
@ -195,13 +196,6 @@ onMounted(() => {
opacity: 0.5;
cursor: not-allowed;
}
.close-btn {
background: transparent;
color: var(--text-tertiary);
border: none;
font-size: 20px;
cursor: pointer;
}
.body {
padding: 12px 16px;
overflow: auto;

View File

@ -4,7 +4,7 @@
<div class="overlay-card">
<div class="overlay-header">
<h3>{{ $t('overlay.pathAuthTitle') }}</h3>
<button type="button" @click="$emit('close')">×</button>
<CloseButton :label="$t('common.close')" @click="$emit('close')" />
</div>
<div class="mode-switch">
<button
@ -51,6 +51,8 @@
</template>
<script setup lang="ts">
import CloseButton from '@/components/common/CloseButton.vue';
defineProps<{ open: boolean; value: string; mode: 'writable' | 'readable'; saving?: boolean }>();
defineEmits<{
(e: 'close'): void;
@ -65,7 +67,6 @@ defineEmits<{
.overlay-card { width: min(680px, 92vw); background: var(--theme-surface-soft); border: 1px solid var(--theme-control-border); border-radius: 12px; padding: 12px; }
.overlay-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
.overlay-header h3 { margin: 0; font-size: 16px; }
.overlay-header button { border: none; background: transparent; font-size: 20px; cursor: pointer; }
.hint { font-size: 12px; color: var(--text-secondary); margin: 0 0 8px 0; }
.mode-switch { display: inline-flex; border: 1px solid var(--theme-control-border); border-radius: 10px; overflow: hidden; margin-bottom: 8px; }
.mode-btn { border: none; background: transparent; padding: 6px 10px; cursor: pointer; font-size: 12px; }

View File

@ -4,17 +4,13 @@
<section class="plan-approval-card" role="dialog" aria-modal="true" :aria-label="$t('overlay.planApprovalAriaLabel')">
<header class="plan-approval-windowbar">
<div class="plan-approval-window-title">{{ $t('overlay.planApprovalTitle') }}</div>
<button
type="button"
class="plan-approval-close"
:title="$t('overlay.planApprovalMinimize')"
:aria-label="$t('overlay.planApprovalMinimize')"
@click="emit('minimize')"
>
<svg viewBox="0 0 20 20" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M5 5l10 10M15 5L5 15" />
</svg>
</button>
<span class="plan-approval-close">
<CloseButton
:label="$t('overlay.planApprovalMinimize')"
:title="$t('overlay.planApprovalMinimize')"
@click="emit('minimize')"
/>
</span>
</header>
<header class="plan-approval-header">
@ -77,6 +73,7 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import MarkdownRenderer from '../chat/MarkdownRenderer.vue';
import CloseButton from '@/components/common/CloseButton.vue';
const props = defineProps<{
visible: boolean;
@ -165,22 +162,9 @@ function reject() {
right: 10px;
top: 50%;
transform: translateY(-50%);
width: 26px;
height: 26px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
border: none;
border-radius: 7px;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
}
.plan-approval-close:hover {
background: var(--hover-bg);
color: var(--text-primary);
}
.plan-approval-window-title {

View File

@ -9,17 +9,9 @@
>
<header class="sandbox-setup-windowbar">
<div class="sandbox-setup-window-title">{{ $t('sandbox.setupTitle') }}</div>
<button
type="button"
class="sandbox-setup-close"
:title="$t('common.close')"
:aria-label="$t('common.close')"
@click="onClose"
>
<svg viewBox="0 0 20 20" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M5 5l10 10M15 5L5 15" />
</svg>
</button>
<span class="sandbox-setup-close">
<CloseButton :label="$t('common.close')" :title="$t('common.close')" @click="onClose" />
</span>
</header>
<div class="sandbox-setup-body">
@ -144,6 +136,7 @@
import { computed, nextTick, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import FancyCheck from '@/components/common/FancyCheck.vue';
import CloseButton from '@/components/common/CloseButton.vue';
import { useSandboxSetupStore } from '@/stores/sandboxSetup';
defineOptions({ name: 'SandboxSetupDialog' });
@ -312,22 +305,9 @@ function onClose() {
right: 10px;
top: 50%;
transform: translateY(-50%);
width: 26px;
height: 26px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
border: none;
border-radius: 7px;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
}
.sandbox-setup-close:hover {
background: var(--hover-bg);
color: var(--text-primary);
}
.sandbox-setup-body {

View File

@ -6,7 +6,7 @@
<div class="subagent-activity-title">
{{ $t('overlay.subAgentProgressTitle', { id: activeAgent.agent_id || activeAgent.task_id }) }}
</div>
<button type="button" class="subagent-activity-close" @click="close">×</button>
<CloseButton :label="$t('common.close')" @click="close" />
</div>
<div class="subagent-activity-meta">
<span class="subagent-activity-status" :class="activeAgent.status || ''">{{
@ -59,6 +59,7 @@
import { computed, ref } from 'vue';
import { storeToRefs } from 'pinia';
import { t, currentLocale } from '@/locales';
import CloseButton from '@/components/common/CloseButton.vue';
import { useSubAgentStore } from '@/stores/subAgent';
type ActivityEntry = {

View File

@ -3,8 +3,8 @@
<div v-if="visible && questions.length" class="user-question-overlay" @click.self="emit('minimize')">
<section class="user-question-card" role="dialog" aria-modal="true" :aria-label="$t('overlay.userQuestionAriaLabel')">
<header class="user-question-windowbar">
<div class="user-question-traffic" :aria-label="$t('overlay.windowControlAria')">
<button type="button" class="traffic-dot traffic-dot--close" :aria-label="$t('overlay.minimizeAria')" @click="emit('minimize')"></button>
<div class="user-question-traffic">
<CloseButton :label="$t('overlay.minimizeAria')" @click="emit('minimize')" />
</div>
<div class="user-question-window-title">{{ $t('overlay.userQuestionWindowTitle') }}</div>
</header>
@ -80,6 +80,7 @@
<script setup lang="ts">
import { computed, reactive, watch } from 'vue';
import CloseButton from '@/components/common/CloseButton.vue';
const props = defineProps<{
visible: boolean;
@ -208,16 +209,7 @@ function dismiss() {
align-items: center;
}
.traffic-dot {
width: 12px;
height: 12px;
border-radius: 50%;
border: 1px solid var(--border-strong);
padding: 0;
cursor: pointer;
}
.traffic-dot--close { background: var(--mac-close); }
/* 关闭按钮本体样式在 common/CloseButton.vueboxed 变体traffic-dot 红点样式已随统一下线 */
.user-question-window-title {
text-align: center;

View File

@ -20,9 +20,7 @@
<span class="icon icon-sm" :style="iconStyle('refreshCw')" aria-hidden="true"></span>
<span>{{ $t('overlay.refresh') }}</span>
</button>
<button type="button" class="icon-close-btn" :aria-label="$t('common.close')" @click="$emit('close')">
<span class="icon icon-sm" :style="iconStyle('x')" aria-hidden="true"></span>
</button>
<CloseButton :label="$t('common.close')" @click="$emit('close')" />
</div>
</header>
@ -155,6 +153,7 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import CloseButton from '@/components/common/CloseButton.vue';
const props = defineProps<{
hostMode: boolean;
@ -498,26 +497,6 @@ const splitDiffContent = (content: any): string[] => {
cursor: not-allowed;
}
.icon-close-btn {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border: 0;
border-radius: 9px;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
transition:
background 140ms ease,
color 140ms ease;
}
.icon-close-btn:hover {
background: var(--theme-tab-active);
color: var(--text-primary);
}
/* ===== 主体:左右分栏靠竖线 ===== */
.versioning-body {
display: grid;

View File

@ -16,7 +16,7 @@
@change="onLocalChange"
/>
</div>
<button class="close-btn" @click="close">×</button>
<CloseButton :label="$t('common.close')" @click="close" />
</div>
<div class="body">
<div v-if="loading" class="loading">{{ $t('common.loading') }}</div>
@ -59,6 +59,7 @@
<script setup lang="ts">
import { computed, ref, watch, onMounted, withDefaults } from 'vue';
import CloseButton from '@/components/common/CloseButton.vue';
interface VideoEntry {
name: string;
@ -197,13 +198,6 @@ onMounted(() => {
opacity: 0.5;
cursor: not-allowed;
}
.close-btn {
background: transparent;
color: var(--text-tertiary);
border: none;
font-size: 20px;
cursor: pointer;
}
.body {
padding: 12px 16px;
overflow: auto;

View File

@ -9,15 +9,11 @@
<span class="icon icon-sm" :style="iconStyle('eye')" aria-hidden="true"></span>
<span>{{ $t('shell.focusFilesCount', { n: focusedCount }) }}</span>
</h3>
<button
<CloseButton
v-if="showCloseButton"
type="button"
class="focus-close-btn"
:aria-label="$t('shell.closeFocusPanel')"
:label="$t('shell.closeFocusPanel')"
@click="$emit('close')"
>
×
</button>
/>
</div>
<div class="focused-files" v-if="!collapsed">
<div v-if="!focusedCount" class="no-files">{{ $t('shell.noFocusFiles') }}</div>
@ -40,6 +36,7 @@
import { computed } from 'vue';
import { storeToRefs } from 'pinia';
import { useFocusStore } from '@/stores/focus';
import CloseButton from '@/components/common/CloseButton.vue';
defineOptions({ name: 'FocusPanel' });

View File

@ -9,9 +9,7 @@
<div class="git-changes-panel__summary">
<span class="git-changes-panel__add">+{{ additions }}</span>
<span class="git-changes-panel__del">-{{ deletions }}</span>
<button type="button" class="git-changes-panel__close" :aria-label="$t('shell.closeGitPanel')" @click="$emit('close')">
×
</button>
<CloseButton :label="$t('shell.closeGitPanel')" @click="$emit('close')" />
</div>
</header>
@ -135,6 +133,7 @@
import { computed } from 'vue';
import { ref } from 'vue';
import { t, currentLocale } from '@/locales';
import CloseButton from '@/components/common/CloseButton.vue';
defineOptions({ name: 'GitChangesPanel' });

View File

@ -18,12 +18,7 @@
<span class="terminal-panel__tab-name">{{ name }}</span>
</button>
</div>
<button
type="button"
class="terminal-panel__close"
:aria-label="$t('shell.closeTerminalPanel')"
@click="$emit('close')"
>&times;</button>
<CloseButton :label="$t('shell.closeTerminalPanel')" @click="$emit('close')" />
</header>
<!-- 终端容器 -->
@ -44,6 +39,7 @@ import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { Terminal } from 'xterm';
import { io as createSocketClient } from 'socket.io-client';
import 'xterm/css/xterm.css';
import CloseButton from '@/components/common/CloseButton.vue';
defineOptions({ name: 'TerminalPanel' });
@ -541,29 +537,6 @@ onBeforeUnmount(() => {
text-overflow: ellipsis;
}
.terminal-panel__close {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: 14px;
background: transparent;
color: var(--text-secondary);
font-size: 18px;
line-height: 1;
cursor: pointer;
flex-shrink: 0;
margin-left: 4px;
}
.terminal-panel__close:hover {
background: var(--theme-tab-active);
color: var(--text-primary);
box-shadow: 0 1px 2px var(--shadow-color);
}
.terminal-panel__body {
flex: 1;
overflow: hidden;

View File

@ -6,9 +6,7 @@
>
<div class="sidebar-header" :class="{ 'mobile-header': isMobileViewport }">
<h3 class="icon-label">{{ panelTitle }}</h3>
<button type="button" class="approval-close-btn" :aria-label="$t('shell.closeApprovalPanel')" @click="handleCloseClick">
×
</button>
<CloseButton :label="$t('shell.closeApprovalPanel')" @click="handleCloseClick" />
</div>
<div class="approval-panel-body" v-if="!collapsed">
<div v-if="!approvals.length && !isGoalApprovalMode" class="no-files">{{ $t('shell.noPendingApprovals') }}</div>
@ -132,6 +130,7 @@
<script setup lang="ts">
import { onMounted, computed } from 'vue';
import { t, currentLocale } from '@/locales';
import CloseButton from '@/components/common/CloseButton.vue';
defineOptions({ name: 'ToolApprovalPanel' });

View File

@ -23,29 +23,19 @@
<div class="settings-mobile-bar">
<span class="settings-mobile-bar-cell" aria-hidden="true"></span>
<span class="settings-mobile-bar-title">{{ $t('common.settings') }}</span>
<button
type="button"
class="settings-mobile-bar-btn"
:aria-label="$t('personalization.closePersonalSpaceAriaLabel')"
@click="personalization.closeDrawer()"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<path d="M6 6l12 12M18 6 6 18" />
</svg>
</button>
<span class="settings-mobile-bar-btn">
<CloseButton
:label="$t('personalization.closePersonalSpaceAriaLabel')"
@click="personalization.closeDrawer()"
/>
</span>
</div>
<div class="settings-nav-head">
<button
type="button"
class="settings-close-button"
<CloseButton
data-tutorial="personal-close"
:aria-label="$t('personalization.closePersonalSpaceAriaLabel')"
:label="$t('personalization.closePersonalSpaceAriaLabel')"
@click="personalization.closeDrawer()"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round">
<path d="M6 6l12 12M18 6 6 18" />
</svg>
</button>
/>
</div>
<div class="settings-redesign-tabs">
<button
@ -164,6 +154,7 @@
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick, provide } from 'vue';
import { storeToRefs } from 'pinia';
import FancyCheck from '@/components/common/FancyCheck.vue';
import CloseButton from '@/components/common/CloseButton.vue';
import GeneralTab from './tabs/GeneralTab.vue';
import PreferencesTab from './tabs/PreferencesTab.vue';
import ModelTab from './tabs/ModelTab.vue';

View File

@ -33,31 +33,6 @@ body[data-theme='dark'] .settings-redesign-card {
padding: 12px 12px 4px;
}
.settings-close-button {
width: 32px;
height: 32px;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--text-secondary);
display: grid;
place-items: center;
cursor: pointer;
}
@media (hover: hover) {
.settings-close-button:hover {
background: var(--hover-bg);
color: var(--text-primary);
}
}
.settings-close-button svg {
width: 17px;
height: 17px;
stroke-width: 2;
}
/* 移动端顶栏:桌面端隐藏 */
.settings-mobile-bar {
display: none;

View File

@ -2,7 +2,7 @@
<transition name="quota-toast-fade">
<div class="quota-toast" v-if="quotaToast">
<span class="quota-toast-label">{{ quotaToast.message }}</span>
<button type="button" class="toast-close" :aria-label="$t('shell.closeNotification')" @click="dismiss">×</button>
<CloseButton size="sm" :label="$t('shell.closeNotification')" @click="dismiss" />
</div>
</transition>
</template>
@ -10,6 +10,7 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia';
import { useUiStore } from '@/stores/ui';
import CloseButton from '@/components/common/CloseButton.vue';
const uiStore = useUiStore();
const { quotaToast } = storeToRefs(uiStore);

View File

@ -10,15 +10,12 @@
<div v-if="toast.title" class="app-toast-title">{{ toast.title }}</div>
<div class="app-toast-message">{{ toast.message }}</div>
</div>
<button
<CloseButton
v-if="toast.closable !== false"
type="button"
class="toast-close"
:aria-label="$t('shell.closeNotification')"
size="sm"
:label="$t('shell.closeNotification')"
@click="dismiss(toast.id)"
>
×
</button>
/>
</div>
</transition-group>
</div>
@ -27,6 +24,7 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia';
import { useUiStore } from '@/stores/ui';
import CloseButton from '@/components/common/CloseButton.vue';
const uiStore = useUiStore();
const { toastQueue: toasts } = storeToRefs(uiStore);

View File

@ -1,15 +1,12 @@
<template>
<div class="token-drawer" v-if="visible" :class="{ collapsed }" data-tutorial="token-drawer">
<div class="token-display-panel">
<button
<CloseButton
class="token-close-btn"
type="button"
data-tutorial="token-close"
:label="$t('sidebar.collapseUsage')"
@click="emit('toggle')"
:aria-label="$t('sidebar.collapseUsage')"
>
<span class="sr-only">{{ $t('common.close') }}</span>
</button>
/>
<div class="token-panel-content">
<div class="usage-dashboard">
<div class="usage-cell usage-cell--left usage-cell--token panel-card">
@ -127,6 +124,7 @@ defineOptions({ name: 'TokenDrawer' });
import { computed } from 'vue';
import { t, currentLocale } from '@/locales';
import CloseButton from '@/components/common/CloseButton.vue';
const emit = defineEmits<{
(e: 'toggle'): void;

View File

@ -18,7 +18,7 @@
<div v-else class="wf-shell__loading">{{ $t('common.loading') }}</div>
<div v-if="errorMessage" class="wf-shell__error" role="alert">
<span>{{ errorMessage }}</span>
<button type="button" class="wf-shell__error-close" :aria-label="$t('common.close')" @click="errorMessage = ''">×</button>
<CloseButton :label="$t('common.close')" @click="errorMessage = ''" />
</div>
</div>
</template>
@ -26,6 +26,7 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { t } from '@/locales';
import CloseButton from '@/components/common/CloseButton.vue';
import WorkflowLibraryView from './WorkflowLibraryView.vue';
import WorkflowEditorView from './WorkflowEditorView.vue';
import { createEmptyWorkflow, type WorkflowDef } from './workflowModel';

View File

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

View File

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

View File

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

View File

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

View File

@ -2007,11 +2007,13 @@ html.show-html-fullscreen-open body {
flex: 0 0 auto;
}
/* 与卡片 ⋯ 按钮一致的纯文字风格无边框无背景hover 仅变色 */
/* 全屏预览顶栏按钮与全站统一关闭按钮common/CloseButton.vue boxed 变体同一形态
圆角方透明底hover/active 出现 --hover-bg 底色块动态 DOM 无法复用组件样式在此对齐 */
.show-html-fullscreen__btn {
width: 32px;
height: 32px;
width: 28px;
height: 28px;
border: 0;
border-radius: 7px;
background: transparent;
color: var(--text-secondary);
display: inline-flex;
@ -2020,10 +2022,18 @@ html.show-html-fullscreen-open body {
font-size: 16px;
line-height: 1;
cursor: pointer;
transition: color 0.18s ease;
transition: background 140ms ease, color 140ms ease;
}
.show-html-fullscreen__btn:hover {
@media (hover: hover) {
.show-html-fullscreen__btn:hover {
background: var(--hover-bg);
color: var(--text-primary);
}
}
.show-html-fullscreen__btn:active {
background: var(--hover-bg);
color: var(--text-primary);
}
@ -2058,6 +2068,14 @@ html.show-html-fullscreen-open body {
@extend .chat-inline-card__caption;
}
/* 可点击预览的内容图片统一给放大镜光标提示
show_image 卡片 / Markdown 正文图 / 工具结果图点击由 bootstrap.ts 事件委托承接 */
.chat-inline-image img,
.text-content img,
.tool-result-image img {
cursor: zoom-in;
}
/* show_html 在 streaming 重建期间会短暂回到原始标签,需提供稳定占位避免 scrollHeight 抖动 */
show_html:not([data-rendered='1']),
show-html:not([data-rendered='1']) {
@ -2463,6 +2481,106 @@ a.md-download-link {
}
}
// ===== 行内引用胶囊citation chip =====
// 交互约定hover / 展开态只变色不动边框弹层交互见 CitationPopover.vue
.md-citation-chip {
display: inline-flex;
align-items: center;
gap: 4px;
height: 20px;
padding: 0 7px;
margin: 0 2px;
border-radius: 999px;
font-size: 12px;
line-height: 1;
white-space: nowrap;
vertical-align: middle; // 几何中心对齐基线 + x字高/2与文字完全上下居中 20px 盒仍在 25.5px 行框内不撑行
background: var(--surface-muted);
border: 1px solid var(--border-default);
color: var(--text-secondary);
cursor: pointer;
user-select: none;
transition: background 0.15s, color 0.15s;
&:hover,
&.is-open {
background: var(--hover-bg);
color: var(--text-primary);
}
&.is-pending {
cursor: default;
}
.chip-icons {
display: inline-flex;
align-items: center;
flex: none;
}
.chip-favicon {
width: 12px;
height: 12px;
border-radius: 3px;
flex: none;
}
.chip-favicon + .chip-favicon {
margin-left: -5px;
border: 1px solid var(--surface-muted);
}
.chip-file-icon {
width: 12px;
height: 12px;
flex: none;
color: var(--text-tertiary);
}
.chip-letter {
display: inline-flex;
align-items: center;
justify-content: center;
width: 12px;
height: 12px;
border-radius: 3px;
flex: none;
font-size: 8px;
font-weight: 700;
color: var(--on-accent);
background: var(--accent);
}
.chip-label {
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 500;
// 行高 1 会把 j/g/y 等字母的下沉笔画裁掉放大行高让 descender 落入盒内
// chip 总高 20px 由自身 height 锁定行高放大不撑高
line-height: 1.4;
}
.chip-count {
color: var(--text-tertiary);
font-weight: 500;
line-height: 1.4;
}
&:hover .chip-count,
&.is-open .chip-count {
color: var(--text-secondary);
}
.chip-pending-dot {
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--text-tertiary);
margin: 0 4px;
}
}
// ===== 数学公式样式 =====
.math-block {
display: block;

View File

@ -1479,15 +1479,6 @@ body[data-theme='light'] {
padding: 0 16px !important;
}
/* 移动端审批面板关闭按钮 - 移除圆形外框 */
.mobile-panel-sheet--approval .approval-close-btn {
background: transparent !important;
border-radius: 0 !important;
width: auto !important;
height: auto !important;
padding: 4px !important;
font-size: 24px !important;
}
}
.user-question-mini-dot {

View File

@ -1811,20 +1811,7 @@
background: var(--surface-panel);
}
.mobile-overlay-close {
position: absolute;
top: 10px;
right: 10px;
width: 32px;
height: 32px;
border-radius: 16px;
border: none;
background: var(--border-default);
color: var(--text-primary);
font-size: 18px;
cursor: pointer;
z-index: 5;
}
/* .mobile-overlay-close 为历史遗留死代码(全站无模板引用),统一关闭按钮改造时删除 */
.mobile-overlay-content {
margin-top: 0;
@ -2085,20 +2072,6 @@
color: var(--text-primary);
}
.subagent-activity-close {
border: none;
background: transparent;
color: var(--text-primary);
font-size: 20px;
cursor: pointer;
padding: 4px 8px;
border-radius: 8px;
}
.subagent-activity-close:hover {
background: var(--theme-control-bg);
}
.subagent-activity-meta {
display: flex;
align-items: center;

View File

@ -172,29 +172,11 @@ body[data-theme='dark'] {
color: var(--text-secondary);
}
/* 按钮本体样式在 common/CloseButton.vueboxed 变体),此处仅保留抽屉左上角定位 */
.token-close-btn {
position: absolute;
top: 12px;
left: 12px;
width: 14px;
height: 14px;
border: 1px solid var(--mac-close-border);
border-radius: 50%;
background: var(--mac-close);
display: block;
padding: 0;
cursor: pointer;
transition: background 0.2s ease, transform 0.2s ease;
box-shadow: 0 2px 4px var(--shadow-color);
}
.token-close-btn:hover {
background: var(--mac-close-hover);
transform: translateY(-1px);
}
.token-close-btn:active {
transform: translateY(0);
}
.sr-only {
@ -437,19 +419,7 @@ body[data-theme='dark'] {
line-height: 1.4;
}
.toast-close {
border: none;
background: transparent;
color: var(--text-secondary);
font-size: 16px;
cursor: pointer;
padding: 2px;
}
.toast-close:hover {
color: var(--text-primary);
}
/* .toast-close 已移除Toast 关闭按钮统一改用 common/CloseButton.vueboxed sm */
.status-pill--running {
background: color-mix(in srgb, var(--state-success) 22%, transparent);

View File

@ -40,28 +40,6 @@
overflow-y: auto;
}
.focus-close-btn {
width: 32px;
height: 32px;
border: none;
border-radius: 16px;
background: var(--hover-bg);
color: var(--text-primary);
font-size: 20px;
cursor: pointer;
}
.approval-close-btn {
width: 32px;
height: 32px;
border: none;
border-radius: 16px;
background: var(--hover-bg);
color: var(--text-primary);
font-size: 20px;
cursor: pointer;
}
.git-changes-panel {
flex: 0 0 auto;
min-width: 340px;
@ -126,24 +104,6 @@
color: var(--git-diff-del-text);
}
.git-changes-panel__close {
width: 28px;
height: 28px;
border: none;
border-radius: 14px;
background: transparent;
color: var(--text-secondary);
font-size: 18px;
line-height: 1;
cursor: pointer;
}
.git-changes-panel__close:hover {
background: var(--theme-tab-active);
color: var(--text-primary);
box-shadow: 0 1px 2px var(--shadow-color);
}
.git-changes-panel__body {
flex: 1 1 auto;
min-height: 0;
@ -843,19 +803,4 @@ body[data-theme='dark'] {
background: var(--surface-base);
}
.focus-close-btn {
background: var(--badge-bg);
}
.focus-close-btn:hover {
background: var(--surface-muted);
}
.approval-close-btn {
background: var(--badge-bg);
}
.approval-close-btn:hover {
background: var(--surface-muted);
}
}

View File

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