diff --git a/src/main/app/ipc-handlers.ts b/src/main/app/ipc-handlers.ts index e592962a..c728e29f 100644 --- a/src/main/app/ipc-handlers.ts +++ b/src/main/app/ipc-handlers.ts @@ -340,9 +340,9 @@ export function registerIpcHandlers(deps: IpcDeps): void { handle("importFile", async (input) => { try { - const fileId = await files.importFile(input); - if (fileId) notifyDataChanged(); - return ok(fileId); + const imported = await files.importFile(input); + if (imported.fileIds.length > 0) notifyDataChanged(); + return ok(imported); } catch (error) { return fail("file_import_failed", error instanceof Error ? error.message : String(error)); } diff --git a/src/main/services/chat-service.ts b/src/main/services/chat-service.ts index a0229e53..2c764bc0 100644 --- a/src/main/services/chat-service.ts +++ b/src/main/services/chat-service.ts @@ -120,7 +120,7 @@ export class ChatService { sendMessage(input: SendMessageInput): string { const body = input.bodyMd.trim(); - if (!body) throw new Error("Message body is required."); + if (!body && (input.attachmentFileIds ?? []).length === 0) throw new Error("A message needs text or a file."); const space = this.requireSpace(input.orgId, input.spaceId); const sender = this.requireSender(input.orgId, input.senderType, input.senderId); if (sender) this.requireMembership(space.id, sender); @@ -160,7 +160,7 @@ export class ChatService { this.db.prepare(` insert into chat_threads(id, org_id, space_id, root_message_id, title, state, created_at, updated_at) values (?, ?, ?, ?, ?, ?, ?, ?) - `).run(threadId, input.orgId, input.spaceId, id, createThreadTitle(body), "open", now, now); + `).run(threadId, input.orgId, input.spaceId, id, createThreadTitle(body || this.fileNames(input.orgId, attachmentFileIds)), "open", now, now); } else { this.db.prepare("update chat_threads set updated_at = ? where id = ? and org_id = ?").run(now, input.threadId, input.orgId); } @@ -426,6 +426,13 @@ export class ChatService { return thread; } + /** The attached files' names, in order, to title a thread whose first message is files alone. */ + private fileNames(orgId: string, fileIds: string[]): string { + const names = this.db.prepare("select id, name from file_objects where org_id = ?").all(orgId) as Array<{ id: string; name: string }>; + const byId = new Map(names.map((file) => [file.id, file.name])); + return fileIds.map((fileId) => byId.get(fileId)!).join(", "); + } + private requireAttachments(orgId: string, fileIds: string[], sender: PrincipalRef | null): FileHandoffAttachment[] { if (new Set(fileIds).size !== fileIds.length) throw new Error("Attachment file ids must be unique."); return fileIds.map((fileId) => { diff --git a/src/main/services/file-service.ts b/src/main/services/file-service.ts index 823de1ee..262e7c96 100644 --- a/src/main/services/file-service.ts +++ b/src/main/services/file-service.ts @@ -3,7 +3,7 @@ import { copyFile } from "node:fs/promises"; import { basename, join } from "node:path"; import type { DatabaseSync } from "node:sqlite"; import { dialog, shell } from "electron"; -import type { ImportFileInput, OpenFileInput } from "../../shared/commands"; +import type { ImportedFiles, ImportFileInput, OpenFileInput } from "../../shared/commands"; import { FILE_SCAN_STATE } from "../../shared/domain"; import { createId } from "../../shared/ids"; import { AuditService } from "./audit-service"; @@ -26,13 +26,24 @@ export class FileService { mkdirSync(this.artifactRoot, { recursive: true }); } - async importFile(input: ImportFileInput): Promise { + /** Imports every file the user picks, in order; one that fails is reported and does not undo or hide the others. */ + async importFile(input: ImportFileInput): Promise { const result = await dialog.showOpenDialog({ - properties: ["openFile"], + properties: ["openFile", "multiSelections"], securityScopedBookmarks: true, }); - if (result.canceled || result.filePaths.length === 0) return null; - return this.importPath(input.orgId, result.filePaths[0], input.spaceId ?? null); + const imported: ImportedFiles = { fileIds: [], failures: [] }; + if (result.canceled) return imported; + for (const path of result.filePaths) { + try { + imported.fileIds.push(await this.importPath(input.orgId, path, input.spaceId ?? null)); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + console.warn(`[files] import of ${basename(path)} failed: ${reason}`); + imported.failures.push({ name: basename(path), reason }); + } + } + return imported; } /** A file imported into a direct conversation or restricted channel is readable by that space's members only. */ diff --git a/src/renderer/src/i18n/zh.ts b/src/renderer/src/i18n/zh.ts index 66666db6..d1bf8a91 100644 --- a/src/renderer/src/i18n/zh.ts +++ b/src/renderer/src/i18n/zh.ts @@ -37,6 +37,8 @@ export const zh: Record = { "Waiting on you, or resumes on its own at {time}.": "等待你处理,或于 {time} 自动恢复。", "Waiting on you.": "等待你处理。", "Not sent: {reason}": "未发送:{reason}", + "Remove {name}": "移除 {name}", + "Not attached: {reason}": "未能附加:{reason}", "Resume": "恢复", "Missing this run": "本次运行缺少的能力", "Disconnect": "断开连接", @@ -420,7 +422,6 @@ export const zh: Record = { "Region": "区域", "Rejected": "已驳回", "Reload models": "重新加载模型", - "Remove attachment": "移除附件", "Reporting lines": "汇报关系", "Request ID": "请求 ID", "Request instructions": "请求指令", diff --git a/src/renderer/src/pages/ChatPage.tsx b/src/renderer/src/pages/ChatPage.tsx index 29575112..f7cd4fac 100644 --- a/src/renderer/src/pages/ChatPage.tsx +++ b/src/renderer/src/pages/ChatPage.tsx @@ -22,7 +22,7 @@ function value(form: FormData, key: string): string { } const IDLE_SEND: SendState = { pending: false, error: null }; -const EMPTY_DRAFT: Draft = { body: "", attachmentFileId: "" }; +const EMPTY_DRAFT: Draft = { body: "", attachmentFileIds: [] }; export function ChatPage({ snapshot, actions }: PageProps) { const orgId = activeOrgId(snapshot); @@ -72,6 +72,7 @@ export function ChatPage({ snapshot, actions }: PageProps) { const [createOpen, setCreateOpen] = useState(false); // Text and attachment are kept per conversation, so switching neither loses one nor carries the other along. const [drafts, setDrafts] = useState>({}); + const [attachErrors, setAttachErrors] = useState>({}); const updateDraft = useCallback((spaceId: string, change: Partial) => setDrafts((current) => ({ ...current, [spaceId]: { ...(current[spaceId] ?? EMPTY_DRAFT), ...change } })), []); const saveDraftBody = useCallback((spaceId: string, body: string) => updateDraft(spaceId, { body }), [updateDraft]); @@ -119,9 +120,9 @@ export function ChatPage({ snapshot, actions }: PageProps) { event.preventDefault(); if (!activeSpace || sends[activeSpace.id]?.pending) return; const spaceId = activeSpace.id; - const sentAttachment = (drafts[spaceId] ?? EMPTY_DRAFT).attachmentFileId; + const sentAttachments = (drafts[spaceId] ?? EMPTY_DRAFT).attachmentFileIds; const body = value(new FormData(event.currentTarget), "message"); - if (!body) return; + if (!body && sentAttachments.length === 0) return; setSends((current) => ({ ...current, [spaceId]: { pending: true, error: null } })); // The draft and attachment stay until the message is saved, so a refused send loses nothing. actions.run(() => window.agentCompany.sendMessage({ @@ -131,14 +132,14 @@ export function ChatPage({ snapshot, actions }: PageProps) { senderType: "employee", senderId: employee.id, bodyMd: body, - attachmentFileIds: sentAttachment ? [sentAttachment] : [], + attachmentFileIds: sentAttachments, })).then(() => { // Clear this conversation's mounted composer directly; reset() could restore the sent text as its default. const node = composerRef.current; if (node?.form?.dataset.spaceId === spaceId) node.value = ""; setDrafts((current) => { - const kept = (current[spaceId] ?? EMPTY_DRAFT).attachmentFileId; - return { ...current, [spaceId]: { body: "", attachmentFileId: kept === sentAttachment ? "" : kept } }; + const kept = (current[spaceId] ?? EMPTY_DRAFT).attachmentFileIds.filter((fileId) => !sentAttachments.includes(fileId)); + return { ...current, [spaceId]: { body: "", attachmentFileIds: kept } }; }); setSends((current) => ({ ...current, [spaceId]: { pending: false, error: null } })); }).catch((error: unknown) => { @@ -198,8 +199,18 @@ export function ChatPage({ snapshot, actions }: PageProps) { if (!activeSpaceId) return; const spaceId = activeSpaceId; void window.agentCompany.importFile({ orgId, spaceId }).then((result) => { - if (!result.ok) throw new Error(result.error.message); - if (result.data) updateDraft(spaceId, { attachmentFileId: result.data }); + if (!result.ok) { + setAttachErrors((current) => ({ ...current, [spaceId]: result.error.message })); + return; + } + const { fileIds, failures } = result.data; + setDrafts((current) => { + const draft = current[spaceId] ?? EMPTY_DRAFT; + const added = fileIds.filter((fileId) => !draft.attachmentFileIds.includes(fileId)); + return { ...current, [spaceId]: { ...draft, attachmentFileIds: [...draft.attachmentFileIds, ...added] } }; + }); + const reason = failures.map((failure) => `${failure.name} (${failure.reason})`).join("; "); + setAttachErrors((current) => ({ ...current, [spaceId]: reason || null })); }); }; @@ -231,10 +242,13 @@ export function ChatPage({ snapshot, actions }: PageProps) { files={files} context={{ employees, agents, readersByMessage }} draft={(activeSpaceId ? drafts[activeSpaceId] : undefined) ?? EMPTY_DRAFT} + attachError={(activeSpaceId ? attachErrors[activeSpaceId] : undefined) ?? null} onDraftLeave={saveDraftBody} currentEmployeeId={employee.id} - onAttachmentChange={(fileId) => { - if (activeSpaceId) updateDraft(activeSpaceId, { attachmentFileId: fileId }); + onAttachmentRemove={(fileId) => { + if (!activeSpaceId) return; + const remaining = ((drafts[activeSpaceId] ?? EMPTY_DRAFT).attachmentFileIds).filter((id) => id !== fileId); + updateDraft(activeSpaceId, { attachmentFileIds: remaining }); }} onAttach={attachFile} onApprove={(approvalId) => decideApproval(approvalId, "approved")} diff --git a/src/renderer/src/pages/FilesPage.tsx b/src/renderer/src/pages/FilesPage.tsx index c7896e1c..e4dbc3c5 100644 --- a/src/renderer/src/pages/FilesPage.tsx +++ b/src/renderer/src/pages/FilesPage.tsx @@ -44,6 +44,7 @@ export function FilesPage({ snapshot, actions }: PageProps) { const upload = () => { void window.agentCompany.importFile({ orgId }).then((result) => { if (!result.ok) throw new Error(result.error.message); + if (result.data.failures.length > 0) throw new Error(result.data.failures.map((failure) => `${failure.name}: ${failure.reason}`).join("; ")); }); }; const openFile = (file: FileObjectRecord) => { diff --git a/src/renderer/src/pages/chat/ChatComposer.tsx b/src/renderer/src/pages/chat/ChatComposer.tsx index f46ab0a9..1642894d 100644 --- a/src/renderer/src/pages/chat/ChatComposer.tsx +++ b/src/renderer/src/pages/chat/ChatComposer.tsx @@ -11,22 +11,23 @@ export interface SendState { error: string | null; } -/** A conversation's unsent message: its text and the file picked to go with it. */ +/** A conversation's unsent message: its text and the files picked to go with it. */ export interface Draft { body: string; - attachmentFileId: string; + attachmentFileIds: string[]; } export function ChatComposer({ disabled, send, draft, + attachError, spaceId, onLeave, files, agents, employees, - onAttachmentChange, + onAttachmentRemove, onAttach, onSubmit, composerRef, @@ -34,13 +35,15 @@ export function ChatComposer({ disabled: boolean; send: SendState; draft: Draft; + /** Why some picked files could not be attached, or null. */ + attachError: string | null; spaceId: string | null; /** Called with the unsent text when the composer goes away, so the conversation keeps it; must be stable. */ onLeave: (spaceId: string, body: string) => void; files: FileObjectRecord[]; agents: AgentRecord[]; employees: EmployeeRecord[]; - onAttachmentChange: (next: string) => void; + onAttachmentRemove: (fileId: string) => void; onAttach: () => void; onSubmit: (event: FormEvent) => void; composerRef: MutableRefObject; @@ -48,7 +51,7 @@ export function ChatComposer({ const [slashOpen, setSlashOpen] = useState(false); // While a send is in flight nothing may change the draft it will clear on success. const locked = disabled || send.pending; - const attachment = files.find((file) => file.id === draft.attachmentFileId) ?? null; + const attachments = draft.attachmentFileIds.map((fileId) => files.find((file) => file.id === fileId) ?? { id: fileId, name: fileId }); const internalRef = useRef(null); const ref = composerRef ?? internalRef; useEffect(() => { @@ -89,27 +92,28 @@ export function ChatComposer({
setSlashOpen((open) => !open)} disabled={locked} /> - {attachment && ( - + {attachments.map((attachment) => ( + {attachment.name} - )} + ))}
Ctrl+Enter
{send.error &&
{t("Not sent: {reason}", { reason: send.error })}
} + {attachError &&
{t("Not attached: {reason}", { reason: attachError })}
} {slashOpen && !locked && (
{SLASH_COMMANDS.map((command) => ( diff --git a/src/renderer/src/pages/chat/ChatConversation.tsx b/src/renderer/src/pages/chat/ChatConversation.tsx index 21e61aed..a19f4f79 100644 --- a/src/renderer/src/pages/chat/ChatConversation.tsx +++ b/src/renderer/src/pages/chat/ChatConversation.tsx @@ -25,9 +25,10 @@ export function ChatConversation({ files, context, draft, + attachError, onDraftLeave, currentEmployeeId, - onAttachmentChange, + onAttachmentRemove, onAttach, onApprove, onDecline, @@ -48,9 +49,10 @@ export function ChatConversation({ files: FileObjectRecord[]; context: MessageContext; draft: Draft; + attachError: string | null; onDraftLeave: (spaceId: string, body: string) => void; currentEmployeeId: string; - onAttachmentChange: (next: string) => void; + onAttachmentRemove: (fileId: string) => void; onAttach: () => void; onApprove: (approvalId: string) => void; onDecline: (approvalId: string) => void; @@ -144,9 +146,10 @@ export function ChatConversation({ agents={context.agents} employees={context.employees} draft={draft} + attachError={attachError} spaceId={space?.id ?? null} onLeave={onDraftLeave} - onAttachmentChange={onAttachmentChange} + onAttachmentRemove={onAttachmentRemove} onAttach={onAttach} onSubmit={onSubmit} composerRef={composerRef} diff --git a/src/shared/commands/files.ts b/src/shared/commands/files.ts index 4ab39308..8ecdc0d5 100644 --- a/src/shared/commands/files.ts +++ b/src/shared/commands/files.ts @@ -1,3 +1,9 @@ +/** What a pick imported, in order, and each picked file that could not be imported, with why. */ +export interface ImportedFiles { + fileIds: string[]; + failures: Array<{ name: string; reason: string }>; +} + export interface ImportFileInput { orgId: string; /** The conversation the file is attached in; a direct or restricted one keeps the file to its members. */ diff --git a/src/shared/desktop-api.ts b/src/shared/desktop-api.ts index 7a40737f..8964b39b 100644 --- a/src/shared/desktop-api.ts +++ b/src/shared/desktop-api.ts @@ -58,6 +58,7 @@ import type { DeleteMessageInput, DeleteOrganizationInput, EditMessageInput, + ImportedFiles, ImportFileInput, OpenFileInput, GrantRoleInput, @@ -174,7 +175,7 @@ export interface DesktopApi { launchSocialLogin(input: LaunchSocialLoginInput): Promise>; createScheduledPost(input: CreateScheduledPostInput): Promise>; setScheduledPostState(input: SetScheduledPostStateInput): Promise>; - importFile(input: ImportFileInput): Promise>; + importFile(input: ImportFileInput): Promise>; openFile(input: OpenFileInput): Promise>; pickDirectory(): Promise>; setFilePinned(input: SetFilePinnedInput): Promise>; diff --git a/tests/unit/chat-file-only.test.ts b/tests/unit/chat-file-only.test.ts new file mode 100644 index 00000000..7d3a290e --- /dev/null +++ b/tests/unit/chat-file-only.test.ts @@ -0,0 +1,46 @@ +import { randomUUID } from "node:crypto"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, test, vi } from "vitest"; + +const picked: { paths: string[] } = { paths: [] }; +vi.mock("electron", () => ({ dialog: { showOpenDialog: async () => ({ canceled: false, filePaths: picked.paths }) }, shell: {} })); + +const { DatabaseConnection } = await import("../../src/main/database/connection"); +const { OrganizationService } = await import("../../src/main/services/organization-service"); +const { ChatService } = await import("../../src/main/services/chat-service"); +const { FileService } = await import("../../src/main/services/file-service"); + +describe("delivering files in chat", () => { + test("several files go in one message, and a message may be files alone", async () => { + const connection = new DatabaseConnection(join(tmpdir(), `ac-file-only-${randomUUID()}.db`)); + const chat = new ChatService(connection.db); + let orgId = ""; + connection.transaction(() => { + orgId = new OrganizationService(connection.db).createOrganization({ organizationName: "Acme", employeeName: "Yang", mission: "m", goal: "g" }); + }); + const employeeId = (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id; + const spaceId = (connection.db.prepare("select id from chat_spaces where org_id = ? and name = 'general'").get(orgId) as { id: string }).id; + const source = join(tmpdir(), `ac-file-only-src-${randomUUID()}`); + mkdirSync(source, { recursive: true }); + writeFileSync(join(source, "spec.md"), "The spec."); + writeFileSync(join(source, "plan.md"), "The plan."); + picked.paths = [join(source, "spec.md"), join(source, "missing.md"), join(source, "plan.md")]; + const imported = await new FileService(connection.db, join(tmpdir(), `ac-file-only-art-${randomUUID()}`)).importFile({ orgId, spaceId }); + // A file that cannot be imported is reported by name and does not drop the others. + expect(imported.fileIds).toHaveLength(2); + expect(imported.failures.map((failure) => failure.name)).toEqual(["missing.md"]); + const fileIds = imported.fileIds; + + const send = (bodyMd: string, attachmentFileIds: string[]) => connection.transaction(() => chat.sendMessage({ + orgId, spaceId, threadId: null, senderType: "employee", senderId: employeeId, bodyMd, attachmentFileIds, + })); + const messageId = send(" ", fileIds); + expect(connection.db.prepare("select count(*) as n from chat_attachments where message_id = ?").get(messageId)).toEqual({ n: 2 }); + expect(connection.db.prepare("select t.title from chat_threads t join chat_messages m on m.thread_id = t.id where m.id = ?").get(messageId)) + .toEqual({ title: "spec.md, plan.md" }); + expect(() => send(" ", [])).toThrow("A message needs text or a file."); + connection.close(); + }); +});