anentrypoint-design 0.0.78 → 0.0.80
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/README.md +4 -0
- package/app-shell.css +3 -0
- package/dist/247420.app.js +4 -4
- package/dist/247420.css +3 -0
- package/dist/247420.js +26 -26
- package/package.json +4 -1
- package/src/components/freddie/pages-chat.js +1 -2
- package/src/components/freddie/pages-config.js +21 -9
- package/src/components/freddie/pages-core.js +26 -14
- package/src/desktop/about-app.js +52 -0
- package/src/desktop/files-app.js +44 -0
- package/src/desktop/monitor-app.js +34 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "anentrypoint-design",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.80",
|
|
4
4
|
"description": "247420 design system SDK — webjsx + modified ripple-ui, single-file ESM bundle for reproducible use of the AnEntrypoint design.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/247420.js",
|
|
@@ -26,6 +26,9 @@
|
|
|
26
26
|
"./desktop/shell.js": "./src/desktop/shell.js",
|
|
27
27
|
"./desktop/freddie-dashboard.js": "./src/desktop/freddie-dashboard.js",
|
|
28
28
|
"./desktop/freddie-dashboard.css": "./src/desktop/freddie-dashboard.css",
|
|
29
|
+
"./desktop/files-app.js": "./src/desktop/files-app.js",
|
|
30
|
+
"./desktop/monitor-app.js": "./src/desktop/monitor-app.js",
|
|
31
|
+
"./desktop/about-app.js": "./src/desktop/about-app.js",
|
|
29
32
|
"./colors_and_type.css": "./colors_and_type.css",
|
|
30
33
|
"./app-shell.css": "./app-shell.css",
|
|
31
34
|
"./page-html": {
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import * as webjsx from '../../../vendor/webjsx/index.js';
|
|
2
2
|
import { Panel, Hero, Receipt } from '../content.js';
|
|
3
|
-
import { Chip } from '../shell.js';
|
|
4
3
|
import { skillLabel, getRecentPaths, saveRecentPath, renderChatMessages } from './helpers.js';
|
|
5
4
|
const h = webjsx.createElement;
|
|
6
5
|
|
|
@@ -105,6 +104,6 @@ export async function chat(h0) {
|
|
|
105
104
|
] }),
|
|
106
105
|
configured.length === 0
|
|
107
106
|
? Panel({ title: 'no providers configured', children: Receipt({ rows: [['set API key', 'keys tab → click chip'], ['or use acptoapi', 'run acptoapi server on localhost:4800']] }) })
|
|
108
|
-
:
|
|
107
|
+
: null
|
|
109
108
|
];
|
|
110
109
|
}
|
|
@@ -25,7 +25,7 @@ export async function models(h0) {
|
|
|
25
25
|
const loading = probeState[p.name] === 'loading';
|
|
26
26
|
if (loading) probedPanels.push(Panel({ title: p.name + ' ⏳', children: h('span', { class: 'fd-muted' }, 'probing…') }));
|
|
27
27
|
else if (ms && ms.length > 0) probedPanels.push(Panel({ title: p.name + (p.available ? ' ●' : ' ○'), count: ms.length, children: Table({ headers: ['model id'], rows: ms.map(m => [m]) }) }));
|
|
28
|
-
else unprobedRows.push([p.name, p.available ? 'available' : 'unavailable', p.modelsError ? 'error: '+p.modelsError : '
|
|
28
|
+
else unprobedRows.push([p.name, p.available ? 'available' : 'unavailable', p.modelsError ? 'error: '+p.modelsError : '—']);
|
|
29
29
|
}
|
|
30
30
|
const modelPanels = [
|
|
31
31
|
...probedPanels,
|
|
@@ -33,7 +33,7 @@ export async function models(h0) {
|
|
|
33
33
|
].filter(Boolean);
|
|
34
34
|
return [
|
|
35
35
|
Hero({ title: 'models', body: 'pick a provider, pick a model. probe to list available models.', accent: configured.length+' configured' }),
|
|
36
|
-
Kpi({ items: [[configured.length,'configured'],[providers.
|
|
36
|
+
Kpi({ items: [[configured.length,'configured'],[probedPanels.length,'probed'],[providers.length-configured.length,'unconfigured']] }),
|
|
37
37
|
Panel({ title: 'change active model', children: Form({ fields: [
|
|
38
38
|
{ name: 'provider', placeholder: 'provider', value: cfg.agent?.provider || '' },
|
|
39
39
|
{ name: 'model', placeholder: 'model id', value: cfg.agent?.model || '' }
|
|
@@ -52,12 +52,19 @@ export async function cron(h0) {
|
|
|
52
52
|
const list = await h0.pi.cron.list();
|
|
53
53
|
return [
|
|
54
54
|
Hero({ title: 'cron', body: 'scheduled prompts. cron syntax, fired by freddie.', accent: list.length+' jobs' }),
|
|
55
|
-
Kpi({ items: [[list.length,'jobs']] }),
|
|
55
|
+
Kpi({ items: [[list.length,'jobs'],[list.filter(j => j.enabled).length,'enabled']] }),
|
|
56
56
|
Panel({ title: 'add job', children: Form({ fields: [
|
|
57
|
-
{ name: 'cron', placeholder: '* * * *
|
|
58
|
-
{ name: 'prompt', placeholder: 'prompt', required: true }
|
|
59
|
-
], submit: 'create', onSubmit: async ev => { await h0.pi.cron.create({ cron: ev.target.elements.cron.value, prompt: ev.target.elements.prompt.value }); } }) }),
|
|
60
|
-
Panel({ title: '
|
|
57
|
+
{ name: 'cron', placeholder: '0 * * * * (m h dom mon dow)', required: true },
|
|
58
|
+
{ name: 'prompt', placeholder: 'prompt to run', required: true }
|
|
59
|
+
], submit: 'create', onSubmit: async ev => { await h0.pi.cron.create({ cron: ev.target.elements.cron.value, prompt: ev.target.elements.prompt.value }); if (typeof window.__fd_nav === 'function') window.__fd_nav('cron'); } }) }),
|
|
60
|
+
Panel({ title: 'cron syntax', children: Receipt({ rows: [
|
|
61
|
+
['every minute', '* * * * *'],
|
|
62
|
+
['every hour (top)', '0 * * * *'],
|
|
63
|
+
['daily 09:00', '0 9 * * *'],
|
|
64
|
+
['weekdays 18:00', '0 18 * * 1-5'],
|
|
65
|
+
['every 15 min', '*/15 * * * *']
|
|
66
|
+
] }) }),
|
|
67
|
+
Panel({ title: 'jobs', count: list.length, children: list.length === 0 ? EmptyState({ text: 'no cron jobs — add one with the form above', glyph: '◷' }) : Table({ headers: ['id','cron','prompt','enabled'], rows: list.map(j => [j.id, j.cron, (j.prompt||'').slice(0,40), j.enabled ? 'yes' : 'no']) }) })
|
|
61
68
|
];
|
|
62
69
|
}
|
|
63
70
|
|
|
@@ -117,10 +124,15 @@ export async function env(h0) {
|
|
|
117
124
|
export async function tools(h0) {
|
|
118
125
|
const list = [...h0.pi.tools.values()];
|
|
119
126
|
const envIsSet = k => typeof h0.pi.env?.isSet === 'function' ? h0.pi.env.isSet(k) : false;
|
|
120
|
-
const
|
|
127
|
+
const f = window.__fd_toolFilter = window.__fd_toolFilter || { q: '' };
|
|
128
|
+
const q = (f.q || '').toLowerCase();
|
|
129
|
+
const filtered = q ? list.filter(t => (t.name||'').toLowerCase().includes(q) || (t.description||'').toLowerCase().includes(q) || (t.toolset||'').toLowerCase().includes(q)) : list;
|
|
130
|
+
const bySet = filtered.reduce((a, t) => { (a[t.toolset||'core'] = a[t.toolset||'core'] || []).push(t); return a; }, {});
|
|
131
|
+
const search = h('input', { type: 'search', placeholder: 'filter tools…', value: f.q, oninput: ev => { f.q = ev.target.value; if (typeof window.__fd_nav === 'function') window.__fd_nav('tools'); }, class: 'fd-search' });
|
|
121
132
|
return [
|
|
122
133
|
Hero({ title: 'tools', body: 'every tool the agent can call. param count + required env per row.', accent: list.length+' tools' }),
|
|
123
|
-
Kpi({ items: [[list.length,'tools'],[Object.keys(bySet).length,'toolsets']] }),
|
|
134
|
+
Kpi({ items: [[list.length,'tools'],[filtered.length,'shown'],[Object.keys(bySet).length,'toolsets']] }),
|
|
135
|
+
Panel({ title: 'filter', right: search, children: filtered.length === 0 ? EmptyState({ text: 'no matches for "'+f.q+'"', glyph: '⌕' }) : h('span', { class: 'fd-muted' }, filtered.length+' / '+list.length+' tools shown') }),
|
|
124
136
|
...Object.entries(bySet).map(([ts, items]) => Panel({ title: 'toolset · '+ts, count: items.length, children: items.map(t => {
|
|
125
137
|
const params = t.schema?.parameters?.properties ? Object.keys(t.schema.parameters.properties).length : 0;
|
|
126
138
|
const reqEnv = Array.isArray(t.requiresEnv) ? t.requiresEnv : [];
|
|
@@ -7,6 +7,11 @@ const h = webjsx.createElement;
|
|
|
7
7
|
export async function home(h0) {
|
|
8
8
|
const sessions = await h0.pi.sessions.list();
|
|
9
9
|
const health = h0.pi.health();
|
|
10
|
+
const fmt = (k, v) => {
|
|
11
|
+
if (k === 'ts' && typeof v === 'number') { const d = new Date(v); return d.toISOString().replace('T', ' ').slice(0, 19) + ' UTC'; }
|
|
12
|
+
if (typeof v === 'boolean') return v ? '✓' : '✗';
|
|
13
|
+
return String(v);
|
|
14
|
+
};
|
|
10
15
|
return [
|
|
11
16
|
Hero({ title: 'freddie', body: 'open js agent harness.', accent: h0.version || 'web' }),
|
|
12
17
|
Kpi({ items: [[sessions.length,'sessions'],[h0.pi.tools.size,'tools'],[h0.pi.skills.size,'skills']] }),
|
|
@@ -16,13 +21,16 @@ export async function home(h0) {
|
|
|
16
21
|
['set api key','keys tab → click chip'],
|
|
17
22
|
['add cron','cron tab → form']
|
|
18
23
|
] }) }),
|
|
19
|
-
Panel({ title: '
|
|
24
|
+
Panel({ title: 'system status', children: Receipt({ rows: Object.entries(health).map(([k,v]) => [k, fmt(k, v)]) }) })
|
|
20
25
|
];
|
|
21
26
|
}
|
|
22
27
|
|
|
23
28
|
export async function sessions(h0) {
|
|
24
29
|
const list = await h0.pi.sessions.list();
|
|
25
|
-
const
|
|
30
|
+
const f = window.__fd_sessFilter = window.__fd_sessFilter || { q: '' };
|
|
31
|
+
const q = (f.q || '').toLowerCase();
|
|
32
|
+
const filtered = q ? list.filter(s => (s.title||'').toLowerCase().includes(q) || (s.id||'').includes(q) || (s.platform||'').toLowerCase().includes(q) || (s.cwd||'').toLowerCase().includes(q)) : list;
|
|
33
|
+
const rows = filtered.map(s => {
|
|
26
34
|
const cont = h('button', { class: 'btn-primary', onclick: async () => {
|
|
27
35
|
const msgs = await h0.pi.sessions.getMessages(s.id);
|
|
28
36
|
const cs = window.__fd_chatState = window.__fd_chatState || { messages: [], busy: false, sessionId: null, cwd: '', skill: '', provider: '', model: '' };
|
|
@@ -34,10 +42,11 @@ export async function sessions(h0) {
|
|
|
34
42
|
} }, 'continue');
|
|
35
43
|
return [(s.id||'').slice(0,8), s.title||'—', s.platform||'—', s.model||'—', s.cwd?s.cwd.slice(-30):'—', s.skill?skillLabel({name:s.skill}):'—', cont];
|
|
36
44
|
});
|
|
45
|
+
const search = h('input', { type: 'search', placeholder: 'filter by title / id / platform / cwd…', value: f.q, oninput: ev => { f.q = ev.target.value; if (typeof window.__fd_nav === 'function') window.__fd_nav('sessions'); }, class: 'fd-search' });
|
|
37
46
|
return [
|
|
38
47
|
Hero({ title: 'sessions', body: 'every chat turn lives here.', accent: list.length+' total' }),
|
|
39
|
-
Kpi({ items: [[list.length,'sessions']] }),
|
|
40
|
-
Panel({ title: 'sessions', count:
|
|
48
|
+
Kpi({ items: [[list.length,'sessions'],[filtered.length,'shown']] }),
|
|
49
|
+
Panel({ title: 'sessions', count: filtered.length, right: search, children: list.length === 0 ? EmptyState({ text: 'no sessions yet — start one in /chat', glyph: '✉' }) : filtered.length === 0 ? EmptyState({ text: 'no matches for "'+f.q+'"', glyph: '⌕' }) : Table({ headers: ['id','title','platform','model','cwd','skill',''], rows }) })
|
|
41
50
|
];
|
|
42
51
|
}
|
|
43
52
|
|
|
@@ -67,16 +76,19 @@ export async function agents(h0) {
|
|
|
67
76
|
const a = typeof h0.pi.agents === 'function' ? await h0.pi.agents() : { count: 0, turns: 0, active: null };
|
|
68
77
|
const sList = await h0.pi.sessions.list();
|
|
69
78
|
const recent = sList.slice(0, 10);
|
|
79
|
+
const idle = !a.count;
|
|
70
80
|
return [
|
|
71
|
-
Hero({ title: 'agents', body: 'agent state machine snapshot. one xstate per turn.', accent: (a.count
|
|
72
|
-
Kpi({ items: [[a.count||0,'active'],[a.turns||0,'turns'],[sList.length,'
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
81
|
+
Hero({ title: 'agents', body: 'agent state machine snapshot. one xstate per turn.', accent: idle ? 'idle' : (a.count+' active') }),
|
|
82
|
+
Kpi({ items: [[a.count||0,'active'],[a.turns||0,'turns total'],[sList.length,'sessions in store']] }),
|
|
83
|
+
idle
|
|
84
|
+
? Panel({ title: 'no agent running', children: EmptyState({ text: 'start an agent by sending a prompt in /chat', glyph: '◌' }) })
|
|
85
|
+
: Panel({ title: 'current agent', children: Receipt({ rows: [
|
|
86
|
+
['active session', a.active || '(none)'],
|
|
87
|
+
['total turns', String(a.turns || 0)],
|
|
88
|
+
['last activity', a.last_activity ? new Date(a.last_activity).toISOString().replace('T',' ').slice(0,19)+' UTC' : '—']
|
|
89
|
+
] }) }),
|
|
78
90
|
Panel({ title: 'recent sessions', count: recent.length, children: recent.length === 0
|
|
79
|
-
? EmptyState({ text: 'no recent sessions', glyph: '
|
|
91
|
+
? EmptyState({ text: 'no recent sessions', glyph: '✉' })
|
|
80
92
|
: Table({ headers: ['id','title','platform','turns'], rows: recent.map(s => [(s.id||'').slice(0,8), s.title||'—', s.platform||'—', String(s.turns ?? s.message_count ?? '—')]) })
|
|
81
93
|
})
|
|
82
94
|
];
|
|
@@ -86,8 +98,8 @@ export async function analytics(h0) {
|
|
|
86
98
|
const list = await h0.pi.sessions.list();
|
|
87
99
|
const tools = [...h0.pi.tools.values()];
|
|
88
100
|
const skills = [...h0.pi.skills.values()];
|
|
89
|
-
const byPlat = list.reduce((a,s) => { const k = s.platform||'
|
|
90
|
-
const byModel = list.reduce((a,s) => { const k = s.model||'
|
|
101
|
+
const byPlat = list.reduce((a,s) => { const k = s.platform||'(unset)'; a[k] = (a[k]||0)+1; return a; }, {});
|
|
102
|
+
const byModel = list.reduce((a,s) => { const k = s.model||'(unset)'; a[k] = (a[k]||0)+1; return a; }, {});
|
|
91
103
|
const byToolset = tools.reduce((a,t) => { const k = t.toolset||'core'; a[k] = (a[k]||0)+1; return a; }, {});
|
|
92
104
|
const bySkillCat = skills.reduce((a,s) => { const k = s.category||'other'; a[k] = (a[k]||0)+1; return a; }, {});
|
|
93
105
|
const sortDesc = obj => Object.entries(obj).sort((a,b) => b[1]-a[1]).map(([k,v]) => [k, String(v)]);
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// About-app paint surface — static info card, bible classes.
|
|
2
|
+
// renderAboutApp({brand, tagline, bullets, links}) -> {node, dispose}.
|
|
3
|
+
// Consumer provides content; module owns layout + classes.
|
|
4
|
+
|
|
5
|
+
export function renderAboutApp(opts = {}) {
|
|
6
|
+
const {
|
|
7
|
+
brand = 'thebird / web os',
|
|
8
|
+
tagline = 'browser-native web OS. multi-instance, per-instance fs / worker / shell / browser. no server.',
|
|
9
|
+
bullets = [
|
|
10
|
+
'POSIX terminal · IndexedDB filesystem',
|
|
11
|
+
'OffscreenCanvas worker per instance',
|
|
12
|
+
'CDP-shaped browser pane',
|
|
13
|
+
'libsql via sql.js · freddie host',
|
|
14
|
+
'responsive: phone / tablet / desktop',
|
|
15
|
+
],
|
|
16
|
+
footer = 'open <code>validator</code> to run isolation harness · click apps menu for more.',
|
|
17
|
+
links = [
|
|
18
|
+
{ href: 'https://github.com/AnEntrypoint/thebird', text: 'source' },
|
|
19
|
+
{ href: './validate.html', text: 'validate' },
|
|
20
|
+
],
|
|
21
|
+
} = opts;
|
|
22
|
+
|
|
23
|
+
const node = document.createElement('div');
|
|
24
|
+
node.className = 'app-pane';
|
|
25
|
+
node.dataset.component = 'about-app';
|
|
26
|
+
|
|
27
|
+
const h2 = document.createElement('h2');
|
|
28
|
+
h2.textContent = brand;
|
|
29
|
+
const p = document.createElement('p');
|
|
30
|
+
p.textContent = tagline;
|
|
31
|
+
const ul = document.createElement('ul');
|
|
32
|
+
for (const b of bullets) {
|
|
33
|
+
const li = document.createElement('li');
|
|
34
|
+
li.textContent = b;
|
|
35
|
+
ul.appendChild(li);
|
|
36
|
+
}
|
|
37
|
+
const foot = document.createElement('p');
|
|
38
|
+
foot.innerHTML = footer;
|
|
39
|
+
const meta = document.createElement('p');
|
|
40
|
+
meta.className = 'meta';
|
|
41
|
+
links.forEach((l, i) => {
|
|
42
|
+
const a = document.createElement('a');
|
|
43
|
+
a.href = l.href;
|
|
44
|
+
a.textContent = l.text;
|
|
45
|
+
meta.appendChild(a);
|
|
46
|
+
if (i < links.length - 1) meta.appendChild(document.createTextNode(' · '));
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
node.append(h2, p, ul, foot, meta);
|
|
50
|
+
|
|
51
|
+
return { node, dispose() {} };
|
|
52
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Files-app paint surface — bible classes, pure DOM. Consumer provides callbacks.
|
|
2
|
+
// renderFilesApp({list, readFile}) -> {node, refresh, dispose}.
|
|
3
|
+
// list() -> Promise<string[]>; readFile(path) -> Promise<string|Uint8Array>.
|
|
4
|
+
// Header text comes from {label} (consumer assembles "<id> — N files").
|
|
5
|
+
|
|
6
|
+
export function renderFilesApp(opts = {}) {
|
|
7
|
+
const { list, readFile, label = '', pollMs = 2000 } = opts;
|
|
8
|
+
const node = document.createElement('div');
|
|
9
|
+
node.className = 'app-pane mono';
|
|
10
|
+
node.dataset.component = 'files-app';
|
|
11
|
+
|
|
12
|
+
let preview = null;
|
|
13
|
+
async function refresh() {
|
|
14
|
+
const items = await list();
|
|
15
|
+
node.innerHTML = '';
|
|
16
|
+
const head = document.createElement('div');
|
|
17
|
+
head.className = 'head';
|
|
18
|
+
head.textContent = (label ? label + ' — ' : '') + items.length + ' files';
|
|
19
|
+
node.appendChild(head);
|
|
20
|
+
for (const p of items) {
|
|
21
|
+
const row = document.createElement('div');
|
|
22
|
+
row.className = 'row';
|
|
23
|
+
row.textContent = p;
|
|
24
|
+
row.addEventListener('click', async () => {
|
|
25
|
+
const body = await readFile(p);
|
|
26
|
+
if (preview) preview.remove();
|
|
27
|
+
preview = document.createElement('pre');
|
|
28
|
+
preview.textContent = String(body);
|
|
29
|
+
node.appendChild(preview);
|
|
30
|
+
});
|
|
31
|
+
node.appendChild(row);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let timer = null;
|
|
36
|
+
refresh().catch(() => {});
|
|
37
|
+
if (pollMs > 0) timer = setInterval(() => refresh().catch(() => {}), pollMs);
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
node,
|
|
41
|
+
refresh,
|
|
42
|
+
dispose() { if (timer) clearInterval(timer); },
|
|
43
|
+
};
|
|
44
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Monitor-app paint surface — bible classes, pure DOM. Consumer supplies getStats().
|
|
2
|
+
// getStats() -> Promise<{instanceId, frames, shells, windows, appsRegistered, jsHeapMb, jsHeapLimitMb, time}>.
|
|
3
|
+
// Field names map directly to displayed lines so consumer controls labels by value, not template.
|
|
4
|
+
|
|
5
|
+
export function renderMonitorApp(opts = {}) {
|
|
6
|
+
const { getStats, pollMs = 1000 } = opts;
|
|
7
|
+
const node = document.createElement('div');
|
|
8
|
+
node.className = 'app-pane mono';
|
|
9
|
+
node.dataset.component = 'monitor-app';
|
|
10
|
+
|
|
11
|
+
async function tick() {
|
|
12
|
+
const s = (await Promise.resolve(getStats())) || {};
|
|
13
|
+
const heap = (s.jsHeapMb != null && s.jsHeapLimitMb != null)
|
|
14
|
+
? `js heap: ${Number(s.jsHeapMb).toFixed(1)} MB / ${Number(s.jsHeapLimitMb).toFixed(0)} MB`
|
|
15
|
+
: 'js heap: n/a';
|
|
16
|
+
node.textContent = [
|
|
17
|
+
`instance: ${s.instanceId ?? ''}`,
|
|
18
|
+
`worker frames: ${s.frames ?? 0}`,
|
|
19
|
+
`shells: ${s.shells ?? 0}`,
|
|
20
|
+
`windows: ${s.windows ?? 0}`,
|
|
21
|
+
`apps registered: ${s.appsRegistered ?? 0}`,
|
|
22
|
+
heap,
|
|
23
|
+
`now: ${s.time ?? new Date().toLocaleTimeString()}`,
|
|
24
|
+
].join('\n');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
tick().catch(() => {});
|
|
28
|
+
const timer = setInterval(() => tick().catch(() => {}), pollMs);
|
|
29
|
+
return {
|
|
30
|
+
node,
|
|
31
|
+
tick,
|
|
32
|
+
dispose() { clearInterval(timer); },
|
|
33
|
+
};
|
|
34
|
+
}
|