This commit is contained in:
Damien 2025-02-04 13:38:48 -05:00
parent 086e311aa4
commit 1cebb80b08
10 changed files with 378 additions and 163 deletions

1
.gitignore vendored
View File

@ -39,3 +39,4 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
/electron-file-search/datahound-win32-x64

9
Modelfile Normal file
View File

@ -0,0 +1,9 @@
FROM deepseek-r1:1.5b
# Disable GPU usage
PARAMETER num_gpu 0
# Increase context length
PARAMETER max_tokens 16384
# Set temperature to 0.6
PARAMETER temperature 0.6

View File

@ -148,7 +148,7 @@ export class LLMService {
case 'ollama': case 'ollama':
const ollamaResponse = await ollamaService.chat({ const ollamaResponse = await ollamaService.chat({
model: this.#config.model || 'phi4:latest', model: this.#config.model || 'llama2:7b',
messages: [{ role: 'user', content: question }], messages: [{ role: 'user', content: question }],
temperature: this.#config.temperature, temperature: this.#config.temperature,
onChunk, onChunk,
@ -195,7 +195,7 @@ export class LLMService {
const cleanConfig = { const cleanConfig = {
provider: newConfig.provider, provider: newConfig.provider,
apiKey: newConfig.apiKey ?? null, apiKey: newConfig.apiKey ?? null,
model: newConfig.model ?? (newConfig.provider === 'ollama' ? 'phi4' : null), model: newConfig.model ?? (newConfig.provider === 'ollama' ? 'llama2:7b' : null),
baseUrl: newConfig.provider === 'ollama' ? (newConfig.baseUrl ?? 'http://localhost:11434') : (newConfig.baseUrl ?? null), baseUrl: newConfig.provider === 'ollama' ? (newConfig.baseUrl ?? 'http://localhost:11434') : (newConfig.baseUrl ?? null),
temperature: typeof newConfig.temperature === 'number' ? newConfig.temperature : 0.7 temperature: typeof newConfig.temperature === 'number' ? newConfig.temperature : 0.7
}; };

View File

@ -58,7 +58,7 @@ const schema: Store.Schema<Schema> = {
additionalProperties: false, additionalProperties: false,
default: { default: {
provider: 'ollama', provider: 'ollama',
model: 'phi4', model: 'llama2:7b',
baseUrl: 'http://localhost:11434', baseUrl: 'http://localhost:11434',
temperature: 0.7 temperature: 0.7
} }

View File

@ -17,6 +17,7 @@
"chokidar": "^3.5.3", "chokidar": "^3.5.3",
"electron-store": "^8.1.0", "electron-store": "^8.1.0",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"lucide-react": "^0.474.0",
"ollama": "^0.5.12", "ollama": "^0.5.12",
"openai": "^4.82.0", "openai": "^4.82.0",
"openrouter-client": "^1.2.0", "openrouter-client": "^1.2.0",
@ -6546,6 +6547,15 @@
"yallist": "^3.0.2" "yallist": "^3.0.2"
} }
}, },
"node_modules/lucide-react": {
"version": "0.474.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.474.0.tgz",
"integrity": "sha512-CmghgHkh0OJNmxGKWc0qfPJCYHASPMVSyGY8fj3xgk4v84ItqDg64JNKFZn5hC6E0vHi6gxnbCgwhyVB09wQtA==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/matcher": { "node_modules/matcher": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz",

View File

@ -20,6 +20,7 @@
"chokidar": "^3.5.3", "chokidar": "^3.5.3",
"electron-store": "^8.1.0", "electron-store": "^8.1.0",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"lucide-react": "^0.474.0",
"ollama": "^0.5.12", "ollama": "^0.5.12",
"openai": "^4.82.0", "openai": "^4.82.0",
"openrouter-client": "^1.2.0", "openrouter-client": "^1.2.0",

View File

@ -149,6 +149,18 @@ function AppContent() {
}, },
}} }}
> >
<Box
sx={{
display: 'flex',
flex: 1,
width: '100%',
height: '100%',
justifyContent: 'center',
alignItems: 'center',
}}
>
Data Hound V2
</Box>
<div className="window-controls"> <div className="window-controls">
<button <button
className="control-button" className="control-button"
@ -249,12 +261,45 @@ function AppContent() {
> >
<TextField <TextField
fullWidth fullWidth
multiline
variant="outlined" variant="outlined"
placeholder="Ask a question..." placeholder="Ask a question..."
value={input} value={input}
onChange={(e) => setInput(e.target.value)} onChange={(e) => setInput(e.target.value)}
disabled={isLoading} disabled={isLoading}
size="small" size="small"
minRows={1}
maxRows={4}
onKeyDown={(event) => {
if (event.key === 'Enter') {
if (event.shiftKey) {
return; // Allow new line with Shift+Enter
}
event.preventDefault();
handleSubmit(event);
}
}}
InputProps={{
style: {
resize: 'vertical'
},
sx: {
'&::-webkit-scrollbar': {
width: '8px',
background: 'transparent',
},
'&::-webkit-scrollbar-thumb': {
background: (theme) => theme.palette.primary.main, // Using your theme's primary blue
borderRadius: '2px', // Made more square
'&:hover': {
background: (theme) => theme.palette.primary.light, // Lighter blue on hover
},
},
'&::-webkit-scrollbar-track': {
background: 'transparent',
},
}
}}
/> />
<IconButton <IconButton
type="submit" type="submit"

View File

@ -1,9 +1,10 @@
import React, { useEffect, useRef } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { Box, Typography, Paper, Avatar } from '@mui/material'; import { Box, Typography, Paper, Avatar, Collapse, IconButton } from '@mui/material';
import type { DocumentMetadata } from '../../../electron/types'; import { Loader2, ChevronDown, ChevronUp } from 'lucide-react';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import rehypeHighlight from 'rehype-highlight'; import rehypeHighlight from 'rehype-highlight';
import 'highlight.js/styles/github.css'; import 'highlight.js/styles/github.css';
import type { DocumentMetadata } from '../../../electron/types';
interface ChatMessage { interface ChatMessage {
id: string; id: string;
@ -18,91 +19,139 @@ interface MessageListProps {
messages: ChatMessage[]; messages: ChatMessage[];
} }
export default function MessageList({ messages }: MessageListProps) { interface ThinkingContentProps {
const messageListRef = useRef<HTMLDivElement>(null); content: string;
isThinking: boolean;
useEffect(() => {
if (messageListRef.current) {
messageListRef.current.scrollTop = messageListRef.current.scrollHeight;
} }
}, [messages]);
interface MessageSegment {
type: 'text' | 'thinking';
content: string;
inProgress?: boolean;
}
interface MessageContentProps {
text: string;
}
const ThinkingContent: React.FC<ThinkingContentProps> = ({ content, isThinking }) => {
const [isExpanded, setIsExpanded] = useState<boolean>(false);
return ( return (
<Box sx={{ mt: 1 }}>
<Box <Box
ref={messageListRef}
sx={{
flex: 1,
overflow: 'auto',
p: 2,
display: 'flex',
flexDirection: 'column',
gap: 2,
scrollBehavior: 'smooth',
'&::-webkit-scrollbar': {
width: '8px',
background: 'transparent',
},
'&::-webkit-scrollbar-thumb': {
background: (theme) => theme.palette.divider,
borderRadius: '4px',
'&:hover': {
background: (theme) => theme.palette.action.hover,
},
},
'&::-webkit-scrollbar-track': {
background: 'transparent',
},
}}>
{messages.map((message) => (
<Box
key={message.id}
sx={{ sx={{
display: 'flex', display: 'flex',
flexDirection: message.isUser ? 'row-reverse' : 'row', alignItems: 'center',
alignItems: 'flex-start', gap: 1,
gap: 2, cursor: 'pointer',
maxWidth: '80%', color: 'text.secondary'
alignSelf: message.isUser ? 'flex-end' : 'flex-start',
}} }}
onClick={() => setIsExpanded(!isExpanded)}
> >
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 0.5 }}> <IconButton size="small">
<Avatar {isExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
src={message.isUser ? '/profiles/user-profile.webp' : '/profiles/ai-profile.webp'} </IconButton>
alt={message.isUser ? 'User' : 'AI'} <Typography variant="caption" sx={{ fontWeight: 500 }}>
variant="square" Thinking Process
</Typography>
{isThinking && (
<Box
component={Loader2}
size={16}
className="animate-spin"
sx={{ sx={{
width: 40, animation: 'spin 1s linear infinite',
height: 40, '@keyframes spin': {
boxShadow: 1 '0%': {
transform: 'rotate(0deg)',
},
'100%': {
transform: 'rotate(360deg)',
},
},
}} }}
/> />
<Typography )}
variant="caption"
sx={{
fontSize: '0.75rem',
color: 'text.secondary',
fontWeight: 500
}}
>
{message.isUser ? 'User' : 'Data Hound'}
</Typography>
</Box> </Box>
<Box <Collapse in={isExpanded}>
component={Paper} <Box sx={{
elevation={1} pl: 4,
sx={{ pr: 2,
p: 2, py: 1,
flex: 1, mt: 1,
bgcolor: message.isUser ? 'primary.main' : 'background.paper', borderLeft: '2px solid',
color: message.isUser ? 'primary.contrastText' : 'text.primary', borderColor: 'divider',
transition: 'all 0.2s ease-in-out', color: 'text.secondary'
boxShadow: 1, }}>
borderRadius: 2, <ReactMarkdown
'&:hover': { className="markdown-body"
boxShadow: 2, rehypePlugins={[rehypeHighlight]}
},
}}
> >
{content}
</ReactMarkdown>
</Box>
</Collapse>
</Box>
);
};
const MessageContent: React.FC<MessageContentProps> = ({ text }) => {
const [segments, setSegments] = useState<MessageSegment[]>([]);
const [isThinking, setIsThinking] = useState<boolean>(false);
useEffect(() => {
const parseContent = (): void => {
const parts: MessageSegment[] = [];
let remainingText = text;
// Handle <think> tags
const thinkRegex = /<think>([\s\S]*?)(?:<\/think>|$)/g;
let lastIndex = 0;
let match;
while ((match = thinkRegex.exec(text)) !== null) {
// Add text before the think tag if there is any
const beforeText = text.slice(lastIndex, match.index).trim();
if (beforeText) {
parts.push({
type: 'text',
content: beforeText
});
}
// Add the thinking content
parts.push({
type: 'thinking',
content: match[1],
inProgress: !match[0].endsWith('</think>')
});
lastIndex = match.index + match[0].length;
}
// Add any remaining text after the last think tag
const afterText = text.slice(lastIndex).trim();
if (afterText) {
parts.push({
type: 'text',
content: afterText
});
}
setSegments(parts);
setIsThinking(parts.some(part => part.type === 'thinking' && part.inProgress));
};
parseContent();
}, [text]);
return (
<Box>
{segments.map((segment, index) => {
if (segment.type === 'text' && segment.content.trim()) {
return (
<Box key={index}>
<Box sx={{ <Box sx={{
'& .markdown-body': { '& .markdown-body': {
whiteSpace: 'pre-wrap', whiteSpace: 'pre-wrap',
@ -158,20 +207,95 @@ export default function MessageList({ messages }: MessageListProps) {
className="markdown-body" className="markdown-body"
rehypePlugins={[rehypeHighlight]} rehypePlugins={[rehypeHighlight]}
> >
{message.text} {segment.content}
</ReactMarkdown> </ReactMarkdown>
</Box> </Box>
{message.sources && message.sources.length > 0 && ( </Box>
<Box sx={{ mt: 2, pt: 1, borderTop: '1px solid', borderColor: 'divider' }}> );
<Typography }
variant="caption" if (segment.type === 'thinking') {
color="text.secondary" return (
<Box key={index}>
<ThinkingContent
content={segment.content}
isThinking={segment.inProgress || false}
/>
</Box>
);
}
return null;
})}
</Box>
);
};
const MessageList: React.FC<MessageListProps> = ({ messages }) => {
// Memoize messages to preserve their original timestamps
const memoizedMessages = React.useMemo(() => messages, [messages]);
const messageListRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (messageListRef.current) {
messageListRef.current.scrollTop = messageListRef.current.scrollHeight;
}
}, [messages]);
return (
<Box
ref={messageListRef}
sx={{ sx={{
display: 'block', flex: 1,
mb: 0.5, overflow: 'auto',
fontWeight: 'medium' p: 2,
display: 'flex',
flexDirection: 'column',
gap: 2,
scrollBehavior: 'smooth',
'&::-webkit-scrollbar': {
width: '8px',
background: 'transparent',
},
'&::-webkit-scrollbar-thumb': {
background: (theme) => theme.palette.divider,
borderRadius: '4px',
'&:hover': {
background: (theme) => theme.palette.action.hover,
},
},
}} }}
> >
{memoizedMessages.map((message) => (
<Box
key={message.id}
sx={{
display: 'flex',
flexDirection: message.isUser ? 'row-reverse' : 'row',
alignItems: 'flex-start',
gap: 2,
maxWidth: '80%',
alignSelf: message.isUser ? 'flex-end' : 'flex-start',
}}
>
<Avatar
src={message.isUser ? '/profiles/user-profile.webp' : '/profiles/ai-profile.webp'}
alt={message.isUser ? 'User' : 'AI'}
sx={{ width: 40, height: 40 }}
/>
<Paper
elevation={1}
sx={{
px: 2,
py: 1.5,
flex: 1,
bgcolor: message.isUser ? 'primary.main' : 'background.paper',
color: message.isUser ? 'primary.contrastText' : 'text.primary',
borderRadius: 2,
}}
>
<MessageContent text={message.text} />
{message.sources && message.sources.length > 0 && (
<Box sx={{ mt: 2, pt: 1, borderTop: '1px solid', borderColor: 'divider' }}>
<Typography variant="caption" color="text.secondary">
Sources: Sources:
</Typography> </Typography>
{message.sources.map((source, index) => ( {message.sources.map((source, index) => (
@ -180,27 +304,17 @@ export default function MessageList({ messages }: MessageListProps) {
variant="caption" variant="caption"
component="div" component="div"
color="text.secondary" color="text.secondary"
sx={{
pl: 1,
borderLeft: '2px solid',
borderColor: 'divider'
}}
> >
{source.path} {source.path}
</Typography> </Typography>
))} ))}
</Box> </Box>
)} )}
<Typography </Paper>
variant="caption"
color={message.isUser ? '#ffffff' : 'text.secondary'}
sx={{ display: 'block', mt: 0.5 }}
>
{new Date(message.timestamp).toLocaleTimeString()}
</Typography>
</Box>
</Box> </Box>
))} ))}
</Box> </Box>
); );
} };
export default MessageList;

View File

@ -13,6 +13,8 @@ import {
Alert, Alert,
Box, Box,
Typography, Typography,
TextField,
InputAdornment,
} from '@mui/material'; } from '@mui/material';
import { import {
Folder as FolderIcon, Folder as FolderIcon,
@ -21,7 +23,10 @@ import {
Cloud as DropboxIcon, Cloud as DropboxIcon,
Chat as DiscordIcon, Chat as DiscordIcon,
Computer as LocalIcon, Computer as LocalIcon,
Search,
Padding
} from '@mui/icons-material'; } from '@mui/icons-material';
import { useElectron } from '../../hooks/useElectron'; import { useElectron } from '../../hooks/useElectron';
interface Directory { interface Directory {
@ -105,16 +110,46 @@ export default function DirectoryPicker({ onSelect }: DirectoryPickerProps) {
mr: 2, mr: 2,
mb: 2, mb: 2,
}}> }}>
<Typography sx={{ pl:2 ,flexGrow: 1 }} variant="h6">Folders</Typography>
<Button <TextField
fullWidth
variant="outlined"
size="small"
placeholder="Search..."
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Search className="text-gray-400"/>
</InputAdornment>
),
sx: {ml:2,
'&::-webkit-scrollbar': {
width: '8px',
background: 'transparent',
},
'&::-webkit-scrollbar-thumb': {
background: '#2196f3',
borderRadius: '2px',
'&:hover': {
background: '#64b5f6',
},
},
'&::-webkit-scrollbar-track': {
background: 'transparent',
}
}
}}
/> <Button
variant="contained" variant="contained"
onClick={handleOpen} onClick={handleOpen}
size="small" size="small"
sx={{ sx={{
ml:3,
minWidth: 0, minWidth: 0,
width: '32px', width: '64px',
height: '32px', height: '38px',
borderRadius: '50%', borderRadius: '5%',
padding: 0, padding: 0,
overflow: 'hidden', overflow: 'hidden',
backgroundColor: 'primary.main', backgroundColor: 'primary.main',
@ -130,10 +165,10 @@ export default function DirectoryPicker({ onSelect }: DirectoryPickerProps) {
}, },
'&:hover': { '&:hover': {
backgroundColor: 'primary.dark', backgroundColor: 'primary.dark',
width: '180px', width: '400%',
borderRadius: '16px', borderRadius: '5%',
'& .buttonText': { '& .buttonText': {
width: '110px', width: '150px',
marginLeft: '8px', marginLeft: '8px',
}, },
}, },

View File

@ -22,7 +22,7 @@ export function useLLMConfig() {
}); });
setConfig(config || { setConfig(config || {
provider: 'ollama', provider: 'ollama',
model: 'phi4', model: 'llama2:7b',
baseUrl: 'http://localhost:11434', baseUrl: 'http://localhost:11434',
temperature: 0.7, temperature: 0.7,
apiKey: undefined apiKey: undefined