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
144 changes: 142 additions & 2 deletions electron/assistant-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ import {
AssistantFileStatus,
ListAssistantFilesFilter,
UploadAssistantFileParams,
ChatParams,
ChatResponse,
ChatStreamChunk,
ChatMessage,
Citation,
} from './types'

/**
Expand Down Expand Up @@ -86,7 +91,7 @@ export class AssistantService {
name: model.name,
status: model.status as AssistantModel['status'],
instructions: model.instructions ?? undefined,
metadata: model.metadata as Record<string, string> | undefined,
metadata: (model.metadata ?? undefined) as Record<string, string> | undefined,
host: model.host,
createdAt: model.createdAt?.toISOString(),
updatedAt: model.updatedAt?.toISOString(),
Expand Down Expand Up @@ -123,7 +128,8 @@ export class AssistantService {
const response = await assistant.uploadFile({
path: params.filePath,
metadata: params.metadata,
})
multimodal: params.multimodal,
} as Parameters<typeof assistant.uploadFile>[0])
return this.mapFileModel(response)
}

Expand Down Expand Up @@ -161,4 +167,138 @@ export class AssistantService {
updatedOn: file.updatedOn?.toISOString(),
}
}

// ============================================================================
// Chat Operations
// ============================================================================

/**
* Send a chat message to an assistant (non-streaming)
*/
async chat(assistantName: string, params: ChatParams): Promise<ChatResponse> {
const assistant = this.client.assistant(assistantName)
const response = await assistant.chat({
messages: params.messages.map(m => ({ role: m.role, content: m.content })),
model: params.model,
filter: params.filter,
jsonResponse: params.jsonResponse,
includeHighlights: params.includeHighlights,
temperature: params.temperature,
contextOptions: params.contextOptions,
} as Parameters<typeof assistant.chat>[0])

return {
id: response.id || crypto.randomUUID(),
message: {
role: 'assistant' as const,
content: response.message?.content || '',
},
citations: this.mapCitations(response.citations),
usage: response.usage ? {
promptTokens: response.usage.promptTokens || 0,
completionTokens: response.usage.completionTokens || 0,
totalTokens: response.usage.totalTokens || 0,
} : undefined,
model: response.model,
finishReason: response.finishReason,
}
}

/**
* Send a chat message to an assistant with streaming response
* @param onChunk Callback for each chunk received
* @param signal AbortSignal for cancellation
*/
async chatStream(
assistantName: string,
params: ChatParams,
onChunk: (chunk: ChatStreamChunk) => void,
signal?: AbortSignal
): Promise<void> {
const assistant = this.client.assistant(assistantName)

try {
const stream = await assistant.chat({
messages: params.messages.map(m => ({ role: m.role, content: m.content })),
model: params.model,
filter: params.filter,
stream: true,
})

// Process the stream
for await (const chunk of stream) {
if (signal?.aborted) {
break
}

// Map chunk type based on content
if (chunk.type === 'message_start') {
onChunk({
type: 'message_start',
id: chunk.id,
model: chunk.model,
role: 'assistant',
})
} else if (chunk.type === 'content_chunk' || chunk.contentDelta) {
onChunk({
type: 'content',
content: chunk.contentDelta || chunk.delta?.content || '',
})
} else if (chunk.type === 'citation') {
onChunk({
type: 'citation',
citation: this.mapSingleCitation(chunk.citation),
})
} else if (chunk.type === 'message_end') {
onChunk({
type: 'message_end',
usage: chunk.usage ? {
promptTokens: chunk.usage.promptTokens || 0,
completionTokens: chunk.usage.completionTokens || 0,
totalTokens: chunk.usage.totalTokens || 0,
} : undefined,
finishReason: chunk.finishReason,
})
}
}
} catch (error) {
if (signal?.aborted) {
return // Don't send error for intentional abort
}
onChunk({
type: 'error',
error: error instanceof Error ? error.message : 'Stream error',
})
}
}

/**
* Map SDK citations to our Citation type
*/
private mapCitations(citations?: unknown[]): Citation[] | undefined {
if (!citations || !Array.isArray(citations)) return undefined
return citations.map(c => this.mapSingleCitation(c)).filter((c): c is Citation => c !== undefined)
}

/**
* Map a single citation
*/
private mapSingleCitation(citation: unknown): Citation | undefined {
if (!citation || typeof citation !== 'object') return undefined
const c = citation as Record<string, unknown>
return {
position: (c.position as number) || 0,
references: Array.isArray(c.references) ? c.references.map((ref: unknown) => {
const r = ref as Record<string, unknown>
const file = r.file as Record<string, unknown> | undefined
return {
file: {
name: (file?.name as string) || '',
id: (file?.id as string) || '',
},
pages: r.pages as number[] | undefined,
}
}) : [],
}
}
}
148 changes: 146 additions & 2 deletions electron/main.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { app, BrowserWindow, ipcMain, Menu, MenuItemConstructorOptions, shell } from 'electron'
import { app, BrowserWindow, dialog, ipcMain, Menu, MenuItemConstructorOptions, shell } from 'electron'

// Redirect userData to test directory if running in test mode
// This MUST happen before any store initialization
Expand All @@ -9,6 +9,7 @@ if (process.env.NODE_ENV === 'test' && process.env.E2E_USER_DATA_DIR) {
// Set app name before anything else (affects menu bar, about dialog, etc.)
app.name = 'Pinecone Explorer'
import path from 'node:path'
import crypto from 'node:crypto'
import { fileURLToPath } from 'node:url'
import { pineconeConnectionPool } from './pinecone-service'
import { connectionStore } from './connection-store'
Expand Down Expand Up @@ -633,6 +634,46 @@ ipcMain.on('context-menu:show-namespace', (event, namespace: string) => {
}
})

// Assistant context menu handler
ipcMain.on('context-menu:show-assistant', (event, assistantName: string) => {
const template: MenuItemConstructorOptions[] = [
{
label: 'Edit',
click: () => event.sender.send('context-menu:assistant-action', { action: 'edit', assistantName })
},
{ type: 'separator' },
{
label: 'Delete',
click: () => event.sender.send('context-menu:assistant-action', { action: 'delete', assistantName })
}
]
const menu = Menu.buildFromTemplate(template)
const win = BrowserWindow.fromWebContents(event.sender)
if (win) {
menu.popup({ window: win })
}
})

// File context menu handler
ipcMain.on('context-menu:show-file', (event, assistantName: string, fileId: string, fileName: string) => {
const template: MenuItemConstructorOptions[] = [
{
label: 'Download',
click: () => event.sender.send('context-menu:file-action', { action: 'download', assistantName, fileId, fileName })
},
{ type: 'separator' },
{
label: 'Delete',
click: () => event.sender.send('context-menu:file-action', { action: 'delete', assistantName, fileId, fileName })
}
]
const menu = Menu.buildFromTemplate(template)
const win = BrowserWindow.fromWebContents(event.sender)
if (win) {
menu.popup({ window: win })
}
})

// ============================================================================
// Profile Management IPC Handlers
// ============================================================================
Expand Down Expand Up @@ -910,7 +951,7 @@ ipcMain.handle('assistant:files:describe', async (_event, profileId: string, ass
}
})

ipcMain.handle('assistant:files:upload', async (_event, profileId: string, assistantName: string, params: { filePath: string; metadata?: Record<string, string | number> }) => {
ipcMain.handle('assistant:files:upload', async (_event, profileId: string, assistantName: string, params: { filePath: string; metadata?: Record<string, string | number>; multimodal?: boolean }) => {
try {
const service = pineconeConnectionPool.getConnection(profileId)
if (!service) {
Expand Down Expand Up @@ -940,6 +981,85 @@ ipcMain.handle('assistant:files:delete', async (_event, profileId: string, assis
}
})

// ============================================================================
// Assistant Chat IPC Handlers
// ============================================================================

// Track active chat streams for cancellation
const activeChatStreams: Map<string, AbortController> = new Map()

ipcMain.handle('assistant:chat', async (_event, profileId: string, assistantName: string, params: { messages: Array<{ role: 'user' | 'assistant'; content: string }>; model?: string; filter?: Record<string, unknown> }) => {
try {
const service = pineconeConnectionPool.getConnection(profileId)
if (!service) {
return { success: false, error: 'Not connected to Pinecone' }
}
const assistantService = service.getAssistantService()
const response = await assistantService.chat(assistantName, params)
return { success: true, data: response }
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to send chat message'
return { success: false, error: message }
}
})

ipcMain.handle('assistant:chat:stream:start', async (event, profileId: string, assistantName: string, params: { messages: Array<{ role: 'user' | 'assistant'; content: string }>; model?: string; filter?: Record<string, unknown> }) => {
try {
const service = pineconeConnectionPool.getConnection(profileId)
if (!service) {
return { success: false, error: 'Not connected to Pinecone' }
}

const streamId = crypto.randomUUID()
const abortController = new AbortController()
activeChatStreams.set(streamId, abortController)

const assistantService = service.getAssistantService()

// Start streaming in background
assistantService.chatStream(
assistantName,
params,
(chunk) => {
// Send chunk to renderer if still connected
if (!event.sender.isDestroyed()) {
event.sender.send('assistant:chat:chunk', streamId, chunk)
}
},
abortController.signal
).catch((error) => {
// Send error chunk to renderer if still connected
if (!event.sender.isDestroyed()) {
event.sender.send('assistant:chat:chunk', streamId, {
type: 'error',
error: error instanceof Error ? error.message : 'Stream error'
})
}
}).finally(() => {
activeChatStreams.delete(streamId)
})

return { success: true, data: { streamId } }
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to start chat stream'
return { success: false, error: message }
}
})

ipcMain.handle('assistant:chat:stream:cancel', async (_event, streamId: string) => {
try {
const controller = activeChatStreams.get(streamId)
if (controller) {
controller.abort()
activeChatStreams.delete(streamId)
}
return { success: true }
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to cancel chat stream'
return { success: false, error: message }
}
})

// ============================================================================
// Window Management IPC Handlers
// ============================================================================
Expand Down Expand Up @@ -1112,6 +1232,30 @@ ipcMain.handle('shell:openExternal', async (_event, url: string) => {
}
})

// ============================================================================
// Dialog IPC Handlers
// ============================================================================

ipcMain.handle('dialog:showOpenDialog', async (_event, options: {
properties?: Array<'openFile' | 'openDirectory' | 'multiSelections' | 'showHiddenFiles'>
filters?: Array<{ name: string; extensions: string[] }>
title?: string
defaultPath?: string
}) => {
try {
const result = await dialog.showOpenDialog({
properties: options.properties || ['openFile'],
filters: options.filters,
title: options.title,
defaultPath: options.defaultPath,
})
return { success: true, data: result }
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to show dialog'
return { success: false, error: message }
}
})

// ============================================================================
// App Lifecycle
// ============================================================================
Expand Down
2 changes: 2 additions & 0 deletions electron/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -482,5 +482,7 @@ export interface UploadAssistantFileParams {
filePath: string
/** Optional metadata to attach to the file */
metadata?: Record<string, string | number>
/** Enable multimodal processing for PDFs (extracts images and charts) */
multimodal?: boolean
}

2 changes: 1 addition & 1 deletion src/components/files/UploadFileDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ export function UploadFileDialog({
} catch (err) {
setUploadError(err instanceof Error ? err.message : 'Failed to upload file')
}
}, [selectedFile, metadataJson, validateMetadata, uploadMutation, onOpenChange])
}, [selectedFile, metadataJson, validateMetadata, uploadMutation, onOpenChange, multimodal])

// Format file size
const formatFileSize = (bytes: number): string => {
Expand Down
Loading