dsh-contextos-devops 0.0.0-stage → 1.48.32

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.
Files changed (80) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +207 -2
  3. package/assets/avatar.jpg +0 -0
  4. package/assets/client.css +3316 -0
  5. package/assets/icon-180.png +0 -0
  6. package/build/client.mjs +64 -0
  7. package/client.js +7622 -0
  8. package/cordis.patch.yml +3 -0
  9. package/icon.svg +15 -0
  10. package/index.js +345 -0
  11. package/lib/base/env.js +22 -0
  12. package/lib/base/field.js +25 -0
  13. package/lib/base/http.js +140 -0
  14. package/lib/base/platform.js +33 -0
  15. package/lib/base/registry.js +186 -0
  16. package/lib/base/state.js +66 -0
  17. package/lib/base/table.js +47 -0
  18. package/lib/base/texttable.js +26 -0
  19. package/lib/base/url.js +178 -0
  20. package/lib/core/README.md +17 -0
  21. package/lib/core/apps.js +675 -0
  22. package/lib/core/auth.js +110 -0
  23. package/lib/core/avatar.js +289 -0
  24. package/lib/core/backup.js +105 -0
  25. package/lib/core/codegen.js +401 -0
  26. package/lib/core/copilot.js +419 -0
  27. package/lib/core/data.js +157 -0
  28. package/lib/core/device.js +148 -0
  29. package/lib/core/files.js +152 -0
  30. package/lib/core/gobuild.js +63 -0
  31. package/lib/core/iam.js +122 -0
  32. package/lib/core/identity.js +22 -0
  33. package/lib/core/nodes.js +627 -0
  34. package/lib/core/ops.js +306 -0
  35. package/lib/core/project.js +393 -0
  36. package/lib/core/spaces.js +406 -0
  37. package/lib/core/terminal.js +137 -0
  38. package/lib/core/terminal2.js +141 -0
  39. package/lib/core/users.js +51 -0
  40. package/lib/core/vocab.js +158 -0
  41. package/lib/misc/async.js +15 -0
  42. package/lib/misc/shell.js +27 -0
  43. package/lib/misc/static.js +33 -0
  44. package/locale/en.json +10 -0
  45. package/locale/zh.json +10 -0
  46. package/package.json +92 -4
  47. package/src/client/README.md +37 -0
  48. package/src/client/parts/00-prelude.js +315 -0
  49. package/src/client/parts/05-bootstrap.js +61 -0
  50. package/src/client/parts/08-store.js +85 -0
  51. package/src/client/parts/10-ui.js +188 -0
  52. package/src/client/parts/12-debug.js +361 -0
  53. package/src/client/parts/15-layout.js +86 -0
  54. package/src/client/parts/20-devworkspace.js +789 -0
  55. package/src/client/parts/25-editor-ui.js +28 -0
  56. package/src/client/parts/30-modals.js +85 -0
  57. package/src/client/parts/32-cards.js +176 -0
  58. package/src/client/parts/34-forms.js +176 -0
  59. package/src/client/parts/36-data.js +267 -0
  60. package/src/client/parts/40-project.js +430 -0
  61. package/src/client/parts/45-map.js +109 -0
  62. package/src/client/parts/46-pm.js +158 -0
  63. package/src/client/parts/47-three.js +324 -0
  64. package/src/client/parts/49-msg.js +167 -0
  65. package/src/client/parts/50-chat.js +1422 -0
  66. package/src/client/parts/51-pmselection.js +31 -0
  67. package/src/client/parts/52-pmdata.js +29 -0
  68. package/src/client/parts/55-pm.js +146 -0
  69. package/src/client/parts/58-config.js +381 -0
  70. package/src/client/parts/59-monitor.js +262 -0
  71. package/src/client/parts/60-page.js +1518 -0
  72. package/src/client/styles/kit.css +20 -0
  73. package/src/client/styles/main.css +3294 -0
  74. package/src/client/styles/tokens.css +2 -0
  75. package/vendor/leaflet/leaflet.css +661 -0
  76. package/vendor/leaflet/leaflet.js +6 -0
  77. package/vendor/three/three.module.js +53044 -0
  78. package/vendor/xterm/addon-fit.js +2 -0
  79. package/vendor/xterm/xterm.css +218 -0
  80. package/vendor/xterm/xterm.js +2 -0
@@ -0,0 +1,419 @@
1
+ // 核心业务:AI Copilot(模型解析、对话、动作提取、目标拆解)。
2
+ import path from 'node:path';
3
+ import { http, encode } from '../base/http.js';
4
+ import { fieldSegments, splitFields } from '../base/field.js';
5
+ import { listAvatars } from './avatar.js';
6
+
7
+ let dshLlm = null;
8
+ export function setLlm(v) { dshLlm = v; }
9
+ export function getLlm() { return dshLlm; }
10
+
11
+ export function chatProviders() {
12
+ try { return dshLlm?.listProviders?.() || []; } catch { return []; }
13
+ }
14
+
15
+ async function resolveChatModel({ provider, model } = {}) {
16
+ if (!dshLlm) throw new Error('DSH llm service is not mounted');
17
+ const providers = chatProviders();
18
+ const ordered = provider
19
+ ? providers.filter((p) => String(p.id) === String(provider))
20
+ : providers.slice().sort((a, b) => (/deepseek/i.test(String(a.id)) ? -1 : 0) - (/deepseek/i.test(String(b.id)) ? -1 : 0));
21
+ let chosen = null;
22
+ let models = [];
23
+ for (const p of ordered) {
24
+ let list = [];
25
+ try { list = await dshLlm.listModels(p.id); } catch { /* catalog-less adapter */ }
26
+ if (list.length || (provider && String(p.id) === String(provider))) { chosen = p; models = list; break; }
27
+ }
28
+ if (!chosen) {
29
+ chosen = providers.find((p) => /deepseek/i.test(String(p.id))) || providers[0];
30
+ if (!chosen) throw new Error('DSH has no llm provider mounted');
31
+ try { models = await dshLlm.listModels(chosen.id); } catch { models = []; }
32
+ }
33
+ const modelId = model || models.find((m) => /flash/i.test(String(m.id)))?.id || models[0]?.id || 'deepseek-v4-flash';
34
+ return { provider: chosen.id, model: modelId };
35
+ }
36
+
37
+ function extractAction(text) {
38
+ const raw = String(text || '').trim();
39
+ const start = raw.indexOf('{');
40
+ const end = raw.lastIndexOf('}');
41
+ if (start < 0 || end <= start) return null;
42
+ try {
43
+ const parsed = JSON.parse(raw.slice(start, end + 1));
44
+ return parsed && typeof parsed === 'object' ? parsed : null;
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ const MODE_ACTIONS = {
51
+ avatar: ['createInstance', 'createStoreInstance', 'readData', 'writeData', 'listApps', 'storeApps', 'projectStatus', 'pmList', 'pmCreate', 'pmModify', 'pmRemove', 'planProject', 'createAvatar', 'openApp'],
52
+ factory: ['deploy', 'start', 'stop', 'delete', 'createSpace', 'officialSpaces', 'initSpace', 'spaceStart', 'spaceStop', 'spaceBuild', 'autogenApp', 'createApp', 'generateAppCode', 'code', 'buildSpace', 'compile', 'dockerDeploy', 'remoteDeploy', 'remoteLogin', 'createInstance', 'createStoreInstance', 'listApps', 'storeApps'],
53
+ iot: ['devices', 'sshHosts', 'sysControl', 'updateBackground', 'createBackground', 'openApp', 'openNode', 'listApps', 'projectStatus', 'pmList'],
54
+ };
55
+
56
+ export async function chatWithDSH({ message, history = [], context = {}, provider = '', model = '', mode = 'factory' } = {}) {
57
+ if (!String(message || '').trim()) throw new Error('empty message');
58
+ const choice = await resolveChatModel({ provider, model });
59
+ const system = [
60
+ 'RSI IRON LAW (highest priority; applies to every reply):',
61
+ 'GOAL → PLAN(time/cost/risk) → ANALYZE/DECIDE → TASKS → EXECUTE → REPORT(what/how/result) → FEEDBACK(lesson/gate).',
62
+ 'No goal, no action. No report, not done. No feedback, not closed.',
63
+ 'Prefer existing code/specs/flows; abstract duplicates; sediment lessons into code/docs/memory; never repeat a known error.',
64
+ 'For non-trivial work reply with the sections 目标/计划/分析与决策/任务/执行/报告/反馈. Simple Q&A may stay concise but must not invent results.',
65
+ 'If goal or acceptance criteria is missing, ask before changing code. After code changes, verify API read-back and rendered UI; add a gate for every recurring bug.',
66
+ 'CONTEXTOS DOCTRINE (三纲五常): three guides are cluster deployment (all devices connected), modular development (all code generated), automated applications (all data flowing); five constants are code, docs, conversation, team, market.',
67
+ 'Every finished round must leave four artifacts: runnable code, updated docs, a strengthened gate, and a written rule. Prefer abstraction so the next similar feature needs less code.',
68
+ 'You are ContextOS DevOps Copilot inside DeepSeek Harness.',
69
+ 'Turn the user request into exactly one JSON object, no markdown:',
70
+ '{"reply":"short Chinese answer","action":null}',
71
+ 'or for one operation:',
72
+ '{"reply":"short Chinese answer","action":{"op":"deploy|createSpace|officialSpaces|initSpace|autogenApp|createApp|generateAppCode|createInstance|storeApps|createStoreInstance|createAvatar|projectStatus|planProject|pmList|pmCreate|pmModify|pmRemove|pmAct|buildSpace|openApp|openNode|code|listApps|devices|data","args":{...}}}',
73
+ 'or for multiple operations:',
74
+ '{"reply":"short Chinese answer","actions":[{"op":"pmCreate","args":{...}},{"op":"pmCreate","args":{...}}]}',
75
+ 'Rules:',
76
+ '- Only use actions that are valid in the current context; ask for missing node/space/name instead of inventing.',
77
+ '- deploy args: {port:number}; createSpace args: {name:string}; initSpace args: {space:string};',
78
+ '- officialSpaces args: {keys:[standard key or Chinese name]}; autogenApp args: {name,help,place,value,trans}; createInstance args: {space,command,place,name,city,company}; buildSpace args: {space};',
79
+ '- createApp/generateAppCode args: {name, zone?, help?}; openApp args: {space,name}; openNode args: {port}; code args: {name}; listApps args: {}.',
80
+ '- data args (仅 avatar 模式,写/读应用数据): {space, command, text}. command 形如 web.team.<module>.<function>(取自最近应用 functions,如 supplier/flow/request);space 取自该应用;text = 用户原话(客户端据此匹配实例与字段)。这三个参数不能为空,缺一个就凭 context.apps 补,补不到才追问。',
81
+ '- Six capabilities, in order: deploy node, create workspace, generate app template, edit app code, create app instance, add app data.',
82
+ '- NEVER answer a data-write request with prose only: always attach the data action with its three args. If you cannot fill them, still return the action with what you can and say what is missing in reply.',
83
+ '- RSI RULE: every iteration must abstract reusable code/specs/flows, record lessons, and never repeat a known error; prefer existing capabilities over new one-off code.',
84
+ '- WORKFLOW RULE: 目标 → 计划(时间/金钱预期)→ 分析决策 → 任务拆解 → 执行 → 报告(做了什么/怎么做)→ 反馈(总结/沉淀)。No goal, no action; no report, not done; no feedback, not closed.',
85
+ '- FACTORY CODE RULES (must follow ContextOS SPEC-CODE.md and USER-CODE.md):',
86
+ ' * Go commands live under the app module src/<module>/; use icebergs ice.*, mdb/nfs/aaa helpers. Never write raw SQL, never open var/db/*.db directly.',
87
+ ' * Frontend uses Volcanos can.page/can.onimport and $output-scoped CSS. Never use document.createElement/innerHTML or unscoped global CSS.',
88
+ ' * API methods: create/insert=PUT, remove/delete=DELETE, other actions=POST; GET /action/ is only for login/run/command.',
89
+ ' * Parse ContextOS envelopes via option/append/key,value; 200 + count:0x0 is not success; always read back.',
90
+ ' * Entity ids are 32-char hashes; use target_uid/plan_uid/task_uid/report_uid/feedback_uid naming.',
91
+ ' * autogenApp value is a comma-separated ASCII Go identifier list (profile,position,...); Chinese display labels belong only in trans.',
92
+ ' * After Go/JS/CSS changes: compile via vimer/action/compile, inspect warn/catch/error, verify bin/ice.bin mtime, then read back.',
93
+ ' * Generated UI must use the project design tokens/UI kit (USER-VIEW.md); no ad-hoc duplicate card/table/empty markup.',
94
+ ' * Never commit secrets or hard-code /Users/<name>; derive paths from CTX_ROOT.',
95
+
96
+ `MODE: ${mode}.`,
97
+ 'MODE BEHAVIOUR:',
98
+ '- avatar(数字分身): only create app instances and add data; never deploy nodes, create project spaces or generate app code.',
99
+ '- avatar 模式下的「创建/生成/做一个 <X> 应用」= 先 storeApps 找到最匹配 X 的服务模板(按名称相似度),再 createStoreInstance(name=X,city/company/uid 取 context.avatar)。这是允许的动作,不要拒绝,不要回答「请切换到软件工厂模式」,也不要调用 createApp。',
100
+ '- avatar 模式下的「给 <X> 添加/录入 <Y>」= 先从最近应用(context.apps)里按名称相似度挑出 X 对应的应用,再从该应用的 functions 里按关键词选功能:定义流程(创建/新建/添加流程)→flow;发起流程(发起/提交/送审/报批/走流程,或「申请」+流程)→request;申请权限→apply;审批(通过/驳回)→allow;通知→notice;事件→event;成员→member;设置→setting。flow 是创建流程定义,request 是发起一次流程申请,两者不可混用,然后返回 action {op:"data", args:{space, command, text}}(space/command 取自该应用,command 形如 web.team.<name>.<function>,text 为用户原话)。只有最近应用里确实没有匹配项时才追问一句,不要先要求用户选应用。',
101
+ '- factory(软件工厂): deploy service nodes, create project spaces, generate app templates, generate app code, compile and instantiate —— 全链路生产线。此模式禁止 data 动作、禁止查询最近应用:说「创建空间」就是 createSpace(确认表单),说「生成应用」就是 createApp/autogenApp。',
102
+ '- iot(赛博世界): 只做设备三件事——扫描设备(蓝牙/局域网)、连接设备、控制设备(切换场景/下发指令);不建空间、不生成应用、不读写数据。',
103
+ `ALLOWED ACTIONS IN ${String(mode).toUpperCase()} MODE: ${(MODE_ACTIONS[mode] || []).join(', ')}.`,
104
+ '- Refuse any action outside the allowlist with one short Chinese sentence explaining the mode; do not invent extra capabilities.',
105
+ '- storeApps args: {}; createStoreInstance args: {name?,city?,company?,command?}; createAvatar args: {name,city,company,persona,preferences,style}; projectStatus args: {}; planProject args: {goal}; pmList args: {}; pmCreate args: {kind,target_uid?,plan_uid?,task_uid?,report_uid?,title?,content?,begin_time?,end_time?,report_type?,score?,feedback_status?}; pmAct args: {kind,item_uid,action}; pmModify args: {kind,item_uid,title?,content?,begin_time?,end_time?,report_type?,score?,feedback_status?}; pmRemove args: {kind,item_uid}; devices args: {}.',
106
+ '- CURRENT SELECTION is authoritative: context.node, context.space and context.app are the active node/workspace/app instance. Use them directly; do not ask the user to repeat node/space/app if present.',
107
+ '- Every request carries context {node, space, app, avatar, scene, mode}; these are the current conversation parameters. If any action argument is missing, fill it from context.node/space/app/avatar. Only ask when that field is empty.',
108
+ '- Every request also carries context.functions {target, plan, task, report, feedback}; these are the five project functions of the active plan session. Use their uid/title as defaults for pmCreate and any project action; never ask for a uid that is already present.',
109
+ '- If the user says add target/plan/task/report/feedback, call pmCreate with kind and omit parent uids when context.functions already provides them.',
110
+ '- For MULTIPLE items (多个任务/三个计划/批量创建), return an actions array with one pmCreate per item. Never stop after the first item when the user asked for several. Each action is executed sequentially by the host.',
111
+ '- PROJECT MANAGEMENT SKILL: entities target→plan→task→report→feedback. Operations: List=pmList; Create=pmCreate; Modify=pmModify; Remove=pmRemove.',
112
+ '- For List/查看/列表, call pmList. For Create/新增/添加, call pmCreate with kind and parent uid defaults from context.functions. For Modify/修改/编辑, call pmModify {kind,item_uid,...}. For Remove/删除, call pmRemove {kind,item_uid}.',
113
+ '- Current context.functions always contains the selected target/plan/task/report/feedback (uid/title/status). Use the selected one for item_uid; only ask when it is null and the user did not specify which item.',
114
+ '- After any pmCreate/pmModify/pmRemove the host refreshes the left/right project panels automatically; reply with what changed, do not claim until the action result is returned.',
115
+ '- Project chain: target → plan → task → report → feedback. Use uid=target_uid for target/action/planCreate, uid=plan_uid for plan/action/taskCreate, uid=task_uid for task/action/reportCreate, uid=report_uid for report/action/feedbackCreate.',
116
+ '- Project status gates: plan must reach process before taskCreate; task must reach process before reportCreate; feedback canFinish is required before task finish.',
117
+ '- When the user gives a goal for daily work, call planProject {goal}; ask only for missing goal details (target, date range, task list, report/feedback expectations).',
118
+ '- If the user says open/data/code/instance and context.app exists, act on context.app without asking for its name.',
119
+ '- context.avatar is the active digital avatar: uid/city/company/persona/preferences/style. When creating an instance always pass context.avatar.uid, city, company.',
120
+ '- context.scene is the active virtual scene: theme, language, timezone, location. Treat these as the user environment (language/theme for renders, timezone for dates, location for city-level answers). Switching scene changes them; empty means system default.',
121
+ '- context.web is the web page the user is currently viewing in a tab (url/title). When the user says "this page" or asks about what they are looking at, use context.web as the reference.',
122
+ '- CONTEXT FIELDS (single canonical name per concept, no aliases): node=服务节点, space=项目空间, app=应用实例, avatar=数字分身, scene=赛博世界, mode=工作模式(factory/avatar/iot), view=视图模式(chat/manage/dev), web=网页界面, editor=源码编程.',
123
+ '- When the user says 节点/serve/node use context.node; 空间/pod use context.space; 应用实例/place use context.app; 赛博世界/scene use context.scene; 视图/view use context.view; 网页 use context.web; 文件/代码 use context.editor.',
124
+ '- sshHosts args: {}; lists ~/.ssh/host/*.json services (name, username, host, port, init — these are the user\'s SSH services), ~/.ssh/known_hosts hosts, ~/.ssh/config entries and shell history ssh commands (hashed known_hosts entries are shown as hashed). Prefer context from sshHosts.services when the user asks about their services.',
125
+ '- sysControl args: {action, value}. actions: volume (value 0-100, empty=read), mute (on/off), clipboard (empty=read, text=write), screenshot, open (http(s)/absolute path), wifi (on/off/empty=status), sleep, lock, wallpaper (absolute image path), shortcut (name), shortcuts (list), info (battery/disk/displays). Only in iot mode; ask before sleep/lock.',
126
+ '- context.editor is the file currently open in the dev editor: {node, space, path, language, dirty, content (truncated)}. When the user says "this file"/"这个文件"/asks about the open code, use context.editor.path and context.editor.content.',
127
+ '- createSpace name regex [a-zA-Z][a-zA-Z0-9_]*; autogen name/place same regex.',
128
+ '- Standard spaces: community=云社区, enterprise=云办公, operation=云研发, knowledge=云知识, education=云教育, healthy=云健康, tourism=云文旅, charity=云公益, industry=云工业, agriculture=云农业.',
129
+ '- If the user asks to create/deploy any standard space by Chinese name, use officialSpaces {keys:[...]}; multiple names may be selected at once. Do not use createSpace for these ten.',
130
+ '- Do not claim success; the host executes the action and reports the real result.',
131
+ '- reply must be concise Chinese, 1-3 sentences. Translate every label, status, field name and entity type into Chinese; do not leave English enum values or raw field names in reply.',
132
+ `Current context: ${JSON.stringify(context).slice(0, 6000)}`,
133
+ ].join('\n');
134
+ // Use one request-only user message. Durable system/assistant messages need
135
+ // ids/sources; a single user turn keeps this plugin independent of loop state.
136
+ const historyText = (history || []).slice(-8)
137
+ .filter((h) => h && h.text && (h.role === 'user' || h.role === 'ai' || h.role === 'assistant'))
138
+ .map((h) => `${h.role === 'user' ? 'USER' : 'ASSISTANT'}: ${String(h.text).slice(0, 1200)}`)
139
+ .join('\n');
140
+ const prompt = `${system}\n\nHISTORY:\n${historyText || '(none)'}\n\nUSER: ${String(message).slice(0, 2000)}`;
141
+ const messages = [{ role: 'user', content: [{ type: 'text', text: prompt }] }];
142
+
143
+ let output = '';
144
+ let failure = '';
145
+ let usage = null;
146
+ const startedAt = Date.now();
147
+ for await (const chunk of dshLlm.stream({ provider: choice.provider, model: choice.model, messages })) {
148
+ if (chunk?.type === 'text-delta' && chunk.text) output += chunk.text;
149
+ if (chunk?.type === 'usage' && chunk.usage) usage = chunk.usage;
150
+ if (chunk?.usage) usage = chunk.usage;
151
+ if (chunk?.type === 'finish' && chunk.reason?.kind === 'error') {
152
+ failure = chunk.reason.failure?.message || chunk.reason.failure?.code || 'model call failed';
153
+ }
154
+ if (chunk?.type === 'finish' && chunk.reason?.kind === 'aborted') failure = 'model call aborted';
155
+ }
156
+ if (failure) throw new Error(`DSH model: ${failure}`);
157
+ const elapsedMs = Date.now() - startedAt;
158
+ const parsed = extractAction(output);
159
+ return {
160
+ reply: String(parsed?.reply || output || '已理解,但没有生成可执行动作。').trim(),
161
+ action: parsed?.action && typeof parsed.action === 'object' ? parsed.action : null,
162
+ actions: Array.isArray(parsed?.actions) ? parsed.actions.filter((a) => a && typeof a === 'object') : [],
163
+ provider: choice.provider,
164
+ model: choice.model,
165
+ raw: parsed ? undefined : output.slice(0, 800),
166
+ ms: elapsedMs,
167
+ usage: usage || null,
168
+ promptBytes: Buffer.byteLength(prompt, 'utf8'),
169
+ outputBytes: Buffer.byteLength(output, 'utf8'),
170
+ };
171
+ }
172
+
173
+
174
+ export async function llmJson({ prompt, provider = '', model = '' } = {}) {
175
+ const choice = await resolveChatModel({ provider, model });
176
+ const messages = [{ role: 'user', content: [{ type: 'text', text: prompt }] }];
177
+ let output = '';
178
+ let failure = '';
179
+ for await (const chunk of dshLlm.stream({ provider: choice.provider, model: choice.model, messages })) {
180
+ if (chunk?.type === 'text-delta' && chunk.text) output += chunk.text;
181
+ if (chunk?.type === 'finish' && chunk.reason?.kind === 'error') failure = chunk.reason.failure?.message || 'model failed';
182
+ }
183
+ if (failure) throw new Error(failure);
184
+ const start = output.indexOf('{');
185
+ const end = output.lastIndexOf('}');
186
+ if (start < 0 || end <= start) throw new Error('model returned no JSON');
187
+ return JSON.parse(output.slice(start, end + 1));
188
+ }
189
+
190
+
191
+ // 生成应用前先调研:按用户需求 + 同类产品(钉钉/飞书/传统 SaaS)的常见做法,
192
+ // 定出这个应用的五个核心功能及其依赖关系。返回值直接进确认表单。
193
+ export async function appFunctionPlan({ name = '', help = '', text = '' } = {}) {
194
+ const prompt = [
195
+ '你是企业应用架构师。请为一个业务应用设计「五个核心功能」,并给出依赖关系。',
196
+ `应用:${help || name || '未命名应用'}`,
197
+ `用户原话/需求:${String(text || help || name).slice(0, 400)}`,
198
+ '',
199
+ '要求:',
200
+ '1. 恰好五个功能,按业务先后依赖排序(后面的依赖前面的)。',
201
+ '2. 参考同类产品(钉钉、飞书、用友、金蝶、Salesforce 等)在该业务领域的标准模块,但要用中文命名。',
202
+ '3. 每个功能给出:name(小写字母,用于代码标识)、label(中文名)、depends(依赖的功能 name,数组,第一个为空)、why(一句话说明为什么需要它)。',
203
+ '4. 只输出 JSON,不要解释,格式:',
204
+ '{"functions":[{"name":"target","label":"目标","depends":[],"why":"..."}]}',
205
+ ].join('\n');
206
+ const out = await llmJson({ prompt });
207
+ const list = Array.isArray(out?.functions) ? out.functions : [];
208
+ const clean = list.map((f) => ({
209
+ name: String(f?.name || '').toLowerCase().replace(/[^a-z0-9_]/g, '').slice(0, 24),
210
+ label: String(f?.label || '').trim().slice(0, 12),
211
+ depends: Array.isArray(f?.depends) ? f.depends.map((x) => String(x).toLowerCase()) : [],
212
+ why: String(f?.why || '').slice(0, 80),
213
+ })).filter((f) => /^[a-z][a-z0-9_]*$/.test(f.name) && f.label);
214
+ if (clean.length !== 5) throw new Error(`model returned ${clean.length} functions, expected 5`);
215
+ return { name, help: help || name, functions: clean };
216
+ }
217
+
218
+ // 把「给某个应用写数据」拆成:选应用 → 选功能 → 抽字段值。
219
+ // 应用池来自最近应用;功能名必须来自该应用的 functions。
220
+ // 代码生成意图:把「工具栏加个驳回键」「当状态=待审时显示通过键」解析成模板 + 变量
221
+ export async function codegenPlan({ text = '', module: mod = '', fields = [] } = {}) {
222
+ const t = String(text || '').trim();
223
+ const prompt = [
224
+ 'You turn a user request into a ContextOS code plan. Output ONLY JSON:',
225
+ '{"scope":"toolbar|row","action":"Reject","label":"驳回","field":"status","op":"eq|ne|gt|lt|nonempty|empty","value":"created","why":"..."}',
226
+ 'Rules:',
227
+ '- scope=toolbar when the button belongs to the toolbar (工具栏/工具条/页面上方).',
228
+ '- scope=row when it depends on a record (当…时/如果…就/某条数据…).',
229
+ '- action is a Go identifier in PascalCase derived from the Chinese label.',
230
+ '- field must be one of the module fields if provided; op defaults to eq; value is the literal compared.',
231
+ `- module: ${mod || '(unknown)'}; fields: ${(fields || []).join(', ') || '(unknown)'}`,
232
+ `Request: ${t}`,
233
+ ].join('\n');
234
+ let out = null;
235
+ try {
236
+ out = await llmJson({ prompt });
237
+ } catch (e) {
238
+ out = null;
239
+ }
240
+ const isToolbar = /(工具栏|工具条|页面上方|顶部|导航栏)/.test(t);
241
+ const scope = out ? (out.scope === 'toolbar' ? 'toolbar' : 'row') : (isToolbar ? 'toolbar' : 'row');
242
+ const label = (out && out.label) || (t.match(/[「"]([^」"]{1,8})[」"]/) || [])[1] || '';
243
+ const action = (out && out.action) || labelToIdent(label || t);
244
+ // 兜底:从「状态=待审」「status 为 approved」「金额大于 100」里抽字段/比较/值
245
+ const hit = t.match(/([a-z_]{2,20})\s*(?:=|==|为|是)\s*([^\s,。;;]{1,20})/i);
246
+ let field = (out && out.field) || (hit ? hit[1] : 'status');
247
+ let value = (out && out.value) || (hit ? hit[2] : '');
248
+ let op = (out && out.op) || 'eq';
249
+ if (!out) {
250
+ if (/(不为空|有值|非空)/.test(t)) { op = 'nonempty'; value = ''; }
251
+ else if (/(为空|没有值)/.test(t)) { op = 'empty'; value = ''; }
252
+ else if (/(不等于|不为|不是)/.test(t)) op = 'ne';
253
+ else if (/(大于|超过|高于)/.test(t)) op = 'gt';
254
+ else if (/(小于|低于|少于)/.test(t)) op = 'lt';
255
+ }
256
+ // 意图分类:加字段 / 加操作(按钮)/ 新建功能
257
+ const wantField = /(字段|一列|列名|column)/i.test(t) && !/(按钮|按键|操作|动作)/.test(t);
258
+ const wantAction = /(操作|动作|按键|按钮|功能)/.test(t);
259
+ if (wantField) {
260
+ const f = (t.match(/[「"]([a-z_]{2,20})[」"]/i) || [])[1] || (t.match(/(?:添加|新增|增加|加)(?:一个|个)?\s*([a-z_]{2,20})\s*(?:字段|列)/i) || [])[1] || '';
261
+ return {
262
+ scope: 'field', template: 'field', action: '', label: '', field: f,
263
+ op: '', value: '', why: '在模块里增加一个字段(系统级改 field,平台级改 fields + model 常量)', source: out ? 'llm' : 'fallback',
264
+ };
265
+ }
266
+ if (!wantAction) {
267
+ return {
268
+ scope: 'action', template: 'action', action, label: label || action, field: '', op: '', value: '',
269
+ params: 'title* content*', why: '给模块加一个动作(声明 + 方法 + 按钮)', source: out ? 'llm' : 'fallback',
270
+ };
271
+ }
272
+ return {
273
+ scope,
274
+ template: scope === 'toolbar' ? 'toolbarButton' : 'rowButton',
275
+ action,
276
+ label: label || action,
277
+ field,
278
+ op,
279
+ value,
280
+ params: 'title* content*',
281
+ why: (out && out.why) || '',
282
+ source: out ? 'llm' : 'fallback',
283
+ };
284
+ }
285
+
286
+ function labelToIdent(s) {
287
+ const map = { 驳回: 'Reject', 通过: 'Approve', 审批: 'Approve', 提交: 'Submit', 完成: 'Finish', 取消: 'Cancel', 删除: 'Remove', 归档: 'Archive', 提醒: 'Notify', 确认: 'Confirm', 退回: 'SendBack', 撤回: 'Withdraw' };
288
+ for (const k of Object.keys(map)) if (String(s).includes(k)) return map[k];
289
+ return 'Custom';
290
+ }
291
+
292
+ export async function resolveWrite({ text = '', apps = [], mode = 'write' } = {}) {
293
+ const list = (Array.isArray(apps) ? apps : []).slice(0, 12).map((a, i) => ({
294
+ i,
295
+ title: a.title || a.name || '',
296
+ space: a.space || '',
297
+ command: a.command || '',
298
+ functions: String(a.functions || '').slice(0, 200),
299
+ }));
300
+ if (!list.length) throw new Error('no recent app');
301
+ const prompt = [
302
+ mode === 'read'
303
+ ? '你是 ContextOS 的应用操作解析器。用户想【查询/查看】某个应用里的数据。'
304
+ : '你是 ContextOS 的应用操作解析器。用户在描述一次【往应用里写数据】的操作。',
305
+ '把应用当成目录、把功能当成目录里的文件:先选应用(目录),再选功能(文件)。',
306
+ `用户原话:${String(text).slice(0, 500)}`,
307
+ '可用的应用实例(functions 是逗号分隔的功能名):',
308
+ JSON.stringify(list),
309
+ '',
310
+ '任务:',
311
+ '1. 选出最匹配的一个应用,用 i 表示它的下标。',
312
+ '2. 从该应用的 functions 里选出要调用的功能名(如 supplier / flow / request)。',
313
+ mode === 'read'
314
+ ? '3. 不需要抽取字段值,values 输出 {}。'
315
+ : '3. 只抽取原话里明确出现的字段值,抽不到就留空,不要编造。',
316
+ '只输出 JSON:{"i":0,"fn":"supplier","values":{"title":"中艾科技"},"why":"一句话"}',
317
+ ].join('\n');
318
+ const out = await llmJson({ prompt });
319
+ const i = Number(out?.i);
320
+ const app = list.find((x) => x.i === i) || null;
321
+ if (!app) throw new Error('model picked no app');
322
+ const fn = String(out?.fn || '').toLowerCase().replace(/[^a-z0-9_]/g, '').slice(0, 24);
323
+ const values = {};
324
+ if (out && out.values && typeof out.values === 'object') {
325
+ for (const [k, v] of Object.entries(out.values)) {
326
+ const key = String(k).replace(/[^a-z0-9_]/gi, '');
327
+ if (key && v != null && String(v).trim()) values[key] = String(v).slice(0, 200);
328
+ }
329
+ }
330
+ return { space: app.space, command: app.command, fn, values, mode, title: app.title, why: String(out?.why || '').slice(0, 120) };
331
+ }
332
+
333
+ export async function avatarSession(node, avatar) {
334
+ const r = await http(node, `/c/aaa.sess/action/create/${encode(avatar.username)}`, { method: 'PUT', timeout: 12000 });
335
+ const sid = r.text.trim();
336
+ if (r.status !== 200 || !sid) throw new Error(`avatar session failed: HTTP ${r.status}`);
337
+ return sid;
338
+ }
339
+
340
+ export async function pmCreate({ node, space, command, fields, uid, projectUid, sessid = '' }) {
341
+ const parts = splitFields(fields);
342
+ const seg = fieldSegments(parts.path);
343
+ const q = new URLSearchParams({ project_uid: projectUid, 'user.uid': uid });
344
+ if (sessid) q.set('sessid', sessid);
345
+ for (const [k, v] of Object.entries(parts.query)) q.set(k, v);
346
+ const r = await http(node, `/s/${encode(space)}/c/web.team.xiangmuguanli.portal/action/run/${command}/action/create/${seg}?${q}`, { method: 'PUT', timeout: 20000 });
347
+ try {
348
+ const env = JSON.parse(r.text);
349
+ return env.value_uid?.[0] || env.uid?.[0] || '';
350
+ } catch { return ''; }
351
+ }
352
+
353
+ export async function pmAction({ node, space, command, action, uid = '', fields = {}, projectUid, actorUid, sessid = '', method = '', taskUid = '', reportUid = '', extra = {} }) {
354
+ const verb = method || (/Create$/.test(action) ? 'PUT' : /Remove$/.test(action) ? 'DELETE' : /List$/.test(action) ? 'GET' : 'POST');
355
+ const parts = splitFields(fields || {});
356
+ const seg = fieldSegments(parts.path);
357
+ const q = new URLSearchParams({ project_uid: projectUid, 'user.uid': actorUid });
358
+ for (const [k, v] of Object.entries(parts.query)) q.set(k, v);
359
+ if (sessid) q.set('sessid', sessid);
360
+ if (uid) q.set('uid', uid);
361
+ if (taskUid) q.set('task_uid', taskUid);
362
+ if (reportUid) q.set('report_uid', reportUid);
363
+ for (const [k, v] of Object.entries(extra || {})) if (/_uid$/.test(k) && k !== 'uid' && k !== 'user_uid' && v) q.set(k, String(v));
364
+ const r = await http(node, `/s/${encode(space)}/c/web.team.xiangmuguanli.portal/action/run/${command}/action/${action}${seg ? '/' + seg : ''}?${q}`, { method: verb, timeout: 20000 });
365
+ let valueUid = '';
366
+ try { const env = JSON.parse(r.text); valueUid = env.value_uid?.[0] || ''; } catch { /* redirect */ }
367
+ return { status: r.status, uid: valueUid, text: r.text };
368
+ }
369
+
370
+ export async function planProject({ node, avatarUid = '', goal = '' } = {}) {
371
+ const avatars = await listAvatars({ node });
372
+ const avatar = (avatars.items || []).find((x) => x.uid === (avatarUid || avatars.active));
373
+ if (!avatar?.project?.space || !avatar.project.place_uid) throw new Error('avatar has no project');
374
+ const project = avatar.project;
375
+ let sessid = '';
376
+ try { sessid = await avatarSession(node, avatar); } catch { /* fall back to query uid */ }
377
+ const targetText = String(goal || '日常目标').slice(0, 200);
378
+ let plan = null;
379
+ try {
380
+ plan = await llmJson({ prompt: `你是项目管理助手。把目标拆成 JSON,只输出 JSON:\n{"target":{"title":"...","content":"..."},"plans":[{"title":"...","content":"...","begin_time":"YYYY-MM-DD","end_time":"YYYY-MM-DD","tasks":[{"title":"...","content":"...","report":{"title":"...","content":"..."},"feedback":{"title":"...","content":"...","score":"5","feedback_status":"canFinish"}}]}]}\n目标:${targetText}\n当前日期:${new Date().toISOString().slice(0, 10)}` });
381
+ } catch {
382
+ plan = { target: { title: targetText, content: `AI 拆解:${targetText}` }, plans: [{ title: '执行计划', content: `完成:${targetText}`, begin_time: new Date().toISOString().slice(0, 10), end_time: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10), tasks: [{ title: '第一步', content: targetText, report: { title: '进度报告', content: '待填写' }, feedback: { title: '反馈', content: '待填写', score: '5', feedback_status: 'canFinish' } }] }] };
383
+ }
384
+ const warnings = [];
385
+ const target = plan.target || { title: targetText, content: targetText };
386
+ const targetUid = await pmCreate({ node, space: project.space, command: 'web.team.xiangmuguanli.target', fields: { title: target.title || targetText, content: target.content || targetText }, uid: avatar.uid, projectUid: project.place_uid, sessid });
387
+ let plans = 0, tasks = 0, reports = 0, feedbacks = 0;
388
+ for (const p of plan.plans || []) {
389
+ let planUid = '';
390
+ try {
391
+ const r = await pmAction({ node, space: project.space, command: 'web.team.xiangmuguanli.target', action: 'planCreate', uid: targetUid, fields: { title: p.title || '计划', content: p.content || '', begin_time: p.begin_time || new Date().toISOString().slice(0, 10), end_time: p.end_time || new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10) }, projectUid: project.place_uid, actorUid: avatar.uid, sessid });
392
+ planUid = r.uid;
393
+ plans++;
394
+ } catch (e) { warnings.push(`plan: ${String(e.message || e)}`); continue; }
395
+ // State machine: created → submitted → approved → process.
396
+ for (const action of ['submit', 'approve', 'process']) {
397
+ try { await pmAction({ node, space: project.space, command: 'web.team.xiangmuguanli.plan', action, uid: planUid, projectUid: project.place_uid, actorUid: avatar.uid, sessid }); }
398
+ catch { /* role may skip a transition; taskCreate below is the real gate */ }
399
+ }
400
+ for (const k of p.tasks || []) {
401
+ let taskUid = '';
402
+ try {
403
+ const r = await pmAction({ node, space: project.space, command: 'web.team.xiangmuguanli.plan', action: 'taskCreate', uid: planUid, fields: { title: k.title || '任务', content: k.content || '' }, projectUid: project.place_uid, actorUid: avatar.uid, sessid });
404
+ taskUid = r.uid; tasks++;
405
+ } catch (e) { warnings.push(`task: ${String(e.message || e)}`); continue; }
406
+ try { await pmAction({ node, space: project.space, command: 'web.team.xiangmuguanli.task', action: 'process', uid: taskUid, projectUid: project.place_uid, actorUid: avatar.uid, sessid }); } catch (e) { warnings.push(`task process: ${String(e.message || e)}`); }
407
+ let reportUid = '';
408
+ try {
409
+ const r = await pmAction({ node, space: project.space, command: 'web.team.xiangmuguanli.task', action: 'reportCreate', uid: taskUid, fields: { report_type: 'daily', title: k.report?.title || '进度报告', content: k.report?.content || '待填写' }, projectUid: project.place_uid, actorUid: avatar.uid, sessid });
410
+ reportUid = r.uid; reports++;
411
+ } catch (e) { warnings.push(`report: ${String(e.message || e)}`); continue; }
412
+ try {
413
+ await pmAction({ node, space: project.space, command: 'web.team.xiangmuguanli.report', action: 'feedbackCreate', uid: reportUid, fields: { title: k.feedback?.title || '反馈', content: k.feedback?.content || '待填写', score: k.feedback?.score || '5', feedback_status: k.feedback?.feedback_status || 'canFinish' }, projectUid: project.place_uid, actorUid: avatar.uid, sessid, taskUid });
414
+ feedbacks++;
415
+ } catch (e) { warnings.push(`feedback: ${String(e.message || e)}`); }
416
+ }
417
+ }
418
+ return { avatar, project, target_uid: targetUid, plans, tasks, reports, feedbacks, warnings };
419
+ }
@@ -0,0 +1,157 @@
1
+ // 核心业务:应用数据读写(create/modify/remove + 回读)。
2
+ import { http, encode, envelopeShape } from '../base/http.js';
3
+ import { parseTable } from '../base/table.js';
4
+ import { fieldSegments, splitFields } from '../base/field.js';
5
+ import { waitFor } from '../misc/async.js';
6
+ import { apiPath, dataPath, portalOf, placeUidKey, methodOf } from '../base/url.js';
7
+
8
+ // URL 形状统一在 lib/base/url.js(接口 URL / 网页 URL 两套,见 docs/URL-SPEC.md)
9
+ const portalPath = portalOf;
10
+
11
+ // 空间没启动时,任何请求都只回 echo(body 只有 detail),写数据自然不生效。
12
+ async function reviveSpace(node, space) {
13
+ try { await http(node, `/c/dream/action/start?name=${encode(space)}`, { method: 'POST', timeout: 60000 }); } catch { /* ignore */ }
14
+ await new Promise((r) => setTimeout(r, 2500));
15
+ }
16
+
17
+ // 内核没有并发控制:同一个 (节点, 空间, 命令, 实例) 上的写操作串行化,
18
+ // 避免两个对话同时提交造成重复创建 / 覆盖。
19
+ const WRITE_QUEUE = new Map();
20
+ function serialize(key, fn) {
21
+ const prev = WRITE_QUEUE.get(key) || Promise.resolve();
22
+ const run = prev.then(fn, fn);
23
+ WRITE_QUEUE.set(key, run.catch(() => {}));
24
+ return run;
25
+ }
26
+
27
+ async function writeDataRaw({ node, space, command, fields = {}, action = 'create', uid = '', scope = {} }) {
28
+ // 实例条件就是 <place>_uid(place 是表名,默认 system),不存在通用的 system_uid
29
+ const systemUid = String((scope && (scope['system_uid'] || scope['project_uid'] || Object.entries(scope).find(([k, v]) => /_uid$/.test(k) && k !== 'user_uid' && v)?.[1])) || '');
30
+ const portal = systemUid ? portalPath(command) : '';
31
+ const parts = splitFields(fields);
32
+ const seg = fieldSegments(parts.path);
33
+ // 门户转发要拿回执(判断是否真的写入),不再用 noecho
34
+ const query = new URLSearchParams(portal ? {} : { 'space.noecho': 'true' });
35
+ const actor = String(uid || scope.user_uid || scope['user.uid'] || '');
36
+ if (uid) query.set('uid', uid);
37
+ if (actor) { query.set('user.uid', actor); query.set('user_uid', actor); }
38
+ for (const [k, v] of Object.entries(parts.query)) query.set(k, v);
39
+ for (const [k, v] of Object.entries(scope || {})) if (v) query.set(k, v);
40
+ let after;
41
+ for (let attempt = 0; attempt < 3 && !after; attempt++) {
42
+ if (attempt) await new Promise((r) => setTimeout(r, attempt * 2500));
43
+ const q = Object.fromEntries(query.entries());
44
+ // 写:/action/run/<cmd>/action/<action>/<k>/<v>…?user.uid=&<place>_uid=
45
+ const path = portal ? dataPath({ pod: space, command, action, seg, portal, scope: q }) : apiPath({ pod: space, command, action, seg, scope: q, portal });
46
+ const wrote = await http(node, path, { method: methodOf({ action }), timeout: 30000 });
47
+ if (/"detail"\s*:/.test(String(wrote.text || ''))) {
48
+ // 空间未启动/重启中:先把空间拉起来再重试
49
+ await reviveSpace(node, space);
50
+ continue;
51
+ }
52
+ let wroteCount = '';
53
+ try {
54
+ const st = JSON.parse(JSON.parse(wrote.text)._status?.[0] || '[]');
55
+ wroteCount = String(st.find((x) => x.name === 'count')?.value || '');
56
+ } catch { wroteCount = ''; }
57
+ if (wroteCount && !/^0x0$/.test(wroteCount)) {
58
+ // 创建回执本身就是新行:直接从中取 uid,供详情链接使用
59
+ let columns = [];
60
+ let rows = [];
61
+ let newUid = '';
62
+ try {
63
+ const env0 = JSON.parse(wrote.text);
64
+ const raw0 = env0.result ?? env0._result ?? env0.uid ?? '';
65
+ const first0 = Array.isArray(raw0) ? raw0[0] : raw0;
66
+ newUid = /^[0-9a-f]{24,64}$/i.test(String(first0 || '')) ? String(first0) : '';
67
+ } catch { /* 无 result 时留空 */ }
68
+ try {
69
+ const env = JSON.parse(wrote.text);
70
+ columns = Array.isArray(env.append) ? env.append : [];
71
+ const height = columns.length && Array.isArray(env[columns[0]]) ? env[columns[0]].length : 0;
72
+ rows = Array.from({ length: height }, (_, i) => {
73
+ const row = {};
74
+ for (const c of columns) row[c] = Array.isArray(env[c]) ? env[c][i] : '';
75
+ return row;
76
+ });
77
+ } catch { /* 回执不是表格结构时忽略 */ }
78
+ return { node, space, command, count: wroteCount, columns, rows, uid: newUid || rows[0]?.uid || '', write: { action, fields }, via: portal ? 'portal' : 'direct' };
79
+ }
80
+ try {
81
+ after = await waitFor(async () => {
82
+ const data = await readData({ node, space, command, query: scope });
83
+ return data.count && !/^0x/.test(data.count) ? data : undefined;
84
+ }, { timeout: 6000, interval: 600, label: `record in ${command}` });
85
+ } catch {
86
+ /* the space may still be restarting after compile; retry the write once */
87
+ }
88
+ }
89
+ if (!after) throw new Error(`写入未生效:空间可能正在编译/重启,请等「模板已就绪」后再提交(${command})`);
90
+ return { ...after, write: { action, fields } };
91
+ }
92
+
93
+ export function writeData(args = {}) {
94
+ const { node, space, command, scope = {}, uid = '' } = args;
95
+ const inst = scope?.system_uid || scope?.project_uid || Object.entries(scope || {}).find(([k, v]) => /_uid$/.test(k) && k !== 'user_uid' && v)?.[1] || '';
96
+ const key = `${node}|${space}|${command}|${inst}|${uid}`;
97
+ return serialize(key, () => writeDataRaw(args));
98
+ }
99
+
100
+ export async function readData({ node, space, command, query = {}, inst = '', record = '' }) {
101
+ const qs = new URLSearchParams();
102
+ for (const [k, v] of Object.entries(query || {})) if (v) qs.set(k, v);
103
+ // 读:列表 /action/run/<cmd>/<place_uid>?user.uid=
104
+ // 详情 /action/run/<cmd>/<place_uid>/<uid>?user.uid=
105
+ const portal = portalOf(command);
106
+ const path = portal && inst
107
+ ? dataPath({ pod: space, command, action: '', inst, record, portal, scope: Object.fromEntries(qs.entries()) })
108
+ : `/s/${encode(space)}/c/${command}${qs.toString() ? '?' + qs : ''}`;
109
+ const r = await http(node, path, { timeout: 10000 });
110
+ let count = '';
111
+ let columns = [];
112
+ let rows = [];
113
+ let shape = 'list';
114
+ let fields = null;
115
+ try {
116
+ const envelope = JSON.parse(r.text);
117
+ const parsed = envelopeShape(r.text);
118
+ if (parsed.kind === 'detail') { shape = 'detail'; fields = parsed.fields; }
119
+ if (envelope && !Array.isArray(envelope)) {
120
+ try {
121
+ const status = JSON.parse(envelope._status?.[0] || '[]');
122
+ count = status.find((row) => row.name === 'count')?.value || '';
123
+ } catch {
124
+ /* count stays empty */
125
+ }
126
+ columns = Array.isArray(envelope.append) ? envelope.append : [];
127
+ const height = Array.isArray(envelope[columns[0]]) ? envelope[columns[0]].length : 0;
128
+ rows = Array.from({ length: height }, (_, i) => {
129
+ const row = {};
130
+ for (const c of columns) row[c] = Array.isArray(envelope[c]) ? envelope[c][i] : '';
131
+ return row;
132
+ });
133
+ }
134
+ } catch {
135
+ const table = parseTable(r.text);
136
+ columns = table.columns;
137
+ rows = table.rows;
138
+ }
139
+ return { node, space, command, status: r.status, shape, columns, rows, fields, count, raw: r.text.slice(0, 4000) };
140
+ }
141
+
142
+ // 删除:DELETE /action/run/<cmd>/action/remove?user.uid=&<place>_uid=&uid=
143
+ // 修改用 modify(POST),删除用 remove(DELETE),动作名不同,别混。
144
+ export async function removeData({ node, space, command, uid = '', scope = {} }) {
145
+ const actor = String(scope['user.uid'] || scope.user_uid || '');
146
+ const inst = String(scope.inst || scope.system_uid || scope.project_uid || Object.entries(scope).find(([k, v]) => /_uid$/.test(k) && k !== 'user_uid' && v)?.[1] || '');
147
+ const portal = portalOf(command);
148
+ const q = {};
149
+ if (actor) q['user.uid'] = actor;
150
+ if (inst) q[placeUidKey(String(scope.place || 'system'))] = inst;
151
+ if (uid) q.uid = uid;
152
+ const path = portal
153
+ ? dataPath({ pod: space, command, action: 'remove', seg: '', portal, scope: q })
154
+ : `/s/${encode(space)}/c/${command}/action/remove${new URLSearchParams(q).toString() ? '?' + new URLSearchParams(q) : ''}`;
155
+ const r = await http(node, path, { method: 'DELETE', timeout: 30000 });
156
+ return { status: r.status, removed: r.text.slice(0, 500), url: path };
157
+ }