@yss123/cage-admin 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +31 -3
  2. package/dist/bootstrap.d.ts +27 -1
  3. package/dist/bootstrap.js +52 -16
  4. package/dist/bootstrap.js.map +1 -1
  5. package/dist/catalog.d.ts +5 -0
  6. package/dist/catalog.js +5 -0
  7. package/dist/catalog.js.map +1 -1
  8. package/dist/diagnostics.d.ts +14 -0
  9. package/dist/diagnostics.js +163 -0
  10. package/dist/diagnostics.js.map +1 -0
  11. package/dist/domain/constraints.d.ts +23 -0
  12. package/dist/domain/constraints.js +27 -0
  13. package/dist/domain/constraints.js.map +1 -1
  14. package/dist/domain/index.d.ts +5 -0
  15. package/dist/domain/index.js +5 -0
  16. package/dist/domain/index.js.map +1 -1
  17. package/dist/domain/org.d.ts +82 -1
  18. package/dist/domain/org.js +98 -4
  19. package/dist/domain/org.js.map +1 -1
  20. package/dist/http.d.ts +11 -0
  21. package/dist/http.js +24 -0
  22. package/dist/http.js.map +1 -1
  23. package/dist/index.d.ts +5 -0
  24. package/dist/index.js +5 -0
  25. package/dist/index.js.map +1 -1
  26. package/package.json +3 -2
  27. package/ui/dist/app/shell.js +25 -0
  28. package/ui/dist/app.js +10 -0
  29. package/ui/dist/modules/audit/audit-page.js +12 -0
  30. package/ui/dist/modules/audit/index.js +4 -0
  31. package/ui/dist/modules/auth/index.js +4 -0
  32. package/ui/dist/modules/auth/login-page.js +8 -0
  33. package/ui/dist/modules/models/index.js +5 -0
  34. package/ui/dist/modules/models/models-page.js +60 -0
  35. package/ui/dist/modules/operations/index.js +4 -0
  36. package/ui/dist/modules/operations/operations-page.js +15 -0
  37. package/ui/dist/modules/operations/sync-page.js +12 -0
  38. package/ui/dist/modules/org/index.js +4 -0
  39. package/ui/dist/modules/org/org-page.js +30 -0
  40. package/ui/dist/modules/personalities/index.js +5 -0
  41. package/ui/dist/modules/personalities/personalities-page.js +161 -0
  42. package/ui/dist/modules/roles/index.js +4 -0
  43. package/ui/dist/modules/roles/roles-page.js +40 -0
  44. package/ui/dist/modules/tasks/index.js +4 -0
  45. package/ui/dist/modules/tasks/tasks-page.js +8 -0
  46. package/ui/dist/shared/api.js +84 -2
  47. package/ui/dist/shared/dom.js +36 -0
  48. package/ui/dist/shared/labels.js +29 -0
  49. package/ui/dist/shared/navigation.js +7 -0
  50. package/ui/index.html +1 -0
  51. package/ui/style.css +81 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yss123/cage-admin",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "组织模型声明与 /admin 系统管理",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -25,7 +25,8 @@
25
25
  "fastify": "^5.6.0",
26
26
  "zod": "^4.1.5",
27
27
  "kysely": "^0.28.5",
28
- "@yss123/cage-kernel": "0.2.0"
28
+ "@yss123/cage-kernel": "0.3.0",
29
+ "@yss123/cage-ai-protocol": "0.3.0"
29
30
  },
30
31
  "license": "MIT",
31
32
  "repository": {
@@ -1,3 +1,8 @@
1
+ /**
2
+ * 系统管理应用壳,负责 hash 页面映射、导航、账号操作与加载/错误状态。
3
+ * 页面模块经公开入口组合,模块只接收内容容器;权限提示按当前会话能力计算,后端仍做权威授权。
4
+ * 管理界面使用自身 hash 导航,返回业务首页通过普通链接,避免与业务 React Router 混用。
5
+ */
1
6
  import { api, can, run, state } from '../shared/api.js';
2
7
  import { emptyState, h, icon, panel } from '../shared/dom.js';
3
8
  import { navigate } from '../shared/navigation.js';
@@ -7,7 +12,22 @@ import { renderRoles } from '../modules/roles/index.js';
7
12
  import { renderAudit } from '../modules/audit/index.js';
8
13
  import { renderOperations, renderSync } from '../modules/operations/index.js';
9
14
  import { loginPage } from '../modules/auth/index.js';
15
+ import { renderModels } from '../modules/models/index.js';
16
+ import { renderPersonalities } from '../modules/personalities/index.js';
17
+ /**
18
+ * 页面目录及所需能力清单。need 为空表示有登录会话即可尝试进入,非空采用 some 允许满足任一能力。
19
+ * 这些条目同时决定导航显示和页面级提示,不能替代模块请求或服务端鉴权。
20
+ */
10
21
  const pages = {
22
+ personalities: {
23
+ title: '助理性格', description: '设置助理的语气、表达习惯和默认性格,随时试聊。',
24
+ glyph: 'users', category: '运行与记录', render: renderPersonalities, need: ['Sys.ai.personalities.manage'],
25
+ },
26
+ models: {
27
+ title: 'AI 模型',
28
+ description: '为智能审批与业务助理配置模型连接,保存后后续调用自动生效。',
29
+ glyph: 'grid', category: '运行与记录', render: renderModels, need: ['Sys.ai.models.manage'],
30
+ },
11
31
  tasks: {
12
32
  title: '我的待办',
13
33
  description: '集中处理分配给你的审批任务,让每一步协作及时推进。',
@@ -58,6 +78,11 @@ const pages = {
58
78
  },
59
79
  };
60
80
  /** 应用组合层只负责导航、会话和页面加载;各管理模块维护自己的表单和请求。 */
81
+ /**
82
+ * 根据当前会话与 hash 重建侧栏、页头和内容容器,并等待目标模块完成数据加载。
83
+ * 没有会话时返回登录页,登录成功调用 boot 重新取身份和目录;退出成功后清空本地会话资料。
84
+ * 加载异常写入错误面板,finally 移除加载提示;本函数不把隐藏按钮视为服务端授权。
85
+ */
61
86
  export async function render(boot) {
62
87
  const root = document.getElementById('app');
63
88
  if (!state.me) {
package/ui/dist/app.js CHANGED
@@ -1,7 +1,17 @@
1
+ /**
2
+ * 系统管理浏览器入口:恢复登录身份、加载展示目录,再交给 shell 组合页面。
3
+ * 监听 hash 导航、刷新及会话过期事件;未登录进入登录页,网络或服务异常显示可重试连接状态。
4
+ * 本入口不保存授权或执行领域逻辑,真实身份与目录通过同源 HTTP 请求获取。
5
+ */
1
6
  import { api, ApiError, state } from './shared/api.js';
2
7
  import { h, icon } from './shared/dom.js';
3
8
  import { render } from './app/shell.js';
4
9
  /** 浏览器入口恢复真实会话与展示目录,不包含具体页面、表单或授权逻辑。 */
10
+ /**
11
+ * 按顺序读取 /api/me 与管理目录,成功后渲染当前 hash 页面。
12
+ * 仅 UNAUTHENTICATED 走未登录界面;其他故障保留明确重连入口,避免把网络异常伪装为退出。
13
+ * 此函数也作为登录成功回调和重新连接动作,恢复失败通过页面反馈。
14
+ */
5
15
  async function boot() {
6
16
  try {
7
17
  state.me = await api('GET', '/api/me');
@@ -1,3 +1,7 @@
1
+ /**
2
+ * 操作审计查询页面,通过安全审计接口读取记录并展示操作人、时间和变化详情。
3
+ * 筛选、分页与详情均属于读操作;界面不补写审计,也不将查到的部分记录当作完整请求轨迹。
4
+ */
1
5
  import { api, run, state } from '../../shared/api.js';
2
6
  import { badge, field, formatTime, h, panel, table } from '../../shared/dom.js';
3
7
  import { capabilityName, objectName } from '../../shared/labels.js';
@@ -14,6 +18,10 @@ const actors = {
14
18
  job: '定时任务',
15
19
  system: '系统',
16
20
  };
21
+ /**
22
+ * 构建审计筛选器与列表,等待初次 load 完成;服务端仍校验 Sys.audit.read 与安全视图。
23
+ * 详情通过 DOM 文本展示,包含结构化差异时也不解释为 HTML。
24
+ */
17
25
  export async function renderAudit(main) {
18
26
  const actor = h('input', { placeholder: '操作人的用户 ID' });
19
27
  const aggregate = h('select', {}, h('option', { value: '' }, '全部业务对象'), state.catalog.aggregates.map((item) => h('option', { value: item.name }, item.label)));
@@ -47,6 +55,10 @@ export async function renderAudit(main) {
47
55
  void load();
48
56
  },
49
57
  }, field('业务对象', aggregate), field('操作类型', kind), field('操作人', actor), field('单据编号', aggregateId), field('操作标识', command), search), out, h('div', { class: 'pagination' }, pageText, h('div', { class: 'row-actions' }, previous, next))));
58
+ /**
59
+ * 按当前筛选和页码请求审计记录,更新表格及分页控制。
60
+ * 查询失败保留明确错误反馈,不生成占位成功记录;请求只读,不重放任何业务命令。
61
+ */
50
62
  async function load() {
51
63
  const params = new URLSearchParams(Object.entries(fields)
52
64
  .filter(([, input]) => input.value)
@@ -1 +1,5 @@
1
+ /**
2
+ * 审计查询模块的公开入口。
3
+ * 应用壳通过这里组合页面,其他模块无需依赖内部表单实现;导出本身不发请求或执行管理操作。
4
+ */
1
5
  export { renderAudit } from './audit-page.js';
@@ -1 +1,5 @@
1
+ /**
2
+ * 登录页面模块的公开入口。
3
+ * 应用壳通过这里组合页面,其他模块无需依赖内部表单实现;导出本身不发请求或执行管理操作。
4
+ */
1
5
  export { loginPage } from './login-page.js';
@@ -1,5 +1,13 @@
1
+ /**
2
+ * 构造系统管理登录页面,复用统一 JSON/CSRF 请求和操作反馈。
3
+ * 账号密码只用于本次登录表单;成功清掉旧 CSRF 后由上层重新加载真实身份和目录,SSO 使用独立服务端入口。
4
+ */
1
5
  import { api, run, state } from '../../shared/api.js';
2
6
  import { field, h, icon } from '../../shared/dom.js';
7
+ /**
8
+ * 返回未挂载的登录页面 DOM。提交期间禁用按钮,失败由 run 显示原因并保留可重试表单。
9
+ * onLogin 仅在登录请求成功后等待执行,负责恢复会话并切换页面;本组件不自行伪造身份或能力列表。
10
+ */
3
11
  export function loginPage(onLogin) {
4
12
  const username = h('input', {
5
13
  name: 'username',
@@ -0,0 +1,5 @@
1
+ /**
2
+ * 托管模型配置模块的公开入口。
3
+ * 应用壳通过这里组合页面,其他模块无需依赖内部表单实现;导出本身不发请求或执行管理操作。
4
+ */
5
+ export { renderModels } from './models-page.js';
@@ -0,0 +1,60 @@
1
+ import { api, run } from '../../shared/api.js';
2
+ import { badge, emptyState, field, formatTime, h, panel } from '../../shared/dom.js';
3
+ /** Admin 只管理框架模型配置;审批规则、业务工具与聊天界面仍由各自声明和应用负责。 */
4
+ /**
5
+ * 读取托管模型列表并在 main 中构建配置表单,无模型时显示启用提示。
6
+ * 表单以秒显示超时,提交转换为毫秒;保存携带已读取的 version 防止覆盖其他编辑。
7
+ * 测试仅使用已保存配置,连接成功不证明全部协议特性或业务工具验收通过;错误由共享操作层反馈。
8
+ */
9
+ export async function renderModels(main) {
10
+ const models = await api('GET', '/api/ai/admin/models');
11
+ if (!models.length) {
12
+ main.append(panel('尚无可管理的模型', undefined, emptyState('应用尚未启用托管模型', '应用声明模型后,可在这里配置服务地址与密钥。', 'grid')));
13
+ return;
14
+ }
15
+ main.append(h('div', { class: 'model-overview' }, h('div', {}, h('span', { class: 'eyebrow' }, 'MODEL CONNECTIONS'), h('h2', {}, '一个连接,支撑每次智能协作'), h('p', {}, '模型按当前租户配置。API Key 加密保存,不会回显到页面。')), badge(`${models.length} 个模型入口`, 'green')));
16
+ for (let saved of models) {
17
+ const label = h('input', { value: saved.label, required: true, maxlength: 100 });
18
+ const baseUrl = h('input', { type: 'url', value: saved.baseUrl, placeholder: 'https://api.example.com/v1', required: true, maxlength: 1000 });
19
+ const model = h('input', { value: saved.model, placeholder: '填写服务商提供的模型名称', required: true, maxlength: 200 });
20
+ const apiKey = h('input', { type: 'password', autocomplete: 'new-password', placeholder: saved.hasApiKey ? '已保存;留空保留原密钥' : '粘贴 API Key', maxlength: 4096 });
21
+ const enabled = h('input', { type: 'checkbox', checked: saved.enabled });
22
+ const timeout = h('input', { type: 'number', min: 1, max: 180, value: saved.timeoutMs / 1000, required: true });
23
+ const compaction = h('input', { type: 'checkbox', checked: saved.responsesCompaction, disabled: saved.apiFormat !== 'responses' });
24
+ const apiFormat = h('select', { value: saved.apiFormat, onchange: () => {
25
+ compaction.disabled = apiFormat.value !== 'responses';
26
+ if (compaction.disabled)
27
+ compaction.checked = false;
28
+ } }, h('option', { value: 'chat-completions' }, 'Chat Completions'), h('option', { value: 'responses' }, 'Responses'));
29
+ const contextWindow = h('input', { type: 'number', min: 8192, max: 2000000, step: 1, value: saved.contextWindowTokens, required: true });
30
+ const status = h('div', { class: 'model-save-status', role: 'status' });
31
+ const summary = h('div', { class: 'model-meta' });
32
+ const refresh = () => summary.replaceChildren(badge(saved.enabled ? '已启用' : '未启用', saved.enabled ? 'green' : 'neutral'), h('span', {}, saved.hasApiKey ? '密钥已保存' : '等待配置'), h('span', {}, saved.apiFormat === 'responses' ? 'Responses · 服务端接续' : 'Chat Completions'), h('span', {}, `更新于 ${formatTime(saved.updatedAt)}`));
33
+ refresh();
34
+ const save = h('button', { class: 'primary', type: 'submit' }, '保存配置');
35
+ const test = h('button', { type: 'button', disabled: !saved.enabled, onclick: async () => {
36
+ status.textContent = '正在测试已保存的配置…';
37
+ const result = await run(() => api('POST', `/api/ai/admin/models/${encodeURIComponent(saved.key)}/test`), undefined, test);
38
+ status.textContent = result ? result.ok ? `连接成功 · ${result.latencyMs} ms · 配置版本 ${result.version}`
39
+ : `连接未成功(${result.code}),请核对地址、模型名称与密钥。` : '测试未完成,请根据错误提示检查配置。';
40
+ } }, '测试连接');
41
+ const form = h('form', { class: 'model-form', onsubmit: async (event) => {
42
+ event.preventDefault();
43
+ test.disabled = true;
44
+ const result = await run(() => api('POST', `/api/ai/admin/models/${encodeURIComponent(saved.key)}`, {
45
+ version: saved.version, label: label.value, baseUrl: baseUrl.value, model: model.value, apiKey: apiKey.value || undefined,
46
+ enabled: enabled.checked, timeoutMs: Number(timeout.value) * 1000,
47
+ apiFormat: apiFormat.value, contextWindowTokens: Number(contextWindow.value), responsesCompaction: compaction.checked,
48
+ }).catch(error => { status.textContent = error.message; throw error; }), '模型配置已保存', save);
49
+ if (result) {
50
+ saved = result;
51
+ apiKey.value = '';
52
+ apiKey.placeholder = '已保存;留空保留原密钥';
53
+ refresh();
54
+ status.textContent = '已保存,后续模型调用将使用这份配置。';
55
+ }
56
+ test.disabled = !saved.enabled;
57
+ } }, h('div', { class: 'model-fields' }, field('显示名称', label), field('模型名称', model), field('接口协议', apiFormat, 'Responses 可由服务端接续对话,兼容服务须支持对应协议。'), field('模型上下文窗口(Token)', contextWindow, '按服务商公布的容量填写,用于预留回答空间和限制每次输入。'), field('API 基地址', baseUrl, '支持 HTTP / HTTPS。可填写基础地址或对应接口完整路径;更换地址时需重新填写密钥。'), field('API Key', apiKey, '仅在首次配置或更换密钥时填写。'), field('调用超时(秒)', timeout), field('服务端自动压缩', compaction, '仅对支持 context_management 的 Responses 服务启用;历史仍计入上下文与输入费用。'), h('label', { class: 'model-enabled' }, enabled, '启用此模型')), h('div', { class: 'model-actions' }, status, test, save));
58
+ main.append(panel(saved.label, `模型入口:${saved.key}`, summary, form));
59
+ }
60
+ }
@@ -1,2 +1,6 @@
1
+ /**
2
+ * 异步运维与第三方同步记录模块的公开入口。
3
+ * 应用壳通过这里组合页面,其他模块无需依赖内部表单实现;导出本身不发请求或执行管理操作。
4
+ */
1
5
  export { renderOperations } from './operations-page.js';
2
6
  export { renderSync } from './sync-page.js';
@@ -1,7 +1,15 @@
1
+ /**
2
+ * 异步运行运维页面,读取 outbox 汇总、未处理死信和注册任务状态。
3
+ * 运维读取与管理能力分开,重放/丢弃与启停任务通过系统接口执行;界面不直接操作数据库。
4
+ */
1
5
  import { api, can, run } from '../../shared/api.js';
2
6
  import { badge, field, formatTime, h, icon, modal, panel, table } from '../../shared/dom.js';
3
7
  import { capabilityName, eventName, jobName, statusName } from '../../shared/labels.js';
4
8
  import { navigate } from '../../shared/navigation.js';
9
+ /**
10
+ * 并行取得运行快照并渲染统计、队列、死信和任务列表。
11
+ * Sys.ops.manage 控制动作按钮,成功请求后重新加载数据;重放仅表示重新投递,不保证远端业务最终成功。
12
+ */
5
13
  export async function renderOperations(main) {
6
14
  const [summary, dead, jobs] = await Promise.all([
7
15
  api('GET', '/api/sys/ops/summary'),
@@ -74,6 +82,10 @@ export async function renderOperations(main) {
74
82
  }, job.enabled ? '停用' : '启用')),
75
83
  ],
76
84
  ], jobs, '当前业务没有声明定时任务')));
85
+ /**
86
+ * 打开针对单条死信的确认操作,提交 discard 后按成功结果刷新页面。
87
+ * 丢弃会改变后续事件处理条件,因此需要用户明确选择,不能作为自动修复失败的默认动作。
88
+ */
77
89
  function discard(item) {
78
90
  const confirm = h('input', { type: 'checkbox', required: true });
79
91
  const save = h('button', { class: 'danger', type: 'submit' }, '确认丢弃事件');
@@ -90,6 +102,9 @@ export async function renderOperations(main) {
90
102
  const dialog = modal('丢弃死信事件', form);
91
103
  }
92
104
  }
105
+ /**
106
+ * 以既有计数生成运行统计卡,warning 仅决定视觉提示,不改变后台任务状态。
107
+ */
93
108
  function stat(label, value, glyph, warning = false) {
94
109
  return h('div', { class: `stat-card ${warning ? 'attention' : ''}` }, h('span', { class: 'stat-icon' }, icon(glyph)), h('div', {}, h('span', {}, label), h('strong', {}, value)));
95
110
  }
@@ -1,5 +1,13 @@
1
+ /**
2
+ * 第三方同步记录查询页面,以连接器和状态筛选展示实际外呼记录。
3
+ * 响应受服务器数量上限限制,结果记录与业务最终完成是不同事实,页面没有绕过未知结果隔离的重发入口。
4
+ */
1
5
  import { api, run, state } from '../../shared/api.js';
2
6
  import { badge, field, formatTime, h, panel, table } from '../../shared/dom.js';
7
+ /**
8
+ * 构建连接器/状态筛选与同步表格,初始 load 完成后返回。
9
+ * 读取需运维权限,错误和 HTTP 状态用于定位外部调用,不把 fake 或单次回执当真实系统验收。
10
+ */
3
11
  export async function renderSync(main) {
4
12
  const connector = h('input', { placeholder: '连接器标识,留空查看全部' });
5
13
  const status = h('select', {}, h('option', { value: '' }, '全部结果'), h('option', { value: 'ok' }, '同步成功'), h('option', { value: 'failed' }, '同步失败'));
@@ -12,6 +20,10 @@ export async function renderSync(main) {
12
20
  void load();
13
21
  },
14
22
  }, field('连接器', connector), field('同步结果', status), query), out));
23
+ /**
24
+ * 根据当前连接器及状态筛选发起只读请求,再替换同步记录列表。
25
+ * 查询失败通过页面反馈,不修改失败计数、回执或任务状态。
26
+ */
15
27
  async function load() {
16
28
  const params = new URLSearchParams();
17
29
  if (connector.value)
@@ -1 +1,5 @@
1
+ /**
2
+ * 部门与用户管理模块的公开入口。
3
+ * 应用壳通过这里组合页面,其他模块无需依赖内部表单实现;导出本身不发请求或执行管理操作。
4
+ */
1
5
  export { renderOrg } from './org-page.js';
@@ -1,7 +1,15 @@
1
+ /**
2
+ * 组织管理页面:按读取能力分别加载部门、用户与角色,再提供受能力控制的组织命令入口。
3
+ * 部门负责人用于审批选人,角色用于授权,两种配置独立维护;页面显示只反映当前取回的分页数据。
4
+ */
1
5
  import { api, can, run } from '../../shared/api.js';
2
6
  import { badge, field, h, icon, modal, panel, searchInput, table } from '../../shared/dom.js';
3
7
  import { navigate } from '../../shared/navigation.js';
4
8
  /** 组织编辑继续走生成命令;负责人和角色是两份配置,在界面上明确区分。 */
9
+ /**
10
+ * 并行读取最多一页组织数据,缺读取能力的集合按空列表处理,后端继续约束实际范围。
11
+ * 部门用 ancestors 深度显示层级;用户列表支持本地搜索,写入通过生成的领域路由,成功后刷新管理页面。
12
+ */
5
13
  export async function renderOrg(main) {
6
14
  const [departments, people, roleResult] = await Promise.all([
7
15
  can('Dept.read')
@@ -73,6 +81,10 @@ export async function renderOrg(main) {
73
81
  toolbar.append(h('button', { class: 'primary', disabled: !depts.length, onclick: createUser }, icon('plus'), '新建用户'));
74
82
  const userPanel = panel('用户与角色', '通过角色分配操作权限;账号停用后将无法继续访问系统。', toolbar, out);
75
83
  main.append(userPanel);
84
+ /**
85
+ * 按当前本地搜索条件重画已加载用户,并根据命令能力展示角色、密码、启停操作。
86
+ * 过滤范围限于当前返回的数据,不执行服务器全量搜索。
87
+ */
76
88
  function renderUsers() {
77
89
  const visible = users.filter((user) => `${user.name} ${user.username} ${departmentName.get(user.deptId) ?? ''}`
78
90
  .toLowerCase()
@@ -123,6 +135,9 @@ export async function renderOrg(main) {
123
135
  }
124
136
  if (people.total > users.length || departments.total > depts.length)
125
137
  main.append(h('p', { class: 'muted' }, '当前仅展示前 200 条可见组织记录;超出部分请通过业务组织接口查询。'));
138
+ /**
139
+ * 打开新建部门表单,通过 Dept.create 传入父部门与资料;成功关闭弹窗并重载组织页。
140
+ */
126
141
  function createDepartment() {
127
142
  const name = h('input', { required: true, maxlength: 100, placeholder: '例如:采购部' });
128
143
  const parent = selectDepartment();
@@ -140,6 +155,10 @@ export async function renderOrg(main) {
140
155
  const dialog = modal('新建部门', form);
141
156
  name.focus();
142
157
  }
158
+ /**
159
+ * 收集用户名、姓名及部门并调用用户创建命令;成功后刷新组织页,密码和角色需通过独立操作配置。
160
+ * 页面成功动作基于实际请求结果,不通过直接写表配置账号。
161
+ */
143
162
  function createUser() {
144
163
  const username = h('input', { required: true, placeholder: '用于登录的账号', autocomplete: 'off' });
145
164
  const name = h('input', { required: true, placeholder: '用户真实姓名' });
@@ -162,6 +181,10 @@ export async function renderOrg(main) {
162
181
  const dialog = modal('新建用户', form);
163
182
  username.focus();
164
183
  }
184
+ /**
185
+ * 编辑所选用户的完整角色 ID 集合,已有选择作为初值,保存走 User.assignRoles。
186
+ * 界面提示不能代替服务端防提权和角色有效性检查,成功后才关闭并刷新。
187
+ */
165
188
  function assignRoles(user) {
166
189
  const selected = new Set(user.roleIds);
167
190
  const save = h('button', { class: 'primary', type: 'submit' }, '保存角色');
@@ -187,6 +210,10 @@ export async function renderOrg(main) {
187
210
  }), h('span', {}, h('strong', {}, role.name), h('small', {}, role.code))))), h('div', { class: 'form-actions' }, save));
188
211
  const dialog = modal('配置用户角色', form);
189
212
  }
213
+ /**
214
+ * 为指定用户打开密码重置表单,提交到独立 set-password 命令。
215
+ * 密码仅保留在表单和请求中;输入校验/授权由服务端再次执行,不影响角色分配。
216
+ */
190
217
  function resetPassword(user) {
191
218
  const password = h('input', {
192
219
  type: 'password',
@@ -208,6 +235,9 @@ export async function renderOrg(main) {
208
235
  password.focus();
209
236
  }
210
237
  }
238
+ /**
239
+ * 生成组织页的统计展示卡;value 由调用者传入,不能把本页条数误当服务器总数。
240
+ */
211
241
  function stat(label, value, glyph) {
212
242
  return h('div', { class: 'stat-card' }, h('span', { class: 'stat-icon' }, icon(glyph)), h('div', {}, h('span', {}, label), h('strong', {}, value)));
213
243
  }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * 助理性格配置与试聊模块的公开入口。
3
+ * 应用壳通过这里组合页面,其他模块无需依赖内部表单实现;导出本身不发请求或执行管理操作。
4
+ */
5
+ export { renderPersonalities } from './personalities-page.js';
@@ -0,0 +1,161 @@
1
+ import { api, postEvents, run } from '../../shared/api.js';
2
+ import { badge, field, h } from '../../shared/dom.js';
3
+ /** 编辑器只提交框架协议;配置与试聊的权限、模型和持久化全部由 AI 运行时处理。 */
4
+ /**
5
+ * 读取性格目录并装配默认选择、卡片、编辑器和试聊区。
6
+ * 保存期间冻结表单,成功才清草稿并替换服务端版本;失败保留输入,finally 恢复控件。
7
+ * 试聊逐段追加文本并在 done 时校准最终回复;断流和取消保留已收到内容并给出明确状态。
8
+ */
9
+ export async function renderPersonalities(main) {
10
+ let catalog = await api('GET', '/api/ai/admin/personalities');
11
+ let selected = catalog.settings.defaultKey, saving = false, activePreview = null;
12
+ const drafts = new Map();
13
+ const grid = h('div', { class: 'persona-grid', 'aria-label': '性格列表' });
14
+ const editor = h('div', { class: 'persona-editor-area' });
15
+ const defaults = h('div', { class: 'persona-default' });
16
+ const current = () => drafts.get(selected) ?? catalog.items.find(p => p.key === selected);
17
+ /**
18
+ * 重建默认性格选择,只列已启用项;提交携带 settings.version,成功后更新目录和默认标记。
19
+ */
20
+ function drawDefault() {
21
+ const select = h('select', { 'aria-label': '默认性格', value: catalog.settings.defaultKey }, catalog.items.filter(p => p.enabled).map(p => h('option', { value: p.key }, `${p.avatar} ${p.label}`)));
22
+ const save = h('button', { onclick: async () => {
23
+ const result = await run(() => api('POST', '/api/ai/admin/assistant-settings', {
24
+ version: catalog.settings.version, defaultKey: select.value,
25
+ }), '默认性格已更新', save);
26
+ if (result) {
27
+ catalog.settings = result;
28
+ drawCards();
29
+ drawDefault();
30
+ }
31
+ } }, '设为默认');
32
+ defaults.replaceChildren(h('div', {}, h('strong', {}, '默认的相处方式'), h('p', {}, '跟随默认的会话,从下一条消息开始使用此性格。')), h('div', { class: 'persona-default-actions' }, select, save));
33
+ }
34
+ /**
35
+ * 合并已保存目录与新建草稿生成卡片;选择只改变本页编辑对象,保存期间禁止切换。
36
+ */
37
+ function drawCards() {
38
+ const all = new Map(catalog.items.map(p => [p.key, p]));
39
+ for (const [key, draft] of drafts)
40
+ if (!all.has(key))
41
+ all.set(key, draft);
42
+ grid.replaceChildren(...[...all.values()].map(profile => h('button', { class: `persona-card${selected === profile.key ? ' selected' : ''}`,
43
+ 'aria-pressed': selected === profile.key, onclick: () => { if (saving)
44
+ return; selected = profile.key; drawCards(); drawEditor(); },
45
+ }, h('span', { class: 'persona-avatar', 'aria-hidden': 'true' }, profile.avatar), h('span', { class: 'persona-card-copy' }, h('strong', {}, profile.label), h('small', {}, profile.description)), h('span', { class: 'persona-card-tags' }, profile.key === catalog.settings.defaultKey ? badge('默认', 'green') : !profile.enabled ? badge('已停用') : badge(profile.builtin ? '预设' : '自定义')))));
46
+ }
47
+ /**
48
+ * 取消上一份预览,再依据当前草稿/目录构造编辑器。
49
+ * 表单 input 保存内存草稿;试聊使用当前未保存字段,保存与设默认是独立请求。
50
+ */
51
+ function drawEditor() {
52
+ activePreview?.abort();
53
+ activePreview = null;
54
+ let profile = current();
55
+ const label = h('input', { value: profile.label, required: true, maxlength: 40 });
56
+ const avatar = h('input', { value: profile.avatar, required: true, maxlength: 16 });
57
+ const description = h('textarea', { rows: 5, required: true, maxlength: 3000, value: profile.description, placeholder: '描述你希望助理怎样表达、解释和与你相处…' });
58
+ const verbosity = h('select', { value: profile.verbosity }, ...[['concise', '简短利落'], ['balanced', '详略适中'], ['detailed', '详细解释']].map(([value, text]) => h('option', { value }, text)));
59
+ const emoji = h('select', { value: profile.emoji }, ...[['none', '不使用'], ['light', '偶尔点缀'], ['expressive', '活泼一些']].map(([value, text]) => h('option', { value }, text)));
60
+ const address = h('input', { value: profile.address, maxlength: 40, placeholder: '留空,自然称呼' });
61
+ const examples = [0, 1, 2].map(i => h('input', { value: profile.examples[i] ?? '', maxlength: 400, 'aria-label': `说话示例 ${i + 1}`, placeholder: `示例 ${i + 1}(可选)` }));
62
+ const enabled = h('input', { type: 'checkbox', checked: profile.enabled });
63
+ const status = h('p', { class: 'persona-save-status', role: 'status' }, drafts.has(profile.key) ? '有未保存的修改' : profile.version ? `已保存 · 版本 ${profile.version}` : '框架预设,可直接调整');
64
+ const values = () => ({ label: label.value, avatar: avatar.value, description: description.value,
65
+ verbosity: verbosity.value, emoji: emoji.value, address: address.value,
66
+ examples: examples.map(e => e.value.trim()).filter(Boolean), enabled: enabled.checked });
67
+ const save = h('button', { class: 'primary', type: 'submit' }, '保存性格');
68
+ const form = h('form', { class: 'persona-form', oninput: () => {
69
+ drafts.set(profile.key, { ...profile, ...values() });
70
+ status.textContent = '有未保存的修改';
71
+ }, onsubmit: async (event) => {
72
+ event.preventDefault();
73
+ if (saving)
74
+ return;
75
+ saving = true;
76
+ // 保存期间冻结这份表单,避免请求结束后把后来输入的草稿误标为已保存。
77
+ const controls = [label, avatar, description, verbosity, emoji, address, ...examples, enabled];
78
+ controls.forEach(control => { control.disabled = true; });
79
+ try {
80
+ const result = await run(() => api('POST', `/api/ai/admin/personalities/${encodeURIComponent(profile.key)}`, {
81
+ ...values(), version: profile.version,
82
+ }).catch(error => { status.textContent = error.message; throw error; }), '性格已保存', save);
83
+ if (result) {
84
+ profile = result;
85
+ drafts.delete(result.key);
86
+ catalog.items = [...catalog.items.filter(p => p.key !== result.key), result];
87
+ status.textContent = `已保存 · 版本 ${result.version} · 后续消息生效`;
88
+ drawCards();
89
+ drawDefault();
90
+ }
91
+ }
92
+ finally {
93
+ saving = false;
94
+ controls.forEach(control => { control.disabled = false; });
95
+ }
96
+ } }, h('div', { class: 'persona-section-heading' }, h('span', { class: 'eyebrow' }, 'CHARACTER'), h('h2', {}, '定义它的表达方式')), h('div', { class: 'persona-fields' }, field('性格名称', label), field('头像符号', avatar, '输入一个表情或短符号'), field('回复长度', verbosity), field('表情使用', emoji)), field('性格描述', description), field('如何称呼用户', address), h('div', { class: 'persona-examples' }, h('strong', {}, '说话示例'), h('small', {}, '两三句自然的话,帮助助理找到语气。'), examples), h('label', { class: 'persona-enabled' }, enabled, '允许用户选择此性格'), h('div', { class: 'persona-save-row' }, status, save));
97
+ const output = h('div', { class: 'persona-preview-answer', role: 'log', 'aria-live': 'polite' }, '先打个招呼,听听它会怎么说。');
98
+ const message = h('textarea', { rows: 2, required: true, maxlength: 2000, 'aria-label': '试聊消息', value: '今天事情有点多,你能怎么帮我?' });
99
+ const previewStatus = h('p', { class: 'persona-preview-status', role: 'status' });
100
+ const tryButton = h('button', { class: 'primary', type: 'submit' }, '试聊一下');
101
+ const stop = h('button', { type: 'button', hidden: true, onclick: () => activePreview?.abort() }, '停止');
102
+ const preview = h('form', { class: 'persona-preview', onsubmit: async (event) => {
103
+ event.preventDefault();
104
+ if (activePreview || !form.reportValidity())
105
+ return;
106
+ const controller = new AbortController();
107
+ activePreview = controller;
108
+ tryButton.disabled = true;
109
+ stop.hidden = false;
110
+ output.textContent = '';
111
+ previewStatus.textContent = '正在准备回复…';
112
+ let done = false;
113
+ const abort = () => controller.abort();
114
+ window.addEventListener('hashchange', abort, { once: true });
115
+ window.addEventListener('cage:session-expired', abort, { once: true });
116
+ try {
117
+ await postEvents('/api/ai/admin/personality-preview', { profile: values(), message: message.value }, value => {
118
+ if (value.type === 'delta') {
119
+ output.append(document.createTextNode(value.text));
120
+ previewStatus.textContent = '正在回复…';
121
+ }
122
+ if (value.type === 'done') {
123
+ output.textContent = value.result.text;
124
+ previewStatus.textContent = `试聊完成 · ${(value.result.latencyMs / 1000).toFixed(1)} 秒`;
125
+ done = true;
126
+ }
127
+ if (value.type === 'error')
128
+ throw new Error(value.code === 'RATE_LIMITED' ? '已有试聊正在进行,请稍后重试' : `试聊未完成(${value.code}),请检查模型配置后重试`);
129
+ }, controller.signal);
130
+ if (!done)
131
+ throw new Error('试聊连接中断,请重试');
132
+ }
133
+ catch (error) {
134
+ previewStatus.textContent = controller.signal.aborted ? '已停止,保留已收到的文字' : error.message;
135
+ }
136
+ finally {
137
+ window.removeEventListener('hashchange', abort);
138
+ window.removeEventListener('cage:session-expired', abort);
139
+ if (activePreview === controller)
140
+ activePreview = null;
141
+ tryButton.disabled = false;
142
+ stop.hidden = true;
143
+ }
144
+ } }, h('span', { class: 'eyebrow' }, 'SAY HELLO'), h('h2', {}, '先聊两句'), h('p', { class: 'persona-preview-intro' }, '直接体验当前草稿的语气,不必先保存。这里仅预览表达风格,不处理实际业务。'), output, previewStatus, field('你想说什么', message), h('div', { class: 'persona-preview-actions' }, stop, tryButton));
145
+ editor.replaceChildren(form, preview);
146
+ }
147
+ const create = h('button', { class: 'primary', onclick: () => {
148
+ if (saving)
149
+ return;
150
+ const key = `custom-${crypto.randomUUID()}`;
151
+ drafts.set(key, { key, label: '我的助理', avatar: '✨', description: '', verbosity: 'balanced', address: '', emoji: 'light', examples: [], enabled: true, version: 0, builtin: false, updatedAt: null });
152
+ selected = key;
153
+ drawCards();
154
+ drawEditor();
155
+ editor.scrollIntoView({ behavior: 'smooth', block: 'start' });
156
+ } }, '+ 自定义性格');
157
+ main.append(h('div', { class: 'persona-overview' }, h('div', {}, h('span', { class: 'eyebrow' }, 'A LITTLE CHARACTER'), h('h2', {}, '同样的可靠,多一点个性。'), h('p', {}, '选一种合拍的语气,或创造属于团队的助理。')), create), defaults, grid, editor);
158
+ drawDefault();
159
+ drawCards();
160
+ drawEditor();
161
+ }
@@ -1 +1,5 @@
1
+ /**
2
+ * 角色能力和范围配置模块的公开入口。
3
+ * 应用壳通过这里组合页面,其他模块无需依赖内部表单实现;导出本身不发请求或执行管理操作。
4
+ */
1
5
  export { renderRoles } from './roles-page.js';