ollama app welcome screen for first time run (#80)

This commit is contained in:
hoyyeva 2023-07-14 19:34:24 -04:00 committed by GitHub
parent 567e74e7d7
commit e88dd25bab
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 2442 additions and 209 deletions

View file

@ -58,7 +58,7 @@ const config: ForgeConfig = {
new AutoUnpackNativesPlugin({}), new AutoUnpackNativesPlugin({}),
new WebpackPlugin({ new WebpackPlugin({
mainConfig, mainConfig,
devContentSecurityPolicy: `default-src * 'unsafe-eval' 'unsafe-inline'`, devContentSecurityPolicy: `default-src * 'unsafe-eval' 'unsafe-inline'; img-src data: 'self'`,
renderer: { renderer: {
config: rendererConfig, config: rendererConfig,
nodeIntegration: true, nodeIntegration: true,

2274
app/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -30,6 +30,7 @@
"@electron-forge/plugin-auto-unpack-natives": "^6.2.1", "@electron-forge/plugin-auto-unpack-natives": "^6.2.1",
"@electron-forge/plugin-webpack": "^6.2.1", "@electron-forge/plugin-webpack": "^6.2.1",
"@electron-forge/publisher-github": "^6.2.1", "@electron-forge/publisher-github": "^6.2.1",
"@svgr/webpack": "^8.0.1",
"@types/chmodr": "^1.0.0", "@types/chmodr": "^1.0.0",
"@types/node": "^20.4.0", "@types/node": "^20.4.0",
"@types/react": "^18.2.14", "@types/react": "^18.2.14",
@ -54,17 +55,21 @@
"prettier": "^2.8.8", "prettier": "^2.8.8",
"prettier-plugin-tailwindcss": "^0.3.0", "prettier-plugin-tailwindcss": "^0.3.0",
"style-loader": "^3.3.3", "style-loader": "^3.3.3",
"svg-inline-loader": "^0.8.2",
"tailwindcss": "^3.3.2", "tailwindcss": "^3.3.2",
"ts-loader": "^9.4.3", "ts-loader": "^9.4.3",
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
"typescript": "~4.5.4", "typescript": "~4.5.4",
"url-loader": "^4.1.1",
"webpack": "^5.88.0", "webpack": "^5.88.0",
"webpack-cli": "^5.1.4", "webpack-cli": "^5.1.4",
"webpack-dev-server": "^4.15.1" "webpack-dev-server": "^4.15.1"
}, },
"dependencies": { "dependencies": {
"@electron/remote": "^2.0.10", "@electron/remote": "^2.0.10",
"@heroicons/react": "^2.0.18",
"@segment/analytics-node": "^1.0.0", "@segment/analytics-node": "^1.0.0",
"copy-to-clipboard": "^3.3.3",
"electron-squirrel-startup": "^1.0.0", "electron-squirrel-startup": "^1.0.0",
"electron-store": "^8.1.0", "electron-store": "^8.1.0",
"react": "^18.2.0", "react": "^18.2.0",

View file

@ -1,160 +1,127 @@
import { useState } from 'react' import { useState } from "react"
import path from 'path' import copy from 'copy-to-clipboard'
import os from 'os' import { exec } from 'child_process'
import { dialog, getCurrentWindow } from '@electron/remote' import * as path from 'path'
import * as fs from 'fs'
import { DocumentDuplicateIcon } from '@heroicons/react/24/outline'
import { app } from '@electron/remote'
import OllamaIcon from './ollama.svg'
const API_URL = 'http://127.0.0.1:7734' const ollama = app.isPackaged
? path.join(process.resourcesPath, 'ollama')
: path.resolve(process.cwd(), '..', 'ollama')
type Message = { function installCLI(callback: () => void) {
sender: 'bot' | 'human' const symlinkPath = '/usr/local/bin/ollama'
content: string
}
const userInfo = os.userInfo() if (fs.existsSync(symlinkPath) && fs.readlinkSync(symlinkPath) === ollama) {
callback && callback()
return
}
async function generate(prompt: string, model: string, callback: (res: string) => void) { const command = `
const result = await fetch(`${API_URL}/generate`, { do shell script "ln -F -s ${ollama} /usr/local/bin/ollama" with administrator privileges
method: 'POST', `
headers: { exec(`osascript -e '${command}'`, (error: Error | null, stdout: string, stderr: string) => {
'Content-Type': 'application/json', if (error) {
}, console.error(`cli: failed to install cli: ${error.message}`)
body: JSON.stringify({ callback && callback()
prompt, return
model, }
}),
callback && callback()
}) })
if (!result.ok) {
return
}
let reader = result.body.getReader()
while (true) {
const { done, value } = await reader.read()
if (done) {
break
}
let decoder = new TextDecoder()
let str = decoder.decode(value)
let re = /}\s*{/g
str = '[' + str.replace(re, '},{') + ']'
let messages = JSON.parse(str)
for (const message of messages) {
const choice = message.choices[0]
callback(choice.text)
if (choice.finish_reason === 'stop') {
break
}
}
}
return
} }
export default function () { export default function () {
const [prompt, setPrompt] = useState('') const [step, setStep] = useState(0)
const [messages, setMessages] = useState<Message[]>([])
const [model, setModel] = useState('') const command = 'ollama run orca'
const [generating, setGenerating] = useState(false)
return ( return (
<div className='flex min-h-screen flex-1 flex-col justify-between bg-white'> <div className='flex flex-col justify-between mx-auto w-full pt-16 px-4 min-h-screen bg-white'>
<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'> {step === 0 && (
<div className='mx-auto w-full max-w-xl leading-none'> <>
<h1 className='text-sm font-medium'>{path.basename(model).replace('.bin', '')}</h1> <div className="mx-auto text-center">
</div> <h1 className="mt-4 mb-6 text-2xl tracking-tight text-gray-900">Welcome to Ollama</h1>
</header> <p className="mx-auto w-[65%] text-sm text-gray-400">
{model ? ( Lets get you up and running with your own large language models.
<section className='mx-auto mb-10 w-full max-w-xl flex-1 break-words'> </p>
{messages.map((m, i) => (
<div className='my-4 flex gap-4' key={i}>
<div className='flex-none pr-1 text-lg'>
{m.sender === 'human' ? (
<div className='mt-px flex h-6 w-6 items-center justify-center rounded-md bg-neutral-200 text-sm text-neutral-700'>
{userInfo.username[0].toUpperCase()}
</div>
) : (
<div className='mt-0.5 flex h-6 w-6 items-center justify-center rounded-md bg-blue-600 text-sm text-white'>
{path.basename(model)[0].toUpperCase()}
</div>
)}
</div>
<div className='flex-1 text-gray-800'>
{m.content}
{m.sender === 'bot' && generating && i === messages.length - 1 && (
<span className='blink relative -top-[3px] left-1 text-[10px]'></span>
)}
</div>
</div>
))}
</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 <button
onClick={async () => { onClick={() => {
const res = await dialog.showOpenDialog(getCurrentWindow(), { setStep(1)
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' className='mx-auto w-[40%] rounded-dm my-8 rounded-md bg-black px-4 py-2 text-sm text-white hover:brightness-110'
> >
Open file... Next
</button> </button>
</section> </div>
<div className="mx-auto">
<OllamaIcon />
</div>
</>
)} )}
<div className='sticky bottom-0 bg-gradient-to-b from-transparent to-white'> {step === 1 && (
{model && ( <>
<textarea <div className="flex flex-col space-y-28 mx-auto text-center">
autoFocus <h1 className="mt-4 text-2xl tracking-tight text-gray-900">Install the command line</h1>
rows={1} <pre className="mx-auto text-4xl text-gray-400">
value={prompt} &gt; ollama
placeholder='Send a message...' </pre>
onChange={e => setPrompt(e.target.value)} <div className="mx-auto">
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' <button
onKeyDownCapture={async e => { onClick={() => {
if (e.key === 'Enter' && !e.shiftKey) { // install the command line
e.preventDefault() installCLI(() => {
window.focus()
if (generating) { setStep(2)
return
}
if (!prompt) {
return
}
await setMessages(messages => {
return [...messages, { sender: 'human', content: prompt }, { sender: 'bot', content: '' }]
}) })
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> className='mx-auto w-[60%] rounded-dm rounded-md bg-black px-4 py-2 text-sm text-white hover:brightness-110'
>
Install
</button>
<p className="mx-auto w-[70%] text-xs text-gray-400 my-4">
You will be prompted for administrator access
</p>
</div>
</div>
</>
)}
{step === 2 && (
<>
<div className="flex flex-col space-y-20 mx-auto text-center">
<h1 className="mt-4 text-2xl tracking-tight text-gray-900">Run your first model</h1>
<div className="flex flex-col">
<div className="group relative flex items-center">
<pre className="text-start w-full language-none rounded-md bg-gray-100 px-4 py-3 text-2xs leading-normal">
{command}
</pre>
<button
className='absolute right-[5px] rounded-md border bg-white/90 px-2 py-2 text-gray-400 opacity-0 backdrop-blur-xl hover:text-gray-600 group-hover:opacity-100'
onClick={() => {
copy(command)
}}
>
<DocumentDuplicateIcon className="h-4 w-4 text-gray-500" />
</button>
</div>
<p className="mx-auto w-[70%] text-xs text-gray-400 my-4">
Run this command in your favorite terminal.
</p>
</div>
<button
onClick={() => {
window.close()
}}
className='mx-auto w-[60%] rounded-dm rounded-md bg-black px-4 py-2 text-sm text-white hover:brightness-110'
>
Finish
</button>
</div>
</>
)} )}
</div> </div>
</div>
) )
} }

4
app/src/declarations.d.ts vendored Normal file
View file

@ -0,0 +1,4 @@
declare module '*.svg' {
const content: string;
export default content;
}

View file

@ -1,17 +1,20 @@
import { spawn, exec } from 'child_process' import { spawn } from 'child_process'
import { app, autoUpdater, dialog, Tray, Menu } from 'electron' import { app, autoUpdater, dialog, Tray, Menu, BrowserWindow } from 'electron'
import Store from 'electron-store' import Store from 'electron-store'
import winston from 'winston' import winston from 'winston'
import 'winston-daily-rotate-file' import 'winston-daily-rotate-file'
import * as path from 'path' import * as path from 'path'
import * as fs from 'fs'
import { analytics, id } from './telemetry' import { analytics, id } from './telemetry'
require('@electron/remote/main').initialize() require('@electron/remote/main').initialize()
const store = new Store() const store = new Store()
let tray: Tray | null = null let tray: Tray | null = null
let welcomeWindow: BrowserWindow | null = null
declare const MAIN_WINDOW_WEBPACK_ENTRY: string
const logger = winston.createLogger({ const logger = winston.createLogger({
transports: [ transports: [
@ -30,7 +33,37 @@ if (!SingleInstanceLock) {
app.quit() app.quit()
} }
const createSystemtray = () => {
function firstRunWindow() {
// Create the browser window.
welcomeWindow = new BrowserWindow({
width: 400,
height: 500,
frame: false,
fullscreenable: false,
resizable: false,
movable: false,
transparent: true,
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
},
})
require('@electron/remote/main').enable(welcomeWindow.webContents)
// and load the index.html of the app.
welcomeWindow.loadURL(MAIN_WINDOW_WEBPACK_ENTRY)
// for debugging
// welcomeWindow.webContents.openDevTools()
if (process.platform === 'darwin') {
app.dock.hide()
}
}
function createSystemtray() {
let iconPath = path.join(__dirname, '..', '..', 'assets', 'ollama_icon_16x16Template.png') let iconPath = path.join(__dirname, '..', '..', 'assets', 'ollama_icon_16x16Template.png')
if (app.isPackaged) { if (app.isPackaged) {
@ -49,8 +82,6 @@ if (require('electron-squirrel-startup')) {
app.quit() app.quit()
} }
const ollama = path.join(process.resourcesPath, 'ollama')
function server() { function server() {
const binary = app.isPackaged const binary = app.isPackaged
? path.join(process.resourcesPath, 'ollama') ? path.join(process.resourcesPath, 'ollama')
@ -81,51 +112,12 @@ function server() {
}) })
} }
function installCLI() { if (process.platform === 'darwin') {
const symlinkPath = '/usr/local/bin/ollama' app.dock.hide()
if (fs.existsSync(symlinkPath) && fs.readlinkSync(symlinkPath) === ollama) {
return
}
dialog
.showMessageBox({
type: 'info',
title: 'Ollama CLI installation',
message: 'To make the Ollama command work in your terminal, it needs administrator privileges.',
buttons: ['OK'],
})
.then(result => {
if (result.response === 0) {
const command = `
do shell script "ln -F -s ${ollama} /usr/local/bin/ollama" with administrator privileges
`
exec(`osascript -e '${command}'`, (error: Error | null, stdout: string, stderr: string) => {
if (error) {
logger.error(`cli: failed to install cli: ${error.message}`)
return
}
logger.info(stdout)
logger.error(stderr)
})
}
})
} }
app.on('ready', () => { app.on('ready', () => {
if (process.platform === 'darwin') { if (process.platform === 'darwin') {
app.dock.hide()
if (!store.has('first-time-run')) {
// This is the first run
app.setLoginItemSettings({ openAtLogin: true })
store.set('first-time-run', false)
} else {
// The app has been run before
app.setLoginItemSettings({ openAtLogin: app.getLoginItemSettings().openAtLogin })
}
if (app.isPackaged) { if (app.isPackaged) {
if (!app.isInApplicationsFolder()) { if (!app.isInApplicationsFolder()) {
const chosen = dialog.showMessageBoxSync({ const chosen = dialog.showMessageBoxSync({
@ -157,13 +149,21 @@ app.on('ready', () => {
} }
} }
} }
installCLI()
} }
} }
createSystemtray() createSystemtray()
server() server()
if (!store.has('first-time-run')) {
// This is the first run
app.setLoginItemSettings({ openAtLogin: true })
firstRunWindow()
store.set('first-time-run', false)
} else {
// The app has been run before
app.setLoginItemSettings({ openAtLogin: app.getLoginItemSettings().openAtLogin })
}
}) })
// Quit when all windows are closed, except on macOS. There, it's common // Quit when all windows are closed, except on macOS. There, it's common

9
app/src/ollama.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 17 KiB

View file

@ -28,4 +28,8 @@ export const rules: Required<ModuleOptions>['rules'] = [
}, },
}, },
}, },
{
test: /\.svg$/,
use: ['@svgr/webpack'],
},
] ]