@aakrit512/gatekeep 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +91 -0
- package/dist/ai/chat.js +33 -0
- package/dist/ai/contextUtils.js +79 -0
- package/dist/ai/openAiClient.js +72 -0
- package/dist/ai/summarizer.js +65 -0
- package/dist/cli/configStore.js +68 -0
- package/dist/cli/validation.js +45 -0
- package/dist/cli.js +74 -0
- package/dist/config.js +36 -0
- package/dist/functions/toolCallHandler.js +25 -0
- package/dist/functions/toolDefinitions.js +422 -0
- package/dist/functions/toolExecutor.js +1044 -0
- package/dist/prompts/initializationPrompt.js +46 -0
- package/dist/prompts/systemPrompt.js +72 -0
- package/dist/ui/projectDb.js +220 -0
- package/dist/ui/server.js +523 -0
- package/dist/ui/webAgent.js +170 -0
- package/package.json +47 -0
- package/ui/app.html +952 -0
package/ui/app.html
ADDED
|
@@ -0,0 +1,952 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>My Code</title>
|
|
7
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
|
8
|
+
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
|
9
|
+
<script>
|
|
10
|
+
tailwind.config = {
|
|
11
|
+
theme: {
|
|
12
|
+
extend: {
|
|
13
|
+
colors: {
|
|
14
|
+
ink: '#171f25',
|
|
15
|
+
muted: '#65727d',
|
|
16
|
+
line: '#d9dfda',
|
|
17
|
+
paper: '#fbfbf8',
|
|
18
|
+
wash: '#eef1ed',
|
|
19
|
+
accent: '#0f766e'
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
</script>
|
|
25
|
+
<style>
|
|
26
|
+
html, body { height: 100%; }
|
|
27
|
+
body { letter-spacing: 0; }
|
|
28
|
+
.markdown h1,.markdown h2,.markdown h3 { font-weight: 650; margin: 1rem 0 .45rem; }
|
|
29
|
+
.markdown h1 { font-size: 1.35rem; }
|
|
30
|
+
.markdown h2 { font-size: 1.15rem; }
|
|
31
|
+
.markdown h3 { font-size: 1rem; }
|
|
32
|
+
.markdown p { margin: .55rem 0; }
|
|
33
|
+
.markdown ul,.markdown ol { padding-left: 1.25rem; margin: .55rem 0; }
|
|
34
|
+
.markdown ul { list-style: disc; }
|
|
35
|
+
.markdown ol { list-style: decimal; }
|
|
36
|
+
.markdown pre { overflow: auto; background: #111827; color: #f9fafb; border-radius: .5rem; padding: .85rem; margin: .75rem 0; }
|
|
37
|
+
.markdown code { background: #eef1ed; border: 1px solid #d9dfda; border-radius: .25rem; padding: .05rem .25rem; font-size: .9em; }
|
|
38
|
+
.markdown pre code { background: transparent; border: 0; padding: 0; }
|
|
39
|
+
.markdown blockquote { border-left: 3px solid #c8d2cc; padding-left: .75rem; color: #55616b; }
|
|
40
|
+
.markdown table { width: 100%; border-collapse: collapse; margin: .85rem 0; overflow: hidden; border: 1px solid #d9dfda; border-radius: .5rem; font-size: .92em; }
|
|
41
|
+
.markdown th { background: #eef1ed; font-weight: 650; text-align: left; }
|
|
42
|
+
.markdown th,.markdown td { border: 1px solid #d9dfda; padding: .5rem .6rem; vertical-align: top; }
|
|
43
|
+
.markdown tr:nth-child(even) td { background: rgba(238, 241, 237, .45); }
|
|
44
|
+
</style>
|
|
45
|
+
</head>
|
|
46
|
+
<body class="min-h-full bg-wash text-ink antialiased">
|
|
47
|
+
<div id="app"></div>
|
|
48
|
+
<script>
|
|
49
|
+
const state = {
|
|
50
|
+
routeProjectName: decodeURIComponent(location.pathname.replace(/^\//, '')),
|
|
51
|
+
project: null,
|
|
52
|
+
projects: [],
|
|
53
|
+
config: null,
|
|
54
|
+
messages: [],
|
|
55
|
+
busy: false,
|
|
56
|
+
activePopover: '',
|
|
57
|
+
usage: null,
|
|
58
|
+
streamText: '',
|
|
59
|
+
streamNode: null,
|
|
60
|
+
streamMetaNode: null,
|
|
61
|
+
streamQueue: '',
|
|
62
|
+
typingFrame: null,
|
|
63
|
+
activeThinking: [],
|
|
64
|
+
activityNode: null,
|
|
65
|
+
activityItems: [],
|
|
66
|
+
currentUsage: null,
|
|
67
|
+
models: [],
|
|
68
|
+
modelsLoaded: false,
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const fallbackModels = __FALLBACK_MODELS__;
|
|
72
|
+
|
|
73
|
+
const icon = {
|
|
74
|
+
settings: '<svg viewBox="0 0 24 24" class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.88l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06A1.7 1.7 0 0 0 15 19.4a1.7 1.7 0 0 0-1 .6 1.7 1.7 0 0 0-.4 1.1V21a2 2 0 1 1-4 0v-.09A1.7 1.7 0 0 0 8.6 19.4a1.7 1.7 0 0 0-1.88.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-.6-1 1.7 1.7 0 0 0-1.1-.4H3a2 2 0 1 1 0-4h.09A1.7 1.7 0 0 0 4.6 8.6a1.7 1.7 0 0 0-.34-1.88l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.7 1.7 0 0 0 9 4.6a1.7 1.7 0 0 0 1-.6 1.7 1.7 0 0 0 .4-1.1V3a2 2 0 1 1 4 0v.09A1.7 1.7 0 0 0 15.4 4.6a1.7 1.7 0 0 0 1.88-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.7 1.7 0 0 0 19.4 9c.38.1.73.3 1 .6.3.27.5.62.6 1H21a2 2 0 1 1 0 4h-.09a1.7 1.7 0 0 0-1.51.4Z"/></svg>',
|
|
75
|
+
info: '<svg viewBox="0 0 24 24" class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>',
|
|
76
|
+
code: '<svg viewBox="0 0 24 24" class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2"><path d="m16 18 6-6-6-6"/><path d="m8 6-6 6 6 6"/></svg>',
|
|
77
|
+
send: '<svg viewBox="0 0 24 24" class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2"><path d="m22 2-7 20-4-9-9-4Z"/><path d="M22 2 11 13"/></svg>',
|
|
78
|
+
trash: '<svg viewBox="0 0 24 24" class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v5"/><path d="M14 11v5"/></svg>',
|
|
79
|
+
clear: '<svg viewBox="0 0 24 24" class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2"><path d="m16 3 5 5L8 21H3v-5Z"/><path d="M14 5l5 5"/><path d="M3 21h8"/></svg>',
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
function api(path, body) {
|
|
83
|
+
return fetch(path, {
|
|
84
|
+
method: body ? 'POST' : 'GET',
|
|
85
|
+
headers: body ? { 'content-type': 'application/json' } : {},
|
|
86
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
87
|
+
}).then(async (res) => {
|
|
88
|
+
const data = await res.json();
|
|
89
|
+
if (!res.ok) throw new Error(data.error || 'Request failed');
|
|
90
|
+
return data;
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function escapeHtml(value) {
|
|
95
|
+
return String(value)
|
|
96
|
+
.replaceAll('&', '&')
|
|
97
|
+
.replaceAll('<', '<')
|
|
98
|
+
.replaceAll('>', '>')
|
|
99
|
+
.replaceAll('"', '"');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function markdown(value) {
|
|
103
|
+
if (!window.marked) return escapeHtml(value);
|
|
104
|
+
return marked.parse(value || '', { breaks: true, gfm: true });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function modelOptionsHtml(selected) {
|
|
108
|
+
const chosen = selected || state.config?.model || fallbackModels[0] || '';
|
|
109
|
+
const models = Array.from(new Set([chosen, ...state.models, ...fallbackModels].filter(Boolean)));
|
|
110
|
+
return models.map((model) => `<option value="${escapeHtml(model)}" ${model === chosen ? 'selected' : ''}>${escapeHtml(model)}</option>`).join('');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function refreshModelSelects() {
|
|
114
|
+
document.querySelectorAll('select[data-model-select]').forEach((select) => {
|
|
115
|
+
const selected = select.value || state.config?.model || fallbackModels[0] || '';
|
|
116
|
+
select.innerHTML = modelOptionsHtml(selected);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function loadModels() {
|
|
121
|
+
if (state.modelsLoaded) {
|
|
122
|
+
refreshModelSelects();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
state.modelsLoaded = true;
|
|
126
|
+
try {
|
|
127
|
+
const data = await api('/api/models', payload());
|
|
128
|
+
state.models = data.models || [];
|
|
129
|
+
refreshModelSelects();
|
|
130
|
+
const status = document.getElementById('modelStatus');
|
|
131
|
+
if (status) status.textContent = data.warning || (data.source === 'api' ? 'loaded from OpenAI' : '');
|
|
132
|
+
} catch (error) {
|
|
133
|
+
const status = document.getElementById('modelStatus');
|
|
134
|
+
if (status) status.textContent = error.message;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function resetModelLoad() {
|
|
139
|
+
state.modelsLoaded = false;
|
|
140
|
+
loadModels();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function chooseProjectDirectory() {
|
|
144
|
+
const status = document.getElementById('settingsStatus');
|
|
145
|
+
if (status) status.textContent = 'Opening folder picker...';
|
|
146
|
+
try {
|
|
147
|
+
const data = await api('/api/pick-directory', {});
|
|
148
|
+
const input = document.getElementById('projectPath');
|
|
149
|
+
if (input) input.value = data.path || '';
|
|
150
|
+
if (status) status.textContent = data.path ? 'Project folder selected.' : '';
|
|
151
|
+
} catch (error) {
|
|
152
|
+
if (status) status.textContent = error.message;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function bindSetupControls() {
|
|
157
|
+
document.getElementById('pickDirectoryButton')?.addEventListener('click', chooseProjectDirectory);
|
|
158
|
+
document.getElementById('apiKey')?.addEventListener('change', resetModelLoad);
|
|
159
|
+
document.getElementById('baseUrl')?.addEventListener('change', resetModelLoad);
|
|
160
|
+
if (document.getElementById('model')) loadModels();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function isMarkdownTableRow(line) {
|
|
164
|
+
const trimmed = line.trim();
|
|
165
|
+
return trimmed.includes('|') && /^\|?.+\|.+\|?$/.test(trimmed);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function isMarkdownTableSeparator(line) {
|
|
169
|
+
return /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(line);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function findPendingTableStart(value) {
|
|
173
|
+
const lines = value.split('\n');
|
|
174
|
+
const offsets = [];
|
|
175
|
+
let offset = 0;
|
|
176
|
+
for (const line of lines) {
|
|
177
|
+
offsets.push(offset);
|
|
178
|
+
offset += line.length + 1;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
for (let index = 0; index < lines.length; index++) {
|
|
182
|
+
const hasHeader = isMarkdownTableRow(lines[index] || '');
|
|
183
|
+
const hasSeparator = index + 1 < lines.length && isMarkdownTableSeparator(lines[index + 1] || '');
|
|
184
|
+
if (!hasHeader) continue;
|
|
185
|
+
|
|
186
|
+
if (!hasSeparator) {
|
|
187
|
+
const isTail = index >= lines.length - 2;
|
|
188
|
+
return isTail ? offsets[index] : -1;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
let cursor = index + 2;
|
|
192
|
+
while (cursor < lines.length && isMarkdownTableRow(lines[cursor] || '')) {
|
|
193
|
+
cursor++;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const tableRunsToTail = cursor >= lines.length || (cursor === lines.length - 1 && lines[cursor] === '');
|
|
197
|
+
if (tableRunsToTail) return offsets[index];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return -1;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function tableSkeleton() {
|
|
204
|
+
return `
|
|
205
|
+
<div class="my-3 w-full max-w-3xl overflow-hidden rounded-lg border border-line bg-white/70">
|
|
206
|
+
<div class="grid grid-cols-3 border-b border-line bg-[#eef1ed]">
|
|
207
|
+
<div class="h-9 border-r border-line p-2"><div class="h-3 w-2/3 animate-pulse rounded bg-muted/20"></div></div>
|
|
208
|
+
<div class="h-9 border-r border-line p-2"><div class="h-3 w-1/2 animate-pulse rounded bg-muted/20"></div></div>
|
|
209
|
+
<div class="h-9 p-2"><div class="h-3 w-3/4 animate-pulse rounded bg-muted/20"></div></div>
|
|
210
|
+
</div>
|
|
211
|
+
${[0, 1, 2].map((row) => `
|
|
212
|
+
<div class="grid grid-cols-3 border-b border-line/70 last:border-b-0">
|
|
213
|
+
<div class="h-9 border-r border-line/70 p-2"><div class="h-3 w-${row === 1 ? '3/4' : '1/2'} animate-pulse rounded bg-muted/15"></div></div>
|
|
214
|
+
<div class="h-9 border-r border-line/70 p-2"><div class="h-3 w-${row === 2 ? '2/3' : '1/3'} animate-pulse rounded bg-muted/15"></div></div>
|
|
215
|
+
<div class="h-9 p-2"><div class="h-3 w-${row === 0 ? '5/6' : '1/2'} animate-pulse rounded bg-muted/15"></div></div>
|
|
216
|
+
</div>
|
|
217
|
+
`).join('')}
|
|
218
|
+
</div>
|
|
219
|
+
`;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function streamingMarkdown(value) {
|
|
223
|
+
const pendingTableStart = findPendingTableStart(value);
|
|
224
|
+
if (pendingTableStart < 0) return markdown(value);
|
|
225
|
+
|
|
226
|
+
const prefix = value.slice(0, pendingTableStart).trimEnd();
|
|
227
|
+
return markdown(prefix) + tableSkeleton();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function money(value) {
|
|
231
|
+
if (typeof value !== 'number') return 'unpriced';
|
|
232
|
+
if (value === 0) return '$0.0000';
|
|
233
|
+
if (value < 0.0001) return '<$0.0001';
|
|
234
|
+
return '$' + value.toFixed(4);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function usageLine(message) {
|
|
238
|
+
if (!message || !message.totalTokens) return '';
|
|
239
|
+
const parts = [
|
|
240
|
+
message.totalTokens + ' total',
|
|
241
|
+
message.promptTokens + ' in',
|
|
242
|
+
message.completionTokens + ' out'
|
|
243
|
+
];
|
|
244
|
+
if (typeof message.estimatedCost === 'number') parts.push(money(message.estimatedCost));
|
|
245
|
+
return parts.join(' · ');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function activityIcon(type) {
|
|
249
|
+
if (type === 'tool_call') {
|
|
250
|
+
return '<svg viewBox="0 0 24 24" class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 17.5 2 12l2-5.5"/><path d="m9 18 6-12"/><path d="m20 6.5 2 5.5-2 5.5"/></svg>';
|
|
251
|
+
}
|
|
252
|
+
if (type === 'tool_result') {
|
|
253
|
+
return '<svg viewBox="0 0 24 24" class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 6 9 17l-5-5"/></svg>';
|
|
254
|
+
}
|
|
255
|
+
if (type === 'tokens') {
|
|
256
|
+
return '<svg viewBox="0 0 24 24" class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2"><circle cx="8" cy="8" r="5"/><path d="M18 10v8"/><path d="M14 14h8"/></svg>';
|
|
257
|
+
}
|
|
258
|
+
return '<svg viewBox="0 0 24 24" class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 12h16"/><path d="M12 4v16"/></svg>';
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function activityTone(type) {
|
|
262
|
+
if (type === 'tool_call') return { icon: 'text-sky-500/70', label: 'text-sky-700/80', detail: 'text-slate-500/70' };
|
|
263
|
+
if (type === 'tool_result') return { icon: 'text-emerald-500/70', label: 'text-emerald-700/75', detail: 'text-slate-500/65' };
|
|
264
|
+
if (type === 'tokens') return { icon: 'text-slate-400/60', label: 'text-slate-500/60', detail: 'text-slate-400/80' };
|
|
265
|
+
return { icon: 'text-slate-400/70', label: 'text-slate-500/75', detail: 'text-slate-500/65' };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function activityRowHtml(item) {
|
|
269
|
+
const tone = activityTone(item.type);
|
|
270
|
+
const label = item.label || item.type || 'activity';
|
|
271
|
+
const preview = item.preview
|
|
272
|
+
? `<pre data-tool-preview class="mt-1 max-h-28 overflow-hidden whitespace-pre-wrap rounded-md border border-line/60 bg-white/50 px-2 py-1.5 text-[11px] leading-4 text-slate-500/80">${escapeHtml(item.preview)}</pre>`
|
|
273
|
+
: '';
|
|
274
|
+
const expand = item.truncated && item.full
|
|
275
|
+
? `<button type="button" class="mt-1 text-[11px] text-sky-600/70 hover:text-sky-700" data-expand-output="${escapeHtml(item.full)}">Show more</button>`
|
|
276
|
+
: '';
|
|
277
|
+
return `
|
|
278
|
+
<div class="flex items-start gap-2 py-0.5 leading-5">
|
|
279
|
+
<span class="mt-0.5 inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-md ${tone.icon}">${activityIcon(item.type)}</span>
|
|
280
|
+
<span class="min-w-0">
|
|
281
|
+
<span class="font-medium ${tone.label}">${escapeHtml(label)}</span>${item.detail ? `<span class="ml-1 ${tone.detail}">${escapeHtml(item.detail)}</span>` : ''}
|
|
282
|
+
${preview}
|
|
283
|
+
${expand}
|
|
284
|
+
</span>
|
|
285
|
+
</div>
|
|
286
|
+
`;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function activityForMessage(message) {
|
|
290
|
+
if (Array.isArray(message.activity) && message.activity.length > 0) return message.activity;
|
|
291
|
+
if (!message.totalTokens) return [];
|
|
292
|
+
return [
|
|
293
|
+
{ type: 'status', label: 'thinking', detail: 'completed request' },
|
|
294
|
+
{
|
|
295
|
+
type: 'tokens',
|
|
296
|
+
label: 'tokens',
|
|
297
|
+
detail: message.totalTokens + ' total · ' + message.promptTokens + ' in · ' + message.completionTokens + ' out' + (typeof message.estimatedCost === 'number' ? ' · ' + money(message.estimatedCost) : ''),
|
|
298
|
+
},
|
|
299
|
+
];
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function renderActivity(activity) {
|
|
303
|
+
if (!Array.isArray(activity) || activity.length === 0) return '';
|
|
304
|
+
return `
|
|
305
|
+
<div class="mb-2 ml-1 grid max-w-4xl gap-0.5 text-[12px]">
|
|
306
|
+
${activity.map(activityRowHtml).join('')}
|
|
307
|
+
</div>
|
|
308
|
+
`;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function route(path) {
|
|
312
|
+
history.pushState(null, '', path);
|
|
313
|
+
state.routeProjectName = decodeURIComponent(location.pathname.replace(/^\//, ''));
|
|
314
|
+
state.activePopover = '';
|
|
315
|
+
refresh();
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
window.addEventListener('popstate', () => {
|
|
319
|
+
state.routeProjectName = decodeURIComponent(location.pathname.replace(/^\//, ''));
|
|
320
|
+
refresh();
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
function shell(content) {
|
|
324
|
+
document.getElementById('app').innerHTML = content;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function renderIndex() {
|
|
328
|
+
shell(`
|
|
329
|
+
<main class="min-h-screen bg-[#e9ebe8]">
|
|
330
|
+
<section class="grid h-screen w-full overflow-hidden border border-black/10 bg-[#f8f8f5] md:grid-cols-[240px_minmax(0,1fr)]">
|
|
331
|
+
<aside class="hidden border-r border-black/10 bg-[#ecedea]/90 px-3 py-3 md:block">
|
|
332
|
+
<div class="mb-4 flex items-center gap-2 px-1">
|
|
333
|
+
<span class="h-3 w-3 rounded-full bg-[#ff5f57]"></span>
|
|
334
|
+
<span class="h-3 w-3 rounded-full bg-[#febc2e]"></span>
|
|
335
|
+
<span class="h-3 w-3 rounded-full bg-[#28c840]"></span>
|
|
336
|
+
</div>
|
|
337
|
+
<div class="space-y-5 text-[13px]">
|
|
338
|
+
<div>
|
|
339
|
+
<div class="px-2 text-[11px] font-medium uppercase tracking-wide text-slate-400">Favorites</div>
|
|
340
|
+
<button class="mt-1 flex w-full items-center gap-2 rounded-md bg-white/70 px-2 py-1.5 text-left font-medium text-slate-700 shadow-sm">
|
|
341
|
+
<svg viewBox="0 0 24 24" class="h-4 w-4 text-sky-500" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 7h18"/><path d="M5 7v11a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7"/><path d="M8 7V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
|
342
|
+
Projects
|
|
343
|
+
</button>
|
|
344
|
+
<div class="mt-1 flex items-center gap-2 rounded-md px-2 py-1.5 text-slate-500">
|
|
345
|
+
<svg viewBox="0 0 24 24" class="h-4 w-4 text-slate-400" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>
|
|
346
|
+
Initialize
|
|
347
|
+
</div>
|
|
348
|
+
<div class="mt-1 flex items-center gap-2 rounded-md px-2 py-1.5 text-slate-500">
|
|
349
|
+
<svg viewBox="0 0 24 24" class="h-4 w-4 text-slate-400" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.88l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06A1.7 1.7 0 0 0 15 19.4"/></svg>
|
|
350
|
+
Settings
|
|
351
|
+
</div>
|
|
352
|
+
</div>
|
|
353
|
+
<div>
|
|
354
|
+
<div class="px-2 text-[11px] font-medium uppercase tracking-wide text-slate-400">Storage</div>
|
|
355
|
+
<div class="mt-1 rounded-md px-2 py-1.5 text-xs leading-5 text-slate-500">
|
|
356
|
+
<div>${state.projects.length} projects</div>
|
|
357
|
+
<div class="truncate">${escapeHtml(state.databasePath || 'projects.sqlite')}</div>
|
|
358
|
+
</div>
|
|
359
|
+
</div>
|
|
360
|
+
</div>
|
|
361
|
+
</aside>
|
|
362
|
+
<div class="grid min-w-0 grid-rows-[auto_minmax(0,1fr)_auto]">
|
|
363
|
+
<header class="flex min-h-[56px] items-center justify-between gap-3 border-b border-black/10 bg-[#fbfbf9]/90 px-4 backdrop-blur">
|
|
364
|
+
<div class="flex items-center gap-2">
|
|
365
|
+
<button class="inline-flex h-8 w-8 items-center justify-center rounded-md text-slate-400 hover:bg-slate-200/60">
|
|
366
|
+
<svg viewBox="0 0 24 24" class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2"><path d="m15 18-6-6 6-6"/></svg>
|
|
367
|
+
</button>
|
|
368
|
+
<button class="inline-flex h-8 w-8 items-center justify-center rounded-md text-slate-400 hover:bg-slate-200/60">
|
|
369
|
+
<svg viewBox="0 0 24 24" class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2"><path d="m9 18 6-6-6-6"/></svg>
|
|
370
|
+
</button>
|
|
371
|
+
<div class="ml-2">
|
|
372
|
+
<h1 class="text-[15px] font-semibold text-slate-800">Projects</h1>
|
|
373
|
+
<p class="text-xs text-slate-400">Local coding-agent workspaces</p>
|
|
374
|
+
</div>
|
|
375
|
+
</div>
|
|
376
|
+
<div class="flex items-center gap-2">
|
|
377
|
+
<div class="hidden rounded-md border border-black/10 bg-white px-2 py-1.5 text-xs text-slate-400 sm:block">localhost:9808</div>
|
|
378
|
+
<button id="doctorButton" class="inline-flex h-8 items-center gap-1.5 rounded-md border border-black/10 bg-white px-2.5 text-xs text-slate-500 hover:text-slate-800">
|
|
379
|
+
<svg viewBox="0 0 24 24" class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 6 9 17l-5-5"/></svg>
|
|
380
|
+
Doctor
|
|
381
|
+
</button>
|
|
382
|
+
<button id="showInitButton" class="inline-flex h-8 items-center gap-1.5 rounded-md bg-slate-800 px-2.5 text-xs text-white hover:bg-slate-700">
|
|
383
|
+
<svg viewBox="0 0 24 24" class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14"/><path d="M5 12h14"/></svg>
|
|
384
|
+
Initialize
|
|
385
|
+
</button>
|
|
386
|
+
</div>
|
|
387
|
+
</header>
|
|
388
|
+
<section class="min-h-0 overflow-auto bg-[#fdfdfb]">
|
|
389
|
+
<div class="grid grid-cols-[minmax(180px,1.4fr)_minmax(260px,2fr)_120px_170px] border-b border-black/10 bg-[#f4f5f2] px-4 py-2 text-[11px] font-medium uppercase tracking-wide text-slate-400">
|
|
390
|
+
<div>Name</div>
|
|
391
|
+
<div>Path</div>
|
|
392
|
+
<div>Model</div>
|
|
393
|
+
<div>Last opened</div>
|
|
394
|
+
</div>
|
|
395
|
+
<div class="divide-y divide-black/[0.06]">
|
|
396
|
+
${state.projects.map((project) => `
|
|
397
|
+
<button data-route="${project.href}" class="grid w-full grid-cols-[minmax(180px,1.4fr)_minmax(260px,2fr)_120px_170px] items-center px-4 py-2.5 text-left text-sm hover:bg-sky-50/80">
|
|
398
|
+
<div class="flex min-w-0 items-center gap-2">
|
|
399
|
+
<svg viewBox="0 0 24 24" class="h-4 w-4 shrink-0 text-sky-500" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 7h7l2 2h9v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/></svg>
|
|
400
|
+
<span class="truncate font-medium text-slate-800">${escapeHtml(project.name)}</span>
|
|
401
|
+
</div>
|
|
402
|
+
<div class="truncate text-xs text-slate-500">${escapeHtml(project.path)}</div>
|
|
403
|
+
<div><span class="rounded-md bg-slate-100 px-1.5 py-0.5 text-xs text-slate-500">${escapeHtml(project.model)}</span></div>
|
|
404
|
+
<div class="truncate text-xs text-slate-400">${escapeHtml(project.lastOpenedAt)}</div>
|
|
405
|
+
</button>
|
|
406
|
+
`).join('') || `
|
|
407
|
+
<div class="px-4 py-10 text-center text-sm text-slate-400">No projects yet. Click + Initialize to open the project-init terminal.</div>
|
|
408
|
+
`}
|
|
409
|
+
</div>
|
|
410
|
+
</section>
|
|
411
|
+
<section id="initTerminal" class="hidden border-t border-black/10 bg-[#151716] px-4 py-3 font-mono text-[12px] text-slate-300">
|
|
412
|
+
<div class="mb-2 flex items-center justify-between">
|
|
413
|
+
<div class="flex items-center gap-2 text-slate-500">
|
|
414
|
+
<span class="h-2.5 w-2.5 rounded-full bg-[#ff5f57]"></span>
|
|
415
|
+
<span class="h-2.5 w-2.5 rounded-full bg-[#febc2e]"></span>
|
|
416
|
+
<span class="h-2.5 w-2.5 rounded-full bg-[#28c840]"></span>
|
|
417
|
+
<span class="ml-2 text-slate-400">project-init</span>
|
|
418
|
+
</div>
|
|
419
|
+
<div class="flex items-center gap-2">
|
|
420
|
+
<p id="settingsStatus" class="text-[11px] text-slate-500"></p>
|
|
421
|
+
<button id="hideInitButton" class="inline-flex h-7 items-center rounded-md border border-white/10 px-2.5 text-[11px] text-slate-400 hover:border-white/20 hover:text-slate-200">Cancel</button>
|
|
422
|
+
<button id="initButton" class="inline-flex h-7 items-center gap-1.5 rounded-md bg-white px-2.5 text-[11px] font-medium text-slate-900 hover:bg-slate-100">
|
|
423
|
+
<svg viewBox="0 0 24 24" class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 6 9 17l-5-5"/></svg>
|
|
424
|
+
Save & Initialize
|
|
425
|
+
</button>
|
|
426
|
+
</div>
|
|
427
|
+
</div>
|
|
428
|
+
<div class="grid gap-2 md:grid-cols-2">
|
|
429
|
+
<label class="grid gap-1"><span class="text-slate-500">$ project path</span><span class="flex gap-2"><input id="projectPath" class="min-w-0 flex-1 rounded-md border border-white/10 bg-black/30 px-3 py-2 text-slate-100 outline-none placeholder:text-slate-600 focus:border-sky-500/60" placeholder="/path/to/your/project"><button id="pickDirectoryButton" type="button" class="inline-flex items-center rounded-md border border-white/10 px-3 text-slate-400 hover:border-white/20 hover:text-slate-100">Choose</button></span></label>
|
|
430
|
+
<label class="grid gap-1"><span class="text-slate-500">$ api key</span><input id="apiKey" type="password" autocomplete="off" placeholder="${state.config?.hasApiKey ? 'saved; leave blank to keep' : 'paste key'}" class="rounded-md border border-white/10 bg-black/30 px-3 py-2 text-slate-100 outline-none focus:border-sky-500/60"></label>
|
|
431
|
+
<label class="grid gap-1"><span class="flex items-center justify-between text-slate-500">$ model <small id="modelStatus" class="font-sans text-[10px] text-slate-600"></small></span><select id="model" data-model-select class="rounded-md border border-white/10 bg-black/30 px-3 py-2 text-slate-100 outline-none focus:border-sky-500/60">${modelOptionsHtml(state.config?.model || '')}</select></label>
|
|
432
|
+
<label class="grid gap-1"><span class="text-slate-500">$ base url</span><input id="baseUrl" class="rounded-md border border-white/10 bg-black/30 px-3 py-2 text-slate-100 outline-none focus:border-sky-500/60" value="${escapeHtml(state.config?.baseUrl || '')}"></label>
|
|
433
|
+
</div>
|
|
434
|
+
<div class="mt-3 text-slate-500"><span class="text-emerald-400">➜</span> Save & Initialize writes config, registers the project, and builds repository context</div>
|
|
435
|
+
</section>
|
|
436
|
+
</div>
|
|
437
|
+
</section>
|
|
438
|
+
</main>
|
|
439
|
+
`);
|
|
440
|
+
document.querySelectorAll('[data-route]').forEach((node) => {
|
|
441
|
+
node.addEventListener('click', () => route(node.dataset.route));
|
|
442
|
+
});
|
|
443
|
+
document.getElementById('showInitButton')?.addEventListener('click', () => {
|
|
444
|
+
const terminal = document.getElementById('initTerminal');
|
|
445
|
+
terminal?.classList.remove('hidden');
|
|
446
|
+
document.getElementById('projectPath')?.focus();
|
|
447
|
+
loadModels();
|
|
448
|
+
});
|
|
449
|
+
document.getElementById('hideInitButton')?.addEventListener('click', () => {
|
|
450
|
+
document.getElementById('initTerminal')?.classList.add('hidden');
|
|
451
|
+
});
|
|
452
|
+
document.getElementById('initButton')?.addEventListener('click', initializeProject);
|
|
453
|
+
document.getElementById('doctorButton')?.addEventListener('click', () => {
|
|
454
|
+
document.getElementById('initTerminal')?.classList.remove('hidden');
|
|
455
|
+
runDoctor();
|
|
456
|
+
});
|
|
457
|
+
bindSetupControls();
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function popover(name, inner) {
|
|
461
|
+
if (state.activePopover !== name) return '';
|
|
462
|
+
return `
|
|
463
|
+
<div class="absolute right-0 top-12 z-20 w-[min(92vw,28rem)] overflow-hidden rounded-2xl border border-black/10 bg-[#f8f8f8]/90 text-sm shadow-2xl shadow-slate-900/20 ring-1 ring-white/70 backdrop-blur-2xl">
|
|
464
|
+
<div class="flex h-9 items-center gap-1.5 border-b border-black/10 bg-white/45 px-4">
|
|
465
|
+
<span class="h-2.5 w-2.5 rounded-full bg-[#ff5f57]"></span>
|
|
466
|
+
<span class="h-2.5 w-2.5 rounded-full bg-[#febc2e]"></span>
|
|
467
|
+
<span class="h-2.5 w-2.5 rounded-full bg-[#28c840]"></span>
|
|
468
|
+
</div>
|
|
469
|
+
<div class="p-4">${inner}</div>
|
|
470
|
+
</div>
|
|
471
|
+
`;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function renderMessages() {
|
|
475
|
+
const rows = state.messages.map((message) => {
|
|
476
|
+
if (message.role === 'user') {
|
|
477
|
+
return `<div class="flex justify-end"><div class="max-w-[78%] rounded-[20px] rounded-br-md bg-[#007aff] px-4 py-2.5 text-sm leading-6 text-white shadow-sm">${escapeHtml(message.content)}</div></div>`;
|
|
478
|
+
}
|
|
479
|
+
if (message.role === 'system') {
|
|
480
|
+
return `<div class="mx-auto max-w-3xl rounded-full border border-black/5 bg-white/55 px-3 py-1.5 text-xs text-slate-400 shadow-sm">${escapeHtml(message.content)}</div>`;
|
|
481
|
+
}
|
|
482
|
+
const answer = message.content
|
|
483
|
+
? `<div class="markdown max-w-[86%] rounded-[20px] rounded-bl-md border border-black/5 bg-white px-4 py-3 text-sm leading-6 text-slate-800 shadow-sm shadow-slate-900/5">${markdown(message.content)}</div>`
|
|
484
|
+
: '';
|
|
485
|
+
return `<div class="max-w-5xl">${renderActivity(activityForMessage(message))}${answer}${usageLine(message) ? `<div class="mt-1 px-4 text-[11px] text-slate-400/70">${escapeHtml(usageLine(message))}</div>` : ''}</div>`;
|
|
486
|
+
}).join('');
|
|
487
|
+
|
|
488
|
+
return rows || `<div class="mx-auto mt-24 max-w-xl text-center"><div class="mx-auto mb-4 grid h-14 w-14 place-items-center rounded-2xl bg-white text-2xl shadow-sm">⌘</div><h2 class="text-lg font-semibold text-slate-700">Ready when you are.</h2><p class="mt-2 text-sm text-slate-400">Ask for a review, a diff summary, or a guided read of this codebase.</p></div>`;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function formatToolArgs(args) {
|
|
492
|
+
const entries = Object.entries(args || {}).filter(([, value]) => value !== undefined && value !== null && value !== '');
|
|
493
|
+
if (!entries.length) return '';
|
|
494
|
+
return entries.slice(0, 3).map(([key, value]) => {
|
|
495
|
+
const text = typeof value === 'string' ? value : JSON.stringify(value);
|
|
496
|
+
return key + ': ' + text.slice(0, 90);
|
|
497
|
+
}).join(' · ');
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function renderProject() {
|
|
501
|
+
const project = state.project;
|
|
502
|
+
if (!project) {
|
|
503
|
+
shell(`<main class="grid min-h-screen place-items-center px-5"><div class="rounded-lg border border-line bg-paper p-6 text-center"><h1 class="font-semibold">Project not found</h1><p class="mt-2 text-sm text-muted">Return to the project list and choose a saved project.</p><button id="homeButton" class="mt-5 rounded-md border border-line bg-white px-3 py-2 text-sm">Back to projects</button></div></main>`);
|
|
504
|
+
document.getElementById('homeButton').addEventListener('click', () => route('/'));
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const usage = state.usage;
|
|
509
|
+
const cost = usage ? money(usage.cost) : 'waiting';
|
|
510
|
+
shell(`
|
|
511
|
+
<main class="flex h-screen min-h-0 flex-col bg-[#f2f2f7]">
|
|
512
|
+
<header class="relative flex min-h-[64px] items-center justify-between gap-3 border-b border-black/10 bg-[#f8f8f8]/90 px-4 py-3 backdrop-blur-xl">
|
|
513
|
+
<div class="flex min-w-0 items-center gap-3">
|
|
514
|
+
<div class="hidden items-center gap-2 pr-1 sm:flex">
|
|
515
|
+
<span class="h-3 w-3 rounded-full bg-[#ff5f57]"></span>
|
|
516
|
+
<span class="h-3 w-3 rounded-full bg-[#febc2e]"></span>
|
|
517
|
+
<span class="h-3 w-3 rounded-full bg-[#28c840]"></span>
|
|
518
|
+
</div>
|
|
519
|
+
<button id="homeButton" class="inline-flex h-9 items-center gap-1.5 rounded-full border border-black/10 bg-white/70 px-3 text-sm text-slate-500 shadow-sm hover:bg-white hover:text-slate-800">
|
|
520
|
+
<svg viewBox="0 0 24 24" class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2"><path d="m15 18-6-6 6-6"/></svg>
|
|
521
|
+
Projects
|
|
522
|
+
</button>
|
|
523
|
+
<div class="min-w-0">
|
|
524
|
+
<h1 class="truncate text-[15px] font-semibold text-slate-800">${escapeHtml(project.name)}</h1>
|
|
525
|
+
<p class="truncate text-xs text-slate-400">${escapeHtml(project.path)}</p>
|
|
526
|
+
</div>
|
|
527
|
+
</div>
|
|
528
|
+
<div class="flex items-center gap-2">
|
|
529
|
+
<div class="hidden text-right text-[11px] text-slate-400/80 md:block">
|
|
530
|
+
<div id="usageTopTokens">${usage ? escapeHtml(usage.totalTokens + ' tokens') : 'Token usage appears here'}</div>
|
|
531
|
+
<div id="usageTopCost">${cost} estimated</div>
|
|
532
|
+
</div>
|
|
533
|
+
<button id="openCodeButton" title="Open in VS Code" class="inline-flex h-9 w-9 items-center justify-center rounded-full border border-black/10 bg-white/70 text-slate-500 shadow-sm hover:bg-white hover:text-slate-800">${icon.code}</button>
|
|
534
|
+
<div class="relative">
|
|
535
|
+
<button data-popover="storage" title="Storage and config" class="inline-flex h-9 w-9 items-center justify-center rounded-full border border-black/10 bg-white/70 text-slate-500 shadow-sm hover:bg-white hover:text-slate-800">${icon.info}</button>
|
|
536
|
+
${popover('storage', `
|
|
537
|
+
<h2 class="text-[13px] font-semibold text-slate-800">Storage and Config</h2>
|
|
538
|
+
<dl class="mt-3 grid gap-2 text-xs">
|
|
539
|
+
<div class="rounded-xl border border-black/5 bg-white/60 px-3 py-2"><dt class="text-[10px] font-medium uppercase tracking-wide text-slate-400">Config</dt><dd class="mt-1 break-all text-slate-600">${escapeHtml(state.configPath || '')}</dd></div>
|
|
540
|
+
<div class="rounded-xl border border-black/5 bg-white/60 px-3 py-2"><dt class="text-[10px] font-medium uppercase tracking-wide text-slate-400">SQLite</dt><dd class="mt-1 break-all text-slate-600">${escapeHtml(state.databasePath || '')}</dd></div>
|
|
541
|
+
<div class="rounded-xl border border-black/5 bg-white/60 px-3 py-2"><dt class="text-[10px] font-medium uppercase tracking-wide text-slate-400">Provider</dt><dd class="mt-1 text-slate-600">${escapeHtml(state.config?.provider || '')}</dd></div>
|
|
542
|
+
<div class="rounded-xl border border-black/5 bg-white/60 px-3 py-2"><dt class="text-[10px] font-medium uppercase tracking-wide text-slate-400">API key</dt><dd class="mt-1 text-slate-600">${escapeHtml(state.config?.apiKey || '')}</dd></div>
|
|
543
|
+
</dl>
|
|
544
|
+
`)}
|
|
545
|
+
</div>
|
|
546
|
+
<div class="relative">
|
|
547
|
+
<button data-popover="settings" title="Settings" class="inline-flex h-9 w-9 items-center justify-center rounded-full border border-black/10 bg-white/70 text-slate-500 shadow-sm hover:bg-white hover:text-slate-800">${icon.settings}</button>
|
|
548
|
+
${popover('settings', `
|
|
549
|
+
<h2 class="text-[13px] font-semibold text-slate-800">Project Settings</h2>
|
|
550
|
+
<div class="mt-3 grid gap-3">
|
|
551
|
+
<label class="grid gap-1 text-xs font-medium text-slate-500">Project path<span class="flex gap-2"><input id="projectPath" class="min-w-0 flex-1 rounded-xl border border-black/10 bg-white/75 px-3 py-2 text-sm text-slate-800 outline-none placeholder:text-slate-300 focus:border-[#007aff]/40 focus:bg-white" placeholder="/path/to/your/project" value="${escapeHtml(project.path)}"><button id="pickDirectoryButton" type="button" class="rounded-xl border border-black/10 bg-white/70 px-3 py-2 text-xs text-slate-500 shadow-sm hover:bg-white hover:text-slate-800">Choose</button></span></label>
|
|
552
|
+
<label class="grid gap-1 text-xs font-medium text-slate-500">API key<input id="apiKey" type="password" autocomplete="off" placeholder="Leave blank to keep saved key" class="rounded-xl border border-black/10 bg-white/75 px-3 py-2 text-sm text-slate-800 outline-none placeholder:text-slate-300 focus:border-[#007aff]/40 focus:bg-white"></label>
|
|
553
|
+
<label class="grid gap-1 text-xs font-medium text-slate-500">Model<span class="flex items-center gap-2"><select id="model" data-model-select class="min-w-0 flex-1 rounded-xl border border-black/10 bg-white/75 px-3 py-2 text-sm text-slate-800 outline-none focus:border-[#007aff]/40 focus:bg-white">${modelOptionsHtml(state.config?.model || project.model || '')}</select><small id="modelStatus" class="whitespace-nowrap text-[10px] text-slate-400"></small></span></label>
|
|
554
|
+
<label class="grid gap-1 text-xs font-medium text-slate-500">Base URL<input id="baseUrl" class="rounded-xl border border-black/10 bg-white/75 px-3 py-2 text-sm text-slate-800 outline-none focus:border-[#007aff]/40 focus:bg-white" value="${escapeHtml(state.config?.baseUrl || '')}"></label>
|
|
555
|
+
<div class="flex gap-2">
|
|
556
|
+
<button id="initButton" class="rounded-full bg-[#007aff] px-4 py-2 text-sm text-white shadow-sm">Initialize</button>
|
|
557
|
+
<button id="doctorButton" class="rounded-full border border-black/10 bg-white/70 px-4 py-2 text-sm text-slate-600 shadow-sm hover:bg-white hover:text-slate-900">Doctor</button>
|
|
558
|
+
</div>
|
|
559
|
+
<div class="border-t border-black/10 pt-3">
|
|
560
|
+
<button id="deleteProjectButton" class="inline-flex w-full items-center justify-center gap-2 rounded-full border border-red-200 bg-red-50 px-4 py-2 text-sm text-red-700 shadow-sm hover:bg-red-100">
|
|
561
|
+
${icon.trash}
|
|
562
|
+
Delete Project
|
|
563
|
+
</button>
|
|
564
|
+
</div>
|
|
565
|
+
<p id="settingsStatus" class="text-xs text-slate-400"></p>
|
|
566
|
+
</div>
|
|
567
|
+
`)}
|
|
568
|
+
</div>
|
|
569
|
+
</div>
|
|
570
|
+
</header>
|
|
571
|
+
<section id="thinking" class="hidden border-b border-black/10 bg-white/65 px-4 py-2 text-xs text-slate-500 backdrop-blur"></section>
|
|
572
|
+
<section id="chat" class="min-h-0 flex-1 overflow-auto bg-[radial-gradient(circle_at_top,_rgba(255,255,255,.92),_rgba(242,242,247,.92)_42%,_rgba(235,237,242,.95))] px-4 py-6">
|
|
573
|
+
<div class="mx-auto flex max-w-5xl flex-col gap-3">${renderMessages()}</div>
|
|
574
|
+
</section>
|
|
575
|
+
<form id="chatForm" class="border-t border-black/10 bg-[#f8f8f8]/90 px-4 py-3 backdrop-blur-xl">
|
|
576
|
+
<div class="mx-auto flex max-w-5xl items-end gap-2 rounded-[24px] border border-black/10 bg-white px-3 py-2 shadow-sm">
|
|
577
|
+
<textarea id="messageInput" rows="1" class="max-h-40 min-h-9 flex-1 resize-none bg-transparent px-2 py-2 text-sm text-slate-800 outline-none placeholder:text-slate-400" placeholder="Message ${escapeHtml(project.name)}"></textarea>
|
|
578
|
+
<button id="sendButton" class="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-[#007aff] text-white shadow-sm disabled:opacity-50">${icon.send}</button>
|
|
579
|
+
</div>
|
|
580
|
+
<div class="mx-auto mt-2 flex max-w-5xl items-center justify-between px-2 text-[11px] text-slate-400/70">
|
|
581
|
+
<span class="flex min-w-0 items-center gap-2">
|
|
582
|
+
<span class="truncate">${escapeHtml(project.model)} · ${escapeHtml(project.provider)}</span>
|
|
583
|
+
<button id="clearChatButton" type="button" class="inline-flex items-center gap-1 rounded-full border border-black/10 bg-white/60 px-2 py-1 text-slate-500 hover:bg-white hover:text-slate-800 disabled:opacity-40">
|
|
584
|
+
${icon.clear}
|
|
585
|
+
Clear Chat
|
|
586
|
+
</button>
|
|
587
|
+
</span>
|
|
588
|
+
<span id="usageBottom">${usage ? escapeHtml(usage.promptTokens + ' in · ' + usage.completionTokens + ' out · ' + cost) : 'pricing updates after each response'}</span>
|
|
589
|
+
</div>
|
|
590
|
+
</form>
|
|
591
|
+
</main>
|
|
592
|
+
`);
|
|
593
|
+
bindProjectHandlers();
|
|
594
|
+
scrollChat();
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function payload() {
|
|
598
|
+
return {
|
|
599
|
+
projectPath: document.getElementById('projectPath')?.value || state.project?.path || '',
|
|
600
|
+
apiKey: document.getElementById('apiKey')?.value || '',
|
|
601
|
+
model: document.getElementById('model')?.value || state.config?.model || '',
|
|
602
|
+
baseUrl: document.getElementById('baseUrl')?.value || state.config?.baseUrl || '',
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function setThinking(items) {
|
|
607
|
+
const node = document.getElementById('thinking');
|
|
608
|
+
if (!node) return;
|
|
609
|
+
state.activeThinking = items;
|
|
610
|
+
if (!items.length) {
|
|
611
|
+
node.classList.add('hidden');
|
|
612
|
+
node.innerHTML = '';
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
node.classList.remove('hidden');
|
|
616
|
+
node.innerHTML = '<div class="mx-auto flex max-w-5xl flex-wrap items-center gap-2"><span class="inline-flex items-center gap-1 rounded-full border border-line bg-white px-2 py-1"><span class="h-1.5 w-1.5 animate-pulse rounded-full bg-accent"></span>working</span>' + items.map((item) => `
|
|
617
|
+
<span class="rounded-full border border-line bg-white/80 px-2 py-1">${escapeHtml(item)}</span>
|
|
618
|
+
`).join('') + '</div>';
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function updateUsage(data) {
|
|
622
|
+
state.usage = data;
|
|
623
|
+
state.currentUsage = data;
|
|
624
|
+
const cost = money(data.cost);
|
|
625
|
+
const topTokens = document.getElementById('usageTopTokens');
|
|
626
|
+
const topCost = document.getElementById('usageTopCost');
|
|
627
|
+
const bottom = document.getElementById('usageBottom');
|
|
628
|
+
if (topTokens) topTokens.textContent = data.totalTokens + ' tokens';
|
|
629
|
+
if (topCost) topCost.textContent = cost + ' estimated';
|
|
630
|
+
if (bottom) bottom.textContent = data.promptTokens + ' in · ' + data.completionTokens + ' out · ' + cost;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function ensureActivityNode() {
|
|
634
|
+
if (state.activityNode) return state.activityNode;
|
|
635
|
+
const chatInner = document.querySelector('#chat > div');
|
|
636
|
+
const wrapper = document.createElement('div');
|
|
637
|
+
wrapper.className = 'ml-1 grid max-w-4xl gap-0.5 text-[12px]';
|
|
638
|
+
wrapper.innerHTML = '<div class="mb-1 flex items-center gap-2 text-slate-400/80"><span class="h-1.5 w-1.5 animate-pulse rounded-full bg-accent/70"></span><span>working</span></div><div class="grid gap-0.5" data-activity-list></div>';
|
|
639
|
+
chatInner.appendChild(wrapper);
|
|
640
|
+
state.activityNode = wrapper.querySelector('[data-activity-list]');
|
|
641
|
+
scrollChat();
|
|
642
|
+
return state.activityNode;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function expandOutput(button) {
|
|
646
|
+
const container = button.closest('.min-w-0');
|
|
647
|
+
const pre = container?.querySelector('[data-tool-preview]');
|
|
648
|
+
if (!pre) return;
|
|
649
|
+
pre.className = 'mt-1 max-h-80 overflow-auto whitespace-pre-wrap rounded-md border border-line/70 bg-white/70 px-2 py-1.5 text-[11px] leading-4 text-slate-500/85';
|
|
650
|
+
pre.textContent = button.dataset.expandOutput || '';
|
|
651
|
+
button.remove();
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function addActivity(type, label, detail, extra = {}) {
|
|
655
|
+
const item = { type, label, detail, ...extra };
|
|
656
|
+
state.activityItems.push(item);
|
|
657
|
+
const node = ensureActivityNode();
|
|
658
|
+
const row = document.createElement('div');
|
|
659
|
+
row.innerHTML = activityRowHtml(item);
|
|
660
|
+
row.querySelectorAll('[data-expand-output]').forEach((button) => {
|
|
661
|
+
button.addEventListener('click', () => expandOutput(button));
|
|
662
|
+
});
|
|
663
|
+
node.appendChild(row);
|
|
664
|
+
scrollChat();
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function scrollChat() {
|
|
668
|
+
const chat = document.getElementById('chat');
|
|
669
|
+
if (chat) chat.scrollTop = chat.scrollHeight;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function bindProjectHandlers() {
|
|
673
|
+
document.getElementById('homeButton')?.addEventListener('click', () => route('/'));
|
|
674
|
+
document.getElementById('openCodeButton')?.addEventListener('click', openCode);
|
|
675
|
+
document.getElementById('clearChatButton')?.addEventListener('click', clearChat);
|
|
676
|
+
document.getElementById('deleteProjectButton')?.addEventListener('click', deleteProject);
|
|
677
|
+
document.querySelectorAll('[data-popover]').forEach((node) => {
|
|
678
|
+
node.addEventListener('click', () => {
|
|
679
|
+
state.activePopover = state.activePopover === node.dataset.popover ? '' : node.dataset.popover;
|
|
680
|
+
renderProject();
|
|
681
|
+
});
|
|
682
|
+
});
|
|
683
|
+
document.getElementById('initButton')?.addEventListener('click', initializeProject);
|
|
684
|
+
document.getElementById('doctorButton')?.addEventListener('click', runDoctor);
|
|
685
|
+
document.getElementById('chatForm')?.addEventListener('submit', submitChat);
|
|
686
|
+
document.getElementById('messageInput')?.addEventListener('keydown', (event) => {
|
|
687
|
+
if (event.key === 'Enter' && event.shiftKey) {
|
|
688
|
+
event.preventDefault();
|
|
689
|
+
document.getElementById('chatForm')?.requestSubmit();
|
|
690
|
+
}
|
|
691
|
+
});
|
|
692
|
+
bindSetupControls();
|
|
693
|
+
document.querySelectorAll('[data-expand-output]').forEach((button) => {
|
|
694
|
+
button.addEventListener('click', () => expandOutput(button));
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
async function refresh() {
|
|
699
|
+
const data = state.routeProjectName
|
|
700
|
+
? await api('/api/projects/' + encodeURIComponent(state.routeProjectName))
|
|
701
|
+
: await api('/api/projects');
|
|
702
|
+
state.projects = data.projects || [];
|
|
703
|
+
state.projectPath = data.projectPath || state.projectPath || '';
|
|
704
|
+
state.project = data.project || null;
|
|
705
|
+
state.config = data.config || null;
|
|
706
|
+
state.configPath = data.configPath;
|
|
707
|
+
state.databasePath = data.databasePath;
|
|
708
|
+
state.messages = data.messages || [];
|
|
709
|
+
if (state.routeProjectName) renderProject();
|
|
710
|
+
else renderIndex();
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
async function initializeProject() {
|
|
714
|
+
const status = document.getElementById('settingsStatus');
|
|
715
|
+
if (status) status.textContent = 'Initializing...';
|
|
716
|
+
try {
|
|
717
|
+
const data = await api('/api/init', payload());
|
|
718
|
+
state.activePopover = '';
|
|
719
|
+
route(data.project.href);
|
|
720
|
+
} catch (error) {
|
|
721
|
+
if (status) status.textContent = error.message;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
async function runDoctor() {
|
|
726
|
+
const status = document.getElementById('settingsStatus');
|
|
727
|
+
try {
|
|
728
|
+
const data = await api('/api/doctor', payload());
|
|
729
|
+
if (status) status.textContent = data.errors.length ? data.errors.join(' ') : 'Doctor checks passed.';
|
|
730
|
+
} catch (error) {
|
|
731
|
+
if (status) status.textContent = error.message;
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
async function openCode() {
|
|
736
|
+
try {
|
|
737
|
+
await api('/api/projects/' + encodeURIComponent(state.project.name) + '/open-code', {});
|
|
738
|
+
} catch (error) {
|
|
739
|
+
setThinking([error.message]);
|
|
740
|
+
setTimeout(() => setThinking([]), 4000);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
async function clearChat() {
|
|
745
|
+
if (!state.project || state.busy) return;
|
|
746
|
+
if (!confirm('Clear all chat messages for ' + state.project.name + '?')) return;
|
|
747
|
+
setThinking(['clearing chat']);
|
|
748
|
+
try {
|
|
749
|
+
await api('/api/projects/' + encodeURIComponent(state.project.name) + '/clear-chat', {});
|
|
750
|
+
state.messages = [];
|
|
751
|
+
state.usage = null;
|
|
752
|
+
state.currentUsage = null;
|
|
753
|
+
state.streamText = '';
|
|
754
|
+
state.streamQueue = '';
|
|
755
|
+
state.streamNode = null;
|
|
756
|
+
state.streamMetaNode = null;
|
|
757
|
+
state.activityNode = null;
|
|
758
|
+
state.activityItems = [];
|
|
759
|
+
if (state.typingFrame) cancelAnimationFrame(state.typingFrame);
|
|
760
|
+
state.typingFrame = null;
|
|
761
|
+
renderProject();
|
|
762
|
+
} catch (error) {
|
|
763
|
+
setThinking([error.message]);
|
|
764
|
+
setTimeout(() => setThinking([]), 4000);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
async function deleteProject() {
|
|
769
|
+
if (!state.project || state.busy) return;
|
|
770
|
+
const name = state.project.name;
|
|
771
|
+
if (!confirm('Delete ' + name + ' from Gatekeep? The project folder will stay on disk.')) return;
|
|
772
|
+
const status = document.getElementById('settingsStatus');
|
|
773
|
+
if (status) status.textContent = 'Deleting project...';
|
|
774
|
+
try {
|
|
775
|
+
await api('/api/projects/' + encodeURIComponent(name) + '/delete', {});
|
|
776
|
+
state.project = null;
|
|
777
|
+
state.messages = [];
|
|
778
|
+
state.usage = null;
|
|
779
|
+
state.activePopover = '';
|
|
780
|
+
route('/');
|
|
781
|
+
} catch (error) {
|
|
782
|
+
if (status) status.textContent = error.message;
|
|
783
|
+
else setThinking([error.message]);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function appendUserMessage(content) {
|
|
788
|
+
state.messages.push({ role: 'user', content });
|
|
789
|
+
renderProject();
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
function ensureStreamNode() {
|
|
793
|
+
if (state.streamNode) return state.streamNode;
|
|
794
|
+
const chatInner = document.querySelector('#chat > div');
|
|
795
|
+
const wrapper = document.createElement('div');
|
|
796
|
+
wrapper.className = 'max-w-5xl';
|
|
797
|
+
wrapper.innerHTML = '<div class="markdown max-w-[86%] rounded-[20px] rounded-bl-md border border-black/5 bg-white px-4 py-3 text-sm leading-6 text-slate-800 shadow-sm shadow-slate-900/5"></div><div data-stream-meta class="mt-1 px-4 text-[11px] text-slate-400/70"></div>';
|
|
798
|
+
chatInner.appendChild(wrapper);
|
|
799
|
+
state.streamNode = wrapper.querySelector('.markdown');
|
|
800
|
+
state.streamMetaNode = wrapper.querySelector('[data-stream-meta]');
|
|
801
|
+
scrollChat();
|
|
802
|
+
return state.streamNode;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
function paintStream() {
|
|
806
|
+
if (!state.streamQueue.length) {
|
|
807
|
+
state.typingFrame = null;
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
const take = Math.min(state.streamQueue.length, state.streamQueue.length > 180 ? 8 : 3);
|
|
812
|
+
state.streamText += state.streamQueue.slice(0, take);
|
|
813
|
+
state.streamQueue = state.streamQueue.slice(take);
|
|
814
|
+
ensureStreamNode().innerHTML = streamingMarkdown(state.streamText);
|
|
815
|
+
scrollChat();
|
|
816
|
+
state.typingFrame = requestAnimationFrame(paintStream);
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function enqueueStreamText(text) {
|
|
820
|
+
state.streamQueue += text;
|
|
821
|
+
if (!state.typingFrame) {
|
|
822
|
+
state.typingFrame = requestAnimationFrame(paintStream);
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
async function flushTyping() {
|
|
827
|
+
while (state.streamQueue.length || state.typingFrame) {
|
|
828
|
+
await new Promise((resolve) => setTimeout(resolve, 16));
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
async function submitChat(event) {
|
|
833
|
+
event.preventDefault();
|
|
834
|
+
const input = document.getElementById('messageInput');
|
|
835
|
+
const message = input.value.trim();
|
|
836
|
+
if (!message || state.busy) return;
|
|
837
|
+
input.value = '';
|
|
838
|
+
appendUserMessage(message);
|
|
839
|
+
state.busy = true;
|
|
840
|
+
state.streamText = '';
|
|
841
|
+
state.streamNode = null;
|
|
842
|
+
state.streamMetaNode = null;
|
|
843
|
+
state.streamQueue = '';
|
|
844
|
+
state.activityNode = null;
|
|
845
|
+
state.activityItems = [];
|
|
846
|
+
state.currentUsage = null;
|
|
847
|
+
if (state.typingFrame) cancelAnimationFrame(state.typingFrame);
|
|
848
|
+
state.typingFrame = null;
|
|
849
|
+
setThinking(['reading context']);
|
|
850
|
+
|
|
851
|
+
try {
|
|
852
|
+
const res = await fetch('/api/projects/' + encodeURIComponent(state.project.name) + '/chat-stream', {
|
|
853
|
+
method: 'POST',
|
|
854
|
+
headers: { 'content-type': 'application/json' },
|
|
855
|
+
body: JSON.stringify({ ...payload(), message }),
|
|
856
|
+
});
|
|
857
|
+
if (!res.ok || !res.body) {
|
|
858
|
+
const data = await res.json().catch(() => ({ error: 'Request failed' }));
|
|
859
|
+
throw new Error(data.error || 'Request failed');
|
|
860
|
+
}
|
|
861
|
+
await readSse(res.body.getReader());
|
|
862
|
+
} catch (error) {
|
|
863
|
+
setThinking([error.message]);
|
|
864
|
+
} finally {
|
|
865
|
+
state.busy = false;
|
|
866
|
+
state.streamNode = null;
|
|
867
|
+
setTimeout(() => setThinking([]), 1200);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
async function readSse(reader) {
|
|
872
|
+
const decoder = new TextDecoder();
|
|
873
|
+
let buffer = '';
|
|
874
|
+
const thinking = ['thinking'];
|
|
875
|
+
while (true) {
|
|
876
|
+
const { value, done } = await reader.read();
|
|
877
|
+
if (done) break;
|
|
878
|
+
buffer += decoder.decode(value, { stream: true });
|
|
879
|
+
const chunks = buffer.split('\n\n');
|
|
880
|
+
buffer = chunks.pop() || '';
|
|
881
|
+
for (const chunk of chunks) {
|
|
882
|
+
const eventLine = chunk.split('\n').find((line) => line.startsWith('event: '));
|
|
883
|
+
const dataLine = chunk.split('\n').find((line) => line.startsWith('data: '));
|
|
884
|
+
if (!eventLine || !dataLine) continue;
|
|
885
|
+
const event = eventLine.slice(7);
|
|
886
|
+
const data = JSON.parse(dataLine.slice(6));
|
|
887
|
+
if (event === 'tool') {
|
|
888
|
+
thinking.push(data.name + ': ' + data.summary);
|
|
889
|
+
setThinking(thinking.slice(-4));
|
|
890
|
+
addActivity('tool_result', 'tool_result ' + data.name, data.summary, {
|
|
891
|
+
preview: data.preview,
|
|
892
|
+
full: data.full,
|
|
893
|
+
truncated: data.truncated,
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
if (event === 'tool_call') {
|
|
897
|
+
const detail = formatToolArgs(data.args);
|
|
898
|
+
thinking.push('tool_call ' + data.name);
|
|
899
|
+
setThinking(thinking.slice(-4));
|
|
900
|
+
addActivity('tool_call', 'tool_call ' + data.name, detail);
|
|
901
|
+
}
|
|
902
|
+
if (event === 'status') {
|
|
903
|
+
thinking.push(data.message);
|
|
904
|
+
setThinking(thinking.slice(-4));
|
|
905
|
+
addActivity('status', 'thinking', data.message);
|
|
906
|
+
}
|
|
907
|
+
if (event === 'usage') {
|
|
908
|
+
updateUsage(data);
|
|
909
|
+
setThinking(thinking.slice(-4));
|
|
910
|
+
addActivity('tokens', 'tokens', data.totalTokens + ' total · ' + data.promptTokens + ' in · ' + data.completionTokens + ' out · ' + money(data.cost));
|
|
911
|
+
}
|
|
912
|
+
if (event === 'assistant_delta') {
|
|
913
|
+
enqueueStreamText(data.delta);
|
|
914
|
+
}
|
|
915
|
+
if (event === 'done') {
|
|
916
|
+
await flushTyping();
|
|
917
|
+
if (state.streamNode) {
|
|
918
|
+
state.streamNode.innerHTML = markdown(state.streamText);
|
|
919
|
+
}
|
|
920
|
+
if (state.streamMetaNode && state.currentUsage) {
|
|
921
|
+
state.streamMetaNode.textContent = usageLine({
|
|
922
|
+
promptTokens: state.currentUsage.promptTokens,
|
|
923
|
+
completionTokens: state.currentUsage.completionTokens,
|
|
924
|
+
totalTokens: state.currentUsage.totalTokens,
|
|
925
|
+
estimatedCost: state.currentUsage.cost,
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
state.messages.push({
|
|
929
|
+
role: 'assistant',
|
|
930
|
+
content: state.streamText,
|
|
931
|
+
model: state.currentUsage?.model,
|
|
932
|
+
promptTokens: state.currentUsage?.promptTokens || 0,
|
|
933
|
+
completionTokens: state.currentUsage?.completionTokens || 0,
|
|
934
|
+
totalTokens: state.currentUsage?.totalTokens || 0,
|
|
935
|
+
estimatedCost: state.currentUsage?.cost,
|
|
936
|
+
activity: state.activityItems,
|
|
937
|
+
});
|
|
938
|
+
setThinking([]);
|
|
939
|
+
}
|
|
940
|
+
if (event === 'error') {
|
|
941
|
+
throw new Error(data.error);
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
refresh().catch((error) => {
|
|
948
|
+
shell('<main class="grid min-h-screen place-items-center px-5"><div class="rounded-lg border border-line bg-paper p-6 text-sm text-red-700">' + escapeHtml(error.message) + '</div></main>');
|
|
949
|
+
});
|
|
950
|
+
</script>
|
|
951
|
+
</body>
|
|
952
|
+
</html>
|