Skip to content
Merged
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
22 changes: 22 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ LOCAL_STUDIO_INFERENCE_PORT=8000
# Enable mock inference mode (no external LLM required). Useful for local UI/E2E testing.
# LOCAL_STUDIO_MOCK_INFERENCE=true

# Optional OpenAI-compatible base URL for the remote first-run starter preset.
# When unset, only the local download presets are offered.
# LOCAL_STUDIO_REMOTE_PRESET_URL=http://your-endpoint.example:8080/v1

# =============================================================================
# Paths
# =============================================================================
Expand All @@ -60,3 +64,21 @@ LOCAL_STUDIO_DATA_DIR=./data
# Strict OpenAI model matching for /v1/chat/completions.
# If true, only configured recipes are routable through the controller.
# LOCAL_STUDIO_STRICT_OPENAI_MODELS=false

# =============================================================================
# Sidebar network links (frontend, build-time)
# =============================================================================

# External links to sibling services on your network, rendered in the sidebar
# footer and the mobile nav drawer (frontend/src/features/shell/network-links.tsx).
# Both are inlined at `next build` time, so set them in frontend/.env.local
# (gitignored) on the machine that builds the frontend — deploy-remote.sh ships
# that file to the remote when it exists. When neither is set, no network links
# render. Only http:// and https:// hrefs are accepted; anything else, and any
# malformed JSON, is dropped silently rather than breaking the sidebar.
#
# NEXT_PUBLIC_SIBLING_LINKS is a JSON array of {"label","href"} objects, on one
# line. The examples below are deliberately unroutable placeholders — never
# commit your own hostnames to this public repository.
# NEXT_PUBLIC_PORTAL_URL=https://example.invalid/portal.html
# NEXT_PUBLIC_SIBLING_LINKS=[{"label":"Dashboard","href":"https://example.invalid/dashboard/"},{"label":"Metrics","href":"https://example.invalid/metrics/"}]
14 changes: 11 additions & 3 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
name: Security

on:
# docs/workflow.md lists TruffleHog, CodeQL and Dependency Review as required
# gates into `dev` as well as `main`, but this workflow only listened for
# `main` — so every PR into the default branch skipped them entirely.
pull_request:
branches: [main]
branches: [main, dev]
push:
branches: [main]
branches: [main, dev]
schedule:
- cron: "0 0 * * 0"

Expand Down Expand Up @@ -52,7 +55,12 @@ jobs:
dependency-review:
name: Dependency Review
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
# GitHub keeps the dependency graph switched off on forked repositories,
# and dependency-review-action then hard-fails with "Dependency review is
# not supported on this repository" on every single PR — a permanently red
# required check that no diff can fix. Skip it on forks; to turn it back
# on, enable the dependency graph under Settings → Code security.
if: github.event_name == 'pull_request' && !github.event.repository.fork
steps:
- uses: actions/checkout@v4

Expand Down
73 changes: 73 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,14 @@ Start the frontend in a second terminal, then open
cd frontend && npm ci && npm run dev
```

The agent runtime bundle has its own lockfile and dependency tree — without
this step `npm run build` fails at the bundle step with "Missing browser
runtime package: playwright-core":

```bash
cd services/agent-runtime && bun install
```

`npm ci` runs a postinstall patch against `@earendil-works/pi-ai`. If that step
prints a warning, agent streaming may misrender. The setup wizard walks through
choosing a models directory, installing an engine, downloading a model,
Expand Down Expand Up @@ -182,6 +190,71 @@ service can start the compiled app after login and restart it after a crash, but
it is intentionally not installed automatically. The host must still be on,
awake, online, and connected to Tailscale.

### Path-prefix deployments

To serve the frontend behind a reverse-proxy path prefix (e.g. `/studio`),
build with `NEXT_PUBLIC_BASE_PATH=/studio`. Next.js applies it to routing,
`<Link>`, and `_next` assets via `basePath`, and the hand-written references
(manifest/icon links, `sw.js` registration, the `/api/proxy` client base) read
the same variable. When it is unset, the default root-mount deployment (the
Tailscale Serve pattern above) is unchanged. The PWA manifest
(`frontend/public/manifest.json`) is a static file whose `start_url`, `scope`,
and icon paths cannot be made prefix-safe without a build step, so
service-worker registration is automatically disabled when
`NEXT_PUBLIC_BASE_PATH` is set, even if `LOCAL_STUDIO_ENABLE_SERVICE_WORKER`
is true. Hand-written root-absolute `fetch("/api/...")` calls are rewritten
onto the prefix by the boot-script fetch wrapper in `src/app/layout.tsx`;
non-fetch references (anchors, `src/lib/api/client.ts`) read the variable
directly.

### Sidebar network links

The sidebar footer and the mobile navigation drawer can link out to sibling
services on your network. Two build-time variables drive it, and when neither
is set nothing renders — there is no fallback URL in the source.

| Variable | Value |
|---|---|
| `NEXT_PUBLIC_PORTAL_URL` | A single absolute URL. Rendered first, labelled "Portal". |
| `NEXT_PUBLIC_SIBLING_LINKS` | A **JSON array** of `{"label": string, "href": string}` objects, as one literal string. |

`NEXT_PUBLIC_SIBLING_LINKS` is parsed with `JSON.parse`. The literal string
must be a JSON array — not comma-separated pairs, not an object — for example:

```
[{"label":"Hub","href":"https://example.invalid/hub/"},{"label":"Metrics","href":"https://example.invalid/metrics/"}]
```

Only `label` and `href` are read; any other keys are ignored. An entry is
dropped unless `label` is a non-empty string and `href` is an absolute
`http://` or `https://` URL — `javascript:`, `data:` and relative paths are
rejected. If the variable is absent, blank, not valid JSON, or not an array,
the whole list degrades to empty and the app renders normally with no sibling
links (see `frontend/src/features/shell/network-links.test.ts`).

Both variables are inlined by `next build`, so they must be present in the
build environment, not the runtime one. Either export them for the build:

```bash
cd frontend
NEXT_PUBLIC_SIBLING_LINKS='[{"label":"Hub","href":"https://example.invalid/hub/"}]' \
NEXT_PUBLIC_PORTAL_URL='https://example.invalid/portal.html' \
npm run build
```

…or, preferably, put them in `frontend/.env.local` (gitignored, documented in
`.env.example`) on the machine that builds the frontend:

```
NEXT_PUBLIC_SIBLING_LINKS=[{"label":"Hub","href":"https://example.invalid/hub/"}]
```

Note the shell quoting difference: in a shell command the JSON must be wrapped
in single quotes so the double quotes survive; in `.env.local` it is written
unquoted on one line. Keep private hostnames in these deployment files — never
commit them to this public repository. `scripts/deploy-remote.sh` ships
`frontend/.env.local` to the remote so the remote build inlines them.

## Remote / LAN deployment

The controller binds `127.0.0.1` by default. Binding a non-loopback host (e.g.
Expand Down
8 changes: 7 additions & 1 deletion controller/src/http/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,13 @@ export const createApp = (
},
}),
),
app.get("/api/docs", swaggerUI({ url: "/api/spec" })),
// Relative on purpose: resolves to /api/spec when the docs are opened
// directly, and to /api/proxy/api/spec if someone opens them through the
// frontend proxy. Note the swagger UI pulls its assets from
// cdn.jsdelivr.net, so it only renders on hosts with internet access —
// the frontend (features/logs/server-view.tsx) links to the raw spec and
// browses it in-app instead of linking here.
app.get("/api/docs", swaggerUI({ url: "spec" })),
);

documentedRoutes.notFound((ctx) => ctx.json({ detail: "Not Found" }, { status: 404 }));
Expand Down
39 changes: 24 additions & 15 deletions controller/src/modules/studio/configs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,13 @@ import type { StudioStarterPreset } from "./types";
/**
* First-run presets shown when a controller has no recipes yet. Three lanes:
* a serious local model, a small fast local model, and a remote endpoint —
* so every machine (and no machine at all) has a working first chat.
* so every machine (and no machine at all) has a working first chat. The
* remote lane only appears when LOCAL_STUDIO_REMOTE_PRESET_URL is set: a
* hardcoded endpoint would be dead on every other install (and must never
* leak a private hostname into this public repo).
*/
const remotePresetBaseUrl = process.env["LOCAL_STUDIO_REMOTE_PRESET_URL"];

export const STUDIO_STARTER_PRESETS: StudioStarterPreset[] = [
{
id: "qwen3-6-35b",
Expand Down Expand Up @@ -44,18 +49,22 @@ export const STUDIO_STARTER_PRESETS: StudioStarterPreset[] = [
max_model_len: 32768,
},
},
{
id: "deepseek-v4-flash",
name: "DeepSeek V4 Flash",
description:
"Connect a hosted endpoint with one API key — full-strength chat with nothing to download.",
kind: "remote",
tags: ["remote", "instant"],
size_gb: null,
min_vram_gb: null,
remote: {
base_url: "http://pop-os-1.tailadb2c1.ts.net:8080/v1",
model: "deepseek-v4-flash",
},
},
...(remotePresetBaseUrl
? [
{
id: "deepseek-v4-flash",
name: "DeepSeek V4 Flash",
description:
"Connect a hosted endpoint with one API key — full-strength chat with nothing to download.",
kind: "remote" as const,
tags: ["remote", "instant"],
size_gb: null,
min_vram_gb: null,
remote: {
base_url: remotePresetBaseUrl,
model: "deepseek-v4-flash",
},
},
]
: []),
];
19 changes: 10 additions & 9 deletions frontend/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@ import type { NextConfig } from "next";
import path from "path";

const nextConfig: NextConfig = {
// Optional reverse-proxy path prefix (e.g. "/studio"). When set, Next
// prefixes routing, <Link>, and _next assets itself; hand-written absolute
// references (layout.tsx, src/lib/api/client.ts) read the same var. Unset
// (the default root-mount / tailscale-serve deployment) leaves behavior
// unchanged.
basePath: process.env.NEXT_PUBLIC_BASE_PATH || undefined,
// Workaround for Next.js 16 bug: when unset, config.generateBuildId becomes
// undefined, but generateBuildId() calls it as a function without a guard.
generateBuildId: () => Date.now().toString(36) + Math.random().toString(36).slice(2, 8),
Expand Down Expand Up @@ -120,19 +126,14 @@ const nextConfig: NextConfig = {
return [
{
source: "/models",
destination: "/configure#models",
// ConfigurePage selects its section from the ?section query param (the
// hash alone falls back to "overview"), so both must be present — same
// as the legacy /recipes and /discover redirect stubs.
destination: "/configure?section=models#models",
permanent: true,
},
];
},
async rewrites() {
return [
{
source: "/api/chat-v2",
destination: "/api/chat",
},
];
},
async headers() {
// Baseline security headers. The CSP is intentionally permissive on inline
// scripts/styles (Next's hydration + theme bootstrap script, Tailwind, xterm,
Expand Down
12 changes: 6 additions & 6 deletions frontend/public/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,15 +41,15 @@
{
"name": "Chat",
"short_name": "Chat",
"description": "Open the chat interface",
"url": "/chat",
"description": "Open the agent workbench",
"url": "/agent",
"icons": [{ "src": "/icons/icon-192.png", "sizes": "192x192" }]
},
{
"name": "Recipes",
"short_name": "Recipes",
"description": "Manage model recipes",
"url": "/recipes",
"name": "Models",
"short_name": "Models",
"description": "Manage models and recipes",
"url": "/configure?section=models",
"icons": [{ "src": "/icons/icon-192.png", "sizes": "192x192" }]
}
]
Expand Down
47 changes: 35 additions & 12 deletions frontend/public/sw.js
Original file line number Diff line number Diff line change
@@ -1,18 +1,29 @@
const CACHE_NAME = 'local-studio-v9';
const CACHE_NAME = 'local-studio-v12';
// Precache real routes only. /recipes is a 308 redirect stub to /configure —
// precaching a redirected response breaks offline navigation replay in
// Chromium (redirect-mode mismatch), so the destination is listed instead.
const STATIC_ASSETS = [
'/',
'/chat',
'/recipes',
'/agent',
'/configure',
'/logs',
'/manifest.json',
];

// Install event - cache static assets
// Install event - cache static assets.
// Deliberately not cache.addAll(): that rejects the whole install if any one
// route is unavailable (a controller still booting, a route removed in a later
// build), which leaves the app with no service worker at all. Each asset is
// added independently and failures are tolerated.
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(STATIC_ASSETS);
})
caches.open(CACHE_NAME).then((cache) =>
Promise.all(
STATIC_ASSETS.map((asset) =>
cache.add(new Request(asset, { cache: 'reload' })).catch(() => {})
)
)
)
);
self.skipWaiting();
});
Expand Down Expand Up @@ -43,18 +54,30 @@ self.addEventListener('fetch', (event) => {
event.respondWith(
fetch(event.request)
.then((response) => {
// Clone and cache successful responses
if (response.status === 200) {
// Only same-origin, non-redirected 200s are worth storing. Caching a
// redirect response and replaying it for a navigation trips Chromium's
// redirect-mode check and surfaces as a broken page rather than a
// cached one; opaque cross-origin responses are useless here.
if (response.status === 200 && response.type === 'basic' && !response.redirected) {
const responseClone = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseClone);
});
}
return response;
})
.catch(() => {
// Fall back to cache
return caches.match(event.request);
.catch(async () => {
const cached = await caches.match(event.request);
if (cached) return cached;
// respondWith(undefined) surfaces as a network error, so a navigation
// to a page that was never cached would look like a broken app rather
// than an offline one. Fall back to the cached app shell, which can
// client-route onward, and to an explicit 503 only as a last resort.
if (event.request.mode === 'navigate') {
const shell = await caches.match('/');
if (shell) return shell;
}
return new Response('', { status: 503, statusText: 'Offline' });
})
);
});
6 changes: 5 additions & 1 deletion frontend/src/app/api/huggingface/avatar/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,11 @@ export async function GET(request: NextRequest) {
}

const avatarUrl = await resolveAvatarUrl(owner);
if (!avatarUrl) return NextResponse.json({ error: "Avatar not found." }, { status: 404 });
// 204, not 404: "no avatar" is the normal case on offline/tailnet-only hosts
// where huggingface.co is unreachable. A non-2xx here paints a red console
// error per model owner on every models-page render; an empty 204 still
// fails <img> decoding, so ModelLogo's onError letter-badge fallback fires.
if (!avatarUrl) return new NextResponse(null, { status: 204 });

// Proxy the image bytes rather than 307-redirecting. In the Electron desktop
// context, a cross-origin redirect to cdn-avatars.huggingface.co can be
Expand Down
Loading
Loading