@pingagent/sdk 0.1.7 → 0.1.9

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/src/web-server.ts DELETED
@@ -1,1148 +0,0 @@
1
- /**
2
- * PingAgent Web - Local debugging and audit UI.
3
- * Serves a simple web app for viewing contacts, conversations, messages, and sending messages.
4
- * Auth: CLI holds identity + token; browser only talks to localhost. No token in browser.
5
- */
6
-
7
- import * as fs from 'node:fs';
8
- import * as http from 'node:http';
9
- import * as path from 'node:path';
10
- import {
11
- PingAgentClient,
12
- loadIdentity,
13
- updateStoredToken,
14
- ensureTokenValid,
15
- LocalStore,
16
- ContactManager,
17
- } from './index.js';
18
- import { SCHEMA_TEXT } from '@pingagent/schemas';
19
-
20
- const DEFAULT_PORT = 3846;
21
- const DEFAULT_ROOT = '~/.pingagent';
22
-
23
- function resolvePath(p: string): string {
24
- if (!p || !p.startsWith('~')) return p;
25
- return path.join(process.env.HOME || process.env.USERPROFILE || '', p.slice(1));
26
- }
27
-
28
- export interface ProfileEntry {
29
- id: string;
30
- did?: string;
31
- /** Server URL for this profile (from identity); used so local vs remote profiles work in one web session. */
32
- serverUrl?: string;
33
- identityPath: string;
34
- storePath: string;
35
- }
36
-
37
- export interface WebServerOptions {
38
- /** Root dir to scan for profiles (e.g. ~/.pingagent). When set, user selects profile on page. */
39
- rootDir?: string;
40
- /** When set, use this identity only (no profile picker). Overrides rootDir. */
41
- fixedIdentityPath?: string;
42
- fixedStorePath?: string;
43
- serverUrl: string;
44
- port?: number;
45
- }
46
-
47
- function listProfiles(rootDir: string): ProfileEntry[] {
48
- const root = resolvePath(rootDir);
49
- const profiles: ProfileEntry[] = [];
50
- if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) return profiles;
51
-
52
- // default: root/identity.json
53
- const defaultIdentity = path.join(root, 'identity.json');
54
- if (fs.existsSync(defaultIdentity)) {
55
- try {
56
- const id = loadIdentity(defaultIdentity);
57
- profiles.push({
58
- id: 'default',
59
- did: id.did,
60
- serverUrl: id.serverUrl,
61
- identityPath: defaultIdentity,
62
- storePath: path.join(root, 'store.db'),
63
- });
64
- } catch {
65
- profiles.push({ id: 'default', identityPath: defaultIdentity, storePath: path.join(root, 'store.db') });
66
- }
67
- }
68
-
69
- // profiles/<name>/identity.json
70
- const profilesDir = path.join(root, 'profiles');
71
- if (fs.existsSync(profilesDir) && fs.statSync(profilesDir).isDirectory()) {
72
- for (const name of fs.readdirSync(profilesDir)) {
73
- const sub = path.join(profilesDir, name);
74
- if (!fs.statSync(sub).isDirectory()) continue;
75
- const idPath = path.join(sub, 'identity.json');
76
- if (fs.existsSync(idPath)) {
77
- try {
78
- const id = loadIdentity(idPath);
79
- profiles.push({ id: name, did: id.did, serverUrl: id.serverUrl, identityPath: idPath, storePath: path.join(sub, 'store.db') });
80
- } catch {
81
- profiles.push({ id: name, identityPath: idPath, storePath: path.join(sub, 'store.db') });
82
- }
83
- }
84
- }
85
- }
86
-
87
- // root/<dir>/identity.json (e.g. receiver, agent1, remote1c)
88
- let names: string[];
89
- try {
90
- names = fs.readdirSync(root);
91
- } catch {
92
- return profiles;
93
- }
94
- for (const name of names) {
95
- if (name === 'profiles' || name === 'identity.json' || name === 'store.db') continue;
96
- const sub = path.join(root, name);
97
- if (!fs.statSync(sub).isDirectory()) continue;
98
- const idPath = path.join(sub, 'identity.json');
99
- if (fs.existsSync(idPath) && !profiles.some((p) => p.id === name)) {
100
- try {
101
- const id = loadIdentity(idPath);
102
- profiles.push({ id: name, did: id.did, serverUrl: id.serverUrl, identityPath: idPath, storePath: path.join(sub, 'store.db') });
103
- } catch {
104
- profiles.push({ id: name, identityPath: idPath, storePath: path.join(sub, 'store.db') });
105
- }
106
- }
107
- }
108
-
109
- return profiles;
110
- }
111
-
112
- /** Default server URL when identity has none (e.g. legacy). */
113
- const DEFAULT_SERVER_URL = 'https://pingagent.chat';
114
-
115
- async function getContextForProfile(
116
- profile: ProfileEntry,
117
- defaultServerUrl: string,
118
- ): Promise<{ client: PingAgentClient; contactManager: ContactManager; myDid: string; serverUrl: string }> {
119
- const identity = loadIdentity(profile.identityPath);
120
- const serverUrl = identity.serverUrl ?? defaultServerUrl ?? DEFAULT_SERVER_URL;
121
- await ensureTokenValid(profile.identityPath, serverUrl);
122
- const identityAfter = loadIdentity(profile.identityPath);
123
- const store = new LocalStore(profile.storePath);
124
- const contactManager = new ContactManager(store);
125
- const client = new PingAgentClient({
126
- serverUrl,
127
- identity: identityAfter,
128
- accessToken: identityAfter.accessToken ?? '',
129
- store,
130
- onTokenRefreshed: (token, expiresAt) => updateStoredToken(token, expiresAt, profile.identityPath),
131
- });
132
- return { client, contactManager, myDid: identityAfter.did, serverUrl };
133
- }
134
-
135
- const clientCache = new Map<string, { client: PingAgentClient; contactManager: ContactManager; myDid: string; serverUrl: string }>();
136
-
137
- export async function startWebServer(opts: WebServerOptions): Promise<http.Server> {
138
- const port = opts.port ?? DEFAULT_PORT;
139
- const defaultServerUrl = opts.serverUrl ?? DEFAULT_SERVER_URL;
140
- const rootDir = opts.rootDir ? resolvePath(opts.rootDir) : resolvePath(DEFAULT_ROOT);
141
-
142
- let profiles: ProfileEntry[];
143
- let fixedContext: { client: PingAgentClient; contactManager: ContactManager; myDid: string; serverUrl: string } | null = null;
144
-
145
- if (opts.fixedIdentityPath && opts.fixedStorePath) {
146
- const identityPath = resolvePath(opts.fixedIdentityPath);
147
- const storePath = resolvePath(opts.fixedStorePath);
148
- fixedContext = await getContextForProfile({ id: 'fixed', identityPath, storePath }, defaultServerUrl);
149
- profiles = [{ id: 'fixed', did: fixedContext.myDid, identityPath, storePath }];
150
- } else {
151
- profiles = listProfiles(opts.rootDir ?? DEFAULT_ROOT);
152
- if (profiles.length === 0) {
153
- throw new Error(`No identity found in ${rootDir}. Run: pingagent init`);
154
- }
155
- }
156
-
157
- const html = getHtml();
158
-
159
- const server = http.createServer(async (req, res) => {
160
- const url = new URL(req.url || '/', `http://${req.headers.host}`);
161
- const pathname = url.pathname;
162
- const raw = url.searchParams.get('profile') || req.headers['x-profile'];
163
- const profileId = Array.isArray(raw) ? raw[0] : raw;
164
-
165
- const origin = req.headers.origin || '';
166
- const allowOrigin = /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin) ? origin : 'null';
167
- res.setHeader('Access-Control-Allow-Origin', allowOrigin);
168
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
169
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Profile');
170
-
171
- if (req.method === 'OPTIONS') {
172
- res.writeHead(204);
173
- res.end();
174
- return;
175
- }
176
-
177
- if (pathname === '/' || pathname === '/index.html') {
178
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
179
- res.end(html);
180
- return;
181
- }
182
-
183
- if (pathname.startsWith('/api/')) {
184
- try {
185
- if (pathname === '/api/profiles' || pathname === '/api/profiles/') {
186
- res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
187
- res.end(JSON.stringify({ profiles: profiles.map((p) => ({ id: p.id, did: p.did, server: p.serverUrl })) }));
188
- return;
189
- }
190
- let ctx = fixedContext;
191
- if (!ctx) {
192
- const pid: string | null =
193
- (typeof profileId === 'string' ? profileId : null) ||
194
- (profiles.length === 1 ? profiles[0].id : null);
195
- if (!pid) {
196
- res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
197
- res.end(JSON.stringify({ error: 'Select a profile first (?profile=xxx)' }));
198
- return;
199
- }
200
- const cached = clientCache.get(pid);
201
- if (cached) ctx = cached;
202
- else {
203
- const p = profiles.find((x) => x.id === pid);
204
- if (!p) throw new Error(`Unknown profile: ${pid}`);
205
- ctx = await getContextForProfile(p, defaultServerUrl);
206
- clientCache.set(pid, ctx);
207
- }
208
- }
209
- const result = await handleApi(pathname, req, ctx.client, ctx.contactManager, ctx.myDid, ctx.serverUrl);
210
- res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
211
- res.end(JSON.stringify(result));
212
- } catch (err: any) {
213
- res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
214
- res.end(JSON.stringify({ error: err?.message ?? 'Internal error' }));
215
- }
216
- return;
217
- }
218
-
219
- res.writeHead(404);
220
- res.end('Not found');
221
- });
222
-
223
- server.listen(port, '127.0.0.1', () => {
224
- console.log(`PingAgent Web: http://127.0.0.1:${port}`);
225
- if (fixedContext) {
226
- console.log(` DID: ${fixedContext.myDid}`);
227
- console.log(` Server: ${fixedContext.serverUrl}`);
228
- } else {
229
- console.log(` Profiles: ${profiles.map((p) => p.id).join(', ')} (each uses its identity server URL)`);
230
- }
231
- });
232
-
233
- return server;
234
- }
235
-
236
- async function handleApi(
237
- pathname: string,
238
- req: http.IncomingMessage,
239
- client: PingAgentClient,
240
- contactManager: ContactManager,
241
- myDid: string,
242
- serverUrl: string,
243
- ): Promise<unknown> {
244
- const parts = pathname.slice(5).split('/').filter(Boolean);
245
-
246
- if (parts[0] === 'me') {
247
- return { did: myDid, serverUrl };
248
- }
249
-
250
- if (parts[0] === 'profile') {
251
- if (req.method === 'GET') {
252
- const res = await client.getProfile();
253
- if (!res.ok) throw new Error(res.error?.message ?? 'Failed to get profile');
254
- return res.data;
255
- }
256
- if (req.method === 'POST') {
257
- const body = await readBody(req);
258
- const profile: Record<string, unknown> = {};
259
- if (body?.display_name != null) profile.display_name = body.display_name;
260
- if (body?.bio != null) profile.bio = body.bio;
261
- if (Array.isArray(body?.capabilities)) profile.capabilities = body.capabilities;
262
- else if (typeof body?.capabilities === 'string') profile.capabilities = body.capabilities.split(',').map((s: string) => s.trim()).filter(Boolean);
263
- if (Array.isArray(body?.tags)) profile.tags = body.tags;
264
- else if (typeof body?.tags === 'string') profile.tags = body.tags.split(',').map((s: string) => s.trim()).filter(Boolean);
265
- if (typeof body?.discoverable === 'boolean') profile.discoverable = body.discoverable;
266
- const res = await client.updateProfile(profile as any);
267
- if (!res.ok) throw new Error(res.error?.message ?? 'Failed to update profile');
268
- return res.data;
269
- }
270
- }
271
-
272
- if (parts[0] === 'feed') {
273
- if (parts[1] === 'publish' && req.method === 'POST') {
274
- const body = await readBody(req);
275
- const text = String(body?.text ?? '').trim();
276
- if (!text) throw new Error('Missing text');
277
- const res = await client.publishPost({ text, artifact_ref: body?.artifact_ref as string | undefined });
278
- if (!res.ok) throw new Error(res.error?.message ?? 'Failed to publish');
279
- return res.data;
280
- }
281
- if (parts[1] === 'public') {
282
- const url = new URL(req.url || '', 'http://x');
283
- const limit = parseInt(url.searchParams.get('limit') ?? '20', 10);
284
- const since = url.searchParams.get('since');
285
- const res = await client.listFeedPublic({ limit, since: since ? parseInt(since, 10) : undefined });
286
- if (!res.ok) throw new Error(res.error?.message ?? 'Failed to list feed');
287
- return res.data;
288
- }
289
- if (parts[1] === 'by_did') {
290
- const url = new URL(req.url || '', 'http://x');
291
- const did = url.searchParams.get('did') || myDid;
292
- const limit = parseInt(url.searchParams.get('limit') ?? '20', 10);
293
- const res = await client.listFeedByDid(did, { limit });
294
- if (!res.ok) throw new Error(res.error?.message ?? 'Failed to list feed');
295
- return res.data;
296
- }
297
- }
298
-
299
- if (parts[0] === 'channels') {
300
- if (!parts[1] || parts[1] === 'mine') {
301
- const listRes = await client.listConversations({ type: 'channel' });
302
- if (!listRes.ok) throw new Error(listRes.error?.message ?? 'Failed to list channels');
303
- return { channels: listRes.data?.conversations ?? [] };
304
- }
305
- if (parts[1] === 'discover') {
306
- const url = new URL(req.url || '', 'http://x');
307
- const limit = parseInt(url.searchParams.get('limit') ?? '20', 10);
308
- const q = url.searchParams.get('q') || undefined;
309
- const res = await client.discoverChannels({ limit, query: q });
310
- if (!res.ok) throw new Error(res.error?.message ?? 'Failed to discover channels');
311
- return res.data;
312
- }
313
- if (parts[1] === 'create' && req.method === 'POST') {
314
- const body = await readBody(req);
315
- const name = String(body?.name ?? '').trim();
316
- if (!name) throw new Error('Missing name');
317
- const res = await client.createChannel({
318
- name,
319
- alias: body?.alias as string | undefined,
320
- description: body?.description as string | undefined,
321
- discoverable: body?.discoverable as boolean | undefined,
322
- });
323
- if (!res.ok) throw new Error(res.error?.message ?? 'Failed to create channel');
324
- return res.data;
325
- }
326
- if (parts[1] === 'update' && req.method === 'PATCH') {
327
- const body = await readBody(req);
328
- const alias = String(body?.alias ?? '').trim();
329
- if (!alias) throw new Error('Missing alias');
330
- const res = await client.updateChannel({
331
- alias,
332
- name: body?.name as string | undefined,
333
- description: body?.description as string | undefined,
334
- discoverable: body?.discoverable as boolean | undefined,
335
- });
336
- if (!res.ok) throw new Error(res.error?.message ?? 'Failed to update channel');
337
- return res.data;
338
- }
339
- if (parts[1] === 'delete' && req.method === 'DELETE') {
340
- const body = await readBody(req);
341
- const alias = String(body?.alias ?? '').trim();
342
- if (!alias) throw new Error('Missing alias');
343
- const res = await client.deleteChannel(alias);
344
- if (!res.ok) throw new Error(res.error?.message ?? 'Failed to delete channel');
345
- return res.data;
346
- }
347
- if (parts[1] === 'join' && req.method === 'POST') {
348
- const body = await readBody(req);
349
- const alias = String(body?.alias ?? '').trim().replace(/^@ch\//, '');
350
- if (!alias) throw new Error('Missing alias');
351
- const res = await client.joinChannel(alias);
352
- if (!res.ok) throw new Error(res.error?.message ?? 'Failed to join channel');
353
- return res.data;
354
- }
355
- }
356
-
357
- if (parts[0] === 'contacts') {
358
- const list = contactManager.list();
359
- return { contacts: list };
360
- }
361
-
362
- if (parts[0] === 'conversations' && !parts[1]) {
363
- const listRes = await client.listConversations({ type: 'dm' });
364
- if (!listRes.ok) throw new Error(listRes.error?.message ?? 'Failed to list conversations');
365
- const convos = listRes.data?.conversations ?? [];
366
- // Merge with contacts for display names
367
- const contacts = contactManager.list();
368
- const byDid = new Map(contacts.map((c) => [c.did, c]));
369
- const enriched = convos.map((c) => ({
370
- ...c,
371
- display_name: byDid.get(c.target_did)?.display_name ?? byDid.get(c.target_did)?.alias,
372
- }));
373
- return { conversations: enriched };
374
- }
375
-
376
- if (parts[0] === 'conversations' && parts[1] && parts[2] === 'messages') {
377
- const conversationId = decodeURIComponent(parts[1]);
378
- const sinceSeq = parseInt(new URL(req.url || '', 'http://x').searchParams.get('since_seq') ?? '0', 10) || 0;
379
- const toMsg = (m: any) => ({
380
- message_id: m.message_id,
381
- seq: m.seq,
382
- sender_did: m.sender_did,
383
- schema: m.schema,
384
- payload: m.payload,
385
- ts_ms: m.ts_ms,
386
- isMe: m.sender_did === myDid,
387
- });
388
- const seen = new Set<string>();
389
- const merged: ReturnType<typeof toMsg>[] = [];
390
- for (const box of ['ready', 'strangers'] as const) {
391
- const fetchRes = await client.fetchInbox(conversationId, { sinceSeq, limit: 100, box });
392
- if (!fetchRes.ok) continue;
393
- for (const m of fetchRes.data?.messages ?? []) {
394
- if (m.message_id && !seen.has(m.message_id)) {
395
- seen.add(m.message_id);
396
- merged.push(toMsg(m));
397
- }
398
- }
399
- }
400
- merged.sort((a, b) => (a.ts_ms ?? 0) - (b.ts_ms ?? 0));
401
- const hm = client.getHistoryManager();
402
- if (hm) {
403
- const stored = hm.list(conversationId, { limit: 100 });
404
- for (const m of stored) {
405
- if (!seen.has(m.message_id)) {
406
- seen.add(m.message_id);
407
- merged.push({
408
- message_id: m.message_id,
409
- seq: m.seq,
410
- sender_did: m.sender_did,
411
- schema: m.schema,
412
- payload: m.payload,
413
- ts_ms: m.ts_ms,
414
- isMe: m.sender_did === myDid,
415
- });
416
- }
417
- }
418
- merged.sort((a, b) => (a.ts_ms ?? 0) - (b.ts_ms ?? 0));
419
- }
420
- return { messages: merged };
421
- }
422
-
423
- if (parts[0] === 'conversations' && parts[1] && parts[2] === 'send' && req.method === 'POST') {
424
- const conversationId = decodeURIComponent(parts[1]);
425
- const body = await readBody(req);
426
- const text = body?.text ?? body?.message ?? '';
427
- if (!text) throw new Error('Missing text or message');
428
- const sendRes = await client.sendMessage(conversationId, SCHEMA_TEXT, { text });
429
- if (!sendRes.ok) throw new Error(sendRes.error?.message ?? 'Failed to send');
430
- return { ok: true, message_id: sendRes.data?.message_id };
431
- }
432
-
433
- if (parts[0] === 'open' && req.method === 'POST') {
434
- const body = await readBody(req);
435
- const targetDid = String(body?.target_did ?? body?.did ?? '').trim();
436
- if (!targetDid) throw new Error('Missing target_did');
437
- const openRes = await client.openConversation(targetDid);
438
- if (!openRes.ok) throw new Error(openRes.error?.message ?? 'Failed to open conversation');
439
- const conv = openRes.data!;
440
- if (!conv.trusted && body?.send_contact_request) {
441
- const msg = typeof body?.message === 'string' ? body.message : undefined;
442
- await client.sendContactRequest(conv.conversation_id, msg);
443
- }
444
- return { conversation_id: conv.conversation_id, type: conv.type, trusted: conv.trusted };
445
- }
446
-
447
- throw new Error('Unknown API');
448
- }
449
-
450
- function readBody(req: http.IncomingMessage): Promise<Record<string, unknown>> {
451
- return new Promise((resolve, reject) => {
452
- const chunks: Buffer[] = [];
453
- req.on('data', (c) => chunks.push(c));
454
- req.on('end', () => {
455
- try {
456
- const raw = Buffer.concat(chunks).toString('utf-8');
457
- resolve(raw ? JSON.parse(raw) : {});
458
- } catch (e) {
459
- reject(new Error('Invalid JSON'));
460
- }
461
- });
462
- req.on('error', reject);
463
- });
464
- }
465
-
466
- function getHtml(_fixedOnly?: boolean): string {
467
- const profilePicker = `
468
- <div class="profile-picker" id="profilePicker">
469
- <div class="profile-label">选择 Profile 登录</div>
470
- <div class="profile-list" id="profileList"><p class="profile-loading">加载中...</p></div>
471
- </div>
472
- <div class="profile-current" id="profileCurrent" style="display:none">
473
- <span>当前: <strong id="currentProfileName"></strong></span>
474
- <button class="profile-switch-btn" id="switchProfileBtn">切换</button>
475
- </div>`;
476
- return `<!DOCTYPE html>
477
- <html lang="zh-CN">
478
- <head>
479
- <meta charset="UTF-8">
480
- <meta name="viewport" content="width=device-width, initial-scale=1">
481
- <title>PingAgent Web - 本地调试与审计</title>
482
- <style>
483
- * { box-sizing: border-box; }
484
- body { font-family: ui-sans-serif, system-ui, sans-serif; margin: 0; background: #0f0f12; color: #e4e4e7; }
485
- .layout { display: flex; height: 100vh; }
486
- .sidebar { width: 280px; border-right: 1px solid #27272a; overflow-y: auto; }
487
- .main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
488
- .header { padding: 12px 16px; border-bottom: 1px solid #27272a; font-size: 13px; color: #a1a1aa; }
489
- .header strong { color: #fafafa; }
490
- .list { padding: 8px 0; }
491
- .list-item { padding: 10px 16px; cursor: pointer; font-size: 14px; border-left: 3px solid transparent; }
492
- .list-item:hover { background: #18181b; }
493
- .list-item.active { background: #18181b; border-left-color: #3b82f6; }
494
- .list-item .sub { font-size: 12px; color: #71717a; margin-top: 2px; }
495
- .messages { flex: 1; overflow-y: auto; padding: 16px; }
496
- .msg { max-width: 75%; margin-bottom: 12px; padding: 10px 14px; border-radius: 12px; font-size: 14px; }
497
- .msg.me { margin-left: auto; background: #3b82f6; color: #fff; }
498
- .msg.other { background: #27272a; }
499
- .msg .meta { font-size: 11px; color: #71717a; margin-bottom: 4px; }
500
- .msg.me .meta { color: rgba(255,255,255,0.7); }
501
- .input-area { padding: 12px 16px; border-top: 1px solid #27272a; display: flex; gap: 8px; }
502
- .input-area input { flex: 1; padding: 10px 14px; border: 1px solid #3f3f46; border-radius: 8px; background: #18181b; color: #fafafa; font-size: 14px; }
503
- .input-area input:focus { outline: none; border-color: #3b82f6; }
504
- .input-area button { padding: 10px 20px; background: #3b82f6; color: #fff; border: none; border-radius: 8px; cursor: pointer; font-size: 14px; }
505
- .input-area button:hover { background: #2563eb; }
506
- .input-area button:disabled { opacity: 0.5; cursor: not-allowed; }
507
- .empty { padding: 24px; text-align: center; color: #71717a; font-size: 14px; }
508
- .add-conv { padding: 12px 16px; border-bottom: 1px solid #27272a; }
509
- .add-conv input { width: 100%; padding: 8px 12px; border: 1px solid #3f3f46; border-radius: 6px; background: #18181b; color: #fafafa; font-size: 13px; }
510
- .add-conv button { margin-top: 8px; padding: 8px 12px; background: #27272a; color: #e4e4e7; border: none; border-radius: 6px; cursor: pointer; font-size: 13px; }
511
- .add-conv button:hover { background: #3f3f46; }
512
- .error { color: #f87171; font-size: 13px; padding: 8px 0; }
513
- .profile-picker { padding: 12px 16px; border-bottom: 1px solid #27272a; }
514
- .profile-label { font-size: 12px; color: #71717a; margin-bottom: 8px; }
515
- .profile-list { min-height: 160px; }
516
- .profile-loading { font-size: 12px; color: #71717a; margin: 0; }
517
- .profile-list .profile-btn { display: block; width: 100%; padding: 8px 12px; margin-bottom: 4px; background: #27272a; color: #e4e4e7; border: none; border-radius: 6px; cursor: pointer; font-size: 13px; text-align: left; }
518
- .profile-list .profile-btn:hover { background: #3f3f46; }
519
- .profile-list .profile-btn.selected { border: 1px solid #3b82f6; background: #1e3a5f; }
520
- .profile-list .profile-btn .sub { font-size: 11px; color: #71717a; }
521
- .profile-current { padding: 12px 16px; border-bottom: 1px solid #27272a; display: flex; align-items: center; justify-content: space-between; gap: 8px; }
522
- .profile-current span { font-size: 13px; color: #a1a1aa; }
523
- .profile-switch-btn { padding: 4px 10px; font-size: 12px; background: #27272a; color: #e4e4e7; border: none; border-radius: 6px; cursor: pointer; }
524
- .profile-switch-btn:hover { background: #3f3f46; }
525
- .section-label { font-size: 11px; color: #71717a; padding: 8px 16px 4px; text-transform: uppercase; }
526
- .panel { display: none; flex-direction: column; flex: 1; min-height: 0; }
527
- .panel.active { display: flex; }
528
- .panel-content { flex: 1; overflow-y: auto; padding: 16px; }
529
- .form-group { margin-bottom: 12px; }
530
- .form-group label { display: block; font-size: 12px; color: #71717a; margin-bottom: 4px; }
531
- .form-group input, .form-group textarea { width: 100%; padding: 8px 12px; border: 1px solid #3f3f46; border-radius: 6px; background: #18181b; color: #fafafa; font-size: 13px; }
532
- .form-group textarea { min-height: 60px; resize: vertical; }
533
- .btn-primary { padding: 8px 16px; background: #3b82f6; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 13px; }
534
- .btn-primary:hover { background: #2563eb; }
535
- .btn-secondary { padding: 8px 16px; background: #27272a; color: #e4e4e7; border: none; border-radius: 6px; cursor: pointer; font-size: 13px; }
536
- .btn-secondary:hover { background: #3f3f46; }
537
- .nav-item { padding: 10px 16px; cursor: pointer; font-size: 14px; border-left: 3px solid transparent; display: flex; align-items: center; gap: 8px; }
538
- .nav-item:hover { background: #18181b; }
539
- .nav-item.active { background: #18181b; border-left-color: #3b82f6; }
540
- </style>
541
- </head>
542
- <body>
543
- <div class="layout">
544
- <div class="sidebar">
545
- <div class="header"><strong>PingAgent Web</strong><br>本地调试与审计</div>
546
- ${profilePicker}
547
- <div class="add-conv">
548
- <input type="text" id="newDid" placeholder="输入 DID 或别名新建会话">
549
- <button id="openConv">打开会话</button>
550
- </div>
551
- <div class="section-label">会话</div>
552
- <div class="list" id="convList"></div>
553
- <div class="section-label">联系人</div>
554
- <div class="list" id="contactList"></div>
555
- <div class="section-label">频道</div>
556
- <div class="list" id="channelList"></div>
557
- <div class="add-conv">
558
- <button id="discoverChannelsBtn" class="btn-secondary" style="width:100%">发现频道</button>
559
- <button id="createChannelBtn" class="btn-secondary" style="width:100%;margin-top:4px">创建频道</button>
560
- </div>
561
- <div class="section-label">Feed</div>
562
- <div class="nav-item" id="navFeed">发布动态</div>
563
- <div class="section-label">Profile</div>
564
- <div class="nav-item" id="navProfile">编辑资料</div>
565
- </div>
566
- <div class="main">
567
- <div class="header" id="mainHeader">选择会话或新建</div>
568
- <div id="dmPanel" class="panel active">
569
- <div class="messages" id="messages"></div>
570
- <div class="input-area" id="inputArea" style="display:none">
571
- <input type="text" id="msgInput" placeholder="输入消息..." autocomplete="off">
572
- <button id="sendBtn">发送</button>
573
- </div>
574
- </div>
575
- <div id="feedPanel" class="panel">
576
- <div class="panel-content">
577
- <div class="form-group">
578
- <label>发布 Feed</label>
579
- <textarea id="feedText" placeholder="分享动态..." rows="3"></textarea>
580
- <button id="publishFeedBtn" class="btn-primary" style="margin-top:8px">发布</button>
581
- </div>
582
- <div style="margin-top:16px">
583
- <div class="section-label">动态流</div>
584
- <div id="feedTimeline"></div>
585
- </div>
586
- </div>
587
- </div>
588
- <div id="profilePanel" class="panel">
589
- <div class="panel-content">
590
- <div class="form-group">
591
- <label>昵称 (display_name)</label>
592
- <input type="text" id="profileDisplayName" placeholder="昵称">
593
- </div>
594
- <div class="form-group">
595
- <label>简介 (bio)</label>
596
- <textarea id="profileBio" placeholder="简短介绍" rows="3"></textarea>
597
- </div>
598
- <div class="form-group">
599
- <label>标签 (tags, 逗号分隔)</label>
600
- <input type="text" id="profileTags" placeholder="coding, devops">
601
- </div>
602
- <div class="form-group">
603
- <label>能力 (capabilities, 逗号分隔)</label>
604
- <input type="text" id="profileCapabilities" placeholder="coding, testing">
605
- </div>
606
- <button id="saveProfileBtn" class="btn-primary">保存</button>
607
- </div>
608
- </div>
609
- <div id="discoverPanel" class="panel">
610
- <div class="panel-content">
611
- <div class="form-group">
612
- <input type="text" id="discoverQuery" placeholder="搜索频道...">
613
- <button id="discoverSearchBtn" class="btn-primary" style="margin-top:8px">搜索</button>
614
- </div>
615
- <div id="discoverChannelList"></div>
616
- </div>
617
- </div>
618
- <div id="createChannelPanel" class="panel">
619
- <div class="panel-content">
620
- <div class="form-group">
621
- <label>频道名称</label>
622
- <input type="text" id="createChannelName" placeholder="名称" required>
623
- </div>
624
- <div class="form-group">
625
- <label>别名 (可选, 如 my-channel)</label>
626
- <input type="text" id="createChannelAlias" placeholder="alias">
627
- </div>
628
- <div class="form-group">
629
- <label>描述 (可选)</label>
630
- <input type="text" id="createChannelDesc" placeholder="描述">
631
- </div>
632
- <button id="createChannelSubmitBtn" class="btn-primary">创建</button>
633
- <button id="createChannelBackBtn" class="btn-secondary" style="margin-left:8px">返回</button>
634
- </div>
635
- </div>
636
- </div>
637
- </div>
638
- <script>
639
- const API = '';
640
- let me = {}, conversations = [], contacts = [], channels = [], currentConv = null, currentView = 'dm';
641
- let selectedProfile = sessionStorage.getItem('pingagent_web_profile') || null;
642
- const profilePickerEl = document.getElementById('profilePicker');
643
- const profileCurrentEl = document.getElementById('profileCurrent');
644
- const switchProfileBtn = document.getElementById('switchProfileBtn');
645
- let profilesCache = null;
646
-
647
- function showProfileCurrent() {
648
- if (profileCurrentEl) {
649
- profileCurrentEl.style.display = 'flex';
650
- const nameEl = document.getElementById('currentProfileName');
651
- if (nameEl) nameEl.textContent = selectedProfile || '';
652
- }
653
- if (profilePickerEl) profilePickerEl.style.display = '';
654
- highlightSelectedProfile();
655
- }
656
-
657
- function showProfilePicker() {
658
- if (profileCurrentEl) profileCurrentEl.style.display = 'none';
659
- if (profilePickerEl) profilePickerEl.style.display = '';
660
- currentConv = null;
661
- document.getElementById('inputArea').style.display = 'none';
662
- document.getElementById('messages').innerHTML = '';
663
- document.getElementById('mainHeader').innerHTML = '<strong>选择 Profile 登录</strong>';
664
- }
665
-
666
- function highlightSelectedProfile() {
667
- const listEl = document.getElementById('profileList');
668
- if (!listEl) return;
669
- listEl.querySelectorAll('.profile-btn').forEach(btn => {
670
- btn.classList.toggle('selected', btn.dataset.id === selectedProfile);
671
- });
672
- }
673
-
674
- async function loadProfiles() {
675
- const listEl = document.getElementById('profileList');
676
- if (!profilePickerEl || !listEl) return [];
677
- if (profilesCache) return profilesCache;
678
- try {
679
- const ctrl = new AbortController();
680
- const t = setTimeout(() => ctrl.abort(), 8000);
681
- const r = await fetch('/api/profiles', { signal: ctrl.signal });
682
- clearTimeout(t);
683
- if (!r.ok) {
684
- listEl.innerHTML = '<p class="profile-loading">请求失败 ' + r.status + '</p>';
685
- return [];
686
- }
687
- const data = await r.json().catch(() => ({}));
688
- const list = Array.isArray(data.profiles) ? data.profiles : [];
689
- profilesCache = list;
690
- if (list.length === 0) listEl.innerHTML = '<p class="profile-loading">未找到 profile</p>';
691
- return profilesCache;
692
- } catch (e) {
693
- const msg = (e && e.name === 'AbortError') ? '请求超时' : '加载失败,请刷新';
694
- listEl.innerHTML = '<p class="profile-loading">' + msg + '</p>';
695
- return [];
696
- }
697
- }
698
-
699
- function renderProfileList(profiles) {
700
- const listEl = document.getElementById('profileList');
701
- if (!listEl) return;
702
- const list = profiles || [];
703
- if (list.length === 0) {
704
- listEl.innerHTML = '<p class="profile-loading">未找到 profile</p>';
705
- return;
706
- }
707
- listEl.innerHTML = list.map(p => {
708
- var s = (p.server && typeof p.server === 'string') ? p.server : '';
709
- var serverLabel = s.indexOf('://') >= 0 ? s.split('://')[1].split('/')[0] : (s || 'local');
710
- var didShort = (p.did && typeof p.did === 'string') ? p.did.slice(0, 24) + '...' : '';
711
- var sel = p.id === selectedProfile ? ' selected' : '';
712
- return '<button class="profile-btn' + sel + '" data-id="' + p.id + '">' + p.id + '<div class="sub">' + serverLabel + ' \u00B7 ' + didShort + '<' + '/div><' + '/button>';
713
- }).join('');
714
- listEl.querySelectorAll('.profile-btn').forEach(btn => {
715
- btn.addEventListener('click', async () => {
716
- selectedProfile = btn.dataset.id || null;
717
- if (selectedProfile) sessionStorage.setItem('pingagent_web_profile', selectedProfile);
718
- showProfileCurrent();
719
- await loadDataForProfile();
720
- });
721
- });
722
- }
723
-
724
- function showPanel(panelId) {
725
- document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
726
- const el = document.getElementById(panelId);
727
- if (el) el.classList.add('active');
728
- currentView = panelId.replace('Panel', '');
729
- document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
730
- const navMap = { feed: 'navFeed', profile: 'navProfile' };
731
- const nav = document.getElementById(navMap[currentView]);
732
- if (nav) nav.classList.add('active');
733
- }
734
-
735
- async function loadDataForProfile() {
736
- const headerEl = document.getElementById('mainHeader');
737
- try {
738
- await loadMe();
739
- await loadConversations();
740
- await loadContacts();
741
- await loadChannels();
742
- } catch (e) {
743
- const msg = (e && e.message) ? e.message : '加载失败';
744
- if (headerEl) headerEl.innerHTML = '<span class="error">' + msg.replace(/</g, '&lt;') + '</span>';
745
- conversations = [];
746
- contacts = [];
747
- channels = [];
748
- renderConvList();
749
- renderContactList();
750
- renderChannelList();
751
- }
752
- }
753
-
754
- async function switchProfile() {
755
- selectedProfile = null;
756
- sessionStorage.removeItem('pingagent_web_profile');
757
- const profiles = await loadProfiles();
758
- renderProfileList(profiles);
759
- showProfilePicker();
760
- }
761
-
762
- async function api(path, opts = {}) {
763
- let url = API + path;
764
- if (profilePickerEl && selectedProfile) {
765
- url += (path.includes('?') ? '&' : '?') + 'profile=' + encodeURIComponent(selectedProfile);
766
- }
767
- const res = await fetch(url, opts);
768
- const data = await res.json().catch(() => ({}));
769
- if (data.error) throw new Error(data.error);
770
- return data;
771
- }
772
-
773
- async function loadMe() {
774
- me = await api('/api/me');
775
- document.getElementById('mainHeader').innerHTML = '<strong>' + (me.did || 'Loading...').slice(0, 40) + '...</strong>';
776
- }
777
-
778
- async function loadConversations() {
779
- const data = await api('/api/conversations');
780
- conversations = data.conversations || [];
781
- renderConvList();
782
- }
783
-
784
- async function loadContacts() {
785
- const data = await api('/api/contacts');
786
- contacts = data.contacts || [];
787
- renderContactList();
788
- }
789
-
790
- async function loadChannels() {
791
- try {
792
- const data = await api('/api/channels');
793
- channels = data.channels || [];
794
- renderChannelList();
795
- } catch (e) {
796
- channels = [];
797
- renderChannelList();
798
- }
799
- }
800
-
801
- function renderChannelList() {
802
- const el = document.getElementById('channelList');
803
- if (!el) return;
804
- if (!channels.length) {
805
- el.innerHTML = '<div class="empty">暂无频道</div>';
806
- return;
807
- }
808
- el.innerHTML = channels.map(c => {
809
- const name = (c.target_did || c.conversation_id || '').slice(0, 24) + (c.target_did ? '...' : '');
810
- const convId = (c.conversation_id || '').replace(/"/g, '&quot;');
811
- const targetDid = (c.target_did || '').replace(/"/g, '&quot;');
812
- return '<div class="list-item channel-item' + (currentConv?.conversation_id === c.conversation_id && currentConv?.isChannel ? ' active' : '') + '" data-id="' + convId + '" data-did="' + targetDid + '">频道 ' + name + '</div>';
813
- }).join('');
814
- el.querySelectorAll('.channel-item').forEach(n => {
815
- n.addEventListener('click', () => selectChannel(n.dataset.id, n.dataset.did));
816
- });
817
- }
818
-
819
- async function selectChannel(convId, targetDid) {
820
- currentConv = { conversation_id: convId, target_did: targetDid, isChannel: true };
821
- renderConvList();
822
- renderChannelList();
823
- showPanel('dmPanel');
824
- document.getElementById('inputArea').style.display = 'flex';
825
- document.getElementById('mainHeader').innerHTML = '<strong>频道 ' + (targetDid || convId).slice(0, 40) + '</strong>';
826
- await loadMessages(convId);
827
- }
828
-
829
- async function loadProfile() {
830
- try {
831
- const p = await api('/api/profile');
832
- document.getElementById('profileDisplayName').value = p.display_name || '';
833
- document.getElementById('profileBio').value = p.bio || '';
834
- document.getElementById('profileTags').value = (p.tags || []).join(', ');
835
- document.getElementById('profileCapabilities').value = (p.capabilities || []).join(', ');
836
- } catch (e) {
837
- document.getElementById('profilePanel').querySelector('.panel-content').innerHTML = '<div class="error">加载失败: ' + (e.message || '') + '</div>';
838
- }
839
- }
840
-
841
- async function saveProfile() {
842
- try {
843
- const display_name = document.getElementById('profileDisplayName').value.trim();
844
- const bio = document.getElementById('profileBio').value.trim();
845
- const tags = document.getElementById('profileTags').value.split(',').map(s => s.trim()).filter(Boolean);
846
- const capabilities = document.getElementById('profileCapabilities').value.split(',').map(s => s.trim()).filter(Boolean);
847
- await api('/api/profile', {
848
- method: 'POST',
849
- headers: { 'Content-Type': 'application/json' },
850
- body: JSON.stringify({ display_name, bio, tags, capabilities })
851
- });
852
- alert('保存成功');
853
- } catch (e) {
854
- alert('保存失败: ' + e.message);
855
- }
856
- }
857
-
858
- async function publishFeed() {
859
- const text = document.getElementById('feedText').value.trim();
860
- if (!text) return;
861
- try {
862
- await api('/api/feed/publish', {
863
- method: 'POST',
864
- headers: { 'Content-Type': 'application/json' },
865
- body: JSON.stringify({ text })
866
- });
867
- document.getElementById('feedText').value = '';
868
- await loadFeedTimeline();
869
- } catch (e) {
870
- alert('发布失败: ' + e.message);
871
- }
872
- }
873
-
874
- async function loadFeedTimeline() {
875
- const el = document.getElementById('feedTimeline');
876
- try {
877
- const data = await api('/api/feed/by_did?did=' + encodeURIComponent(me.did || '') + '&limit=30');
878
- const posts = (data.posts || []).slice(0, 30);
879
- if (!posts.length) {
880
- el.innerHTML = '<div class="empty">暂无动态,去发布一条吧</div>';
881
- return;
882
- }
883
- el.innerHTML = posts.map(p => {
884
- const time = new Date(p.ts_ms).toLocaleString();
885
- const text = (p.text || '').replace(/</g, '&lt;').replace(/>/g, '&gt;');
886
- return '<div class="msg" style="margin-bottom:12px"><div class="meta">' + time + '</div>' + text + '</div>';
887
- }).join('');
888
- } catch (e) {
889
- el.innerHTML = '<div class="error">加载失败: ' + (e.message || '') + '</div>';
890
- }
891
- }
892
-
893
- async function discoverChannels() {
894
- showPanel('discoverPanel');
895
- document.getElementById('mainHeader').innerHTML = '<strong>发现频道</strong>';
896
- await doDiscoverChannels();
897
- }
898
-
899
- async function doDiscoverChannels() {
900
- const el = document.getElementById('discoverChannelList');
901
- const q = document.getElementById('discoverQuery')?.value?.trim() || '';
902
- try {
903
- const url = '/api/channels/discover?limit=20' + (q ? '&q=' + encodeURIComponent(q) : '');
904
- const data = await api(url);
905
- const list = data.channels || [];
906
- if (!list.length) {
907
- el.innerHTML = '<div class="empty">暂无频道</div>';
908
- return;
909
- }
910
- el.innerHTML = list.map(ch => {
911
- const alias = (ch.alias || '').replace(/"/g, '&quot;');
912
- const name = (ch.name || ch.alias || '').replace(/</g, '&lt;').replace(/>/g, '&gt;');
913
- const desc = (ch.description || '').slice(0, 60).replace(/</g, '&lt;');
914
- return '<div class="msg" style="margin-bottom:12px"><strong>' + name + '</strong> <code>' + alias + '</code><div class="sub">' + desc + '</div><button class="btn-secondary join-channel-btn" data-alias="' + alias.replace(/@ch\//g, '') + '" style="margin-top:6px">加入</button></div>';
915
- }).join('');
916
- el.querySelectorAll('.join-channel-btn').forEach(btn => {
917
- btn.addEventListener('click', async () => {
918
- const alias = btn.dataset.alias;
919
- try {
920
- await api('/api/channels/join', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ alias }) });
921
- alert('加入成功');
922
- await loadChannels();
923
- showPanel('dmPanel');
924
- document.getElementById('mainHeader').innerHTML = '<strong>频道</strong>';
925
- } catch (e) {
926
- alert('加入失败: ' + e.message);
927
- }
928
- });
929
- });
930
- } catch (e) {
931
- el.innerHTML = '<div class="error">加载失败: ' + (e.message || '') + '</div>';
932
- }
933
- }
934
-
935
- function showCreateChannel() {
936
- showPanel('createChannelPanel');
937
- document.getElementById('mainHeader').innerHTML = '<strong>创建频道</strong>';
938
- document.getElementById('createChannelName').value = '';
939
- document.getElementById('createChannelAlias').value = '';
940
- document.getElementById('createChannelDesc').value = '';
941
- }
942
-
943
- async function createChannelSubmit() {
944
- const name = document.getElementById('createChannelName').value.trim();
945
- if (!name) { alert('请输入频道名称'); return; }
946
- const alias = document.getElementById('createChannelAlias').value.trim() || undefined;
947
- const description = document.getElementById('createChannelDesc').value.trim() || undefined;
948
- try {
949
- const data = await api('/api/channels/create', {
950
- method: 'POST',
951
- headers: { 'Content-Type': 'application/json' },
952
- body: JSON.stringify({ name, alias, description })
953
- });
954
- alert('创建成功');
955
- await loadChannels();
956
- if (data.conversation_id) selectChannel(data.conversation_id, data.alias || '');
957
- showPanel('dmPanel');
958
- } catch (e) {
959
- alert('创建失败: ' + e.message);
960
- }
961
- }
962
-
963
- function renderContactList() {
964
- const el = document.getElementById('contactList');
965
- if (!el) return;
966
- if (!contacts.length) {
967
- el.innerHTML = '<div class="empty">暂无联系人</div>';
968
- return;
969
- }
970
- el.innerHTML = contacts.map(c => {
971
- const name = (c.display_name || c.alias || c.did || '').slice(0, 28);
972
- const did = (c.did || '').replace(/"/g, '&quot;');
973
- const convId = (c.conversation_id || '').replace(/"/g, '&quot;');
974
- return '<div class="list-item contact-item" data-did="' + did + '" data-conv-id="' + convId + '">' + name + '</div>';
975
- }).join('');
976
- el.querySelectorAll('.contact-item').forEach(n => {
977
- n.addEventListener('click', () => {
978
- const did = n.dataset.did;
979
- const convId = n.dataset.convId;
980
- if (did) openConversationWithDid(did, convId);
981
- });
982
- });
983
- }
984
-
985
- async function openConversationWithDid(targetDid, knownConvId) {
986
- if (!targetDid) return;
987
- if (knownConvId) {
988
- await loadConversations();
989
- selectConv(knownConvId, targetDid);
990
- return;
991
- }
992
- try {
993
- const data = await api('/api/open', {
994
- method: 'POST',
995
- headers: { 'Content-Type': 'application/json' },
996
- body: JSON.stringify({ target_did: targetDid, send_contact_request: false })
997
- });
998
- if (data.conversation_id) {
999
- await loadConversations();
1000
- selectConv(data.conversation_id, targetDid);
1001
- }
1002
- } catch (e) {
1003
- alert('打开会话失败: ' + e.message);
1004
- }
1005
- }
1006
-
1007
- function renderConvList() {
1008
- const el = document.getElementById('convList');
1009
- if (!conversations.length) {
1010
- el.innerHTML = '<div class="empty">暂无会话<br>输入 DID 新建</div>';
1011
- return;
1012
- }
1013
- el.innerHTML = conversations.map(c => {
1014
- const name = c.display_name || c.target_did?.slice(0, 24) + '...';
1015
- const sub = c.trusted ? 'DM' : '待批准';
1016
- return '<div class="list-item' + (currentConv?.conversation_id === c.conversation_id ? ' active' : '') + '" data-id="' + c.conversation_id + '" data-did="' + (c.target_did||'') + '">' + name + '<div class="sub">' + sub + '</div></div>';
1017
- }).join('');
1018
- el.querySelectorAll('.list-item').forEach(n => n.addEventListener('click', () => selectConv(n.dataset.id, n.dataset.did)));
1019
- }
1020
-
1021
- async function selectConv(convId, targetDid) {
1022
- currentConv = { conversation_id: convId, target_did: targetDid, isChannel: false };
1023
- renderConvList();
1024
- renderChannelList();
1025
- showPanel('dmPanel');
1026
- document.getElementById('inputArea').style.display = 'flex';
1027
- document.getElementById('mainHeader').innerHTML = '<strong>' + (targetDid || convId).slice(0, 50) + '</strong>';
1028
- await loadMessages(convId);
1029
- }
1030
-
1031
- async function loadMessages(convId, since) {
1032
- const el = document.getElementById('messages');
1033
- if (!since) el.innerHTML = '';
1034
- try {
1035
- const data = await api('/api/conversations/' + encodeURIComponent(convId) + '/messages' + (since ? '?since_seq=' + since : ''));
1036
- const msgs = data.messages || [];
1037
- msgs.forEach(m => {
1038
- const div = document.createElement('div');
1039
- div.className = 'msg ' + (m.isMe ? 'me' : 'other');
1040
- const meta = new Date(m.ts_ms).toLocaleString() + ' ' + (m.isMe ? '(我)' : (m.sender_did || '').slice(0, 16) + '...');
1041
- let body = '';
1042
- if (m.schema === 'pingagent.text@1' && m.payload?.text) body = m.payload.text;
1043
- else body = JSON.stringify(m.payload || m).slice(0, 200);
1044
- div.innerHTML = '<div class="meta">' + meta + '</div>' + body.replace(/</g, '&lt;').replace(/>/g, '&gt;');
1045
- el.appendChild(div);
1046
- });
1047
- if (!since && msgs.length === 0) el.innerHTML = '<div class="empty">暂无消息</div>';
1048
- el.scrollTop = el.scrollHeight;
1049
- } catch (e) {
1050
- el.innerHTML = '<div class="error">加载失败: ' + (e.message || '未知错误') + '</div>';
1051
- }
1052
- }
1053
-
1054
- async function sendMessage() {
1055
- const input = document.getElementById('msgInput');
1056
- const text = input.value.trim();
1057
- if (!text || !currentConv) return;
1058
- try {
1059
- await api('/api/conversations/' + encodeURIComponent(currentConv.conversation_id) + '/send', {
1060
- method: 'POST',
1061
- headers: { 'Content-Type': 'application/json' },
1062
- body: JSON.stringify({ text })
1063
- });
1064
- input.value = '';
1065
- await loadMessages(currentConv.conversation_id);
1066
- } catch (e) {
1067
- document.getElementById('messages').innerHTML += '<div class="error">发送失败: ' + e.message + '</div>';
1068
- }
1069
- }
1070
-
1071
- async function openConversation() {
1072
- const input = document.getElementById('newDid');
1073
- const targetDid = input.value.trim();
1074
- if (!targetDid) return;
1075
- try {
1076
- const data = await api('/api/open', {
1077
- method: 'POST',
1078
- headers: { 'Content-Type': 'application/json' },
1079
- body: JSON.stringify({ target_did: targetDid, send_contact_request: true })
1080
- });
1081
- input.value = '';
1082
- await loadConversations();
1083
- if (data.conversation_id) selectConv(data.conversation_id, targetDid);
1084
- } catch (e) {
1085
- alert('打开失败: ' + e.message);
1086
- }
1087
- }
1088
-
1089
- document.getElementById('openConv').addEventListener('click', openConversation);
1090
- document.getElementById('newDid').addEventListener('keydown', e => { if (e.key === 'Enter') openConversation(); });
1091
- document.getElementById('sendBtn').addEventListener('click', sendMessage);
1092
- document.getElementById('msgInput').addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } });
1093
- document.getElementById('navFeed')?.addEventListener('click', () => { showPanel('feedPanel'); document.getElementById('mainHeader').innerHTML = '<strong>Feed</strong>'; loadFeedTimeline(); });
1094
- document.getElementById('navProfile')?.addEventListener('click', () => { showPanel('profilePanel'); document.getElementById('mainHeader').innerHTML = '<strong>编辑 Profile</strong>'; loadProfile(); });
1095
- document.getElementById('discoverChannelsBtn')?.addEventListener('click', discoverChannels);
1096
- document.getElementById('createChannelBtn')?.addEventListener('click', showCreateChannel);
1097
- document.getElementById('publishFeedBtn')?.addEventListener('click', publishFeed);
1098
- document.getElementById('saveProfileBtn')?.addEventListener('click', saveProfile);
1099
- document.getElementById('discoverSearchBtn')?.addEventListener('click', doDiscoverChannels);
1100
- document.getElementById('createChannelSubmitBtn')?.addEventListener('click', createChannelSubmit);
1101
- document.getElementById('createChannelBackBtn')?.addEventListener('click', () => { showPanel('discoverPanel'); document.getElementById('mainHeader').innerHTML = '<strong>发现频道</strong>'; });
1102
-
1103
- function showProfileCurrent() {
1104
- if (!profilePickerEl) return;
1105
- profilePickerEl.style.display = 'none';
1106
- if (profileCurrentEl) {
1107
- profileCurrentEl.style.display = 'flex';
1108
- const nameEl = document.getElementById('currentProfileName');
1109
- if (nameEl) nameEl.textContent = selectedProfile || '';
1110
- }
1111
- if (switchProfileBtn) {
1112
- switchProfileBtn.onclick = async () => {
1113
- await switchProfile();
1114
- };
1115
- }
1116
- }
1117
-
1118
- async function init() {
1119
- if (!profilePickerEl) {
1120
- await loadMe();
1121
- await loadConversations();
1122
- await loadContacts();
1123
- return;
1124
- }
1125
- const profiles = await loadProfiles();
1126
- if (profiles.length === 0) {
1127
- const header = document.getElementById('mainHeader');
1128
- if (header) header.innerHTML = '<strong>无可用 Profile,请先 pingagent init</strong>';
1129
- renderProfileList([]);
1130
- return;
1131
- }
1132
- if (profiles.length === 1 && !selectedProfile) {
1133
- selectedProfile = profiles[0].id;
1134
- sessionStorage.setItem('pingagent_web_profile', selectedProfile);
1135
- }
1136
- renderProfileList(profiles);
1137
- if (selectedProfile && profiles.some(p => p.id === selectedProfile)) {
1138
- showProfileCurrent();
1139
- await loadDataForProfile();
1140
- return;
1141
- }
1142
- showProfilePicker();
1143
- }
1144
- init();
1145
- </script>
1146
- </body>
1147
- </html>`;
1148
- }