@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.
- package/connection/message-router.js +7 -0
- package/context.js +1 -0
- package/index.js +5 -0
- package/package.json +1 -1
- package/yeaft/asset-outbox.js +124 -0
- package/yeaft/conversation/persist.js +14 -0
- package/yeaft/engine.js +11 -2
- package/yeaft/image-assets.js +91 -0
- package/yeaft/remote-image-download.js +199 -0
- package/yeaft/stop-hooks.js +12 -6
- package/yeaft/tools/grep.js +151 -30
- package/yeaft/tools/image-generation.js +14 -16
- package/yeaft/vp/registry.js +9 -2
- package/yeaft/vp/seed-defaults.js +79 -11
- package/yeaft/vp/seed-topup.js +62 -7
- package/yeaft/vp/stock-ids.js +1 -1
- package/yeaft/vp/vp-bridge.js +6 -2
- package/yeaft/vp/vp-crud.js +57 -7
- package/yeaft/vp/vp-store.js +23 -2
- package/yeaft/web-bridge.js +41 -5
- package/yeaft/work-center/bridge.js +2 -0
- package/yeaft/work-center/planner.js +2 -0
package/yeaft/vp/vp-crud.js
CHANGED
|
@@ -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
|
-
//
|
|
138
|
-
//
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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,
|
package/yeaft/vp/vp-store.js
CHANGED
|
@@ -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]
|
|
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
|
|
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
|
package/yeaft/web-bridge.js
CHANGED
|
@@ -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: {
|
|
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:
|
|
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:
|
|
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 || '',
|