vcorp-chrome 1.8.1 → 2.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/README.md +20 -1
- package/docs//345/215/217/350/256/256.md +53 -0
- package/docs//345/257/271/346/257/224.md +55 -53
- package/docs//350/203/275/345/212/233/345/214/205/344/270/216eda-/344/272/244/346/216/245.md +288 -0
- package/extension/background.js +20 -2
- package/extension/cloud-policy.js +14 -0
- package/extension/cloud.js +229 -6
- package/extension/desktop-policy.js +400 -0
- package/extension/desktop-render.js +81 -0
- package/extension/device.js +144 -0
- package/extension/manifest.json +4 -1
- package/extension/panel.html +10 -0
- package/extension/panel.js +476 -427
- package/native-host/host.js +122 -0
- package/package.json +6 -2
- package/src/bridge.js +31 -2
- package/src/cli.js +35 -1
- package/src/lib/desktop.js +171 -0
- package/src/lib/native-install.js +139 -0
- package/src/lib/paths.js +20 -0
- package/src/lib/rpc.js +7 -2
- package/src/mcp-server.js +135 -10
package/src/lib/rpc.js
CHANGED
|
@@ -100,6 +100,7 @@ export class BridgeClient {
|
|
|
100
100
|
this.extensionVersion = msg.extensionVersion;
|
|
101
101
|
this.versionMismatch = !!msg.versionMismatch;
|
|
102
102
|
this.extensionProtocol = msg.extensionProtocol;
|
|
103
|
+
this.bridgeDevices = !!msg.devices;
|
|
103
104
|
ws.onmessage = (e2) => this.#dispatch(JSON.parse(e2.data));
|
|
104
105
|
ws.onclose = () => { this.ws = null; this.#failAll('桥连接已断开'); };
|
|
105
106
|
ws.onerror = () => {};
|
|
@@ -144,7 +145,11 @@ export class BridgeClient {
|
|
|
144
145
|
}
|
|
145
146
|
|
|
146
147
|
// 桥侧已有 30s 超时兜底;这里的 35s 只防「桥自己也没了」的情况
|
|
147
|
-
|
|
148
|
+
// device:指定投给哪一个浏览器设备(devices 列出的 id)。不给就由桥挑有窗口、最近活跃的那个。
|
|
149
|
+
async call(cmd, params = {}, { tabId, timeoutMs = 35000, device } = {}) {
|
|
150
|
+
if ((device || cmd === 'devices') && this.ws && !this.bridgeDevices) {
|
|
151
|
+
throw Object.assign(new Error('在跑的桥是旧版本,不认识设备:指定 device 的命令会被它投给默认的 Chrome。所有会话都退出、桥自动重启之后再用。'), { code: 'BRIDGE_OLD' });
|
|
152
|
+
}
|
|
148
153
|
if (!this.ws) await this.connect();
|
|
149
154
|
const id = 'c' + ++this.seq;
|
|
150
155
|
return new Promise((resolve, reject) => {
|
|
@@ -154,7 +159,7 @@ export class BridgeClient {
|
|
|
154
159
|
}, timeoutMs);
|
|
155
160
|
this.waiting.set(id, { resolve, reject, timer });
|
|
156
161
|
// 桥侧也要知道这条命令能等多久,否则它按默认 30s 就把慢命令判死了
|
|
157
|
-
this.ws.send(JSON.stringify({ type: 'cmd', id, cmd, params, tabId, timeout: timeoutMs - 3000 }));
|
|
162
|
+
this.ws.send(JSON.stringify({ type: 'cmd', id, cmd, params, tabId, timeout: timeoutMs - 3000, ...(device ? { to: { device } } : {}) }));
|
|
158
163
|
});
|
|
159
164
|
}
|
|
160
165
|
}
|
package/src/mcp-server.js
CHANGED
|
@@ -12,8 +12,12 @@ import { resolveHost } from './lib/host.js';
|
|
|
12
12
|
import { flatCount } from '../extension/script.js';
|
|
13
13
|
import { LOADS } from '../extension/loads/index.js';
|
|
14
14
|
import { getLearnings, saveLearnings, saveDraft, adoptDraft } from './lib/learnings.js';
|
|
15
|
-
import { audit } from './lib/paths.js';
|
|
15
|
+
import { audit, machineId, desktopDeviceId } from './lib/paths.js';
|
|
16
|
+
import { DesktopSession, findBinary } from './lib/desktop.js';
|
|
17
|
+
import { redact } from './bridge.js';
|
|
18
|
+
import { describeDevices } from '../extension/device.js';
|
|
16
19
|
import fs from 'node:fs';
|
|
20
|
+
import os from 'node:os';
|
|
17
21
|
import path from 'node:path';
|
|
18
22
|
import { VERSION } from './lib/version.js';
|
|
19
23
|
import { compat, compatNote } from '../extension/compat.js';
|
|
@@ -208,6 +212,7 @@ const TOOLS = [
|
|
|
208
212
|
tabId: TAB,
|
|
209
213
|
label: { type: 'string', description: 'With new/select: what this tab is for ("CRM import — batch 2"). Shown in list, page panel, and to the user.' },
|
|
210
214
|
focus: { type: 'boolean', description: 'Also bring the tab to the foreground. Interrupts the user — off by default.' },
|
|
215
|
+
device: { type: 'string', description: 'With 2+ Chromes: id from devices. That tabId then sticks to it.' },
|
|
211
216
|
},
|
|
212
217
|
required: ['action'],
|
|
213
218
|
},
|
|
@@ -500,6 +505,39 @@ const TOOLS = [
|
|
|
500
505
|
},
|
|
501
506
|
},
|
|
502
507
|
},
|
|
508
|
+
{
|
|
509
|
+
name: 'desktop',
|
|
510
|
+
description:
|
|
511
|
+
'Native apps and OS dialogs (the file picker a page opens) via the accessibility tree. '
|
|
512
|
+
+ 'list-windows → snapshot {app|window} → act on the FULL ref "@s…:eN" → read the effect line. '
|
|
513
|
+
+ 'Browser windows are refused (use browser tools). headed:true = real mouse/keys. '
|
|
514
|
+
+ 'steps:[{cmd,…}] (≤20) stops on failure or send/delete.',
|
|
515
|
+
inputSchema: {
|
|
516
|
+
type: 'object',
|
|
517
|
+
properties: {
|
|
518
|
+
cmd: {
|
|
519
|
+
type: 'string',
|
|
520
|
+
enum: ['list-apps', 'list-windows', 'snapshot', 'find', 'get', 'screenshot', 'wait', 'click', 'double-click',
|
|
521
|
+
'right-click', 'type', 'set-value', 'clear', 'focus', 'select', 'toggle', 'check', 'uncheck', 'expand',
|
|
522
|
+
'collapse', 'scroll', 'scroll-to', 'press', 'launch', 'focus-window', 'clipboard-set', 'clipboard-get',
|
|
523
|
+
'list-notifications', 'notification-action'],
|
|
524
|
+
},
|
|
525
|
+
app: { type: 'string' }, window: { type: 'string' }, ref: { type: 'string' },
|
|
526
|
+
root: { type: 'string' }, skeleton: { type: 'boolean' }, all: { type: 'boolean' },
|
|
527
|
+
text: { type: 'string' }, value: { type: 'string' }, keys: { type: 'string', description: 'press: "ctrl+s"' },
|
|
528
|
+
role: { type: 'string' }, name: { type: 'string' }, nth: { type: 'number' }, property: { type: 'string' },
|
|
529
|
+
direction: { type: 'string' }, amount: { type: 'number' },
|
|
530
|
+
ms: { type: 'number' }, windowTitle: { type: 'string' }, timeout: { type: 'number' },
|
|
531
|
+
index: { type: 'number' }, action: { type: 'string' }, headed: { type: 'boolean' },
|
|
532
|
+
steps: { type: 'array', items: { type: 'object' } },
|
|
533
|
+
},
|
|
534
|
+
},
|
|
535
|
+
},
|
|
536
|
+
{
|
|
537
|
+
name: 'devices',
|
|
538
|
+
description: 'Devices this session can drive: each connected Chrome and this computer\'s desktop.',
|
|
539
|
+
inputSchema: { type: 'object', properties: {} },
|
|
540
|
+
},
|
|
503
541
|
{
|
|
504
542
|
name: 'reload',
|
|
505
543
|
description:
|
|
@@ -552,7 +590,7 @@ warning change target or approach, never repeat the same call.
|
|
|
552
590
|
|
|
553
591
|
A STEP NEEDS A HUMAN (captcha, QR login, OTP, payment)? Call \`ask\` — never retry or work
|
|
554
592
|
around. Never let a download put a dialog on their screen: see the \`download\` tool for the
|
|
555
|
-
required order. OS file dialogs
|
|
593
|
+
required order. Native apps and OS file dialogs: \`desktop\`.`;
|
|
556
594
|
|
|
557
595
|
// 页面来的文本全部走这里。边界标记 + 降权说明,both 是给模型看的。
|
|
558
596
|
function wrapUntrusted(body, meta = '') {
|
|
@@ -573,6 +611,9 @@ export async function startMcpServer({ client = 'unknown' } = {}) {
|
|
|
573
611
|
|
|
574
612
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
|
|
575
613
|
|
|
614
|
+
let desk = null;
|
|
615
|
+
const tabDevice = new Map();
|
|
616
|
+
|
|
576
617
|
// 宿主身份:--client 只是兜底,真名以 initialize 里宿主自报的 clientInfo 为准
|
|
577
618
|
// (见 src/lib/host.js)。握手一定先于第一条工具调用,所以在那时认一次就够。
|
|
578
619
|
let host = null;
|
|
@@ -585,7 +626,7 @@ export async function startMcpServer({ client = 'unknown' } = {}) {
|
|
|
585
626
|
return host;
|
|
586
627
|
};
|
|
587
628
|
|
|
588
|
-
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
629
|
+
server.setRequestHandler(CallToolRequestSchema, busy(async (req) => {
|
|
589
630
|
const { name, arguments: args = {} } = req.params;
|
|
590
631
|
identify();
|
|
591
632
|
try {
|
|
@@ -598,12 +639,51 @@ export async function startMcpServer({ client = 'unknown' } = {}) {
|
|
|
598
639
|
return { content: [{ type: 'text', text }] };
|
|
599
640
|
}
|
|
600
641
|
|
|
642
|
+
// desktop 也不经过桥:本机直接起 agent-desktop 子进程。审计照记,设备号写本机桌面
|
|
643
|
+
if (name === 'desktop') {
|
|
644
|
+
desk ??= new DesktopSession({ session: `vc-${bridge.sessionId}` });
|
|
645
|
+
const t0 = Date.now();
|
|
646
|
+
const id = `desktop:${t0}`;
|
|
647
|
+
const cmd = `desktop.${args.steps !== undefined ? 'steps' : args.cmd}`;
|
|
648
|
+
audit({ ev: 'cmd', id, cmd, client: bridge.client, sid: bridge.sessionId, device: desktopDeviceId(machineId()), params: redact(args) });
|
|
649
|
+
let r;
|
|
650
|
+
try {
|
|
651
|
+
r = await desk.run(args, { timeoutMs: args.steps ? 120000 : Math.min(Math.max((Number(args.timeout) || 0) + 10000, 30000), 600000) });
|
|
652
|
+
} catch (e) {
|
|
653
|
+
audit({ ev: 'res', id, cmd, ok: false, ms: Date.now() - t0, error: e.code || 'INTERNAL' });
|
|
654
|
+
throw e;
|
|
655
|
+
}
|
|
656
|
+
const ok = r.env ? !!r.env.ok : !r.stopped;
|
|
657
|
+
audit({ ev: 'res', id, cmd, ok, ms: Date.now() - t0, error: ok ? undefined : r.env?.error?.code, effect: r.env?.data?.disposition?.delivery });
|
|
658
|
+
if (r.image) return { content: [{ type: 'image', data: r.image, mimeType: 'image/png' }] };
|
|
659
|
+
return { content: [{ type: 'text', text: wrapUntrusted(r.text, 'source="desktop"') }], isError: !ok || undefined };
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
if (name === 'devices') {
|
|
663
|
+
const machine = machineId();
|
|
664
|
+
const list = [{ id: desktopDeviceId(machine), kind: 'desktop', name: `${os.hostname()} 桌面`, machine, online: true,
|
|
665
|
+
caps: { platform: process.platform } }];
|
|
666
|
+
let browserNote = '';
|
|
667
|
+
try {
|
|
668
|
+
if (!bridge.ws) await bridge.connect();
|
|
669
|
+
const d = await bridge.call('devices', {}, { timeoutMs: 8000 });
|
|
670
|
+
list.unshift(...d.devices.map((x) => (x.primary ? { ...x, name: `${x.name}(默认)` } : x)));
|
|
671
|
+
} catch (e) {
|
|
672
|
+
browserNote = `\n浏览器设备查不到:[${e.code || 'INTERNAL'}] ${e.message}`;
|
|
673
|
+
}
|
|
674
|
+
return { content: [{ type: 'text', text: describeDevices(list) + browserNote }] };
|
|
675
|
+
}
|
|
676
|
+
|
|
601
677
|
if (!bridge.ws) await bridge.connect();
|
|
602
678
|
|
|
679
|
+
// 指定设备:tabs 带 device 时记住新开/选中的 tabId 属于哪台,之后同一 tabId 自动跟过去
|
|
680
|
+
const device = args.device || (args.tabId != null ? tabDevice.get(args.tabId) : undefined);
|
|
681
|
+
if (args.device !== undefined) delete args.device;
|
|
682
|
+
|
|
603
683
|
// 翻页在这一侧循环:审计里 fetch→fetch 相邻 386 次,agent 在一页一个回合地
|
|
604
684
|
// 手动翻。扩展只管发单次请求,翻页的循环、落盘、停机判定都不该经过模型。
|
|
605
685
|
if (name === 'fetch' && args.pages && !args.binary) {
|
|
606
|
-
return { content: [{ type: 'text', text: await fetchPages(bridge, args) }] };
|
|
686
|
+
return { content: [{ type: 'text', text: await fetchPages(bridge, args, device) }] };
|
|
607
687
|
}
|
|
608
688
|
|
|
609
689
|
// upload:先把**路径**交给扩展,让它用 CDP 的 DOM.setFileInputFiles 让浏览器
|
|
@@ -617,7 +697,7 @@ export async function startMcpServer({ client = 'unknown' } = {}) {
|
|
|
617
697
|
const base = { path: args.path, name: path.basename(args.path), type: mime, bytes: st.size, selector: args.selector, dropSelector: args.dropSelector };
|
|
618
698
|
// 上传本身可能很慢(本机→浏览器只是投喂,但页面自己的上传要时间),给足预算
|
|
619
699
|
const timeoutMs = Math.max(60000, Math.round(st.size / (1024 * 1024)) * 1000);
|
|
620
|
-
let out = args.dropSelector ? null : await bridge.call('upload', base, { tabId: args.tabId, timeoutMs });
|
|
700
|
+
let out = args.dropSelector ? null : await bridge.call('upload', base, { tabId: args.tabId, timeoutMs, device });
|
|
621
701
|
if (!out || out.needBytes) {
|
|
622
702
|
// 走拖放:这条必须有文件字节。超过 DROP_MAX 就明确报错,不要拿桥去撞——
|
|
623
703
|
// 桥一断,当前 agent 会话就再也连不回来了,比报错难查得多。
|
|
@@ -629,7 +709,7 @@ export async function startMcpServer({ client = 'unknown' } = {}) {
|
|
|
629
709
|
`先压小,或改用页面上真实存在的 file input(给 selector)。`);
|
|
630
710
|
}
|
|
631
711
|
out = await bridge.call('upload', { ...base, base64: fs.readFileSync(args.path).toString('base64') },
|
|
632
|
-
{ tabId: args.tabId, timeoutMs });
|
|
712
|
+
{ tabId: args.tabId, timeoutMs, device });
|
|
633
713
|
}
|
|
634
714
|
return { content: [{ type: 'text', text: out.text }] };
|
|
635
715
|
}
|
|
@@ -670,7 +750,11 @@ export async function startMcpServer({ client = 'unknown' } = {}) {
|
|
|
670
750
|
// 无人值守(cron、服务器)没有人可问。开关放在 MCP 这一侧读环境变量,
|
|
671
751
|
// 扩展只管照做——扩展不该知道自己跑在什么场景里。
|
|
672
752
|
name === 'ask' ? { ...args, disabled: process.env.HUASHU_CHROME_ASK === 'off' } : args,
|
|
673
|
-
{ tabId: args.tabId, timeoutMs: budgetOf(name, args, askMs) });
|
|
753
|
+
{ tabId: args.tabId, timeoutMs: budgetOf(name, args, askMs), device });
|
|
754
|
+
if (device && name === 'tabs') {
|
|
755
|
+
const tid = data?.tabId ?? (args.action === 'select' ? args.tabId : undefined);
|
|
756
|
+
if (tid != null) tabDevice.set(tid, device);
|
|
757
|
+
}
|
|
674
758
|
|
|
675
759
|
// 浏览器只能下到 Downloads 里;下完再挪到 agent 要的位置,对它保持透明
|
|
676
760
|
if (name === 'download' && args.savePath && data.path) {
|
|
@@ -721,7 +805,7 @@ export async function startMcpServer({ client = 'unknown' } = {}) {
|
|
|
721
805
|
} catch (e) {
|
|
722
806
|
return { content: [{ type: 'text', text: hint(e) + mismatchNote(true) }], isError: true };
|
|
723
807
|
}
|
|
724
|
-
});
|
|
808
|
+
}));
|
|
725
809
|
|
|
726
810
|
// 扩展和本地端版本对不上时在回执里说一次——回执是唯一一定有人读的通道。
|
|
727
811
|
// 分级见 extension/compat.js:补丁号差异静默;本地端旧了提一次怎么升级;
|
|
@@ -738,16 +822,51 @@ export async function startMcpServer({ client = 'unknown' } = {}) {
|
|
|
738
822
|
}
|
|
739
823
|
|
|
740
824
|
await server.connect(new StdioServerTransport());
|
|
825
|
+
watchIdle();
|
|
741
826
|
return server;
|
|
742
827
|
}
|
|
743
828
|
|
|
829
|
+
// 闲置自退。
|
|
830
|
+
//
|
|
831
|
+
// 本该是宿主的事:会话结束就关掉 stdin,我们读到 EOF 自然就退了(实测成立)。
|
|
832
|
+
// 可 Goose 桌面版每开一个会话起一个 mcp、会话结束却从不关管道——11-2 查到
|
|
833
|
+
// 一个 goose serve 下面挂着 34 个,最老的一周前,合计约 2 GB。宿主不收,只能自己收。
|
|
834
|
+
//
|
|
835
|
+
// 判据是「stdin 上多久没来过任何东西」,且手上没有在跑的工具调用。默认 4 小时:
|
|
836
|
+
// 一个真在用的会话很少静默这么久;万一真是,宿主下一次调用会看到连接已断,
|
|
837
|
+
// 代价是重开一次会话,远小于无限期地攒进程。HUASHU_CHROME_MCP_IDLE_MIN=0 关掉。
|
|
838
|
+
let inflight = 0;
|
|
839
|
+
let lastActive = Date.now();
|
|
840
|
+
const busy = (fn) => async (...a) => {
|
|
841
|
+
inflight++;
|
|
842
|
+
try { return await fn(...a); } finally { inflight--; lastActive = Date.now(); }
|
|
843
|
+
};
|
|
844
|
+
|
|
845
|
+
export function idleLimitMs(env = process.env) {
|
|
846
|
+
const raw = env.HUASHU_CHROME_MCP_IDLE_MIN;
|
|
847
|
+
const min = raw == null || raw === '' ? 240 : Number(raw);
|
|
848
|
+
return Number.isFinite(min) && min > 0 ? min * 60000 : 0;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
function watchIdle() {
|
|
852
|
+
const limit = idleLimitMs();
|
|
853
|
+
if (!limit) return;
|
|
854
|
+
process.stdin.on('data', () => { lastActive = Date.now(); });
|
|
855
|
+
const tick = setInterval(() => {
|
|
856
|
+
if (inflight || Date.now() - lastActive < limit) return;
|
|
857
|
+
console.error(`[vcorp-chrome] ${Math.round(limit / 60000)} 分钟没有任何请求,宿主大概已经不用这个进程了,自行退出`);
|
|
858
|
+
process.exit(0);
|
|
859
|
+
}, Math.min(60000, Math.max(limit / 4, 50)));
|
|
860
|
+
tick.unref(); // 吊着进程的该是 stdin,不是这个检查
|
|
861
|
+
}
|
|
862
|
+
|
|
744
863
|
// 自动翻页。页码型:改一个 query 参数递增;游标型:从上一页响应里按路径取下一个游标。
|
|
745
864
|
// 停机判据都是确定性的:非 2xx、空体、和上一页一模一样、游标为空或没变、跑满 max。
|
|
746
865
|
// 落盘是每页一行 JSON(url/status/body),body 能解析成 JSON 就存解析后的。
|
|
747
866
|
const pathGet = (obj, p) => String(p || '').split('.').filter(Boolean).reduce((o, k) => (o == null ? undefined : o[k]), obj);
|
|
748
867
|
const EMPTY_BODY = /^\s*(\[\s*\]|\{\s*\}|null)?\s*$/;
|
|
749
868
|
|
|
750
|
-
export async function fetchPages(bridge, args) {
|
|
869
|
+
export async function fetchPages(bridge, args, device) {
|
|
751
870
|
const pg = args.pages || {};
|
|
752
871
|
if (!pg.param && !pg.cursorParam) throw Object.assign(new Error('pages 需要 param(页码参数名)或 cursorParam(游标参数名)'), { code: 'INTERNAL' });
|
|
753
872
|
const max = Math.min(Math.max(Number(pg.max) || 10, 1), 50);
|
|
@@ -762,7 +881,7 @@ export async function fetchPages(bridge, args) {
|
|
|
762
881
|
const url = new URL(base);
|
|
763
882
|
if (pg.param) url.searchParams.set(pg.param, String(n));
|
|
764
883
|
if (pg.cursorParam && cursor) url.searchParams.set(pg.cursorParam, cursor);
|
|
765
|
-
const data = await bridge.call('fetch', { url: url.toString(), init: args.init, maxBody: args.maxBody || 2000000, via: args.via }, { tabId: args.tabId });
|
|
884
|
+
const data = await bridge.call('fetch', { url: url.toString(), init: args.init, maxBody: args.maxBody || 2000000, via: args.via }, { tabId: args.tabId, device });
|
|
766
885
|
const m = /^(\d+)\n\n([\s\S]*)$/.exec(data?.text || '');
|
|
767
886
|
const status = Number(m?.[1] || 0), body = m?.[2] ?? '';
|
|
768
887
|
if (!(status >= 200 && status < 300)) { stop = `第 ${i + 1} 页返回 ${status},停下`; break; }
|
|
@@ -822,6 +941,12 @@ function hint(e) {
|
|
|
822
941
|
+ '在「高保真模式」那一栏点「开启」——只需一次。',
|
|
823
942
|
L2_BUSY: '真实输入事件用不了(多半是用户自己开着 DevTools,一个标签页只允许一个调试器)。'
|
|
824
943
|
+ '已经用普通事件完成了;如果结果不对,让用户关掉 DevTools 再试。',
|
|
944
|
+
DEVICE_OFFLINE: '指定的设备不在线。devices 看现在有哪些,换一台或请用户把它打开。',
|
|
945
|
+
AMBIGUOUS_DEVICE: '有不止一台设备能做这件事。从候选里挑一个,用 device 指定。',
|
|
946
|
+
DESKTOP_DENIED: '这条桌面命令被安全规则拦下(原因见上)。浏览器窗口用浏览器工具;别换个写法绕。',
|
|
947
|
+
DESKTOP_UNAVAILABLE: '这台电脑上的桌面能力没装好。运行 npx vcorp-chrome doctor 看缺什么。',
|
|
948
|
+
PAY_REFUSED: '付款类按钮只能由用户亲手点。用 ask 把要付的东西说清楚,请用户自己完成。',
|
|
949
|
+
NEEDS_SINGLE_CALL: '发送/删除一类的动作不在批处理里做。先确认目标没错,再单独调用一次。',
|
|
825
950
|
};
|
|
826
951
|
return `[${e.code || 'INTERNAL'}] ${e.message}` + (map[e.code] ? `\n→ ${map[e.code]}` : '');
|
|
827
952
|
}
|