87 | >(({ className, ...props }, ref) => (
88 | [role=checkbox]]:translate-y-[2px]",
92 | className
93 | )}
94 | {...props}
95 | />
96 | ))
97 | TableCell.displayName = "TableCell"
98 |
99 | const TableCaption = React.forwardRef<
100 | HTMLTableCaptionElement,
101 | React.HTMLAttributes
102 | >(({ className, ...props }, ref) => (
103 |
108 | ))
109 | TableCaption.displayName = "TableCaption"
110 |
111 | export {
112 | Table,
113 | TableHeader,
114 | TableBody,
115 | TableFooter,
116 | TableHead,
117 | TableRow,
118 | TableCell,
119 | TableCaption,
120 | }
121 |
--------------------------------------------------------------------------------
/app/components/ui/tabs.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as TabsPrimitive from "@radix-ui/react-tabs"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const Tabs = TabsPrimitive.Root
7 |
8 | const TabsList = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
20 | ))
21 | TabsList.displayName = TabsPrimitive.List.displayName
22 |
23 | const TabsTrigger = React.forwardRef<
24 | React.ElementRef,
25 | React.ComponentPropsWithoutRef
26 | >(({ className, ...props }, ref) => (
27 |
35 | ))
36 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
37 |
38 | const TabsContent = React.forwardRef<
39 | React.ElementRef,
40 | React.ComponentPropsWithoutRef
41 | >(({ className, ...props }, ref) => (
42 |
50 | ))
51 | TabsContent.displayName = TabsPrimitive.Content.displayName
52 |
53 | export { Tabs, TabsList, TabsTrigger, TabsContent }
54 |
--------------------------------------------------------------------------------
/app/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | export interface TextareaProps
6 | extends React.TextareaHTMLAttributes {}
7 |
8 | const Textarea = React.forwardRef(
9 | ({ className, ...props }, ref) => {
10 | return (
11 |
19 | )
20 | }
21 | )
22 | Textarea.displayName = "Textarea"
23 |
24 | export { Textarea }
25 |
--------------------------------------------------------------------------------
/app/components/ui/tooltip.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as TooltipPrimitive from "@radix-ui/react-tooltip"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const TooltipProvider = TooltipPrimitive.Provider
7 |
8 | const Tooltip = TooltipPrimitive.Root
9 |
10 | const TooltipTrigger = TooltipPrimitive.Trigger
11 |
12 | const TooltipContent = React.forwardRef<
13 | React.ElementRef,
14 | React.ComponentPropsWithoutRef
15 | >(({ className, sideOffset = 4, ...props }, ref) => (
16 |
25 | ))
26 | TooltipContent.displayName = TooltipPrimitive.Content.displayName
27 |
28 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
29 |
--------------------------------------------------------------------------------
/app/entry.server.tsx:
--------------------------------------------------------------------------------
1 | import { PassThrough } from "node:stream";
2 |
3 | import type { AppLoadContext, EntryContext } from "@remix-run/node";
4 | import { createReadableStreamFromReadable } from "@remix-run/node";
5 | import { RemixServer } from "@remix-run/react";
6 | import { isbot } from "isbot";
7 | import { renderToPipeableStream } from "react-dom/server";
8 |
9 | const ABORT_DELAY = 28000;
10 | export const streamTimeout = 30000;
11 |
12 | export default function handleRequest(
13 | request: Request,
14 | responseStatusCode: number,
15 | responseHeaders: Headers,
16 | remixContext: EntryContext,
17 | loadContext: AppLoadContext
18 | ) {
19 | let prohibitOutOfOrderStreaming =
20 | isbot(request.headers.get("user-agent")) || remixContext.isSpaMode;
21 |
22 | return prohibitOutOfOrderStreaming
23 | ? handleBotRequest(
24 | request,
25 | responseStatusCode,
26 | responseHeaders,
27 | remixContext
28 | )
29 | : handleBrowserRequest(
30 | request,
31 | responseStatusCode,
32 | responseHeaders,
33 | remixContext
34 | );
35 | }
36 |
37 | function handleBotRequest(
38 | request: Request,
39 | responseStatusCode: number,
40 | responseHeaders: Headers,
41 | remixContext: EntryContext
42 | ) {
43 | return new Promise((resolve, reject) => {
44 | let shellRendered = false;
45 | const { pipe, abort } = renderToPipeableStream(
46 | ,
51 | {
52 | onAllReady() {
53 | shellRendered = true;
54 | const body = new PassThrough();
55 | const stream = createReadableStreamFromReadable(body);
56 |
57 | responseHeaders.set("Content-Type", "text/html");
58 |
59 | resolve(
60 | new Response(stream, {
61 | headers: responseHeaders,
62 | status: responseStatusCode,
63 | })
64 | );
65 |
66 | pipe(body);
67 | },
68 | onShellError(error: unknown) {
69 | reject(error);
70 | },
71 | onError(error: unknown) {
72 | responseStatusCode = 500;
73 | // Log streaming rendering errors from inside the shell. Don't log
74 | // errors encountered during initial shell rendering since they'll
75 | // reject and get logged in handleDocumentRequest.
76 | if (shellRendered) {
77 | console.error(error);
78 | }
79 | },
80 | }
81 | );
82 |
83 | setTimeout(abort, ABORT_DELAY);
84 | });
85 | }
86 |
87 | function handleBrowserRequest(
88 | request: Request,
89 | responseStatusCode: number,
90 | responseHeaders: Headers,
91 | remixContext: EntryContext
92 | ) {
93 | return new Promise((resolve, reject) => {
94 | let shellRendered = false;
95 | const { pipe, abort } = renderToPipeableStream(
96 | ,
101 | {
102 | onShellReady() {
103 | shellRendered = true;
104 | const body = new PassThrough();
105 | const stream = createReadableStreamFromReadable(body);
106 |
107 | responseHeaders.set("Content-Type", "text/html");
108 |
109 | resolve(
110 | new Response(stream, {
111 | headers: responseHeaders,
112 | status: responseStatusCode,
113 | })
114 | );
115 |
116 | pipe(body);
117 | },
118 | onShellError(error: unknown) {
119 | reject(error);
120 | },
121 | onError(error: unknown) {
122 | responseStatusCode = 500;
123 | // Log streaming rendering errors from inside the shell. Don't log
124 | // errors encountered during initial shell rendering since they'll
125 | // reject and get logged in handleDocumentRequest.
126 | if (shellRendered) {
127 | console.error(error);
128 | }
129 | },
130 | }
131 | );
132 |
133 | setTimeout(abort, ABORT_DELAY);
134 | });
135 | }
136 |
--------------------------------------------------------------------------------
/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | :root {
7 | --background: 0 0% 100%;
8 | --foreground: 20 14.3% 4.1%;
9 | --card: 0 0% 100%;
10 | --card-foreground: 20 14.3% 4.1%;
11 | --popover: 0 0% 100%;
12 | --popover-foreground: 20 14.3% 4.1%;
13 | --primary: 24 9.8% 10%;
14 | --primary-foreground: 60 9.1% 97.8%;
15 | --secondary: 60 4.8% 95.9%;
16 | --secondary-foreground: 24 9.8% 10%;
17 | --muted: 60 4.8% 95.9%;
18 | --muted-foreground: 25 5.3% 44.7%;
19 | --accent: 60 4.8% 95.9%;
20 | --accent-foreground: 24 9.8% 10%;
21 | --destructive: 0 84.2% 60.2%;
22 | --destructive-foreground: 60 9.1% 97.8%;
23 | --border: 20 5.9% 90%;
24 | --input: 20 5.9% 90%;
25 | --ring: 20 14.3% 4.1%;
26 | --radius: 0.5rem;
27 | --chart-1: 12 76% 61%;
28 | --chart-2: 173 58% 39%;
29 | --chart-3: 197 37% 24%;
30 | --chart-4: 43 74% 66%;
31 | --chart-5: 27 87% 67%;
32 | }
33 |
34 | .dark {
35 | --background: 20 14.3% 4.1%;
36 | --foreground: 60 9.1% 97.8%;
37 | --card: 20 14.3% 4.1%;
38 | --card-foreground: 60 9.1% 97.8%;
39 | --popover: 20 14.3% 4.1%;
40 | --popover-foreground: 60 9.1% 97.8%;
41 | --primary: 60 9.1% 97.8%;
42 | --primary-foreground: 24 9.8% 10%;
43 | --secondary: 12 6.5% 15.1%;
44 | --secondary-foreground: 60 9.1% 97.8%;
45 | --muted: 12 6.5% 15.1%;
46 | --muted-foreground: 24 5.4% 63.9%;
47 | --accent: 12 6.5% 15.1%;
48 | --accent-foreground: 60 9.1% 97.8%;
49 | --destructive: 0 62.8% 30.6%;
50 | --destructive-foreground: 60 9.1% 97.8%;
51 | --border: 12 6.5% 15.1%;
52 | --input: 12 6.5% 15.1%;
53 | --ring: 24 5.7% 82.9%;
54 | --chart-1: 220 70% 50%;
55 | --chart-2: 160 60% 45%;
56 | --chart-3: 30 80% 55%;
57 | --chart-4: 280 65% 60%;
58 | --chart-5: 340 75% 55%;
59 | }
60 |
61 | /* invert the color schemes */
62 | @media (prefers-color-scheme: dark) {
63 | :root {
64 | --background: 20 14.3% 4.1%;
65 | --foreground: 60 9.1% 97.8%;
66 | --card: 20 14.3% 4.1%;
67 | --card-foreground: 60 9.1% 97.8%;
68 | --popover: 20 14.3% 4.1%;
69 | --popover-foreground: 60 9.1% 97.8%;
70 | --primary: 60 9.1% 97.8%;
71 | --primary-foreground: 24 9.8% 10%;
72 | --secondary: 12 6.5% 15.1%;
73 | --secondary-foreground: 60 9.1% 97.8%;
74 | --muted: 12 6.5% 15.1%;
75 | --muted-foreground: 24 5.4% 63.9%;
76 | --accent: 12 6.5% 15.1%;
77 | --accent-foreground: 60 9.1% 97.8%;
78 | --destructive: 0 62.8% 30.6%;
79 | --destructive-foreground: 60 9.1% 97.8%;
80 | --border: 12 6.5% 15.1%;
81 | --input: 12 6.5% 15.1%;
82 | --ring: 24 5.7% 82.9%;
83 | --chart-1: 220 70% 50%;
84 | --chart-2: 160 60% 45%;
85 | --chart-3: 30 80% 55%;
86 | --chart-4: 280 65% 60%;
87 | --chart-5: 340 75% 55%;
88 | }
89 |
90 | .dark {
91 | --background: 0 0% 100%;
92 | --foreground: 20 14.3% 4.1%;
93 | --card: 0 0% 100%;
94 | --card-foreground: 20 14.3% 4.1%;
95 | --popover: 0 0% 100%;
96 | --popover-foreground: 20 14.3% 4.1%;
97 | --primary: 24 9.8% 10%;
98 | --primary-foreground: 60 9.1% 97.8%;
99 | --secondary: 60 4.8% 95.9%;
100 | --secondary-foreground: 24 9.8% 10%;
101 | --muted: 60 4.8% 95.9%;
102 | --muted-foreground: 25 5.3% 44.7%;
103 | --accent: 60 4.8% 95.9%;
104 | --accent-foreground: 24 9.8% 10%;
105 | --destructive: 0 84.2% 60.2%;
106 | --destructive-foreground: 60 9.1% 97.8%;
107 | --border: 20 5.9% 90%;
108 | --input: 20 5.9% 90%;
109 | --ring: 20 14.3% 4.1%;
110 | --radius: 0.5rem;
111 | --chart-1: 12 76% 61%;
112 | --chart-2: 173 58% 39%;
113 | --chart-3: 197 37% 24%;
114 | --chart-4: 43 74% 66%;
115 | --chart-5: 27 87% 67%;
116 | }
117 | }
118 | }
119 |
120 | @layer base {
121 | * {
122 | @apply border-border;
123 | overscroll-behavior: none;
124 | }
125 | body {
126 | @apply bg-background text-foreground;
127 | }
128 | }
129 |
--------------------------------------------------------------------------------
/app/lib/chat.server.ts:
--------------------------------------------------------------------------------
1 | import { chats, db, messages } from "@/lib/db.server";
2 | import { asc, desc, eq } from "drizzle-orm";
3 |
4 | export async function createChat({
5 | name,
6 | prompt,
7 | temperature,
8 | }: {
9 | name: string | null;
10 | prompt: string | null;
11 | temperature: number | null;
12 | }) {
13 | const rows = await db
14 | .insert(chats)
15 | .values({ name, prompt, temperature })
16 | .returning({ id: chats.id });
17 | const id = rows[0]?.id;
18 | if (typeof id !== "number") throw new Error("Failed to create chat");
19 | return id;
20 | }
21 |
22 | export async function getChat(id: number): Promise {
23 | const chat = await db.query.chats.findFirst({
24 | where: eq(chats.id, id),
25 | columns: {
26 | id: true,
27 | name: true,
28 | model: true,
29 | prompt: true,
30 | temperature: true,
31 | },
32 | with: {
33 | messages: {
34 | orderBy: asc(messages.id),
35 | columns: {
36 | id: true,
37 | from: true,
38 | content: true,
39 | },
40 | },
41 | },
42 | });
43 | return chat ?? null;
44 | }
45 |
46 | export async function getChats({ limit = 10 }: { limit?: number } = {}) {
47 | return await db
48 | .select({
49 | id: chats.id,
50 | name: chats.name,
51 | })
52 | .from(chats)
53 | .orderBy(desc(chats.id))
54 | .limit(limit);
55 | }
56 |
57 | export async function updateChat(
58 | id: number,
59 | {
60 | model,
61 | prompt,
62 | temperature,
63 | }: {
64 | model: string | null;
65 | prompt: string | null;
66 | temperature: number | null;
67 | }
68 | ) {
69 | await db
70 | .update(chats)
71 | .set({ model, prompt, temperature })
72 | .where(eq(chats.id, id));
73 | }
74 |
75 | export async function createMessage(
76 | chatId: number,
77 | { from, content }: { from: "assistant" | "user"; content: string }
78 | ) {
79 | const rows = await db
80 | .insert(messages)
81 | .values({ chatId, from, content })
82 | .returning({
83 | id: messages.id,
84 | });
85 | const id = rows[0]?.id;
86 | if (typeof id !== "number") throw new Error("Failed to create message");
87 | return id;
88 | }
89 |
90 | export type Chat = {
91 | id: number;
92 | name: string | null;
93 | messages: ChatMessage[];
94 | model: string | null;
95 | prompt: string | null;
96 | temperature: number | null;
97 | };
98 |
99 | export type ChatMessage = {
100 | id: number;
101 | from: "assistant" | "user";
102 | content: string;
103 | };
104 |
--------------------------------------------------------------------------------
/app/lib/chat.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect, useRef, useState } from "react";
2 | import type { ChatChunk } from "@/lib/utils.server";
3 |
4 | interface UseChatChunkOptions {
5 | onDone?: () => void;
6 | onError?: (reason: unknown) => void;
7 | }
8 |
9 | export function useChatChunk(
10 | initialChunk?: ChatChunk,
11 | options: UseChatChunkOptions = {}
12 | ) {
13 | const [state, setState] = useState<{ done: boolean; content: string }>({
14 | done: false,
15 | content: "",
16 | });
17 | const processingRef = useRef(false);
18 | const optionsRef = useRef(options);
19 | optionsRef.current = options;
20 |
21 | const processChunks = useCallback(async (startChunk: ChatChunk) => {
22 | if (processingRef.current) return;
23 | processingRef.current = true;
24 |
25 | let currentChunk: ChatChunk | null = startChunk;
26 |
27 | while (currentChunk) {
28 | switch (currentChunk.type) {
29 | case "text":
30 | const content = currentChunk.content;
31 | setState((prevState) => ({
32 | ...prevState,
33 | content: prevState.content + content,
34 | }));
35 | if (currentChunk.next) {
36 | try {
37 | currentChunk = await currentChunk.next;
38 | } catch (err) {
39 | setState((prevState) => ({ ...prevState, done: true }));
40 | if (processingRef.current) {
41 | optionsRef.current.onError?.(err);
42 | }
43 | processingRef.current = false;
44 | return;
45 | }
46 | } else {
47 | currentChunk = null;
48 | }
49 | break;
50 | case "done":
51 | setState((prevState) => ({ ...prevState, done: true }));
52 | if (processingRef.current) {
53 | optionsRef.current.onDone?.();
54 | }
55 | currentChunk = null;
56 | processingRef.current = false;
57 | return;
58 | case "error":
59 | if (processingRef.current) {
60 | setState((prevState) => ({ ...prevState, done: true }));
61 | }
62 | optionsRef.current.onError?.(currentChunk.reason);
63 | currentChunk = null;
64 | processingRef.current = false;
65 | return;
66 | }
67 | }
68 |
69 | processingRef.current = false;
70 | }, []);
71 |
72 | useEffect(() => {
73 | setState({ content: "", done: false }); // Reset content when starting to process a new chunk
74 |
75 | if (initialChunk) {
76 | processChunks(initialChunk);
77 | }
78 |
79 | return () => {
80 | processingRef.current = false;
81 | };
82 | }, [initialChunk, processChunks]);
83 |
84 | return [
85 | state.content.replace(/\r?\n/g, "").trim() ? state.content : "...",
86 | state.done,
87 | () => setState({ content: "", done: false }),
88 | ] as const;
89 | }
90 |
--------------------------------------------------------------------------------
/app/lib/db.server.ts:
--------------------------------------------------------------------------------
1 | import * as os from "node:os";
2 | import * as path from "node:path";
3 |
4 | import Database from "better-sqlite3";
5 | import { relations } from "drizzle-orm";
6 | import { drizzle } from "drizzle-orm/better-sqlite3";
7 | import { text, integer, real, sqliteTable } from "drizzle-orm/sqlite-core";
8 |
9 | export const chats = sqliteTable("chats", {
10 | id: integer("id").notNull().primaryKey({ autoIncrement: true }),
11 | name: text("name"),
12 | model: text("model"),
13 | prompt: text("prompt"),
14 | temperature: real("temperature"),
15 | });
16 |
17 | const chatsRelations = relations(chats, ({ many }) => ({
18 | messages: many(messages),
19 | }));
20 |
21 | export const messages = sqliteTable("messages", {
22 | id: integer("id").notNull().primaryKey({ autoIncrement: true }),
23 | chatId: integer("chat_id")
24 | .notNull()
25 | .references(() => chats.id),
26 | from: text("from", { enum: ["assistant", "user"] }).notNull(),
27 | content: text("content").notNull(),
28 | });
29 |
30 | const messagesRelations = relations(messages, ({ one }) => ({
31 | chat: one(chats, {
32 | fields: [messages.chatId],
33 | references: [chats.id],
34 | }),
35 | }));
36 |
37 | const sqlite = new Database(
38 | path.join(os.homedir(), ".localllama", "db.sqlite")
39 | );
40 | export const db = drizzle(sqlite, {
41 | schema: { chats, chatsRelations, messages, messagesRelations },
42 | });
43 |
--------------------------------------------------------------------------------
/app/lib/settings.server.ts:
--------------------------------------------------------------------------------
1 | import * as fsp from "node:fs/promises";
2 | import * as os from "node:os";
3 | import * as path from "node:path";
4 |
5 | export type GlobalSettings = {
6 | defaultModel: string;
7 | defaultTemperature: number;
8 | defaultSystemPrompt: string;
9 | ollamaHost: string;
10 | defaultOllamaHost: string;
11 | };
12 |
13 | function getGlobalSettingsFilePath() {
14 | return path.join(os.homedir(), ".localllama", "settings.json");
15 | }
16 |
17 | export async function getGlobalSettings(): Promise {
18 | const defaultOllamaHost = process.env.OLLAMA_HOST ?? "http://localhost:11434";
19 | try {
20 | await fsp.mkdir(path.dirname(getGlobalSettingsFilePath()), {
21 | recursive: true,
22 | });
23 | const content = await fsp.readFile(getGlobalSettingsFilePath(), "utf8");
24 | let parsed = JSON.parse(content);
25 | parsed = typeof parsed === "object" ? parsed : {};
26 | return {
27 | ...parsed,
28 | defaultOllamaHost,
29 | ollamaHost: parsed.ollamaHost || defaultOllamaHost,
30 | };
31 | } catch (error) {
32 | if ((error as any).code === "ENOENT") {
33 | return {
34 | defaultModel: "llama3.1:latest",
35 | defaultTemperature: 0.5,
36 | defaultSystemPrompt: "",
37 | defaultOllamaHost,
38 | ollamaHost: defaultOllamaHost,
39 | };
40 | }
41 | throw error;
42 | }
43 | }
44 |
45 | type PartialNullable = { [P in keyof T]?: T[P] | null };
46 |
47 | export function updateGlobalSettings(
48 | newSettings: PartialNullable,
49 | merge?: true | undefined
50 | ): Promise;
51 | export function updateGlobalSettings(
52 | newSettings: GlobalSettings,
53 | merge: false
54 | ): Promise;
55 | export async function updateGlobalSettings(
56 | newSettings: GlobalSettings | PartialNullable,
57 | merge: boolean = true
58 | ): Promise {
59 | const settings = merge
60 | ? { ...(await getGlobalSettings()), ...newSettings }
61 | : newSettings;
62 |
63 | await fsp.mkdir(path.dirname(getGlobalSettingsFilePath()), {
64 | recursive: true,
65 | });
66 | await fsp.writeFile(
67 | getGlobalSettingsFilePath(),
68 | JSON.stringify(settings, null, 2)
69 | );
70 | }
71 |
--------------------------------------------------------------------------------
/app/lib/utils.server.ts:
--------------------------------------------------------------------------------
1 | import type { ChatResponse } from "ollama";
2 |
3 | export type ChatChunk =
4 | | { type: "done" }
5 | | { type: "error"; reason: unknown }
6 | | {
7 | type: "text";
8 | content: string;
9 | next: Promise | null;
10 | };
11 |
12 | export async function streamResponse(
13 | response: AsyncIterable,
14 | onDone: (content: string) => void | Promise,
15 | onError: (reason: unknown) => void
16 | ): Promise {
17 | let content = "";
18 | let firstChunkContent = "";
19 | const iterable = response[Symbol.asyncIterator]();
20 | try {
21 | const firstChunk = await iterable.next();
22 | if (firstChunk.done || firstChunk.value.done) {
23 | await onDone(content);
24 | return { type: "done" };
25 | }
26 | firstChunkContent = firstChunk.value.message.content ?? "";
27 | content += firstChunkContent;
28 | } catch (reason) {
29 | onError(reason);
30 | return { type: "error", reason };
31 | }
32 |
33 | let next = new Deferred();
34 | const firstPromise = next.promise;
35 | (async () => {
36 | try {
37 | do {
38 | const chunk = await iterable.next();
39 | if (chunk.done || chunk.value.done) {
40 | await onDone(content);
41 | next.resolve({ type: "done" });
42 | return;
43 | }
44 | const nextNext = new Deferred();
45 | const chunkContent = chunk.value.message.content ?? "";
46 | content += chunkContent;
47 |
48 | next.resolve({
49 | type: "text",
50 | content: chunkContent,
51 | next: nextNext.promise,
52 | });
53 | next = nextNext;
54 | } while (true);
55 | } catch (reason) {
56 | onError(reason);
57 | next.resolve({ type: "error", reason });
58 | }
59 | })();
60 |
61 | return {
62 | type: "text",
63 | content: firstChunkContent,
64 | next: firstPromise,
65 | };
66 | }
67 |
68 | export class Deferred {
69 | promise: Promise;
70 | resolve!: (value: T) => void;
71 | reject!: (reason?: any) => void;
72 | constructor() {
73 | this.promise = new Promise((resolve, reject) => {
74 | this.resolve = resolve;
75 | this.reject = reject;
76 | });
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/app/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx";
2 | import { useSyncExternalStore } from "react";
3 | import { twMerge } from "tailwind-merge";
4 |
5 | export function cn(...inputs: ClassValue[]) {
6 | return twMerge(clsx(inputs));
7 | }
8 |
9 | export function ellipse(text: string, length: number) {
10 | if (text.length <= length) return text;
11 | return text.slice(0, length - 3) + "...";
12 | }
13 |
--------------------------------------------------------------------------------
/app/root.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Links,
3 | Meta,
4 | Outlet,
5 | Scripts,
6 | ScrollRestoration,
7 | useRouteError,
8 | } from "@remix-run/react";
9 |
10 | import { DashboardLayout } from "@/components/layouts/dashboard";
11 |
12 | import globalStylesHref from "./globals.css?url";
13 |
14 | export function Layout({ children }: { children: React.ReactNode }) {
15 | return (
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | {children}
26 |
27 |
28 |
29 |
30 | );
31 | }
32 |
33 | export default function App() {
34 | return (
35 |
36 |
37 |
38 | );
39 | }
40 |
41 | export function ErrorBoundary() {
42 | const error = useRouteError();
43 | console.error(error);
44 | return (
45 |
46 | An error occurred
47 |
48 | A few things you could try:
49 |
50 |
51 | - Make sure ollama is running
52 | - Refresh the page
53 |
54 |
55 | );
56 | }
57 |
--------------------------------------------------------------------------------
/app/routes/_index.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button";
2 | import {
3 | Card,
4 | CardContent,
5 | CardDescription,
6 | CardHeader,
7 | CardTitle,
8 | } from "@/components/ui/card";
9 | import { getChats } from "@/lib/chat.server";
10 | import type { MetaFunction } from "@remix-run/react";
11 | import { Link, useLoaderData } from "@remix-run/react";
12 | import { PlusCircle } from "lucide-react";
13 |
14 | export const handle = {
15 | breadcrumbs: ["Dashboard"],
16 | };
17 |
18 | export const meta: MetaFunction = () => [{ title: "Dashboard | Local Llama" }];
19 |
20 | export async function loader() {
21 | const chats = await getChats();
22 |
23 | return { chats };
24 | }
25 |
26 | export default function Dashboard() {
27 | const { chats } = useLoaderData();
28 |
29 | return (
30 |
31 |
32 |
33 |
39 |
40 |
41 |
42 |
43 |
44 | Recent chats
45 | {chats.length > 0 && (
46 | Your last {chats.length} chats
47 | )}
48 |
49 |
50 |
51 | {chats.length > 0 ? (
52 | chats.map((chat) => (
53 | -
54 |
58 |
59 |
60 | {chat.name}
61 |
62 |
63 |
64 |
65 | ))
66 | ) : (
67 | No chats yet.
68 | )}
69 |
70 |
71 |
72 |
73 | );
74 | }
75 |
--------------------------------------------------------------------------------
/app/routes/chat.($chatId)._index.tsx:
--------------------------------------------------------------------------------
1 | import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
2 | import type {
3 | MetaFunction,
4 | ShouldRevalidateFunctionArgs,
5 | } from "@remix-run/react";
6 | import {
7 | useFetcher,
8 | useLoaderData,
9 | useLocation,
10 | useNavigate,
11 | useNavigation,
12 | } from "@remix-run/react";
13 | import {
14 | CornerDownLeft,
15 | LoaderCircle,
16 | Settings,
17 | StopCircle,
18 | } from "lucide-react";
19 | import type { Message } from "ollama";
20 | import { Ollama } from "ollama";
21 | import { useEffect, useState } from "react";
22 |
23 | import { ChatMessage } from "@/components/chat";
24 | import { Button } from "@/components/ui/button";
25 | import { Label } from "@/components/ui/label";
26 | import {
27 | Select,
28 | SelectContent,
29 | SelectItem,
30 | SelectTrigger,
31 | SelectValue,
32 | } from "@/components/ui/select";
33 | import { Textarea } from "@/components/ui/textarea";
34 | import {
35 | Tooltip,
36 | TooltipContent,
37 | TooltipProvider,
38 | TooltipTrigger,
39 | } from "@/components/ui/tooltip";
40 | import { useChatChunk } from "@/lib/chat";
41 | import type {
42 | Chat as ChatType,
43 | ChatMessage as ChatMessageType,
44 | } from "@/lib/chat.server";
45 | import {
46 | createChat,
47 | createMessage,
48 | getChat,
49 | updateChat,
50 | } from "@/lib/chat.server";
51 | import { ellipse } from "@/lib/utils";
52 | import { Deferred, streamResponse } from "@/lib/utils.server";
53 | import {
54 | Sheet,
55 | SheetContent,
56 | SheetDescription,
57 | SheetTitle,
58 | SheetTrigger,
59 | } from "@/components/ui/sheet";
60 | import { Slider } from "@/components/ui/slider";
61 | import { Input } from "@/components/ui/input";
62 | import { getGlobalSettings } from "@/lib/settings.server";
63 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
64 |
65 | export const handle = {
66 | breadcrumbs: [
67 | { to: "/", name: "Dashboard" },
68 | "Chat",
69 | ({ chat }: Awaited>) =>
70 | Number.isSafeInteger(chat.id)
71 | ? chat.name
72 | ? ellipse(chat.name, 40)
73 | : null ?? "Untitled"
74 | : "New Chat",
75 | ],
76 | };
77 |
78 | export const meta: MetaFunction = ({ data }) => [
79 | { title: `${data?.chat.name ?? "New Chat"} | Local Llama` },
80 | ];
81 |
82 | export async function loader({ params, request }: LoaderFunctionArgs) {
83 | const chatId = params.chatId ? Number.parseInt(params.chatId) : NaN;
84 | const ollama = new Ollama({
85 | host: process.env.OLLAMA_HOST,
86 | fetch: (input, init) => fetch(input, { ...init, signal: request.signal }),
87 | });
88 |
89 | const modelsPromise = ollama.list().then((r) => r.models.map((m) => m.name));
90 | const settingsPromise = getGlobalSettings();
91 |
92 | const chat: ChatType = (Number.isSafeInteger(chatId)
93 | ? await getChat(chatId)
94 | : null) ?? {
95 | id: NaN,
96 | name: null,
97 | model: null,
98 | prompt: null,
99 | temperature: null,
100 | messages: [],
101 | };
102 |
103 | return {
104 | chat,
105 | models: await modelsPromise,
106 | settings: await settingsPromise,
107 | };
108 | }
109 |
110 | export async function action({ request, params }: ActionFunctionArgs) {
111 | const formData = new URLSearchParams(await request.text());
112 | const message = formData.get("message")?.replace(/\r?\n/g, "").trim();
113 | if (!message) {
114 | return {
115 | error: "Message is required",
116 | };
117 | }
118 | let model = formData.get("model");
119 | if (model && typeof model !== "string") {
120 | return {
121 | error: "Invalid model",
122 | };
123 | }
124 | const settings = await getGlobalSettings();
125 |
126 | model = model || settings.defaultModel;
127 | let systemPrompt = formData.get("systemPrompt");
128 | if (systemPrompt && typeof systemPrompt !== "string") {
129 | return {
130 | error: "Invalid system prompt",
131 | };
132 | }
133 | const temperatureStr = formData.get("temperature");
134 | if (temperatureStr && typeof temperatureStr !== "string") {
135 | return {
136 | error: "Invalid temperature",
137 | };
138 | }
139 | let temperature = temperatureStr ? Number.parseFloat(temperatureStr) : NaN;
140 |
141 | const chatHistory: Message[] = [];
142 | let chatId: number | null = null;
143 | if (params.chatId) {
144 | const lookupId = Number.parseInt(params.chatId);
145 | if (!Number.isSafeInteger(lookupId)) {
146 | return {
147 | error: "Invalid chat ID",
148 | };
149 | }
150 | const chat = await getChat(lookupId);
151 | chatId = chat?.id ?? null;
152 |
153 | if (!systemPrompt && chat?.prompt) {
154 | systemPrompt = chat.prompt;
155 | }
156 |
157 | temperature = Number.isFinite(temperature)
158 | ? temperature
159 | : chat?.temperature ?? settings.defaultTemperature;
160 | for (const message of chat?.messages ?? []) {
161 | chatHistory.push({
162 | role: message.from,
163 | content: message.content,
164 | });
165 | }
166 | }
167 | if (systemPrompt) {
168 | chatHistory.unshift({
169 | role: "system",
170 | content: systemPrompt,
171 | });
172 | }
173 |
174 | const ollama = new Ollama({
175 | host: process.env.OLLAMA_HOST,
176 | fetch: (input, init) => fetch(input, { ...init, signal: request.signal }),
177 | });
178 |
179 | let newName: string | null = null;
180 | if (typeof chatId !== "number") {
181 | const titleResponse = await ollama
182 | .chat({
183 | model,
184 | options: {
185 | temperature: 0.1,
186 | num_predict: 12,
187 | },
188 | messages: [
189 | {
190 | role: "system",
191 | content: [
192 | "You are a summarization model. You accept a message and return a title appropriate for display in a constrained space.",
193 | "Keep the title short and sweet, idealy less than 60 characters.",
194 | "Respond with a title for the message you receive and nothing else.",
195 | ].join("\n"),
196 | },
197 | {
198 | role: "user",
199 | content: "What color is the sky?",
200 | },
201 | {
202 | role: "assistant",
203 | content: "Sky Color",
204 | },
205 | {
206 | role: "user",
207 | content: "Write a fib function in TS",
208 | },
209 | {
210 | role: "assistant",
211 | content: "Fibonacci in TypeScript",
212 | },
213 | {
214 | role: "user",
215 | content: "Write something long",
216 | },
217 | {
218 | role: "assistant",
219 | content: "Random long response",
220 | },
221 | {
222 | role: "user",
223 | content: message,
224 | },
225 | ],
226 | })
227 | .catch((reason) => {
228 | console.error("Failed to get title", reason);
229 | return null;
230 | });
231 | newName =
232 | titleResponse?.message.content?.replace(/\r?\n/g, "").trim() ||
233 | "Untitled";
234 | }
235 |
236 | const readyToSave = new Deferred();
237 | try {
238 | const response = await ollama.chat({
239 | stream: true,
240 | model,
241 | options: {
242 | temperature,
243 | },
244 | messages: [
245 | ...chatHistory,
246 | {
247 | role: "user",
248 | content: message,
249 | },
250 | ],
251 | });
252 |
253 | const assistantResponse = await streamResponse(
254 | response,
255 | async (content) => {
256 | await readyToSave.promise;
257 | if (typeof chatId !== "number") throw new Error("Chat ID is missing");
258 | await createMessage(chatId, { from: "assistant", content });
259 | },
260 | console.error
261 | );
262 | let newChatId: number | null = null;
263 | if (!chatId) {
264 | newChatId = chatId = await createChat({
265 | name: newName,
266 | prompt: systemPrompt,
267 | temperature,
268 | });
269 | } else {
270 | await updateChat(chatId, {
271 | model,
272 | prompt: systemPrompt,
273 | temperature,
274 | });
275 | }
276 |
277 | await createMessage(chatId, { from: "user", content: message });
278 |
279 | readyToSave.resolve();
280 | return { newChatId, assistantResponse };
281 | } catch (reason) {
282 | readyToSave.reject(reason);
283 |
284 | return {
285 | error: "Failed to complete message",
286 | };
287 | }
288 | }
289 |
290 | export function shouldRevalidate({
291 | currentParams,
292 | nextParams,
293 | }: ShouldRevalidateFunctionArgs) {
294 | return currentParams.chatId !== nextParams.chatId;
295 | }
296 |
297 | export default function Chat() {
298 | const { chat, models, settings } = useLoaderData();
299 | const location = useLocation();
300 | const fetcher = useFetcher({ key: location.key });
301 | const { error, newChatId: newId, assistantResponse } = fetcher.data ?? {};
302 | const navigate = useNavigate();
303 | const navigation = useNavigation();
304 |
305 | const [{ messages, renderStream }, setState] = useState<
306 | Omit & {
307 | messages: (Omit & { id: string | number })[];
308 | renderStream: boolean;
309 | }
310 | >({
311 | ...chat,
312 | renderStream: false,
313 | });
314 |
315 | const [temperature, setTemperature] = useState(
316 | chat.temperature ?? settings.defaultTemperature
317 | );
318 | const [model, setModel] = useState(chat.model ?? settings.defaultModel);
319 | const [systemPrompt, setSystemPrompt] = useState(
320 | chat.prompt ?? settings.defaultSystemPrompt
321 | );
322 |
323 | const [assistantResponseText, assistantResponseDone, resetAssistantResponse] =
324 | useChatChunk(assistantResponse, {
325 | onDone() {
326 | resetAssistantResponse();
327 |
328 | setState((state) => ({
329 | ...state,
330 | renderStream: false,
331 | messages: [
332 | ...state.messages,
333 | {
334 | id: `assistant-${state.messages.length}`,
335 | from: "assistant",
336 | content: assistantResponseText ?? "",
337 | },
338 | ],
339 | }));
340 |
341 | if (typeof newId === "number") {
342 | navigate(`/chat/${newId}`, {
343 | preventScrollReset: true,
344 | replace: true,
345 | });
346 | }
347 | },
348 | onError(reason) {
349 | console.error(reason);
350 | resetAssistantResponse();
351 | setState((state) => ({
352 | ...state,
353 | renderStream: false,
354 | }));
355 | },
356 | });
357 |
358 | useEffect(() => {
359 | setTimeout(() => {
360 | window.scrollTo(0, document.body.scrollHeight);
361 | });
362 | setState({
363 | ...chat,
364 | renderStream: false,
365 | });
366 | setTemperature(chat.temperature ?? settings.defaultTemperature);
367 | setModel(chat.model ?? settings.defaultModel);
368 | setSystemPrompt(chat.prompt ?? settings.defaultSystemPrompt);
369 | }, [chat.id, location.key, settings]);
370 |
371 | const isScrolledToBottom =
372 | typeof document !== "undefined" &&
373 | document.body.scrollHeight - window.scrollY <= window.innerHeight + 1;
374 | useEffect(() => {
375 | if (assistantResponseText && isScrolledToBottom) {
376 | setTimeout(() => {
377 | window.scrollTo(0, document.body.scrollHeight);
378 | });
379 | }
380 | }, [assistantResponseText, isScrolledToBottom]);
381 |
382 | const fetcherActive =
383 | fetcher.state !== "idle" ||
384 | (renderStream && !error && !assistantResponseDone);
385 | const formDisabled = navigation.state !== "idle" || fetcherActive;
386 |
387 | return (
388 | <>
389 |
390 |
391 |
396 | {messages.map((message) => (
397 |
398 | {message.content}
399 |
400 | ))}
401 | {renderStream && !error && (
402 |
403 |
407 | {assistantResponseText}
408 |
409 |
410 | )}
411 | {error && (
412 |
413 |
414 | Error
415 |
416 |
417 | {error}
418 |
419 |
420 | )}
421 |
422 |
423 |
424 |
425 | {
430 | if (formDisabled) {
431 | event.preventDefault();
432 | return;
433 | }
434 |
435 | const form = event.currentTarget;
436 | const content = (
437 | form.elements.namedItem("message") as HTMLTextAreaElement
438 | ).value;
439 | setTimeout(() => {
440 | window.scrollTo(0, document.body.scrollHeight);
441 | });
442 | setState((state) => ({
443 | ...state,
444 | renderStream: true,
445 | messages: [
446 | ...state.messages,
447 | {
448 | id: `user-${state.messages.length}`,
449 | from: "user",
450 | content,
451 | },
452 | ],
453 | }));
454 | setTimeout(() => {
455 | form.reset();
456 | });
457 | }}
458 | onKeyDown={(event) => {
459 | if (event.key === "Enter" && event.shiftKey) {
460 | event.currentTarget.requestSubmit();
461 | event.preventDefault();
462 | }
463 | }}
464 | >
465 |
466 |
467 |
468 |
469 |
472 |
479 |
480 | {fetcherActive ? (
481 | //
493 |
494 | ) : (
495 |
504 | )}
505 |
506 |
507 |
508 |
509 |
513 |
514 |
515 | Chat Settings
516 |
517 |
518 |
519 |
520 |
521 |
522 |
526 | Chat settings
527 |
528 | Access all the chat settings.
529 |
530 |
531 |
532 |
533 |
534 |
551 |
552 |
553 |
554 |
555 | {
560 | setTemperature(value[0]);
561 | }}
562 | />
563 | {
573 | setTemperature(Number(event.target.value));
574 | }}
575 | />
576 |
577 |
578 |
579 |
580 |
594 |
595 |
596 |
597 |
611 | >
612 | );
613 | }
614 |
--------------------------------------------------------------------------------
/app/routes/settings._index.tsx:
--------------------------------------------------------------------------------
1 | import { Form, useFetcher, useLoaderData } from "@remix-run/react";
2 | import { Ollama } from "ollama";
3 | import { useEffect, useRef, useState } from "react";
4 |
5 | import { Button } from "@/components/ui/button";
6 | import {
7 | Card,
8 | CardContent,
9 | CardDescription,
10 | CardFooter,
11 | CardHeader,
12 | CardTitle,
13 | } from "@/components/ui/card";
14 | import {
15 | Select,
16 | SelectContent,
17 | SelectItem,
18 | SelectTrigger,
19 | SelectValue,
20 | } from "@/components/ui/select";
21 | import { Textarea } from "@/components/ui/textarea";
22 | import type {
23 | ActionFunctionArgs,
24 | LoaderFunctionArgs,
25 | } from "@remix-run/server-runtime";
26 | import { Input } from "@/components/ui/input";
27 | import { Slider } from "@/components/ui/slider";
28 | import { getGlobalSettings, updateGlobalSettings } from "@/lib/settings.server";
29 |
30 | export const handle = {
31 | breadcrumbs: ["Global Settings", "General"],
32 | };
33 |
34 | export async function loader({ request }: LoaderFunctionArgs) {
35 | const ollama = new Ollama({
36 | host: process.env.OLLAMA_HOST,
37 | fetch: (input, init) => fetch(input, { ...init, signal: request.signal }),
38 | });
39 |
40 | const [models, settings] = await Promise.all([
41 | ollama.list().then((r) => Array.from(new Set(r.models.map((m) => m.name)))),
42 | getGlobalSettings(),
43 | ]);
44 |
45 | return { models, settings };
46 | }
47 |
48 | export async function action({ request }: ActionFunctionArgs) {
49 | const formData = new URLSearchParams(await request.text());
50 | const intent = formData.get("intent");
51 | switch (intent) {
52 | case "defaultModel": {
53 | const defaultModel = formData.get("defaultModel");
54 | await updateGlobalSettings({ defaultModel });
55 | break;
56 | }
57 | case "defaultTemperature": {
58 | const defaultTemperature = Number(formData.get("defaultTemperature"));
59 | if (!Number.isFinite(defaultTemperature)) {
60 | throw new Error("Invalid temperature.");
61 | }
62 | await updateGlobalSettings({ defaultTemperature });
63 | break;
64 | }
65 | case "defaultSystemPrompt": {
66 | const defaultSystemPrompt = formData.get("defaultSystemPrompt");
67 | await updateGlobalSettings({ defaultSystemPrompt });
68 | break;
69 | }
70 | default:
71 | throw new Error(`Invalid intent: ${intent}`);
72 | }
73 | return null;
74 | }
75 |
76 | export default function GeneralSettings() {
77 | const { models, settings } = useLoaderData();
78 | const defaultModelRef = useRef(null);
79 |
80 | const [defaultTemperature, setDefaultTemperature] = useState(
81 | settings.defaultTemperature
82 | );
83 | useEffect(() => {
84 | setDefaultTemperature(settings.defaultTemperature);
85 | }, [settings]);
86 |
87 | const defaultModelFetcher = useFetcher();
88 | const defaultTemperatureFetcher = useFetcher();
89 | const defaultSystemPromptFetcher = useFetcher();
90 |
91 | return (
92 |
93 |
94 |
95 |
96 |
97 | Default Model
98 |
99 | Changes the default model used for inference.
100 |
101 |
102 |
103 |
110 |
130 |
131 |
132 |
138 |
139 |
140 |
141 |
142 |
143 |
144 |
145 |
146 | Default Temperature
147 |
148 | Changes the default temperature used for inference.
149 |
150 |
151 |
152 |
153 | {
158 | setDefaultTemperature(value[0]);
159 | }}
160 | />
161 | {
171 | setDefaultTemperature(Number(event.target.value));
172 | }}
173 | />
174 |
175 |
176 |
177 |
178 |
179 |
180 |
181 |
182 |
183 |
184 |
185 |
186 |
187 | Default System Prompt
188 |
189 |
190 | Changes how the assistant responds.
191 |
192 |
193 |
194 |
200 |
201 |
202 |
203 |
204 |
205 |
206 |
207 | );
208 | }
209 |
--------------------------------------------------------------------------------
/app/routes/settings.ollama.tsx:
--------------------------------------------------------------------------------
1 | import { useFetcher, useLoaderData } from "@remix-run/react";
2 | import type { ActionFunctionArgs } from "@remix-run/node";
3 |
4 | import { Button } from "@/components/ui/button";
5 | import {
6 | Card,
7 | CardContent,
8 | CardDescription,
9 | CardFooter,
10 | CardHeader,
11 | CardTitle,
12 | } from "@/components/ui/card";
13 | import { Input } from "@/components/ui/input";
14 | import { getGlobalSettings, updateGlobalSettings } from "@/lib/settings.server";
15 |
16 | export const handle = {
17 | breadcrumbs: [{ to: "/settings", name: "Global Settings" }, "Ollama"],
18 | };
19 |
20 | export async function loader() {
21 | const settings = await getGlobalSettings();
22 | return { settings };
23 | }
24 |
25 | export async function action({ request }: ActionFunctionArgs) {
26 | const formData = new URLSearchParams(await request.text());
27 | const intent = formData.get("intent");
28 | if (intent === "ollamaHost") {
29 | const ollamaHost = formData.get("ollamaHost");
30 | await updateGlobalSettings({ ollamaHost });
31 | }
32 | return null;
33 | }
34 |
35 | export default function OllamaSettings() {
36 | const { settings } = useLoaderData();
37 |
38 | const ollamaHostFetcher = useFetcher();
39 |
40 | return (
41 |
68 | );
69 | }
70 |
--------------------------------------------------------------------------------
/app/routes/settings.tsx:
--------------------------------------------------------------------------------
1 | import { NavLink, Outlet } from "@remix-run/react";
2 |
3 | export default function Settings() {
4 | return (
5 |
6 |
7 | Global Settings
8 |
9 |
10 |
28 |
29 |
30 |
31 | );
32 | }
33 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "new-york",
4 | "rsc": false,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.js",
8 | "css": "app/globals.css",
9 | "baseColor": "stone",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils"
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/drizzle.config.ts:
--------------------------------------------------------------------------------
1 | import * as os from "node:os";
2 | import * as path from "node:path";
3 | import { pathToFileURL } from "node:url";
4 |
5 | import { defineConfig } from "drizzle-kit";
6 |
7 | const url = pathToFileURL(
8 | path.join(os.homedir(), ".localllama", "db.sqlite")
9 | ).href;
10 |
11 | export default defineConfig({
12 | dialect: "sqlite",
13 | schema: "./app/lib/db.server.ts",
14 | out: "./migrations",
15 | dbCredentials: {
16 | url,
17 | },
18 | });
19 |
--------------------------------------------------------------------------------
/migrations/0000_brainy_dorian_gray.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE `chats` (
2 | `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
3 | `name` text,
4 | `model` text,
5 | `prompt` text,
6 | `temperature` real
7 | );
8 | --> statement-breakpoint
9 | CREATE TABLE `messages` (
10 | `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
11 | `chat_id` integer NOT NULL,
12 | `from` text NOT NULL,
13 | `content` text NOT NULL,
14 | FOREIGN KEY (`chat_id`) REFERENCES `chats`(`id`) ON UPDATE no action ON DELETE no action
15 | );
16 |
--------------------------------------------------------------------------------
/migrations/meta/0000_snapshot.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "6",
3 | "dialect": "sqlite",
4 | "id": "9245fbac-9591-40c8-8c3d-19aafae3b7c7",
5 | "prevId": "00000000-0000-0000-0000-000000000000",
6 | "tables": {
7 | "chats": {
8 | "name": "chats",
9 | "columns": {
10 | "id": {
11 | "name": "id",
12 | "type": "integer",
13 | "primaryKey": true,
14 | "notNull": true,
15 | "autoincrement": true
16 | },
17 | "name": {
18 | "name": "name",
19 | "type": "text",
20 | "primaryKey": false,
21 | "notNull": false,
22 | "autoincrement": false
23 | },
24 | "model": {
25 | "name": "model",
26 | "type": "text",
27 | "primaryKey": false,
28 | "notNull": false,
29 | "autoincrement": false
30 | },
31 | "prompt": {
32 | "name": "prompt",
33 | "type": "text",
34 | "primaryKey": false,
35 | "notNull": false,
36 | "autoincrement": false
37 | },
38 | "temperature": {
39 | "name": "temperature",
40 | "type": "real",
41 | "primaryKey": false,
42 | "notNull": false,
43 | "autoincrement": false
44 | }
45 | },
46 | "indexes": {},
47 | "foreignKeys": {},
48 | "compositePrimaryKeys": {},
49 | "uniqueConstraints": {}
50 | },
51 | "messages": {
52 | "name": "messages",
53 | "columns": {
54 | "id": {
55 | "name": "id",
56 | "type": "integer",
57 | "primaryKey": true,
58 | "notNull": true,
59 | "autoincrement": true
60 | },
61 | "chat_id": {
62 | "name": "chat_id",
63 | "type": "integer",
64 | "primaryKey": false,
65 | "notNull": true,
66 | "autoincrement": false
67 | },
68 | "from": {
69 | "name": "from",
70 | "type": "text",
71 | "primaryKey": false,
72 | "notNull": true,
73 | "autoincrement": false
74 | },
75 | "content": {
76 | "name": "content",
77 | "type": "text",
78 | "primaryKey": false,
79 | "notNull": true,
80 | "autoincrement": false
81 | }
82 | },
83 | "indexes": {},
84 | "foreignKeys": {
85 | "messages_chat_id_chats_id_fk": {
86 | "name": "messages_chat_id_chats_id_fk",
87 | "tableFrom": "messages",
88 | "tableTo": "chats",
89 | "columnsFrom": [
90 | "chat_id"
91 | ],
92 | "columnsTo": [
93 | "id"
94 | ],
95 | "onDelete": "no action",
96 | "onUpdate": "no action"
97 | }
98 | },
99 | "compositePrimaryKeys": {},
100 | "uniqueConstraints": {}
101 | }
102 | },
103 | "enums": {},
104 | "_meta": {
105 | "schemas": {},
106 | "tables": {},
107 | "columns": {}
108 | },
109 | "internal": {
110 | "indexes": {}
111 | }
112 | }
--------------------------------------------------------------------------------
/migrations/meta/_journal.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "7",
3 | "dialect": "sqlite",
4 | "entries": [
5 | {
6 | "idx": 0,
7 | "version": "6",
8 | "when": 1725266584224,
9 | "tag": "0000_brainy_dorian_gray",
10 | "breakpoints": true
11 | }
12 | ]
13 | }
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "localllama",
3 | "version": "0.0.10",
4 | "type": "module",
5 | "license": "ISC",
6 | "readme": "README.md",
7 | "repository": {
8 | "type": "git",
9 | "url": "https://github.com/jacob-ebey/localllama"
10 | },
11 | "bin": {
12 | "localllama": "./startup.cjs"
13 | },
14 | "files": [
15 | "build",
16 | "migrations",
17 | "README.md",
18 | "startup.cjs"
19 | ],
20 | "scripts": {
21 | "build": "remix vite:build",
22 | "dev": "remix vite:dev",
23 | "generate": "NODE_ENV=migration drizzle-kit generate",
24 | "migrate": "drizzle-kit migrate"
25 | },
26 | "pnpm": {
27 | "overrides": {
28 | "@remix-run/dev": "0.0.0-nightly-d9a607635-20240910",
29 | "@remix-run/express": "0.0.0-nightly-d9a607635-20240910",
30 | "@remix-run/node": "0.0.0-nightly-d9a607635-20240910",
31 | "@remix-run/react": "0.0.0-nightly-d9a607635-20240910",
32 | "@remix-run/serve": "0.0.0-nightly-d9a607635-20240910",
33 | "@remix-run/server-runtime": "0.0.0-nightly-d9a607635-20240910",
34 | "react": "19.0.0-rc-e56f4ae3-20240830",
35 | "react-dom": "19.0.0-rc-e56f4ae3-20240830"
36 | }
37 | },
38 | "dependencies": {
39 | "@remix-run/express": "0.0.0-nightly-d9a607635-20240910",
40 | "better-sqlite3": "11.2.1",
41 | "drizzle-orm": "0.33.0",
42 | "express": "^4.19.2",
43 | "ollama": "0.5.8"
44 | },
45 | "devDependencies": {
46 | "@radix-ui/react-dialog": "^1.1.1",
47 | "@radix-ui/react-dropdown-menu": "^2.1.1",
48 | "@radix-ui/react-icons": "^1.3.0",
49 | "@radix-ui/react-label": "^2.1.0",
50 | "@radix-ui/react-progress": "^1.1.0",
51 | "@radix-ui/react-select": "^2.1.1",
52 | "@radix-ui/react-separator": "^1.1.0",
53 | "@radix-ui/react-slider": "^1.2.0",
54 | "@radix-ui/react-slot": "^1.1.0",
55 | "@radix-ui/react-tabs": "^1.1.0",
56 | "@radix-ui/react-tooltip": "^1.1.2",
57 | "@remix-run/dev": "0.0.0-nightly-d9a607635-20240910",
58 | "@remix-run/node": "0.0.0-nightly-d9a607635-20240910",
59 | "@remix-run/react": "0.0.0-nightly-d9a607635-20240910",
60 | "@remix-run/serve": "0.0.0-nightly-d9a607635-20240910",
61 | "@remix-run/server-runtime": "0.0.0-nightly-d9a607635-20240910",
62 | "@tailwindcss/typography": "0.5.15",
63 | "@types/better-sqlite3": "7.6.11",
64 | "@types/node": "22.5.1",
65 | "@types/react": "18.3.4",
66 | "@types/react-dom": "18.3.0",
67 | "autoprefixer": "10.4.20",
68 | "class-variance-authority": "^0.7.0",
69 | "clsx": "^2.1.1",
70 | "drizzle-kit": "0.24.2",
71 | "isbot": "5.1.17",
72 | "lucide-react": "0.436.0",
73 | "postcss": "8.4.41",
74 | "react": "19.0.0-rc-e56f4ae3-20240830",
75 | "react-dom": "19.0.0-rc-e56f4ae3-20240830",
76 | "react-markdown": "9.0.1",
77 | "tailwindcss": "3.4.10",
78 | "tailwind-merge": "^2.5.2",
79 | "tailwindcss-animate": "^1.0.7",
80 | "typescript": "5.5.4",
81 | "vite": "5.4.2",
82 | "vite-tsconfig-paths": "5.0.1"
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/postcss.config.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jacob-ebey/localllama/b3c850e8e179641a7f575f0571400cdd198c6cf4/public/favicon.ico
--------------------------------------------------------------------------------
/startup.cjs:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | const fs = require("node:fs");
4 | const os = require("node:os");
5 | const path = require("node:path");
6 |
7 | const { createRequestHandler } = require("@remix-run/express");
8 | const Database = require("better-sqlite3");
9 | const { drizzle } = require("drizzle-orm/better-sqlite3");
10 | const { migrate } = require("drizzle-orm/better-sqlite3/migrator");
11 | const express = require("express");
12 |
13 | fs.mkdirSync(path.join(os.homedir(), ".localllama"), { recursive: true });
14 |
15 | const sqlite = new Database(
16 | path.join(os.homedir(), ".localllama", "db.sqlite")
17 | );
18 | const db = drizzle(sqlite);
19 |
20 | migrate(db, {
21 | migrationsFolder: path.join(__dirname, "migrations"),
22 | });
23 |
24 | const remixHandler = createRequestHandler({
25 | build: () => import("./build/server/index.js"),
26 | });
27 |
28 | const app = express();
29 | app.disable("x-powered-by");
30 |
31 | app.use(
32 | "/assets",
33 | express.static(path.resolve(__dirname, "build/client/assets"), {
34 | immutable: true,
35 | maxAge: "1y",
36 | })
37 | );
38 | app.use(
39 | express.static(path.resolve(__dirname, "build/client"), { maxAge: "1h" })
40 | );
41 | app.all("*", remixHandler);
42 |
43 | const port = Number.parseInt(process.env.PORT || "3000");
44 | app.listen(port, () =>
45 | console.log(`Local Llama server listening at http://localhost:${port}`)
46 | );
47 |
--------------------------------------------------------------------------------
/tailwind.config.cjs:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | darkMode: "media",
4 | content: [
5 | "./pages/**/*.{ts,tsx}",
6 | "./components/**/*.{ts,tsx}",
7 | "./app/**/*.{ts,tsx}",
8 | "./src/**/*.{ts,tsx}",
9 | ],
10 | prefix: "",
11 | theme: {
12 | container: {
13 | center: true,
14 | padding: "2rem",
15 | screens: {
16 | "2xl": "1400px",
17 | },
18 | },
19 | extend: {
20 | colors: {
21 | border: "hsl(var(--border))",
22 | input: "hsl(var(--input))",
23 | ring: "hsl(var(--ring))",
24 | background: "hsl(var(--background))",
25 | foreground: "hsl(var(--foreground))",
26 | primary: {
27 | DEFAULT: "hsl(var(--primary))",
28 | foreground: "hsl(var(--primary-foreground))",
29 | },
30 | secondary: {
31 | DEFAULT: "hsl(var(--secondary))",
32 | foreground: "hsl(var(--secondary-foreground))",
33 | },
34 | destructive: {
35 | DEFAULT: "hsl(var(--destructive))",
36 | foreground: "hsl(var(--destructive-foreground))",
37 | },
38 | muted: {
39 | DEFAULT: "hsl(var(--muted))",
40 | foreground: "hsl(var(--muted-foreground))",
41 | },
42 | accent: {
43 | DEFAULT: "hsl(var(--accent))",
44 | foreground: "hsl(var(--accent-foreground))",
45 | },
46 | popover: {
47 | DEFAULT: "hsl(var(--popover))",
48 | foreground: "hsl(var(--popover-foreground))",
49 | },
50 | card: {
51 | DEFAULT: "hsl(var(--card))",
52 | foreground: "hsl(var(--card-foreground))",
53 | },
54 | },
55 | borderRadius: {
56 | lg: "var(--radius)",
57 | md: "calc(var(--radius) - 2px)",
58 | sm: "calc(var(--radius) - 4px)",
59 | },
60 | keyframes: {
61 | "accordion-down": {
62 | from: { height: "0" },
63 | to: { height: "var(--radix-accordion-content-height)" },
64 | },
65 | "accordion-up": {
66 | from: { height: "var(--radix-accordion-content-height)" },
67 | to: { height: "0" },
68 | },
69 | },
70 | animation: {
71 | "accordion-down": "accordion-down 0.2s ease-out",
72 | "accordion-up": "accordion-up 0.2s ease-out",
73 | },
74 | typography: () => ({
75 | DEFAULT: {
76 | css: {
77 | color: "hsl(var(--foreground))",
78 | '[class~="lead"]': {
79 | color: "hsl(var(--foreground))",
80 | },
81 | a: {
82 | color: "hsl(var(--primary))",
83 | },
84 | strong: {
85 | color: "hsl(var(--foreground))",
86 | },
87 | "a strong": {
88 | color: "hsl(var(--primary))",
89 | },
90 | "blockquote strong": {
91 | color: "hsl(var(--foreground))",
92 | },
93 | "thead th strong": {
94 | color: "hsl(var(--foreground))",
95 | },
96 | "ol > li::marker": {
97 | color: "hsl(var(--foreground))",
98 | },
99 | "ul > li::marker": {
100 | color: "hsl(var(--foreground))",
101 | },
102 | dt: {
103 | color: "hsl(var(--foreground))",
104 | },
105 | blockquote: {
106 | color: "hsl(var(--foreground))",
107 | },
108 | h1: {
109 | color: "hsl(var(--foreground))",
110 | },
111 | "h1 strong": {
112 | color: "hsl(var(--foreground))",
113 | },
114 | h2: {
115 | color: "hsl(var(--foreground))",
116 | },
117 | "h2 strong": {
118 | color: "hsl(var(--foreground))",
119 | },
120 | h3: {
121 | color: "hsl(var(--foreground))",
122 | },
123 | "h3 strong": {
124 | color: "hsl(var(--foreground))",
125 | },
126 | h4: {
127 | color: "hsl(var(--foreground))",
128 | },
129 | "h4 strong": {
130 | color: "hsl(var(--foreground))",
131 | },
132 | kbd: {
133 | color: "hsl(var(--foreground))",
134 | },
135 | code: {
136 | color: "hsl(var(--foreground))",
137 | },
138 | "a code": {
139 | color: "hsl(var(--primary))",
140 | },
141 | "h1 code": {
142 | color: "hsl(var(--foreground))",
143 | },
144 | "h2 code": {
145 | color: "hsl(var(--foreground))",
146 | },
147 | "h3 code": {
148 | color: "hsl(var(--foreground))",
149 | },
150 | "h4 code": {
151 | color: "hsl(var(--foreground))",
152 | },
153 | "blockquote code": {
154 | color: "hsl(var(--foreground))",
155 | },
156 | "thead th code": {
157 | color: "hsl(var(--foreground))",
158 | },
159 | pre: {
160 | color: "hsl(var(--foreground))",
161 | },
162 | "pre code": {
163 | color: "hsl(var(--foreground))",
164 | },
165 | "thead th": {
166 | color: "hsl(var(--foreground))",
167 | },
168 | figcaption: {
169 | color: "hsl(var(--foreground))",
170 | },
171 | },
172 | },
173 | }),
174 | },
175 | },
176 | plugins: [require("@tailwindcss/typography"), require("tailwindcss-animate")],
177 | };
178 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": ["**/*.ts", "**/*.tsx", "server.js", "vite.config.ts"],
3 | "compilerOptions": {
4 | "lib": ["DOM", "DOM.Iterable", "ES2022"],
5 | "types": [
6 | "@remix-run/node",
7 | "react/canary",
8 | "react-dom/canary",
9 | "node",
10 | "vite/client"
11 | ],
12 | "isolatedModules": true,
13 | "esModuleInterop": true,
14 | "jsx": "react-jsx",
15 | "module": "ESNext",
16 | "moduleResolution": "Bundler",
17 | "resolveJsonModule": true,
18 | "target": "ES2022",
19 | "strict": true,
20 | "allowJs": true,
21 | "skipLibCheck": true,
22 | "forceConsistentCasingInFileNames": true,
23 | "baseUrl": ".",
24 | "paths": {
25 | "@/*": ["./app/*"]
26 | },
27 |
28 | // Vite takes care of building everything, not tsc.
29 | "noEmit": true
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { vitePlugin as remix } from "@remix-run/dev";
2 | import { defineConfig } from "vite";
3 | import tsconfigPaths from "vite-tsconfig-paths";
4 |
5 | declare module "@remix-run/server-runtime" {
6 | interface Future {
7 | unstable_singleFetch: true;
8 | }
9 | }
10 |
11 | export default defineConfig(({ command }) => ({
12 | ssr:
13 | command === "build"
14 | ? {
15 | noExternal: true,
16 | external: ["better-sqlite3", "drizzle-orm", "ollama"],
17 | }
18 | : undefined,
19 | plugins: [
20 | tsconfigPaths(),
21 | remix({
22 | future: {
23 | v3_fetcherPersist: true,
24 | v3_relativeSplatPath: true,
25 | v3_throwAbortReason: true,
26 | unstable_lazyRouteDiscovery: true,
27 | unstable_singleFetch: true,
28 | },
29 | }),
30 | ],
31 | }));
32 |
--------------------------------------------------------------------------------
|