koishi-plugin-chatluna-usage 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/echarts-CZNhpw9M.js +68 -0
- package/dist/index-Bvy3XN9m.js +8 -0
- package/dist/index.js +1 -0
- package/dist/model-pie-Ba0jUDul.js +1 -0
- package/dist/model-success-Bj37bQPf.js +1 -0
- package/dist/style.css +1 -0
- package/lib/index.cjs +424 -0
- package/lib/index.d.ts +311 -0
- package/lib/index.mjs +396 -0
- package/package.json +79 -0
package/lib/index.cjs
ADDED
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
3
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
4
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
5
|
+
var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name2 in all)
|
|
8
|
+
__defProp(target, name2, { get: all[name2], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
ChatLunaUsage: () => ChatLunaUsage,
|
|
24
|
+
Config: () => Config,
|
|
25
|
+
apply: () => apply,
|
|
26
|
+
cleanupUsage: () => cleanupUsage,
|
|
27
|
+
default: () => index_default,
|
|
28
|
+
inject: () => inject,
|
|
29
|
+
name: () => name,
|
|
30
|
+
queryUsage: () => queryUsage
|
|
31
|
+
});
|
|
32
|
+
module.exports = __toCommonJS(index_exports);
|
|
33
|
+
var import_koishi = require("koishi");
|
|
34
|
+
var import_plugin_console = require("@koishijs/plugin-console");
|
|
35
|
+
var import_path = require("path");
|
|
36
|
+
var logger = new import_koishi.Logger("chatluna-usage");
|
|
37
|
+
var ChatLunaUsage = class extends import_plugin_console.DataService {
|
|
38
|
+
constructor(ctx, config) {
|
|
39
|
+
super(ctx, "chatluna_usage", {
|
|
40
|
+
immediate: true
|
|
41
|
+
});
|
|
42
|
+
this.config = config;
|
|
43
|
+
ctx.database.extend(
|
|
44
|
+
"chatluna_usage",
|
|
45
|
+
{
|
|
46
|
+
id: "unsigned",
|
|
47
|
+
source: { type: "char", length: 128 },
|
|
48
|
+
callType: { type: "char", length: 20 },
|
|
49
|
+
platform: { type: "char", length: 128 },
|
|
50
|
+
chatPlatform: { type: "char", length: 128, nullable: true },
|
|
51
|
+
model: { type: "char", length: 255 },
|
|
52
|
+
usageMetadata: {
|
|
53
|
+
type: "json",
|
|
54
|
+
nullable: false,
|
|
55
|
+
initial: {
|
|
56
|
+
input_tokens: 0,
|
|
57
|
+
output_tokens: 0,
|
|
58
|
+
total_tokens: 0
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
estimated: "boolean",
|
|
62
|
+
success: "boolean",
|
|
63
|
+
createdAt: { type: "timestamp", nullable: false },
|
|
64
|
+
conversationId: { type: "char", length: 255, nullable: true },
|
|
65
|
+
requestId: { type: "char", length: 255, nullable: true },
|
|
66
|
+
userId: { type: "char", length: 255, nullable: true },
|
|
67
|
+
guildId: { type: "char", length: 255, nullable: true }
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
autoInc: true,
|
|
71
|
+
primary: "id",
|
|
72
|
+
indexes: ["createdAt", "source", "model", "guildId"]
|
|
73
|
+
}
|
|
74
|
+
);
|
|
75
|
+
ctx.on("chatluna/model-usage", async (usage) => {
|
|
76
|
+
try {
|
|
77
|
+
await ctx.database.create("chatluna_usage", {
|
|
78
|
+
source: usage.source,
|
|
79
|
+
callType: usage.callType,
|
|
80
|
+
platform: usage.platform,
|
|
81
|
+
chatPlatform: usage.context?.chatPlatform ?? null,
|
|
82
|
+
model: usage.model,
|
|
83
|
+
usageMetadata: usage.usageMetadata,
|
|
84
|
+
estimated: usage.estimated,
|
|
85
|
+
success: usage.success,
|
|
86
|
+
createdAt: usage.createdAt,
|
|
87
|
+
conversationId: usage.context?.conversationId ?? null,
|
|
88
|
+
requestId: usage.context?.requestId ?? null,
|
|
89
|
+
userId: usage.context?.userId ?? null,
|
|
90
|
+
guildId: usage.context?.guildId ?? null
|
|
91
|
+
});
|
|
92
|
+
if (config.webui) await this.refresh();
|
|
93
|
+
} catch (e) {
|
|
94
|
+
logger.error(e);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
if (!config.webui) return;
|
|
98
|
+
ctx.inject(["console"], (ctx2) => {
|
|
99
|
+
ctx2.console.addListener(
|
|
100
|
+
"chatluna-usage/query",
|
|
101
|
+
async (input) => this.query(input)
|
|
102
|
+
);
|
|
103
|
+
ctx2.console.addListener(
|
|
104
|
+
"chatluna-usage/list",
|
|
105
|
+
async (input) => this.list(input)
|
|
106
|
+
);
|
|
107
|
+
ctx2.console.addListener(
|
|
108
|
+
"chatluna-usage/cleanup",
|
|
109
|
+
async (before) => {
|
|
110
|
+
await this.cleanup(before ? new Date(before) : void 0);
|
|
111
|
+
await this.refresh();
|
|
112
|
+
return { success: true };
|
|
113
|
+
}
|
|
114
|
+
);
|
|
115
|
+
ctx2.console.addEntry({
|
|
116
|
+
dev: (0, import_path.resolve)(__dirname, "../client/index.ts"),
|
|
117
|
+
prod: (0, import_path.resolve)(__dirname, "../dist")
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
static {
|
|
122
|
+
__name(this, "ChatLunaUsage");
|
|
123
|
+
}
|
|
124
|
+
async get() {
|
|
125
|
+
return await this.query();
|
|
126
|
+
}
|
|
127
|
+
async query(input = {}) {
|
|
128
|
+
const rows = await this.search(input);
|
|
129
|
+
const groupBy = input.groupBy ?? "model";
|
|
130
|
+
const sortBy = input.sortBy ?? "totalTokens";
|
|
131
|
+
const desc = input.desc ?? true;
|
|
132
|
+
const groups = /* @__PURE__ */ new Map();
|
|
133
|
+
const models = /* @__PURE__ */ new Map();
|
|
134
|
+
const sources = /* @__PURE__ */ new Map();
|
|
135
|
+
const timeline = /* @__PURE__ */ new Map();
|
|
136
|
+
const modelTimeline = /* @__PURE__ */ new Map();
|
|
137
|
+
const totals = {
|
|
138
|
+
key: "total",
|
|
139
|
+
label: "全部用量",
|
|
140
|
+
calls: 0,
|
|
141
|
+
successfulCalls: 0,
|
|
142
|
+
failedCalls: 0,
|
|
143
|
+
inputTokens: 0,
|
|
144
|
+
outputTokens: 0,
|
|
145
|
+
totalTokens: 0,
|
|
146
|
+
estimatedTokens: 0,
|
|
147
|
+
cachedTokens: 0,
|
|
148
|
+
reasoningTokens: 0,
|
|
149
|
+
successRate: 0
|
|
150
|
+
};
|
|
151
|
+
for (const row of rows) {
|
|
152
|
+
const key = this.groupKey(row, groupBy);
|
|
153
|
+
const item = groups.get(key) ?? {
|
|
154
|
+
key,
|
|
155
|
+
label: this.groupLabel(key, groupBy),
|
|
156
|
+
platform: groupBy === "model" ? row.platform : void 0,
|
|
157
|
+
calls: 0,
|
|
158
|
+
successfulCalls: 0,
|
|
159
|
+
failedCalls: 0,
|
|
160
|
+
inputTokens: 0,
|
|
161
|
+
outputTokens: 0,
|
|
162
|
+
totalTokens: 0,
|
|
163
|
+
estimatedTokens: 0,
|
|
164
|
+
cachedTokens: 0,
|
|
165
|
+
reasoningTokens: 0,
|
|
166
|
+
successRate: 0
|
|
167
|
+
};
|
|
168
|
+
const model = models.get(row.model) ?? {
|
|
169
|
+
key: row.model,
|
|
170
|
+
label: row.model,
|
|
171
|
+
platform: row.platform,
|
|
172
|
+
calls: 0,
|
|
173
|
+
successfulCalls: 0,
|
|
174
|
+
failedCalls: 0,
|
|
175
|
+
inputTokens: 0,
|
|
176
|
+
outputTokens: 0,
|
|
177
|
+
totalTokens: 0,
|
|
178
|
+
estimatedTokens: 0,
|
|
179
|
+
cachedTokens: 0,
|
|
180
|
+
reasoningTokens: 0,
|
|
181
|
+
successRate: 0
|
|
182
|
+
};
|
|
183
|
+
const source = sources.get(row.source) ?? {
|
|
184
|
+
key: row.source,
|
|
185
|
+
label: row.source,
|
|
186
|
+
calls: 0,
|
|
187
|
+
successfulCalls: 0,
|
|
188
|
+
failedCalls: 0,
|
|
189
|
+
inputTokens: 0,
|
|
190
|
+
outputTokens: 0,
|
|
191
|
+
totalTokens: 0,
|
|
192
|
+
estimatedTokens: 0,
|
|
193
|
+
cachedTokens: 0,
|
|
194
|
+
reasoningTokens: 0,
|
|
195
|
+
successRate: 0
|
|
196
|
+
};
|
|
197
|
+
const date = this.dateKey(row.createdAt, input.period ?? "day");
|
|
198
|
+
const point = timeline.get(date) ?? {
|
|
199
|
+
date,
|
|
200
|
+
calls: 0,
|
|
201
|
+
inputTokens: 0,
|
|
202
|
+
outputTokens: 0,
|
|
203
|
+
totalTokens: 0,
|
|
204
|
+
cachedTokens: 0,
|
|
205
|
+
reasoningTokens: 0
|
|
206
|
+
};
|
|
207
|
+
this.add(row, item);
|
|
208
|
+
this.add(row, model);
|
|
209
|
+
this.add(row, source);
|
|
210
|
+
this.add(row, totals);
|
|
211
|
+
point.calls += 1;
|
|
212
|
+
point.inputTokens += row.usageMetadata.input_tokens;
|
|
213
|
+
point.outputTokens += row.usageMetadata.output_tokens;
|
|
214
|
+
point.totalTokens += row.usageMetadata.total_tokens;
|
|
215
|
+
point.cachedTokens += (row.usageMetadata.input_token_details?.cache_read ?? 0) + (row.usageMetadata.input_token_details?.cache_creation ?? 0);
|
|
216
|
+
point.reasoningTokens += row.usageMetadata.output_token_details?.reasoning ?? 0;
|
|
217
|
+
if (!modelTimeline.has(row.model))
|
|
218
|
+
modelTimeline.set(row.model, /* @__PURE__ */ new Map());
|
|
219
|
+
modelTimeline.get(row.model).set(date, (modelTimeline.get(row.model).get(date) ?? 0) + 1);
|
|
220
|
+
groups.set(key, item);
|
|
221
|
+
models.set(row.model, model);
|
|
222
|
+
sources.set(row.source, source);
|
|
223
|
+
timeline.set(date, point);
|
|
224
|
+
}
|
|
225
|
+
this.finish(totals);
|
|
226
|
+
return {
|
|
227
|
+
query: this.withDefaults(input),
|
|
228
|
+
totals,
|
|
229
|
+
groups: [...groups.values()].map((row) => this.finish(row)).sort((a, b) => {
|
|
230
|
+
const diff = a[sortBy] - b[sortBy];
|
|
231
|
+
return desc ? -diff : diff;
|
|
232
|
+
}),
|
|
233
|
+
models: [...models.values()].map((row) => this.finish(row)).sort((a, b) => b.calls - a.calls),
|
|
234
|
+
sources: [...sources.values()].map((row) => this.finish(row)).sort((a, b) => b.calls - a.calls),
|
|
235
|
+
timeline: [...timeline.values()].sort(
|
|
236
|
+
(a, b) => a.date.localeCompare(b.date)
|
|
237
|
+
),
|
|
238
|
+
modelTimeline: [...modelTimeline.entries()].map(
|
|
239
|
+
([model, dates]) => ({
|
|
240
|
+
model,
|
|
241
|
+
points: [...dates.entries()].map(([date, calls]) => ({ date, calls })).sort((a, b) => a.date.localeCompare(b.date))
|
|
242
|
+
})
|
|
243
|
+
),
|
|
244
|
+
list: this.pageRows(rows, input)
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
async list(input = {}) {
|
|
248
|
+
const rows = await this.search(input);
|
|
249
|
+
return this.pageRows(rows, input);
|
|
250
|
+
}
|
|
251
|
+
async cleanup(before) {
|
|
252
|
+
await this.ctx.database.remove(
|
|
253
|
+
"chatluna_usage",
|
|
254
|
+
before ? { createdAt: { $lt: before } } : {}
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
async search(input) {
|
|
258
|
+
const query = this.withDefaults(input);
|
|
259
|
+
const where = {
|
|
260
|
+
createdAt: { $gte: query.start, $lt: query.end }
|
|
261
|
+
};
|
|
262
|
+
if (query.source) where.source = query.source;
|
|
263
|
+
if (query.model) where.model = query.model;
|
|
264
|
+
if (query.platform) where.platform = query.platform;
|
|
265
|
+
if (query.callType) where.callType = query.callType;
|
|
266
|
+
if (query.success != null) where.success = query.success;
|
|
267
|
+
if (query.estimated != null) where.estimated = query.estimated;
|
|
268
|
+
const rows = await this.ctx.database.get(
|
|
269
|
+
"chatluna_usage",
|
|
270
|
+
where
|
|
271
|
+
);
|
|
272
|
+
if (!query.chatPlatform && !query.guildId && !query.userId && !query.keyword) {
|
|
273
|
+
return rows;
|
|
274
|
+
}
|
|
275
|
+
return rows.filter((row) => {
|
|
276
|
+
if (query.chatPlatform && !(row.chatPlatform ?? "").includes(query.chatPlatform)) {
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
if (query.guildId && !(row.guildId ?? "").includes(query.guildId)) {
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
if (query.userId && !(row.userId ?? "").includes(query.userId)) {
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
if (!query.keyword) return true;
|
|
286
|
+
return [
|
|
287
|
+
row.source,
|
|
288
|
+
row.callType,
|
|
289
|
+
row.platform,
|
|
290
|
+
row.chatPlatform,
|
|
291
|
+
row.model,
|
|
292
|
+
row.conversationId,
|
|
293
|
+
row.requestId,
|
|
294
|
+
row.userId,
|
|
295
|
+
row.guildId
|
|
296
|
+
].filter(Boolean).some((value) => value.includes(query.keyword));
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
withDefaults(input) {
|
|
300
|
+
const period = input.period ?? "day";
|
|
301
|
+
const end = input.end ? new Date(input.end) : /* @__PURE__ */ new Date();
|
|
302
|
+
const start = input.start ? new Date(input.start) : period === "year" ? new Date(end.getFullYear() - 1, end.getMonth(), end.getDate()) : period === "month" ? new Date(end.getFullYear(), end.getMonth() - 11, 1) : new Date(+end - this.config.recentDays * import_koishi.Time.day);
|
|
303
|
+
return {
|
|
304
|
+
...input,
|
|
305
|
+
period,
|
|
306
|
+
groupBy: input.groupBy ?? "model",
|
|
307
|
+
sortBy: input.sortBy ?? "totalTokens",
|
|
308
|
+
desc: input.desc ?? true,
|
|
309
|
+
page: input.page ?? 1,
|
|
310
|
+
pageSize: input.pageSize ?? this.config.pageSize,
|
|
311
|
+
listSortBy: input.listSortBy ?? "createdAt",
|
|
312
|
+
listDesc: input.listDesc ?? true,
|
|
313
|
+
start,
|
|
314
|
+
end
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
pageRows(rows, input) {
|
|
318
|
+
const query = this.withDefaults(input);
|
|
319
|
+
const sorted = rows.map((row) => ({
|
|
320
|
+
...row,
|
|
321
|
+
inputTokens: row.usageMetadata.input_tokens,
|
|
322
|
+
outputTokens: row.usageMetadata.output_tokens,
|
|
323
|
+
totalTokens: row.usageMetadata.total_tokens,
|
|
324
|
+
estimated: row.estimated,
|
|
325
|
+
cachedTokens: (row.usageMetadata.input_token_details?.cache_read ?? 0) + (row.usageMetadata.input_token_details?.cache_creation ?? 0),
|
|
326
|
+
reasoningTokens: row.usageMetadata.output_token_details?.reasoning ?? 0
|
|
327
|
+
})).sort((a, b) => {
|
|
328
|
+
const left = a[query.listSortBy];
|
|
329
|
+
const right = b[query.listSortBy];
|
|
330
|
+
let diff;
|
|
331
|
+
if (left instanceof Date && right instanceof Date) {
|
|
332
|
+
diff = +left - +right;
|
|
333
|
+
} else if (typeof left === "string" && typeof right === "string") {
|
|
334
|
+
diff = left.localeCompare(right);
|
|
335
|
+
} else {
|
|
336
|
+
diff = Number(left) - Number(right);
|
|
337
|
+
}
|
|
338
|
+
return query.listDesc ? -diff : diff;
|
|
339
|
+
});
|
|
340
|
+
const start = (query.page - 1) * query.pageSize;
|
|
341
|
+
return {
|
|
342
|
+
total: sorted.length,
|
|
343
|
+
page: query.page,
|
|
344
|
+
pageSize: query.pageSize,
|
|
345
|
+
rows: sorted.slice(start, start + query.pageSize)
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
add(row, item) {
|
|
349
|
+
item.calls += 1;
|
|
350
|
+
if (row.success) item.successfulCalls += 1;
|
|
351
|
+
else item.failedCalls += 1;
|
|
352
|
+
item.inputTokens += row.usageMetadata.input_tokens;
|
|
353
|
+
item.outputTokens += row.usageMetadata.output_tokens;
|
|
354
|
+
item.totalTokens += row.usageMetadata.total_tokens;
|
|
355
|
+
item.cachedTokens += (row.usageMetadata.input_token_details?.cache_read ?? 0) + (row.usageMetadata.input_token_details?.cache_creation ?? 0);
|
|
356
|
+
item.reasoningTokens += row.usageMetadata.output_token_details?.reasoning ?? 0;
|
|
357
|
+
if (row.estimated)
|
|
358
|
+
item.estimatedTokens += row.usageMetadata.total_tokens;
|
|
359
|
+
if (!item.lastSeen || row.createdAt > item.lastSeen)
|
|
360
|
+
item.lastSeen = row.createdAt;
|
|
361
|
+
}
|
|
362
|
+
finish(item) {
|
|
363
|
+
item.successRate = item.calls ? item.successfulCalls / item.calls : 0;
|
|
364
|
+
return item;
|
|
365
|
+
}
|
|
366
|
+
groupKey(row, groupBy) {
|
|
367
|
+
if (groupBy === "guild") return row.guildId ?? "private";
|
|
368
|
+
if (groupBy === "chatPlatform") return row.chatPlatform ?? "unknown";
|
|
369
|
+
return row[groupBy];
|
|
370
|
+
}
|
|
371
|
+
groupLabel(key, groupBy) {
|
|
372
|
+
if (groupBy === "guild" && key === "private") return "私聊/未知群";
|
|
373
|
+
if (groupBy === "chatPlatform" && key === "unknown")
|
|
374
|
+
return "未知聊天平台";
|
|
375
|
+
return key;
|
|
376
|
+
}
|
|
377
|
+
dateKey(date, period) {
|
|
378
|
+
const y = date.getFullYear();
|
|
379
|
+
const m = String(date.getMonth() + 1).padStart(2, "0");
|
|
380
|
+
const d = String(date.getDate()).padStart(2, "0");
|
|
381
|
+
if (period === "year") return String(y);
|
|
382
|
+
if (period === "month") return `${y}-${m}`;
|
|
383
|
+
return `${y}-${m}-${d}`;
|
|
384
|
+
}
|
|
385
|
+
};
|
|
386
|
+
((ChatLunaUsage2) => {
|
|
387
|
+
ChatLunaUsage2.Config = import_koishi.Schema.object({
|
|
388
|
+
recentDays: import_koishi.Schema.natural().description("默认统计最近几天的数据。").default(30),
|
|
389
|
+
pageSize: import_koishi.Schema.natural().description("调用明细分页大小。").default(50),
|
|
390
|
+
webui: import_koishi.Schema.boolean().description("启用 Web UI 控制台用量面板。").default(true)
|
|
391
|
+
});
|
|
392
|
+
ChatLunaUsage2.inject = ["chatluna", "database"];
|
|
393
|
+
})(ChatLunaUsage || (ChatLunaUsage = {}));
|
|
394
|
+
var index_default = ChatLunaUsage;
|
|
395
|
+
async function queryUsage(ctx, source) {
|
|
396
|
+
const result = await ctx.chatluna_usage.query({ groupBy: "source" });
|
|
397
|
+
if (!source) return result.groups;
|
|
398
|
+
return result.groups.filter((row) => row.key === source);
|
|
399
|
+
}
|
|
400
|
+
__name(queryUsage, "queryUsage");
|
|
401
|
+
async function cleanupUsage(ctx, before) {
|
|
402
|
+
await ctx.chatluna_usage.cleanup(before);
|
|
403
|
+
}
|
|
404
|
+
__name(cleanupUsage, "cleanupUsage");
|
|
405
|
+
function apply(ctx, config) {
|
|
406
|
+
ctx.plugin(ChatLunaUsage, config);
|
|
407
|
+
}
|
|
408
|
+
__name(apply, "apply");
|
|
409
|
+
var Config = ChatLunaUsage.Config;
|
|
410
|
+
var inject = {
|
|
411
|
+
required: ["chatluna", "database"],
|
|
412
|
+
optional: ["console"]
|
|
413
|
+
};
|
|
414
|
+
var name = "chatluna-usage";
|
|
415
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
416
|
+
0 && (module.exports = {
|
|
417
|
+
ChatLunaUsage,
|
|
418
|
+
Config,
|
|
419
|
+
apply,
|
|
420
|
+
cleanupUsage,
|
|
421
|
+
inject,
|
|
422
|
+
name,
|
|
423
|
+
queryUsage
|
|
424
|
+
});
|