From f112df0877b23240a6df7e90d6b3fc1cd3dbe1a9 Mon Sep 17 00:00:00 2001 From: crease Date: Sat, 21 Mar 2026 16:50:43 +0800 Subject: [PATCH 1/2] feat: enable import character cards from SillyTavern Add ST card import functionality with PNG/ZIP file upload, character data extraction, and automatic mod generation via LLM. Includes CLI import command, extract preview modal, and batch processing support. --- .claude/commands/import.md | 121 ++ apps/webuiapps/package.json | 3 + .../src/components/ChatPanel/ModPanel.tsx | 5 +- .../src/components/ChatPanel/index.tsx | 32 +- .../Shell/ExtractPreviewModal.module.scss | 351 ++++++ .../components/Shell/ExtractPreviewModal.tsx | 216 ++++ .../src/components/Shell/index.module.scss | 296 +++++ apps/webuiapps/src/components/Shell/index.tsx | 225 +++- .../src/components/Shell/modPrompt.ts | 138 +++ apps/webuiapps/src/lib/cardExtractor.ts | 1024 +++++++++++++++++ apps/webuiapps/src/lib/index.ts | 12 + apps/webuiapps/src/lib/modManager.ts | 2 + apps/webuiapps/vite.config.ts | 442 +++++++ pnpm-lock.yaml | 551 ++++----- prompt_generate_mod_en.md | 135 +++ 15 files changed, 3249 insertions(+), 304 deletions(-) create mode 100644 .claude/commands/import.md create mode 100644 apps/webuiapps/src/components/Shell/ExtractPreviewModal.module.scss create mode 100644 apps/webuiapps/src/components/Shell/ExtractPreviewModal.tsx create mode 100644 apps/webuiapps/src/components/Shell/modPrompt.ts create mode 100644 apps/webuiapps/src/lib/cardExtractor.ts create mode 100644 prompt_generate_mod_en.md diff --git a/.claude/commands/import.md b/.claude/commands/import.md new file mode 100644 index 0000000..55c5352 --- /dev/null +++ b/.claude/commands/import.md @@ -0,0 +1,121 @@ +# ST Card Import — CLI Orchestrator + +Import SillyTavern character card files (PNG / CharX / ZIP) into the VibeApp system. Extracts apps from the card's character book and generates VibeApp code + mod scenario. + +## Parameter Parsing + +- `$ARGUMENTS` format: `{FilePath}` +- `FilePath`: path to a `.png`, `.charx`, or `.zip` file containing a SillyTavern character card + +If `$ARGUMENTS` is empty, ask the user for the file path. + +## Execution Protocol + +### 1. Extract Card Data + +Run the extraction script: + +```bash +python3 .claude/scripts/extract-card.py "{FilePath}" +``` + +Capture the JSON output. If extraction fails, report the error and stop. + +### 2. Analyze & Present Results + +Parse the extraction output JSON. Display a structured summary to the user: + +``` +Card: {source} ({source_type}) +Character: {character.name} + Description: {first 100 chars of character.description}... + +Apps Found ({count}): + 1. [{comment}] — keywords: {keywords} | format: {format} | tags: {tag_names} + 2. ... + +Lore Entries: {count} +Regex Scripts: {count} +``` + +### 3. User Selection + +Ask the user which apps to generate using AskUserQuestion: +- Option 1: Generate all apps (recommended) +- Option 2: Select specific apps +- Option 3: Skip app generation (mod only) + +If the user selects specific apps, present a multi-select list of extracted apps. + +### 4. Generate Apps via Vibe Workflow + +For each selected app, derive a VibeApp requirement from the card data: + +#### 4.1 App Name Derivation + +Convert the app's `comment` field to PascalCase for the VibeApp name: +- `"live stream"` → `LiveStream` +- `"social-feed"` → `SocialFeed` +- `"music app"` → `MusicApp` +- Chinese names: translate to English PascalCase + +#### 4.2 Requirement Generation + +Build a comprehensive requirement description from the extracted data: + +``` +A {format}-based app that provides {functional description based on keywords and tags}. + +UI Features: +{For each tag: describe the UI element it represents} + +Data Resources: +{For each resource list: describe what data it manages} + +Content Format: {format type — xml tags / bracket notation / prose} + +Regex Scripts (for reference): +{List relevant scripts that transform this app's output} +``` + +#### 4.3 Execute Vibe Workflow + +For each app, execute the `/vibe` command: + +``` +/vibe {PascalCaseAppName} {GeneratedRequirement} +``` + +Process apps **sequentially** — each vibe workflow must complete before starting the next. + +**Important**: Before starting each app, check if a VibeApp with that name already exists at `src/pages/{AppName}/`. If it does, ask the user whether to: +- Skip this app +- Overwrite (delete existing and regenerate) +- Use change mode (modify existing app) + +### 5. Completion Report + +``` +═══════════════════════════════════════ + ST Card Import Complete +═══════════════════════════════════════ + Source: {filename} ({source_type}) + Character: {character.name} + + Apps Generated ({count}): + • {AppName1} → http://localhost:3000/{app-name-1} + • {AppName2} → http://localhost:3000/{app-name-2} +═══════════════════════════════════════ +``` + +## Error Handling + +- If extraction fails: report error, suggest checking file format +- If a vibe workflow fails for one app: log error, continue with remaining apps + +## Notes + +- The extraction script handles both PNG (ccv3/chara tEXt chunks) and CharX/ZIP (card.json) formats +- Apps are identified by character book entries containing `` in their content +- The vibe workflow handles all code generation, architecture, and integration +- Lore entries are preserved as reference data but not directly used in app generation diff --git a/apps/webuiapps/package.json b/apps/webuiapps/package.json index d5f3d85..27e1e38 100644 --- a/apps/webuiapps/package.json +++ b/apps/webuiapps/package.json @@ -16,12 +16,15 @@ "test:coverage": "vitest run --coverage" }, "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.2.72", "framer-motion": "^12.34.0", + "jszip": "^3.10.1", "react-markdown": "^10.1.0", "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1" }, "devDependencies": { + "@types/jszip": "^3.4.1", "@vitest/coverage-istanbul": "^1.6.1", "@vitest/coverage-v8": "^1.6.1", "happy-dom": "^14.0.0", diff --git a/apps/webuiapps/src/components/ChatPanel/ModPanel.tsx b/apps/webuiapps/src/components/ChatPanel/ModPanel.tsx index 03de2bf..932b78b 100644 --- a/apps/webuiapps/src/components/ChatPanel/ModPanel.tsx +++ b/apps/webuiapps/src/components/ChatPanel/ModPanel.tsx @@ -16,11 +16,12 @@ interface ModPanelProps { collection: ModCollection; onSave: (collection: ModCollection) => void; onClose: () => void; + initialEditId?: string; } -const ModPanel: React.FC = ({ collection, onSave, onClose }) => { +const ModPanel: React.FC = ({ collection, onSave, onClose, initialEditId }) => { const [col, setCol] = useState(() => ({ ...collection })); - const [editingId, setEditingId] = useState(null); + const [editingId, setEditingId] = useState(initialEditId ?? null); const mods = getModList(col); const activeId = col.activeId; diff --git a/apps/webuiapps/src/components/ChatPanel/index.tsx b/apps/webuiapps/src/components/ChatPanel/index.tsx index 76a8ceb..2befa6b 100644 --- a/apps/webuiapps/src/components/ChatPanel/index.tsx +++ b/apps/webuiapps/src/components/ChatPanel/index.tsx @@ -425,8 +425,19 @@ const ChatPanel: React.FC<{ const [suggestedReplies, setSuggestedReplies] = useState([]); const [showCharacterPanel, setShowCharacterPanel] = useState(false); const [showModPanel, setShowModPanel] = useState(false); + const [initialEditModId, setInitialEditModId] = useState(); const [currentEmotion, setCurrentEmotion] = useState(); + // Auto-open mod editor if redirected from card import + useEffect(() => { + const editModId = sessionStorage.getItem('openroom_edit_mod_id'); + if (editModId) { + sessionStorage.removeItem('openroom_edit_mod_id'); + setInitialEditModId(editModId); + setShowModPanel(true); + } + }, []); + // Memories loaded for SP injection const [memories, setMemories] = useState([]); @@ -540,6 +551,20 @@ const ChatPanel: React.FC<{ }); }, []); + // Listen for mod collection changes from Shell (e.g. after mod generation) + useEffect(() => { + const handler = (e: Event) => { + const col = (e as CustomEvent).detail; + if (col) { + setModCollection(col); + const entry = getActiveModEntry(col); + setModManager(new ModManager(entry.config, entry.state)); + } + }; + window.addEventListener('mod-collection-changed', handler); + return () => window.removeEventListener('mod-collection-changed', handler); + }, []); + const handleClearHistory = useCallback(async () => { await clearChatHistory(sessionPathRef.current); seedPrologue(); @@ -1169,14 +1194,19 @@ const ChatPanel: React.FC<{ {showModPanel && ( { setModCollection(col); saveModCollection(col); const entry = getActiveModEntry(col); setModManager(new ModManager(entry.config, entry.state)); setShowModPanel(false); + setInitialEditModId(undefined); + }} + onClose={() => { + setShowModPanel(false); + setInitialEditModId(undefined); }} - onClose={() => setShowModPanel(false)} /> )} diff --git a/apps/webuiapps/src/components/Shell/ExtractPreviewModal.module.scss b/apps/webuiapps/src/components/Shell/ExtractPreviewModal.module.scss new file mode 100644 index 0000000..7d3300e --- /dev/null +++ b/apps/webuiapps/src/components/Shell/ExtractPreviewModal.module.scss @@ -0,0 +1,351 @@ +.overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + z-index: 10002; + display: flex; + align-items: center; + justify-content: center; + backdrop-filter: blur(4px); +} + +.modal { + width: 600px; + max-width: 92vw; + max-height: 85vh; + background: var(--bg-2, #1c1d20); + border-radius: 16px; + border: 1px solid rgba(255, 255, 255, 0.1); + box-shadow: 0 8px 40px rgba(0, 0, 0, 0.6); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + color: rgba(255, 255, 255, 0.9); + font-size: 16px; + font-weight: 600; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + flex-shrink: 0; +} + +.closeBtn { + background: none; + border: none; + color: rgba(255, 255, 255, 0.5); + cursor: pointer; + padding: 4px; + border-radius: 4px; + + &:hover { + color: rgba(255, 255, 255, 0.9); + background: rgba(255, 255, 255, 0.1); + } +} + +.body { + flex: 1; + overflow-y: auto; + padding: 16px 20px; + display: flex; + flex-direction: column; + gap: 20px; +} + +.section { + display: flex; + flex-direction: column; + gap: 12px; +} + +.sectionTitle { + margin: 0; + font-size: 13px; + font-weight: 600; + color: rgba(255, 255, 255, 0.5); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +/* App cards */ +.appList { + display: flex; + flex-direction: column; + gap: 8px; +} + +.appCard { + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 10px; + padding: 12px; + display: flex; + flex-direction: column; + gap: 8px; + cursor: pointer; + transition: + border-color 0.15s, + opacity 0.15s; + + &.selected { + border-color: rgba(250, 234, 95, 0.3); + } + + &.unselected { + opacity: 0.5; + } +} + +.checkbox { + width: 16px; + height: 16px; + border-radius: 4px; + border: 1.5px solid rgba(255, 255, 255, 0.3); + flex-shrink: 0; + position: relative; + transition: all 0.15s; + + &.checked { + background: #faea5f; + border-color: #faea5f; + + &::after { + content: ''; + position: absolute; + left: 4px; + top: 1px; + width: 5px; + height: 9px; + border: solid #121214; + border-width: 0 2px 2px 0; + transform: rotate(45deg); + } + } +} + +.appHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.appInfo { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.appName { + font-size: 14px; + font-weight: 600; + color: rgba(255, 255, 255, 0.9); +} + +.appId { + font-size: 11px; + color: rgba(255, 255, 255, 0.35); + background: rgba(255, 255, 255, 0.06); + padding: 2px 6px; + border-radius: 4px; + font-family: monospace; +} + +.genBtn { + display: flex; + align-items: center; + gap: 4px; + padding: 5px 12px; + border-radius: 6px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.75); + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; + flex-shrink: 0; + + &:hover:not(:disabled) { + background: rgba(250, 234, 95, 0.15); + color: #faea5f; + border-color: rgba(250, 234, 95, 0.3); + } + + &:disabled { + cursor: not-allowed; + opacity: 0.5; + } + + &.done { + background: rgba(74, 222, 128, 0.12); + color: #4ade80; + border-color: rgba(74, 222, 128, 0.2); + opacity: 1; + } + + &.busy { + background: rgba(46, 167, 255, 0.12); + color: #2ea7ff; + border-color: rgba(46, 167, 255, 0.2); + opacity: 1; + } +} + +.appScenario { + margin: 0; + font-size: 12px; + color: rgba(255, 255, 255, 0.55); + line-height: 1.5; +} + +.appMeta { + display: flex; + align-items: flex-start; + gap: 8px; +} + +.metaLabel { + font-size: 11px; + color: rgba(255, 255, 255, 0.4); + flex-shrink: 0; + min-width: 60px; + padding-top: 2px; +} + +.tagList { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.tag { + font-size: 11px; + padding: 1px 6px; + border-radius: 4px; + background: rgba(96, 131, 255, 0.12); + color: rgba(96, 131, 255, 0.85); +} + +.featureList { + display: flex; + flex-direction: column; + gap: 2px; +} + +.feature { + font-size: 12px; + color: rgba(255, 255, 255, 0.6); + line-height: 1.4; +} + +.errorMsg { + margin: 0; + font-size: 12px; + color: var(--color-red, #ff3f4d); + padding: 4px 8px; + background: rgba(255, 63, 77, 0.1); + border-radius: 4px; +} + +/* Character card */ +.charCard { + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 10px; + padding: 12px; + display: flex; + flex-direction: column; + gap: 10px; + max-height: 300px; + overflow-y: auto; +} + +.charField { + display: flex; + flex-direction: column; + gap: 4px; +} + +.charLabel { + font-size: 11px; + color: rgba(255, 255, 255, 0.4); + font-weight: 500; +} + +.charValue { + font-size: 14px; + color: rgba(255, 255, 255, 0.9); + font-weight: 600; +} + +.charText { + margin: 0; + font-size: 13px; + color: rgba(255, 255, 255, 0.65); + line-height: 1.5; + overflow-y: visible; + white-space: pre-wrap; + word-break: break-word; +} + +/* Footer */ +.footer { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 12px 20px; + border-top: 1px solid rgba(255, 255, 255, 0.08); + flex-shrink: 0; +} + +.cancelBtn { + padding: 8px 20px; + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: transparent; + color: rgba(255, 255, 255, 0.65); + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s; + + &:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.06); + color: rgba(255, 255, 255, 0.85); + } + + &:disabled { + cursor: not-allowed; + opacity: 0.5; + } +} + +.confirmBtn { + padding: 8px 20px; + border-radius: 8px; + border: none; + background: #faea5f; + color: #121214; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s; + box-shadow: 0 0 12px rgba(250, 234, 95, 0.3); + + &:hover:not(:disabled) { + background: #fffdbb; + } + + &:disabled { + cursor: not-allowed; + opacity: 0.6; + } +} diff --git a/apps/webuiapps/src/components/Shell/ExtractPreviewModal.tsx b/apps/webuiapps/src/components/Shell/ExtractPreviewModal.tsx new file mode 100644 index 0000000..5a58b6c --- /dev/null +++ b/apps/webuiapps/src/components/Shell/ExtractPreviewModal.tsx @@ -0,0 +1,216 @@ +import React, { useState, useCallback, useEffect, useRef } from 'react'; +import { X, Zap } from 'lucide-react'; +import type { Manifest, AppEntry } from '@/lib'; +import styles from './ExtractPreviewModal.module.scss'; + +interface ExtractPreviewModalProps { + manifest: Manifest; + generating: boolean; + modGenerating: boolean; + genProgress: Record; + onGenerateApp: (app: AppEntry) => void; + onConfirm: (selectedApps: AppEntry[]) => void; + onCancel: () => void; +} + +const ExtractPreviewModal: React.FC = ({ + manifest, + generating, + modGenerating, + genProgress, + onGenerateApp, + onConfirm, + onCancel, +}) => { + const { apps, character } = manifest; + const [selectedIds, setSelectedIds] = useState>(() => new Set(apps.map((a) => a.id))); + + // Keep selectedIds in sync when app IDs change (e.g. after summarize renames them) + const prevAppIdsRef = useRef(apps.map((a) => a.id)); + useEffect(() => { + const prevIds = prevAppIdsRef.current; + const currIds = apps.map((a) => a.id); + if (prevIds.length === currIds.length) { + setSelectedIds((prev) => { + const next = new Set(prev); + for (let i = 0; i < prevIds.length; i++) { + if (prevIds[i] !== currIds[i] && next.has(prevIds[i])) { + next.delete(prevIds[i]); + next.add(currIds[i]); + } + } + return next; + }); + } + prevAppIdsRef.current = currIds; + }, [apps]); + + const toggleSelect = useCallback((id: string) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }, []); + + const selectedApps = apps.filter((a) => selectedIds.has(a.id)); + + return ( +
+
e.stopPropagation()}> + {/* Header */} +
+ Card Analysis Result + +
+ +
+ {/* Apps section */} + {apps.length > 0 && ( +
+

+ Apps ({selectedApps.length}/{apps.length}) +

+
+ {apps.map((app) => { + const progress = genProgress[app.id]; + const appStatus = progress?.status; + return ( +
!generating && toggleSelect(app.id)} + > +
+
+ + {app.name} + {app.id} +
+ +
+ + {app.format && app.format.length > 20 && ( +

{app.format}

+ )} + + {app.keywords.length > 0 && ( +
+ Keywords +
+ {app.keywords.map((kw, i) => ( + + {kw} + + ))} +
+
+ )} + + {app.tags.length > 0 && ( +
+ Features +
+ {app.tags + .filter((t) => t.description) + .map((t, i) => ( + + {t.name}: {t.description} + + ))} +
+
+ )} + + {progress?.status === 'error' && progress.message && ( +

{progress.message}

+ )} +
+ ); + })} +
+
+ )} + + {/* Character section */} +
+

Character

+
+ {character.name && ( +
+ Name + {character.name} +
+ )} + {character.description && ( +
+ Description +

{character.description}

+
+ )} + {character.firstMessage && ( +
+ First Message +

{character.firstMessage}

+
+ )} +
+
+
+ + {/* Footer */} +
+ + +
+
+
+ ); +}; + +export default ExtractPreviewModal; diff --git a/apps/webuiapps/src/components/Shell/index.module.scss b/apps/webuiapps/src/components/Shell/index.module.scss index bce2305..01ba414 100644 --- a/apps/webuiapps/src/components/Shell/index.module.scss +++ b/apps/webuiapps/src/components/Shell/index.module.scss @@ -254,3 +254,299 @@ opacity: 0.4; } } + +/* Floating add button — positioned to the left of the bottom bar */ +.addBtn { + position: fixed; + bottom: 22px; + left: calc(50% - 140px); + transform: translateX(-50%); + width: 42px; + height: 42px; + border-radius: 50%; + border: 2px solid rgba(255, 255, 255, 0.2); + background: rgba(250, 234, 95, 0.9); + color: #121214; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); + transition: all 0.2s; + + &:hover { + transform: translateX(-50%) scale(1.12); + background: #faea5f; + } + + &.chatOpen { + left: calc(50% - 140px - 160px); + } +} + +/* Upload overlay & modal */ +.uploadOverlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + z-index: 10001; + display: flex; + align-items: center; + justify-content: center; + backdrop-filter: blur(4px); +} + +.uploadModal { + width: 400px; + max-width: 90vw; + background: #1c1d20; + border-radius: 16px; + border: 1px solid rgba(255, 255, 255, 0.1); + box-shadow: 0 8px 40px rgba(0, 0, 0, 0.6); + padding: 24px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.uploadHeader { + display: flex; + align-items: center; + justify-content: space-between; + color: rgba(255, 255, 255, 0.9); + font-size: 16px; + font-weight: 600; +} + +.uploadClose { + background: none; + border: none; + color: rgba(255, 255, 255, 0.5); + cursor: pointer; + padding: 4px; + border-radius: 4px; + + &:hover { + color: rgba(255, 255, 255, 0.9); + background: rgba(255, 255, 255, 0.1); + } +} + +.uploadDropZone { + border: 2px dashed rgba(255, 255, 255, 0.15); + border-radius: 12px; + padding: 32px; + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + cursor: pointer; + color: rgba(255, 255, 255, 0.5); + transition: all 0.15s; + + &:hover { + border-color: rgba(250, 234, 95, 0.4); + color: rgba(255, 255, 255, 0.7); + background: rgba(255, 255, 255, 0.03); + } + + p { + margin: 0; + font-size: 14px; + } +} + +.uploadHint { + font-size: 12px !important; + color: rgba(255, 255, 255, 0.35); +} + +.uploadedFileCenter { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 24px 16px; + background: rgba(255, 255, 255, 0.04); + border-radius: 12px; + color: rgba(255, 255, 255, 0.75); + position: relative; +} + +.uploadFileName { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13px; + text-align: center; +} + +.analyzingCard { + display: flex; + align-items: center; + gap: 12px; + padding: 16px 24px; + background: var(--bg-2, #1c1d20); + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.85); + font-size: 14px; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.5); +} + +.analyzingSpinner { + width: 20px; + height: 20px; + border: 2px solid rgba(255, 255, 255, 0.15); + border-top-color: #faea5f; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.uploadRemoveBtn { + background: none; + border: none; + color: rgba(255, 255, 255, 0.4); + cursor: pointer; + padding: 2px; + border-radius: 4px; + display: flex; + + &:hover { + color: #ff3f4d; + background: rgba(255, 63, 77, 0.15); + } +} + +.uploadSubmitBtn { + padding: 10px 16px; + border-radius: 10px; + border: none; + background: rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.35); + font-weight: 600; + font-size: 14px; + cursor: pointer; + transition: all 0.15s; + + &.active { + background: #faea5f; + color: #121214; + box-shadow: 0 0 12px rgba(250, 234, 95, 0.4); + + &:hover { + background: #fffdbb; + } + } + + &:disabled { + cursor: not-allowed; + } +} + +/* Generation progress panel */ +.genPanel { + position: fixed; + top: 16px; + right: 16px; + width: 300px; + max-height: 400px; + background: var(--bg-2, #1c1d20); + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.1); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); + z-index: 10001; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.genHeader { + padding: 12px 16px; + font-size: 14px; + font-weight: 600; + color: var(--color-yellow, #faea5f); + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +.genList { + padding: 8px 0; + overflow-y: auto; + flex: 1; +} + +.genItem { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + font-size: 13px; +} + +.genDot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + background: rgba(255, 255, 255, 0.2); +} + +.genName { + flex: 1; + color: rgba(255, 255, 255, 0.85); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.genStatus { + font-size: 12px; + color: rgba(255, 255, 255, 0.5); + white-space: nowrap; +} + +.gen_pending .genDot { + background: rgba(255, 255, 255, 0.25); +} + +.gen_summarizing .genDot { + background: var(--color-purple, #7660ff); + box-shadow: 0 0 6px rgba(118, 96, 255, 0.5); + animation: pulse-radio 1.5s ease-in-out infinite; +} + +.gen_generating .genDot { + background: var(--color-cyan, #2ea7ff); + box-shadow: 0 0 6px rgba(46, 167, 255, 0.5); + animation: pulse-radio 1.5s ease-in-out infinite; +} + +.gen_completed .genDot { + background: #4ade80; +} + +.gen_error .genDot { + background: var(--color-red, #ff3f4d); +} + +.gen_error .genStatus { + color: var(--color-red, #ff3f4d); +} + +.uploadError { + margin: 0; + padding: 8px 12px; + border-radius: 8px; + background: rgba(255, 63, 77, 0.12); + color: #ff3f4d; + font-size: 13px; + line-height: 1.4; +} diff --git a/apps/webuiapps/src/components/Shell/index.tsx b/apps/webuiapps/src/components/Shell/index.tsx index d7c4236..e898cb9 100644 --- a/apps/webuiapps/src/components/Shell/index.tsx +++ b/apps/webuiapps/src/components/Shell/index.tsx @@ -14,7 +14,11 @@ import { Radio, Video, VideoOff, + Plus, X, + Upload, + FileImage, + FileArchive, type LucideIcon, } from 'lucide-react'; import ChatPanel from '../ChatPanel'; @@ -22,9 +26,22 @@ import AppWindow from '../AppWindow'; import { getWindows, subscribe, openWindow, claimZIndex } from '@/lib/windowManager'; import { getDesktopApps } from '@/lib/appRegistry'; import { reportUserOsAction, onOSEvent } from '@/lib/vibeContainerMock'; -import { setReportUserActions } from '@/lib'; +import { setReportUserActions, extractCard } from '@/lib'; +import type { ExtractResult, Manifest } from '@/lib'; +import { buildModPrompt } from './modPrompt'; +import { chat, loadConfig } from '@/lib/llmClient'; +import { + generateModId, + addMod, + setActiveMod, + saveModCollection, + loadModCollectionSync, + DEFAULT_MOD_COLLECTION, +} from '@/lib/modManager'; +import type { ModConfig } from '@/lib/modManager'; import i18next from 'i18next'; import { seedMetaFiles } from '@/lib/seedMeta'; +import { logger } from '@/lib/logger'; import styles from './index.module.scss'; function useWindows() { @@ -71,7 +88,128 @@ const Shell: React.FC = () => { const [chatOpen, setChatOpen] = useState(true); const [reportEnabled, setReportEnabled] = useState(true); const [lang, setLang] = useState<'en' | 'zh'>('en'); - const [liveWallpaper, setLiveWallpaper] = useState(false); + const [liveWallpaper, setLiveWallpaper] = useState(true); + const [uploadOpen, setUploadOpen] = useState(false); + const [uploadedFile, setUploadedFile] = useState(null); + const [extractResult, setExtractResult] = useState(null); + const [extracting, setExtracting] = useState(false); + const [modGenerating, setModGenerating] = useState(false); + const fileInputRef = useRef(null); + + const handleFileChange = useCallback((e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + setUploadedFile(file); + setExtractResult(null); + } + if (fileInputRef.current) fileInputRef.current.value = ''; + }, []); + + const handleRemoveFile = useCallback(() => { + setUploadedFile(null); + setExtractResult(null); + }, []); + + const generateMod = useCallback( + async (character: Manifest['character']): Promise => { + const llmConfig = await loadConfig(); + if (!llmConfig) { + logger.warn('Shell', 'No LLM config available, skipping mod generation'); + return; + } + + const prompt = buildModPrompt([], JSON.stringify({ character, apps: [] })); + logger.info('Shell', 'Mod generation prompt built, length:', prompt.length); + + setModGenerating(true); + try { + const response = await chat([{ role: 'user', content: prompt }], [], llmConfig); + logger.info('Shell', 'Mod generation LLM response length:', response.content.length); + + // Strip markdown fences if present + let jsonStr = response.content.trim(); + const fenceMatch = jsonStr.match(/```(?:json)?\s*([\s\S]*?)```/); + if (fenceMatch) jsonStr = fenceMatch[1].trim(); + + const modJson = JSON.parse(jsonStr); + const modId = generateModId(); + const modConfig: ModConfig = { + id: modId, + mod_name: modJson.name || modJson.identifier || 'Generated Mod', + mod_name_en: modJson.name || modJson.identifier || 'Generated Mod', + mod_description: modJson.description || '', + display_desc: modJson.display_desc || '', + prologue: modJson.prologue || '', + opening_rec_replies: Array.isArray(modJson.opening_rec_replies) + ? modJson.opening_rec_replies.map((r: string) => ({ reply_text: r })) + : [], + stage_count: Array.isArray(modJson.stages) ? modJson.stages.length : 0, + stages: Array.isArray(modJson.stages) + ? Object.fromEntries( + modJson.stages.map( + ( + s: { + name: string; + description: string; + targets: Array<{ id: number; description: string }>; + }, + i: number, + ) => [ + i, + { + stage_index: i, + stage_name: s.name || `Stage ${i + 1}`, + stage_description: s.description || '', + stage_targets: Object.fromEntries( + (s.targets || []).map((t) => [t.id, t.description]), + ), + }, + ], + ), + ) + : {}, + }; + + const collection = loadModCollectionSync() ?? DEFAULT_MOD_COLLECTION; + const updated = setActiveMod(addMod(collection, modConfig), modId); + await saveModCollection(updated); + logger.info('Shell', 'Mod saved successfully:', modId, modConfig.mod_name); + return modId; + } catch (err) { + logger.error('Shell', 'Mod generation failed:', err); + } finally { + setModGenerating(false); + } + }, + [], + ); + + const handleUploadSubmit = useCallback(async () => { + if (!uploadedFile) return; + setExtracting(true); + setExtractResult(null); + try { + const result = await extractCard(uploadedFile); + setExtractResult(result); + if (result.status === 'success') { + logger.info('Shell', 'Card extracted:', result.manifest); + setUploadedFile(null); + setUploadOpen(false); + setExtractResult(null); + setExtracting(false); + + // Directly generate mod from character data (skip app extraction) + const modId = await generateMod(result.manifest.character); + if (modId) { + // Store mod ID so ChatPanel auto-opens mod editor after reload + sessionStorage.setItem('openroom_edit_mod_id', modId); + window.location.reload(); + } + } + } finally { + setExtracting(false); + } + }, [uploadedFile, generateMod]); // eslint-disable-next-line @typescript-eslint/no-unused-vars const [wallpaper, setWallpaper] = useState(VIDEO_WALLPAPER); const [chatZIndex, setChatZIndex] = useState(() => claimZIndex()); @@ -145,6 +283,13 @@ const Shell: React.FC = () => { seedMetaFiles(); }, []); + // Pause user action reporting while upload or mod generation is in progress + useEffect(() => { + const shouldListen = !uploadOpen && !modGenerating; + setReportUserActions(shouldListen); + setReportEnabled(shouldListen); + }, [uploadOpen, modGenerating]); + // Listen for OS events (e.g. wallpaper changes from agent) useEffect(() => { return onOSEvent((event) => { @@ -217,7 +362,81 @@ const Shell: React.FC = () => { onFocus={() => setChatZIndex(claimZIndex())} /> -
+ {/* Upload Modal */} + {uploadOpen && ( +
setUploadOpen(false)}> +
e.stopPropagation()}> +
+ Upload File + +
+ {uploadedFile ? ( +
+ {uploadedFile.name.endsWith('.zip') ? ( + + ) : ( + + )} + {uploadedFile.name} + +
+ ) : ( +
fileInputRef.current?.click()}> + +

Click to select a file

+

PNG image or ZIP archive

+
+ )} + + {extractResult?.status === 'error' && ( +

{extractResult.message}

+ )} + +
+
+ )} + + {/* Mod generating overlay */} + {modGenerating && ( +
+
+
+ Generating mod... +
+
+ )} + + {/* Floating add button */} + + +
-
- -
- {/* Apps section */} - {apps.length > 0 && ( -
-

- Apps ({selectedApps.length}/{apps.length}) -

-
- {apps.map((app) => { - const progress = genProgress[app.id]; - const appStatus = progress?.status; - return ( -
!generating && toggleSelect(app.id)} - > -
-
- - {app.name} - {app.id} -
- -
- - {app.format && app.format.length > 20 && ( -

{app.format}

- )} - - {app.keywords.length > 0 && ( -
- Keywords -
- {app.keywords.map((kw, i) => ( - - {kw} - - ))} -
-
- )} - - {app.tags.length > 0 && ( -
- Features -
- {app.tags - .filter((t) => t.description) - .map((t, i) => ( - - {t.name}: {t.description} - - ))} -
-
- )} - - {progress?.status === 'error' && progress.message && ( -

{progress.message}

- )} -
- ); - })} -
-
- )} - - {/* Character section */} -
-

Character

-
- {character.name && ( -
- Name - {character.name} -
- )} - {character.description && ( -
- Description -

{character.description}

-
- )} - {character.firstMessage && ( -
- First Message -

{character.firstMessage}

-
- )} -
-
-
- - {/* Footer */} -
- - -
-
-
- ); -}; - -export default ExtractPreviewModal; diff --git a/apps/webuiapps/src/components/Shell/index.tsx b/apps/webuiapps/src/components/Shell/index.tsx index e898cb9..2066df3 100644 --- a/apps/webuiapps/src/components/Shell/index.tsx +++ b/apps/webuiapps/src/components/Shell/index.tsx @@ -88,7 +88,7 @@ const Shell: React.FC = () => { const [chatOpen, setChatOpen] = useState(true); const [reportEnabled, setReportEnabled] = useState(true); const [lang, setLang] = useState<'en' | 'zh'>('en'); - const [liveWallpaper, setLiveWallpaper] = useState(true); + const [liveWallpaper, setLiveWallpaper] = useState(false); const [uploadOpen, setUploadOpen] = useState(false); const [uploadedFile, setUploadedFile] = useState(null); const [extractResult, setExtractResult] = useState(null); @@ -110,84 +110,88 @@ const Shell: React.FC = () => { setExtractResult(null); }, []); - const generateMod = useCallback( - async (character: Manifest['character']): Promise => { - const llmConfig = await loadConfig(); - if (!llmConfig) { - logger.warn('Shell', 'No LLM config available, skipping mod generation'); - return; - } + const generateMod = useCallback(async (character: Manifest['character']): Promise => { + const llmConfig = await loadConfig(); + if (!llmConfig) { + throw new Error( + 'No LLM configuration found. Please open Settings (gear icon) and configure your LLM API key first.', + ); + } + + const prompt = buildModPrompt([], JSON.stringify({ character, apps: [] })); + logger.info('Shell', 'Mod generation prompt built, length:', prompt.length); - const prompt = buildModPrompt([], JSON.stringify({ character, apps: [] })); - logger.info('Shell', 'Mod generation prompt built, length:', prompt.length); + setModGenerating(true); + try { + const response = await chat([{ role: 'user', content: prompt }], [], llmConfig); + logger.info('Shell', 'Mod generation LLM response length:', response.content.length); + + let jsonStr = response.content.trim(); + const fenceMatch = jsonStr.match(/```(?:json)?\s*([\s\S]*?)```/); + if (fenceMatch) jsonStr = fenceMatch[1].trim(); - setModGenerating(true); + let modJson: Record; try { - const response = await chat([{ role: 'user', content: prompt }], [], llmConfig); - logger.info('Shell', 'Mod generation LLM response length:', response.content.length); - - // Strip markdown fences if present - let jsonStr = response.content.trim(); - const fenceMatch = jsonStr.match(/```(?:json)?\s*([\s\S]*?)```/); - if (fenceMatch) jsonStr = fenceMatch[1].trim(); - - const modJson = JSON.parse(jsonStr); - const modId = generateModId(); - const modConfig: ModConfig = { - id: modId, - mod_name: modJson.name || modJson.identifier || 'Generated Mod', - mod_name_en: modJson.name || modJson.identifier || 'Generated Mod', - mod_description: modJson.description || '', - display_desc: modJson.display_desc || '', - prologue: modJson.prologue || '', - opening_rec_replies: Array.isArray(modJson.opening_rec_replies) - ? modJson.opening_rec_replies.map((r: string) => ({ reply_text: r })) - : [], - stage_count: Array.isArray(modJson.stages) ? modJson.stages.length : 0, - stages: Array.isArray(modJson.stages) - ? Object.fromEntries( - modJson.stages.map( - ( - s: { - name: string; - description: string; - targets: Array<{ id: number; description: string }>; - }, - i: number, - ) => [ - i, - { - stage_index: i, - stage_name: s.name || `Stage ${i + 1}`, - stage_description: s.description || '', - stage_targets: Object.fromEntries( - (s.targets || []).map((t) => [t.id, t.description]), - ), - }, - ], - ), - ) - : {}, - }; - - const collection = loadModCollectionSync() ?? DEFAULT_MOD_COLLECTION; - const updated = setActiveMod(addMod(collection, modConfig), modId); - await saveModCollection(updated); - logger.info('Shell', 'Mod saved successfully:', modId, modConfig.mod_name); - return modId; - } catch (err) { - logger.error('Shell', 'Mod generation failed:', err); - } finally { - setModGenerating(false); + modJson = JSON.parse(jsonStr); + } catch { + throw new Error('LLM returned invalid JSON. Try again or use a different model.'); } - }, - [], - ); + + const modId = generateModId(); + const modConfig: ModConfig = { + id: modId, + mod_name: (modJson.name as string) || (modJson.identifier as string) || 'Generated Mod', + mod_name_en: (modJson.name as string) || (modJson.identifier as string) || 'Generated Mod', + mod_description: (modJson.description as string) || '', + display_desc: (modJson.display_desc as string) || '', + prologue: (modJson.prologue as string) || '', + opening_rec_replies: Array.isArray(modJson.opening_rec_replies) + ? (modJson.opening_rec_replies as string[]).map((r) => ({ reply_text: r })) + : [], + stage_count: Array.isArray(modJson.stages) ? modJson.stages.length : 0, + stages: Array.isArray(modJson.stages) + ? Object.fromEntries( + ( + modJson.stages as Array<{ + name: string; + description: string; + targets: Array<{ id: number; description: string }>; + }> + ).map((s, i) => [ + i, + { + stage_index: i, + stage_name: s.name || `Stage ${i + 1}`, + stage_description: s.description || '', + stage_targets: Object.fromEntries( + (s.targets || []).map((t) => [t.id, t.description]), + ), + }, + ]), + ) + : {}, + }; + + const collection = loadModCollectionSync() ?? DEFAULT_MOD_COLLECTION; + const updated = setActiveMod(addMod(collection, modConfig), modId); + await saveModCollection(updated); + logger.info('Shell', 'Mod saved successfully:', modId, modConfig.mod_name); + return modId; + } catch (err) { + logger.error('Shell', 'Mod generation failed:', err); + throw err; + } finally { + setModGenerating(false); + } + }, []); + + const [modGenError, setModGenError] = useState(null); const handleUploadSubmit = useCallback(async () => { if (!uploadedFile) return; setExtracting(true); setExtractResult(null); + setModGenError(null); try { const result = await extractCard(uploadedFile); setExtractResult(result); @@ -196,16 +200,13 @@ const Shell: React.FC = () => { setUploadedFile(null); setUploadOpen(false); setExtractResult(null); - setExtracting(false); - - // Directly generate mod from character data (skip app extraction) + // extracting will be cleared in finally; generateMod shows its own modGenerating overlay const modId = await generateMod(result.manifest.character); - if (modId) { - // Store mod ID so ChatPanel auto-opens mod editor after reload - sessionStorage.setItem('openroom_edit_mod_id', modId); - window.location.reload(); - } + window.dispatchEvent(new CustomEvent('open-mod-editor', { detail: { modId } })); } + } catch (err) { + logger.error('Shell', 'Upload submit error:', err); + setModGenError(err instanceof Error ? err.message : String(err)); } finally { setExtracting(false); } @@ -426,6 +427,21 @@ const Shell: React.FC = () => { )} + {/* Mod generation error toast */} + {modGenError && !modGenerating && ( +
setModGenError(null)}> +
e.stopPropagation()}> +

{modGenError}

+ +
+
+ )} + {/* Floating add button */}