mirror of
https://github.com/likelovewant/ollama-for-amd.git
synced 2025-12-21 14:26:30 +00:00
529 lines
14 KiB
Go
529 lines
14 KiB
Go
//go:build windows || darwin
|
|
|
|
package main
|
|
|
|
// #include "menu.h"
|
|
import "C"
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
"unsafe"
|
|
|
|
"github.com/ollama/ollama/app/dialog"
|
|
"github.com/ollama/ollama/app/store"
|
|
"github.com/ollama/ollama/app/webview"
|
|
)
|
|
|
|
type Webview struct {
|
|
port int
|
|
token string
|
|
webview webview.WebView
|
|
mutex sync.Mutex
|
|
|
|
Store *store.Store
|
|
}
|
|
|
|
// Run initializes the webview and starts its event loop.
|
|
// Note: this must be called from the primary app thread
|
|
// This returns the OS native window handle to the caller
|
|
func (w *Webview) Run(path string) unsafe.Pointer {
|
|
var url string
|
|
if devMode {
|
|
// In development mode, use the local dev server
|
|
url = fmt.Sprintf("http://localhost:5173%s", path)
|
|
} else {
|
|
url = fmt.Sprintf("http://127.0.0.1:%d%s", w.port, path)
|
|
}
|
|
w.mutex.Lock()
|
|
defer w.mutex.Unlock()
|
|
|
|
if w.webview == nil {
|
|
// Note: turning on debug on macos throws errors but is marginally functional for debugging
|
|
// TODO (jmorganca): we should pre-create the window and then provide it here to
|
|
// webview so we can hide it from the start and make other modifications
|
|
wv := webview.New(debug)
|
|
// start the window hidden
|
|
hideWindow(wv.Window())
|
|
wv.SetTitle("Ollama")
|
|
|
|
// TODO (jmorganca): this isn't working yet since it needs to be set
|
|
// on the first page load, ideally in an interstitial page like `/token`
|
|
// that exists only to set the cookie and redirect to /
|
|
// wv.Init(fmt.Sprintf(`document.cookie = "token=%s; path=/"`, w.token))
|
|
init := `
|
|
// Disable reload
|
|
document.addEventListener('keydown', function(e) {
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 'r') {
|
|
e.preventDefault();
|
|
return false;
|
|
}
|
|
});
|
|
|
|
// Prevent back/forward navigation
|
|
window.addEventListener('popstate', function(e) {
|
|
e.preventDefault();
|
|
history.pushState(null, '', window.location.pathname);
|
|
return false;
|
|
});
|
|
|
|
// Clear history on load
|
|
window.addEventListener('load', function() {
|
|
history.pushState(null, '', window.location.pathname);
|
|
window.history.replaceState(null, '', window.location.pathname);
|
|
});
|
|
|
|
// Set token cookie
|
|
document.cookie = "token=` + w.token + `; path=/";
|
|
`
|
|
// Windows-specific scrollbar styling
|
|
if runtime.GOOS == "windows" {
|
|
init += `
|
|
// Fix scrollbar styling for Edge WebView2 on Windows only
|
|
function updateScrollbarStyles() {
|
|
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
const existingStyle = document.getElementById('scrollbar-style');
|
|
if (existingStyle) existingStyle.remove();
|
|
|
|
const style = document.createElement('style');
|
|
style.id = 'scrollbar-style';
|
|
|
|
if (isDark) {
|
|
style.textContent = ` + "`" + `
|
|
::-webkit-scrollbar { width: 6px !important; height: 6px !important; }
|
|
::-webkit-scrollbar-track { background: #1a1a1a !important; }
|
|
::-webkit-scrollbar-thumb { background: #404040 !important; border-radius: 6px !important; }
|
|
::-webkit-scrollbar-thumb:hover { background: #505050 !important; }
|
|
::-webkit-scrollbar-corner { background: #1a1a1a !important; }
|
|
::-webkit-scrollbar-button {
|
|
background: transparent !important;
|
|
border: none !important;
|
|
width: 0px !important;
|
|
height: 0px !important;
|
|
margin: 0 !important;
|
|
padding: 0 !important;
|
|
}
|
|
::-webkit-scrollbar-button:vertical:start:decrement {
|
|
background: transparent !important;
|
|
height: 0px !important;
|
|
}
|
|
::-webkit-scrollbar-button:vertical:end:increment {
|
|
background: transparent !important;
|
|
height: 0px !important;
|
|
}
|
|
::-webkit-scrollbar-button:horizontal:start:decrement {
|
|
background: transparent !important;
|
|
width: 0px !important;
|
|
}
|
|
::-webkit-scrollbar-button:horizontal:end:increment {
|
|
background: transparent !important;
|
|
width: 0px !important;
|
|
}
|
|
` + "`" + `;
|
|
} else {
|
|
style.textContent = ` + "`" + `
|
|
::-webkit-scrollbar { width: 6px !important; height: 6px !important; }
|
|
::-webkit-scrollbar-track { background: #f0f0f0 !important; }
|
|
::-webkit-scrollbar-thumb { background: #c0c0c0 !important; border-radius: 6px !important; }
|
|
::-webkit-scrollbar-thumb:hover { background: #a0a0a0 !important; }
|
|
::-webkit-scrollbar-corner { background: #f0f0f0 !important; }
|
|
::-webkit-scrollbar-button {
|
|
background: transparent !important;
|
|
border: none !important;
|
|
width: 0px !important;
|
|
height: 0px !important;
|
|
margin: 0 !important;
|
|
padding: 0 !important;
|
|
}
|
|
::-webkit-scrollbar-button:vertical:start:decrement {
|
|
background: transparent !important;
|
|
height: 0px !important;
|
|
}
|
|
::-webkit-scrollbar-button:vertical:end:increment {
|
|
background: transparent !important;
|
|
height: 0px !important;
|
|
}
|
|
::-webkit-scrollbar-button:horizontal:start:decrement {
|
|
background: transparent !important;
|
|
width: 0px !important;
|
|
}
|
|
::-webkit-scrollbar-button:horizontal:end:increment {
|
|
background: transparent !important;
|
|
width: 0px !important;
|
|
}
|
|
` + "`" + `;
|
|
}
|
|
document.head.appendChild(style);
|
|
}
|
|
|
|
window.addEventListener('load', updateScrollbarStyles);
|
|
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', updateScrollbarStyles);
|
|
`
|
|
}
|
|
// on windows make ctrl+n open new chat
|
|
// TODO (jmorganca): later we should use proper accelerators
|
|
// once we introduce a native menu for the window
|
|
// this is only used on windows since macOS uses the proper accelerators
|
|
if runtime.GOOS == "windows" {
|
|
init += `
|
|
document.addEventListener('keydown', function(e) {
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 'n') {
|
|
e.preventDefault();
|
|
// Use the existing navigation method
|
|
history.pushState({}, '', '/c/new');
|
|
window.dispatchEvent(new PopStateEvent('popstate'));
|
|
return false;
|
|
}
|
|
});
|
|
`
|
|
}
|
|
|
|
init += `
|
|
window.OLLAMA_WEBSEARCH = true;
|
|
`
|
|
|
|
wv.Init(init)
|
|
|
|
// Add keyboard handler for zoom
|
|
wv.Init(`
|
|
window.addEventListener('keydown', function(e) {
|
|
// CMD/Ctrl + Plus/Equals (zoom in)
|
|
if ((e.metaKey || e.ctrlKey) && (e.key === '+' || e.key === '=')) {
|
|
e.preventDefault();
|
|
window.zoomIn && window.zoomIn();
|
|
return false;
|
|
}
|
|
|
|
// CMD/Ctrl + Minus (zoom out)
|
|
if ((e.metaKey || e.ctrlKey) && e.key === '-') {
|
|
e.preventDefault();
|
|
window.zoomOut && window.zoomOut();
|
|
return false;
|
|
}
|
|
|
|
// CMD/Ctrl + 0 (reset zoom)
|
|
if ((e.metaKey || e.ctrlKey) && e.key === '0') {
|
|
e.preventDefault();
|
|
window.zoomReset && window.zoomReset();
|
|
return false;
|
|
}
|
|
}, true);
|
|
`)
|
|
|
|
wv.Bind("zoomIn", func() {
|
|
current := wv.GetZoom()
|
|
wv.SetZoom(current + 0.1)
|
|
})
|
|
|
|
wv.Bind("zoomOut", func() {
|
|
current := wv.GetZoom()
|
|
wv.SetZoom(current - 0.1)
|
|
})
|
|
|
|
wv.Bind("zoomReset", func() {
|
|
wv.SetZoom(1.0)
|
|
})
|
|
|
|
wv.Bind("ready", func() {
|
|
showWindow(wv.Window())
|
|
})
|
|
|
|
wv.Bind("close", func() {
|
|
hideWindow(wv.Window())
|
|
})
|
|
|
|
// Webviews do not allow access to the file system by default, so we need to
|
|
// bind file system operations here
|
|
wv.Bind("selectModelsDirectory", func() {
|
|
go func() {
|
|
// Helper function to call the JavaScript callback with data or null
|
|
callCallback := func(data interface{}) {
|
|
dataJSON, _ := json.Marshal(data)
|
|
wv.Dispatch(func() {
|
|
wv.Eval(fmt.Sprintf("window.__selectModelsDirectoryCallback && window.__selectModelsDirectoryCallback(%s)", dataJSON))
|
|
})
|
|
}
|
|
|
|
directory, err := dialog.Directory().Title("Select Model Directory").ShowHidden(true).Browse()
|
|
if err != nil {
|
|
slog.Debug("Directory selection cancelled or failed", "error", err)
|
|
callCallback(nil)
|
|
return
|
|
}
|
|
slog.Debug("Directory selected", "path", directory)
|
|
callCallback(directory)
|
|
}()
|
|
})
|
|
|
|
// Bind selectFiles function for selecting multiple files at once
|
|
wv.Bind("selectFiles", func() {
|
|
go func() {
|
|
// Helper function to call the JavaScript callback with data or null
|
|
callCallback := func(data interface{}) {
|
|
dataJSON, _ := json.Marshal(data)
|
|
wv.Dispatch(func() {
|
|
wv.Eval(fmt.Sprintf("window.__selectFilesCallback && window.__selectFilesCallback(%s)", dataJSON))
|
|
})
|
|
}
|
|
|
|
// Define allowed extensions for native dialog filtering
|
|
textExts := []string{
|
|
"pdf", "docx", "txt", "md", "csv", "json", "xml", "html", "htm",
|
|
"js", "jsx", "ts", "tsx", "py", "java", "cpp", "c", "cc", "h", "cs", "php", "rb",
|
|
"go", "rs", "swift", "kt", "scala", "sh", "bat", "yaml", "yml", "toml", "ini",
|
|
"cfg", "conf", "log", "rtf",
|
|
}
|
|
imageExts := []string{"png", "jpg", "jpeg", "webp"}
|
|
allowedExts := append(textExts, imageExts...)
|
|
|
|
// Use native multiple file selection with extension filtering
|
|
filenames, err := dialog.File().
|
|
Filter("Supported Files", allowedExts...).
|
|
Title("Select Files").
|
|
LoadMultiple()
|
|
if err != nil {
|
|
slog.Debug("Multiple file selection cancelled or failed", "error", err)
|
|
callCallback(nil)
|
|
return
|
|
}
|
|
|
|
if len(filenames) == 0 {
|
|
callCallback(nil)
|
|
return
|
|
}
|
|
|
|
var files []map[string]string
|
|
maxFileSize := int64(10 * 1024 * 1024) // 10MB
|
|
|
|
for _, filename := range filenames {
|
|
// Check file extension (double-check after native dialog filtering)
|
|
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(filename), "."))
|
|
validExt := false
|
|
for _, allowedExt := range allowedExts {
|
|
if ext == allowedExt {
|
|
validExt = true
|
|
break
|
|
}
|
|
}
|
|
if !validExt {
|
|
slog.Warn("file extension not allowed, skipping", "filename", filepath.Base(filename), "extension", ext)
|
|
continue
|
|
}
|
|
|
|
// Check file size before reading (pre-filter large files)
|
|
fileStat, err := os.Stat(filename)
|
|
if err != nil {
|
|
slog.Error("failed to get file info", "error", err, "filename", filename)
|
|
continue
|
|
}
|
|
|
|
if fileStat.Size() > maxFileSize {
|
|
slog.Warn("file too large, skipping", "filename", filepath.Base(filename), "size", fileStat.Size())
|
|
continue
|
|
}
|
|
|
|
fileBytes, err := os.ReadFile(filename)
|
|
if err != nil {
|
|
slog.Error("failed to read file", "error", err, "filename", filename)
|
|
continue
|
|
}
|
|
|
|
mimeType := http.DetectContentType(fileBytes)
|
|
dataURL := fmt.Sprintf("data:%s;base64,%s", mimeType, base64.StdEncoding.EncodeToString(fileBytes))
|
|
|
|
fileResult := map[string]string{
|
|
"filename": filepath.Base(filename),
|
|
"path": filename,
|
|
"dataURL": dataURL,
|
|
}
|
|
|
|
files = append(files, fileResult)
|
|
}
|
|
|
|
if len(files) == 0 {
|
|
callCallback(nil)
|
|
} else {
|
|
callCallback(files)
|
|
}
|
|
}()
|
|
})
|
|
|
|
wv.Bind("drag", func() {
|
|
wv.Dispatch(func() {
|
|
drag(wv.Window())
|
|
})
|
|
})
|
|
|
|
wv.Bind("doubleClick", func() {
|
|
wv.Dispatch(func() {
|
|
doubleClick(wv.Window())
|
|
})
|
|
})
|
|
|
|
// Add binding for working directory selection
|
|
wv.Bind("selectWorkingDirectory", func() {
|
|
go func() {
|
|
// Helper function to call the JavaScript callback with data or null
|
|
callCallback := func(data interface{}) {
|
|
dataJSON, _ := json.Marshal(data)
|
|
wv.Dispatch(func() {
|
|
wv.Eval(fmt.Sprintf("window.__selectWorkingDirectoryCallback && window.__selectWorkingDirectoryCallback(%s)", dataJSON))
|
|
})
|
|
}
|
|
|
|
directory, err := dialog.Directory().Title("Select Working Directory").ShowHidden(true).Browse()
|
|
if err != nil {
|
|
slog.Debug("Directory selection cancelled or failed", "error", err)
|
|
callCallback(nil)
|
|
return
|
|
}
|
|
slog.Debug("Directory selected", "path", directory)
|
|
callCallback(directory)
|
|
}()
|
|
})
|
|
|
|
wv.Bind("setContextMenuItems", func(items []map[string]interface{}) error {
|
|
menuMutex.Lock()
|
|
defer menuMutex.Unlock()
|
|
|
|
if len(menuItems) > 0 {
|
|
pinner.Unpin()
|
|
}
|
|
|
|
menuItems = nil
|
|
for _, item := range items {
|
|
menuItem := C.menuItem{
|
|
label: C.CString(item["label"].(string)),
|
|
enabled: 0,
|
|
separator: 0,
|
|
}
|
|
|
|
if item["enabled"] != nil {
|
|
menuItem.enabled = 1
|
|
}
|
|
|
|
if item["separator"] != nil {
|
|
menuItem.separator = 1
|
|
}
|
|
menuItems = append(menuItems, menuItem)
|
|
}
|
|
return nil
|
|
})
|
|
|
|
// Debounce resize events
|
|
var resizeTimer *time.Timer
|
|
var resizeMutex sync.Mutex
|
|
|
|
wv.Bind("resize", func(width, height int) {
|
|
if w.Store != nil {
|
|
resizeMutex.Lock()
|
|
if resizeTimer != nil {
|
|
resizeTimer.Stop()
|
|
}
|
|
resizeTimer = time.AfterFunc(100*time.Millisecond, func() {
|
|
err := w.Store.SetWindowSize(width, height)
|
|
if err != nil {
|
|
slog.Error("failed to set window size", "error", err)
|
|
}
|
|
})
|
|
resizeMutex.Unlock()
|
|
}
|
|
})
|
|
|
|
// On Darwin, we can't have 2 threads both running global event loops
|
|
// but on Windows, the event loops are tied to the window, so we're
|
|
// able to run in both the tray and webview
|
|
if runtime.GOOS != "darwin" {
|
|
slog.Debug("starting webview event loop")
|
|
go func() {
|
|
wv.Run()
|
|
slog.Debug("webview event loop exited")
|
|
}()
|
|
}
|
|
|
|
if w.Store != nil {
|
|
width, height, err := w.Store.WindowSize()
|
|
if err != nil {
|
|
slog.Error("failed to get window size", "error", err)
|
|
}
|
|
if width > 0 && height > 0 {
|
|
wv.SetSize(width, height, webview.HintNone)
|
|
} else {
|
|
wv.SetSize(800, 600, webview.HintNone)
|
|
}
|
|
}
|
|
wv.SetSize(800, 600, webview.HintMin)
|
|
|
|
w.webview = wv
|
|
w.webview.Navigate(url)
|
|
} else {
|
|
w.webview.Eval(fmt.Sprintf(`
|
|
history.pushState({}, '', '%s');
|
|
`, path))
|
|
showWindow(w.webview.Window())
|
|
}
|
|
|
|
return w.webview.Window()
|
|
}
|
|
|
|
func (w *Webview) Terminate() {
|
|
w.mutex.Lock()
|
|
if w.webview == nil {
|
|
w.mutex.Unlock()
|
|
return
|
|
}
|
|
|
|
wv := w.webview
|
|
w.webview = nil
|
|
w.mutex.Unlock()
|
|
wv.Terminate()
|
|
wv.Destroy()
|
|
}
|
|
|
|
func (w *Webview) IsRunning() bool {
|
|
w.mutex.Lock()
|
|
defer w.mutex.Unlock()
|
|
return w.webview != nil
|
|
}
|
|
|
|
var (
|
|
menuItems []C.menuItem
|
|
menuMutex sync.RWMutex
|
|
pinner runtime.Pinner
|
|
)
|
|
|
|
//export menu_get_item_count
|
|
func menu_get_item_count() C.int {
|
|
menuMutex.RLock()
|
|
defer menuMutex.RUnlock()
|
|
return C.int(len(menuItems))
|
|
}
|
|
|
|
//export menu_get_items
|
|
func menu_get_items() unsafe.Pointer {
|
|
menuMutex.RLock()
|
|
defer menuMutex.RUnlock()
|
|
|
|
if len(menuItems) == 0 {
|
|
return nil
|
|
}
|
|
|
|
// Return pointer to the slice data
|
|
pinner.Pin(&menuItems[0])
|
|
return unsafe.Pointer(&menuItems[0])
|
|
}
|
|
|
|
//export menu_handle_selection
|
|
func menu_handle_selection(item *C.char) {
|
|
wv.webview.Eval(fmt.Sprintf("window.handleContextMenuResult('%s')", C.GoString(item)))
|
|
}
|