mirror of
https://github.com/ollama/ollama.git
synced 2026-09-21 05:28:00 -05:00
app: add desktop onboarding flow (#17853)
This commit is contained in:
+61
-23
@@ -146,15 +146,10 @@ func main() {
|
||||
// Do this after logging is set up so we can debug issues
|
||||
if runtime.GOOS == "windows" && urlSchemeRequest != "" {
|
||||
slog.Debug("checking for existing instance", "url", urlSchemeRequest)
|
||||
if checkAndHandleExistingInstance(urlSchemeRequest) {
|
||||
// The function will exit if it successfully sends to another instance
|
||||
// If we reach here, we're the first/only instance
|
||||
} else {
|
||||
// No existing instance found, handle the URL scheme in this instance
|
||||
go func() {
|
||||
handleURLSchemeInCurrentInstance(urlSchemeRequest)
|
||||
}()
|
||||
}
|
||||
// This exits after forwarding the request when another instance is
|
||||
// running. First-instance requests are handled later by osRun, after the
|
||||
// Windows UI dependencies are initialized and from the primary thread.
|
||||
checkAndHandleExistingInstance(urlSchemeRequest)
|
||||
}
|
||||
|
||||
// Detect if this is a first start after an upgrade, in
|
||||
@@ -205,6 +200,12 @@ func main() {
|
||||
uiServerPort = port
|
||||
|
||||
st := &store.Store{}
|
||||
if devMode {
|
||||
if dbPath := strings.TrimSpace(os.Getenv("OLLAMA_APP_DB_PATH")); dbPath != "" {
|
||||
st.DBPath = dbPath
|
||||
slog.Debug("using development app database", "path", dbPath)
|
||||
}
|
||||
}
|
||||
appStore = st
|
||||
|
||||
// Enable CORS in development mode
|
||||
@@ -324,11 +325,11 @@ func main() {
|
||||
quit()
|
||||
}()
|
||||
|
||||
if urlSchemeRequest != "" {
|
||||
if urlSchemeRequest != "" && runtime.GOOS != "windows" {
|
||||
go func() {
|
||||
handleURLSchemeInCurrentInstance(urlSchemeRequest)
|
||||
}()
|
||||
} else {
|
||||
} else if urlSchemeRequest == "" {
|
||||
slog.Debug("no URL scheme request to handle")
|
||||
}
|
||||
|
||||
@@ -343,7 +344,13 @@ func main() {
|
||||
}
|
||||
}()
|
||||
|
||||
osRun(cancel, hasCompletedFirstRun, startHidden)
|
||||
settings, settingsErr := st.Settings()
|
||||
showOnboarding := shouldShowOnboarding(settings, settingsErr)
|
||||
if settingsErr != nil {
|
||||
slog.Error("failed to load onboarding state", "error", settingsErr)
|
||||
}
|
||||
|
||||
osRun(cancel, hasCompletedFirstRun, startHidden, showOnboarding, urlSchemeRequest)
|
||||
|
||||
slog.Info("shutting down desktop server")
|
||||
if err := srv.Close(); err != nil {
|
||||
@@ -355,6 +362,31 @@ func main() {
|
||||
<-done
|
||||
}
|
||||
|
||||
func shouldShowOnboarding(settings store.Settings, err error) bool {
|
||||
return err != nil || settings.OnboardingVersion < store.CurrentOnboardingVersion
|
||||
}
|
||||
|
||||
func runInitialWindowsUI(
|
||||
startHidden bool,
|
||||
showOnboarding bool,
|
||||
urlSchemeRequest string,
|
||||
startHiddenFn func(),
|
||||
handleURLFn func(string),
|
||||
showOnboardingFn func(),
|
||||
) {
|
||||
if urlSchemeRequest != "" {
|
||||
handleURLFn(urlSchemeRequest)
|
||||
return
|
||||
}
|
||||
if startHidden {
|
||||
startHiddenFn()
|
||||
return
|
||||
}
|
||||
if showOnboarding {
|
||||
showOnboardingFn()
|
||||
}
|
||||
}
|
||||
|
||||
func startHiddenTasks() {
|
||||
// If an upgrade is ready and we're in hidden mode, perform it at startup.
|
||||
// If we're not in hidden mode, we want to start as fast as possible and not
|
||||
@@ -432,7 +464,7 @@ func checkUserLoggedIn(uiServerPort int) bool {
|
||||
func handleConnectURLScheme() {
|
||||
if checkUserLoggedIn(uiServerPort) {
|
||||
slog.Info("user is already logged in, opening app instead")
|
||||
showWindow(wv.webview.Window())
|
||||
openUI("/")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -491,17 +523,23 @@ func parseURLScheme(urlSchemeRequest string) (isConnect bool, err error) {
|
||||
|
||||
// handleURLSchemeInCurrentInstance processes URL scheme requests in the current instance
|
||||
func handleURLSchemeInCurrentInstance(urlSchemeRequest string) {
|
||||
isConnect, err := parseURLScheme(urlSchemeRequest)
|
||||
err := dispatchURLSchemeRequest(urlSchemeRequest, handleConnectURLScheme, func() {
|
||||
openUI("/")
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("failed to parse URL scheme request", "url", urlSchemeRequest, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
if isConnect {
|
||||
handleConnectURLScheme()
|
||||
} else {
|
||||
if wv.webview != nil {
|
||||
showWindow(wv.webview.Window())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func dispatchURLSchemeRequest(urlSchemeRequest string, connect, open func()) error {
|
||||
isConnect, err := parseURLScheme(urlSchemeRequest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if isConnect {
|
||||
connect()
|
||||
} else {
|
||||
open()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
+16
-10
@@ -54,14 +54,17 @@ func StartUI(path *C.cchar_t) {
|
||||
|
||||
//export ShowUI
|
||||
func ShowUI() {
|
||||
// If webview is already running, just show the window
|
||||
openUI("/")
|
||||
}
|
||||
|
||||
func openUI(path string) {
|
||||
if wv.IsRunning() && wv.webview != nil {
|
||||
showWindow(wv.webview.Window())
|
||||
} else {
|
||||
root := C.CString("/")
|
||||
defer C.free(unsafe.Pointer(root))
|
||||
StartUI(root)
|
||||
return
|
||||
}
|
||||
p := C.CString(path)
|
||||
defer C.free(unsafe.Pointer(p))
|
||||
StartUI(p)
|
||||
}
|
||||
|
||||
//export StopUI
|
||||
@@ -172,13 +175,13 @@ func UpdateAvailable(ver string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func osRun(_ func(), hasCompletedFirstRun, startHidden bool) {
|
||||
func osRun(_ func(), hasCompletedFirstRun, startHidden, showOnboarding bool, _ string) {
|
||||
registerLaunchAgent(hasCompletedFirstRun)
|
||||
|
||||
// Run the native macOS app
|
||||
// Note: this will block until the app is closed
|
||||
slog.Debug("starting native darwin event loop")
|
||||
C.run(C._Bool(hasCompletedFirstRun), C._Bool(startHidden))
|
||||
C.run(C._Bool(showOnboarding), C._Bool(startHidden))
|
||||
}
|
||||
|
||||
func quit() {
|
||||
@@ -229,6 +232,11 @@ func styleWindow(ptr unsafe.Pointer) {
|
||||
C.styleWindow(C.uintptr_t(uintptr(ptr)))
|
||||
}
|
||||
|
||||
func setOnboardingWindowStyle(ptr unsafe.Pointer, enabled bool) {
|
||||
styleWindow(ptr)
|
||||
C.setWindowResizable(C.uintptr_t(uintptr(ptr)), C.bool(!enabled))
|
||||
}
|
||||
|
||||
func runInBackground() {
|
||||
cmd := exec.Command(filepath.Join(updater.BundlePath, "Contents", "MacOS", "Ollama"), "hidden")
|
||||
if cmd != nil {
|
||||
@@ -257,6 +265,4 @@ func handleConnectURL() {
|
||||
}
|
||||
|
||||
// checkAndHandleExistingInstance is not needed on non-Windows platforms
|
||||
func checkAndHandleExistingInstance(_ string) bool {
|
||||
return false
|
||||
}
|
||||
func checkAndHandleExistingInstance(_ string) {}
|
||||
|
||||
@@ -16,7 +16,7 @@ enum AppMove
|
||||
MoveError,
|
||||
};
|
||||
|
||||
void run(bool firstTimeRun, bool startHidden);
|
||||
void run(bool showOnboarding, bool startHidden);
|
||||
void killOtherInstances();
|
||||
enum AppMove askToMoveToApplications();
|
||||
int createSymlinkWithAuthorization();
|
||||
@@ -38,6 +38,7 @@ void setWindowDelegate(void *window);
|
||||
void showWindow(uintptr_t wndPtr);
|
||||
void hideWindow(uintptr_t wndPtr);
|
||||
void styleWindow(uintptr_t wndPtr);
|
||||
void setWindowResizable(uintptr_t wndPtr, bool resizable);
|
||||
void drag(uintptr_t wndPtr);
|
||||
void doubleClick(uintptr_t wndPtr);
|
||||
void handleConnectURL();
|
||||
|
||||
+47
-35
@@ -19,7 +19,27 @@ extern NSString *SystemWidePath;
|
||||
|
||||
@implementation AppDelegate
|
||||
|
||||
bool firstTimeRun,startHidden; // Set in run before initialization
|
||||
bool showOnboarding,startHidden; // Set in run before initialization
|
||||
|
||||
static NSBundle *OllamaResourceBundle(void) {
|
||||
NSBundle *bundle = [NSBundle mainBundle];
|
||||
if ([bundle.bundlePath hasSuffix:@".app"]) {
|
||||
return bundle;
|
||||
}
|
||||
|
||||
NSString *cwdPath = [[NSFileManager defaultManager] currentDirectoryPath];
|
||||
NSArray<NSString *> *bundlePaths = @[
|
||||
[cwdPath stringByAppendingPathComponent:@"darwin/Ollama.app"],
|
||||
[cwdPath stringByAppendingPathComponent:@"app/darwin/Ollama.app"],
|
||||
];
|
||||
for (NSString *bundlePath in bundlePaths) {
|
||||
if ([[NSFileManager defaultManager] fileExistsAtPath:bundlePath]) {
|
||||
return [NSBundle bundleWithPath:bundlePath];
|
||||
}
|
||||
}
|
||||
|
||||
return bundle;
|
||||
}
|
||||
|
||||
- (void)application:(NSApplication *)application openURLs:(NSArray<NSURL *> *)urls {
|
||||
for (NSURL *url in urls) {
|
||||
@@ -30,11 +50,9 @@ bool firstTimeRun,startHidden; // Set in run before initialization
|
||||
// Special case: handle connect by opening browser instead of app
|
||||
handleConnectURL();
|
||||
} else {
|
||||
// Set app to be active and visible
|
||||
[NSApp setActivationPolicy:NSApplicationActivationPolicyRegular];
|
||||
[NSApp activateIgnoringOtherApps:YES];
|
||||
[self openUI];
|
||||
}
|
||||
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -51,29 +69,24 @@ bool firstTimeRun,startHidden; // Set in run before initialization
|
||||
// if we're in development mode, set the app icon
|
||||
NSString *bundlePath = [[NSBundle mainBundle] bundlePath];
|
||||
if (![bundlePath hasSuffix:@".app"]) {
|
||||
NSString *cwdPath =
|
||||
[[NSFileManager defaultManager] currentDirectoryPath];
|
||||
NSString *iconPath = [cwdPath
|
||||
stringByAppendingPathComponent:
|
||||
[NSString
|
||||
stringWithFormat:
|
||||
@"darwin/Ollama.app/Contents/Resources/icon.icns"]];
|
||||
NSString *iconPath =
|
||||
[OllamaResourceBundle() pathForResource:@"icon" ofType:@"icns"];
|
||||
NSImage *customIcon = [[NSImage alloc] initWithContentsOfFile:iconPath];
|
||||
[NSApp setApplicationIconImage:customIcon];
|
||||
}
|
||||
|
||||
// Create status item and menu
|
||||
NSMenu *menu = [[NSMenu alloc] init];
|
||||
[menu addItemWithTitle:@"Settings..."
|
||||
action:@selector(settingsUI)
|
||||
keyEquivalent:@","];
|
||||
|
||||
NSMenuItem *openMenuItem =
|
||||
[[NSMenuItem alloc] initWithTitle:@"Open Ollama"
|
||||
[[NSMenuItem alloc] initWithTitle:@"Open Ollama Chat"
|
||||
action:@selector(openUI)
|
||||
keyEquivalent:@""];
|
||||
[openMenuItem setTarget:self];
|
||||
[menu addItem:openMenuItem];
|
||||
|
||||
[menu addItemWithTitle:@"Settings..."
|
||||
action:@selector(settingsUI)
|
||||
keyEquivalent:@","];
|
||||
[menu addItem:[NSMenuItem separatorItem]];
|
||||
|
||||
NSMenuItem *updateAvailable =
|
||||
@@ -214,10 +227,8 @@ bool firstTimeRun,startHidden; // Set in run before initialization
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if (hidden || startHidden) {
|
||||
darwinStartHiddenTasks();
|
||||
} else {
|
||||
if (!startHidden) {
|
||||
StartUI("/");
|
||||
}
|
||||
} else if (showOnboarding) {
|
||||
StartUI("/");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -264,7 +275,7 @@ bool firstTimeRun,startHidden; // Set in run before initialization
|
||||
}
|
||||
|
||||
- (void)openUI {
|
||||
ShowUI();
|
||||
[self uiRequest:@"/"];
|
||||
}
|
||||
|
||||
- (void)newChat {
|
||||
@@ -325,17 +336,7 @@ bool firstTimeRun,startHidden; // Set in run before initialization
|
||||
}
|
||||
|
||||
NSImage *statusImage;
|
||||
NSBundle *bundle = [NSBundle mainBundle];
|
||||
if (![bundle.bundlePath hasSuffix:@".app"]) {
|
||||
NSString *cwdPath =
|
||||
[[NSFileManager defaultManager] currentDirectoryPath];
|
||||
NSString *bundlePath =
|
||||
[cwdPath stringByAppendingPathComponent:
|
||||
[NSString stringWithFormat:@"darwin/Ollama.app"]];
|
||||
bundle = [NSBundle bundleWithPath:bundlePath];
|
||||
}
|
||||
|
||||
statusImage = [bundle imageForResource:iconName];
|
||||
statusImage = [OllamaResourceBundle() imageForResource:iconName];
|
||||
[statusImage setTemplate:YES];
|
||||
self.statusItem.button.image = statusImage;
|
||||
}
|
||||
@@ -621,12 +622,12 @@ decidePolicyForNavigationAction:(WKNavigationAction *)action
|
||||
@end
|
||||
|
||||
AppDelegate *appDelegate;
|
||||
void run(bool ftr, bool sh) {
|
||||
void run(bool so, bool sh) {
|
||||
[NSApplication sharedApplication];
|
||||
[NSApp setActivationPolicy:NSApplicationActivationPolicyAccessory];
|
||||
appDelegate = [[AppDelegate alloc] init];
|
||||
[NSApp setDelegate:appDelegate];
|
||||
firstTimeRun = ftr;
|
||||
showOnboarding = so;
|
||||
startHidden = sh;
|
||||
[NSApp run];
|
||||
StopUI();
|
||||
@@ -1097,6 +1098,17 @@ void styleWindow(uintptr_t wndPtr) {
|
||||
L.borderWidth = 0.0;
|
||||
}
|
||||
|
||||
void setWindowResizable(uintptr_t wndPtr, bool resizable) {
|
||||
NSWindow *w = (__bridge NSWindow *)wndPtr;
|
||||
if (!w) return;
|
||||
|
||||
if (resizable) {
|
||||
w.styleMask |= NSWindowStyleMaskResizable;
|
||||
} else {
|
||||
w.styleMask &= ~NSWindowStyleMaskResizable;
|
||||
}
|
||||
}
|
||||
|
||||
void drag(uintptr_t wndPtr) {
|
||||
NSWindow *w = (__bridge NSWindow *)wndPtr;
|
||||
if (!w) return;
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
//go:build windows || darwin
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/ollama/ollama/app/store"
|
||||
)
|
||||
|
||||
func TestShouldShowOnboarding(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
settings store.Settings
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "fresh install",
|
||||
settings: store.Settings{OnboardingVersion: 0},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "completed onboarding",
|
||||
settings: store.Settings{OnboardingVersion: store.CurrentOnboardingVersion},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "settings failure",
|
||||
err: errors.New("settings unavailable"),
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := shouldShowOnboarding(tt.settings, tt.err); got != tt.want {
|
||||
t.Fatalf("shouldShowOnboarding() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchURLSchemeRequest(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
request string
|
||||
wantConnect bool
|
||||
wantOpen bool
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "bare URL opens app", request: "ollama://", wantOpen: true},
|
||||
{name: "connect URL starts connection", request: "ollama://connect", wantConnect: true},
|
||||
{name: "unsupported URL", request: "ollama://unsupported", wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
connected := false
|
||||
opened := false
|
||||
err := dispatchURLSchemeRequest(
|
||||
tt.request,
|
||||
func() { connected = true },
|
||||
func() { opened = true },
|
||||
)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("dispatchURLSchemeRequest() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
if connected != tt.wantConnect {
|
||||
t.Errorf("connect called = %v, want %v", connected, tt.wantConnect)
|
||||
}
|
||||
if opened != tt.wantOpen {
|
||||
t.Errorf("open called = %v, want %v", opened, tt.wantOpen)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunInitialWindowsUIWithBareURL(t *testing.T) {
|
||||
hiddenCalls := 0
|
||||
urlCalls := 0
|
||||
onboardingCalls := 0
|
||||
openCalls := 0
|
||||
|
||||
runInitialWindowsUI(
|
||||
false,
|
||||
true,
|
||||
"ollama://",
|
||||
func() { hiddenCalls++ },
|
||||
func(request string) {
|
||||
urlCalls++
|
||||
if err := dispatchURLSchemeRequest(request, func() {}, func() { openCalls++ }); err != nil {
|
||||
t.Fatalf("dispatchURLSchemeRequest() error = %v", err)
|
||||
}
|
||||
},
|
||||
func() { onboardingCalls++ },
|
||||
)
|
||||
|
||||
if urlCalls != 1 {
|
||||
t.Fatalf("URL handled %d times, want 1", urlCalls)
|
||||
}
|
||||
if openCalls != 1 {
|
||||
t.Errorf("app opened %d times, want 1", openCalls)
|
||||
}
|
||||
if hiddenCalls != 0 {
|
||||
t.Errorf("hidden startup called %d times, want 0", hiddenCalls)
|
||||
}
|
||||
if onboardingCalls != 0 {
|
||||
t.Errorf("onboarding opened %d times, want 0", onboardingCalls)
|
||||
}
|
||||
}
|
||||
+15
-27
@@ -95,11 +95,15 @@ func (ac *appCallbacks) UIRun(path string) {
|
||||
}
|
||||
|
||||
func (*appCallbacks) UIShow() {
|
||||
if wv.webview != nil {
|
||||
openUI("/")
|
||||
}
|
||||
|
||||
func openUI(path string) {
|
||||
if wv.IsRunning() && wv.webview != nil {
|
||||
showWindow(wv.webview.Window())
|
||||
} else {
|
||||
wv.Run("/")
|
||||
return
|
||||
}
|
||||
wv.Run(path)
|
||||
}
|
||||
|
||||
func (*appCallbacks) UITerminate() {
|
||||
@@ -138,19 +142,7 @@ func (app *appCallbacks) HandleURLScheme(urlScheme string) {
|
||||
|
||||
// handleURLSchemeRequest processes URL scheme requests from other instances
|
||||
func handleURLSchemeRequest(urlScheme string) {
|
||||
isConnect, err := parseURLScheme(urlScheme)
|
||||
if err != nil {
|
||||
slog.Error("failed to parse URL scheme request", "url", urlScheme, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
if isConnect {
|
||||
handleConnectURLScheme()
|
||||
} else {
|
||||
if wv.webview != nil {
|
||||
showWindow(wv.webview.Window())
|
||||
}
|
||||
}
|
||||
handleURLSchemeInCurrentInstance(urlScheme)
|
||||
}
|
||||
|
||||
func UpdateAvailable(ver string) error {
|
||||
@@ -161,7 +153,7 @@ func UpdateAvailable(ver string) error {
|
||||
return app.t.UpdateAvailable(ver)
|
||||
}
|
||||
|
||||
func osRun(shutdown func(), hasCompletedFirstRun, startHidden bool) {
|
||||
func osRun(shutdown func(), hasCompletedFirstRun, startHidden, showOnboarding bool, urlSchemeRequest string) {
|
||||
var err error
|
||||
app.shutdown = shutdown
|
||||
app.t, err = wintray.NewTray(app)
|
||||
@@ -205,9 +197,7 @@ func osRun(shutdown func(), hasCompletedFirstRun, startHidden bool) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if startHidden {
|
||||
startHiddenTasks()
|
||||
} else {
|
||||
runInitialWindowsUI(startHidden, showOnboarding, urlSchemeRequest, startHiddenTasks, handleURLSchemeInCurrentInstance, func() {
|
||||
ptr := wv.Run("/")
|
||||
|
||||
// Set the window icon using the tray icon
|
||||
@@ -225,7 +215,7 @@ func osRun(shutdown func(), hasCompletedFirstRun, startHidden bool) {
|
||||
}
|
||||
|
||||
centerWindow(ptr)
|
||||
}
|
||||
})
|
||||
|
||||
if !hasCompletedFirstRun {
|
||||
// Only create the login shortcut on first start
|
||||
@@ -408,6 +398,8 @@ func hideWindow(ptr unsafe.Pointer) {
|
||||
}
|
||||
}
|
||||
|
||||
func setOnboardingWindowStyle(_ unsafe.Pointer, _ bool) {}
|
||||
|
||||
func runInBackground() {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
@@ -432,17 +424,13 @@ func drag(ptr unsafe.Pointer) {}
|
||||
func doubleClick(ptr unsafe.Pointer) {}
|
||||
|
||||
// checkAndHandleExistingInstance checks if another instance is running and sends the URL to it
|
||||
func checkAndHandleExistingInstance(urlSchemeRequest string) bool {
|
||||
func checkAndHandleExistingInstance(urlSchemeRequest string) {
|
||||
if urlSchemeRequest == "" {
|
||||
return false
|
||||
return
|
||||
}
|
||||
|
||||
// Try to send URL to existing instance using wintray messaging
|
||||
if wintray.CheckAndSendToExistingInstance(urlSchemeRequest) {
|
||||
os.Exit(0)
|
||||
return true
|
||||
}
|
||||
|
||||
// No existing instance, we'll handle it ourselves
|
||||
return false
|
||||
}
|
||||
|
||||
+40
-5
@@ -24,6 +24,13 @@ import (
|
||||
"github.com/ollama/ollama/app/webview"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultWindowWidth = 1360
|
||||
defaultWindowHeight = 960
|
||||
onboardingWindowWidth = 900
|
||||
onboardingWindowHeight = 660
|
||||
)
|
||||
|
||||
type Webview struct {
|
||||
port int
|
||||
token string
|
||||
@@ -237,10 +244,38 @@ func (w *Webview) Run(path string) unsafe.Pointer {
|
||||
showWindow(wv.Window())
|
||||
})
|
||||
|
||||
wv.Bind("activateOllama", func() {
|
||||
showWindow(wv.Window())
|
||||
})
|
||||
|
||||
wv.Bind("close", func() {
|
||||
hideWindow(wv.Window())
|
||||
})
|
||||
|
||||
wv.Bind("setOnboardingWindow", func(enabled bool) {
|
||||
wv.Dispatch(func() {
|
||||
if enabled {
|
||||
wv.SetSize(onboardingWindowWidth, onboardingWindowHeight, webview.HintFixed)
|
||||
setOnboardingWindowStyle(wv.Window(), true)
|
||||
return
|
||||
}
|
||||
|
||||
width, height := defaultWindowWidth, defaultWindowHeight
|
||||
if w.Store != nil {
|
||||
storedWidth, storedHeight, err := w.Store.WindowSize()
|
||||
if err != nil {
|
||||
slog.Error("failed to restore window size", "error", err)
|
||||
} else if storedWidth > 0 && storedHeight > 0 {
|
||||
width, height = storedWidth, storedHeight
|
||||
}
|
||||
}
|
||||
|
||||
wv.SetSize(width, height, webview.HintNone)
|
||||
wv.SetSize(800, 600, webview.HintMin)
|
||||
setOnboardingWindowStyle(wv.Window(), false)
|
||||
})
|
||||
})
|
||||
|
||||
// Webviews do not allow access to the file system by default, so we need to
|
||||
// bind file system operations here
|
||||
wv.Bind("selectModelsDirectory", func() {
|
||||
@@ -450,17 +485,17 @@ func (w *Webview) Run(path string) unsafe.Pointer {
|
||||
}()
|
||||
}
|
||||
|
||||
width, height := defaultWindowWidth, defaultWindowHeight
|
||||
if w.Store != nil {
|
||||
width, height, err := w.Store.WindowSize()
|
||||
storedWidth, storedHeight, 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)
|
||||
if storedWidth > 0 && storedHeight > 0 {
|
||||
width, height = storedWidth, storedHeight
|
||||
}
|
||||
}
|
||||
wv.SetSize(width, height, webview.HintNone)
|
||||
wv.SetSize(800, 600, webview.HintMin)
|
||||
|
||||
w.webview = wv
|
||||
|
||||
+32
-22
@@ -14,7 +14,7 @@ import (
|
||||
|
||||
// currentSchemaVersion defines the current database schema version.
|
||||
// Increment this when making schema changes that require migrations.
|
||||
const currentSchemaVersion = 16
|
||||
const currentSchemaVersion = 17
|
||||
|
||||
// database wraps the SQLite connection.
|
||||
// SQLite handles its own locking for concurrent access:
|
||||
@@ -82,7 +82,8 @@ func (db *database) init() error {
|
||||
websearch_enabled BOOLEAN NOT NULL DEFAULT 0,
|
||||
selected_model TEXT NOT NULL DEFAULT '',
|
||||
sidebar_open BOOLEAN NOT NULL DEFAULT 0,
|
||||
last_home_view TEXT NOT NULL DEFAULT 'launch',
|
||||
last_home_view TEXT NOT NULL DEFAULT 'chat',
|
||||
onboarding_version INTEGER NOT NULL DEFAULT 0,
|
||||
think_enabled BOOLEAN NOT NULL DEFAULT 0,
|
||||
think_level TEXT NOT NULL DEFAULT '',
|
||||
cloud_setting_migrated BOOLEAN NOT NULL DEFAULT 0,
|
||||
@@ -271,6 +272,12 @@ func (db *database) migrate() error {
|
||||
return fmt.Errorf("migrate v15 to v16: %w", err)
|
||||
}
|
||||
version = 16
|
||||
case 16:
|
||||
// Existing users should not be shown onboarding after an upgrade.
|
||||
if err := db.migrateV16ToV17(); err != nil {
|
||||
return fmt.Errorf("migrate v16 to v17: %w", err)
|
||||
}
|
||||
version = 17
|
||||
default:
|
||||
// If we have a version we don't recognize, just set it to current
|
||||
// This might happen during development
|
||||
@@ -527,7 +534,7 @@ func (db *database) migrateV14ToV15() error {
|
||||
|
||||
// migrateV15ToV16 adds the last_home_view column to the settings table
|
||||
func (db *database) migrateV15ToV16() error {
|
||||
_, err := db.conn.Exec(`ALTER TABLE settings ADD COLUMN last_home_view TEXT NOT NULL DEFAULT 'launch'`)
|
||||
_, err := db.conn.Exec(`ALTER TABLE settings ADD COLUMN last_home_view TEXT NOT NULL DEFAULT 'chat'`)
|
||||
if err != nil && !duplicateColumnError(err) {
|
||||
return fmt.Errorf("add last_home_view column: %w", err)
|
||||
}
|
||||
@@ -540,6 +547,23 @@ func (db *database) migrateV15ToV16() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateV16ToV17 adds versioned onboarding state. The schema default stays at
|
||||
// zero for genuinely new installs, while all existing rows are marked complete
|
||||
// and moved off the retired launch home view.
|
||||
func (db *database) migrateV16ToV17() error {
|
||||
_, err := db.conn.Exec(`ALTER TABLE settings ADD COLUMN onboarding_version INTEGER NOT NULL DEFAULT 0`)
|
||||
if err != nil && !duplicateColumnError(err) {
|
||||
return fmt.Errorf("add onboarding_version column: %w", err)
|
||||
}
|
||||
|
||||
_, err = db.conn.Exec(`UPDATE settings SET onboarding_version = 1, last_home_view = 'chat', schema_version = 17`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("complete onboarding for existing users: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// cleanupOrphanedData removes orphaned records that may exist due to the foreign key bug
|
||||
func (db *database) cleanupOrphanedData() error {
|
||||
_, err := db.conn.Exec(`
|
||||
@@ -1188,9 +1212,9 @@ func (db *database) getSettings() (Settings, error) {
|
||||
var s Settings
|
||||
|
||||
err := db.conn.QueryRow(`
|
||||
SELECT expose, survey, browser, models, agent, tools, working_dir, context_length, turbo_enabled, websearch_enabled, selected_model, sidebar_open, last_home_view, think_enabled, think_level, auto_update_enabled
|
||||
SELECT expose, survey, browser, models, agent, tools, working_dir, context_length, turbo_enabled, websearch_enabled, selected_model, sidebar_open, last_home_view, onboarding_version, think_enabled, think_level, auto_update_enabled
|
||||
FROM settings
|
||||
`).Scan(&s.Expose, &s.Survey, &s.Browser, &s.Models, &s.Agent, &s.Tools, &s.WorkingDir, &s.ContextLength, &s.TurboEnabled, &s.WebSearchEnabled, &s.SelectedModel, &s.SidebarOpen, &s.LastHomeView, &s.ThinkEnabled, &s.ThinkLevel, &s.AutoUpdateEnabled)
|
||||
`).Scan(&s.Expose, &s.Survey, &s.Browser, &s.Models, &s.Agent, &s.Tools, &s.WorkingDir, &s.ContextLength, &s.TurboEnabled, &s.WebSearchEnabled, &s.SelectedModel, &s.SidebarOpen, &s.LastHomeView, &s.OnboardingVersion, &s.ThinkEnabled, &s.ThinkLevel, &s.AutoUpdateEnabled)
|
||||
if err != nil {
|
||||
return Settings{}, fmt.Errorf("get settings: %w", err)
|
||||
}
|
||||
@@ -1200,28 +1224,14 @@ func (db *database) getSettings() (Settings, error) {
|
||||
|
||||
func (db *database) setSettings(s Settings) error {
|
||||
lastHomeView := strings.ToLower(strings.TrimSpace(s.LastHomeView))
|
||||
validLaunchView := map[string]struct{}{
|
||||
"launch": {},
|
||||
"openclaw": {},
|
||||
"claude": {},
|
||||
"hermes": {},
|
||||
"codex": {},
|
||||
"codex-app": {},
|
||||
"copilot": {},
|
||||
"opencode": {},
|
||||
"droid": {},
|
||||
"pi": {},
|
||||
}
|
||||
if lastHomeView != "chat" {
|
||||
if _, ok := validLaunchView[lastHomeView]; !ok {
|
||||
lastHomeView = "launch"
|
||||
}
|
||||
lastHomeView = "chat"
|
||||
}
|
||||
|
||||
_, err := db.conn.Exec(`
|
||||
UPDATE settings
|
||||
SET expose = ?, survey = ?, browser = ?, models = ?, agent = ?, tools = ?, working_dir = ?, context_length = ?, turbo_enabled = ?, websearch_enabled = ?, selected_model = ?, sidebar_open = ?, last_home_view = ?, think_enabled = ?, think_level = ?, auto_update_enabled = ?
|
||||
`, s.Expose, s.Survey, s.Browser, s.Models, s.Agent, s.Tools, s.WorkingDir, s.ContextLength, s.TurboEnabled, s.WebSearchEnabled, s.SelectedModel, s.SidebarOpen, lastHomeView, s.ThinkEnabled, s.ThinkLevel, s.AutoUpdateEnabled)
|
||||
SET expose = ?, survey = ?, browser = ?, models = ?, agent = ?, tools = ?, working_dir = ?, context_length = ?, turbo_enabled = ?, websearch_enabled = ?, selected_model = ?, sidebar_open = ?, last_home_view = ?, onboarding_version = ?, think_enabled = ?, think_level = ?, auto_update_enabled = ?
|
||||
`, s.Expose, s.Survey, s.Browser, s.Models, s.Agent, s.Tools, s.WorkingDir, s.ContextLength, s.TurboEnabled, s.WebSearchEnabled, s.SelectedModel, s.SidebarOpen, lastHomeView, s.OnboardingVersion, s.ThinkEnabled, s.ThinkLevel, s.AutoUpdateEnabled)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set settings: %w", err)
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ func TestMigrationV13ToV14ContextLength(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrationV15ToV16LastHomeViewDefaultsToLaunch(t *testing.T) {
|
||||
func TestMigrationV15ToV16LastHomeViewMigratesToChat(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
dbPath := filepath.Join(tmpDir, "test.db")
|
||||
|
||||
@@ -161,8 +161,8 @@ func TestMigrationV15ToV16LastHomeViewDefaultsToLaunch(t *testing.T) {
|
||||
t.Fatalf("failed to read last_home_view: %v", err)
|
||||
}
|
||||
|
||||
if lastHomeView != "launch" {
|
||||
t.Fatalf("expected last_home_view to default to launch after migration, got %q", lastHomeView)
|
||||
if lastHomeView != "chat" {
|
||||
t.Fatalf("expected last_home_view to migrate to chat, got %q", lastHomeView)
|
||||
}
|
||||
|
||||
version, err := db.getSchemaVersion()
|
||||
@@ -174,6 +174,53 @@ func TestMigrationV15ToV16LastHomeViewDefaultsToLaunch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnboardingVersionDefaultsAndMigration(t *testing.T) {
|
||||
t.Run("fresh installs need onboarding", func(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "fresh.db")
|
||||
db, err := newDatabase(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
settings, err := db.getSettings()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read settings: %v", err)
|
||||
}
|
||||
if settings.OnboardingVersion != 0 {
|
||||
t.Fatalf("expected fresh install onboarding version 0, got %d", settings.OnboardingVersion)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("existing installs skip onboarding", func(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "existing.db")
|
||||
db, err := newDatabase(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if _, err := db.conn.Exec(`
|
||||
ALTER TABLE settings DROP COLUMN onboarding_version;
|
||||
UPDATE settings SET schema_version = 16;
|
||||
`); err != nil {
|
||||
t.Fatalf("failed to seed v16 settings row: %v", err)
|
||||
}
|
||||
|
||||
if err := db.migrate(); err != nil {
|
||||
t.Fatalf("migration from v16 to v17 failed: %v", err)
|
||||
}
|
||||
|
||||
settings, err := db.getSettings()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read settings: %v", err)
|
||||
}
|
||||
if settings.OnboardingVersion != 1 {
|
||||
t.Fatalf("expected existing install onboarding version 1, got %d", settings.OnboardingVersion)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestChatDeletionWithCascade(t *testing.T) {
|
||||
t.Run("chat deletion cascades to related messages", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
@@ -57,6 +57,14 @@ func TestConfigMigration(t *testing.T) {
|
||||
t.Error("expected has completed first run to be true after migration")
|
||||
}
|
||||
|
||||
settings, err := s.Settings()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get settings: %v", err)
|
||||
}
|
||||
if settings.OnboardingVersion != CurrentOnboardingVersion {
|
||||
t.Fatalf("expected migrated user to skip onboarding, got version %d", settings.OnboardingVersion)
|
||||
}
|
||||
|
||||
// Verify migration is marked as complete
|
||||
migrated, err := s.db.isConfigMigrated()
|
||||
if err != nil {
|
||||
|
||||
+18
-2
@@ -167,13 +167,19 @@ type Settings struct {
|
||||
// SidebarOpen indicates if the chat sidebar is open
|
||||
SidebarOpen bool
|
||||
|
||||
// LastHomeView stores the preferred home route target ("chat" or integration name)
|
||||
// LastHomeView is retained for settings compatibility and resolves to chat.
|
||||
LastHomeView string
|
||||
|
||||
// OnboardingVersion stores the latest onboarding flow the user has completed.
|
||||
OnboardingVersion int
|
||||
|
||||
// AutoUpdateEnabled indicates if automatic updates should be downloaded
|
||||
AutoUpdateEnabled bool
|
||||
}
|
||||
|
||||
// Keep in sync with CURRENT_ONBOARDING_VERSION in app/ui/app/src/lib/onboarding.ts.
|
||||
const CurrentOnboardingVersion = 1
|
||||
|
||||
type Store struct {
|
||||
// DBPath allows overriding the default database path (mainly for testing)
|
||||
DBPath string
|
||||
@@ -334,6 +340,16 @@ func (s *Store) migrateFromConfig(database *database) error {
|
||||
if err := database.setHasCompletedFirstRun(hasCompleted); err != nil {
|
||||
return fmt.Errorf("migrate first time run: %w", err)
|
||||
}
|
||||
if hasCompleted {
|
||||
settings, err := database.getSettings()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read settings for onboarding migration: %w", err)
|
||||
}
|
||||
settings.OnboardingVersion = CurrentOnboardingVersion
|
||||
if err := database.setSettings(settings); err != nil {
|
||||
return fmt.Errorf("migrate onboarding completion: %w", err)
|
||||
}
|
||||
}
|
||||
slog.Info("migrated first run status from config.json", "hasCompleted", hasCompleted)
|
||||
|
||||
// Mark as migrated
|
||||
@@ -393,7 +409,7 @@ func (s *Store) Settings() (Settings, error) {
|
||||
}
|
||||
|
||||
if settings.LastHomeView == "" {
|
||||
settings.LastHomeView = "launch"
|
||||
settings.LastHomeView = "chat"
|
||||
}
|
||||
|
||||
return settings, nil
|
||||
|
||||
+38
-12
@@ -81,18 +81,18 @@ func TestStore(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("settings default home view is launch", func(t *testing.T) {
|
||||
t.Run("settings default home view is chat", func(t *testing.T) {
|
||||
loaded, err := s.Settings()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if loaded.LastHomeView != "launch" {
|
||||
t.Fatalf("expected default LastHomeView to be launch, got %q", loaded.LastHomeView)
|
||||
if loaded.LastHomeView != "chat" {
|
||||
t.Fatalf("expected default LastHomeView to be chat, got %q", loaded.LastHomeView)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("settings empty home view falls back to launch", func(t *testing.T) {
|
||||
t.Run("settings empty home view falls back to chat", func(t *testing.T) {
|
||||
if err := s.SetSettings(Settings{LastHomeView: ""}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -102,12 +102,12 @@ func TestStore(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if loaded.LastHomeView != "launch" {
|
||||
t.Fatalf("expected empty LastHomeView to fall back to launch, got %q", loaded.LastHomeView)
|
||||
if loaded.LastHomeView != "chat" {
|
||||
t.Fatalf("expected empty LastHomeView to fall back to chat, got %q", loaded.LastHomeView)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("settings disabled home view falls back to launch", func(t *testing.T) {
|
||||
t.Run("settings retired home view falls back to chat", func(t *testing.T) {
|
||||
if err := s.SetSettings(Settings{LastHomeView: "claude-desktop"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -117,12 +117,12 @@ func TestStore(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if loaded.LastHomeView != "launch" {
|
||||
t.Fatalf("expected disabled LastHomeView to fall back to launch, got %q", loaded.LastHomeView)
|
||||
if loaded.LastHomeView != "chat" {
|
||||
t.Fatalf("expected retired LastHomeView to fall back to chat, got %q", loaded.LastHomeView)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("settings codex app home view is accepted", func(t *testing.T) {
|
||||
t.Run("settings integration home view falls back to chat", func(t *testing.T) {
|
||||
if err := s.SetSettings(Settings{LastHomeView: "codex-app"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -132,8 +132,8 @@ func TestStore(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if loaded.LastHomeView != "codex-app" {
|
||||
t.Fatalf("expected codex-app LastHomeView to be preserved, got %q", loaded.LastHomeView)
|
||||
if loaded.LastHomeView != "chat" {
|
||||
t.Fatalf("expected integration LastHomeView to fall back to chat, got %q", loaded.LastHomeView)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -227,6 +227,32 @@ func TestStore(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestOnboardingVersionRoundTrip(t *testing.T) {
|
||||
s, cleanup := setupTestStore(t)
|
||||
defer cleanup()
|
||||
|
||||
settings, err := s.Settings()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if settings.OnboardingVersion != 0 {
|
||||
t.Fatalf("expected onboarding version 0 by default, got %d", settings.OnboardingVersion)
|
||||
}
|
||||
|
||||
settings.OnboardingVersion = 1
|
||||
if err := s.SetSettings(settings); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
loaded, err := s.Settings()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.OnboardingVersion != 1 {
|
||||
t.Fatalf("expected onboarding version 1, got %d", loaded.OnboardingVersion)
|
||||
}
|
||||
}
|
||||
|
||||
// setupTestStore creates a temporary store for testing
|
||||
func setupTestStore(t *testing.T) (*Store, func()) {
|
||||
t.Helper()
|
||||
|
||||
@@ -415,6 +415,7 @@ export class Settings {
|
||||
SelectedModel: string;
|
||||
SidebarOpen: boolean;
|
||||
LastHomeView: string;
|
||||
OnboardingVersion: number;
|
||||
AutoUpdateEnabled: boolean;
|
||||
|
||||
constructor(source: any = {}) {
|
||||
@@ -434,6 +435,7 @@ export class Settings {
|
||||
this.SelectedModel = source["SelectedModel"];
|
||||
this.SidebarOpen = source["SidebarOpen"];
|
||||
this.LastHomeView = source["LastHomeView"];
|
||||
this.OnboardingVersion = source["OnboardingVersion"];
|
||||
this.AutoUpdateEnabled = source["AutoUpdateEnabled"];
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 10 KiB |
@@ -0,0 +1,27 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { fetchConnectUrl } from "./api";
|
||||
|
||||
describe("fetchConnectUrl", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("requests a desktop handoff after account creation", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
signin_url:
|
||||
"https://ollama.com/connect?name=MacBook&key=public-key",
|
||||
}),
|
||||
{ status: 401 },
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await expect(fetchConnectUrl()).resolves.toBe(
|
||||
"https://ollama.com/connect?name=MacBook&key=public-key&launch=true",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -81,7 +81,9 @@ export async function fetchConnectUrl(): Promise<string> {
|
||||
if (response.status === 401) {
|
||||
const data = await response.json();
|
||||
if (data.signin_url) {
|
||||
return data.signin_url;
|
||||
const connectUrl = new URL(data.signin_url);
|
||||
connectUrl.searchParams.set("launch", "true");
|
||||
return connectUrl.toString();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -418,7 +420,9 @@ export interface ModelRecommendationsResponse {
|
||||
recommendations: ModelRecommendation[];
|
||||
}
|
||||
|
||||
export async function getModelRecommendations(): Promise<ModelRecommendation[]> {
|
||||
export async function getModelRecommendations(): Promise<
|
||||
ModelRecommendation[]
|
||||
> {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/api/experimental/model-recommendations`,
|
||||
);
|
||||
|
||||
@@ -6,14 +6,12 @@ import { getChat } from "@/api";
|
||||
import { Link } from "@/components/ui/link";
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
|
||||
import { ChatsResponse } from "@/gotypes";
|
||||
import { CogIcon, RocketLaunchIcon } from "@heroicons/react/24/outline";
|
||||
import { CogIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
// there's a hidden debug feature to copy a chat's data to the clipboard by
|
||||
// holding shift and clicking this many times within this many seconds
|
||||
const DEBUG_SHIFT_CLICKS_REQUIRED = 5;
|
||||
const DEBUG_SHIFT_CLICK_WINDOW_MS = 7000; // 7 seconds
|
||||
const launchSidebarRequestedKey = "ollama.launchSidebarRequested";
|
||||
|
||||
interface ChatSidebarProps {
|
||||
currentChatId?: string;
|
||||
}
|
||||
@@ -268,8 +266,9 @@ export function ChatSidebar({ currentChatId }: ChatSidebarProps) {
|
||||
<Link
|
||||
href="/c/new"
|
||||
mask={{ to: "/" }}
|
||||
className={`flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-800 dark:text-neutral-100 ${currentChatId === "new" ? "bg-neutral-100 dark:bg-neutral-800" : ""
|
||||
}`}
|
||||
className={`flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-800 dark:text-neutral-100 ${
|
||||
currentChatId === "new" ? "bg-neutral-100 dark:bg-neutral-800" : ""
|
||||
}`}
|
||||
draggable={false}
|
||||
>
|
||||
<svg
|
||||
@@ -283,23 +282,6 @@ export function ChatSidebar({ currentChatId }: ChatSidebarProps) {
|
||||
</svg>
|
||||
<span className="truncate">New Chat</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/c/$chatId"
|
||||
params={{ chatId: "launch" }}
|
||||
onClick={() => {
|
||||
if (currentChatId !== "launch") {
|
||||
sessionStorage.setItem(launchSidebarRequestedKey, "1");
|
||||
}
|
||||
}}
|
||||
className={`flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-800 dark:text-neutral-100 cursor-pointer ${currentChatId === "launch"
|
||||
? "bg-neutral-100 dark:bg-neutral-800"
|
||||
: ""
|
||||
}`}
|
||||
draggable={false}
|
||||
>
|
||||
<RocketLaunchIcon className="h-5 w-5 stroke-current" />
|
||||
<span className="truncate">Launch</span>
|
||||
</Link>
|
||||
{isWindows && (
|
||||
<Link
|
||||
href="/settings"
|
||||
@@ -321,18 +303,19 @@ export function ChatSidebar({ currentChatId }: ChatSidebarProps) {
|
||||
{group.chats.map((chat) => (
|
||||
<div
|
||||
key={chat.id}
|
||||
className={`allow-context-menu flex items-center relative text-sm text-neutral-800 dark:text-neutral-400 rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-800 ${chat.id === currentChatId
|
||||
? "bg-neutral-100 text-black dark:bg-neutral-800"
|
||||
: ""
|
||||
}`}
|
||||
className={`allow-context-menu flex items-center relative text-sm text-neutral-800 dark:text-neutral-400 rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-800 ${
|
||||
chat.id === currentChatId
|
||||
? "bg-neutral-100 text-black dark:bg-neutral-800"
|
||||
: ""
|
||||
}`}
|
||||
onMouseEnter={() => handleMouseEnter(chat.id)}
|
||||
onContextMenu={(e) =>
|
||||
handleContextMenu(
|
||||
e,
|
||||
chat.id,
|
||||
chat.title ||
|
||||
chat.userExcerpt ||
|
||||
chat.createdAt.toLocaleString(),
|
||||
chat.userExcerpt ||
|
||||
chat.createdAt.toLocaleString(),
|
||||
)
|
||||
}
|
||||
>
|
||||
|
||||
@@ -68,7 +68,7 @@ const CopyButton: React.FC<CopyButtonProps> = ({
|
||||
const iconSize = size === "sm" ? "h-3 w-3" : "h-7 w-7";
|
||||
const baseClasses =
|
||||
size === "sm"
|
||||
? `text-xs px-4 py-2 z-10 rounded-lg hover:cursor-pointer ${className}`
|
||||
? `text-xs px-4 py-2 z-10 cursor-pointer rounded-lg ${className}`
|
||||
: `${iconSize} px-1 py-0.5 text-xs cursor-pointer rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-800 flex items-center justify-center ${className}`;
|
||||
|
||||
const icon = isCopied ? (
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
import { useSettings } from "@/hooks/useSettings";
|
||||
import CopyButton from "@/components/CopyButton";
|
||||
|
||||
interface LaunchCommand {
|
||||
id: string;
|
||||
name: string;
|
||||
command: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
darkIcon?: string;
|
||||
iconClassName?: string;
|
||||
borderless?: boolean;
|
||||
}
|
||||
|
||||
const LAUNCH_COMMANDS: LaunchCommand[] = [
|
||||
{
|
||||
id: "claude",
|
||||
name: "Claude Code",
|
||||
command: "ollama launch claude",
|
||||
description: "Anthropic's coding tool with subagents",
|
||||
icon: "/launch-icons/claude-code.svg",
|
||||
iconClassName: "h-7 w-7",
|
||||
},
|
||||
{
|
||||
id: "chatgpt",
|
||||
name: "ChatGPT",
|
||||
command: "ollama launch chatgpt",
|
||||
description: "Complete work with ChatGPT",
|
||||
icon: "/launch-icons/codex-app.png",
|
||||
iconClassName: "h-full w-full",
|
||||
},
|
||||
{
|
||||
id: "hermes",
|
||||
name: "Hermes Agent",
|
||||
command: "ollama launch hermes",
|
||||
description: "Self-improving AI agent built by Nous Research",
|
||||
icon: "/launch-icons/hermes-agent.svg",
|
||||
iconClassName: "h-7 w-7",
|
||||
},
|
||||
{
|
||||
id: "openclaw",
|
||||
name: "OpenClaw",
|
||||
command: "ollama launch openclaw",
|
||||
description: "Personal AI with 100+ skills",
|
||||
icon: "/launch-icons/openclaw.svg",
|
||||
},
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
command: "ollama launch opencode",
|
||||
description: "Anomaly's open-source coding agent",
|
||||
icon: "/launch-icons/opencode.svg",
|
||||
iconClassName: "h-7 w-7 rounded",
|
||||
},
|
||||
{
|
||||
id: "codex",
|
||||
name: "Codex",
|
||||
command: "ollama launch codex",
|
||||
description: "OpenAI's open-source coding agent",
|
||||
icon: "/launch-icons/codex.svg",
|
||||
darkIcon: "/launch-icons/codex-dark.svg",
|
||||
iconClassName: "h-7 w-7",
|
||||
},
|
||||
{
|
||||
id: "copilot",
|
||||
name: "Copilot CLI",
|
||||
command: "ollama launch copilot",
|
||||
description: "GitHub's AI coding agent for the terminal",
|
||||
icon: "/launch-icons/copilot.svg",
|
||||
darkIcon: "/launch-icons/copilot-dark.svg",
|
||||
iconClassName: "h-7 w-7",
|
||||
},
|
||||
{
|
||||
id: "droid",
|
||||
name: "Droid",
|
||||
command: "ollama launch droid",
|
||||
description: "Factory's coding agent across terminal and IDEs",
|
||||
icon: "/launch-icons/droid.svg",
|
||||
},
|
||||
{
|
||||
id: "dsh",
|
||||
name: "DeepSeek Harness",
|
||||
command: "ollama launch dsh",
|
||||
description: "DeepSeek's open-source agent harness",
|
||||
icon: "/launch-icons/deepseek-harness.svg",
|
||||
iconClassName: "h-7 w-7",
|
||||
},
|
||||
{
|
||||
id: "pi",
|
||||
name: "Pi",
|
||||
command: "ollama launch pi",
|
||||
description: "Minimal AI agent toolkit with plugin support",
|
||||
icon: "/launch-icons/pi.svg",
|
||||
darkIcon: "/launch-icons/pi-dark.svg",
|
||||
iconClassName: "h-7 w-7",
|
||||
},
|
||||
];
|
||||
|
||||
export default function LaunchCommands() {
|
||||
const isWindows = navigator.platform.toLowerCase().includes("win");
|
||||
const { setSettings } = useSettings();
|
||||
|
||||
const renderCommandCard = (item: LaunchCommand) => (
|
||||
<div key={item.command} className="w-full text-left">
|
||||
<div className="flex items-start gap-4 sm:gap-5">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-lg overflow-hidden ${item.borderless ? "" : "border border-neutral-200 bg-white dark:border-neutral-700 dark:bg-neutral-900"}`}
|
||||
>
|
||||
{item.darkIcon ? (
|
||||
<picture>
|
||||
<source srcSet={item.darkIcon} media="(prefers-color-scheme: dark)" />
|
||||
<img src={item.icon} alt="" className={`${item.iconClassName ?? "h-8 w-8"} rounded-sm`} />
|
||||
</picture>
|
||||
) : (
|
||||
<img src={item.icon} alt="" className={item.borderless ? "h-full w-full rounded-xl" : `${item.iconClassName ?? "h-8 w-8"} rounded-sm`} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{item.name}
|
||||
</span>
|
||||
<p className="mt-0.5 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{item.description}
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-2 rounded-xl border-neutral-200 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-800 px-3 py-2">
|
||||
<code className="min-w-0 flex-1 truncate text-xs text-neutral-600 dark:text-neutral-300">
|
||||
{item.command}
|
||||
</code>
|
||||
<CopyButton
|
||||
content={item.command}
|
||||
size="md"
|
||||
title="Copy command to clipboard"
|
||||
className="text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200 hover:bg-neutral-200/60 dark:hover:bg-neutral-700/70"
|
||||
onCopy={() => {
|
||||
setSettings({ LastHomeView: item.id }).catch(() => { });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="flex h-screen w-full flex-col relative">
|
||||
<section
|
||||
className={`flex-1 overflow-y-auto overscroll-contain relative min-h-0 ${isWindows ? "xl:pt-4" : "xl:pt-8"}`}
|
||||
>
|
||||
<div className="max-w-[730px] mx-auto w-full px-4 pt-4 pb-20 sm:px-6 sm:pt-6 sm:pb-24 lg:px-8 lg:pt-8 lg:pb-28">
|
||||
<h1 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
Launch
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
|
||||
Copy a command and run it in your terminal.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 grid gap-7">
|
||||
{LAUNCH_COMMANDS.map(renderCommandCard)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,170 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
FIRST_MODEL_COMMAND,
|
||||
IntroScreen,
|
||||
default as Onboarding,
|
||||
RunOllamaScreen,
|
||||
WelcomeScreen,
|
||||
} from "./Onboarding";
|
||||
import {
|
||||
authenticationTimeoutAction,
|
||||
nextOnboardingStep,
|
||||
onboardingConnectUrl,
|
||||
} from "@/lib/onboarding";
|
||||
|
||||
describe("Onboarding", () => {
|
||||
it("explains what Ollama is before asking the user to choose a path", () => {
|
||||
const html = renderToStaticMarkup(<IntroScreen onContinue={vi.fn()} />);
|
||||
|
||||
expect(html).toContain("Welcome to Ollama!");
|
||||
expect(html.indexOf('alt="Ollama waving"')).toBeLessThan(
|
||||
html.indexOf("Welcome to Ollama!"),
|
||||
);
|
||||
expect(html).not.toContain("Run open models locally or in the cloud.");
|
||||
expect(html.indexOf("Connect your apps")).toBeLessThan(
|
||||
html.indexOf("Easily switch models"),
|
||||
);
|
||||
expect(html.indexOf("Easily switch models")).toBeLessThan(
|
||||
html.indexOf("Your data stays yours"),
|
||||
);
|
||||
expect(html).toContain("Power your existing coding apps with open models");
|
||||
expect(html).toContain("Swap between frontier models in one click.");
|
||||
expect(html).toContain("Your prompt data is never logged or trained on.");
|
||||
expect(html).toContain("Continue");
|
||||
expect(html).not.toContain("Skip");
|
||||
});
|
||||
|
||||
it("shows the account choice only to signed-out users", () => {
|
||||
expect(nextOnboardingStep("intro", "continue", false)).toBe("welcome");
|
||||
expect(nextOnboardingStep("intro", "continue", true)).toBe("run");
|
||||
expect(nextOnboardingStep("welcome", "local", false)).toBe("run");
|
||||
});
|
||||
|
||||
it("lets an in-flight authentication check finish before timing out", () => {
|
||||
expect(authenticationTimeoutAction(false, true)).toBe("defer");
|
||||
expect(authenticationTimeoutAction(false, false)).toBe("fail");
|
||||
expect(authenticationTimeoutAction(true, true)).toBe("ignore");
|
||||
});
|
||||
|
||||
it("opens the device connection flow without relaunching the app", () => {
|
||||
expect(
|
||||
onboardingConnectUrl(
|
||||
"https://ollama.com/connect?name=MacBook&key=public-key&launch=true",
|
||||
"signin",
|
||||
),
|
||||
).toBe("https://ollama.com/connect?name=MacBook&key=public-key");
|
||||
expect(
|
||||
onboardingConnectUrl(
|
||||
"https://ollama.com/connect?name=MacBook&key=public-key",
|
||||
"signup",
|
||||
),
|
||||
).toBe(
|
||||
"https://ollama.com/connect?name=MacBook&key=public-key&signup=true",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows Run Ollama after a successful connection and hides sign in", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<Onboarding
|
||||
isAuthenticated
|
||||
isSigningIn={false}
|
||||
signInError={null}
|
||||
completionError={null}
|
||||
onSignIn={vi.fn()}
|
||||
onRetryCompletion={vi.fn()}
|
||||
onUseLocal={vi.fn()}
|
||||
showRun
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain("Run Ollama");
|
||||
expect(html).not.toContain("Welcome to Ollama");
|
||||
expect(html).not.toContain("Sign up");
|
||||
});
|
||||
|
||||
it("offers cloud sign-up, local setup, and sign in on the welcome screen", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<WelcomeScreen
|
||||
isAuthenticated={false}
|
||||
isSigningIn={false}
|
||||
signInError={null}
|
||||
onSignIn={vi.fn()}
|
||||
onSignUp={vi.fn()}
|
||||
onLocal={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain("Create an account");
|
||||
expect(html).toContain(
|
||||
"Create your account for access to faster, larger open models.",
|
||||
);
|
||||
expect(html).toContain("Your data is never logged or trained on.");
|
||||
expect(html).toContain("Sign up");
|
||||
expect(html).toContain("No thanks, I'll use Ollama locally");
|
||||
expect(html).toContain("Sign in");
|
||||
expect(html).not.toContain("Skip");
|
||||
});
|
||||
|
||||
it("shows the cloud choice without a sign-in link for authenticated users", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<WelcomeScreen
|
||||
isAuthenticated
|
||||
isSigningIn={false}
|
||||
signInError={null}
|
||||
onSignIn={vi.fn()}
|
||||
onSignUp={vi.fn()}
|
||||
onLocal={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain("Create an account");
|
||||
expect(html).toContain(
|
||||
"Create your account for access to faster, larger open models.",
|
||||
);
|
||||
expect(html).toContain("Your data is never logged or trained on.");
|
||||
expect(html).not.toContain(">Sign in<");
|
||||
});
|
||||
|
||||
it("shows only the local command on the final page", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<RunOllamaScreen completionError={null} onRetryCompletion={vi.fn()} />,
|
||||
);
|
||||
|
||||
expect(html).toContain("Run Ollama");
|
||||
expect(html).toContain(FIRST_MODEL_COMMAND);
|
||||
expect(html).not.toContain("Finish");
|
||||
expect(html).not.toContain("Sign in");
|
||||
expect(html).not.toContain("create an account");
|
||||
});
|
||||
|
||||
it("shows the connecting state on the welcome action", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<WelcomeScreen
|
||||
isAuthenticated={false}
|
||||
isSigningIn
|
||||
signInError={null}
|
||||
onSignIn={vi.fn()}
|
||||
onSignUp={vi.fn()}
|
||||
onLocal={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain("Finish in your browser…");
|
||||
expect(html).not.toContain("Waiting for sign in…");
|
||||
});
|
||||
|
||||
it("shows a retryable error when onboarding completion cannot be saved", () => {
|
||||
const onRetryCompletion = vi.fn();
|
||||
const html = renderToStaticMarkup(
|
||||
<RunOllamaScreen
|
||||
completionError="Unable to save setup. Please try again."
|
||||
onRetryCompletion={onRetryCompletion}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain("Unable to save setup. Please try again.");
|
||||
expect(html).toContain('role="alert"');
|
||||
expect(html).toContain("Try again");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,302 @@
|
||||
import CopyButton from "@/components/CopyButton";
|
||||
import Logo from "@/components/Logo";
|
||||
import { nextOnboardingStep, type OnboardingStep } from "@/lib/onboarding";
|
||||
import {
|
||||
ArrowsRightLeftIcon,
|
||||
CommandLineIcon,
|
||||
ShieldCheckIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
|
||||
export const FIRST_MODEL_COMMAND = "ollama";
|
||||
|
||||
interface ScreenProps {
|
||||
isSigningIn: boolean;
|
||||
signInError: string | null;
|
||||
onSignIn: () => void;
|
||||
}
|
||||
|
||||
interface WelcomeScreenProps extends ScreenProps {
|
||||
isAuthenticated: boolean;
|
||||
onLocal: () => void;
|
||||
onSignUp: () => void;
|
||||
}
|
||||
|
||||
interface RunOllamaScreenProps {
|
||||
completionError: string | null;
|
||||
onRetryCompletion: () => void;
|
||||
}
|
||||
|
||||
function TitleBar({ onSignIn }: { onSignIn?: () => void }) {
|
||||
return (
|
||||
<header
|
||||
className="relative flex h-10 shrink-0 items-center justify-center bg-white"
|
||||
onDoubleClick={() => window.doubleClick?.()}
|
||||
onMouseDown={() => window.drag?.()}
|
||||
>
|
||||
{onSignIn && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-5 top-3 cursor-pointer rounded-md px-2 py-0 text-sm font-normal text-neutral-500 hover:text-neutral-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-500"
|
||||
onClick={onSignIn}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function OnboardingIcon({ compact = false }: { compact?: boolean }) {
|
||||
return (
|
||||
<div className="flex items-center justify-center">
|
||||
<Logo
|
||||
size={compact ? 42 : 54}
|
||||
containerClassName="mb-0"
|
||||
showBackground={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OnboardingCard({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<section className="flex min-h-0 flex-1 items-center justify-center overflow-y-auto bg-white px-6 pb-10 pt-0">
|
||||
<div className="flex w-full max-w-[760px] flex-col items-center justify-center bg-white px-10 py-6 text-center">
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const OLLAMA_FEATURES = [
|
||||
{
|
||||
title: "Connect your apps",
|
||||
description: "Power your existing coding apps with open models",
|
||||
icon: CommandLineIcon,
|
||||
},
|
||||
{
|
||||
title: "Easily switch models",
|
||||
description: "Swap between frontier models in one click.",
|
||||
icon: ArrowsRightLeftIcon,
|
||||
},
|
||||
{
|
||||
title: "Your data stays yours",
|
||||
description: "Your prompt data is never logged or trained on.",
|
||||
icon: ShieldCheckIcon,
|
||||
},
|
||||
];
|
||||
|
||||
export function IntroScreen({ onContinue }: { onContinue: () => void }) {
|
||||
return (
|
||||
<main className="flex h-screen w-full flex-col overflow-hidden bg-white text-neutral-950">
|
||||
<TitleBar />
|
||||
|
||||
<section className="flex min-h-0 flex-1 items-center justify-center overflow-y-auto px-6 pb-10">
|
||||
<div className="mx-auto flex min-h-full w-full max-w-[620px] flex-col items-center justify-center py-4 text-center">
|
||||
<div className="flex flex-col items-center justify-center gap-2">
|
||||
<img
|
||||
src="/hello.png"
|
||||
alt="Ollama waving"
|
||||
className="h-[72px] w-[72px] select-none object-contain"
|
||||
draggable={false}
|
||||
/>
|
||||
<h1 className="font-rounded text-2xl font-medium leading-8">
|
||||
Welcome to Ollama!
|
||||
</h1>
|
||||
</div>
|
||||
<div className="mx-auto mt-8 flex w-fit max-w-full flex-col gap-6 text-left">
|
||||
{OLLAMA_FEATURES.map((feature) => {
|
||||
const Icon = feature.icon;
|
||||
|
||||
return (
|
||||
<div key={feature.title} className="flex items-start gap-4">
|
||||
<Icon className="mt-0.5 h-7 w-7 shrink-0 stroke-[1.5] text-neutral-700" />
|
||||
<div>
|
||||
<h2 className="text-sm font-medium text-neutral-950">
|
||||
{feature.title}
|
||||
</h2>
|
||||
<p className="mt-0.5 text-[13px] leading-5 text-neutral-500">
|
||||
{feature.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="mt-8 flex h-11 w-full max-w-[240px] cursor-pointer items-center justify-center rounded-full bg-neutral-900 px-5 font-sans text-sm font-normal text-white transition-colors hover:bg-neutral-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-500"
|
||||
onClick={onContinue}
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function InlineError({
|
||||
message,
|
||||
className = "mt-3 text-sm",
|
||||
}: {
|
||||
message: string | null;
|
||||
className?: string;
|
||||
}) {
|
||||
if (!message) return null;
|
||||
|
||||
return (
|
||||
<p role="alert" className={`${className} text-red-600`}>
|
||||
{message}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export function WelcomeScreen({
|
||||
isAuthenticated,
|
||||
isSigningIn,
|
||||
signInError,
|
||||
onSignIn,
|
||||
onSignUp,
|
||||
onLocal,
|
||||
}: WelcomeScreenProps) {
|
||||
return (
|
||||
<main className="flex min-h-screen w-full flex-col bg-white text-neutral-950">
|
||||
<TitleBar onSignIn={isAuthenticated ? undefined : onSignIn} />
|
||||
<OnboardingCard>
|
||||
<OnboardingIcon />
|
||||
<h1 className="mt-7 font-rounded text-2xl font-medium leading-8">
|
||||
Create an account
|
||||
</h1>
|
||||
<p className="mt-3 max-w-[400px] text-sm leading-6 text-neutral-400">
|
||||
Create your account for access to faster, larger open models.
|
||||
</p>
|
||||
<p className="mt-1 max-w-[400px] text-sm leading-6 text-neutral-400">
|
||||
Your data is never logged or trained on.
|
||||
</p>
|
||||
|
||||
<div className="mt-7 flex w-full max-w-[240px] flex-col items-center">
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-11 w-full cursor-pointer items-center justify-center rounded-full bg-neutral-900 px-5 font-sans text-sm font-normal text-white transition-colors hover:bg-neutral-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-500 disabled:cursor-wait disabled:opacity-70"
|
||||
onClick={onSignUp}
|
||||
disabled={isSigningIn}
|
||||
aria-busy={isSigningIn}
|
||||
>
|
||||
{isSigningIn ? "Finish in your browser…" : "Sign up"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-2 cursor-pointer rounded-md px-3 py-2 text-sm font-normal text-neutral-600 underline decoration-neutral-300 underline-offset-4 hover:text-neutral-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-500"
|
||||
onClick={onLocal}
|
||||
>
|
||||
No thanks, I'll use Ollama locally
|
||||
</button>
|
||||
<InlineError message={signInError} />
|
||||
</div>
|
||||
</OnboardingCard>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export function RunOllamaScreen({
|
||||
completionError,
|
||||
onRetryCompletion,
|
||||
}: RunOllamaScreenProps) {
|
||||
return (
|
||||
<main className="flex min-h-screen w-full flex-col bg-white text-neutral-950">
|
||||
<TitleBar />
|
||||
<OnboardingCard>
|
||||
<OnboardingIcon compact />
|
||||
<h1 className="mt-6 font-rounded text-[22px] font-medium leading-7">
|
||||
Run Ollama
|
||||
</h1>
|
||||
|
||||
<div className="mt-6 grid h-12 w-full max-w-[330px] grid-cols-[minmax(0,1fr)_32px] items-center rounded-full bg-neutral-100 px-4 pr-3">
|
||||
<code className="min-w-0 truncate text-left font-mono text-sm">
|
||||
{FIRST_MODEL_COMMAND}
|
||||
</code>
|
||||
<CopyButton
|
||||
content={FIRST_MODEL_COMMAND}
|
||||
size="md"
|
||||
title="Copy command to clipboard"
|
||||
className="shrink-0 text-neutral-400 hover:!bg-transparent hover:!text-neutral-400 dark:hover:!bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="mt-3 max-w-xs text-[13px] leading-5 text-neutral-400">
|
||||
Run this command in your terminal to get started.
|
||||
</p>
|
||||
|
||||
<InlineError message={completionError} />
|
||||
{completionError && (
|
||||
<button
|
||||
type="button"
|
||||
className="mt-2 cursor-pointer rounded-md px-3 py-1 text-sm font-normal text-neutral-600 underline decoration-neutral-300 underline-offset-4 hover:text-neutral-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-500"
|
||||
onClick={onRetryCompletion}
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
)}
|
||||
</OnboardingCard>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
interface OnboardingProps extends ScreenProps {
|
||||
completionError: string | null;
|
||||
isAuthenticated: boolean;
|
||||
onRetryCompletion: () => void;
|
||||
onSignUp: () => void;
|
||||
onUseLocal: () => void;
|
||||
showRun: boolean;
|
||||
}
|
||||
|
||||
export default function Onboarding(props: OnboardingProps) {
|
||||
const [step, setStep] = useState<OnboardingStep>("intro");
|
||||
|
||||
useEffect(() => {
|
||||
window.setOnboardingWindow?.(true);
|
||||
return () => window.setOnboardingWindow?.(false);
|
||||
}, []);
|
||||
|
||||
if (props.showRun || step === "run") {
|
||||
return (
|
||||
<RunOllamaScreen
|
||||
completionError={props.completionError}
|
||||
onRetryCompletion={props.onRetryCompletion}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === "intro") {
|
||||
return (
|
||||
<IntroScreen
|
||||
onContinue={() => {
|
||||
const nextStep = nextOnboardingStep(
|
||||
step,
|
||||
"continue",
|
||||
props.isAuthenticated,
|
||||
);
|
||||
if (nextStep === "run") props.onUseLocal();
|
||||
setStep(nextStep);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<WelcomeScreen
|
||||
{...props}
|
||||
onLocal={() => {
|
||||
props.onUseLocal();
|
||||
setStep((current) =>
|
||||
nextOnboardingStep(current, "local", props.isAuthenticated),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -12,13 +12,10 @@ import {
|
||||
BoltIcon,
|
||||
WrenchIcon,
|
||||
CloudIcon,
|
||||
XMarkIcon,
|
||||
CogIcon,
|
||||
ArrowLeftIcon,
|
||||
ArrowDownTrayIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { Settings as SettingsType } from "@/gotypes";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useUser } from "@/hooks/useUser";
|
||||
import { useCloudStatus } from "@/hooks/useCloudStatus";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
@@ -61,7 +58,6 @@ export default function Settings() {
|
||||
const [isAwaitingConnection, setIsAwaitingConnection] = useState(false);
|
||||
const [connectionError, setConnectionError] = useState<string | null>(null);
|
||||
const [pollingInterval, setPollingInterval] = useState<number | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
cloudDisabled,
|
||||
cloudStatus,
|
||||
@@ -273,10 +269,6 @@ export default function Settings() {
|
||||
}
|
||||
|
||||
const isWindows = navigator.platform.toLowerCase().includes("win");
|
||||
const handleCloseSettings = () => {
|
||||
const chatId = settings.LastHomeView === "chat" ? "new" : "launch";
|
||||
navigate({ to: "/c/$chatId", params: { chatId } });
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="flex h-screen w-full flex-col select-none dark:bg-neutral-900">
|
||||
@@ -288,24 +280,8 @@ export default function Settings() {
|
||||
<h1
|
||||
className={`${isWindows ? "pl-4" : "pl-24"} flex items-center font-rounded text-md font-medium dark:text-white`}
|
||||
>
|
||||
{isWindows && (
|
||||
<button
|
||||
onClick={handleCloseSettings}
|
||||
className="hover:bg-neutral-100 mr-3 dark:hover:bg-neutral-800 rounded-full p-1.5"
|
||||
>
|
||||
<ArrowLeftIcon className="w-5 h-5 dark:text-white" />
|
||||
</button>
|
||||
)}
|
||||
Settings
|
||||
</h1>
|
||||
{!isWindows && (
|
||||
<button
|
||||
onClick={handleCloseSettings}
|
||||
className="p-1 hover:bg-neutral-100 mr-3 dark:hover:bg-neutral-800 rounded-full"
|
||||
>
|
||||
<XMarkIcon className="w-6 h-6 dark:text-white" />
|
||||
</button>
|
||||
)}
|
||||
</header>
|
||||
<div className="w-full p-6 overflow-y-auto flex-1 overscroll-contain">
|
||||
<div className="space-y-4 max-w-2xl mx-auto">
|
||||
@@ -463,7 +439,9 @@ export default function Settings() {
|
||||
<div className="flex-shrink-0">
|
||||
<Switch
|
||||
checked={settings.AutoUpdateEnabled}
|
||||
onChange={(checked) => handleChange("AutoUpdateEnabled", checked)}
|
||||
onChange={(checked) =>
|
||||
handleChange("AutoUpdateEnabled", checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -544,7 +522,9 @@ export default function Settings() {
|
||||
</Description>
|
||||
<div className="mt-3">
|
||||
<Slider
|
||||
value={settings.ContextLength || defaultContextLength || 0}
|
||||
value={
|
||||
settings.ContextLength || defaultContextLength || 0
|
||||
}
|
||||
onChange={(value) => {
|
||||
handleChange("ContextLength", value);
|
||||
}}
|
||||
|
||||
@@ -10,6 +10,7 @@ interface SettingsState {
|
||||
selectedModel: string;
|
||||
sidebarOpen: boolean;
|
||||
lastHomeView: string;
|
||||
onboardingVersion: number;
|
||||
thinkEnabled: boolean;
|
||||
thinkLevel: string;
|
||||
}
|
||||
@@ -23,6 +24,7 @@ type SettingsUpdate = Partial<{
|
||||
SelectedModel: string;
|
||||
SidebarOpen: boolean;
|
||||
LastHomeView: string;
|
||||
OnboardingVersion: number;
|
||||
}>;
|
||||
|
||||
export function useSettings() {
|
||||
@@ -52,7 +54,8 @@ export function useSettings() {
|
||||
thinkLevel: settingsData?.settings?.ThinkLevel ?? "none",
|
||||
selectedModel: settingsData?.settings?.SelectedModel ?? "",
|
||||
sidebarOpen: settingsData?.settings?.SidebarOpen ?? false,
|
||||
lastHomeView: settingsData?.settings?.LastHomeView ?? "launch",
|
||||
lastHomeView: settingsData?.settings?.LastHomeView ?? "chat",
|
||||
onboardingVersion: settingsData?.settings?.OnboardingVersion ?? 0,
|
||||
}),
|
||||
[settingsData?.settings],
|
||||
);
|
||||
|
||||
@@ -8,6 +8,19 @@
|
||||
"SF Pro Rounded", ui-sans-serif, system-ui, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
a[href],
|
||||
button:not(:disabled),
|
||||
[role="button"]:not([aria-disabled="true"]) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled,
|
||||
[role="button"][aria-disabled="true"] {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
/* Dark mode styles go here */
|
||||
:root {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// Keep in sync with store.CurrentOnboardingVersion in app/store/store.go.
|
||||
export const CURRENT_ONBOARDING_VERSION = 1;
|
||||
|
||||
export type OnboardingAuthMode = "signin" | "signup";
|
||||
|
||||
export function onboardingConnectUrl(
|
||||
connectUrl: string,
|
||||
mode: OnboardingAuthMode,
|
||||
): string {
|
||||
const url = new URL(connectUrl);
|
||||
url.searchParams.delete("launch");
|
||||
if (mode === "signup") {
|
||||
url.searchParams.set("signup", "true");
|
||||
} else {
|
||||
url.searchParams.delete("signup");
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export const AUTHENTICATION_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
|
||||
export type OnboardingStep = "intro" | "welcome" | "run";
|
||||
export type OnboardingAction = "continue" | "local";
|
||||
export type AuthenticationTimeoutAction = "ignore" | "defer" | "fail";
|
||||
|
||||
export function nextOnboardingStep(
|
||||
step: OnboardingStep,
|
||||
action: OnboardingAction,
|
||||
isAuthenticated: boolean,
|
||||
): OnboardingStep {
|
||||
if (action === "local") return "run";
|
||||
if (step === "intro") return isAuthenticated ? "run" : "welcome";
|
||||
return step;
|
||||
}
|
||||
|
||||
export function authenticationTimeoutAction(
|
||||
settled: boolean,
|
||||
checking: boolean,
|
||||
): AuthenticationTimeoutAction {
|
||||
if (settled) return "ignore";
|
||||
if (checking) return "defer";
|
||||
return "fail";
|
||||
}
|
||||
|
||||
export function homeChatId(): "new" {
|
||||
return "new";
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
import { Route as rootRoute } from './routes/__root'
|
||||
import { Route as SettingsImport } from './routes/settings'
|
||||
import { Route as OnboardingImport } from './routes/onboarding'
|
||||
import { Route as IndexImport } from './routes/index'
|
||||
import { Route as CChatIdImport } from './routes/c.$chatId'
|
||||
|
||||
@@ -23,6 +24,12 @@ const SettingsRoute = SettingsImport.update({
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const OnboardingRoute = OnboardingImport.update({
|
||||
id: '/onboarding',
|
||||
path: '/onboarding',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const IndexRoute = IndexImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
@@ -46,6 +53,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof IndexImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/onboarding': {
|
||||
id: '/onboarding'
|
||||
path: '/onboarding'
|
||||
fullPath: '/onboarding'
|
||||
preLoaderRoute: typeof OnboardingImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/settings': {
|
||||
id: '/settings'
|
||||
path: '/settings'
|
||||
@@ -67,12 +81,14 @@ declare module '@tanstack/react-router' {
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/onboarding': typeof OnboardingRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/c/$chatId': typeof CChatIdRoute
|
||||
}
|
||||
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/onboarding': typeof OnboardingRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/c/$chatId': typeof CChatIdRoute
|
||||
}
|
||||
@@ -80,27 +96,30 @@ export interface FileRoutesByTo {
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRoute
|
||||
'/': typeof IndexRoute
|
||||
'/onboarding': typeof OnboardingRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/c/$chatId': typeof CChatIdRoute
|
||||
}
|
||||
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths: '/' | '/settings' | '/c/$chatId'
|
||||
fullPaths: '/' | '/onboarding' | '/settings' | '/c/$chatId'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: '/' | '/settings' | '/c/$chatId'
|
||||
id: '__root__' | '/' | '/settings' | '/c/$chatId'
|
||||
to: '/' | '/onboarding' | '/settings' | '/c/$chatId'
|
||||
id: '__root__' | '/' | '/onboarding' | '/settings' | '/c/$chatId'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
OnboardingRoute: typeof OnboardingRoute
|
||||
SettingsRoute: typeof SettingsRoute
|
||||
CChatIdRoute: typeof CChatIdRoute
|
||||
}
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
OnboardingRoute: OnboardingRoute,
|
||||
SettingsRoute: SettingsRoute,
|
||||
CChatIdRoute: CChatIdRoute,
|
||||
}
|
||||
@@ -116,6 +135,7 @@ export const routeTree = rootRoute
|
||||
"filePath": "__root.tsx",
|
||||
"children": [
|
||||
"/",
|
||||
"/onboarding",
|
||||
"/settings",
|
||||
"/c/$chatId"
|
||||
]
|
||||
@@ -123,6 +143,9 @@ export const routeTree = rootRoute
|
||||
"/": {
|
||||
"filePath": "index.tsx"
|
||||
},
|
||||
"/onboarding": {
|
||||
"filePath": "onboarding.tsx"
|
||||
},
|
||||
"/settings": {
|
||||
"filePath": "settings.tsx"
|
||||
},
|
||||
|
||||
@@ -1,40 +1,25 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
import { useChat } from "@/hooks/useChats";
|
||||
import Chat from "@/components/Chat";
|
||||
import { getChat } from "@/api";
|
||||
import { SidebarLayout } from "@/components/layout/layout";
|
||||
import { ChatSidebar } from "@/components/ChatSidebar";
|
||||
import LaunchCommands from "@/components/LaunchCommands";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useSettings } from "@/hooks/useSettings";
|
||||
|
||||
const launchSidebarRequestedKey = "ollama.launchSidebarRequested";
|
||||
const launchSidebarSeenKey = "ollama.launchSidebarSeen";
|
||||
const fallbackSessionState = new Map<string, string>();
|
||||
|
||||
function getSessionState() {
|
||||
if (typeof sessionStorage !== "undefined") {
|
||||
return sessionStorage;
|
||||
}
|
||||
|
||||
return {
|
||||
getItem(key: string) {
|
||||
return fallbackSessionState.get(key) ?? null;
|
||||
},
|
||||
setItem(key: string, value: string) {
|
||||
fallbackSessionState.set(key, value);
|
||||
},
|
||||
removeItem(key: string) {
|
||||
fallbackSessionState.delete(key);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/c/$chatId")({
|
||||
component: RouteComponent,
|
||||
beforeLoad: ({ params }) => {
|
||||
if (params.chatId === "launch") {
|
||||
throw redirect({
|
||||
to: "/c/$chatId",
|
||||
params: { chatId: "new" },
|
||||
mask: { to: "/" },
|
||||
});
|
||||
}
|
||||
},
|
||||
loader: async ({ context, params }) => {
|
||||
// Skip loading for special non-chat views
|
||||
if (params.chatId !== "new" && params.chatId !== "launch") {
|
||||
if (params.chatId !== "new") {
|
||||
context.queryClient.ensureQueryData({
|
||||
queryKey: ["chat", params.chatId],
|
||||
queryFn: () => getChat(params.chatId),
|
||||
@@ -47,61 +32,19 @@ export const Route = createFileRoute("/c/$chatId")({
|
||||
function RouteComponent() {
|
||||
const { chatId } = Route.useParams();
|
||||
const { settingsData, setSettings } = useSettings();
|
||||
const previousChatIdRef = useRef<string | null>(null);
|
||||
|
||||
// Always call hooks at the top level - use a flag to skip data when chatId is a special view
|
||||
const {
|
||||
data: chatData,
|
||||
isLoading: chatLoading,
|
||||
error: chatError,
|
||||
} = useChat(chatId === "new" || chatId === "launch" ? "" : chatId);
|
||||
} = useChat(chatId === "new" ? "" : chatId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!settingsData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousChatId = previousChatIdRef.current;
|
||||
previousChatIdRef.current = chatId;
|
||||
|
||||
if (chatId === "launch") {
|
||||
const sessionState = getSessionState();
|
||||
const shouldOpenSidebar =
|
||||
previousChatId !== "launch" &&
|
||||
(() => {
|
||||
if (sessionState.getItem(launchSidebarRequestedKey) === "1") {
|
||||
sessionState.removeItem(launchSidebarRequestedKey);
|
||||
sessionState.setItem(launchSidebarSeenKey, "1");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (sessionState.getItem(launchSidebarSeenKey) !== "1") {
|
||||
sessionState.setItem(launchSidebarSeenKey, "1");
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
})();
|
||||
const updates: { LastHomeView?: string; SidebarOpen?: boolean } = {};
|
||||
|
||||
if (settingsData.LastHomeView !== "launch") {
|
||||
updates.LastHomeView = "launch";
|
||||
}
|
||||
|
||||
if (shouldOpenSidebar && !settingsData.SidebarOpen) {
|
||||
updates.SidebarOpen = true;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSettings(updates).catch(() => {
|
||||
// Best effort persistence for home view preference.
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (settingsData.LastHomeView === "chat") {
|
||||
return;
|
||||
}
|
||||
@@ -120,14 +63,6 @@ function RouteComponent() {
|
||||
);
|
||||
}
|
||||
|
||||
if (chatId === "launch") {
|
||||
return (
|
||||
<SidebarLayout sidebar={<ChatSidebar currentChatId={chatId} />}>
|
||||
<LaunchCommands />
|
||||
</SidebarLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle existing chat case
|
||||
if (chatLoading) {
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
import { getSettings } from "@/api";
|
||||
import { CURRENT_ONBOARDING_VERSION, homeChatId } from "@/lib/onboarding";
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
beforeLoad: async ({ context }) => {
|
||||
@@ -7,8 +8,11 @@ export const Route = createFileRoute("/")({
|
||||
queryKey: ["settings"],
|
||||
queryFn: getSettings,
|
||||
});
|
||||
const chatId =
|
||||
settingsData?.settings?.LastHomeView === "chat" ? "new" : "launch";
|
||||
if (settingsData.settings.OnboardingVersion < CURRENT_ONBOARDING_VERSION) {
|
||||
throw redirect({ to: "/onboarding" });
|
||||
}
|
||||
|
||||
const chatId = homeChatId();
|
||||
|
||||
throw redirect({
|
||||
to: "/c/$chatId",
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import Onboarding from "@/components/Onboarding";
|
||||
import { getSettings } from "@/api";
|
||||
import { useSettings } from "@/hooks/useSettings";
|
||||
import { useUser } from "@/hooks/useUser";
|
||||
import {
|
||||
AUTHENTICATION_TIMEOUT_MS,
|
||||
authenticationTimeoutAction,
|
||||
CURRENT_ONBOARDING_VERSION,
|
||||
homeChatId,
|
||||
onboardingConnectUrl,
|
||||
type OnboardingAuthMode,
|
||||
} from "@/lib/onboarding";
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
export const Route = createFileRoute("/onboarding")({
|
||||
beforeLoad: async ({ context }) => {
|
||||
// Let developers review onboarding without resetting their local app data.
|
||||
if (
|
||||
import.meta.env.DEV &&
|
||||
new URLSearchParams(window.location.search).get("preview") === "1"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const settingsData = await context.queryClient.ensureQueryData({
|
||||
queryKey: ["settings"],
|
||||
queryFn: getSettings,
|
||||
});
|
||||
|
||||
if (settingsData.settings.OnboardingVersion >= CURRENT_ONBOARDING_VERSION) {
|
||||
const chatId = homeChatId();
|
||||
throw redirect({
|
||||
to: "/c/$chatId",
|
||||
params: { chatId },
|
||||
mask: { to: "/" },
|
||||
});
|
||||
}
|
||||
},
|
||||
component: OnboardingRoute,
|
||||
});
|
||||
|
||||
function OnboardingRoute() {
|
||||
const { settingsData, setSettings } = useSettings();
|
||||
const { fetchConnectUrl, refetchUser, isAuthenticated } = useUser();
|
||||
const [isAwaitingAuth, setIsAwaitingAuth] = useState(false);
|
||||
const [showRun, setShowRun] = useState(false);
|
||||
const [signInError, setSignInError] = useState<string | null>(null);
|
||||
const [completionError, setCompletionError] = useState<string | null>(null);
|
||||
const authAttemptRef = useRef(0);
|
||||
|
||||
const completeOnboarding = useCallback(async (): Promise<boolean> => {
|
||||
setCompletionError(null);
|
||||
|
||||
try {
|
||||
if (!settingsData) {
|
||||
throw new Error("Settings are not loaded");
|
||||
}
|
||||
|
||||
await setSettings({
|
||||
OnboardingVersion: CURRENT_ONBOARDING_VERSION,
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to save onboarding state:", error);
|
||||
setCompletionError("Unable to save setup. Please try again.");
|
||||
return false;
|
||||
}
|
||||
}, [setSettings, settingsData]);
|
||||
|
||||
const showRunScreen = useCallback(() => {
|
||||
setShowRun(true);
|
||||
void completeOnboarding();
|
||||
}, [completeOnboarding]);
|
||||
|
||||
const retryCompletion = useCallback(() => {
|
||||
void completeOnboarding();
|
||||
}, [completeOnboarding]);
|
||||
|
||||
const authenticate = useCallback(
|
||||
async (mode: OnboardingAuthMode) => {
|
||||
setSignInError(null);
|
||||
|
||||
if (isAuthenticated) {
|
||||
showRunScreen();
|
||||
return;
|
||||
}
|
||||
|
||||
const authAttempt = ++authAttemptRef.current;
|
||||
setIsAwaitingAuth(true);
|
||||
|
||||
try {
|
||||
const result = await fetchConnectUrl();
|
||||
if (authAttempt !== authAttemptRef.current) return;
|
||||
if (!result.data) {
|
||||
throw new Error("No sign-in URL was returned");
|
||||
}
|
||||
|
||||
window.open(onboardingConnectUrl(result.data, mode), "_blank");
|
||||
} catch (error) {
|
||||
if (authAttempt !== authAttemptRef.current) return;
|
||||
console.error("Failed to start sign in:", error);
|
||||
setIsAwaitingAuth(false);
|
||||
setSignInError("Unable to start sign in. Please try again.");
|
||||
}
|
||||
},
|
||||
[fetchConnectUrl, isAuthenticated, showRunScreen],
|
||||
);
|
||||
|
||||
const signIn = useCallback(() => authenticate("signin"), [authenticate]);
|
||||
const signUp = useCallback(() => authenticate("signup"), [authenticate]);
|
||||
|
||||
const useLocal = useCallback(() => {
|
||||
authAttemptRef.current += 1;
|
||||
setIsAwaitingAuth(false);
|
||||
setSignInError(null);
|
||||
showRunScreen();
|
||||
}, [showRunScreen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAwaitingAuth) return;
|
||||
|
||||
let checking = false;
|
||||
let settled = false;
|
||||
let timeoutPending = false;
|
||||
const authAttempt = authAttemptRef.current;
|
||||
|
||||
const failConnection = () => {
|
||||
if (settled || authAttempt !== authAttemptRef.current) return;
|
||||
settled = true;
|
||||
setIsAwaitingAuth(false);
|
||||
setSignInError(
|
||||
"Connection is taking longer than expected. Please try again.",
|
||||
);
|
||||
};
|
||||
|
||||
const checkConnection = async () => {
|
||||
if (checking || settled || authAttempt !== authAttemptRef.current) return;
|
||||
checking = true;
|
||||
|
||||
try {
|
||||
const result = await refetchUser();
|
||||
if (
|
||||
!settled &&
|
||||
authAttempt === authAttemptRef.current &&
|
||||
result.data?.name
|
||||
) {
|
||||
settled = true;
|
||||
setIsAwaitingAuth(false);
|
||||
showRunScreen();
|
||||
window.activateOllama?.();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to check sign-in status:", error);
|
||||
} finally {
|
||||
checking = false;
|
||||
if (timeoutPending) failConnection();
|
||||
}
|
||||
};
|
||||
|
||||
void checkConnection();
|
||||
const pollingInterval = window.setInterval(checkConnection, 1000);
|
||||
const timeout = window.setTimeout(() => {
|
||||
const action = authenticationTimeoutAction(settled, checking);
|
||||
if (action === "ignore") return;
|
||||
if (action === "defer") {
|
||||
timeoutPending = true;
|
||||
return;
|
||||
}
|
||||
failConnection();
|
||||
}, AUTHENTICATION_TIMEOUT_MS);
|
||||
|
||||
window.addEventListener("focus", checkConnection);
|
||||
return () => {
|
||||
settled = true;
|
||||
window.clearInterval(pollingInterval);
|
||||
window.clearTimeout(timeout);
|
||||
window.removeEventListener("focus", checkConnection);
|
||||
};
|
||||
}, [isAwaitingAuth, refetchUser, showRunScreen]);
|
||||
|
||||
return (
|
||||
<Onboarding
|
||||
completionError={completionError}
|
||||
isAuthenticated={isAuthenticated}
|
||||
isSigningIn={isAwaitingAuth}
|
||||
signInError={signInError}
|
||||
onSignIn={signIn}
|
||||
onSignUp={signUp}
|
||||
onRetryCompletion={retryCompletion}
|
||||
onUseLocal={useLocal}
|
||||
showRun={showRun}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Vendored
+2
@@ -24,6 +24,8 @@ declare global {
|
||||
webview?: WebviewAPI;
|
||||
drag?: () => void;
|
||||
doubleClick?: () => void;
|
||||
activateOllama?: () => void;
|
||||
setOnboardingWindow?: (enabled: boolean) => void;
|
||||
menu: (items: MenuItem[]) => Promise<string | null>;
|
||||
OLLAMA_TOOLS?: boolean;
|
||||
OLLAMA_WEBSEARCH?: boolean;
|
||||
|
||||
+12
-2
@@ -1464,11 +1464,21 @@ func (s *Server) settings(w http.ResponseWriter, r *http.Request) error {
|
||||
return fmt.Errorf("failed to load settings: %w", err)
|
||||
}
|
||||
|
||||
var settings store.Settings
|
||||
if err := json.NewDecoder(r.Body).Decode(&settings); err != nil {
|
||||
var request struct {
|
||||
store.Settings
|
||||
OnboardingVersion *int
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
return fmt.Errorf("invalid request body: %w", err)
|
||||
}
|
||||
|
||||
settings := request.Settings
|
||||
if request.OnboardingVersion == nil {
|
||||
settings.OnboardingVersion = old.OnboardingVersion
|
||||
} else {
|
||||
settings.OnboardingVersion = *request.OnboardingVersion
|
||||
}
|
||||
|
||||
if err := s.Store.SetSettings(settings); err != nil {
|
||||
return fmt.Errorf("failed to save settings: %w", err)
|
||||
}
|
||||
|
||||
@@ -718,6 +718,55 @@ func TestSettingsToggleAutoUpdateOff_CancelsDownload(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsPreservesOnboardingVersionWhenOmitted(t *testing.T) {
|
||||
testStore := &store.Store{
|
||||
DBPath: filepath.Join(t.TempDir(), "db.sqlite"),
|
||||
}
|
||||
defer testStore.Close()
|
||||
|
||||
settings, err := testStore.Settings()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings.OnboardingVersion = 1
|
||||
if err := testStore.SetSettings(settings); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(settings)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var fields map[string]any
|
||||
if err := json.Unmarshal(payload, &fields); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
delete(fields, "OnboardingVersion")
|
||||
payload, err = json.Marshal(fields)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
server := &Server{
|
||||
Store: testStore,
|
||||
Restart: func() {},
|
||||
}
|
||||
req := httptest.NewRequest("POST", "/api/v1/settings", bytes.NewReader(payload))
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
if err := server.settings(rr, req); err != nil {
|
||||
t.Fatalf("settings() error = %v", err)
|
||||
}
|
||||
|
||||
saved, err := testStore.Settings()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if saved.OnboardingVersion != 1 {
|
||||
t.Fatalf("OnboardingVersion = %d, want 1", saved.OnboardingVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsToggleAutoUpdateOn_WithPendingUpdate_ShowsNotification(t *testing.T) {
|
||||
testStore := &store.Store{
|
||||
DBPath: filepath.Join(t.TempDir(), "db.sqlite"),
|
||||
|
||||
@@ -80,7 +80,7 @@ func (t *winTray) wndProc(hWnd windows.Handle, message uint32, wParam, lParam ui
|
||||
t.app.DoUpdate()
|
||||
case openUIMenuID:
|
||||
// UI must be initialized on this thread so don't use the callbacks
|
||||
t.app.UIShow()
|
||||
t.app.UIRun("/")
|
||||
case settingsUIMenuID:
|
||||
// UI must be initialized on this thread so don't use the callbacks
|
||||
t.app.UIRun("/settings")
|
||||
@@ -174,14 +174,7 @@ func (t *winTray) wndProc(hWnd windows.Handle, message uint32, wParam, lParam ui
|
||||
}
|
||||
}
|
||||
case uint32(FOCUS_WINDOW_MSG_ID):
|
||||
// Handle focus window request from another instance
|
||||
if t.app.UIRunning() {
|
||||
// If UI is already running, just show it
|
||||
t.app.UIShow()
|
||||
} else {
|
||||
// If UI is not running, start it
|
||||
t.app.UIRun("/")
|
||||
}
|
||||
focusUI(t.app)
|
||||
lResult = 1 // Return non-zero to indicate success
|
||||
default:
|
||||
// Calls the default window procedure to provide default processing for any window messages that an application does not process.
|
||||
@@ -197,6 +190,14 @@ func (t *winTray) wndProc(hWnd windows.Handle, message uint32, wParam, lParam ui
|
||||
return
|
||||
}
|
||||
|
||||
func focusUI(app AppCallbacks) {
|
||||
if app.UIRunning() {
|
||||
app.UIShow()
|
||||
return
|
||||
}
|
||||
app.UIRun("/")
|
||||
}
|
||||
|
||||
func (t *winTray) Quit() {
|
||||
// slog.Debug("XXX in winTray.Quit")
|
||||
t.quitting = true
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
//go:build windows
|
||||
|
||||
package wintray
|
||||
|
||||
import "testing"
|
||||
|
||||
type lifecycleApp struct {
|
||||
running bool
|
||||
runPath string
|
||||
showCall bool
|
||||
}
|
||||
|
||||
func (a *lifecycleApp) UIRun(path string) { a.runPath = path }
|
||||
func (a *lifecycleApp) UIShow() { a.showCall = true }
|
||||
func (a *lifecycleApp) UITerminate() {}
|
||||
func (a *lifecycleApp) UIRunning() bool { return a.running }
|
||||
func (a *lifecycleApp) Quit() {}
|
||||
func (a *lifecycleApp) DoUpdate() {}
|
||||
|
||||
func TestFocusUICreatesOrShowsWindow(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
running bool
|
||||
wantRun string
|
||||
wantShow bool
|
||||
}{
|
||||
{name: "creates window when tray only", wantRun: "/"},
|
||||
{name: "shows existing window", running: true, wantShow: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
app := &lifecycleApp{running: tt.running}
|
||||
focusUI(app)
|
||||
if app.runPath != tt.wantRun {
|
||||
t.Errorf("run path = %q, want %q", app.runPath, tt.wantRun)
|
||||
}
|
||||
if app.showCall != tt.wantShow {
|
||||
t.Errorf("show called = %v, want %v", app.showCall, tt.wantShow)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -28,10 +28,10 @@ const (
|
||||
)
|
||||
|
||||
func (t *winTray) initMenus() error {
|
||||
if err := t.addOrUpdateMenuItem(openUIMenuID, 0, openUIMenuTitle, false); err != nil {
|
||||
if err := t.addOrUpdateMenuItem(settingsUIMenuID, 0, settingsUIMenuTitle, false); err != nil {
|
||||
return fmt.Errorf("unable to create menu entries %w", err)
|
||||
}
|
||||
if err := t.addOrUpdateMenuItem(settingsUIMenuID, 0, settingsUIMenuTitle, false); err != nil {
|
||||
if err := t.addOrUpdateMenuItem(openUIMenuID, 0, openUIMenuTitle, false); err != nil {
|
||||
return fmt.Errorf("unable to create menu entries %w", err)
|
||||
}
|
||||
if err := t.addOrUpdateMenuItem(diagLogsMenuID, 0, diagLogsMenuTitle, false); err != nil {
|
||||
|
||||
@@ -12,6 +12,6 @@ const (
|
||||
updateAvailableMenuTitle = "An update is available"
|
||||
updateMenuTitle = "Restart to update"
|
||||
diagLogsMenuTitle = "View logs"
|
||||
openUIMenuTitle = "Open Ollama"
|
||||
openUIMenuTitle = "Open Ollama Chat"
|
||||
settingsUIMenuTitle = "Settings..."
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user