@geminixiang/mikan 1.0.0-beta.71 → 1.0.0-beta.73
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 +12 -0
- package/dist/adapters/web/admin/portal.d.ts.map +1 -1
- package/dist/adapters/web/admin/portal.js +522 -333
- package/dist/adapters/web/admin/portal.js.map +1 -1
- package/dist/harness/index.d.ts +1 -1
- package/dist/harness/index.d.ts.map +1 -1
- package/dist/harness/index.js +1 -1
- package/dist/harness/index.js.map +1 -1
- package/dist/harness/skills.d.ts +2 -0
- package/dist/harness/skills.d.ts.map +1 -1
- package/dist/harness/skills.js +2 -1
- package/dist/harness/skills.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { validateEventFilename } from "../../../events/index.js";
|
|
2
|
-
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, } from "node:fs";
|
|
3
3
|
import { basename, join, resolve as pathResolve, sep as pathSep } from "node:path";
|
|
4
|
-
import {
|
|
4
|
+
import { atomicWritePrivateFile } from "../../../file-guards.js";
|
|
5
|
+
import { MikanModels, parseFrontmatter, validateSkill } from "../../../harness/index.js";
|
|
5
6
|
import { SessionStore } from "../../../sessions/session-store.js";
|
|
6
7
|
import { InMemoryTokenStore } from "../token-store.js";
|
|
7
8
|
const ADMIN_TOKEN_TTL_MS = 30 * 60 * 1000;
|
|
@@ -104,7 +105,8 @@ async function routeGetApiRequest(res, url, services) {
|
|
|
104
105
|
}
|
|
105
106
|
}
|
|
106
107
|
async function routePostApiRequest(req, res, url, services) {
|
|
107
|
-
const
|
|
108
|
+
const bodyLimit = url.pathname === "/admin/api/skills/mutate" ? 256 * 1024 : 32 * 1024;
|
|
109
|
+
const body = await readJsonBody(req, res, bodyLimit);
|
|
108
110
|
if (!body)
|
|
109
111
|
return;
|
|
110
112
|
const token = services.adminTokenStore.peek(typeof body.token === "string" ? body.token : "");
|
|
@@ -127,6 +129,8 @@ async function routePostApiRequest(req, res, url, services) {
|
|
|
127
129
|
return serveConversationEventDelete(res, body, services, token);
|
|
128
130
|
case "/admin/api/mcp-servers/mutate":
|
|
129
131
|
return serveMcpServerMutation(res, body, services, token);
|
|
132
|
+
case "/admin/api/skills/mutate":
|
|
133
|
+
return serveSkillMutation(res, body, services, token);
|
|
130
134
|
case "/admin/api/settings/model":
|
|
131
135
|
return serveGlobalModelUpdate(res, body, services);
|
|
132
136
|
case "/admin/api/settings/sandbox":
|
|
@@ -1221,6 +1225,96 @@ function serveSkillFile(res, url, services, token) {
|
|
|
1221
1225
|
}
|
|
1222
1226
|
servePreviewFile(res, safe.absolute, { source, directory }, "Skill file not found");
|
|
1223
1227
|
}
|
|
1228
|
+
const SKILL_DIRECTORY_PATTERN = /^[a-z0-9-]+$/;
|
|
1229
|
+
function resolveSkillsRoot(workspace, scope, source) {
|
|
1230
|
+
if (source !== "global" && source !== "conversation") {
|
|
1231
|
+
return { error: "Invalid skill source" };
|
|
1232
|
+
}
|
|
1233
|
+
return {
|
|
1234
|
+
root: source === "global" ? workspace.skillsDir : workspace.office(scope.address).skillsDir,
|
|
1235
|
+
source,
|
|
1236
|
+
};
|
|
1237
|
+
}
|
|
1238
|
+
/**
|
|
1239
|
+
* Create, overwrite, or delete one skill directory. The directory name is
|
|
1240
|
+
* fixed at creation (renaming would leave the old directory as an orphan);
|
|
1241
|
+
* editing an existing skill always targets its current directory. Writes go
|
|
1242
|
+
* straight to the workspace skills tree — the same location the harness
|
|
1243
|
+
* loader reads — so a saved skill is available on the conversation's next
|
|
1244
|
+
* turn without a restart.
|
|
1245
|
+
*/
|
|
1246
|
+
async function serveSkillMutation(res, body, services, token) {
|
|
1247
|
+
const action = body.action;
|
|
1248
|
+
if (action !== "save" && action !== "delete") {
|
|
1249
|
+
jsonRes(res, 400, { error: "action must be 'save' or 'delete'" });
|
|
1250
|
+
return;
|
|
1251
|
+
}
|
|
1252
|
+
const scope = resolveTargetConversation(body, token);
|
|
1253
|
+
if (scope.error) {
|
|
1254
|
+
jsonRes(res, 403, { error: scope.error });
|
|
1255
|
+
return;
|
|
1256
|
+
}
|
|
1257
|
+
const workspace = requireAdminWorkspace(res, services);
|
|
1258
|
+
if (!workspace)
|
|
1259
|
+
return;
|
|
1260
|
+
const resolved = resolveSkillsRoot(workspace, scope, body.source);
|
|
1261
|
+
if ("error" in resolved) {
|
|
1262
|
+
jsonRes(res, 400, { error: resolved.error });
|
|
1263
|
+
return;
|
|
1264
|
+
}
|
|
1265
|
+
const directory = typeof body.directory === "string" ? body.directory.trim() : "";
|
|
1266
|
+
if (!directory || !SKILL_DIRECTORY_PATTERN.test(directory)) {
|
|
1267
|
+
jsonRes(res, 400, {
|
|
1268
|
+
error: "directory must be lowercase a-z, 0-9 and hyphens",
|
|
1269
|
+
});
|
|
1270
|
+
return;
|
|
1271
|
+
}
|
|
1272
|
+
const safe = safeJoinUnderRoot(resolved.root, directory);
|
|
1273
|
+
if (safe.error) {
|
|
1274
|
+
jsonRes(res, 400, { error: safe.error });
|
|
1275
|
+
return;
|
|
1276
|
+
}
|
|
1277
|
+
if (action === "delete") {
|
|
1278
|
+
if (!existsSync(join(safe.absolute, "SKILL.md"))) {
|
|
1279
|
+
jsonRes(res, 404, { error: "Skill not found" });
|
|
1280
|
+
return;
|
|
1281
|
+
}
|
|
1282
|
+
try {
|
|
1283
|
+
rmSync(safe.absolute, { recursive: true, force: true });
|
|
1284
|
+
}
|
|
1285
|
+
catch (err) {
|
|
1286
|
+
jsonRes(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
1287
|
+
return;
|
|
1288
|
+
}
|
|
1289
|
+
jsonRes(res, 200, { ok: true });
|
|
1290
|
+
return;
|
|
1291
|
+
}
|
|
1292
|
+
const name = typeof body.name === "string" ? body.name.trim() : directory;
|
|
1293
|
+
const description = typeof body.description === "string" ? body.description.trim() : "";
|
|
1294
|
+
const content = typeof body.content === "string" ? body.content : "";
|
|
1295
|
+
if (!description) {
|
|
1296
|
+
jsonRes(res, 400, { error: "description is required" });
|
|
1297
|
+
return;
|
|
1298
|
+
}
|
|
1299
|
+
const errors = validateSkill(name, description);
|
|
1300
|
+
if (errors.length > 0) {
|
|
1301
|
+
jsonRes(res, 400, { error: errors.join("; ") });
|
|
1302
|
+
return;
|
|
1303
|
+
}
|
|
1304
|
+
try {
|
|
1305
|
+
if (existsSync(safe.absolute) && lstatSync(safe.absolute).isSymbolicLink()) {
|
|
1306
|
+
throw new Error("Refusing to write through a symlinked skill directory");
|
|
1307
|
+
}
|
|
1308
|
+
mkdirSync(safe.absolute, { recursive: true });
|
|
1309
|
+
const frontmatter = ["---", `name: ${name}`, `description: ${description}`, "---", ""].join("\n");
|
|
1310
|
+
atomicWritePrivateFile(join(safe.absolute, "SKILL.md"), frontmatter + content.trim() + "\n");
|
|
1311
|
+
}
|
|
1312
|
+
catch (err) {
|
|
1313
|
+
jsonRes(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
1314
|
+
return;
|
|
1315
|
+
}
|
|
1316
|
+
jsonRes(res, 200, { ok: true, name, directory, source: resolved.source });
|
|
1317
|
+
}
|
|
1224
1318
|
// ── Events ─────────────────────────────────────────────────────────────────────
|
|
1225
1319
|
/**
|
|
1226
1320
|
* List events through the owning store, whose payloads are validated by the
|
|
@@ -1320,188 +1414,95 @@ async function serveConversationEventDelete(res, body, services, token) {
|
|
|
1320
1414
|
}
|
|
1321
1415
|
// ── Utilities ──────────────────────────────────────────────────────────────────
|
|
1322
1416
|
const esc = escapeHtml;
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
<div class="sect-title"><span class="sect-caret" aria-hidden="true">▸</span><div>
|
|
1362
|
-
<p class="eyebrow">Skills</p>
|
|
1363
|
-
<h2 class="card-title">可用的 skills</h2>
|
|
1364
|
-
</div></div>
|
|
1365
|
-
<button class="refresh-btn" onclick="event.stopPropagation(); loadSkills()">↻</button>
|
|
1366
|
-
</summary>
|
|
1367
|
-
<div class="sect-body">
|
|
1368
|
-
<div class="workspace-split">
|
|
1369
|
-
<div id="skills-content" class="workspace-tree"><div class="loading-msg">Loading…</div></div>
|
|
1370
|
-
<div id="skills-preview" class="workspace-preview"><div class="placeholder-msg">Click a skill to preview SKILL.md</div></div>
|
|
1371
|
-
</div>
|
|
1372
|
-
</div>
|
|
1373
|
-
</details>
|
|
1374
|
-
|
|
1375
|
-
<details class="card sect" id="sect-mcp" data-section="mcp">
|
|
1376
|
-
<summary class="sect-head">
|
|
1377
|
-
<div class="sect-title"><span class="sect-caret" aria-hidden="true">▸</span><div>
|
|
1378
|
-
<p class="eyebrow">MCP Servers</p>
|
|
1379
|
-
<h2 class="card-title">此對話的 MCP servers</h2>
|
|
1380
|
-
</div></div>
|
|
1381
|
-
<button class="refresh-btn" onclick="event.stopPropagation(); loadMcpServers()">↻</button>
|
|
1382
|
-
</summary>
|
|
1383
|
-
<div class="sect-body">
|
|
1384
|
-
<div id="mcp-conv-msg" class="status-msg" style="display:none"></div>
|
|
1385
|
-
<div id="mcp-conv-content"><div class="loading-msg">Loading…</div></div>
|
|
1386
|
-
</div>
|
|
1387
|
-
</details>
|
|
1388
|
-
|
|
1389
|
-
<details class="card sect" id="sect-vault" data-section="vault">
|
|
1390
|
-
<summary class="sect-head">
|
|
1391
|
-
<div class="sect-title"><span class="sect-caret" aria-hidden="true">▸</span><div>
|
|
1392
|
-
<p class="eyebrow">Vault</p>
|
|
1393
|
-
<h2 class="card-title">該對話的憑證</h2>
|
|
1394
|
-
</div></div>
|
|
1395
|
-
<button class="primary-action-btn" onclick="event.stopPropagation(); openLogin()">Open in new tab ↗</button>
|
|
1396
|
-
</summary>
|
|
1397
|
-
<div class="sect-body">
|
|
1398
|
-
<div id="vault-link-result" class="link-result" style="display:none"></div>
|
|
1399
|
-
<p class="card-desc">Opens the one-time credential form for this conversation's vault in a new tab.</p>
|
|
1400
|
-
</div>
|
|
1401
|
-
</details>
|
|
1402
|
-
|
|
1403
|
-
<details class="card sect" id="sect-events" data-section="events">
|
|
1404
|
-
<summary class="sect-head">
|
|
1405
|
-
<div class="sect-title"><span class="sect-caret" aria-hidden="true">▸</span><div>
|
|
1406
|
-
<p class="eyebrow">Events</p>
|
|
1407
|
-
<h2 class="card-title">關聯此對話的 events</h2>
|
|
1408
|
-
</div></div>
|
|
1409
|
-
<button class="refresh-btn" onclick="event.stopPropagation(); loadConversationEvents()">↻</button>
|
|
1410
|
-
</summary>
|
|
1411
|
-
<div class="sect-body">
|
|
1412
|
-
<div id="events-content"><div class="loading-msg">Loading…</div></div>
|
|
1417
|
+
const ICONS = {
|
|
1418
|
+
settings: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33h.01a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82v.01a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>`,
|
|
1419
|
+
workspace: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/></svg>`,
|
|
1420
|
+
skills: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5V5a2 2 0 0 1 2-2h11a1 1 0 0 1 1 1v14"/><path d="M6 17h13v4H6.5A2.5 2.5 0 0 1 4 18.5v0A2.5 2.5 0 0 1 6.5 16H20"/></svg>`,
|
|
1421
|
+
mcp: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="7" rx="1.5"/><rect x="3" y="13" width="18" height="7" rx="1.5"/><circle cx="7" cy="7.5" r="1" fill="currentColor" stroke="none"/><circle cx="7" cy="16.5" r="1" fill="currentColor" stroke="none"/></svg>`,
|
|
1422
|
+
vault: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="10" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>`,
|
|
1423
|
+
events: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="16" rx="2"/><path d="M3 10h18M8 3v4M16 3v4"/></svg>`,
|
|
1424
|
+
session: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg>`,
|
|
1425
|
+
overview: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="8" height="8" rx="1.5"/><rect x="13" y="3" width="8" height="5" rx="1.5"/><rect x="13" y="11" width="8" height="10" rx="1.5"/><rect x="3" y="14" width="8" height="7" rx="1.5"/></svg>`,
|
|
1426
|
+
usage: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21V3"/><path d="M3 21h18"/><path d="M7 17V11M12 17V7M17 17v-4"/></svg>`,
|
|
1427
|
+
};
|
|
1428
|
+
/** One entry in the settings rail: an icon, a label, and the pane id it activates. */
|
|
1429
|
+
function railLink(id, label, icon, active = false) {
|
|
1430
|
+
const svg = ICONS[icon];
|
|
1431
|
+
return `<button class="rail-link${active ? " active" : ""}" type="button" data-pane="${id}" aria-current="${active ? "page" : "false"}">${svg}<span>${esc(label)}</span></button>`;
|
|
1432
|
+
}
|
|
1433
|
+
/**
|
|
1434
|
+
* One settings pane: an eyebrow-free header (icon lives in the rail, not
|
|
1435
|
+
* repeated here) with a title, a one-line description, and header actions,
|
|
1436
|
+
* followed by body content. Every pane shares this shape so scanning the
|
|
1437
|
+
* page feels like one continuous settings surface instead of assembled
|
|
1438
|
+
* cards — the visual language `frontend-design` calls for here is a
|
|
1439
|
+
* quiet, editorial settings app, not a dashboard of boxes.
|
|
1440
|
+
*/
|
|
1441
|
+
function settingsPane(id, title, description, actions, body) {
|
|
1442
|
+
return `<section class="pane" id="pane-${id}" data-pane="${id}">
|
|
1443
|
+
<header class="pane-head">
|
|
1444
|
+
<div><h2 class="pane-title">${esc(title)}</h2><p class="pane-desc">${esc(description)}</p></div>
|
|
1445
|
+
<div class="pane-actions">${actions}</div>
|
|
1446
|
+
</header>
|
|
1447
|
+
<div class="pane-body">${body}</div>
|
|
1448
|
+
</section>`;
|
|
1449
|
+
}
|
|
1450
|
+
const adminViewBody = `<div class="settings-shell">
|
|
1451
|
+
<aside class="settings-rail" aria-label="Admin sections">
|
|
1452
|
+
<div class="rail-scope" role="tablist" aria-label="Scope">
|
|
1453
|
+
<button class="rail-scope-btn active" role="tab" aria-selected="true" aria-controls="panel-conversation" data-tab="conversation">This conversation</button>
|
|
1454
|
+
<button class="rail-scope-btn" role="tab" aria-selected="false" aria-controls="panel-global" data-tab="global">Workspace</button>
|
|
1413
1455
|
</div>
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1456
|
+
<nav class="rail-nav" id="rail-nav-conversation" data-scope="conversation">
|
|
1457
|
+
${railLink("settings", "Settings", "settings", true)}
|
|
1458
|
+
${railLink("workspace", "Workspace", "workspace")}
|
|
1459
|
+
${railLink("skills", "Skills", "skills")}
|
|
1460
|
+
${railLink("mcp", "MCP servers", "mcp")}
|
|
1461
|
+
${railLink("vault", "Vault", "vault")}
|
|
1462
|
+
${railLink("events", "Events", "events")}
|
|
1463
|
+
${railLink("session", "Session view", "session")}
|
|
1464
|
+
</nav>
|
|
1465
|
+
<nav class="rail-nav" id="rail-nav-global" data-scope="global" hidden>
|
|
1466
|
+
${railLink("g-overview", "All conversations", "overview", true)}
|
|
1467
|
+
${railLink("g-usage", "Token usage", "usage")}
|
|
1468
|
+
${railLink("g-settings", "Defaults", "settings")}
|
|
1469
|
+
${railLink("g-mcp", "MCP servers", "mcp")}
|
|
1470
|
+
${railLink("g-skills", "Skills", "skills")}
|
|
1471
|
+
${railLink("g-events", "Events", "events")}
|
|
1472
|
+
</nav>
|
|
1473
|
+
</aside>
|
|
1474
|
+
|
|
1475
|
+
<div class="settings-panels">
|
|
1476
|
+
<div class="tab-panel active" id="panel-conversation">
|
|
1477
|
+
${settingsPane("settings", "Settings", "Model, thinking level, auto-reply, and workspace visibility for this conversation.", '<button class="refresh-btn" onclick="loadSettings()">↻</button>', '<div id="settings-content"><div class="loading-msg">Loading…</div></div>')}
|
|
1478
|
+
|
|
1479
|
+
${settingsPane("workspace", "Workspace", "Read-only browser for this conversation's files on disk.", '<button class="refresh-btn" onclick="loadWorkspace()">↻</button>', '<div class="workspace-split"><div id="workspace-tree" class="workspace-tree"><div class="loading-msg">Loading…</div></div><div id="workspace-preview" class="workspace-preview"><div class="placeholder-msg">Click a file to preview</div></div></div>')}
|
|
1480
|
+
|
|
1481
|
+
${settingsPane("skills", "Skills", "Instructions the agent can load for specific tasks in this conversation.", '<button class="primary-action-btn" onclick="openSkillDialog(\'conversation\')">+ New skill</button><button class="refresh-btn" onclick="loadSkills()">↻</button>', '<div class="workspace-split"><div id="skills-content" class="workspace-tree"><div class="loading-msg">Loading…</div></div><div id="skills-preview" class="workspace-preview"><div class="placeholder-msg">Click a skill to preview SKILL.md</div></div></div>')}
|
|
1482
|
+
|
|
1483
|
+
${settingsPane("mcp", "MCP servers", "External tools this conversation can call during a run.", '<button class="refresh-btn" onclick="loadMcpServers()">↻</button>', '<div id="mcp-conv-msg" class="status-msg" style="display:none"></div><div id="mcp-conv-content"><div class="loading-msg">Loading…</div></div>')}
|
|
1484
|
+
|
|
1485
|
+
${settingsPane("vault", "Vault", "Credentials scoped to this conversation.", '<button class="primary-action-btn" onclick="openLogin()">Open in new tab ↗</button>', '<div id="vault-link-result" class="link-result" style="display:none"></div><p class="card-desc">Opens the one-time credential form for this conversation\'s vault in a new tab.</p>')}
|
|
1486
|
+
|
|
1487
|
+
${settingsPane("events", "Events", "Scheduled and one-shot events tied to this conversation.", '<button class="refresh-btn" onclick="loadConversationEvents()">↻</button>', '<div id="events-content"><div class="loading-msg">Loading…</div></div>')}
|
|
1488
|
+
|
|
1489
|
+
${settingsPane("session", "Session view", "The full message and tool-call timeline for this conversation.", '<button class="primary-action-btn" onclick="openSessionView()">Open in new tab ↗</button>', '<div id="session-link-result" class="link-result" style="display:none"></div><p class="card-desc">Opens the session timeline for this conversation in a new tab.</p>')}
|
|
1427
1490
|
</div>
|
|
1428
|
-
</details>
|
|
1429
|
-
</div>
|
|
1430
1491
|
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
<div>
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
<button class="refresh-btn" onclick="
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
<section class="card sect">
|
|
1444
|
-
<header class="sect-head">
|
|
1445
|
-
<div>
|
|
1446
|
-
<p class="eyebrow">Token Usage</p>
|
|
1447
|
-
</div>
|
|
1448
|
-
<button class="refresh-btn" onclick="loadTokenUsage()">↻</button>
|
|
1449
|
-
</header>
|
|
1450
|
-
<h2 class="card-subtitle" style="margin-bottom:10px">Top 20 sessions</h3>
|
|
1451
|
-
<div id="session-usage-content"><div class="loading-msg">Loading…</div></div>
|
|
1452
|
-
<h2 class="card-subtitle" style="margin:24px 0 10px">Usage timeline</h3>
|
|
1453
|
-
<div class="timeline-controls">
|
|
1454
|
-
<label>Conversation
|
|
1455
|
-
<select id="timeline-conv" onchange="loadUsageTimeline()"></select>
|
|
1456
|
-
</label>
|
|
1492
|
+
<div class="tab-panel" id="panel-global">
|
|
1493
|
+
${settingsPane("g-overview", "All conversations", "Every registered conversation across every platform.", '<button class="refresh-btn" onclick="loadAllConversations()">↻</button>', '<div id="all-conv-content"><div class="loading-msg">Loading…</div></div>')}
|
|
1494
|
+
|
|
1495
|
+
${settingsPane("g-usage", "Token usage", "Spend and volume across every conversation.", '<button class="refresh-btn" onclick="loadTokenUsage()">↻</button>', '<h3 class="card-subtitle">Top 20 sessions</h3><div id="session-usage-content"><div class="loading-msg">Loading…</div></div><h3 class="card-subtitle" style="margin-top:24px">Usage timeline</h3><div class="timeline-controls"><label>Conversation<select id="timeline-conv" onchange="loadUsageTimeline()"></select></label></div><div id="usage-timeline-content"><div class="loading-msg">Loading…</div></div>')}
|
|
1496
|
+
|
|
1497
|
+
${settingsPane("g-settings", "Workspace defaults", "The fallback model, sandbox limits, and Slack behavior every conversation inherits.", '<button class="refresh-btn" onclick="loadGlobalSettings()">↻</button>', '<div id="global-settings-content"><div class="loading-msg">Loading…</div></div>')}
|
|
1498
|
+
|
|
1499
|
+
${settingsPane("g-mcp", "MCP servers", "External tools available to every conversation in this workspace.", '<button class="refresh-btn" onclick="loadMcpServers()">↻</button>', '<div id="mcp-global-msg" class="status-msg" style="display:none"></div><div id="mcp-global-content"><div class="loading-msg">Loading…</div></div>')}
|
|
1500
|
+
|
|
1501
|
+
${settingsPane("g-skills", "Skills", "Shared instructions available to every conversation in this workspace.", '<button class="primary-action-btn" onclick="openSkillDialog(\'global\')">+ New global skill</button><button class="refresh-btn" onclick="loadGlobalSkills()">↻</button>', '<div class="workspace-split"><div id="global-skills-content" class="workspace-tree"><div class="loading-msg">Loading…</div></div><div id="global-skills-preview" class="workspace-preview"><div class="placeholder-msg">Click a skill to preview SKILL.md</div></div></div>')}
|
|
1502
|
+
|
|
1503
|
+
${settingsPane("g-events", "Global events", "Every scheduled event across the whole workspace.", '<button class="refresh-btn" onclick="loadEvents()">↻</button>', '<div id="global-events-content"><div class="loading-msg">Loading…</div></div>')}
|
|
1457
1504
|
</div>
|
|
1458
|
-
|
|
1459
|
-
</section>
|
|
1460
|
-
|
|
1461
|
-
<section class="card sect">
|
|
1462
|
-
<header class="sect-head">
|
|
1463
|
-
<div>
|
|
1464
|
-
<p class="eyebrow">Global Settings</p>
|
|
1465
|
-
<h2 class="card-title">全域預設</h2>
|
|
1466
|
-
</div>
|
|
1467
|
-
<button class="refresh-btn" onclick="loadGlobalSettings()">↻</button>
|
|
1468
|
-
</header>
|
|
1469
|
-
<div id="global-settings-content"><div class="loading-msg">Loading…</div></div>
|
|
1470
|
-
</section>
|
|
1471
|
-
|
|
1472
|
-
<section class="card sect">
|
|
1473
|
-
<header class="sect-head">
|
|
1474
|
-
<div>
|
|
1475
|
-
<p class="eyebrow">Global MCP Servers</p>
|
|
1476
|
-
<h2 class="card-title">所有對話都可用的 MCP servers</h2>
|
|
1477
|
-
</div>
|
|
1478
|
-
<button class="refresh-btn" onclick="loadMcpServers()">↻</button>
|
|
1479
|
-
</header>
|
|
1480
|
-
<div id="mcp-global-msg" class="status-msg" style="display:none"></div>
|
|
1481
|
-
<div id="mcp-global-content"><div class="loading-msg">Loading…</div></div>
|
|
1482
|
-
</section>
|
|
1483
|
-
|
|
1484
|
-
<section class="card sect">
|
|
1485
|
-
<header class="sect-head">
|
|
1486
|
-
<div>
|
|
1487
|
-
<p class="eyebrow">Global Skills</p>
|
|
1488
|
-
<h2 class="card-title">全域 skills</h2>
|
|
1489
|
-
</div>
|
|
1490
|
-
<button class="refresh-btn" onclick="loadGlobalSkills()">↻</button>
|
|
1491
|
-
</header>
|
|
1492
|
-
<div id="global-skills-content"><div class="loading-msg">Loading…</div></div>
|
|
1493
|
-
</section>
|
|
1494
|
-
|
|
1495
|
-
<section class="card sect">
|
|
1496
|
-
<header class="sect-head">
|
|
1497
|
-
<div>
|
|
1498
|
-
<p class="eyebrow">Global Events</p>
|
|
1499
|
-
<h2 class="card-title">全域 events.json</h2>
|
|
1500
|
-
</div>
|
|
1501
|
-
<button class="refresh-btn" onclick="loadEvents()">↻</button>
|
|
1502
|
-
</header>
|
|
1503
|
-
<div id="global-events-content"><div class="loading-msg">Loading…</div></div>
|
|
1504
|
-
</section>
|
|
1505
|
+
</div>
|
|
1505
1506
|
</div>
|
|
1506
1507
|
|
|
1507
1508
|
<dialog id="mcp-install-dialog" class="mcp-dialog" aria-labelledby="mcp-dialog-title">
|
|
@@ -1534,6 +1535,38 @@ const adminViewBody = `<nav class="tab-nav" role="tablist" aria-label="Admin sec
|
|
|
1534
1535
|
<button class="mcp-btn" type="button" onclick="closeMcpCustomDialog()">Cancel</button>
|
|
1535
1536
|
<button id="mcp-custom-dialog-submit" class="primary-action-btn" type="button" onclick="submitMcpCustomDialog(this)">新增並測試連線</button>
|
|
1536
1537
|
</div>
|
|
1538
|
+
</dialog>
|
|
1539
|
+
|
|
1540
|
+
<dialog id="skill-dialog" class="mcp-dialog" aria-labelledby="skill-dialog-title">
|
|
1541
|
+
<div class="mcp-dialog-head">
|
|
1542
|
+
<div>
|
|
1543
|
+
<p class="eyebrow" id="skill-dialog-eyebrow">Skill</p>
|
|
1544
|
+
<h2 id="skill-dialog-title" class="card-title">New skill</h2>
|
|
1545
|
+
</div>
|
|
1546
|
+
<button class="mcp-dialog-close" type="button" aria-label="Close" onclick="closeSkillDialog()">×</button>
|
|
1547
|
+
</div>
|
|
1548
|
+
<div class="mcp-field">
|
|
1549
|
+
<span>Directory</span>
|
|
1550
|
+
<input id="skill-dialog-directory" class="form-input mcp-json" placeholder="my-skill" autocomplete="off" />
|
|
1551
|
+
<small class="mcp-secret-note">Lowercase letters, digits, hyphens. Cannot be changed after creation.</small>
|
|
1552
|
+
</div>
|
|
1553
|
+
<div class="mcp-field">
|
|
1554
|
+
<span>Name</span>
|
|
1555
|
+
<input id="skill-dialog-name" class="form-input mcp-json" placeholder="my-skill" autocomplete="off" />
|
|
1556
|
+
</div>
|
|
1557
|
+
<div class="mcp-field">
|
|
1558
|
+
<span>Description</span>
|
|
1559
|
+
<input id="skill-dialog-description" class="form-input mcp-json" placeholder="Use when …" autocomplete="off" />
|
|
1560
|
+
</div>
|
|
1561
|
+
<div class="mcp-field">
|
|
1562
|
+
<span>Instructions (SKILL.md body)</span>
|
|
1563
|
+
<textarea id="skill-dialog-content" class="form-input mcp-json" spellcheck="false" rows="12" placeholder="# My skill Instructions here."></textarea>
|
|
1564
|
+
</div>
|
|
1565
|
+
<div id="skill-dialog-error" class="inline-result err" style="display:none"></div>
|
|
1566
|
+
<div class="mcp-dialog-actions">
|
|
1567
|
+
<button class="mcp-btn" type="button" onclick="closeSkillDialog()">Cancel</button>
|
|
1568
|
+
<button id="skill-dialog-submit" class="primary-action-btn" type="button" onclick="submitSkillDialog(this)">Save</button>
|
|
1569
|
+
</div>
|
|
1537
1570
|
</dialog>`;
|
|
1538
1571
|
const adminViewScript = ` let activeConversationKey = defaultConversationKey;
|
|
1539
1572
|
function scopeOf(key) {
|
|
@@ -1656,98 +1689,117 @@ const adminViewScript = ` let activeConversationKey = defaultConversationKey;
|
|
|
1656
1689
|
return sections.join('');
|
|
1657
1690
|
}
|
|
1658
1691
|
|
|
1659
|
-
// ──
|
|
1692
|
+
// ── Scope tabs (rail top switch) ────────────────────────────────────────────
|
|
1660
1693
|
|
|
1661
|
-
const
|
|
1694
|
+
const scopeBtns = document.querySelectorAll('.rail-scope-btn');
|
|
1662
1695
|
const tabPanels = document.querySelectorAll('.tab-panel');
|
|
1696
|
+
const railNavConversation = document.getElementById('rail-nav-conversation');
|
|
1697
|
+
const railNavGlobal = document.getElementById('rail-nav-global');
|
|
1663
1698
|
|
|
1664
1699
|
function switchTab(tabId) {
|
|
1665
|
-
|
|
1700
|
+
scopeBtns.forEach((btn) => {
|
|
1666
1701
|
const active = btn.dataset.tab === tabId;
|
|
1667
1702
|
btn.classList.toggle('active', active);
|
|
1668
1703
|
btn.setAttribute('aria-selected', active ? 'true' : 'false');
|
|
1669
1704
|
});
|
|
1670
1705
|
tabPanels.forEach((panel) => panel.classList.toggle('active', panel.id === 'panel-' + tabId));
|
|
1706
|
+
railNavConversation.hidden = tabId !== 'conversation';
|
|
1707
|
+
railNavGlobal.hidden = tabId !== 'global';
|
|
1671
1708
|
if (tabId === 'global') initGlobal();
|
|
1709
|
+
const firstPane = (tabId === 'global' ? railNavGlobal : railNavConversation).querySelector('.rail-link');
|
|
1710
|
+
if (firstPane) selectPane(tabId, firstPane.dataset.pane);
|
|
1672
1711
|
}
|
|
1673
|
-
|
|
1712
|
+
scopeBtns.forEach((btn) => btn.addEventListener('click', () => switchTab(btn.dataset.tab)));
|
|
1674
1713
|
|
|
1675
|
-
// ──
|
|
1714
|
+
// ── Rail navigation (single active pane per scope) ──────────────────────────
|
|
1676
1715
|
|
|
1677
|
-
async function initConvSwitcher() {
|
|
1678
|
-
const sel = document.getElementById('conv-switcher');
|
|
1679
|
-
try {
|
|
1680
|
-
const data = await apiGet('/admin/api/conversations');
|
|
1681
|
-
sel.innerHTML = data.conversations.map((c) => {
|
|
1682
|
-
const key = c.platform + ':' + c.conversationId;
|
|
1683
|
-
const label = (c.label || c.conversationId) + (c.running ? ' (running)' : '');
|
|
1684
|
-
const selected = key === defaultConversationKey ? ' selected' : '';
|
|
1685
|
-
return '<option value="' + escAttr(key) + '"' + selected + '>' + escHtml(label) + '</option>';
|
|
1686
|
-
}).join('');
|
|
1687
|
-
sel.addEventListener('change', () => setActiveConversation(sel.value));
|
|
1688
|
-
} catch (err) {
|
|
1689
|
-
// ignore; conversation selector stays empty
|
|
1690
|
-
}
|
|
1691
|
-
}
|
|
1692
|
-
|
|
1693
|
-
// Sections load lazily: only when their <details> is (or becomes) open,
|
|
1694
|
-
// so switching conversations or opening the admin page doesn't fire every
|
|
1695
|
-
// API at once. sectionLoaded is cleared on conversation switch so an
|
|
1696
|
-
// already-open section refetches for the new scope.
|
|
1697
|
-
//
|
|
1698
1716
|
// Looked up by name (not collected into an object up front) because the
|
|
1699
1717
|
// loaders below are const declarations further down this same script;
|
|
1700
1718
|
// referencing them before their own line runs would hit the temporal
|
|
1701
|
-
// dead zone. By the time this function is actually called (after a
|
|
1702
|
-
//
|
|
1703
|
-
//
|
|
1704
|
-
function
|
|
1719
|
+
// dead zone. By the time this function is actually called (after a rail
|
|
1720
|
+
// click, always after the whole script has executed), they're all
|
|
1721
|
+
// initialized.
|
|
1722
|
+
function paneLoader(key) {
|
|
1705
1723
|
switch (key) {
|
|
1706
1724
|
case 'settings': return loadSettings;
|
|
1707
1725
|
case 'workspace': return loadWorkspace;
|
|
1708
1726
|
case 'skills': return loadSkills;
|
|
1709
1727
|
case 'mcp': return loadMcpServers;
|
|
1710
1728
|
case 'events': return loadConversationEvents;
|
|
1729
|
+
case 'g-overview': return loadAllConversations;
|
|
1730
|
+
case 'g-usage': return loadTokenUsage;
|
|
1731
|
+
case 'g-settings': return loadGlobalSettings;
|
|
1732
|
+
case 'g-mcp': return loadMcpServers;
|
|
1733
|
+
case 'g-skills': return loadGlobalSkills;
|
|
1734
|
+
case 'g-events': return loadEvents;
|
|
1711
1735
|
default: return undefined;
|
|
1712
1736
|
}
|
|
1713
1737
|
}
|
|
1714
|
-
const
|
|
1715
|
-
|
|
1716
|
-
function conversationSectionEls() {
|
|
1717
|
-
return document.querySelectorAll('#panel-conversation > details[data-section]');
|
|
1718
|
-
}
|
|
1738
|
+
const paneLoaded = new Set();
|
|
1719
1739
|
|
|
1720
|
-
function
|
|
1721
|
-
if (
|
|
1722
|
-
const loader =
|
|
1740
|
+
function ensurePaneLoaded(key) {
|
|
1741
|
+
if (paneLoaded.has(key)) return;
|
|
1742
|
+
const loader = paneLoader(key);
|
|
1723
1743
|
if (!loader) return;
|
|
1724
|
-
|
|
1744
|
+
paneLoaded.add(key);
|
|
1725
1745
|
loader();
|
|
1726
1746
|
}
|
|
1727
1747
|
|
|
1728
|
-
function
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1748
|
+
function selectPane(scope, key) {
|
|
1749
|
+
const nav = scope === 'global' ? railNavGlobal : railNavConversation;
|
|
1750
|
+
const panel = document.getElementById(scope === 'global' ? 'panel-global' : 'panel-conversation');
|
|
1751
|
+
nav.querySelectorAll('.rail-link').forEach((btn) => {
|
|
1752
|
+
const active = btn.dataset.pane === key;
|
|
1753
|
+
btn.classList.toggle('active', active);
|
|
1754
|
+
btn.setAttribute('aria-current', active ? 'page' : 'false');
|
|
1755
|
+
});
|
|
1756
|
+
panel.querySelectorAll('.pane').forEach((pane) => {
|
|
1757
|
+
pane.classList.toggle('active', pane.dataset.pane === key);
|
|
1758
|
+
});
|
|
1759
|
+
ensurePaneLoaded(key);
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
function initRailNav() {
|
|
1763
|
+
document.querySelectorAll('.rail-link').forEach((btn) => {
|
|
1764
|
+
btn.addEventListener('click', () => {
|
|
1765
|
+
const nav = btn.closest('.rail-nav');
|
|
1766
|
+
const scope = nav.dataset.scope;
|
|
1767
|
+
selectPane(scope, btn.dataset.pane);
|
|
1737
1768
|
});
|
|
1738
1769
|
});
|
|
1770
|
+
// The first rail link in each scope starts active in the markup, so its
|
|
1771
|
+
// pane needs the matching 'active' class and its data loaded up front.
|
|
1772
|
+
selectPane('conversation', 'settings');
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
// ── Conversation switcher ───────────────────────────────────────────────────
|
|
1776
|
+
|
|
1777
|
+
async function initConvSwitcher() {
|
|
1778
|
+
const sel = document.getElementById('conv-switcher');
|
|
1779
|
+
try {
|
|
1780
|
+
const data = await apiGet('/admin/api/conversations');
|
|
1781
|
+
sel.innerHTML = data.conversations.map((c) => {
|
|
1782
|
+
const key = c.platform + ':' + c.conversationId;
|
|
1783
|
+
const label = (c.label || c.conversationId) + (c.running ? ' (running)' : '');
|
|
1784
|
+
const selected = key === defaultConversationKey ? ' selected' : '';
|
|
1785
|
+
return '<option value="' + escAttr(key) + '"' + selected + '>' + escHtml(label) + '</option>';
|
|
1786
|
+
}).join('');
|
|
1787
|
+
sel.addEventListener('change', () => setActiveConversation(sel.value));
|
|
1788
|
+
} catch (err) {
|
|
1789
|
+
// ignore; conversation selector stays empty
|
|
1790
|
+
}
|
|
1739
1791
|
}
|
|
1740
1792
|
|
|
1741
1793
|
function setActiveConversation(key) {
|
|
1742
1794
|
activeConversationKey = key;
|
|
1743
1795
|
const sel = document.getElementById('conv-switcher');
|
|
1744
1796
|
if (sel && sel.value !== key) sel.value = key;
|
|
1745
|
-
// Data is scoped to the previous conversation; drop
|
|
1746
|
-
//
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1797
|
+
// Data is scoped to the previous conversation; drop the conversation
|
|
1798
|
+
// panes (not the global ones — those aren't conversation-scoped) so
|
|
1799
|
+
// the active pane refetches for the new scope.
|
|
1800
|
+
const activeLink = railNavConversation.querySelector('.rail-link.active');
|
|
1801
|
+
['settings', 'workspace', 'skills', 'mcp', 'events'].forEach((key) => paneLoaded.delete(key));
|
|
1802
|
+
if (activeLink) ensurePaneLoaded(activeLink.dataset.pane);
|
|
1751
1803
|
openLogin(true);
|
|
1752
1804
|
openSessionView(true);
|
|
1753
1805
|
}
|
|
@@ -2287,6 +2339,31 @@ const adminViewScript = ` let activeConversationKey = defaultConversationKey;
|
|
|
2287
2339
|
void mutateMcpServer(btn.dataset.mcpScope, btn.dataset.mcpAction, btn.dataset.mcpName);
|
|
2288
2340
|
});
|
|
2289
2341
|
|
|
2342
|
+
let skillsCache = [];
|
|
2343
|
+
|
|
2344
|
+
function renderSkillsInto(containerId, skills, allowedSource) {
|
|
2345
|
+
const container = document.getElementById(containerId);
|
|
2346
|
+
if (!container) return;
|
|
2347
|
+
const filtered = allowedSource ? skills.filter((s) => s.source === allowedSource) : skills;
|
|
2348
|
+
if (filtered.length === 0) {
|
|
2349
|
+
container.innerHTML = '<div class="empty-state">No skills available</div>';
|
|
2350
|
+
return;
|
|
2351
|
+
}
|
|
2352
|
+
container.innerHTML = '<div class="skills-list">' +
|
|
2353
|
+
filtered.map((s) =>
|
|
2354
|
+
'<div class="skill-row">' +
|
|
2355
|
+
'<button class="skill-row-btn" data-skill-source="' + escAttr(s.source) + '" data-skill-directory="' + escAttr(s.directory) + '" data-skill-name="' + escAttr(s.name) + '">' +
|
|
2356
|
+
'<div class="skill-name">' + escHtml(s.name) + '<span class="skill-source skill-source-' + s.source + '">' + s.source + '</span></div>' +
|
|
2357
|
+
(s.description ? '<div class="skill-desc">' + escHtml(s.description) + '</div>' : '') +
|
|
2358
|
+
'</button>' +
|
|
2359
|
+
'<div class="skill-row-actions">' +
|
|
2360
|
+
'<button class="mcp-btn" data-skill-edit-source="' + escAttr(s.source) + '" data-skill-edit-directory="' + escAttr(s.directory) + '">Edit</button>' +
|
|
2361
|
+
'<button class="mcp-btn mcp-btn-danger" data-skill-delete-source="' + escAttr(s.source) + '" data-skill-delete-directory="' + escAttr(s.directory) + '" data-skill-delete-name="' + escAttr(s.name) + '">Delete</button>' +
|
|
2362
|
+
'</div>' +
|
|
2363
|
+
'</div>'
|
|
2364
|
+
).join('') + '</div>';
|
|
2365
|
+
}
|
|
2366
|
+
|
|
2290
2367
|
async function loadSkills() {
|
|
2291
2368
|
const container = document.getElementById('skills-content');
|
|
2292
2369
|
const previewEl = document.getElementById('skills-preview');
|
|
@@ -2294,25 +2371,19 @@ const adminViewScript = ` let activeConversationKey = defaultConversationKey;
|
|
|
2294
2371
|
if (previewEl) previewEl.innerHTML = '<div class="placeholder-msg">Click a skill to preview SKILL.md</div>';
|
|
2295
2372
|
try {
|
|
2296
2373
|
const data = await apiGet('/admin/api/skills?' + scopeQuery());
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
}
|
|
2301
|
-
container.innerHTML = '<div class="skills-list">' +
|
|
2302
|
-
data.skills.map((s) =>
|
|
2303
|
-
'<button class="skill-row skill-row-btn" data-skill-source="' + escAttr(s.source) + '" data-skill-directory="' + escAttr(s.directory) + '" data-skill-name="' + escAttr(s.name) + '">' +
|
|
2304
|
-
'<div class="skill-name">' + escHtml(s.name) + '<span class="skill-source skill-source-' + s.source + '">' + s.source + '</span></div>' +
|
|
2305
|
-
(s.description ? '<div class="skill-desc">' + escHtml(s.description) + '</div>' : '') +
|
|
2306
|
-
'</button>'
|
|
2307
|
-
).join('') + '</div>';
|
|
2308
|
-
|
|
2374
|
+
skillsCache = Array.isArray(data.skills) ? data.skills : [];
|
|
2375
|
+
renderSkillsInto('skills-content', skillsCache);
|
|
2376
|
+
renderSkillsInto('global-skills-content', skillsCache, 'global');
|
|
2309
2377
|
} catch (err) {
|
|
2310
2378
|
container.innerHTML = '<div class="err-msg">' + escHtml(err.message) + '</div>';
|
|
2311
2379
|
}
|
|
2312
2380
|
}
|
|
2313
2381
|
|
|
2314
|
-
|
|
2315
|
-
|
|
2382
|
+
const loadGlobalSkills = loadSkills;
|
|
2383
|
+
|
|
2384
|
+
async function previewSkillInto(previewId, source, directory, name) {
|
|
2385
|
+
const previewEl = document.getElementById(previewId);
|
|
2386
|
+
if (!previewEl) return;
|
|
2316
2387
|
if (!source || !directory) {
|
|
2317
2388
|
previewEl.innerHTML = '<div class="err-msg">Missing skill source or directory</div>';
|
|
2318
2389
|
return;
|
|
@@ -2326,11 +2397,105 @@ const adminViewScript = ` let activeConversationKey = defaultConversationKey;
|
|
|
2326
2397
|
}
|
|
2327
2398
|
}
|
|
2328
2399
|
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2400
|
+
async function deleteSkill(source, directory, name) {
|
|
2401
|
+
if (!confirm('Delete skill "' + (name || directory) + '" (' + source + ')?')) return;
|
|
2402
|
+
try {
|
|
2403
|
+
await apiPost('/admin/api/skills/mutate', { action: 'delete', source: source, directory: directory, ...scopeBody() });
|
|
2404
|
+
await loadSkills();
|
|
2405
|
+
} catch (err) {
|
|
2406
|
+
alert(err.message);
|
|
2407
|
+
}
|
|
2408
|
+
}
|
|
2409
|
+
|
|
2410
|
+
function bindSkillListEvents(containerId, previewId) {
|
|
2411
|
+
const el = document.getElementById(containerId);
|
|
2412
|
+
if (!el) return;
|
|
2413
|
+
el.addEventListener('click', (event) => {
|
|
2414
|
+
const previewBtn = event.target.closest('[data-skill-source]');
|
|
2415
|
+
if (previewBtn) {
|
|
2416
|
+
previewSkillInto(previewId, previewBtn.dataset.skillSource, previewBtn.dataset.skillDirectory, previewBtn.dataset.skillName);
|
|
2417
|
+
return;
|
|
2418
|
+
}
|
|
2419
|
+
const editBtn = event.target.closest('[data-skill-edit-source]');
|
|
2420
|
+
if (editBtn) {
|
|
2421
|
+
const skill = skillsCache.find((s) => s.source === editBtn.dataset.skillEditSource && s.directory === editBtn.dataset.skillEditDirectory);
|
|
2422
|
+
if (skill) void openSkillDialog(skill.source, skill);
|
|
2423
|
+
return;
|
|
2424
|
+
}
|
|
2425
|
+
const deleteBtn = event.target.closest('[data-skill-delete-source]');
|
|
2426
|
+
if (deleteBtn) {
|
|
2427
|
+
void deleteSkill(deleteBtn.dataset.skillDeleteSource, deleteBtn.dataset.skillDeleteDirectory, deleteBtn.dataset.skillDeleteName);
|
|
2428
|
+
}
|
|
2429
|
+
});
|
|
2430
|
+
}
|
|
2431
|
+
bindSkillListEvents('skills-content', 'skills-preview');
|
|
2432
|
+
bindSkillListEvents('global-skills-content', 'global-skills-preview');
|
|
2433
|
+
|
|
2434
|
+
let editingSkill = null;
|
|
2435
|
+
let pendingSkillScope = null;
|
|
2436
|
+
|
|
2437
|
+
async function openSkillDialog(scope, existing) {
|
|
2438
|
+
editingSkill = existing ? { source: existing.source, directory: existing.directory } : null;
|
|
2439
|
+
document.getElementById('skill-dialog-eyebrow').textContent = scope === 'global' ? 'Global skill' : 'Conversation skill';
|
|
2440
|
+
document.getElementById('skill-dialog-title').textContent = existing ? 'Edit skill' : 'New skill';
|
|
2441
|
+
const dirInput = document.getElementById('skill-dialog-directory');
|
|
2442
|
+
dirInput.value = existing ? existing.directory : '';
|
|
2443
|
+
dirInput.disabled = Boolean(existing);
|
|
2444
|
+
document.getElementById('skill-dialog-name').value = existing ? existing.name : '';
|
|
2445
|
+
document.getElementById('skill-dialog-description').value = existing ? existing.description : '';
|
|
2446
|
+
const contentEl = document.getElementById('skill-dialog-content');
|
|
2447
|
+
contentEl.value = '';
|
|
2448
|
+
const dialogError = document.getElementById('skill-dialog-error');
|
|
2449
|
+
dialogError.style.display = 'none';
|
|
2450
|
+
dialogError.textContent = '';
|
|
2451
|
+
pendingSkillScope = scope;
|
|
2452
|
+
if (existing) {
|
|
2453
|
+
contentEl.value = 'Loading…';
|
|
2454
|
+
try {
|
|
2455
|
+
const data = await apiGet('/admin/api/skills/file?' + scopeQuery() + '&source=' + encodeURIComponent(existing.source) + '&directory=' + encodeURIComponent(existing.directory));
|
|
2456
|
+
contentEl.value = data.binary ? '' : (data.content || '').replace(/^---[\\s\\S]*?---\\n?/, '').trim();
|
|
2457
|
+
} catch (err) {
|
|
2458
|
+
contentEl.value = '';
|
|
2459
|
+
dialogError.textContent = 'Failed to load current content: ' + err.message;
|
|
2460
|
+
dialogError.style.display = 'block';
|
|
2461
|
+
}
|
|
2462
|
+
}
|
|
2463
|
+
document.getElementById('skill-dialog').showModal();
|
|
2464
|
+
}
|
|
2465
|
+
|
|
2466
|
+
function closeSkillDialog() {
|
|
2467
|
+
editingSkill = null;
|
|
2468
|
+
pendingSkillScope = null;
|
|
2469
|
+
document.getElementById('skill-dialog').close();
|
|
2470
|
+
}
|
|
2471
|
+
|
|
2472
|
+
async function submitSkillDialog(btn) {
|
|
2473
|
+
const dialogError = document.getElementById('skill-dialog-error');
|
|
2474
|
+
dialogError.style.display = 'none';
|
|
2475
|
+
dialogError.textContent = '';
|
|
2476
|
+
const directory = document.getElementById('skill-dialog-directory').value.trim();
|
|
2477
|
+
const name = document.getElementById('skill-dialog-name').value.trim() || directory;
|
|
2478
|
+
const description = document.getElementById('skill-dialog-description').value.trim();
|
|
2479
|
+
const content = document.getElementById('skill-dialog-content').value;
|
|
2480
|
+
if (!directory) { dialogError.textContent = 'Directory is required'; dialogError.style.display = 'block'; return; }
|
|
2481
|
+
if (!description) { dialogError.textContent = 'Description is required'; dialogError.style.display = 'block'; return; }
|
|
2482
|
+
const source = editingSkill ? editingSkill.source : pendingSkillScope;
|
|
2483
|
+
btn.disabled = true;
|
|
2484
|
+
btn.textContent = 'Saving…';
|
|
2485
|
+
try {
|
|
2486
|
+
await apiPost('/admin/api/skills/mutate', {
|
|
2487
|
+
action: 'save', source: source, directory: directory, name: name, description: description, content: content, ...scopeBody(),
|
|
2488
|
+
});
|
|
2489
|
+
closeSkillDialog();
|
|
2490
|
+
await loadSkills();
|
|
2491
|
+
} catch (err) {
|
|
2492
|
+
dialogError.textContent = err.message;
|
|
2493
|
+
dialogError.style.display = 'block';
|
|
2494
|
+
} finally {
|
|
2495
|
+
btn.disabled = false;
|
|
2496
|
+
btn.textContent = 'Save';
|
|
2497
|
+
}
|
|
2498
|
+
}
|
|
2334
2499
|
|
|
2335
2500
|
// ── Vault (Login link) ───────────────────────────────────────────────────────
|
|
2336
2501
|
|
|
@@ -2413,17 +2578,10 @@ const adminViewScript = ` let activeConversationKey = defaultConversationKey;
|
|
|
2413
2578
|
|
|
2414
2579
|
// ── Global section ──────────────────────────────────────────────────────────
|
|
2415
2580
|
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
loadAllConversations();
|
|
2421
|
-
loadTokenUsage();
|
|
2422
|
-
loadGlobalSettings();
|
|
2423
|
-
loadGlobalSkills();
|
|
2424
|
-
loadEvents();
|
|
2425
|
-
loadMcpServers();
|
|
2426
|
-
}
|
|
2581
|
+
// Switching to the Workspace scope only needs to (re)select its first
|
|
2582
|
+
// pane; every pane still loads lazily through paneLoader/ensurePaneLoaded
|
|
2583
|
+
// the first time it becomes active, same as the conversation scope.
|
|
2584
|
+
function initGlobal() {}
|
|
2427
2585
|
|
|
2428
2586
|
async function loadAllConversations() {
|
|
2429
2587
|
const container = document.getElementById('all-conv-content');
|
|
@@ -2673,32 +2831,12 @@ const adminViewScript = ` let activeConversationKey = defaultConversationKey;
|
|
|
2673
2831
|
await saveSetting(btn, result, 'settings/slack', { replyMode }, 'Save Slack');
|
|
2674
2832
|
}
|
|
2675
2833
|
|
|
2676
|
-
async function loadGlobalSkills() {
|
|
2677
|
-
const container = document.getElementById('global-skills-content');
|
|
2678
|
-
container.innerHTML = '<div class="loading-msg">Loading…</div>';
|
|
2679
|
-
try {
|
|
2680
|
-
// Reuse skills endpoint scoped to a conversation that doesn't have any of its own; the global half is what we want.
|
|
2681
|
-
const data = await apiGet('/admin/api/skills?' + scopeQuery());
|
|
2682
|
-
const globals = data.skills.filter((s) => s.source === 'global');
|
|
2683
|
-
if (globals.length === 0) {
|
|
2684
|
-
container.innerHTML = '<div class="empty-state">No global skills</div>';
|
|
2685
|
-
return;
|
|
2686
|
-
}
|
|
2687
|
-
container.innerHTML = '<div class="skills-list">' + globals.map((s) =>
|
|
2688
|
-
'<div class="skill-row"><div class="skill-name">' + escHtml(s.name) + '</div>' +
|
|
2689
|
-
(s.description ? '<div class="skill-desc">' + escHtml(s.description) + '</div>' : '') + '</div>'
|
|
2690
|
-
).join('') + '</div>';
|
|
2691
|
-
} catch (err) {
|
|
2692
|
-
container.innerHTML = '<div class="err-msg">' + escHtml(err.message) + '</div>';
|
|
2693
|
-
}
|
|
2694
|
-
}
|
|
2695
|
-
|
|
2696
2834
|
// ── Init ─────────────────────────────────────────────────────────────────────
|
|
2697
2835
|
|
|
2698
|
-
//
|
|
2836
|
+
// Panes load on demand (see ensurePaneLoaded); loadSettingsPanel already
|
|
2699
2837
|
// calls loadModels() itself the first time it's needed.
|
|
2700
2838
|
initConvSwitcher();
|
|
2701
|
-
|
|
2839
|
+
initRailNav();
|
|
2702
2840
|
`;
|
|
2703
2841
|
function renderAdminPage(token) {
|
|
2704
2842
|
const userLabel = token.platformUserName ?? token.platformUserId;
|
|
@@ -2731,28 +2869,93 @@ function renderAdminErrorPage(message) {
|
|
|
2731
2869
|
}
|
|
2732
2870
|
// ── Styles ─────────────────────────────────────────────────────────────────────
|
|
2733
2871
|
const adminViewStyles = `
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
.
|
|
2742
|
-
|
|
2743
|
-
|
|
2872
|
+
/* The shared shell caps at 960px for the narrower session/login portals;
|
|
2873
|
+
the sidebar + single-pane layout here reads cramped at that width, so
|
|
2874
|
+
admin alone widens it. */
|
|
2875
|
+
.shell { max-width: 1180px; }
|
|
2876
|
+
|
|
2877
|
+
/* ── Settings shell: persistent rail + single active pane ───────────── */
|
|
2878
|
+
|
|
2879
|
+
.settings-shell {
|
|
2880
|
+
display: grid;
|
|
2881
|
+
grid-template-columns: 216px 1fr;
|
|
2882
|
+
align-items: start;
|
|
2883
|
+
gap: 22px;
|
|
2884
|
+
}
|
|
2885
|
+
|
|
2886
|
+
.settings-rail {
|
|
2887
|
+
position: sticky;
|
|
2888
|
+
top: 28px;
|
|
2889
|
+
display: flex;
|
|
2890
|
+
flex-direction: column;
|
|
2891
|
+
gap: 16px;
|
|
2892
|
+
}
|
|
2893
|
+
|
|
2894
|
+
.rail-scope {
|
|
2895
|
+
display: flex;
|
|
2896
|
+
padding: 3px;
|
|
2897
|
+
border: 1px solid var(--border);
|
|
2898
|
+
border-radius: 11px;
|
|
2899
|
+
background: rgba(0,0,0,0.03);
|
|
2900
|
+
gap: 2px;
|
|
2901
|
+
}
|
|
2902
|
+
.rail-scope-btn {
|
|
2903
|
+
flex: 1;
|
|
2904
|
+
padding: 7px 8px;
|
|
2905
|
+
border: none;
|
|
2906
|
+
border-radius: 8px;
|
|
2907
|
+
background: transparent;
|
|
2744
2908
|
color: var(--muted);
|
|
2745
|
-
font:
|
|
2746
|
-
|
|
2909
|
+
font: 600 0.72rem/1.2 'DM Sans', sans-serif;
|
|
2910
|
+
letter-spacing: 0.01em;
|
|
2911
|
+
cursor: pointer;
|
|
2747
2912
|
transition: background 140ms, color 140ms;
|
|
2748
2913
|
}
|
|
2749
|
-
.
|
|
2750
|
-
.
|
|
2751
|
-
.
|
|
2752
|
-
|
|
2753
|
-
.
|
|
2914
|
+
.rail-scope-btn:hover { color: var(--text); }
|
|
2915
|
+
.rail-scope-btn.active { background: var(--surface); color: var(--text); box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
|
|
2916
|
+
.rail-scope-btn:focus-visible { outline: 2px solid var(--text); outline-offset: 2px; }
|
|
2917
|
+
|
|
2918
|
+
.rail-nav { display: flex; flex-direction: column; gap: 1px; }
|
|
2919
|
+
.rail-nav[hidden] { display: none; }
|
|
2920
|
+
.rail-link {
|
|
2921
|
+
display: flex; align-items: center; gap: 10px;
|
|
2922
|
+
padding: 8px 10px; border: none; border-radius: 9px;
|
|
2923
|
+
background: transparent; color: var(--muted); text-align: left;
|
|
2924
|
+
font: 500 0.86rem/1.2 'DM Sans', sans-serif; cursor: pointer;
|
|
2925
|
+
transition: background 120ms, color 120ms;
|
|
2926
|
+
}
|
|
2927
|
+
.rail-link svg { flex-shrink: 0; opacity: 0.75; }
|
|
2928
|
+
.rail-link:hover { background: rgba(0,0,0,0.045); color: var(--text); }
|
|
2929
|
+
.rail-link:hover svg { opacity: 1; }
|
|
2930
|
+
.rail-link.active {
|
|
2931
|
+
background: var(--text); color: #fafafa; font-weight: 600;
|
|
2932
|
+
}
|
|
2933
|
+
.rail-link.active svg { opacity: 1; }
|
|
2934
|
+
.rail-link:focus-visible { outline: 2px solid var(--text); outline-offset: 1px; }
|
|
2935
|
+
|
|
2936
|
+
.settings-panels { min-width: 0; }
|
|
2937
|
+
.tab-panel { display: none; flex-direction: column; gap: 0; }
|
|
2754
2938
|
.tab-panel.active { display: flex; }
|
|
2755
2939
|
|
|
2940
|
+
/* ── Panes: one continuous surface, not stacked cards ────────────────── */
|
|
2941
|
+
|
|
2942
|
+
.pane { display: none; padding: 30px 0; border-bottom: 1px solid var(--border); }
|
|
2943
|
+
.pane:last-child { border-bottom: none; }
|
|
2944
|
+
.pane.active { display: block; animation: pane-in 180ms ease; }
|
|
2945
|
+
@keyframes pane-in { from { opacity: 0; transform: translateY(3px); } to { opacity: 1; transform: none; } }
|
|
2946
|
+
|
|
2947
|
+
.pane-head {
|
|
2948
|
+
display: flex; align-items: flex-start; justify-content: space-between;
|
|
2949
|
+
gap: 16px; margin-bottom: 22px; flex-wrap: wrap;
|
|
2950
|
+
}
|
|
2951
|
+
.pane-title {
|
|
2952
|
+
font-family: 'Lora', Georgia, serif; font-size: 1.32rem; font-weight: 600;
|
|
2953
|
+
letter-spacing: -0.01em; line-height: 1.25; margin-bottom: 5px;
|
|
2954
|
+
}
|
|
2955
|
+
.pane-desc { color: var(--muted); font-size: 0.88rem; line-height: 1.55; max-width: 52ch; }
|
|
2956
|
+
.pane-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
|
|
2957
|
+
.pane-body { display: flex; flex-direction: column; gap: 14px; }
|
|
2958
|
+
|
|
2756
2959
|
.card-desc { color: var(--muted); font-size: 0.9rem; line-height: 1.55; margin-bottom: 12px; }
|
|
2757
2960
|
|
|
2758
2961
|
.link-result {
|
|
@@ -2799,36 +3002,12 @@ const adminViewStyles = `
|
|
|
2799
3002
|
.inline-result.ok { background: var(--ok-bg); color: var(--ok-text); border: 1px solid var(--ok-border); }
|
|
2800
3003
|
.inline-result.err { background: var(--err-bg); color: var(--err-text); border: 1px solid var(--err-border); }
|
|
2801
3004
|
|
|
2802
|
-
/* ── Sections (Conversation page stack) ─────────────────────────────── */
|
|
2803
|
-
|
|
2804
|
-
/* Each section is a <details>; the card's own padding sits on the
|
|
2805
|
-
<summary>/<div class="sect-body"> instead of the <details> element so a
|
|
2806
|
-
collapsed section is just its header row. */
|
|
2807
|
-
details.sect { padding: 0; overflow: hidden; }
|
|
2808
|
-
details.sect > summary.sect-head {
|
|
2809
|
-
padding: 24px 28px; margin-bottom: 0; cursor: pointer; list-style: none;
|
|
2810
|
-
}
|
|
2811
|
-
details.sect > summary.sect-head::-webkit-details-marker { display: none; }
|
|
2812
|
-
details.sect > .sect-body { padding: 0 28px 24px; }
|
|
2813
|
-
|
|
2814
|
-
.sect-head {
|
|
2815
|
-
display: flex; align-items: flex-start; justify-content: space-between;
|
|
2816
|
-
gap: 12px; flex-wrap: wrap;
|
|
2817
|
-
}
|
|
2818
|
-
.sect-head .card-title { margin-bottom: 0; }
|
|
2819
|
-
.sect-title { display: flex; align-items: flex-start; gap: 10px; min-width: 0; }
|
|
2820
|
-
.sect-caret {
|
|
2821
|
-
flex-shrink: 0; margin-top: 3px; color: var(--subtle);
|
|
2822
|
-
transition: transform 140ms; display: inline-block;
|
|
2823
|
-
}
|
|
2824
|
-
details.sect[open] > summary.sect-head .sect-caret { transform: rotate(90deg); }
|
|
2825
|
-
.sect-disabled { opacity: 0.7; }
|
|
2826
|
-
|
|
2827
3005
|
.refresh-btn {
|
|
2828
3006
|
flex-shrink: 0; padding: 6px 12px;
|
|
2829
3007
|
border: 1px solid var(--border); border-radius: 10px;
|
|
2830
3008
|
background: rgba(0,0,0,0.025); color: var(--muted);
|
|
2831
|
-
font: 500 0.
|
|
3009
|
+
font: 500 0.8rem/1.2 'DM Sans', sans-serif; cursor: pointer;
|
|
3010
|
+
transition: background 120ms, color 120ms;
|
|
2832
3011
|
}
|
|
2833
3012
|
.refresh-btn:hover { background: rgba(0,0,0,0.06); color: var(--text); }
|
|
2834
3013
|
|
|
@@ -2881,11 +3060,14 @@ const adminViewStyles = `
|
|
|
2881
3060
|
.skill-row {
|
|
2882
3061
|
padding: 10px 12px; border: 1px solid var(--border); border-radius: 10px;
|
|
2883
3062
|
background: rgba(0,0,0,0.02);
|
|
3063
|
+
display: flex; align-items: flex-start; justify-content: space-between; gap: 10px;
|
|
2884
3064
|
}
|
|
2885
3065
|
.skill-row-btn {
|
|
2886
|
-
width:
|
|
3066
|
+
flex: 1 1 auto; min-width: 0; text-align: left; cursor: pointer; font-family: inherit;
|
|
3067
|
+
background: transparent; border: none; padding: 0;
|
|
2887
3068
|
}
|
|
2888
|
-
.skill-row-btn:hover {
|
|
3069
|
+
.skill-row-btn:hover .skill-name { color: var(--accent); }
|
|
3070
|
+
.skill-row-actions { display: flex; gap: 6px; flex-shrink: 0; }
|
|
2889
3071
|
.skill-name {
|
|
2890
3072
|
font-weight: 650; font-size: 0.9rem; color: var(--text);
|
|
2891
3073
|
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
|
@@ -3194,8 +3376,15 @@ const adminViewStyles = `
|
|
|
3194
3376
|
}
|
|
3195
3377
|
.status-pill.running { background: var(--ok-bg); color: var(--ok-text); border: 1px solid var(--ok-border); }
|
|
3196
3378
|
|
|
3379
|
+
@media (max-width: 860px) {
|
|
3380
|
+
.settings-shell { grid-template-columns: 1fr; }
|
|
3381
|
+
.settings-rail { position: static; }
|
|
3382
|
+
.rail-nav { flex-direction: row; flex-wrap: wrap; }
|
|
3383
|
+
.rail-link span { display: none; }
|
|
3384
|
+
.rail-link { padding: 9px; }
|
|
3385
|
+
}
|
|
3386
|
+
|
|
3197
3387
|
@media (max-width: 640px) {
|
|
3198
|
-
.tab-btn { padding: 9px 12px; font-size: 0.82rem; min-width: 60px; }
|
|
3199
3388
|
.config-grid { grid-template-columns: 1fr; }
|
|
3200
3389
|
.config-row { grid-template-columns: 1fr; gap: 4px; }
|
|
3201
3390
|
.workspace-split { grid-template-columns: 1fr; }
|