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
+18 -35
View File
@@ -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
View File
@@ -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 ?? [];
}
+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}>
-29
View File
@@ -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);
}
-7
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
}
-15
View File
@@ -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;
-17
View File
@@ -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)}`;
}
+1 -2
View File
@@ -13,8 +13,7 @@ export type ChatSkillIconId =
| 'form'
| 'table'
| 'summary'
| 'analyze'
| 'image';
| 'analyze';
export type ChatSkillOption = {
id: string;