webapp/templates/index.html
Rama main · réplica de lectura del repositorio original.
{% extends "base.html" %}
{% block title %}{{ _("Ask · Local SQL Agent") }}{% endblock %}
{% block content %}
{% set sample_questions = [
_("Top 5 customers by purchase value in 1402"),
_("Trade volume in the cement ring in Mordad 1405"),
_("Which ring had the highest sales in 1404?"),
_("Total purchase value by month in 1405"),
_("How many active customers exist?"),
_("Sales on Thursdays in the petrochemical ring"),
_("Top 5 brokers by purchase value in 1403"),
_("Sales in the Kish export ring in 1402"),
] %}
<div class="layout">
<aside class="card sample-panel">
<h2>{{ _("Sample Questions") }}</h2>
<p class="muted sample-hint">{{ _("Click a question to ask it instantly.") }}</p>
<div class="sample-list">
{% for question in sample_questions %}
<button type="button" class="sample-btn" data-question="{{ question }}">{{ question }}</button>
{% endfor %}
</div>
</aside>
<div class="main-panel">
<section class="card ask-card">
<h2>{{ _("Ask your question") }}</h2>
<p class="muted">{{ _("English or Persian — the agent will answer against the database.") }}</p>
<form method="post" action="{{ url_for('index') }}" id="ask-form">
<!-- Enforced server-side (webapp/app.py's before_request _check_csrf,
finding 17b) -- a token-less POST here is rejected with 400. -->
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<textarea name="question" rows="4" dir="auto" required></textarea>
<div class="form-row">
<label class="check">
<input type="checkbox" name="interpret" checked>
{{ _("Show plain-language summary") }}
</label>
<div class="btn-group">
<button class="btn btn-primary" type="submit">{{ _("Ask") }}</button>
</div>
</div>
</form>
</section>
<div id="loading" class="loading" hidden>
<img src="{{ url_for('static', filename='images/spinner.svg') }}" alt="Loading">
<p class="muted">{{ _("Running your question…") }}</p>
</div>
{% if result %}
<section class="card results-card">
<div class="results-head">
<h2>{{ _("Results") }}</h2>
{% if result.status == "SUCCESS" %}
<span class="badge badge-ok">{{ _("SUCCESS") }}</span>
{% else %}
<span class="badge badge-err">{{ _("ERROR") }}</span>
{% endif %}
<span class="muted meta">{{ _("model") }}: {{ result.model or "—" }} · {% if current_lang == "fa" %}<bdi>{{ result.elapsed_seconds }}</bdi> {{ _("s") }}{% else %}{{ result.elapsed_seconds }}{{ _("s") }}{% endif %}</span>
</div>
<p class="asked" dir="auto"><strong>{{ _("Question:") }}</strong> {{ result.question }}</p>
{% if result.sql %}
<details class="sql-box">
<summary>{{ _("Generated SQL") }}</summary>
<div class="sql-body" dir="ltr">
<pre><code id="sql-code">{{ result.sql }}</code></pre>
<button type="button" class="btn btn-small btn-copy" id="copy-sql">{{ _("Copy") }}</button>
</div>
</details>
{% endif %}
{% if result.interpretation %}
<p class="interpretation" dir="auto">{{ result.interpretation }}</p>
{% endif %}
{% if result.columns %}
<div class="table-wrap">
<table>
<thead>
<tr>{% for col in result.columns %}<th dir="auto">{{ col }}</th>{% endfor %}</tr>
</thead>
<tbody id="results-body">
{% for row in result.rows %}
<tr>{% for col in result.columns %}<td dir="auto">{{ row[col] }}</td>{% endfor %}</tr>
{% endfor %}
</tbody>
</table>
</div>
<nav class="pagination" id="pagination" hidden aria-label="{{ _('Results') }}">
<button type="button" class="btn btn-small" id="page-prev">{{ _("Previous") }}</button>
<span class="page-info" id="page-info" dir="auto"></span>
<button type="button" class="btn btn-small" id="page-next">{{ _("Next") }}</button>
</nav>
<p class="meta">
{% if current_lang == "fa" %}
{{ result.row_count }} ردیف
{% else %}
{{ result.row_count }} row{{ "s" if result.row_count != 1 }}
{% endif %}
</p>
{% elif result.status == "SUCCESS" %}
<p class="muted">{{ _("No rows returned for this question.") }}</p>
{% endif %}
{% if output_filename %}
<div class="results-download">
<a class="btn btn-download btn-download-icon" href="{{ url_for('download', filename=output_filename) }}" title="{{ _('Report') }}" aria-label="{{ _('Report') }}"><svg class="icon-dl" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></a>
</div>
{% endif %}
</section>
{% endif %}
</div>
</div>
<script src="{{ url_for('static', filename='vendor/sql-formatter.min.js') }}"></script>
<script src="{{ url_for('static', filename='vendor/prism.min.js') }}"></script>
<script src="{{ url_for('static', filename='vendor/prism-sql.min.js') }}"></script>
<script>
// Guards against double submission — Enter (requestSubmit) bypasses a
// disabled button, so the flag is checked in the handler itself.
let formSubmitting = false;
document.getElementById("ask-form").addEventListener("submit", (e) => {
if (formSubmitting) {
e.preventDefault();
return;
}
formSubmitting = true;
document.getElementById("loading").hidden = false;
document.querySelector("#ask-form button[type=submit]").disabled = true;
document.querySelectorAll(".sample-btn").forEach((btn) => (btn.disabled = true));
document.querySelectorAll(".results-card, .btn-download").forEach((el) => el.remove());
});
// Enter submits the question; Shift+Enter inserts a newline (IME-safe so
// confirming a Persian composition candidate never submits mid-word).
const questionInput = document.querySelector("#ask-form textarea[name=question]");
if (questionInput) {
questionInput.addEventListener("keydown", (e) => {
if (e.key !== "Enter" || e.shiftKey || e.isComposing || e.keyCode === 229) return;
e.preventDefault();
questionInput.form.requestSubmit();
});
}
// Clicking a sample question fills the box and submits it immediately
// (the ASK button is disabled while the request is processing).
document.querySelectorAll(".sample-btn").forEach((btn) => {
btn.addEventListener("click", () => {
if (formSubmitting || !questionInput) return;
questionInput.value = btn.dataset.question;
questionInput.focus();
questionInput.form.requestSubmit();
});
});
// Beautify the generated SQL and colour its keywords (Prism) so it is
// easier to read. Falls back to the raw SQL if the libraries are missing.
const sqlCode = document.getElementById("sql-code");
if (sqlCode) {
const rawSql = sqlCode.textContent;
try {
const formatted = window.sqlFormatter.format(rawSql, {
language: "tsql",
keywordCase: "upper",
});
sqlCode.innerHTML = window.Prism.highlight(formatted, window.Prism.languages.sql, "sql");
} catch {
sqlCode.textContent = rawSql;
}
}
const copyBtn = document.getElementById("copy-sql");
if (copyBtn) {
copyBtn.addEventListener("click", async () => {
const sql = document.querySelector(".sql-body code").innerText;
try {
await navigator.clipboard.writeText(sql);
} catch {
const ta = document.createElement("textarea");
ta.value = sql;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
ta.remove();
}
const original = copyBtn.textContent;
copyBtn.textContent = "{{ _('Copied') }}";
setTimeout(() => { copyBtn.textContent = original; }, 2000);
});
}
// Paginate the results table at 20 rows per page.
const resultsBody = document.getElementById("results-body");
if (resultsBody) {
const ROWS_PER_PAGE = 10;
const rows = Array.from(resultsBody.querySelectorAll("tr"));
const pageCount = Math.max(1, Math.ceil(rows.length / ROWS_PER_PAGE));
const nav = document.getElementById("pagination");
const prevBtn = document.getElementById("page-prev");
const nextBtn = document.getElementById("page-next");
const pageInfo = document.getElementById("page-info");
const pageLabel = "{{ _('Page %(page)s of %(pages)s') }}";
let page = 1;
const renderPage = () => {
const start = (page - 1) * ROWS_PER_PAGE;
rows.forEach((row, i) => {
row.hidden = i < start || i >= start + ROWS_PER_PAGE;
});
prevBtn.disabled = page <= 1;
nextBtn.disabled = page >= pageCount;
pageInfo.textContent = pageLabel
.replace("%(page)s", String(page))
.replace("%(pages)s", String(pageCount));
};
if (pageCount > 1) {
nav.hidden = false;
prevBtn.addEventListener("click", () => { if (page > 1) { page--; renderPage(); } });
nextBtn.addEventListener("click", () => { if (page < pageCount) { page++; renderPage(); } });
renderPage();
}
}
</script>
{% endblock %}