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'));