Skip to content

Commit 48ee09e

Browse files
Alejandro Gómez Cerezoolivermontes
authored andcommitted
feat: store absolute attachment paths in message content for AI context
## Summary Enables text-based AI models to reference images and other attachments from previous generations by storing absolute file paths as hidden markers in the message content. This allows seamless multi-model conversations where image-generating models (DALL-E, Flux) can pass their output to text models. ## Changes ### Backend Changes - Add `getBasePath()` IPC handler to expose attachment storage directory path - Update `AttachmentStorage` constructor documentation to clarify Electron userData paths across platforms (macOS, Windows, Linux) ### Frontend State Management - Modify `persistMessage()` to generate content markers when messages have attachments but no text content - Format: `[attachment:type:/absolute/path:filename]` - Implement absolute path resolution via IPC to `attachmentStorage.getBasePath()` - Return generated content from `persistMessage()` for UI synchronization - Track generated content separately to avoid duplicating in state ### UI Rendering - Filter attachment markers from visible content in `Response` component so users don't see the internal markers - Support both new format `[attachment:...]` and old markdown format `![...](levante://attachments/...)` - Preserve message rendering when content is only attachment markers ### Message State Synchronization - Update `ChatPage.tsx` to capture generated content from `persistMessage()` - Add generated content to message `parts` array for inclusion in AI context - Ensure message state stays in sync with persisted content ## Technical Details - Attachment markers are invisible to end users but included in AI context - Absolute paths ensure models can locate files regardless of session - Backward compatible with existing messages and old markdown format - All changes preserve type safety with proper TypeScript updates ## Migration - Script updated to use correct Electron userData path - Previous: ~/levante/attachments/ - Correct: ~/Library/Application Support/Levante/attachments/ (macOS)
1 parent f54dab5 commit 48ee09e

File tree

7 files changed

+115
-21
lines changed

7 files changed

+115
-21
lines changed

src/main/ipc/attachmentHandlers.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,5 +164,14 @@ export function setupAttachmentHandlers() {
164164
}
165165
});
166166

167+
// Get base path for attachments
168+
ipcMain.removeHandler('levante/attachments/base-path');
169+
ipcMain.handle('levante/attachments/base-path', () => {
170+
return {
171+
success: true,
172+
data: attachmentStorage.getBasePath()
173+
};
174+
});
175+
167176
logger.ipc.info('Attachment IPC handlers registered');
168177
}

src/main/services/attachmentStorage.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,10 @@ export class AttachmentStorage {
2323
private baseDir: string;
2424

2525
constructor() {
26-
// Store attachments in user data directory: ~/levante/attachments/
26+
// Store attachments in Electron userData directory
27+
// macOS: ~/Library/Application Support/Levante/attachments/
28+
// Windows: %APPDATA%/Levante/attachments/
29+
// Linux: ~/.config/Levante/attachments/
2730
const userData = app.getPath('userData');
2831
this.baseDir = path.join(userData, 'attachments');
2932
this.ensureBaseDirExists();

src/preload/api/attachments.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,9 @@ export const attachmentsApi = {
4949
* Get storage statistics
5050
*/
5151
stats: () => ipcRenderer.invoke('levante/attachments/stats'),
52+
53+
/**
54+
* Get base path for attachments storage
55+
*/
56+
getBasePath: () => ipcRenderer.invoke('levante/attachments/base-path'),
5257
};

src/preload/preload.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -577,6 +577,11 @@ export interface LevanteAPI {
577577
data?: { totalSize: number; fileCount: number };
578578
error?: string;
579579
}>;
580+
getBasePath: () => Promise<{
581+
success: boolean;
582+
data?: string;
583+
error?: string;
584+
}>;
580585
};
581586

582587
// Analytics functionality

src/renderer/components/ai-elements/response.tsx

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -71,21 +71,41 @@ const processContentWithMermaid = (content: string) => {
7171
return parts.length > 1 ? parts : [{ type: 'text', content }];
7272
};
7373

74+
// Filter out attachment markers from content (they're for AI context only)
75+
const filterAttachmentMarkers = (content: string): string => {
76+
return content
77+
// New format: [attachment:type:path:filename]
78+
.replace(/\[attachment:[^\]]+\]\n?/g, '')
79+
// Old format: ![filename](levante://attachments/...)
80+
.replace(/!\[[^\]]*\]\(levante:\/\/attachments\/[^)]*\)\n?/g, '')
81+
.trim();
82+
};
83+
7484
export const Response = memo(
7585
({ className, children, ...props }: ResponseProps) => {
7686
const [shouldProcessMermaid, setShouldProcessMermaid] = useState(false);
7787
const { streamFinished } = useStreamingContext();
7888
// Streamdown expects [lightTheme, darkTheme] tuple
7989
const shikiTheme: [BundledTheme, BundledTheme] = ['github-light', 'github-dark'];
8090

91+
// Filter attachment markers from string content
92+
const filteredChildren = typeof children === 'string'
93+
? filterAttachmentMarkers(children)
94+
: children;
95+
8196
// Listen for streaming finish events
8297
useEffect(() => {
83-
if (typeof children === 'string' && children.includes('```mermaid')) {
98+
if (typeof filteredChildren === 'string' && filteredChildren.includes('```mermaid')) {
8499
setShouldProcessMermaid(true);
85100
}
86-
}, [streamFinished, children]);
101+
}, [streamFinished, filteredChildren]);
102+
103+
// Don't render anything if content is only attachment markers
104+
if (typeof filteredChildren === 'string' && !filteredChildren) {
105+
return null;
106+
}
87107

88-
if (typeof children !== 'string') {
108+
if (typeof filteredChildren !== 'string') {
89109
return (
90110
<Streamdown
91111
className={cn(
@@ -98,17 +118,17 @@ export const Response = memo(
98118
shikiTheme={shikiTheme}
99119
{...props}
100120
>
101-
{children}
121+
{filteredChildren}
102122
</Streamdown>
103123
);
104124
}
105125

106126
// Check if content has complete mermaid blocks
107-
const hasCompleteMermaid = /```mermaid\s*\n[\s\S]*?\n\s*```/.test(children);
127+
const hasCompleteMermaid = /```mermaid\s*\n[\s\S]*?\n\s*```/.test(filteredChildren);
108128

109129
// If we should process Mermaid and have complete blocks, do so
110130
if (shouldProcessMermaid && hasCompleteMermaid) {
111-
const parts = processContentWithMermaid(children);
131+
const parts = processContentWithMermaid(filteredChildren);
112132

113133
if (parts.length > 1 || (parts.length === 1 && parts[0].type === 'mermaid')) {
114134
return (
@@ -150,7 +170,7 @@ export const Response = memo(
150170
shikiTheme={shikiTheme}
151171
{...props}
152172
>
153-
{children}
173+
{filteredChildren}
154174
</Streamdown>
155175
);
156176
}

src/renderer/pages/ChatPage.tsx

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -266,22 +266,42 @@ const ChatPage = () => {
266266
attachments: messageWithAttachments.attachments,
267267
});
268268

269-
await persistMessage(messageWithAttachments);
269+
const persistResult = await persistMessage(messageWithAttachments);
270270

271-
// Update the message in useChat state to include attachments
271+
// Update the message in useChat state to include attachments and generated content
272272
if (generatedAttachments.length > 0) {
273273
logger.core.info('Updating message state with attachments', {
274274
messageId: message.id,
275275
attachmentCount: generatedAttachments.length,
276+
hasGeneratedContent: !!persistResult?.generatedContent,
276277
});
277278

278279
// Find and update the message in the messages array
279280
setMessages((prevMessages) =>
280-
prevMessages.map((m) =>
281-
m.id === message.id
282-
? { ...m, attachments: generatedAttachments } as any
283-
: m
284-
)
281+
prevMessages.map((m) => {
282+
if (m.id !== message.id) return m;
283+
284+
// Build updated message with attachments
285+
const updatedMessage: any = {
286+
...m,
287+
attachments: generatedAttachments,
288+
};
289+
290+
// If content was generated from attachments, add it to parts
291+
if (persistResult?.generatedContent) {
292+
const existingParts = m.parts || [];
293+
const hasTextPart = existingParts.some((p: any) => p.type === 'text');
294+
295+
if (!hasTextPart) {
296+
updatedMessage.parts = [
297+
...existingParts,
298+
{ type: 'text', text: persistResult.generatedContent }
299+
];
300+
}
301+
}
302+
303+
return updatedMessage;
304+
})
285305
);
286306
}
287307
}

src/renderer/stores/chatStore.ts

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@ interface ChatStore {
4141
startNewChat: () => void;
4242

4343
// Message persistence (called by useChat onFinish callback)
44-
persistMessage: (message: UIMessage) => Promise<void>;
44+
// Returns generated content if attachments were converted to text markers
45+
persistMessage: (message: UIMessage) => Promise<{ generatedContent?: string } | void>;
4546
loadHistoricalMessages: (sessionId: string) => Promise<UIMessage[]>;
4647

4748
// Deep link actions
@@ -334,22 +335,51 @@ export const useChatStore = create<ChatStore>()(
334335
},
335336

336337
// Message persistence
337-
persistMessage: async (message: UIMessage) => {
338+
persistMessage: async (message: UIMessage): Promise<{ generatedContent?: string } | void> => {
338339
const { currentSession } = get();
339340

340341
if (!currentSession) {
341342
logger.database.warn('Cannot persist message: no active session');
342343
return;
343344
}
344345

346+
let generatedContent: string | undefined;
347+
345348
try {
346349
// Extract text content from parts
347350
const textParts = message.parts.filter((p) => p.type === 'text');
348-
const content = textParts
351+
let content = textParts
349352
.map((p: any) => p.text)
350353
.join('\n')
351354
.trim();
352355

356+
// Extract attachments if present (from UIMessage extension)
357+
const attachments = (message as any).attachments || undefined;
358+
359+
// If no text content but has attachments, generate hidden content marker for AI context
360+
if (!content && attachments && attachments.length > 0) {
361+
// Get base path for absolute paths
362+
const basePathResult = await window.levante.attachments.getBasePath();
363+
const basePath = basePathResult.success ? basePathResult.data : '';
364+
365+
content = attachments
366+
.map((att: any) => {
367+
const relativePath = att.path || att.storagePath;
368+
const absolutePath = basePath ? `${basePath}/${relativePath}` : relativePath;
369+
return `[attachment:${att.type}:${absolutePath}:${att.filename}]`;
370+
})
371+
.join('\n');
372+
373+
// Store generated content to return to caller
374+
generatedContent = content;
375+
376+
logger.core.info('Generated content from attachments', {
377+
messageId: message.id,
378+
attachmentCount: attachments.length,
379+
generatedContent: content,
380+
});
381+
}
382+
353383
// Extract tool calls from parts
354384
const toolCallParts = message.parts.filter((p) =>
355385
p.type.startsWith('tool-')
@@ -366,9 +396,6 @@ export const useChatStore = create<ChatStore>()(
366396
}));
367397
}
368398

369-
// Extract attachments if present (from UIMessage extension)
370-
const attachments = (message as any).attachments || undefined;
371-
372399
// Debug: Log attachments being persisted
373400
if (attachments) {
374401
logger.core.info('💾 Persisting message WITH attachments', {
@@ -436,6 +463,11 @@ export const useChatStore = create<ChatStore>()(
436463
error: err instanceof Error ? err.message : err,
437464
});
438465
}
466+
467+
// Return generated content if any (for updating UI state)
468+
if (generatedContent) {
469+
return { generatedContent };
470+
}
439471
},
440472

441473
loadHistoricalMessages: async (sessionId: string): Promise<UIMessage[]> => {

0 commit comments

Comments
 (0)