leanOS: initial import (basiert auf NEON NOODLE OS)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Thomas Lutz Kolter 2026-09-15 14:58:54 +02:00
commit ee203fd9fb
51 changed files with 8505 additions and 0 deletions

View File

@ -0,0 +1 @@
{"sessionId":"e450aa9e-5601-46b2-8f3f-7760d5516dd2","pid":11268,"procStart":"Tue Jun 23 16:08:36 2026","acquiredAt":1782301061628}

13
.env.example Normal file
View File

@ -0,0 +1,13 @@
# Anthropic API key for the in-OS AI (app generation, assistant, self-healing).
# Copy this file to ".env" and paste your key. NEVER commit .env.
# Get one at https://console.anthropic.com/
VITE_ANTHROPIC_API_KEY=
# Optional: override the model (default: claude-sonnet-4-6)
VITE_ANTHROPIC_MODEL=claude-sonnet-4-6
# Optional: API base URL (override for a proxy/gateway or an Anthropic-compatible
# provider like z.ai). Either a base URL or a full ".../v1/messages" endpoint works
# — "/v1/messages" is appended automatically when missing.
# Examples:
# https://api.anthropic.com
# https://api.z.ai/api/anthropic
VITE_ANTHROPIC_API_URL=https://api.anthropic.com

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
node_modules
dist
.env
.env.local
*.log
.DS_Store

86
CLAUDE.md Normal file
View File

@ -0,0 +1,86 @@
# CLAUDE.md
## Project Context
<!-- Passe diesen Abschnitt pro Projekt an -->
- **Prompt for this project**:
- /PROMPT.md
- **Key Paths**:
- Task tracking: `tasks/todo.md`
- Lessons learned: `tasks/lessons.md`
---
## Core Principles
- **Simplicity First**: Make every change as simple as possible. Impact minimal code.
- **No Laziness**: ALWAYS find root causes. No temporary fixes. Senior developer standards.
- **Minimal Impact**: Changes MUST only touch what's necessary. Never introduce new bugs.
---
## Workflow Orchestration
### 1. Plan Mode Default
- ALWAYS enter plan mode for ANY non-trivial task (3+ steps or architectural decisions).
- If something goes sideways, STOP immediately and re-plan do NOT keep pushing.
- Use plan mode for verification steps, not just building.
- Write detailed specs upfront to reduce ambiguity.
### 2. Subagent Strategy
- ALWAYS spawn subagents for research, exploration, and parallel analysis.
- Keep the main context window clean offload aggressively.
- For complex problems, throw more compute at it via subagents.
- One task per subagent for focused execution.
### 3. Self-Improvement Loop
- After ANY correction from the user: IMMEDIATELY update `tasks/lessons.md` with the pattern.
- Write rules for yourself that prevent the same mistake from recurring.
- Ruthlessly iterate on these lessons until the mistake rate drops to zero.
- Review `tasks/lessons.md` at session start for the relevant project.
### 4. Verification Before Done
- NEVER mark a task complete without proving it works.
- Diff behavior between main and your changes when relevant.
- Ask yourself: "Would a staff engineer approve this?"
- Run tests, check logs, demonstrate correctness no exceptions.
- Write the tests before you've written a single line of code.
### 5. Demand Elegance (Balanced)
- For non-trivial changes: pause and ask "Is there a more elegant way?"
- If a fix feels hacky: "Knowing everything I know now, implement the elegant solution."
- Skip this for simple, obvious fixes do NOT over-engineer.
- Challenge your own work before presenting it.
### 6. Autonomous Bug Fixing
- When given a bug report: just fix it. Do NOT ask for hand-holding.
- Point at logs, errors, failing tests then resolve them.
- Zero context switching required from the user.
- Go fix failing CI tests without being told how.
---
## Task Management
1. **Plan First**: Write plan to `tasks/todo.md` with checkable items (`- [ ]`).
2. **Verify Plan**: Check in with the user before starting implementation.
3. **Track Progress**: Mark items complete (`- [x]`) as you go.
4. **Explain Changes**: Provide a high-level summary at each step.
5. **Document Results**: Add a review section to `tasks/todo.md` when done.
6. **Capture Lessons**: Update `tasks/lessons.md` after ANY correction.
---
## Communication Style
- Be direct and concise. No filler, no fluff.
- Summarize changes at a high level don't narrate every line.
- If blocked or uncertain, say so immediately instead of guessing.

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 ZeroPerson.ai
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

89
PROMPT.md Normal file
View File

@ -0,0 +1,89 @@
# PROJECT: "OS" An AI-native web operating system
Build a single-page web application that feels and behaves like a real desktop
operating system (inspirations: macOS, Windows 11, Ubuntu/GNOME). It is NOT a mockup
windows, apps, and state actually work. At its heart is an AI that generates missing
applications on demand and integrates them seamlessly into the system.
## TECH STACK
- React + TypeScript + Vite
- Tailwind CSS for the design system
- Zustand (or Context) for global state (windows, processes, filesystem)
- Framer Motion for window animations
- Persistence via IndexedDB (virtual filesystem + installed apps survive reload)
- Anthropic Messages API for the AI (model: claude-sonnet-4-6), streaming where possible
## CORE EXPERIENCE (the most important part)
1. On startup: boot sequence (short, stylish, with logo + progress), then login/lockscreen,
then the desktop.
2. Desktop with wallpaper, icons, menu bar at the top (or taskbar at the bottom), clock, status icons.
3. A "dock"/"start menu" with the available apps.
4. A central AI ("the assistant" / Spotlight-style command bar, via Cmd+Space):
The user types e.g. "I need a text editor" or "build me a Pomodoro app".
- If the app already exists → open it.
- If it does NOT exist → the AI generates a complete, working app component,
"installs" it (icon appears in the dock + filesystem), and opens it immediately.
## THE APP-GENERATING AI (the centerpiece build it carefully)
- The AI receives a system prompt containing:
a) the design system (design tokens, allowed components, color/spacing rules),
b) an app-manifest specification (name, icon, default window size, category),
c) the instruction to return a self-contained, runnable React component as a string.
- Response format: strict JSON (no markdown, no backticks):
{ "name", "icon", "category", "defaultSize", "code" }
- The returned component code is executed safely at runtime
(e.g. via Babel-standalone transform + a controlled scope with provided
libraries/hooks). Apps may access ONLY the allowed system APIs
(see System SDK below) no direct DOM/network access outside the SDK.
- Generated apps are persisted in IndexedDB and loaded automatically on the next start.
- Error handling: if generated code crashes, an error boundary catches it and
offers to let the AI repair the code automatically ("self-healing").
## SYSTEM SDK (what generated apps are allowed to use)
Provide a clean API that is injected into every generated app:
- os.fs → virtual filesystem (read/write/list/delete) via IndexedDB
- os.window → set window title, close, resize
- os.ai → apps can call the AI themselves (e.g. a notes app asks for a summary)
- os.notify → system notifications
- os.storage → key-value persistence per app
- os.theme → read the current design tokens (so apps adapt to the theme)
## WINDOW MANAGER (must feel real)
- Movable, resizable windows with a title bar (minimize/maximize/close).
- Z-index stacking: the active window comes to the front.
- Snap-to-edge (dock windows to the screen edge → half/full screen).
- Minimize into the dock with animation, restore animation.
- Multiple instances, multiple apps at the same time.
- Optional: Mission-Control / overview view (all windows tiled).
## DESIGN SYSTEM (consistency is mandatory)
- Define central design tokens (colors, radii, shadows, blur/glass, typography, spacing).
- ALL apps including generated ones look like they came from one mold: same buttons,
inputs, window chrome, iconography.
- Aesthetic suggestion (swappable): warm amber "NEON NOODLE OS" dark background,
glassy panels with a subtle glow, monospace accents, smooth animations.
(If you can think of something better, propose your own coherent design language.)
- Frosted-glass effects (backdrop-blur), light/dark-mode toggle in the system settings.
## PRE-INSTALLED APPS (as a design reference + baseline kit)
- Files (file manager for the virtual FS)
- Terminal (with a few real commands: ls, cat, mkdir, "ai <prompt>")
- Settings (theme, wallpaper, light/dark, "manage installed apps")
- The AI assistant (chat window + Spotlight command bar)
- "App Builder" explicitly shows how the AI generates apps (for the demo/wow effect)
## NICE-TO-HAVE (if there's time/room)
- App-store view with AI-suggested app ideas.
- Right-click context menus on the desktop and in apps.
- Sound design (boot chime, clicks) subtle, can be turned off.
- Apps can send each other data (inter-process via an os bus).
## IMPLEMENTATION REQUIREMENTS
- Cleanly modularized: kernel/ (state, fs, window-manager, ai), system-ui/, apps/, design/.
- The API key is NOT hard-coded (environment variable or handled by the host).
- Write it so I can start it locally with `npm run dev`.
- Begin with a brief architecture overview, then build iteratively:
first boot + desktop + window manager, then a static app, then the AI generation.
Goal: on first launch I say "build me a calculator" and 5 seconds later a fully
functional calculator that fits the OS perfectly is sitting on the desktop.

110
README.md Normal file
View File

@ -0,0 +1,110 @@
# NEON NOODLE OS — an AI-native web desktop
> **What happens to the PC when software stops being something you *install* — and
> becomes something you *describe*?**
For 40 years the desktop has been a fixed set of apps someone else shipped. NEON NOODLE
OS is a research prototype exploring the next step: an operating system where the
interface **generates itself on demand**, around what you need in the moment. The unit
of computing shifts from the *app* to the *intent*.
It's a single-page web app that behaves like a real desktop OS — windows, apps, and
state are genuinely functional, not a mockup. The heart of the system is an AI that
**generates missing applications on demand** and installs them live.
> Open the assistant (`⌘/Ctrl + Space`), type *"build me a calculator"*, and a few
> seconds later a fully working, on-brand calculator is sitting on your desktop. If it
> crashes, it repairs itself.
Built by **[ZeroPerson](https://github.com/ZeroPersonAI)**, where we research AI use
cases. This one probes a simple question: when generating software becomes nearly free
and instant, what is an "operating system" even *for*?
## Quick start
```bash
npm install
npm run dev # → http://localhost:5173
```
The OS boots and runs **without** any key — you get the full desktop, window manager,
filesystem, terminal, and built-in apps. The AI features (app generation, the
assistant chat, terminal `ai`, self-healing) light up once you add a key.
**Add a key in Settings → Language Model.** Pick a provider and paste a key — keys are
stored locally in your browser (IndexedDB) and never hard-coded. Supported providers:
| Provider | API shape |
|----------|-----------|
| Anthropic (Claude) | Messages API |
| OpenAI · OpenRouter · Grok (xAI) · Groq · Cerebras | OpenAI-compatible |
Prefer env vars? Copy `.env.example` to `.env` and set `VITE_ANTHROPIC_API_KEY` — it
seeds the Anthropic provider on boot, and Settings still overrides it.
## What it does
- **Boot → Lockscreen → Desktop** with a coherent "NEON NOODLE OS" amber/dark glass aesthetic.
- **Real window manager**: drag, resize from any edge/corner, snap-to-edge (left/right/top),
maximize, minimize-to-dock, z-stacking, multiple windows.
- **Spotlight** (`⌘/Ctrl + Space`): search installed apps or describe a new one to build.
- **AI app generation**: returns strict JSON `{name, icon, category, defaultSize, code}`,
the code is Babel-transformed and run in a **sandboxed scope** (only React + the OS SDK
are reachable — `fetch`, `localStorage`, `eval`, etc. are blocked).
- **Self-healing**: if a generated app crashes, an error boundary offers to let the AI
read the error and rewrite the code.
- **Persistence**: the virtual filesystem and installed apps live in IndexedDB and
survive a reload.
## Built-in apps
| App | What it is |
|-----|------------|
| **Assistant** | Chat with the OS AI; turn any request into an app. |
| **App Builder** | Watch the generation pipeline; one-click app ideas. |
| **Files** | Browse & edit the virtual filesystem. |
| **Terminal** | `ls`, `cd`, `cat`, `mkdir`, `touch`, `rm`, `echo > file`, `open`, and `ai <prompt>`. |
| **Settings** | Theme (light/dark), wallpaper, manage installed apps. |
## The System SDK (what generated apps may use)
Every app — built-in or generated — receives an injected `os` object and may **only**
touch the outside world through it:
- `os.fs` — virtual filesystem (`read/write/list/delete/mkdir/exists`)
- `os.window``setTitle / close / setSize / minimize`
- `os.ai``ask(prompt)`, `stream(prompt, onToken)`, `available`
- `os.notify(title, body)` — system notifications
- `os.storage` — per-app key/value persistence
- `os.theme` — current design tokens (apps style themselves on-brand)
- `os.bus` — inter-app message bus
## Architecture
```
src/
kernel/ state (zustand), fs (IndexedDB), ai + providers (multi-provider LLM), sdk, appRuntime (Babel+sandbox), registry
design/ design tokens — the single source of visual truth
system-ui/ Boot, Lockscreen, Desktop, MenuBar, Dock, Window(Manager), Spotlight, Notifications
apps/ Files, Terminal, Settings, Assistant, AppBuilder
```
Build it iteratively in this order if you want to read along: boot + desktop +
window manager → a static app → the AI generation pipeline.
## Notes
- In dev, LLM requests route through a same-origin Vite proxy so every provider works
without CORS headaches. A production build calls the provider directly — for a real
deployment, proxy the API through a backend so keys never reach the client.
- The generated-app sandbox (Babel transform + restricted `new Function` scope) is
**defense-in-depth, not a true security boundary** — generated code shares the page
realm. Real isolation would use an iframe/worker. Fine for a local research prototype.
- The bundle is large because `@babel/standalone` (the in-browser JSX compiler) ships
with it. That's the cost of compiling AI-authored React in the browser.
## About ZeroPerson
We research AI use cases — prototypes that probe where AI changes the shape of software,
not just its features. NEON NOODLE OS is one such probe into the future of the desktop.
MIT-licensed; explore, fork, and build on it.

157
REVIEW.md Normal file
View File

@ -0,0 +1,157 @@
# Code Review — NEON NOODLE OS
Five-pass review. One pass per loop iteration. Findings: file, line, severity, fix.
Critical/high issues are fixed directly; medium/low are logged for triage.
| Pass | Topic | Status |
|------|-------|--------|
| 1 | Runtime / type / broken-import errors | ✅ done |
| 2 | Logic bugs (races, edge cases, null checks) | ✅ done |
| 3 | Security audit | ✅ done |
| 4 | Error handling | ✅ done |
| 5 | Consistency (naming, dead code, duplication) | ✅ done |
---
## Pass 1 — Runtime / type / broken-import errors
**Method:** `npx tsc --noEmit` (strict), production `vite build`, import-resolution scan,
manual read of the runtime-critical path (`appRuntime.tsx` Babel transform + `new Function` scope).
**Result: no runtime, type, or import errors.**
- `tsc --noEmit` — clean, zero diagnostics.
- `vite build` — succeeds (only the pre-existing >500 kB chunk-size advisory, not an error).
- No project ESLint config present (ESLint not configured) — noted, not a defect.
- All `import` specifiers resolve to existing modules; no dangling paths.
- `appRuntime.compileApp` passes React + hooks + `os` into a `new Function` scope and
memoises the built component per `os` instance — no obvious runtime fault in the happy path.
### Findings
_None at severity high or above. Observations deferred to later passes:_
| # | File:line | Sev | Note | Defer to |
|---|-----------|-----|------|----------|
| 1.1 | `kernel/appRuntime.tsx:223` | low | Compile `cache` Map is never evicted; entries accumulate across self-heals (bounded by app count × versions). | Pass 2 |
| 1.2 | `kernel/ai.ts:62` | low | `currentProvider()` exported but unused by callers (Settings reads `aiProvider` from the store). Possible dead export. | Pass 5 |
No fixes applied in Pass 1 (nothing at critical/high).
---
## Pass 2 — Logic bugs (races, edge cases, off-by-one, null checks)
**Method:** manual read of state/logic paths — `store.ts` (window mgr, openApp,
installAndOpen), `fs.ts`, `sdk.ts`, `bus.ts`, `appRuntime.tsx`, `AppHost.tsx`,
`Window.tsx` (drag/resize/snap), `Terminal.tsx`, `Assistant.tsx`.
### Findings
| # | File:line | Sev | Issue | Fix |
|---|-----------|-----|-------|-----|
| 2.1 | `apps/Terminal.tsx:84` (rm) | **high** | `resolve("")` returns `cwd`, so a bare `rm` (no operand) deletes the **current directory** — at `/` it wipes the entire virtual FS. Also affected `cat`/`mkdir`/`touch` (operated on cwd silently). | ✅ **Fixed** — added missing-operand guards to `rm`, `cat`, `mkdir`, `touch`. |
| 2.2 | `kernel/sdk.ts:32` | **medium** | `ai.available` was a one-time snapshot (`aiAvailable()`) captured when the window's `os` API is built (memoised per window in `AppHost`). Configuring the key in Settings afterwards left already-open apps (e.g. Terminal) reporting AI offline. | ✅ **Fixed** — converted `available` to a getter returning the live value. |
| 2.3 | `system-ui/Window.tsx:33` | low | Title-drag clamps Y to below the menu bar but never clamps X — a window can be dragged fully off-screen left/right with no way back. | deferred (UX, low) |
| 2.4 | `kernel/store.ts:274` | low | `installAndOpen` fuzzy match: `a.name.toLowerCase().includes(query)` can match the wrong app on very short queries (e.g. "a"). Intended fuzzy behaviour; acceptable. | deferred (low) |
| 2.5 | `kernel/fs.ts:65` (write) | low | `write` only auto-creates the parent if it's absent; if the parent path exists as a **file**, the child is written anyway (no "not a directory" error, unlike `mkdir`). | deferred (low) |
| 2.6 | `kernel/appRuntime.tsx:223` | low | (from Pass 1) compile `cache` Map never evicted. Bounded by app×version count; not a leak in practice. | deferred (low) |
**Fixes applied:** 2.1 (high), 2.2 (medium). Typecheck clean after changes.
---
## Pass 3 — Security audit
**Method:** `npm audit`, dangerous-sink grep (`innerHTML`, `dangerouslySetInnerHTML`,
`eval`, `new Function`, `document.write`), secret-handling trace (`ai.ts`/`sdk.ts`/`db.ts`),
sandbox/blocklist review (`appRuntime.tsx`), dev-proxy review (`vite.config.ts`), `.gitignore`.
**Good:**
- `npm audit` (prod deps) — **0 vulnerabilities**.
- No `innerHTML` / `dangerouslySetInnerHTML` / `document.write` anywhere — React auto-escaping intact; Terminal renders into `<pre>` (safe).
- `.env`, `.env.local` are git-ignored — secrets not committed.
- API keys are the user's own and sent only to the selected provider (correct auth headers per API shape).
### Findings
| # | File:line | Sev | Issue | Fix |
|---|-----------|-----|-------|-----|
| 3.1 | `kernel/appRuntime.tsx:25` | **medium** | Generated apps run via `new Function` in the page realm; the regex blocklist omitted `indexedDB`, so app code could open the `fluid-os` DB and read the `settings → aiConfig` store (all API keys) and exfiltrate them. | ✅ **Fixed** — added `indexedDB`, `globalThis`, `Function(`, `import.meta` to the blocklist (defense-in-depth). |
| 3.2 | `kernel/appRuntime.tsx:42` | medium | The blocklist is **not** a real boundary — generated code shares the realm and can still escape via deep prototype tricks (`[].constructor.constructor`, etc.). Documented inline. | accepted by design (PROMPT.md: in-page execution). True isolation = iframe/worker — large refactor, deferred. |
| 3.3 | `vite.config.ts:90` (`/__ai/proxy`) | low (dev-only) | Dev proxy forwards to any `x-llm-url` with the request's auth headers — an open proxy / SSRF + key-relay vector. **Not present in production builds** (no middleware ships). | accepted (dev convenience; required for multi-provider CORS bypass). |
| 3.4 | `vite.config.ts:9` (`/__proxy`) | low (dev-only) | In-OS browser proxy fetches arbitrary `?url=` server-side and strips framing headers — SSRF in dev only. | accepted (dev-only; intended for the browser app). |
| 3.5 | `kernel/db.ts` (settings store) | low/info | API keys persisted in IndexedDB in plaintext — inherent to a backend-less client app; keys never leave except to the chosen provider. Mitigated against in-page theft by 3.1. | accepted by design. |
| 3.6 | `kernel/ai.ts:100` | info | `anthropic-dangerous-direct-browser-access: true` enables direct browser calls to Anthropic — intentional for local dev. | accepted. |
**Fixes applied:** 3.1 (medium — blocklist hardening). Typecheck clean.
---
## Pass 4 — Error handling (uncaught exceptions, missing try/catch, silent failures)
**Method:** traced every async path and entry point — `store.init/installAndOpen/healApp`,
`App.tsx` boot, `sdk.ts` fs/ai, built-in apps (`Terminal`, `Files`, `Assistant`, `AppBuilder`),
`ai.ts` chat/stream, the render-time `AppErrorBoundary`.
**Already solid:**
- `installAndOpen`, `healApp` (store) — try/catch with user-facing `notify`. ✓
- `chat` / `chatStream` (ai.ts) — throw with provider + status + body on non-2xx; callers catch. ✓
- `Terminal.run`, `Assistant.send` — try/catch surfacing errors to the UI. ✓
- `AppErrorBoundary` + self-heal catches **render** errors in generated apps. ✓
- `bus.emit` — wraps each handler in try/catch. ✓
### Findings
| # | File:line | Sev | Issue | Fix |
|---|-----------|-----|-------|-----|
| 4.1 | `kernel/store.ts:100` + `App.tsx:53` | **high** | `init()` had no try/catch and `App` calls `void init()`. Any failure in `fs.seed`/`loadAiConfig`/`loadApps` (e.g. IndexedDB blocked in private mode) left `ready=false`**OS stuck on the boot screen forever, silently** (rejection swallowed by `void`). | ✅ **Fixed**`init()` now try/catches: on failure it boots with built-in apps + in-memory defaults, sets `ready=true`, and notifies "Storage unavailable". OS always reaches the desktop. |
| 4.2 | `apps/Files.tsx:13,22,32,44,54` | low | `refresh/open/save/create/del` call `os.fs.*` without try/catch; an IndexedDB failure becomes an unhandled rejection (console-only, no UI feedback). Error boundary doesn't catch async. | deferred (low — IndexedDB ops reliable; built-in app). |
| 4.3 | `apps/Settings.tsx` `onSave` | low | `await saveAiConfig(cfg)` not wrapped; a persistence failure would skip `setSaved(true)` and reject unhandled. | deferred (low). |
| 4.4 | `kernel/store.ts` `setTheme`/`setWallpaper` | low | `void setSetting(...)` fire-and-forget swallows persistence errors silently (cosmetic settings only). | accepted (low impact). |
| 4.5 | `apps/Terminal.tsx` (ai stream) | low | If `os.ai.stream` throws mid-stream, the catch reports it but the pre-pushed empty "out" line remains. Cosmetic. | deferred (low). |
**Fixes applied:** 4.1 (high — boot resilience). Typecheck clean.
---
## Pass 5 — Consistency (naming, dead code, duplicated logic)
**Method:** export-usage grep (dead code), stale-string scan, magic-number/duplication scan.
### Findings
| # | File:line | Sev | Issue | Fix |
|---|-----------|-----|-------|-----|
| 5.1 | `kernel/ai.ts:62` | low | `currentProvider()` exported but never imported anywhere (Settings reads `aiProvider` from the store). Dead code. | ✅ **Fixed** — removed. |
| 5.2 | Spotlight:148, Assistant:43, AppBuilder:33, Terminal:109, store:327 | medium | Five user-facing strings still said "set VITE_ANTHROPIC_API_KEY in .env" — stale since keys moved to **Settings → Language Model** (multi-provider). Misleading/inconsistent UX. | ✅ **Fixed** — all reworded to "add an API key in Settings → Language Model". |
| 5.3 | `store.ts:78` & `Window.tsx:8` | low | `MENUBAR_H = 36` duplicated as a magic number in two modules (plus `DOCK_RESERVE`, `MIN_W/H`). | deferred — extract to a shared `layout` constants module (low risk, low value). |
| 5.4 | `App.tsx:12` `cssVars` vs `tokens.ts` `tokensAsCss` | low | Two overlapping CSS-var builders. Intentional (apps get a subset; root gets the full set incl. shadows/radius-xl) but the key list is partly duplicated. | accepted (different scopes by design). |
| 5.5 | `registry.ts` icons | low | App Builder uses "⚙️" and Settings uses "⚙" — near-identical gears, visually ambiguous in dock/desktop. | deferred (cosmetic identity choice — recommend a distinct glyph for App Builder, e.g. 🛠️). |
**Fixes applied:** 5.1 (dead code removed), 5.2 (medium — stale strings). Typecheck + full `vite build` clean.
---
# Summary
All 5 passes complete. Verified with `tsc --noEmit` (clean) and production `vite build` (succeeds).
**Fixes applied (7):**
| Sev | Pass | Fix |
|-----|------|-----|
| **high** | 2.1 | `Terminal` `rm`/`cat`/`mkdir`/`touch` now require an operand — a bare `rm` no longer deletes the cwd / whole virtual FS. |
| **high** | 4.1 | `store.init()` is now fault-tolerant — IndexedDB failure boots the OS with defaults + a notification instead of hanging on the boot screen forever. |
| **medium** | 2.2 | `os.ai.available` is a live getter, not a stale snapshot — apps see the key after it's configured in Settings. |
| **medium** | 3.1 | Sandbox blocklist hardened (`indexedDB`, `globalThis`, `Function(`, `import.meta`) — closes the trivial API-key exfiltration path. |
| **medium** | 5.2 | Five stale "VITE_ANTHROPIC_API_KEY" prompts reworded to point at Settings → Language Model. |
| low | 5.1 | Removed dead `currentProvider()` export. |
**Notable accepted/deferred items:**
- **3.2 (medium):** generated-code sandbox is not a true security boundary (in-page `new Function`). Accepted by design (PROMPT.md); real isolation needs an iframe/worker — a substantial refactor, out of scope for this review.
- **3.3/3.4 (low):** dev-server proxies are open-proxy/SSRF vectors but ship only in dev, never in production builds.
- Low-severity polish (window X-clamp, Files/Settings async try/catch, MENUBAR_H dedupe, duplicate gear icons) logged above for future triage.
**Overall:** healthy codebase. 0 dependency vulnerabilities, no type/import errors, no XSS sinks. The two highest-impact issues (FS-wiping `rm`, silent boot hang) are fixed; the chief residual risk (sandbox isolation) is inherent to the product's design and documented.
_Review complete — loop stopped._

19
index.html Normal file
View File

@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ctext y='26' font-size='26'%3E%E2%97%89%3C/text%3E%3C/svg%3E" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap"
rel="stylesheet"
/>
<title>NEON NOODLE OS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2949
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

33
package.json Normal file
View File

@ -0,0 +1,33 @@
{
"name": "neon-noodle-os",
"private": true,
"version": "0.1.0",
"license": "MIT",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@babel/standalone": "^7.26.4",
"framer-motion": "^11.15.0",
"idb": "^8.0.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"zustand": "^5.0.2"
},
"devDependencies": {
"@types/babel__standalone": "^7.1.9",
"@types/node": "^22.20.0",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.2",
"vite": "^6.0.7"
}
}

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

96
src/App.tsx Normal file
View File

@ -0,0 +1,96 @@
import { useEffect, useMemo, useState } from "react";
import { AnimatePresence } from "framer-motion";
import { useOS } from "./kernel/store";
import { tokensFor } from "./design/tokens";
import { bus } from "./kernel/bus";
import { Boot } from "./system-ui/Boot";
import { Lockscreen } from "./system-ui/Lockscreen";
import { Desktop } from "./system-ui/Desktop";
// Full set of CSS variables exposed at the OS root. Apps (built-in and generated)
// read these via var(--token), guaranteeing one coherent look.
function cssVars(mode: "dark" | "light"): React.CSSProperties {
const t = tokensFor(mode);
return {
"--bg": t.color.bg,
"--bg-elevated": t.color.bgElevated,
"--glass": t.color.glass,
"--glass-border": t.color.glassBorder,
"--text": t.color.text,
"--text-muted": t.color.textMuted,
"--accent": t.color.accent,
"--accent-soft": t.color.accentSoft,
"--accent-text": t.color.accentText,
"--danger": t.color.danger,
"--success": t.color.success,
"--ring": t.color.ring,
"--radius-sm": t.radius.sm,
"--radius-md": t.radius.md,
"--radius-lg": t.radius.lg,
"--radius-xl": t.radius.xl,
"--shadow-window": t.shadow.window,
"--shadow-soft": t.shadow.soft,
"--shadow-glow": t.shadow.glow,
"--blur": t.blur,
"--font-sans": t.font.sans,
"--font-mono": t.font.mono,
} as React.CSSProperties;
}
export default function App() {
const ready = useOS((s) => s.ready);
const phase = useOS((s) => s.phase);
const themeMode = useOS((s) => s.themeMode);
const init = useOS((s) => s.init);
const openApp = useOS((s) => s.openApp);
const setSpotlight = useOS((s) => s.setSpotlight);
const spotlightOpen = useOS((s) => s.spotlightOpen);
const apps = useOS((s) => s.apps);
const [booted, setBooted] = useState(false);
// boot the kernel once
useEffect(() => {
void init();
// Dev-only: expose the store for smoke tests / debugging in the console.
if (import.meta.env.DEV) (window as unknown as { __OS: typeof useOS }).__OS = useOS;
}, [init]);
// global shortcut: Cmd/Ctrl + Space → spotlight
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.code === "Space") {
e.preventDefault();
if (phase === "desktop") setSpotlight(!spotlightOpen);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [phase, spotlightOpen, setSpotlight]);
// terminal `open <app>` → resolve by name and launch
useEffect(() => {
return bus.on("app:open-app-by-name", (payload) => {
const name = String(payload).toLowerCase().trim();
const app = apps.find(
(a) => a.name.toLowerCase() === name || a.name.toLowerCase().includes(name)
);
if (app) openApp(app.id);
});
}, [apps, openApp]);
const style = useMemo(() => cssVars(themeMode), [themeMode]);
return (
<div style={{ ...style, height: "100%", width: "100%", color: "var(--text)" }}>
<AnimatePresence mode="wait">
{!booted || !ready ? (
<Boot key="boot" onDone={() => setBooted(true)} />
) : phase !== "desktop" ? (
<Lockscreen key="lock" onUnlock={() => useOS.getState().unlock()} />
) : (
<Desktop key="desktop" />
)}
</AnimatePresence>
</div>
);
}

172
src/apps/AppBuilder.tsx Normal file
View File

@ -0,0 +1,172 @@
import { useState } from "react";
import type { AppProps } from "../kernel/types";
import { useOS } from "../kernel/store";
import { generateApp, aiAvailable } from "../kernel/ai";
import { persistApp } from "../kernel/db";
import { tokensFor } from "../design/tokens";
import { Button, Panel, AppIcon } from "../system-ui/components/ui";
const IDEAS = [
"a pomodoro timer with start/pause/reset",
"a calculator",
"a markdown notes app that saves to the filesystem",
"a color palette generator",
"a unit converter",
"a kanban board",
"a tip calculator",
"a stopwatch with laps",
];
export default function AppBuilder({ os }: AppProps) {
const themeMode = useOS((s) => s.themeMode);
const apps = useOS((s) => s.apps);
const setApps = useOS.setState;
const openApp = useOS((s) => s.openApp);
const [request, setRequest] = useState("");
const [phase, setPhase] = useState<"idle" | "generating" | "done" | "error">("idle");
const [result, setResult] = useState<{ name: string; icon: string; code: string } | null>(null);
const [error, setError] = useState("");
const build = async (req: string) => {
if (!req.trim()) return;
if (!aiAvailable()) {
setError("AI unavailable — add an API key in Settings → Language Model");
setPhase("error");
return;
}
setPhase("generating");
setResult(null);
setError("");
try {
const gen = await generateApp(req, tokensFor(themeMode));
const id = `gen-${Date.now().toString(36)}`;
const app = {
id,
name: gen.name,
icon: gen.icon,
category: gen.category,
defaultSize: gen.defaultSize,
description: gen.description,
code: gen.code,
builtin: false,
createdAt: Date.now(),
};
// persist + register through the store
await persistApp(app);
setApps((s) => ({ apps: [...s.apps, app] }));
setResult({ name: gen.name, icon: gen.icon, code: gen.code });
setPhase("done");
os.notify(`${gen.icon} ${gen.name} built`, "Generated by the App Builder");
openApp(id);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
setPhase("error");
}
};
return (
<div style={{ height: "100%", overflow: "auto", padding: 22, color: "var(--text)" }}>
<div style={{ fontSize: 20, fontWeight: 700, marginBottom: 4 }}> App Builder</div>
<div style={{ color: "var(--text-muted)", fontSize: 13, marginBottom: 18 }}>
Describe an app. The AI generates a complete, on-brand React component, installs it into the
Dock, and opens it.
</div>
<div style={{ display: "flex", gap: 8, marginBottom: 14 }}>
<input
value={request}
onChange={(e) => setRequest(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && build(request)}
placeholder="e.g. a habit tracker with streaks"
style={{
flex: 1,
background: "var(--bg-elevated)",
color: "var(--text)",
border: "1px solid var(--glass-border)",
borderRadius: "var(--radius-md)",
padding: "10px 12px",
outline: "none",
fontSize: 13.5,
}}
/>
<Button onClick={() => build(request)} disabled={phase === "generating"}>
{phase === "generating" ? "Building…" : "Build"}
</Button>
</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: 7, marginBottom: 20 }}>
{IDEAS.map((idea) => (
<button
key={idea}
onClick={() => {
setRequest(idea);
build(idea);
}}
disabled={phase === "generating"}
style={{
background: "var(--accent-soft)",
color: "var(--text)",
border: "1px solid var(--glass-border)",
borderRadius: 999,
padding: "6px 12px",
fontSize: 12,
cursor: "pointer",
}}
>
{idea}
</button>
))}
</div>
{phase === "generating" && (
<Panel style={{ padding: 18 }}>
<div className="builder-shimmer" style={{ fontWeight: 600, marginBottom: 6 }}>
Generating component
</div>
<div style={{ color: "var(--text-muted)", fontSize: 12 }}>
Writing JSON manifest + React code, then compiling in the sandbox.
</div>
</Panel>
)}
{phase === "error" && (
<Panel style={{ padding: 16, color: "var(--danger)", fontSize: 13 }}>{error}</Panel>
)}
{phase === "done" && result && (
<Panel style={{ padding: 16 }}>
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 10 }}>
<AppIcon icon={result.icon} size={36} />
<span style={{ fontWeight: 700, fontSize: 15 }}>{result.name}</span>
<span style={{ color: "var(--success)", fontSize: 12 }}> installed & opened</span>
</div>
<details>
<summary style={{ cursor: "pointer", fontSize: 12, color: "var(--text-muted)" }}>
View generated source
</summary>
<pre
style={{
marginTop: 10,
background: "var(--bg-elevated)",
border: "1px solid var(--glass-border)",
borderRadius: "var(--radius-md)",
padding: 12,
fontSize: 11.5,
fontFamily: "var(--font-mono)",
overflow: "auto",
maxHeight: 280,
whiteSpace: "pre-wrap",
}}
>
{result.code}
</pre>
</details>
</Panel>
)}
<div style={{ marginTop: 20, fontSize: 12, color: "var(--text-muted)" }}>
{apps.filter((a) => !a.builtin).length} AI-generated app(s) installed.
</div>
</div>
);
}

137
src/apps/Assistant.tsx Normal file
View File

@ -0,0 +1,137 @@
import { useEffect, useRef, useState } from "react";
import type { AppProps } from "../kernel/types";
import { useOS } from "../kernel/store";
import { chatStream, aiAvailable } from "../kernel/ai";
import { Button } from "../system-ui/components/ui";
interface Turn {
role: "user" | "assistant";
content: string;
}
const SYSTEM =
"You are the assistant inside NEON NOODLE OS, an AI-native web desktop. Be concise and helpful. " +
"If the user wants a tool or app (e.g. 'a calculator', 'a timer'), tell them you can build it and " +
"to click the 'Build as app' button or use ⌘Space. Otherwise just answer normally.";
export default function Assistant({ os }: AppProps) {
const [turns, setTurns] = useState<Turn[]>([
{
role: "assistant",
content:
"Hi — I'm your NEON NOODLE OS assistant. Ask me anything, or say what app you need and I'll build it. Try ⌘Space for the quick launcher.",
},
]);
const [input, setInput] = useState("");
const [busy, setBusy] = useState(false);
const installAndOpen = useOS((s) => s.installAndOpen);
const endRef = useRef<HTMLDivElement>(null);
useEffect(() => {
endRef.current?.scrollIntoView({ behavior: "smooth" });
}, [turns]);
const send = async () => {
const text = input.trim();
if (!text || busy) return;
setInput("");
const history: Turn[] = [...turns, { role: "user", content: text }];
setTurns([...history, { role: "assistant", content: "" }]);
if (!aiAvailable()) {
setTurns([
...history,
{ role: "assistant", content: "AI is offline — add an API key in Settings → Language Model to chat." },
]);
return;
}
setBusy(true);
try {
let acc = "";
await chatStream(
history.map((t) => ({ role: t.role, content: t.content })),
(tok) => {
acc += tok;
setTurns((prev) => {
const copy = [...prev];
copy[copy.length - 1] = { role: "assistant", content: acc };
return copy;
});
},
{ system: SYSTEM }
);
} catch (e) {
setTurns((prev) => {
const copy = [...prev];
copy[copy.length - 1] = {
role: "assistant",
content: "⚠️ " + (e instanceof Error ? e.message : String(e)),
};
return copy;
});
} finally {
setBusy(false);
}
};
const buildAsApp = () => {
const last = [...turns].reverse().find((t) => t.role === "user");
const req = input.trim() || last?.content;
if (req) {
setInput("");
installAndOpen(req);
}
};
return (
<div style={{ height: "100%", display: "flex", flexDirection: "column", color: "var(--text)" }}>
<div style={{ flex: 1, overflow: "auto", padding: 16, display: "flex", flexDirection: "column", gap: 12 }}>
{turns.map((t, i) => (
<div
key={i}
style={{
alignSelf: t.role === "user" ? "flex-end" : "flex-start",
maxWidth: "85%",
background: t.role === "user" ? "var(--accent)" : "var(--bg-elevated)",
color: t.role === "user" ? "var(--accent-text)" : "var(--text)",
border: t.role === "user" ? "none" : "1px solid var(--glass-border)",
borderRadius: "var(--radius-lg)",
padding: "10px 13px",
fontSize: 13.5,
lineHeight: 1.55,
whiteSpace: "pre-wrap",
}}
>
{t.content || (busy && i === turns.length - 1 ? "…" : "")}
</div>
))}
<div ref={endRef} />
</div>
<div style={{ borderTop: "1px solid var(--glass-border)", padding: 12, display: "flex", gap: 8 }}>
<input
value={input}
autoFocus
placeholder="Message the assistant…"
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && !e.shiftKey && send()}
style={{
flex: 1,
background: "var(--bg-elevated)",
color: "var(--text)",
border: "1px solid var(--glass-border)",
borderRadius: "var(--radius-md)",
padding: "10px 12px",
outline: "none",
fontSize: 13.5,
fontFamily: "var(--font-sans)",
}}
/>
<Button variant="ghost" onClick={buildAsApp} title="Generate an app from this request">
Build app
</Button>
<Button onClick={send} disabled={busy}>
{busy ? "…" : "Send"}
</Button>
</div>
</div>
);
}

218
src/apps/Files.tsx Normal file
View File

@ -0,0 +1,218 @@
import { useEffect, useState, useCallback } from "react";
import type { AppProps, FsNode } from "../kernel/types";
import { Button, Input } from "../system-ui/components/ui";
export default function Files({ os }: AppProps) {
const [cwd, setCwd] = useState("/");
const [nodes, setNodes] = useState<FsNode[]>([]);
const [selected, setSelected] = useState<FsNode | null>(null);
const [content, setContent] = useState("");
const [dirty, setDirty] = useState(false);
const [newName, setNewName] = useState("");
const refresh = useCallback(async () => {
setNodes(await os.fs.list(cwd));
}, [cwd, os]);
useEffect(() => {
refresh();
setSelected(null);
}, [refresh]);
const open = async (n: FsNode) => {
if (n.type === "dir") {
setCwd(n.path);
return;
}
setSelected(n);
setContent((await os.fs.read(n.path)) ?? "");
setDirty(false);
};
const save = async () => {
if (!selected) return;
await os.fs.write(selected.path, content);
setDirty(false);
os.notify("Saved", selected.path);
};
const up = () => {
if (cwd === "/") return;
setCwd(cwd.slice(0, cwd.lastIndexOf("/")) || "/");
};
const create = async (type: "file" | "dir") => {
const name = newName.trim();
if (!name) return;
const path = (cwd === "/" ? "" : cwd) + "/" + name;
if (type === "dir") await os.fs.mkdir(path);
else await os.fs.write(path, "");
setNewName("");
refresh();
};
const del = async (n: FsNode) => {
await os.fs.delete(n.path);
if (selected?.path === n.path) setSelected(null);
refresh();
};
return (
<div style={{ display: "flex", height: "100%", color: "var(--text)" }}>
{/* sidebar */}
<div
style={{
width: 230,
borderRight: "1px solid var(--glass-border)",
display: "flex",
flexDirection: "column",
minWidth: 0,
}}
>
<div
style={{
display: "flex",
gap: 6,
padding: 10,
borderBottom: "1px solid var(--glass-border)",
alignItems: "center",
}}
>
<Button variant="ghost" onClick={up} style={{ padding: "6px 10px" }}>
</Button>
<div
style={{
fontFamily: "var(--font-mono)",
fontSize: 12,
color: "var(--text-muted)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{cwd}
</div>
</div>
<div style={{ flex: 1, overflow: "auto", padding: 6 }}>
{nodes.length === 0 && (
<div style={{ color: "var(--text-muted)", fontSize: 12, padding: 10 }}>
Empty folder
</div>
)}
{nodes.map((n) => (
<div
key={n.path}
onClick={() => open(n)}
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "7px 9px",
borderRadius: "var(--radius-sm)",
cursor: "pointer",
background: selected?.path === n.path ? "var(--accent-soft)" : "transparent",
fontSize: 13,
}}
>
<span>{n.type === "dir" ? "📁" : "📄"}</span>
<span
style={{
flex: 1,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{n.path.slice(n.path.lastIndexOf("/") + 1)}
</span>
<span
onClick={(e) => {
e.stopPropagation();
del(n);
}}
style={{ opacity: 0.5, fontSize: 12 }}
title="Delete"
>
</span>
</div>
))}
</div>
<div style={{ display: "flex", gap: 6, padding: 8, borderTop: "1px solid var(--glass-border)" }}>
<Input
placeholder="name…"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && create("file")}
style={{ fontSize: 12 }}
/>
<Button variant="ghost" onClick={() => create("dir")} title="New folder" style={{ padding: "6px 9px" }}>
📁+
</Button>
<Button onClick={() => create("file")} title="New file" style={{ padding: "6px 9px" }}>
+
</Button>
</div>
</div>
{/* editor */}
<div style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0 }}>
{selected ? (
<>
<div
style={{
padding: "9px 12px",
borderBottom: "1px solid var(--glass-border)",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12 }}>
{selected.path}
{dirty ? " •" : ""}
</span>
<Button onClick={save} disabled={!dirty} style={{ opacity: dirty ? 1 : 0.5 }}>
Save
</Button>
</div>
<textarea
value={content}
onChange={(e) => {
setContent(e.target.value);
setDirty(true);
}}
spellCheck={false}
style={{
flex: 1,
resize: "none",
border: "none",
outline: "none",
background: "transparent",
color: "var(--text)",
fontFamily: "var(--font-mono)",
fontSize: 13,
lineHeight: 1.6,
padding: 16,
}}
/>
</>
) : (
<div
style={{
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "var(--text-muted)",
fontSize: 13,
}}
>
Select a file to view & edit
</div>
)}
</div>
</div>
);
}

334
src/apps/Settings.tsx Normal file
View File

@ -0,0 +1,334 @@
import { useState } from "react";
import type { AppProps } from "../kernel/types";
import { useOS } from "../kernel/store";
import { WALLPAPERS } from "../design/tokens";
import { Button, Panel, Input, AppIcon } from "../system-ui/components/ui";
import { getAiConfig } from "../kernel/ai";
import {
PROVIDER_LIST,
PROVIDERS,
modelFor,
type AiConfig,
type ProviderId,
} from "../kernel/providers";
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div style={{ marginBottom: 22 }}>
<div
style={{
fontSize: 12,
textTransform: "uppercase",
letterSpacing: 1,
color: "var(--text-muted)",
marginBottom: 10,
fontWeight: 600,
}}
>
{title}
</div>
{children}
</div>
);
}
export default function Settings(_props: AppProps) {
const themeMode = useOS((s) => s.themeMode);
const toggleTheme = useOS((s) => s.toggleTheme);
const wallpaper = useOS((s) => s.wallpaper);
const setWallpaper = useOS((s) => s.setWallpaper);
const apps = useOS((s) => s.apps);
const uninstall = useOS((s) => s.uninstallApp);
const generated = apps.filter((a) => !a.builtin);
return (
<div style={{ height: "100%", overflow: "auto", padding: 22, color: "var(--text)" }}>
<Section title="Language Model">
<LanguageModelSection />
</Section>
<Section title="Appearance">
<Panel style={{ padding: 14, display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<div>
<div style={{ fontWeight: 600 }}>Theme</div>
<div style={{ fontSize: 12, color: "var(--text-muted)" }}>
Currently {themeMode === "dark" ? "Dark" : "Light"}
</div>
</div>
<Button variant="ghost" onClick={toggleTheme}>
{themeMode === "dark" ? "🌙 Dark" : "☀️ Light"} switch
</Button>
</Panel>
</Section>
<Section title="Wallpaper">
<div style={{ display: "grid", gridTemplateColumns: "repeat(2, 1fr)", gap: 10 }}>
{WALLPAPERS.map((w) => (
<button
key={w.id}
onClick={() => setWallpaper(w.id)}
className={w.animated ? "wallpaper-animated" : undefined}
style={{
height: 84,
borderRadius: "var(--radius-md)",
border:
wallpaper === w.id ? "2px solid var(--accent)" : "1px solid var(--glass-border)",
background: w.css,
backgroundSize: w.backgroundSize,
animation: w.animation,
cursor: "pointer",
position: "relative",
overflow: "hidden",
}}
>
<span
style={{
position: "absolute",
left: 8,
bottom: 6,
fontSize: 11,
fontWeight: 600,
color: "#fff",
textShadow: "0 1px 4px rgba(0,0,0,0.6)",
}}
>
{w.name}
</span>
{w.animated && (
<span
style={{
position: "absolute",
top: 6,
right: 6,
fontSize: 9,
fontWeight: 700,
letterSpacing: 0.4,
padding: "2px 6px",
borderRadius: 999,
background: "rgba(0,0,0,0.45)",
color: "#fff",
backdropFilter: "blur(4px)",
}}
>
LIVE
</span>
)}
</button>
))}
</div>
</Section>
<Section title="Installed apps">
{generated.length === 0 ? (
<Panel style={{ padding: 14, fontSize: 13, color: "var(--text-muted)" }}>
No AI-generated apps yet. Open the assistant (Space) and ask for one.
</Panel>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{generated.map((a) => (
<Panel
key={a.id}
style={{ padding: "10px 12px", display: "flex", alignItems: "center", gap: 12 }}
>
<AppIcon icon={a.icon} size={34} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600 }}>{a.name}</div>
<div
style={{
fontSize: 12,
color: "var(--text-muted)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{a.description ?? a.category}
</div>
</div>
<Button variant="danger" onClick={() => uninstall(a.id)}>
Uninstall
</Button>
</Panel>
))}
</div>
)}
</Section>
<Section title="System">
<Panel style={{ padding: 14, fontSize: 13, lineHeight: 1.7 }}>
<div>
<b>NEON NOODLE OS</b> an AI-native web desktop.
</div>
<div style={{ color: "var(--text-muted)" }}>
Configure the AI provider and API keys in the Language Model section above.
</div>
</Panel>
</Section>
</div>
);
}
function LanguageModelSection() {
const aiReady = useOS((s) => s.aiReady);
const saveAiConfig = useOS((s) => s.saveAiConfig);
// Local draft of the config; committed on Save.
const [cfg, setCfg] = useState<AiConfig>(() => structuredClone(getAiConfig()));
const [saved, setSaved] = useState(false);
const provider = cfg.provider;
const def = PROVIDERS[provider];
const key = cfg.keys[provider] ?? "";
const update = (patch: Partial<AiConfig>) => {
setCfg((c) => ({ ...c, ...patch }));
setSaved(false);
};
const setProvider = (id: ProviderId) => update({ provider: id });
const setKey = (v: string) => update({ keys: { ...cfg.keys, [provider]: v } });
const setModel = (v: string) => update({ models: { ...cfg.models, [provider]: v } });
const setBaseUrl = (v: string) =>
update({ baseUrls: { ...cfg.baseUrls, [provider]: v } });
const setMaxTokens = (v: string) => {
const n = parseInt(v, 10);
update({ maxTokens: Number.isFinite(n) && n > 0 ? n : undefined });
};
const onSave = async () => {
await saveAiConfig(cfg);
setSaved(true);
};
const lbl: React.CSSProperties = {
fontSize: 12,
fontWeight: 600,
color: "var(--text-muted)",
display: "block",
marginBottom: 5,
};
return (
<Panel style={{ padding: 14, display: "flex", flexDirection: "column", gap: 14 }}>
{/* provider picker */}
<div>
<label style={lbl}>Provider</label>
<div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
{PROVIDER_LIST.map((p) => {
const active = p.id === provider;
const hasKey = Boolean(cfg.keys[p.id]?.trim());
return (
<button
key={p.id}
onClick={() => setProvider(p.id)}
style={{
display: "flex",
alignItems: "center",
gap: 6,
padding: "7px 11px",
borderRadius: "var(--radius-md)",
fontSize: 13,
fontWeight: 600,
cursor: "pointer",
color: "var(--text)",
border: active ? "1px solid var(--accent)" : "1px solid var(--glass-border)",
background: active ? "var(--accent-soft)" : "var(--bg-elevated)",
}}
>
<span
style={{
width: 7,
height: 7,
borderRadius: "50%",
background: hasKey ? "var(--success)" : "var(--glass-border)",
}}
/>
{p.label}
</button>
);
})}
</div>
</div>
{/* api key */}
<div>
<label style={lbl}>API key {def.label}</label>
<Input
type="password"
value={key}
placeholder={def.keyPlaceholder}
autoComplete="off"
onChange={(e) => setKey(e.target.value)}
/>
<div style={{ fontSize: 11, color: "var(--text-muted)", marginTop: 5 }}>
Stored locally in your browser (IndexedDB). Get a key at{" "}
<a
href={def.keysUrl}
target="_blank"
rel="noreferrer"
style={{ color: "var(--accent)" }}
>
{def.keysUrl.replace(/^https?:\/\//, "")}
</a>
</div>
</div>
{/* model */}
<div>
<label style={lbl}>Model</label>
<Input
value={cfg.models[provider] ?? ""}
placeholder={def.defaultModel}
list={`models-${provider}`}
onChange={(e) => setModel(e.target.value)}
/>
<datalist id={`models-${provider}`}>
{def.models.map((m) => (
<option key={m} value={m} />
))}
</datalist>
</div>
{/* max output tokens */}
<div>
<label style={lbl}>Max output tokens</label>
<Input
type="number"
min={256}
step={256}
value={cfg.maxTokens ?? ""}
placeholder="default (8000 for generation)"
onChange={(e) => setMaxTokens(e.target.value)}
/>
<div style={{ fontSize: 11, color: "var(--text-muted)", marginTop: 5 }}>
Counts toward per-minute limits. Lower it if you hit rate limits e.g.
Groq's free tier allows ~8000 tokens/min total, so set 6000 here.
</div>
</div>
{/* base url override */}
<details>
<summary style={{ ...lbl, cursor: "pointer", marginBottom: 0 }}>
Advanced base URL override
</summary>
<div style={{ marginTop: 8 }}>
<Input
value={cfg.baseUrls[provider] ?? ""}
placeholder={def.baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
/>
</div>
</details>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<Button onClick={onSave}>Save</Button>
<span style={{ fontSize: 12, color: "var(--text-muted)" }}>
{saved
? "Saved ✓"
: aiReady
? `Active: ${PROVIDERS[useOS.getState().aiProvider].label} · ${modelFor(getAiConfig())}`
: "No API key set yet"}
</span>
</div>
</Panel>
);
}

211
src/apps/Terminal.tsx Normal file
View File

@ -0,0 +1,211 @@
import { useEffect, useRef, useState } from "react";
import type { AppProps } from "../kernel/types";
import { normalize } from "../kernel/fs";
interface Line {
kind: "in" | "out" | "err" | "sys";
text: string;
}
export default function Terminal({ os }: AppProps) {
const [lines, setLines] = useState<Line[]>([
{ kind: "sys", text: "NEON NOODLE OS shell. Type `help` for commands." },
]);
const [cwd, setCwd] = useState("/");
const [input, setInput] = useState("");
const [busy, setBusy] = useState(false);
const [history, setHistory] = useState<string[]>([]);
const [hIdx, setHIdx] = useState(-1);
const endRef = useRef<HTMLDivElement>(null);
useEffect(() => {
endRef.current?.scrollIntoView({ behavior: "smooth" });
}, [lines]);
const push = (l: Line) => setLines((prev) => [...prev, l]);
const resolve = (arg: string) => {
if (!arg) return cwd;
if (arg.startsWith("/")) return normalize(arg);
return normalize((cwd === "/" ? "" : cwd) + "/" + arg);
};
const run = async (raw: string) => {
const cmd = raw.trim();
if (!cmd) return;
push({ kind: "in", text: `${cwd} $ ${cmd}` });
setHistory((h) => [...h, cmd]);
const [name, ...args] = cmd.split(/\s+/);
const rest = cmd.slice(name.length).trim();
try {
switch (name) {
case "help":
push({
kind: "out",
text: "commands: ls [dir], cd <dir>, cat <file>, mkdir <dir>, touch <file>, rm <path>, echo <text> [> file], pwd, clear, ai <prompt>, open <app>",
});
break;
case "pwd":
push({ kind: "out", text: cwd });
break;
case "ls": {
const nodes = await os.fs.list(resolve(args[0] ?? ""));
push({
kind: "out",
text:
nodes
.map((n) => (n.type === "dir" ? n.path.split("/").pop() + "/" : n.path.split("/").pop()))
.join(" ") || "(empty)",
});
break;
}
case "cd": {
const target = args[0] === ".." ? cwd.slice(0, cwd.lastIndexOf("/")) || "/" : resolve(args[0] ?? "/");
if (args[0] && args[0] !== ".." && !(await os.fs.exists(target))) {
push({ kind: "err", text: `cd: no such directory: ${args[0]}` });
} else {
setCwd(target || "/");
}
break;
}
case "cat": {
if (!args[0]) { push({ kind: "err", text: "cat: missing operand" }); break; }
const c = await os.fs.read(resolve(args[0]));
if (c == null) push({ kind: "err", text: `cat: ${args[0]}: no such file` });
else push({ kind: "out", text: c || "(empty)" });
break;
}
case "mkdir":
if (!args[0]) { push({ kind: "err", text: "mkdir: missing operand" }); break; }
await os.fs.mkdir(resolve(args[0]));
break;
case "touch":
if (!args[0]) { push({ kind: "err", text: "touch: missing operand" }); break; }
if (!(await os.fs.exists(resolve(args[0])))) await os.fs.write(resolve(args[0]), "");
break;
case "rm":
// Guard: `resolve("")` returns cwd, so a bare `rm` would delete the
// current directory (or the entire FS at root). Require an operand.
if (!args[0]) { push({ kind: "err", text: "rm: missing operand" }); break; }
await os.fs.delete(resolve(args[0]));
break;
case "echo": {
const redir = rest.match(/^(.*?)\s*>\s*(\S+)\s*$/);
if (redir) await os.fs.write(resolve(redir[2]), redir[1].replace(/^["']|["']$/g, ""));
else push({ kind: "out", text: rest });
break;
}
case "clear":
setLines([]);
break;
case "open": {
os.bus.emit("open-app-by-name", rest);
push({ kind: "sys", text: `requested: open ${rest}` });
break;
}
case "ai": {
if (!os.ai.available) {
push({ kind: "err", text: "ai: unavailable (add an API key in Settings)" });
break;
}
setBusy(true);
push({ kind: "out", text: "" });
let acc = "";
await os.ai.stream(rest, (tok) => {
acc += tok;
setLines((prev) => {
const copy = [...prev];
copy[copy.length - 1] = { kind: "out", text: acc };
return copy;
});
});
setBusy(false);
break;
}
default:
push({ kind: "err", text: `command not found: ${name}` });
}
} catch (e) {
setBusy(false);
push({ kind: "err", text: e instanceof Error ? e.message : String(e) });
}
};
const onKey = async (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !busy) {
const v = input;
setInput("");
setHIdx(-1);
await run(v);
} else if (e.key === "ArrowUp") {
e.preventDefault();
const idx = hIdx === -1 ? history.length - 1 : Math.max(0, hIdx - 1);
if (history[idx] != null) {
setHIdx(idx);
setInput(history[idx]);
}
} else if (e.key === "ArrowDown") {
e.preventDefault();
if (hIdx === -1) return;
const idx = hIdx + 1;
if (idx >= history.length) {
setHIdx(-1);
setInput("");
} else {
setHIdx(idx);
setInput(history[idx]);
}
}
};
const color = (k: Line["kind"]) =>
k === "err" ? "var(--danger)" : k === "in" ? "var(--accent)" : k === "sys" ? "var(--text-muted)" : "var(--text)";
return (
<div
style={{
height: "100%",
background: "rgba(0,0,0,0.25)",
fontFamily: "var(--font-mono)",
fontSize: 13,
color: "var(--text)",
display: "flex",
flexDirection: "column",
padding: 12,
cursor: "text",
}}
onClick={() => (document.getElementById("term-in") as HTMLInputElement)?.focus()}
>
<div style={{ flex: 1, overflow: "auto", lineHeight: 1.55 }}>
{lines.map((l, i) => (
<pre key={i} style={{ margin: 0, whiteSpace: "pre-wrap", color: color(l.kind) }}>
{l.text}
</pre>
))}
<div ref={endRef} />
</div>
<div style={{ display: "flex", gap: 8, alignItems: "center", marginTop: 6 }}>
<span style={{ color: "var(--accent)" }}>{cwd} $</span>
<input
id="term-in"
autoFocus
value={input}
disabled={busy}
onChange={(e) => setInput(e.target.value)}
onKeyDown={onKey}
style={{
flex: 1,
background: "transparent",
border: "none",
outline: "none",
color: "var(--text)",
fontFamily: "var(--font-mono)",
fontSize: 13,
}}
/>
{busy && <span style={{ color: "var(--text-muted)" }}></span>}
</div>
</div>
);
}

176
src/design/tokens.ts Normal file
View File

@ -0,0 +1,176 @@
// Central design system. ALL apps — built-in and AI-generated — derive their look
// from these tokens so the whole OS feels like one coherent product.
// Aesthetic: "NEON NOODLE OS" — deep slate base, warm amber accent, frosted glass panels,
// soft glow, monospace accents.
export type ThemeMode = "dark" | "light";
export interface DesignTokens {
mode: ThemeMode;
color: {
bg: string; // app/window surface base
bgElevated: string; // raised surfaces (inputs, cards)
glass: string; // translucent panel fill
glassBorder: string;
text: string;
textMuted: string;
accent: string; // amber
accentSoft: string;
accentText: string; // text on accent
danger: string;
success: string;
ring: string;
};
radius: { sm: string; md: string; lg: string; xl: string };
shadow: { window: string; glow: string; soft: string };
blur: string;
font: { sans: string; mono: string };
space: (n: number) => string;
}
const sans = "Inter, system-ui, -apple-system, sans-serif";
const mono = "'JetBrains Mono', ui-monospace, monospace";
export const darkTokens: DesignTokens = {
mode: "dark",
color: {
bg: "rgba(18, 20, 28, 0.72)",
bgElevated: "rgba(38, 41, 54, 0.85)",
glass: "rgba(26, 28, 38, 0.62)",
glassBorder: "rgba(255, 255, 255, 0.10)",
text: "#ECE9E2",
textMuted: "#9A968C",
accent: "#F5A623",
accentSoft: "rgba(245, 166, 35, 0.16)",
accentText: "#1a1206",
danger: "#FF6B6B",
success: "#4ADE80",
ring: "rgba(245, 166, 35, 0.55)",
},
radius: { sm: "6px", md: "10px", lg: "16px", xl: "22px" },
shadow: {
window: "0 24px 70px -12px rgba(0,0,0,0.65), 0 0 0 1px rgba(255,255,255,0.06)",
glow: "0 0 40px -6px rgba(245,166,35,0.45)",
soft: "0 8px 24px -8px rgba(0,0,0,0.5)",
},
blur: "20px",
font: { sans, mono },
space: (n) => `${n * 4}px`,
};
export const lightTokens: DesignTokens = {
mode: "light",
color: {
bg: "rgba(250, 248, 244, 0.80)",
bgElevated: "rgba(255, 255, 255, 0.92)",
glass: "rgba(255, 253, 250, 0.66)",
glassBorder: "rgba(20, 20, 30, 0.10)",
text: "#1E1B16",
textMuted: "#6B6658",
accent: "#D98410",
accentSoft: "rgba(217, 132, 16, 0.14)",
accentText: "#fff7ec",
danger: "#DC2626",
success: "#16A34A",
ring: "rgba(217, 132, 16, 0.45)",
},
radius: { sm: "6px", md: "10px", lg: "16px", xl: "22px" },
shadow: {
window: "0 24px 70px -16px rgba(60,50,30,0.30), 0 0 0 1px rgba(20,20,30,0.06)",
glow: "0 0 40px -8px rgba(217,132,16,0.35)",
soft: "0 8px 24px -10px rgba(60,50,30,0.25)",
},
blur: "20px",
font: { sans, mono },
space: (n) => `${n * 4}px`,
};
export function tokensFor(mode: ThemeMode): DesignTokens {
return mode === "light" ? lightTokens : darkTokens;
}
// Curated wallpapers (CSS gradients — zero network dependency, always crisp).
// Animated ones drift their layered gradients via a keyframe defined in index.css;
// `backgroundSize` > 100% gives each layer room to move (parallax). Motion is
// disabled automatically for users with prefers-reduced-motion.
export interface Wallpaper {
id: string;
name: string;
css: string;
animated?: boolean;
backgroundSize?: string;
/** CSS animation shorthand referencing a wp-* keyframe in index.css. */
animation?: string;
}
export const WALLPAPERS: Wallpaper[] = [
{
id: "ember",
name: "Ember",
css: "radial-gradient(circle at 20% 20%, #3a2a12 0%, transparent 50%), radial-gradient(circle at 80% 70%, #4a1f2e 0%, transparent 55%), linear-gradient(135deg, #0d0f16 0%, #161019 100%)",
},
{
id: "aurora",
name: "Aurora",
css: "radial-gradient(circle at 70% 20%, #12343a 0%, transparent 55%), radial-gradient(circle at 25% 80%, #2a1840 0%, transparent 55%), linear-gradient(160deg, #0a0d12 0%, #0f1119 100%)",
},
{
id: "dawn",
name: "Dawn",
css: "radial-gradient(circle at 30% 25%, #f6d9a8 0%, transparent 55%), radial-gradient(circle at 75% 75%, #f3b07a 0%, transparent 55%), linear-gradient(150deg, #fcefe0 0%, #f6e3d4 100%)",
},
{
id: "noir",
name: "Noir",
css: "radial-gradient(circle at 50% 0%, #2a2d3a 0%, transparent 60%), linear-gradient(180deg, #0a0a0d 0%, #14141a 100%)",
},
// ---- animated ----
{
id: "aurora-flow",
name: "Aurora Flow",
animated: true,
backgroundSize: "200% 200%, 240% 240%, 100% 100%",
animation: "wp-drift 32s ease-in-out infinite alternate",
css: "radial-gradient(circle at 25% 25%, #16414a 0%, transparent 50%), radial-gradient(circle at 75% 65%, #3a1f5c 0%, transparent 55%), linear-gradient(160deg, #0a0d12 0%, #0f1119 100%)",
},
{
id: "ember-pulse",
name: "Ember Pulse",
animated: true,
backgroundSize: "220% 220%, 200% 200%, 100% 100%",
animation: "wp-drift-rev 28s ease-in-out infinite alternate",
css: "radial-gradient(circle at 20% 30%, #5a2a12 0%, transparent 52%), radial-gradient(circle at 80% 75%, #5a1f3a 0%, transparent 55%), linear-gradient(135deg, #0d0f16 0%, #161019 100%)",
},
{
id: "lagoon",
name: "Lagoon",
animated: true,
backgroundSize: "230% 230%, 200% 200%, 100% 100%",
animation: "wp-drift 38s ease-in-out infinite alternate",
css: "radial-gradient(circle at 70% 20%, #0e4f5c 0%, transparent 55%), radial-gradient(circle at 25% 80%, #123a6b 0%, transparent 55%), linear-gradient(150deg, #07090f 0%, #0a1018 100%)",
},
];
// Theme tokens exposed to AI-generated apps as a plain JSON-serialisable object,
// and embedded in the generation system prompt so apps style themselves on-brand.
export function tokensAsCss(t: DesignTokens): Record<string, string> {
return {
"--bg": t.color.bg,
"--bg-elevated": t.color.bgElevated,
"--glass": t.color.glass,
"--glass-border": t.color.glassBorder,
"--text": t.color.text,
"--text-muted": t.color.textMuted,
"--accent": t.color.accent,
"--accent-soft": t.color.accentSoft,
"--accent-text": t.color.accentText,
"--danger": t.color.danger,
"--success": t.color.success,
"--ring": t.color.ring,
"--radius-sm": t.radius.sm,
"--radius-md": t.radius.md,
"--radius-lg": t.radius.lg,
"--font-sans": t.font.sans,
"--font-mono": t.font.mono,
};
}

98
src/index.css Normal file
View File

@ -0,0 +1,98 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
* {
box-sizing: border-box;
}
html,
body,
#root {
height: 100%;
margin: 0;
padding: 0;
}
body {
font-family: Inter, system-ui, -apple-system, sans-serif;
overflow: hidden;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
background: #08090d;
}
/* Subtle, on-brand scrollbars everywhere (incl. generated apps). */
::-webkit-scrollbar {
width: 9px;
height: 9px;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.16);
border-radius: 999px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.28);
}
::-webkit-scrollbar-track {
background: transparent;
}
button {
font-family: inherit;
}
.dock-pulse {
animation: dockpulse 1.1s ease-in-out infinite;
}
@keyframes dockpulse {
0%,
100% {
box-shadow: 0 0 0 0 rgba(245, 166, 35, 0.5);
}
50% {
box-shadow: 0 0 0 8px rgba(245, 166, 35, 0);
}
}
/* Animated wallpapers: drift layered gradients. backgroundSize > 100% (set per
wallpaper) gives the radial layers room to move; the linear base stays put. */
@keyframes wp-drift {
0% {
background-position: 0% 50%;
}
100% {
background-position: 100% 50%;
}
}
@keyframes wp-drift-rev {
0% {
background-position: 100% 0%;
}
100% {
background-position: 0% 100%;
}
}
/* Respect users who prefer less motion — freeze animated wallpapers. */
@media (prefers-reduced-motion: reduce) {
.wallpaper-animated {
animation: none !important;
}
}
.builder-shimmer {
background: linear-gradient(90deg, var(--text-muted), var(--accent), var(--text-muted));
background-size: 200% auto;
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
animation: shimmer 2s linear infinite;
}
@keyframes shimmer {
0% {
background-position: 0% center;
}
100% {
background-position: 200% center;
}
}

340
src/kernel/ai.ts Normal file
View File

@ -0,0 +1,340 @@
// Multi-provider LLM client (direct-from-browser for local dev).
// Supports Anthropic (Messages API) and OpenAI-compatible providers
// (OpenAI, OpenRouter, Grok/xAI, Cerebras). Credentials are configured at
// runtime in Settings and persisted in IndexedDB — never hard-coded.
import type { AppManifest, Size } from "./types";
import type { DesignTokens } from "../design/tokens";
import { tokensAsCss } from "../design/tokens";
import { getSetting, setSetting } from "./db";
import {
PROVIDERS,
emptyConfig,
modelFor,
endpointFor,
type AiConfig,
type ProviderId,
} from "./providers";
const SETTINGS_KEY = "aiConfig";
// ---------- runtime config ----------
// In-memory cache. Seeded from env so an existing .env keeps working, then
// overlaid with whatever the user persisted in Settings.
let config: AiConfig = seedFromEnv();
function seedFromEnv(): AiConfig {
const cfg = emptyConfig();
const envKey = import.meta.env.VITE_ANTHROPIC_API_KEY as string | undefined;
const envUrl = import.meta.env.VITE_ANTHROPIC_API_URL as string | undefined;
const envModel = import.meta.env.VITE_ANTHROPIC_MODEL as string | undefined;
if (envKey) cfg.keys.anthropic = envKey;
if (envUrl) cfg.baseUrls.anthropic = envUrl;
if (envModel) cfg.models.anthropic = envModel;
return cfg;
}
/** Load persisted config from IndexedDB and merge over the env seed. */
export async function loadAiConfig(): Promise<AiConfig> {
const saved = await getSetting<Partial<AiConfig> | null>(SETTINGS_KEY, null);
if (saved) {
config = {
provider: saved.provider ?? config.provider,
keys: { ...config.keys, ...(saved.keys ?? {}) },
models: { ...config.models, ...(saved.models ?? {}) },
baseUrls: { ...config.baseUrls, ...(saved.baseUrls ?? {}) },
};
}
return config;
}
export function getAiConfig(): AiConfig {
return config;
}
/** Update + persist the AI configuration. */
export async function setAiConfig(next: AiConfig): Promise<void> {
config = next;
await setSetting(SETTINGS_KEY, next);
}
export function currentModel(): string {
return modelFor(config);
}
export function getApiKey(id: ProviderId = config.provider): string | undefined {
return config.keys[id]?.trim() || undefined;
}
export function aiAvailable(): boolean {
return Boolean(getApiKey());
}
// ---------- transport ----------
// In dev we route through a same-origin proxy that forwards to the provider
// (see vite.config.ts). This sidesteps browser CORS for every provider. In a
// production build there is no proxy, so we call the endpoint directly.
function transport(endpoint: string): { url: string; headers: Record<string, string> } {
if (import.meta.env.DEV) {
return { url: "/__ai/proxy", headers: { "x-llm-url": endpoint } };
}
return { url: endpoint, headers: {} };
}
interface Msg {
role: "user" | "assistant";
content: string;
}
function authHeaders(): Record<string, string> {
const provider = PROVIDERS[config.provider];
const key = getApiKey();
if (!key) throw new Error(`AI unavailable: add a ${provider.label} API key in Settings`);
if (provider.kind === "anthropic") {
return {
"x-api-key": key,
"anthropic-version": "2023-06-01",
"anthropic-dangerous-direct-browser-access": "true",
};
}
const h: Record<string, string> = { authorization: `Bearer ${key}` };
if (config.provider === "openrouter") {
h["http-referer"] = "https://neon-noodle-os.local";
h["x-title"] = "NEON NOODLE OS";
}
return h;
}
// Clamp the requested output tokens to the user's configured cap (if any).
// Providers count max_tokens toward per-minute limits, so this prevents 413s.
function capTokens(requested: number): number {
const cap = config.maxTokens;
return cap && cap > 0 ? Math.min(requested, cap) : requested;
}
function buildBody(messages: Msg[], opts: { system?: string; maxTokens?: number }, stream: boolean) {
const provider = PROVIDERS[config.provider];
const model = currentModel();
const max = capTokens(opts.maxTokens ?? 4096);
if (provider.kind === "anthropic") {
return { model, max_tokens: max, system: opts.system, messages, stream };
}
// openai-compatible: system becomes the first message
const msgs = opts.system ? [{ role: "system", content: opts.system }, ...messages] : messages;
return { model, max_tokens: max, messages: msgs, stream };
}
function fetchOpts(messages: Msg[], opts: { system?: string; maxTokens?: number }, stream: boolean) {
const endpoint = endpointFor(config);
const t = transport(endpoint);
return {
url: t.url,
init: {
method: "POST",
headers: {
"content-type": "application/json",
...authHeaders(),
...t.headers,
},
body: JSON.stringify(buildBody(messages, opts, stream)),
} as RequestInit,
};
}
function errLabel() {
return PROVIDERS[config.provider].label;
}
export async function chat(
messages: Msg[],
opts: { system?: string; maxTokens?: number } = {}
): Promise<string> {
const { url, init } = fetchOpts(messages, opts, false);
const res = await fetch(url, init);
if (!res.ok) {
const text = await res.text();
throw new Error(`${errLabel()} API ${res.status}: ${text}`);
}
const data = await res.json();
if (PROVIDERS[config.provider].kind === "anthropic") {
return (data.content ?? [])
.filter((b: { type: string }) => b.type === "text")
.map((b: { text: string }) => b.text)
.join("");
}
return data.choices?.[0]?.message?.content ?? "";
}
export async function chatStream(
messages: Msg[],
onToken: (chunk: string) => void,
opts: { system?: string; maxTokens?: number } = {}
): Promise<string> {
const { url, init } = fetchOpts(messages, opts, true);
const res = await fetch(url, init);
if (!res.ok || !res.body) {
const text = await res.text().catch(() => "");
throw new Error(`${errLabel()} API ${res.status}: ${text}`);
}
const anthropic = PROVIDERS[config.provider].kind === "anthropic";
const reader = res.body.getReader();
const decoder = new TextDecoder();
let full = "";
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed.startsWith("data:")) continue;
const payload = trimmed.slice(5).trim();
if (payload === "[DONE]") continue;
try {
const evt = JSON.parse(payload);
let chunk = "";
if (anthropic) {
if (evt.type === "content_block_delta" && evt.delta?.type === "text_delta") {
chunk = evt.delta.text;
}
} else {
chunk = evt.choices?.[0]?.delta?.content ?? "";
}
if (chunk) {
full += chunk;
onToken(chunk);
}
} catch {
// ignore keep-alive / partial frames
}
}
}
return full;
}
// ---------- App generation ----------
function appSystemPrompt(tokens: DesignTokens): string {
const css = tokensAsCss(tokens);
const cssList = Object.entries(css)
.map(([k, v]) => ` ${k}: ${v};`)
.join("\n");
return `You are the app-generation kernel of "NEON NOODLE OS", an AI-native web desktop.
You build small, complete, BEAUTIFUL React apps on demand. They run inside a sandboxed
runtime, not a fresh page. Follow these rules EXACTLY.
OUTPUT FORMAT return ONE JSON object and NOTHING else. No markdown, no backticks, no prose:
{
"name": "Short App Name",
"icon": "single emoji",
"category": "productivity|utility|media|developer|game|other",
"defaultSize": { "w": <number 320-900>, "h": <number 240-700> },
"description": "one sentence",
"code": "<a React component source string, see below>"
}
THE CODE FIELD:
- Plain JavaScript + JSX (it is Babel-transformed at runtime). No TypeScript types, no imports, no exports.
- Define exactly one component named "App" as a function: function App(props) { ... }
Do NOT call ReactDOM. Do NOT add "export default". The runtime renders <App os={os} /> for you.
- React and its hooks are in scope as globals: React, useState, useEffect, useRef, useMemo, useCallback, useReducer.
- The OS SDK is in scope as the global "os" AND passed as props.os. Use it for everything external:
os.fs.read/write/list/delete/mkdir/exists virtual filesystem (async, returns Promises)
os.storage.get(key)/set(key,val)/remove/keys per-app key/value persistence (async)
os.ai.ask(prompt, {system}) call the AI (async). os.ai.available is a boolean.
os.ai.stream(prompt, onToken, {system}) streamed AI.
os.notify(title, body) system notification
os.net.proxyUrl(url) same-origin URL for embedding an external page in an <iframe>
os.window.setTitle(t) / os.window.close() / os.window.setSize({w,h})
os.theme.tokens the CSS variables below (already applied to your root)
- FORBIDDEN: fetch, XMLHttpRequest, WebSocket, localStorage, document.cookie, window.open,
eval, new Function, importing libraries, <script>. Use only the SDK above.
- WEB BROWSER apps: an <iframe> is allowed ONLY for this case, and its src MUST be
os.net.proxyUrl(theUrl) never a raw external URL. Most sites send X-Frame-Options/CSP
that block direct embedding; os.net.proxyUrl routes through the OS proxy which strips
those headers. Always normalise user input to include https:// before passing it in.
- Persist meaningful state with os.storage so it survives reload.
STYLING the app feels native because it uses the design tokens. They are available as CSS
variables on your root element (already injected). Use them via var(--token):
${cssList}
Guidelines:
- Root element: style with background: transparent, color: var(--text), font-family: var(--font-sans),
height:100%, display:flex, flex-direction:column, padding ~16px, box-sizing:border-box, overflow:auto.
- Buttons: background: var(--accent); color: var(--accent-text); border:none; border-radius: var(--radius-md);
padding: 8px 14px; font-weight:600; cursor:pointer. Secondary buttons: background: var(--bg-elevated); color: var(--text).
- Inputs: background: var(--bg-elevated); color: var(--text); border:1px solid var(--glass-border);
border-radius: var(--radius-md); padding: 8px 10px; outline:none.
- Cards/panels: background: var(--glass); border:1px solid var(--glass-border); border-radius: var(--radius-lg).
- Use generous spacing, rounded corners, and the amber accent sparingly for emphasis.
- Mono font (var(--font-mono)) for code/numbers where fitting.
- Make it genuinely functional and polished this is a flagship demo. Handle empty/error states.
Return only the JSON object.`;
}
export interface GeneratedApp {
name: string;
icon: string;
category: AppManifest["category"];
defaultSize: Size;
description?: string;
code: string;
}
function extractJson(raw: string): GeneratedApp {
let text = raw.trim();
// strip accidental code fences
text = text.replace(/^```(?:json)?/i, "").replace(/```$/i, "").trim();
// find the outermost JSON object
const start = text.indexOf("{");
const end = text.lastIndexOf("}");
if (start === -1 || end === -1) throw new Error("AI returned no JSON object");
const slice = text.slice(start, end + 1);
const parsed = JSON.parse(slice) as GeneratedApp;
if (!parsed.code || !parsed.name) throw new Error("AI JSON missing required fields");
if (!parsed.defaultSize || !parsed.defaultSize.w) parsed.defaultSize = { w: 520, h: 420 };
if (!parsed.icon) parsed.icon = "✦";
if (!parsed.category) parsed.category = "other";
return parsed;
}
export async function generateApp(
request: string,
tokens: DesignTokens
): Promise<GeneratedApp> {
const raw = await chat(
[{ role: "user", content: `Build this app: ${request}` }],
{ system: appSystemPrompt(tokens), maxTokens: 8000 }
);
return extractJson(raw);
}
// Self-healing: feed the broken code + error back and ask for a corrected version.
export async function repairApp(
app: { name: string; code: string },
error: string,
tokens: DesignTokens
): Promise<GeneratedApp> {
const raw = await chat(
[
{
role: "user",
content: `This generated app named "${app.name}" crashed at runtime with the error:
${error}
Here is its current code:
${app.code}
Return the SAME JSON object format as before, with a corrected "code" field that fixes the crash. Keep the app's purpose and design identical. Return only the JSON object.`,
},
],
{ system: appSystemPrompt(tokens), maxTokens: 8000 }
);
return extractJson(raw);
}

242
src/kernel/appRuntime.tsx Normal file
View File

@ -0,0 +1,242 @@
import React, {
Component,
useState,
useEffect,
useRef,
useMemo,
useCallback,
useReducer,
type ReactNode,
} from "react";
import { transform } from "@babel/standalone";
import type { AppManifest, OSApi } from "./types";
import { useOS } from "./store";
// Compiles a generated app's source string into a React component.
// The code is transformed (JSX -> JS) and evaluated in a controlled scope that
// exposes ONLY React, the standard hooks, and the injected `os` SDK.
// There is no access to fetch/window/document/eval except what we choose to pass.
interface CompileResult {
Component?: React.ComponentType<{ os: OSApi }>;
error?: string;
}
// Static guard against forbidden globals in generated code.
// NOTE: this is defense-in-depth, NOT a real security boundary — generated code
// runs in the page's own realm via `new Function`, so a determined payload can
// still escape (e.g. via deep prototype tricks). True isolation would require an
// iframe/worker. The list below blocks the cheap, obvious exfiltration paths —
// most importantly `indexedDB`, which otherwise lets app code read the settings
// store where the user's API keys live.
const FORBIDDEN = [
/\bfetch\s*\(/,
/\bXMLHttpRequest\b/,
/\bWebSocket\b/,
/\bimport\s*\(/,
/\bimport\s*\.\s*meta\b/,
/\blocalStorage\b/,
/\bsessionStorage\b/,
/\bindexedDB\b/,
/\bdocument\.cookie\b/,
/\bwindow\.open\b/,
/\bglobalThis\b/,
/\bnew\s+Function\b/,
/\bFunction\s*\(/,
/\beval\s*\(/,
/<\s*script/i,
// <iframe> is intentionally allowed: it's the only way to build a web-browser
// app, and its src must go through os.net.proxyUrl (same-origin OS proxy).
];
export function compileApp(code: string): CompileResult {
for (const rx of FORBIDDEN) {
if (rx.test(code)) {
return { error: `Blocked: generated code uses a forbidden API (${rx}).` };
}
}
try {
const transformed = transform(code, {
presets: ["react"],
// no module transform — we run it as a plain script body
}).code;
if (!transformed) return { error: "Empty compile output." };
// Build a function whose scope contains exactly the allowed globals.
// The app's code defines `function App(...)`; we return it.
const scopeKeys = [
"React",
"useState",
"useEffect",
"useRef",
"useMemo",
"useCallback",
"useReducer",
];
const factoryBody = `
"use strict";
${transformed}
if (typeof App !== "function") {
throw new Error("Generated code must define a function named 'App'.");
}
return App;
`;
// eslint-disable-next-line no-new-func
const factory = new Function(...scopeKeys, "os", factoryBody);
const Wrapped: React.ComponentType<{ os: OSApi }> = (props) => {
// Build the App component ONCE per os instance. Rebuilding it on every
// render would give it a new identity each time, forcing React to
// remount the subtree — wiping state and re-firing effects in a loop.
const App = useMemo(
() =>
factory(
React,
useState,
useEffect,
useRef,
useMemo,
useCallback,
useReducer,
props.os
) as React.ComponentType<{ os: OSApi }>,
[props.os]
);
return React.createElement(App, { os: props.os });
};
return { Component: Wrapped };
} catch (e) {
return { error: e instanceof Error ? e.message : String(e) };
}
}
// ---- Error boundary with self-heal ----
interface BoundaryProps {
appId: string;
appName: string;
children: ReactNode;
}
interface BoundaryState {
error: Error | null;
}
export class AppErrorBoundary extends Component<BoundaryProps, BoundaryState> {
state: BoundaryState = { error: null };
static getDerivedStateFromError(error: Error): BoundaryState {
return { error };
}
componentDidCatch(error: Error) {
console.error(`App "${this.props.appName}" crashed:`, error);
}
reset = () => this.setState({ error: null });
render() {
if (this.state.error) {
return (
<CrashScreen
appId={this.props.appId}
appName={this.props.appName}
error={this.state.error}
onRetry={this.reset}
/>
);
}
return this.props.children;
}
}
function CrashScreen({
appId,
appName,
error,
onRetry,
}: {
appId: string;
appName: string;
error: Error;
onRetry: () => void;
}) {
const heal = useOS((s) => s.healApp);
const [healing, setHealing] = useState(false);
return (
<div
style={{
height: "100%",
display: "flex",
flexDirection: "column",
gap: 14,
padding: 24,
color: "var(--text)",
fontFamily: "var(--font-sans)",
overflow: "auto",
}}
>
<div style={{ fontSize: 30 }}>💥</div>
<div style={{ fontWeight: 700, fontSize: 16 }}>{appName} crashed</div>
<pre
style={{
fontFamily: "var(--font-mono)",
fontSize: 12,
background: "var(--bg-elevated)",
border: "1px solid var(--glass-border)",
borderRadius: "var(--radius-md)",
padding: 12,
whiteSpace: "pre-wrap",
color: "var(--danger)",
margin: 0,
}}
>
{error.message}
</pre>
<div style={{ display: "flex", gap: 8 }}>
<button
onClick={async () => {
setHealing(true);
await heal(appId, error.message);
setHealing(false);
onRetry();
}}
disabled={healing}
style={{
background: "var(--accent)",
color: "var(--accent-text)",
border: "none",
borderRadius: "var(--radius-md)",
padding: "9px 16px",
fontWeight: 600,
cursor: healing ? "wait" : "pointer",
}}
>
{healing ? "Healing…" : "🩹 Let AI fix it"}
</button>
<button
onClick={onRetry}
style={{
background: "var(--bg-elevated)",
color: "var(--text)",
border: "1px solid var(--glass-border)",
borderRadius: "var(--radius-md)",
padding: "9px 16px",
fontWeight: 600,
cursor: "pointer",
}}
>
Retry
</button>
</div>
</div>
);
}
// Memoised compile cache so we don't re-transform on every render.
const cache = new Map<string, CompileResult>();
export function getCompiled(app: AppManifest, version: number): CompileResult {
const key = `${app.id}:${version}`;
let res = cache.get(key);
if (!res) {
res = compileApp(app.code ?? "");
cache.set(key, res);
}
return res;
}

25
src/kernel/bus.ts Normal file
View File

@ -0,0 +1,25 @@
// Tiny synchronous event bus for inter-process (inter-app) messaging.
type Handler = (payload: unknown) => void;
const channels = new Map<string, Set<Handler>>();
export const bus = {
emit(channel: string, payload: unknown) {
channels.get(channel)?.forEach((h) => {
try {
h(payload);
} catch (e) {
console.error(`bus handler for "${channel}" threw`, e);
}
});
},
on(channel: string, handler: Handler): () => void {
let set = channels.get(channel);
if (!set) {
set = new Set();
channels.set(channel, set);
}
set.add(handler);
return () => set!.delete(handler);
},
};

77
src/kernel/db.ts Normal file
View File

@ -0,0 +1,77 @@
import { openDB, type DBSchema, type IDBPDatabase } from "idb";
import type { AppManifest, FsNode } from "./types";
interface OSDB extends DBSchema {
fs: {
key: string; // path
value: FsNode;
};
apps: {
key: string; // appId
value: AppManifest;
};
// Per-app key/value storage. Key is `${appId}:${key}`.
appstore: {
key: string;
value: { k: string; appId: string; value: unknown };
indexes: { byApp: string };
};
// System-level settings (theme, wallpaper, etc.)
settings: {
key: string;
value: unknown;
};
}
let dbPromise: Promise<IDBPDatabase<OSDB>> | null = null;
export function db(): Promise<IDBPDatabase<OSDB>> {
if (!dbPromise) {
dbPromise = openDB<OSDB>("fluid-os", 1, {
upgrade(database) {
if (!database.objectStoreNames.contains("fs")) {
database.createObjectStore("fs", { keyPath: "path" });
}
if (!database.objectStoreNames.contains("apps")) {
database.createObjectStore("apps", { keyPath: "id" });
}
if (!database.objectStoreNames.contains("appstore")) {
const store = database.createObjectStore("appstore", { keyPath: "k" });
store.createIndex("byApp", "appId");
}
if (!database.objectStoreNames.contains("settings")) {
database.createObjectStore("settings");
}
},
});
}
return dbPromise;
}
// ---- Settings helpers ----
export async function getSetting<T>(key: string, fallback: T): Promise<T> {
const d = await db();
const v = await d.get("settings", key);
return (v as T) ?? fallback;
}
export async function setSetting(key: string, value: unknown): Promise<void> {
const d = await db();
await d.put("settings", value, key);
}
// ---- App persistence ----
export async function persistApp(app: AppManifest): Promise<void> {
// Never persist live component references — only serialisable data.
const { component, ...rest } = app;
void component;
const d = await db();
await d.put("apps", rest as AppManifest);
}
export async function loadApps(): Promise<AppManifest[]> {
const d = await db();
return d.getAll("apps");
}
export async function removeApp(id: string): Promise<void> {
const d = await db();
await d.delete("apps", id);
}

136
src/kernel/fs.ts Normal file
View File

@ -0,0 +1,136 @@
import { db } from "./db";
import type { FsNode } from "./types";
// Virtual filesystem backed by IndexedDB. Paths are POSIX-style absolute strings.
function now() {
return Date.now();
}
export function normalize(path: string): string {
if (!path.startsWith("/")) path = "/" + path;
// collapse duplicate slashes, drop trailing slash (except root)
path = path.replace(/\/+/g, "/");
if (path.length > 1 && path.endsWith("/")) path = path.slice(0, -1);
return path;
}
export function parentOf(path: string): string {
const p = normalize(path);
if (p === "/") return "/";
const idx = p.lastIndexOf("/");
return idx === 0 ? "/" : p.slice(0, idx);
}
export function basename(path: string): string {
const p = normalize(path);
if (p === "/") return "/";
return p.slice(p.lastIndexOf("/") + 1);
}
export const fs = {
async exists(path: string): Promise<boolean> {
const d = await db();
return (await d.get("fs", normalize(path))) != null;
},
async stat(path: string): Promise<FsNode | null> {
const d = await db();
return (await d.get("fs", normalize(path))) ?? null;
},
async read(path: string): Promise<string | null> {
const node = await this.stat(path);
if (!node || node.type !== "file") return null;
return node.content ?? "";
},
async mkdir(path: string): Promise<void> {
const p = normalize(path);
if (p === "/") return;
const d = await db();
// ensure ancestors
const parent = parentOf(p);
if (parent !== "/" && !(await d.get("fs", parent))) {
await this.mkdir(parent);
}
const existing = await d.get("fs", p);
if (existing) {
if (existing.type === "file") throw new Error(`Not a directory: ${p}`);
return;
}
await d.put("fs", { path: p, type: "dir", createdAt: now(), updatedAt: now() });
},
async write(path: string, content: string): Promise<void> {
const p = normalize(path);
const d = await db();
const parent = parentOf(p);
if (parent !== "/" && !(await d.get("fs", parent))) {
await this.mkdir(parent);
}
const existing = await d.get("fs", p);
await d.put("fs", {
path: p,
type: "file",
content,
createdAt: existing?.createdAt ?? now(),
updatedAt: now(),
});
},
async list(path: string): Promise<FsNode[]> {
const p = normalize(path);
const d = await db();
const all = await d.getAll("fs");
return all
.filter((n) => n.path !== p && parentOf(n.path) === p)
.sort((a, b) => {
if (a.type !== b.type) return a.type === "dir" ? -1 : 1;
return basename(a.path).localeCompare(basename(b.path));
});
},
async delete(path: string): Promise<void> {
const p = normalize(path);
const d = await db();
const all = await d.getAll("fs");
// delete node and any descendants
const tx = d.transaction("fs", "readwrite");
for (const n of all) {
if (n.path === p || n.path.startsWith(p + "/")) {
await tx.store.delete(n.path);
}
}
await tx.done;
},
/** Seed a fresh OS with a friendly starter tree (idempotent). */
async seed(): Promise<void> {
const d = await db();
const count = (await d.getAllKeys("fs")).length;
if (count > 0) return;
await this.mkdir("/Documents");
await this.mkdir("/Pictures");
await this.mkdir("/Apps");
await this.write(
"/Documents/welcome.txt",
[
"Welcome to NEON NOODLE OS.",
"",
"This is a real, working desktop in your browser.",
"Press Cmd/Ctrl + Space to open the assistant and try:",
' "build me a pomodoro timer"',
' "I need a calculator"',
"",
"The AI generates the app, installs it into the Dock,",
"and opens it instantly. Everything lives in IndexedDB,",
"so your files and installed apps survive a reload.",
].join("\n")
);
await this.write(
"/Documents/scratch.md",
"# Scratchpad\n\n- [ ] try the Terminal: type `ai write me a haiku`\n- [ ] change the wallpaper in Settings\n"
);
},
};

140
src/kernel/providers.ts Normal file
View File

@ -0,0 +1,140 @@
// LLM provider catalogue. Two API shapes are supported:
// - "anthropic": POST /v1/messages, x-api-key auth, content_block_delta SSE.
// - "openai": POST /v1/chat/completions, Bearer auth, choices[].delta SSE.
// OpenAI / OpenRouter / Grok (xAI) / Groq / Cerebras all speak the OpenAI shape.
// NOTE: "grok" (xAI, api.x.ai) and "groq" (api.groq.com) are different services.
export type ProviderId = "anthropic" | "openai" | "openrouter" | "grok" | "groq" | "cerebras";
export type ApiKind = "anthropic" | "openai";
export interface ProviderDef {
id: ProviderId;
label: string;
kind: ApiKind;
/** Base URL up to (but not including) the endpoint path. */
baseUrl: string;
defaultModel: string;
/** Free-text model field suggestions. */
models: string[];
/** Where the user gets a key. */
keysUrl: string;
keyPlaceholder: string;
}
export const PROVIDERS: Record<ProviderId, ProviderDef> = {
anthropic: {
id: "anthropic",
label: "Anthropic (Claude)",
kind: "anthropic",
baseUrl: "https://api.anthropic.com",
defaultModel: "claude-sonnet-4-6",
models: ["claude-sonnet-4-6", "claude-opus-4-8", "claude-haiku-4-5-20251001"],
keysUrl: "https://console.anthropic.com/settings/keys",
keyPlaceholder: "sk-ant-…",
},
openai: {
id: "openai",
label: "OpenAI",
kind: "openai",
baseUrl: "https://api.openai.com/v1",
defaultModel: "gpt-4o",
models: ["gpt-4o", "gpt-4o-mini", "gpt-4.1", "o4-mini"],
keysUrl: "https://platform.openai.com/api-keys",
keyPlaceholder: "sk-…",
},
openrouter: {
id: "openrouter",
label: "OpenRouter",
kind: "openai",
baseUrl: "https://openrouter.ai/api/v1",
defaultModel: "anthropic/claude-sonnet-4-6",
models: [
"anthropic/claude-sonnet-4-6",
"openai/gpt-4o",
"google/gemini-2.0-flash-001",
"meta-llama/llama-3.3-70b-instruct",
],
keysUrl: "https://openrouter.ai/keys",
keyPlaceholder: "sk-or-…",
},
grok: {
id: "grok",
label: "Grok (xAI)",
kind: "openai",
baseUrl: "https://api.x.ai/v1",
defaultModel: "grok-2-latest",
models: ["grok-2-latest", "grok-2", "grok-beta"],
keysUrl: "https://console.x.ai",
keyPlaceholder: "xai-…",
},
groq: {
id: "groq",
label: "Groq",
kind: "openai",
baseUrl: "https://api.groq.com/openai/v1",
defaultModel: "llama-3.3-70b-versatile",
models: [
"llama-3.3-70b-versatile",
"llama-3.1-8b-instant",
"qwen/qwen3.6-27b",
"qwen/qwen3-32b",
"deepseek-r1-distill-llama-70b",
],
keysUrl: "https://console.groq.com/keys",
keyPlaceholder: "gsk_…",
},
cerebras: {
id: "cerebras",
label: "Cerebras",
kind: "openai",
baseUrl: "https://api.cerebras.ai/v1",
defaultModel: "llama-3.3-70b",
models: ["llama-3.3-70b", "llama3.1-8b", "qwen-3-32b"],
keysUrl: "https://cloud.cerebras.ai",
keyPlaceholder: "csk-…",
},
};
export const PROVIDER_LIST: ProviderDef[] = Object.values(PROVIDERS);
export interface AiConfig {
provider: ProviderId;
/** Per-provider API key. */
keys: Partial<Record<ProviderId, string>>;
/** Per-provider selected model (free text). */
models: Partial<Record<ProviderId, string>>;
/** Optional per-provider base-URL overrides. */
baseUrls: Partial<Record<ProviderId, string>>;
/**
* Optional cap on output tokens (max_tokens). Providers count this reservation
* toward per-minute token limits, so lowering it avoids rate-limit (413) errors
* on free tiers (e.g. Groq's 8000 TPM). Undefined = use each call's default.
*/
maxTokens?: number;
}
export function emptyConfig(): AiConfig {
return { provider: "anthropic", keys: {}, models: {}, baseUrls: {} };
}
export function modelFor(cfg: AiConfig, id: ProviderId = cfg.provider): string {
return cfg.models[id]?.trim() || PROVIDERS[id].defaultModel;
}
export function baseUrlFor(cfg: AiConfig, id: ProviderId = cfg.provider): string {
return cfg.baseUrls[id]?.trim() || PROVIDERS[id].baseUrl;
}
// Resolve the full endpoint URL for a provider's API shape.
export function endpointFor(cfg: AiConfig, id: ProviderId = cfg.provider): string {
const base = baseUrlFor(cfg, id).replace(/\/+$/, "");
if (PROVIDERS[id].kind === "anthropic") {
if (/\/messages$/.test(base)) return base;
if (/\/v1$/.test(base)) return `${base}/messages`;
return `${base}/v1/messages`;
}
// openai-compatible
if (/\/chat\/completions$/.test(base)) return base;
if (/\/v1$/.test(base)) return `${base}/chat/completions`;
return `${base}/v1/chat/completions`;
}

66
src/kernel/registry.ts Normal file
View File

@ -0,0 +1,66 @@
import type { AppManifest } from "./types";
import Files from "../apps/Files";
import Terminal from "../apps/Terminal";
import Settings from "../apps/Settings";
import Assistant from "../apps/Assistant";
import AppBuilder from "../apps/AppBuilder";
// Built-in apps ship as real React components. They are the design reference
// for everything the AI generates.
export const BUILTIN_APPS: AppManifest[] = [
{
id: "assistant",
name: "Assistant",
icon: "✦",
category: "system",
defaultSize: { w: 460, h: 560 },
builtin: true,
component: Assistant,
createdAt: 0,
description: "Chat with the OS AI",
},
{
id: "app-builder",
name: "App Builder",
icon: "⚙️",
category: "developer",
defaultSize: { w: 600, h: 580 },
builtin: true,
component: AppBuilder,
createdAt: 0,
description: "Generate apps with AI",
},
{
id: "files",
name: "Files",
icon: "📁",
category: "system",
defaultSize: { w: 720, h: 480 },
builtin: true,
component: Files,
createdAt: 0,
description: "Virtual filesystem browser",
},
{
id: "terminal",
name: "Terminal",
icon: "▸",
category: "developer",
defaultSize: { w: 640, h: 420 },
builtin: true,
component: Terminal,
createdAt: 0,
description: "Shell with ls, cat, ai…",
},
{
id: "settings",
name: "Settings",
icon: "⚙",
category: "system",
defaultSize: { w: 520, h: 560 },
builtin: true,
component: Settings,
createdAt: 0,
description: "Theme, wallpaper, apps",
},
];

92
src/kernel/sdk.ts Normal file
View File

@ -0,0 +1,92 @@
import type { OSApi, Size } from "./types";
import { fs } from "./fs";
import { db } from "./db";
import { bus } from "./bus";
import { chat, chatStream, aiAvailable } from "./ai";
import { useOS } from "./store";
import { tokensFor, tokensAsCss } from "../design/tokens";
// Builds the sandboxed OS API handed to a single app window.
// Apps may ONLY reach the outside world through this object.
export function makeOSApi(appId: string, windowId: string): OSApi {
const store = () => useOS.getState();
return {
fs: {
read: (p) => fs.read(p),
write: (p, c) => fs.write(p, c),
list: (p) => fs.list(p),
delete: (p) => fs.delete(p),
mkdir: (p) => fs.mkdir(p),
exists: (p) => fs.exists(p),
},
window: {
setTitle: (title) => store().setWindowTitle(windowId, title),
close: () => store().closeWindow(windowId),
setSize: (size: Size) => store().resizeWindow(windowId, size),
minimize: () => store().minimizeWindow(windowId),
},
ai: {
// Getter, not a snapshot: the API key can be configured in Settings after
// an app window is already open, and the app must see the live value.
get available() {
return aiAvailable();
},
ask: (prompt, opts) => chat([{ role: "user", content: prompt }], { system: opts?.system }),
stream: (prompt, onToken, opts) =>
chatStream([{ role: "user", content: prompt }], onToken, { system: opts?.system }),
},
notify: (title, body, icon) => store().notify({ title, body, icon, appId }),
net: {
proxyUrl: (url: string) => {
const u = (url || "").trim();
if (!u) return u;
// In dev we have the /__proxy middleware; in a prod build there is none,
// so fall back to the raw URL (which the target may still block).
if (import.meta.env.DEV && /^https?:\/\//i.test(u)) {
return `/__proxy?url=${encodeURIComponent(u)}`;
}
return u;
},
},
storage: {
async get<T = unknown>(key: string): Promise<T | null> {
const d = await db();
const row = await d.get("appstore", `${appId}:${key}`);
return row ? (row.value as T) : null;
},
async set(key, value) {
const d = await db();
await d.put("appstore", { k: `${appId}:${key}`, appId, value });
},
async remove(key) {
const d = await db();
await d.delete("appstore", `${appId}:${key}`);
},
async keys() {
const d = await db();
const rows = await d.getAllFromIndex("appstore", "byApp", appId);
return rows.map((r) => r.k.slice(appId.length + 1));
},
},
theme: {
get tokens() {
return tokensAsCss(tokensFor(store().themeMode));
},
get mode() {
return store().themeMode;
},
},
bus: {
emit: (channel, payload) => bus.emit(`app:${channel}`, payload),
on: (channel, handler) => bus.on(`app:${channel}`, handler),
},
};
}

440
src/kernel/store.ts Normal file
View File

@ -0,0 +1,440 @@
import { create } from "zustand";
import type { AppManifest, Notification, Position, Size, WindowInstance } from "./types";
import type { ThemeMode } from "../design/tokens";
import { tokensFor } from "../design/tokens";
import { getSetting, setSetting, persistApp, loadApps, removeApp } from "./db";
import { fs } from "./fs";
import { BUILTIN_APPS } from "./registry";
import { generateApp, repairApp, aiAvailable, loadAiConfig, setAiConfig } from "./ai";
import type { AiConfig, ProviderId } from "./providers";
export type BootPhase = "booting" | "locked" | "desktop";
let zCounter = 10;
let winCounter = 0;
interface OSState {
// lifecycle
phase: BootPhase;
bootProgress: number;
ready: boolean;
// theme / look
themeMode: ThemeMode;
wallpaper: string;
// registry + windows
apps: AppManifest[];
windows: WindowInstance[];
notifications: Notification[];
// spotlight / generation status
spotlightOpen: boolean;
launcherOpen: boolean;
generating: { request: string } | null;
// AI configuration (mirrors ai.ts cache so the UI reacts to changes)
aiProvider: ProviderId;
aiReady: boolean;
// ---- actions ----
init: () => Promise<void>;
setBootProgress: (n: number) => void;
unlock: () => void;
setTheme: (mode: ThemeMode) => void;
toggleTheme: () => void;
setWallpaper: (id: string) => void;
getApp: (id: string) => AppManifest | undefined;
openApp: (appId: string) => string | undefined;
closeWindow: (id: string) => void;
focusWindow: (id: string) => void;
moveWindow: (id: string, pos: Position) => void;
resizeWindow: (id: string, size: Size, pos?: Position) => void;
setWindowTitle: (id: string, title: string) => void;
minimizeWindow: (id: string) => void;
toggleMaximize: (id: string) => void;
snapWindow: (id: string, edge: "left" | "right" | "top") => void;
setSpotlight: (open: boolean) => void;
setLauncher: (open: boolean) => void;
saveAiConfig: (cfg: AiConfig) => Promise<void>;
installAndOpen: (request: string) => Promise<string | undefined>;
healApp: (appId: string, error: string) => Promise<void>;
uninstallApp: (appId: string) => Promise<void>;
notify: (n: Omit<Notification, "id" | "createdAt">) => void;
dismissNotification: (id: string) => void;
}
function viewport() {
return {
w: typeof window !== "undefined" ? window.innerWidth : 1280,
h: typeof window !== "undefined" ? window.innerHeight : 800,
};
}
const MENUBAR_H = 36;
const DOCK_RESERVE = 96;
export const useOS = create<OSState>((set, get) => ({
phase: "booting",
bootProgress: 0,
ready: false,
themeMode: "dark",
wallpaper: "ember",
apps: [],
windows: [],
notifications: [],
spotlightOpen: false,
launcherOpen: false,
generating: null,
aiProvider: "anthropic",
aiReady: false,
async init() {
try {
await fs.seed();
const aiCfg = await loadAiConfig();
const themeMode = await getSetting<ThemeMode>("themeMode", "dark");
const wallpaper = await getSetting<string>("wallpaper", "ember");
const persisted = await loadApps();
// Merge built-ins (source of truth for components) with persisted generated apps.
const generated = persisted.filter((a) => !a.builtin);
set({
themeMode,
wallpaper,
apps: [...BUILTIN_APPS, ...generated],
aiProvider: aiCfg.provider,
aiReady: aiAvailable(),
ready: true,
});
} catch (e) {
// Never leave the OS stuck on the boot screen. If IndexedDB / persistence
// is unavailable, boot with built-ins and in-memory defaults instead.
console.error("OS init failed; booting with defaults:", e);
set({
apps: [...BUILTIN_APPS],
aiReady: aiAvailable(),
ready: true,
});
get().notify({
title: "Storage unavailable",
body: "Booted without persistence — files and installed apps won't be saved.",
icon: "⚠️",
});
}
},
setBootProgress(n) {
set({ bootProgress: Math.min(100, n) });
},
unlock() {
set({ phase: "desktop" });
},
setTheme(mode) {
set({ themeMode: mode });
void setSetting("themeMode", mode);
},
toggleTheme() {
const next = get().themeMode === "dark" ? "light" : "dark";
get().setTheme(next);
},
setWallpaper(id) {
set({ wallpaper: id });
void setSetting("wallpaper", id);
},
getApp(id) {
return get().apps.find((a) => a.id === id);
},
openApp(appId) {
const app = get().getApp(appId);
if (!app) return undefined;
// If a window for this app exists and is minimized/exists, focus instead of duplicating
// (we still allow multiple instances via spotlight "new"; default behavior: focus existing).
const existing = get().windows.find((w) => w.appId === appId);
if (existing) {
get().focusWindow(existing.id);
if (existing.minimized) {
set({
windows: get().windows.map((w) =>
w.id === existing.id ? { ...w, minimized: false } : w
),
});
}
return existing.id;
}
const vp = viewport();
const size: Size = {
w: Math.min(app.defaultSize.w, vp.w - 40),
h: Math.min(app.defaultSize.h, vp.h - MENUBAR_H - DOCK_RESERVE),
};
const offset = (get().windows.length % 6) * 28;
const pos: Position = {
x: Math.max(20, Math.round((vp.w - size.w) / 2) + offset - 80),
y: Math.max(MENUBAR_H + 16, Math.round((vp.h - size.h) / 2.6) + offset),
};
const id = `win-${++winCounter}`;
const win: WindowInstance = {
id,
appId,
title: app.name,
pos,
size,
z: ++zCounter,
minimized: false,
maximized: false,
};
set({ windows: [...get().windows, win] });
return id;
},
closeWindow(id) {
set({ windows: get().windows.filter((w) => w.id !== id) });
},
focusWindow(id) {
set({
windows: get().windows.map((w) => (w.id === id ? { ...w, z: ++zCounter } : w)),
});
},
moveWindow(id, pos) {
set({ windows: get().windows.map((w) => (w.id === id ? { ...w, pos } : w)) });
},
resizeWindow(id, size, pos) {
set({
windows: get().windows.map((w) =>
w.id === id ? { ...w, size, pos: pos ?? w.pos } : w
),
});
},
setWindowTitle(id, title) {
set({ windows: get().windows.map((w) => (w.id === id ? { ...w, title } : w)) });
},
minimizeWindow(id) {
set({
windows: get().windows.map((w) =>
w.id === id ? { ...w, minimized: !w.minimized } : w
),
});
},
toggleMaximize(id) {
const w = get().windows.find((x) => x.id === id);
if (!w) return;
const vp = viewport();
if (w.maximized) {
set({
windows: get().windows.map((x) =>
x.id === id
? {
...x,
maximized: false,
pos: x.restore?.pos ?? x.pos,
size: x.restore?.size ?? x.size,
z: ++zCounter,
}
: x
),
});
} else {
set({
windows: get().windows.map((x) =>
x.id === id
? {
...x,
maximized: true,
restore: { pos: x.pos, size: x.size },
pos: { x: 8, y: MENUBAR_H + 8 },
size: { w: vp.w - 16, h: vp.h - MENUBAR_H - DOCK_RESERVE - 8 },
z: ++zCounter,
}
: x
),
});
}
},
snapWindow(id, edge) {
const vp = viewport();
const top = MENUBAR_H + 8;
const usableH = vp.h - MENUBAR_H - DOCK_RESERVE - 8;
let pos: Position, size: Size;
if (edge === "left") {
pos = { x: 8, y: top };
size = { w: vp.w / 2 - 12, h: usableH };
} else if (edge === "right") {
pos = { x: vp.w / 2 + 4, y: top };
size = { w: vp.w / 2 - 12, h: usableH };
} else {
pos = { x: 8, y: top };
size = { w: vp.w - 16, h: usableH };
}
set({
windows: get().windows.map((w) =>
w.id === id
? { ...w, pos, size, maximized: false, restore: { pos: w.pos, size: w.size }, z: ++zCounter }
: w
),
});
},
setSpotlight(open) {
set({ spotlightOpen: open });
},
setLauncher(open) {
set({ launcherOpen: open });
},
async saveAiConfig(cfg) {
await setAiConfig(cfg);
set({ aiProvider: cfg.provider, aiReady: aiAvailable() });
},
async installAndOpen(request) {
const trimmed = request.trim();
if (!trimmed) return undefined;
// 1) Try to match an existing app by name / keyword.
const lower = trimmed.toLowerCase();
const match = get().apps.find(
(a) =>
lower.includes(a.name.toLowerCase()) ||
a.name.toLowerCase().includes(lower.replace(/^(open|launch|start|öffne)\s+/i, ""))
);
if (match) {
set({ spotlightOpen: false });
return get().openApp(match.id);
}
if (!aiAvailable()) {
get().notify({
title: "AI unavailable",
body: "Add an API key in Settings → Language Model to generate apps.",
icon: "⚠️",
});
return undefined;
}
// 2) Generate a new app.
set({ spotlightOpen: false, generating: { request: trimmed } });
get().notify({ title: "Generating app…", body: trimmed, icon: "✨" });
try {
const tokens = tokensFor(get().themeMode);
const gen = await generateApp(trimmed, tokens);
const id = `gen-${Date.now().toString(36)}-${Math.floor(zCounter)}`;
const app: AppManifest = {
id,
name: gen.name,
icon: gen.icon,
category: gen.category,
defaultSize: gen.defaultSize,
description: gen.description,
code: gen.code,
builtin: false,
createdAt: Date.now(),
};
await persistApp(app);
set({ apps: [...get().apps, app], generating: null });
get().notify({ title: `${app.icon} ${app.name} installed`, body: "Opening…", icon: app.icon });
return get().openApp(id);
} catch (e) {
set({ generating: null });
get().notify({
title: "Generation failed",
body: e instanceof Error ? e.message : String(e),
icon: "⚠️",
});
return undefined;
}
},
async healApp(appId, error) {
const app = get().getApp(appId);
if (!app || !app.code) return;
if (!aiAvailable()) {
get().notify({ title: "Cannot self-heal", body: "AI unavailable.", icon: "⚠️" });
return;
}
get().notify({ title: `Healing ${app.name}`, icon: "🩹" });
try {
const tokens = tokensFor(get().themeMode);
const fixed = await repairApp({ name: app.name, code: app.code }, error, tokens);
const updated: AppManifest = {
...app,
code: fixed.code,
defaultSize: fixed.defaultSize ?? app.defaultSize,
};
await persistApp(updated);
set({ apps: get().apps.map((a) => (a.id === appId ? updated : a)) });
// bounce the windows so error boundaries reset
const wins = get().windows.filter((w) => w.appId === appId);
get().notify({ title: `${app.name} repaired`, icon: "✅" });
// force remount by toggling minimize off / focusing
wins.forEach((w) => get().focusWindow(w.id));
bumpRuntimeVersion(appId);
} catch (e) {
get().notify({
title: "Self-heal failed",
body: e instanceof Error ? e.message : String(e),
icon: "⚠️",
});
}
},
async uninstallApp(appId) {
const app = get().getApp(appId);
if (!app || app.builtin) return;
await removeApp(appId);
set({
apps: get().apps.filter((a) => a.id !== appId),
windows: get().windows.filter((w) => w.appId !== appId),
});
get().notify({ title: `Uninstalled ${app.name}`, icon: "🗑️" });
},
notify(n) {
const note: Notification = {
...n,
id: `note-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
createdAt: Date.now(),
};
set({ notifications: [note, ...get().notifications].slice(0, 6) });
const id = note.id;
setTimeout(() => get().dismissNotification(id), 5200);
},
dismissNotification(id) {
set({ notifications: get().notifications.filter((n) => n.id !== id) });
},
}));
// Runtime version map: bumping forces a generated app's windows to remount
// (used after self-heal so the error boundary re-renders fresh code).
const runtimeVersions = new Map<string, number>();
const versionListeners = new Set<() => void>();
export function runtimeVersionOf(appId: string): number {
return runtimeVersions.get(appId) ?? 0;
}
export function bumpRuntimeVersion(appId: string) {
runtimeVersions.set(appId, runtimeVersionOf(appId) + 1);
versionListeners.forEach((l) => l());
}
export function onRuntimeVersionChange(l: () => void): () => void {
versionListeners.add(l);
return () => versionListeners.delete(l);
}

128
src/kernel/types.ts Normal file
View File

@ -0,0 +1,128 @@
import type { ComponentType } from "react";
export interface Size {
w: number;
h: number;
}
export interface Position {
x: number;
y: number;
}
export type AppCategory =
| "system"
| "productivity"
| "utility"
| "media"
| "developer"
| "game"
| "other";
// An app definition. Built-in apps ship a React component directly;
// AI-generated apps ship source `code` that the runtime transforms.
export interface AppManifest {
id: string;
name: string;
icon: string; // emoji or single glyph
category: AppCategory;
defaultSize: Size;
builtin: boolean;
/** AI-generated source (a default-exported React component as a string). */
code?: string;
/** Built-in component (not persisted). */
component?: ComponentType<AppProps>;
createdAt: number;
/** True while the AI is still generating this app. */
generating?: boolean;
description?: string;
}
// Props injected into every app (built-in or generated).
export interface AppProps {
os: OSApi;
windowId: string;
}
// A live window instance.
export interface WindowInstance {
id: string;
appId: string;
title: string;
pos: Position;
size: Size;
z: number;
minimized: boolean;
maximized: boolean;
/** snapshot of pre-maximize geometry for restore */
restore?: { pos: Position; size: Size };
}
export interface FsNode {
path: string; // absolute, e.g. "/Documents/notes.txt"
type: "file" | "dir";
content?: string;
createdAt: number;
updatedAt: number;
}
export interface Notification {
id: string;
title: string;
body?: string;
icon?: string;
appId?: string;
createdAt: number;
}
// The System SDK handed to every app. Apps may ONLY touch the OS through this.
export interface OSApi {
fs: {
read(path: string): Promise<string | null>;
write(path: string, content: string): Promise<void>;
list(path: string): Promise<FsNode[]>;
delete(path: string): Promise<void>;
mkdir(path: string): Promise<void>;
exists(path: string): Promise<boolean>;
};
window: {
setTitle(title: string): void;
close(): void;
setSize(size: Size): void;
minimize(): void;
};
ai: {
/** Free-form chat completion. Returns the assistant text. */
ask(prompt: string, opts?: { system?: string }): Promise<string>;
/** Streamed chat completion. */
stream(
prompt: string,
onToken: (chunk: string) => void,
opts?: { system?: string }
): Promise<string>;
available: boolean;
};
notify(title: string, body?: string, icon?: string): void;
net: {
/**
* Returns a same-origin URL that proxies an external page through the OS,
* stripping X-Frame-Options / CSP so it can be embedded in an <iframe>.
* Use this for any in-OS "web browser" never put a raw external URL in an iframe.
*/
proxyUrl(url: string): string;
};
storage: {
get<T = unknown>(key: string): Promise<T | null>;
set(key: string, value: unknown): Promise<void>;
remove(key: string): Promise<void>;
keys(): Promise<string[]>;
};
theme: {
tokens: Record<string, string>;
mode: "dark" | "light";
};
/** Inter-process message bus. */
bus: {
emit(channel: string, payload: unknown): void;
on(channel: string, handler: (payload: unknown) => void): () => void;
};
}

10
src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

53
src/system-ui/AppHost.tsx Normal file
View File

@ -0,0 +1,53 @@
import React, { useMemo, useSyncExternalStore } from "react";
import type { AppManifest } from "../kernel/types";
import { makeOSApi } from "../kernel/sdk";
import {
AppErrorBoundary,
getCompiled,
} from "../kernel/appRuntime";
import { onRuntimeVersionChange, runtimeVersionOf } from "../kernel/store";
// Renders a single app instance inside a window body.
// Built-in apps render their component directly; generated apps are compiled.
export function AppHost({ app, windowId }: { app: AppManifest; windowId: string }) {
const version = useSyncExternalStore(
onRuntimeVersionChange,
() => runtimeVersionOf(app.id)
);
const os = useMemo(() => makeOSApi(app.id, windowId), [app.id, windowId]);
// Only meaningful for generated apps; cheap no-op for built-ins (cached).
const compiled = useMemo(
() => (app.builtin ? null : getCompiled(app, version)),
[app, version]
);
if (app.builtin && app.component) {
const C = app.component;
return (
<AppErrorBoundary appId={app.id} appName={app.name}>
<C os={os} windowId={windowId} />
</AppErrorBoundary>
);
}
// generated app
if (!compiled) return null;
if (compiled.error) {
return (
<AppErrorBoundary appId={app.id} appName={app.name}>
<CompileError message={compiled.error} />
</AppErrorBoundary>
);
}
const Generated = compiled.Component!;
return (
<AppErrorBoundary appId={app.id} appName={app.name} key={version}>
<Generated os={os} />
</AppErrorBoundary>
);
}
function CompileError({ message }: { message: string }): React.ReactElement {
// Thrown so the error boundary picks it up and offers self-heal.
throw new Error("Compile error: " + message);
}

94
src/system-ui/Boot.tsx Normal file
View File

@ -0,0 +1,94 @@
import { useEffect, useState } from "react";
import { motion } from "framer-motion";
const STEPS = [
"Igniting kernel…",
"Mounting virtual filesystem…",
"Loading installed apps…",
"Linking AI core…",
"Composing desktop…",
];
export function Boot({ onDone }: { onDone: () => void }) {
const [progress, setProgress] = useState(0);
const [step, setStep] = useState(0);
useEffect(() => {
let p = 0;
const t = setInterval(() => {
p += Math.random() * 16 + 7;
const clamped = Math.min(100, p);
setProgress(clamped);
setStep(Math.min(STEPS.length - 1, Math.floor((clamped / 100) * STEPS.length)));
if (clamped >= 100) {
clearInterval(t);
setTimeout(onDone, 450);
}
}, 240);
return () => clearInterval(t);
}, [onDone]);
return (
<div
style={{
position: "fixed",
inset: 0,
background: "radial-gradient(circle at 50% 40%, #1a1410 0%, #08090d 70%)",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 30,
color: "#ECE9E2",
fontFamily: "Inter, system-ui, sans-serif",
}}
>
<motion.div
initial={{ scale: 0.7, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.8, ease: "easeOut" }}
style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 16 }}
>
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 8, repeat: Infinity, ease: "linear" }}
style={{
fontSize: 72,
filter: "drop-shadow(0 0 28px rgba(245,166,35,0.6))",
color: "#F5A623",
}}
>
</motion.div>
<div style={{ fontSize: 30, fontWeight: 700, letterSpacing: 1 }}>NEON NOODLE OS</div>
<div style={{ fontSize: 12, color: "#9A968C", fontFamily: "'JetBrains Mono', monospace" }}>
AI-native desktop
</div>
</motion.div>
<div style={{ width: 260, display: "flex", flexDirection: "column", gap: 10 }}>
<div
style={{
height: 4,
borderRadius: 999,
background: "rgba(255,255,255,0.08)",
overflow: "hidden",
}}
>
<motion.div
animate={{ width: `${progress}%` }}
transition={{ ease: "easeOut" }}
style={{
height: "100%",
background: "linear-gradient(90deg, #F5A623, #ffce7a)",
boxShadow: "0 0 12px rgba(245,166,35,0.7)",
}}
/>
</div>
<div style={{ fontSize: 11.5, color: "#9A968C", fontFamily: "'JetBrains Mono', monospace", height: 16 }}>
{STEPS[step]}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,82 @@
import { useEffect } from "react";
import { motion } from "framer-motion";
export interface MenuItem {
label: string;
icon?: string;
onClick: () => void;
danger?: boolean;
}
export function ContextMenu({
x,
y,
items,
onClose,
}: {
x: number;
y: number;
items: MenuItem[];
onClose: () => void;
}) {
useEffect(() => {
const close = () => onClose();
window.addEventListener("click", close);
window.addEventListener("contextmenu", close);
return () => {
window.removeEventListener("click", close);
window.removeEventListener("contextmenu", close);
};
}, [onClose]);
const left = Math.min(x, window.innerWidth - 220);
const top = Math.min(y, window.innerHeight - items.length * 38 - 16);
return (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.1 }}
style={{
position: "fixed",
left,
top,
zIndex: 11000,
minWidth: 200,
background: "var(--bg)",
backdropFilter: "blur(var(--blur))",
WebkitBackdropFilter: "blur(var(--blur))",
border: "1px solid var(--glass-border)",
borderRadius: "var(--radius-md)",
boxShadow: "var(--shadow-window)",
padding: 6,
}}
>
{items.map((item, i) => (
<div
key={i}
onClick={(e) => {
e.stopPropagation();
item.onClick();
onClose();
}}
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "8px 10px",
borderRadius: "var(--radius-sm)",
cursor: "pointer",
fontSize: 13,
color: item.danger ? "var(--danger)" : "var(--text)",
}}
onMouseEnter={(e) => (e.currentTarget.style.background = "var(--accent-soft)")}
onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
>
{item.icon && <span style={{ width: 18 }}>{item.icon}</span>}
{item.label}
</div>
))}
</motion.div>
);
}

118
src/system-ui/Desktop.tsx Normal file
View File

@ -0,0 +1,118 @@
import { useState } from "react";
import { useOS } from "../kernel/store";
import { WALLPAPERS } from "../design/tokens";
import { MenuBar } from "./MenuBar";
import { Dock } from "./Dock";
import { WindowManager } from "./WindowManager";
import { Spotlight } from "./Spotlight";
import { Launcher } from "./Launcher";
import { Notifications } from "./Notifications";
import { ContextMenu, type MenuItem } from "./ContextMenu";
import { AppIcon } from "./components/ui";
export function Desktop() {
const wallpaper = useOS((s) => s.wallpaper);
const apps = useOS((s) => s.apps);
const openApp = useOS((s) => s.openApp);
const setSpotlight = useOS((s) => s.setSpotlight);
const toggleTheme = useOS((s) => s.toggleTheme);
const setWallpaper = useOS((s) => s.setWallpaper);
const [menu, setMenu] = useState<{ x: number; y: number; items: MenuItem[] } | null>(null);
const wp = WALLPAPERS.find((w) => w.id === wallpaper) ?? WALLPAPERS[0];
// Desktop icons: built-in apps shown on the desktop surface.
const desktopApps = apps.filter((a) => a.builtin);
const onContext = (e: React.MouseEvent) => {
e.preventDefault();
const nextWp = WALLPAPERS[(WALLPAPERS.findIndex((w) => w.id === wallpaper) + 1) % WALLPAPERS.length];
setMenu({
x: e.clientX,
y: e.clientY,
items: [
{ label: "Open Assistant", icon: "✦", onClick: () => setSpotlight(true) },
{ label: "App Builder", icon: "⚙️", onClick: () => openApp("app-builder") },
{ label: `Wallpaper: ${nextWp.name}`, icon: "🖼️", onClick: () => setWallpaper(nextWp.id) },
{ label: "Toggle theme", icon: "🌓", onClick: toggleTheme },
{ label: "Settings", icon: "⚙", onClick: () => openApp("settings") },
],
});
};
return (
<div
onContextMenu={onContext}
className={wp.animated ? "wallpaper-animated" : undefined}
style={{
position: "fixed",
inset: 0,
background: wp.css,
backgroundSize: wp.backgroundSize,
animation: wp.animation,
overflow: "hidden",
}}
>
<MenuBar />
{/* desktop icons */}
<div
style={{
position: "absolute",
top: 52,
left: 16,
display: "flex",
flexDirection: "column",
flexWrap: "wrap",
gap: 4,
maxHeight: "calc(100vh - 160px)",
}}
>
{desktopApps.map((a) => (
<button
key={a.id}
onDoubleClick={() => openApp(a.id)}
onClick={(e) => e.detail === 2 && openApp(a.id)}
title={`Open ${a.name}`}
style={{
width: 84,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 5,
padding: "10px 6px",
background: "transparent",
border: "none",
borderRadius: "var(--radius-md)",
cursor: "pointer",
color: "#fff",
}}
onMouseEnter={(e) => (e.currentTarget.style.background = "rgba(255,255,255,0.12)")}
onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
>
<AppIcon icon={a.icon} size={48} />
<span
style={{
fontSize: 11.5,
fontWeight: 500,
textShadow: "0 1px 4px rgba(0,0,0,0.7)",
textAlign: "center",
lineHeight: 1.2,
}}
>
{a.name}
</span>
</button>
))}
</div>
<WindowManager />
<Dock />
<Launcher />
<Spotlight />
<Notifications />
{menu && <ContextMenu {...menu} onClose={() => setMenu(null)} />}
</div>
);
}

142
src/system-ui/Dock.tsx Normal file
View File

@ -0,0 +1,142 @@
import { motion } from "framer-motion";
import { useOS } from "../kernel/store";
import { AppIcon } from "./components/ui";
// macOS-style dock: pinned built-ins + generated apps + running indicators.
export function Dock() {
const apps = useOS((s) => s.apps);
const windows = useOS((s) => s.windows);
const openApp = useOS((s) => s.openApp);
const generating = useOS((s) => s.generating);
const launcherOpen = useOS((s) => s.launcherOpen);
const setLauncher = useOS((s) => s.setLauncher);
const runningAppIds = new Set(windows.map((w) => w.appId));
return (
<div
style={{
position: "fixed",
bottom: 20,
left: 0,
right: 0,
display: "flex",
justifyContent: "center",
zIndex: 9000,
pointerEvents: "none",
}}
>
<motion.div
initial={{ y: 40, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ type: "spring", stiffness: 300, damping: 26, delay: 0.15 }}
style={{
pointerEvents: "auto",
display: "flex",
alignItems: "center",
gap: 10,
padding: "10px 14px",
// Evenly frosted glass, gently tinted toward the icons' blue so the
// dock reads as part of the same family as the wallpaper and tiles.
background:
"linear-gradient(180deg, rgba(96,150,210,0.20) 0%, rgba(42,66,108,0.12) 100%)",
backdropFilter: "blur(var(--blur))",
WebkitBackdropFilter: "blur(var(--blur))",
border: "1px solid rgba(255,255,255,0.14)",
borderRadius: 26,
boxShadow:
"0 22px 55px -22px rgba(0,0,0,0.6), inset 0 1px 0 rgba(255,255,255,0.16)",
}}
>
{/* Start / All apps button */}
<DockIcon
icon="▦"
label="All apps"
running={false}
active={launcherOpen}
onClick={() => setLauncher(!launcherOpen)}
/>
<div
style={{
width: 1,
alignSelf: "stretch",
margin: "6px 2px",
background:
"linear-gradient(180deg, transparent, var(--glass-border) 35%, var(--glass-border) 65%, transparent)",
}}
/>
{apps.map((app) => (
<DockIcon
key={app.id}
icon={app.icon}
label={app.name}
running={runningAppIds.has(app.id)}
onClick={() => openApp(app.id)}
/>
))}
{generating && (
<DockIcon icon="✨" label={`Generating: ${generating.request}`} running={false} pulsing />
)}
</motion.div>
</div>
);
}
function DockIcon({
icon,
label,
running,
pulsing,
active,
onClick,
}: {
icon: string;
label: string;
running: boolean;
pulsing?: boolean;
active?: boolean;
onClick?: () => void;
}) {
return (
<motion.button
onClick={onClick}
whileHover={{ y: -8, scale: 1.18 }}
whileTap={{ scale: 0.95 }}
transition={{ type: "spring", stiffness: 400, damping: 18 }}
title={label}
className={pulsing ? "dock-pulse" : undefined}
style={{
position: "relative",
width: 46,
height: 46,
padding: 0,
borderRadius: 12,
border: "none",
background: "transparent",
lineHeight: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
outline: active ? "2px solid rgba(255,255,255,0.5)" : "none",
outlineOffset: 3,
}}
>
<AppIcon icon={icon} size={46} />
{running && (
<span
style={{
position: "absolute",
bottom: -7,
left: "50%",
transform: "translateX(-50%)",
width: 4,
height: 4,
borderRadius: "50%",
background: "rgba(255,255,255,0.8)",
}}
/>
)}
</motion.button>
);
}

239
src/system-ui/Launcher.tsx Normal file
View File

@ -0,0 +1,239 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { useOS } from "../kernel/store";
import { aiAvailable } from "../kernel/ai";
import { AppIcon } from "./components/ui";
// Windows 11 "All apps" / macOS Launchpad-style launcher:
// a full app grid with a search box, opened from the Dock's Start button.
export function Launcher() {
const open = useOS((s) => s.launcherOpen);
const setOpen = useOS((s) => s.setLauncher);
const apps = useOS((s) => s.apps);
const openApp = useOS((s) => s.openApp);
const installAndOpen = useOS((s) => s.installAndOpen);
const [q, setQ] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (open) {
setQ("");
setTimeout(() => inputRef.current?.focus(), 40);
}
}, [open]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && open) setOpen(false);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, setOpen]);
const matches = useMemo(() => {
const t = q.trim().toLowerCase();
if (!t) return apps;
return apps.filter(
(a) =>
a.name.toLowerCase().includes(t) ||
a.description?.toLowerCase().includes(t) ||
a.category.includes(t)
);
}, [apps, q]);
const showGenerate = q.trim().length > 2 && aiAvailable() && matches.length === 0;
const launch = (id: string) => {
openApp(id);
setOpen(false);
};
return (
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onMouseDown={() => setOpen(false)}
style={{
position: "fixed",
inset: 0,
zIndex: 9800,
background: "rgba(0,0,0,0.38)",
backdropFilter: "blur(10px)",
WebkitBackdropFilter: "blur(10px)",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "0 24px 120px",
}}
>
<motion.div
initial={{ scale: 0.96, y: 24, opacity: 0 }}
animate={{ scale: 1, y: 0, opacity: 1 }}
exit={{ scale: 0.97, y: 16, opacity: 0 }}
transition={{ type: "spring", stiffness: 360, damping: 30 }}
onMouseDown={(e) => e.stopPropagation()}
style={{
width: "min(880px, 94vw)",
maxHeight: "72vh",
background: "var(--bg)",
border: "1px solid var(--glass-border)",
borderRadius: "var(--radius-xl)",
boxShadow: "var(--shadow-window)",
overflow: "hidden",
display: "flex",
flexDirection: "column",
}}
>
{/* search */}
<div
style={{
display: "flex",
alignItems: "center",
gap: 10,
margin: "18px auto 6px",
padding: "9px 16px",
width: "min(420px, 80%)",
background: "var(--bg-elevated)",
border: "1px solid var(--glass-border)",
borderRadius: "var(--radius-lg)",
}}
>
<span style={{ fontSize: 15, opacity: 0.7 }}>🔍</span>
<input
ref={inputRef}
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Search apps…"
style={{
flex: 1,
background: "transparent",
border: "none",
outline: "none",
color: "var(--text)",
fontSize: 14,
fontFamily: "var(--font-sans)",
}}
/>
</div>
<div
style={{
fontSize: 12,
fontWeight: 600,
color: "var(--text-muted)",
padding: "8px 28px 2px",
}}
>
All apps
</div>
{/* grid */}
<div
style={{
flex: 1,
overflow: "auto",
padding: "12px 22px 24px",
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(108px, 1fr))",
gap: 6,
alignContent: "start",
}}
>
{matches.map((a) => (
<motion.button
key={a.id}
onClick={() => launch(a.id)}
whileHover={{ scale: 1.04 }}
whileTap={{ scale: 0.96 }}
title={a.description ?? a.name}
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 8,
padding: "16px 8px",
background: "transparent",
border: "1px solid transparent",
borderRadius: "var(--radius-md)",
cursor: "pointer",
color: "var(--text)",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "var(--accent-soft)";
e.currentTarget.style.borderColor = "var(--glass-border)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "transparent";
e.currentTarget.style.borderColor = "transparent";
}}
>
<AppIcon icon={a.icon} size={52} />
<span
style={{
fontSize: 12,
fontWeight: 500,
textAlign: "center",
lineHeight: 1.25,
overflow: "hidden",
textOverflow: "ellipsis",
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
}}
>
{a.name}
</span>
</motion.button>
))}
{showGenerate && (
<motion.button
onClick={() => {
installAndOpen(q);
setOpen(false);
}}
whileHover={{ scale: 1.04 }}
whileTap={{ scale: 0.96 }}
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 8,
padding: "16px 8px",
background: "var(--accent-soft)",
border: "1px solid var(--glass-border)",
borderRadius: "var(--radius-md)",
cursor: "pointer",
color: "var(--accent)",
}}
>
<AppIcon icon="✨" size={52} />
<span style={{ fontSize: 12, fontWeight: 600, textAlign: "center", lineHeight: 1.25 }}>
Build {q.trim()}
</span>
</motion.button>
)}
{matches.length === 0 && !showGenerate && (
<div
style={{
gridColumn: "1 / -1",
padding: 28,
textAlign: "center",
color: "var(--text-muted)",
fontSize: 13,
}}
>
No apps match {q.trim()}.
</div>
)}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}

View File

@ -0,0 +1,84 @@
import { useEffect, useState } from "react";
import { motion } from "framer-motion";
import { WALLPAPERS } from "../design/tokens";
import { useOS } from "../kernel/store";
export function Lockscreen({ onUnlock }: { onUnlock: () => void }) {
const wallpaper = useOS((s) => s.wallpaper);
const wp = WALLPAPERS.find((w) => w.id === wallpaper) ?? WALLPAPERS[0];
const [now, setNow] = useState(new Date());
useEffect(() => {
const t = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(t);
}, []);
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onUnlock}
style={{
position: "fixed",
inset: 0,
background: wp.css,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 22,
cursor: "pointer",
color: "#fff",
fontFamily: "Inter, system-ui, sans-serif",
}}
>
<motion.div
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.1 }}
style={{ textAlign: "center", textShadow: "0 2px 24px rgba(0,0,0,0.5)" }}
>
<div style={{ fontSize: 78, fontWeight: 700, letterSpacing: -1, fontVariantNumeric: "tabular-nums" }}>
{now.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })}
</div>
<div style={{ fontSize: 18, opacity: 0.9, marginTop: 4 }}>
{now.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" })}
</div>
</motion.div>
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.3 }}
style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 12 }}
>
<div
style={{
width: 72,
height: 72,
borderRadius: "50%",
background: "rgba(255,255,255,0.15)",
backdropFilter: "blur(10px)",
border: "1px solid rgba(255,255,255,0.3)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 34,
}}
>
</div>
<div style={{ fontWeight: 600 }}>NEON NOODLE OS</div>
</motion.div>
<motion.div
animate={{ opacity: [0.4, 1, 0.4] }}
transition={{ duration: 2.4, repeat: Infinity }}
style={{ fontSize: 13, marginTop: 12, textShadow: "0 1px 8px rgba(0,0,0,0.6)" }}
>
Click anywhere to enter
</motion.div>
</motion.div>
);
}

83
src/system-ui/MenuBar.tsx Normal file
View File

@ -0,0 +1,83 @@
import { useEffect, useState } from "react";
import { useOS } from "../kernel/store";
export function MenuBar() {
const [now, setNow] = useState(new Date());
const setSpotlight = useOS((s) => s.setSpotlight);
const toggleTheme = useOS((s) => s.toggleTheme);
const themeMode = useOS((s) => s.themeMode);
const windows = useOS((s) => s.windows);
const apps = useOS((s) => s.apps);
const aiReady = useOS((s) => s.aiReady);
useEffect(() => {
const t = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(t);
}, []);
// Active window's app name (highest z, not minimized).
const active = [...windows].filter((w) => !w.minimized).sort((a, b) => b.z - a.z)[0];
const activeApp = active ? apps.find((a) => a.id === active.appId) : null;
return (
<div
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
height: 36,
zIndex: 9500,
display: "flex",
alignItems: "center",
gap: 16,
padding: "0 14px",
background: "var(--glass)",
backdropFilter: "blur(var(--blur))",
WebkitBackdropFilter: "blur(var(--blur))",
borderBottom: "1px solid var(--glass-border)",
color: "var(--text)",
fontSize: 13,
userSelect: "none",
}}
>
<span style={{ fontWeight: 700, letterSpacing: 0.3, display: "flex", alignItems: "center", gap: 6 }}>
<span>NEON NOODLE OS</span>
</span>
<span style={{ fontWeight: 600, opacity: 0.9 }}>{activeApp ? activeApp.name : "Desktop"}</span>
<div style={{ flex: 1 }} />
<button
onClick={() => setSpotlight(true)}
title="Assistant (⌘Space)"
style={menuBtn}
>
🔍 <span style={{ opacity: 0.7, fontSize: 11 }}>Space</span>
</button>
<button onClick={toggleTheme} style={menuBtn} title="Toggle theme">
{themeMode === "dark" ? "🌙" : "☀️"}
</button>
<span title={aiReady ? "AI connected" : "AI offline — add an API key in Settings"}>
{aiReady ? "🟢" : "🔴"}
</span>
<span style={{ fontVariantNumeric: "tabular-nums", fontWeight: 500 }}>
{now.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" })}{" "}
{now.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })}
</span>
</div>
);
}
const menuBtn: React.CSSProperties = {
background: "transparent",
border: "none",
color: "var(--text)",
cursor: "pointer",
fontSize: 13,
display: "flex",
alignItems: "center",
gap: 5,
padding: "4px 6px",
borderRadius: "var(--radius-sm)",
};

View File

@ -0,0 +1,68 @@
import { motion, AnimatePresence } from "framer-motion";
import { useOS } from "../kernel/store";
export function Notifications() {
const notes = useOS((s) => s.notifications);
const dismiss = useOS((s) => s.dismissNotification);
return (
<div
style={{
position: "fixed",
top: 46,
right: 14,
zIndex: 9800,
display: "flex",
flexDirection: "column",
gap: 10,
width: 320,
pointerEvents: "none",
}}
>
<AnimatePresence>
{notes.map((n) => (
<motion.div
key={n.id}
initial={{ opacity: 0, x: 40, scale: 0.95 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{ opacity: 0, x: 40, scale: 0.9 }}
transition={{ type: "spring", stiffness: 380, damping: 30 }}
onClick={() => dismiss(n.id)}
style={{
pointerEvents: "auto",
cursor: "pointer",
background: "var(--bg)",
backdropFilter: "blur(var(--blur))",
WebkitBackdropFilter: "blur(var(--blur))",
border: "1px solid var(--glass-border)",
borderRadius: "var(--radius-lg)",
boxShadow: "var(--shadow-soft)",
padding: "12px 14px",
display: "flex",
gap: 11,
alignItems: "flex-start",
}}
>
<span style={{ fontSize: 20, lineHeight: 1 }}>{n.icon ?? "🔔"}</span>
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: 13, color: "var(--text)" }}>{n.title}</div>
{n.body && (
<div
style={{
fontSize: 12,
color: "var(--text-muted)",
marginTop: 2,
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{n.body}
</div>
)}
</div>
</motion.div>
))}
</AnimatePresence>
</div>
);
}

200
src/system-ui/Spotlight.tsx Normal file
View File

@ -0,0 +1,200 @@
import { useEffect, useRef, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { useOS } from "../kernel/store";
import { aiAvailable } from "../kernel/ai";
import { AppIcon } from "./components/ui";
export function Spotlight() {
const open = useOS((s) => s.spotlightOpen);
const setOpen = useOS((s) => s.setSpotlight);
const apps = useOS((s) => s.apps);
const openApp = useOS((s) => s.openApp);
const installAndOpen = useOS((s) => s.installAndOpen);
const [q, setQ] = useState("");
const [sel, setSel] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (open) {
setQ("");
setSel(0);
setTimeout(() => inputRef.current?.focus(), 30);
}
}, [open]);
const matches = q.trim()
? apps.filter(
(a) =>
a.name.toLowerCase().includes(q.toLowerCase()) ||
a.description?.toLowerCase().includes(q.toLowerCase()) ||
a.category.includes(q.toLowerCase())
)
: apps;
// Build a results list: matching apps, then a "generate" action if there's a query.
const showGenerate = q.trim().length > 2 && aiAvailable();
const total = matches.length + (showGenerate ? 1 : 0);
const choose = (index: number) => {
if (index < matches.length) {
openApp(matches[index].id);
setOpen(false);
} else if (showGenerate) {
installAndOpen(q);
}
};
const onKey = (e: React.KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
else if (e.key === "ArrowDown") {
e.preventDefault();
setSel((s) => (s + 1) % Math.max(1, total));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSel((s) => (s - 1 + Math.max(1, total)) % Math.max(1, total));
} else if (e.key === "Enter") {
e.preventDefault();
choose(sel);
}
};
return (
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onMouseDown={() => setOpen(false)}
style={{
position: "fixed",
inset: 0,
zIndex: 10000,
background: "rgba(0,0,0,0.32)",
backdropFilter: "blur(3px)",
display: "flex",
alignItems: "flex-start",
justifyContent: "center",
paddingTop: "16vh",
}}
>
<motion.div
initial={{ scale: 0.96, y: -10, opacity: 0 }}
animate={{ scale: 1, y: 0, opacity: 1 }}
exit={{ scale: 0.97, opacity: 0 }}
transition={{ type: "spring", stiffness: 420, damping: 30 }}
onMouseDown={(e) => e.stopPropagation()}
style={{
width: "min(620px, 92vw)",
background: "var(--bg)",
backdropFilter: "blur(var(--blur))",
WebkitBackdropFilter: "blur(var(--blur))",
border: "1px solid var(--glass-border)",
borderRadius: "var(--radius-xl)",
boxShadow: "var(--shadow-window)",
overflow: "hidden",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "16px 18px" }}>
<span style={{ fontSize: 20 }}></span>
<input
ref={inputRef}
value={q}
onChange={(e) => {
setQ(e.target.value);
setSel(0);
}}
onKeyDown={onKey}
placeholder="Search apps or describe one to build…"
style={{
flex: 1,
background: "transparent",
border: "none",
outline: "none",
color: "var(--text)",
fontSize: 18,
fontFamily: "var(--font-sans)",
}}
/>
</div>
<div style={{ maxHeight: "46vh", overflow: "auto", borderTop: "1px solid var(--glass-border)" }}>
{matches.map((a, i) => (
<Row
key={a.id}
icon={a.icon}
title={a.name}
sub={a.description ?? a.category}
active={sel === i}
onClick={() => choose(i)}
onHover={() => setSel(i)}
/>
))}
{showGenerate && (
<Row
icon="✨"
title={`Build “${q.trim()}`}
sub="Generate a new app with AI"
accent
active={sel === matches.length}
onClick={() => choose(matches.length)}
onHover={() => setSel(matches.length)}
/>
)}
{matches.length === 0 && !showGenerate && (
<div style={{ padding: 18, color: "var(--text-muted)", fontSize: 13 }}>
{aiAvailable()
? "Keep typing to generate a new app…"
: "No match. Add an API key in Settings → Language Model to generate apps."}
</div>
)}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}
function Row({
icon,
title,
sub,
active,
accent,
onClick,
onHover,
}: {
icon: string;
title: string;
sub: string;
active: boolean;
accent?: boolean;
onClick: () => void;
onHover: () => void;
}) {
return (
<div
onClick={onClick}
onMouseEnter={onHover}
style={{
display: "flex",
alignItems: "center",
gap: 13,
padding: "11px 18px",
cursor: "pointer",
background: active ? "var(--accent-soft)" : "transparent",
borderLeft: active ? "3px solid var(--accent)" : "3px solid transparent",
}}
>
<AppIcon icon={icon} size={30} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 600, color: accent ? "var(--accent)" : "var(--text)" }}>
{title}
</div>
<div style={{ fontSize: 12, color: "var(--text-muted)" }}>{sub}</div>
</div>
{active && <span style={{ color: "var(--text-muted)", fontSize: 12 }}></span>}
</div>
);
}

196
src/system-ui/Window.tsx Normal file
View File

@ -0,0 +1,196 @@
import { useRef } from "react";
import { motion } from "framer-motion";
import type { AppManifest, WindowInstance } from "../kernel/types";
import { useOS } from "../kernel/store";
import { AppHost } from "./AppHost";
import { AppIcon } from "./components/ui";
const MENUBAR_H = 36;
const MIN_W = 260;
const MIN_H = 160;
const SNAP_THRESHOLD = 12;
export function Window({ win, app }: { win: WindowInstance; app: AppManifest }) {
const focus = useOS((s) => s.focusWindow);
const move = useOS((s) => s.moveWindow);
const resize = useOS((s) => s.resizeWindow);
const close = useOS((s) => s.closeWindow);
const minimize = useOS((s) => s.minimizeWindow);
const toggleMax = useOS((s) => s.toggleMaximize);
const snap = useOS((s) => s.snapWindow);
const dragState = useRef<{ dx: number; dy: number } | null>(null);
const onTitleDown = (e: React.PointerEvent) => {
if ((e.target as HTMLElement).dataset.noDrag) return;
focus(win.id);
if (win.maximized) return;
dragState.current = { dx: e.clientX - win.pos.x, dy: e.clientY - win.pos.y };
(e.target as HTMLElement).setPointerCapture(e.pointerId);
};
const onTitleMove = (e: React.PointerEvent) => {
if (!dragState.current) return;
const x = e.clientX - dragState.current.dx;
const y = Math.max(MENUBAR_H + 4, e.clientY - dragState.current.dy);
move(win.id, { x, y });
};
const onTitleUp = (e: React.PointerEvent) => {
if (!dragState.current) return;
dragState.current = null;
// edge snapping
if (e.clientX <= SNAP_THRESHOLD) snap(win.id, "left");
else if (e.clientX >= window.innerWidth - SNAP_THRESHOLD) snap(win.id, "right");
else if (e.clientY <= MENUBAR_H + SNAP_THRESHOLD) snap(win.id, "top");
};
const startResize = (e: React.PointerEvent, dir: string) => {
e.stopPropagation();
focus(win.id);
const start = { x: e.clientX, y: e.clientY, ...win.size, px: win.pos.x, py: win.pos.y };
const target = e.currentTarget as HTMLElement;
target.setPointerCapture(e.pointerId);
const onMove = (ev: PointerEvent) => {
let { w, h } = start;
let px = start.px;
let py = start.py;
const ddx = ev.clientX - start.x;
const ddy = ev.clientY - start.y;
if (dir.includes("e")) w = Math.max(MIN_W, start.w + ddx);
if (dir.includes("s")) h = Math.max(MIN_H, start.h + ddy);
if (dir.includes("w")) {
w = Math.max(MIN_W, start.w - ddx);
px = start.px + (start.w - w);
}
if (dir.includes("n")) {
h = Math.max(MIN_H, start.h - ddy);
py = Math.max(MENUBAR_H + 4, start.py + (start.h - h));
}
resize(win.id, { w, h }, { x: px, y: py });
};
const onUp = () => {
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
};
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
};
const handle = (dir: string, style: React.CSSProperties) => (
<div
onPointerDown={(e) => startResize(e, dir)}
style={{ position: "absolute", zIndex: 5, ...style }}
/>
);
return (
<motion.div
initial={{ opacity: 0, scale: 0.94, y: 8 }}
animate={{
opacity: win.minimized ? 0 : 1,
scale: win.minimized ? 0.7 : 1,
y: win.minimized ? 60 : 0,
pointerEvents: win.minimized ? "none" : "auto",
}}
exit={{ opacity: 0, scale: 0.94 }}
transition={{ type: "spring", stiffness: 380, damping: 32 }}
onMouseDown={() => focus(win.id)}
style={{
position: "absolute",
left: win.pos.x,
top: win.pos.y,
width: win.size.w,
height: win.size.h,
zIndex: win.z,
display: win.minimized ? "none" : "flex",
flexDirection: "column",
borderRadius: "var(--radius-lg)",
overflow: "hidden",
background: "var(--bg)",
backdropFilter: "blur(var(--blur))",
WebkitBackdropFilter: "blur(var(--blur))",
border: "1px solid var(--glass-border)",
boxShadow: "var(--shadow-window)",
}}
>
{/* title bar */}
<div
onPointerDown={onTitleDown}
onPointerMove={onTitleMove}
onPointerUp={onTitleUp}
onDoubleClick={() => toggleMax(win.id)}
style={{
height: 38,
flexShrink: 0,
display: "flex",
alignItems: "center",
gap: 8,
padding: "0 12px",
background: "var(--glass)",
borderBottom: "1px solid var(--glass-border)",
cursor: "grab",
userSelect: "none",
}}
>
<div data-no-drag style={{ display: "flex", gap: 8 }}>
<Dot color="#ff5f57" onClick={() => close(win.id)} title="Close" />
<Dot color="#febc2e" onClick={() => minimize(win.id)} title="Minimize" />
<Dot color="#28c840" onClick={() => toggleMax(win.id)} title="Maximize" />
</div>
<div
style={{
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 7,
fontSize: 12.5,
fontWeight: 600,
color: "var(--text)",
}}
>
<AppIcon icon={app.icon} size={18} radius={5} />
{win.title}
</div>
<div style={{ width: 52 }} />
</div>
{/* body */}
<div style={{ flex: 1, minHeight: 0, overflow: "hidden", position: "relative" }}>
<AppHost app={app} windowId={win.id} />
</div>
{/* resize handles */}
{!win.maximized && (
<>
{handle("n", { top: -3, left: 8, right: 8, height: 6, cursor: "ns-resize" })}
{handle("s", { bottom: -3, left: 8, right: 8, height: 6, cursor: "ns-resize" })}
{handle("e", { right: -3, top: 8, bottom: 8, width: 6, cursor: "ew-resize" })}
{handle("w", { left: -3, top: 8, bottom: 8, width: 6, cursor: "ew-resize" })}
{handle("se", { right: -3, bottom: -3, width: 14, height: 14, cursor: "nwse-resize" })}
{handle("sw", { left: -3, bottom: -3, width: 14, height: 14, cursor: "nesw-resize" })}
{handle("ne", { right: -3, top: -3, width: 14, height: 14, cursor: "nesw-resize" })}
{handle("nw", { left: -3, top: -3, width: 14, height: 14, cursor: "nwse-resize" })}
</>
)}
</motion.div>
);
}
function Dot({ color, onClick, title }: { color: string; onClick: () => void; title: string }) {
return (
<button
data-no-drag
onClick={onClick}
title={title}
style={{
width: 12,
height: 12,
borderRadius: "50%",
background: color,
border: "none",
cursor: "pointer",
padding: 0,
}}
/>
);
}

View File

@ -0,0 +1,18 @@
import { AnimatePresence } from "framer-motion";
import { useOS } from "../kernel/store";
import { Window } from "./Window";
export function WindowManager() {
const windows = useOS((s) => s.windows);
const apps = useOS((s) => s.apps);
return (
<AnimatePresence>
{windows.map((win) => {
const app = apps.find((a) => a.id === win.appId);
if (!app) return null;
return <Window key={win.id} win={win} app={app} />;
})}
</AnimatePresence>
);
}

View File

@ -0,0 +1,148 @@
import React from "react";
// Shared UI primitives. Styled purely with the CSS variables injected at the
// OS root, so built-in apps and chrome look identical to generated apps.
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: "primary" | "ghost" | "danger";
};
export function Button({ variant = "primary", style, ...rest }: ButtonProps) {
const base: React.CSSProperties = {
border: "1px solid transparent",
borderRadius: "var(--radius-md)",
padding: "8px 14px",
fontWeight: 600,
fontSize: 13,
cursor: "pointer",
fontFamily: "var(--font-sans)",
transition: "filter .15s ease, transform .05s ease",
};
const variants: Record<string, React.CSSProperties> = {
primary: { background: "var(--accent)", color: "var(--accent-text)" },
ghost: {
background: "var(--bg-elevated)",
color: "var(--text)",
borderColor: "var(--glass-border)",
},
danger: { background: "var(--danger)", color: "#fff" },
};
return (
<button
{...rest}
style={{ ...base, ...variants[variant], ...style }}
onMouseDown={(e) => {
(e.currentTarget as HTMLButtonElement).style.transform = "scale(0.97)";
rest.onMouseDown?.(e);
}}
onMouseUp={(e) => {
(e.currentTarget as HTMLButtonElement).style.transform = "scale(1)";
rest.onMouseUp?.(e);
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLButtonElement).style.transform = "scale(1)";
rest.onMouseLeave?.(e);
}}
/>
);
}
export function Input(props: React.InputHTMLAttributes<HTMLInputElement>) {
return (
<input
{...props}
style={{
background: "var(--bg-elevated)",
color: "var(--text)",
border: "1px solid var(--glass-border)",
borderRadius: "var(--radius-md)",
padding: "8px 11px",
fontSize: 13,
outline: "none",
fontFamily: "var(--font-sans)",
width: "100%",
boxSizing: "border-box",
...props.style,
}}
/>
);
}
// Unified app icon: renders any emoji/glyph as a clean white silhouette on a
// glossy rounded blue tile, so built-in and AI-generated apps share one look.
// The brightness(0) invert(1) filter flattens a (colored) emoji to pure white.
export function AppIcon({
icon,
size = 44,
radius,
style,
}: {
icon: string;
size?: number;
radius?: number;
style?: React.CSSProperties;
}) {
const r = radius ?? Math.round(size * 0.24);
return (
<div
style={{
width: size,
height: size,
borderRadius: r,
position: "relative",
overflow: "hidden",
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "linear-gradient(160deg, #5aa6e6 0%, #2f7bc4 48%, #1c5aa0 100%)",
boxShadow:
"0 6px 15px -4px rgba(15,55,105,0.55), inset 0 1px 0 rgba(255,255,255,0.5), inset 0 -3px 7px rgba(0,35,80,0.45)",
...style,
}}
>
{/* glossy top highlight */}
<div
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
height: "50%",
background: "linear-gradient(180deg, rgba(255,255,255,0.42), rgba(255,255,255,0.04))",
pointerEvents: "none",
}}
/>
<span
style={{
position: "relative",
fontSize: Math.round(size * 0.5),
lineHeight: 1,
filter: "brightness(0) invert(1) drop-shadow(0 1px 1px rgba(0,30,70,0.4))",
}}
>
{icon}
</span>
</div>
);
}
export function Panel({
children,
style,
}: {
children: React.ReactNode;
style?: React.CSSProperties;
}) {
return (
<div
style={{
background: "var(--glass)",
border: "1px solid var(--glass-border)",
borderRadius: "var(--radius-lg)",
...style,
}}
>
{children}
</div>
);
}

10
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_ANTHROPIC_API_KEY?: string;
readonly VITE_ANTHROPIC_MODEL?: string;
readonly VITE_ANTHROPIC_API_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

21
tailwind.config.js Normal file
View File

@ -0,0 +1,21 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: {
extend: {
fontFamily: {
mono: ["'JetBrains Mono'", "ui-monospace", "SFMono-Regular", "monospace"],
sans: ["Inter", "system-ui", "-apple-system", "sans-serif"],
},
keyframes: {
"fade-in": { "0%": { opacity: "0" }, "100%": { opacity: "1" } },
shimmer: { "0%": { backgroundPosition: "-200% 0" }, "100%": { backgroundPosition: "200% 0" } },
},
animation: {
"fade-in": "fade-in 0.3s ease-out",
shimmer: "shimmer 2s linear infinite",
},
},
},
plugins: [],
};

57
tasks/lessons.md Normal file
View File

@ -0,0 +1,57 @@
# Lessons
## Runtime-kompilierte React-Komponenten: Identität stabil halten
**Kontext:** Generierte Apps werden via `new Function(...)` zu einer `App`-Komponente
kompiliert und gerendert.
**Fehler:** Die `App`-Funktion wurde im Render-Body des Wrapper-Components per
`factory(...)` neu erzeugt. Jeder Render → neue Funktionsidentität → React unmountet/
remountet den Teilbaum → `useState` resettet, `useEffect` feuert erneut → Endlos-
Schleife ("Maximum update depth exceeded") bei jeder App mit State.
**Regel:** Dynamisch erzeugte Komponenten **einmal** memoisieren
(`useMemo(() => factory(...), [stableDeps])`), nie pro Render neu bauen. Galt hier
für die Sandbox-Runtime; gilt generell für jedes `React.createElement` mit einer
zur Laufzeit konstruierten Komponente.
## Verifikation: innerText enthält keine Placeholder
Beim Headless-Test gegen Spotlight schlug die Assertion fehl, weil sie auf den
`placeholder`-Text eines `<input>` prüfte — `document.body.innerText` liefert
Placeholder/Attribut-Text nicht. Für UI-Smoke-Tests auf echte Text-Knoten oder
Selektoren (`querySelector('input[placeholder*="…"]')`) prüfen, nicht auf innerText.
## Anthropic-kompatible Provider (z.ai): URL-Pfad + CORS beim Streaming
Zwei aufeinanderfolgende Stolpersteine beim Umstellen auf z.ai/GLM:
1. **URL ohne Pfad:** `VITE_ANTHROPIC_API_URL=https://api.z.ai/api/anthropic` ist nur die
Basis-URL → Request landete auf `…/anthropic` statt `…/anthropic/v1/messages`
(z.ai: `{"code":500,"msg":"404 NOT_FOUND"}`). Fix: `resolveApiUrl()` hängt
`/v1/messages` an, wenn nicht vorhanden (Basis- oder Voll-URL beide ok).
2. **Doppelte CORS-Header beim Streaming:** Die `stream:true`-Antwort von z.ai enthält
ZWEI `access-control-allow-origin`-Header (`http://localhost:5173` UND `*`) plus
`allow-credentials: true`. Das ist laut CORS-Spec ungültig → Browser wirft
"TypeError: Failed to fetch". Die Nicht-Stream-Antwort hat nur einen Header,
deshalb ging App-Generierung, aber der Assistant nicht.
**Fix:** Vite-Dev-Proxy (`server.proxy["/__ai"]` → Upstream-Origin+Pfad aus env,
`changeOrigin:true`). Browser ruft same-origin `/__ai/v1/messages` → kein CORS,
Streaming bleibt erhalten. Client nutzt im DEV den Proxy, im Prod-Build die
absolute URL. Merke: curl testet CORS NICHT — Browser-Reproduktion ist Pflicht.
## X-Frame-Options / CSP beim Einbetten externer Seiten (in-OS Browser)
`X-Frame-Options` und CSP `frame-ancestors` werden vom **Browser** anhand der
**Ziel-Response** erzwungen — client-seitig NICHT umgehbar (kein iframe-Trick, kein
Header-Override). Einzige saubere Lösung: **Server-Proxy**, der die Seite serverseitig
holt (dort sind die Header wirkungslos) und sie ohne Framing-Header zurückgibt.
Umsetzung hier: Vite-Dev-Middleware `/__proxy?url=…` → fetch upstream, droppt
`x-frame-options`/`content-security-policy`/`content-encoding`/`content-length`,
injiziert `<base href>` (relative Assets) + ein Klick-Interceptor-Script (Navigation
bleibt im Proxy). SDK-Helfer `os.net.proxyUrl(url)` + Generierungs-Prompt weisen
Browser-Apps an, NIE eine rohe URL in den iframe zu setzen. `<iframe>` im
Forbidden-Guard freigeben. End-to-end verifiziert: en.wikipedia/wiki/Cat lädt im
iframe (innerTitle "Cat - Wikipedia"). Prod-Build hat keinen Proxy → Fallback auf
rohe URL (dann wieder geblockt); für Prod bräuchte es einen echten Backend-Proxy.
## Verifikation lohnt sich auch ohne externen Key
Die KI-App-Generierung braucht einen API-Key. Statt "kann ich nicht testen" wurde der
**konsumierende** Runtime (Babel→Sandbox→Render→Error-Boundary→Forbidden-Guard) mit
synthetischem KI-Output headless verifiziert. Das fand den Remount-Bug, den der
echte API-Pfad sonst erst beim Nutzer gezeigt hätte.

75
tasks/todo.md Normal file
View File

@ -0,0 +1,75 @@
# TODO — "OS": KI-natives Web-Betriebssystem
## Phase 0 — Scaffolding
- [ ] package.json, vite, tsconfig, tailwind, postcss, .env.example
- [ ] index.html, main.tsx, index.css
- [ ] npm install
## Phase 1 — Kernel
- [ ] design/tokens.ts (Design-System)
- [ ] kernel/db.ts (IndexedDB via idb)
- [ ] kernel/fs.ts (virtuelles Dateisystem)
- [ ] kernel/store.ts (zustand: boot, windows, apps, theme, fs cache, notifications)
- [ ] kernel/ai.ts (Anthropic Messages API + App-Generierung + Self-Healing)
- [ ] kernel/sdk.ts (os.* SDK für Apps)
- [ ] kernel/appRuntime.tsx (Babel-Transform + sicheres Rendern)
- [ ] kernel/registry.ts (Builtin + generierte Apps)
## Phase 2 — System-UI
- [ ] components: Button, Input, Panel, Icon
- [ ] system-ui/Boot.tsx (Boot-Sequenz)
- [ ] system-ui/Lockscreen.tsx
- [ ] system-ui/Window.tsx (drag/resize/snap/min/max, z-index)
- [ ] system-ui/WindowManager.tsx
- [ ] system-ui/Dock.tsx
- [ ] system-ui/MenuBar.tsx (Uhr, Statusicons)
- [ ] system-ui/Spotlight.tsx (Cmd+Space, KI-Befehlsleiste)
- [ ] system-ui/Notifications.tsx
- [ ] system-ui/ContextMenu.tsx
- [ ] system-ui/Desktop.tsx (Wallpaper, Icons)
- [ ] App.tsx (orchestriert boot→lock→desktop)
## Phase 3 — Vorinstallierte Apps
- [ ] apps/Files.tsx
- [ ] apps/Terminal.tsx (ls, cat, mkdir, ai <prompt>)
- [ ] apps/Settings.tsx (Theme, Wallpaper, Light/Dark, App-Verwaltung)
- [ ] apps/Assistant.tsx (Chat + Spotlight)
- [ ] apps/AppBuilder.tsx (zeigt KI-Generierung)
## Phase 4 — Verifikation
- [ ] npm run build (typecheck) grün
- [ ] npm run dev startet, Boot→Desktop sichtbar
- [ ] Fenster-Manager funktioniert
- [ ] App-Generierung end-to-end (mit API-Key)
## Review
**Status: alle Phasen abgeschlossen und verifiziert.**
Gebaut: Vollständiges KI-natives Web-OS ("Fluid OS"). Boot→Lockscreen→Desktop,
echter Fenster-Manager (Drag/Resize/Snap/Min/Max/Z-Stacking), Spotlight (⌘Space),
5 Builtin-Apps, KI-App-Generierung (JSON → Babel-Transform → Sandbox), Self-Healing,
IndexedDB-Persistenz, Light/Dark + Wallpaper, Kontextmenüs, Notifications, IPC-Bus.
### Verifikation (headless Chrome, puppeteer-core)
- Boot → Lockscreen → Desktop: ✅
- MenuBar (Uhr/Status), Dock (5 Apps), 5 Desktop-Icons: ✅
- Fenster öffnen (Terminal) + Chrome rendert: ✅
- Spotlight via ⌘Space: ✅
- Generierte-App-Runtime: Babel-Transform + Sandbox + State + os.window.setTitle: ✅
- Error-Boundary + Self-Heal-UI bei Crash: ✅
- Forbidden-API-Guard (fetch etc. blockiert): ✅
- Keine unerwarteten Konsolenfehler: ✅
- `tsc --noEmit` + `npm run build`: grün
### Gefundener & behobener Bug
`appRuntime` baute die generierte `App`-Funktion bei *jedem* Render neu → neue
Komponenten-Identität → Remount-Schleife ("Maximum update depth exceeded"), State-Verlust
bei allen zustandsbehafteten generierten Apps. Fix: `App` per `useMemo([os])` einmal
pro Fenster bauen. Siehe tasks/lessons.md.
### Nicht verifizierbar ohne API-Key
Der echte Anthropic-Call (App-Generierung end-to-end) braucht `VITE_ANTHROPIC_API_KEY`.
Der konsumierende Runtime ist mit synthetischem KI-Output vollständig getestet; der
Netzwerkpfad ist implementiert und endet sauber an der API-Grenze.

21
tsconfig.json Normal file
View File

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2021",
"useDefineForClassFields": true,
"lib": ["ES2021", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"allowJs": true
},
"include": ["src", "vite.config.ts"]
}

1
tsconfig.tsbuildinfo Normal file
View File

@ -0,0 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/apps/appbuilder.tsx","./src/apps/assistant.tsx","./src/apps/files.tsx","./src/apps/settings.tsx","./src/apps/terminal.tsx","./src/design/tokens.ts","./src/kernel/ai.ts","./src/kernel/appruntime.tsx","./src/kernel/bus.ts","./src/kernel/db.ts","./src/kernel/fs.ts","./src/kernel/providers.ts","./src/kernel/registry.ts","./src/kernel/sdk.ts","./src/kernel/store.ts","./src/kernel/types.ts","./src/system-ui/apphost.tsx","./src/system-ui/boot.tsx","./src/system-ui/contextmenu.tsx","./src/system-ui/desktop.tsx","./src/system-ui/dock.tsx","./src/system-ui/launcher.tsx","./src/system-ui/lockscreen.tsx","./src/system-ui/menubar.tsx","./src/system-ui/notifications.tsx","./src/system-ui/spotlight.tsx","./src/system-ui/window.tsx","./src/system-ui/windowmanager.tsx","./src/system-ui/components/ui.tsx","./vite.config.ts"],"version":"5.9.3"}

167
vite.config.ts Normal file
View File

@ -0,0 +1,167 @@
import { defineConfig, type Connect } from "vite";
import react from "@vitejs/plugin-react";
// Dev-only middleware that lets the in-OS browser embed external pages.
// X-Frame-Options / CSP frame-ancestors are enforced by the BROWSER based on the
// target's response headers and cannot be bypassed client-side. We fetch the page
// server-side (where those headers are inert), strip the framing headers, inject a
// <base> tag so relative assets resolve, and keep link navigation inside the proxy.
function browserProxyPlugin() {
const handler: Connect.NextHandleFunction = async (req, res, next) => {
if (!req.url || !req.url.startsWith("/__proxy")) return next();
const target = new URL(req.url, "http://localhost").searchParams.get("url");
if (!target || !/^https?:\/\//i.test(target)) {
res.statusCode = 400;
res.end("Missing or invalid ?url=");
return;
}
try {
const upstream = await fetch(target, {
redirect: "follow",
headers: {
"user-agent":
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36",
accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
"accept-language": "en,de;q=0.8",
},
});
const ctype = upstream.headers.get("content-type") || "application/octet-stream";
// pass through most headers, but drop framing/security/encoding ones
const DROP = new Set([
"x-frame-options",
"content-security-policy",
"content-security-policy-report-only",
"content-encoding",
"content-length",
"transfer-encoding",
"strict-transport-security",
"set-cookie",
]);
upstream.headers.forEach((v, k) => {
if (!DROP.has(k.toLowerCase())) res.setHeader(k, v);
});
res.statusCode = upstream.status;
if (ctype.includes("text/html")) {
let html = await upstream.text();
const baseHref = upstream.url || target;
// remove any CSP set via <meta>
html = html.replace(/<meta[^>]+http-equiv=["']?content-security-policy["']?[^>]*>/gi, "");
const inject =
`<base href="${baseHref}">` +
`<script>(function(){var B=${JSON.stringify(baseHref)};` +
`function px(u){try{return "/__proxy?url="+encodeURIComponent(new URL(u,B).href);}catch(e){return u;}}` +
`document.addEventListener("click",function(e){var a=e.target.closest&&e.target.closest("a[href]");` +
`if(!a)return;var h=a.getAttribute("href");if(!h||h[0]==="#"||/^(javascript|mailto|tel):/i.test(h))return;` +
`var abs;try{abs=new URL(h,B).href;}catch(_){return;}if(!/^https?:/i.test(abs))return;` +
`e.preventDefault();window.top.location.href=px(abs);},true);})();</script>`;
html = /<head[^>]*>/i.test(html)
? html.replace(/<head[^>]*>/i, (m) => m + inject)
: inject + html;
res.setHeader("content-type", "text/html; charset=utf-8");
res.end(html);
} else {
const buf = new Uint8Array(await upstream.arrayBuffer());
res.setHeader("content-type", ctype);
res.end(buf);
}
} catch (e) {
res.statusCode = 502;
res.setHeader("content-type", "text/html; charset=utf-8");
res.end(
`<body style="font-family:sans-serif;padding:24px;color:#333">` +
`<h3>⚠️ Proxy could not load this page</h3>` +
`<p>${(e as Error).message}</p><p style="color:#888">${target}</p></body>`
);
}
};
return {
name: "in-os-browser-proxy",
configureServer(server: { middlewares: Connect.Server }) {
server.middlewares.use(handler);
},
configurePreviewServer(server: { middlewares: Connect.Server }) {
server.middlewares.use(handler);
},
};
}
// Dynamic AI proxy: the browser POSTs to "/__ai/proxy" with the full upstream
// endpoint in the "x-llm-url" header. The dev server forwards the request
// server-side and streams the response back same-origin. This sidesteps CORS
// for every provider (Anthropic, OpenAI, OpenRouter, Grok, Cerebras) and lets
// the active provider be switched at runtime without restarting Vite.
function aiProxyPlugin() {
// Only these headers are forwarded upstream — auth + protocol, nothing else.
const FORWARD = new Set([
"content-type",
"accept",
"authorization",
"x-api-key",
"anthropic-version",
"anthropic-dangerous-direct-browser-access",
"http-referer",
"x-title",
]);
const handler: Connect.NextHandleFunction = async (req, res, next) => {
if (!req.url || !req.url.startsWith("/__ai/proxy")) return next();
const target = (req.headers["x-llm-url"] as string) || "";
if (!/^https?:\/\//i.test(target)) {
res.statusCode = 400;
res.end("Missing or invalid x-llm-url header");
return;
}
try {
const chunks: Buffer[] = [];
for await (const c of req) chunks.push(c as Buffer);
const body = Buffer.concat(chunks);
const headers: Record<string, string> = {};
for (const [k, v] of Object.entries(req.headers)) {
if (FORWARD.has(k.toLowerCase()) && typeof v === "string") headers[k] = v;
}
const upstream = await fetch(target, {
method: req.method || "POST",
headers,
body: req.method === "GET" || req.method === "HEAD" ? undefined : body,
});
res.statusCode = upstream.status;
const ctype = upstream.headers.get("content-type");
if (ctype) res.setHeader("content-type", ctype);
if (upstream.body) {
const reader = upstream.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (value) res.write(Buffer.from(value));
}
}
res.end();
} catch (e) {
res.statusCode = 502;
res.setHeader("content-type", "application/json");
res.end(JSON.stringify({ error: { message: (e as Error).message } }));
}
};
return {
name: "fluid-os-ai-proxy",
configureServer(server: { middlewares: Connect.Server }) {
server.middlewares.use(handler);
},
configurePreviewServer(server: { middlewares: Connect.Server }) {
server.middlewares.use(handler);
},
};
}
export default defineConfig(() => {
return {
plugins: [react(), browserProxyPlugin(), aiProxyPlugin()],
server: {
port: 5173,
},
};
});