agentworkshop 0.3.0 → 0.4.1
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/LICENSE +91 -0
- package/README-zh.md +431 -424
- package/README.md +10 -3
- package/app/components/AppSidebar.vue +1 -0
- package/app/pages/plugins/index.vue +258 -0
- package/app/plugins/aw-plugins.client.ts +87 -47
- package/cli/commands/plugin.mjs +57 -7
- package/docs/plugins.md +133 -63
- package/docs/sdk.md +265 -0
- package/i18n/locales/en.ts +17 -0
- package/i18n/locales/zh-CN.ts +17 -0
- package/package.json +4 -2
- package/scripts/_dbg-app-shot.mjs +50 -0
- package/scripts/_dbg-docs-site-shot.mjs +32 -0
- package/scripts/home-bootstrap.mjs +26 -1
- package/sdk/client.mjs +6 -2
- package/sdk/examples/line-sentinel/client.mjs +31 -0
- package/sdk/examples/line-sentinel/index.mjs +102 -0
- package/sdk/examples/ops-notifier/client.mjs +23 -0
- package/sdk/examples/ops-notifier/index.mjs +34 -0
- package/server/api/workshop/plugins/[name]/disable.post.ts +19 -0
- package/server/api/workshop/plugins/[name]/enable.post.ts +19 -0
- package/server/api/workshop/plugins/[name]/index.get.ts +14 -0
- package/server/api/workshop/plugins/index.get.ts +17 -0
- package/server/data/daqs.json +68 -46
- package/server/data/dcw-lines.json +7 -0
- package/server/data/dcw-products.json +7 -0
- package/server/data/dcw-recipes.json +20 -0
- package/server/data/dcw-rollback.json +1 -1
- package/server/data/dcw-runs.json +25 -18
- package/server/data/dcws.json +21 -0
- package/server/data/line-runs.json +30 -20
- package/server/services/workshop/plugins/host.mjs +177 -49
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// ============================================================
|
|
2
|
-
// AgentWorkShop 插件宿主 —— 发现 / 装载 /
|
|
2
|
+
// AgentWorkShop 插件宿主 —— 发现 / 装载 / 启停状态 / 热重载 / 路由表
|
|
3
3
|
// ------------------------------------------------------------
|
|
4
4
|
// 目录(与 aw commands 同哲学):
|
|
5
5
|
// project: <repo>/.AgentWorkShop/plugins/<name>/index.mjs
|
|
@@ -7,10 +7,15 @@
|
|
|
7
7
|
// 契约:入口导出普通对象 { name, version?, description?, setup(ctx)?,
|
|
8
8
|
// client?: './client.mjs', routes?: [{method,path,handler}] }
|
|
9
9
|
// —— ctx 由宿主注入,插件运行时零导入依赖(sdk/ 供类型与显式糖)。
|
|
10
|
+
//
|
|
11
|
+
// 启停状态机:配置根 plugins-state.json { version, updatedAt, disabled: string[] }
|
|
12
|
+
// · 装载时跳过 disabled 插件(manifest 仍可见,enabled:false)
|
|
13
|
+
// · 状态文件变化(fs.watch)→ 热重载:全部 dispose/解绑 → 重新装载
|
|
14
|
+
// · CLI(aw plugin enable/disable) 与 Web 设置页均只写状态文件,服务自感知
|
|
10
15
|
// 错误隔离:单插件装载/执行失败记入 failures,绝不拖垮主服务。
|
|
11
16
|
// ============================================================
|
|
12
|
-
import { existsSync, readdirSync, readFileSync, watch } from 'node:fs'
|
|
13
|
-
import { join, resolve } from 'node:path'
|
|
17
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, watch, writeFileSync } from 'node:fs'
|
|
18
|
+
import { dirname, join, resolve } from 'node:path'
|
|
14
19
|
import { pathToFileURL } from 'node:url'
|
|
15
20
|
import { HookBus, createPluginContext, createRouteTable, validatePluginModule } from '@/sdk/index.mjs'
|
|
16
21
|
|
|
@@ -18,6 +23,7 @@ const g = globalThis
|
|
|
18
23
|
|
|
19
24
|
function log() {
|
|
20
25
|
return {
|
|
26
|
+
debug: (...a) => console.log('[aw-plugins][debug]', ...a),
|
|
21
27
|
info: (...a) => console.log('[aw-plugins]', ...a),
|
|
22
28
|
warn: (...a) => console.warn('[aw-plugins]', ...a),
|
|
23
29
|
error: (...a) => console.error('[aw-plugins]', ...a),
|
|
@@ -35,6 +41,7 @@ function modePaths(cwd) {
|
|
|
35
41
|
return {
|
|
36
42
|
projectDir: isRepo ? join(cwd, '.AgentWorkShop', 'plugins') : null,
|
|
37
43
|
userDir: join(process.env.AW_HOME && String(process.env.AW_HOME).trim() ? String(process.env.AW_HOME).trim() : defaultHome(), 'plugins'),
|
|
44
|
+
homeDir: process.env.AW_HOME && String(process.env.AW_HOME).trim() ? String(process.env.AW_HOME).trim() : defaultHome(),
|
|
38
45
|
}
|
|
39
46
|
}
|
|
40
47
|
|
|
@@ -42,6 +49,29 @@ function pathToUrl(p) {
|
|
|
42
49
|
return pathToFileURL(resolve(p)).href
|
|
43
50
|
}
|
|
44
51
|
|
|
52
|
+
// ---- 启停状态(单一事实源:<配置根>/plugins-state.json;CLI/Web/宿主三方读写) ----
|
|
53
|
+
export function statePathFor(homeDir) {
|
|
54
|
+
return join(homeDir, 'plugins-state.json')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function readDisabledSet(homeDir) {
|
|
58
|
+
try {
|
|
59
|
+
const j = JSON.parse(readFileSync(statePathFor(homeDir), 'utf8'))
|
|
60
|
+
return new Set(Array.isArray(j.disabled) ? j.disabled : [])
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return new Set()
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function writeDisabledSet(homeDir, disabled) {
|
|
68
|
+
const p = statePathFor(homeDir)
|
|
69
|
+
mkdirSync(dirname(p), { recursive: true })
|
|
70
|
+
const tmp = `${p}.${process.pid}.tmp`
|
|
71
|
+
writeFileSync(tmp, `${JSON.stringify({ version: 1, updatedAt: new Date().toISOString(), disabled: [...disabled] }, null, 2)}\n`, 'utf8')
|
|
72
|
+
renameSync(tmp, p)
|
|
73
|
+
}
|
|
74
|
+
|
|
45
75
|
/** 发现两个作用域下的插件入口(project 同名覆盖 user) */
|
|
46
76
|
export function discoverPluginDirs(cwd = process.cwd()) {
|
|
47
77
|
const { projectDir, userDir } = modePaths(cwd)
|
|
@@ -62,12 +92,11 @@ export function discoverPluginDirs(cwd = process.cwd()) {
|
|
|
62
92
|
|
|
63
93
|
/**
|
|
64
94
|
* 装载插件宿主(idempotent;nitro 启动期调用一次)。
|
|
65
|
-
* 装载 = 动态 import 入口 → 形态校验 → createPluginContext → setup(ctx)
|
|
66
|
-
* → 收集 routes/client → emit plugin:host:init
|
|
67
95
|
*/
|
|
68
96
|
export async function initPluginHost({ cwd = process.cwd(), packageRoot } = {}) {
|
|
69
97
|
if (g.__awPluginHost) return g.__awPluginHost
|
|
70
98
|
const logger = log()
|
|
99
|
+
const homeDir = modePaths(cwd).homeDir
|
|
71
100
|
const host = {
|
|
72
101
|
bus: new HookBus({
|
|
73
102
|
name: 'aw-plugins',
|
|
@@ -75,17 +104,20 @@ export async function initPluginHost({ cwd = process.cwd(), packageRoot } = {})
|
|
|
75
104
|
}),
|
|
76
105
|
routes: createRouteTable(),
|
|
77
106
|
plugins: new Map(),
|
|
78
|
-
disposables: new Map(), // name → fn[](
|
|
107
|
+
disposables: new Map(), // name → fn[](ctx.onDispose 登记)
|
|
108
|
+
hookOffs: new Map(), // name → off[](ctx.hooks.on 登记;热重载时解绑)
|
|
79
109
|
failures: [],
|
|
80
110
|
initedAt: null,
|
|
111
|
+
cwd,
|
|
112
|
+
packageRoot,
|
|
81
113
|
logger,
|
|
82
114
|
}
|
|
83
115
|
g.__awPluginHost = host
|
|
84
116
|
|
|
85
|
-
// 有效配置(
|
|
117
|
+
// 有效配置(只读面;引擎/模式解析从运行根动态加载)
|
|
86
118
|
let config = null
|
|
87
119
|
let settingsPath = null
|
|
88
|
-
let paths = { home:
|
|
120
|
+
let paths = { home: homeDir, configRoot: join(cwd, '.AgentWorkShop'), dataDir: join(cwd, '.AgentWorkShop', 'data') }
|
|
89
121
|
try {
|
|
90
122
|
const homeMod = await import(pathToUrl(join(cwd, 'shared', 'config', 'home.mjs')))
|
|
91
123
|
const rm = homeMod.resolveRunMode({ cwd, packageRoot, env: process.env })
|
|
@@ -102,8 +134,9 @@ export async function initPluginHost({ cwd = process.cwd(), packageRoot } = {})
|
|
|
102
134
|
host.logger.warn('配置引擎加载降级(插件 ctx.config 将为空):', err?.message)
|
|
103
135
|
}
|
|
104
136
|
host.config = config
|
|
105
|
-
|
|
106
|
-
//
|
|
137
|
+
|
|
138
|
+
// 自环 origin:PORT env(prod:start.mjs 注入 / dev:dev-guard 注入 CLI 显式值)权威;
|
|
139
|
+
// nitro listen 钩子兜底回填
|
|
107
140
|
const argPort = (() => {
|
|
108
141
|
const argv = process.argv
|
|
109
142
|
const i = argv.indexOf('--port')
|
|
@@ -117,38 +150,21 @@ export async function initPluginHost({ cwd = process.cwd(), packageRoot } = {})
|
|
|
117
150
|
host.logger.info(`自环 origin 就绪: ${selfOrigin}`)
|
|
118
151
|
}
|
|
119
152
|
host.selfOrigin = () => selfOrigin
|
|
120
|
-
host.logger.info(`自环 origin: ${selfOrigin}
|
|
153
|
+
host.logger.info(`自环 origin: ${selfOrigin}`)
|
|
121
154
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
watch(settingsPath, () => {
|
|
127
|
-
clearTimeout(debounce)
|
|
128
|
-
debounce = setTimeout(async () => {
|
|
129
|
-
try {
|
|
130
|
-
if (host.config && existsSync(settingsPath)) {
|
|
131
|
-
const homeMod = await import(pathToUrl(join(cwd, 'shared', 'config', 'home.mjs')))
|
|
132
|
-
const rm = homeMod.resolveRunMode({ cwd, packageRoot, env: process.env })
|
|
133
|
-
const engine = await import(pathToUrl(join((rm.mode === 'repo' ? rm.root : packageRoot ?? rm.root) ?? cwd, 'shared', 'config', 'engine.mjs')))
|
|
134
|
-
const fresh = engine.loadEffective({ configPath: rm.configPath, settingsPath: rm.settingsPath, env: process.env })
|
|
135
|
-
Object.assign(host.config.effective, fresh.effective)
|
|
136
|
-
host.config.sources = fresh.sources
|
|
137
|
-
}
|
|
138
|
-
await host.bus.emit('config:changed', { at: new Date().toISOString() })
|
|
139
|
-
host.logger.info('配置变更已广播(config:changed)')
|
|
140
|
-
}
|
|
141
|
-
catch (err) {
|
|
142
|
-
host.logger.warn('config:changed 处理失败:', err?.message)
|
|
143
|
-
}
|
|
144
|
-
}, 300)
|
|
145
|
-
})
|
|
146
|
-
}
|
|
147
|
-
catch { /* fs.watch 不可用时插件可经轮询自行感知 */ }
|
|
148
|
-
}
|
|
155
|
+
await loadAllPlugins(host, { config, settingsPath, paths })
|
|
156
|
+
ensureStateWatcher(host)
|
|
157
|
+
return host
|
|
158
|
+
}
|
|
149
159
|
|
|
150
|
-
|
|
151
|
-
|
|
160
|
+
/** 装载/重载全部插件(跳过 disabled;manifest 仍可见) */
|
|
161
|
+
async function loadAllPlugins(host, { config, paths }) {
|
|
162
|
+
const homeDir = modePaths(host.cwd).homeDir
|
|
163
|
+
const disabled = readDisabledSet(homeDir)
|
|
164
|
+
host.disabledSet = disabled
|
|
165
|
+
|
|
166
|
+
const entries = discoverPluginDirs(host.cwd)
|
|
167
|
+
if (entries.length) host.logger.info(`发现 ${entries.length} 个插件(停用 ${disabled.size}),开始装载 ...`)
|
|
152
168
|
|
|
153
169
|
for (const { dir, scope } of entries) {
|
|
154
170
|
const entry = join(dir, 'index.mjs')
|
|
@@ -159,6 +175,22 @@ export async function initPluginHost({ cwd = process.cwd(), packageRoot } = {})
|
|
|
159
175
|
const def = check.def
|
|
160
176
|
|
|
161
177
|
if (host.plugins.has(def.name)) throw new Error(`插件重名(后装载者跳过): ${def.name}`)
|
|
178
|
+
if (disabled.has(def.name)) {
|
|
179
|
+
host.plugins.set(def.name, {
|
|
180
|
+
name: def.name,
|
|
181
|
+
version: String(def.version ?? '0.0.0'),
|
|
182
|
+
description: String(def.description ?? ''),
|
|
183
|
+
scope,
|
|
184
|
+
dir,
|
|
185
|
+
entry,
|
|
186
|
+
clientPath: def.client ? resolve(dir, def.client) : null,
|
|
187
|
+
routes: [],
|
|
188
|
+
enabled: false,
|
|
189
|
+
error: null,
|
|
190
|
+
})
|
|
191
|
+
host.logger.info(`⊘ 跳过(已停用) [${scope}] ${def.name}`)
|
|
192
|
+
continue
|
|
193
|
+
}
|
|
162
194
|
|
|
163
195
|
const rec = {
|
|
164
196
|
name: def.name,
|
|
@@ -169,6 +201,8 @@ export async function initPluginHost({ cwd = process.cwd(), packageRoot } = {})
|
|
|
169
201
|
entry,
|
|
170
202
|
clientPath: def.client ? resolve(dir, def.client) : null,
|
|
171
203
|
routes: [],
|
|
204
|
+
enabled: true,
|
|
205
|
+
error: null,
|
|
172
206
|
}
|
|
173
207
|
|
|
174
208
|
const emitter = {
|
|
@@ -178,17 +212,33 @@ export async function initPluginHost({ cwd = process.cwd(), packageRoot } = {})
|
|
|
178
212
|
},
|
|
179
213
|
}
|
|
180
214
|
const perPluginDisposables = []
|
|
215
|
+
const hookOffs = []
|
|
181
216
|
host.disposables.set(def.name, perPluginDisposables)
|
|
217
|
+
host.hookOffs.set(def.name, hookOffs)
|
|
218
|
+
const scopedHooks = {
|
|
219
|
+
on: (t, fn) => {
|
|
220
|
+
const off = host.bus.on(t, fn)
|
|
221
|
+
hookOffs.push(off)
|
|
222
|
+
return off
|
|
223
|
+
},
|
|
224
|
+
once: (t, fn) => {
|
|
225
|
+
const off = host.bus.once(t, fn)
|
|
226
|
+
hookOffs.push(off)
|
|
227
|
+
return off
|
|
228
|
+
},
|
|
229
|
+
off: (t, fn) => host.bus.off(t, fn),
|
|
230
|
+
emit: (t, p) => host.bus.emit(t, p),
|
|
231
|
+
}
|
|
182
232
|
const ctx = createPluginContext({
|
|
183
233
|
name: def.name,
|
|
184
234
|
scope,
|
|
185
235
|
dir,
|
|
186
|
-
hooks:
|
|
236
|
+
hooks: scopedHooks,
|
|
187
237
|
logger: {
|
|
188
|
-
debug: (...a) => logger.info(`[${def.name}][debug]`, ...a),
|
|
189
|
-
info: (...a) => logger.info(`[${def.name}]`, ...a),
|
|
190
|
-
warn: (...a) => logger.warn(`[${def.name}]`, ...a),
|
|
191
|
-
error: (...a) => logger.error(`[${def.name}]`, ...a),
|
|
238
|
+
debug: (...a) => host.logger.info(`[${def.name}][debug]`, ...a),
|
|
239
|
+
info: (...a) => host.logger.info(`[${def.name}]`, ...a),
|
|
240
|
+
warn: (...a) => host.logger.warn(`[${def.name}]`, ...a),
|
|
241
|
+
error: (...a) => host.logger.error(`[${def.name}]`, ...a),
|
|
192
242
|
},
|
|
193
243
|
config,
|
|
194
244
|
paths,
|
|
@@ -197,10 +247,9 @@ export async function initPluginHost({ cwd = process.cwd(), packageRoot } = {})
|
|
|
197
247
|
perPluginDisposables.push(fn)
|
|
198
248
|
return fn
|
|
199
249
|
},
|
|
200
|
-
selfOrigin: host
|
|
250
|
+
selfOrigin: () => selfOriginRef(host),
|
|
201
251
|
})
|
|
202
252
|
|
|
203
|
-
// 声明式 routes + setup 内 ctx.route() 两种形态都支持
|
|
204
253
|
for (const r of (Array.isArray(def.routes) ? def.routes : [])) {
|
|
205
254
|
emitter.registerRoute(def.name, r.method ?? 'GET', r.path, r.handler)
|
|
206
255
|
}
|
|
@@ -217,10 +266,87 @@ export async function initPluginHost({ cwd = process.cwd(), packageRoot } = {})
|
|
|
217
266
|
|
|
218
267
|
host.initedAt = new Date().toISOString()
|
|
219
268
|
await host.bus.emit('plugin:host:init', { plugins: [...host.plugins.keys()], failures: host.failures.length })
|
|
220
|
-
host.logger.info(`装载完成: ${host.plugins.size}
|
|
269
|
+
host.logger.info(`装载完成: ${host.plugins.size} 个(含停用)/ ${host.failures.length} 失败 / 活跃路由 ${host.routes.size} 条`)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function selfOriginRef(host) {
|
|
273
|
+
return host.selfOrigin()
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** 热重载:全部 dispose/解绑 → 重新装载(跳过停用)→ 广播 plugins.reloaded(并发合并) */
|
|
277
|
+
export async function reloadPluginHost() {
|
|
278
|
+
const host = getPluginHost()
|
|
279
|
+
if (!host) return null
|
|
280
|
+
if (host.reloadInFlight) return host.reloadInFlight
|
|
281
|
+
host.reloadInFlight = doReload(host).finally(() => {
|
|
282
|
+
host.reloadInFlight = null
|
|
283
|
+
})
|
|
284
|
+
return host.reloadInFlight
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function doReload(host) {
|
|
288
|
+
host.logger.info('热重载插件宿主 ...')
|
|
289
|
+
for (const [name, list] of host.disposables ?? []) {
|
|
290
|
+
for (const fn of list.splice(0)) {
|
|
291
|
+
try {
|
|
292
|
+
await fn()
|
|
293
|
+
}
|
|
294
|
+
catch (err) {
|
|
295
|
+
host.logger.warn(`[${name}] onDispose 失败:`, err?.message)
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
for (const offs of (host.hookOffs ?? new Map()).values()) {
|
|
300
|
+
for (const off of offs.splice(0)) {
|
|
301
|
+
try {
|
|
302
|
+
off()
|
|
303
|
+
}
|
|
304
|
+
catch { /* 解绑失败忽略 */ }
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
host.plugins.clear()
|
|
308
|
+
host.routes = createRouteTable()
|
|
309
|
+
host.failures = []
|
|
310
|
+
await loadAllPlugins(host, { config: host.config, settingsPath: null, paths: { home: modePaths(host.cwd).homeDir, configRoot: join(host.cwd, '.AgentWorkShop'), dataDir: join(host.cwd, '.AgentWorkShop', 'data') } })
|
|
311
|
+
await host.bus.emit('plugins:reloaded', { plugins: pluginManifest() })
|
|
312
|
+
try {
|
|
313
|
+
const m = await import('@/server/services/workshop/scene-events')
|
|
314
|
+
m.broadcastSceneEvent('plugins.reloaded', { plugins: pluginManifest() })
|
|
315
|
+
}
|
|
316
|
+
catch { /* 广播失败不影响重载 */ }
|
|
221
317
|
return host
|
|
222
318
|
}
|
|
223
319
|
|
|
320
|
+
/** 启停单插件(写状态文件;调用方随后 reloadPluginHost 或由 state watcher 触发) */
|
|
321
|
+
export function setPluginEnabled(name, enabled) {
|
|
322
|
+
const host = getPluginHost()
|
|
323
|
+
const homeDir = modePaths(host?.cwd ?? process.cwd()).homeDir
|
|
324
|
+
const set = readDisabledSet(homeDir)
|
|
325
|
+
if (enabled) set.delete(name)
|
|
326
|
+
else set.add(name)
|
|
327
|
+
writeDisabledSet(homeDir, set)
|
|
328
|
+
return [...set]
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** 状态文件监视 → 热重载(CLI/Web 只写文件,服务自感知) */
|
|
332
|
+
function ensureStateWatcher(host) {
|
|
333
|
+
const statePath = statePathFor(modePaths(host.cwd).homeDir)
|
|
334
|
+
try {
|
|
335
|
+
mkdirSync(dirname(statePath), { recursive: true })
|
|
336
|
+
if (!existsSync(statePath)) writeDisabledSet(modePaths(host.cwd).homeDir, new Set())
|
|
337
|
+
let debounce = null
|
|
338
|
+
watch(statePath, () => {
|
|
339
|
+
clearTimeout(debounce)
|
|
340
|
+
debounce = setTimeout(() => {
|
|
341
|
+
void reloadPluginHost()
|
|
342
|
+
}, 400)
|
|
343
|
+
})
|
|
344
|
+
}
|
|
345
|
+
catch (err) {
|
|
346
|
+
host.logger.warn('状态文件监视不可用(启停需手动重启):', err?.message)
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
224
350
|
/** 单例访问(未初始化返回 null —— 桥接点据此快速 no-op) */
|
|
225
351
|
export function getPluginHost() {
|
|
226
352
|
return g.__awPluginHost ?? null
|
|
@@ -256,7 +382,7 @@ export function readClientScript(name) {
|
|
|
256
382
|
return { status: 200, code: readFileSync(rec.clientPath, 'utf8'), contentType: 'text/javascript; charset=utf-8' }
|
|
257
383
|
}
|
|
258
384
|
|
|
259
|
-
/** 清单(
|
|
385
|
+
/** 清单(非敏感只读;含启停状态与路由) */
|
|
260
386
|
export function pluginManifest() {
|
|
261
387
|
const host = getPluginHost()
|
|
262
388
|
if (!host) return []
|
|
@@ -265,8 +391,10 @@ export function pluginManifest() {
|
|
|
265
391
|
version: r.version,
|
|
266
392
|
description: r.description,
|
|
267
393
|
scope: r.scope,
|
|
394
|
+
enabled: r.enabled !== false,
|
|
268
395
|
hasClient: Boolean(r.clientPath),
|
|
269
396
|
routes: host.routes.byPlugin(r.name),
|
|
397
|
+
error: r.error,
|
|
270
398
|
}))
|
|
271
399
|
}
|
|
272
400
|
|