Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<CHANNEL_TABLE>` 推导 + 订阅 on* ×11 与 `platform` 手写 |
| `src/ipc.ts` | `PiApi` | `window.pi` 完整类型:invoke 成员由 `InvokeApi<CHANNEL_TABLE>` 推导;订阅 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/网络模式分类,误判只影响标题措辞 |
Expand Down
3 changes: 2 additions & 1 deletion packages/desktop/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(channel: string): (cb: (payload: T) => void) => () => void {
Expand All @@ -36,6 +36,7 @@ const invokeApi = Object.fromEntries(

const api: PiApi = {
platform: process.platform,
getPathForFile: (file) => webUtils.getPathForFile(file as Parameters<typeof webUtils.getPathForFile>[0]),
...invokeApi,
onProviderLoginEvent: makeSubscription<LoginEventPayload>(IpcChannels.SettingsLoginEvent),
onUiPluginsEvent: makeSubscription<UiPluginsEventPayload>(IpcChannels.UiPluginsEvent),
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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"]);
});
});
Original file line number Diff line number Diff line change
@@ -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<unknown>,
getPathForFile: (file: unknown) => string,
): string[] {
const paths = new Set<string>();
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];
}
4 changes: 3 additions & 1 deletion packages/shared/src/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,13 @@ export {
/**
* 渲染进程经 preload 暴露的 window.pi 类型。
* invoke 成员:表化通道由 InvokeApi<CHANNEL_TABLE> 推导(shared/ipc-channels.ts 单一事实源,
* key = 方法名);表外成员(订阅 on*、platform)与迁移期遗留 invoke 在下方手写。
* key = 方法名);表外成员(订阅 on*、platform、getPathForFile)在下方手写。
*/
export interface PiApi extends InvokeApi<typeof CHANNEL_TABLE> {
/** 运行平台(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);返回取消函数 */
Expand Down
Loading