sidekick-chat 0.2.4 → 0.2.6
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 +137 -131
- package/package.json +1 -1
- package/src/activity.mjs +17 -0
- package/src/api.mjs +3 -3
- package/src/images.mjs +46 -0
- package/src/main.mjs +656 -609
- package/src/quota.mjs +98 -98
- package/src/tui.mjs +100 -90
package/src/quota.mjs
CHANGED
|
@@ -1,98 +1,98 @@
|
|
|
1
|
-
import { SidekickApi } from './api.mjs';
|
|
2
|
-
|
|
3
|
-
/** Same host and credentials as chat; proxy prefix or the standard direct port. */
|
|
4
|
-
export function ccwebBase(base) {
|
|
5
|
-
if (!base) return '';
|
|
6
|
-
const url = new URL(base);
|
|
7
|
-
const path = url.pathname.replace(/\/+$/, '');
|
|
8
|
-
if (path.endsWith('/chat')) url.pathname = path.slice(0, -5) + '/ccweb';
|
|
9
|
-
else if (!path && url.port === '18792') url.port = '18794';
|
|
10
|
-
else throw new Error('无法推导 ccweb 地址');
|
|
11
|
-
url.search = '';
|
|
12
|
-
url.hash = '';
|
|
13
|
-
return url.toString().replace(/\/+$/, '');
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
const percentage = (value) => typeof value === 'number' && Number.isFinite(value)
|
|
17
|
-
? Math.round(Math.max(0, Math.min(100, value))) : null;
|
|
18
|
-
const timestamp = (value) => {
|
|
19
|
-
const time = typeof value === 'number' ? value : Date.parse(value);
|
|
20
|
-
return Number.isFinite(time) && time > 0 ? time : null;
|
|
21
|
-
};
|
|
22
|
-
const usedWindows = (windows) => (Array.isArray(windows) ? windows : []).map((w) => ({
|
|
23
|
-
label: w.label || w.id || '?',
|
|
24
|
-
left: percentage(typeof w.percent === 'number' ? 100 - w.percent : null),
|
|
25
|
-
resetsAt: timestamp(w.resetsAt),
|
|
26
|
-
}));
|
|
27
|
-
|
|
28
|
-
export function quotaRows(values, now = Date.now()) {
|
|
29
|
-
const { opencode, codex, claude } = values;
|
|
30
|
-
const row = (label, value, windows, at, note = '') => ({
|
|
31
|
-
label, windows, at: timestamp(at),
|
|
32
|
-
stale: Boolean(value?.failed || value?.data?.stale || timestamp(at) && now - timestamp(at) > 10 * 60_000),
|
|
33
|
-
note: value?.failed ? (value.data ? '刷新失败,保留旧值' : '暂不可用') : note,
|
|
34
|
-
});
|
|
35
|
-
const oc = opencode?.data;
|
|
36
|
-
const cx = codex?.data;
|
|
37
|
-
const rows = [
|
|
38
|
-
row('OpenCode', opencode, usedWindows(oc?.windows), oc?.sampledAt, oc?.stale ? '缓存' : ''),
|
|
39
|
-
row('Codex', codex, usedWindows(cx?.windows), cx?.sampledAt, cx?.loggedIn === false ? '未登录' : ''),
|
|
40
|
-
];
|
|
41
|
-
const agents = (Array.isArray(claude?.data?.agents) ? claude.data.agents : []).filter((a) => a.engine === 'claude-code');
|
|
42
|
-
// Keep both requested channels visible even before their first report.
|
|
43
|
-
for (const name of ['WSL', 'Docker']) if (!agents.some((a) => a.name === name)) agents.push({ name });
|
|
44
|
-
for (const agent of agents) {
|
|
45
|
-
rows.push(row(`Claude ${agent.name}`, claude,
|
|
46
|
-
(Array.isArray(agent.usage?.windows) ? agent.usage.windows : []).map((w) => ({
|
|
47
|
-
label: w.label || w.key || '?', left: percentage(w.leftPct), resetsAt: timestamp(w.resetsAt),
|
|
48
|
-
})), agent.usage?.updatedAt, agent.online === false ? '离线' : ''));
|
|
49
|
-
}
|
|
50
|
-
return rows;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const localTime = (at) => {
|
|
54
|
-
const d = new Date(at);
|
|
55
|
-
const pad = (n) => String(n).padStart(2, '0');
|
|
56
|
-
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
57
|
-
};
|
|
58
|
-
|
|
59
|
-
export function formatQuota(rows, { detail = false } = {}) {
|
|
60
|
-
return rows.map((r) => {
|
|
61
|
-
const windows = r.windows.map((w) => `${w.label} ${w.left === null ? '?' : w.left + '%'}${detail && w.resetsAt ? `(${localTime(w.resetsAt)} 重置)` : ''}`).join(' / ');
|
|
62
|
-
const notes = [r.note, r.stale ? '旧数据' : '', r.at ? `${localTime(r.at)} 采样` : '尚无采样'].filter(Boolean);
|
|
63
|
-
return `${r.label} 剩余 ${windows || '未知'} · ${notes.join(' · ')}`;
|
|
64
|
-
}).join('\n');
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/** Independent failures preserve each source's last good snapshot. Requests are made only by the usage command. */
|
|
68
|
-
export function createQuotaReader(settings, { api = new SidekickApi(settings), agentsApi } = {}) {
|
|
69
|
-
const values = {};
|
|
70
|
-
const ctl = new AbortController();
|
|
71
|
-
let pending;
|
|
72
|
-
let stopped = false;
|
|
73
|
-
let ccApi = agentsApi;
|
|
74
|
-
const sources = {
|
|
75
|
-
opencode: () => api.request('GET', 'v1/opencode/quota', undefined, { signal: ctl.signal }),
|
|
76
|
-
codex: () => api.request('GET', 'v1/codex/status', undefined, { signal: ctl.signal }),
|
|
77
|
-
claude: () => {
|
|
78
|
-
ccApi ??= new SidekickApi({ ...settings, url: ccwebBase(settings.url), alt: ccwebBase(settings.alt) });
|
|
79
|
-
return ccApi.request('GET', 'v1/agents', undefined, { signal: ctl.signal });
|
|
80
|
-
},
|
|
81
|
-
};
|
|
82
|
-
function refresh() {
|
|
83
|
-
if (stopped) return Promise.resolve(quotaRows(values));
|
|
84
|
-
if (pending) return pending;
|
|
85
|
-
pending = (async () => {
|
|
86
|
-
await Promise.allSettled(Object.entries(sources).map(async ([key, fetch]) => {
|
|
87
|
-
try { values[key] = { data: await fetch(), failed: false }; }
|
|
88
|
-
catch { values[key] = { ...values[key], failed: true }; }
|
|
89
|
-
}));
|
|
90
|
-
return quotaRows(values);
|
|
91
|
-
})().finally(() => { pending = null; });
|
|
92
|
-
return pending;
|
|
93
|
-
}
|
|
94
|
-
return {
|
|
95
|
-
refresh,
|
|
96
|
-
stop() { stopped = true; ctl.abort(); },
|
|
97
|
-
};
|
|
98
|
-
}
|
|
1
|
+
import { SidekickApi } from './api.mjs';
|
|
2
|
+
|
|
3
|
+
/** Same host and credentials as chat; proxy prefix or the standard direct port. */
|
|
4
|
+
export function ccwebBase(base) {
|
|
5
|
+
if (!base) return '';
|
|
6
|
+
const url = new URL(base);
|
|
7
|
+
const path = url.pathname.replace(/\/+$/, '');
|
|
8
|
+
if (path.endsWith('/chat')) url.pathname = path.slice(0, -5) + '/ccweb';
|
|
9
|
+
else if (!path && url.port === '18792') url.port = '18794';
|
|
10
|
+
else throw new Error('无法推导 ccweb 地址');
|
|
11
|
+
url.search = '';
|
|
12
|
+
url.hash = '';
|
|
13
|
+
return url.toString().replace(/\/+$/, '');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const percentage = (value) => typeof value === 'number' && Number.isFinite(value)
|
|
17
|
+
? Math.round(Math.max(0, Math.min(100, value))) : null;
|
|
18
|
+
const timestamp = (value) => {
|
|
19
|
+
const time = typeof value === 'number' ? value : Date.parse(value);
|
|
20
|
+
return Number.isFinite(time) && time > 0 ? time : null;
|
|
21
|
+
};
|
|
22
|
+
const usedWindows = (windows) => (Array.isArray(windows) ? windows : []).map((w) => ({
|
|
23
|
+
label: w.label || w.id || '?',
|
|
24
|
+
left: percentage(typeof w.percent === 'number' ? 100 - w.percent : null),
|
|
25
|
+
resetsAt: timestamp(w.resetsAt),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
export function quotaRows(values, now = Date.now()) {
|
|
29
|
+
const { opencode, codex, claude } = values;
|
|
30
|
+
const row = (label, value, windows, at, note = '') => ({
|
|
31
|
+
label, windows, at: timestamp(at),
|
|
32
|
+
stale: Boolean(value?.failed || value?.data?.stale || timestamp(at) && now - timestamp(at) > 10 * 60_000),
|
|
33
|
+
note: value?.failed ? (value.data ? '刷新失败,保留旧值' : '暂不可用') : note,
|
|
34
|
+
});
|
|
35
|
+
const oc = opencode?.data;
|
|
36
|
+
const cx = codex?.data;
|
|
37
|
+
const rows = [
|
|
38
|
+
row('OpenCode', opencode, usedWindows(oc?.windows), oc?.sampledAt, oc?.stale ? '缓存' : ''),
|
|
39
|
+
row('Codex', codex, usedWindows(cx?.windows), cx?.sampledAt, cx?.loggedIn === false ? '未登录' : ''),
|
|
40
|
+
];
|
|
41
|
+
const agents = (Array.isArray(claude?.data?.agents) ? claude.data.agents : []).filter((a) => a.engine === 'claude-code');
|
|
42
|
+
// Keep both requested channels visible even before their first report.
|
|
43
|
+
for (const name of ['WSL', 'Docker']) if (!agents.some((a) => a.name === name)) agents.push({ name });
|
|
44
|
+
for (const agent of agents) {
|
|
45
|
+
rows.push(row(`Claude ${agent.name}`, claude,
|
|
46
|
+
(Array.isArray(agent.usage?.windows) ? agent.usage.windows : []).map((w) => ({
|
|
47
|
+
label: w.label || w.key || '?', left: percentage(w.leftPct), resetsAt: timestamp(w.resetsAt),
|
|
48
|
+
})), agent.usage?.updatedAt, agent.online === false ? '离线' : ''));
|
|
49
|
+
}
|
|
50
|
+
return rows;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const localTime = (at) => {
|
|
54
|
+
const d = new Date(at);
|
|
55
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
56
|
+
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export function formatQuota(rows, { detail = false } = {}) {
|
|
60
|
+
return rows.map((r) => {
|
|
61
|
+
const windows = r.windows.map((w) => `${w.label} ${w.left === null ? '?' : w.left + '%'}${detail && w.resetsAt ? `(${localTime(w.resetsAt)} 重置)` : ''}`).join(' / ');
|
|
62
|
+
const notes = [r.note, r.stale ? '旧数据' : '', r.at ? `${localTime(r.at)} 采样` : '尚无采样'].filter(Boolean);
|
|
63
|
+
return `${r.label} 剩余 ${windows || '未知'} · ${notes.join(' · ')}`;
|
|
64
|
+
}).join('\n');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Independent failures preserve each source's last good snapshot. Requests are made only by the usage command. */
|
|
68
|
+
export function createQuotaReader(settings, { api = new SidekickApi(settings), agentsApi } = {}) {
|
|
69
|
+
const values = {};
|
|
70
|
+
const ctl = new AbortController();
|
|
71
|
+
let pending;
|
|
72
|
+
let stopped = false;
|
|
73
|
+
let ccApi = agentsApi;
|
|
74
|
+
const sources = {
|
|
75
|
+
opencode: () => api.request('GET', 'v1/opencode/quota', undefined, { signal: ctl.signal }),
|
|
76
|
+
codex: () => api.request('GET', 'v1/codex/status', undefined, { signal: ctl.signal }),
|
|
77
|
+
claude: () => {
|
|
78
|
+
ccApi ??= new SidekickApi({ ...settings, url: ccwebBase(settings.url), alt: ccwebBase(settings.alt) });
|
|
79
|
+
return ccApi.request('GET', 'v1/agents', undefined, { signal: ctl.signal });
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
function refresh() {
|
|
83
|
+
if (stopped) return Promise.resolve(quotaRows(values));
|
|
84
|
+
if (pending) return pending;
|
|
85
|
+
pending = (async () => {
|
|
86
|
+
await Promise.allSettled(Object.entries(sources).map(async ([key, fetch]) => {
|
|
87
|
+
try { values[key] = { data: await fetch(), failed: false }; }
|
|
88
|
+
catch { values[key] = { ...values[key], failed: true }; }
|
|
89
|
+
}));
|
|
90
|
+
return quotaRows(values);
|
|
91
|
+
})().finally(() => { pending = null; });
|
|
92
|
+
return pending;
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
refresh,
|
|
96
|
+
stop() { stopped = true; ctl.abort(); },
|
|
97
|
+
};
|
|
98
|
+
}
|
package/src/tui.mjs
CHANGED
|
@@ -1,90 +1,100 @@
|
|
|
1
|
-
import { Container, Editor, Markdown, ProcessTerminal, Text, TuiMainScreen } from '@earendil-works/pi-tui';
|
|
2
|
-
import { paint, formatMessage } from './format.mjs';
|
|
3
|
-
|
|
4
|
-
export function markdownTheme(color) {
|
|
5
|
-
const style = (code) => (text) => paint(color, code, text);
|
|
6
|
-
return Object.fromEntries(Object.entries({
|
|
7
|
-
heading: 'bold', link: 'cyan', linkUrl: 'gray', code: 'yellow',
|
|
8
|
-
codeBlock: 'yellow', codeBlockBorder: 'gray', quote: 'gray', quoteBorder: 'gray',
|
|
9
|
-
hr: 'gray', listBullet: 'cyan', bold: 'bold', italic: 'dim',
|
|
10
|
-
strikethrough: 'dim', underline: 'cyan',
|
|
11
|
-
}).map(([name, code]) => [name, style(code)]));
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
/** Pi's renderer owns every screen write, including the editor and busy status. */
|
|
15
|
-
export function createChatUI({ color, onSubmit, onQuit, onInput, terminal = new ProcessTerminal() }) {
|
|
16
|
-
const tui = new TuiMainScreen(terminal);
|
|
17
|
-
const transcript = new Container();
|
|
18
|
-
const live = new Container();
|
|
19
|
-
const drafts = new Map();
|
|
20
|
-
const draftKey = (frame) => `${frame.from}:${frame.id}`;
|
|
21
|
-
const dropDraft = (key) => {
|
|
22
|
-
const draft = drafts.get(key);
|
|
23
|
-
if (draft) live.removeChild(draft.container);
|
|
24
|
-
drafts.delete(key);
|
|
25
|
-
};
|
|
26
|
-
const
|
|
27
|
-
const
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
tui.addChild(
|
|
34
|
-
tui.addChild(
|
|
35
|
-
tui.addChild(
|
|
36
|
-
tui.addChild(
|
|
37
|
-
tui.
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
editor.
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
1
|
+
import { Container, Editor, Markdown, ProcessTerminal, Text, TuiMainScreen, matchesKey } from '@earendil-works/pi-tui';
|
|
2
|
+
import { paint, formatMessage } from './format.mjs';
|
|
3
|
+
|
|
4
|
+
export function markdownTheme(color) {
|
|
5
|
+
const style = (code) => (text) => paint(color, code, text);
|
|
6
|
+
return Object.fromEntries(Object.entries({
|
|
7
|
+
heading: 'bold', link: 'cyan', linkUrl: 'gray', code: 'yellow',
|
|
8
|
+
codeBlock: 'yellow', codeBlockBorder: 'gray', quote: 'gray', quoteBorder: 'gray',
|
|
9
|
+
hr: 'gray', listBullet: 'cyan', bold: 'bold', italic: 'dim',
|
|
10
|
+
strikethrough: 'dim', underline: 'cyan',
|
|
11
|
+
}).map(([name, code]) => [name, style(code)]));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Pi's renderer owns every screen write, including the editor and busy status. */
|
|
15
|
+
export function createChatUI({ color, onSubmit, onQuit, onInput, onPasteImage, hasImages = () => false, terminal = new ProcessTerminal() }) {
|
|
16
|
+
const tui = new TuiMainScreen(terminal);
|
|
17
|
+
const transcript = new Container();
|
|
18
|
+
const live = new Container();
|
|
19
|
+
const drafts = new Map();
|
|
20
|
+
const draftKey = (frame) => `${frame.from}:${frame.id}`;
|
|
21
|
+
const dropDraft = (key) => {
|
|
22
|
+
const draft = drafts.get(key);
|
|
23
|
+
if (draft) live.removeChild(draft.container);
|
|
24
|
+
drafts.delete(key);
|
|
25
|
+
};
|
|
26
|
+
const activity = new Text('', 1, 0);
|
|
27
|
+
const status = new Text('', 1, 0);
|
|
28
|
+
const identity = (text) => text;
|
|
29
|
+
const editor = new Editor(tui, {
|
|
30
|
+
borderColor: (text) => paint(color, 'gray', text),
|
|
31
|
+
selectList: Object.fromEntries(['selectedPrefix', 'selectedText', 'description', 'scrollInfo', 'noMatch'].map((key) => [key, identity])),
|
|
32
|
+
});
|
|
33
|
+
tui.addChild(transcript);
|
|
34
|
+
tui.addChild(live);
|
|
35
|
+
tui.addChild(activity);
|
|
36
|
+
tui.addChild(status);
|
|
37
|
+
tui.addChild(editor);
|
|
38
|
+
const attachments = new Text('', 1, 0);
|
|
39
|
+
tui.addChild(attachments);
|
|
40
|
+
tui.addChild(new Text(paint(color, 'gray', 'Enter 发送 · Ctrl+V 图片 · /paste · /image 路径 · /help · Ctrl+C 退出'), 1, 0));
|
|
41
|
+
tui.setFocus(editor);
|
|
42
|
+
tui.addInputListener((data) => {
|
|
43
|
+
// 任何一个按键都算「人在终端前面」:调用方据此决定要不要把新消息标成已读。
|
|
44
|
+
onInput?.(data);
|
|
45
|
+
if (matchesKey(data, 'ctrl+v') && onPasteImage) {
|
|
46
|
+
onPasteImage();
|
|
47
|
+
return { consume: true };
|
|
48
|
+
}
|
|
49
|
+
if (data === '\u0003' || data === '\u0004' && !editor.getText()) {
|
|
50
|
+
onQuit();
|
|
51
|
+
return { consume: true };
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
editor.onSubmit = (text) => {
|
|
55
|
+
if (!text.trim() && !hasImages()) return;
|
|
56
|
+
editor.addToHistory(text);
|
|
57
|
+
editor.setText('');
|
|
58
|
+
onSubmit(text.trim());
|
|
59
|
+
tui.requestRender();
|
|
60
|
+
};
|
|
61
|
+
return {
|
|
62
|
+
start: () => tui.start(),
|
|
63
|
+
stop: () => tui.stop(),
|
|
64
|
+
attachments(count) { attachments.setText(count ? `待发送图片 ${count}/4 · Enter 发送 · /drop 清除` : ''); tui.requestRender(); },
|
|
65
|
+
out(text) { transcript.addChild(new Text(text, 1, 0)); tui.requestRender(); },
|
|
66
|
+
message(message, options) {
|
|
67
|
+
if (message.draft) dropDraft(draftKey({ from: message.from, id: message.draft }));
|
|
68
|
+
const header = formatMessage({ ...message, text: '', images: [] }, options).trimEnd();
|
|
69
|
+
transcript.addChild(new Text(`\n${header}`, 1, 0));
|
|
70
|
+
transcript.addChild(message.from === 'user'
|
|
71
|
+
? new Text(String(message.text ?? ''), 2, 0)
|
|
72
|
+
: new Markdown(String(message.text ?? ''), 2, 0, markdownTheme(color)));
|
|
73
|
+
for (const name of message.images ?? []) transcript.addChild(new Text(`[图片] ${options.imageUrl?.(name) || name}`, 2, 0));
|
|
74
|
+
tui.requestRender();
|
|
75
|
+
},
|
|
76
|
+
draft(frame, options) {
|
|
77
|
+
if (!frame?.id || !frame.from) return;
|
|
78
|
+
const key = draftKey(frame);
|
|
79
|
+
if (frame.end) dropDraft(key);
|
|
80
|
+
else if (typeof frame.text === 'string') {
|
|
81
|
+
let draft = drafts.get(key);
|
|
82
|
+
if (!draft) {
|
|
83
|
+
const container = new Container();
|
|
84
|
+
const header = formatMessage({ from: frame.from, ts: Date.now(), text: '' }, options).trimEnd();
|
|
85
|
+
container.addChild(new Text(`\n${header} · 正在生成`, 1, 0));
|
|
86
|
+
const body = new Markdown(frame.text, 2, 0, markdownTheme(color));
|
|
87
|
+
container.addChild(body);
|
|
88
|
+
draft = { container, body };
|
|
89
|
+
drafts.set(key, draft);
|
|
90
|
+
live.addChild(container);
|
|
91
|
+
} else draft.body.setText(frame.text);
|
|
92
|
+
}
|
|
93
|
+
tui.requestRender();
|
|
94
|
+
},
|
|
95
|
+
clearDrafts() { drafts.clear(); live.clear(); tui.requestRender(); },
|
|
96
|
+
activity(text) { activity.setText(paint(color, 'gray', text)); tui.requestRender(); },
|
|
97
|
+
typing(on) { status.setText(on ? paint(color, 'gray', '… 正在输入') : ''); tui.requestRender(); },
|
|
98
|
+
clear() { transcript.clear(); drafts.clear(); live.clear(); tui.requestRender(true); },
|
|
99
|
+
};
|
|
100
|
+
}
|