diff --git a/docker/personal-agent/personal-agent-entrypoint.sh b/docker/personal-agent/personal-agent-entrypoint.sh index 33865ada..dde1f819 100644 --- a/docker/personal-agent/personal-agent-entrypoint.sh +++ b/docker/personal-agent/personal-agent-entrypoint.sh @@ -31,4 +31,6 @@ su - pa-desktop -c 'export DISPLAY=:99 XAUTHORITY=/run/pa-desktop/.Xauthority; s # Live screen: VNC listens only on a socket in the private directory; pa-stream checks the token. su - pa-desktop -c 'sleep 2; while true; do x11vnc -display :99 -auth /run/pa-desktop/.Xauthority -rfbport 0 -unixsock /run/pa-desktop/vnc.sock -forever -shared -nopw -quiet -nowf -noscr -ncache 0 -cursor most -xkb -wait 10 -defer 10; sleep 1; done' >/run/pa-desktop/vnc.log 2>&1 & su - pa-desktop -c 'while true; do /opt/python/current/bin/python3 -I /usr/local/bin/pa-stream; sleep 1; done' >/run/pa-desktop/stream.log 2>&1 & -exec sleep infinity +# PID 1 reaps every orphan and kills what task commands (UID 1000) leave behind; +# the desktop above runs as UID 1001 and is only reaped. +exec sandbox-init 1000 diff --git a/docker/sandbox/Dockerfile.opensandbox b/docker/sandbox/Dockerfile.opensandbox index 82960bfd..957ebb3f 100644 --- a/docker/sandbox/Dockerfile.opensandbox +++ b/docker/sandbox/Dockerfile.opensandbox @@ -223,3 +223,12 @@ RUN mkdir -p /home/ubuntu/.local /home/ubuntu/.jupyter /home/ubuntu/.npm \ # Real launch detects driver/browser revision mismatches before delivery. COPY docker/sandbox/verify-browser-runtime.py /opt/verify-browser-runtime.py RUN runuser -u ubuntu -- $PY_BIN /opt/verify-browser-runtime.py --container-isolated + +# ─── sandbox-init(PID 1 + 命令的 subreaper)───────────────────────────── +# 由 provider 的 entrypoint 作为 PID 1 启动:回收所有孤儿进程,并杀掉沙盒用户 +# (SANDBOX_RUN_UID)在命令结束后留下的进程;每条用户命令经 `sandbox-init +# --subreaper` 运行,setsid / 两次 fork 也逃不出命令的进程树。放在末尾, +# 改动不击穿上面的大层缓存。 +COPY docker/sandbox/sandbox-init.c /tmp/sandbox-init.c +RUN gcc -O2 -Wall -Werror -o /usr/local/bin/sandbox-init /tmp/sandbox-init.c \ + && rm /tmp/sandbox-init.c diff --git a/docker/sandbox/sandbox-init.c b/docker/sandbox/sandbox-init.c new file mode 100644 index 00000000..39224bf0 --- /dev/null +++ b/docker/sandbox/sandbox-init.c @@ -0,0 +1,78 @@ +/* + * PID 1 of an OpenSandbox container, and the subreaper every user command runs under. + * + * sandbox-init as PID 1: reap every child, and SIGKILL the ones + * running as + * sandbox-init --subreaper cmd ... become a child subreaper, then exec cmd + * + * User commands run as under --subreaper, so whatever they start stays in their + * subtree for as long as they run, however it detaches (setsid, double fork). A leftover + * only reaches PID 1 once its command has ended, and is killed there: no process of + * outlives the command that started it. Sandbox services run as root and are + * only reaped. + */ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include + +/* The real uid: a process cannot change it, unlike the owner of /proc/. */ +static long real_uid(long pid) +{ + char path[64], line[256]; + long uid = -1; + snprintf(path, sizeof path, "/proc/%ld/status", pid); + FILE *status = fopen(path, "r"); + if (!status) + return -1; + while (fgets(line, sizeof line, status)) + if (sscanf(line, "Uid: %ld", &uid) == 1) + break; + fclose(status); + return uid; +} + +static void kill_orphans(long uid) +{ + /* Nothing is reaped during the scan, so no PID read here can be reused before its kill. */ + FILE *children = fopen("/proc/1/task/1/children", "r"); + long pid; + if (!children) + return; + while (fscanf(children, "%ld", &pid) == 1) + if (real_uid(pid) == uid) + kill((pid_t)pid, SIGKILL); + fclose(children); +} + +int main(int argc, char **argv) +{ + if (argc > 2 && strcmp(argv[1], "--subreaper") == 0) { + if (prctl(PR_SET_CHILD_SUBREAPER, 1) == 0) + execvp(argv[2], argv + 2); + perror("sandbox-init"); + return 127; + } + if (argc != 2) { + fprintf(stderr, "usage: sandbox-init | sandbox-init --subreaper cmd ...\n"); + return 2; + } + long uid = strtol(argv[1], NULL, 10); + sigset_t chld; + sigemptyset(&chld); + sigaddset(&chld, SIGCHLD); + sigprocmask(SIG_BLOCK, &chld, NULL); + /* An orphan is adopted without any signal, so look for leftovers every second too. */ + const struct timespec tick = {1, 0}; + for (;;) { + sigtimedwait(&chld, NULL, &tick); + while (waitpid(-1, NULL, WNOHANG) > 0) + ; + kill_orphans(uid); + } +} diff --git a/src/backend/api/routes/file_archive.py b/src/backend/api/routes/file_archive.py index dcd2734e..aeab0bda 100644 --- a/src/backend/api/routes/file_archive.py +++ b/src/backend/api/routes/file_archive.py @@ -6,13 +6,14 @@ import tempfile from urllib.parse import quote -from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request from fastapi.responses import Response from sqlalchemy.orm import Session from core.auth.backend import UserContext, get_current_user from core.files.content.archive_browser import ArchiveError, MAX_ARCHIVE_BYTES, inspect_archive, read_archive_member from core.db.engine import get_db +from core.infra.byte_range import byte_range_response, is_continuation_range from core.infra.exceptions import StorageError from core.infra.responses import success_response from core.files.storage import get_storage @@ -22,7 +23,7 @@ @contextmanager -def _source(file_id, user, db): +def _source(file_id, user, db, *, audit: bool = True): from api.routes.files import _authorize_access, _load_artifact_item, _record_audit item = _load_artifact_item(file_id, db) @@ -54,6 +55,8 @@ def _source(file_id, user, db): raise HTTPException(exc.status, detail=exc.code) from exc except (OSError, KeyError, StorageError) as exc: raise HTTPException(503, detail="archive_read_failed") from exc + if not audit: + return _record_audit( user=user, db=db, @@ -78,13 +81,16 @@ def list_archive( @router.get("/{file_id}/archive/member", summary="读取压缩包内文件") def archive_member( + request: Request, file_id: str, path: str = Query(min_length=1, max_length=1024), inline: bool = False, user: UserContext = Depends(get_current_user), db: Session = Depends(get_db), ): - with _source(file_id, user, db) as (source, name, _): + range_header = request.headers.get("range") + # A player's mid-file seeks re-read the member; audit only the first read. + with _source(file_id, user, db, audit=not is_continuation_range(range_header)) as (source, name, _): data = read_archive_member(source, name, path) filename = PurePosixPath(path).name mime = mimetypes.guess_type(filename)[0] or "application/octet-stream" @@ -99,8 +105,9 @@ def archive_member( if mime in ("text/html", "image/svg+xml"): mime = "text/plain" safe_inline = inline - return Response( + return byte_range_response( data, + range_header, media_type=mime, headers={ "Cache-Control": "no-store", diff --git a/src/backend/api/routes/files.py b/src/backend/api/routes/files.py index 122c768f..d348ae0e 100644 --- a/src/backend/api/routes/files.py +++ b/src/backend/api/routes/files.py @@ -31,10 +31,18 @@ router = APIRouter(prefix="/files", tags=["files"]) router.include_router(archive_router) router.include_router(local_actions_router) -POWERPOINT_EXTENSIONS = {".ppt", ".pptx"} -POWERPOINT_MIME_MARKERS = ("presentationml", "powerpoint") -WORD_EXTENSIONS = {".doc", ".docx"} -WORD_MIME_MARKERS = ("wordprocessingml", "msword") +# Documents rendered to PDF by LibreOffice for browser preview: legacy binary +# Word/PowerPoint, OOXML, OpenDocument text/presentation and RTF. +OFFICE_PREVIEW_EXTENSIONS = {".doc", ".docx", ".rtf", ".odt", ".ppt", ".pptx", ".odp"} +OFFICE_PREVIEW_MIME_MARKERS = ( + "wordprocessingml", + "msword", + "presentationml", + "powerpoint", + "opendocument.text", + "opendocument.presentation", + "application/rtf", +) def _load_artifact_item(file_id: str, db: Session) -> dict[str, Any]: @@ -208,28 +216,16 @@ def _prepare_local_file( return str(temp_path) -def _is_powerpoint_file(item: dict[str, Any]) -> bool: - name = str(item.get("name", "")) +def _is_office_previewable(item: dict[str, Any]) -> bool: + ext = Path(str(item.get("name", ""))).suffix.lower() mime_type = str(item.get("mime_type", "")).lower() - ext = Path(name).suffix.lower() - return ext in POWERPOINT_EXTENSIONS or any( - marker in mime_type for marker in POWERPOINT_MIME_MARKERS + return ext in OFFICE_PREVIEW_EXTENSIONS or any( + marker in mime_type for marker in OFFICE_PREVIEW_MIME_MARKERS ) -def _is_word_file(item: dict[str, Any]) -> bool: - name = str(item.get("name", "")) - mime_type = str(item.get("mime_type", "")).lower() - ext = Path(name).suffix.lower() - return ext in WORD_EXTENSIONS or any(marker in mime_type for marker in WORD_MIME_MARKERS) - - -def _is_office_previewable(item: dict[str, Any]) -> bool: - return _is_powerpoint_file(item) or _is_word_file(item) - - def _convert_office_to_pdf(source_path: str, file_id: str) -> tuple[str, str]: - """Render an Office document (PPT/PPTX/DOC/DOCX) to PDF via headless LibreOffice.""" + """Render an Office document (DOC/DOCX/RTF/ODT/PPT/PPTX/ODP) to PDF via headless LibreOffice.""" libreoffice = find_libreoffice_binary() if not libreoffice: raise RuntimeError( @@ -421,7 +417,7 @@ def preview_file( user: Optional[UserContext] = Depends(require_auth(required=False)), db: Session = Depends(get_db), ): - """将 Office 文件(PPT/PPTX/DOC/DOCX)经 LibreOffice 转为可在浏览器内联预览的 PDF。 + """将 Office 文件(DOC/DOCX/RTF/ODT/PPT/PPTX/ODP)经 LibreOffice 转为可在浏览器内联预览的 PDF。 需通过文件访问鉴权;目前 format 仅支持 pdf。 """ @@ -439,7 +435,7 @@ def preview_file( if not _is_office_previewable(item): raise HTTPException( status_code=400, - detail="Only Word (DOC/DOCX) and PowerPoint (PPT/PPTX) files support preview", + detail="Only Word (DOC/DOCX/RTF/ODT) and PowerPoint (PPT/PPTX/ODP) files support preview", ) source_path = _prepare_local_file( diff --git a/src/backend/api/routes/v1/space_files.py b/src/backend/api/routes/v1/space_files.py index 80a8e30b..3fb69322 100644 --- a/src/backend/api/routes/v1/space_files.py +++ b/src/backend/api/routes/v1/space_files.py @@ -14,6 +14,7 @@ from core.files.spaces.mounts import mount_revision from core.files.spaces.files import MAX_TRANSFER_BYTES, SpaceFiles from core.files.storage import get_storage +from core.infra.byte_range import byte_range_response router = APIRouter(prefix="/v1/myspace", tags=["Space Files"]) @@ -43,11 +44,14 @@ def entries(space: str, path: str = "", cursor: str | None = None, return success_response(data=files.list(space, path, cursor=cursor, limit=limit, keyword=keyword)) @router.get("/content") -def download(space: str, path: str, inline: bool = False, files: SpaceFiles = Depends(service)): +def download(request: Request, space: str, path: str, inline: bool = False, + files: SpaceFiles = Depends(service)): data, info = files.read(space, path) mime = mimetypes.guess_type(info.name)[0] or "application/octet-stream" safe_inline = inline and (mime.startswith(("image/", "audio/", "video/", "text/")) or mime == "application/pdf") - return Response(content=data, media_type=mime if safe_inline else "application/octet-stream", + # Range lets the canvas audio/video player seek without refetching the whole file. + return byte_range_response(data, request.headers.get("range"), + media_type=mime if safe_inline else "application/octet-stream", headers={"Content-Disposition": ("inline" if safe_inline else "attachment") + "; filename*=UTF-8''" + quote(info.name, safe=""), "Content-Security-Policy": "sandbox", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff"}) diff --git a/src/backend/api/routes/v1/space_previews.py b/src/backend/api/routes/v1/space_previews.py index 2cf49a61..f4750be5 100644 --- a/src/backend/api/routes/v1/space_previews.py +++ b/src/backend/api/routes/v1/space_previews.py @@ -6,8 +6,9 @@ import tempfile from urllib.parse import quote -from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Response +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, Response from core.files.content.archive_browser import ArchiveError, inspect_archive, read_archive_member +from core.infra.byte_range import byte_range_response from core.infra.responses import success_response from core.files.spaces.files import SpaceFiles from api.routes.v1.space_files import service @@ -58,7 +59,7 @@ def archive(space: str, path: str, response: Response, files: SpaceFiles = Depen @router.get("/archive/member") -def member(space: str, path: str, member: str, inline: bool = False, +def member(request: Request, space: str, path: str, member: str, inline: bool = False, files: SpaceFiles = Depends(service)): with source(files, space, path) as (local, name): data = read_archive_member(local, name, member) @@ -67,7 +68,7 @@ def member(space: str, path: str, member: str, inline: bool = False, if mime in ("text/html", "image/svg+xml"): mime = "text/plain" safe = inline and (mime.startswith(("image/", "audio/", "video/", "text/")) or mime == "application/pdf") - return Response(data, media_type=mime, headers={ + return byte_range_response(data, request.headers.get("range"), media_type=mime, headers={ "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff", "Content-Security-Policy": "sandbox; default-src 'none'", "Content-Disposition": ("inline" if safe else "attachment") + "; filename*=UTF-8''" + quote(filename, safe=""), diff --git a/src/backend/core/chat/context.py b/src/backend/core/chat/context.py index 8224591e..ef08a5ea 100644 --- a/src/backend/core/chat/context.py +++ b/src/backend/core/chat/context.py @@ -12,10 +12,12 @@ from datetime import datetime from typing import Any, Dict, List, Optional +from pydantic import ValidationError from sqlalchemy.orm import Session from core.capabilities.catalog.resolver import resolve_all_runtime_enabled from core.auth.users import UserService +from core.chat.schemas import QuoteFileSource, QuotedFollowUpItem logger = logging.getLogger(__name__) @@ -73,43 +75,64 @@ def generate_smart_title(message: str) -> str: return message if len(message) <= 20 else message[:20] + "..." +def _parse_quote(quoted_follow_up: Optional[Any]) -> Optional[QuotedFollowUpItem]: + """Request model as-is; a stored ``extra_data`` dict is validated the same way, and an + unreadable one is dropped rather than failing the replayed turn.""" + if quoted_follow_up is None or isinstance(quoted_follow_up, QuotedFollowUpItem): + return quoted_follow_up + try: + return QuotedFollowUpItem.model_validate(quoted_follow_up) + except ValidationError: + return None + + +def _render_quote(quote: QuotedFollowUpItem, message: str) -> str: + source = quote.source + if isinstance(source, QuoteFileSource): + origin = f"文件《{source.file_name}》(文件 ID:{source.file_id}" + origin += f",位置:{source.locator})" if source.locator else ")" + return ( + f"用户在右侧预览中打开了{origin},选取了其中一段原文并就它提问。\n" + "要求:\n" + "1. 将【引用原文】视为这次提问直接针对的内容;“这段/这里/它/上面”等指代优先指向它。\n" + "2. 回答时以引用原文为准;需要文件其余部分的上下文时,按上面的文件名或文件 ID 查阅原文件。\n" + "3. 直接回答用户的问题,不要重复无关背景,也不要提及本提示词。\n\n" + f"【引用原文】(摘自{origin})\n{quote.text}\n\n" + f"【用户提问】\n{message}" + ) + return ( + "你正在回答同一会话中的一条追问消息。请优先结合当前会话上下文,并重点参考下面的引用原文来理解代词、省略和上下文指向。\n" + "要求:\n" + "1. 将【引用原文】视为这次追问直接关联的内容。\n" + "2. 若用户问题中出现“这个/这个点/它/上述/刚才提到的”等指代,优先从【引用原文】和最近几轮会话补全语义。\n" + "3. 直接回答用户当前追问,不要重复无关背景,也不要提及本提示词或“根据引用原文”。\n\n" + f"【引用原文】\n{quote.text}\n\n" + f"【用户追问】\n{message}" + ) + + def build_effective_user_message( message: str, quoted_follow_up: Optional[Any], reference_block: str = "", ) -> str: - """Splice the "follow-up quote" into the user message (domain-level assembly shared by send and history replay, not HTTP-layer logic). + """Splice the user's quote into the user message (domain-level assembly shared by send and history replay, not HTTP-layer logic). - ``quoted_follow_up`` accepts any object with a ``text`` attribute (pydantic model) or - a ``{"text": ...}`` dict; returns unchanged when there is no quote. + ``quoted_follow_up`` is a ``QuotedFollowUpItem`` or its stored dict form; its + ``source`` decides the framing — a passage from an earlier message in this chat, + or a passage from a file the user has open in the canvas (named, with its ID and + locator, so the model knows which file and where). No quote → message unchanged. ``reference_block`` 是用户这一轮显式引用的历史会话名片(见 ``core.chat.references.render_reference_block``),拼在最前面:它交代 的是"回答这条消息要参考哪些会话",属于这一轮的背景,先于追问引用和用户原话。 """ - if isinstance(quoted_follow_up, dict): - quote_text = quoted_follow_up.get("text") - else: - quote_text = getattr(quoted_follow_up, "text", None) if quoted_follow_up is not None else None prefix = (reference_block or "").strip() prefix = f"{prefix}\n\n" if prefix else "" - - if not quote_text: + quote = _parse_quote(quoted_follow_up) + if quote is None: return f"{prefix}{message}" - - quote = str(quote_text).strip() - if not quote: - return f"{prefix}{message}" - - return prefix + ( - "你正在回答同一会话中的一条追问消息。请优先结合当前会话上下文,并重点参考下面的引用原文来理解代词、省略和上下文指向。\n" - "要求:\n" - "1. 将【引用原文】视为这次追问直接关联的内容。\n" - "2. 若用户问题中出现“这个/这个点/它/上述/刚才提到的”等指代,优先从【引用原文】和最近几轮会话补全语义。\n" - "3. 直接回答用户当前追问,不要重复无关背景,也不要提及本提示词或“根据引用原文”。\n\n" - f"【引用原文】\n{quote}\n\n" - f"【用户追问】\n{message}" - ) + return prefix + _render_quote(quote, message) def resolve_user_facing_error(exc: Exception) -> str: diff --git a/src/backend/core/chat/schemas.py b/src/backend/core/chat/schemas.py index 1968086e..47bc1896 100644 --- a/src/backend/core/chat/schemas.py +++ b/src/backend/core/chat/schemas.py @@ -1,6 +1,6 @@ """API request/response models.""" -from typing import Any, Dict, List, Literal, Optional +from typing import Annotated, Any, Dict, List, Literal, Optional, Union from core.config.settings import DEFAULT_CHAT_MODEL_ALIAS from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator, model_validator @@ -16,11 +16,32 @@ class AttachmentItem(BaseModel): file_id: str = Field(..., min_length=1, description="持久化后的文件 ID") +class QuoteMessageSource(BaseModel): + """引用来自本会话的一条消息。""" + + kind: Literal["message"] + ts: Optional[int] = Field(None, description="被引用消息的前端时间戳") + + +class QuoteFileSource(BaseModel): + """引用来自右侧预览中打开的一份文件。""" + + kind: Literal["file"] + file_id: str = Field(..., min_length=1, max_length=128, description="文件 ID") + file_name: str = Field(..., min_length=1, max_length=255, description="文件名") + locator: Optional[str] = Field( + None, max_length=200, description="文件内位置:最近的标题、页码或 Sheet1!A1:C5 这类区域" + ) + + +QuoteSource = Annotated[Union[QuoteMessageSource, QuoteFileSource], Field(discriminator="kind")] + + class QuotedFollowUpItem(BaseModel): - """追问引用信息""" + """用户为本轮引用的一段原文,及其出处。""" text: str = Field(..., description="被引用的原始文本", min_length=1, max_length=8000) - ts: Optional[int] = Field(None, description="前端消息时间戳(可选)") + source: Optional[QuoteSource] = Field(None, description="原文出处:会话消息或文件") @field_validator("text") @classmethod @@ -188,7 +209,7 @@ class ChatRequest(BaseModel): ) quoted_follow_up: Optional[QuotedFollowUpItem] = Field( default=None, - description="追问场景下引用的原始文本,用于增强上下文理解", + description="本轮引用的原文及出处(会话消息或文件),拼进发给模型的用户消息", ) project_id: Optional[str] = Field( default=None, diff --git a/src/backend/core/infra/byte_range.py b/src/backend/core/infra/byte_range.py new file mode 100644 index 00000000..0cdd13f2 --- /dev/null +++ b/src/backend/core/infra/byte_range.py @@ -0,0 +1,71 @@ +"""HTTP single-range responses for content already held in memory. + +Starlette's ``FileResponse`` answers ``Range`` requests by itself; routes that +return ``Response(bytes)`` do not, so browsers cannot seek inside audio/video +served by them. Multi-range requests are answered with the full body (200), +which RFC 9110 allows. +""" + +import re +from typing import Mapping, Optional + +from starlette.responses import Response + +_SINGLE_RANGE = re.compile(r"bytes=(\d*)-(\d*)", re.IGNORECASE) + + +def _resolve(range_header: str, size: int) -> Optional[tuple[int, int]]: + """Return the inclusive ``(start, end)`` span, ``None`` to send the full body, + or raise ``ValueError`` when the range cannot be satisfied.""" + match = _SINGLE_RANGE.fullmatch(range_header.strip()) + if not match or match.groups() == ("", ""): + return None + first, last = match.groups() + if first and last and int(first) > int(last): + # An invalid range-spec is ignored (RFC 9110 §14.2), not refused. + return None + if first: + start = int(first) + end = min(int(last), size - 1) if last else size - 1 + else: + suffix = int(last) + if suffix == 0: + raise ValueError("empty suffix range") + start, end = max(size - suffix, 0), size - 1 + if start >= size: + raise ValueError("range outside content") + return start, end + + +def is_continuation_range(range_header: Optional[str]) -> bool: + """True for a range that resumes mid-file — the follow-up requests a media + player sends while seeking, which should not be audited as new reads.""" + match = _SINGLE_RANGE.fullmatch((range_header or "").strip()) + return bool(match and match.group(1) and int(match.group(1)) > 0) + + +def byte_range_response( + data: bytes, + range_header: Optional[str], + *, + media_type: str, + headers: Mapping[str, str], +) -> Response: + """Serve ``data`` whole (200), as one slice (206) or as unsatisfiable (416).""" + base = {**headers, "Accept-Ranges": "bytes"} + if not range_header: + return Response(data, media_type=media_type, headers=base) + size = len(data) + try: + span = _resolve(range_header, size) + except ValueError: + return Response(status_code=416, headers={**base, "Content-Range": f"bytes */{size}"}) + if span is None: + return Response(data, media_type=media_type, headers=base) + start, end = span + return Response( + data[start : end + 1], + status_code=206, + media_type=media_type, + headers={**base, "Content-Range": f"bytes {start}-{end}/{size}"}, + ) diff --git a/src/backend/core/sandbox/processes/cloud.py b/src/backend/core/sandbox/processes/cloud.py index a78f79ea..24a3692a 100644 --- a/src/backend/core/sandbox/processes/cloud.py +++ b/src/backend/core/sandbox/processes/cloud.py @@ -17,6 +17,10 @@ from core.sandbox.processes.sessions import ProcessSessionError, ProcessSessions +# No process outlives its command, officecli's resident included: write each edit to disk at once. +_COMMAND_ENVS = {"OFFICECLI_RESIDENT_FLUSH": "each"} + + def _module_owned(session_id) -> bool: """A module's dedicated sandbox never receives the platform's extra credentials.""" from core.harness.registry import sandbox_route @@ -139,9 +143,13 @@ async def _spawn(self, req): working_directory=WORKSPACE, uid=SANDBOX_RUN_UID, gid=SANDBOX_RUN_GID, - envs=({**_EXECD_BASH_ENVS, "EXECD_ACCESS_TOKEN": ""} if _module_owned(req.session_id) else {**_EXECD_BASH_ENVS, **credential_environment(), **_firecrawl_extra_envs()}), + envs=({**_EXECD_BASH_ENVS, **_COMMAND_ENVS, "EXECD_ACCESS_TOKEN": ""} if _module_owned(req.session_id) else {**_EXECD_BASH_ENVS, **_COMMAND_ENVS, **credential_environment(), **_firecrawl_extra_envs()}), + ) + # As a subreaper the command keeps everything it starts, however detached, + # until it ends; whatever is left then reaches PID 1 and is killed there. + execution = await sess.sandbox.commands.run( + "sandbox-init --subreaper " + command, opts=opts ) - execution = await sess.sandbox.commands.run(command, opts=opts) if not execution.id: raise SandboxError("Background command returned no execution ID") return OpenSandboxHandle(provider, req, sess.sandbox.commands, execution.id) @@ -157,9 +165,9 @@ async def _spawn(self, req): cwd=WORKSPACE, user="root", envs=( - {"EXECD_ACCESS_TOKEN": ""} + {**_COMMAND_ENVS, "EXECD_ACCESS_TOKEN": ""} if _module_owned(req.session_id) - else {**credential_environment(), **_firecrawl_extra_envs()} or None + else {**_COMMAND_ENVS, **credential_environment(), **_firecrawl_extra_envs()} ), stdin=False, timeout=0, diff --git a/src/backend/core/sandbox/providers/opensandbox/creation.py b/src/backend/core/sandbox/providers/opensandbox/creation.py index 35abf70d..01e5d6bb 100644 --- a/src/backend/core/sandbox/providers/opensandbox/creation.py +++ b/src/backend/core/sandbox/providers/opensandbox/creation.py @@ -92,7 +92,7 @@ async def _create_jupyter_sandbox( return sbx async def _create_light_sandbox(self) -> Any: - """Create a light sandbox: just the sleep entrypoint + execd, no Jupyter. + """Create a light sandbox: just the init entrypoint + execd, no Jupyter. Used for ephemeral one-shot execution (the frontend "re-run" button, skill calls without a chat_id). Key point: ``skip_health_check=True`` ── the SDK's default readiness probe diff --git a/src/backend/core/sandbox/providers/opensandbox/runtime.py b/src/backend/core/sandbox/providers/opensandbox/runtime.py index a63fb6b7..6871edfc 100644 --- a/src/backend/core/sandbox/providers/opensandbox/runtime.py +++ b/src/backend/core/sandbox/providers/opensandbox/runtime.py @@ -69,14 +69,16 @@ async def write_file(sbx: Any, path: str, content: Any) -> None: # The ephemeral path does not start Jupyter; the injected execd alone provides commands.run. # Startup drops from ~10s to ~3s. -_LIGHT_ENTRYPOINT = ["sleep", "infinity"] +# PID 1 is sandbox-init (docker/sandbox/sandbox-init.c): it reaps every orphan and kills +# what user commands, which run as SANDBOX_RUN_UID, leave behind. +_LIGHT_ENTRYPOINT = ["sandbox-init", str(SANDBOX_RUN_UID)] # 用户绑定沙箱的 entrypoint:容器启动时就建好目录约定(scratch + /myspace 软链), # 首条命令不必再为此花一次 execd 往返(实测固定 ~1s,而这段 shell 只要 2ms)。uid 走 # 环境变量,entrypoint 保持常量,adopt 仍能按它认出自己创建的沙箱。 # # 两个约束:``/myspace`` 已是真目录时不能直接 ln(软链会建到目录里面),所以先判断; -# 整段不许中途退出,任何一步失败都要走到 exec sleep infinity,否则容器直接死掉。 +# 整段不许中途退出,任何一步失败都要走到 exec sandbox-init,否则容器直接死掉。 _SETUP_ENTRYPOINT = [ "/bin/sh", "-c", @@ -89,7 +91,7 @@ async def write_file(sbx: Any, path: str, content: Any) -> None: 'rm -f /myspace; ln -s "/workspace/myspace/$JX_MYSPACE_UID" /myspace 2>/dev/null; ' "fi; " "fi; " - "exec sleep infinity", + f"exec sandbox-init {SANDBOX_RUN_UID}", ] diff --git a/src/backend/core/sandbox/runner/server.py b/src/backend/core/sandbox/runner/server.py index 398b638f..515103f4 100644 --- a/src/backend/core/sandbox/runner/server.py +++ b/src/backend/core/sandbox/runner/server.py @@ -149,6 +149,7 @@ def _session_workspace( "DOTNET_CLI_TELEMETRY_OPTOUT": "1", # disable dotnet telemetry "DOTNET_NOLOGO": "1", # suppress dotnet startup banner "DOTNET_EnableDiagnostics": "0", # stop dotnet from creating diagnostic pipes/core dump files + "OFFICECLI_RESIDENT_FLUSH": "each", # the resident dies with the command's process group; flush every edit } if os.name != "nt": SAFE_ENV.update( diff --git a/src/backend/personal_agent/tools/tools.py b/src/backend/personal_agent/tools/tools.py index a272ce98..9a4eaa4d 100644 --- a/src/backend/personal_agent/tools/tools.py +++ b/src/backend/personal_agent/tools/tools.py @@ -60,9 +60,8 @@ async def pa_bash(command: str, timeout_seconds: int = 60) -> ToolChunk: from hugagent_cli.transport import enabled # Most deployments are private networks without TLS, so plain HTTP is allowed unless disabled. allow_http = "1" if enabled(os.environ.get("PERSONAL_AGENT_CLI_ALLOW_HTTP", "1")) else "0" - # Each call's processes end with it, officecli's resident too: flush its edits before every command returns. script = ("export HUGAGENT_SERVER=" + shlex.quote(server) + " HUGAGENT_API_KEY=" + shlex.quote(token) + " HUGAGENT_ALLOW_HTTP=" + allow_http - + " OFFICECLI_RESIDENT_FLUSH=each\n" + command) + + "\n" + command) try: result = await provider.run_to_completion(ProcessRequest(script_content=script, script_name="personal-task.sh", language="bash", timeout=duration, user_id=uid, session_id=state["box"])) finally: diff --git a/src/backend/tests/chat/test_quoted_follow_up.py b/src/backend/tests/chat/test_quoted_follow_up.py new file mode 100644 index 00000000..16f104a4 --- /dev/null +++ b/src/backend/tests/chat/test_quoted_follow_up.py @@ -0,0 +1,93 @@ +"""The user's quote — from a chat message or from a file open in the canvas — reaches the model.""" + +from types import SimpleNamespace + +import pytest +from pydantic import ValidationError + +from core.chat.compaction.history import normalize_rows +from core.chat.context import build_effective_user_message +from core.chat.schemas import ChatRequest, QuoteFileSource, QuoteMessageSource + +FILE_QUOTE = { + "text": " 第二季度营收同比增长 18% ", + "source": { + "kind": "file", + "file_id": "file_abc", + "file_name": "经营报告.docx", + "locator": "第 3 页 · 2.1 营收", + }, +} + + +def test_request_parses_file_and_message_sources(): + request = ChatRequest(chat_id="c1", message="解释这段内容", quoted_follow_up=FILE_QUOTE) + quote = request.quoted_follow_up + assert quote.text == "第二季度营收同比增长 18%" + assert isinstance(quote.source, QuoteFileSource) + assert (quote.source.file_name, quote.source.locator) == ("经营报告.docx", "第 3 页 · 2.1 营收") + + request = ChatRequest( + chat_id="c1", + message="展开说说", + quoted_follow_up={"text": "上一轮的结论", "source": {"kind": "message", "ts": 1700}}, + ) + assert isinstance(request.quoted_follow_up.source, QuoteMessageSource) + assert request.quoted_follow_up.source.ts == 1700 + + +@pytest.mark.parametrize( + "source", + [ + {"kind": "file", "file_id": "f1"}, # a file quote must name the file + {"kind": "page", "page": 3}, # unknown origin + {"kind": "file", "file_id": "f1", "file_name": "a.md", "locator": "x" * 201}, + ], +) +def test_request_rejects_malformed_sources(source): + with pytest.raises(ValidationError) as caught: + ChatRequest(chat_id="c1", message="hi", quoted_follow_up={"text": "原文", "source": source}) + assert all(error["loc"][0] == "quoted_follow_up" for error in caught.value.errors()) + + +def test_file_quote_tells_the_model_which_file_and_where(): + request = ChatRequest(chat_id="c1", message="解释这段内容", quoted_follow_up=FILE_QUOTE) + sent = build_effective_user_message(request.message, request.quoted_follow_up) + + assert "经营报告.docx" in sent and "file_abc" in sent and "第 3 页 · 2.1 营收" in sent + assert sent.index("【引用原文】") < sent.index("第二季度营收同比增长 18%") + assert sent.index("第二季度营收同比增长 18%") < sent.index("【用户提问】") + assert sent.rstrip().endswith("解释这段内容") + assert "追问" not in sent + + +def test_file_quote_without_locator_still_names_the_file(): + quote = {"text": "原文", "source": {"kind": "file", "file_id": "f1", "file_name": "a.md"}} + sent = build_effective_user_message("改写", quote) + assert "a.md" in sent and "位置" not in sent + + +def test_message_quote_keeps_follow_up_framing(): + for quote in ({"text": "上一轮的结论", "source": {"kind": "message", "ts": 1}}, {"text": "上一轮的结论"}): + sent = build_effective_user_message("为什么", quote) + assert "【引用原文】\n上一轮的结论" in sent and sent.endswith("【用户追问】\n为什么") + + +def test_unreadable_stored_quote_is_dropped_not_fatal(): + assert build_effective_user_message("你好", {"source": {"kind": "file"}}) == "你好" + assert build_effective_user_message("你好", None) == "你好" + + +def test_history_replay_keeps_the_file_source(): + """The stored turn (extra_data is the request's model_dump) replays with the same framing.""" + request = ChatRequest(chat_id="c1", message="解释这段内容", quoted_follow_up=FILE_QUOTE) + row = SimpleNamespace( + role="user", + content=request.message, + extra_data={"quoted_follow_up": request.quoted_follow_up.model_dump()}, + ) + [replayed] = normalize_rows([row]) + assert replayed["content"] == build_effective_user_message( + request.message, request.quoted_follow_up + ) + assert "经营报告.docx" in replayed["content"] diff --git a/src/backend/tests/content/test_file_preview.py b/src/backend/tests/content/test_file_preview.py index 975696cf..9d1a6323 100644 --- a/src/backend/tests/content/test_file_preview.py +++ b/src/backend/tests/content/test_file_preview.py @@ -105,5 +105,71 @@ def test_preview_file_rejects_non_powerpoint(monkeypatch): ) assert exc_info.value.status_code == 400 - # Production code files.py has extended the preview hint message to also support Word (feature drift, unrelated to the agentscope migration) - assert exc_info.value.detail == "Only Word (DOC/DOCX) and PowerPoint (PPT/PPTX) files support preview" + assert exc_info.value.detail == "Only Word (DOC/DOCX/RTF/ODT) and PowerPoint (PPT/PPTX/ODP) files support preview" + + +@pytest.mark.parametrize(("name", "mime_type"), [ + ("旧版合同.doc", "application/msword"), + ("notes.rtf", "application/rtf"), + ("draft.odt", "application/vnd.oasis.opendocument.text"), + ("deck.odp", "application/vnd.oasis.opendocument.presentation"), + ("unnamed", "application/msword"), +]) +def test_non_ooxml_office_documents_are_previewable(name, mime_type): + assert file_routes._is_office_previewable({"name": name, "mime_type": mime_type}) + + +def test_spreadsheets_are_not_routed_through_office_preview(): + assert not file_routes._is_office_previewable({"name": "data.xlsx", "mime_type": ""}) + + +def test_legacy_doc_converts_to_pdf_with_libreoffice(tmp_path): + """Binary Word (.doc) cannot be parsed by docx-preview; the server must render it.""" + import subprocess + + from core.files.content.office import find_libreoffice_binary + + libreoffice = find_libreoffice_binary() + if not libreoffice: + pytest.skip("LibreOffice is not installed") + source = tmp_path / "legacy.txt" + source.write_text("Legacy Word document", encoding="utf-8") + subprocess.run( + [libreoffice, f"-env:UserInstallation={(tmp_path / 'profile').as_uri()}", "--headless", + "--convert-to", "doc:MS Word 97", "--outdir", str(tmp_path), str(source)], + check=True, capture_output=True, timeout=120, + ) + doc = tmp_path / "legacy.doc" + assert doc.read_bytes()[:8] == bytes.fromhex("d0cf11e0a1b11ae1") # OLE2 container, not OOXML + + pdf_path, temp_dir = file_routes._convert_office_to_pdf(str(doc), "doc_1") + try: + assert Path(pdf_path).read_bytes().startswith(b"%PDF") + finally: + file_routes._cleanup_path(temp_dir) + + +def test_direct_download_supports_range_for_media_seeking(tmp_path): + from fastapi import FastAPI + from fastapi.testclient import TestClient + + media = tmp_path / "clip.mp4" + media.write_bytes(b"0123456789") + item = {"name": "clip.mp4", "mime_type": "video/mp4", "path": str(media)} + app = FastAPI() + + @app.get("/clip") + def clip(background_tasks: BackgroundTasks): + return file_routes._build_direct_download_response( + item=item, file_id="clip", inline=True, background_tasks=background_tasks, + ) + + with TestClient(app) as client: + full = client.get("/clip") + partial = client.get("/clip", headers={"Range": "bytes=6-"}) + + assert full.headers["accept-ranges"] == "bytes" + assert partial.status_code == 206 + assert partial.content == b"6789" + assert partial.headers["content-range"] == "bytes 6-9/10" + assert partial.headers["content-disposition"].startswith("inline") diff --git a/src/backend/tests/infra/test_byte_range.py b/src/backend/tests/infra/test_byte_range.py new file mode 100644 index 00000000..8a2bc813 --- /dev/null +++ b/src/backend/tests/infra/test_byte_range.py @@ -0,0 +1,56 @@ +"""Single-range responses for in-memory bodies (audio/video seeking).""" +import pytest + +from core.infra.byte_range import byte_range_response, is_continuation_range + +DATA = b"0123456789" + + +def _call(range_header): + return byte_range_response(DATA, range_header, media_type="video/mp4", headers={"X-Test": "1"}) + + +def test_without_range_sends_full_body_and_advertises_ranges(): + response = _call(None) + assert response.status_code == 200 + assert response.body == DATA + assert response.headers["accept-ranges"] == "bytes" + assert response.headers["x-test"] == "1" + + +@pytest.mark.parametrize(("header", "body", "content_range"), [ + ("bytes=2-5", b"2345", "bytes 2-5/10"), + ("bytes=7-", b"789", "bytes 7-9/10"), + ("bytes=-3", b"789", "bytes 7-9/10"), + ("bytes=8-100", b"89", "bytes 8-9/10"), + ("bytes=-100", DATA, "bytes 0-9/10"), + ("Bytes=2-5", b"2345", "bytes 2-5/10"), +]) +def test_single_range_returns_partial_content(header, body, content_range): + response = _call(header) + assert response.status_code == 206 + assert response.body == body + assert response.headers["content-range"] == content_range + assert response.headers["content-length"] == str(len(body)) + assert response.headers["content-type"] == "video/mp4" + + +@pytest.mark.parametrize("header", ["bytes=10-", "bytes=-0"]) +def test_unsatisfiable_range_returns_416(header): + response = _call(header) + assert response.status_code == 416 + assert response.headers["content-range"] == "bytes */10" + + +@pytest.mark.parametrize("header", ["bytes=0-1,4-5", "items=0-3", "bytes=-", "bytes=5-2"]) +def test_unsupported_range_falls_back_to_full_body(header): + response = _call(header) + assert response.status_code == 200 + assert response.body == DATA + + +@pytest.mark.parametrize(("header", "expected"), [ + (None, False), ("bytes=0-", False), ("bytes=-500", False), ("bytes=1024-", True), ("bytes=0-1,4-5", False), +]) +def test_continuation_range_detection(header, expected): + assert is_continuation_range(header) is expected diff --git a/src/backend/tests/sandbox/test_cloud_process_sessions.py b/src/backend/tests/sandbox/test_cloud_process_sessions.py index 9cb6ede8..75b4d8e0 100644 --- a/src/backend/tests/sandbox/test_cloud_process_sessions.py +++ b/src/backend/tests/sandbox/test_cloud_process_sessions.py @@ -45,8 +45,10 @@ async def test_opensandbox_starts_without_execution_timeout_and_interrupts_owner ) assert first["status"] == "running" opts = commands.run.call_args.kwargs["opts"] + assert commands.run.call_args.args[0].startswith("sandbox-init --subreaper bash -c ") assert opts.background is True assert opts.timeout is None + assert opts.envs["OFFICECLI_RESIDENT_FLUSH"] == "each" from core.sandbox.errors import SandboxError with pytest.raises(SandboxError, match="Unknown process session"): @@ -125,6 +127,7 @@ async def run(cmd, **kwargs): assert kwargs["background"] is True assert kwargs["timeout"] == 0 assert kwargs["stdin"] is False + assert kwargs["envs"]["OFFICECLI_RESIDENT_FLUSH"] == "each" kwargs["on_stdout"]("cube-output") return command @@ -299,7 +302,8 @@ async def run(command, opts): proc = await asyncio.create_subprocess_exec( "bash", "-c", - command, + # sandbox-init ships with the sandbox image, not with this host. + command.removeprefix("sandbox-init --subreaper "), stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, cwd=opts.working_directory, diff --git a/src/backend/tests/sandbox/test_sandbox_init.py b/src/backend/tests/sandbox/test_sandbox_init.py new file mode 100644 index 00000000..e173403c --- /dev/null +++ b/src/backend/tests/sandbox/test_sandbox_init.py @@ -0,0 +1,70 @@ +"""sandbox-init: no process of the sandbox user outlives the command that started it. + +The sandbox's PID 1 and the subreaper each user command runs under, exercised +the way the container uses them, in a fresh PID namespace. +""" + +from __future__ import annotations + +import shlex +import shutil +import subprocess +import sys +import time +import uuid +from pathlib import Path + +import pytest + +SOURCE = Path(__file__).resolve().parents[4] / "docker" / "sandbox" / "sandbox-init.c" +NAMESPACE = ["unshare", "--user", "--map-root-user", "--pid", "--fork", "--mount-proc"] + +pytestmark = pytest.mark.skipif( + not sys.platform.startswith("linux") + or not shutil.which("cc") + or not shutil.which("unshare") + or not SOURCE.exists(), + reason="needs Linux, a C compiler, unshare and the sandbox image sources", +) + + +def _running(argv: list[str]) -> bool: + wanted = "\0".join(argv).encode() + b"\0" + for entry in Path("/proc").iterdir(): + try: + if entry.name.isdigit() and (entry / "cmdline").read_bytes() == wanted: + return True + except OSError: + continue + return False + + +def test_a_detached_process_lives_while_its_command_runs_and_dies_with_it(tmp_path): + if subprocess.run([*NAMESPACE, "true"], capture_output=True).returncode != 0: + pytest.skip("needs unprivileged PID namespaces") + init = tmp_path / "sandbox-init" + subprocess.run(["cc", "-O2", "-o", str(init), str(SOURCE)], check=True) + leftover = ["sleep", str(10_000 + uuid.uuid4().int % 10_000)] + # Detach the way a daemon does: double fork into a new session. + command = ( + f"(setsid {shlex.join(leftover)} & echo $! > held); echo $$ > command; " + "sleep 0.5; cut -d' ' -f4 /proc/$(cat held)/stat > seen && mv seen parent" + ) + # As in the container: PID 1 runs a command as a subreaper, then becomes the init. + # Root inside the namespace is the user here, so that is the uid it polices. + pid1 = subprocess.Popen( + [*NAMESPACE, "sh", "-c", f'"$0" --subreaper sh -c {shlex.quote(command)}; exec "$0" 0', str(init)], + cwd=tmp_path, + ) + try: + deadline = time.monotonic() + 5 + while not (tmp_path / "parent").exists() and time.monotonic() < deadline: + time.sleep(0.05) + assert (tmp_path / "parent").read_text().split() == (tmp_path / "command").read_text().split() + while _running(leftover) and time.monotonic() < deadline: + time.sleep(0.05) + assert not _running(leftover) + assert pid1.poll() is None + finally: + pid1.kill() + pid1.wait() diff --git a/src/backend/tests/test_script_runner_process_limits.py b/src/backend/tests/test_script_runner_process_limits.py index 45cc333d..42f07a4e 100644 --- a/src/backend/tests/test_script_runner_process_limits.py +++ b/src/backend/tests/test_script_runner_process_limits.py @@ -177,7 +177,7 @@ def test_ce_installer_and_script_runner_ship_office_skill_runtime(): assert f"/usr/local/bin/{retired}" not in dockerfile -def test_local_managed_officecli_disables_self_update(tmp_path): +def test_managed_officecli_flushes_every_edit_and_skips_update_locally(tmp_path): import os import subprocess @@ -186,7 +186,7 @@ def test_local_managed_officecli_disables_self_update(tmp_path): from core.sandbox.runner.server import app with TestClient(app) as client: response = client.post('/processes/start', json={ - 'script_content': "import os; print(os.getenv('OFFICECLI_SKIP_UPDATE', 'unset'))", + 'script_content': "import os; print(os.getenv('OFFICECLI_SKIP_UPDATE', 'unset'), os.getenv('OFFICECLI_RESIDENT_FLUSH'))", 'script_name': 'check_env.py', 'language': 'python', 'session_id': 'officecli-env', }) assert response.status_code == 200, response.text @@ -194,7 +194,7 @@ def test_local_managed_officecli_disables_self_update(tmp_path): assert result['exit_code'] == 0, result print(result['stdout'].strip()) """ - for profile, expected in (("local", "1"), ("docker", "unset")): + for profile, expected in (("local", "1 each"), ("docker", "unset each")): result = subprocess.check_output( [sys.executable, "-c", code], env={ diff --git a/src/frontend/package-lock.json b/src/frontend/package-lock.json index c75b6510..5274e895 100644 --- a/src/frontend/package-lock.json +++ b/src/frontend/package-lock.json @@ -45,6 +45,7 @@ "mermaid": "^11.14.0", "motion": "^12.38.0", "opentype.js": "1.3.4", + "pdfjs-dist": "^5.7.284", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router": "^7.18.4", @@ -1471,6 +1472,256 @@ "langium": "^4.0.0" } }, + "node_modules/@napi-rs/canvas": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.100.tgz", + "integrity": "sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==", + "license": "MIT", + "optional": true, + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "0.1.100", + "@napi-rs/canvas-darwin-arm64": "0.1.100", + "@napi-rs/canvas-darwin-x64": "0.1.100", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.100", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.100", + "@napi-rs/canvas-linux-arm64-musl": "0.1.100", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.100", + "@napi-rs/canvas-linux-x64-gnu": "0.1.100", + "@napi-rs/canvas-linux-x64-musl": "0.1.100", + "@napi-rs/canvas-win32-arm64-msvc": "0.1.100", + "@napi-rs/canvas-win32-x64-msvc": "0.1.100" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.100.tgz", + "integrity": "sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.100.tgz", + "integrity": "sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.100.tgz", + "integrity": "sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.100.tgz", + "integrity": "sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.100.tgz", + "integrity": "sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.100.tgz", + "integrity": "sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.100.tgz", + "integrity": "sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.100.tgz", + "integrity": "sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.100.tgz", + "integrity": "sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-arm64-msvc": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.100.tgz", + "integrity": "sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.100.tgz", + "integrity": "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, "node_modules/@noble/ed25519": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@noble/ed25519/-/ed25519-2.3.0.tgz", @@ -8680,6 +8931,18 @@ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==" }, + "node_modules/pdfjs-dist": { + "version": "5.7.284", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.7.284.tgz", + "integrity": "sha512-h4EdYQczmGhbOlqc3PPZwxevn7ApdWPbovAuWXOB/DjIyigSnwfy2oze7c6mRcSr9XgLp3eN3EeL4DyySTPMFw==", + "license": "Apache-2.0", + "engines": { + "node": ">=22.13.0 || >=24" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^0.1.100" + } + }, "node_modules/performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", diff --git a/src/frontend/package.json b/src/frontend/package.json index a042ce3f..5249b87c 100755 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -10,6 +10,7 @@ "check:dark": "node scripts/check-dark-mode.mjs", "check:motion": "node scripts/check-loading-motion.mjs", "test:file-open-browser": "node scripts/test-file-open-browser.mjs", + "test:canvas-quote-browser": "node scripts/test-canvas-quote-browser.mjs", "test:tool-capability-icons": "esbuild scripts/test-tool-capability-icons.ts --bundle --platform=node --format=esm --outfile=node_modules/.tmp/test-tool-capability-icons.mjs && node node_modules/.tmp/test-tool-capability-icons.mjs", "test:subagent-canvas": "esbuild scripts/test-subagent-canvas.ts --bundle --platform=node --format=esm --outfile=node_modules/.tmp/test-subagent-canvas.mjs && node node_modules/.tmp/test-subagent-canvas.mjs", "test:subagent-canvas-browser": "node scripts/test-subagent-canvas-browser.mjs", @@ -54,6 +55,7 @@ "test:canvas-file-header": "node scripts/test-canvas-file-header-browser.mjs", "test:canvas-split": "node scripts/test-canvas-split-browser.mjs", "test:canvas-layout-browser": "node scripts/test-canvas-layout-browser.mjs", + "test:canvas-viewers-browser": "node scripts/test-canvas-viewers-browser.mjs", "test:chat-width-browser": "node scripts/test-chat-width-browser.mjs", "test:html-preview-browser": "node scripts/test-html-preview-browser.mjs", "test:desktop-menu-update": "esbuild scripts/test-desktop-update.tsx scripts/test-desktop-folder.ts scripts/test-client-download.ts --bundle --platform=node --format=esm --jsx=automatic --packages=external --define:import.meta.env={} --outdir=node_modules/.tmp/desktop-menu-update --out-extension:.js=.mjs && node node_modules/.tmp/desktop-menu-update/test-desktop-update.mjs && node node_modules/.tmp/desktop-menu-update/test-desktop-folder.mjs && node node_modules/.tmp/desktop-menu-update/test-client-download.mjs", @@ -147,6 +149,7 @@ "mermaid": "^11.14.0", "motion": "^12.38.0", "opentype.js": "1.3.4", + "pdfjs-dist": "^5.7.284", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router": "^7.18.4", diff --git a/src/frontend/scripts/desktop-lint-baseline.json b/src/frontend/scripts/desktop-lint-baseline.json index 54e498d7..ec80fe63 100644 --- a/src/frontend/scripts/desktop-lint-baseline.json +++ b/src/frontend/scripts/desktop-lint-baseline.json @@ -19,7 +19,6 @@ "src/components/chat/MermaidBlock.tsx": {"2:react-refresh/only-export-components": 1}, "src/components/chat/OntologyRevisionPanel.tsx": {"1:react-hooks/exhaustive-deps": 1}, "src/components/chat/PromptHubPanel.tsx": {"2:react-hooks/set-state-in-effect": 1}, - "src/components/chat/useMessageSelection.tsx": {"1:react-hooks/exhaustive-deps": 1}, "src/components/citation/CitationBadge.tsx": {"2:react-refresh/only-export-components": 1}, "src/components/citation/CitationHtmlBlock.tsx": {"2:react-hooks/set-state-in-effect": 1}, "src/components/citation/CitationMarkdownBlock.tsx": {"1:react-hooks/exhaustive-deps": 1}, diff --git a/src/frontend/scripts/test-attachment-send.tsx b/src/frontend/scripts/test-attachment-send.tsx index f78f99bf..1ab7433c 100644 --- a/src/frontend/scripts/test-attachment-send.tsx +++ b/src/frontend/scripts/test-attachment-send.tsx @@ -109,7 +109,7 @@ useChatStore.setState({currentChatId:'queue-owner',sending:true,planMode:false,l const retained = new File(['later'],'later.txt'); readComposer().setInput('Next turn'); readComposer().setUploadedFiles([retained]); -readComposer().setQuotedFollowUp({text:'Quoted question',ts:1}); +readComposer().setQuotedFollowUp({text:'Quoted question',source:{kind:'message',ts:1}}); readComposer().setActivePlugin({id:'p',name:'Plugin'}); await actions.send(); assert.equal(useChatStore.getState().queuedMessages['queue-owner'].content,'Next turn'); @@ -124,7 +124,7 @@ const { sendLoopMode } = await import('../src/hooks/useLoopMode'); useChatStore.setState({currentChatId:'loop-owner',sending:false,loopMode:true}); readComposer().setInput('Loop objective'); readComposer().setUploadedFiles([retained]); -readComposer().setQuotedFollowUp({text:'Keep quote',ts:2}); +readComposer().setQuotedFollowUp({text:'Keep quote',source:{kind:'message',ts:2}}); readComposer().setActivePlugin({id:'keep-plugin',name:'Keep plugin'}); globalThis.fetch=async (input) => String(input).endsWith('/start') ? new Response('data: [DONE]\n\n',{headers:{'Content-Type':'text/event-stream'}}) diff --git a/src/frontend/scripts/test-canvas-quote-browser.mjs b/src/frontend/scripts/test-canvas-quote-browser.mjs new file mode 100644 index 00000000..f53385bc --- /dev/null +++ b/src/frontend/scripts/test-canvas-quote-browser.mjs @@ -0,0 +1,131 @@ +// Selecting text in the canvas file preview → toolbar → quote / quick prompt lands in the chat composer. +import assert from 'node:assert/strict'; +import { build } from 'esbuild'; +import { mkdir, readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +const { chromium } = await import(process.env.PLAYWRIGHT_MODULE || 'playwright'); +const output = resolve('node_modules/.tmp/canvas-quote-browser'); +await mkdir(output, { recursive: true }); +await build({ plugins:[{name:'asset-url',setup(build){build.onResolve({filter:new RegExp('[?]url$')},args=>({path:args.path,namespace:'asset-url'}));build.onLoad({filter:/.*/,namespace:'asset-url'},()=>({contents:'export default "/mock-worklet.js"',loader:'js'}));}}], stdin: { contents: ` +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import { createMemoryRouter, RouterProvider } from 'react-router'; +import { InputArea } from './src/components/chat/InputArea'; +import { MessageBubble } from './src/components/chat/MessageBubble'; +import { RightSidebarPanel } from './src/components/canvas/RightSidebarPanel'; +import { useCanvasStore } from './src/stores/canvasStore'; +import { useChatStore } from './src/stores/chatStore'; +import { readComposer, chatDraftKey } from './src/stores/composerStore'; +import { quoteToWire } from './src/utils/quotedFollowUp'; +import './src/styles/variables.css'; +import './src/styles/common.css'; +import './src/styles/chat.css'; +import './src/styles/canvas.css'; + +useChatStore.setState({ currentUserId: 'quote-owner', currentChatId: 'a', + store: { order: ['a'], chats: { a: { id: 'a', title: 'a', createdAt: 1, updatedAt: 1, messages: [] } } } }); +function Shell() { + const inputRef = React.useRef(null), fileRef = React.useRef(null); + return
+
+ {}} exportChatRecord={async () => {}} /> +
+ {}} handleFileSelect={() => {}} removeFile={() => {}} /> +
+
+
; +} +const router = createMemoryRouter([{ path: '*', element: }], { initialEntries: ['/'] }); +window.fixture = { + canvas: useCanvasStore, router, + draft: () => { const d = readComposer(chatDraftKey('a')); return { input: d.input, quote: d.quotedFollowUp && quoteToWire(d.quotedFollowUp) }; }, + clearQuote: () => readComposer(chatDraftKey('a')).setQuotedFollowUp(null), +}; +createRoot(document.getElementById('root')).render(); +`, resolveDir:process.cwd(),loader:'tsx'},outfile:resolve(output,'fixture.js'),bundle:true,format:'esm',jsx:'automatic',define:{'import.meta.env':'{}'},external:['/loader.gif','/loader-done.png'],loader:{'.woff':'dataurl','.woff2':'dataurl','.ttf':'dataurl'}}); + +const MARKDOWN = '# 第一章 总览\n\n## 1.1 背景\n\n这是一段需要被引用的原文内容,用来验证选区工具条。\n'; +const browser = await chromium.launch({ headless: true, ...(process.env.PLAYWRIGHT_CHROMIUM ? { executablePath: process.env.PLAYWRIGHT_CHROMIUM } : {}) }); +try { + const page = await browser.newPage({ viewport: { width: 1400, height: 900 } }); + page.setDefaultTimeout(10000); + const errors = []; + page.on('pageerror', e => errors.push(e.message)); + await page.route('https://quote.test/**', async route => { + const path = new URL(route.request().url()).pathname; + if (path === '/fixture.js' || path === '/fixture.css') return route.fulfill({ contentType: path.endsWith('.js') ? 'text/javascript' : 'text/css', body: await readFile(resolve(output, path.slice(1))) }); + const text = path.match(/\/v1\/file\/([^/]+)\/text$/); + if (text) return route.fulfill({ json: { code: 0, data: { file_id: text[1], name: 'notes.md', content: MARKDOWN, revision: 'r1', size: MARKDOWN.length, can_edit: false } } }); + if (/^(\/api)?\/files\//.test(path)) return route.fulfill({ contentType: 'text/markdown', body: MARKDOWN }); + if (path.startsWith('/api')) return route.fulfill({ json: { code: 0, data: [] } }); + return route.fulfill({ contentType: 'text/html', body: '
' }); + }); + await page.goto('https://quote.test/'); + await page.evaluate(() => window.fixture.canvas.getState().openCanvas({ file_id: 'notes', name: 'notes.md', url: '/files/notes', mime_type: 'text/markdown' })); + const paragraph = page.locator('.jx-canvas-body p', { hasText: '需要被引用的原文' }); + await paragraph.waitFor(); + + const select = async (target = paragraph) => { + const box = await target.boundingBox(); + await page.mouse.move(box.x + 2, box.y + box.height / 2); + await page.mouse.down(); + await page.mouse.move(box.x + Math.min(box.width - 2, 260), box.y + box.height / 2, { steps: 8 }); + await page.mouse.up(); + return page.getByRole('toolbar', { name: '选中内容操作' }); + }; + + // 1. Quick prompt: quotes the selection with its file + heading, prefills (does not send). + let toolbar = await select(); + await toolbar.waitFor(); + const labels = await toolbar.getByRole('button').allInnerTexts(); + assert.deepEqual(labels.map(s => s.trim()), ['复制', '引用到对话', '解释', '改写', '翻译']); + await toolbar.getByRole('button', { name: '解释' }).dispatchEvent('mousedown', { button: 0 }); + await page.waitForFunction(() => !!window.fixture.draft().quote); + let draft = await page.evaluate(() => window.fixture.draft()); + assert.equal(draft.input, '解释这段内容'); + assert.equal(draft.quote.source.kind, 'file'); + assert.equal(draft.quote.source.file_id, 'notes'); + assert.equal(draft.quote.source.file_name, 'notes.md'); + assert.equal(draft.quote.source.locator, '1.1 背景'); + assert.ok(draft.quote.text.startsWith('这是一段'), draft.quote.text); + await page.getByRole('toolbar', { name: '选中内容操作' }).waitFor({ state: 'detached' }); + const chip = page.locator('.jx-inputQuote'); + await chip.waitFor(); + assert.equal((await chip.locator('.jx-inputQuoteBadge').innerText()).trim(), '文件引用'); + assert.equal((await chip.locator('.jx-inputQuoteSource').innerText()).trim(), 'notes.md · 1.1 背景'); + assert.equal((await page.locator('.jx-composerEditor').innerText()).trim(), '解释这段内容'); + assert.ok(await page.evaluate(() => document.activeElement?.classList.contains('jx-composerEditor')), '引用后光标应在输入框'); + await page.screenshot({ path: resolve(output, 'quoted.png') }); + + // 2. Plain quote replaces the previous one. + await page.evaluate(() => window.fixture.clearQuote()); + toolbar = await select(); + await toolbar.waitFor(); + await toolbar.getByRole('button', { name: '引用到对话' }).dispatchEvent('mousedown', { button: 0 }); + await page.waitForFunction(() => !!window.fixture.draft().quote); + draft = await page.evaluate(() => window.fixture.draft()); + assert.equal(draft.quote.source.file_name, 'notes.md'); + + // 3. Chat messages share the same toolbar: copy + follow-up quote with a message source. + await page.evaluate(() => window.fixture.clearQuote()); + toolbar = await select(page.locator('.jx-msgContent p', { hasText: '助手回答' })); + await toolbar.waitFor(); + assert.deepEqual((await toolbar.getByRole('button').allInnerTexts()).map(s => s.trim()), ['复制', '追问']); + await toolbar.getByRole('button', { name: '追问' }).dispatchEvent('mousedown', { button: 0 }); + await page.waitForFunction(() => !!window.fixture.draft().quote); + draft = await page.evaluate(() => window.fixture.draft()); + assert.deepEqual(draft.quote.source, { kind: 'message', ts: 4242 }); + assert.equal((await page.locator('.jx-inputQuoteBadge').innerText()).trim(), '追问引用'); + + // 4. Canvas opened outside a chat (an app page): copy only. + await page.evaluate(() => window.fixture.router.navigate('/apps/demo')); + toolbar = await select(); + await toolbar.waitFor(); + assert.deepEqual((await toolbar.getByRole('button').allInnerTexts()).map(s => s.trim()), ['复制']); + + assert.deepEqual(errors, []); + console.log('PASS: canvas selection → toolbar → file quote + prompt in composer; copy-only outside a chat'); +} finally { + await browser.close(); +} diff --git a/src/frontend/scripts/test-canvas-viewers-browser.mjs b/src/frontend/scripts/test-canvas-viewers-browser.mjs new file mode 100644 index 00000000..777c4a4d --- /dev/null +++ b/src/frontend/scripts/test-canvas-viewers-browser.mjs @@ -0,0 +1,213 @@ +// Canvas viewers in a real Vite dev server (pdf.js worker, ?worker and +// import.meta.glob assets need Vite): PDF render / virtualization / text-layer +// selection / zoom + fit-on-resize / find / bookmarks outline, code view +// highlighting / line numbers / wrap / find, markdown outline / find. +// PLAYWRIGHT_CHROMIUM may point at a cached chromium-headless-shell binary. +import assert from 'node:assert/strict'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { createServer } from 'vite'; +import { chromium } from 'playwright'; + +const dir = resolve('node_modules/.tmp/canvas-viewers'); +await mkdir(dir, { recursive: true }); +await writeFile(resolve(dir, 'index.html'), ` + +
+`); +await writeFile(resolve(dir, 'fixture.tsx'), `import { createRoot } from 'react-dom/client'; +import { RightSidebarPanel } from '../../../src/components/canvas/RightSidebarPanel'; +import { useCanvasStore } from '../../../src/stores/canvasStore'; +import { useCanvasZoomStore } from '../../../src/components/canvas/canvasZoom'; +import '../../../src/styles/index'; +import 'highlight.js/styles/github.css'; + +Object.assign(window, { canvasStore: useCanvasStore, zoomStore: useCanvasZoomStore }); +function Fixture() { + const open = useCanvasStore((s) => s.isOpen); + return
+
chat
+ {open &&
} +
; +} +createRoot(document.getElementById('root')!).render(); +`); +const server = await createServer({ server: { port: 0 }, logLevel: 'error' }); +await server.listen(); + +// Minimal multi-page PDF with Helvetica text and a 2-level outline. +function makePdf(pages = 30) { + const objs = []; + const add = (body) => { objs.push(body); return objs.length; }; + const catalog = add(null), pagesId = add(null), font = add('<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>'); + const pageIds = []; + for (let i = 1; i <= pages; i++) { + const lines = [`Chapter ${Math.ceil(i / 10)} - Page ${i}`, 'The quick brown fox jumps over the lazy dog.', `Needle marker on page ${i}.`, 'Selectable text layer line for quoting.']; + const stream = 'BT /F1 18 Tf 72 720 Td ' + lines.map((l, k) => `${k ? '0 -28 Td ' : ''}(${l}) Tj`).join(' ') + ' ET'; + const content = add(`<< /Length ${stream.length} >>\nstream\n${stream}\nendstream`); + pageIds.push(add(`<< /Type /Page /Parent ${pagesId} 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 ${font} 0 R >> >> /Contents ${content} 0 R >>`)); + } + objs[pagesId - 1] = `<< /Type /Pages /Kids [${pageIds.map((id) => `${id} 0 R`).join(' ')}] /Count ${pages} >>`; + // Outline: Chapter 1 (p1) > Section 1.2 (p5); Chapter 2 (p11); Chapter 3 (p21) + const outlines = add(null); + const ch = [add(null), add(null), add(null)]; + const sec = add(null); + const dest = (p, top = 792) => `[${pageIds[p - 1]} 0 R /XYZ 0 ${top} null]`; + objs[outlines - 1] = `<< /Type /Outlines /First ${ch[0]} 0 R /Last ${ch[2]} 0 R /Count 4 >>`; + objs[ch[0] - 1] = `<< /Title (Chapter 1) /Parent ${outlines} 0 R /Next ${ch[1]} 0 R /First ${sec} 0 R /Last ${sec} 0 R /Count 1 /Dest ${dest(1)} >>`; + objs[sec - 1] = `<< /Title (Section 1.2) /Parent ${ch[0]} 0 R /Dest ${dest(5, 700)} >>`; + objs[ch[1] - 1] = `<< /Title (Chapter 2) /Parent ${outlines} 0 R /Prev ${ch[0]} 0 R /Next ${ch[2]} 0 R /Dest ${dest(11)} >>`; + objs[ch[2] - 1] = `<< /Title (Chapter 3) /Parent ${outlines} 0 R /Prev ${ch[1]} 0 R /Dest ${dest(21)} >>`; + objs[catalog - 1] = `<< /Type /Catalog /Pages ${pagesId} 0 R /Outlines ${outlines} 0 R >>`; + let out = '%PDF-1.4\n'; + const offsets = []; + objs.forEach((body, i) => { offsets.push(out.length); out += `${i + 1} 0 obj\n${body}\nendobj\n`; }); + const xref = out.length; + out += `xref\n0 ${objs.length + 1}\n0000000000 65535 f \n` + offsets.map((o) => `${String(o).padStart(10, '0')} 00000 n \n`).join(''); + out += `trailer\n<< /Size ${objs.length + 1} /Root ${catalog} 0 R >>\nstartxref\n${xref}\n%%EOF\n`; + return Buffer.from(out, 'latin1'); +} + +const pdf = makePdf(30); +const code = Array.from({ length: 60 }, (_, i) => `export function fn${i}(value: number): number { return value * ${i}; } // needle ${i}`).join('\n'); +const markdown = '# Title\n\nIntro needle\n\n## Part A\n\ntext\n\n### Detail A1\n\nmore needle\n\n## Part B\n\nend'; + +const browser = await chromium.launch({ headless: true, executablePath: process.env.PLAYWRIGHT_CHROMIUM || undefined }); +const page = await browser.newPage({ viewport: { width: 1400, height: 900 } }); +page.setDefaultTimeout(15000); +const errors = []; +page.on('pageerror', (e) => errors.push(e.message)); +page.on('console', (m) => { if (m.type() === 'error') errors.push('console: ' + m.text()); }); +await page.route(/\/(files|v1)\//, (route) => { + const url = new URL(route.request().url()); url.pathname = url.pathname.replace(/^\/api/, ''); + if (url.pathname === '/files/doc') return route.fulfill({ contentType: 'application/pdf', body: pdf }); + if (url.pathname === '/files/code') return route.fulfill({ contentType: 'text/plain', body: code }); + if (url.pathname === '/files/notes') return route.fulfill({ contentType: 'text/markdown', body: markdown }); + const text = url.pathname.match(/^\/v1\/file\/([^/]+)\/text$/); + if (text) return route.fulfill({ json: { code: 0, data: { file_id: text[1], name: text[1], content: text[1] === 'code' ? code : markdown, revision: 'r1', size: 1, can_edit: false } } }); + return route.fulfill({ json: { code: 0, data: null } }); +}); +await page.goto(`${server.resolvedUrls.local[0]}node_modules/.tmp/canvas-viewers/index.html`); +const open = (file_id, name, mime_type) => page.evaluate((a) => window.canvasStore.getState().openCanvas({ ...a, url: '/files/' + a.file_id }), { file_id, name, mime_type }); + +try { + // ── PDF: render, virtualization, text layer selection ── + await open('doc', 'fixture.pdf', 'application/pdf'); + + await page.locator('.jx-pdfPage canvas').first().waitFor(); + const pageCount = await page.locator('.jx-pdfPage').count(); + assert.equal(pageCount, 30); + const rendered = await page.locator('.jx-pdfPage canvas').count(); + assert.ok(rendered < 30, `virtualized: only ${rendered} canvases rendered`); + await page.locator('.jx-pdfPage[data-page="1"] .textLayer span').first().waitFor(); + assert.equal(await page.locator('.jx-pdfViewer-pageIndicator').innerText(), '1 / 30'); + // fit width: page width == scroller content width minus padding + const fit = await page.evaluate(() => { + const s = document.querySelector('.jx-pdfViewer-scroll'); + const p = document.querySelector('.jx-pdfPage'); + return { avail: s.clientWidth - 32, page: p.getBoundingClientRect().width }; + }); + assert.ok(Math.abs(fit.avail - fit.page) <= 1, `fit width ${JSON.stringify(fit)}`); + // select text in the text layer with the mouse + const span = page.locator('.jx-pdfPage[data-page="1"] .textLayer span', { hasText: 'quick brown fox' }); + const box = await span.boundingBox(); + await page.mouse.move(box.x + 2, box.y + box.height / 2); + await page.mouse.down(); + await page.mouse.move(box.x + box.width - 2, box.y + box.height / 2, { steps: 8 }); + await page.mouse.up(); + const selected = await page.evaluate(() => String(window.getSelection())); + assert.match(selected, /quick brown fox/); + assert.ok(await page.evaluate(() => !!window.getSelection().anchorNode.parentElement.closest('.jx-canvas-body')), 'selection lives inside .jx-canvas-body'); + await page.screenshot({ path: resolve(dir, 'pdf-fit.png') }); + + // zoom numeric -> page width grows; back to fit + const tabId = await page.evaluate(() => window.canvasStore.getState().activeTabId); + await page.evaluate((id) => window.zoomStore.getState().setZoom(id, 1.5), tabId); + await page.waitForFunction(() => Math.abs(document.querySelector('.jx-pdfPage').getBoundingClientRect().width - 612 * 1.5 * 96 / 72) < 2); + await page.evaluate((id) => window.zoomStore.getState().setZoom(id, 'fit'), tabId); + // fit recomputes on resize + await page.evaluate(() => window.canvasStore.getState().setPanelWidth(560)); + await page.waitForFunction(() => { + const s = document.querySelector('.jx-pdfViewer-scroll'); + return Math.abs(document.querySelector('.jx-pdfPage').getBoundingClientRect().width - (s.clientWidth - 32)) <= 1; + }); + + // ── Find in pdf (Ctrl+F while hovering the pane) ── + await page.mouse.move(box.x + 10, box.y + 40); + await page.keyboard.press('Control+f'); + const input = page.getByRole('textbox', { name: '查找内容' }); + await input.waitFor(); + assert.ok(await input.evaluate((el) => el === document.activeElement), 'find input focused'); + await input.fill('needle marker'); + await page.locator('.jx-canvasFind-count', { hasText: '1/30' }).waitFor(); + await page.keyboard.press('Enter'); // -> 2/30 + await page.locator('.jx-canvasFind-count', { hasText: '2/30' }).waitFor(); + await page.keyboard.press('Shift+Enter'); + await page.keyboard.press('Shift+Enter'); // wraps to 30/30 -> page 30 must render + await page.locator('.jx-canvasFind-count', { hasText: '30/30' }).waitFor(); + await page.waitForFunction(() => CSS.highlights.get('jx-canvas-find-primary-current')?.size === 1); + const currentText = await page.evaluate(() => [...CSS.highlights.get('jx-canvas-find-primary-current')][0].toString()); + assert.equal(currentText.toLowerCase(), 'needle marker'); + assert.equal(await page.locator('.jx-pdfViewer-pageIndicator').innerText(), '30 / 30'); + await page.screenshot({ path: resolve(dir, 'pdf-find.png') }); + await page.keyboard.press('Escape'); + await input.waitFor({ state: 'detached' }); + assert.equal(await page.evaluate(() => CSS.highlights.has('jx-canvas-find-primary')), false); + + // ── Outline (pdf bookmarks) ── + await page.getByRole('button', { name: '目录', exact: true }).click(); + const tree = page.getByRole('tree'); + await tree.getByRole('button', { name: 'Chapter 2' }).waitFor(); + assert.ok(await tree.getByRole('button', { name: 'Section 1.2' }).isVisible(), 'first level expanded'); + await tree.getByRole('button', { name: 'Chapter 2' }).click(); + await page.locator('.jx-pdfViewer-pageIndicator', { hasText: '11 / 30' }).waitFor(); + await tree.getByRole('button', { name: 'Section 1.2' }).click(); + await page.locator('.jx-pdfViewer-pageIndicator', { hasText: '5 / 30' }).waitFor(); + await page.screenshot({ path: resolve(dir, 'pdf-outline.png') }); + await page.getByRole('button', { name: '关闭目录' }).click(); + + // ── Code view ── + await open('code', 'sample.ts', 'text/plain'); + await page.locator('.jx-codeView-line').first().waitFor(); + await page.waitForFunction(() => document.querySelectorAll('.jx-codeView-line').length === 60); + assert.ok(await page.locator('.jx-codeView .hljs-keyword').count() > 0, 'syntax highlighted'); + assert.match(await page.locator('.jx-codeView-line').first().evaluate((el) => getComputedStyle(el, '::before').content), /counter/); + const nowrap = await page.locator('.jx-codeView-line').first().evaluate((el) => getComputedStyle(el).whiteSpace); + await page.getByRole('button', { name: '自动换行' }).click(); + const wrapped = await page.locator('.jx-codeView-line').first().evaluate((el) => getComputedStyle(el).whiteSpace); + assert.deepEqual([nowrap, wrapped], ['pre', 'pre-wrap']); + await page.mouse.move(900, 400); + await page.keyboard.press('Control+f'); + await page.getByRole('textbox', { name: '查找内容' }).fill('needle 5'); + await page.locator('.jx-canvasFind-count', { hasText: /^1\/11$/ }).waitFor(); // needle 5, 50..59 + await page.screenshot({ path: resolve(dir, 'code.png') }); + await page.keyboard.press('Escape'); + await page.evaluate(() => { document.documentElement.dataset.theme = 'dark'; }); + await page.screenshot({ path: resolve(dir, 'code-dark.png') }); + await page.evaluate(() => { delete document.documentElement.dataset.theme; }); + + // ── Markdown outline + find ── + await open('notes', 'notes.md', 'text/markdown'); + await page.locator('.jx-canvas-markdown h2').first().waitFor(); + await page.getByRole('button', { name: '目录', exact: true }).click(); + await page.getByRole('tree').getByRole('button', { name: 'Part B' }).waitFor(); + // Three levels start expanded; a level can still be collapsed and reopened. + await page.getByRole('tree').getByRole('button', { name: 'Detail A1' }).waitFor(); + await page.getByRole('tree').getByRole('button', { name: '收起' }).nth(1).click(); + assert.ok(await page.getByRole('tree').getByRole('button', { name: 'Detail A1' }).isHidden(), 'collapsed level hides its entries'); + await page.getByRole('tree').getByRole('button', { name: '展开' }).first().click(); + await page.getByRole('tree').getByRole('button', { name: 'Detail A1' }).waitFor(); + // Clicking an entry selects it even when nothing has to scroll. + await page.getByRole('tree').getByRole('button', { name: 'Part B' }).click(); + assert.equal(await page.locator('.jx-canvasOutline-row.is-active').innerText(), 'Part B'); + await page.getByRole('button', { name: '在文件中查找' }).click(); + await page.getByRole('textbox', { name: '查找内容' }).fill('needle'); + await page.locator('.jx-canvasFind-count', { hasText: '1/2' }).waitFor(); + await page.screenshot({ path: resolve(dir, 'markdown.png') }); + + assert.deepEqual(errors.filter((e) => !/favicon|404|Failed to load resource/.test(e)), []); + console.log('canvas viewers: pdf render/virtualize/select/zoom/fit-resize/find/outline, code highlight/lines/wrap/find, markdown outline/find passed'); +} finally { + await browser.close(); + await server.close(); +} diff --git a/src/frontend/scripts/test-canvas-zoom-browser.mjs b/src/frontend/scripts/test-canvas-zoom-browser.mjs new file mode 100644 index 00000000..041e7f48 --- /dev/null +++ b/src/frontend/scripts/test-canvas-zoom-browser.mjs @@ -0,0 +1,254 @@ +// Canvas content zoom: docx fit / numeric scale / wide-table scrolling, image +// zoom + pan, markdown CSS zoom, keyboard + ctrl-wheel scoping, panel-width +// persistence. Real docx-preview rendering of a generated A4 document. +import { createServer } from 'node:http'; +import assert from 'node:assert/strict'; +import { build } from 'esbuild'; +import JSZip from 'jszip'; +import { mkdir, readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { chromium } from 'playwright'; + +const output = resolve('node_modules/.tmp/canvas-zoom'); +await mkdir(output, { recursive: true }); + +async function fixtureDocx() { + const zip = new JSZip(); + zip.file('[Content_Types].xml', ''); + zip.file('_rels/.rels', ''); + const p = (text, size = 22) => `${text}`; + const cell = (text) => `${p(text)}`; + const cols = Array.from({ length: 8 }, (_, i) => `Column ${i + 1}`); + const table = `${cols.map(() => '').join('')}${cols.map(cell).join('')}${cols.map((c) => cell('value of ' + c)).join('')}`; + const body = [ + p('Quarterly Operations Review', 48), + p('Summary', 32), + ...Array.from({ length: 6 }, (_, i) => p(`Paragraph ${i + 1}. The quick brown fox jumps over the lazy dog while the operations team reviews throughput, latency and cost across every region this quarter.`)), + table, + ...Array.from({ length: 30 }, (_, i) => p(`Follow-up line ${i + 1}.`)), + ].join(''); + zip.file('word/document.xml', `${body}`); + return zip.generateAsync({ type: 'nodebuffer' }); +} + +const files = { + doc: { body: await fixtureDocx(), type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' }, + manual: { body: await readFile(resolve('../../internal design docs(V3.0).docx')), type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' }, + img: { body: '', type: 'image/svg+xml' }, + md: { body: '# Heading\n\nSome **markdown** body text.\n', type: 'text/markdown' }, + bin: { body: 'x', type: 'application/octet-stream' }, +}; + +await build({ + stdin: { + contents: ` +import {createRoot} from 'react-dom/client'; +import {RightSidebarPanel} from './src/components/canvas/RightSidebarPanel'; +import {useCanvasStore} from './src/stores/canvasStore'; +import {useCanvasZoomStore} from './src/components/canvas/canvasZoom'; +import './src/styles/variables.css';import './src/styles/motion.css';import './src/styles/common.css';import './src/styles/canvas.css'; +window.canvas=useCanvasStore;window.zoom=useCanvasZoomStore; +const names={doc:'review.docx',manual:'manual.docx',img:'chart.svg',md:'notes.md',bin:'blob.bin'}; +window.openFile=(id)=>useCanvasStore.getState().openCanvas({file_id:id,name:names[id],url:'/api/files/'+id,origin:'cloud'}); +createRoot(document.getElementById('root')).render(
); +`, + resolveDir: process.cwd(), loader: 'tsx', + }, + bundle: true, format: 'esm', jsx: 'automatic', define: { 'import.meta.env': '{}' }, + external: ['/loader.gif', '/loader-done.png'], loader: { '.woff': 'dataurl', '.woff2': 'dataurl', '.ttf': 'dataurl' }, + outfile: resolve(output, 'fixture.js'), +}); + +const server = createServer(async (request, response) => { + const path = new URL(request.url, 'http://127.0.0.1').pathname; + if (path === '/fixture.js' || path === '/fixture.css') { + response.writeHead(200, { 'Content-Type': path.endsWith('.js') ? 'text/javascript' : 'text/css' }); + return response.end(await readFile(resolve(output, path.slice(1)))); + } + const file = files[path.replace(/^\/api\/files\//, '').split('/')[0]]; + if (file && !path.endsWith('/location')) { + response.writeHead(200, { 'Content-Type': file.type }); + return response.end(file.body); + } + if (path.endsWith('/location')) { + response.writeHead(200, { 'Content-Type': 'application/json' }); + return response.end(JSON.stringify({ kind: 'personal', root_name: null, relative_path: 'docs' })); + } + if (path.startsWith('/api')) { + response.writeHead(200, { 'Content-Type': 'application/json' }); + return response.end(JSON.stringify({ code: 0, data: [] })); + } + response.writeHead(200, { 'Content-Type': 'text/html' }); + response.end('
'); +}); +await new Promise((done) => server.listen(0, '127.0.0.1', done)); +const origin = `http://127.0.0.1:${server.address().port}/`; + +const browser = await chromium.launch({ headless: true }); +let page; +try { + page = await browser.newPage({ viewport: { width: 1200, height: 900 } }); + page.setDefaultTimeout(15000); + const errors = []; + page.on('pageerror', (error) => errors.push(error.message)); + await page.goto(origin); + + const body = page.locator('.jx-canvas-body'); + const zoomValue = page.locator('.jx-canvasZoom-value'); + const state = () => page.evaluate(() => { + const viewport = document.querySelector('.jx-canvas-body'); + const vb = viewport.getBoundingClientRect(); + const pageBox = document.querySelector('section.docx').getBoundingClientRect(); + const table = document.querySelector('section.docx table')?.getBoundingClientRect(); + return { + vb: { x: vb.x, w: viewport.clientWidth }, page: { x: pageBox.x, w: pageBox.width }, + tableRight: table ? table.right - vb.x + viewport.scrollLeft : 0, + scrollWidth: viewport.scrollWidth, scrollLeft: viewport.scrollLeft, + zoom: document.querySelector('.docx-wrapper').style.zoom, + }; + }); + const percent = async () => Number((await zoomValue.textContent()).replace('%', '')); + + // ── Docx, wide pane: fit centres the page and fills the width (capped at 150%). + await page.evaluate(() => window.openFile('doc')); + await page.locator('section.docx').first().waitFor(); + await page.waitForFunction(() => document.querySelector('.docx-wrapper')?.style.zoom); + let s = await state(); + const fitWide = await percent(); + assert.ok(fitWide > 100 && fitWide <= 150, `wide fit ${fitWide}`); + const leftGap = s.page.x - s.vb.x, rightGap = s.vb.x + s.vb.w - (s.page.x + s.page.w); + assert.ok(Math.abs(leftGap - rightGap) < 2, `page centred (${leftGap} vs ${rightGap})`); + assert.equal(await page.getByRole('button', { name: '适应宽度' }).getAttribute('aria-pressed'), 'true'); + await page.screenshot({ animations: 'disabled', path: resolve(output, 'docx-wide-light.png') }); + + // ── Narrow pane: page shrinks to fit, nothing clipped. + await page.setViewportSize({ width: 480, height: 900 }); + await page.waitForFunction((prev) => Number(document.querySelector('.jx-canvasZoom-value').textContent.replace('%', '')) < prev, fitWide); + s = await state(); + const fitNarrow = await percent(); + assert.ok(fitNarrow < 70, `narrow fit ${fitNarrow}`); + assert.ok(s.page.x >= s.vb.x - 1 && s.page.x + s.page.w <= s.vb.x + s.vb.w + 1, 'page inside narrow pane'); + await page.screenshot({ animations: 'disabled', path: resolve(output, 'docx-narrow-light.png') }); + + // ── Wide table is never clipped: its right edge is inside the scrollable area. + assert.ok(s.scrollWidth + 1 >= s.tableRight, `table reachable (${s.scrollWidth} vs ${s.tableRight})`); + + // ── Buttons: + steps above fit, page overflows → horizontal scroll from the page's left edge. + await page.getByRole('button', { name: '放大' }).click(); + await page.getByRole('button', { name: '放大' }).click(); + assert.ok(await percent() > fitNarrow); + s = await state(); + assert.ok(s.scrollWidth > s.vb.w, 'horizontal scroll at > fit'); + assert.ok(s.page.x >= s.vb.x - 1, 'left edge of the page reachable at scrollLeft 0'); + assert.equal(await page.getByRole('button', { name: '适应宽度' }).getAttribute('aria-pressed'), 'false'); + await zoomValue.click(); + assert.equal(await percent(), 100); + s = await state(); + assert.equal(s.zoom, '1'); + assert.ok(Math.abs(s.page.w - 794) < 3, `100% = A4 width (${s.page.w})`); + + // ── Keyboard + wheel scoped to the hovered / focused pane. + await body.hover(); + await page.keyboard.press('Control+Equal'); + assert.equal(await percent(), 110); + await page.keyboard.press('Control+Minus'); + assert.equal(await percent(), 100); + await page.mouse.wheel(0, -200).catch(() => {}); + await page.keyboard.down('Control'); + await page.mouse.wheel(0, -100); + await page.keyboard.up('Control'); + await page.waitForFunction(() => Number(document.querySelector('.jx-canvasZoom-value').textContent.replace('%', '')) > 100); + await page.keyboard.press('Control+0'); + assert.equal(await page.getByRole('button', { name: '适应宽度' }).getAttribute('aria-pressed'), 'true'); + // Pointer and focus outside the pane: Ctrl+= must stay the browser's. + await page.evaluate(() => { + const outside = document.createElement('input'); + outside.id = 'outside'; + Object.assign(outside.style, { position: 'fixed', left: '0', top: '0', zIndex: '99' }); + document.body.append(outside); + }); + await page.mouse.move(1, 1); + await page.dispatchEvent('.jx-canvas', 'pointerleave'); + await page.focus('#outside'); + const prevented = await page.evaluate(() => { + const event = new KeyboardEvent('keydown', { key: '=', ctrlKey: true, bubbles: true, cancelable: true }); + document.getElementById('outside').dispatchEvent(event); + return event.defaultPrevented; + }); + assert.equal(prevented, false, 'Ctrl+= outside the pane is not hijacked'); + assert.equal(await page.getByRole('button', { name: '适应宽度' }).getAttribute('aria-pressed'), 'true'); + + // ── Dark theme: page stays white paper on a dark desk. + await page.setViewportSize({ width: 900, height: 900 }); + await page.evaluate(() => { document.documentElement.dataset.theme = 'dark'; window.openFile('manual'); }); + await page.waitForFunction(() => document.querySelectorAll('section.docx').length > 1 && document.querySelector('.docx-wrapper')?.style.zoom); + const colors = await page.evaluate(() => ({ + paper: getComputedStyle(document.querySelector('section.docx')).backgroundColor, + desk: getComputedStyle(document.querySelector('.jx-canvas-docx')).backgroundColor, + })); + assert.equal(colors.paper, 'rgb(255, 255, 255)'); + assert.notEqual(colors.desk, 'rgb(255, 255, 255)'); + await page.screenshot({ animations: 'disabled', path: resolve(output, 'docx-manual-dark.png') }); + await page.setViewportSize({ width: 480, height: 900 }); + await page.waitForTimeout(300); + await page.screenshot({ animations: 'disabled', path: resolve(output, 'docx-manual-dark-narrow.png') }); + await page.evaluate(() => { document.documentElement.dataset.theme = 'light'; }); + await page.screenshot({ animations: 'disabled', path: resolve(output, 'docx-manual-light-narrow.png') }); + + // ── Image: fit reports its scale; double-click → 100%, drag pans, double-click → fit. + await page.setViewportSize({ width: 900, height: 900 }); + await page.evaluate(() => window.openFile('img')); + const img = page.locator('.jx-canvas-image-img'); + await page.waitForFunction(() => document.querySelector('.jx-canvas-image-img')?.complete); + await page.waitForFunction(() => Number(document.querySelector('.jx-canvasZoom-value')?.textContent.replace('%', '')) < 100); + const fitImg = await percent(); + assert.ok(Math.abs((await img.boundingBox()).width - 1600 * fitImg / 100) < 20); + await img.dblclick(); + assert.equal(await percent(), 100); + assert.equal(Math.round((await img.boundingBox()).width), 1600); + const viewport = page.locator('.jx-canvas-image'); + const before = await viewport.evaluate((el) => [el.scrollLeft, el.scrollTop]); + assert.ok(before[0] > 0, 'zoomed image opens centred'); + const vbox = await viewport.boundingBox(); + await page.mouse.move(vbox.x + vbox.width / 2, vbox.y + vbox.height / 2); + await page.mouse.down(); + await page.mouse.move(vbox.x + vbox.width / 2 + 150, vbox.y + vbox.height / 2 + 80, { steps: 5 }); + await page.mouse.up(); + const after = await viewport.evaluate((el) => [el.scrollLeft, el.scrollTop]); + assert.ok(Math.abs(before[0] - after[0] - 150) < 3 && Math.abs(before[1] - after[1] - 80) < 3, `drag pans (${before} → ${after})`); + await page.screenshot({ animations: 'disabled', path: resolve(output, 'image-100.png') }); + await img.dblclick(); + assert.equal(await page.getByRole('button', { name: '适应宽度' }).getAttribute('aria-pressed'), 'true'); + + // ── Markdown: CSS-var zoom. + await page.evaluate(() => window.openFile('md')); + await page.locator('.jx-canvas-markdown').waitFor(); + await page.getByRole('button', { name: '放大' }).click(); + assert.equal(await page.locator('.jx-canvas-markdown').evaluate((el) => getComputedStyle(el).zoom), '1.1'); + + // ── Unsupported category: no control. + await page.evaluate(() => window.openFile('bin')); + await page.locator('.jx-canvas-unknown').waitFor(); + assert.equal(await page.locator('.jx-canvasZoom').count(), 0); + + // ── Panel width survives a reload, clamped to the window. + await page.evaluate(() => window.canvas.getState().setPanelWidth(5000)); + await page.waitForTimeout(300); + await page.reload(); + await page.waitForFunction(() => window.canvas); + assert.equal(await page.evaluate(() => window.canvas.getState().panelWidth), 900 - 420); + await page.evaluate(() => window.canvas.getState().setPanelWidth(450)); + await page.waitForTimeout(300); + await page.reload(); + await page.waitForFunction(() => window.canvas); + assert.equal(await page.evaluate(() => window.canvas.getState().panelWidth), 450); + + assert.deepEqual(errors, []); + console.log('Canvas zoom: docx fit (wide/narrow, centred, wide table), steps/100%, keyboard + ctrl-wheel scoping, dark paper, image zoom/pan/dblclick, markdown zoom, hidden for unsupported, panel width persistence passed'); +} catch (error) { + await page?.screenshot({ path: resolve(output, 'failure.png') }); + throw error; +} finally { + await browser.close(); + await new Promise((done) => server.close(done)); +} diff --git a/src/frontend/scripts/test-chat-fork.tsx b/src/frontend/scripts/test-chat-fork.tsx index 8f46d5c4..0c003817 100644 --- a/src/frontend/scripts/test-chat-fork.tsx +++ b/src/frontend/scripts/test-chat-fork.tsx @@ -113,7 +113,7 @@ discovered.updateStore((store) => ({ ...store, order: [discoveredId, ...store.or }, } })); useComposerStore.getState().activate(chatDraftKey(discoveredId)); -readComposer(chatDraftKey(discoveredId)).setQuotedFollowUp({text:'Keep branch draft',ts:1}); +readComposer(chatDraftKey(discoveredId)).setQuotedFollowUp({text:'Keep branch draft',source:{kind:'message',ts:1}}); discovered.addBackendSessionId(discoveredId); discovered.addLoadedMsgId(discoveredId); discovered.setMessagePaging(discoveredId, { nextPage: 5, hasOlder: false, loading: true }); diff --git a/src/frontend/scripts/test-queued-invocation.ts b/src/frontend/scripts/test-queued-invocation.ts index 43ea55b8..315991b0 100644 --- a/src/frontend/scripts/test-queued-invocation.ts +++ b/src/frontend/scripts/test-queued-invocation.ts @@ -65,4 +65,16 @@ assert.deepEqual(normalizeChatInvocation({ assert.equal(hasChatInvocation({}), false); assert.deepEqual(queuedChatInvocation({ invocation: {} }), {}); +// A quote taken before queueing travels with the queued turn through storage. +const fileQuote = { text: 'searchable-token-3', source: { kind: 'file' as const, fileId: 'f1', fileName: '长文.pdf', locator: '第 2 页' } }; +const quotedTurn = createQueuedChatTurn({ + id: 'q2', content: '编号是多少?', createdAt: 2, source: { quotedFollowUp: fileQuote }, +}); +assert.deepEqual(quotedTurn.invocation, { quote: fileQuote }); +assert.equal(hasChatInvocation(quotedTurn.invocation), true); +assert.deepEqual(queuedChatInvocation(JSON.parse(JSON.stringify(quotedTurn))), { quote: fileQuote }); +assert.deepEqual(normalizeChatInvocation({ quote: { text: 'x', source: { kind: 'file', fileId: 1 } } }), { quote: { text: 'x' } }); +assert.deepEqual(normalizeChatInvocation({ quote: { text: '' } }), {}); +assert.deepEqual(chatInvocationRequestFields({ quote: fileQuote }), {}); + console.log('queued invocation tests passed'); diff --git a/src/frontend/src/components/canvas/CanvasFileHeader.css b/src/frontend/src/components/canvas/CanvasFileHeader.css index fc3669f5..b95fbbec 100644 --- a/src/frontend/src/components/canvas/CanvasFileHeader.css +++ b/src/frontend/src/components/canvas/CanvasFileHeader.css @@ -15,3 +15,9 @@ .jx-fileHeader-note { overflow: hidden; max-width: 130px; white-space: nowrap; text-overflow: ellipsis; font-size: 11px; color: var(--color-text-secondary); } .jx-fileHeader-status { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } .jx-canvas-fileHeader button:disabled { opacity: .5; cursor: default; } +.jx-canvasZoom { display: flex; flex: none; align-items: center; gap: 0; margin-right: 4px; } +.jx-canvas-fileHeader .jx-canvasZoom .jx-canvas-actionBtn svg { width: 13px; height: 13px; } +.jx-canvas-fileHeader .jx-canvasZoom .jx-canvas-actionBtn[aria-pressed='true'] { color: var(--color-primary); } +.jx-canvasZoom-value { flex: none; min-width: 44px; height: 28px; padding: 0 4px; border: 0; border-radius: 6px; background: transparent; color: var(--color-text-secondary); font-family: var(--font-family-number); font-size: 12px; font-variant-numeric: tabular-nums; cursor: pointer; } +.jx-canvasZoom-value:hover { color: var(--color-text); background: var(--color-bg-gray); } +.jx-canvasZoom-value:focus-visible { outline: 2px solid var(--color-primary); outline-offset: -2px; } diff --git a/src/frontend/src/components/canvas/CanvasFileHeader.tsx b/src/frontend/src/components/canvas/CanvasFileHeader.tsx index e19e0dc7..014d398b 100644 --- a/src/frontend/src/components/canvas/CanvasFileHeader.tsx +++ b/src/frontend/src/components/canvas/CanvasFileHeader.tsx @@ -6,6 +6,7 @@ import { artifactOrigin, isArchiveMember } from '../../utils/artifactAccess'; import { FileOpenControl, type FileActionPhase } from '../file/FileOpenControl'; import { ArtifactFileAction } from '../file/ArtifactFileAction'; import { FilePathLabel } from './FilePathLabel'; +import { CanvasZoomControl } from './CanvasZoomControl'; import { useFileLocation } from './useFileLocation'; import { t } from '../../i18n'; import './CanvasFileHeader.css'; @@ -28,6 +29,7 @@ export function CanvasFileHeader({ artifact, revision, dirty, saving, onReload, onClick={location.retry} title={t('重试获取文件位置')} aria-label={t('重试获取文件位置')}>} {location.state === 'unfiled' && {t('尚未归档到我的空间')}}
+ {children} diff --git a/src/frontend/src/components/canvas/CanvasFileRenderers.tsx b/src/frontend/src/components/canvas/CanvasFileRenderers.tsx deleted file mode 100644 index b8b62f64..00000000 --- a/src/frontend/src/components/canvas/CanvasFileRenderers.tsx +++ /dev/null @@ -1,338 +0,0 @@ -import { htmlPreviewDocument } from './htmlPreviewDocument'; -import { useEffect, useRef, useState, useCallback } from 'react'; -import { FileExclamationOutlined } from '@ant-design/icons'; -import { t } from '../../i18n'; -import { getFileIconSrc } from '../../utils/fileIcon'; -import type { CanvasArtifact } from '../../stores/canvasStore'; -import { CitationMarkdownBlock } from '../citation'; -import { authFetch } from '../../api'; -import { withUrlParams } from '../../utils/artifactAccess'; -import { ArtifactFileAction } from '../file/ArtifactFileAction'; -import { PreviewFileTooLargeError, formatPreviewBytes, readLimitedBlob, readLimitedText } from '../../utils/filePreviewSafety'; - -function previewErrorMessage(error: unknown, fallback: string): string { - if (error instanceof PreviewFileTooLargeError) { - return t('文件超过 {limit} 的安全预览上限,请下载后在本地打开', { - limit: formatPreviewBytes(error.limitBytes), - }); - } - return error instanceof Error ? error.message : fallback; -} - -/* ── Renderers ── */ - -export function DocxRenderer({ url, maxBytes }: { url: string; maxBytes: number }) { - const containerRef = useRef(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - const enhanceDocxLayout = useCallback((root: HTMLElement) => { - const page = root.querySelector('section.docx'); - if (!page) return; - - const paragraphs = Array.from(page.querySelectorAll('p')) as HTMLParagraphElement[]; - let titleMarked = false; - - paragraphs.forEach((p) => { - p.classList.remove('jx-docx-title', 'jx-docx-meta', 'jx-docx-subtitle'); - const text = (p.textContent || '').trim(); - if (!text) return; - - if (!titleMarked) { - titleMarked = true; - p.classList.add('jx-docx-title'); - return; - } - - if (/^(来源|作者|日期|原文链接)\s*[::]/.test(text)) { - p.classList.add('jx-docx-meta'); - return; - } - - if (/^(核心观点|摘要|结论|一、|二、|三、|四、|五、|六、|七、|八、|九、|十、)/.test(text)) { - p.classList.add('jx-docx-subtitle'); - } - }); - }, []); - - useEffect(() => { - let cancelled = false; - const controller = new AbortController(); - (async () => { - try { - setLoading(true); - setError(null); - const resp = await authFetch(url, { signal: controller.signal }); - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); - const blob = await readLimitedBlob(resp, maxBytes); - const { renderAsync } = await import('docx-preview'); - if (cancelled || !containerRef.current) return; - containerRef.current.innerHTML = ''; - await renderAsync(blob, containerRef.current, undefined, { - className: 'jx-canvas-docx-wrapper', - inWrapper: true, - ignoreWidth: false, - ignoreHeight: true, - }); - enhanceDocxLayout(containerRef.current); - } catch (error: unknown) { - if (!cancelled) setError(previewErrorMessage(error, t('文档预览失败'))); - } finally { - if (!cancelled) setLoading(false); - } - })(); - return () => { - cancelled = true; - controller.abort(); - }; - }, [enhanceDocxLayout, maxBytes, url]); - - if (error) return
{error}
; - return ( - <> - {loading &&
{t('正在渲染文档…')}
} - {/* fadeIn class is only attached once rendering finishes, so the entrance - animation starts exactly when the document becomes visible. */} -
- - ); -} - -/* XlsxRenderer removed — replaced by UniverSpreadsheet */ - -export function PdfRenderer({ url }: { url: string }) { - const inlineUrl = withUrlParams(url, { inline: '1' }); - return ( -
- - - -
- ); -} - -export function PptRenderer({ url, maxBytes }: { url: string; maxBytes: number }) { - const [pdfUrl, setPdfUrl] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - let cancelled = false; - let objectUrl: string | null = null; - const controller = new AbortController(); - - (async () => { - try { - setLoading(true); - setError(null); - setPdfUrl(null); - - const resp = await authFetch(url, { signal: controller.signal }); - if (!resp.ok) { - let detail = `HTTP ${resp.status}`; - try { - const payload = await resp.json() as { detail?: unknown }; - if (typeof payload.detail === 'string' && payload.detail.trim()) { - detail = payload.detail; - } - } catch { - // Keep the HTTP fallback text when the response is not JSON. - } - throw new Error(detail); - } - - const blob = await readLimitedBlob(resp, maxBytes); - objectUrl = URL.createObjectURL(blob); - if (!cancelled) { - setPdfUrl(objectUrl); - } - } catch (error: unknown) { - if (!cancelled) { - setError(previewErrorMessage(error, t('PPT 预览失败'))); - } - } finally { - if (!cancelled) { - setLoading(false); - } - } - })(); - - return () => { - cancelled = true; - controller.abort(); - if (objectUrl) { - URL.revokeObjectURL(objectUrl); - } - }; - }, [maxBytes, url]); - - if (error) return
{error}
; - if (loading || !pdfUrl) return
{t('正在渲染演示文稿…')}
; - - return ( -
- - - -
- ); -} - -export function ImageRenderer({ url, name }: { url: string; name: string }) { - return ( -
- {name} -
- ); -} - -function useRemoteText(url: string, maxBytes: number) { - const [content, setContent] = useState(''); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - let cancelled = false; - const controller = new AbortController(); - (async () => { - try { - setLoading(true); - setError(null); - const resp = await authFetch(url, { signal: controller.signal }); - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); - const text = await readLimitedText(resp, maxBytes); - if (!cancelled) setContent(text); - } catch (error: unknown) { - if (!cancelled) setError(previewErrorMessage(error, t('文本加载失败'))); - } finally { - if (!cancelled) setLoading(false); - } - })(); - return () => { - cancelled = true; - controller.abort(); - }; - }, [maxBytes, url]); - - return { content, loading, error }; -} - -export function TextRenderer({ url, maxBytes }: { url: string; maxBytes: number }) { - const { content, loading, error } = useRemoteText(url, maxBytes); - - if (error) return
{error}
; - if (loading) return
{t('正在加载…')}
; - return ( -
-
{content}
-
- ); -} - -export function MarkdownRenderer({ url, maxBytes }: { url: string; maxBytes: number }) { - const { content, loading, error } = useRemoteText(url, maxBytes); - - if (error) return
{error}
; - if (loading) return
{t('正在加载…')}
; - return ( - - ); -} - -export function HtmlRenderer(props: { url: string; version: string | number; maxBytes: number }) { - // Cross-origin iframe navigation does not require CORS. Preserve support for - // external artifact links; only our file API can provide readable HTTP errors. - if (new URL(props.url, window.location.href).origin !== window.location.origin) { - return