Compare commits
3 Commits
a3fa5e59c6
...
92e4931408
| Author | SHA1 | Date | |
|---|---|---|---|
| 92e4931408 | |||
| b3158913ef | |||
| e6d8f194d2 |
@ -92,7 +92,7 @@ class ToolsDefinitionSearchWebToolsMixin:
|
|||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
"name": "web_search",
|
"name": "web_search",
|
||||||
"description": "当现有资料不足时搜索外部信息。调用前说明目的,精准撰写 query,并合理设置时间/主题参数;避免重复或无意义的搜索。",
|
"description": "当现有资料不足时搜索外部信息。调用前说明目的,精准撰写 query,并合理设置时间/主题参数;避免重复或无意义的搜索。结果中每条来源带 [src_xxx] 引用 ID,回答中标注事实来源时使用。",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": self._inject_intent({
|
"properties": self._inject_intent({
|
||||||
@ -145,7 +145,7 @@ class ToolsDefinitionSearchWebToolsMixin:
|
|||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
"name": "extract_webpage",
|
"name": "extract_webpage",
|
||||||
"description": "在 web_search 结果不够详细时提取网页正文。调用前说明用途,注意提取内容会消耗大量 token,超过80000字符将被拒绝。",
|
"description": "在 web_search 结果不够详细时提取网页正文。调用前说明用途,注意提取内容会消耗大量 token,超过80000字符将被拒绝。结果会带来源引用 ID。",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": self._inject_intent({
|
"properties": self._inject_intent({
|
||||||
|
|||||||
@ -1697,13 +1697,35 @@ class MainTerminalToolsExecutionMixin:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if search_response["success"]:
|
if search_response["success"]:
|
||||||
|
# 注册网页来源 citation,并让模型可见的 summary 带上 [src_xxx] 前缀
|
||||||
|
from modules.citations import get_registry
|
||||||
|
registry = get_registry(self)
|
||||||
|
citations = []
|
||||||
|
results_list = search_response.get("results", [])
|
||||||
|
for item in results_list:
|
||||||
|
ann = registry.register_url(
|
||||||
|
title=item.get("title") or item.get("url") or "",
|
||||||
|
url=item.get("url") or "",
|
||||||
|
snippet=item.get("content", ""),
|
||||||
|
published_date=item.get("published_date") or None,
|
||||||
|
source_tool="web_search",
|
||||||
|
)
|
||||||
|
if ann:
|
||||||
|
item["citation_id"] = ann["id"]
|
||||||
|
citations.append(ann)
|
||||||
result = {
|
result = {
|
||||||
"success": True,
|
"success": True,
|
||||||
"summary": search_response["summary"],
|
"summary": self.search_engine.build_summary_text(
|
||||||
|
search_response.get("query") or arguments["query"],
|
||||||
|
results_list,
|
||||||
|
search_response.get("filters", {}),
|
||||||
|
search_response.get("timestamp", ""),
|
||||||
|
),
|
||||||
"filters": search_response.get("filters", {}),
|
"filters": search_response.get("filters", {}),
|
||||||
"query": search_response.get("query"),
|
"query": search_response.get("query"),
|
||||||
"results": search_response.get("results", []),
|
"results": results_list,
|
||||||
"total_results": search_response.get("total_results", 0)
|
"total_results": search_response.get("total_results", 0),
|
||||||
|
"citations": citations,
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
result = {
|
result = {
|
||||||
@ -1738,10 +1760,19 @@ class MainTerminalToolsExecutionMixin:
|
|||||||
"url": url
|
"url": url
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
|
from modules.citations import get_registry
|
||||||
|
ann = get_registry(self).register_url(
|
||||||
|
title=url,
|
||||||
|
url=url,
|
||||||
|
snippet=full_content[:500],
|
||||||
|
source_tool="extract_webpage",
|
||||||
|
)
|
||||||
result = {
|
result = {
|
||||||
"success": True,
|
"success": True,
|
||||||
"url": url,
|
"url": url,
|
||||||
"content": full_content
|
"content": full_content,
|
||||||
|
"citation_id": ann.get("id") if ann else None,
|
||||||
|
"citations": [ann] if ann else [],
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
result = {
|
result = {
|
||||||
|
|||||||
283
modules/citations.py
Normal file
283
modules/citations.py
Normal file
@ -0,0 +1,283 @@
|
|||||||
|
"""Inline Citations:引用注册表、marker 解析与校验。
|
||||||
|
|
||||||
|
数据流:
|
||||||
|
工具执行(web_search / extract_webpage)→ CitationRegistry.register_url()
|
||||||
|
→ 工具结果文本带 [src_xxx](模型可见)
|
||||||
|
→ 模型输出 【cite:src_xxx】/【file:相对路径】 marker
|
||||||
|
→ assistant 消息落库前 finalize_message_citations() 校验 + 挂载 message.citations
|
||||||
|
→ 前端渲染 citation chip
|
||||||
|
|
||||||
|
设计要点:
|
||||||
|
- 网页来源用 cite: 前缀 + ID(src_<sha1(规范化 url) 前 10 位>),确定性、同 URL 天然去重;
|
||||||
|
- 文件来源用 file: 前缀 + 工作区相对路径(可带 #L120-148 / #p7 locator),不经过 registry;
|
||||||
|
- registry 是会话运行期的临时查找表,不落盘;历史消息靠 message.citations 自包含恢复。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
# marker 语法:【cite:src_xxx】/【file:AGENTS.md】/同前缀多来源逗号并列(中英文逗号均可)
|
||||||
|
CITATION_MARK_RE = re.compile(r"【(cite|file):([^】]+)】")
|
||||||
|
|
||||||
|
# locator 后缀:#L120 / #L120-148 / #L120-L148(GitHub 风格)/ #p7
|
||||||
|
_LOCATOR_RE = re.compile(r"^(?P<path>.*?)#(?:L(?P<line_start>\d+)(?:-L?(?P<line_end>\d+))?|p(?P<page>\d+))$", re.IGNORECASE)
|
||||||
|
|
||||||
|
_SRC_ID_RE = re.compile(r"^src_[0-9a-f]{10}x*$")
|
||||||
|
|
||||||
|
SNIPPET_MAX_CHARS = 300
|
||||||
|
|
||||||
|
# 文件 snippet 读取上限(前 64KB 足够覆盖 locator 行段与开头摘要)
|
||||||
|
_FILE_SNIPPET_READ_BYTES = 65536
|
||||||
|
|
||||||
|
# 图片扩展名:引用弹层渲染 <img> 而不是文本摘要
|
||||||
|
_IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".ico", ".avif"}
|
||||||
|
|
||||||
|
|
||||||
|
def _read_file_snippet(target: Path, ref: Dict[str, Any]) -> Optional[str]:
|
||||||
|
"""读取文本文件内容片段作为引用预览:有 #L 定位时取对应行段,否则取开头。
|
||||||
|
|
||||||
|
二进制文件(含空字节)返回 None。空白压缩为单行,限长 SNIPPET_MAX_CHARS。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open(target, "rb") as f:
|
||||||
|
raw = f.read(_FILE_SNIPPET_READ_BYTES)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
if b"\x00" in raw:
|
||||||
|
return None
|
||||||
|
text = raw.decode("utf-8", errors="replace")
|
||||||
|
line_start = ref.get("line_start")
|
||||||
|
if line_start:
|
||||||
|
lines = text.splitlines()
|
||||||
|
start = max(1, int(line_start))
|
||||||
|
end = min(len(lines), int(ref.get("line_end") or start))
|
||||||
|
snippet = "\n".join(lines[start - 1 : end]) if start <= len(lines) else ""
|
||||||
|
else:
|
||||||
|
snippet = text
|
||||||
|
# 保留换行(弹层 pre-line 渲染),仅压缩行内空白、去空行
|
||||||
|
snippet = "\n".join(ln.strip() for ln in snippet.splitlines() if ln.strip())
|
||||||
|
return snippet[:SNIPPET_MAX_CHARS] or None
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_url(url: str) -> str:
|
||||||
|
"""URL 规范化:小写 scheme/host、去末尾斜杠。用于同来源去重。"""
|
||||||
|
url = (url or "").strip()
|
||||||
|
try:
|
||||||
|
p = urlparse(url)
|
||||||
|
scheme = (p.scheme or "https").lower()
|
||||||
|
netloc = p.netloc.lower()
|
||||||
|
path = p.path.rstrip("/")
|
||||||
|
query = f"?{p.query}" if p.query else ""
|
||||||
|
return f"{scheme}://{netloc}{path}{query}"
|
||||||
|
except Exception:
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
def domain_of(url: str) -> str:
|
||||||
|
try:
|
||||||
|
return urlparse(url).netloc.lower().lstrip("www.")
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def make_url_citation_id(url: str) -> str:
|
||||||
|
digest = hashlib.sha1(normalize_url(url).encode("utf-8")).hexdigest()[:10]
|
||||||
|
return f"src_{digest}"
|
||||||
|
|
||||||
|
|
||||||
|
class CitationRegistry:
|
||||||
|
"""会话级网页来源注册表(运行期内存态,不落盘)。"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._by_id: Dict[str, Dict[str, Any]] = {}
|
||||||
|
self._id_by_url: Dict[str, str] = {}
|
||||||
|
|
||||||
|
def register_url(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
title: str = "",
|
||||||
|
url: str = "",
|
||||||
|
snippet: str = "",
|
||||||
|
published_date: Optional[str] = None,
|
||||||
|
source_tool: Optional[str] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""注册(或按规范化 URL 去重返回已有)一个网页来源 annotation。"""
|
||||||
|
if not url:
|
||||||
|
return {}
|
||||||
|
norm = normalize_url(url)
|
||||||
|
existing_id = self._id_by_url.get(norm)
|
||||||
|
if existing_id is not None:
|
||||||
|
return self._by_id[existing_id]
|
||||||
|
|
||||||
|
cid = make_url_citation_id(url)
|
||||||
|
# 哈希碰撞兜底(同一 URL 已命中则上面已返回;不同 URL 撞 hash 时加后缀)
|
||||||
|
while cid in self._by_id and normalize_url(self._by_id[cid].get("url", "")) != norm:
|
||||||
|
cid += "x"
|
||||||
|
|
||||||
|
ann: Dict[str, Any] = {
|
||||||
|
"id": cid,
|
||||||
|
"type": "url_citation",
|
||||||
|
"title": title or url,
|
||||||
|
"url": url,
|
||||||
|
"domain": domain_of(url),
|
||||||
|
"snippet": (snippet or "")[:SNIPPET_MAX_CHARS],
|
||||||
|
}
|
||||||
|
if published_date:
|
||||||
|
ann["published_date"] = published_date
|
||||||
|
if source_tool:
|
||||||
|
ann["source_tool"] = source_tool
|
||||||
|
self._by_id[cid] = ann
|
||||||
|
self._id_by_url[norm] = cid
|
||||||
|
return ann
|
||||||
|
|
||||||
|
def resolve(self, citation_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
return self._by_id.get(citation_id)
|
||||||
|
|
||||||
|
|
||||||
|
def get_registry(terminal: Any) -> CitationRegistry:
|
||||||
|
"""取 terminal 实例上的会话级 registry(懒创建)。"""
|
||||||
|
reg = getattr(terminal, "_citation_registry", None)
|
||||||
|
if reg is None:
|
||||||
|
reg = CitationRegistry()
|
||||||
|
terminal._citation_registry = reg
|
||||||
|
return reg
|
||||||
|
|
||||||
|
|
||||||
|
def parse_marker_content(content: str) -> List[str]:
|
||||||
|
"""拆分 marker 内容为引用 token 列表(支持中英文逗号分隔)。"""
|
||||||
|
parts = re.split(r"[,,]", content or "")
|
||||||
|
return [p.strip() for p in parts if p.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def extract_citation_refs(text: str) -> List[str]:
|
||||||
|
"""提取正文中全部完整 marker 的引用 token(保持出现顺序,去重)。"""
|
||||||
|
refs: List[str] = []
|
||||||
|
for m in CITATION_MARK_RE.finditer(text or ""):
|
||||||
|
for token in parse_marker_content(m.group(2)):
|
||||||
|
if token not in refs:
|
||||||
|
refs.append(token)
|
||||||
|
return refs
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_file_ref(token: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""解析文件引用 token(裸相对路径,可带 #L120-148 / #p7 locator)。非法返回 None。"""
|
||||||
|
body = token.strip()
|
||||||
|
if not body:
|
||||||
|
return None
|
||||||
|
m = _LOCATOR_RE.match(body)
|
||||||
|
if m:
|
||||||
|
ref: Dict[str, Any] = {"path": m.group("path")}
|
||||||
|
if m.group("page"):
|
||||||
|
ref["page"] = int(m.group("page"))
|
||||||
|
if m.group("line_start"):
|
||||||
|
ref["line_start"] = int(m.group("line_start"))
|
||||||
|
if m.group("line_end"):
|
||||||
|
ref["line_end"] = int(m.group("line_end"))
|
||||||
|
return ref
|
||||||
|
return {"path": body}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_file_annotation(ref: Dict[str, Any], token: str, workspace_root: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""校验文件引用:路径必须落在工作区内且文件存在;富化 file_name/size。
|
||||||
|
|
||||||
|
annotation id 与 marker 内 token 完全一致,前端按 id 精确匹配。
|
||||||
|
"""
|
||||||
|
# 注意不能用 lstrip("./"):它是字符集语义,会把 .astrion 这类隐藏目录的前导点吃掉
|
||||||
|
rel_path = (ref.get("path") or "").strip()
|
||||||
|
if rel_path.startswith("./"):
|
||||||
|
rel_path = rel_path[2:]
|
||||||
|
if not rel_path or rel_path.startswith("/"):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
root = Path(workspace_root).resolve()
|
||||||
|
target = (root / rel_path).resolve()
|
||||||
|
# 边界检查:必须在工作区内
|
||||||
|
if root != target and root not in target.parents:
|
||||||
|
return None
|
||||||
|
if not target.is_file():
|
||||||
|
return None
|
||||||
|
stat = target.stat()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
ann: Dict[str, Any] = {
|
||||||
|
"id": token,
|
||||||
|
"type": "file_citation",
|
||||||
|
"file_path": rel_path,
|
||||||
|
"file_name": target.name,
|
||||||
|
"size": stat.st_size,
|
||||||
|
}
|
||||||
|
snippet: Optional[str] = None
|
||||||
|
# 图片文件的 snippet 无意义(且 svg 这类文本型图片会把标记源码当摘要),
|
||||||
|
# 前端弹层按扩展名直接渲染 <img>
|
||||||
|
if target.suffix.lower() not in _IMAGE_EXTS:
|
||||||
|
snippet = _read_file_snippet(target, ref)
|
||||||
|
if snippet:
|
||||||
|
ann["snippet"] = snippet
|
||||||
|
if ref.get("page") is not None:
|
||||||
|
ann["page"] = ref["page"]
|
||||||
|
if ref.get("line_start") is not None:
|
||||||
|
ann["line_start"] = ref["line_start"]
|
||||||
|
if ref.get("line_end") is not None:
|
||||||
|
ann["line_end"] = ref["line_end"]
|
||||||
|
return ann
|
||||||
|
|
||||||
|
|
||||||
|
def finalize_message_citations(
|
||||||
|
text: str,
|
||||||
|
registry: Optional[CitationRegistry],
|
||||||
|
workspace_root: str,
|
||||||
|
) -> Tuple[str, List[Dict[str, Any]]]:
|
||||||
|
"""assistant 消息落库前处理:剥离无效 marker,返回 (clean_text, used_annotations)。
|
||||||
|
|
||||||
|
- src_xxx:registry 查得到才保留,否则剥离 marker;
|
||||||
|
- file: 引用:路径在工作区内且文件存在才保留;
|
||||||
|
- 一个 marker 里部分有效时,只保留有效 token 重建 marker;全部无效则整体移除。
|
||||||
|
"""
|
||||||
|
if not text or ("【cite:" not in text and "【file:" not in text):
|
||||||
|
return text, []
|
||||||
|
|
||||||
|
used: List[Dict[str, Any]] = []
|
||||||
|
seen_ids = set()
|
||||||
|
|
||||||
|
def _keep(ann: Optional[Dict[str, Any]]) -> Optional[str]:
|
||||||
|
if not ann:
|
||||||
|
return None
|
||||||
|
ann_id = ann.get("id")
|
||||||
|
if ann_id and ann_id not in seen_ids:
|
||||||
|
seen_ids.add(ann_id)
|
||||||
|
used.append(ann)
|
||||||
|
return ann_id
|
||||||
|
|
||||||
|
def _replace(m: re.Match) -> str:
|
||||||
|
kind = m.group(1)
|
||||||
|
tokens = parse_marker_content(m.group(2))
|
||||||
|
kept: List[str] = []
|
||||||
|
for token in tokens:
|
||||||
|
if kind == "cite":
|
||||||
|
# 网页来源:src_xxx 且 registry 查得到才保留
|
||||||
|
if not _SRC_ID_RE.match(token):
|
||||||
|
continue
|
||||||
|
ann = registry.resolve(token) if registry else None
|
||||||
|
if _keep(ann):
|
||||||
|
kept.append(token)
|
||||||
|
else:
|
||||||
|
# 文件来源:路径在工作区内且存在才保留
|
||||||
|
ref = _parse_file_ref(token)
|
||||||
|
if not ref:
|
||||||
|
continue
|
||||||
|
ann = _build_file_annotation(ref, token, workspace_root)
|
||||||
|
if _keep(ann):
|
||||||
|
kept.append(token)
|
||||||
|
if not kept:
|
||||||
|
return ""
|
||||||
|
return "【" + kind + ":" + ",".join(kept) + "】"
|
||||||
|
|
||||||
|
clean = CITATION_MARK_RE.sub(_replace, text)
|
||||||
|
return clean, used
|
||||||
@ -6,6 +6,7 @@ from typing import Dict, Optional, Any, List
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import re
|
import re
|
||||||
|
from urllib.parse import urlparse
|
||||||
try:
|
try:
|
||||||
from config import TAVILY_API_KEY, SEARCH_MAX_RESULTS, OUTPUT_FORMATS, DATA_DIR
|
from config import TAVILY_API_KEY, SEARCH_MAX_RESULTS, OUTPUT_FORMATS, DATA_DIR
|
||||||
except ImportError:
|
except ImportError:
|
||||||
@ -150,10 +151,12 @@ class SearchEngine:
|
|||||||
|
|
||||||
# 处理每个搜索结果
|
# 处理每个搜索结果
|
||||||
for idx, result in enumerate(raw_data.get("results", []), 1):
|
for idx, result in enumerate(raw_data.get("results", []), 1):
|
||||||
|
url = result.get("url", "")
|
||||||
formatted_result = {
|
formatted_result = {
|
||||||
"index": idx,
|
"index": idx,
|
||||||
"title": result.get("title", "无标题"),
|
"title": result.get("title", "无标题"),
|
||||||
"url": result.get("url", ""),
|
"url": url,
|
||||||
|
"domain": urlparse(url).netloc.lower() if url else "",
|
||||||
"content": result.get("content", ""),
|
"content": result.get("content", ""),
|
||||||
"score": result.get("score", 0),
|
"score": result.get("score", 0),
|
||||||
"published_date": result.get("published_date", "")
|
"published_date": result.get("published_date", "")
|
||||||
@ -161,6 +164,48 @@ class SearchEngine:
|
|||||||
formatted["results"].append(formatted_result)
|
formatted["results"].append(formatted_result)
|
||||||
|
|
||||||
return formatted
|
return formatted
|
||||||
|
|
||||||
|
def build_summary_text(
|
||||||
|
self,
|
||||||
|
query: str,
|
||||||
|
results: List[Dict[str, Any]],
|
||||||
|
filters: Dict[str, Any],
|
||||||
|
timestamp: str
|
||||||
|
) -> str:
|
||||||
|
"""构建给模型看的搜索摘要文本。
|
||||||
|
|
||||||
|
若结果项带 citation_id(tools_execution 注册 citation 后回填),
|
||||||
|
标题行会带 [src_xxx] 前缀,供模型在行内引用中使用。
|
||||||
|
"""
|
||||||
|
summary_lines = [
|
||||||
|
f"🔍 搜索查询: {query}",
|
||||||
|
f"📅 搜索时间: {timestamp}"
|
||||||
|
]
|
||||||
|
|
||||||
|
filter_notes = self._summarize_filters(filters or {})
|
||||||
|
if filter_notes:
|
||||||
|
summary_lines.append(filter_notes)
|
||||||
|
summary_lines.append("")
|
||||||
|
|
||||||
|
# 添加搜索结果
|
||||||
|
if results:
|
||||||
|
summary_lines.append("📊 搜索结果:")
|
||||||
|
|
||||||
|
for result in results:
|
||||||
|
cid = result.get("citation_id")
|
||||||
|
title_line = f"\n{result['index']}. [{cid}] {result['title']}" if cid else f"\n{result['index']}. {result['title']}"
|
||||||
|
summary_lines.extend([
|
||||||
|
title_line,
|
||||||
|
f" 🔗 {result['url']}",
|
||||||
|
f" 📄 {result['content'][:200]}..." if len(result['content']) > 200 else f" 📄 {result['content']}",
|
||||||
|
])
|
||||||
|
|
||||||
|
if result.get("published_date"):
|
||||||
|
summary_lines.append(f" 📅 发布时间: {result['published_date']}")
|
||||||
|
else:
|
||||||
|
summary_lines.append("未找到相关结果")
|
||||||
|
|
||||||
|
return "\n".join(summary_lines)
|
||||||
|
|
||||||
async def search_with_summary(
|
async def search_with_summary(
|
||||||
self,
|
self,
|
||||||
@ -203,36 +248,15 @@ class SearchEngine:
|
|||||||
"summary": ""
|
"summary": ""
|
||||||
}
|
}
|
||||||
|
|
||||||
# 构建摘要
|
|
||||||
summary_lines = [
|
|
||||||
f"🔍 搜索查询: {query}",
|
|
||||||
f"📅 搜索时间: {results['timestamp']}"
|
|
||||||
]
|
|
||||||
|
|
||||||
filter_notes = self._summarize_filters(results.get("filters", {}))
|
|
||||||
if filter_notes:
|
|
||||||
summary_lines.append(filter_notes)
|
|
||||||
summary_lines.append("")
|
|
||||||
|
|
||||||
# 添加搜索结果
|
|
||||||
if results["results"]:
|
|
||||||
summary_lines.append("📊 搜索结果:")
|
|
||||||
|
|
||||||
for result in results["results"]:
|
|
||||||
summary_lines.extend([
|
|
||||||
f"\n{result['index']}. {result['title']}",
|
|
||||||
f" 🔗 {result['url']}",
|
|
||||||
f" 📄 {result['content'][:200]}..." if len(result['content']) > 200 else f" 📄 {result['content']}",
|
|
||||||
])
|
|
||||||
|
|
||||||
if result.get("published_date"):
|
|
||||||
summary_lines.append(f" 📅 发布时间: {result['published_date']}")
|
|
||||||
else:
|
|
||||||
summary_lines.append("未找到相关结果")
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"summary": "\n".join(summary_lines),
|
"summary": self.build_summary_text(
|
||||||
|
query,
|
||||||
|
results["results"],
|
||||||
|
results.get("filters", {}),
|
||||||
|
results["timestamp"]
|
||||||
|
),
|
||||||
|
"timestamp": results["timestamp"],
|
||||||
"filters": results.get("filters", {}),
|
"filters": results.get("filters", {}),
|
||||||
"query": results.get("query", query),
|
"query": results.get("query", query),
|
||||||
"results": results.get("results", []),
|
"results": results.get("results", []),
|
||||||
|
|||||||
@ -111,6 +111,29 @@
|
|||||||
|
|
||||||
注意:路径必须是工作区内已存在的文件。输出前确保文件已通过 `write_file` 或其他方式创建。
|
注意:路径必须是工作区内已存在的文件。输出前确保文件已通过 `write_file` 或其他方式创建。
|
||||||
|
|
||||||
|
### 3.1.6 行内引用(Citation)
|
||||||
|
|
||||||
|
当你使用网页搜索结果、文件内容或其他外部资料回答时,必须在对应事实陈述之后添加引用标记,前端会渲染为可点击的来源胶囊。用户明确要求「引用」某个文件/来源时,同样必须输出标记——用加粗、行内代码或表格提到文件名都不算引用。
|
||||||
|
|
||||||
|
标记格式:
|
||||||
|
- 网页来源:【cite:src_xxx】(src_xxx 必须来自本次对话工具结果中明确提供的引用 ID,如 `[src_xxx]` 前缀或「来源 ID: src_xxx」)
|
||||||
|
- 文件来源:【file:工作区相对路径】,例如 【file:AGENTS.md】;指向具体行段:【file:AGENTS.md#L120-148】
|
||||||
|
- 图片等二进制文件同样用 file 标记引用(如 【file:.astrion/user_upload/xxx.png】),前端会渲染缩略预览
|
||||||
|
- 一个陈述引用多个来源:同类逗号并列【cite:src_xxx,src_yyy】/【file:report.pdf,notes.md】;网页与文件混排相邻写两个标记,例如 【cite:src_xxx】【file:report.pdf】
|
||||||
|
|
||||||
|
引用要求:
|
||||||
|
1. 只引用实际支持当前陈述的来源,不引用无关来源
|
||||||
|
2. 引用紧跟在相关句子或段落末尾,不要统一放在回答结尾或集中列「参考资料」(除非用户要求)
|
||||||
|
3. 没有使用外部资料时不要添加引用,也不要逐句标注
|
||||||
|
4. 只能使用工具结果明确提供的 src_xxx 和工作区内真实存在的文件相对路径,禁止编造;标记内禁止写 URL
|
||||||
|
5. 代码块和行内代码中的标记不会被渲染,不要在代码示例里放引用
|
||||||
|
|
||||||
|
示例:
|
||||||
|
- 该功能在 v2.3 版本引入。【cite:src_abc123def4】
|
||||||
|
- 两个项目的实现方案一致。【cite:src_abc123def4,src_xyz987abcd】
|
||||||
|
- 配置项说明见部署文档。【file:docs/deploy.md#L45-60】
|
||||||
|
- 这一结论同时有网页和本地文档支持。【cite:src_abc123def4】【file:docs/deploy.md】
|
||||||
|
|
||||||
### 3.2 文件操作
|
### 3.2 文件操作
|
||||||
|
|
||||||
- `write_file`:写入文件(`append` 控制覆盖/追加)
|
- `write_file`:写入文件(`append` 控制覆盖/追加)
|
||||||
|
|||||||
@ -111,6 +111,29 @@
|
|||||||
|
|
||||||
注意:路径必须是工作区内已存在的文件。输出前确保文件已通过 `write_file` 或其他方式创建。
|
注意:路径必须是工作区内已存在的文件。输出前确保文件已通过 `write_file` 或其他方式创建。
|
||||||
|
|
||||||
|
### 3.1.6 行内引用(Citation)
|
||||||
|
|
||||||
|
当你使用网页搜索结果、文件内容或其他外部资料回答时,必须在对应事实陈述之后添加引用标记,前端会渲染为可点击的来源胶囊。用户明确要求「引用」某个文件/来源时,同样必须输出标记——用加粗、行内代码或表格提到文件名都不算引用。
|
||||||
|
|
||||||
|
标记格式:
|
||||||
|
- 网页来源:【cite:src_xxx】(src_xxx 必须来自本次对话工具结果中明确提供的引用 ID,如 `[src_xxx]` 前缀或「来源 ID: src_xxx」)
|
||||||
|
- 文件来源:【file:工作区相对路径】,例如 【file:AGENTS.md】;指向具体行段:【file:AGENTS.md#L120-148】
|
||||||
|
- 图片等二进制文件同样用 file 标记引用(如 【file:.astrion/user_upload/xxx.png】),前端会渲染缩略预览
|
||||||
|
- 一个陈述引用多个来源:同类逗号并列【cite:src_xxx,src_yyy】/【file:report.pdf,notes.md】;网页与文件混排相邻写两个标记,例如 【cite:src_xxx】【file:report.pdf】
|
||||||
|
|
||||||
|
引用要求:
|
||||||
|
1. 只引用实际支持当前陈述的来源,不引用无关来源
|
||||||
|
2. 引用紧跟在相关句子或段落末尾,不要统一放在回答结尾或集中列「参考资料」(除非用户要求)
|
||||||
|
3. 没有使用外部资料时不要添加引用,也不要逐句标注
|
||||||
|
4. 只能使用工具结果明确提供的 src_xxx 和工作区内真实存在的文件相对路径,禁止编造;标记内禁止写 URL
|
||||||
|
5. 代码块和行内代码中的标记不会被渲染,不要在代码示例里放引用
|
||||||
|
|
||||||
|
示例:
|
||||||
|
- 该功能在 v2.3 版本引入。【cite:src_abc123def4】
|
||||||
|
- 两个项目的实现方案一致。【cite:src_abc123def4,src_xyz987abcd】
|
||||||
|
- 配置项说明见部署文档。【file:docs/deploy.md#L45-60】
|
||||||
|
- 这一结论同时有网页和本地文档支持。【cite:src_abc123def4】【file:docs/deploy.md】
|
||||||
|
|
||||||
### 3.2 文件操作
|
### 3.2 文件操作
|
||||||
|
|
||||||
- `write_file`:写入文件(`append` 控制覆盖/追加)
|
- `write_file`:写入文件(`append` 控制覆盖/追加)
|
||||||
|
|||||||
@ -283,6 +283,29 @@ id: ask_fse_001
|
|||||||
|
|
||||||
注意:路径必须是工作区内已存在的文件。输出前确保文件已通过 `write_file` 或其他方式创建。
|
注意:路径必须是工作区内已存在的文件。输出前确保文件已通过 `write_file` 或其他方式创建。
|
||||||
|
|
||||||
|
### 行内引用(Citation)
|
||||||
|
|
||||||
|
你是最终向用户汇报的人。当你使用网页搜索结果、文件内容或其他外部资料回答时,必须在对应事实陈述之后添加引用标记,前端会渲染为可点击的来源胶囊。用户明确要求「引用」某个文件/来源时,同样必须输出标记——用加粗、行内代码或表格提到文件名都不算引用。
|
||||||
|
|
||||||
|
标记格式:
|
||||||
|
- 网页来源:【cite:src_xxx】(src_xxx 必须来自本次对话工具结果中明确提供的引用 ID,如 `[src_xxx]` 前缀或「来源 ID: src_xxx」;子智能体汇报中附带的来源 ID 同样可用)
|
||||||
|
- 文件来源:【file:工作区相对路径】,例如 【file:AGENTS.md】;指向具体行段:【file:AGENTS.md#L120-148】
|
||||||
|
- 图片等二进制文件同样用 file 标记引用(如 【file:.astrion/user_upload/xxx.png】),前端会渲染缩略预览
|
||||||
|
- 一个陈述引用多个来源:同类逗号并列【cite:src_xxx,src_yyy】/【file:report.pdf,notes.md】;网页与文件混排相邻写两个标记,例如 【cite:src_xxx】【file:report.pdf】
|
||||||
|
|
||||||
|
引用要求:
|
||||||
|
1. 只引用实际支持当前陈述的来源,不引用无关来源
|
||||||
|
2. 引用紧跟在相关句子或段落末尾,不要统一放在回答结尾或集中列「参考资料」(除非用户要求)
|
||||||
|
3. 没有使用外部资料时不要添加引用,也不要逐句标注
|
||||||
|
4. 只能使用工具结果或子智能体汇报中明确提供的 src_xxx 和工作区内真实存在的文件相对路径,禁止编造;标记内禁止写 URL
|
||||||
|
5. 代码块和行内代码中的标记不会被渲染,不要在代码示例里放引用
|
||||||
|
|
||||||
|
示例:
|
||||||
|
- 该功能在 v2.3 版本引入。【cite:src_abc123def4】
|
||||||
|
- 两个项目的实现方案一致。【cite:src_abc123def4,src_xyz987abcd】
|
||||||
|
- 配置项说明见部署文档。【file:docs/deploy.md#L45-60】
|
||||||
|
- 这一结论同时有网页和本地文档支持。【cite:src_abc123def4】【file:docs/deploy.md】
|
||||||
|
|
||||||
## 关于显示名
|
## 关于显示名
|
||||||
|
|
||||||
- 主智能体固定显示名:`Team Leader`
|
- 主智能体固定显示名:`Team Leader`
|
||||||
|
|||||||
@ -441,17 +441,48 @@ def terminal_page():
|
|||||||
return current_app.send_static_file('terminal.html')
|
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>
|
||||||
|
结构,会解析到一个空工作区,导致已上传文件 404(show_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>')
|
@auth_bp.route('/user_upload/<path:filename>')
|
||||||
@login_required
|
@login_required
|
||||||
def serve_user_upload(filename: str):
|
def serve_user_upload(filename: str):
|
||||||
user = get_current_user_record()
|
user = get_current_user_record()
|
||||||
if not user:
|
if not user:
|
||||||
return redirect('/login')
|
return redirect('/login')
|
||||||
workspace = state.user_manager.ensure_user_workspace(
|
uploads_dir, _project_root = _resolve_serving_workspace_roots(user)
|
||||||
user.username,
|
if not uploads_dir:
|
||||||
session.get("workspace_id") or "default",
|
abort(404)
|
||||||
)
|
|
||||||
uploads_dir = workspace.uploads_dir.resolve()
|
|
||||||
target = (uploads_dir / filename).resolve()
|
target = (uploads_dir / filename).resolve()
|
||||||
try:
|
try:
|
||||||
target.relative_to(uploads_dir)
|
target.relative_to(uploads_dir)
|
||||||
@ -468,11 +499,9 @@ def serve_workspace_file(filename: str):
|
|||||||
user = get_current_user_record()
|
user = get_current_user_record()
|
||||||
if not user:
|
if not user:
|
||||||
return redirect('/login')
|
return redirect('/login')
|
||||||
workspace = state.user_manager.ensure_user_workspace(
|
_uploads_dir, project_root = _resolve_serving_workspace_roots(user)
|
||||||
user.username,
|
if not project_root:
|
||||||
session.get("workspace_id") or "default",
|
abort(404)
|
||||||
)
|
|
||||||
project_root = workspace.project_path.resolve()
|
|
||||||
target = (project_root / filename).resolve()
|
target = (project_root / filename).resolve()
|
||||||
try:
|
try:
|
||||||
target.relative_to(project_root)
|
target.relative_to(project_root)
|
||||||
|
|||||||
@ -2034,6 +2034,8 @@ async def handle_task_with_sender(
|
|||||||
# 统计和限制变量
|
# 统计和限制变量
|
||||||
total_iterations = 0
|
total_iterations = 0
|
||||||
total_tool_calls = 0
|
total_tool_calls = 0
|
||||||
|
# 行内引用:任务级已用来源累积(task_complete 时带给前端实时渲染),按 id 去重
|
||||||
|
task_citations: Dict[str, dict] = {}
|
||||||
consecutive_same_tool = defaultdict(int)
|
consecutive_same_tool = defaultdict(int)
|
||||||
last_tool_name = ""
|
last_tool_name = ""
|
||||||
auto_fix_attempts = 0
|
auto_fix_attempts = 0
|
||||||
@ -2312,6 +2314,23 @@ async def handle_task_with_sender(
|
|||||||
|
|
||||||
assistant_content = "\n".join(assistant_content_parts) if assistant_content_parts else ""
|
assistant_content = "\n".join(assistant_content_parts) if assistant_content_parts else ""
|
||||||
|
|
||||||
|
# 行内引用:校验 marker、剥离无效引用,已用来源挂到消息 metadata 一并落库
|
||||||
|
round_citations = []
|
||||||
|
if assistant_content and ("【cite:" in assistant_content or "【file:" in assistant_content):
|
||||||
|
try:
|
||||||
|
from modules.citations import finalize_message_citations, get_registry
|
||||||
|
assistant_content, round_citations = finalize_message_citations(
|
||||||
|
assistant_content,
|
||||||
|
get_registry(web_terminal),
|
||||||
|
str(getattr(web_terminal, "project_path", "") or ""),
|
||||||
|
)
|
||||||
|
for ann in round_citations:
|
||||||
|
if ann.get("id"):
|
||||||
|
task_citations[ann["id"]] = ann
|
||||||
|
except Exception as _cite_exc:
|
||||||
|
debug_log(f"[Citations] 行内引用处理失败,保留原文: {_cite_exc}")
|
||||||
|
round_citations = []
|
||||||
|
|
||||||
# 添加到消息历史(用于API继续对话,不保存到文件)
|
# 添加到消息历史(用于API继续对话,不保存到文件)
|
||||||
assistant_message = {
|
assistant_message = {
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
@ -2328,7 +2347,8 @@ async def handle_task_with_sender(
|
|||||||
"assistant",
|
"assistant",
|
||||||
assistant_content,
|
assistant_content,
|
||||||
tool_calls=tool_calls if tool_calls else None,
|
tool_calls=tool_calls if tool_calls else None,
|
||||||
reasoning_content=current_thinking or ""
|
reasoning_content=current_thinking or "",
|
||||||
|
metadata={"citations": round_citations} if round_citations else None
|
||||||
)
|
)
|
||||||
|
|
||||||
# 为下一轮迭代重置流状态标志,但保留 full_response 供上面保存使用
|
# 为下一轮迭代重置流状态标志,但保留 full_response 供上面保存使用
|
||||||
@ -2757,4 +2777,6 @@ async def handle_task_with_sender(
|
|||||||
# 前端就应保持运行态并继续轮询。
|
# 前端就应保持运行态并继续轮询。
|
||||||
'has_running_multi_agent': has_running_multi_agent or has_pending_ma_messages,
|
'has_running_multi_agent': has_running_multi_agent or has_pending_ma_messages,
|
||||||
'pending_runtime_guidance_messages': pending_runtime_guidance_messages,
|
'pending_runtime_guidance_messages': pending_runtime_guidance_messages,
|
||||||
|
# 行内引用:本任务全部已用来源,前端挂到当前 assistant 消息渲染 chip
|
||||||
|
'citations': list(task_citations.values()),
|
||||||
})
|
})
|
||||||
|
|||||||
@ -404,6 +404,7 @@
|
|||||||
<FilePreviewPanel
|
<FilePreviewPanel
|
||||||
v-if="!isMobileViewport"
|
v-if="!isMobileViewport"
|
||||||
/>
|
/>
|
||||||
|
<CitationPopover :host-mode="versioningHostMode" />
|
||||||
<div
|
<div
|
||||||
v-if="!isMobileViewport && (terminalPanelOpen || gitChangesPanelOpen)"
|
v-if="!isMobileViewport && (terminalPanelOpen || gitChangesPanelOpen)"
|
||||||
class="resize-handle resize-handle--right-panels"
|
class="resize-handle resize-handle--right-panels"
|
||||||
@ -843,6 +844,7 @@ import VideoPicker from './components/overlay/VideoPicker.vue';
|
|||||||
import ImageLightbox from './components/overlay/ImageLightbox.vue';
|
import ImageLightbox from './components/overlay/ImageLightbox.vue';
|
||||||
import QuickDock from './components/chat/quickdock/QuickDock.vue';
|
import QuickDock from './components/chat/quickdock/QuickDock.vue';
|
||||||
import FilePreviewPanel from './components/chat/quickdock/FilePreviewPanel.vue';
|
import FilePreviewPanel from './components/chat/quickdock/FilePreviewPanel.vue';
|
||||||
|
import CitationPopover from './components/chat/CitationPopover.vue';
|
||||||
import { useTutorialStore } from './stores/tutorial';
|
import { useTutorialStore } from './stores/tutorial';
|
||||||
import { usePersonalizationStore } from './stores/personalization';
|
import { usePersonalizationStore } from './stores/personalization';
|
||||||
|
|
||||||
|
|||||||
@ -49,7 +49,7 @@
|
|||||||
<transition name="fade">
|
<transition name="fade">
|
||||||
<div v-if="banner.message" class="banner" :class="banner.type">
|
<div v-if="banner.message" class="banner" :class="banner.type">
|
||||||
<span>{{ banner.message }}</span>
|
<span>{{ banner.message }}</span>
|
||||||
<button type="button" class="banner-close" @click="banner.message = ''">×</button>
|
<CloseButton :label="$t('common.close')" @click="banner.message = ''" />
|
||||||
</div>
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
|
|
||||||
@ -300,6 +300,7 @@ import { computed, reactive, ref, onMounted, onBeforeUnmount, watch } from 'vue'
|
|||||||
import { useSecondaryPass } from './useSecondaryPass';
|
import { useSecondaryPass } from './useSecondaryPass';
|
||||||
import SecondaryGate from './SecondaryGate.vue';
|
import SecondaryGate from './SecondaryGate.vue';
|
||||||
import FancyCheck from '@/components/common/FancyCheck.vue';
|
import FancyCheck from '@/components/common/FancyCheck.vue';
|
||||||
|
import CloseButton from '@/components/common/CloseButton.vue';
|
||||||
import { t, currentLocale } from '@/locales';
|
import { t, currentLocale } from '@/locales';
|
||||||
|
|
||||||
type TargetType = 'global' | 'role' | 'user' | 'invite';
|
type TargetType = 'global' | 'role' | 'user' | 'invite';
|
||||||
@ -1061,14 +1062,6 @@ onBeforeUnmount(() => {
|
|||||||
border-color: rgba(189, 93, 58, 0.35);
|
border-color: rgba(189, 93, 58, 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
.banner-close {
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 16px;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
background: rgba(255, 255, 255, 0.9);
|
background: rgba(255, 255, 255, 0.9);
|
||||||
border: 1px solid rgba(118, 103, 84, 0.2);
|
border: 1px solid rgba(118, 103, 84, 0.2);
|
||||||
|
|||||||
@ -2,8 +2,9 @@
|
|||||||
import katex from 'katex';
|
import katex from 'katex';
|
||||||
import DOMPurify from 'dompurify';
|
import DOMPurify from 'dompurify';
|
||||||
import { createApp } from 'vue';
|
import { createApp } from 'vue';
|
||||||
import { t } from '@/locales';
|
import { t, i18n } from '@/locales';
|
||||||
import ShowFileCard from '../components/chat/ShowFileCard.vue';
|
import ShowFileCard from '../components/chat/ShowFileCard.vue';
|
||||||
|
import { useUiStore } from '@/stores/ui';
|
||||||
import { buildShowHtmlIframeSrcdoc } from '../utils/showHtmlSandbox';
|
import { buildShowHtmlIframeSrcdoc } from '../utils/showHtmlSandbox';
|
||||||
import {
|
import {
|
||||||
openShowHtmlFullscreen,
|
openShowHtmlFullscreen,
|
||||||
@ -174,10 +175,15 @@ function normalizeShowImageSrc(src: string) {
|
|||||||
trimmed = trimmed.replace(/^\/(?=[A-Za-z]:\/)/, '');
|
trimmed = trimmed.replace(/^\/(?=[A-Za-z]:\/)/, '');
|
||||||
}
|
}
|
||||||
if (trimmed.startsWith('/user_upload/')) return trimmed;
|
if (trimmed.startsWith('/user_upload/')) return trimmed;
|
||||||
// 兼容容器内部路径:/workspace/.../user_upload/xxx.png 或 /workspace/user_upload/xxx
|
// 兼容容器内部绝对路径:/workspace/.../user_upload/xxx.png 或 /workspace/user_upload/xxx
|
||||||
const idx = trimmed.toLowerCase().indexOf('/user_upload/');
|
// 注意:.astrion/user_upload/xxx 是工作区相对路径,不在此特判,
|
||||||
if (idx >= 0) {
|
// 让它落到下方通用分支走 /api/file/content(与 _validate_path 同源解析,
|
||||||
return '/user_upload/' + trimmed.slice(idx + '/user_upload/'.length);
|
// 避免依赖 /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 前缀是容器内绝对路径写法,剥掉后按工作区相对路径处理
|
// /workspace 前缀是容器内绝对路径写法,剥掉后按工作区相对路径处理
|
||||||
if (trimmed === '/workspace') return '';
|
if (trimmed === '/workspace') return '';
|
||||||
@ -1256,6 +1262,36 @@ function buildNodePathKey(node: Element) {
|
|||||||
return parts.reverse().join('/');
|
return parts.reverse().join('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 内容图片点击预览(document 级事件委托) =====
|
||||||
|
// 覆盖三类无 Vue 事件绑定的注入图片:
|
||||||
|
// 1. <show_image> 卡片(figure.chat-inline-image,renderShowImages 动态创建)
|
||||||
|
// 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() {
|
export function setupShowImageObserver() {
|
||||||
if (showImageObserver || showContainerObserver) return;
|
if (showImageObserver || showContainerObserver) return;
|
||||||
setupLayoutDebugObservers();
|
setupLayoutDebugObservers();
|
||||||
@ -1471,6 +1507,9 @@ function renderShowFileCard(node: Element) {
|
|||||||
node.replaceChildren(mountEl);
|
node.replaceChildren(mountEl);
|
||||||
|
|
||||||
const app = createApp(ShowFileCard, { path });
|
const app = createApp(ShowFileCard, { path });
|
||||||
|
// 独立 createApp 实例不继承主应用的插件,必须显式安装 i18n,
|
||||||
|
// 否则组件模板里的 $t 不可用(报 "j.$t is not a function")
|
||||||
|
app.use(i18n);
|
||||||
app.mount(mountEl);
|
app.mount(mountEl);
|
||||||
showFileAppMap.set(node, app);
|
showFileAppMap.set(node, app);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
import { useChatActionStore } from '../stores/chatActions';
|
import { useChatActionStore } from '../stores/chatActions';
|
||||||
import { useSandboxSetupStore } from '../stores/sandboxSetup';
|
import { useSandboxSetupStore } from '../stores/sandboxSetup';
|
||||||
import { normalizeScrollLock } from '../composables/useScrollControl';
|
import { normalizeScrollLock } from '../composables/useScrollControl';
|
||||||
import { setupShowImageObserver, teardownShowImageObserver } from './bootstrap';
|
import { setupShowImageObserver, teardownShowImageObserver, setupImagePreviewDelegation } from './bootstrap';
|
||||||
import { debugLog } from './methods/common';
|
import { debugLog } from './methods/common';
|
||||||
|
|
||||||
export function created() {
|
export function created() {
|
||||||
@ -41,6 +41,7 @@ export async function mounted() {
|
|||||||
normalizeScrollLock(this);
|
normalizeScrollLock(this);
|
||||||
});
|
});
|
||||||
setupShowImageObserver();
|
setupShowImageObserver();
|
||||||
|
setupImagePreviewDelegation();
|
||||||
|
|
||||||
// 立即加载初始数据(并行获取状态,优先同步运行模式)
|
// 立即加载初始数据(并行获取状态,优先同步运行模式)
|
||||||
const initialDataPromise = this.loadInitialData();
|
const initialDataPromise = this.loadInitialData();
|
||||||
|
|||||||
@ -318,6 +318,26 @@ export const historyMethods = {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 行内引用:工具循环的多条 assistant 持久化消息会合并成同一条前端消息,
|
||||||
|
// citations 需随合并按 id 去重汇聚,否则 chip 无数据可渲染
|
||||||
|
const msgCitations = message.metadata?.citations;
|
||||||
|
if (Array.isArray(msgCitations) && msgCitations.length) {
|
||||||
|
const merged = Array.isArray(currentAssistantMessage.metadata?.citations)
|
||||||
|
? [...currentAssistantMessage.metadata.citations]
|
||||||
|
: [];
|
||||||
|
const seen = new Set(merged.map((c) => c?.id));
|
||||||
|
msgCitations.forEach((c) => {
|
||||||
|
if (c && c.id && !seen.has(c.id)) {
|
||||||
|
seen.add(c.id);
|
||||||
|
merged.push(c);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
currentAssistantMessage.metadata = {
|
||||||
|
...(currentAssistantMessage.metadata || {}),
|
||||||
|
citations: merged
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const content = message.content || '';
|
const content = message.content || '';
|
||||||
const reasoningText = (message.reasoning_content || '').trim();
|
const reasoningText = (message.reasoning_content || '').trim();
|
||||||
|
|
||||||
|
|||||||
@ -446,6 +446,18 @@ export const lifecycleMethods = {
|
|||||||
debugLog('[TaskPolling] 任务完成');
|
debugLog('[TaskPolling] 任务完成');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 行内引用:任务完成事件带回权威来源(轮询通道;socket 通道在 useLegacySocket 同款处理),
|
||||||
|
// 挂到最后一条 assistant 消息 metadata,MarkdownRenderer watcher 据此做 chip 裁决与富化
|
||||||
|
if (Array.isArray(data?.citations) && Array.isArray(this.messages) && this.messages.length) {
|
||||||
|
const lastMsg = this.messages[this.messages.length - 1];
|
||||||
|
if (lastMsg && lastMsg.role === 'assistant') {
|
||||||
|
lastMsg.metadata = {
|
||||||
|
...(lastMsg.metadata || {}),
|
||||||
|
citations: data.citations
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 同步处理状态更新
|
// 同步处理状态更新
|
||||||
this.streamingMessage = false;
|
this.streamingMessage = false;
|
||||||
this.stopRequested = false;
|
this.stopRequested = false;
|
||||||
|
|||||||
@ -187,6 +187,9 @@
|
|||||||
<MinimalBlocks
|
<MinimalBlocks
|
||||||
v-if="hasRenderableAssistantActions(msg.actions || [])"
|
v-if="hasRenderableAssistantActions(msg.actions || [])"
|
||||||
:actions="msg.actions || []"
|
:actions="msg.actions || []"
|
||||||
|
:citations="citationsForMessage(msg)"
|
||||||
|
:citations-final="citationsFinalForMessage(msg)"
|
||||||
|
enable-citations
|
||||||
:conversation-running="streamingMessage"
|
:conversation-running="streamingMessage"
|
||||||
:is-latest-message="index === latestMessageIndex"
|
:is-latest-message="index === latestMessageIndex"
|
||||||
:icon-style="iconStyleSafe"
|
:icon-style="iconStyleSafe"
|
||||||
@ -308,6 +311,9 @@
|
|||||||
<MarkdownRenderer
|
<MarkdownRenderer
|
||||||
:content="group.action.content || ''"
|
:content="group.action.content || ''"
|
||||||
:is-streaming="group.action.streaming"
|
:is-streaming="group.action.streaming"
|
||||||
|
:citations="citationsForMessage(msg)"
|
||||||
|
:citations-final="citationsFinalForMessage(msg)"
|
||||||
|
enable-citations
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -533,6 +539,9 @@
|
|||||||
<MarkdownRenderer
|
<MarkdownRenderer
|
||||||
:content="action.content || ''"
|
:content="action.content || ''"
|
||||||
:is-streaming="action.streaming"
|
:is-streaming="action.streaming"
|
||||||
|
:citations="citationsForMessage(msg)"
|
||||||
|
:citations-final="citationsFinalForMessage(msg)"
|
||||||
|
enable-citations
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -739,6 +748,7 @@ import ToolAction from '@/components/chat/actions/ToolAction.vue';
|
|||||||
import StackedBlocks from './StackedBlocks.vue';
|
import StackedBlocks from './StackedBlocks.vue';
|
||||||
import MinimalBlocks from './MinimalBlocks.vue';
|
import MinimalBlocks from './MinimalBlocks.vue';
|
||||||
import MarkdownRenderer from './MarkdownRenderer.vue';
|
import MarkdownRenderer from './MarkdownRenderer.vue';
|
||||||
|
import { collectConversationCitations, type CitationAnnotation } from './citationChips';
|
||||||
import EditSummaryCard from './EditSummaryCard.vue';
|
import EditSummaryCard from './EditSummaryCard.vue';
|
||||||
import { usePersonalizationStore } from '@/stores/personalization';
|
import { usePersonalizationStore } from '@/stores/personalization';
|
||||||
import { useUiStore } from '@/stores/ui';
|
import { useUiStore } from '@/stores/ui';
|
||||||
@ -778,6 +788,22 @@ const personalizationReady = computed(() => {
|
|||||||
const renderPending = computed(() => {
|
const renderPending = computed(() => {
|
||||||
return !!props.historyLoading || !personalizationReady.value;
|
return !!props.historyLoading || !personalizationReady.value;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 行内引用:对话级来源收集(扫描所有消息的工具结果 result.citations)。
|
||||||
|
// 工具完成先于模型输出 marker,chip 在输出瞬间即可解析渲染,不等任务完成。
|
||||||
|
const collectedCitations = computed<CitationAnnotation[]>(() =>
|
||||||
|
collectConversationCitations(props.messages || [])
|
||||||
|
);
|
||||||
|
/** 该消息可用的引用来源:权威(metadata.citations,后端裁决富化)优先,否则用收集表 */
|
||||||
|
function citationsForMessage(msg: any): CitationAnnotation[] | undefined {
|
||||||
|
const meta = msg?.metadata?.citations;
|
||||||
|
if (Array.isArray(meta)) return meta;
|
||||||
|
return collectedCitations.value.length ? collectedCitations.value : undefined;
|
||||||
|
}
|
||||||
|
/** metadata.citations 到达即为权威裁决(含空数组=全部无效),触发无效 chip 移除与富化 */
|
||||||
|
function citationsFinalForMessage(msg: any): boolean {
|
||||||
|
return Array.isArray(msg?.metadata?.citations);
|
||||||
|
}
|
||||||
const blockDisplayMode = computed(() => {
|
const blockDisplayMode = computed(() => {
|
||||||
return personalization.experiments.blockDisplayMode || 'stacked';
|
return personalization.experiments.blockDisplayMode || 'stacked';
|
||||||
});
|
});
|
||||||
|
|||||||
577
static/src/components/chat/CitationPopover.vue
Normal file
577
static/src/components/chat/CitationPopover.vue
Normal 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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
const FILE_ICON_SVG =
|
||||||
|
'<svg class="chip-file-icon" viewBox="0 0 16 16" fill="none">' +
|
||||||
|
'<path d="M4 1.5h5.5L13 5v9.5H4z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/>' +
|
||||||
|
'<path d="M9.5 1.5V5H13" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>';
|
||||||
|
|
||||||
|
function iconHtml(ann: CitationAnnotation): string {
|
||||||
|
if (isFile(ann)) return FILE_ICON_SVG;
|
||||||
|
const d = shortDomain(ann.domain);
|
||||||
|
const letter = (d[0] || '?').toUpperCase();
|
||||||
|
return (
|
||||||
|
`<img class="chip-favicon" loading="lazy" alt="" ` +
|
||||||
|
`src="https://www.google.com/s2/favicons?domain=${encodeURIComponent(d)}&sz=64" ` +
|
||||||
|
`onerror="this.outerHTML='<span class="chip-letter">${letter}</span>'">`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function headerText(ann: CitationAnnotation): string {
|
||||||
|
return isFile(ann) ? (ann.file_path || ann.file_name || '') : shortDomain(ann.domain);
|
||||||
|
}
|
||||||
|
|
||||||
|
function locatorText(ann: CitationAnnotation): string {
|
||||||
|
const bits: string[] = [];
|
||||||
|
if (ann.page != null) bits.push(`p.${ann.page}`);
|
||||||
|
if (ann.line_start != null) {
|
||||||
|
bits.push(
|
||||||
|
ann.line_end != null && ann.line_end !== ann.line_start
|
||||||
|
? `L${ann.line_start}–${ann.line_end}`
|
||||||
|
: `L${ann.line_start}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return bits.join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowSubText(ann: CitationAnnotation): string {
|
||||||
|
// 行号徽章已提到标题行,副行固定显示路径(网页显示域名)
|
||||||
|
if (isFile(ann)) return ann.file_path || '';
|
||||||
|
return shortDomain(ann.domain);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openFile(ann: CitationAnnotation) {
|
||||||
|
if (!ann.file_path || !canPreviewFile.value) return;
|
||||||
|
quickDock.openPreview(ann.file_path);
|
||||||
|
closeCitationPopover();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 文件内容片段懒加载 ----------
|
||||||
|
* 后端落库时已富化 snippet;但旧消息(富化前持久化)与流式期的临时 annotation
|
||||||
|
* 没有 snippet,这里在弹层打开时按 file_path 拉一次内容补齐。 */
|
||||||
|
const snippetCache = ref<Record<string, string>>({});
|
||||||
|
|
||||||
|
function displaySnippet(ann: CitationAnnotation): string {
|
||||||
|
return ann.snippet || snippetCache.value[ann.id] || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function maybeFetchSnippet(ann: CitationAnnotation) {
|
||||||
|
if (!isFile(ann) || isImageFile(ann) || ann.snippet || !ann.file_path || snippetCache.value[ann.id]) return;
|
||||||
|
const path = ann.file_path;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/file/content?path=${encodeURIComponent(path)}`);
|
||||||
|
if (!resp.ok) return;
|
||||||
|
let text = await resp.text();
|
||||||
|
// 二进制守卫:空字节或大量替换字符则放弃
|
||||||
|
if (text.includes('\x00')) return;
|
||||||
|
const bad = (text.match(/\uFFFD/g) || []).length;
|
||||||
|
if (bad > Math.max(4, text.length * 0.005)) return;
|
||||||
|
if (ann.line_start != null) {
|
||||||
|
const lines = text.split('\n');
|
||||||
|
const s = Math.max(1, ann.line_start);
|
||||||
|
const e = Math.min(lines.length, ann.line_end ?? s);
|
||||||
|
text = lines.slice(s - 1, e).join('\n');
|
||||||
|
}
|
||||||
|
// 保留换行(弹层 pre-line 渲染),仅压缩行内空白、去空行
|
||||||
|
text = text
|
||||||
|
.split('\n')
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n')
|
||||||
|
.slice(0, 300);
|
||||||
|
if (text) {
|
||||||
|
snippetCache.value = { ...snippetCache.value, [ann.id]: text };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* 读取失败静默:弹层退化为仅路径/定位信息 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [citationPopover.visible, single.value?.id] as const,
|
||||||
|
([visible]) => {
|
||||||
|
if (visible && single.value) maybeFetchSnippet(single.value);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 文件卡片头部点击 → 复用右侧文件预览面板 */
|
||||||
|
function onHeaderClick(ann: CitationAnnotation) {
|
||||||
|
if (isFile(ann)) openFile(ann);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 在电脑上直接打开:复用 QuickDock 的链路(系统默认应用 = 候选列表第一个) */
|
||||||
|
async function openOnComputer(ann: CitationAnnotation) {
|
||||||
|
const path = ann.file_path;
|
||||||
|
if (!path) return;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/project/file-open-apps?path=${encodeURIComponent(path)}`);
|
||||||
|
const payload = await resp.json().catch(() => ({}));
|
||||||
|
if (!resp.ok || !payload?.success) {
|
||||||
|
throw new Error(payload?.error || t('quickdock.revealDetectAppsFailed'));
|
||||||
|
}
|
||||||
|
const apps = Array.isArray(payload?.data?.apps) ? payload.data.apps : [];
|
||||||
|
if (!apps.length) {
|
||||||
|
throw new Error(t('quickdock.revealNoAppsFound'));
|
||||||
|
}
|
||||||
|
const openResp = await fetch('/api/project/open-file-with-app', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ path, app_id: apps[0].id }),
|
||||||
|
});
|
||||||
|
const openPayload = await openResp.json().catch(() => ({}));
|
||||||
|
if (!openResp.ok || !openPayload?.success) {
|
||||||
|
throw new Error(openPayload?.error || t('quickdock.revealOpenFileFailed'));
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
uiStore.pushToast({ message: err?.message || t('quickdock.revealOpenFileFailed'), type: 'error' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closeCitationPopover();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRowClick(ann: CitationAnnotation) {
|
||||||
|
if (isFile(ann)) {
|
||||||
|
openFile(ann);
|
||||||
|
} else if (ann.url) {
|
||||||
|
window.open(ann.url, '_blank', 'noopener');
|
||||||
|
closeCitationPopover();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 定位:优先放胶囊下方,空间不足翻到上方;水平方向视口内夹取 */
|
||||||
|
function positionPopover() {
|
||||||
|
const anchor = citationPopover.anchor;
|
||||||
|
const el = popoverEl.value;
|
||||||
|
if (!anchor || !el) return;
|
||||||
|
const rect = anchor.getBoundingClientRect();
|
||||||
|
const width = 320;
|
||||||
|
const maxHeight = 300;
|
||||||
|
const height = Math.min(el.offsetHeight || 200, maxHeight);
|
||||||
|
let left = Math.min(Math.max(8, rect.left), window.innerWidth - width - 8);
|
||||||
|
let top = rect.bottom + 6;
|
||||||
|
if (top + height > window.innerHeight - 8) {
|
||||||
|
top = Math.max(8, rect.top - height - 6);
|
||||||
|
}
|
||||||
|
popoverStyle.value = { left: `${left}px`, top: `${top}px` };
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => citationPopover.visible,
|
||||||
|
async (visible) => {
|
||||||
|
if (visible) {
|
||||||
|
await nextTick();
|
||||||
|
positionPopover();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const onGlobalClick = (e: MouseEvent) => {
|
||||||
|
if (popoverEl.value && !popoverEl.value.contains(e.target as Node)) {
|
||||||
|
closeCitationPopover();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onKeydown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') closeCitationPopover();
|
||||||
|
};
|
||||||
|
// 页面一旦滚动就收起(含固定态),符合全局交互约定
|
||||||
|
const onScroll = () => closeCitationPopover();
|
||||||
|
const onResize = () => closeCitationPopover();
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('click', onGlobalClick, true);
|
||||||
|
document.addEventListener('keydown', onKeydown);
|
||||||
|
window.addEventListener('scroll', onScroll, true);
|
||||||
|
window.addEventListener('resize', onResize);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener('click', onGlobalClick, true);
|
||||||
|
document.removeEventListener('keydown', onKeydown);
|
||||||
|
window.removeEventListener('scroll', onScroll, true);
|
||||||
|
window.removeEventListener('resize', onResize);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 实体面板:不透明、无 backdrop-filter;中性阴影;颜色全走语义 token */
|
||||||
|
.citation-popover {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1000;
|
||||||
|
width: 320px;
|
||||||
|
max-height: 300px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--surface-card);
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-scroll {
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 0;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.pop-scroll::-webkit-scrollbar {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 12px;
|
||||||
|
flex: none;
|
||||||
|
border-bottom: 1px solid var(--border-default);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
.pop-header.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.pop-header.clickable:hover {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.pop-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.pop-domain {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-body {
|
||||||
|
padding: 10px 12px 12px;
|
||||||
|
}
|
||||||
|
.pop-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.pop-title {
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--text-primary);
|
||||||
|
min-width: 0;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.pop-url {
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.pop-locator {
|
||||||
|
flex: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 20px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--surface-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.pop-snippet {
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: pre-line; /* 保留文件内容换行 */
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 4;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.pop-image {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
.pop-image img {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 180px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.pop-footer {
|
||||||
|
flex: none;
|
||||||
|
height: 38px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-top: 1px solid var(--border-default);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.pop-open {
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 500;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.pop-open:hover {
|
||||||
|
color: var(--accent-hover);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-list-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
height: 44px;
|
||||||
|
padding: 0 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.pop-list-row:hover {
|
||||||
|
background: var(--hover-bg);
|
||||||
|
}
|
||||||
|
.pop-list-row + .pop-list-row {
|
||||||
|
border-top: 1px solid var(--border-default);
|
||||||
|
}
|
||||||
|
.pop-list-icon {
|
||||||
|
flex: none;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.pop-list-icon :deep(.chip-favicon) {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
.pop-list-icon :deep(.chip-file-icon) {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
color: var(--text-tertiary); /* 显式颜色绑定,stroke=currentColor 才能生效 */
|
||||||
|
}
|
||||||
|
.pop-list-text {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.pop-list-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
.pop-list-title-text {
|
||||||
|
min-width: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
/* 列表行内的小型行号徽章(44px 行高内容纳得下) */
|
||||||
|
.pop-locator--sm {
|
||||||
|
height: 16px;
|
||||||
|
padding: 0 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
.pop-list-domain {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-header :deep(.chip-favicon) {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.pop-header :deep(.chip-file-icon) {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
.pop-header :deep(.chip-letter),
|
||||||
|
.pop-list-icon :deep(.chip-letter) {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--on-accent);
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -17,6 +17,7 @@
|
|||||||
*/
|
*/
|
||||||
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
||||||
import { useUiStore } from '@/stores/ui';
|
import { useUiStore } from '@/stores/ui';
|
||||||
|
import CloseButton from '@/components/common/CloseButton.vue';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
summary: any;
|
summary: any;
|
||||||
@ -342,15 +343,12 @@ function lineNumber(line: any): string {
|
|||||||
<span class="edit-summary-plus">+{{ activeFile.added || 0 }}</span>
|
<span class="edit-summary-plus">+{{ activeFile.added || 0 }}</span>
|
||||||
<span class="edit-summary-minus">-{{ activeFile.removed || 0 }}</span>
|
<span class="edit-summary-minus">-{{ activeFile.removed || 0 }}</span>
|
||||||
</span>
|
</span>
|
||||||
<button
|
<CloseButton
|
||||||
v-if="pinned || isMobile"
|
v-if="pinned || isMobile"
|
||||||
type="button"
|
:label="$t('common.close')"
|
||||||
class="es-modal__close"
|
size="sm"
|
||||||
:aria-label="$t('common.close')"
|
|
||||||
@click.stop="close"
|
@click.stop="close"
|
||||||
>
|
/>
|
||||||
<span class="icon icon-sm" :style="props.iconStyle('x')" aria-hidden="true"></span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="es-modal__body">
|
<div class="es-modal__body">
|
||||||
<template v-if="(activeFile.lines || []).length">
|
<template v-if="(activeFile.lines || []).length">
|
||||||
@ -539,26 +537,6 @@ function lineNumber(line: any): string {
|
|||||||
font-variant-numeric: tabular-nums;
|
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 {
|
.es-modal__body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
|||||||
@ -32,12 +32,17 @@ import {
|
|||||||
type MarkdownSegment
|
type MarkdownSegment
|
||||||
} from '@/composables/useMarkdownRenderer';
|
} from '@/composables/useMarkdownRenderer';
|
||||||
import { chunkRenderedHtml, type HtmlChunk } from '@/utils/htmlChunks';
|
import { chunkRenderedHtml, type HtmlChunk } from '@/utils/htmlChunks';
|
||||||
|
import { enhanceCitationChips, type CitationAnnotation } from './citationChips';
|
||||||
|
|
||||||
defineOptions({ name: 'MarkdownRenderer' });
|
defineOptions({ name: 'MarkdownRenderer' });
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
content: string;
|
content: string;
|
||||||
isStreaming?: boolean;
|
isStreaming?: boolean;
|
||||||
|
citations?: CitationAnnotation[];
|
||||||
|
/** citations 是否为后端裁决后的权威版本(message.metadata.citations 已到达) */
|
||||||
|
citationsFinal?: boolean;
|
||||||
|
enableCitations?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const containerRef = ref<HTMLElement | null>(null);
|
const containerRef = ref<HTMLElement | null>(null);
|
||||||
@ -47,7 +52,8 @@ const segments = computed(() => parseMarkdownSegments(props.content || '', props
|
|||||||
|
|
||||||
function renderText(text: string) {
|
function renderText(text: string) {
|
||||||
// 透传流式标志:show_html 卡片在流式期间需要 partial 渲染(实时渲染/渲染中占位)
|
// 透传流式标志:show_html 卡片在流式期间需要 partial 渲染(实时渲染/渲染中占位)
|
||||||
return renderMarkdownText(text, props.isStreaming);
|
// enableCitations:仅 assistant 正文开启引用渲染,其他场景【cite:】按原文显示
|
||||||
|
return renderMarkdownText(text, props.isStreaming, props.enableCitations);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 分段级分块缓存:segments 每个 token 都是新对象,按 key 缓存避免对
|
// 分段级分块缓存:segments 每个 token 都是新对象,按 key 缓存避免对
|
||||||
@ -83,6 +89,22 @@ function renderMath() {
|
|||||||
onMounted(renderMath);
|
onMounted(renderMath);
|
||||||
onUpdated(renderMath);
|
onUpdated(renderMath);
|
||||||
watch(() => props.content, renderMath, { immediate: true });
|
watch(() => props.content, renderMath, { immediate: true });
|
||||||
|
|
||||||
|
// 行内引用:渲染后扫描 chip 占位 span,填充内容并接管交互。
|
||||||
|
// chip 在输出瞬间即解析(工具结果查表 / file token 自解析);
|
||||||
|
// citationsFinal 到达时做权威裁决(移除无效 chip)与富化。
|
||||||
|
function enhanceCitations() {
|
||||||
|
if (!props.enableCitations) return;
|
||||||
|
nextTick(() => {
|
||||||
|
if (containerRef.value) {
|
||||||
|
enhanceCitationChips(containerRef.value, props.citations, { final: props.citationsFinal });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(enhanceCitations);
|
||||||
|
onUpdated(enhanceCitations);
|
||||||
|
watch(() => [props.citations, props.citationsFinal], enhanceCitations);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@ -121,7 +121,7 @@
|
|||||||
:class="{ 'streaming-text': group.streaming }"
|
:class="{ 'streaming-text': group.streaming }"
|
||||||
>
|
>
|
||||||
<div class="text-content" :class="{ 'streaming-text': group.streaming }">
|
<div class="text-content" :class="{ 'streaming-text': group.streaming }">
|
||||||
<MarkdownRenderer :content="group.content || ''" :is-streaming="group.streaming" />
|
<MarkdownRenderer :content="group.content || ''" :is-streaming="group.streaming" :citations="citations" :citations-final="citationsFinal" :enable-citations="enableCitations" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="group.type === 'system'" class="summary-group sub-agent-system-group">
|
<div v-else-if="group.type === 'system'" class="summary-group sub-agent-system-group">
|
||||||
@ -159,6 +159,7 @@ const personalizationStore = usePersonalizationStore();
|
|||||||
const heightLimited = computed(() => personalizationStore.form.minimal_expand_height_limited);
|
const heightLimited = computed(() => personalizationStore.form.minimal_expand_height_limited);
|
||||||
import { getRandomLoader } from './loaders/index';
|
import { getRandomLoader } from './loaders/index';
|
||||||
import MarkdownRenderer from './MarkdownRenderer.vue';
|
import MarkdownRenderer from './MarkdownRenderer.vue';
|
||||||
|
import type { CitationAnnotation } from './citationChips';
|
||||||
|
|
||||||
interface Action {
|
interface Action {
|
||||||
id?: string;
|
id?: string;
|
||||||
@ -196,6 +197,9 @@ interface BlockGroup {
|
|||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
actions: Action[];
|
actions: Action[];
|
||||||
|
citations?: CitationAnnotation[];
|
||||||
|
citationsFinal?: boolean;
|
||||||
|
enableCitations?: boolean;
|
||||||
conversationRunning?: boolean;
|
conversationRunning?: boolean;
|
||||||
isLatestMessage?: boolean;
|
isLatestMessage?: boolean;
|
||||||
iconStyle?: (name: string) => any;
|
iconStyle?: (name: string) => any;
|
||||||
|
|||||||
@ -62,7 +62,7 @@
|
|||||||
:class="{ 'sfc-image--android': isAndroidApp }"
|
:class="{ 'sfc-image--android': isAndroidApp }"
|
||||||
:src="contentUrl"
|
:src="contentUrl"
|
||||||
:alt="displayName"
|
:alt="displayName"
|
||||||
@click="!isAndroidApp && openFullImage()"
|
@click="openFullImage()"
|
||||||
@error="onImageError"
|
@error="onImageError"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
@ -82,6 +82,7 @@ import { buildShowHtmlIframeSrcdoc } from '@/utils/showHtmlSandbox';
|
|||||||
import { openShowHtmlFullscreen } from '@/utils/showHtmlFullscreen';
|
import { openShowHtmlFullscreen } from '@/utils/showHtmlFullscreen';
|
||||||
import PdfPreview from './PdfPreview.vue';
|
import PdfPreview from './PdfPreview.vue';
|
||||||
import { t } from '@/locales';
|
import { t } from '@/locales';
|
||||||
|
import { useUiStore } from '@/stores/ui';
|
||||||
|
|
||||||
// 文本类文件内容缓存,避免父组件反复重建卡片时重复 fetch 导致闪烁
|
// 文本类文件内容缓存,避免父组件反复重建卡片时重复 fetch 导致闪烁
|
||||||
const SHOW_FILE_CONTENT_CACHE = new Map<string, { content: string; ts: number }>();
|
const SHOW_FILE_CONTENT_CACHE = new Map<string, { content: string; ts: number }>();
|
||||||
@ -430,7 +431,9 @@ async function copyContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openFullImage() {
|
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() {
|
function onImageError() {
|
||||||
|
|||||||
@ -768,7 +768,9 @@ function renderViewImage(result: any, args: any): string {
|
|||||||
|
|
||||||
if (imageUrl) {
|
if (imageUrl) {
|
||||||
const safeUrl = escapeHtml(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;
|
return html;
|
||||||
|
|||||||
275
static/src/components/chat/citationChips.ts
Normal file
275
static/src/components/chat/citationChips.ts
Normal file
@ -0,0 +1,275 @@
|
|||||||
|
/**
|
||||||
|
* 行内引用(Inline Citations):chip 增强与 popover 状态控制。
|
||||||
|
*
|
||||||
|
* 数据流:消息文本中的【cite:...】marker 经 useMarkdownRenderer 转换为
|
||||||
|
* <span class="md-citation-chip" data-citation-ids="...">,本模块在
|
||||||
|
* MarkdownRenderer 渲染后扫描容器,用 message.metadata.citations 填充
|
||||||
|
* chip 内容并接管交互(悬停延迟开 / 移出延迟关 / 点击固定 / 滚动收起)。
|
||||||
|
*/
|
||||||
|
import { reactive } from 'vue';
|
||||||
|
|
||||||
|
export interface CitationAnnotation {
|
||||||
|
id: string;
|
||||||
|
type: 'url_citation' | 'file_citation';
|
||||||
|
// url 来源
|
||||||
|
title?: string;
|
||||||
|
url?: string;
|
||||||
|
domain?: string;
|
||||||
|
published_date?: string;
|
||||||
|
// 文件来源
|
||||||
|
file_path?: string;
|
||||||
|
file_name?: string;
|
||||||
|
size?: number;
|
||||||
|
// 可选定位
|
||||||
|
page?: number;
|
||||||
|
line_start?: number;
|
||||||
|
line_end?: number;
|
||||||
|
snippet?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** popover 全局状态(单例;同一时间只存在一个引用弹层) */
|
||||||
|
export const citationPopover = reactive({
|
||||||
|
visible: false,
|
||||||
|
pinned: false,
|
||||||
|
anchor: null as HTMLElement | null,
|
||||||
|
annotations: [] as CitationAnnotation[],
|
||||||
|
});
|
||||||
|
|
||||||
|
const HOVER_OPEN_DELAY = 250; // 悬停多久后打开
|
||||||
|
const HOVER_CLOSE_DELAY = 300; // 移出多久后关闭(留出移到弹层上的余量)
|
||||||
|
|
||||||
|
/** chip 元素上挂的当前 annotations(final 到达后可被富化替换,触发器事件时读取) */
|
||||||
|
type ChipWithAnnotations = HTMLElement & { _citationAnnotations?: CitationAnnotation[] };
|
||||||
|
|
||||||
|
let openTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
let closeTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
function clearPopoverTimers() {
|
||||||
|
clearTimeout(openTimer);
|
||||||
|
clearTimeout(closeTimer);
|
||||||
|
openTimer = undefined;
|
||||||
|
closeTimer = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openCitationPopover(anchor: HTMLElement, annotations: CitationAnnotation[], pinned: boolean) {
|
||||||
|
closeCitationPopover();
|
||||||
|
citationPopover.anchor = anchor;
|
||||||
|
citationPopover.annotations = annotations;
|
||||||
|
citationPopover.pinned = pinned;
|
||||||
|
citationPopover.visible = true;
|
||||||
|
anchor.classList.add('is-open');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeCitationPopover() {
|
||||||
|
clearPopoverTimers();
|
||||||
|
if (citationPopover.anchor) {
|
||||||
|
citationPopover.anchor.classList.remove('is-open');
|
||||||
|
}
|
||||||
|
citationPopover.visible = false;
|
||||||
|
citationPopover.pinned = false;
|
||||||
|
citationPopover.anchor = null;
|
||||||
|
citationPopover.annotations = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedulePopoverClose() {
|
||||||
|
clearTimeout(closeTimer);
|
||||||
|
closeTimer = setTimeout(() => {
|
||||||
|
if (!citationPopover.pinned) closeCitationPopover();
|
||||||
|
}, HOVER_CLOSE_DELAY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** popover 自身 hover 进入时取消关闭(CitationPopover 组件调用) */
|
||||||
|
export function keepCitationPopover() {
|
||||||
|
clearTimeout(closeTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** popover 自身 hover 离开时按延迟关闭(未固定时) */
|
||||||
|
export function leaveCitationPopover() {
|
||||||
|
if (!citationPopover.pinned) schedulePopoverClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachChipTriggers(chip: ChipWithAnnotations) {
|
||||||
|
// 事件时从元素上读最新 annotations(final 富化替换后无需重绑监听器)
|
||||||
|
const current = () => chip._citationAnnotations || [];
|
||||||
|
chip.addEventListener('mouseenter', () => {
|
||||||
|
clearTimeout(closeTimer);
|
||||||
|
if (citationPopover.anchor === chip && citationPopover.visible) return;
|
||||||
|
clearTimeout(openTimer);
|
||||||
|
openTimer = setTimeout(() => openCitationPopover(chip, current(), false), HOVER_OPEN_DELAY);
|
||||||
|
});
|
||||||
|
chip.addEventListener('mouseleave', () => {
|
||||||
|
clearTimeout(openTimer);
|
||||||
|
if (citationPopover.visible && citationPopover.anchor === chip && !citationPopover.pinned) {
|
||||||
|
schedulePopoverClose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
chip.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
clearPopoverTimers();
|
||||||
|
if (citationPopover.anchor === chip && citationPopover.visible && citationPopover.pinned) {
|
||||||
|
closeCitationPopover(); // 再点已固定的弹层 → 收起
|
||||||
|
} else {
|
||||||
|
openCitationPopover(chip, current(), true); // 点击 = 固定
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从文件引用 token(如 docs/x.md#L1-10)直接解析出临时 annotation:
|
||||||
|
* marker 自带路径与定位,chip 在输出瞬间即可渲染,无需等后端富化。 */
|
||||||
|
export function fileAnnotationFromToken(token: string): CitationAnnotation | null {
|
||||||
|
const raw = (token || '').trim();
|
||||||
|
if (!raw || /^src_/i.test(raw)) return null;
|
||||||
|
// 兼容 #L1-10 与 GitHub 风格 #L1-L10 / #p7
|
||||||
|
const m = raw.match(/^(.*?)#(?:L(\d+)(?:-L?(\d+))?|p(\d+))$/i);
|
||||||
|
let path = (m ? m[1] : raw).trim();
|
||||||
|
if (path.startsWith('./')) path = path.slice(2);
|
||||||
|
if (!path) return null;
|
||||||
|
const ann: CitationAnnotation = {
|
||||||
|
id: token,
|
||||||
|
type: 'file_citation',
|
||||||
|
file_path: path,
|
||||||
|
file_name: path.split('/').pop() || path,
|
||||||
|
};
|
||||||
|
if (m) {
|
||||||
|
if (m[4]) ann.page = parseInt(m[4], 10);
|
||||||
|
if (m[2]) {
|
||||||
|
ann.line_start = parseInt(m[2], 10);
|
||||||
|
if (m[3]) ann.line_end = parseInt(m[3], 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ann;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 扫描对话消息中的工具结果,收集 web_search / extract_webpage 注册的来源 annotation。
|
||||||
|
* 工具完成先于模型输出 marker,因此流式期间即可解析 cite:src_xxx。 */
|
||||||
|
export function collectConversationCitations(messages: any[]): CitationAnnotation[] {
|
||||||
|
const map = new Map<string, CitationAnnotation>();
|
||||||
|
for (const msg of messages || []) {
|
||||||
|
for (const action of msg?.actions || []) {
|
||||||
|
const list = action?.tool?.result?.citations;
|
||||||
|
if (!Array.isArray(list)) continue;
|
||||||
|
for (const ann of list) {
|
||||||
|
if (ann && typeof ann.id === 'string' && !map.has(ann.id)) {
|
||||||
|
map.set(ann.id, ann);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...map.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortDomain(domain?: string): string {
|
||||||
|
return (domain || '').replace(/^www\./, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function chipLabel(ann: CitationAnnotation): string {
|
||||||
|
return ann.type === 'file_citation' ? (ann.file_name || ann.file_path || '') : shortDomain(ann.domain);
|
||||||
|
}
|
||||||
|
|
||||||
|
function faviconHtml(domain: string): string {
|
||||||
|
const d = shortDomain(domain);
|
||||||
|
// 站点图标加载失败时退化为域名首字符
|
||||||
|
const letter = (d[0] || '?').toUpperCase();
|
||||||
|
return (
|
||||||
|
`<img class="chip-favicon" loading="lazy" alt="" ` +
|
||||||
|
`src="https://www.google.com/s2/favicons?domain=${encodeURIComponent(d)}&sz=64" ` +
|
||||||
|
`onerror="this.outerHTML='<span class="chip-letter">${letter}</span>'">`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const FILE_ICON_SVG =
|
||||||
|
'<svg class="chip-file-icon" viewBox="0 0 16 16" fill="none">' +
|
||||||
|
'<path d="M4 1.5h5.5L13 5v9.5H4z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/>' +
|
||||||
|
'<path d="M9.5 1.5V5H13" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>';
|
||||||
|
|
||||||
|
function chipIconHtml(ann: CitationAnnotation): string {
|
||||||
|
return ann.type === 'file_citation' ? FILE_ICON_SVG : faviconHtml(ann.domain || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 扫描容器内的 chip 占位 span,填充内容并接管交互。
|
||||||
|
*
|
||||||
|
* 两阶段解析:
|
||||||
|
* - 即时(输出瞬间):cite 来源从对话工具结果收集的 citations 查表;
|
||||||
|
* file 来源 token 自解析(marker 自带路径/locator,无需等待后端)。
|
||||||
|
* - 权威(final=true,message.metadata.citations 到达):仅以权威表为准,
|
||||||
|
* 查不到的 chip 移除(后端已剥离无效 marker);已渲染 chip 用富化数据
|
||||||
|
* 替换引用(popover 可读 snippet/size),DOM 不重建。
|
||||||
|
*/
|
||||||
|
export function enhanceCitationChips(
|
||||||
|
container: HTMLElement,
|
||||||
|
citations: CitationAnnotation[] | undefined,
|
||||||
|
opts: { final?: boolean } = {},
|
||||||
|
) {
|
||||||
|
const chips = container.querySelectorAll<ChipWithAnnotations>('.md-citation-chip');
|
||||||
|
if (!chips.length) return;
|
||||||
|
const final = !!opts.final;
|
||||||
|
const map = new Map((citations || []).map((c) => [c.id, c]));
|
||||||
|
|
||||||
|
chips.forEach((chip) => {
|
||||||
|
const ids = (chip.dataset.citationIds || '')
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
// 已渲染的 chip:final 到达时换权威富化数据(供 popover 读取),DOM 不动
|
||||||
|
if (chip.dataset.citeEnhanced === '1' && chip.dataset.citeLoaded === '1') {
|
||||||
|
if (final) {
|
||||||
|
const enriched = ids
|
||||||
|
.map((id) => map.get(id))
|
||||||
|
.filter((a): a is CitationAnnotation => !!a);
|
||||||
|
if (enriched.length) chip._citationAnnotations = enriched;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let annotations = ids
|
||||||
|
.map((id) => map.get(id))
|
||||||
|
.filter((a): a is CitationAnnotation => !!a);
|
||||||
|
|
||||||
|
// 非 final:file token 自解析作为临时数据(查表未命中时),让 chip 即时渲染
|
||||||
|
if (!annotations.length && !final) {
|
||||||
|
const derived = ids
|
||||||
|
.map((token) => fileAnnotationFromToken(token))
|
||||||
|
.filter((a): a is CitationAnnotation => !!a);
|
||||||
|
if (derived.length) annotations = derived;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!annotations.length) {
|
||||||
|
if (final) {
|
||||||
|
// 权威裁决:后端已剥离该 marker(幻觉 id / 文件不存在),移除 chip
|
||||||
|
chip.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 流式占位:中性胶囊,暂不可交互
|
||||||
|
if (chip.dataset.citeEnhanced !== '1') {
|
||||||
|
chip.dataset.citeEnhanced = '1';
|
||||||
|
chip.classList.add('is-pending');
|
||||||
|
chip.innerHTML = '<span class="chip-pending-dot"></span>';
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const icons = annotations.slice(0, 2).map(chipIconHtml).join('');
|
||||||
|
const more =
|
||||||
|
annotations.length > 1 ? `<span class="chip-count">+${annotations.length - 1}</span>` : '';
|
||||||
|
chip.innerHTML =
|
||||||
|
`<span class="chip-icons">${icons}</span>` +
|
||||||
|
`<span class="chip-label">${escapeText(chipLabel(annotations[0]))}</span>` +
|
||||||
|
more;
|
||||||
|
chip.classList.remove('is-pending');
|
||||||
|
chip.classList.add(annotations[0].type === 'file_citation' ? 'is-file' : 'is-url');
|
||||||
|
chip.dataset.citeEnhanced = '1';
|
||||||
|
chip.dataset.citeLoaded = '1';
|
||||||
|
chip._citationAnnotations = annotations;
|
||||||
|
attachChipTriggers(chip);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeText(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
@ -25,16 +25,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<span class="qd-preview__name" :title="previewPath">{{ fileName }}</span>
|
<span class="qd-preview__name" :title="previewPath">{{ fileName }}</span>
|
||||||
<span class="qd-preview__path" :title="previewPath">{{ dirName }}</span>
|
<span class="qd-preview__path" :title="previewPath">{{ dirName }}</span>
|
||||||
<button class="qd-preview__close" :title="$t('common.close')" @click="close">
|
<CloseButton :label="$t('common.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>
|
|
||||||
</header>
|
</header>
|
||||||
<div class="qd-preview__body">
|
<div class="qd-preview__body">
|
||||||
<div v-if="loading" class="qd-preview__loading">{{ $t('common.loading') }}</div>
|
<div v-if="loading" class="qd-preview__loading">{{ $t('common.loading') }}</div>
|
||||||
@ -70,6 +61,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
|
import CloseButton from '@/components/common/CloseButton.vue';
|
||||||
import { t } from '@/locales';
|
import { t } from '@/locales';
|
||||||
import { useQuickDockStore } from '@/stores/quickDock';
|
import { useQuickDockStore } from '@/stores/quickDock';
|
||||||
import { usePersonalizationStore } from '@/stores/personalization';
|
import { usePersonalizationStore } from '@/stores/personalization';
|
||||||
|
|||||||
@ -9,16 +9,9 @@
|
|||||||
<span class="qd-detail__title" :title="title">{{ title }}</span>
|
<span class="qd-detail__title" :title="title">{{ title }}</span>
|
||||||
<span class="qd-detail__badge" :class="`is-${stateClass}`">{{ statusText }}</span>
|
<span class="qd-detail__badge" :class="`is-${stateClass}`">{{ statusText }}</span>
|
||||||
<span v-if="tokensText" class="qd-detail__tokens" :title="tokensTitle">{{ tokensText }}</span>
|
<span v-if="tokensText" class="qd-detail__tokens" :title="tokensTitle">{{ tokensText }}</span>
|
||||||
<button class="qd-detail__close" :title="$t('common.close')" @click="close">
|
<span class="qd-detail__close">
|
||||||
<svg viewBox="0 0 16 16" fill="none">
|
<CloseButton :label="$t('common.close')" :title="$t('common.close')" @click="close" />
|
||||||
<path
|
</span>
|
||||||
d="M4 4l8 8M12 4l-8 8"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.4"
|
|
||||||
stroke-linecap="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</header>
|
</header>
|
||||||
<div ref="bodyRef" class="qd-detail__body" :class="{ 'body-fade': bodyFading }">
|
<div ref="bodyRef" class="qd-detail__body" :class="{ 'body-fade': bodyFading }">
|
||||||
<!-- 子智能体:工具调用 + 文本输出时间线 -->
|
<!-- 子智能体:工具调用 + 文本输出时间线 -->
|
||||||
|
|||||||
@ -581,30 +581,8 @@
|
|||||||
.qd-detail__close {
|
.qd-detail__close {
|
||||||
flex: none;
|
flex: none;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
width: 26px;
|
|
||||||
height: 26px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
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 {
|
.qd-detail__body {
|
||||||
@ -879,34 +857,6 @@
|
|||||||
text-overflow: ellipsis;
|
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 {
|
.qd-preview__body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
|||||||
131
static/src/components/common/CloseButton.vue
Normal file
131
static/src/components/common/CloseButton.vue
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 全局统一关闭按钮(X)——两种变体,全站所有「关闭」语义按钮统一引用。
|
||||||
|
*
|
||||||
|
* 变体:
|
||||||
|
* - boxed(默认):用于一切「有容器承载」场景(窗口/弹窗/抽屉/侧边栏/面板/通知卡片)。
|
||||||
|
* 28×28px(sm=24×24px),圆角 7px(sm=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>
|
||||||
@ -6,7 +6,7 @@
|
|||||||
<div class="subagent-activity-title">
|
<div class="subagent-activity-title">
|
||||||
{{ $t('overlay.bgCommandTitle', { id: activeCommand.command_id }) }}
|
{{ $t('overlay.bgCommandTitle', { id: activeCommand.command_id }) }}
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="subagent-activity-close" @click="close">×</button>
|
<CloseButton :label="$t('common.close')" @click="close" />
|
||||||
</div>
|
</div>
|
||||||
<div class="subagent-activity-meta">
|
<div class="subagent-activity-meta">
|
||||||
<span
|
<span
|
||||||
@ -49,6 +49,7 @@
|
|||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { t } from '@/locales';
|
import { t } from '@/locales';
|
||||||
|
import CloseButton from '@/components/common/CloseButton.vue';
|
||||||
import { useBackgroundCommandStore } from '@/stores/backgroundCommand';
|
import { useBackgroundCommandStore } from '@/stores/backgroundCommand';
|
||||||
|
|
||||||
const commandStore = useBackgroundCommandStore();
|
const commandStore = useBackgroundCommandStore();
|
||||||
|
|||||||
@ -10,16 +10,12 @@
|
|||||||
<div v-if="generatedPath" class="hint" :title="generatedPath">
|
<div v-if="generatedPath" class="hint" :title="generatedPath">
|
||||||
{{ $t('overlay.generatedHint', { path: generatedPath }) }}
|
{{ $t('overlay.generatedHint', { path: generatedPath }) }}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<CloseButton
|
||||||
type="button"
|
:label="$t('common.close')"
|
||||||
class="icon-close-btn"
|
|
||||||
:aria-label="$t('common.close')"
|
|
||||||
:title="$t('common.close')"
|
:title="$t('common.close')"
|
||||||
@click="$emit('close')"
|
|
||||||
:disabled="submitting"
|
:disabled="submitting"
|
||||||
>
|
@click="$emit('close')"
|
||||||
<span class="icon icon-sm" :style="iconStyle('x')" aria-hidden="true"></span>
|
/>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -127,6 +123,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import CloseButton from '@/components/common/CloseButton.vue';
|
||||||
|
|
||||||
defineOptions({ name: 'ConversationReviewDialog' });
|
defineOptions({ name: 'ConversationReviewDialog' });
|
||||||
|
|
||||||
const props = defineProps<{
|
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 {
|
.review-body {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
<div class="subagent-activity-title">
|
<div class="subagent-activity-title">
|
||||||
{{ isDone ? $t('overlay.goalDoneTitle') : isStopped ? $t('overlay.goalStoppedTitle') : $t('overlay.goalRunningTitle') }}
|
{{ isDone ? $t('overlay.goalDoneTitle') : isStopped ? $t('overlay.goalStoppedTitle') : $t('overlay.goalRunningTitle') }}
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="subagent-activity-close" @click="$emit('close')">×</button>
|
<CloseButton :label="$t('common.close')" @click="$emit('close')" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="goal-progress-meta">
|
<div class="goal-progress-meta">
|
||||||
@ -49,6 +49,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
||||||
import { t, currentLocale } from '@/locales';
|
import { t, currentLocale } from '@/locales';
|
||||||
|
import CloseButton from '@/components/common/CloseButton.vue';
|
||||||
|
|
||||||
defineOptions({ name: 'GoalProgressDialog' });
|
defineOptions({ name: 'GoalProgressDialog' });
|
||||||
|
|
||||||
|
|||||||
@ -9,14 +9,12 @@
|
|||||||
:aria-label="preview.name || $t('overlay.imagePreview')"
|
:aria-label="preview.name || $t('overlay.imagePreview')"
|
||||||
@click.self="close"
|
@click.self="close"
|
||||||
>
|
>
|
||||||
<button
|
<CloseButton
|
||||||
type="button"
|
variant="bare"
|
||||||
class="image-lightbox__close"
|
class="image-lightbox__close"
|
||||||
:aria-label="$t('overlay.closePreview')"
|
:label="$t('overlay.closePreview')"
|
||||||
@click.stop="close"
|
@click="close"
|
||||||
>
|
/>
|
||||||
×
|
|
||||||
</button>
|
|
||||||
<div class="image-lightbox__stage" @click.self="close">
|
<div class="image-lightbox__stage" @click.self="close">
|
||||||
<img
|
<img
|
||||||
class="image-lightbox__img"
|
class="image-lightbox__img"
|
||||||
@ -34,6 +32,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, watch } from 'vue';
|
import { computed, onBeforeUnmount, watch } from 'vue';
|
||||||
import { useUiStore } from '@/stores/ui';
|
import { useUiStore } from '@/stores/ui';
|
||||||
|
import CloseButton from '@/components/common/CloseButton.vue';
|
||||||
|
|
||||||
const uiStore = useUiStore();
|
const uiStore = useUiStore();
|
||||||
const preview = computed(() => uiStore.imagePreview);
|
const preview = computed(() => uiStore.imagePreview);
|
||||||
@ -125,28 +124,15 @@ onBeforeUnmount(() => syncLock(false));
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* bare 变体:按钮本体样式在 CloseButton 组件内,此处只负责灯箱场景的定位与配色覆盖。
|
||||||
|
灯箱遮罩永远为深色,颜色不随主题变量;默认约 72% 透明度的白,hover 全亮。 */
|
||||||
.image-lightbox__close {
|
.image-lightbox__close {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: calc(10px + env(safe-area-inset-top, 0px));
|
top: calc(10px + env(safe-area-inset-top, 0px));
|
||||||
right: calc(10px + env(safe-area-inset-right, 0px));
|
right: calc(10px + env(safe-area-inset-right, 0px));
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
width: 36px;
|
--close-btn-color: color-mix(in srgb, var(--lightbox-text) 72%, transparent);
|
||||||
height: 36px;
|
--close-btn-color-hover: var(--lightbox-text);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.lightbox-fade-enter-active,
|
.lightbox-fade-enter-active,
|
||||||
|
|||||||
@ -17,7 +17,7 @@
|
|||||||
@change="onLocalChange"
|
@change="onLocalChange"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button class="close-btn" @click="close">×</button>
|
<CloseButton :label="$t('common.close')" @click="close" />
|
||||||
</div>
|
</div>
|
||||||
<div class="body">
|
<div class="body">
|
||||||
<div v-if="loading" class="loading">{{ $t('common.loading') }}</div>
|
<div v-if="loading" class="loading">{{ $t('common.loading') }}</div>
|
||||||
@ -57,6 +57,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch, onMounted, withDefaults } from 'vue';
|
import { computed, ref, watch, onMounted, withDefaults } from 'vue';
|
||||||
|
import CloseButton from '@/components/common/CloseButton.vue';
|
||||||
|
|
||||||
interface ImageEntry {
|
interface ImageEntry {
|
||||||
name: string;
|
name: string;
|
||||||
@ -195,13 +196,6 @@ onMounted(() => {
|
|||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
.close-btn {
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
border: none;
|
|
||||||
font-size: 20px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.body {
|
.body {
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
<div class="overlay-card">
|
<div class="overlay-card">
|
||||||
<div class="overlay-header">
|
<div class="overlay-header">
|
||||||
<h3>{{ $t('overlay.pathAuthTitle') }}</h3>
|
<h3>{{ $t('overlay.pathAuthTitle') }}</h3>
|
||||||
<button type="button" @click="$emit('close')">×</button>
|
<CloseButton :label="$t('common.close')" @click="$emit('close')" />
|
||||||
</div>
|
</div>
|
||||||
<div class="mode-switch">
|
<div class="mode-switch">
|
||||||
<button
|
<button
|
||||||
@ -51,6 +51,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import CloseButton from '@/components/common/CloseButton.vue';
|
||||||
|
|
||||||
defineProps<{ open: boolean; value: string; mode: 'writable' | 'readable'; saving?: boolean }>();
|
defineProps<{ open: boolean; value: string; mode: 'writable' | 'readable'; saving?: boolean }>();
|
||||||
defineEmits<{
|
defineEmits<{
|
||||||
(e: 'close'): void;
|
(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-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 { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
||||||
.overlay-header h3 { margin: 0; font-size: 16px; }
|
.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; }
|
.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-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; }
|
.mode-btn { border: none; background: transparent; padding: 6px 10px; cursor: pointer; font-size: 12px; }
|
||||||
|
|||||||
@ -4,17 +4,13 @@
|
|||||||
<section class="plan-approval-card" role="dialog" aria-modal="true" :aria-label="$t('overlay.planApprovalAriaLabel')">
|
<section class="plan-approval-card" role="dialog" aria-modal="true" :aria-label="$t('overlay.planApprovalAriaLabel')">
|
||||||
<header class="plan-approval-windowbar">
|
<header class="plan-approval-windowbar">
|
||||||
<div class="plan-approval-window-title">{{ $t('overlay.planApprovalTitle') }}</div>
|
<div class="plan-approval-window-title">{{ $t('overlay.planApprovalTitle') }}</div>
|
||||||
<button
|
<span class="plan-approval-close">
|
||||||
type="button"
|
<CloseButton
|
||||||
class="plan-approval-close"
|
:label="$t('overlay.planApprovalMinimize')"
|
||||||
:title="$t('overlay.planApprovalMinimize')"
|
:title="$t('overlay.planApprovalMinimize')"
|
||||||
:aria-label="$t('overlay.planApprovalMinimize')"
|
@click="emit('minimize')"
|
||||||
@click="emit('minimize')"
|
/>
|
||||||
>
|
</span>
|
||||||
<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>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<header class="plan-approval-header">
|
<header class="plan-approval-header">
|
||||||
@ -77,6 +73,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import MarkdownRenderer from '../chat/MarkdownRenderer.vue';
|
import MarkdownRenderer from '../chat/MarkdownRenderer.vue';
|
||||||
|
import CloseButton from '@/components/common/CloseButton.vue';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@ -165,22 +162,9 @@ function reject() {
|
|||||||
right: 10px;
|
right: 10px;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
width: 26px;
|
|
||||||
height: 26px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: 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 {
|
.plan-approval-window-title {
|
||||||
|
|||||||
@ -9,17 +9,9 @@
|
|||||||
>
|
>
|
||||||
<header class="sandbox-setup-windowbar">
|
<header class="sandbox-setup-windowbar">
|
||||||
<div class="sandbox-setup-window-title">{{ $t('sandbox.setupTitle') }}</div>
|
<div class="sandbox-setup-window-title">{{ $t('sandbox.setupTitle') }}</div>
|
||||||
<button
|
<span class="sandbox-setup-close">
|
||||||
type="button"
|
<CloseButton :label="$t('common.close')" :title="$t('common.close')" @click="onClose" />
|
||||||
class="sandbox-setup-close"
|
</span>
|
||||||
: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>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="sandbox-setup-body">
|
<div class="sandbox-setup-body">
|
||||||
@ -144,6 +136,7 @@
|
|||||||
import { computed, nextTick, ref, watch } from 'vue';
|
import { computed, nextTick, ref, watch } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import FancyCheck from '@/components/common/FancyCheck.vue';
|
import FancyCheck from '@/components/common/FancyCheck.vue';
|
||||||
|
import CloseButton from '@/components/common/CloseButton.vue';
|
||||||
import { useSandboxSetupStore } from '@/stores/sandboxSetup';
|
import { useSandboxSetupStore } from '@/stores/sandboxSetup';
|
||||||
|
|
||||||
defineOptions({ name: 'SandboxSetupDialog' });
|
defineOptions({ name: 'SandboxSetupDialog' });
|
||||||
@ -312,22 +305,9 @@ function onClose() {
|
|||||||
right: 10px;
|
right: 10px;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
width: 26px;
|
|
||||||
height: 26px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: 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 {
|
.sandbox-setup-body {
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
<div class="subagent-activity-title">
|
<div class="subagent-activity-title">
|
||||||
{{ $t('overlay.subAgentProgressTitle', { id: activeAgent.agent_id || activeAgent.task_id }) }}
|
{{ $t('overlay.subAgentProgressTitle', { id: activeAgent.agent_id || activeAgent.task_id }) }}
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="subagent-activity-close" @click="close">×</button>
|
<CloseButton :label="$t('common.close')" @click="close" />
|
||||||
</div>
|
</div>
|
||||||
<div class="subagent-activity-meta">
|
<div class="subagent-activity-meta">
|
||||||
<span class="subagent-activity-status" :class="activeAgent.status || ''">{{
|
<span class="subagent-activity-status" :class="activeAgent.status || ''">{{
|
||||||
@ -59,6 +59,7 @@
|
|||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { t, currentLocale } from '@/locales';
|
import { t, currentLocale } from '@/locales';
|
||||||
|
import CloseButton from '@/components/common/CloseButton.vue';
|
||||||
import { useSubAgentStore } from '@/stores/subAgent';
|
import { useSubAgentStore } from '@/stores/subAgent';
|
||||||
|
|
||||||
type ActivityEntry = {
|
type ActivityEntry = {
|
||||||
|
|||||||
@ -3,8 +3,8 @@
|
|||||||
<div v-if="visible && questions.length" class="user-question-overlay" @click.self="emit('minimize')">
|
<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')">
|
<section class="user-question-card" role="dialog" aria-modal="true" :aria-label="$t('overlay.userQuestionAriaLabel')">
|
||||||
<header class="user-question-windowbar">
|
<header class="user-question-windowbar">
|
||||||
<div class="user-question-traffic" :aria-label="$t('overlay.windowControlAria')">
|
<div class="user-question-traffic">
|
||||||
<button type="button" class="traffic-dot traffic-dot--close" :aria-label="$t('overlay.minimizeAria')" @click="emit('minimize')"></button>
|
<CloseButton :label="$t('overlay.minimizeAria')" @click="emit('minimize')" />
|
||||||
</div>
|
</div>
|
||||||
<div class="user-question-window-title">{{ $t('overlay.userQuestionWindowTitle') }}</div>
|
<div class="user-question-window-title">{{ $t('overlay.userQuestionWindowTitle') }}</div>
|
||||||
</header>
|
</header>
|
||||||
@ -80,6 +80,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, reactive, watch } from 'vue';
|
import { computed, reactive, watch } from 'vue';
|
||||||
|
import CloseButton from '@/components/common/CloseButton.vue';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@ -208,16 +209,7 @@ function dismiss() {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.traffic-dot {
|
/* 关闭按钮本体样式在 common/CloseButton.vue(boxed 变体),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); }
|
|
||||||
|
|
||||||
.user-question-window-title {
|
.user-question-window-title {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@ -20,9 +20,7 @@
|
|||||||
<span class="icon icon-sm" :style="iconStyle('refreshCw')" aria-hidden="true"></span>
|
<span class="icon icon-sm" :style="iconStyle('refreshCw')" aria-hidden="true"></span>
|
||||||
<span>{{ $t('overlay.refresh') }}</span>
|
<span>{{ $t('overlay.refresh') }}</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="icon-close-btn" :aria-label="$t('common.close')" @click="$emit('close')">
|
<CloseButton :label="$t('common.close')" @click="$emit('close')" />
|
||||||
<span class="icon icon-sm" :style="iconStyle('x')" aria-hidden="true"></span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@ -155,6 +153,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import CloseButton from '@/components/common/CloseButton.vue';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
hostMode: boolean;
|
hostMode: boolean;
|
||||||
@ -498,26 +497,6 @@ const splitDiffContent = (content: any): string[] => {
|
|||||||
cursor: not-allowed;
|
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 {
|
.versioning-body {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@ -16,7 +16,7 @@
|
|||||||
@change="onLocalChange"
|
@change="onLocalChange"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button class="close-btn" @click="close">×</button>
|
<CloseButton :label="$t('common.close')" @click="close" />
|
||||||
</div>
|
</div>
|
||||||
<div class="body">
|
<div class="body">
|
||||||
<div v-if="loading" class="loading">{{ $t('common.loading') }}</div>
|
<div v-if="loading" class="loading">{{ $t('common.loading') }}</div>
|
||||||
@ -59,6 +59,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch, onMounted, withDefaults } from 'vue';
|
import { computed, ref, watch, onMounted, withDefaults } from 'vue';
|
||||||
|
import CloseButton from '@/components/common/CloseButton.vue';
|
||||||
|
|
||||||
interface VideoEntry {
|
interface VideoEntry {
|
||||||
name: string;
|
name: string;
|
||||||
@ -197,13 +198,6 @@ onMounted(() => {
|
|||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
.close-btn {
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
border: none;
|
|
||||||
font-size: 20px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.body {
|
.body {
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
|||||||
@ -9,15 +9,11 @@
|
|||||||
<span class="icon icon-sm" :style="iconStyle('eye')" aria-hidden="true"></span>
|
<span class="icon icon-sm" :style="iconStyle('eye')" aria-hidden="true"></span>
|
||||||
<span>{{ $t('shell.focusFilesCount', { n: focusedCount }) }}</span>
|
<span>{{ $t('shell.focusFilesCount', { n: focusedCount }) }}</span>
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<CloseButton
|
||||||
v-if="showCloseButton"
|
v-if="showCloseButton"
|
||||||
type="button"
|
:label="$t('shell.closeFocusPanel')"
|
||||||
class="focus-close-btn"
|
|
||||||
:aria-label="$t('shell.closeFocusPanel')"
|
|
||||||
@click="$emit('close')"
|
@click="$emit('close')"
|
||||||
>
|
/>
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="focused-files" v-if="!collapsed">
|
<div class="focused-files" v-if="!collapsed">
|
||||||
<div v-if="!focusedCount" class="no-files">{{ $t('shell.noFocusFiles') }}</div>
|
<div v-if="!focusedCount" class="no-files">{{ $t('shell.noFocusFiles') }}</div>
|
||||||
@ -40,6 +36,7 @@
|
|||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { useFocusStore } from '@/stores/focus';
|
import { useFocusStore } from '@/stores/focus';
|
||||||
|
import CloseButton from '@/components/common/CloseButton.vue';
|
||||||
|
|
||||||
defineOptions({ name: 'FocusPanel' });
|
defineOptions({ name: 'FocusPanel' });
|
||||||
|
|
||||||
|
|||||||
@ -9,9 +9,7 @@
|
|||||||
<div class="git-changes-panel__summary">
|
<div class="git-changes-panel__summary">
|
||||||
<span class="git-changes-panel__add">+{{ additions }}</span>
|
<span class="git-changes-panel__add">+{{ additions }}</span>
|
||||||
<span class="git-changes-panel__del">-{{ deletions }}</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')">
|
<CloseButton :label="$t('shell.closeGitPanel')" @click="$emit('close')" />
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@ -135,6 +133,7 @@
|
|||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { t, currentLocale } from '@/locales';
|
import { t, currentLocale } from '@/locales';
|
||||||
|
import CloseButton from '@/components/common/CloseButton.vue';
|
||||||
|
|
||||||
defineOptions({ name: 'GitChangesPanel' });
|
defineOptions({ name: 'GitChangesPanel' });
|
||||||
|
|
||||||
|
|||||||
@ -18,12 +18,7 @@
|
|||||||
<span class="terminal-panel__tab-name">{{ name }}</span>
|
<span class="terminal-panel__tab-name">{{ name }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<CloseButton :label="$t('shell.closeTerminalPanel')" @click="$emit('close')" />
|
||||||
type="button"
|
|
||||||
class="terminal-panel__close"
|
|
||||||
:aria-label="$t('shell.closeTerminalPanel')"
|
|
||||||
@click="$emit('close')"
|
|
||||||
>×</button>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- 终端容器 -->
|
<!-- 终端容器 -->
|
||||||
@ -44,6 +39,7 @@ import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
|||||||
import { Terminal } from 'xterm';
|
import { Terminal } from 'xterm';
|
||||||
import { io as createSocketClient } from 'socket.io-client';
|
import { io as createSocketClient } from 'socket.io-client';
|
||||||
import 'xterm/css/xterm.css';
|
import 'xterm/css/xterm.css';
|
||||||
|
import CloseButton from '@/components/common/CloseButton.vue';
|
||||||
|
|
||||||
defineOptions({ name: 'TerminalPanel' });
|
defineOptions({ name: 'TerminalPanel' });
|
||||||
|
|
||||||
@ -541,29 +537,6 @@ onBeforeUnmount(() => {
|
|||||||
text-overflow: ellipsis;
|
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 {
|
.terminal-panel__body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
@ -6,9 +6,7 @@
|
|||||||
>
|
>
|
||||||
<div class="sidebar-header" :class="{ 'mobile-header': isMobileViewport }">
|
<div class="sidebar-header" :class="{ 'mobile-header': isMobileViewport }">
|
||||||
<h3 class="icon-label">{{ panelTitle }}</h3>
|
<h3 class="icon-label">{{ panelTitle }}</h3>
|
||||||
<button type="button" class="approval-close-btn" :aria-label="$t('shell.closeApprovalPanel')" @click="handleCloseClick">
|
<CloseButton :label="$t('shell.closeApprovalPanel')" @click="handleCloseClick" />
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="approval-panel-body" v-if="!collapsed">
|
<div class="approval-panel-body" v-if="!collapsed">
|
||||||
<div v-if="!approvals.length && !isGoalApprovalMode" class="no-files">{{ $t('shell.noPendingApprovals') }}</div>
|
<div v-if="!approvals.length && !isGoalApprovalMode" class="no-files">{{ $t('shell.noPendingApprovals') }}</div>
|
||||||
@ -132,6 +130,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, computed } from 'vue';
|
import { onMounted, computed } from 'vue';
|
||||||
import { t, currentLocale } from '@/locales';
|
import { t, currentLocale } from '@/locales';
|
||||||
|
import CloseButton from '@/components/common/CloseButton.vue';
|
||||||
|
|
||||||
defineOptions({ name: 'ToolApprovalPanel' });
|
defineOptions({ name: 'ToolApprovalPanel' });
|
||||||
|
|
||||||
|
|||||||
@ -23,29 +23,19 @@
|
|||||||
<div class="settings-mobile-bar">
|
<div class="settings-mobile-bar">
|
||||||
<span class="settings-mobile-bar-cell" aria-hidden="true"></span>
|
<span class="settings-mobile-bar-cell" aria-hidden="true"></span>
|
||||||
<span class="settings-mobile-bar-title">{{ $t('common.settings') }}</span>
|
<span class="settings-mobile-bar-title">{{ $t('common.settings') }}</span>
|
||||||
<button
|
<span class="settings-mobile-bar-btn">
|
||||||
type="button"
|
<CloseButton
|
||||||
class="settings-mobile-bar-btn"
|
:label="$t('personalization.closePersonalSpaceAriaLabel')"
|
||||||
:aria-label="$t('personalization.closePersonalSpaceAriaLabel')"
|
@click="personalization.closeDrawer()"
|
||||||
@click="personalization.closeDrawer()"
|
/>
|
||||||
>
|
</span>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-nav-head">
|
<div class="settings-nav-head">
|
||||||
<button
|
<CloseButton
|
||||||
type="button"
|
|
||||||
class="settings-close-button"
|
|
||||||
data-tutorial="personal-close"
|
data-tutorial="personal-close"
|
||||||
:aria-label="$t('personalization.closePersonalSpaceAriaLabel')"
|
:label="$t('personalization.closePersonalSpaceAriaLabel')"
|
||||||
@click="personalization.closeDrawer()"
|
@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>
|
||||||
<div class="settings-redesign-tabs">
|
<div class="settings-redesign-tabs">
|
||||||
<button
|
<button
|
||||||
@ -164,6 +154,7 @@
|
|||||||
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick, provide } from 'vue';
|
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick, provide } from 'vue';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import FancyCheck from '@/components/common/FancyCheck.vue';
|
import FancyCheck from '@/components/common/FancyCheck.vue';
|
||||||
|
import CloseButton from '@/components/common/CloseButton.vue';
|
||||||
import GeneralTab from './tabs/GeneralTab.vue';
|
import GeneralTab from './tabs/GeneralTab.vue';
|
||||||
import PreferencesTab from './tabs/PreferencesTab.vue';
|
import PreferencesTab from './tabs/PreferencesTab.vue';
|
||||||
import ModelTab from './tabs/ModelTab.vue';
|
import ModelTab from './tabs/ModelTab.vue';
|
||||||
|
|||||||
@ -33,31 +33,6 @@ body[data-theme='dark'] .settings-redesign-card {
|
|||||||
padding: 12px 12px 4px;
|
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 {
|
.settings-mobile-bar {
|
||||||
display: none;
|
display: none;
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
<transition name="quota-toast-fade">
|
<transition name="quota-toast-fade">
|
||||||
<div class="quota-toast" v-if="quotaToast">
|
<div class="quota-toast" v-if="quotaToast">
|
||||||
<span class="quota-toast-label">{{ quotaToast.message }}</span>
|
<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>
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
</template>
|
</template>
|
||||||
@ -10,6 +10,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { useUiStore } from '@/stores/ui';
|
import { useUiStore } from '@/stores/ui';
|
||||||
|
import CloseButton from '@/components/common/CloseButton.vue';
|
||||||
|
|
||||||
const uiStore = useUiStore();
|
const uiStore = useUiStore();
|
||||||
const { quotaToast } = storeToRefs(uiStore);
|
const { quotaToast } = storeToRefs(uiStore);
|
||||||
|
|||||||
@ -10,15 +10,12 @@
|
|||||||
<div v-if="toast.title" class="app-toast-title">{{ toast.title }}</div>
|
<div v-if="toast.title" class="app-toast-title">{{ toast.title }}</div>
|
||||||
<div class="app-toast-message">{{ toast.message }}</div>
|
<div class="app-toast-message">{{ toast.message }}</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<CloseButton
|
||||||
v-if="toast.closable !== false"
|
v-if="toast.closable !== false"
|
||||||
type="button"
|
size="sm"
|
||||||
class="toast-close"
|
:label="$t('shell.closeNotification')"
|
||||||
:aria-label="$t('shell.closeNotification')"
|
|
||||||
@click="dismiss(toast.id)"
|
@click="dismiss(toast.id)"
|
||||||
>
|
/>
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</transition-group>
|
</transition-group>
|
||||||
</div>
|
</div>
|
||||||
@ -27,6 +24,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { useUiStore } from '@/stores/ui';
|
import { useUiStore } from '@/stores/ui';
|
||||||
|
import CloseButton from '@/components/common/CloseButton.vue';
|
||||||
|
|
||||||
const uiStore = useUiStore();
|
const uiStore = useUiStore();
|
||||||
const { toastQueue: toasts } = storeToRefs(uiStore);
|
const { toastQueue: toasts } = storeToRefs(uiStore);
|
||||||
|
|||||||
@ -1,15 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="token-drawer" v-if="visible" :class="{ collapsed }" data-tutorial="token-drawer">
|
<div class="token-drawer" v-if="visible" :class="{ collapsed }" data-tutorial="token-drawer">
|
||||||
<div class="token-display-panel">
|
<div class="token-display-panel">
|
||||||
<button
|
<CloseButton
|
||||||
class="token-close-btn"
|
class="token-close-btn"
|
||||||
type="button"
|
|
||||||
data-tutorial="token-close"
|
data-tutorial="token-close"
|
||||||
|
:label="$t('sidebar.collapseUsage')"
|
||||||
@click="emit('toggle')"
|
@click="emit('toggle')"
|
||||||
:aria-label="$t('sidebar.collapseUsage')"
|
/>
|
||||||
>
|
|
||||||
<span class="sr-only">{{ $t('common.close') }}</span>
|
|
||||||
</button>
|
|
||||||
<div class="token-panel-content">
|
<div class="token-panel-content">
|
||||||
<div class="usage-dashboard">
|
<div class="usage-dashboard">
|
||||||
<div class="usage-cell usage-cell--left usage-cell--token panel-card">
|
<div class="usage-cell usage-cell--left usage-cell--token panel-card">
|
||||||
@ -127,6 +124,7 @@ defineOptions({ name: 'TokenDrawer' });
|
|||||||
|
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { t, currentLocale } from '@/locales';
|
import { t, currentLocale } from '@/locales';
|
||||||
|
import CloseButton from '@/components/common/CloseButton.vue';
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'toggle'): void;
|
(e: 'toggle'): void;
|
||||||
|
|||||||
@ -18,7 +18,7 @@
|
|||||||
<div v-else class="wf-shell__loading">{{ $t('common.loading') }}</div>
|
<div v-else class="wf-shell__loading">{{ $t('common.loading') }}</div>
|
||||||
<div v-if="errorMessage" class="wf-shell__error" role="alert">
|
<div v-if="errorMessage" class="wf-shell__error" role="alert">
|
||||||
<span>{{ errorMessage }}</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -26,6 +26,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import { t } from '@/locales';
|
import { t } from '@/locales';
|
||||||
|
import CloseButton from '@/components/common/CloseButton.vue';
|
||||||
import WorkflowLibraryView from './WorkflowLibraryView.vue';
|
import WorkflowLibraryView from './WorkflowLibraryView.vue';
|
||||||
import WorkflowEditorView from './WorkflowEditorView.vue';
|
import WorkflowEditorView from './WorkflowEditorView.vue';
|
||||||
import { createEmptyWorkflow, type WorkflowDef } from './workflowModel';
|
import { createEmptyWorkflow, type WorkflowDef } from './workflowModel';
|
||||||
|
|||||||
@ -1802,6 +1802,18 @@ export async function initializeLegacySocket(ctx: any) {
|
|||||||
|
|
||||||
resetPendingToolEvents();
|
resetPendingToolEvents();
|
||||||
|
|
||||||
|
// 行内引用:任务完成时后端带回已用来源,挂到最后一条 assistant 消息的 metadata,
|
||||||
|
// MarkdownRenderer 的 citations watcher 会触发 chip 增强(流式期间的占位胶囊在此转正)
|
||||||
|
if (Array.isArray(data?.citations) && Array.isArray(ctx.messages) && ctx.messages.length) {
|
||||||
|
const lastMsg = ctx.messages[ctx.messages.length - 1];
|
||||||
|
if (lastMsg && lastMsg.role === 'assistant') {
|
||||||
|
lastMsg.metadata = {
|
||||||
|
...(lastMsg.metadata || {}),
|
||||||
|
citations: data.citations
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 任务完成后立即更新Token统计(关键修复)
|
// 任务完成后立即更新Token统计(关键修复)
|
||||||
if (ctx.currentConversationId) {
|
if (ctx.currentConversationId) {
|
||||||
ctx.updateCurrentContextTokens();
|
ctx.updateCurrentContextTokens();
|
||||||
|
|||||||
@ -313,6 +313,45 @@ function transformShowFileBlocks(raw: string) {
|
|||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预处理行内引用 marker(【cite:src_xxx】网页 / 【file:相对路径】文件),转换为 citation chip 占位 span。
|
||||||
|
* - 仅转换完整闭合的 marker;流式期间尾部未闭合的【…暂时隐藏,避免原始字符闪烁
|
||||||
|
* - 行内代码内的 marker 保持原样(fenced code block 已在 segment 拆分阶段剥离)
|
||||||
|
* - marker 内容进 data 属性前做属性转义,防注入
|
||||||
|
*/
|
||||||
|
function transformCitationMarkers(raw: string, isStreaming: boolean): string {
|
||||||
|
if (!raw || raw.indexOf('【') === -1) return raw;
|
||||||
|
|
||||||
|
// 保护行内代码:`...` 内的 marker 原样展示
|
||||||
|
const INLINE_CODE_PLACEHOLDER = '__CITE_PROTECT_INLINE_CODE_';
|
||||||
|
const inlineCodes: string[] = [];
|
||||||
|
let text = raw.replace(/`[^`\n]+`/g, (match) => {
|
||||||
|
inlineCodes.push(match);
|
||||||
|
return `${INLINE_CODE_PLACEHOLDER}${inlineCodes.length - 1}__`;
|
||||||
|
});
|
||||||
|
|
||||||
|
text = text.replace(/【(?:cite|file):([^】]+)】/g, (_m, ids: string) => {
|
||||||
|
const clean = ids
|
||||||
|
.split(/[,,]/)
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((s) => s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>'))
|
||||||
|
.join(',');
|
||||||
|
if (!clean) return '';
|
||||||
|
return `<span class="md-citation-chip" data-citation-ids="${clean}"></span>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isStreaming) {
|
||||||
|
// 尾部未闭合 marker(【cite:... 甚至只输入了一半的【)暂时隐藏
|
||||||
|
text = text.replace(/【[^】]*$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 还原行内代码
|
||||||
|
text = text.replace(new RegExp(`${INLINE_CODE_PLACEHOLDER}(\\d+)__`, 'g'), (_m, i: string) => inlineCodes[Number(i)]);
|
||||||
|
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 预处理 LaTeX 数学公式,避免被 markdown parser 拆段。
|
* 预处理 LaTeX 数学公式,避免被 markdown parser 拆段。
|
||||||
* $$...$$ 转换为块级占位符,$...$ 转换为行内占位符,
|
* $$...$$ 转换为块级占位符,$...$ 转换为行内占位符,
|
||||||
@ -532,7 +571,7 @@ const sanitizedSchema: Record<string, any> = {
|
|||||||
attributes: {
|
attributes: {
|
||||||
...(defaultSchema.attributes || {}),
|
...(defaultSchema.attributes || {}),
|
||||||
div: [...((defaultSchema.attributes || {}).div || []), 'className', 'data-md-table-scroll'],
|
div: [...((defaultSchema.attributes || {}).div || []), 'className', 'data-md-table-scroll'],
|
||||||
span: [...((defaultSchema.attributes || {}).span || []), 'className', 'dataLatex', 'dataDisplay', 'dataMathRendered', 'data-latex', 'data-display', 'data-math-rendered'],
|
span: [...((defaultSchema.attributes || {}).span || []), 'className', 'dataLatex', 'dataDisplay', 'dataMathRendered', 'data-latex', 'data-display', 'data-math-rendered', 'data-citation-ids', 'dataCitationIds'],
|
||||||
a: [
|
a: [
|
||||||
'ariaDescribedBy', 'ariaLabel', 'ariaLabelledBy',
|
'ariaDescribedBy', 'ariaLabel', 'ariaLabelledBy',
|
||||||
'dataFootnoteBackref', 'dataFootnoteRef',
|
'dataFootnoteBackref', 'dataFootnoteRef',
|
||||||
@ -719,15 +758,17 @@ export function parseMarkdownSegments(text: string, isStreaming = false): Markdo
|
|||||||
return segments;
|
return segments;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderMarkdownText(text: string, isStreaming = false): string {
|
export function renderMarkdownText(text: string, isStreaming = false, enableCitations = false): string {
|
||||||
if (!text) return '';
|
if (!text) return '';
|
||||||
|
|
||||||
// isStreaming 必须透传:流式期间未闭合的 show_html 需要编码成 data-partial 占位
|
// isStreaming 必须透传:流式期间未闭合的 show_html 需要编码成 data-partial 占位
|
||||||
// (js=off 实时渲染 / js=on 显示"渲染中"),否则原始标签文本会直接散落到消息里
|
// (js=off 实时渲染 / js=on 显示"渲染中"),否则原始标签文本会直接散落到消息里
|
||||||
|
// enableCitations:仅 assistant 正文开启;用户消息/预览等静态文本里的【cite:】原样显示
|
||||||
|
const withCustomBlocks = transformShowFileBlocks(
|
||||||
|
transformShowImageBlocks(transformShowHtmlBlocks(text, isStreaming))
|
||||||
|
);
|
||||||
const safeText = transformMathBlocks(
|
const safeText = transformMathBlocks(
|
||||||
transformShowFileBlocks(
|
enableCitations ? transformCitationMarkers(withCustomBlocks, isStreaming) : withCustomBlocks
|
||||||
transformShowImageBlocks(transformShowHtmlBlocks(text, isStreaming))
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let html = '';
|
let html = '';
|
||||||
|
|||||||
@ -37,6 +37,10 @@ export default {
|
|||||||
thinking: 'Thinking',
|
thinking: 'Thinking',
|
||||||
thinkingRunning: 'Thinking...',
|
thinkingRunning: 'Thinking...',
|
||||||
|
|
||||||
|
// —— Inline citations (citation chip / popover) ——
|
||||||
|
citationSources: '{n} sources',
|
||||||
|
citationOpenSource: 'Open source',
|
||||||
|
|
||||||
// —— File append (append / append_payload) ——
|
// —— File append (append / append_payload) ——
|
||||||
targetFile: 'target file',
|
targetFile: 'target file',
|
||||||
appendDone: 'File append complete',
|
appendDone: 'File append complete',
|
||||||
|
|||||||
@ -47,6 +47,10 @@ export default {
|
|||||||
thinking: '思考过程',
|
thinking: '思考过程',
|
||||||
thinkingRunning: '正在思考...',
|
thinkingRunning: '正在思考...',
|
||||||
|
|
||||||
|
// —— 行内引用(citation chip / popover) ——
|
||||||
|
citationSources: '{n} 个来源',
|
||||||
|
citationOpenSource: '打开来源',
|
||||||
|
|
||||||
// —— 文件追加(append / append_payload) ——
|
// —— 文件追加(append / append_payload) ——
|
||||||
targetFile: '目标文件',
|
targetFile: '目标文件',
|
||||||
appendDone: '文件追加完成',
|
appendDone: '文件追加完成',
|
||||||
|
|||||||
@ -2007,11 +2007,13 @@ html.show-html-fullscreen-open body {
|
|||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 与卡片 ⋯ 按钮一致的纯文字风格:无边框无背景,hover 仅变色 */
|
/* 全屏预览顶栏按钮:与全站统一关闭按钮(common/CloseButton.vue boxed 变体)同一形态——
|
||||||
|
圆角方透明底,hover/active 出现 --hover-bg 底色块(动态 DOM 无法复用组件,样式在此对齐) */
|
||||||
.show-html-fullscreen__btn {
|
.show-html-fullscreen__btn {
|
||||||
width: 32px;
|
width: 28px;
|
||||||
height: 32px;
|
height: 28px;
|
||||||
border: 0;
|
border: 0;
|
||||||
|
border-radius: 7px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@ -2020,10 +2022,18 @@ html.show-html-fullscreen-open body {
|
|||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
cursor: pointer;
|
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);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2058,6 +2068,14 @@ html.show-html-fullscreen-open body {
|
|||||||
@extend .chat-inline-card__caption;
|
@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 在 streaming 重建期间会短暂回到原始标签,需提供稳定占位避免 scrollHeight 抖动 */
|
||||||
show_html:not([data-rendered='1']),
|
show_html:not([data-rendered='1']),
|
||||||
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 {
|
.math-block {
|
||||||
display: block;
|
display: block;
|
||||||
|
|||||||
@ -1479,15 +1479,6 @@ body[data-theme='light'] {
|
|||||||
padding: 0 16px !important;
|
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 {
|
.user-question-mini-dot {
|
||||||
|
|||||||
@ -1811,20 +1811,7 @@
|
|||||||
background: var(--surface-panel);
|
background: var(--surface-panel);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-overlay-close {
|
/* .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-content {
|
.mobile-overlay-content {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
@ -2085,20 +2072,6 @@
|
|||||||
color: var(--text-primary);
|
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 {
|
.subagent-activity-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@ -172,29 +172,11 @@ body[data-theme='dark'] {
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 按钮本体样式在 common/CloseButton.vue(boxed 变体),此处仅保留抽屉左上角定位 */
|
||||||
.token-close-btn {
|
.token-close-btn {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 12px;
|
top: 12px;
|
||||||
left: 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 {
|
.sr-only {
|
||||||
@ -437,19 +419,7 @@ body[data-theme='dark'] {
|
|||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast-close {
|
/* .toast-close 已移除:Toast 关闭按钮统一改用 common/CloseButton.vue(boxed sm) */
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 16px;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast-close:hover {
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.status-pill--running {
|
.status-pill--running {
|
||||||
background: color-mix(in srgb, var(--state-success) 22%, transparent);
|
background: color-mix(in srgb, var(--state-success) 22%, transparent);
|
||||||
|
|||||||
@ -40,28 +40,6 @@
|
|||||||
overflow-y: auto;
|
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 {
|
.git-changes-panel {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
min-width: 340px;
|
min-width: 340px;
|
||||||
@ -126,24 +104,6 @@
|
|||||||
color: var(--git-diff-del-text);
|
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 {
|
.git-changes-panel__body {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@ -843,19 +803,4 @@ body[data-theme='dark'] {
|
|||||||
background: var(--surface-base);
|
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -33,7 +33,9 @@ def _format_extract_webpage(result_data: Dict[str, Any]) -> str:
|
|||||||
content = result_data.get("content") or ""
|
content = result_data.get("content") or ""
|
||||||
length = len(content)
|
length = len(content)
|
||||||
truncated_flag = result_data.get("truncated") or False
|
truncated_flag = result_data.get("truncated") or False
|
||||||
header = f"提取完成:{url},长度 {length} 字符。"
|
citation_id = result_data.get("citation_id")
|
||||||
|
citation_note = f"(来源 ID: {citation_id})" if citation_id else ""
|
||||||
|
header = f"提取完成:{url}{citation_note},长度 {length} 字符。"
|
||||||
if not content:
|
if not content:
|
||||||
return f"{header} 内容为空。"
|
return f"{header} 内容为空。"
|
||||||
# 为模型保留完整正文,避免 800 字预览导致上下文缺失
|
# 为模型保留完整正文,避免 800 字预览导致上下文缺失
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user