Compare commits
10
Commits
1a88eb0251
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
652e4ae3c2 | ||
|
|
eef4003ac0 | ||
|
|
92f9f21490 | ||
|
|
7f6d71fb1e | ||
|
|
c5078cc5f8 | ||
|
|
5719bcf06e | ||
|
|
57b11b2aa9 | ||
|
|
d4917a0663 | ||
|
|
c4acb93e65 | ||
|
|
262e85634c |
@@ -409,6 +409,24 @@ def initialize_database():
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
logger.info("Migration: added idx_activity_log_time index to activity_log.")
|
logger.info("Migration: added idx_activity_log_time index to activity_log.")
|
||||||
|
|
||||||
|
# ── ai_analysis_log: add document_text column if missing ──────────────
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = 'ai_analysis_log'
|
||||||
|
AND COLUMN_NAME = 'document_text'
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
(has_doc_text,) = cursor.fetchone()
|
||||||
|
if not has_doc_text:
|
||||||
|
cursor.execute(
|
||||||
|
"ALTER TABLE ai_analysis_log "
|
||||||
|
"ADD COLUMN document_text MEDIUMTEXT NULL AFTER criteria_snapshot"
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
logger.info("Migration: added document_text column to ai_analysis_log.")
|
||||||
|
|
||||||
# ── Seed app_settings from environment variables (first-run bootstrap) ─
|
# ── Seed app_settings from environment variables (first-run bootstrap) ─
|
||||||
# Uses INSERT IGNORE so values already saved via the Admin UI are never
|
# Uses INSERT IGNORE so values already saved via the Admin UI are never
|
||||||
# overwritten — .env only fills in keys that are completely absent.
|
# overwritten — .env only fills in keys that are completely absent.
|
||||||
|
|||||||
@@ -1367,15 +1367,17 @@ def delete_criterion(admin_id: int, criterion_id: int):
|
|||||||
|
|
||||||
|
|
||||||
def save_ai_analysis(user_id: int, file_names: str, model: str,
|
def save_ai_analysis(user_id: int, file_names: str, model: str,
|
||||||
verdict, criteria_snapshot, summary_text: str) -> int:
|
verdict, criteria_snapshot, summary_text: str,
|
||||||
|
document_text: str = None) -> int:
|
||||||
conn = None
|
conn = None
|
||||||
try:
|
try:
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"INSERT INTO ai_analysis_log (user_id, file_names, model, verdict, criteria_snapshot, summary_text) "
|
"INSERT INTO ai_analysis_log "
|
||||||
"VALUES (%s,%s,%s,%s,%s,%s)",
|
"(user_id, file_names, model, verdict, criteria_snapshot, document_text, summary_text) "
|
||||||
(user_id, file_names, model, verdict, criteria_snapshot, summary_text),
|
"VALUES (%s,%s,%s,%s,%s,%s,%s)",
|
||||||
|
(user_id, file_names, model, verdict, criteria_snapshot, document_text, summary_text),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
new_id = cur.lastrowid
|
new_id = cur.lastrowid
|
||||||
|
|||||||
+187
-46
@@ -100,7 +100,8 @@ def analyze():
|
|||||||
])
|
])
|
||||||
analysis_id = save_ai_analysis(
|
analysis_id = save_ai_analysis(
|
||||||
user["id"], ", ".join(file_names), model,
|
user["id"], ", ".join(file_names), model,
|
||||||
result.get("verdict"), criteria_snap, result.get("summary", "")
|
result.get("verdict"), criteria_snap, result.get("summary", ""),
|
||||||
|
document_text=combined,
|
||||||
)
|
)
|
||||||
log_action(user["id"], "AI_ANALYSIS", "ai_analysis_log", analysis_id,
|
log_action(user["id"], "AI_ANALYSIS", "ai_analysis_log", analysis_id,
|
||||||
f"AI analysis on {len(file_names)} file(s). Verdict: {result.get('verdict')}.")
|
f"AI analysis on {len(file_names)} file(s). Verdict: {result.get('verdict')}.")
|
||||||
@@ -182,48 +183,113 @@ def _extract_text(file_obj, ext: str) -> str:
|
|||||||
# Office address used as origin for distance/travel-time estimates.
|
# Office address used as origin for distance/travel-time estimates.
|
||||||
_OFFICE_ADDRESS = "2815 Hartland Road, Falls Church, VA 22043, USA"
|
_OFFICE_ADDRESS = "2815 Hartland Road, Falls Church, VA 22043, USA"
|
||||||
|
|
||||||
# Stage 1 — extraction prompt (always sent)
|
# Document text limit per API call.
|
||||||
_EXTRACTION_PROMPT = """You are an expert government procurement analyst.
|
# Stage 1 prompt template is ~1.8 KB overhead; 14 KB of doc text keeps the total
|
||||||
The user has provided {n} document(s). Treat all provided content as a single combined source — do not analyze each file individually. Extract the following information once, consolidating details from all documents.
|
# JSON payload well under Groq's request size limit on all plan tiers.
|
||||||
|
_TEXT_LIMIT = 14_000
|
||||||
|
|
||||||
IMPORTANT — Our office is located at:
|
# Analyst persona — injected as the system message in every API call.
|
||||||
{office}
|
_SYSTEM_PROMPT = (
|
||||||
Use this as the ORIGIN address for all driving distance and travel time calculations in field #9 below.
|
"You are a senior government procurement analyst supporting a small business BD team. "
|
||||||
|
"Extract and organize solicitation information with precision. "
|
||||||
|
"Format all output in clean Markdown with tables and section headers. "
|
||||||
|
"Write \"Not specified\" for any field not found in the documents — never guess."
|
||||||
|
)
|
||||||
|
|
||||||
Extract and clearly label the following fields (write "N/A" if not found):
|
# Stage 1 — extraction prompt (always sent).
|
||||||
|
_EXTRACTION_PROMPT = """\
|
||||||
|
Analyze the {n} document(s) below as one combined source. Do not analyze files individually.
|
||||||
|
Write "Not specified in the document." for any field not found — never guess.
|
||||||
|
Our office address (use as origin for all travel estimates): {office}
|
||||||
|
|
||||||
1. Solicitation Number
|
---
|
||||||
2. Solicitation Type (e.g. RFP, RFQ, IFB, etc.)
|
|
||||||
3. Set-Aside (e.g. Small Business, 8(a), N/A)
|
|
||||||
4. Description / Scope of Work
|
|
||||||
5. Work Site / Location(s)
|
|
||||||
6. Pre-Proposal Conference / Site-Visit (date, time, full address)
|
|
||||||
7. Point of Contact (POC) (name, phone, email)
|
|
||||||
8. Total Square Footage (if applicable)
|
|
||||||
9. Driving Distance & Travel Time
|
|
||||||
- Origin: {office}
|
|
||||||
- Destination: Pre-Proposal Conference or primary Work Site address
|
|
||||||
- Provide your best estimate of driving distance (miles) and typical driving time using major highways
|
|
||||||
- Note that these are AI estimates; actual times may vary with traffic
|
|
||||||
10. Last Day to Submit Questions
|
|
||||||
11. Due Date & Time
|
|
||||||
12. Any other notable requirements or deadlines
|
|
||||||
|
|
||||||
Then provide a detailed OVERALL SUMMARY covering:
|
## Solicitation Details
|
||||||
|
|
||||||
A. Scope of Work
|
| Field | Value |
|
||||||
B. Contract Period
|
|---|---|
|
||||||
C. Proposal Submission Requirements
|
| Solicitation Number | |
|
||||||
D. Key Deadlines & Action Items
|
| Solicitation Type | (RFP / RFQ / IFB / IDIQ / BPA / etc.) |
|
||||||
|
| Set-Aside | (Small Business / 8(a) / SDVOSB / HUBZone / WOSB / Unrestricted / etc.) |
|
||||||
|
| Issuing Agency / Office | |
|
||||||
|
| Contract Type | (FFP / T&M / Cost-Plus / IDIQ / etc.) |
|
||||||
|
| Contract Period | |
|
||||||
|
| NAICS Code | |
|
||||||
|
| Estimated Value | |
|
||||||
|
| Work Site / Location(s) | |
|
||||||
|
| Total Square Footage | |
|
||||||
|
| Last Day to Submit Questions | |
|
||||||
|
| **Proposal Due Date & Time** | |
|
||||||
|
| Award / Period of Performance Start | |
|
||||||
|
|
||||||
Be precise, detailed, and use bullet points throughout.
|
---
|
||||||
If information is not explicitly stated in the documents, note it as "Not specified in the document."
|
|
||||||
|
## Pre-Proposal Conference / Site Visit
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| Date & Time | |
|
||||||
|
| Full Address | |
|
||||||
|
| Attendance | (Mandatory / Optional / N/A) |
|
||||||
|
| RSVP Required | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Point of Contact
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| Name & Title | |
|
||||||
|
| Phone | |
|
||||||
|
| Email | |
|
||||||
|
| Questions Submission Method | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Travel Estimate
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| Origin | {office} |
|
||||||
|
| Destination | (Pre-Proposal Conference address or primary work site) |
|
||||||
|
| Estimated Driving Distance | |
|
||||||
|
| Estimated Drive Time | |
|
||||||
|
|
||||||
|
*(AI estimates — verify with a mapping service before scheduling.)*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope of Work
|
||||||
|
|
||||||
|
*(Detail the required services, deliverables, and performance standards. Use bullet points.)*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notable Requirements
|
||||||
|
|
||||||
|
*(Security clearances, bonding, certifications, teaming restrictions, tight timelines, incumbent indicators, or other bid/no-bid risk factors. Use bullet points.)*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overall Summary
|
||||||
|
|
||||||
|
**A. Scope of Work:**
|
||||||
|
|
||||||
|
**B. Contract Period & Value:**
|
||||||
|
|
||||||
|
**C. Proposal Submission Requirements:**
|
||||||
|
|
||||||
|
**D. Key Deadlines & Action Items:**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
DOCUMENTS:
|
DOCUMENTS:
|
||||||
{documents}"""
|
{documents}"""
|
||||||
|
|
||||||
# Stage 2 — criteria evaluation suffix (appended only when active criteria exist)
|
# Criteria evaluation — appended to the extraction prompt in the same API call.
|
||||||
_CRITERIA_PROMPT_SUFFIX = """
|
# A single call avoids the rate-limit (429) that two back-to-back calls trigger
|
||||||
|
# on Groq's free tier.
|
||||||
|
_CRITERIA_SUFFIX = """
|
||||||
|
|
||||||
================================================================================
|
================================================================================
|
||||||
OPPORTUNITY ALIGNMENT EVALUATION
|
OPPORTUNITY ALIGNMENT EVALUATION
|
||||||
@@ -255,23 +321,21 @@ in plain business language."""
|
|||||||
|
|
||||||
|
|
||||||
def _call_groq(api_key: str, model: str, text: str, criteria: list) -> dict:
|
def _call_groq(api_key: str, model: str, text: str, criteria: list) -> dict:
|
||||||
"""Call the Groq chat completions REST API directly (no SDK required)."""
|
"""Single Groq API call: extraction + optional criteria evaluation in one request."""
|
||||||
import re
|
truncated = len(text) > _TEXT_LIMIT
|
||||||
|
doc_text = text[:_TEXT_LIMIT]
|
||||||
|
n = doc_text.count("=== ") or 1
|
||||||
|
|
||||||
n = text.count("=== ") or 1 # count file separators for the prompt header
|
|
||||||
truncated = len(text) > 14000
|
|
||||||
|
|
||||||
# Build the two-stage prompt matching the desktop app exactly
|
|
||||||
prompt = _EXTRACTION_PROMPT.format(
|
prompt = _EXTRACTION_PROMPT.format(
|
||||||
n=n, office=_OFFICE_ADDRESS, documents=text[:14000]
|
n=n, office=_OFFICE_ADDRESS, documents=doc_text
|
||||||
)
|
)
|
||||||
|
|
||||||
if criteria:
|
if criteria:
|
||||||
criteria_list = "\n".join(
|
criteria_list = "\n".join(
|
||||||
f" {i+1}. {c['title']}: {c['description']}"
|
f"{i+1}. **{c['title']}**: {c['description']}"
|
||||||
for i, c in enumerate(criteria)
|
for i, c in enumerate(criteria)
|
||||||
)
|
)
|
||||||
prompt += _CRITERIA_PROMPT_SUFFIX.format(criteria_list=criteria_list)
|
prompt += _CRITERIA_SUFFIX.format(criteria_list=criteria_list)
|
||||||
|
|
||||||
response = http_requests.post(
|
response = http_requests.post(
|
||||||
"https://api.groq.com/openai/v1/chat/completions",
|
"https://api.groq.com/openai/v1/chat/completions",
|
||||||
@@ -281,11 +345,14 @@ def _call_groq(api_key: str, model: str, text: str, criteria: list) -> dict:
|
|||||||
},
|
},
|
||||||
json={
|
json={
|
||||||
"model": model,
|
"model": model,
|
||||||
"messages": [{"role": "user", "content": prompt}],
|
"messages": [
|
||||||
|
{"role": "system", "content": _SYSTEM_PROMPT},
|
||||||
|
{"role": "user", "content": prompt},
|
||||||
|
],
|
||||||
"max_tokens": 4096,
|
"max_tokens": 4096,
|
||||||
"temperature": 0.2,
|
"temperature": 0.1,
|
||||||
},
|
},
|
||||||
timeout=90,
|
timeout=120,
|
||||||
)
|
)
|
||||||
if not response.ok:
|
if not response.ok:
|
||||||
logger.error(f"Groq API {response.status_code}: {response.text[:300]}")
|
logger.error(f"Groq API {response.status_code}: {response.text[:300]}")
|
||||||
@@ -293,7 +360,6 @@ def _call_groq(api_key: str, model: str, text: str, criteria: list) -> dict:
|
|||||||
|
|
||||||
content = response.json()["choices"][0]["message"]["content"] or ""
|
content = response.json()["choices"][0]["message"]["content"] or ""
|
||||||
|
|
||||||
# Parse the machine-readable RECOMMENDATION label (only present when criteria used)
|
|
||||||
verdict = None
|
verdict = None
|
||||||
if criteria:
|
if criteria:
|
||||||
match = re.search(
|
match = re.search(
|
||||||
@@ -358,6 +424,81 @@ def delete_criterion_view(criterion_id):
|
|||||||
return redirect(url_for("ai_summary.ai_summary"))
|
return redirect(url_for("ai_summary.ai_summary"))
|
||||||
|
|
||||||
|
|
||||||
|
@ai_summary_bp.route("/chat", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def chat():
|
||||||
|
"""Follow-up Q&A about a previously analyzed document."""
|
||||||
|
user = session["user"]
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
analysis_id = data.get("analysis_id")
|
||||||
|
messages = data.get("messages") or []
|
||||||
|
|
||||||
|
if not analysis_id:
|
||||||
|
return jsonify({"error": "analysis_id is required."}), 400
|
||||||
|
if not messages or not isinstance(messages, list):
|
||||||
|
return jsonify({"error": "messages is required."}), 400
|
||||||
|
|
||||||
|
record = get_ai_analysis_detail(int(analysis_id))
|
||||||
|
if not record:
|
||||||
|
return jsonify({"error": "Analysis not found."}), 404
|
||||||
|
|
||||||
|
api_key = (os.environ.get("GROQ_API_KEY") or "").strip() or get_setting("groq.api_key", "").strip()
|
||||||
|
if not api_key:
|
||||||
|
return jsonify({"error": "Groq API key is not configured."}), 400
|
||||||
|
|
||||||
|
model = get_setting("groq.model", GROQ_MODELS[0])
|
||||||
|
|
||||||
|
# Prefer raw document text so the AI can answer questions from source material.
|
||||||
|
# Fall back to the stored summary for analyses run before this feature was added.
|
||||||
|
raw_text = (record.get("document_text") or "").strip()
|
||||||
|
if raw_text:
|
||||||
|
context_label = "ORIGINAL DOCUMENT CONTENT"
|
||||||
|
context_body = raw_text[:12000]
|
||||||
|
else:
|
||||||
|
context_label = "ANALYZED DOCUMENT SUMMARY"
|
||||||
|
context_body = (record.get("summary_text") or "")[:8000]
|
||||||
|
|
||||||
|
system_msg = (
|
||||||
|
"You are a government procurement analyst assistant. "
|
||||||
|
"The user has questions about a solicitation document. "
|
||||||
|
"Answer based solely on the document content provided below. "
|
||||||
|
"If the information is not present, say so clearly. Be concise and direct.\n\n"
|
||||||
|
f"## {context_label}\n\n" + context_body
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sanitize and cap conversation history at 20 turns
|
||||||
|
api_messages = []
|
||||||
|
for m in messages[-20:]:
|
||||||
|
role = m.get("role") if isinstance(m, dict) else None
|
||||||
|
content = (m.get("content") or "").strip() if isinstance(m, dict) else ""
|
||||||
|
if role in ("user", "assistant") and content:
|
||||||
|
api_messages.append({"role": role, "content": content})
|
||||||
|
|
||||||
|
if not api_messages or api_messages[-1]["role"] != "user":
|
||||||
|
return jsonify({"error": "Last message must be from the user."}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = http_requests.post(
|
||||||
|
"https://api.groq.com/openai/v1/chat/completions",
|
||||||
|
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
|
||||||
|
json={
|
||||||
|
"model": model,
|
||||||
|
"messages": [{"role": "system", "content": system_msg}] + api_messages,
|
||||||
|
"max_tokens": 1024,
|
||||||
|
"temperature": 0.2,
|
||||||
|
},
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
if not response.ok:
|
||||||
|
logger.error(f"Groq chat {response.status_code}: {response.text[:200]}")
|
||||||
|
response.raise_for_status()
|
||||||
|
reply = response.json()["choices"][0]["message"]["content"] or ""
|
||||||
|
return jsonify({"reply": reply})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"AI chat error: {e}")
|
||||||
|
return jsonify({"error": f"Chat failed: {e}"}), 500
|
||||||
|
|
||||||
|
|
||||||
@ai_summary_bp.route("/history/<int:analysis_id>")
|
@ai_summary_bp.route("/history/<int:analysis_id>")
|
||||||
@login_required
|
@login_required
|
||||||
def analysis_detail(analysis_id):
|
def analysis_detail(analysis_id):
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
<td class="text-muted">{{ u.created_at.strftime('%Y-%m-%d') if u.created_at else '—' }}</td>
|
<td class="text-muted">{{ u.created_at.strftime('%Y-%m-%d') if u.created_at else '—' }}</td>
|
||||||
<td class="actions">
|
<td class="actions">
|
||||||
<button class="btn btn-ghost btn-sm js-edit-user"
|
<button class="btn btn-ghost btn-sm js-edit-user"
|
||||||
data-user="{{ u|tojson|e }}">✎ Edit</button>
|
data-user='{{ u|tojson }}'>✎ Edit</button>
|
||||||
{% if u.email %}
|
{% if u.email %}
|
||||||
<button class="btn btn-ghost btn-sm js-send-reset"
|
<button class="btn btn-ghost btn-sm js-send-reset"
|
||||||
data-id="{{ u.id }}" data-username="{{ u.username }}" data-email="{{ u.email }}"
|
data-id="{{ u.id }}" data-username="{{ u.username }}" data-email="{{ u.email }}"
|
||||||
|
|||||||
+280
-43
@@ -96,6 +96,7 @@
|
|||||||
<button class="btn btn-secondary btn-sm" id="btn-copy" onclick="copyOutput()" style="display:none">📋 Copy</button>
|
<button class="btn btn-secondary btn-sm" id="btn-copy" onclick="copyOutput()" style="display:none">📋 Copy</button>
|
||||||
<button class="btn btn-secondary btn-sm" id="btn-save" onclick="saveOutput()" style="display:none">💾 Save as TXT</button>
|
<button class="btn btn-secondary btn-sm" id="btn-save" onclick="saveOutput()" style="display:none">💾 Save as TXT</button>
|
||||||
<button class="btn btn-secondary btn-sm" id="btn-clear" onclick="clearOutput()" style="display:none">🗑 Clear</button>
|
<button class="btn btn-secondary btn-sm" id="btn-clear" onclick="clearOutput()" style="display:none">🗑 Clear</button>
|
||||||
|
<button class="btn btn-primary btn-sm" id="btn-chat-live" onclick="openChat(_liveAnalysisId)" style="display:none">💬 Chat</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -269,16 +270,33 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button class="btn btn-danger btn-sm" id="btn-delete-analysis" style="margin-right:auto;display:none" onclick="deleteAnalysis()">🗑 Delete</button>
|
<button class="btn btn-danger btn-sm" id="btn-delete-analysis" style="margin-right:auto;display:none" onclick="deleteAnalysis()">🗑 Delete</button>
|
||||||
|
<button class="btn btn-secondary btn-sm" onclick="openChat(_currentAnalysisId)">💬 Chat</button>
|
||||||
<button class="btn btn-secondary" onclick="closeModal('modal-history')">Close</button>
|
<button class="btn btn-secondary" onclick="closeModal('modal-history')">Close</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Chat Modal ────────────────────────────────────────────── -->
|
||||||
|
<div class="modal-overlay" id="modal-chat">
|
||||||
|
<div class="modal chat-modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<span class="modal-title">💬 Ask AI about this document</span>
|
||||||
|
<button class="modal-close" onclick="closeModal('modal-chat')">✕</button>
|
||||||
|
</div>
|
||||||
|
<div id="chat-messages" class="chat-messages"></div>
|
||||||
|
<div class="chat-input-row">
|
||||||
|
<input type="text" id="chat-input" class="chat-input" placeholder="Ask a question about this document…" autocomplete="off">
|
||||||
|
<button class="btn btn-primary" id="btn-chat-send" onclick="sendChatMessage()">Send</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- marked.js for markdown rendering -->
|
<!-- marked.js for markdown rendering -->
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/9.1.6/marked.min.js"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/9.1.6/marked.min.js"></script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
var IS_ADMIN = {{ 'true' if is_admin else 'false' }};
|
var IS_ADMIN = {{ 'true' if is_admin else 'false' }};
|
||||||
|
var _liveAnalysisId = null; // set after a live analysis completes
|
||||||
|
|
||||||
/* ─── Markdown renderer config ──────────────────────────────── */
|
/* ─── Markdown renderer config ──────────────────────────────── */
|
||||||
marked.setOptions({ breaks: true, gfm: true });
|
marked.setOptions({ breaks: true, gfm: true });
|
||||||
@@ -310,16 +328,6 @@ var _rawText = ''; // kept for copy/save
|
|||||||
|
|
||||||
function showResult(data) {
|
function showResult(data) {
|
||||||
_rawText = data.summary || '';
|
_rawText = data.summary || '';
|
||||||
var fileList = (data.file_names || '').split(', ').map(function(f) {
|
|
||||||
return ' - ' + f;
|
|
||||||
}).join('\n');
|
|
||||||
var criteriaNote = data.criteria_count > 0
|
|
||||||
? 'Criteria evaluated: ' + data.criteria_count
|
|
||||||
: 'No evaluation criteria configured.';
|
|
||||||
var header = 'AI Extraction | Model: ' + (data.model || '')
|
|
||||||
+ '\nFiles analyzed (' + (data.file_count || 1) + '):\n' + fileList
|
|
||||||
+ '\n' + criteriaNote
|
|
||||||
+ '\n' + '='.repeat(60);
|
|
||||||
|
|
||||||
// Verdict banner
|
// Verdict banner
|
||||||
var banner = document.getElementById('verdict-banner');
|
var banner = document.getElementById('verdict-banner');
|
||||||
@@ -331,15 +339,36 @@ function showResult(data) {
|
|||||||
banner.innerHTML = '';
|
banner.innerHTML = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render output: header as plain preformatted, then markdown body
|
// Meta pill bar (replaces old <pre> header block)
|
||||||
document.getElementById('ai-output').innerHTML =
|
var files = (data.file_names || '').split(', ').filter(Boolean);
|
||||||
'<pre class="ai-header-block">' + escHtml(header) + '</pre>'
|
var metaHtml = '<div class="ai-meta-bar">'
|
||||||
+ '<div class="ai-rendered-output">' + renderAI(_rawText) + '</div>';
|
+ '<span class="ai-meta-pill">' + escHtml(data.model || 'AI') + '</span>'
|
||||||
|
+ '<span class="ai-meta-pill">📄 ' + files.length + ' file' + (files.length !== 1 ? 's' : '')
|
||||||
|
+ (files.length <= 3 ? ': ' + files.map(escHtml).join(', ') : '') + '</span>'
|
||||||
|
+ (data.criteria_count > 0 ? '<span class="ai-meta-pill ai-meta-pill-green">✓ ' + data.criteria_count + ' criteria evaluated</span>' : '')
|
||||||
|
+ (data.truncated ? '<span class="ai-meta-pill ai-meta-pill-warn">⚠ Document truncated to first ~14 000 chars</span>' : '')
|
||||||
|
+ '</div>';
|
||||||
|
|
||||||
|
// Render markdown, then colorize evaluation keywords
|
||||||
|
var rendered = document.createElement('div');
|
||||||
|
rendered.className = 'ai-rendered-output';
|
||||||
|
rendered.innerHTML = renderAI(_rawText);
|
||||||
|
colorizeEval(rendered);
|
||||||
|
|
||||||
|
var outputEl = document.getElementById('ai-output');
|
||||||
|
outputEl.innerHTML = metaHtml;
|
||||||
|
outputEl.appendChild(rendered);
|
||||||
|
|
||||||
// Show action buttons
|
// Show action buttons
|
||||||
document.getElementById('btn-copy').style.display = '';
|
document.getElementById('btn-copy').style.display = '';
|
||||||
document.getElementById('btn-save').style.display = '';
|
document.getElementById('btn-save').style.display = '';
|
||||||
document.getElementById('btn-clear').style.display = '';
|
document.getElementById('btn-clear').style.display = '';
|
||||||
|
|
||||||
|
// Show chat button and remember which analysis is loaded
|
||||||
|
if (data.analysis_id) {
|
||||||
|
_liveAnalysisId = data.analysis_id;
|
||||||
|
document.getElementById('btn-chat-live').style.display = '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearOutput() {
|
function clearOutput() {
|
||||||
@@ -348,9 +377,11 @@ function clearOutput() {
|
|||||||
'<div class="ai-placeholder"><p>Upload a document and click <strong>Analyze with AI</strong> to begin.</p></div>';
|
'<div class="ai-placeholder"><p>Upload a document and click <strong>Analyze with AI</strong> to begin.</p></div>';
|
||||||
document.getElementById('verdict-banner').style.display = 'none';
|
document.getElementById('verdict-banner').style.display = 'none';
|
||||||
document.getElementById('verdict-banner').innerHTML = '';
|
document.getElementById('verdict-banner').innerHTML = '';
|
||||||
document.getElementById('btn-copy').style.display = 'none';
|
document.getElementById('btn-copy').style.display = 'none';
|
||||||
document.getElementById('btn-save').style.display = 'none';
|
document.getElementById('btn-save').style.display = 'none';
|
||||||
document.getElementById('btn-clear').style.display = 'none';
|
document.getElementById('btn-clear').style.display = 'none';
|
||||||
|
document.getElementById('btn-chat-live').style.display = 'none';
|
||||||
|
_liveAnalysisId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyOutput() {
|
function copyOutput() {
|
||||||
@@ -586,8 +617,9 @@ document.querySelectorAll('.history-row').forEach(function(row) {
|
|||||||
} catch(e) { /* ignore malformed snapshot */ }
|
} catch(e) { /* ignore malformed snapshot */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('hist-body').innerHTML =
|
var histBody = document.getElementById('hist-body');
|
||||||
renderAI(d.summary_text || '');
|
histBody.innerHTML = renderAI(d.summary_text || '');
|
||||||
|
colorizeEval(histBody);
|
||||||
if (IS_ADMIN) {
|
if (IS_ADMIN) {
|
||||||
document.getElementById('btn-delete-analysis').style.display = '';
|
document.getElementById('btn-delete-analysis').style.display = '';
|
||||||
}
|
}
|
||||||
@@ -638,6 +670,112 @@ function escHtml(str) {
|
|||||||
return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c];
|
return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function colorizeEval(container) {
|
||||||
|
container.querySelectorAll('li, td, p').forEach(function(el) {
|
||||||
|
if (el.querySelector('ul,ol,table,div')) return; // skip block containers
|
||||||
|
var h = el.innerHTML;
|
||||||
|
// Null-byte placeholders prevent cascading replacements
|
||||||
|
h = h.replace(/DOES NOT MEET/g, '\x00NM\x00');
|
||||||
|
h = h.replace(/PARTIALLY MEETS/g, '\x00PM\x00');
|
||||||
|
h = h.replace(/\bMEETS\b/g, '\x00M\x00');
|
||||||
|
h = h.replace(/CANNOT DETERMINE/g,'\x00CD\x00');
|
||||||
|
h = h.replace(/\x00NM\x00/g, '<span class="eval-badge eval-not-meet">DOES NOT MEET</span>');
|
||||||
|
h = h.replace(/\x00PM\x00/g, '<span class="eval-badge eval-partial">PARTIALLY MEETS</span>');
|
||||||
|
h = h.replace(/\x00M\x00/g, '<span class="eval-badge eval-meets">MEETS</span>');
|
||||||
|
h = h.replace(/\x00CD\x00/g, '<span class="eval-badge eval-unclear">CANNOT DETERMINE</span>');
|
||||||
|
h = h.replace(/Not specified in the document\./g,
|
||||||
|
'<span class="eval-missing">Not specified in the document.</span>');
|
||||||
|
el.innerHTML = h;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Chat ──────────────────────────────────────────────────── */
|
||||||
|
var _chatAnalysisId = null;
|
||||||
|
var _chatMessages = [];
|
||||||
|
|
||||||
|
function openChat(analysisId) {
|
||||||
|
if (!analysisId) { alert('No analysis loaded.'); return; }
|
||||||
|
_chatAnalysisId = analysisId;
|
||||||
|
_chatMessages = [];
|
||||||
|
document.getElementById('chat-messages').innerHTML = '';
|
||||||
|
document.getElementById('chat-input').value = '';
|
||||||
|
// Close history modal if open, then open chat
|
||||||
|
closeModal('modal-history');
|
||||||
|
openModal('modal-chat');
|
||||||
|
_appendChatMsg('assistant',
|
||||||
|
'Hi! Ask me anything about this solicitation — dates, scope, contacts, requirements, and more.');
|
||||||
|
setTimeout(function() { document.getElementById('chat-input').focus(); }, 150);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _appendChatMsg(role, content) {
|
||||||
|
var wrap = document.createElement('div');
|
||||||
|
wrap.className = 'chat-msg chat-msg-' + role;
|
||||||
|
var bubble = document.createElement('div');
|
||||||
|
bubble.className = 'chat-bubble';
|
||||||
|
bubble.innerHTML = renderAI(content);
|
||||||
|
wrap.appendChild(bubble);
|
||||||
|
var box = document.getElementById('chat-messages');
|
||||||
|
box.appendChild(wrap);
|
||||||
|
box.scrollTop = box.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _appendTyping() {
|
||||||
|
var wrap = document.createElement('div');
|
||||||
|
wrap.className = 'chat-msg chat-msg-assistant';
|
||||||
|
wrap.id = 'chat-typing';
|
||||||
|
wrap.innerHTML = '<div class="chat-bubble chat-typing"><span></span><span></span><span></span></div>';
|
||||||
|
var box = document.getElementById('chat-messages');
|
||||||
|
box.appendChild(wrap);
|
||||||
|
box.scrollTop = box.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendChatMessage() {
|
||||||
|
var input = document.getElementById('chat-input');
|
||||||
|
var text = input.value.trim();
|
||||||
|
if (!text || !_chatAnalysisId) return;
|
||||||
|
|
||||||
|
var sendBtn = document.getElementById('btn-chat-send');
|
||||||
|
input.value = '';
|
||||||
|
input.disabled = true;
|
||||||
|
sendBtn.disabled = true;
|
||||||
|
|
||||||
|
_chatMessages.push({ role: 'user', content: text });
|
||||||
|
_appendChatMsg('user', text);
|
||||||
|
_appendTyping();
|
||||||
|
|
||||||
|
try {
|
||||||
|
var resp = await fetch('/ai-summary/chat', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken() },
|
||||||
|
body: JSON.stringify({ analysis_id: _chatAnalysisId, messages: _chatMessages }),
|
||||||
|
});
|
||||||
|
var data = await resp.json();
|
||||||
|
var typing = document.getElementById('chat-typing');
|
||||||
|
if (typing) typing.remove();
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
_chatMessages.pop(); // discard the failed user message
|
||||||
|
_appendChatMsg('assistant', '⚠ ' + escHtml(data.error));
|
||||||
|
} else {
|
||||||
|
_chatMessages.push({ role: 'assistant', content: data.reply });
|
||||||
|
_appendChatMsg('assistant', data.reply);
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
var typing = document.getElementById('chat-typing');
|
||||||
|
if (typing) typing.remove();
|
||||||
|
_chatMessages.pop();
|
||||||
|
_appendChatMsg('assistant', '⚠ Request failed. Please try again.');
|
||||||
|
} finally {
|
||||||
|
input.disabled = false;
|
||||||
|
sendBtn.disabled = false;
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('chat-input').addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendChatMessage(); }
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -663,45 +801,85 @@ function escHtml(str) {
|
|||||||
.ai-placeholder{
|
.ai-placeholder{
|
||||||
padding:2rem 1.5rem;color:var(--text-muted);
|
padding:2rem 1.5rem;color:var(--text-muted);
|
||||||
}
|
}
|
||||||
.ai-header-block{
|
|
||||||
background:var(--bg-subtle);border-bottom:1px solid var(--border);
|
/* ── Meta pill bar ─────────────────────────────────────────── */
|
||||||
padding:.85rem 1.4rem;font-family:'DM Mono',monospace;font-size:.78rem;
|
|
||||||
color:var(--text-secondary);margin:0;white-space:pre-wrap;line-height:1.6;
|
|
||||||
}
|
|
||||||
.ai-meta-bar{
|
.ai-meta-bar{
|
||||||
padding:.6rem 1.5rem;font-size:.78rem;font-family:'DM Mono',monospace;
|
display:flex;flex-wrap:wrap;gap:.4rem;align-items:center;
|
||||||
color:var(--text-muted);background:var(--bg-subtle);
|
padding:.7rem 1.4rem;background:var(--bg-subtle);
|
||||||
border-bottom:1px solid var(--border);
|
border-bottom:1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
.ai-meta-pill{
|
||||||
|
display:inline-flex;align-items:center;
|
||||||
|
font-size:.72rem;font-family:'DM Mono',monospace;
|
||||||
|
background:var(--bg-card,#fff);color:var(--text-secondary);
|
||||||
|
border:1px solid var(--border);border-radius:999px;
|
||||||
|
padding:.18rem .65rem;white-space:nowrap;
|
||||||
|
}
|
||||||
|
.ai-meta-pill-green{border-color:#86efac;color:#15803d;background:#f0fdf4}
|
||||||
|
.ai-meta-pill-warn{border-color:#fcd34d;color:#92400e;background:#fffbeb}
|
||||||
|
|
||||||
/* ── Rendered markdown output ──────────────────────────────── */
|
/* ── Rendered markdown output ──────────────────────────────── */
|
||||||
.ai-rendered-output{
|
.ai-rendered-output{
|
||||||
padding:1.1rem 1.4rem 1.5rem;
|
padding:1.4rem 1.75rem 2rem;
|
||||||
font-size:.9rem;line-height:1.8;color:var(--text-secondary);
|
font-size:.9rem;line-height:1.85;color:var(--text);
|
||||||
}
|
}
|
||||||
.ai-rendered-output h1,.ai-rendered-output h2{
|
.ai-rendered-output h1{
|
||||||
font-size:1rem;font-weight:700;color:var(--text);
|
font-size:1.05rem;font-weight:800;color:var(--text);
|
||||||
margin:1.4rem 0 .5rem;padding-bottom:.3rem;
|
margin:1.8rem 0 .6rem;padding-bottom:.4rem;
|
||||||
|
border-bottom:2px solid var(--accent);
|
||||||
|
}
|
||||||
|
.ai-rendered-output h2{
|
||||||
|
font-size:.97rem;font-weight:700;color:var(--text);
|
||||||
|
margin:1.5rem 0 .5rem;padding-bottom:.3rem;
|
||||||
border-bottom:1px solid var(--border);
|
border-bottom:1px solid var(--border);
|
||||||
}
|
}
|
||||||
.ai-rendered-output h3,.ai-rendered-output h4{
|
.ai-rendered-output h3{
|
||||||
font-size:.9rem;font-weight:700;color:var(--text);margin:1rem 0 .35rem;
|
font-size:.8rem;font-weight:700;letter-spacing:.06em;text-transform:uppercase;
|
||||||
|
color:var(--text-secondary);margin:1.25rem 0 .35rem;
|
||||||
}
|
}
|
||||||
.ai-rendered-output p{margin:.35rem 0}
|
.ai-rendered-output h4{font-size:.9rem;font-weight:700;color:var(--text);margin:.9rem 0 .25rem}
|
||||||
|
.ai-rendered-output p{margin:.4rem 0}
|
||||||
.ai-rendered-output strong{color:var(--text);font-weight:700}
|
.ai-rendered-output strong{color:var(--text);font-weight:700}
|
||||||
.ai-rendered-output ul,.ai-rendered-output ol{
|
.ai-rendered-output em{font-style:italic;color:var(--text-secondary)}
|
||||||
padding-left:1.4rem;margin:.35rem 0;
|
.ai-rendered-output ul,.ai-rendered-output ol{padding-left:1.5rem;margin:.45rem 0 .65rem}
|
||||||
}
|
.ai-rendered-output li{margin:.3rem 0;line-height:1.75}
|
||||||
.ai-rendered-output li{margin:.2rem 0}
|
.ai-rendered-output hr{border:none;border-top:2px solid var(--border);margin:1.75rem 0}
|
||||||
.ai-rendered-output hr{
|
|
||||||
border:none;border-top:2px solid var(--border);margin:1.25rem 0;
|
|
||||||
}
|
|
||||||
.ai-rendered-output pre,.ai-rendered-output code{
|
.ai-rendered-output pre,.ai-rendered-output code{
|
||||||
font-family:'DM Mono',monospace;font-size:.82rem;
|
font-family:'DM Mono',monospace;font-size:.82rem;
|
||||||
background:var(--bg-subtle);border-radius:var(--r-sm);
|
background:var(--bg-subtle);border-radius:var(--r-sm);
|
||||||
}
|
}
|
||||||
.ai-rendered-output pre{padding:.75rem 1rem;overflow-x:auto}
|
.ai-rendered-output pre{padding:.75rem 1rem;overflow-x:auto;border:1px solid var(--border)}
|
||||||
.ai-rendered-output code{padding:.1rem .3rem}
|
.ai-rendered-output code{padding:.15rem .35rem;border:1px solid var(--border)}
|
||||||
|
|
||||||
|
/* Tables ── */
|
||||||
|
.ai-rendered-output table{
|
||||||
|
width:100%;border-collapse:collapse;font-size:.845rem;
|
||||||
|
margin:.75rem 0 1.25rem;border:1px solid var(--border);
|
||||||
|
border-radius:var(--r-sm);overflow:hidden;display:table;
|
||||||
|
}
|
||||||
|
.ai-rendered-output th{
|
||||||
|
background:var(--bg-subtle);color:var(--text);font-weight:700;
|
||||||
|
padding:.5rem .9rem;border-bottom:2px solid var(--border);text-align:left;
|
||||||
|
font-size:.75rem;text-transform:uppercase;letter-spacing:.05em;white-space:nowrap;
|
||||||
|
}
|
||||||
|
.ai-rendered-output td{
|
||||||
|
padding:.45rem .9rem;border-bottom:1px solid var(--border);
|
||||||
|
vertical-align:top;color:var(--text-secondary);
|
||||||
|
}
|
||||||
|
.ai-rendered-output tr:last-child td{border-bottom:none}
|
||||||
|
.ai-rendered-output tbody tr:hover td{background:var(--bg-subtle)}
|
||||||
|
.ai-rendered-output td:first-child{font-weight:600;color:var(--text);width:38%;white-space:nowrap}
|
||||||
|
|
||||||
|
/* Evaluation badges ── */
|
||||||
|
.eval-badge{
|
||||||
|
display:inline-block;font-size:.72rem;font-weight:700;
|
||||||
|
padding:.1rem .5rem;border-radius:999px;border:1px solid;letter-spacing:.02em;
|
||||||
|
}
|
||||||
|
.eval-meets{background:#f0fdf4;color:#15803d;border-color:#86efac}
|
||||||
|
.eval-not-meet{background:#fef2f2;color:#dc2626;border-color:#fca5a5}
|
||||||
|
.eval-partial{background:#fffbeb;color:#92400e;border-color:#fcd34d}
|
||||||
|
.eval-unclear{background:var(--bg-subtle);color:var(--text-secondary);border-color:var(--border)}
|
||||||
|
.eval-missing{color:var(--text-muted);font-style:italic}
|
||||||
|
|
||||||
/* ── Criteria list ─────────────────────────────────────────── */
|
/* ── Criteria list ─────────────────────────────────────────── */
|
||||||
.criteria-list{display:flex;flex-direction:column}
|
.criteria-list{display:flex;flex-direction:column}
|
||||||
@@ -749,5 +927,64 @@ function escHtml(str) {
|
|||||||
.ai-layout{grid-template-columns:1fr}
|
.ai-layout{grid-template-columns:1fr}
|
||||||
.ai-output-body{max-height:60vh}
|
.ai-output-body{max-height:60vh}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Chat modal ────────────────────────────────────────────── */
|
||||||
|
.chat-modal{
|
||||||
|
max-width:660px;width:100%;
|
||||||
|
display:flex;flex-direction:column;
|
||||||
|
height:75vh;max-height:600px;
|
||||||
|
}
|
||||||
|
.chat-messages{
|
||||||
|
flex:1;overflow-y:auto;
|
||||||
|
padding:.85rem 1.2rem;
|
||||||
|
display:flex;flex-direction:column;gap:.6rem;
|
||||||
|
background:var(--bg-subtle);
|
||||||
|
}
|
||||||
|
.chat-msg{display:flex}
|
||||||
|
.chat-msg-user{justify-content:flex-end}
|
||||||
|
.chat-msg-assistant{justify-content:flex-start}
|
||||||
|
.chat-bubble{
|
||||||
|
max-width:82%;padding:.55rem .85rem;
|
||||||
|
border-radius:1rem;font-size:.865rem;line-height:1.6;
|
||||||
|
}
|
||||||
|
.chat-msg-user .chat-bubble{
|
||||||
|
background:var(--accent);color:#fff;
|
||||||
|
border-bottom-right-radius:.25rem;
|
||||||
|
}
|
||||||
|
.chat-msg-assistant .chat-bubble{
|
||||||
|
background:var(--bg-card,#fff);color:var(--text);
|
||||||
|
border:1px solid var(--border);
|
||||||
|
border-bottom-left-radius:.25rem;
|
||||||
|
}
|
||||||
|
.chat-bubble p{margin:.2rem 0}
|
||||||
|
.chat-bubble p:first-child{margin-top:0}
|
||||||
|
.chat-bubble p:last-child{margin-bottom:0}
|
||||||
|
.chat-bubble ul,.chat-bubble ol{margin:.3rem 0;padding-left:1.2rem}
|
||||||
|
.chat-bubble li{margin:.15rem 0}
|
||||||
|
.chat-bubble strong{font-weight:700}
|
||||||
|
.chat-bubble table{border-collapse:collapse;font-size:.82rem;width:100%}
|
||||||
|
.chat-bubble th,.chat-bubble td{border:1px solid var(--border);padding:.2rem .5rem;text-align:left}
|
||||||
|
.chat-msg-user .chat-bubble strong{color:#fff}
|
||||||
|
/* Typing indicator */
|
||||||
|
.chat-typing{display:flex;align-items:center;gap:4px;padding:.55rem .75rem}
|
||||||
|
.chat-typing span{
|
||||||
|
display:inline-block;width:7px;height:7px;border-radius:50%;
|
||||||
|
background:var(--text-muted);animation:chatBounce 1.2s infinite;
|
||||||
|
}
|
||||||
|
.chat-typing span:nth-child(2){animation-delay:.2s}
|
||||||
|
.chat-typing span:nth-child(3){animation-delay:.4s}
|
||||||
|
@keyframes chatBounce{0%,80%,100%{transform:translateY(0)}40%{transform:translateY(-6px)}}
|
||||||
|
/* Input row */
|
||||||
|
.chat-input-row{
|
||||||
|
display:flex;gap:.5rem;padding:.75rem 1rem;
|
||||||
|
border-top:1px solid var(--border);background:var(--bg-card,#fff);
|
||||||
|
border-radius:0 0 var(--r) var(--r);
|
||||||
|
}
|
||||||
|
.chat-input{
|
||||||
|
flex:1;padding:.45rem .75rem;border:1px solid var(--border);
|
||||||
|
border-radius:var(--r-sm);font-family:inherit;font-size:.875rem;
|
||||||
|
background:var(--bg);color:var(--text);
|
||||||
|
}
|
||||||
|
.chat-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-light,rgba(59,130,246,.15))}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user