From 40d92e73eba36d2565e957342900530dc39280cf Mon Sep 17 00:00:00 2001 From: XianYuDaXian Date: Tue, 24 Mar 2026 20:14:05 +0800 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=E4=BC=98=E5=8C=96=E8=81=8A?= =?UTF-8?q?=E5=A4=A9=E4=B8=8E=E5=AA=92=E4=BD=93=E9=A1=B5=E4=BD=93=E9=AA=8C?= =?UTF-8?q?=E9=9D=A0=E6=8B=A2=E5=AE=98=E7=BD=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/grok/services/chat.py | 18 + app/services/reverse/utils/session.py | 108 ++++ app/static/cf-worker/worker.js | 64 ++ app/static/i18n/README.md | 213 +++++++ app/static/i18n/i18n.js | 124 ++++ app/static/i18n/locales/en.json | 583 ++++++++++++++++++ app/static/i18n/locales/zh.json | 583 ++++++++++++++++++ app/static/public/css/chat.css | 177 +++++- app/static/public/css/imagine_workbench.css | 14 + app/static/public/css/video.css | 18 + app/static/public/js/imagine_workbench.js | 16 +- app/static/public/js/video.js | 21 + .../public/pages/imagine_workbench.html | 7 +- app/static/public/pages/video.html | 5 +- 14 files changed, 1928 insertions(+), 23 deletions(-) create mode 100644 app/services/reverse/utils/session.py create mode 100644 app/static/cf-worker/worker.js create mode 100644 app/static/i18n/README.md create mode 100644 app/static/i18n/i18n.js create mode 100644 app/static/i18n/locales/en.json create mode 100644 app/static/i18n/locales/zh.json diff --git a/app/services/grok/services/chat.py b/app/services/grok/services/chat.py index e7afa90..55b4a9a 100644 --- a/app/services/grok/services/chat.py +++ b/app/services/grok/services/chat.py @@ -259,6 +259,15 @@ def extract_sources_payload(model_response: Dict[str, Any]) -> Dict[str, Any] | return None +def extract_render_payload(model_response: Dict[str, Any]) -> Dict[str, Any] | None: + if not isinstance(model_response, dict) or not model_response: + return None + return { + "rawModelResponse": model_response, + "extraImages": proc_base._collect_images(model_response), + } + + def _get_chat_semaphore() -> asyncio.Semaphore: global _CHAT_SEMAPHORE, _CHAT_SEM_VALUE value = max(1, int(get_config("chat.concurrent"))) @@ -903,6 +912,7 @@ class StreamProcessor(proc_base.BaseProcessor): finish: str = None, tool_calls: list = None, sources: Dict[str, Any] = None, + rendering: Dict[str, Any] = None, ) -> str: """Build SSE response.""" delta = {} @@ -926,6 +936,8 @@ class StreamProcessor(proc_base.BaseProcessor): } if sources is not None: chunk["sources"] = sources + if rendering is not None: + chunk["rendering"] = rendering return f"data: {orjson.dumps(chunk).decode()}\n\n" def _ensure_source_group( @@ -1018,6 +1030,9 @@ class StreamProcessor(proc_base.BaseProcessor): continue if mr := resp.get("modelResponse"): + render_payload = extract_render_payload(mr) + if render_payload: + yield self._sse(rendering=render_payload) if self.image_think_active and self.think_opened: async for chunk in self._close_think_block(): yield chunk @@ -1361,6 +1376,7 @@ class CollectProcessor(proc_base.BaseProcessor): finish_reason = "tool_calls" sources_payload = extract_sources_payload(final_model_response) + render_payload = extract_render_payload(final_model_response) message_obj = { "role": "assistant", "content": content, @@ -1371,6 +1387,8 @@ class CollectProcessor(proc_base.BaseProcessor): message_obj["tool_calls"] = tool_calls_result if sources_payload: message_obj["sources"] = sources_payload + if render_payload: + message_obj["rendering"] = render_payload return { "id": response_id, diff --git a/app/services/reverse/utils/session.py b/app/services/reverse/utils/session.py new file mode 100644 index 0000000..b41662d --- /dev/null +++ b/app/services/reverse/utils/session.py @@ -0,0 +1,108 @@ +""" +Resettable session wrapper for reverse requests. +""" + +import asyncio +from typing import Any, Iterable, Optional + +from curl_cffi.requests import AsyncSession +from curl_cffi.const import CurlOpt + +from app.core.config import get_config +from app.core.logger import logger + + +def _should_skip_proxy_ssl() -> bool: + return bool(get_config("proxy.skip_proxy_ssl_verify")) and bool( + get_config("proxy.base_proxy_url") + ) + + +class ResettableSession: + """AsyncSession wrapper that resets connection on specific HTTP status codes.""" + + def __init__( + self, + *, + reset_on_status: Optional[Iterable[int]] = None, + **session_kwargs: Any, + ): + self._session_kwargs = dict(session_kwargs) + if not self._session_kwargs.get("impersonate"): + browser = get_config("proxy.browser") + if browser: + self._session_kwargs["impersonate"] = browser + config_codes = get_config("retry.reset_session_status_codes") + if reset_on_status is None: + reset_on_status = config_codes if config_codes is not None else [403] + if isinstance(reset_on_status, int): + reset_on_status = [reset_on_status] + self._reset_on_status = ( + {int(code) for code in reset_on_status} if reset_on_status else set() + ) + self._skip_proxy_ssl = _should_skip_proxy_ssl() + self._reset_requested = False + self._reset_lock = asyncio.Lock() + self._session = self._create_session() + + def _create_session(self) -> AsyncSession: + kwargs = dict(self._session_kwargs) + if self._skip_proxy_ssl: + opts = kwargs.get("curl_options", {}) + opts[CurlOpt.PROXY_SSL_VERIFYPEER] = 0 + opts[CurlOpt.PROXY_SSL_VERIFYHOST] = 0 + kwargs["curl_options"] = opts + return AsyncSession(**kwargs) + + async def _maybe_reset(self) -> None: + if not self._reset_requested: + return + async with self._reset_lock: + if not self._reset_requested: + return + self._reset_requested = False + old_session = self._session + self._session = self._create_session() + try: + await old_session.close() + except Exception: + pass + logger.debug("ResettableSession: session reset") + + async def _request(self, method: str, *args: Any, **kwargs: Any): + await self._maybe_reset() + response = await getattr(self._session, method)(*args, **kwargs) + if self._reset_on_status and response.status_code in self._reset_on_status: + self._reset_requested = True + return response + + async def get(self, *args: Any, **kwargs: Any): + return await self._request("get", *args, **kwargs) + + async def post(self, *args: Any, **kwargs: Any): + return await self._request("post", *args, **kwargs) + + async def reset(self) -> None: + self._reset_requested = True + await self._maybe_reset() + + async def close(self) -> None: + if self._session is None: + return + try: + await self._session.close() + finally: + self._session = None + self._reset_requested = False + + async def __aenter__(self) -> "ResettableSession": + return self + + async def __aexit__(self, exc_type, exc, tb) -> None: + await self.close() + + def __getattr__(self, name: str) -> Any: + return getattr(self._session, name) + + +__all__ = ["ResettableSession"] diff --git a/app/static/cf-worker/worker.js b/app/static/cf-worker/worker.js new file mode 100644 index 0000000..3b7ee92 --- /dev/null +++ b/app/static/cf-worker/worker.js @@ -0,0 +1,64 @@ +/** + * Grok2API Cloudflare Worker Proxy + * + * 功能: + * 1. 代理请求到后端 grok2api 实例 + * 2. 处理 CORS 跨域请求 + * 3. 支持 Server-Sent Events (SSE) 流式返回 + */ + +const BACKEND_URL = "https://your-grok2api-instance.com"; // 替换为你的后端地址 +const API_KEY = ""; // 可选:如果后端开启了认证,可以在这里硬编码或通过 KV/环境变量传入 + +export default { + async fetch(request, env, ctx) { + const url = new URL(request.url); + const targetUrl = new URL(url.pathname + url.search, BACKEND_URL); + + // 处理 OPTIONS 预检请求 + if (request.method === "OPTIONS") { + return handleOptions(request); + } + + const newRequest = new Request(targetUrl, { + method: request.method, + headers: new Headers(request.headers), + body: request.body, + redirect: "follow", + }); + + // 如果后端需要 API Key,可以在这里注入 + // if (API_KEY) { + // newRequest.headers.set("Authorization", `Bearer ${API_KEY}`); + // } + + try { + const response = await fetch(newRequest); + + // 克隆响应以修改头部(例如 CORS) + const newResponse = new Response(response.body, response); + newResponse.headers.set("Access-Control-Allow-Origin", "*"); + newResponse.headers.set("Access-Control-Allow-Methods", "GET, POST, OPTIONS, DELETE, PUT"); + newResponse.headers.set("Access-Control-Allow-Headers", "*"); + + return newResponse; + } catch (e) { + return new Response(JSON.stringify({ error: "Proxy connection failed", detail: e.message }), { + status: 502, + headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" }, + }); + } + }, +}; + +function handleOptions(request) { + return new Response(null, { + status: 204, + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS, DELETE, PUT", + "Access-Control-Allow-Headers": "*", + "Access-Control-Max-Age": "86400", + }, + }); +} diff --git a/app/static/i18n/README.md b/app/static/i18n/README.md new file mode 100644 index 0000000..cd737fa --- /dev/null +++ b/app/static/i18n/README.md @@ -0,0 +1,213 @@ +# Grok2API i18n Developer Guide + +## Architecture + +The i18n system is a single-file, zero-dependency module (`i18n.js`) that: + +1. **Detects** the user's language: `localStorage` > `?lang=` URL param > browser `navigator.language` > fallback (`zh`) +2. **Fetches** the matching locale JSON (`/static/i18n/locales/{lang}.json`) +3. **Applies** translations to the DOM via `data-i18n-*` attributes +4. **Exposes** a global `t(key, params)` function for JS-driven strings + +The script self-initializes on DOM ready. No import/export — it attaches `window.I18n` and `window.t`. + +## File structure + +``` +public/static/i18n/ + i18n.js # Core module (IIFE, ~110 lines) + locales/ + zh.json # Chinese (default/fallback) + en.json # English + README.md # This file +``` + +Each HTML page includes the script before page-specific JS: + +```html + + +``` + +## Translation key conventions + +Keys use **dot-notation** with a two-level namespace: `{page}.{name}` + +| Namespace | Scope | +|-----------|-------| +| `common` | Shared strings (save, cancel, loading, errors) | +| `nav` | Navigation links and header labels | +| `login` | Login pages (admin + public) | +| `config` | Admin config page | +| `token` | Token management page | +| `cache` | Cache management page | +| `chat` | Chat page | +| `imagine` | Image generation page | +| `video` | Video generation page | +| `voice` | Voice/LiveKit page | + +Naming patterns: +- `page.pageTitle` — browser tab `` +- `page.title` / `page.subtitle` — heading/subheading +- `page.placeholder` — main input placeholder +- `common.{verb}Failed` / `common.{verb}Error` — error messages +- `page.confirm{Action}` — confirmation dialog text + +## Translating a static HTML element + +Add one of these attributes to the HTML element: + +### `data-i18n` — sets `textContent` + +```html +<h2 data-i18n="chat.title">Chat 聊天</h2> +``` + +### `data-i18n-placeholder` — sets `placeholder` + +```html +<textarea data-i18n-placeholder="chat.placeholder" placeholder="询问任何内容"></textarea> +``` + +### `data-i18n-title` — sets `title` (tooltip) + +```html +<button data-i18n-title="chat.send" title="发送">▶</button> +``` + +### `data-i18n-html` — sets `innerHTML` (available, not currently used) + +```html +<span data-i18n-html="chat.richContent"></span> +``` + +> Use only when the translation contains HTML markup. Prefer `data-i18n` for plain text to avoid XSS risks. + +> Keep the Chinese fallback as the element's default content/attribute so the page renders correctly before translations load. + +## Translating a dynamic JS string + +Use the global `t(key, params?)` function: + +```js +showToast(t('cache.clearSuccess', { size: data.result.size_mb }), 'success'); +``` + +The `params` object replaces `{var}` placeholders in the locale string: + +```json +{ "clearSuccess": "Cleared successfully, freed {size} MB" } +``` + +### Guard pattern for shared modules + +Shared code (e.g., `toast.js`, `admin-auth.js`) may run before `i18n.js` loads. Guard with `typeof t`: + +```js +const label = typeof t === 'function' ? t('common.notice') : '提示'; +``` + +## Translating dynamically injected HTML + +When HTML is injected at runtime (e.g., fetching a header fragment), call `I18n.applyToDOM(container)` after insertion: + +```js +container.innerHTML = await res.text(); + +if (window.I18n) { + I18n.applyToDOM(container); +} +``` + +This scans the container for `data-i18n-*` attributes and applies translations. The language toggle button text is set separately since it isn't a translation key: + +```js +var toggle = container.querySelector('#lang-toggle'); +if (toggle) toggle.textContent = I18n.getLang() === 'zh' ? 'EN' : '中'; +``` + +## API reference + +| Method | Description | +|--------|-------------| +| `t(key, params?)` | Translate a key. Returns the key itself if not found. | +| `I18n.applyToDOM(root?)` | Scan `root` (default: `document`) for `data-i18n-*` attrs and apply translations. | +| `I18n.setLang(lang)` | Set language, persist to localStorage, reload page. | +| `I18n.toggleLang()` | Toggle between `zh` and `en`, reload. | +| `I18n.getLang()` | Return current language code (`'zh'` or `'en'`). | +| `I18n.onReady(cb)` | Run `cb` after translations have loaded. Runs immediately if already loaded. | + +## How to add a new language + +1. Create `public/static/i18n/locales/{code}.json` — copy `en.json` as a template +2. Translate every key (545+ keys across 10 namespaces) +3. In `i18n.js`, add the code to the `SUPPORTED` array: + ```js + var SUPPORTED = ['zh', 'en', 'ja']; + ``` +4. Update the `toggleLang` function if you want rotation instead of binary toggle +5. Update `#lang-toggle` button logic in `header.js` / `public-header.js` + +## How to add a new page + +Checklist: + +- [ ] Add a new namespace in both `zh.json` and `en.json` (e.g., `"mypage": { ... }`) +- [ ] In the HTML: add `<script src="/static/i18n/i18n.js"></script>` before your page JS +- [ ] In the HTML: add `data-i18n` / `data-i18n-placeholder` / `data-i18n-title` attributes to all user-visible elements +- [ ] In the JS: use `t('mypage.keyName')` for any strings built in code +- [ ] If injecting HTML dynamically: call `I18n.applyToDOM(container)` after insertion +- [ ] If the page loads before `i18n.js`: use the `typeof t === 'function'` guard + +## Common patterns + +### Button with icon + translated text + +```html +<button class="btn" type="button"> + <svg>...</svg> + <span data-i18n="voice.startSession">开始会话</span> +</button> +``` + +Wrap the text in a `<span>` so the icon is preserved. + +### Select options translated in JS + +```js +const option = document.createElement('option'); +option.value = 'value'; +option.textContent = t('voice.optionLabel'); +select.appendChild(option); +``` + +### Interpolation for dynamic values + +```js +// Locale: "lastClear": "Last cleared: {time}" +setText(el, t('cache.lastClear', { time: timeText })); +``` + +### Table rows with inline translated strings + +```js +body.innerHTML = `<tr><td colspan="5">${t('common.loading')}</td></tr>`; +``` + +## Gotchas + +1. **Script order matters.** `i18n.js` must be loaded before page JS if the page JS calls `t()` at the top level. Otherwise use the `typeof t === 'function'` guard. + +2. **`t()` returns the key on miss.** If you see a raw key like `chat.title` in the UI, the key is missing from the locale JSON or was misspelled. + +3. **Keep both locale files in sync.** Every key in `zh.json` must exist in `en.json` and vice versa. Missing keys fall through to displaying the raw key string. + +4. **`innerHTML` vs `textContent`.** Use `data-i18n-html` only when the translation contains HTML markup. For plain text, always use `data-i18n` to avoid XSS risks. + +5. **Default content is Chinese.** The HTML files keep Chinese as inline fallback text. This means the page briefly shows Chinese before translations load — this is intentional and ensures the page is never blank. + +6. **`setLang()` reloads the page.** Language switching is not live — it persists to localStorage and triggers a full page reload. + +7. **`applyToDOM()` is idempotent.** Safe to call multiple times on the same container. + +8. **Shared modules need guards.** Files under `common/js/` (toast, header, admin-auth) may be loaded by pages that don't include `i18n.js`. Always guard `t()` calls in shared code. diff --git a/app/static/i18n/i18n.js b/app/static/i18n/i18n.js new file mode 100644 index 0000000..67141c9 --- /dev/null +++ b/app/static/i18n/i18n.js @@ -0,0 +1,124 @@ +/** + * Grok2API i18n module — lightweight, zero-dependency internationalization. + * Detects language from: localStorage > ?lang= URL param > browser > fallback (zh). + * Translates static HTML via data-i18n / data-i18n-placeholder / data-i18n-title attrs. + * Provides global t(key, params) for dynamic JS strings with {var} interpolation. + */ +(function () { + 'use strict'; + + var STORAGE_KEY = 'grok2api_lang'; + var SUPPORTED = ['zh', 'en']; + var DEFAULT = 'zh'; + + var lang = DEFAULT; + var data = {}; + var ready = false; + var queue = []; + + function getStaticVersion() { + var script = document.querySelector('script[src*="?v="]'); + if (!script || !script.src) return ''; + var m = script.src.match(/[?&]v=([^&]+)/); + return m ? m[1] : ''; + } + + function detect() { + try { + var s = localStorage.getItem(STORAGE_KEY); + if (s && SUPPORTED.indexOf(s) !== -1) return s; + } catch (e) { /* ignore */ } + try { + var p = new URLSearchParams(location.search).get('lang'); + if (p && SUPPORTED.indexOf(p) !== -1) { + try { localStorage.setItem(STORAGE_KEY, p); } catch (e) { /* ignore */ } + return p; + } + } catch (e) { /* ignore */ } + var b = (navigator.language || '').split('-')[0]; + if (SUPPORTED.indexOf(b) !== -1) return b; + return DEFAULT; + } + + function get(obj, key) { + for (var parts = key.split('.'), i = 0; i < parts.length; i++) { + if (obj == null) return undefined; + obj = obj[parts[i]]; + } + return obj; + } + + function t(key, params) { + var v = get(data, key); + if (v === undefined) return key; + if (params) { + Object.keys(params).forEach(function (k) { + v = v.replace(new RegExp('\\{' + k + '\\}', 'g'), params[k]); + }); + } + return v; + } + + function applyToDOM(root) { + var c = root || document; + c.querySelectorAll('[data-i18n]').forEach(function (el) { + var v = get(data, el.getAttribute('data-i18n')); + if (v !== undefined) el.textContent = v; + }); + c.querySelectorAll('[data-i18n-placeholder]').forEach(function (el) { + var v = get(data, el.getAttribute('data-i18n-placeholder')); + if (v !== undefined) el.placeholder = v; + }); + c.querySelectorAll('[data-i18n-title]').forEach(function (el) { + var v = get(data, el.getAttribute('data-i18n-title')); + if (v !== undefined) el.title = v; + }); + c.querySelectorAll('[data-i18n-aria-label]').forEach(function (el) { + var v = get(data, el.getAttribute('data-i18n-aria-label')); + if (v !== undefined) el.setAttribute('aria-label', v); + }); + c.querySelectorAll('[data-i18n-html]').forEach(function (el) { + var v = get(data, el.getAttribute('data-i18n-html')); + if (v !== undefined) el.innerHTML = v; + }); + } + + function init() { + lang = detect(); + document.documentElement.lang = lang === 'zh' ? 'zh-CN' : lang; + var version = getStaticVersion(); + var url = '/static/i18n/locales/' + lang + '.json' + (version ? ('?v=' + encodeURIComponent(version)) : ''); + fetch(url) + .then(function (r) { if (!r.ok) throw r; return r.json(); }) + .then(function (j) { data = j; }) + .catch(function () { data = {}; }) + .then(function () { + ready = true; + applyToDOM(document); + queue.forEach(function (cb) { cb(); }); + queue = []; + }); + } + + function setLang(l) { + if (SUPPORTED.indexOf(l) === -1) return; + try { localStorage.setItem(STORAGE_KEY, l); } catch (e) { /* ignore */ } + location.reload(); + } + + window.I18n = { + t: t, + applyToDOM: applyToDOM, + setLang: setLang, + toggleLang: function () { setLang(lang === 'zh' ? 'en' : 'zh'); }, + getLang: function () { return lang; }, + onReady: function (cb) { if (ready) cb(); else queue.push(cb); } + }; + window.t = t; + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})(); diff --git a/app/static/i18n/locales/en.json b/app/static/i18n/locales/en.json new file mode 100644 index 0000000..3e66c6f --- /dev/null +++ b/app/static/i18n/locales/en.json @@ -0,0 +1,583 @@ +{ + "common": { + "save": "Save", + "cancel": "Cancel", + "confirm": "Confirm", + "delete": "Delete", + "loading": "Loading...", + "invalidKey": "Invalid key", + "connectionFailed": "Connection failed", + "requestFailed": "Request failed", + "operationSuccess": "Operation successful", + "gotIt": "Got it", + "notice": "Notice", + "rateLimitNotice": "The official GROK website update did not expose the rate-limits API, making it impossible to accurately calculate remaining Token usage. Please wait for the official API. Currently the count resets to 8 after auto-refresh.", + "pleaseConfirm": "Please confirm", + "ok": "OK", + "close": "Close", + "error": "Error", + "unknownError": "Unknown error", + "connectionInterrupted": "Connection interrupted", + "taskInProgress": "A task is already in progress", + "taskNoPause": "Current task does not support pausing", + "batchNoPause": "Current batch task does not support pausing", + "waitTaskFinish": "Please wait for the current task to finish", + "pause": "Pause", + "stop": "Stop", + "selectAll": "Select all", + "selected": "Selected", + "items": "items", + "export": "Export", + "refresh": "Refresh", + "load": "Load", + "files": "files", + "view": "View", + "retry": "Retry", + "copy": "Copy", + "edit": "Edit", + "loadFailed": "Load failed", + "loadError": "Load failed: {msg}", + "saveFailed": "Save failed", + "saveError": "Save error: {msg}", + "deleteFailed": "Delete failed", + "deleteSuccess": "Deleted successfully", + "notConnected": "Not connected", + "connecting": "Connecting", + "connected": "Connected", + "connectionError": "Connection error", + "generating": "Generating", + "done": "Done", + "failed": "Failed", + "stopped": "Stopped", + "terminated": "Terminated", + "sending": "Sending", + "fileReadFailed": "File read failed", + "noFileSelected": "No file selected", + "configurePublicKey": "Please configure Public Key first", + "enterPrompt": "Please enter a prompt", + "enterContent": "Please enter content", + "alreadyRunning": "Already running", + "createTaskFailed": "Failed to create task", + "copyFailed": "Copy failed", + "copied": "Copied", + "generationFailed": "Generation failed", + "partialError": "Partial error", + "loadingInProgress": "Loading in progress, please wait", + "cleaningInProgress": "Cleaning in progress, please wait", + "noTokenSelected": "No Token selected", + "clearCache": "Clear cache", + "failureDetails": "Failure Details", + "retryFailed": "Retry failed items" + }, + "nav": { + "tokenManage": "Token Mgmt", + "configManage": "Config Mgmt", + "cacheManage": "Cache Mgmt", + "chat": "Chat", + "imagine": "Imagine Gallery", + "video": "Video Generation", + "voice": "LiveKit Voice", + "feedback": "Feedback", + "logout": "Logout", + "adminPanel": "Admin Panel", + "storageMode": "Storage Mode" + }, + "login": { + "adminPageTitle": "Grok2API - Login", + "adminTitle": "Admin Panel", + "adminSubtitle": "Enter admin password to continue", + "adminPlaceholder": "Admin Password (app_key)", + "continue": "Continue", + "publicPageTitle": "Grok2API - Public", + "publicTitle": "Public Access", + "publicSubtitle": "Enter Public Key (leave empty if not configured)", + "publicPlaceholder": "Public Key", + "enter": "Enter", + "goToAdmin": "Go to Admin Panel" + }, + "config": { + "pageTitle": "Grok2API - Config", + "title": "Configuration", + "subtitle": "Manage API keys and system parameters.", + "saving": "Saving...", + "saved": "Saved", + "configSaved": "Configuration saved", + "invalidJson": "Invalid JSON: {field}", + "appKeyRequired": "app_key cannot be empty (admin password)", + "flaresolverrRequired": "FlareSolverr URL is required when auto-refresh is enabled", + "generate": "Generate", + "publicAccess": "Public Access", + "noDesc": "No description available. Please refer to the documentation.", + "sectionFallback": "{section} Settings", + "sections": { + "app": "App Settings", + "proxy": "Proxy Config", + "retry": "Retry Strategy", + "chat": "Chat Config", + "video": "Video Config", + "image": "Image Config", + "imagine_fast": "Imagine Fast Config", + "asset": "Asset Config", + "voice": "Voice Config", + "token": "Token Pool Mgmt", + "cache": "Cache Mgmt", + "nsfw": "NSFW Config", + "usage": "Usage Config" + }, + "sectionDescs": { + "proxy": "Incorrect configuration will cause 403 errors. The IP of the first request to Grok must match the IP used to obtain the CF Clearance. Subsequent IP changes will not cause 403." + }, + "fields": { + "app": { + "api_key": {"title": "API Key", "desc": "Token for calling Grok2API services (optional, supports multiple, comma-separated or array)."}, + "app_key": {"title": "Admin Password", "desc": "Password for logging into the Grok2API admin panel (required)."}, + "function_enabled": {"title": "Enable Function Access", "desc": "Whether to enable the function access entry (disabled means function pages are inaccessible)."}, + "function_key": {"title": "Function Password", "desc": "Access password for function pages (optional)."}, + "app_url": {"title": "App URL", "desc": "External access URL for the Grok2API service, used for file link access."}, + "image_format": {"title": "Image Format", "desc": "Default image format (url or base64)."}, + "video_format": {"title": "Video Format", "desc": "Default video format (html or url, url is the processed link)."}, + "temporary": {"title": "Temporary Chat", "desc": "Whether to enable temporary chat mode by default."}, + "disable_memory": {"title": "Disable Memory", "desc": "Whether to disable Grok memory feature by default."}, + "stream": {"title": "Streaming", "desc": "Whether to enable streaming output by default."}, + "thinking": {"title": "Chain of Thought", "desc": "Whether to enable chain of thought output by default."}, + "dynamic_statsig": {"title": "Dynamic Fingerprint", "desc": "Whether to enable dynamic Statsig fingerprint generation by default."}, + "custom_instruction": {"title": "Custom Instruction", "desc": "Multi-line text passed through as Grok request parameter customPersonality."}, + "filter_tags": {"title": "Filter Tags", "desc": "Configure automatic filtering of special tags in Grok responses."} + }, + "proxy": { + "base_proxy_url": {"title": "Base Proxy URL", "desc": "Base service address for proxying requests to Grok website."}, + "asset_proxy_url": {"title": "Asset Proxy URL", "desc": "Address for proxying static resource (image/video) requests to Grok website."}, + "skip_proxy_ssl_verify": {"title": "Skip Proxy SSL Verify", "desc": "Enable when your proxy uses a self-signed certificate (only bypasses proxy certificate validation)."}, + "enabled": {"title": "Enable CF Auto-Refresh", "desc": "When enabled, automatically obtains cf_clearance through FlareSolverr."}, + "flaresolverr_url": {"title": "FlareSolverr URL", "desc": "HTTP address of FlareSolverr service (e.g. http://flaresolverr:8191)."}, + "refresh_interval": {"title": "Refresh Interval (sec)", "desc": "Time interval for auto-refreshing cf_clearance, recommended minimum 300 seconds."}, + "timeout": {"title": "Challenge Timeout (sec)", "desc": "Maximum time to wait for FlareSolverr to solve CF challenge."}, + "cf_clearance": {"title": "CF Clearance", "desc": "Cloudflare Clearance Cookie for bypassing anti-bot verification. Automatically managed when auto-refresh is enabled."}, + "browser": {"title": "Browser Fingerprint", "desc": "curl_cffi browser fingerprint identifier (e.g. chrome136). Automatically managed when auto-refresh is enabled."}, + "user_agent": {"title": "User-Agent", "desc": "HTTP request User-Agent string. Automatically managed when auto-refresh is enabled."} + }, + "retry": { + "max_retry": {"title": "Max Retries", "desc": "Maximum number of retries when Grok service request fails."}, + "retry_status_codes": {"title": "Retry Status Codes", "desc": "HTTP status codes that trigger a retry."}, + "reset_session_status_codes": {"title": "Session Reset Status Codes", "desc": "HTTP status codes that trigger session rebuild (used for proxy rotation)."}, + "retry_backoff_base": {"title": "Backoff Base", "desc": "Base delay (seconds) for retry backoff."}, + "retry_backoff_factor": {"title": "Backoff Factor", "desc": "Exponential multiplier for retry backoff."}, + "retry_backoff_max": {"title": "Backoff Max", "desc": "Maximum delay (seconds) for a single retry wait."}, + "retry_budget": {"title": "Backoff Budget", "desc": "Maximum total retry time (seconds) for a single request."} + }, + "chat": { + "concurrent": {"title": "Concurrency Limit", "desc": "Reverse API concurrency limit."}, + "timeout": {"title": "Request Timeout", "desc": "Reverse API timeout (seconds)."}, + "stream_timeout": {"title": "Stream Idle Timeout", "desc": "Streaming idle timeout (seconds)."} + }, + "video": { + "enable_public_asset": {"title": "Public Asset Link", "desc": "Whether to create a public asset link after video generation finishes."}, + "concurrent": {"title": "Concurrency Limit", "desc": "Reverse API concurrency limit."}, + "timeout": {"title": "Request Timeout", "desc": "Reverse API timeout (seconds)."}, + "stream_timeout": {"title": "Stream Idle Timeout", "desc": "Streaming idle timeout (seconds)."}, + "upscale_timing": {"title": "Upscale Timing", "desc": "Basic pool 720p upscale mode: single upscales after each extension round, complete upscales once after all rounds."} + }, + "image": { + "timeout": {"title": "Request Timeout", "desc": "WebSocket request timeout (seconds)."}, + "stream_timeout": {"title": "Stream Idle Timeout", "desc": "WebSocket streaming idle timeout (seconds)."}, + "final_timeout": {"title": "Final Image Timeout", "desc": "Timeout (seconds) to wait for final image after receiving medium image."}, + "blocked_grace_seconds": {"title": "Review Grace Seconds", "desc": "Grace seconds for suspected review/censorship after receiving medium image (default 10, configurable)."}, + "nsfw": {"title": "NSFW Mode", "desc": "Whether to enable NSFW for WebSocket requests."}, + "medium_min_bytes": {"title": "Medium Min Bytes", "desc": "Minimum byte size for medium quality image detection."}, + "final_min_bytes": {"title": "Final Min Bytes", "desc": "Minimum byte size for final image detection (typically JPG > 100KB)."}, + "blocked_parallel_enabled": {"title": "Enable Parallel Compensation", "desc": "Whether to enable parallel compensation when suspected review/blocking occurs."}, + "blocked_parallel_attempts": {"title": "Block Compensation Concurrency", "desc": "Number of parallel compensation attempts when suspected review/blocking prevents final image."} + }, + "imagine_fast": { + "n": {"title": "Generation Count", "desc": "Server-side unified generation count for grok-imagine-1.0-fast only (1-10)."}, + "size": {"title": "Image Size", "desc": "Server-side unified size for grok-imagine-1.0-fast only."}, + "response_format": {"title": "Response Format", "desc": "Server-side unified response format for grok-imagine-1.0-fast only."} + }, + "asset": { + "upload_concurrent": {"title": "Upload Concurrency", "desc": "Maximum concurrent uploads. Recommended: 30."}, + "upload_timeout": {"title": "Upload Timeout", "desc": "Upload timeout (seconds). Recommended: 60."}, + "download_concurrent": {"title": "Download Concurrency", "desc": "Maximum concurrent downloads. Recommended: 30."}, + "download_timeout": {"title": "Download Timeout", "desc": "Download timeout (seconds). Recommended: 60."}, + "list_concurrent": {"title": "Query Concurrency", "desc": "Maximum concurrent asset queries. Recommended: 10."}, + "list_timeout": {"title": "Query Timeout", "desc": "Asset query timeout (seconds). Recommended: 60."}, + "list_batch_size": {"title": "Query Batch Size", "desc": "Number of tokens per query batch. Recommended: 10."}, + "delete_concurrent": {"title": "Delete Concurrency", "desc": "Maximum concurrent asset deletions. Recommended: 10."}, + "delete_timeout": {"title": "Delete Timeout", "desc": "Asset deletion timeout (seconds). Recommended: 60."}, + "delete_batch_size": {"title": "Delete Batch Size", "desc": "Number of tokens per delete batch. Recommended: 10."} + }, + "voice": { + "timeout": {"title": "Request Timeout", "desc": "Voice request timeout (seconds)."} + }, + "token": { + "auto_refresh": {"title": "Auto Refresh", "desc": "Whether to enable automatic Token refresh."}, + "refresh_interval_hours": {"title": "Refresh Interval", "desc": "Time interval (hours) for regular Token refresh."}, + "super_refresh_interval_hours": {"title": "Super Refresh Interval", "desc": "Time interval (hours) for Super Token refresh."}, + "fail_threshold": {"title": "Failure Threshold", "desc": "Number of consecutive failures before a Token is marked unavailable."}, + "save_delay_ms": {"title": "Save Delay", "desc": "Delay (ms) for batching Token change writes."}, + "usage_flush_interval_sec": {"title": "Usage Flush Interval", "desc": "Minimum interval (seconds) for writing usage data to database."}, + "reload_interval_sec": {"title": "Sync Interval", "desc": "Token state refresh interval (seconds) in multi-worker scenarios."} + }, + "cache": { + "enable_auto_clean": {"title": "Auto Clean", "desc": "Whether to enable automatic cache cleanup based on size limit."}, + "limit_mb": {"title": "Clean Threshold", "desc": "Cache size threshold (MB) that triggers cleanup."} + }, + "nsfw": { + "concurrent": {"title": "Concurrency Limit", "desc": "Maximum concurrent requests for batch NSFW enable. Recommended: 10."}, + "batch_size": {"title": "Batch Size", "desc": "Batch processing count for NSFW enable. Recommended: 50."}, + "timeout": {"title": "Request Timeout", "desc": "Timeout for NSFW-related requests (seconds). Recommended: 60."} + }, + "usage": { + "concurrent": {"title": "Concurrency Limit", "desc": "Maximum concurrent requests for batch usage refresh. Recommended: 10."}, + "batch_size": {"title": "Batch Size", "desc": "Batch processing count for usage refresh. Recommended: 50."}, + "timeout": {"title": "Request Timeout", "desc": "Usage query timeout (seconds). Recommended: 60."} + } + } + }, + "token": { + "pageTitle": "Grok2API - Token Mgmt", + "title": "Token List", + "subtitle": "Manage the Grok2API Token service pool.", + "import": "Import", + "add": "Add", + "statTotal": "Total Tokens", + "statActive": "Active", + "statCooling": "Rate Limited", + "statInvalid": "Invalid", + "statChatQuota": "Chat Remaining", + "statImageQuota": "Image Remaining", + "statVideoQuota": "Video Remaining", + "statVideoUnavailable": "N/A", + "statTotalCalls": "Total Calls", + "tabAll": "All", + "tabActive": "Active", + "tabCooling": "Rate Limited", + "tabExpired": "Expired", + "tabNsfw": "NSFW On", + "tabNoNsfw": "NSFW Off", + "statusFilterAria": "Token status filter", + "tableToken": "Token", + "tableType": "Type", + "tableStatus": "Status", + "tableQuota": "Quota", + "tableNote": "Note", + "tableActions": "Actions", + "refreshStatus": "Refresh status", + "emptyState": "No Tokens yet. Click Import or Add above.", + "emptyFilterState": "No results for current filter. Try a different filter.", + "selectPage": "Select page", + "selectAllFiltered": "Select all", + "clearSelection": "Clear selection", + "prevPage": "Prev", + "nextPage": "Next", + "perPage": "{size} / page", + "pagination": "Page {current} / {total} \u00b7 {count} items", + "batchExport": "Export", + "batchRefresh": "Refresh", + "batchDisable": "Disable", + "batchEnable": "Enable", + "batchDelete": "Delete", + "importTitle": "Batch Import Tokens", + "importTargetPool": "Target Pool", + "importTokenList": "Token List (one per line)", + "importPlaceholder": "Paste Tokens, one per line...", + "startImport": "Start Import", + "editTitle": "Edit Token", + "addTitle": "Add Token", + "editType": "Type", + "editQuota": "Quota", + "editNote": "Note", + "editNotePlaceholder": "Optional note", + "tokenEmpty": "Token cannot be empty", + "tokenExists": "Token already exists", + "confirmDelete": "Are you sure you want to delete this Token?", + "confirmDisable": "Are you sure you want to disable this Token?\n{token}", + "confirmEnable": "Are you sure you want to enable this Token?\n{token}", + "confirmBatchDelete": "Are you sure you want to delete {count} selected Tokens?", + "confirmBatchDisable": "Are you sure you want to disable {count} selected Tokens?", + "confirmBatchEnable": "Are you sure you want to enable {count} selected Tokens?", + "disableToken": "Disable", + "enableToken": "Enable", + "disableDone": "Token disabled", + "enableDone": "Token enabled", + "batchDisableDone": "Batch disable complete", + "batchEnableDone": "Batch enable complete", + "noTokenToDisable": "All selected Tokens are already disabled", + "noTokenToEnable": "All selected Tokens are already enabled", + "deleteDone": "Delete complete", + "refreshDone": "Refresh complete", + "refreshSuccess": "Refresh successful", + "refreshFailed": "Refresh failed", + "refreshError": "Refresh failed: {msg}", + "requestError": "Request error", + "notValidJson": "Response is not valid JSON (HTTP {status})", + "listEmpty": "List is empty", + "stopRefresh": "Refresh stopped", + "stopDelete": "Delete stopped", + "stopNsfw": "NSFW stopped", + "nsfwConfirm": "Enable NSFW mode for {count} selected Tokens?", + "nsfwEnable": "Enable NSFW", + "nsfwDone": "NSFW enable complete", + "nsfwResult": "NSFW enable complete: {ok} succeeded, {fail} failed", + "nsfwFailed": "Enable failed: {msg}", + "emptyResponse": "Empty response (HTTP {status})" + }, + "cache": { + "pageTitle": "Grok2API - Cache Mgmt", + "title": "Cache Management", + "subtitle": "Manage local resources and online asset cache.", + "localImages": "Local Images", + "localVideos": "Local Videos", + "onlineAssets": "Online Assets", + "tableFile": "File", + "tableSize": "Size", + "tableTime": "Time", + "tableActions": "Actions", + "tableToken": "Token", + "tableType": "Type", + "tableAssetCount": "Asset Count", + "tableLastClear": "Last Cleared", + "selectPage": "Select page", + "selectAllFiles": "Select all", + "clearSelection": "Clear selection", + "prevPage": "Prev", + "nextPage": "Next", + "perPageLabel": "Per page", + "perPage": "{size} / page", + "pagination": "Page {current} / {total} · {count} items", + "notLoaded": "Not loaded", + "noAccounts": "No accounts available", + "noFiles": "No files", + "noTokenAvailable": "No Token available", + "cannotConnect": "Cannot connect", + "lastClear": "Last cleared: {time}", + "clear": "Clear", + "clean": "Clean", + "confirmClearImage": "Clear all local image cache?", + "confirmClearVideo": "Clear all local video cache?", + "clearSuccess": "Cleanup successful, freed {size} MB", + "clearFailed": "Cleanup failed", + "confirmDeleteFile": "Are you sure you want to delete this file?", + "confirmBatchDeleteFiles": "Are you sure you want to delete {count} selected files?", + "noFilesSelected": "No files selected", + "deletedFiles": "Deleted {count} files", + "deleteResult": "Delete complete: {success} succeeded, {failed} failed", + "loadStatsFailed": "Failed to load statistics", + "selectAccountsToLoad": "Please select accounts to load", + "selectAccountsToClear": "Please select accounts to clear", + "confirmClearAccounts": "Clear online assets for {count} selected accounts?", + "confirmClearAccount": "Clear online assets for account {label}?", + "batchCleanInProgress": "Batch cleaning online assets, please wait...", + "cleanInProgress": "Cleaning online assets, please wait...", + "batchCleanDone": "Batch cleanup complete", + "cleanResult": "Cleanup complete (succeeded: {success}, failed: {failed})", + "cleanFailedEntry": "Cleanup failed", + "requestTimeout": "Request timed out or failed", + "loadDone": "Load complete", + "loadingStatus": "Loading", + "loadStopped": "Load stopped", + "loadFailedMsg": "Load failed: {msg}", + "stoppedLoadRequests": "Remaining load requests stopped", + "cleanStopped": "Clean stopped", + "cleanFailedMsg": "Clean failed: {msg}", + "stoppedCleanRequests": "Remaining clean requests stopped" + }, + "chat": { + "pageTitle": "Grok2API - Chat", + "title": "Chat", + "subtitle": "Chat with Grok via the chat API", + "sessions": "Sessions", + "newChat": "New", + "collapseSidebar": "Collapse", + "expandSidebar": "Expand", + "sessionList": "Session list", + "newSession": "New Session", + "ready": "Ready", + "emptyState": "Type a message to start a conversation.", + "uploadFile": "Upload file", + "placeholder": "Ask anything", + "settings": "Settings", + "send": "Send", + "temperature": "Temperature", + "topP": "Top P", + "systemPrompt": "System Prompt", + "systemPromptPlaceholder": "Optional, set system prompt", + "fileLabel": "[File]", + "compositeContent": "[Composite]", + "storageFull": "Local storage is full, some sessions were not saved", + "attachmentNoEdit": "Attachment messages cannot be edited yet", + "saveEdit": "Save", + "saveEditTitle": "Save edit", + "cancelEdit": "Cancel", + "cancelEditTitle": "Cancel edit", + "contentEmpty": "Content cannot be empty", + "editMessage": "Edit", + "editMessageTitle": "Edit message content", + "regenerate": "Regenerate", + "regenerateTitle": "Regenerate response from here", + "retryTitle": "Retry last response", + "editAnswer": "Edit", + "editAnswerTitle": "Edit response content", + "copyAnswer": "Copy", + "copyAnswerTitle": "Copy response content", + "feedback": "Feedback", + "feedbackTitle": "Feedback to Grok2API", + "noContentToCopy": "No content to copy", + "noChatToRetry": "No conversation to retry", + "requestFailedCheck": "Request failed, please check service status", + "requestFailedStatus": "Request failed: {status}", + "clickRetry": "Click to retry", + "thinking": "Thinking", + "thinkingSec": "Thinking {sec}s", + "thought": "Thought", + "thinkLabel": "Thinking", + "empty": "(empty)", + "toolWebSearch": "Web search", + "toolImageSearch": "Image search", + "toolAgentThink": "Reasoning", + "toolDefault": "Tool" + }, + "imagine": { + "pageTitle": "Grok2API - Imagine Gallery", + "title": "Imagine Gallery", + "subtitle": "Continuously generate images via WebSocket, real-time base64 gallery.", + "genSettings": "Generation Settings", + "prompt": "Prompt", + "promptPlaceholder": "Describe the image you want, e.g.: futuristic city neon rain at night, wide angle photography", + "autoFollow": "Auto Follow", + "autoFollowDesc": "Scroll to latest", + "autoSave": "Auto Save", + "autoSaveDesc": "Auto-save images", + "aspectRatio": "Aspect Ratio", + "ratio2_3": "2:3 Portrait", + "ratio1_1": "1:1 Square", + "ratio3_2": "3:2 Landscape", + "ratio16_9": "16:9 Wide", + "ratio9_16": "9:16 Tall", + "concurrent": "Concurrency", + "tasks": "{n} tasks", + "concurrentTask1": "1 task", + "concurrentTask2": "2 tasks", + "concurrentTask3": "3 tasks", + "autoFilter": "Auto Filter", + "autoFilterDesc": "Filter substandard images", + "nsfwLabel": "NSFW", + "nsfwOn": "On", + "nsfwOff": "Off", + "saveLocation": "Save Location", + "browserDefault": "Browser default location", + "reverseOrder": "Reverse Order", + "reverseOrderDesc": "Show newest on top", + "connectionStatus": "Connection Status", + "connectionMode": "Connection Mode", + "connectionModeAria": "Connection mode", + "imageCount": "Image Count", + "activeTasks": "Active Tasks", + "avgTime": "Avg Time", + "gallery": "Gallery", + "waitingTitle": "Waiting for connection", + "waitingSubtitle": "Generated images will appear here after starting a task", + "start": "Start", + "batch": "Batch", + "batchDownloadTitle": "Batch download", + "selectAll": "Select all", + "deselectAll": "Deselect all", + "download": "Download", + "startedTasks": "Started {count} concurrent tasks", + "startedTasksSSE": "Started {count} concurrent tasks (SSE)", + "generatingSSE": "Generating (SSE)", + "selectFolder": "Selected folder: {name}", + "selectFolderFailed": "Failed to select folder", + "noImagesSelected": "Please select images to download first", + "jszipFailed": "JSZip library failed to load, please refresh and try again", + "packing": "Packing {count} images...", + "packingBtn": "Packing...", + "packingProgress": "Packing... ({done}/{total})", + "noImagesDownloaded": "No images were downloaded successfully", + "generatingZip": "Generating ZIP...", + "packSuccess": "Successfully packed {count} images", + "packFailed": "Packing failed, please try again" + }, + "video": { + "pageTitle": "Grok2API - Video Generation", + "title": "Video Generation", + "subtitle": "Generate short videos with reference images and preset styles.", + "startGenerate": "Generate", + "genSettings": "Generation Settings", + "prompt": "Prompt", + "promptPlaceholder": "e.g.: neon rain at night on the street, slow motion, film grain", + "referenceImage": "Reference Image", + "referenceImagePlaceholder": "https://... or data:image/...", + "aspectRatio": "Aspect Ratio", + "ratio3_2": "3:2 Landscape", + "ratio2_3": "2:3 Portrait", + "ratio16_9": "16:9 Wide", + "ratio9_16": "9:16 Tall", + "ratio1_1": "1:1 Square", + "duration": "Duration", + "seconds": "{n}s", + "resolution": "Resolution", + "stylePreset": "Style Preset", + "upload": "Upload", + "clearImage": "Clear", + "runStatus": "Run Status", + "elapsedTime": "Elapsed {sec}s", + "elapsedTimeNone": "Elapsed -", + "metaRatio": "Ratio", + "metaDuration": "Duration", + "metaResolution": "Resolution", + "metaPreset": "Preset", + "videoPreview": "Video Preview", + "clearPreview": "Clear", + "waitingGenerate": "Waiting for video generation", + "videoTitle": "Video {n}", + "open": "Open", + "generatingPlaceholder": "Generating...", + "superResolution": "Super Resolution", + "superResolutionInProgress": "Super resolution in progress", + "alreadyGenerating": "Already generating", + "referenceConflict": "Reference image: choose either URL/Base64 or file upload", + "downloadFailed": "Download failed, please check if the video link is accessible", + "sec6": "6s", + "sec10": "10s", + "sec15": "15s" + }, + "voice": { + "pageTitle": "Grok2API - LiveKit Voice", + "title": "LiveKit Voice", + "subtitle": "LiveKit voice session, connect to Grok Voice.", + "startSession": "Start Session", + "stopSession": "Stop Session", + "connectionSettings": "Connection Settings", + "voiceLabel": "Voice", + "personalityLabel": "Personality", + "speedLabel": "Speed", + "settingsHint": "Tip: Voice / Personality settings are sent to the server before connecting.", + "sessionLog": "Session Log", + "clearLog": "Clear", + "copyLog": "Copy", + "sessionStatus": "Session Status", + "audioOutput": "Audio Output", + "audioHint": "Subscribed audio tracks will be automatically inserted here.", + "livekitSDKError": "Error: LiveKit SDK failed to load properly, please refresh the page", + "livekitLoadFailed": "LiveKit SDK failed to load", + "secureContextBrowser": "Please use a modern browser and allow microphone access", + "secureContextHTTPS": "Please use HTTPS or access from localhost", + "secureContextError": "Current environment does not support microphone access, {hint}", + "connectingStatus": "Connecting", + "fetchingToken": "Fetching Token...", + "fetchTokenFailed": "Failed to get Token: {status}", + "fetchTokenSuccess": "Token obtained successfully", + "participantConnected": "Participant connected: {identity}", + "participantDisconnected": "Participant disconnected: {identity}", + "trackSubscribed": "Track subscribed: {kind}", + "disconnected": "Disconnected", + "connectedToServer": "Connected to LiveKit server", + "inCall": "In call", + "openingMic": "Opening microphone...", + "voiceEnabled": "Voice enabled", + "voiceConnected": "Voice connected successfully", + "errorPrefix": "Error: {msg}", + "logCopied": "Log copied", + "copyLogFailed": "Copy failed, please select manually" + } +} diff --git a/app/static/i18n/locales/zh.json b/app/static/i18n/locales/zh.json new file mode 100644 index 0000000..206e70e --- /dev/null +++ b/app/static/i18n/locales/zh.json @@ -0,0 +1,583 @@ +{ + "common": { + "save": "保存", + "cancel": "取消", + "confirm": "确认", + "delete": "删除", + "loading": "加载中...", + "invalidKey": "密钥无效", + "connectionFailed": "连接失败", + "requestFailed": "请求失败", + "operationSuccess": "操作成功", + "gotIt": "我知道了", + "notice": "提示", + "rateLimitNotice": "GROK 官方网页更新后未真实暴露 rate-limits 接口,导致无法准确计算 Token 剩余,请耐心等待官方接口上线,目前自动刷新后会更新为 8 次", + "pleaseConfirm": "请确认", + "ok": "确定", + "close": "关闭", + "error": "错误", + "unknownError": "未知错误", + "connectionInterrupted": "连接中断", + "taskInProgress": "当前有任务进行中", + "taskNoPause": "当前任务不支持暂停", + "batchNoPause": "当前批量任务不支持暂停", + "waitTaskFinish": "请等待当前任务结束", + "pause": "暂停", + "stop": "终止", + "selectAll": "全选", + "selected": "已选择", + "items": "项", + "export": "导出", + "refresh": "刷新", + "load": "加载", + "files": "个文件", + "view": "查看", + "retry": "重试", + "copy": "复制", + "edit": "编辑", + "loadFailed": "加载失败", + "loadError": "加载失败: {msg}", + "saveFailed": "保存失败", + "saveError": "保存错误: {msg}", + "deleteFailed": "删除失败", + "deleteSuccess": "删除成功", + "notConnected": "未连接", + "connecting": "连接中", + "connected": "连接正常", + "connectionError": "连接错误", + "generating": "生成中", + "done": "完成", + "failed": "失败", + "stopped": "已停止", + "terminated": "已终止", + "sending": "发送中", + "fileReadFailed": "文件读取失败", + "noFileSelected": "未选择文件", + "configurePublicKey": "请先配置 Public Key", + "enterPrompt": "请输入提示词", + "enterContent": "请输入内容", + "alreadyRunning": "已在运行中", + "createTaskFailed": "创建任务失败", + "copyFailed": "复制失败", + "copied": "已复制", + "generationFailed": "生成失败", + "partialError": "部分异常", + "loadingInProgress": "正在加载中,请稍候", + "cleaningInProgress": "正在清理中,请稍候", + "noTokenSelected": "未选择 Token", + "clearCache": "清空缓存", + "failureDetails": "失败详情", + "retryFailed": "重试失败项" + }, + "nav": { + "tokenManage": "Token管理", + "configManage": "配置管理", + "cacheManage": "缓存管理", + "chat": "Chat 聊天", + "imagine": "Imagine 瀑布流", + "video": "Video 视频生成", + "voice": "LiveKit 陪聊", + "feedback": "反馈", + "logout": "退出", + "adminPanel": "管理后台", + "storageMode": "存储模式" + }, + "login": { + "adminPageTitle": "Grok2API - 登录", + "adminTitle": "管理后台", + "adminSubtitle": "请输入后台密码以继续", + "adminPlaceholder": "后台密码 (app_key)", + "continue": "继续", + "publicPageTitle": "Grok2API - Public", + "publicTitle": "Public Access", + "publicSubtitle": "请输入 Public Key(如未配置可直接进入)", + "publicPlaceholder": "Public Key", + "enter": "进入", + "goToAdmin": "进入管理后台" + }, + "config": { + "pageTitle": "Grok2API - 配置管理", + "title": "配置管理", + "subtitle": "管理 API 密钥及系统参数设置。", + "saving": "保存中...", + "saved": "成功", + "configSaved": "配置已保存", + "invalidJson": "无效的 JSON: {field}", + "appKeyRequired": "app_key 不能为空(后台密码)", + "flaresolverrRequired": "启用自动刷新时必须填写 FlareSolverr 地址", + "generate": "生成", + "publicAccess": "功能玩法", + "noDesc": "暂无说明,请参考配置文档。", + "sectionFallback": "{section} 设置", + "sections": { + "app": "应用设置", + "proxy": "代理配置", + "retry": "重试策略", + "chat": "对话配置", + "video": "视频配置", + "image": "图像配置", + "imagine_fast": "Imagine Fast 配置", + "asset": "资产配置", + "voice": "语音配置", + "token": "Token 池管理", + "cache": "缓存管理", + "nsfw": "NSFW 配置", + "usage": "Usage 配置" + }, + "sectionDescs": { + "proxy": "配置不正确将导致 403 错误。服务首次请求 Grok 时的 IP 必须与获取 CF Clearance 时的 IP 一致,后续服务器请求 IP 变化不会导致 403。" + }, + "fields": { + "app": { + "api_key": {"title": "API 密钥", "desc": "调用 Grok2API 服务的 Token(可选,支持多个,逗号分隔或数组)。"}, + "app_key": {"title": "后台密码", "desc": "登录 Grok2API 管理后台的密码(必填)。"}, + "function_enabled": {"title": "启用功能玩法", "desc": "是否启用功能玩法入口(关闭则功能玩法页面不可访问)。"}, + "function_key": {"title": "Function 密码", "desc": "功能玩法页面的访问密码(可选)。"}, + "app_url": {"title": "应用地址", "desc": "当前 Grok2API 服务的外部访问 URL,用于文件链接访问。"}, + "image_format": {"title": "图片格式", "desc": "默认生成的图片格式(url 或 base64)。"}, + "video_format": {"title": "视频格式", "desc": "默认生成的视频格式(html 或 url,url 为处理后的链接)。"}, + "temporary": {"title": "临时对话", "desc": "是否默认启用临时对话模式。"}, + "disable_memory": {"title": "禁用记忆", "desc": "是否默认禁用 Grok 记忆功能。"}, + "stream": {"title": "流式响应", "desc": "是否默认启用流式输出。"}, + "thinking": {"title": "思维链", "desc": "是否默认启用思维链输出。"}, + "dynamic_statsig": {"title": "动态指纹", "desc": "是否默认启用动态生成 Statsig 指纹。"}, + "custom_instruction": {"title": "自定义指令", "desc": "多行文本,会透传为 Grok 请求参数 customPersonality。"}, + "filter_tags": {"title": "过滤标签", "desc": "设置自动过滤 Grok 响应中的特殊标签。"} + }, + "proxy": { + "base_proxy_url": {"title": "基础代理 URL", "desc": "代理请求到 Grok 官网的基础服务地址。"}, + "asset_proxy_url": {"title": "资源代理 URL", "desc": "代理请求到 Grok 官网的静态资源(图片/视频)地址。"}, + "skip_proxy_ssl_verify": {"title": "跳过代理 SSL 校验", "desc": "代理使用自签名证书时启用(仅放行代理证书校验)。"}, + "enabled": {"title": "启用 CF 自动刷新", "desc": "启用后将通过 FlareSolverr 自动获取 cf_clearance。"}, + "flaresolverr_url": {"title": "FlareSolverr 地址", "desc": "FlareSolverr 服务的 HTTP 地址(如 http://flaresolverr:8191)。"}, + "refresh_interval": {"title": "刷新间隔(秒)", "desc": "自动刷新 cf_clearance 的时间间隔,建议不低于 300 秒。"}, + "timeout": {"title": "挑战超时(秒)", "desc": "等待 FlareSolverr 解决 CF 挑战的最大时间。"}, + "cf_clearance": {"title": "CF Clearance", "desc": "Cloudflare Clearance Cookie,用于绕过反爬虫验证。启用自动刷新时由系统自动管理。"}, + "browser": {"title": "浏览器指纹", "desc": "curl_cffi 浏览器指纹标识(如 chrome136)。启用自动刷新时由系统自动管理。"}, + "user_agent": {"title": "User-Agent", "desc": "HTTP 请求的 User-Agent 字符串。启用自动刷新时由系统自动管理。"} + }, + "retry": { + "max_retry": {"title": "最大重试次数", "desc": "请求 Grok 服务失败时的最大重试次数。"}, + "retry_status_codes": {"title": "重试状态码", "desc": "触发重试的 HTTP 状态码列表。"}, + "reset_session_status_codes": {"title": "重建状态码", "desc": "触发重建 session 的 HTTP 状态码列表(用于轮换代理)。"}, + "retry_backoff_base": {"title": "退避基数", "desc": "重试退避的基础延迟(秒)。"}, + "retry_backoff_factor": {"title": "退避倍率", "desc": "重试退避的指数放大系数。"}, + "retry_backoff_max": {"title": "退避上限", "desc": "单次重试等待的最大延迟(秒)。"}, + "retry_budget": {"title": "退避预算", "desc": "单次请求的最大重试总耗时(秒)。"} + }, + "chat": { + "concurrent": {"title": "并发上限", "desc": "Reverse 接口并发上限。"}, + "timeout": {"title": "请求超时", "desc": "Reverse 接口超时时间(秒)。"}, + "stream_timeout": {"title": "流空闲超时", "desc": "流式空闲超时时间(秒)。"} + }, + "video": { + "enable_public_asset": {"title": "公开资产链接", "desc": "是否开启生成结束后创建 Public 资产。"}, + "concurrent": {"title": "并发上限", "desc": "Reverse 接口并发上限。"}, + "timeout": {"title": "请求超时", "desc": "Reverse 接口超时时间(秒)。"}, + "stream_timeout": {"title": "流空闲超时", "desc": "流式空闲超时时间(秒)。"}, + "upscale_timing": {"title": "超分时机", "desc": "Basic 号池 720p 超分模式:single 为每轮扩展后超分,complete 为全部扩展完成后再超分。"} + }, + "image": { + "timeout": {"title": "请求超时", "desc": "WebSocket 请求超时时间(秒)。"}, + "stream_timeout": {"title": "流空闲超时", "desc": "WebSocket 流式空闲超时时间(秒)。"}, + "final_timeout": {"title": "最终图超时", "desc": "收到中等图后等待最终图的超时秒数。"}, + "blocked_grace_seconds": {"title": "审查宽限秒数", "desc": "收到中等图后,判定疑似被审查的宽限秒数(默认 10 秒,可自定义)。"}, + "nsfw": {"title": "NSFW 模式", "desc": "WebSocket 请求是否启用 NSFW。"}, + "medium_min_bytes": {"title": "中等图最小字节", "desc": "判定中等质量图的最小字节数。"}, + "final_min_bytes": {"title": "最终图最小字节", "desc": "判定最终图的最小字节数(通常 JPG > 100KB)。"}, + "blocked_parallel_enabled": {"title": "启用并行补偿", "desc": "疑似审查/拦截时,是否启用并行补偿生成。"}, + "blocked_parallel_attempts": {"title": "拦截补偿并发次数", "desc": "疑似审查/拦截导致无最终图时,自动并行补偿生成次数。"} + }, + "imagine_fast": { + "n": {"title": "生成数量", "desc": "仅用于 grok-imagine-1.0-fast 的服务端统一生成数量(1-10)。"}, + "size": {"title": "图片尺寸", "desc": "仅用于 grok-imagine-1.0-fast 的服务端统一尺寸。"}, + "response_format": {"title": "响应格式", "desc": "仅用于 grok-imagine-1.0-fast 的服务端统一返回格式。"} + }, + "asset": { + "upload_concurrent": {"title": "上传并发", "desc": "上传接口的最大并发数。推荐 30。"}, + "upload_timeout": {"title": "上传超时", "desc": "上传接口超时时间(秒)。推荐 60。"}, + "download_concurrent": {"title": "下载并发", "desc": "下载接口的最大并发数。推荐 30。"}, + "download_timeout": {"title": "下载超时", "desc": "下载接口超时时间(秒)。推荐 60。"}, + "list_concurrent": {"title": "查询并发", "desc": "资产查询接口的最大并发数。推荐 10。"}, + "list_timeout": {"title": "查询超时", "desc": "资产查询接口超时时间(秒)。推荐 60。"}, + "list_batch_size": {"title": "查询批次大小", "desc": "单次查询可处理的 Token 数量。推荐 10。"}, + "delete_concurrent": {"title": "删除并发", "desc": "资产删除接口的最大并发数。推荐 10。"}, + "delete_timeout": {"title": "删除超时", "desc": "资产删除接口超时时间(秒)。推荐 60。"}, + "delete_batch_size": {"title": "删除批次大小", "desc": "单次删除可处理的 Token 数量。推荐 10。"} + }, + "voice": { + "timeout": {"title": "请求超时", "desc": "Voice 请求超时时间(秒)。"} + }, + "token": { + "auto_refresh": {"title": "自动刷新", "desc": "是否开启 Token 自动刷新机制。"}, + "refresh_interval_hours": {"title": "刷新间隔", "desc": "普通 Token 刷新的时间间隔(小时)。"}, + "super_refresh_interval_hours": {"title": "Super 刷新间隔", "desc": "Super Token 刷新的时间间隔(小时)。"}, + "fail_threshold": {"title": "失败阈值", "desc": "单个 Token 连续失败多少次后被标记为不可用。"}, + "save_delay_ms": {"title": "保存延迟", "desc": "Token 变更合并写入的延迟(毫秒)。"}, + "usage_flush_interval_sec": {"title": "用量落库间隔", "desc": "用量类字段写入数据库的最小间隔(秒)。"}, + "reload_interval_sec": {"title": "同步间隔", "desc": "多 worker 场景下 Token 状态刷新间隔(秒)。"} + }, + "cache": { + "enable_auto_clean": {"title": "自动清理", "desc": "是否启用缓存自动清理,开启后按上限自动回收。"}, + "limit_mb": {"title": "清理阈值", "desc": "缓存大小阈值(MB),超过阈值会触发清理。"} + }, + "nsfw": { + "concurrent": {"title": "并发上限", "desc": "批量开启 NSFW 模式时的并发请求上限。推荐 10。"}, + "batch_size": {"title": "批次大小", "desc": "批量开启 NSFW 模式的单批处理数量。推荐 50。"}, + "timeout": {"title": "请求超时", "desc": "NSFW 开启相关请求的超时时间(秒)。推荐 60。"} + }, + "usage": { + "concurrent": {"title": "并发上限", "desc": "批量刷新用量时的并发请求上限。推荐 10。"}, + "batch_size": {"title": "批次大小", "desc": "批量刷新用量的单批处理数量。推荐 50。"}, + "timeout": {"title": "请求超时", "desc": "用量查询接口的超时时间(秒)。推荐 60。"} + } + } + }, + "token": { + "pageTitle": "Grok2API - Token 管理", + "title": "Token 列表", + "subtitle": "管理 Grok2API 的 Token 服务号池。", + "import": "导入", + "add": "添加", + "statTotal": "Token 总数", + "statActive": "Token 正常", + "statCooling": "Token 限流", + "statInvalid": "Token 失效", + "statChatQuota": "Chat 剩余", + "statImageQuota": "Image 剩余", + "statVideoQuota": "Video 剩余", + "statVideoUnavailable": "无法统计", + "statTotalCalls": "总调用次数", + "tabAll": "全部", + "tabActive": "正常", + "tabCooling": "限流", + "tabExpired": "失效", + "tabNsfw": "已开 NSFW", + "tabNoNsfw": "未开 NSFW", + "statusFilterAria": "Token 状态筛选", + "tableToken": "Token", + "tableType": "类型", + "tableStatus": "状态", + "tableQuota": "额度", + "tableNote": "备注", + "tableActions": "操作", + "refreshStatus": "刷新状态", + "emptyState": "暂无 Token,请点击右上角导入或添加。", + "emptyFilterState": "当前筛选无结果,请切换筛选条件。", + "selectPage": "全选本页", + "selectAllFiltered": "全选全部", + "clearSelection": "取消全选", + "prevPage": "上一页", + "nextPage": "下一页", + "perPage": "{size} / 页", + "pagination": "第 {current} / {total} 页 · 共 {count} 条", + "batchExport": "导出", + "batchRefresh": "刷新", + "batchDisable": "禁用", + "batchEnable": "启用", + "batchDelete": "删除", + "importTitle": "批量导入 Token", + "importTargetPool": "目标 Pool", + "importTokenList": "Token 列表 (每行一个)", + "importPlaceholder": "粘贴 Token,一行一个...", + "startImport": "开始导入", + "editTitle": "编辑 Token", + "addTitle": "添加 Token", + "editType": "类型", + "editQuota": "额度", + "editNote": "备注", + "editNotePlaceholder": "可选备注", + "tokenEmpty": "Token 不能为空", + "tokenExists": "Token 已存在", + "confirmDelete": "确定要删除此 Token 吗?", + "confirmDisable": "确定要禁用该 Token 吗?\n{token}", + "confirmEnable": "确定要启用该 Token 吗?\n{token}", + "confirmBatchDelete": "确定要删除选中的 {count} 个 Token 吗?", + "confirmBatchDisable": "确定要禁用选中的 {count} 个 Token 吗?", + "confirmBatchEnable": "确定要启用选中的 {count} 个 Token 吗?", + "disableToken": "禁用", + "enableToken": "启用", + "disableDone": "Token 已禁用", + "enableDone": "Token 已启用", + "batchDisableDone": "批量禁用完成", + "batchEnableDone": "批量启用完成", + "noTokenToDisable": "选中的 Token 已全部是禁用状态", + "noTokenToEnable": "选中的 Token 已全部是启用状态", + "deleteDone": "删除完成", + "refreshDone": "刷新完成", + "refreshSuccess": "刷新成功", + "refreshFailed": "刷新失败", + "refreshError": "刷新失败: {msg}", + "requestError": "请求错误", + "notValidJson": "响应不是有效 JSON (HTTP {status})", + "listEmpty": "列表为空", + "stopRefresh": "已终止刷新", + "stopDelete": "已终止删除", + "stopNsfw": "已终止 NSFW", + "nsfwConfirm": "是否为选中的 {count} 个 Token 开启 NSFW 模式?", + "nsfwEnable": "开启 NSFW", + "nsfwDone": "NSFW 开启完成", + "nsfwResult": "NSFW 开启完成:成功 {ok},失败 {fail}", + "nsfwFailed": "开启失败: {msg}", + "emptyResponse": "空响应 (HTTP {status})" + }, + "cache": { + "pageTitle": "Grok2API - 缓存管理", + "title": "缓存管理", + "subtitle": "管理本地资源与在线资产缓存。", + "localImages": "本地图片", + "localVideos": "本地视频", + "onlineAssets": "在线资产", + "tableFile": "文件", + "tableSize": "大小", + "tableTime": "时间", + "tableActions": "操作", + "tableToken": "Token", + "tableType": "类型", + "tableAssetCount": "资产数", + "tableLastClear": "上次清空时间", + "selectPage": "全选本页", + "selectAllFiles": "全选全部", + "clearSelection": "取消全选", + "prevPage": "上一页", + "nextPage": "下一页", + "perPageLabel": "每页", + "perPage": "{size} / 页", + "pagination": "第 {current} / {total} 页 · 共 {count} 条", + "notLoaded": "未加载", + "noAccounts": "暂无可用账号", + "noFiles": "暂无文件", + "noTokenAvailable": "无可用 Token", + "cannotConnect": "无法连接", + "lastClear": "上次清空:{time}", + "clear": "清空", + "clean": "清理", + "confirmClearImage": "确定要清空本地图片缓存吗?", + "confirmClearVideo": "确定要清空本地视频缓存吗?", + "clearSuccess": "清理成功,释放 {size} MB", + "clearFailed": "清理失败", + "confirmDeleteFile": "确定要删除该文件吗?", + "confirmBatchDeleteFiles": "确定要删除选中的 {count} 个文件吗?", + "noFilesSelected": "未选择文件", + "deletedFiles": "已删除 {count} 个文件", + "deleteResult": "删除完成:成功 {success},失败 {failed}", + "loadStatsFailed": "加载统计失败", + "selectAccountsToLoad": "请选择要加载的账号", + "selectAccountsToClear": "请选择要清空的账号", + "confirmClearAccounts": "确定要清空选中的 {count} 个账号在线资产吗?", + "confirmClearAccount": "确定要清空账号 {label} 的在线资产吗?", + "batchCleanInProgress": "正在批量清理在线资产,请稍候...", + "cleanInProgress": "正在清理在线资产,请稍候...", + "batchCleanDone": "批量清理完成", + "cleanResult": "清理完成 (成功: {success}, 失败: {failed})", + "cleanFailedEntry": "清理失败", + "requestTimeout": "请求超时或失败", + "loadDone": "加载完成", + "loadingStatus": "加载中", + "loadStopped": "已终止加载", + "loadFailedMsg": "加载失败: {msg}", + "stoppedLoadRequests": "已终止剩余加载请求", + "cleanStopped": "已终止清理", + "cleanFailedMsg": "清理失败: {msg}", + "stoppedCleanRequests": "已终止剩余清理请求" + }, + "chat": { + "pageTitle": "Grok2API - Chat 聊天", + "title": "Chat 聊天", + "subtitle": "通过 chat 接口与 Grok 进行会话聊天", + "sessions": "会话", + "newChat": "新增", + "collapseSidebar": "折叠", + "expandSidebar": "展开", + "sessionList": "会话列表", + "newSession": "新会话", + "ready": "就绪", + "emptyState": "输入一条消息开始对话。", + "uploadFile": "上传文件", + "placeholder": "询问任何内容", + "settings": "设置", + "send": "发送", + "temperature": "温度", + "topP": "Top P", + "systemPrompt": "System Prompt", + "systemPromptPlaceholder": "可选,设置系统提示词", + "fileLabel": "[文件]", + "compositeContent": "[复合内容]", + "storageFull": "本地存储空间不足,部分会话未保存", + "attachmentNoEdit": "附件消息暂不支持编辑", + "saveEdit": "保存", + "saveEditTitle": "保存编辑", + "cancelEdit": "取消", + "cancelEditTitle": "取消编辑", + "contentEmpty": "内容不能为空", + "editMessage": "编辑", + "editMessageTitle": "编辑消息内容", + "regenerate": "重新生成", + "regenerateTitle": "从此处重新生成回复", + "retryTitle": "重试上一条回答", + "editAnswer": "编辑", + "editAnswerTitle": "编辑回答内容", + "copyAnswer": "复制", + "copyAnswerTitle": "复制回答内容", + "feedback": "反馈", + "feedbackTitle": "反馈到 Grok2API", + "noContentToCopy": "暂无内容可复制", + "noChatToRetry": "没有可重试的对话", + "requestFailedCheck": "请求失败,请检查服务状态", + "requestFailedStatus": "请求失败: {status}", + "clickRetry": "点击重试", + "thinking": "思考中", + "thinkingSec": "思考 {sec} 秒", + "thought": "已思考", + "thinkLabel": "思考", + "empty": "(空)", + "toolWebSearch": "网页搜索", + "toolImageSearch": "图片搜索", + "toolAgentThink": "思考推理", + "toolDefault": "工具" + }, + "imagine": { + "pageTitle": "Grok2API - Imagine 瀑布流", + "title": "Imagine 瀑布流", + "subtitle": "通过 WebSocket 持续生成图片,实时展示 base64 瀑布流。", + "genSettings": "生成设置", + "prompt": "提示词", + "promptPlaceholder": "描述你想要的画面,例如:未来城市霓虹雨夜,广角摄影", + "autoFollow": "自动跟随", + "autoFollowDesc": "滚动到最新", + "autoSave": "自动保存", + "autoSaveDesc": "自动保存图片", + "aspectRatio": "宽高比", + "ratio2_3": "2:3 竖图", + "ratio1_1": "1:1 方图", + "ratio3_2": "3:2 横图", + "ratio16_9": "16:9 宽屏", + "ratio9_16": "9:16 竖屏", + "concurrent": "并发数量", + "tasks": "{n} 个任务", + "concurrentTask1": "1 个任务", + "concurrentTask2": "2 个任务", + "concurrentTask3": "3 个任务", + "autoFilter": "自动过滤", + "autoFilterDesc": "过滤不达标图片", + "nsfwLabel": "是否 NSFW", + "nsfwOn": "开启", + "nsfwOff": "关闭", + "saveLocation": "保存位置", + "browserDefault": "浏览器默认位置", + "reverseOrder": "反向新增", + "reverseOrderDesc": "最新显示在上方", + "connectionStatus": "连接状态", + "connectionMode": "连接模式", + "connectionModeAria": "连接模式", + "imageCount": "图片数量", + "activeTasks": "活跃任务", + "avgTime": "平均耗时", + "gallery": "瀑布流", + "waitingTitle": "等待连接中", + "waitingSubtitle": "启动任务后,生成的图片会自动汇聚到这里", + "start": "开始", + "batch": "批量", + "batchDownloadTitle": "批量下载", + "selectAll": "全选", + "deselectAll": "取消全选", + "download": "下载", + "startedTasks": "已启动 {count} 个并发任务", + "startedTasksSSE": "已启动 {count} 个并发任务 (SSE)", + "generatingSSE": "生成中 (SSE)", + "selectFolder": "已选择文件夹: {name}", + "selectFolderFailed": "选择文件夹失败", + "noImagesSelected": "请先选择要下载的图片", + "jszipFailed": "JSZip 库加载失败,请刷新页面重试", + "packing": "正在打包 {count} 张图片...", + "packingBtn": "打包中...", + "packingProgress": "打包中... ({done}/{total})", + "noImagesDownloaded": "没有成功获取任何图片", + "generatingZip": "生成压缩包...", + "packSuccess": "成功打包 {count} 张图片", + "packFailed": "打包失败,请重试" + }, + "video": { + "pageTitle": "Grok2API - Video 视频生成", + "title": "Video 视频生成", + "subtitle": "生成短视频,支持参考图与多种预设风格。", + "startGenerate": "开始生成", + "genSettings": "生成设置", + "prompt": "提示词", + "promptPlaceholder": "例如:街头霓虹雨夜,慢镜头,胶片质感", + "referenceImage": "参考图", + "referenceImagePlaceholder": "https://... 或 data:image/...", + "aspectRatio": "画面比例", + "ratio3_2": "3:2 横构图", + "ratio2_3": "2:3 竖构图", + "ratio16_9": "16:9 宽屏", + "ratio9_16": "9:16 竖屏", + "ratio1_1": "1:1 方形", + "duration": "视频时长", + "seconds": "{n} 秒", + "resolution": "分辨率", + "stylePreset": "风格预设", + "upload": "上传", + "clearImage": "清除", + "runStatus": "运行状态", + "elapsedTime": "耗时 {sec}s", + "elapsedTimeNone": "耗时 -", + "metaRatio": "比例", + "metaDuration": "时长", + "metaResolution": "分辨率", + "metaPreset": "预设", + "videoPreview": "视频预览", + "clearPreview": "清空", + "waitingGenerate": "等待生成视频", + "videoTitle": "视频 {n}", + "open": "打开", + "generatingPlaceholder": "生成中…", + "superResolution": "超分辨率", + "superResolutionInProgress": "超分辨率中", + "alreadyGenerating": "已在生成中", + "referenceConflict": "参考图只能选择其一:URL/Base64 或 本地上传", + "downloadFailed": "下载失败,请检查视频链接是否可访问", + "sec6": "6 秒", + "sec10": "10 秒", + "sec15": "15 秒" + }, + "voice": { + "pageTitle": "Grok2API - LiveKit 陪聊", + "title": "LiveKit 陪聊", + "subtitle": "LiveKit 语音会话,连接 Grok Voice。", + "startSession": "开始会话", + "stopSession": "停止会话", + "connectionSettings": "连接设置", + "voiceLabel": "声音 (Voice)", + "personalityLabel": "个性 (Personality)", + "speedLabel": "语速", + "settingsHint": "提示:Voice / Personality 会在建立连接前发送到服务端。", + "sessionLog": "会话日志", + "clearLog": "清空", + "copyLog": "复制", + "sessionStatus": "会话状态", + "audioOutput": "音频输出", + "audioHint": "订阅到的音轨会自动插入此区域。", + "livekitSDKError": "错误: LiveKit SDK 未能正确加载,请刷新页面重试", + "livekitLoadFailed": "LiveKit SDK 加载失败", + "secureContextBrowser": "请使用最新版浏览器并允许麦克风权限", + "secureContextHTTPS": "请使用 HTTPS 或在本机 localhost 访问", + "secureContextError": "当前环境不支持麦克风权限,{hint}", + "connectingStatus": "正在连接", + "fetchingToken": "正在获取 Token...", + "fetchTokenFailed": "获取 Token 失败: {status}", + "fetchTokenSuccess": "获取 Token 成功", + "participantConnected": "参与者已连接: {identity}", + "participantDisconnected": "参与者已断开: {identity}", + "trackSubscribed": "订阅音轨: {kind}", + "disconnected": "已断开连接", + "connectedToServer": "已连接到 LiveKit 服务器", + "inCall": "通话中", + "openingMic": "正在开启麦克风...", + "voiceEnabled": "语音已开启", + "voiceConnected": "语音连接成功", + "errorPrefix": "错误: {msg}", + "logCopied": "日志已复制", + "copyLogFailed": "复制失败,请手动选择" + } +} diff --git a/app/static/public/css/chat.css b/app/static/public/css/chat.css index 5378783..1fefc32 100644 --- a/app/static/public/css/chat.css +++ b/app/static/public/css/chat.css @@ -207,12 +207,52 @@ body { max-width: min(640px, calc(100vw - 48px)); max-height: 320px; overflow: auto; - padding: 8px; + padding: 0; border-radius: 14px; border: 1px solid var(--chat-border); background: var(--chat-surface); box-shadow: 0 18px 32px rgba(17, 17, 17, 0.14); z-index: 20; + isolation: isolate; +} + +.sources-panel-header { + position: sticky; + top: 0; + z-index: 4; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 12px 10px; + margin: 0; + background: #121a26; + border-bottom: 1px solid var(--chat-border); + box-shadow: 0 1px 0 rgba(0, 0, 0, 0.18); +} + +.sources-panel-heading { + padding: 0; + margin: 0; +} + +.sources-panel-close { + width: 32px; + height: 32px; + border: 1px solid var(--chat-border); + border-radius: 999px; + background: var(--chat-muted); + color: var(--chat-ink); + font-size: 20px; + line-height: 1; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.sources-panel-close:hover { + background: rgba(255, 255, 255, 0.1); } .sources-widget:not([open]) .sources-panel { @@ -222,21 +262,30 @@ body { .sources-section-title { font-size: 11px; color: var(--accents-4); - padding: 6px 8px 4px; + padding: 10px 12px 6px; +} + +.sources-section-anchor { + height: 1px; + margin: 0; + opacity: 0; + pointer-events: none; } .source-row { display: flex; - align-items: center; + align-items: flex-start; gap: 10px; - padding: 8px; - border-radius: 10px; + padding: 12px 12px 10px; + border-radius: 14px; color: inherit; text-decoration: none; + border: 1px solid transparent; } .source-row:hover { background: var(--chat-muted); + border-color: var(--chat-border); } .source-row.is-query { @@ -249,22 +298,36 @@ body { .source-row-text { min-width: 0; + width: 100%; display: flex; flex-direction: column; - gap: 2px; + gap: 6px; } .source-row-meta { font-size: 11px; color: var(--accents-4); + order: 3; } .source-row-title { - font-size: 13px; + font-size: 16px; + line-height: 1.35; + font-weight: 700; color: var(--chat-ink); - white-space: nowrap; + word-break: break-word; +} + +.source-row-preview { + font-size: 14px; + line-height: 1.5; + color: var(--chat-ink); + opacity: 0.88; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; overflow: hidden; - text-overflow: ellipsis; + word-break: break-word; } .message-bubble { @@ -413,11 +476,17 @@ body { .message-content.rendered .inline-citation-chip__count { display: inline-flex; align-items: center; + margin-left: 4px; color: color-mix(in srgb, var(--chat-ink) 38%, transparent); font-size: 11px; line-height: 1; } +.message-content.rendered .inline-citation-chip__count::before { + content: " "; + white-space: pre; +} + .message-content.rendered .print-hidden { display: inline; } @@ -602,6 +671,25 @@ body { box-shadow: 0 2px 8px rgba(17, 17, 17, 0.15); } +@keyframes thinkSpinSlow { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.think-block[data-thinking="true"] .think-summary::before { + animation: thinkSpinSlow 5.5s linear infinite; + animation-delay: var(--think-spin-delay, 0s); +} + +.think-block[data-thinking="true"] .think-agent summary::before { + animation: thinkSpinSlow 5.5s linear infinite; + animation-delay: var(--think-spin-delay, 0s); +} + .think-summary::after { content: ""; width: 6px; @@ -646,19 +734,25 @@ body { } .img-grid { - display: grid; - gap: 2px; + display: flex; + gap: 12px; margin: 18px 0 20px; - grid-template-columns: repeat(var(--cols, 4), minmax(0, 1fr)); width: 100%; max-width: 100%; - justify-items: stretch; + overflow-x: auto; + overflow-y: hidden; + overscroll-behavior-x: contain; + scroll-snap-type: x proximity; + -webkit-overflow-scrolling: touch; + padding-bottom: 6px; } .img-grid > * { display: block; - width: 100%; + width: auto; + flex: 0 0 clamp(160px, 22vw, 260px); margin: 0; + scroll-snap-align: start; } .message-content.rendered .img-grid .message-image-card { @@ -669,7 +763,7 @@ body { .message-content.rendered .img-grid img { width: 100%; - height: 100%; + height: auto; display: block; max-width: 100%; max-height: none; @@ -682,7 +776,7 @@ body { } .message-content.rendered .img-grid .message-image-caption { - padding: 0 4px; + display: none; } .message-content.rendered .img-grid .message-image-card { @@ -690,6 +784,15 @@ body { border-radius: 12px; } +.img-grid::-webkit-scrollbar { + height: 8px; +} + +.img-grid::-webkit-scrollbar-thumb { + background: var(--chat-border); + border-radius: 999px; +} + .img-retry { width: 100%; min-height: 120px; @@ -975,6 +1078,19 @@ html[data-theme='dark'] .think-block[data-thinking="true"] .think-agent summary margin-top: auto; } +#app-footer .app-footer { + position: static; + left: auto; + right: auto; + bottom: auto; + display: block; + margin-top: 10px; + padding-bottom: 4px; + text-align: center; + z-index: auto; + pointer-events: auto; +} + .composer-meta { display: flex; align-items: center; @@ -1373,15 +1489,38 @@ html[data-theme='dark'] .model-option.active { @media (max-width: 720px) { .sources-panel { - left: auto; - right: 0; - min-width: min(340px, calc(100vw - 32px)); + position: fixed; + left: 12px; + right: 12px; + bottom: 132px; + width: auto; + min-width: 0; + max-width: none; + max-height: min(52vh, 420px); + } + + .sources-panel-header { + padding: 12px 12px 10px; + background: #121a26; } .message-bubble { max-width: 100%; } + .source-row { + padding: 10px; + } + + .source-row-title { + font-size: 15px; + } + + .source-row-preview { + font-size: 13px; + -webkit-line-clamp: 4; + } + .composer-meta { order: 1; align-items: center; diff --git a/app/static/public/css/imagine_workbench.css b/app/static/public/css/imagine_workbench.css index a67174f..7146f87 100644 --- a/app/static/public/css/imagine_workbench.css +++ b/app/static/public/css/imagine_workbench.css @@ -79,11 +79,25 @@ body { margin-bottom: 12px; } +.panel-head-actions { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + .head-meta { font-size: 12px; color: var(--workbench-muted); } +.panel-toggle-btn { + min-height: 32px; + padding: 0 12px; + font-size: 12px; + white-space: nowrap; +} + .upload-row { display: flex; align-items: center; diff --git a/app/static/public/css/video.css b/app/static/public/css/video.css index 2f8634c..559679c 100644 --- a/app/static/public/css/video.css +++ b/app/static/public/css/video.css @@ -377,6 +377,20 @@ body > .video-settings-card > .card-title { margin-bottom: 12px; } +.panel-head-actions { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.panel-toggle-btn { + min-height: 32px; + padding: 0 12px; + font-size: 12px; + white-space: nowrap; +} + .history-panel .video-stage { min-height: 220px; max-height: 980px; @@ -385,6 +399,10 @@ body > .video-settings-card > .card-title { border-color: var(--video-outline); } +.history-panel .video-stage.is-reversed { + flex-direction: column-reverse; +} + .history-panel .video-item { background: #fff; border: 1px solid var(--video-outline); diff --git a/app/static/public/js/imagine_workbench.js b/app/static/public/js/imagine_workbench.js index 4ab8970..0afb636 100644 --- a/app/static/public/js/imagine_workbench.js +++ b/app/static/public/js/imagine_workbench.js @@ -20,6 +20,7 @@ const historyCount = document.getElementById('historyCount'); const historyEmpty = document.getElementById('historyEmpty'); const historyList = document.getElementById('historyList'); + const historyReverseBtn = document.getElementById('historyReverseBtn'); const editProgressWrap = document.getElementById('editProgressWrap'); const editProgressBar = document.getElementById('editProgressBar'); const editProgressText = document.getElementById('editProgressText'); @@ -35,6 +36,7 @@ currentModeValue: 'upload', history: [], editRound: 0, + historyReversed: false, }; let workbenchEditAbortController = null; let editProgressTimer = null; @@ -1168,6 +1170,10 @@ if (!historyList || !historyEmpty || !historyCount) return; historyList.innerHTML = ''; historyCount.textContent = `${state.history.length} 条`; + if (historyReverseBtn) { + historyReverseBtn.textContent = state.historyReversed ? '正序显示' : '倒序显示'; + historyReverseBtn.setAttribute('aria-pressed', state.historyReversed ? 'true' : 'false'); + } if (!state.history.length) { historyEmpty.classList.remove('hidden'); @@ -1175,7 +1181,8 @@ } historyEmpty.classList.add('hidden'); - state.history.forEach((entry) => { + const entries = state.historyReversed ? [...state.history].reverse() : state.history; + entries.forEach((entry) => { const item = document.createElement('div'); item.className = 'history-item'; @@ -1249,6 +1256,13 @@ }); } + if (historyReverseBtn) { + historyReverseBtn.addEventListener('click', () => { + state.historyReversed = !state.historyReversed; + renderHistory(); + }); + } + function resetCycle(keepSeedPreview = true) { state.currentParentPostId = ''; state.currentSourceImageUrl = ''; diff --git a/app/static/public/js/video.js b/app/static/public/js/video.js index 473202d..88788b1 100644 --- a/app/static/public/js/video.js +++ b/app/static/public/js/video.js @@ -65,6 +65,7 @@ const referenceLightboxImg = document.getElementById('referenceLightboxImg'); const closeReferenceLightboxBtn = document.getElementById('closeReferenceLightboxBtn'); const historyCount = document.getElementById('historyCount'); + const videoHistoryReverseBtn = document.getElementById('videoHistoryReverseBtn'); const editPreviewWrap = editVideo ? editVideo.closest('.edit-preview-wrap') : null; let taskStates = new Map(); @@ -132,6 +133,7 @@ let workspaceLockedHeight = 0; let editVideoNameTapTimer = 0; let editVideoNameTapCount = 0; + let videoHistoryReversed = false; function buildHistoryTitle(type, serial) { const n = Math.max(1, parseInt(String(serial || '1'), 10) || 1); @@ -622,6 +624,15 @@ if (!historyCount || !videoStage) return; const count = videoStage.querySelectorAll('.video-item').length; historyCount.textContent = String(count); + if (videoHistoryReverseBtn) { + videoHistoryReverseBtn.textContent = videoHistoryReversed ? '正序显示' : '倒序显示'; + videoHistoryReverseBtn.setAttribute('aria-pressed', videoHistoryReversed ? 'true' : 'false'); + } + } + + function syncVideoHistoryOrder() { + if (!videoStage) return; + videoStage.classList.toggle('is-reversed', videoHistoryReversed); } function removePreviewItem(item) { @@ -1569,6 +1580,7 @@ if (videoEmpty) { videoEmpty.classList.add('hidden'); } + syncVideoHistoryOrder(); updateHistoryCount(); return item; } @@ -3336,6 +3348,14 @@ }); } + if (videoHistoryReverseBtn) { + videoHistoryReverseBtn.addEventListener('click', () => { + videoHistoryReversed = !videoHistoryReversed; + syncVideoHistoryOrder(); + updateHistoryCount(); + }); + } + if (imageFileInput) { imageFileInput.addEventListener('change', async () => { const files = imageFileInput.files ? Array.from(imageFileInput.files) : []; @@ -3654,6 +3674,7 @@ }); updateMeta(); + syncVideoHistoryOrder(); updateHistoryCount(); refreshAllDeleteZoneTracks(); syncTimelineAvailability(); diff --git a/app/static/public/pages/imagine_workbench.html b/app/static/public/pages/imagine_workbench.html index 96cf9d7..cbd462b 100644 --- a/app/static/public/pages/imagine_workbench.html +++ b/app/static/public/pages/imagine_workbench.html @@ -103,7 +103,10 @@ <aside class="panel"> <div class="panel-head"> <div class="panel-title">编辑历史</div> - <div id="historyCount" class="head-meta">0 条</div> + <div class="panel-head-actions"> + <button id="historyReverseBtn" type="button" class="geist-button-outline panel-toggle-btn">倒序显示</button> + <div id="historyCount" class="head-meta">0 条</div> + </div> </div> <div id="historyEmpty" class="empty-state">执行编辑后会在这里记录历史版本</div> <div id="historyList" class="history-list"></div> @@ -173,7 +176,7 @@ <script src="/static/common/js/toast.js"></script> <script src="/static/common/js/prompt-enhancer.js?v=0.1.15"></script> <script src="/static/common/js/pwa.js?v=0.1.1"></script> - <script src="/static/public/js/imagine_workbench.js?v=0.2.1"></script> + <script src="/static/public/js/imagine_workbench.js?v=0.2.2"></script> </body> </html> diff --git a/app/static/public/pages/video.html b/app/static/public/pages/video.html index c49b3d7..208c285 100644 --- a/app/static/public/pages/video.html +++ b/app/static/public/pages/video.html @@ -284,6 +284,9 @@ <aside class="video-card history-panel"> <div class="panel-head"> <div class="card-title">历史视频</div> + <div class="panel-head-actions"> + <button id="videoHistoryReverseBtn" type="button" class="geist-button-outline panel-toggle-btn">倒序显示</button> + </div> </div> <div id="videoEmpty" class="video-empty">生成视频与延长视频都会在这里保存,点击可替换到工作区。</div> <div id="videoStage" class="video-stage hidden"></div> @@ -369,7 +372,7 @@ <script src="/static/common/js/toast.js"></script> <script src="/static/common/js/prompt-enhancer.js?v=0.1.15"></script> <script src="/static/common/js/pwa.js?v=0.1.1"></script> - <script src="/static/public/js/video.js?v=0.6.5"></script> + <script src="/static/public/js/video.js?v=0.6.6"></script> </body> </html>