Unverified Commit d3b4b997 authored by Daniel Hiltgen's avatar Daniel Hiltgen Committed by GitHub
Browse files

app: add code for macOS and Windows apps under 'app' (#12933)



* app: add code for macOS and Windows apps under 'app'

* app: add readme

* app: windows and linux only for now

* ci: fix ui CI validation

---------
Co-authored-by: default avatarjmorganca <jmorganca@gmail.com>
parent a4770107
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { renameChat } from "@/api";
export function useRenameChat() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ chatId, title }: { chatId: string; title: string }) =>
renameChat(chatId, title),
onSuccess: (_, { chatId }) => {
// Invalidate and refetch chats list
queryClient.invalidateQueries({ queryKey: ["chats"] });
// Invalidate the specific chat to update its title
queryClient.invalidateQueries({ queryKey: ["chat", chatId] });
},
onError: (error) => {
console.error("Failed to rename chat:", error);
},
});
}
import { useEffect, useMemo, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import { useModels } from "./useModels";
import { useChat } from "./useChats";
import { useSettings } from "./useSettings.ts";
import { Model } from "@/gotypes";
import { FEATURED_MODELS } from "@/utils/mergeModels";
import { getTotalVRAM } from "@/utils/vram.ts";
import { getInferenceCompute } from "@/api";
export function recommendDefaultModel(totalVRAM: number): string {
const vram = Math.max(0, Number(totalVRAM) || 0);
if (vram < 6) {
return "gemma3:1b";
} else if (vram < 16) {
return "gemma3:4b";
}
return "gpt-oss:20b";
}
export function useSelectedModel(currentChatId?: string, searchQuery?: string) {
const { settings, setSettings } = useSettings();
const { data: models = [], isLoading } = useModels(searchQuery || "");
const { data: chatData, isLoading: isChatLoading } = useChat(
currentChatId && currentChatId !== "new" ? currentChatId : "",
);
const { data: inferenceComputes = [] } = useQuery({
queryKey: ["inference-compute"],
queryFn: getInferenceCompute,
enabled: !settings.selectedModel, // Only fetch if no model is selected
});
const totalVRAM = useMemo(
() => getTotalVRAM(inferenceComputes),
[inferenceComputes],
);
const recommendedModel = useMemo(
() => recommendDefaultModel(totalVRAM),
[totalVRAM],
);
// Track which chat we've already restored the model for
const restoredChatRef = useRef<string | null>(null);
const selectedModel: Model | null = useMemo(() => {
// if airplane mode is on and selected model ends with cloud,
// switch to recommended default model
if (settings.airplaneMode && settings.selectedModel?.endsWith("cloud")) {
return (
models.find((m) => m.model === recommendedModel) ||
models.find((m) => m.isCloud) ||
models.find((m) => m.digest === undefined || m.digest === "") ||
models[0] ||
null
);
}
// Migration logic: if turboEnabled is true and selectedModel is a base model,
// migrate to the cloud version and disable turboEnabled permanently
// TODO: remove this logic in a future release
const baseModelsToMigrate = [
"gpt-oss:20b",
"gpt-oss:120b",
"deepseek-v3.1:671b",
"qwen3-coder:480b",
];
const shouldMigrate =
!settings.airplaneMode &&
settings.turboEnabled &&
baseModelsToMigrate.includes(settings.selectedModel);
if (shouldMigrate) {
const cloudModel = `${settings.selectedModel}cloud`;
return (
models.find((m) => m.model === cloudModel) ||
new Model({
model: cloudModel,
cloud: true,
ollama_host: false,
})
);
}
return (
models.find((m) => m.model === settings.selectedModel) ||
(settings.selectedModel &&
new Model({
model: settings.selectedModel,
cloud: FEATURED_MODELS.some(
(f) => f.endsWith("cloud") && f === settings.selectedModel,
),
ollama_host: false,
})) ||
null
);
}, [models, settings.selectedModel, settings.airplaneMode, recommendedModel]);
useEffect(() => {
if (!selectedModel) return;
if (
settings.airplaneMode &&
settings.selectedModel?.endsWith("cloud") &&
selectedModel.model !== settings.selectedModel
) {
setSettings({ SelectedModel: selectedModel.model });
}
if (
!settings.airplaneMode &&
settings.turboEnabled &&
selectedModel.model !== settings.selectedModel
) {
setSettings({ SelectedModel: selectedModel.model, TurboEnabled: false });
}
}, [selectedModel, settings.airplaneMode, settings.selectedModel]);
// Set model from chat history when chat data loads
useEffect(() => {
// Only run this effect if we have a valid currentChatId
if (!currentChatId || currentChatId === "new") {
return;
}
if (
chatData?.chat?.messages &&
!isChatLoading &&
restoredChatRef.current !== currentChatId
) {
// Find the most recent model used in this chat
const messages = [...chatData.chat.messages].reverse();
for (const message of messages) {
if (message.model) {
const chatModelName = message.model;
if (chatModelName !== settings.selectedModel) {
setSettings({ SelectedModel: chatModelName });
}
// Mark this chat as restored
restoredChatRef.current = currentChatId;
return;
}
}
// Mark this chat as processed even if no model was found
restoredChatRef.current = currentChatId;
}
}, [
currentChatId,
chatData,
isChatLoading,
settings.selectedModel,
setSettings,
]);
// On initial load, if no model is selected, set default model
useEffect(() => {
if (
isLoading ||
inferenceComputes.length === 0 ||
models.length === 0 ||
settings.selectedModel
) {
return;
}
const defaultModel =
models.find((m) => m.model === recommendedModel) ||
models.find((m) => m.isCloud()) ||
models.find((m) => m.digest === undefined || m.digest === "") ||
models[0];
if (defaultModel) {
setSettings({ SelectedModel: defaultModel.model });
}
}, [
isLoading,
inferenceComputes.length,
models.length,
settings.selectedModel,
]);
// Add the selected model to the models list if it's not already there
const allModels = useMemo(() => {
if (!selectedModel || models.find((m) => m.model === selectedModel.model)) {
return models;
}
return [...models, selectedModel];
}, [models, selectedModel]);
return {
selectedModel,
setSettings,
models: allModels,
loading: isLoading,
};
}
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Settings } from "@/gotypes";
import { getSettings, updateSettings } from "@/api";
import { useMemo, useCallback } from "react";
// TODO(hoyyeva): remove turboEnabled when we remove Migration logic in useSelectedModel.ts
interface SettingsState {
turboEnabled: boolean;
webSearchEnabled: boolean;
selectedModel: string;
sidebarOpen: boolean;
airplaneMode: boolean;
thinkEnabled: boolean;
thinkLevel: string;
}
// Type for partial settings updates
type SettingsUpdate = Partial<{
TurboEnabled: boolean;
WebSearchEnabled: boolean;
ThinkEnabled: boolean;
ThinkLevel: string;
SelectedModel: string;
SidebarOpen: boolean;
}>;
export function useSettings() {
const queryClient = useQueryClient();
// Fetch settings with useQuery
const { data: settingsData, error } = useQuery({
queryKey: ["settings"],
queryFn: getSettings,
});
// Update settings with useMutation
const updateSettingsMutation = useMutation({
mutationFn: updateSettings,
onSuccess: () => {
// Invalidate the query to ensure fresh data
queryClient.invalidateQueries({ queryKey: ["settings"] });
},
});
// Extract settings with defaults
const settings: SettingsState = useMemo(
() => ({
turboEnabled: settingsData?.settings?.TurboEnabled ?? false,
webSearchEnabled: settingsData?.settings?.WebSearchEnabled ?? false,
thinkEnabled: settingsData?.settings?.ThinkEnabled ?? false,
thinkLevel: settingsData?.settings?.ThinkLevel ?? "none",
selectedModel: settingsData?.settings?.SelectedModel ?? "",
sidebarOpen: settingsData?.settings?.SidebarOpen ?? false,
airplaneMode: settingsData?.settings?.AirplaneMode ?? false,
}),
[settingsData?.settings],
);
// Single function to update most settings
const setSettings = useCallback(
async (updates: SettingsUpdate) => {
if (!settingsData?.settings) return;
const updatedSettings = new Settings({
...settingsData.settings,
...updates,
});
await updateSettingsMutation.mutateAsync(updatedSettings);
},
[settingsData?.settings, updateSettingsMutation],
);
return useMemo(
() => ({
settings,
settingsData: settingsData?.settings,
error,
setSettings,
}),
[settings, settingsData?.settings, error, setSettings],
);
}
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { fetchUser, fetchConnectUrl, disconnectUser } from "@/api";
export function useUser() {
const queryClient = useQueryClient();
const [initialDataLoaded, setInitialDataLoaded] = useState(false);
// Wait for initial data to be loaded
useEffect(() => {
const initialPromise = window.__initialUserDataPromise;
if (initialPromise) {
initialPromise.finally(() => {
setInitialDataLoaded(true);
});
} else {
setInitialDataLoaded(true);
}
}, []);
const userQuery = useQuery({
queryKey: ["user"],
queryFn: () => fetchUser(),
staleTime: 5 * 60 * 1000, // Consider data stale after 5 minutes
gcTime: 10 * 60 * 1000, // Keep in cache for 10 minutes
initialData: null, // Start with null to prevent flashing
});
// Mutation to refresh user data
const refreshUser = useMutation({
mutationFn: () => fetchUser(),
onSuccess: (data) => {
queryClient.setQueryData(["user"], data);
},
});
// Query for connect URL (only fetched when needed)
const connectUrlQuery = useQuery({
queryKey: ["connectUrl"],
queryFn: fetchConnectUrl,
enabled: false, // Don't fetch automatically
staleTime: Infinity, // Connect URL doesn't change
});
const disconnectMutation = useMutation({
mutationFn: disconnectUser,
onSuccess: () => {
queryClient.setQueryData(["user"], null);
},
});
return {
user: userQuery.data,
isLoading:
!initialDataLoaded ||
(userQuery.isLoading && userQuery.data === undefined), // Show loading until initial data is loaded
isError: userQuery.isError,
error: userQuery.error,
isAuthenticated: Boolean(userQuery.data?.name),
refreshUser: refreshUser.mutate,
isRefreshing: refreshUser.isPending,
refetchUser: userQuery.refetch,
fetchConnectUrl: connectUrlQuery.refetch,
connectUrl: connectUrlQuery.data,
disconnectUser: disconnectMutation.mutate,
};
}
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@import "katex/dist/katex.min.css";
@theme {
--font-sans: ui-sans-serif, system-ui, "Segoe UI", sans-serif;
--font-rounded:
"SF Pro Rounded", ui-sans-serif, system-ui, "Segoe UI", sans-serif;
}
@media (prefers-color-scheme: dark) {
/* Dark mode styles go here */
:root {
/* Example dark mode variables */
--bg-color: #1a1a1a;
--text-color: #ffffff;
}
}
@media (prefers-color-scheme: light) {
.prose {
/**
* One Light theme for prism.js
* Based on Atom's One Light theme: https://github.com/atom/atom/tree/master/packages/one-light-syntax
*/
/**
* One Light colours (accurate as of commit eb064bf on 19 Feb 2021)
* From colors.less
* --mono-1: hsl(230, 8%, 24%);
* --mono-2: hsl(230, 6%, 44%);
* --mono-3: hsl(230, 4%, 64%)
* --hue-1: hsl(198, 99%, 37%);
* --hue-2: hsl(221, 87%, 60%);
* --hue-3: hsl(301, 63%, 40%);
* --hue-4: hsl(119, 34%, 47%);
* --hue-5: hsl(5, 74%, 59%);
* --hue-5-2: hsl(344, 84%, 43%);
* --hue-6: hsl(35, 99%, 36%);
* --hue-6-2: hsl(35, 99%, 40%);
* --syntax-fg: hsl(230, 8%, 24%);
* --syntax-bg: hsl(230, 1%, 98%);
* --syntax-gutter: hsl(230, 1%, 62%);
* --syntax-guide: hsla(230, 8%, 24%, 0.2);
* --syntax-accent: hsl(230, 100%, 66%);
* From syntax-variables.less
* --syntax-selection-color: hsl(230, 1%, 90%);
* --syntax-gutter-background-color-selected: hsl(230, 1%, 90%);
* --syntax-cursor-line: hsla(230, 8%, 24%, 0.05);
*/
.token.comment,
.token.prolog,
.token.cdata {
color: hsl(230, 4%, 64%);
}
.token.doctype,
.token.punctuation,
.token.entity {
color: hsl(230, 8%, 24%);
}
.token.attr-name,
.token.class-name,
.token.boolean,
.token.constant,
.token.number,
.token.atrule {
color: hsl(35, 99%, 36%);
}
.token.keyword {
color: hsl(301, 63%, 40%);
}
.token.property,
.token.tag,
.token.symbol,
.token.deleted,
.token.important {
color: hsl(5, 74%, 59%);
}
.token.selector,
.token.string,
.token.char,
.token.builtin,
.token.inserted,
.token.regex,
.token.attr-value,
.token.attr-value > .token.punctuation {
color: hsl(119, 34%, 47%);
}
.token.variable,
.token.operator,
.token.function {
color: hsl(221, 87%, 60%);
}
.token.url {
color: hsl(198, 99%, 37%);
}
/* HTML overrides */
.token.attr-value > .token.punctuation.attr-equals,
.token.special-attr > .token.attr-value > .token.value.css {
color: hsl(230, 8%, 24%);
}
/* CSS overrides */
.language-css .token.selector {
color: hsl(5, 74%, 59%);
}
.language-css .token.property {
color: hsl(230, 8%, 24%);
}
.language-css .token.function,
.language-css .token.url > .token.function {
color: hsl(198, 99%, 37%);
}
.language-css .token.url > .token.string.url {
color: hsl(119, 34%, 47%);
}
.language-css .token.important,
.language-css .token.atrule .token.rule {
color: hsl(301, 63%, 40%);
}
/* JS overrides */
.language-javascript .token.operator {
color: hsl(301, 63%, 40%);
}
.language-javascript
.token.template-string
> .token.interpolation
> .token.interpolation-punctuation.punctuation {
color: hsl(344, 84%, 43%);
}
/* JSON overrides */
.language-json .token.operator {
color: hsl(230, 8%, 24%);
}
.language-json .token.null.keyword {
color: hsl(35, 99%, 36%);
}
/* MD overrides */
.language-markdown .token.url,
.language-markdown .token.url > .token.operator,
.language-markdown .token.url-reference.url > .token.string {
color: hsl(230, 8%, 24%);
}
.language-markdown .token.url > .token.content {
color: hsl(221, 87%, 60%);
}
.language-markdown .token.url > .token.url,
.language-markdown .token.url-reference.url {
color: hsl(198, 99%, 37%);
}
.language-markdown .token.blockquote.punctuation,
.language-markdown .token.hr.punctuation {
color: hsl(230, 4%, 64%);
font-style: italic;
}
.language-markdown .token.code-snippet {
color: hsl(119, 34%, 47%);
}
.language-markdown .token.bold .token.content {
color: hsl(35, 99%, 36%);
}
.language-markdown .token.italic .token.content {
color: hsl(301, 63%, 40%);
}
.language-markdown .token.strike .token.content,
.language-markdown .token.strike .token.punctuation,
.language-markdown .token.list.punctuation,
.language-markdown .token.title.important > .token.punctuation {
color: hsl(5, 74%, 59%);
}
/* General */
.token.bold {
font-weight: bold;
}
.token.comment,
.token.italic {
font-style: italic;
}
.token.entity {
cursor: help;
}
.token.namespace {
opacity: 0.8;
}
/* Plugin overrides */
/* Selectors should have higher specificity than those in the plugins' default stylesheets */
/* Show Invisibles plugin overrides */
.token.token.tab:not(:empty):before,
.token.token.cr:before,
.token.token.lf:before,
.token.token.space:before {
color: hsla(230, 8%, 24%, 0.2);
}
/* Toolbar plugin overrides */
/* Space out all buttons and move them away from the right edge of the code block */
div.code-toolbar > .toolbar.toolbar > .toolbar-item {
margin-right: 0.4em;
}
/* Styling the buttons */
div.code-toolbar > .toolbar.toolbar > .toolbar-item > button,
div.code-toolbar > .toolbar.toolbar > .toolbar-item > a,
div.code-toolbar > .toolbar.toolbar > .toolbar-item > span {
background: hsl(230, 1%, 90%);
color: hsl(230, 6%, 44%);
padding: 0.1em 0.4em;
border-radius: 0.3em;
}
div.code-toolbar > .toolbar.toolbar > .toolbar-item > button:hover,
div.code-toolbar > .toolbar.toolbar > .toolbar-item > button:focus,
div.code-toolbar > .toolbar.toolbar > .toolbar-item > a:hover,
div.code-toolbar > .toolbar.toolbar > .toolbar-item > a:focus,
div.code-toolbar > .toolbar.toolbar > .toolbar-item > span:hover,
div.code-toolbar > .toolbar.toolbar > .toolbar-item > span:focus {
background: hsl(230, 1%, 78%); /* custom: darken(--syntax-bg, 20%) */
color: hsl(230, 8%, 24%);
}
/* Line Highlight plugin overrides */
/* The highlighted line itself */
.line-highlight.line-highlight {
background: hsla(230, 8%, 24%, 0.05);
}
/* Default line numbers in Line Highlight plugin */
.line-highlight.line-highlight:before,
.line-highlight.line-highlight[data-end]:after {
background: hsl(230, 1%, 90%);
color: hsl(230, 8%, 24%);
padding: 0.1em 0.6em;
border-radius: 0.3em;
box-shadow: 0 2px 0 0 rgba(0, 0, 0, 0.2); /* same as Toolbar plugin default */
}
/* Hovering over a linkable line number (in the gutter area) */
/* Requires Line Numbers plugin as well */
pre[id].linkable-line-numbers.linkable-line-numbers
span.line-numbers-rows
> span:hover:before {
background-color: hsla(230, 8%, 24%, 0.05);
}
/* Line Numbers and Command Line plugins overrides */
/* Line separating gutter from coding area */
.line-numbers.line-numbers .line-numbers-rows,
.command-line .command-line-prompt {
border-right-color: hsla(230, 8%, 24%, 0.2);
}
/* Stuff in the gutter */
.line-numbers .line-numbers-rows > span:before,
.command-line .command-line-prompt > span:before {
color: hsl(230, 1%, 62%);
}
/* Match Braces plugin overrides */
/* Note: Outline colour is inherited from the braces */
.rainbow-braces .token.token.punctuation.brace-level-1,
.rainbow-braces .token.token.punctuation.brace-level-5,
.rainbow-braces .token.token.punctuation.brace-level-9 {
color: hsl(5, 74%, 59%);
}
.rainbow-braces .token.token.punctuation.brace-level-2,
.rainbow-braces .token.token.punctuation.brace-level-6,
.rainbow-braces .token.token.punctuation.brace-level-10 {
color: hsl(119, 34%, 47%);
}
.rainbow-braces .token.token.punctuation.brace-level-3,
.rainbow-braces .token.token.punctuation.brace-level-7,
.rainbow-braces .token.token.punctuation.brace-level-11 {
color: hsl(221, 87%, 60%);
}
.rainbow-braces .token.token.punctuation.brace-level-4,
.rainbow-braces .token.token.punctuation.brace-level-8,
.rainbow-braces .token.token.punctuation.brace-level-12 {
color: hsl(301, 63%, 40%);
}
/* Diff Highlight plugin overrides */
/* Taken from https://github.com/atom/github/blob/master/styles/variables.less */
pre.diff-highlight > code .token.token.deleted:not(.prefix),
pre > code.diff-highlight .token.token.deleted:not(.prefix) {
background-color: hsla(353, 100%, 66%, 0.15);
}
pre.diff-highlight > code .token.token.deleted:not(.prefix)::-moz-selection,
pre.diff-highlight
> code
.token.token.deleted:not(.prefix)
*::-moz-selection,
pre > code.diff-highlight .token.token.deleted:not(.prefix)::-moz-selection,
pre
> code.diff-highlight
.token.token.deleted:not(.prefix)
*::-moz-selection {
background-color: hsla(353, 95%, 66%, 0.25);
}
pre.diff-highlight > code .token.token.deleted:not(.prefix)::selection,
pre.diff-highlight > code .token.token.deleted:not(.prefix) *::selection,
pre > code.diff-highlight .token.token.deleted:not(.prefix)::selection,
pre > code.diff-highlight .token.token.deleted:not(.prefix) *::selection {
background-color: hsla(353, 95%, 66%, 0.25);
}
pre.diff-highlight > code .token.token.inserted:not(.prefix),
pre > code.diff-highlight .token.token.inserted:not(.prefix) {
background-color: hsla(137, 100%, 55%, 0.15);
}
pre.diff-highlight
> code
.token.token.inserted:not(.prefix)::-moz-selection,
pre.diff-highlight
> code
.token.token.inserted:not(.prefix)
*::-moz-selection,
pre
> code.diff-highlight
.token.token.inserted:not(.prefix)::-moz-selection,
pre
> code.diff-highlight
.token.token.inserted:not(.prefix)
*::-moz-selection {
background-color: hsla(135, 73%, 55%, 0.25);
}
pre.diff-highlight > code .token.token.inserted:not(.prefix)::selection,
pre.diff-highlight > code .token.token.inserted:not(.prefix) *::selection,
pre > code.diff-highlight .token.token.inserted:not(.prefix)::selection,
pre > code.diff-highlight .token.token.inserted:not(.prefix) *::selection {
background-color: hsla(135, 73%, 55%, 0.25);
}
/* Previewers plugin overrides */
/* Based on https://github.com/atom-community/atom-ide-datatip/blob/master/styles/atom-ide-datatips.less and https://github.com/atom/atom/blob/master/packages/one-light-ui */
/* Border around popup */
.prism-previewer.prism-previewer:before,
.prism-previewer-gradient.prism-previewer-gradient div {
border-color: hsl(0, 0, 95%);
}
/* Angle and time should remain as circles and are hence not included */
.prism-previewer-color.prism-previewer-color:before,
.prism-previewer-gradient.prism-previewer-gradient div,
.prism-previewer-easing.prism-previewer-easing:before {
border-radius: 0.3em;
}
/* Triangles pointing to the code */
.prism-previewer.prism-previewer:after {
border-top-color: hsl(0, 0, 95%);
}
.prism-previewer-flipped.prism-previewer-flipped.after {
border-bottom-color: hsl(0, 0, 95%);
}
/* Background colour within the popup */
.prism-previewer-angle.prism-previewer-angle:before,
.prism-previewer-time.prism-previewer-time:before,
.prism-previewer-easing.prism-previewer-easing {
background: hsl(0, 0%, 100%);
}
/* For angle, this is the positive area (eg. 90deg will display one quadrant in this colour) */
/* For time, this is the alternate colour */
.prism-previewer-angle.prism-previewer-angle circle,
.prism-previewer-time.prism-previewer-time circle {
stroke: hsl(230, 8%, 24%);
stroke-opacity: 1;
}
/* Stroke colours of the handle, direction point, and vector itself */
.prism-previewer-easing.prism-previewer-easing circle,
.prism-previewer-easing.prism-previewer-easing path,
.prism-previewer-easing.prism-previewer-easing line {
stroke: hsl(230, 8%, 24%);
}
/* Fill colour of the handle */
.prism-previewer-easing.prism-previewer-easing circle {
fill: transparent;
}
}
}
@media (prefers-color-scheme: dark) {
.prose {
.token.comment,
.token.prolog,
.token.cdata {
color: hsl(220, 10%, 40%);
}
.token.doctype,
.token.punctuation,
.token.entity {
color: hsl(220, 14%, 71%);
}
.token.attr-name,
.token.class-name,
.token.boolean,
.token.constant,
.token.number,
.token.atrule {
color: hsl(29, 54%, 61%);
}
.token.keyword {
color: hsl(286, 60%, 67%);
}
.token.property,
.token.tag,
.token.symbol,
.token.deleted,
.token.important {
color: hsl(355, 65%, 65%);
}
.token.selector,
.token.string,
.token.char,
.token.builtin,
.token.inserted,
.token.regex,
.token.attr-value,
.token.attr-value > .token.punctuation {
color: hsl(95, 38%, 62%);
}
.token.variable,
.token.operator,
.token.function {
color: hsl(207, 82%, 66%);
}
.token.url {
color: hsl(187, 47%, 55%);
}
/* HTML overrides */
.token.attr-value > .token.punctuation.attr-equals,
.token.special-attr > .token.attr-value > .token.value.css {
color: hsl(220, 14%, 71%);
}
/* CSS overrides */
.language-css .token.selector {
color: hsl(355, 65%, 65%);
}
.language-css .token.property {
color: hsl(220, 14%, 71%);
}
.language-css .token.function,
.language-css .token.url > .token.function {
color: hsl(187, 47%, 55%);
}
.language-css .token.url > .token.string.url {
color: hsl(95, 38%, 62%);
}
.language-css .token.important,
.language-css .token.atrule .token.rule {
color: hsl(286, 60%, 67%);
}
/* JS overrides */
.language-javascript .token.operator {
color: hsl(286, 60%, 67%);
}
.language-javascript
.token.template-string
> .token.interpolation
> .token.interpolation-punctuation.punctuation {
color: hsl(5, 48%, 51%);
}
/* JSON overrides */
.language-json .token.operator {
color: hsl(220, 14%, 71%);
}
.language-json .token.null.keyword {
color: hsl(29, 54%, 61%);
}
/* MD overrides */
.language-markdown .token.url,
.language-markdown .token.url > .token.operator,
.language-markdown .token.url-reference.url > .token.string {
color: hsl(220, 14%, 71%);
}
.language-markdown .token.url > .token.content {
color: hsl(207, 82%, 66%);
}
.language-markdown .token.url > .token.url,
.language-markdown .token.url-reference.url {
color: hsl(187, 47%, 55%);
}
.language-markdown .token.blockquote.punctuation,
.language-markdown .token.hr.punctuation {
color: hsl(220, 10%, 40%);
font-style: italic;
}
.language-markdown .token.code-snippet {
color: hsl(95, 38%, 62%);
}
.language-markdown .token.bold .token.content {
color: hsl(29, 54%, 61%);
}
.language-markdown .token.italic .token.content {
color: hsl(286, 60%, 67%);
}
.language-markdown .token.strike .token.content,
.language-markdown .token.strike .token.punctuation,
.language-markdown .token.list.punctuation,
.language-markdown .token.title.important > .token.punctuation {
color: hsl(355, 65%, 65%);
}
/* General */
.token.bold {
font-weight: bold;
}
.token.comment,
.token.italic {
font-style: italic;
}
.token.entity {
cursor: help;
}
.token.namespace {
opacity: 0.8;
}
/* Plugin overrides */
/* Selectors should have higher specificity than those in the plugins' default stylesheets */
/* Show Invisibles plugin overrides */
.token.token.tab:not(:empty):before,
.token.token.cr:before,
.token.token.lf:before,
.token.token.space:before {
color: hsla(220, 14%, 71%, 0.15);
text-shadow: none;
}
/* Toolbar plugin overrides */
/* Space out all buttons and move them away from the right edge of the code block */
div.code-toolbar > .toolbar.toolbar > .toolbar-item {
margin-right: 0.4em;
}
/* Styling the buttons */
div.code-toolbar > .toolbar.toolbar > .toolbar-item > button,
div.code-toolbar > .toolbar.toolbar > .toolbar-item > a,
div.code-toolbar > .toolbar.toolbar > .toolbar-item > span {
background: hsl(220, 13%, 26%);
color: hsl(220, 9%, 55%);
padding: 0.1em 0.4em;
border-radius: 0.3em;
}
div.code-toolbar > .toolbar.toolbar > .toolbar-item > button:hover,
div.code-toolbar > .toolbar.toolbar > .toolbar-item > button:focus,
div.code-toolbar > .toolbar.toolbar > .toolbar-item > a:hover,
div.code-toolbar > .toolbar.toolbar > .toolbar-item > a:focus,
div.code-toolbar > .toolbar.toolbar > .toolbar-item > span:hover,
div.code-toolbar > .toolbar.toolbar > .toolbar-item > span:focus {
background: hsl(220, 13%, 28%);
color: hsl(220, 14%, 71%);
}
/* Line Highlight plugin overrides */
/* The highlighted line itself */
.line-highlight.line-highlight {
background: hsla(220, 100%, 80%, 0.04);
}
/* Default line numbers in Line Highlight plugin */
.line-highlight.line-highlight:before,
.line-highlight.line-highlight[data-end]:after {
background: hsl(220, 13%, 26%);
color: hsl(220, 14%, 71%);
padding: 0.1em 0.6em;
border-radius: 0.3em;
box-shadow: 0 2px 0 0 rgba(0, 0, 0, 0.2); /* same as Toolbar plugin default */
}
/* Hovering over a linkable line number (in the gutter area) */
/* Requires Line Numbers plugin as well */
pre[id].linkable-line-numbers.linkable-line-numbers
span.line-numbers-rows
> span:hover:before {
background-color: hsla(220, 100%, 80%, 0.04);
}
/* Line Numbers and Command Line plugins overrides */
/* Line separating gutter from coding area */
.line-numbers.line-numbers .line-numbers-rows,
.command-line .command-line-prompt {
border-right-color: hsla(220, 14%, 71%, 0.15);
}
/* Stuff in the gutter */
.line-numbers .line-numbers-rows > span:before,
.command-line .command-line-prompt > span:before {
color: hsl(220, 14%, 45%);
}
/* Match Braces plugin overrides */
/* Note: Outline colour is inherited from the braces */
.rainbow-braces .token.token.punctuation.brace-level-1,
.rainbow-braces .token.token.punctuation.brace-level-5,
.rainbow-braces .token.token.punctuation.brace-level-9 {
color: hsl(355, 65%, 65%);
}
.rainbow-braces .token.token.punctuation.brace-level-2,
.rainbow-braces .token.token.punctuation.brace-level-6,
.rainbow-braces .token.token.punctuation.brace-level-10 {
color: hsl(95, 38%, 62%);
}
.rainbow-braces .token.token.punctuation.brace-level-3,
.rainbow-braces .token.token.punctuation.brace-level-7,
.rainbow-braces .token.token.punctuation.brace-level-11 {
color: hsl(207, 82%, 66%);
}
.rainbow-braces .token.token.punctuation.brace-level-4,
.rainbow-braces .token.token.punctuation.brace-level-8,
.rainbow-braces .token.token.punctuation.brace-level-12 {
color: hsl(286, 60%, 67%);
}
/* Diff Highlight plugin overrides */
/* Taken from https://github.com/atom/github/blob/master/styles/variables.less */
pre.diff-highlight > code .token.token.deleted:not(.prefix),
pre > code.diff-highlight .token.token.deleted:not(.prefix) {
background-color: hsla(353, 100%, 66%, 0.15);
}
pre.diff-highlight > code .token.token.deleted:not(.prefix)::-moz-selection,
pre.diff-highlight
> code
.token.token.deleted:not(.prefix)
*::-moz-selection,
pre > code.diff-highlight .token.token.deleted:not(.prefix)::-moz-selection,
pre
> code.diff-highlight
.token.token.deleted:not(.prefix)
*::-moz-selection {
background-color: hsla(353, 95%, 66%, 0.25);
}
pre.diff-highlight > code .token.token.deleted:not(.prefix)::selection,
pre.diff-highlight > code .token.token.deleted:not(.prefix) *::selection,
pre > code.diff-highlight .token.token.deleted:not(.prefix)::selection,
pre > code.diff-highlight .token.token.deleted:not(.prefix) *::selection {
background-color: hsla(353, 95%, 66%, 0.25);
}
pre.diff-highlight > code .token.token.inserted:not(.prefix),
pre > code.diff-highlight .token.token.inserted:not(.prefix) {
background-color: hsla(137, 100%, 55%, 0.15);
}
pre.diff-highlight
> code
.token.token.inserted:not(.prefix)::-moz-selection,
pre.diff-highlight
> code
.token.token.inserted:not(.prefix)
*::-moz-selection,
pre
> code.diff-highlight
.token.token.inserted:not(.prefix)::-moz-selection,
pre
> code.diff-highlight
.token.token.inserted:not(.prefix)
*::-moz-selection {
background-color: hsla(135, 73%, 55%, 0.25);
}
pre.diff-highlight > code .token.token.inserted:not(.prefix)::selection,
pre.diff-highlight > code .token.token.inserted:not(.prefix) *::selection,
pre > code.diff-highlight .token.token.inserted:not(.prefix)::selection,
pre > code.diff-highlight .token.token.inserted:not(.prefix) *::selection {
background-color: hsla(135, 73%, 55%, 0.25);
}
/* Previewers plugin overrides */
/* Based on https://github.com/atom-community/atom-ide-datatip/blob/master/styles/atom-ide-datatips.less and https://github.com/atom/atom/blob/master/packages/one-dark-ui */
/* Border around popup */
.prism-previewer.prism-previewer:before,
.prism-previewer-gradient.prism-previewer-gradient div {
border-color: hsl(224, 13%, 17%);
}
/* Angle and time should remain as circles and are hence not included */
.prism-previewer-color.prism-previewer-color:before,
.prism-previewer-gradient.prism-previewer-gradient div,
.prism-previewer-easing.prism-previewer-easing:before {
border-radius: 0.3em;
}
/* Triangles pointing to the code */
.prism-previewer.prism-previewer:after {
border-top-color: hsl(224, 13%, 17%);
}
.prism-previewer-flipped.prism-previewer-flipped.after {
border-bottom-color: hsl(224, 13%, 17%);
}
/* Background colour within the popup */
.prism-previewer-angle.prism-previewer-angle:before,
.prism-previewer-time.prism-previewer-time:before,
.prism-previewer-easing.prism-previewer-easing {
background: hsl(219, 13%, 22%);
}
/* For angle, this is the positive area (eg. 90deg will display one quadrant in this colour) */
/* For time, this is the alternate colour */
.prism-previewer-angle.prism-previewer-angle circle,
.prism-previewer-time.prism-previewer-time circle {
stroke: hsl(220, 14%, 71%);
stroke-opacity: 1;
}
/* Stroke colours of the handle, direction point, and vector itself */
.prism-previewer-easing.prism-previewer-easing circle,
.prism-previewer-easing.prism-previewer-easing path,
.prism-previewer-easing.prism-previewer-easing line {
stroke: hsl(220, 14%, 71%);
}
/* Fill colour of the handle */
.prism-previewer-easing.prism-previewer-easing circle {
fill: transparent;
}
}
}
.prose pre {
contain: layout style;
}
/* Or more aggressively */
.prose pre code {
contain: layout style paint;
}
/* messaging-style typing indicator animation */
@keyframes typing {
0%,
60%,
100% {
opacity: 0;
}
30% {
opacity: 1;
}
}
import { Ollama } from "ollama/browser";
let _ollamaClient: Ollama | null = null;
export const ollamaClient = new Proxy({} as Ollama, {
get(_target, prop) {
if (!_ollamaClient) {
_ollamaClient = new Ollama({
host: window.location.origin,
});
}
const value = _ollamaClient[prop as keyof Ollama];
return typeof value === "function" ? value.bind(_ollamaClient) : value;
},
});
import { StrictMode } from "react";
import ReactDOM from "react-dom/client";
import { RouterProvider, createRouter } from "@tanstack/react-router";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { routeTree } from "./routeTree.gen";
import { fetchUser } from "./api";
import { StreamingProvider } from "./contexts/StreamingContext";
import { User } from "@/gotypes";
declare global {
interface Window {
__initialUserDataPromise?: Promise<User | null>;
}
}
const queryClient = new QueryClient({
defaultOptions: {
mutations: {
networkMode: "always", // Run mutations regardless of network state
},
queries: {
networkMode: "always", // Allow queries even when offline (local server)
},
},
});
// Track initial user data fetch
let initialUserDataPromise: Promise<User | null> | null = null;
// Initialize user data on app startup
const initializeUserData = async () => {
try {
const userData = await fetchUser();
queryClient.setQueryData(["user"], userData);
return userData;
} catch (error) {
console.error("Error initializing user data:", error);
queryClient.setQueryData(["user"], null);
return null;
}
};
// Start initialization immediately and track the promise
initialUserDataPromise = initializeUserData();
// Export the promise so hooks can await it
window.__initialUserDataPromise = initialUserDataPromise;
const router = createRouter({
routeTree,
context: { queryClient },
});
// Register the router instance for type safety
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}
const rootElement = document.getElementById("root")!;
if (!rootElement.innerHTML) {
const root = ReactDOM.createRoot(rootElement);
root.render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<StreamingProvider>
<RouterProvider router={router} />
</StreamingProvider>
</QueryClientProvider>
</StrictMode>,
);
}
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
// Import Routes
import { Route as rootRoute } from './routes/__root'
import { Route as SettingsImport } from './routes/settings'
import { Route as IndexImport } from './routes/index'
import { Route as CChatIdImport } from './routes/c.$chatId'
// Create/Update Routes
const SettingsRoute = SettingsImport.update({
id: '/settings',
path: '/settings',
getParentRoute: () => rootRoute,
} as any)
const IndexRoute = IndexImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRoute,
} as any)
const CChatIdRoute = CChatIdImport.update({
id: '/c/$chatId',
path: '/c/$chatId',
getParentRoute: () => rootRoute,
} as any)
// Populate the FileRoutesByPath interface
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexImport
parentRoute: typeof rootRoute
}
'/settings': {
id: '/settings'
path: '/settings'
fullPath: '/settings'
preLoaderRoute: typeof SettingsImport
parentRoute: typeof rootRoute
}
'/c/$chatId': {
id: '/c/$chatId'
path: '/c/$chatId'
fullPath: '/c/$chatId'
preLoaderRoute: typeof CChatIdImport
parentRoute: typeof rootRoute
}
}
}
// Create and export the route tree
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/settings': typeof SettingsRoute
'/c/$chatId': typeof CChatIdRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/settings': typeof SettingsRoute
'/c/$chatId': typeof CChatIdRoute
}
export interface FileRoutesById {
__root__: typeof rootRoute
'/': typeof IndexRoute
'/settings': typeof SettingsRoute
'/c/$chatId': typeof CChatIdRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/settings' | '/c/$chatId'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/settings' | '/c/$chatId'
id: '__root__' | '/' | '/settings' | '/c/$chatId'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
SettingsRoute: typeof SettingsRoute
CChatIdRoute: typeof CChatIdRoute
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
SettingsRoute: SettingsRoute,
CChatIdRoute: CChatIdRoute,
}
export const routeTree = rootRoute
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
/* ROUTE_MANIFEST_START
{
"routes": {
"__root__": {
"filePath": "__root.tsx",
"children": [
"/",
"/settings",
"/c/$chatId"
]
},
"/": {
"filePath": "index.tsx"
},
"/settings": {
"filePath": "settings.tsx"
},
"/c/$chatId": {
"filePath": "c.$chatId.tsx"
}
}
}
ROUTE_MANIFEST_END */
import type { QueryClient } from "@tanstack/react-query";
import { createRootRouteWithContext, Outlet } from "@tanstack/react-router";
import { getSettings } from "@/api";
import { useQuery } from "@tanstack/react-query";
function RootComponent() {
// This hook ensures settings are fetched on app startup
useQuery({
queryKey: ["settings"],
queryFn: getSettings,
});
return (
<div>
<Outlet />
</div>
);
}
export const Route = createRootRouteWithContext<{
queryClient: QueryClient;
}>()({
component: RootComponent,
});
import { createFileRoute } 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";
export const Route = createFileRoute("/c/$chatId")({
component: RouteComponent,
loader: async ({ context, params }) => {
// Skip loading for "new" chat
if (params.chatId !== "new") {
context.queryClient.ensureQueryData({
queryKey: ["chat", params.chatId],
queryFn: () => getChat(params.chatId),
staleTime: 1500,
});
}
},
});
function RouteComponent() {
const { chatId } = Route.useParams();
// Always call hooks at the top level - use a flag to skip data when chatId is "new"
const {
data: chatData,
isLoading: chatLoading,
error: chatError,
} = useChat(chatId === "new" ? "" : chatId);
// Handle "new" chat case - just use Chat component which handles everything
if (chatId === "new") {
return (
<SidebarLayout sidebar={<ChatSidebar currentChatId={chatId} />}>
<Chat chatId={chatId} />
</SidebarLayout>
);
}
// Handle existing chat case
if (chatLoading) {
return (
<SidebarLayout sidebar={<ChatSidebar currentChatId={chatId} />}>
<div className="p-4">Loading chat...</div>
</SidebarLayout>
);
}
if (chatError) {
return (
<SidebarLayout sidebar={<ChatSidebar currentChatId={chatId} />}>
<div className="p-4 text-red-500">Error loading chat</div>
</SidebarLayout>
);
}
if (!chatData) {
return (
<SidebarLayout sidebar={<ChatSidebar currentChatId={chatId} />}>
<div className="p-4">Chat not found</div>
</SidebarLayout>
);
}
return (
<SidebarLayout sidebar={<ChatSidebar currentChatId={chatId} />}>
<Chat chatId={chatId} />
</SidebarLayout>
);
}
import { createFileRoute, redirect } from "@tanstack/react-router";
export const Route = createFileRoute("/")({
beforeLoad: () => {
throw redirect({
to: "/c/$chatId",
params: { chatId: "new" },
mask: {
to: "/",
},
});
},
});
import { createFileRoute } from "@tanstack/react-router";
import Settings from "@/components/Settings";
export const Route = createFileRoute("/settings")({
component: Settings,
});
// Type declarations for webview API functions
interface ImageData {
filename: string;
path: string;
dataURL: string; // base64 encoded file data
}
interface MenuItem {
label: string;
enabled?: boolean;
separator?: boolean;
}
interface WebviewAPI {
selectFile: () => Promise<ImageData | null>;
selectMultipleFiles: () => Promise<ImageData[] | null>;
selectModelsDirectory: () => Promise<string | null>;
selectWorkingDirectory: () => Promise<string | null>;
}
declare global {
interface Window {
webview?: WebviewAPI;
drag?: () => void;
doubleClick?: () => void;
menu: (items: MenuItem[]) => Promise<string | null>;
OLLAMA_TOOLS?: boolean;
OLLAMA_WEBSEARCH?: boolean;
}
namespace JSX {
interface IntrinsicElements {
input: React.DetailedHTMLProps<
React.InputHTMLAttributes<HTMLInputElement> & {
webkitdirectory?: string;
directory?: string;
},
HTMLInputElement
>;
}
}
interface File {
readonly webkitRelativePath: string;
}
}
export type { ImageData, WebviewAPI, ContextMenuItem, ContextMenuResult };
export async function* parseJsonlFromStream<T>(
stream: ReadableStream<Uint8Array>,
): AsyncGenerator<T, void, unknown> {
const reader = stream.getReader();
const decoder = new TextDecoder();
let buffer = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
// Process any remaining data in buffer
if (buffer.trim()) {
try {
yield JSON.parse(buffer.trim());
} catch (error) {
console.error(`Failed to parse final buffer: ${buffer}`, error);
}
}
break;
}
// Decode the chunk and add to buffer
buffer += decoder.decode(value, { stream: true });
// Process complete lines
const lines = buffer.split("\n");
buffer = lines.pop() || ""; // Keep incomplete line in buffer
for (const line of lines) {
const trimmed = line.trim();
if (trimmed) {
try {
yield JSON.parse(trimmed);
} catch (error) {
console.error(`Failed to parse line: ${trimmed}`, error);
}
}
}
}
} finally {
reader.releaseLock();
}
}
/**
* Helper function to parse JSONL from a Response object
*/
export async function* parseJsonlFromResponse<T>(
response: Response,
): AsyncGenerator<T, void, unknown> {
if (!response.body) {
throw new Error("Response body is null");
}
yield* parseJsonlFromStream<T>(response.body);
}
import { Model } from "@/gotypes";
// Shared file validation logic used by both FileUpload and native dialog selection
export const TEXT_FILE_EXTENSIONS = [
"pdf",
"docx",
"txt",
"md",
"csv",
"json",
"xml",
"html",
"htm",
"js",
"jsx",
"ts",
"tsx",
"py",
"java",
"cpp",
"c",
"cc",
"h",
"cs",
"php",
"rb",
"go",
"rs",
"swift",
"kt",
"scala",
"sh",
"bat",
"yaml",
"yml",
"toml",
"ini",
"cfg",
"conf",
"log",
"rtf",
];
export const IMAGE_EXTENSIONS = ["png", "jpg", "jpeg"];
export interface FileValidationOptions {
maxFileSize?: number; // in MB
allowedExtensions?: string[];
hasVisionCapability?: boolean;
selectedModel?: Model | null;
customValidator?: (file: File) => { valid: boolean; error?: string };
}
export interface ValidationResult {
valid: boolean;
error?: string;
}
export function validateFile(
file: File,
options: FileValidationOptions = {},
): ValidationResult {
const {
maxFileSize = 10,
allowedExtensions = [...TEXT_FILE_EXTENSIONS, ...IMAGE_EXTENSIONS],
hasVisionCapability = false,
customValidator,
} = options;
const MAX_FILE_SIZE = maxFileSize * 1024 * 1024; // Convert MB to bytes
const fileExtension = file.name.toLowerCase().split(".").pop();
// Custom validation first
if (customValidator) {
const customResult = customValidator(file);
if (!customResult.valid) {
return customResult;
}
}
// File extension validation
if (!fileExtension || !allowedExtensions.includes(fileExtension)) {
return { valid: false, error: "File type not supported" };
}
if (IMAGE_EXTENSIONS.includes(fileExtension) && !hasVisionCapability) {
return { valid: false, error: "This model does not support images" };
}
// File size validation
if (file.size > MAX_FILE_SIZE) {
return { valid: false, error: "File too large" };
}
return { valid: true };
}
// Helper function to read file as Uint8Array
export function readFileAsBytes(file: File): Promise<Uint8Array> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const arrayBuffer = reader.result as ArrayBuffer;
resolve(new Uint8Array(arrayBuffer));
};
reader.onerror = () => reject(reader.error);
reader.readAsArrayBuffer(file);
});
}
// Process multiple files with validation
export async function processFiles(
files: File[],
options: FileValidationOptions = {},
): Promise<{
validFiles: Array<{ filename: string; data: Uint8Array; type?: string }>;
errors: Array<{ filename: string; error: string }>;
}> {
const validFiles: Array<{
filename: string;
data: Uint8Array;
type?: string;
}> = [];
const errors: Array<{ filename: string; error: string }> = [];
for (const file of files) {
const validation = validateFile(file, options);
if (!validation.valid) {
errors.push({
filename: file.name,
error: validation.error || "File validation failed",
});
continue;
}
try {
const fileBytes = await readFileAsBytes(file);
validFiles.push({
filename: file.name,
data: fileBytes,
type: file.type || undefined,
});
} catch (error) {
console.error(`Error reading file ${file.name}:`, error);
errors.push({
filename: file.name,
error: "Error reading file",
});
}
}
return { validFiles, errors };
}
export function isImageFile(filename: string): boolean {
const extension = filename.toLowerCase().split(".").pop();
return ["png", "jpg", "jpeg", "gif", "webp"].includes(extension || "");
}
import { describe, it, expect } from "vitest";
import { Model } from "@/gotypes";
import { mergeModels, FEATURED_MODELS } from "@/utils/mergeModels";
import "@/api";
describe("Model merging logic", () => {
it("should handle cloud models with -cloud suffix", () => {
const localModels: Model[] = [
new Model({ model: "gpt-oss:120b-cloud" }),
new Model({ model: "llama3:latest" }),
new Model({ model: "mistral:latest" }),
];
const merged = mergeModels(localModels);
// First verify cloud models are first and in FEATURED_MODELS order
const cloudModels = FEATURED_MODELS.filter((m: string) =>
m.endsWith("cloud"),
);
for (let i = 0; i < cloudModels.length; i++) {
expect(merged[i].model).toBe(cloudModels[i]);
expect(merged[i].isCloud()).toBe(true);
}
// Then verify non-cloud featured models are next and in FEATURED_MODELS order
const nonCloudFeatured = FEATURED_MODELS.filter(
(m: string) => !m.endsWith("cloud"),
);
for (let i = 0; i < nonCloudFeatured.length; i++) {
const model = merged[i + cloudModels.length];
expect(model.model).toBe(nonCloudFeatured[i]);
expect(model.isCloud()).toBe(false);
}
// Verify local models are preserved and come after featured models
const featuredCount = FEATURED_MODELS.length;
expect(merged[featuredCount].model).toBe("llama3:latest");
expect(merged[featuredCount + 1].model).toBe("mistral:latest");
// Length should be exactly featured models plus our local models
expect(merged.length).toBe(FEATURED_MODELS.length + 2);
});
it("should hide cloud models in airplane mode", () => {
const localModels: Model[] = [
new Model({ model: "gpt-oss:120b-cloud" }),
new Model({ model: "llama3:latest" }),
new Model({ model: "mistral:latest" }),
];
const merged = mergeModels(localModels, true); // airplane mode = true
// No cloud models should be present
const cloudModels = merged.filter((m) => m.isCloud());
expect(cloudModels.length).toBe(0);
// Should have non-cloud featured models
const nonCloudFeatured = FEATURED_MODELS.filter(
(m) => !m.endsWith("cloud"),
);
for (let i = 0; i < nonCloudFeatured.length; i++) {
const model = merged[i];
expect(model.model).toBe(nonCloudFeatured[i]);
expect(model.isCloud()).toBe(false);
}
// Local models should be preserved
const featuredCount = nonCloudFeatured.length;
expect(merged[featuredCount].model).toBe("llama3:latest");
expect(merged[featuredCount + 1].model).toBe("mistral:latest");
});
it("should handle empty input", () => {
const merged = mergeModels([]);
// First verify cloud models are first and in FEATURED_MODELS order
const cloudModels = FEATURED_MODELS.filter((m) => m.endsWith("cloud"));
for (let i = 0; i < cloudModels.length; i++) {
expect(merged[i].model).toBe(cloudModels[i]);
expect(merged[i].isCloud()).toBe(true);
}
// Then verify non-cloud featured models are next and in FEATURED_MODELS order
const nonCloudFeatured = FEATURED_MODELS.filter(
(m) => !m.endsWith("cloud"),
);
for (let i = 0; i < nonCloudFeatured.length; i++) {
const model = merged[i + cloudModels.length];
expect(model.model).toBe(nonCloudFeatured[i]);
expect(model.isCloud()).toBe(false);
}
// Length should be exactly FEATURED_MODELS length
expect(merged.length).toBe(FEATURED_MODELS.length);
});
it("should sort models correctly", () => {
const localModels: Model[] = [
new Model({ model: "zephyr:latest" }),
new Model({ model: "alpha:latest" }),
new Model({ model: "gpt-oss:120b-cloud" }),
];
const merged = mergeModels(localModels);
// First verify cloud models are first and in FEATURED_MODELS order
const cloudModels = FEATURED_MODELS.filter((m) => m.endsWith("cloud"));
for (let i = 0; i < cloudModels.length; i++) {
expect(merged[i].model).toBe(cloudModels[i]);
expect(merged[i].isCloud()).toBe(true);
}
// Then verify non-cloud featured models are next and in FEATURED_MODELS order
const nonCloudFeatured = FEATURED_MODELS.filter(
(m) => !m.endsWith("cloud"),
);
for (let i = 0; i < nonCloudFeatured.length; i++) {
const model = merged[i + cloudModels.length];
expect(model.model).toBe(nonCloudFeatured[i]);
expect(model.isCloud()).toBe(false);
}
// Non-featured local models should be at the end in alphabetical order
const featuredCount = FEATURED_MODELS.length;
expect(merged[featuredCount].model).toBe("alpha:latest");
expect(merged[featuredCount + 1].model).toBe("zephyr:latest");
});
});
import { Model } from "@/gotypes";
// Featured models list (in priority order)
export const FEATURED_MODELS = [
"gpt-oss:120b-cloud",
"gpt-oss:20b-cloud",
"deepseek-v3.1:671b-cloud",
"qwen3-coder:480b-cloud",
"qwen3-vl:235b-cloud",
"minimax-m2:cloud",
"glm-4.6:cloud",
"gpt-oss:120b",
"gpt-oss:20b",
"gemma3:27b",
"gemma3:12b",
"gemma3:4b",
"gemma3:1b",
"deepseek-r1:8b",
"qwen3-coder:30b",
"qwen3-vl:30b",
"qwen3-vl:8b",
"qwen3-vl:4b",
"qwen3:30b",
"qwen3:8b",
"qwen3:4b",
];
function alphabeticalSort(a: Model, b: Model): number {
return a.model.toLowerCase().localeCompare(b.model.toLowerCase());
}
//Merges models, sorting cloud models first, then other models
export function mergeModels(
localModels: Model[],
airplaneMode: boolean = false,
): Model[] {
const allModels = (localModels || []).map((model) => model);
// 1. Get cloud models from local models and featured list
const cloudModels = [...allModels.filter((m) => m.isCloud())];
// Add any cloud models from FEATURED_MODELS that aren't in local models
FEATURED_MODELS.filter((f) => f.endsWith("cloud")).forEach((cloudModel) => {
if (!cloudModels.some((m) => m.model === cloudModel)) {
cloudModels.push(new Model({ model: cloudModel }));
}
});
// 2. Get other featured models (non-cloud)
const featuredModels = FEATURED_MODELS.filter(
(f) => !f.endsWith("cloud"),
).map((model) => {
// Check if this model exists in local models
const localMatch = allModels.find(
(m) => m.model.toLowerCase() === model.toLowerCase(),
);
if (localMatch) return localMatch;
return new Model({
model,
});
});
// 3. Get remaining local models that aren't featured and aren't cloud models
const remainingModels = allModels.filter(
(model) =>
!model.isCloud() &&
!FEATURED_MODELS.some(
(f) => f.toLowerCase() === model.model.toLowerCase(),
),
);
cloudModels.sort((a, b) => {
const aIndex = FEATURED_MODELS.indexOf(a.model);
const bIndex = FEATURED_MODELS.indexOf(b.model);
// If both are featured, sort by their position in FEATURED_MODELS
if (aIndex !== -1 && bIndex !== -1) {
return aIndex - bIndex;
}
// If only one is featured, featured model comes first
if (aIndex !== -1 && bIndex === -1) return -1;
if (aIndex === -1 && bIndex !== -1) return 1;
// If neither is featured, sort alphabetically
return a.model.toLowerCase().localeCompare(b.model.toLowerCase());
});
featuredModels.sort(
(a, b) =>
FEATURED_MODELS.indexOf(a.model) - FEATURED_MODELS.indexOf(b.model),
);
remainingModels.sort(alphabeticalSort);
return airplaneMode
? [...featuredModels, ...remainingModels]
: [...cloudModels, ...featuredModels, ...remainingModels];
}
import { remark } from "remark";
import remarkStringify from "remark-stringify";
import remarkStreamingMarkdown from "./remarkStreamingMarkdown";
/**
* Process markdown content for streaming display using the remark plugin.
* This is primarily used for testing the remark plugin with string inputs/outputs.
*/
export function processStreamingMarkdown(content: string): string {
if (!content) return content;
const result = remark()
.use(remarkStreamingMarkdown, { debug: false })
.use(remarkStringify)
.processSync(content);
// remove trailing newline to keep tests cleaner
let output = result.toString();
if (output.endsWith("\n")) {
output = output.slice(0, -1);
}
return output;
}
import { visit } from "unist-util-visit";
import type { Root, RootContent } from "mdast";
export default function remarkMyDelimiter() {
return (tree: Root) => {
// First pass: convert citations to nodes
visit(tree, "text", (node, index, parent) => {
// Example: 【1†L25-L30】
// should be parsed into:
// cursor: 1, start: 25, end: 30
const regex = /【(\d+)†L(\d+)-L(\d+)】/g;
let match;
let last = 0;
const pieces: RootContent[] = [];
while ((match = regex.exec(node.value))) {
// text before the delimiter
if (match.index > last) {
pieces.push({
type: "text",
value: node.value.slice(last, match.index),
});
}
// the delimited content → new custom node
pieces.push({
// @ts-expect-error: custom type
type: "custom-citation" as const,
data: {
// tell rehype/rehype-react to render <Citation>
hName: "ol-citation",
hProperties: {
cursor: match[1],
start: match[2],
end: match[3],
},
},
});
last = match.index + match[0].length;
}
// After handling range-style citations, handle generic ones in the remaining text
// Generic style citations like [1†...] should also be parsed as citations
const remaining = node.value.slice(last);
const generic = /【(\d+)[^]*】/g;
let gLast = 0;
while ((match = generic.exec(remaining))) {
if (match.index > gLast) {
pieces.push({
type: "text",
value: remaining.slice(gLast, match.index),
});
}
pieces.push({
// @ts-expect-error: custom type
type: "custom-citation" as const,
data: {
hName: "ol-citation",
hProperties: {
cursor: match[1],
},
},
});
gLast = match.index + match[0].length;
}
// trailing text after generic
if (gLast < remaining.length) {
pieces.push({ type: "text", value: remaining.slice(gLast) });
}
if (pieces.length) {
parent?.children?.splice(index ?? 0, 1, ...pieces);
return (index ?? 0) + pieces.length;
}
});
// Second pass: remove adjacent duplicate citations
visit(tree, (node, index, parent) => {
if (
parent &&
parent.children &&
index !== null &&
index !== undefined &&
index > 0
) {
const currentNode = node as any;
const prevNode = parent.children[index - 1] as any;
// Check if both nodes are citations with the same cursor
if (
currentNode.type === "custom-citation" &&
prevNode.type === "custom-citation" &&
currentNode.data?.hProperties?.cursor ===
prevNode.data?.hProperties?.cursor
) {
// Remove the current duplicate citation
parent.children.splice(index, 1);
return index;
}
}
});
};
}
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment