chore: consolidate admin cleanup and colima config

This commit is contained in:
john
2026-06-28 08:20:43 +08:00
parent fffa1f7c7d
commit dcf8fd1ff9
19 changed files with 426 additions and 89 deletions
+3
View File
@@ -0,0 +1,3 @@
TKMIND_API_TARGET=https://127.0.0.1:18006
MEMIND_LIB_ROOT=/Users/john/Project/Memind
VITE_MAIN_APP_URL=http://127.0.0.1:5173
+12 -2
View File
@@ -16,7 +16,7 @@ DATABASE_URL=mysql://boot:password@host:3306/goose
# MYSQL_DATABASE=goose
# 用户工作区根目录(创建用户时使用)
H5_USERS_ROOT=/Users/john/Project/memind_adm/data/users
H5_USERS_ROOT=/Users/john/Project/Memind/users
# 管理员(可选;设置后启动时同步到数据库)
# H5_ADMIN_USERNAME=admin
@@ -25,6 +25,11 @@ H5_USERS_ROOT=/Users/john/Project/memind_adm/data/users
# LLM 配置同步(可选)
# TKMIND_API_TARGET=https://127.0.0.1:18006
# TKMIND_SERVER__SECRET_KEY=local-dev-secret
NODE_BIN=/opt/homebrew/opt/node@24/bin/node
AIDER_BIN=/Users/john/Project/aider/aider.sh
GOOSE_AIDER_BIN=/Users/john/Project/aider/aider.sh
OPENHANDS_BIN=/Users/john/.openhands/bin/studio-openhands
GOOSE_OPENHANDS_BIN=/Users/john/.openhands/bin/studio-openhands
# ── Skill 系统 ──
# goosed 节点地址(逗号分隔支持多节点;本地单节点只填一个)
@@ -36,6 +41,11 @@ H5_USERS_ROOT=/Users/john/Project/memind_adm/data/users
# GOOSE_SERVER__SECRET_KEY=local-dev-secret
# skill 执行超时(毫秒,默认 120000)
# SKILL_GOOSED_TIMEOUT_MS=120000
# goosed skill 会话工作目录(默认 /tmp)
# GOOSE_SKILL_WORKING_DIR=/tmp
# goosed LLM provider(与 goosed 本地 config.yaml 中的 active_provider 对应)
# GOOSE_PROVIDER=custom_deepseek
# GOOSE_MODEL=deepseek-reasoner
# 套餐同步到生产后台(可选;配置后套餐增删改会自动镜像到目标后台)
# PLAN_SYNC_TARGET_BASE_URL=https://md.tkmind.cn
@@ -44,7 +54,7 @@ H5_USERS_ROOT=/Users/john/Project/memind_adm/data/users
# PLAN_SYNC_TIMEOUT_MS=10000
# Memind 业务模块路径(user-auth / llm-providers 等,默认 ../Memind
# MEMIND_LIB_ROOT=/Users/john/Project/Memind
MEMIND_LIB_ROOT=/Users/john/Project/Memind
# 超管页「返回对话」跳转主 H5(可选)
VITE_MAIN_APP_URL=https://h5.tkmind.cn
+3
View File
@@ -1,6 +1,7 @@
.DS_Store
.env
.env.local
.env.production
*.local
node_modules/
dist/
@@ -9,3 +10,5 @@ memind-lib/
.vite/
*.log
logs/
.mindops/
.adm-*.pid
+5 -5
View File
@@ -59,13 +59,13 @@
## gadm 独立登录
`https://gadm.tkmind.cn` 应部署 **本仓库 memind_adm**,勿再反代到 `Memind/ops` Vite dev(旧版未登录会跳 `localhost:5173`)。
`https://md.tkmind.cn` 应部署 **本仓库 memind_adm**,勿再反代到 `Memind/ops` Vite dev(旧版未登录会跳 `localhost:5173`)。
| 项 | 说明 |
|---|---|
| 登录 | 本页 `/auth/login`,账号来自共用 MySQL |
| 会话 | 设置 `H5_PUBLIC_BASE_URL=https://gadm.tkmind.cn`**不要**设 `H5_COOKIE_DOMAIN=.tkmind.cn` |
| 路径 | `https://gadm.tkmind.cn/` 直达超管后台;Plaza 运营在 `/ops``/ops/admin` 已废弃 |
| 会话 | 设置 `H5_PUBLIC_BASE_URL=https://md.tkmind.cn`**不要**设 `H5_COOKIE_DOMAIN=.tkmind.cn` |
| 路径 | `https://md.tkmind.cn/` 直达超管后台;Plaza 运营在 `/ops``/ops/admin` 已废弃 |
| API | `ADM_DEV_BACKEND=http://127.0.0.1:8085`preview 反代目标) |
| 初始化 admin | `npm run admin:init` 只更新 `Memind` 主用户表中的 admin 密码,不创建独立后台账号 |
@@ -187,7 +187,7 @@ ssh john@10.10.0.2 'tail -f /Users/john/Project/memind_adm/adm-preview.log'
# preview 反代目标(/auth、/admin-api、/api)— 指向本仓库 Admin API
ADM_API_PORT=8085
ADM_DEV_BACKEND=http://127.0.0.1:8085
H5_PUBLIC_BASE_URL=https://gadm.tkmind.cn
H5_PUBLIC_BASE_URL=https://md.tkmind.cn
VITE_BASE_PATH=/ops
# 共享 Memind 主库与主实现
@@ -196,7 +196,7 @@ MEMIND_LIB_ROOT=/Users/john/Project/Memind
H5_USERS_ROOT=/Users/john/Project/Memind/users
# 套餐自动同步到生产后台(可选)
PLAN_SYNC_TARGET_BASE_URL=https://gadm.tkmind.cn
PLAN_SYNC_TARGET_BASE_URL=https://md.tkmind.cn
PLAN_SYNC_USERNAME=admin
PLAN_SYNC_PASSWORD=<admin-password>
PLAN_SYNC_TIMEOUT_MS=10000
+1 -1
View File
@@ -1,4 +1,4 @@
# gadm.tkmind.cn → memind_admvite preview :5174 + Admin API :8085
# md.tkmind.cn → memind_admvite preview :5174 + Admin API :8085
# 生产 nginx 需把 /ops/ 反代到 5174,勿再指向 Memind/ops dev server
:8091 {
reverse_proxy 127.0.0.1:5174 {
+5 -5
View File
@@ -1,10 +1,10 @@
# gadm.tkmind.cn @ 105 nginx → Studio LAN 10.10.0.2fallback 58.38.22.103
# md.tkmind.cn @ 105 nginx → Studio LAN 10.10.0.2fallback 58.38.22.103
# memind_adm: preview :5174, Admin API :8085(根路径部署,/ 直达后台)
server {
listen 80;
listen [::]:80;
server_name gadm.tkmind.cn;
server_name md.tkmind.cn;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
@@ -18,9 +18,9 @@ server {
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name gadm.tkmind.cn;
ssl_certificate /etc/letsencrypt/live/gadm.tkmind.cn/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/gadm.tkmind.cn/privkey.pem;
server_name md.tkmind.cn;
ssl_certificate /etc/letsencrypt/live/md.tkmind.cn/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/md.tkmind.cn/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
+2 -2
View File
@@ -1,4 +1,4 @@
# gadm.tkmind.cn — memind_adm 独立管理端(替换原 Memind /ops Vite dev
# md.tkmind.cn — memind_adm 独立管理端(替换原 Memind /ops Vite dev
#
# 前置:100 服务器 remote_restart.sh 已启动
# Admin API: 127.0.0.1:8085
@@ -8,7 +8,7 @@
server {
listen 443 ssl http2;
server_name gadm.tkmind.cn;
server_name md.tkmind.cn;
# ssl_certificate ...;
+10 -7
View File
@@ -4,24 +4,27 @@ set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
PORT="${ADM_PORT:-5174}"
API_PORT="${ADM_API_PORT:-8085}"
NODE_PATH="${LOCAL_NODE_PATH:-/opt/homebrew/opt/node@24/bin:/opt/homebrew/opt/node@22/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin}"
NODE_BIN="${NODE_BIN:-/opt/homebrew/opt/node@24/bin/node}"
LOG="${ROOT}/adm-preview.log"
API_LOG="${ROOT}/adm-api.log"
PID_FILE="${ROOT}/.adm-preview.pid"
API_PID_FILE="${ROOT}/.adm-api.pid"
export PATH="${NODE_PATH}:${PATH}"
command -v node >/dev/null 2>&1 || { echo "❌ 未找到 node" >&2; exit 1; }
[[ -x "${NODE_BIN}" ]] || { echo "❌ NODE_BIN 不可执行: ${NODE_BIN}" >&2; exit 1; }
NODE_DIR="$(dirname "${NODE_BIN}")"
export PATH="${NODE_DIR}:${PATH}"
NPM_BIN="${NPM_BIN:-${NODE_DIR}/npm}"
[[ -x "${NPM_BIN}" ]] || { echo "❌ NPM_BIN 不可执行: ${NPM_BIN}" >&2; exit 1; }
cd "${ROOT}"
if [[ ! -d node_modules ]]; then
echo "==> 安装依赖..."
npm install
"${NPM_BIN}" install
fi
echo "==> 本机构建..."
npm run build
"${NPM_BIN}" run build
echo "==> 停止旧进程 (port ${PORT}, ${API_PORT})..."
lsof -ti "TCP:${PORT}" -sTCP:LISTEN 2>/dev/null | xargs kill 2>/dev/null || true
@@ -32,7 +35,7 @@ lsof -ti "TCP:${API_PORT}" -sTCP:LISTEN 2>/dev/null | xargs kill -9 2>/dev/null
rm -f "${PID_FILE}" "${API_PID_FILE}"
echo "==> 启动本机 Admin API @ 127.0.0.1:${API_PORT}..."
nohup node server/index.mjs >>"${API_LOG}" 2>&1 &
nohup "${NODE_BIN}" server/index.mjs >>"${API_LOG}" 2>&1 &
echo $! >"${API_PID_FILE}"
sleep 2
@@ -43,7 +46,7 @@ if ! curl -sf "http://127.0.0.1:${API_PORT}/health" >/dev/null; then
fi
echo "==> 启动本机 vite preview @ 127.0.0.1:${PORT}..."
nohup npm run dev:preview -- --host 127.0.0.1 --port "${PORT}" >>"${LOG}" 2>&1 &
nohup "${NPM_BIN}" run dev:preview -- --host 127.0.0.1 --port "${PORT}" >>"${LOG}" 2>&1 &
echo $! >"${PID_FILE}"
sleep 2
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "${ROOT}"
set -a
source "${ROOT}/.env"
if [[ -f "${ROOT}/.env.colima" ]]; then
source "${ROOT}/.env.colima"
fi
set +a
exec bash "${ROOT}/scripts/local_restart.sh"
+10 -9
View File
@@ -116,6 +116,10 @@ say "打包 Memind 共享模块到 memind-lib"
rm -rf "${MEMIND_LIB}"
mkdir -p "${MEMIND_LIB}"
find "${MEMIND_SRC}" -maxdepth 1 -name '*.mjs' -exec cp -f {} "${MEMIND_LIB}/" \;
# 同步技能目录(skills/ 下的 SKILL.md 是技能目录来源)
if [[ -d "${MEMIND_SRC}/skills" ]]; then
cp -a "${MEMIND_SRC}/skills" "${MEMIND_LIB}/"
fi
[[ -f "${MEMIND_LIB}/user-auth.mjs" ]] || {
echo "memind-lib 缺少 user-auth.mjs" >&2
exit 1
@@ -225,15 +229,12 @@ done
say "安装运行依赖"
cd "${RELEASE_DIR}"
export PATH="/opt/homebrew/bin:/opt/homebrew/opt/node@24/bin:/opt/homebrew/opt/node@22/bin:/usr/local/bin:/usr/bin:/bin:${PATH}"
if command -v npm >/dev/null 2>&1; then
NPM_BIN="$(command -v npm)"
elif [[ -x /opt/homebrew/opt/node@24/bin/npm ]]; then
NPM_BIN="/opt/homebrew/opt/node@24/bin/npm"
else
echo "npm not found on remote" >&2
exit 1
fi
NODE_BIN="${NODE_BIN:-/opt/homebrew/opt/node@24/bin/node}"
[[ -x "${NODE_BIN}" ]] || { echo "NODE_BIN is not executable: ${NODE_BIN}" >&2; exit 1; }
NODE_DIR="$(dirname "${NODE_BIN}")"
export PATH="${NODE_DIR}:${PATH}"
NPM_BIN="${NPM_BIN:-${NODE_DIR}/npm}"
[[ -x "${NPM_BIN}" ]] || { echo "NPM_BIN is not executable: ${NPM_BIN}" >&2; exit 1; }
if [[ -f package-lock.json ]]; then
"${NPM_BIN}" ci --no-audit --no-fund
else
+9 -6
View File
@@ -4,15 +4,18 @@ set -euo pipefail
ROOT="${ADM_ROOT:-/Users/john/Project/memind_adm}"
PORT="${ADM_PORT:-5174}"
NODE_PATH="${LAN_NODE_PATH:-/opt/homebrew/opt/node@24/bin:/opt/homebrew/opt/node@22/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin}"
NODE_BIN="${NODE_BIN:-/opt/homebrew/opt/node@24/bin/node}"
API_PORT="${ADM_API_PORT:-8085}"
LOG="${ROOT}/adm-preview.log"
API_LOG="${ROOT}/adm-api.log"
PID_FILE="${ROOT}/.adm-preview.pid"
API_PID_FILE="${ROOT}/.adm-api.pid"
export PATH="${NODE_PATH}:${PATH}"
command -v node >/dev/null 2>&1 || { echo "❌ 未找到 node" >&2; exit 1; }
[[ -x "${NODE_BIN}" ]] || { echo "❌ NODE_BIN 不可执行: ${NODE_BIN}" >&2; exit 1; }
NODE_DIR="$(dirname "${NODE_BIN}")"
export PATH="${NODE_DIR}:${PATH}"
NPM_BIN="${NPM_BIN:-${NODE_DIR}/npm}"
[[ -x "${NPM_BIN}" ]] || { echo "❌ NPM_BIN 不可执行: ${NPM_BIN}" >&2; exit 1; }
cd "${ROOT}"
@@ -41,7 +44,7 @@ fi
if [[ ! -d node_modules ]]; then
echo "==> 安装依赖..."
npm install
"${NPM_BIN}" install
fi
echo "==> 停止旧进程 (port ${PORT}, ${API_PORT})..."
@@ -59,7 +62,7 @@ lsof -ti "TCP:${API_PORT}" -sTCP:LISTEN 2>/dev/null | xargs kill -9 2>/dev/null
sleep 1
echo "==> 启动 Admin API @ ${API_HOST}:${API_PORT}..."
nohup node server/index.mjs >>"${API_LOG}" 2>&1 &
nohup "${NODE_BIN}" server/index.mjs >>"${API_LOG}" 2>&1 &
echo $! >"${API_PID_FILE}"
sleep 2
@@ -70,7 +73,7 @@ if ! curl -sf "http://127.0.0.1:${API_PORT}/health" >/dev/null; then
fi
echo "==> 启动 vite preview @ ${WEB_HOST}:${PORT} (base=${BASE_PATH})..."
nohup npx vite preview --config scripts/vite-preview.config.mjs >>"${LOG}" 2>&1 &
nohup "${NPM_BIN}" exec vite preview --config scripts/vite-preview.config.mjs >>"${LOG}" 2>&1 &
echo $! >"${PID_FILE}"
sleep 3
+1 -1
View File
@@ -12,7 +12,7 @@ export default defineConfig(({ mode }) => {
host: env.ADM_WEB_HOST ?? env.ADM_API_HOST ?? '127.0.0.1',
port: Number(env.ADM_PORT ?? 5174),
strictPort: true,
allowedHosts: ['gadm.tkmind.cn', 'localhost', '127.0.0.1', '10.10.0.2', '58.38.22.103'],
allowedHosts: ['md.tkmind.cn', 'gadm.tkmind.cn', 'localhost', '127.0.0.1', '10.10.0.2', '58.38.22.103'],
proxy: {
'/api': backend,
'/auth': backend,
+59 -29
View File
@@ -1,16 +1,18 @@
/**
* GoosedExecutor — runs a skill via a one-shot goosed session.
* GoosedExecutor — runs a skill via a goosed session.
*
* Session lifecycle:
* 1. POST /agent/start { messages: [{ role: 'user', content: prompt }] }
* 2. GET /sessions/:id/events (SSE) → collect until Finish event or timeout
* 3. DELETE /sessions/:id (best-effort cleanup)
* 1. POST /agent/start { working_dir }
* 2. POST /sessions/:id/reply { request_id: UUID, user_message: {...} }
* 3. GET /sessions/:id/events (SSE) → collect until Finish event or timeout
* 4. DELETE /sessions/:id (best-effort cleanup)
*
* Dual-node: pickGoosedUrl() round-robins across configured nodes.
* Protocol: HTTPS with self-signed cert (rejectUnauthorized: false).
*/
import https from 'node:https';
import crypto from 'node:crypto';
import { goosedFetch, pickGoosedUrl } from '../goosed-client.mjs';
const SESSION_TIMEOUT_MS = Number(process.env.SKILL_GOOSED_TIMEOUT_MS ?? 120_000);
@@ -18,19 +20,14 @@ const tlsAgent = new https.Agent({ rejectUnauthorized: false });
export async function invokeGoosedSkill(skill, input) {
const node = pickGoosedUrl();
const prompt = buildPrompt(skill, input);
const systemPrompt = buildSystemPrompt(skill, input);
const userPrompt = buildUserPrompt(skill, input);
// 1. Start session
// 1. Start session (no messages yet)
const startRes = await goosedFetch('/agent/start', {
method: 'POST',
node,
body: {
version: '1.0.0',
session_id: null,
...(systemPrompt ? { system_prompt_override: systemPrompt } : {}),
messages: [{ role: 'user', content: userPrompt }],
working_dir: process.env.GOOSE_SKILL_WORKING_DIR ?? '/tmp',
},
});
@@ -41,12 +38,42 @@ export async function invokeGoosedSkill(skill, input) {
);
}
const sessionId = startRes.body?.session_id ?? startRes.body?.id;
const sessionId = startRes.body?.id ?? startRes.body?.session_id;
if (!sessionId) {
throw new Error(`goosed did not return a session_id: ${JSON.stringify(startRes.body)}`);
throw new Error(`goosed did not return a session id: ${JSON.stringify(startRes.body)}`);
}
// 2. Stream events and collect result
// 1b. Set the LLM provider on the session
const goosedProvider = process.env.GOOSE_PROVIDER ?? process.env.GOOSED_PROVIDER ?? 'custom_deepseek';
const goosedModel = process.env.GOOSE_MODEL ?? process.env.GOOSED_MODEL;
const providerBody = { session_id: sessionId, provider: goosedProvider };
if (goosedModel) providerBody.model = goosedModel;
await goosedFetch('/agent/update_provider', { method: 'POST', node, body: providerBody });
// 2. Send the prompt via reply
const requestId = crypto.randomUUID();
const replyRes = await goosedFetch(`/sessions/${sessionId}/reply`, {
method: 'POST',
node,
body: {
request_id: requestId,
user_message: {
role: 'user',
created: Math.floor(Date.now() / 1000),
metadata: { userVisible: true, agentVisible: true },
content: [{ type: 'text', text: prompt }],
},
},
});
if (!replyRes.ok) {
throw Object.assign(
new Error(`goosed /sessions/${sessionId}/reply failed (${replyRes.status})`),
{ status: replyRes.status, upstream: replyRes.body },
);
}
// 3. Stream SSE events and collect result
try {
return await collectSessionResult(node, sessionId);
} finally {
@@ -54,14 +81,22 @@ export async function invokeGoosedSkill(skill, input) {
}
}
function buildSystemPrompt(skill, input) {
if (skill.runtime?.config?.system_prompt) return skill.runtime.config.system_prompt;
return null;
}
function buildUserPrompt(skill, input) {
function buildPrompt(skill, input) {
if (input.design_md_content) {
return `You are a UI generation assistant. Follow this design system exactly:\n\n${input.design_md_content}\n\nTask: ${input.task ?? input.prompt ?? ''}`;
return `You are a UI generation assistant. Your output must be a complete, self-contained HTML page — not a component, not a snippet, not a code block with backticks. Output raw HTML only, starting with <!DOCTYPE html>.
Apply the following design system exactly. Match the colors, typography, spacing, border styles, shadows, and interaction patterns described:
${input.design_md_content}
Task: ${input.task ?? input.prompt ?? ''}
Requirements:
- Output a single complete HTML file with <html>, <head>, <body> and all inline <style> — no external stylesheets or JS frameworks
- The page should feel like a real product page from this brand, not a generic template
- Use the exact color values, font sizes, border-radius, shadow formulas from the design system above
- Include realistic placeholder content relevant to the task
- The page must be visually complete and ready to render in a browser`;
}
return input.task ?? input.prompt ?? JSON.stringify(input);
}
@@ -80,13 +115,10 @@ function collectSessionResult(node, sessionId) {
path: url.pathname,
method: 'GET',
headers: { 'X-Secret-Key': secret, Accept: 'text/event-stream' },
agent: url.protocol === 'https:' ? tlsAgent : undefined,
rejectUnauthorized: false,
agent: tlsAgent,
};
const proto = url.protocol === 'https:' ? https : (() => { throw new Error('http not supported for goosed'); })();
const req = proto.request(options, (res) => {
const req = https.request(options, (res) => {
if (res.statusCode !== 200) {
res.resume();
return reject(new Error(`SSE stream returned ${res.statusCode}`));
@@ -144,14 +176,12 @@ function collectSessionResult(node, sessionId) {
}
function extractText(event) {
// Handle goosed SSE event shapes
if (event?.type === 'Message' && event?.message?.role === 'assistant') {
return (event.message.content ?? [])
.filter((c) => c.type === 'text')
.map((c) => c.text)
.join('');
}
// Simpler delta format
if (event?.delta?.text) return event.delta.text;
if (typeof event?.content === 'string') return event.content;
return '';
+35 -17
View File
@@ -46,28 +46,46 @@ export function goosedUrls() {
/**
* Make a JSON request to a goosed node.
* Returns { ok, status, body } — never throws on HTTP errors, only on network failure.
* Uses https.request directly so rejectUnauthorized: false works for self-signed certs.
*/
export async function goosedFetch(path, { method = 'GET', body, node } = {}) {
export function goosedFetch(path, { method = 'GET', body, node } = {}) {
const base = node ?? pickGoosedUrl();
const secret = process.env.GOOSE_SERVER__SECRET_KEY ??
const secret =
process.env.GOOSE_SERVER__SECRET_KEY ??
process.env.TKMIND_SERVER__SECRET_KEY ??
'local-dev-secret';
const url = `${base}${path}`;
const init = {
method,
headers: {
'Content-Type': 'application/json',
'X-Secret-Key': secret,
},
agent: tlsAgent,
};
if (body !== undefined) init.body = JSON.stringify(body);
const url = new URL(`${base}${path}`);
const payload = body !== undefined ? JSON.stringify(body) : null;
const res = await fetch(url, init);
let json = null;
const text = await res.text();
try { json = JSON.parse(text); } catch { json = { raw: text }; }
return new Promise((resolve, reject) => {
const options = {
hostname: url.hostname,
port: url.port || (url.protocol === 'https:' ? 443 : 80),
path: url.pathname + url.search,
method,
headers: {
'Content-Type': 'application/json',
'X-Secret-Key': secret,
...(payload ? { 'Content-Length': Buffer.byteLength(payload) } : {}),
},
agent: tlsAgent,
};
return { ok: res.ok, status: res.status, body: json };
const req = https.request(options, (res) => {
let text = '';
res.setEncoding('utf8');
res.on('data', (chunk) => { text += chunk; });
res.on('end', () => {
let json = null;
try { json = JSON.parse(text); } catch { json = { raw: text }; }
resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode, body: json });
});
res.on('error', reject);
});
req.on('error', reject);
if (payload) req.write(payload);
req.end();
});
}
+1 -1
View File
@@ -46,7 +46,7 @@ export async function ensureSkillSchema(pool) {
CREATE TABLE IF NOT EXISTS adm_skill_invocations (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
skill_id INT UNSIGNED NOT NULL,
caller_id INT UNSIGNED NULL,
caller_id VARCHAR(64) NULL,
caller_type VARCHAR(32) NOT NULL DEFAULT 'unknown',
input_snapshot JSON NULL,
status ENUM('pending','running','success','failed','blocked_by_flag')
+101 -2
View File
@@ -1,6 +1,93 @@
import { useCallback, useEffect, useState } from 'react';
import {
listBackendSkills,
setBackendSkillEnabled,
type BackendSkill,
} from '../../api/client';
import { SkillSettings } from '../../components/SkillSettings';
import { useAdminUsers } from '../hooks/useAdminUsers';
function BackendSkillCard({
skill,
onToggle,
}: {
skill: BackendSkill;
onToggle: (slug: string, enabled: boolean) => Promise<void>;
}) {
const [busy, setBusy] = useState(false);
const handleToggle = async () => {
setBusy(true);
try {
await onToggle(skill.slug, !skill.enabled);
} finally {
setBusy(false);
}
};
return (
<div className={`backend-skill-card${skill.enabled ? '' : ' disabled'}`}>
<div className="backend-skill-info">
<div className="backend-skill-name">
{skill.name}
<span className="backend-skill-slug"> · {skill.slug}</span>
</div>
{skill.meta?.description && (
<div className="backend-skill-desc">{skill.meta.description}</div>
)}
{!skill.enabled && (
<div className="backend-skill-reason"></div>
)}
<div className="backend-skill-meta">
<span className="backend-skill-tag">{skill.type}</span>
{skill.meta?.tags?.map((t) => (
<span key={t} className="backend-skill-tag">{t}</span>
))}
</div>
</div>
<label className="toggle-switch" title={skill.enabled ? '点击禁用' : '点击启用'}>
<input
type="checkbox"
checked={skill.enabled}
disabled={busy}
onChange={handleToggle}
/>
<span className="toggle-slider" />
</label>
</div>
);
}
function BackendSkillsPanel() {
const [skills, setSkills] = useState<BackendSkill[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
listBackendSkills()
.then(setSkills)
.catch((e) => setError(e.message ?? '加载失败'))
.finally(() => setLoading(false));
}, []);
const handleToggle = useCallback(async (slug: string, enabled: boolean) => {
await setBackendSkillEnabled(slug, enabled);
setSkills((prev) => prev.map((s) => (s.slug === slug ? { ...s, enabled } : s)));
}, []);
if (loading) return <p className="muted"></p>;
if (error) return <p className="banner banner-error">{error}</p>;
if (!skills.length) return <p className="muted"></p>;
return (
<div className="backend-skill-list">
{skills.map((s) => (
<BackendSkillCard key={s.slug} skill={s} onToggle={handleToggle} />
))}
</div>
);
}
export function SkillsPage() {
const { users, loading, error } = useAdminUsers();
@@ -10,8 +97,20 @@ export function SkillsPage() {
<h2></h2>
<p className="muted"> MindSpace </p>
</div>
{error && <p className="banner banner-error">{error}</p>}
{loading ? <p className="muted"></p> : <SkillSettings users={users} />}
<section className="admin-section">
<h3 className="admin-section-title"></h3>
<p className="muted" style={{ marginBottom: 12 }}>
AI
</p>
<BackendSkillsPanel />
</section>
<section className="admin-section" style={{ marginTop: 32 }}>
<h3 className="admin-section-title"></h3>
{error && <p className="banner banner-error">{error}</p>}
{loading ? <p className="muted"></p> : <SkillSettings users={users} />}
</section>
</div>
);
}
+33
View File
@@ -598,6 +598,39 @@ export async function clearUserSkillOverrides(userId: string): Promise<void> {
await portalFetch(`/admin-api/users/${userId}/skills`, { method: 'DELETE' });
}
// ── Backend Skills (Skill Marketplace) ────────────────
export interface BackendSkill {
id: number;
slug: string;
name: string;
type: string;
status: string;
enabled: boolean;
rollout_pct: number;
meta: {
description?: string;
tags?: string[];
source?: string;
} | null;
publisher_id: string | null;
}
export async function listBackendSkills(): Promise<BackendSkill[]> {
const result = await portalFetch<{ skills: BackendSkill[] }>('/admin-api/skills/');
return result.skills ?? [];
}
export async function setBackendSkillEnabled(
slug: string,
enabled: boolean,
): Promise<{ ok: boolean; slug: string; enabled: boolean }> {
return portalFetch(`/admin-api/skills/${slug}/enabled`, {
method: 'PATCH',
body: JSON.stringify({ enabled }),
});
}
// ── LLM Providers ─────────────────────────────────────
export async function listLlmProviderCatalog(): Promise<LlmProviderDefinition[]> {
+119
View File
@@ -1660,3 +1660,122 @@ body,
display: flex;
gap: 6px;
}
/* ── Admin section grouping ── */
.admin-section {
margin-bottom: 24px;
}
.admin-section-title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary, #111);
margin: 0 0 8px;
padding-bottom: 6px;
border-bottom: 1px solid var(--border, #e5e5e5);
}
/* ── Backend Skill Cards ── */
.backend-skill-list {
display: flex;
flex-direction: column;
gap: 10px;
max-width: 680px;
}
.backend-skill-card {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 14px 16px;
border: 1px solid var(--border, #e5e5e5);
border-radius: 8px;
background: var(--surface, #fff);
transition: opacity 0.15s;
}
.backend-skill-card.disabled {
opacity: 0.55;
}
.backend-skill-info {
flex: 1;
min-width: 0;
}
.backend-skill-name {
font-size: 14px;
font-weight: 600;
color: var(--text-primary, #111);
}
.backend-skill-slug {
font-weight: 400;
font-size: 12px;
color: var(--text-muted, #888);
}
.backend-skill-desc {
font-size: 13px;
color: var(--text-secondary, #555);
margin-top: 3px;
line-height: 1.4;
}
.backend-skill-reason {
font-size: 12px;
color: var(--error, #c00);
margin-top: 4px;
}
.backend-skill-meta {
display: flex;
gap: 6px;
margin-top: 6px;
flex-wrap: wrap;
}
.backend-skill-tag {
font-size: 11px;
padding: 2px 7px;
border-radius: 4px;
background: var(--surface-alt, #f3f3f3);
color: var(--text-muted, #888);
}
/* ── Toggle Switch (reusable) ── */
.toggle-switch {
position: relative;
display: inline-block;
width: 40px;
height: 22px;
flex-shrink: 0;
cursor: pointer;
margin-top: 2px;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
position: absolute;
}
.toggle-slider {
position: absolute;
inset: 0;
border-radius: 22px;
background: var(--border, #ccc);
transition: background 0.2s;
}
.toggle-slider::before {
content: '';
position: absolute;
width: 16px;
height: 16px;
left: 3px;
top: 3px;
border-radius: 50%;
background: #fff;
transition: transform 0.2s;
box-shadow: 0 1px 3px rgba(0,0,0,.2);
}
.toggle-switch input:checked + .toggle-slider {
background: var(--accent, #1a73e8);
}
.toggle-switch input:checked + .toggle-slider::before {
transform: translateX(18px);
}
.toggle-switch input:disabled + .toggle-slider {
opacity: 0.5;
cursor: not-allowed;
}
+2 -2
View File
@@ -14,7 +14,7 @@ export default defineConfig(({ mode }) => {
host: true,
port: 5174,
strictPort: true,
allowedHosts: ['gadm.tkmind.cn', 'localhost', '127.0.0.1'],
allowedHosts: ['md.tkmind.cn', 'gadm.tkmind.cn', 'localhost', '127.0.0.1'],
proxy: {
'/api': backendTarget,
'/auth': backendTarget,
@@ -25,7 +25,7 @@ export default defineConfig(({ mode }) => {
host: '127.0.0.1',
port: 5174,
strictPort: true,
allowedHosts: ['gadm.tkmind.cn', 'localhost', '127.0.0.1'],
allowedHosts: ['md.tkmind.cn', 'gadm.tkmind.cn', 'localhost', '127.0.0.1'],
proxy: {
'/api': backendTarget,
'/auth': backendTarget,