@pingagent/sdk 0.1.0 → 0.1.2

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/dist/index.d.ts CHANGED
@@ -151,6 +151,8 @@ interface ConversationEntry {
151
151
  target_did: string;
152
152
  trusted: boolean;
153
153
  created_at: number;
154
+ /** Server-side last message write time (ms). Omitted or null when unknown; client should fetch when missing. */
155
+ last_activity_at?: number | null;
154
156
  }
155
157
  interface ConversationListResponse {
156
158
  conversations: ConversationEntry[];
@@ -473,6 +475,8 @@ interface WsSubscriptionOptions {
473
475
  onMessage: (envelope: any, conversationId: string) => void;
474
476
  onControl?: (control: WsControlPayload, conversationId: string) => void;
475
477
  onError?: (err: Error) => void;
478
+ /** Called when a WebSocket opens or reopens; e.g. Skill can run catchUp to fill gaps. */
479
+ onOpen?: (conversationId: string) => void;
476
480
  }
477
481
  declare class WsSubscription {
478
482
  private opts;
package/dist/index.js CHANGED
@@ -16,7 +16,7 @@ import {
16
16
  loadIdentity,
17
17
  saveIdentity,
18
18
  updateStoredToken
19
- } from "./chunk-4SRPVWK4.js";
19
+ } from "./chunk-MDGELIR5.js";
20
20
  export {
21
21
  A2AAdapter,
22
22
  ContactManager,
@@ -9,6 +9,8 @@ import * as http from 'node:http';
9
9
  interface ProfileEntry {
10
10
  id: string;
11
11
  did?: string;
12
+ /** Server URL for this profile (from identity); used so local vs remote profiles work in one web session. */
13
+ serverUrl?: string;
12
14
  identityPath: string;
13
15
  storePath: string;
14
16
  }
@@ -5,7 +5,7 @@ import {
5
5
  ensureTokenValid,
6
6
  loadIdentity,
7
7
  updateStoredToken
8
- } from "./chunk-4SRPVWK4.js";
8
+ } from "./chunk-MDGELIR5.js";
9
9
 
10
10
  // src/web-server.ts
11
11
  import * as fs from "fs";
@@ -29,6 +29,7 @@ function listProfiles(rootDir) {
29
29
  profiles.push({
30
30
  id: "default",
31
31
  did: id.did,
32
+ serverUrl: id.serverUrl,
32
33
  identityPath: defaultIdentity,
33
34
  storePath: path.join(root, "store.db")
34
35
  });
@@ -45,7 +46,7 @@ function listProfiles(rootDir) {
45
46
  if (fs.existsSync(idPath)) {
46
47
  try {
47
48
  const id = loadIdentity(idPath);
48
- profiles.push({ id: name, did: id.did, identityPath: idPath, storePath: path.join(sub, "store.db") });
49
+ profiles.push({ id: name, did: id.did, serverUrl: id.serverUrl, identityPath: idPath, storePath: path.join(sub, "store.db") });
49
50
  } catch {
50
51
  profiles.push({ id: name, identityPath: idPath, storePath: path.join(sub, "store.db") });
51
52
  }
@@ -66,7 +67,7 @@ function listProfiles(rootDir) {
66
67
  if (fs.existsSync(idPath) && !profiles.some((p) => p.id === name)) {
67
68
  try {
68
69
  const id = loadIdentity(idPath);
69
- profiles.push({ id: name, did: id.did, identityPath: idPath, storePath: path.join(sub, "store.db") });
70
+ profiles.push({ id: name, did: id.did, serverUrl: id.serverUrl, identityPath: idPath, storePath: path.join(sub, "store.db") });
70
71
  } catch {
71
72
  profiles.push({ id: name, identityPath: idPath, storePath: path.join(sub, "store.db") });
72
73
  }
@@ -74,31 +75,34 @@ function listProfiles(rootDir) {
74
75
  }
75
76
  return profiles;
76
77
  }
77
- async function getContextForProfile(profile, serverUrl) {
78
- await ensureTokenValid(profile.identityPath, serverUrl);
78
+ var DEFAULT_SERVER_URL = "http://localhost:8787";
79
+ async function getContextForProfile(profile, defaultServerUrl) {
79
80
  const identity = loadIdentity(profile.identityPath);
81
+ const serverUrl = identity.serverUrl ?? defaultServerUrl ?? DEFAULT_SERVER_URL;
82
+ await ensureTokenValid(profile.identityPath, serverUrl);
83
+ const identityAfter = loadIdentity(profile.identityPath);
80
84
  const store = new LocalStore(profile.storePath);
81
85
  const contactManager = new ContactManager(store);
82
86
  const client = new PingAgentClient({
83
87
  serverUrl,
84
- identity,
85
- accessToken: identity.accessToken ?? "",
88
+ identity: identityAfter,
89
+ accessToken: identityAfter.accessToken ?? "",
86
90
  store,
87
91
  onTokenRefreshed: (token, expiresAt) => updateStoredToken(token, expiresAt, profile.identityPath)
88
92
  });
89
- return { client, contactManager, myDid: identity.did };
93
+ return { client, contactManager, myDid: identityAfter.did, serverUrl };
90
94
  }
91
95
  var clientCache = /* @__PURE__ */ new Map();
92
96
  async function startWebServer(opts) {
93
97
  const port = opts.port ?? DEFAULT_PORT;
94
- const serverUrl = opts.serverUrl;
98
+ const defaultServerUrl = opts.serverUrl ?? DEFAULT_SERVER_URL;
95
99
  const rootDir = opts.rootDir ? resolvePath(opts.rootDir) : resolvePath(DEFAULT_ROOT);
96
100
  let profiles;
97
101
  let fixedContext = null;
98
102
  if (opts.fixedIdentityPath && opts.fixedStorePath) {
99
103
  const identityPath = resolvePath(opts.fixedIdentityPath);
100
104
  const storePath = resolvePath(opts.fixedStorePath);
101
- fixedContext = await getContextForProfile({ id: "fixed", identityPath, storePath }, serverUrl);
105
+ fixedContext = await getContextForProfile({ id: "fixed", identityPath, storePath }, defaultServerUrl);
102
106
  profiles = [{ id: "fixed", did: fixedContext.myDid, identityPath, storePath }];
103
107
  } else {
104
108
  profiles = listProfiles(opts.rootDir ?? DEFAULT_ROOT);
@@ -106,7 +110,7 @@ async function startWebServer(opts) {
106
110
  throw new Error(`No identity found in ${rootDir}. Run: pingagent init`);
107
111
  }
108
112
  }
109
- const html = getHtml(!!opts.fixedIdentityPath);
113
+ const html = getHtml();
110
114
  const server = http.createServer(async (req, res) => {
111
115
  const url = new URL(req.url || "/", `http://${req.headers.host}`);
112
116
  const pathname = url.pathname;
@@ -131,7 +135,7 @@ async function startWebServer(opts) {
131
135
  try {
132
136
  if (pathname === "/api/profiles" || pathname === "/api/profiles/") {
133
137
  res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
134
- res.end(JSON.stringify({ profiles: profiles.map((p) => ({ id: p.id, did: p.did })) }));
138
+ res.end(JSON.stringify({ profiles: profiles.map((p) => ({ id: p.id, did: p.did, server: p.serverUrl })) }));
135
139
  return;
136
140
  }
137
141
  let ctx = fixedContext;
@@ -147,11 +151,11 @@ async function startWebServer(opts) {
147
151
  else {
148
152
  const p = profiles.find((x) => x.id === pid);
149
153
  if (!p) throw new Error(`Unknown profile: ${pid}`);
150
- ctx = await getContextForProfile(p, serverUrl);
154
+ ctx = await getContextForProfile(p, defaultServerUrl);
151
155
  clientCache.set(pid, ctx);
152
156
  }
153
157
  }
154
- const result = await handleApi(pathname, req, ctx.client, ctx.contactManager, ctx.myDid, serverUrl);
158
+ const result = await handleApi(pathname, req, ctx.client, ctx.contactManager, ctx.myDid, ctx.serverUrl);
155
159
  res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
156
160
  res.end(JSON.stringify(result));
157
161
  } catch (err) {
@@ -167,10 +171,10 @@ async function startWebServer(opts) {
167
171
  console.log(`PingAgent Web: http://127.0.0.1:${port}`);
168
172
  if (fixedContext) {
169
173
  console.log(` DID: ${fixedContext.myDid}`);
174
+ console.log(` Server: ${fixedContext.serverUrl}`);
170
175
  } else {
171
- console.log(` Profiles: ${profiles.map((p) => p.id).join(", ")}`);
176
+ console.log(` Profiles: ${profiles.map((p) => p.id).join(", ")} (each uses its identity server URL)`);
172
177
  }
173
- console.log(` Server: ${serverUrl}`);
174
178
  });
175
179
  return server;
176
180
  }
@@ -384,11 +388,11 @@ function readBody(req) {
384
388
  req.on("error", reject);
385
389
  });
386
390
  }
387
- function getHtml(fixedOnly) {
388
- const profilePicker = fixedOnly ? "" : `
391
+ function getHtml(_fixedOnly) {
392
+ const profilePicker = `
389
393
  <div class="profile-picker" id="profilePicker">
390
394
  <div class="profile-label">\u9009\u62E9 Profile \u767B\u5F55</div>
391
- <div class="profile-list" id="profileList"></div>
395
+ <div class="profile-list" id="profileList"><p class="profile-loading">\u52A0\u8F7D\u4E2D...</p></div>
392
396
  </div>
393
397
  <div class="profile-current" id="profileCurrent" style="display:none">
394
398
  <span>\u5F53\u524D: <strong id="currentProfileName"></strong></span>
@@ -433,8 +437,11 @@ function getHtml(fixedOnly) {
433
437
  .error { color: #f87171; font-size: 13px; padding: 8px 0; }
434
438
  .profile-picker { padding: 12px 16px; border-bottom: 1px solid #27272a; }
435
439
  .profile-label { font-size: 12px; color: #71717a; margin-bottom: 8px; }
440
+ .profile-list { min-height: 160px; }
441
+ .profile-loading { font-size: 12px; color: #71717a; margin: 0; }
436
442
  .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; }
437
443
  .profile-list .profile-btn:hover { background: #3f3f46; }
444
+ .profile-list .profile-btn.selected { border: 1px solid #3b82f6; background: #1e3a5f; }
438
445
  .profile-list .profile-btn .sub { font-size: 11px; color: #71717a; }
439
446
  .profile-current { padding: 12px 16px; border-bottom: 1px solid #27272a; display: flex; align-items: center; justify-content: space-between; gap: 8px; }
440
447
  .profile-current span { font-size: 13px; color: #a1a1aa; }
@@ -563,41 +570,72 @@ function getHtml(fixedOnly) {
563
570
  let profilesCache = null;
564
571
 
565
572
  function showProfileCurrent() {
566
- if (profileCurrentEl && profilePickerEl) {
573
+ if (profileCurrentEl) {
567
574
  profileCurrentEl.style.display = 'flex';
568
- profilePickerEl.style.display = 'none';
569
575
  const nameEl = document.getElementById('currentProfileName');
570
576
  if (nameEl) nameEl.textContent = selectedProfile || '';
571
577
  }
578
+ if (profilePickerEl) profilePickerEl.style.display = '';
579
+ highlightSelectedProfile();
572
580
  }
573
581
 
574
582
  function showProfilePicker() {
575
- if (profileCurrentEl && profilePickerEl) {
576
- profileCurrentEl.style.display = 'none';
577
- profilePickerEl.style.display = '';
578
- currentConv = null;
579
- document.getElementById('inputArea').style.display = 'none';
580
- document.getElementById('messages').innerHTML = '';
581
- document.getElementById('mainHeader').innerHTML = '<strong>\u9009\u62E9 Profile \u767B\u5F55</strong>';
582
- }
583
+ if (profileCurrentEl) profileCurrentEl.style.display = 'none';
584
+ if (profilePickerEl) profilePickerEl.style.display = '';
585
+ currentConv = null;
586
+ document.getElementById('inputArea').style.display = 'none';
587
+ document.getElementById('messages').innerHTML = '';
588
+ document.getElementById('mainHeader').innerHTML = '<strong>\u9009\u62E9 Profile \u767B\u5F55</strong>';
589
+ }
590
+
591
+ function highlightSelectedProfile() {
592
+ const listEl = document.getElementById('profileList');
593
+ if (!listEl) return;
594
+ listEl.querySelectorAll('.profile-btn').forEach(btn => {
595
+ btn.classList.toggle('selected', btn.dataset.id === selectedProfile);
596
+ });
583
597
  }
584
598
 
585
599
  async function loadProfiles() {
586
- if (!profilePickerEl) return [];
600
+ const listEl = document.getElementById('profileList');
601
+ if (!profilePickerEl || !listEl) return [];
587
602
  if (profilesCache) return profilesCache;
588
- const { profiles } = await fetch(API + '/api/profiles')
589
- .then(r => r.json())
590
- .catch(() => ({ profiles: [] }));
591
- profilesCache = Array.isArray(profiles) ? profiles : [];
592
- return profilesCache;
603
+ try {
604
+ const ctrl = new AbortController();
605
+ const t = setTimeout(() => ctrl.abort(), 8000);
606
+ const r = await fetch('/api/profiles', { signal: ctrl.signal });
607
+ clearTimeout(t);
608
+ if (!r.ok) {
609
+ listEl.innerHTML = '<p class="profile-loading">\u8BF7\u6C42\u5931\u8D25 ' + r.status + '</p>';
610
+ return [];
611
+ }
612
+ const data = await r.json().catch(() => ({}));
613
+ const list = Array.isArray(data.profiles) ? data.profiles : [];
614
+ profilesCache = list;
615
+ if (list.length === 0) listEl.innerHTML = '<p class="profile-loading">\u672A\u627E\u5230 profile</p>';
616
+ return profilesCache;
617
+ } catch (e) {
618
+ const msg = (e && e.name === 'AbortError') ? '\u8BF7\u6C42\u8D85\u65F6' : '\u52A0\u8F7D\u5931\u8D25\uFF0C\u8BF7\u5237\u65B0';
619
+ listEl.innerHTML = '<p class="profile-loading">' + msg + '</p>';
620
+ return [];
621
+ }
593
622
  }
594
623
 
595
624
  function renderProfileList(profiles) {
596
625
  const listEl = document.getElementById('profileList');
597
626
  if (!listEl) return;
598
- listEl.innerHTML = (profiles || []).map(p =>
599
- '<button class="profile-btn" data-id="' + p.id + '">' + p.id + '<div class="sub">' + (p.did || '').slice(0, 32) + '...</div></button>'
600
- ).join('');
627
+ const list = profiles || [];
628
+ if (list.length === 0) {
629
+ listEl.innerHTML = '<p class="profile-loading">\u672A\u627E\u5230 profile</p>';
630
+ return;
631
+ }
632
+ listEl.innerHTML = list.map(p => {
633
+ var s = (p.server && typeof p.server === 'string') ? p.server : '';
634
+ var serverLabel = s.indexOf('://') >= 0 ? s.split('://')[1].split('/')[0] : (s || 'local');
635
+ var didShort = (p.did && typeof p.did === 'string') ? p.did.slice(0, 24) + '...' : '';
636
+ var sel = p.id === selectedProfile ? ' selected' : '';
637
+ return '<button class="profile-btn' + sel + '" data-id="' + p.id + '">' + p.id + '<div class="sub">' + serverLabel + ' \xB7 ' + didShort + '<' + '/div><' + '/button>';
638
+ }).join('');
601
639
  listEl.querySelectorAll('.profile-btn').forEach(btn => {
602
640
  btn.addEventListener('click', async () => {
603
641
  selectedProfile = btn.dataset.id || null;
@@ -1011,19 +1049,21 @@ function getHtml(fixedOnly) {
1011
1049
  }
1012
1050
  const profiles = await loadProfiles();
1013
1051
  if (profiles.length === 0) {
1014
- document.getElementById('mainHeader').innerHTML = '<strong>\u65E0\u53EF\u7528 Profile\uFF0C\u8BF7\u5148 pingagent init</strong>';
1052
+ const header = document.getElementById('mainHeader');
1053
+ if (header) header.innerHTML = '<strong>\u65E0\u53EF\u7528 Profile\uFF0C\u8BF7\u5148 pingagent init</strong>';
1054
+ renderProfileList([]);
1015
1055
  return;
1016
1056
  }
1017
1057
  if (profiles.length === 1 && !selectedProfile) {
1018
1058
  selectedProfile = profiles[0].id;
1019
1059
  sessionStorage.setItem('pingagent_web_profile', selectedProfile);
1020
1060
  }
1061
+ renderProfileList(profiles);
1021
1062
  if (selectedProfile && profiles.some(p => p.id === selectedProfile)) {
1022
1063
  showProfileCurrent();
1023
1064
  await loadDataForProfile();
1024
1065
  return;
1025
1066
  }
1026
- renderProfileList(profiles);
1027
1067
  showProfilePicker();
1028
1068
  }
1029
1069
  init();
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@pingagent/sdk",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "license": "MIT",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
8
8
  "repository": {
9
9
  "type": "git",
10
- "url": "https://github.com/pingagent-chat/PingAgent.git"
10
+ "url": "https://github.com/PingAgent/PingAgent.git"
11
11
  },
12
12
  "type": "module",
13
13
  "main": "dist/index.js",
@@ -26,9 +26,9 @@
26
26
  "commander": "^13.0.0",
27
27
  "uuid": "^11.0.0",
28
28
  "ws": "^8.0.0",
29
- "@pingagent/protocol": "0.1.0",
30
- "@pingagent/a2a": "0.1.0",
31
- "@pingagent/schemas": "0.1.0"
29
+ "@pingagent/protocol": "0.1.1",
30
+ "@pingagent/a2a": "0.1.1",
31
+ "@pingagent/schemas": "0.1.1"
32
32
  },
33
33
  "devDependencies": {
34
34
  "@types/better-sqlite3": "^7.6.0",
package/src/client.ts CHANGED
@@ -62,6 +62,8 @@ export interface ConversationEntry {
62
62
  target_did: string;
63
63
  trusted: boolean;
64
64
  created_at: number;
65
+ /** Server-side last message write time (ms). Omitted or null when unknown; client should fetch when missing. */
66
+ last_activity_at?: number | null;
65
67
  }
66
68
 
67
69
  export interface ConversationListResponse {
package/src/web-server.ts CHANGED
@@ -28,6 +28,8 @@ function resolvePath(p: string): string {
28
28
  export interface ProfileEntry {
29
29
  id: string;
30
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;
31
33
  identityPath: string;
32
34
  storePath: string;
33
35
  }
@@ -55,6 +57,7 @@ function listProfiles(rootDir: string): ProfileEntry[] {
55
57
  profiles.push({
56
58
  id: 'default',
57
59
  did: id.did,
60
+ serverUrl: id.serverUrl,
58
61
  identityPath: defaultIdentity,
59
62
  storePath: path.join(root, 'store.db'),
60
63
  });
@@ -73,7 +76,7 @@ function listProfiles(rootDir: string): ProfileEntry[] {
73
76
  if (fs.existsSync(idPath)) {
74
77
  try {
75
78
  const id = loadIdentity(idPath);
76
- profiles.push({ id: name, did: id.did, identityPath: idPath, storePath: path.join(sub, 'store.db') });
79
+ profiles.push({ id: name, did: id.did, serverUrl: id.serverUrl, identityPath: idPath, storePath: path.join(sub, 'store.db') });
77
80
  } catch {
78
81
  profiles.push({ id: name, identityPath: idPath, storePath: path.join(sub, 'store.db') });
79
82
  }
@@ -81,7 +84,7 @@ function listProfiles(rootDir: string): ProfileEntry[] {
81
84
  }
82
85
  }
83
86
 
84
- // root/<dir>/identity.json (e.g. receiver, agent1) - skip if profiles dir doesn't exist
87
+ // root/<dir>/identity.json (e.g. receiver, agent1, remote1c)
85
88
  let names: string[];
86
89
  try {
87
90
  names = fs.readdirSync(root);
@@ -96,7 +99,7 @@ function listProfiles(rootDir: string): ProfileEntry[] {
96
99
  if (fs.existsSync(idPath) && !profiles.some((p) => p.id === name)) {
97
100
  try {
98
101
  const id = loadIdentity(idPath);
99
- profiles.push({ id: name, did: id.did, identityPath: idPath, storePath: path.join(sub, 'store.db') });
102
+ profiles.push({ id: name, did: id.did, serverUrl: id.serverUrl, identityPath: idPath, storePath: path.join(sub, 'store.db') });
100
103
  } catch {
101
104
  profiles.push({ id: name, identityPath: idPath, storePath: path.join(sub, 'store.db') });
102
105
  }
@@ -106,38 +109,43 @@ function listProfiles(rootDir: string): ProfileEntry[] {
106
109
  return profiles;
107
110
  }
108
111
 
112
+ /** Default server URL when identity has none (e.g. legacy). */
113
+ const DEFAULT_SERVER_URL = 'http://localhost:8787';
114
+
109
115
  async function getContextForProfile(
110
116
  profile: ProfileEntry,
111
- serverUrl: string,
112
- ): Promise<{ client: PingAgentClient; contactManager: ContactManager; myDid: string }> {
113
- await ensureTokenValid(profile.identityPath, serverUrl);
117
+ defaultServerUrl: string,
118
+ ): Promise<{ client: PingAgentClient; contactManager: ContactManager; myDid: string; serverUrl: string }> {
114
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);
115
123
  const store = new LocalStore(profile.storePath);
116
124
  const contactManager = new ContactManager(store);
117
125
  const client = new PingAgentClient({
118
126
  serverUrl,
119
- identity,
120
- accessToken: identity.accessToken ?? '',
127
+ identity: identityAfter,
128
+ accessToken: identityAfter.accessToken ?? '',
121
129
  store,
122
130
  onTokenRefreshed: (token, expiresAt) => updateStoredToken(token, expiresAt, profile.identityPath),
123
131
  });
124
- return { client, contactManager, myDid: identity.did };
132
+ return { client, contactManager, myDid: identityAfter.did, serverUrl };
125
133
  }
126
134
 
127
- const clientCache = new Map<string, { client: PingAgentClient; contactManager: ContactManager; myDid: string }>();
135
+ const clientCache = new Map<string, { client: PingAgentClient; contactManager: ContactManager; myDid: string; serverUrl: string }>();
128
136
 
129
137
  export async function startWebServer(opts: WebServerOptions): Promise<http.Server> {
130
138
  const port = opts.port ?? DEFAULT_PORT;
131
- const serverUrl = opts.serverUrl;
139
+ const defaultServerUrl = opts.serverUrl ?? DEFAULT_SERVER_URL;
132
140
  const rootDir = opts.rootDir ? resolvePath(opts.rootDir) : resolvePath(DEFAULT_ROOT);
133
141
 
134
142
  let profiles: ProfileEntry[];
135
- let fixedContext: { client: PingAgentClient; contactManager: ContactManager; myDid: string } | null = null;
143
+ let fixedContext: { client: PingAgentClient; contactManager: ContactManager; myDid: string; serverUrl: string } | null = null;
136
144
 
137
145
  if (opts.fixedIdentityPath && opts.fixedStorePath) {
138
146
  const identityPath = resolvePath(opts.fixedIdentityPath);
139
147
  const storePath = resolvePath(opts.fixedStorePath);
140
- fixedContext = await getContextForProfile({ id: 'fixed', identityPath, storePath }, serverUrl);
148
+ fixedContext = await getContextForProfile({ id: 'fixed', identityPath, storePath }, defaultServerUrl);
141
149
  profiles = [{ id: 'fixed', did: fixedContext.myDid, identityPath, storePath }];
142
150
  } else {
143
151
  profiles = listProfiles(opts.rootDir ?? DEFAULT_ROOT);
@@ -146,7 +154,7 @@ export async function startWebServer(opts: WebServerOptions): Promise<http.Serve
146
154
  }
147
155
  }
148
156
 
149
- const html = getHtml(!!opts.fixedIdentityPath);
157
+ const html = getHtml();
150
158
 
151
159
  const server = http.createServer(async (req, res) => {
152
160
  const url = new URL(req.url || '/', `http://${req.headers.host}`);
@@ -176,7 +184,7 @@ export async function startWebServer(opts: WebServerOptions): Promise<http.Serve
176
184
  try {
177
185
  if (pathname === '/api/profiles' || pathname === '/api/profiles/') {
178
186
  res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
179
- res.end(JSON.stringify({ profiles: profiles.map((p) => ({ id: p.id, did: p.did })) }));
187
+ res.end(JSON.stringify({ profiles: profiles.map((p) => ({ id: p.id, did: p.did, server: p.serverUrl })) }));
180
188
  return;
181
189
  }
182
190
  let ctx = fixedContext;
@@ -194,11 +202,11 @@ export async function startWebServer(opts: WebServerOptions): Promise<http.Serve
194
202
  else {
195
203
  const p = profiles.find((x) => x.id === pid);
196
204
  if (!p) throw new Error(`Unknown profile: ${pid}`);
197
- ctx = await getContextForProfile(p, serverUrl);
205
+ ctx = await getContextForProfile(p, defaultServerUrl);
198
206
  clientCache.set(pid, ctx);
199
207
  }
200
208
  }
201
- const result = await handleApi(pathname, req, ctx.client, ctx.contactManager, ctx.myDid, serverUrl);
209
+ const result = await handleApi(pathname, req, ctx.client, ctx.contactManager, ctx.myDid, ctx.serverUrl);
202
210
  res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
203
211
  res.end(JSON.stringify(result));
204
212
  } catch (err: any) {
@@ -216,10 +224,10 @@ export async function startWebServer(opts: WebServerOptions): Promise<http.Serve
216
224
  console.log(`PingAgent Web: http://127.0.0.1:${port}`);
217
225
  if (fixedContext) {
218
226
  console.log(` DID: ${fixedContext.myDid}`);
227
+ console.log(` Server: ${fixedContext.serverUrl}`);
219
228
  } else {
220
- console.log(` Profiles: ${profiles.map((p) => p.id).join(', ')}`);
229
+ console.log(` Profiles: ${profiles.map((p) => p.id).join(', ')} (each uses its identity server URL)`);
221
230
  }
222
- console.log(` Server: ${serverUrl}`);
223
231
  });
224
232
 
225
233
  return server;
@@ -455,13 +463,11 @@ function readBody(req: http.IncomingMessage): Promise<Record<string, unknown>> {
455
463
  });
456
464
  }
457
465
 
458
- function getHtml(fixedOnly: boolean): string {
459
- const profilePicker = fixedOnly
460
- ? ''
461
- : `
466
+ function getHtml(_fixedOnly?: boolean): string {
467
+ const profilePicker = `
462
468
  <div class="profile-picker" id="profilePicker">
463
469
  <div class="profile-label">选择 Profile 登录</div>
464
- <div class="profile-list" id="profileList"></div>
470
+ <div class="profile-list" id="profileList"><p class="profile-loading">加载中...</p></div>
465
471
  </div>
466
472
  <div class="profile-current" id="profileCurrent" style="display:none">
467
473
  <span>当前: <strong id="currentProfileName"></strong></span>
@@ -506,8 +512,11 @@ function getHtml(fixedOnly: boolean): string {
506
512
  .error { color: #f87171; font-size: 13px; padding: 8px 0; }
507
513
  .profile-picker { padding: 12px 16px; border-bottom: 1px solid #27272a; }
508
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; }
509
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; }
510
518
  .profile-list .profile-btn:hover { background: #3f3f46; }
519
+ .profile-list .profile-btn.selected { border: 1px solid #3b82f6; background: #1e3a5f; }
511
520
  .profile-list .profile-btn .sub { font-size: 11px; color: #71717a; }
512
521
  .profile-current { padding: 12px 16px; border-bottom: 1px solid #27272a; display: flex; align-items: center; justify-content: space-between; gap: 8px; }
513
522
  .profile-current span { font-size: 13px; color: #a1a1aa; }
@@ -636,41 +645,72 @@ function getHtml(fixedOnly: boolean): string {
636
645
  let profilesCache = null;
637
646
 
638
647
  function showProfileCurrent() {
639
- if (profileCurrentEl && profilePickerEl) {
648
+ if (profileCurrentEl) {
640
649
  profileCurrentEl.style.display = 'flex';
641
- profilePickerEl.style.display = 'none';
642
650
  const nameEl = document.getElementById('currentProfileName');
643
651
  if (nameEl) nameEl.textContent = selectedProfile || '';
644
652
  }
653
+ if (profilePickerEl) profilePickerEl.style.display = '';
654
+ highlightSelectedProfile();
645
655
  }
646
656
 
647
657
  function showProfilePicker() {
648
- if (profileCurrentEl && profilePickerEl) {
649
- profileCurrentEl.style.display = 'none';
650
- profilePickerEl.style.display = '';
651
- currentConv = null;
652
- document.getElementById('inputArea').style.display = 'none';
653
- document.getElementById('messages').innerHTML = '';
654
- document.getElementById('mainHeader').innerHTML = '<strong>选择 Profile 登录</strong>';
655
- }
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
+ });
656
672
  }
657
673
 
658
674
  async function loadProfiles() {
659
- if (!profilePickerEl) return [];
675
+ const listEl = document.getElementById('profileList');
676
+ if (!profilePickerEl || !listEl) return [];
660
677
  if (profilesCache) return profilesCache;
661
- const { profiles } = await fetch(API + '/api/profiles')
662
- .then(r => r.json())
663
- .catch(() => ({ profiles: [] }));
664
- profilesCache = Array.isArray(profiles) ? profiles : [];
665
- 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
+ }
666
697
  }
667
698
 
668
699
  function renderProfileList(profiles) {
669
700
  const listEl = document.getElementById('profileList');
670
701
  if (!listEl) return;
671
- listEl.innerHTML = (profiles || []).map(p =>
672
- '<button class="profile-btn" data-id="' + p.id + '">' + p.id + '<div class="sub">' + (p.did || '').slice(0, 32) + '...</div></button>'
673
- ).join('');
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('');
674
714
  listEl.querySelectorAll('.profile-btn').forEach(btn => {
675
715
  btn.addEventListener('click', async () => {
676
716
  selectedProfile = btn.dataset.id || null;
@@ -1084,19 +1124,21 @@ function getHtml(fixedOnly: boolean): string {
1084
1124
  }
1085
1125
  const profiles = await loadProfiles();
1086
1126
  if (profiles.length === 0) {
1087
- document.getElementById('mainHeader').innerHTML = '<strong>无可用 Profile,请先 pingagent init</strong>';
1127
+ const header = document.getElementById('mainHeader');
1128
+ if (header) header.innerHTML = '<strong>无可用 Profile,请先 pingagent init</strong>';
1129
+ renderProfileList([]);
1088
1130
  return;
1089
1131
  }
1090
1132
  if (profiles.length === 1 && !selectedProfile) {
1091
1133
  selectedProfile = profiles[0].id;
1092
1134
  sessionStorage.setItem('pingagent_web_profile', selectedProfile);
1093
1135
  }
1136
+ renderProfileList(profiles);
1094
1137
  if (selectedProfile && profiles.some(p => p.id === selectedProfile)) {
1095
1138
  showProfileCurrent();
1096
1139
  await loadDataForProfile();
1097
1140
  return;
1098
1141
  }
1099
- renderProfileList(profiles);
1100
1142
  showProfilePicker();
1101
1143
  }
1102
1144
  init();
@@ -19,6 +19,8 @@ export interface WsSubscriptionOptions {
19
19
  onMessage: (envelope: any, conversationId: string) => void;
20
20
  onControl?: (control: WsControlPayload, conversationId: string) => void;
21
21
  onError?: (err: Error) => void;
22
+ /** Called when a WebSocket opens or reopens; e.g. Skill can run catchUp to fill gaps. */
23
+ onOpen?: (conversationId: string) => void;
22
24
  }
23
25
 
24
26
  const RECONNECT_BASE_MS = 1000;
@@ -106,6 +108,7 @@ export class WsSubscription {
106
108
 
107
109
  ws.on('open', () => {
108
110
  this.reconnectAttempts.set(conversationId, 0);
111
+ this.opts.onOpen?.(conversationId);
109
112
  // ws_connected will arrive as first message
110
113
  });
111
114