mirror of
https://github.com/Heretek-AI/cod.ai.git
synced 2026-11-07 10:46:11 -10:00
- Web GUI: a Chat / Agent mode selector. Agent mode runs the coding agent on `cod serve --root`, shows tool calls as cards, and asks for approval in the browser; sessions live in .cod/sessions/ and resume after a restart. Closing the stream stops the turn. - The API now rejects requests whose Host isn't loopback (DNS rebinding) and POSTs without the per-process token embedded in the page. - `explore` tool: a sub-agent on an in-memory session with only read_file, glob and grep, returning a report; its usage counts toward the turn. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_015NGPpYxuPWkyJUoefut3Wo
1065 lines
43 KiB
HTML
1065 lines
43 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<meta name="cod-token" content="__COD_API_TOKEN__" />
|
|
<title>cod.ai chat</title>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&family=Newsreader:opsz,[email protected],400;6..72,500&display=swap" rel="stylesheet" />
|
|
<style>
|
|
:root {
|
|
--bg-canvas: #faf9f5;
|
|
--bg-surface-soft: #f5f0e8;
|
|
--bg-surface-card: #efe9de;
|
|
--bg-surface-strong: #e8e0d2;
|
|
--bg-code: #181715;
|
|
--text-code: #faf9f5;
|
|
--accent-primary: #cc785c;
|
|
--accent-primary-active: #a9583e;
|
|
--accent-disabled: #e6dfd8;
|
|
--text-ink: #141413;
|
|
--text-body: #3d3d3a;
|
|
--text-muted: #6c6a64;
|
|
--border-hairline: #d1cfc5;
|
|
--accent-olive: #788c5d;
|
|
--accent-fig: #c46686;
|
|
--font-prose: 'Newsreader', Georgia, serif;
|
|
--font-ui: 'Inter', system-ui, -apple-system, sans-serif;
|
|
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
|
|
}
|
|
@media (prefers-color-scheme: dark) {
|
|
:root:not([data-theme="light"]) {
|
|
--bg-canvas: #181715;
|
|
--bg-surface-soft: #1f1e1b;
|
|
--bg-surface-card: #252320;
|
|
--bg-surface-strong: #2f2d29;
|
|
--bg-code: #111110;
|
|
--accent-primary: #d97757;
|
|
--accent-primary-active: #c6613f;
|
|
--accent-disabled: #3a3733;
|
|
--text-ink: #faf9f5;
|
|
--text-body: #d1cfc5;
|
|
--text-muted: #a09d96;
|
|
--border-hairline: #33312d;
|
|
--accent-olive: #8fa870;
|
|
--accent-fig: #d97a9a;
|
|
}
|
|
}
|
|
:root[data-theme="dark"] {
|
|
--bg-canvas: #181715;
|
|
--bg-surface-soft: #1f1e1b;
|
|
--bg-surface-card: #252320;
|
|
--bg-surface-strong: #2f2d29;
|
|
--bg-code: #111110;
|
|
--accent-primary: #d97757;
|
|
--accent-primary-active: #c6613f;
|
|
--accent-disabled: #3a3733;
|
|
--text-ink: #faf9f5;
|
|
--text-body: #d1cfc5;
|
|
--text-muted: #a09d96;
|
|
--border-hairline: #33312d;
|
|
--accent-olive: #8fa870;
|
|
--accent-fig: #d97a9a;
|
|
}
|
|
|
|
* { box-sizing: border-box; }
|
|
html, body { height: 100%; }
|
|
body {
|
|
margin: 0;
|
|
background: var(--bg-canvas);
|
|
color: var(--text-ink);
|
|
font: 14px/1.45 var(--font-ui);
|
|
display: flex;
|
|
}
|
|
button, select, textarea { font: inherit; color: inherit; }
|
|
button { cursor: pointer; }
|
|
:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 2px; }
|
|
|
|
/* Sidebar */
|
|
#sidebar {
|
|
width: 260px;
|
|
flex-shrink: 0;
|
|
background: var(--bg-surface-soft);
|
|
border-right: 1px solid var(--border-hairline);
|
|
display: flex;
|
|
flex-direction: column;
|
|
padding: 12px;
|
|
gap: 8px;
|
|
}
|
|
#sidebar .brand { font: 500 20px/1.2 var(--font-prose); padding: 4px 6px 8px; }
|
|
#conversation-list { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 2px; }
|
|
.conv-item {
|
|
display: flex; align-items: center; gap: 4px;
|
|
border-radius: 8px;
|
|
}
|
|
.conv-item button.open {
|
|
flex: 1; min-width: 0; text-align: left; background: none; border: 0;
|
|
padding: 7px 8px; border-radius: 8px;
|
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
color: var(--text-body);
|
|
}
|
|
.conv-item.active button.open, .conv-item button.open:hover { background: var(--bg-surface-strong); color: var(--text-ink); }
|
|
.conv-item button.delete {
|
|
background: none; border: 0; color: var(--text-muted); padding: 4px 8px; border-radius: 6px; visibility: hidden;
|
|
}
|
|
.conv-item:hover button.delete, .conv-item.active button.delete { visibility: visible; }
|
|
.conv-item button.delete:hover { color: var(--accent-fig); }
|
|
|
|
.btn {
|
|
border: 1px solid var(--border-hairline);
|
|
background: var(--bg-canvas);
|
|
border-radius: 8px;
|
|
padding: 7px 12px;
|
|
}
|
|
.btn:hover { background: var(--bg-surface-strong); }
|
|
.btn-primary {
|
|
border: 0; border-radius: 10px; padding: 8px 14px;
|
|
background: var(--accent-primary); color: #fff; font-weight: 500;
|
|
}
|
|
.btn-primary:hover { background: var(--accent-primary-active); }
|
|
.btn-primary:disabled { background: var(--accent-disabled); color: var(--text-muted); cursor: default; }
|
|
|
|
/* Main column */
|
|
main { flex: 1; min-width: 0; display: flex; flex-direction: column; height: 100%; }
|
|
header {
|
|
display: flex; align-items: center; gap: 8px;
|
|
padding: 8px 16px; border-bottom: 1px solid var(--border-hairline);
|
|
min-height: 48px;
|
|
}
|
|
header .title { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text-muted); }
|
|
#btn-sidebar { display: none; }
|
|
|
|
#scroll { flex: 1; overflow-y: auto; padding: 24px 16px; }
|
|
#messages { max-width: 760px; margin: 0 auto; display: flex; flex-direction: column; gap: 24px; }
|
|
#empty-state { max-width: 760px; margin: 12vh auto 0; text-align: center; }
|
|
#empty-state h1 { font: 400 34px/1.2 var(--font-prose); margin: 0 0 8px; }
|
|
#empty-state p { color: var(--text-muted); margin: 0; }
|
|
|
|
.msg-user {
|
|
align-self: flex-end; max-width: 85%;
|
|
background: var(--bg-surface-strong); border-radius: 16px; padding: 10px 14px;
|
|
white-space: pre-wrap; overflow-wrap: anywhere;
|
|
}
|
|
.msg-user.excluded { opacity: .6; }
|
|
.msg-assistant { font: 16.5px/1.65 var(--font-prose); color: var(--text-ink); overflow-wrap: anywhere; }
|
|
.msg-assistant p { margin: 0 0 .8em; }
|
|
.msg-assistant h1, .msg-assistant h2, .msg-assistant h3, .msg-assistant h4 { font-weight: 500; line-height: 1.3; margin: 1.1em 0 .5em; }
|
|
.msg-assistant ul, .msg-assistant ol { margin: 0 0 .8em; padding-left: 1.4em; }
|
|
.msg-assistant a { color: var(--accent-primary); }
|
|
.msg-assistant code {
|
|
font: 13px/1.5 var(--font-mono); background: var(--bg-surface-card);
|
|
padding: 1px 5px; border-radius: 4px;
|
|
}
|
|
.code-block { margin: 0 0 .9em; border-radius: 12px; overflow: hidden; background: var(--bg-code); }
|
|
.code-block .bar {
|
|
display: flex; justify-content: space-between; align-items: center;
|
|
padding: 4px 10px; font: 12px var(--font-mono); color: #a09d96;
|
|
}
|
|
.code-block .bar button { background: none; border: 0; color: #a09d96; font: 12px var(--font-ui); }
|
|
.code-block .bar button:hover { color: #faf9f5; }
|
|
.code-block pre { margin: 0; padding: 4px 14px 14px; overflow-x: auto; }
|
|
.code-block pre code { background: none; padding: 0; color: var(--text-code); }
|
|
|
|
details.thinking {
|
|
margin: 0 0 12px; border: 1px solid var(--border-hairline); border-radius: 12px;
|
|
background: var(--bg-surface-soft); font: 13.5px/1.55 var(--font-ui);
|
|
}
|
|
details.thinking summary { padding: 8px 12px; color: var(--text-muted); cursor: pointer; }
|
|
details.thinking .body { padding: 0 12px 10px; color: var(--text-body); white-space: pre-wrap; }
|
|
|
|
.notice {
|
|
font: 13px/1.45 var(--font-ui); border-radius: 10px; padding: 8px 12px; margin: 0 0 10px;
|
|
background: var(--bg-surface-card); color: var(--text-body);
|
|
}
|
|
.notice.error { border-left: 3px solid var(--accent-fig); }
|
|
.meta { font: 12px/1.4 var(--font-mono); color: var(--text-muted); margin-top: 4px; }
|
|
|
|
/* Agent tool calls */
|
|
.tool {
|
|
margin: 0 0 12px; border: 1px solid var(--border-hairline); border-radius: 12px;
|
|
background: var(--bg-surface-soft); font: 13px/1.5 var(--font-ui); overflow: hidden;
|
|
}
|
|
.tool .head { display: flex; gap: 8px; align-items: baseline; padding: 8px 12px; }
|
|
.tool .name { font-weight: 600; color: var(--text-ink); }
|
|
.tool .summary { font-family: var(--font-mono); font-size: 12.5px; color: var(--text-body); overflow-wrap: anywhere; white-space: pre-wrap; }
|
|
.tool .state { margin-left: auto; color: var(--text-muted); font-size: 12px; white-space: nowrap; }
|
|
.tool.error .state { color: var(--accent-fig); }
|
|
.tool details { border-top: 1px solid var(--border-hairline); }
|
|
.tool details summary { padding: 6px 12px; color: var(--text-muted); cursor: pointer; font-size: 12px; }
|
|
.tool pre {
|
|
margin: 0; padding: 0 12px 10px; max-height: 320px; overflow: auto;
|
|
font: 12px/1.5 var(--font-mono); white-space: pre-wrap; overflow-wrap: anywhere; color: var(--text-body);
|
|
}
|
|
.tool .approve { border-top: 1px solid var(--border-hairline); padding: 10px 12px; background: var(--bg-surface-card); }
|
|
.tool .approve .question { margin-bottom: 8px; color: var(--text-ink); }
|
|
.tool .approve .diff { margin: 0 0 8px; font: 12px/1.5 var(--font-mono); white-space: pre-wrap; max-height: 240px; overflow: auto; }
|
|
.tool .approve .diff .del { color: var(--accent-fig); }
|
|
.tool .approve .diff .add { color: var(--accent-olive); }
|
|
.tool .approve .buttons { display: flex; gap: 8px; flex-wrap: wrap; }
|
|
.cursor::after { content: "▍"; color: var(--accent-primary); animation: blink 1s steps(2) infinite; }
|
|
@keyframes blink { 50% { opacity: 0; } }
|
|
|
|
/* Composer */
|
|
#composer-wrap { padding: 0 16px 16px; }
|
|
#composer {
|
|
max-width: 760px; margin: 0 auto; background: var(--bg-surface-card);
|
|
border: 1px solid var(--border-hairline); border-radius: 16px; padding: 10px;
|
|
}
|
|
#prompt {
|
|
width: 100%; resize: none; border: 0; outline: 0; background: transparent;
|
|
min-height: 44px; max-height: 240px; padding: 4px 6px; font-size: 15px;
|
|
}
|
|
#composer .row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
|
#composer .row .spacer { flex: 1; }
|
|
#composer select {
|
|
border: 1px solid var(--border-hairline); background: var(--bg-canvas);
|
|
border-radius: 999px; padding: 4px 10px; font-size: 12.5px;
|
|
}
|
|
#composer label { font-size: 12px; color: var(--text-muted); display: flex; align-items: center; gap: 4px; }
|
|
|
|
#toast {
|
|
position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%);
|
|
background: var(--text-ink); color: var(--bg-canvas); padding: 8px 14px; border-radius: 10px;
|
|
font-size: 13px; opacity: 0; pointer-events: none; transition: opacity .2s;
|
|
}
|
|
#toast.show { opacity: 1; }
|
|
|
|
@media (max-width: 760px) {
|
|
#sidebar { position: fixed; inset: 0 auto 0 0; z-index: 10; transform: translateX(-100%); transition: transform .2s; }
|
|
body.sidebar-open #sidebar { transform: none; box-shadow: 0 0 0 100vmax rgba(0,0,0,.3); }
|
|
#btn-sidebar { display: inline-block; }
|
|
.msg-user { max-width: 100%; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<aside id="sidebar" aria-label="Conversations">
|
|
<div class="brand">cod.ai</div>
|
|
<button id="btn-new" type="button" class="btn-primary">New chat</button>
|
|
<nav id="conversation-list"></nav>
|
|
</aside>
|
|
|
|
<main>
|
|
<header>
|
|
<button id="btn-sidebar" type="button" class="btn" aria-label="Show conversations">☰</button>
|
|
<div class="title" id="conv-title">New chat</div>
|
|
<button id="btn-theme" type="button" class="btn" aria-label="Toggle dark mode">Theme</button>
|
|
</header>
|
|
|
|
<div id="scroll">
|
|
<div id="empty-state">
|
|
<h1>What are we building?</h1>
|
|
<p id="empty-sub">Replies stream from the Claude API through the local cod.ai server.</p>
|
|
</div>
|
|
<div id="messages" aria-live="polite"></div>
|
|
</div>
|
|
|
|
<div id="composer-wrap">
|
|
<form id="composer">
|
|
<textarea id="prompt" rows="2" placeholder="Message Claude… (Enter to send, Shift+Enter for a new line)" aria-label="Message"></textarea>
|
|
<div class="row">
|
|
<select id="select-mode" aria-label="Mode">
|
|
<option value="chat">Chat</option>
|
|
<option value="agent">Agent</option>
|
|
<option value="agent-edits">Agent, accept edits</option>
|
|
</select>
|
|
<select id="select-model" aria-label="Model"></select>
|
|
<label>Effort <select id="select-effort" aria-label="Effort"></select></label>
|
|
<label>Thinking <select id="select-thinking" aria-label="Thinking display">
|
|
<option value="summarized">summarized</option>
|
|
<option value="omitted">hidden</option>
|
|
</select></label>
|
|
<span class="spacer"></span>
|
|
<button id="btn-send" type="submit" class="btn-primary" disabled>Send</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</main>
|
|
|
|
<div id="toast" role="status"></div>
|
|
|
|
<script>
|
|
(function () {
|
|
"use strict";
|
|
|
|
var STORAGE_KEY = "cod.ai.conversations.v1";
|
|
var THEME_KEY = "cod.ai.theme";
|
|
|
|
var config = {
|
|
default_model: "claude-opus-5-5",
|
|
models: [{ id: "claude-opus-5-5", label: "Claude Opus 5.5", default_effort: "medium" }],
|
|
effort_levels: ["low", "medium", "high", "xhigh", "max"]
|
|
};
|
|
var conversations = loadConversations();
|
|
var activeId = conversations.length ? conversations[0].id : null;
|
|
var inflight = null; // { controller, turn }
|
|
var tokenMeta = document.querySelector('meta[name="cod-token"]');
|
|
var API_TOKEN = tokenMeta ? tokenMeta.content : "";
|
|
|
|
var el = {
|
|
list: document.getElementById("conversation-list"),
|
|
messages: document.getElementById("messages"),
|
|
empty: document.getElementById("empty-state"),
|
|
scroll: document.getElementById("scroll"),
|
|
title: document.getElementById("conv-title"),
|
|
form: document.getElementById("composer"),
|
|
prompt: document.getElementById("prompt"),
|
|
send: document.getElementById("btn-send"),
|
|
model: document.getElementById("select-model"),
|
|
effort: document.getElementById("select-effort"),
|
|
thinking: document.getElementById("select-thinking"),
|
|
mode: document.getElementById("select-mode"),
|
|
toast: document.getElementById("toast")
|
|
};
|
|
|
|
// ---------- storage ----------
|
|
|
|
function loadConversations() {
|
|
try {
|
|
var raw = localStorage.getItem(STORAGE_KEY);
|
|
var parsed = raw ? JSON.parse(raw) : [];
|
|
return Array.isArray(parsed) ? parsed : [];
|
|
} catch (e) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function saveConversations() {
|
|
try {
|
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(conversations));
|
|
} catch (e) {
|
|
toast("Couldn't save this conversation in the browser (storage is full or blocked).");
|
|
}
|
|
}
|
|
|
|
function activeConversation() {
|
|
for (var i = 0; i < conversations.length; i++) {
|
|
if (conversations[i].id === activeId) return conversations[i];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function newConversation(firstText) {
|
|
var conv = {
|
|
id: "c" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
|
|
title: firstText.replace(/\s+/g, " ").trim().slice(0, 60) || "New chat",
|
|
createdAt: Date.now(),
|
|
mode: el.mode.value,
|
|
turns: []
|
|
};
|
|
conversations.unshift(conv);
|
|
activeId = conv.id;
|
|
return conv;
|
|
}
|
|
|
|
// The history sent to the API: completed user/assistant pairs, assistant turns
|
|
// echoed exactly as the server returned them. Failed, refused or stopped turns
|
|
// are left out so the conversation stays append-only and well formed.
|
|
function apiMessages(conv) {
|
|
var out = [];
|
|
conv.turns.forEach(function (t) {
|
|
if (!t.included) return;
|
|
if (t.role === "user") out.push({ role: "user", content: t.text });
|
|
else out.push({ role: "assistant", content: t.history });
|
|
});
|
|
return out;
|
|
}
|
|
|
|
// ---------- rendering ----------
|
|
|
|
// Everything below builds DOM nodes and sets text with textContent, so
|
|
// model output is never parsed as HTML.
|
|
function h(tag, className, text) {
|
|
var node = document.createElement(tag);
|
|
if (className) node.className = className;
|
|
if (text != null) node.textContent = text;
|
|
return node;
|
|
}
|
|
|
|
function safeHttpUrl(raw) {
|
|
try {
|
|
var url = new URL(raw);
|
|
return url.protocol === "http:" || url.protocol === "https:" ? url.href : null;
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
var INLINE = /`([^`\n]+)`|\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)|\*\*([^*]+)\*\*|\*([^*\s][^*]*)\*/g;
|
|
|
|
// Appends inline Markdown (code, links, bold, italics) to a parent node.
|
|
function appendInline(parent, text) {
|
|
var last = 0;
|
|
var m;
|
|
INLINE.lastIndex = 0;
|
|
while ((m = INLINE.exec(text))) {
|
|
if (m.index > last) parent.appendChild(document.createTextNode(text.slice(last, m.index)));
|
|
if (m[1] != null) {
|
|
parent.appendChild(h("code", null, m[1]));
|
|
} else if (m[2] != null) {
|
|
var href = safeHttpUrl(m[3]);
|
|
if (href) {
|
|
var a = h("a", null, m[2]);
|
|
a.href = href;
|
|
a.target = "_blank";
|
|
a.rel = "noopener noreferrer";
|
|
parent.appendChild(a);
|
|
} else {
|
|
parent.appendChild(document.createTextNode(m[0]));
|
|
}
|
|
} else if (m[4] != null) {
|
|
parent.appendChild(h("strong", null, m[4]));
|
|
} else {
|
|
parent.appendChild(h("em", null, m[5]));
|
|
}
|
|
last = INLINE.lastIndex;
|
|
}
|
|
if (last < text.length) parent.appendChild(document.createTextNode(text.slice(last)));
|
|
}
|
|
|
|
// A small Markdown renderer: fenced code, headings, lists, paragraphs and
|
|
// the inline forms above. Raw HTML in the source stays literal text.
|
|
function appendMarkdown(parent, src) {
|
|
var lines = src.replace(/\r\n/g, "\n").split("\n");
|
|
var para = [];
|
|
var list = null;
|
|
|
|
function flushPara() {
|
|
if (!para.length) return;
|
|
var p = h("p");
|
|
para.forEach(function (line, idx) {
|
|
if (idx) p.appendChild(h("br"));
|
|
appendInline(p, line);
|
|
});
|
|
parent.appendChild(p);
|
|
para = [];
|
|
}
|
|
function flushList() {
|
|
if (list) parent.appendChild(list.node);
|
|
list = null;
|
|
}
|
|
|
|
for (var i = 0; i < lines.length; i++) {
|
|
var line = lines[i];
|
|
var fence = line.match(/^\s*```\s*([\w+#.-]*)\s*$/);
|
|
if (fence) {
|
|
flushPara(); flushList();
|
|
var code = [];
|
|
i++;
|
|
while (i < lines.length && !/^\s*```\s*$/.test(lines[i])) { code.push(lines[i]); i++; }
|
|
parent.appendChild(codeBlock(fence[1], code.join("\n")));
|
|
continue;
|
|
}
|
|
var heading = line.match(/^(#{1,4})\s+(.*)$/);
|
|
if (heading) {
|
|
flushPara(); flushList();
|
|
var hn = h("h" + (heading[1].length + 1));
|
|
appendInline(hn, heading[2]);
|
|
parent.appendChild(hn);
|
|
continue;
|
|
}
|
|
var bullet = line.match(/^\s*[-*+]\s+(.*)$/);
|
|
var numbered = line.match(/^\s*\d+[.)]\s+(.*)$/);
|
|
if (bullet || numbered) {
|
|
flushPara();
|
|
var tag = bullet ? "ul" : "ol";
|
|
if (!list || list.tag !== tag) { flushList(); list = { tag: tag, node: h(tag) }; }
|
|
var li = h("li");
|
|
appendInline(li, (bullet || numbered)[1]);
|
|
list.node.appendChild(li);
|
|
continue;
|
|
}
|
|
if (!line.trim()) { flushPara(); flushList(); continue; }
|
|
flushList();
|
|
para.push(line);
|
|
}
|
|
flushPara(); flushList();
|
|
}
|
|
|
|
function codeBlock(lang, code) {
|
|
var wrap = h("div", "code-block");
|
|
var bar = h("div", "bar");
|
|
bar.appendChild(h("span", null, lang || "code"));
|
|
var copy = h("button", null, "Copy");
|
|
copy.type = "button";
|
|
copy.setAttribute("data-copy", "");
|
|
bar.appendChild(copy);
|
|
var pre = h("pre");
|
|
pre.appendChild(h("code", null, code));
|
|
wrap.appendChild(bar);
|
|
wrap.appendChild(pre);
|
|
return wrap;
|
|
}
|
|
|
|
function fmt(n) { return (n || 0).toLocaleString(); }
|
|
|
|
function usageLine(t) {
|
|
if (!t.usage) return "";
|
|
var u = t.usage;
|
|
var parts = [t.model || "", "in " + fmt(u.input_tokens), "out " + fmt(u.output_tokens)];
|
|
if (u.cache_read_input_tokens) parts.push("cache read " + fmt(u.cache_read_input_tokens));
|
|
if (u.cache_creation_input_tokens) parts.push("cache write " + fmt(u.cache_creation_input_tokens));
|
|
return parts.filter(Boolean).join(" · ");
|
|
}
|
|
|
|
function appendAssistant(div, t) {
|
|
function notice(text, extra) { div.appendChild(h("div", "notice" + (extra ? " " + extra : ""), text)); }
|
|
t.blocks.forEach(function (b) {
|
|
if (b.type === "thinking") {
|
|
if (!b.text) {
|
|
if (t.status === "streaming") notice("Thinking…");
|
|
return;
|
|
}
|
|
var details = h("details", "thinking");
|
|
if (t.status === "streaming") details.open = true;
|
|
details.appendChild(h("summary", null, "Thinking"));
|
|
details.appendChild(h("div", "body", b.text));
|
|
div.appendChild(details);
|
|
} else if (b.type === "text") {
|
|
appendMarkdown(div, b.text);
|
|
} else if (b.type === "fallback") {
|
|
notice(b.from + " declined; continuing on " + b.to + ".");
|
|
} else if (b.type === "compaction") {
|
|
notice("Earlier conversation summarized to free up context.");
|
|
} else if (b.type === "tool") {
|
|
div.appendChild(toolCard(t, b));
|
|
}
|
|
});
|
|
if (t.status === "streaming" && !t.blocks.length) notice("Waiting for Claude…");
|
|
if (t.status === "stopped") {
|
|
notice(t.agent ? "Stopped. Tool calls that finished are kept in the session." : "Stopped. This reply is left out of the conversation.");
|
|
}
|
|
if (t.agent && t.status === "done" && t.stopReason === "tool_rounds") notice("Stopped after the maximum number of tool rounds. Send a message to continue.");
|
|
if (t.status === "refused") {
|
|
notice("Claude declined this request" + (t.refusalCategory ? " (" + t.refusalCategory + ")" : "") +
|
|
". It is left out of the conversation, so you can rephrase and try again.", "error");
|
|
}
|
|
if (t.status === "error") notice(t.error || "Request failed.", "error");
|
|
if (t.status === "done" && t.stopReason === "max_tokens") notice("The reply hit the output token limit.");
|
|
var meta = usageLine(t);
|
|
if (meta) div.appendChild(h("div", "meta", meta));
|
|
}
|
|
|
|
var OUTCOME_LABELS = { denied: "denied", blocked: "blocked by settings", truncated: "not run (output cut off)" };
|
|
|
|
function toolCard(t, b) {
|
|
var card = h("div", "tool" + (b.isError ? " error" : ""));
|
|
var head = h("div", "head");
|
|
head.appendChild(h("span", "name", b.name));
|
|
head.appendChild(h("span", "summary", b.summary));
|
|
var state = b.output == null ? (b.approval && b.approval.state === "pending" ? "needs approval" : "running…")
|
|
: (OUTCOME_LABELS[b.outcome] || (b.isError ? "error" : "done"));
|
|
head.appendChild(h("span", "state", state));
|
|
card.appendChild(head);
|
|
if (b.output != null && b.output !== "") {
|
|
var details = h("details");
|
|
details.appendChild(h("summary", null, "Output"));
|
|
details.appendChild(h("pre", null, b.output));
|
|
card.appendChild(details);
|
|
}
|
|
if (b.approval && b.approval.state === "pending" && t.status === "streaming") {
|
|
card.appendChild(approvalBox(t, b));
|
|
}
|
|
return card;
|
|
}
|
|
|
|
function approvalBox(t, b) {
|
|
var box = h("div", "approve");
|
|
box.appendChild(h("div", "question", "Allow " + b.name + "?"));
|
|
var d = b.approval.details || {};
|
|
if (d.old != null || d.preview != null) {
|
|
var diff = h("pre", "diff");
|
|
if (d.old != null) {
|
|
d.old.split("\n").forEach(function (line) { diff.appendChild(h("div", "del", "- " + line)); });
|
|
d.new.split("\n").forEach(function (line) { diff.appendChild(h("div", "add", "+ " + line)); });
|
|
} else {
|
|
diff.appendChild(h("div", null, d.lines + " lines"));
|
|
diff.appendChild(h("div", "add", d.preview));
|
|
}
|
|
box.appendChild(diff);
|
|
}
|
|
var buttons = h("div", "buttons");
|
|
[["yes", "Allow", "btn-primary"], ["always", b.name === "bash" ? "Always allow this command" : "Always allow " + b.name, "btn"], ["no", "Deny", "btn"]]
|
|
.forEach(function (spec) {
|
|
var btn = h("button", spec[2], spec[1]);
|
|
btn.type = "button";
|
|
btn.addEventListener("click", function () { answerApproval(t, b, spec[0]); });
|
|
buttons.appendChild(btn);
|
|
});
|
|
box.appendChild(buttons);
|
|
return box;
|
|
}
|
|
|
|
function answerApproval(t, b, answer) {
|
|
var conv = activeConversation();
|
|
if (!conv || !conv.sessionId || !b.approval) return;
|
|
b.approval.state = answer;
|
|
rerenderTurn(t);
|
|
fetch("/api/agent/sessions/" + encodeURIComponent(conv.sessionId) + "/approvals/" + encodeURIComponent(b.approval.id), {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", "X-Cod-Token": API_TOKEN },
|
|
body: JSON.stringify({ answer: answer })
|
|
}).then(function (r) {
|
|
if (!r.ok) { b.approval.state = "pending"; rerenderTurn(t); toast("Couldn't send the answer."); }
|
|
}, function () { b.approval.state = "pending"; rerenderTurn(t); toast("Couldn't reach the cod.ai server."); });
|
|
}
|
|
|
|
function renderTurn(t) {
|
|
var div = document.createElement("div");
|
|
if (t.role === "user") {
|
|
div.className = "msg-user" + (t.included ? "" : " excluded");
|
|
div.textContent = t.text;
|
|
} else {
|
|
div.className = "msg-assistant" + (t.status === "streaming" ? " cursor" : "");
|
|
appendAssistant(div, t);
|
|
}
|
|
div.dataset.turn = t.key;
|
|
return div;
|
|
}
|
|
|
|
function rerenderTurn(t) {
|
|
var old = el.messages.querySelector('[data-turn="' + t.key + '"]');
|
|
if (old) old.replaceWith(renderTurn(t));
|
|
}
|
|
|
|
function renderMessages() {
|
|
var conv = activeConversation();
|
|
el.messages.replaceChildren();
|
|
el.empty.style.display = conv && conv.turns.length ? "none" : "";
|
|
el.title.textContent = conv ? conv.title : "New chat";
|
|
if (!conv) return;
|
|
conv.turns.forEach(function (t) {
|
|
el.messages.appendChild(renderTurn(t));
|
|
});
|
|
scrollToBottom(true);
|
|
}
|
|
|
|
function renderList() {
|
|
el.list.replaceChildren();
|
|
conversations.forEach(function (c) {
|
|
var item = document.createElement("div");
|
|
item.className = "conv-item" + (c.id === activeId ? " active" : "");
|
|
var open = document.createElement("button");
|
|
open.type = "button";
|
|
open.className = "open";
|
|
open.textContent = c.title;
|
|
open.addEventListener("click", function () {
|
|
if (inflight) return toast("Wait for the current reply or stop it first.");
|
|
activeId = c.id;
|
|
document.body.classList.remove("sidebar-open");
|
|
renderAll();
|
|
});
|
|
var del = document.createElement("button");
|
|
del.type = "button";
|
|
del.className = "delete";
|
|
del.setAttribute("aria-label", "Delete conversation");
|
|
del.textContent = "✕";
|
|
del.addEventListener("click", function () {
|
|
if (inflight && c.id === activeId) return toast("Stop the current reply before deleting.");
|
|
if (!confirm("Delete “" + c.title + "”?")) return;
|
|
conversations = conversations.filter(function (x) { return x.id !== c.id; });
|
|
if (activeId === c.id) activeId = conversations.length ? conversations[0].id : null;
|
|
saveConversations();
|
|
renderAll();
|
|
});
|
|
item.appendChild(open);
|
|
item.appendChild(del);
|
|
el.list.appendChild(item);
|
|
});
|
|
}
|
|
|
|
function renderAll() { renderList(); renderMessages(); updateSendButton(); syncModeSelector(); }
|
|
|
|
// A conversation keeps the mode (and, in agent mode, the model) it started with.
|
|
function syncModeSelector() {
|
|
var conv = activeConversation();
|
|
var started = !!(conv && conv.turns.length);
|
|
if (conv && conv.mode) el.mode.value = conv.mode;
|
|
el.mode.disabled = started;
|
|
el.model.disabled = started && conv.mode !== "chat" && !!conv.mode;
|
|
if (el.model.disabled && conv.model) el.model.value = conv.model;
|
|
}
|
|
|
|
function scrollToBottom(force) {
|
|
var s = el.scroll;
|
|
var nearBottom = s.scrollHeight - s.scrollTop - s.clientHeight < 120;
|
|
if (force || nearBottom) s.scrollTop = s.scrollHeight;
|
|
}
|
|
|
|
function toast(msg) {
|
|
el.toast.textContent = msg;
|
|
el.toast.classList.add("show");
|
|
clearTimeout(toast._t);
|
|
toast._t = setTimeout(function () { el.toast.classList.remove("show"); }, 3200);
|
|
}
|
|
|
|
// ---------- settings ----------
|
|
|
|
function populateSelectors() {
|
|
el.model.replaceChildren();
|
|
config.models.forEach(function (m) {
|
|
var o = document.createElement("option");
|
|
o.value = m.id;
|
|
o.textContent = m.label;
|
|
el.model.appendChild(o);
|
|
});
|
|
el.effort.replaceChildren();
|
|
config.effort_levels.forEach(function (e) {
|
|
var o = document.createElement("option");
|
|
o.value = e;
|
|
o.textContent = e;
|
|
el.effort.appendChild(o);
|
|
});
|
|
el.model.value = config.default_model;
|
|
applyDefaultEffort();
|
|
}
|
|
|
|
function applyDefaultEffort() {
|
|
var m = config.models.filter(function (x) { return x.id === el.model.value; })[0];
|
|
if (m) el.effort.value = m.default_effort;
|
|
}
|
|
|
|
function loadConfig() {
|
|
fetch("/api/config").then(function (r) { return r.ok ? r.json() : null; }).then(function (cfg) {
|
|
if (cfg) config = cfg;
|
|
populateSelectors();
|
|
updateEmptyState();
|
|
}).catch(function () {
|
|
populateSelectors();
|
|
document.getElementById("empty-sub").textContent =
|
|
"The cod.ai server isn't reachable. Start it with `cod serve` and open http://127.0.0.1:8642.";
|
|
});
|
|
}
|
|
|
|
// ---------- streaming ----------
|
|
|
|
function updateSendButton() {
|
|
if (inflight) {
|
|
el.send.disabled = false;
|
|
el.send.textContent = "Stop";
|
|
} else {
|
|
el.send.textContent = "Send";
|
|
el.send.disabled = !el.prompt.value.trim();
|
|
}
|
|
}
|
|
|
|
// Parses a text/event-stream body into {event, data} records.
|
|
function readEvents(response, onEvent) {
|
|
var reader = response.body.getReader();
|
|
var decoder = new TextDecoder();
|
|
var buffer = "";
|
|
function pump() {
|
|
return reader.read().then(function (chunk) {
|
|
if (chunk.done) return;
|
|
buffer += decoder.decode(chunk.value, { stream: true });
|
|
var idx;
|
|
while ((idx = buffer.indexOf("\n\n")) !== -1) {
|
|
var raw = buffer.slice(0, idx);
|
|
buffer = buffer.slice(idx + 2);
|
|
var event = "message";
|
|
var data = [];
|
|
raw.split("\n").forEach(function (line) {
|
|
if (line.indexOf("event:") === 0) event = line.slice(6).trim();
|
|
else if (line.indexOf("data:") === 0) data.push(line.slice(5).replace(/^ /, ""));
|
|
});
|
|
if (data.length) onEvent(event, JSON.parse(data.join("\n")));
|
|
}
|
|
return pump();
|
|
});
|
|
}
|
|
return pump();
|
|
}
|
|
|
|
function errorMessage(resp) {
|
|
return resp.json().catch(function () { return {}; }).then(function (j) {
|
|
var detail = j && j.detail;
|
|
return Array.isArray(detail) ? detail.map(function (d) { return d.msg; }).join("; ") : (detail || resp.statusText);
|
|
});
|
|
}
|
|
|
|
function send(text) {
|
|
var existing = activeConversation();
|
|
var mode = existing ? existing.mode || "chat" : el.mode.value;
|
|
if (mode !== "chat") return sendAgent(text);
|
|
var conv = existing || newConversation(text);
|
|
var userTurn = { key: "u" + Date.now(), role: "user", text: text, included: true };
|
|
var reply = {
|
|
key: "a" + Date.now(), role: "assistant", status: "streaming", blocks: [], byIndex: {},
|
|
included: false, history: null, model: el.model.value
|
|
};
|
|
conv.turns.push(userTurn, reply);
|
|
saveConversations();
|
|
renderAll();
|
|
|
|
var body = {
|
|
model: el.model.value,
|
|
effort: el.effort.value,
|
|
thinking_display: el.thinking.value,
|
|
messages: apiMessages(conv)
|
|
};
|
|
var controller = new AbortController();
|
|
inflight = { controller: controller, reply: reply, user: userTurn };
|
|
updateSendButton();
|
|
|
|
var pending = false;
|
|
function repaint() {
|
|
if (pending) return;
|
|
pending = true;
|
|
requestAnimationFrame(function () {
|
|
pending = false;
|
|
rerenderTurn(reply);
|
|
scrollToBottom(false);
|
|
});
|
|
}
|
|
|
|
var finished = false;
|
|
function finish(status, extra) {
|
|
if (finished) return;
|
|
finished = true;
|
|
reply.status = status;
|
|
Object.keys(extra || {}).forEach(function (k) { reply[k] = extra[k]; });
|
|
reply.included = status === "done" && Array.isArray(reply.history) && reply.history.length > 0;
|
|
userTurn.included = reply.included;
|
|
delete reply.byIndex;
|
|
inflight = null;
|
|
saveConversations();
|
|
renderAll();
|
|
}
|
|
|
|
fetch("/api/chat", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", "X-Cod-Token": API_TOKEN },
|
|
body: JSON.stringify(body),
|
|
signal: controller.signal
|
|
}).then(function (resp) {
|
|
if (!resp.ok) {
|
|
return errorMessage(resp).then(function (msg) {
|
|
finish("error", { error: "The server rejected the request: " + msg });
|
|
});
|
|
}
|
|
return readEvents(resp, function (event, data) {
|
|
if (event === "start") {
|
|
reply.model = data.model;
|
|
} else if (event === "block") {
|
|
var b = data.block;
|
|
var view;
|
|
if (b.type === "fallback") view = { type: "fallback", from: (b.from || {}).model, to: (b.to || {}).model };
|
|
else if (b.type === "thinking" || b.type === "text") view = { type: b.type, text: "" };
|
|
else return;
|
|
reply.byIndex[data.index] = view;
|
|
reply.blocks.push(view);
|
|
} else if (event === "delta") {
|
|
var target = reply.byIndex[data.index];
|
|
if (target) target.text += data.text;
|
|
} else if (event === "done") {
|
|
var refused = data.stop_reason === "refusal";
|
|
finish(refused ? "refused" : "done", {
|
|
model: data.model,
|
|
stopReason: data.stop_reason,
|
|
refusalCategory: refused && data.stop_details ? data.stop_details.category : null,
|
|
history: data.history,
|
|
usage: data.usage
|
|
});
|
|
return;
|
|
} else if (event === "error") {
|
|
finish("error", { error: (data.status ? "API error " + data.status + ": " : "") + data.message });
|
|
return;
|
|
}
|
|
repaint();
|
|
}).then(function () {
|
|
finish("error", { error: "The connection closed before the reply finished." });
|
|
});
|
|
}).catch(function (err) {
|
|
if (err && err.name === "AbortError") finish("stopped");
|
|
else finish("error", { error: "Couldn't reach the cod.ai server." });
|
|
});
|
|
}
|
|
|
|
// Agent mode: the server keeps the session; the page sends only the prompt
|
|
// and renders the turn's events, answering approval requests as they come.
|
|
function sendAgent(text) {
|
|
var conv = activeConversation() || newConversation(text);
|
|
conv.model = conv.model || el.model.value;
|
|
var userTurn = { key: "u" + Date.now(), role: "user", text: text, included: true };
|
|
var reply = { key: "a" + Date.now(), role: "assistant", agent: true, status: "streaming", blocks: [], included: true, model: conv.model };
|
|
conv.turns.push(userTurn, reply);
|
|
saveConversations();
|
|
renderAll();
|
|
|
|
var controller = new AbortController();
|
|
inflight = { controller: controller, reply: reply, user: userTurn };
|
|
updateSendButton();
|
|
var headers = { "Content-Type": "application/json", "X-Cod-Token": API_TOKEN };
|
|
var current = null; // the text or thinking block receiving deltas
|
|
var tools = {};
|
|
|
|
var pending = false;
|
|
function repaint() {
|
|
if (pending) return;
|
|
pending = true;
|
|
requestAnimationFrame(function () { pending = false; rerenderTurn(reply); scrollToBottom(false); });
|
|
}
|
|
var finished = false;
|
|
function finish(status, extra) {
|
|
if (finished) return;
|
|
finished = true;
|
|
reply.status = status;
|
|
Object.keys(extra || {}).forEach(function (k) { reply[k] = extra[k]; });
|
|
// The server drops a refused turn from the session, prompt included.
|
|
userTurn.included = status !== "refused";
|
|
inflight = null;
|
|
saveConversations();
|
|
renderAll();
|
|
}
|
|
|
|
var ready = conv.sessionId ? Promise.resolve() : fetch("/api/agent/sessions", {
|
|
method: "POST", headers: headers, signal: controller.signal,
|
|
body: JSON.stringify({
|
|
model: conv.model, effort: el.effort.value, thinking_display: el.thinking.value,
|
|
mode: conv.mode === "agent-edits" ? "accept-edits" : "default"
|
|
})
|
|
}).then(function (resp) {
|
|
if (!resp.ok) return errorMessage(resp).then(function (msg) { throw new Error("The server couldn't start a session: " + msg); });
|
|
return resp.json().then(function (j) { conv.sessionId = j.id; saveConversations(); });
|
|
});
|
|
|
|
ready.then(function () {
|
|
return fetch("/api/agent/sessions/" + encodeURIComponent(conv.sessionId) + "/turns", {
|
|
method: "POST", headers: headers, signal: controller.signal,
|
|
body: JSON.stringify({ prompt: text, effort: el.effort.value })
|
|
});
|
|
}).then(function (resp) {
|
|
if (!resp.ok) return errorMessage(resp).then(function (msg) { finish("error", { error: "The server rejected the request: " + msg }); });
|
|
return readEvents(resp, function (event, data) {
|
|
if (event === "block") {
|
|
current = { type: data.kind, text: "" };
|
|
reply.blocks.push(current);
|
|
} else if (event === "delta") {
|
|
if (!current || current.type !== data.kind) { current = { type: data.kind, text: "" }; reply.blocks.push(current); }
|
|
current.text += data.text;
|
|
} else if (event === "tool_call") {
|
|
current = null;
|
|
tools[data.id] = { type: "tool", id: data.id, name: data.name, summary: data.summary, output: null };
|
|
reply.blocks.push(tools[data.id]);
|
|
} else if (event === "approval") {
|
|
var waiting = reply.blocks.filter(function (b) { return b.type === "tool" && b.output == null; });
|
|
var target = waiting[waiting.length - 1];
|
|
if (target) target.approval = { id: data.id, state: "pending", details: data.details };
|
|
} else if (event === "tool_output") {
|
|
var tool = tools[data.id];
|
|
if (tool) { tool.output = data.content; tool.isError = data.is_error; tool.outcome = data.outcome; }
|
|
} else if (event === "fallback") {
|
|
reply.blocks.push({ type: "fallback", from: data.from, to: data.to });
|
|
} else if (event === "compaction") {
|
|
reply.blocks.push({ type: "compaction" });
|
|
} else if (event === "done") {
|
|
var refused = data.stop_reason === "refusal";
|
|
finish(refused ? "refused" : "done", {
|
|
model: data.model, stopReason: data.stop_reason, refusalCategory: data.refusal_category, usage: data.usage
|
|
});
|
|
return;
|
|
} else if (event === "error") {
|
|
finish("error", { error: (data.status ? "API error " + data.status + ": " : "") + data.message });
|
|
return;
|
|
}
|
|
repaint();
|
|
}).then(function () {
|
|
finish("error", { error: "The connection closed before the turn finished." });
|
|
});
|
|
}).catch(function (err) {
|
|
if (err && err.name === "AbortError") finish("stopped");
|
|
else finish("error", { error: err && err.message ? err.message : "Couldn't reach the cod.ai server." });
|
|
});
|
|
}
|
|
|
|
// ---------- events ----------
|
|
|
|
el.form.addEventListener("submit", function (e) {
|
|
e.preventDefault();
|
|
if (inflight) { inflight.controller.abort(); return; }
|
|
var text = el.prompt.value.trim();
|
|
if (!text) return;
|
|
el.prompt.value = "";
|
|
autosize();
|
|
send(text);
|
|
});
|
|
|
|
el.prompt.addEventListener("keydown", function (e) {
|
|
if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
|
|
e.preventDefault();
|
|
if (!inflight) el.form.requestSubmit();
|
|
}
|
|
});
|
|
|
|
function autosize() {
|
|
el.prompt.style.height = "auto";
|
|
el.prompt.style.height = Math.min(el.prompt.scrollHeight, 240) + "px";
|
|
}
|
|
el.prompt.addEventListener("input", function () { autosize(); updateSendButton(); });
|
|
|
|
el.model.addEventListener("change", applyDefaultEffort);
|
|
el.mode.addEventListener("change", updateEmptyState);
|
|
|
|
function updateEmptyState() {
|
|
var sub = document.getElementById("empty-sub");
|
|
if (el.mode.value === "chat") {
|
|
sub.textContent = "Replies stream from the Claude API through the local cod.ai server.";
|
|
} else {
|
|
sub.textContent = "Agent mode works in " + (config.agent_root || "the server's project folder") +
|
|
". It reads freely and asks before editing files or running commands" +
|
|
(el.mode.value === "agent-edits" ? ", except edits, which it applies." : ".");
|
|
}
|
|
}
|
|
|
|
document.getElementById("btn-new").addEventListener("click", function () {
|
|
if (inflight) return toast("Wait for the current reply or stop it first.");
|
|
activeId = null;
|
|
document.body.classList.remove("sidebar-open");
|
|
renderAll();
|
|
el.prompt.focus();
|
|
});
|
|
|
|
document.getElementById("btn-sidebar").addEventListener("click", function () {
|
|
document.body.classList.toggle("sidebar-open");
|
|
});
|
|
|
|
el.messages.addEventListener("click", function (e) {
|
|
var btn = e.target.closest("[data-copy]");
|
|
if (!btn) return;
|
|
var code = btn.closest(".code-block").querySelector("code").textContent;
|
|
navigator.clipboard.writeText(code).then(function () { toast("Copied"); }, function () { toast("Couldn't copy"); });
|
|
});
|
|
|
|
function applyTheme(theme) {
|
|
if (theme) document.documentElement.setAttribute("data-theme", theme);
|
|
else document.documentElement.removeAttribute("data-theme");
|
|
}
|
|
try { applyTheme(localStorage.getItem(THEME_KEY)); } catch (e) {}
|
|
document.getElementById("btn-theme").addEventListener("click", function () {
|
|
var dark = getComputedStyle(document.documentElement).getPropertyValue("--bg-canvas").trim() === "#181715";
|
|
var next = dark ? "light" : "dark";
|
|
applyTheme(next);
|
|
try { localStorage.setItem(THEME_KEY, next); } catch (e) {}
|
|
});
|
|
|
|
// A reply that was streaming when the page closed never finished.
|
|
conversations.forEach(function (c) {
|
|
c.turns.forEach(function (t, i) {
|
|
if (t.role === "assistant" && t.status === "streaming") {
|
|
t.status = "stopped";
|
|
t.included = false;
|
|
delete t.byIndex;
|
|
if (c.turns[i - 1]) c.turns[i - 1].included = false;
|
|
}
|
|
});
|
|
});
|
|
|
|
loadConfig();
|
|
renderAll();
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|