This commit is contained in:
Damien 2025-02-04 13:39:50 -05:00
parent 240d8ece6a
commit 512e140b80
6 changed files with 221 additions and 73 deletions

View File

@ -78,6 +78,18 @@ export function setupIpcHandlers() {
}
});
// Ollama Operations
ipcMain.handle('check-ollama', async () => {
try {
const status = await (await import('../services/ollamaService')).ollamaService.checkOllamaInstallation();
return status;
} catch (error) {
const err = error as Error;
console.error('Error checking Ollama:', err);
throw err;
}
});
// Model Operations
ipcMain.handle('check-model', async (_: unknown, modelName: string) => {
try {

View File

@ -161,44 +161,6 @@ ipcMain.handle('window-close', (event) => {
window?.close();
});
ipcMain.handle('check-ollama', async () => {
const checkInstalled = () => {
return new Promise((resolve) => {
const check = spawn('ollama', ['--version']);
check.on('close', (code) => {
resolve(code === 0);
});
});
};
const checkRunning = async () => {
try {
const response = await fetch('http://localhost:11434/api/version');
return response.ok;
} catch (error) {
return false;
}
};
const startOllama = () => {
return new Promise<void>((resolve) => {
const start = spawn('ollama', ['serve']);
// Wait a bit for the server to start
setTimeout(resolve, 2000);
});
};
const installed = await checkInstalled();
let running = await checkRunning();
if (installed && !running) {
await startOllama();
running = await checkRunning();
}
return { installed, running };
});
ipcMain.handle('open-external', (_, url) => {
return require('electron').shell.openExternal(url);
});

View File

@ -65,7 +65,8 @@ contextBridge.exposeInMainWorld('electron', {
ipcRenderer.removeListener(channel, callback);
},
checkOllama: () => ipcRenderer.invoke('check-ollama'),
checkOllama: async (): Promise<{ installed: boolean; running: boolean }> =>
ipcRenderer.invoke('check-ollama'),
openExternal: (url: string) => ipcRenderer.invoke('open-external', url),
// Model Operations

View File

@ -1,5 +1,15 @@
import { ServiceError } from '../types';
import { net } from 'electron';
import { platform } from 'os';
const { exec } = require('child_process');
const { promisify } = require('util');
const execAsync = promisify(exec);
interface OllamaStatus {
installed: boolean;
running: boolean;
}
interface OllamaModel {
name: string;
@ -40,12 +50,31 @@ class OllamaService {
private baseUrl: string = 'http://127.0.0.1:11434';
private _lastProgress: number | null = null;
async checkOllamaInstallation(): Promise<OllamaStatus> {
try {
// Check if ollama binary exists
const cmd = platform() === 'win32' ? 'where ollama' : 'which ollama';
await execAsync(cmd);
// Check if Ollama server is running by attempting to connect to the API
try {
await this.makeRequest<any>('/api/tags');
return { installed: true, running: true };
} catch (error) {
return { installed: true, running: false };
}
} catch (error) {
return { installed: false, running: false };
}
}
private async makeRequest<T>(
path: string,
method: string = 'GET',
body?: any,
onChunk?: (chunk: string) => void
): Promise<T> {
let accumulatedContent = ''; // Add accumulator for chat content
return new Promise((resolve, reject) => {
try {
const url = new URL(path, this.baseUrl);
@ -88,8 +117,11 @@ class OllamaService {
try {
const parsed = JSON.parse(line);
if (path === '/api/chat' && parsed.message?.content && onChunk) {
onChunk(parsed.message.content);
if (path === '/api/chat' && parsed.message?.content) {
accumulatedContent += parsed.message.content;
if (onChunk) {
onChunk(parsed.message.content);
}
} else if (path === '/api/pull' && onChunk) {
if (parsed.status === 'success') {
onChunk('downloading: 100% complete');
@ -118,28 +150,26 @@ class OllamaService {
response.on('end', () => {
try {
if (path === '/api/chat' || path === '/api/pull') {
// Handle any remaining data in the streaming buffer
if (path === '/api/chat') {
// Process any remaining data in the buffer for chat
if (streamBuffer.trim()) {
try {
const parsed = JSON.parse(streamBuffer);
if (path === '/api/chat' && parsed.message?.content && onChunk) {
onChunk(parsed.message.content);
} else if (path === '/api/pull' && onChunk) {
if (parsed.status === 'success') {
onChunk('downloading: 100% complete');
} else if (parsed.total && parsed.completed !== undefined) {
const percentage = ((parsed.completed / parsed.total) * 100).toFixed(1);
onChunk(`downloading: ${percentage}% complete`);
} else if (parsed.status) {
onChunk(parsed.status);
}
if (parsed.message?.content) {
accumulatedContent += parsed.message.content;
}
} catch (e) {
console.warn('Failed to parse final chunk:', { buffer: streamBuffer, error: e });
console.warn('Failed to parse final chat chunk:', { buffer: streamBuffer, error: e });
}
}
// Resolve streaming endpoints with success response
// Resolve with the complete accumulated content
resolve({
message: {
content: accumulatedContent
}
} as T);
} else if (path === '/api/pull') {
// For pull, resolve with success
resolve({ success: true } as T);
} else {
// For non-streaming endpoints, parse the accumulated response
@ -216,11 +246,7 @@ class OllamaService {
onChunk
);
if (!response?.message) {
throw new Error('Invalid response format from Ollama');
}
return response;
return response as OllamaChatResponse;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to chat with Ollama';
console.error('Chat error:', {

View File

@ -1,5 +1,6 @@
import React, { useEffect, useRef } from 'react';
import { Box, Typography, Paper, Avatar } from '@mui/material';
import React, { useEffect, useRef, useState, useMemo } from 'react';
import { Box, Typography, Paper, Avatar, IconButton } from '@mui/material';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import type { DocumentMetadata } from '../../../electron/types';
import ReactMarkdown from 'react-markdown';
import rehypeHighlight from 'rehype-highlight';
@ -61,8 +62,8 @@ export default function MessageList({ messages }: MessageListProps) {
flexDirection: message.isUser ? 'row-reverse' : 'row',
alignItems: 'flex-start',
gap: 2,
maxWidth: '80%',
alignSelf: message.isUser ? 'flex-end' : 'flex-start',
maxWidth: message.isUser ? '80%' : '100%',
alignSelf: message.isUser ? 'flex-end' : 'stretch',
}}
>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 0.5 }}>
@ -91,7 +92,7 @@ export default function MessageList({ messages }: MessageListProps) {
component={Paper}
elevation={1}
sx={{
p: 2,
p: 2.5,
flex: 1,
bgcolor: message.isUser ? 'primary.main' : 'background.paper',
color: message.isUser ? 'primary.contrastText' : 'text.primary',
@ -154,15 +155,70 @@ export default function MessageList({ messages }: MessageListProps) {
}
}
}}>
<ReactMarkdown
className="markdown-body"
rehypePlugins={[rehypeHighlight]}
>
{message.text}
</ReactMarkdown>
{message.text.includes('<think>') ? (
message.text.split(/<think>|<\/think>/).map((segment, index) => {
if (index % 2 === 1) { // This is a thinking section
// Calculate thinking time - assume 1 character = 0.1s
const thinkingTime = (segment.length * 0.1).toFixed(1);
return (
<div key={index} className="thinking-section">
<div
className="thinking-header"
onClick={() => {
const content = document.getElementById(`thinking-content-${message.id}-${index}`);
const indicator = document.getElementById(`thinking-indicator-${message.id}-${index}`);
if (content && indicator) {
content.classList.toggle('open');
indicator.classList.toggle('open');
}
}}
>
<div className="thinking-header-left">
<ChevronRightIcon
id={`thinking-indicator-${message.id}-${index}`}
className="indicator"
/>
<span>Reasoning</span>
</div>
<span className="timestamp">(thought for {thinkingTime}s)</span>
</div>
<div
id={`thinking-content-${message.id}-${index}`}
className="thinking-content"
>
{segment}
</div>
</div>
);
} else if (segment.includes('Sorry, there was an error processing your request.')) {
return (
<div key={index} className="error-message">
{segment}
</div>
);
}
return (
<ReactMarkdown
key={index}
className="markdown-body"
rehypePlugins={[rehypeHighlight]}
>
{segment}
</ReactMarkdown>
);
})
) : (
<ReactMarkdown
className="markdown-body"
rehypePlugins={[rehypeHighlight]}
>
{message.text}
</ReactMarkdown>
)}
</Box>
{message.sources && message.sources.length > 0 && (
<Box sx={{ mt: 2, pt: 1, borderTop: '1px solid', borderColor: 'divider' }}>
<Box sx={{ mt: 1, pt: 0, borderTop: '1px solid', borderColor: 'divider' }}>
<Typography
variant="caption"
color="text.secondary"

View File

@ -14,3 +14,94 @@ html, body {
height: 100%;
width: 100%;
}
.error-message {
width: 100%;
background-color: rgba(211, 47, 47, 0.1);
padding: 8px 12px;
border-radius: 4px;
margin-bottom: 8px;
color: #d32f2f;
border: 1px solid rgba(211, 47, 47, 0.2);
}
[data-theme="dark"] .error-message {
background-color: rgba(211, 47, 47, 0.15);
color: #ef5350;
border-color: rgba(211, 47, 47, 0.3);
}
.thinking-section {
border-radius: 8px;
margin: 0 0 8px 0;
overflow: hidden;
background-color: #1976d2;
color: white;
}
[data-theme="dark"] .thinking-section {
background-color: #1565c0;
}
.thinking-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
cursor: pointer;
user-select: none;
color: rgba(255, 255, 255, 0.9);
font-size: 0.9rem;
background-color: rgba(0, 0, 0, 0.1);
transition: background-color 0.2s ease;
}
.thinking-header:hover {
background-color: rgba(0, 0, 0, 0.2);
}
[data-theme="dark"] .thinking-header {
color: rgba(255, 255, 255, 0.9);
background-color: rgba(0, 0, 0, 0.2);
}
[data-theme="dark"] .thinking-header:hover {
background-color: rgba(0, 0, 0, 0.3);
}
.thinking-header-left {
display: flex;
align-items: center;
gap: 8px;
}
.thinking-header .indicator {
width: 20px;
height: 20px;
transition: transform 0.3s ease;
color: rgba(255, 255, 255, 0.9);
}
.thinking-header .indicator.open {
transform: rotate(90deg);
}
.thinking-header .timestamp {
font-size: 0.85rem;
color: rgba(255, 255, 255, 0.7);
}
.thinking-content {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease-out;
padding: 0 16px;
color: rgba(255, 255, 255, 0.9);
line-height: 1.5;
background-color: rgba(0, 0, 0, 0.1);
}
.thinking-content.open {
max-height: 2000px;
padding: 16px;
}