269 lines
15 KiB
HTML
269 lines
15 KiB
HTML
{{ define "index" }}
|
||
<!DOCTYPE html>
|
||
<html lang="{{ .Lang }}"{{ if .Theme }} data-theme="{{ .Theme }}"{{ end }}>
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>{{ .ConversationTitle }} — Enkly</title>
|
||
<!--
|
||
allowEval:false — htmx otherwise uses eval()/new Function() for hx-on:*
|
||
handlers and trigger filters, which the server's Content-Security-Policy
|
||
(script-src 'self', no 'unsafe-eval') would block, silently disabling
|
||
them. Nothing on this page needs eval. includeIndicatorStyles:false stops
|
||
htmx injecting an inline <style> block.
|
||
-->
|
||
<meta name="htmx-config" content='{"allowEval":false,"includeIndicatorStyles":false}' />
|
||
<link rel="shortcut icon" type="image/x-icon" href="/static/img/enkly-logo.png" />
|
||
<link rel="stylesheet" href="/static/css/style.css" />
|
||
<!--
|
||
Vendored locally (web/static/js/vendor/, from npm htmx.org@2.0.4 and
|
||
htmx-ext-sse@2.2.2) rather than loaded from a CDN. This is
|
||
self-hosted software: a script tag pointed at unpkg.com silently
|
||
fails with zero on-page indication whenever the host has no
|
||
outbound internet access (common for self-hosted/air-gapped
|
||
deployments) or that CDN is blocked/slow/down — and when it fails,
|
||
every hx-* attribute in this page becomes an inert, do-nothing
|
||
HTML attribute, which is exactly why clicking "Send" could
|
||
previously appear to do nothing at all. Vendoring removes the
|
||
runtime dependency on any third-party host entirely.
|
||
-->
|
||
<script src="/static/js/vendor/htmx.min.js"></script>
|
||
<script src="/static/js/vendor/htmx-sse.js"></script>
|
||
</head>
|
||
<body>
|
||
<!--
|
||
CSS-only mobile sidebar toggle: a hidden checkbox plus a <label>
|
||
acting as the hamburger button. No JavaScript is needed for this —
|
||
see .sidebar-toggle / .sidebar-backdrop in style.css.
|
||
-->
|
||
<input type="checkbox" id="sidebar-toggle" class="sidebar-toggle-input" hidden />
|
||
|
||
<div class="app-shell" hx-ext="sse">
|
||
<label for="sidebar-toggle" class="sidebar-backdrop"></label>
|
||
|
||
<!-- ============================= SIDEBAR ============================= -->
|
||
<aside class="sidebar">
|
||
<div class="sidebar-header">
|
||
<div class="brand-lockup">
|
||
<img src="/static/img/enkly-logo.png" alt="enkly" width="40" height="40" />
|
||
</div>
|
||
<label for="sidebar-toggle" class="sidebar-close" aria-label="{{ t .Lang "nav.close_sidebar" }}">×</label>
|
||
</div>
|
||
|
||
<form method="post" action="/conversations/new" class="new-chat-form">
|
||
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}" />
|
||
<button type="submit" class="new-chat-btn">
|
||
<span class="plus-icon" aria-hidden="true">+</span> {{ t .Lang "nav.new_chat" }}
|
||
</button>
|
||
</form>
|
||
|
||
<div class="sidebar-section conversation-section">
|
||
<div class="sidebar-section-title"><span>{{ t .Lang "nav.conversations" }}</span></div>
|
||
<div class="conversation-list">
|
||
{{ if .Conversations }}
|
||
{{ range .Conversations }}
|
||
<div class="conversation-item {{ if eq .ID $.ConversationID }}active{{ end }}">
|
||
<a href="/c/{{ .ID }}" class="conversation-link">{{ .Title }}</a>
|
||
<div class="conversation-actions">
|
||
<form method="post" action="/conversations/{{ .ID }}/rename" class="rename-form">
|
||
<input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}" />
|
||
<input type="text" name="title" value="{{ .Title }}" class="rename-input" aria-label="Rename conversation" />
|
||
<button type="submit" class="icon-btn" aria-label="{{ t $.Lang "nav.rename_save" }}" title="{{ t $.Lang "nav.rename_save" }}">✓</button>
|
||
</form>
|
||
<form method="post" action="/conversations/{{ .ID }}/delete" class="delete-form">
|
||
<input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}" />
|
||
<button type="submit" class="icon-btn icon-btn-danger" aria-label="{{ t $.Lang "nav.delete" }}" title="{{ t $.Lang "nav.delete" }}">×</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
{{ end }}
|
||
{{ else }}
|
||
<div class="conversation-item-empty">{{ t .Lang "nav.no_conversations" }}</div>
|
||
{{ end }}
|
||
</div>
|
||
</div>
|
||
|
||
<!--<div class="sidebar-section files-section">
|
||
<div class="sidebar-section-title">
|
||
<span>Indexed files</span>
|
||
<span>{{ len .Files }}</span>
|
||
</div>
|
||
<div class="file-list">
|
||
{{ if .Files }}
|
||
{{ range .Files }}
|
||
<div class="file-item">
|
||
<span class="file-icon">›</span>
|
||
<span>{{ . }}</span>
|
||
</div>
|
||
{{ end }}
|
||
{{ else }}
|
||
<div class="file-item file-item-empty">
|
||
No files indexed yet. Run the indexer to populate pgvector.
|
||
</div>
|
||
{{ end }}
|
||
</div>
|
||
</div>-->
|
||
|
||
{{ template "explorer_panel" . }}
|
||
|
||
{{ template "workspace_files_panel" . }}
|
||
|
||
<div class="sidebar-footer">
|
||
<a href="/subscription" class="plan-chip" title="Manage your subscription">
|
||
<span class="plan-dot"></span>
|
||
<span>{{ .PlanName }} {{ t .Lang "nav.plan_suffix" }}</span>
|
||
<span class="plan-chip-manage">{{ t .Lang "nav.manage" }}</span>
|
||
</a>
|
||
<div class="user-chip">
|
||
<span class="user-avatar" aria-hidden="true">{{ if .UserEmail }}{{ slice .UserEmail 0 1 }}{{ end }}</span>
|
||
<span class="user-email" title="{{ .UserEmail }}">{{ .UserEmail }}</span>
|
||
<form method="post" action="/logout">
|
||
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}" />
|
||
<button class="logout-btn" type="submit">{{ t .Lang "nav.log_out" }}</button>
|
||
</form>
|
||
</div>
|
||
<form method="post" action="/theme" class="theme-toggle" aria-label="Color theme">
|
||
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}" />
|
||
<input type="hidden" name="next" value="/c/{{ .ConversationID }}" />
|
||
<button type="submit" name="theme" value="light" class="theme-btn{{ if eq .Theme "light" }} is-active{{ end }}" title="Light theme">{{ t .Lang "nav.theme_light" }}</button>
|
||
<button type="submit" name="theme" value="dark" class="theme-btn{{ if eq .Theme "dark" }} is-active{{ end }}" title="Dark theme">{{ t .Lang "nav.theme_dark" }}</button>
|
||
<button type="submit" name="theme" value="auto" class="theme-btn{{ if not .Theme }} is-active{{ end }}" title="Follow the system setting">{{ t .Lang "nav.theme_auto" }}</button>
|
||
</form>
|
||
{{ template "lang_switcher" (dict "CSRFToken" .CSRFToken "Lang" .Lang "Next" (print "/c/" .ConversationID)) }}
|
||
<span class="model-tag">local agent · tools enabled</span>
|
||
</div>
|
||
</aside>
|
||
|
||
<!-- ============================ MAIN COLUMN =========================== -->
|
||
<main class="main-col">
|
||
<div class="chat-topbar">
|
||
<label for="sidebar-toggle" class="sidebar-open" aria-label="{{ t .Lang "nav.open_sidebar" }}">☰</label>
|
||
<h2>{{ .ConversationTitle }}</h2>
|
||
<!-- Opens the command palette (app.js): search chats, jump to actions. -->
|
||
<button type="button" class="cmd-trigger" id="cmd-trigger" aria-haspopup="dialog" aria-label="Search conversations and actions">
|
||
<span class="glyph" aria-hidden="true"></span>
|
||
<span class="cmd-trigger-label">{{ t .Lang "nav.search_placeholder" }}</span>
|
||
<kbd>⌘K</kbd>
|
||
</button>
|
||
<span class="model-badge">{{ t .Lang "nav.agent_ready" }}</span>
|
||
</div>
|
||
|
||
<div class="chat-window" id="chat-window">
|
||
{{ if .Messages }}
|
||
{{ range .Messages }}
|
||
{{ template "message" . }}
|
||
{{ end }}
|
||
{{ with .ActiveStream }}{{ template "assistant_stream" . }}{{ end }}
|
||
{{ else }}
|
||
<div class="empty-state">
|
||
<div class="hero">
|
||
<span class="hero-mark" aria-hidden="true"><span class="glyph"></span></span>
|
||
<h3>{{ t .Lang "hero.title" }}</h3>
|
||
<p>{{ t .Lang "hero.subtitle" }}</p>
|
||
</div>
|
||
|
||
<h4 class="block-title">{{ t .Lang "hero.start_with" }}</h4>
|
||
<div class="starters">
|
||
<button type="button" class="starter" style="--k: var(--k-read)" data-prompt="Give me a tour of this workspace: the main folders, how they fit together, and where I should start reading." data-send="true">
|
||
<span class="starter-icon"><span class="glyph" style="--glyph: var(--i-folder)"></span></span>
|
||
<span class="starter-title">{{ t .Lang "starter.tour_title" }}</span>
|
||
<span class="starter-desc">{{ t .Lang "starter.tour_desc" }}</span>
|
||
</button>
|
||
<button type="button" class="starter" style="--k: var(--k-search)" data-prompt="Find where authentication is handled and explain how a request flows through it." data-send="true">
|
||
<span class="starter-icon"><span class="glyph" style="--glyph: var(--i-search)"></span></span>
|
||
<span class="starter-title">{{ t .Lang "starter.trace_title" }}</span>
|
||
<span class="starter-desc">{{ t .Lang "starter.trace_desc" }}</span>
|
||
</button>
|
||
<button type="button" class="starter" style="--k: var(--k-write)" data-prompt="Fix this bug: ">
|
||
<span class="starter-icon"><span class="glyph" style="--glyph: var(--i-edit)"></span></span>
|
||
<span class="starter-title">{{ t .Lang "starter.fix_title" }}</span>
|
||
<span class="starter-desc">{{ t .Lang "starter.fix_desc" }}</span>
|
||
</button>
|
||
<button type="button" class="starter" style="--k: var(--k-run)" data-prompt="Run the tests and summarise any failures with a suggested fix for each." data-send="true">
|
||
<span class="starter-icon"><span class="glyph" style="--glyph: var(--i-term)"></span></span>
|
||
<span class="starter-title">{{ t .Lang "starter.tests_title" }}</span>
|
||
<span class="starter-desc">{{ t .Lang "starter.tests_desc" }}</span>
|
||
</button>
|
||
</div>
|
||
|
||
<h4 class="block-title">{{ t .Lang "hero.tools_heading" }}</h4>
|
||
<div class="tools-strip">
|
||
<span class="tool-chip" style="--k: var(--k-read)"><span class="glyph" style="--glyph: var(--i-file)"></span>{{ t .Lang "tool.read" }}</span>
|
||
<span class="tool-chip" style="--k: var(--k-write)"><span class="glyph" style="--glyph: var(--i-edit)"></span>{{ t .Lang "tool.write" }}</span>
|
||
<span class="tool-chip" style="--k: var(--k-search)"><span class="glyph" style="--glyph: var(--i-search)"></span>{{ t .Lang "tool.search" }}</span>
|
||
<span class="tool-chip" style="--k: var(--k-run)"><span class="glyph" style="--glyph: var(--i-term)"></span>{{ t .Lang "tool.run" }}</span>
|
||
<span class="tool-chip" style="--k: var(--k-agent)"><span class="glyph" style="--glyph: var(--i-spark)"></span>{{ t .Lang "tool.subagents" }}</span>
|
||
</div>
|
||
|
||
<h4 class="block-title">{{ t .Lang "hero.explore_heading" }}</h4>
|
||
<div class="explore">
|
||
<button type="button" class="explore-chip" data-prompt="Write unit tests for ">{{ t .Lang "explore.tests" }}</button>
|
||
<button type="button" class="explore-chip" data-prompt="Review this code for bugs and style issues: ">{{ t .Lang "explore.review" }}</button>
|
||
<button type="button" class="explore-chip" data-prompt="Explain what this file does: ">{{ t .Lang "explore.explain" }}</button>
|
||
<button type="button" class="explore-chip" data-prompt="Create a Word document summarising ">{{ t .Lang "explore.document" }}</button>
|
||
<button type="button" class="explore-chip" data-prompt="Refactor this to be simpler: ">{{ t .Lang "explore.refactor" }}</button>
|
||
</div>
|
||
</div>
|
||
{{ end }}
|
||
</div>
|
||
|
||
<div class="composer">
|
||
<!--
|
||
Staged-attachment tray: empty (and hidden via .composer-attachments:empty
|
||
in style.css) until app.js adds a chip for a picked/dropped/pasted file.
|
||
See the "Composer attachments" section of web/static/js/app.js and
|
||
internal/handlers/attachments.go.
|
||
-->
|
||
<div class="composer-attachments" id="composer-attachments" aria-live="polite"></div>
|
||
<form
|
||
class="composer-form"
|
||
data-empty="true"
|
||
hx-post="/chat"
|
||
hx-target="#chat-window"
|
||
hx-swap="beforeend"
|
||
>
|
||
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}" />
|
||
<input type="hidden" name="conversation_id" value="{{ .ConversationID }}" />
|
||
<!--
|
||
Unnamed on purpose: this input is never submitted as part of the
|
||
form itself. app.js uploads each selected file individually (over
|
||
fetch, to POST /files/attach) the moment it's chosen, then adds a
|
||
separate hidden "attachments" input per successful upload below.
|
||
-->
|
||
<input type="file" id="composer-file-input" class="composer-file-input" multiple hidden tabindex="-1" aria-hidden="true" />
|
||
<button type="button" class="attach-btn" id="attach-btn" aria-label="{{ t .Lang "composer.attach" }}" title="{{ t .Lang "composer.attach" }}">
|
||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
||
</button>
|
||
<textarea
|
||
name="prompt"
|
||
id="composer-input"
|
||
rows="1"
|
||
placeholder="{{ t .Lang "composer.placeholder" }}"
|
||
></textarea>
|
||
<button class="send-btn" type="submit" aria-label="{{ t .Lang "composer.send" }}">
|
||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5M5 12l7-7 7 7"/></svg>
|
||
<span class="send-btn-label">{{ t .Lang "composer.send" }}</span>
|
||
</button>
|
||
</form>
|
||
<div class="composer-hint">
|
||
<span class="hint-kbd"><kbd>Enter</kbd> {{ t .Lang "composer.hint_send" }}</span>
|
||
<span class="hint-kbd"><kbd>Shift</kbd><kbd>Enter</kbd> {{ t .Lang "composer.hint_newline" }}</span>
|
||
<span>{{ t .Lang "composer.hint_drop" }}</span>
|
||
</div>
|
||
</div>
|
||
<div id="status-bar" class="status-bar" hx-get="/status?conversation_id={{ .ConversationID }}" hx-trigger="load" hx-swap="outerHTML"></div>
|
||
</main>
|
||
</div>
|
||
|
||
<!--
|
||
Small, same-origin enhancement layer: auto-scroll, auto-resizing
|
||
textarea, Enter-to-send, clearing the composer after a send, the copy
|
||
button on code blocks, and a fetch-based Stop button. Everything except
|
||
sending a chat message (which needs htmx anyway) still works with this
|
||
file blocked. See web/static/js/app.js.
|
||
-->
|
||
<script src="/static/js/app.js"></script>
|
||
</body>
|
||
</html>
|
||
{{ end }}
|