web/index.html
Rama main · réplica de lectura del repositorio original.
<!DOCTYPE html>
<html lang="fa" dir="rtl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Finding 2's last layer: this page and web/admin/index.html are served
by whatever static host sits in front of them (a plain file server,
nginx, ...) -- a different origin than the FastAPI app, whose own
response headers (api/middleware.py's SecurityHeadersMiddleware)
never reach a document fetched from somewhere else. A <meta> policy
travels WITH the document, so it applies no matter what serves it.
script-src is deliberately 'self' only, with no 'unsafe-inline': every
script this page loads is a real file (assets/vendor/prism*.min.js,
js/main.js), so an injected inline handler -- the exact shape the
audit reproduced through main.js's escapeHtml/data-pid gap -- has
nowhere to run even if every other layer fails. connect-src is left
open because the backend base URL is deliberately end-user
configurable (web/js/config.js's DEFAULT_BASE_URL, the persisted
value in web/js/state.js, the admin panel's own "اتصال" control) --
narrowing it to one origin would break that by design. -->
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src *; base-uri 'none'; form-action 'self'; object-src 'none'">
<title>عامل هوشمند SQL — گفتگوی تحلیلی</title>
<link rel="stylesheet" href="styles/tokens.css">
<link rel="stylesheet" href="styles/fonts.css">
<link rel="stylesheet" href="styles/style.css">
<link rel="stylesheet" href="assets/vendor/prism-sql-theme.css">
<style>
/* Icon utility — 16px stroke icons from js/icons.js / inline chrome SVG */
.icon { display: inline-block; vertical-align: -0.15em; flex: 0 0 auto; }
.topbar .icon, .sidebar-toggle .icon, .memory-panel-close .icon { color: currentColor; }
</style>
</head>
<body>
<header class="topbar">
<div class="brand">
<span class="brand-mark" aria-hidden="true">SQL</span>
<div class="brand-text">
<h1 data-i18n="productTitle">عامل هوشمند SQL</h1>
<p data-i18n="productTagline">پرسش به زبان فارسی ← SQL ← پاسخ، روی زیرساخت خودتان</p>
</div>
</div>
<div class="topbar-right">
<!-- Backend base URL is deploy config (web/js/config.js), not an
analyst control. API key is identity and lives in the user menu. -->
<button class="memory-toggle" id="memory-toggle" type="button" aria-haspopup="dialog" aria-controls="memory-panel" aria-expanded="false">
<svg class="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 8a3 3 0 016 0v1h1a2 2 0 012 2v6a2 2 0 01-2 2H8a2 2 0 01-2-2v-6a2 2 0 012-2h1V8zm3 4v4m-2-2h4"/></svg>
<span data-i18n="memory">حافظهٔ تحلیلی</span>
</button>
<!-- Static pre-JS fallback: mirrors setHealth's own DOM shape (main.js)
so the first real render never has to change structure, only
state. Starts "unknown", not "ok" -- main.js calls refreshHealth()
on boot, but until that resolves, claiming every service is up is
the same false-all-clear this rail was rewritten to stop making. -->
<div class="health" id="health" role="status" aria-live="polite">
<span class="pill" aria-label="API: نامشخص"><span class="dot dot-unknown"></span><span class="pill-name">API</span><span class="pill-state">نامشخص</span></span>
<span class="pill" aria-label="LLM: نامشخص"><span class="dot dot-unknown"></span><span class="pill-name">LLM</span><span class="pill-state">نامشخص</span></span>
<span class="pill" aria-label="DB: نامشخص"><span class="dot dot-unknown"></span><span class="pill-name">DB</span><span class="pill-state">نامشخص</span></span>
</div>
<!-- User menu: theme, language, API key. One place for identity and
preference chrome so the topbar stays readable on a 13" screen. -->
<div class="user-menu" id="user-menu">
<button class="user-menu-btn" id="user-menu-btn" type="button" aria-haspopup="true" aria-expanded="false" aria-controls="user-menu-panel">
<span class="user-avatar" aria-hidden="true">A</span>
<span data-i18n="userMenu">حساب</span>
</button>
<div class="user-menu-panel" id="user-menu-panel" role="dialog" aria-label="حساب" hidden>
<div class="um-section">
<div class="um-label" data-i18n="theme">پوسته</div>
<div class="um-segment" id="theme-segment" role="group" aria-label="پوسته">
<button type="button" data-theme-val="system" data-i18n="themeSystem">سیستم</button>
<button type="button" data-theme-val="light" data-i18n="themeLight">روشن</button>
<button type="button" data-theme-val="dark" data-i18n="themeDark">تیره</button>
</div>
</div>
<div class="um-section">
<div class="um-label" data-i18n="language">زبان</div>
<div class="um-segment" id="lang-segment" role="group" aria-label="زبان">
<button type="button" data-lang="fa" data-i18n="langFa">فارسی</button>
<button type="button" data-lang="en" data-i18n="langEn">English</button>
</div>
</div>
<div class="um-section">
<div class="um-label" data-i18n="apiKey">کلید API</div>
<div class="live-key-row" id="live-key-row">
<span class="live-key-status" id="live-key-status"></span>
<span class="live-key-entry" id="live-key-entry">
<input type="password" id="live-key-input" dir="ltr" class="live-key-input" autocomplete="off" data-i18n-attr="aria-label:apiKey,placeholder:apiKeyPlaceholder" aria-label="کلید API" placeholder="کلید API خود را وارد کنید">
<button class="live-key-save" id="live-key-save" type="button" data-i18n="apiKeySave">ذخیره</button>
</span>
<button class="live-key-change" id="live-key-change" type="button" hidden data-i18n="apiKeyChange">تغییر کلید</button>
<button class="live-key-clear" id="live-key-clear" type="button" data-i18n="apiKeyClear">حذف کلید</button>
</div>
<p class="um-hint" data-i18n="apiKeyHint" hidden></p>
</div>
<!-- ADR-004 part 1: the minimal analyst-facing status read for
"Request access" -- the account/menu area already holds the
per-analyst chrome (theme, language, API key), so this reuses
that surface instead of adding a new panel. Populated by
main.js's refreshAccessRequestsList() when the menu opens
(live mode only); every row's column/status/reason is
server-supplied and untrusted -- rendered via textContent
only, never innerHTML. -->
<div class="um-section" id="access-requests-section">
<div class="um-label" data-i18n="accessRequests">درخواستهای دسترسی</div>
<div id="access-requests-list" class="access-requests-list" aria-live="polite">
<p class="access-requests-empty" data-i18n="accessRequestsEmpty">درخواستی ثبت نشده است.</p>
</div>
</div>
</div>
</div>
</div>
</header>
<!-- Page-level answer to "the status lights are red": filled by main.js's
refreshHealth() through diagnosis.js only when the address this page
calls is not the API, or the call was refused/unreachable. Text only
(textContent); hidden whenever the status check succeeds. -->
<div id="api-diagnosis" class="notice error api-diagnosis" role="alert" hidden></div>
<div class="app-body">
<button class="sidebar-toggle" id="sidebar-toggle" type="button" aria-expanded="true" aria-controls="sidebar">
<svg class="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
<span data-i18n="sessions">گفتگوها</span>
</button>
<aside class="sidebar" id="sidebar" aria-label="فهرست گفتگوها">
<div class="sidebar-head">
<h2 data-i18n="sessions">گفتگوها</h2>
<button class="btn-new-session" id="btn-new-session" type="button" data-i18n="newSession">+ گفتگوی جدید</button>
</div>
<div id="session-list-host" aria-live="polite"></div>
</aside>
<main class="layout">
<!-- D3 (docs/design/DESIGN.md §5.1): composer lives BELOW the
transcript in DOM order, not just visually — a screen-reader user
and a sighted one meet the conversation before the input, in the
same order. It is pinned to the viewport bottom in style.css
(`.composer { position: sticky; bottom }`) so a long transcript
never scrolls it out of reach; that pin is exactly why it must be
the LAST child of .layout, not the first. -->
<section id="transcript" aria-live="polite" aria-relevant="additions" data-i18n-attr="aria-label:transcript" aria-label="گفتگوی تحلیلی">
<!-- #transcript-empty (sample-story chips included) is injected by
main.js's renderTranscriptEmptyState() only while this session's
transcript has zero turns, and only in simulated mode (DESIGN.md
decision B5 — samples are demo scaffolding, never a live
production session's UI). Keeping it out of the composer card
instead of overlapping the textarea's placeholder, which is
where it used to live. -->
</section>
<div class="card composer">
<label class="card-title" for="question" data-i18n="askLabel">پرسش خود را به زبان طبیعی بنویسید</label>
<div class="ask-row">
<textarea id="question" rows="2" dir="auto" data-i18n-attr="placeholder:askPlaceholder" placeholder="مثلاً «معاملات مشتریهای تالار سیمان را نشان بده»"></textarea>
<button class="btn-ask" id="btn-ask" type="button" data-i18n="ask">پرسش</button>
</div>
<!-- Per-analyst, persisted in this browser (js/state.js), and sent
on every turn. It is a choice worth exposing rather than a
deployment setting because it decides whether up to twenty rows
of the analyst's own result go to the language model: the person
who asked the question is the one who knows whether these
particular rows should. -->
<div class="ask-options">
<label class="ask-toggle">
<input type="checkbox" id="interpret-toggle">
<span data-i18n="interpret">تفسیر نتیجه</span>
</label>
<span class="ask-toggle-hint" data-i18n="interpretHint">خلاصهٔ سادهشده، با فرستادن حداکثر ۲۰ ردیف نتیجه به مدل زبانی</span>
</div>
<div id="notice-area"></div>
</div>
</main>
</div>
<div class="memory-overlay" id="memory-overlay" hidden></div>
<div class="memory-panel" id="memory-panel" role="dialog" aria-modal="true" aria-label="حافظهٔ تحلیلی — اولویتهای ثابت" hidden>
<div class="memory-panel-head">
<h2>حافظهٔ تحلیلی</h2>
<button class="memory-panel-close" id="memory-panel-close" type="button" aria-label="بستن پنل حافظه">
<svg class="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg>
</button>
</div>
<p class="memory-panel-intro">
مقادیری که اینجا میبینید فقط با اقدام صریح شما — دکمهٔ «بهخاطر بسپار» روی یک مفروضه، یا تنظیم مستقیم زیر — ذخیره میشوند؛
سامانه هرگز بهخودیخود چیزی را استنباط و ذخیره نمیکند.
</p>
<div id="memory-panel-host"></div>
</div>
<footer class="foot">
<!-- Placeholder only -- js/main.js's setMode() overwrites this immediately
on load, per the real default in web/js/state.js ("live"). -->
<span id="foot-mode" data-i18n="footLiveChecking">حالت زندهٔ API — در حال بررسی اتصال به بکاند...</span>
<span id="foot-time"></span>
</footer>
<!-- Vendored locally, no CDN (offline requirement — see web/README.md).
Display pipeline lives in web/js/sql-display.js: sql-formatter
prettifies one-liners only (multi-line sql_display / scenario SQL is
left alone), Prism + a T-SQL patch colour the block. Copy always uses
Turn.sql_display || Turn.sql, never this DOM. -->
<script src="assets/vendor/sql-formatter.min.js" defer></script>
<script src="assets/vendor/prism.min.js" data-manual defer></script>
<script src="assets/vendor/prism-sql.min.js" data-manual defer></script>
<script src="js/prism-tsql-patch.js" defer></script>
<script type="module" src="js/main.js"></script>
</body>
</html>