diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b68780ac..3001d9bf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile diff --git a/.gitignore b/.gitignore index 50d04abb..0bcbb8f4 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,24 @@ CLAUDE.local.md # Cursor .cursor/ +# Kayley data (symlinked/generated — lives in Kayley_Cowork + ~/.kayley-journal) +apps/webuiapps/public/kayley-selfies +apps/webuiapps/public/kayley-selfies/ +apps/webuiapps/public/kayley-selfies-index.json +apps/webuiapps/public/kayley-moments +apps/webuiapps/public/kayley-moments/ +apps/webuiapps/public/kayley-moments-index.json +apps/webuiapps/public/kayley-journal +apps/webuiapps/public/kayley-journal/ +apps/webuiapps/public/kayley-journal-index.json +apps/webuiapps/public/kayley-story +apps/webuiapps/public/kayley-story/ +apps/webuiapps/public/kayley-weeks-index.json +apps/webuiapps/public/kayley-promises-index.json +apps/webuiapps/public/kayley-storylines-index.json +apps/webuiapps/public/kayley-email-index.json +apps/webuiapps/public/kayley-twitter-feed.json + # Internal CI/CD & Scripts scripts/ .gitlab-ci.yml diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..e5fba42e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,170 @@ +# VibeApp Development Guide + +An iframe-based micro-frontend VibeApp system (React + Cloud NAS). + +## Quick Start + +Create a new app: `/vibe {AppName} {Description}` +Modify an existing app: `/vibe {AppName} {ChangeDescription}` +Resume an existing workflow: `/vibe {AppName}` +Re-run from a specific stage: `/vibe {AppName} --from=04-codegen` + +Creation mode runs 6 stages: Requirement Analysis -> Architecture Design -> Task Planning -> Code Generation -> Asset Generation -> Project Integration. +Change mode runs 4 stages: Change Impact Analysis -> Change Task Planning -> Change Code Implementation -> Change Verification. +You can also enter a requirement description directly, and the system will automatically detect and trigger the corresponding mode. + +## Tech Stack + +- **Framework**: React 18 + TypeScript + Vite +- **Styling**: Tailwind CSS (H5 Pages), CSS Modules (Components) +- **Icons**: Lucide React (No Emoji) +- **Storage**: Cloud NAS via `@/lib` (Repository Pattern) +- **Communication**: `postMessage` via `@/lib` + +## File Structure + +```text +src/pages/{AppName}/ +├── components/ # UI Components +├── pages/ # Sub-pages (H5 Templates) +├── data/ # Seed Data (JSON only) +├── assets/ # Generated Image Assets +├── mock/ # Dev Mock Data (TypeScript) +├── store/ # Context/Reducer +├── actions/ # Action Definitions +├── styles/ # CSS Variables +├── i18n/ # en.ts + zh.ts +├── meta/ +│ ├── meta_cn/ # Chinese: guide.md + meta.yaml +│ └── meta_en/ # English: guide.md + meta.yaml +├── index.tsx # Entry (lifecycle reports here only) +├── index.module.scss +└── types.ts +``` + +## Workflow Structure + +```text +.Codex/ +├── commands/vibe.md # Single entry orchestrator +├── workflow/ +│ ├── stages/ # Stage definitions (loaded on-demand) +│ │ ├── 01-analysis.md # Create: Requirement Analysis +│ │ ├── 02-architecture.md # Create: Architecture Design +│ │ ├── 03-planning.md # Create: Task Planning +│ │ ├── 04-codegen.md # Create: Code Generation +│ │ ├── 05-assets.md # Create: Asset Generation +│ │ ├── 06-integration.md # Create: Project Integration +│ │ ├── 01-change-analysis.md # Change: Impact Analysis +│ │ ├── 02-change-planning.md # Change: Task Planning +│ │ ├── 03-change-codegen.md # Change: Code Implementation +│ │ └── 04-change-verification.md # Change: Verification +│ └── rules/ # Stage-specific rules (loaded on-demand) +│ ├── app-definition.md +│ ├── responsive-layout.md +│ ├── guide-md.md +│ └── meta-yaml.md +├── rules/ # Global rules (always loaded) +│ ├── data-interaction.md +│ ├── design-tokens.md +│ ├── concurrent-execution.md +│ └── post-task-check.md +└── thinking/{AppName}/ # Per-app workflow state & artifacts + ├── workflow.json + ├── 01-requirement-analysis.md + ├── 02-architecture-design.md + ├── 03-task-planning.md + ├── 04-code-generation.md + └── outputs/ + ├── requirement-breakdown.json + ├── solution-design.json + └── workflow-todolist.json +``` + +## Rules + +The following global rules in `.Codex/rules/` are mandatory constraints across all stages: + +| Rule File | Description | +|---|---| +| `data-interaction.md` | Data interaction & NAS storage specification | +| `design-tokens.md` | Design token system & usage guidelines | +| `concurrent-execution.md` | Concurrent execution & task scheduling rules | +| `post-task-check.md` | Post-task completion checklist | + +Stage-specific rules are in `.Codex/workflow/rules/`, loaded on-demand by `/vibe`. + +## Auto Workflow Trigger + +When the user enters a requirement description directly (instead of using the `/vibe` command), automatically execute the workflow defined in `.Codex/commands/vibe.md`. + +### Creating a New App + +Detection rules: + +- The user's message contains an explicit new app requirement (e.g., "build a XX app", "I need a XX", "help me develop XX") +- Automatically extract AppName (PascalCase format) from the requirement; Description is the user's full requirement text +- Equivalent to executing `/vibe {AppName} {Description}` + +### Modifying an Existing App + +Detection rules: + +- The user's message explicitly targets an existing App with a change request (e.g., "add lyrics feature to MusicApp", "MusicApp needs to support XX") +- Also includes cases where the App name is not specified but can be inferred from context (e.g., there is only one App, and the user says "add a lyrics display feature") +- Automatically identify AppName; Description is the change requirement text +- Equivalent to executing `/vibe {AppName} {ChangeDescription}` (vibe.md's mode detection logic automatically enters change mode) + +### Cases That Do NOT Trigger + +- The user is clearly asking questions, chatting, or making fine-grained code modifications (e.g., "change this button color to red") +- The user used the `/vibe` command (already handled by the command mechanism) +- The user requests resuming or re-running from a specific stage (must explicitly use `/vibe {AppName}` or `/vibe {AppName} --from=XX`) + +## Testing + +### Unit Tests (Vitest) + +Run from the webuiapps package: + +```bash +cd apps/webuiapps && pnpm test # single run +cd apps/webuiapps && pnpm test:watch # watch mode +``` + +### E2E Tests (Playwright) + +Run from the repo root. The dev server starts automatically. + +```bash +pnpm test:e2e # headless, Chromium only +pnpm test:e2e:ui # interactive UI mode +``` + +- Config: `playwright.config.ts` (root) +- Tests: `e2e/` directory +- The web server (`pnpm dev`) is auto-launched on port 3000 and reused if already running. +- Only Chromium is configured by default; add projects in `playwright.config.ts` for Firefox/WebKit. +- After completing code changes that affect UI or routing, run `pnpm test:e2e` and report pass/fail. + +## Task completion quality bar (mandatory) + +Before declaring a task complete, agents must satisfy all of the following: + +1. **Unit tests must pass** for the affected package(s). + - For `apps/webuiapps`, run the relevant Vitest command(s), for example: + ```bash + cd apps/webuiapps && pnpm test + cd apps/webuiapps && pnpm test:coverage + ``` +2. **Code coverage must be > 90%** for the code touched by the task. + - If current config thresholds are lower, do not treat that as sufficient. + - Add or improve tests until the changed area exceeds 90% coverage, or explicitly report why that is not yet achievable. +3. **E2E coverage must be complete for impacted user flows.** + - Do not stop at smoke tests if the change affects real behavior. + - Cover the primary user path, key state transitions, and at least one meaningful assertion of successful behavior. + - If UI behavior changes, prefer stable selectors (`data-testid`) over fragile class-name/text-only selectors. +4. **Report exact validation commands and results** in the final handoff. + - Include what passed, what failed, and any known gaps. + +Minimum expectation: no task is "done" if unit tests are red, coverage on changed code is below 90%, or impacted E2E coverage is missing/incomplete. diff --git a/PROBLEM.txt b/PROBLEM.txt new file mode 100644 index 00000000..8fd8a7d9 --- /dev/null +++ b/PROBLEM.txt @@ -0,0 +1,48 @@ +PROBLEM: +A tech billionaire dies in his locked study at midnight. Five guests. One killer. + +Victim: Marcus Vale — Founder / CEO of Lumin + + +SUSPECTS: + +Priya Shah Co-founder, ex-girlfriend +Pushed out of the cap table last month. Showed up anyway. + +ACTIONS: Dossier - Interrogate + +Jake Ostrowski Head of security +Ex-Marine. The one who found the body. Master keycard. + +ACTIONS: Dossier - Interrogate + +Dr. Mira Chen Marcus's therapist +Here as "a friend." Carrying a small leather case she won't open. + +ACTIONS: Dossier - Interrogate + +Cole Reyes Investigative journalist (uninvited) +Blocked from every press list. Got in wearing someone else's badge. + +ACTIONS: Dossier - Interrogate + + +Aiden Park Lumin CTO +Marcus's oldest friend. The other master-keycard holder. + +ACTIONS: Dossier - Interrogate + + +LOCATIONS TO SEARCH + +Marcus's locked study +Where the body was found — second floor, door bolted from the inside. + +Second-floor balcony +Open to the cold air. Guests slipped out here to smoke and scheme. + +The main bar +Downstairs. Loud. Everyone passed through at least once. + +Upstairs guest bathroom +Quieter than the downstairs line. Anyone could have excused themselves. \ No newline at end of file diff --git a/apps/webuiapps/Dockerfile b/apps/webuiapps/Dockerfile index 165c5384..cca02906 100644 --- a/apps/webuiapps/Dockerfile +++ b/apps/webuiapps/Dockerfile @@ -1,4 +1,4 @@ -FROM node:20-alpine as builder +FROM node:24-alpine as builder ARG ENV ARG SENTRY_AUTH_TOKEN diff --git a/apps/webuiapps/README.md b/apps/webuiapps/README.md index b6bae8b5..410108a6 100644 --- a/apps/webuiapps/README.md +++ b/apps/webuiapps/README.md @@ -2,6 +2,18 @@ This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. +## YouTube API key (for YouTubeApp search) + +`YouTubeApp` can use the official YouTube Data API v3 to return real search results. + +Create `apps/webuiapps/.env.local` (not committed) with: + +```bash +VITE_YOUTUBE_API_KEY=your_api_key_here +``` + +Restart the dev server after adding/changing the key. + Currently, two official plugins are available: - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) diff --git a/apps/webuiapps/package.json b/apps/webuiapps/package.json index d1db7a9b..d67ab990 100644 --- a/apps/webuiapps/package.json +++ b/apps/webuiapps/package.json @@ -7,25 +7,28 @@ "scripts": { "dev": "vite", "debug": "vite --debug", - "build:test": "NODE_ENV=test vite build", - "build": "NODE_ENV=production vite build", - "build:analyze": "NODE_ENV=production ANALYZE=analyze vite build", + "build:test": "cross-env NODE_ENV=test vite build", + "build": "cross-env NODE_ENV=production vite build", + "build:analyze": "cross-env NODE_ENV=production ANALYZE=analyze vite build", "preview": "vite preview", - "test": "vitest run", - "test:watch": "vitest", - "test:coverage": "vitest run --coverage" + "test": "..\\\\..\\\\node_modules\\\\.bin\\\\vitest.cmd run", + "test:watch": "..\\\\..\\\\node_modules\\\\.bin\\\\vitest.cmd", + "test:coverage": "..\\\\..\\\\node_modules\\\\.bin\\\\vitest.cmd run --coverage" }, "dependencies": { "framer-motion": "^12.34.0", "jszip": "^3.10.1", "react-markdown": "^10.1.0", + "react-rnd": "^10.5.3", "rehype-raw": "^7.0.0", - "remark-gfm": "^4.0.1" + "remark-gfm": "^4.0.1", + "youtube-search-api": "^2.0.1" }, "devDependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.72", "@vitest/coverage-istanbul": "^1.6.1", "@vitest/coverage-v8": "^1.6.1", + "cross-env": "^10.1.0", "happy-dom": "^14.0.0", "jsdom": "^25.0.0", "vitest": "^1.6.1" diff --git a/apps/webuiapps/public/live-stream/aoi-live.mp4 b/apps/webuiapps/public/live-stream/aoi-live.mp4 new file mode 100644 index 00000000..a20120b0 Binary files /dev/null and b/apps/webuiapps/public/live-stream/aoi-live.mp4 differ diff --git a/apps/webuiapps/public/wallpaper-1.jpg b/apps/webuiapps/public/wallpaper-1.jpg new file mode 100644 index 00000000..014ea406 Binary files /dev/null and b/apps/webuiapps/public/wallpaper-1.jpg differ diff --git a/apps/webuiapps/public/wallpaper-2.jpg b/apps/webuiapps/public/wallpaper-2.jpg new file mode 100644 index 00000000..3a18cc20 Binary files /dev/null and b/apps/webuiapps/public/wallpaper-2.jpg differ diff --git a/apps/webuiapps/public/wallpaper-blade-runner-1.jpg b/apps/webuiapps/public/wallpaper-blade-runner-1.jpg new file mode 100644 index 00000000..014ea406 Binary files /dev/null and b/apps/webuiapps/public/wallpaper-blade-runner-1.jpg differ diff --git a/apps/webuiapps/public/wallpaper-blade-runner-2.jpg b/apps/webuiapps/public/wallpaper-blade-runner-2.jpg new file mode 100644 index 00000000..3a18cc20 Binary files /dev/null and b/apps/webuiapps/public/wallpaper-blade-runner-2.jpg differ diff --git a/apps/webuiapps/public/wallpaper-city-view.png b/apps/webuiapps/public/wallpaper-city-view.png new file mode 100644 index 00000000..587b0390 Binary files /dev/null and b/apps/webuiapps/public/wallpaper-city-view.png differ diff --git a/apps/webuiapps/src/common.scss b/apps/webuiapps/src/common.scss index 8625db0b..443f6d70 100644 --- a/apps/webuiapps/src/common.scss +++ b/apps/webuiapps/src/common.scss @@ -4,4 +4,5 @@ body, height: 100%; margin: 0; padding: 0; + overflow: hidden; } diff --git a/apps/webuiapps/src/components/AppWindow/index.module.scss b/apps/webuiapps/src/components/AppWindow/index.module.scss index 2f46c1cb..57cc00d0 100644 --- a/apps/webuiapps/src/components/AppWindow/index.module.scss +++ b/apps/webuiapps/src/components/AppWindow/index.module.scss @@ -1,5 +1,12 @@ .window { position: absolute; + min-width: 300px; + min-height: 200px; +} + +.windowInner { + width: 100%; + height: 100%; border-radius: 10px; overflow: hidden; background: var(--bg-2, #1c1d20); @@ -8,8 +15,6 @@ 0 0 0 1px rgba(255, 255, 255, 0.08); display: flex; flex-direction: column; - min-width: 300px; - min-height: 200px; } .titleBar { @@ -86,27 +91,6 @@ } } -.resizeHandle { - position: absolute; - right: 0; - bottom: 0; - width: 16px; - height: 16px; - cursor: nwse-resize; - z-index: 10; - - &::after { - content: ''; - position: absolute; - right: 3px; - bottom: 3px; - width: 8px; - height: 8px; - border-right: 2px solid rgba(255, 255, 255, 0.3); - border-bottom: 2px solid rgba(255, 255, 255, 0.3); - } -} - .loading { display: flex; align-items: center; diff --git a/apps/webuiapps/src/components/AppWindow/index.tsx b/apps/webuiapps/src/components/AppWindow/index.tsx index c63c4894..3da67c70 100644 --- a/apps/webuiapps/src/components/AppWindow/index.tsx +++ b/apps/webuiapps/src/components/AppWindow/index.tsx @@ -1,5 +1,6 @@ -import React, { useRef, useCallback, lazy, Suspense } from 'react'; -import { X, Minus } from 'lucide-react'; +import React, { useState, useCallback, lazy, Suspense } from 'react'; +import { Rnd } from 'react-rnd'; +import { X, Minus, Maximize2 } from 'lucide-react'; import { type WindowState, closeWindow, @@ -31,123 +32,101 @@ interface Props { } const AppWindow: React.FC = ({ win }) => { - const dragRef = useRef<{ startX: number; startY: number; winX: number; winY: number } | null>( - null, - ); - const resizeRef = useRef<{ startX: number; startY: number; winW: number; winH: number } | null>( - null, - ); - - const handleResizeMouseDown = useCallback( - (e: React.MouseEvent) => { - e.stopPropagation(); - e.preventDefault(); - focusWindow(win.appId); - resizeRef.current = { - startX: e.clientX, - startY: e.clientY, - winW: win.width, - winH: win.height, - }; - - const handleMouseMove = (ev: MouseEvent) => { - if (!resizeRef.current) return; - const dx = ev.clientX - resizeRef.current.startX; - const dy = ev.clientY - resizeRef.current.startY; - resizeWindow(win.appId, resizeRef.current.winW + dx, resizeRef.current.winH + dy); - }; - - const handleMouseUp = () => { - resizeRef.current = null; - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - }; - - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - }, - [win.appId, win.width, win.height], - ); - - const handleMouseDown = useCallback( - (e: React.MouseEvent) => { - focusWindow(win.appId); - dragRef.current = { - startX: e.clientX, - startY: e.clientY, - winX: win.x, - winY: win.y, - }; + const [maximized, setMaximized] = useState(false); + const [preMaxState, setPreMaxState] = useState<{ + x: number; + y: number; + width: number; + height: number; + } | null>(null); - const handleMouseMove = (ev: MouseEvent) => { - if (!dragRef.current) return; - const dx = ev.clientX - dragRef.current.startX; - const dy = ev.clientY - dragRef.current.startY; - moveWindow(win.appId, dragRef.current.winX + dx, dragRef.current.winY + dy); - }; - - const handleMouseUp = () => { - dragRef.current = null; - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - }; - - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - }, - [win.appId, win.x, win.y], - ); + const toggleMax = useCallback(() => { + if (!maximized) { + setPreMaxState({ x: win.x, y: win.y, width: win.width, height: win.height }); + // Inset from edges to keep the bottom dock (wallpaper, upload, chat toggle) accessible. + const inset = 8; + const dockHeight = 80; + moveWindow(win.appId, inset, inset); + resizeWindow( + win.appId, + window.innerWidth - inset * 2, + window.innerHeight - inset * 2 - dockHeight, + ); + setMaximized(true); + } else { + if (preMaxState) { + moveWindow(win.appId, preMaxState.x, preMaxState.y); + resizeWindow(win.appId, preMaxState.width, preMaxState.height); + } + setMaximized(false); + setPreMaxState(null); + } + }, [maximized, preMaxState, win.appId, win.x, win.y, win.width, win.height]); const AppComp = APP_COMPONENTS[win.appId]; if (!AppComp) return null; - if (win.minimized) return null; return ( -
focusWindow(win.appId)} + onDragStop={(_, d) => moveWindow(win.appId, d.x, d.y)} + onResizeStop={(_, __, ref, ___, pos) => { + resizeWindow(win.appId, ref.offsetWidth, ref.offsetHeight); + moveWindow(win.appId, pos.x, pos.y); + }} > -
- {win.title} -
- - +
+
+ {win.title} +
+ + + +
-
-
-
- Loading...
}> - - +
+
+ Loading...
}> + + +
-
-
+ ); }; diff --git a/apps/webuiapps/src/components/ChatPanel/index.module.scss b/apps/webuiapps/src/components/ChatPanel/index.module.scss index 96d49a22..1b300d9e 100644 --- a/apps/webuiapps/src/components/ChatPanel/index.module.scss +++ b/apps/webuiapps/src/components/ChatPanel/index.module.scss @@ -10,6 +10,17 @@ flex-direction: row; } +/* When wrapped in a floating/resizable window (ChatWindow), fill the parent + instead of clinging to the viewport edge. */ +.panel.windowed { + position: relative; + width: 100%; + height: 100%; + border-left: none; + min-width: 0; + min-height: 0; +} + /* ---- Left: Character Avatar ---- */ .avatarSide { @@ -452,3 +463,93 @@ color: rgba(250, 234, 95, 0.7); margin-top: 4px; } + +// ── Kayley channel additions ──────────────────────────────────────────────── + +.kayleyDot { + display: inline-block; + width: 6px; + height: 6px; + border-radius: 50%; + background: #4ade80; + margin-left: 6px; + vertical-align: middle; +} + +.sttDraftBar { + padding: 8px 16px; + border-top: 1px solid rgba(255, 255, 255, 0.06); + background: rgba(250, 234, 95, 0.06); + display: flex; + align-items: center; + gap: 8px; +} + +.sttDraftText { + flex: 1; + font-size: 13px; + color: rgba(255, 255, 255, 0.8); + font-style: italic; +} + +.sttDraftConfirm { + background: #faea5f; + color: #121214; + border: none; + border-radius: 6px; + padding: 4px 10px; + cursor: pointer; + font-weight: 600; + font-size: 12px; + white-space: nowrap; + + &:hover { + background: #f5e04a; + } +} + +.sttDraftDismiss { + background: transparent; + color: rgba(255, 255, 255, 0.5); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 6px; + padding: 4px 8px; + cursor: pointer; + font-size: 12px; + + &:hover { + color: rgba(255, 255, 255, 0.8); + border-color: rgba(255, 255, 255, 0.3); + } +} + +.micBtn { + background: transparent; + color: rgba(255, 255, 255, 0.6); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 8px; + padding: 8px 10px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.15s; + flex-shrink: 0; + + &:hover { + color: rgba(255, 255, 255, 0.9); + border-color: rgba(255, 255, 255, 0.3); + } +} + +.micActive { + background: rgba(239, 68, 68, 0.15); + color: #ef4444; + border-color: rgba(239, 68, 68, 0.4); + animation: micPulse 1s ease-in-out infinite; +} + +@keyframes micPulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.6; } +} diff --git a/apps/webuiapps/src/components/ChatPanel/index.tsx b/apps/webuiapps/src/components/ChatPanel/index.tsx index 740488c3..f49d65f1 100644 --- a/apps/webuiapps/src/components/ChatPanel/index.tsx +++ b/apps/webuiapps/src/components/ChatPanel/index.tsx @@ -9,8 +9,11 @@ import { ChevronRight, Pencil, List, + Mic, + X, } from 'lucide-react'; import { chat, loadConfig, loadConfigSync, saveConfig, type ChatMessage } from '@/lib/llmClient'; +import { useKayleyChannel } from '@/hooks/useKayleyChannel'; import { PROVIDER_MODELS, getDefaultProviderConfig, @@ -35,6 +38,7 @@ import { } from '@/lib/appRegistry'; import { seedMetaFiles } from '@/lib/seedMeta'; import { dispatchAgentAction, onUserAction } from '@/lib/vibeContainerMock'; +import { sendKayleyWsPayload } from '@/lib/kayleyWsBridge'; import { closeAllWindows } from '@/lib/windowManager'; import { getFileToolDefinitions, isFileTool, executeFileTool } from '@/lib/fileTools'; import { setSessionPath } from '@/lib/sessionPath'; @@ -85,6 +89,10 @@ import CharacterPanel from './CharacterPanel'; import ModPanel from './ModPanel'; import styles from './index.module.scss'; +// If the Kayley brain (MCP) doesn't respond within this window we release the +// UI so `loading` can't stick forever. +const KAYLEY_SEND_TIMEOUT_MS = 90_000; + // --------------------------------------------------------------------------- // Extended DisplayMessage with character-specific fields // --------------------------------------------------------------------------- @@ -388,7 +396,13 @@ const ChatPanel: React.FC<{ visible?: boolean; zIndex?: number; onFocus?: () => void; -}> = ({ onClose, visible = true, zIndex, onFocus }) => { + /** + * When true, the panel renders filling its parent (for use inside a + * floating/resizable window wrapper like ChatWindow). Defaults to false, + * which keeps the legacy right-stuck drawer behavior. + */ + windowed?: boolean; +}> = ({ onClose, visible = true, zIndex, onFocus, windowed = false }) => { // Character + Mod state (collection-based) const [charCollection, setCharCollection] = useState( () => loadCharacterCollectionSync() ?? DEFAULT_CHAR_COLLECTION, @@ -451,6 +465,7 @@ const ChatPanel: React.FC<{ // Pending tool calls for current response (grouped per assistant turn) const pendingToolCallsRef = useRef([]); + const messagesWrapRef = useRef(null); const messagesEndRef = useRef(null); const messagesRef = useRef(messages); messagesRef.current = messages; @@ -459,6 +474,14 @@ const ChatPanel: React.FC<{ const suggestedRepliesRef = useRef(suggestedReplies); suggestedRepliesRef.current = suggestedReplies; + // ── Kayley channel (ws://localhost:5180) ────────────────────── + // When connected, bypasses local LLM and routes all chat through the + // Claude Opus brain with full MCPs + memory. Falls back to local LLM if + // Kayley server is not running. + const kayley = useKayleyChannel(); + const kayleyRef = useRef(kayley); + kayleyRef.current = kayley; + // Debounced save const saveTimerRef = useRef | null>(null); @@ -624,13 +647,46 @@ const ChatPanel: React.FC<{ }, [modCollection, seedPrologue]); useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + const el = messagesWrapRef.current; + if (!el) return; + el.scrollTop = el.scrollHeight; }, [messages, loading]); const addMessage = useCallback((msg: CharacterDisplayMessage) => { setMessages((prev) => [...prev, msg]); }, []); + // ── Kayley send timeout ─────────────────────────────────────── + // If the Kayley brain stalls (MCP doesn't reply), clear `loading` after + // KAYLEY_SEND_TIMEOUT_MS so the UI doesn't lock forever. Cleared whenever a + // new reply arrives or the component unmounts. + const kayleySendTimeoutRef = useRef | null>(null); + + const clearKayleySendTimeout = useCallback(() => { + if (kayleySendTimeoutRef.current) { + clearTimeout(kayleySendTimeoutRef.current); + kayleySendTimeoutRef.current = null; + } + }, []); + + useEffect(() => { + return () => clearKayleySendTimeout(); + }, [clearKayleySendTimeout]); + + // ── Kayley response handler ─────────────────────────────────── + // Each new latestMessage is a unique object (different timestamp), so this + // effect fires exactly once per incoming reply. + useEffect(() => { + if (!kayley.latestMessage) return; + clearKayleySendTimeout(); + addMessage({ + id: String(kayley.latestMessage.timestamp), + role: 'assistant', + content: kayley.latestMessage.text, + }); + setLoading(false); + }, [kayley.latestMessage, addMessage, clearKayleySendTimeout]); + const configRef = useRef(config); configRef.current = config; const imageGenConfigRef = useRef(imageGenConfig); @@ -675,9 +731,6 @@ const ChatPanel: React.FC<{ // Listen for user actions from apps useEffect(() => { const unsubscribe = onUserAction((event: unknown) => { - const cfg = configRef.current; - if (!hasUsableLLMConfig(cfg)) return; - const evt = event as { app_action?: { app_id: number; @@ -697,17 +750,57 @@ const ChatPanel: React.FC<{ if (!app) return; const actionMsg = `[User performed action in ${app.displayName} (appName: ${app.appName})] action_type: ${action.action_type}, params: ${JSON.stringify(action.params || {})}`; + + if (kayleyRef.current.connected) { + sendKayleyWsPayload({ + type: 'openroom_user_action', + line: actionMsg, + }); + return; + } + + const cfg = configRef.current; + if (!hasUsableLLMConfig(cfg)) return; + actionQueueRef.current.push(actionMsg); processActionQueue(); }); return unsubscribe; }, [processActionQueue]); - // Send message + // Send message — routes to Kayley brain if connected, local LLM otherwise const handleSend = useCallback( async (overrideText?: string) => { const text = overrideText ?? input.trim(); if (!text || loading) return; + + const kRef = kayleyRef.current; + + if (kRef.connected) { + // ── Kayley brain mode — bypass local LLM ──────────────── + if (!overrideText) setInput(''); + setSuggestedReplies([]); + addMessage({ id: String(Date.now()), role: 'user', content: text }); + setLoading(true); + kRef.sendText(text); + + // 90-second stall guard — if MCP doesn't reply, release the UI and + // surface an inline notice. Cleared when latestMessage arrives or on unmount. + clearKayleySendTimeout(); + kayleySendTimeoutRef.current = setTimeout(() => { + kayleySendTimeoutRef.current = null; + setLoading(false); + addMessage({ + id: String(Date.now()), + role: 'assistant', + content: + "Kayley's thinking took too long — try again or check the connection.", + }); + }, KAYLEY_SEND_TIMEOUT_MS); + return; + } + + // ── Local LLM mode (fallback when Kayley is not running) ── if (!hasUsableLLMConfig(config)) { setShowSettings(true); return; @@ -740,9 +833,25 @@ const ChatPanel: React.FC<{ setLoading(false); } }, - [input, loading, config, chatHistory, addMessage], + [input, loading, config, chatHistory, addMessage, clearKayleySendTimeout], ); + // ── Bridge from HostedHUD pill input ────────────────────────── + // HostedHUD handles Kayley-connected sends itself. When Kayley is NOT + // connected, it dispatches `hosted-hud-send` so the local-LLM fallback + // path (handleSend) runs here with full tool-loop support. + useEffect(() => { + const onHudSend = (ev: Event) => { + const detail = (ev as CustomEvent<{ text: string }>).detail; + if (!detail?.text) return; + handleSend(detail.text); + }; + window.addEventListener('hosted-hud-send', onHudSend as EventListener); + return () => { + window.removeEventListener('hosted-hud-send', onHudSend as EventListener); + }; + }, [handleSend]); + // Core conversation loop const runConversation = async (history: ChatMessage[], cfg: LLMConfig) => { await seedMetaFiles(); @@ -1041,7 +1150,7 @@ const ChatPanel: React.FC<{ return ( <>
+ {/* Internal header hidden in windowed mode — ChatWindow renders + its own header with min/max/close. */} + {!windowed && (
{character.character_name} + {kayley.connected && ( + + )}
-
setShowModPanel(true)} style={{ cursor: 'pointer' }}> - -
- - - + {/* Stage / Reset / Clear / Settings / Maximize: only shown in + the original (non-windowed) layout. Steven's windowed UI + prefers a minimal header — Min + Close only. */} + {!windowed && ( + <> +
setShowModPanel(true)} style={{ cursor: 'pointer' }}> + +
+ + + + + + )} - + {windowed && ( + + )}
+ )} -
+
{messages.length === 0 && (
- {hasUsableLLMConfig(config) + {kayley.connected + ? 'Kayley is ready — type anything or tap the mic' + : hasUsableLLMConfig(config) ? `${character.character_name} is ready to chat...` : 'Click the gear icon to configure your LLM connection'}
@@ -1137,8 +1276,9 @@ const ChatPanel: React.FC<{
- {/* Suggested Replies */} - {suggestedReplies.length > 0 && !loading && ( + {/* Suggested Replies — hidden in windowed (Kayley) layout per + Steven's preference for a minimal chat surface. */} + {!windowed && suggestedReplies.length > 0 && !loading && (
{suggestedReplies.map((reply, i) => (
)} + {/* STT draft confirmation bar — shown after Whisper transcribes mic audio */} + {kayley.sttDraft !== null && ( +
+ {kayley.sttDraft} + + +
+ )} +