Add live-streaming output to Character Sheets generation (v1.14.0)
Character Sheets generation only ever showed a progress bar - no visible reading/thinking/filling-out, unlike the casting flow which already streams the LLM's output live. Refactored /api/character-sheets into shared _charsheets_prepare/_charsheets_parse helpers (same split used for attribution) and added /api/character-sheets/stream, proxying the LLM's SSE stream through the same shared lock used by the other attribution endpoints. Client: new csGenerateStream (mirrors audiobookAttributeStream) tries the streaming endpoint first per passage, updating a new "Live output" panel in the progress dialog with the raw JSON answer as it's written - itself the "watch it fill out the sheet" experience, since there's no separate reasoning channel worth hiding it behind here. Falls back to the blocking endpoint on any stream failure. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
f2bdd98a4f
commit
960389ef5b
@ -9,6 +9,13 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [1.14.0] — 2026-07-06
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Watch Character Sheets fill out live** — the "Character sheets" progress dialog now has a "Live output" pane that streams the LLM's raw answer as it's written for the passage currently being processed, the same live-streaming approach already used for casting. Backed by a new `/api/character-sheets/stream` endpoint (sharing prompt-building and parsing with the existing blocking one), with automatic fallback to the blocking endpoint if the model/backend doesn't support streaming.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [1.13.10] — 2026-07-06
|
## [1.13.10] — 2026-07-06
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@ -536,19 +536,10 @@ def _extract_json_block(text: str) -> str:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/character-sheets")
|
def _charsheets_prepare(data: dict) -> dict:
|
||||||
async def character_sheets(request: Request):
|
"""Resolve settings and build the chat payload for one character-sheets
|
||||||
"""Extract actor-facing RPG-style character sheets from a passage.
|
extraction request. Shared by the blocking endpoint and the streaming
|
||||||
|
(watch-it-fill-out) endpoint so the prompt can never drift between them."""
|
||||||
Body: {text, known_characters:[...], language, llm_url, model}
|
|
||||||
The text may contain "[p.N]" page markers so the model can cite sources.
|
|
||||||
Returns: {sheets:[{name, aliases, first_name, last_name, full_name, title, archetype, physical, alignment,
|
|
||||||
attribute_high, attribute_low, skills, inventory:[...], secret,
|
|
||||||
conflict_style, win_condition, tier:"main"|"supporting",
|
|
||||||
sources:[{page, quote}]}], characters:[names]}
|
|
||||||
Deduced (not explicit) values are marked with a trailing " *".
|
|
||||||
"""
|
|
||||||
data = await request.json()
|
|
||||||
text: str = (data.get("text") or "").strip()
|
text: str = (data.get("text") or "").strip()
|
||||||
known: list = data.get("known_characters") or []
|
known: list = data.get("known_characters") or []
|
||||||
target_mode: bool = bool(data.get("target_mode"))
|
target_mode: bool = bool(data.get("target_mode"))
|
||||||
@ -658,18 +649,15 @@ async def character_sheets(request: Request):
|
|||||||
}
|
}
|
||||||
if model:
|
if model:
|
||||||
payload["model"] = model
|
payload["model"] = model
|
||||||
try:
|
return {
|
||||||
resp = await asyncio.to_thread(
|
"payload": payload, "llm_url": llm_url, "model": model,
|
||||||
_post_llm_chat_completion,
|
"api_key": _settings.get("llm_api_key") or "sk-dummy-key",
|
||||||
llm_url, payload,
|
"timeout_seconds": timeout_seconds,
|
||||||
{"Authorization": f"Bearer {_settings.get('llm_api_key') or 'sk-dummy-key'}"}, timeout_seconds,
|
}
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
_msg = resp.json()["choices"][0]["message"]
|
|
||||||
raw = (_msg.get("content") or _msg.get("reasoning_content") or "").strip()
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(502, f"LLM character-sheet generation failed: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
|
def _charsheets_parse(raw: str) -> dict:
|
||||||
|
"""Turn the LLM's raw answer into normalised {sheets, characters}."""
|
||||||
content = re.sub(r"<think>.*?</think>", "", raw, flags=re.DOTALL).strip() or raw
|
content = re.sub(r"<think>.*?</think>", "", raw, flags=re.DOTALL).strip() or raw
|
||||||
sheets = []
|
sheets = []
|
||||||
for cand in (content, _extract_json_block(content)):
|
for cand in (content, _extract_json_block(content)):
|
||||||
@ -744,6 +732,123 @@ async def character_sheets(request: Request):
|
|||||||
return {"sheets": clean, "characters": names}
|
return {"sheets": clean, "characters": names}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/character-sheets")
|
||||||
|
async def character_sheets(request: Request):
|
||||||
|
"""Extract actor-facing RPG-style character sheets from a passage.
|
||||||
|
|
||||||
|
Body: {text, known_characters:[...], language, llm_url, model}
|
||||||
|
The text may contain "[p.N]" page markers so the model can cite sources.
|
||||||
|
Returns: {sheets:[{name, aliases, first_name, last_name, full_name, title, archetype, physical, alignment,
|
||||||
|
attribute_high, attribute_low, skills, inventory:[...], secret,
|
||||||
|
conflict_style, win_condition, tier:"main"|"supporting",
|
||||||
|
sources:[{page, quote}]}], characters:[names]}
|
||||||
|
Deduced (not explicit) values are marked with a trailing " *".
|
||||||
|
"""
|
||||||
|
data = await request.json()
|
||||||
|
prep = _charsheets_prepare(data)
|
||||||
|
try:
|
||||||
|
if not await asyncio.to_thread(_attribution_llm_lock.acquire, True, prep["timeout_seconds"]):
|
||||||
|
raise HTTPException(503, "Attribution engine busy")
|
||||||
|
try:
|
||||||
|
resp = await asyncio.to_thread(
|
||||||
|
_post_llm_chat_completion,
|
||||||
|
prep["llm_url"], prep["payload"],
|
||||||
|
{"Authorization": f"Bearer {prep['api_key']}"}, prep["timeout_seconds"],
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
_attribution_llm_lock.release()
|
||||||
|
resp.raise_for_status()
|
||||||
|
_msg = resp.json()["choices"][0]["message"]
|
||||||
|
raw = (_msg.get("content") or _msg.get("reasoning_content") or "").strip()
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(502, f"LLM character-sheet generation failed: {e}")
|
||||||
|
return _charsheets_parse(raw)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/character-sheets/stream")
|
||||||
|
async def character_sheets_stream(request: Request):
|
||||||
|
"""Same job as /api/character-sheets, but streams the LLM's raw output
|
||||||
|
(its JSON answer being written field by field) as SSE `{"t": "..."}`
|
||||||
|
events, ending with `{"done": true, "result": {...}}` — lets the UI show
|
||||||
|
the sheet actually filling out passage by passage instead of just a
|
||||||
|
progress bar. On upstream failure it emits `{"error": "..."}` and the
|
||||||
|
client falls back to the blocking endpoint."""
|
||||||
|
data = await request.json()
|
||||||
|
prep = _charsheets_prepare(data)
|
||||||
|
payload = dict(prep["payload"])
|
||||||
|
payload["stream"] = True
|
||||||
|
headers = {"Authorization": f"Bearer {prep['api_key']}"}
|
||||||
|
|
||||||
|
def gen():
|
||||||
|
if not _attribution_llm_lock.acquire(timeout=prep["timeout_seconds"]):
|
||||||
|
yield 'data: {"error": "Attribution engine busy"}\n\n'
|
||||||
|
return
|
||||||
|
upstream = None
|
||||||
|
try:
|
||||||
|
urls = _llm_chat_completion_urls(prep["llm_url"])
|
||||||
|
last_err = None
|
||||||
|
for u in urls:
|
||||||
|
try:
|
||||||
|
upstream = requests.post(
|
||||||
|
u, json=payload, headers=headers,
|
||||||
|
timeout=(15, prep["timeout_seconds"]), stream=True,
|
||||||
|
)
|
||||||
|
if upstream.status_code in (404, 405) and u != urls[-1]:
|
||||||
|
upstream.close()
|
||||||
|
upstream = None
|
||||||
|
continue
|
||||||
|
break
|
||||||
|
except Exception as exc:
|
||||||
|
last_err = exc
|
||||||
|
upstream = None
|
||||||
|
if upstream is None:
|
||||||
|
print(f"[character-sheets/stream] LLM connect failed: {last_err}")
|
||||||
|
yield f'data: {json.dumps({"error": str(last_err or "LLM connect failed")})}\n\n'
|
||||||
|
return
|
||||||
|
if upstream.status_code >= 400:
|
||||||
|
print(
|
||||||
|
f"[character-sheets/stream] LLM failed ({upstream.status_code}) for model "
|
||||||
|
f"{payload.get('model') or '(default)'}: {_response_error_text(upstream)[:300]}"
|
||||||
|
)
|
||||||
|
yield f'data: {json.dumps({"error": f"HTTP {upstream.status_code}: {_response_error_text(upstream)[:300]}"})}\n\n'
|
||||||
|
return
|
||||||
|
raw_parts = []
|
||||||
|
upstream.encoding = "utf-8"
|
||||||
|
for line in upstream.iter_lines(decode_unicode=True):
|
||||||
|
if not line or not line.startswith("data:"):
|
||||||
|
continue
|
||||||
|
chunk = line[5:].strip()
|
||||||
|
if chunk == "[DONE]":
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
delta = json.loads(chunk)["choices"][0]["delta"]
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
t = delta.get("reasoning_content") or delta.get("content") or ""
|
||||||
|
if delta.get("content"):
|
||||||
|
raw_parts.append(delta["content"])
|
||||||
|
if t:
|
||||||
|
yield f'data: {json.dumps({"t": t})}\n\n'
|
||||||
|
result = _charsheets_parse("".join(raw_parts))
|
||||||
|
yield f'data: {json.dumps({"done": True, "result": result})}\n\n'
|
||||||
|
except GeneratorExit:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
yield f'data: {json.dumps({"error": str(e)})}\n\n'
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
if upstream is not None:
|
||||||
|
upstream.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
_attribution_llm_lock.release()
|
||||||
|
|
||||||
|
return StreamingResponse(gen(), media_type="text/event-stream",
|
||||||
|
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/character-deep-analysis")
|
@router.post("/api/character-deep-analysis")
|
||||||
async def character_deep_analysis(request: Request):
|
async def character_deep_analysis(request: Request):
|
||||||
"""Run a deep 5-area psychological analysis of a single character.
|
"""Run a deep 5-area psychological analysis of a single character.
|
||||||
|
|||||||
@ -10,7 +10,7 @@
|
|||||||
<meta name="format-detection" content="telephone=no">
|
<meta name="format-detection" content="telephone=no">
|
||||||
<meta name="color-scheme" content="light dark">
|
<meta name="color-scheme" content="light dark">
|
||||||
<meta name="theme-color" content="#2563EB">
|
<meta name="theme-color" content="#2563EB">
|
||||||
<meta name="app-version" content="1.13.10">
|
<meta name="app-version" content="1.14.0">
|
||||||
<link rel="manifest" href="/manifest.webmanifest">
|
<link rel="manifest" href="/manifest.webmanifest">
|
||||||
<link rel="icon" href="/static/icon.svg" type="image/svg+xml">
|
<link rel="icon" href="/static/icon.svg" type="image/svg+xml">
|
||||||
<link rel="apple-touch-icon" href="/static/icon.svg">
|
<link rel="apple-touch-icon" href="/static/icon.svg">
|
||||||
@ -27,7 +27,7 @@
|
|||||||
|
|
||||||
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
|
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
|
||||||
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
|
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
|
||||||
<link rel="stylesheet" href="/static/style.css?v=1.13.10">
|
<link rel="stylesheet" href="/static/style.css?v=1.14.0">
|
||||||
|
|
||||||
|
|
||||||
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
|
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
|
||||||
@ -365,7 +365,7 @@ window.toggleNavTree = function(treeId, chevronId) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
|
<!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
|
||||||
<script src="/static/loader.js?v=1.13.10"></script>
|
<script src="/static/loader.js?v=1.14.0"></script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -233,6 +233,55 @@ function csProgressSnapshot(map, changed = []) {
|
|||||||
}).sort((a, b) => (b.status ? 1 : 0) - (a.status ? 1 : 0) || b.filled - a.filled || a.name.localeCompare(b.name)).slice(0, 18);
|
}).sort((a, b) => (b.status ? 1 : 0) - (a.status ? 1 : 0) || b.filled - a.filled || a.name.localeCompare(b.name)).slice(0, 18);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SSE reader for /api/character-sheets/stream, mirroring
|
||||||
|
// audiobookAttributeStream — idle-timeout-based abort (re-armed on every
|
||||||
|
// received chunk) rather than one fixed overall timeout, since generation
|
||||||
|
// can legitimately take a while but a truly stalled stream should still
|
||||||
|
// give up. Falls back to the blocking endpoint on any failure.
|
||||||
|
async function csGenerateStream(body, onDelta, outerSignal, idleTimeoutMs = 60000) {
|
||||||
|
const ctl = new AbortController();
|
||||||
|
const onAbort = () => ctl.abort();
|
||||||
|
if (outerSignal) {
|
||||||
|
if (outerSignal.aborted) ctl.abort();
|
||||||
|
else outerSignal.addEventListener('abort', onAbort, { once: true });
|
||||||
|
}
|
||||||
|
let idleTimer = null;
|
||||||
|
const armIdle = () => { clearTimeout(idleTimer); idleTimer = setTimeout(() => ctl.abort(), idleTimeoutMs); };
|
||||||
|
try {
|
||||||
|
armIdle();
|
||||||
|
const r = await fetch('/api/character-sheets/stream', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
signal: ctl.signal, body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!r.ok || !r.body) throw new Error('stream HTTP ' + r.status);
|
||||||
|
const reader = r.body.getReader();
|
||||||
|
const dec = new TextDecoder();
|
||||||
|
let buf = '', result = null;
|
||||||
|
for (;;) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
armIdle();
|
||||||
|
buf += dec.decode(value, { stream: true });
|
||||||
|
let at;
|
||||||
|
while ((at = buf.indexOf('\n\n')) >= 0) {
|
||||||
|
const line = buf.slice(0, at).trim();
|
||||||
|
buf = buf.slice(at + 2);
|
||||||
|
if (!line.startsWith('data:')) continue;
|
||||||
|
let d;
|
||||||
|
try { d = JSON.parse(line.slice(5)); } catch (_) { continue; }
|
||||||
|
if (d.t && onDelta) onDelta(d.t);
|
||||||
|
if (d.error) throw new Error(d.error);
|
||||||
|
if (d.done) result = d.result || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!result) throw new Error('stream ended without result');
|
||||||
|
return result;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(idleTimer);
|
||||||
|
if (outerSignal) outerSignal.removeEventListener('abort', onAbort);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function csGenerate(text, cacheKey, initialRoster) {
|
async function csGenerate(text, cacheKey, initialRoster) {
|
||||||
if (_cs.running) return null;
|
if (_cs.running) return null;
|
||||||
if (!text) { toast('Nothing to analyse', 'error'); return null; }
|
if (!text) { toast('Nothing to analyse', 'error'); return null; }
|
||||||
@ -265,10 +314,18 @@ async function csGenerate(text, cacheKey, initialRoster) {
|
|||||||
? `${knownTotal} cast characters queued · ${detailed} profiles with details`
|
? `${knownTotal} cast characters queued · ${detailed} profiles with details`
|
||||||
: `${map.size} characters found`;
|
: `${map.size} characters found`;
|
||||||
prog.update(i, `Passage ${i + 1} / ${chunks.length}…`, charLabel, csProgressSnapshot(map));
|
prog.update(i, `Passage ${i + 1} / ${chunks.length}…`, charLabel, csProgressSnapshot(map));
|
||||||
|
prog.startPassage();
|
||||||
try {
|
try {
|
||||||
|
const body = { text: chunks[i], known_characters: roster.slice(0, 120), target_mode: targetMode, existing: csExistingSummary(map), language, llm_url, model };
|
||||||
|
let data = null;
|
||||||
|
let sawDelta = false;
|
||||||
|
try {
|
||||||
|
data = await csGenerateStream(body, (delta) => { sawDelta = true; prog.thinking(delta); });
|
||||||
|
} catch (streamErr) {
|
||||||
|
if (!sawDelta) prog.noStream();
|
||||||
const r = await fetch('/api/character-sheets', {
|
const r = await fetch('/api/character-sheets', {
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ text: chunks[i], known_characters: roster.slice(0, 120), target_mode: targetMode, existing: csExistingSummary(map), language, llm_url, model }),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
let detail = `HTTP ${r.status}`;
|
let detail = `HTTP ${r.status}`;
|
||||||
@ -276,9 +333,9 @@ async function csGenerate(text, cacheKey, initialRoster) {
|
|||||||
throw new Error(detail);
|
throw new Error(detail);
|
||||||
}
|
}
|
||||||
const raw = await r.text();
|
const raw = await r.text();
|
||||||
let data;
|
|
||||||
try { data = JSON.parse(raw); }
|
try { data = JSON.parse(raw); }
|
||||||
catch (_) { throw new Error('Invalid JSON from server (passage may be too large)'); }
|
catch (_) { throw new Error('Invalid JSON from server (passage may be too large)'); }
|
||||||
|
}
|
||||||
const changed = csMerge(map, data.sheets || []);
|
const changed = csMerge(map, data.sheets || []);
|
||||||
if (!targetMode) {
|
if (!targetMode) {
|
||||||
(data.characters || []).forEach(n => { if (!roster.some(r => r.toLowerCase() === String(n || '').toLowerCase())) roster.push(n); });
|
(data.characters || []).forEach(n => { if (!roster.some(r => r.toLowerCase() === String(n || '').toLowerCase())) roster.push(n); });
|
||||||
@ -311,10 +368,14 @@ function csProgress(total) {
|
|||||||
if (!ov) {
|
if (!ov) {
|
||||||
ov = document.createElement('div');
|
ov = document.createElement('div');
|
||||||
ov.id = 'cs-progress'; ov.className = 'audiobook-overlay';
|
ov.id = 'cs-progress'; ov.className = 'audiobook-overlay';
|
||||||
ov.innerHTML = `<div class="audiobook-box">
|
ov.innerHTML = `<div class="audiobook-box cs-progress-box">
|
||||||
<div class="audiobook-title"><span class="mdi mdi-account-details-outline"></span> Character sheets</div>
|
<div class="audiobook-title"><span class="mdi mdi-account-details-outline"></span> Character sheets</div>
|
||||||
<div class="audiobook-msg" id="cs-progress-msg">Analysing…</div>
|
<div class="audiobook-msg" id="cs-progress-msg">Analysing…</div>
|
||||||
<div class="reader-synth-track" style="margin-bottom:4px"><div class="reader-synth-fill" id="cs-progress-fill"></div></div>
|
<div class="reader-synth-track" style="margin-bottom:4px"><div class="reader-synth-fill" id="cs-progress-fill"></div></div>
|
||||||
|
<details class="cs-progress-live" id="cs-progress-live" open>
|
||||||
|
<summary><span class="mdi mdi-text-long"></span> Live output <span class="cs-progress-live-status" id="cs-progress-live-status">watching…</span></summary>
|
||||||
|
<pre class="cs-progress-live-pre" id="cs-progress-live-pre"></pre>
|
||||||
|
</details>
|
||||||
<div class="cs-progress-chars" id="cs-progress-chars"></div>
|
<div class="cs-progress-chars" id="cs-progress-chars"></div>
|
||||||
<div class="audiobook-actions"><button class="btn-secondary btn-sm" id="cs-progress-cancel">Cancel</button></div>
|
<div class="audiobook-actions"><button class="btn-secondary btn-sm" id="cs-progress-cancel">Cancel</button></div>
|
||||||
</div>`;
|
</div>`;
|
||||||
@ -325,7 +386,28 @@ function csProgress(total) {
|
|||||||
const fill = ov.querySelector('#cs-progress-fill');
|
const fill = ov.querySelector('#cs-progress-fill');
|
||||||
const msg = ov.querySelector('#cs-progress-msg');
|
const msg = ov.querySelector('#cs-progress-msg');
|
||||||
const chars = ov.querySelector('#cs-progress-chars');
|
const chars = ov.querySelector('#cs-progress-chars');
|
||||||
|
const livePre = ov.querySelector('#cs-progress-live-pre');
|
||||||
|
const liveStatus = ov.querySelector('#cs-progress-live-status');
|
||||||
return {
|
return {
|
||||||
|
// Called once per passage before its request starts, so the live-output
|
||||||
|
// pane is empty and honestly labelled before we know whether this model
|
||||||
|
// streams real content or the request falls back to the blocking path.
|
||||||
|
startPassage() {
|
||||||
|
if (livePre) livePre.textContent = '';
|
||||||
|
if (liveStatus) { liveStatus.textContent = 'watching…'; liveStatus.className = 'cs-progress-live-status'; }
|
||||||
|
},
|
||||||
|
// Raw LLM output as it streams in — the JSON answer being written field
|
||||||
|
// by field is itself the "watch it fill out the sheet" experience here,
|
||||||
|
// there's no separate reasoning channel worth hiding it behind.
|
||||||
|
thinking(delta) {
|
||||||
|
if (!livePre || !delta) return;
|
||||||
|
if (liveStatus) { liveStatus.textContent = 'streaming'; liveStatus.className = 'cs-progress-live-status is-live'; }
|
||||||
|
livePre.textContent = (livePre.textContent + delta).slice(-20000);
|
||||||
|
livePre.scrollTop = livePre.scrollHeight;
|
||||||
|
},
|
||||||
|
noStream() {
|
||||||
|
if (liveStatus) { liveStatus.textContent = 'no live output for this model'; liveStatus.className = 'cs-progress-live-status'; }
|
||||||
|
},
|
||||||
update(d, label, charLabel, roster = []) {
|
update(d, label, charLabel, roster = []) {
|
||||||
if (fill) fill.style.width = (d / total * 100) + '%';
|
if (fill) fill.style.width = (d / total * 100) + '%';
|
||||||
if (msg && label) msg.textContent = label;
|
if (msg && label) msg.textContent = label;
|
||||||
|
|||||||
@ -5748,6 +5748,20 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
|||||||
text-shadow: 0 1px 3px rgba(0,0,0,.8);
|
text-shadow: 0 1px 3px rgba(0,0,0,.8);
|
||||||
}
|
}
|
||||||
.cs-arc-note { font-size: 11px; color: var(--subtext); margin-top: 3px; font-style: italic; }
|
.cs-arc-note { font-size: 11px; color: var(--subtext); margin-top: 3px; font-style: italic; }
|
||||||
|
.cs-progress-box { width: min(520px, 92vw); }
|
||||||
|
.cs-progress-live { margin-bottom: 10px; border: 1px solid var(--border); border-radius: 8px; }
|
||||||
|
.cs-progress-live summary {
|
||||||
|
display: flex; align-items: center; gap: 6px; padding: 7px 10px; font-size: 11.5px;
|
||||||
|
font-weight: 700; color: var(--subtext); cursor: pointer; list-style: none;
|
||||||
|
}
|
||||||
|
.cs-progress-live summary::-webkit-details-marker { display: none; }
|
||||||
|
.cs-progress-live-status { margin-left: auto; font-weight: 500; font-style: italic; opacity: .8; }
|
||||||
|
.cs-progress-live-status.is-live { color: var(--accent); opacity: 1; }
|
||||||
|
.cs-progress-live-pre {
|
||||||
|
margin: 0; padding: 8px 10px; max-height: 160px; overflow-y: auto; white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere; font-size: 11px; line-height: 1.5; color: var(--text);
|
||||||
|
background: var(--bg); border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
.cs-progress-chars { font-size: 11px; color: var(--subtext); min-height: 16px; margin-bottom: 8px; }
|
.cs-progress-chars { font-size: 11px; color: var(--subtext); min-height: 16px; margin-bottom: 8px; }
|
||||||
.cs-progress-summary { text-align: center; margin-bottom: 8px; color: var(--subtext); }
|
.cs-progress-summary { text-align: center; margin-bottom: 8px; color: var(--subtext); }
|
||||||
.cs-progress-list {
|
.cs-progress-list {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user