Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Root env for docker-compose: frontend build-time vars (baked into the bundle).
# Backend secrets live in backend/.env (see backend/.env.example).
#
# Defaults below are the stable demo keys printed by `supabase start`. If your
# local `supabase status` shows different values, paste them here.

NEXT_PUBLIC_SUPABASE_URL=http://localhost:54321
NEXT_PUBLIC_API_BASE_URL=http://localhost:3001

# Local Supabase anon key (well-known CLI default):
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0
73 changes: 73 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,79 @@ It has a Next.js frontend, an Express backend, Supabase Auth/Postgres, and Cloud

Website: [mikeoss.com](https://mikeoss.com)

## Docker (local, account-free)

Run the whole stack in containers with **one command** and no external
accounts. `docker-compose.yml` embeds everything: Supabase (Postgres + Auth +
data API + a gateway), RustFS for S3-compatible object storage, and the
frontend/backend. The schema loads itself on first boot.

The only thing you must supply is **at least one model provider key**
(Anthropic, Gemini, or OpenAI — these are external APIs and cannot be
self-hosted).

```bash
# 1. Env files
cp .env.example .env # frontend build vars (local demo keys prefilled)
cp backend/.env.example backend/.env

# 2. In backend/.env set:
# - one of ANTHROPIC_API_KEY / GEMINI_API_KEY / OPENAI_API_KEY
# - DOWNLOAD_SIGNING_SECRET and USER_API_KEYS_ENCRYPTION_SECRET (openssl rand -hex 32)
# SUPABASE_URL / SUPABASE_SECRET_KEY / R2_* are set by docker-compose, leave them.

# 3. Up
docker compose up --build
```

Open `http://localhost:3000` and sign up. Other endpoints: Postgres
`localhost:54322`, Supabase API `localhost:54321`, storage console
`localhost:9001` (`rustfsadmin` / `rustfsadmin`), and the **Mailpit inbox** at
`localhost:8025`.

### What happens when you register

1. You sign up with email + password at `localhost:3000`.
2. The Auth service creates an **unconfirmed** user and emails a confirmation
link. No mail leaves your machine — it is caught by Mailpit.
3. Open the Mailpit inbox at **http://localhost:8025**, open the "Confirm Your
Email" message, and click the link. That confirms the account and redirects
back to the app.
4. Now you can log in.

To skip the email step entirely (instant signup), set
`GOTRUE_MAILER_AUTOCONFIRM: "true"` on the `auth` service in
`docker-compose.yml` and `docker compose up -d --force-recreate auth`.

> Mailpit only catches **auth** emails (signup, password reset). Other app email
> (via Resend) still needs a real `RESEND_API_KEY` and is not routed to Mailpit.

### Local models via Ollama

[Ollama](https://ollama.com) models are detected **dynamically** — whatever you
have installed (`ollama list`) shows up in every model picker under a **Local**
group, with no API key. The backend reaches Ollama on the host at
`http://host.docker.internal:11434/v1` (override with `OLLAMA_BASE_URL`) and
exposes the live list at `GET /models/ollama`.

Just pull a model and it appears after a refresh:

```bash
ollama pull qwen3.6
```

Notes:
- Models that support tool-calling can drive the full assistant; ones that
don't (e.g. `phi3:mini`) still work for plain chat — the backend retries
without tools automatically.
- Quality and speed depend on the local model; large models are noticeably
slower for tabular review (which runs the model across many cells).

The Supabase JWT secret and the anon/`service_role` keys baked into
`docker-compose.yml` / `.env.example` are the well-known Supabase **local demo**
values — convenient for localhost, but regenerate them before exposing this
anywhere.

## Contents

- `frontend/` - Next.js application
Expand Down
5 changes: 5 additions & 0 deletions backend/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
node_modules
dist
.env
.env.*
*.log
17 changes: 17 additions & 0 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# ponytail: single stage. LibreOffice dwarfs everything, multi-stage saves little.
FROM node:22-slim

# libreoffice-convert shells out to soffice for doc conversion (see src/lib/convert.ts)
RUN apt-get update \
&& apt-get install -y --no-install-recommends libreoffice \
&& rm -rf /var/lib/apt/lists/*

WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

ENV NODE_ENV=production
EXPOSE 3001
CMD ["npm", "start"]
2 changes: 2 additions & 0 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { libraryRouter } from "./routes/library";
import { tabularRouter } from "./routes/tabular";
import { workflowsRouter } from "./routes/workflows";
import { userRouter } from "./routes/user";
import { modelsRouter } from "./routes/models";
import { downloadsRouter } from "./routes/downloads";
import { caseLawRouter } from "./routes/caseLaw";

Expand Down Expand Up @@ -147,6 +148,7 @@ app.use((req, res, next) =>
);

app.use("/chat", chatRouter);
app.use("/models", modelsRouter);
app.use("/projects", projectsRouter);
app.use("/projects/:projectId/chat", projectChatRouter);
app.use("/single-documents", documentsRouter);
Expand Down
3 changes: 3 additions & 0 deletions backend/src/lib/llm/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { streamClaude, completeClaudeText } from "./claude";
import { streamGemini, completeGeminiText } from "./gemini";
import { streamOpenAI, completeOpenAIText } from "./openai";
import { streamOllama, completeOllamaText } from "./ollama";
import { providerForModel } from "./models";
import type { StreamChatParams, StreamChatResult, UserApiKeys } from "./types";

Expand All @@ -13,6 +14,7 @@ export async function streamChatWithTools(
const provider = providerForModel(params.model);
if (provider === "claude") return streamClaude(params);
if (provider === "openai") return streamOpenAI(params);
if (provider === "ollama") return streamOllama(params);
return streamGemini(params);
}

Expand All @@ -26,5 +28,6 @@ export async function completeText(params: {
const provider = providerForModel(params.model);
if (provider === "claude") return completeClaudeText(params);
if (provider === "openai") return completeOpenAIText(params);
if (provider === "ollama") return completeOllamaText(params);
return completeGeminiText(params);
}
5 changes: 4 additions & 1 deletion backend/src/lib/llm/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export const GEMINI_MAIN_MODELS = [
"gemini-3-flash-preview",
] as const;
export const OPENAI_MAIN_MODELS = ["gpt-5.5", "gpt-5.4"] as const;
// Ollama models are detected dynamically (see GET /models/ollama). Any id of
// the form "ollama/<tag>" is valid — see providerForModel / resolveModel.

// Mid-tier (used for tabular review) — user picks one in account settings.
export const CLAUDE_MID_MODELS = ["claude-sonnet-4-6"] as const;
Expand Down Expand Up @@ -49,13 +51,14 @@ const ALL_MODELS = new Set<string>([
// ---------------------------------------------------------------------------

export function providerForModel(model: string): Provider {
if (model.startsWith("ollama")) return "ollama";
if (model.startsWith("claude")) return "claude";
if (model.startsWith("gemini")) return "gemini";
if (model.startsWith("gpt-")) return "openai";
throw new Error(`Unknown model id: ${model}`);
}

export function resolveModel(id: string | null | undefined, fallback: string): string {
if (id && ALL_MODELS.has(id)) return id;
if (id && (ALL_MODELS.has(id) || id.startsWith("ollama/"))) return id;
return fallback;
}
205 changes: 205 additions & 0 deletions backend/src/lib/llm/ollama.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
// Ollama provider — talks to Ollama's OpenAI-compatible /v1/chat/completions.
// No API key (local). Base URL + model are overridable per-instance:
// OLLAMA_BASE_URL (default http://localhost:11434/v1)
// OLLAMA_MODEL (default: the tag after "ollama/" in the model id)
import type {
StreamChatParams,
StreamChatResult,
NormalizedToolCall,
LlmMessage,
OpenAIToolSchema,
} from "./types";

function baseUrl(): string {
return (process.env.OLLAMA_BASE_URL?.trim() || "http://localhost:11434/v1").replace(/\/$/, "");
}

// Optional bearer auth — bare Ollama needs none, but OpenAI-compatible servers
// behind a gateway (vLLM --api-key, Open WebUI, LiteLLM) require it.
export function authHeaders(): Record<string, string> {
const key = process.env.OLLAMA_API_KEY?.trim();
return key ? { Authorization: `Bearer ${key}` } : {};
}

function modelName(modelId: string): string {
// The id's tag (e.g. "ollama/qwen3.6:latest" -> "qwen3.6:latest") wins; the
// OLLAMA_MODEL env is only a fallback for a bare "ollama" id.
const tag = modelId.replace(/^ollama\/?/, "");
return tag || process.env.OLLAMA_MODEL?.trim() || "qwen3.6";
}

function throwIfAborted(signal?: AbortSignal) {
if (signal?.aborted) throw new Error("Request aborted");
}

// Chat-completions message shape (superset of LlmMessage with tool roles).
type ChatMessage =
| { role: "system" | "user" | "assistant"; content: string }
| {
role: "assistant";
content: string;
tool_calls: {
id: string;
type: "function";
function: { name: string; arguments: string };
}[];
}
| { role: "tool"; tool_call_id: string; content: string };

function initialMessages(systemPrompt: string, messages: LlmMessage[]): ChatMessage[] {
const out: ChatMessage[] = [];
if (systemPrompt.trim()) out.push({ role: "system", content: systemPrompt });
for (const m of messages) out.push({ role: m.role, content: m.content });
return out;
}

// Accumulates streamed tool-call deltas (id/name arrive once, arguments stream).
type PartialToolCall = { id: string; name: string; arguments: string };

async function postChat(
body: unknown,
signal: AbortSignal | undefined,
): Promise<Response> {
const response = await fetch(`${baseUrl()}/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders() },
body: JSON.stringify(body),
signal,
});
if (!response.ok) {
const text = await response.text().catch(() => "");
throw new Error(
`Ollama request failed (${response.status}): ${text || response.statusText}`,
);
}
return response;
}

export async function streamOllama(
params: StreamChatParams,
): Promise<StreamChatResult> {
const { model, systemPrompt, tools = [], callbacks = {}, runTools } = params;
const maxIter = params.maxIterations ?? 10;
const messages = initialMessages(systemPrompt, params.messages);
let fullText = "";
// Some small local models reject the `tools` param. Drop it and carry on
// (the model just can't call tools) rather than failing the whole chat.
let useTools = tools.length > 0;

for (let iter = 0; iter < maxIter; iter++) {
throwIfAborted(params.abortSignal);
const sendBody = () => ({
model: modelName(model),
messages,
tools: useTools ? tools : undefined,
stream: true,
});
let response: Response;
try {
response = await postChat(sendBody(), params.abortSignal);
} catch (err) {
if (useTools && /does not support tools/i.test(String((err as Error)?.message))) {
useTools = false;
response = await postChat(sendBody(), params.abortSignal);
} else {
throw err;
}
}
if (!response.body) throw new Error("Ollama response had no body");

const reader = response.body.getReader();
const decoder = new TextDecoder();
const partials = new Map<number, PartialToolCall>();
let assistantText = "";
let buffer = "";

while (true) {
throwIfAborted(params.abortSignal);
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });

// SSE: events are newline-delimited "data: {json}" lines.
let nl: number;
while ((nl = buffer.indexOf("\n")) !== -1) {
const line = buffer.slice(0, nl).trim();
buffer = buffer.slice(nl + 1);
if (!line.startsWith("data:")) continue;
const data = line.slice(5).trim();
if (data === "[DONE]") continue;

const delta = JSON.parse(data)?.choices?.[0]?.delta;
if (!delta) continue;

if (typeof delta.content === "string" && delta.content) {
assistantText += delta.content;
fullText += delta.content;
callbacks.onContentDelta?.(delta.content);
}
for (const tc of delta.tool_calls ?? []) {
const idx = tc.index ?? 0;
const acc = partials.get(idx) ?? { id: "", name: "", arguments: "" };
if (tc.id) acc.id = tc.id;
if (tc.function?.name) acc.name = tc.function.name;
if (tc.function?.arguments) acc.arguments += tc.function.arguments;
partials.set(idx, acc);
}
}
}

const toolCalls: NormalizedToolCall[] = [...partials.values()].map((p) => {
let input: Record<string, unknown> = {};
try {
input = p.arguments ? JSON.parse(p.arguments) : {};
} catch {
input = {};
}
return { id: p.id || p.name, name: p.name, input };
});

if (!toolCalls.length || !runTools) break;

// Echo the assistant turn (with tool_calls) then feed tool results back.
messages.push({
role: "assistant",
content: assistantText,
tool_calls: toolCalls.map((c) => ({
id: c.id,
type: "function",
function: { name: c.name, arguments: JSON.stringify(c.input) },
})),
});
for (const call of toolCalls) callbacks.onToolCallStart?.(call);

throwIfAborted(params.abortSignal);
const results = await runTools(toolCalls);
for (const r of results) {
messages.push({ role: "tool", tool_call_id: r.tool_use_id, content: r.content });
}
}

return { fullText };
}

export async function completeOllamaText(params: {
model: string;
systemPrompt?: string;
user: string;
maxTokens?: number;
}): Promise<string> {
const response = await postChat(
{
model: modelName(params.model),
messages: initialMessages(params.systemPrompt ?? "", [
{ role: "user", content: params.user },
]),
max_tokens: params.maxTokens ?? 512,
stream: false,
},
undefined,
);
const json = (await response.json()) as {
choices?: { message?: { content?: string } }[];
};
return json.choices?.[0]?.message?.content ?? "";
}
2 changes: 1 addition & 1 deletion backend/src/lib/llm/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// Callers always speak OpenAI-style tools + { role, content } messages; each
// provider translates internally.

export type Provider = "claude" | "gemini" | "openai";
export type Provider = "claude" | "gemini" | "openai" | "ollama";

export type OpenAIToolSchema = {
type: "function";
Expand Down
Loading
Loading