diff --git a/.gitignore b/.gitignore index 50d04ab..0bcbb8f 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/apps/webuiapps/package.json b/apps/webuiapps/package.json index d1db7a9..db95661 100644 --- a/apps/webuiapps/package.json +++ b/apps/webuiapps/package.json @@ -7,9 +7,9 @@ "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", @@ -26,6 +26,7 @@ "@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/src/components/ChatPanel/index.module.scss b/apps/webuiapps/src/components/ChatPanel/index.module.scss index 96d49a2..cbdbeb6 100644 --- a/apps/webuiapps/src/components/ChatPanel/index.module.scss +++ b/apps/webuiapps/src/components/ChatPanel/index.module.scss @@ -452,3 +452,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 740488c..8934fef 100644 --- a/apps/webuiapps/src/components/ChatPanel/index.tsx +++ b/apps/webuiapps/src/components/ChatPanel/index.tsx @@ -9,8 +9,10 @@ import { ChevronRight, Pencil, List, + Mic, } from 'lucide-react'; import { chat, loadConfig, loadConfigSync, saveConfig, type ChatMessage } from '@/lib/llmClient'; +import { useKayleyChannel } from '@/hooks/useKayleyChannel'; import { PROVIDER_MODELS, getDefaultProviderConfig, @@ -85,6 +87,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 // --------------------------------------------------------------------------- @@ -459,6 +465,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); @@ -631,6 +645,37 @@ const ChatPanel: React.FC<{ 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); @@ -703,11 +748,39 @@ const ChatPanel: React.FC<{ 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,7 +813,7 @@ const ChatPanel: React.FC<{ setLoading(false); } }, - [input, loading, config, chatHistory, addMessage], + [input, loading, config, chatHistory, addMessage, clearKayleySendTimeout], ); // Core conversation loop @@ -1064,6 +1137,9 @@ const ChatPanel: React.FC<{ style={{ cursor: 'pointer' }} > {character.character_name} + {kayley.connected && ( + + )}
@@ -1106,7 +1182,9 @@ const ChatPanel: React.FC<{
{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'}
@@ -1148,6 +1226,48 @@ const ChatPanel: React.FC<{
)} + {/* STT draft confirmation bar — shown after Whisper transcribes mic audio */} + {kayley.sttDraft !== null && ( +
+ {kayley.sttDraft} + + +
+ )} +