Initial commit: Memind H5 portal with MindSpace, Plaza, and agent jobs.

Track application source and tests; exclude local env, user workspaces, and runtime data via .gitignore.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
John
2026-06-15 15:04:43 -07:00
commit 2e14873f2d
272 changed files with 64133 additions and 0 deletions
+335
View File
@@ -0,0 +1,335 @@
import { useEffect, useState } from 'react';
import { getMe, getWechatAuthConfig, login, loginLegacy, register, resetPassword } from '../api/client';
import type { CapabilityMap, PortalUser } from '../types';
import { buildWechatAuthorizeUrl, isWechatContext, readWechatAuthError } from '../utils/wechat';
import { TKMindAvatar } from './TKMindAvatar';
import { resolvePlazaHomeUrl } from '../utils/publicUrl';
type Mode = 'login' | 'register' | 'reset';
const MODE_META: Record<Mode, { title: string; desc: string }> = {
login: { title: 'TKMind', desc: '登录你的账号' },
register: { title: '创建账号', desc: '填写信息完成注册' },
reset: { title: '重置密码', desc: '验证注册邮箱后设置新密码' },
};
export function AuthView({
legacyMode,
onAuth,
}: {
legacyMode?: boolean;
onAuth: (
user?: PortalUser | null,
capabilities?: CapabilityMap,
grantedSkills?: string[],
) => void;
}) {
const [mode, setMode] = useState<Mode>('login');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [displayName, setDisplayName] = useState('');
const [email, setEmail] = useState('');
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [wechatEnabled, setWechatEnabled] = useState(false);
const [wechatContext, setWechatContext] = useState(() =>
isWechatContext({ search: window.location.search }),
);
const searchParams = new URLSearchParams(window.location.search);
const fromPlaza = (searchParams.get('utm_source') ?? '').toLowerCase() === 'plaza';
const returnTo = searchParams.get('return_to');
useEffect(() => {
const wechatError = readWechatAuthError();
if (wechatError) {
setError(wechatError);
}
}, []);
useEffect(() => {
if (legacyMode) return;
void getWechatAuthConfig().then((config) => {
setWechatEnabled(config.enabled);
setWechatContext((prev) =>
isWechatContext({
search: window.location.search,
serverInWechat: config.inWechat,
}) || prev,
);
});
}, [legacyMode]);
const showWechatLogin = !legacyMode && mode === 'login' && wechatContext && wechatEnabled;
const switchMode = (next: Mode) => {
setMode(next);
setError(null);
setSuccess(null);
setConfirmPassword('');
};
const handleWechatLogin = () => {
const params = new URLSearchParams(window.location.search);
window.location.href = buildWechatAuthorizeUrl({
returnTo: returnTo ?? undefined,
utmSource: params.get('utm_source') ?? params.get('from') ?? 'wechat',
utmMedium: params.get('utm_medium') ?? undefined,
utmCampaign: params.get('utm_campaign') ?? undefined,
});
};
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setSubmitting(true);
setError(null);
try {
if (legacyMode) {
await loginLegacy(password);
onAuth(null);
return;
}
const user = await login(username, password);
const me = await getMe().catch(() => null);
onAuth(user, me?.capabilities, me?.grantedSkills);
} catch (err) {
setError(err instanceof Error ? err.message : '登录失败');
} finally {
setSubmitting(false);
}
};
const handleRegister = async (e: React.FormEvent) => {
e.preventDefault();
setSubmitting(true);
setError(null);
try {
await register(
username,
password,
displayName || undefined,
email,
{
utm_source: searchParams.get('utm_source') ?? undefined,
utm_medium: searchParams.get('utm_medium') ?? undefined,
utm_campaign: searchParams.get('utm_campaign') ?? undefined,
ref: searchParams.get('ref') ?? undefined,
},
);
const user = await login(username, password);
const me = await getMe().catch(() => null);
onAuth(user, me?.capabilities, me?.grantedSkills);
} catch (err) {
setError(err instanceof Error ? err.message : '注册失败');
} finally {
setSubmitting(false);
}
};
const handleReset = async (e: React.FormEvent) => {
e.preventDefault();
if (password !== confirmPassword) {
setError('两次输入的密码不一致');
return;
}
setSubmitting(true);
setError(null);
setSuccess(null);
try {
await resetPassword(username, email, password);
setSuccess('密码已重置,请使用新密码登录');
setPassword('');
setConfirmPassword('');
setTimeout(() => switchMode('login'), 1500);
} catch (err) {
setError(err instanceof Error ? err.message : '重置失败');
} finally {
setSubmitting(false);
}
};
const meta = legacyMode
? { title: 'TKMind', desc: '内部访问入口' }
: fromPlaza
? {
...MODE_META[mode],
desc:
mode === 'register'
? '注册后将返回 Plaza 继续浏览'
: '登录后将返回 Plaza 继续浏览',
}
: MODE_META[mode];
const plazaBackUrl = (() => {
if (!returnTo) return resolvePlazaHomeUrl();
try {
return new URL(returnTo, window.location.origin).toString();
} catch {
return resolvePlazaHomeUrl();
}
})();
const onSubmit =
mode === 'register' && !legacyMode
? handleRegister
: mode === 'reset' && !legacyMode
? handleReset
: handleLogin;
const submitLabel =
submitting
? '处理中…'
: legacyMode
? '解锁'
: mode === 'register'
? '注册并登录'
: mode === 'reset'
? '确认重置'
: '登录';
const submitDisabled =
submitting ||
(legacyMode
? !password.trim()
: mode === 'login'
? !username.trim() || !password.trim()
: mode === 'register'
? !username.trim() || !password.trim() || !email.trim()
: !username.trim() ||
!email.trim() ||
!password.trim() ||
!confirmPassword.trim());
return (
<div className="login-container">
<div className="login-card">
{!legacyMode && fromPlaza && (
<div className="login-plaza-back">
<a href={plazaBackUrl}> 广</a>
</div>
)}
{!legacyMode && (
<div className="login-topbar">
{mode === 'login' ? (
<>
<button type="button" className="login-link" onClick={() => switchMode('register')}>
</button>
<span className="login-topbar-sep" aria-hidden="true" />
<button type="button" className="login-link" onClick={() => switchMode('reset')}>
</button>
</>
) : (
<button type="button" className="login-link" onClick={() => switchMode('login')}>
</button>
)}
</div>
)}
<form className="login-form" onSubmit={onSubmit}>
<div className="login-brand">
<TKMindAvatar size="md" className="login-brand-avatar" />
<h1 className="login-title">{meta.title}</h1>
<p className="login-desc">{meta.desc}</p>
</div>
{showWechatLogin && (
<>
<button
type="button"
className="login-wechat-btn"
onClick={handleWechatLogin}
disabled={submitting}
>
</button>
<div className="login-divider" aria-hidden="true">
<span></span>
</div>
</>
)}
<div className="login-fields">
{!legacyMode && (
<input
className={`login-input${error ? ' login-input-error' : ''}`}
type="text"
placeholder="用户名"
value={username}
autoComplete="username"
autoFocus={!showWechatLogin}
onChange={(e) => setUsername(e.target.value)}
/>
)}
{mode === 'register' && !legacyMode && (
<>
<input
className="login-input"
type="email"
placeholder="邮箱"
value={email}
autoComplete="email"
required
onChange={(e) => setEmail(e.target.value)}
/>
<input
className="login-input"
type="text"
placeholder="显示名称(可选)"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
/>
</>
)}
{mode === 'reset' && !legacyMode && (
<input
className="login-input"
type="email"
placeholder="注册邮箱"
value={email}
autoComplete="email"
required
onChange={(e) => setEmail(e.target.value)}
/>
)}
<input
className={`login-input${error ? ' login-input-error' : ''}`}
type="password"
placeholder={mode === 'reset' ? '新密码(至少 6 位)' : '密码'}
value={password}
autoComplete={
mode === 'register' || mode === 'reset' ? 'new-password' : 'current-password'
}
autoFocus={legacyMode}
onChange={(e) => setPassword(e.target.value)}
/>
{mode === 'reset' && !legacyMode && (
<input
className={`login-input${error ? ' login-input-error' : ''}`}
type="password"
placeholder="确认新密码"
value={confirmPassword}
autoComplete="new-password"
onChange={(e) => setConfirmPassword(e.target.value)}
/>
)}
</div>
{error && <p className="login-error">{error}</p>}
{success && <p className="login-success">{success}</p>}
<button type="submit" className="login-btn" disabled={submitDisabled}>
{submitLabel}
</button>
</form>
</div>
</div>
);
}
+123
View File
@@ -0,0 +1,123 @@
import { useEffect, useId, useRef, useState } from 'react';
import { useUserAvatar } from '../hooks/useUserAvatar';
import {
AVATAR_PICKER_OPEN_EVENT,
getAvatarPickerInput,
openAvatarPicker,
registerAvatarPickerInput,
} from '../utils/userAvatar';
import { UserAvatar } from './UserAvatar';
type AvatarPickerProps = {
variant?: 'sidebar' | 'compact';
};
export function AvatarPicker({ variant = 'sidebar' }: AvatarPickerProps) {
const inputId = useId();
const inputRef = useRef<HTMLInputElement>(null);
const ownsInput = true;
const { avatarUrl, updateAvatar, resetAvatar } = useUserAvatar();
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
useEffect(() => {
if (!ownsInput) return;
registerAvatarPickerInput(inputRef.current);
const handleOpen = () => {
if (!busy) inputRef.current?.click();
};
window.addEventListener(AVATAR_PICKER_OPEN_EVENT, handleOpen);
return () => {
if (getAvatarPickerInput() === inputRef.current) {
registerAvatarPickerInput(null);
}
window.removeEventListener(AVATAR_PICKER_OPEN_EVENT, handleOpen);
};
}, [busy, ownsInput]);
const triggerPicker = () => {
if (busy) return;
if (ownsInput) {
inputRef.current?.click();
} else {
openAvatarPicker();
}
};
const handleChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
setBusy(true);
setError(null);
try {
await updateAvatar(file);
} catch (err) {
setError(err instanceof Error ? err.message : '头像更新失败');
} finally {
setBusy(false);
}
};
const fileInput = ownsInput ? (
<input
ref={inputRef}
id={inputId}
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
className="avatar-picker-input"
onChange={(e) => void handleChange(e)}
/>
) : null;
if (variant === 'compact') {
return (
<>
<UserAvatar
avatarUrl={avatarUrl}
size="sm"
onClick={triggerPicker}
title={error ?? '点击更换头像'}
/>
{fileInput}
</>
);
}
return (
<div className="avatar-picker">
<button
type="button"
className="avatar-picker-trigger"
disabled={busy}
onClick={triggerPicker}
>
<UserAvatar avatarUrl={avatarUrl} size="md" />
<span className="avatar-picker-text">
<span className="avatar-picker-label">{busy ? '处理中…' : '更换我的头像'}</span>
<span className="avatar-picker-hint">JPG · PNG · WebP · 2MB</span>
</span>
</button>
{avatarUrl && (
<button
type="button"
className="avatar-picker-reset"
disabled={busy}
onClick={() => {
resetAvatar();
setError(null);
}}
>
</button>
)}
{error && <p className="avatar-picker-error">{error}</p>}
{fileInput}
</div>
);
}
+145
View File
@@ -0,0 +1,145 @@
import { useEffect, useRef, useState, type CSSProperties } from 'react';
const RING_R = 16;
const CIRC = 2 * Math.PI * RING_R;
function formatYuan(cents: number) {
return `¥${(cents / 100).toFixed(2)}`;
}
type BalanceRingProps = {
balanceCents: number;
totalCreditCents?: number;
onRecharge: (force?: boolean) => void;
};
export function BalanceRing({ balanceCents, totalCreditCents, onRecharge }: BalanceRingProps) {
const [open, setOpen] = useState(false);
const [popoverStyle, setPopoverStyle] = useState<CSSProperties>({});
const wrapRef = useRef<HTMLDivElement>(null);
const popoverWidth = 220;
const total = Math.max(totalCreditCents ?? balanceCents, balanceCents, 0);
const spent = Math.max(0, total - balanceCents);
const pct = total > 0 ? Math.round((balanceCents / total) * 100) : 0;
const spentLen = total > 0 ? (spent / total) * CIRC : 0;
const remainLen = total > 0 ? (balanceCents / total) * CIRC : 0;
const spentPct = total > 0 ? (spent / total) * 100 : 0;
const remainPct = total > 0 ? (balanceCents / total) * 100 : 0;
const low = balanceCents > 0 && balanceCents <= 100;
const empty = balanceCents <= 0;
useEffect(() => {
if (!open) return;
const handleClick = (event: MouseEvent) => {
if (wrapRef.current && !wrapRef.current.contains(event.target as Node)) {
setOpen(false);
}
};
document.addEventListener('click', handleClick);
return () => document.removeEventListener('click', handleClick);
}, [open]);
useEffect(() => {
if (!open) return;
const updatePosition = () => {
const anchor = wrapRef.current;
if (!anchor) return;
const rect = anchor.getBoundingClientRect();
const width = Math.min(popoverWidth, window.innerWidth - 24);
let left = rect.left + rect.width / 2 - width / 2;
left = Math.max(12, Math.min(left, window.innerWidth - width - 12));
setPopoverStyle({
position: 'fixed',
top: rect.bottom + 10,
left,
width,
});
};
updatePosition();
window.addEventListener('resize', updatePosition);
window.addEventListener('scroll', updatePosition, true);
return () => {
window.removeEventListener('resize', updatePosition);
window.removeEventListener('scroll', updatePosition, true);
};
}, [open]);
const centerLabel = total <= 0 ? '—' : empty ? '0%' : `${pct}%`;
const ariaLabel = total > 0 ? `账户额度,剩余 ${pct}%` : '账户额度';
return (
<div className={`balance-popover-wrap${open ? ' open' : ''}`} ref={wrapRef}>
<button
type="button"
className={`balance-ring-btn${low ? ' low' : ''}${empty ? ' empty' : ''}`}
aria-label={ariaLabel}
aria-expanded={open}
onClick={(event) => {
event.stopPropagation();
setOpen((value) => !value);
}}
>
<svg className="balance-ring-svg" viewBox="0 0 40 40" aria-hidden="true">
<circle className="balance-ring-track" cx="20" cy="20" r={RING_R} />
<circle
className="balance-ring-spent"
cx="20"
cy="20"
r={RING_R}
strokeDasharray={`${spentLen} ${CIRC}`}
strokeDashoffset={0}
/>
<circle
className="balance-ring-remain"
cx="20"
cy="20"
r={RING_R}
strokeDasharray={`${remainLen} ${CIRC}`}
strokeDashoffset={-spentLen}
/>
</svg>
<span className="balance-ring-center">{centerLabel}</span>
</button>
<div className="balance-popover" role="dialog" aria-label="账户额度" style={popoverStyle}>
<h4></h4>
<div className="balance-popover-row">
<span className="balance-popover-label">
<span className="balance-dot balance-dot-remain" aria-hidden="true" />
</span>
<strong>{formatYuan(balanceCents)}</strong>
</div>
<div className="balance-popover-row">
<span className="balance-popover-label">
<span className="balance-dot balance-dot-spent" aria-hidden="true" />
</span>
<strong>{formatYuan(spent)}</strong>
</div>
<div className="balance-popover-row">
<span className="balance-popover-label balance-popover-label-muted"></span>
<span>{formatYuan(total)}</span>
</div>
<div className="balance-popover-bar" aria-hidden="true">
<div className="balance-popover-bar-spent" style={{ width: `${spentPct}%` }} />
<div className="balance-popover-bar-remain" style={{ width: `${remainPct}%` }} />
</div>
<button
type="button"
className="balance-popover-cta"
onClick={() => {
setOpen(false);
onRecharge(empty);
}}
>
</button>
</div>
</div>
);
}
+315
View File
@@ -0,0 +1,315 @@
import { useCallback, useEffect, useState } from 'react';
import {
clearUserCapabilityOverrides,
getRoleCapabilities,
getUserCapabilities,
listCapabilityCatalog,
updateRoleCapabilities,
updateUserCapabilities,
} from '../api/client';
import type { AdminUserRow, CapabilityDefinition, CapabilityMap } from '../types';
const RISK_LABEL: Record<string, string> = {
low: '低',
medium: '中',
high: '高',
};
type FilterType = 'all' | 'high' | 'enabled';
function CapabilityGrid({
catalog,
values,
disabled,
onChange,
}: {
catalog: CapabilityDefinition[];
values: CapabilityMap;
disabled?: boolean;
onChange: (key: string, allowed: boolean) => void;
}) {
const [filter, setFilter] = useState<FilterType>('all');
const grouped = catalog.reduce<Record<string, CapabilityDefinition[]>>((acc, item) => {
acc[item.category] ??= [];
acc[item.category].push(item);
return acc;
}, {});
const filterItem = (item: CapabilityDefinition) => {
if (filter === 'high') return item.risk === 'high';
if (filter === 'enabled') return Boolean(values[item.key]);
return true;
};
const FILTER_OPTIONS: { key: FilterType; label: string }[] = [
{ key: 'all', label: '全部' },
{ key: 'high', label: '高风险' },
{ key: 'enabled', label: '已启用' },
];
return (
<div className="capability-grid">
<div className="capability-filter-bar">
{FILTER_OPTIONS.map((opt) => (
<button
key={opt.key}
type="button"
className={`capability-filter-btn${filter === opt.key ? ' active' : ''}`}
onClick={() => setFilter(opt.key)}
>
{opt.label}
</button>
))}
</div>
{Object.entries(grouped).map(([category, items]) => {
const visible = items.filter(filterItem);
if (visible.length === 0) return null;
const enabledCount = items.filter((i) => Boolean(values[i.key])).length;
return (
<div key={category} className="capability-group">
<div className="capability-group-header">
<h3>{category}</h3>
<span className="capability-group-count">
{enabledCount}/{items.length}
</span>
</div>
<ul>
{visible.map((item) => {
const checked = Boolean(values[item.key]);
return (
<li key={item.key} className="capability-item">
<div className="capability-item-info">
<span className="capability-label">
{item.label}
<span className={`risk-pill risk-${item.risk}`}>
{RISK_LABEL[item.risk] ?? item.risk}
</span>
</span>
<span className="muted capability-desc">{item.description}</span>
</div>
<label className="capability-toggle" aria-label={item.label}>
<input
type="checkbox"
checked={checked}
disabled={disabled}
onChange={(e) => onChange(item.key, e.target.checked)}
/>
<span className="toggle-track">
<span className="toggle-thumb" />
</span>
</label>
</li>
);
})}
</ul>
</div>
);
})}
</div>
);
}
export function CapabilitySettings({
users,
userId,
userOnly = false,
}: {
users: AdminUserRow[];
userId?: string;
userOnly?: boolean;
}) {
const [catalog, setCatalog] = useState<CapabilityDefinition[]>([]);
const [roleCapabilities, setRoleCapabilities] = useState<CapabilityMap>({});
const [selectedUserId, setSelectedUserId] = useState(userId ?? '');
const [userCapabilities, setUserCapabilities] = useState<CapabilityMap>({});
const [userOverrides, setUserOverrides] = useState<CapabilityMap>({});
const [userUnrestricted, setUserUnrestricted] = useState(false);
const [loading, setLoading] = useState(true);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [catalogItems, roleState] = await Promise.all([
listCapabilityCatalog(),
getRoleCapabilities('user'),
]);
setCatalog(catalogItems);
setRoleCapabilities(roleState.capabilities);
} catch (err) {
setError(err instanceof Error ? err.message : '加载权限配置失败');
} finally {
setLoading(false);
}
}, []);
const loadUserCapabilities = useCallback(async (userId: string) => {
if (!userId) {
setUserCapabilities({});
setUserOverrides({});
setUserUnrestricted(false);
return;
}
try {
const state = await getUserCapabilities(userId);
setUserCapabilities(state.capabilities);
setUserOverrides(state.overrides);
setUserUnrestricted(state.unrestricted);
} catch (err) {
setError(err instanceof Error ? err.message : '加载用户权限失败');
}
}, []);
useEffect(() => {
void load();
}, [load]);
useEffect(() => {
void loadUserCapabilities(selectedUserId);
}, [loadUserCapabilities, selectedUserId]);
useEffect(() => {
if (userId) {
setSelectedUserId(userId);
}
}, [userId]);
const saveRoleDefaults = async () => {
setMessage(null);
setError(null);
try {
const result = await updateRoleCapabilities('user', roleCapabilities);
setRoleCapabilities(result.capabilities);
setMessage('普通用户默认权限已保存(对新用户及无单独配置的用户生效)');
} catch (err) {
setError(err instanceof Error ? err.message : '保存失败');
}
};
const saveUserOverrides = async () => {
if (!selectedUserId) return;
setMessage(null);
setError(null);
try {
const result = await updateUserCapabilities(selectedUserId, userCapabilities);
setUserCapabilities(result.capabilities);
await loadUserCapabilities(selectedUserId);
setMessage('用户单独权限已保存');
} catch (err) {
setError(err instanceof Error ? err.message : '保存失败');
}
};
const resetUserOverrides = async () => {
if (!selectedUserId) return;
setMessage(null);
setError(null);
try {
await clearUserCapabilityOverrides(selectedUserId);
await loadUserCapabilities(selectedUserId);
setMessage('已恢复为角色默认权限');
} catch (err) {
setError(err instanceof Error ? err.message : '重置失败');
}
};
const regularUsers = users.filter((user) => user.role === 'user');
return (
<section className="admin-card">
<div className="admin-card-head">
<h2></h2>
<button type="button" className="ghost-btn" onClick={() => void load()}>
</button>
</div>
<p className="muted capability-intro">
/MindSpace/ static-page-publish
API
</p>
{error && <p className="banner banner-error">{error}</p>}
{message && <p className="banner banner-info">{message}</p>}
{loading ? (
<p className="muted"></p>
) : (
<>
{!userOnly && (
<>
<h3 className="capability-section-title"></h3>
<CapabilityGrid
catalog={catalog}
values={roleCapabilities}
onChange={(key, allowed) =>
setRoleCapabilities((prev) => ({ ...prev, [key]: allowed }))
}
/>
<div className="capability-actions">
<button type="button" className="send-btn" onClick={() => void saveRoleDefaults()}>
</button>
</div>
</>
)}
{!userOnly && <h3 className="capability-section-title"></h3>}
{!userOnly && (
<div className="admin-form capability-user-picker">
<select
value={selectedUserId}
onChange={(e) => setSelectedUserId(e.target.value)}
>
<option value=""></option>
{regularUsers.map((user) => (
<option key={user.id} value={user.id}>
{user.displayName} (@{user.username})
</option>
))}
</select>
</div>
)}
{selectedUserId && (
<>
{userUnrestricted ? (
<p className="muted"></p>
) : (
<>
{Object.keys(userOverrides).length > 0 && (
<p className="muted">
{Object.keys(userOverrides).length}
</p>
)}
<CapabilityGrid
catalog={catalog}
values={userCapabilities}
onChange={(key, allowed) =>
setUserCapabilities((prev) => ({ ...prev, [key]: allowed }))
}
/>
<div className="capability-actions">
<button
type="button"
className="send-btn"
onClick={() => void saveUserOverrides()}
>
</button>
<button
type="button"
className="ghost-btn"
onClick={() => void resetUserOverrides()}
>
</button>
</div>
</>
)}
</>
)}
</>
)}
</section>
);
}
+186
View File
@@ -0,0 +1,186 @@
import { useEffect, useRef, useState } from 'react';
import { useNetworkStatus } from '../hooks/useNetworkStatus';
import { openAvatarPicker } from '../utils/userAvatar';
import { buildPublishSkillPrompt } from '../utils/publishSkill';
import { AvatarPicker } from './AvatarPicker';
import { MessageList } from './MessageList';
import { PageSaveDialog } from './PageSaveDialog';
import { VoiceInputButton } from './VoiceInputButton';
import type { CapabilityMap, ChatState, Message, PortalUser, Session, ToolConfirmation } from '../types';
import type { MindSpaceSaveCategory } from '../types';
export function ChatPanel({
variant,
user,
messages,
chatState,
pendingTool,
session,
capabilities,
grantedSkills,
onSubmit,
onStop,
onApproveTool,
onPageSaved,
}: {
variant: 'full' | 'compact';
user?: PortalUser | null;
messages: Message[];
chatState: ChatState;
pendingTool: ToolConfirmation | null;
session: Session | null;
capabilities?: CapabilityMap;
grantedSkills?: string[];
onSubmit: (text: string) => void | Promise<void>;
onStop: () => void | Promise<void>;
onApproveTool: (allow: boolean) => void | Promise<void>;
onPageSaved?: (result: {
kind: 'page' | 'category';
pageId?: string;
categoryCode?: MindSpaceSaveCategory;
}) => void;
}) {
const online = useNetworkStatus();
const [input, setInput] = useState('');
const [pageSource, setPageSource] = useState<Message | null>(null);
const [voiceNotice, setVoiceNotice] = useState<string | null>(null);
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, chatState, pendingTool]);
const busy = chatState === 'streaming' || chatState === 'loading';
const offlineBlocked = !online;
const publishSkillName = user?.publishSkillName ?? 'static-page-publish';
const hasPublishSkill = grantedSkills?.includes(publishSkillName) ?? false;
const canPublish = Boolean(capabilities?.static_publish) || hasPublishSkill;
const compact = variant === 'compact';
const handleSubmit = async () => {
const text = input;
if (!text.trim()) return;
setInput('');
await onSubmit(text);
};
const placeholder = offlineBlocked
? '网络断开,恢复连接后可继续输入'
: compact
? '在这里继续和 Agent 对话…'
: '输入任务,例如:列出当前目录文件';
const handleVoiceSend = async (text: string) => {
setVoiceNotice(null);
await onSubmit(text);
};
const voiceDisabled = busy || !!pendingTool || chatState === 'error' || offlineBlocked;
return (
<>
<main className={compact ? 'space-chat-panel-body' : 'main'}>
<MessageList
messages={messages}
streaming={chatState === 'streaming'}
onAvatarClick={compact ? undefined : openAvatarPicker}
onSaveAsPage={(message) => setPageSource(message)}
publishUsername={user?.username}
compact={compact}
/>
{!compact && (
<div className="user-avatar-input-host" aria-hidden="true">
<AvatarPicker variant="compact" />
</div>
)}
<div ref={bottomRef} />
</main>
{pendingTool && (
<div className={`tool-confirm${compact ? ' tool-confirm-compact' : ''}`}>
<div className="tool-confirm-text">
<strong>{pendingTool.toolName}</strong>
{pendingTool.prompt && <p>{pendingTool.prompt}</p>}
</div>
<div className="tool-confirm-actions">
<button type="button" onClick={() => void onApproveTool(true)}>
</button>
<button type="button" className="danger" onClick={() => void onApproveTool(false)}>
</button>
</div>
</div>
)}
{pageSource?.id && session?.id && (
<PageSaveDialog
sessionId={session.id}
messageId={pageSource.id}
compact={compact}
onClose={() => setPageSource(null)}
onSaved={(result) => {
setPageSource(null);
onPageSaved?.(result);
}}
/>
)}
<footer className={compact ? 'space-chat-panel-footer' : 'footer'}>
{voiceNotice && (
<div className="voice-notice" role="status">
{voiceNotice}
</div>
)}
<div className="chat-input-row">
<VoiceInputButton
disabled={voiceDisabled}
onSend={(text) => void handleVoiceSend(text)}
onError={setVoiceNotice}
/>
<textarea
className="input"
rows={1}
placeholder={placeholder}
value={input}
disabled={voiceDisabled}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
void handleSubmit();
}
}}
/>
{!compact && canPublish && (
<button
type="button"
className="ghost-btn chat-input-action"
disabled={voiceDisabled}
title={`加载 ${publishSkillName} 技能并生成 HTML`}
onClick={() => {
setInput(buildPublishSkillPrompt(publishSkillName));
}}
>
</button>
)}
{chatState === 'streaming' ? (
<button type="button" className="danger-btn chat-input-action" onClick={() => void onStop()}>
</button>
) : (
<button
type="button"
className="send-btn chat-input-action"
disabled={!input.trim() || voiceDisabled}
onClick={() => void handleSubmit()}
>
</button>
)}
</div>
</footer>
</>
);
}
+233
View File
@@ -0,0 +1,233 @@
import { useEffect, useState } from 'react';
import { useChat } from '../context/ChatProvider';
import type { CapabilityMap, PortalUser } from '../types';
import { useNetworkStatus } from '../hooks/useNetworkStatus';
import { getSessionDisplayName } from '../utils/sessions';
import { openAvatarPicker } from '../utils/userAvatar';
import { HistorySidebar } from './HistorySidebar';
import { TKMindAvatar } from './TKMindAvatar';
import { ChatPanel } from './ChatPanel';
import { BalanceRing } from './BalanceRing';
import type { MindSpaceSaveCategory } from '../types';
export function ChatView({
user,
capabilities,
grantedSkills,
onLogout,
onOpenSpace,
onOpenPage,
onOpenAdmin,
}: {
user?: PortalUser | null;
capabilities?: CapabilityMap;
grantedSkills?: string[];
onUserUpdate?: (user: PortalUser) => void;
onLogout?: () => void;
onOpenSpace?: (target?: { categoryCode?: MindSpaceSaveCategory; pageId?: string }) => void;
onOpenPage?: (pageId: string) => void;
onOpenAdmin?: () => void;
}) {
const {
session,
sessions,
sessionsLoading,
messages,
chatState,
error,
notice,
pendingTool,
memoryLoading,
submit,
stop,
approveTool,
newSession,
rememberCurrentContext,
refreshProjectMemory,
switchSession,
retryConnect,
dismissNotice,
onSidebarOpen,
workingDir,
balanceCents,
totalCreditCents,
openRecharge,
} = useChat();
const online = useNetworkStatus();
const [sidebarOpen, setSidebarOpen] = useState(false);
useEffect(() => {
if (sidebarOpen) {
onSidebarOpen();
}
}, [sidebarOpen, onSidebarOpen]);
const busy = chatState === 'streaming' || chatState === 'loading';
const handleSelectSession = (sessionId: string) => {
void switchSession(sessionId);
setSidebarOpen(false);
};
const handleNewSession = () => {
void newSession();
setSidebarOpen(false);
};
const sessionTitle = session ? getSessionDisplayName(session) : '连接中…';
return (
<div className="app-shell">
<HistorySidebar
open={sidebarOpen}
sessions={sessions}
activeSessionId={session?.id}
loading={sessionsLoading}
onClose={() => setSidebarOpen(false)}
onSelect={handleSelectSession}
onNew={handleNewSession}
/>
<div className="app">
<header className="header">
<div className="header-left">
<button
type="button"
className="header-menu-btn"
aria-label="展开历史对话"
onClick={() => setSidebarOpen(true)}
>
</button>
<TKMindAvatar size="sm" className="header-brand-avatar" />
<div>
<div className="header-title">{user?.displayName ?? 'TKMind'}</div>
<div className="header-sub">{sessionTitle}</div>
</div>
</div>
<div className="header-actions">
{typeof balanceCents === 'number' && (
<BalanceRing
balanceCents={balanceCents}
totalCreditCents={totalCreditCents}
onRecharge={openRecharge}
/>
)}
{onOpenAdmin && (
<button type="button" className="ghost-btn" onClick={onOpenAdmin}>
</button>
)}
{onOpenSpace && (
<button type="button" className="ghost-btn" onClick={() => onOpenSpace?.()}>
</button>
)}
<button
type="button"
className="ghost-btn"
disabled={busy || memoryLoading || messages.length === 0}
onClick={() => void rememberCurrentContext()}
title="把最近几条对话保存到 harness 项目知识库"
>
</button>
<button
type="button"
className="ghost-btn"
disabled={busy || memoryLoading}
onClick={() => void refreshProjectMemory()}
title="从 harness 和项目文件重新加载长期记忆"
>
{memoryLoading ? '同步中…' : '刷新记忆'}
</button>
<button type="button" className="ghost-btn" onClick={() => void handleNewSession()}>
</button>
{onLogout && (
<button type="button" className="ghost-btn logout-btn" onClick={onLogout}>
</button>
)}
</div>
</header>
{!online && (
<div className="banner banner-offline"></div>
)}
{typeof balanceCents === 'number' && balanceCents <= 0 && (
<div className="banner banner-error">
<span>使</span>
<button type="button" className="banner-action" onClick={() => openRecharge(true)}>
</button>
</div>
)}
{typeof balanceCents === 'number' && balanceCents > 0 && balanceCents <= 100 && (
<div className="banner banner-warning">
<span>¥{(balanceCents / 100).toFixed(2)}</span>
<button type="button" className="banner-action" onClick={() => openRecharge(false)}>
</button>
</div>
)}
{user?.publishUrl && (
<div className="banner banner-info publish-banner">
<span>
<a href={user.publishUrl} target="_blank" rel="noreferrer">
{user.publishUrl}
</a>
{Boolean(capabilities?.static_publish) || (grantedSkills?.includes(user.publishSkillName ?? 'static-page-publish') ?? false)
? '(已开通静态页面技能,生成后可直接访问)'
: '(需管理员在「技能配置」中勾选 static-page-publish'}
</span>
</div>
)}
{notice && (
<div className="banner banner-info">
<span>{notice}</span>
<button type="button" className="banner-dismiss" onClick={dismissNotice}>
</button>
</div>
)}
{error && (
<div className="banner banner-error">
<span>{error}</span>
<button type="button" className="banner-action" onClick={() => void retryConnect()}>
</button>
</div>
)}
<ChatPanel
variant="full"
user={user}
messages={messages}
chatState={chatState}
pendingTool={pendingTool}
session={session}
capabilities={capabilities}
grantedSkills={grantedSkills}
onSubmit={submit}
onStop={stop}
onApproveTool={approveTool}
onPageSaved={(result) => {
if (result.kind === 'page' && result.pageId) {
onOpenPage?.(result.pageId);
return;
}
onOpenSpace?.({ categoryCode: result.categoryCode });
}}
/>
{workingDir && <div className="meta">{workingDir}</div>}
</div>
</div>
);
}
+117
View File
@@ -0,0 +1,117 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { AvatarPicker } from './AvatarPicker';
import { appConfig } from '../config';
import type { Session } from '../types';
import { getSessionListLabel } from '../utils/sessions';
function formatSessionTime(iso?: string): string {
if (!iso) return '';
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return '';
const now = new Date();
const isToday =
date.getFullYear() === now.getFullYear() &&
date.getMonth() === now.getMonth() &&
date.getDate() === now.getDate();
if (isToday) {
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
}
return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
}
type HistorySidebarProps = {
open: boolean;
sessions: Session[];
activeSessionId?: string;
loading: boolean;
onClose: () => void;
onSelect: (sessionId: string) => void;
onNew: () => void;
};
export function HistorySidebar({
open,
sessions,
activeSessionId,
loading,
onClose,
onSelect,
onNew,
}: HistorySidebarProps) {
const bodyRef = useRef<HTMLDivElement>(null);
const [visibleCount, setVisibleCount] = useState(appConfig.sessionPageSize);
useEffect(() => {
if (open) {
setVisibleCount(appConfig.sessionPageSize);
}
}, [open, sessions.length]);
const handleScroll = useCallback(() => {
const el = bodyRef.current;
if (!el) return;
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
if (nearBottom && visibleCount < sessions.length) {
setVisibleCount((count) => Math.min(count + appConfig.sessionPageSize, sessions.length));
}
}, [visibleCount, sessions.length]);
if (!open) return null;
const visibleSessions = sessions.slice(0, visibleCount);
const hasMore = visibleCount < sessions.length;
return (
<>
<button type="button" className="sidebar-backdrop" aria-label="关闭历史" onClick={onClose} />
<aside className="sidebar sidebar-open">
<div className="sidebar-header">
<span className="sidebar-title"></span>
<button type="button" className="sidebar-close" aria-label="收起历史" onClick={onClose}>
</button>
</div>
<div className="sidebar-body" ref={bodyRef} onScroll={handleScroll}>
<button type="button" className="sidebar-new" onClick={onNew}>
+
</button>
{loading && sessions.length === 0 ? (
<div className="sidebar-empty"></div>
) : sessions.length === 0 ? (
<div className="sidebar-empty"></div>
) : (
<>
<ul className="sidebar-list">
{visibleSessions.map((item) => (
<li key={item.id}>
<button
type="button"
className={`sidebar-item ${item.id === activeSessionId ? 'sidebar-item-active' : ''}`}
onClick={() => onSelect(item.id)}
>
<span className="sidebar-item-title">{getSessionListLabel(item)}</span>
<span className="sidebar-item-meta">
{formatSessionTime(item.updated_at ?? item.created_at)}
</span>
</button>
</li>
))}
</ul>
{hasMore && <div className="sidebar-more"></div>}
</>
)}
</div>
<div className="sidebar-footer">
<AvatarPicker />
</div>
</aside>
</>
);
}
+320
View File
@@ -0,0 +1,320 @@
import { useState } from 'react';
import { useUserAvatar } from '../hooks/useUserAvatar';
import type { Message } from '../types';
import { getDisplayText, getThinking, getVisibleText } from '../utils/message';
import { getMessageSaveActions } from '../utils/messageSave';
import { renderMarkdown } from '../utils/markdown';
import { formatChatTime, shouldShowTimestamp } from '../utils/time';
import { TKMindAvatar } from './TKMindAvatar';
import { UserAvatar } from './UserAvatar';
function CopyIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<rect x="8" y="8" width="12" height="12" rx="2" stroke="currentColor" strokeWidth="1.8" />
<path
d="M6 16H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
/>
</svg>
);
}
function CheckIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M5 12.5l4.5 4.5L19 7.5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function ToolBadge({ message }: { message: Message }) {
const tools = message.content.filter((c) => c.type === 'toolRequest' || c.type === 'toolResponse');
if (tools.length === 0) return null;
return (
<div className="tool-badges">
{tools.map((tool, i) => (
<span key={`${message.id}-${i}`} className="tool-badge">
{tool.type === 'toolRequest'
? `🔧 ${tool.toolCall.functionName}`
: `✓ 工具完成`}
</span>
))}
</div>
);
}
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
if (!text) return;
try {
await navigator.clipboard.writeText(text);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} catch {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
}
};
return (
<button
type="button"
className={`msg-copy${copied ? ' msg-copy-done' : ''}`}
aria-label={copied ? '已复制' : '复制'}
title={copied ? '已复制' : '复制'}
onClick={() => void handleCopy()}
>
{copied ? <CheckIcon /> : <CopyIcon />}
</button>
);
}
function TimeDivider({ timestamp }: { timestamp: number }) {
return (
<div className="msg-time-divider">
<span>{formatChatTime(timestamp)}</span>
</div>
);
}
function ChevronIcon({ open }: { open: boolean }) {
return (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
className={open ? 'msg-actions-chevron is-open' : 'msg-actions-chevron'}
>
<path
d="M9 6l6 6-6 6"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function MessageRow({
message,
avatarUrl,
onAvatarClick,
onSaveAsPage,
saveDisabled,
publishUsername,
compact = false,
}: {
message: Message;
avatarUrl: string | null;
onAvatarClick?: () => void;
onSaveAsPage?: (message: Message) => void;
saveDisabled?: boolean;
publishUsername?: string;
compact?: boolean;
}) {
const [actionsOpen, setActionsOpen] = useState(false);
const text = getDisplayText(message);
const saveActions = getMessageSaveActions(text, publishUsername);
const thinking = getThinking(message);
const isUser = message.role === 'user';
const copyText = [thinking ? `【思考】\n${thinking}` : '', text].filter(Boolean).join('\n\n');
if (!text && !thinking && message.role === 'assistant') {
return (
<div className={`msg-row msg-row-assistant`}>
<TKMindAvatar />
<div className="msg-content">
<ToolBadge message={message} />
</div>
</div>
);
}
const hasAssistantActions =
!isUser && Boolean(message.id && onSaveAsPage && copyText);
const showActionsToggle = compact && Boolean(copyText);
return (
<div className={`msg-row ${isUser ? 'msg-row-user' : 'msg-row-assistant'}`}>
{!isUser && <TKMindAvatar />}
<div className="msg-content">
<div className={`msg-bubble-wrap${compact ? ' msg-bubble-wrap-compact' : ''}`}>
<div className={compact ? 'msg-bubble-row' : undefined}>
<div className={`msg-bubble ${isUser ? 'msg-bubble-user' : 'msg-bubble-assistant'}`}>
{thinking && (
<details className="thinking">
<summary></summary>
<pre>{thinking}</pre>
</details>
)}
{text && (
<div
className="bubble-text"
dangerouslySetInnerHTML={{ __html: renderMarkdown(text) }}
/>
)}
<ToolBadge message={message} />
</div>
{showActionsToggle && (
<button
type="button"
className={`msg-actions-toggle${actionsOpen ? ' is-open' : ''}`}
aria-label={actionsOpen ? '收起操作' : '展开操作'}
aria-expanded={actionsOpen}
onClick={() => setActionsOpen((current) => !current)}
>
<ChevronIcon open={actionsOpen} />
</button>
)}
</div>
{copyText && !compact && (
<div className="msg-actions">
<CopyButton text={copyText} />
{hasAssistantActions && (
<>
<button
type="button"
className="msg-save-page"
disabled={saveDisabled}
onClick={() => onSaveAsPage!(message)}
>
{saveActions.kind === 'page' ? '保存页面' : '保存为文章'}
</button>
{saveActions.previewUrl && (
<a
className="msg-open-preview"
href={saveActions.previewUrl}
target="_blank"
rel="noreferrer"
>
</a>
)}
</>
)}
</div>
)}
{copyText && compact && actionsOpen && (
<div className="msg-actions msg-actions-compact">
<CopyButton text={copyText} />
{hasAssistantActions && (
<>
<button
type="button"
className="msg-save-page"
disabled={saveDisabled}
onClick={() => onSaveAsPage!(message)}
>
{saveActions.kind === 'page' ? '保存页面' : '保存为文章'}
</button>
{saveActions.previewUrl && (
<a
className="msg-open-preview"
href={saveActions.previewUrl}
target="_blank"
rel="noreferrer"
>
</a>
)}
</>
)}
</div>
)}
</div>
</div>
{isUser && (
<UserAvatar
avatarUrl={avatarUrl}
className="msg-avatar msg-avatar-user"
onClick={onAvatarClick}
title={onAvatarClick ? '点击更换头像' : undefined}
/>
)}
</div>
);
}
export function MessageList({
messages,
streaming,
onAvatarClick,
onSaveAsPage,
publishUsername,
compact = false,
}: {
messages: Message[];
streaming: boolean;
onAvatarClick?: () => void;
onSaveAsPage?: (message: Message) => void;
publishUsername?: string;
compact?: boolean;
}) {
const { avatarUrl } = useUserAvatar();
return (
<div className="message-list">
{messages.length === 0 && (
<div className={`empty-state${compact ? ' empty-state-compact' : ''}`}>
<TKMindAvatar />
<h2>TKMind</h2>
<p>{compact ? '继续和空间里的 Agent 对话' : '发送消息即可在本机执行 Agent 任务'}</p>
</div>
)}
{messages.map((message, index) => {
const prev = index > 0 ? messages[index - 1] : undefined;
const showTime = shouldShowTimestamp(message.created, prev?.created);
return (
<div key={message.id ?? `msg-${index}`} className="msg-block">
{showTime && <TimeDivider timestamp={message.created} />}
<MessageRow
message={message}
avatarUrl={avatarUrl}
onAvatarClick={onAvatarClick}
onSaveAsPage={onSaveAsPage}
saveDisabled={streaming}
publishUsername={publishUsername}
compact={compact}
/>
</div>
);
})}
{streaming && (
<div className="msg-row msg-row-assistant">
<TKMindAvatar />
<div className="msg-content">
<div className="msg-bubble msg-bubble-assistant msg-typing">
<span className="typing-dots">
<span />
<span />
<span />
</span>
</div>
</div>
</div>
)}
</div>
);
}
+125
View File
@@ -0,0 +1,125 @@
import type { MindSpacePage } from '../types';
import { WorkCover } from './WorkCover';
const ACCESS_MODE_ICONS: Record<string, { icon: string; title: string }> = {
public: { icon: '🌐', title: '完全公开' },
password: { icon: '🔑', title: '密码访问' },
private_link: { icon: '🔗', title: '私密链接' },
time_limited: { icon: '⏱', title: '限时访问' },
login_required:{ icon: '👤', title: '仅登录用户' },
owner_only: { icon: '🔒', title: '仅自己可见' },
};
const PAGE_STATUS_LABELS: Record<string, string> = {
draft: '草稿',
reviewing: '待检查',
risk_found: '发现风险',
ready: '可发布',
published: '已公开',
protected: '受保护',
expired: '已过期',
offline: '已下线',
};
const TEMPLATE_LABELS: Record<string, string> = {
editorial: '长文',
report: '报告',
'knowledge-card': '知识卡片',
profile: '个人介绍',
};
function feedTypeLabel(page: MindSpacePage) {
if (page.contentFormat === 'html') return '网页';
return TEMPLATE_LABELS[page.templateId] ?? page.templateId;
}
function formatFeedDate(timestamp: number) {
const date = new Date(timestamp);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (24 * 60 * 60 * 1000));
if (diffDays < 1) return '今天';
if (diffDays === 1) return '昨天';
if (diffDays < 7) return `${diffDays}天前`;
return date.toLocaleDateString('zh-CN', { month: 'numeric', day: 'numeric' });
}
const PREVIEW_THUMBNAILS: Record<string, string> = {
'page-1': '/thumbnail-demo/mapo-tofu-feed.svg',
'page-2': '/thumbnail-demo/malaysia-travel-feed.svg',
};
export function MindSpaceFeedCard({
page,
previewMode = false,
onClick,
actions,
}: {
page: MindSpacePage;
previewMode?: boolean;
onClick: () => void;
actions?: { label: string; onClick: (e: React.MouseEvent) => void }[];
}) {
const typeLabel = feedTypeLabel(page);
const isHtml = page.contentFormat === 'html';
const previewThumbnail = previewMode ? PREVIEW_THUMBNAILS[page.id] : null;
return (
<div className="mindspace-feed-card-wrap">
<button type="button" className="mindspace-feed-card" onClick={onClick}>
<div className="mindspace-feed-cover">
{isHtml ? (
<WorkCover
coverMode="thumbnail-first"
thumbnailUrl={
previewThumbnail ??
`/api/mindspace/v1/pages/${page.id}/thumbnail?v=${page.updatedAt}`
}
previewUrl={`/api/mindspace/v1/pages/${page.id}/preview?v=${page.versionNo ?? page.updatedAt}`}
fallbackLabel={typeLabel}
title={page.title}
imageClassName="mindspace-feed-cover-media"
previewFrameClassName="mindspace-feed-cover-preview"
/>
) : (
<div className="mindspace-feed-cover-fallback" aria-hidden="true">
<span>{typeLabel}</span>
</div>
)}
<span className="mindspace-feed-badge">{typeLabel}</span>
</div>
<div className="mindspace-feed-body">
<h3 className="mindspace-feed-title">{page.title}</h3>
<p className="mindspace-feed-meta">
{page.publicationAccessMode && ACCESS_MODE_ICONS[page.publicationAccessMode] ? (
<span
className="mindspace-feed-access-icon"
title={ACCESS_MODE_ICONS[page.publicationAccessMode].title}
aria-label={ACCESS_MODE_ICONS[page.publicationAccessMode].title}
>
{ACCESS_MODE_ICONS[page.publicationAccessMode].icon}
</span>
) : null}
{PAGE_STATUS_LABELS[page.status] ?? page.status} · {formatFeedDate(page.updatedAt)}
</p>
</div>
</button>
{actions && actions.length > 0 && (
<div className="mindspace-feed-card-actions">
{actions.map((action) => (
<button
key={action.label}
type="button"
onClick={(event) => {
event.stopPropagation();
action.onClick(event);
}}
>
{action.label}
</button>
))}
</div>
)}
</div>
);
}
+69
View File
@@ -0,0 +1,69 @@
import { useEffect, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
export function MindSpaceModal({
open,
onClose,
title,
eyebrow,
children,
className,
disableClose = false,
}: {
open: boolean;
onClose: () => void;
title: string;
eyebrow?: string;
children: ReactNode;
className?: string;
disableClose?: boolean;
}) {
useEffect(() => {
if (!open) return undefined;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && !disableClose) onClose();
};
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
window.addEventListener('keydown', onKeyDown);
return () => {
document.body.style.overflow = previousOverflow;
window.removeEventListener('keydown', onKeyDown);
};
}, [open, onClose, disableClose]);
if (!open) return null;
return createPortal(
<div
className="mindspace-upload-backdrop"
role="presentation"
onClick={disableClose ? undefined : onClose}
>
<section
className={`mindspace-upload-dialog${className ? ` ${className}` : ''}`}
role="dialog"
aria-modal="true"
aria-labelledby="mindspace-modal-title"
onClick={(event) => event.stopPropagation()}
>
<div className="mindspace-upload-dialog-bar">
<div>
{eyebrow ? <p className="mindspace-eyebrow">{eyebrow}</p> : null}
<h3 id="mindspace-modal-title">{title}</h3>
</div>
<button
type="button"
className="mindspace-upload-dialog-close"
onClick={onClose}
disabled={disableClose}
>
</button>
</div>
{children}
</section>
</div>,
document.body,
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,219 @@
import { useEffect, useRef, useState } from 'react';
import {
regenerateMindSpacePageThumbnail,
uploadMindSpacePageThumbnail,
} from '../api/client';
import {
buildEditablePreviewDocument,
MINDSPACE_PAGE_CONTENT_MESSAGE,
} from '../utils/mindspaceVisualEditor';
function readFileAsBase64(file: File): Promise<{ base64: string; mimeType: string }> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = String(reader.result ?? '');
const match = result.match(/^data:([^;]+);base64,(.+)$/);
if (!match) {
reject(new Error('无法读取图片'));
return;
}
resolve({ mimeType: match[1], base64: match[2] });
};
reader.onerror = () => reject(new Error('无法读取图片'));
reader.readAsDataURL(file);
});
}
export function MindSpacePagePreviewPanel({
pageId,
title,
summary,
content,
reloadKey,
onContentChange,
}: {
pageId: string;
title: string;
summary: string;
content: string;
reloadKey: number;
onContentChange: (html: string) => void;
}) {
const [previewFailed, setPreviewFailed] = useState(false);
const [thumbnailFailed, setThumbnailFailed] = useState(false);
const [thumbnailVersion, setThumbnailVersion] = useState(reloadKey);
const [thumbBusy, setThumbBusy] = useState<'upload' | 'ai' | null>(null);
const [thumbNotice, setThumbNotice] = useState<string | null>(null);
const [thumbError, setThumbError] = useState<string | null>(null);
const [srcdoc, setSrcdoc] = useState(() => buildEditablePreviewDocument(content));
const iframeContentRef = useRef(content);
const iframeRef = useRef<HTMLIFrameElement>(null);
const uploadInputRef = useRef<HTMLInputElement>(null);
const thumbnailUrl = `/api/mindspace/v1/pages/${pageId}/thumbnail?v=${thumbnailVersion}`;
useEffect(() => {
setThumbnailVersion(reloadKey);
}, [reloadKey]);
useEffect(() => {
setThumbnailFailed(false);
}, [thumbnailUrl]);
useEffect(() => {
iframeContentRef.current = content;
setSrcdoc(buildEditablePreviewDocument(content));
setPreviewFailed(false);
}, [reloadKey]);
useEffect(() => {
if (content === iframeContentRef.current) return;
iframeContentRef.current = content;
setSrcdoc(buildEditablePreviewDocument(content));
setPreviewFailed(false);
}, [content]);
useEffect(() => {
const onMessage = (event: MessageEvent) => {
if (event.source !== iframeRef.current?.contentWindow) return;
if (event.data?.type !== MINDSPACE_PAGE_CONTENT_MESSAGE) return;
const html = String(event.data.html ?? '');
if (!html.trim()) return;
iframeContentRef.current = html;
onContentChange(html);
};
window.addEventListener('message', onMessage);
return () => window.removeEventListener('message', onMessage);
}, [onContentChange]);
const bumpThumbnail = () => {
setThumbnailVersion(Date.now());
setThumbnailFailed(false);
};
const handleUploadClick = () => {
uploadInputRef.current?.click();
};
const handleUploadFile = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = '';
if (!file) return;
if (!file.type.startsWith('image/')) {
setThumbError('请选择图片文件');
return;
}
setThumbBusy('upload');
setThumbError(null);
setThumbNotice(null);
try {
const { base64, mimeType } = await readFileAsBase64(file);
await uploadMindSpacePageThumbnail(pageId, {
imageBase64: base64,
mimeType,
title,
summary,
content,
});
bumpThumbnail();
setThumbNotice('封面已替换,保存页面后仍会保留');
} catch (err) {
setThumbError(err instanceof Error ? err.message : '上传封面失败');
} finally {
setThumbBusy(null);
}
};
const handleAiRegenerate = async () => {
setThumbBusy('ai');
setThumbError(null);
setThumbNotice(null);
try {
const result = await regenerateMindSpacePageThumbnail(pageId, {
title,
summary,
content,
useAi: true,
instruction: '根据当前页面内容与标题,生成更精美、可在信息流中直接展示的中文封面。',
});
if (result.content) {
iframeContentRef.current = result.content;
onContentChange(result.content);
}
bumpThumbnail();
setThumbNotice('AI 已重新生成封面');
} catch (err) {
setThumbError(err instanceof Error ? err.message : 'AI 封面生成失败');
} finally {
setThumbBusy(null);
}
};
return (
<div className="page-save-preview-dual mindspace-page-preview-dual">
<div className="page-save-preview-pane page-save-preview-pane-page">
<span className="page-save-preview-label"></span>
<p className="page-save-preview-hint">
· ·
</p>
<div className="page-save-mini-page mindspace-page-mini-page">
{!previewFailed ? (
<iframe
ref={iframeRef}
title="页面预览"
srcDoc={srcdoc}
className="page-save-mini-page-frame"
sandbox="allow-same-origin allow-scripts"
scrolling="yes"
onError={() => setPreviewFailed(true)}
/>
) : (
<div className="page-save-card-thumb-placeholder"></div>
)}
</div>
</div>
<div className="page-save-preview-pane page-save-preview-pane-thumb">
<span className="page-save-preview-label"></span>
<p className="page-save-preview-hint"> 3:4 </p>
<div className="page-save-card-thumb">
{!thumbnailFailed ? (
<img
src={thumbnailUrl}
alt={`${title || '页面'} 卡片预览图`}
onError={() => setThumbnailFailed(true)}
/>
) : (
<span className="page-save-card-thumb-placeholder"></span>
)}
</div>
<div className="mindspace-page-thumb-actions">
<input
ref={uploadInputRef}
type="file"
accept="image/*"
hidden
onChange={(event) => void handleUploadFile(event)}
/>
<button
type="button"
onClick={handleUploadClick}
disabled={thumbBusy !== null}
>
{thumbBusy === 'upload' ? '上传中…' : '上传替换'}
</button>
<button
type="button"
onClick={() => void handleAiRegenerate()}
disabled={thumbBusy !== null}
>
{thumbBusy === 'ai' ? '生成中…' : 'AI 重新生成'}
</button>
</div>
{thumbNotice ? <p className="mindspace-page-thumb-note">{thumbNotice}</p> : null}
{thumbError ? <p className="mindspace-page-thumb-error">{thumbError}</p> : null}
</div>
</div>
);
}
+43
View File
@@ -0,0 +1,43 @@
import { useState } from 'react';
import { useChat } from '../context/ChatProvider';
import type { MindSpaceChatContext, MindSpaceSaveCategory, PortalUser } from '../types';
import { SpaceChatFab } from './SpaceChatFab';
import { SpaceChatPanel } from './SpaceChatPanel';
export function MindSpaceSpaceChat({
context,
user,
onOpenFullChat,
onPageSaved,
}: {
context: MindSpaceChatContext;
user: PortalUser;
onOpenFullChat: () => void;
onPageSaved?: (result: {
kind: 'page' | 'category';
pageId?: string;
categoryCode?: MindSpaceSaveCategory;
}) => void;
}) {
const { chatState, pendingTool } = useChat();
const [open, setOpen] = useState(false);
return (
<>
<SpaceChatFab
open={open}
streaming={chatState === 'streaming'}
pendingTool={Boolean(pendingTool)}
onClick={() => setOpen((current) => !current)}
/>
<SpaceChatPanel
open={open}
context={context}
user={user}
onClose={() => setOpen(false)}
onOpenFullChat={onOpenFullChat}
onPageSaved={onPageSaved}
/>
</>
);
}
File diff suppressed because it is too large Load Diff
+66
View File
@@ -0,0 +1,66 @@
import { useCallback, useEffect, useRef, useState } from 'react';
export function PagePreviewFrame({
src,
title,
reloadKey = 0,
minHeight = 680,
maxHeight = 4800,
className = 'mindspace-page-preview-frame',
}: {
src: string;
title: string;
reloadKey?: number;
minHeight?: number;
maxHeight?: number;
className?: string;
}) {
const iframeRef = useRef<HTMLIFrameElement>(null);
const [height, setHeight] = useState(minHeight);
const syncHeight = useCallback(() => {
const iframe = iframeRef.current;
if (!iframe) return;
try {
const doc = iframe.contentDocument;
if (!doc) return;
const body = doc.body;
const root = doc.documentElement;
const next = Math.ceil(
Math.max(
body?.scrollHeight ?? 0,
body?.offsetHeight ?? 0,
root?.scrollHeight ?? 0,
root?.offsetHeight ?? 0,
minHeight,
),
);
setHeight(Math.min(next, maxHeight));
} catch {
setHeight(minHeight);
}
}, [maxHeight, minHeight]);
useEffect(() => {
setHeight(minHeight);
}, [src, reloadKey, minHeight]);
useEffect(() => {
const iframe = iframeRef.current;
if (!iframe) return;
iframe.addEventListener('load', syncHeight);
return () => iframe.removeEventListener('load', syncHeight);
}, [src, reloadKey, syncHeight]);
return (
<iframe
ref={iframeRef}
key={`${src}:${reloadKey}`}
title={title}
src={src}
sandbox="allow-same-origin"
className={className}
style={{ height: `${height}px` }}
/>
);
}
+389
View File
@@ -0,0 +1,389 @@
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { analyzeChatSave, saveChatMessageAsPage } from '../api/client';
import type { ChatSaveAnalysis, MindSpaceSaveCategory } from '../types';
import { PageSavePreviewPanel } from './PageSavePreviewPanel';
const CATEGORY_OPTIONS: Array<{
code: MindSpaceSaveCategory;
label: string;
hint: string;
}> = [
{
code: 'draft',
label: '页面草稿',
hint: '继续编辑后再决定是否发布',
},
{
code: 'oa',
label: 'OA 工作区',
hint: '与上传文档并列的资料卡片',
},
{
code: 'private',
label: '私人区',
hint: '默认不可公开,发布前需检查',
},
{
code: 'public',
label: '公开区',
hint: '可公开候选,仍需走发布流程',
},
];
const CATEGORY_LABELS: Record<MindSpaceSaveCategory, string> = {
draft: '页面草稿',
oa: 'OA 工作区',
private: '私人区',
public: '公开区',
};
export type PageSaveDialogProps = {
sessionId: string;
messageId: string;
compact?: boolean;
onClose: () => void;
onSaved: (result: {
kind: 'page' | 'asset';
categoryCode: MindSpaceSaveCategory;
pageId?: string;
}) => void;
};
export function PageSaveDialog({
sessionId,
messageId,
compact = false,
onClose,
onSaved,
}: PageSaveDialogProps) {
const [analysis, setAnalysis] = useState<ChatSaveAnalysis | null>(null);
const [analysisLoading, setAnalysisLoading] = useState(true);
const [analysisError, setAnalysisError] = useState<string | null>(null);
const [selectedLinkIndex, setSelectedLinkIndex] = useState(0);
const [title, setTitle] = useState('');
const [summary, setSummary] = useState('');
const [templateId, setTemplateId] = useState('editorial');
const [categoryCode, setCategoryCode] = useState<MindSpaceSaveCategory>('draft');
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [saveNotice, setSaveNotice] = useState<string | null>(null);
const [privateAcknowledged, setPrivateAcknowledged] = useState(false);
const [thumbnailVersion, setThumbnailVersion] = useState(0);
const [previewScrollLock, setPreviewScrollLock] = useState(false);
const titleRef = useRef('');
const summaryRef = useRef('');
const loadAnalysis = async (
linkIndex: number,
options: { updateForm?: boolean; previewTitle?: string; previewSummary?: string } = {},
) => {
const updateForm = options.updateForm ?? true;
if (updateForm) {
setAnalysisLoading(true);
setAnalysisError(null);
}
try {
const result = await analyzeChatSave({
sessionId,
messageId,
selectedLinkIndex: linkIndex,
previewTitle: options.previewTitle,
previewSummary: options.previewSummary,
});
setAnalysis(result);
if (updateForm) {
setTitle(result.suggestedTitle);
setSummary(result.suggestedSummary);
titleRef.current = result.suggestedTitle;
summaryRef.current = result.suggestedSummary;
setSelectedLinkIndex(result.selectedLinkIndex >= 0 ? result.selectedLinkIndex : linkIndex);
}
if (result.thumbnailUrl) {
setThumbnailVersion(Date.now());
}
} catch (err) {
if (updateForm) {
setAnalysisError(err instanceof Error ? err.message : '无法分析消息内容');
}
} finally {
if (updateForm) {
setAnalysisLoading(false);
}
}
};
useEffect(() => {
void loadAnalysis(0);
}, [sessionId, messageId]);
useEffect(() => {
titleRef.current = title;
}, [title]);
useEffect(() => {
summaryRef.current = summary;
}, [summary]);
useEffect(() => {
if (!analysis || analysis.contentMode !== 'static_html') return undefined;
const timer = window.setTimeout(() => {
setThumbnailVersion(Date.now());
}, 500);
return () => window.clearTimeout(timer);
}, [title, summary, analysis?.contentMode]);
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && !saving) onClose();
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [onClose, saving]);
useEffect(() => {
setPrivateAcknowledged(false);
}, [categoryCode, analysis?.privacyScan?.findings.length]);
const isStaticHtml = analysis?.contentMode === 'static_html';
const privacyFindings = analysis?.privacyScan.findings ?? [];
const privateBlocked = categoryCode === 'private' && analysis?.privacyScan.allowed === false;
const privateNeedsAck =
categoryCode === 'private' && privacyFindings.length > 0 && analysis?.privacyScan.allowed;
const save = async () => {
if (!title.trim()) return;
setSaving(true);
setSaveError(null);
try {
const result = await saveChatMessageAsPage({
sessionId,
messageId,
title: title.trim(),
summary: summary.trim(),
templateId: isStaticHtml ? 'static-html' : templateId,
categoryCode,
selectedLinkIndex,
acknowledgedFindingIds:
categoryCode === 'private' && privateNeedsAck && privateAcknowledged
? privacyFindings.map((finding) => finding.id)
: undefined,
});
if (result.kind === 'page') {
setSaveNotice(`已保存到${CATEGORY_LABELS[categoryCode]}`);
onSaved({ kind: 'page', categoryCode: 'draft', pageId: result.page.id });
return;
}
setSaveNotice(`已保存到${CATEGORY_LABELS[result.categoryCode]}`);
onSaved({ kind: 'asset', categoryCode: result.categoryCode });
} catch (err) {
setSaveError(err instanceof Error ? err.message : '保存失败');
} finally {
setSaving(false);
}
};
return createPortal(
<div
className={`page-save-backdrop${previewScrollLock ? ' is-preview-scroll-locked' : ''}`}
role="presentation"
onClick={saving ? undefined : onClose}
>
<section
className={`page-save-panel${compact ? ' page-save-panel-compact' : ' page-save-panel-wide'}`}
role="dialog"
aria-modal="true"
onClick={(event) => event.stopPropagation()}
>
<div className={`page-save-scroll${previewScrollLock ? ' is-preview-scroll-locked' : ''}`}>
<p className="mindspace-eyebrow">SAVE TO MINDSPACE</p>
<h2>{isStaticHtml ? '保存生成的页面' : '保存 AI 回答为文章'}</h2>
{analysisLoading && <div className="page-save-state"></div>}
{analysisError && <div className="page-save-error">{analysisError}</div>}
{!analysisLoading && analysis && (
<div className="page-save-layout">
<div className="page-save-form">
{isStaticHtml && analysis && (
<div className="page-save-preview-block">
<PageSavePreviewPanel
sessionId={sessionId}
messageId={messageId}
selectedLinkIndex={selectedLinkIndex}
title={title}
summary={summary}
analysis={analysis}
thumbnailVersion={thumbnailVersion}
onPreviewHoverChange={setPreviewScrollLock}
/>
{analysis.links.length > 1 && (
<label>
<select
value={selectedLinkIndex}
onChange={(event) => {
const nextIndex = Number(event.target.value);
setSelectedLinkIndex(nextIndex);
void loadAnalysis(nextIndex);
}}
>
{analysis.links.map((link, index) => (
<option value={index} key={link.publicUrl}>
{link.filename}
</option>
))}
</select>
</label>
)}
<p className="page-save-source">
{analysis.filename ?? analysis.relativePath}
{analysis.previewUrl ? ` · 公网链接仅作参考` : ''}
</p>
</div>
)}
<label>
<input
value={title}
maxLength={255}
onChange={(event) => setTitle(event.target.value)}
/>
</label>
<label>
<textarea
value={summary}
maxLength={1000}
rows={3}
onChange={(event) => setSummary(event.target.value)}
/>
</label>
{!isStaticHtml && (
<label>
<select value={templateId} onChange={(event) => setTemplateId(event.target.value)}>
<option value="editorial"></option>
<option value="report"></option>
<option value="knowledge-card"></option>
<option value="profile"></option>
</select>
</label>
)}
<fieldset className="page-save-targets">
<legend></legend>
{CATEGORY_OPTIONS.map((option) => {
const disabledPrivate =
option.code === 'private' && analysis.privacyScan.allowed === false;
return (
<label
className={`page-save-target${disabledPrivate ? ' is-disabled' : ''}`}
key={option.code}
>
<input
type="radio"
name="save-target"
value={option.code}
checked={categoryCode === option.code}
disabled={disabledPrivate}
onChange={() => setCategoryCode(option.code)}
/>
<span>
<strong>{option.label}</strong>
<small>
{disabledPrivate
? '内容含阻断级敏感信息,不能保存到私人区'
: option.hint}
</small>
</span>
</label>
);
})}
</fieldset>
{categoryCode === 'private' && (
<div className="page-save-private-panel">
<strong></strong>
<p></p>
{privacyFindings.length > 0 && (
<ul className="page-save-private-findings">
{privacyFindings.map((finding) => (
<li key={finding.id}>
{finding.blocking ? '阻断' : '提醒'} · {finding.label ?? finding.type}
{finding.occurrenceCount} {finding.sampleMasked}
</li>
))}
</ul>
)}
{privateNeedsAck && (
<label className="page-save-private-ack">
<input
type="checkbox"
checked={privateAcknowledged}
onChange={(event) => setPrivateAcknowledged(event.target.checked)}
/>
<span></span>
</label>
)}
</div>
)}
</div>
{!compact && (
<aside className="page-save-aside">
<p className="mindspace-eyebrow">WHERE IT GOES</p>
<h3></h3>
<div className="page-save-destination-preview">
<span>{CATEGORY_LABELS[categoryCode]}</span>
<div className="page-save-destination-grid">
<div className="page-save-destination-card is-muted">📄 </div>
<div
className={`page-save-destination-card${categoryCode === 'draft' || isStaticHtml ? ' is-active' : ''}`}
>
{isStaticHtml ? '🌐 页面' : '📝 文章'}
</div>
<div className="page-save-destination-card is-muted">📊 </div>
</div>
<p>
{categoryCode === 'draft'
? '进入草稿编辑工作台,可预览真实页面并决定是否发布。'
: '以资料卡片形式保存在该分区,与上传文件并列展示。'}
</p>
</div>
</aside>
)}
</div>
)}
{saveNotice && <div className="page-save-notice">{saveNotice}</div>}
{saveError && <div className="page-save-error">{saveError}</div>}
</div>
<div className="page-save-actions">
<button type="button" onClick={onClose} disabled={saving}>
</button>
<button
type="button"
className="page-save-primary"
onClick={() => void save()}
disabled={
saving ||
analysisLoading ||
!title.trim() ||
Boolean(analysisError) ||
privateBlocked ||
(privateNeedsAck && !privateAcknowledged)
}
>
{saving ? '正在保存…' : categoryCode === 'draft' ? '保存并进入编辑' : '保存到空间'}
</button>
</div>
</section>
</div>,
document.body,
);
}
+143
View File
@@ -0,0 +1,143 @@
import { useEffect, useState } from 'react';
import { buildChatSaveThumbnailUrl, fetchPreviewAsset } from '../api/client';
import type { ChatSaveAnalysis } from '../types';
export function PageSavePreviewPanel({
sessionId,
messageId,
selectedLinkIndex,
title,
summary,
analysis,
thumbnailVersion,
onPreviewHoverChange,
}: {
sessionId: string;
messageId: string;
selectedLinkIndex: number;
title: string;
summary: string;
analysis: ChatSaveAnalysis;
thumbnailVersion: number;
onPreviewHoverChange?: (locked: boolean) => void;
}) {
const [thumbnailSrc, setThumbnailSrc] = useState<string | null>(null);
const [thumbnailFailed, setThumbnailFailed] = useState(false);
const [previewFailed, setPreviewFailed] = useState(false);
const pagePreviewUrl = analysis.localPreviewUrl ?? analysis.previewFrameUrl;
useEffect(() => {
setPreviewFailed(false);
}, [pagePreviewUrl]);
useEffect(() => {
if (!analysis.hasHtmlContent) {
setThumbnailSrc(null);
setThumbnailFailed(false);
return undefined;
}
let objectUrl: string | null = null;
let cancelled = false;
const loadThumbnail = async () => {
setThumbnailFailed(false);
const apiUrl = buildChatSaveThumbnailUrl({
sessionId,
messageId,
selectedLinkIndex,
previewTitle: title,
previewSummary: summary,
cacheKey: thumbnailVersion,
});
try {
objectUrl = await fetchPreviewAsset(apiUrl);
if (!cancelled) setThumbnailSrc(objectUrl);
return;
} catch {
if (cancelled) return;
if (analysis.localThumbnailUrl) {
setThumbnailSrc(`${analysis.localThumbnailUrl}?v=${thumbnailVersion}`);
return;
}
setThumbnailSrc(null);
setThumbnailFailed(true);
}
};
void loadThumbnail();
return () => {
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [
analysis.hasHtmlContent,
analysis.localThumbnailUrl,
messageId,
selectedLinkIndex,
sessionId,
summary,
thumbnailVersion,
title,
]);
if (!analysis.hasHtmlContent) {
return (
<div className="page-save-error">
Agent HTML MindSpace
</div>
);
}
if (!pagePreviewUrl) {
return <div className="page-save-error"></div>;
}
return (
<div className="page-save-preview-dual">
<div
className="page-save-preview-pane page-save-preview-pane-page"
onMouseEnter={() => onPreviewHoverChange?.(true)}
onMouseLeave={() => onPreviewHoverChange?.(false)}
onWheelCapture={(event) => event.stopPropagation()}
>
<span className="page-save-preview-label"></span>
<p className="page-save-preview-hint"></p>
<div className="page-save-mini-page">
{!previewFailed ? (
<iframe
title="页面预览"
src={pagePreviewUrl}
className="page-save-mini-page-frame"
sandbox="allow-same-origin allow-scripts"
scrolling="yes"
onError={() => setPreviewFailed(true)}
/>
) : (
<div className="page-save-card-thumb-placeholder"></div>
)}
</div>
</div>
<div className="page-save-preview-pane page-save-preview-pane-thumb">
<span className="page-save-preview-label"></span>
<p className="page-save-preview-hint"> 3:4 </p>
<div className="page-save-card-thumb">
{thumbnailSrc && !thumbnailFailed ? (
<img
src={thumbnailSrc}
alt={`${title || '页面'} 卡片预览图`}
onError={() => setThumbnailFailed(true)}
/>
) : (
<span className="page-save-card-thumb-placeholder">
{thumbnailFailed ? '预览图生成失败' : '正在生成预览图…'}
</span>
)}
</div>
</div>
</div>
);
}
+280
View File
@@ -0,0 +1,280 @@
import { useCallback, useEffect, useState } from 'react';
import {
clearUserPolicyOverrides,
getRolePolicies,
getUserPolicies,
listPolicyCatalog,
updateRolePolicies,
updateUserPolicies,
} from '../api/client';
import type { AdminUserRow, PolicyDefinition, PolicyMap } from '../types';
const RISK_LABEL: Record<string, string> = {
low: '低',
medium: '中',
high: '高',
};
function PolicyGrid({
catalog,
values,
disabled,
onChange,
}: {
catalog: PolicyDefinition[];
values: PolicyMap;
disabled?: boolean;
onChange: (key: string, value: PolicyMap[string]) => void;
}) {
const grouped = catalog.reduce<Record<string, PolicyDefinition[]>>((acc, item) => {
acc[item.category] ??= [];
acc[item.category].push(item);
return acc;
}, {});
return (
<div className="capability-grid">
{Object.entries(grouped).map(([category, items]) => (
<div key={category} className="capability-group">
<h3>{category}</h3>
<ul>
{items.map((item) => (
<li key={item.key}>
<label>
{item.type === 'boolean' ? (
<input
type="checkbox"
checked={Boolean(values[item.key])}
disabled={disabled}
onChange={(e) => onChange(item.key, e.target.checked)}
/>
) : (
<select
value={String(values[item.key] ?? item.defaultValue)}
disabled={disabled}
onChange={(e) => onChange(item.key, e.target.value)}
>
{(item.options ?? []).map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
)}
<span className="capability-label">
{item.label}
<span className={`risk-pill risk-${item.risk}`}>
{RISK_LABEL[item.risk] ?? item.risk}
</span>
</span>
<span className="muted capability-desc">{item.description}</span>
</label>
</li>
))}
</ul>
</div>
))}
</div>
);
}
export function PolicySettings({
users,
userId,
userOnly = false,
}: {
users: AdminUserRow[];
userId?: string;
userOnly?: boolean;
}) {
const [catalog, setCatalog] = useState<PolicyDefinition[]>([]);
const [rolePolicies, setRolePolicies] = useState<PolicyMap>({});
const [selectedUserId, setSelectedUserId] = useState(userId ?? '');
const [userPolicies, setUserPolicies] = useState<PolicyMap>({});
const [userOverrides, setUserOverrides] = useState<PolicyMap>({});
const [userUnrestricted, setUserUnrestricted] = useState(false);
const [loading, setLoading] = useState(true);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [catalogItems, roleState] = await Promise.all([
listPolicyCatalog(),
getRolePolicies('user'),
]);
setCatalog(catalogItems);
setRolePolicies(roleState.policies);
} catch (err) {
setError(err instanceof Error ? err.message : '加载安全策略失败');
} finally {
setLoading(false);
}
}, []);
const loadUserPolicies = useCallback(async (userId: string) => {
if (!userId) {
setUserPolicies({});
setUserOverrides({});
setUserUnrestricted(false);
return;
}
try {
const state = await getUserPolicies(userId);
setUserPolicies(state.policies);
setUserOverrides(state.overrides);
setUserUnrestricted(state.unrestricted);
} catch (err) {
setError(err instanceof Error ? err.message : '加载用户策略失败');
}
}, []);
useEffect(() => {
void load();
}, [load]);
useEffect(() => {
void loadUserPolicies(selectedUserId);
}, [loadUserPolicies, selectedUserId]);
useEffect(() => {
if (userId) {
setSelectedUserId(userId);
}
}, [userId]);
const saveRoleDefaults = async () => {
setMessage(null);
setError(null);
try {
const result = await updateRolePolicies('user', rolePolicies);
setRolePolicies(result.policies);
setMessage('普通用户默认安全策略已保存');
} catch (err) {
setError(err instanceof Error ? err.message : '保存失败');
}
};
const saveUserOverrides = async () => {
if (!selectedUserId) return;
setMessage(null);
setError(null);
try {
const result = await updateUserPolicies(selectedUserId, userPolicies);
setUserPolicies(result.policies);
await loadUserPolicies(selectedUserId);
setMessage('用户单独策略已保存');
} catch (err) {
setError(err instanceof Error ? err.message : '保存失败');
}
};
const resetUserOverrides = async () => {
if (!selectedUserId) return;
setMessage(null);
setError(null);
try {
await clearUserPolicyOverrides(selectedUserId);
await loadUserPolicies(selectedUserId);
setMessage('已恢复为角色默认策略');
} catch (err) {
setError(err instanceof Error ? err.message : '重置失败');
}
};
const regularUsers = users.filter((user) => user.role === 'user');
return (
<section className="admin-card">
<div className="admin-card-head">
<h2></h2>
<button type="button" className="ghost-btn" onClick={() => void load()}>
</button>
</div>
<p className="muted capability-intro">
TKMind API
</p>
{error && <p className="banner banner-error">{error}</p>}
{message && <p className="banner banner-info">{message}</p>}
{loading ? (
<p className="muted"></p>
) : (
<>
{!userOnly && (
<>
<h3 className="capability-section-title"></h3>
<PolicyGrid
catalog={catalog}
values={rolePolicies}
onChange={(key, value) => setRolePolicies((prev) => ({ ...prev, [key]: value }))}
/>
<div className="capability-actions">
<button type="button" className="send-btn" onClick={() => void saveRoleDefaults()}>
</button>
</div>
</>
)}
{!userOnly && <h3 className="capability-section-title"></h3>}
{!userOnly && (
<div className="admin-form capability-user-picker">
<select
value={selectedUserId}
onChange={(e) => setSelectedUserId(e.target.value)}
>
<option value=""></option>
{regularUsers.map((user) => (
<option key={user.id} value={user.id}>
{user.displayName} (@{user.username})
</option>
))}
</select>
</div>
)}
{selectedUserId && (
<>
{userUnrestricted ? (
<p className="muted"></p>
) : (
<>
{Object.keys(userOverrides).length > 0 && (
<p className="muted">
{Object.keys(userOverrides).length}
</p>
)}
<PolicyGrid
catalog={catalog}
values={userPolicies}
onChange={(key, value) =>
setUserPolicies((prev) => ({ ...prev, [key]: value }))
}
/>
<div className="capability-actions">
<button
type="button"
className="send-btn"
onClick={() => void saveUserOverrides()}
>
</button>
<button
type="button"
className="ghost-btn"
onClick={() => void resetUserOverrides()}
>
</button>
</div>
</>
)}
</>
)}
</>
)}
</section>
);
}
+535
View File
@@ -0,0 +1,535 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
createLlmProviderKey,
deleteLlmProviderKey,
getLlmGlobalSettings,
listLlmProviderCatalog,
listLlmProviderKeys,
selectLlmProviderKey,
setLlmGlobalModel,
syncLlmProviderToGoosed,
testLlmProviderDraft,
testLlmProviderKey,
updateLlmProviderKey,
} from '../api/client';
import type {
LlmConnectionTestResult,
LlmGlobalSettings,
LlmProviderDefinition,
LlmProviderKeyRow,
} from '../types';
const CUSTOM_PROVIDER_ID = '__custom__';
const emptyForm = {
providerId: CUSTOM_PROVIDER_ID,
name: 'Relay Buyer Ollama',
apiKey: 'UqyHPKSSEZq0-oPnl8sru-7hZcJ2anPUL1yAVk866Vo',
apiUrl: 'http://127.0.0.1:18300/relay/buyer/v1/chat/completions',
basePath: '',
modelsText: 'qwen2.5:3b',
defaultModel: 'qwen2.5:3b',
relayProvider: 'ollama',
engine: 'openai',
};
function parseModelsText(text: string) {
return [...new Set(text.split(/[\n,]/).map((item) => item.trim()).filter(Boolean))];
}
function TestResultBanner({ result }: { result: LlmConnectionTestResult | null }) {
if (!result) return null;
if (!result.ok) {
return <p className="banner banner-error">{result.message ?? '未知错误'}</p>;
}
return (
<p className="banner banner-info">
{result.latencyMs}ms {result.model}{result.reply}
</p>
);
}
export function ProviderKeySettings() {
const [catalog, setCatalog] = useState<LlmProviderDefinition[]>([]);
const [keys, setKeys] = useState<LlmProviderKeyRow[]>([]);
const [globalSettings, setGlobalSettings] = useState<LlmGlobalSettings | null>(null);
const [globalModelDraft, setGlobalModelDraft] = useState('');
const [form, setForm] = useState(emptyForm);
const [loading, setLoading] = useState(true);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [testingForm, setTestingForm] = useState(false);
const [testingGlobal, setTestingGlobal] = useState(false);
const [testingKeyId, setTestingKeyId] = useState<string | null>(null);
const [formTestResult, setFormTestResult] = useState<LlmConnectionTestResult | null>(null);
const [globalTestResult, setGlobalTestResult] = useState<LlmConnectionTestResult | null>(null);
const isCustom = form.providerId === CUSTOM_PROVIDER_ID;
const selectedCatalog = useMemo(
() => catalog.find((item) => item.id === form.providerId) ?? null,
[catalog, form.providerId],
);
const customModels = useMemo(() => parseModelsText(form.modelsText), [form.modelsText]);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [catalogItems, keyRows, globalState] = await Promise.all([
listLlmProviderCatalog(),
listLlmProviderKeys(),
getLlmGlobalSettings(),
]);
setCatalog(catalogItems);
setKeys(keyRows);
setGlobalSettings(globalState);
setGlobalModelDraft(globalState.globalModel ?? '');
} catch (err) {
setError(err instanceof Error ? err.message : '加载 LLM 配置失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
useEffect(() => {
if (isCustom) {
setForm((current) => ({
...current,
defaultModel: current.defaultModel || customModels[0] || '',
}));
return;
}
if (!selectedCatalog) return;
setForm((current) => ({
...current,
defaultModel: current.defaultModel || selectedCatalog.defaultModel,
}));
}, [customModels, isCustom, selectedCatalog]);
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
setMessage(null);
setError(null);
setFormTestResult(null);
try {
if (isCustom) {
await createLlmProviderKey({
providerId: CUSTOM_PROVIDER_ID,
name: form.name,
apiKey: form.apiKey,
apiUrl: form.apiUrl,
basePath: form.basePath || undefined,
models: customModels,
defaultModel: form.defaultModel || customModels[0],
engine: form.engine,
relayProvider: form.relayProvider || undefined,
});
} else {
await createLlmProviderKey({
providerId: form.providerId,
name: form.name,
apiKey: form.apiKey,
defaultModel: form.defaultModel || undefined,
});
}
setForm({ ...emptyForm, providerId: form.providerId });
setMessage('LLM 配置已保存到数据库');
await load();
} catch (err) {
setError(err instanceof Error ? err.message : '保存失败');
}
};
const handleTestForm = async () => {
setTestingForm(true);
setFormTestResult(null);
setError(null);
try {
const result = await testLlmProviderDraft({
providerId: CUSTOM_PROVIDER_ID,
name: form.name || 'test',
apiKey: form.apiKey,
apiUrl: form.apiUrl,
basePath: form.basePath || undefined,
models: customModels,
defaultModel: form.defaultModel || customModels[0],
testModel: form.defaultModel || customModels[0],
relayProvider: form.relayProvider || undefined,
});
setFormTestResult(result);
} catch (err) {
setFormTestResult({
ok: false,
message: err instanceof Error ? err.message : '联通测试失败',
});
} finally {
setTestingForm(false);
}
};
const handleApplyGlobalModel = async () => {
setMessage(null);
setError(null);
setGlobalTestResult(null);
try {
const result = await setLlmGlobalModel(globalModelDraft);
setGlobalSettings(result.global);
setMessage(`全局模型已设为 ${result.global.globalModel},并已同步到 TKMind Agent`);
await load();
} catch (err) {
setError(err instanceof Error ? err.message : '保存全局模型失败');
}
};
const handleTestGlobal = async () => {
if (!globalSettings?.keyId) {
setError('请先启用一个 LLM 配置');
return;
}
setTestingGlobal(true);
setGlobalTestResult(null);
setError(null);
try {
const result = await testLlmProviderKey(
globalSettings.keyId,
globalModelDraft || globalSettings.globalModel || undefined,
);
setGlobalTestResult(result);
} catch (err) {
setGlobalTestResult({
ok: false,
message: err instanceof Error ? err.message : '联通测试失败',
});
} finally {
setTestingGlobal(false);
}
};
const handleSelect = async (keyId: string) => {
setMessage(null);
setError(null);
try {
await selectLlmProviderKey(keyId);
setMessage('已切换当前 LLM 并同步到 TKMind Agent');
await load();
} catch (err) {
setError(err instanceof Error ? err.message : '切换失败');
}
};
const handleTestKey = async (row: LlmProviderKeyRow) => {
setTestingKeyId(row.id);
setError(null);
try {
const result = await testLlmProviderKey(row.id, row.defaultModel);
if (result.ok) {
setMessage(`${row.name}」联通成功(${result.latencyMs}ms):${result.reply}`);
} else {
setError(`${row.name}」联通失败:${result.message}`);
}
} catch (err) {
setError(err instanceof Error ? err.message : '联通测试失败');
} finally {
setTestingKeyId(null);
}
};
const handleToggleStatus = async (row: LlmProviderKeyRow) => {
setMessage(null);
setError(null);
try {
await updateLlmProviderKey(row.id, {
status: row.status === 'active' ? 'disabled' : 'active',
});
setMessage('状态已更新');
await load();
} catch (err) {
setError(err instanceof Error ? err.message : '更新失败');
}
};
const handleDelete = async (row: LlmProviderKeyRow) => {
if (!window.confirm(`确定删除配置「${row.name}」?`)) return;
setMessage(null);
setError(null);
try {
await deleteLlmProviderKey(row.id);
setMessage('配置已删除');
await load();
} catch (err) {
setError(err instanceof Error ? err.message : '删除失败');
}
};
const handleSync = async () => {
setMessage(null);
setError(null);
try {
const result = await syncLlmProviderToGoosed();
setMessage(result.synced ? '已重新同步到 TKMind Agent' : '当前没有启用的 LLM 配置');
} catch (err) {
setError(err instanceof Error ? err.message : '同步失败');
}
};
return (
<section className="admin-card">
<div className="admin-card-head">
<h2>LLM Key </h2>
<button type="button" className="ghost-btn" onClick={() => void load()}>
</button>
</div>
<p className="muted">
Relay Buyer Ollama
GOOSE_MODEL / TKMIND_MODEL使
</p>
<div className="admin-card global-model-card">
<div className="admin-card-head">
<h3></h3>
</div>
{loading ? (
<p className="muted"></p>
) : !globalSettings?.keyId ? (
<p className="muted"> LLM Relay </p>
) : (
<>
<p className="muted">
Provider<strong>{globalSettings.providerLabel}</strong>
{globalSettings.keyName}
</p>
<div className="admin-form global-model-form">
<select
value={globalModelDraft}
onChange={(e) => setGlobalModelDraft(e.target.value)}
>
{globalSettings.availableModels.map((model) => (
<option key={model} value={model}>
{model}
</option>
))}
</select>
<button type="button" className="send-btn" onClick={() => void handleApplyGlobalModel()}>
</button>
<button
type="button"
className="ghost-btn"
disabled={testingGlobal}
onClick={() => void handleTestGlobal()}
>
{testingGlobal ? '测试中…' : '联通测试'}
</button>
</div>
<TestResultBanner result={globalTestResult} />
</>
)}
</div>
<form className="admin-form" onSubmit={handleCreate}>
<select
value={form.providerId}
onChange={(e) =>
setForm((state) => ({
...state,
providerId: e.target.value,
defaultModel:
e.target.value === CUSTOM_PROVIDER_ID
? parseModelsText(state.modelsText)[0] ?? ''
: (catalog.find((item) => item.id === e.target.value)?.defaultModel ?? ''),
}))
}
>
{catalog.map((item) => (
<option key={item.id} value={item.id}>
{item.label}
</option>
))}
</select>
<input
placeholder="配置名称"
value={form.name}
onChange={(e) => setForm((state) => ({ ...state, name: e.target.value }))}
/>
{isCustom ? (
<>
<input
placeholder="API 地址"
value={form.apiUrl}
onChange={(e) => setForm((state) => ({ ...state, apiUrl: e.target.value }))}
/>
<textarea
placeholder="模型列表,每行一个"
value={form.modelsText}
rows={4}
onChange={(e) =>
setForm((state) => ({
...state,
modelsText: e.target.value,
defaultModel: parseModelsText(e.target.value)[0] ?? state.defaultModel,
}))
}
/>
<input
placeholder="Relay Provider(请求 body 中的 provider,如 ollama"
value={form.relayProvider}
onChange={(e) => setForm((state) => ({ ...state, relayProvider: e.target.value }))}
/>
</>
) : null}
<input
placeholder="API Key / Bearer Token"
type="password"
value={form.apiKey}
onChange={(e) => setForm((state) => ({ ...state, apiKey: e.target.value }))}
/>
{isCustom ? (
<select
value={form.defaultModel || customModels[0] || ''}
onChange={(e) => setForm((state) => ({ ...state, defaultModel: e.target.value }))}
>
{customModels.map((model) => (
<option key={model} value={model}>
{model}
</option>
))}
</select>
) : (
<select
value={form.defaultModel || selectedCatalog?.defaultModel || ''}
onChange={(e) => setForm((state) => ({ ...state, defaultModel: e.target.value }))}
>
{(selectedCatalog?.models ?? []).map((model) => (
<option key={model} value={model}>
{model}
</option>
))}
</select>
)}
<button type="submit" className="send-btn">
</button>
{isCustom && (
<button
type="button"
className="ghost-btn"
disabled={testingForm}
onClick={() => void handleTestForm()}
>
{testingForm ? '测试中…' : '联通测试'}
</button>
)}
</form>
<TestResultBanner result={formTestResult} />
{message && <p className="banner banner-info">{message}</p>}
{error && <p className="banner banner-error">{error}</p>}
<div className="admin-card-head">
<h3></h3>
<button type="button" className="ghost-btn" onClick={() => void handleSync()}>
Agent
</button>
</div>
{loading ? (
<p className="muted"></p>
) : keys.length === 0 ? (
<p className="muted"> Relay Buyer Ollama</p>
) : (
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<th></th>
<th>Endpoint</th>
<th></th>
<th>Key</th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{keys.map((row) => (
<tr key={row.id}>
<td>
{row.name}
{row.isSelected && <span className="risk-pill risk-low"></span>}
</td>
<td className="mono">
{row.providerKind === 'custom' ? (
<>
<div>{row.apiUrl}</div>
{row.relayProvider && (
<div className="muted">provider: {row.relayProvider}</div>
)}
</>
) : (
row.providerLabel
)}
</td>
<td className="mono">
<div>{row.defaultModel}</div>
{row.models.length > 1 && (
<div className="muted">+{row.models.length - 1} </div>
)}
</td>
<td className="mono">{row.apiKeyMasked}</td>
<td>{row.status === 'active' ? '可用' : '禁用'}</td>
<td className="admin-actions">
{row.providerKind === 'custom' && (
<button
type="button"
className="ghost-btn"
disabled={testingKeyId === row.id}
onClick={() => void handleTestKey(row)}
>
{testingKeyId === row.id ? '测试中…' : '测试'}
</button>
)}
{!row.isSelected && row.status === 'active' && (
<button
type="button"
className="ghost-btn"
onClick={() => void handleSelect(row.id)}
>
</button>
)}
<button
type="button"
className="ghost-btn"
disabled={row.isSelected}
onClick={() => void handleToggleStatus(row)}
>
{row.status === 'active' ? '禁用' : '恢复'}
</button>
<button
type="button"
className="ghost-btn"
disabled={row.isSelected}
onClick={() => void handleDelete(row)}
>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
);
}
+292
View File
@@ -0,0 +1,292 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import QRCode from 'qrcode';
import { createRechargeOrder, getBillingConfig, getRechargeOrder } from '../api/client';
import type { BillingConfig, RechargeOrder } from '../types';
function formatYuan(cents: number) {
return `¥${(cents / 100).toFixed(cents % 100 === 0 ? 0 : 2)}`;
}
function isWeChatBrowser() {
return /MicroMessenger/i.test(navigator.userAgent);
}
function detectPayScene(): 'native' | 'h5' {
// 微信内置浏览器走扫码:H5 收银台需商户单独开通,长按识别二维码即可支付
if (isWeChatBrowser()) return 'native';
return /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent) ? 'h5' : 'native';
}
async function createRechargeOrderWithFallback(amountCents: number): Promise<RechargeOrder> {
const payScene = detectPayScene();
try {
return await createRechargeOrder({ amountCents, payScene });
} catch (err) {
if (payScene !== 'h5') throw err;
return createRechargeOrder({ amountCents, payScene: 'native' });
}
}
function pickDefaultTier(tiers: number[], balanceCents: number) {
if (!tiers.length) return 1000;
if (balanceCents <= 0) {
return tiers.find((tier) => tier >= 1000) ?? tiers[0];
}
return tiers.find((tier) => tier >= 1000) ?? tiers[0];
}
type RechargeModalProps = {
open: boolean;
force?: boolean;
balanceCents: number;
onClose: () => void;
onSuccess: (balanceCents: number) => void;
};
export function RechargeModal({
open,
force = false,
balanceCents,
onClose,
onSuccess,
}: RechargeModalProps) {
const [config, setConfig] = useState<BillingConfig | null>(null);
const [loadingConfig, setLoadingConfig] = useState(false);
const [selectedCents, setSelectedCents] = useState(1000);
const [order, setOrder] = useState<RechargeOrder | null>(null);
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const [paying, setPaying] = useState(false);
const [polling, setPolling] = useState(false);
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const pollRef = useRef<number | null>(null);
const redirectedRef = useRef(false);
const tiers = config?.tiersCents ?? [];
const projectedBalance = balanceCents + (order?.amountCents ?? selectedCents);
const resetPayState = useCallback(() => {
setOrder(null);
setQrDataUrl(null);
setPaying(false);
setPolling(false);
setError(null);
setSuccessMessage(null);
redirectedRef.current = false;
if (pollRef.current != null) {
window.clearInterval(pollRef.current);
pollRef.current = null;
}
}, []);
useEffect(() => {
if (!open) {
resetPayState();
return;
}
setLoadingConfig(true);
setError(null);
void getBillingConfig()
.then((loaded) => {
setConfig(loaded);
setSelectedCents(pickDefaultTier(loaded.tiersCents, loaded.balanceCents));
})
.catch((err) => {
setError(err instanceof Error ? err.message : '无法加载充值配置');
})
.finally(() => setLoadingConfig(false));
}, [open, resetPayState]);
const stopPolling = useCallback(() => {
if (pollRef.current != null) {
window.clearInterval(pollRef.current);
pollRef.current = null;
}
setPolling(false);
}, []);
const startPolling = useCallback(
(orderId: string) => {
stopPolling();
setPolling(true);
pollRef.current = window.setInterval(() => {
void getRechargeOrder(orderId)
.then((result) => {
if (result.order.status === 'paid') {
stopPolling();
const nextBalance = result.balanceCents ?? projectedBalance;
setSuccessMessage(`充值成功,当前余额 ${formatYuan(nextBalance)}`);
window.setTimeout(() => onSuccess(nextBalance), 900);
} else if (result.order.status === 'expired') {
stopPolling();
setError('订单已过期,请重新发起支付');
setOrder(null);
setQrDataUrl(null);
}
})
.catch(() => {});
}, 2000);
},
[onSuccess, projectedBalance, stopPolling],
);
useEffect(() => () => stopPolling(), [stopPolling]);
useEffect(() => {
if (!order?.codeUrl) {
setQrDataUrl(null);
return;
}
void QRCode.toDataURL(order.codeUrl, {
width: 220,
margin: 1,
color: { dark: '#18211d', light: '#ffffff' },
}).then(setQrDataUrl);
}, [order?.codeUrl]);
const handlePay = async () => {
if (!config?.wechatEnabled) {
setError('微信支付尚未配置,请联系管理员');
return;
}
setPaying(true);
setError(null);
setSuccessMessage(null);
try {
const created = await createRechargeOrderWithFallback(selectedCents);
setOrder(created);
if (created.payMode === 'h5' && created.h5Url && !redirectedRef.current) {
redirectedRef.current = true;
window.location.href = created.h5Url;
}
startPolling(created.id);
} catch (err) {
setError(err instanceof Error ? err.message : '创建支付订单失败');
} finally {
setPaying(false);
}
};
const expireLabel = useMemo(() => {
if (!order?.expireAt) return null;
return new Date(order.expireAt).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
});
}, [order?.expireAt]);
if (!open) return null;
return createPortal(
<div className="recharge-backdrop" role="presentation">
<div
className="recharge-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="recharge-title"
onClick={(event) => event.stopPropagation()}
>
<div className="recharge-dialog-bar">
<div>
<p className="recharge-eyebrow"></p>
<h3 id="recharge-title"></h3>
</div>
{!force && (
<button type="button" className="recharge-close" onClick={onClose} aria-label="关闭">
</button>
)}
</div>
<div className="recharge-balance-row">
<span></span>
<strong>{formatYuan(balanceCents)}</strong>
</div>
{loadingConfig ? (
<p className="recharge-muted"></p>
) : (
<>
<p className="recharge-muted">
{formatYuan(config?.minRechargeCents ?? 500)} AI
</p>
<div className="recharge-tier-grid">
{tiers.map((tier) => (
<button
key={tier}
type="button"
className={`recharge-tier${selectedCents === tier ? ' recharge-tier-active' : ''}`}
disabled={Boolean(order)}
onClick={() => setSelectedCents(tier)}
>
{formatYuan(tier)}
</button>
))}
</div>
<p className="recharge-projected">
<strong>{formatYuan(projectedBalance)}</strong>
</p>
</>
)}
{error && <p className="recharge-error">{error}</p>}
{successMessage && <p className="recharge-success">{successMessage}</p>}
{order?.payMode === 'native' && qrDataUrl && (
<div className="recharge-qr-panel">
<img src={qrDataUrl} alt="微信支付二维码" className="recharge-qr" />
<p className="recharge-muted">
{isWeChatBrowser()
? '长按下方二维码,选择「识别图中二维码」完成支付'
: '请使用微信扫一扫完成支付'}
</p>
{expireLabel && <p className="recharge-muted"> {expireLabel} </p>}
</div>
)}
{order?.payMode === 'h5' && (
<div className="recharge-h5-panel">
<p className="recharge-muted"></p>
{order.h5Url && (
<a className="recharge-h5-link" href={order.h5Url}>
</a>
)}
</div>
)}
<div className="recharge-actions">
{!order ? (
<button
type="button"
className="recharge-pay-btn"
disabled={paying || loadingConfig || !config?.wechatEnabled}
onClick={() => void handlePay()}
>
{paying ? '创建订单中…' : '微信支付'}
</button>
) : (
<button
type="button"
className="recharge-secondary-btn"
disabled={polling}
onClick={() => {
resetPayState();
}}
>
</button>
)}
</div>
{!config?.wechatEnabled && !loadingConfig && (
<p className="recharge-warning"></p>
)}
<p className="recharge-legal"></p>
</div>
</div>,
document.body,
);
}
+108
View File
@@ -0,0 +1,108 @@
import { useState } from 'react';
import {
SHARE_CHANNELS,
canUseNativeShare,
copyShareText,
nativeShare,
type ShareChannel,
type SharePayload,
} from '../utils/shareChannels';
export function ShareSheet({
payload,
onClose,
}: {
payload: SharePayload;
onClose: () => void;
}) {
const [message, setMessage] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const handleChannel = async (channel: ShareChannel) => {
setBusy(true);
setMessage(null);
try {
const text = channel.buildText(payload);
await copyShareText(text);
setMessage(channel.hint);
} catch {
setMessage('复制失败,请手动复制链接');
} finally {
setBusy(false);
}
};
const handleNativeShare = async () => {
setBusy(true);
setMessage(null);
try {
await nativeShare(payload);
onClose();
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return;
setMessage('系统分享不可用,请选择其他平台');
} finally {
setBusy(false);
}
};
return (
<div className="share-sheet-backdrop" role="presentation" onClick={onClose}>
<section
className="share-sheet-panel"
role="dialog"
aria-modal="true"
aria-labelledby="share-sheet-title"
onClick={(event) => event.stopPropagation()}
>
<header className="share-sheet-header">
<div>
<p className="share-sheet-eyebrow">SHARE</p>
<h2 id="share-sheet-title"></h2>
<p className="share-sheet-subtitle">{payload.title}</p>
</div>
<button type="button" className="share-sheet-close" onClick={onClose} aria-label="关闭">
×
</button>
</header>
<div className="share-sheet-grid">
{SHARE_CHANNELS.map((channel) => (
<button
key={channel.id}
type="button"
className={`share-sheet-option share-sheet-option-${channel.id}`}
disabled={busy}
onClick={() => void handleChannel(channel)}
>
<span className="share-sheet-option-icon" aria-hidden="true">
{channel.label.slice(0, 1)}
</span>
<span className="share-sheet-option-label">{channel.label}</span>
</button>
))}
{canUseNativeShare() && (
<button
type="button"
className="share-sheet-option share-sheet-option-system"
disabled={busy}
onClick={() => void handleNativeShare()}
>
<span className="share-sheet-option-icon" aria-hidden="true">
···
</span>
<span className="share-sheet-option-label"></span>
</button>
)}
</div>
<div className="share-sheet-link">
<span className="muted"></span>
<code>{payload.url}</code>
</div>
{message && <p className="share-sheet-message">{message}</p>}
</section>
</div>
);
}
+236
View File
@@ -0,0 +1,236 @@
import { useCallback, useEffect, useState } from 'react';
import {
clearUserSkillOverrides,
getRoleSkills,
getUserSkills,
listSkillCatalog,
updateRoleSkills,
updateUserSkills,
} from '../api/client';
import type { AdminUserRow, SkillDefinition, SkillMap } from '../types';
function SkillGrid({
catalog,
values,
disabled,
onChange,
}: {
catalog: SkillDefinition[];
values: SkillMap;
disabled?: boolean;
onChange: (name: string, enabled: boolean) => void;
}) {
return (
<div className="capability-grid">
<div className="capability-group">
<h3></h3>
<ul>
{catalog.map((item) => (
<li key={item.name}>
<label>
<input
type="checkbox"
checked={Boolean(values[item.name])}
disabled={disabled}
onChange={(e) => onChange(item.name, e.target.checked)}
/>
<span className="capability-label">
{item.label}
<code className="skill-name-tag">{item.name}</code>
{item.requiresPublish && (
<span className="risk-pill risk-medium"></span>
)}
</span>
<span className="muted capability-desc">{item.description}</span>
</label>
</li>
))}
</ul>
</div>
</div>
);
}
export function SkillSettings({
users,
userId,
userOnly = false,
}: {
users: AdminUserRow[];
userId?: string;
userOnly?: boolean;
}) {
const [catalog, setCatalog] = useState<SkillDefinition[]>([]);
const [roleSkills, setRoleSkills] = useState<SkillMap>({});
const [selectedUserId, setSelectedUserId] = useState(userId ?? '');
const [userSkills, setUserSkills] = useState<SkillMap>({});
const [userOverrides, setUserOverrides] = useState<SkillMap>({});
const [loading, setLoading] = useState(true);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [catalogItems, roleState] = await Promise.all([
listSkillCatalog(),
getRoleSkills('user'),
]);
setCatalog(catalogItems);
setRoleSkills(roleState.skills);
} catch (err) {
setError(err instanceof Error ? err.message : '加载技能目录失败');
} finally {
setLoading(false);
}
}, []);
const loadUserSkills = useCallback(async (userId: string) => {
if (!userId) {
setUserSkills({});
setUserOverrides({});
return;
}
try {
const state = await getUserSkills(userId);
setUserSkills(state.skills);
setUserOverrides(state.overrides);
} catch (err) {
setError(err instanceof Error ? err.message : '加载用户技能失败');
}
}, []);
useEffect(() => {
void load();
}, [load]);
useEffect(() => {
void loadUserSkills(selectedUserId);
}, [loadUserSkills, selectedUserId]);
useEffect(() => {
if (userId) {
setSelectedUserId(userId);
}
}, [userId]);
const saveRoleDefaults = async () => {
setMessage(null);
setError(null);
try {
const result = await updateRoleSkills('user', roleSkills);
setRoleSkills(result.skills);
setMessage('普通用户默认技能已保存,并已同步到各用户工作区');
} catch (err) {
setError(err instanceof Error ? err.message : '保存失败');
}
};
const saveUserOverrides = async () => {
if (!selectedUserId) return;
setMessage(null);
setError(null);
try {
const result = await updateUserSkills(selectedUserId, userSkills);
setUserSkills(result.skills);
await loadUserSkills(selectedUserId);
setMessage('用户技能已保存,并已安装到其 MindSpace 发布目录');
} catch (err) {
setError(err instanceof Error ? err.message : '保存失败');
}
};
const resetUserOverrides = async () => {
if (!selectedUserId) return;
setMessage(null);
setError(null);
try {
await clearUserSkillOverrides(selectedUserId);
await loadUserSkills(selectedUserId);
setMessage('已恢复为角色默认技能');
} catch (err) {
setError(err instanceof Error ? err.message : '重置失败');
}
};
const regularUsers = users.filter((user) => user.role === 'user');
return (
<section className="admin-card">
<div className="admin-card-head">
<h2></h2>
<button type="button" className="ghost-btn" onClick={() => void load()}>
</button>
</div>
<p className="muted capability-intro">
<code>MindSpace//.agents/skills/</code> load_skill
使
</p>
{error && <p className="banner banner-error">{error}</p>}
{message && <p className="banner banner-info">{message}</p>}
{loading ? (
<p className="muted"></p>
) : catalog.length === 0 ? (
<p className="muted"> ui/h5/skills/ SKILL.md</p>
) : (
<>
{!userOnly && (
<>
<h3 className="capability-section-title"></h3>
<SkillGrid
catalog={catalog}
values={roleSkills}
onChange={(name, enabled) => setRoleSkills((prev) => ({ ...prev, [name]: enabled }))}
/>
<div className="capability-actions">
<button type="button" className="send-btn" onClick={() => void saveRoleDefaults()}>
</button>
</div>
</>
)}
{!userOnly && <h3 className="capability-section-title"></h3>}
{!userOnly && (
<div className="admin-form capability-user-picker">
<select value={selectedUserId} onChange={(e) => setSelectedUserId(e.target.value)}>
<option value=""></option>
{regularUsers.map((user) => (
<option key={user.id} value={user.id}>
{user.displayName} (@{user.username})
</option>
))}
</select>
</div>
)}
{selectedUserId && (
<>
{Object.keys(userOverrides).length > 0 && (
<p className="muted">
{Object.keys(userOverrides).length}
</p>
)}
<SkillGrid
catalog={catalog}
values={userSkills}
onChange={(name, enabled) =>
setUserSkills((prev) => ({ ...prev, [name]: enabled }))
}
/>
<div className="capability-actions">
<button type="button" className="send-btn" onClick={() => void saveUserOverrides()}>
</button>
<button type="button" className="ghost-btn" onClick={() => void resetUserOverrides()}>
</button>
</div>
</>
)}
</>
)}
</section>
);
}
+26
View File
@@ -0,0 +1,26 @@
import { TKMindAvatar } from './TKMindAvatar';
export function SpaceChatFab({
open,
streaming,
pendingTool,
onClick,
}: {
open: boolean;
streaming: boolean;
pendingTool: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
className={`space-chat-fab${open ? ' is-open' : ''}${streaming ? ' is-streaming' : ''}${pendingTool ? ' has-pending-tool' : ''}`}
aria-label={open ? '关闭 Agent 对话' : '打开 Agent 对话'}
aria-expanded={open}
onClick={onClick}
>
<TKMindAvatar size="sm" />
{pendingTool && <span className="space-chat-fab-badge" aria-hidden="true" />}
</button>
);
}
+97
View File
@@ -0,0 +1,97 @@
import { useEffect } from 'react';
import { useChat } from '../context/ChatProvider';
import type { MindSpaceChatContext, MindSpaceSaveCategory, PortalUser } from '../types';
import { formatContextChip } from '../utils/mindspaceChatContext';
import { ChatPanel } from './ChatPanel';
import { TKMindAvatar } from './TKMindAvatar';
export function SpaceChatPanel({
open,
context,
user,
onClose,
onOpenFullChat,
onPageSaved,
}: {
open: boolean;
context: MindSpaceChatContext;
user: PortalUser;
onClose: () => void;
onOpenFullChat: () => void;
onPageSaved?: (result: {
kind: 'page' | 'category';
pageId?: string;
categoryCode?: MindSpaceSaveCategory;
}) => void;
}) {
const {
session,
messages,
chatState,
pendingTool,
submit,
stop,
approveTool,
error,
retryConnect,
} = useChat();
useEffect(() => {
if (!open) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [open, onClose]);
if (!open) return null;
const contextLabel = formatContextChip(context);
return (
<div className="space-chat-panel" role="dialog" aria-modal="false" aria-label="Agent 对话">
<header className="space-chat-panel-header">
<div className="space-chat-panel-brand">
<TKMindAvatar size="sm" />
<div>
<strong>TKMind</strong>
<span className="space-chat-context-chip" title={context.route}>
{contextLabel}
</span>
</div>
</div>
<div className="space-chat-panel-actions">
<button type="button" className="ghost-btn" onClick={onOpenFullChat}>
</button>
<button type="button" className="space-chat-panel-close" onClick={onClose} aria-label="关闭">
×
</button>
</div>
</header>
{error && (
<div className="space-chat-panel-error">
<span>{error}</span>
<button type="button" onClick={() => void retryConnect()}>
</button>
</div>
)}
<ChatPanel
variant="compact"
user={user}
messages={messages}
chatState={chatState}
pendingTool={pendingTool}
session={session}
onSubmit={(text) => submit(text, { mindspaceContext: context })}
onStop={stop}
onApproveTool={approveTool}
onPageSaved={onPageSaved}
/>
</div>
);
}
+19
View File
@@ -0,0 +1,19 @@
import tkmindAvatar from '../assets/tkmind-avatar.png';
type TKMindAvatarProps = {
className?: string;
size?: 'sm' | 'md';
};
export function TKMindAvatar({ className = '', size = 'sm' }: TKMindAvatarProps) {
const sizeClass = size === 'md' ? 'tkmind-avatar-md' : 'tkmind-avatar-sm';
return (
<div
className={`tkmind-avatar ${sizeClass} msg-avatar msg-avatar-assistant ${className}`.trim()}
aria-hidden="true"
>
<img src={tkmindAvatar} alt="TKMind" className="tkmind-avatar-img" />
</div>
);
}
+56
View File
@@ -0,0 +1,56 @@
type UserAvatarProps = {
avatarUrl?: string | null;
size?: 'sm' | 'md';
className?: string;
onClick?: () => void;
title?: string;
};
function DefaultUserAvatar() {
return (
<svg viewBox="0 0 40 40" fill="none" aria-hidden="true">
<rect width="40" height="40" rx="6" fill="#c8c8c8" />
<circle cx="20" cy="15" r="6.5" fill="#f5f5f5" />
<path d="M9 31c2.5-5.5 7-8.5 11-8.5s8.5 3 11 8.5" fill="#f5f5f5" />
</svg>
);
}
export function UserAvatar({
avatarUrl,
size = 'sm',
className = '',
onClick,
title,
}: UserAvatarProps) {
const sizeClass = size === 'md' ? 'user-avatar-md' : 'user-avatar-sm';
const interactive = Boolean(onClick);
return (
<div
className={`user-avatar ${sizeClass} ${interactive ? 'user-avatar-interactive' : ''} ${className}`.trim()}
role={interactive ? 'button' : undefined}
tabIndex={interactive ? 0 : undefined}
aria-label={interactive ? title ?? '更换头像' : undefined}
title={title}
onClick={onClick}
onKeyDown={
interactive
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClick?.();
}
}
: undefined
}
>
{avatarUrl ? (
<img src={avatarUrl} alt="" className="user-avatar-img" />
) : (
<DefaultUserAvatar />
)}
{interactive && <span className="user-avatar-edit-badge" aria-hidden="true" />}
</div>
);
}
+66
View File
@@ -0,0 +1,66 @@
import { useEffect, useState } from 'react';
import { shouldShowVoiceInputControl, isVoiceInputAvailable } from '../voice/capabilities';
import { VoiceInputDialog } from './VoiceInputDialog';
function MicIcon() {
return (
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
<path
fill="currentColor"
d="M12 14a3 3 0 0 0 3-3V6a3 3 0 1 0-6 0v5a3 3 0 0 0 3 3Zm5-3a1 1 0 1 0-2 0 5 5 0 0 1-10 0 1 1 0 1 0-2 0 7 7 0 0 0 6 6.92V19H9a1 1 0 1 0 0 2h6a1 1 0 1 0 0-2h-2v-1.08A7 7 0 0 0 17 11Z"
/>
</svg>
);
}
export function VoiceInputButton({
disabled = false,
onSend,
onError,
}: {
disabled?: boolean;
onSend: (text: string) => void;
onError?: (message: string) => void;
}) {
const [open, setOpen] = useState(false);
const [sessionKey, setSessionKey] = useState(0);
const [available, setAvailable] = useState(true);
useEffect(() => {
setAvailable(isVoiceInputAvailable());
}, []);
if (!shouldShowVoiceInputControl()) return null;
const blocked = disabled || !available;
return (
<>
<button
type="button"
className={`voice-btn${open ? ' voice-btn-recording' : ''}`}
disabled={blocked}
aria-label="语音输入"
title={available ? '语音输入' : '当前浏览器不支持麦克风,请改用文字输入'}
onClick={() => {
if (blocked) {
if (!available) onError?.('当前浏览器不支持语音输入');
return;
}
setSessionKey((value) => value + 1);
setOpen(true);
}}
>
<MicIcon />
</button>
<VoiceInputDialog
key={sessionKey}
open={open}
disabled={disabled}
onClose={() => setOpen(false)}
onSend={onSend}
onError={onError}
/>
</>
);
}
+145
View File
@@ -0,0 +1,145 @@
import { useCallback, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { useVoiceSession } from '../hooks/useVoiceSession';
import { isVoiceInputAvailable } from '../voice/capabilities';
import { VoiceWaveform } from './VoiceWaveform';
export function VoiceInputDialog({
open,
disabled = false,
onClose,
onSend,
onError,
}: {
open: boolean;
disabled?: boolean;
onClose: () => void;
onSend: (text: string) => void;
onError?: (message: string) => void;
}) {
const { phase, text, analyser, liveRecognition, updateText, stopListening, finishFallbackRecording } =
useVoiceSession({
active: open && !disabled,
onError,
});
const handleClose = useCallback(() => {
stopListening();
onClose();
}, [onClose, stopListening]);
useEffect(() => {
if (!open) return undefined;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') handleClose();
};
window.addEventListener('keydown', onKeyDown);
return () => {
document.body.style.overflow = previousOverflow;
window.removeEventListener('keydown', onKeyDown);
};
}, [open, handleClose]);
if (!open) return null;
const listening = phase === 'listening';
const transcribing = phase === 'transcribing';
const busy = phase === 'requesting' || transcribing;
const canSend = Boolean(text.trim()) && !busy && !disabled;
const handleSend = () => {
const value = text.trim();
if (!value || disabled) return;
stopListening();
onSend(value);
onClose();
};
const statusText =
phase === 'requesting'
? '正在准备麦克风…'
: listening
? liveRecognition
? '正在聆听,文字会实时显示'
: '正在录音,完成后点击「完成识别」'
: transcribing
? '正在识别…'
: '可编辑识别结果后发送';
const supported = isVoiceInputAvailable();
if (!supported) {
return createPortal(
<div className="voice-dialog-backdrop" role="presentation" onClick={handleClose}>
<section className="voice-dialog" role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
<p className="voice-dialog-status"></p>
<div className="voice-dialog-actions">
<button type="button" className="ghost-btn" onClick={handleClose}>
</button>
</div>
</section>
</div>,
document.body,
);
}
return createPortal(
<div className="voice-dialog-backdrop" role="presentation" onClick={handleClose}>
<section
className="voice-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="voice-dialog-title"
onClick={(event) => event.stopPropagation()}
>
<div className="voice-dialog-header">
<div>
<p className="voice-dialog-eyebrow"></p>
<h3 id="voice-dialog-title"></h3>
</div>
<button type="button" className="voice-dialog-close" onClick={handleClose} aria-label="关闭">
</button>
</div>
<VoiceWaveform analyser={analyser} active={listening || phase === 'requesting'} />
<p className="voice-dialog-status" role="status">
{transcribing ? <span className="voice-btn-spinner" aria-hidden="true" /> : null}
{statusText}
</p>
<textarea
className="voice-dialog-input input"
rows={4}
placeholder={listening ? '识别文字会出现在这里…' : '输入或编辑要发送的内容…'}
value={text}
disabled={busy}
onChange={(event) => updateText(event.target.value)}
/>
<div className="voice-dialog-actions">
<button type="button" className="ghost-btn" onClick={handleClose}>
</button>
{!liveRecognition && listening ? (
<button
type="button"
className="ghost-btn"
disabled={transcribing}
onClick={() => void finishFallbackRecording()}
>
</button>
) : null}
<button type="button" className="send-btn" disabled={!canSend} onClick={handleSend}>
</button>
</div>
</section>
</div>,
document.body,
);
}
+47
View File
@@ -0,0 +1,47 @@
import { useEffect, useRef } from 'react';
const BAR_COUNT = 24;
export function VoiceWaveform({
analyser,
active,
}: {
analyser: AnalyserNode | null;
active: boolean;
}) {
const barsRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!analyser || !active || !barsRef.current) return undefined;
const bars = Array.from(barsRef.current.children) as HTMLElement[];
const data = new Uint8Array(analyser.frequencyBinCount);
let frame = 0;
const tick = () => {
analyser.getByteFrequencyData(data);
const step = Math.max(1, Math.floor(data.length / BAR_COUNT));
for (let i = 0; i < BAR_COUNT; i += 1) {
const sample = data[i * step] ?? 0;
const height = Math.max(12, Math.round((sample / 255) * 100));
bars[i]?.style.setProperty('--voice-bar-height', `${height}%`);
}
frame = window.requestAnimationFrame(tick);
};
tick();
return () => window.cancelAnimationFrame(frame);
}, [analyser, active]);
return (
<div
className={`voice-waveform${active ? ' voice-waveform-active' : ''}${active && !analyser ? ' voice-waveform-idle' : ''}`}
aria-hidden="true"
ref={barsRef}
>
{Array.from({ length: BAR_COUNT }, (_, index) => (
<span key={index} className="voice-waveform-bar" />
))}
</div>
);
}
+82
View File
@@ -0,0 +1,82 @@
import { useState } from 'react';
import type { MindSpaceAsset } from '../types';
import { buildAssetPreviewFrameConfig } from '../utils/mindspaceCards';
export type WorkCoverMode = 'thumbnail-first' | 'preview-first' | 'preview-only';
export function WorkCover({
thumbnailUrl,
previewUrl,
previewAsset,
fallbackLabel,
title,
imageClassName = 'mindspace-item-cover-img',
coverMode = 'thumbnail-first',
previewFrameClassName = 'mindspace-preview-embed',
}: {
thumbnailUrl: string;
previewUrl?: string;
previewAsset?: Pick<MindSpaceAsset, 'id' | 'mimeType'>;
fallbackLabel: string;
title: string;
imageClassName?: string;
coverMode?: WorkCoverMode;
previewFrameClassName?: string;
}) {
const [thumbFailed, setThumbFailed] = useState(false);
const [previewFailed, setPreviewFailed] = useState(false);
const previewFrame =
previewAsset != null
? buildAssetPreviewFrameConfig(previewAsset)
: previewUrl
? { src: previewUrl, sandbox: 'allow-same-origin' as const }
: null;
const showPreview =
Boolean(previewFrame) &&
!previewFailed &&
(coverMode === 'preview-first' || coverMode === 'preview-only' || thumbFailed);
if (showPreview && previewFrame) {
return (
<div className={previewFrameClassName} aria-hidden="true">
<iframe
title={`${title} 预览`}
src={previewFrame.src}
{...(previewFrame.sandbox ? { sandbox: previewFrame.sandbox } : {})}
loading="lazy"
onError={() => setPreviewFailed(true)}
/>
</div>
);
}
if (coverMode !== 'preview-only' && !thumbFailed) {
return (
<img
className={imageClassName}
src={thumbnailUrl}
alt={`${title} 封面`}
loading="lazy"
onError={() => setThumbFailed(true)}
/>
);
}
if (previewFrame && !previewFailed) {
return (
<div className={previewFrameClassName} aria-hidden="true">
<iframe
title={`${title} 预览`}
src={previewFrame.src}
{...(previewFrame.sandbox ? { sandbox: previewFrame.sandbox } : {})}
loading="lazy"
onError={() => setPreviewFailed(true)}
/>
</div>
);
}
return <span>{fallbackLabel}</span>;
}