Extract memind_adm admin server, add local dev tooling, and remove image-generation.

Split platform admin and ops APIs into standalone admin-server.mjs with network guards; simplify billing to RMB token pricing, refactor user auth, and add rsync deploy plus local-test scripts and docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Your Name
2026-06-17 16:39:39 -07:00
parent ab0718938e
commit b0f5d6a51c
98 changed files with 5394 additions and 3010 deletions
+3 -13
View File
@@ -1,5 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import { useAppSession } from '../context/AppSessionContext';
import { useNetworkStatus } from '../hooks/useNetworkStatus';
import { openAvatarPicker } from '../utils/userAvatar';
import { CHAT_SKILL_OPTIONS, filterChatSkills } from '../utils/chatSkills';
@@ -45,7 +44,6 @@ export function ChatPanel({
onClose?: () => void;
}) {
const online = useNetworkStatus();
const { capabilities: sessionCapabilities, grantedSkills: sessionGrantedSkills } = useAppSession();
const [input, setInput] = useState('');
const [pageSource, setPageSource] = useState<Message | null>(null);
const [voiceNotice, setVoiceNotice] = useState<string | null>(null);
@@ -58,14 +56,9 @@ export function ChatPanel({
const busy = chatState === 'streaming' || chatState === 'loading' || chatState === 'connecting';
const offlineBlocked = !online;
const publishSkillName = user?.publishSkillName ?? 'static-page-publish';
const effectiveCapabilities = capabilities ?? sessionCapabilities;
const effectiveGrantedSkills = grantedSkills ?? sessionGrantedSkills;
const hasPublishSkill = effectiveGrantedSkills?.includes(publishSkillName) ?? false;
const canPublish = Boolean(effectiveCapabilities?.static_publish) || hasPublishSkill;
const chatSkills = filterChatSkills(CHAT_SKILL_OPTIONS, {
grantedSkills: effectiveGrantedSkills,
canPublish,
});
const hasPublishSkill = grantedSkills?.includes(publishSkillName) ?? false;
const canPublish = Boolean(capabilities?.static_publish) || hasPublishSkill;
const chatSkills = filterChatSkills(CHAT_SKILL_OPTIONS, { grantedSkills, canPublish });
const compact = variant === 'compact';
const handleSubmit = async () => {
@@ -135,9 +128,6 @@ export function ChatPanel({
onSaved={(result) => {
setPageSource(null);
onPageSaved?.(result);
if (result.kind === 'page' && result.pageId) {
onClose?.();
}
}}
/>
)}
-12
View File
@@ -90,17 +90,6 @@ function AnalyzeIcon({ className }: IconProps) {
);
}
function ImageIcon({ className }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path
fill="currentColor"
d="M5 3a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2H5Zm0 2h14v10.17l-3.59-3.58a1 1 0 0 0-1.41 0L10 17.17 8.41 15.6a1 1 0 0 0-1.41 0L5 17.59V5Zm2.5 2a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Zm11.5 9H5v1.41l2-2 3.59 3.58a1 1 0 0 0 1.41 0L16.83 13l3.17 3.17V16Z"
/>
</svg>
);
}
const ICONS: Record<ChatSkillIconId, (props: IconProps) => JSX.Element> = {
spark: SparkIcon,
page: PageIcon,
@@ -110,7 +99,6 @@ const ICONS: Record<ChatSkillIconId, (props: IconProps) => JSX.Element> = {
table: TableIcon,
summary: SummaryIcon,
analyze: AnalyzeIcon,
image: ImageIcon,
};
export function ChatSkillIcon({ id, className }: { id: ChatSkillIconId; className?: string }) {
+1 -91
View File
@@ -156,7 +156,6 @@ export function MindSpacePageDetail({
const [plazaPost, setPlazaPost] = useState<PlazaPostBrief | null>(null);
const [pushToPlaza, setPushToPlaza] = useState(false);
const [fixNotice, setFixNotice] = useState<MindSpaceRedactionChange[] | null>(null);
const [saving, setSaving] = useState(false);
const applyPageRecord = useCallback(
(next: MindSpacePage, options?: { recordHistory?: boolean }) => {
@@ -469,61 +468,6 @@ export function MindSpacePageDetail({
}
}, [applyPageRecord, content, markPreviewRefreshPending, page, pageId, summary, title, triggerChangeHighlight]);
const isDirty =
Boolean(page) &&
(title !== page.title ||
summary !== page.summary ||
(content ?? '') !== (page.content ?? '') ||
templateId !== page.templateId);
const save = useCallback(async () => {
if (!page || saving) return;
if (!title.trim()) {
setError('标题不能为空');
return;
}
if (!content.trim()) {
setError('页面内容不能为空');
return;
}
setSaving(true);
setError(null);
try {
const updated = await updateMindSpacePage(page.id, {
expectedVersion: page.versionNo,
title: title.trim(),
summary: summary.trim(),
content,
templateId,
changeNote: `保存草稿 v${page.versionNo + 1}`,
});
applyPageRecord(updated);
resetDraft({
title: updated.title,
summary: updated.summary,
content: updated.content ?? '',
});
setPreviewContent(updated.content ?? '');
setPreviewRefreshPending(false);
setPreviewKey((value) => value + 1);
await onSaved();
} catch (err) {
setError(err instanceof Error ? err.message : '页面保存失败');
} finally {
setSaving(false);
}
}, [
applyPageRecord,
content,
onSaved,
page,
resetDraft,
saving,
summary,
templateId,
title,
]);
const applyOneClickFix = async () => {
if (!page) return;
setPublishing(true);
@@ -576,11 +520,6 @@ export function MindSpacePageDetail({
) {
return;
}
if (event.key.toLowerCase() === 's') {
event.preventDefault();
void save();
return;
}
if (event.key.toLowerCase() === 'z' && event.shiftKey) {
const next = redo();
if (next) {
@@ -599,7 +538,7 @@ export function MindSpacePageDetail({
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [redo, save, syncPreviewAfterHistory, undo]);
}, [redo, syncPreviewAfterHistory, undo]);
useEffect(() => {
if (!page || !onContextUpdate) return;
@@ -1110,21 +1049,6 @@ export function MindSpacePageDetail({
/>
</label>
)}
<div className="mindspace-page-editor-actions">
<span>
{isDirty
? '有未保存的修改。保存会创建新版本,历史版本不会被覆盖。'
: '保存会创建新版本,历史版本不会被覆盖。'}
</span>
<button
type="button"
className="mindspace-primary"
onClick={() => void save()}
disabled={saving || !title.trim() || !content.trim() || !isDirty}
>
{saving ? '保存中…' : isDirty ? '保存新版本' : '已是最新'}
</button>
</div>
</div>
<div className="mindspace-page-preview">
@@ -1159,17 +1083,6 @@ export function MindSpacePageDetail({
>
{previewRefreshPending ? ' · 有新修改' : ''}
</button>
{page.contentFormat === 'html' ? (
<button
type="button"
className="mindspace-primary"
onClick={() => void save()}
disabled={saving || !title.trim() || !content.trim() || !isDirty}
title="保存到服务器 (⌘S)"
>
{saving ? '保存中…' : isDirty ? '保存' : '已保存'}
</button>
) : null}
</div>
</div>
{page.contentFormat === 'html' ? (
@@ -1256,9 +1169,6 @@ export function MindSpacePageDetail({
onRefresh={() => handleManualPreviewRefresh()}
refreshPending={previewRefreshPending}
onContentChange={handlePreviewContentChange}
onSave={() => void save()}
saving={saving}
canSave={isDirty && Boolean(title.trim()) && Boolean(content.trim())}
/>
) : null}
</>
@@ -16,9 +16,6 @@ export function MindSpacePageFullscreenPreview({
onRefresh,
refreshPending = false,
onContentChange,
onSave,
saving = false,
canSave = false,
}: {
pageTitle: string;
title: string;
@@ -33,9 +30,6 @@ export function MindSpacePageFullscreenPreview({
onRefresh: () => void;
refreshPending?: boolean;
onContentChange: (html: string) => void;
onSave?: () => void;
saving?: boolean;
canSave?: boolean;
}) {
useEffect(() => {
const previousOverflow = document.body.style.overflow;
@@ -57,13 +51,6 @@ export function MindSpacePageFullscreenPreview({
}
const mod = event.metaKey || event.ctrlKey;
if (mod && !event.altKey && event.key.toLowerCase() === 's') {
if (onSave && canSave && !saving) {
event.preventDefault();
onSave();
}
return;
}
if (mod && !event.altKey && event.key.toLowerCase() === 'z') {
if (event.shiftKey) {
if (canRedo && onRedo?.()) event.preventDefault();
@@ -79,7 +66,7 @@ export function MindSpacePageFullscreenPreview({
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [canRedo, canSave, canUndo, onClose, onRedo, onSave, onUndo, saving]);
}, [canRedo, canUndo, onClose, onRedo, onUndo]);
return (
<div className="mindspace-page-fullscreen-preview" role="dialog" aria-modal="true" aria-label="页面全屏预览编辑">
@@ -106,18 +93,7 @@ export function MindSpacePageFullscreenPreview({
>
{refreshPending ? ' · 有新修改' : ''}
</button>
{onSave ? (
<button
type="button"
className="mindspace-primary"
onClick={onSave}
disabled={saving || !canSave}
title="保存到服务器 (⌘S)"
>
{saving ? '保存中…' : canSave ? '保存' : '已是最新'}
</button>
) : null}
<button type="button" onClick={onClose}>
<button type="button" className="mindspace-primary" onClick={onClose}>
退
</button>
</div>
+12 -36
View File
@@ -151,43 +151,16 @@ export function PageSaveDialog({
const privateNeedsAck =
categoryCode === 'private' && privacyFindings.length > 0 && analysis?.privacyScan.allowed;
const resolveSaveTitle = () =>
title.trim() || analysis?.suggestedTitle?.trim() || '';
const resolveSaveSummary = () =>
summary.trim() || analysis?.suggestedSummary?.trim() || '';
const effectiveTitle = resolveSaveTitle();
const canSave =
Boolean(effectiveTitle) &&
!analysisLoading &&
!analysisError &&
!privateBlocked &&
!(privateNeedsAck && !privateAcknowledged);
const save = async () => {
const saveTitle = resolveSaveTitle();
if (!saveTitle) {
setSaveError('请填写页面标题');
return;
}
const saveSummary = resolveSaveSummary();
if (!title.trim() && saveTitle !== title) {
setTitle(saveTitle);
titleRef.current = saveTitle;
}
if (!summary.trim() && saveSummary) {
setSummary(saveSummary);
summaryRef.current = saveSummary;
}
if (!title.trim()) return;
setSaving(true);
setSaveError(null);
try {
const result = await saveChatMessageAsPage({
sessionId,
messageId,
title: saveTitle,
summary: saveSummary,
title: title.trim(),
summary: summary.trim(),
templateId: isStaticHtml ? 'static-html' : templateId,
categoryCode,
selectedLinkIndex,
@@ -197,12 +170,8 @@ export function PageSaveDialog({
: undefined,
});
if (result.kind === 'page') {
const pageId = result.page?.id;
if (!pageId) {
throw new Error('保存成功但未返回页面 ID');
}
setSaveNotice(`已保存到${CATEGORY_LABELS[categoryCode]}`);
onSaved({ kind: 'page', categoryCode: 'draft', pageId });
onSaved({ kind: 'page', categoryCode: 'draft', pageId: result.page.id });
return;
}
setSaveNotice(`已保存到${CATEGORY_LABELS[result.categoryCode]}`);
@@ -401,7 +370,14 @@ export function PageSaveDialog({
type="button"
className="page-save-primary"
onClick={() => void save()}
disabled={saving || !canSave}
disabled={
saving ||
analysisLoading ||
!title.trim() ||
Boolean(analysisError) ||
privateBlocked ||
(privateNeedsAck && !privateAcknowledged)
}
>
{saving ? '正在保存…' : categoryCode === 'draft' ? '保存并进入编辑' : '保存到空间'}
</button>
+1 -3
View File
@@ -85,11 +85,9 @@ export function PageSavePreviewPanel({
]);
if (!analysis.hasHtmlContent) {
const hint = analysis.filename || analysis.relativePath || analysis.previewUrl;
return (
<div className="page-save-error">
{hint ? `${hint}` : ''} Agent write {' '}
<code>public/.html</code>访
Agent HTML MindSpace
</div>
);
}
-7
View File
@@ -3,7 +3,6 @@ import { createPortal } from 'react-dom';
import QRCode from 'qrcode';
import { createRechargeOrder, getBillingConfig, getRechargeOrder } from '../api/client';
import type { BillingConfig, RechargeOrder } from '../types';
import { formatBillingRates } from '../utils/billing';
import { invokeWechatJsapiPay, isWeChatBrowser } from '../utils/wechatPay';
function formatYuan(cents: number) {
@@ -216,12 +215,6 @@ export function RechargeModal({
<p className="recharge-muted">
{formatYuan(config?.minRechargeCents ?? 500)} AI
</p>
{config && (
<p className="recharge-rates">
{formatBillingRates(config.inputCentsPer1k, config.outputCentsPer1k)}
Agent
</p>
)}
<div className="recharge-tier-grid">
{tiers.map((tier) => (
<button
-18
View File
@@ -48,16 +48,6 @@ export function SpaceChatPanel({
openRecharge,
retryConnect,
} = chat;
const busy = chatState === 'streaming' || chatState === 'loading' || chatState === 'connecting';
const memoryLoading = chatBridge ? false : mainChat.memoryLoading;
const handleNewSession = () => {
if (chatBridge) {
void chatBridge.newSession();
return;
}
void mainChat.newSession();
};
useEffect(() => {
if (!open) return;
@@ -85,14 +75,6 @@ export function SpaceChatPanel({
</div>
</div>
<div className="space-chat-panel-actions">
<button
type="button"
className="ghost-btn space-chat-panel-new-session"
disabled={busy || memoryLoading}
onClick={handleNewSession}
>
</button>
{!hideOpenFullChat ? (
<button type="button" className="ghost-btn" onClick={onOpenFullChat}>