Files
enkly-ai/services/gateway/web/templates/index.html
enkly 79ce8dc4c0
Some checks failed
Go CI Pipeline / build_and_test (push) Failing after 22m0s
Init Project
2026-10-30 18:06:55 +01:00

269 lines
15 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{{ 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 }}