Replace standalone Telegram bot with full CodeAnywhere framework fork. BetterBot shares all framework code and customizes only: - instance.py: BetterBot identity, system prompt, feature flags - tools/site_editing/: list_files, read_file, write_file with auto git push - .env: model defaults and site directory paths - compose/: Docker setup with betterlifesg + memoraiz mounts - deploy script: RackNerd with Infisical secrets
2437 lines
No EOL
84 KiB
HTML
2437 lines
No EOL
84 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>CodeAnywhere</title>
|
|
<style>
|
|
:root {
|
|
color-scheme: dark;
|
|
--bg: #0b1220;
|
|
--panel: rgba(17, 27, 47, 0.9);
|
|
--panel-strong: rgba(22, 36, 61, 0.92);
|
|
--line: rgba(151, 180, 255, 0.18);
|
|
--text: #eaf1ff;
|
|
--muted: #9fb4d9;
|
|
--accent: #80b3ff;
|
|
--accent-strong: #4d8dff;
|
|
--warning-line: rgba(255, 157, 181, 0.35);
|
|
--warning-text: #ffd6de;
|
|
--shadow: 0 18px 50px rgba(0, 0, 0, 0.28);
|
|
--radius: 20px;
|
|
}
|
|
|
|
* {
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
html,
|
|
body {
|
|
margin: 0;
|
|
min-height: 100%;
|
|
background:
|
|
radial-gradient(circle at top right, rgba(128, 179, 255, 0.16), transparent 26%),
|
|
linear-gradient(180deg, #07101d 0%, #0b1220 100%);
|
|
color: var(--text);
|
|
font-family: "Segoe UI", "IBM Plex Sans", sans-serif;
|
|
}
|
|
|
|
body {
|
|
padding: 16px;
|
|
overflow-x: hidden;
|
|
}
|
|
|
|
.hidden {
|
|
display: none !important;
|
|
}
|
|
|
|
.shell {
|
|
min-height: calc(100vh - 32px);
|
|
display: grid;
|
|
gap: 12px;
|
|
}
|
|
|
|
.banner,
|
|
.panel,
|
|
.chat-shell,
|
|
.debug-dock,
|
|
.native-chat {
|
|
backdrop-filter: blur(16px);
|
|
box-shadow: var(--shadow);
|
|
}
|
|
|
|
.banner {
|
|
position: fixed;
|
|
top: 16px;
|
|
right: 16px;
|
|
z-index: 20;
|
|
width: min(320px, calc(100vw - 32px));
|
|
padding: 12px 14px;
|
|
border: 1px solid var(--line);
|
|
border-radius: 14px;
|
|
background: var(--panel-strong);
|
|
color: var(--accent);
|
|
font-size: 14px;
|
|
}
|
|
|
|
.panel {
|
|
padding: 14px 16px;
|
|
border-radius: 16px;
|
|
border: 1px solid var(--warning-line);
|
|
background: rgba(35, 24, 28, 0.92);
|
|
color: var(--warning-text);
|
|
}
|
|
|
|
.panel h2 {
|
|
margin: 0 0 8px;
|
|
font-size: 16px;
|
|
}
|
|
|
|
.panel p,
|
|
.panel li {
|
|
margin: 0 0 8px;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.panel ul {
|
|
margin: 0;
|
|
padding-left: 18px;
|
|
}
|
|
|
|
.panel code,
|
|
.meta,
|
|
pre {
|
|
font-family: "Cascadia Code", Consolas, monospace;
|
|
}
|
|
|
|
.chat-shell {
|
|
position: relative;
|
|
display: grid;
|
|
grid-template-columns: 240px minmax(0, 1fr);
|
|
gap: 12px;
|
|
min-height: 78vh;
|
|
}
|
|
|
|
body.sidebar-collapsed .chat-shell {
|
|
grid-template-columns: 72px minmax(0, 1fr);
|
|
}
|
|
|
|
.sidebar,
|
|
.conversation {
|
|
border: 1px solid var(--line);
|
|
border-radius: var(--radius);
|
|
background: var(--panel);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.sidebar {
|
|
display: grid;
|
|
grid-template-rows: auto auto 1fr;
|
|
transition: transform 0.18s ease, width 0.18s ease;
|
|
}
|
|
|
|
.sidebar-header,
|
|
.conversation-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
padding: 18px;
|
|
border-bottom: 1px solid var(--line);
|
|
background: var(--panel-strong);
|
|
}
|
|
|
|
.sidebar-headline {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
min-width: 0;
|
|
}
|
|
|
|
.sidebar-copy {
|
|
min-width: 0;
|
|
}
|
|
|
|
.eyebrow {
|
|
margin: 0 0 6px;
|
|
color: var(--muted);
|
|
font-size: 12px;
|
|
letter-spacing: 0.12em;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
h1,
|
|
h2 {
|
|
margin: 0;
|
|
font-size: 22px;
|
|
line-height: 1.1;
|
|
}
|
|
|
|
.controls,
|
|
.thread-tools {
|
|
display: flex;
|
|
gap: 8px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.controls {
|
|
justify-content: flex-end;
|
|
}
|
|
|
|
.conversation-heading {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
min-width: 0;
|
|
flex: 1 1 240px;
|
|
}
|
|
|
|
.mobile-sidebar-toggle {
|
|
display: none;
|
|
align-items: center;
|
|
justify-content: center;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
button,
|
|
input,
|
|
textarea {
|
|
font: inherit;
|
|
}
|
|
|
|
button {
|
|
border: none;
|
|
border-radius: 999px;
|
|
background: linear-gradient(135deg, var(--accent-strong), var(--accent));
|
|
color: #03101f;
|
|
padding: 10px 14px;
|
|
font-weight: 700;
|
|
cursor: pointer;
|
|
}
|
|
|
|
button:disabled {
|
|
opacity: 0.55;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.ghost {
|
|
background: transparent;
|
|
color: var(--text);
|
|
border: 1px solid var(--line);
|
|
font-weight: 600;
|
|
}
|
|
|
|
.icon-button {
|
|
min-width: 44px;
|
|
padding: 10px 12px;
|
|
}
|
|
|
|
.model-panel {
|
|
padding: 16px 18px;
|
|
border-bottom: 1px solid var(--line);
|
|
display: grid;
|
|
gap: 10px;
|
|
}
|
|
|
|
.model-panel label,
|
|
.composer label {
|
|
display: grid;
|
|
gap: 6px;
|
|
color: var(--muted);
|
|
font-size: 13px;
|
|
}
|
|
|
|
input,
|
|
textarea {
|
|
width: 100%;
|
|
border-radius: 14px;
|
|
border: 1px solid var(--line);
|
|
background: rgba(8, 15, 28, 0.9);
|
|
color: var(--text);
|
|
padding: 12px 14px;
|
|
}
|
|
|
|
textarea {
|
|
min-height: 124px;
|
|
resize: vertical;
|
|
}
|
|
|
|
.threads {
|
|
overflow-y: auto;
|
|
padding: 8px;
|
|
display: grid;
|
|
gap: 6px;
|
|
align-content: start;
|
|
}
|
|
|
|
.thread-card {
|
|
width: 100%;
|
|
height: 64px;
|
|
border: 1px solid transparent;
|
|
border-radius: 14px;
|
|
background: rgba(8, 15, 28, 0.82);
|
|
color: var(--text);
|
|
padding: 8px 10px;
|
|
text-align: left;
|
|
display: grid;
|
|
grid-template-columns: 36px minmax(0, 1fr);
|
|
align-items: center;
|
|
gap: 8px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.thread-card.active {
|
|
border-color: rgba(128, 179, 255, 0.45);
|
|
background: rgba(21, 36, 63, 0.98);
|
|
}
|
|
|
|
.thread-badge {
|
|
width: 36px;
|
|
height: 36px;
|
|
display: grid;
|
|
place-items: center;
|
|
border-radius: 10px;
|
|
background: rgba(128, 179, 255, 0.14);
|
|
color: var(--accent);
|
|
font-weight: 700;
|
|
font-size: 13px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.thread-card.active .thread-badge {
|
|
background: var(--accent);
|
|
color: #03101f;
|
|
}
|
|
|
|
.thread-copy {
|
|
min-width: 0;
|
|
display: grid;
|
|
gap: 2px;
|
|
}
|
|
|
|
.thread-title-text {
|
|
display: block;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
font-size: 13px;
|
|
line-height: 1.3;
|
|
}
|
|
|
|
.meta {
|
|
color: var(--muted);
|
|
font-size: 11px;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
body.sidebar-collapsed .sidebar-copy,
|
|
body.sidebar-collapsed .model-panel,
|
|
body.sidebar-collapsed .thread-copy {
|
|
display: none;
|
|
}
|
|
|
|
body.sidebar-collapsed #new-chat-btn {
|
|
min-width: 44px;
|
|
padding-left: 0;
|
|
padding-right: 0;
|
|
}
|
|
|
|
body.sidebar-collapsed .thread-card {
|
|
grid-template-columns: 1fr;
|
|
justify-items: center;
|
|
height: 52px;
|
|
padding: 8px 6px;
|
|
}
|
|
|
|
body.sidebar-collapsed #magic-rename-all-btn {
|
|
min-width: 44px;
|
|
padding-left: 0;
|
|
padding-right: 0;
|
|
}
|
|
|
|
.conversation {
|
|
display: grid;
|
|
grid-template-rows: auto 1fr auto;
|
|
min-height: 78vh;
|
|
}
|
|
|
|
.messages {
|
|
padding: 20px;
|
|
overflow-y: auto;
|
|
display: grid;
|
|
gap: 14px;
|
|
align-content: start;
|
|
background:
|
|
linear-gradient(180deg, rgba(9, 16, 29, 0.9), rgba(15, 24, 40, 0.9)),
|
|
radial-gradient(circle at top left, rgba(128, 179, 255, 0.08), transparent 30%);
|
|
}
|
|
|
|
.message {
|
|
max-width: min(760px, 100%);
|
|
padding: 14px 16px;
|
|
border-radius: 18px;
|
|
border: 1px solid var(--line);
|
|
background: rgba(17, 27, 47, 0.88);
|
|
}
|
|
|
|
.message.user {
|
|
justify-self: end;
|
|
background: rgba(77, 141, 255, 0.18);
|
|
border-color: rgba(128, 179, 255, 0.34);
|
|
}
|
|
|
|
.message.assistant {
|
|
justify-self: start;
|
|
}
|
|
|
|
.message.pending {
|
|
border-style: dashed;
|
|
background: rgba(12, 24, 43, 0.9);
|
|
}
|
|
|
|
.message.pending .message-body {
|
|
color: var(--accent);
|
|
}
|
|
|
|
.message.trace-only {
|
|
background: rgba(11, 19, 34, 0.94);
|
|
}
|
|
|
|
.message-label {
|
|
margin: 0 0 8px;
|
|
color: var(--muted);
|
|
font-size: 12px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.12em;
|
|
}
|
|
|
|
.message-body {
|
|
color: var(--text);
|
|
line-height: 1.6;
|
|
word-break: break-word;
|
|
font-size: 15px;
|
|
}
|
|
|
|
.message-body.is-plain {
|
|
white-space: pre-wrap;
|
|
font-family: "Cascadia Code", Consolas, monospace;
|
|
}
|
|
|
|
.message-images {
|
|
display: grid;
|
|
gap: 10px;
|
|
margin-top: 12px;
|
|
}
|
|
|
|
.message-image {
|
|
margin: 0;
|
|
display: grid;
|
|
gap: 8px;
|
|
}
|
|
|
|
.message-image img {
|
|
display: block;
|
|
width: 100%;
|
|
max-width: min(460px, 100%);
|
|
border-radius: 14px;
|
|
border: 1px solid rgba(128, 179, 255, 0.2);
|
|
background: rgba(6, 12, 22, 0.92);
|
|
}
|
|
|
|
.message-image figcaption {
|
|
color: var(--muted);
|
|
font-size: 12px;
|
|
}
|
|
|
|
.trace-stack {
|
|
display: grid;
|
|
gap: 10px;
|
|
margin-top: 12px;
|
|
}
|
|
|
|
.trace-bubble {
|
|
border: 1px solid var(--line);
|
|
border-radius: 14px;
|
|
background: rgba(8, 15, 28, 0.9);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.trace-bubble summary {
|
|
list-style: none;
|
|
cursor: pointer;
|
|
padding: 10px 12px;
|
|
display: grid;
|
|
gap: 4px;
|
|
}
|
|
|
|
.trace-bubble summary::-webkit-details-marker {
|
|
display: none;
|
|
}
|
|
|
|
.trace-kicker {
|
|
color: var(--muted);
|
|
font-size: 11px;
|
|
letter-spacing: 0.08em;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.trace-title {
|
|
color: var(--text);
|
|
font-size: 13px;
|
|
font-weight: 700;
|
|
line-height: 1.4;
|
|
}
|
|
|
|
.trace-summary {
|
|
color: var(--muted);
|
|
font-size: 12px;
|
|
line-height: 1.55;
|
|
word-break: break-word;
|
|
}
|
|
|
|
.trace-body {
|
|
margin: 0;
|
|
padding: 0 12px 12px;
|
|
border-top: 1px solid var(--line);
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
color: #c8d8f0;
|
|
font-size: 12px;
|
|
line-height: 1.6;
|
|
font-family: "Cascadia Code", Consolas, monospace;
|
|
}
|
|
|
|
.trace-bubble.subagent {
|
|
border-color: rgba(128, 179, 255, 0.28);
|
|
}
|
|
|
|
.trace-bubble.tool_call {
|
|
border-color: rgba(94, 196, 255, 0.28);
|
|
}
|
|
|
|
.trace-bubble.tool_output {
|
|
border-color: rgba(120, 214, 163, 0.28);
|
|
}
|
|
|
|
.trace-collapsed-summary {
|
|
border: 1px solid var(--line);
|
|
border-radius: 14px;
|
|
background: rgba(8, 15, 28, 0.9);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.trace-counts-line {
|
|
list-style: none;
|
|
cursor: pointer;
|
|
padding: 10px 14px;
|
|
color: var(--muted);
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
letter-spacing: 0.02em;
|
|
user-select: none;
|
|
}
|
|
|
|
.trace-counts-line::-webkit-details-marker {
|
|
display: none;
|
|
}
|
|
|
|
.trace-collapsed-summary[open] .trace-counts-line {
|
|
border-bottom: 1px solid var(--line);
|
|
color: var(--accent);
|
|
}
|
|
|
|
.trace-expanded-items {
|
|
padding: 8px;
|
|
display: grid;
|
|
gap: 8px;
|
|
}
|
|
|
|
.trace-detail-section {
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.trace-detail-label {
|
|
display: block;
|
|
color: var(--muted);
|
|
font-size: 11px;
|
|
font-weight: 700;
|
|
letter-spacing: 0.06em;
|
|
text-transform: uppercase;
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.markdown-body> :first-child {
|
|
margin-top: 0;
|
|
}
|
|
|
|
.markdown-body> :last-child {
|
|
margin-bottom: 0;
|
|
}
|
|
|
|
.markdown-body h1,
|
|
.markdown-body h2,
|
|
.markdown-body h3,
|
|
.markdown-body h4,
|
|
.markdown-body h5,
|
|
.markdown-body h6 {
|
|
margin: 0 0 12px;
|
|
font-size: inherit;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.markdown-body p,
|
|
.markdown-body ul,
|
|
.markdown-body ol,
|
|
.markdown-body pre,
|
|
.markdown-body blockquote,
|
|
.markdown-body table,
|
|
.markdown-body hr {
|
|
margin: 0 0 12px;
|
|
}
|
|
|
|
.markdown-body ul,
|
|
.markdown-body ol {
|
|
padding-left: 22px;
|
|
}
|
|
|
|
.markdown-body li+li {
|
|
margin-top: 4px;
|
|
}
|
|
|
|
.markdown-body a {
|
|
color: var(--accent);
|
|
}
|
|
|
|
.markdown-body blockquote {
|
|
padding-left: 12px;
|
|
border-left: 3px solid rgba(128, 179, 255, 0.35);
|
|
color: var(--muted);
|
|
}
|
|
|
|
.markdown-body hr {
|
|
border: none;
|
|
border-top: 1px solid var(--line);
|
|
}
|
|
|
|
.markdown-body code,
|
|
.markdown-body pre {
|
|
font-family: "Cascadia Code", Consolas, monospace;
|
|
}
|
|
|
|
.markdown-body :not(pre)>code {
|
|
padding: 0.16em 0.38em;
|
|
border-radius: 8px;
|
|
background: rgba(128, 179, 255, 0.12);
|
|
font-size: 0.94em;
|
|
}
|
|
|
|
.markdown-body pre {
|
|
padding: 12px 14px;
|
|
border-radius: 14px;
|
|
border: 1px solid var(--line);
|
|
background: rgba(6, 12, 22, 0.92);
|
|
overflow: auto;
|
|
}
|
|
|
|
.markdown-body pre code {
|
|
padding: 0;
|
|
background: transparent;
|
|
font-size: 0.94em;
|
|
}
|
|
|
|
.markdown-body table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.markdown-body th,
|
|
.markdown-body td {
|
|
border: 1px solid var(--line);
|
|
padding: 8px 10px;
|
|
text-align: left;
|
|
vertical-align: top;
|
|
}
|
|
|
|
.markdown-body img {
|
|
max-width: 100%;
|
|
border-radius: 12px;
|
|
}
|
|
|
|
.empty-state {
|
|
align-self: center;
|
|
justify-self: center;
|
|
max-width: 460px;
|
|
padding: 24px;
|
|
text-align: center;
|
|
color: var(--muted);
|
|
line-height: 1.6;
|
|
}
|
|
|
|
.composer {
|
|
display: grid;
|
|
gap: 12px;
|
|
padding: 18px;
|
|
border-top: 1px solid var(--line);
|
|
background: var(--panel-strong);
|
|
}
|
|
|
|
.composer-uploads {
|
|
display: grid;
|
|
gap: 10px;
|
|
}
|
|
|
|
.composer-upload-list {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 10px;
|
|
}
|
|
|
|
.composer-upload {
|
|
min-width: 0;
|
|
display: grid;
|
|
grid-template-columns: 72px minmax(0, 1fr) auto;
|
|
gap: 10px;
|
|
align-items: center;
|
|
padding: 10px;
|
|
border: 1px solid var(--line);
|
|
border-radius: 16px;
|
|
background: rgba(8, 15, 28, 0.88);
|
|
}
|
|
|
|
.composer-upload img {
|
|
width: 72px;
|
|
height: 72px;
|
|
object-fit: cover;
|
|
border-radius: 12px;
|
|
border: 1px solid rgba(128, 179, 255, 0.2);
|
|
background: rgba(6, 12, 22, 0.92);
|
|
}
|
|
|
|
.composer-upload-copy {
|
|
min-width: 0;
|
|
display: grid;
|
|
gap: 4px;
|
|
}
|
|
|
|
.composer-upload-title {
|
|
color: var(--text);
|
|
font-size: 13px;
|
|
font-weight: 700;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.composer-upload-meta {
|
|
color: var(--muted);
|
|
font-size: 12px;
|
|
}
|
|
|
|
.composer-secondary-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
min-width: 0;
|
|
}
|
|
|
|
.composer-actions {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
gap: 12px;
|
|
color: var(--muted);
|
|
font-size: 13px;
|
|
}
|
|
|
|
.native-chat {
|
|
display: block;
|
|
width: 100%;
|
|
min-height: 78vh;
|
|
border-radius: var(--radius);
|
|
overflow: hidden;
|
|
border: 1px solid var(--line);
|
|
background: var(--panel);
|
|
}
|
|
|
|
.debug-dock {
|
|
position: fixed;
|
|
right: 16px;
|
|
bottom: 16px;
|
|
z-index: 25;
|
|
width: fit-content;
|
|
max-width: min(420px, calc(100vw - 32px));
|
|
border: 1px solid var(--line);
|
|
border-radius: 14px;
|
|
background: rgba(10, 16, 28, 0.74);
|
|
opacity: 0.76;
|
|
transition: opacity 0.18s ease;
|
|
}
|
|
|
|
.debug-dock:hover,
|
|
.debug-dock[open] {
|
|
opacity: 1;
|
|
}
|
|
|
|
.debug-dock summary {
|
|
cursor: pointer;
|
|
padding: 8px 12px;
|
|
color: var(--muted);
|
|
font-size: 12px;
|
|
letter-spacing: 0.08em;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.debug-dock pre {
|
|
margin: 0;
|
|
padding: 0 12px 12px;
|
|
max-height: 220px;
|
|
overflow: auto;
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
color: #b7c9e8;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.sidebar-backdrop {
|
|
position: fixed;
|
|
inset: 0;
|
|
z-index: 40;
|
|
border: none;
|
|
background: rgba(3, 8, 16, 0.56);
|
|
backdrop-filter: blur(6px);
|
|
}
|
|
|
|
body.mobile-sidebar-open {
|
|
overflow: hidden;
|
|
}
|
|
|
|
@media (max-width: 1240px) {
|
|
|
|
.sidebar-header,
|
|
.conversation-header {
|
|
flex-wrap: wrap;
|
|
align-items: flex-start;
|
|
}
|
|
|
|
.controls,
|
|
.thread-tools {
|
|
width: 100%;
|
|
justify-content: flex-start;
|
|
}
|
|
|
|
.controls button,
|
|
.thread-tools button {
|
|
flex: 1 1 auto;
|
|
min-width: 0;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 900px) {
|
|
body {
|
|
padding: 0;
|
|
}
|
|
|
|
.chat-shell {
|
|
grid-template-columns: 1fr;
|
|
min-height: 100dvh;
|
|
gap: 0;
|
|
}
|
|
|
|
.sidebar {
|
|
position: fixed;
|
|
top: 12px;
|
|
bottom: 12px;
|
|
left: 12px;
|
|
width: min(260px, calc(100vw - 56px));
|
|
max-width: calc(100vw - 56px);
|
|
z-index: 50;
|
|
border-radius: 22px;
|
|
transition: transform 0.2s ease, opacity 0.2s ease;
|
|
}
|
|
|
|
body.sidebar-collapsed .sidebar {
|
|
transform: translateX(calc(-100% - 18px));
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.conversation {
|
|
min-height: 100dvh;
|
|
border-radius: 0;
|
|
border-left: none;
|
|
border-right: none;
|
|
border-bottom: none;
|
|
}
|
|
|
|
.conversation-header {
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 10;
|
|
}
|
|
|
|
.sidebar-header,
|
|
.conversation-header,
|
|
.composer,
|
|
.model-panel {
|
|
padding: 14px;
|
|
}
|
|
|
|
.messages {
|
|
padding: 16px 14px 132px;
|
|
}
|
|
|
|
.controls,
|
|
.thread-tools,
|
|
.composer-actions {
|
|
width: 100%;
|
|
justify-content: flex-start;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.controls button,
|
|
.thread-tools button,
|
|
.composer-actions button {
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.composer-upload {
|
|
grid-template-columns: 56px minmax(0, 1fr);
|
|
}
|
|
|
|
.composer-upload img {
|
|
width: 56px;
|
|
height: 56px;
|
|
}
|
|
|
|
.composer-upload button {
|
|
grid-column: 1 / -1;
|
|
}
|
|
|
|
.mobile-sidebar-toggle {
|
|
display: inline-flex;
|
|
flex: 0 0 auto;
|
|
}
|
|
|
|
.debug-dock {
|
|
right: 12px;
|
|
bottom: 12px;
|
|
max-width: calc(100vw - 24px);
|
|
}
|
|
|
|
.banner {
|
|
top: 12px;
|
|
left: 12px;
|
|
right: 12px;
|
|
width: auto;
|
|
}
|
|
|
|
.empty-state {
|
|
padding: 18px;
|
|
}
|
|
}
|
|
</style>
|
|
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js"></script>
|
|
<script src="https://cdn.platform.openai.com/deployments/chatkit/chatkit.js" async></script>
|
|
</head>
|
|
|
|
<body>
|
|
<div class="shell">
|
|
<div id="status" class="banner hidden"></div>
|
|
|
|
<div id="error-panel" class="panel hidden">
|
|
<h2 id="error-title">Something needs attention</h2>
|
|
<p id="error-message"></p>
|
|
<ul>
|
|
<li>The workspace UI stays available even if native ChatKit fails.</li>
|
|
<li>Default thread titles now come from the first non-command line of your first prompt.</li>
|
|
<li>Use <code>?native=1</code> only if you explicitly want the OpenAI ChatKit widget.</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<button id="sidebar-backdrop" class="sidebar-backdrop hidden" type="button"
|
|
aria-label="Close thread list"></button>
|
|
|
|
<div id="workspace-app" class="chat-shell">
|
|
<aside class="sidebar">
|
|
<div class="sidebar-header">
|
|
<div class="sidebar-headline">
|
|
<button id="sidebar-toggle-btn" class="ghost icon-button" type="button"
|
|
aria-label="Collapse thread list">
|
|
<span id="sidebar-toggle-icon"><</span>
|
|
</button>
|
|
<div class="sidebar-copy">
|
|
<p class="eyebrow">Threads</p>
|
|
<h1>CodeAnywhere</h1>
|
|
</div>
|
|
</div>
|
|
<div class="controls">
|
|
<button id="magic-rename-all-btn" class="ghost" type="button">Magic rename all</button>
|
|
<button id="new-chat-btn" type="button">New chat</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="model-panel">
|
|
<label>
|
|
Active model
|
|
<input id="model-input" type="text" placeholder="gpt-5.4-mini">
|
|
</label>
|
|
<button id="save-model-btn" type="button">Save model</button>
|
|
<div id="model-hint" class="meta">Your first prompt creates the thread and uses this model.</div>
|
|
</div>
|
|
|
|
<div id="threads" class="threads"></div>
|
|
</aside>
|
|
|
|
<section class="conversation">
|
|
<div class="conversation-header">
|
|
<div class="conversation-heading">
|
|
<button id="mobile-sidebar-toggle-btn" class="ghost mobile-sidebar-toggle"
|
|
type="button">Threads</button>
|
|
<div>
|
|
<p class="eyebrow">Workspace chat</p>
|
|
<h2 id="thread-title">Start a chat</h2>
|
|
</div>
|
|
</div>
|
|
<div class="thread-tools">
|
|
<button id="magic-rename-btn" class="ghost" type="button">Magic rename</button>
|
|
<button id="rename-chat-btn" class="ghost" type="button">Rename</button>
|
|
<button id="delete-chat-btn" class="ghost" type="button">Delete</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="messages" class="messages"></div>
|
|
|
|
<form id="composer-form" class="composer">
|
|
<label>
|
|
Prompt
|
|
<textarea id="composer-input"
|
|
placeholder="Ask anything. The first line becomes the thread title."></textarea>
|
|
</label>
|
|
<input id="composer-image-input" class="hidden" type="file" accept="image/*" multiple>
|
|
<div id="composer-uploads" class="composer-uploads hidden"></div>
|
|
<div class="composer-actions">
|
|
<div class="composer-secondary-actions">
|
|
<button id="add-image-btn" class="ghost" type="button">Add image</button>
|
|
<span id="composer-hint">Shift+Enter for a newline. Press Enter to send.</span>
|
|
</div>
|
|
<button id="send-btn" type="submit">Send</button>
|
|
</div>
|
|
</form>
|
|
</section>
|
|
</div>
|
|
|
|
<openai-chatkit id="chat" class="native-chat hidden"></openai-chatkit>
|
|
|
|
<details id="debug-dock" class="debug-dock">
|
|
<summary>Debug</summary>
|
|
<pre id="debug-log"></pre>
|
|
</details>
|
|
</div>
|
|
|
|
<script>
|
|
const statusEl = document.getElementById('status');
|
|
const errorPanelEl = document.getElementById('error-panel');
|
|
const errorTitleEl = document.getElementById('error-title');
|
|
const errorMessageEl = document.getElementById('error-message');
|
|
const debugLogEl = document.getElementById('debug-log');
|
|
const workspaceAppEl = document.getElementById('workspace-app');
|
|
const chatEl = document.getElementById('chat');
|
|
const sidebarBackdropEl = document.getElementById('sidebar-backdrop');
|
|
const threadsEl = document.getElementById('threads');
|
|
const threadTitleEl = document.getElementById('thread-title');
|
|
const messagesEl = document.getElementById('messages');
|
|
const modelInputEl = document.getElementById('model-input');
|
|
const modelHintEl = document.getElementById('model-hint');
|
|
const composerFormEl = document.getElementById('composer-form');
|
|
const composerInputEl = document.getElementById('composer-input');
|
|
const composerImageInputEl = document.getElementById('composer-image-input');
|
|
const composerUploadsEl = document.getElementById('composer-uploads');
|
|
const composerHintEl = document.getElementById('composer-hint');
|
|
const addImageBtnEl = document.getElementById('add-image-btn');
|
|
const sendBtnEl = document.getElementById('send-btn');
|
|
const newChatBtnEl = document.getElementById('new-chat-btn');
|
|
const magicRenameAllBtnEl = document.getElementById('magic-rename-all-btn');
|
|
const magicRenameBtnEl = document.getElementById('magic-rename-btn');
|
|
const renameChatBtnEl = document.getElementById('rename-chat-btn');
|
|
const deleteChatBtnEl = document.getElementById('delete-chat-btn');
|
|
const saveModelBtnEl = document.getElementById('save-model-btn');
|
|
const sidebarToggleBtnEl = document.getElementById('sidebar-toggle-btn');
|
|
const sidebarToggleIconEl = document.getElementById('sidebar-toggle-icon');
|
|
const mobileSidebarToggleBtnEl = document.getElementById('mobile-sidebar-toggle-btn');
|
|
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
const wantsNativeChatKit = urlParams.get('native') === '1';
|
|
|
|
const workspaceState = {
|
|
config: null,
|
|
activeThreadId: null,
|
|
currentThread: null,
|
|
threads: [],
|
|
initialized: false,
|
|
sending: false,
|
|
magicRenaming: false,
|
|
magicRenamingAll: false,
|
|
sidebarCollapsed: false,
|
|
pendingAssistant: null,
|
|
traceHistory: {},
|
|
composerUploads: [],
|
|
uploadingImages: false,
|
|
};
|
|
|
|
let readyTimeout = null;
|
|
let statusTimeout = null;
|
|
|
|
if (window.marked && typeof window.marked.setOptions === 'function') {
|
|
window.marked.setOptions({
|
|
breaks: true,
|
|
gfm: true,
|
|
});
|
|
}
|
|
|
|
const appendLog = (message, data) => {
|
|
const rendered = data === undefined ? message : `${message}: ${JSON.stringify(data, null, 2)}`;
|
|
debugLogEl.textContent += `${new Date().toISOString()} ${rendered}\n`;
|
|
};
|
|
|
|
const reportClientEvent = async (name, data) => {
|
|
try {
|
|
await fetch('/client-log', {
|
|
method: 'POST',
|
|
credentials: 'same-origin',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ name, data }),
|
|
});
|
|
} catch (error) {
|
|
appendLog('client-log failed', error && error.message ? error.message : String(error));
|
|
}
|
|
};
|
|
|
|
const showStatus = (message) => {
|
|
if (statusTimeout) {
|
|
window.clearTimeout(statusTimeout);
|
|
statusTimeout = null;
|
|
}
|
|
statusEl.textContent = message;
|
|
statusEl.classList.remove('hidden');
|
|
appendLog('status', message);
|
|
};
|
|
|
|
const hideStatus = () => {
|
|
if (statusTimeout) {
|
|
window.clearTimeout(statusTimeout);
|
|
statusTimeout = null;
|
|
}
|
|
statusEl.classList.add('hidden');
|
|
};
|
|
|
|
const flashStatus = (message, duration = 2400) => {
|
|
showStatus(message);
|
|
statusTimeout = window.setTimeout(() => {
|
|
statusTimeout = null;
|
|
hideStatus();
|
|
}, duration);
|
|
};
|
|
|
|
const showPanel = (title, message) => {
|
|
errorTitleEl.textContent = title;
|
|
errorMessageEl.textContent = message;
|
|
errorPanelEl.classList.remove('hidden');
|
|
appendLog(title, message);
|
|
reportClientEvent(title, { message });
|
|
};
|
|
|
|
const hidePanel = () => {
|
|
errorPanelEl.classList.add('hidden');
|
|
};
|
|
|
|
const escapeHtml = (value) => String(value || '')
|
|
.replaceAll('&', '&')
|
|
.replaceAll('<', '<')
|
|
.replaceAll('>', '>')
|
|
.replaceAll('"', '"')
|
|
.replaceAll("'", ''');
|
|
|
|
const renderPlainText = (value) => escapeHtml(value);
|
|
|
|
const renderAssistantMarkdown = (value) => {
|
|
const source = String(value || '');
|
|
if (!source) {
|
|
return '';
|
|
}
|
|
if (!window.marked || typeof window.marked.parse !== 'function') {
|
|
return renderPlainText(source).replaceAll('\n', '<br>');
|
|
}
|
|
if (!window.DOMPurify || typeof window.DOMPurify.sanitize !== 'function') {
|
|
return renderPlainText(source).replaceAll('\n', '<br>');
|
|
}
|
|
try {
|
|
return window.DOMPurify.sanitize(window.marked.parse(source));
|
|
} catch (error) {
|
|
appendLog('markdown render failed', error && error.message ? error.message : String(error));
|
|
return renderPlainText(source).replaceAll('\n', '<br>');
|
|
}
|
|
};
|
|
|
|
const formatTimestamp = (value) => {
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) {
|
|
return '';
|
|
}
|
|
return new Intl.DateTimeFormat(undefined, {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
hour: 'numeric',
|
|
minute: '2-digit',
|
|
}).format(date);
|
|
};
|
|
|
|
const formatBytes = (value) => {
|
|
const size = Number(value || 0);
|
|
if (!Number.isFinite(size) || size <= 0) {
|
|
return '0 B';
|
|
}
|
|
if (size < 1024) {
|
|
return `${size} B`;
|
|
}
|
|
if (size < 1024 * 1024) {
|
|
return `${(size / 1024).toFixed(1)} KB`;
|
|
}
|
|
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
|
};
|
|
|
|
const uploadPreviewUrl = (upload) => upload && upload.preview_url
|
|
? String(upload.preview_url)
|
|
: `/uploads/${encodeURIComponent(String((upload && upload.id) || ''))}`;
|
|
|
|
const buildAttachmentPayload = (attachments) => Array.isArray(attachments)
|
|
? attachments.map((attachment) => ({ id: attachment.id }))
|
|
: [];
|
|
|
|
const _buildUserParts = (content, attachments) => {
|
|
const parts = [];
|
|
if (String(content || '').trim()) {
|
|
parts.push({ type: 'input_text', text: String(content || '').trim() });
|
|
}
|
|
for (const attachment of Array.isArray(attachments) ? attachments : []) {
|
|
parts.push({
|
|
type: 'input_image',
|
|
file_id: attachment.id,
|
|
name: attachment.name,
|
|
mime_type: attachment.mime_type,
|
|
detail: 'auto',
|
|
preview_url: uploadPreviewUrl(attachment),
|
|
});
|
|
}
|
|
return parts;
|
|
};
|
|
|
|
const messageImageParts = (message) => Array.isArray(message && message.parts)
|
|
? message.parts.filter((part) => part && part.type === 'input_image' && (part.preview_url || part.file_id))
|
|
: [];
|
|
|
|
const renderMessageImagesHtml = (message) => {
|
|
const imageParts = messageImageParts(message);
|
|
if (!imageParts.length) {
|
|
return '';
|
|
}
|
|
|
|
return `<div class="message-images">${imageParts.map((part) => {
|
|
const src = part.preview_url || `/uploads/${encodeURIComponent(String(part.file_id || ''))}`;
|
|
const title = String(part.name || 'Uploaded image');
|
|
return `
|
|
<figure class="message-image">
|
|
<img src="${escapeHtml(src)}" alt="${escapeHtml(title)}" loading="lazy">
|
|
<figcaption>${escapeHtml(title)}</figcaption>
|
|
</figure>
|
|
`;
|
|
}).join('')}</div>`;
|
|
};
|
|
|
|
const renderComposerUploads = () => {
|
|
const uploads = Array.isArray(workspaceState.composerUploads) ? workspaceState.composerUploads : [];
|
|
if (!uploads.length) {
|
|
composerUploadsEl.innerHTML = '';
|
|
composerUploadsEl.classList.add('hidden');
|
|
return;
|
|
}
|
|
|
|
composerUploadsEl.classList.remove('hidden');
|
|
composerUploadsEl.innerHTML = `
|
|
<div class="composer-upload-list">
|
|
${uploads.map((upload) => `
|
|
<div class="composer-upload">
|
|
<img src="${escapeHtml(uploadPreviewUrl(upload))}" alt="${escapeHtml(upload.name || 'Uploaded image')}" loading="lazy">
|
|
<div class="composer-upload-copy">
|
|
<span class="composer-upload-title">${escapeHtml(upload.name || 'Uploaded image')}</span>
|
|
<span class="composer-upload-meta">${escapeHtml(upload.mime_type || 'image')} · ${escapeHtml(formatBytes(upload.size))}</span>
|
|
</div>
|
|
<button type="button" class="ghost" data-remove-upload-id="${escapeHtml(upload.id || '')}">Remove</button>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
`;
|
|
|
|
composerUploadsEl.querySelectorAll('[data-remove-upload-id]').forEach((button) => {
|
|
button.addEventListener('click', async () => {
|
|
await removeComposerUpload(button.getAttribute('data-remove-upload-id'));
|
|
});
|
|
});
|
|
};
|
|
|
|
const clearComposerUploads = () => {
|
|
workspaceState.composerUploads = [];
|
|
renderComposerUploads();
|
|
};
|
|
|
|
const removeComposerUpload = async (uploadId) => {
|
|
const normalizedUploadId = String(uploadId || '');
|
|
if (!normalizedUploadId) {
|
|
return;
|
|
}
|
|
|
|
const remaining = workspaceState.composerUploads.filter((upload) => upload.id !== normalizedUploadId);
|
|
workspaceState.composerUploads = remaining;
|
|
renderComposerUploads();
|
|
|
|
try {
|
|
await apiJson(`/api/uploads/${encodeURIComponent(normalizedUploadId)}`, { method: 'DELETE' });
|
|
} catch (error) {
|
|
appendLog('upload delete failed', error && error.message ? error.message : String(error));
|
|
}
|
|
};
|
|
|
|
const readFileAsDataUrl = (file) => new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onload = () => resolve(String(reader.result || ''));
|
|
reader.onerror = () => reject(new Error(`Failed to read ${file.name || 'image'}`));
|
|
reader.readAsDataURL(file);
|
|
});
|
|
|
|
const uploadSelectedImages = async (files) => {
|
|
const selectedFiles = Array.from(files || []).filter(Boolean);
|
|
if (!selectedFiles.length) {
|
|
return;
|
|
}
|
|
|
|
workspaceState.uploadingImages = true;
|
|
updateActionButtons();
|
|
showStatus(`Uploading ${selectedFiles.length} image${selectedFiles.length === 1 ? '' : 's'}...`);
|
|
|
|
try {
|
|
for (const file of selectedFiles) {
|
|
if (!String(file.type || '').startsWith('image/')) {
|
|
throw new Error(`${file.name || 'This file'} is not an image.`);
|
|
}
|
|
|
|
const dataUrl = await readFileAsDataUrl(file);
|
|
const payload = await apiJson('/api/uploads', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
name: file.name || 'image',
|
|
mimeType: file.type || 'image/png',
|
|
dataUrl,
|
|
}),
|
|
});
|
|
|
|
if (payload && payload.upload) {
|
|
workspaceState.composerUploads = workspaceState.composerUploads.concat([payload.upload]);
|
|
}
|
|
}
|
|
|
|
renderComposerUploads();
|
|
flashStatus(`Image${selectedFiles.length === 1 ? '' : 's'} ready to send.`);
|
|
} catch (error) {
|
|
hideStatus();
|
|
throw error;
|
|
} finally {
|
|
workspaceState.uploadingImages = false;
|
|
composerImageInputEl.value = '';
|
|
updateActionButtons();
|
|
}
|
|
};
|
|
|
|
const threadInitial = (title) => {
|
|
const match = String(title || '').trim().match(/[A-Za-z0-9]/);
|
|
return match ? match[0].toUpperCase() : '#';
|
|
};
|
|
|
|
const readSidebarPreference = () => {
|
|
try {
|
|
const saved = window.localStorage.getItem('codeanywhere.sidebarCollapsed');
|
|
if (saved !== null) {
|
|
return saved === '1';
|
|
}
|
|
} catch (error) {
|
|
appendLog('sidebar preference unavailable', error && error.message ? error.message : String(error));
|
|
}
|
|
return window.innerWidth <= 900;
|
|
};
|
|
|
|
const isMobileViewport = () => window.matchMedia('(max-width: 900px)').matches;
|
|
|
|
const isMagicRenameCandidate = (thread) => {
|
|
if (!thread) {
|
|
return false;
|
|
}
|
|
const messageCount = Number(thread.message_count || (thread.messages ? thread.messages.length : 0) || 0);
|
|
const titleSource = String(thread.title_source || '').trim().toLowerCase();
|
|
return messageCount > 0 && !['manual', 'magic'].includes(titleSource);
|
|
};
|
|
|
|
const countMagicRenameCandidates = () => workspaceState.threads.filter(isMagicRenameCandidate).length;
|
|
|
|
const updateActionButtons = () => {
|
|
const busy = workspaceState.sending || workspaceState.magicRenaming || workspaceState.magicRenamingAll;
|
|
const currentThread = workspaceState.currentThread;
|
|
const currentMessageCount = Number(
|
|
(currentThread && (currentThread.message_count || (currentThread.messages ? currentThread.messages.length : 0))) || 0
|
|
);
|
|
const candidateCount = countMagicRenameCandidates();
|
|
|
|
newChatBtnEl.disabled = workspaceState.magicRenaming || workspaceState.magicRenamingAll;
|
|
newChatBtnEl.textContent = workspaceState.sidebarCollapsed ? '+' : 'New chat';
|
|
newChatBtnEl.title = workspaceState.sidebarCollapsed ? 'New chat' : 'Create a new chat';
|
|
|
|
magicRenameAllBtnEl.disabled = workspaceState.magicRenaming || workspaceState.magicRenamingAll || candidateCount === 0;
|
|
magicRenameAllBtnEl.textContent = workspaceState.sidebarCollapsed
|
|
? (candidateCount > 0 ? `AI ${candidateCount}` : 'AI')
|
|
: (candidateCount > 0 ? `Magic rename all (${candidateCount})` : 'Magic rename all');
|
|
magicRenameAllBtnEl.title = candidateCount > 0
|
|
? `Use gpt-5.4-nano to rename ${candidateCount} eligible threads`
|
|
: 'Only untouched auto-titled conversations are eligible';
|
|
|
|
sendBtnEl.disabled = busy || workspaceState.uploadingImages;
|
|
addImageBtnEl.disabled = busy || workspaceState.uploadingImages;
|
|
magicRenameBtnEl.disabled = busy || !currentThread || currentMessageCount === 0;
|
|
renameChatBtnEl.disabled = busy || !currentThread;
|
|
deleteChatBtnEl.disabled = busy || !currentThread;
|
|
saveModelBtnEl.disabled = busy || !currentThread;
|
|
};
|
|
|
|
const updateSidebarUi = () => {
|
|
const mobile = isMobileViewport();
|
|
document.body.classList.toggle('sidebar-collapsed', workspaceState.sidebarCollapsed);
|
|
document.body.classList.toggle('mobile-sidebar-open', mobile && !workspaceState.sidebarCollapsed);
|
|
sidebarToggleIconEl.textContent = workspaceState.sidebarCollapsed ? '>' : '<';
|
|
sidebarToggleBtnEl.title = workspaceState.sidebarCollapsed ? 'Expand thread list' : 'Collapse thread list';
|
|
mobileSidebarToggleBtnEl.textContent = workspaceState.sidebarCollapsed ? 'Threads' : 'Hide threads';
|
|
mobileSidebarToggleBtnEl.setAttribute('aria-expanded', String(!workspaceState.sidebarCollapsed));
|
|
sidebarBackdropEl.classList.toggle('hidden', !(mobile && !workspaceState.sidebarCollapsed));
|
|
updateActionButtons();
|
|
};
|
|
|
|
const setSidebarCollapsed = (collapsed) => {
|
|
workspaceState.sidebarCollapsed = collapsed;
|
|
try {
|
|
window.localStorage.setItem('codeanywhere.sidebarCollapsed', collapsed ? '1' : '0');
|
|
} catch (error) {
|
|
appendLog('sidebar preference save failed', error && error.message ? error.message : String(error));
|
|
}
|
|
updateSidebarUi();
|
|
};
|
|
|
|
const apiJson = async (url, init = {}) => {
|
|
const response = await fetch(url, {
|
|
credentials: 'same-origin',
|
|
...init,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
...(init.headers || {}),
|
|
},
|
|
});
|
|
const text = await response.text();
|
|
let payload = null;
|
|
if (text) {
|
|
try {
|
|
payload = JSON.parse(text);
|
|
} catch (error) {
|
|
payload = null;
|
|
}
|
|
}
|
|
if (!response.ok) {
|
|
const detail = payload && payload.detail ? payload.detail : text || `Request failed (${response.status})`;
|
|
throw new Error(detail);
|
|
}
|
|
return payload || {};
|
|
};
|
|
|
|
const parseSseBlock = (block) => {
|
|
const normalized = String(block || '').trim();
|
|
if (!normalized) {
|
|
return null;
|
|
}
|
|
|
|
let event = 'message';
|
|
const dataLines = [];
|
|
|
|
for (const line of normalized.split('\n')) {
|
|
if (!line || line.startsWith(':')) {
|
|
continue;
|
|
}
|
|
if (line.startsWith('event:')) {
|
|
event = line.slice(6).trim();
|
|
continue;
|
|
}
|
|
if (line.startsWith('data:')) {
|
|
dataLines.push(line.slice(5).trimStart());
|
|
}
|
|
}
|
|
|
|
if (!dataLines.length) {
|
|
return null;
|
|
}
|
|
|
|
const rawData = dataLines.join('\n');
|
|
let data;
|
|
try {
|
|
data = JSON.parse(rawData);
|
|
} catch (error) {
|
|
data = { message: rawData };
|
|
}
|
|
return { event, data };
|
|
};
|
|
|
|
const consumeEventStream = async (url, init = {}, handlers = {}) => {
|
|
const response = await fetch(url, {
|
|
credentials: 'same-origin',
|
|
...init,
|
|
headers: {
|
|
Accept: 'text/event-stream',
|
|
'Content-Type': 'application/json',
|
|
...(init.headers || {}),
|
|
},
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const text = await response.text();
|
|
let detail = text || `Request failed (${response.status})`;
|
|
if (text) {
|
|
try {
|
|
const payload = JSON.parse(text);
|
|
if (payload && payload.detail) {
|
|
detail = payload.detail;
|
|
}
|
|
} catch (error) {
|
|
}
|
|
}
|
|
throw new Error(detail);
|
|
}
|
|
|
|
if (!response.body) {
|
|
throw new Error('Stream response body is unavailable');
|
|
}
|
|
|
|
const reader = response.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
let buffer = '';
|
|
|
|
const dispatchBlock = (block) => {
|
|
const parsed = parseSseBlock(block);
|
|
if (!parsed) {
|
|
return;
|
|
}
|
|
|
|
if (parsed.event === 'progress' && typeof handlers.onProgress === 'function') {
|
|
handlers.onProgress(parsed.data);
|
|
return;
|
|
}
|
|
|
|
if (parsed.event === 'final' && typeof handlers.onFinal === 'function') {
|
|
handlers.onFinal(parsed.data);
|
|
return;
|
|
}
|
|
|
|
if (parsed.event === 'error') {
|
|
if (typeof handlers.onError === 'function') {
|
|
handlers.onError(parsed.data);
|
|
return;
|
|
}
|
|
throw new Error(parsed.data && parsed.data.message ? parsed.data.message : 'The stream failed.');
|
|
}
|
|
};
|
|
|
|
while (true) {
|
|
const { value, done } = await reader.read();
|
|
buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
|
|
buffer = buffer.replaceAll('\r\n', '\n');
|
|
|
|
let boundaryIndex = buffer.indexOf('\n\n');
|
|
while (boundaryIndex !== -1) {
|
|
const block = buffer.slice(0, boundaryIndex);
|
|
buffer = buffer.slice(boundaryIndex + 2);
|
|
dispatchBlock(block);
|
|
boundaryIndex = buffer.indexOf('\n\n');
|
|
}
|
|
|
|
if (done) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (buffer.trim()) {
|
|
dispatchBlock(buffer);
|
|
}
|
|
};
|
|
|
|
const streamThreadMessage = async (threadId, content, attachments = []) => {
|
|
let finalPayload = null;
|
|
|
|
await consumeEventStream(`/api/threads/${threadId}/messages/stream`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
content,
|
|
attachments: buildAttachmentPayload(attachments),
|
|
}),
|
|
}, {
|
|
onProgress: (data) => {
|
|
applyProgressEvent(threadId, data);
|
|
},
|
|
onFinal: (data) => {
|
|
finalPayload = data;
|
|
},
|
|
onError: (data) => {
|
|
throw new Error(data && data.message ? data.message : 'The stream failed.');
|
|
},
|
|
});
|
|
|
|
if (!finalPayload) {
|
|
throw new Error('The stream ended before a final reply arrived.');
|
|
}
|
|
|
|
return finalPayload;
|
|
};
|
|
|
|
const cloneTraceItems = (traceItems) => Array.isArray(traceItems)
|
|
? traceItems.map((item) => ({ ...item }))
|
|
: [];
|
|
|
|
const clearTraceHistory = (threadId) => {
|
|
if (!threadId || !workspaceState.traceHistory[threadId]) {
|
|
return;
|
|
}
|
|
const nextTraceHistory = { ...workspaceState.traceHistory };
|
|
delete nextTraceHistory[threadId];
|
|
workspaceState.traceHistory = nextTraceHistory;
|
|
};
|
|
|
|
const upsertPendingTraceItem = (pendingAssistant, entry) => {
|
|
const traceItems = cloneTraceItems(pendingAssistant.traceItems);
|
|
const key = String(entry.key || `${entry.category}:${entry.title}`);
|
|
const outputDetail = entry.output_detail ? String(entry.output_detail) : '';
|
|
const normalizedEntry = {
|
|
key,
|
|
category: String(entry.category || 'trace'),
|
|
tool_name: String(entry.tool_name || ''),
|
|
title: String(entry.title || entry.text || 'Trace event'),
|
|
summary: String(entry.summary || entry.text || ''),
|
|
detail: String(entry.detail || ''),
|
|
output_detail: outputDetail,
|
|
};
|
|
const index = traceItems.findIndex((item) => item.key === key);
|
|
if (index === -1) {
|
|
traceItems.push(normalizedEntry);
|
|
} else {
|
|
// Merge: keep input detail from the call, append output_detail
|
|
const merged = { ...traceItems[index], ...normalizedEntry };
|
|
if (!merged.detail && traceItems[index].detail) {
|
|
merged.detail = traceItems[index].detail;
|
|
}
|
|
if (outputDetail && !traceItems[index].output_detail) {
|
|
merged.output_detail = outputDetail;
|
|
}
|
|
traceItems[index] = merged;
|
|
}
|
|
pendingAssistant.traceItems = traceItems;
|
|
};
|
|
|
|
const applyProgressEvent = (threadId, data) => {
|
|
const text = data && data.text ? String(data.text) : '';
|
|
const summary = data && data.summary ? String(data.summary) : '';
|
|
const kind = data && data.kind ? String(data.kind) : (data && data.category ? 'trace' : 'status');
|
|
const isTraceOnly = kind === 'trace' && data && data.category;
|
|
const existing = workspaceState.pendingAssistant;
|
|
const pendingAssistant = existing && existing.threadId === threadId
|
|
? {
|
|
...existing,
|
|
traceItems: cloneTraceItems(existing.traceItems),
|
|
}
|
|
: {
|
|
id: `local-status-${Date.now()}`,
|
|
threadId,
|
|
role: 'assistant',
|
|
content: isTraceOnly ? 'Working...' : (text || summary || 'Working...'),
|
|
created_at: new Date().toISOString(),
|
|
pending: true,
|
|
kind: isTraceOnly ? (existing ? existing.kind || 'status' : 'status') : kind,
|
|
traceItems: [],
|
|
};
|
|
|
|
// Only update the visible status text for status/reasoning events,
|
|
// not for individual tool call trace events (those go into the collapsed counts).
|
|
if (!isTraceOnly) {
|
|
if (text.trim()) {
|
|
pendingAssistant.content = text;
|
|
pendingAssistant.kind = kind;
|
|
} else if (!pendingAssistant.content && summary.trim()) {
|
|
pendingAssistant.content = summary;
|
|
pendingAssistant.kind = kind;
|
|
}
|
|
}
|
|
|
|
if (data && data.category) {
|
|
upsertPendingTraceItem(pendingAssistant, data);
|
|
}
|
|
|
|
workspaceState.pendingAssistant = pendingAssistant;
|
|
if (workspaceState.currentThread && workspaceState.currentThread.id === threadId) {
|
|
renderMessages();
|
|
}
|
|
};
|
|
|
|
const setPendingAssistant = (threadId, text, kind = 'status') => {
|
|
const content = String(text || '');
|
|
const existing = workspaceState.pendingAssistant;
|
|
if (!content.trim() && !(existing && existing.threadId === threadId)) {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
existing
|
|
&& existing.threadId === threadId
|
|
&& existing.content === content
|
|
&& existing.kind === kind
|
|
) {
|
|
return;
|
|
}
|
|
|
|
if (existing && existing.threadId === threadId) {
|
|
workspaceState.pendingAssistant = {
|
|
...existing,
|
|
content,
|
|
kind,
|
|
traceItems: cloneTraceItems(existing.traceItems),
|
|
};
|
|
} else {
|
|
workspaceState.pendingAssistant = {
|
|
id: `local-status-${Date.now()}`,
|
|
threadId,
|
|
role: 'assistant',
|
|
content,
|
|
created_at: new Date().toISOString(),
|
|
pending: true,
|
|
kind,
|
|
traceItems: [],
|
|
};
|
|
}
|
|
|
|
if (workspaceState.currentThread && workspaceState.currentThread.id === threadId) {
|
|
renderMessages();
|
|
}
|
|
};
|
|
|
|
const preservePendingAssistantTrace = (threadId) => {
|
|
const pendingAssistant = workspaceState.pendingAssistant;
|
|
if (
|
|
!pendingAssistant
|
|
|| pendingAssistant.threadId !== threadId
|
|
|| !Array.isArray(pendingAssistant.traceItems)
|
|
|| !pendingAssistant.traceItems.length
|
|
) {
|
|
return;
|
|
}
|
|
|
|
workspaceState.traceHistory = {
|
|
...workspaceState.traceHistory,
|
|
[threadId]: {
|
|
id: `trace-${pendingAssistant.id}`,
|
|
role: 'assistant',
|
|
traceOnly: true,
|
|
content: pendingAssistant.content,
|
|
created_at: pendingAssistant.created_at,
|
|
traceItems: cloneTraceItems(pendingAssistant.traceItems),
|
|
},
|
|
};
|
|
};
|
|
|
|
const clearPendingAssistant = (threadId = null, { preserveTrace = false } = {}) => {
|
|
if (!workspaceState.pendingAssistant) {
|
|
return;
|
|
}
|
|
if (threadId && workspaceState.pendingAssistant.threadId !== threadId) {
|
|
return;
|
|
}
|
|
if (preserveTrace) {
|
|
preservePendingAssistantTrace(workspaceState.pendingAssistant.threadId);
|
|
}
|
|
workspaceState.pendingAssistant = null;
|
|
};
|
|
|
|
const renderTraceStackHtml = (traceItems) => {
|
|
if (!Array.isArray(traceItems) || !traceItems.length) {
|
|
return '';
|
|
}
|
|
|
|
// Group items by category for collapsed count display
|
|
const groups = {};
|
|
for (const entry of traceItems) {
|
|
const cat = String(entry.category || 'trace');
|
|
if (!groups[cat]) {
|
|
groups[cat] = [];
|
|
}
|
|
groups[cat].push(entry);
|
|
}
|
|
|
|
// Build collapsed count summary line: "🔧 12 read_file · 5 shell_run · 2 handoffs"
|
|
const countParts = [];
|
|
for (const [cat, items] of Object.entries(groups)) {
|
|
if (cat === 'tool_call') {
|
|
// Sub-group by tool_name
|
|
const toolGroups = {};
|
|
for (const item of items) {
|
|
const toolName = item.tool_name || item.title || 'tool';
|
|
if (!toolGroups[toolName]) {
|
|
toolGroups[toolName] = [];
|
|
}
|
|
toolGroups[toolName].push(item);
|
|
}
|
|
for (const [toolName, toolItems] of Object.entries(toolGroups)) {
|
|
countParts.push({ label: toolName, count: toolItems.length, items: toolItems, cat });
|
|
}
|
|
} else {
|
|
const label = cat === 'subagent' ? 'handoffs' : cat.replaceAll('_', ' ');
|
|
countParts.push({ label, count: items.length, items, cat });
|
|
}
|
|
}
|
|
countParts.sort((a, b) => b.count - a.count);
|
|
|
|
const summaryLine = countParts.map((part) =>
|
|
`${part.count} ${escapeHtml(part.label)}`
|
|
).join(' · ');
|
|
|
|
// Render each group's items as expandable detail rows
|
|
const detailRows = traceItems.map((entry) => {
|
|
const inputSection = entry.detail
|
|
? `<div class="trace-detail-section"><span class="trace-detail-label">Input:</span>\n${renderPlainText(entry.detail)}</div>`
|
|
: '';
|
|
const outputSection = entry.output_detail
|
|
? `<div class="trace-detail-section"><span class="trace-detail-label">Output:</span>\n${renderPlainText(entry.output_detail)}</div>`
|
|
: '';
|
|
const detailContent = inputSection || outputSection
|
|
? `${inputSection}${outputSection}`
|
|
: renderPlainText('No additional detail captured.');
|
|
return `
|
|
<details class="trace-bubble ${escapeHtml(entry.category || 'trace')}">
|
|
<summary>
|
|
<span class="trace-kicker">${escapeHtml(String(entry.category || 'trace').replaceAll('_', ' '))}</span>
|
|
<span class="trace-title">${escapeHtml(entry.title || entry.summary || 'Trace event')}</span>
|
|
<span class="trace-summary">${escapeHtml(entry.summary || 'Expand for details')}</span>
|
|
</summary>
|
|
<div class="trace-body">${detailContent}</div>
|
|
</details>
|
|
`;
|
|
}).join('');
|
|
|
|
return `<div class="trace-stack">
|
|
<details class="trace-collapsed-summary">
|
|
<summary class="trace-counts-line">🔧 ${summaryLine}</summary>
|
|
<div class="trace-expanded-items">${detailRows}</div>
|
|
</details>
|
|
</div>`;
|
|
};
|
|
|
|
const renderMessageBodyHtml = (message) => {
|
|
if (message.traceOnly) {
|
|
const summaryHtml = message.content
|
|
? `<div class="message-body is-plain">${renderPlainText(message.content)}</div>`
|
|
: '';
|
|
return `${renderTraceStackHtml(message.traceItems)}${summaryHtml}`;
|
|
}
|
|
|
|
const trimmedContent = String(message.content || '').trim();
|
|
let bodyHtml = '';
|
|
if (trimmedContent) {
|
|
if (message.role === 'assistant' && !message.pending) {
|
|
bodyHtml = `<div class="message-body markdown-body">${renderAssistantMarkdown(message.content)}</div>`;
|
|
} else {
|
|
bodyHtml = `<div class="message-body is-plain">${renderPlainText(message.content)}</div>`;
|
|
}
|
|
}
|
|
|
|
const imagesHtml = renderMessageImagesHtml(message);
|
|
if (message.pending) {
|
|
// Trace counts above status text — not after
|
|
return `${renderTraceStackHtml(message.traceItems)}${bodyHtml}${imagesHtml}`;
|
|
}
|
|
return `${bodyHtml}${imagesHtml}`;
|
|
};
|
|
|
|
const messageLabel = (message) => {
|
|
const timestamp = escapeHtml(formatTimestamp(message.created_at) || 'now');
|
|
if (message.role === 'user') {
|
|
return `You | ${timestamp}`;
|
|
}
|
|
if (message.traceOnly) {
|
|
return `CodeAnywhere | run trace | ${timestamp}`;
|
|
}
|
|
if (message.pending) {
|
|
const phase = message.kind === 'reasoning' ? 'thinking' : 'live status';
|
|
return `CodeAnywhere | ${phase} | ${timestamp}`;
|
|
}
|
|
return `CodeAnywhere | ${timestamp}`;
|
|
};
|
|
|
|
const renderThreads = () => {
|
|
if (!workspaceState.threads.length) {
|
|
threadsEl.innerHTML = '<div class="empty-state">Threads show up here after your first prompt. The sidebar stays compact, not full-screen.</div>';
|
|
updateActionButtons();
|
|
return;
|
|
}
|
|
|
|
threadsEl.innerHTML = workspaceState.threads.map((thread) => {
|
|
const activeClass = thread.id === workspaceState.activeThreadId ? 'active' : '';
|
|
const updated = formatTimestamp(thread.updated_at) || 'just now';
|
|
return `
|
|
<button type="button" class="thread-card ${activeClass}" data-thread-id="${thread.id}" title="${escapeHtml(thread.title)}">
|
|
<span class="thread-badge">${escapeHtml(threadInitial(thread.title))}</span>
|
|
<span class="thread-copy">
|
|
<strong class="thread-title-text">${escapeHtml(thread.title)}</strong>
|
|
<span class="meta">${escapeHtml(thread.model)}</span>
|
|
<span class="meta">Updated ${escapeHtml(updated)}</span>
|
|
</span>
|
|
</button>
|
|
`;
|
|
}).join('');
|
|
|
|
threadsEl.querySelectorAll('[data-thread-id]').forEach((button) => {
|
|
button.addEventListener('click', async () => {
|
|
await loadThread(button.dataset.threadId);
|
|
});
|
|
});
|
|
|
|
updateActionButtons();
|
|
};
|
|
|
|
const renderMessages = () => {
|
|
const thread = workspaceState.currentThread;
|
|
|
|
if (!thread) {
|
|
threadTitleEl.textContent = 'Start a chat';
|
|
messagesEl.innerHTML = '<div class="empty-state">Type your first prompt. Its first line becomes the thread title, and the sidebar will update automatically.</div>';
|
|
composerHintEl.textContent = 'Your first prompt creates the thread. Shift+Enter adds a newline, and images can be attached.';
|
|
modelHintEl.textContent = 'The first prompt uses the model shown here.';
|
|
modelInputEl.disabled = false;
|
|
if (!modelInputEl.value.trim()) {
|
|
modelInputEl.value = workspaceState.config ? workspaceState.config.defaultModel : 'gpt-5.4-mini';
|
|
}
|
|
updateActionButtons();
|
|
return;
|
|
}
|
|
|
|
threadTitleEl.textContent = thread.title;
|
|
composerHintEl.textContent = `Current model: ${thread.model}. Attach images or type a prompt.`;
|
|
modelHintEl.textContent = 'Save a model override for this thread, or use /model <name> in chat.';
|
|
modelInputEl.disabled = false;
|
|
modelInputEl.value = thread.model;
|
|
|
|
const messages = Array.isArray(thread.messages) ? thread.messages.slice() : [];
|
|
if (workspaceState.traceHistory[thread.id]) {
|
|
messages.push(workspaceState.traceHistory[thread.id]);
|
|
}
|
|
if (workspaceState.pendingAssistant && workspaceState.pendingAssistant.threadId === thread.id) {
|
|
messages.push(workspaceState.pendingAssistant);
|
|
}
|
|
|
|
if (!messages.length) {
|
|
messagesEl.innerHTML = '<div class="empty-state">This thread is empty. The first non-command line you send becomes its title.</div>';
|
|
updateActionButtons();
|
|
return;
|
|
}
|
|
|
|
messagesEl.innerHTML = messages.map((message) => {
|
|
const pendingClass = message.pending ? ' pending' : '';
|
|
const traceOnlyClass = message.traceOnly ? ' trace-only' : '';
|
|
return `
|
|
<article class="message ${message.role}${pendingClass}${traceOnlyClass}">
|
|
<div class="message-label">${messageLabel(message)}</div>
|
|
${renderMessageBodyHtml(message)}
|
|
</article>
|
|
`;
|
|
}).join('');
|
|
|
|
messagesEl.scrollTop = messagesEl.scrollHeight;
|
|
updateActionButtons();
|
|
};
|
|
|
|
const refreshThreads = async (preferredThreadId) => {
|
|
const data = await apiJson('/api/threads', { method: 'GET' });
|
|
workspaceState.threads = data.threads || [];
|
|
|
|
if (preferredThreadId) {
|
|
workspaceState.activeThreadId = preferredThreadId;
|
|
} else if (!workspaceState.activeThreadId || !workspaceState.threads.some((thread) => thread.id === workspaceState.activeThreadId)) {
|
|
workspaceState.activeThreadId = workspaceState.threads[0] ? workspaceState.threads[0].id : null;
|
|
}
|
|
|
|
renderThreads();
|
|
|
|
if (!workspaceState.activeThreadId) {
|
|
workspaceState.currentThread = null;
|
|
renderMessages();
|
|
return;
|
|
}
|
|
|
|
const threadData = await apiJson(`/api/threads/${workspaceState.activeThreadId}`, { method: 'GET' });
|
|
workspaceState.currentThread = threadData.thread;
|
|
renderMessages();
|
|
};
|
|
|
|
const createThread = async () => {
|
|
showStatus('Creating a new chat...');
|
|
const selectedModel = modelInputEl.value.trim() || (workspaceState.config ? workspaceState.config.defaultModel : 'gpt-5.4-mini');
|
|
const data = await apiJson('/api/threads', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ model: selectedModel }),
|
|
});
|
|
workspaceState.activeThreadId = data.thread.id;
|
|
await refreshThreads(data.thread.id);
|
|
if (isMobileViewport()) {
|
|
setSidebarCollapsed(true);
|
|
}
|
|
hideStatus();
|
|
};
|
|
|
|
const loadThread = async (threadId) => {
|
|
workspaceState.activeThreadId = threadId;
|
|
const data = await apiJson(`/api/threads/${threadId}`, { method: 'GET' });
|
|
workspaceState.currentThread = data.thread;
|
|
renderThreads();
|
|
renderMessages();
|
|
if (isMobileViewport()) {
|
|
setSidebarCollapsed(true);
|
|
}
|
|
};
|
|
|
|
const renameThread = async () => {
|
|
if (!workspaceState.currentThread) {
|
|
return;
|
|
}
|
|
const nextTitle = window.prompt('Rename thread', workspaceState.currentThread.title);
|
|
if (!nextTitle) {
|
|
return;
|
|
}
|
|
showStatus('Renaming thread...');
|
|
await apiJson(`/api/threads/${workspaceState.currentThread.id}`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify({ title: nextTitle }),
|
|
});
|
|
await refreshThreads(workspaceState.currentThread.id);
|
|
hideStatus();
|
|
};
|
|
|
|
const magicRenameThread = async () => {
|
|
if (!workspaceState.currentThread) {
|
|
return;
|
|
}
|
|
|
|
const threadId = workspaceState.currentThread.id;
|
|
workspaceState.magicRenaming = true;
|
|
updateActionButtons();
|
|
showStatus('Summarizing this thread with gpt-5.4-nano...');
|
|
|
|
try {
|
|
const data = await apiJson(`/api/threads/${threadId}/magic-rename`, {
|
|
method: 'POST',
|
|
});
|
|
workspaceState.currentThread = data.thread;
|
|
await refreshThreads(threadId);
|
|
flashStatus('Thread renamed with gpt-5.4-nano.');
|
|
} catch (error) {
|
|
hideStatus();
|
|
showPanel('Magic rename failed', error && error.message ? error.message : String(error));
|
|
} finally {
|
|
workspaceState.magicRenaming = false;
|
|
updateActionButtons();
|
|
}
|
|
};
|
|
|
|
const magicRenameAllThreads = async () => {
|
|
const candidateCount = countMagicRenameCandidates();
|
|
if (!candidateCount) {
|
|
flashStatus('No eligible threads to rename.');
|
|
return;
|
|
}
|
|
|
|
workspaceState.magicRenamingAll = true;
|
|
updateActionButtons();
|
|
showStatus(`Summarizing ${candidateCount} threads with gpt-5.4-nano...`);
|
|
|
|
try {
|
|
const data = await apiJson('/api/threads/magic-rename-all', {
|
|
method: 'POST',
|
|
});
|
|
await refreshThreads(workspaceState.activeThreadId);
|
|
const renamedCount = Number(data.renamedCount || 0);
|
|
const skippedCount = Number(data.skippedCount || 0);
|
|
if (renamedCount > 0) {
|
|
flashStatus(
|
|
skippedCount > 0
|
|
? `Renamed ${renamedCount} threads. Skipped ${skippedCount}.`
|
|
: `Renamed ${renamedCount} threads.`
|
|
);
|
|
} else {
|
|
flashStatus('No eligible threads were renamed.');
|
|
}
|
|
} catch (error) {
|
|
hideStatus();
|
|
showPanel('Magic rename all failed', error && error.message ? error.message : String(error));
|
|
} finally {
|
|
workspaceState.magicRenamingAll = false;
|
|
updateActionButtons();
|
|
}
|
|
};
|
|
|
|
const deleteThread = async () => {
|
|
if (!workspaceState.currentThread) {
|
|
return;
|
|
}
|
|
const confirmed = window.confirm(`Delete thread "${workspaceState.currentThread.title}"?`);
|
|
if (!confirmed) {
|
|
return;
|
|
}
|
|
showStatus('Deleting thread...');
|
|
const deletedThreadId = workspaceState.currentThread.id;
|
|
await apiJson(`/api/threads/${deletedThreadId}`, { method: 'DELETE' });
|
|
workspaceState.activeThreadId = workspaceState.threads.find((thread) => thread.id !== deletedThreadId)?.id || null;
|
|
await refreshThreads(workspaceState.activeThreadId);
|
|
hideStatus();
|
|
};
|
|
|
|
const saveModel = async () => {
|
|
if (!workspaceState.currentThread) {
|
|
return;
|
|
}
|
|
const model = modelInputEl.value.trim();
|
|
if (!model) {
|
|
showPanel('Model is required', 'Enter a model name before saving it for this thread.');
|
|
return;
|
|
}
|
|
showStatus('Saving model...');
|
|
await apiJson(`/api/threads/${workspaceState.currentThread.id}`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify({ model }),
|
|
});
|
|
await refreshThreads(workspaceState.currentThread.id);
|
|
hideStatus();
|
|
};
|
|
|
|
const sendMessage = async () => {
|
|
const content = composerInputEl.value.trim();
|
|
const attachments = Array.isArray(workspaceState.composerUploads) ? workspaceState.composerUploads.slice() : [];
|
|
if ((!content && !attachments.length) || workspaceState.sending || workspaceState.uploadingImages) {
|
|
return;
|
|
}
|
|
|
|
if (!workspaceState.currentThread) {
|
|
await createThread();
|
|
}
|
|
|
|
workspaceState.sending = true;
|
|
sendBtnEl.textContent = 'Sending...';
|
|
sendBtnEl.disabled = true;
|
|
hidePanel();
|
|
showStatus('Running the agent...');
|
|
|
|
const threadId = workspaceState.currentThread.id;
|
|
clearTraceHistory(threadId);
|
|
const optimisticMessages = (workspaceState.currentThread.messages || []).concat([
|
|
{
|
|
id: `local-${Date.now()}`,
|
|
role: 'user',
|
|
content,
|
|
parts: attachments.length ? _buildUserParts(content, attachments) : [],
|
|
created_at: new Date().toISOString(),
|
|
},
|
|
]);
|
|
|
|
workspaceState.currentThread = {
|
|
...workspaceState.currentThread,
|
|
messages: optimisticMessages,
|
|
};
|
|
setPendingAssistant(threadId, 'Working...', 'status');
|
|
|
|
try {
|
|
const data = await streamThreadMessage(threadId, content, attachments);
|
|
clearPendingAssistant(threadId, { preserveTrace: true });
|
|
workspaceState.currentThread = data.thread;
|
|
composerInputEl.value = '';
|
|
clearComposerUploads();
|
|
renderMessages();
|
|
await refreshThreads(threadId);
|
|
hideStatus();
|
|
} catch (error) {
|
|
clearPendingAssistant(threadId, { preserveTrace: true });
|
|
renderMessages();
|
|
showPanel('Message failed', error && error.message ? error.message : String(error));
|
|
await refreshThreads(threadId);
|
|
hideStatus();
|
|
} finally {
|
|
workspaceState.sending = false;
|
|
sendBtnEl.textContent = 'Send';
|
|
renderMessages();
|
|
}
|
|
};
|
|
|
|
const bindWorkspaceUi = () => {
|
|
if (workspaceState.initialized) {
|
|
return;
|
|
}
|
|
workspaceState.initialized = true;
|
|
|
|
sidebarToggleBtnEl.addEventListener('click', () => {
|
|
setSidebarCollapsed(!workspaceState.sidebarCollapsed);
|
|
});
|
|
|
|
mobileSidebarToggleBtnEl.addEventListener('click', () => {
|
|
setSidebarCollapsed(!workspaceState.sidebarCollapsed);
|
|
});
|
|
|
|
sidebarBackdropEl.addEventListener('click', () => {
|
|
setSidebarCollapsed(true);
|
|
});
|
|
|
|
window.addEventListener('resize', () => {
|
|
updateSidebarUi();
|
|
});
|
|
|
|
newChatBtnEl.addEventListener('click', async () => {
|
|
try {
|
|
await createThread();
|
|
} catch (error) {
|
|
showPanel('New chat failed', error && error.message ? error.message : String(error));
|
|
}
|
|
});
|
|
|
|
magicRenameAllBtnEl.addEventListener('click', async () => {
|
|
try {
|
|
await magicRenameAllThreads();
|
|
} catch (error) {
|
|
showPanel('Magic rename all failed', error && error.message ? error.message : String(error));
|
|
}
|
|
});
|
|
|
|
magicRenameBtnEl.addEventListener('click', async () => {
|
|
try {
|
|
await magicRenameThread();
|
|
} catch (error) {
|
|
showPanel('Magic rename failed', error && error.message ? error.message : String(error));
|
|
}
|
|
});
|
|
|
|
renameChatBtnEl.addEventListener('click', async () => {
|
|
try {
|
|
await renameThread();
|
|
} catch (error) {
|
|
showPanel('Rename failed', error && error.message ? error.message : String(error));
|
|
}
|
|
});
|
|
|
|
deleteChatBtnEl.addEventListener('click', async () => {
|
|
try {
|
|
await deleteThread();
|
|
} catch (error) {
|
|
showPanel('Delete failed', error && error.message ? error.message : String(error));
|
|
}
|
|
});
|
|
|
|
saveModelBtnEl.addEventListener('click', async () => {
|
|
try {
|
|
await saveModel();
|
|
} catch (error) {
|
|
showPanel('Model save failed', error && error.message ? error.message : String(error));
|
|
}
|
|
});
|
|
|
|
addImageBtnEl.addEventListener('click', () => {
|
|
composerImageInputEl.click();
|
|
});
|
|
|
|
composerImageInputEl.addEventListener('change', async (event) => {
|
|
try {
|
|
await uploadSelectedImages(event.target.files);
|
|
} catch (error) {
|
|
showPanel('Image upload failed', error && error.message ? error.message : String(error));
|
|
}
|
|
});
|
|
|
|
composerFormEl.addEventListener('submit', async (event) => {
|
|
event.preventDefault();
|
|
await sendMessage();
|
|
});
|
|
|
|
composerInputEl.addEventListener('keydown', async (event) => {
|
|
if (event.key === 'Enter' && !event.shiftKey) {
|
|
event.preventDefault();
|
|
await sendMessage();
|
|
}
|
|
});
|
|
};
|
|
|
|
const startWorkspaceUi = async (config, reason = null) => {
|
|
workspaceState.config = config;
|
|
workspaceAppEl.classList.remove('hidden');
|
|
chatEl.classList.add('hidden');
|
|
bindWorkspaceUi();
|
|
setSidebarCollapsed(readSidebarPreference());
|
|
renderComposerUploads();
|
|
|
|
if (reason) {
|
|
showPanel('Native ChatKit switched back to workspace mode', reason);
|
|
reportClientEvent('workspace-mode', { reason });
|
|
} else {
|
|
hidePanel();
|
|
}
|
|
|
|
await refreshThreads(workspaceState.activeThreadId);
|
|
hideStatus();
|
|
appendLog('ui-mode', 'workspace');
|
|
};
|
|
|
|
const initChatKit = async (config) => {
|
|
workspaceAppEl.classList.add('hidden');
|
|
chatEl.classList.remove('hidden');
|
|
showStatus('Loading native ChatKit...');
|
|
|
|
chatEl.addEventListener('chatkit.ready', () => {
|
|
if (readyTimeout) {
|
|
window.clearTimeout(readyTimeout);
|
|
readyTimeout = null;
|
|
}
|
|
hidePanel();
|
|
hideStatus();
|
|
appendLog('chatkit.ready');
|
|
reportClientEvent('chatkit.ready', {});
|
|
}, { once: true });
|
|
|
|
chatEl.addEventListener('chatkit.error', async (event) => {
|
|
const err = event.detail && event.detail.error ? event.detail.error : null;
|
|
const message = err && err.message ? err.message : String(err || 'Unknown ChatKit error');
|
|
if (readyTimeout) {
|
|
window.clearTimeout(readyTimeout);
|
|
readyTimeout = null;
|
|
}
|
|
await startWorkspaceUi(config, message);
|
|
});
|
|
|
|
chatEl.addEventListener('chatkit.log', (event) => {
|
|
appendLog(`chatkit.log:${event.detail?.name || 'unknown'}`, event.detail?.data || {});
|
|
});
|
|
|
|
readyTimeout = window.setTimeout(async () => {
|
|
await startWorkspaceUi(
|
|
config,
|
|
'The ChatKit widget did not become ready in time, so the workspace UI stayed active.'
|
|
);
|
|
}, 12000);
|
|
|
|
await customElements.whenDefined('openai-chatkit');
|
|
|
|
chatEl.setOptions({
|
|
api: {
|
|
url: config.apiUrl,
|
|
domainKey: config.domainKey,
|
|
fetch: (url, init) => fetch(url, { ...init, credentials: 'same-origin' }),
|
|
},
|
|
theme: {
|
|
colorScheme: 'dark',
|
|
radius: 'round',
|
|
},
|
|
frameTitle: 'CodeAnywhere Chat',
|
|
header: {
|
|
enabled: true,
|
|
title: {
|
|
enabled: true,
|
|
text: 'CodeAnywhere',
|
|
},
|
|
},
|
|
history: {
|
|
enabled: true,
|
|
showDelete: true,
|
|
showRename: true,
|
|
},
|
|
startScreen: {
|
|
greeting: 'CodeAnywhere',
|
|
prompts: [
|
|
{ label: 'Directory structure', prompt: 'Show me the directory structure of /repos/' },
|
|
{ label: 'Pending changes', prompt: 'What repos have pending changes?' },
|
|
{ label: 'Run karakeep tests', prompt: 'Run the tests in karakeep' },
|
|
],
|
|
},
|
|
composer: {
|
|
placeholder: `Ask anything, or /model <name> to switch models (default: ${config.defaultModel})`,
|
|
attachments: {
|
|
enabled: true,
|
|
},
|
|
},
|
|
threadItemActions: {
|
|
retry: true,
|
|
feedback: false,
|
|
},
|
|
});
|
|
|
|
appendLog('ui-mode', 'native-chatkit');
|
|
};
|
|
|
|
window.addEventListener('error', (event) => {
|
|
showPanel('Browser Error', event.message || 'Unknown browser error');
|
|
});
|
|
|
|
window.addEventListener('unhandledrejection', (event) => {
|
|
const reason = event.reason;
|
|
const message = reason && reason.message ? reason.message : String(reason || 'Unknown promise rejection');
|
|
showPanel('Unhandled Promise Rejection', message);
|
|
});
|
|
|
|
const loadConfig = async () => {
|
|
const response = await fetch('/ui-config', { credentials: 'same-origin' });
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to load UI config (${response.status})`);
|
|
}
|
|
return response.json();
|
|
};
|
|
|
|
const init = async () => {
|
|
showStatus('Loading CodeAnywhere...');
|
|
const config = await loadConfig();
|
|
appendLog('ui-config', {
|
|
usingFallbackDomainKey: config.usingFallbackDomainKey,
|
|
defaultModel: config.defaultModel,
|
|
});
|
|
|
|
if (wantsNativeChatKit && !config.usingFallbackDomainKey) {
|
|
await initChatKit(config);
|
|
return;
|
|
}
|
|
|
|
if (wantsNativeChatKit && config.usingFallbackDomainKey) {
|
|
await startWorkspaceUi(config, 'Native ChatKit is unavailable without a valid production domain key.');
|
|
return;
|
|
}
|
|
|
|
await startWorkspaceUi(config);
|
|
};
|
|
|
|
init().catch((error) => {
|
|
showPanel('Initialization Failed', error && error.message ? error.message : String(error));
|
|
});
|
|
</script>
|
|
</body>
|
|
|
|
</html> |