@yeaft/webchat-agent 1.0.177 → 1.0.180

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.
@@ -97,7 +97,7 @@ function vpRolePathFor(libDir, vpId) {
97
97
  * Serialise a VP payload into role.md text with YAML frontmatter matching
98
98
  * the parser in vp-store.js.
99
99
  *
100
- * @param {{vpId:string, displayName?:string, role?:string, traits?:string[], modelHint?:string, persona?:string}} p
100
+ * @param {{vpId:string, displayName?:string, displayNameZh?:string, aliases?:string[], description?:string, descriptionZh?:string, role?:string, roleZh?:string, area?:string, traits?:string[], modelHint?:string, persona?:string, planInstruction?:string}} p
101
101
  * @returns {string}
102
102
  */
103
103
  export function buildRoleMd(p) {
@@ -105,6 +105,8 @@ export function buildRoleMd(p) {
105
105
  const name = p.displayName != null ? String(p.displayName) : id;
106
106
  const nameZh = p.displayNameZh != null ? String(p.displayNameZh) : '';
107
107
  const aliases = Array.isArray(p.aliases) ? p.aliases.map(a => String(a)).filter(Boolean) : [];
108
+ const description = p.description != null ? String(p.description) : '';
109
+ const descriptionZh = p.descriptionZh != null ? String(p.descriptionZh) : '';
108
110
  const role = p.role != null ? String(p.role) : '';
109
111
  const roleZh = p.roleZh != null ? String(p.roleZh) : '';
110
112
  // Taxonomy bucket. Optional + additive — written only when present so
@@ -116,10 +118,15 @@ export function buildRoleMd(p) {
116
118
 
117
119
  const lines = ['---', `id: ${id}`, `name: ${yamlScalar(name)}`];
118
120
  if (nameZh) lines.push(`nameZh: ${yamlScalar(nameZh)}`);
121
+ if (description) lines.push(`description: ${yamlScalar(description)}`);
122
+ if (descriptionZh) lines.push(`descriptionZh: ${yamlScalar(descriptionZh)}`);
119
123
  lines.push(`role: ${yamlScalar(role)}`);
120
124
  if (roleZh) lines.push(`roleZh: ${yamlScalar(roleZh)}`);
121
125
  if (area) lines.push(`area: ${yamlScalar(area)}`);
122
126
  if (modelHint) lines.push(`modelHint: ${modelHint}`);
127
+ if (p.planInstruction != null && String(p.planInstruction)) {
128
+ lines.push(`planInstruction: ${yamlScalar(String(p.planInstruction))}`);
129
+ }
123
130
  if (traits.length > 0) {
124
131
  lines.push('traits:');
125
132
  for (const t of traits) lines.push(` - ${yamlScalar(t)}`);
@@ -134,21 +141,58 @@ export function buildRoleMd(p) {
134
141
 
135
142
  function yamlScalar(v) {
136
143
  const s = String(v);
137
- // Quote anything that the minimal parser might mis-read: colons, leading
138
- // dashes, or surrounding whitespace. Plain text passes through unquoted.
139
- if (/^[\s]|[\s]$|^[-:]|[:#]/.test(s)) {
140
- return `"${s.replace(/"/g, '\\"')}"`;
144
+ // YAML single-quoted scalars keep backslashes and double quotes literal.
145
+ // Doubling an apostrophe is the only escape, and parseYamlScalar reverses
146
+ // exactly that operation. This also avoids interpreting legacy Windows
147
+ // paths such as `C:\temp` as JSON/YAML control escapes.
148
+ if (/^[\s]|[\s]$|^[-:]|[:#'"\\\n\r\t]/.test(s)) {
149
+ return `'${s.replace(/'/g, "''")}'`;
141
150
  }
142
151
  return s;
143
152
  }
144
153
 
154
+ function hasOwn(obj, key) {
155
+ return Object.prototype.hasOwnProperty.call(obj, key);
156
+ }
157
+
158
+ /**
159
+ * Merge an update payload with the persisted editable VP shape. Missing keys
160
+ * mean "old client does not know this field" and must be preserved; an
161
+ * explicitly supplied empty value remains a deliberate clear operation.
162
+ */
163
+ function mergeVpUpdate(current, payload, vpId) {
164
+ const fields = [
165
+ 'displayName',
166
+ 'displayNameZh',
167
+ 'aliases',
168
+ 'description',
169
+ 'descriptionZh',
170
+ 'role',
171
+ 'roleZh',
172
+ 'area',
173
+ 'traits',
174
+ 'modelHint',
175
+ 'persona',
176
+ 'planInstruction',
177
+ ];
178
+ const merged = { vpId };
179
+ for (const field of fields) {
180
+ merged[field] = hasOwn(payload, field) ? payload[field] : current[field];
181
+ }
182
+ return merged;
183
+ }
184
+
145
185
  /**
146
186
  * Create a new VP. Writes `<lib>/<vpId>/role.md` and ensures `memory/` dir.
147
187
  *
148
188
  * @param {object} payload
149
189
  * @param {string} payload.vpId
150
190
  * @param {string} [payload.displayName]
191
+ * @param {string} [payload.displayNameZh]
192
+ * @param {string} [payload.description]
193
+ * @param {string} [payload.descriptionZh]
151
194
  * @param {string} [payload.role]
195
+ * @param {string} [payload.roleZh]
152
196
  * @param {string[]} [payload.traits]
153
197
  * @param {'primary'|'fast'} [payload.modelHint]
154
198
  * @param {string} [payload.persona]
@@ -208,7 +252,10 @@ export function updateVp(payload, options = {}) {
208
252
  if (!existsSync(dir) || !existsSync(vpRolePathFor(libDir, vpId))) {
209
253
  throw new VpCrudError('not_found', vpId);
210
254
  }
211
- writeFileSync(vpRolePathFor(libDir, vpId), buildRoleMd({ ...payload, vpId }), 'utf-8');
255
+ const current = readVp(vpId, { libDir });
256
+ if (!current) throw new VpCrudError('not_found', vpId);
257
+ const merged = mergeVpUpdate(current, payload || {}, vpId);
258
+ writeFileSync(vpRolePathFor(libDir, vpId), buildRoleMd(merged), 'utf-8');
212
259
  return { vpId, dir };
213
260
  }
214
261
 
@@ -258,7 +305,7 @@ export function deleteVp(vpId, options = {}) {
258
305
  *
259
306
  * @param {string} vpId
260
307
  * @param {object} [options]
261
- * @returns {?{vpId:string, displayName:string, role:string, traits:string[], modelHint:?string, persona:string}}
308
+ * @returns {?{vpId:string, displayName:string, displayNameZh:string, aliases:string[], description:string, descriptionZh:string, role:string, roleZh:string, area:string, traits:string[], modelHint:?string, persona:string, planInstruction:string}}
262
309
  */
263
310
  export function readVp(vpId, options = {}) {
264
311
  const libDir = options.libDir || DEFAULT_VP_LIB_DIR;
@@ -279,8 +326,11 @@ export function readVp(vpId, options = {}) {
279
326
  displayName: String(meta.name || id),
280
327
  displayNameZh: typeof meta.nameZh === 'string' ? String(meta.nameZh) : '',
281
328
  aliases: Array.isArray(meta.aliases) ? meta.aliases.map(String) : [],
329
+ description: typeof meta.description === 'string' ? String(meta.description) : '',
330
+ descriptionZh: typeof meta.descriptionZh === 'string' ? String(meta.descriptionZh) : '',
282
331
  role: String(meta.role || ''),
283
332
  roleZh: typeof meta.roleZh === 'string' ? String(meta.roleZh) : '',
333
+ area: typeof meta.area === 'string' ? String(meta.area).trim() : '',
284
334
  traits: Array.isArray(meta.traits) ? meta.traits.map(String) : [],
285
335
  modelHint,
286
336
  persona: body,
@@ -29,7 +29,11 @@ import { createHash } from 'crypto';
29
29
  * @typedef {Object} VP
30
30
  * @property {string} id — VP id (default: dir name)
31
31
  * @property {string} name
32
+ * @property {string} nameZh — optional Chinese display name; '' if absent.
33
+ * @property {string} description — concise English capability summary for roster lists.
34
+ * @property {string} descriptionZh — optional Chinese capability summary; '' if absent.
32
35
  * @property {string} role
36
+ * @property {string} roleZh — optional Chinese role label; '' if absent.
33
37
  * @property {string} area — taxonomy bucket (e.g. 'philosophy', 'investing'); '' if absent.
34
38
  * Optional, additive: no consumer is required to dispatch on it.
35
39
  * Sidebar grouping by area is intentionally a future PR.
@@ -70,6 +74,21 @@ export function personaHash(persona) {
70
74
  * @param {string} source
71
75
  * @returns {{ meta: Record<string, any>, body: string }}
72
76
  */
77
+ function parseYamlScalar(raw) {
78
+ const value = String(raw || '').trim();
79
+ if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
80
+ // Compatibility for files written by the old CRUD serializer. It escaped
81
+ // only double quotes, not backslashes, so JSON.parse would corrupt valid
82
+ // Windows paths (`C:\temp` -> a tab). Undo only the escape the old writer
83
+ // actually introduced and leave every other backslash literal.
84
+ return value.slice(1, -1).replace(/\\"/g, '"');
85
+ }
86
+ if (value.length >= 2 && value.startsWith("'") && value.endsWith("'")) {
87
+ return value.slice(1, -1).replace(/''/g, "'");
88
+ }
89
+ return value;
90
+ }
91
+
73
92
  export function parseRoleMd(source) {
74
93
  const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
75
94
  if (!match) return { meta: {}, body: source };
@@ -84,7 +103,7 @@ export function parseRoleMd(source) {
84
103
  if (!line.trim()) continue;
85
104
  const listMatch = line.match(/^\s+-\s+(.+?)\s*$/);
86
105
  if (listMatch && currentList) {
87
- currentList.push(listMatch[1].replace(/^['"]|['"]$/g, ''));
106
+ currentList.push(parseYamlScalar(listMatch[1]));
88
107
  continue;
89
108
  }
90
109
  const kvMatch = line.match(/^([\w-]+):\s*(.*)$/);
@@ -95,7 +114,7 @@ export function parseRoleMd(source) {
95
114
  currentList = [];
96
115
  meta[key] = currentList;
97
116
  } else {
98
- meta[key] = value.replace(/^['"]|['"]$/g, '');
117
+ meta[key] = parseYamlScalar(value);
99
118
  currentList = null;
100
119
  }
101
120
  }
@@ -156,6 +175,8 @@ export function loadVpFromDir(dir) {
156
175
  aliases: Array.isArray(meta.aliases)
157
176
  ? meta.aliases.map(String).map(s => s.trim()).filter(Boolean)
158
177
  : [],
178
+ description: typeof meta.description === 'string' ? String(meta.description) : '',
179
+ descriptionZh: typeof meta.descriptionZh === 'string' ? String(meta.descriptionZh) : '',
159
180
  role: String(meta.role || ''),
160
181
  roleZh: typeof meta.roleZh === 'string' ? String(meta.roleZh) : '',
161
182
  // Taxonomy bucket for sidebar grouping / filtering. Absent for legacy
@@ -63,6 +63,7 @@ import {
63
63
  import { persistYeaftAttachments, attachmentsForPersistence, persistedAttachmentPreviewPayload } from './attachments.js';
64
64
  import { ConversationStore, parseSeqFromId } from './conversation/persist.js';
65
65
  import { isHiddenConversationRow } from './conversation/internal-control.js';
66
+ import { imageMetadataForPersistence } from './image-assets.js';
66
67
  import { sliceLastNTurns } from './turn-utils.js';
67
68
  import { pairSanitize } from './pair-sanitize.js';
68
69
  import { filterSnapshotForVp } from './snapshot-filter.js';
@@ -999,6 +1000,7 @@ function projectPersistedToHistoryEntry(m) {
999
1000
  if (m.id) entry.id = m.id;
1000
1001
  entry.threadId = m.threadId || m.turnId || 'main';
1001
1002
  if (m.turnId) entry.turnId = m.turnId;
1003
+ if (m.imageAssetAnchor) entry.imageAssetAnchor = true;
1002
1004
  if (m.sessionId) entry.sessionId = m.sessionId;
1003
1005
  if (m.clientMessageId) entry.clientMessageId = m.clientMessageId;
1004
1006
  if (m.speakerVpId) entry.speakerVpId = m.speakerVpId;
@@ -1016,8 +1018,9 @@ function projectPersistedToHistoryEntry(m) {
1016
1018
  if (m.isError) entry.isError = true;
1017
1019
  if (m.ts) entry.ts = m.ts;
1018
1020
  else if (m.time) entry.ts = m.time;
1021
+ if (Array.isArray(m.images) && m.images.length > 0) entry.images = m.images;
1019
1022
  if (Array.isArray(m.attachments) && m.attachments.length > 0) entry.attachments = m.attachments;
1020
- if ((entry.role === 'user' || entry.role === 'assistant') && !entry.content && !entry.attachments && !entry.toolCalls && !entry.toolSummaryCount) return null;
1023
+ if ((entry.role === 'user' || entry.role === 'assistant') && !entry.content && !entry.attachments && !entry.images && !entry.toolCalls && !entry.toolSummaryCount) return null;
1021
1024
  return entry;
1022
1025
  }
1023
1026
 
@@ -1106,7 +1109,9 @@ function projectVisibleHistoryChunkMessages(messages = []) {
1106
1109
  ...(m.clientMessageId ? { clientMessageId: m.clientMessageId } : {}),
1107
1110
  threadId: m.threadId || m.turnId || 'main',
1108
1111
  ...(m.turnId ? { turnId: m.turnId } : {}),
1112
+ ...(m.imageAssetAnchor === true ? { imageAssetAnchor: true } : {}),
1109
1113
  ...(Array.isArray(m.attachments) && m.attachments.length > 0 ? { attachments: hydrateHistoryAttachmentPreviews(m.attachments) } : {}),
1114
+ ...(Array.isArray(m.images) && m.images.length > 0 ? { images: m.images } : {}),
1110
1115
  ...(m.speakerVpId ? { speakerVpId: m.speakerVpId } : {}),
1111
1116
  ...(Number.isFinite(m.toolSummaryCount) && m.toolSummaryCount > 0
1112
1117
  ? { toolSummaryCount: m.toolSummaryCount }
@@ -1156,7 +1161,13 @@ function emitLegacyHistoryOutputFrames(replayEntries) {
1156
1161
  if (entry.speakerVpId) envelopeOpts.vpId = entry.speakerVpId;
1157
1162
  sendSessionOutputFrame({
1158
1163
  type: 'assistant',
1159
- message: { id: entry.id || null, content: [{ type: 'text', text: entry.content }] },
1164
+ message: {
1165
+ id: entry.id || null,
1166
+ content: [
1167
+ ...(entry.content ? [{ type: 'text', text: entry.content }] : []),
1168
+ ...((entry.images || []).map(image => ({ type: 'image_asset', image }))),
1169
+ ],
1170
+ },
1160
1171
  ts: entry.ts || null,
1161
1172
  }, envelopeOpts);
1162
1173
  if (Array.isArray(entry.toolCalls) && entry.toolCalls.length > 0) {
@@ -2851,6 +2862,7 @@ export function handleYeaftDeleteSession(msg) {
2851
2862
  try {
2852
2863
  const yeaftDir = ctx.CONFIG?.yeaftDir;
2853
2864
  const result = deleteSession(yeaftDir, sessionId);
2865
+ ctx.assetOutbox?.removeSession(sessionId);
2854
2866
  // Cascade: remove every persisted message stamped with this group id.
2855
2867
  // Hard delete (per user spec): no soft-archive, the bytes are gone.
2856
2868
  // Skipped silently if the session/store isn't initialized — the next
@@ -3299,12 +3311,34 @@ function handleEngineEvent(event, hctx) {
3299
3311
  }, envelope);
3300
3312
  break;
3301
3313
 
3302
- case 'tool_end':
3314
+ case 'tool_end': {
3315
+ const images = Array.isArray(event.displayImages) ? event.displayImages : [];
3316
+ const displayOutput = typeof event.output === 'string' ? event.output : JSON.stringify(event.output ?? '');
3317
+ for (const image of images) {
3318
+ const persistedImage = imageMetadataForPersistence(image);
3319
+ try {
3320
+ if (!ctx.assetOutbox) throw new Error('asset outbox is unavailable');
3321
+ const deliveryId = ctx.assetOutbox.enqueue({
3322
+ conversationId: yeaftConversationId,
3323
+ metadata: persistedImage,
3324
+ sessionId: hctx.sessionId,
3325
+ vpId: hctx.vpId,
3326
+ turnId: hctx.turnId,
3327
+ threadId: hctx.threadId || event.threadId,
3328
+ image,
3329
+ });
3330
+ if (!deliveryId) throw new Error('asset outbox did not persist the image');
3331
+ image.deliveryQueued = true;
3332
+ ctx.assetOutbox.drain().catch(err => console.warn('[AssetOutbox] drain failed:', err?.message || err));
3333
+ } catch (err) {
3334
+ console.warn('[AssetOutbox] failed to queue image:', err?.message || err);
3335
+ }
3336
+ }
3303
3337
  if (hctx.toolResultsAccum) {
3304
3338
  hctx.toolResultsAccum.push({
3305
3339
  role: 'tool',
3306
3340
  toolCallId: event.id,
3307
- content: typeof event.output === 'string' ? event.output : JSON.stringify(event.output ?? ''),
3341
+ content: displayOutput,
3308
3342
  isError: !!event.isError,
3309
3343
  });
3310
3344
  }
@@ -3330,7 +3364,7 @@ function handleEngineEvent(event, hctx) {
3330
3364
  tool_use_result: [{
3331
3365
  type: 'tool_result',
3332
3366
  tool_use_id: event.id,
3333
- content: event.output || '',
3367
+ content: displayOutput,
3334
3368
  is_error: event.isError || false,
3335
3369
  }],
3336
3370
  }, envelope);
@@ -3341,6 +3375,7 @@ function handleEngineEvent(event, hctx) {
3341
3375
  // streaming' flicker on every tool call. Hold the 'tool' state
3342
3376
  // until the next real event arrives.
3343
3377
  break;
3378
+ }
3344
3379
 
3345
3380
  case 'tool_result_update': {
3346
3381
  const content = typeof event.content === 'string'
@@ -6613,6 +6648,7 @@ export async function handleYeaftMcpReload(msg = {}) {
6613
6648
 
6614
6649
  export const __testHooks = {
6615
6650
  loadVisibleGroupHistoryPage,
6651
+ projectVisibleHistoryChunkMessages,
6616
6652
  persistInboundMessageOnceByMsgId,
6617
6653
  buildPendingRescueEnvelope,
6618
6654
  runYeaftSessionSendForTest(msg) {
@@ -66,6 +66,8 @@ async function getSettingsRuntime() {
66
66
  id: vp.id,
67
67
  name: vp.name || vp.id,
68
68
  nameZh: vp.nameZh || '',
69
+ description: vp.description || vp.role || '',
70
+ descriptionZh: vp.descriptionZh || vp.roleZh || vp.description || vp.role || '',
69
71
  role: vp.role || '',
70
72
  roleZh: vp.roleZh || '',
71
73
  area: vp.area || '',
@@ -6,6 +6,8 @@ function publicVp(vp) {
6
6
  id: vp.id,
7
7
  name: vp.name || vp.id,
8
8
  nameZh: vp.nameZh || '',
9
+ description: vp.description || vp.role || '',
10
+ descriptionZh: vp.descriptionZh || vp.roleZh || vp.description || vp.role || '',
9
11
  role: vp.role || '',
10
12
  roleZh: vp.roleZh || '',
11
13
  area: vp.area || '',