diff --git a/CHANGELOG.md b/CHANGELOG.md index 0db5de7f6..193f649cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ notes. Entries describe what users experience, not internal refactors. ## [Unreleased] +### Removed + +- External MCP and Google Drive connections in the AI assistant. Local file + references, citations and saved artifacts remain available without account + authorization or a connector backend. + ### Fixed - Editing a document after more than seven idle days keeps the new recovery diff --git a/docs/developer/assistant-workspace.md b/docs/developer/assistant-workspace.md index 7da3c8a8a..ba3702234 100644 --- a/docs/developer/assistant-workspace.md +++ b/docs/developer/assistant-workspace.md @@ -1,22 +1,20 @@ # Assistant workspace owners -The panel composes existing inference and document-review lifecycles with two independent features. A model service is an execution destination, a connector is a reference source, and an artifact is an explicitly saved result. None creates a site account. +The panel composes existing inference and document-review lifecycles with two independent features. A model service is an execution destination, a local reference is an explicitly selected file, and an artifact is an explicitly saved result. None creates a site account. External account connectors and the external-service MCP client were removed to keep the editor account-free and statically deployable; see the [MCP investigation and product decision](../explorations/2026-10-18-mcp-connectors-and-frontend-boundaries.md). ## Sources -`connectors/reference-sources.ts` owns immutable reference snapshots, per-conversation attachment sets and a read-only MCP client. Local references support `.txt`, `.md`, `.csv`, `.tsv` and `.json` up to 256 KiB, plus standalone DOCX, XLSX, PPTX and text-bearing PDF readers with 20 MiB input and 256 KiB extracted-text limits. These local readers do not replace or mutate the open document. OOXML extraction bounds archives to 2,000 entries, 32 MiB cumulative inflation and 100,000 XML elements, with 10,000 paragraphs or 10,000 cells across 20 sheets; PDF extraction allows 100 pages. All extraction has a 15-second deadline; scanned images require OCR, which is not provided. See [reference limits](../reference.md) for format and offline requirements. Up to five attachments are selected explicitly. Attachments remain in memory and are not silently restored from disk. +`references/reference-sources.ts` owns immutable local reference snapshots and per-conversation attachment sets. Local references support `.txt`, `.md`, `.csv`, `.tsv` and `.json` up to 256 KiB, plus standalone DOCX, XLSX, PPTX and text-bearing PDF readers with 20 MiB input and 256 KiB extracted-text limits. These local readers do not replace or mutate the open document. OOXML extraction bounds archives to 2,000 entries, 32 MiB cumulative inflation and 100,000 XML elements, with 10,000 paragraphs or 10,000 cells across 20 sheets; PDF extraction allows 100 pages. All extraction has a 15-second deadline; scanned images require OCR, which is not provided. See [reference limits](../reference.md) for format and offline requirements. Up to five attachments are selected explicitly. Attachments remain in memory and are not silently restored from disk. -The MCP client supports the 2025-11-25 Streamable HTTP resource protocol with JSON and SSE responses. It lists up to 100 resources per explicit page and reads selected text resources. Load more follows only the currently returned cursor, with repeated-cursor protection, at most 10 pages and 500 unique resources; no page is prefetched. It does not invoke tools, run server instructions, implement stdio or handle provider OAuth. Use an explicitly configured HTTPS endpoint or HTTP loopback endpoint, with browser CORS access. An optional bearer token remains in memory. This is separate from the editor's outbound WebMCP tool exposure. +`ui/sources-view.ts` owns local file attachment, removal, search and read-only preview. Attachment capacity errors identify the five-source limit. Local reads have byte limits and deadlines; epoch checks prevent late reads from entering another conversation. `panel.ts` captures selected references at enqueue, guards them before execution and continuation, and cancels execution when its attachments are invalidated. Adding another attachment cannot change an already queued snapshot. -`ui/sources-view.ts` owns source management and authorization completion presentation. Authorization failures restore connection controls and clear expired discovery results without silently dropping attached snapshots or retrying. Attachment capacity errors identify the five-source limit. Source reads have byte limits and deadlines; epoch checks prevent late reads from entering another conversation or reattaching after disconnect. `panel.ts` captures selected references at enqueue, guards them before execution and continuation, and cancels execution when its attachments are invalidated. Adding another attachment cannot change an already queued snapshot. +`references/reference-search.ts` owns literal local filtering and query-centered excerpt windows. Source disclosures show up to 8,000 characters, highlight the actual match and label partial previews. Expanding a source or a captured citation never fetches content or starts inference. -`connectors/reference-search.ts` owns literal local filtering and query-centered excerpt windows. Source disclosures show up to 8,000 characters, highlight the actual match and label partial previews. Expanding a source or a captured citation never fetches content or starts inference. Google filename search runs only on explicit Browse; changing the query cancels the previous presentation owner and resets continuation. Drive discovery deduplicates up to 300 files across 10 explicit pages. +`ui/request-context.ts` creates bounded JSON excerpts with controlled ids S1–S5, source titles, source-kind metadata and truncation flags. Host locators stay outside the model payload. It returns the exact serialized evidence, and native/ordinary-chat routes publish their final serialization before streaming. The runtime reports any additional clipping before each inference. `ui/source-evidence.ts` reconciles this with the immutable captured snapshot. `ui/source-citations.ts` decorates only matching literal citations using text-only captured excerpts; unknown ids remain plain text and original Copy/Save content is unchanged. -`ui/request-context.ts` creates bounded JSON excerpts with controlled ids S1–S5, source titles, connector identity and truncation flags. Host locators stay outside the model payload. It returns the exact serialized evidence, and native/ordinary-chat routes publish their final serialization before streaming. The runtime reports any additional clipping before each inference. `ui/source-evidence.ts` reconciles this with the immutable captured snapshot. `ui/source-citations.ts` decorates only matching literal citations using text-only captured excerpts; unknown ids remain plain text and original Copy/Save content is unchanged. +Each inference response can carry a host-minted stable `hostPresentation.id` with bounded presentation metadata, attached directly to that response rather than matched by prose or history index. The runtime strips the annotation before all provider calls. Conversation opt-in is still required for persistence; removing a conversation removes its annotations. Only the bounded excerpts actually sent are retained, never the full attachment. Restored legacy responses without annotations do not invent citations. Instruction and existing request-envelope headroom are reserved. Native requests, conversational requests, document drafting and dedicated writing routes receive quoted reference data through their existing owners. Reference content never adds tool definitions or bypasses host review. The historical `connector` metadata field remains readable in saved conversations and artifact provenance; it does not restore a remote connection. -Each inference response can carry a host-minted stable `hostPresentation.id` with bounded presentation metadata, attached directly to that response rather than matched by prose or history index. The runtime strips the annotation before all provider calls. Conversation opt-in is still required for persistence; removing a conversation removes its annotations. Only the bounded excerpts actually sent are retained, never the full attachment. Restored legacy responses without annotations do not invent citations. Instruction and existing request-envelope headroom are reserved. Native requests, conversational requests, document drafting and dedicated writing routes receive quoted reference data through their existing owners. Remote content never adds tool definitions or bypasses host review. - -Google Drive is optional and uses the [server connector](google-drive-connector.md). The service worker bypasses all `/api/connectors/` routes, including OAuth callback navigations, so authenticated responses cannot become offline assets. Cloudflare's `_routes.json` restricts function invocation to the Drive API path. Static deployments retain local/MCP references and explain unavailable cloud routes. +The service worker bypasses `/api/` requests rather than caching them as offline pages. No connector API, OAuth callback or Functions routing configuration is shipped. The editor's outbound WebMCP tool exposure remains independent from reference-file attachment. ## Artifacts @@ -24,7 +22,7 @@ Google Drive is optional and uses the [server connector](google-drive-connector. Updates check the expected version inside the same read-write transaction that appends the new version. Concurrent editors cannot overwrite one another. Selecting an older version and saving creates a new version. Deletion requires an explicit UI confirmation. Failed saves preserve editable drafts. Capacity errors distinguish artifact count, text size and version history. At 20 versions, an explicit save-as-new action preserves the draft and source provenance in a separate artifact without modifying existing history. -`ui/artifacts-view.ts` owns local text editing, version selection and conflict presentation. The generic chat package provides an optional host save action with opaque, render-time provenance. It does not know IndexedDB or connectors. Source titles and conversation identity are captured with live results; old restored results with no retained source metadata do not invent source provenance. +`ui/artifacts-view.ts` owns local text editing, version selection and conflict presentation. The generic chat package provides an optional host save action with opaque, render-time provenance. It does not know IndexedDB or reference storage. Source titles and conversation identity are captured with live results; old restored results with no retained source metadata do not invent source provenance. `artifacts/edit-proposal.ts` owns immutable draft identity, title/body/revision freshness and native UTF-16 selection offsets. A collapsed selection means the complete artifact; selecting a range sends and replaces only that range. `ui/artifact-workflow.ts` owns a single disclosed instruction, bounded streaming preview and explicit diff acceptance. Accepting updates the editable draft only; Save version remains separate. Default previews show at most 8,000 characters; expanding the complete diff is explicit. Stop, rejection and failures keep the instruction and draft. A quiet scope line distinguishes selected text from the entire artifact before submission. Queue recovery routes instructions back to artifact refinement, never through the Office chat composer. If a restored selection belonged to an older body, a fresh selection or explicit entire-artifact choice is required; old offsets are not reused. @@ -34,4 +32,4 @@ Applying an artifact captures a fresh Word target and creates the existing `Acti ## Offline availability -Offline mode hides Google Drive authorization and browsing, the downloadable model catalog and remote model URLs. It retains attached reference snapshots, local file attachment, saved artifact editing and versions, cached model management, local GGUF files, and local or LAN services/MCP. Existing cloud configuration remains inspectable. Connectivity restoration only restores controls; it never authorizes, downloads, reconnects or resends automatically. Browser connectivity is a presentation signal; actual request failures still receive actionable recovery messages. +Offline mode hides the downloadable model catalog and remote model URLs. It retains attached reference snapshots, local file attachment, saved artifact editing and versions, cached model management, local GGUF files, and local or LAN model services. Existing model configuration remains inspectable. Connectivity restoration only restores controls; it never downloads, reconnects or resends automatically. Browser connectivity is a presentation signal; actual request failures still receive actionable recovery messages. diff --git a/docs/developer/google-drive-connector.md b/docs/developer/google-drive-connector.md index 78646d5ce..5e76cb522 100644 --- a/docs/developer/google-drive-connector.md +++ b/docs/developer/google-drive-connector.md @@ -1,61 +1,5 @@ -# Optional Google Drive connector +# Google Drive connector retired -The editor, local references, model configuration and artifacts do not require a Google account. Google authorization is requested only when a user explicitly connects Drive. This optional Cloudflare Pages Functions bridge is separate from model providers. +The optional Google Drive connector was removed on 2026-10-18 to retain the editor's account-free, frontend-only deployment model. The Google OAuth routes, server bindings and connection UI are no longer part of the application. -## Deployment - -Deploy the repository's `functions/` directory alongside the built static site with Cloudflare Pages. A static-only deployment cannot provide these routes; its client must display connector unavailability without disabling local features. Production and preview origins need separate Google OAuth clients/configuration. Do not inherit production secrets into untrusted pull-request previews. - -Configure these server-side Cloudflare environment bindings (never `VITE_*`, generated HTML, or frontend storage): - -- `GOOGLE_CLIENT_ID`: OAuth web application client ID. -- `GOOGLE_CLIENT_SECRET`: the corresponding client secret. -- `GOOGLE_CONNECTOR_SECRET`: random high-entropy encryption secret, at least 32 characters. Rotate it to invalidate outstanding sessions and authorization flows. -- `GOOGLE_CONNECTOR_ORIGIN`: exact trusted HTTPS origin without a trailing slash, for example `https://edit.chaxus.com`. Incoming Host headers cannot choose the callback destination. Subpath deployments need a gateway or a separate root-origin deployment of these fixed routes. - -Register the exact redirect URI `${GOOGLE_CONNECTOR_ORIGIN}/api/connectors/google/callback` with Google's OAuth web application, enable Drive API, and configure consent-screen audiences/test users. This version requests `https://www.googleapis.com/auth/drive.readonly` to browse and read supported files; this is broad read access and production publication may require Google's restricted-scope verification/security assessment. Explain that permission in the connection UI. It grants no writing permission. A narrower Picker-only flow would require a separate product implementation, not just a scope substitution. - -Sessions last at most one hour and never request refresh tokens. Expired authorization requires explicit reconnect. The browser receives encrypted Secure, HttpOnly, SameSite=Lax, host-only cookies; neither OAuth tokens nor client secrets reach application JavaScript. The short authorization-flow cookie expires after ten minutes. AES-GCM binds cookie purpose and configured origin; the callback checks state and uses PKCE. Google authorization codes are single-use; the callback clears the flow cookie on success and failure. Cross-origin POST actions are rejected. - -The callback uses a nonce-authorized inline script and same-origin `BroadcastChannel`, not `window.opener`, so the editor's COOP/COEP isolation does not need weakening. Open a blank popup synchronously from the user's gesture, call `start`, then navigate that popup to `authorizationUrl`. Listen to `document-google-connector`, treat its `{type:'google-connector-complete',success:boolean}` message as a refresh hint, and fetch `status` for authoritative state. The callback also displays a plain close-window instruction when script or popup behavior is unavailable. Do not log callback URLs: their query includes a short-lived authorization code. - -## Deployment acceptance checklist - -Current integration status (2026-10-16): the OAuth client has not been created or configured. Automated tests use mocked Google endpoints. Real consent, deployed token exchange and live Drive reads remain unverified. - -1. Choose the stable HTTPS deployment origin and deploy Pages Functions with the static site. Register its exact callback URL above, enable Drive API and prepare the consent audience/test account. -2. Set all four bindings above in the appropriate Cloudflare deployment environment, then redeploy. Keep the client secret and encryption secret in server-side secret bindings. Do not paste them into issues, chat, screenshots or a frontend environment file. -3. Open `/api/connectors/google/status` on that origin. Expect JSON with `configured:true` and `connected:false` before connecting. An HTML response indicates missing routing/functions; `not_configured` indicates missing or invalid bindings. Neither is a successful connection. -4. From Sources, explicitly choose Connect. Verify the consent screen identifies the intended app and read-only Drive permission. Cancel once and confirm the editor stays usable with a clear disconnected state; then connect with a test account. -5. Browse and attach a supported test file. Confirm no model request occurs until an explicit AI request, and that its captured reference belongs to the intended conversation. -6. Disconnect, confirm browsing requires reconnect and that local references remain usable. Repeat after session expiry. Check popup completion under the deployed COOP/COEP policy without weakening isolation headers. -7. Check an offline session and an unconfigured preview: network actions should be unavailable with understandable guidance while local models, prepared local readers and artifacts remain usable. Do not copy production credentials into PR previews to make this test pass. - -Record the tested deployment origin, build commit, date and pass/fail outcomes without tokens, authorization codes or private file contents. Only mark live authorization verified after these deployed checks pass. - -## Browser API contract - -All routes are same-origin under `/api/connectors/google`, use `credentials: 'same-origin'`, and return `Cache-Control: no-store`. Mutations require a same-origin `Origin` header. - -| Route | Method | Result | -| ----------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------- | -| `/start` | POST | `{authorizationUrl}`; navigate the user-initiated popup | -| `/callback` | GET | Google-owned redirect target; HTML completion page, no tokens | -| `/status` | GET | `{configured:true,connected:boolean,expiresAt?:number}`; epoch milliseconds | -| `/list?q=…&pageToken=…` | GET | `{files:[{id,name,mimeType,modifiedTime,size?}],nextPageToken?}`; 30 supported files per page; optional filename query | -| `/read?id=…` | GET | `{id,name,mimeType,modifiedTime?,text}` | -| `/disconnect` | POST | `{connected:false}`; attempts Google revocation and always clears local cookies | - -Missing server configuration returns HTTP 503 and `{configured:false,connected:false,error:'not_configured'}`. A static deployment may instead return an HTML 404; the client must distinguish that from successful JSON. Other sanitized error codes include `invalid_origin`, `method_not_allowed`, `reconnect_required`, `invalid_file`, `invalid_query`, `unsupported_file`, `source_too_large`, and `service_unavailable`. Upstream response bodies, access tokens and request secrets are never included in error output. Treat 401 as a disconnected state and offer explicit reconnect; do not automatically resend a user request. - -Filename search accepts up to 200 characters, rejects control characters, and escapes quotes and backslashes as one Drive query literal. It cannot supply arbitrary query predicates or API URLs. Page tokens remain bounded to 2,048 characters; the client requires explicit Load more, deduplicates files and stops repeated cursors and traversal limits. - -Supported content is Google Docs exported as plain text and UTF-8 plain text, Markdown, CSV and JSON files. Binary Office/PDF files are deliberately excluded. Responses are streamed with a 128 KiB bound (including files with no Content-Length), a 15-second timeout and no upstream redirects. Only fixed Google API endpoints can be fetched; file IDs and supported MIME types are validated. The later AI request envelope has its own smaller budget, so selecting a file does not mean its entire text will fit into a model request. Display any truncation at that boundary. - -Disconnect attempts token revocation even if the provider is temporarily unavailable, then removes local session cookies. A copied encrypted cookie remains usable until expiry if provider revocation fails; this stateless version does not claim server-side session deletion. Do not persist source text or send it to a model without the user's explicit attachment/request action. - -## Verification - -`pnpm exec vitest run test/unit/agent-google-connector.test.ts` covers missing configuration, trusted origin/method checks, encrypted cookies, state mismatch/tampering/expiry, callback replay without the flow cookie, server-only token exchange, supported listing/export, invalid URL/file IDs, streaming size bounds, unsupported MIME types, sanitized errors, revocation failure and session expiry. These deterministic tests mock Google. Live consent, restricted-scope verification and production Cloudflare deployment require real credentials and are separate deployment checks. - -References: [Google server OAuth flow](https://developers.google.com/identity/protocols/oauth2/web-server), [Drive scopes](https://developers.google.com/workspace/drive/api/guides/api-specific-auth), [Cloudflare Pages Functions](https://developers.cloudflare.com/pages/functions/). +See the [MCP connector investigation and product decision](../explorations/2026-10-18-mcp-connectors-and-frontend-boundaries.md) for the original implementation, measured limitations, industry patterns and final removal scope. Local reference files and explicitly configured model services remain supported. diff --git a/docs/explorations/2026-10-18-mcp-connectors-and-frontend-boundaries.md b/docs/explorations/2026-10-18-mcp-connectors-and-frontend-boundaries.md new file mode 100644 index 000000000..9285ed436 --- /dev/null +++ b/docs/explorations/2026-10-18-mcp-connectors-and-frontend-boundaries.md @@ -0,0 +1,200 @@ +# MCP 连接器技术调研与纯前端产品边界 + +日期:2026-10-18。适用项目:document。本次决策:移除 AI 助手主动连接外部 MCP 和 Google Drive 的功能,保留本地参考文件、来源引用、成果管理及现有模型配置。项目继续以无站点账户、可静态部署、本地优先的文档编辑器为定位。 + +这是一项产品边界选择。标准 MCP 客户端在浏览器中有可行实现,但纯浏览器无法保证接入任意厂商服务;为了扩大覆盖而增加账户、凭据托管、网关或本地伴随程序,会改变本项目的部署与信任边界。本文保存会话中的调查事实、原理解释、方案演变及最终取舍,作为后续讨论的依据,不作为连接器开发计划。 + +## 会话中的目标变化 + +| 阶段 | 用户关注点 | 得出的结论 | +| ------------ | ------------------------------------- | ---------------------------------------------------------------------------------------------- | +| 同步与排查 | 同步远程主分支,找出现有连接器的卡点 | 当时 `main` 与 `origin/main` 一致,提交为 `db4ddcd8`;Drive 配置、MCP 能力和上下文截取均有限制 | +| 理解连接器 | 连接器怎样让 AI 获取外部信息 | 连接器处理授权和访问,模型接收工具说明与结果,宿主应用执行实际调用 | +| 完善方案 | 如何改善资料读取、连接状态和引用 | 最初提出的方案偏向参考资料管理,未覆盖用户期望的通用工具接入 | +| 澄清扩展目标 | 接入 Figma、Notion、Longbridge 等 MCP | 应使用标准 MCP 客户端、动态工具发现和统一工具适配,避免逐家重写协议 | +| 约束核对 | 当前项目纯前端,是否还可实现 | 可以实现浏览器直连,但服务的 CORS、公共客户端授权和准入政策构成外部条件 | +| 业界对照 | 为什么成熟产品可以广泛连接 | 云端代理和本机运行时承担了网页不能承担的网络、凭据和进程管理 | +| 最终决定 | 无账户纯前端定位优先 | 保存研究,删除外部连接器;不继续开发 MCP Host、账户系统或代理网关 | + +移除工作开始时,原工作区已切换至其他开发分支并有未提交改动,因此本次实现从当时已提交的 `ae350c3d` 创建独立 worktree,不搬移或覆盖原工作区改动。上表中的主分支同步结果是调研开始时的历史事实,不代表删除开始时重新同步过主分支。 + +## 连接器涉及的角色 + +模型服务与连接器解决不同问题:模型服务执行推理,连接器提供外部数据或操作能力。一个模型可以使用多个连接器,一个连接器也可以供不同模型使用,前提是宿主能把工具定义与执行结果适配到当前模型。 + +- **Host,宿主应用**:本项目的 AI 助手。管理用户选择、权限、上下文、工具执行、取消和结果呈现。 +- **MCP Client,客户端组件**:宿主内部与某个 MCP 服务通信的组件。多个服务需要分别隔离其连接配置和授权状态。 +- **MCP Server,外部服务**:提供工具、资源或提示模板,将协议请求转换成其业务系统的操作。 +- **授权服务**:让用户登录、同意权限并颁发访问凭据。它可能与 MCP 服务属于不同域名。 +- **模型**:根据用户问题和可用工具提出调用意图;网络请求由宿主执行。 + +MCP 标准化消息与能力交换,不替宿主决定使用哪个模型、保存哪些资料、如何审批操作,也不要求某一种产品账户体系。常见的 `mcpServers` 配置是客户端生态约定,不能当作 MCP 协议规定的唯一配置格式。 + +## 一次工具调用怎样完成 + +以“从 Notion 找产品方案,整理成当前 Word 文档的大纲”为例: + +1. 用户添加 MCP 服务地址并选择连接。 +2. 客户端发现授权要求,完成适用的 OAuth 流程或使用用户提供的凭据。 +3. 客户端依据服务支持的协议版本建立通信并发现能力。 +4. 通过 `tools/list` 获得工具名称、说明和参数 schema;只向模型提供本次允许使用的工具。 +5. 模型提出搜索调用,宿主校验参数与权限后发送 `tools/call`。 +6. 服务执行搜索并返回结果;模型可能继续请求读取页面。 +7. 宿主把结果纳入受限上下文,模型生成大纲。修改当前文档仍应走原有预览与确认流程。 + +```mermaid +sequenceDiagram + participant U as 用户 + participant H as 宿主助手 + participant M as 模型 + participant S as MCP 服务 + U->>H: 搜索产品方案并整理大纲 + H->>S: 发现允许使用的工具 + S-->>H: 工具说明与参数 schema + H->>M: 用户问题与工具定义 + M-->>H: 工具调用意图 + H->>S: 校验后执行 tools/call + S-->>H: 数据或执行结果 + H->>M: 提交结果 + M-->>H: 回答或下一次调用 + H-->>U: 展示大纲与来源 +``` + +会话最初使用现有客户端的 `2025-11-25` 握手式流程讲解,其中包含 `initialize` 与 `notifications/initialized`。这不是对所有未来版本固定不变的实现要求。当前 SDK 与协议文档已有更新,若未来另立项目实现客户端,应由经验证的 SDK 处理协议版本差异,避免继续手写并固定旧版本。 + +读取工具也可能通过 `tools/call` 提供;不能因为只需要读数据,就假设所有服务都有 `resources/list`。资源、工具和提示模板是不同的能力。 + +## 三个厂商示例 + +| 服务 | 官方远程入口 | 接入条件与能力 | +| ---------- | ---------------------------- | -------------------------------------------------------------------------------------------------------------- | +| Figma | `https://mcp.figma.com/mcp` | 设计相关工具,通过 Figma 登录授权。调研当日官方文档注明新客户端需要申请准入,不能仅凭实现标准协议就承诺可用 | +| Notion | `https://mcp.notion.com/mcp` | 搜索、读取和更新工作区内容;官方提供自定义客户端的 OAuth、PKCE、客户端注册和刷新 Token 指南 | +| Longbridge | `https://mcp.longbridge.com` | 行情、账户及交易等工具;能力受地区、账户和授权范围影响。其官方另提供中国大陆入口,账户适用范围需按厂商文档选择 | + +标准化的收益是复用客户端与工具发现机制。厂商客户端准入、OAuth 注册策略、账户权限、工具结果类型和配额仍需分别验证。“可添加任意地址”与“保证任意服务可用”是两个不同承诺。 + +## 调研时项目已有实现与卡点 + +下列是删除前实现的历史记录,文件路径用于定位 Git 历史,不表示功能仍在当前版本提供。 + +| 范围 | 已实现内容 | 主要限制 | +| --------------------------------------------- | --------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| 本地参考文件 | 文本、DOCX、XLSX、PPTX、含文字 PDF 的浏览器读取、会话附件、预览和引用 | 最多五份附件;解析有大小、页数和时间限制,扫描 PDF 没有 OCR | +| `connectors/reference-sources.ts` 中的 MCP | Streamable HTTP JSON/SSE、内存 Bearer Token、列出和读取资源 | 无 OAuth、工具调用、资源模板、stdio;固定 `2025-11-25`,未据服务能力选择资源操作 | +| `ui/sources-view.ts` 中的 Drive | 弹窗授权、浏览文件、文件名搜索、读取和断开 | 依赖专用服务端;初始化不核实状态,多类错误合并成通用失败提示 | +| `functions/api/connectors/google/[[path]].ts` | OAuth、PKCE、加密 HttpOnly Cookie、受控 Google API 请求 | 需要部署端配置与 Google OAuth 应用,真实授权尚未完成验收;会话最长一小时,无刷新 Token | +| Drive 文件覆盖 | Google Docs 导出纯文本及部分文本格式 | 不包括 Sheets、Slides、云端 Office/PDF | +| `ui/request-context.ts` | 受限摘录、S1–S5 引用 ID、最终请求证据 | 约 4 KiB 预算还需容纳指令等信息;从正文开头截取,相关段落在后面时容易丢失 | + +本次会话实际取得的证据: + +- 公开的 Drive `/api/connectors/google/status` 返回 HTTP 503,JSON 为 `configured:false`、`connected:false`、`error:not_configured`。这说明当时路由可达但配置检查失败;并未检查部署后台的各项凭据,不能指出具体缺少哪一项。 +- 六个相关单元测试文件共 47 个用例通过;Google 请求为模拟响应,不等同于真实 OAuth 与文件读取验收。 +- 对现有 MCP 源码的受控探针:返回其他版本时得到 `source-protocol`;仅提供 tools 的服务仍被调用 `resources/list`,最终得到 `source-invalid-response`。该探针验证的是客户端行为,没有登录任何厂商账户。 +- 对现有序列化函数的合成中文长文探针:每份约 20,011 字,一份资料时保留 1,120 字,五份时每份 192 字,放在末尾的“预算为42万元”均未进入请求。数字仅对应当时的合成输入与指令,不是每个请求固定可用的字数。 + +## 纯前端能做什么 + +纯前端是指本站没有自建业务后端;第三方 MCP 服务仍运行在外部服务器。浏览器可以通过 HTTPS 和流式响应实现协议请求,也可以完成公共客户端 OAuth。技术可行性需要逐条满足外部条件。 + +### 浏览器跨域访问 + +跨域请求是否允许由目标服务器的 CORS 响应决定。MCP 请求、授权元数据、客户端注册、Token 换取和刷新涉及的各个地址都需要检查。仅 MCP 地址的预检成功,不代表整条授权链路成功;SDK 和 `no-cors` 也不能解除浏览器的访问限制。 + +需要发送的请求头和需要读取的响应头都影响兼容性,例如 Authorization、协议版本、会话标识和授权挑战。`Access-Control-Allow-Headers: *` 不能替代对 Authorization 的显式允许;没有带 Cookie 时某些其他通配符才具有预期语义。正式验收应在真实浏览器中执行,而不是把命令行响应当作浏览器放行结果。 + +### 前端 OAuth + +OAuth 不必然要求本站后端。若授权方支持公共客户端、授权码加 PKCE、合法回调地址和相应跨域访问,前端能够发起登录并换取 Token。客户端 ID 可以公开,客户端密钥不能在前端保密。要求保密客户端密钥的授权方案需要后端或本机程序协助。 + +PKCE、state 与授权方校验、客户端注册、凭据生命周期、刷新 Token 轮换及撤销都属于实际工作量。把用户 Token 放入一个输入框,并不能视为已经支持通用 OAuth。浏览器存储也不等同于 HttpOnly Cookie 或操作系统密钥链。 + +### 本地进程 + +普通网页无法直接运行 `npx`、Python 或启动 stdio MCP 子进程。浏览器扩展、本地伴随程序、桌面壳或代理可以提供桥接,但这些会增加安装或服务依赖。连接已运行的本地 HTTP 服务是另一种情况,仍需满足跨域、服务端 Origin 校验和浏览器本地网络访问条件。 + +### 本次初步跨域探针 + +探针向三个公开入口发送 OPTIONS 和未认证 GET,并携带 `Origin: https://edit.chaxus.com`。没有使用 Token、完成登录、调用业务工具或操作账户。 + +| 服务 | 观察 | 结论边界 | +| ---------- | ---------------------------------------------------------------- | ---------------------------------------------------------------- | +| Notion | OPTIONS 和 GET 均得到 403,未观察到允许跨域的响应头 | 本次环境未确认可直连,不能由此断言所有浏览器和未来部署永远不支持 | +| Figma | OPTIONS 为 200,返回部分 CORS 允许头;GET 为 405 | 不能证明客户端准入、OAuth 或带会话标识的请求成功 | +| Longbridge | OPTIONS 为 200,返回 CORS 通配头;GET 为 401,返回授权元数据地址 | 不能证明带 Authorization 的预检、Token 换取及刷新链路可用 | + +因此,本次调查没有得出“Figma、Notion 和 Longbridge 均已支持本项目纯前端直连”的结论。 + +## 业界连接放在哪里 + +| 路线 | 网络请求从哪里发出 | 代表与特点 | +| -------------- | --------------------------------------------------------- | ----------------------------------------------------------------------------- | +| 云端连接 | 网页调用产品后端,后端访问 MCP 与授权服务 | Claude 官方明确说明远程连接来自其云端基础设施,即便用户使用桌面客户端 | +| 本机连接 | 桌面程序、IDE 或本机进程访问 MCP,必要时启动 stdio 子进程 | Cursor、VS Code 提供本地命令与远程 HTTP 等配置能力 | +| 网页配本地后端 | 浏览器提供操作界面,本地后端承担连接与凭据职责 | 官方 MCP Inspector 的网页版本配有 Node 后端,不能因界面在网页中就认定其纯前端 | +| 浏览器直连 | 网站 JavaScript 直接访问第三方服务 | 可以实现,但受 CORS、公共客户端授权、厂商准入与本地进程能力限制 | + +上述例子说明架构选择,不构成市场份额统计。协议标准与运行位置是两个维度:使用官方 SDK 并不意味着必须增加云端后端,保留纯前端也不意味着能获得桌面客户端的全部连接能力。 + +## 曾讨论的方案与最终取舍 + +最初讨论了三条改进路径:只补 Drive 配置;完善资料连接、状态与相关摘录;实现通用 MCP Host。用户澄清后,通用方案收敛为官方 SDK、配置化服务、动态工具发现、OAuth 管理、到现有 `AgentTool` 的统一适配,以及宿主执行控制。 + +随后讨论了浏览器默认直连、可选网关或本地程序的连接分层。它能够扩大长期扩展空间,但“可选”仍意味着需要额外维护凭据与网络信任边界,不能把这一方案当作现有纯前端已经具备的通用能力。 + +用户最终明确选择无账户纯前端定位,撤回继续建设外部连接器的方向。不存在“先保留隐藏入口、等待未来接通”的当前需求,也没有创建 OAuth 应用、网关或账户系统的实施任务。 + +## 删除范围与兼容边界 + +删除外部 MCP 客户端、Google Drive 界面、授权回调通道、Google Functions、专用 Functions 路由配置、连接器专用文案和远程连接用例。把仍需保留的本地资料读取模块迁至 `lib/agent-plugin/references/`,并更新所有实际消费者和离线构建测试。 + +保留以下行为: + +- 用户手动添加本地文件,浏览器解析、搜索、预览、移除和五份附件上限。 +- 会话之间的附件隔离、排队请求快照、取消和迟到结果失效处理。 +- 请求上下文预算、最终发送摘录的引用、已保存对话与成果的兼容读取。 +- 成果保存、版本、编辑、导出和文档修改预览。 +- 用户主动配置的模型服务;它属于推理目的地,不属于此次删除的外部账户连接器。 +- 现有 embed API、编辑器工具及向外部浏览器代理暴露编辑器能力的 WebMCP。它们与助手主动连接第三方 MCP 的方向不同。 + +持久化来源记录中的 `connector` 字段继续作为历史来源类型元数据保留,避免为删除访问入口而修改已有对话与成果格式。旧来源链接仅作为记录展示,不会恢复远程连接。Service Worker 对 `/api/` 保持一般性网络绕过,避免 API 或旧授权链接被当作离线页面缓存;这不提供任何连接器路由。 + +## 实施步骤与验收 + +本次直接执行用户已经授权的删除,不创建新平台或依赖包。最小方案是删除远程分支并复用本地资料生命周期,而不是重建资料存储或迁移历史数据库。 + +1. 在独立 worktree 记录调研与产品决定,先验证原有相关测试基线。 +2. 调整测试,证明资料页仅有本地操作、在线离线切换不产生连接请求;保留附件容量、取消和历史引用覆盖。 +3. 删除 MCP 与 Drive 代码、后端和路由;迁移本地资料目录并更新七种语言、样式及现行开发文档。旧设计/计划仅增加被本决策替代的说明,保留历史正文。 +4. 运行相关及全量单元测试、格式/lint/类型检查、生产构建,并用真实浏览器覆盖本地资料、引用、成果和会话切换。 +5. 检索运行时与构建入口,确保没有剩余远程连接实现;检查原工作区改动未被覆盖。 + +具体变更归属:`ui/sources-view.ts` 与 `ui/workspace-labels.ts` 负责收敛本地界面;`references/` 接收原本地读取模块;`packages/shared/src/i18n/` 删除专用文案;`functions/` 与 `public/_routes.json` 移除服务端部署入口;`public/sw.js` 与相关路由测试改为一般 API 绕过;`styles/assistant.css` 删除远程专用样式;相关单元/E2E、当前开发说明及 `CHANGELOG.md` 同步。 + +验收重点:资料页没有地址、Token 或账户授权控件;本地附件仍可用于 AI;切换会话或取消后不附加迟到文本;旧对话引用及成果版本仍可读取;生产构建不携带外部连接器后端。既有模型服务连接和 WebMCP 不应被误删。 + +## 移除后的验证结果 + +- 删除前相关测试基线:六个文件、47 个用例通过。 +- 本地资料页回归用例先在旧实现下失败:实际存在地址和 Token 两个控件,期望为零;删除后通过。 +- 相关本地资料、引用、国际化、离线路由和构建图测试:八个文件、63 个用例通过。 +- 全量单测:`pnpm exec vitest run --maxWorkers=2`,250 个文件、6,746 个用例通过。首轮默认并发运行出现失败并中止,随后降低并发完成全量复核;没有为消除这些首轮失败而修改无关业务逻辑。 +- Chromium:`E2E_PORT=4287 pnpm exec playwright test test/e2e/agent-sources-artifacts.spec.ts test/e2e/agent-document-references.spec.ts --workers=2`,九个用例通过,涵盖本地 Office/PDF、引用恢复、离线成果、附件容量和迟到读取的会话隔离。模型响应使用受控测试服务,不是模型质量评测。 +- `pnpm run lint:ts`、`pnpm run format:check`、`pnpm run build` 均通过。生产资产中未检出远程连接器标识,未输出 `_routes.json` 或 Cloudflare Functions Worker。 +- 独立只读代码审查未发现需要修改的问题;文档相对链接与差异空白检查通过。删除的是仓库实现,本次没有部署线上,也没有变更厂商授权或 Cloudflare 环境配置。 + +## 官方参考资料 + +以下页面在本次会话中查阅,内容状态以 2026-10-18 为时间点;厂商政策和 SDK 后续可能改变。 + +- [MCP 架构](https://modelcontextprotocol.io/specification/2026-07-28/architecture):Host、Client、Server 的职责和协议边界。 +- [MCP 授权规范](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization):公共客户端、授权发现与客户端注册等要求。 +- [官方 TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk):协议客户端、传输和授权辅助能力;历史示例中的包名不能替代实施时的版本确认。 +- [Figma 远程 MCP 接入](https://developers.figma.com/docs/figma-mcp-server/remote-server-installation/):官方地址、授权及客户端准入。 +- [Notion 自定义 MCP 客户端](https://developers.notion.com/guides/mcp/build-mcp-client):OAuth、PKCE、注册、刷新和连接要求。 +- [Longbridge MCP](https://open.longbridge.com/docs/mcp):官方入口、账户权限和 OAuth 兼容要求。 +- [MDN CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS):浏览器跨域、预检和响应头规则。 +- [Claude 远程连接器网络位置](https://support.claude.com/en/articles/11175166-get-started-with-custom-connectors-using-remote-mcp):请求来自云端基础设施。 +- [Cursor MCP](https://prod.cursor.com/docs/mcp)、[VS Code MCP](https://code.visualstudio.com/docs/agent-customization/mcp-servers):本地和远程接入能力。 +- [官方 MCP Inspector](https://github.com/modelcontextprotocol/inspector):网页界面与 Node 后端组成。 diff --git a/docs/superpowers/plans/2026-10-16-ai-workspace-craft.md b/docs/superpowers/plans/2026-10-16-ai-workspace-craft.md index a3f6c69cc..a0e1639cd 100644 --- a/docs/superpowers/plans/2026-10-16-ai-workspace-craft.md +++ b/docs/superpowers/plans/2026-10-16-ai-workspace-craft.md @@ -1,5 +1,7 @@ # AI Workspace Craft Implementation Plan +> Connector scope superseded on 2026-10-18: external MCP and Google Drive connections were removed. Local file references and artifacts remain. See the [investigation and product decision](../../explorations/2026-10-18-mcp-connectors-and-frontend-boundaries.md). The original plan below is historical. + > **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development. Steps use checkbox syntax for tracking. Implement one task at a time; reviewers may work read-only. **Goal:** Deliver the approved document AI interaction improvements and bounded workspace capabilities in PR #274. diff --git a/docs/superpowers/plans/2026-10-16-ai-workspace-sources-artifacts.md b/docs/superpowers/plans/2026-10-16-ai-workspace-sources-artifacts.md index e815da28b..8cff72821 100644 --- a/docs/superpowers/plans/2026-10-16-ai-workspace-sources-artifacts.md +++ b/docs/superpowers/plans/2026-10-16-ai-workspace-sources-artifacts.md @@ -1,5 +1,7 @@ # AI workspace sources and artifacts +> Connector scope superseded on 2026-10-18: external MCP and Google Drive connections were removed. Local file references and artifacts remain. See the [investigation and product decision](../../explorations/2026-10-18-mcp-connectors-and-frontend-boundaries.md). The original plan below is historical. + ## Confirmed scope The user approved both connector classes in PR #274, staged commits, and optional Google authorization with a Cloudflare backend. The editor remains usable without an account. Existing uncommitted interaction improvements are retained and verified first. diff --git a/docs/superpowers/plans/2026-10-16-assistant-interaction-polish.md b/docs/superpowers/plans/2026-10-16-assistant-interaction-polish.md index 9f9f72eaf..a3d236aad 100644 --- a/docs/superpowers/plans/2026-10-16-assistant-interaction-polish.md +++ b/docs/superpowers/plans/2026-10-16-assistant-interaction-polish.md @@ -1,5 +1,7 @@ # Assistant interaction polish +> Connector scope superseded on 2026-10-18: external MCP and Google Drive connections were removed. Local file references and artifacts remain. See the [investigation and product decision](../../explorations/2026-10-18-mcp-connectors-and-frontend-boundaries.md). The original plan below is historical. + Goal: fix repeated setup failures shown in the user screenshots, make pending requests compact and actionable, and highlight literal query matches in conversation titles/snippets. English UI with Chinese requests is expected and must remain supported. Design: follow existing ranui tokens and component owners. A compact queue above the composer shows count/state, one visible resume action, a primary edit affordance and secondary per-request actions in an accessible menu. Setup requirements are state guidance, not repeatedly appended conversation errors. Never grant writing consent automatically or start inference/service connections without a deliberate user action. Search highlighting uses DOM text nodes and marks, never HTML injection or regular-expression interpretation of the query. diff --git a/docs/superpowers/specs/2026-10-16-ai-workspace-craft-design.md b/docs/superpowers/specs/2026-10-16-ai-workspace-craft-design.md index 2bd1e33d0..65d249d6b 100644 --- a/docs/superpowers/specs/2026-10-16-ai-workspace-craft-design.md +++ b/docs/superpowers/specs/2026-10-16-ai-workspace-craft-design.md @@ -1,5 +1,7 @@ # AI workspace craft and workflow completion +> Connector scope superseded on 2026-10-18: external MCP and Google Drive connections were removed. Local file references and artifacts remain. See the [investigation and product decision](../../explorations/2026-10-18-mcp-connectors-and-frontend-boundaries.md). The original plan below is historical. + ## Confirmed direction The user approved implementing the concrete improvements discussed in this conversation, continuing in PR #274 with multiple commits. The product is a browser-first document AI workspace. Preserve a flat, quiet interface, one composer boundary, document scope, explicit review, local/offline operation and seven complete locales. The user does not want another confirmation round for this approved direction. diff --git a/docs/superpowers/specs/2026-10-16-assistant-workspace-consistency-design.md b/docs/superpowers/specs/2026-10-16-assistant-workspace-consistency-design.md index 00ea713b6..930bc36af 100644 --- a/docs/superpowers/specs/2026-10-16-assistant-workspace-consistency-design.md +++ b/docs/superpowers/specs/2026-10-16-assistant-workspace-consistency-design.md @@ -1,5 +1,7 @@ # Assistant workspace consistency +> Connector scope superseded on 2026-10-18: external MCP and Google Drive connections were removed. Local file references and artifacts remain. See the [investigation and product decision](../../explorations/2026-10-18-mcp-connectors-and-frontend-boundaries.md). The original plan below is historical. + ## Scope and evidence The owner requested uniform buttons, spacing and interactions across the AI workspace, explicitly using design-system tokens. PR #276 is already merged; this increment starts from `origin/main` (`2427b27`) and requires a follow-up PR. Prior approval for compact, flat UI and continued implementation remains in force. diff --git a/functions/api/connectors/google/[[path]].ts b/functions/api/connectors/google/[[path]].ts deleted file mode 100644 index 6b6234b9a..000000000 --- a/functions/api/connectors/google/[[path]].ts +++ /dev/null @@ -1,330 +0,0 @@ -/** Optional server-only Drive bridge. Never import this module into browser code. */ -export interface GoogleConnectorEnv { - GOOGLE_CLIENT_ID?: string; - GOOGLE_CLIENT_SECRET?: string; - GOOGLE_CONNECTOR_SECRET?: string; - GOOGLE_CONNECTOR_ORIGIN?: string; -} -type Session = { access: string; expires: number }; -type Flow = { state: string; verifier: string; expires: number }; -const BASE = '/api/connectors/google'; -const COOKIE = '__Host-document-drive'; -const FLOW = '__Host-document-drive-flow'; -const MAX_TEXT = 128 * 1024; -const encoder = new TextEncoder(); -const encode = (bytes: Uint8Array) => - btoa(String.fromCharCode(...bytes)) - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, ''); -const decode = (value: string) => - Uint8Array.from(atob(value.replace(/-/g, '+').replace(/_/g, '/')), (char) => char.charCodeAt(0)); -const random = () => encode(crypto.getRandomValues(new Uint8Array(32))); -const cookie = (name: string, value: string, age: number) => - `${name}=${value}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=${age}`; -const getCookie = (request: Request, name: string) => - request.headers - .get('cookie') - ?.split(';') - .map((part) => part.trim()) - .find((part) => part.startsWith(`${name}=`)) - ?.slice(name.length + 1); -async function key(secret: string) { - return crypto.subtle.importKey( - 'raw', - await crypto.subtle.digest('SHA-256', encoder.encode(secret)), - 'AES-GCM', - false, - ['encrypt', 'decrypt'], - ); -} -async function seal(value: Session | Flow, secret: string) { - const iv = crypto.getRandomValues(new Uint8Array(12)); - const ciphertext = await crypto.subtle.encrypt( - { name: 'AES-GCM', iv, additionalData: encoder.encode('access' in value ? COOKIE : FLOW) }, - await key(secret), - encoder.encode(JSON.stringify(value)), - ); - return `${encode(iv)}.${encode(new Uint8Array(ciphertext))}`; -} -async function unseal( - value: string | undefined, - secret: string, - purpose: string, -): Promise { - try { - if (!value || value.length > 8000) return; - const [iv, body, extra] = value.split('.'); - if (extra) return; - const plaintext = await crypto.subtle.decrypt( - { name: 'AES-GCM', iv: decode(iv), additionalData: encoder.encode(purpose) }, - await key(secret), - decode(body), - ); - const result = JSON.parse(new TextDecoder().decode(plaintext)) as T; - return result.expires > Date.now() ? result : undefined; - } catch { - return; - } -} -class ConnectorError extends Error { - constructor( - readonly code: string, - readonly status = 502, - ) { - super(code); - } -} -async function bounded(response: Response, limit: number) { - if (Number(response.headers.get('content-length')) > limit) { - await response.body?.cancel(); - throw new ConnectorError('source_too_large', 413); - } - const reader = response.body?.getReader(); - if (!reader) return ''; - const chunks: Uint8Array[] = []; - let length = 0; - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - length += value.byteLength; - if (length > limit) throw new ConnectorError('source_too_large', 413); - chunks.push(value); - } - } finally { - await reader.cancel(); - } - const output = new Uint8Array(length); - let offset = 0; - for (const chunk of chunks) { - output.set(chunk, offset); - offset += chunk.length; - } - return new TextDecoder('utf-8', { fatal: true }).decode(output); -} -async function google(url: string, init: RequestInit, limit = MAX_TEXT) { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 15000); - try { - const response = await fetch(url, { ...init, signal: controller.signal, redirect: 'error' }); - if (!response.ok) { - await response.body?.cancel(); - throw new ConnectorError( - response.status === 401 ? 'reconnect_required' : 'service_unavailable', - response.status === 401 ? 401 : 502, - ); - } - return await bounded(response, limit); - } finally { - clearTimeout(timeout); - } -} -function json(data: unknown, status = 200) { - return new Response(JSON.stringify(data), { - status, - headers: { - 'Content-Type': 'application/json', - 'Cache-Control': 'no-store', - 'Cross-Origin-Opener-Policy': 'same-origin', - 'Cross-Origin-Embedder-Policy': 'require-corp', - 'X-Content-Type-Options': 'nosniff', - }, - }); -} -function callbackPage(success: boolean) { - const nonce = random(); - // COOP isolates the popup's opener; BroadcastChannel works without weakening isolation. - return new Response( - `Google Drive

${success ? 'Connected. You may close this window.' : 'Connection failed. Close this window and try again.'}

`, - { - headers: { - 'Content-Type': 'text/html; charset=utf-8', - 'Cache-Control': 'no-store', - 'Cross-Origin-Opener-Policy': 'same-origin', - 'Cross-Origin-Embedder-Policy': 'require-corp', - 'Content-Security-Policy': `default-src 'none'; script-src 'nonce-${nonce}'; base-uri 'none'; frame-ancestors 'none'`, - 'Referrer-Policy': 'no-referrer', - }, - }, - ); -} -export async function handleGoogleConnector(request: Request, env: GoogleConnectorEnv): Promise { - const url = new URL(request.url); - const action = url.pathname.slice(BASE.length).replace(/^\//, ''); - const configured = Boolean( - env.GOOGLE_CLIENT_ID && - env.GOOGLE_CLIENT_SECRET && - env.GOOGLE_CONNECTOR_SECRET && - env.GOOGLE_CONNECTOR_SECRET.length >= 32 && - env.GOOGLE_CONNECTOR_ORIGIN, - ); - if (!configured) return json({ configured: false, connected: false, error: 'not_configured' }, 503); - let origin: string; - try { - const parsed = new URL(env.GOOGLE_CONNECTOR_ORIGIN!); - if (parsed.protocol !== 'https:' || parsed.origin !== env.GOOGLE_CONNECTOR_ORIGIN) throw new Error(); - origin = parsed.origin; - } catch { - return json({ error: 'not_configured' }, 503); - } - if (url.origin !== origin) return json({ error: 'invalid_origin' }, 403); - const secret = `${env.GOOGLE_CONNECTOR_SECRET!}:${origin}`; - const redirectUri = `${origin}${BASE}/callback`; - const methods: Record = { - start: 'POST', - callback: 'GET', - status: 'GET', - list: 'GET', - read: 'GET', - disconnect: 'POST', - }; - if (!methods[action]) return json({ error: 'not_found' }, 404); - if (request.method !== methods[action]) return json({ error: 'method_not_allowed' }, 405); - if (request.method === 'POST' && request.headers.get('origin') !== origin) - return json({ error: 'invalid_origin' }, 403); - try { - if (action === 'start') { - const state = random(); - const verifier = random(); - const flow = await seal({ state, verifier, expires: Date.now() + 10 * 60000 }, secret); - const authorization = new URL('https://accounts.google.com/o/oauth2/v2/auth'); - authorization.search = new URLSearchParams({ - client_id: env.GOOGLE_CLIENT_ID!, - redirect_uri: redirectUri, - response_type: 'code', - scope: 'https://www.googleapis.com/auth/drive.readonly', - state, - code_challenge: encode(new Uint8Array(await crypto.subtle.digest('SHA-256', encoder.encode(verifier)))), - code_challenge_method: 'S256', - access_type: 'online', - }).toString(); - const response = json({ authorizationUrl: authorization.href }); - response.headers.append('Set-Cookie', cookie(FLOW, flow, 600)); - return response; - } - if (action === 'callback') { - const flow = await unseal(getCookie(request, FLOW), secret, FLOW); - const responseFailure = callbackPage(false); - responseFailure.headers.append('Set-Cookie', cookie(FLOW, '', 0)); - const code = url.searchParams.get('code'); - if ( - !flow || - !code || - code.length > 2048 || - url.searchParams.get('state') !== flow.state || - url.searchParams.has('error') - ) - return responseFailure; - try { - const tokens = JSON.parse( - await google( - 'https://oauth2.googleapis.com/token', - { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ - code, - client_id: env.GOOGLE_CLIENT_ID!, - client_secret: env.GOOGLE_CLIENT_SECRET!, - redirect_uri: redirectUri, - grant_type: 'authorization_code', - code_verifier: flow.verifier, - }), - }, - 16384, - ), - ); - if ( - typeof tokens.access_token !== 'string' || - tokens.access_token.length > 3000 || - !Number.isFinite(tokens.expires_in) || - tokens.expires_in <= 0 - ) - return responseFailure; - const ttl = Math.min(tokens.expires_in, 3600); - const response = callbackPage(true); - response.headers.append('Set-Cookie', cookie(FLOW, '', 0)); - response.headers.append( - 'Set-Cookie', - cookie(COOKIE, await seal({ access: tokens.access_token, expires: Date.now() + ttl * 1000 }, secret), ttl), - ); - return response; - } catch { - return responseFailure; - } - } - const session = await unseal(getCookie(request, COOKIE), secret, COOKIE); - if (action === 'status') - return json({ - configured: true, - connected: Boolean(session), - ...(session ? { expiresAt: session.expires } : {}), - }); - if (action === 'disconnect') { - if (session) { - try { - await google( - 'https://oauth2.googleapis.com/revoke', - { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ token: session.access }), - }, - 4096, - ); - } catch { - /* Clear the local session even when revocation is unavailable. */ - } - } - const response = json({ connected: false }); - response.headers.append('Set-Cookie', cookie(COOKIE, '', 0)); - response.headers.append('Set-Cookie', cookie(FLOW, '', 0)); - return response; - } - if (!session) return json({ error: 'reconnect_required' }, 401); - const headers = { Authorization: `Bearer ${session.access}` }; - if (action === 'list') { - const search = (url.searchParams.get('q') ?? '').trim(); - if (search.length > 200 || Array.from(search).some((character) => character.charCodeAt(0) < 32)) - return json({ error: 'invalid_query' }, 400); - const literal = search.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); - const upstream = new URL('https://www.googleapis.com/drive/v3/files'); - upstream.search = new URLSearchParams({ - pageSize: '30', - fields: 'nextPageToken,files(id,name,mimeType,modifiedTime,size)', - q: - "trashed = false and (mimeType = 'application/vnd.google-apps.document' or mimeType = 'text/plain' or mimeType = 'text/markdown' or mimeType = 'text/csv' or mimeType = 'application/json')" + - (search ? ` and name contains '${literal}'` : ''), - ...(url.searchParams.get('pageToken') ? { pageToken: url.searchParams.get('pageToken')!.slice(0, 2048) } : {}), - }).toString(); - const result = JSON.parse(await google(upstream.href, { headers })); - return json({ files: result.files ?? [], nextPageToken: result.nextPageToken }); - } - const id = url.searchParams.get('id'); - if (!id || !/^[a-zA-Z0-9_-]{1,200}$/.test(id)) return json({ error: 'invalid_file' }, 400); - const metadata = JSON.parse( - await google( - `https://www.googleapis.com/drive/v3/files/${id}?fields=id,name,mimeType,size,modifiedTime`, - { headers }, - 16384, - ), - ); - const doc = metadata.mimeType === 'application/vnd.google-apps.document'; - if (!doc && !['text/plain', 'text/markdown', 'text/csv', 'application/json'].includes(metadata.mimeType)) - return json({ error: 'unsupported_file' }, 415); - if (Number(metadata.size) > MAX_TEXT) return json({ error: 'source_too_large' }, 413); - const text = await google( - `https://www.googleapis.com/drive/v3/files/${id}${doc ? '/export?mimeType=text%2Fplain' : '?alt=media'}`, - { headers }, - ); - return json({ id, name: metadata.name, mimeType: metadata.mimeType, modifiedTime: metadata.modifiedTime, text }); - } catch (error) { - return json( - { error: error instanceof ConnectorError ? error.code : 'service_unavailable' }, - error instanceof ConnectorError ? error.status : 502, - ); - } -} -export const onRequest = ({ request, env }: { request: Request; env: GoogleConnectorEnv }) => - handleGoogleConnector(request, env); diff --git a/lib/agent-plugin/connectors/reference-sources.ts b/lib/agent-plugin/connectors/reference-sources.ts deleted file mode 100644 index 31d2804af..000000000 --- a/lib/agent-plugin/connectors/reference-sources.ts +++ /dev/null @@ -1,311 +0,0 @@ -import { withReferenceAbort } from './reference-lifetime'; -/** Explicit read-only reference sources. Text is untrusted data, never agent instructions. */ -export interface ReferenceText { - id: string; - connector: string; - title: string; - text: string; - uri?: string; -} -export const MAX_REFERENCE_BYTES = 262144; -export class ReferenceSources { - private entries = new Map>(); - private readonly conversations = new Map>>(); - select(id: string): void { - let entries = this.conversations.get(id); - if (!entries) { - entries = new Map(); - this.conversations.set(id, entries); - } - this.entries = entries; - } - list(): readonly Readonly[] { - return [...this.entries.values()]; - } - add(reference: ReferenceText): void { - if ( - !reference.id || - !reference.connector || - !reference.title || - new TextEncoder().encode(reference.text).length > MAX_REFERENCE_BYTES - ) - throw new Error('source-too-large'); - if (!this.entries.has(reference.id) && this.entries.size >= 5) throw new Error('source-limit'); - this.entries.set(reference.id, Object.freeze({ ...reference, title: reference.title.slice(0, 200) })); - } - remove(id: string): void { - this.entries.delete(id); - } - disconnect(connector: string): void { - for (const entries of new Set([this.entries, ...this.conversations.values()])) - for (const [id, source] of entries) if (source.connector === connector) entries.delete(id); - } - forget(id?: string): void { - if (id !== undefined) { - const entries = this.conversations.get(id); - entries?.clear(); - this.conversations.delete(id); - if (entries === this.entries) this.entries = new Map(); - } else { - this.entries.clear(); - for (const entries of this.conversations.values()) entries.clear(); - this.conversations.clear(); - } - } - capture() { - const entries = this.entries; - const references = Object.freeze([...entries.values()]); - return Object.freeze({ - references, - isCurrent: () => references.every((source) => entries.get(source.id) === source), - }); - } -} -export async function readLocalReference(file: File, signal?: AbortSignal): Promise { - const extension = file.name.split('.').pop()?.toLowerCase() ?? ''; - const office = ['docx', 'xlsx', 'pptx'].includes(extension); - const document = office || extension === 'pdf'; - if (!document && !['txt', 'md', 'csv', 'tsv', 'json'].includes(extension)) throw new Error('source-unsupported'); - if (file.size > (document ? 20 * 1024 * 1024 : MAX_REFERENCE_BYTES)) throw new Error('source-too-large'); - const deadline = AbortSignal.timeout(15000); - const operation = signal ? AbortSignal.any([signal, deadline]) : deadline; - operation.throwIfAborted(); - let text: string; - try { - if (document) { - const bytes = new Uint8Array(await withReferenceAbort(file.arrayBuffer(), operation)); - operation.throwIfAborted(); - text = office - ? await ( - await withReferenceAbort(import('./document-reference-readers'), operation) - ).readOfficeReference(bytes, extension, operation) - : await ( - await withReferenceAbort(import('./pdf-reference-reader'), operation) - ).readPdfReference(bytes, operation); - } else text = await withReferenceAbort(file.text(), operation); - operation.throwIfAborted(); - } catch (error) { - if (deadline.aborted && !signal?.aborted) throw new Error('source-timeout'); - if (operation.aborted) throw operation.reason; - if (document && !(error instanceof Error && error.message.startsWith('source-'))) throw new Error('source-corrupt'); - throw error; - } - if (text.includes('\0')) throw new Error('source-unsupported'); - return { id: crypto.randomUUID(), connector: 'local', title: file.name, text }; -} -export async function boundedText(response: Response, limit = MAX_REFERENCE_BYTES): Promise { - if (Number(response.headers.get('content-length')) > limit) { - await response.body?.cancel(); - throw new Error('source-too-large'); - } - const reader = response.body?.getReader(); - if (!reader) return ''; - let length = 0; - const decoder = new TextDecoder(); - let result = ''; - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - length += value.length; - if (length > limit) throw new Error('source-too-large'); - result += decoder.decode(value, { stream: true }); - } - return result + decoder.decode(); - } finally { - await reader.cancel().catch(() => undefined); - } -} -export function validateMcpUrl(value: string): string { - let url: URL; - try { - url = new URL(value); - } catch { - throw new Error('source-invalid-url'); - } - const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname); - if ( - (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) || - url.username || - url.password || - url.hash || - url.search - ) - throw new Error('source-invalid-url'); - return url.href; -} -export interface McpResource { - uri: string; - name: string; -} -/** Resource-only Streamable HTTP client; credentials stay in memory and no tool calls are exposed. */ -export class McpResources { - private readonly url: string; - private sequence = 0; - private session = ''; - private initialized = false; - private resources = new Map(); - private cursors = new Set(); - private nextCursor = ''; - get continuation(): string { - return this.nextCursor; - } - private readonly lifetime = new AbortController(); - constructor( - url: string, - private token = '', - private readonly fetcher: typeof fetch = fetch, - ) { - this.url = validateMcpUrl(url); - } - disconnect(): void { - this.lifetime.abort(); - this.token = ''; - this.session = ''; - this.resources.clear(); - } - private async rpc(method: string, params: unknown, notification = false): Promise> { - const id = ++this.sequence; - const signal = AbortSignal.any([this.lifetime.signal, AbortSignal.timeout(15000)]); - const headers: Record = { - 'Content-Type': 'application/json', - Accept: 'application/json, text/event-stream', - }; - if (this.token) headers.Authorization = `Bearer ${this.token}`; - if (this.session) headers['Mcp-Session-Id'] = this.session; - if (this.initialized) headers['MCP-Protocol-Version'] = '2025-11-25'; - const response = await this.fetcher(this.url, { - method: 'POST', - headers, - body: JSON.stringify({ jsonrpc: '2.0', ...(notification ? {} : { id }), method, params }), - signal, - credentials: 'omit', - redirect: 'error', - referrerPolicy: 'no-referrer', - }); - if (!response.ok) - throw new Error(response.status === 401 || response.status === 403 ? 'source-auth' : 'source-unavailable'); - if (method === 'initialize') this.session = response.headers.get('Mcp-Session-Id') ?? ''; - if (notification) { - await response.body?.cancel(); - return {}; - } - let message: { id?: number; result?: Record; error?: unknown } | undefined; - if (response.headers.get('content-type')?.includes('text/event-stream')) { - const limit = MAX_REFERENCE_BYTES + 32768; - if (Number(response.headers.get('content-length')) > limit) { - await response.body?.cancel(); - throw new Error('source-too-large'); - } - const reader = response.body?.getReader(); - if (!reader) throw new Error('source-invalid-response'); - const decoder = new TextDecoder(); - let bytes = 0; - let pending = ''; - const abort = () => { - void reader.cancel(signal.reason).catch(() => undefined); - }; - signal.addEventListener('abort', abort, { once: true }); - try { - signal.throwIfAborted(); - while (!message) { - const { done, value } = await reader.read(); - signal.throwIfAborted(); - if (value) { - bytes += value.length; - if (bytes > limit) throw new Error('source-too-large'); - } - pending += done ? decoder.decode() : decoder.decode(value, { stream: true }); - let boundary: RegExpExecArray | null; - while ((boundary = /\r?\n\r?\n/.exec(pending))) { - const block = pending.slice(0, boundary.index); - pending = pending.slice(boundary.index + boundary[0].length); - const data = block - .split(/\r?\n/) - .filter((line) => line.startsWith('data:')) - .map((line) => line.slice(5).replace(/^ /, '')) - .join('\n'); - if (!data) continue; - let candidate; - try { - candidate = JSON.parse(data); - } catch { - throw new Error('source-invalid-response'); - } - if (candidate?.id === id) { - message = candidate; - break; - } - } - if (done) break; - } - } finally { - signal.removeEventListener('abort', abort); - await reader.cancel().catch(() => undefined); - } - } else { - const text = await boundedText(response, MAX_REFERENCE_BYTES + 32768); - try { - message = JSON.parse(text); - } catch { - throw new Error('source-invalid-response'); - } - } - if (message?.id !== id || message.error || !message.result) throw new Error('source-invalid-response'); - return message.result; - } - async list(cursor = ''): Promise { - if ( - cursor && - (cursor !== this.nextCursor || cursor.length > 2048 || this.cursors.has(cursor) || this.cursors.size >= 9) - ) - throw new Error('source-invalid-response'); - if (!this.initialized) { - const init = await this.rpc('initialize', { - protocolVersion: '2025-11-25', - capabilities: {}, - clientInfo: { name: 'document', version: '1.0' }, - }); - if (init.protocolVersion !== '2025-11-25') throw new Error('source-protocol'); - this.initialized = true; - await this.rpc('notifications/initialized', {}, true); - } - const result = await this.rpc('resources/list', cursor ? { cursor } : {}); - if (!Array.isArray(result.resources)) throw new Error('source-invalid-response'); - if (!cursor) { - this.resources.clear(); - this.cursors.clear(); - } else this.cursors.add(cursor); - const page: McpResource[] = []; - for (const item of result.resources.slice(0, 100)) - if (typeof item?.uri === 'string' && typeof item?.name === 'string') - if (item.uri.length <= 2048 && !this.resources.has(item.uri) && this.resources.size < 500) { - const resource = { uri: item.uri, name: item.name.slice(0, 200) }; - this.resources.set(item.uri, resource); - page.push(resource); - } - const next = result.nextCursor; - this.nextCursor = - typeof next === 'string' && - next.length <= 2048 && - next !== cursor && - !this.cursors.has(next) && - this.resources.size < 500 && - this.cursors.size < 9 - ? next - : ''; - return page; - } - async read(uri: string): Promise { - const resource = this.resources.get(uri); - if (!resource) throw new Error('source-unlisted'); - const result = await this.rpc('resources/read', { uri }); - if (!Array.isArray(result.contents)) throw new Error('source-invalid-response'); - const text = result.contents - .filter((item) => typeof item?.text === 'string') - .map((item) => item.text) - .join('\n'); - if (!text || new TextEncoder().encode(text).length > MAX_REFERENCE_BYTES) throw new Error('source-too-large'); - return { id: crypto.randomUUID(), connector: 'mcp', title: resource.name, uri, text }; - } -} diff --git a/lib/agent-plugin/connectors/document-reference-readers.ts b/lib/agent-plugin/references/document-reference-readers.ts similarity index 100% rename from lib/agent-plugin/connectors/document-reference-readers.ts rename to lib/agent-plugin/references/document-reference-readers.ts diff --git a/lib/agent-plugin/connectors/pdf-reference-reader.ts b/lib/agent-plugin/references/pdf-reference-reader.ts similarity index 100% rename from lib/agent-plugin/connectors/pdf-reference-reader.ts rename to lib/agent-plugin/references/pdf-reference-reader.ts diff --git a/lib/agent-plugin/connectors/reference-lifetime.ts b/lib/agent-plugin/references/reference-lifetime.ts similarity index 100% rename from lib/agent-plugin/connectors/reference-lifetime.ts rename to lib/agent-plugin/references/reference-lifetime.ts diff --git a/lib/agent-plugin/connectors/reference-search.ts b/lib/agent-plugin/references/reference-search.ts similarity index 100% rename from lib/agent-plugin/connectors/reference-search.ts rename to lib/agent-plugin/references/reference-search.ts diff --git a/lib/agent-plugin/references/reference-sources.ts b/lib/agent-plugin/references/reference-sources.ts new file mode 100644 index 000000000..5ce91391b --- /dev/null +++ b/lib/agent-plugin/references/reference-sources.ts @@ -0,0 +1,91 @@ +import { withReferenceAbort } from './reference-lifetime'; +/** Explicit read-only reference sources. Text is untrusted data, never agent instructions. */ +export interface ReferenceText { + id: string; + connector: string; + title: string; + text: string; + uri?: string; +} +export const MAX_REFERENCE_BYTES = 262144; +export class ReferenceSources { + private entries = new Map>(); + private readonly conversations = new Map>>(); + select(id: string): void { + let entries = this.conversations.get(id); + if (!entries) { + entries = new Map(); + this.conversations.set(id, entries); + } + this.entries = entries; + } + list(): readonly Readonly[] { + return [...this.entries.values()]; + } + add(reference: ReferenceText): void { + if ( + !reference.id || + !reference.connector || + !reference.title || + new TextEncoder().encode(reference.text).length > MAX_REFERENCE_BYTES + ) + throw new Error('source-too-large'); + if (!this.entries.has(reference.id) && this.entries.size >= 5) throw new Error('source-limit'); + this.entries.set(reference.id, Object.freeze({ ...reference, title: reference.title.slice(0, 200) })); + } + remove(id: string): void { + this.entries.delete(id); + } + forget(id?: string): void { + if (id !== undefined) { + const entries = this.conversations.get(id); + entries?.clear(); + this.conversations.delete(id); + if (entries === this.entries) this.entries = new Map(); + } else { + this.entries.clear(); + for (const entries of this.conversations.values()) entries.clear(); + this.conversations.clear(); + } + } + capture() { + const entries = this.entries; + const references = Object.freeze([...entries.values()]); + return Object.freeze({ + references, + isCurrent: () => references.every((source) => entries.get(source.id) === source), + }); + } +} +export async function readLocalReference(file: File, signal?: AbortSignal): Promise { + const extension = file.name.split('.').pop()?.toLowerCase() ?? ''; + const office = ['docx', 'xlsx', 'pptx'].includes(extension); + const document = office || extension === 'pdf'; + if (!document && !['txt', 'md', 'csv', 'tsv', 'json'].includes(extension)) throw new Error('source-unsupported'); + if (file.size > (document ? 20 * 1024 * 1024 : MAX_REFERENCE_BYTES)) throw new Error('source-too-large'); + const deadline = AbortSignal.timeout(15000); + const operation = signal ? AbortSignal.any([signal, deadline]) : deadline; + operation.throwIfAborted(); + let text: string; + try { + if (document) { + const bytes = new Uint8Array(await withReferenceAbort(file.arrayBuffer(), operation)); + operation.throwIfAborted(); + text = office + ? await ( + await withReferenceAbort(import('./document-reference-readers'), operation) + ).readOfficeReference(bytes, extension, operation) + : await ( + await withReferenceAbort(import('./pdf-reference-reader'), operation) + ).readPdfReference(bytes, operation); + } else text = await withReferenceAbort(file.text(), operation); + operation.throwIfAborted(); + } catch (error) { + if (deadline.aborted && !signal?.aborted) throw new Error('source-timeout'); + if (operation.aborted) throw operation.reason; + if (document && !(error instanceof Error && error.message.startsWith('source-'))) throw new Error('source-corrupt'); + throw error; + } + if (text.includes('\0')) throw new Error('source-unsupported'); + return { id: crypto.randomUUID(), connector: 'local', title: file.name, text }; +} diff --git a/lib/agent-plugin/ui/artifact-request.ts b/lib/agent-plugin/ui/artifact-request.ts index 3085bbd27..1acdeebd3 100644 --- a/lib/agent-plugin/ui/artifact-request.ts +++ b/lib/agent-plugin/ui/artifact-request.ts @@ -1,7 +1,7 @@ import type { LLMProvider } from '@ranuts/agent-core/llm/types'; import type { ArtifactStore } from '../artifacts/store'; import type { ArtifactDraftBinding } from '../artifacts/edit-proposal'; -import type { ReferenceText } from '../connectors/reference-sources'; +import type { ReferenceText } from '../references/reference-sources'; import { ArtifactRevisionError, runArtifactRevision, type ArtifactRevisionRequest } from './artifact-workflow'; import type { QueuedRequest } from './request-queue'; import type { TurnResult } from './controller'; diff --git a/lib/agent-plugin/ui/panel.ts b/lib/agent-plugin/ui/panel.ts index 15ffe85e5..08eeb09fa 100644 --- a/lib/agent-plugin/ui/panel.ts +++ b/lib/agent-plugin/ui/panel.ts @@ -5,7 +5,7 @@ import { createWorkspaceNavigation } from './workspace-navigation'; import { createArtifactStore, type ArtifactProvenance } from '../artifacts/store'; import { createArtifactsView } from './artifacts-view'; import { createArtifactRequestOwner, type ArtifactRequestBinding } from './artifact-request'; -import { ReferenceSources } from '../connectors/reference-sources'; +import { ReferenceSources } from '../references/reference-sources'; import { createSourcesView } from './sources-view'; import { sourcesLabels, artifactLabels, artifactWorkflowLabels, resultActionLabels } from './workspace-labels'; import { runDocumentTask } from '../document-task'; diff --git a/lib/agent-plugin/ui/request-context.ts b/lib/agent-plugin/ui/request-context.ts index a663d0de7..1c4ab62ca 100644 --- a/lib/agent-plugin/ui/request-context.ts +++ b/lib/agent-plugin/ui/request-context.ts @@ -3,7 +3,7 @@ import { getEditorApi } from '../editor-bridge'; import { readWordBodyText } from '../word-text-read'; import { readPresentationShapeText } from '../slide-text-read'; import { captureComposerContext } from './composer-context'; -import { safeSourceUri } from '../connectors/reference-search'; +import { safeSourceUri } from '../references/reference-search'; import type { DocumentContext } from '../document-context'; interface ReadContent { diff --git a/lib/agent-plugin/ui/source-preview.ts b/lib/agent-plugin/ui/source-preview.ts index 8d75cf526..3ff879b31 100644 --- a/lib/agent-plugin/ui/source-preview.ts +++ b/lib/agent-plugin/ui/source-preview.ts @@ -1,6 +1,6 @@ import { renderSearchText } from './search-highlight'; -import { safeSourceUri, sourceExcerpt } from '../connectors/reference-search'; -export { safeSourceUri, sourceMatches } from '../connectors/reference-search'; +import { safeSourceUri, sourceExcerpt } from '../references/reference-search'; +export { safeSourceUri, sourceMatches } from '../references/reference-search'; export function sourcePreview( source: { title: string; text: string; uri?: string; truncated?: boolean }, label: string, diff --git a/lib/agent-plugin/ui/sources-view.ts b/lib/agent-plugin/ui/sources-view.ts index c2d2c02ab..42762e6a3 100644 --- a/lib/agent-plugin/ui/sources-view.ts +++ b/lib/agent-plugin/ui/sources-view.ts @@ -1,9 +1,8 @@ import { createViewHeader, labelViewBack } from './view-header'; import { sourcePreview } from './source-preview'; -import { sourceMatches } from '../connectors/reference-search'; -import { renderSearchText } from './search-highlight'; +import { sourceMatches } from '../references/reference-search'; import { Div, View } from 'ranui/builder'; -import { ReferenceSources, McpResources, boundedText, readLocalReference } from '../connectors/reference-sources'; +import { ReferenceSources, readLocalReference } from '../references/reference-sources'; export interface SourcesLabels { title: string; @@ -11,15 +10,6 @@ export interface SourcesLabels { excerpt: string; local: string; localHint: string; - mcp: string; - url: string; - token: string; - connect: string; - disconnect: string; - drive: string; - driveHint: string; - unavailable: string; - reconnect: string; loading: string; failed: string; tooLarge: string; @@ -30,10 +20,8 @@ export interface SourcesLabels { timeout?: string; unsupported: string; remove: string; - attached: string; attach: string; close: string; - more: string; search?: string; openSource?: string; previewTruncated?: string; @@ -53,17 +41,8 @@ export function createSourcesView(options: { const offlineHint = View('p').class('agent-offline-hint agent-entry-note').attr('role', 'status').build(); const status = View('p').class('agent-sources-status').attr('role', 'status').build(); const search = View('input').attr('type', 'search').build(); - const driveSearch = View('input').attr('type', 'search').build(); search.addEventListener('input', () => { update(); - filterDiscovered(); - }); - driveSearch.maxLength = 200; - driveSearch.addEventListener('input', () => { - cancelPending(); - nextPage = ''; - more.hidden = true; - driveRows.replaceChildren(); }); const attached = Div().class('agent-sources-attached').build(); const local = View('input') @@ -71,22 +50,11 @@ export function createSourcesView(options: { .attr('accept', '.txt,.md,.csv,.tsv,.json,.docx,.xlsx,.pptx,.pdf') .build(); const localHint = View('p').class('agent-entry-note').build(); - const url = View('input').attr('type', 'url').build(); - const token = View('input').attr('type', 'password').attr('autocomplete', 'off').build(); - const mcpRows = Div().class('agent-source-options').build(); - const driveRows = Div().class('agent-source-options').build(); const localHeading = View('h3').build(); - const mcpHeading = View('h3').build(); - const driveHeading = View('h3').build(); - const driveHint = View('p').class('agent-entry-note').build(); - let client: McpResources | undefined; let disposed = false; let epoch = 0; let busy = false; let localOperation: AbortController | undefined; - let mcpConnected = false; - let driveConnected = false; - let driveDisconnecting = false; let operationId = 0; const attach = (source: Parameters[0], isCurrent: () => boolean) => { if (isCurrent()) { @@ -100,111 +68,6 @@ export function createSourcesView(options: { return b; }; const back = button(options.close); - const connect = button( - () => - void run(async (isCurrent) => { - client?.disconnect(); - options.sources.disconnect('mcp'); - options.changed(); - client = new McpResources(url.value, token.value); - token.value = ''; - const current = client; - const resources = await current.list(); - if (!isCurrent() || client !== current) return; - mcpConnected = true; - syncConnectionActions(); - mcpRows.replaceChildren(); - renderMcp(resources, current); - }, 'mcp'), - ); - function renderMcp(resources: Awaited>, current: McpResources) { - for (const resource of resources) { - const row = button( - () => - void run(async (isCurrent) => { - const source = await current.read(resource.uri); - if (current !== client) return; - attach(source, isCurrent); - }, 'mcp'), - ); - row.textContent = resource.name; - row.dataset.sourceTitle = resource.name; - mcpRows.append(row); - } - mcpMore.hidden = !current.continuation; - filterDiscovered(); - } - const mcpMore = button( - () => - void run(async (isCurrent) => { - const current = client; - if (!current?.continuation) return; - const rows = await current.list(current.continuation); - if (isCurrent() && current === client) renderMcp(rows, current); - }, 'mcp'), - ); - const disconnect = button(() => { - cancelPending(); - client?.disconnect(); - client = undefined; - mcpConnected = false; - syncConnectionActions(); - options.sources.disconnect('mcp'); - mcpRows.replaceChildren(); - update(); - }); - const drive = button(() => { - if (busy || disposed || driveDisconnecting || navigator.onLine === false) return; - const popup = window.open('about:blank', '_blank', 'popup,width=560,height=720'); - if (!popup) { - status.textContent = labels.failed; - return; - } - void run(async (isCurrent) => { - try { - const result = await google('start', 'POST'); - if (!isCurrent()) { - popup.close(); - return; - } - const destination = new URL(String(result.authorizationUrl)); - if (destination.origin !== 'https://accounts.google.com') throw new Error('source-unavailable'); - popup.location.href = destination.href; - } catch (error) { - popup.close(); - throw error; - } - }, 'google'); - }); - const driveDisconnect = button(() => { - if (driveDisconnecting) return; - driveDisconnecting = true; - cancelPending(); - driveConnected = false; - syncConnectionActions(); - options.sources.disconnect('google'); - update(); - driveRows.replaceChildren(); - status.textContent = labels.loading; - const revision = epoch; - const operation = operationId; - void google('disconnect', 'POST') - .then(() => { - if (!disposed && epoch === revision && operation === operationId) status.textContent = ''; - }) - .catch(() => { - if (!disposed && epoch === revision && operation === operationId) status.textContent = labels.failed; - }) - .finally(() => { - driveDisconnecting = false; - if (!disposed) syncConnectionActions(); - }); - }); - const driveList = button(() => void run((isCurrent) => listDrive('', isCurrent), 'google')); - let nextPage = ''; - const drivePages = new Set(); - const driveFiles = new Set(); - const more = button(() => void run((isCurrent) => listDrive(nextPage, isCurrent), 'google')); const label = (control: HTMLElement, key: string) => { const wrapper = View('label').build(); const text = View('span').text(key).build(); @@ -214,15 +77,6 @@ export function createSourcesView(options: { const attachFile = button(() => local.click()); const localField = label(local, ''); localField.wrapper.hidden = true; - const urlField = label(url, ''); - const tokenField = label(token, ''); - const mcpDetails = View('details').class('agent-disclosure agent-source-settings').build(); - const mcpSummary = View('summary').children(mcpHeading).build(); - mcpDetails.append(mcpSummary, urlField.wrapper, tokenField.wrapper, connect, disconnect, mcpRows, mcpMore); - const driveSection = View('section') - .class('agent-cloud-sources') - .children(driveHeading, driveHint, drive, driveSearch, driveList, driveDisconnect, driveRows, more) - .build(); el.append( createViewHeader(heading, back), excerpt, @@ -234,97 +88,8 @@ export function createSourcesView(options: { attachFile, localField.wrapper, localHint, - mcpDetails, - driveSection, ); - function syncConnectionActions() { - mcpMore.hidden = !mcpConnected || !client?.continuation; - connect.hidden = mcpConnected; - disconnect.hidden = !mcpConnected; - drive.hidden = driveConnected; - drive.disabled = driveDisconnecting; - driveList.disabled = driveDisconnecting; - driveDisconnect.hidden = !driveConnected; - } - async function google(path: string, method = 'GET'): Promise> { - if (navigator.onLine === false) throw new Error('source-offline'); - const response = await fetch(`/api/connectors/google/${path}`, { - method, - credentials: 'same-origin', - redirect: 'error', - signal: AbortSignal.timeout(15000), - }); - if (!response.headers.get('content-type')?.includes('application/json')) throw new Error('source-unavailable'); - const data = JSON.parse(await boundedText(response)); - if (!response.ok) - throw new Error( - response.status === 401 - ? 'source-auth' - : data.error === 'source_too_large' - ? 'source-too-large' - : data.error === 'not_configured' - ? 'source-unavailable' - : 'source-failed', - ); - return data; - } - async function listDrive(pageToken: string, isCurrent: () => boolean) { - const params = new URLSearchParams({ q: driveSearch.value.trim() }); - if (pageToken) params.set('pageToken', pageToken); - const result = await google(`list?${params}`); - if (!isCurrent()) return; - if (!pageToken) { - driveRows.replaceChildren(); - drivePages.clear(); - driveFiles.clear(); - } else drivePages.add(pageToken); - if (!Array.isArray(result.files)) throw new Error('source-failed'); - for (const file of result.files.slice(0, 30)) { - if ( - typeof file.id !== 'string' || - typeof file.name !== 'string' || - driveFiles.has(file.id) || - driveFiles.size >= 300 - ) - continue; - driveFiles.add(file.id); - const row = button( - () => - void run(async (isCurrent) => { - const read = await google(`read?id=${encodeURIComponent(file.id)}`); - if (typeof read.text !== 'string') throw new Error('source-failed'); - attach( - { - id: crypto.randomUUID(), - connector: 'google', - title: file.name, - text: read.text, - uri: `https://drive.google.com/file/d/${encodeURIComponent(file.id)}/view`, - }, - isCurrent, - ); - }, 'google'), - ); - row.textContent = file.name; - row.dataset.sourceTitle = file.name; - driveRows.append(row); - } - driveConnected = true; - syncConnectionActions(); - const token = result.nextPageToken; - nextPage = - typeof token === 'string' && - token.length <= 2048 && - token !== pageToken && - !drivePages.has(token) && - drivePages.size < 9 && - driveFiles.size < 300 - ? token - : ''; - more.hidden = !nextPage; - filterDiscovered(); - } - async function run(action: (isCurrent: () => boolean) => Promise, connector?: 'mcp' | 'google') { + async function run(action: (isCurrent: () => boolean) => Promise) { if (busy || disposed) return; const revision = epoch; const id = ++operationId; @@ -336,31 +101,14 @@ export function createSourcesView(options: { if (isCurrent()) status.textContent = ''; } catch (error) { if (isCurrent()) { - if (error instanceof Error && error.message === 'source-auth') { - if (connector === 'mcp') { - client?.disconnect(); - client = undefined; - mcpConnected = false; - mcpRows.replaceChildren(); - } else if (connector === 'google') { - driveConnected = false; - driveRows.replaceChildren(); - nextPage = ''; - more.hidden = true; - } - syncConnectionActions(); - } const errors: Record = { 'source-corrupt': labels.corrupt, 'source-encrypted': labels.encrypted, 'source-empty': labels.empty, 'source-timeout': labels.timeout, - 'source-offline': labels.offline, 'source-limit': labels.limit, 'source-too-large': labels.tooLarge, 'source-unsupported': labels.unsupported, - 'source-auth': labels.reconnect, - 'source-unavailable': labels.unavailable, }; status.textContent = (error instanceof Error ? errors[error.message] : undefined) ?? labels.failed; } @@ -381,20 +129,6 @@ export function createSourcesView(options: { } }); }); - function filterDiscovered() { - for (const container of [mcpRows, driveRows]) - for (const row of container.children) { - renderSearchText( - row as HTMLElement, - (row as HTMLElement).dataset.sourceTitle ?? '', - search.value.replace(/\s+/gu, ' ').trim().toLocaleLowerCase(), - ); - (row as HTMLElement).hidden = !sourceMatches( - { title: (row as HTMLElement).dataset.sourceTitle ?? '', text: '' }, - search.value, - ); - } - } function update() { attached.replaceChildren(); for (const source of options.sources.list()) { @@ -412,55 +146,23 @@ export function createSourcesView(options: { } options.changed(); } - let channel: BroadcastChannel | undefined; - try { - channel = new BroadcastChannel('document-google-connector'); - channel.onmessage = (event) => { - if (event.data?.type === 'google-connector-complete') - void run(async (isCurrent) => { - const state = await google('status'); - if (!state.connected) throw new Error('source-auth'); - await listDrive('', isCurrent); - }, 'google'); - }; - } catch { - /* Explicit list remains available. */ - } function setLabels(next: SourcesLabels) { labels = next; heading.textContent = labels.title; search.placeholder = labels.search ?? labels.title; search.setAttribute('aria-label', labels.search ?? labels.title); - driveSearch.placeholder = labels.search ?? labels.drive; - driveSearch.setAttribute('aria-label', `${labels.drive}: ${labels.search ?? labels.title}`); - mcpMore.textContent = labels.more; - mcpMore.hidden = !client?.continuation; offlineHint.textContent = labels.offline; syncConnectivity(); excerpt.textContent = labels.excerpt; labelViewBack(back, labels.close); localHeading.textContent = labels.local; attachFile.textContent = labels.attach; - syncConnectionActions(); localField.text.textContent = labels.attach; localHint.textContent = labels.localHint; - mcpHeading.textContent = labels.mcp; - urlField.text.textContent = labels.url; - tokenField.text.textContent = labels.token; - connect.textContent = labels.connect; - disconnect.textContent = labels.disconnect; - driveHeading.textContent = labels.drive; - driveHint.textContent = labels.driveHint; - drive.textContent = labels.connect; - driveList.textContent = labels.attached; - driveDisconnect.textContent = labels.disconnect; - more.textContent = labels.more; - more.hidden = !nextPage; update(); } function syncConnectivity() { const offline = navigator.onLine === false; - driveSection.hidden = offline; offlineHint.hidden = !offline; } window.addEventListener('online', syncConnectivity); @@ -485,11 +187,8 @@ export function createSourcesView(options: { localOperation?.abort(); localOperation = undefined; epoch++; - client?.disconnect(); - channel?.close(); window.removeEventListener('online', syncConnectivity); window.removeEventListener('offline', syncConnectivity); - token.value = ''; }, }; } diff --git a/lib/agent-plugin/ui/workspace-labels.ts b/lib/agent-plugin/ui/workspace-labels.ts index 3549f27f8..5941a2346 100644 --- a/lib/agent-plugin/ui/workspace-labels.ts +++ b/lib/agent-plugin/ui/workspace-labels.ts @@ -7,15 +7,6 @@ export function sourcesLabels(): SourcesLabels { excerpt: t('agentSourcesExcerpt'), local: t('agentSourcesLocal'), localHint: t('agentSourcesLocalHint'), - mcp: t('agentSourcesMcp'), - url: t('agentSourcesUrl'), - token: t('agentSourcesToken'), - connect: t('agentSourcesConnect'), - disconnect: t('agentSourcesDisconnect'), - drive: t('agentSourcesDrive'), - driveHint: t('agentSourcesDriveHint'), - unavailable: t('agentSourcesUnavailable'), - reconnect: t('agentSourcesReconnect'), loading: t('agentSourcesLoading'), failed: t('agentSourcesFailed'), tooLarge: t('agentSourcesTooLarge'), @@ -26,10 +17,8 @@ export function sourcesLabels(): SourcesLabels { timeout: t('agentSourcesTimeout'), unsupported: t('agentSourcesUnsupported'), remove: t('agentSourcesRemove'), - attached: t('agentSourcesAttached'), attach: t('agentSourcesAttach'), close: t('agentSourcesClose'), - more: t('agentSourcesMore'), search: t('agentSourcesSearch'), previewTruncated: t('agentSourcesPreviewTruncated'), openSource: t('agentSourcesOpen'), diff --git a/packages/shared/src/i18n/messages/de.ts b/packages/shared/src/i18n/messages/de.ts index 072b195a2..38c6c5aa4 100644 --- a/packages/shared/src/i18n/messages/de.ts +++ b/packages/shared/src/i18n/messages/de.ts @@ -510,17 +510,9 @@ export const de: Partial = { agentSourcesSources: 'Quellen', agentSourcesLocal: 'Lokale Dateien', agentSourcesLocalHint: 'Office/PDF bis 20 MiB · extrahierter Text bis 256 KiB · 5 Anhänge', - agentSourcesMcp: 'MCP-Ressourcen', - agentSourcesUrl: 'Server-URL', - agentSourcesToken: 'Zugriffstoken (nur diese Sitzung)', - agentSourcesConnect: 'Verbinden', - agentSourcesDisconnect: 'Trennen', - agentSourcesDrive: 'Google Drive', - agentSourcesDriveHint: 'Optionale Google-Autorisierung für Lesezugriff auf Drive. Nur benötigte Quellen anhängen.', - agentSourcesUnavailable: 'Diese Bereitstellung hat keinen Cloud-Konnektor. Lokale Dateien und MCP bleiben verfügbar.', - agentSourcesReconnect: 'Autorisierung abgelaufen oder verweigert. Erneut verbinden.', agentSourcesLoading: 'Quellen werden gelesen…', - agentSourcesFailed: 'Quelle konnte nicht gelesen werden. Verbindung, Berechtigungen und CORS prüfen.', + agentSourcesFailed: + 'Die Datei kann nicht gelesen werden. Versuchen Sie eine andere lokale Datei oder exportieren Sie sie als Klartext.', agentSourcesTooLarge: 'Quelle zu groß. Kleinere Datei oder Auszug wählen.', agentSourcesCorrupt: 'Datei nicht lesbar, möglicherweise beschädigt. Bitte neu exportieren.', agentSourcesEncrypted: 'Datei ist passwortgeschützt. Bitte eine entsperrte Kopie anhängen.', @@ -528,14 +520,12 @@ export const de: Partial = { agentSourcesTimeout: 'Lesen dauerte zu lange. Kleinere Datei oder Textexport versuchen.', agentSourcesUnsupported: 'Dateityp noch nicht unterstützt. Zuerst als Text exportieren.', agentSourcesRemove: 'Entfernen', - agentSourcesAttached: 'Dateien durchsuchen', agentSourcesAttach: 'Datei anhängen', agentSourcesClose: 'Zurück zum Chat', agentSourcesPreviewTruncated: 'Teilvorschau · bis zu 8.000 Zeichen', agentSourcesPartial: 'Für diese Anfrage gesendeter Quellenauszug', agentSourcesSearch: 'Quellen durchsuchen', agentSourcesOpen: 'Quelle öffnen', - agentSourcesMore: 'Mehr laden', agentSourcesExcerpt: 'Quellen werden beim Senden als begrenzte Auszüge an den gewählten KI-Dienst gesendet.', agentArtifactTitle: 'Artefakte', agentArtifactEmpty: 'Nützliche Ergebnisse hier speichern, bearbeiten und lokal versionieren.', diff --git a/packages/shared/src/i18n/messages/en.ts b/packages/shared/src/i18n/messages/en.ts index 75b587401..6df0965c5 100644 --- a/packages/shared/src/i18n/messages/en.ts +++ b/packages/shared/src/i18n/messages/en.ts @@ -484,19 +484,8 @@ export const en: I18nMessages = { agentSourcesSources: 'Sources', agentSourcesLocal: 'Local files', agentSourcesLocalHint: 'Office/PDF up to 20 MiB · extracted text up to 256 KiB · 5 attachments', - agentSourcesMcp: 'MCP resources', - agentSourcesUrl: 'Server URL', - agentSourcesToken: 'Access token (this session only)', - agentSourcesConnect: 'Connect', - agentSourcesDisconnect: 'Disconnect', - agentSourcesDrive: 'Google Drive', - agentSourcesDriveHint: - 'Optional Google authorization grants read-only access to Drive. Attach only what this request needs.', - agentSourcesUnavailable: 'This deployment has no cloud connector configured. Local files and MCP remain available.', - agentSourcesReconnect: 'Authorization expired or was denied. Connect again to continue.', agentSourcesLoading: 'Reading sources…', - agentSourcesFailed: - 'Could not read the source. Check the connection, permissions and browser CORS access, then try again.', + agentSourcesFailed: 'Unable to read this file. Try another local file or export it as plain text.', agentSourcesTooLarge: 'This source is too large. Choose a smaller file or excerpt.', agentSourcesCorrupt: 'This file cannot be read. It may be damaged. Try exporting a new copy.', agentSourcesEncrypted: 'This file is password-protected. Attach an unlocked copy.', @@ -504,14 +493,12 @@ export const en: I18nMessages = { agentSourcesTimeout: 'Reading took too long. Try a smaller file or export as text.', agentSourcesUnsupported: 'This file type is not supported as a reference yet. Export it as plain text first.', agentSourcesRemove: 'Remove', - agentSourcesAttached: 'Browse files', agentSourcesAttach: 'Attach file', agentSourcesClose: 'Back to conversation', agentSourcesPreviewTruncated: 'Partial source preview · up to 8,000 characters', agentSourcesPartial: 'Partial source excerpt sent for this request', agentSourcesSearch: 'Search sources', agentSourcesOpen: 'Open source', - agentSourcesMore: 'Load more', agentSourcesExcerpt: 'Sources are sent as bounded excerpts to the selected AI service when you send a request.', agentArtifactTitle: 'Artifacts', agentArtifactEmpty: 'Save useful results here to edit and keep versions on this device.', diff --git a/packages/shared/src/i18n/messages/es.ts b/packages/shared/src/i18n/messages/es.ts index f65c11a93..ded8e467d 100644 --- a/packages/shared/src/i18n/messages/es.ts +++ b/packages/shared/src/i18n/messages/es.ts @@ -497,17 +497,8 @@ export const es: Partial = { agentSourcesSources: 'Fuentes', agentSourcesLocal: 'Archivos locales', agentSourcesLocalHint: 'Office/PDF hasta 20 MiB · texto extraído hasta 256 KiB · 5 adjuntos', - agentSourcesMcp: 'Recursos MCP', - agentSourcesUrl: 'URL del servidor', - agentSourcesToken: 'Token de acceso (solo esta sesión)', - agentSourcesConnect: 'Conectar', - agentSourcesDisconnect: 'Desconectar', - agentSourcesDrive: 'Google Drive', - agentSourcesDriveHint: 'La autorización opcional de Google permite leer Drive. Adjunta solo lo necesario.', - agentSourcesUnavailable: 'Este sitio no tiene conector en la nube. Los archivos locales y MCP siguen disponibles.', - agentSourcesReconnect: 'La autorización caducó o fue denegada. Vuelve a conectar.', agentSourcesLoading: 'Leyendo fuentes…', - agentSourcesFailed: 'No se pudo leer la fuente. Revisa conexión, permisos y CORS.', + agentSourcesFailed: 'No se pudo leer el archivo. Prueba otro archivo local o expórtalo como texto sin formato.', agentSourcesTooLarge: 'La fuente es demasiado grande. Elige un archivo o extracto menor.', agentSourcesCorrupt: 'No se puede leer el archivo. Puede estar dañado. Exporta otra copia.', agentSourcesEncrypted: 'Archivo protegido por contraseña. Adjunta una copia desbloqueada.', @@ -515,14 +506,12 @@ export const es: Partial = { agentSourcesTimeout: 'La lectura tardó demasiado. Usa un archivo menor o expórtalo como texto.', agentSourcesUnsupported: 'Este formato aún no se admite. Expórtalo como texto.', agentSourcesRemove: 'Quitar', - agentSourcesAttached: 'Explorar archivos', agentSourcesAttach: 'Adjuntar archivo', agentSourcesClose: 'Volver al chat', agentSourcesPreviewTruncated: 'Vista previa parcial · hasta 8.000 caracteres', agentSourcesPartial: 'Extracto parcial enviado para esta solicitud', agentSourcesSearch: 'Buscar fuentes', agentSourcesOpen: 'Abrir fuente', - agentSourcesMore: 'Cargar más', agentSourcesExcerpt: 'Las fuentes se envían como extractos limitados al servicio de IA seleccionado.', agentArtifactTitle: 'Artefactos', agentArtifactEmpty: 'Guarda resultados aquí para editarlos y conservar versiones en este dispositivo.', diff --git a/packages/shared/src/i18n/messages/ja.ts b/packages/shared/src/i18n/messages/ja.ts index 98044130e..c7fa93d99 100644 --- a/packages/shared/src/i18n/messages/ja.ts +++ b/packages/shared/src/i18n/messages/ja.ts @@ -489,17 +489,9 @@ export const ja: Partial = { agentSourcesSources: 'ソース', agentSourcesLocal: 'ローカルファイル', agentSourcesLocalHint: 'Office/PDF 最大20 MiB・抽出テキスト最大256 KiB・5件', - agentSourcesMcp: 'MCPリソース', - agentSourcesUrl: 'サーバーURL', - agentSourcesToken: 'アクセストークン(このセッションのみ)', - agentSourcesConnect: '接続', - agentSourcesDisconnect: '接続解除', - agentSourcesDrive: 'Google Drive', - agentSourcesDriveHint: 'Googleの認証は任意です。Driveの読み取りを許可し、必要な資料だけを添付します。', - agentSourcesUnavailable: 'この環境にはクラウド接続が未設定です。ローカルファイルとMCPは利用できます。', - agentSourcesReconnect: '認証の期限切れ、または拒否されました。再接続してください。', agentSourcesLoading: '資料を読み込み中…', - agentSourcesFailed: '資料を読み込めません。接続・権限・CORS設定を確認してください。', + agentSourcesFailed: + 'このファイルを読み取れません。別のローカルファイルを選ぶか、プレーンテキストに書き出してください。', agentSourcesTooLarge: '資料が大きすぎます。小さいファイルや抜粋を選んでください。', agentSourcesCorrupt: 'ファイルを読み取れません。破損している可能性があります。再度書き出してください。', agentSourcesEncrypted: 'パスワードで保護されています。解除したコピーを追加してください。', @@ -507,14 +499,12 @@ export const ja: Partial = { agentSourcesTimeout: '読み取りがタイムアウトしました。小さいファイルかテキストを使用してください。', agentSourcesUnsupported: 'この形式の資料は未対応です。テキストとして書き出してください。', agentSourcesRemove: '削除', - agentSourcesAttached: 'ファイルを参照', agentSourcesAttach: 'ファイルを添付', agentSourcesClose: '会話に戻る', agentSourcesPreviewTruncated: '一部のソースをプレビュー・最大8,000文字', agentSourcesPartial: 'このリクエストに送信した一部の抜粋', agentSourcesSearch: 'ソースを検索', agentSourcesOpen: 'ソースを開く', - agentSourcesMore: 'さらに読み込む', agentSourcesExcerpt: '送信時に資料の抜粋が選択したAIサービスへ送られます。', agentArtifactTitle: '成果物', agentArtifactEmpty: '結果を保存して、この端末で編集・バージョン管理できます。', diff --git a/packages/shared/src/i18n/messages/ko.ts b/packages/shared/src/i18n/messages/ko.ts index b01cf849b..7bb10ff50 100644 --- a/packages/shared/src/i18n/messages/ko.ts +++ b/packages/shared/src/i18n/messages/ko.ts @@ -476,17 +476,8 @@ export const ko: Partial = { agentSourcesSources: '자료', agentSourcesLocal: '로컬 파일', agentSourcesLocalHint: 'Office/PDF 최대20 MiB · 추출 텍스트 최대256 KiB · 첨부5개', - agentSourcesMcp: 'MCP 리소스', - agentSourcesUrl: '서버 URL', - agentSourcesToken: '액세스 토큰(현재 세션만)', - agentSourcesConnect: '연결', - agentSourcesDisconnect: '연결 해제', - agentSourcesDrive: 'Google Drive', - agentSourcesDriveHint: 'Google 인증은 선택 사항입니다. Drive 읽기 권한을 부여하고 필요한 자료만 첨부하세요.', - agentSourcesUnavailable: '이 배포에는 클라우드 연결이 구성되지 않았습니다. 로컬 파일과 MCP는 사용할 수 있습니다.', - agentSourcesReconnect: '인증이 만료되었거나 거부되었습니다. 다시 연결하세요.', agentSourcesLoading: '자료 읽는 중…', - agentSourcesFailed: '자료를 읽을 수 없습니다. 연결, 권한, CORS 설정을 확인하세요.', + agentSourcesFailed: '파일을 읽을 수 없습니다. 다른 로컬 파일을 선택하거나 일반 텍스트로 내보내세요.', agentSourcesTooLarge: '자료가 너무 큽니다. 더 작은 파일이나 발췌문을 선택하세요.', agentSourcesCorrupt: '파일을 읽을 수 없습니다. 손상되었을 수 있으니 다시 내보내세요.', agentSourcesEncrypted: '암호로 보호된 파일입니다. 잠금을 해제한 사본을 첨부하세요.', @@ -494,14 +485,12 @@ export const ko: Partial = { agentSourcesTimeout: '읽기 시간이 초과되었습니다. 작은 파일이나 텍스트 파일을 사용하세요.', agentSourcesUnsupported: '이 자료 형식은 아직 지원되지 않습니다. 텍스트로 내보내세요.', agentSourcesRemove: '제거', - agentSourcesAttached: '파일 찾아보기', agentSourcesAttach: '파일 첨부', agentSourcesClose: '대화로 돌아가기', agentSourcesPreviewTruncated: '일부 소스 미리보기 · 최대 8,000자', agentSourcesPartial: '이 요청에 전송된 일부 소스 발췌', agentSourcesSearch: '소스 검색', agentSourcesOpen: '소스 열기', - agentSourcesMore: '더 불러오기', agentSourcesExcerpt: '요청 전송 시 자료의 제한된 발췌문을 선택한 AI 서비스에 보냅니다.', agentArtifactTitle: '아티팩트', agentArtifactEmpty: '결과를 저장하고 이 기기에서 편집하며 버전을 보관하세요.', diff --git a/packages/shared/src/i18n/messages/pt.ts b/packages/shared/src/i18n/messages/pt.ts index e688d9a01..166e59206 100644 --- a/packages/shared/src/i18n/messages/pt.ts +++ b/packages/shared/src/i18n/messages/pt.ts @@ -492,17 +492,8 @@ export const pt: Partial = { agentSourcesSources: 'Fontes', agentSourcesLocal: 'Arquivos locais', agentSourcesLocalHint: 'Office/PDF até 20 MiB · texto extraído até 256 KiB · 5 anexos', - agentSourcesMcp: 'Recursos MCP', - agentSourcesUrl: 'URL do servidor', - agentSourcesToken: 'Token de acesso (apenas esta sessão)', - agentSourcesConnect: 'Conectar', - agentSourcesDisconnect: 'Desconectar', - agentSourcesDrive: 'Google Drive', - agentSourcesDriveHint: 'A autorização opcional do Google permite ler o Drive. Anexe apenas o necessário.', - agentSourcesUnavailable: 'Esta instalação não tem conector na nuvem. Arquivos locais e MCP continuam disponíveis.', - agentSourcesReconnect: 'A autorização expirou ou foi negada. Conecte novamente.', agentSourcesLoading: 'Lendo fontes…', - agentSourcesFailed: 'Não foi possível ler a fonte. Verifique conexão, permissões e CORS.', + agentSourcesFailed: 'Não foi possível ler o arquivo. Tente outro arquivo local ou exporte como texto simples.', agentSourcesTooLarge: 'A fonte é grande demais. Escolha um arquivo ou trecho menor.', agentSourcesCorrupt: 'Não foi possível ler o arquivo. Pode estar danificado. Exporte outra cópia.', agentSourcesEncrypted: 'Arquivo protegido por senha. Anexe uma cópia desbloqueada.', @@ -510,14 +501,12 @@ export const pt: Partial = { agentSourcesTimeout: 'A leitura demorou demais. Use um arquivo menor ou exporte como texto.', agentSourcesUnsupported: 'Este formato ainda não é aceito. Exporte como texto.', agentSourcesRemove: 'Remover', - agentSourcesAttached: 'Explorar arquivos', agentSourcesAttach: 'Anexar arquivo', agentSourcesClose: 'Voltar à conversa', agentSourcesPreviewTruncated: 'Prévia parcial · até 8.000 caracteres', agentSourcesPartial: 'Trecho parcial enviado para esta solicitação', agentSourcesSearch: 'Pesquisar fontes', agentSourcesOpen: 'Abrir fonte', - agentSourcesMore: 'Carregar mais', agentSourcesExcerpt: 'As fontes são enviadas como trechos limitados ao serviço de IA escolhido.', agentArtifactTitle: 'Artefatos', agentArtifactEmpty: 'Salve resultados aqui para editar e manter versões neste dispositivo.', diff --git a/packages/shared/src/i18n/messages/zh-CN.ts b/packages/shared/src/i18n/messages/zh-CN.ts index acb380c83..01ae562db 100644 --- a/packages/shared/src/i18n/messages/zh-CN.ts +++ b/packages/shared/src/i18n/messages/zh-CN.ts @@ -459,17 +459,8 @@ export const zhCN: I18nMessages = { agentSourcesSources: '资料来源', agentSourcesLocal: '本地文件', agentSourcesLocalHint: 'Office/PDF 最大 20 MiB · 提取文本最大 256 KiB · 最多 5 份', - agentSourcesMcp: 'MCP 资料服务', - agentSourcesUrl: '服务地址', - agentSourcesToken: '访问令牌(仅本次会话)', - agentSourcesConnect: '连接', - agentSourcesDisconnect: '断开连接', - agentSourcesDrive: 'Google Drive', - agentSourcesDriveHint: '按需授权 Google Drive 只读访问,仅引用本次请求需要的资料。', - agentSourcesUnavailable: '当前部署尚未配置云端连接器,仍可使用本地文件和 MCP。', - agentSourcesReconnect: '授权已过期或未获准,请重新连接。', agentSourcesLoading: '正在读取资料…', - agentSourcesFailed: '无法读取资料,请检查连接、权限及浏览器跨域访问配置后重试。', + agentSourcesFailed: '无法读取此文件,请尝试其他本地文件,或先导出为纯文本。', agentSourcesTooLarge: '资料过大,请选择更小的文件或摘录。', agentSourcesCorrupt: '无法读取此文件,文件可能已损坏。请重新导出后再试。', agentSourcesEncrypted: '文件受密码保护,请附加已解锁的副本。', @@ -477,14 +468,12 @@ export const zhCN: I18nMessages = { agentSourcesTimeout: '读取超时,请使用较小的文件或导出为纯文本。', agentSourcesUnsupported: '暂不支持将此格式作为参考资料,请先导出为纯文本。', agentSourcesRemove: '移除', - agentSourcesAttached: '浏览文件', agentSourcesAttach: '添加文件', agentSourcesClose: '返回对话', agentSourcesPreviewTruncated: '部分来源预览 · 最多 8,000 个字符', agentSourcesPartial: '本次请求发送的部分来源摘录', agentSourcesSearch: '搜索来源', agentSourcesOpen: '打开来源', - agentSourcesMore: '加载更多', agentSourcesExcerpt: '发送请求时,资料会以有长度限制的摘录发送给所选 AI 服务。', agentArtifactTitle: '成果', agentArtifactEmpty: '将有用的结果保存在这里,在此设备编辑并保留版本。', diff --git a/packages/shared/src/i18n/types.ts b/packages/shared/src/i18n/types.ts index dfaae7229..5de8d7f44 100644 --- a/packages/shared/src/i18n/types.ts +++ b/packages/shared/src/i18n/types.ts @@ -474,15 +474,6 @@ export interface I18nMessages { agentSourcesSources: string; agentSourcesLocal: string; agentSourcesLocalHint: string; - agentSourcesMcp: string; - agentSourcesUrl: string; - agentSourcesToken: string; - agentSourcesConnect: string; - agentSourcesDisconnect: string; - agentSourcesDrive: string; - agentSourcesDriveHint: string; - agentSourcesUnavailable: string; - agentSourcesReconnect: string; agentSourcesLoading: string; agentSourcesFailed: string; agentSourcesTooLarge: string; @@ -492,14 +483,12 @@ export interface I18nMessages { agentSourcesTimeout: string; agentSourcesUnsupported: string; agentSourcesRemove: string; - agentSourcesAttached: string; agentSourcesAttach: string; agentSourcesClose: string; agentSourcesPreviewTruncated: string; agentSourcesPartial: string; agentSourcesSearch: string; agentSourcesOpen: string; - agentSourcesMore: string; agentSourcesExcerpt: string; agentArtifactTitle: string; agentArtifactEmpty: string; diff --git a/public/_routes.json b/public/_routes.json deleted file mode 100644 index ed3d8ab67..000000000 --- a/public/_routes.json +++ /dev/null @@ -1 +0,0 @@ -{ "version": 1, "include": ["/api/connectors/google/*"], "exclude": [] } diff --git a/public/sw.js b/public/sw.js index 30c39c1d8..a05b77205 100644 --- a/public/sw.js +++ b/public/sw.js @@ -545,8 +545,8 @@ self.addEventListener('fetch', (event) => { // 2. Only handle same-origin requests to avoid caching external APIs/documents if (url.origin !== self.location.origin) return; - // Optional authenticated connector APIs and OAuth callbacks are never offline assets. - if (url.pathname.startsWith('/api/connectors/')) return; + // API responses are never offline assets. + if (url.pathname.startsWith('/api/')) return; // Match Pages' canonical route offline too. Otherwise the cached root shell // would resolve its relative assets under /editor/ and render a blank page. diff --git a/styles/assistant.css b/styles/assistant.css index 4440bdd62..3500345dd 100644 --- a/styles/assistant.css +++ b/styles/assistant.css @@ -2399,25 +2399,6 @@ r-dropdown[role='listbox'] { .agent-sources-view [role='status']:empty { display: none; } -.agent-source-settings { - margin-block: var(--ran-space-4); -} -.agent-source-settings summary { - cursor: pointer; - padding-block: var(--ran-space-1); -} -.agent-source-settings summary h3 { - display: inline; - margin: 0; -} -.agent-source-options { - display: flex; - flex-direction: column; - align-items: stretch; -} -.agent-source-options button { - overflow-wrap: anywhere; -} .agent-model-choice[hidden], .agent-offline-hint[hidden] { display: none; diff --git a/test/e2e/agent-sources-artifacts.spec.ts b/test/e2e/agent-sources-artifacts.spec.ts index 73fc1f320..05d380470 100644 --- a/test/e2e/agent-sources-artifacts.spec.ts +++ b/test/e2e/agent-sources-artifacts.spec.ts @@ -49,6 +49,11 @@ test('local sources and versioned artifacts remain explicit, compact and reviewe await page.frameLocator('iframe[name="frameEditor"]').locator('.agent-sidebar-entry').click(); await expect(page.locator('.cui-save')).toHaveCount(1); await page.locator('.agent-sources-toggle').click(); + await expect( + page.locator('.agent-sources-view input[type=url], .agent-sources-view input[type=password]'), + ).toHaveCount(0); + await expect(page.locator('.agent-sources-view')).not.toContainText('Google Drive'); + await expect(page.locator('.agent-sources-view')).not.toContainText('MCP resources'); await page .locator('.agent-sources-view input[type=file]') .setInputFiles({ name: 'notes.md', mimeType: 'text/markdown', buffer: Buffer.from('Project reference') }); @@ -244,7 +249,7 @@ test('cloud model settings exclude browser runtime configuration and cache hints await page.locator('.agent-runtime-panel').screenshot({ path: testInfo.outputPath('cloud-settings.png') }); }); -test('offline workspace hides cloud discovery and download controls while retaining local work without automatic reconnect', async ({ +test('offline workspace keeps local sources and hides download controls without automatic reconnect', async ({ page, context, l0, @@ -318,9 +323,10 @@ test('offline workspace hides cloud discovery and download controls while retain }); await context.setOffline(true); await page.locator('.agent-sources-toggle').click(); - await expect(page.locator('.agent-cloud-sources')).toBeHidden(); + await expect( + page.locator('.agent-sources-view input[type=url], .agent-sources-view input[type=password]'), + ).toHaveCount(0); await expect(page.locator('.agent-sources-view .agent-offline-hint')).toBeVisible(); - await expect(page.locator('.agent-source-settings')).toBeVisible(); await page.locator('.agent-sources-view input[type=file]').setInputFiles({ name: 'offline.txt', mimeType: 'text/plain', @@ -350,7 +356,9 @@ test('offline workspace hides cloud discovery and download controls while retain offline = false; await page.getByRole('button', { name: 'Artifacts', exact: true }).click(); await page.locator('.agent-sources-toggle').click(); - await expect(page.locator('.agent-cloud-sources')).toBeVisible(); + await expect( + page.locator('.agent-sources-view input[type=url], .agent-sources-view input[type=password]'), + ).toHaveCount(0); await expect(page.locator('.agent-source-row')).toContainText('offline.txt'); expect(connectorRequests).toEqual([]); await page.getByRole('button', { name: 'Back to conversation', exact: true }).click(); @@ -369,19 +377,12 @@ test('offline workspace hides cloud discovery and download controls while retain } }); -test('expired Drive authorization and attachment limits expose usable recovery actions', async ({ page }) => { +test('local attachment limits expose usable recovery actions', async ({ page }) => { test.setTimeout(90000); await page.addInitScript(() => { localStorage.setItem('document-assistant-enabled', 'true'); localStorage.setItem('agent-local-preset', 'Qwen3-4B-q4f16_1-MLC'); }); - let reads = 0; - await mockApi(page, '**/api/connectors/google/**', async (route) => { - if (route.request().url().includes('/read')) { - reads++; - await route.fulfill({ status: 401, json: { error: 'reconnect_required' } }); - } else await route.fulfill({ json: { files: [{ id: 'policy', name: 'Policy' }], nextPageToken: 'next' } }); - }); await page.goto('/editor?new=docx&locale=en'); await page.waitForFunction(() => Boolean(window.__ooFrames.readyEditor())); await page.frameLocator('iframe[name="frameEditor"]').locator('.agent-sidebar-entry').click(); @@ -403,17 +404,6 @@ test('expired Drive authorization and attachment limits expose usable recovery a await input.setInputFiles({ name: 'sixth.txt', mimeType: 'text/plain', buffer: Buffer.from('Sixth reference') }); await expect(page.locator('.agent-source-row')).toHaveCount(5); await expect(page.locator('.agent-sources-attached')).toContainText('sixth.txt'); - const cloud = page.locator('.agent-cloud-sources'); - const connect = cloud.getByRole('button', { name: 'Connect', exact: true }); - await cloud.getByRole('button', { name: 'Browse files', exact: true }).click(); - await expect(connect).toBeHidden(); - await cloud.getByRole('button', { name: 'Policy', exact: true }).click(); - await expect(page.locator('.agent-sources-status')).toContainText('Connect again'); - await expect(connect).toBeVisible(); - await expect(cloud.getByRole('button', { name: 'Policy', exact: true })).toHaveCount(0); - await expect(cloud.getByRole('button', { name: 'Load more', exact: true })).toBeHidden(); - await expect(page.locator('.agent-source-row')).toHaveCount(5); - expect(reads).toBe(1); }); test('discard restores artifact titles and a full version history can continue as a new artifact', async ({ @@ -490,36 +480,38 @@ test('switching conversations releases a pending source read without attaching i localStorage.setItem('document-assistant-enabled', 'true'); localStorage.setItem('agent-local-preset', 'Qwen3-4B-q4f16_1-MLC'); }); - let release!: () => void; - let reading = false; - const waitForRelease = new Promise((resolve) => { - release = resolve; - }); - await mockApi(page, '**/api/connectors/google/**', async (route) => { - if (route.request().url().includes('/read')) { - reading = true; - await waitForRelease; - await route.fulfill({ json: { text: 'Old conversation private data' } }); - } else await route.fulfill({ json: { files: [{ id: 'old', name: 'Old policy' }] } }); - }); await page.goto('/editor?new=docx&locale=en'); await page.waitForFunction(() => Boolean(window.__ooFrames.readyEditor())); await page.frameLocator('iframe[name="frameEditor"]').locator('.agent-sidebar-entry').click(); await page.locator('.agent-sources-toggle').click(); - await page.getByRole('button', { name: 'Browse files', exact: true }).click(); - await page.getByRole('button', { name: 'Old policy', exact: true }).click(); - await expect.poll(() => reading).toBe(true); + await page.evaluate(() => { + const original = File.prototype.text; + const host = window as Window & { releaseOldReference?: () => void }; + File.prototype.text = function () { + if (this.name !== 'old.txt') return original.call(this); + return new Promise((resolve) => { + host.releaseOldReference = () => { + File.prototype.text = original; + resolve('Old conversation private data'); + }; + }); + }; + }); + await page.locator('.agent-sources-view input[type=file]').setInputFiles({ + name: 'old.txt', + mimeType: 'text/plain', + buffer: Buffer.from('Old conversation private data'), + }); + await page.waitForFunction( + () => typeof (window as Window & { releaseOldReference?: () => void }).releaseOldReference === 'function', + ); await page.locator('.agent-panel-clear').click(); await page.locator('.agent-sources-toggle').click(); await page .locator('.agent-sources-view input[type=file]') .setInputFiles({ name: 'fresh.txt', mimeType: 'text/plain', buffer: Buffer.from('New conversation data') }); await expect(page.locator('.agent-source-row')).toHaveText('fresh.txtRemove'); - const completed = page.waitForResponse( - (response) => new URL(originalApiUrl(page, response.url())).pathname === '/api/connectors/google/read', - ); - release(); - await completed; + await page.evaluate(() => (window as Window & { releaseOldReference?: () => void }).releaseOldReference!()); await expect(page.locator('.agent-source-row')).toHaveText('fresh.txtRemove'); await page.getByRole('button', { name: 'Back to conversation', exact: true }).click(); await expect(page.locator('.agent-sources-summary')).toHaveText('fresh.txt'); diff --git a/test/unit/agent-document-reference-readers.test.ts b/test/unit/agent-document-reference-readers.test.ts index e16f1114c..c71ccc7f5 100644 --- a/test/unit/agent-document-reference-readers.test.ts +++ b/test/unit/agent-document-reference-readers.test.ts @@ -1,6 +1,6 @@ import { zip } from '../fixtures/helpers/reference-files'; import { expect, it, vi } from 'vitest'; -import { readOfficeReference } from '../../lib/agent-plugin/connectors/document-reference-readers'; +import { readOfficeReference } from '../../lib/agent-plugin/references/document-reference-readers'; it('extracts paragraphs and table paragraphs in DOCX document order', async () => { const xml = diff --git a/test/unit/agent-google-connector.test.ts b/test/unit/agent-google-connector.test.ts deleted file mode 100644 index ac35800d5..000000000 --- a/test/unit/agent-google-connector.test.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { webcrypto } from 'node:crypto'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { handleGoogleConnector, type GoogleConnectorEnv } from '../../functions/api/connectors/google/[[path]]'; -const origin = 'https://editor.example'; -const env: GoogleConnectorEnv = { - GOOGLE_CLIENT_ID: 'client', - GOOGLE_CLIENT_SECRET: 'secret-client', - GOOGLE_CONNECTOR_SECRET: 'test-secret-at-least-thirty-two-characters', - GOOGLE_CONNECTOR_ORIGIN: origin, -}; -const request = (path: string, options: RequestInit = {}) => - new Request(`${origin}/api/connectors/google/${path}`, options); -const post = (path: string) => request(path, { method: 'POST', headers: { origin } }); -async function start() { - const response = await handleGoogleConnector(post('start'), env); - const { authorizationUrl } = await response.json(); - return { url: new URL(authorizationUrl), cookie: response.headers.get('set-cookie')!.split(';')[0] }; -} -async function connect() { - const flow = await start(); - const response = await handleGoogleConnector( - request(`callback?code=one-time&state=${flow.url.searchParams.get('state')}`, { headers: { cookie: flow.cookie } }), - env, - ); - const cookies = response.headers.getSetCookie(); - return { response, cookie: cookies.find((item) => item.startsWith('__Host-document-drive='))!.split(';')[0] }; -} -beforeEach(() => { - vi.stubGlobal('crypto', webcrypto); -}); -afterEach(() => { - vi.unstubAllGlobals(); - vi.useRealTimers(); -}); -describe('optional Google Drive server connector', () => { - it('reports missing/invalid deployment without requiring a site account', async () => { - expect((await handleGoogleConnector(request('status'), {})).status).toBe(503); - expect( - (await handleGoogleConnector(request('status'), { ...env, GOOGLE_CONNECTOR_ORIGIN: 'http://editor.example' })) - .status, - ).toBe(503); - expect(await (await handleGoogleConnector(request('status'), env)).json()).toEqual({ - configured: true, - connected: false, - }); - }); - it('requires explicit same-origin POST and uses PKCE + encrypted HttpOnly flow', async () => { - expect((await handleGoogleConnector(request('start'), env)).status).toBe(405); - expect((await handleGoogleConnector(request('start', { method: 'POST' }), env)).status).toBe(403); - expect( - ( - await handleGoogleConnector( - new Request('https://evil.example/api/connectors/google/start', { method: 'POST', headers: { origin } }), - env, - ) - ).status, - ).toBe(403); - const flow = await start(); - expect(flow.url.origin).toBe('https://accounts.google.com'); - expect(flow.url.searchParams.get('code_challenge_method')).toBe('S256'); - expect(flow.cookie).not.toContain(flow.url.searchParams.get('state')); - expect(flow.url.searchParams.has('client_secret')).toBe(false); - const response = await handleGoogleConnector(post('start'), env); - expect(response.headers.get('set-cookie')).toContain('Secure; HttpOnly; SameSite=Lax'); - }); - it('rejects tampered, mismatched, missing and expired callback state without exchange', async () => { - const fetcher = vi.fn(); - vi.stubGlobal('fetch', fetcher); - const flow = await start(); - for (const [state, cookie] of [ - ['wrong', flow.cookie], - [flow.url.searchParams.get('state'), flow.cookie + 'x'], - [flow.url.searchParams.get('state'), ''], - ]) { - const response = await handleGoogleConnector( - request(`callback?code=code&state=${state}`, { headers: { cookie: cookie! } }), - env, - ); - expect(await response.text()).toContain('Connection failed'); - expect(response.headers.get('set-cookie')).toContain('Max-Age=0'); - } - vi.useFakeTimers(); - vi.setSystemTime(Date.now() + 11 * 60000); - expect( - await ( - await handleGoogleConnector( - request(`callback?code=code&state=${flow.url.searchParams.get('state')}`, { - headers: { cookie: flow.cookie }, - }), - env, - ) - ).text(), - ).toContain('Connection failed'); - expect(fetcher).not.toHaveBeenCalled(); - }); - it('keeps access tokens server-only and rejects replay without flow cookie', async () => { - const fetcher = vi - .fn() - .mockResolvedValue(new Response(JSON.stringify({ access_token: 'private-token', expires_in: 3600 }))); - vi.stubGlobal('fetch', fetcher); - const { response, cookie } = await connect(); - const html = await response.text(); - expect(html).toContain('BroadcastChannel'); - expect(html).not.toContain('private-token'); - expect(response.headers.get('content-security-policy')).toContain("script-src 'nonce-"); - expect(cookie).not.toContain('private-token'); - const status = await (await handleGoogleConnector(request('status', { headers: { cookie } }), env)).json(); - expect(status.connected).toBe(true); - expect(JSON.stringify(status)).not.toContain('private-token'); - expect(await (await handleGoogleConnector(request('callback?code=one-time&state=old'), env)).text()).toContain( - 'Connection failed', - ); - expect(fetcher).toHaveBeenCalledTimes(1); - expect(String(fetcher.mock.calls[0][1].body)).toContain('code_verifier='); - }); - it('lists supported resources, exports documents, bounds bytes, and never fetches supplied URLs', async () => { - const fetcher = vi - .fn() - .mockResolvedValueOnce(new Response(JSON.stringify({ access_token: 'private-token', expires_in: 3600 }))); - vi.stubGlobal('fetch', fetcher); - const { cookie } = await connect(); - fetcher.mockResolvedValueOnce( - new Response(JSON.stringify({ files: [{ id: 'abc', name: 'Plan', mimeType: 'text/plain' }] })), - ); - expect( - (await (await handleGoogleConnector(request('list', { headers: { cookie } }), env)).json()).files[0].id, - ).toBe('abc'); - expect(fetcher.mock.calls[1][0]).toContain('https://www.googleapis.com/drive/v3/files?'); - fetcher - .mockResolvedValueOnce( - new Response(JSON.stringify({ mimeType: 'application/vnd.google-apps.document', name: 'Plan' })), - ) - .mockResolvedValueOnce(new Response('Document text')); - expect( - (await (await handleGoogleConnector(request('read?id=abc', { headers: { cookie } }), env)).json()).text, - ).toBe('Document text'); - expect(fetcher.mock.calls[3][0]).toContain('/abc/export?mimeType=text%2Fplain'); - const count = fetcher.mock.calls.length; - expect( - (await handleGoogleConnector(request('read?id=https://evil.example', { headers: { cookie } }), env)).status, - ).toBe(400); - expect(fetcher).toHaveBeenCalledTimes(count); - fetcher - .mockResolvedValueOnce(new Response(JSON.stringify({ mimeType: 'text/plain' }))) - .mockResolvedValueOnce(new Response('x'.repeat(131073))); - expect((await handleGoogleConnector(request('read?id=abc', { headers: { cookie } }), env)).status).toBe(413); - fetcher.mockResolvedValueOnce(new Response(JSON.stringify({ mimeType: 'application/pdf' }))); - expect((await handleGoogleConnector(request('read?id=abc', { headers: { cookie } }), env)).status).toBe(415); - }); - it('escapes bounded Drive filename search as one literal and rejects oversized queries', async () => { - const fetcher = vi - .fn() - .mockResolvedValueOnce(new Response(JSON.stringify({ access_token: 'token', expires_in: 3600 }))); - vi.stubGlobal('fetch', fetcher); - const { cookie } = await connect(); - fetcher.mockResolvedValueOnce(new Response(JSON.stringify({ files: [] }))); - const query = "a' or trashed = true or name contains 'b\\c"; - const response = await handleGoogleConnector( - request(`list?q=${encodeURIComponent(query)}`, { headers: { cookie } }), - env, - ); - expect(response.status).toBe(200); - const sent = new URL(fetcher.mock.calls[1][0]).searchParams.get('q')!; - expect(sent).toContain("name contains 'a\\' or trashed = true or name contains \\'b\\\\c'"); - expect( - (await handleGoogleConnector(request(`list?q=${'x'.repeat(201)}`, { headers: { cookie } }), env)).status, - ).toBe(400); - }); - it('clears sessions despite revoke failure, hides upstream bodies, and expires sessions', async () => { - const fetcher = vi - .fn() - .mockResolvedValueOnce(new Response(JSON.stringify({ access_token: 'private-token', expires_in: 1 }))); - vi.stubGlobal('fetch', fetcher); - const { cookie } = await connect(); - fetcher.mockResolvedValueOnce(new Response('private failure', { status: 503 })); - expect(await (await handleGoogleConnector(request('list', { headers: { cookie } }), env)).json()).toEqual({ - error: 'service_unavailable', - }); - fetcher.mockRejectedValueOnce(new Error('private')); - const response = await handleGoogleConnector(new Request(post('disconnect'), { headers: { origin, cookie } }), env); - expect(response.status).toBe(200); - expect(response.headers.get('set-cookie')).toContain('Max-Age=0'); - vi.useFakeTimers(); - vi.setSystemTime(Date.now() + 2000); - expect((await handleGoogleConnector(request('list', { headers: { cookie } }), env)).status).toBe(401); - }); -}); diff --git a/test/unit/agent-pdf-reference-reader.test.ts b/test/unit/agent-pdf-reference-reader.test.ts index 76bc02b9f..9845c62a0 100644 --- a/test/unit/agent-pdf-reference-reader.test.ts +++ b/test/unit/agent-pdf-reference-reader.test.ts @@ -36,7 +36,7 @@ vi.mock('pdfjs-dist', () => ({ }; }, })); -import { readPdfReference } from '../../lib/agent-plugin/connectors/pdf-reference-reader'; +import { readPdfReference } from '../../lib/agent-plugin/references/pdf-reference-reader'; beforeEach(() => { state.pending = undefined; state.pages = 1; diff --git a/test/unit/agent-reference-lifetime.test.ts b/test/unit/agent-reference-lifetime.test.ts index fbe127063..e61377c2e 100644 --- a/test/unit/agent-reference-lifetime.test.ts +++ b/test/unit/agent-reference-lifetime.test.ts @@ -1,5 +1,5 @@ import { expect, it } from 'vitest'; -import { withReferenceAbort } from '../../lib/agent-plugin/connectors/reference-lifetime'; +import { withReferenceAbort } from '../../lib/agent-plugin/references/reference-lifetime'; it('releases a stalled lazy dependency at the deadline and ignores late completion', async () => { const deadline = new AbortController(); let complete!: (value: string) => void; diff --git a/test/unit/agent-reference-sources.test.ts b/test/unit/agent-reference-sources.test.ts index 9020b541f..2effc0dcf 100644 --- a/test/unit/agent-reference-sources.test.ts +++ b/test/unit/agent-reference-sources.test.ts @@ -1,16 +1,12 @@ import { expect, it, vi } from 'vitest'; -import { - ReferenceSources, - readLocalReference, - McpResources, -} from '../../lib/agent-plugin/connectors/reference-sources'; +import { ReferenceSources, readLocalReference } from '../../lib/agent-plugin/references/reference-sources'; it('accepts bounded text files and rejects binary or oversized sources', async () => { expect((await readLocalReference(new File(['hello'], 'notes.md'))).text).toBe('hello'); await expect(readLocalReference(new File(['binary'], 'notes.docx'))).rejects.toThrow('corrupt'); await expect(readLocalReference(new File(['x'.repeat(262145)], 'large.txt'))).rejects.toThrow('too-large'); }); -it('captures immutable references and invalidates a snapshot on disconnect', () => { +it('captures immutable references and invalidates a snapshot on removal', () => { const sources = new ReferenceSources(); sources.add({ id: 'one', connector: 'local', title: 'Notes', text: 'original' }); const snapshot = sources.capture(); @@ -20,124 +16,17 @@ it('captures immutable references and invalidates a snapshot on disconnect', () sources.remove('one'); expect(snapshot.isCurrent()).toBe(false); }); -it('never accepts unsafe endpoint URLs or arbitrary tools', async () => { - expect(() => new McpResources('http://example.com/mcp')).toThrow('invalid-url'); - expect(() => new McpResources('https://user:secret@example.com/mcp')).toThrow('invalid-url'); - const fetcher = vi.fn(async (_url: unknown, init?: RequestInit) => { - const request = JSON.parse(String(init?.body)); - const result = - request.method === 'initialize' - ? { protocolVersion: '2025-11-25', capabilities: { resources: {} }, serverInfo: { name: 'test', version: '1' } } - : request.method === 'resources/list' - ? { resources: [{ uri: 'notes://one', name: 'Notes' }] } - : { contents: [{ uri: 'notes://one', text: 'reference text' }] }; - return new Response(JSON.stringify({ jsonrpc: '2.0', id: request.id, result }), { - headers: { 'content-type': 'application/json' }, - }); - }); - const client = new McpResources('http://localhost:1234/mcp', '', fetcher as typeof fetch); - expect(await client.list()).toEqual([{ uri: 'notes://one', name: 'Notes' }]); - expect((await client.read('notes://one')).text).toBe('reference text'); - await expect(client.read('notes://other')).rejects.toThrow('unlisted'); - expect(fetcher.mock.calls.every(([, init]) => !String(init?.body).includes('tools/call'))).toBe(true); -}); -it('keeps attachments scoped to conversations and disconnects across all scopes', () => { +it('keeps attachments scoped to conversations and forgets their original scope', () => { const sources = new ReferenceSources(); sources.select('one'); - sources.add({ id: 'x', connector: 'mcp', title: 'Source', text: 'one' }); + sources.add({ id: 'x', connector: 'local', title: 'Source', text: 'one' }); const queued = sources.capture(); sources.select('two'); expect(sources.list()).toEqual([]); expect(queued.isCurrent()).toBe(true); - sources.disconnect('mcp'); + sources.forget('one'); expect(queued.isCurrent()).toBe(false); }); -it('consumes matching SSE responses across chunk boundaries without waiting for closure', async () => { - const cancel = vi.fn(); - const fetcher = vi.fn(async (_url: unknown, init?: RequestInit) => { - const request = JSON.parse(String(init?.body)); - if (!request.id) return new Response(null, { status: 202 }); - const result = - request.method === 'initialize' - ? { protocolVersion: '2025-11-25' } - : { resources: [{ uri: 'notes://one', name: '笔记' }] }; - const bytes = new TextEncoder().encode(`: ping\r\ndata: ${JSON.stringify({ id: request.id, result })}\r\n\r\n`); - return new Response( - new ReadableStream({ - start(controller) { - // Split CRLF boundaries and multi-byte UTF-8 characters into individual bytes. - for (const byte of bytes) controller.enqueue(new Uint8Array([byte])); - }, - cancel, - }), - { headers: { 'content-type': 'text/event-stream' } }, - ); - }); - const client = new McpResources('https://example.com/mcp', '', fetcher as typeof fetch); - const result = await Promise.race([ - client.list(), - new Promise((_, reject) => setTimeout(() => reject(new Error('stream did not resolve')), 100)), - ]); - expect(result).toEqual([{ uri: 'notes://one', name: '笔记' }]); - expect(cancel).toHaveBeenCalledTimes(2); -}); -it('cancels pending SSE readers when disconnected', async () => { - const cancel = vi.fn(); - const client = new McpResources( - 'https://example.com/mcp', - '', - (async () => - new Response(new ReadableStream({ cancel }), { - headers: { 'content-type': 'text/event-stream' }, - })) as typeof fetch, - ); - const pending = client.list(); - await Promise.resolve(); - client.disconnect(); - await expect(pending).rejects.toThrow(); - expect(cancel).toHaveBeenCalled(); -}); -it('retains cumulative byte limits for SSE responses', async () => { - const cancel = vi.fn(); - const client = new McpResources( - 'https://example.com/mcp', - '', - (async () => - new Response( - new ReadableStream({ - start(controller) { - controller.enqueue(new Uint8Array(262144 + 32769)); - }, - cancel, - }), - { headers: { 'content-type': 'text/event-stream' } }, - )) as typeof fetch, - ); - await expect(client.list()).rejects.toThrow('source-too-large'); - expect(cancel).toHaveBeenCalled(); -}); -it('cancels the SSE reader when the request deadline expires', async () => { - const deadline = new AbortController(); - const timeout = vi.spyOn(AbortSignal, 'timeout').mockReturnValue(deadline.signal); - const cancel = vi.fn(); - try { - const client = new McpResources( - 'https://example.com/mcp', - '', - (async () => - new Response(new ReadableStream({ cancel }), { - headers: { 'content-type': 'text/event-stream' }, - })) as typeof fetch, - ); - const pending = client.list(); - await Promise.resolve(); - deadline.abort(new DOMException('Deadline exceeded', 'TimeoutError')); - await expect(pending).rejects.toThrow('Deadline exceeded'); - expect(cancel).toHaveBeenCalled(); - } finally { - timeout.mockRestore(); - } -}); it('forgets deleted conversation attachments and invalidates their snapshots', () => { const sources = new ReferenceSources(); sources.select('deleted'); @@ -153,36 +42,6 @@ it('forgets deleted conversation attachments and invalidates their snapshots', ( expect(current.isCurrent()).toBe(false); expect(sources.list()).toEqual([]); }); -it('loads explicit bounded MCP pages, deduplicates entries and stops cursor cycles', async () => { - let page = 0; - const fetcher = vi.fn(async (_url: unknown, init?: RequestInit) => { - const request = JSON.parse(String(init?.body)); - const result = - request.method === 'initialize' - ? { protocolVersion: '2025-11-25' } - : request.method === 'resources/list' - ? ++page === 1 - ? { resources: [{ uri: 'notes://one', name: 'One' }], nextCursor: 'next' } - : { - resources: [ - { uri: 'notes://one', name: 'One' }, - { uri: 'notes://two', name: 'Two' }, - ], - nextCursor: 'next', - } - : {}; - return new Response(JSON.stringify({ id: request.id, result }), { - headers: { 'content-type': 'application/json' }, - }); - }); - const client = new McpResources('https://example.com/mcp', '', fetcher as typeof fetch); - expect(await client.list()).toHaveLength(1); - expect(page).toBe(1); - expect(client.continuation).toBe('next'); - expect(await client.list('next')).toEqual([{ uri: 'notes://two', name: 'Two' }]); - expect(client.continuation).toBe(''); - await expect(client.list('next')).rejects.toThrow('invalid-response'); -}); it('releases canceled file reads and ignores their late completion', async () => { let complete!: (value: string) => void; const file = { diff --git a/test/unit/agent-source-discovery.test.ts b/test/unit/agent-source-discovery.test.ts index b9dd4587f..61e5ed2aa 100644 --- a/test/unit/agent-source-discovery.test.ts +++ b/test/unit/agent-source-discovery.test.ts @@ -78,7 +78,7 @@ it('restores exact host-owned message annotations, bounded independently from hi validSourcePresentation({ id: 'bad', metadata: { evidence: [{ ...evidence[0], text: 'x'.repeat(9000) }] } }), ).toBe(false); }); -import { sourceExcerpt } from '../../lib/agent-plugin/connectors/reference-search'; +import { sourceExcerpt } from '../../lib/agent-plugin/references/reference-search'; it('centers a bounded source preview on late literal search hits', () => { const result = sourceExcerpt( 'prefix '.repeat(3000) + 'Budget forecast' + ' suffix'.repeat(3000), diff --git a/test/unit/agent-sources-view.test.ts b/test/unit/agent-sources-view.test.ts index 750d8f100..6618bc9bf 100644 --- a/test/unit/agent-sources-view.test.ts +++ b/test/unit/agent-sources-view.test.ts @@ -1,37 +1,46 @@ import { afterEach, expect, it, vi } from 'vitest'; import { createSourcesView, type SourcesLabels } from '../../lib/agent-plugin/ui/sources-view'; -import { ReferenceSources } from '../../lib/agent-plugin/connectors/reference-sources'; +import { ReferenceSources } from '../../lib/agent-plugin/references/reference-sources'; const labels: SourcesLabels = { title: 'Sources', offline: 'Offline', excerpt: 'Bounded excerpts', local: 'Local files', localHint: 'Text only', - mcp: 'MCP', - url: 'URL', - token: 'Token', - connect: 'Connect', - disconnect: 'Disconnect', - drive: 'Drive', - driveHint: 'Read only', - unavailable: 'Unavailable', - reconnect: 'Reconnect', loading: 'Reading', failed: 'Failed', tooLarge: 'Too large', limit: 'Remove a source before adding another', unsupported: 'Unsupported', remove: 'Remove', - attached: 'Browse files', attach: 'Attach', close: 'Back', - more: 'More', }; afterEach(() => { document.body.replaceChildren(); vi.unstubAllGlobals(); }); -it('does not read or connect until the user acts; removing a source invalidates its queued snapshot', () => { +it('offers local references without remote account or server controls', () => { + const fetcher = vi.fn(); + vi.stubGlobal('fetch', fetcher); + const popup = vi.spyOn(window, 'open'); + const view = createSourcesView({ sources: new ReferenceSources(), labels, changed: vi.fn(), close: vi.fn() }); + document.body.append(view.el); + expect(view.el.querySelector('input[type=file]')).toBeTruthy(); + expect(view.el.querySelectorAll('input[type=url], input[type=password]')).toHaveLength(0); + expect( + [...view.el.querySelectorAll('button')].some((button) => + ['Connect', 'Disconnect', 'Browse files'].includes(button.textContent ?? ''), + ), + ).toBe(false); + window.dispatchEvent(new Event('offline')); + window.dispatchEvent(new Event('online')); + expect(fetcher).not.toHaveBeenCalled(); + expect(popup).not.toHaveBeenCalled(); + view.dispose(); + popup.mockRestore(); +}); +it('does not read until the user acts; removing a source invalidates its queued snapshot', () => { const fetcher = vi.fn(); vi.stubGlobal('fetch', fetcher); const sources = new ReferenceSources(); @@ -45,21 +54,6 @@ it('does not read or connect until the user acts; removing a source invalidates expect(snapshot.isCurrent()).toBe(false); view.dispose(); }); -it('explains missing cloud deployment without displaying returned HTML or private bodies', async () => { - vi.stubGlobal( - 'fetch', - vi.fn( - async () => - new Response('private server response', { status: 404, headers: { 'content-type': 'text/html' } }), - ), - ); - const view = createSourcesView({ sources: new ReferenceSources(), labels, changed: vi.fn(), close: vi.fn() }); - document.body.append(view.el); - [...view.el.querySelectorAll('button')].find((b) => b.textContent === 'Browse files')!.click(); - await vi.waitFor(() => expect(view.el.querySelector('.agent-sources-status')!.textContent).toBe('Unavailable')); - expect(view.el.textContent).not.toContain('private server'); - view.dispose(); -}); it('discards a delayed file read after switching conversation or disposing', async () => { const sources = new ReferenceSources(); sources.select('one'); @@ -85,124 +79,6 @@ it('discards a delayed file read after switching conversation or disposing', asy expect(sources.list()).toEqual([]); view.dispose(); }); -it('closes a stale authorization popup when disconnect wins over start', async () => { - const popup = { close: vi.fn(), location: { href: '' } }; - vi.spyOn(window, 'open').mockReturnValue(popup as unknown as Window); - let complete!: (response: Response) => void; - vi.stubGlobal( - 'fetch', - vi.fn((url: string) => - url.endsWith('/start') - ? new Promise((resolve) => { - complete = resolve; - }) - : Promise.resolve( - new Response(JSON.stringify({ connected: false }), { headers: { 'content-type': 'application/json' } }), - ), - ), - ); - const view = createSourcesView({ sources: new ReferenceSources(), labels, changed: vi.fn(), close: vi.fn() }); - document.body.append(view.el); - const buttons = [...view.el.querySelectorAll('button')]; - buttons - .filter((b) => b.textContent === 'Connect') - .at(-1)! - .click(); - buttons - .filter((b) => b.textContent === 'Disconnect') - .at(-1)! - .click(); - complete( - new Response(JSON.stringify({ authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth' }), { - headers: { 'content-type': 'application/json' }, - }), - ); - await vi.waitFor(() => expect(popup.close).toHaveBeenCalled()); - expect(popup.location.href).toBe(''); - view.dispose(); - vi.restoreAllMocks(); -}); -it('hides cloud discovery offline while keeping attachments and local MCP controls, without reconnecting online', () => { - const network = vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false); - const fetcher = vi.fn(); - vi.stubGlobal('fetch', fetcher); - const sources = new ReferenceSources(); - sources.add({ id: 'cached-drive', connector: 'google', title: 'Cached policy', text: 'Already attached' }); - const view = createSourcesView({ sources, labels, changed: vi.fn(), close: vi.fn() }); - document.body.append(view.el); - expect((view.el.querySelector('.agent-cloud-sources') as HTMLElement).hidden).toBe(true); - expect(view.el.querySelector('.agent-source-row')?.textContent).toContain('Cached policy'); - expect(view.el.querySelector('details')).toBeTruthy(); - expect(view.el.querySelector('[type=file]')).toBeTruthy(); - expect((view.el.querySelector('.agent-offline-hint') as HTMLElement).hidden).toBe(false); - network.mockReturnValue(true); - window.dispatchEvent(new Event('online')); - expect((view.el.querySelector('.agent-cloud-sources') as HTMLElement).hidden).toBe(false); - expect(fetcher).not.toHaveBeenCalled(); - expect(sources.list()).toHaveLength(1); - view.dispose(); - network.mockRestore(); -}); - -it('restores the Drive connect action after authorization expires without losing attached snapshots', async () => { - let expired = false; - const fetcher = vi.fn( - async () => - new Response( - JSON.stringify( - expired - ? { error: 'reconnect_required' } - : { files: [{ id: 'file', name: 'Policy' }], nextPageToken: 'next' }, - ), - { status: expired ? 401 : 200, headers: { 'content-type': 'application/json' } }, - ), - ); - vi.stubGlobal('fetch', fetcher); - const sources = new ReferenceSources(); - sources.add({ id: 'saved', connector: 'google', title: 'Attached', text: 'snapshot' }); - const view = createSourcesView({ sources, labels, changed: vi.fn(), close: vi.fn() }); - document.body.append(view.el); - const cloud = view.el.querySelector('.agent-cloud-sources')!; - const connect = [...cloud.querySelectorAll('button')].find((b) => b.textContent === 'Connect')!; - const browse = [...cloud.querySelectorAll('button')].find((b) => b.textContent === 'Browse files')!; - browse.click(); - await vi.waitFor(() => expect(connect.hidden).toBe(true)); - expired = true; - [...cloud.querySelectorAll('button')].find((b) => b.textContent === 'Policy')!.click(); - await vi.waitFor(() => expect(view.el.querySelector('.agent-sources-status')!.textContent).toBe('Reconnect')); - expect(connect.hidden).toBe(false); - expect(cloud.querySelector('.agent-source-options')!.childElementCount).toBe(0); - expect([...cloud.querySelectorAll('button')].find((b) => b.textContent === 'More')!.hidden).toBe(true); - expect(sources.list()).toHaveLength(1); - expect(fetcher).toHaveBeenCalledTimes(2); - view.dispose(); -}); -it('restores MCP connection controls after an expired token so a replacement token can be entered', async () => { - const { McpResources } = await import('../../lib/agent-plugin/connectors/reference-sources'); - const list = vi.spyOn(McpResources.prototype, 'list').mockResolvedValue([{ uri: 'resource:one', name: 'Policy' }]); - const read = vi.spyOn(McpResources.prototype, 'read').mockRejectedValueOnce(new Error('source-auth')); - read.mockResolvedValue({ id: 'retried', connector: 'mcp', title: 'Policy', text: 'Readable after reconnect' }); - const view = createSourcesView({ sources: new ReferenceSources(), labels, changed: vi.fn(), close: vi.fn() }); - document.body.append(view.el); - const details = view.el.querySelector('details')!; - (details.querySelector('[type=url]') as HTMLInputElement).value = 'https://mcp.example.test'; - const connect = [...details.querySelectorAll('button')].find((b) => b.textContent === 'Connect')!; - connect.click(); - await vi.waitFor(() => expect(connect.hidden).toBe(true)); - [...details.querySelectorAll('button')].find((b) => b.textContent === 'Policy')!.click(); - await vi.waitFor(() => expect(view.el.querySelector('.agent-sources-status')!.textContent).toBe('Reconnect')); - expect(connect.hidden).toBe(false); - expect(details.querySelector('.agent-source-options')!.childElementCount).toBe(0); - (details.querySelector('[type=password]') as HTMLInputElement).value = 'replacement-token'; - connect.click(); - await vi.waitFor(() => expect(list).toHaveBeenCalledTimes(2)); - await vi.waitFor(() => expect(connect.hidden).toBe(true)); - [...details.querySelectorAll('button')].find((b) => b.textContent === 'Policy')!.click(); - await vi.waitFor(() => expect(view.el.querySelector('.agent-source-row')?.textContent).toContain('Policy')); - expect((details.querySelector('[type=password]') as HTMLInputElement).value).toBe(''); - view.dispose(); - vi.restoreAllMocks(); -}); it('explains the five attachment limit and lets removal unblock the next attachment', async () => { const sources = new ReferenceSources(); for (let i = 0; i < 5; i++) sources.add({ id: String(i), connector: 'local', title: String(i), text: 'text' }); @@ -285,48 +161,6 @@ it('an obsolete completion cannot clear the status or busy ownership of a newer view.dispose(); }); -it('a delayed disconnect cannot erase a newer attachment status', async () => { - let finishDisconnect!: (response: Response) => void; - vi.stubGlobal( - 'fetch', - vi.fn((url: string) => - url.endsWith('/disconnect') - ? new Promise((r) => { - finishDisconnect = r; - }) - : Promise.resolve( - new Response(JSON.stringify({ files: [] }), { headers: { 'content-type': 'application/json' } }), - ), - ), - ); - const view = createSourcesView({ sources: new ReferenceSources(), labels, changed: vi.fn(), close: vi.fn() }); - document.body.append(view.el); - const cloud = view.el.querySelector('.agent-cloud-sources')!; - [...cloud.querySelectorAll('button')].find((b) => b.textContent === 'Browse files')!.click(); - const disconnect = [...cloud.querySelectorAll('button')].find((b) => b.textContent === 'Disconnect')!; - await vi.waitFor(() => expect(disconnect.hidden).toBe(false)); - disconnect.click(); - const reconnect = [...cloud.querySelectorAll('button')].find((b) => b.textContent === 'Connect')!; - expect(reconnect.disabled).toBe(true); - let finishFile!: (text: string) => void; - const file = new File(['body'], 'fresh.txt'); - Object.defineProperty(file, 'text', { - value: () => - new Promise((r) => { - finishFile = r; - }), - }); - const input = view.el.querySelector('[type=file]')!; - Object.defineProperty(input, 'files', { value: [file] }); - input.dispatchEvent(new Event('change')); - finishDisconnect(new Response('{}', { headers: { 'content-type': 'application/json' } })); - await new Promise((r) => setTimeout(r, 0)); - expect(view.el.querySelector('.agent-sources-status')!.textContent).toBe('Reading'); - expect(reconnect.disabled).toBe(false); - finishFile('fresh data'); - await vi.waitFor(() => expect(view.el.querySelector('.agent-source-row')?.textContent).toContain('fresh.txt')); - view.dispose(); -}); it('finds and highlights late attached-source hits without reading the network', async () => { const sources = new ReferenceSources(); sources.add({ @@ -382,3 +216,22 @@ it.each(['corrupt', 'encrypted', 'empty', 'timeout'] as const)( view.dispose(); }, ); + +it('retains local attachments across offline and online transitions without a network request', () => { + const network = vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false); + const fetcher = vi.fn(); + vi.stubGlobal('fetch', fetcher); + const sources = new ReferenceSources(); + sources.add({ id: 'notes', connector: 'local', title: 'Local policy', text: 'Attached text' }); + const view = createSourcesView({ sources, labels, changed: vi.fn(), close: vi.fn() }); + document.body.append(view.el); + expect(view.el.querySelector('.agent-source-row')?.textContent).toContain('Local policy'); + expect((view.el.querySelector('.agent-offline-hint') as HTMLElement).hidden).toBe(false); + network.mockReturnValue(true); + window.dispatchEvent(new Event('online')); + expect((view.el.querySelector('.agent-offline-hint') as HTMLElement).hidden).toBe(true); + expect(sources.list()).toHaveLength(1); + expect(fetcher).not.toHaveBeenCalled(); + view.dispose(); + network.mockRestore(); +}); diff --git a/test/unit/i18n-locales.test.ts b/test/unit/i18n-locales.test.ts index 114ca099f..3abee5abb 100644 --- a/test/unit/i18n-locales.test.ts +++ b/test/unit/i18n-locales.test.ts @@ -57,7 +57,7 @@ const SAME_AS_ENGLISH: Partial>> = { }; /** The product name, which reads the same in every language. */ -const NEVER_TRANSLATED: Array = ['webOffice', 'agentSourcesDrive']; +const NEVER_TRANSLATED: Array = ['webOffice']; const restore = () => { setLanguage(LanguageCode.EN); diff --git a/test/unit/offline-assets-build.test.ts b/test/unit/offline-assets-build.test.ts index dc75408c6..c24edd83d 100644 --- a/test/unit/offline-assets-build.test.ts +++ b/test/unit/offline-assets-build.test.ts @@ -57,7 +57,7 @@ it('partitions actual capability graphs without downloading unrelated emitted pa graph['lib/agent-plugin/agent-plugin.ts'].dynamicImports = [ 'node_modules/@mlc-ai/web-llm/lib/index.js', 'packages/agent-core/dist/llm/wllama-cpu-runtime.js', - 'lib/agent-plugin/connectors/pdf-reference-reader.ts', + 'lib/agent-plugin/references/pdf-reference-reader.ts', 'reference', ]; graph['lib/agent-plugin/agent-plugin.ts'].assets = [ @@ -82,7 +82,7 @@ it('partitions actual capability graphs without downloading unrelated emitted pa file: 'assets/compat-url.js', assets: ['assets/compat.wasm'], }; - graph['lib/agent-plugin/connectors/pdf-reference-reader.ts'] = { + graph['lib/agent-plugin/references/pdf-reference-reader.ts'] = { file: 'assets/pdf.js', assets: ['assets/pdf.worker-test.mjs'], }; diff --git a/test/unit/sw-offline-editor.test.ts b/test/unit/sw-offline-editor.test.ts index e6928a0c6..98c319720 100644 --- a/test/unit/sw-offline-editor.test.ts +++ b/test/unit/sw-offline-editor.test.ts @@ -3,10 +3,10 @@ import { runInNewContext } from 'node:vm'; import { expect, it, vi } from 'vitest'; it.each([ - ['/api/connectors/google/status', false], - ['/api/connectors/google/list', false], - ['/api/connectors/google/read?id=private', false], - ['/api/connectors/google/callback?code=private', false], + ['/api/status', false], + ['/api/list', false], + ['/api/read?id=private', false], + ['/api/callback?code=private', false], ['/editor?new=docx&saved=local', true], ['/editor.html?new=xlsx', true], ['/unrelated?new=docx', false], @@ -75,7 +75,7 @@ it.each([ }); expect(await response.catch(() => undefined)).toBe(expected ? shell : undefined); }); -it('never intercepts authenticated connector responses or callback URLs', () => { +it('never intercepts API responses or callback URLs', () => { const listeners = new Map void>(); const respond = vi.fn(); const cache = vi.fn(); @@ -92,7 +92,7 @@ it('never intercepts authenticated connector responses or callback URLs', () => }); for (const path of ['status', 'list', 'read?id=private', 'callback?code=private']) listeners.get('fetch')!({ - request: { method: 'GET', url: `https://editor.example/api/connectors/google/${path}`, mode: 'navigate' }, + request: { method: 'GET', url: `https://editor.example/api/${path}`, mode: 'navigate' }, respondWith: respond, waitUntil: vi.fn(), }); diff --git a/test/unit/sw-routing.test.ts b/test/unit/sw-routing.test.ts index fa95f2a54..0df7c361d 100644 --- a/test/unit/sw-routing.test.ts +++ b/test/unit/sw-routing.test.ts @@ -48,7 +48,7 @@ function swShouldHandle(method: string, urlStr: string): boolean { if (method !== 'GET') return false; const url = new URL(urlStr); if (url.origin !== ORIGIN) return false; - if (url.pathname.startsWith('/api/connectors/')) return false; + if (url.pathname.startsWith('/api/')) return false; if (url.searchParams.has('file') || url.searchParams.has('src')) return false; if (FONT_REGEX.test(url.pathname)) return false; if (url.pathname.includes('/sdkjs/common/spell/')) return false;