@whyshu/argent-erp 0.0.0-stage → 0.2.3
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 +179 -2
- package/cordis.patch.yml +6 -0
- package/dist/client.js +614 -0
- package/package.json +55 -4
- package/presets/argent-office.patch.yml +78 -0
- package/src/data.mjs +297 -0
- package/src/index.js +123 -0
- package/src/office-guard.js +105 -0
- package/src/vendor/account-token.mjs +41 -0
- package/src/vendor/redact.mjs +37 -0
package/package.json
CHANGED
|
@@ -1,6 +1,57 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@whyshu/argent-erp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.2.3",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Argent ERP 会话视图(dsh conversation.view)+ 六禾园办公室助理 preset(只聊办公室、只读 ERP)+ 办公室聊一聊入口",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"dsh",
|
|
8
|
+
"deepseek-harness",
|
|
9
|
+
"dsh-plugin",
|
|
10
|
+
"argent",
|
|
11
|
+
"erp",
|
|
12
|
+
"conversation-view",
|
|
13
|
+
"agent-preset"
|
|
14
|
+
],
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/cstcen/argent-dsh.git",
|
|
19
|
+
"directory": "plugins/argent-erp"
|
|
20
|
+
},
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=24.2.0"
|
|
23
|
+
},
|
|
24
|
+
"main": "src/index.js",
|
|
25
|
+
"exports": {
|
|
26
|
+
".": "./src/index.js",
|
|
27
|
+
"./office-guard": "./src/office-guard.js",
|
|
28
|
+
"./client": "./dist/client.js",
|
|
29
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
30
|
+
"./presets/argent-office.patch.yml": "./presets/argent-office.patch.yml",
|
|
31
|
+
"./package.json": "./package.json"
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"src/",
|
|
35
|
+
"dist/",
|
|
36
|
+
"presets/",
|
|
37
|
+
"cordis.patch.yml",
|
|
38
|
+
"README.md"
|
|
39
|
+
],
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
},
|
|
43
|
+
"dsh": {
|
|
44
|
+
"bundle": {
|
|
45
|
+
"patch": [
|
|
46
|
+
"./cordis.patch.yml",
|
|
47
|
+
"./presets/argent-office.patch.yml"
|
|
48
|
+
]
|
|
49
|
+
},
|
|
50
|
+
"client": {
|
|
51
|
+
"platform": "web"
|
|
52
|
+
},
|
|
53
|
+
"plugin": {
|
|
54
|
+
"displayName": "Argent ERP 会话视图 + 办公室助理"
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# Agent preset argent-office —— 六禾园办公室助理(只聊办公室,只读 ERP)
|
|
2
|
+
#
|
|
3
|
+
# 形态:dsh 0.2.x 的"行式"preset(`@deepseek-ai/dsh-agent-preset` 一行 + `config.plugins` 子行)。
|
|
4
|
+
# 装法:随 `@whyshu/argent-erp` bundle 一起落位(package.json#dsh.bundle.patch 是数组),
|
|
5
|
+
# `plugin_manager install_bundle <包目录>` 即生效。
|
|
6
|
+
#
|
|
7
|
+
# 边界(三层):
|
|
8
|
+
# ① 能力边界:本 preset **不挂** bash / pwsh / fs / fs-search / web / skill / subagent / workflow 行
|
|
9
|
+
# ⇒ 这个会话没有本机、终端、文件、联网能力;
|
|
10
|
+
# ② 工具边界:`office-guard`(本包 src/office-guard.js)把作用域内可见的全局工具收敛成
|
|
11
|
+
# 只放行 `mcp__argent__erp_*`(ERP 只读工具),Agent Team 等其余全局工具一律不可见;
|
|
12
|
+
# ③ 话题边界:persona 写死"只聊六禾园办公室",其它话题统一拒绝并引导回来。
|
|
13
|
+
#
|
|
14
|
+
# 为什么不做成 `presets/argent-office/` 作者格式:先按"dev 机器即插即用"的 bundle 形态落地;
|
|
15
|
+
# 若此角色要进客户机分发,再挪到 `presets/argent-office/` 并由 scripts/gen-preset-rows.mjs 生成
|
|
16
|
+
# 到 plugins/argent-presets(一处真源,避免两份手工维护)。
|
|
17
|
+
|
|
18
|
+
- insert:
|
|
19
|
+
- id: preset-argent-office
|
|
20
|
+
name: '@deepseek-ai/dsh-agent-preset'
|
|
21
|
+
config:
|
|
22
|
+
id: argent-office
|
|
23
|
+
name: '六禾园办公室助理'
|
|
24
|
+
description: '问述科技 Argent — 六禾园(ly-0001)办公室助理:只聊办公室现场/待办/货件节点/发票/执行机,只读 ERP,其它话题一律不接。'
|
|
25
|
+
order: 9
|
|
26
|
+
plugins:
|
|
27
|
+
- id: persona
|
|
28
|
+
name: '@deepseek-ai/dsh-persona'
|
|
29
|
+
config:
|
|
30
|
+
prefix: |-
|
|
31
|
+
你是「六禾园办公室助理」——问述科技(whyshu.com)Argent ERP 的办公室助手,运行在 DeepSeek Harness 上。
|
|
32
|
+
你只服务一个租户:六禾园(appId=ly-0001)的办公室日常。
|
|
33
|
+
|
|
34
|
+
## 一、只聊办公室(硬边界)
|
|
35
|
+
- 只回答与六禾园办公室**直接相关**的问题:在办/待办、货件与时间线节点进度、发票状态、
|
|
36
|
+
执行机与智能体在岗情况、成员与角色、运营日报、异常与阻塞、现场动态、以及"下一步该做什么"。
|
|
37
|
+
- 其它话题**一律不接**:写代码/改仓库、本机文件与终端、网页搜索、其它租户或其它业务线、
|
|
38
|
+
通用知识问答、闲聊与情感陪伴、投资/医疗/法律建议、代写文案等。
|
|
39
|
+
- 拒绝话术(简短、不解释太多):
|
|
40
|
+
「我只负责六禾园办公室的事,这个建议找通用助手。要不要我帮你看一下办公室现场 / 待办 / 时间线?」
|
|
41
|
+
- 用户问「你能做什么」时,只列办公室范围内的事,不要罗列工具名。
|
|
42
|
+
|
|
43
|
+
## 二、事实必须来自 ERP 工具(不许编)
|
|
44
|
+
- 所有业务事实都必须先用 ERP 只读工具查证;没有查证就不要说。
|
|
45
|
+
- 查不到就直说查不到,并说明缺什么(没有记录 / 无权限 / 上游不可达),不要猜测或用常识补全。
|
|
46
|
+
- 只读边界:不做任何写操作。用户要派单、开票、改节点、关任务时,说明这要在 ERP 网页里操作。
|
|
47
|
+
- 隐私:不输出 token、内部数据库 id、未打码手机号;提到成员用姓名。
|
|
48
|
+
|
|
49
|
+
## 三、回答风格
|
|
50
|
+
- 中文;先结论、后依据(货件号 / 节点 / 时间,一律 24 小时制)。
|
|
51
|
+
- 能列表就列表;长内容最多 5 条,其余的告诉用户"还有 N 条"。
|
|
52
|
+
- 涉及积分/金额时说明单位与来源;涉及时间时标明时区(本地时间)。
|
|
53
|
+
|
|
54
|
+
# 上下文管理:办公室会话会很长(反复查时间线/货件),必须自动压缩 + 裁剪工具结果,
|
|
55
|
+
# 否则每轮 prompt 越滚越大。与官方 standard / argent-* preset 同款写法。
|
|
56
|
+
- id: compaction
|
|
57
|
+
name: cordis:group
|
|
58
|
+
group: true
|
|
59
|
+
isolate:
|
|
60
|
+
compaction: true
|
|
61
|
+
toolResultPruner: true
|
|
62
|
+
config:
|
|
63
|
+
- id: compaction-basic
|
|
64
|
+
name: '@deepseek-ai/dsh-compaction-basic'
|
|
65
|
+
|
|
66
|
+
- id: command-compact
|
|
67
|
+
name: '@deepseek-ai/dsh-command-compact'
|
|
68
|
+
|
|
69
|
+
- id: tool-result-pruner
|
|
70
|
+
name: '@deepseek-ai/dsh-compaction-tool-result-pruner'
|
|
71
|
+
config:
|
|
72
|
+
thresholdChars: 8192
|
|
73
|
+
headChars: 4096
|
|
74
|
+
tailChars: 1024
|
|
75
|
+
|
|
76
|
+
# 工具边界(见文件头 ②):只放行 ERP 只读工具
|
|
77
|
+
- id: office-guard
|
|
78
|
+
name: '@whyshu/argent-erp/office-guard'
|
package/src/data.mjs
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* argent-erp · 数据层(宿主侧,Node)
|
|
3
|
+
*
|
|
4
|
+
* 为什么宿主侧要中转:账号 JWT 在**宿主**(credentials / env),浏览器里没有;
|
|
5
|
+
* 视图只调本地 `/api/argent-erp/*`,由这里带 token 去 whyshu.com,token 永不下发前端。
|
|
6
|
+
*
|
|
7
|
+
* 与 `plugins/argent-erp-ui/src/data.mjs` 的关系:本文件是同一份口径的**演进版**
|
|
8
|
+
* (多出办公室现场 employees / agents / machines 与「办公室聊一聊」历史只读),
|
|
9
|
+
* 旧包保留不动;新视图以本文件为准。
|
|
10
|
+
*
|
|
11
|
+
* 红线:只读(GET);本文件不做任何写操作。
|
|
12
|
+
*/
|
|
13
|
+
// ⚠️ 用 src/vendor 里的副本,**不**跨目录 import 仓库 core/lib:
|
|
14
|
+
// 当插件被 pnpm/复制安装到 profile(非软链)时,相对路径会指到 node_modules/core/lib ⇒ import 失败 ⇒ 整个插件不激活。
|
|
15
|
+
import { resolveAccountToken } from './vendor/account-token.mjs'
|
|
16
|
+
import { redactDeep } from './vendor/redact.mjs'
|
|
17
|
+
|
|
18
|
+
const API_BASE = (process.env.ARGENT_API_BASE || 'https://whyshu.com').replace(/\/+$/, '')
|
|
19
|
+
const DEFAULT_TENANT = process.env.ARGENT_TENANT || 'ly-0001'
|
|
20
|
+
const TIMEOUT_MS = Number(process.env.ARGENT_ERP_UI_TIMEOUT_MS || 15000)
|
|
21
|
+
|
|
22
|
+
export class ErpError extends Error {
|
|
23
|
+
constructor(code, message, status = 0) { super(message); this.code = code; this.status = status }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function apiGet(path, query = {}) {
|
|
27
|
+
const { token, source } = resolveAccountToken()
|
|
28
|
+
if (!token) throw new ErpError('no_token', '本机没有问述账号 token(先登录 / 检查 credentials)', 401)
|
|
29
|
+
const url = new URL(`${API_BASE}/api/argent/v1${path}`)
|
|
30
|
+
for (const [k, v] of Object.entries(query)) if (v !== undefined && v !== null && v !== '') url.searchParams.set(k, String(v))
|
|
31
|
+
let r
|
|
32
|
+
try {
|
|
33
|
+
r = await fetch(url, { headers: { 'x-api-key': token, accept: 'application/json' }, signal: AbortSignal.timeout(TIMEOUT_MS) })
|
|
34
|
+
} catch (e) {
|
|
35
|
+
throw new ErpError('network', `ERP 不可达:${String(e.message).slice(0, 120)}`)
|
|
36
|
+
}
|
|
37
|
+
if (r.status === 401) throw new ErpError('unauthorized', '账号 token 失效(401)⇒ 重新登录问述账号', 401)
|
|
38
|
+
if (r.status === 403) throw new ErpError('forbidden', '当前账号无权限(403)', 403)
|
|
39
|
+
if (!r.ok) throw new ErpError('http', `ERP HTTP ${r.status}`, r.status)
|
|
40
|
+
const body = await r.json()
|
|
41
|
+
if (body && body.ok === false) throw new ErpError('erp', (body.error && body.error.message) || 'ERP 返回失败')
|
|
42
|
+
return { data: body.data, source }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 账号 + 租户列表(视图首屏用它决定默认租户) */
|
|
46
|
+
export async function whoami() {
|
|
47
|
+
const me = await apiGet('/me')
|
|
48
|
+
const tenants = await apiGet('/me/tenants')
|
|
49
|
+
const list = (tenants.data && tenants.data.tenants) || []
|
|
50
|
+
const preferred = list.find((t) => t.appId === DEFAULT_TENANT) || list[0] || null
|
|
51
|
+
return { user: (me.data && me.data.user) || null, tenants: list, defaultTenant: preferred && preferred.appId, tokenSource: me.source }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 视图主数据:办公室现场 + KPI + 待办 + 动态(全部脱敏后返回)。
|
|
56
|
+
*
|
|
57
|
+
* 相比旧包的 overview(),这里保留 `employees` / `agents` / `kpiDetails.agents`:
|
|
58
|
+
* 「办公室」视图要看到"谁在岗、在做什么",旧包只留了 5 个数字。
|
|
59
|
+
* 裁剪口径:employees ≤ 12、runtime agents ≤ 12、machines ≤ 10、presets ≤ 40、动态 ≤ 10。
|
|
60
|
+
*/
|
|
61
|
+
export async function overview(tenant = DEFAULT_TENANT) {
|
|
62
|
+
const o = await apiGet(`/tenants/${encodeURIComponent(tenant)}/office`)
|
|
63
|
+
const d = o.data || {}
|
|
64
|
+
const kpis = d.kpis || {}
|
|
65
|
+
const agentsSection = d.agents || {}
|
|
66
|
+
return redactDeep({
|
|
67
|
+
tenant,
|
|
68
|
+
generatedAt: d.generatedAt || null,
|
|
69
|
+
profile: d.profile || null,
|
|
70
|
+
kpis: {
|
|
71
|
+
pending: kpis.pending || 0,
|
|
72
|
+
inProgress: kpis.inProgress || 0,
|
|
73
|
+
doneToday: kpis.doneToday || 0,
|
|
74
|
+
agentsOnline: kpis.agentsOnline || 0,
|
|
75
|
+
agentsTotal: kpis.agentsTotal || 0,
|
|
76
|
+
agentsLabel: kpis.agentsLabel || '执行机在线',
|
|
77
|
+
agentsSub: kpis.agentsSub || '在线 / 总数',
|
|
78
|
+
runners: kpis.runners || 0,
|
|
79
|
+
queue: kpis.queue || 0,
|
|
80
|
+
},
|
|
81
|
+
meta: d.meta || null,
|
|
82
|
+
employees: (d.employees || []).slice(0, 12).map((e) => ({
|
|
83
|
+
id: e.id,
|
|
84
|
+
name: e.name,
|
|
85
|
+
roleLabel: e.roleLabel || e.role || '',
|
|
86
|
+
kind: e.kind || '',
|
|
87
|
+
status: e.status || '',
|
|
88
|
+
current: e.current || null,
|
|
89
|
+
queueCount: e.queueCount || 0,
|
|
90
|
+
preset: e.preset || '',
|
|
91
|
+
})),
|
|
92
|
+
agents: {
|
|
93
|
+
agents: (agentsSection.agents || []).slice(0, 12).map((a) => ({
|
|
94
|
+
id: a.id,
|
|
95
|
+
machineId: a.machineId,
|
|
96
|
+
name: a.name,
|
|
97
|
+
preset: a.preset || '',
|
|
98
|
+
presetLabel: a.presetLabel || '',
|
|
99
|
+
roleLabel: a.roleLabel || '',
|
|
100
|
+
status: a.status || '',
|
|
101
|
+
current: a.current || null,
|
|
102
|
+
jobs: (a.jobs || []).slice(0, 10),
|
|
103
|
+
})),
|
|
104
|
+
machines: (agentsSection.machines || []).slice(0, 10).map((m) => ({
|
|
105
|
+
machineId: m.machineId,
|
|
106
|
+
name: m.name || m.machineId,
|
|
107
|
+
online: Boolean(m.online),
|
|
108
|
+
lastSeenAt: m.lastSeenAt || null,
|
|
109
|
+
defaultPreset: m.defaultPreset || '',
|
|
110
|
+
presets: (m.presets || []).slice(0, 40),
|
|
111
|
+
})),
|
|
112
|
+
presets: (agentsSection.presets || []).slice(0, 40),
|
|
113
|
+
},
|
|
114
|
+
workItems: (d.workItems || []).slice(0, 20),
|
|
115
|
+
activity: (d.activity || []).slice(0, 10),
|
|
116
|
+
authSource: o.source, // ⚠️ 别叫 tokenSource:redactDeep 会把含 token 的键丢掉
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** 货件列表(视图搜索用) */
|
|
121
|
+
export async function shipments(tenant = DEFAULT_TENANT, query = {}) {
|
|
122
|
+
const r = await apiGet(`/tenants/${encodeURIComponent(tenant)}/shipments`, {
|
|
123
|
+
q: query.q, status: query.status, fullStatus: query.fullStatus,
|
|
124
|
+
page: query.page || 1, limit: Math.min(Number(query.limit) || 20, 50),
|
|
125
|
+
})
|
|
126
|
+
return redactDeep(r.data)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* 节点 key → 中文名(与 ERP `progress.nodes[].label` 对齐,2026-11-04 实测)。
|
|
131
|
+
* ERP 若新增节点,这里补一行即可(视图只用于展示,不影响判定)。
|
|
132
|
+
*/
|
|
133
|
+
export const NODE_LABELS = {
|
|
134
|
+
packing: '备货装箱',
|
|
135
|
+
order: '接单 / FULL 建单',
|
|
136
|
+
carton_mark: '箱唛标签',
|
|
137
|
+
invoice: '发票文件',
|
|
138
|
+
handover: '交运货代',
|
|
139
|
+
in_transit: '在途运输',
|
|
140
|
+
warehouse: '入仓上架',
|
|
141
|
+
completed: '完成',
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* 时间线(对应 ERP `/admin/argent/#/timeline` 的时间线工作台)。
|
|
146
|
+
* 数据源 = `GET /sku-flows`(ERP 前端 timeline.js 用的就是它),按 updatedAt 倒序、按天分组。
|
|
147
|
+
* ⚠️ 不叫 timeline 字段:这里只取列表需要的摘要,详情走 flowDetail()。
|
|
148
|
+
*/
|
|
149
|
+
export async function timeline(tenant = DEFAULT_TENANT, { days = 30, limit = 50 } = {}) {
|
|
150
|
+
const r = await apiGet(`/tenants/${encodeURIComponent(tenant)}/sku-flows`, { limit: Math.min(Number(limit) || 50, 100) })
|
|
151
|
+
const flows = (r.data && r.data.flows) || []
|
|
152
|
+
const since = Date.now() - (Number(days) || 30) * 86400000
|
|
153
|
+
const items = flows
|
|
154
|
+
.map((f) => ({
|
|
155
|
+
id: f._id || f.id,
|
|
156
|
+
title: f.title || f.productName || f.sku || '',
|
|
157
|
+
sku: f.sku || '',
|
|
158
|
+
store: f.store || '',
|
|
159
|
+
shipmentNo: f.shipmentNo || '',
|
|
160
|
+
statusLabel: f.statusLabel || f.status || '',
|
|
161
|
+
currentStepLabel: f.currentStepLabel || f.currentStep || '',
|
|
162
|
+
updatedAt: f.updatedAt || f.createdAt || '',
|
|
163
|
+
todoCount: Number(f.todoCount || 0),
|
|
164
|
+
}))
|
|
165
|
+
.filter((f) => f.id && Date.parse(f.updatedAt) >= since)
|
|
166
|
+
.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt))
|
|
167
|
+
const byDay = new Map()
|
|
168
|
+
for (const it of items) {
|
|
169
|
+
const d = new Date(it.updatedAt)
|
|
170
|
+
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
|
171
|
+
if (!byDay.has(key)) byDay.set(key, [])
|
|
172
|
+
byDay.get(key).push(it)
|
|
173
|
+
}
|
|
174
|
+
return redactDeep({
|
|
175
|
+
tenant,
|
|
176
|
+
total: items.length,
|
|
177
|
+
days: [...byDay.entries()].map(([date, evs]) => ({ date, events: evs })),
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** 流程详情(时间线详情页):8 节点 + 每一步的字段 */
|
|
182
|
+
export async function flowDetail(tenant = DEFAULT_TENANT, flowId) {
|
|
183
|
+
if (!flowId) throw new ErpError('validation', '缺 flowId', 400)
|
|
184
|
+
const r = await apiGet(`/tenants/${encodeURIComponent(tenant)}/sku-flows/${encodeURIComponent(flowId)}`)
|
|
185
|
+
const f = (r.data && r.data.flow) || {}
|
|
186
|
+
const nodes = ((f.timeline && f.timeline.nodes) || []).map((n) => ({
|
|
187
|
+
key: n.key,
|
|
188
|
+
title: n.title || n.key,
|
|
189
|
+
status: n.status || '',
|
|
190
|
+
statusLabel: n.statusLabel || n.status || '',
|
|
191
|
+
ownerName: n.ownerName || n.owner || '',
|
|
192
|
+
at: n.completedAt || n.at || n.startedAt || null,
|
|
193
|
+
fields: (n.fields || []).slice(0, 12).map((x) => ({ label: x.label, value: x.value === undefined || x.value === null ? '' : String(x.value).slice(0, 120) })),
|
|
194
|
+
}))
|
|
195
|
+
return redactDeep({
|
|
196
|
+
flowId,
|
|
197
|
+
code: f.code || '',
|
|
198
|
+
title: f.title || f.productName || f.sku || flowId,
|
|
199
|
+
sku: f.sku || '',
|
|
200
|
+
store: f.store || '',
|
|
201
|
+
shipmentNo: f.shipmentNo || '',
|
|
202
|
+
statusLabel: f.statusLabel || f.status || '',
|
|
203
|
+
currentStepLabel: f.currentStepLabel || f.currentStep || '',
|
|
204
|
+
updatedAt: f.updatedAt || '',
|
|
205
|
+
nodes,
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* 待办事项(flow case)详情。ERP 里待办的 id 是 case id(不是 shipment 的 sourceRecordId)。
|
|
211
|
+
* 404 时抛 not_found,由视图回退成"在浏览器打开 #/timeline/<id>"。
|
|
212
|
+
*/
|
|
213
|
+
export async function caseDetail(tenant = DEFAULT_TENANT, caseId) {
|
|
214
|
+
if (!caseId) throw new ErpError('validation', '缺 caseId', 400)
|
|
215
|
+
const r = await apiGet(`/tenants/${encodeURIComponent(tenant)}/flows/cases/${encodeURIComponent(caseId)}`)
|
|
216
|
+
const c = (r.data && (r.data.case || r.data)) || {}
|
|
217
|
+
const fields = c.fields || c.data || {}
|
|
218
|
+
return redactDeep({
|
|
219
|
+
caseId,
|
|
220
|
+
title: c.title || fields.title || fields.shipmentNo || caseId,
|
|
221
|
+
node: c.node || c.nodeLabel || '',
|
|
222
|
+
status: c.status || '',
|
|
223
|
+
ownerName: c.ownerName || '',
|
|
224
|
+
updatedAt: c.updatedAt || c.at || '',
|
|
225
|
+
fields: Object.fromEntries(Object.entries(fields).slice(0, 20)),
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** 货件详情(视图点进去看):只挑视图要显示的字段,其余丢弃 */
|
|
230
|
+
export async function shipmentDetail(tenant = DEFAULT_TENANT, recordId) {
|
|
231
|
+
if (!recordId) throw new ErpError('validation', '缺 recordId', 400)
|
|
232
|
+
const r = await apiGet(`/tenants/${encodeURIComponent(tenant)}/shipments/${encodeURIComponent(recordId)}`)
|
|
233
|
+
const d = r.data || {}
|
|
234
|
+
const sh = d.shipment || {}
|
|
235
|
+
const inv = d.invoice || {}
|
|
236
|
+
const nodes = ((d.progress && d.progress.nodes) || []).map((n) => ({ label: n.label, status: n.status, at: n.completedAt || null }))
|
|
237
|
+
return redactDeep({
|
|
238
|
+
recordId,
|
|
239
|
+
shipmentNo: sh.appointmentShipmentNo || sh.shipmentNo || sh.sourceRecordId || recordId,
|
|
240
|
+
store: sh.shop || sh.storeName || '',
|
|
241
|
+
fullStatus: sh.fullStatus || '',
|
|
242
|
+
currentStep: sh.currentStep || '',
|
|
243
|
+
boxCount: sh.boxCount || null,
|
|
244
|
+
sku: sh.sku || '',
|
|
245
|
+
nodes,
|
|
246
|
+
invoice: { status: inv.invoiceStatus || inv.status || '', ready: Boolean(inv.invoiceReady), error: inv.error || '' },
|
|
247
|
+
})
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* 「办公室聊一聊」历史(**只读**)。
|
|
252
|
+
*
|
|
253
|
+
* ⚠️ 口径(2026-11-07 侦察):这条 chat 是 **ERP 服务端**的 LLM + 工具循环(POST .../office/chat,自定义 SSE),
|
|
254
|
+
* 不是 dsh 会话;这里只读它的历史落库(OfficeChatSession,tenantId+userId 双隔离)。
|
|
255
|
+
* 真正的 dsh 会话通道是另一套(ERP→执行机的 office/sessions 命令队列,见 office-ai-team.js)。
|
|
256
|
+
*/
|
|
257
|
+
export async function chatSessions(tenant = DEFAULT_TENANT, { limit = 20 } = {}) {
|
|
258
|
+
const r = await apiGet(`/tenants/${encodeURIComponent(tenant)}/office/chat/sessions`, { limit: Math.min(Number(limit) || 20, 50) })
|
|
259
|
+
const list = (r.data && (r.data.sessions || r.data.items)) || []
|
|
260
|
+
return redactDeep({
|
|
261
|
+
tenant,
|
|
262
|
+
sessions: list.slice(0, 50).map((s) => ({
|
|
263
|
+
id: s.id || s._id,
|
|
264
|
+
title: s.title || '',
|
|
265
|
+
updatedAt: s.updatedAt || s.createdAt || '',
|
|
266
|
+
messageCount: s.messageCount || 0,
|
|
267
|
+
})),
|
|
268
|
+
})
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** 「办公室聊一聊」单个历史会话(只读):messages ≤ 100,卡片只留摘要 */
|
|
272
|
+
export async function chatSession(tenant = DEFAULT_TENANT, sessionId) {
|
|
273
|
+
if (!sessionId) throw new ErpError('validation', '缺 sessionId', 400)
|
|
274
|
+
const r = await apiGet(`/tenants/${encodeURIComponent(tenant)}/office/chat/sessions/${encodeURIComponent(sessionId)}`)
|
|
275
|
+
const s = (r.data && (r.data.session || r.data)) || {}
|
|
276
|
+
const messages = (s.messages || []).slice(-100).map((m) => ({
|
|
277
|
+
role: m.role === 'assistant' ? 'assistant' : 'user',
|
|
278
|
+
content: m.content === undefined || m.content === null ? '' : String(m.content).slice(0, 4000),
|
|
279
|
+
ts: m.ts || null,
|
|
280
|
+
points: m.points || null,
|
|
281
|
+
tools: (m.tools || []).slice(0, 10),
|
|
282
|
+
cards: (m.cards || []).slice(0, 3).map((c) => ({ type: c.type || '', title: c.title || '' })),
|
|
283
|
+
proposal: m.proposal ? { proposalId: m.proposal.proposalId, action: m.proposal.action, actionLabel: m.proposal.actionLabel, status: m.proposal.status } : null,
|
|
284
|
+
}))
|
|
285
|
+
return redactDeep({ sessionId, id: s.id || sessionId, title: s.title || '', createdAt: s.createdAt || null, updatedAt: s.updatedAt || null, messages })
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** 视图角标:只返回数字,前端不用拉全量 */
|
|
289
|
+
export async function badge(tenant = DEFAULT_TENANT) {
|
|
290
|
+
try {
|
|
291
|
+
const o = await apiGet(`/tenants/${encodeURIComponent(tenant)}/office`)
|
|
292
|
+
const k = (o.data && o.data.kpis) || {}
|
|
293
|
+
return { pending: k.pending || 0, inProgress: k.inProgress || 0 }
|
|
294
|
+
} catch {
|
|
295
|
+
return { pending: 0, inProgress: 0, error: true }
|
|
296
|
+
}
|
|
297
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* argent-erp · 宿主侧插件(v0.2.3)
|
|
3
|
+
*
|
|
4
|
+
* 职责只有两件:
|
|
5
|
+
* 1. 注册本地只读端点 `/api/argent-erp/*`(带账号 token 去 whyshu.com,**token 不下发前端**);
|
|
6
|
+
* 2. 由 bundle 的客户端部分(dist/client.js)在对话顶部注册一个 `conversation.view` tab。
|
|
7
|
+
*
|
|
8
|
+
* 红线:只读;不重启宿主;不改任何核心配置项;ERP 不可达时**降级为错误卡片**,不阻塞 dsh。
|
|
9
|
+
*/
|
|
10
|
+
import { readFileSync } from 'node:fs'
|
|
11
|
+
import { dirname, join } from 'node:path'
|
|
12
|
+
import { fileURLToPath } from 'node:url'
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
whoami, overview, shipments, shipmentDetail, caseDetail, timeline, flowDetail, badge,
|
|
16
|
+
chatSessions, chatSession, ErpError,
|
|
17
|
+
} from './data.mjs'
|
|
18
|
+
|
|
19
|
+
/** 每次都读盘:ESM 模块缓存会让"重装后 version 还显示旧值",健康检查必须反映当前磁盘版本 */
|
|
20
|
+
function pkgVersion() {
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(readFileSync(join(dirname(dirname(fileURLToPath(import.meta.url))), 'package.json'), 'utf8')).version || '0.0.0'
|
|
23
|
+
} catch { return '0.0.0' }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** apply 时注入的宿主 ctx(只用于健康检查里数 ERP 工具,不缓存业务数据) */
|
|
27
|
+
let HOST_CTX = null
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 当前可见的 ERP 只读工具数(= 0 说明没装 @whyshu/argent / MCP 没起;-1 = 读不到 tools 服务)
|
|
31
|
+
* ⚠️ 用 ctx.get('tools') 而不是 ctx.tools:本插件只 inject webServer,直接取 ctx.tools 会抛。
|
|
32
|
+
*/
|
|
33
|
+
function erpToolCount() {
|
|
34
|
+
try {
|
|
35
|
+
const tools = HOST_CTX && typeof HOST_CTX.get === 'function' ? HOST_CTX.get('tools') : HOST_CTX?.tools
|
|
36
|
+
if (!tools || typeof tools.schemas !== 'function') return -1
|
|
37
|
+
const list = tools.schemas() || []
|
|
38
|
+
return list.filter((s) => String((s && s.name) || '').startsWith('mcp__argent__erp_')).length
|
|
39
|
+
} catch { return -1 }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const name = 'argent-erp'
|
|
43
|
+
export const inject = ['webServer']
|
|
44
|
+
|
|
45
|
+
/** 客户端 bundle 是否真的被加载过(区分"bundle 没加载" vs "加载了但没显示") */
|
|
46
|
+
const CLIENT_PING = { at: null, ua: '' }
|
|
47
|
+
/** 视图是否真的**渲染**过(只有切到 ERP tab 才会打;用于区分"加载了但没选这个 tab") */
|
|
48
|
+
const VIEW_PING = { at: null, ua: '' }
|
|
49
|
+
|
|
50
|
+
const json = (res, code, body) => {
|
|
51
|
+
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' })
|
|
52
|
+
res.end(JSON.stringify(body))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function handle(req, res) {
|
|
56
|
+
const url = new URL(req.url || '/', 'http://localhost')
|
|
57
|
+
const tenant = url.searchParams.get('tenant') || undefined
|
|
58
|
+
if (req.method !== 'GET') return json(res, 405, { ok: false, error: 'method_not_allowed' })
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
if (url.pathname === '/api/argent-erp/health') {
|
|
62
|
+
const erpTools = erpToolCount()
|
|
63
|
+
return json(res, 200, {
|
|
64
|
+
ok: true, version: pkgVersion(),
|
|
65
|
+
erpTools, // 0 ⇒ 只装了本插件、没装 @whyshu/argent
|
|
66
|
+
officePreset: 'argent-office', // 本插件声明的办公室角色 preset id
|
|
67
|
+
clientPingAt: CLIENT_PING.at, clientUa: CLIENT_PING.ua,
|
|
68
|
+
viewPingAt: VIEW_PING.at, viewUa: VIEW_PING.ua,
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
if (url.pathname === '/api/argent-erp/ping') {
|
|
72
|
+
const at = new Date().toISOString()
|
|
73
|
+
const ua = String((req.headers && req.headers['user-agent']) || '').slice(0, 80)
|
|
74
|
+
if (url.searchParams.get('from') === 'view') Object.assign(VIEW_PING, { at, ua })
|
|
75
|
+
else Object.assign(CLIENT_PING, { at, ua })
|
|
76
|
+
return json(res, 200, { ok: true })
|
|
77
|
+
}
|
|
78
|
+
if (url.pathname === '/api/argent-erp/whoami') return json(res, 200, { ok: true, data: await whoami() })
|
|
79
|
+
if (url.pathname === '/api/argent-erp/overview') return json(res, 200, { ok: true, data: await overview(tenant) })
|
|
80
|
+
if (url.pathname === '/api/argent-erp/badge') return json(res, 200, { ok: true, data: await badge(tenant) })
|
|
81
|
+
if (url.pathname === '/api/argent-erp/timeline') {
|
|
82
|
+
return json(res, 200, { ok: true, data: await timeline(tenant, { days: url.searchParams.get('days') || 30 }) })
|
|
83
|
+
}
|
|
84
|
+
if (url.pathname === '/api/argent-erp/flow') {
|
|
85
|
+
return json(res, 200, { ok: true, data: await flowDetail(tenant, url.searchParams.get('id') || '') })
|
|
86
|
+
}
|
|
87
|
+
if (url.pathname === '/api/argent-erp/case') {
|
|
88
|
+
return json(res, 200, { ok: true, data: await caseDetail(tenant, url.searchParams.get('id') || '') })
|
|
89
|
+
}
|
|
90
|
+
if (url.pathname === '/api/argent-erp/shipment') {
|
|
91
|
+
return json(res, 200, { ok: true, data: await shipmentDetail(tenant, url.searchParams.get('recordId') || '') })
|
|
92
|
+
}
|
|
93
|
+
if (url.pathname === '/api/argent-erp/shipments') {
|
|
94
|
+
return json(res, 200, { ok: true, data: await shipments(tenant, { q: url.searchParams.get('q') || '', limit: url.searchParams.get('limit') || 20 }) })
|
|
95
|
+
}
|
|
96
|
+
if (url.pathname === '/api/argent-erp/chat/sessions') {
|
|
97
|
+
return json(res, 200, { ok: true, data: await chatSessions(tenant, { limit: url.searchParams.get('limit') || 20 }) })
|
|
98
|
+
}
|
|
99
|
+
if (url.pathname === '/api/argent-erp/chat/session') {
|
|
100
|
+
return json(res, 200, { ok: true, data: await chatSession(tenant, url.searchParams.get('id') || '') })
|
|
101
|
+
}
|
|
102
|
+
return json(res, 404, { ok: false, error: 'not_found' })
|
|
103
|
+
} catch (e) {
|
|
104
|
+
const code = e instanceof ErpError ? e.code : 'error'
|
|
105
|
+
const status = e instanceof ErpError && e.status ? e.status : 502
|
|
106
|
+
return json(res, status, { ok: false, error: code, message: String(e.message || e).slice(0, 200) })
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function apply(ctx) {
|
|
111
|
+
// ⛔ 永不抛出:插件激活失败会让整个 web boot 报 "entry did not activate"(2026-11-04 实测)。
|
|
112
|
+
try {
|
|
113
|
+
HOST_CTX = ctx
|
|
114
|
+
if (!ctx || !ctx.webServer || typeof ctx.webServer.register !== 'function') {
|
|
115
|
+
console.error('[argent-erp] 未拿到 webServer 服务,跳过端点注册(视图会显示 404,但 dsh 正常启动)')
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: '/api/argent-erp', handler: handle }), 'argent-erp: read-only endpoints')
|
|
119
|
+
console.log(`[argent-erp] 已注册 /api/argent-erp/*(v${pkgVersion()},只读,账号 token 仅在宿主侧使用)`)
|
|
120
|
+
} catch (e) {
|
|
121
|
+
console.error('[argent-erp] 端点注册失败(不影响 dsh 其他功能):', e?.message || e)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* argent-office-guard · 办公室会话的**工具边界**(preset 内挂载)
|
|
3
|
+
*
|
|
4
|
+
* 为什么要它:dsh 的"全局工具"对所有会话可见(MCP 网关、Agent Team 等都由 host 层注册)。
|
|
5
|
+
* 「只聊办公室」如果只写在提示词里,模型一旦被诱导仍可能去调本机/其它能力。
|
|
6
|
+
* 这里用 `ctx.tools.restrict({ allow })` 把**本 preset 作用域**内可见的全局工具收敛成
|
|
7
|
+
* 只有 ERP 只读工具(`mcp__argent__erp_*`)——其余一律不可见(工具列表里根本没有)。
|
|
8
|
+
* 没有 ERP 工具时(客户只装了本插件、没装 @whyshu/argent)⇒ 用 `deny` 全禁,**不**退回"全部可用"。
|
|
9
|
+
*
|
|
10
|
+
* 红线:
|
|
11
|
+
* - ⛔ 永不抛出:本行若激活失败,整个 preset 不可用(DSH 的 preset 激活是原子的)。
|
|
12
|
+
* - ⛔ 不写死工具名:MCP 工具名带服务端后缀(如 `_db43c747eef4`),按前缀匹配、运行时重算。
|
|
13
|
+
* - ⛔ 无循环:allow 集合从**全局视图**(`ctx.tools.definitions()` 不带 scope)计算,
|
|
14
|
+
* 该视图不受本作用域 mask 影响;集合不变则不再重新注册。
|
|
15
|
+
*/
|
|
16
|
+
export const name = 'argent-office-guard'
|
|
17
|
+
export const inject = ['tools']
|
|
18
|
+
|
|
19
|
+
/** 只放行 ERP 只读工具。MCP 命名:`mcp__<server>__<tool>[_<serverSuffix>]`。 */
|
|
20
|
+
export const ERP_TOOL_PREFIX = 'mcp__argent__erp_'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 从**全局**工具名里挑出 ERP 只读工具(纯函数,可单测)。
|
|
24
|
+
* 为什么要按前缀而不是写死名单:MCP 工具名带服务端后缀(`_db43c747eef4`),会随服务端配置变。
|
|
25
|
+
* @param {string[]} names - `ctx.tools.definitions()` 的 name 集合(全局视图)
|
|
26
|
+
* @returns {string[]} 排序后的允许列表;空数组表示 MCP 还没注册
|
|
27
|
+
*/
|
|
28
|
+
export function filterErpTools(names) {
|
|
29
|
+
return (Array.isArray(names) ? names : [])
|
|
30
|
+
.map((n) => String(n || ''))
|
|
31
|
+
.filter((n) => n.startsWith(ERP_TOOL_PREFIX))
|
|
32
|
+
.sort()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function apply(ctx) {
|
|
36
|
+
try {
|
|
37
|
+
let disposeMask = null
|
|
38
|
+
let lastMask = ''
|
|
39
|
+
/**
|
|
40
|
+
* 重入闸(2026-11-08 修):`ctx.tools.restrict()` 与它的 disposer **都会同步 emit
|
|
41
|
+
* `tools/change`**(见 dsh-scope `ScopedLayers.effect` 的两处 onChange)。
|
|
42
|
+
* 第一次收敛之后才出现的全局工具(典型:MCP 网关在启动后几秒才注册
|
|
43
|
+
* `mcp__argent__erp_*`)会让本函数在「dispose 旧 mask → restrict 新 mask」中途被
|
|
44
|
+
* 同步回调重入;此时 `lastMask` 还没更新 ⇒ 每层都判定"集合变了" ⇒ 无限同步递归
|
|
45
|
+
* (主线程 100% CPU + 堆 OOM,dsh 端口 bound 但 HTTP 不再响应)。
|
|
46
|
+
* 判据:工具名集合没变就只允许一个 reconcile 在跑;本函数自己触发的变化只记 dirty,
|
|
47
|
+
* 等这一轮收敛完再补一轮(保证真有新工具时不会漏收敛)。
|
|
48
|
+
*/
|
|
49
|
+
let reconciling = false
|
|
50
|
+
let dirty = false
|
|
51
|
+
|
|
52
|
+
/** 收敛一轮:按当前全局工具名重算并换 mask。 */
|
|
53
|
+
const applyMask = () => {
|
|
54
|
+
// 全局视图(不传 scope)⇒ 拿到的是"未收敛"的全量工具,不受本作用域 mask 影响
|
|
55
|
+
const all = typeof ctx.tools?.definitions === 'function' ? ctx.tools.definitions() : []
|
|
56
|
+
const names = all.map((d) => (d && d.name) || '')
|
|
57
|
+
const allow = filterErpTools(names)
|
|
58
|
+
// 有 ERP 工具 ⇒ 只放行它们;一个都没有(没装 @whyshu/argent / MCP 未起)⇒ **全部禁掉**,
|
|
59
|
+
// 宁可这个会话无工具,也不让 Agent Team 等全局工具漏进来(边界优先)。
|
|
60
|
+
const mask = allow.length ? { allow } : (names.length ? { deny: names } : null)
|
|
61
|
+
if (!mask) return
|
|
62
|
+
const key = JSON.stringify(mask)
|
|
63
|
+
if (key === lastMask) return
|
|
64
|
+
// 先记账再动 mask:restrict/它的 disposer 同步 emit 时,重入会被闸挡住且只标 dirty;
|
|
65
|
+
// 即便真漏进来一次,key 也已是最新值,不会再次判定"集合变了"。
|
|
66
|
+
lastMask = key
|
|
67
|
+
try { disposeMask?.() } catch { /* noop */ }
|
|
68
|
+
disposeMask = ctx.tools.restrict(mask)
|
|
69
|
+
console.log(allow.length
|
|
70
|
+
? `[argent-office] 工具边界:仅放行 ${allow.length} 个 ERP 只读工具`
|
|
71
|
+
: '[argent-office] 工具边界:未发现 ERP 工具 ⇒ 全部工具禁用(检查 @whyshu/argent 是否安装)')
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const reconcile = () => {
|
|
75
|
+
if (reconciling) { dirty = true; return }
|
|
76
|
+
reconciling = true
|
|
77
|
+
try {
|
|
78
|
+
// 最多补 20 轮:只有"收敛过程中又出现了新工具"才会再来一轮,正常情况 1 轮结束
|
|
79
|
+
for (let round = 0; round < 20; round++) {
|
|
80
|
+
dirty = false
|
|
81
|
+
applyMask()
|
|
82
|
+
if (!dirty) break
|
|
83
|
+
}
|
|
84
|
+
} catch (e) {
|
|
85
|
+
// 收敛失败不阻塞会话:提示词仍然是第二道边界;清掉 key 让下一次工具变化时重试
|
|
86
|
+
lastMask = ''
|
|
87
|
+
console.warn('[argent-office] 工具边界收敛失败(继续按提示词边界运行):', (e && e.message) || e)
|
|
88
|
+
} finally {
|
|
89
|
+
reconciling = false
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
ctx.effect(() => {
|
|
94
|
+
reconcile()
|
|
95
|
+
let off = null
|
|
96
|
+
try { off = ctx.on('tools/change', reconcile) } catch { /* 没有事件服务就只做一次 */ }
|
|
97
|
+
return () => {
|
|
98
|
+
try { off?.() } catch { /* noop */ }
|
|
99
|
+
try { disposeMask?.() } catch { /* noop */ }
|
|
100
|
+
}
|
|
101
|
+
}, 'argent-office: ERP-only tool mask')
|
|
102
|
+
} catch (e) {
|
|
103
|
+
console.warn('[argent-office] guard 挂载失败(不影响会话,提示词边界仍生效):', (e && e.message) || e)
|
|
104
|
+
}
|
|
105
|
+
}
|