leanOS: initial import (basiert auf NEON NOODLE OS)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
commit
ee203fd9fb
1
.claude/scheduled_tasks.lock
Normal file
1
.claude/scheduled_tasks.lock
Normal 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
13
.env.example
Normal 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
6
.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
86
CLAUDE.md
Normal file
86
CLAUDE.md
Normal 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
21
LICENSE
Normal 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
89
PROMPT.md
Normal 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
110
README.md
Normal 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
157
REVIEW.md
Normal 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
19
index.html
Normal 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
2949
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
package.json
Normal file
33
package.json
Normal 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
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
96
src/App.tsx
Normal file
96
src/App.tsx
Normal 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
172
src/apps/AppBuilder.tsx
Normal 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
137
src/apps/Assistant.tsx
Normal 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
218
src/apps/Files.tsx
Normal 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
334
src/apps/Settings.tsx
Normal 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
211
src/apps/Terminal.tsx
Normal 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
176
src/design/tokens.ts
Normal 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
98
src/index.css
Normal 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
340
src/kernel/ai.ts
Normal 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
242
src/kernel/appRuntime.tsx
Normal 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
25
src/kernel/bus.ts
Normal 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
77
src/kernel/db.ts
Normal 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
136
src/kernel/fs.ts
Normal 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
140
src/kernel/providers.ts
Normal 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
66
src/kernel/registry.ts
Normal 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
92
src/kernel/sdk.ts
Normal 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
440
src/kernel/store.ts
Normal 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
128
src/kernel/types.ts
Normal 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
10
src/main.tsx
Normal 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
53
src/system-ui/AppHost.tsx
Normal 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
94
src/system-ui/Boot.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
src/system-ui/ContextMenu.tsx
Normal file
82
src/system-ui/ContextMenu.tsx
Normal 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
118
src/system-ui/Desktop.tsx
Normal 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
142
src/system-ui/Dock.tsx
Normal 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
239
src/system-ui/Launcher.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
src/system-ui/Lockscreen.tsx
Normal file
84
src/system-ui/Lockscreen.tsx
Normal 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
83
src/system-ui/MenuBar.tsx
Normal 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)",
|
||||||
|
};
|
||||||
68
src/system-ui/Notifications.tsx
Normal file
68
src/system-ui/Notifications.tsx
Normal 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
200
src/system-ui/Spotlight.tsx
Normal 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
196
src/system-ui/Window.tsx
Normal 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,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
src/system-ui/WindowManager.tsx
Normal file
18
src/system-ui/WindowManager.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
148
src/system-ui/components/ui.tsx
Normal file
148
src/system-ui/components/ui.tsx
Normal 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
10
src/vite-env.d.ts
vendored
Normal 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
21
tailwind.config.js
Normal 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
57
tasks/lessons.md
Normal 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
75
tasks/todo.md
Normal 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
21
tsconfig.json
Normal 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
1
tsconfig.tsbuildinfo
Normal 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
167
vite.config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user