ollama app welcome screen for first time run (#80)
This commit is contained in:
parent
567e74e7d7
commit
e88dd25bab
8 changed files with 2442 additions and 209 deletions
|
@ -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
2274
app/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -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",
|
||||||
|
|
251
app/src/app.tsx
251
app/src/app.tsx
|
@ -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()
|
||||||
async function generate(prompt: string, model: string, callback: (res: string) => void) {
|
|
||||||
const result = await fetch(`${API_URL}/generate`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
prompt,
|
|
||||||
model,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!result.ok) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let reader = result.body.getReader()
|
const command = `
|
||||||
|
do shell script "ln -F -s ${ollama} /usr/local/bin/ollama" with administrator privileges
|
||||||
while (true) {
|
`
|
||||||
const { done, value } = await reader.read()
|
exec(`osascript -e '${command}'`, (error: Error | null, stdout: string, stderr: string) => {
|
||||||
|
if (error) {
|
||||||
if (done) {
|
console.error(`cli: failed to install cli: ${error.message}`)
|
||||||
break
|
callback && callback()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let decoder = new TextDecoder()
|
callback && callback()
|
||||||
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 ? (
|
Let’s 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) => (
|
<button
|
||||||
<div className='my-4 flex gap-4' key={i}>
|
onClick={() => {
|
||||||
<div className='flex-none pr-1 text-lg'>
|
setStep(1)
|
||||||
{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'>
|
className='mx-auto w-[40%] rounded-dm my-8 rounded-md bg-black px-4 py-2 text-sm text-white hover:brightness-110'
|
||||||
{userInfo.username[0].toUpperCase()}
|
>
|
||||||
</div>
|
Next
|
||||||
) : (
|
</button>
|
||||||
<div className='mt-0.5 flex h-6 w-6 items-center justify-center rounded-md bg-blue-600 text-sm text-white'>
|
</div>
|
||||||
{path.basename(model)[0].toUpperCase()}
|
<div className="mx-auto">
|
||||||
</div>
|
<OllamaIcon />
|
||||||
)}
|
</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
|
|
||||||
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'>
|
{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}
|
> 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)
|
className='mx-auto w-[60%] rounded-dm rounded-md bg-black px-4 py-2 text-sm text-white hover:brightness-110'
|
||||||
}
|
>
|
||||||
}}
|
Install
|
||||||
></textarea>
|
</button>
|
||||||
)}
|
<p className="mx-auto w-[70%] text-xs text-gray-400 my-4">
|
||||||
</div>
|
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>
|
||||||
|
|
||||||
)
|
)
|
||||||
}
|
}
|
4
app/src/declarations.d.ts
vendored
Normal file
4
app/src/declarations.d.ts
vendored
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
declare module '*.svg' {
|
||||||
|
const content: string;
|
||||||
|
export default content;
|
||||||
|
}
|
|
@ -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
9
app/src/ollama.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 17 KiB |
|
@ -28,4 +28,8 @@ export const rules: Required<ModuleOptions>['rules'] = [
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
test: /\.svg$/,
|
||||||
|
use: ['@svgr/webpack'],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
Loading…
Reference in a new issue