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/.claude/prompt_generate_mod_en.md b/.claude/prompt_generate_mod_en.md new file mode 100644 index 0000000..fada0c8 --- /dev/null +++ b/.claude/prompt_generate_mod_en.md @@ -0,0 +1,135 @@ +# Prompt: Generate Mod from Character Card Export Data + +## Usage + +Send the prompt below together with a character card's exported JSON to an LLM to generate the corresponding Mod JSON. + +--- + +## Prompt + +``` +You are an expert interactive narrative designer. Your task is to analyze a character card export JSON and generate a mod — a narrative scenario framework that defines the DRAMATIC STRUCTURE of a conversation experience. + +## Critical Principle: Separation of Concerns + +A mod is NOT a character profile. The character card already contains all character-specific information (personality, backstory, speech patterns, appearance, behavioral rules). The mod must NOT duplicate any of that. + +The mod provides ONLY what the character card lacks: +- A narrative starting situation (where and when does the story begin?) +- A dramatic arc (what unfolds over the course of the experience?) +- Stage-by-stage pacing (what narrative functions happen in what order?) +- App orchestration (how do platform apps reinforce the narrative at each stage?) +- Transition conditions (what observable events signal that the story should advance?) + +Think of it this way: +- Character card = WHO the character is (permanent) +- Mod = WHAT HAPPENS in this particular scenario (temporal) + +If you find yourself writing personality descriptions, speech pattern guides, backstory summaries, or behavioral rules in the mod, STOP — that information belongs in the character card, not here. + +**Never use any of the character's names** (real name, stage name, screen name, account name, nickname, etc.). All references to the character must use functional designations such as "the character", "the character's online persona", "the character's real identity", "the character's public account", "the character's private account", etc. Names are exclusive to the character card; the mod describes only structure and function. + +## Output Format + +Output ONLY valid JSON. No markdown fences, no explanation. + +{ + "name": "string - A short title conveying the scenario's theme, not just the character's name", + "identifier": "string - lowercase_snake_case unique identifier", + "description": "string - SCENARIO FRAMEWORK directive for the AI agent. Must describe:\n 1. The dramatic situation (not the character): what is happening, what is at stake, what tension drives the scenario\n 2. Relationship starting point: **State the functional relationship type in a single sentence** (e.g. 'a deep online acquaintanceship with information asymmetry', 'a chance encounter between strangers'), **and stop there — do not elaborate further**. Do not explain why the asymmetry exists, do not describe what the character has done, **do not describe whether the two have met, who knows what, who does not know what, the specific direction or content of any information gap**. A single functional relationship type constitutes the complete relationship starting point.\n 3. Narrative trajectory: the overall arc from beginning to end, described in functional terms (e.g. 'guarded distance → reluctant trust → involuntary vulnerability → honest connection')\n 4. App usage philosophy: how platform apps serve as narrative channels in this scenario (not mechanical instructions, but dramatic roles — e.g. 'diary entries reveal what is never said aloud', 'wallpaper shifts mirror emotional atmosphere changes'). **App usage philosophy must only describe each app's narrative functional positioning; it must not imply the character's behavioral motivation or psychological mechanisms** (e.g. 'protective shell', 'an escape from reality' and other metaphors implying behavioral patterns are forbidden). **App usage philosophy must not use metaphors implying the character's internal processes — including 'vehicle', 'transition', 'transformation', and other phrasing that implies some internal change occurs through that channel.** Each app should only state what dimension of information presentation it provides, not what change the character undergoes through it.\n - Forbidden: 'Voice becomes the vehicle for personality-layer transition' (implies the character undergoes a personality shift through the voice channel)\n - Correct: 'Voice calls provide an audio-only dimension of information presentation'\n - Forbidden: 'Diary is a window for emotional catharsis' (implies behavioral motivation)\n - Correct: 'Diary presents unedited monologue content not filtered for external consumption'\n **App usage philosophy must not reference specific details from the character card** (e.g. specific number of accounts, specific platform identities); use functional tier descriptions instead (e.g. use 'public layer and private layer' instead of 'two accounts').\n 5. Global pacing directive\n MUST NOT contain: character personality, speech patterns, appearance, backstory, or any information already present in the character card. This includes specific historical events between the character and user (how they met, whether they have met in person, whether one has a unilateral information advantage, etc.).", + "display_desc": "string - User-facing, 2-4 sentences. Describe the SITUATION, not the character. End with 'Genre: [X] / [Y]'. **Must not contain specific data from the character card** (e.g. follower counts, job titles, account types); use functional descriptions instead (e.g. 'an online public identity with a large following'). **Must not contain specific relationship descriptions** (e.g. 'you are their most special person', 'you two are close friends'); only describe a functional overview of the dramatic situation the user is entering.", + "prologue": "string - The scenario's opening moment. Describe the SCENE and SITUATION the user enters, not the character's biography. Derived from first_mes/alternate_greetings but stripped to pure scene-setting. 2-6 sentences. **Do not describe the relationship status or interaction patterns between the user and character** (e.g. 'you two are close friends', 'they often reach out to chat', 'you are their most special online friend'). The prologue should only describe immediate scene elements the user perceives (what is on screen, what is the environment, what is happening right now); relationship status should be implied by scene elements rather than stated directly. **Do not imply pre-existing relationships through user-perspective active behaviors** (e.g. 'an online identity you follow' implies the user already follows them, 'a livestream you often watch' implies an existing interaction habit); online identities in scene elements should appear in neutral third-party form (e.g. 'an online identity', 'a certain account'), without presupposing any pre-existing connection between the user and that identity. **Do not directly or indirectly convey specific platform data or account settings from the character card**, including but not limited to: number of accounts, interaction volume differences (e.g. 'comments scrolling rapidly' implies high popularity, 'almost no interaction traces' implies alt-account status), follower scale, account activity contrasts, etc. Scene elements should remain neutral, carrying no quantitative or comparative information mappable to specific character card settings. Likewise, **do not reproduce specific plot details from the character card**.", + "opening_rec_replies": ["3 short replies (1-6 words), natural reactions to the prologue's specific scene"], + "stages": [ + { + "id": 0, + "name": "Evocative title reflecting narrative function", + "description": "Stage directive (4-8 sentences). Must specify:\n 1. NARRATIVE FUNCTION: What this stage accomplishes in the dramatic arc (not what the character does — what the STORY does)\n 2. INTERACTION DYNAMIC: The emotional register of exchanges in this stage (e.g. 'surface-level and guarded', 'increasingly honest', 'raw and unfiltered')\n 3. APP ORCHESTRATION: Which apps activate, what kind of content they carry, described as templates:\n - 'Diary: [reflection on {the core tension of this stage}]'\n - 'Wallpaper: [visual atmosphere matching {dominant emotion}]'\n - 'Music: [tone/mood descriptor]'\n - 'Social feed: [public-facing content that {contrasts with / reinforces} private state]'\n 4. PACING: How quickly or slowly this stage should unfold\n 5. TRANSITION SIGNAL: What indicates this stage is complete", + "targets": [ + { + "id": 0, + "description": "An observable conversation event (a question asked, a topic raised, a reaction given) — NOT an internal character state" + } + ] + } + ] +} + +## Narrative Pattern Detection + +Analyze the character card to identify the dominant dramatic pattern, then design the scenario arc around it: + +| Signal in Character Data | Narrative Pattern | +|---|---| +| Hidden identity / dual persona / public vs. private self | **Revelation Arc**: surface → cracks → exposure → reckoning | +| Emotional wound / trauma / guarded past | **Trust Arc**: walls up → testing → breach → vulnerability | +| Rich world / abilities / lore / adventure elements | **Discovery Arc**: encounter → exploration → wonder → deeper understanding | +| Intense feelings toward user / possessiveness / longing | **Intimacy Arc**: distance → closeness → boundary testing → commitment or rupture | +| Conflicting loyalties / moral dilemmas | **Tension Arc**: status quo → pressure → forced choice → consequence | +| Mundane setting with emotional depth | **Slice-of-Life Arc**: routine → disruption → adaptation → new normal | + +Choose the MOST DRAMATICALLY COMPELLING pattern as the primary arc. Secondary patterns can inform individual stages. + +## Stage Design Rules + +1. **3-5 stages**, each defined by NARRATIVE FUNCTION, not plot events +2. **2-4 targets per stage**, each an observable conversation condition +3. **Target IDs globally sequential** across all stages (0, 1, 2, 3...) +4. **Stage descriptions must NOT contain character-specific content** — no character names (use functional designations like "the character", "the character's online persona"), no personality descriptions, no dialogue examples, no backstory. Only scenario structure and app directives. +5. **Personality and emotional texture leak check**: If stage descriptions contain adjectives or depictions describing the character's personality, tone, or behavioral manner (e.g. "brooding", "bubbly", "cute", "clumsy", "sweet", "clingy", "heavy", "raw", "gentle"), **they must be deleted and replaced with narrative function terms** (e.g. "a rupture from the previously established pattern", "a fundamental shift in communication mode", "the established persona's protective layer is removed", "unfiltered direct interaction"). **This rule equally applies to interaction dynamic descriptions — emotional texture descriptors like "clumsy yet sincere", "raw and unfiltered" are also forbidden and must be replaced with functional terms** (e.g. "unfiltered direct expression after the established pattern has been removed", "the process of both parties re-establishing interaction methods under a new paradigm"). Self-check method: if the description still holds after removing the character card, it passes; if understanding requires knowledge of the character card's content, it fails and must be rewritten. +6. **App content in stages uses bracket templates**, not literal text: + - GOOD: "Diary: [a raw reflection on {what just happened}, revealing something the character would not say directly]" + - BAD: "Diary: 'I watched her leave and I wanted to scream but I just stood there'" +7. **Surveillance/monitoring templates must use abstract functional descriptions**; specific physical actions, facial expressions, or behavioral details are forbidden: + - GOOD: "Surveillance: [the character's unguarded emotional reaction triggered by recalling a key scene while alone]" + - GOOD: "Surveillance: [the character's unguarded genuine body language and emotional reactions in a face-to-face setting]" + - BAD: "Surveillance: [unable to meet the user's eyes, trembling when physically close]" + - BAD: "Surveillance: [hiding in the room covering their face]" +8. **Stage titles must only reflect narrative function; metaphors mappable to specific character card settings are forbidden** (e.g. do not use "dual layer" when the character has two accounts, do not use "mask" when the character has a disguised identity — these are imagery that directly maps to character card structure). Use universal dramatic terminology instead (e.g. "stable operation of the established pattern", "uncontrollable rupture", "fundamental collapse", "establishment of a new paradigm"). +9. **Stages follow universal dramatic progression**: + - Equilibrium → Disruption → Escalation → Climax → New Equilibrium + - May compress or split stages as needed (3-5 total) +10. **Final stage must define a transformed relationship state**, not a plot conclusion +11. **Every target must be a user-side observable conversation event** — i.e. what the user said, asked, or chose. **Character-side behaviors or state changes are forbidden as targets** (e.g. "the character's information control mechanism fails", "the character exposes a piece of information"), because targets are conditions used to determine stage progression and must be based on observable user behavior. If you need to describe a character-side trigger event, reframe it as the observable result that event produces on the user side (e.g. change "the character's information control mechanism fails" to "the user receives information in conversation that clearly contradicts the established pattern and responds to it"). + +## Content Boundaries + +The mod MUST contain: +- Starting situation and scene +- Dramatic arc definition +- Stage-by-stage narrative functions +- App orchestration templates +- Observable transition targets + +The mod MUST NOT contain: +- Any of the character's names (real name, stage name, screen name, account name, nickname) — always replace with functional designations +- **Specific historical events or relationship details between the character and user** — including but not limited to: "the two have never met in person", "the user does not know the character's real identity", "the character unilaterally knows about the user", "the user has no direct awareness of the character's true state", "you are their most special online friend", "you two are close friends who often chat privately", etc. **All of these are specific plot details that must be replaced with a single functional relationship type sentence (e.g. "a deep online relationship with information asymmetry") and must not be expanded into multi-sentence descriptions.** This rule equally applies to display_desc and prologue. +- **The character's specific professional identity, platform data, or account settings** (e.g. follower counts, account types, specific job titles); replace with functional descriptions (e.g. "an online public identity with a large following", "public layer and private layer"). **This rule equally applies to indirect descriptions** — do not indirectly convey character card platform data through interaction volume depictions ("rapidly scrolling comment sections"), activity level differences ("almost no interaction traces"), etc. +- Character personality traits or descriptions +- Character speech patterns or dialogue style +- Character backstory or history +- Character appearance +- Behavioral rules (these belong in the character card's roleplay[] field) +- Specific dialogue lines or example utterances +- **Specific depictions of the character's online/offline contrast** (e.g. "bubbly vs. brooding", "cute and clingy vs. heavy and clumsy") — these are personality descriptions and should be replaced with functional descriptions like "a rupture between the established pattern and the authentic pattern" +- **The character's specific behavioral patterns toward the user** (e.g. "secretly watching", "monitoring", "unilaterally witnessing") — these are backstory and behavioral rules and should be replaced with structural descriptions like "information asymmetry" +- **Specific emotional texture depictions of interactions** (e.g. "anime-style sweetness", "cute tone", "clumsy and heavy expression", "clumsy yet sincere", "raw and unfiltered") — these are speech patterns and should be replaced with functional descriptions like "interaction conforming to the established persona pattern", "expression that ruptures the prior communication pattern", "unfiltered direct expression after the established pattern has been removed" +- **Metaphors implying the character's behavioral motivation or psychological mechanisms** (e.g. "protective shell", "an escape outlet", "a window for emotional catharsis", "a vehicle for personality transition") — these are behavioral rules and personality descriptions and should be replaced with pure functional positioning descriptions (e.g. "core display arena", "private communication channel", "audio-only dimension of information presentation") + +## Language Rule + +All output must match the primary language of `character.description`. + +--- + +Now generate a Mod JSON from the following character card export data and available apps: + + +{List available apps, e.g.: wallpaper, music_player, diary, social_feed, live_stream, messaging} + + + +{Paste character card export JSON here} + +``` diff --git a/apps/webuiapps/package.json b/apps/webuiapps/package.json index d5f3d85..d1db7a9 100644 --- a/apps/webuiapps/package.json +++ b/apps/webuiapps/package.json @@ -17,11 +17,13 @@ }, "dependencies": { "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": { + "@anthropic-ai/claude-agent-sdk": "^0.2.72", "@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..bd2f3ab 100644 --- a/apps/webuiapps/src/components/ChatPanel/index.tsx +++ b/apps/webuiapps/src/components/ChatPanel/index.tsx @@ -425,8 +425,22 @@ 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(); + // Open mod editor when triggered from Shell (e.g. after card import mod generation) + useEffect(() => { + const handler = (e: Event) => { + const modId = (e as CustomEvent<{ modId: string }>).detail?.modId; + if (modId) { + setInitialEditModId(modId); + setShowModPanel(true); + } + }; + window.addEventListener('open-mod-editor', handler); + return () => window.removeEventListener('open-mod-editor', handler); + }, []); + // Memories loaded for SP injection const [memories, setMemories] = useState([]); @@ -540,6 +554,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 +1197,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/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..2066df3 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() { @@ -72,6 +89,128 @@ const Shell: React.FC = () => { const [reportEnabled, setReportEnabled] = useState(true); const [lang, setLang] = useState<'en' | 'zh'>('en'); const [liveWallpaper, setLiveWallpaper] = useState(false); + 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) { + 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); + + 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(); + + let modJson: Record; + try { + 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); + if (result.status === 'success') { + logger.info('Shell', 'Card extracted:', result.manifest); + setUploadedFile(null); + setUploadOpen(false); + setExtractResult(null); + // extracting will be cleared in finally; generateMod shows its own modGenerating overlay + const modId = await generateMod(result.manifest.character); + 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); + } + }, [uploadedFile, generateMod]); // eslint-disable-next-line @typescript-eslint/no-unused-vars const [wallpaper, setWallpaper] = useState(VIDEO_WALLPAPER); const [chatZIndex, setChatZIndex] = useState(() => claimZIndex()); @@ -145,6 +284,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 +363,96 @@ 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... +
+
+ )} + + {/* Mod generation error toast */} + {modGenError && !modGenerating && ( +
setModGenError(null)}> +
e.stopPropagation()}> +

{modGenError}

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