BareGit

Tavern-translate: initial commit

Author: MetroWind <chris.corsair@gmail.com>
Date: Sun Apr 19 21:50:52 2026 -0700
Commit: b3b766498fa757e695299127ff8c6be0d0b3dfa5

Changes

diff --git a/tavern-translate/app.js b/tavern-translate/app.js
new file mode 100644
index 0000000..68b12e2
--- /dev/null
+++ b/tavern-translate/app.js
@@ -0,0 +1,430 @@
+import { h, render } from 'https://esm.sh/preact';
+import { useState, useEffect } from 'https://esm.sh/preact/hooks';
+import { signal, computed } from 'https://esm.sh/@preact/signals';
+import htm from 'https://esm.sh/htm';
+
+const html = htm.bind(h);
+
+// --- State and Classes ---
+
+class TranslationNode
+{
+    constructor(original_value, is_ignored = false)
+    {
+        this.original_value = original_value;
+        this.translated_value = signal(null);
+        this.status = signal(is_ignored ? 'ignored' : 'pending');
+        this.error = signal(null);
+    }
+}
+
+class TaskQueue
+{
+    constructor(concurrency = 3)
+    {
+        this.concurrency = concurrency;
+        this.queue = [];
+        this.active_count = 0;
+    }
+
+    async add(task)
+    {
+        return new Promise((resolve, reject) =>
+        {
+            this.queue.push({ task, resolve, reject });
+            this.process();
+        });
+    }
+
+    async process()
+    {
+        if(this.active_count >= this.concurrency || this.queue.length === 0)
+        {
+            return;
+        }
+
+        const { task, resolve, reject } = this.queue.shift();
+        this.active_count++;
+
+        try
+        {
+            const result = await task();
+            resolve(result);
+        }
+        catch(err)
+        {
+            reject(err);
+        }
+        finally
+        {
+            this.active_count--;
+            this.process();
+        }
+    }
+}
+
+// --- Global Signals ---
+
+const STORAGE_PREFIX = 'tavern-translator-';
+const getStorage = (key) => localStorage.getItem(STORAGE_PREFIX + key);
+const setStorage = (key, val) => localStorage.setItem(STORAGE_PREFIX + key, val);
+
+const config = signal({
+    api_url: getStorage('api_url') || 'https://api.openai.com/v1/chat/completions',
+    api_key: getStorage('api_key') || '',
+    system_prompt: getStorage('system_prompt') || '作为一名资深翻译家,把用户提供的文本从英文翻译成中文,保持原文的格式和语气。注意 `{{user}}` 和 `{{char}}` 为特殊标记,不要翻译。',
+    ignored_keys: getStorage('ignored_keys') || 'spec,spec_version,avatar,url,id,keys,secondary_keys,creator,version,chub,fav,talkativeness',
+    concurrency: parseInt(getStorage('concurrency') || '3', 10)
+});
+
+const task_queue = new TaskQueue(config.value.concurrency);
+
+// --- Helper Functions ---
+
+function createProcessTree(input_node, ignored_patterns, is_parent_ignored = false)
+{
+    if(Array.isArray(input_node))
+    {
+        return input_node.map(item => createProcessTree(item, ignored_patterns, is_parent_ignored));
+    }
+    else if(input_node !== null && typeof input_node === 'object')
+    {
+        const new_obj = {};
+        for(const [key, value] of Object.entries(input_node))
+        {
+            const is_ignored = is_parent_ignored || ignored_patterns.some(pattern =>
+            {
+                try
+                {
+                    return new RegExp(pattern.trim()).test(key);
+                }
+                catch(e)
+                {
+                    return false;
+                }
+            });
+            new_obj[key] = createProcessTree(value, ignored_patterns, is_ignored);
+        }
+        return new_obj;
+    }
+    else if(typeof input_node === 'string')
+    {
+        const node = new TranslationNode(input_node, is_parent_ignored);
+        if(!input_node.trim())
+        {
+            node.status.value = 'done';
+        }
+        return node;
+    }
+    return input_node;
+}
+
+function flattenTree(node)
+{
+    if(Array.isArray(node))
+    {
+        return node.map(flattenTree);
+    }
+    else if(node instanceof TranslationNode)
+    {
+        return node.status.value === 'done' ? node.translated_value.value : node.original_value;
+    }
+    else if(node !== null && typeof node === 'object')
+    {
+        const new_obj = {};
+        for(const [key, value] of Object.entries(node))
+        {
+            new_obj[key] = flattenTree(value);
+        }
+        return new_obj;
+    }
+    return node;
+}
+
+async function translateNode(node, force = false)
+{
+    if(node.status.value === 'translating') return;
+    if(!force && node.status.value === 'done') return;
+
+    node.status.value = 'translating';
+    node.error.value = null;
+
+    try
+    {
+        const result = await task_queue.add(async () =>
+        {
+            const response = await fetch(config.value.api_url, {
+                method: 'POST',
+                headers: {
+                    'Content-Type': 'application/json',
+                    'Authorization': `Bearer ${config.value.api_key}`
+                },
+                body: JSON.stringify({
+                    model: 'gpt-3.5-turbo',
+                    messages: [
+                        { role: 'system', content: config.value.system_prompt },
+                        { role: 'user', content: node.original_value }
+                    ],
+                    temperature: 0.3
+                })
+            });
+
+            if(!response.ok)
+            {
+                const error_data = await response.json();
+                throw new Error(error_data.error?.message || 'API Error');
+            }
+
+            const data = await response.json();
+            return data.choices[0].message.content;
+        });
+
+        node.translated_value.value = result;
+        node.status.value = 'done';
+    }
+    catch(err)
+    {
+        node.status.value = 'error';
+        node.error.value = err.message;
+    }
+}
+
+// --- Components ---
+
+function TreeNode({ label, value, depth = 0 })
+{
+    const [is_open, set_is_open] = useState(depth < 2);
+
+    if(value instanceof TranslationNode)
+    {
+        const can_revert = value.status.value === 'done' || value.status.value === 'error';
+        const can_retry = value.status.value === 'done' || value.status.value === 'error';
+
+        return html`
+            <div class="tree-item">
+                <span class="key-name">${label}: </span>
+                <span class="status-badge status-${value.status.value}">${value.status.value}</span>
+                <div class="translation-box">
+                    <div class="original-text">${value.original_value}</div>
+                    <div class="translated-text">${value.status.value === 'done' ? value.translated_value.value : '(Pending/Original)'}</div>
+                    ${value.error.value && html`<div class="error-text" style="color: var(--error-color); font-size: 11px;">${value.error.value}</div>`}
+                    <div class="node-actions">
+                        <button onclick=${() => translateNode(value)} disabled=${value.status.value === 'done'}>Translate</button>
+                        <button class="secondary-btn" onclick=${() => { value.translated_value.value = null; value.status.value = 'pending'; }} disabled=${!can_revert}>Revert</button>
+                        <button class="secondary-btn" onclick=${() => translateNode(value, true)} disabled=${!can_retry}>Retry</button>
+                    </div>
+                </div>
+            </div>
+        `;
+    }
+
+    if(Array.isArray(value) || (value !== null && typeof value === 'object'))
+    {
+        return html`
+            <div class="tree-node-container">
+                <div class="tree-item" onclick=${() => set_is_open(!is_open)} style="cursor: pointer;">
+                    <span class="key-name">${is_open ? '▼' : '▶'} ${label}</span>
+                </div>
+                ${is_open && html`
+                    <div class="tree-node">
+                        ${Object.entries(value).map(([k, v]) => html`<${TreeNode} label=${k} value=${v} depth=${depth + 1} />`)}
+                    </div>
+                `}
+            </div>
+        `;
+    }
+
+    return html`
+        <div class="tree-item">
+            <span class="key-name">${label}: </span>
+            <span>${String(value)}</span>
+        </div>
+    `;
+}
+
+function OptionsDialog({ close })
+{
+    const [local_config, set_local_config] = useState({ ...config.value });
+
+    const save = () =>
+    {
+        config.value = local_config;
+        Object.entries(local_config).forEach(([k, v]) => setStorage(k, v));
+        task_queue.concurrency = local_config.concurrency;
+        close();
+    };
+
+    return html`
+        <div class="modal-overlay">
+            <div class="modal-content">
+                <h3>Options</h3>
+                <div class="form-group">
+                    <label>API Endpoint URL</label>
+                    <input type="text" value=${local_config.api_url} oninput=${(e) => set_local_config({ ...local_config, api_url: e.target.value })} />
+                </div>
+                <div class="form-group">
+                    <label>API Key</label>
+                    <input type="password" value=${local_config.api_key} oninput=${(e) => set_local_config({ ...local_config, api_key: e.target.value })} />
+                </div>
+                <div class="form-group">
+                    <label>Concurrency (Max active requests)</label>
+                    <input type="number" min="1" max="20" value=${local_config.concurrency} oninput=${(e) => set_local_config({ ...local_config, concurrency: parseInt(e.target.value, 10) })} />
+                </div>
+                <div class="form-group">
+                    <label>System Prompt</label>
+                    <textarea rows="4" value=${local_config.system_prompt} oninput=${(e) => set_local_config({ ...local_config, system_prompt: e.target.value })}></textarea>
+                </div>
+                <div class="form-group">
+                    <label>Ignored Keys (Regex, one per line)</label>
+                    <textarea rows="4" value=${local_config.ignored_keys.split(',').join('\n')} oninput=${(e) => set_local_config({ ...local_config, ignored_keys: e.target.value.split('\n').join(',') })}></textarea>
+                </div>
+                <div class="modal-actions">
+                    <button class="secondary-btn" onclick=${close}>Cancel</button>
+                    <button onclick=${save}>Save</button>
+                </div>
+            </div>
+        </div>
+    `;
+}
+
+function App()
+{
+    const [show_options_modal, set_show_options_modal] = useState(false);
+    const [tree, set_tree] = useState(null);
+    const [translating, set_translating] = useState(false);
+    const [json_input, set_json_input] = useState('');
+    const [progress, set_progress] = useState({ current: 0, total: 0 });
+
+    const handleFileUpload = (e) =>
+    {
+        const file = e.target.files[0];
+        if(!file) return;
+
+        const reader = new FileReader();
+        reader.onload = (event) =>
+        {
+            set_json_input(event.target.result);
+            loadJson(event.target.result);
+        };
+        reader.readAsText(file);
+    };
+
+    const loadJson = async (input = json_input) =>
+    {
+        try
+        {
+            const parsed = JSON.parse(input);
+            const patterns = config.value.ignored_keys.split(',').filter(p => p.trim());
+            const new_tree = createProcessTree(parsed, patterns);
+            set_tree(new_tree);
+
+            // Start translation immediately
+            await startAutoTranslate(new_tree);
+        }
+        catch(err)
+        {
+            alert('Invalid JSON: ' + err.message);
+        }
+    };
+
+    const startAutoTranslate = async (current_tree) =>
+    {
+        const target_tree = current_tree || tree;
+        if(!target_tree) return;
+
+        set_translating(true);
+
+        const nodes = [];
+        const findNodes = (obj) =>
+        {
+            if(obj instanceof TranslationNode)
+            {
+                if(obj.status.value === 'pending') nodes.push(obj);
+            }
+            else if(Array.isArray(obj))
+            {
+                obj.forEach(findNodes);
+            }
+            else if(obj !== null && typeof obj === 'object')
+            {
+                Object.values(obj).forEach(findNodes);
+            }
+        };
+
+        findNodes(target_tree);
+
+        set_progress({ current: 0, total: nodes.length });
+
+        let completed = 0;
+        const tasks = nodes.map(async (node) =>
+        {
+            await translateNode(node);
+            completed++;
+            set_progress({ current: completed, total: nodes.length });
+        });
+
+        await Promise.all(tasks);
+        set_translating(false);
+    };
+
+    const downloadResult = () =>
+    {
+        const flattened = flattenTree(tree);
+        const blob = new Blob([JSON.stringify(flattened, null, 4)], { type: 'application/json' });
+        const url = URL.createObjectURL(blob);
+        const a = document.createElement('a');
+        a.href = url;
+        a.download = 'translated_card.json';
+        a.click();
+    };
+
+    const progress_percent = progress.total > 0 ? (progress.current / progress.total) * 100 : 0;
+
+    return html`
+        <div>
+            <header>
+                <h1>SillyTavern Card Translator</h1>
+                <button class="secondary-btn" onclick=${() => set_show_options_modal(true)}>Options</button>
+            </header>
+
+            <main>
+                <div class="input-section">
+                    <div class="form-group">
+                        <label>Upload SillyTavern JSON</label>
+                        <input type="file" accept=".json" onchange=${handleFileUpload} />
+                    </div>
+                    <div class="form-group">
+                        <label>Or Paste JSON</label>
+                        <textarea rows="5" value=${json_input} oninput=${(e) => set_json_input(e.target.value)}></textarea>
+                    </div>
+                    <button onclick=${() => loadJson()}>Load and Translate</button>
+                </div>
+
+                ${tree && html`
+                    <div class="controls">
+                        ${translating && html`
+                            <div style="flex-grow: 1;">
+                                <div class="progress-container">
+                                    <div class="progress-bar" style="width: ${progress_percent}%"></div>
+                                    <div class="progress-text">${progress.current} / ${progress.total}</div>
+                                </div>
+                            </div>
+                        `}
+                        <button onclick=${() => startAutoTranslate()} disabled=${translating}>
+                            ${translating ? 'Translating...' : 'Retry All Pending'}
+                        </button>
+                        <button class="secondary-btn" onclick=${downloadResult} disabled=${translating}>Download JSON</button>
+                    </div>
+
+                    <div class="tree-container">
+                        <${TreeNode} label="Root" value=${tree} />
+                    </div>
+                `}
+            </main>
+
+            ${show_options_modal && html`<${OptionsDialog} close=${() => set_show_options_modal(false)} />`}
+        </div>
+    `;
+}
+
+render(html`<${App} />`, document.getElementById('app'));
diff --git a/tavern-translate/designs/design-0-translator.md b/tavern-translate/designs/design-0-translator.md
new file mode 100644
index 0000000..4a22b2c
--- /dev/null
+++ b/tavern-translate/designs/design-0-translator.md
@@ -0,0 +1,182 @@
+# Design Document: SillyTavern Character Card Translator
+
+## 1. Overview
+This document describes the design and implementation of a pure front-end web application intended to translate SillyTavern character cards (JSON format) from one language to another using OpenAI-compatible LLM APIs.
+
+The application is designed to be highly portable, requiring no build process and capable of being served from any static file server.
+
+## 2. Technical Stack
+
+### 2.1 Preact.js (without Build Process)
+We use [Preact](https://preactjs.com/) as the UI library for its small footprint and compatibility with React. To avoid a build process (like Webpack or Vite), we utilize [htm](https://github.com/developit/htm) (Hyperscript Tagged Markup).
+
+- **Script Loading**: Preact and htm will be loaded via ESM (ECMAScript Modules) from a CDN (e.g., [esm.sh](https://esm.sh/)).
+- **Syntax**: We will use `html` tagged templates instead of JSX.
+    ```javascript
+    import { h, render } from 'https://esm.sh/preact';
+    import htm from 'https://esm.sh/htm';
+    const html = htm.bind(h);
+    ```
+
+### 2.2 Styling
+No external CSS frameworks like Bootstrap or Tailwind will be used, as per the workspace mandates.
+- **CSS**: Custom Vanilla CSS will be used, written in a separate `style.css` file or embedded within the HTML.
+- **Design Principles**: Focus on accessibility, clean typography, and responsive layouts.
+
+### 2.3 Storage
+- **localStorage**: Used to persist configuration options (API keys, endpoints, prompts, ignored keys).
+
+## 3. Architecture and Data Structures
+
+### 3.1 Intermediate Data Representation
+To support "revert" and "retry" functionality, we cannot simply mutate the JSON strings in place. We must maintain a mapping between the original and translated content.
+
+We will define a class (or a structure) `TranslationNode`:
+```javascript
+class TranslationNode
+{
+    constructor(original_value)
+    {
+        this.original_value = original_value;
+        this.translated_value = null;
+        this.status = 'pending'; // 'pending', 'translating', 'done', 'error'
+    }
+}
+```
+
+### 3.2 Application State
+The global state will manage:
+1.  **Configuration**: API credentials and preferences.
+2.  **Source Data**: The original JSON object.
+3.  **Process Tree**: A mirrored object tree where leaf strings are replaced by `TranslationNode` instances.
+
+## 4. Translation Logic
+
+### 4.1 Recursive Traversal and State Mirroring
+The application will transform the input JSON into a reactive state tree. This process is crucial because it converts static strings into interactive objects.
+
+**Algorithm: `createProcessTree(input_node, ignored_patterns)`**
+1.  **Input**: A JavaScript value (Object, Array, String, Number, Boolean, or Null).
+2.  **Logic**:
+    - If `input_node` is an **Array**:
+        - Create a new reactive array (e.g., a signal-wrapped array).
+        - Iterate through each element and recursively call `createProcessTree` for each.
+    - If `input_node` is an **Object**:
+        - Create a new reactive object.
+        - Iterate through each `key-value` pair.
+        - Check if `key` matches any regex in `ignored_patterns`.
+        - If it matches, recursively call `createProcessTree` for the value, but pass a flag to mark all downstream nodes as `ignored`.
+        - Otherwise, recursively call `createProcessTree` for the value.
+    - If `input_node` is a **String**:
+        - If the `ignored` flag is set, return a `TranslationNode` with `status: 'ignored'`.
+        - Otherwise, return a new `TranslationNode(input_node)`.
+    - For all other types: Return the value as-is.
+
+### 4.2 OpenAI API Integration Details
+The `translateText` function must handle the specific schema of OpenAI-compatible APIs (like GPT-4 or local models like Ollama/vLLM).
+
+**Request Structure**:
+```json
+{
+    "model": "gpt-3.5-turbo",
+    "messages": [
+        {"role": "system", "content": "System prompt from settings..."},
+        {"role": "user", "content": "Text to translate..."}
+    ],
+    "temperature": 0.3
+}
+```
+
+**Response Handling**:
+- Success: Extract `choices[0].message.content`.
+- Error: Handle `401` (Unauthorized), `429` (Rate Limit), and `500` (Server Error). Update the `TranslationNode.status` to `'error'` and store the error message for display.
+
+**Concurrency**: To avoid browser-level hang-ups or API rate limits, we use a task queue pattern. A `TaskQueue` class will manage pending translations, ensuring only `N` (e.g., 3) requests are active at once.
+
+**TaskQueue Implementation Details**:
+1.  **Queue Storage**: A private array `this._queue` stores objects containing the `text` and a `resolve/reject` pair.
+2.  **Execution Loop**: A `process()` method that:
+    - Checks if the number of active requests is less than the limit.
+    - If so, shifts a task from the queue.
+    - Increments the `active_count`.
+    - Executes the `fetch` request.
+    - Upon completion (success or failure), decrements `active_count` and calls `process()` again to trigger the next task.
+3.  **Usage**: `const translated = await task_queue.add(() => translateText(node.original_value))`.
+
+## 5. UI Components
+
+### 5.1 Main Layout
+A simple container with:
+- **Header**: Title and "Options" button.
+- **Input Area**: A file upload or text area for the raw JSON.
+- **Control Bar**: "Start Translation", "Download Result" buttons.
+- **Main View**: Toggleable between the raw input and the Fine-tuning Tree View.
+
+### 5.2 Options Dialog
+A modal containing:
+- **API URL**: e.g., `https://api.openai.com/v1/chat/completions`.
+- **API Key**: Password field for the key.
+- **System Prompt**: Multi-line text area.
+- **Ignored Keys**: A list of strings, each treated as a regex.
+- **Save/Close**: Persists to `localStorage`.
+
+### 5.3 Fine-tuning Tree View Implementation
+The Tree View is a recursive component: `TreeNode({ key, value, depth })`.
+
+- **Collapsible Folders**: If `value` is an Object or Array, render a toggle icon to collapse/expand children. Use a `depth` variable to apply CSS `padding-left`.
+- **String Node Rendering**:
+    - If `value` is a `TranslationNode`:
+        - Render the `original_value` in a dimmed style.
+        - Render a text area or div containing the `translated_value` (if present) or `original_value` (if pending).
+        - **Action Buttons**:
+            - `Translate`: Sets `status` to `'translating'`, calls `translateText`, then updates `translated_value` and sets `status` to `'done'`.
+            - `Revert`: Sets `translated_value` to `null` and `status` to `'pending'`.
+            - `Retry`: Same as `Translate`, but ignores the "already translated" check.
+
+### 5.4 Global State Management (Signals)
+Since we are using Preact, we will use [@preact/signals](https://preactjs.com/guide/v10/signals/) for state management. This allows fine-grained updates; clicking "Retry" on a single leaf will only re-render that specific `TreeNode` component, not the entire tree.
+
+- `config`: A signal holding the settings object.
+- `processTree`: A signal holding the root of the mirrored JSON tree.
+- `isTranslating`: A boolean signal to disable global controls during batch processing.
+
+## 6. Detailed Implementation Steps
+
+### Step 1: Basic Scaffolding
+1.  Create `index.html`.
+2.  Import Preact and htm.
+3.  Create a basic "Hello World" component to verify the environment.
+
+### Step 2: Configuration Management
+1.  Implement `Settings` service to read/write to `localStorage`.
+2.  Build the `OptionsDialog` component.
+
+### Step 3: JSON Processing Engine
+1.  Write a function `preprocessJson(obj, path, ignored_patterns)` that returns a tree of `TranslationNode` objects.
+2.  Implement the recursive logic for `ignored_keys`.
+
+### Step 4: Translation Service
+1.  Implement `translateText(text, settings)` which calls the LLM API.
+2.  Handle error states and network timeouts.
+
+### Step 5: Tree View Component
+1.  Build a recursive `TreeNode` component.
+2.  Connect the `Translate`, `Revert`, and `Retry` buttons to the state.
+
+### Step 6: Export Logic
+1.  Implement a function to "flatten" the `TranslationNode` tree back into a standard JSON string for download.
+
+## 7. Coding Standards (Per GEMINI.md)
+
+- **Indentation**: 4 spaces.
+- **Braces**: Left brace on a new line.
+- **Naming**:
+    - Variables: `snake_case` (e.g., `api_key`).
+    - Functions: `camelCase` (e.g., `handleFileUpload`).
+    - Classes: `CapCase` (e.g., `TranslationManager`).
+- **Logic**: Use `unique_ptr` logic for state (ensure single source of truth).
+
+## 8. Testing Strategy
+- **Unit Tests**: Test the regex filtering logic with various SillyTavern card samples.
+- **Integration Tests**: Mock the API response to test the UI's handling of translation results and errors.
+- **Manual Verification**: Upload a real character card and verify the tree view renders correctly.
diff --git a/tavern-translate/index.html b/tavern-translate/index.html
new file mode 100644
index 0000000..8396339
--- /dev/null
+++ b/tavern-translate/index.html
@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>SillyTavern Card Translator</title>
+    <link rel="stylesheet" href="style.css">
+</head>
+<body>
+    <div id="app"></div>
+    <script type="module" src="app.js"></script>
+</body>
+</html>
diff --git a/tavern-translate/prd.md b/tavern-translate/prd.md
new file mode 100644
index 0000000..39be35a
--- /dev/null
+++ b/tavern-translate/prd.md
@@ -0,0 +1,73 @@
+# Translator for SillyTavern Cards
+
+This is a pure front-end web app to translate (e.g. from English to
+Chinese) SillyTavern character cards.
+
+## Tech stack
+
+- Use preact.js. The app should be written in a way that doesn’t
+  require a build process. The user should be able to simply host the
+  files as static files in an HTTP server and use it.
+- No style-related external libraries (e.g. bootstrap).
+
+## The translation process
+
+A SillyTavern character card is basically just a piece of JSON code.
+The process of tranlate such a character card is the following:
+
+1. Parse the JSON into JavaScript object
+2. Iterate over all the “leaf” values in this object. If the value is
+   a string, it should be translated.
+3. For each “leaf” string value, the app should use an
+   OpenAI-compatible LLM API to translate it, and replace the value
+   with the translated value. These requests should be managed by a
+   task queue to handle concurrency and prevent overloading the API
+   or the browser.
+4. After all “leaf” string values are translated, provide the JSON as
+   a string to the user.
+
+## Details
+
+- The app should have an option dialog.
+
+- The options should include the LLM prompt.
+
+- The endpoint URL of the LLM API, the model name, and the API key are
+  also configurable in the option dialog.
+
+- There should be a list of “ignored keys”, which is a list of regular
+  expressions. In the process of iterating over the “leaf” values, if
+  the any of patterns in the ignored keys is found in the key, all the
+  values under the key should not be translated. Note that the key
+  does not have to be the key of the “leaf” value; it could be a key
+  in an intermediate layer. The ignored keys should be configurable in
+  the option dialog.
+
+- The number of concurrent translations in the task queue should also
+  be configurable in the option dialog.
+
+- The options are saved in the browser’s local storage.
+
+- After the translation is done. There should be an interface that
+  layout the JSON as a tree on the UI. Each string value will be
+  accompanied by three buttons: translate, revert, or retry. This
+  interface will allow the user to finetune the translation result.
+
+  * If the user clicks the “translate” button, and the value was not
+    translated at that point (maybe because it was ignored by an
+    ignored key), the app should translate the value. If the value is
+    already translated, the “translate” button does nothing.
+
+  * If the user clicks the “revert” button, and the value was
+    translated, it should revert the value to the original value. If
+    that value was not translated, this button does nothing.
+
+  * If the user clicks the “retry” button, and the value was
+    translated, the app should redo the translation on that value. If
+    the value was not translated, this button does nothing.
+
+- Because of this interface, it is recommended to create an
+  intermediate type (maybe a JavaScript class), which stores both the
+  original value, and the translated value. In the beginning of the
+  translation process, the string values in the JSON should be
+  replaced by an object of this type.
diff --git a/tavern-translate/style.css b/tavern-translate/style.css
new file mode 100644
index 0000000..6828526
--- /dev/null
+++ b/tavern-translate/style.css
@@ -0,0 +1,249 @@
+:root
+{
+    --bg-color: #1a1a1a;
+    --text-color: #e0e0e0;
+    --accent-color: #4a90e2;
+    --secondary-bg: #2d2d2d;
+    --border-color: #444;
+    --success-color: #4caf50;
+    --error-color: #f44336;
+    --pending-color: #ff9800;
+}
+
+body
+{
+    background-color: var(--bg-color);
+    color: var(--text-color);
+    font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
+    margin: 0;
+    padding: 0;
+    line-height: 1.6;
+}
+
+#app
+{
+    max-width: 1000px;
+    margin: 0 auto;
+    padding: 20px;
+}
+
+header
+{
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    border-bottom: 1px solid var(--border-color);
+    margin-bottom: 20px;
+    padding-bottom: 10px;
+}
+
+button
+{
+    background-color: var(--accent-color);
+    color: white;
+    border: none;
+    padding: 8px 16px;
+    border-radius: 4px;
+    cursor: pointer;
+    font-size: 14px;
+    transition: opacity 0.2s;
+}
+
+button:hover
+{
+    opacity: 0.8;
+}
+
+button:disabled
+{
+    background-color: #666;
+    cursor: not-allowed;
+}
+
+.secondary-btn
+{
+    background-color: transparent;
+    border: 1px solid var(--accent-color);
+    color: var(--accent-color);
+}
+
+.controls
+{
+    display: flex;
+    gap: 10px;
+    margin-bottom: 20px;
+}
+
+.input-section
+{
+    background-color: var(--secondary-bg);
+    padding: 20px;
+    border-radius: 8px;
+    margin-bottom: 20px;
+}
+
+textarea
+{
+    width: 100%;
+    background-color: #121212;
+    color: #ccc;
+    border: 1px solid var(--border-color);
+    border-radius: 4px;
+    padding: 10px;
+    font-family: monospace;
+    resize: vertical;
+}
+
+/* Tree View Styling */
+.tree-container
+{
+    background-color: var(--secondary-bg);
+    border-radius: 8px;
+    padding: 20px;
+    overflow-x: auto;
+}
+
+.tree-node
+{
+    margin-left: 20px;
+    border-left: 1px solid #444;
+    padding-left: 10px;
+}
+
+.tree-item
+{
+    margin: 5px 0;
+}
+
+.key-name
+{
+    font-weight: bold;
+    color: var(--accent-color);
+}
+
+.translation-box
+{
+    background-color: #222;
+    padding: 10px;
+    border-radius: 4px;
+    margin-top: 5px;
+}
+
+.original-text
+{
+    font-size: 12px;
+    color: #888;
+    margin-bottom: 5px;
+    white-space: pre-wrap;
+}
+
+.translated-text
+{
+    white-space: pre-wrap;
+}
+
+.node-actions
+{
+    margin-top: 5px;
+    display: flex;
+    gap: 5px;
+}
+
+.node-actions button
+{
+    padding: 4px 8px;
+    font-size: 11px;
+}
+
+.status-badge
+{
+    display: inline-block;
+    padding: 2px 6px;
+    border-radius: 3px;
+    font-size: 10px;
+    margin-left: 10px;
+    text-transform: uppercase;
+}
+
+.status-pending { background-color: var(--pending-color); }
+.status-translating { background-color: var(--accent-color); }
+.status-done { background-color: var(--success-color); }
+.status-error { background-color: var(--error-color); }
+.status-ignored { background-color: #555; }
+
+/* Modal Styling */
+.modal-overlay
+{
+    position: fixed;
+    top: 0;
+    left: 0;
+    right: 0;
+    bottom: 0;
+    background-color: rgba(0,0,0,0.8);
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    z-index: 1000;
+}
+
+.modal-content
+{
+    background-color: var(--secondary-bg);
+    padding: 30px;
+    border-radius: 8px;
+    width: 90%;
+    max-width: 600px;
+}
+
+.form-group
+{
+    margin-bottom: 15px;
+}
+
+.form-group label
+{
+    display: block;
+    margin-bottom: 5px;
+}
+
+.form-group input, .form-group textarea
+{
+    width: 100%;
+    box-sizing: border-box;
+}
+
+.progress-container
+{
+    background-color: #333;
+    border-radius: 10px;
+    height: 20px;
+    width: 100%;
+    margin-bottom: 20px;
+    overflow: hidden;
+    border: 1px solid var(--border-color);
+    position: relative;
+}
+
+.progress-bar
+{
+    background-color: var(--success-color);
+    height: 100%;
+    transition: width 0.3s ease;
+}
+
+.progress-text
+{
+    position: absolute;
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    font-size: 12px;
+    font-weight: bold;
+    color: white;
+    text-shadow: 1px 1px 2px rgba(0,0,0,0.8);
+    pointer-events: none;
+}