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

/**
Expand Down Expand Up @@ -91,7 +86,7 @@ export class AssistantService {
name: model.name,
status: model.status as AssistantModel['status'],
instructions: model.instructions ?? undefined,
metadata: (model.metadata ?? undefined) as Record<string, string> | undefined,
metadata: model.metadata as Record<string, string> | undefined,
host: model.host,
createdAt: model.createdAt?.toISOString(),
updatedAt: model.updatedAt?.toISOString(),
Expand Down Expand Up @@ -166,137 +161,4 @@ 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,
// @ts-expect-error - SDK types may not include all options
jsonResponse: params.jsonResponse,
includeHighlights: params.includeHighlights,
})

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,
}
}) : [],
}
}
}
136 changes: 1 addition & 135 deletions electron/main.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { app, BrowserWindow, dialog, ipcMain, Menu, MenuItemConstructorOptions, shell } from 'electron'
import { app, BrowserWindow, 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,7 +9,6 @@ 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 @@ -634,46 +633,6 @@ 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 @@ -981,75 +940,6 @@ 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
event.sender.send('assistant:chat:chunk', streamId, chunk)
},
abortController.signal
).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 @@ -1222,30 +1112,6 @@ 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
Loading
Loading