浏览代码

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

hoyyeva 1 年之前
父节点
当前提交
e88dd25bab
共有 8 个文件被更改,包括 1626 次插入308 次删除
  1. 1 1
      app/forge.config.ts
  2. 1444 112
      app/package-lock.json
  3. 5 0
      app/package.json
  4. 113 146
      app/src/app.tsx
  5. 4 0
      app/src/declarations.d.ts
  6. 49 49
      app/src/index.ts
  7. 6 0
      app/src/ollama.svg
  8. 4 0
      app/webpack.rules.ts

+ 1 - 1
app/forge.config.ts

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

文件差异内容过多而无法显示
+ 1444 - 112
app/package-lock.json


+ 5 - 0
app/package.json

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

+ 113 - 146
app/src/app.tsx

@@ -1,160 +1,127 @@
-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:7734'
-
-type Message = {
-  sender: 'bot' | 'human'
-  content: string
-}
-
-const userInfo = os.userInfo()
-
-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) {
+import { useState } from "react"
+import copy from 'copy-to-clipboard'
+import { exec } from 'child_process'
+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 ollama = app.isPackaged
+? path.join(process.resourcesPath, 'ollama')
+: path.resolve(process.cwd(), '..', 'ollama')
+
+function installCLI(callback: () => void) {
+  const symlinkPath = '/usr/local/bin/ollama'
+
+  if (fs.existsSync(symlinkPath) && fs.readlinkSync(symlinkPath) === ollama) {
+    callback && callback()
     return
   }
 
-  let reader = result.body.getReader()
-
-  while (true) {
-    const { done, value } = await reader.read()
-
-    if (done) {
-      break
+  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) {
+      console.error(`cli: failed to install cli: ${error.message}`)
+      callback && callback()
+      return
     }
-
-    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
+    
+    callback && callback()
+  })
 }
 
 export default function () {
-  const [prompt, setPrompt] = useState('')
-  const [messages, setMessages] = useState<Message[]>([])
-  const [model, setModel] = useState('')
-  const [generating, setGenerating] = useState(false)
+  const [step, setStep] = useState(0)
+
+  const command = 'ollama run orca'
 
   return (
-    <div className='flex min-h-screen flex-1 flex-col justify-between 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'>
-        <div className='mx-auto w-full max-w-xl leading-none'>
-          <h1 className='text-sm font-medium'>{path.basename(model).replace('.bin', '')}</h1>
-        </div>
-      </header>
-      {model ? (
-        <section className='mx-auto mb-10 w-full max-w-xl flex-1 break-words'>
-          {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 className='flex flex-col justify-between mx-auto w-full pt-16 px-4 min-h-screen bg-white'>
+      {step === 0 && (
+        <>
+          <div className="mx-auto text-center">
+            <h1 className="mt-4 mb-6 text-2xl tracking-tight text-gray-900">Welcome to Ollama</h1>
+            <p className="mx-auto w-[65%] text-sm text-gray-400">
+              Let’s get you up and running with your own large language models.
+            </p>
+            <button
+              onClick={() => {
+                setStep(1)
+              }}
+              className='mx-auto w-[40%] rounded-dm my-8 rounded-md bg-black px-4 py-2 text-sm text-white hover:brightness-110'
+            >
+              Next
+            </button>      
+          </div>
+          <div className="mx-auto">
+            <OllamaIcon />
+          </div>
+        </>
+      )}
+      {step === 1 && (
+        <>
+          <div className="flex flex-col space-y-28 mx-auto text-center">
+            <h1 className="mt-4 text-2xl tracking-tight text-gray-900">Install the command line</h1>
+            <pre className="mx-auto text-4xl text-gray-400">
+             &gt; ollama
+            </pre>
+            <div className="mx-auto">
+              <button
+                onClick={() => {
+                  // install the command line
+                  installCLI(() => {
+                    window.focus()
+                    setStep(2)
+                  })
+                }}
+                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>
-          ))}
-        </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>
+            <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 className='sticky bottom-0 bg-gradient-to-b from-transparent to-white'>
-        {model && (
-          <textarea
-            autoFocus
-            rows={1}
-            value={prompt}
-            placeholder='Send a message...'
-            onChange={e => setPrompt(e.target.value)}
-            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 => {
-              if (e.key === 'Enter' && !e.shiftKey) {
-                e.preventDefault()
-
-                if (generating) {
-                  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>
-        )}
-      </div>
     </div>
+
   )
-}
+}

+ 4 - 0
app/src/declarations.d.ts

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

+ 49 - 49
app/src/index.ts

@@ -1,17 +1,20 @@
-import { spawn, exec } from 'child_process'
-import { app, autoUpdater, dialog, Tray, Menu } from 'electron'
+import { spawn } from 'child_process'
+import { app, autoUpdater, dialog, Tray, Menu, BrowserWindow } from 'electron'
 import Store from 'electron-store'
 import winston from 'winston'
 import 'winston-daily-rotate-file'
 import * as path from 'path'
-import * as fs from 'fs'
 
 import { analytics, id } from './telemetry'
 
 require('@electron/remote/main').initialize()
 
+
 const store = new Store()
 let tray: Tray | null = null
+let welcomeWindow: BrowserWindow | null = null
+
+declare const MAIN_WINDOW_WEBPACK_ENTRY: string
 
 const logger = winston.createLogger({
   transports: [
@@ -30,7 +33,37 @@ if (!SingleInstanceLock) {
   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')
 
   if (app.isPackaged) {
@@ -49,8 +82,6 @@ if (require('electron-squirrel-startup')) {
   app.quit()
 }
 
-const ollama = path.join(process.resourcesPath, 'ollama')
-
 function server() {
   const binary = app.isPackaged
     ? path.join(process.resourcesPath, 'ollama')
@@ -81,51 +112,12 @@ function server() {
   })
 }
 
-function installCLI() {
-  const symlinkPath = '/usr/local/bin/ollama'
-
-  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)
-        })
-      }
-    })
+if (process.platform === 'darwin') {
+  app.dock.hide()
 }
 
 app.on('ready', () => {
   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.isInApplicationsFolder()) {
         const chosen = dialog.showMessageBoxSync({
@@ -157,13 +149,21 @@ app.on('ready', () => {
           }
         }
       }
-
-      installCLI()
     }
   }
 
   createSystemtray()
   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

文件差异内容过多而无法显示
+ 6 - 0
app/src/ollama.svg


+ 4 - 0
app/webpack.rules.ts

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

部分文件因为文件数量过多而无法显示