-
-
- {points}
-
+
+ {isLoading ? (
+ <>
+
+
+ ...
+
+ >
+ ) : error ? (
+ <>
+
+
+ --
+
+ >
+ ) : (
+ <>
+
+
+ {points}
+
+ >
+ )}
- {/* 头像区域 - 重叠在积分显示上 */}
-
+ {/* 头像区域 - 重叠在积分显示上,确保完全可见 */}
+
{children}
diff --git a/react/src/components/auth/UserMenu.tsx b/react/src/components/auth/UserMenu.tsx
index 3126f751f..8cc6c98a0 100644
--- a/react/src/components/auth/UserMenu.tsx
+++ b/react/src/components/auth/UserMenu.tsx
@@ -1,7 +1,7 @@
-import React from 'react'
import { useTranslation } from 'react-i18next'
import { useAuth } from '@/contexts/AuthContext'
-import { useConfigs, useRefreshModels } from '@/contexts/configs'
+import { useConfigs } from '@/contexts/configs'
+import { useNavigate, useLocation } from '@tanstack/react-router'
import { BASE_API_URL } from '@/constants'
import { Button } from '@/components/ui/button'
import {
@@ -14,67 +14,258 @@ import {
} from '@/components/ui/dropdown-menu'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { logout } from '@/api/auth'
-import { PointsDisplay } from './PointsDisplay'
+import { useBalance } from '@/hooks/use-balance'
+import { useUserInfo } from '@/hooks/use-user-info'
+import { useEffect, useState, useCallback } from 'react'
+import { LogOut, Crown, Gift } from 'lucide-react'
+import { InviteDialog } from '@/components/invite/InviteDialog'
+
+// 🆕 Helper function to format level display with i18n support
+const formatLevelDisplay = (level: string, t: any): { name: string, period: string, isMax: boolean } => {
+ if (!level || level === 'free') {
+ return { name: t('common:auth.levels.free'), period: '', isMax: false }
+ }
+
+ // 解析新的level格式:base_monthly, pro_yearly等
+ const parts = level.split('_')
+ if (parts.length !== 2) {
+ // 兼容旧格式
+ const levelKey = level as 'base' | 'pro' | 'max'
+ return {
+ name: t(`common:auth.levels.${levelKey}`, { defaultValue: level }),
+ period: '',
+ isMax: level === 'max'
+ }
+ }
+
+ const [planType, billingPeriod] = parts
+
+ return {
+ name: t(`common:auth.levels.${planType}`, { defaultValue: planType }),
+ period: t(`common:auth.levels.${billingPeriod}`, { defaultValue: billingPeriod }),
+ isMax: planType === 'max'
+ }
+}
export function UserMenu() {
const { authStatus, refreshAuth } = useAuth()
const { setShowLoginDialog } = useConfigs()
- const refreshModels = useRefreshModels()
const { t } = useTranslation()
+ const { balance, isLoading: balanceLoading, error: balanceError } = useBalance()
+ const { userInfo, currentLevel, isLoggedIn: userInfoLoggedIn, isLoading: userInfoLoading, refreshUserInfo } = useUserInfo()
+ const navigate = useNavigate()
+ const location = useLocation()
+ const [showInviteDialog, setShowInviteDialog] = useState(false)
+
+ // 🎯 用户菜单打开时主动刷新用户数据,确保信息是最新的
+ const handleMenuOpen = useCallback(() => {
+ console.log('👤 UserMenu: 菜单打开,主动刷新用户数据...')
+ // 同时刷新认证状态和用户信息
+ refreshAuth().catch(error => {
+ console.error('❌ UserMenu: 刷新认证状态失败:', error)
+ })
+ refreshUserInfo().catch(error => {
+ console.error('❌ UserMenu: 刷新用户信息失败:', error)
+ })
+ }, [refreshAuth, refreshUserInfo])
+
+ // 计算积分显示
+ const points = Math.max(0, Math.floor(parseFloat(balance) * 100))
+
+ // 🎯 组件加载时主动刷新一次用户数据,确保等级信息最新
+ useEffect(() => {
+ if (authStatus.is_logged_in && authStatus.user_info) {
+ console.log('👤 UserMenu: 组件加载,主动刷新用户数据确保等级最新...')
+ refreshAuth().catch(error => {
+ console.error('❌ UserMenu: 初始刷新认证状态失败:', error)
+ })
+ }
+ }, []) // 只在组件加载时执行一次
+
+ // 调试状态信息
+ useEffect(() => {
+ console.log('👤 UserMenu 状态信息:', {
+ // AuthContext数据
+ authIsLoggedIn: authStatus.is_logged_in,
+ authUserLevel: authStatus.user_info?.level,
+ // useUserInfo数据
+ userInfoLoggedIn: userInfoLoggedIn,
+ currentLevel: currentLevel,
+ userInfoLoading: userInfoLoading,
+ points,
+ })
+ }, [authStatus, userInfoLoggedIn, currentLevel, userInfoLoading, points])
const handleLogout = async () => {
- await logout()
- await refreshAuth()
- // Refresh models list after logout and config update
- refreshModels()
+ console.log('🚪 UserMenu: Starting logout...')
+ try {
+ // 🚀 调用优化后的logout函数
+ // 它会:1.调用后端API 2.清理前端数据 3.通知其他标签页
+ await logout()
+
+ // 🏠 logout成功后,导航到首页
+ console.log('🏠 UserMenu: Navigating to homepage...')
+ navigate({ to: '/' })
+ } catch (error) {
+ console.error('❌ UserMenu logout failed:', error)
+ // 即使出错,也尝试导航到首页
+ console.log('🏠 UserMenu: Fallback - navigating to homepage...')
+ navigate({ to: '/' })
+ }
}
- // 如果用户已登录,显示用户菜单
- if (authStatus.is_logged_in && authStatus.user_info) {
- const { username, image_url } = authStatus.user_info
+ // 🎯 智能判断登录状态:优先使用userInfo的数据,回退到authStatus
+ const isLoggedIn = userInfoLoggedIn || authStatus.is_logged_in
+ const hasUserInfo = (userInfo?.user_info && userInfo.is_logged_in) || authStatus.user_info
+
+ // 🚨 检查是否在logout过程中,如果是则强制显示Login按钮
+ const isLoggingOut = sessionStorage.getItem('is_logging_out') === 'true' ||
+ sessionStorage.getItem('force_logout') === 'true'
+
+ // 如果用户已登录且不在logout过程中,显示用户菜单
+ if (isLoggedIn && hasUserInfo && !isLoggingOut) {
+ // 🎯 智能合并用户信息:userInfo提供level,AuthContext提供完整用户信息
+ const authUserInfo = authStatus.user_info
+ const apiUserInfo = userInfo?.user_info
+
+ // 🔧 优先使用AuthContext的username和image_url,因为API接口没有返回这些字段
+ const username = authUserInfo?.username || apiUserInfo?.email?.split('@')[0] || 'User'
+ const image_url = authUserInfo?.image_url
+ const email = apiUserInfo?.email || authUserInfo?.email
+ const level = currentLevel || authUserInfo?.level || 'free'
const initials = username ? username.substring(0, 2).toUpperCase() : 'U'
+
+ // 🔍 调试头像信息
+ console.log('🔍 UserMenu: 头像信息调试:', {
+ authUserInfo,
+ apiUserInfo,
+ finalUsername: username,
+ finalImageUrl: image_url,
+ finalEmail: email,
+ finalLevel: level
+ })
+
+ // 🔍 调试:显示实际使用的level值
+ console.log('🔍 UserMenu: 最终使用的用户等级:', {
+ currentLevel: currentLevel,
+ authLevel: authStatus.user_info?.level,
+ finalLevel: level,
+ source: currentLevel ? 'useUserInfo' : 'AuthContext'
+ })
+
+ // 🆕 格式化用户等级显示
+ const levelInfo = formatLevelDisplay(level, t)
+ console.log('🔍 UserMenu: 格式化结果:', levelInfo)
+
return (
-
-
-
-
-
- {t('common:auth.myAccount')}
- {username}
-
- {
- const billingUrl = `${BASE_API_URL}/billing`
- if (window.electronAPI?.openBrowserUrl) {
- window.electronAPI.openBrowserUrl(billingUrl)
- } else {
- window.open(billingUrl, '_blank')
- }
- }}
- >
- {t('common:auth.recharge')}
-
-
-
- {t('common:auth.logout')}
-
-
-
+
+
+
+ {/* User Profile Header */}
+
+
+
+
+ {initials}
+
+
+
+ {username}
+
+
+ {email || 'No email provided'}
+
+ {/* 🆕 显示用户计划信息 */}
+
+
+ {levelInfo.name}
+
+ {levelInfo.period && (
+
+ ({levelInfo.period})
+
+ )}
+
+
+
+
+
+
+ {/* Upgrade Button */}
+
+ navigate({ to: '/pricing' })}
+ className="w-full bg-gradient-to-r from-slate-700/90 to-slate-800/90 hover:from-slate-600/90 hover:to-slate-700/90 text-white border border-white/20 hover:border-amber-400/40 shadow-lg hover:shadow-xl backdrop-blur-sm transition-all duration-300"
+ size="sm"
+ >
+
+ {levelInfo.isMax ? t('common:auth.managePlan') : t('common:auth.upgrade')}
+
+
+
+ {/* Credits 显示 */}
+
+
+
{t('common:auth.currentPoints')}
+
+
+ {balanceLoading ? '...' : balanceError ? '--' : points}
+
+ {t('common:auth.left')}
+
+
+
+
+ {/* Menu Items */}
+
+ {/* 邀请码 */}
+
setShowInviteDialog(true)}
+ className="px-3 py-2 cursor-pointer hover:bg-white/40 transition-colors text-slate-700 hover:text-slate-800"
+ >
+
+
+ {t('common:auth.inviteCode')}
+
+
+
+ {/* 退出 */}
+
+
+
+ {t('common:auth.logout')}
+
+
+
+
+
+
+ {/* 邀请码弹窗 - 移到外部避免与DropdownMenu冲突 */}
+
+
+
+ >
)
}
- // 未登录状态,显示登录按钮
+ // 未登录状态,检查是否在邀请页面
+ const isInvitePage = location.pathname.startsWith('/join/')
+
return (
setShowLoginDialog(true)}>
- {t('common:auth.login')}
+ {isInvitePage ? t('common:auth.signUp') : t('common:auth.login')}
)
}
diff --git a/react/src/components/canvas/CanvasExcali.tsx b/react/src/components/canvas/CanvasExcali.tsx
index 50c77899c..ad3addb11 100644
--- a/react/src/components/canvas/CanvasExcali.tsx
+++ b/react/src/components/canvas/CanvasExcali.tsx
@@ -409,13 +409,23 @@ const CanvasExcali: React.FC
= ({
initialData={() => {
const data = initialData
console.log('👇initialData', data)
- if (data?.appState) {
- data.appState = {
- ...data.appState,
- collaborators: undefined!,
- }
+
+ // 🎨 设置自定义背景色 - 与蓝色渐变主题呼应
+ // 颜色选项:
+ // '#fafbff' - 非常淡的蓝白色(推荐,与主题完美呼应)
+ // '#f8faff' - 稍蓝一点的版本(更明显的蓝色调)
+ // '#fbfcff' - 极淡版本(几乎白色但保持蓝色调)
+ // '#ffffff' - 经典纯白色(如需回到原始效果)
+ const customAppState = {
+ ...(data?.appState || {}),
+ collaborators: undefined!,
+ viewBackgroundColor: '#fafbff', // 当前使用:非常淡的蓝白色
}
- return data || null
+
+ return {
+ ...data,
+ appState: customAppState,
+ } || null
}}
renderEmbeddable={renderEmbeddable}
// Allow all URLs for embeddable content
diff --git a/react/src/components/canvas/CanvasHeader.tsx b/react/src/components/canvas/CanvasHeader.tsx
index 7d71d57e1..71498549c 100644
--- a/react/src/components/canvas/CanvasHeader.tsx
+++ b/react/src/components/canvas/CanvasHeader.tsx
@@ -1,5 +1,4 @@
import { Input } from '@/components/ui/input'
-import CanvasExport from './CanvasExport'
import TopMenu from '../TopMenu'
type CanvasHeaderProps = {
@@ -18,14 +17,15 @@ const CanvasHeader: React.FC = ({
return (
onNameChange(e.target.value)}
- onBlur={onNameSave}
- />
+
+ onNameChange(e.target.value)}
+ onBlur={onNameSave}
+ />
+
}
- right={}
/>
)
}
diff --git a/react/src/components/canvas/ChatPanelHeader.tsx b/react/src/components/canvas/ChatPanelHeader.tsx
new file mode 100644
index 000000000..093d13a8c
--- /dev/null
+++ b/react/src/components/canvas/ChatPanelHeader.tsx
@@ -0,0 +1,82 @@
+import { Button } from '@/components/ui/button'
+import { SessionHistoryDropdown } from './SessionHistoryDropdown'
+import { EditableTitle } from './EditableTitle'
+import { Session } from '@/types/types'
+import { Plus, Minimize2 } from 'lucide-react'
+import { getSessionDisplayName } from '@/utils/sessionUtils'
+
+interface ChatPanelHeaderProps {
+ sessionList: Session[]
+ currentSessionId: string
+ onClose: () => void
+ onNewSession: () => void
+ onSessionSelect: (sessionId: string) => void
+ onSessionNameChange?: (sessionId: string, newName: string) => void
+}
+
+export function ChatPanelHeader({
+ sessionList,
+ currentSessionId,
+ onClose,
+ onNewSession,
+ onSessionSelect,
+ onSessionNameChange
+}: ChatPanelHeaderProps) {
+ // 获取当前session
+ const currentSession = sessionList.find(session => session.id === currentSessionId)
+ const sessionName = getSessionDisplayName(currentSession, sessionList)
+
+ // 处理session名称变更
+ const handleSessionNameSave = (newName: string) => {
+ if (onSessionNameChange && currentSessionId) {
+ onSessionNameChange(currentSessionId, newName)
+ }
+ }
+ return (
+
+ {/* 左侧:Session名称 */}
+
+
+
+
+ {/* 右侧:功能按钮 */}
+
+ {/* 新建按钮 */}
+
+
+
+
+ {/* 历史会话下拉菜单 */}
+
+
+ {/* Hide Chat按钮 */}
+
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/react/src/components/canvas/EditableTitle.tsx b/react/src/components/canvas/EditableTitle.tsx
new file mode 100644
index 000000000..304ea91fa
--- /dev/null
+++ b/react/src/components/canvas/EditableTitle.tsx
@@ -0,0 +1,119 @@
+import { useState, useRef, useEffect } from 'react'
+import { cn } from '@/lib/utils'
+import { useTranslation } from 'react-i18next'
+
+interface EditableTitleProps {
+ title: string
+ onSave: (newTitle: string) => void
+ className?: string
+ placeholder?: string
+ maxLength?: number
+}
+
+export function EditableTitle({
+ title,
+ onSave,
+ className,
+ placeholder,
+ maxLength = 50
+}: EditableTitleProps) {
+ const { t } = useTranslation(['common'])
+ const [isEditing, setIsEditing] = useState(false)
+ const [editValue, setEditValue] = useState(title)
+ const inputRef = useRef(null)
+
+ // 使用多语言的默认占位符
+ const defaultPlaceholder = placeholder || t('common:buttons.edit', 'Edit title...')
+
+ // 双击进入编辑模式
+ const handleDoubleClick = () => {
+ setIsEditing(true)
+ setEditValue(title)
+ }
+
+ // 保存编辑
+ const handleSave = () => {
+ const trimmedValue = editValue.trim()
+ if (trimmedValue) {
+ // 总是调用onSave,让父组件决定是否需要实际保存
+ onSave(trimmedValue)
+ }
+ setIsEditing(false)
+ }
+
+ // 取消编辑
+ const handleCancel = () => {
+ setEditValue(title)
+ setIsEditing(false)
+ }
+
+ // 处理键盘事件
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter') {
+ e.preventDefault()
+ handleSave()
+ } else if (e.key === 'Escape') {
+ e.preventDefault()
+ handleCancel()
+ }
+ }
+
+ // 失去焦点时保存
+ const handleBlur = () => {
+ handleSave()
+ }
+
+ // 进入编辑模式时自动聚焦和选中文本
+ useEffect(() => {
+ if (isEditing && inputRef.current) {
+ inputRef.current.focus()
+ inputRef.current.select()
+ }
+ }, [isEditing])
+
+ if (isEditing) {
+ return (
+ setEditValue(e.target.value)}
+ onKeyDown={handleKeyDown}
+ onBlur={handleBlur}
+ placeholder={defaultPlaceholder}
+ maxLength={maxLength}
+ className={cn(
+ // 与h4完全一致的基础样式 - h4字体大小
+ "text-xs font-medium text-gray-800 px-2 py-1 rounded transition-colors min-w-0",
+ // 编辑状态特有样式 - 强制透明背景
+ "!bg-transparent border border-gray-300 focus:border-blue-400 focus:ring-1 focus:ring-blue-400/50",
+ // 重置input所有默认样式
+ "outline-none appearance-none m-0 w-full h-auto",
+ // 确保字体完全一致,不继承任何默认字体
+ "font-sans leading-normal text-xs",
+ // 强制覆盖任何可能的默认样式
+ "!shadow-none !backdrop-filter-none",
+ className
+ )}
+ />
+ )
+ }
+
+ return (
+
+ {title || defaultPlaceholder}
+
+ )
+}
\ No newline at end of file
diff --git a/react/src/components/canvas/FloatingChatPanel.tsx b/react/src/components/canvas/FloatingChatPanel.tsx
new file mode 100644
index 000000000..cec5b1d49
--- /dev/null
+++ b/react/src/components/canvas/FloatingChatPanel.tsx
@@ -0,0 +1,132 @@
+import { useState } from 'react'
+import { Button } from '@/components/ui/button'
+import ChatInterface from '@/components/chat/Chat'
+import { ChatPanelHeader } from './ChatPanelHeader'
+import { Session } from '@/types/types'
+import { MessageCircle, X } from 'lucide-react'
+import { cn } from '@/lib/utils'
+import { useNavigate } from '@tanstack/react-router'
+
+interface FloatingChatPanelProps {
+ canvasId: string
+ sessionList: Session[]
+ setSessionList: (sessions: Session[]) => void
+ sessionId: string
+ onNewSession?: () => void
+ onSessionNameChange?: (sessionId: string, newName: string) => void
+}
+
+export function FloatingChatPanel({
+ canvasId,
+ sessionList,
+ setSessionList,
+ sessionId,
+ onNewSession,
+ onSessionNameChange,
+}: FloatingChatPanelProps) {
+ const [isOpen, setIsOpen] = useState(true)
+ const navigate = useNavigate()
+
+ // 新建会话 - 现在直接调用传入的回调
+ const handleNewSession = () => {
+ onNewSession?.()
+ }
+
+ // 切换会话
+ const handleSessionSelect = (newSessionId: string) => {
+ // 跳转到指定会话
+ navigate({
+ to: '/canvas/$id',
+ params: { id: canvasId },
+ search: { sessionId: newSessionId }
+ })
+ }
+
+ return (
+ <>
+ {/* 聊天切换按钮 - 右侧中间位置,移动端友好 */}
+ {!isOpen && (
+
+ setIsOpen(true)}
+ size="sm"
+ className="p-3 h-auto w-auto rounded-full bg-white/90 backdrop-blur-md border border-gray-200/50 shadow-lg hover:bg-white"
+ style={{
+ transition: 'transform 200ms cubic-bezier(0.16, 1, 0.3, 1), background-color 200ms ease',
+ }}
+ onMouseEnter={(e) => {
+ e.currentTarget.style.transform = 'scale(1.05)'
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.transform = 'scale(1)'
+ }}
+ >
+
+
+
+ )}
+
+ {/* 浮动聊天窗口 - 只在桌面端显示,底部留出空间 */}
+
+
+ {/* 功能栏 */}
+
setIsOpen(false)}
+ onNewSession={handleNewSession}
+ onSessionSelect={handleSessionSelect}
+ onSessionNameChange={onSessionNameChange}
+ />
+
+ {/* 聊天界面 */}
+
+
+
+
+
+
+ {/* 移动端适配:小屏幕时的全屏模式 */}
+ {isOpen && (
+
+ {/* 移动端功能栏 */}
+
setIsOpen(false)}
+ onNewSession={handleNewSession}
+ onSessionSelect={handleSessionSelect}
+ onSessionNameChange={onSessionNameChange}
+ />
+
+ {/* 移动端聊天界面 */}
+
+
+
+
+ )}
+ >
+ )
+}
\ No newline at end of file
diff --git a/react/src/components/canvas/FloatingLogo.tsx b/react/src/components/canvas/FloatingLogo.tsx
new file mode 100644
index 000000000..e25d57cb5
--- /dev/null
+++ b/react/src/components/canvas/FloatingLogo.tsx
@@ -0,0 +1,72 @@
+import { Button } from '@/components/ui/button'
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu'
+import { LOGO_URL } from '@/constants'
+import { useNavigate } from '@tanstack/react-router'
+import { useTranslation } from 'react-i18next'
+import { Home, FileText, Plus, Trash2 } from 'lucide-react'
+
+export function FloatingLogo() {
+ const navigate = useNavigate()
+ const { t } = useTranslation('common')
+
+ return (
+
+
+
+
+
+
+
+
+ navigate({ to: '/' })}
+ className="flex items-center gap-3 cursor-pointer hover:bg-white/60 transition-colors"
+ >
+
+ Home
+
+
+ navigate({ to: '/templates' })}
+ className="flex items-center gap-3 cursor-pointer hover:bg-white/60 transition-colors"
+ >
+
+ Templates
+
+
+
+
+
+
+ New Project
+
+
+
+
+ Delete Project
+
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/react/src/components/canvas/FloatingProjectInfo.tsx b/react/src/components/canvas/FloatingProjectInfo.tsx
new file mode 100644
index 000000000..0f8e05059
--- /dev/null
+++ b/react/src/components/canvas/FloatingProjectInfo.tsx
@@ -0,0 +1,280 @@
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu'
+import { LOGO_URL, DEFAULT_SYSTEM_PROMPT } from '@/constants'
+import { useNavigate, useParams } from '@tanstack/react-router'
+import { useTranslation } from 'react-i18next'
+import { Home, FileText, Plus, Trash2, Edit3 } from 'lucide-react'
+import { useState, useRef, useEffect } from 'react'
+import { createCanvas, deleteCanvas } from '@/api/canvas'
+import { nanoid } from 'nanoid'
+import { useConfigs } from '@/contexts/configs'
+import { toast } from 'sonner'
+import ProjectDeleteDialog from './ProjectDeleteDialog'
+import { useCanvas } from '@/contexts/canvas'
+
+interface FloatingProjectInfoProps {
+ projectName: string
+ onProjectNameChange: (name: string) => void
+ onProjectNameSave: (nameToSave?: string) => Promise
+}
+
+export function FloatingProjectInfo({
+ projectName,
+ onProjectNameChange,
+ onProjectNameSave
+}: FloatingProjectInfoProps) {
+ const navigate = useNavigate()
+ const { t } = useTranslation('common')
+ const { id } = useParams({ from: '/canvas/$id' })
+ const { textModel, selectedTools } = useConfigs()
+ const { excalidrawAPI } = useCanvas()
+ const [isEditing, setIsEditing] = useState(false)
+ const [tempName, setTempName] = useState(projectName)
+ const [isSaving, setIsSaving] = useState(false)
+ const [isCreating, setIsCreating] = useState(false)
+ const [isDeleting, setIsDeleting] = useState(false)
+ const [showDeleteDialog, setShowDeleteDialog] = useState(false)
+ const inputRef = useRef(null)
+
+ // 同步外部的projectName到内部状态
+ useEffect(() => {
+ setTempName(projectName)
+ }, [projectName])
+
+ // 开始编辑
+ const handleStartEdit = () => {
+ setIsEditing(true)
+ setTempName(projectName)
+ setTimeout(() => {
+ inputRef.current?.focus()
+ inputRef.current?.select()
+ }, 0)
+ }
+
+ // 保存编辑
+ const handleSaveEdit = async () => {
+ const trimmedName = tempName.trim()
+ if (trimmedName) {
+ try {
+ setIsSaving(true)
+ // 确保最终名称已更新
+ onProjectNameChange(trimmedName)
+ // 调用保存API,直接传递要保存的名称避免状态更新延迟
+ await onProjectNameSave(trimmedName)
+ console.log('Project名称保存成功')
+ } catch (error) {
+ console.error('保存Project名称失败:', error)
+ // 如果保存失败,恢复原来的名称
+ setTempName(projectName)
+ onProjectNameChange(projectName)
+ } finally {
+ setIsSaving(false)
+ }
+ } else {
+ // 如果输入为空,恢复原来的名称
+ setTempName(projectName)
+ onProjectNameChange(projectName)
+ }
+ setIsEditing(false)
+ }
+
+ // 取消编辑
+ const handleCancelEdit = () => {
+ setTempName(projectName)
+ setIsEditing(false)
+ }
+
+ // 键盘事件处理
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter') {
+ handleSaveEdit()
+ } else if (e.key === 'Escape') {
+ handleCancelEdit()
+ }
+ }
+
+ // 创建新项目
+ const handleNewProject = async () => {
+ try {
+ setIsCreating(true)
+
+ // 🔧 在创建新项目前,清空当前画布状态
+ if (excalidrawAPI) {
+ console.log('🧹 清空当前画布状态,准备创建新项目')
+ // 清空画布内容
+ excalidrawAPI.updateScene({
+ elements: [],
+ appState: {
+ ...excalidrawAPI.getAppState(),
+ selectedElementIds: {},
+ selectedGroupIds: {},
+ }
+ })
+ // 清空文件数据
+ excalidrawAPI.addFiles([])
+ }
+
+ const newCanvas = await createCanvas({
+ name: t('home:newCanvas'),
+ canvas_id: nanoid(),
+ messages: [],
+ session_id: nanoid(),
+ text_model: textModel,
+ tool_list: selectedTools,
+ system_prompt: localStorage.getItem('system_prompt') || DEFAULT_SYSTEM_PROMPT,
+ })
+
+ // 跳转到新创建的canvas
+ const newSessionId = nanoid()
+ navigate({
+ to: '/canvas/$id',
+ params: { id: newCanvas.id },
+ search: { sessionId: newSessionId }
+ })
+
+ toast.success(t('canvas:messages.projectCreated'))
+ } catch (error) {
+ console.error('创建新项目失败:', error)
+ toast.error(t('canvas:messages.failedToCreateProject'))
+ } finally {
+ setIsCreating(false)
+ }
+ }
+
+ // 显示删除确认对话框
+ const handleShowDeleteDialog = () => {
+ setShowDeleteDialog(true)
+ }
+
+ // 确认删除项目
+ const handleConfirmDelete = async () => {
+ if (!id) return
+
+ try {
+ setIsDeleting(true)
+ await deleteCanvas(id)
+
+ // 删除成功后跳转到首页
+ navigate({ to: '/' })
+ toast.success(t('canvas:messages.projectDeleted'))
+ } catch (error) {
+ console.error('删除项目失败:', error)
+ toast.error(t('canvas:messages.failedToDeleteProject'))
+ } finally {
+ setIsDeleting(false)
+ setShowDeleteDialog(false)
+ }
+ }
+
+ return (
+
+
+ {/* Logo按钮 */}
+
+
+
+
+
+
+
+ navigate({ to: '/' })}
+ className="flex items-center gap-3 cursor-pointer hover:bg-white/60"
+ >
+
+ {t('canvas:menu.home')}
+
+
+ navigate({ to: '/templates' })}
+ className="flex items-center gap-3 cursor-pointer hover:bg-white/60"
+ >
+
+ {t('canvas:menu.templates')}
+
+
+
+
+
+
+ {isCreating ? t('canvas:messages.creating') : t('canvas:menu.newProject')}
+
+
+
+
+ {isDeleting ? t('canvas:messages.deleting') : t('canvas:menu.deleteProject')}
+
+
+
+
+
+ {/* Project名称编辑区域 */}
+
+ {isEditing ? (
+
{
+ setTempName(e.target.value)
+ // 🚀 移除实时更新,仅在本地更新tempName,避免触发Canvas重新渲染
+ }}
+ onBlur={handleSaveEdit}
+ onKeyDown={handleKeyDown}
+ className="h-7 md:h-8 text-sm md:text-lg font-medium bg-white/90 border-gray-300 focus:border-gray-500 rounded-md"
+ placeholder="输入项目名称..."
+ />
+ ) : (
+
+
+ {projectName || '未命名项目'}
+ {isSaving && (保存中...)}
+
+
+
+ )}
+
+
+
+ {/* 项目删除确认对话框 */}
+
setShowDeleteDialog(false)}
+ onConfirm={handleConfirmDelete}
+ isDeleting={isDeleting}
+ projectName={projectName}
+ />
+
+ )
+}
\ No newline at end of file
diff --git a/react/src/components/canvas/FloatingUserInfo.tsx b/react/src/components/canvas/FloatingUserInfo.tsx
new file mode 100644
index 000000000..7ec038a04
--- /dev/null
+++ b/react/src/components/canvas/FloatingUserInfo.tsx
@@ -0,0 +1,274 @@
+import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
+import { Button } from '@/components/ui/button'
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu'
+import { useAuth } from '@/contexts/AuthContext'
+import { useBalance } from '@/hooks/use-balance'
+import { useUserInfo } from '@/hooks/use-user-info'
+import { useConfigs } from '@/contexts/configs'
+import { useCanvas } from '@/contexts/canvas'
+import { useTranslation } from 'react-i18next'
+import { useNavigate } from '@tanstack/react-router'
+import { Zap, Minus, Plus, LogOut, Crown, Gift } from 'lucide-react'
+import { useState, useCallback, useEffect } from 'react'
+import { logout } from '@/api/auth'
+import { InviteDialog } from '@/components/invite/InviteDialog'
+
+// 🆕 Helper function to format level display with i18n support
+const formatLevelDisplay = (level: string, t: any): { name: string, period: string, isMax: boolean } => {
+ if (!level || level === 'free') {
+ return { name: t('common:auth.levels.free'), period: '', isMax: false }
+ }
+
+ // 解析新的level格式:base_monthly, pro_yearly等
+ const parts = level.split('_')
+ if (parts.length !== 2) {
+ // 兼容旧格式
+ const levelKey = level as 'base' | 'pro' | 'max'
+ return {
+ name: t(`common:auth.levels.${levelKey}`, { defaultValue: level }),
+ period: '',
+ isMax: level === 'max'
+ }
+ }
+
+ const [planType, billingPeriod] = parts
+
+ return {
+ name: t(`common:auth.levels.${planType}`, { defaultValue: planType }),
+ period: t(`common:auth.levels.${billingPeriod}`, { defaultValue: billingPeriod }),
+ isMax: planType === 'max'
+ }
+}
+
+export function FloatingUserInfo() {
+ const { authStatus, refreshAuth } = useAuth()
+ const { setShowLoginDialog } = useConfigs()
+ const { t } = useTranslation()
+ const { balance, isLoading: balanceLoading, error: balanceError } = useBalance()
+ const { userInfo, currentLevel, isLoggedIn: userInfoLoggedIn, isLoading: userInfoLoading, refreshUserInfo } = useUserInfo()
+ const { excalidrawAPI } = useCanvas()
+ const navigate = useNavigate()
+ const [currentZoom, setCurrentZoom] = useState(100)
+ const [showInviteDialog, setShowInviteDialog] = useState(false)
+
+ // 🎯 用户菜单打开时主动刷新用户数据,确保信息是最新的
+ const handleMenuOpen = useCallback(() => {
+ console.log('👤 FloatingUserInfo: 菜单打开,主动刷新用户数据...')
+ // 同时刷新认证状态和用户信息
+ refreshAuth().catch(error => {
+ console.error('❌ FloatingUserInfo: 刷新认证状态失败:', error)
+ })
+ refreshUserInfo().catch(error => {
+ console.error('❌ FloatingUserInfo: 刷新用户信息失败:', error)
+ })
+ }, [refreshAuth, refreshUserInfo])
+
+ // 计算积分显示
+ const points = Math.max(0, Math.floor(parseFloat(balance) * 100))
+
+ const handleLogout = async () => {
+ console.log('🚪 FloatingUserInfo: Starting logout...')
+ try {
+ // 🚀 调用优化后的logout函数
+ // 它会:1.调用后端API 2.清理前端数据 3.通知其他标签页
+ await logout()
+
+ // 🏠 logout成功后,导航到首页
+ console.log('🏠 FloatingUserInfo: Navigating to homepage...')
+ navigate({ to: '/' })
+ } catch (error) {
+ console.error('❌ FloatingUserInfo logout failed:', error)
+ // 即使出错,也尝试导航到首页
+ console.log('🏠 FloatingUserInfo: Fallback - navigating to homepage...')
+ navigate({ to: '/' })
+ }
+ }
+
+ // 缩放控制函数
+ const handleZoomChange = (zoom: number) => {
+ excalidrawAPI?.updateScene({
+ appState: {
+ zoom: {
+ // @ts-ignore
+ value: zoom / 100,
+ },
+ },
+ })
+ }
+
+ const handleZoomFit = () => {
+ excalidrawAPI?.scrollToContent(undefined, {
+ fitToContent: true,
+ animate: true,
+ })
+ }
+
+ // 监听缩放变化
+ excalidrawAPI?.onChange((_elements, appState, _files) => {
+ const zoom = (appState.zoom.value * 100).toFixed(0)
+ setCurrentZoom(Number(zoom))
+ })
+
+ // 智能判断登录状态
+ const isLoggedIn = userInfoLoggedIn || authStatus.is_logged_in
+ const hasUserInfo = (userInfo?.user_info && userInfo.is_logged_in) || authStatus.user_info
+
+ // 检查是否在logout过程中
+ const isLoggingOut = sessionStorage.getItem('is_logging_out') === 'true' ||
+ sessionStorage.getItem('force_logout') === 'true'
+
+ // 如果用户已登录且不在logout过程中,显示用户信息
+ if (isLoggedIn && hasUserInfo && !isLoggingOut) {
+ // 🎯 智能合并用户信息:userInfo提供level,AuthContext提供完整用户信息
+ const authUserInfo = authStatus.user_info
+ const apiUserInfo = userInfo?.user_info
+
+ // 🔧 优先使用AuthContext的username和image_url,因为API接口没有返回这些字段
+ const username = authUserInfo?.username || apiUserInfo?.email?.split('@')[0] || 'User'
+ const image_url = authUserInfo?.image_url
+ const email = apiUserInfo?.email || authUserInfo?.email
+ const level = currentLevel || authUserInfo?.level || 'free'
+ const initials = username ? username.substring(0, 2).toUpperCase() : 'U'
+
+ // 🆕 格式化用户等级显示
+ const levelInfo = formatLevelDisplay(level, t)
+
+ return (
+ <>
+
+
open && handleMenuOpen()}>
+
+
+
+ {/* 用户头像 */}
+
+
+
+ {initials}
+
+
+
+ {/* 积分显示 */}
+
+
+
+
+
+ {balanceLoading ? '...' : balanceError ? '--' : points}
+
+
+
+
+
+
+ {/* User Profile Header */}
+
+
+
+
+ {initials}
+
+
+
+ {username}
+
+
+ {email || 'No email provided'}
+
+ {/* 🆕 显示用户计划信息 */}
+
+
+ {levelInfo.name}
+
+ {levelInfo.period && (
+
+ ({levelInfo.period})
+
+ )}
+
+
+
+
+
+ {/* Upgrade Button */}
+
+ navigate({ to: '/pricing' })}
+ className="w-full bg-gradient-to-r from-slate-700/90 to-slate-800/90 hover:from-slate-600/90 hover:to-slate-700/90 text-white border border-white/20 hover:border-amber-400/40 backdrop-blur-sm transition-all duration-300"
+ size="sm"
+ >
+
+ {levelInfo.isMax ? t('common:auth.managePlan') : t('common:auth.upgrade')}
+
+
+
+ {/* Credits 显示 */}
+
+
+
{t('common:auth.currentPoints')}
+
+
+ {balanceLoading ? '...' : balanceError ? '--' : points}
+
+ {t('common:auth.left')}
+
+
+
+
+ {/* Menu Items */}
+
+ {/* 邀请码 */}
+
setShowInviteDialog(true)}
+ className="px-3 py-2 cursor-pointer hover:bg-white/40 transition-colors text-slate-700 hover:text-slate-800"
+ >
+
+
+ {t('common:auth.inviteCode')}
+
+
+
+ {/* 退出 */}
+
+
+
+ {t('common:auth.logout')}
+
+
+
+
+
+
+
+ {/* 邀请码弹窗 - 移到外部避免与DropdownMenu冲突 */}
+
+
+
+ >
+ )
+ }
+
+ // 未登录状态,显示登录提示
+ return (
+
+ setShowLoginDialog(true)}
+ className="bg-white/85 backdrop-blur-md border-gray-200/30 text-gray-700 hover:bg-white/90 transition-all duration-200 text-[10px] px-1.5 py-0.5 h-auto shadow-none rounded-lg"
+ >
+ {t('common:auth.login')}
+
+
+ )
+}
\ No newline at end of file
diff --git a/react/src/components/canvas/ProjectDeleteDialog.tsx b/react/src/components/canvas/ProjectDeleteDialog.tsx
new file mode 100644
index 000000000..6699026c7
--- /dev/null
+++ b/react/src/components/canvas/ProjectDeleteDialog.tsx
@@ -0,0 +1,99 @@
+import CommonDialogContent from '@/components/common/DialogContent'
+import { Button } from '@/components/ui/button'
+import {
+ Dialog,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import { AlertTriangle, Trash2 } from 'lucide-react'
+import { useTranslation } from 'react-i18next'
+
+type ProjectDeleteDialogProps = {
+ isOpen: boolean
+ onClose: () => void
+ onConfirm: () => void
+ isDeleting?: boolean
+ projectName?: string
+}
+
+const ProjectDeleteDialog: React.FC = ({
+ isOpen,
+ onClose,
+ onConfirm,
+ isDeleting = false,
+ projectName
+}) => {
+ const { t } = useTranslation()
+
+ const handleConfirm = () => {
+ onConfirm()
+ onClose()
+ }
+
+ return (
+
+ )
+}
+
+export default ProjectDeleteDialog
\ No newline at end of file
diff --git a/react/src/components/canvas/SessionHistoryDropdown.tsx b/react/src/components/canvas/SessionHistoryDropdown.tsx
new file mode 100644
index 000000000..8364c5c6b
--- /dev/null
+++ b/react/src/components/canvas/SessionHistoryDropdown.tsx
@@ -0,0 +1,112 @@
+import { Button } from '@/components/ui/button'
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu'
+import { Session } from '@/types/types'
+import { History, MessageSquare, Clock, Plus } from 'lucide-react'
+import { useTranslation } from 'react-i18next'
+import { getSessionDisplayName } from '@/utils/sessionUtils'
+import { useLanguage } from '@/hooks/use-language'
+
+interface SessionHistoryDropdownProps {
+ sessionList: Session[]
+ currentSessionId: string
+ onSessionSelect: (sessionId: string) => void
+ onNewSession: () => void
+}
+
+export function SessionHistoryDropdown({
+ sessionList,
+ currentSessionId,
+ onSessionSelect,
+ onNewSession
+}: SessionHistoryDropdownProps) {
+ const { t } = useTranslation(['chat', 'common'])
+ const { currentLanguage } = useLanguage()
+
+ // 格式化创建时间
+ const formatCreatedTime = (session: Session) => {
+ try {
+ const date = new Date(session.created_at)
+ return date.toLocaleDateString(currentLanguage === 'zh-CN' ? 'zh-CN' : 'en-US', {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit'
+ })
+ } catch {
+ return ''
+ }
+ }
+
+ // 排序会话:按创建时间倒序
+ const sortedSessions = [...sessionList].sort((a, b) => {
+ const timeA = new Date(a.created_at).getTime()
+ const timeB = new Date(b.created_at).getTime()
+ return timeB - timeA
+ })
+
+ return (
+
+
+
+
+
+
+
+ {/* Header */}
+
+
{t('chat:sessionHistory.historyTitle')}
+
+
+ {/* Session List */}
+
+ {sortedSessions.length > 0 ? (
+ sortedSessions.map((session, index) => (
+
onSessionSelect(session.id)}
+ className={`px-6 py-4 cursor-pointer transition-all duration-150 hover:bg-gray-50 border-b border-gray-100/50 last:border-b-0 ${
+ session.id === currentSessionId
+ ? 'bg-blue-50 border-r-3 border-blue-500'
+ : ''
+ }`}
+ >
+
+ {getSessionDisplayName(session, sessionList)}
+
+
+ {formatCreatedTime(session)}
+
+
+ ))
+ ) : (
+
+
+
+
+
{t('chat:sessionHistory.noSessionsYet')}
+
{t('chat:sessionHistory.startNewConversation')}
+
+ )}
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/react/src/components/canvas/menu/CanvasToolMenu.tsx b/react/src/components/canvas/menu/CanvasToolMenu.tsx
index c88ac7e99..ade0de412 100644
--- a/react/src/components/canvas/menu/CanvasToolMenu.tsx
+++ b/react/src/components/canvas/menu/CanvasToolMenu.tsx
@@ -32,7 +32,7 @@ const CanvasToolMenu = () => {
]
return (
-
+
{tools.map((tool, index) =>
tool ? (
{
return (
handleZoomChange(currentZoom - 10)}
@@ -58,7 +58,7 @@ const CanvasViewMenu = () => {
- {currentZoom}%
+ {currentZoom}%
{[10, 50, 100, 150, 200].map((zoom) => (
@@ -73,7 +73,7 @@ const CanvasViewMenu = () => {
handleZoomChange(currentZoom + 10)}
diff --git a/react/src/components/canvas/pop-bar/CanvasMagicGenerator.tsx b/react/src/components/canvas/pop-bar/CanvasMagicGenerator.tsx
index ee330fc2b..896c7f31f 100644
--- a/react/src/components/canvas/pop-bar/CanvasMagicGenerator.tsx
+++ b/react/src/components/canvas/pop-bar/CanvasMagicGenerator.tsx
@@ -4,72 +4,391 @@ import { useCanvas } from '@/contexts/canvas'
import { eventBus, TCanvasAddImagesToChatEvent } from '@/lib/event'
import { useKeyPress } from 'ahooks'
import { motion } from 'motion/react'
-import { memo } from 'react'
+import { memo, useState, useRef } from 'react'
import { useTranslation } from 'react-i18next'
-import { exportToCanvas } from "@excalidraw/excalidraw";
+import { exportToCanvas, exportToBlob, exportToSvg } from '@excalidraw/excalidraw'
import { OrderedExcalidrawElement } from '@excalidraw/excalidraw/element/types'
import { toast } from 'sonner'
+import { useUserInfo } from '@/hooks/use-user-info'
+import { processRemoteImage } from '@/utils/remoteImageProcessor'
type CanvasMagicGeneratorProps = {
- selectedImages: TCanvasAddImagesToChatEvent
- selectedElements: OrderedExcalidrawElement[]
+ selectedImages: TCanvasAddImagesToChatEvent
+ selectedElements: OrderedExcalidrawElement[]
}
const CanvasMagicGenerator = ({ selectedImages, selectedElements }: CanvasMagicGeneratorProps) => {
- const { t } = useTranslation()
- const { excalidrawAPI } = useCanvas()
-
- const handleMagicGenerate = async () => {
- if (!excalidrawAPI) return;
-
- // 获取选中的元素
- const appState = excalidrawAPI.getAppState();
- const selectedIds = appState.selectedElementIds;
- if (Object.keys(selectedIds).length === 0) {
- console.log('没有选中任何元素');
- return;
+ const { t } = useTranslation()
+ const { excalidrawAPI } = useCanvas()
+ const { userInfo } = useUserInfo()
+
+ // 防重复机制
+ const [isGenerating, setIsGenerating] = useState(false)
+ const lastGenerateTimeRef = useRef(0)
+
+ const handleMagicGenerate = async () => {
+ console.log('[CanvasMagicGenerator] 开始Magic Generation流程...')
+
+ // 防重复检查 - 防止短时间内重复点击
+ const currentTime = Date.now()
+ const timeDiff = currentTime - lastGenerateTimeRef.current
+
+ if (isGenerating) {
+ console.warn('[CanvasMagicGenerator] 正在生成中,忽略重复请求')
+ toast.warning('正在生成中,请稍候...')
+ return
+ }
+
+ if (timeDiff < 2000) { // 2秒内不允许重复点击
+ console.warn('[CanvasMagicGenerator] 点击过于频繁,忽略请求')
+ toast.warning('请不要频繁点击')
+ return
+ }
+
+ // 更新状态和时间戳
+ setIsGenerating(true)
+ lastGenerateTimeRef.current = currentTime
+
+ if (!excalidrawAPI) {
+ console.error('[CanvasMagicGenerator] excalidrawAPI不可用')
+ toast.error('Canvas API不可用,请刷新页面重试')
+ setIsGenerating(false)
+ return
+ }
+
+ try {
+ // 获取选中的元素
+ console.log('[CanvasMagicGenerator] 获取选中的元素...')
+ const appState = excalidrawAPI.getAppState()
+ const selectedIds = appState.selectedElementIds
+ console.log('[CanvasMagicGenerator] 选中的元素ID:', selectedIds)
+
+ if (Object.keys(selectedIds).length === 0) {
+ console.warn('[CanvasMagicGenerator] 没有选中任何元素')
+ toast.error('请先选中要生成的元素')
+ setIsGenerating(false)
+ return
+ }
+
+ const files = excalidrawAPI.getFiles()
+ console.log('[CanvasMagicGenerator] 获取到的文件:', Object.keys(files).length, '个')
+ console.log('[CanvasMagicGenerator] 选中的元素数量:', selectedElements.length)
+
+ // 详细分析files对象结构
+ const fileIds = Object.keys(files)
+ fileIds.forEach((fileId, index) => {
+ const file = files[fileId]
+ console.log(`[CanvasMagicGenerator] 文件${index + 1} (${fileId}):`, {
+ type: typeof file,
+ isDataURL: file && typeof file === 'object' && 'dataURL' in file,
+ hasUrl: file && typeof file === 'object' && 'url' in file,
+ keys: file && typeof file === 'object' ? Object.keys(file) : 'not object',
+ dataURLPreview:
+ file && file.dataURL ? file.dataURL.substring(0, 50) + '...' : 'no dataURL',
+ })
+
+ // 检查是否是远程URL
+ if (file && file.dataURL && file.dataURL.startsWith('http')) {
+ console.log(`[CanvasMagicGenerator] ⚠️ 检测到远程图片URL: ${file.dataURL}`)
}
+ })
+
+ // 分析选中的图片元素
+ const imageElements = selectedElements.filter((element) => element.type === 'image')
+ imageElements.forEach((element, index) => {
+ console.log(`[CanvasMagicGenerator] 图片元素${index + 1}:`, {
+ id: element.id,
+ fileId: element.fileId,
+ width: element.width,
+ height: element.height,
+ hasFileId: !!element.fileId,
+ fileExists: element.fileId ? !!files[element.fileId] : false,
+ })
+ })
- const files = excalidrawAPI.getFiles();
+ // 检查是否包含图片元素(可能导致Canvas污染)
+ const hasImages = selectedElements.some((element) => element.type === 'image')
+ console.log('[CanvasMagicGenerator] Canvas安全检测:', {
+ hasImages,
+ imageElementsCount: imageElements.length,
+ fileCount: fileIds.length,
+ fileIds: fileIds.slice(0, 3), // 只显示前3个文件ID
+ })
- // 使用官方SDK导出canvas
- const canvas = await exportToCanvas({
+
+ // 预处理所有远程图片
+ const processedFiles = { ...files }
+ const remoteFileIds = fileIds.filter((fileId) => {
+ const file = files[fileId]
+ return file && file.dataURL && file.dataURL.startsWith('http')
+ })
+
+ if (remoteFileIds.length > 0) {
+ console.log(`[CanvasMagicGenerator] 检测到 ${remoteFileIds.length} 个远程图片,开始智能处理...`)
+
+ // 首先快速检查哪些文件真的需要下载
+ const filesToDownload: string[] = []
+
+ for (const fileId of remoteFileIds) {
+ const file = files[fileId]
+ const { extractFileIdentifier, checkLocalFile } = await import('@/utils/remoteImageProcessor')
+ const filename = extractFileIdentifier(file.dataURL)
+ const localUrl = await checkLocalFile(filename, userInfo)
+
+ if (!localUrl) {
+ filesToDownload.push(fileId)
+ } else {
+ console.log(`[CanvasMagicGenerator] 文件已存在本地,直接使用: ${filename}`)
+ // 直接从本地URL获取数据
+ try {
+ const response = await fetch(localUrl, { credentials: 'include' })
+ const blob = await response.blob()
+ const reader = new FileReader()
+ const dataURL = await new Promise((resolve, reject) => {
+ reader.onload = () => resolve(reader.result as string)
+ reader.onerror = reject
+ reader.readAsDataURL(blob)
+ })
+
+ processedFiles[fileId] = {
+ ...file,
+ dataURL: dataURL as typeof file.dataURL,
+ }
+ } catch (error) {
+ console.warn(`[CanvasMagicGenerator] 读取本地文件失败,将重新下载: ${filename}`, error)
+ filesToDownload.push(fileId)
+ }
+ }
+ }
+
+ // 只有真正需要下载的文件才显示下载提示
+ if (filesToDownload.length > 0) {
+ console.log(`[CanvasMagicGenerator] 需要下载 ${filesToDownload.length} 个远程图片`)
+ toast.loading(`正在下载 ${filesToDownload.length} 个远程图片...`, {
+ id: 'download-images',
+ })
+
+ try {
+ for (const fileId of filesToDownload) {
+ const file = files[fileId]
+ const localDataURL = await processRemoteImage(file.dataURL, userInfo)
+ processedFiles[fileId] = {
+ ...file,
+ dataURL: localDataURL as typeof file.dataURL,
+ }
+ console.log(`[CanvasMagicGenerator] 远程图片已转换为本地: ${fileId}`)
+ }
+
+ console.log(`[CanvasMagicGenerator] 所有需要的图片已准备完成,开始导出Canvas`)
+ } catch (error) {
+ console.error(`[CanvasMagicGenerator] 批量下载远程图片失败:`, error)
+ toast.error(`图片下载失败: ${error instanceof Error ? error.message : '未知错误'}`, {
+ id: 'download-images',
+ })
+ setIsGenerating(false)
+ return
+ }
+ } else {
+ console.log(`[CanvasMagicGenerator] 所有图片都已存在本地,无需下载`)
+ }
+ } else {
+ console.log(`[CanvasMagicGenerator] 未检测到远程图片,直接进行Canvas导出`)
+ }
+
+ let base64: string
+ let width: number
+ let height: number
+
+ if (hasImages && fileIds.length > 0) {
+ // 有图片时使用更安全的Blob导出方案
+ console.log('[CanvasMagicGenerator] 检测到图片元素,使用Blob导出方案...')
+
+ try {
+ const blob = await exportToBlob({
elements: selectedElements,
appState: {
- ...appState,
- selectedElementIds: selectedIds,
+ ...appState,
+ selectedElementIds: selectedIds,
},
- files,
+ files: processedFiles, // 使用处理后的files对象
mimeType: 'image/png',
- maxWidthOrHeight: 2048,
- quality: 1,
- });
-
- // 转base64
- const base64 = canvas.toDataURL('image/png', 0.8);
-
- // 发送魔法生成事件
- eventBus.emit('Canvas::MagicGenerate', {
- fileId: `magic-${Date.now()}`,
- base64: base64,
- width: canvas.width,
- height: canvas.height,
- timestamp: new Date().toISOString(),
- });
-
- // 清除选中状态
- excalidrawAPI?.updateScene({
- appState: { selectedElementIds: {} },
+ quality: 0.8,
+ exportPadding: 10,
+ })
+ console.log('[CanvasMagicGenerator] Blob导出成功,大小:', blob.size, 'bytes')
+
+ // 将Blob转换为base64
+ base64 = await new Promise((resolve, reject) => {
+ const reader = new FileReader()
+ reader.onload = () => resolve(reader.result as string)
+ reader.onerror = reject
+ reader.readAsDataURL(blob)
+ })
+ console.log('[CanvasMagicGenerator] Blob转base64成功,长度:', base64.length)
+
+ // 通过创建临时image获取尺寸
+ const tempImg = new Image()
+ const imgLoad = new Promise((resolve, reject) => {
+ tempImg.onload = () => resolve()
+ tempImg.onerror = reject
+ tempImg.src = base64
+ })
+
+ await imgLoad
+ width = tempImg.width
+ height = tempImg.height
+ console.log('[CanvasMagicGenerator] 图片尺寸:', width, 'x', height)
+ } catch (blobError) {
+ console.warn('[CanvasMagicGenerator] Blob导出失败,尝试SVG转PNG方案:', blobError)
+
+ // Blob失败时尝试SVG导出并转换为PNG
+ const svgString = await exportToSvg({
+ elements: selectedElements,
+ appState: {
+ ...appState,
+ selectedElementIds: selectedIds,
+ },
+ files: processedFiles, // 使用处理后的files对象
+ exportPadding: 10,
+ })
+ console.log('[CanvasMagicGenerator] SVG导出成功,长度:', svgString.outerHTML.length)
+
+ // SVG转PNG(通过Canvas)
+ const svgWidth = parseInt(svgString.getAttribute('width') || '800')
+ const svgHeight = parseInt(svgString.getAttribute('height') || '600')
+
+ // 创建临时Canvas将SVG转换为PNG
+ const tempCanvas = document.createElement('canvas')
+ const ctx = tempCanvas.getContext('2d')
+ tempCanvas.width = svgWidth
+ tempCanvas.height = svgHeight
+
+ const img = new Image()
+ const svgBlob = new Blob([svgString.outerHTML], { type: 'image/svg+xml' })
+ const svgUrl = URL.createObjectURL(svgBlob)
+
+ await new Promise((resolve, reject) => {
+ img.onload = () => {
+ if (ctx) {
+ ctx.drawImage(img, 0, 0)
+ }
+ URL.revokeObjectURL(svgUrl)
+ resolve()
+ }
+ img.onerror = () => {
+ URL.revokeObjectURL(svgUrl)
+ reject(new Error('SVG to PNG conversion failed'))
+ }
+ img.src = svgUrl
+ })
+
+ // 转换为PNG base64
+ base64 = tempCanvas.toDataURL('image/png', 0.8)
+ width = svgWidth
+ height = svgHeight
+ console.log('[CanvasMagicGenerator] SVG转PNG成功,尺寸:', width, 'x', height)
+ }
+ } else {
+ // 没有图片时使用原来的Canvas导出方案
+ console.log('[CanvasMagicGenerator] 无图片元素,使用Canvas导出方案...')
+
+ const canvas = await exportToCanvas({
+ elements: selectedElements,
+ appState: {
+ ...appState,
+ selectedElementIds: selectedIds,
+ },
+ files: processedFiles, // 使用处理后的files对象
+ mimeType: 'image/png',
+ maxWidthOrHeight: 2048,
+ quality: 1,
})
+ console.log(
+ '[CanvasMagicGenerator] Canvas导出成功,尺寸:',
+ canvas.width,
+ 'x',
+ canvas.height
+ )
+
+ try {
+ base64 = canvas.toDataURL('image/png', 0.8)
+ width = canvas.width
+ height = canvas.height
+ console.log('[CanvasMagicGenerator] Canvas toDataURL成功,长度:', base64.length)
+ } catch (canvasError) {
+ console.error('[CanvasMagicGenerator] Canvas被污染,fallback到Blob方案:', canvasError)
+
+ // Canvas被污染时fallback到Blob
+ const blob = await exportToBlob({
+ elements: selectedElements,
+ appState: {
+ ...appState,
+ selectedElementIds: selectedIds,
+ },
+ files: processedFiles, // 使用处理后的files对象
+ mimeType: 'image/png',
+ quality: 0.8,
+ exportPadding: 10,
+ })
+
+ base64 = await new Promise((resolve, reject) => {
+ const reader = new FileReader()
+ reader.onload = () => resolve(reader.result as string)
+ reader.onerror = reject
+ reader.readAsDataURL(blob)
+ })
+ width = canvas.width
+ height = canvas.height
+ console.log('[CanvasMagicGenerator] Fallback Blob转换成功')
+ }
+ }
+
+ // 发送魔法生成事件
+ const eventData = {
+ fileId: `magic-${Date.now()}`,
+ base64: base64,
+ width: width,
+ height: height,
+ timestamp: new Date().toISOString(),
+ }
+ console.log(
+ '[CanvasMagicGenerator] 准备发送事件:',
+ eventData.fileId,
+ '尺寸:',
+ width,
+ 'x',
+ height
+ )
+
+ eventBus.emit('Canvas::MagicGenerate', eventData)
+ console.log('[CanvasMagicGenerator] 事件发送成功')
+
+ // 清除选中状态
+ excalidrawAPI?.updateScene({
+ appState: { selectedElementIds: {} },
+ })
+ console.log('[CanvasMagicGenerator] 清除选中状态完成')
+ } catch (error) {
+ console.error('[CanvasMagicGenerator] Magic Generation过程中发生错误:', error)
+ console.error(
+ '[CanvasMagicGenerator] 错误堆栈:',
+ error instanceof Error ? error.stack : '无堆栈信息'
+ )
+ toast.error('Magic Generation失败: ' + (error instanceof Error ? error.message : '未知错误'))
+ } finally {
+ // 无论成功还是失败,都要重置生成状态
+ setIsGenerating(false)
+ console.log('[CanvasMagicGenerator] 重置生成状态')
}
+ }
- useKeyPress(['meta.b', 'ctrl.b'], handleMagicGenerate)
+ useKeyPress(['meta.b', 'ctrl.b'], handleMagicGenerate)
- return (
-
- {t('canvas:popbar.magicGenerate')}
-
- )
+ return (
+
+ {isGenerating ? '生成中...' : t('canvas:popbar.magicGenerate')}
+
+ )
}
export default memo(CanvasMagicGenerator)
diff --git a/react/src/components/canvas/pop-bar/CanvasPopbarContainer.tsx b/react/src/components/canvas/pop-bar/CanvasPopbarContainer.tsx
index 947d98b09..027337f6b 100644
--- a/react/src/components/canvas/pop-bar/CanvasPopbarContainer.tsx
+++ b/react/src/components/canvas/pop-bar/CanvasPopbarContainer.tsx
@@ -34,7 +34,7 @@ const CanvasPopbarContainer = ({
top: `${pos.y + 5}px`,
}}
>
-
+
{showAddToChat && (
)}
diff --git a/react/src/components/canvas/pop-bar/index.tsx b/react/src/components/canvas/pop-bar/index.tsx
index cc2221d9e..c7952b26b 100644
--- a/react/src/components/canvas/pop-bar/index.tsx
+++ b/react/src/components/canvas/pop-bar/index.tsx
@@ -51,7 +51,14 @@ const CanvasPopbarWrapper = () => {
.map((image) => {
const file = files[image.fileId!]
const isBase64 = file.dataURL.startsWith('data:')
- const id = isBase64 ? file.id : file.dataURL.split('/').at(-1)!
+ let id: string
+ if (isBase64) {
+ id = file.id
+ } else {
+ // 从URL中提取文件名,去掉查询参数
+ const urlPath = file.dataURL.split('?')[0] // 去掉查询参数
+ id = urlPath.split('/').at(-1)! // 提取文件名
+ }
return {
fileId: id,
base64: isBase64 ? file.dataURL : undefined,
diff --git a/react/src/components/chat/Chat.tsx b/react/src/components/chat/Chat.tsx
index 7205afc7b..142216f3d 100644
--- a/react/src/components/chat/Chat.tsx
+++ b/react/src/components/chat/Chat.tsx
@@ -3,25 +3,12 @@ import Blur from '@/components/common/Blur'
import { ScrollArea } from '@/components/ui/scroll-area'
import { eventBus, TEvents } from '@/lib/event'
import ChatMagicGenerator from './ChatMagicGenerator'
-import {
- AssistantMessage,
- Message,
- Model,
- PendingType,
- Session,
-} from '@/types/types'
+import { AssistantMessage, Message, Model, PendingType, Session } from '@/types/types'
import { useSearch } from '@tanstack/react-router'
import { produce } from 'immer'
import { motion } from 'motion/react'
import { nanoid } from 'nanoid'
-import {
- Dispatch,
- SetStateAction,
- useCallback,
- useEffect,
- useRef,
- useState,
-} from 'react'
+import { Dispatch, SetStateAction, useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { PhotoProvider } from 'react-photo-view'
import { toast } from 'sonner'
@@ -34,6 +21,7 @@ import SessionSelector from './SessionSelector'
import ChatSpinner from './Spinner'
import ToolcallProgressUpdate from './ToolcallProgressUpdate'
import ShareTemplateDialog from './ShareTemplateDialog'
+import { generateChatSessionTitle } from '@/utils/formatDate'
import { useConfigs } from '@/contexts/configs'
import 'react-photo-view/dist/react-photo-view.css'
@@ -44,7 +32,7 @@ import { Share2 } from 'lucide-react'
import { useAuth } from '@/contexts/AuthContext'
import { useQueryClient } from '@tanstack/react-query'
import MixedContent, { MixedContentImages, MixedContentText } from './Message/MixedContent'
-
+import Timestamp from './Message/Timestamp'
type ChatInterfaceProps = {
canvasId: string
@@ -59,13 +47,34 @@ const ChatInterface: React.FC
= ({
setSessionList,
sessionId: searchSessionId,
}) => {
- const { t } = useTranslation()
+ const { t } = useTranslation(['chat', 'common'])
const [session, setSession] = useState(null)
- const { initCanvas, setInitCanvas } = useConfigs()
+ const { initCanvas, setInitCanvas, textModel } = useConfigs()
const { authStatus } = useAuth()
const [showShareDialog, setShowShareDialog] = useState(false)
const queryClient = useQueryClient()
+ const [messages, setMessages] = useState([])
+ const [pending, setPending] = useState(false) // 不再基于initCanvas设置初始状态
+ const [hasDisplayedInitialMessage, setHasDisplayedInitialMessage] = useState(false)
+
+ const mergedToolCallIds = useRef([])
+ const pendingTimeoutRef = useRef(undefined)
+ const hasDisplayedInitialMessageRef = useRef(false)
+ const currentMessagesRef = useRef([])
+ const isNewSessionRef = useRef(false) // 🔥 新增:标记是否为新建session
+
+ const sessionId = session?.id ?? searchSessionId
+
+ // 同步状态到ref
+ useEffect(() => {
+ hasDisplayedInitialMessageRef.current = hasDisplayedInitialMessage
+ }, [hasDisplayedInitialMessage])
+
+ useEffect(() => {
+ currentMessagesRef.current = messages
+ }, [messages])
+
useEffect(() => {
if (sessionList.length > 0) {
let _session = null
@@ -80,46 +89,326 @@ const ChatInterface: React.FC = ({
}
}, [sessionList, searchSessionId])
- const [messages, setMessages] = useState([])
- const [pending, setPending] = useState(
- initCanvas ? 'text' : false
- )
- const mergedToolCallIds = useRef([])
-
- const sessionId = session?.id ?? searchSessionId
-
const sessionIdRef = useRef(session?.id || nanoid())
const [expandingToolCalls, setExpandingToolCalls] = useState([])
- const [pendingToolConfirmations, setPendingToolConfirmations] = useState<
- string[]
- >([])
+ const [pendingToolConfirmations, setPendingToolConfirmations] = useState([])
const scrollRef = useRef(null)
- const isAtBottomRef = useRef(false)
+ const isAtBottomRef = useRef(true) // 初始默认在底部
+ const isUserScrollingRef = useRef(false) // 跟踪用户是否在手动滚动
+
+ const checkIfAtBottom = useCallback(() => {
+ if (scrollRef.current) {
+ const { scrollTop, scrollHeight, clientHeight } = scrollRef.current
+ const threshold = 50 // 50px的阈值,更宽容的底部检测
+ const atBottom = scrollHeight - scrollTop - clientHeight < threshold
+ isAtBottomRef.current = atBottom
+ return atBottom
+ }
+ return false
+ }, [])
const scrollToBottom = useCallback(() => {
- if (!isAtBottomRef.current) {
- return
+ // 只有在用户在底部或者是新消息时才自动滚动
+ if (isAtBottomRef.current && !isUserScrollingRef.current) {
+ setTimeout(() => {
+ if (scrollRef.current) {
+ scrollRef.current.scrollTo({
+ top: scrollRef.current.scrollHeight,
+ behavior: 'smooth',
+ })
+ }
+ }, 100) // 减少延迟以提供更好的响应性
}
+ }, [])
+
+ const forceScrollToBottom = useCallback(() => {
+ // 强制滚动到底部,用于用户发送消息时
setTimeout(() => {
- scrollRef.current?.scrollTo({
- top: scrollRef.current!.scrollHeight,
- behavior: 'smooth',
+ if (scrollRef.current) {
+ scrollRef.current.scrollTo({
+ top: scrollRef.current.scrollHeight,
+ behavior: 'smooth',
+ })
+ isAtBottomRef.current = true
+ }
+ }, 100)
+ }, [])
+
+ // 立即检查并显示初始用户消息 - 组件挂载时就检查
+ useEffect(() => {
+ const checkAndDisplayInitialMessage = () => {
+ const initialMessageData = localStorage.getItem('initial_user_message')
+ console.log('🔍 检查初始用户消息', {
+ initialMessageData: !!initialMessageData,
+ hasDisplayedInitialMessage,
+ searchSessionId
})
- }, 200)
+
+ if (initialMessageData && !hasDisplayedInitialMessage) {
+ try {
+ const { sessionId: storedSessionId, message, timestamp, canvasId } = JSON.parse(initialMessageData)
+ console.log('📄 解析初始消息数据', {
+ storedSessionId,
+ searchSessionId,
+ canvasId,
+ messageContent: message?.content?.length > 0 ? '有内容' : '无内容',
+ timestamp: new Date(timestamp).toLocaleString()
+ })
+
+ // 检查timestamp是否在5分钟内
+ const isWithinTimeLimit = Date.now() - timestamp < 5 * 60 * 1000
+ console.log('⏰ 时间检查', {
+ isWithinTimeLimit,
+ timeDiff: Math.floor((Date.now() - timestamp) / 1000) + '秒'
+ })
+
+ if (isWithinTimeLimit) {
+ // 🔧 放宽sessionId匹配条件:
+ // 1. 如果存储的sessionId和当前的sessionId匹配
+ // 2. 或者还没有searchSessionId(刚跳转过来)
+ // 3. 或者是同一个canvas下的消息(即使session不同)
+ const shouldDisplayMessage = (
+ !searchSessionId ||
+ storedSessionId === searchSessionId ||
+ (canvasId && window.location.pathname.includes(canvasId))
+ )
+
+ console.log('🎯 SessionId匹配检查', {
+ shouldDisplayMessage,
+ 条件1_无当前SessionId: !searchSessionId,
+ 条件2_SessionId匹配: storedSessionId === searchSessionId,
+ 条件3_同一Canvas: canvasId && window.location.pathname.includes(canvasId)
+ })
+
+ if (shouldDisplayMessage) {
+ console.log('✅ 显示初始用户消息')
+ setMessages([message])
+ setHasDisplayedInitialMessage(true)
+
+ // 延迟显示等待状态,让用户先看到自己的消息
+ pendingTimeoutRef.current = setTimeout(() => {
+ console.log('⏳ 设置pending状态为text')
+ setPending('text')
+ }, 300)
+
+ // 多次尝试滚动确保成功
+ setTimeout(() => forceScrollToBottom(), 50)
+ setTimeout(() => forceScrollToBottom(), 200)
+ setTimeout(() => forceScrollToBottom(), 500)
+
+ // 延迟清除localStorage,给后端推送时间
+ setTimeout(() => {
+ console.log('🗑️ 清除localStorage中的初始消息')
+ localStorage.removeItem('initial_user_message')
+ }, 2000)
+ return true
+ } else {
+ console.log('❌ SessionId不匹配,不显示消息')
+ }
+ } else {
+ console.log('⏰ 消息已过期,清除localStorage')
+ localStorage.removeItem('initial_user_message')
+ }
+ } catch (error) {
+ console.error('❌ 解析初始消息失败', error)
+ localStorage.removeItem('initial_user_message')
+ }
+ }
+ return false
+ }
+
+ // 立即检查一次
+ const displayed = checkAndDisplayInitialMessage()
+
+ // 如果没有显示,等待一小段时间再检查一次(防止sessionId延迟)
+ if (!displayed && !hasDisplayedInitialMessage) {
+ const timeoutId = setTimeout(() => {
+ console.log('🔄 延迟重新检查初始消息')
+ checkAndDisplayInitialMessage()
+ }, 200)
+
+ return () => clearTimeout(timeoutId)
+ }
+ }, [searchSessionId, hasDisplayedInitialMessage, forceScrollToBottom])
+
+ // 当sessionId变化时也检查一次(兜底逻辑)
+ useEffect(() => {
+ if (!hasDisplayedInitialMessage && sessionId) {
+ const initialMessageData = localStorage.getItem('initial_user_message')
+ console.log('🔄 SessionId变化时检查初始消息', {
+ sessionId,
+ hasInitialMessage: !!initialMessageData,
+ hasDisplayedInitialMessage
+ })
+
+ if (initialMessageData) {
+ try {
+ const { sessionId: storedSessionId, message, timestamp, canvasId } = JSON.parse(initialMessageData)
+ console.log('📄 SessionId变化时解析数据', {
+ storedSessionId,
+ currentSessionId: sessionId,
+ canvasId,
+ timeDiff: Math.floor((Date.now() - timestamp) / 1000) + '秒'
+ })
+
+ // 🔧 同样放宽匹配条件
+ const isWithinTimeLimit = Date.now() - timestamp < 5 * 60 * 1000
+ const shouldDisplayMessage = (
+ storedSessionId === sessionId ||
+ (canvasId && window.location.pathname.includes(canvasId))
+ )
+
+ console.log('🎯 SessionId变化时匹配检查', {
+ isWithinTimeLimit,
+ shouldDisplayMessage,
+ sessionMatch: storedSessionId === sessionId,
+ canvasMatch: canvasId && window.location.pathname.includes(canvasId)
+ })
+
+ if (shouldDisplayMessage && isWithinTimeLimit) {
+ console.log('✅ SessionId变化时显示初始消息')
+ setMessages([message])
+ setHasDisplayedInitialMessage(true)
+
+ // 延迟显示等待状态,让用户先看到自己的消息
+ pendingTimeoutRef.current = setTimeout(() => {
+ console.log('⏳ SessionId变化时设置pending状态')
+ setPending('text')
+ }, 300)
+
+ // 多次尝试滚动确保成功
+ setTimeout(() => forceScrollToBottom(), 50)
+ setTimeout(() => forceScrollToBottom(), 200)
+
+ // 延迟清除localStorage,给后端推送时间
+ setTimeout(() => {
+ console.log('🗑️ SessionId变化时清除localStorage')
+ localStorage.removeItem('initial_user_message')
+ }, 2000)
+ }
+ } catch (error) {
+ console.error('❌ SessionId变化时解析失败', error)
+ setTimeout(() => {
+ localStorage.removeItem('initial_user_message')
+ }, 1000)
+ }
+ }
+ }
+ }, [sessionId, hasDisplayedInitialMessage, forceScrollToBottom])
+
+ // 🔧 增加兜底检查 - 如果前面的逻辑都没有显示消息,则更积极地尝试
+ useEffect(() => {
+ if (!hasDisplayedInitialMessage) {
+ const timeoutId = setTimeout(() => {
+ const initialMessageData = localStorage.getItem('initial_user_message')
+ if (initialMessageData) {
+ try {
+ const { message, timestamp } = JSON.parse(initialMessageData)
+
+ // 如果消息还在有效期内,无论sessionId如何,都显示
+ if (Date.now() - timestamp < 30 * 1000) { // 30秒内的消息
+ console.log('🚨 兜底显示初始消息(忽略sessionId检查)')
+ setMessages([message])
+ setHasDisplayedInitialMessage(true)
+
+ pendingTimeoutRef.current = setTimeout(() => {
+ console.log('⏳ 兜底设置pending状态')
+ setPending('text')
+ }, 300)
+
+ setTimeout(() => forceScrollToBottom(), 100)
+ setTimeout(() => forceScrollToBottom(), 300)
+
+ setTimeout(() => {
+ localStorage.removeItem('initial_user_message')
+ }, 2000)
+ }
+ } catch (error) {
+ console.error('❌ 兜底解析失败', error)
+ }
+ }
+ }, 1000) // 1秒后检查
+
+ return () => clearTimeout(timeoutId)
+ }
+ }, [hasDisplayedInitialMessage, forceScrollToBottom])
+
+ // 监听messages变化,确保用户消息显示后立即滚动
+ useEffect(() => {
+ if (messages.length > 0 && hasDisplayedInitialMessage) {
+ // 延迟一点确保DOM已更新
+ setTimeout(() => {
+ forceScrollToBottom()
+ }, 100)
+ }
+ }, [messages, hasDisplayedInitialMessage, forceScrollToBottom])
+
+ // 监听pending状态变化,确保"Thinking..."出现时滚动到底部
+ useEffect(() => {
+ if (pending) {
+ // 立即滚动一次
+ forceScrollToBottom()
+
+ // 延迟滚动确保ChatSpinner已经渲染
+ setTimeout(() => {
+ forceScrollToBottom()
+ }, 100)
+
+ // 再次延迟滚动确保完全显示
+ setTimeout(() => {
+ forceScrollToBottom()
+ }, 300)
+ }
+ }, [pending, forceScrollToBottom])
+
+ // 清理函数
+ useEffect(() => {
+ return () => {
+ if (pendingTimeoutRef.current) {
+ clearTimeout(pendingTimeoutRef.current)
+ }
+ }
}, [])
const mergeToolCallResult = (messages: Message[]) => {
- const messagesWithToolCallResult = messages.map((message, index) => {
+ // 修复:基于消息ID去重,而不是内容去重,避免误删相同内容的不同消息
+ const uniqueMessages = messages.filter((message, index, arr) => {
+ // 如果消息有message_id,基于ID去重
+ const messageWithId = message as Message & { message_id?: string }
+ if (messageWithId.message_id) {
+ const isDuplicate = arr.slice(0, index).some((prevMessage) => {
+ const prevMessageWithId = prevMessage as Message & { message_id?: string }
+ return prevMessageWithId.message_id === messageWithId.message_id
+ })
+ return !isDuplicate
+ }
+
+ // 对于没有message_id的消息(兼容旧数据),只对工具调用消息进行去重
+ if (message.role === 'tool') {
+ const toolMessage = message as Message & { tool_call_id?: string }
+ const isDuplicate = arr.slice(0, index).some((prevMessage) => {
+ const prevToolMessage = prevMessage as Message & { tool_call_id?: string }
+ return (
+ prevMessage.role === 'tool' &&
+ prevToolMessage.tool_call_id === toolMessage.tool_call_id &&
+ JSON.stringify(prevMessage.content) === JSON.stringify(message.content)
+ )
+ })
+ return !isDuplicate
+ }
+
+ // 用户消息和助手消息不进行内容去重,允许重复内容
+ return true
+ })
+
+ const messagesWithToolCallResult = uniqueMessages.map((message, index) => {
if (message.role === 'assistant' && message.tool_calls) {
for (const toolCall of message.tool_calls) {
// From the next message, find the tool call result
- for (let i = index + 1; i < messages.length; i++) {
- const nextMessage = messages[i]
- if (
- nextMessage.role === 'tool' &&
- nextMessage.tool_call_id === toolCall.id
- ) {
+ for (let i = index + 1; i < uniqueMessages.length; i++) {
+ const nextMessage = uniqueMessages[i]
+ if (nextMessage.role === 'tool' && nextMessage.tool_call_id === toolCall.id) {
toolCall.result = nextMessage.content
mergedToolCallIds.current.push(toolCall.id)
}
@@ -142,21 +431,26 @@ const ChatInterface: React.FC = ({
setMessages(
produce((prev) => {
const last = prev.at(-1)
- if (
- last?.role === 'assistant' &&
- last.content != null &&
- last.tool_calls == null
- ) {
+ // 确保只有当最后一条消息是assistant且没有tool_calls时才追加内容
+ if (last?.role === 'assistant' && last.content != null && !last.tool_calls) {
if (typeof last.content === 'string') {
last.content += data.text
} else if (
- last.content &&
- last.content.at(-1) &&
- last.content.at(-1)!.type === 'text'
+ Array.isArray(last.content) &&
+ last.content.length > 0 &&
+ last.content.at(-1)?.type === 'text'
) {
;(last.content.at(-1) as { text: string }).text += data.text
+ } else {
+ // 如果最后一条内容不是文本,添加新的文本内容
+ if (Array.isArray(last.content)) {
+ last.content.push({ type: 'text', text: data.text })
+ } else {
+ last.content = data.text
+ }
}
} else {
+ // 创建新的assistant消息
prev.push({
role: 'assistant',
content: data.text,
@@ -176,10 +470,7 @@ const ChatInterface: React.FC = ({
}
const existToolCall = messages.find(
- (m) =>
- m.role === 'assistant' &&
- m.tool_calls &&
- m.tool_calls.find((t) => t.id == data.id)
+ (m) => m.role === 'assistant' && m.tool_calls && m.tool_calls.find((t) => t.id == data.id)
)
if (existToolCall) {
@@ -188,7 +479,6 @@ const ChatInterface: React.FC = ({
setMessages(
produce((prev) => {
- console.log('👇tool_call event get', data)
setPending('tool')
prev.push({
role: 'assistant',
@@ -223,10 +513,7 @@ const ChatInterface: React.FC = ({
}
const existToolCall = messages.find(
- (m) =>
- m.role === 'assistant' &&
- m.tool_calls &&
- m.tool_calls.find((t) => t.id == data.id)
+ (m) => m.role === 'assistant' && m.tool_calls && m.tool_calls.find((t) => t.id == data.id)
)
if (existToolCall) {
@@ -315,7 +602,7 @@ const ChatInterface: React.FC = ({
msg.tool_calls.forEach((tc) => {
if (tc.id === data.id) {
// 添加取消状态标记
- tc.result = '工具调用已取消'
+ tc.result = t('chat:toolCall.cancelled')
}
})
}
@@ -323,7 +610,7 @@ const ChatInterface: React.FC = ({
})
)
},
- [sessionId]
+ [sessionId, t]
)
const handleToolCallArguments = useCallback(
@@ -337,15 +624,11 @@ const ChatInterface: React.FC = ({
setPending('tool')
const lastMessage = prev.find(
(m) =>
- m.role === 'assistant' &&
- m.tool_calls &&
- m.tool_calls.find((t) => t.id == data.id)
+ m.role === 'assistant' && m.tool_calls && m.tool_calls.find((t) => t.id == data.id)
) as AssistantMessage
if (lastMessage) {
- const toolCall = lastMessage.tool_calls!.find(
- (t) => t.id == data.id
- )
+ const toolCall = lastMessage.tool_calls!.find((t) => t.id == data.id)
if (toolCall) {
// 检查是否是待确认的工具调用,如果是则跳过参数追加
if (pendingToolConfirmations.includes(data.id)) {
@@ -389,18 +672,85 @@ const ChatInterface: React.FC = ({
const handleImageGenerated = useCallback(
(data: TEvents['Socket::Session::ImageGenerated']) => {
- if (
- data.canvas_id &&
- data.canvas_id !== canvasId &&
- data.session_id !== sessionId
- ) {
+ if (data.canvas_id && data.canvas_id !== canvasId && data.session_id !== sessionId) {
return
}
console.log('⭐️dispatching image_generated', data)
- setPending('image')
+
+ // 添加图片消息到聊天记录
+ const imageMessage: Message = {
+ role: 'assistant',
+ content: [
+ {
+ type: 'text',
+ text: t('chat:generation.imageGenerated'),
+ },
+ {
+ type: 'image_url',
+ image_url: {
+ url: data.image_url,
+ },
+ },
+ ] as MessageContent[],
+ }
+
+ // 添加canvas定位信息到消息(用于点击定位功能)
+ const messageWithCanvasInfo = {
+ ...imageMessage,
+ canvas_element_id: data.element.id, // 添加canvas元素ID
+ canvas_id: data.canvas_id, // 添加canvas ID
+ }
+
+ setMessages(
+ produce((prev) => {
+ prev.push(messageWithCanvasInfo)
+ })
+ )
+
+ setPending(false) // 取消loading状态
+
+ // 立即滚动一次
+ forceScrollToBottom()
+
+ // 多次延迟滚动确保图片加载完成后正确显示
+ setTimeout(() => {
+ forceScrollToBottom()
+ }, 200)
+
+ setTimeout(() => {
+ forceScrollToBottom()
+ }, 600)
+
+ // 最后一次滚动确保图片完全可见
+ setTimeout(() => {
+ forceScrollToBottom()
+ }, 1200)
},
- [canvasId, sessionId]
+ [canvasId, sessionId, forceScrollToBottom, t]
+ )
+
+ const handleUserImages = useCallback(
+ (data: TEvents['Socket::Session::UserImages']) => {
+ if (data.session_id && data.session_id !== sessionId) {
+ return
+ }
+
+ console.log('📸 接收到用户图片', data.message)
+
+ // 将用户图片消息添加到消息列表
+ setMessages(
+ produce((prev) => {
+ prev.push({
+ role: 'user',
+ content: data.message.content,
+ })
+ })
+ )
+
+ scrollToBottom()
+ },
+ [sessionId, scrollToBottom]
)
const handleAllMessages = useCallback(
@@ -408,15 +758,41 @@ const ChatInterface: React.FC = ({
if (data.session_id && data.session_id !== sessionId) {
return
}
-
- setMessages(() => {
- console.log('👇all_messages', data.messages)
- return data.messages
+
+ console.log('🔍 [DEBUG] handleAllMessages called:', {
+ sessionId,
+ currentMessagesCount: messages.length,
+ newMessagesCount: data.messages.length,
+ hasDisplayedInitialMessage,
+ firstNewMessage: data.messages[0]?.role,
+ currentMessages: messages.map(m => ({ role: m.role, content: typeof m.content === 'string' ? m.content.slice(0, 50) : 'mixed' }))
})
- setMessages(mergeToolCallResult(data.messages))
+
+ const processedMessages = mergeToolCallResult(data.messages)
+
+ // 如果已经显示了初始用户消息,且后端消息为空,则不覆盖
+ if (hasDisplayedInitialMessage && processedMessages.length === 0 && messages.length > 0) {
+ console.log('🔍 [DEBUG] handleAllMessages: 保持当前消息,不覆盖空消息')
+ return
+ }
+
+ // 如果已显示初始消息,且后端消息不包含用户消息,则合并
+ if (hasDisplayedInitialMessage && messages.length > 0) {
+ const hasUserMessage = processedMessages.some((msg) => msg.role === 'user')
+ if (!hasUserMessage) {
+ const mergedMessages = [...messages, ...processedMessages]
+ console.log('🔍 [DEBUG] handleAllMessages: 合并消息,当前消息数:', messages.length, '新消息数:', processedMessages.length, '合并后:', mergedMessages.length)
+ setMessages(mergedMessages)
+ scrollToBottom()
+ return
+ }
+ }
+
+ console.log('🔍 [DEBUG] handleAllMessages: 完全替换消息列表,从', messages.length, '条消息到', processedMessages.length, '条消息')
+ setMessages(processedMessages)
scrollToBottom()
},
- [sessionId, scrollToBottom]
+ [sessionId, scrollToBottom, messages, hasDisplayedInitialMessage]
)
const handleDone = useCallback(
@@ -437,13 +813,51 @@ const ChatInterface: React.FC = ({
)
const handleError = useCallback((data: TEvents['Socket::Session::Error']) => {
- setPending(false)
- toast.error('Error: ' + data.error, {
- closeButton: true,
- duration: 3600 * 1000,
- style: { color: 'red' },
+ console.log('🚨 [Chat] 收到Socket错误事件:', {
+ error_code: data.error_code,
+ current_points: data.current_points,
+ required_points: data.required_points,
+ session_id: data.session_id,
+ current_session_id: sessionId,
+ error: data.error
})
- }, [])
+
+ setPending(false)
+
+ // 特别处理积分不足错误
+ if (data.error_code === 'insufficient_points') {
+ console.log('💰 [Chat] 处理积分不足错误')
+ if (data.current_points !== undefined && data.required_points !== undefined) {
+ console.log('📊 [Chat] 显示详细积分不足提示', {
+ current: data.current_points,
+ required: data.required_points
+ })
+ toast.error(t('common:toast.insufficientPointsWithDetails', {
+ current: data.current_points,
+ required: data.required_points
+ }), {
+ closeButton: true,
+ duration: 5000,
+ style: { color: 'red' },
+ })
+ } else {
+ console.log('📊 [Chat] 显示基本积分不足提示')
+ toast.error(t('common:toast.insufficientPoints'), {
+ closeButton: true,
+ duration: 5000,
+ style: { color: 'red' },
+ })
+ }
+ } else {
+ console.log('⚠️ [Chat] 处理其他类型错误:', data.error)
+ // 其他错误使用原有的显示方式
+ toast.error('Error: ' + data.error, {
+ closeButton: true,
+ duration: 3600 * 1000,
+ style: { color: 'red' },
+ })
+ }
+ }, [t, sessionId])
const handleInfo = useCallback((data: TEvents['Socket::Session::Info']) => {
toast.info(data.info, {
@@ -452,34 +866,45 @@ const ChatInterface: React.FC = ({
})
}, [])
+
useEffect(() => {
+ let scrollTimeout: NodeJS.Timeout
+
const handleScroll = () => {
- if (scrollRef.current) {
- isAtBottomRef.current =
- scrollRef.current.scrollHeight - scrollRef.current.scrollTop <=
- scrollRef.current.clientHeight + 1
- }
+ // 标记用户正在滚动
+ isUserScrollingRef.current = true
+
+ // 检查是否在底部
+ checkIfAtBottom()
+
+ // 清除之前的定时器
+ clearTimeout(scrollTimeout)
+
+ // 延迟重置滚动状态,给滚动动画时间完成
+ scrollTimeout = setTimeout(() => {
+ isUserScrollingRef.current = false
+ }, 150)
}
+
const scrollEl = scrollRef.current
- scrollEl?.addEventListener('scroll', handleScroll)
+ scrollEl?.addEventListener('scroll', handleScroll, { passive: true })
eventBus.on('Socket::Session::Delta', handleDelta)
eventBus.on('Socket::Session::ToolCall', handleToolCall)
- eventBus.on(
- 'Socket::Session::ToolCallPendingConfirmation',
- handleToolCallPendingConfirmation
- )
+ eventBus.on('Socket::Session::ToolCallPendingConfirmation', handleToolCallPendingConfirmation)
eventBus.on('Socket::Session::ToolCallConfirmed', handleToolCallConfirmed)
eventBus.on('Socket::Session::ToolCallCancelled', handleToolCallCancelled)
eventBus.on('Socket::Session::ToolCallArguments', handleToolCallArguments)
eventBus.on('Socket::Session::ToolCallResult', handleToolCallResult)
eventBus.on('Socket::Session::ImageGenerated', handleImageGenerated)
+ eventBus.on('Socket::Session::UserImages', handleUserImages)
eventBus.on('Socket::Session::AllMessages', handleAllMessages)
eventBus.on('Socket::Session::Done', handleDone)
eventBus.on('Socket::Session::Error', handleError)
eventBus.on('Socket::Session::Info', handleInfo)
return () => {
scrollEl?.removeEventListener('scroll', handleScroll)
+ clearTimeout(scrollTimeout)
eventBus.off('Socket::Session::Delta', handleDelta)
eventBus.off('Socket::Session::ToolCall', handleToolCall)
@@ -487,20 +912,12 @@ const ChatInterface: React.FC = ({
'Socket::Session::ToolCallPendingConfirmation',
handleToolCallPendingConfirmation
)
- eventBus.off(
- 'Socket::Session::ToolCallConfirmed',
- handleToolCallConfirmed
- )
- eventBus.off(
- 'Socket::Session::ToolCallCancelled',
- handleToolCallCancelled
- )
- eventBus.off(
- 'Socket::Session::ToolCallArguments',
- handleToolCallArguments
- )
+ eventBus.off('Socket::Session::ToolCallConfirmed', handleToolCallConfirmed)
+ eventBus.off('Socket::Session::ToolCallCancelled', handleToolCallCancelled)
+ eventBus.off('Socket::Session::ToolCallArguments', handleToolCallArguments)
eventBus.off('Socket::Session::ToolCallResult', handleToolCallResult)
eventBus.off('Socket::Session::ImageGenerated', handleImageGenerated)
+ eventBus.off('Socket::Session::UserImages', handleUserImages)
eventBus.off('Socket::Session::AllMessages', handleAllMessages)
eventBus.off('Socket::Session::Done', handleDone)
eventBus.off('Socket::Session::Error', handleError)
@@ -515,71 +932,138 @@ const ChatInterface: React.FC = ({
sessionIdRef.current = sessionId
- const resp = await fetch('/api/chat_session/' + sessionId)
- const data = await resp.json()
- const msgs = data?.length ? data : []
-
- setMessages(mergeToolCallResult(msgs))
- if (msgs.length > 0) {
- setInitCanvas(false)
+ // 🔥 优先检查:如果是新建session,直接保持空白状态
+ if (isNewSessionRef.current) {
+ console.log('[debug] 检测到新session,保持空白状态')
+ setMessages([])
+ setPending(false)
+ setHasDisplayedInitialMessage(false)
+ isNewSessionRef.current = false // 重置标志
+ return
}
- scrollToBottom()
- }, [sessionId, scrollToBottom, setInitCanvas])
+ try {
+ const resp = await fetch('/api/chat_session/' + sessionId)
+ const data = await resp.json()
+ const msgs = data?.length ? data : []
+
+ console.log('[debug] initChat 获取到历史消息:', msgs.length, 'for session:', sessionId)
+
+ // 🔥 关键修复:每次切换session都要重置消息状态
+ // 如果后端无历史消息,设置为空白状态(而不是保持当前状态)
+ if (msgs.length === 0) {
+ console.log('[debug] session无历史消息,设置空白状态')
+ setMessages([])
+ setPending(false)
+ setHasDisplayedInitialMessage(false)
+ return
+ }
+
+ // 如果已经显示了初始用户消息,且历史消息不包含用户消息,则合并
+ if (hasDisplayedInitialMessageRef.current && currentMessagesRef.current.length > 0) {
+ const hasUserInHistory = msgs.some((msg: Message) => msg.role === 'user')
+ if (!hasUserInHistory) {
+ console.log('[debug] 合并当前消息和历史消息')
+ const processedMessages = mergeToolCallResult(msgs)
+ const mergedMessages = [...currentMessagesRef.current, ...processedMessages]
+ setMessages(mergedMessages)
+ forceScrollToBottom()
+ return
+ }
+ }
+
+ // 正常情况:设置历史消息
+ console.log('[debug] 设置历史消息:', msgs.length)
+ const processedMessages = mergeToolCallResult(msgs)
+ setMessages(processedMessages)
+
+ if (msgs.length > 0) {
+ setInitCanvas(false)
+ // 如果有历史消息,滚动到底部
+ forceScrollToBottom()
+ }
+ } catch (error) {
+ console.error('[debug] 初始化聊天失败:', error)
+ // 🔥 出错时也要清空状态,防止显示错误的消息
+ setMessages([])
+ setPending(false)
+ setHasDisplayedInitialMessage(false)
+ }
+ }, [sessionId, forceScrollToBottom, setInitCanvas])
useEffect(() => {
initChat()
}, [sessionId, initChat])
const onSelectSession = (sessionId: string) => {
+ console.log('[debug] 切换session:', sessionId)
+
+ // 🔥 确保session切换时状态一致性
+ // 重置可能影响新session的状态
+ setPending(false)
+ setHasDisplayedInitialMessage(false)
+
+ // 设置新session
setSession(sessionList.find((s) => s.id === sessionId) || null)
- window.history.pushState(
- {},
- '',
- `/canvas/${canvasId}?sessionId=${sessionId}`
- )
+ window.history.pushState({}, '', `/canvas/${canvasId}?sessionId=${sessionId}`)
}
const onClickNewChat = () => {
+ console.log('[debug] 点击New Chat')
+
+ // 计算新session的名称
+ const newSessionNumber = sessionList.length + 1
+ const newSessionName = `New Session ${newSessionNumber}`
+
const newSession: Session = {
id: nanoid(),
- title: t('chat:newChat'),
+ title: generateChatSessionTitle(),
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
- model: session?.model || 'gpt-4o',
- provider: session?.provider || 'openai',
+ model: textModel?.model || session?.model || 'gpt-4o',
+ provider: textModel?.provider || session?.provider || 'openai',
+ name: newSessionName, // 设置明确的session名称
+ messages: []
}
- setSessionList((prev) => [...prev, newSession])
+ // 🔥 关键修复:标记为新session,防止initChat加载历史消息
+ isNewSessionRef.current = true
+
+ console.log('[debug] 创建新session:', newSession.id, '标记为新session')
+
+ // 添加新session到列表头部并选择(最新的在前面)
+ setSessionList((prev) => [newSession, ...prev])
onSelectSession(newSession.id)
}
const onSendMessages = useCallback(
- (data: Message[], configs: { textModel: Model; toolList: ToolInfo[] }) => {
+ (data: Message[], configs: {
+ textModel: ModelInfo | null
+ toolList: ToolInfo[]
+ modelName: string
+ }) => {
+ const startTime = performance.now()
setPending('text')
setMessages(data)
+ // Ensure we have a valid sessionId
+ const effectiveSessionId = sessionId || sessionIdRef.current || nanoid()
+
+ const sendStart = performance.now()
sendMessages({
- sessionId: sessionId!,
+ sessionId: effectiveSessionId,
canvasId: canvasId,
newMessages: data,
- textModel: configs.textModel,
- toolList: configs.toolList,
- systemPrompt:
- localStorage.getItem('system_prompt') || DEFAULT_SYSTEM_PROMPT,
+ modelName: configs.modelName,
+ systemPrompt: localStorage.getItem('system_prompt') || DEFAULT_SYSTEM_PROMPT,
})
-
- if (searchSessionId !== sessionId) {
- window.history.pushState(
- {},
- '',
- `/canvas/${canvasId}?sessionId=${sessionId}`
- )
+ if (searchSessionId !== effectiveSessionId) {
+ window.history.pushState({}, '', `/canvas/${canvasId}?sessionId=${effectiveSessionId}`)
}
- scrollToBottom()
+ forceScrollToBottom() // 用户发送消息时强制滚动到底部
},
- [canvasId, sessionId, searchSessionId, scrollToBottom]
+ [canvasId, sessionId, searchSessionId, forceScrollToBottom]
)
const handleCancelChat = useCallback(() => {
@@ -591,7 +1075,7 @@ const ChatInterface: React.FC = ({