Commit ad4ffdf7 authored by Jeffrey Morgan's avatar Jeffrey Morgan
Browse files

desktop: load model from file

parent a12df7fa
# Desktop
The Ollama desktop experience
## Running
```
npm install
npm start
```
## Packaging
```
npm run package
```
...@@ -13,7 +13,7 @@ const config: ForgeConfig = { ...@@ -13,7 +13,7 @@ const config: ForgeConfig = {
packagerConfig: { packagerConfig: {
asar: true, asar: true,
icon: './images/icon', icon: './images/icon',
extraResource: ['../server/dist/server'], extraResource: ['../dist/ollama'],
}, },
rebuildConfig: {}, rebuildConfig: {},
makers: [new MakerSquirrel({}), new MakerZIP({}, ['darwin']), new MakerRpm({}), new MakerDeb({})], makers: [new MakerSquirrel({}), new MakerZIP({}, ['darwin']), new MakerRpm({}), new MakerDeb({})],
...@@ -24,6 +24,7 @@ const config: ForgeConfig = { ...@@ -24,6 +24,7 @@ const config: ForgeConfig = {
devContentSecurityPolicy: `default-src * 'unsafe-eval' 'unsafe-inline'`, devContentSecurityPolicy: `default-src * 'unsafe-eval' 'unsafe-inline'`,
renderer: { renderer: {
config: rendererConfig, config: rendererConfig,
nodeIntegration: true,
entryPoints: [ entryPoints: [
{ {
html: './src/index.html', html: './src/index.html',
......
This diff is collapsed.
{ {
"name": "olama", "name": "ollama",
"productName": "ollama", "productName": "Ollama",
"version": "0.0.1", "version": "0.0.1",
"description": "Ollama", "description": "ollama",
"main": ".webpack/main", "main": ".webpack/main",
"scripts": { "scripts": {
"start": "electron-forge start", "start": "electron-forge start",
...@@ -46,6 +46,8 @@ ...@@ -46,6 +46,8 @@
"postcss-import": "^15.1.0", "postcss-import": "^15.1.0",
"postcss-loader": "^7.3.3", "postcss-loader": "^7.3.3",
"postcss-preset-env": "^8.5.1", "postcss-preset-env": "^8.5.1",
"prettier": "^2.8.8",
"prettier-plugin-tailwindcss": "^0.3.0",
"style-loader": "^3.3.3", "style-loader": "^3.3.3",
"tailwindcss": "^3.3.2", "tailwindcss": "^3.3.2",
"ts-loader": "^9.4.3", "ts-loader": "^9.4.3",
...@@ -56,6 +58,7 @@ ...@@ -56,6 +58,7 @@
"webpack-dev-server": "^4.15.1" "webpack-dev-server": "^4.15.1"
}, },
"dependencies": { "dependencies": {
"@electron/remote": "^2.0.10",
"@types/node": "^20.3.1", "@types/node": "^20.3.1",
"electron-squirrel-startup": "^1.0.0", "electron-squirrel-startup": "^1.0.0",
"react": "^18.2.0", "react": "^18.2.0",
......
...@@ -10,3 +10,21 @@ body { ...@@ -10,3 +10,21 @@ body {
.drag { .drag {
-webkit-app-region: drag; -webkit-app-region: drag;
} }
.blink {
-webkit-animation: 1s blink step-end infinite;
-moz-animation: 1s blink step-end infinite;
-ms-animation: 1s blink step-end infinite;
-o-animation: 1s blink step-end infinite;
animation: 1s blink step-end infinite;
}
@keyframes blink {
from,
to {
color: transparent;
}
50% {
color: black;
}
}
import { useState } from 'react' import { useState } from 'react'
import path from 'path'
import os from 'os'
import { dialog, getCurrentWindow } from '@electron/remote'
const API_URL = 'http://127.0.0.1:5001' const API_URL = 'http://127.0.0.1:5001'
type Message = { type Message = {
sender: string sender: 'bot' | 'human'
content: string content: string
} }
async function completion(prompt: string, callback: (res: string) => void) { const userInfo = os.userInfo()
async function generate(prompt: string, model: string, callback: (res: string) => void) {
const result = await fetch(`${API_URL}/generate`, { const result = await fetch(`${API_URL}/generate`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ body: JSON.stringify({
prompt: prompt, prompt,
model: 'ggml-model-q4_0', model,
}), }),
}) })
if (!result.ok || !result.body) { if (!result.ok) {
return return
} }
...@@ -54,37 +59,62 @@ async function completion(prompt: string, callback: (res: string) => void) { ...@@ -54,37 +59,62 @@ async function completion(prompt: string, callback: (res: string) => void) {
export default function () { export default function () {
const [prompt, setPrompt] = useState('') const [prompt, setPrompt] = useState('')
const [messages, setMessages] = useState<Message[]>([]) const [messages, setMessages] = useState<Message[]>([])
const [model, setModel] = useState('')
const [generating, setGenerating] = useState(false)
return ( return (
<div className='flex min-h-screen flex-1 flex-col justify-between bg-white'> <div className='flex min-h-screen flex-1 flex-col justify-between bg-white'>
<header className='drag sticky top-0 z-50 flex w-full flex-row items-center border-b border-black/5 bg-gray-50/75 p-3 backdrop-blur-md'> <header className='drag sticky top-0 z-50 flex h-14 w-full flex-row items-center border-b border-black/10 bg-white/75 backdrop-blur-md'>
<div className='mx-auto w-full max-w-xl leading-none'> <div className='mx-auto w-full max-w-xl leading-none'>
<h1 className='text-sm font-medium'>LLaMa</h1> <h1 className='text-sm font-medium'>{path.basename(model).replace('.bin', '')}</h1>
<h2 className='text-xs text-black/50'>Meta Platforms, Inc.</h2>
</div> </div>
</header> </header>
{model ? (
<section className='mx-auto mb-10 w-full max-w-xl flex-1 break-words'> <section className='mx-auto mb-10 w-full max-w-xl flex-1 break-words'>
{messages.map((m, i) => ( {messages.map((m, i) => (
<div className='my-4 flex gap-4' key={i}> <div className='my-4 flex gap-4' key={i}>
<div className='flex-none pr-1 text-lg'> <div className='flex-none pr-1 text-lg'>
{m.sender === 'human' ? ( {m.sender === 'human' ? (
<div className='bg-neutral-200 text-neutral-700 text-sm h-6 w-6 rounded-md flex items-center justify-center mt-px'> <div className='mt-px flex h-6 w-6 items-center justify-center rounded-md bg-neutral-200 text-sm text-neutral-700'>
H {userInfo.username[0].toUpperCase()}
</div> </div>
) : ( ) : (
<div className='bg-blue-600 text-white text-sm h-6 w-6 rounded-md flex items-center justify-center mt-0.5'> <div className='mt-0.5 flex h-6 w-6 items-center justify-center rounded-md bg-blue-600 text-sm text-white'>
L {path.basename(model)[0].toUpperCase()}
</div> </div>
)} )}
</div> </div>
<div className='flex-1 text-gray-800'> <div className='flex-1 text-gray-800'>
{m.content} {m.content}
{m.sender === 'bot' && <span className='relative -top-[3px] left-1 text-[10px]'></span>} {m.sender === 'bot' && generating && (
<span className='blink relative -top-[3px] left-1 text-[10px]'></span>
)}
</div> </div>
</div> </div>
))} ))}
</section> </section>
) : (
<section className='flex flex-1 select-none flex-col items-center justify-center pb-20'>
<h2 className='text-3xl font-light text-neutral-400'>No model selected</h2>
<button
onClick={async () => {
const res = await dialog.showOpenDialog(getCurrentWindow(), {
properties: ['openFile', 'multiSelections'],
})
if (res.canceled) {
return
}
setModel(res.filePaths[0])
}}
className='rounded-dm my-8 rounded-md bg-blue-600 px-4 py-2 text-sm text-white hover:brightness-110'
>
Open file...
</button>
</section>
)}
<div className='sticky bottom-0 bg-gradient-to-b from-transparent to-white'> <div className='sticky bottom-0 bg-gradient-to-b from-transparent to-white'>
{model && (
<textarea <textarea
autoFocus autoFocus
rows={1} rows={1}
...@@ -94,31 +124,34 @@ export default function () { ...@@ -94,31 +124,34 @@ export default function () {
className='mx-auto my-4 block w-full max-w-xl resize-none rounded-xl border border-gray-200 px-5 py-3.5 text-[15px] shadow-lg shadow-black/5 focus:outline-none' className='mx-auto my-4 block w-full max-w-xl resize-none rounded-xl border border-gray-200 px-5 py-3.5 text-[15px] shadow-lg shadow-black/5 focus:outline-none'
onKeyDownCapture={async e => { onKeyDownCapture={async e => {
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault() // Prevents the newline character from being inserted e.preventDefault()
// Perform your desired action here, such as submitting the form or handling the entered text
await setMessages(messages => {
return [...messages, { sender: 'human', content: prompt }]
})
const index = messages.length + 1 if (generating) {
completion(prompt, res => { return
setMessages(messages => {
let message = messages[index]
if (!message) {
message = { sender: 'bot', content: '' }
} }
message.content = message.content + res if (!prompt) {
return
}
return [...messages.slice(0, index), message] await setMessages(messages => {
}) return [...messages, { sender: 'human', content: prompt }, { sender: 'bot', content: '' }]
}) })
setPrompt('') setPrompt('')
setGenerating(true)
await generate(prompt, model, res => {
setMessages(messages => {
let last = messages[messages.length - 1]
return [...messages.slice(0, messages.length - 1), { ...last, content: last.content + res }]
})
})
setGenerating(false)
} }
}} }}
></textarea> ></textarea>
)}
</div> </div>
</div> </div>
) )
......
import { app, BrowserWindow } from 'electron' import { app, BrowserWindow, ipcMain, dialog } from 'electron'
import { spawn } from 'child_process' import { spawn } from 'child_process'
import * as path from 'path' import * as path from 'path'
require('@electron/remote/main').initialize()
// This allows TypeScript to pick up the magic constants that's auto-generated by Forge's Webpack // This allows TypeScript to pick up the magic constants that's auto-generated by Forge's Webpack
// plugin that tells the Electron app where to look for the Webpack-bundled app code (depending on // plugin that tells the Electron app where to look for the Webpack-bundled app code (depending on
// whether you're running in development or production). // whether you're running in development or production).
...@@ -21,9 +24,15 @@ const createWindow = (): void => { ...@@ -21,9 +24,15 @@ const createWindow = (): void => {
minWidth: 400, minWidth: 400,
minHeight: 300, minHeight: 300,
titleBarStyle: 'hiddenInset', titleBarStyle: 'hiddenInset',
transparent: true, webPreferences: {
preload: MAIN_WINDOW_PRELOAD_WEBPACK_ENTRY,
nodeIntegration: true,
contextIsolation: false,
},
}) })
require('@electron/remote/main').enable(mainWindow.webContents)
// and load the index.html of the app. // and load the index.html of the app.
mainWindow.loadURL(MAIN_WINDOW_WEBPACK_ENTRY) mainWindow.loadURL(MAIN_WINDOW_WEBPACK_ENTRY)
} }
...@@ -34,7 +43,7 @@ if (app.isPackaged) { ...@@ -34,7 +43,7 @@ if (app.isPackaged) {
console.log(resources) console.log(resources)
// Start the executable // Start the executable
const exec = path.join(resources, 'server') const exec = path.join(resources, 'serve', '--port', '5001')
console.log(`Starting ${exec}`) console.log(`Starting ${exec}`)
const proc = spawn(exec) const proc = spawn(exec)
proc.stdout.on('data', data => { proc.stdout.on('data', data => {
......
// See the Electron documentation for details on how to use preload scripts:
// https://www.electronjs.org/docs/latest/tutorial/process-model#preload-scripts
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment