nx-kv 0.1.1
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/CHANGELOG.md +48 -0
- package/LICENSE +21 -0
- package/README.md +133 -0
- package/assets/nx-kv/SKILL.md +99 -0
- package/assets/nx-kv/references/agent-workflow.md +178 -0
- package/assets/nx-kv/references/todo-commands.md +193 -0
- package/bin/cli.mjs +10 -0
- package/package.json +57 -0
- package/src/core/config.js +94 -0
- package/src/core/errors.js +81 -0
- package/src/core/frontmatter.js +65 -0
- package/src/core/fstree.js +74 -0
- package/src/core/kvapi.js +201 -0
- package/src/core/open.js +26 -0
- package/src/core/paths.js +46 -0
- package/src/core/prompt.js +60 -0
- package/src/index.js +9 -0
- package/src/modules/auth/index.js +98 -0
- package/src/modules/auth/view.jsx +78 -0
- package/src/modules/bundled/index.js +51 -0
- package/src/modules/bundled/service.js +80 -0
- package/src/modules/group/index.js +199 -0
- package/src/modules/group/view.jsx +159 -0
- package/src/modules/system/index.js +153 -0
- package/src/modules/todo/index.js +280 -0
- package/src/modules/todo/service.js +524 -0
- package/src/modules/todo/view.jsx +280 -0
- package/src/runtime/api.js +108 -0
- package/src/runtime/cli.js +299 -0
- package/src/runtime/registry.js +56 -0
- package/src/runtime/server.js +74 -0
- package/src/runtime/spec.js +147 -0
- package/src/web/frontend/App.jsx +74 -0
- package/src/web/frontend/api/client.js +39 -0
- package/src/web/frontend/components/CliHints.jsx +25 -0
- package/src/web/frontend/components/ui.jsx +197 -0
- package/src/web/frontend/index.html +13 -0
- package/src/web/frontend/main.jsx +17 -0
- package/src/web/frontend/registry.js +12 -0
- package/src/web/frontend/store.jsx +74 -0
- package/src/web/frontend/style.css +368 -0
- package/src/web/public/assets/CliHints-3H3KOoDZ.js +1 -0
- package/src/web/public/assets/index-BnWex75m.css +1 -0
- package/src/web/public/assets/index-jhTtYCb9.js +10 -0
- package/src/web/public/assets/view-CrL-DSJu.js +1 -0
- package/src/web/public/assets/view-Du7Wv_-h.js +1 -0
- package/src/web/public/assets/view-S0N5g6v1.js +2 -0
- package/src/web/public/index.html +13 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// 工作空间页:列出、切换、新建、改名、解散。
|
|
2
|
+
// 切换只改本机配置,不动服务端任何数据;解散有前置条件(后端要求组内已无 KV)。
|
|
3
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
4
|
+
import { api } from '../../web/frontend/api/client.js';
|
|
5
|
+
import { useStore } from '../../web/frontend/store.jsx';
|
|
6
|
+
import { useToast, useGuard, useDialog, Copyable, Modal } from '../../web/frontend/components/ui.jsx';
|
|
7
|
+
import { CliHints } from '../../web/frontend/components/CliHints.jsx';
|
|
8
|
+
|
|
9
|
+
export default function GroupView() {
|
|
10
|
+
const { boot, refreshBoot } = useStore();
|
|
11
|
+
const toast = useToast();
|
|
12
|
+
const guard = useGuard();
|
|
13
|
+
const { dialog, node: dialogNode } = useDialog();
|
|
14
|
+
const [groups, setGroups] = useState([]);
|
|
15
|
+
const [err, setErr] = useState('');
|
|
16
|
+
const [form, setForm] = useState({ name: '', description: '' });
|
|
17
|
+
const [modal, setModal] = useState(null);
|
|
18
|
+
|
|
19
|
+
const load = useCallback(async () => {
|
|
20
|
+
if (!boot?.loggedIn) return;
|
|
21
|
+
try {
|
|
22
|
+
const d = await api('/api/groups');
|
|
23
|
+
setGroups(d.groups || []);
|
|
24
|
+
setErr('');
|
|
25
|
+
} catch (e) {
|
|
26
|
+
setErr(e.message);
|
|
27
|
+
}
|
|
28
|
+
}, [boot?.loggedIn]);
|
|
29
|
+
|
|
30
|
+
useEffect(() => { load(); }, [load]);
|
|
31
|
+
|
|
32
|
+
const use = (locator) => guard(async () => {
|
|
33
|
+
const g = await api('/api/group', { method: 'POST', body: { locator } });
|
|
34
|
+
toast(g.id === 0 ? '已切回默认组' : `已切换到 ${g.name}`);
|
|
35
|
+
await refreshBoot();
|
|
36
|
+
await load();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const create = () => guard(async () => {
|
|
40
|
+
const name = form.name.trim();
|
|
41
|
+
if (!name) { toast('请填名称'); return; }
|
|
42
|
+
const g = await api('/api/groups', {
|
|
43
|
+
method: 'POST',
|
|
44
|
+
body: { name, description: form.description.trim() },
|
|
45
|
+
});
|
|
46
|
+
setForm({ name: '', description: '' });
|
|
47
|
+
toast(`已新建工作空间 ${g.name}(id=${g.id})`);
|
|
48
|
+
await refreshBoot();
|
|
49
|
+
await load();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const rename = (g) => guard(async () => {
|
|
53
|
+
const name = await dialog({ title: `重命名「${g.name}」`, input: true, value: g.name });
|
|
54
|
+
if (name === null || !name.trim()) return;
|
|
55
|
+
await api(`/api/groups/${g.id}`, { method: 'PATCH', body: { name: name.trim() } });
|
|
56
|
+
toast('已更新名称');
|
|
57
|
+
await refreshBoot();
|
|
58
|
+
await load();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const remove = (g) => guard(async () => {
|
|
62
|
+
const ok = await dialog({
|
|
63
|
+
message: `解散工作空间「${g.name}」(id=${g.id})?\n\n后端要求组内已无 KV 数据,否则会拒绝。此操作不可撤销。`,
|
|
64
|
+
danger: true,
|
|
65
|
+
});
|
|
66
|
+
if (!ok) return;
|
|
67
|
+
await api(`/api/groups/${g.id}`, { method: 'DELETE' });
|
|
68
|
+
toast(`已解散 ${g.name}`);
|
|
69
|
+
await refreshBoot();
|
|
70
|
+
await load();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const members = (g) => guard(async () => {
|
|
74
|
+
const d = await api(`/api/groups/${g.id}/members`);
|
|
75
|
+
setModal({
|
|
76
|
+
title: `成员 · ${g.name}`,
|
|
77
|
+
node: (
|
|
78
|
+
<div className="list">
|
|
79
|
+
{d.members.length
|
|
80
|
+
? d.members.map((m) => (
|
|
81
|
+
<div className="row" key={m.userId}>
|
|
82
|
+
<Copyable className="name" text={m.email} title="点击复制邮箱">{m.email}</Copyable>
|
|
83
|
+
<span className="tag">{m.role || 'member'}</span>
|
|
84
|
+
<span className="muted">{m.nickname}</span>
|
|
85
|
+
</div>
|
|
86
|
+
))
|
|
87
|
+
: <div className="row muted">(没有成员)</div>}
|
|
88
|
+
</div>
|
|
89
|
+
),
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
if (!boot?.loggedIn) {
|
|
94
|
+
return (
|
|
95
|
+
<div className="card" style={{ padding: 16 }}>
|
|
96
|
+
<div className="colhead"><h3>请先登录</h3></div>
|
|
97
|
+
<div className="muted">工作空间需要登录后才能列出。</div>
|
|
98
|
+
</div>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
<>
|
|
104
|
+
<div className="toolbar">
|
|
105
|
+
<input placeholder="新工作空间名称" size="16" value={form.name}
|
|
106
|
+
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
|
107
|
+
onKeyDown={(e) => { if (e.key === 'Enter') create(); }} />
|
|
108
|
+
<input placeholder="描述(可选)" size="20" value={form.description}
|
|
109
|
+
onChange={(e) => setForm({ ...form, description: e.target.value })} />
|
|
110
|
+
<button className="btn" onClick={create}>新建</button>
|
|
111
|
+
<span className="sep"></span>
|
|
112
|
+
<button className="btn ghost" onClick={() => guard(load)}>刷新</button>
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
<div className="card">
|
|
116
|
+
<div className="colhead">
|
|
117
|
+
<h3>我的工作空间</h3>
|
|
118
|
+
<span className="muted">切换后清单页立即跟着变</span>
|
|
119
|
+
</div>
|
|
120
|
+
<div className="list">
|
|
121
|
+
{err ? <div className="row muted">{err}</div> : null}
|
|
122
|
+
|
|
123
|
+
<div className="row">
|
|
124
|
+
<span className="name">默认组</span>
|
|
125
|
+
<span className="desc">不传 groupId,由后端回落</span>
|
|
126
|
+
<span className="acts">
|
|
127
|
+
{boot.groupId === 0
|
|
128
|
+
? <span className="tag strong">当前</span>
|
|
129
|
+
: <button className="btn small" onClick={() => use('default')}>切到这里</button>}
|
|
130
|
+
</span>
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
{groups.map((g) => (
|
|
134
|
+
<div className="row" key={g.id}>
|
|
135
|
+
<Copyable className="name" text={g.name} title="点击复制名称">{g.name}</Copyable>
|
|
136
|
+
<span className="tag">{g.myRole || 'member'}</span>
|
|
137
|
+
<span className="desc">{g.description || ''}</span>
|
|
138
|
+
<span className="muted mono">id={g.id}</span>
|
|
139
|
+
<span className="muted">{g.memberCount} 人</span>
|
|
140
|
+
<span className="acts">
|
|
141
|
+
{g.id === boot.groupId
|
|
142
|
+
? <span className="tag strong">当前</span>
|
|
143
|
+
: <button className="btn small" onClick={() => use(String(g.id))}>切到这里</button>}
|
|
144
|
+
<button className="btn small ghost" onClick={() => members(g)}>成员</button>
|
|
145
|
+
<button className="btn small ghost" onClick={() => rename(g)}>改名</button>
|
|
146
|
+
<button className="btn small ghost danger" onClick={() => remove(g)}>解散</button>
|
|
147
|
+
</span>
|
|
148
|
+
</div>
|
|
149
|
+
))}
|
|
150
|
+
{!err && !groups.length ? <div className="row muted">(没有其他工作空间)</div> : null}
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
|
|
154
|
+
<CliHints module="group" />
|
|
155
|
+
{modal ? <Modal title={modal.title} onClose={() => setModal(null)}>{modal.node}</Modal> : null}
|
|
156
|
+
{dialogNode}
|
|
157
|
+
</>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// 系统模块:聚合与自检。没有业务状态,也没有面板视图。
|
|
2
|
+
//
|
|
3
|
+
// 分层例外:本模块是刻意的聚合器,允许 import 其他模块的 service。
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
5
|
+
import { configPathFromEnv, loadConfig } from '../../core/config.js';
|
|
6
|
+
import { badInput, notFound } from '../../core/errors.js';
|
|
7
|
+
import { listGroups } from '../../core/kvapi.js';
|
|
8
|
+
import { DEFAULT_PORT } from '../../core/paths.js';
|
|
9
|
+
|
|
10
|
+
const VERSION = JSON.parse(
|
|
11
|
+
readFileSync(new URL('../../../package.json', import.meta.url), 'utf8')
|
|
12
|
+
).version;
|
|
13
|
+
|
|
14
|
+
// 命令表:CLI 命令 ↔ HTTP 路由的对照。
|
|
15
|
+
// 动态 import:runtime/registry 静态依赖本模块,静态引入会成环;
|
|
16
|
+
// 而该函数只在启动完成后调用,届时 cli.js 早已求值完毕。
|
|
17
|
+
async function commandTable() {
|
|
18
|
+
const { ALL_COMMANDS, commandEntry } = await import('../../runtime/cli.js');
|
|
19
|
+
return ALL_COMMANDS.map(commandEntry);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// 面板启动上下文。groups 失败不致命——未登录时面板仍应能打开并提示去登录,
|
|
23
|
+
// 所以这里降级成空数组而不抛。
|
|
24
|
+
async function bootstrap() {
|
|
25
|
+
const cfg = await loadConfig();
|
|
26
|
+
let groups = [];
|
|
27
|
+
let groupsError = '';
|
|
28
|
+
if (cfg.token) {
|
|
29
|
+
try {
|
|
30
|
+
groups = await listGroups();
|
|
31
|
+
} catch (e) {
|
|
32
|
+
groupsError = String(e.message || e);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
version: VERSION,
|
|
37
|
+
configPath: configPathFromEnv(),
|
|
38
|
+
baseUrl: cfg.baseUrl,
|
|
39
|
+
loggedIn: !!cfg.token,
|
|
40
|
+
email: cfg.email,
|
|
41
|
+
userId: cfg.userId,
|
|
42
|
+
groupId: cfg.groupId,
|
|
43
|
+
groups,
|
|
44
|
+
groupsError,
|
|
45
|
+
defaultPort: DEFAULT_PORT,
|
|
46
|
+
commands: await commandTable(),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export default {
|
|
51
|
+
id: 'system',
|
|
52
|
+
title: '系统',
|
|
53
|
+
order: 0,
|
|
54
|
+
view: null,
|
|
55
|
+
|
|
56
|
+
actions: [
|
|
57
|
+
{
|
|
58
|
+
id: 'system.bootstrap',
|
|
59
|
+
cli: ['bootstrap'],
|
|
60
|
+
http: ['GET', '/api/bootstrap'],
|
|
61
|
+
summary: '聚合上下文:版本 / 登录态 / 工作空间 / 命令表',
|
|
62
|
+
run: bootstrap,
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
id: 'system.health',
|
|
66
|
+
cli: ['health'],
|
|
67
|
+
http: ['GET', '/api/health'],
|
|
68
|
+
summary: '健康检查(本机配置 + 后端可达性)',
|
|
69
|
+
run: async () => {
|
|
70
|
+
const cfg = await loadConfig();
|
|
71
|
+
let backend = 'unreachable';
|
|
72
|
+
let backendError = '';
|
|
73
|
+
try {
|
|
74
|
+
const res = await fetch(cfg.baseUrl.replace(/\/$/, '') + '/api/v1/user/info', {
|
|
75
|
+
headers: cfg.token ? { authorization: `Bearer ${cfg.token}` } : {},
|
|
76
|
+
signal: AbortSignal.timeout(6000),
|
|
77
|
+
});
|
|
78
|
+
backend = res.ok ? 'reachable' : `http ${res.status}`;
|
|
79
|
+
} catch (e) {
|
|
80
|
+
backendError = String(e.message || e);
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
status: 'ok',
|
|
84
|
+
version: VERSION,
|
|
85
|
+
baseUrl: cfg.baseUrl,
|
|
86
|
+
configPath: configPathFromEnv(),
|
|
87
|
+
loggedIn: !!cfg.token,
|
|
88
|
+
backend,
|
|
89
|
+
backendError,
|
|
90
|
+
};
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
id: 'system.routes',
|
|
95
|
+
cli: ['routes'],
|
|
96
|
+
http: null,
|
|
97
|
+
summary: '命令 ↔ 路由对照表(--module 过滤;--http 反查命令)',
|
|
98
|
+
flags: {
|
|
99
|
+
module: { type: 'string', hint: '模块名' },
|
|
100
|
+
http: { type: 'string', hint: 'METHOD /api/path' },
|
|
101
|
+
},
|
|
102
|
+
run: async (ctx) => {
|
|
103
|
+
const table = await commandTable();
|
|
104
|
+
if (ctx.http) {
|
|
105
|
+
const { method, path } = parseHttpQuery(ctx.http);
|
|
106
|
+
const hit = table.filter(
|
|
107
|
+
(c) => c.http && (!method || c.http.method === method) && routeMatches(c.http.path, path)
|
|
108
|
+
);
|
|
109
|
+
if (!hit.length) throw notFound(`没有路由匹配: ${ctx.http}(用 nx-kv routes 查看全部对照)`);
|
|
110
|
+
return hit;
|
|
111
|
+
}
|
|
112
|
+
if (!ctx.module) return table;
|
|
113
|
+
|
|
114
|
+
const known = [...new Set(table.map((c) => c.module))];
|
|
115
|
+
if (!known.includes(ctx.module)) {
|
|
116
|
+
throw badInput(`未知模块: ${ctx.module}(可用: ${known.join(', ')})`);
|
|
117
|
+
}
|
|
118
|
+
return table.filter((c) => c.module === ctx.module);
|
|
119
|
+
},
|
|
120
|
+
render: (list) => {
|
|
121
|
+
if (!list.length) return '(无匹配)';
|
|
122
|
+
const w = Math.max(...list.map((c) => c.command.length));
|
|
123
|
+
return list
|
|
124
|
+
.map(
|
|
125
|
+
(c) =>
|
|
126
|
+
`${c.command.padEnd(w + 2)}${c.http ? `${c.http.method} ${c.http.path}` : '(仅 CLI,无 HTTP 路由)'}`
|
|
127
|
+
)
|
|
128
|
+
.join('\n');
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
],
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// ---- 内部小工具 ----
|
|
135
|
+
|
|
136
|
+
function parseHttpQuery(input) {
|
|
137
|
+
const s = String(input || '').trim();
|
|
138
|
+
const m = /^([A-Za-z]+)\s+(.*)$/.exec(s);
|
|
139
|
+
const method = m ? m[1].toUpperCase() : null;
|
|
140
|
+
const path = (m ? m[2] : s).split('?')[0];
|
|
141
|
+
if (!path.startsWith('/')) {
|
|
142
|
+
throw badInput(`HTTP 查询需形如 "/api/todo" 或 "GET /api/todo",收到: ${input}`);
|
|
143
|
+
}
|
|
144
|
+
return { method, path };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// 段对段比较:`:param` 视为通配
|
|
148
|
+
function routeMatches(pattern, path) {
|
|
149
|
+
const a = pattern.split('/').filter(Boolean);
|
|
150
|
+
const b = path.split('/').filter(Boolean);
|
|
151
|
+
if (a.length !== b.length) return false;
|
|
152
|
+
return a.every((seg, i) => seg.startsWith(':') || seg === b[i]);
|
|
153
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
// 清单模块:四把 key(open / done / freeze / topics)上的任务管理。
|
|
2
|
+
//
|
|
3
|
+
// 声明了 `resource: 'todo'`,因此必须齐备 CRUD 五操作且两端可调用 ——
|
|
4
|
+
// tests/unit/registry.test.mjs 会据此断言。非 CRUD 的动作(完成/冻结/归档/topic/prompt)
|
|
5
|
+
// 用动作动词命名,不受五动词约束,但同样两端可达。
|
|
6
|
+
import * as service from './service.js';
|
|
7
|
+
|
|
8
|
+
// 所有动作都接受 --group 覆盖当前工作空间;默认取本机配置里的当前值
|
|
9
|
+
const GROUP = { type: 'number', hint: '工作空间 id(默认取当前)' };
|
|
10
|
+
|
|
11
|
+
// 完成时间显示:只取到分钟,表格里塞不下完整时间戳
|
|
12
|
+
const shortTime = (s) => (s ? String(s).slice(0, 16).replace('T', ' ') : '');
|
|
13
|
+
|
|
14
|
+
function renderTaskList(d) {
|
|
15
|
+
const groups = [
|
|
16
|
+
['open', '待办'],
|
|
17
|
+
['done', '已完成'],
|
|
18
|
+
['freeze', '冻结'],
|
|
19
|
+
].filter(([k]) => d[k]);
|
|
20
|
+
|
|
21
|
+
const lines = [];
|
|
22
|
+
if (d.topic) lines.push(`topic=${d.topic}`);
|
|
23
|
+
for (const [key, label] of groups) {
|
|
24
|
+
const items = d[key];
|
|
25
|
+
if (!items) continue;
|
|
26
|
+
if (!items.length) {
|
|
27
|
+
lines.push(`${label}: (空)`);
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
lines.push(`${label}(${items.length}):`);
|
|
31
|
+
const w = Math.max(...items.map((t) => String(t.id).length));
|
|
32
|
+
for (const t of items) {
|
|
33
|
+
const mark = key === 'done' ? `✓ ${shortTime(t.doneAt)}` : key === 'freeze' ? `❄ ${shortTime(t.frozenAt)}` : '';
|
|
34
|
+
lines.push(` ${String(t.id).padStart(w)} [${t.topic}] ${t.text}${mark ? ' ' + mark : ''}`);
|
|
35
|
+
if (t.note) lines.push(` ${' '.repeat(w)} note: ${t.note.slice(0, 100)}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return lines.join('\n');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const renderTask = (t) =>
|
|
42
|
+
[
|
|
43
|
+
`#${t.id} [${t.topic}] ${t.text}`,
|
|
44
|
+
` 状态: ${t.doneAt ? '已完成' : t.frozenAt ? '冻结' : '待办'}${t.bucket ? `(在 ${t.bucket})` : ''}`,
|
|
45
|
+
` 创建: ${t.createdAt}`,
|
|
46
|
+
t.doneAt ? ` 完成: ${t.doneAt}` : '',
|
|
47
|
+
t.frozenAt ? ` 冻结: ${t.frozenAt}` : '',
|
|
48
|
+
t.note ? ` 备注: ${t.note}` : '',
|
|
49
|
+
]
|
|
50
|
+
.filter(Boolean)
|
|
51
|
+
.join('\n');
|
|
52
|
+
|
|
53
|
+
export default {
|
|
54
|
+
id: 'todo',
|
|
55
|
+
title: '清单(= Web「清单」页)',
|
|
56
|
+
order: 30,
|
|
57
|
+
view: () => import('./view.jsx'),
|
|
58
|
+
|
|
59
|
+
// CRUD 资源声明:任务的增删查改必须齐备,且 CLI 与面板都能调用
|
|
60
|
+
resource: 'todo',
|
|
61
|
+
|
|
62
|
+
actions: [
|
|
63
|
+
// ─── CRUD 五操作 ───────────────────────────────────────────
|
|
64
|
+
{
|
|
65
|
+
id: 'todo.list',
|
|
66
|
+
cli: ['todo', 'list'],
|
|
67
|
+
http: ['GET', '/api/todo'],
|
|
68
|
+
summary: '列出任务(--topic / --status 过滤;默认三个桶全列)',
|
|
69
|
+
flags: {
|
|
70
|
+
topic: { type: 'string', hint: '只看某个主题' },
|
|
71
|
+
status: { type: 'string', enum: ['open', 'done', 'freeze', 'all'], default: 'all' },
|
|
72
|
+
group: GROUP,
|
|
73
|
+
},
|
|
74
|
+
run: (ctx) => service.listTodos({ topic: ctx.topic, status: ctx.status, groupId: ctx.group }),
|
|
75
|
+
render: renderTaskList,
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
id: 'todo.get',
|
|
79
|
+
cli: ['todo', 'get'],
|
|
80
|
+
http: ['GET', '/api/todo/:id'],
|
|
81
|
+
summary: '查看单个任务(自动在待办/已完成/冻结里找)',
|
|
82
|
+
args: ['id'],
|
|
83
|
+
flags: {
|
|
84
|
+
topic: { type: 'string', hint: '消歧:同 id 有多条时用主题定位' },
|
|
85
|
+
pick: { type: 'number', hint: '消歧:同 id 多条时按编号选(配合报错里的列表)' },
|
|
86
|
+
group: GROUP,
|
|
87
|
+
},
|
|
88
|
+
run: (ctx) => service.getTodo(Number(ctx.id), { topic: ctx.topic, pick: ctx.pick, groupId: ctx.group }),
|
|
89
|
+
render: renderTask,
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
id: 'todo.add',
|
|
93
|
+
cli: ['todo', 'add'],
|
|
94
|
+
http: ['POST', '/api/todo'],
|
|
95
|
+
summary: '新增任务到待办(topic 不在快捷列表时自动补上)',
|
|
96
|
+
args: ['text'],
|
|
97
|
+
flags: { topic: { type: 'string', required: true, hint: '主题,路由维度' }, group: GROUP },
|
|
98
|
+
run: (ctx) => service.addTodo({ topic: ctx.topic, text: ctx.text, groupId: ctx.group }),
|
|
99
|
+
render: (d) =>
|
|
100
|
+
`已加入待办: #${d.id} [${d.task.topic}] ${d.task.text}` +
|
|
101
|
+
(d.topicAdded ? `\n(已把 ${d.task.topic} 加入快捷 topic)` : ''),
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
id: 'todo.update',
|
|
105
|
+
cli: ['todo', 'update'],
|
|
106
|
+
http: ['PATCH', '/api/todo/:id'],
|
|
107
|
+
summary: '编辑任务(只改传入的字段)',
|
|
108
|
+
args: ['id'],
|
|
109
|
+
flags: {
|
|
110
|
+
topic: { type: 'string', hint: '改成这个主题' },
|
|
111
|
+
text: { type: 'string' },
|
|
112
|
+
note: { type: 'string' },
|
|
113
|
+
matchTopic: { type: 'string', hint: '消歧:同 id 有多条时用主题定位' },
|
|
114
|
+
pick: { type: 'number', hint: '消歧:同 id 多条时按编号选(配合报错里的列表)' },
|
|
115
|
+
group: GROUP,
|
|
116
|
+
},
|
|
117
|
+
run: (ctx) =>
|
|
118
|
+
service.updateTodo(Number(ctx.id), {
|
|
119
|
+
topic: ctx.topic,
|
|
120
|
+
text: ctx.text,
|
|
121
|
+
note: ctx.note,
|
|
122
|
+
matchTopic: ctx.matchTopic,
|
|
123
|
+
pick: ctx.pick,
|
|
124
|
+
groupId: ctx.group,
|
|
125
|
+
}),
|
|
126
|
+
render: (d) => `已更新 #${d.task.id}(在 ${d.bucket})`,
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
id: 'todo.remove',
|
|
130
|
+
cli: ['todo', 'remove'],
|
|
131
|
+
http: ['DELETE', '/api/todo/:id'],
|
|
132
|
+
summary: '删除任务(只删命中的那一条)',
|
|
133
|
+
args: ['id'],
|
|
134
|
+
flags: {
|
|
135
|
+
topic: { type: 'string', hint: '消歧:同 id 有多条时用主题定位' },
|
|
136
|
+
pick: { type: 'number', hint: '消歧:同 id 多条时按编号选(配合报错里的列表)' },
|
|
137
|
+
group: GROUP,
|
|
138
|
+
},
|
|
139
|
+
run: (ctx) => service.removeTodo(Number(ctx.id), { topic: ctx.topic, pick: ctx.pick, groupId: ctx.group }),
|
|
140
|
+
render: (d) => `已删除 #${d.removed.id} [${d.removed.topic}] ${d.removed.text}`,
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
// ─── 状态流转 ──────────────────────────────────────────────
|
|
144
|
+
{
|
|
145
|
+
id: 'todo.done',
|
|
146
|
+
cli: ['todo', 'done'],
|
|
147
|
+
http: ['POST', '/api/todo/:id/done'],
|
|
148
|
+
summary: '标记完成(--result 写进 note 作为完成结果)',
|
|
149
|
+
args: ['id'],
|
|
150
|
+
flags: {
|
|
151
|
+
result: { type: 'string', hint: '完成结果摘要' },
|
|
152
|
+
topic: { type: 'string', hint: '消歧:同 id 有多条时用主题定位' },
|
|
153
|
+
pick: { type: 'number', hint: '消歧:同 id 多条时按编号选(配合报错里的列表)' },
|
|
154
|
+
group: GROUP,
|
|
155
|
+
},
|
|
156
|
+
run: (ctx) =>
|
|
157
|
+
service.doneTodo(Number(ctx.id), { result: ctx.result, topic: ctx.topic, pick: ctx.pick, groupId: ctx.group }),
|
|
158
|
+
render: (d) => `已完成 #${d.task.id} [${d.task.topic}] ${d.task.text}\n ${d.task.doneAt}`,
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
id: 'todo.freeze',
|
|
162
|
+
cli: ['todo', 'freeze'],
|
|
163
|
+
http: ['POST', '/api/todo/:id/freeze'],
|
|
164
|
+
summary: '冻结任务(id 保留,解冻时校验冲突)',
|
|
165
|
+
args: ['id'],
|
|
166
|
+
flags: {
|
|
167
|
+
topic: { type: 'string', hint: '消歧:同 id 有多条时用主题定位' },
|
|
168
|
+
pick: { type: 'number', hint: '消歧:同 id 多条时按编号选(配合报错里的列表)' },
|
|
169
|
+
group: GROUP,
|
|
170
|
+
},
|
|
171
|
+
run: (ctx) => service.freezeTodo(Number(ctx.id), { topic: ctx.topic, pick: ctx.pick, groupId: ctx.group }),
|
|
172
|
+
render: (d) => `已冻结 #${d.task.id} [${d.task.topic}] ${d.task.text}`,
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
id: 'todo.unfreeze',
|
|
176
|
+
cli: ['todo', 'unfreeze'],
|
|
177
|
+
http: ['POST', '/api/todo/:id/unfreeze'],
|
|
178
|
+
summary: '解冻回待办(id 撞车时自动换新 id)',
|
|
179
|
+
args: ['id'],
|
|
180
|
+
flags: {
|
|
181
|
+
topic: { type: 'string', hint: '消歧:同 id 有多条时用主题定位' },
|
|
182
|
+
pick: { type: 'number', hint: '消歧:同 id 多条时按编号选(配合报错里的列表)' },
|
|
183
|
+
group: GROUP,
|
|
184
|
+
},
|
|
185
|
+
run: (ctx) => service.unfreezeTodo(Number(ctx.id), { topic: ctx.topic, pick: ctx.pick, groupId: ctx.group }),
|
|
186
|
+
render: (d) =>
|
|
187
|
+
`已解冻 #${d.task.id} [${d.task.topic}] ${d.task.text}` +
|
|
188
|
+
(d.reIded ? '\n(原 id 与现有待办冲突,已分配新 id)' : ''),
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
id: 'todo.archive',
|
|
192
|
+
cli: ['todo', 'archive'],
|
|
193
|
+
http: ['POST', '/api/todo/archive'],
|
|
194
|
+
summary: '把已完成里较旧的条目归档到冷 key(默认 30 天前)',
|
|
195
|
+
flags: {
|
|
196
|
+
before: { type: 'string', hint: '截止日期,如 2026-08-01' },
|
|
197
|
+
group: GROUP,
|
|
198
|
+
},
|
|
199
|
+
run: (ctx) => service.archiveDone({ before: ctx.before, groupId: ctx.group }),
|
|
200
|
+
render: (d) =>
|
|
201
|
+
d.moved
|
|
202
|
+
? `已归档 ${d.moved} 条 -> ${d.coldKey}(已完成剩 ${d.remaining} 条,冷 key 共 ${d.total} 条)`
|
|
203
|
+
: '没有需要归档的条目',
|
|
204
|
+
},
|
|
205
|
+
|
|
206
|
+
// ─── 快捷 topic(动作集合,非 CRUD)───────────────────────
|
|
207
|
+
{
|
|
208
|
+
id: 'topic.list',
|
|
209
|
+
cli: ['topic', 'list'],
|
|
210
|
+
http: ['GET', '/api/topics'],
|
|
211
|
+
summary: '快捷主题列表(含各状态任务数)',
|
|
212
|
+
flags: { group: GROUP },
|
|
213
|
+
run: (ctx) => service.listTopics({ groupId: ctx.group }),
|
|
214
|
+
render: (list) => {
|
|
215
|
+
if (!list.length) return '(暂无快捷主题)';
|
|
216
|
+
const w = Math.max(...list.map((t) => t.name.length));
|
|
217
|
+
return list
|
|
218
|
+
.map((t) => `${t.name.padEnd(w)} 待办 ${t.open} 完成 ${t.done} 冻结 ${t.freeze}`)
|
|
219
|
+
.join('\n');
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
id: 'topic.add',
|
|
224
|
+
cli: ['topic', 'add'],
|
|
225
|
+
http: ['POST', '/api/topics'],
|
|
226
|
+
summary: '加入快捷主题',
|
|
227
|
+
args: ['name'],
|
|
228
|
+
flags: { group: GROUP },
|
|
229
|
+
run: (ctx) => service.addTopic(ctx.name, { groupId: ctx.group }),
|
|
230
|
+
render: (d) => (d.added ? `已加入快捷主题: ${d.name}` : `${d.name} 已在快捷列表中`),
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
id: 'topic.remove',
|
|
234
|
+
cli: ['topic', 'remove'],
|
|
235
|
+
http: ['DELETE', '/api/topics'],
|
|
236
|
+
summary: '移出快捷主题(不动已有任务;仍被任务使用时会在结果里提示)',
|
|
237
|
+
args: ['name'],
|
|
238
|
+
flags: { group: GROUP },
|
|
239
|
+
run: (ctx) => service.removeTopic(ctx.name, { groupId: ctx.group }),
|
|
240
|
+
render: (d) => {
|
|
241
|
+
if (!d.removed) return `${d.name} 本就不在快捷列表中`;
|
|
242
|
+
return d.inUse
|
|
243
|
+
? `已移出快捷主题: ${d.name}\n注意: 仍有 ${d.inUse} 条任务在用它,主题名不会消失,只是不再出现在候选里`
|
|
244
|
+
: `已移出快捷主题: ${d.name}`;
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
|
|
248
|
+
// ─── 主题提示词(给 agent 的上下文)───────────────────────
|
|
249
|
+
{
|
|
250
|
+
id: 'prompt.get',
|
|
251
|
+
cli: ['prompt', 'get'],
|
|
252
|
+
http: ['GET', '/api/prompts/:topic'],
|
|
253
|
+
summary: '读取某主题的上下文提示词',
|
|
254
|
+
args: ['topic'],
|
|
255
|
+
flags: { group: GROUP },
|
|
256
|
+
run: (ctx) => service.getPrompt(ctx.topic, { groupId: ctx.group }),
|
|
257
|
+
render: (d) => (d.hasPrompt ? d.prompt : `(${d.topic} 没有配置提示词)`),
|
|
258
|
+
},
|
|
259
|
+
{
|
|
260
|
+
id: 'prompt.set',
|
|
261
|
+
cli: ['prompt', 'set'],
|
|
262
|
+
http: ['POST', '/api/prompts/:topic'],
|
|
263
|
+
summary: '写入某主题的上下文提示词(覆盖)',
|
|
264
|
+
args: ['topic', 'text'],
|
|
265
|
+
flags: { group: GROUP },
|
|
266
|
+
run: (ctx) => service.setPrompt(ctx.topic, ctx.text, { groupId: ctx.group }),
|
|
267
|
+
render: (d) => `已写入 ${d.topic} 的提示词(${d.bytes} 字节)`,
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
id: 'prompt.remove',
|
|
271
|
+
cli: ['prompt', 'remove'],
|
|
272
|
+
http: ['DELETE', '/api/prompts/:topic'],
|
|
273
|
+
summary: '删除某主题的提示词',
|
|
274
|
+
args: ['topic'],
|
|
275
|
+
flags: { group: GROUP },
|
|
276
|
+
run: (ctx) => service.removePrompt(ctx.topic, { groupId: ctx.group }),
|
|
277
|
+
render: (d) => (d.removed ? `已删除 ${d.topic} 的提示词` : `${d.topic} 本就没有提示词`),
|
|
278
|
+
},
|
|
279
|
+
],
|
|
280
|
+
};
|