Spaces:
Running on Zero
Running on Zero
| """Gradio layout for the mock Objectverse archive UI.""" | |
| from __future__ import annotations | |
| from html import escape | |
| from pathlib import Path | |
| from typing import Any | |
| import gradio as gr | |
| from src.config import APP_TITLE, DEFAULT_MODE, PERSONALITY_MODES | |
| from src.example_cache import load_sample_generation | |
| from src.examples import EXAMPLE_OBJECTS, example_button_label | |
| from src.models.llama_cpp_runner import reply_as_object | |
| from src.models.schema import GenerationResult | |
| from src.models.vision_runner import probe_vision_runtime | |
| from src.pipeline import format_diary_markdown, generate_object_diary | |
| from src.renderer.share_card import render_share_card | |
| from src.ui import copy | |
| from src.utils.zero_gpu import zero_gpu | |
| CHAT_EMPTY_MESSAGE = "Wake an object first." | |
| ARCHIVE_STATUS_EMPTY = """ | |
| <div class="archive-status asleep"> | |
| <div> | |
| <span class="archive-label">Archive Status <span class="lang-zh">档案状态</span></span> | |
| <strong>Object asleep</strong> | |
| <p>Drop in a photo or use a sample object to open a case file.</p> | |
| <p class="lang-zh block">上传照片或使用示例物品来开启档案。</p> | |
| </div> | |
| <div class="status-pills"> | |
| <span>Case pending</span> | |
| <span>Tiny models ready</span> | |
| </div> | |
| </div> | |
| """ | |
| OBJECT_FILE_EMPTY = """ | |
| <div class="archive-empty"> | |
| <span class="archive-label">Object File <span class="lang-zh">物品档案</span></span> | |
| <h3>No case opened yet.</h3> | |
| <p>The shelf is quiet. Wake an everyday object to see its file.</p> | |
| <p class="lang-zh block">档案架仍然安静。唤醒一个日常物品后查看档案。</p> | |
| </div> | |
| """ | |
| DIARY_EMPTY = """ | |
| ### Secret Diary | |
| Wake an object to open its private field notes. | |
| <div class="lang-zh block zh-helper"> | |
| 唤醒物品后阅读它的秘密观察记录。 | |
| </div> | |
| """ | |
| SHARE_CARD_EMPTY = """ | |
| <div class="objectverse-placeholder"> | |
| <span>Share Card <span class="lang-zh">分享卡片</span></span> | |
| <strong>Evidence slot empty.</strong> | |
| <p>A screenshot-friendly archive card appears after the object wakes.</p> | |
| <p class="lang-zh block">物品醒来后,这里会出现可截图分享的档案卡片。</p> | |
| </div> | |
| """ | |
| TRACE_EMPTY = """ | |
| <div class="archive-empty compact"> | |
| <span class="archive-label">Trace <span class="lang-zh">模型轨迹</span></span> | |
| <p>No trace saved yet.</p> | |
| <p class="lang-zh block">尚未保存 trace。</p> | |
| </div> | |
| """ | |
| UI_CONTROL_SCRIPT = r""" | |
| (() => { | |
| const root = document.documentElement; | |
| const INTERNAL_TEXT_REPLACEMENTS = new Map([ | |
| ["将图像文件拖放到此处以上传", "Drop image file here to upload"], | |
| ["将图像拖放到此处", "Drop image here"], | |
| ["- 或 -", "- or -"], | |
| ["点击上传", "Click to upload"], | |
| ["清空对话", "Clear chat"], | |
| ["通过 API 使用", "Use via API"], | |
| ["使用 Gradio 构建", "Built with Gradio"], | |
| ["设置", "Settings"], | |
| ["标志", "icon"], | |
| ]); | |
| const CJK_RE = /[\u3400-\u9fff]/; | |
| const CJK_WRAP_RE = /[\u3400-\u9fff,。!?、;::“”‘’()《》【】]+/g; | |
| const SKIP_TEXT_SELECTOR = "script, style, textarea, input, select, option, svg, .lang-zh, .auto-zh"; | |
| function readStoredTheme() { | |
| try { | |
| return localStorage.getItem("objectverse-theme") === "light" ? "light" : "dark"; | |
| } catch { | |
| return "dark"; | |
| } | |
| } | |
| root.dataset.ovTheme = readStoredTheme(); | |
| function syncLanguageButtons(value) { | |
| document.querySelectorAll("[data-lang-toggle]").forEach((button) => { | |
| const active = button.dataset.langToggle === value; | |
| button.classList.toggle("active", active); | |
| button.setAttribute("aria-pressed", String(active)); | |
| }); | |
| } | |
| function syncThemeButtons(value) { | |
| const isLight = value === "light"; | |
| document.querySelectorAll("[data-theme-toggle]").forEach((button) => { | |
| button.dataset.themeToggle = value; | |
| button.classList.toggle("active", isLight); | |
| button.setAttribute("aria-pressed", String(isLight)); | |
| button.setAttribute("aria-label", isLight ? "Switch to dark theme" : "Switch to light theme"); | |
| button.setAttribute("title", isLight ? "Switch to dark theme" : "Switch to light theme"); | |
| button.querySelectorAll("[data-theme-icon]").forEach((icon) => { | |
| const hidden = icon.dataset.themeIcon !== value; | |
| icon.hidden = hidden; | |
| icon.toggleAttribute("hidden", hidden); | |
| }); | |
| }); | |
| } | |
| function syncControls() { | |
| syncLanguageButtons(root.dataset.ovLang === "zh" ? "zh" : "en"); | |
| syncThemeButtons(root.dataset.ovTheme === "light" ? "light" : "dark"); | |
| } | |
| function scheduleControlSync() { | |
| syncControls(); | |
| window.setTimeout(syncControls, 50); | |
| window.setTimeout(syncControls, 250); | |
| window.setTimeout(syncControls, 1000); | |
| } | |
| function applyLanguage(value) { | |
| const language = value === "zh" ? "zh" : "en"; | |
| root.dataset.ovLang = language; | |
| if (document.body) { | |
| document.body.dataset.ovLang = language; | |
| } | |
| syncLanguageButtons(language); | |
| } | |
| function applyTheme(value) { | |
| const theme = value === "light" ? "light" : "dark"; | |
| root.dataset.ovTheme = theme; | |
| if (document.body) { | |
| document.body.dataset.ovTheme = theme; | |
| } | |
| try { | |
| localStorage.setItem("objectverse-theme", theme); | |
| } catch { | |
| // Local storage can be unavailable in embedded previews. | |
| } | |
| syncThemeButtons(theme); | |
| } | |
| function initControls() { | |
| root.lang = "en"; | |
| applyLanguage("en"); | |
| applyTheme(readStoredTheme()); | |
| normalizeGradioChrome(document.body); | |
| wrapChineseText(document.body); | |
| scheduleControlSync(); | |
| } | |
| function normalizeString(value) { | |
| let nextValue = value; | |
| INTERNAL_TEXT_REPLACEMENTS.forEach((replacement, source) => { | |
| nextValue = nextValue.split(source).join(replacement); | |
| }); | |
| return nextValue; | |
| } | |
| function normalizeGradioChrome(rootNode) { | |
| if (!rootNode) return; | |
| rootNode.querySelectorAll("[aria-label], [title], [alt]").forEach((element) => { | |
| ["aria-label", "title", "alt"].forEach((attribute) => { | |
| const value = element.getAttribute(attribute); | |
| if (value && CJK_RE.test(value)) { | |
| const normalizedValue = normalizeString(value); | |
| if (normalizedValue !== value) { | |
| element.setAttribute(attribute, normalizedValue); | |
| } | |
| } | |
| }); | |
| }); | |
| const walker = document.createTreeWalker(rootNode, NodeFilter.SHOW_TEXT); | |
| const nodes = []; | |
| let node = walker.nextNode(); | |
| while (node) { | |
| const parent = node.parentElement; | |
| const text = node.nodeValue || ""; | |
| if (parent && !parent.closest(SKIP_TEXT_SELECTOR) && CJK_RE.test(text)) { | |
| nodes.push(node); | |
| } | |
| node = walker.nextNode(); | |
| } | |
| nodes.forEach((textNode) => { | |
| const text = textNode.nodeValue || ""; | |
| const normalizedText = normalizeString(text); | |
| if (normalizedText !== text) { | |
| textNode.nodeValue = normalizedText; | |
| } | |
| }); | |
| } | |
| function wrapChineseText(rootNode) { | |
| if (!rootNode) return; | |
| const walker = document.createTreeWalker(rootNode, NodeFilter.SHOW_TEXT); | |
| const nodes = []; | |
| let node = walker.nextNode(); | |
| while (node) { | |
| const parent = node.parentElement; | |
| const text = node.nodeValue || ""; | |
| if (parent && !parent.closest(SKIP_TEXT_SELECTOR) && CJK_RE.test(text)) { | |
| nodes.push(node); | |
| } | |
| node = walker.nextNode(); | |
| } | |
| nodes.forEach((textNode) => { | |
| const text = textNode.nodeValue || ""; | |
| const fragment = document.createDocumentFragment(); | |
| let lastIndex = 0; | |
| text.replace(CJK_WRAP_RE, (match, index) => { | |
| if (index > lastIndex) { | |
| fragment.append(document.createTextNode(text.slice(lastIndex, index))); | |
| } | |
| const span = document.createElement("span"); | |
| span.className = "auto-zh"; | |
| span.textContent = match; | |
| fragment.append(span); | |
| lastIndex = index + match.length; | |
| return match; | |
| }); | |
| if (lastIndex < text.length) { | |
| fragment.append(document.createTextNode(text.slice(lastIndex))); | |
| } | |
| textNode.replaceWith(fragment); | |
| }); | |
| } | |
| document.addEventListener("click", (event) => { | |
| const langButton = event.target.closest("[data-lang-toggle]"); | |
| if (langButton) { | |
| applyLanguage(langButton.dataset.langToggle); | |
| return; | |
| } | |
| const themeButton = event.target.closest("[data-theme-toggle]"); | |
| if (themeButton) { | |
| const currentTheme = root.dataset.ovTheme === "light" ? "light" : "dark"; | |
| applyTheme(currentTheme === "light" ? "dark" : "light"); | |
| } | |
| }); | |
| if (document.readyState === "loading") { | |
| document.addEventListener("DOMContentLoaded", initControls); | |
| } else { | |
| initControls(); | |
| } | |
| const observer = new MutationObserver(() => { | |
| normalizeGradioChrome(document.body); | |
| wrapChineseText(document.body); | |
| syncControls(); | |
| }); | |
| if (document.body) { | |
| observer.observe(document.body, { childList: true, subtree: true }); | |
| } else { | |
| document.addEventListener("DOMContentLoaded", () => { | |
| observer.observe(document.body, { childList: true, subtree: true }); | |
| }); | |
| } | |
| })(); | |
| """ | |
| GenerationUiResult = tuple[ | |
| str, | |
| dict[str, Any], | |
| dict[str, Any], | |
| str, | |
| str, | |
| str, | |
| dict[str, Any], | |
| str, | |
| dict[str, Any] | None, | |
| list[dict[str, str]], | |
| str, | |
| ] | |
| def build_app() -> gr.Blocks: | |
| css = Path("src/ui/styles.css").read_text(encoding="utf-8") | |
| custom_theme = gr.themes.Monochrome( | |
| primary_hue="amber", | |
| secondary_hue="yellow", | |
| neutral_hue="stone", | |
| ).set( | |
| body_background_fill="#161513", | |
| body_background_fill_dark="#161513", | |
| background_fill_primary="#161513", | |
| background_fill_primary_dark="#161513", | |
| background_fill_secondary="rgba(30, 28, 25, 0.6)", | |
| background_fill_secondary_dark="rgba(30, 28, 25, 0.6)", | |
| border_color_primary="rgba(212, 175, 55, 0.15)", | |
| border_color_primary_dark="rgba(212, 175, 55, 0.15)", | |
| body_text_color="#E6E1D3", | |
| body_text_color_dark="#E6E1D3", | |
| body_text_color_subdued="#A89B84", | |
| body_text_color_subdued_dark="#A89B84", | |
| link_text_color="#D4AF37", | |
| link_text_color_dark="#D4AF37", | |
| link_text_color_hover="#F5D061", | |
| link_text_color_hover_dark="#F5D061", | |
| link_text_color_active="#F5D061", | |
| link_text_color_active_dark="#F5D061", | |
| link_text_color_visited="#D4AF37", | |
| link_text_color_visited_dark="#D4AF37", | |
| block_background_fill="rgba(30, 28, 25, 0.72)", | |
| block_background_fill_dark="rgba(30, 28, 25, 0.72)", | |
| block_border_width="1px", | |
| block_info_text_color="#A89B84", | |
| block_info_text_color_dark="#A89B84", | |
| block_label_text_color="#A89B84", | |
| block_label_text_color_dark="#A89B84", | |
| block_title_text_color="#E6E1D3", | |
| block_title_text_color_dark="#E6E1D3", | |
| panel_background_fill="rgba(30, 28, 25, 0.88)", | |
| panel_background_fill_dark="rgba(30, 28, 25, 0.88)", | |
| accordion_text_color="#E6E1D3", | |
| accordion_text_color_dark="#E6E1D3", | |
| table_text_color="#E6E1D3", | |
| table_text_color_dark="#E6E1D3", | |
| input_background_fill="#1b1a18", | |
| input_background_fill_dark="#1b1a18", | |
| input_background_fill_focus="#1b1a18", | |
| input_background_fill_focus_dark="#1b1a18", | |
| input_background_fill_hover="#1b1a18", | |
| input_background_fill_hover_dark="#1b1a18", | |
| input_border_color="rgba(212, 175, 55, 0.3)", | |
| input_border_color_dark="rgba(212, 175, 55, 0.3)", | |
| input_border_color_focus="#D4AF37", | |
| input_border_color_focus_dark="#D4AF37", | |
| input_placeholder_color="#8B8678", | |
| input_placeholder_color_dark="#8B8678", | |
| checkbox_label_text_color="#E6E1D3", | |
| checkbox_label_text_color_dark="#E6E1D3", | |
| checkbox_label_text_color_selected="#F5D061", | |
| checkbox_label_text_color_selected_dark="#F5D061", | |
| checkbox_label_background_fill="transparent", | |
| checkbox_label_background_fill_dark="transparent", | |
| checkbox_label_background_fill_selected="rgba(212, 175, 55, 0.05)", | |
| checkbox_label_background_fill_selected_dark="rgba(212, 175, 55, 0.05)", | |
| checkbox_label_border_color="rgba(212, 175, 55, 0.3)", | |
| checkbox_label_border_color_dark="rgba(212, 175, 55, 0.3)", | |
| checkbox_label_border_color_selected="#D4AF37", | |
| checkbox_label_border_color_selected_dark="#D4AF37", | |
| button_secondary_background_fill="rgba(22, 21, 19, 0.8)", | |
| button_secondary_background_fill_dark="rgba(22, 21, 19, 0.8)", | |
| button_secondary_background_fill_hover="rgba(38, 35, 29, 0.9)", | |
| button_secondary_background_fill_hover_dark="rgba(38, 35, 29, 0.9)", | |
| button_secondary_border_color="rgba(212, 175, 55, 0.15)", | |
| button_secondary_border_color_dark="rgba(212, 175, 55, 0.15)", | |
| button_secondary_border_color_hover="#D4AF37", | |
| button_secondary_border_color_hover_dark="#D4AF37", | |
| button_secondary_text_color="#E6E1D3", | |
| button_secondary_text_color_dark="#E6E1D3", | |
| button_secondary_text_color_hover="#F5D061", | |
| button_secondary_text_color_hover_dark="#F5D061", | |
| button_primary_text_color="#2a261f", | |
| button_primary_text_color_dark="#2a261f", | |
| ) | |
| with gr.Blocks(theme=custom_theme, head=f"<style>{css}</style><script>{UI_CONTROL_SCRIPT}</script>", title=APP_TITLE, fill_width=True, elem_id="objectverse-app") as demo: | |
| with gr.Column(elem_id="app-container"): | |
| gr.HTML( | |
| f""" | |
| <header id="objectverse-hero"> | |
| <div class="hero-copy"> | |
| <span class="archive-label">Gradio Small Model Lab</span> | |
| <h1>{APP_TITLE}</h1> | |
| <p class="hero-kicker">Every object has a secret life.</p> | |
| <p class="hero-feature">Upload a photo, wake a tiny object persona, read its diary, chat, and export the evidence.</p> | |
| <p class="hero-kicker lang-zh block">万物日记:每个物品都有秘密人生。</p> | |
| <p class="hero-feature lang-zh block">上传照片,唤醒小模型生成的物品人格,阅读日记、对话并保存证据。</p> | |
| <div class="hero-badges" aria-label="Project badges"> | |
| <span>Gradio Blocks</span> | |
| <span>Mock-safe MVP</span> | |
| <span>< 32B params</span> | |
| </div> | |
| </div> | |
| <div class="top-controls" aria-label="Display controls"> | |
| <div class="top-actions" aria-label="Project links and theme"> | |
| <a class="icon-link" href="https://x.com/GeekCrafter/status/2064250293001576556?s=20" target="_blank" rel="noopener noreferrer" aria-label="Open X social post" title="X post"> | |
| <span class="x-mark" aria-hidden="true">X</span> | |
| </a> | |
| <a class="icon-link" href="https://youtu.be/5HbhP21hooA" target="_blank" rel="noopener noreferrer" aria-label="Open YouTube demo video" title="YouTube demo"> | |
| <svg aria-hidden="true" viewBox="0 0 24 24" focusable="false"> | |
| <rect x="3" y="6" width="18" height="12" rx="3"></rect> | |
| <path d="M10 9.5v5l5-2.5-5-2.5Z"></path> | |
| </svg> | |
| </a> | |
| <button type="button" class="icon-link theme-toggle" data-theme-toggle="dark" aria-pressed="false" aria-label="Switch to light theme" title="Switch to light theme"> | |
| <svg data-theme-icon="dark" aria-hidden="true" viewBox="0 0 24 24" focusable="false"> | |
| <path d="M20 15.5A8.5 8.5 0 0 1 8.5 4a7.2 7.2 0 1 0 11.5 11.5Z"></path> | |
| </svg> | |
| <svg data-theme-icon="light" aria-hidden="true" viewBox="0 0 24 24" focusable="false" hidden> | |
| <circle cx="12" cy="12" r="4"></circle> | |
| <path d="M12 2v2M12 20v2M4 12H2M22 12h-2M5 5l1.5 1.5M17.5 17.5 19 19M19 5l-1.5 1.5M6.5 17.5 5 19"></path> | |
| </svg> | |
| </button> | |
| </div> | |
| <div class="language-control"> | |
| <span>Language</span> | |
| <div class="segmented-control"> | |
| <button type="button" class="active" data-lang-toggle="en" aria-pressed="true">EN</button> | |
| <button type="button" data-lang-toggle="zh" aria-pressed="false">ZH</button> | |
| </div> | |
| </div> | |
| </div> | |
| </header> | |
| """, | |
| padding=False, | |
| ) | |
| result_state = gr.State() | |
| zero_gpu_probe_button = gr.Button(visible=False) | |
| zero_gpu_probe_output = gr.JSON(visible=False) | |
| vision_runtime_probe_button = gr.Button(visible=False) | |
| vision_runtime_probe_output = gr.JSON(visible=False) | |
| archive_status = gr.HTML(value=ARCHIVE_STATUS_EMPTY, elem_id="archive-status", padding=False) | |
| with gr.Row(elem_id="intake", elem_classes=["content-section", "top-grid"]): | |
| with gr.Column(scale=7, elem_classes=["archive-panel", "intake-panel"]): | |
| gr.HTML(_panel_header("01", "Wake an Object", "Upload a photo or describe an everyday object.", "唤醒物品"), padding=False) | |
| image_input = gr.Image( | |
| label=copy.UPLOAD_LABEL, | |
| show_label=False, | |
| type="filepath", | |
| sources=["upload"], | |
| placeholder="Drop an object photo here or click to upload.", | |
| elem_id="object-upload", | |
| ) | |
| description_input = gr.Textbox( | |
| label=copy.DESCRIPTION_LABEL, | |
| placeholder=copy.DESCRIPTION_PLACEHOLDER, | |
| lines=2, | |
| max_lines=5, | |
| elem_id="object-description", | |
| ) | |
| gr.HTML("""<div class="mode-header">Personality mode <span class="lang-zh">人格模式</span></div>""", padding=False) | |
| mode_input = gr.Radio( | |
| label=copy.MODE_LABEL, | |
| show_label=False, | |
| choices=PERSONALITY_MODES, | |
| value=DEFAULT_MODE, | |
| elem_id="personality-mode", | |
| elem_classes=["mode-switch"], | |
| ) | |
| generate_button = gr.Button(copy.GENERATE_LABEL, variant="primary", elem_id="wake-button") | |
| with gr.Column(scale=4, elem_classes=["archive-panel", "examples-panel"]): | |
| gr.HTML( | |
| """ | |
| <div class="example-header"> | |
| <div> | |
| <strong>Example Objects</strong> | |
| <span class="lang-zh block">示例物品</span> | |
| </div> | |
| <span class="example-badge">6 filed samples</span> | |
| </div> | |
| """, | |
| padding=False, | |
| ) | |
| example_buttons: list[gr.Button] = [] | |
| for index in range(len(EXAMPLE_OBJECTS)): | |
| example_buttons.append( | |
| gr.Button( | |
| example_button_label(index), | |
| elem_classes=["example-card"], | |
| variant="secondary", | |
| ) | |
| ) | |
| with gr.Row(elem_id="results", elem_classes=["content-section", "results-grid"]): | |
| with gr.Column(scale=5, elem_classes=["archive-panel", "file-panel"]): | |
| gr.HTML(_panel_header("02", "Object File", "Structured understanding, persona, and evidence tags.", "物品档案"), padding=False) | |
| object_file_summary = gr.HTML(value=OBJECT_FILE_EMPTY, elem_id="object-file-summary", padding=False) | |
| with gr.Column(scale=6, elem_classes=["archive-panel", "diary-panel"]): | |
| gr.HTML(_panel_header("03", "Secret Diary", "A private note written by the object.", "秘密日记"), padding=False) | |
| diary_output = gr.Markdown( | |
| value=DIARY_EMPTY, | |
| label=copy.DIARY_LABEL, | |
| elem_id="diary-output", | |
| ) | |
| with gr.Row(elem_id="share-chat", elem_classes=["content-section", "split-section"]): | |
| with gr.Column(scale=5, elem_classes=["archive-panel", "share-panel"], elem_id="share-panel"): | |
| gr.HTML(_panel_header("04", "Share Card", "Screenshot-friendly field evidence.", "分享卡片"), padding=False) | |
| share_card = gr.HTML(value=SHARE_CARD_EMPTY, label=copy.SHARE_CARD_LABEL, padding=False) | |
| with gr.Column(scale=4, elem_classes=["archive-panel", "chat-panel"], elem_id="chat-panel"): | |
| gr.HTML(_panel_header("05", "Object Chat", "Ask after the object wakes up.", "物品对话"), padding=False) | |
| chatbot = gr.Chatbot( | |
| value=_empty_chat_history(), | |
| label=copy.CHAT_LABEL, | |
| type="messages", | |
| height=300, | |
| allow_tags=False, | |
| ) | |
| chat_input = gr.Textbox(placeholder=copy.CHAT_INPUT_PLACEHOLDER, show_label=False) | |
| chat_button = gr.Button(copy.CHAT_BUTTON_LABEL, elem_classes=["quiet-button"]) | |
| with gr.Accordion("Developer details", open=False, elem_classes=["developer-details"]): | |
| trace_summary = gr.HTML(value=TRACE_EMPTY, elem_id="trace-summary", padding=False) | |
| with gr.Row(elem_classes=["developer-json-grid"]): | |
| object_json = gr.JSON(value={}, label=copy.OBJECT_JSON_LABEL) | |
| persona_json = gr.JSON(value={}, label=copy.PERSONA_JSON_LABEL) | |
| trace_json = gr.JSON(value={}, label=copy.TRACE_JSON_LABEL) | |
| trace_path = gr.Textbox(label=copy.TRACE_PATH_LABEL, interactive=False) | |
| manual_outputs = [ | |
| object_file_summary, | |
| object_json, | |
| persona_json, | |
| diary_output, | |
| share_card, | |
| trace_summary, | |
| trace_json, | |
| trace_path, | |
| result_state, | |
| chatbot, | |
| archive_status, | |
| ] | |
| generate_button.click( | |
| fn=generate_object_file, | |
| inputs=[image_input, description_input, mode_input], | |
| outputs=manual_outputs, | |
| ) | |
| for index, button in enumerate(example_buttons): | |
| button.click( | |
| fn=_example_handler(index), | |
| inputs=[], | |
| outputs=[description_input, mode_input, *manual_outputs], | |
| ) | |
| chat_button.click( | |
| fn=chat_with_object, | |
| inputs=[chat_input, chatbot, result_state], | |
| outputs=[chatbot, chat_input], | |
| ) | |
| chat_input.submit( | |
| fn=chat_with_object, | |
| inputs=[chat_input, chatbot, result_state], | |
| outputs=[chatbot, chat_input], | |
| ) | |
| zero_gpu_probe_button.click( | |
| fn=zero_gpu_probe, | |
| inputs=[], | |
| outputs=[zero_gpu_probe_output], | |
| api_name="zero_gpu_probe", | |
| ) | |
| vision_runtime_probe_button.click( | |
| fn=vision_runtime_probe, | |
| inputs=[], | |
| outputs=[vision_runtime_probe_output], | |
| api_name="vision_runtime_probe", | |
| ) | |
| return demo | |
| def _panel_header(index: str, title: str, note: str, chinese: str = "") -> str: | |
| chinese_label = f' <small class="lang-zh">{escape(chinese)}</small>' if chinese else "" | |
| return f""" | |
| <header class="panel-header"> | |
| <span>{escape(index)}</span> | |
| <div> | |
| <h2>{escape(title)}{chinese_label}</h2> | |
| <p>{escape(note)}</p> | |
| </div> | |
| </header> | |
| """ | |
| def _example_handler(index: int): | |
| def load_example() -> tuple[Any, ...]: | |
| item = EXAMPLE_OBJECTS[index] | |
| cached_result = load_sample_generation(index) | |
| if cached_result is not None: | |
| return item["description"], item["mode"], *_format_generation_result(cached_result) | |
| result = generate_object_file(None, item["description"], item["mode"]) | |
| return item["description"], item["mode"], *result | |
| return load_example | |
| def generate_object_file( | |
| image_path: str | None, | |
| description: str, | |
| mode: str, | |
| ) -> GenerationUiResult: | |
| try: | |
| result = generate_object_diary(image_path, description, mode) | |
| except Exception as exc: # pragma: no cover - exercised manually by UI failure paths. | |
| return _generation_error(exc, description, mode) | |
| return _format_generation_result(result) | |
| def _format_generation_result(result: GenerationResult) -> GenerationUiResult: | |
| object_payload = result.object_understanding.model_dump(mode="json") | |
| persona_payload = result.persona.model_dump(mode="json") | |
| return ( | |
| _render_object_file(result), | |
| object_payload, | |
| persona_payload, | |
| format_diary_markdown(result.diary.title, result.diary.english, result.diary.chinese), | |
| render_share_card(result.persona, result.diary), | |
| _render_trace_summary(result), | |
| result.trace.model_dump(mode="json"), | |
| result.trace_path, | |
| result.model_dump(mode="json"), | |
| _awake_chat_history(result), | |
| _render_archive_status(result), | |
| ) | |
| def _render_object_file(result: GenerationResult) -> str: | |
| obj = result.object_understanding.object | |
| persona = result.persona.persona | |
| features = "".join(f"<li>{escape(feature)}</li>" for feature in obj.visible_features) | |
| tags = "".join(f"<span>{escape(tag)}</span>" for tag in persona.tags) | |
| confidence = f"{obj.confidence:.0%}" | |
| case_id = _case_id(result) | |
| runtime_badge = "mock-safe" if "mock" in result.trace.model_runtime["text"] else "llama.cpp" | |
| evidence_tag = f"{result.trace.mode.lower()} witness" | |
| return f""" | |
| <article class="object-file-card"> | |
| <div class="case-strip"> | |
| <span>Case ID {escape(case_id)}</span> | |
| <span>Awake</span> | |
| <span>{escape(runtime_badge)}</span> | |
| </div> | |
| <div class="file-meta"> | |
| <span>Confidence {escape(confidence)}</span> | |
| <span>{escape(result.trace.mode)}</span> | |
| <span>Evidence: {escape(evidence_tag)}</span> | |
| </div> | |
| <h3>{escape(persona.character_name)}</h3> | |
| <p class="object-name">{escape(obj.name)} / {escape(persona.object_name)}</p> | |
| <dl> | |
| <div> | |
| <dt>Mood</dt> | |
| <dd>{escape(persona.mood)}</dd> | |
| </div> | |
| <div> | |
| <dt>Secret fear</dt> | |
| <dd>{escape(persona.secret_fear)}</dd> | |
| </div> | |
| <div> | |
| <dt>Core memory</dt> | |
| <dd>{escape(persona.core_memory)}</dd> | |
| </div> | |
| </dl> | |
| <div class="feature-list"> | |
| <strong>Visible features <span class="lang-zh">可见特征</span></strong> | |
| <ul>{features}</ul> | |
| </div> | |
| <p class="complaint">{escape(persona.complaint)}</p> | |
| <div class="file-tags">{tags}</div> | |
| </article> | |
| """ | |
| def _render_archive_status(result: GenerationResult) -> str: | |
| obj = result.object_understanding.object | |
| persona = result.persona.persona | |
| case_id = _case_id(result) | |
| return f""" | |
| <div class="archive-status awake"> | |
| <div> | |
| <span class="archive-label">Archive Status <span class="lang-zh">档案状态</span></span> | |
| <strong>{escape(persona.character_name)} is awake</strong> | |
| <p>Case {escape(case_id)} opened for {escape(obj.name)} with {obj.confidence:.0%} object confidence.</p> | |
| <p class="lang-zh block">档案 {escape(case_id)} 已开启,物品识别置信度 {obj.confidence:.0%}。</p> | |
| </div> | |
| <div class="status-pills"> | |
| <span>Object awake</span> | |
| <span>Diary unlocked</span> | |
| <span>{escape(result.trace.mode)} mode</span> | |
| </div> | |
| </div> | |
| """ | |
| def _render_trace_summary(result: GenerationResult) -> str: | |
| return f""" | |
| <div class="trace-card"> | |
| <span class="archive-label">Trace saved <span class="lang-zh">Trace 已保存</span></span> | |
| <strong>{escape(result.trace.trace_id)}</strong> | |
| <p>{escape(result.trace.model_runtime["vision"])} · {escape(result.trace.model_runtime["text"])}</p> | |
| </div> | |
| """ | |
| def _generation_error(exc: Exception, description: str, mode: str) -> GenerationUiResult: | |
| error_type = type(exc).__name__ | |
| error_message = str(exc) or "Unknown generation error" | |
| error_payload = { | |
| "error": error_type, | |
| "message": error_message, | |
| "input": {"description": description, "mode": mode}, | |
| } | |
| error_html = f""" | |
| <div class="archive-error"> | |
| <span>Generation failed <span class="lang-zh">生成失败</span></span> | |
| <strong>{escape(error_type)}</strong> | |
| <p>{escape(error_message)}</p> | |
| </div> | |
| """ | |
| error_markdown = ( | |
| "### Generation failed\n\n" | |
| f"{error_type}: {error_message}\n\n" | |
| "Please try another description or sample object.\n\n" | |
| '<div class="lang-zh block zh-helper">请尝试其他描述或示例物品。</div>' | |
| ) | |
| return ( | |
| error_html, | |
| error_payload, | |
| error_payload, | |
| error_markdown, | |
| error_html, | |
| error_html, | |
| error_payload, | |
| "", | |
| None, | |
| [{"role": "assistant", "content": f"Generation failed: {error_type}"}], | |
| f""" | |
| <div class="archive-status error"> | |
| <div> | |
| <span class="archive-label">Archive Status <span class="lang-zh">档案状态</span></span> | |
| <strong>Case jammed</strong> | |
| <p>{escape(error_type)}: {escape(error_message)}</p> | |
| <p class="lang-zh block">生成失败,请换一个描述或示例物品再试。</p> | |
| </div> | |
| <div class="status-pills"> | |
| <span>Needs retry</span> | |
| <span>{escape(mode)}</span> | |
| </div> | |
| </div> | |
| """, | |
| ) | |
| def _case_id(result: GenerationResult) -> str: | |
| return result.trace.trace_id.replace("_", "-").upper() | |
| def _empty_chat_history() -> list[dict[str, str]]: | |
| return [{"role": "assistant", "content": CHAT_EMPTY_MESSAGE}] | |
| def _awake_chat_history(result: GenerationResult) -> list[dict[str, str]]: | |
| name = result.persona.persona.character_name | |
| return [ | |
| { | |
| "role": "assistant", | |
| "content": f"{name} is awake. Ask what it remembers.", | |
| } | |
| ] | |
| def chat_with_object( | |
| message: str, | |
| history: list[dict[str, str]] | None, | |
| result_state: dict[str, Any] | None, | |
| ) -> tuple[list[dict[str, str]], str]: | |
| history = history or _empty_chat_history() | |
| clean_message = message.strip() | |
| if not clean_message: | |
| return history, "" | |
| if not result_state: | |
| reply = CHAT_EMPTY_MESSAGE | |
| else: | |
| reply = reply_as_object(result_state["persona"], clean_message) | |
| history.append({"role": "user", "content": clean_message}) | |
| history.append({"role": "assistant", "content": reply}) | |
| return history, "" | |
| def zero_gpu_probe() -> dict[str, Any]: | |
| try: | |
| import torch | |
| except Exception as exc: | |
| return {"torch_import": False, "error": f"{type(exc).__name__}: {exc}"} | |
| cuda_available = torch.cuda.is_available() | |
| return { | |
| "torch_import": True, | |
| "cuda_available": cuda_available, | |
| "device_count": torch.cuda.device_count(), | |
| "device_name": torch.cuda.get_device_name(0) if cuda_available else "", | |
| } | |
| def vision_runtime_probe() -> dict[str, Any]: | |
| return probe_vision_runtime(load_model=True) | |