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:
@@ -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?.();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}>
|
||||
打开完整聊天
|
||||
|
||||
Reference in New Issue
Block a user