@agent-link/server 0.1.28 → 0.1.30
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/package.json +1 -1
- package/web/app.js +111 -1030
- package/web/modules/askQuestion.js +63 -0
- package/web/modules/connection.js +342 -0
- package/web/modules/fileAttachments.js +125 -0
- package/web/modules/markdown.js +82 -0
- package/web/modules/messageHelpers.js +185 -0
- package/web/modules/sidebar.js +186 -0
- package/web/modules/streaming.js +93 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// ── AskUserQuestion interaction ───────────────────────────────────────────────
|
|
2
|
+
|
|
3
|
+
export function selectQuestionOption(msg, qIndex, optLabel) {
|
|
4
|
+
if (msg.answered) return;
|
|
5
|
+
const q = msg.questions[qIndex];
|
|
6
|
+
if (!q) return;
|
|
7
|
+
if (q.multiSelect) {
|
|
8
|
+
const sel = msg.selectedAnswers[qIndex] || [];
|
|
9
|
+
const idx = sel.indexOf(optLabel);
|
|
10
|
+
if (idx >= 0) sel.splice(idx, 1);
|
|
11
|
+
else sel.push(optLabel);
|
|
12
|
+
msg.selectedAnswers[qIndex] = [...sel];
|
|
13
|
+
} else {
|
|
14
|
+
msg.selectedAnswers[qIndex] = optLabel;
|
|
15
|
+
msg.customTexts[qIndex] = '';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function submitQuestionAnswer(msg, wsSend) {
|
|
20
|
+
if (msg.answered) return;
|
|
21
|
+
const answers = {};
|
|
22
|
+
for (let i = 0; i < msg.questions.length; i++) {
|
|
23
|
+
const q = msg.questions[i];
|
|
24
|
+
const key = q.question || String(i);
|
|
25
|
+
const custom = (msg.customTexts[i] || '').trim();
|
|
26
|
+
if (custom) {
|
|
27
|
+
answers[key] = custom;
|
|
28
|
+
} else {
|
|
29
|
+
const sel = msg.selectedAnswers[i];
|
|
30
|
+
if (Array.isArray(sel) && sel.length > 0) {
|
|
31
|
+
answers[key] = sel.join(', ');
|
|
32
|
+
} else if (sel != null) {
|
|
33
|
+
answers[key] = sel;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
msg.answered = true;
|
|
38
|
+
wsSend({ type: 'ask_user_answer', requestId: msg.requestId, answers });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function hasQuestionAnswer(msg) {
|
|
42
|
+
for (let i = 0; i < msg.questions.length; i++) {
|
|
43
|
+
const sel = msg.selectedAnswers[i];
|
|
44
|
+
const custom = (msg.customTexts[i] || '').trim();
|
|
45
|
+
if (custom || (Array.isArray(sel) ? sel.length > 0 : sel != null)) return true;
|
|
46
|
+
}
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function getQuestionResponseSummary(msg) {
|
|
51
|
+
const parts = [];
|
|
52
|
+
for (let i = 0; i < msg.questions.length; i++) {
|
|
53
|
+
const custom = (msg.customTexts[i] || '').trim();
|
|
54
|
+
if (custom) {
|
|
55
|
+
parts.push(custom);
|
|
56
|
+
} else {
|
|
57
|
+
const sel = msg.selectedAnswers[i];
|
|
58
|
+
if (Array.isArray(sel)) parts.push(sel.join(', '));
|
|
59
|
+
else if (sel) parts.push(sel);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return parts.join(' | ');
|
|
63
|
+
}
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
// ── WebSocket connection, message routing, reconnection ──────────────────────
|
|
2
|
+
import { encrypt, decrypt, isEncrypted, decodeKey } from '../encryption.js';
|
|
3
|
+
import { isContextSummary } from './messageHelpers.js';
|
|
4
|
+
|
|
5
|
+
const MAX_RECONNECT_ATTEMPTS = 50;
|
|
6
|
+
const RECONNECT_BASE_DELAY = 1000;
|
|
7
|
+
const RECONNECT_MAX_DELAY = 15000;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Creates the WebSocket connection controller.
|
|
11
|
+
* @param {object} deps - All reactive state and callbacks needed
|
|
12
|
+
*/
|
|
13
|
+
export function createConnection(deps) {
|
|
14
|
+
const {
|
|
15
|
+
status, agentName, hostname, workDir, sessionId, error,
|
|
16
|
+
messages, isProcessing, isCompacting, visibleLimit,
|
|
17
|
+
historySessions, currentClaudeSessionId, loadingSessions, loadingHistory,
|
|
18
|
+
folderPickerLoading, folderPickerEntries, folderPickerPath,
|
|
19
|
+
streaming, sidebar,
|
|
20
|
+
scrollToBottom,
|
|
21
|
+
} = deps;
|
|
22
|
+
|
|
23
|
+
let ws = null;
|
|
24
|
+
let sessionKey = null;
|
|
25
|
+
let reconnectAttempts = 0;
|
|
26
|
+
let reconnectTimer = null;
|
|
27
|
+
|
|
28
|
+
function wsSend(msg) {
|
|
29
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
30
|
+
if (sessionKey) {
|
|
31
|
+
const encrypted = encrypt(msg, sessionKey);
|
|
32
|
+
ws.send(JSON.stringify(encrypted));
|
|
33
|
+
} else {
|
|
34
|
+
ws.send(JSON.stringify(msg));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function getSessionId() {
|
|
39
|
+
const match = window.location.pathname.match(/^\/s\/([^/]+)/);
|
|
40
|
+
return match ? match[1] : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function finalizeStreamingMsg(scheduleHighlight) {
|
|
44
|
+
const sid = streaming.getStreamingMessageId();
|
|
45
|
+
if (sid === null) return;
|
|
46
|
+
const streamMsg = messages.value.find(m => m.id === sid);
|
|
47
|
+
if (streamMsg) {
|
|
48
|
+
streamMsg.isStreaming = false;
|
|
49
|
+
if (isContextSummary(streamMsg.content)) {
|
|
50
|
+
streamMsg.role = 'context-summary';
|
|
51
|
+
streamMsg.contextExpanded = false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
streaming.setStreamingMessageId(null);
|
|
55
|
+
if (scheduleHighlight) scheduleHighlight();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function handleClaudeOutput(msg, scheduleHighlight) {
|
|
59
|
+
const data = msg.data;
|
|
60
|
+
if (!data) return;
|
|
61
|
+
|
|
62
|
+
if (data.type === 'content_block_delta' && data.delta) {
|
|
63
|
+
streaming.appendPending(data.delta);
|
|
64
|
+
streaming.startReveal();
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (data.type === 'tool_use' && data.tools) {
|
|
69
|
+
streaming.flushReveal();
|
|
70
|
+
finalizeStreamingMsg(scheduleHighlight);
|
|
71
|
+
|
|
72
|
+
for (const tool of data.tools) {
|
|
73
|
+
messages.value.push({
|
|
74
|
+
id: streaming.nextId(), role: 'tool',
|
|
75
|
+
toolId: tool.id, toolName: tool.name || 'unknown',
|
|
76
|
+
toolInput: tool.input ? JSON.stringify(tool.input, null, 2) : '',
|
|
77
|
+
hasResult: false, expanded: (tool.name === 'Edit' || tool.name === 'TodoWrite'), timestamp: new Date(),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
scrollToBottom();
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (data.type === 'user' && data.tool_use_result) {
|
|
85
|
+
const result = data.tool_use_result;
|
|
86
|
+
const results = Array.isArray(result) ? result : [result];
|
|
87
|
+
for (const r of results) {
|
|
88
|
+
const toolMsg = [...messages.value].reverse().find(
|
|
89
|
+
m => m.role === 'tool' && m.toolId === r.tool_use_id
|
|
90
|
+
);
|
|
91
|
+
if (toolMsg) {
|
|
92
|
+
toolMsg.toolOutput = typeof r.content === 'string'
|
|
93
|
+
? r.content : JSON.stringify(r.content, null, 2);
|
|
94
|
+
toolMsg.hasResult = true;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
scrollToBottom();
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function connect(scheduleHighlight) {
|
|
103
|
+
const sid = getSessionId();
|
|
104
|
+
if (!sid) {
|
|
105
|
+
status.value = 'No Session';
|
|
106
|
+
error.value = 'No session ID in URL. Use a session URL provided by agentlink start.';
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
sessionId.value = sid;
|
|
110
|
+
status.value = 'Connecting...';
|
|
111
|
+
error.value = '';
|
|
112
|
+
|
|
113
|
+
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
114
|
+
const wsUrl = `${protocol}//${window.location.host}/?type=web&sessionId=${sid}`;
|
|
115
|
+
ws = new WebSocket(wsUrl);
|
|
116
|
+
|
|
117
|
+
ws.onopen = () => { error.value = ''; reconnectAttempts = 0; };
|
|
118
|
+
|
|
119
|
+
ws.onmessage = (event) => {
|
|
120
|
+
let msg;
|
|
121
|
+
const parsed = JSON.parse(event.data);
|
|
122
|
+
|
|
123
|
+
if (parsed.type === 'connected') {
|
|
124
|
+
msg = parsed;
|
|
125
|
+
if (typeof parsed.sessionKey === 'string') {
|
|
126
|
+
sessionKey = decodeKey(parsed.sessionKey);
|
|
127
|
+
}
|
|
128
|
+
} else if (sessionKey && isEncrypted(parsed)) {
|
|
129
|
+
msg = decrypt(parsed, sessionKey);
|
|
130
|
+
if (!msg) {
|
|
131
|
+
console.error('[WS] Failed to decrypt message');
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
} else {
|
|
135
|
+
msg = parsed;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (msg.type === 'connected') {
|
|
139
|
+
if (msg.agent) {
|
|
140
|
+
status.value = 'Connected';
|
|
141
|
+
agentName.value = msg.agent.name;
|
|
142
|
+
hostname.value = msg.agent.hostname || '';
|
|
143
|
+
workDir.value = msg.agent.workDir;
|
|
144
|
+
const savedDir = localStorage.getItem('agentlink-workdir');
|
|
145
|
+
if (savedDir && savedDir !== msg.agent.workDir) {
|
|
146
|
+
wsSend({ type: 'change_workdir', workDir: savedDir });
|
|
147
|
+
}
|
|
148
|
+
sidebar.requestSessionList();
|
|
149
|
+
} else {
|
|
150
|
+
status.value = 'Waiting';
|
|
151
|
+
error.value = 'Agent is not connected yet.';
|
|
152
|
+
}
|
|
153
|
+
} else if (msg.type === 'agent_disconnected') {
|
|
154
|
+
status.value = 'Waiting';
|
|
155
|
+
agentName.value = '';
|
|
156
|
+
hostname.value = '';
|
|
157
|
+
error.value = 'Agent disconnected. Waiting for reconnect...';
|
|
158
|
+
isProcessing.value = false;
|
|
159
|
+
isCompacting.value = false;
|
|
160
|
+
} else if (msg.type === 'agent_reconnected') {
|
|
161
|
+
status.value = 'Connected';
|
|
162
|
+
error.value = '';
|
|
163
|
+
if (msg.agent) {
|
|
164
|
+
agentName.value = msg.agent.name;
|
|
165
|
+
hostname.value = msg.agent.hostname || '';
|
|
166
|
+
workDir.value = msg.agent.workDir;
|
|
167
|
+
}
|
|
168
|
+
sidebar.requestSessionList();
|
|
169
|
+
} else if (msg.type === 'error') {
|
|
170
|
+
status.value = 'Error';
|
|
171
|
+
error.value = msg.message;
|
|
172
|
+
isProcessing.value = false;
|
|
173
|
+
isCompacting.value = false;
|
|
174
|
+
} else if (msg.type === 'claude_output') {
|
|
175
|
+
handleClaudeOutput(msg, scheduleHighlight);
|
|
176
|
+
} else if (msg.type === 'command_output') {
|
|
177
|
+
streaming.flushReveal();
|
|
178
|
+
finalizeStreamingMsg(scheduleHighlight);
|
|
179
|
+
messages.value.push({
|
|
180
|
+
id: streaming.nextId(), role: 'user',
|
|
181
|
+
content: msg.content, isCommandOutput: true,
|
|
182
|
+
timestamp: new Date(),
|
|
183
|
+
});
|
|
184
|
+
scrollToBottom();
|
|
185
|
+
} else if (msg.type === 'context_compaction') {
|
|
186
|
+
if (msg.status === 'started') {
|
|
187
|
+
isCompacting.value = true;
|
|
188
|
+
} else if (msg.status === 'completed') {
|
|
189
|
+
isCompacting.value = false;
|
|
190
|
+
}
|
|
191
|
+
} else if (msg.type === 'turn_completed' || msg.type === 'execution_cancelled') {
|
|
192
|
+
isProcessing.value = false;
|
|
193
|
+
isCompacting.value = false;
|
|
194
|
+
streaming.flushReveal();
|
|
195
|
+
finalizeStreamingMsg(scheduleHighlight);
|
|
196
|
+
if (msg.type === 'execution_cancelled') {
|
|
197
|
+
messages.value.push({
|
|
198
|
+
id: streaming.nextId(), role: 'system',
|
|
199
|
+
content: 'Generation stopped.', timestamp: new Date(),
|
|
200
|
+
});
|
|
201
|
+
scrollToBottom();
|
|
202
|
+
}
|
|
203
|
+
} else if (msg.type === 'ask_user_question') {
|
|
204
|
+
streaming.flushReveal();
|
|
205
|
+
finalizeStreamingMsg(scheduleHighlight);
|
|
206
|
+
for (let i = messages.value.length - 1; i >= 0; i--) {
|
|
207
|
+
const m = messages.value[i];
|
|
208
|
+
if (m.role === 'tool' && m.toolName === 'AskUserQuestion') {
|
|
209
|
+
messages.value.splice(i, 1);
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
if (m.role === 'user') break;
|
|
213
|
+
}
|
|
214
|
+
const questions = msg.questions || [];
|
|
215
|
+
const selectedAnswers = {};
|
|
216
|
+
const customTexts = {};
|
|
217
|
+
for (let i = 0; i < questions.length; i++) {
|
|
218
|
+
selectedAnswers[i] = questions[i].multiSelect ? [] : null;
|
|
219
|
+
customTexts[i] = '';
|
|
220
|
+
}
|
|
221
|
+
messages.value.push({
|
|
222
|
+
id: streaming.nextId(),
|
|
223
|
+
role: 'ask-question',
|
|
224
|
+
requestId: msg.requestId,
|
|
225
|
+
questions,
|
|
226
|
+
answered: false,
|
|
227
|
+
selectedAnswers,
|
|
228
|
+
customTexts,
|
|
229
|
+
timestamp: new Date(),
|
|
230
|
+
});
|
|
231
|
+
scrollToBottom();
|
|
232
|
+
} else if (msg.type === 'sessions_list') {
|
|
233
|
+
historySessions.value = msg.sessions || [];
|
|
234
|
+
loadingSessions.value = false;
|
|
235
|
+
} else if (msg.type === 'conversation_resumed') {
|
|
236
|
+
currentClaudeSessionId.value = msg.claudeSessionId;
|
|
237
|
+
if (msg.history && Array.isArray(msg.history)) {
|
|
238
|
+
const batch = [];
|
|
239
|
+
for (const h of msg.history) {
|
|
240
|
+
if (h.role === 'user') {
|
|
241
|
+
if (isContextSummary(h.content)) {
|
|
242
|
+
batch.push({
|
|
243
|
+
id: streaming.nextId(), role: 'context-summary',
|
|
244
|
+
content: h.content, contextExpanded: false,
|
|
245
|
+
timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
|
|
246
|
+
});
|
|
247
|
+
} else {
|
|
248
|
+
batch.push({
|
|
249
|
+
id: streaming.nextId(), role: 'user',
|
|
250
|
+
content: h.content, isCommandOutput: !!h.isCommandOutput,
|
|
251
|
+
timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
} else if (h.role === 'assistant') {
|
|
255
|
+
const last = batch[batch.length - 1];
|
|
256
|
+
if (last && last.role === 'assistant' && !last.isStreaming) {
|
|
257
|
+
last.content += '\n\n' + h.content;
|
|
258
|
+
} else {
|
|
259
|
+
batch.push({
|
|
260
|
+
id: streaming.nextId(), role: 'assistant',
|
|
261
|
+
content: h.content, isStreaming: false,
|
|
262
|
+
timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
} else if (h.role === 'tool') {
|
|
266
|
+
batch.push({
|
|
267
|
+
id: streaming.nextId(), role: 'tool',
|
|
268
|
+
toolId: h.toolId || '', toolName: h.toolName || 'unknown',
|
|
269
|
+
toolInput: h.toolInput || '', hasResult: true,
|
|
270
|
+
expanded: (h.toolName === 'Edit' || h.toolName === 'TodoWrite'), timestamp: h.timestamp ? new Date(h.timestamp) : new Date(),
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
messages.value = batch;
|
|
275
|
+
}
|
|
276
|
+
loadingHistory.value = false;
|
|
277
|
+
messages.value.push({
|
|
278
|
+
id: streaming.nextId(), role: 'system',
|
|
279
|
+
content: 'Session restored. You can continue the conversation.',
|
|
280
|
+
timestamp: new Date(),
|
|
281
|
+
});
|
|
282
|
+
scrollToBottom();
|
|
283
|
+
} else if (msg.type === 'directory_listing') {
|
|
284
|
+
folderPickerLoading.value = false;
|
|
285
|
+
folderPickerEntries.value = (msg.entries || [])
|
|
286
|
+
.filter(e => e.type === 'directory')
|
|
287
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
288
|
+
if (msg.dirPath != null) folderPickerPath.value = msg.dirPath;
|
|
289
|
+
} else if (msg.type === 'workdir_changed') {
|
|
290
|
+
workDir.value = msg.workDir;
|
|
291
|
+
localStorage.setItem('agentlink-workdir', msg.workDir);
|
|
292
|
+
messages.value = [];
|
|
293
|
+
visibleLimit.value = 50;
|
|
294
|
+
streaming.setMessageIdCounter(0);
|
|
295
|
+
streaming.setStreamingMessageId(null);
|
|
296
|
+
streaming.reset();
|
|
297
|
+
currentClaudeSessionId.value = null;
|
|
298
|
+
isProcessing.value = false;
|
|
299
|
+
messages.value.push({
|
|
300
|
+
id: streaming.nextId(), role: 'system',
|
|
301
|
+
content: 'Working directory changed to: ' + msg.workDir,
|
|
302
|
+
timestamp: new Date(),
|
|
303
|
+
});
|
|
304
|
+
sidebar.requestSessionList();
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
ws.onclose = () => {
|
|
309
|
+
sessionKey = null;
|
|
310
|
+
const wasConnected = status.value === 'Connected' || status.value === 'Connecting...';
|
|
311
|
+
isProcessing.value = false;
|
|
312
|
+
isCompacting.value = false;
|
|
313
|
+
|
|
314
|
+
if (wasConnected || reconnectAttempts > 0) {
|
|
315
|
+
scheduleReconnect(scheduleHighlight);
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
ws.onerror = () => {};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function scheduleReconnect(scheduleHighlight) {
|
|
323
|
+
if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
|
324
|
+
status.value = 'Disconnected';
|
|
325
|
+
error.value = 'Unable to reconnect. Please refresh the page.';
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
const delay = Math.min(RECONNECT_BASE_DELAY * Math.pow(1.5, reconnectAttempts), RECONNECT_MAX_DELAY);
|
|
329
|
+
reconnectAttempts++;
|
|
330
|
+
status.value = 'Reconnecting...';
|
|
331
|
+
error.value = 'Connection lost. Reconnecting... (attempt ' + reconnectAttempts + ')';
|
|
332
|
+
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
333
|
+
reconnectTimer = setTimeout(() => { reconnectTimer = null; connect(scheduleHighlight); }, delay);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function closeWs() {
|
|
337
|
+
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
338
|
+
if (ws) ws.close();
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return { connect, wsSend, closeWs };
|
|
342
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// ── File attachment handling ──────────────────────────────────────────────────
|
|
2
|
+
|
|
3
|
+
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
|
4
|
+
const MAX_FILES = 5;
|
|
5
|
+
const ACCEPTED_EXTENSIONS = [
|
|
6
|
+
'.pdf', '.json', '.md', '.py', '.js', '.ts', '.tsx', '.jsx', '.css',
|
|
7
|
+
'.html', '.xml', '.yaml', '.yml', '.toml', '.sh', '.sql', '.csv',
|
|
8
|
+
'.c', '.cpp', '.h', '.hpp', '.java', '.go', '.rs', '.rb', '.php',
|
|
9
|
+
'.swift', '.kt', '.scala', '.r', '.m', '.vue', '.svelte', '.txt',
|
|
10
|
+
'.log', '.cfg', '.ini', '.env', '.gitignore', '.dockerfile',
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
function isAcceptedFile(file) {
|
|
14
|
+
if (file.type.startsWith('image/')) return true;
|
|
15
|
+
if (file.type.startsWith('text/')) return true;
|
|
16
|
+
const ext = '.' + file.name.split('.').pop().toLowerCase();
|
|
17
|
+
return ACCEPTED_EXTENSIONS.includes(ext);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function formatFileSize(bytes) {
|
|
21
|
+
if (bytes < 1024) return bytes + ' B';
|
|
22
|
+
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
|
23
|
+
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function readFileAsBase64(file) {
|
|
27
|
+
return new Promise((resolve, reject) => {
|
|
28
|
+
const reader = new FileReader();
|
|
29
|
+
reader.onload = () => {
|
|
30
|
+
const base64 = reader.result.split(',')[1];
|
|
31
|
+
resolve(base64);
|
|
32
|
+
};
|
|
33
|
+
reader.onerror = reject;
|
|
34
|
+
reader.readAsDataURL(file);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Creates file attachment handlers bound to Vue reactive state.
|
|
40
|
+
* @param {import('vue').Ref} attachments - ref([])
|
|
41
|
+
* @param {import('vue').Ref} fileInputRef - ref(null)
|
|
42
|
+
* @param {import('vue').Ref} dragOver - ref(false)
|
|
43
|
+
*/
|
|
44
|
+
export function createFileAttachments(attachments, fileInputRef, dragOver) {
|
|
45
|
+
|
|
46
|
+
async function addFiles(fileList) {
|
|
47
|
+
const currentCount = attachments.value.length;
|
|
48
|
+
const remaining = MAX_FILES - currentCount;
|
|
49
|
+
if (remaining <= 0) return;
|
|
50
|
+
|
|
51
|
+
const files = Array.from(fileList).slice(0, remaining);
|
|
52
|
+
for (const file of files) {
|
|
53
|
+
if (!isAcceptedFile(file)) continue;
|
|
54
|
+
if (file.size > MAX_FILE_SIZE) continue;
|
|
55
|
+
if (attachments.value.some(a => a.name === file.name && a.size === file.size)) continue;
|
|
56
|
+
|
|
57
|
+
const data = await readFileAsBase64(file);
|
|
58
|
+
const isImage = file.type.startsWith('image/');
|
|
59
|
+
let thumbUrl = null;
|
|
60
|
+
if (isImage) {
|
|
61
|
+
thumbUrl = URL.createObjectURL(file);
|
|
62
|
+
}
|
|
63
|
+
attachments.value.push({
|
|
64
|
+
name: file.name,
|
|
65
|
+
mimeType: file.type || 'application/octet-stream',
|
|
66
|
+
size: file.size,
|
|
67
|
+
data,
|
|
68
|
+
isImage,
|
|
69
|
+
thumbUrl,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function removeAttachment(index) {
|
|
75
|
+
const att = attachments.value[index];
|
|
76
|
+
if (att.thumbUrl) URL.revokeObjectURL(att.thumbUrl);
|
|
77
|
+
attachments.value.splice(index, 1);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function triggerFileInput() {
|
|
81
|
+
if (fileInputRef.value) fileInputRef.value.click();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function handleFileSelect(e) {
|
|
85
|
+
if (e.target.files) addFiles(e.target.files);
|
|
86
|
+
e.target.value = '';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function handleDragOver(e) {
|
|
90
|
+
e.preventDefault();
|
|
91
|
+
dragOver.value = true;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function handleDragLeave(e) {
|
|
95
|
+
e.preventDefault();
|
|
96
|
+
dragOver.value = false;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function handleDrop(e) {
|
|
100
|
+
e.preventDefault();
|
|
101
|
+
dragOver.value = false;
|
|
102
|
+
if (e.dataTransfer?.files) addFiles(e.dataTransfer.files);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function handlePaste(e) {
|
|
106
|
+
const items = e.clipboardData?.items;
|
|
107
|
+
if (!items) return;
|
|
108
|
+
const files = [];
|
|
109
|
+
for (const item of items) {
|
|
110
|
+
if (item.kind === 'file') {
|
|
111
|
+
const file = item.getAsFile();
|
|
112
|
+
if (file) files.push(file);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (files.length > 0) {
|
|
116
|
+
e.preventDefault();
|
|
117
|
+
addFiles(files);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
addFiles, removeAttachment, triggerFileInput, handleFileSelect,
|
|
123
|
+
handleDragOver, handleDragLeave, handleDrop, handlePaste,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// ── Markdown rendering, code copy, tool icons ────────────────────────────────
|
|
2
|
+
|
|
3
|
+
if (typeof marked !== 'undefined') {
|
|
4
|
+
marked.setOptions({
|
|
5
|
+
breaks: true,
|
|
6
|
+
gfm: true,
|
|
7
|
+
highlight: function(code, lang) {
|
|
8
|
+
if (typeof hljs !== 'undefined' && lang && hljs.getLanguage(lang)) {
|
|
9
|
+
try { return hljs.highlight(code, { language: lang }).value; } catch {}
|
|
10
|
+
}
|
|
11
|
+
return code;
|
|
12
|
+
},
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const _mdCache = new Map();
|
|
17
|
+
|
|
18
|
+
export function renderMarkdown(text) {
|
|
19
|
+
if (!text) return '';
|
|
20
|
+
const cached = _mdCache.get(text);
|
|
21
|
+
if (cached) return cached;
|
|
22
|
+
let html;
|
|
23
|
+
try {
|
|
24
|
+
if (typeof marked !== 'undefined') {
|
|
25
|
+
html = marked.parse(text);
|
|
26
|
+
// Add copy buttons to code blocks
|
|
27
|
+
html = html.replace(/<pre><code([^>]*)>([\s\S]*?)<\/code><\/pre>/g,
|
|
28
|
+
(match, attrs, code) => {
|
|
29
|
+
const langMatch = attrs.match(/class="language-(\w+)"/);
|
|
30
|
+
const lang = langMatch ? langMatch[1] : '';
|
|
31
|
+
return `<div class="code-block-wrapper">
|
|
32
|
+
<div class="code-block-header">
|
|
33
|
+
<span class="code-lang">${lang}</span>
|
|
34
|
+
<button class="code-copy-btn" onclick="window.__copyCodeBlock(this)" title="Copy">
|
|
35
|
+
<svg viewBox="0 0 24 24" width="14" height="14"><path fill="currentColor" d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>
|
|
36
|
+
</button>
|
|
37
|
+
</div>
|
|
38
|
+
<pre><code${attrs}>${code}</code></pre>
|
|
39
|
+
</div>`;
|
|
40
|
+
}
|
|
41
|
+
);
|
|
42
|
+
} else {
|
|
43
|
+
html = text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
44
|
+
}
|
|
45
|
+
} catch {
|
|
46
|
+
html = text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
47
|
+
}
|
|
48
|
+
if (_mdCache.size > 500) _mdCache.clear();
|
|
49
|
+
_mdCache.set(text, html);
|
|
50
|
+
return html;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Global code copy handler
|
|
54
|
+
window.__copyCodeBlock = async function(btn) {
|
|
55
|
+
const wrapper = btn.closest('.code-block-wrapper');
|
|
56
|
+
const code = wrapper?.querySelector('code');
|
|
57
|
+
if (!code) return;
|
|
58
|
+
try {
|
|
59
|
+
await navigator.clipboard.writeText(code.textContent);
|
|
60
|
+
btn.innerHTML = '<svg viewBox="0 0 24 24" width="14" height="14"><path fill="currentColor" d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>';
|
|
61
|
+
setTimeout(() => {
|
|
62
|
+
btn.innerHTML = '<svg viewBox="0 0 24 24" width="14" height="14"><path fill="currentColor" d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>';
|
|
63
|
+
}, 2000);
|
|
64
|
+
} catch {}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// Tool icons (monochrome SVG)
|
|
68
|
+
const TOOL_SVG = {
|
|
69
|
+
Read: '<svg viewBox="0 0 16 16" width="14" height="14"><path fill="currentColor" d="M1 2.5A2.5 2.5 0 0 1 3.5 0h8.75a.75.75 0 0 1 .75.75v12.5a.75.75 0 0 1-.75.75H3.5a1 1 0 0 0-1 1h9.25a.75.75 0 0 1 0 1.5H3.5A2.5 2.5 0 0 1 1 14V2.5zm3 0v7l1.5-1.25L7 9.5v-7H4z"/></svg>',
|
|
70
|
+
Edit: '<svg viewBox="0 0 16 16" width="14" height="14"><path fill="currentColor" d="M11.013 1.427a1.75 1.75 0 0 1 2.474 0l1.086 1.086a1.75 1.75 0 0 1 0 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 0 1-.927-.928l.929-3.25a1.75 1.75 0 0 1 .445-.758l8.61-8.61zM11.524 2.2l-8.61 8.61a.25.25 0 0 0-.064.108l-.57 1.996 1.996-.57a.25.25 0 0 0 .108-.064l8.61-8.61a.25.25 0 0 0 0-.354l-1.086-1.086a.25.25 0 0 0-.354 0z"/></svg>',
|
|
71
|
+
Write: '<svg viewBox="0 0 16 16" width="14" height="14"><path fill="currentColor" d="M8.75 1.75a.75.75 0 0 0-1.5 0V6H2.75a.75.75 0 0 0 0 1.5H7.25v4.25a.75.75 0 0 0 1.5 0V7.5h4.25a.75.75 0 0 0 0-1.5H8.75V1.75z"/></svg>',
|
|
72
|
+
Bash: '<svg viewBox="0 0 16 16" width="14" height="14"><path fill="currentColor" d="M0 2.75C0 1.784.784 1 1.75 1h12.5c.966 0 1.75.784 1.75 1.75v10.5A1.75 1.75 0 0 1 14.25 15H1.75A1.75 1.75 0 0 1 0 13.25V2.75zm1.75-.25a.25.25 0 0 0-.25.25v10.5c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25V2.75a.25.25 0 0 0-.25-.25H1.75zM7 11a.75.75 0 0 1 .75-.75h3.5a.75.75 0 0 1 0 1.5h-3.5A.75.75 0 0 1 7 11zm-3.22-4.53a.75.75 0 0 1 1.06 0l2 2a.75.75 0 0 1 0 1.06l-2 2a.75.75 0 0 1-1.06-1.06L5.25 9 3.78 7.53a.75.75 0 0 1 0-1.06z"/></svg>',
|
|
73
|
+
Glob: '<svg viewBox="0 0 16 16" width="14" height="14"><path fill="currentColor" d="M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.749.749 0 1 1-1.06 1.06l-3.04-3.04zM11.5 7a4.499 4.499 0 1 0-8.997 0A4.499 4.499 0 0 0 11.5 7z"/></svg>',
|
|
74
|
+
Grep: '<svg viewBox="0 0 16 16" width="14" height="14"><path fill="currentColor" d="M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.749.749 0 1 1-1.06 1.06l-3.04-3.04zM11.5 7a4.499 4.499 0 1 0-8.997 0A4.499 4.499 0 0 0 11.5 7z"/></svg>',
|
|
75
|
+
Task: '<svg viewBox="0 0 16 16" width="14" height="14"><path fill="currentColor" d="M1.75 1h12.5c.966 0 1.75.784 1.75 1.75v10.5A1.75 1.75 0 0 1 14.25 15H1.75A1.75 1.75 0 0 1 0 13.25V2.75C0 1.784.784 1 1.75 1zm0 1.5a.25.25 0 0 0-.25.25v10.5c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25V2.75a.25.25 0 0 0-.25-.25H1.75zM3.5 5h9v1.5h-9V5zm0 3h9v1.5h-9V8zm0 3h5v1.5h-5V11z"/></svg>',
|
|
76
|
+
WebFetch: '<svg viewBox="0 0 16 16" width="14" height="14"><path fill="currentColor" d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0zm3.7 5.3a.75.75 0 0 0-1.06-1.06l-5.5 5.5a.75.75 0 1 0 1.06 1.06l5.5-5.5zM8 1.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13z"/></svg>',
|
|
77
|
+
WebSearch: '<svg viewBox="0 0 16 16" width="14" height="14"><path fill="currentColor" d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0zm3.7 5.3a.75.75 0 0 0-1.06-1.06l-5.5 5.5a.75.75 0 1 0 1.06 1.06l5.5-5.5zM8 1.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13z"/></svg>',
|
|
78
|
+
TodoWrite: '<svg viewBox="0 0 16 16" width="14" height="14"><path fill="currentColor" d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 1.042-1.08L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0z"/></svg>',
|
|
79
|
+
};
|
|
80
|
+
const TOOL_SVG_DEFAULT = '<svg viewBox="0 0 16 16" width="14" height="14"><path fill="currentColor" d="M7.429 1.525a3.751 3.751 0 0 1 4.41.899l.04.045a.75.75 0 0 1-.17 1.143l-2.2 1.378a1.25 1.25 0 0 0-.473 1.58l.614 1.341a1.25 1.25 0 0 0 1.412.663l2.476-.542a.75.75 0 0 1 .848.496 3.75 3.75 0 0 1-1.468 4.155 3.751 3.751 0 0 1-4.41-.898l-.04-.046a.75.75 0 0 1 .17-1.142l2.2-1.378a1.25 1.25 0 0 0 .473-1.58l-.614-1.342a1.25 1.25 0 0 0-1.412-.662l-2.476.541a.75.75 0 0 1-.848-.496 3.75 3.75 0 0 1 1.468-4.155z"/></svg>';
|
|
81
|
+
|
|
82
|
+
export function getToolIcon(name) { return TOOL_SVG[name] || TOOL_SVG_DEFAULT; }
|