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:
+18
-35
@@ -5,7 +5,6 @@ import { AuthView } from './components/AuthView';
|
||||
import { ChatView } from './components/ChatView';
|
||||
import { MindSpaceView } from './components/MindSpaceView';
|
||||
import { ChatProvider } from './context/ChatProvider';
|
||||
import { AppSessionProvider } from './context/AppSessionContext';
|
||||
import { PREVIEW_USER } from './dev/mindspacePreviewData';
|
||||
import { MindSpaceRoute } from './routes/MindSpaceRoute';
|
||||
import type { CapabilityMap, PortalUser } from './types';
|
||||
@@ -89,18 +88,16 @@ function AuthenticatedApp({
|
||||
);
|
||||
|
||||
return (
|
||||
<AppSessionProvider capabilities={capabilities} grantedSkills={grantedSkills}>
|
||||
<ChatProvider user={user} capabilities={capabilities} onUserUpdate={onUserUpdate}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/space/*"
|
||||
element={<MindSpaceAuthGate user={user} onLogout={handleLogout} />}
|
||||
/>
|
||||
<Route path="/" element={chatElement} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</ChatProvider>
|
||||
</AppSessionProvider>
|
||||
<ChatProvider user={user} capabilities={capabilities} onUserUpdate={onUserUpdate}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/space/*"
|
||||
element={<MindSpaceAuthGate user={user} onLogout={handleLogout} />}
|
||||
/>
|
||||
<Route path="/" element={chatElement} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</ChatProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -118,32 +115,18 @@ export function App() {
|
||||
setUnauthorizedHandler(() => {
|
||||
setAuthed(false);
|
||||
setUser(null);
|
||||
setGrantedSkills(undefined);
|
||||
setCapabilities(undefined);
|
||||
if (window.location.pathname !== '/') {
|
||||
navigate('/', { replace: true });
|
||||
}
|
||||
});
|
||||
|
||||
const syncAuth = () =>
|
||||
checkAuth().then((status) => {
|
||||
setLegacyMode(status.mode === 'legacy');
|
||||
setAuthed(status.authenticated);
|
||||
setUser(status.user ?? null);
|
||||
setCapabilities(status.capabilities);
|
||||
setGrantedSkills(status.grantedSkills);
|
||||
});
|
||||
|
||||
void syncAuth();
|
||||
|
||||
const handleFocus = () => {
|
||||
void syncAuth();
|
||||
};
|
||||
window.addEventListener('focus', handleFocus);
|
||||
return () => {
|
||||
window.removeEventListener('focus', handleFocus);
|
||||
setUnauthorizedHandler(null);
|
||||
};
|
||||
void checkAuth().then((status) => {
|
||||
setLegacyMode(status.mode === 'legacy');
|
||||
setAuthed(status.authenticated);
|
||||
setUser(status.user ?? null);
|
||||
setCapabilities(status.capabilities);
|
||||
setGrantedSkills(status.grantedSkills);
|
||||
});
|
||||
return () => setUnauthorizedHandler(null);
|
||||
}, [mindSpacePreview, navigate]);
|
||||
|
||||
if (mindSpacePreview) {
|
||||
|
||||
+8
-27
@@ -41,7 +41,6 @@ import type {
|
||||
SessionEvent,
|
||||
SessionListResponse,
|
||||
UsageRecord,
|
||||
UsageSummary,
|
||||
BalanceUpdate,
|
||||
} from '../types';
|
||||
|
||||
@@ -272,15 +271,14 @@ export async function checkAuth(): Promise<AuthStatus> {
|
||||
if (!response.ok) return { authenticated: false };
|
||||
const status = (await response.json()) as AuthStatus;
|
||||
if (status.authenticated) resetUnauthorizedGuard();
|
||||
if (status.authenticated && status.mode === 'user') {
|
||||
if (status.authenticated && status.mode === 'user' && !status.capabilities) {
|
||||
try {
|
||||
const me = await getMe();
|
||||
return {
|
||||
...status,
|
||||
user: me.user ?? status.user,
|
||||
capabilities: me.capabilities ?? status.capabilities,
|
||||
grantedSkills: me.grantedSkills ?? status.grantedSkills,
|
||||
unrestricted: me.unrestricted ?? status.unrestricted,
|
||||
capabilities: me.capabilities,
|
||||
grantedSkills: me.grantedSkills,
|
||||
user: me.user,
|
||||
};
|
||||
} catch {
|
||||
return status;
|
||||
@@ -301,14 +299,8 @@ export async function getMe(): Promise<{
|
||||
return portalFetch('/auth/me');
|
||||
}
|
||||
|
||||
export async function getMyUsageSummary(): Promise<UsageSummary> {
|
||||
const result = await portalFetch<{ summary: UsageSummary }>('/auth/usage/summary');
|
||||
return result.summary;
|
||||
}
|
||||
|
||||
export async function getMyUsage(limit = 20): Promise<UsageRecord[]> {
|
||||
const query = limit ? `?limit=${encodeURIComponent(String(limit))}` : '';
|
||||
const result = await portalFetch<{ records: UsageRecord[] }>(`/auth/usage${query}`);
|
||||
export async function getMyUsage(): Promise<UsageRecord[]> {
|
||||
const result = await portalFetch<{ records: UsageRecord[] }>('/auth/usage');
|
||||
return result.records ?? [];
|
||||
}
|
||||
|
||||
@@ -1018,19 +1010,8 @@ export async function getAdminDashboardSummary(): Promise<AdminDashboardSummary>
|
||||
return result.summary;
|
||||
}
|
||||
|
||||
export async function getAdminUsageSummary(userId?: string): Promise<UsageSummary> {
|
||||
const params = new URLSearchParams();
|
||||
if (userId) params.set('userId', userId);
|
||||
const query = params.toString() ? `?${params.toString()}` : '';
|
||||
const result = await portalFetch<{ summary: UsageSummary }>(`/admin-api/usage/summary${query}`);
|
||||
return result.summary;
|
||||
}
|
||||
|
||||
export async function listAdminUsage(userId?: string, limit = 20): Promise<UsageRecord[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (userId) params.set('userId', userId);
|
||||
if (limit) params.set('limit', String(limit));
|
||||
const query = params.toString() ? `?${params.toString()}` : '';
|
||||
export async function listAdminUsage(userId?: string): Promise<UsageRecord[]> {
|
||||
const query = userId ? `?userId=${encodeURIComponent(userId)}` : '';
|
||||
const result = await portalFetch<{ records: UsageRecord[] }>(`/admin-api/usage${query}`);
|
||||
return result.records ?? [];
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
打开完整聊天
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { createContext, useContext, type ReactNode } from 'react';
|
||||
import type { CapabilityMap } from '../types';
|
||||
|
||||
type AppSessionContextValue = {
|
||||
capabilities?: CapabilityMap;
|
||||
grantedSkills?: string[];
|
||||
};
|
||||
|
||||
const AppSessionContext = createContext<AppSessionContextValue>({});
|
||||
|
||||
export function AppSessionProvider({
|
||||
capabilities,
|
||||
grantedSkills,
|
||||
children,
|
||||
}: {
|
||||
capabilities?: CapabilityMap;
|
||||
grantedSkills?: string[];
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<AppSessionContext.Provider value={{ capabilities, grantedSkills }}>
|
||||
{children}
|
||||
</AppSessionContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAppSession() {
|
||||
return useContext(AppSessionContext);
|
||||
}
|
||||
@@ -306,12 +306,6 @@ export function usePageEditSubChat({
|
||||
}
|
||||
}, [connectSubSession, start]);
|
||||
|
||||
const newSession = useCallback(async () => {
|
||||
if (chatState === 'streaming' || chatState === 'loading' || chatState === 'connecting') return;
|
||||
await close({ merge: false });
|
||||
await start();
|
||||
}, [chatState, close, start]);
|
||||
|
||||
const startedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -341,7 +335,6 @@ export function usePageEditSubChat({
|
||||
dismissNotice,
|
||||
retryConnect,
|
||||
openRecharge: () => setNotice('余额不足,请充值后继续使用'),
|
||||
newSession,
|
||||
close,
|
||||
};
|
||||
}
|
||||
|
||||
+19
-32
@@ -507,43 +507,30 @@ export function useTKMindChat(
|
||||
setChatState('loading');
|
||||
setError(null);
|
||||
|
||||
let sessionId: string | null = null;
|
||||
let sessionId = readStoredSessionId(userRef.current?.id);
|
||||
let staleSession = false;
|
||||
let freshSession: Session | null = null;
|
||||
|
||||
setSessionsLoading(true);
|
||||
try {
|
||||
const items = sortAndTrim(await listSessions());
|
||||
setSessions(items);
|
||||
sessionId = items[0]?.id ?? null;
|
||||
} catch {
|
||||
// Fall back to stored session or create a new one when the list is unavailable.
|
||||
} finally {
|
||||
setSessionsLoading(false);
|
||||
if (sessionId) {
|
||||
try {
|
||||
await getSession(sessionId);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && (err.status === 403 || err.status === 404 || err.status === 400)) {
|
||||
clearStoredSessionId(userRef.current?.id);
|
||||
sessionId = null;
|
||||
staleSession = true;
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!sessionId) {
|
||||
let staleSession = false;
|
||||
sessionId = readStoredSessionId(userRef.current?.id);
|
||||
if (sessionId) {
|
||||
try {
|
||||
await getSession(sessionId);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && (err.status === 403 || err.status === 404 || err.status === 400)) {
|
||||
clearStoredSessionId(userRef.current?.id);
|
||||
sessionId = null;
|
||||
staleSession = true;
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!sessionId) {
|
||||
freshSession = await startSession();
|
||||
sessionId = freshSession.id;
|
||||
if (staleSession) {
|
||||
setNotice('上次会话已失效,已为你新建对话');
|
||||
}
|
||||
freshSession = await startSession();
|
||||
sessionId = freshSession.id;
|
||||
writeStoredSessionId(userRef.current?.id, sessionId);
|
||||
if (staleSession) {
|
||||
setNotice('上次会话已失效,已为你新建对话');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-39
@@ -990,12 +990,6 @@ body,
|
||||
background: #2f6f57;
|
||||
}
|
||||
|
||||
.page-save-actions button:disabled,
|
||||
.page-save-primary:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.48;
|
||||
}
|
||||
|
||||
.page-save-error {
|
||||
color: #8b2d20;
|
||||
font-size: 13px;
|
||||
@@ -1608,8 +1602,6 @@ body,
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 168px;
|
||||
max-height: min(320px, 50vh);
|
||||
overflow-y: auto;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
@@ -2881,17 +2873,6 @@ body,
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.recharge-rates {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.recharge-tier-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
@@ -3041,25 +3022,6 @@ body,
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.admin-usage-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.admin-usage-summary-card h3 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.admin-usage-hint {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.admin-usage-filter {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.admin-stat-card {
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--color-border);
|
||||
@@ -6045,7 +6007,7 @@ body,
|
||||
border-radius: 24px 24px 0 0;
|
||||
}
|
||||
|
||||
.space-chat-panel-actions .ghost-btn:not(.space-chat-panel-new-session) {
|
||||
.space-chat-panel-actions .ghost-btn {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,8 +141,6 @@ export type BillingConfig = {
|
||||
tiersCents: number[];
|
||||
minRechargeCents: number;
|
||||
balanceCents: number;
|
||||
inputCentsPer1k: number;
|
||||
outputCentsPer1k: number;
|
||||
};
|
||||
|
||||
export type JsapiPayParams = {
|
||||
@@ -621,19 +619,6 @@ export type UsageRecord = {
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
export type UsageTotals = {
|
||||
requestCount: number;
|
||||
inputTokens: number | null;
|
||||
outputTokens: number | null;
|
||||
totalTokens: number;
|
||||
costCents: number;
|
||||
};
|
||||
|
||||
export type UsageSummary = {
|
||||
allTime: UsageTotals;
|
||||
last24h: UsageTotals;
|
||||
};
|
||||
|
||||
export type BalanceUpdate = {
|
||||
balanceCents: number;
|
||||
tokensUsed?: number;
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
/** Format CNY cents-per-1k-tokens as readable yuan string. */
|
||||
export function formatYuanPer1kTokens(centsPer1k: number) {
|
||||
const yuan = centsPer1k / 100;
|
||||
if (yuan >= 0.01) return `¥${yuan.toFixed(2)}/1k`;
|
||||
return `¥${yuan.toFixed(3)}/1k`;
|
||||
}
|
||||
|
||||
/** Approximate yuan per million tokens from cents-per-1k rate. */
|
||||
export function formatYuanPerMillionTokens(centsPer1k: number) {
|
||||
const yuanPerM = (centsPer1k / 100) * 1000;
|
||||
if (yuanPerM >= 1) return `约 ¥${yuanPerM.toFixed(1).replace(/\.0$/, '')}/百万`;
|
||||
return `约 ¥${yuanPerM.toFixed(2)}/百万`;
|
||||
}
|
||||
|
||||
export function formatBillingRates(inputCentsPer1k: number, outputCentsPer1k: number) {
|
||||
return `输入 ${formatYuanPer1kTokens(inputCentsPer1k)}(${formatYuanPerMillionTokens(inputCentsPer1k)}),输出 ${formatYuanPer1kTokens(outputCentsPer1k)}(${formatYuanPerMillionTokens(outputCentsPer1k)})`;
|
||||
}
|
||||
@@ -13,8 +13,7 @@ export type ChatSkillIconId =
|
||||
| 'form'
|
||||
| 'table'
|
||||
| 'summary'
|
||||
| 'analyze'
|
||||
| 'image';
|
||||
| 'analyze';
|
||||
|
||||
export type ChatSkillOption = {
|
||||
id: string;
|
||||
|
||||
Reference in New Issue
Block a user