feat: release chat and mindspace UI updates
This commit is contained in:
@@ -12,6 +12,38 @@ import type { CapabilityMap, ChatState, Message, PortalUser, Session, ToolConfir
|
||||
import type { MindSpaceSaveCategory } from '../types';
|
||||
|
||||
const MAX_PENDING_IMAGES = 6;
|
||||
const CHAT_PLACEHOLDER_PROMPTS = [
|
||||
'帮我写一篇温柔一点的小短文',
|
||||
'讲个轻松的笑话,让我换换脑子',
|
||||
'帮我看看今天的天气和出门建议',
|
||||
'规划一份周末城市漫游攻略',
|
||||
'把这组数据整理成一段分析结论',
|
||||
'帮我总结一份会议纪要或文档',
|
||||
'写一个有记忆点的产品介绍',
|
||||
'设计一个简单好玩的小游戏',
|
||||
'帮我做一份旅行行程和预算',
|
||||
'把一段想法改成更专业的表达',
|
||||
'给我起几个品牌名和一句 slogan',
|
||||
'帮我写一封礼貌但坚定的邮件',
|
||||
'整理一份学习计划和每日任务',
|
||||
'分析一个产品页面哪里可以优化',
|
||||
'把复杂概念讲得像聊天一样简单',
|
||||
'帮我生成一份短视频脚本',
|
||||
'写一段适合发朋友圈的文案',
|
||||
'帮我拆解一个商业想法是否可行',
|
||||
'把长文压缩成三条重点',
|
||||
'做一个活动策划和执行清单',
|
||||
'帮我准备一次面试的回答思路',
|
||||
'写一个网页或小工具的需求说明',
|
||||
'帮我把表格数据变成洞察报告',
|
||||
'给孩子讲一个睡前故事',
|
||||
'帮我规划一周健康饮食',
|
||||
'把这段话翻译得自然一点',
|
||||
'帮我做一份竞品对比',
|
||||
'给一个新功能设计使用流程',
|
||||
'帮我写一段代码并解释思路',
|
||||
'整理一个今天就能开始的行动计划',
|
||||
];
|
||||
|
||||
type PendingChatImage = {
|
||||
id: string;
|
||||
@@ -76,6 +108,9 @@ export function ChatPanel({
|
||||
const [uploadingImage, setUploadingImage] = useState(false);
|
||||
const [imageError, setImageError] = useState<string | null>(null);
|
||||
const [voiceStopSignal, setVoiceStopSignal] = useState(0);
|
||||
const [randomPrompt] = useState(
|
||||
() => CHAT_PLACEHOLDER_PROMPTS[Math.floor(Math.random() * CHAT_PLACEHOLDER_PROMPTS.length)],
|
||||
);
|
||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef(input);
|
||||
@@ -105,6 +140,7 @@ export function ChatPanel({
|
||||
const canPublish = Boolean(capabilities?.static_publish) || hasPublishSkill;
|
||||
const chatSkills = filterChatSkills(CHAT_SKILL_OPTIONS, { grantedSkills, canPublish });
|
||||
const compact = variant === 'compact';
|
||||
const showHomeWelcome = !compact && messages.length === 0;
|
||||
|
||||
const placeholder = offlineBlocked
|
||||
? '网络断开,恢复连接后可继续输入'
|
||||
@@ -112,7 +148,7 @@ export function ChatPanel({
|
||||
? '正在连接会话…'
|
||||
: compact
|
||||
? '在这里继续和 Agent 对话…'
|
||||
: '输入任务,例如:列出当前目录文件';
|
||||
: randomPrompt;
|
||||
|
||||
const mergeVoiceText = (spoken: string) => {
|
||||
const base = voiceBaseRef.current.trimEnd();
|
||||
@@ -321,7 +357,7 @@ export function ChatPanel({
|
||||
|
||||
return (
|
||||
<>
|
||||
<main className={compact ? 'space-chat-panel-body' : 'main'}>
|
||||
<main className={compact ? 'space-chat-panel-body' : `main${showHomeWelcome ? ' main-home' : ''}`}>
|
||||
<MessageList
|
||||
messages={messages}
|
||||
streaming={chatState === 'streaming'}
|
||||
@@ -329,6 +365,7 @@ export function ChatPanel({
|
||||
onSaveAsPage={(message) => setPageSource(message)}
|
||||
publishUserId={user?.id}
|
||||
publishUsername={user?.username}
|
||||
sessionId={session?.id}
|
||||
compact={compact}
|
||||
/>
|
||||
{!compact && (
|
||||
@@ -369,7 +406,7 @@ export function ChatPanel({
|
||||
/>
|
||||
)}
|
||||
|
||||
<footer className={compact ? 'space-chat-panel-footer' : 'footer'}>
|
||||
<footer className={compact ? 'space-chat-panel-footer' : `footer${showHomeWelcome ? ' footer-home' : ''}`}>
|
||||
{voiceNotice && (
|
||||
<div className="voice-notice" role="status">
|
||||
{voiceNotice}
|
||||
@@ -399,8 +436,8 @@ export function ChatPanel({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="chat-input-row">
|
||||
<div className="chat-input-shell">
|
||||
<div className={`chat-input-row${showHomeWelcome ? ' chat-input-row-home' : ''}`}>
|
||||
<div className={`chat-input-shell${showHomeWelcome ? ' chat-input-shell-home' : ''}`}>
|
||||
{onUploadImage && (
|
||||
<>
|
||||
<button
|
||||
@@ -449,7 +486,7 @@ export function ChatPanel({
|
||||
stopSignal={voiceStopSignal}
|
||||
/>
|
||||
</div>
|
||||
{chatSkills.length > 0 && (
|
||||
{!showHomeWelcome && chatSkills.length > 0 && (
|
||||
<ChatSkillPicker
|
||||
skills={chatSkills}
|
||||
disabled={voiceDisabled}
|
||||
@@ -457,16 +494,18 @@ export function ChatPanel({
|
||||
onPrefill={setInput}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="chat-skill-trigger"
|
||||
title="Design Style Generator"
|
||||
disabled={voiceDisabled}
|
||||
onClick={() => setDesignPanelOpen(true)}
|
||||
>
|
||||
<DesignBtnIcon className="chat-skill-trigger-icon" />
|
||||
<span className="chat-skill-trigger-label">design</span>
|
||||
</button>
|
||||
{!showHomeWelcome && (
|
||||
<button
|
||||
type="button"
|
||||
className="chat-skill-trigger"
|
||||
title="Design Style Generator"
|
||||
disabled={voiceDisabled}
|
||||
onClick={() => setDesignPanelOpen(true)}
|
||||
>
|
||||
<DesignBtnIcon className="chat-skill-trigger-icon" />
|
||||
<span className="chat-skill-trigger-label">design</span>
|
||||
</button>
|
||||
)}
|
||||
{designPanelOpen && (
|
||||
<DesignSkillPanel
|
||||
onClose={() => setDesignPanelOpen(false)}
|
||||
@@ -483,7 +522,7 @@ export function ChatPanel({
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="send-btn chat-input-action"
|
||||
className={`send-btn chat-input-action${showHomeWelcome ? ' chat-input-action-home' : ''}`}
|
||||
disabled={!canSubmit || voiceDisabled || uploadingImage}
|
||||
onClick={() => void handleSubmit()}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { buildChatSavePreviewFrameUrl, quickShareFromChat } from '../api/client';
|
||||
|
||||
type SharePhase =
|
||||
| { kind: 'idle' }
|
||||
| { kind: 'loading' }
|
||||
| { kind: 'done'; publicUrl: string }
|
||||
| { kind: 'error'; message: string };
|
||||
|
||||
export function ChatSharePreviewModal({
|
||||
sessionId,
|
||||
messageId,
|
||||
selectedLinkIndex = 0,
|
||||
onClose,
|
||||
onSave,
|
||||
}: {
|
||||
sessionId: string;
|
||||
messageId: string;
|
||||
selectedLinkIndex?: number;
|
||||
onClose: () => void;
|
||||
onSave?: () => void;
|
||||
}) {
|
||||
const [phase, setPhase] = useState<SharePhase>({ kind: 'idle' });
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const previewUrl = buildChatSavePreviewFrameUrl({ sessionId, messageId, selectedLinkIndex });
|
||||
|
||||
useEffect(() => {
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => { document.body.style.overflow = prev; };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => () => { if (copyTimer.current) clearTimeout(copyTimer.current); }, []);
|
||||
|
||||
const share = async () => {
|
||||
if (phase.kind === 'loading') return;
|
||||
setPhase({ kind: 'loading' });
|
||||
try {
|
||||
const result = await quickShareFromChat({ sessionId, messageId, selectedLinkIndex });
|
||||
setPhase({ kind: 'done', publicUrl: result.publicUrl });
|
||||
} catch (err) {
|
||||
setPhase({ kind: 'error', message: err instanceof Error ? err.message : '公开分享失败,请重试' });
|
||||
}
|
||||
};
|
||||
|
||||
const copy = async (url: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
setCopied(true);
|
||||
if (copyTimer.current) clearTimeout(copyTimer.current);
|
||||
copyTimer.current = setTimeout(() => setCopied(false), 2000);
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
onClose();
|
||||
onSave?.();
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div className="chat-share-preview-overlay" role="dialog" aria-modal="true" aria-label="页面预览">
|
||||
<div className="chat-share-preview-header">
|
||||
<span className="chat-share-preview-title">页面预览</span>
|
||||
<button type="button" className="chat-share-preview-close" onClick={onClose} aria-label="关闭">✕</button>
|
||||
</div>
|
||||
<div className="chat-share-preview-body">
|
||||
<iframe src={previewUrl} title="页面预览" sandbox="allow-same-origin allow-scripts allow-popups" className="chat-share-preview-frame" />
|
||||
|
||||
<div className="chat-share-side-actions">
|
||||
{phase.kind === 'done' ? (
|
||||
<div className="chat-share-result-card">
|
||||
<p className="chat-share-result-label">✅ 已公开!</p>
|
||||
<button type="button" className="chat-share-result-copy" onClick={() => void copy(phase.publicUrl)}>
|
||||
{copied ? '已复制 ✓' : '复制链接'}
|
||||
</button>
|
||||
<a href={phase.publicUrl} target="_blank" rel="noreferrer" className="chat-share-result-open">
|
||||
打开页面 →
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={`chat-share-side-btn chat-share-side-btn-primary${phase.kind === 'loading' ? ' is-loading' : ''}`}
|
||||
disabled={phase.kind === 'loading'}
|
||||
onClick={() => void share()}
|
||||
title="将页面转为公开链接,私有图片自动转为公开地址"
|
||||
>
|
||||
{phase.kind === 'loading' ? '处理中…' : '🌐 公开分享'}
|
||||
</button>
|
||||
)}
|
||||
{phase.kind === 'error' && <p className="chat-share-side-error">{phase.message}</p>}
|
||||
{onSave && (
|
||||
<button type="button" className="chat-share-side-btn" onClick={handleSave}>
|
||||
📄 保存页面
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -72,6 +72,7 @@ export function ChatView({
|
||||
}, [sidebarOpen, onSidebarOpen]);
|
||||
|
||||
const busy = chatState === 'streaming' || chatState === 'loading' || chatState === 'connecting';
|
||||
const showHomeWelcome = messages.length === 0;
|
||||
|
||||
const handleSelectSession = (sessionId: string) => {
|
||||
void switchSession(sessionId);
|
||||
@@ -85,7 +86,7 @@ export function ChatView({
|
||||
|
||||
const sessionTitle = session
|
||||
? getSessionDisplayName(session)
|
||||
: chatState === 'connecting'
|
||||
: chatState === 'idle'
|
||||
? '新对话'
|
||||
: '连接中…';
|
||||
|
||||
@@ -128,8 +129,8 @@ export function ChatView({
|
||||
onDelete={(sessionId) => void deleteSession(sessionId)}
|
||||
/>
|
||||
|
||||
<div className="app">
|
||||
<header className="header">
|
||||
<div className={`app${showHomeWelcome ? ' app-home' : ''}`}>
|
||||
<header className={`header${showHomeWelcome ? ' header-home' : ''}`}>
|
||||
<div className="header-left">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
PenSquare,
|
||||
Code2,
|
||||
BarChart3,
|
||||
FileText,
|
||||
Bot,
|
||||
} from 'lucide-react';
|
||||
import { TKMindAvatar } from './TKMindAvatar';
|
||||
|
||||
const features = [
|
||||
{ icon: PenSquare, text: '写作创作' },
|
||||
{ icon: Code2, text: '编程开发' },
|
||||
{ icon: BarChart3, text: '数据分析' },
|
||||
{ icon: FileText, text: '文档总结' },
|
||||
{ icon: Bot, text: '自动执行' },
|
||||
];
|
||||
|
||||
export function ChatWelcomePanel({ compact }: { compact?: boolean }) {
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="empty-state empty-state-compact">
|
||||
<TKMindAvatar />
|
||||
<h2>TKMind</h2>
|
||||
<p>继续和空间里的 Agent 对话</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="welcome-panel">
|
||||
<div className="welcome-panel-content">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -15 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="welcome-panel-avatar-wrap"
|
||||
>
|
||||
<TKMindAvatar size="lg" className="welcome-panel-avatar" />
|
||||
</motion.div>
|
||||
|
||||
<motion.h2
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.15 }}
|
||||
className="welcome-panel-brand"
|
||||
>
|
||||
TKMind智趣
|
||||
</motion.h2>
|
||||
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 18 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.25 }}
|
||||
className="welcome-panel-headline"
|
||||
>
|
||||
<span className="welcome-panel-headline-base">你的全能</span>
|
||||
<span className="welcome-panel-headline-gradient">AI 助手</span>
|
||||
</motion.h1>
|
||||
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
className="welcome-panel-subtitle"
|
||||
>
|
||||
想到什么,就发什么。
|
||||
</motion.p>
|
||||
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.55 }}
|
||||
className="welcome-panel-description"
|
||||
>
|
||||
写作创作、编程开发、数据分析、文档总结、自动执行任务,统统交给 TKMind 智趣。
|
||||
</motion.p>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.7 }}
|
||||
className="welcome-panel-actions"
|
||||
>
|
||||
{features.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<div key={item.text} className="welcome-panel-pill">
|
||||
<span className="welcome-panel-pill-icon" aria-hidden="true">
|
||||
<Icon size={22} strokeWidth={2} />
|
||||
</span>
|
||||
<span>{item.text}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.95 }}
|
||||
className="welcome-panel-hint"
|
||||
>
|
||||
<div className="welcome-panel-hint-arrow">↓</div>
|
||||
<div className="text-base">在下方输入你的问题或任务</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -58,10 +58,12 @@ export function HistorySidebar({
|
||||
}: HistorySidebarProps) {
|
||||
const bodyRef = useRef<HTMLDivElement>(null);
|
||||
const [visibleCount, setVisibleCount] = useState(appConfig.sessionPageSize);
|
||||
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setVisibleCount(appConfig.sessionPageSize);
|
||||
setPendingDeleteId(null);
|
||||
}
|
||||
}, [open, sessions.length]);
|
||||
|
||||
@@ -74,14 +76,22 @@ export function HistorySidebar({
|
||||
}
|
||||
}, [visibleCount, sessions.length]);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(sessionId: string, label: string) => {
|
||||
if (!window.confirm(`确定删除「${label}」?此操作不可恢复。`)) return;
|
||||
const handleDeleteClick = useCallback((sessionId: string) => {
|
||||
setPendingDeleteId(sessionId);
|
||||
}, []);
|
||||
|
||||
const handleConfirmDelete = useCallback(
|
||||
(sessionId: string) => {
|
||||
setPendingDeleteId(null);
|
||||
onDelete(sessionId);
|
||||
},
|
||||
[onDelete],
|
||||
);
|
||||
|
||||
const handleCancelDelete = useCallback(() => {
|
||||
setPendingDeleteId(null);
|
||||
}, []);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const visibleSessions = sessions.slice(0, visibleCount);
|
||||
@@ -119,26 +129,46 @@ export function HistorySidebar({
|
||||
<ul className="sidebar-list">
|
||||
{group.sessions.map((item) => {
|
||||
const label = getSessionListLabel(item);
|
||||
const isPending = pendingDeleteId === item.id;
|
||||
return (
|
||||
<li key={item.id} className="sidebar-item-wrap">
|
||||
<li key={item.id} className={`sidebar-item-wrap${isPending ? ' sidebar-item-confirming' : ''}`}>
|
||||
<button
|
||||
type="button"
|
||||
className={`sidebar-item ${item.id === activeSessionId ? 'sidebar-item-active' : ''}`}
|
||||
onClick={() => onSelect(item.id)}
|
||||
onClick={() => { if (!isPending) onSelect(item.id); }}
|
||||
>
|
||||
<span className="sidebar-item-title">{label}</span>
|
||||
<span className="sidebar-item-meta">
|
||||
{formatSessionTime(item.updated_at ?? item.created_at)}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="sidebar-item-delete"
|
||||
aria-label={`删除 ${label}`}
|
||||
onClick={() => handleDelete(item.id, label)}
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
{isPending ? (
|
||||
<div className="sidebar-item-confirm">
|
||||
<button
|
||||
type="button"
|
||||
className="sidebar-confirm-yes"
|
||||
onClick={() => handleConfirmDelete(item.id)}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="sidebar-confirm-no"
|
||||
onClick={handleCancelDelete}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="sidebar-item-delete"
|
||||
aria-label={`删除 ${label}`}
|
||||
onClick={() => handleDeleteClick(item.id)}
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -8,6 +8,8 @@ import { filterText } from '../utils/wordFilter';
|
||||
import { formatChatTime, shouldShowTimestamp } from '../utils/time';
|
||||
import { TKMindAvatar } from './TKMindAvatar';
|
||||
import { UserAvatar } from './UserAvatar';
|
||||
import { ChatSharePreviewModal } from './ChatSharePreviewModal';
|
||||
import { ChatWelcomePanel } from './ChatWelcomePanel';
|
||||
|
||||
function CopyIcon() {
|
||||
return (
|
||||
@@ -37,18 +39,58 @@ function CheckIcon() {
|
||||
);
|
||||
}
|
||||
|
||||
function ToolSpinnerIcon() {
|
||||
return (
|
||||
<span className="tool-badge-spinner" aria-hidden="true">
|
||||
<span />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolDoneIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" 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;
|
||||
|
||||
const completed = tools.filter((t) => t.type === 'toolResponse').length;
|
||||
const total = tools.length;
|
||||
|
||||
return (
|
||||
<div className="tool-badges">
|
||||
{tools.map((tool, i) => (
|
||||
<span key={`${message.id}-${i}`} className="tool-badge">
|
||||
{tool.type === 'toolRequest'
|
||||
? `🔧 ${tool.toolCall.functionName}`
|
||||
: `✓ 工具完成`}
|
||||
</span>
|
||||
))}
|
||||
{tools.map((tool, i) => {
|
||||
const isRequest = tool.type === 'toolRequest';
|
||||
const toolName = isRequest ? String(tool.toolCall.functionName ?? '').trim() : '';
|
||||
|
||||
let label: string;
|
||||
if (isRequest && !toolName) {
|
||||
label = `正在加载 tools (${completed}/${total})`;
|
||||
} else {
|
||||
label = isRequest ? toolName : '工具完成';
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
key={`${message.id}-${i}`}
|
||||
className={`tool-badge${isRequest ? ' is-pending' : ' is-complete'}`}
|
||||
>
|
||||
{isRequest ? <ToolSpinnerIcon /> : <ToolDoneIcon />}
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -126,6 +168,7 @@ function MessageRow({
|
||||
saveDisabled,
|
||||
publishUserId,
|
||||
publishUsername,
|
||||
sessionId,
|
||||
compact = false,
|
||||
}: {
|
||||
message: Message;
|
||||
@@ -135,9 +178,11 @@ function MessageRow({
|
||||
saveDisabled?: boolean;
|
||||
publishUserId?: string;
|
||||
publishUsername?: string;
|
||||
sessionId?: string;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const [actionsOpen, setActionsOpen] = useState(false);
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
const rawText = getDisplayText(message);
|
||||
const text = filterText(rawText);
|
||||
const saveActions = getMessageSaveActions(text, { userId: publishUserId, username: publishUsername });
|
||||
@@ -227,15 +272,14 @@ function MessageRow({
|
||||
>
|
||||
{saveActions.kind === 'page' ? '保存页面' : '保存为文章'}
|
||||
</button>
|
||||
{saveActions.previewUrl && (
|
||||
<a
|
||||
{saveActions.previewUrl && sessionId && message.id && (
|
||||
<button
|
||||
type="button"
|
||||
className="msg-open-preview"
|
||||
href={saveActions.previewUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={() => setPreviewOpen(true)}
|
||||
>
|
||||
打开预览
|
||||
</a>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -254,15 +298,14 @@ function MessageRow({
|
||||
>
|
||||
{saveActions.kind === 'page' ? '保存页面' : '保存为文章'}
|
||||
</button>
|
||||
{saveActions.previewUrl && (
|
||||
<a
|
||||
{saveActions.previewUrl && sessionId && message.id && (
|
||||
<button
|
||||
type="button"
|
||||
className="msg-open-preview"
|
||||
href={saveActions.previewUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={() => setPreviewOpen(true)}
|
||||
>
|
||||
打开预览
|
||||
</a>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -278,6 +321,14 @@ function MessageRow({
|
||||
title={onAvatarClick ? '点击更换头像' : undefined}
|
||||
/>
|
||||
)}
|
||||
{previewOpen && sessionId && message.id && (
|
||||
<ChatSharePreviewModal
|
||||
sessionId={sessionId}
|
||||
messageId={message.id}
|
||||
onClose={() => setPreviewOpen(false)}
|
||||
onSave={onSaveAsPage ? () => onSaveAsPage(message) : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -289,6 +340,7 @@ export function MessageList({
|
||||
onSaveAsPage,
|
||||
publishUserId,
|
||||
publishUsername,
|
||||
sessionId,
|
||||
compact = false,
|
||||
}: {
|
||||
messages: Message[];
|
||||
@@ -297,19 +349,14 @@ export function MessageList({
|
||||
onSaveAsPage?: (message: Message) => void;
|
||||
publishUserId?: string;
|
||||
publishUsername?: string;
|
||||
sessionId?: 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.length === 0 && <ChatWelcomePanel compact={compact} />}
|
||||
{messages.map((message, index) => {
|
||||
const prev = index > 0 ? messages[index - 1] : undefined;
|
||||
const showTime = shouldShowTimestamp(message.created, prev?.created);
|
||||
@@ -324,6 +371,7 @@ export function MessageList({
|
||||
saveDisabled={streaming}
|
||||
publishUserId={publishUserId}
|
||||
publishUsername={publishUsername}
|
||||
sessionId={sessionId}
|
||||
compact={compact}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -54,11 +54,15 @@ export function MindSpaceFeedCard({
|
||||
previewMode = false,
|
||||
onClick,
|
||||
actions,
|
||||
checked,
|
||||
onCheckChange,
|
||||
}: {
|
||||
page: MindSpacePage;
|
||||
previewMode?: boolean;
|
||||
onClick: () => void;
|
||||
actions?: { label: string; onClick: (e: React.MouseEvent) => void }[];
|
||||
checked?: boolean;
|
||||
onCheckChange?: (checked: boolean) => void;
|
||||
}) {
|
||||
const typeLabel = feedTypeLabel(page);
|
||||
const isHtml = page.contentFormat === 'html';
|
||||
@@ -68,6 +72,19 @@ export function MindSpaceFeedCard({
|
||||
<div className="mindspace-feed-card-wrap">
|
||||
<button type="button" className="mindspace-feed-card" onClick={onClick}>
|
||||
<div className="mindspace-feed-cover">
|
||||
{onCheckChange !== undefined && (
|
||||
<label
|
||||
className="mindspace-feed-select"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked ?? false}
|
||||
onChange={(e) => onCheckChange(e.target.checked)}
|
||||
/>
|
||||
<span>{checked ? '已选' : '选择'}</span>
|
||||
</label>
|
||||
)}
|
||||
{isHtml ? (
|
||||
<WorkCover
|
||||
coverMode="thumbnail-first"
|
||||
|
||||
@@ -476,6 +476,8 @@ export function MindSpaceView({
|
||||
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
|
||||
const [selectedAssetIds, setSelectedAssetIds] = useState<string[]>([]);
|
||||
const [bulkDeletingAssets, setBulkDeletingAssets] = useState(false);
|
||||
const [selectedDraftPageIds, setSelectedDraftPageIds] = useState<string[]>([]);
|
||||
const [bulkDeletingDraftPages, setBulkDeletingDraftPages] = useState(false);
|
||||
const [newPageOpen, setNewPageOpen] = useState(false);
|
||||
const [newPageTitle, setNewPageTitle] = useState('');
|
||||
const [newPageContent, setNewPageContent] = useState('');
|
||||
@@ -1335,6 +1337,55 @@ export function MindSpaceView({
|
||||
}
|
||||
};
|
||||
|
||||
const selectedDraftPageSet = useMemo(() => new Set(selectedDraftPageIds), [selectedDraftPageIds]);
|
||||
const allDraftPagesSelected =
|
||||
pages.length > 0 && pages.every((p) => selectedDraftPageSet.has(p.id));
|
||||
|
||||
const toggleDraftPageSelected = (pageId: string) => {
|
||||
setSelectedDraftPageIds((ids) =>
|
||||
ids.includes(pageId) ? ids.filter((id) => id !== pageId) : [...ids, pageId],
|
||||
);
|
||||
};
|
||||
|
||||
const toggleAllDraftPagesSelected = () => {
|
||||
if (allDraftPagesSelected) {
|
||||
setSelectedDraftPageIds([]);
|
||||
} else {
|
||||
setSelectedDraftPageIds(pages.map((p) => p.id));
|
||||
}
|
||||
};
|
||||
|
||||
const removeDraftPages = async () => {
|
||||
if (selectedDraftPageIds.length === 0) return;
|
||||
if (previewMode) {
|
||||
setError('预览模式下无法删除页面');
|
||||
return;
|
||||
}
|
||||
setBulkDeletingDraftPages(true);
|
||||
setError(null);
|
||||
const deletingIds = [...selectedDraftPageIds];
|
||||
const failedIds: string[] = [];
|
||||
const failedNames: string[] = [];
|
||||
try {
|
||||
for (const pageId of deletingIds) {
|
||||
try {
|
||||
await deleteMindSpacePage(pageId);
|
||||
if (selectedPageId === pageId) closePage();
|
||||
} catch {
|
||||
failedIds.push(pageId);
|
||||
failedNames.push(pages.find((p) => p.id === pageId)?.title ?? pageId);
|
||||
}
|
||||
}
|
||||
setSelectedDraftPageIds((ids) => ids.filter((id) => failedIds.includes(id)));
|
||||
await load();
|
||||
if (failedNames.length > 0) {
|
||||
setError(`有 ${failedNames.length} 个页面删除失败:${failedNames.slice(0, 3).join('、')}`);
|
||||
}
|
||||
} finally {
|
||||
setBulkDeletingDraftPages(false);
|
||||
}
|
||||
};
|
||||
|
||||
const previewAsset = useMemo(
|
||||
() => assets.find((asset) => asset.id === previewAssetId) ?? null,
|
||||
[assets, previewAssetId],
|
||||
@@ -1362,11 +1413,6 @@ export function MindSpaceView({
|
||||
发布到广场
|
||||
</button>
|
||||
)}
|
||||
{canGenerateWithAgent(asset) && (
|
||||
<button type="button" onClick={() => openAgentGenerator(asset)}>
|
||||
AI 生成页面
|
||||
</button>
|
||||
)}
|
||||
{asset.publicUrl && (
|
||||
<a
|
||||
className="mindspace-asset-share"
|
||||
@@ -1936,7 +1982,7 @@ export function MindSpaceView({
|
||||
{!agentJobsLoading && agentJobsList.length === 0 && (
|
||||
<div className="mindspace-empty">
|
||||
<h2>还没有 AI 生成任务</h2>
|
||||
<p>在 OA 工作区选择资料并点击「AI 生成页面」后,任务会出现在这里。</p>
|
||||
<p>在 OA 工作区选择资料并创建页面任务后,任务会出现在这里。</p>
|
||||
</div>
|
||||
)}
|
||||
{!agentJobsLoading && agentJobsList.length > 0 && (
|
||||
@@ -2004,16 +2050,47 @@ export function MindSpaceView({
|
||||
|
||||
{selectedCategory.code === 'draft' ? (
|
||||
pages.length > 0 ? (
|
||||
<div className="mindspace-feed-grid">
|
||||
{pages.map((page) => (
|
||||
<MindSpaceFeedCard
|
||||
key={page.id}
|
||||
page={page}
|
||||
previewMode={previewMode}
|
||||
onClick={() => showPage(page.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<>
|
||||
<div className="mindspace-asset-bulkbar">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleAllDraftPagesSelected}
|
||||
disabled={bulkDeletingDraftPages}
|
||||
>
|
||||
{allDraftPagesSelected ? '取消全选' : '全选'}
|
||||
</button>
|
||||
<span>已选 {selectedDraftPageIds.length} 项</span>
|
||||
{selectedDraftPageIds.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedDraftPageIds([])}
|
||||
disabled={bulkDeletingDraftPages}
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="mindspace-asset-bulk-delete"
|
||||
onClick={() => void removeDraftPages()}
|
||||
disabled={selectedDraftPageIds.length === 0 || bulkDeletingDraftPages}
|
||||
>
|
||||
{bulkDeletingDraftPages ? '删除中…' : '删除选中'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mindspace-feed-grid">
|
||||
{pages.map((page) => (
|
||||
<MindSpaceFeedCard
|
||||
key={page.id}
|
||||
page={page}
|
||||
previewMode={previewMode}
|
||||
onClick={() => showPage(page.id)}
|
||||
checked={selectedDraftPageSet.has(page.id)}
|
||||
onCheckChange={() => toggleDraftPageSelected(page.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="mindspace-empty">
|
||||
<h2>还没有页面草稿</h2>
|
||||
|
||||
@@ -68,6 +68,7 @@ export function PageSaveDialog({
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [saveNotice, setSaveNotice] = useState<string | null>(null);
|
||||
const [duplicateResolved, setDuplicateResolved] = useState<'replace' | 'new' | null>(null);
|
||||
const [privateAcknowledged, setPrivateAcknowledged] = useState(false);
|
||||
const [thumbnailVersion, setThumbnailVersion] = useState(0);
|
||||
const [previewScrollLock, setPreviewScrollLock] = useState(false);
|
||||
@@ -151,7 +152,10 @@ export function PageSaveDialog({
|
||||
const privateNeedsAck =
|
||||
categoryCode === 'private' && privacyFindings.length > 0 && analysis?.privacyScan.allowed;
|
||||
|
||||
const save = async () => {
|
||||
const existingPage = analysis?.existingPage ?? null;
|
||||
const showDuplicatePrompt = existingPage !== null && duplicateResolved === null && !saveNotice;
|
||||
|
||||
const save = async (replacePageId?: string) => {
|
||||
if (!title.trim()) return;
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
@@ -168,10 +172,11 @@ export function PageSaveDialog({
|
||||
categoryCode === 'private' && privateNeedsAck && privateAcknowledged
|
||||
? privacyFindings.map((finding) => finding.id)
|
||||
: undefined,
|
||||
replacePageId,
|
||||
});
|
||||
if (result.kind === 'page') {
|
||||
setSaveNotice(`已保存到${CATEGORY_LABELS[categoryCode]}`);
|
||||
onSaved({ kind: 'page', categoryCode: 'draft', pageId: result.page.id });
|
||||
setSaveNotice(replacePageId ? '已替换原有页面' : `已保存到${CATEGORY_LABELS[categoryCode]}`);
|
||||
onSaved({ kind: 'page', categoryCode, pageId: result.page.id });
|
||||
return;
|
||||
}
|
||||
setSaveNotice(`已保存到${CATEGORY_LABELS[result.categoryCode]}`);
|
||||
@@ -202,7 +207,29 @@ export function PageSaveDialog({
|
||||
{analysisLoading && <div className="page-save-state">正在识别页面内容…</div>}
|
||||
{analysisError && <div className="page-save-error">{analysisError}</div>}
|
||||
|
||||
{!analysisLoading && analysis && (
|
||||
{!analysisLoading && showDuplicatePrompt && (
|
||||
<div className="page-save-duplicate">
|
||||
<p>此消息已保存为「{existingPage!.title}」,请选择操作:</p>
|
||||
<div className="page-save-duplicate-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="page-save-duplicate-btn"
|
||||
onClick={() => setDuplicateResolved('replace')}
|
||||
>
|
||||
替换旧版本
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="page-save-duplicate-btn page-save-duplicate-btn-new"
|
||||
onClick={() => setDuplicateResolved('new')}
|
||||
>
|
||||
另存为新页面
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!analysisLoading && analysis && !showDuplicatePrompt && (
|
||||
<div className="page-save-layout">
|
||||
<div className="page-save-form">
|
||||
{isStaticHtml && analysis && (
|
||||
@@ -225,6 +252,7 @@ export function PageSaveDialog({
|
||||
onChange={(event) => {
|
||||
const nextIndex = Number(event.target.value);
|
||||
setSelectedLinkIndex(nextIndex);
|
||||
setDuplicateResolved(null);
|
||||
void loadAnalysis(nextIndex);
|
||||
}}
|
||||
>
|
||||
@@ -362,26 +390,38 @@ export function PageSaveDialog({
|
||||
{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>
|
||||
{!showDuplicatePrompt && (
|
||||
<div className="page-save-actions">
|
||||
<button type="button" onClick={onClose} disabled={saving}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="page-save-primary"
|
||||
onClick={() =>
|
||||
void save(
|
||||
duplicateResolved === 'replace' && existingPage ? existingPage.id : undefined,
|
||||
)
|
||||
}
|
||||
disabled={
|
||||
saving ||
|
||||
analysisLoading ||
|
||||
!title.trim() ||
|
||||
Boolean(analysisError) ||
|
||||
privateBlocked ||
|
||||
(privateNeedsAck && !privateAcknowledged)
|
||||
}
|
||||
>
|
||||
{saving
|
||||
? '正在保存…'
|
||||
: duplicateResolved === 'replace'
|
||||
? '替换并进入编辑'
|
||||
: categoryCode === 'draft'
|
||||
? '保存并进入编辑'
|
||||
: '保存到空间'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>,
|
||||
document.body,
|
||||
|
||||
@@ -2,11 +2,11 @@ import tkmindAvatar from '../assets/tkmind-avatar.png';
|
||||
|
||||
type TKMindAvatarProps = {
|
||||
className?: string;
|
||||
size?: 'sm' | 'md';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
};
|
||||
|
||||
export function TKMindAvatar({ className = '', size = 'sm' }: TKMindAvatarProps) {
|
||||
const sizeClass = size === 'md' ? 'tkmind-avatar-md' : 'tkmind-avatar-sm';
|
||||
const sizeClass = size === 'lg' ? 'tkmind-avatar-lg' : size === 'md' ? 'tkmind-avatar-md' : 'tkmind-avatar-sm';
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user