@jesonliu/lark-claudecode-bridge 1.3.8 → 1.4.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.
@@ -1,16 +1,18 @@
1
- // ============ 记忆增强(OpenViking 长期记忆,生命周期方案 §6.7/§6.8/§6.9) ============
2
- // 双视图页面(互斥渲染,消除「未安装却看一堆服务状态行」的错位):
3
- // - 未安装向导(!serverFound && !healthy && 非远程模式):引导横幅(⚡ 一键安装 + 五步流水线
4
- // 水平 stepper,模型未保存时安装置灰)+ 模型配置(安装前置)+ 外部服务入口 + 折叠手动指引。
5
- // - 已安装:总开关卡(OFF 时下方整体置灰仅查看)+ 服务状态卡(deriveState 八态精确状态横幅
6
- // + 关键信息网格;启停 / 检查修复 / 刷新按钮就在卡标题右侧——状态与操作同屏)+ 模型配置卡。
1
+ // ============ 记忆增强(OpenViking 长期记忆,生命周期方案 §6.7/§6.8/§6.9 + 最终方案 v1.1) ============
2
+ // 页内双 Tab(最终方案 §六:「服务与配置」|「用户与密钥」,形态同配置页分栏;侧边栏不加一级导航):
3
+ // - Tab1 服务与配置:双视图(未安装向导 / 已安装管理)——总开关 + 服务状态(五步流水线 stepper
4
+ // + 启停/检查修复/刷新)+ 模型配置(P2 双端点:vlm 块与 embedding 块各自独立端点/Key)。
5
+ // P1 保存即生效:模型变更保存后弹「立即生效」确认 → /api/memory/apply 重渲染 ov.conf + 重启服务。
6
+ // - Tab2 用户与密钥:一键初始化 / 用户列表(web / 机器人) / 打开记忆空间(密钥进剪贴板 + 自动
7
+ // 新标签开 1933,Ctrl+V 一次连接) / 复制·重置·删除(重置删除均二次确认,删除先备份)。
8
+ // api_key 模式但 web 用户未建时 Tab 亮红点「待初始化鉴权」(前置化浏览器 401 重定向类问题)。
7
9
  // 配置读写走标准 doc 管道(saveDoc → PUT /api/config,secret 自动回填);测试连接走
8
10
  // /api/memory/test-connection(表单现值,不落盘);安装/启停走 /api/memory/install|start|stop。
9
- // 安装回写会改 config.yaml——完成后走 refresh()(重拉 S.doc 再渲染),避免陈旧 doc 覆盖回写。
10
11
  import { S, $, esc, api, toast, saveDoc, hooks, refresh } from '../core.js';
11
12
  import { confirmDialog } from '../ui.js';
12
13
 
13
- /** 服务商预设(§6.8 填表助手:只预填已知值,全部可见可改) */
14
+ /** 服务商预设(§6.8 填表助手:只预填已知值,全部可见可改)。预设只填主块(vlm);
15
+ * 向量块留空沿用主块——跨厂商组合时单独填向量端点/Key(P2 双端点拆分语义) */
14
16
  const PRESETS = {
15
17
  glm: { label: '智谱 GLM', api_base: 'https://open.bigmodel.cn/api/paas/v4', vlm_model: 'glm-4.7', embedding_model: 'embedding-3', key_hint: 'open.bigmodel.cn 的 API Key' },
16
18
  volcengine: { label: '火山方舟', api_base: 'https://ark.cn-beijing.volces.com/api/v3', vlm_model: '', embedding_model: '', key_hint: '方舟 API Key;模型名填推理接入点 EP ID' },
@@ -22,7 +24,7 @@ const DOC_URL = 'https://github.com/volcengine/OpenViking';
22
24
 
23
25
  /** 探活失败分类 → 下一步行动建议(后端 reason 驱动;not-running 由状态机接管,不在此列) */
24
26
  const REASON_HINT = {
25
- auth: '鉴权失败——服务为 api_key 模式时,检查 ov.conf 的 root key 与本页保存的 API Key 是否一致(一键安装会自动对齐)',
27
+ auth: '鉴权失败——api_key 模式下网页/机器人各需一把用户密钥。点「用户与密钥」Tab 一键初始化,或检查 ov.conf 的 root key 与本页保存的 API Key 是否一致',
26
28
  timeout: '服务无响应——进程可能卡死,可「一键检查 / 修复」重拉',
27
29
  unreachable: '端点不可达——检查服务端点地址与网络(远程模式常见),或服务是否监听该地址',
28
30
  'http-error': '服务返回异常状态码——可「一键检查 / 修复」重拉,或查看服务日志定位',
@@ -78,12 +80,39 @@ const infoCell = (icon, label, valueHtml, span = 1) =>
78
80
  <div style="font-size:13px;display:flex;align-items:center;gap:6px;flex-wrap:wrap;line-height:1.5">${valueHtml}</div>
79
81
  </div>`;
80
82
 
81
- /** 页头说明行(卡片外,两视图共用):一句话定位 + 文档外链 */
83
+ /** 页头说明行(卡片外,双 Tab 共用):一句话定位 + 文档外链 */
82
84
  const pageIntro = `<div style="display:flex;align-items:baseline;gap:14px;flex-wrap:wrap;margin:0 0 14px">
83
85
  <span class="hint" style="font-size:12.5px">OpenViking 长期记忆:每条消息自动召回相关历史、会话归档自动提炼,按 <code>{appId}-{userId}</code> 命名空间隔离(机器人之间互不可见)。</span>
84
86
  <!--<a href="${DOC_URL}" target="_blank" rel="noreferrer" style="font-size:12.5px;flex:0 0 auto;margin-left:auto">查看文档 ↗</a>-->
85
87
  </div>`;
86
88
 
89
+ // ============ 页内双 Tab(最终方案 §六:「服务与配置」|「用户与密钥」) ============
90
+
91
+ /** 当前 Tab(模块级:rerender 保持);用户快照(红点判定 + Tab2 渲染数据) */
92
+ let activeTab = 'service';
93
+ let usersSnapshot = null;
94
+
95
+ /** 切 Tab(重渲染整个记忆页——S.doc/状态已在内存,切换零请求) */
96
+ function switchTab(tab) {
97
+ activeTab = tab;
98
+ hooks.rerender();
99
+ }
100
+
101
+ /** Tab 条:与「权限」页同款标准 .tabs 组件(下划线高亮,main.css 统一样式);待初始化时 Tab2 亮红点 */
102
+ function tabBarHtml(authInitNeeded) {
103
+ const dot = ' <span style="display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--danger);vertical-align:2px" title="待初始化鉴权:服务为 api_key 模式但网页端用户未创建"></span>';
104
+ return `<div class="tabs">
105
+ <button data-memtab="service" class="${activeTab === 'service' ? 'active' : ''}">服务与配置</button>
106
+ <button data-memtab="users" class="${activeTab === 'users' ? 'active' : ''}">用户与密钥${authInitNeeded ? dot : ''}</button>
107
+ </div>`;
108
+ }
109
+
110
+ function bindTabBar() {
111
+ document.querySelectorAll('[data-memtab]').forEach((b) => {
112
+ b.onclick = () => switchTab(b.dataset.memtab);
113
+ });
114
+ }
115
+
87
116
  // ============ 服务状态八态(「一概而论异常」→ 精确状态;探活 reason 全量映射) ============
88
117
 
89
118
  function deriveState(st, svcInstalled) {
@@ -118,34 +147,72 @@ const PLUGIN_SOURCE_LABEL = {
118
147
  managed: 'LCB 自管副本',
119
148
  };
120
149
 
121
- // ============ 模型表单(双视图共用:渲染 + 绑定) ============
150
+ /** 客户端接入状态标注(status.plugin.clientConfState):插件侧启用判定 env 优先、文件兜底——
151
+ * 与 LCB 侧「加载渠道」是两回事(加载渠道 ✓ 不代表启用 ✓)。missing 且记忆开启 = env 通道
152
+ * 启用(LCB 每任务下发 OPENVIKING_MEMORY_ENABLED,无须任何文件) */
153
+ const CLIENT_CONF_LABEL = {
154
+ 'lcb-managed': '客户端配置 LCB 托管',
155
+ 'user-managed': '客户端配置本机自管',
156
+ external: '外部 ov.conf 接入',
157
+ missing: 'env 通道启用',
158
+ };
159
+
160
+ /** 记忆插件 cell 尾部的启用徽标:未启用红字提醒(全绿但 hooks 空转的历史盲区在此现形) */
161
+ function clientConfBadge(p) {
162
+ if (!p) return '';
163
+ if (p.clientEnabled === false) return ' <span class="hint" style="color:var(--warn-tx)">未启用(无 env、无配置文件)</span>';
164
+ const label = p.clientConfState && CLIENT_CONF_LABEL[p.clientConfState];
165
+ return label ? ` <span class="hint" title="插件侧启用判定:env 优先,客户端配置兜底(${esc(p.clientConfPath || '')})">${esc(label)}</span>` : '';
166
+ }
167
+
168
+ // ============ 模型表单(双视图共用:渲染 + 绑定;P2 双端点拆分) ============
122
169
 
123
170
  function modelFormHtml(model, endpoint) {
124
171
  const secretVal = (v) => (v && typeof v === 'object' && v.secretSet) ? v.secretHint : '';
172
+ // 向量块独立凭证展示:own=显式配置(输入框脱敏提示「留空不修改」);未配置=沿用主块(提示现沿用值)
173
+ const embOwn = !!model.embeddingKeyOwn;
174
+ const embKeyHint = secretVal(model.embeddingApiKey);
175
+ const embKeyPlaceholder = embOwn
176
+ ? `已保存 ${embKeyHint || '••••'},留空不修改`
177
+ : embKeyHint ? `当前沿用主 Key ${embKeyHint},填写后独立` : '留空沿用主 Key(可单独指定)';
178
+ const embBasePlaceholder = model.embeddingApiBaseResolved || '留空沿用主端点';
179
+ // 左右两栏面板(提炼主模型 | 向量模型):用「权限」页同款 row-2col 响应式栅格,窄屏自动上下叠放
180
+ const panel = (title, hint, inner) => `
181
+ <div style="border:1px solid var(--border);border-radius:10px;padding:14px 16px 4px;background:var(--field);min-width:0">
182
+ <div style="font-size:13px;font-weight:700;margin-bottom:12px">${title}
183
+ <span class="hint" style="font-weight:400;margin-left:6px">${hint}</span></div>
184
+ ${inner}
185
+ </div>`;
186
+ const fld = (label, inner, hint) => `
187
+ <div style="margin-bottom:12px"><label>${label}</label>${inner}
188
+ ${hint ? `<div class="hint" style="margin-top:3px">${hint}</div>` : ''}</div>`;
125
189
  return `
126
- <div class="grid2">
127
- <div><label>服务商预设(填表助手,值可改)</label>
128
- <select id="memProvider">
190
+ <div class="row-2col" style="gap:16px;align-items:stretch">
191
+ ${panel('提炼主模型配置', 'vlm · 会话归档时提炼记忆', `
192
+ ${fld('服务商预设(填表助手,值可改)', `<select id="memProvider">
129
193
  <option value="">— 选择预设 —</option>
130
194
  ${Object.entries(PRESETS).map(([k, p]) => `<option value="${k}" ${model.provider === k ? 'selected' : ''}>${p.label}</option>`).join('')}
131
- </select>
132
- <div class="hint" style="margin-top:3px">选择后只预填空字段,已填值不覆盖</div></div>
133
- <div><label>API 端点(api_base)</label><input type="text" id="memApiBase" value="${esc(model.apiBase || '')}" placeholder="https://…/v1">
134
- <div class="hint" style="margin-top:3px">OpenAI 协议端点,与机器人对话用的厂商档案相互独立</div></div>
135
- <div><label>API Key ${secretVal(model.apiKey) ? `<span class="hint">已保存 ${esc(secretVal(model.apiKey))},测试连接将使用已保存 Key;更换请输入新值</span>` : ''}</label>
136
- <input type="password" id="memApiKey" value="" placeholder="${esc(PRESETS[model.provider]?.key_hint || 'sk-…(本地端点可留空)')}"></div>
137
- <div><label>提炼模型(vlm,会话归档时用)</label><input type="text" id="memVlm" value="${esc(model.vlmModel || '')}" placeholder="glm-4.7">
138
- <div class="hint" style="margin-top:3px">归档会话时提炼记忆的对话模型,可选更强档位</div></div>
139
- <div><label>向量模型(embedding)</label><input type="text" id="memEmb" value="${esc(model.embeddingModel || '')}" placeholder="embedding-3">
140
- <div class="hint" style="margin-top:3px">文本向量化模型,测试连接会真调一次验证</div></div>
141
- <div><label>服务端点(OpenViking server)</label><input type="text" id="memEndpoint" value="${esc(endpoint || DEFAULT_ENDPOINT)}" placeholder="${DEFAULT_ENDPOINT}">
195
+ </select>`, '选择后只预填空字段,已填值不覆盖(同时预填右侧向量模型名)')}
196
+ ${fld('API 端点(api_base)', `<input type="text" id="memApiBase" value="${esc(model.apiBase || '')}" placeholder="https://…/v1">`, 'OpenAI 协议端点,与机器人对话用的厂商档案相互独立')}
197
+ ${fld(`API Key ${secretVal(model.apiKey) ? `<span class="hint">已保存 ${esc(secretVal(model.apiKey))},留空不修改</span>` : ''}`, `<input type="password" id="memApiKey" value="" placeholder="${esc(PRESETS[model.provider]?.key_hint || 'sk-…(本地端点可留空)')}">`)}
198
+ ${fld('提炼模型(vlm)', `<input type="text" id="memVlm" value="${esc(model.vlmModel || '')}" placeholder="glm-4.7">`, '归档会话时提炼记忆的对话模型,可选更强档位')}
199
+ `)}
200
+ ${panel('向量模型配置', 'embedding · 向量化入库与召回', `
201
+ ${fld('向量模型(embedding)', `<input type="text" id="memEmb" value="${esc(model.embeddingModel || '')}" placeholder="embedding-3">`, '测试连接会真调一次验证')}
202
+ ${fld('向量端点(独立)', `<input type="text" id="memEmbBase" value="${esc(model.embeddingApiBase || '')}" placeholder="${esc(embBasePlaceholder)}">`, '跨厂商组合时单独指定(如主模型=glm + 向量=OpenAI 系);留空沿用主端点')}
203
+ ${fld('向量 Key(独立)', `<input type="password" id="memEmbKey" value="" placeholder="${esc(embKeyPlaceholder)}">`, '与主 Key 相同时留空即可;两块凭证互不影响(留空 = 不修改)')}
204
+ `)}
205
+ </div>
206
+ <div class="row" style="margin-top:12px;align-items:end">
207
+ <div style="flex:1;min-width:260px"><label>服务端点(OpenViking server)</label><input type="text" id="memEndpoint" value="${esc(endpoint || DEFAULT_ENDPOINT)}" placeholder="${DEFAULT_ENDPOINT}">
142
208
  <div class="hint" style="margin-top:3px">一般无须修改:一键安装自动回写;远程模式填外部服务地址</div></div>
143
209
  </div>`;
144
210
  }
145
211
 
146
- /** 绑定模型表单交互(预设联动 / 测试连接双绿 / 保存)。isWizard 只影响保存后的提示文案 */
147
- function bindModelForm(isWizard) {
148
- // 预设联动:只预填空字段(已手填的值不覆盖)
212
+ /** 绑定模型表单交互(预设联动 / 测试连接双绿 / 保存 + P1 立即生效)。isWizard 只影响保存后的
213
+ * 提示文案;canApply(已安装视图)= 保存后检测到模型变更时提供「立即生效」 */
214
+ function bindModelForm(isWizard, canApply) {
215
+ // 预设联动:只预填主块空字段(已手填的值不覆盖;向量块留空沿用——跨厂商组合才手填)
149
216
  $('#memProvider').onchange = (e) => {
150
217
  const p = PRESETS[e.target.value];
151
218
  if (!p) return;
@@ -154,7 +221,7 @@ function bindModelForm(isWizard) {
154
221
  if (!$('#memEmb').value.trim()) $('#memEmb').value = p.embedding_model;
155
222
  $('#memApiKey').placeholder = p.key_hint || 'sk-…';
156
223
  };
157
- // 测试连接:表单现值直测(不落盘)——把 Key/端点/模型名错误挡在开启之前
224
+ // 测试连接:表单现值直测(不落盘)——向量块留空回退主块现值,与渲染 ov.conf 同口径
158
225
  $('#memTest').onclick = async () => {
159
226
  const btn = $('#memTest');
160
227
  btn.disabled = true;
@@ -166,6 +233,8 @@ function bindModelForm(isWizard) {
166
233
  api_key: $('#memApiKey').value.trim(),
167
234
  vlm_model: $('#memVlm').value.trim(),
168
235
  embedding_model: $('#memEmb').value.trim(),
236
+ embedding_api_base: $('#memEmbBase').value.trim(),
237
+ embedding_api_key: $('#memEmbKey').value.trim(),
169
238
  });
170
239
  const line = (name, c) => `${c.ok ? '✅' : '❌'} ${name}:${c.detail}`;
171
240
  $('#memTestResult').innerHTML = r.ok
@@ -178,24 +247,42 @@ function bindModelForm(isWizard) {
178
247
  }
179
248
  btn.disabled = false;
180
249
  };
181
- // 保存模型配置:写入 S.doc.memory(secret 空值保持脱敏对象 → 后端 applySecrets 回填现值)
250
+ // 保存模型配置:写入 S.doc.memory(secret 空值保持脱敏对象 → 后端 applySecrets 按块独立回填)。
251
+ // P1 保存即生效:已安装视图检测到模型实际变更 → 弹「立即生效」确认 → apply 流水线
252
+ //(重渲染 ov.conf + 强制重启受管服务)——消灭「保存后还需手动检查修复」两步坑
182
253
  $('#memSaveModel').onclick = async () => {
183
254
  if (!S.doc.memory || typeof S.doc.memory !== 'object') S.doc.memory = {};
184
255
  const mem = S.doc.memory;
185
- mem.endpoint = $('#memEndpoint').value.trim() || DEFAULT_ENDPOINT;
186
- const keep = (mem.model && typeof mem.model === 'object') ? mem.model : {};
256
+ const prev = (mem.model && typeof mem.model === 'object') ? mem.model : {};
257
+ // 变更检测须在改 S.doc 前取原值(api_key 输入非空视为换 Key)
258
+ const newApiBase = $('#memApiBase').value.trim();
259
+ const newVlm = $('#memVlm').value.trim();
260
+ const newEmb = $('#memEmb').value.trim();
261
+ const newEmbBase = $('#memEmbBase').value.trim();
262
+ const rawKey = $('#memApiKey').value.trim();
263
+ const rawEmbKey = $('#memEmbKey').value.trim();
187
264
  const pv = $('#memProvider').value;
265
+ const modelChanged = newApiBase !== (prev.api_base || '')
266
+ || newVlm !== (prev.vlm_model || '')
267
+ || newEmb !== (prev.embedding_model || '')
268
+ || newEmbBase !== (prev.embedding_api_base || '')
269
+ || (pv || '') !== (prev.provider || '')
270
+ || !!rawKey || !!rawEmbKey;
271
+ mem.endpoint = $('#memEndpoint').value.trim() || DEFAULT_ENDPOINT;
188
272
  const next = {
189
- ...keep,
273
+ ...prev,
190
274
  ...(pv ? { provider: pv } : {}),
191
- api_base: $('#memApiBase').value.trim(),
192
- vlm_model: $('#memVlm').value.trim(),
193
- embedding_model: $('#memEmb').value.trim(),
275
+ api_base: newApiBase,
276
+ vlm_model: newVlm,
277
+ embedding_model: newEmb,
194
278
  };
195
279
  if (!pv) delete next.provider;
280
+ // 向量端点(非凭证):留空 = 清除 → 沿用主端点(与输入框提示一致)
281
+ if (!newEmbBase) delete next.embedding_api_base;
282
+ else next.embedding_api_base = newEmbBase;
196
283
  mem.model = next;
197
- const rawKey = $('#memApiKey').value.trim();
198
- if (rawKey) mem.model.api_key = rawKey; // 空值保留脱敏对象 → applySecrets 回填
284
+ if (rawKey) mem.model.api_key = rawKey; // Key 空值保留脱敏对象 → applySecrets 回填主 Key(留空=不修改)
285
+ if (rawEmbKey) mem.model.embedding_api_key = rawEmbKey; // 向量 Key 按块独立,同款回填
199
286
  if (!await saveDoc()) return;
200
287
  if (isWizard) {
201
288
  // 保存即解锁「⚡ 一键安装」:重渲染让 modelSaved 重新计算(不让用户对着灰按钮困惑到刷新)
@@ -203,14 +290,29 @@ function bindModelForm(isWizard) {
203
290
  hooks.rerender();
204
291
  return;
205
292
  }
293
+ if (modelChanged && canApply) {
294
+ const ok = await confirmDialog({
295
+ title: '使新配置立即生效',
296
+ message: '模型配置已保存。<br><br>记忆服务需<b>重新渲染配置并重启</b>才能应用新模型(运行中的进程不会热加载)。<br>立即生效?',
297
+ confirmText: '立即生效',
298
+ });
299
+ if (ok) {
300
+ await startPipeline('/api/memory/apply', '正在应用新模型配置(重渲染 → 重启服务 → 校验插件),本页实时显示进度')();
301
+ return;
302
+ }
303
+ toast('已保存。稍后点「检查修复」或再次保存时选择「立即生效」即可应用');
304
+ return;
305
+ }
206
306
  toast('记忆模型配置已保存(未开启记忆时仅保存,打开开关后生效)');
207
307
  };
208
308
  }
209
309
 
210
- // ============ 安装流水线(双视图共用:水平 stepper + 轮询 + 进页恢复) ============
310
+ // ============ 安装流水线(双 Tab1 共用:水平 stepper + 轮询 + 进页恢复) ============
211
311
 
212
312
  /** 模块级轮询句柄:rerender 不产生重复 interval(旧闭包继续刷新 DOM,gate 以最新渲染为准) */
213
313
  let installTimer = null;
314
+ /** 已安装视图的门禁(受管 conf 缺失且模型未保存时禁「检查修复」);向导视图 = 模型未保存禁安装 */
315
+ let installGateFn = () => false;
214
316
 
215
317
  function installStepsHtml(steps) {
216
318
  const chip = (s, i) => {
@@ -232,49 +334,58 @@ function installStepsHtml(steps) {
232
334
  return `<div style="display:flex;gap:6px;align-items:stretch;margin-top:12px">${steps.map(chip).join('<span class="hint" style="align-self:center;flex:0 0 auto">→</span>')}</div>`;
233
335
  }
234
336
 
235
- /**
236
- * 绑定安装按钮 + 轮询 + 进页恢复。gate() = 额外禁用条件(向导:模型未保存时禁装——
237
- * 首装生成 ov.conf 需要模型三件套;已安装视图的「检查/修复」不受此限)。
238
- */
239
- function bindInstallActions(gate) {
240
- const renderInstall = (ist) => {
241
- const box = $('#memInstallSteps');
242
- if (box) box.innerHTML = installStepsHtml(ist.steps);
243
- const failed = ist.steps.find((s) => s.status === 'failed');
244
- const hintEl = $('#memInstallHint');
245
- const kindTxt = ist.kind === 'start' ? '启动流水线' : '安装流水线';
246
- if (hintEl) hintEl.innerHTML = ist.running ? `${kindTxt}执行中…`
247
- : failed ? `<span style="color:var(--warn-tx)">「${esc(failed.title)}」失败:${esc(failed.detail || '')}——修正后可重跑(已就绪步骤自动跳过)</span>`
248
- : ist.finishedAt ? (ist.kind === 'start'
249
- ? '<span class="tag ok">启动完成</span> 服务已就绪、记忆插件已接入——LCB 接管的完整记忆链路即刻生效'
250
- : '<span class="tag ok">全部就绪</span> 配置已回写(endpoint / mode=embedded),机器人记忆即刻生效')
251
- : '';
252
- const btn = $('#memInstall');
253
- if (btn) btn.disabled = !!ist.running || gate();
254
- ['memStart', 'memStop', 'memRefresh'].forEach((id) => { const b = $('#' + id); if (b) b.disabled = !!ist.running; });
255
- };
256
- const poll = async () => {
257
- try {
258
- const ist = await api('GET', '/api/memory/install/status');
259
- renderInstall(ist);
260
- if (!ist.running) {
261
- if (installTimer) { clearInterval(installTimer); installTimer = null; }
262
- // 回写改了 config.yaml:refresh 重拉 S.doc 再整页渲染(防止陈旧 doc 在下次保存时覆盖回写)
263
- refresh().catch(() => hooks.rerender());
264
- return;
265
- }
266
- } catch { /* 轮询失败静默重试 */ }
267
- };
268
- const startPipeline = (path, runningMsg) => async () => {
337
+ /** 安装状态渲染(P0:慢启动时「拉起/拉起受管服务」步骤保持 running 并实时显示「已等 N 秒」,
338
+ * 不出现红叉——后端转后台续等,就绪自动续跑后续步骤) */
339
+ function renderInstallState(ist) {
340
+ const box = $('#memInstallSteps');
341
+ if (box) box.innerHTML = installStepsHtml(ist.steps);
342
+ const failed = ist.steps.find((s) => s.status === 'failed');
343
+ const hintEl = $('#memInstallHint');
344
+ const kindTxt = ist.kind === 'start' ? '启动流水线' : ist.steps.some((s) => s.detail && s.detail.includes('重渲染')) ? '应用流水线' : '安装流水线';
345
+ if (hintEl) hintEl.innerHTML = ist.running ? `${kindTxt}执行中…`
346
+ : failed ? `<span style="color:var(--warn-tx)">「${esc(failed.title)}」失败:${esc(failed.detail || '')}——修正后可重跑(已就绪步骤自动跳过)</span>`
347
+ : ist.finishedAt ? (ist.kind === 'start'
348
+ ? '<span class="tag ok">启动完成</span> 服务已就绪、记忆插件已接入——LCB 接管的完整记忆链路即刻生效'
349
+ : '<span class="tag ok">全部就绪</span> 配置已回写(endpoint / mode=embedded),机器人记忆即刻生效')
350
+ : '';
351
+ const btn = $('#memInstall');
352
+ if (btn) btn.disabled = !!ist.running || installGateFn();
353
+ ['memStart', 'memStop', 'memRefresh'].forEach((id) => { const b = $('#' + id); if (b) b.disabled = !!ist.running; });
354
+ }
355
+
356
+ const pollInstall = async () => {
357
+ try {
358
+ const ist = await api('GET', '/api/memory/install/status');
359
+ renderInstallState(ist);
360
+ if (!ist.running) {
361
+ if (installTimer) { clearInterval(installTimer); installTimer = null; }
362
+ // 回写改了 config.yaml:refresh 重拉 S.doc 再整页渲染(防止陈旧 doc 在下次保存时覆盖回写)
363
+ refresh().catch(() => hooks.rerender());
364
+ return;
365
+ }
366
+ } catch { /* 轮询失败静默重试 */ }
367
+ };
368
+
369
+ /** 发起流水线(安装 / 启动 / P1 立即生效共用):POST + toast + 轮询 */
370
+ function startPipeline(path, runningMsg) {
371
+ return async () => {
269
372
  try {
270
373
  await api('POST', path, {});
271
374
  toast(runningMsg);
272
- if (!installTimer) installTimer = setInterval(poll, 2000);
273
- poll();
375
+ if (!installTimer) installTimer = setInterval(pollInstall, 2000);
376
+ pollInstall();
274
377
  } catch (e) { toast(e.message, true); }
275
378
  };
276
- const btn = $('#memInstall');
277
- if (btn) btn.onclick = startPipeline('/api/memory/install', '安装流水线已启动(后台执行,本页实时显示进度)');
379
+ }
380
+
381
+ /**
382
+ * 绑定安装按钮 + 轮询 + 进页恢复。gate() = 额外禁用条件(向导:模型未保存时禁装——
383
+ * 首装生成 ov.conf 需要模型三件套;已安装视图的「检查/修复」不受此限)。
384
+ */
385
+ function bindInstallActions(gate) {
386
+ installGateFn = gate;
387
+ const installBtn = $('#memInstall');
388
+ if (installBtn) installBtn.onclick = startPipeline('/api/memory/install', '安装流水线已启动(后台执行,本页实时显示进度)');
278
389
  // 启动服务:LCB 接管式启动流水线(检查 → 拉起探活 → 接入插件),同款 stepper 实时进度——
279
390
  // 结束即完整接管态,无须再点「检查修复」补插件
280
391
  const startBtn = $('#memStart');
@@ -282,8 +393,8 @@ function bindInstallActions(gate) {
282
393
  // 进页恢复(切页回来 / 安装刚结束):在跑则继续轮询,刚结束则展示结果
283
394
  api('GET', '/api/memory/install/status').then((ist) => {
284
395
  if (ist.running || ist.finishedAt) {
285
- renderInstall(ist);
286
- if (ist.running && !installTimer) installTimer = setInterval(poll, 2000);
396
+ renderInstallState(ist);
397
+ if (ist.running && !installTimer) installTimer = setInterval(pollInstall, 2000);
287
398
  }
288
399
  }).catch(() => {});
289
400
  }
@@ -299,6 +410,9 @@ export async function renderMemory(el) {
299
410
  el.innerHTML = `<div class="card"><h3>记忆增强</h3><div class="hint">状态读取失败:${esc(e.message)}</div></div>`;
300
411
  return;
301
412
  }
413
+ // 用户快照(Tab2 数据 + 红点判定);失败按 null(Tab2 内可重试),不阻塞 Tab1
414
+ try { usersSnapshot = await api('GET', '/api/memory/users'); } catch { usersSnapshot = null; }
415
+
302
416
  const m = st.memory ?? { enabled: false, mode: 'remote', endpoint: DEFAULT_ENDPOINT };
303
417
  const model = m.model ?? {};
304
418
  const inst = st.install ?? {};
@@ -307,21 +421,21 @@ export async function renderMemory(el) {
307
421
  // 远程模式(endpoint 指向非本机):不引导本地安装,进已安装视图管理外部服务
308
422
  const remoteConfigured = m.mode === 'remote' && !!m.endpoint && !isLocalHost(m.endpoint);
309
423
  const showWizard = !svcInstalled && !svcOk && !remoteConfigured;
310
- const state = deriveState(st, svcInstalled);
311
- const meta = STATE_META[state] ?? STATE_META.error;
312
- // 首装模型三件套就绪判定(安装流水线生成 ov.conf 的前置)
313
- const modelSaved = !!(model.apiBase && model.vlmModel && model.embeddingModel);
314
-
315
- if (showWizard) return renderWizard(el, { m, model, modelSaved });
316
- return renderInstalled(el, { st, m, model, inst, state, meta, remoteConfigured });
424
+ const authInitNeeded = !!usersSnapshot?.authInitNeeded;
425
+
426
+ el.innerHTML = `${pageIntro}${tabBarHtml(authInitNeeded)}<div id="memTabBody"></div>`;
427
+ bindTabBar();
428
+ const body = $('#memTabBody');
429
+ if (activeTab === 'users') return renderUsersTab(body, { st, m });
430
+ if (showWizard) return renderWizard(body, { m, model, modelSaved: !!(model.apiBase && model.vlmModel && model.embeddingModel) });
431
+ return renderInstalled(body, { st, m, model, inst, state: deriveState(st, svcInstalled), meta: STATE_META[deriveState(st, svcInstalled)] ?? STATE_META.error, remoteConfigured, authInitNeeded });
317
432
  }
318
433
 
319
- // ============ 未安装向导 ============
434
+ // ============ Tab1 · 未安装向导 ============
320
435
 
321
436
  function renderWizard(el, { m, model, modelSaved }) {
322
437
  const epVal = (m.endpoint && m.endpoint !== DEFAULT_ENDPOINT) ? m.endpoint : '';
323
438
  el.innerHTML = `
324
- ${pageIntro}
325
439
  <div class="card">
326
440
  <div style="display:flex;align-items:center;gap:10px">
327
441
  <h3 style="margin:0">开始使用记忆增强</h3>
@@ -330,7 +444,7 @@ function renderWizard(el, { m, model, modelSaved }) {
330
444
  </div>
331
445
  <div style="background:var(--primary-weak);border-radius:10px;padding:14px 16px">
332
446
  <b>未安装 OpenViking 服务</b>
333
- <div class="hint" style="margin-top:4px;line-height:1.7">点右上「⚡ 一键安装」自动完成安装与配置(检测/安装 uv 运行时 → 安装 OpenViking 服务 → 生成配置 → 拉起服务 → 接入记忆插件)。全程无须终端;已就绪部件自动跳过,可重复执行。</div>
447
+ <div class="hint" style="margin-top:4px;line-height:1.7">点右上「⚡ 一键安装」自动完成安装与配置(检测/安装 uv 运行时 → 安装 OpenViking 服务 → 生成配置 → 拉起服务 → 接入记忆插件)。全程无须终端;已就绪部件自动跳过,可重复执行。安装完成后到「用户与密钥」Tab 一键初始化网页/机器人密钥。</div>
334
448
  ${modelSaved ? '' : `<div class="hint" style="margin-top:8px;color:var(--warn-tx)">⚠ 请先在下方「模型配置」填表并保存——安装需要用模型三件套(API 端点 / 提炼模型 / 向量模型)生成服务配置。</div>`}
335
449
  </div>
336
450
  <div id="memInstallSteps">${installStepsHtml(IDLE_STEPS)}</div>
@@ -344,7 +458,7 @@ function renderWizard(el, { m, model, modelSaved }) {
344
458
  <button class="btn sm" id="memTest">测试连接</button>
345
459
  <button class="btn sm primary" id="memSaveModel">保存配置</button>
346
460
  </div>
347
- <div class="desc" style="margin-top:6px">记忆的<b>提炼(vlm)与向量化(embedding)</b>走 OpenAI 协议端点,与机器人对话用的厂商档案相互独立。建议先 [测试连接] 双绿,再保存并一键安装。</div>
461
+ <div class="desc" style="margin-top:6px">记忆的<b>提炼(vlm)与向量化(embedding)</b>走 OpenAI 协议端点,两块可各自独立端点与 Key(跨厂商组合),默认向量块沿用主块。建议先 [测试连接] 双绿,再保存并一键安装。</div>
348
462
  ${modelFormHtml(model, m.endpoint)}
349
463
  <div id="memTestResult" class="hint" style="margin-top:8px"></div>
350
464
  <pre class="report" id="memTestReport" style="display:none;margin-top:6px"></pre>
@@ -370,16 +484,16 @@ function renderWizard(el, { m, model, modelSaved }) {
370
484
  claude marketplace add openviking
371
485
  claude plugin install openviking-memory@openviking
372
486
 
373
- ③ 回到本页:保存模型配置 → 一键安装(或直接开启)</pre>
487
+ ③ 回到本页:保存模型配置 → 一键安装 → 「用户与密钥」一键初始化</pre>
374
488
  <div class="hint" style="margin-top:8px;line-height:1.9">常见问题:
375
- · 启动失败 / 服务异常 → 点「一键检查 / 修复」重跑流水线(已就绪步骤自动跳过,不覆盖现有数据)
376
- · 鉴权失败 → ov.conf 的 root_api_key 与本页保存的 API Key 需一致(一键安装会自动对齐)
377
- · 支持哪些模型 → 任意 OpenAI 协议端点(智谱 / 方舟 / 中转 / Ollama 本地)
489
+ · 启动失败 / 服务异常 → 点「一键检查 / 修复」重跑流水线(已就绪步骤自动跳过,不覆盖现有数据);首装冷启动可能超过 1 分钟,流水线会转为后台等待并实时显示秒数,无需反复重试
490
+ · 鉴权失败 → 到「用户与密钥」Tab 一键初始化(api_key 模式下网页/机器人各需一把用户密钥)
491
+ · 支持哪些模型 → 任意 OpenAI 协议端点(智谱 / 方舟 / 中转 / Ollama 本地);提炼与向量可分别指定
378
492
  · 修改端口 → 模型配置里的「服务端点」;一键安装会自动避开占用端口</div>
379
493
  </details>
380
494
  </div>`;
381
495
 
382
- bindModelForm(true);
496
+ bindModelForm(true, false);
383
497
  bindInstallActions(() => !modelSaved);
384
498
 
385
499
  // 外部服务入口:保存远程 endpoint → remoteConfigured → 切到已安装(外部)视图
@@ -392,9 +506,9 @@ function renderWizard(el, { m, model, modelSaved }) {
392
506
  };
393
507
  }
394
508
 
395
- // ============ 已安装视图 ============
509
+ // ============ Tab1 · 已安装视图 ============
396
510
 
397
- function renderInstalled(el, { st, m, model, inst, state, meta, remoteConfigured }) {
511
+ function renderInstalled(el, { st, m, model, inst, state, meta, remoteConfigured, authInitNeeded }) {
398
512
  const ver = st.health?.version;
399
513
  const endpoint = m.endpoint || DEFAULT_ENDPOINT;
400
514
  const authNote = st.authNote;
@@ -404,8 +518,11 @@ function renderInstalled(el, { st, m, model, inst, state, meta, remoteConfigured
404
518
  const statusSub = state === 'running' ? 'OpenViking 服务运行正常,记忆召回与归档已就绪'
405
519
  : state === 'stopped' ? '服务已安装,进程未运行——点右上「▶ 启动服务」一键拉起(自动检查配置 → 拉起探活 → 接入插件)'
406
520
  : '';
521
+ // 鉴权失败态优先引导「用户与密钥」Tab(api_key 模式下 401 的正确解法是初始化用户密钥,而非改模型配置)
407
522
  const statusDetail = state === 'running' || state === 'stopped' ? ''
408
523
  : `${esc(st.health?.detail || '探活失败')}${REASON_HINT[st.health?.reason] ? `<br>➜ ${REASON_HINT[st.health.reason]}` : ''}`;
524
+ const goUsersBtn = (state === 'auth' || authInitNeeded)
525
+ ? '<button class="btn sm" id="memGoUsers" style="margin-top:8px">去 用户与密钥 →</button>' : '';
409
526
 
410
527
  // 信息网格:只渲染「有值」的列(隐藏字段不占列位);运行方式用 mode 推导(embedded=LCB 受管安装)
411
528
  // 网格固定 3 列(一行 3 项;auto-fit 会在宽屏挤成 6 列);地址列不跨列,长 URL 列内省略号截断防撑破;插件列值整体 nowrap 防拆行
@@ -417,15 +534,15 @@ function renderInstalled(el, { st, m, model, inst, state, meta, remoteConfigured
417
534
  : `外部自管 <span class="hint">非 LCB 拉起</span>`),
418
535
  ...(inst.managedSince ? [infoCell('clock', '运行时间', esc(fmtSince(inst.managedSince)))] : []),
419
536
  ...(st.health?.authMode ? [infoCell('server', '鉴权模式', `${esc(st.health.authMode)}${authNote ? ` <span class="hint" title="${esc(authNote)}">ⓘ</span>` : ''}`)] : []),
420
- infoCell('spark', '记忆插件', st.plugin?.found
537
+ infoCell('spark', '记忆插件', (st.plugin?.found
421
538
  ? `<span style="white-space:nowrap;display:inline-flex;align-items:center;gap:6px"><span style="color:var(--ok-tx);font-weight:600">✓</span><code>${esc(st.plugin.name)}</code>${st.plugin.version ? `<span class="hint">v${esc(st.plugin.version)}</span>` : ''}${st.plugin.source && PLUGIN_SOURCE_LABEL[st.plugin.source] ? `<span class="hint" title="实际加载渠道:与机器人任务装配一致">${esc(PLUGIN_SOURCE_LABEL[st.plugin.source])}</span>` : ''}</span>`
422
- : '<span style="color:var(--warn-tx);font-weight:600">○ 未安装</span><span class="hint">「检查修复」可补装</span>'),
539
+ : '<span style="color:var(--warn-tx);font-weight:600">○ 未安装</span><span class="hint">「检查修复」可补装</span>') + clientConfBadge(st.plugin)),
423
540
  ];
424
541
 
425
542
  // 卡标题右按钮组:主行动随状态切换(启动/停止),刷新与检查修复常驻
426
543
  // 检查/修复门禁与向导一键安装同口径:受管 conf 缺失(= 重跑会走首装生成 ov.conf)且模型
427
544
  // 三件套未保存时禁用——把「缺模型」挡在点击之前,而不是让用户在「生成服务配置」步骤撞失败
428
- const needModelFirst = !inst.confFound && !modelSaved;
545
+ const needModelFirst = !inst.confFound && !modelSavedCheck(model);
429
546
  const repairBtn = `<button class="btn sm" id="memInstall" title="${needModelFirst
430
547
  ? '受管服务配置缺失,生成配置需要模型三件套——请先在下方「模型配置」卡填写并保存'
431
548
  : '重跑安装流水线(已就绪部件自动跳过)'}">检查修复</button>`;
@@ -437,7 +554,6 @@ function renderInstalled(el, { st, m, model, inst, state, meta, remoteConfigured
437
554
  : `${repairBtn} ${refreshBtn}`;
438
555
 
439
556
  el.innerHTML = `
440
- ${pageIntro}
441
557
  <div class="card">
442
558
  <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
443
559
  <label class="switch"><input type="checkbox" id="memEnabled" ${m.enabled ? 'checked' : ''}><span class="track"></span></label>
@@ -461,8 +577,10 @@ function renderInstalled(el, { st, m, model, inst, state, meta, remoteConfigured
461
577
  ${statusSub ? `<span class="hint">— ${statusSub}</span>` : ''}
462
578
  </div>
463
579
  ${statusDetail ? `<div class="hint" style="margin-top:9px;line-height:1.8">${statusDetail}</div>` : ''}
580
+ ${goUsersBtn}
464
581
  <div style="display:grid;grid-template-columns:repeat(3,minmax(0,1fr));grid-auto-flow:dense;gap:16px 22px;margin-top:16px">${cells.join('')}</div>
465
582
  </div>
583
+ ${authInitNeeded ? `<div class="hint" style="margin-top:10px;padding:9px 14px;background:var(--warn-bg);color:var(--warn-tx);border-radius:10px">🔑 待初始化鉴权:服务为 api_key 模式但网页端用户尚未创建——浏览器打开记忆空间会 401。到「用户与密钥」Tab 点「一键初始化」即可。</div>` : ''}
466
584
  <div id="memInstallSteps"></div>
467
585
  <div id="memInstallHint" class="hint" style="margin-top:8px"></div>
468
586
  </div>
@@ -473,7 +591,7 @@ function renderInstalled(el, { st, m, model, inst, state, meta, remoteConfigured
473
591
  <button class="btn sm" id="memTest">测试连接</button>
474
592
  <button class="btn sm primary" id="memSaveModel">保存配置</button>
475
593
  </div>
476
- <div class="desc" style="margin-top:6px">记忆的<b>提炼(vlm)与向量化(embedding)</b>走 OpenAI 协议端点,与机器人对话用的厂商档案相互独立(改档案不影响记忆)。建议变更后先 [测试连接] 双绿再保存。</div>
594
+ <div class="desc" style="margin-top:6px">记忆的<b>提炼(vlm)与向量化(embedding)</b>走 OpenAI 协议端点,两块可各自独立端点与 Key(跨厂商组合),默认向量块沿用主块。变更后先 [测试连接] 双绿再保存;保存后可「立即生效」。</div>
477
595
  ${modelFormHtml(model, m.endpoint)}
478
596
  <div id="memTestResult" class="hint" style="margin-top:8px"></div>
479
597
  <pre class="report" id="memTestReport" style="display:none;margin-top:6px"></pre>
@@ -489,6 +607,7 @@ function renderInstalled(el, { st, m, model, inst, state, meta, remoteConfigured
489
607
  blockers.push(`OpenViking 服务不可达:${esc(st.health?.detail || '未知原因')}${state === 'stopped' ? '——开启后可在「服务状态」卡点「▶ 启动服务」拉起' : ''}`);
490
608
  }
491
609
  if (!st.plugin?.found) blockers.push('记忆插件未安装——开启后可在「服务状态」卡点「一键检查 / 修复」补装');
610
+ if (authInitNeeded) blockers.push('网页端用户未创建(待初始化鉴权)——开启后到「用户与密钥」Tab 一键初始化');
492
611
  const warnTxt = blockers.length
493
612
  ? `开启前请注意:<br>· ${blockers.join('<br>· ')}<br><br>仍可开启(保存配置,日志会在每条消息给出指引),确认继续?`
494
613
  : '开启后每个机器人的每条消息将自动:注入记忆召回(hooks)+ 按命名空间隔离读写。确认开启?';
@@ -521,7 +640,9 @@ function renderInstalled(el, { st, m, model, inst, state, meta, remoteConfigured
521
640
  catch { toast('复制失败(浏览器未授权剪贴板)', true); }
522
641
  };
523
642
 
524
- // 启动服务已统一走 bindInstallActions 的启动流水线(检查 → 拉起探活 → 接入插件,stepper 实时进度)
643
+ // 去「用户与密钥」Tab(入口联动:Tab1 只留跳转,不承载密钥操作——权限隔离)
644
+ const goUsers = $('#memGoUsers');
645
+ if (goUsers) goUsers.onclick = () => switchTab('users');
525
646
 
526
647
  // 停止受管服务(危险确认;外部自管服务不显示此按钮)
527
648
  const stopBtn = $('#memStop');
@@ -530,11 +651,236 @@ function renderInstalled(el, { st, m, model, inst, state, meta, remoteConfigured
530
651
  try { await api('POST', '/api/memory/stop', {}); toast('已停止'); hooks.rerender(); } catch (e) { toast(e.message, true); }
531
652
  };
532
653
 
533
- bindModelForm(false);
654
+ bindModelForm(false, true);
534
655
  // 已安装视图:检查/修复门禁——受管 conf 已存在时放行(沿用现值重跑);缺失时与向导同口径要模型三件套
535
656
  bindInstallActions(() => needModelFirst);
536
657
  }
537
658
 
659
+ /** 模型三件套就绪判定(status 数据源:apiBase/vlmModel/embeddingModel) */
660
+ const modelSavedCheck = (model) => !!(model && model.apiBase && model.vlmModel && model.embeddingModel);
661
+
662
+ // ============ Tab2 · 用户与密钥(最终方案 §六) ============
663
+
664
+ /** 体量人性化 */
665
+ const fmtSize = (b) => (b === undefined || b === null) ? '—'
666
+ : b < 1024 ? `${b} B`
667
+ : b < 1048576 ? `${(b / 1024).toFixed(0)} KB`
668
+ : `${(b / 1048576).toFixed(1)} MB`;
669
+
670
+ /** 最近活跃人性化(目录最大 mtime) */
671
+ const fmtActive = (iso) => {
672
+ const t = Date.parse(iso);
673
+ if (!Number.isFinite(t)) return '—';
674
+ const diff = Date.now() - t;
675
+ if (diff < 86400000) return '今天';
676
+ const days = Math.floor(diff / 86400000);
677
+ if (days < 30) return `${days} 天前`;
678
+ return new Date(t).toLocaleDateString();
679
+ };
680
+
681
+ /** 用户行(网页管理 / 机器人 / 其他;升级 bot×人 后机器人组可展开聊天者维度) */
682
+ function userRowHtml(u) {
683
+ const displayName = u.appName || (u.kind === 'web' ? '网页管理' : u.userId);
684
+ const keyState = u.keyAvailable
685
+ ? `密钥 <span title="脱敏显示,完整密钥经「复制 key / 打开记忆空间」按需放行">${esc(u.keyHint || '••••')}</span>`
686
+ : '<span style="color:var(--warn-tx)">密钥未在库</span>';
687
+ const meta = [fmtSize(u.sizeBytes), u.lastActive ? `活跃于 ${fmtActive(u.lastActive)}` : '暂无记忆数据'].filter(Boolean).join(' · ');
688
+ return `<div style="display:flex;align-items:center;gap:12px;padding:11px 0;border-bottom:1px solid var(--border);flex-wrap:wrap">
689
+ <span style="width:8px;height:8px;border-radius:50%;background:var(--ok-tx);flex:0 0 auto"></span>
690
+ <div style="min-width:180px;flex:1">
691
+ <div style="font-size:13.5px;font-weight:600;display:flex;align-items:center;gap:8px;flex-wrap:wrap">${esc(displayName)}
692
+ <span class="hint"><code style="font-size:11px">${esc(u.userId)}</code></span>
693
+ <span class="hint">${keyState}</span>
694
+ </div>
695
+ <div class="hint" style="margin-top:3px">${meta}</div>
696
+ </div>
697
+ <button class="btn sm primary" data-open="${esc(u.userId)}" title="密钥进剪贴板 + 自动打开记忆空间(文件系统页),粘贴一次即可连接">打开记忆空间</button>
698
+ <button class="btn sm" data-copy="${esc(u.userId)}" title="复制密钥(脱敏显示,此操作会留痕)">⧉ 复制key</button>
699
+ <button class="btn sm" data-reset="${esc(u.userId)}" title="重签密钥:旧密钥立即失效,所有使用方需同步更新">重置</button>
700
+ <button class="btn sm danger" data-del="${esc(u.userId)}" title="删除用户与记忆(先自动备份本地数据)">删除</button>
701
+ </div>`;
702
+ }
703
+
704
+ /** Tab2 主渲染:一键初始化 + 用户列表(网页管理 / 机器人分组)+ 粒度向导 */
705
+ function renderUsersTab(el, { st, m }) {
706
+ const u = usersSnapshot;
707
+ const healthy = !!st.health?.healthy;
708
+ const apiKeyMode = st.health?.authMode === 'api_key';
709
+ const gran = (S.doc.memory && typeof S.doc.memory === 'object' && S.doc.memory.granularity) || 'bot';
710
+
711
+ let bodyHtml;
712
+ if (!u) {
713
+ bodyHtml = `<div class="card"><div class="hint">用户列表加载失败(服务未运行或请求异常)。</div>
714
+ <button class="btn sm" id="memUsersRetry" style="margin-top:10px">重试</button></div>`;
715
+ } else if (!healthy) {
716
+ bodyHtml = `<div class="card">
717
+ <div style="background:var(--warn-bg);border-radius:10px;padding:14px 16px">
718
+ <b>服务未运行</b>
719
+ <div class="hint" style="margin-top:4px;line-height:1.7">${esc(u.error || st.health?.detail || 'OpenViking 服务不可达')}——请先到「服务与配置」Tab 拉起服务,再回到本页管理用户与密钥。</div>
720
+ </div></div>`;
721
+ } else if (!apiKeyMode) {
722
+ bodyHtml = `<div class="card">
723
+ <div style="background:var(--off-bg);border-radius:10px;padding:14px 16px">
724
+ <b>当前为 ${esc(u.authMode || 'dev')} 模式:无须用户密钥</b>
725
+ <div class="hint" style="margin-top:4px;line-height:1.7">本机回环免鉴权,机器人身份经命名空间(<code>{appId}-{userId}</code>)隔离——浏览器打开记忆空间可直接访问。部署走向多人共享 / 出现敏感内容隔离需求时,配置 root_api_key 切换到 api_key 模式后,回本页一键初始化网页与机器人密钥。</div>
726
+ </div>
727
+ ${granularityCardHtml(gran)}</div>`;
728
+ } else {
729
+ const webUsers = u.users.filter((x) => x.kind === 'web');
730
+ const botUsers = u.users.filter((x) => x.kind === 'bot');
731
+ const otherUsers = u.users.filter((x) => x.kind === 'other');
732
+ // 聊天者归属映射(前端兜底,与后端同口径):历史复合 ID({appId}-{ou_xxx})按前缀归属到
733
+ // 对应飞书应用——后端未重启(旧构建无映射)时本层照样生效,刷新即见
734
+ const bots = st.bots || [];
735
+ otherUsers.forEach((x) => {
736
+ if (x.appName) return;
737
+ const hit = bots.find((b) => x.userId.startsWith(`${b.appId}-`));
738
+ if (hit) x.appName = `${hit.name} · 聊天者`;
739
+ });
740
+ const group = (title, rows, hint) => rows.length === 0 ? '' : `
741
+ <div style="margin-top:14px">
742
+ <div style="font-size:12.5px;font-weight:700;color:var(--muted);letter-spacing:.4px">${esc(title)} <span class="hint" style="font-weight:400">${hint || ''}</span></div>
743
+ <div>${rows.map(userRowHtml).join('')}</div>
744
+ </div>`;
745
+ bodyHtml = `<div class="card">
746
+ <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
747
+ <h3 style="margin:0">用户与密钥</h3>
748
+ <span class="hint">${esc(String(u.users.length))} 个用户 · 合计 ${fmtSize(u.totalBytes)} 记忆数据</span>
749
+ <span style="flex:1"></span>
750
+ ${u.authInitNeeded ? '<button class="btn sm primary" id="memUsersInit">⚡ 一键初始化</button>' : '<button class="btn sm" id="memUsersInit">⚡ 一键初始化</button>'}
751
+ <button class="btn sm" id="memUsersRefresh">↻ 刷新</button>
752
+ </div>
753
+ <div class="desc" style="margin-top:6px">api_key 模式:网页与每个机器人各持一把独立密钥(密钥落盘已加密、页面只脱敏显示,查看行为留痕审计)。「打开记忆空间」= 密钥进剪贴板 + 自动新标签打开文件系统页,粘贴一次即可连接。</div>
754
+ <div class="hint" style="margin-top:6px">ⓘ 1933 网页右上角的身份切换显示的是技术 ID(OpenViking 暂不支持显示名)——哪个 ID 对应哪台机器人,以本页对照为准。</div>
755
+ ${u.authInitNeeded ? `<div class="hint" style="margin-top:10px;padding:9px 14px;background:var(--warn-bg);color:var(--warn-tx);border-radius:10px">🔑 待初始化鉴权:网页端用户(web)尚未创建——浏览器打开记忆空间会 401。点右上「⚡ 一键初始化」自动创建网页 + 各机器人用户并签发密钥。</div>` : ''}
756
+ ${u.users.length === 0 && !u.authInitNeeded ? '<div class="hint" style="margin-top:12px">暂无用户——点「⚡ 一键初始化」创建。</div>' : ''}
757
+ ${group('网页管理', webUsers, '浏览器打开记忆空间用')}
758
+ ${group('机器人', botUsers, '每个 bot 一把独立密钥(升级 bot×人 后此处可展开聊天者维度)')}
759
+ ${group('聊天者与其他', otherUsers, '历史身份({appId}-{ou_xxx})已自动归属到对应机器人')}
760
+ ${granularityCardHtml(gran)}
761
+ </div>`;
762
+ }
763
+ el.innerHTML = bodyHtml;
764
+ bindUsersTabActions(el, { m });
765
+ }
766
+
767
+ /** 粒度向导卡(方案 §六:默认 bot 级;「升级到 bot×人」开关 = 增量升级意向,不动既有 key 与记忆) */
768
+ const granularityCardHtml = (gran) => `
769
+ <div class="card" style="margin-top:14px">
770
+ <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
771
+ <b style="font-size:13.5px">记忆粒度</b>
772
+ <span class="hint">${gran === 'bot-user' ? '已选择:bot × 人(每位聊天者独立空间)' : '当前:每 bot 一把密钥(默认,单机自用零损失)'}</span>
773
+ <span style="flex:1"></span>
774
+ <label style="display:inline-flex;align-items:center;gap:8px;cursor:pointer">
775
+ <span class="hint">升级到 bot×人</span>
776
+ <label class="switch"><input type="checkbox" id="memGran" ${gran === 'bot-user' ? 'checked' : ''}><span class="track"></span></label>
777
+ </label>
778
+ </div>
779
+ <div class="hint" style="margin-top:8px;line-height:1.8">升级属<b>增量演进</b>:既有密钥与记忆不受影响、会话不中断,仅为新聊天者追加独立签发。建议触发条件(任一出现即升级):同一 bot 服务 2 人以上且内容敏感 / 出现「记忆是谁写的」追溯需求 / 部署走向团队共享。</div>
780
+ </div>`;
781
+
782
+ function bindUsersTabActions(el, { m }) {
783
+ const endpoint = (m.endpoint || DEFAULT_ENDPOINT).replace(/\/+$/, '');
784
+
785
+ const reload = async () => {
786
+ try { usersSnapshot = await api('GET', '/api/memory/users'); } catch { usersSnapshot = null; }
787
+ hooks.rerender();
788
+ };
789
+
790
+ const retry = $('#memUsersRetry');
791
+ if (retry) retry.onclick = reload;
792
+
793
+ const refreshBtn = $('#memUsersRefresh');
794
+ if (refreshBtn) refreshBtn.onclick = reload;
795
+
796
+ const initBtn = $('#memUsersInit');
797
+ if (initBtn) initBtn.onclick = async () => {
798
+ initBtn.disabled = true;
799
+ initBtn.textContent = '初始化中…';
800
+ try {
801
+ const r = await api('POST', '/api/memory/users/init', {});
802
+ if (r.ok) toast(r.detail || '初始化完成');
803
+ else toast((r.errors && r.errors[0]) || r.detail || '初始化失败', true);
804
+ } catch (e) { toast(e.message, true); }
805
+ await reload();
806
+ };
807
+
808
+ // 打开记忆空间(最终方案 §三 决策 5):密钥进剪贴板 → 自动新标签开 Web Studio 文件系统页
809
+ //(深链 /studio/filesystem 直落记忆目录树,避开默认的 VikingBot 标签——那是 OpenViking
810
+ // 自带的 Agent 对话功能,需 --with-bot 启动,与记忆浏览无关)。未连接的浏览器会被
811
+ // Studio 引导到连接设置页,粘贴一次即可。
812
+ const openSpace = async (uid) => {
813
+ try {
814
+ const r = await api('POST', '/api/memory/users/reveal', { user_id: uid });
815
+ if (!r.ok || !r.key) return toast(r.error || '密钥获取失败', true);
816
+ try { await navigator.clipboard.writeText(r.key); } catch { return toast('密钥已放行但剪贴板不可用——请在浏览器地址栏手动粘贴', true); }
817
+ window.open(`${endpoint}/studio/filesystem`, '_blank', 'noopener');
818
+ toast('密钥已复制到剪贴板——在打开的页面粘贴一次(Ctrl+V)即可连接');
819
+ } catch (e) { toast(e.message, true); }
820
+ };
821
+
822
+ const copyKey = async (uid) => {
823
+ try {
824
+ const r = await api('POST', '/api/memory/users/reveal', { user_id: uid });
825
+ if (!r.ok || !r.key) return toast(r.error || '密钥获取失败', true);
826
+ await navigator.clipboard.writeText(r.key);
827
+ toast(`已复制 ${uid} 的密钥(查看行为已留痕)`);
828
+ } catch (e) { toast(e.message, true); }
829
+ };
830
+
831
+ const resetKey = async (uid) => {
832
+ if (!(await confirmDialog({
833
+ title: '重置密钥', danger: true,
834
+ message: `确定重置 <b>${esc(uid)}</b> 的密钥?<br><br>旧密钥将<b>立即失效</b>——所有使用此密钥的一方(浏览器连接、已打开的记忆空间、机器人会话)需同步更新并重启会话。`,
835
+ confirmText: '重置',
836
+ }))) return;
837
+ try {
838
+ const r = await api('POST', '/api/memory/users/reset', { user_id: uid });
839
+ if (!r.ok || !r.key) return toast(r.error || '重置失败', true);
840
+ try { await navigator.clipboard.writeText(r.key); toast('新密钥已生成并复制到剪贴板(旧密钥已失效)'); }
841
+ catch { toast('新密钥已生成(旧密钥已失效;剪贴板不可用,可稍后复制)'); }
842
+ } catch (e) { toast(e.message, true); }
843
+ };
844
+
845
+ const delUser = async (uid) => {
846
+ if (!(await confirmDialog({
847
+ title: '删除用户', danger: true,
848
+ message: `确定删除 <b>${esc(uid)}</b>?<br><br>该用户的服务端数据将被异步清理;删除前会自动把本地记忆目录打包备份到 <code>openviking/backups/</code>(备份失败会中止删除)。`,
849
+ confirmText: '删除',
850
+ }))) return;
851
+ try {
852
+ const r = await api('POST', '/api/memory/users/delete', { user_id: uid });
853
+ toast(r.ok ? (r.detail || '已删除') : (r.error || r.detail || '删除失败'), !r.ok);
854
+ } catch (e) { toast(e.message, true); }
855
+ await reload();
856
+ };
857
+
858
+ el.addEventListener('click', (e) => {
859
+ const t = e.target.closest('[data-open],[data-copy],[data-reset],[data-del]');
860
+ if (!t) return;
861
+ if (t.dataset.open) return void openSpace(t.dataset.open);
862
+ if (t.dataset.copy) return void copyKey(t.dataset.copy);
863
+ if (t.dataset.reset) return void resetKey(t.dataset.reset);
864
+ if (t.dataset.del) return void delUser(t.dataset.del);
865
+ });
866
+
867
+ const granEl = $('#memGran');
868
+ if (granEl) granEl.onchange = async (e) => {
869
+ const next = e.target.checked ? 'bot-user' : 'bot';
870
+ if (!S.doc.memory || typeof S.doc.memory !== 'object') S.doc.memory = {};
871
+ const old = S.doc.memory.granularity;
872
+ S.doc.memory.granularity = next;
873
+ if (await saveDoc()) {
874
+ toast(next === 'bot-user'
875
+ ? '已记录升级意向:bot×人(增量生效——既有密钥与记忆不动,新聊天者独立签发)'
876
+ : '已回到每 bot 一把密钥');
877
+ } else {
878
+ S.doc.memory.granularity = old;
879
+ e.target.checked = !e.target.checked;
880
+ }
881
+ };
882
+ }
883
+
538
884
  export const page = {
539
885
  id: 'memory',
540
886
  title: '记忆增强',