Compare commits
4 Commits
2a978aa333
...
227b66cb4f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
227b66cb4f | ||
|
|
89300605a4 | ||
|
|
808f4205d9 | ||
|
|
de6eaf9f8b |
15
.env.example
15
.env.example
@@ -62,11 +62,16 @@ CORS_ALLOWED_ORIGINS=
|
||||
# neither Origin nor Referer (curl, server-to-server callers) are
|
||||
# always allowed.
|
||||
#
|
||||
# Default is empty: CSRF check disabled (operator opt-out). For a
|
||||
# browser-exposed deployment this should be set to the SvelteKit
|
||||
# origin, e.g. https://app.example.com. For a same-origin
|
||||
# docker-compose deploy where only one origin exists, set the same
|
||||
# value the browser uses.
|
||||
# Empty does NOT mean "off" for browsers: cookie-authenticated admin
|
||||
# mutations FAIL CLOSED (403) when this is empty, since there's no
|
||||
# allowlist to check the Origin against. Non-cookie callers (curl,
|
||||
# bots with a Bearer token, no Origin/Referer) are still allowed.
|
||||
# So set this to the SvelteKit origin for any browser-exposed deploy,
|
||||
# e.g. https://app.example.com. For a same-origin docker-compose deploy
|
||||
# set the same value the browser uses.
|
||||
# Local dev (native `npm run dev`): the Vite origin is
|
||||
# http://localhost:5173 — set ADMIN_ALLOWED_ORIGINS=http://localhost:5173
|
||||
# or admin toggles (e.g. enabling the analysis worker) return 403.
|
||||
ADMIN_ALLOWED_ORIGINS=
|
||||
|
||||
# ----- Admin bootstrap -----
|
||||
|
||||
4
backend/.gitignore
vendored
4
backend/.gitignore
vendored
@@ -1,3 +1,7 @@
|
||||
/target
|
||||
/.sqlx
|
||||
.env
|
||||
|
||||
# Local OCR models for native dev (downloaded, not source)
|
||||
models/
|
||||
*.rten
|
||||
|
||||
@@ -434,7 +434,7 @@ async fn spawn_analysis_daemon(
|
||||
let (dispatcher, readiness): (
|
||||
Arc<dyn crate::analysis::daemon::AnalyzeDispatcher>,
|
||||
Option<Arc<dyn crate::analysis::daemon::VisionReadiness>>,
|
||||
) = match cfg.backend {
|
||||
) = match cfg.effective_backend() {
|
||||
crate::config::AnalysisBackend::Ocr => {
|
||||
// Load the `.rten` models once; a bad path is a loud boot error.
|
||||
let engine = crate::analysis::ocr::OcrsEngine::from_model_paths(
|
||||
@@ -1473,29 +1473,30 @@ mod tests {
|
||||
// Bind to a local so the TempDir lives for the rest of the test.
|
||||
// `tempfile::tempdir().unwrap().path()` would drop the TempDir
|
||||
// at end-of-expression and `LocalStorage` would hold a path to
|
||||
// a deleted directory. (Today this is fine because the dispatch
|
||||
// fails before storage is touched, but it makes the test fragile
|
||||
// to any future code rearrangement.)
|
||||
// a deleted directory.
|
||||
let storage_dir = tempfile::tempdir().unwrap();
|
||||
let storage: Arc<dyn Storage> =
|
||||
Arc::new(LocalStorage::new(storage_dir.path()));
|
||||
let mut cfg = crate::config::AnalysisConfig::default();
|
||||
// Use the vision backend so the daemon doesn't try to load the ocrs
|
||||
// `.rten` models (absent in unit CI). This test only exercises the
|
||||
// pre-worker lease reclaim, which is engine-agnostic; no dispatch runs.
|
||||
cfg.backend = crate::config::AnalysisBackend::Vision;
|
||||
// The worker always runs OCR now (vision is dormant — see
|
||||
// `effective_backend`), and the `.rten` models aren't shipped to unit
|
||||
// CI. Point the engine at a path that can't exist so the *engine build*
|
||||
// fails deterministically. Reclaim runs at the very top of
|
||||
// `spawn_analysis_daemon`, before — and independently of — engine
|
||||
// readiness, so the row must still be reclaimed even though spawn
|
||||
// returns Err. That's exactly the regression this test guards (an
|
||||
// analysis-only deploy must reclaim orphaned leases at startup).
|
||||
cfg.ocr_detection_model = "/nonexistent/text-detection.rten".to_string();
|
||||
cfg.ocr_recognition_model = "/nonexistent/text-recognition.rten".to_string();
|
||||
cfg.workers = 1;
|
||||
cfg.job_timeout = Duration::from_secs(1);
|
||||
let events = Arc::new(crate::analysis::events::AnalysisEvents::new());
|
||||
|
||||
let handle = spawn_analysis_daemon(pool.clone(), storage, &cfg, events)
|
||||
.await
|
||||
.expect("spawn");
|
||||
// Immediately shut down — we're only here to prove reclaim ran.
|
||||
// The workers may briefly pick up the now-pending row; that's
|
||||
// fine, but we cancel before any real dispatch (the LocalStorage
|
||||
// key doesn't exist, so a dispatch would fail anyway).
|
||||
handle.shutdown().await;
|
||||
let spawned = spawn_analysis_daemon(pool.clone(), storage, &cfg, events).await;
|
||||
assert!(
|
||||
spawned.is_err(),
|
||||
"engine build must fail with a missing model path"
|
||||
);
|
||||
|
||||
// The reclaim must have moved the row back to pending with the
|
||||
// attempt refunded (attempts goes from 1 → 0). Reaching it on the
|
||||
|
||||
@@ -262,6 +262,24 @@ impl Default for AnalysisConfig {
|
||||
}
|
||||
|
||||
impl AnalysisConfig {
|
||||
/// The backend the worker actually dispatches through.
|
||||
///
|
||||
/// Vision is **temporarily disabled**: the engine code (`analysis::vision`,
|
||||
/// `RealAnalyzeDispatcher`, the readiness probe) is kept intact but never
|
||||
/// selected. Until it's re-enabled, this returns [`AnalysisBackend::Ocr`]
|
||||
/// regardless of the parsed `backend`, logging a warning if `vision` was
|
||||
/// requested so an env override isn't silently ignored. Re-enabling vision
|
||||
/// is then a one-line change here (return `self.backend`).
|
||||
pub fn effective_backend(&self) -> AnalysisBackend {
|
||||
if self.backend == AnalysisBackend::Vision {
|
||||
tracing::warn!(
|
||||
"ANALYSIS_BACKEND=vision requested but the vision backend is temporarily \
|
||||
disabled; running OCR instead"
|
||||
);
|
||||
}
|
||||
AnalysisBackend::Ocr
|
||||
}
|
||||
|
||||
pub fn from_env() -> Self {
|
||||
let d = AnalysisConfig::default();
|
||||
Self {
|
||||
@@ -840,6 +858,19 @@ mod tests {
|
||||
assert_eq!(AnalysisConfig::from_env().backend, AnalysisBackend::Ocr);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_backend_is_ocr_even_when_vision_requested() {
|
||||
// Vision is temporarily disabled: the worker always runs OCR no matter
|
||||
// what `ANALYSIS_BACKEND` parsed to. `backend` still reflects the raw
|
||||
// request (so the override is visible/loggable), but `effective_backend`
|
||||
// is the value the daemon actually dispatches through.
|
||||
let mut cfg = AnalysisConfig::default();
|
||||
cfg.backend = AnalysisBackend::Vision;
|
||||
assert_eq!(cfg.effective_backend(), AnalysisBackend::Ocr);
|
||||
cfg.backend = AnalysisBackend::Ocr;
|
||||
assert_eq!(cfg.effective_backend(), AnalysisBackend::Ocr);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ocr_model_paths_parse_from_env() {
|
||||
let _g = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner());
|
||||
|
||||
@@ -25,7 +25,7 @@ use serde::{Deserialize, Serialize};
|
||||
use crate::analysis::prompt::{
|
||||
GROUNDING_PROMPT_DEFAULT, OCR_PROMPT_DEFAULT, SYSTEM_PROMPT_DEFAULT,
|
||||
};
|
||||
use crate::config::{AnalysisConfig, CrawlerConfig, ResponseFormat};
|
||||
use crate::config::{AnalysisBackend, AnalysisConfig, CrawlerConfig, ResponseFormat};
|
||||
use crate::crawler::safety::DownloadAllowlist;
|
||||
|
||||
/// `app_settings.key` for the crawler group.
|
||||
@@ -355,6 +355,15 @@ impl AnalysisSettings {
|
||||
if self.workers < 1 {
|
||||
errs.push("workers", "must be at least 1");
|
||||
}
|
||||
// The endpoint/model are vision-only knobs — the OCR backend never
|
||||
// dials a URL or sends a model id. While vision is dormant
|
||||
// (`base.backend == Ocr`), skip the live-worker SSRF gate and the
|
||||
// model-required check so an OCR operator can enable the worker
|
||||
// without a vision endpoint/model (the OCR settings UI doesn't even
|
||||
// expose them). The basic malformed-URL sanity check below stays
|
||||
// unconditional. Re-enabling vision restores the full gate via the
|
||||
// `base.backend == Vision` predicate.
|
||||
let vision_active = base.backend == AnalysisBackend::Vision;
|
||||
let trimmed_endpoint = self.endpoint.trim();
|
||||
if trimmed_endpoint.is_empty() {
|
||||
errs.push("endpoint", "must be a valid absolute URL");
|
||||
@@ -362,7 +371,7 @@ impl AnalysisSettings {
|
||||
// Always reject obviously-malformed URLs (matches prior behaviour).
|
||||
let _ = e;
|
||||
errs.push("endpoint", "must be a valid absolute URL");
|
||||
} else if self.enabled {
|
||||
} else if self.enabled && vision_active {
|
||||
// SSRF + API-key exfiltration defence — only enforced when the
|
||||
// worker is actually live. Worker attaches an env-managed bearer
|
||||
// token to every call; without this check, an admin (or one
|
||||
@@ -379,7 +388,7 @@ impl AnalysisSettings {
|
||||
errs.push("endpoint", url_safety_message(&e));
|
||||
}
|
||||
}
|
||||
if self.enabled && self.model.trim().is_empty() {
|
||||
if self.enabled && vision_active && self.model.trim().is_empty() {
|
||||
errs.push("model", "required when analysis is enabled");
|
||||
}
|
||||
if self.max_tokens < 1 {
|
||||
@@ -701,7 +710,8 @@ mod tests {
|
||||
// a hostile/CSRF-able admin must NOT be able to point endpoint at
|
||||
// cloud metadata, loopback services, or RFC1918 hosts — when the
|
||||
// worker is enabled (toggling enabled=true later re-runs this gate).
|
||||
let base = AnalysisConfig::default();
|
||||
let mut base = AnalysisConfig::default();
|
||||
base.backend = AnalysisBackend::Vision;
|
||||
for url in [
|
||||
"http://169.254.169.254/v1/chat/completions",
|
||||
"http://127.0.0.1:5432/",
|
||||
@@ -725,7 +735,8 @@ mod tests {
|
||||
// The documented default — docker DNS name resolving to a private IP
|
||||
// at runtime — must still validate, because the bearer recipient
|
||||
// identity is the operator-chosen hostname, not the underlying IP.
|
||||
let base = AnalysisConfig::default();
|
||||
let mut base = AnalysisConfig::default();
|
||||
base.backend = AnalysisBackend::Vision;
|
||||
for url in [
|
||||
"http://mangalord-vision:8000/v1/chat/completions",
|
||||
"https://api.openai.com/v1/chat/completions",
|
||||
@@ -756,7 +767,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn analysis_requires_model_only_when_enabled() {
|
||||
let base = AnalysisConfig::default();
|
||||
// Vision base: the model id is required only when the vision worker
|
||||
// is actually live (see the OCR carve-out below).
|
||||
let mut base = AnalysisConfig::default();
|
||||
base.backend = AnalysisBackend::Vision;
|
||||
let disabled = AnalysisSettings {
|
||||
enabled: false,
|
||||
model: "".to_string(),
|
||||
@@ -772,6 +786,28 @@ mod tests {
|
||||
assert!(errs.errors.iter().any(|e| e.field == "model"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analysis_ocr_backend_enable_skips_vision_endpoint_and_model_validation() {
|
||||
// With the OCR backend active the worker never dials the vision
|
||||
// endpoint nor sends a model id, so enabling analysis must NOT
|
||||
// validate those vision-only fields. The default base carries the
|
||||
// dev-localhost endpoint (which the SSRF gate would otherwise reject)
|
||||
// and an empty model — under OCR, enabling is still valid. Without
|
||||
// this carve-out an OCR operator can't turn the worker on, since the
|
||||
// OCR settings UI doesn't expose endpoint/model to fix.
|
||||
let base = AnalysisConfig::default(); // backend = Ocr
|
||||
let dto = AnalysisSettings {
|
||||
enabled: true,
|
||||
endpoint: "http://localhost:8000/v1/chat/completions".to_string(),
|
||||
model: "".to_string(),
|
||||
..AnalysisSettings::from_config(&base)
|
||||
};
|
||||
assert!(
|
||||
dto.to_config(&base).is_ok(),
|
||||
"OCR-enabled config must not fail on vision endpoint/model"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dtos_serialize_to_json_and_back() {
|
||||
let c = CrawlerSettings::default();
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
let total = $state(0);
|
||||
let page = $state(1);
|
||||
let statusFilter = $state<'' | 'done' | 'failed'>('');
|
||||
let nsfwOnly = $state(false);
|
||||
let search = $state('');
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
@@ -34,7 +33,6 @@
|
||||
try {
|
||||
const resp = await listAnalysisHistory({
|
||||
status: statusFilter || undefined,
|
||||
nsfw: nsfwOnly,
|
||||
search: search.trim() || undefined,
|
||||
limit: LIMIT,
|
||||
offset: (page - 1) * LIMIT
|
||||
@@ -86,15 +84,6 @@
|
||||
<option value="done">done</option>
|
||||
<option value="failed">failed</option>
|
||||
</select>
|
||||
<label class="nsfw">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={nsfwOnly}
|
||||
onchange={applyFilters}
|
||||
data-testid="analysis-history-nsfw"
|
||||
/>
|
||||
NSFW only
|
||||
</label>
|
||||
<input
|
||||
class="search"
|
||||
type="text"
|
||||
@@ -136,7 +125,6 @@
|
||||
<td><span class="status-pill {r.status}">{r.status}</span></td>
|
||||
<td>
|
||||
{r.manga_title} · Ch {r.chapter_number} · p{r.page_number}
|
||||
{#if r.is_nsfw}<span class="nsfw-tag">⚠ NSFW</span>{/if}
|
||||
</td>
|
||||
<td class="muted">{r.model ?? '—'}</td>
|
||||
<td class="num">{fmtDuration(r.duration_ms)}</td>
|
||||
@@ -169,13 +157,6 @@
|
||||
color: var(--text);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
.nsfw {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
@@ -218,11 +199,6 @@
|
||||
background: color-mix(in srgb, var(--danger) 16%, transparent);
|
||||
color: var(--danger);
|
||||
}
|
||||
.nsfw-tag {
|
||||
font-size: var(--font-xs);
|
||||
color: #b85e1a;
|
||||
margin-left: var(--space-1);
|
||||
}
|
||||
.error {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { render, screen, cleanup, waitFor } from '@testing-library/svelte';
|
||||
|
||||
// Mock the admin API so the component's onMount load() is deterministic.
|
||||
const listAnalysisHistory = vi.fn();
|
||||
vi.mock('$lib/api/admin', () => ({
|
||||
listAnalysisHistory: (...args: unknown[]) => listAnalysisHistory(...args)
|
||||
}));
|
||||
|
||||
import AnalysisHistoryTable from './AnalysisHistoryTable.svelte';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
listAnalysisHistory.mockReset();
|
||||
});
|
||||
|
||||
function row(over: Record<string, unknown> = {}) {
|
||||
return {
|
||||
page_id: 'p1',
|
||||
page_number: 1,
|
||||
chapter_id: 'c1',
|
||||
chapter_number: 5,
|
||||
manga_id: 'm1',
|
||||
manga_title: 'Berserk',
|
||||
status: 'done',
|
||||
is_nsfw: true, // even when the backend reports nsfw, the OCR UI must not surface it
|
||||
model: 'ocrs',
|
||||
error: null,
|
||||
analyzed_at: '2026-01-02T00:00:00Z',
|
||||
duration_ms: 1234,
|
||||
...over
|
||||
};
|
||||
}
|
||||
|
||||
function resolveOnce(rows: ReturnType<typeof row>[]) {
|
||||
listAnalysisHistory.mockResolvedValue({
|
||||
items: rows,
|
||||
page: { total: rows.length }
|
||||
});
|
||||
}
|
||||
|
||||
describe('AnalysisHistoryTable (OCR)', () => {
|
||||
it('does not render the NSFW-only filter', async () => {
|
||||
resolveOnce([row()]);
|
||||
render(AnalysisHistoryTable, { props: { onOpenDetail: () => {} } });
|
||||
await waitFor(() => expect(listAnalysisHistory).toHaveBeenCalled());
|
||||
expect(screen.queryByTestId('analysis-history-nsfw')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not pass an nsfw filter to the history query', async () => {
|
||||
resolveOnce([row()]);
|
||||
render(AnalysisHistoryTable, { props: { onOpenDetail: () => {} } });
|
||||
await waitFor(() => expect(listAnalysisHistory).toHaveBeenCalled());
|
||||
const arg = listAnalysisHistory.mock.calls[0][0] as Record<string, unknown>;
|
||||
expect('nsfw' in arg).toBe(false);
|
||||
});
|
||||
|
||||
it('never shows an NSFW tag on a row, even if the row is flagged', async () => {
|
||||
resolveOnce([row({ is_nsfw: true })]);
|
||||
render(AnalysisHistoryTable, { props: { onOpenDetail: () => {} } });
|
||||
await waitFor(() => expect(screen.getByText(/Berserk/)).toBeTruthy());
|
||||
expect(screen.queryByText(/NSFW/i)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -105,30 +105,6 @@
|
||||
<span class="value">{metrics.failed}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>By model</h2>
|
||||
{#if metrics.by_model.length === 0}
|
||||
<p class="muted">No completed analyses in this window.</p>
|
||||
{:else}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Model</th>
|
||||
<th class="num">Avg</th>
|
||||
<th class="num">N</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each metrics.by_model as m (m.model)}
|
||||
<tr>
|
||||
<td>{m.model ?? '(unknown)'}</td>
|
||||
<td class="num">{fmtDuration(m.avg_ms)}</td>
|
||||
<td class="num">{m.n}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
@@ -186,29 +162,6 @@
|
||||
font-weight: var(--weight-semibold);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
h2 {
|
||||
margin: 0 0 var(--space-2);
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
max-width: 32rem;
|
||||
}
|
||||
th,
|
||||
td {
|
||||
padding: var(--space-2);
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
.num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { render, screen, cleanup, waitFor } from '@testing-library/svelte';
|
||||
|
||||
const getAnalysisMetrics = vi.fn();
|
||||
const getAnalysisMetricsSeries = vi.fn();
|
||||
vi.mock('$lib/api/admin', () => ({
|
||||
getAnalysisMetrics: (...a: unknown[]) => getAnalysisMetrics(...a),
|
||||
getAnalysisMetricsSeries: (...a: unknown[]) => getAnalysisMetricsSeries(...a)
|
||||
}));
|
||||
|
||||
import AnalysisMetricsPanel from './AnalysisMetricsPanel.svelte';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
getAnalysisMetrics.mockReset();
|
||||
getAnalysisMetricsSeries.mockReset();
|
||||
});
|
||||
|
||||
describe('AnalysisMetricsPanel (OCR)', () => {
|
||||
it('renders the aggregate tiles but not the by-model table', async () => {
|
||||
getAnalysisMetrics.mockResolvedValue({
|
||||
n: 10,
|
||||
ok: 9,
|
||||
failed: 1,
|
||||
avg_ms: 1200,
|
||||
// Even when the API returns a by-model breakdown, OCR has a single
|
||||
// engine so the panel must not surface the table.
|
||||
by_model: [{ model: 'ocrs', avg_ms: 1200, n: 10 }]
|
||||
});
|
||||
getAnalysisMetricsSeries.mockResolvedValue({ buckets: [] });
|
||||
|
||||
render(AnalysisMetricsPanel);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('analysis-metrics-n').textContent).toContain('10')
|
||||
);
|
||||
expect(screen.queryByText(/By model/i)).toBeNull();
|
||||
expect(screen.queryByRole('table')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -533,9 +533,9 @@
|
||||
</span>
|
||||
</div>
|
||||
<p class="lede">
|
||||
Coverage of the AI analysis worker (OCR, auto-tags, scene description,
|
||||
NSFW moderation). Browse what's analyzed, queue more, and inspect any
|
||||
page's result. Updates stream live as pages are queued and processed.
|
||||
Coverage of the OCR text-extraction worker. Browse which pages have had
|
||||
their text extracted, queue more, and inspect any page's OCR result.
|
||||
Updates stream live as pages are queued and processed.
|
||||
</p>
|
||||
|
||||
<div class="viewtabs">
|
||||
@@ -818,37 +818,12 @@
|
||||
<p class="error">{detail.error ?? 'Analysis failed.'}</p>
|
||||
{/if}
|
||||
|
||||
{#if detail.is_nsfw || detail.content_warnings.length > 0}
|
||||
<div class="warnings" data-testid="admin-analysis-detail-warnings">
|
||||
<span class="cw-label">⚠ NSFW</span>
|
||||
{#each detail.content_warnings as w (w)}
|
||||
<span class="cw-chip">{w}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if detail.scene_description}
|
||||
<section class="block">
|
||||
<h3>Scene</h3>
|
||||
<p>{detail.scene_description}</p>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if detail.tags.length > 0}
|
||||
<section class="block">
|
||||
<h3>Tags</h3>
|
||||
<div class="tag-row">
|
||||
{#each detail.tags as t (t)}<span class="tag">{t}</span>{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if detail.ocr.length > 0}
|
||||
<section class="block">
|
||||
<h3>OCR text</h3>
|
||||
<ul class="ocr">
|
||||
{#each detail.ocr as line, i (i)}
|
||||
<li><span class="ocr-kind">{line.kind}</span> {line.text}</li>
|
||||
<li>{#if line.kind}<span class="ocr-kind">{line.kind}</span> {/if}{line.text}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
@@ -1186,28 +1161,6 @@
|
||||
background: color-mix(in srgb, var(--danger) 16%, transparent);
|
||||
color: var(--danger);
|
||||
}
|
||||
.warnings {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
background: color-mix(in srgb, #d4762a 12%, transparent);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
.cw-label {
|
||||
font-weight: 600;
|
||||
color: #b85e1a;
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
.cw-chip {
|
||||
font-size: var(--font-sm);
|
||||
padding: 1px var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, #d4762a 22%, transparent);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.block {
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
@@ -1218,17 +1171,6 @@
|
||||
letter-spacing: 0.04em;
|
||||
margin: 0 0 var(--space-1);
|
||||
}
|
||||
.tag-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
.tag {
|
||||
font-size: var(--font-sm);
|
||||
padding: 1px var(--space-2);
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--surface-elevated);
|
||||
}
|
||||
.ocr {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
|
||||
@@ -9,8 +9,7 @@
|
||||
updateAnalysisSettings,
|
||||
type CrawlerSettings,
|
||||
type CrawlerEnvOnly,
|
||||
type AnalysisSettings,
|
||||
type PromptDefaults
|
||||
type AnalysisSettings
|
||||
} from '$lib/api/admin';
|
||||
|
||||
type Tab = 'crawler' | 'analysis';
|
||||
@@ -21,10 +20,11 @@
|
||||
let crawlerEnv = $state<CrawlerEnvOnly | null>(null);
|
||||
let allowlistText = $state(''); // textarea mirror of download_allowlist
|
||||
|
||||
// Analysis state
|
||||
// Analysis state. The full settings object is loaded and saved as-is;
|
||||
// vision-only fields (endpoint, model, prompts, slicing, sampling) ride
|
||||
// along unchanged while the OCR backend is active — only the OCR-relevant
|
||||
// knobs are surfaced below.
|
||||
let analysis = $state<AnalysisSettings | null>(null);
|
||||
let promptDefaults = $state<PromptDefaults | null>(null);
|
||||
let apiKeyConfigured = $state(false);
|
||||
|
||||
let loading = $state(true);
|
||||
let loadError = $state<string | null>(null);
|
||||
@@ -46,8 +46,6 @@
|
||||
crawlerEnv = c.env_only;
|
||||
allowlistText = c.editable.download_allowlist.join('\n');
|
||||
analysis = a.editable;
|
||||
promptDefaults = a.prompt_defaults;
|
||||
apiKeyConfigured = a.env_only.api_key_configured;
|
||||
} catch (e) {
|
||||
loadError = e instanceof Error ? e.message : 'Failed to load settings.';
|
||||
} finally {
|
||||
@@ -96,8 +94,6 @@
|
||||
} else if (tab === 'analysis' && analysis) {
|
||||
const r = await updateAnalysisSettings(analysis);
|
||||
analysis = r.editable;
|
||||
promptDefaults = r.prompt_defaults;
|
||||
apiKeyConfigured = r.env_only.api_key_configured;
|
||||
}
|
||||
success = 'Saved and applied. The subsystem was restarted with the new settings.';
|
||||
} catch (e) {
|
||||
@@ -113,9 +109,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
function resetPrompt(which: 'system_prompt' | 'ocr_prompt' | 'grounding_prompt') {
|
||||
if (analysis) analysis[which] = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<h1>Settings</h1>
|
||||
@@ -322,145 +315,29 @@
|
||||
{:else if tab === 'analysis' && analysis}
|
||||
<div class="form">
|
||||
<fieldset>
|
||||
<legend>Endpoint & model</legend>
|
||||
<label class="field">
|
||||
<span>Vision endpoint URL</span>
|
||||
<input type="text" bind:value={analysis.endpoint} />
|
||||
{#if fieldErrors.endpoint}<small class="fe">{fieldErrors.endpoint}</small>{/if}
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Model</span>
|
||||
<input type="text" bind:value={analysis.model} />
|
||||
{#if fieldErrors.model}<small class="fe">{fieldErrors.model}</small>{/if}
|
||||
</label>
|
||||
<legend>Engine</legend>
|
||||
<p class="env-note muted">
|
||||
API key: {apiKeyConfigured
|
||||
? 'configured via environment'
|
||||
: 'not set'} (managed via environment).
|
||||
Backend: OCR (ocrs) — in-process text extraction. Selected via
|
||||
environment; vision is temporarily disabled.
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Workers & timeouts</legend>
|
||||
<legend>Worker</legend>
|
||||
<label class="field">
|
||||
<span>Workers</span>
|
||||
<input type="number" min="1" bind:value={analysis.workers} />
|
||||
{#if fieldErrors.workers}<small class="fe">{fieldErrors.workers}</small>{/if}
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Request timeout (seconds)</span>
|
||||
<input type="number" min="1" bind:value={analysis.request_timeout_secs} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Job timeout (seconds)</span>
|
||||
<input type="number" min="1" bind:value={analysis.job_timeout_secs} />
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Sampling & output</legend>
|
||||
<label class="field">
|
||||
<span>Temperature</span>
|
||||
<input type="number" min="0" step="0.05" bind:value={analysis.temperature} />
|
||||
{#if fieldErrors.temperature}<small class="fe"
|
||||
>{fieldErrors.temperature}</small
|
||||
>{/if}
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Frequency penalty</span>
|
||||
<input type="number" step="0.05" bind:value={analysis.frequency_penalty} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Max output tokens</span>
|
||||
<input type="number" min="1" bind:value={analysis.max_tokens} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Response format</span>
|
||||
<select bind:value={analysis.response_format}>
|
||||
<option value="json_schema">json_schema</option>
|
||||
<option value="json_object">json_object</option>
|
||||
<option value="none">none</option>
|
||||
</select>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Image slicing</legend>
|
||||
<label class="field">
|
||||
<span>Max pixels per slice</span>
|
||||
<input type="number" min="1" bind:value={analysis.max_pixels} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Min slice height (px)</span>
|
||||
<input type="number" min="1" bind:value={analysis.min_slice_height} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Slice overlap (0–0.9)</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="0.9"
|
||||
step="0.01"
|
||||
bind:value={analysis.slice_overlap}
|
||||
/>
|
||||
{#if fieldErrors.slice_overlap}<small class="fe"
|
||||
>{fieldErrors.slice_overlap}</small
|
||||
>{/if}
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Tall aspect threshold (≥1.0)</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
step="0.1"
|
||||
bind:value={analysis.tall_aspect_threshold}
|
||||
/>
|
||||
{#if fieldErrors.tall_aspect_threshold}<small class="fe"
|
||||
>{fieldErrors.tall_aspect_threshold}</small
|
||||
>{/if}
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Max slices</span>
|
||||
<input type="number" min="1" bind:value={analysis.max_slices} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Max image bytes</span>
|
||||
<input type="number" min="0" bind:value={analysis.max_image_bytes} />
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Prompts</legend>
|
||||
{#each [['system_prompt', 'System prompt'], ['ocr_prompt', 'OCR prompt (slices)'], ['grounding_prompt', 'Grounding prompt']] as [key, label] (key)}
|
||||
{@const k = key as 'system_prompt' | 'ocr_prompt' | 'grounding_prompt'}
|
||||
<div class="prompt">
|
||||
<div class="prompt-head">
|
||||
<span>{label}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="link"
|
||||
disabled={analysis[k] == null}
|
||||
onclick={() => resetPrompt(k)}>Reset to default</button
|
||||
>
|
||||
</div>
|
||||
<textarea
|
||||
rows="6"
|
||||
class="mono"
|
||||
value={analysis[k] ?? ''}
|
||||
placeholder={promptDefaults?.[k] ?? ''}
|
||||
oninput={(e) => {
|
||||
const v = (e.currentTarget as HTMLTextAreaElement).value;
|
||||
if (analysis) analysis[k] = v.trim() === '' ? null : v;
|
||||
}}
|
||||
></textarea>
|
||||
<small class="muted">
|
||||
{analysis[k] == null
|
||||
? 'Using compiled default (shown as placeholder).'
|
||||
: `${analysis[k]?.length ?? 0} chars (override).`}
|
||||
</small>
|
||||
</div>
|
||||
{/each}
|
||||
</fieldset>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -564,7 +441,6 @@
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.field input,
|
||||
.field select,
|
||||
.field textarea {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--border);
|
||||
@@ -584,39 +460,6 @@
|
||||
color: var(--danger, #dc2626);
|
||||
font-size: var(--font-xs);
|
||||
}
|
||||
.mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: var(--font-xs);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: var(--space-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
}
|
||||
.prompt {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
.prompt-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
button.link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--primary);
|
||||
cursor: pointer;
|
||||
font-size: var(--font-xs);
|
||||
padding: 0;
|
||||
}
|
||||
button.link:disabled {
|
||||
color: var(--text-muted);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.env dl {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
|
||||
Reference in New Issue
Block a user