Skip to content
Draft
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
1 change: 1 addition & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
"@xstate/react": "^6.0.0",
"@xstate/store": "^3.11.2",
"ai": "^5.0.93",
"async-mutex": "^0.5.0",
"chroma-js": "^3.1.2",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
Expand Down
6 changes: 6 additions & 0 deletions apps/desktop/src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,12 @@
{ "path": "$DOWNLOAD/**/*" }
]
},
{
"identifier": "fs:allow-remove",
"allow": [
{ "path": "$DATA/hyprnote/**/*" }
]
},
"db2:default",
"windows:default",
"tracing:default",
Expand Down
52 changes: 52 additions & 0 deletions apps/desktop/src/chat/resolve-attachments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { FileUIPart } from "ai";

import { readChatAttachmentAsDataURL } from "../components/chat/attachments/storage";
import type { HyprUIMessage } from "./types";

export async function resolveChatFileReferences(
messages: HyprUIMessage[],
chatGroupId?: string,
): Promise<HyprUIMessage[]> {
const resolved: HyprUIMessage[] = [];

for (const message of messages) {
const resolvedParts = await Promise.all(
message.parts.map(async (part) => {
if (part.type === "data-chat-file") {
if (!chatGroupId) {
return part;
}

const dataUrl = await readChatAttachmentAsDataURL(
chatGroupId,
part.data.attachmentId,
);

if (!dataUrl) {
return part;
}

return {
type: "file",
filename: part.data.filename,
mediaType: part.data.mediaType,
url: dataUrl,
} satisfies FileUIPart;
}

if (part.type === "file" && part.url.startsWith("blob:")) {
return part;
}

return part;
}),
);

resolved.push({
...message,
parts: resolvedParts,
});
}

return resolved;
}
9 changes: 8 additions & 1 deletion apps/desktop/src/chat/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,26 @@ import {
} from "ai";

import { type ToolRegistry } from "../contexts/tool";
import { resolveChatFileReferences } from "./resolve-attachments";
import type { HyprUIMessage } from "./types";

export class CustomChatTransport implements ChatTransport<HyprUIMessage> {
constructor(
private registry: ToolRegistry,
private model: LanguageModel,
private chatGroupId?: string,
) {}
Comment on lines +17 to 18
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Guard against attachment resolution failures so sends still succeed

Right now, if resolveChatFileReferences throws (e.g., readChatAttachmentAsDataURL fails), sendMessages will reject and the whole send will fail, even though we could safely fall back to the original messages without resolved files.

To keep chat more robust (and consistent with the more defensive session attachment resolver), consider wrapping the resolver in a try/catch and defaulting back to options.messages on error:

-    const resolvedMessages = await resolveChatFileReferences(
-      options.messages,
-      this.chatGroupId,
-    );
+    let resolvedMessages = options.messages;
+    try {
+      resolvedMessages = await resolveChatFileReferences(
+        options.messages,
+        this.chatGroupId,
+      );
+    } catch (error) {
+      console.error(
+        "[chat] failed to resolve attachments; falling back to original messages",
+        error,
+      );
+    }

This preserves the new attachment support while avoiding a hard failure path when resolution misbehaves.

Also applies to: 25-29, 43-45

🤖 Prompt for AI Agents
In apps/desktop/src/chat/transport.ts around lines 17-18 (also apply same change
at 25-29 and 43-45): the calls that run resolveChatFileReferences can throw and
currently bubble up, causing sendMessages to fail; wrap each
resolveChatFileReferences invocation in a try/catch, on catch log or warn the
error, and fall back to using the original options.messages (or the previous
messages variable) so the send proceeds without resolved attachments; ensure
types remain compatible when falling back and avoid swallowing unexpected errors
(only suppress attachment-resolution failures).


sendMessages: ChatTransport<HyprUIMessage>["sendMessages"] = async (
options,
) => {
const tools = this.registry.getTools("chat");

const resolvedMessages = await resolveChatFileReferences(
options.messages,
this.chatGroupId,
);

const agent = new Agent({
model: this.model,
tools,
Expand All @@ -34,7 +41,7 @@ export class CustomChatTransport implements ChatTransport<HyprUIMessage> {
});

const result = agent.stream({
messages: convertToModelMessages(options.messages),
messages: convertToModelMessages(resolvedMessages),
});

return result.toUIMessageStream({
Expand Down
16 changes: 15 additions & 1 deletion apps/desktop/src/chat/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,18 @@ export const messageMetadataSchema = z.object({
});

export type MessageMetadata = z.infer<typeof messageMetadataSchema>;
export type HyprUIMessage = UIMessage<MessageMetadata>;

export type ChatFileReferencePart = {
type: "chat-file";
attachmentId: string;
filename: string;
mediaType: string;
size: number;
fileUrl: string;
};

export type ChatDataParts = {
"chat-file": ChatFileReferencePart;
};

export type HyprUIMessage = UIMessage<MessageMetadata, ChatDataParts>;
52 changes: 52 additions & 0 deletions apps/desktop/src/components/chat/attachments/storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { ATTACHMENT_SIZE_LIMIT } from "../../../shared/attachments/constants";
import {
createAttachmentStorage,
ManifestCorruptionError,
type ManifestEntry,
} from "../../../shared/attachments/storage";

export type PersistedChatAttachment = ManifestEntry & {
filePath: string;
fileUrl: string;
};

export { ManifestCorruptionError };

const chatStorage = createAttachmentStorage<ManifestEntry>({
getBasePath: (groupId: string) => `hyprnote/chat/${groupId}`,
entityName: "chat group",
maxSize: ATTACHMENT_SIZE_LIMIT,
includeTitle: false,
});

export async function loadChatAttachments(
groupId: string,
): Promise<PersistedChatAttachment[]> {
return await chatStorage.load(groupId);
}

export async function saveChatAttachment(
groupId: string,
file: File,
attachmentId = crypto.randomUUID(),
): Promise<PersistedChatAttachment> {
return await chatStorage.save(groupId, file, {}, attachmentId);
}

export async function removeChatAttachment(
groupId: string,
attachmentId: string,
) {
return await chatStorage.remove(groupId, attachmentId);
}

export async function removeChatGroupAttachments(groupId: string) {
return await chatStorage.removeAll(groupId);
}

export async function readChatAttachmentAsDataURL(
groupId: string,
attachmentId: string,
): Promise<string | null> {
return await chatStorage.readAsDataURL(groupId, attachmentId);
}
Loading
Loading