jekyll-theme-zer0 1.18.1 → 1.19.1
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +60 -0
- data/README.md +8 -4
- data/_data/backlog.yml +135 -2
- data/_data/features.yml +31 -10
- data/_data/hub.yml +68 -0
- data/_data/hub_index.yml +203 -0
- data/_data/navigation/hub.yml +110 -0
- data/_data/navigation/main.yml +7 -0
- data/_includes/analytics/google-analytics.html +14 -6
- data/_includes/analytics/google-tag-manager-head.html +13 -5
- data/_includes/components/ai-chat.html +143 -346
- data/_includes/core/head.html +4 -2
- data/_layouts/home.html +17 -4
- data/_sass/core/_docs-layout.scss +1 -0
- data/assets/js/ai-chat.js +853 -0
- data/scripts/lib/hub.rb +208 -0
- data/scripts/provision-org-sites.rb +252 -0
- data/scripts/provision-org-sites.sh +23 -0
- data/scripts/sync-hub-metadata.rb +184 -0
- data/scripts/sync-hub-metadata.sh +22 -0
- metadata +11 -2
|
@@ -0,0 +1,853 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ===================================================================
|
|
3
|
+
* AI Chat Widget — Claude Messages API client with GitHub tool use
|
|
4
|
+
* ===================================================================
|
|
5
|
+
*
|
|
6
|
+
* File: ai-chat.js
|
|
7
|
+
* Path: assets/js/ai-chat.js
|
|
8
|
+
* Purpose: Drives the floating chat assistant rendered by
|
|
9
|
+
* _includes/components/ai-chat.html. Talks to the Claude
|
|
10
|
+
* Messages API (streaming, Server-Sent Events) either through
|
|
11
|
+
* a same-origin proxy (production) or directly from the
|
|
12
|
+
* browser (local development only), and exposes GitHub
|
|
13
|
+
* actions — create an issue, propose a page-improvement pull
|
|
14
|
+
* request — to the model via Claude tool use.
|
|
15
|
+
*
|
|
16
|
+
* Configuration is injected by the include as JSON/text blocks:
|
|
17
|
+
* #aiChatConfig — widget + API + GitHub settings
|
|
18
|
+
* #aiChatPageContext — page metadata (title, url, source path, …)
|
|
19
|
+
* #aiChatPageContent — truncated plain-text page content
|
|
20
|
+
*
|
|
21
|
+
* Request shape (Messages API): POST {model, max_tokens, system,
|
|
22
|
+
* messages, tools?, stream: true}. The response stream is parsed from
|
|
23
|
+
* SSE events (content_block_start / content_block_delta /
|
|
24
|
+
* content_block_stop / message_delta). When the model stops with
|
|
25
|
+
* stop_reason "tool_use" the widget executes the requested tool —
|
|
26
|
+
* creation tools only after an inline user confirmation card — sends
|
|
27
|
+
* the tool_result back, and continues the loop.
|
|
28
|
+
*
|
|
29
|
+
* Security:
|
|
30
|
+
* - Proxy mode (recommended) keeps the Anthropic key and GitHub token
|
|
31
|
+
* server-side; the browser only ever talks to the same-origin proxy.
|
|
32
|
+
* - Direct mode sends x-api-key from the page and requires the
|
|
33
|
+
* anthropic-dangerous-direct-browser-access header — local dev only.
|
|
34
|
+
* - GitHub "url" mode never touches a token: it opens pre-filled
|
|
35
|
+
* github.com forms that the user reviews and submits themselves.
|
|
36
|
+
* - Issue/PR creation always requires an explicit in-chat confirmation.
|
|
37
|
+
* ===================================================================
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
(function () {
|
|
41
|
+
'use strict';
|
|
42
|
+
|
|
43
|
+
var configEl = document.getElementById('aiChatConfig');
|
|
44
|
+
var toggle = document.getElementById('aiChatToggle');
|
|
45
|
+
var panel = document.getElementById('aiChatPanel');
|
|
46
|
+
if (!configEl || !toggle || !panel) return;
|
|
47
|
+
|
|
48
|
+
var CONFIG;
|
|
49
|
+
try {
|
|
50
|
+
CONFIG = JSON.parse(configEl.textContent);
|
|
51
|
+
} catch (e) {
|
|
52
|
+
console.warn('AI Chat: invalid configuration JSON', e);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
CONFIG.github = CONFIG.github || {};
|
|
56
|
+
|
|
57
|
+
// --- Constants ---------------------------------------------------
|
|
58
|
+
var ANTHROPIC_DIRECT_URL = 'https://api.anthropic.com/v1/messages';
|
|
59
|
+
var SEND_COOLDOWN_MS = 1000; // Minimum time between sends
|
|
60
|
+
var MAX_MESSAGES = 40; // Conversation history cap (see trimHistory)
|
|
61
|
+
var MAX_TOOL_ROUNDS = 5; // Upper bound on tool_use round-trips per send
|
|
62
|
+
var MAX_SOURCE_CHARS = 48000; // Cap on fetched page source fed back to the model
|
|
63
|
+
|
|
64
|
+
var STATUS_MESSAGES = {
|
|
65
|
+
401: 'Authentication failed. The API key may be invalid or missing.',
|
|
66
|
+
403: 'Access denied. Check the API key permissions.',
|
|
67
|
+
429: 'Rate limit exceeded. Please wait a moment and try again.',
|
|
68
|
+
500: 'The AI service is temporarily unavailable. Please try again later.',
|
|
69
|
+
503: 'The AI service is temporarily unavailable. Please try again later.',
|
|
70
|
+
529: 'The AI service is overloaded right now. Please try again shortly.'
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// --- State -------------------------------------------------------
|
|
74
|
+
var isOpen = false;
|
|
75
|
+
var isLoading = false;
|
|
76
|
+
var lastSendTime = 0;
|
|
77
|
+
var history = []; // Messages API turns: {role, content: string | block[]}
|
|
78
|
+
|
|
79
|
+
// --- DOM ---------------------------------------------------------
|
|
80
|
+
var messagesContainer = document.getElementById('aiChatMessages');
|
|
81
|
+
var form = document.getElementById('aiChatForm');
|
|
82
|
+
var input = document.getElementById('aiChatInput');
|
|
83
|
+
var closeBtn = document.getElementById('aiChatClose');
|
|
84
|
+
var iconOpen = toggle.querySelector('.ai-chat-icon-open');
|
|
85
|
+
var iconClose = toggle.querySelector('.ai-chat-icon-close');
|
|
86
|
+
|
|
87
|
+
// --- Page context ------------------------------------------------
|
|
88
|
+
function pageMeta() {
|
|
89
|
+
try {
|
|
90
|
+
return JSON.parse(document.getElementById('aiChatPageContext').textContent);
|
|
91
|
+
} catch (e) {
|
|
92
|
+
console.warn('AI Chat: could not parse page context', e);
|
|
93
|
+
return {};
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function getPageContext(meta) {
|
|
98
|
+
var contentEl = document.getElementById('aiChatPageContent');
|
|
99
|
+
var pageContent = contentEl ? contentEl.textContent.trim() : '';
|
|
100
|
+
var context = 'Current page context:\n';
|
|
101
|
+
if (meta.page_title) context += '- Title: ' + meta.page_title + '\n';
|
|
102
|
+
if (meta.page_description) context += '- Description: ' + meta.page_description + '\n';
|
|
103
|
+
if (meta.page_url) context += '- URL: ' + meta.page_url + '\n';
|
|
104
|
+
if (meta.page_path) context += '- Source file in repository: ' + meta.page_path + '\n';
|
|
105
|
+
if (meta.page_categories && meta.page_categories.length) {
|
|
106
|
+
context += '- Categories: ' + [].concat(meta.page_categories).join(', ') + '\n';
|
|
107
|
+
}
|
|
108
|
+
if (meta.page_tags && meta.page_tags.length) {
|
|
109
|
+
context += '- Tags: ' + [].concat(meta.page_tags).join(', ') + '\n';
|
|
110
|
+
}
|
|
111
|
+
if (meta.page_date) context += '- Date: ' + meta.page_date + '\n';
|
|
112
|
+
if (meta.site_title) context += '- Site: ' + meta.site_title + '\n';
|
|
113
|
+
if (meta.repository) context += '- GitHub repository: ' + meta.repository + '\n';
|
|
114
|
+
if (pageContent) context += '\nPage content:\n' + pageContent + '\n';
|
|
115
|
+
return context;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// --- System prompt -----------------------------------------------
|
|
119
|
+
function todayISO() {
|
|
120
|
+
try { return new Date().toISOString().slice(0, 10); } catch (e) { return ''; }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function buildSystemPrompt(meta) {
|
|
124
|
+
var system = CONFIG.systemPrompt || 'You are a helpful assistant.';
|
|
125
|
+
var today = todayISO();
|
|
126
|
+
if (CONFIG.strictContext) {
|
|
127
|
+
system += '\n\nGrounding rules:\n'
|
|
128
|
+
+ '- Answer questions ONLY using the provided page context.\n'
|
|
129
|
+
+ '- If the answer is not in the page context, reply exactly with: "' + CONFIG.outOfScopeMessage + '"\n'
|
|
130
|
+
+ '- Do not invent facts, links, or features.\n'
|
|
131
|
+
+ '- These rules restrict how you ANSWER questions; the GitHub tools below may still be used when the user wants to report or improve something.\n';
|
|
132
|
+
}
|
|
133
|
+
if (githubEnabled()) {
|
|
134
|
+
system += '\n\nGitHub actions:\n'
|
|
135
|
+
+ '- This site lives in the GitHub repository ' + CONFIG.github.repository + '.\n'
|
|
136
|
+
+ '- Use create_github_issue when the user reports a bug, typo, broken link, confusing content, or requests an enhancement. Gather the essentials first (what, where, expected vs actual), then call the tool with a clear title and a well-structured Markdown body.\n';
|
|
137
|
+
if (prToolEnabled()) {
|
|
138
|
+
system += '- Use create_pull_request to propose a concrete improvement to this page\'s content or UI/UX. ALWAYS call get_page_source first and base updated_content on the real source file. updated_content replaces the ENTIRE file: keep the change minimal, preserve the YAML front matter (but set `lastmod` to today, ' + today + '), and do not reformat unrelated lines. The repo runs an automated content review on PRs — follow the page\'s front-matter, SEO, and structure conventions so the change passes.\n';
|
|
139
|
+
} else {
|
|
140
|
+
system += '- Pull requests are not available in this deployment. For content-improvement proposals, file an issue instead and include the suggested replacement text in the body.\n';
|
|
141
|
+
}
|
|
142
|
+
system += '- The site shows the user a confirmation card before anything is created — you do not need to ask "shall I create it?" once the details are clear.\n'
|
|
143
|
+
+ '- After a tool succeeds, give the user a one-sentence summary with the link if one was returned.\n';
|
|
144
|
+
}
|
|
145
|
+
if (CONFIG.localEdit) {
|
|
146
|
+
system += '\n\nEditing this page (local development):\n'
|
|
147
|
+
+ '- Use update_page_content to apply content or UI-copy improvements directly to the CURRENT page\'s source file. The change takes effect immediately on the local dev server.\n'
|
|
148
|
+
+ '- ALWAYS call get_page_source first and base updated_content on the real file. updated_content replaces the ENTIRE file: change only what the user asked, preserve the YAML front matter (but set `lastmod` to today, ' + today + '), and do not reformat unrelated lines.\n'
|
|
149
|
+
+ '- Prefer this over opening a pull request when the user just wants to change this page locally. The site shows a confirmation card before writing.\n';
|
|
150
|
+
}
|
|
151
|
+
var context = getPageContext(meta);
|
|
152
|
+
if (context) system += '\n\n' + context;
|
|
153
|
+
return system;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// --- Tools -------------------------------------------------------
|
|
157
|
+
function githubEnabled() {
|
|
158
|
+
return Boolean(CONFIG.github.enabled && CONFIG.github.repository);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function prToolEnabled() {
|
|
162
|
+
return githubEnabled() && CONFIG.github.mode === 'proxy';
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function buildTools() {
|
|
166
|
+
if (!githubEnabled() && !CONFIG.localEdit) return [];
|
|
167
|
+
var tools = [
|
|
168
|
+
{
|
|
169
|
+
name: 'get_page_source',
|
|
170
|
+
description: 'Fetch the raw source (Markdown/HTML/SCSS/…) of a file in the site\'s GitHub repository. '
|
|
171
|
+
+ 'Call this before proposing any content change so edits are based on the actual source file rather than the rendered page text. '
|
|
172
|
+
+ 'Defaults to the current page\'s source file.',
|
|
173
|
+
input_schema: {
|
|
174
|
+
type: 'object',
|
|
175
|
+
properties: {
|
|
176
|
+
file_path: {
|
|
177
|
+
type: 'string',
|
|
178
|
+
description: 'Repository-relative path, e.g. "pages/_posts/2026-01-01-example.md". Omit to use the current page\'s source path.'
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
required: []
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
name: 'create_github_issue',
|
|
186
|
+
description: 'Open a GitHub issue on the site\'s repository. '
|
|
187
|
+
+ 'Call this when the user reports a bug, typo, broken link, or confusing content, or requests an enhancement to the page or site UI/UX. '
|
|
188
|
+
+ 'Summarize the conversation into a specific title and a Markdown body with context (page URL, what is wrong, expected behavior). '
|
|
189
|
+
+ 'The user confirms in the chat before the issue is created.',
|
|
190
|
+
input_schema: {
|
|
191
|
+
type: 'object',
|
|
192
|
+
properties: {
|
|
193
|
+
title: { type: 'string', description: 'Concise, specific issue title.' },
|
|
194
|
+
body: { type: 'string', description: 'Markdown issue body. Include the page URL, a description of the problem or request, and any reproduction steps or suggested fix.' },
|
|
195
|
+
labels: {
|
|
196
|
+
type: 'array',
|
|
197
|
+
items: { type: 'string' },
|
|
198
|
+
description: 'Optional labels, e.g. ["bug"] or ["enhancement"].'
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
required: ['title', 'body']
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
];
|
|
205
|
+
if (prToolEnabled()) {
|
|
206
|
+
tools.push({
|
|
207
|
+
name: 'create_pull_request',
|
|
208
|
+
description: 'Create a GitHub pull request that updates ONE source file with improved content — page copy, front matter, or UI/UX tweaks. '
|
|
209
|
+
+ 'You MUST call get_page_source first and derive updated_content from it: updated_content replaces the entire file. '
|
|
210
|
+
+ 'Keep the diff minimal and preserve the YAML front matter. The user confirms in the chat before the pull request is created.',
|
|
211
|
+
input_schema: {
|
|
212
|
+
type: 'object',
|
|
213
|
+
properties: {
|
|
214
|
+
file_path: { type: 'string', description: 'Repository-relative path of the file to update.' },
|
|
215
|
+
title: { type: 'string', description: 'Pull request title (imperative, e.g. "Clarify installation steps").' },
|
|
216
|
+
body: { type: 'string', description: 'Markdown PR description: what changed, why, and a link to the page.' },
|
|
217
|
+
updated_content: { type: 'string', description: 'The COMPLETE new file content, based on get_page_source output with the improvement applied.' },
|
|
218
|
+
branch_name: { type: 'string', description: 'Optional branch name (lowercase, hyphenated). One is generated when omitted.' }
|
|
219
|
+
},
|
|
220
|
+
required: ['file_path', 'title', 'body', 'updated_content']
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
if (CONFIG.localEdit) {
|
|
225
|
+
tools.push({
|
|
226
|
+
name: 'update_page_content',
|
|
227
|
+
description: 'Apply improved content to the CURRENT page\'s source file in the local working tree (local development only). '
|
|
228
|
+
+ 'Call get_page_source first and base updated_content on it — updated_content replaces the ENTIRE file. '
|
|
229
|
+
+ 'Preserve the YAML front matter and change only what the user asked. The user confirms in the chat before the file is written, and the dev server reloads it.',
|
|
230
|
+
input_schema: {
|
|
231
|
+
type: 'object',
|
|
232
|
+
properties: {
|
|
233
|
+
file_path: { type: 'string', description: 'Repository-relative path; omit to use the current page\'s source file.' },
|
|
234
|
+
updated_content: { type: 'string', description: 'The COMPLETE new file content, based on get_page_source with the change applied.' },
|
|
235
|
+
summary: { type: 'string', description: 'One-line summary of what changed.' }
|
|
236
|
+
},
|
|
237
|
+
required: ['updated_content']
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
return tools;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// --- Rendering helpers ---------------------------------------------
|
|
245
|
+
function escapeHtml(text) {
|
|
246
|
+
return String(text)
|
|
247
|
+
.replace(/&/g, '&')
|
|
248
|
+
.replace(/</g, '<')
|
|
249
|
+
.replace(/>/g, '>')
|
|
250
|
+
.replace(/"/g, '"')
|
|
251
|
+
.replace(/'/g, ''');
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function renderAssistantMarkdown(raw) {
|
|
255
|
+
// Escape first, then allow a small markdown subset safely.
|
|
256
|
+
var safe = escapeHtml(raw || '');
|
|
257
|
+
safe = safe
|
|
258
|
+
.replace(/^###\s+(.+)$/gm, '<strong>$1</strong>')
|
|
259
|
+
.replace(/^##\s+(.+)$/gm, '<strong>$1</strong>')
|
|
260
|
+
.replace(/^#\s+(.+)$/gm, '<strong>$1</strong>')
|
|
261
|
+
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
|
262
|
+
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
|
263
|
+
.replace(/\[(.+?)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>')
|
|
264
|
+
.replace(/^\s*[-*]\s+(.+)$/gm, '• $1')
|
|
265
|
+
.replace(/\n/g, '<br>');
|
|
266
|
+
return safe;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function scrollToBottom() {
|
|
270
|
+
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function appendMessage(role, content) {
|
|
274
|
+
var wrapper = document.createElement('div');
|
|
275
|
+
wrapper.className = 'ai-chat-message ai-chat-message--' + role + ' mb-2';
|
|
276
|
+
var bubble = document.createElement('div');
|
|
277
|
+
bubble.className = 'ai-chat-bubble p-2 rounded-3 small';
|
|
278
|
+
if (role === 'assistant') {
|
|
279
|
+
bubble.innerHTML = renderAssistantMarkdown(content);
|
|
280
|
+
} else {
|
|
281
|
+
bubble.textContent = content;
|
|
282
|
+
}
|
|
283
|
+
wrapper.appendChild(bubble);
|
|
284
|
+
messagesContainer.appendChild(wrapper);
|
|
285
|
+
scrollToBottom();
|
|
286
|
+
return bubble;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function showTyping() {
|
|
290
|
+
removeTyping();
|
|
291
|
+
var wrapper = document.createElement('div');
|
|
292
|
+
wrapper.className = 'ai-chat-message ai-chat-message--assistant mb-2';
|
|
293
|
+
wrapper.id = 'aiChatLoading';
|
|
294
|
+
var bubble = document.createElement('div');
|
|
295
|
+
bubble.className = 'ai-chat-bubble p-2 rounded-3 small';
|
|
296
|
+
bubble.innerHTML = '<span class="ai-chat-typing"><span>.</span><span>.</span><span>.</span></span>';
|
|
297
|
+
wrapper.appendChild(bubble);
|
|
298
|
+
messagesContainer.appendChild(wrapper);
|
|
299
|
+
scrollToBottom();
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function removeTyping() {
|
|
303
|
+
var loading = document.getElementById('aiChatLoading');
|
|
304
|
+
if (loading) loading.remove();
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Inline confirmation card for creation tools. Resolves true/false.
|
|
308
|
+
function requestConfirmation(opts) {
|
|
309
|
+
return new Promise(function (resolve) {
|
|
310
|
+
var card = document.createElement('div');
|
|
311
|
+
card.className = 'ai-chat-action-card rounded-3 p-2 mb-2 small';
|
|
312
|
+
|
|
313
|
+
var heading = document.createElement('div');
|
|
314
|
+
heading.className = 'fw-semibold mb-1';
|
|
315
|
+
heading.textContent = opts.heading;
|
|
316
|
+
card.appendChild(heading);
|
|
317
|
+
|
|
318
|
+
(opts.fields || []).forEach(function (field) {
|
|
319
|
+
if (!field.value) return;
|
|
320
|
+
var row = document.createElement('div');
|
|
321
|
+
row.className = 'ai-chat-action-meta';
|
|
322
|
+
var label = document.createElement('span');
|
|
323
|
+
label.className = 'text-muted';
|
|
324
|
+
label.textContent = field.label + ': ';
|
|
325
|
+
var value = document.createElement('span');
|
|
326
|
+
value.textContent = field.value;
|
|
327
|
+
row.appendChild(label);
|
|
328
|
+
row.appendChild(value);
|
|
329
|
+
card.appendChild(row);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
var buttons = document.createElement('div');
|
|
333
|
+
buttons.className = 'd-flex gap-2 mt-2';
|
|
334
|
+
var confirmBtn = document.createElement('button');
|
|
335
|
+
confirmBtn.type = 'button';
|
|
336
|
+
confirmBtn.className = 'btn btn-primary btn-sm';
|
|
337
|
+
confirmBtn.textContent = opts.confirmLabel || 'Confirm';
|
|
338
|
+
var cancelBtn = document.createElement('button');
|
|
339
|
+
cancelBtn.type = 'button';
|
|
340
|
+
cancelBtn.className = 'btn btn-outline-secondary btn-sm';
|
|
341
|
+
cancelBtn.textContent = 'Cancel';
|
|
342
|
+
buttons.appendChild(confirmBtn);
|
|
343
|
+
buttons.appendChild(cancelBtn);
|
|
344
|
+
card.appendChild(buttons);
|
|
345
|
+
|
|
346
|
+
function finish(result) {
|
|
347
|
+
confirmBtn.disabled = true;
|
|
348
|
+
cancelBtn.disabled = true;
|
|
349
|
+
card.classList.add('ai-chat-action-card--resolved');
|
|
350
|
+
resolve(result);
|
|
351
|
+
}
|
|
352
|
+
confirmBtn.addEventListener('click', function () { finish(true); });
|
|
353
|
+
cancelBtn.addEventListener('click', function () { finish(false); });
|
|
354
|
+
|
|
355
|
+
messagesContainer.appendChild(card);
|
|
356
|
+
scrollToBottom();
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Result card with a link to the created issue / pull request.
|
|
361
|
+
function appendLinkCard(label, url) {
|
|
362
|
+
if (!/^https:\/\/github\.com\//.test(url)) return;
|
|
363
|
+
var card = document.createElement('div');
|
|
364
|
+
card.className = 'ai-chat-action-card rounded-3 p-2 mb-2 small';
|
|
365
|
+
var link = document.createElement('a');
|
|
366
|
+
link.href = url;
|
|
367
|
+
link.target = '_blank';
|
|
368
|
+
link.rel = 'noopener noreferrer';
|
|
369
|
+
link.textContent = label + ' ↗';
|
|
370
|
+
card.appendChild(link);
|
|
371
|
+
messagesContainer.appendChild(card);
|
|
372
|
+
scrollToBottom();
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// --- Claude Messages API (streaming) -------------------------------
|
|
376
|
+
function apiUrl() {
|
|
377
|
+
if (CONFIG.authMode === 'direct') {
|
|
378
|
+
// Direct mode talks straight to Anthropic unless an explicit
|
|
379
|
+
// non-proxy endpoint was configured (e.g. a mock for testing).
|
|
380
|
+
if (!CONFIG.endpoint || CONFIG.endpoint === '/api/chat') return ANTHROPIC_DIRECT_URL;
|
|
381
|
+
}
|
|
382
|
+
return CONFIG.endpoint;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async function streamClaude(payload, onTextDelta) {
|
|
386
|
+
var headers = { 'content-type': 'application/json' };
|
|
387
|
+
if (CONFIG.authMode === 'direct') {
|
|
388
|
+
headers['x-api-key'] = CONFIG.apiKey;
|
|
389
|
+
headers['anthropic-version'] = CONFIG.anthropicVersion || '2023-06-01';
|
|
390
|
+
// Required for browser (CORS) access to the Anthropic API.
|
|
391
|
+
headers['anthropic-dangerous-direct-browser-access'] = 'true';
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
var response = await fetch(apiUrl(), {
|
|
395
|
+
method: 'POST',
|
|
396
|
+
headers: headers,
|
|
397
|
+
body: JSON.stringify(payload)
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
if (!response.ok) {
|
|
401
|
+
var errorData = await response.json().catch(function () { return {}; });
|
|
402
|
+
var apiMessage = errorData.error && errorData.error.message;
|
|
403
|
+
throw new Error(apiMessage || STATUS_MESSAGES[response.status] || 'API request failed (' + response.status + ')');
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Proxies may answer non-streaming JSON; accept both.
|
|
407
|
+
var contentType = response.headers.get('content-type') || '';
|
|
408
|
+
if (contentType.indexOf('text/event-stream') === -1) {
|
|
409
|
+
var data = await response.json();
|
|
410
|
+
return { content: data.content || [], stopReason: data.stop_reason || null };
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
var reader = response.body.getReader();
|
|
414
|
+
var decoder = new TextDecoder();
|
|
415
|
+
var buffer = '';
|
|
416
|
+
var blocks = [];
|
|
417
|
+
var partialJson = {};
|
|
418
|
+
var stopReason = null;
|
|
419
|
+
|
|
420
|
+
function handleEvent(evt) {
|
|
421
|
+
switch (evt.type) {
|
|
422
|
+
case 'content_block_start':
|
|
423
|
+
blocks[evt.index] = Object.assign({}, evt.content_block);
|
|
424
|
+
if (evt.content_block.type === 'tool_use') partialJson[evt.index] = '';
|
|
425
|
+
break;
|
|
426
|
+
case 'content_block_delta':
|
|
427
|
+
if (evt.delta.type === 'text_delta') {
|
|
428
|
+
blocks[evt.index].text = (blocks[evt.index].text || '') + evt.delta.text;
|
|
429
|
+
if (onTextDelta) onTextDelta(evt.delta.text);
|
|
430
|
+
} else if (evt.delta.type === 'input_json_delta') {
|
|
431
|
+
partialJson[evt.index] += evt.delta.partial_json;
|
|
432
|
+
}
|
|
433
|
+
break;
|
|
434
|
+
case 'content_block_stop':
|
|
435
|
+
if (blocks[evt.index] && blocks[evt.index].type === 'tool_use') {
|
|
436
|
+
try {
|
|
437
|
+
blocks[evt.index].input = partialJson[evt.index] ? JSON.parse(partialJson[evt.index]) : {};
|
|
438
|
+
} catch (e) {
|
|
439
|
+
blocks[evt.index].input = {};
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
break;
|
|
443
|
+
case 'message_delta':
|
|
444
|
+
if (evt.delta && evt.delta.stop_reason) stopReason = evt.delta.stop_reason;
|
|
445
|
+
break;
|
|
446
|
+
case 'error':
|
|
447
|
+
throw new Error((evt.error && evt.error.message) || 'Stream error');
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
while (true) {
|
|
452
|
+
var chunk = await reader.read();
|
|
453
|
+
if (chunk.done) break;
|
|
454
|
+
buffer += decoder.decode(chunk.value, { stream: true });
|
|
455
|
+
var newlineIndex;
|
|
456
|
+
while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
|
|
457
|
+
var line = buffer.slice(0, newlineIndex).replace(/\r$/, '');
|
|
458
|
+
buffer = buffer.slice(newlineIndex + 1);
|
|
459
|
+
if (line.indexOf('data:') !== 0) continue;
|
|
460
|
+
var dataStr = line.slice(5).trim();
|
|
461
|
+
if (!dataStr) continue;
|
|
462
|
+
var evt;
|
|
463
|
+
try { evt = JSON.parse(dataStr); } catch (e) { continue; }
|
|
464
|
+
handleEvent(evt);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Keep only the block types we replay into history (no thinking blocks
|
|
469
|
+
// are requested, but stay defensive against future stream additions).
|
|
470
|
+
var content = blocks.filter(Boolean).map(function (block) {
|
|
471
|
+
if (block.type === 'text') return { type: 'text', text: block.text || '' };
|
|
472
|
+
if (block.type === 'tool_use') return { type: 'tool_use', id: block.id, name: block.name, input: block.input || {} };
|
|
473
|
+
return null;
|
|
474
|
+
}).filter(Boolean);
|
|
475
|
+
|
|
476
|
+
return { content: content, stopReason: stopReason };
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// --- Tool execution -------------------------------------------------
|
|
480
|
+
function toolResult(toolUseId, content, isError) {
|
|
481
|
+
var result = { type: 'tool_result', tool_use_id: toolUseId, content: content };
|
|
482
|
+
if (isError) result.is_error = true;
|
|
483
|
+
return result;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function sanitizeRepoPath(path) {
|
|
487
|
+
var clean = String(path || '').replace(/^\/+/, '').trim();
|
|
488
|
+
if (!clean || clean.indexOf('..') !== -1 || clean.indexOf('\\') !== -1) return null;
|
|
489
|
+
return clean;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
async function execGetPageSource(block, meta) {
|
|
493
|
+
var path = sanitizeRepoPath((block.input && block.input.file_path) || meta.page_path);
|
|
494
|
+
if (!path) return toolResult(block.id, 'No valid source path is available for this page.', true);
|
|
495
|
+
|
|
496
|
+
// Local dev: read the working-tree file via the dev proxy so edits are
|
|
497
|
+
// based on the real local source (which may differ from GitHub).
|
|
498
|
+
if (CONFIG.localEdit) {
|
|
499
|
+
try {
|
|
500
|
+
var localResp = await fetch(CONFIG.localEditEndpoint + '/source?path=' + encodeURIComponent(path));
|
|
501
|
+
var localData = await localResp.json().catch(function () { return {}; });
|
|
502
|
+
if (!localResp.ok) {
|
|
503
|
+
return toolResult(block.id, 'Could not read local source for ' + path + ': ' + ((localData.error && localData.error.message) || localResp.status), true);
|
|
504
|
+
}
|
|
505
|
+
var localText = localData.content || '';
|
|
506
|
+
if (localText.length > MAX_SOURCE_CHARS) {
|
|
507
|
+
localText = localText.slice(0, MAX_SOURCE_CHARS) + '\n\n[... truncated: file exceeds ' + MAX_SOURCE_CHARS + ' characters ...]';
|
|
508
|
+
}
|
|
509
|
+
return toolResult(block.id, 'Source of ' + path + ' (local working tree):\n\n' + localText);
|
|
510
|
+
} catch (e) {
|
|
511
|
+
return toolResult(block.id, 'Network error reading local source: ' + e.message, true);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
var url = 'https://raw.githubusercontent.com/' + CONFIG.github.repository + '/'
|
|
516
|
+
+ (CONFIG.github.baseBranch || 'main').split('/').map(encodeURIComponent).join('/') + '/'
|
|
517
|
+
+ path.split('/').map(encodeURIComponent).join('/');
|
|
518
|
+
var response;
|
|
519
|
+
try {
|
|
520
|
+
response = await fetch(url);
|
|
521
|
+
} catch (e) {
|
|
522
|
+
return toolResult(block.id, 'Network error fetching ' + path + ': ' + e.message, true);
|
|
523
|
+
}
|
|
524
|
+
if (!response.ok) {
|
|
525
|
+
return toolResult(block.id, 'Could not fetch source for ' + path + ' (HTTP ' + response.status + '). The file may not exist on branch ' + (CONFIG.github.baseBranch || 'main') + '.', true);
|
|
526
|
+
}
|
|
527
|
+
var text = await response.text();
|
|
528
|
+
if (text.length > MAX_SOURCE_CHARS) {
|
|
529
|
+
text = text.slice(0, MAX_SOURCE_CHARS) + '\n\n[... truncated: file exceeds ' + MAX_SOURCE_CHARS + ' characters ...]';
|
|
530
|
+
}
|
|
531
|
+
return toolResult(block.id, 'Source of ' + path + ':\n\n' + text);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function mergedLabels(labels) {
|
|
535
|
+
var merged = [].concat(labels || [], CONFIG.github.defaultLabels || []);
|
|
536
|
+
return merged.filter(function (label, i) { return label && merged.indexOf(label) === i; });
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
async function githubProxyPost(path, body) {
|
|
540
|
+
var response = await fetch(CONFIG.github.endpoint + path, {
|
|
541
|
+
method: 'POST',
|
|
542
|
+
headers: { 'content-type': 'application/json' },
|
|
543
|
+
body: JSON.stringify(body)
|
|
544
|
+
});
|
|
545
|
+
var data = await response.json().catch(function () { return {}; });
|
|
546
|
+
if (!response.ok) {
|
|
547
|
+
throw new Error((data.error && data.error.message) || 'GitHub proxy request failed (' + response.status + ')');
|
|
548
|
+
}
|
|
549
|
+
return data;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
async function execCreateIssue(block, meta) {
|
|
553
|
+
var inputData = block.input || {};
|
|
554
|
+
if (!inputData.title || !inputData.body) {
|
|
555
|
+
return toolResult(block.id, 'Missing required fields: title and body.', true);
|
|
556
|
+
}
|
|
557
|
+
var issueBody = String(inputData.body || '');
|
|
558
|
+
var confirmed = await requestConfirmation({
|
|
559
|
+
heading: 'Create a GitHub issue?',
|
|
560
|
+
fields: [
|
|
561
|
+
{ label: 'Repository', value: CONFIG.github.repository },
|
|
562
|
+
{ label: 'Title', value: inputData.title },
|
|
563
|
+
{ label: 'Labels', value: mergedLabels(inputData.labels).join(', ') },
|
|
564
|
+
{ label: 'Body', value: issueBody.slice(0, 280) + (issueBody.length > 280 ? '…' : '') }
|
|
565
|
+
],
|
|
566
|
+
confirmLabel: CONFIG.github.mode === 'proxy' ? 'Create issue' : 'Open issue form'
|
|
567
|
+
});
|
|
568
|
+
if (!confirmed) {
|
|
569
|
+
return toolResult(block.id, 'The user declined this action in the confirmation dialog. Do not retry unless they ask again.');
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
if (CONFIG.github.mode === 'proxy') {
|
|
573
|
+
try {
|
|
574
|
+
var created = await githubProxyPost('/issue', {
|
|
575
|
+
title: inputData.title,
|
|
576
|
+
body: inputData.body,
|
|
577
|
+
labels: mergedLabels(inputData.labels)
|
|
578
|
+
});
|
|
579
|
+
appendLinkCard('Issue #' + created.number + ' created', created.url);
|
|
580
|
+
return toolResult(block.id, 'Issue created: ' + created.url);
|
|
581
|
+
} catch (e) {
|
|
582
|
+
return toolResult(block.id, 'Failed to create issue: ' + e.message, true);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// URL mode: open a pre-filled github.com form — the user submits it
|
|
587
|
+
// with their own account, so no token is ever needed in the browser.
|
|
588
|
+
var params = new URLSearchParams();
|
|
589
|
+
params.set('title', String(inputData.title).slice(0, 256));
|
|
590
|
+
var body = String(inputData.body);
|
|
591
|
+
if (meta.page_url && body.indexOf(meta.page_url) === -1) {
|
|
592
|
+
body += '\n\n---\nPage: ' + meta.page_url;
|
|
593
|
+
}
|
|
594
|
+
params.set('body', body.slice(0, 6000));
|
|
595
|
+
var labels = mergedLabels(inputData.labels);
|
|
596
|
+
if (labels.length) params.set('labels', labels.join(','));
|
|
597
|
+
window.open('https://github.com/' + CONFIG.github.repository + '/issues/new?' + params.toString(), '_blank', 'noopener');
|
|
598
|
+
return toolResult(block.id, 'A pre-filled GitHub issue form was opened in a new browser tab. The user reviews and submits it there (a GitHub account is required).');
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
async function execCreatePullRequest(block) {
|
|
602
|
+
var inputData = block.input || {};
|
|
603
|
+
if (!inputData.file_path || !inputData.title || !inputData.body || !inputData.updated_content) {
|
|
604
|
+
return toolResult(block.id, 'Missing required fields: file_path, title, body, updated_content.', true);
|
|
605
|
+
}
|
|
606
|
+
var path = sanitizeRepoPath(inputData.file_path);
|
|
607
|
+
if (!path) return toolResult(block.id, 'Invalid file path: ' + inputData.file_path, true);
|
|
608
|
+
|
|
609
|
+
var prBody = String(inputData.body || '');
|
|
610
|
+
var confirmed = await requestConfirmation({
|
|
611
|
+
heading: 'Open a pull request?',
|
|
612
|
+
fields: [
|
|
613
|
+
{ label: 'Repository', value: CONFIG.github.repository },
|
|
614
|
+
{ label: 'File', value: path },
|
|
615
|
+
{ label: 'Title', value: inputData.title },
|
|
616
|
+
{ label: 'Summary', value: prBody.slice(0, 280) + (prBody.length > 280 ? '…' : '') }
|
|
617
|
+
],
|
|
618
|
+
confirmLabel: 'Create pull request'
|
|
619
|
+
});
|
|
620
|
+
if (!confirmed) {
|
|
621
|
+
return toolResult(block.id, 'The user declined this action in the confirmation dialog. Do not retry unless they ask again.');
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
try {
|
|
625
|
+
var created = await githubProxyPost('/pull-request', {
|
|
626
|
+
title: inputData.title,
|
|
627
|
+
body: inputData.body,
|
|
628
|
+
file_path: path,
|
|
629
|
+
updated_content: inputData.updated_content,
|
|
630
|
+
branch_name: inputData.branch_name || ''
|
|
631
|
+
});
|
|
632
|
+
appendLinkCard('Pull request #' + created.number + ' opened', created.url);
|
|
633
|
+
return toolResult(block.id, 'Pull request created: ' + created.url);
|
|
634
|
+
} catch (e) {
|
|
635
|
+
return toolResult(block.id, 'Failed to create pull request: ' + e.message, true);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// Result card with a button to reload the page after a local edit.
|
|
640
|
+
function appendReloadCard(path) {
|
|
641
|
+
var card = document.createElement('div');
|
|
642
|
+
card.className = 'ai-chat-action-card rounded-3 p-2 mb-2 small';
|
|
643
|
+
var label = document.createElement('div');
|
|
644
|
+
label.className = 'mb-1';
|
|
645
|
+
label.textContent = 'Updated ' + path + ' — the dev server will rebuild it.';
|
|
646
|
+
card.appendChild(label);
|
|
647
|
+
var btn = document.createElement('button');
|
|
648
|
+
btn.type = 'button';
|
|
649
|
+
btn.className = 'btn btn-primary btn-sm';
|
|
650
|
+
btn.textContent = 'Reload page';
|
|
651
|
+
btn.addEventListener('click', function () { window.location.reload(); });
|
|
652
|
+
card.appendChild(btn);
|
|
653
|
+
messagesContainer.appendChild(card);
|
|
654
|
+
scrollToBottom();
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
async function execUpdatePageContent(block, meta) {
|
|
658
|
+
var inputData = block.input || {};
|
|
659
|
+
var path = sanitizeRepoPath(inputData.file_path || meta.page_path);
|
|
660
|
+
if (!path) return toolResult(block.id, 'No valid source path is available for this page.', true);
|
|
661
|
+
if (!inputData.updated_content) return toolResult(block.id, 'Missing updated_content.', true);
|
|
662
|
+
|
|
663
|
+
var confirmed = await requestConfirmation({
|
|
664
|
+
heading: 'Apply this edit to the current page?',
|
|
665
|
+
fields: [
|
|
666
|
+
{ label: 'File', value: path },
|
|
667
|
+
{ label: 'Change', value: inputData.summary || '(no summary provided)' },
|
|
668
|
+
{ label: 'New length', value: inputData.updated_content.length + ' characters' }
|
|
669
|
+
],
|
|
670
|
+
confirmLabel: 'Apply edit'
|
|
671
|
+
});
|
|
672
|
+
if (!confirmed) {
|
|
673
|
+
return toolResult(block.id, 'The user declined the edit in the confirmation dialog. Do not retry unless they ask again.');
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
try {
|
|
677
|
+
var response = await fetch(CONFIG.localEditEndpoint + '/update', {
|
|
678
|
+
method: 'POST',
|
|
679
|
+
headers: { 'content-type': 'application/json' },
|
|
680
|
+
body: JSON.stringify({ file_path: path, updated_content: inputData.updated_content })
|
|
681
|
+
});
|
|
682
|
+
var data = await response.json().catch(function () { return {}; });
|
|
683
|
+
if (!response.ok) {
|
|
684
|
+
return toolResult(block.id, 'Failed to update the page: ' + ((data.error && data.error.message) || response.status), true);
|
|
685
|
+
}
|
|
686
|
+
appendReloadCard(data.path || path);
|
|
687
|
+
return toolResult(block.id, 'Updated ' + (data.path || path) + ' (' + (data.bytes || 0) + ' bytes) in the local working tree. The dev server rebuilds and reloads it.');
|
|
688
|
+
} catch (e) {
|
|
689
|
+
return toolResult(block.id, 'Failed to update the page: ' + e.message, true);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
async function executeToolUse(block, meta) {
|
|
694
|
+
switch (block.name) {
|
|
695
|
+
case 'get_page_source': return execGetPageSource(block, meta);
|
|
696
|
+
case 'create_github_issue': return execCreateIssue(block, meta);
|
|
697
|
+
case 'create_pull_request': return execCreatePullRequest(block);
|
|
698
|
+
case 'update_page_content': return execUpdatePageContent(block, meta);
|
|
699
|
+
default: return toolResult(block.id, 'Unknown tool: ' + block.name, true);
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// --- History management ----------------------------------------------
|
|
704
|
+
// Trim from the front, then keep trimming until the history starts with a
|
|
705
|
+
// plain user text turn — never orphan a tool_result from its tool_use.
|
|
706
|
+
function trimHistory() {
|
|
707
|
+
while (history.length > MAX_MESSAGES) history.shift();
|
|
708
|
+
while (history.length && !(history[0].role === 'user' && typeof history[0].content === 'string')) {
|
|
709
|
+
history.shift();
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// --- Send / agentic loop ----------------------------------------------
|
|
714
|
+
async function sendMessage(userMessage) {
|
|
715
|
+
if (isLoading || !userMessage.trim()) return;
|
|
716
|
+
var now = Date.now();
|
|
717
|
+
if (now - lastSendTime < SEND_COOLDOWN_MS) return;
|
|
718
|
+
lastSendTime = now;
|
|
719
|
+
|
|
720
|
+
appendMessage('user', userMessage);
|
|
721
|
+
history.push({ role: 'user', content: userMessage });
|
|
722
|
+
|
|
723
|
+
isLoading = true;
|
|
724
|
+
input.disabled = true;
|
|
725
|
+
showTyping();
|
|
726
|
+
|
|
727
|
+
var meta = pageMeta();
|
|
728
|
+
var tools = buildTools();
|
|
729
|
+
var sawText = false;
|
|
730
|
+
|
|
731
|
+
try {
|
|
732
|
+
for (var round = 0; round < MAX_TOOL_ROUNDS; round++) {
|
|
733
|
+
var bubble = null;
|
|
734
|
+
var accumulated = '';
|
|
735
|
+
var payload = {
|
|
736
|
+
model: CONFIG.model,
|
|
737
|
+
max_tokens: CONFIG.maxTokens,
|
|
738
|
+
system: buildSystemPrompt(meta),
|
|
739
|
+
messages: history.slice(),
|
|
740
|
+
stream: true
|
|
741
|
+
};
|
|
742
|
+
if (tools.length) payload.tools = tools;
|
|
743
|
+
|
|
744
|
+
var result = await streamClaude(payload, function (delta) {
|
|
745
|
+
if (!bubble) {
|
|
746
|
+
removeTyping();
|
|
747
|
+
bubble = appendMessage('assistant', '');
|
|
748
|
+
}
|
|
749
|
+
accumulated += delta;
|
|
750
|
+
bubble.textContent = accumulated; // plain text while streaming
|
|
751
|
+
scrollToBottom();
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
removeTyping();
|
|
755
|
+
if (bubble && accumulated) {
|
|
756
|
+
bubble.innerHTML = renderAssistantMarkdown(accumulated); // final markdown pass
|
|
757
|
+
sawText = true;
|
|
758
|
+
} else if (!bubble) {
|
|
759
|
+
// Non-streaming response (proxy returned JSON, not SSE): no text deltas
|
|
760
|
+
// arrived, so render any text blocks from the final content.
|
|
761
|
+
var textOut = result.content
|
|
762
|
+
.filter(function (b) { return b.type === 'text'; })
|
|
763
|
+
.map(function (b) { return b.text || ''; })
|
|
764
|
+
.join('')
|
|
765
|
+
.trim();
|
|
766
|
+
if (textOut) {
|
|
767
|
+
appendMessage('assistant', textOut);
|
|
768
|
+
sawText = true;
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
if (result.content.length) {
|
|
772
|
+
history.push({ role: 'assistant', content: result.content });
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
var toolUses = result.content.filter(function (b) { return b.type === 'tool_use'; });
|
|
776
|
+
if (result.stopReason !== 'tool_use' || !toolUses.length) break;
|
|
777
|
+
|
|
778
|
+
var results = [];
|
|
779
|
+
for (var i = 0; i < toolUses.length; i++) {
|
|
780
|
+
results.push(await executeToolUse(toolUses[i], meta));
|
|
781
|
+
}
|
|
782
|
+
history.push({ role: 'user', content: results });
|
|
783
|
+
showTyping();
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
if (!sawText) {
|
|
787
|
+
appendMessage('assistant', CONFIG.outOfScopeMessage || 'Sorry, I could not generate a response.');
|
|
788
|
+
}
|
|
789
|
+
} catch (error) {
|
|
790
|
+
removeTyping();
|
|
791
|
+
appendMessage('assistant', 'Sorry, something went wrong: ' + error.message);
|
|
792
|
+
console.error('AI Chat error:', error);
|
|
793
|
+
} finally {
|
|
794
|
+
isLoading = false;
|
|
795
|
+
input.disabled = false;
|
|
796
|
+
input.focus();
|
|
797
|
+
trimHistory();
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// --- UI wiring ---------------------------------------------------------
|
|
802
|
+
function toggleChat() {
|
|
803
|
+
isOpen = !isOpen;
|
|
804
|
+
panel.classList.toggle('ai-chat-panel--open', isOpen);
|
|
805
|
+
panel.setAttribute('aria-hidden', String(!isOpen));
|
|
806
|
+
toggle.setAttribute('aria-expanded', String(isOpen));
|
|
807
|
+
iconOpen.classList.toggle('d-none', isOpen);
|
|
808
|
+
iconClose.classList.toggle('d-none', !isOpen);
|
|
809
|
+
if (isOpen) {
|
|
810
|
+
setTimeout(function () { input.focus(); }, 50);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
function init() {
|
|
815
|
+
appendMessage('assistant', CONFIG.welcomeMessage);
|
|
816
|
+
|
|
817
|
+
toggle.addEventListener('click', toggleChat);
|
|
818
|
+
closeBtn.addEventListener('click', toggleChat);
|
|
819
|
+
|
|
820
|
+
form.addEventListener('submit', function (e) {
|
|
821
|
+
e.preventDefault();
|
|
822
|
+
var msg = input.value.trim();
|
|
823
|
+
if (msg) {
|
|
824
|
+
input.value = '';
|
|
825
|
+
sendMessage(msg);
|
|
826
|
+
}
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
// Quick-action chips (rendered only when GitHub actions are enabled)
|
|
830
|
+
panel.querySelectorAll('.ai-chat-chip').forEach(function (chip) {
|
|
831
|
+
chip.addEventListener('click', function () {
|
|
832
|
+
var prompt = chip.getAttribute('data-prompt');
|
|
833
|
+
if (prompt) sendMessage(prompt);
|
|
834
|
+
});
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
document.addEventListener('keydown', function (e) {
|
|
838
|
+
if (e.key === 'Escape' && isOpen) toggleChat();
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
document.addEventListener('click', function (e) {
|
|
842
|
+
if (isOpen && !panel.contains(e.target) && !toggle.contains(e.target)) {
|
|
843
|
+
toggleChat();
|
|
844
|
+
}
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
if (document.readyState === 'loading') {
|
|
849
|
+
document.addEventListener('DOMContentLoaded', init);
|
|
850
|
+
} else {
|
|
851
|
+
init();
|
|
852
|
+
}
|
|
853
|
+
})();
|