feat: polish mindspace decoupling flows
This commit is contained in:
@@ -41,9 +41,14 @@ Streaming runtime operations:
|
||||
node scripts/runtime-worker-metrics.mjs status
|
||||
node scripts/runtime-worker-metrics.mjs sample
|
||||
bash scripts/install-runtime-metrics-agent.sh
|
||||
node scripts/runtime-worker-heartbeat.mjs once
|
||||
node scripts/runtime-worker-heartbeat.mjs serve
|
||||
bash scripts/install-runtime-heartbeat-agent.sh
|
||||
node scripts/runtime-slo-report.mjs
|
||||
node scripts/runtime-slo-report.mjs --write-report
|
||||
node scripts/runtime-slo-report.mjs --write-report --prune --retention-days 30
|
||||
bash scripts/install-runtime-slo-report-agent.sh
|
||||
bash scripts/install-runtime-slo-soak-agent.sh
|
||||
node scripts/runtime-worker-drain.mjs drain goosed-3
|
||||
node scripts/runtime-worker-drain.mjs undrain goosed-3
|
||||
node scripts/check-tool-runtime.mjs
|
||||
@@ -51,3 +56,8 @@ Streaming runtime operations:
|
||||
curl -sk https://mm.tkmind.cn/api/runtime/status # includes toolRuntime.queue
|
||||
node scripts/agent-run-worker.mjs --status
|
||||
node scripts/agent-run-worker.mjs --once
|
||||
bash scripts/install-agent-run-worker-agent.sh # installs disabled by default
|
||||
node scripts/check-agent-run-worker.mjs # read-only LaunchAgent/queue check
|
||||
node scripts/agent-run-guard.mjs # dry-run auto-pause guard check
|
||||
node scripts/agent-run-guard.mjs --apply # stop worker and disable code-run gate when thresholds trip
|
||||
bash scripts/install-agent-run-guard-agent.sh # installs guard LaunchAgent
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const SKIP_REFERENCE = /^(?:https?:|mailto:|javascript:|data:|tel:|#|\/\/)/i;
|
||||
const DOWNLOAD_EXTENSIONS = /\.(?:docx|doc|pdf|xlsx|xls|pptx|ppt|zip|rar|7z|csv)$/i;
|
||||
|
||||
export function isFilesystemPublicReference(raw) {
|
||||
const value = String(raw ?? '').trim();
|
||||
if (!value) return false;
|
||||
if (SKIP_REFERENCE.test(value)) return false;
|
||||
// /api/... and /MindSpace/... are server routes, not on-disk siblings of public HTML.
|
||||
if (value.startsWith('/')) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @deprecated use isFilesystemPublicReference */
|
||||
export function isRelativePublicReference(raw) {
|
||||
return isFilesystemPublicReference(raw);
|
||||
}
|
||||
|
||||
export function isDownloadLikeReference(raw, source = '') {
|
||||
if (!isFilesystemPublicReference(raw)) return false;
|
||||
const ref = normalizeReferencePath(raw);
|
||||
if (DOWNLOAD_EXTENSIONS.test(ref)) return true;
|
||||
return /\bdownload\b/i.test(String(source ?? ''));
|
||||
}
|
||||
|
||||
export function normalizeReferencePath(raw) {
|
||||
const trimmed = String(raw ?? '')
|
||||
.trim()
|
||||
.split('#')[0]
|
||||
.split('?')[0]
|
||||
.trim();
|
||||
if (!trimmed) return '';
|
||||
try {
|
||||
return decodeURIComponent(trimmed);
|
||||
} catch {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveReferencePath(htmlDir, raw) {
|
||||
const ref = normalizeReferencePath(raw);
|
||||
if (!ref) return null;
|
||||
return path.resolve(htmlDir, ref);
|
||||
}
|
||||
|
||||
function parseMindspaceCoverPaths(contentAttr) {
|
||||
const paths = [];
|
||||
if (!contentAttr) return paths;
|
||||
let parsed = null;
|
||||
try {
|
||||
parsed = JSON.parse(contentAttr);
|
||||
} catch {
|
||||
for (const key of ['cover', 'image']) {
|
||||
const match = contentAttr.match(new RegExp(`"${key}"\\s*:\\s*"([^"]+)"`));
|
||||
if (match?.[1]) paths.push(match[1]);
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
for (const key of ['cover', 'image']) {
|
||||
if (parsed?.[key]) paths.push(String(parsed[key]));
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
export function collectPublicHtmlReferences(html, htmlDir, { downloadsOnly = false } = {}) {
|
||||
const refs = [];
|
||||
const seen = new Set();
|
||||
|
||||
const add = (raw, source) => {
|
||||
if (downloadsOnly && !isDownloadLikeReference(raw, source)) return;
|
||||
if (!downloadsOnly && !isFilesystemPublicReference(raw)) return;
|
||||
const ref = normalizeReferencePath(raw);
|
||||
if (!ref) return;
|
||||
const key = `${source}\0${ref}`;
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
refs.push({
|
||||
ref,
|
||||
source,
|
||||
resolvedPath: resolveReferencePath(htmlDir, ref),
|
||||
});
|
||||
};
|
||||
|
||||
for (const match of String(html ?? '').matchAll(/\b(?:href|src)\s*=\s*["']([^"']+)["']/gi)) {
|
||||
add(match[1], match[0]);
|
||||
}
|
||||
|
||||
if (!downloadsOnly) {
|
||||
for (const match of String(html ?? '').matchAll(
|
||||
/<meta\b[^>]*\bname\s*=\s*["']mindspace-cover["'][^>]*>/gi,
|
||||
)) {
|
||||
const tag = match[0];
|
||||
const contentMatch =
|
||||
tag.match(/\bcontent\s*=\s*"([^"]*)"/i) ?? tag.match(/\bcontent\s*=\s*'([^']*)'/i);
|
||||
for (const coverPath of parseMindspaceCoverPaths(contentMatch?.[1])) {
|
||||
add(coverPath, 'mindspace-cover');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return refs;
|
||||
}
|
||||
|
||||
export function findMissingPublicHtmlReferences(htmlPath, html = null, options = {}) {
|
||||
const resolvedHtmlPath = path.resolve(htmlPath);
|
||||
const content = html ?? fs.readFileSync(resolvedHtmlPath, 'utf8');
|
||||
const htmlDir = path.dirname(resolvedHtmlPath);
|
||||
const missing = [];
|
||||
const seen = new Set();
|
||||
|
||||
for (const item of collectPublicHtmlReferences(content, htmlDir, options)) {
|
||||
const key = item.ref;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
if (!item.resolvedPath || !fs.existsSync(item.resolvedPath)) {
|
||||
missing.push({ htmlPath: resolvedHtmlPath, ...item });
|
||||
}
|
||||
}
|
||||
|
||||
return missing;
|
||||
}
|
||||
|
||||
export function listPublicHtmlFiles(publishRoot, { userId = null } = {}) {
|
||||
const root = path.resolve(publishRoot);
|
||||
if (!fs.existsSync(root)) return [];
|
||||
|
||||
const userDirs = userId
|
||||
? [path.join(root, userId)]
|
||||
: fs.readdirSync(root, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => path.join(root, d.name));
|
||||
|
||||
const files = [];
|
||||
for (const userDir of userDirs) {
|
||||
const publicDir = path.join(userDir, 'public');
|
||||
if (!fs.existsSync(publicDir) || !fs.statSync(publicDir).isDirectory()) continue;
|
||||
for (const name of fs.readdirSync(publicDir)) {
|
||||
if (!name.toLowerCase().endsWith('.html')) continue;
|
||||
files.push(path.join(publicDir, name));
|
||||
}
|
||||
}
|
||||
return files.sort();
|
||||
}
|
||||
|
||||
export function scanPublicHtmlLinks(publishRoot, options = {}) {
|
||||
const issues = [];
|
||||
for (const htmlPath of listPublicHtmlFiles(publishRoot, options)) {
|
||||
issues.push(...findMissingPublicHtmlReferences(htmlPath, null, options));
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
import { createRequire as __createRequire } from "node:module"; const require = __createRequire(import.meta.url);
|
||||
|
||||
// mindspace-sandbox-mcp.mjs
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
import path2 from "node:path";
|
||||
import fs2 from "node:fs";
|
||||
import readline from "node:readline";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import mysql from "mysql2/promise";
|
||||
@@ -1132,15 +1132,102 @@ function createScheduleService(pool, options = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
// mindspace-long-image.mjs
|
||||
import fs from "node:fs";
|
||||
import fsPromises from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
var DEFAULT_VIEWPORT_WIDTH = 1280;
|
||||
var DEFAULT_VIEWPORT_HEIGHT = 720;
|
||||
var MAX_LONG_IMAGE_HEIGHT = 2e4;
|
||||
function longImagePathForHtml(htmlPath, outputPath = null) {
|
||||
return outputPath || String(htmlPath).replace(/\.html$/i, ".long.png");
|
||||
}
|
||||
function clampDimension(value, fallback, max) {
|
||||
const number = Math.ceil(Number(value) || fallback);
|
||||
return Math.max(320, Math.min(number, max));
|
||||
}
|
||||
async function launchChromium(chromium) {
|
||||
const base = {
|
||||
headless: true,
|
||||
args: ["--disable-dev-shm-usage", "--hide-scrollbars"]
|
||||
};
|
||||
if (process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH) {
|
||||
return chromium.launch({
|
||||
...base,
|
||||
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
|
||||
});
|
||||
}
|
||||
if (process.env.PLAYWRIGHT_CHROMIUM_CHANNEL) {
|
||||
return chromium.launch({ ...base, channel: process.env.PLAYWRIGHT_CHROMIUM_CHANNEL });
|
||||
}
|
||||
try {
|
||||
return await chromium.launch(base);
|
||||
} catch (error) {
|
||||
if (process.platform === "darwin") {
|
||||
return chromium.launch({ ...base, channel: "chrome" });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
async function renderLongImage({
|
||||
htmlPath = null,
|
||||
url = null,
|
||||
outputPath = null,
|
||||
viewportWidth = DEFAULT_VIEWPORT_WIDTH,
|
||||
viewportHeight = DEFAULT_VIEWPORT_HEIGHT
|
||||
} = {}) {
|
||||
if (!htmlPath && !url) throw new Error("\u7F3A\u5C11 htmlPath \u6216 url");
|
||||
const targetUrl = url || pathToFileURL(path.resolve(htmlPath)).toString();
|
||||
const destination = outputPath ? path.resolve(outputPath) : longImagePathForHtml(path.resolve(htmlPath));
|
||||
const { chromium } = await import("playwright");
|
||||
let browser = null;
|
||||
try {
|
||||
browser = await launchChromium(chromium);
|
||||
const page = await browser.newPage({
|
||||
viewport: {
|
||||
width: clampDimension(viewportWidth, DEFAULT_VIEWPORT_WIDTH, 2400),
|
||||
height: clampDimension(viewportHeight, DEFAULT_VIEWPORT_HEIGHT, MAX_LONG_IMAGE_HEIGHT)
|
||||
},
|
||||
deviceScaleFactor: 2
|
||||
});
|
||||
await page.goto(targetUrl, { waitUntil: "networkidle", timeout: 3e4 });
|
||||
await page.evaluate(() => document.fonts?.ready).catch(() => null);
|
||||
const size = await page.evaluate(() => ({
|
||||
width: Math.max(document.documentElement.scrollWidth, document.body.scrollWidth, 320),
|
||||
height: Math.max(document.documentElement.scrollHeight, document.body.scrollHeight, 320)
|
||||
}));
|
||||
await page.setViewportSize({
|
||||
width: clampDimension(size.width, DEFAULT_VIEWPORT_WIDTH, 2400),
|
||||
height: Math.min(clampDimension(size.height, DEFAULT_VIEWPORT_HEIGHT, MAX_LONG_IMAGE_HEIGHT), 2400)
|
||||
});
|
||||
await fsPromises.mkdir(path.dirname(destination), { recursive: true });
|
||||
await page.screenshot({
|
||||
path: destination,
|
||||
fullPage: true,
|
||||
type: "png",
|
||||
animations: "disabled",
|
||||
caret: "hide"
|
||||
});
|
||||
return {
|
||||
outputPath: destination,
|
||||
bytes: fs.statSync(destination).size
|
||||
};
|
||||
} finally {
|
||||
await browser?.close().catch(() => {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// mindspace-sandbox-mcp.mjs
|
||||
var SANDBOX_ROOT = process.argv[2]?.trim() || process.env.SANDBOX_ROOT?.trim();
|
||||
if (!SANDBOX_ROOT) {
|
||||
process.stderr.write("[mindspace-sandbox-mcp] SANDBOX_ROOT is not set \u2014 refusing to start\n");
|
||||
process.exit(1);
|
||||
}
|
||||
var SANDBOX = path.resolve(SANDBOX_ROOT);
|
||||
var PRIVATE_DATA_DIR = path.join(SANDBOX, ".mindspace");
|
||||
var PRIVATE_DATA_DB = path.join(PRIVATE_DATA_DIR, "private-data.sqlite");
|
||||
var SANDBOX = path2.resolve(SANDBOX_ROOT);
|
||||
var PRIVATE_DATA_DIR = path2.join(SANDBOX, ".mindspace");
|
||||
var PRIVATE_DATA_DB = path2.join(PRIVATE_DATA_DIR, "private-data.sqlite");
|
||||
var SQLITE_BIN = process.env.SQLITE_BIN?.trim() || "sqlite3";
|
||||
var PRIVATE_DATA_MAX_BYTES = Number(process.env.PRIVATE_DATA_MAX_BYTES ?? 20 * 1024 * 1024);
|
||||
var PRIVATE_DATA_QUERY_TIMEOUT_MS = Number(process.env.PRIVATE_DATA_QUERY_TIMEOUT_MS ?? 5e3);
|
||||
@@ -1150,11 +1237,11 @@ var allowedToolsEnv = process.env.ALLOWED_TOOLS?.trim();
|
||||
var ALLOWED_TOOLS = allowedToolsEnv ? new Set(allowedToolsEnv.split(",").map((s) => s.trim())) : null;
|
||||
function resolveSandboxed(p) {
|
||||
if (!p || typeof p !== "string") throw new Error("\u8DEF\u5F84\u53C2\u6570\u65E0\u6548");
|
||||
const resolved = path.isAbsolute(p) ? path.resolve(p) : path.resolve(SANDBOX, p);
|
||||
if (resolved !== SANDBOX && !resolved.startsWith(SANDBOX + path.sep)) {
|
||||
const resolved = path2.isAbsolute(p) ? path2.resolve(p) : path2.resolve(SANDBOX, p);
|
||||
if (resolved !== SANDBOX && !resolved.startsWith(SANDBOX + path2.sep)) {
|
||||
throw Object.assign(new Error(`\u8DEF\u5F84\u8D8A\u754C\uFF1A${p} \u4E0D\u5728\u5F53\u524D\u5DE5\u4F5C\u533A\u5185`), { code: "EACCES" });
|
||||
}
|
||||
if (resolved === PRIVATE_DATA_DIR || resolved.startsWith(PRIVATE_DATA_DIR + path.sep)) {
|
||||
if (resolved === PRIVATE_DATA_DIR || resolved.startsWith(PRIVATE_DATA_DIR + path2.sep)) {
|
||||
throw Object.assign(new Error("\u7981\u6B62\u76F4\u63A5\u8BBF\u95EE\u79C1\u6709\u6570\u636E\u76EE\u5F55\uFF0C\u8BF7\u4F7F\u7528 private_data_* \u5DE5\u5177"), { code: "EACCES" });
|
||||
}
|
||||
return resolved;
|
||||
@@ -1220,6 +1307,18 @@ var ALL_TOOLS = [
|
||||
required: ["path"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "generate_long_image",
|
||||
description: "\u7528 Playwright \u5C06\u5DE5\u4F5C\u533A\u5185\u7684 HTML \u9875\u9762\u6E32\u67D3\u4E3A\u6574\u9875 PNG \u957F\u56FE\u3002\u8F93\u51FA\u6587\u4EF6\u901A\u5E38\u4E3A public/<\u9875\u9762\u540D>.long.png\uFF0C\u5FC5\u987B\u5728\u5DE5\u4F5C\u533A\u5185\u3002",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
html_path: { type: "string", description: "HTML \u6587\u4EF6\u8DEF\u5F84\uFF0C\u5982 public/report.html" },
|
||||
output_path: { type: "string", description: "\u8F93\u51FA PNG \u8DEF\u5F84\uFF0C\u5982 public/report.long.png\uFF1B\u53EF\u9009" }
|
||||
},
|
||||
required: ["html_path"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "private_data_info",
|
||||
description: "\u67E5\u770B\u5F53\u524D\u7528\u6237\u201C\u7528\u6237\u79C1\u6709\u6570\u636E\u7A7A\u95F4\u201D\u7684\u72B6\u6001\u3002\u6BCF\u4E2A\u7528\u6237\u53EA\u6709\u4E00\u4E2A\u79C1\u6709 SQLite \u6570\u636E\u5E93\uFF0C\u9002\u5408\u4FDD\u5B58\u95EE\u5377\u3001\u8868\u5355\u3001\u6E05\u5355\u3001\u8C03\u7814\u6570\u636E\u548C\u5206\u6790\u4E2D\u95F4\u8868\uFF1B\u4E0D\u8981\u521B\u5EFA\u989D\u5916 SQLite \u6587\u4EF6\u3002",
|
||||
@@ -1348,17 +1447,17 @@ if (isScheduleConfigured()) {
|
||||
}
|
||||
var TOOLS = ALLOWED_TOOLS ? ALL_TOOLS.filter((t) => ALLOWED_TOOLS.has(t.name)) : ALL_TOOLS;
|
||||
function ensurePrivateDataDb() {
|
||||
fs.mkdirSync(PRIVATE_DATA_DIR, { recursive: true });
|
||||
if (!fs.existsSync(PRIVATE_DATA_DB)) {
|
||||
fs2.mkdirSync(PRIVATE_DATA_DIR, { recursive: true });
|
||||
if (!fs2.existsSync(PRIVATE_DATA_DB)) {
|
||||
runSqlite(["-batch", PRIVATE_DATA_DB, "PRAGMA journal_mode=WAL; PRAGMA user_version = 1;"]);
|
||||
}
|
||||
return PRIVATE_DATA_DB;
|
||||
}
|
||||
function privateDataSize() {
|
||||
let total = 0;
|
||||
for (const file of fs.existsSync(PRIVATE_DATA_DIR) ? fs.readdirSync(PRIVATE_DATA_DIR) : []) {
|
||||
for (const file of fs2.existsSync(PRIVATE_DATA_DIR) ? fs2.readdirSync(PRIVATE_DATA_DIR) : []) {
|
||||
if (file === "private-data.sqlite" || file.startsWith("private-data.sqlite-")) {
|
||||
total += fs.statSync(path.join(PRIVATE_DATA_DIR, file)).size;
|
||||
total += fs2.statSync(path2.join(PRIVATE_DATA_DIR, file)).size;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
@@ -1505,38 +1604,57 @@ async function callTool(name, args) {
|
||||
switch (name) {
|
||||
case "read_file": {
|
||||
const abs = resolveSandboxed(args.path);
|
||||
const content = fs.readFileSync(abs, "utf8");
|
||||
const content = fs2.readFileSync(abs, "utf8");
|
||||
return [{ type: "text", text: content }];
|
||||
}
|
||||
case "write_file": {
|
||||
const abs = resolveSandboxed(args.path);
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
fs.writeFileSync(abs, args.content ?? "", "utf8");
|
||||
fs2.mkdirSync(path2.dirname(abs), { recursive: true });
|
||||
fs2.writeFileSync(abs, args.content ?? "", "utf8");
|
||||
return [{ type: "text", text: `\u5DF2\u5199\u5165 ${args.path}\uFF08${(args.content ?? "").length} \u5B57\u8282\uFF09` }];
|
||||
}
|
||||
case "edit_file": {
|
||||
const abs = resolveSandboxed(args.path);
|
||||
const original = fs.readFileSync(abs, "utf8");
|
||||
const original = fs2.readFileSync(abs, "utf8");
|
||||
const oldStr = args.old_str ?? "";
|
||||
if (oldStr && !original.includes(oldStr)) {
|
||||
throw new Error("edit_file: old_str \u5728\u6587\u4EF6\u4E2D\u4E0D\u5B58\u5728\uFF0C\u66FF\u6362\u5931\u8D25");
|
||||
}
|
||||
const updated = oldStr ? original.replace(oldStr, args.new_str ?? "") : args.new_str ?? "";
|
||||
fs.writeFileSync(abs, updated, "utf8");
|
||||
fs2.writeFileSync(abs, updated, "utf8");
|
||||
return [{ type: "text", text: `\u5DF2\u7F16\u8F91 ${args.path}` }];
|
||||
}
|
||||
case "list_dir": {
|
||||
const target = args?.path ?? ".";
|
||||
const abs = resolveSandboxed(target);
|
||||
const entries = fs.readdirSync(abs, { withFileTypes: true });
|
||||
const entries = fs2.readdirSync(abs, { withFileTypes: true });
|
||||
const lines = entries.map((e) => `${e.isDirectory() ? "[\u76EE\u5F55]" : "[\u6587\u4EF6]"} ${e.name}`);
|
||||
return [{ type: "text", text: lines.join("\n") || "\uFF08\u7A7A\u76EE\u5F55\uFF09" }];
|
||||
}
|
||||
case "create_dir": {
|
||||
const abs = resolveSandboxed(args.path);
|
||||
fs.mkdirSync(abs, { recursive: true });
|
||||
fs2.mkdirSync(abs, { recursive: true });
|
||||
return [{ type: "text", text: `\u5DF2\u521B\u5EFA\u76EE\u5F55 ${args.path}` }];
|
||||
}
|
||||
case "generate_long_image": {
|
||||
const htmlPath = String(args.html_path ?? args.path ?? "").trim();
|
||||
if (!htmlPath.toLowerCase().endsWith(".html")) {
|
||||
throw new Error("generate_long_image: html_path \u5FC5\u987B\u662F .html \u6587\u4EF6");
|
||||
}
|
||||
const htmlAbs = resolveSandboxed(htmlPath);
|
||||
const outputPath = String(args.output_path ?? "").trim() || htmlPath.replace(/\.html$/i, ".long.png");
|
||||
if (!outputPath.toLowerCase().endsWith(".png")) {
|
||||
throw new Error("generate_long_image: output_path \u5FC5\u987B\u662F .png \u6587\u4EF6");
|
||||
}
|
||||
const outputAbs = resolveSandboxed(outputPath);
|
||||
const result = await renderLongImage({ htmlPath: htmlAbs, outputPath: outputAbs });
|
||||
return [
|
||||
{
|
||||
type: "text",
|
||||
text: `\u5DF2\u751F\u6210\u957F\u56FE ${outputPath}\uFF08${result.bytes} \u5B57\u8282\uFF09`
|
||||
}
|
||||
];
|
||||
}
|
||||
case "private_data_info": {
|
||||
ensurePrivateDataDb();
|
||||
const size = privateDataSize();
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"http-proxy-middleware": "^3.0.3",
|
||||
"jsonrepair": "^3.14.0",
|
||||
"mysql2": "^3.22.5",
|
||||
"playwright": "^1.61.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"redis": "^4.7.1",
|
||||
"sharp": "^0.35.2",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 61 KiB |
@@ -0,0 +1,50 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>微信分享预览 · 连云港三天两夜攻略 🌊</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; min-height: 100vh; padding: 24px; background: #ececec; font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, sans-serif; color: #111; }
|
||||
.wrap { max-width: 420px; margin: 0 auto; }
|
||||
h1 { margin: 0 0 8px; font-size: 18px; }
|
||||
.hint, .note { margin: 0 0 16px; color: #666; font-size: 13px; line-height: 1.6; }
|
||||
.card { display: grid; grid-template-columns: 1fr 72px; gap: 12px; padding: 12px; border-radius: 8px; background: #fff; box-shadow: 0 1px 0 rgba(0,0,0,.06); }
|
||||
.card-main { min-width: 0; }
|
||||
.card-title { margin: 0; font-size: 16px; font-weight: 600; line-height: 1.35; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.card-desc { margin: 6px 0 0; color: #888; font-size: 13px; line-height: 1.45; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.card-foot { display: flex; align-items: center; gap: 6px; margin-top: 10px; color: #999; font-size: 12px; }
|
||||
.card-foot img { width: 16px; height: 16px; border-radius: 999px; object-fit: cover; }
|
||||
.card-thumb { width: 72px; height: 72px; border-radius: 4px; object-fit: cover; background: #f3f3f3; }
|
||||
.card-thumb-empty { display: grid; place-items: center; color: #bbb; font-size: 12px; }
|
||||
.meta { margin-top: 18px; padding: 14px; border-radius: 10px; background: rgba(255,255,255,.72); font-size: 12px; line-height: 1.7; word-break: break-all; }
|
||||
code { background: rgba(0,0,0,.06); padding: 1px 4px; border-radius: 4px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1>微信链接卡片预览</h1>
|
||||
<p class="hint">本地模拟的是「粘贴链接后看到的卡片」,不是 JS-SDK 内部分享弹层。源链接:<code>https://m.tkmind.cn/MindSpace/john/public/lianyungang-guide.html</code></p>
|
||||
<p class="note">修复后:标题 + 描述 + 缩略图 + 底部「TKMind 智趣」小图标应同时出现。</p>
|
||||
<article class="card" aria-label="微信分享卡片预览">
|
||||
<div class="card-main">
|
||||
<h2 class="card-title">连云港三天两夜攻略 🌊</h2>
|
||||
<p class="card-desc">三天两夜·沙滩酒店·必吃美食</p>
|
||||
<div class="card-foot">
|
||||
<img src="http://127.0.0.1:8081/brand/tkmind-icon.png" alt="" onerror="this.style.display='none'">
|
||||
<span>TKMind 智趣</span>
|
||||
</div>
|
||||
</div>
|
||||
<img class="card-thumb" src="https://images.unsplash.com/photo-1507525428034-b723cf961d3e?auto=format&fit=crop&w=200&q=80" alt="">
|
||||
</article>
|
||||
<div class="meta">
|
||||
<div><strong>og:title</strong> 连云港三天两夜攻略 🌊</div>
|
||||
<div><strong>og:description</strong> 三天两夜·沙滩酒店·必吃美食</div>
|
||||
<div><strong>og:site_name</strong> TKMind 智趣</div>
|
||||
<div><strong>og:image</strong> https://images.unsplash.com/photo-1507525428034-b723cf961d3e?auto=format&fit=crop&w=200&q=80</div>
|
||||
<div><strong>icon</strong> /brand/tkmind-icon.png</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,41 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>微信分享预览工具</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; min-height: 100vh; padding: 24px; background: #f3f3f3; font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, sans-serif; color: #111; }
|
||||
.panel { max-width: 560px; margin: 0 auto; padding: 20px; border-radius: 16px; background: #fff; box-shadow: 0 8px 30px rgba(0,0,0,.06); }
|
||||
h1 { margin: 0 0 8px; font-size: 22px; }
|
||||
p { margin: 0 0 16px; color: #666; line-height: 1.6; font-size: 14px; }
|
||||
label { display: block; margin-bottom: 8px; font-size: 13px; font-weight: 600; }
|
||||
input { width: 100%; padding: 12px 14px; border: 1px solid #ddd; border-radius: 10px; font: inherit; }
|
||||
button { margin-top: 14px; width: 100%; padding: 12px 14px; border: 0; border-radius: 10px; background: #2f6f57; color: #fff; font: inherit; font-weight: 700; cursor: pointer; }
|
||||
code { background: rgba(0,0,0,.06); padding: 1px 4px; border-radius: 4px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="panel">
|
||||
<h1>微信分享预览</h1>
|
||||
<p>输入已发布页面路径,本地模拟微信「粘贴链接后」看到的卡片。开发环境走 Portal API:<code>/dev/wechat-share-preview</code></p>
|
||||
<form id="preview-form">
|
||||
<label for="url">页面 URL 或路径</label>
|
||||
<input id="url" name="url" placeholder="/MindSpace/john/public/demo.html" required>
|
||||
<button type="submit">生成预览</button>
|
||||
</form>
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('preview-form').addEventListener('submit', (event) => {
|
||||
event.preventDefault();
|
||||
const value = document.getElementById('url').value.trim();
|
||||
if (!value) return;
|
||||
const target = value.startsWith('http')
|
||||
? `/dev/wechat-share-preview?url=${encodeURIComponent(value)}`
|
||||
: `/dev/wechat-share-preview?url=${encodeURIComponent(value.startsWith('/') ? value : `/${value}`)}`;
|
||||
window.location.href = target;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -11,6 +11,8 @@ CREATE TABLE IF NOT EXISTS h5_users (
|
||||
status ENUM('active', 'suspended', 'disabled') NOT NULL DEFAULT 'active',
|
||||
plan_type VARCHAR(32) NOT NULL DEFAULT 'free',
|
||||
workspace_root VARCHAR(512) NOT NULL,
|
||||
low_balance_gift_eligible TINYINT(1) NOT NULL DEFAULT 0,
|
||||
low_balance_gift_granted_at BIGINT NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
updated_at BIGINT NOT NULL,
|
||||
UNIQUE KEY uq_h5_users_slug (slug),
|
||||
@@ -121,11 +123,14 @@ CREATE TABLE IF NOT EXISTS h5_upload_sessions (
|
||||
temporary_storage_key VARCHAR(512) NOT NULL,
|
||||
checksum CHAR(64) NULL,
|
||||
completed_asset_id CHAR(36) NULL,
|
||||
source_session_id VARCHAR(128) NULL,
|
||||
source_message_id VARCHAR(128) NULL,
|
||||
status ENUM('reserved', 'uploaded', 'completed', 'cancelled', 'expired', 'failed') NOT NULL DEFAULT 'reserved',
|
||||
expires_at BIGINT NOT NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
completed_at BIGINT NULL,
|
||||
KEY idx_h5_upload_user_status (user_id, status, expires_at),
|
||||
KEY idx_h5_upload_source_session (user_id, source_session_id),
|
||||
CONSTRAINT fk_h5_upload_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_h5_upload_space FOREIGN KEY (space_id) REFERENCES h5_user_spaces(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_h5_upload_category FOREIGN KEY (category_id) REFERENCES h5_space_categories(id) ON DELETE CASCADE
|
||||
@@ -348,6 +353,7 @@ CREATE TABLE IF NOT EXISTS h5_user_path_grants (
|
||||
CREATE TABLE IF NOT EXISTS h5_user_sessions (
|
||||
agent_session_id VARCHAR(128) NOT NULL PRIMARY KEY,
|
||||
user_id CHAR(36) NOT NULL,
|
||||
origin ENUM('h5', 'wechat') NOT NULL DEFAULT 'h5',
|
||||
created_at BIGINT NOT NULL,
|
||||
KEY idx_h5_user_sessions_user (user_id),
|
||||
CONSTRAINT fk_h5_session_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE
|
||||
@@ -383,6 +389,52 @@ CREATE TABLE IF NOT EXISTS h5_agent_run_events (
|
||||
CONSTRAINT fk_h5_agent_run_event_run FOREIGN KEY (run_id) REFERENCES h5_agent_runs(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS h5_conversation_packages (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
user_id CHAR(36) NOT NULL,
|
||||
session_id VARCHAR(128) NOT NULL,
|
||||
title VARCHAR(255) NULL,
|
||||
status ENUM('active', 'archived', 'deleted') NOT NULL DEFAULT 'active',
|
||||
storage_prefix VARCHAR(512) NULL,
|
||||
manifest_asset_id CHAR(36) NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
updated_at BIGINT NOT NULL,
|
||||
UNIQUE KEY uq_h5_conversation_package_session (user_id, session_id),
|
||||
KEY idx_h5_conversation_package_user_updated (user_id, updated_at),
|
||||
CONSTRAINT fk_h5_conversation_package_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_h5_conversation_package_manifest FOREIGN KEY (manifest_asset_id) REFERENCES h5_assets(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS h5_conversation_artifacts (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
package_id VARCHAR(64) NOT NULL,
|
||||
asset_id CHAR(36) NULL,
|
||||
page_id CHAR(36) NULL,
|
||||
publication_id CHAR(36) NULL,
|
||||
agent_run_id CHAR(36) NULL,
|
||||
message_id VARCHAR(128) NULL,
|
||||
role VARCHAR(32) NULL,
|
||||
artifact_kind VARCHAR(64) NOT NULL,
|
||||
display_name VARCHAR(255) NULL,
|
||||
mime_type VARCHAR(128) NULL,
|
||||
size_bytes BIGINT NULL,
|
||||
storage_key VARCHAR(512) NULL,
|
||||
canonical_url VARCHAR(512) NULL,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_at BIGINT NOT NULL,
|
||||
KEY idx_h5_conversation_artifact_package (package_id, sort_order, created_at),
|
||||
KEY idx_h5_conversation_artifact_asset (asset_id),
|
||||
KEY idx_h5_conversation_artifact_page (page_id),
|
||||
KEY idx_h5_conversation_artifact_publication (publication_id),
|
||||
KEY idx_h5_conversation_artifact_run (agent_run_id),
|
||||
KEY idx_h5_conversation_artifact_message (message_id),
|
||||
CONSTRAINT fk_h5_conversation_artifact_package FOREIGN KEY (package_id) REFERENCES h5_conversation_packages(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_h5_conversation_artifact_asset FOREIGN KEY (asset_id) REFERENCES h5_assets(id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_h5_conversation_artifact_page FOREIGN KEY (page_id) REFERENCES h5_page_records(id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_h5_conversation_artifact_publication FOREIGN KEY (publication_id) REFERENCES h5_publish_records(id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_h5_conversation_artifact_run FOREIGN KEY (agent_run_id) REFERENCES h5_agent_runs(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS h5_session_billing_state (
|
||||
agent_session_id VARCHAR(128) NOT NULL PRIMARY KEY,
|
||||
user_id CHAR(36) NOT NULL,
|
||||
|
||||
Executable
+346
@@ -0,0 +1,346 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import mysql from 'mysql2/promise';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const DEFAULT_WORKER_LABEL = 'cn.tkmind.memind-agent-run-worker';
|
||||
const DEFAULT_PORTAL_LABEL = 'cn.tkmind.memind-portal';
|
||||
|
||||
function loadEnvFile(filePath) {
|
||||
if (!fs.existsSync(filePath)) return;
|
||||
for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const idx = trimmed.indexOf('=');
|
||||
if (idx < 0) continue;
|
||||
const key = trimmed.slice(0, idx).trim();
|
||||
let value = trimmed.slice(idx + 1).trim();
|
||||
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
if (!process.env[key]) process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
function truthy(value) {
|
||||
return ['1', 'true', 'yes', 'on'].includes(String(value ?? '').trim().toLowerCase());
|
||||
}
|
||||
|
||||
function positiveInt(value, fallback) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {
|
||||
apply: argv.includes('--apply'),
|
||||
dryRun: argv.includes('--dry-run') || !argv.includes('--apply'),
|
||||
help: argv.includes('--help') || argv.includes('-h'),
|
||||
};
|
||||
return args;
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log([
|
||||
'Usage:',
|
||||
' node scripts/agent-run-guard.mjs [--dry-run]',
|
||||
' node scripts/agent-run-guard.mjs --apply',
|
||||
'',
|
||||
'Dry-run is the default. --apply can stop the external worker and disable code-run gate in .env.',
|
||||
].join('\n'));
|
||||
}
|
||||
|
||||
function parseMysqlConfig() {
|
||||
if (process.env.DATABASE_URL) {
|
||||
const url = new URL(process.env.DATABASE_URL);
|
||||
if (url.protocol !== 'mysql:') {
|
||||
throw new Error(`Unsupported DATABASE_URL scheme for agent run guard: ${url.protocol}`);
|
||||
}
|
||||
return {
|
||||
host: url.hostname,
|
||||
port: Number(url.port || 3306),
|
||||
user: decodeURIComponent(url.username),
|
||||
password: decodeURIComponent(url.password),
|
||||
database: url.pathname.replace(/^\/+/, ''),
|
||||
charset: 'utf8mb4',
|
||||
};
|
||||
}
|
||||
return {
|
||||
host: process.env.MYSQL_HOST,
|
||||
port: Number(process.env.MYSQL_PORT || 3306),
|
||||
user: process.env.MYSQL_USER,
|
||||
password: process.env.MYSQL_PASSWORD,
|
||||
database: process.env.MYSQL_DATABASE,
|
||||
charset: 'utf8mb4',
|
||||
};
|
||||
}
|
||||
|
||||
async function runCommand(command, args) {
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync(command, args, { maxBuffer: 1024 * 1024 });
|
||||
return { ok: true, stdout, stderr };
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
code: err?.code ?? null,
|
||||
stdout: err?.stdout ?? '',
|
||||
stderr: err?.stderr ?? '',
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function replaceOrAppendEnv(raw, updates) {
|
||||
const pending = new Map(Object.entries(updates));
|
||||
const lines = raw.split('\n');
|
||||
const next = lines.map((line) => {
|
||||
const match = line.match(/^(\s*)([A-Za-z_][A-Za-z0-9_]*)(\s*=).*/);
|
||||
if (!match) return line;
|
||||
const key = match[2];
|
||||
if (!pending.has(key)) return line;
|
||||
const value = pending.get(key);
|
||||
pending.delete(key);
|
||||
return `${key}=${value}`;
|
||||
});
|
||||
if (pending.size > 0) {
|
||||
if (next.length > 0 && next[next.length - 1] !== '') next.push('');
|
||||
next.push('## agent-run guard auto-disable');
|
||||
for (const [key, value] of pending) next.push(`${key}=${value}`);
|
||||
}
|
||||
return next.join('\n').replace(/\n*$/, '\n');
|
||||
}
|
||||
|
||||
function timestamp() {
|
||||
const d = new Date();
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
async function readQueueHealth(now) {
|
||||
const conn = await mysql.createConnection(parseMysqlConfig());
|
||||
try {
|
||||
const [statusRows] = await conn.query(
|
||||
`SELECT status, COUNT(*) AS count
|
||||
FROM h5_agent_runs
|
||||
WHERE status IN ('queued', 'running', 'retryable')
|
||||
GROUP BY status`,
|
||||
);
|
||||
const statusCounts = {};
|
||||
for (const row of statusRows) statusCounts[row.status] = Number(row.count ?? 0);
|
||||
|
||||
const [oldestPendingRows] = await conn.query(
|
||||
`SELECT MIN(updated_at) AS oldest_updated_at
|
||||
FROM h5_agent_runs
|
||||
WHERE status IN ('queued', 'retryable')`,
|
||||
);
|
||||
const oldestPendingUpdatedAt = oldestPendingRows[0]?.oldest_updated_at == null
|
||||
? null
|
||||
: Number(oldestPendingRows[0].oldest_updated_at);
|
||||
|
||||
const [oldestRunningRows] = await conn.query(
|
||||
`SELECT
|
||||
r.id,
|
||||
r.request_id,
|
||||
r.started_at,
|
||||
r.updated_at,
|
||||
h.latest_heartbeat_at
|
||||
FROM h5_agent_runs r
|
||||
LEFT JOIN (
|
||||
SELECT run_id, MAX(created_at) AS latest_heartbeat_at
|
||||
FROM h5_agent_run_events
|
||||
WHERE event_type = 'worker_heartbeat'
|
||||
GROUP BY run_id
|
||||
) h ON h.run_id = r.id
|
||||
WHERE r.status = 'running'
|
||||
ORDER BY COALESCE(h.latest_heartbeat_at, r.started_at) ASC
|
||||
LIMIT 1`,
|
||||
);
|
||||
const oldestRunningStartedAt = oldestRunningRows[0]?.started_at == null
|
||||
? null
|
||||
: Number(oldestRunningRows[0].started_at);
|
||||
const oldestRunningHeartbeatAt = oldestRunningRows[0]?.latest_heartbeat_at == null
|
||||
? null
|
||||
: Number(oldestRunningRows[0].latest_heartbeat_at);
|
||||
const oldestRunningHeartbeatAgeMs = oldestRunningRows[0]
|
||||
? Math.max(0, now - Number(oldestRunningRows[0].latest_heartbeat_at ?? oldestRunningRows[0].started_at ?? now))
|
||||
: 0;
|
||||
const [runningWithoutHeartbeatRows] = await conn.query(
|
||||
`SELECT COUNT(*) AS count
|
||||
FROM h5_agent_runs r
|
||||
LEFT JOIN (
|
||||
SELECT run_id, MAX(created_at) AS latest_heartbeat_at
|
||||
FROM h5_agent_run_events
|
||||
WHERE event_type = 'worker_heartbeat'
|
||||
GROUP BY run_id
|
||||
) h ON h.run_id = r.id
|
||||
WHERE r.status = 'running' AND h.latest_heartbeat_at IS NULL`,
|
||||
);
|
||||
|
||||
const failedWindowMs = positiveInt(process.env.MEMIND_AGENT_RUN_GUARD_FAILED_WINDOW_MS, 10 * 60 * 1000);
|
||||
const since = now - failedWindowMs;
|
||||
const [failedRows] = await conn.query(
|
||||
`SELECT COUNT(*) AS count
|
||||
FROM h5_agent_runs
|
||||
WHERE status = 'failed' AND updated_at >= ?`,
|
||||
[since],
|
||||
);
|
||||
const [latestFailedRows] = await conn.query(
|
||||
`SELECT id, request_id, error_message, updated_at
|
||||
FROM h5_agent_runs
|
||||
WHERE status = 'failed'
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 1`,
|
||||
);
|
||||
|
||||
return {
|
||||
statusCounts,
|
||||
queuedOrRetryable: Number(statusCounts.queued ?? 0) + Number(statusCounts.retryable ?? 0),
|
||||
running: Number(statusCounts.running ?? 0),
|
||||
oldestPendingUpdatedAt,
|
||||
oldestPendingAgeMs: oldestPendingUpdatedAt == null ? 0 : Math.max(0, now - oldestPendingUpdatedAt),
|
||||
oldestRunningStartedAt,
|
||||
oldestRunningAgeMs: oldestRunningStartedAt == null ? 0 : Math.max(0, now - oldestRunningStartedAt),
|
||||
oldestRunningHeartbeatAt,
|
||||
oldestRunningHeartbeatAgeMs,
|
||||
runningWithoutHeartbeatCount: Number(runningWithoutHeartbeatRows[0]?.count ?? 0),
|
||||
latestRunningRun: oldestRunningRows[0] ? {
|
||||
id: oldestRunningRows[0].id,
|
||||
requestId: oldestRunningRows[0].request_id,
|
||||
startedAt: oldestRunningStartedAt,
|
||||
updatedAt: oldestRunningRows[0].updated_at == null ? null : Number(oldestRunningRows[0].updated_at),
|
||||
heartbeatAt: oldestRunningHeartbeatAt,
|
||||
heartbeatAgeMs: oldestRunningHeartbeatAgeMs,
|
||||
} : null,
|
||||
failedWindowMs,
|
||||
failedRecentCount: Number(failedRows[0]?.count ?? 0),
|
||||
latestFailedRun: latestFailedRows[0] ? {
|
||||
id: latestFailedRows[0].id,
|
||||
requestId: latestFailedRows[0].request_id,
|
||||
error: latestFailedRows[0].error_message,
|
||||
updatedAt: Number(latestFailedRows[0].updated_at ?? 0),
|
||||
} : null,
|
||||
};
|
||||
} finally {
|
||||
await conn.end();
|
||||
}
|
||||
}
|
||||
|
||||
function evaluateHealth(queue) {
|
||||
const thresholds = {
|
||||
maxRecentFailures: positiveInt(process.env.MEMIND_AGENT_RUN_GUARD_MAX_RECENT_FAILURES, 3),
|
||||
maxPendingAgeMs: positiveInt(process.env.MEMIND_AGENT_RUN_GUARD_MAX_PENDING_AGE_MS, 5 * 60 * 1000),
|
||||
maxPendingCount: positiveInt(process.env.MEMIND_AGENT_RUN_GUARD_MAX_PENDING_COUNT, 10),
|
||||
maxRunningAgeMs: positiveInt(process.env.MEMIND_AGENT_RUN_GUARD_MAX_RUNNING_AGE_MS, 15 * 60 * 1000),
|
||||
};
|
||||
const reasons = [];
|
||||
if (queue.failedRecentCount >= thresholds.maxRecentFailures) {
|
||||
reasons.push(`recent_failed_count ${queue.failedRecentCount} >= ${thresholds.maxRecentFailures}`);
|
||||
}
|
||||
if (queue.oldestPendingAgeMs >= thresholds.maxPendingAgeMs) {
|
||||
reasons.push(`oldest_pending_age_ms ${queue.oldestPendingAgeMs} >= ${thresholds.maxPendingAgeMs}`);
|
||||
}
|
||||
if (queue.queuedOrRetryable >= thresholds.maxPendingCount) {
|
||||
reasons.push(`pending_count ${queue.queuedOrRetryable} >= ${thresholds.maxPendingCount}`);
|
||||
}
|
||||
if (queue.oldestRunningHeartbeatAgeMs >= thresholds.maxRunningAgeMs) {
|
||||
reasons.push(`oldest_running_heartbeat_age_ms ${queue.oldestRunningHeartbeatAgeMs} >= ${thresholds.maxRunningAgeMs}`);
|
||||
}
|
||||
return { thresholds, reasons, shouldPause: reasons.length > 0 };
|
||||
}
|
||||
|
||||
async function disableCodeRuns({ root, envFile, workerLabel, portalLabel, gui, reasons, dryRun }) {
|
||||
const actions = [];
|
||||
const updates = {
|
||||
MEMIND_AGENT_CODE_RUNS_ENABLED: '0',
|
||||
MEMIND_AGENT_RUN_AUTODISPATCH: '1',
|
||||
};
|
||||
if (dryRun) {
|
||||
return {
|
||||
applied: false,
|
||||
actions: [
|
||||
`would backup ${envFile}`,
|
||||
`would set ${Object.entries(updates).map(([k, v]) => `${k}=${v}`).join(', ')}`,
|
||||
`would stop and disable ${workerLabel}`,
|
||||
`would kickstart ${portalLabel}`,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const backupRoot = process.env.MEMIND_AGENT_RUN_GUARD_BACKUP_DIR
|
||||
|| path.join(path.dirname(root), 'memind_backups', `${timestamp()}-agent-run-guard-pause`);
|
||||
fs.mkdirSync(backupRoot, { recursive: true });
|
||||
const envBackup = path.join(backupRoot, '.env.before');
|
||||
fs.copyFileSync(envFile, envBackup);
|
||||
actions.push(`backed_up_env:${envBackup}`);
|
||||
|
||||
const raw = fs.readFileSync(envFile, 'utf8');
|
||||
const updated = replaceOrAppendEnv(raw, updates);
|
||||
const marker = [
|
||||
'',
|
||||
`# agent-run guard pause at ${new Date().toISOString()}`,
|
||||
`# reasons: ${reasons.join('; ')}`,
|
||||
].join('\n');
|
||||
fs.writeFileSync(envFile, `${updated.replace(/\n*$/, '\n')}${marker}\n`, 'utf8');
|
||||
actions.push(`updated_env:${envFile}`);
|
||||
|
||||
await runCommand('launchctl', ['bootout', `${gui}/${workerLabel}`]);
|
||||
await runCommand('launchctl', ['disable', `${gui}/${workerLabel}`]);
|
||||
actions.push(`disabled_worker:${workerLabel}`);
|
||||
|
||||
const kick = await runCommand('launchctl', ['kickstart', '-k', `${gui}/${portalLabel}`]);
|
||||
actions.push(kick.ok ? `kickstarted_portal:${portalLabel}` : `portal_kickstart_failed:${kick.message}`);
|
||||
return { applied: true, backupRoot, actions };
|
||||
}
|
||||
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (args.help) {
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const root = path.join(path.dirname(new URL(import.meta.url).pathname), '..');
|
||||
const envFile = process.env.MEMIND_ENV_FILE || path.join(root, '.env');
|
||||
loadEnvFile(envFile);
|
||||
|
||||
const enabled = process.env.MEMIND_AGENT_RUN_GUARD_ENABLED == null
|
||||
? true
|
||||
: truthy(process.env.MEMIND_AGENT_RUN_GUARD_ENABLED);
|
||||
const workerLabel = process.env.MEMIND_AGENT_RUN_WORKER_LABEL || DEFAULT_WORKER_LABEL;
|
||||
const portalLabel = process.env.MEMIND_PORTAL_LABEL || DEFAULT_PORTAL_LABEL;
|
||||
const gui = `gui/${process.getuid()}`;
|
||||
const now = Date.now();
|
||||
const queue = await readQueueHealth(now).catch((err) => ({
|
||||
error: err instanceof Error ? (err.message || err.code || err.name) : String(err),
|
||||
}));
|
||||
const evaluation = queue.error
|
||||
? { thresholds: {}, reasons: [`queue_health_error ${queue.error}`], shouldPause: false }
|
||||
: evaluateHealth(queue);
|
||||
let pause = { applied: false, actions: [] };
|
||||
|
||||
if (enabled && evaluation.shouldPause) {
|
||||
pause = await disableCodeRuns({
|
||||
root,
|
||||
envFile,
|
||||
workerLabel,
|
||||
portalLabel,
|
||||
gui,
|
||||
reasons: evaluation.reasons,
|
||||
dryRun: args.dryRun,
|
||||
});
|
||||
}
|
||||
|
||||
const result = {
|
||||
ok: enabled ? !evaluation.shouldPause || pause.applied || args.dryRun : true,
|
||||
checkedAt: new Date(now).toISOString(),
|
||||
enabled,
|
||||
mode: args.apply ? 'apply' : 'dry-run',
|
||||
queue,
|
||||
evaluation,
|
||||
pause,
|
||||
};
|
||||
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
process.exit(result.ok ? 0 : 1);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -144,6 +144,8 @@ const buildFlags = {
|
||||
serverCodeRunsEnabledTruthy: truthy(process.env.MEMIND_AGENT_CODE_RUNS_ENABLED),
|
||||
enabledEnv: process.env.VITE_AGENT_CODE_RUNS_ENABLED ?? null,
|
||||
autodetectEnv: process.env.VITE_AGENT_CODE_RUNS_AUTODETECT ?? null,
|
||||
enabledUserIdsEnv: process.env.VITE_AGENT_CODE_RUNS_USER_IDS ?? null,
|
||||
serverCodeRunUserIdsEnv: process.env.MEMIND_AGENT_CODE_RUNS_USER_IDS ?? null,
|
||||
enabledTruthy: truthy(process.env.VITE_AGENT_CODE_RUNS_ENABLED),
|
||||
autodetectTruthy: truthy(process.env.VITE_AGENT_CODE_RUNS_AUTODETECT),
|
||||
note: 'These env values describe the current shell, not necessarily the already-built dist.',
|
||||
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import mysql from 'mysql2/promise';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const DEFAULT_LABEL = 'cn.tkmind.memind-agent-run-worker';
|
||||
|
||||
function loadEnvFile(filePath) {
|
||||
if (!fs.existsSync(filePath)) return;
|
||||
for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const idx = trimmed.indexOf('=');
|
||||
if (idx < 0) continue;
|
||||
const key = trimmed.slice(0, idx).trim();
|
||||
const value = trimmed.slice(idx + 1).trim();
|
||||
if (!process.env[key]) process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
function truthy(value) {
|
||||
return ['1', 'true', 'yes', 'on'].includes(String(value ?? '').trim().toLowerCase());
|
||||
}
|
||||
|
||||
function parseMysqlConfig() {
|
||||
if (process.env.DATABASE_URL) {
|
||||
const url = new URL(process.env.DATABASE_URL);
|
||||
if (url.protocol !== 'mysql:') {
|
||||
throw new Error(`Unsupported DATABASE_URL scheme for agent run worker check: ${url.protocol}`);
|
||||
}
|
||||
return {
|
||||
host: url.hostname,
|
||||
port: Number(url.port || 3306),
|
||||
user: decodeURIComponent(url.username),
|
||||
password: decodeURIComponent(url.password),
|
||||
database: url.pathname.replace(/^\/+/, ''),
|
||||
charset: 'utf8mb4',
|
||||
};
|
||||
}
|
||||
return {
|
||||
host: process.env.MYSQL_HOST,
|
||||
port: Number(process.env.MYSQL_PORT || 3306),
|
||||
user: process.env.MYSQL_USER,
|
||||
password: process.env.MYSQL_PASSWORD,
|
||||
database: process.env.MYSQL_DATABASE,
|
||||
charset: 'utf8mb4',
|
||||
};
|
||||
}
|
||||
|
||||
async function runCommand(command, args) {
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync(command, args, {
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
return { ok: true, stdout, stderr };
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
code: err?.code ?? null,
|
||||
stdout: err?.stdout ?? '',
|
||||
stderr: err?.stderr ?? '',
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function parseLaunchctlPrint(text) {
|
||||
const state = text.match(/\bstate = ([^\n]+)/)?.[1]?.trim() ?? null;
|
||||
const pid = text.match(/\bpid = ([0-9]+)/)?.[1] ?? null;
|
||||
const program = text.match(/\bprogram = ([^\n]+)/)?.[1]?.trim() ?? null;
|
||||
const pathValue = text.match(/\bpath = ([^\n]+)/)?.[1]?.trim() ?? null;
|
||||
return {
|
||||
loaded: Boolean(state || pid || program || pathValue),
|
||||
state,
|
||||
pid: pid == null ? null : Number(pid),
|
||||
program,
|
||||
path: pathValue,
|
||||
};
|
||||
}
|
||||
|
||||
function parseDisabled(text, label) {
|
||||
const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const match = text.match(new RegExp(`"${escaped}"\\s*=>\\s*(disabled|enabled)`));
|
||||
return match?.[1] === 'disabled';
|
||||
}
|
||||
|
||||
async function readQueueSummary() {
|
||||
const conn = await mysql.createConnection(parseMysqlConfig());
|
||||
try {
|
||||
const [statusRows] = await conn.query(
|
||||
`SELECT status, COUNT(*) AS count
|
||||
FROM h5_agent_runs
|
||||
WHERE status IN ('queued', 'running', 'retryable')
|
||||
GROUP BY status`,
|
||||
);
|
||||
const statusCounts = {};
|
||||
for (const row of statusRows) statusCounts[row.status] = Number(row.count ?? 0);
|
||||
|
||||
const [lagRows] = await conn.query(
|
||||
`SELECT MIN(updated_at) AS oldest_updated_at
|
||||
FROM h5_agent_runs
|
||||
WHERE status IN ('queued', 'retryable')`,
|
||||
);
|
||||
const oldestUpdatedAt = lagRows[0]?.oldest_updated_at == null
|
||||
? null
|
||||
: Number(lagRows[0].oldest_updated_at);
|
||||
|
||||
const [runningRows] = await conn.query(
|
||||
`SELECT
|
||||
r.id,
|
||||
r.request_id,
|
||||
r.started_at,
|
||||
r.updated_at,
|
||||
h.latest_heartbeat_at
|
||||
FROM h5_agent_runs r
|
||||
LEFT JOIN (
|
||||
SELECT run_id, MAX(created_at) AS latest_heartbeat_at
|
||||
FROM h5_agent_run_events
|
||||
WHERE event_type = 'worker_heartbeat'
|
||||
GROUP BY run_id
|
||||
) h ON h.run_id = r.id
|
||||
WHERE r.status = 'running'
|
||||
ORDER BY COALESCE(h.latest_heartbeat_at, r.started_at) ASC
|
||||
LIMIT 1`,
|
||||
);
|
||||
const oldestRunningStartedAt = runningRows[0]?.started_at == null
|
||||
? null
|
||||
: Number(runningRows[0].started_at);
|
||||
const oldestRunningHeartbeatAt = runningRows[0]?.latest_heartbeat_at == null
|
||||
? null
|
||||
: Number(runningRows[0].latest_heartbeat_at);
|
||||
const oldestRunningHeartbeatAgeMs = runningRows[0]
|
||||
? Math.max(0, Date.now() - Number(runningRows[0].latest_heartbeat_at ?? runningRows[0].started_at ?? Date.now()))
|
||||
: 0;
|
||||
const [runningWithoutHeartbeatRows] = await conn.query(
|
||||
`SELECT COUNT(*) AS count
|
||||
FROM h5_agent_runs r
|
||||
LEFT JOIN (
|
||||
SELECT run_id, MAX(created_at) AS latest_heartbeat_at
|
||||
FROM h5_agent_run_events
|
||||
WHERE event_type = 'worker_heartbeat'
|
||||
GROUP BY run_id
|
||||
) h ON h.run_id = r.id
|
||||
WHERE r.status = 'running' AND h.latest_heartbeat_at IS NULL`,
|
||||
);
|
||||
|
||||
const [failedRows] = await conn.query(
|
||||
`SELECT id, request_id, error_message, updated_at
|
||||
FROM h5_agent_runs
|
||||
WHERE status = 'failed'
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 1`,
|
||||
);
|
||||
|
||||
return {
|
||||
statusCounts,
|
||||
oldestPendingUpdatedAt: oldestUpdatedAt,
|
||||
oldestPendingAgeMs: oldestUpdatedAt == null ? 0 : Math.max(0, Date.now() - oldestUpdatedAt),
|
||||
oldestRunningStartedAt,
|
||||
oldestRunningAgeMs: oldestRunningStartedAt == null ? 0 : Math.max(0, Date.now() - oldestRunningStartedAt),
|
||||
oldestRunningHeartbeatAt,
|
||||
oldestRunningHeartbeatAgeMs,
|
||||
runningWithoutHeartbeatCount: Number(runningWithoutHeartbeatRows[0]?.count ?? 0),
|
||||
latestRunningRun: runningRows[0] ? {
|
||||
id: runningRows[0].id,
|
||||
requestId: runningRows[0].request_id,
|
||||
startedAt: oldestRunningStartedAt,
|
||||
updatedAt: runningRows[0].updated_at == null ? null : Number(runningRows[0].updated_at),
|
||||
heartbeatAt: oldestRunningHeartbeatAt,
|
||||
heartbeatAgeMs: oldestRunningHeartbeatAgeMs,
|
||||
} : null,
|
||||
latestFailedRun: failedRows[0] ? {
|
||||
id: failedRows[0].id,
|
||||
requestId: failedRows[0].request_id,
|
||||
error: failedRows[0].error_message,
|
||||
updatedAt: Number(failedRows[0].updated_at ?? 0),
|
||||
} : null,
|
||||
};
|
||||
} finally {
|
||||
await conn.end();
|
||||
}
|
||||
}
|
||||
|
||||
const root = path.join(path.dirname(new URL(import.meta.url).pathname), '..');
|
||||
loadEnvFile(process.env.MEMIND_ENV_FILE || path.join(root, '.env'));
|
||||
|
||||
const label = process.env.MEMIND_AGENT_RUN_WORKER_LABEL || DEFAULT_LABEL;
|
||||
const gui = `gui/${process.getuid()}`;
|
||||
const plist = path.join(process.env.HOME || '', 'Library', 'LaunchAgents', `${label}.plist`);
|
||||
const expectRunning = truthy(process.env.MEMIND_AGENT_RUN_WORKER_EXPECT_RUNNING);
|
||||
|
||||
const printResult = await runCommand('launchctl', ['print', `${gui}/${label}`]);
|
||||
const disabledResult = await runCommand('launchctl', ['print-disabled', gui]);
|
||||
const pgrepResult = await runCommand('pgrep', ['-fl', 'agent-run-worker.mjs']);
|
||||
const launchd = parseLaunchctlPrint(`${printResult.stdout}\n${printResult.stderr}`);
|
||||
const disabled = disabledResult.ok ? parseDisabled(disabledResult.stdout, label) : null;
|
||||
const pgrepLines = pgrepResult.ok
|
||||
? pgrepResult.stdout.split('\n').map((line) => line.trim()).filter(Boolean)
|
||||
: [];
|
||||
const queue = await readQueueSummary().catch((err) => ({
|
||||
error: err instanceof Error ? (err.message || err.code || err.name) : String(err),
|
||||
}));
|
||||
|
||||
const installed = fs.existsSync(plist);
|
||||
const running = launchd.state === 'running' || pgrepLines.length > 0;
|
||||
const ok = Boolean(
|
||||
installed &&
|
||||
!queue.error &&
|
||||
(expectRunning ? running : disabled === true && !running),
|
||||
);
|
||||
|
||||
console.log(JSON.stringify({
|
||||
ok,
|
||||
checkedAt: new Date().toISOString(),
|
||||
expected: expectRunning ? 'running' : 'disabled',
|
||||
label,
|
||||
plist,
|
||||
installed,
|
||||
disabled,
|
||||
running,
|
||||
launchd,
|
||||
processes: pgrepLines,
|
||||
queue,
|
||||
}, null, 2));
|
||||
|
||||
process.exit(ok ? 0 : 1);
|
||||
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Scan MindSpace public/*.html for broken relative links.
|
||||
*
|
||||
* Default (--downloads-only): attachment / download links only (release gate).
|
||||
* --all-links: also check img/src and mindspace-cover assets.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/check-mindspace-public-links.mjs
|
||||
* node scripts/check-mindspace-public-links.mjs --user <uuid>
|
||||
* node scripts/check-mindspace-public-links.mjs --root /path/to/MindSpace
|
||||
* node scripts/check-mindspace-public-links.mjs --all-links
|
||||
*/
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { scanPublicHtmlLinks } from '../mindspace-public-links.mjs';
|
||||
|
||||
const repoRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const DEFAULT_PUBLISH_ROOT = 'MindSpace';
|
||||
|
||||
function parseArgs(argv) {
|
||||
let root = path.join(repoRoot, DEFAULT_PUBLISH_ROOT);
|
||||
let userId = null;
|
||||
let allLinks = false;
|
||||
for (let i = 2; i < argv.length; i += 1) {
|
||||
if (argv[i] === '--root' && argv[i + 1]) {
|
||||
root = path.resolve(argv[i + 1]);
|
||||
i += 1;
|
||||
} else if (argv[i] === '--user' && argv[i + 1]) {
|
||||
userId = argv[i + 1];
|
||||
i += 1;
|
||||
} else if (argv[i] === '--all-links') {
|
||||
allLinks = true;
|
||||
} else if (argv[i] === '--downloads-only') {
|
||||
allLinks = false;
|
||||
} else if (argv[i] === '--help' || argv[i] === '-h') {
|
||||
console.log(
|
||||
`Usage: node scripts/check-mindspace-public-links.mjs [--root ${DEFAULT_PUBLISH_ROOT}] [--user <uuid>] [--downloads-only|--all-links]`,
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
return { root, userId, downloadsOnly: !allLinks };
|
||||
}
|
||||
|
||||
const { root, userId, downloadsOnly } = parseArgs(process.argv);
|
||||
const issues = scanPublicHtmlLinks(root, { userId, downloadsOnly });
|
||||
const modeLabel = downloadsOnly ? 'download/attachment links' : 'all relative links';
|
||||
|
||||
if (issues.length === 0) {
|
||||
console.log(`OK: no missing ${modeLabel} under ${root}${userId ? ` (user ${userId})` : ''}`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.error(`Found ${issues.length} missing ${modeLabel} under ${root}:`);
|
||||
for (const issue of issues) {
|
||||
const relHtml = path.relative(root, issue.htmlPath);
|
||||
console.error(`- ${relHtml}: ${issue.ref} (${issue.source})`);
|
||||
}
|
||||
process.exit(1);
|
||||
@@ -97,6 +97,19 @@ try {
|
||||
chatInjectsCodeTools: false,
|
||||
aiderTimeoutMs: Number(process.env.MEMIND_AIDER_TIMEOUT_MS ?? 600_000),
|
||||
openhandsTimeoutMs: Number(process.env.MEMIND_OPENHANDS_TIMEOUT_MS ?? 900_000),
|
||||
toolGateway: {
|
||||
enabled: ['1', 'true', 'yes', 'on'].includes(
|
||||
String(process.env.MEMIND_TOOL_GATEWAY_ENABLED ?? '').trim().toLowerCase(),
|
||||
),
|
||||
dryRun: ['1', 'true', 'yes', 'on'].includes(
|
||||
String(process.env.MEMIND_TOOL_GATEWAY_DRY_RUN ?? '').trim().toLowerCase(),
|
||||
),
|
||||
protocol: 'agent-run-v1',
|
||||
defaultExecutor: process.env.MEMIND_TOOL_GATEWAY_DEFAULT_EXECUTOR ?? 'aider',
|
||||
openhandsTaskTypes:
|
||||
process.env.MEMIND_TOOL_GATEWAY_OPENHANDS_TASK_TYPES ??
|
||||
'repo_refactor,multi_file,complex_repo',
|
||||
},
|
||||
},
|
||||
}, null, 2));
|
||||
process.exit(ok ? 0 : 1);
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
NODE_BIN="${NODE_BIN:-/opt/homebrew/opt/node@24/bin/node}"
|
||||
SCRIPT="${MEMIND_AGENT_RUN_GUARD_SCRIPT:-$ROOT/scripts/agent-run-guard.mjs}"
|
||||
LABEL="${MEMIND_AGENT_RUN_GUARD_LABEL:-cn.tkmind.memind-agent-run-guard}"
|
||||
PLIST="$HOME/Library/LaunchAgents/${LABEL}.plist"
|
||||
LOG="${MEMIND_AGENT_RUN_GUARD_LOG:-$HOME/Library/Logs/memind-agent-run-guard.log}"
|
||||
GUI="gui/$(id -u)"
|
||||
INTERVAL="${MEMIND_AGENT_RUN_GUARD_INTERVAL_SECONDS:-60}"
|
||||
START="${MEMIND_AGENT_RUN_GUARD_START:-1}"
|
||||
|
||||
mkdir -p "$HOME/Library/LaunchAgents" "$(dirname "$LOG")"
|
||||
|
||||
if [[ ! -x "$NODE_BIN" ]]; then
|
||||
NODE_BIN="$(command -v node)"
|
||||
fi
|
||||
if [[ ! -f "$SCRIPT" ]]; then
|
||||
echo "agent run guard script not found: $SCRIPT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cat > "$PLIST" <<EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>$LABEL</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>$NODE_BIN</string>
|
||||
<string>$SCRIPT</string>
|
||||
<string>--apply</string>
|
||||
</array>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>$ROOT</string>
|
||||
<key>StartInterval</key>
|
||||
<integer>$INTERVAL</integer>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>StandardOutPath</key>
|
||||
<string>$LOG</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>$LOG</string>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>PATH</key>
|
||||
<string>/opt/homebrew/bin:/opt/homebrew/opt/node@24/bin:/usr/local/bin:/usr/bin:/bin</string>
|
||||
<key>MEMIND_AGENT_RUN_GUARD_ENABLED</key>
|
||||
<string>${MEMIND_AGENT_RUN_GUARD_ENABLED:-1}</string>
|
||||
<key>MEMIND_AGENT_RUN_GUARD_FAILED_WINDOW_MS</key>
|
||||
<string>${MEMIND_AGENT_RUN_GUARD_FAILED_WINDOW_MS:-600000}</string>
|
||||
<key>MEMIND_AGENT_RUN_GUARD_MAX_RECENT_FAILURES</key>
|
||||
<string>${MEMIND_AGENT_RUN_GUARD_MAX_RECENT_FAILURES:-3}</string>
|
||||
<key>MEMIND_AGENT_RUN_GUARD_MAX_PENDING_AGE_MS</key>
|
||||
<string>${MEMIND_AGENT_RUN_GUARD_MAX_PENDING_AGE_MS:-300000}</string>
|
||||
<key>MEMIND_AGENT_RUN_GUARD_MAX_PENDING_COUNT</key>
|
||||
<string>${MEMIND_AGENT_RUN_GUARD_MAX_PENDING_COUNT:-10}</string>
|
||||
<key>MEMIND_AGENT_RUN_GUARD_MAX_RUNNING_AGE_MS</key>
|
||||
<string>${MEMIND_AGENT_RUN_GUARD_MAX_RUNNING_AGE_MS:-900000}</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
|
||||
plutil -lint "$PLIST"
|
||||
launchctl bootout "$GUI/$LABEL" 2>/dev/null || true
|
||||
launchctl bootstrap "$GUI" "$PLIST"
|
||||
|
||||
if [[ "$START" == "1" || "$START" == "true" || "$START" == "yes" ]]; then
|
||||
launchctl enable "$GUI/$LABEL"
|
||||
launchctl kickstart -k "$GUI/$LABEL"
|
||||
state="started"
|
||||
else
|
||||
launchctl disable "$GUI/$LABEL" 2>/dev/null || true
|
||||
state="installed-disabled"
|
||||
fi
|
||||
|
||||
echo "installed $PLIST"
|
||||
echo "state: $state"
|
||||
echo "script: $SCRIPT"
|
||||
echo "interval_seconds: $INTERVAL"
|
||||
echo "log: $LOG"
|
||||
echo "manual stop: launchctl bootout $GUI/$LABEL"
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
NODE_BIN="${NODE_BIN:-/opt/homebrew/opt/node@24/bin/node}"
|
||||
SCRIPT="${MEMIND_AGENT_RUN_WORKER_SCRIPT:-$ROOT/scripts/agent-run-worker.mjs}"
|
||||
LABEL="${MEMIND_AGENT_RUN_WORKER_LABEL:-cn.tkmind.memind-agent-run-worker}"
|
||||
PLIST="$HOME/Library/LaunchAgents/${LABEL}.plist"
|
||||
LOG="${MEMIND_AGENT_RUN_WORKER_LOG:-$HOME/Library/Logs/memind-agent-run-worker.log}"
|
||||
GUI="gui/$(id -u)"
|
||||
POLL_MS="${MEMIND_AGENT_RUN_WORKER_POLL_MS:-3000}"
|
||||
BATCH_SIZE="${MEMIND_AGENT_RUN_WORKER_BATCH_SIZE:-1}"
|
||||
CONCURRENCY="${MEMIND_AGENT_RUN_QUEUE_CONCURRENCY:-1}"
|
||||
RUN_TIMEOUT_MS="${MEMIND_AGENT_RUN_TIMEOUT_MS:-900000}"
|
||||
TOOL_GATEWAY_ENABLED="${MEMIND_TOOL_GATEWAY_ENABLED:-0}"
|
||||
TOOL_GATEWAY_DRY_RUN="${MEMIND_TOOL_GATEWAY_DRY_RUN:-0}"
|
||||
WORKDIR_OVERRIDE="${MEMIND_AGENT_RUN_WORKDIR_OVERRIDE:-}"
|
||||
WORKDIR_OVERRIDE_USER_ID="${MEMIND_AGENT_RUN_WORKDIR_USER_ID:-}"
|
||||
START="${MEMIND_AGENT_RUN_WORKER_START:-0}"
|
||||
|
||||
mkdir -p "$HOME/Library/LaunchAgents" "$(dirname "$LOG")"
|
||||
|
||||
if [[ ! -x "$NODE_BIN" ]]; then
|
||||
NODE_BIN="$(command -v node)"
|
||||
fi
|
||||
if [[ ! -f "$SCRIPT" ]]; then
|
||||
echo "agent run worker script not found: $SCRIPT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cat > "$PLIST" <<EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>$LABEL</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>$NODE_BIN</string>
|
||||
<string>$SCRIPT</string>
|
||||
<string>--poll-ms</string>
|
||||
<string>$POLL_MS</string>
|
||||
<string>--limit</string>
|
||||
<string>$BATCH_SIZE</string>
|
||||
</array>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>$ROOT</string>
|
||||
<key>RunAtLoad</key>
|
||||
<false/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>ThrottleInterval</key>
|
||||
<integer>10</integer>
|
||||
<key>StandardOutPath</key>
|
||||
<string>$LOG</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>$LOG</string>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>PATH</key>
|
||||
<string>/opt/homebrew/bin:/opt/homebrew/opt/node@24/bin:/usr/local/bin:/usr/bin:/bin</string>
|
||||
<key>MEMIND_AGENT_RUN_AUTODISPATCH</key>
|
||||
<string>0</string>
|
||||
<key>MEMIND_AGENT_RUN_WORKER_POLL_MS</key>
|
||||
<string>$POLL_MS</string>
|
||||
<key>MEMIND_AGENT_RUN_WORKER_BATCH_SIZE</key>
|
||||
<string>$BATCH_SIZE</string>
|
||||
<key>MEMIND_AGENT_RUN_QUEUE_CONCURRENCY</key>
|
||||
<string>$CONCURRENCY</string>
|
||||
<key>MEMIND_AGENT_RUN_TIMEOUT_MS</key>
|
||||
<string>$RUN_TIMEOUT_MS</string>
|
||||
<key>MEMIND_TOOL_GATEWAY_ENABLED</key>
|
||||
<string>$TOOL_GATEWAY_ENABLED</string>
|
||||
<key>MEMIND_TOOL_GATEWAY_DRY_RUN</key>
|
||||
<string>$TOOL_GATEWAY_DRY_RUN</string>
|
||||
<key>MEMIND_AGENT_RUN_WORKDIR_OVERRIDE</key>
|
||||
<string>$WORKDIR_OVERRIDE</string>
|
||||
<key>MEMIND_AGENT_RUN_WORKDIR_USER_ID</key>
|
||||
<string>$WORKDIR_OVERRIDE_USER_ID</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
|
||||
plutil -lint "$PLIST"
|
||||
launchctl bootout "$GUI/$LABEL" 2>/dev/null || true
|
||||
launchctl bootstrap "$GUI" "$PLIST"
|
||||
|
||||
if [[ "$START" == "1" || "$START" == "true" || "$START" == "yes" ]]; then
|
||||
launchctl enable "$GUI/$LABEL"
|
||||
launchctl kickstart -k "$GUI/$LABEL"
|
||||
state="started"
|
||||
else
|
||||
launchctl disable "$GUI/$LABEL" 2>/dev/null || true
|
||||
state="installed-disabled"
|
||||
fi
|
||||
|
||||
echo "installed $PLIST"
|
||||
echo "state: $state"
|
||||
echo "script: $SCRIPT"
|
||||
echo "poll_ms: $POLL_MS"
|
||||
echo "batch_size: $BATCH_SIZE"
|
||||
echo "concurrency: $CONCURRENCY"
|
||||
echo "tool_gateway_enabled: $TOOL_GATEWAY_ENABLED"
|
||||
echo "tool_gateway_dry_run: $TOOL_GATEWAY_DRY_RUN"
|
||||
echo "workdir_override: $WORKDIR_OVERRIDE"
|
||||
echo "workdir_override_user_id: $WORKDIR_OVERRIDE_USER_ID"
|
||||
echo "log: $LOG"
|
||||
echo "manual start: launchctl enable $GUI/$LABEL && launchctl kickstart -k $GUI/$LABEL"
|
||||
echo "manual stop: launchctl bootout $GUI/$LABEL"
|
||||
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
NODE_BIN="${NODE_BIN:-/opt/homebrew/opt/node@24/bin/node}"
|
||||
SCRIPT="${MEMIND_RUNTIME_HEARTBEAT_SCRIPT:-$ROOT/scripts/runtime-worker-heartbeat.mjs}"
|
||||
LABEL="${MEMIND_RUNTIME_HEARTBEAT_LABEL:-cn.tkmind.memind-runtime-heartbeat}"
|
||||
PLIST="$HOME/Library/LaunchAgents/${LABEL}.plist"
|
||||
LOG="$HOME/Library/Logs/memind-runtime-heartbeat.log"
|
||||
GUI="gui/$(id -u)"
|
||||
INTERVAL_MS="${MEMIND_RUNTIME_HEARTBEAT_INTERVAL_MS:-15000}"
|
||||
TIMEOUT_MS="${MEMIND_RUNTIME_HEARTBEAT_TIMEOUT_MS:-5000}"
|
||||
TTL_MS="${MEMIND_RUNTIME_HEARTBEAT_TTL_MS:-45000}"
|
||||
|
||||
mkdir -p "$HOME/Library/LaunchAgents" "$HOME/Library/Logs"
|
||||
|
||||
if [[ ! -x "$NODE_BIN" ]]; then
|
||||
NODE_BIN="$(command -v node)"
|
||||
fi
|
||||
if [[ ! -f "$SCRIPT" ]]; then
|
||||
echo "runtime heartbeat script not found: $SCRIPT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cat > "$PLIST" <<EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>$LABEL</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>$NODE_BIN</string>
|
||||
<string>$SCRIPT</string>
|
||||
<string>serve</string>
|
||||
</array>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>$ROOT</string>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>ThrottleInterval</key>
|
||||
<integer>10</integer>
|
||||
<key>StandardOutPath</key>
|
||||
<string>$LOG</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>$LOG</string>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>PATH</key>
|
||||
<string>/opt/homebrew/bin:/opt/homebrew/opt/node@24/bin:/usr/local/bin:/usr/bin:/bin</string>
|
||||
<key>MEMIND_RUNTIME_HEARTBEAT_INTERVAL_MS</key>
|
||||
<string>$INTERVAL_MS</string>
|
||||
<key>MEMIND_RUNTIME_HEARTBEAT_TIMEOUT_MS</key>
|
||||
<string>$TIMEOUT_MS</string>
|
||||
<key>MEMIND_RUNTIME_HEARTBEAT_TTL_MS</key>
|
||||
<string>$TTL_MS</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
|
||||
plutil -lint "$PLIST"
|
||||
launchctl bootout "$GUI/$LABEL" 2>/dev/null || true
|
||||
launchctl bootstrap "$GUI" "$PLIST"
|
||||
launchctl enable "$GUI/$LABEL"
|
||||
launchctl kickstart -k "$GUI/$LABEL"
|
||||
|
||||
echo "installed $PLIST"
|
||||
echo "script: $SCRIPT"
|
||||
echo "interval_ms: $INTERVAL_MS"
|
||||
echo "timeout_ms: $TIMEOUT_MS"
|
||||
echo "ttl_ms: $TTL_MS"
|
||||
echo "log: $LOG"
|
||||
@@ -11,6 +11,7 @@ GUI="gui/$(id -u)"
|
||||
HOUR="${MEMIND_RUNTIME_SLO_HOUR:-23}"
|
||||
MINUTE="${MEMIND_RUNTIME_SLO_MINUTE:-55}"
|
||||
REPORT_DIR="${MEMIND_RUNTIME_REPORT_DIR:-$ROOT/reports/runtime-slo}"
|
||||
RETENTION_DAYS="${MEMIND_RUNTIME_SLO_RETENTION_DAYS:-30}"
|
||||
|
||||
mkdir -p "$HOME/Library/LaunchAgents" "$HOME/Library/Logs" "$REPORT_DIR"
|
||||
|
||||
@@ -34,6 +35,9 @@ cat > "$PLIST" <<EOF
|
||||
<string>$NODE_BIN</string>
|
||||
<string>$SCRIPT</string>
|
||||
<string>--write-report</string>
|
||||
<string>--prune</string>
|
||||
<string>--retention-days</string>
|
||||
<string>$RETENTION_DAYS</string>
|
||||
<string>--report-dir</string>
|
||||
<string>$REPORT_DIR</string>
|
||||
</array>
|
||||
@@ -69,4 +73,5 @@ echo "installed $PLIST"
|
||||
echo "script: $SCRIPT"
|
||||
echo "schedule: daily ${HOUR}:${MINUTE}"
|
||||
echo "report_dir: $REPORT_DIR"
|
||||
echo "retention_days: $RETENTION_DAYS"
|
||||
echo "log: $LOG"
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
NODE_BIN="${NODE_BIN:-/opt/homebrew/opt/node@24/bin/node}"
|
||||
SCRIPT="${MEMIND_RUNTIME_SLO_SCRIPT:-$ROOT/scripts/runtime-slo-report.mjs}"
|
||||
LABEL="${MEMIND_RUNTIME_SLO_SOAK_LABEL:-cn.tkmind.memind-runtime-slo-soak}"
|
||||
PLIST="$HOME/Library/LaunchAgents/${LABEL}.plist"
|
||||
LOG="$HOME/Library/Logs/memind-runtime-slo-soak.log"
|
||||
GUI="gui/$(id -u)"
|
||||
INTERVAL_SECONDS="${MEMIND_RUNTIME_SLO_SOAK_INTERVAL_SECONDS:-3600}"
|
||||
REPORT_DIR="${MEMIND_RUNTIME_SLO_SOAK_REPORT_DIR:-$ROOT/reports/runtime-slo-soak}"
|
||||
RETENTION_DAYS="${MEMIND_RUNTIME_SLO_SOAK_RETENTION_DAYS:-7}"
|
||||
|
||||
mkdir -p "$HOME/Library/LaunchAgents" "$HOME/Library/Logs" "$REPORT_DIR"
|
||||
|
||||
if [[ ! "$INTERVAL_SECONDS" =~ ^[0-9]+$ ]] || [[ "$INTERVAL_SECONDS" -lt 60 ]]; then
|
||||
echo "invalid MEMIND_RUNTIME_SLO_SOAK_INTERVAL_SECONDS: $INTERVAL_SECONDS" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! "$RETENTION_DAYS" =~ ^[0-9]+$ ]] || [[ "$RETENTION_DAYS" -lt 1 ]]; then
|
||||
echo "invalid MEMIND_RUNTIME_SLO_SOAK_RETENTION_DAYS: $RETENTION_DAYS" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -x "$NODE_BIN" ]]; then
|
||||
NODE_BIN="$(command -v node)"
|
||||
fi
|
||||
if [[ ! -f "$SCRIPT" ]]; then
|
||||
echo "runtime SLO script not found: $SCRIPT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cat > "$PLIST" <<EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>$LABEL</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>$NODE_BIN</string>
|
||||
<string>$SCRIPT</string>
|
||||
<string>--write-report</string>
|
||||
<string>--prune</string>
|
||||
<string>--retention-days</string>
|
||||
<string>$RETENTION_DAYS</string>
|
||||
<string>--report-dir</string>
|
||||
<string>$REPORT_DIR</string>
|
||||
</array>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>$ROOT</string>
|
||||
<key>StartInterval</key>
|
||||
<integer>$INTERVAL_SECONDS</integer>
|
||||
<key>StandardOutPath</key>
|
||||
<string>$LOG</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>$LOG</string>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>PATH</key>
|
||||
<string>/opt/homebrew/bin:/opt/homebrew/opt/node@24/bin:/usr/local/bin:/usr/bin:/bin</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
|
||||
plutil -lint "$PLIST"
|
||||
launchctl bootout "$GUI/$LABEL" 2>/dev/null || true
|
||||
launchctl bootstrap "$GUI" "$PLIST"
|
||||
launchctl enable "$GUI/$LABEL"
|
||||
launchctl kickstart -k "$GUI/$LABEL"
|
||||
|
||||
echo "installed $PLIST"
|
||||
echo "script: $SCRIPT"
|
||||
echo "schedule: every ${INTERVAL_SECONDS}s"
|
||||
echo "report_dir: $REPORT_DIR"
|
||||
echo "retention_days: $RETENTION_DAYS"
|
||||
echo "log: $LOG"
|
||||
@@ -0,0 +1,20 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
function loadEnvFile(filePath) {
|
||||
if (!fs.existsSync(filePath)) return;
|
||||
for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const separator = trimmed.indexOf('=');
|
||||
if (separator < 0) continue;
|
||||
const key = trimmed.slice(0, separator).trim();
|
||||
const value = trimmed.slice(separator + 1).trim();
|
||||
if (!process.env[key]) process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
export function loadH5Environment(scriptDirectory) {
|
||||
loadEnvFile(path.join(scriptDirectory, '../../../.env.local'));
|
||||
loadEnvFile(path.join(scriptDirectory, '../.env'));
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
# 反向 SSH 隧道:105 通过 127.0.0.1:19081 访问本机 Portal (:8081)
|
||||
set -euo pipefail
|
||||
|
||||
HOST="${MEMIND_PORTAL_TUNNEL_HOST:-ssh105-public}"
|
||||
LOCAL_PORT="${MEMIND_PORTAL_TUNNEL_LOCAL_PORT:-8081}"
|
||||
REMOTE_PORT="${MEMIND_PORTAL_TUNNEL_REMOTE_PORT:-19081}"
|
||||
|
||||
exec ssh -N \
|
||||
-o ServerAliveInterval=30 \
|
||||
-o ServerAliveCountMax=3 \
|
||||
-o ExitOnForwardFailure=yes \
|
||||
-R "127.0.0.1:${REMOTE_PORT}:127.0.0.1:${LOCAL_PORT}" \
|
||||
"${HOST}"
|
||||
@@ -90,27 +90,85 @@ function uniquePaths(paths) {
|
||||
function parseArgs(argv) {
|
||||
const args = {
|
||||
writeReport: false,
|
||||
prune: false,
|
||||
retentionDays: Number(process.env.MEMIND_RUNTIME_SLO_RETENTION_DAYS || 30),
|
||||
reportDir: process.env.MEMIND_RUNTIME_REPORT_DIR || '',
|
||||
};
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const item = argv[i];
|
||||
if (item === '--write-report') args.writeReport = true;
|
||||
else if (item === '--prune') args.prune = true;
|
||||
else if (item === '--retention-days') args.retentionDays = Number(argv[++i] ?? args.retentionDays);
|
||||
else if (item === '--report-dir') args.reportDir = String(argv[++i] ?? args.reportDir);
|
||||
else if (item === '--help' || item === '-h') args.help = true;
|
||||
}
|
||||
if (!Number.isFinite(args.retentionDays) || args.retentionDays < 1) {
|
||||
throw new Error(`Invalid --retention-days: ${args.retentionDays}`);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log([
|
||||
'Usage:',
|
||||
' node scripts/runtime-slo-report.mjs [--write-report] [--report-dir <dir>]',
|
||||
' node scripts/runtime-slo-report.mjs [--write-report] [--prune] [--retention-days <days>] [--report-dir <dir>]',
|
||||
'',
|
||||
'Default behavior is read-only and writes nothing.',
|
||||
'--write-report writes JSON and Markdown snapshots to an operations report directory.',
|
||||
'--prune deletes old .json/.md snapshots in the report directory only.',
|
||||
].join('\n'));
|
||||
}
|
||||
|
||||
function pruneReportDir(reportDir, retentionDays) {
|
||||
const resolvedDir = path.resolve(reportDir);
|
||||
const cutoffMs = Date.now() - retentionDays * 24 * 60 * 60 * 1000;
|
||||
const result = {
|
||||
enabled: true,
|
||||
reportDir: resolvedDir,
|
||||
retentionDays,
|
||||
deleted: [],
|
||||
kept: 0,
|
||||
skipped: 0,
|
||||
errors: [],
|
||||
};
|
||||
let entries = [];
|
||||
try {
|
||||
entries = fs.readdirSync(resolvedDir, { withFileTypes: true });
|
||||
} catch (err) {
|
||||
result.errors.push(err instanceof Error ? err.message : String(err));
|
||||
return result;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) {
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
if (!entry.name.endsWith('.json') && !entry.name.endsWith('.md')) {
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
const full = path.join(resolvedDir, entry.name);
|
||||
let stat = null;
|
||||
try {
|
||||
stat = fs.statSync(full);
|
||||
} catch (err) {
|
||||
result.errors.push(`${entry.name}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
continue;
|
||||
}
|
||||
if (stat.mtimeMs >= cutoffMs) {
|
||||
result.kept += 1;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
fs.unlinkSync(full);
|
||||
result.deleted.push(entry.name);
|
||||
} catch (err) {
|
||||
result.errors.push(`${entry.name}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function tableCountQueries() {
|
||||
return [
|
||||
['h5_users', 'users'],
|
||||
@@ -166,13 +224,28 @@ async function readRedisSummary(namespace, redisUrl) {
|
||||
|
||||
function summarizeRuntime(runtimeJson) {
|
||||
const workers = runtimeJson?.router?.workers ?? [];
|
||||
const toolQueue = runtimeJson?.toolRuntime?.queue ?? null;
|
||||
const staleMetricMs = 2 * 60 * 1000;
|
||||
const staleHeartbeatMs = Number(process.env.MEMIND_RUNTIME_HEARTBEAT_STALE_MS || 60 * 1000);
|
||||
const now = Date.now();
|
||||
return {
|
||||
routerEnabled: Boolean(runtimeJson?.router?.enabled),
|
||||
publicBaseUrl: runtimeJson?.publicBaseUrl ?? null,
|
||||
toolRuntime: runtimeJson?.toolRuntime ?? null,
|
||||
toolQueue: runtimeJson?.toolRuntime?.queue ?? null,
|
||||
toolQueue,
|
||||
toolQueueSlo: toolQueue
|
||||
? {
|
||||
heartbeatMs: Number(toolQueue.heartbeatMs ?? 0),
|
||||
maxConcurrentRuns: Number(toolQueue.maxConcurrentRuns ?? 0),
|
||||
statusCounts: toolQueue.statusCounts ?? {},
|
||||
oldestRunningStartedAt: toolQueue.oldestRunningStartedAt ?? null,
|
||||
oldestRunningAgeMs: Number(toolQueue.oldestRunningAgeMs ?? 0),
|
||||
oldestRunningHeartbeatAt: toolQueue.oldestRunningHeartbeatAt ?? null,
|
||||
oldestRunningHeartbeatAgeMs: Number(toolQueue.oldestRunningHeartbeatAgeMs ?? 0),
|
||||
runningWithoutHeartbeatCount: Number(toolQueue.runningWithoutHeartbeatCount ?? 0),
|
||||
latestRunningRun: toolQueue.latestRunningRun ?? null,
|
||||
}
|
||||
: null,
|
||||
workers: workers.map((worker) => ({
|
||||
id: worker.id,
|
||||
healthy: runtimeJson?.targets?.find((target) => target.target === worker.target)?.healthy ?? null,
|
||||
@@ -191,6 +264,14 @@ function summarizeRuntime(runtimeJson) {
|
||||
fdPressure: worker.fdPressure,
|
||||
fdCount: worker.fdCount,
|
||||
containerHealth: worker.containerHealth,
|
||||
heartbeat: worker.heartbeat,
|
||||
heartbeatAgeMs: worker.heartbeat ? now - worker.heartbeat : null,
|
||||
heartbeatFresh: worker.heartbeat ? now - worker.heartbeat <= staleHeartbeatMs : false,
|
||||
heartbeatSource: worker.heartbeatSource ?? null,
|
||||
heartbeatOk: worker.heartbeatOk ?? null,
|
||||
heartbeatStatusCode: worker.heartbeatStatusCode ?? null,
|
||||
heartbeatLatencyMs: worker.heartbeatLatencyMs ?? null,
|
||||
heartbeatError: worker.heartbeatError ?? null,
|
||||
metricsAgeMs: worker.metricsSampledAt ? now - worker.metricsSampledAt : null,
|
||||
metricsFresh: worker.metricsSampledAt ? now - worker.metricsSampledAt <= staleMetricMs : false,
|
||||
score: worker.score,
|
||||
@@ -246,6 +327,8 @@ if (!runtime.ok) failures.push('runtime_status_unavailable');
|
||||
if (runtimeSummary && !runtimeSummary.routerEnabled) failures.push('router_disabled');
|
||||
for (const worker of runtimeSummary?.workers ?? []) {
|
||||
if (!worker.healthy) failures.push(`${worker.id}_target_unhealthy`);
|
||||
if (!worker.heartbeatFresh) failures.push(`${worker.id}_heartbeat_stale`);
|
||||
if (worker.heartbeatOk === false) failures.push(`${worker.id}_heartbeat_unhealthy`);
|
||||
if (worker.containerHealth && worker.containerHealth !== 'healthy') failures.push(`${worker.id}_container_${worker.containerHealth}`);
|
||||
if (!worker.metricsFresh) failures.push(`${worker.id}_metrics_stale`);
|
||||
if (worker.streamErrorCount > 0) failures.push(`${worker.id}_stream_errors_${worker.streamErrorCount}`);
|
||||
@@ -256,6 +339,32 @@ if (runtimeSummary?.toolQueue?.error) failures.push('tool_queue_status_error');
|
||||
if (runtimeSummary?.toolQueue?.inFlight > runtimeSummary?.toolQueue?.maxConcurrentRuns) {
|
||||
failures.push('tool_queue_concurrency_exceeded');
|
||||
}
|
||||
const agentRunHeartbeatStaleMs = Number(
|
||||
process.env.MEMIND_AGENT_RUN_HEARTBEAT_STALE_MS ||
|
||||
Math.max(90 * 1000, Number(runtimeSummary?.toolQueueSlo?.heartbeatMs ?? 0) * 3),
|
||||
);
|
||||
if (
|
||||
runtimeSummary?.toolQueueSlo?.oldestRunningHeartbeatAgeMs > agentRunHeartbeatStaleMs
|
||||
) {
|
||||
failures.push(`agent_run_heartbeat_stale_${runtimeSummary.toolQueueSlo.oldestRunningHeartbeatAgeMs}`);
|
||||
}
|
||||
if (
|
||||
Number(runtimeSummary?.toolQueueSlo?.runningWithoutHeartbeatCount ?? 0) > 0 &&
|
||||
Number(runtimeSummary?.toolQueueSlo?.oldestRunningAgeMs ?? 0) > agentRunHeartbeatStaleMs
|
||||
) {
|
||||
failures.push(`agent_run_missing_heartbeat_${runtimeSummary.toolQueueSlo.runningWithoutHeartbeatCount}`);
|
||||
}
|
||||
|
||||
const reportDir = path.resolve(args.reportDir || path.join(appRoot, 'reports', 'runtime-slo'));
|
||||
if (args.writeReport || args.prune) {
|
||||
fs.mkdirSync(reportDir, { recursive: true });
|
||||
}
|
||||
const reportPrune = args.prune
|
||||
? pruneReportDir(reportDir, args.retentionDays)
|
||||
: { enabled: false, reportDir, retentionDays: args.retentionDays };
|
||||
for (const error of reportPrune.errors ?? []) {
|
||||
failures.push(`report_prune_error:${error}`);
|
||||
}
|
||||
|
||||
const report = {
|
||||
ok: failures.length === 0,
|
||||
@@ -274,7 +383,9 @@ const report = {
|
||||
mindSpace: false,
|
||||
redis: false,
|
||||
report: Boolean(args.writeReport),
|
||||
reportPrune: Boolean(args.prune),
|
||||
},
|
||||
reportPrune,
|
||||
};
|
||||
|
||||
function markdownReport(payload) {
|
||||
@@ -290,8 +401,8 @@ function markdownReport(payload) {
|
||||
'',
|
||||
'## Workers',
|
||||
'',
|
||||
'| worker | healthy | active | errors | first-token 5m p50/p95 | first-token 1h p50/p95 | metricsFresh | score |',
|
||||
'| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |',
|
||||
'| worker | healthy | active | errors | first-token 5m p50/p95 | first-token 1h p50/p95 | heartbeat | metricsFresh | score |',
|
||||
'| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |',
|
||||
];
|
||||
for (const worker of workers) {
|
||||
lines.push([
|
||||
@@ -301,6 +412,7 @@ function markdownReport(payload) {
|
||||
worker.streamErrorCount,
|
||||
`${worker.firstToken5m?.p50Ms ?? 0}/${worker.firstToken5m?.p95Ms ?? 0}`,
|
||||
`${worker.firstToken1h?.p50Ms ?? 0}/${worker.firstToken1h?.p95Ms ?? 0}`,
|
||||
`${worker.heartbeatSource ?? 'none'}:${worker.heartbeatFresh}`,
|
||||
worker.metricsFresh,
|
||||
worker.score,
|
||||
].join(' | ').replace(/^/, '| ').replace(/$/, ' |'));
|
||||
@@ -309,6 +421,10 @@ function markdownReport(payload) {
|
||||
'',
|
||||
'## Tool Queue',
|
||||
'',
|
||||
`- heartbeatMs: ${payload.runtime?.toolQueueSlo?.heartbeatMs ?? 0}`,
|
||||
`- oldestRunningHeartbeatAgeMs: ${payload.runtime?.toolQueueSlo?.oldestRunningHeartbeatAgeMs ?? 0}`,
|
||||
`- runningWithoutHeartbeatCount: ${payload.runtime?.toolQueueSlo?.runningWithoutHeartbeatCount ?? 0}`,
|
||||
'',
|
||||
'```json',
|
||||
JSON.stringify(payload.runtime?.toolQueue ?? null, null, 2),
|
||||
'```',
|
||||
@@ -319,13 +435,17 @@ function markdownReport(payload) {
|
||||
JSON.stringify(payload.writes, null, 2),
|
||||
'```',
|
||||
'',
|
||||
'## Report Prune',
|
||||
'',
|
||||
'```json',
|
||||
JSON.stringify(payload.reportPrune ?? null, null, 2),
|
||||
'```',
|
||||
'',
|
||||
);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
if (args.writeReport) {
|
||||
const reportDir = path.resolve(args.reportDir || path.join(appRoot, 'reports', 'runtime-slo'));
|
||||
fs.mkdirSync(reportDir, { recursive: true });
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
report.reportFiles = {
|
||||
json: path.join(reportDir, `${stamp}.json`),
|
||||
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { Agent, fetch as undiciFetch } from 'undici';
|
||||
import { createClient } from 'redis';
|
||||
|
||||
function loadEnvFile(filePath) {
|
||||
if (!fs.existsSync(filePath)) return;
|
||||
for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const idx = trimmed.indexOf('=');
|
||||
if (idx < 0) continue;
|
||||
const key = trimmed.slice(0, idx).trim();
|
||||
const value = trimmed.slice(idx + 1).trim();
|
||||
if (!process.env[key]) process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
function positiveInteger(value, fallback) {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n) || n <= 0) return fallback;
|
||||
return Math.floor(n);
|
||||
}
|
||||
|
||||
function targetWorkers() {
|
||||
const targets = (process.env.TKMIND_API_TARGETS || process.env.TKMIND_API_TARGET || '')
|
||||
.split(',')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
return targets.map((target, index) => ({
|
||||
id: `goosed-${index + 1}`,
|
||||
target,
|
||||
}));
|
||||
}
|
||||
|
||||
function workerKey(namespace, id, field) {
|
||||
return [namespace, 'worker', id, field].join(':');
|
||||
}
|
||||
|
||||
async function probeTarget(target, timeoutMs) {
|
||||
const startedAt = Date.now();
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
const dispatcher = new Agent({ connect: { rejectUnauthorized: false } });
|
||||
try {
|
||||
const url = new URL('/status', target.endsWith('/') ? target : `${target}/`).toString();
|
||||
const res = await undiciFetch(url, {
|
||||
signal: controller.signal,
|
||||
dispatcher: url.startsWith('https://127.0.0.1') ? dispatcher : undefined,
|
||||
headers: process.env.TKMIND_SERVER__SECRET_KEY
|
||||
? { 'X-Secret-Key': process.env.TKMIND_SERVER__SECRET_KEY }
|
||||
: undefined,
|
||||
});
|
||||
const body = (await res.text()).trim().slice(0, 80);
|
||||
return {
|
||||
ok: res.ok && body === 'ok',
|
||||
statusCode: res.status,
|
||||
body,
|
||||
latencyMs: Date.now() - startedAt,
|
||||
error: null,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
statusCode: 0,
|
||||
body: '',
|
||||
latencyMs: Date.now() - startedAt,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
dispatcher.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function writeHeartbeats(client, namespace, workers, options) {
|
||||
const now = Date.now();
|
||||
const results = [];
|
||||
for (const worker of workers) {
|
||||
const probe = await probeTarget(worker.target, options.timeoutMs);
|
||||
const ttlSeconds = Math.max(5, Math.ceil(options.ttlMs / 1000));
|
||||
const multi = client
|
||||
.multi()
|
||||
.set(workerKey(namespace, worker.id, 'heartbeat'), String(now), { EX: ttlSeconds })
|
||||
.set(workerKey(namespace, worker.id, 'heartbeat_at'), String(now), { EX: ttlSeconds })
|
||||
.set(workerKey(namespace, worker.id, 'heartbeat_source'), 'sidecar', { EX: ttlSeconds })
|
||||
.set(workerKey(namespace, worker.id, 'heartbeat_target'), worker.target, { EX: ttlSeconds })
|
||||
.set(workerKey(namespace, worker.id, 'heartbeat_ok'), probe.ok ? '1' : '0', { EX: ttlSeconds })
|
||||
.set(workerKey(namespace, worker.id, 'heartbeat_status_code'), String(probe.statusCode), { EX: ttlSeconds })
|
||||
.set(workerKey(namespace, worker.id, 'heartbeat_latency_ms'), String(probe.latencyMs), { EX: ttlSeconds });
|
||||
if (probe.error) {
|
||||
multi.set(workerKey(namespace, worker.id, 'heartbeat_error'), probe.error, { EX: ttlSeconds });
|
||||
} else {
|
||||
multi.del(workerKey(namespace, worker.id, 'heartbeat_error'));
|
||||
}
|
||||
await multi.exec();
|
||||
results.push({ ...worker, ...probe, heartbeatAt: now });
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
loadEnvFile(process.env.MEMIND_ENV_FILE || path.join(process.cwd(), '.env'));
|
||||
|
||||
const redisUrl = process.env.MEMIND_RUNTIME_REDIS_URL || 'redis://127.0.0.1:6379/0';
|
||||
const namespace = process.env.MEMIND_RUNTIME_REDIS_NAMESPACE || 'memind:runtime';
|
||||
const action = process.argv[2] || 'once';
|
||||
const intervalMs = positiveInteger(process.env.MEMIND_RUNTIME_HEARTBEAT_INTERVAL_MS, 15_000);
|
||||
const timeoutMs = positiveInteger(process.env.MEMIND_RUNTIME_HEARTBEAT_TIMEOUT_MS, 5_000);
|
||||
const ttlMs = positiveInteger(process.env.MEMIND_RUNTIME_HEARTBEAT_TTL_MS, Math.max(45_000, intervalMs * 3));
|
||||
|
||||
if (!['once', 'serve'].includes(action)) {
|
||||
console.error('Usage: node scripts/runtime-worker-heartbeat.mjs <once|serve>');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const workers = targetWorkers();
|
||||
if (workers.length === 0) {
|
||||
console.error('No TKMIND_API_TARGETS configured');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const client = createClient({ url: redisUrl });
|
||||
client.on('error', (err) => {
|
||||
console.error(`Redis error: ${err instanceof Error ? err.message : err}`);
|
||||
});
|
||||
await client.connect();
|
||||
|
||||
let stopping = false;
|
||||
const stop = async () => {
|
||||
stopping = true;
|
||||
await client.quit().catch(() => {});
|
||||
};
|
||||
process.on('SIGTERM', () => void stop().finally(() => process.exit(0)));
|
||||
process.on('SIGINT', () => void stop().finally(() => process.exit(0)));
|
||||
|
||||
async function tick() {
|
||||
const result = await writeHeartbeats(client, namespace, workers, { timeoutMs, ttlMs });
|
||||
const payload = {
|
||||
ok: result.every((worker) => worker.ok),
|
||||
action,
|
||||
namespace,
|
||||
intervalMs: action === 'serve' ? intervalMs : undefined,
|
||||
timeoutMs,
|
||||
ttlMs,
|
||||
workers: result,
|
||||
};
|
||||
console.log(JSON.stringify(payload));
|
||||
return payload;
|
||||
}
|
||||
|
||||
if (action === 'once') {
|
||||
const payload = await tick();
|
||||
await client.quit();
|
||||
process.exit(payload.ok ? 0 : 1);
|
||||
}
|
||||
|
||||
await tick();
|
||||
while (!stopping) {
|
||||
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||
if (!stopping) await tick().catch((err) => {
|
||||
console.error(err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
}
|
||||
Executable
+138
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 微信服务号自定义菜单(menu/create)。
|
||||
*
|
||||
* 变更规范:
|
||||
* - 只在本仓库修改 MENU 常量,禁止 SSH 到 105 直接改线上脚本。
|
||||
* - 流程:本地改码 → Git commit → 正式发布 → 再执行本脚本同步到微信 API。
|
||||
* - 详见 docs/105-server-operations.md
|
||||
*/
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadH5Environment } from './load-env.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
loadH5Environment(__dirname);
|
||||
|
||||
const DEFAULT_TOKEN_URL = 'https://api.weixin.qq.com/cgi-bin/stable_token';
|
||||
const DEFAULT_MENU_CREATE_URL = 'https://api.weixin.qq.com/cgi-bin/menu/create';
|
||||
const DEFAULT_MENU_GET_URL = 'https://api.weixin.qq.com/cgi-bin/menu/get';
|
||||
|
||||
const MENU = {
|
||||
button: [
|
||||
{
|
||||
type: 'view',
|
||||
name: 'TKMind',
|
||||
url: 'https://mm.tkmind.cn',
|
||||
},
|
||||
{
|
||||
type: 'view',
|
||||
name: 'M空间',
|
||||
url: 'https://mm.tkmind.cn/space',
|
||||
},
|
||||
{
|
||||
type: 'view',
|
||||
name: 'M发现',
|
||||
url: 'https://plaza.tkmind.cn',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function endpointFromTokenUrl(pathname, fallback) {
|
||||
const tokenUrl = process.env.H5_WECHAT_MP_TOKEN_URL?.trim() || DEFAULT_TOKEN_URL;
|
||||
try {
|
||||
const url = new URL(tokenUrl);
|
||||
url.pathname = pathname;
|
||||
url.search = '';
|
||||
return url.toString();
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
async function readJson(response) {
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(text || `HTTP ${response.status}`);
|
||||
}
|
||||
return text ? JSON.parse(text) : null;
|
||||
}
|
||||
|
||||
async function getAccessToken() {
|
||||
const appid =
|
||||
process.env.H5_WECHAT_MP_APP_ID?.trim() ?? process.env.H5_WECHAT_APP_ID?.trim() ?? '';
|
||||
const secret =
|
||||
process.env.H5_WECHAT_MP_APP_SECRET?.trim() ?? process.env.H5_WECHAT_APP_SECRET?.trim() ?? '';
|
||||
if (!appid || !secret) {
|
||||
throw new Error(
|
||||
'缺少 H5_WECHAT_MP_APP_ID / H5_WECHAT_MP_APP_SECRET(或 H5_WECHAT_APP_ID / H5_WECHAT_APP_SECRET)',
|
||||
);
|
||||
}
|
||||
|
||||
const tokenUrl = process.env.H5_WECHAT_MP_TOKEN_URL?.trim() || DEFAULT_TOKEN_URL;
|
||||
const payload = await readJson(
|
||||
await fetch(tokenUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
grant_type: 'client_credential',
|
||||
appid,
|
||||
secret,
|
||||
force_refresh: false,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
if (!payload?.access_token) {
|
||||
throw new Error(payload?.errmsg || `获取 access_token 失败 (${payload?.errcode ?? 'unknown'})`);
|
||||
}
|
||||
return payload.access_token;
|
||||
}
|
||||
|
||||
async function createMenu(accessToken) {
|
||||
const baseUrl =
|
||||
process.env.H5_WECHAT_MP_MENU_CREATE_URL?.trim() ||
|
||||
endpointFromTokenUrl('/cgi-bin/menu/create', DEFAULT_MENU_CREATE_URL);
|
||||
const url = `${baseUrl}?access_token=${encodeURIComponent(accessToken)}`;
|
||||
const payload = await readJson(
|
||||
await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(MENU),
|
||||
}),
|
||||
);
|
||||
if (Number(payload?.errcode ?? 0) !== 0) {
|
||||
throw new Error(payload?.errmsg || `创建菜单失败 (${payload?.errcode})`);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function getMenu(accessToken) {
|
||||
const baseUrl =
|
||||
process.env.H5_WECHAT_MP_MENU_GET_URL?.trim() ||
|
||||
endpointFromTokenUrl('/cgi-bin/menu/get', DEFAULT_MENU_GET_URL);
|
||||
const url = `${baseUrl}?access_token=${encodeURIComponent(accessToken)}`;
|
||||
return readJson(await fetch(url, { method: 'GET' }));
|
||||
}
|
||||
|
||||
const dryRun = process.argv.includes('--dry-run');
|
||||
if (dryRun) {
|
||||
console.log(JSON.stringify(MENU, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const accessToken = await getAccessToken();
|
||||
const created = await createMenu(accessToken);
|
||||
const current = await getMenu(accessToken).catch((err) => ({ warning: err.message }));
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
created,
|
||||
menu: MENU,
|
||||
current,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
+4613
-1840
File diff suppressed because it is too large
Load Diff
@@ -17,7 +17,7 @@ description: 在工作区内用 Python 标准库(zipfile + XML)生成 Word .
|
||||
## 何时使用
|
||||
|
||||
- 用户要 Word / docx / .doc 文档(输出 `.docx`)
|
||||
- 需要保存到 `oa/`、`private/` 等分区
|
||||
- 需要保存到 `oa/`、`private/`、`public/` 等分区
|
||||
|
||||
## 推荐命令
|
||||
|
||||
|
||||
@@ -116,7 +116,6 @@ def table_block(headers: list[str], rows: list[list[str]]) -> str:
|
||||
"</w:tblBorders>"
|
||||
"</w:tblPr>"
|
||||
)
|
||||
# Table must be a direct child of w:body — never wrap w:tbl inside w:p.
|
||||
parts = ["<w:tbl>", tbl_pr, f"<w:tblGrid>{grid_cols}</w:tblGrid>"]
|
||||
if headers:
|
||||
parts.append(table_row(headers))
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
name: long-image-download
|
||||
description: 用 Playwright 将 MindSpace HTML 页面生成同名 PNG 长图,并返回可打开图片链接与附件下载链接
|
||||
---
|
||||
|
||||
# 长图下载(Playwright)
|
||||
|
||||
## 何时使用
|
||||
|
||||
- 用户要求“长图下载”“保存长图”“页面图片格式”“整页 PNG”
|
||||
- 已经或即将用 `static-page-publish` 生成 `public/*.html`
|
||||
|
||||
## 必须流程
|
||||
|
||||
1. 先确保 HTML 已经用 `write_file` / `edit_file` 写入 `public/<页面名>.html`
|
||||
2. 调用 sandbox-fs 工具 `generate_long_image`:
|
||||
|
||||
```json
|
||||
{
|
||||
"html_path": "public/report.html",
|
||||
"output_path": "public/report.long.png"
|
||||
}
|
||||
```
|
||||
|
||||
3. 用 `list_dir public` 确认 `<页面名>.long.png` 已存在
|
||||
4. 回复用户时同时给:
|
||||
- 页面链接:`[页面标题](.../public/report.html)`
|
||||
- 长图预览链接:`[长图预览](.../public/report.long.png)`
|
||||
- 长图下载链接:`[下载长图](.../public/report.html?download=long-image)`
|
||||
|
||||
## 规则
|
||||
|
||||
- `generate_long_image` 使用平台 Playwright,不要用 shell、自写截图脚本、`html2canvas` 或 `.thumbnail.svg` 冒充长图
|
||||
- `.thumbnail.svg` 只是信息流封面,不是整页长图
|
||||
- 长图文件名推荐与 HTML 同名:`report.html` -> `report.long.png`
|
||||
- 没有确认 `.long.png` 已生成时,不要声称“长图已生成”
|
||||
@@ -14,13 +14,16 @@ description: 在专属 MindSpace 目录生成可公开访问的静态 HTML 报
|
||||
|
||||
## 规则摘要
|
||||
|
||||
0. 可以用 `apps__create_app` 设计/预览页面,但那一步只是在 Apps 窗口内生成交互式 App,**还没有公网链接**;只要用户要「可访问的链接」「分享出去」,最后必须把内容 `write_file` 落到 `public/*.html`,按下方「回复格式」给出真实链接,不要停在 App 阶段就回复链接
|
||||
1. 只在**当前用户工作区**(会话 `working_dir`)内读写与搜索,从 `.` 开始
|
||||
2. 查找 CSV/文档时只用相对路径(如 `oa/report.csv`),**禁止**去上级目录、MindSpace 根目录、其它用户目录或主机路径搜索
|
||||
3. 读 CSV/列目录:用工作区内的 `shell`(`ls oa/`、`cat file.csv`)或 `tree`;**禁止**用公网 URL 代替
|
||||
4. **禁止**用 `shell` / `cat` / `heredoc` / `echo` / `cp` 写入 `public/*.html`;HTML 必须用 `write_file` / `edit_file`(shell 在容器内执行,公网链接会 404)
|
||||
5. 公网链接**仅**用于让用户浏览器打开已发布的 HTML,不能用来列目录或读数据文件
|
||||
6. 静态文件保存即可访问,**无需重启**
|
||||
7. 页面需提供 **Word/PDF 等二进制下载** 时:文件单独落盘(如 `public/方案.docx`),链接用相对路径;**禁止**在 HTML 内用 `data:...;base64,...` 嵌入 docx(易截断损坏)
|
||||
7. 默认只生成 HTML;不要在没有明确需求时强制生成 Word、PDF、长图等伴生文件
|
||||
8. 只有用户明确要求 **Word/PDF 等二进制下载** 时:文件单独落盘(如 `public/方案.docx`),链接用相对路径;**禁止**在 HTML 内用 `data:...;base64,...` 嵌入 docx(易截断损坏)
|
||||
9. 只有用户明确要求**长图下载**时:必须先 `load_skill` → `long-image-download`,调用 `generate_long_image` 生成同目录 `public/<页面名>.long.png`,再返回长图预览与下载链接;禁止把 `.thumbnail.svg` 当成长图
|
||||
|
||||
详细约束以工作区内的 `.goosehints` 与 `.agents/skills/static-page-publish/SKILL.md` 为准。
|
||||
|
||||
@@ -31,6 +34,17 @@ description: 在专属 MindSpace 目录生成可公开访问的静态 HTML 报
|
||||
3. 在 `<head>` 写入 **mindspace-cover**(必须与页面主题一致,见下文)
|
||||
4. 保存后服务端**立即**生成 `<文件名>.thumbnail.svg`(Agent 交互阶段即生效)
|
||||
5. 按「回复格式」返回**可点击**公网链接
|
||||
6. 若用户明确要求 Word/docx 下载,必须用 `generate_docx`(sandbox-fs 工具)生成 `public/<同名>.docx`,再确认链接目标已落盘
|
||||
7. 若用户明确要求长图下载,必须用 `long-image-download` 生成 `public/<同名>.long.png`,并确认文件存在
|
||||
|
||||
## 按需伴生下载文件
|
||||
|
||||
- 默认不生成伴生文件;只有用户明确要求下载附件时才生成
|
||||
- `<a href="report.docx" download>` 等相对下载链接,目标文件必须已在 HTML 同目录或子目录
|
||||
- 推荐 `public/report.html` + `public/report.docx`;**禁止** HTML 链接名与磁盘文件名不一致
|
||||
- 生成 Word 时必须调用 sandbox-fs 的 `generate_docx`;**禁止**用 `computercontroller` / shell 生成生产下载文件作为交付依据
|
||||
- 从 `oa/` 引用文档时,先 **复制** 到 `public/` 再写链接
|
||||
- 交付前 `list_dir public/` 自检;可跑 `npm run check:mindspace-public-links`
|
||||
|
||||
## 回复格式(必须)
|
||||
|
||||
@@ -44,9 +58,11 @@ description: 在专属 MindSpace 目录生成可公开访问的静态 HTML 报
|
||||
|
||||
- **必须**使用 `[页面标题](完整URL)`,不要只给裸 URL 或「点这里」
|
||||
- 页面写入 `public/` 时,URL **必须**包含 `/public/` 路径段(与磁盘路径一致)
|
||||
- 域名严格按本节模板拼接(`https://goo.tkmind.cn/MindSpace/<用户ID>/public/...`);拿不到真实前缀时,先给相对路径 `public/xxx.html` 说明,不要自己猜一个域名
|
||||
- 标题用页面真实主题名
|
||||
- 可同时给出相对路径(如 `public/malaysia-travel-guide.html`)
|
||||
- 说明:保存即生效,无需重启
|
||||
- 若生成了长图,同时给 `[长图预览](.../public/malaysia-travel-guide.long.png)` 和 `[下载长图](.../public/malaysia-travel-guide.html?download=long-image)`
|
||||
|
||||
## 信息流预览图(必须)
|
||||
|
||||
@@ -80,16 +96,17 @@ description: 在专属 MindSpace 目录生成可公开访问的静态 HTML 报
|
||||
|
||||
## 平台页脚标记(必须)
|
||||
|
||||
页脚平台联系行**必须**使用 `data-mindspace-page-tag="platform-brand"`,且邮箱/域名只用 **tkmind.cn**(如 `contact@tkmind.cn`),**禁止** `tkmind.ai`:
|
||||
页脚平台品牌行**必须**使用 `data-mindspace-page-tag="platform-brand"`,显示为 **TKMind · 智趣**,**禁止**使用邮箱或 `tkmind.ai`:
|
||||
|
||||
```html
|
||||
<p data-mindspace-page-tag="platform-brand">📧 contact@tkmind.cn</p>
|
||||
<p data-mindspace-page-tag="platform-brand">TKMind · 智趣</p>
|
||||
```
|
||||
|
||||
带 `data-mindspace-page-tag` 的区域为平台固定信息:用户在编辑模式中不可见、不可改;预览与发布后正常显示。
|
||||
|
||||
## 附带文件下载(Word / PDF)
|
||||
## 按需附带文件下载(Word / PDF)
|
||||
|
||||
- 只有用户明确要求 Word / PDF 下载时才生成二进制文件
|
||||
- 二进制文件用 `docx-generate` 脚本或平台允许的方式**单独生成**,保存到 `public/`(或 `oa/` 再复制到 `public/`)
|
||||
- 下载按钮示例:`<a href="report.docx" download>下载文档</a>`(与 HTML 同目录时用文件名即可)
|
||||
- **禁止** `<a href="data:application/vnd...;base64,...">` 内嵌 docx/pdf
|
||||
|
||||
@@ -26,3 +26,9 @@ description: 网页抓取与搜索技能:访问网页、查阅文档、搜索
|
||||
2. `extract_text: true`(默认)获取可读文本,`false` 获取原始 HTML
|
||||
3. 不要访问不明来源的链接,向用户确认后再访问
|
||||
4. 官方文档优先于第三方博客
|
||||
|
||||
## 国内网络环境(建议)
|
||||
|
||||
- 生产环境访问不了 `google.com`,优先用本技能的 `web_search`(DuckDuckGo)/`fetch_url`,避免直接拿 `computercontroller__web_scrape` 抓 Google 页面来回重试
|
||||
- `web_search` 连续几次没有可用结果时,改用 `fetch_url` 直接访问 `https://cn.bing.com/search?q=...` 或 `https://www.so.com/s?q=...` 这类国内可达的搜索入口
|
||||
- 百度/知乎/大众点评等站点有反爬拦截,遇到跳转或空结果就换个搜索源,不必在同一个来源上反复硬抓
|
||||
|
||||
@@ -15,6 +15,14 @@ function encodeDownloadFilename(filename) {
|
||||
return `attachment; filename="${encodeURIComponent(safe)}"; filename*=UTF-8''${encodeURIComponent(safe)}`;
|
||||
}
|
||||
|
||||
function sanitizeDownloadFilenameBase(value, fallback = 'mindspace-conversation-package') {
|
||||
const normalized = String(value ?? '')
|
||||
.replace(/[\\/:*?"<>|]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
return normalized || fallback;
|
||||
}
|
||||
|
||||
export function createGetConversationPackageHandler({
|
||||
registry,
|
||||
getRegistry,
|
||||
@@ -85,7 +93,11 @@ export function createDownloadConversationPackageManifestHandler({
|
||||
sessionId,
|
||||
});
|
||||
if (!manifest) return res.status(404).json({ message: '对话包不存在' });
|
||||
const filename = `${manifest.packageId || sessionId}-manifest.json`;
|
||||
const filenameBase = sanitizeDownloadFilenameBase(
|
||||
manifest.displayFolderName ?? manifest.title,
|
||||
'mindspace-conversation-package',
|
||||
);
|
||||
const filename = `${filenameBase}-manifest.json`;
|
||||
res.set('Content-Type', 'application/json; charset=utf-8');
|
||||
res.set('Content-Disposition', encodeDownloadFilename(filename));
|
||||
res.set('Cache-Control', 'private, no-store');
|
||||
@@ -141,4 +153,5 @@ export function createDownloadConversationPackageArtifactHandler({
|
||||
|
||||
export const conversationPackageRouteInternals = {
|
||||
encodeDownloadFilename,
|
||||
sanitizeDownloadFilenameBase,
|
||||
};
|
||||
|
||||
@@ -144,7 +144,12 @@ test('GET conversation package manifest downloads owned package metadata', async
|
||||
registry: {
|
||||
async readManifestForSession(input) {
|
||||
assert.deepEqual(input, { userId: 'user-1', sessionId: 'session-1' });
|
||||
return { packageId: 'cp_session-1', sessionId: 'session-1', artifacts: [] };
|
||||
return {
|
||||
packageId: 'cp_session-1',
|
||||
sessionId: 'session-1',
|
||||
displayFolderName: '旅行计划',
|
||||
artifacts: [],
|
||||
};
|
||||
},
|
||||
},
|
||||
userAuth: {
|
||||
@@ -164,12 +169,16 @@ test('GET conversation package manifest downloads owned package metadata', async
|
||||
|
||||
assert.equal(res.statusCode, 200);
|
||||
assert.equal(res.headers['content-type'], 'application/json; charset=utf-8');
|
||||
assert.match(res.headers['content-disposition'], /cp_session-1-manifest\.json/);
|
||||
assert.match(
|
||||
res.headers['content-disposition'],
|
||||
/%E6%97%85%E8%A1%8C%E8%AE%A1%E5%88%92-manifest\.json/,
|
||||
);
|
||||
assert.equal(res.headers['cache-control'], 'private, no-store');
|
||||
assert.equal(res.headers['x-request-id'], 'req-1');
|
||||
assert.deepEqual(JSON.parse(res.body), {
|
||||
packageId: 'cp_session-1',
|
||||
sessionId: 'session-1',
|
||||
displayFolderName: '旅行计划',
|
||||
artifacts: [],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,6 +41,10 @@ function optionalNumber(value) {
|
||||
return Number.isFinite(number) ? number : null;
|
||||
}
|
||||
|
||||
export function buildConversationDisplayFolderName(title) {
|
||||
return optionalString(title) ?? '本次对话文件夹';
|
||||
}
|
||||
|
||||
function safeSortOrder(value) {
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number)) return 0;
|
||||
@@ -212,6 +216,7 @@ export function buildConversationPackageManifest({ packageRecord, artifacts = []
|
||||
sessionId: record.sessionId,
|
||||
uri: buildConversationPackageUri({ userId: record.userId, sessionId: record.sessionId }),
|
||||
title: record.title,
|
||||
displayFolderName: buildConversationDisplayFolderName(record.title),
|
||||
status: record.status,
|
||||
storagePrefix: record.storagePrefix,
|
||||
manifestAssetId: record.manifestAssetId,
|
||||
|
||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
assertConversationArtifactKind,
|
||||
buildConversationDisplayFolderName,
|
||||
buildConversationPackageManifest,
|
||||
buildConversationPackageUri,
|
||||
buildConversationStoragePrefix,
|
||||
@@ -54,6 +55,11 @@ test('createConversationPackageRecord defaults storage prefix and timestamps', (
|
||||
);
|
||||
});
|
||||
|
||||
test('buildConversationDisplayFolderName prefers title and hides internal ids', () => {
|
||||
assert.equal(buildConversationDisplayFolderName('旅行计划'), '旅行计划');
|
||||
assert.equal(buildConversationDisplayFolderName(' '), '本次对话文件夹');
|
||||
});
|
||||
|
||||
test('createConversationArtifact validates artifact kind and preserves provenance', () => {
|
||||
assert.equal(assertConversationArtifactKind('public_html'), 'public_html');
|
||||
assert.throws(
|
||||
@@ -161,6 +167,7 @@ test('buildConversationPackageManifest sorts artifacts and emits stable public s
|
||||
|
||||
assert.equal(manifest.schemaVersion, 1);
|
||||
assert.equal(manifest.uri, 'mindspace://users/user-1/conversations/session-1');
|
||||
assert.equal(manifest.displayFolderName, '项目周报');
|
||||
assert.equal(manifest.storagePrefix, 'users/user-1/conversations/session-1');
|
||||
assert.deepEqual(
|
||||
manifest.artifacts.map((artifact) => artifact.artifactId),
|
||||
|
||||
@@ -22,12 +22,17 @@ const externalPackages = [
|
||||
'@img/sharp-libvips-darwin-arm64',
|
||||
'@node-rs/argon2',
|
||||
'@resvg/resvg-js',
|
||||
'chromium-bidi',
|
||||
'chromium-bidi/*',
|
||||
'debug',
|
||||
'express',
|
||||
'fsevents',
|
||||
'http-proxy-middleware',
|
||||
'jsonrepair',
|
||||
'mysql2',
|
||||
'mysql2/promise',
|
||||
'playwright',
|
||||
'playwright-core',
|
||||
'qrcode',
|
||||
'redis',
|
||||
'sharp',
|
||||
|
||||
+25
-284
@@ -5,10 +5,7 @@ import { CHAT_SKILL_OPTIONS, filterChatSkills } from '../utils/chatSkills';
|
||||
import { getMessageSaveActions } from '../utils/messageSave';
|
||||
import { getDisplayText } from '../utils/message';
|
||||
import {
|
||||
ApiError,
|
||||
buildMindSpaceConversationPackageManifestDownloadUrl,
|
||||
downloadChatMessageDocx,
|
||||
getMindSpaceConversationPackage,
|
||||
} from '../api/client';
|
||||
import {
|
||||
CHAT_IMAGE_UPLOAD_MAX_COUNT,
|
||||
@@ -22,16 +19,7 @@ import { ChatSharePreviewModal } from './ChatSharePreviewModal';
|
||||
import { MessageList } from './MessageList';
|
||||
import { PageSaveDialog } from './PageSaveDialog';
|
||||
import { VoiceInputButton } from './VoiceInputButton';
|
||||
import type {
|
||||
CapabilityMap,
|
||||
ChatState,
|
||||
Message,
|
||||
MindSpaceConversationArtifact,
|
||||
MindSpaceConversationPackage,
|
||||
PortalUser,
|
||||
Session,
|
||||
ToolConfirmation,
|
||||
} from '../types';
|
||||
import type { CapabilityMap, ChatState, Message, PortalUser, Session, ToolConfirmation } from '../types';
|
||||
import type { MindSpaceSaveCategory } from '../types';
|
||||
|
||||
const CHAT_PLACEHOLDER_PROMPTS = [
|
||||
@@ -121,50 +109,6 @@ function downloadBlob(blob: Blob, filename: string) {
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
}
|
||||
|
||||
function packageArtifactLabel(kind: MindSpaceConversationArtifact['kind']) {
|
||||
switch (kind) {
|
||||
case 'input_image':
|
||||
return '输入图片';
|
||||
case 'input_file':
|
||||
return '输入文件';
|
||||
case 'generated_image':
|
||||
return '生成图片';
|
||||
case 'generated_file':
|
||||
return '生成文件';
|
||||
case 'page':
|
||||
return '页面草稿';
|
||||
case 'public_html':
|
||||
return '公开页面';
|
||||
case 'long_image':
|
||||
return '长图';
|
||||
case 'thumbnail':
|
||||
return '缩略图';
|
||||
case 'docx':
|
||||
return 'Word 文档';
|
||||
case 'pdf':
|
||||
return 'PDF';
|
||||
default:
|
||||
return '文件';
|
||||
}
|
||||
}
|
||||
|
||||
function formatPackageArtifactSize(sizeBytes?: number) {
|
||||
if (!Number.isFinite(sizeBytes)) return null;
|
||||
const bytes = Number(sizeBytes);
|
||||
if (bytes < 1024) return `${bytes}B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes < 10 * 1024 ? 1 : 0)}KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(bytes < 10 * 1024 * 1024 ? 1 : 0)}MB`;
|
||||
}
|
||||
|
||||
function groupPackageArtifacts(artifacts: MindSpaceConversationArtifact[]) {
|
||||
const groups = new Map<string, MindSpaceConversationArtifact[]>();
|
||||
artifacts.forEach((artifact) => {
|
||||
const label = packageArtifactLabel(artifact.kind);
|
||||
groups.set(label, [...(groups.get(label) ?? []), artifact]);
|
||||
});
|
||||
return Array.from(groups.entries());
|
||||
}
|
||||
|
||||
export function ChatPanel({
|
||||
variant,
|
||||
user,
|
||||
@@ -227,12 +171,6 @@ export function ChatPanel({
|
||||
const [uploadingImage, setUploadingImage] = useState(false);
|
||||
const [imageError, setImageError] = useState<string | null>(null);
|
||||
const [voiceStopSignal, setVoiceStopSignal] = useState(0);
|
||||
const [packagePanelOpen, setPackagePanelOpen] = useState(false);
|
||||
const [packageLoading, setPackageLoading] = useState(false);
|
||||
const [conversationPackage, setConversationPackage] = useState<MindSpaceConversationPackage | null>(null);
|
||||
const [packageError, setPackageError] = useState<string | null>(null);
|
||||
const [packageFilter, setPackageFilter] = useState<MindSpaceConversationArtifact['kind'] | 'all'>('all');
|
||||
const [packageMessageFilter, setPackageMessageFilter] = useState<string | 'all'>('all');
|
||||
const [randomPrompt] = useState(
|
||||
() => CHAT_PLACEHOLDER_PROMPTS[Math.floor(Math.random() * CHAT_PLACEHOLDER_PROMPTS.length)],
|
||||
);
|
||||
@@ -299,43 +237,8 @@ export function ChatPanel({
|
||||
initializedSessionRef.current = null;
|
||||
pendingOlderLoadRef.current = null;
|
||||
nearBottomRef.current = true;
|
||||
setPackagePanelOpen(false);
|
||||
setPackageLoading(false);
|
||||
setConversationPackage(null);
|
||||
setPackageError(null);
|
||||
setPackageFilter('all');
|
||||
setPackageMessageFilter('all');
|
||||
}, [session?.id]);
|
||||
|
||||
const loadConversationPackage = useCallback(async () => {
|
||||
if (!session?.id) return;
|
||||
setPackageLoading(true);
|
||||
setPackageError(null);
|
||||
try {
|
||||
const data = await getMindSpaceConversationPackage(session.id);
|
||||
setConversationPackage(data);
|
||||
setPackageFilter((current) =>
|
||||
current === 'all' || data.artifacts.some((artifact) => artifact.kind === current) ? current : 'all',
|
||||
);
|
||||
setPackageMessageFilter((current) =>
|
||||
current === 'all' || (data.messages ?? []).some((message) => message.messageId === current) ? current : 'all',
|
||||
);
|
||||
} catch (err) {
|
||||
setConversationPackage(null);
|
||||
if (err instanceof ApiError && err.status === 404) {
|
||||
setPackageError('这个对话还没有形成文件夹内容');
|
||||
} else {
|
||||
setPackageError(err instanceof Error ? err.message : '对话文件夹加载失败');
|
||||
}
|
||||
} finally {
|
||||
setPackageLoading(false);
|
||||
}
|
||||
}, [session?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (packagePanelOpen) void loadConversationPackage();
|
||||
}, [loadConversationPackage, packagePanelOpen]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = mainRef.current;
|
||||
if (!container) return;
|
||||
@@ -420,10 +323,20 @@ export function ChatPanel({
|
||||
? '随时召唤你的小助手吧'
|
||||
: randomPrompt;
|
||||
const connectStatusText =
|
||||
chatState === 'connecting'
|
||||
? '正在连接会话…'
|
||||
: chatState === 'waiting'
|
||||
? '消息已收到,正在连接后台…'
|
||||
uploadingImage
|
||||
? '正在上传图片,发出后会自动继续…'
|
||||
: chatState === 'connecting'
|
||||
? '正在创建会话…'
|
||||
: chatState === 'waiting'
|
||||
? '请求已发出,正在等待后台开始处理…'
|
||||
: null;
|
||||
const sendButtonLabel =
|
||||
uploadingImage
|
||||
? '上传中…'
|
||||
: chatState === 'connecting'
|
||||
? '连接中…'
|
||||
: chatState === 'waiting'
|
||||
? '提交中…'
|
||||
: null;
|
||||
|
||||
const mergeVoiceText = (spoken: string) => {
|
||||
@@ -457,32 +370,6 @@ export function ChatPanel({
|
||||
const voiceDisabled = busy || !!pendingTool || chatState === 'error' || offlineBlocked;
|
||||
const canSubmit = input.trim() || pendingImages.length > 0;
|
||||
const imageAttachmentDisabled = !onUploadImage || busy || uploadingImage || offlineBlocked || !!pendingTool;
|
||||
const packageArtifacts = conversationPackage?.artifacts ?? [];
|
||||
const packageFilterOptions = Array.from(
|
||||
packageArtifacts.reduce((counts, artifact) => {
|
||||
counts.set(artifact.kind, (counts.get(artifact.kind) ?? 0) + 1);
|
||||
return counts;
|
||||
}, new Map<MindSpaceConversationArtifact['kind'], number>()),
|
||||
).sort(([left], [right]) => packageArtifactLabel(left).localeCompare(packageArtifactLabel(right), 'zh-CN'));
|
||||
const packageMessageOptions = (conversationPackage?.messages ?? [])
|
||||
.filter((message) => message.artifactIds.length > 0)
|
||||
.map((message, index) => ({
|
||||
id: message.messageId,
|
||||
label: `消息 ${index + 1}`,
|
||||
count: message.artifactIds.length,
|
||||
artifactIds: new Set(message.artifactIds),
|
||||
}));
|
||||
const selectedMessageArtifacts =
|
||||
packageMessageFilter === 'all'
|
||||
? null
|
||||
: packageMessageOptions.find((message) => message.id === packageMessageFilter)?.artifactIds ?? null;
|
||||
const filteredPackageArtifacts =
|
||||
packageArtifacts.filter((artifact) => {
|
||||
const kindMatched = packageFilter === 'all' || artifact.kind === packageFilter;
|
||||
const messageMatched = !selectedMessageArtifacts || selectedMessageArtifacts.has(artifact.artifactId);
|
||||
return kindMatched && messageMatched;
|
||||
});
|
||||
const packageGroups = groupPackageArtifacts(filteredPackageArtifacts);
|
||||
|
||||
const revokePendingImage = (item: PendingChatImage) => {
|
||||
if (item.previewUrl.startsWith('blob:')) URL.revokeObjectURL(item.previewUrl);
|
||||
@@ -544,8 +431,9 @@ export function ChatPanel({
|
||||
return { accepted, message };
|
||||
}, [pendingImageBytes, pendingImages.length]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const trimmed = input.trim();
|
||||
const submitText = useCallback(async (textOverride?: string) => {
|
||||
const baseText = typeof textOverride === 'string' ? textOverride : input;
|
||||
const trimmed = baseText.trim();
|
||||
if ((!trimmed && pendingImages.length === 0) || voiceDisabled) return;
|
||||
if (pendingImages.length > 0 && !onUploadImage) {
|
||||
setImageError('当前会话暂不支持图片发送');
|
||||
@@ -642,7 +530,11 @@ export function ChatPanel({
|
||||
}
|
||||
setUploadingImage(false);
|
||||
suppressVoiceUpdateRef.current = false;
|
||||
};
|
||||
}, [input, onSubmit, onUploadImage, pendingImages, revokePendingImage, voiceDisabled, voiceRecording]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
await submitText();
|
||||
}, [submitText]);
|
||||
|
||||
const handleImageInputChange = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
if (!onUploadImage) return;
|
||||
@@ -821,158 +713,7 @@ export function ChatPanel({
|
||||
/>
|
||||
)}
|
||||
|
||||
{packagePanelOpen && !compact && (
|
||||
<div className="conversation-package-backdrop" role="presentation" onClick={() => setPackagePanelOpen(false)}>
|
||||
<section
|
||||
className="conversation-package-panel"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="本次对话文件夹"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="conversation-package-head">
|
||||
<div>
|
||||
<p>MindSpace 对话包</p>
|
||||
<h2>{conversationPackage?.title || session?.name || '本次对话文件夹'}</h2>
|
||||
</div>
|
||||
<button type="button" className="conversation-package-close" onClick={() => setPackagePanelOpen(false)}>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
<div className="conversation-package-meta">
|
||||
<span>{packageArtifacts.length} 个项目</span>
|
||||
{conversationPackage?.uri && <code>{conversationPackage.uri}</code>}
|
||||
</div>
|
||||
{packageLoading && <div className="conversation-package-state">正在整理这个对话里的文件…</div>}
|
||||
{!packageLoading && packageError && (
|
||||
<div className="conversation-package-state conversation-package-state-muted">
|
||||
<strong>对话包暂时打不开</strong>
|
||||
<span>{packageError}</span>
|
||||
<button type="button" className="ghost-btn" onClick={() => void loadConversationPackage()}>
|
||||
重试加载
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!packageLoading && !packageError && conversationPackage && packageArtifacts.length === 0 && (
|
||||
<div className="conversation-package-state conversation-package-state-muted">
|
||||
<strong>这个对话还没有文件</strong>
|
||||
<span>发送图片、保存页面、生成公开页或下载文档后,这里会自动聚合成一个对话文件夹。</span>
|
||||
</div>
|
||||
)}
|
||||
{!packageLoading && !packageError && conversationPackage && packageArtifacts.length > 0 && (
|
||||
<>
|
||||
<div className="conversation-package-filters" aria-label="筛选对话包文件">
|
||||
<button
|
||||
type="button"
|
||||
className={packageFilter === 'all' ? 'is-active' : ''}
|
||||
onClick={() => setPackageFilter('all')}
|
||||
>
|
||||
全部 <span>{packageArtifacts.length}</span>
|
||||
</button>
|
||||
{packageFilterOptions.map(([kind, count]) => (
|
||||
<button
|
||||
type="button"
|
||||
key={kind}
|
||||
className={packageFilter === kind ? 'is-active' : ''}
|
||||
onClick={() => setPackageFilter(kind)}
|
||||
>
|
||||
{packageArtifactLabel(kind)} <span>{count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{packageMessageOptions.length > 0 && (
|
||||
<div className="conversation-package-message-filters" aria-label="按消息筛选对话包文件">
|
||||
<span>按消息</span>
|
||||
<button
|
||||
type="button"
|
||||
className={packageMessageFilter === 'all' ? 'is-active' : ''}
|
||||
onClick={() => setPackageMessageFilter('all')}
|
||||
>
|
||||
全部消息
|
||||
</button>
|
||||
{packageMessageOptions.map((message) => (
|
||||
<button
|
||||
type="button"
|
||||
key={message.id}
|
||||
className={packageMessageFilter === message.id ? 'is-active' : ''}
|
||||
onClick={() => setPackageMessageFilter(message.id)}
|
||||
>
|
||||
{message.label} <span>{message.count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{filteredPackageArtifacts.length === 0 ? (
|
||||
<div className="conversation-package-state conversation-package-state-muted">
|
||||
<strong>当前筛选没有项目</strong>
|
||||
<span>切换到“全部”或“全部消息”可以查看当前对话包里的其它产物。</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="conversation-package-groups">
|
||||
{packageGroups.map(([label, artifacts]) => (
|
||||
<div className="conversation-package-group" key={label}>
|
||||
<h3>{label}</h3>
|
||||
<div className="conversation-package-items">
|
||||
{artifacts.map((artifact) => {
|
||||
const sizeLabel = formatPackageArtifactSize(artifact.sizeBytes);
|
||||
return (
|
||||
<a
|
||||
key={artifact.artifactId}
|
||||
className="conversation-package-item"
|
||||
href={artifact.canonicalUrl || undefined}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-disabled={!artifact.canonicalUrl}
|
||||
onClick={(event) => {
|
||||
if (!artifact.canonicalUrl) event.preventDefault();
|
||||
}}
|
||||
>
|
||||
<span className="conversation-package-item-kind">{packageArtifactLabel(artifact.kind)}</span>
|
||||
<strong>{artifact.displayName || artifact.canonicalUrl || artifact.artifactId}</strong>
|
||||
<span>
|
||||
{[
|
||||
artifact.mimeType,
|
||||
sizeLabel,
|
||||
].filter(Boolean).join(' · ') || '已记录到对话包'}
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="conversation-package-actions">
|
||||
<button type="button" className="ghost-btn" onClick={() => void loadConversationPackage()}>
|
||||
刷新
|
||||
</button>
|
||||
{session?.id && conversationPackage && (
|
||||
<a
|
||||
className="ghost-btn conversation-package-download"
|
||||
href={buildMindSpaceConversationPackageManifestDownloadUrl(session.id)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
下载清单
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<footer className={compact ? 'space-chat-panel-footer' : `footer${showHomeWelcome ? ' footer-home' : ''}`}>
|
||||
{!compact && session?.id && (
|
||||
<div className="conversation-package-strip">
|
||||
<button type="button" onClick={() => setPackagePanelOpen(true)}>
|
||||
本次对话文件夹
|
||||
</button>
|
||||
<span>查看这次对话里的图片、文件、页面和公开产物</span>
|
||||
</div>
|
||||
)}
|
||||
{connectStatusText && (
|
||||
<div className="chat-connect-status" role="status" aria-live="polite">
|
||||
<ChatLoadingSpinner />
|
||||
@@ -1077,7 +818,7 @@ export function ChatPanel({
|
||||
<ChatSkillPicker
|
||||
skills={chatSkills}
|
||||
disabled={voiceDisabled}
|
||||
onSelect={onSubmit}
|
||||
onSelect={submitText}
|
||||
onPrefill={setInput}
|
||||
/>
|
||||
)}
|
||||
@@ -1092,7 +833,7 @@ export function ChatPanel({
|
||||
disabled={!canSubmit || voiceDisabled || uploadingImage}
|
||||
onClick={() => void handleSubmit()}
|
||||
>
|
||||
发送
|
||||
{sendButtonLabel ?? '发送'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+226
-12
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useChat } from '../context/ChatProvider';
|
||||
import { INSUFFICIENT_BALANCE_NOTICE } from '../hooks/useTKMindChat';
|
||||
import type { CapabilityMap, PortalUser } from '../types';
|
||||
import type { CapabilityMap, PortalUser, SessionSummary } from '../types';
|
||||
import { useNetworkStatus } from '../hooks/useNetworkStatus';
|
||||
import { getSessionDisplayName } from '../utils/sessions';
|
||||
import { BalanceRing } from './BalanceRing';
|
||||
@@ -9,12 +9,149 @@ import { HistorySidebar } from './HistorySidebar';
|
||||
import { TKMindAvatar } from './TKMindAvatar';
|
||||
import { ChatPanel } from './ChatPanel';
|
||||
import { ChatLoadingSpinner } from './ChatLoadingSpinner';
|
||||
import { ConversationPackagePanel } from './ConversationPackagePanel';
|
||||
import { WechatBindPrompt } from './WechatBindPrompt';
|
||||
import { WechatAccountButton } from './WechatAccountButton';
|
||||
import { ChatHeaderMoreMenu } from './ChatHeaderMoreMenu';
|
||||
import { NotificationCenter } from './NotificationCenter';
|
||||
import type { MindSpaceSaveCategory } from '../types';
|
||||
|
||||
function RecentSessionRail({
|
||||
sessions,
|
||||
activeSessionId,
|
||||
armedSessionId,
|
||||
loading,
|
||||
loadingMore,
|
||||
hasMore,
|
||||
chipWidth,
|
||||
onSelect,
|
||||
onLoadMore,
|
||||
}: {
|
||||
sessions: SessionSummary[];
|
||||
activeSessionId?: string | null;
|
||||
armedSessionId?: string | null;
|
||||
loading: boolean;
|
||||
loadingMore: boolean;
|
||||
hasMore: boolean;
|
||||
chipWidth?: number | null;
|
||||
onSelect: (session: SessionSummary) => void;
|
||||
onLoadMore: () => void;
|
||||
}) {
|
||||
const compactLabel = (value: string, maxChars = 8) => {
|
||||
const trimmed = String(value ?? '').trim();
|
||||
if (!trimmed) return '未命名';
|
||||
return trimmed.length > maxChars ? `${trimmed.slice(0, maxChars)}...` : trimmed;
|
||||
};
|
||||
|
||||
const railRef = useRef<HTMLDivElement | null>(null);
|
||||
const recentSessions = useMemo(() => sessions, [sessions]);
|
||||
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
||||
const [canScrollRight, setCanScrollRight] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const node = railRef.current;
|
||||
if (!node) return;
|
||||
|
||||
const updateScrollState = () => {
|
||||
const maxScrollLeft = Math.max(0, node.scrollWidth - node.clientWidth);
|
||||
setCanScrollLeft(node.scrollLeft > 8);
|
||||
setCanScrollRight(node.scrollLeft < maxScrollLeft - 8);
|
||||
};
|
||||
|
||||
updateScrollState();
|
||||
node.addEventListener('scroll', updateScrollState, { passive: true });
|
||||
window.addEventListener('resize', updateScrollState);
|
||||
return () => {
|
||||
node.removeEventListener('scroll', updateScrollState);
|
||||
window.removeEventListener('resize', updateScrollState);
|
||||
};
|
||||
}, [recentSessions.length]);
|
||||
|
||||
useEffect(() => {
|
||||
const node = railRef.current;
|
||||
if (!node) return;
|
||||
|
||||
const maybeLoadMore = () => {
|
||||
if (!hasMore || loadingMore) return;
|
||||
const remaining = node.scrollWidth - (node.scrollLeft + node.clientWidth);
|
||||
if (remaining <= 96) onLoadMore();
|
||||
};
|
||||
|
||||
maybeLoadMore();
|
||||
node.addEventListener('scroll', maybeLoadMore, { passive: true });
|
||||
return () => node.removeEventListener('scroll', maybeLoadMore);
|
||||
}, [hasMore, loadingMore, onLoadMore, recentSessions.length]);
|
||||
|
||||
const scrollByPage = (direction: -1 | 1) => {
|
||||
const node = railRef.current;
|
||||
if (!node) return;
|
||||
const amount = Math.max(180, Math.round(node.clientWidth * 0.82)) * direction;
|
||||
node.scrollBy({ left: amount, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
if (!loading && recentSessions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="header-session-rail"
|
||||
aria-label="最近对话入口"
|
||||
style={
|
||||
chipWidth && chipWidth > 0
|
||||
? ({ ['--header-session-chip-width' as string]: `${chipWidth}px` })
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="header-session-rail-arrow header-session-rail-arrow-left"
|
||||
aria-label="查看更早的最近对话"
|
||||
disabled={!canScrollLeft}
|
||||
onClick={() => scrollByPage(-1)}
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<div
|
||||
className="header-session-rail-track"
|
||||
ref={railRef}
|
||||
>
|
||||
{recentSessions.map((item) => {
|
||||
const active = item.id === activeSessionId;
|
||||
const armed = item.id === armedSessionId;
|
||||
const displayName = getSessionDisplayName(item);
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={`header-session-chip${active ? ' is-active' : ''}${armed ? ' is-armed' : ''}`}
|
||||
aria-pressed={active || armed}
|
||||
title={displayName}
|
||||
onClick={() => onSelect(item)}
|
||||
>
|
||||
<strong>{compactLabel(displayName, 8)}</strong>
|
||||
<span>{armed ? '再次点击' : item.message_count > 0 ? `${item.message_count}条` : '继续'}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{(loading || loadingMore) && (
|
||||
<div className="header-session-chip header-session-chip-loading" aria-hidden="true">
|
||||
<strong>{loading ? '加载中…' : '继续加载…'}</strong>
|
||||
<span>{loading ? '正在准备最近对话' : '滑到这里会自动接上更多'}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="header-session-rail-arrow header-session-rail-arrow-right"
|
||||
aria-label="查看更多最近对话"
|
||||
disabled={!canScrollRight}
|
||||
onClick={() => scrollByPage(1)}
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatView({
|
||||
user,
|
||||
capabilities,
|
||||
@@ -72,6 +209,29 @@ export function ChatView({
|
||||
} = useChat();
|
||||
const online = useNetworkStatus();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [packagePanelSession, setPackagePanelSession] = useState<SessionSummary | null>(null);
|
||||
const [armedSessionId, setArmedSessionId] = useState<string | null>(null);
|
||||
const [desktopChipWidth, setDesktopChipWidth] = useState<number | null>(null);
|
||||
const spaceButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const node = spaceButtonRef.current;
|
||||
if (!node) return;
|
||||
|
||||
const updateWidth = () => {
|
||||
const nextWidth = Math.round(node.getBoundingClientRect().width);
|
||||
if (nextWidth > 0) setDesktopChipWidth(nextWidth);
|
||||
};
|
||||
|
||||
updateWidth();
|
||||
const resizeObserver = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(updateWidth) : null;
|
||||
resizeObserver?.observe(node);
|
||||
window.addEventListener('resize', updateWidth);
|
||||
return () => {
|
||||
resizeObserver?.disconnect();
|
||||
window.removeEventListener('resize', updateWidth);
|
||||
};
|
||||
}, [onOpenSpace]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sidebarOpen) {
|
||||
@@ -86,17 +246,43 @@ export function ChatView({
|
||||
setSidebarOpen(false);
|
||||
};
|
||||
|
||||
const handleOpenConversationPackage = (sessionSummary: SessionSummary) => {
|
||||
setPackagePanelSession(sessionSummary);
|
||||
};
|
||||
|
||||
const handleRecentSessionSelect = (sessionSummary: SessionSummary) => {
|
||||
const isSameSession = session?.id === sessionSummary.id;
|
||||
const isArmed = armedSessionId === sessionSummary.id;
|
||||
if (isSameSession && isArmed) {
|
||||
handleOpenConversationPackage(sessionSummary);
|
||||
setArmedSessionId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setPackagePanelSession(null);
|
||||
setArmedSessionId(sessionSummary.id);
|
||||
if (!isSameSession) {
|
||||
handleSelectSession(sessionSummary.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNewSession = () => {
|
||||
void newSession();
|
||||
setSidebarOpen(false);
|
||||
};
|
||||
|
||||
const isConnectingTitle = !session && chatState !== 'idle';
|
||||
const pendingSessionTitle =
|
||||
chatState === 'connecting'
|
||||
? '正在创建新对话…'
|
||||
: chatState === 'waiting'
|
||||
? '请求已发出…'
|
||||
: '连接中…';
|
||||
const sessionTitle = session
|
||||
? getSessionDisplayName(session)
|
||||
: chatState === 'idle'
|
||||
? '新对话'
|
||||
: '连接中…';
|
||||
: pendingSessionTitle;
|
||||
|
||||
const moreMenuItems = [
|
||||
{
|
||||
@@ -104,9 +290,6 @@ export function ChatView({
|
||||
label: '新会话',
|
||||
onClick: () => void handleNewSession(),
|
||||
},
|
||||
...(onOpenFeedback
|
||||
? [{ id: 'feedback', label: '反馈与建议', onClick: () => onOpenFeedback() }]
|
||||
: []),
|
||||
...(onOpenAdmin
|
||||
? [{ id: 'admin', label: '管理', onClick: () => onOpenAdmin() }]
|
||||
: []),
|
||||
@@ -154,6 +337,17 @@ export function ChatView({
|
||||
</div>
|
||||
</div>
|
||||
<div className="header-actions header-actions-desktop">
|
||||
<RecentSessionRail
|
||||
sessions={sessions}
|
||||
activeSessionId={session?.id}
|
||||
armedSessionId={armedSessionId}
|
||||
loading={sessionsLoading}
|
||||
loadingMore={sessionsLoadingMore}
|
||||
hasMore={sessionsHasMore}
|
||||
chipWidth={desktopChipWidth}
|
||||
onSelect={handleRecentSessionSelect}
|
||||
onLoadMore={() => void loadMoreSessions()}
|
||||
/>
|
||||
{typeof balanceCents === 'number' && (
|
||||
<BalanceRing
|
||||
balanceCents={balanceCents}
|
||||
@@ -171,15 +365,15 @@ export function ChatView({
|
||||
</button>
|
||||
)}
|
||||
{onOpenSpace && (
|
||||
<button type="button" className="ghost-btn" onClick={() => onOpenSpace?.()}>
|
||||
<button
|
||||
ref={spaceButtonRef}
|
||||
type="button"
|
||||
className="ghost-btn"
|
||||
onClick={() => onOpenSpace?.()}
|
||||
>
|
||||
我的空间
|
||||
</button>
|
||||
)}
|
||||
{onOpenFeedback && (
|
||||
<button type="button" className="ghost-btn" onClick={() => onOpenFeedback()}>
|
||||
反馈
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="ghost-btn" onClick={() => void handleNewSession()}>
|
||||
新会话
|
||||
</button>
|
||||
@@ -192,6 +386,17 @@ export function ChatView({
|
||||
</div>
|
||||
|
||||
<div className="header-actions header-actions-mobile">
|
||||
<RecentSessionRail
|
||||
sessions={sessions}
|
||||
activeSessionId={session?.id}
|
||||
armedSessionId={armedSessionId}
|
||||
loading={sessionsLoading}
|
||||
loadingMore={sessionsLoadingMore}
|
||||
hasMore={sessionsHasMore}
|
||||
chipWidth={null}
|
||||
onSelect={handleRecentSessionSelect}
|
||||
onLoadMore={() => void loadMoreSessions()}
|
||||
/>
|
||||
{typeof balanceCents === 'number' && (
|
||||
<BalanceRing
|
||||
balanceCents={balanceCents}
|
||||
@@ -311,6 +516,15 @@ export function ChatView({
|
||||
onOpenSpace?.({ categoryCode: result.categoryCode });
|
||||
}}
|
||||
/>
|
||||
<ConversationPackagePanel
|
||||
sessionId={packagePanelSession?.id ?? null}
|
||||
sessionName={packagePanelSession ? getSessionDisplayName(packagePanelSession) : null}
|
||||
open={Boolean(packagePanelSession)}
|
||||
onClose={() => {
|
||||
setPackagePanelSession(null);
|
||||
setArmedSessionId(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
ApiError,
|
||||
buildMindSpaceConversationPackageManifestDownloadUrl,
|
||||
getMindSpaceConversationPackage,
|
||||
} from '../api/client';
|
||||
import type {
|
||||
MindSpaceConversationArtifact,
|
||||
MindSpaceConversationPackage,
|
||||
} from '../types';
|
||||
|
||||
function packageArtifactLabel(kind: MindSpaceConversationArtifact['kind']) {
|
||||
switch (kind) {
|
||||
case 'input_image':
|
||||
return '输入图片';
|
||||
case 'input_file':
|
||||
return '输入文件';
|
||||
case 'generated_image':
|
||||
return '生成图片';
|
||||
case 'generated_file':
|
||||
return '生成文件';
|
||||
case 'page':
|
||||
return '页面草稿';
|
||||
case 'public_html':
|
||||
return '公开页面';
|
||||
case 'long_image':
|
||||
return '长图';
|
||||
case 'thumbnail':
|
||||
return '缩略图';
|
||||
case 'docx':
|
||||
return 'Word 文档';
|
||||
case 'pdf':
|
||||
return 'PDF';
|
||||
default:
|
||||
return '文件';
|
||||
}
|
||||
}
|
||||
|
||||
function formatPackageArtifactSize(sizeBytes?: number) {
|
||||
if (!Number.isFinite(sizeBytes)) return null;
|
||||
const bytes = Number(sizeBytes);
|
||||
if (bytes < 1024) return `${bytes}B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes < 10 * 1024 ? 1 : 0)}KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(bytes < 10 * 1024 * 1024 ? 1 : 0)}MB`;
|
||||
}
|
||||
|
||||
function groupPackageArtifacts(artifacts: MindSpaceConversationArtifact[]) {
|
||||
const groups = new Map<string, MindSpaceConversationArtifact[]>();
|
||||
artifacts.forEach((artifact) => {
|
||||
const label = packageArtifactLabel(artifact.kind);
|
||||
groups.set(label, [...(groups.get(label) ?? []), artifact]);
|
||||
});
|
||||
return Array.from(groups.entries());
|
||||
}
|
||||
|
||||
function resolveConversationPackageName(
|
||||
pkg?: MindSpaceConversationPackage | null,
|
||||
sessionName?: string | null,
|
||||
) {
|
||||
return pkg?.displayFolderName || pkg?.title || sessionName || '本次对话文件夹';
|
||||
}
|
||||
|
||||
export function ConversationPackagePanel({
|
||||
sessionId,
|
||||
sessionName,
|
||||
open,
|
||||
onClose,
|
||||
}: {
|
||||
sessionId: string | null;
|
||||
sessionName?: string | null;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [packageLoading, setPackageLoading] = useState(false);
|
||||
const [conversationPackage, setConversationPackage] = useState<MindSpaceConversationPackage | null>(null);
|
||||
const [packageError, setPackageError] = useState<string | null>(null);
|
||||
const [packageFilter, setPackageFilter] = useState<MindSpaceConversationArtifact['kind'] | 'all'>('all');
|
||||
const [packageMessageFilter, setPackageMessageFilter] = useState<string | 'all'>('all');
|
||||
const packageCacheRef = useRef(new Map<string, MindSpaceConversationPackage>());
|
||||
|
||||
useEffect(() => {
|
||||
const cachedPackage = sessionId ? packageCacheRef.current.get(sessionId) ?? null : null;
|
||||
setPackageLoading(false);
|
||||
setConversationPackage(cachedPackage);
|
||||
setPackageError(null);
|
||||
setPackageFilter('all');
|
||||
setPackageMessageFilter('all');
|
||||
}, [sessionId]);
|
||||
|
||||
const loadConversationPackage = useCallback(async () => {
|
||||
if (!sessionId) return;
|
||||
const cachedPackage = packageCacheRef.current.get(sessionId) ?? null;
|
||||
setPackageLoading(true);
|
||||
setPackageError(null);
|
||||
try {
|
||||
const data = await getMindSpaceConversationPackage(sessionId);
|
||||
packageCacheRef.current.set(sessionId, data);
|
||||
setConversationPackage(data);
|
||||
setPackageFilter((current) =>
|
||||
current === 'all' || data.artifacts.some((artifact) => artifact.kind === current) ? current : 'all',
|
||||
);
|
||||
setPackageMessageFilter((current) =>
|
||||
current === 'all' || (data.messages ?? []).some((message) => message.messageId === current) ? current : 'all',
|
||||
);
|
||||
} catch (err) {
|
||||
if (!cachedPackage) setConversationPackage(null);
|
||||
if (err instanceof ApiError && err.status === 404) {
|
||||
setPackageError('这个对话还没有形成文件夹内容');
|
||||
} else {
|
||||
setPackageError(err instanceof Error ? err.message : '对话文件夹加载失败');
|
||||
}
|
||||
} finally {
|
||||
setPackageLoading(false);
|
||||
}
|
||||
}, [sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) void loadConversationPackage();
|
||||
}, [loadConversationPackage, open]);
|
||||
|
||||
const packageArtifacts = conversationPackage?.artifacts ?? [];
|
||||
const conversationPackageName = resolveConversationPackageName(conversationPackage, sessionName ?? null);
|
||||
const packageFilterOptions = Array.from(
|
||||
packageArtifacts.reduce((counts, artifact) => {
|
||||
counts.set(artifact.kind, (counts.get(artifact.kind) ?? 0) + 1);
|
||||
return counts;
|
||||
}, new Map<MindSpaceConversationArtifact['kind'], number>()),
|
||||
).sort(([left], [right]) => packageArtifactLabel(left).localeCompare(packageArtifactLabel(right), 'zh-CN'));
|
||||
const packageMessageOptions = (conversationPackage?.messages ?? [])
|
||||
.filter((message) => message.artifactIds.length > 0)
|
||||
.map((message, index) => ({
|
||||
id: message.messageId,
|
||||
label: `消息 ${index + 1}`,
|
||||
count: message.artifactIds.length,
|
||||
artifactIds: new Set(message.artifactIds),
|
||||
}));
|
||||
const selectedMessageArtifacts =
|
||||
packageMessageFilter === 'all'
|
||||
? null
|
||||
: packageMessageOptions.find((message) => message.id === packageMessageFilter)?.artifactIds ?? null;
|
||||
const filteredPackageArtifacts =
|
||||
packageArtifacts.filter((artifact) => {
|
||||
const kindMatched = packageFilter === 'all' || artifact.kind === packageFilter;
|
||||
const messageMatched = !selectedMessageArtifacts || selectedMessageArtifacts.has(artifact.artifactId);
|
||||
return kindMatched && messageMatched;
|
||||
});
|
||||
const packageGroups = useMemo(() => groupPackageArtifacts(filteredPackageArtifacts), [filteredPackageArtifacts]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="conversation-package-backdrop" role="presentation" onClick={onClose}>
|
||||
<section
|
||||
className="conversation-package-panel"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="本次对话文件夹"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="conversation-package-head">
|
||||
<div>
|
||||
<p>MindSpace 对话包</p>
|
||||
<h2>{conversationPackageName}</h2>
|
||||
</div>
|
||||
<button type="button" className="conversation-package-close" onClick={onClose}>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
<div className="conversation-package-meta">
|
||||
<span>{packageArtifacts.length} 个项目</span>
|
||||
<span>文件夹名:{conversationPackageName}</span>
|
||||
</div>
|
||||
{packageLoading && <div className="conversation-package-state">正在整理这个对话里的文件…</div>}
|
||||
{!packageLoading && packageError && (
|
||||
<div className="conversation-package-state conversation-package-state-muted">
|
||||
<strong>对话包暂时打不开</strong>
|
||||
<span>{packageError}</span>
|
||||
<button type="button" className="ghost-btn" onClick={() => void loadConversationPackage()}>
|
||||
重试加载
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!packageLoading && !packageError && conversationPackage && packageArtifacts.length === 0 && (
|
||||
<div className="conversation-package-state conversation-package-state-muted">
|
||||
<strong>这个对话还没有文件</strong>
|
||||
<span>发送图片、保存页面、生成公开页或下载文档后,这里会自动聚合成一个对话文件夹。</span>
|
||||
</div>
|
||||
)}
|
||||
{!packageLoading && !packageError && conversationPackage && packageArtifacts.length > 0 && (
|
||||
<>
|
||||
<div className="conversation-package-filters" aria-label="筛选对话包文件">
|
||||
<button
|
||||
type="button"
|
||||
className={packageFilter === 'all' ? 'is-active' : ''}
|
||||
onClick={() => setPackageFilter('all')}
|
||||
>
|
||||
全部 <span>{packageArtifacts.length}</span>
|
||||
</button>
|
||||
{packageFilterOptions.map(([kind, count]) => (
|
||||
<button
|
||||
type="button"
|
||||
key={kind}
|
||||
className={packageFilter === kind ? 'is-active' : ''}
|
||||
onClick={() => setPackageFilter(kind)}
|
||||
>
|
||||
{packageArtifactLabel(kind)} <span>{count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{packageMessageOptions.length > 0 && (
|
||||
<div className="conversation-package-message-filters" aria-label="按消息筛选对话包文件">
|
||||
<span>按消息</span>
|
||||
<button
|
||||
type="button"
|
||||
className={packageMessageFilter === 'all' ? 'is-active' : ''}
|
||||
onClick={() => setPackageMessageFilter('all')}
|
||||
>
|
||||
全部消息
|
||||
</button>
|
||||
{packageMessageOptions.map((message) => (
|
||||
<button
|
||||
type="button"
|
||||
key={message.id}
|
||||
className={packageMessageFilter === message.id ? 'is-active' : ''}
|
||||
onClick={() => setPackageMessageFilter(message.id)}
|
||||
>
|
||||
{message.label} <span>{message.count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{filteredPackageArtifacts.length === 0 ? (
|
||||
<div className="conversation-package-state conversation-package-state-muted">
|
||||
<strong>当前筛选没有项目</strong>
|
||||
<span>切换到“全部”或“全部消息”可以查看当前对话包里的其它产物。</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="conversation-package-groups">
|
||||
{packageGroups.map(([label, artifacts]) => (
|
||||
<div className="conversation-package-group" key={label}>
|
||||
<h3>{label}</h3>
|
||||
<div className="conversation-package-items">
|
||||
{artifacts.map((artifact) => {
|
||||
const sizeLabel = formatPackageArtifactSize(artifact.sizeBytes);
|
||||
return (
|
||||
<a
|
||||
key={artifact.artifactId}
|
||||
className="conversation-package-item"
|
||||
href={artifact.canonicalUrl || undefined}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-disabled={!artifact.canonicalUrl}
|
||||
onClick={(event) => {
|
||||
if (!artifact.canonicalUrl) event.preventDefault();
|
||||
}}
|
||||
>
|
||||
<span className="conversation-package-item-kind">{packageArtifactLabel(artifact.kind)}</span>
|
||||
<strong>{artifact.displayName || artifact.canonicalUrl || artifact.artifactId}</strong>
|
||||
<span>{[artifact.mimeType, sizeLabel].filter(Boolean).join(' · ') || '已记录到对话包'}</span>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="conversation-package-actions">
|
||||
<button type="button" className="ghost-btn" onClick={() => void loadConversationPackage()}>
|
||||
刷新
|
||||
</button>
|
||||
{sessionId && conversationPackage && (
|
||||
<a
|
||||
className="ghost-btn conversation-package-download"
|
||||
href={buildMindSpaceConversationPackageManifestDownloadUrl(sessionId)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
下载清单
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+199
-4
@@ -512,12 +512,139 @@ body,
|
||||
gap: var(--space-sm);
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.header-actions-mobile {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.header-session-rail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
width: calc((var(--header-session-chip-width, 132px) * 3) + 16px + 56px + 12px);
|
||||
flex: 0 0 auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.header-session-rail-arrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(120, 146, 182, 0.1);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
color: rgba(201, 215, 234, 0.54);
|
||||
cursor: pointer;
|
||||
flex: 0 0 28px;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease, opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.header-session-rail-arrow:hover:not(:disabled) {
|
||||
background: rgba(72, 102, 148, 0.1);
|
||||
color: rgba(231, 236, 243, 0.82);
|
||||
border-color: rgba(97, 145, 216, 0.18);
|
||||
}
|
||||
|
||||
.header-session-rail-arrow:disabled {
|
||||
opacity: 0.18;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.header-session-rail-track {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: var(--header-session-chip-width, minmax(132px, calc((100% - 16px) / 3)));
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
overscroll-behavior-x: contain;
|
||||
scroll-snap-type: x proximity;
|
||||
scrollbar-width: none;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.header-session-rail-track::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.header-session-chip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
gap: 1px;
|
||||
min-width: 0;
|
||||
min-height: 30px;
|
||||
padding: 5px 8px;
|
||||
border: 1px solid rgba(120, 146, 182, 0.16);
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
scroll-snap-align: start;
|
||||
transition: border-color 0.15s ease, background 0.15s ease, color 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.header-session-chip:hover {
|
||||
background: rgba(72, 102, 148, 0.18);
|
||||
border-color: rgba(97, 145, 216, 0.28);
|
||||
color: var(--color-text-primary);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.header-session-chip strong,
|
||||
.header-session-chip span {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.header-session-chip strong {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.header-session-chip span {
|
||||
font-size: 9px;
|
||||
line-height: 1.1;
|
||||
color: var(--color-text-faint);
|
||||
}
|
||||
|
||||
.header-session-chip.is-active {
|
||||
background: linear-gradient(180deg, rgba(63, 110, 180, 0.28), rgba(38, 69, 118, 0.24));
|
||||
border-color: rgba(89, 147, 235, 0.46);
|
||||
color: #eef5ff;
|
||||
}
|
||||
|
||||
.header-session-chip.is-active span {
|
||||
color: rgba(220, 233, 255, 0.78);
|
||||
}
|
||||
|
||||
.header-session-chip.is-armed {
|
||||
background: linear-gradient(180deg, rgba(177, 126, 59, 0.32), rgba(120, 78, 25, 0.28));
|
||||
border-color: rgba(255, 195, 96, 0.52);
|
||||
color: #fff3da;
|
||||
box-shadow: 0 0 0 1px rgba(255, 202, 114, 0.12), 0 8px 18px rgba(94, 55, 10, 0.18);
|
||||
}
|
||||
|
||||
.header-session-chip.is-armed span {
|
||||
color: rgba(255, 234, 191, 0.88);
|
||||
}
|
||||
|
||||
.header-session-chip-loading {
|
||||
cursor: default;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.header-icon-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -2067,11 +2194,13 @@ body,
|
||||
inset: 0;
|
||||
z-index: 80;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 18px;
|
||||
background: rgba(7, 12, 19, 0.46);
|
||||
backdrop-filter: blur(10px);
|
||||
padding: 72px 18px 18px;
|
||||
background: rgba(7, 12, 19, 0.38);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
animation: conversation-package-backdrop-fade 0.16s ease-out;
|
||||
}
|
||||
|
||||
.conversation-package-panel {
|
||||
@@ -2088,6 +2217,31 @@ body,
|
||||
radial-gradient(circle at 12% 0%, rgba(67, 113, 154, 0.28), transparent 32%),
|
||||
linear-gradient(160deg, rgba(15, 26, 40, 0.98), rgba(7, 14, 24, 0.98));
|
||||
box-shadow: 0 28px 80px rgba(0, 0, 0, 0.36);
|
||||
transform-origin: top center;
|
||||
will-change: transform, opacity;
|
||||
contain: layout paint style;
|
||||
backface-visibility: hidden;
|
||||
animation: conversation-package-drop-in 0.16s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
@keyframes conversation-package-backdrop-fade {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes conversation-package-drop-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, -18px, 0);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.conversation-package-head {
|
||||
@@ -8932,6 +9086,36 @@ body,
|
||||
.header-actions-mobile {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.header-session-rail {
|
||||
max-width: min(46vw, 240px);
|
||||
flex-basis: 180px;
|
||||
}
|
||||
|
||||
.header-session-rail-arrow {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.header-session-rail-track {
|
||||
--header-session-chip-width: minmax(92px, calc((100% - 8px) / 2));
|
||||
grid-auto-columns: minmax(92px, calc((100% - 8px) / 2));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.header-session-chip {
|
||||
min-height: 30px;
|
||||
padding: 5px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.header-session-chip strong {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.header-session-chip span {
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
.main-home {
|
||||
@@ -9026,6 +9210,17 @@ body,
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.header-session-rail {
|
||||
max-width: 180px;
|
||||
flex-basis: 148px;
|
||||
}
|
||||
|
||||
.header-session-rail-track {
|
||||
--header-session-chip-width: minmax(88px, calc((100% - 6px) / 1.8));
|
||||
grid-auto-columns: minmax(88px, calc((100% - 6px) / 1.8));
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ghost-btn {
|
||||
padding: 8px 9px;
|
||||
font-size: 12px;
|
||||
|
||||
@@ -474,6 +474,7 @@ export type MindSpaceConversationPackage = {
|
||||
sessionId: string;
|
||||
uri: string;
|
||||
title: string | null;
|
||||
displayFolderName: string;
|
||||
status: string;
|
||||
storagePrefix: string;
|
||||
manifestAssetId: string | null;
|
||||
|
||||
Reference in New Issue
Block a user