diff --git a/docs/INDEX.md b/docs/INDEX.md index 9db1351..cb0183f 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -54,7 +54,7 @@ packages/ | 文件 | 关键导出 | 职责 | |---|---|---| | `src/ipc-channels.ts` | `CHANNEL_TABLE`、`ch`、`IpcChannels`、`InvokeApi`/`InvokeHandlers` | **IPC invoke 通道单一事实源**:key(=PiApi 方法名)→ 通道字符串 + args/ret 类型;按域子表(SESSION/SETTINGS/PACKAGES/APP/LAN/EXTENSION_DIALOG/UI_PLUGINS);EVENT_CHANNELS 段 = main→renderer 单向事件(PascalCase 手写) | -| `src/ipc.ts` | `PiApi` | `window.pi` 完整类型:invoke 成员由 `InvokeApi` 推导 + 订阅 on* ×11 与 `platform` 手写 | +| `src/ipc.ts` | `PiApi` | `window.pi` 完整类型:invoke 成员由 `InvokeApi` 推导;订阅 on* ×11、`platform` 与 preload 原生文件路径桥 `getPathForFile` 手写 | | `src/session.ts` | `SessionMeta`、`SessionStats`、`AvailableModel`(可选 `thinkingLevels`/`imageInput`,缺省 fail-open)、`SessionEvent`、`SessionMessage`、`UiState`、`PermissionRequest`、`PermissionMode`(default/fullAccess)、`TrustRequest`、`LoadedResources` 等 | 会话/事件跨进程类型。`SessionEvent` = pi `AgentSessionEvent` ∪ Percho 自有 UI 事件(`subagent_mutex`/`stream_guard_tripped`,不进 trace);`SessionMessage` union:user/assistant(均带 `entryId` 供 fork/撤回;user 专属 `skill`/`sourceText`)+ `role:"image"`(show_image 回放)+ `role:"subagent"` | | `src/transcript/` | `reduceEvent`、`messagesToUIMessages`、`buildChatRows`、`deriveTurnChanges`、`deriveTurnTimings` | **UI 消息状态机(桌面与 lan-web 共用同一份)**:`types`(UIMessage/StreamingState 等)、`helpers`(事件载荷解析)、`reducer`(pi 事件 → UI 状态)、`mapping`(历史回放)、`parse-patch`(unified diff 结构化解析)、`turn-files`(按轮聚合文件变更)、`turn-timings`(按轮计时派生 + runEndedAt 定格)、`chat-rows`(行序列分组 + 轮末行定位规则;入参 `ChatRowsInput` 四字段收窄)、`llm-errors`(LLM 错误轮判定 live/replay 共用)、`meta-summary`(工具语义分类统计) | | `src/errors.ts` | `UiError`、`classifyLlmError`、`buildLlmUiError`、`buildStreamGuardUiError`、`DETAIL_MAX_LENGTH` | 统一报错信封:错误卡数据源(live reducer / 历史回放 mapping / Composer 内联 / LAN 共用);`classifyLlmError` 按 401/429/context/网络模式分类,误判只影响标题措辞 | diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts index b7d5d20..92e97fe 100644 --- a/packages/desktop/src/preload/index.ts +++ b/packages/desktop/src/preload/index.ts @@ -15,7 +15,7 @@ import { type UiPluginsEventPayload, type UpdateState, } from "@percho/shared"; -import { contextBridge, ipcRenderer } from "electron"; +import { contextBridge, ipcRenderer, webUtils } from "electron"; /** 事件订阅包装:ipcRenderer.on + 返回退订函数(removeListener),payload 透传 */ function makeSubscription(channel: string): (cb: (payload: T) => void) => () => void { @@ -36,6 +36,7 @@ const invokeApi = Object.fromEntries( const api: PiApi = { platform: process.platform, + getPathForFile: (file) => webUtils.getPathForFile(file as Parameters[0]), ...invokeApi, onProviderLoginEvent: makeSubscription(IpcChannels.SettingsLoginEvent), onUiPluginsEvent: makeSubscription(IpcChannels.UiPluginsEvent), diff --git a/packages/desktop/src/renderer/src/components/composer/Composer.tsx b/packages/desktop/src/renderer/src/components/composer/Composer.tsx index b84ed98..50d8973 100644 --- a/packages/desktop/src/renderer/src/components/composer/Composer.tsx +++ b/packages/desktop/src/renderer/src/components/composer/Composer.tsx @@ -1,5 +1,6 @@ import type { ImageInput } from "@percho/shared"; -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { getPi } from "../../api"; import { useActiveModelInfo, useSessionReadOnly } from "../../hooks/use-session-state"; import { useT } from "../../i18n"; import { RegionHost } from "../../plugins/RegionHost"; @@ -13,6 +14,7 @@ import { ArrowUpIcon, PencilIcon, PlusIcon, StopIcon } from "../icons"; import { AtMenu } from "./AtMenu"; import { AttachmentChip } from "./AttachmentChip"; import { ContextRing } from "./ContextRing"; +import { resolveDroppedFilePaths } from "./drop-files"; import { ImageTray } from "./ImageTray"; import { ModelPicker } from "./ModelPicker"; import { PermissionPicker } from "./PermissionPicker"; @@ -68,6 +70,16 @@ export function Composer({ centered = false }: { centered?: boolean }) { attachments: typeof updater === "function" ? updater(d.attachments) : updater, })); }; + const appendAttachments = useCallback( + (paths: string[]) => { + if (paths.length === 0) return; + updateDraft(draftKey, (d) => ({ + ...d, + attachments: [...new Set([...d.attachments, ...paths])], + })); + }, + [draftKey, updateDraft], + ); const setQuotes = (updater: string[] | ((prev: string[]) => string[])) => { updateDraft(draftKey, (d) => ({ ...d, @@ -160,6 +172,28 @@ export function Composer({ centered = false }: { centered?: boolean }) { return () => window.removeEventListener(COMPOSER_FOCUS_EVENT, onFocusRequest); }, []); + // Electron sandbox 中 File.path 已移除;preload 通过 webUtils 恢复原生路径并送入现有附件管线。 + useEffect(() => { + if (readOnly) return; + const containsFiles = (event: DragEvent) => event.dataTransfer?.types.includes("Files") ?? false; + const onDragOver = (event: DragEvent) => { + if (!containsFiles(event)) return; + event.preventDefault(); + if (event.dataTransfer) event.dataTransfer.dropEffect = "copy"; + }; + const onDrop = (event: DragEvent) => { + if (!containsFiles(event) || !event.dataTransfer) return; + event.preventDefault(); + appendAttachments(resolveDroppedFilePaths(event.dataTransfer.files, getPi().getPathForFile)); + }; + window.addEventListener("dragover", onDragOver); + window.addEventListener("drop", onDrop); + return () => { + window.removeEventListener("dragover", onDragOver); + window.removeEventListener("drop", onDrop); + }; + }, [appendAttachments, readOnly]); + // 点击输入框容器外部时收起命令/文件面板(文本保留;继续输入时恢复) const { setSlashDismissed } = slash; const { setAtDismissed } = at; diff --git a/packages/desktop/src/renderer/src/components/composer/drop-files.test.ts b/packages/desktop/src/renderer/src/components/composer/drop-files.test.ts new file mode 100644 index 0000000..a3ff739 --- /dev/null +++ b/packages/desktop/src/renderer/src/components/composer/drop-files.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it, vi } from "vitest"; +import { resolveDroppedFilePaths } from "./drop-files"; + +describe("resolveDroppedFilePaths", () => { + it("resolves, de-duplicates, and skips files without a native path", () => { + const files = [{ path: "C:\\a.txt" }, { path: "" }, { path: "C:\\a.txt" }, { path: "C:\\b.md" }]; + const getPathForFile = vi.fn((file: unknown) => (file as { path: string }).path); + + expect(resolveDroppedFilePaths(files, getPathForFile)).toEqual(["C:\\a.txt", "C:\\b.md"]); + expect(getPathForFile).toHaveBeenCalledTimes(4); + }); + + it("preserves whitespace that is part of a native path", () => { + const path = "/tmp/report.pdf "; + + expect(resolveDroppedFilePaths([{ path }], (file) => (file as { path: string }).path)).toEqual([path]); + }); + + it("ignores invalid drop entries instead of aborting the entire batch", () => { + const bad = {}; + const good = {}; + const getPathForFile = vi.fn((file: unknown) => { + if (file === bad) throw new TypeError("not a native File"); + return file === good ? "/tmp/report.pdf" : ""; + }); + + expect(resolveDroppedFilePaths([bad, good], getPathForFile)).toEqual(["/tmp/report.pdf"]); + }); +}); diff --git a/packages/desktop/src/renderer/src/components/composer/drop-files.ts b/packages/desktop/src/renderer/src/components/composer/drop-files.ts new file mode 100644 index 0000000..73b4f58 --- /dev/null +++ b/packages/desktop/src/renderer/src/components/composer/drop-files.ts @@ -0,0 +1,19 @@ +/** + * Resolve native paths for files dropped into the sandboxed renderer. + * Electron can return an empty path for JS-created File objects, so those are ignored. + */ +export function resolveDroppedFilePaths( + files: ArrayLike, + getPathForFile: (file: unknown) => string, +): string[] { + const paths = new Set(); + for (const file of Array.from(files)) { + try { + const path = getPathForFile(file); + if (path.length > 0) paths.add(path); + } catch { + // Ignore values that are not native File objects instead of breaking the drop event. + } + } + return [...paths]; +} diff --git a/packages/shared/src/ipc.ts b/packages/shared/src/ipc.ts index 84350fb..5d7b8ca 100644 --- a/packages/shared/src/ipc.ts +++ b/packages/shared/src/ipc.ts @@ -31,11 +31,13 @@ export { /** * 渲染进程经 preload 暴露的 window.pi 类型。 * invoke 成员:表化通道由 InvokeApi 推导(shared/ipc-channels.ts 单一事实源, - * key = 方法名);表外成员(订阅 on*、platform)与迁移期遗留 invoke 在下方手写。 + * key = 方法名);表外成员(订阅 on*、platform、getPathForFile)在下方手写。 */ export interface PiApi extends InvokeApi { /** 运行平台(preload 同步注入,供 renderer 按平台分流 UI:如顶栏红绿灯/窗口按钮留白) */ readonly platform: "darwin" | "win32" | "linux" | (string & {}); + /** Electron 沙箱渲染器中的 File 不再带 path;由 preload 同步解析原生拖入文件路径。 */ + getPathForFile(file: unknown): string; /** 订阅登录流程事件(event/prompt/prompt-cancel,按 loginId 归属);返回取消函数 */ onProviderLoginEvent(cb: (payload: LoginEventPayload) => void): () => void; /** 订阅更新状态(checking/available/downloading/downloaded/error);返回取消函数 */