claude-simple 0.1.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 +225 -0
- package/client.js +139 -0
- package/dist/addon-fit.min.js +2 -0
- package/dist/app.0.1.0.min.js +825 -0
- package/dist/highlight.min.css +10 -0
- package/dist/highlight.min.js +1244 -0
- package/dist/index.html +16 -0
- package/dist/marked.min.js +6 -0
- package/dist/style.0.1.0.min.css +1050 -0
- package/dist/xterm.min.css +209 -0
- package/dist/xterm.min.js +2 -0
- package/gateway.js +349 -0
- package/package.json +41 -0
- package/public/addon-fit.min.js +2 -0
- package/public/agent-panel.js +1079 -0
- package/public/api.js +78 -0
- package/public/app.js +95 -0
- package/public/base.css +4 -0
- package/public/cache.js +27 -0
- package/public/chat.js +1087 -0
- package/public/files.js +476 -0
- package/public/git.js +130 -0
- package/public/index.html +61 -0
- package/public/lib.js +2 -0
- package/public/router.js +71 -0
- package/public/settings.js +90 -0
- package/public/shell/boot.js +159 -0
- package/public/shell/dashboard.js +269 -0
- package/public/shell/hub.js +192 -0
- package/public/shell/layout.js +195 -0
- package/public/shell/notes.js +323 -0
- package/public/shell/session-context.js +19 -0
- package/public/shell/session-list.js +442 -0
- package/public/shell/session-nav.js +299 -0
- package/public/shell/templates.js +342 -0
- package/public/shell/util.js +63 -0
- package/public/shell.js +589 -0
- package/public/state.js +362 -0
- package/public/style.css +1048 -0
- package/public/terminal.js +177 -0
- package/public/xterm.min.css +209 -0
- package/public/xterm.min.js +2 -0
- package/server.js +12 -0
- package/src/agents/claude.js +58 -0
- package/src/agents/codex.js +284 -0
- package/src/agents/grok.js +318 -0
- package/src/agents/meta-agent.js +1952 -0
- package/src/agents/models.js +339 -0
- package/src/agents/sessions.js +1508 -0
- package/src/app.js +1282 -0
|
@@ -0,0 +1,1079 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-panel.js — Meta Agent UI: chat, reports, VLM, auto-report.
|
|
3
|
+
* Server-side tools stream via SSE (/api/ai/chat, /api/ai/report).
|
|
4
|
+
*/
|
|
5
|
+
import { esc, $ } from './lib.js';
|
|
6
|
+
import { api, authHeaders, currentMachineId } from './api.js';
|
|
7
|
+
import { hubMode, ctx } from './state.js';
|
|
8
|
+
|
|
9
|
+
let panelOpen = false;
|
|
10
|
+
let mode = 'chat'; // chat | report | history | config
|
|
11
|
+
let streaming = false;
|
|
12
|
+
let chatSessionId = null;
|
|
13
|
+
/** Full messages loaded for current session (for resume continuity on client display) */
|
|
14
|
+
let currentSessionMeta = null; // { id, title, updatedAt, messageCount }
|
|
15
|
+
/** Local previews only: { path, previewUrl, label } — message text uses [imgN](path) */
|
|
16
|
+
let pendingImages = [];
|
|
17
|
+
let imgSeq = 0;
|
|
18
|
+
let autoTimer = null;
|
|
19
|
+
let lastAutoDay = localStorage.getItem('ai_last_auto_report') || '';
|
|
20
|
+
let historyCache = [];
|
|
21
|
+
let historySearch = '';
|
|
22
|
+
const LAST_SESSION_KEY = 'ma_last_session_id';
|
|
23
|
+
|
|
24
|
+
function mdHtml(text) {
|
|
25
|
+
if (typeof marked !== 'undefined') {
|
|
26
|
+
try {
|
|
27
|
+
return marked.parse(String(text ?? ''), { breaks: true, gfm: true });
|
|
28
|
+
} catch { /* fallthrough */ }
|
|
29
|
+
}
|
|
30
|
+
return esc(text).replace(/\n/g, '<br>');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function highlight(root) {
|
|
34
|
+
if (!root || typeof hljs === 'undefined') return;
|
|
35
|
+
root.querySelectorAll('pre code').forEach((el) => {
|
|
36
|
+
try { hljs.highlightElement(el); } catch {}
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Fetch SSE with auth (+ hub machine routing). */
|
|
41
|
+
async function streamSSE(path, body, handlers) {
|
|
42
|
+
const headers = authHeaders({ 'Content-Type': 'application/json' });
|
|
43
|
+
const res = await fetch(path, {
|
|
44
|
+
method: 'POST',
|
|
45
|
+
headers,
|
|
46
|
+
body: JSON.stringify(body || {}),
|
|
47
|
+
});
|
|
48
|
+
if (!res.ok) {
|
|
49
|
+
const err = await res.json().catch(() => ({}));
|
|
50
|
+
throw new Error(err.error || `HTTP ${res.status}`);
|
|
51
|
+
}
|
|
52
|
+
const reader = res.body.getReader();
|
|
53
|
+
const dec = new TextDecoder();
|
|
54
|
+
let buf = '';
|
|
55
|
+
let event = 'message';
|
|
56
|
+
while (true) {
|
|
57
|
+
const { done, value } = await reader.read();
|
|
58
|
+
if (done) break;
|
|
59
|
+
buf += dec.decode(value, { stream: true });
|
|
60
|
+
const parts = buf.split('\n');
|
|
61
|
+
buf = parts.pop() || '';
|
|
62
|
+
for (const line of parts) {
|
|
63
|
+
if (line.startsWith('event:')) {
|
|
64
|
+
event = line.slice(6).trim();
|
|
65
|
+
} else if (line.startsWith('data:')) {
|
|
66
|
+
const raw = line.slice(5).trim();
|
|
67
|
+
if (!raw) continue;
|
|
68
|
+
let data;
|
|
69
|
+
try { data = JSON.parse(raw); } catch { continue; }
|
|
70
|
+
handlers[event]?.(data);
|
|
71
|
+
handlers.any?.(event, data);
|
|
72
|
+
event = 'message';
|
|
73
|
+
} else if (line.trim() === '') {
|
|
74
|
+
event = 'message';
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function panelHTML() {
|
|
81
|
+
return `
|
|
82
|
+
<div id="meta-agent-panel" class="meta-agent-panel hidden" aria-hidden="true">
|
|
83
|
+
<div class="meta-agent-backdrop" data-ma-close="1"></div>
|
|
84
|
+
<div class="meta-agent-sheet">
|
|
85
|
+
<header class="meta-agent-header">
|
|
86
|
+
<div class="flex items-center gap-2 min-w-0">
|
|
87
|
+
<span class="text-base font-bold">✦ Meta Agent</span>
|
|
88
|
+
<span class="text-[10px] text-base-content/40 hidden sm:inline">报告 · 问答 · VLM · 代码</span>
|
|
89
|
+
</div>
|
|
90
|
+
<div class="flex items-center gap-1">
|
|
91
|
+
<button type="button" id="ma-btn-chat" class="btn btn-xs btn-ghost border border-primary/40 text-primary">对话</button>
|
|
92
|
+
<button type="button" id="ma-btn-history" class="btn btn-xs btn-ghost border border-base-300" title="历史对话">历史</button>
|
|
93
|
+
<button type="button" id="ma-btn-report" class="btn btn-xs btn-ghost border border-base-300">报告</button>
|
|
94
|
+
<button type="button" id="ma-btn-config" class="btn btn-xs btn-ghost border border-base-300" title="AI 配置">⚙</button>
|
|
95
|
+
<button type="button" id="ma-btn-close" class="btn btn-xs btn-ghost" data-ma-close="1">✕</button>
|
|
96
|
+
</div>
|
|
97
|
+
</header>
|
|
98
|
+
|
|
99
|
+
<div id="ma-mode-chat" class="meta-agent-body flex flex-col min-h-0 flex-1">
|
|
100
|
+
<div class="flex items-center gap-1 px-2 py-1 border-b border-base-300 flex-shrink-0 overflow-x-auto">
|
|
101
|
+
<button type="button" id="ma-new-chat" class="btn btn-ghost btn-xs">+ 新对话</button>
|
|
102
|
+
<button type="button" id="ma-open-history" class="btn btn-ghost btn-xs" title="历史列表">☰ 历史</button>
|
|
103
|
+
<span id="ma-session-title" class="text-[11px] text-base-content/55 truncate flex-1 min-w-0 px-1"></span>
|
|
104
|
+
<button type="button" id="ma-del-session" class="btn btn-ghost btn-xs text-error" title="删除当前会话">🗑</button>
|
|
105
|
+
<button type="button" id="ma-quick-today" class="btn btn-ghost btn-xs text-[10px]">今天做了啥</button>
|
|
106
|
+
<button type="button" id="ma-quick-projects" class="btn btn-ghost btn-xs text-[10px]">项目概况</button>
|
|
107
|
+
</div>
|
|
108
|
+
<div id="ma-resume-bar" class="hidden px-3 py-1.5 border-b border-primary/30 bg-primary/10 text-[11px] flex items-center gap-2 flex-shrink-0">
|
|
109
|
+
<span id="ma-resume-text" class="flex-1 min-w-0 truncate text-base-content/70"></span>
|
|
110
|
+
<button type="button" id="ma-resume-focus" class="btn btn-primary btn-xs">继续聊</button>
|
|
111
|
+
</div>
|
|
112
|
+
<div id="ma-messages" class="flex-1 overflow-y-auto p-3 flex flex-col gap-2"></div>
|
|
113
|
+
<div id="ma-attach-bar" class="hidden px-3 pt-1 flex flex-wrap gap-1"></div>
|
|
114
|
+
<div class="flex gap-2 items-end px-3 py-2 border-t border-base-300 bg-base-200 flex-shrink-0">
|
|
115
|
+
<label class="btn btn-ghost btn-sm px-2 cursor-pointer" title="添加图片 (VLM)" style="height:40px">
|
|
116
|
+
🖼
|
|
117
|
+
<input type="file" id="ma-image-input" accept="image/*" multiple class="hidden">
|
|
118
|
+
</label>
|
|
119
|
+
<textarea id="ma-input" rows="1" placeholder="继续提问… (⌘↵ 发送) · 历史会话会带着上下文"
|
|
120
|
+
class="textarea textarea-bordered flex-1 text-sm resize-none leading-relaxed"
|
|
121
|
+
style="min-height:40px;max-height:120px"></textarea>
|
|
122
|
+
<button type="button" id="ma-send" class="btn btn-primary btn-sm" style="height:40px">发送</button>
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
|
|
126
|
+
<div id="ma-mode-history" class="meta-agent-body hidden flex-col min-h-0 flex-1">
|
|
127
|
+
<div class="flex items-center gap-2 px-3 py-2 border-b border-base-300 flex-shrink-0">
|
|
128
|
+
<input id="ma-history-search" type="search" placeholder="搜索标题 / 预览…"
|
|
129
|
+
class="input input-xs input-bordered flex-1 text-xs" autocomplete="off">
|
|
130
|
+
<button type="button" id="ma-history-refresh" class="btn btn-ghost btn-xs">↻</button>
|
|
131
|
+
<button type="button" id="ma-history-new" class="btn btn-primary btn-xs">+ 新对话</button>
|
|
132
|
+
</div>
|
|
133
|
+
<div id="ma-history-list" class="flex-1 overflow-y-auto p-2 flex flex-col gap-1"></div>
|
|
134
|
+
<p class="text-[10px] text-base-content/40 px-3 py-2 border-t border-base-300 flex-shrink-0">
|
|
135
|
+
点击会话可查看完整记录;发送新消息即 resume(保留 tool / VLM 上下文)。
|
|
136
|
+
</p>
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
<div id="ma-mode-report" class="meta-agent-body hidden flex-col min-h-0 flex-1">
|
|
140
|
+
<div class="flex items-center gap-2 px-3 py-2 border-b border-base-300 flex-shrink-0 flex-wrap">
|
|
141
|
+
<select id="ma-report-days" class="select select-xs select-bordered">
|
|
142
|
+
<option value="1">今天</option>
|
|
143
|
+
<option value="3">近 3 天</option>
|
|
144
|
+
<option value="7">近 7 天</option>
|
|
145
|
+
</select>
|
|
146
|
+
<button type="button" id="ma-gen-report" class="btn btn-primary btn-xs">生成报告</button>
|
|
147
|
+
<button type="button" id="ma-load-reports" class="btn btn-ghost btn-xs">历史</button>
|
|
148
|
+
<label class="flex items-center gap-1 text-[11px] ml-auto cursor-pointer select-none">
|
|
149
|
+
<input type="checkbox" id="ma-auto-report" class="checkbox checkbox-xs">
|
|
150
|
+
自动日报
|
|
151
|
+
</label>
|
|
152
|
+
<select id="ma-auto-hour" class="select select-xs select-bordered w-16" title="自动生成时刻">
|
|
153
|
+
${Array.from({ length: 24 }, (_, h) => `<option value="${h}">${String(h).padStart(2, '0')}:00</option>`).join('')}
|
|
154
|
+
</select>
|
|
155
|
+
</div>
|
|
156
|
+
<div id="ma-report-list" class="hidden border-b border-base-300 max-h-28 overflow-y-auto px-2 py-1"></div>
|
|
157
|
+
<div id="ma-report-body" class="flex-1 overflow-y-auto p-4">
|
|
158
|
+
<div class="text-sm text-base-content/45 text-center py-10">
|
|
159
|
+
点击「生成报告」汇总会话与项目活动,或在对话里问「我今天做了什么」。
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
</div>
|
|
163
|
+
|
|
164
|
+
<div id="ma-mode-config" class="meta-agent-body hidden flex-col min-h-0 flex-1 overflow-y-auto p-4 gap-3">
|
|
165
|
+
<p class="text-xs text-base-content/50">OpenAI 兼容 API(DeepSeek / OpenAI / 本地网关等)。Key 保存在服务端 <code>.ai_config.json</code>。</p>
|
|
166
|
+
<label class="form-control">
|
|
167
|
+
<span class="label-text text-xs">API URL</span>
|
|
168
|
+
<input id="ma-cfg-url" class="input input-sm input-bordered font-mono text-xs" placeholder="https://api.deepseek.com/chat/completions">
|
|
169
|
+
</label>
|
|
170
|
+
<label class="form-control">
|
|
171
|
+
<span class="label-text text-xs">API Key</span>
|
|
172
|
+
<input id="ma-cfg-key" type="password" class="input input-sm input-bordered" placeholder="留空则不修改">
|
|
173
|
+
</label>
|
|
174
|
+
<label class="form-control">
|
|
175
|
+
<span class="label-text text-xs">主模型</span>
|
|
176
|
+
<input id="ma-cfg-model" class="input input-sm input-bordered font-mono text-xs" placeholder="deepseek-chat">
|
|
177
|
+
</label>
|
|
178
|
+
<div class="divider text-xs my-1">VLM 多模态(可留空共用上方)</div>
|
|
179
|
+
<label class="form-control">
|
|
180
|
+
<span class="label-text text-xs">VLM URL</span>
|
|
181
|
+
<input id="ma-cfg-vlm-url" class="input input-sm input-bordered font-mono text-xs" placeholder="可选">
|
|
182
|
+
</label>
|
|
183
|
+
<label class="form-control">
|
|
184
|
+
<span class="label-text text-xs">VLM Key</span>
|
|
185
|
+
<input id="ma-cfg-vlm-key" type="password" class="input input-sm input-bordered" placeholder="可选">
|
|
186
|
+
</label>
|
|
187
|
+
<label class="form-control">
|
|
188
|
+
<span class="label-text text-xs">VLM 模型</span>
|
|
189
|
+
<input id="ma-cfg-vlm-model" class="input input-sm input-bordered font-mono text-xs" placeholder="gpt-4o-mini / …">
|
|
190
|
+
</label>
|
|
191
|
+
<label class="form-control">
|
|
192
|
+
<span class="label-text text-xs">System Prompt</span>
|
|
193
|
+
<textarea id="ma-cfg-system" class="textarea textarea-bordered textarea-sm text-xs min-h-[80px]"></textarea>
|
|
194
|
+
</label>
|
|
195
|
+
<div class="flex gap-2">
|
|
196
|
+
<button type="button" id="ma-cfg-save" class="btn btn-primary btn-sm">保存</button>
|
|
197
|
+
<span id="ma-cfg-status" class="text-xs text-base-content/50 self-center"></span>
|
|
198
|
+
</div>
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
</div>`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function setMode(m) {
|
|
205
|
+
mode = m;
|
|
206
|
+
const chat = $('ma-mode-chat');
|
|
207
|
+
const history = $('ma-mode-history');
|
|
208
|
+
const report = $('ma-mode-report');
|
|
209
|
+
const config = $('ma-mode-config');
|
|
210
|
+
for (const [el, key] of [
|
|
211
|
+
[chat, 'chat'],
|
|
212
|
+
[history, 'history'],
|
|
213
|
+
[report, 'report'],
|
|
214
|
+
[config, 'config'],
|
|
215
|
+
]) {
|
|
216
|
+
if (!el) continue;
|
|
217
|
+
const on = m === key;
|
|
218
|
+
el.classList.toggle('hidden', !on);
|
|
219
|
+
el.classList.toggle('flex', on);
|
|
220
|
+
}
|
|
221
|
+
const tab = (id, on) => {
|
|
222
|
+
const b = $(id);
|
|
223
|
+
if (!b) return;
|
|
224
|
+
b.classList.toggle('border-primary/40', on);
|
|
225
|
+
b.classList.toggle('text-primary', on);
|
|
226
|
+
};
|
|
227
|
+
tab('ma-btn-chat', m === 'chat');
|
|
228
|
+
tab('ma-btn-history', m === 'history');
|
|
229
|
+
tab('ma-btn-report', m === 'report');
|
|
230
|
+
if (m === 'history') renderHistoryList();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function updateSessionChrome() {
|
|
234
|
+
const titleEl = $('ma-session-title');
|
|
235
|
+
const resumeBar = $('ma-resume-bar');
|
|
236
|
+
const resumeText = $('ma-resume-text');
|
|
237
|
+
if (!chatSessionId) {
|
|
238
|
+
if (titleEl) titleEl.textContent = '新对话';
|
|
239
|
+
resumeBar?.classList.add('hidden');
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const t = currentSessionMeta?.title || chatSessionId.slice(0, 8);
|
|
243
|
+
const when = fmtSessionWhen(currentSessionMeta?.updatedAt);
|
|
244
|
+
if (titleEl) titleEl.textContent = when ? `${t} · ${when}` : t;
|
|
245
|
+
if (resumeBar && resumeText) {
|
|
246
|
+
resumeBar.classList.remove('hidden');
|
|
247
|
+
const n = currentSessionMeta?.messageCount;
|
|
248
|
+
resumeText.textContent = n
|
|
249
|
+
? `已加载历史 · ${n} 条消息 · 发送即可继续`
|
|
250
|
+
: '已加载历史会话 · 发送即可继续';
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function openMetaAgent(initialMode = 'chat') {
|
|
255
|
+
const panel = $('meta-agent-panel');
|
|
256
|
+
if (!panel) return;
|
|
257
|
+
panel.classList.remove('hidden');
|
|
258
|
+
panel.setAttribute('aria-hidden', 'false');
|
|
259
|
+
panelOpen = true;
|
|
260
|
+
setMode(initialMode);
|
|
261
|
+
if (initialMode === 'chat') {
|
|
262
|
+
refreshHistoryCache();
|
|
263
|
+
// restore last session if still empty view
|
|
264
|
+
const last = localStorage.getItem(LAST_SESSION_KEY);
|
|
265
|
+
if (!chatSessionId && last) {
|
|
266
|
+
loadSession(last, { silent: true }).catch(() => {
|
|
267
|
+
localStorage.removeItem(LAST_SESSION_KEY);
|
|
268
|
+
});
|
|
269
|
+
} else {
|
|
270
|
+
updateSessionChrome();
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (initialMode === 'history') refreshHistoryCache().then(() => renderHistoryList());
|
|
274
|
+
if (initialMode === 'report') loadTodayReportQuiet();
|
|
275
|
+
if (initialMode === 'config') loadConfigForm();
|
|
276
|
+
setTimeout(() => {
|
|
277
|
+
if (mode === 'chat') $('ma-input')?.focus();
|
|
278
|
+
if (mode === 'history') $('ma-history-search')?.focus();
|
|
279
|
+
}, 50);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export function closeMetaAgent() {
|
|
283
|
+
const panel = $('meta-agent-panel');
|
|
284
|
+
if (!panel) return;
|
|
285
|
+
panel.classList.add('hidden');
|
|
286
|
+
panel.setAttribute('aria-hidden', 'true');
|
|
287
|
+
panelOpen = false;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export function toggleMetaAgent() {
|
|
291
|
+
if (panelOpen) closeMetaAgent();
|
|
292
|
+
else openMetaAgent(mode === 'config' ? 'chat' : mode);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function appendMsg(kind, text, opts = {}) {
|
|
296
|
+
const box = $('ma-messages');
|
|
297
|
+
if (!box) return null;
|
|
298
|
+
const el = document.createElement('div');
|
|
299
|
+
el.className = `ma-msg ma-msg-${kind}`;
|
|
300
|
+
if (kind === 'user') {
|
|
301
|
+
el.innerHTML = `<div class="ma-bubble user">${esc(text)}</div>`;
|
|
302
|
+
} else if (kind === 'assistant') {
|
|
303
|
+
el.innerHTML = `<div class="ma-bubble assistant md">${mdHtml(text)}</div>`;
|
|
304
|
+
highlight(el);
|
|
305
|
+
} else if (kind === 'error') {
|
|
306
|
+
el.innerHTML = `<div class="ma-bubble error">${esc(text)}</div>`;
|
|
307
|
+
} else if (kind === 'system') {
|
|
308
|
+
el.innerHTML = `<div class="ma-sys">${esc(text)}</div>`;
|
|
309
|
+
} else if (kind === 'tool') {
|
|
310
|
+
el.innerHTML = `<div class="ma-tool" data-tool-id="${esc(opts.id || '')}">
|
|
311
|
+
<span class="ma-tool-name">${esc(opts.name || 'tool')}</span>
|
|
312
|
+
<span class="ma-tool-phase">${esc(opts.phase || '')}</span>
|
|
313
|
+
<pre class="ma-tool-args">${esc(JSON.stringify(opts.args || {}, null, 0).slice(0, 200))}</pre>
|
|
314
|
+
</div>`;
|
|
315
|
+
}
|
|
316
|
+
box.appendChild(el);
|
|
317
|
+
box.scrollTop = box.scrollHeight;
|
|
318
|
+
return el;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function updateAssistant(el, text) {
|
|
322
|
+
const body = el?.querySelector('.ma-bubble');
|
|
323
|
+
if (!body) return;
|
|
324
|
+
body.innerHTML = mdHtml(text);
|
|
325
|
+
highlight(body);
|
|
326
|
+
$('ma-messages').scrollTop = $('ma-messages').scrollHeight;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function updateToolChip(id, phase, result) {
|
|
330
|
+
const box = $('ma-messages');
|
|
331
|
+
if (!box) return;
|
|
332
|
+
const el = box.querySelector(`[data-tool-id="${CSS.escape(id)}"]`);
|
|
333
|
+
if (!el) return;
|
|
334
|
+
el.classList.toggle('done', phase === 'done');
|
|
335
|
+
el.classList.toggle('error', phase === 'error');
|
|
336
|
+
const ph = el.querySelector('.ma-tool-phase');
|
|
337
|
+
if (ph) ph.textContent = phase;
|
|
338
|
+
if (result && phase !== 'start') {
|
|
339
|
+
let pre = el.querySelector('.ma-tool-result');
|
|
340
|
+
if (!pre) {
|
|
341
|
+
pre = document.createElement('pre');
|
|
342
|
+
pre.className = 'ma-tool-result';
|
|
343
|
+
el.appendChild(pre);
|
|
344
|
+
}
|
|
345
|
+
pre.textContent = JSON.stringify(result, null, 0).slice(0, 500);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function nextImgLabel() {
|
|
350
|
+
imgSeq += 1;
|
|
351
|
+
return `img${imgSeq}`;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function insertAtCursor(ta, text) {
|
|
355
|
+
if (!ta) return;
|
|
356
|
+
const s = ta.selectionStart ?? ta.value.length;
|
|
357
|
+
const e = ta.selectionEnd ?? ta.value.length;
|
|
358
|
+
ta.value = ta.value.slice(0, s) + text + ta.value.slice(e);
|
|
359
|
+
ta.selectionStart = ta.selectionEnd = s + text.length;
|
|
360
|
+
ta.dispatchEvent(new Event('input'));
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Upload blob → server path; insert [imgN](path) into input. */
|
|
364
|
+
async function uploadAndInsertImage(blob, filename) {
|
|
365
|
+
const headers = authHeaders({ 'x-filename': filename || `paste-${Date.now()}.png` });
|
|
366
|
+
// raw body upload — do not set Content-Type (browser sets multipart/stream)
|
|
367
|
+
delete headers['Content-Type'];
|
|
368
|
+
const res = await fetch('/api/upload-image', {
|
|
369
|
+
method: 'POST',
|
|
370
|
+
headers,
|
|
371
|
+
body: blob,
|
|
372
|
+
});
|
|
373
|
+
if (!res.ok) {
|
|
374
|
+
const err = await res.json().catch(() => ({}));
|
|
375
|
+
throw new Error(err.error || `Upload failed: ${res.status}`);
|
|
376
|
+
}
|
|
377
|
+
const { path: filePath } = await res.json();
|
|
378
|
+
if (!filePath) throw new Error('upload returned no path');
|
|
379
|
+
const label = nextImgLabel();
|
|
380
|
+
const ref = `[${label}](${filePath})`;
|
|
381
|
+
const ta = $('ma-input');
|
|
382
|
+
insertAtCursor(ta, (ta?.value && !ta.value.endsWith('\n') && ta.value.length ? '\n' : '') + ref + '\n');
|
|
383
|
+
let previewUrl = '';
|
|
384
|
+
try {
|
|
385
|
+
previewUrl = URL.createObjectURL(blob);
|
|
386
|
+
} catch { /* ignore */ }
|
|
387
|
+
pendingImages.push({ path: filePath, previewUrl, label, ref });
|
|
388
|
+
renderAttachBar();
|
|
389
|
+
return { path: filePath, ref, label };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function renderAttachBar() {
|
|
393
|
+
const bar = $('ma-attach-bar');
|
|
394
|
+
if (!bar) return;
|
|
395
|
+
if (!pendingImages.length) {
|
|
396
|
+
bar.classList.add('hidden');
|
|
397
|
+
bar.innerHTML = '';
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
bar.classList.remove('hidden');
|
|
401
|
+
bar.innerHTML = pendingImages.map((item, i) => `
|
|
402
|
+
<div class="ma-thumb relative" title="${esc(item.ref || item.path)}">
|
|
403
|
+
${item.previewUrl ? `<img src="${item.previewUrl}" alt="${esc(item.label || 'img')}">` : `<span class="ma-thumb-path">${esc(item.label || 'img')}</span>`}
|
|
404
|
+
<span class="ma-thumb-label">${esc(item.label || '')}</span>
|
|
405
|
+
<button type="button" class="ma-thumb-x" data-rm-img="${i}">✕</button>
|
|
406
|
+
</div>`).join('');
|
|
407
|
+
bar.querySelectorAll('[data-rm-img]').forEach((btn) => {
|
|
408
|
+
btn.addEventListener('click', () => {
|
|
409
|
+
const i = parseInt(btn.dataset.rmImg, 10);
|
|
410
|
+
const item = pendingImages[i];
|
|
411
|
+
if (item?.previewUrl) {
|
|
412
|
+
try { URL.revokeObjectURL(item.previewUrl); } catch {}
|
|
413
|
+
}
|
|
414
|
+
// remove matching ref from input if still present
|
|
415
|
+
const ta = $('ma-input');
|
|
416
|
+
if (ta && item?.ref) {
|
|
417
|
+
ta.value = ta.value.split(item.ref).join('').replace(/\n{3,}/g, '\n\n');
|
|
418
|
+
ta.dispatchEvent(new Event('input'));
|
|
419
|
+
}
|
|
420
|
+
pendingImages.splice(i, 1);
|
|
421
|
+
renderAttachBar();
|
|
422
|
+
});
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function fmtSessionWhen(ts) {
|
|
427
|
+
if (!ts) return '';
|
|
428
|
+
const d = new Date(ts);
|
|
429
|
+
if (Number.isNaN(d.getTime())) return '';
|
|
430
|
+
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
|
431
|
+
const dd = String(d.getDate()).padStart(2, '0');
|
|
432
|
+
const hh = String(d.getHours()).padStart(2, '0');
|
|
433
|
+
const mi = String(d.getMinutes()).padStart(2, '0');
|
|
434
|
+
return `${mm}-${dd} ${hh}:${mi}`;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
async function refreshHistoryCache() {
|
|
438
|
+
try {
|
|
439
|
+
const data = await api('GET', '/api/ai/sessions');
|
|
440
|
+
historyCache = data.sessions || [];
|
|
441
|
+
} catch {
|
|
442
|
+
historyCache = [];
|
|
443
|
+
}
|
|
444
|
+
return historyCache;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function renderHistoryList() {
|
|
448
|
+
const list = $('ma-history-list');
|
|
449
|
+
if (!list) return;
|
|
450
|
+
const q = (historySearch || '').trim().toLowerCase();
|
|
451
|
+
let rows = historyCache;
|
|
452
|
+
if (q) {
|
|
453
|
+
rows = rows.filter((s) => {
|
|
454
|
+
const hay = `${s.title || ''} ${s.preview || ''} ${s.id || ''}`.toLowerCase();
|
|
455
|
+
return hay.includes(q);
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
if (!rows.length) {
|
|
459
|
+
list.innerHTML = `<div class="text-center text-xs text-base-content/40 py-10">
|
|
460
|
+
${q ? '没有匹配的会话' : '还没有历史对话。发一条消息后会出现在这里。'}
|
|
461
|
+
</div>`;
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
list.innerHTML = rows.map((s) => {
|
|
465
|
+
const active = s.id === chatSessionId;
|
|
466
|
+
const when = fmtSessionWhen(s.updatedAt) || '';
|
|
467
|
+
const n = s.messageCount || 0;
|
|
468
|
+
return `
|
|
469
|
+
<div class="ma-hist-item ${active ? 'active' : ''}" data-hist-id="${esc(s.id)}">
|
|
470
|
+
<div class="flex items-start gap-2">
|
|
471
|
+
<button type="button" class="ma-hist-open flex-1 min-w-0 text-left" data-hist-open="${esc(s.id)}">
|
|
472
|
+
<div class="font-medium text-xs truncate">${esc(s.title || s.id.slice(0, 8))}</div>
|
|
473
|
+
<div class="text-[10px] text-base-content/45 mt-0.5 line-clamp-2">${esc(s.preview || '(无预览)')}</div>
|
|
474
|
+
<div class="text-[10px] text-base-content/35 mt-1">${esc(when)}${n ? ` · ${n} 条` : ''}${active ? ' · 当前' : ''}</div>
|
|
475
|
+
</button>
|
|
476
|
+
<div class="flex flex-col gap-0.5 flex-shrink-0">
|
|
477
|
+
<button type="button" class="btn btn-ghost btn-xs px-1" data-hist-resume="${esc(s.id)}" title="打开并继续">↵</button>
|
|
478
|
+
<button type="button" class="btn btn-ghost btn-xs px-1 text-error" data-hist-del="${esc(s.id)}" title="删除">✕</button>
|
|
479
|
+
</div>
|
|
480
|
+
</div>
|
|
481
|
+
</div>`;
|
|
482
|
+
}).join('');
|
|
483
|
+
|
|
484
|
+
list.querySelectorAll('[data-hist-open]').forEach((btn) => {
|
|
485
|
+
btn.addEventListener('click', async () => {
|
|
486
|
+
await loadSession(btn.dataset.histOpen);
|
|
487
|
+
setMode('chat');
|
|
488
|
+
$('ma-input')?.focus();
|
|
489
|
+
});
|
|
490
|
+
});
|
|
491
|
+
list.querySelectorAll('[data-hist-resume]').forEach((btn) => {
|
|
492
|
+
btn.addEventListener('click', async (e) => {
|
|
493
|
+
e.stopPropagation();
|
|
494
|
+
await loadSession(btn.dataset.histResume);
|
|
495
|
+
setMode('chat');
|
|
496
|
+
$('ma-input')?.focus();
|
|
497
|
+
});
|
|
498
|
+
});
|
|
499
|
+
list.querySelectorAll('[data-hist-del]').forEach((btn) => {
|
|
500
|
+
btn.addEventListener('click', async (e) => {
|
|
501
|
+
e.stopPropagation();
|
|
502
|
+
const id = btn.dataset.histDel;
|
|
503
|
+
if (!confirm('删除该历史会话?不可恢复。')) return;
|
|
504
|
+
try {
|
|
505
|
+
await api('DELETE', `/api/ai/sessions/${id}`);
|
|
506
|
+
if (chatSessionId === id) {
|
|
507
|
+
chatSessionId = null;
|
|
508
|
+
currentSessionMeta = null;
|
|
509
|
+
localStorage.removeItem(LAST_SESSION_KEY);
|
|
510
|
+
loadSession(null);
|
|
511
|
+
}
|
|
512
|
+
await refreshHistoryCache();
|
|
513
|
+
renderHistoryList();
|
|
514
|
+
} catch (err) {
|
|
515
|
+
alert(err.message);
|
|
516
|
+
}
|
|
517
|
+
});
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function userContentToText(content) {
|
|
522
|
+
if (typeof content === 'string') return content;
|
|
523
|
+
if (Array.isArray(content)) {
|
|
524
|
+
return content
|
|
525
|
+
.map((p) => {
|
|
526
|
+
if (typeof p === 'string') return p;
|
|
527
|
+
if (p?.type === 'text') return p.text || '';
|
|
528
|
+
if (p?.type === 'image_url') return '[图片]';
|
|
529
|
+
return '';
|
|
530
|
+
})
|
|
531
|
+
.filter(Boolean)
|
|
532
|
+
.join('\n') || '[…]';
|
|
533
|
+
}
|
|
534
|
+
return '[…]';
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function renderHistoryMessages(messages) {
|
|
538
|
+
const box = $('ma-messages');
|
|
539
|
+
if (!box) return;
|
|
540
|
+
box.innerHTML = '';
|
|
541
|
+
if (!messages?.length) {
|
|
542
|
+
appendMsg('system', '此会话暂无消息。');
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
for (const m of messages) {
|
|
546
|
+
if (m.role === 'user') {
|
|
547
|
+
appendMsg('user', userContentToText(m.content));
|
|
548
|
+
continue;
|
|
549
|
+
}
|
|
550
|
+
if (m.role === 'assistant') {
|
|
551
|
+
if (m.tool_calls?.length) {
|
|
552
|
+
for (const tc of m.tool_calls) {
|
|
553
|
+
const name = tc.function?.name || tc.name || 'tool';
|
|
554
|
+
let args = {};
|
|
555
|
+
try {
|
|
556
|
+
args = JSON.parse(tc.function?.arguments || tc.arguments || '{}');
|
|
557
|
+
} catch { /* empty */ }
|
|
558
|
+
appendMsg('tool', '', {
|
|
559
|
+
name,
|
|
560
|
+
args,
|
|
561
|
+
phase: 'history',
|
|
562
|
+
id: tc.id || name,
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
if (m.content) {
|
|
567
|
+
appendMsg('assistant', typeof m.content === 'string' ? m.content : userContentToText(m.content));
|
|
568
|
+
}
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
if (m.role === 'tool') {
|
|
572
|
+
let result = m.content;
|
|
573
|
+
try {
|
|
574
|
+
result = JSON.parse(m.content);
|
|
575
|
+
} catch { /* keep string */ }
|
|
576
|
+
// attach to last matching tool chip if possible
|
|
577
|
+
const id = m.tool_call_id || '';
|
|
578
|
+
if (id) updateToolChip(id, 'done', result);
|
|
579
|
+
else {
|
|
580
|
+
appendMsg('tool', '', {
|
|
581
|
+
name: 'tool_result',
|
|
582
|
+
args: {},
|
|
583
|
+
phase: 'done',
|
|
584
|
+
id: `tr_${Math.random().toString(36).slice(2, 8)}`,
|
|
585
|
+
});
|
|
586
|
+
// best-effort show result on last tool
|
|
587
|
+
const chips = box.querySelectorAll('.ma-tool');
|
|
588
|
+
const last = chips[chips.length - 1];
|
|
589
|
+
if (last) {
|
|
590
|
+
last.classList.add('done');
|
|
591
|
+
let pre = last.querySelector('.ma-tool-result');
|
|
592
|
+
if (!pre) {
|
|
593
|
+
pre = document.createElement('pre');
|
|
594
|
+
pre.className = 'ma-tool-result';
|
|
595
|
+
last.appendChild(pre);
|
|
596
|
+
}
|
|
597
|
+
pre.textContent = typeof result === 'string'
|
|
598
|
+
? result.slice(0, 500)
|
|
599
|
+
: JSON.stringify(result, null, 0).slice(0, 500);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
box.scrollTop = box.scrollHeight;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
async function deleteCurrentSession() {
|
|
608
|
+
if (!chatSessionId) {
|
|
609
|
+
await loadSession(null);
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
if (!confirm('删除当前 Meta Agent 会话?此操作不可恢复。')) return;
|
|
613
|
+
try {
|
|
614
|
+
await api('DELETE', `/api/ai/sessions/${chatSessionId}`);
|
|
615
|
+
chatSessionId = null;
|
|
616
|
+
currentSessionMeta = null;
|
|
617
|
+
localStorage.removeItem(LAST_SESSION_KEY);
|
|
618
|
+
await refreshHistoryCache();
|
|
619
|
+
await loadSession(null);
|
|
620
|
+
if (mode === 'history') renderHistoryList();
|
|
621
|
+
} catch (e) {
|
|
622
|
+
appendMsg('error', e.message);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Load session for viewing + resume.
|
|
628
|
+
* Server keeps full tool history; next send uses same sessionId.
|
|
629
|
+
*/
|
|
630
|
+
async function loadSession(id, opts = {}) {
|
|
631
|
+
if (!id) {
|
|
632
|
+
chatSessionId = null;
|
|
633
|
+
currentSessionMeta = null;
|
|
634
|
+
localStorage.removeItem(LAST_SESSION_KEY);
|
|
635
|
+
const box = $('ma-messages');
|
|
636
|
+
if (box) box.innerHTML = '';
|
|
637
|
+
appendMsg('system', '新对话 — 贴图 → [imgN](路径);需要看图时调用 VLM。历史在「历史」页,可随时 resume。');
|
|
638
|
+
updateSessionChrome();
|
|
639
|
+
return null;
|
|
640
|
+
}
|
|
641
|
+
try {
|
|
642
|
+
const s = await api('GET', `/api/ai/sessions/${id}`);
|
|
643
|
+
chatSessionId = s.id;
|
|
644
|
+
currentSessionMeta = {
|
|
645
|
+
id: s.id,
|
|
646
|
+
title: s.title,
|
|
647
|
+
updatedAt: s.updatedAt,
|
|
648
|
+
messageCount: (s.messages || []).length,
|
|
649
|
+
};
|
|
650
|
+
localStorage.setItem(LAST_SESSION_KEY, s.id);
|
|
651
|
+
renderHistoryMessages(s.messages || []);
|
|
652
|
+
if (!opts.silent) {
|
|
653
|
+
appendMsg('system', '↑ 历史已加载。在下方继续输入即可 resume(服务端保留完整 tool / VLM 上下文)。');
|
|
654
|
+
}
|
|
655
|
+
updateSessionChrome();
|
|
656
|
+
// refresh list active state if visible
|
|
657
|
+
if (mode === 'history') renderHistoryList();
|
|
658
|
+
return s;
|
|
659
|
+
} catch (e) {
|
|
660
|
+
if (!opts.silent) appendMsg('error', e.message);
|
|
661
|
+
throw e;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
async function sendChat() {
|
|
666
|
+
if (streaming) return;
|
|
667
|
+
const input = $('ma-input');
|
|
668
|
+
const text = (input?.value || '').trim();
|
|
669
|
+
if (!text) return;
|
|
670
|
+
|
|
671
|
+
streaming = true;
|
|
672
|
+
$('ma-send').disabled = true;
|
|
673
|
+
appendMsg('user', text);
|
|
674
|
+
input.value = '';
|
|
675
|
+
input.style.height = '';
|
|
676
|
+
// clear local previews (paths already in message text)
|
|
677
|
+
for (const item of pendingImages) {
|
|
678
|
+
if (item.previewUrl) {
|
|
679
|
+
try { URL.revokeObjectURL(item.previewUrl); } catch {}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
pendingImages = [];
|
|
683
|
+
renderAttachBar();
|
|
684
|
+
|
|
685
|
+
let asstEl = null;
|
|
686
|
+
let content = '';
|
|
687
|
+
|
|
688
|
+
try {
|
|
689
|
+
await streamSSE('/api/ai/chat', {
|
|
690
|
+
message: text,
|
|
691
|
+
sessionId: chatSessionId,
|
|
692
|
+
}, {
|
|
693
|
+
session: (d) => {
|
|
694
|
+
chatSessionId = d.id;
|
|
695
|
+
currentSessionMeta = {
|
|
696
|
+
...(currentSessionMeta || {}),
|
|
697
|
+
id: d.id,
|
|
698
|
+
title: d.title || currentSessionMeta?.title || text.slice(0, 40),
|
|
699
|
+
updatedAt: Date.now(),
|
|
700
|
+
};
|
|
701
|
+
localStorage.setItem(LAST_SESSION_KEY, d.id);
|
|
702
|
+
updateSessionChrome();
|
|
703
|
+
},
|
|
704
|
+
content: (d) => {
|
|
705
|
+
content = d.content || '';
|
|
706
|
+
if (!asstEl) asstEl = appendMsg('assistant', '');
|
|
707
|
+
updateAssistant(asstEl, content);
|
|
708
|
+
},
|
|
709
|
+
tool: (d) => {
|
|
710
|
+
if (d.phase === 'start') {
|
|
711
|
+
appendMsg('tool', '', { name: d.name, args: d.args, phase: 'start', id: d.id });
|
|
712
|
+
} else {
|
|
713
|
+
updateToolChip(d.id, d.phase, d.result);
|
|
714
|
+
}
|
|
715
|
+
},
|
|
716
|
+
done: (d) => {
|
|
717
|
+
if (d.sessionId) {
|
|
718
|
+
chatSessionId = d.sessionId;
|
|
719
|
+
localStorage.setItem(LAST_SESSION_KEY, d.sessionId);
|
|
720
|
+
}
|
|
721
|
+
if (d.title || d.sessionId) {
|
|
722
|
+
currentSessionMeta = {
|
|
723
|
+
id: chatSessionId,
|
|
724
|
+
title: d.title || currentSessionMeta?.title || '对话',
|
|
725
|
+
updatedAt: Date.now(),
|
|
726
|
+
messageCount: (currentSessionMeta?.messageCount || 0) + 2,
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
if (d.content && asstEl) updateAssistant(asstEl, d.content);
|
|
730
|
+
updateSessionChrome();
|
|
731
|
+
refreshHistoryCache();
|
|
732
|
+
},
|
|
733
|
+
error: (d) => {
|
|
734
|
+
appendMsg('error', d.message || 'error');
|
|
735
|
+
},
|
|
736
|
+
});
|
|
737
|
+
} catch (e) {
|
|
738
|
+
appendMsg('error', e.message);
|
|
739
|
+
} finally {
|
|
740
|
+
streaming = false;
|
|
741
|
+
$('ma-send').disabled = false;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
async function generateReport() {
|
|
746
|
+
if (streaming) return;
|
|
747
|
+
streaming = true;
|
|
748
|
+
const days = parseInt($('ma-report-days')?.value || '1', 10);
|
|
749
|
+
const body = $('ma-report-body');
|
|
750
|
+
body.innerHTML = `<div class="text-sm text-base-content/50 py-6 text-center">正在汇总近 ${days} 天活动…</div>`;
|
|
751
|
+
$('ma-gen-report').disabled = true;
|
|
752
|
+
let content = '';
|
|
753
|
+
const tools = document.createElement('div');
|
|
754
|
+
tools.className = 'ma-report-tools flex flex-col gap-1 mb-3';
|
|
755
|
+
|
|
756
|
+
try {
|
|
757
|
+
await streamSSE('/api/ai/report', { days }, {
|
|
758
|
+
content: (d) => {
|
|
759
|
+
content = d.content || '';
|
|
760
|
+
body.innerHTML = '';
|
|
761
|
+
body.appendChild(tools);
|
|
762
|
+
const md = document.createElement('div');
|
|
763
|
+
md.className = 'md ma-report-md';
|
|
764
|
+
md.innerHTML = mdHtml(content);
|
|
765
|
+
highlight(md);
|
|
766
|
+
body.appendChild(md);
|
|
767
|
+
},
|
|
768
|
+
tool: (d) => {
|
|
769
|
+
if (d.phase === 'start') {
|
|
770
|
+
const chip = document.createElement('div');
|
|
771
|
+
chip.className = 'ma-tool';
|
|
772
|
+
chip.dataset.toolId = d.id || d.name;
|
|
773
|
+
chip.innerHTML = `<span class="ma-tool-name">${esc(d.name)}</span> <span class="ma-tool-phase">start</span>`;
|
|
774
|
+
tools.appendChild(chip);
|
|
775
|
+
} else {
|
|
776
|
+
const chip = tools.querySelector(`[data-tool-id="${CSS.escape(d.id || d.name)}"]`);
|
|
777
|
+
if (chip) {
|
|
778
|
+
chip.classList.add(d.phase === 'error' ? 'error' : 'done');
|
|
779
|
+
chip.querySelector('.ma-tool-phase').textContent = d.phase;
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
},
|
|
783
|
+
done: (d) => {
|
|
784
|
+
if (d.content) {
|
|
785
|
+
body.innerHTML = '';
|
|
786
|
+
body.appendChild(tools);
|
|
787
|
+
const md = document.createElement('div');
|
|
788
|
+
md.className = 'md ma-report-md';
|
|
789
|
+
md.innerHTML = mdHtml(d.content);
|
|
790
|
+
highlight(md);
|
|
791
|
+
body.appendChild(md);
|
|
792
|
+
}
|
|
793
|
+
},
|
|
794
|
+
error: (d) => {
|
|
795
|
+
body.innerHTML = `<div class="alert alert-error text-sm">${esc(d.message)}</div>`;
|
|
796
|
+
},
|
|
797
|
+
});
|
|
798
|
+
} catch (e) {
|
|
799
|
+
body.innerHTML = `<div class="alert alert-error text-sm">${esc(e.message)}</div>`;
|
|
800
|
+
} finally {
|
|
801
|
+
streaming = false;
|
|
802
|
+
$('ma-gen-report').disabled = false;
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
async function loadTodayReportQuiet() {
|
|
807
|
+
try {
|
|
808
|
+
const today = new Date();
|
|
809
|
+
const day = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
|
810
|
+
const r = await api('GET', `/api/ai/reports/${day}`);
|
|
811
|
+
if (r?.markdown) {
|
|
812
|
+
const body = $('ma-report-body');
|
|
813
|
+
body.innerHTML = `
|
|
814
|
+
<div class="text-[10px] text-base-content/40 mb-2">${esc(r.title || day)} · ${esc(r.generatedAt || '')}</div>
|
|
815
|
+
<div class="md ma-report-md">${mdHtml(r.markdown)}</div>`;
|
|
816
|
+
highlight(body);
|
|
817
|
+
}
|
|
818
|
+
} catch { /* none */ }
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
async function loadReportList() {
|
|
822
|
+
const list = $('ma-report-list');
|
|
823
|
+
try {
|
|
824
|
+
const data = await api('GET', '/api/ai/reports');
|
|
825
|
+
list.classList.remove('hidden');
|
|
826
|
+
const rows = data.reports || [];
|
|
827
|
+
if (!rows.length) {
|
|
828
|
+
list.innerHTML = '<div class="text-xs text-base-content/40 px-2 py-1">暂无历史报告</div>';
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
list.innerHTML = rows.map((r) => `
|
|
832
|
+
<button type="button" class="ma-report-item w-full text-left px-2 py-1 rounded hover:bg-base-200 text-xs"
|
|
833
|
+
data-day="${esc(r.day)}">
|
|
834
|
+
<span class="font-semibold">${esc(r.day)}</span>
|
|
835
|
+
<span class="text-base-content/45 ml-1">${esc(r.title || '')}</span>
|
|
836
|
+
</button>`).join('');
|
|
837
|
+
list.querySelectorAll('[data-day]').forEach((btn) => {
|
|
838
|
+
btn.addEventListener('click', async () => {
|
|
839
|
+
try {
|
|
840
|
+
const r = await api('GET', `/api/ai/reports/${btn.dataset.day}`);
|
|
841
|
+
const body = $('ma-report-body');
|
|
842
|
+
body.innerHTML = `
|
|
843
|
+
<div class="text-[10px] text-base-content/40 mb-2">${esc(r.title || r.day)} · ${esc(r.generatedAt || '')}</div>
|
|
844
|
+
<div class="md ma-report-md">${mdHtml(r.markdown)}</div>`;
|
|
845
|
+
highlight(body);
|
|
846
|
+
} catch (e) {
|
|
847
|
+
$('ma-report-body').innerHTML = `<div class="alert alert-error text-sm">${esc(e.message)}</div>`;
|
|
848
|
+
}
|
|
849
|
+
});
|
|
850
|
+
});
|
|
851
|
+
} catch (e) {
|
|
852
|
+
list.classList.remove('hidden');
|
|
853
|
+
list.innerHTML = `<div class="text-xs text-error px-2">${esc(e.message)}</div>`;
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
async function loadConfigForm() {
|
|
858
|
+
try {
|
|
859
|
+
const c = await api('GET', '/api/ai/config');
|
|
860
|
+
$('ma-cfg-url').value = c.url || '';
|
|
861
|
+
$('ma-cfg-model').value = c.model || '';
|
|
862
|
+
$('ma-cfg-key').placeholder = c.hasKey ? '已配置 · 留空不修改' : 'API Key';
|
|
863
|
+
$('ma-cfg-key').value = '';
|
|
864
|
+
$('ma-cfg-vlm-url').value = c.vlm_url || '';
|
|
865
|
+
$('ma-cfg-vlm-model').value = c.vlm_model || '';
|
|
866
|
+
$('ma-cfg-vlm-key').value = '';
|
|
867
|
+
$('ma-cfg-vlm-key').placeholder = c.hasVlmKey ? '已配置 · 留空不修改' : '可选';
|
|
868
|
+
$('ma-cfg-system').value = c.system || '';
|
|
869
|
+
if ($('ma-auto-report')) $('ma-auto-report').checked = !!c.auto_report;
|
|
870
|
+
if ($('ma-auto-hour')) $('ma-auto-hour').value = String(c.auto_report_hour ?? 18);
|
|
871
|
+
scheduleAutoReport(c);
|
|
872
|
+
} catch (e) {
|
|
873
|
+
$('ma-cfg-status').textContent = e.message;
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
async function saveConfig() {
|
|
878
|
+
const status = $('ma-cfg-status');
|
|
879
|
+
status.textContent = '保存中…';
|
|
880
|
+
try {
|
|
881
|
+
const patch = {
|
|
882
|
+
url: $('ma-cfg-url').value.trim(),
|
|
883
|
+
model: $('ma-cfg-model').value.trim(),
|
|
884
|
+
vlm_url: $('ma-cfg-vlm-url').value.trim(),
|
|
885
|
+
vlm_model: $('ma-cfg-vlm-model').value.trim(),
|
|
886
|
+
system: $('ma-cfg-system').value,
|
|
887
|
+
auto_report: !!$('ma-auto-report')?.checked,
|
|
888
|
+
auto_report_hour: parseInt($('ma-auto-hour')?.value || '18', 10),
|
|
889
|
+
};
|
|
890
|
+
const key = $('ma-cfg-key').value.trim();
|
|
891
|
+
const vlmKey = $('ma-cfg-vlm-key').value.trim();
|
|
892
|
+
if (key) patch.key = key;
|
|
893
|
+
if (vlmKey) patch.vlm_key = vlmKey;
|
|
894
|
+
const c = await api('PUT', '/api/ai/config', patch);
|
|
895
|
+
status.textContent = c.hasKey ? '已保存 ✓' : '已保存(未设置 Key)';
|
|
896
|
+
$('ma-cfg-key').value = '';
|
|
897
|
+
$('ma-cfg-vlm-key').value = '';
|
|
898
|
+
scheduleAutoReport(c);
|
|
899
|
+
} catch (e) {
|
|
900
|
+
status.textContent = e.message;
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
function scheduleAutoReport(cfg) {
|
|
905
|
+
if (autoTimer) {
|
|
906
|
+
clearInterval(autoTimer);
|
|
907
|
+
autoTimer = null;
|
|
908
|
+
}
|
|
909
|
+
if (!cfg?.auto_report) return;
|
|
910
|
+
const hour = cfg.auto_report_hour ?? 18;
|
|
911
|
+
autoTimer = setInterval(async () => {
|
|
912
|
+
if (!ctx.token) return;
|
|
913
|
+
const now = new Date();
|
|
914
|
+
if (now.getHours() !== hour) return;
|
|
915
|
+
const day = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
|
916
|
+
if (lastAutoDay === day) return;
|
|
917
|
+
// only fire in the first 10 minutes of the hour
|
|
918
|
+
if (now.getMinutes() > 10) return;
|
|
919
|
+
try {
|
|
920
|
+
lastAutoDay = day;
|
|
921
|
+
localStorage.setItem('ai_last_auto_report', day);
|
|
922
|
+
await streamSSE('/api/ai/report', { days: cfg.auto_report_days || 1 }, {
|
|
923
|
+
done: () => {
|
|
924
|
+
console.info('[meta-agent] auto report saved for', day);
|
|
925
|
+
},
|
|
926
|
+
error: (d) => console.warn('[meta-agent] auto report', d.message),
|
|
927
|
+
});
|
|
928
|
+
} catch (e) {
|
|
929
|
+
console.warn('[meta-agent] auto report failed', e);
|
|
930
|
+
lastAutoDay = ''; // allow retry
|
|
931
|
+
localStorage.removeItem('ai_last_auto_report');
|
|
932
|
+
}
|
|
933
|
+
}, 60 * 1000);
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
export function initMetaAgent() {
|
|
937
|
+
if ($('meta-agent-panel')) return;
|
|
938
|
+
document.getElementById('root')?.insertAdjacentHTML('beforeend', panelHTML());
|
|
939
|
+
|
|
940
|
+
document.querySelectorAll('[data-ma-close]').forEach((el) => {
|
|
941
|
+
el.addEventListener('click', closeMetaAgent);
|
|
942
|
+
});
|
|
943
|
+
$('ma-btn-chat')?.addEventListener('click', () => setMode('chat'));
|
|
944
|
+
$('ma-btn-history')?.addEventListener('click', async () => {
|
|
945
|
+
await refreshHistoryCache();
|
|
946
|
+
setMode('history');
|
|
947
|
+
});
|
|
948
|
+
$('ma-open-history')?.addEventListener('click', async () => {
|
|
949
|
+
await refreshHistoryCache();
|
|
950
|
+
setMode('history');
|
|
951
|
+
});
|
|
952
|
+
$('ma-btn-report')?.addEventListener('click', () => {
|
|
953
|
+
setMode('report');
|
|
954
|
+
loadTodayReportQuiet();
|
|
955
|
+
});
|
|
956
|
+
$('ma-btn-config')?.addEventListener('click', () => {
|
|
957
|
+
setMode('config');
|
|
958
|
+
loadConfigForm();
|
|
959
|
+
});
|
|
960
|
+
$('ma-send')?.addEventListener('click', sendChat);
|
|
961
|
+
$('ma-input')?.addEventListener('keydown', (e) => {
|
|
962
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
|
963
|
+
e.preventDefault();
|
|
964
|
+
sendChat();
|
|
965
|
+
}
|
|
966
|
+
});
|
|
967
|
+
$('ma-input')?.addEventListener('input', () => {
|
|
968
|
+
const el = $('ma-input');
|
|
969
|
+
el.style.height = 'auto';
|
|
970
|
+
el.style.height = Math.min(el.scrollHeight, 120) + 'px';
|
|
971
|
+
});
|
|
972
|
+
$('ma-new-chat')?.addEventListener('click', () => {
|
|
973
|
+
loadSession(null);
|
|
974
|
+
setMode('chat');
|
|
975
|
+
});
|
|
976
|
+
$('ma-del-session')?.addEventListener('click', deleteCurrentSession);
|
|
977
|
+
$('ma-resume-focus')?.addEventListener('click', () => {
|
|
978
|
+
$('ma-input')?.focus();
|
|
979
|
+
});
|
|
980
|
+
$('ma-history-refresh')?.addEventListener('click', async () => {
|
|
981
|
+
await refreshHistoryCache();
|
|
982
|
+
renderHistoryList();
|
|
983
|
+
});
|
|
984
|
+
$('ma-history-new')?.addEventListener('click', () => {
|
|
985
|
+
loadSession(null);
|
|
986
|
+
setMode('chat');
|
|
987
|
+
});
|
|
988
|
+
$('ma-history-search')?.addEventListener('input', (e) => {
|
|
989
|
+
historySearch = e.target.value || '';
|
|
990
|
+
renderHistoryList();
|
|
991
|
+
});
|
|
992
|
+
$('ma-quick-today')?.addEventListener('click', () => {
|
|
993
|
+
setMode('chat');
|
|
994
|
+
$('ma-input').value = '根据我最近的会话,总结今天我做了什么?按项目列出亮点与未完成事项。';
|
|
995
|
+
sendChat();
|
|
996
|
+
});
|
|
997
|
+
$('ma-quick-projects')?.addEventListener('click', () => {
|
|
998
|
+
setMode('chat');
|
|
999
|
+
$('ma-input').value = '列出最近 14 天活跃的项目,每个项目一句话说明最近在做什么。';
|
|
1000
|
+
sendChat();
|
|
1001
|
+
});
|
|
1002
|
+
$('ma-gen-report')?.addEventListener('click', generateReport);
|
|
1003
|
+
$('ma-load-reports')?.addEventListener('click', loadReportList);
|
|
1004
|
+
$('ma-cfg-save')?.addEventListener('click', saveConfig);
|
|
1005
|
+
$('ma-auto-report')?.addEventListener('change', async () => {
|
|
1006
|
+
try {
|
|
1007
|
+
const c = await api('PUT', '/api/ai/config', {
|
|
1008
|
+
auto_report: !!$('ma-auto-report').checked,
|
|
1009
|
+
auto_report_hour: parseInt($('ma-auto-hour')?.value || '18', 10),
|
|
1010
|
+
});
|
|
1011
|
+
scheduleAutoReport(c);
|
|
1012
|
+
} catch {}
|
|
1013
|
+
});
|
|
1014
|
+
$('ma-auto-hour')?.addEventListener('change', async () => {
|
|
1015
|
+
try {
|
|
1016
|
+
const c = await api('PUT', '/api/ai/config', {
|
|
1017
|
+
auto_report: !!$('ma-auto-report')?.checked,
|
|
1018
|
+
auto_report_hour: parseInt($('ma-auto-hour').value || '18', 10),
|
|
1019
|
+
});
|
|
1020
|
+
scheduleAutoReport(c);
|
|
1021
|
+
} catch {}
|
|
1022
|
+
});
|
|
1023
|
+
$('ma-image-input')?.addEventListener('change', async (e) => {
|
|
1024
|
+
const files = [...(e.target.files || [])];
|
|
1025
|
+
const ta = $('ma-input');
|
|
1026
|
+
if (ta) ta.disabled = true;
|
|
1027
|
+
try {
|
|
1028
|
+
for (const f of files) {
|
|
1029
|
+
if (!f.type.startsWith('image/')) continue;
|
|
1030
|
+
if (f.size > 12 * 1024 * 1024) {
|
|
1031
|
+
appendMsg('error', `${f.name} 过大 (>12MB)`);
|
|
1032
|
+
continue;
|
|
1033
|
+
}
|
|
1034
|
+
await uploadAndInsertImage(f, f.name || `upload-${Date.now()}.png`);
|
|
1035
|
+
}
|
|
1036
|
+
} catch (err) {
|
|
1037
|
+
appendMsg('error', `图片上传失败: ${err.message}`);
|
|
1038
|
+
} finally {
|
|
1039
|
+
e.target.value = '';
|
|
1040
|
+
if (ta) { ta.disabled = false; ta.focus(); }
|
|
1041
|
+
}
|
|
1042
|
+
});
|
|
1043
|
+
|
|
1044
|
+
// paste image → upload → insert [imgN](path)
|
|
1045
|
+
document.addEventListener('paste', async (e) => {
|
|
1046
|
+
if (!panelOpen || mode !== 'chat') return;
|
|
1047
|
+
const ta = $('ma-input');
|
|
1048
|
+
const panel = $('meta-agent-panel');
|
|
1049
|
+
const ae = document.activeElement;
|
|
1050
|
+
// only when focus is inside the agent panel
|
|
1051
|
+
if (!panel || !ae || !panel.contains(ae)) return;
|
|
1052
|
+
const items = [...(e.clipboardData?.items || [])];
|
|
1053
|
+
const imageItems = items.filter((it) => it.type.startsWith('image/'));
|
|
1054
|
+
if (!imageItems.length) return;
|
|
1055
|
+
e.preventDefault();
|
|
1056
|
+
if (ta) ta.disabled = true;
|
|
1057
|
+
try {
|
|
1058
|
+
for (const it of imageItems) {
|
|
1059
|
+
const f = it.getAsFile();
|
|
1060
|
+
if (!f) continue;
|
|
1061
|
+
const ext = (it.type.split('/')[1] || 'png').replace(/[^a-z0-9]/gi, '') || 'png';
|
|
1062
|
+
await uploadAndInsertImage(f, `paste-${Date.now()}.${ext}`);
|
|
1063
|
+
}
|
|
1064
|
+
} catch (err) {
|
|
1065
|
+
appendMsg('error', `粘贴图片失败: ${err.message}`);
|
|
1066
|
+
} finally {
|
|
1067
|
+
if (ta) { ta.disabled = false; ta.focus(); }
|
|
1068
|
+
}
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1071
|
+
// bootstrap: empty chat (last session restored on openMetaAgent)
|
|
1072
|
+
loadSession(null, { silent: true });
|
|
1073
|
+
refreshHistoryCache();
|
|
1074
|
+
api('GET', '/api/ai/config').then(scheduleAutoReport).catch(() => {});
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
// unused import guard for hub — kept for future machine-scoped AI
|
|
1078
|
+
void hubMode;
|
|
1079
|
+
void currentMachineId;
|