Q140: a message can carry several files, or files alone
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
46f7561f77
commit
a6ab09d6b5
11 files changed
+129
-35
No files matched your search
@@ -340,9 +340,9 @@ export function registerIpcHandlers(deps: IpcDeps): void {
|
|||||||
|
|
||||||
handle("importFile", async (input) => {
|
handle("importFile", async (input) => {
|
||||||
try {
|
try {
|
||||||
const fileId = await files.importFile(input);
|
const imported = await files.importFile(input);
|
||||||
if (fileId) notifyDataChanged();
|
if (imported.fileIds.length > 0) notifyDataChanged();
|
||||||
return ok(fileId);
|
return ok(imported);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return fail("file_import_failed", error instanceof Error ? error.message : String(error));
|
return fail("file_import_failed", error instanceof Error ? error.message : String(error));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ export class ChatService {
|
|||||||
|
|
||||||
sendMessage(input: SendMessageInput): string {
|
sendMessage(input: SendMessageInput): string {
|
||||||
const body = input.bodyMd.trim();
|
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 space = this.requireSpace(input.orgId, input.spaceId);
|
||||||
const sender = this.requireSender(input.orgId, input.senderType, input.senderId);
|
const sender = this.requireSender(input.orgId, input.senderType, input.senderId);
|
||||||
if (sender) this.requireMembership(space.id, sender);
|
if (sender) this.requireMembership(space.id, sender);
|
||||||
@@ -160,7 +160,7 @@ export class ChatService {
|
|||||||
this.db.prepare(`
|
this.db.prepare(`
|
||||||
insert into chat_threads(id, org_id, space_id, root_message_id, title, state, created_at, updated_at)
|
insert into chat_threads(id, org_id, space_id, root_message_id, title, state, created_at, updated_at)
|
||||||
values (?, ?, ?, ?, ?, ?, ?, ?)
|
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 {
|
} else {
|
||||||
this.db.prepare("update chat_threads set updated_at = ? where id = ? and org_id = ?").run(now, input.threadId, input.orgId);
|
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;
|
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[] {
|
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.");
|
if (new Set(fileIds).size !== fileIds.length) throw new Error("Attachment file ids must be unique.");
|
||||||
return fileIds.map((fileId) => {
|
return fileIds.map((fileId) => {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { copyFile } from "node:fs/promises";
|
|||||||
import { basename, join } from "node:path";
|
import { basename, join } from "node:path";
|
||||||
import type { DatabaseSync } from "node:sqlite";
|
import type { DatabaseSync } from "node:sqlite";
|
||||||
import { dialog, shell } from "electron";
|
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 { FILE_SCAN_STATE } from "../../shared/domain";
|
||||||
import { createId } from "../../shared/ids";
|
import { createId } from "../../shared/ids";
|
||||||
import { AuditService } from "./audit-service";
|
import { AuditService } from "./audit-service";
|
||||||
@@ -26,13 +26,24 @@ export class FileService {
|
|||||||
mkdirSync(this.artifactRoot, { recursive: true });
|
mkdirSync(this.artifactRoot, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
async importFile(input: ImportFileInput): Promise<string | null> {
|
/** 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<ImportedFiles> {
|
||||||
const result = await dialog.showOpenDialog({
|
const result = await dialog.showOpenDialog({
|
||||||
properties: ["openFile"],
|
properties: ["openFile", "multiSelections"],
|
||||||
securityScopedBookmarks: true,
|
securityScopedBookmarks: true,
|
||||||
});
|
});
|
||||||
if (result.canceled || result.filePaths.length === 0) return null;
|
const imported: ImportedFiles = { fileIds: [], failures: [] };
|
||||||
return this.importPath(input.orgId, result.filePaths[0], input.spaceId ?? null);
|
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. */
|
/** A file imported into a direct conversation or restricted channel is readable by that space's members only. */
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ export const zh: Record<string, string> = {
|
|||||||
"Waiting on you, or resumes on its own at {time}.": "等待你处理,或于 {time} 自动恢复。",
|
"Waiting on you, or resumes on its own at {time}.": "等待你处理,或于 {time} 自动恢复。",
|
||||||
"Waiting on you.": "等待你处理。",
|
"Waiting on you.": "等待你处理。",
|
||||||
"Not sent: {reason}": "未发送:{reason}",
|
"Not sent: {reason}": "未发送:{reason}",
|
||||||
|
"Remove {name}": "移除 {name}",
|
||||||
|
"Not attached: {reason}": "未能附加:{reason}",
|
||||||
"Resume": "恢复",
|
"Resume": "恢复",
|
||||||
"Missing this run": "本次运行缺少的能力",
|
"Missing this run": "本次运行缺少的能力",
|
||||||
"Disconnect": "断开连接",
|
"Disconnect": "断开连接",
|
||||||
@@ -420,7 +422,6 @@ export const zh: Record<string, string> = {
|
|||||||
"Region": "区域",
|
"Region": "区域",
|
||||||
"Rejected": "已驳回",
|
"Rejected": "已驳回",
|
||||||
"Reload models": "重新加载模型",
|
"Reload models": "重新加载模型",
|
||||||
"Remove attachment": "移除附件",
|
|
||||||
"Reporting lines": "汇报关系",
|
"Reporting lines": "汇报关系",
|
||||||
"Request ID": "请求 ID",
|
"Request ID": "请求 ID",
|
||||||
"Request instructions": "请求指令",
|
"Request instructions": "请求指令",
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ function value(form: FormData, key: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const IDLE_SEND: SendState = { pending: false, error: null };
|
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) {
|
export function ChatPage({ snapshot, actions }: PageProps) {
|
||||||
const orgId = activeOrgId(snapshot);
|
const orgId = activeOrgId(snapshot);
|
||||||
@@ -72,6 +72,7 @@ export function ChatPage({ snapshot, actions }: PageProps) {
|
|||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
// Text and attachment are kept per conversation, so switching neither loses one nor carries the other along.
|
// Text and attachment are kept per conversation, so switching neither loses one nor carries the other along.
|
||||||
const [drafts, setDrafts] = useState<Record<string, Draft>>({});
|
const [drafts, setDrafts] = useState<Record<string, Draft>>({});
|
||||||
|
const [attachErrors, setAttachErrors] = useState<Record<string, string | null>>({});
|
||||||
const updateDraft = useCallback((spaceId: string, change: Partial<Draft>) =>
|
const updateDraft = useCallback((spaceId: string, change: Partial<Draft>) =>
|
||||||
setDrafts((current) => ({ ...current, [spaceId]: { ...(current[spaceId] ?? EMPTY_DRAFT), ...change } })), []);
|
setDrafts((current) => ({ ...current, [spaceId]: { ...(current[spaceId] ?? EMPTY_DRAFT), ...change } })), []);
|
||||||
const saveDraftBody = useCallback((spaceId: string, body: string) => updateDraft(spaceId, { body }), [updateDraft]);
|
const saveDraftBody = useCallback((spaceId: string, body: string) => updateDraft(spaceId, { body }), [updateDraft]);
|
||||||
@@ -119,9 +120,9 @@ export function ChatPage({ snapshot, actions }: PageProps) {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!activeSpace || sends[activeSpace.id]?.pending) return;
|
if (!activeSpace || sends[activeSpace.id]?.pending) return;
|
||||||
const spaceId = activeSpace.id;
|
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");
|
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 } }));
|
setSends((current) => ({ ...current, [spaceId]: { pending: true, error: null } }));
|
||||||
// The draft and attachment stay until the message is saved, so a refused send loses nothing.
|
// The draft and attachment stay until the message is saved, so a refused send loses nothing.
|
||||||
actions.run(() => window.agentCompany.sendMessage({
|
actions.run(() => window.agentCompany.sendMessage({
|
||||||
@@ -131,14 +132,14 @@ export function ChatPage({ snapshot, actions }: PageProps) {
|
|||||||
senderType: "employee",
|
senderType: "employee",
|
||||||
senderId: employee.id,
|
senderId: employee.id,
|
||||||
bodyMd: body,
|
bodyMd: body,
|
||||||
attachmentFileIds: sentAttachment ? [sentAttachment] : [],
|
attachmentFileIds: sentAttachments,
|
||||||
})).then(() => {
|
})).then(() => {
|
||||||
// Clear this conversation's mounted composer directly; reset() could restore the sent text as its default.
|
// Clear this conversation's mounted composer directly; reset() could restore the sent text as its default.
|
||||||
const node = composerRef.current;
|
const node = composerRef.current;
|
||||||
if (node?.form?.dataset.spaceId === spaceId) node.value = "";
|
if (node?.form?.dataset.spaceId === spaceId) node.value = "";
|
||||||
setDrafts((current) => {
|
setDrafts((current) => {
|
||||||
const kept = (current[spaceId] ?? EMPTY_DRAFT).attachmentFileId;
|
const kept = (current[spaceId] ?? EMPTY_DRAFT).attachmentFileIds.filter((fileId) => !sentAttachments.includes(fileId));
|
||||||
return { ...current, [spaceId]: { body: "", attachmentFileId: kept === sentAttachment ? "" : kept } };
|
return { ...current, [spaceId]: { body: "", attachmentFileIds: kept } };
|
||||||
});
|
});
|
||||||
setSends((current) => ({ ...current, [spaceId]: { pending: false, error: null } }));
|
setSends((current) => ({ ...current, [spaceId]: { pending: false, error: null } }));
|
||||||
}).catch((error: unknown) => {
|
}).catch((error: unknown) => {
|
||||||
@@ -198,8 +199,18 @@ export function ChatPage({ snapshot, actions }: PageProps) {
|
|||||||
if (!activeSpaceId) return;
|
if (!activeSpaceId) return;
|
||||||
const spaceId = activeSpaceId;
|
const spaceId = activeSpaceId;
|
||||||
void window.agentCompany.importFile({ orgId, spaceId }).then((result) => {
|
void window.agentCompany.importFile({ orgId, spaceId }).then((result) => {
|
||||||
if (!result.ok) throw new Error(result.error.message);
|
if (!result.ok) {
|
||||||
if (result.data) updateDraft(spaceId, { attachmentFileId: result.data });
|
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}
|
files={files}
|
||||||
context={{ employees, agents, readersByMessage }}
|
context={{ employees, agents, readersByMessage }}
|
||||||
draft={(activeSpaceId ? drafts[activeSpaceId] : undefined) ?? EMPTY_DRAFT}
|
draft={(activeSpaceId ? drafts[activeSpaceId] : undefined) ?? EMPTY_DRAFT}
|
||||||
|
attachError={(activeSpaceId ? attachErrors[activeSpaceId] : undefined) ?? null}
|
||||||
onDraftLeave={saveDraftBody}
|
onDraftLeave={saveDraftBody}
|
||||||
currentEmployeeId={employee.id}
|
currentEmployeeId={employee.id}
|
||||||
onAttachmentChange={(fileId) => {
|
onAttachmentRemove={(fileId) => {
|
||||||
if (activeSpaceId) updateDraft(activeSpaceId, { attachmentFileId: fileId });
|
if (!activeSpaceId) return;
|
||||||
|
const remaining = ((drafts[activeSpaceId] ?? EMPTY_DRAFT).attachmentFileIds).filter((id) => id !== fileId);
|
||||||
|
updateDraft(activeSpaceId, { attachmentFileIds: remaining });
|
||||||
}}
|
}}
|
||||||
onAttach={attachFile}
|
onAttach={attachFile}
|
||||||
onApprove={(approvalId) => decideApproval(approvalId, "approved")}
|
onApprove={(approvalId) => decideApproval(approvalId, "approved")}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export function FilesPage({ snapshot, actions }: PageProps) {
|
|||||||
const upload = () => {
|
const upload = () => {
|
||||||
void window.agentCompany.importFile({ orgId }).then((result) => {
|
void window.agentCompany.importFile({ orgId }).then((result) => {
|
||||||
if (!result.ok) throw new Error(result.error.message);
|
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) => {
|
const openFile = (file: FileObjectRecord) => {
|
||||||
|
|||||||
@@ -11,22 +11,23 @@ export interface SendState {
|
|||||||
error: string | null;
|
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 {
|
export interface Draft {
|
||||||
body: string;
|
body: string;
|
||||||
attachmentFileId: string;
|
attachmentFileIds: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatComposer({
|
export function ChatComposer({
|
||||||
disabled,
|
disabled,
|
||||||
send,
|
send,
|
||||||
draft,
|
draft,
|
||||||
|
attachError,
|
||||||
spaceId,
|
spaceId,
|
||||||
onLeave,
|
onLeave,
|
||||||
files,
|
files,
|
||||||
agents,
|
agents,
|
||||||
employees,
|
employees,
|
||||||
onAttachmentChange,
|
onAttachmentRemove,
|
||||||
onAttach,
|
onAttach,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
composerRef,
|
composerRef,
|
||||||
@@ -34,13 +35,15 @@ export function ChatComposer({
|
|||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
send: SendState;
|
send: SendState;
|
||||||
draft: Draft;
|
draft: Draft;
|
||||||
|
/** Why some picked files could not be attached, or null. */
|
||||||
|
attachError: string | null;
|
||||||
spaceId: string | null;
|
spaceId: string | null;
|
||||||
/** Called with the unsent text when the composer goes away, so the conversation keeps it; must be stable. */
|
/** Called with the unsent text when the composer goes away, so the conversation keeps it; must be stable. */
|
||||||
onLeave: (spaceId: string, body: string) => void;
|
onLeave: (spaceId: string, body: string) => void;
|
||||||
files: FileObjectRecord[];
|
files: FileObjectRecord[];
|
||||||
agents: AgentRecord[];
|
agents: AgentRecord[];
|
||||||
employees: EmployeeRecord[];
|
employees: EmployeeRecord[];
|
||||||
onAttachmentChange: (next: string) => void;
|
onAttachmentRemove: (fileId: string) => void;
|
||||||
onAttach: () => void;
|
onAttach: () => void;
|
||||||
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
|
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
|
||||||
composerRef: MutableRefObject<HTMLTextAreaElement | null>;
|
composerRef: MutableRefObject<HTMLTextAreaElement | null>;
|
||||||
@@ -48,7 +51,7 @@ export function ChatComposer({
|
|||||||
const [slashOpen, setSlashOpen] = useState(false);
|
const [slashOpen, setSlashOpen] = useState(false);
|
||||||
// While a send is in flight nothing may change the draft it will clear on success.
|
// While a send is in flight nothing may change the draft it will clear on success.
|
||||||
const locked = disabled || send.pending;
|
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<HTMLTextAreaElement | null>(null);
|
const internalRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
const ref = composerRef ?? internalRef;
|
const ref = composerRef ?? internalRef;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -89,27 +92,28 @@ export function ChatComposer({
|
|||||||
<div className="row" style={{ paddingTop: 6, borderTop: "1px solid var(--border-soft)", gap: 4 }}>
|
<div className="row" style={{ paddingTop: 6, borderTop: "1px solid var(--border-soft)", gap: 4 }}>
|
||||||
<IconButton icon="attach" label={t("Attach file")} onClick={onAttach} disabled={locked} />
|
<IconButton icon="attach" label={t("Attach file")} onClick={onAttach} disabled={locked} />
|
||||||
<IconButton icon="sparkle" label={t("Commands")} onClick={() => setSlashOpen((open) => !open)} disabled={locked} />
|
<IconButton icon="sparkle" label={t("Commands")} onClick={() => setSlashOpen((open) => !open)} disabled={locked} />
|
||||||
{attachment && (
|
{attachments.map((attachment) => (
|
||||||
<span className="pill" style={{ maxWidth: 220 }}>
|
<span key={attachment.id} className="pill" style={{ maxWidth: 220 }}>
|
||||||
<Icon name="file" size={14} />
|
<Icon name="file" size={14} />
|
||||||
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{attachment.name}</span>
|
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{attachment.name}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={t("Remove attachment")}
|
aria-label={t("Remove {name}", { name: attachment.name })}
|
||||||
disabled={locked}
|
disabled={locked}
|
||||||
onClick={() => onAttachmentChange("")}
|
onClick={() => onAttachmentRemove(attachment.id)}
|
||||||
style={{ display: "inline-flex", border: 0, background: "transparent", padding: 0, color: "var(--text-3)", cursor: "pointer" }}
|
style={{ display: "inline-flex", border: 0, background: "transparent", padding: 0, color: "var(--text-3)", cursor: "pointer" }}
|
||||||
>
|
>
|
||||||
<Icon name="x" size={14} />
|
<Icon name="x" size={14} />
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
)}
|
))}
|
||||||
<div style={{ marginLeft: "auto", display: "flex", alignItems: "center", gap: 6 }}>
|
<div style={{ marginLeft: "auto", display: "flex", alignItems: "center", gap: 6 }}>
|
||||||
<span className="mono" style={{ fontSize: 12, color: "var(--text-3)" }}>Ctrl+Enter</span>
|
<span className="mono" style={{ fontSize: 12, color: "var(--text-3)" }}>Ctrl+Enter</span>
|
||||||
<Button tone="primary" size="small" type="submit" disabled={locked}><Icon name="send" size={12} /> {t("Send")}</Button>
|
<Button tone="primary" size="small" type="submit" disabled={locked}><Icon name="send" size={12} /> {t("Send")}</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{send.error && <div className="form-error" role="alert">{t("Not sent: {reason}", { reason: send.error })}</div>}
|
{send.error && <div className="form-error" role="alert">{t("Not sent: {reason}", { reason: send.error })}</div>}
|
||||||
|
{attachError && <div className="form-error" role="alert">{t("Not attached: {reason}", { reason: attachError })}</div>}
|
||||||
{slashOpen && !locked && (
|
{slashOpen && !locked && (
|
||||||
<div style={{ marginTop: 8, display: "flex", flexWrap: "wrap", gap: 6 }}>
|
<div style={{ marginTop: 8, display: "flex", flexWrap: "wrap", gap: 6 }}>
|
||||||
{SLASH_COMMANDS.map((command) => (
|
{SLASH_COMMANDS.map((command) => (
|
||||||
|
|||||||
@@ -25,9 +25,10 @@ export function ChatConversation({
|
|||||||
files,
|
files,
|
||||||
context,
|
context,
|
||||||
draft,
|
draft,
|
||||||
|
attachError,
|
||||||
onDraftLeave,
|
onDraftLeave,
|
||||||
currentEmployeeId,
|
currentEmployeeId,
|
||||||
onAttachmentChange,
|
onAttachmentRemove,
|
||||||
onAttach,
|
onAttach,
|
||||||
onApprove,
|
onApprove,
|
||||||
onDecline,
|
onDecline,
|
||||||
@@ -48,9 +49,10 @@ export function ChatConversation({
|
|||||||
files: FileObjectRecord[];
|
files: FileObjectRecord[];
|
||||||
context: MessageContext;
|
context: MessageContext;
|
||||||
draft: Draft;
|
draft: Draft;
|
||||||
|
attachError: string | null;
|
||||||
onDraftLeave: (spaceId: string, body: string) => void;
|
onDraftLeave: (spaceId: string, body: string) => void;
|
||||||
currentEmployeeId: string;
|
currentEmployeeId: string;
|
||||||
onAttachmentChange: (next: string) => void;
|
onAttachmentRemove: (fileId: string) => void;
|
||||||
onAttach: () => void;
|
onAttach: () => void;
|
||||||
onApprove: (approvalId: string) => void;
|
onApprove: (approvalId: string) => void;
|
||||||
onDecline: (approvalId: string) => void;
|
onDecline: (approvalId: string) => void;
|
||||||
@@ -144,9 +146,10 @@ export function ChatConversation({
|
|||||||
agents={context.agents}
|
agents={context.agents}
|
||||||
employees={context.employees}
|
employees={context.employees}
|
||||||
draft={draft}
|
draft={draft}
|
||||||
|
attachError={attachError}
|
||||||
spaceId={space?.id ?? null}
|
spaceId={space?.id ?? null}
|
||||||
onLeave={onDraftLeave}
|
onLeave={onDraftLeave}
|
||||||
onAttachmentChange={onAttachmentChange}
|
onAttachmentRemove={onAttachmentRemove}
|
||||||
onAttach={onAttach}
|
onAttach={onAttach}
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
composerRef={composerRef}
|
composerRef={composerRef}
|
||||||
|
|||||||
@@ -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 {
|
export interface ImportFileInput {
|
||||||
orgId: string;
|
orgId: string;
|
||||||
/** The conversation the file is attached in; a direct or restricted one keeps the file to its members. */
|
/** The conversation the file is attached in; a direct or restricted one keeps the file to its members. */
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ import type {
|
|||||||
DeleteMessageInput,
|
DeleteMessageInput,
|
||||||
DeleteOrganizationInput,
|
DeleteOrganizationInput,
|
||||||
EditMessageInput,
|
EditMessageInput,
|
||||||
|
ImportedFiles,
|
||||||
ImportFileInput,
|
ImportFileInput,
|
||||||
OpenFileInput,
|
OpenFileInput,
|
||||||
GrantRoleInput,
|
GrantRoleInput,
|
||||||
@@ -174,7 +175,7 @@ export interface DesktopApi {
|
|||||||
launchSocialLogin(input: LaunchSocialLoginInput): Promise<DesktopResult<null>>;
|
launchSocialLogin(input: LaunchSocialLoginInput): Promise<DesktopResult<null>>;
|
||||||
createScheduledPost(input: CreateScheduledPostInput): Promise<DesktopResult<AppSnapshot>>;
|
createScheduledPost(input: CreateScheduledPostInput): Promise<DesktopResult<AppSnapshot>>;
|
||||||
setScheduledPostState(input: SetScheduledPostStateInput): Promise<DesktopResult<AppSnapshot>>;
|
setScheduledPostState(input: SetScheduledPostStateInput): Promise<DesktopResult<AppSnapshot>>;
|
||||||
importFile(input: ImportFileInput): Promise<DesktopResult<string | null>>;
|
importFile(input: ImportFileInput): Promise<DesktopResult<ImportedFiles>>;
|
||||||
openFile(input: OpenFileInput): Promise<DesktopResult<null>>;
|
openFile(input: OpenFileInput): Promise<DesktopResult<null>>;
|
||||||
pickDirectory(): Promise<DesktopResult<string | null>>;
|
pickDirectory(): Promise<DesktopResult<string | null>>;
|
||||||
setFilePinned(input: SetFilePinnedInput): Promise<DesktopResult<AppSnapshot>>;
|
setFilePinned(input: SetFilePinnedInput): Promise<DesktopResult<AppSnapshot>>;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user