diff --git a/.env.template b/.env.template index f79dedc..1f84e30 100644 --- a/.env.template +++ b/.env.template @@ -14,6 +14,7 @@ SUMMARY_MODEL="gpt-4o-mini" EMBEDDING_MODEL="text-embedding-3-large" CHAT_MODEL="gpt-5-mini" FAST_MODEL="gpt-4o-mini" +TAB_CHAT_MODEL="gemini-2.0-flash-lite-preview" EXTERNAL_VECTOR_BASE_URL="" VECTOR_BACKEND="internal" MAX_AI_RESPONSES_PER_TICKET="3" diff --git a/frontend/src/components/chat/message-item.tsx b/frontend/src/components/chat/message-item.tsx index 16d09f2..b26e2b6 100644 --- a/frontend/src/components/chat/message-item.tsx +++ b/frontend/src/components/chat/message-item.tsx @@ -13,7 +13,6 @@ import { type TicketType } from "tentix-server/rpc"; import { Avatar, AvatarFallback, - AvatarImage, Badge, Button, Popover, @@ -28,6 +27,8 @@ import { } from "tentix-ui"; import useLocalUser from "../../hooks/use-local-user.tsx"; import { useChatStore, useSessionMembersStore } from "../../store/index.ts"; +import { useUserCacheStore } from "@store/user-cache"; +import { CachedAvatar } from "@comp/common/cached-avatar"; import ContentRenderer from "./content-renderer.tsx"; import { useTranslation } from "i18n"; import { memo, useState, useEffect } from "react"; @@ -39,13 +40,37 @@ interface MessageItemProps { message: TicketType["messages"][number]; } + +const useMessageSender = (message: TicketType["messages"][number]) => { + const { sessionMembers } = useSessionMembersStore(); + const setUser = useUserCacheStore((state: any) => state.setUser); + + const messageSender = sessionMembers?.find( + (member) => member.id === message.senderId, + ); + + // 缓存消息发送者信息 + useEffect(() => { + if (messageSender) { + setUser({ + id: messageSender.id, + name: messageSender.name, + nickname: messageSender.nickname || messageSender.name, + avatar: messageSender.avatar, + role: messageSender.role || 'unknown' + }); + } + }, [messageSender, setUser]); + + return messageSender; +}; + // other's message component const OtherMessage = ({ message, }: { message: TicketType["messages"][number]; }) => { - const { sessionMembers } = useSessionMembersStore(); const { role } = useLocalUser(); const { currentTicketId, updateMessage } = useChatStore(); const notCustomer = role !== "customer"; @@ -57,9 +82,8 @@ const OtherMessage = ({ const [feedbackComment, setFeedbackComment] = useState(""); const [hasComplaint, setHasComplaint] = useState(false); - const messageSender = sessionMembers?.find( - (member) => member.id === message.senderId, - ); + // 使用提取的Hook + const messageSender = useMessageSender(message); // Get current feedback status const currentFeedback = message.feedbacks?.[0]; @@ -158,15 +182,17 @@ const OtherMessage = ({ return (
- - - - {messageSender?.nickname?.charAt(0) ?? "U"} - - + ) : ( + + U + + )}
{ - const { sessionMembers } = useSessionMembersStore(); const { isMessageSending, withdrawMessageFunc: withdrawMessage, kbSelectionMode } = useChatStore(); const { t } = useTranslation(); - const messageSender = sessionMembers?.find( - (member) => member.id === message.senderId, - ); + // 使用提取的Hook + const messageSender = useMessageSender(message); return (
- - - - {messageSender?.nickname?.charAt(0) ?? "U"} - - + ) : ( + + U + + )}
{ @@ -139,14 +141,18 @@ export function StaffMessageInput({ const [uploadProgress, setUploadProgress] = useState( null, ); + const [isContextOrganizerOpen, setIsContextOrganizerOpen] = useState(false); const editorRef = useRef(null); const { toast } = useToast(); const { kbSelectionMode, clearKbSelection, selectedMessageIds } = useChatStore(); const { id: userId } = useLocalUser(); + + + const authToken = window.localStorage.getItem("token"); + const ticketId = window.location.pathname.split('/').pop(); - // 分析消息内容中的文件情况 const analyzeFileContent = useCallback( (content: JSONContentZod): FileStats => { let count = 0; @@ -257,6 +263,12 @@ export function StaffMessageInput({ ], ); + // 工单整理按钮点击 + const handleContextOrganizerClick = useCallback(() => { + if (isLoading || uploadProgress) return; + setIsContextOrganizerOpen(true); + }, [isLoading, uploadProgress]); + const editorProps = useMemo( () => ({ handleKeyDown: (_: unknown, event: KeyboardEvent) => { @@ -336,6 +348,7 @@ export function StaffMessageInput({ }; const isUploading = uploadProgress !== null; + const isContextOrganizerDisabled = isLoading || isUploading; if (kbSelectionMode) { const count = selectedMessageIds.size; @@ -410,45 +423,75 @@ export function StaffMessageInput({ } return ( -
- {/* 上传进度指示器 */} - {renderUploadProgress()} - - {/* 主要内容区域 - 动态调整顶部间距 */} -
-
- { - onTyping?.(); - setNewMessage(value as JSONContentZod); - }} - throttleDelay={150} - editorContentClassName="overflow-auto h-full" - editable={!isUploading} - editorClassName="focus:outline-none p-4 h-full" - className="border-none" - editorProps={editorProps} - /> -
- - -
-
+
+ { + onTyping?.(); + setNewMessage(value as JSONContentZod); + }} + throttleDelay={150} + editorContentClassName="overflow-auto h-full" + editable={!isUploading} + editorClassName="focus:outline-none p-4 h-full" + ticketId={ticketId || undefined} + authToken={authToken || undefined} + className="border-none" + editorProps={editorProps} + /> +
+ + {/* 工具栏区域 */} +
+ {ticketId && authToken && ( + + )} +
+ + + +
+ + {/* 工单整理对话框 */} + + ); } diff --git a/frontend/src/components/common/cache-debug-panel.tsx b/frontend/src/components/common/cache-debug-panel.tsx new file mode 100644 index 0000000..76d7dba --- /dev/null +++ b/frontend/src/components/common/cache-debug-panel.tsx @@ -0,0 +1,154 @@ +import { useState, useEffect } from "react"; +import { useUserCacheStore } from "../../store/user-cache"; +import { Button } from "tentix-ui"; +import { cn } from "@lib/utils"; + +interface CacheDebugPanelProps { + className?: string; +} + +export function CacheDebugPanel({ className }: CacheDebugPanelProps) { + const getCacheStats = useUserCacheStore((state: any) => state.getCacheStats); + const clearCache = useUserCacheStore((state: any) => state.clearCache); + const users = useUserCacheStore((state: any) => state.users); + + const [stats, setStats] = useState(() => getCacheStats()); + const [isExpanded, setIsExpanded] = useState(false); + + + useEffect(() => { + const interval = setInterval(() => { + setStats(getCacheStats()); + }, 3000); + return () => clearInterval(interval); + }, [getCacheStats]); + + const handleClearCache = () => { + clearCache(); + setStats(getCacheStats()); + console.log("🗑️ [CacheDebugPanel] 缓存已清空"); + }; + + const handleLogStats = () => { + const currentStats = getCacheStats(); + console.table({ + '缓存统计': { + '缓存命中': currentStats.cacheHits, + '缓存未命中': currentStats.cacheMisses, + '命中率': currentStats.hitRate, + '缓存用户数': currentStats.totalUsers, + } + }); + }; + + + if (!import.meta.env.DEV) { + return null; + } + + return ( +
+ +
setIsExpanded(!isExpanded)} + > +
+
+
+ 用户缓存状态 +
+
+ + {isExpanded ? "▼" : "▶"} + +
+
+
+ + + {isExpanded && ( +
+ {/* 统计信息 */} +
+
+
缓存用户
+
{stats.totalUsers}
+
+
+ + +
+
缓存用户列表:
+
+ {Array.from(users.entries() as IterableIterator<[number, any]>).slice(0, 5).map(([id, user]) => ( +
+ {user.name} { + (e.target as HTMLImageElement).style.display = 'none'; + }} + /> + {user.name} + {user.role} +
+ ))} + {users.size > 5 && ( +
+ 还有 {users.size - 5} 个用户... +
+ )} +
+
+ + +
+ + +
+
+ )} +
+ ); +} + + +export function useCacheDebugToggle() { + const [showDebugPanel, setShowDebugPanel] = useState(false); + + useEffect(() => { + const handleKeyPress = (e: KeyboardEvent) => { + + if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'C') { + e.preventDefault(); + setShowDebugPanel(prev => !prev); + } + }; + + if (import.meta.env.DEV) { + window.addEventListener('keydown', handleKeyPress); + return () => window.removeEventListener('keydown', handleKeyPress); + } + }, []); + + return { showDebugPanel, setShowDebugPanel }; +} diff --git a/frontend/src/components/common/cached-avatar.tsx b/frontend/src/components/common/cached-avatar.tsx new file mode 100644 index 0000000..1222d8d --- /dev/null +++ b/frontend/src/components/common/cached-avatar.tsx @@ -0,0 +1,137 @@ +import { Avatar, AvatarFallback, AvatarImage } from "tentix-ui"; +import { useUserCacheStore, type CachedUser } from "../../store/user-cache"; +import { useEffect } from "react"; +import { cn } from "@lib/utils"; + +interface CachedAvatarProps { + user: { + id: number; + name: string; + nickname?: string; + avatar: string; + role?: string; + }; + className?: string; + fallbackClassName?: string; + size?: "sm" | "md" | "lg"; + showDebugInfo?: boolean; +} + +interface CachedAvatarByIdProps { + userId: number; + fallbackText?: string; + className?: string; + size?: "sm" | "md" | "lg"; + showDebugInfo?: boolean; +} + +// 共用的尺寸配置 +const sizeClasses = { + sm: "h-5 w-5", + md: "h-8 w-8", + lg: "h-10 w-10", +}; + + +function DebugIndicator({ cachedUser, userName }: { cachedUser: any; userName: string }) { + return ( +
+
+ ); +} + +export function CachedAvatar({ + user, + className, + fallbackClassName, + size = "md", + showDebugInfo = false +}: CachedAvatarProps) { + const setUser = useUserCacheStore((state: any) => state.setUser); + const getUser = useUserCacheStore((state: any) => state.getUser); + + + const cachedUser = getUser(user.id); + const effectiveUser = cachedUser || user; + + + useEffect(() => { + if (user && (!cachedUser || cachedUser.avatar !== user.avatar)) { + const userToCache: Omit = { + id: user.id, + name: user.name, + nickname: user.nickname || user.name, + avatar: user.avatar, + role: user.role || 'unknown' + }; + + setUser(userToCache); + + if (showDebugInfo && import.meta.env.DEV) { + console.log(`🔄 [CachedAvatar] 更新缓存: ${user.name} (ID: ${user.id})`); + } + } + }, [user.id, user.avatar, cachedUser?.avatar, setUser, showDebugInfo]); + + return ( +
+ + + + {effectiveUser.name.charAt(0).toUpperCase()} + + + + {/* 开发环境下显示缓存状态指示器 */} + {showDebugInfo && import.meta.env.DEV && ( + + )} +
+ ); +} + +export function CachedAvatarById({ + userId, + fallbackText = "U", + className, + size = "md", + showDebugInfo = false +}: CachedAvatarByIdProps) { + const getUser = useUserCacheStore((state: any) => state.getUser); + const user = getUser(userId); + + if (!user) { + if (showDebugInfo && import.meta.env.DEV) { + console.warn(`⚠️ [CachedAvatarById] 用户 ${userId} 不在缓存中`); + } + + return ( +
+ + + {fallbackText} + + + {/* 显示缓存未命中的指示器 */} + {showDebugInfo && import.meta.env.DEV && ( + + )} +
+ ); + } + + return ( + + ); +} diff --git a/frontend/src/components/staff/site-header.tsx b/frontend/src/components/staff/site-header.tsx index eb576fa..d0c39ca 100644 --- a/frontend/src/components/staff/site-header.tsx +++ b/frontend/src/components/staff/site-header.tsx @@ -6,6 +6,7 @@ import { TriangleAlertIcon, LibraryBigIcon, NavigationIcon, + FileTextIcon, } from "lucide-react"; import { type TicketType } from "tentix-server/rpc"; import { updateTicketStatus } from "@lib/query"; @@ -27,6 +28,7 @@ import { useQueryClient, useMutation } from "@tanstack/react-query"; import { useState, useCallback } from "react"; import useLocalUser from "@hook/use-local-user.tsx"; import { useChatStore } from "@store/index"; +import { ContextOrganizerDialog } from "../../modal/use-context-organizer-modal"; interface SiteHeaderProps { ticket: TicketType; @@ -46,9 +48,11 @@ export function StaffSiteHeader({ const queryClient = useQueryClient(); const [showDialog, setShowDialog] = useState(false); + const [showContextDialog, setShowContextDialog] = useState(false); const { t } = useTranslation(); const { role } = useLocalUser(); const notCustomer = role !== "customer"; + const isAgent = role === "agent" || role === "technician" || role === "admin"; const { kbSelectionMode, setKbSelectionMode, clearKbSelection } = useChatStore(); // Close ticket mutation @@ -102,6 +106,8 @@ export function StaffSiteHeader({ setShowDialog(false); }; + + return (
@@ -128,11 +134,31 @@ export function StaffSiteHeader({
+ {/* 上下文整理按钮 - 仅对客服agent可见 */} + {isAgent && ( + + + + + +

{t('organize_ticket_context')}

+
+
+ )}
{transferModal} {updatePriorityModal} + + {/* 上下文整理对话框 */} + {isAgent && ( + + )} + diff --git a/frontend/src/components/staff/staff-right-sidebar.tsx b/frontend/src/components/staff/staff-right-sidebar.tsx index 426ecf7..22c5d18 100644 --- a/frontend/src/components/staff/staff-right-sidebar.tsx +++ b/frontend/src/components/staff/staff-right-sidebar.tsx @@ -1,4 +1,5 @@ import { useSessionMembersStore } from "@store/index"; +import { useUserCacheStore } from "../../store/user-cache"; import { useTranslation } from "i18n"; import { getEnumKey, @@ -7,9 +8,6 @@ import { } from "tentix-server/constants"; import { type TicketType } from "tentix-server/rpc"; import { - Avatar, - AvatarFallback, - AvatarImage, ScrollArea, timeAgo, PendingIcon, @@ -19,7 +17,9 @@ import { } from "tentix-ui"; import { TruncateWithTooltip } from "@comp/common/truncate-with-tooltip"; import { CopyableTruncate } from "@comp/common/copyable-truncate"; +import { CachedAvatar } from "@comp/common/cached-avatar"; import type { TFunction } from "i18next"; +import { useEffect } from "react"; // Custom status display function function getStatusDisplay(status: TicketType["status"], t: TFunction) { @@ -110,10 +110,25 @@ export function TicketHistory({ export function StaffRightSidebar({ ticket }: { ticket: TicketType }) { const { t } = useTranslation(); + const setUsers = useUserCacheStore((state: any) => state.setUsers); const agent = ticket?.agent; const customer = ticket.customer; const statusDisplay = getStatusDisplay(ticket?.status, t); + // 🔄 批量缓存工单相关用户信息 + useEffect(() => { + if (ticket) { + const usersToCache = [ + agent, + customer, + ...ticket.technicians, + ].filter(Boolean); + + setUsers(usersToCache); + + } + }, [ticket, agent, customer, setUsers]); + if (ticket) { return (
@@ -135,20 +150,16 @@ export function StaffRightSidebar({ ticket }: { ticket: TicketType }) { {/* sealos ID */} - {customer.sealosId && ( - <> -
- {t("sealos_id")} -
- - {customer.sealosId} - - - )} +
+ {t("sealos_id")} +
+ + {customer.sealosId} + {/* Region */}
{t("area")} @@ -241,10 +252,11 @@ export function StaffRightSidebar({ ticket }: { ticket: TicketType }) {

- - - {agent.name.charAt(0)} - + {agent.name}
diff --git a/frontend/src/components/user/ticket-details-sidebar.tsx b/frontend/src/components/user/ticket-details-sidebar.tsx index 7ee4418..e140151 100644 --- a/frontend/src/components/user/ticket-details-sidebar.tsx +++ b/frontend/src/components/user/ticket-details-sidebar.tsx @@ -10,9 +10,6 @@ import { } from "tentix-server/rpc"; import { ThumbsUpIcon, ThumbsDownIcon } from "lucide-react"; import { - Avatar, - AvatarFallback, - AvatarImage, ScrollArea, timeAgo, PendingIcon, @@ -30,12 +27,14 @@ import { } from "tentix-ui"; import type { TFunction } from "i18next"; import { useSessionMembersStore } from "@store/index"; +import { useUserCacheStore } from "@store/user-cache"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { technicianFeedbackQueryOptions, submitStaffFeedback, } from "@lib/query"; import { cn } from "@lib/utils"; +import { CachedAvatar } from "@comp/common/cached-avatar"; import { memo, useState, useEffect } from "react"; // Custom status display function @@ -135,6 +134,7 @@ const StaffFeedbackItem = memo( ticketId: string; }) => { const { t } = useTranslation(); + const setUser = useUserCacheStore((state) => state.setUser); const queryClient = useQueryClient(); const [showDislikeForm, setShowDislikeForm] = useState(false); const [dislikeReasons, setDislikeReasons] = useState([]); @@ -146,6 +146,19 @@ const StaffFeedbackItem = memo( const hasLiked = currentFeedback?.feedbackType === "like"; const hasDisliked = currentFeedback?.feedbackType === "dislike"; + + useEffect(() => { + if (staff) { + setUser({ + id: staff.id, + name: staff.name, + nickname: staff.nickname || staff.name, + avatar: staff.avatar, + role: 'staff' + }); + } + }, [staff, setUser]); + // Initialize form state from existing feedback data useEffect(() => { if (currentFeedback?.feedbackType === "dislike") { @@ -210,10 +223,11 @@ const StaffFeedbackItem = memo( return (
- - - {staff.name.charAt(0)} - + {staff.name}
@@ -412,6 +426,7 @@ StaffFeedbackItem.displayName = "StaffFeedbackItem"; export function TicketDetailsSidebar({ ticket }: { ticket: TicketType }) { const { t } = useTranslation(); + const setUsers = useUserCacheStore((state) => state.setUsers); const statusDisplay = getStatusDisplay(ticket?.status, t); // Fetch technicians and their feedback data @@ -419,6 +434,24 @@ export function TicketDetailsSidebar({ ticket }: { ticket: TicketType }) { technicianFeedbackQueryOptions(ticket.id), ); + // 🔄 缓存工单相关用户信息 + useEffect(() => { + if (ticket) { + const usersToCache = [ + ticket.customer, + ticket.agent, + ...ticket.technicians, + ].filter(Boolean); + + setUsers(usersToCache); + + // 输出缓存统计信息(开发环境) + if (import.meta.env.DEV) { + console.log(`📊 [TicketDetailsSidebar] 已缓存 ${usersToCache.length} 个用户`); + } + } + }, [ticket, setUsers]); + if (ticket) { return (
diff --git a/frontend/src/hooks/use-text-optimizer.ts b/frontend/src/hooks/use-text-optimizer.ts new file mode 100644 index 0000000..2a31c69 --- /dev/null +++ b/frontend/src/hooks/use-text-optimizer.ts @@ -0,0 +1,149 @@ +import { useState, useCallback, useRef } from "react"; +import { useToast } from "tentix-ui"; +import { useTranslation } from "i18n"; + +interface OptimizationResult { + optimizedText: string; + confidence: number; + suggestions: string[]; + reasoning: string; +} + +interface UseTextOptimizerProps { + ticketId: string; + messageType?: "public" | "internal"; + priority?: string; +} + +export function useTextOptimizer({ + ticketId, + messageType = "public", + priority +}: UseTextOptimizerProps) { + const [isOptimizing, setIsOptimizing] = useState(false); + const [lastOptimization, setLastOptimization] = useState<{ + original: string; + result: OptimizationResult; + } | null>(null); + + const { toast } = useToast(); + const { t } = useTranslation(); + const abortControllerRef = useRef(null); + + const optimizeText = useCallback(async (originalText: string): Promise => { + const trimmedText = originalText.trim(); + if (!trimmedText) { + toast({ + title: t("optimization_failed"), + description: "文本为空,无法优化", + variant: "destructive", + }); + return null; + } + + // 取消之前的请求 + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + abortControllerRef.current = new AbortController(); + + setIsOptimizing(true); + + try { + const token = localStorage.getItem("token"); + if (!token) { + throw new Error("未登录"); + } + + const response = await fetch("/api/optimize/optimize-text", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${token}`, + }, + body: JSON.stringify({ + originalText: trimmedText, + ticketId, + messageType, + priority, + }), + signal: abortControllerRef.current.signal, + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const data = await response.json(); + + if (data.success) { + const result: OptimizationResult = { + optimizedText: data.optimizedText, + confidence: data.confidence, + suggestions: data.suggestions || [], + reasoning: data.reasoning || "", + }; + + setLastOptimization({ + original: trimmedText, + result, + }); + + toast({ + title: "优化成功", + description: `置信度: ${Math.round(result.confidence * 100)}%`, + }); + + return result; + } else { + throw new Error(data.error || "优化失败"); + } + } catch (error: any) { + if (error.name === "AbortError") { + return null; + } + + console.error("Text optimization error:", error); + toast({ + title: "优化失败", + description: error.message || "网络错误,请重试", + variant: "destructive", + }); + return null; + } finally { + setIsOptimizing(false); + abortControllerRef.current = null; + } + }, [ticketId, messageType, priority, toast, t]); + + const undoOptimization = useCallback(() => { + if (lastOptimization) { + const original = lastOptimization.original; + setLastOptimization(null); + + toast({ + title: "已撤销优化", + description: "文本已恢复到原始状态", + }); + + return original; + } + return null; + }, [lastOptimization, toast]); + + const cancelOptimization = useCallback(() => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + setIsOptimizing(false); + }, []); + + return { + isOptimizing, + lastOptimization: lastOptimization?.result || null, + hasOptimization: !!lastOptimization, + optimizeText, + undoOptimization, + cancelOptimization, + }; +} \ No newline at end of file diff --git a/frontend/src/modal/use-context-organizer-modal.tsx b/frontend/src/modal/use-context-organizer-modal.tsx new file mode 100644 index 0000000..c9f882e --- /dev/null +++ b/frontend/src/modal/use-context-organizer-modal.tsx @@ -0,0 +1,335 @@ +import { useState, useCallback, useEffect } from "react"; +import { Dialog, DialogContent, DialogFooter, Button, Textarea, useToast, cn } from "tentix-ui"; +import { CopyIcon, CheckIcon, RefreshCwIcon, FileTextIcon, BotIcon } from "lucide-react"; + +export interface ContextData { + userId: string; + userName: string; + namespace?: string; + region?: string; + ticketId: string; + priority: string; + status: string; + title: string; + description: string; + recentMessages: string; + createdAt: string; + category?: string; + module?: string; + organizedText?: string; +} + +interface ContextOrganizerDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + ticketId: string; + authToken: string; +} + +export function ContextOrganizerDialog({ + open, + onOpenChange, + ticketId, + authToken, +}: ContextOrganizerDialogProps) { + const { toast } = useToast(); + const [contextData, setContextData] = useState(null); + const [editableText, setEditableText] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [isCopied, setIsCopied] = useState(false); + const [processingStep, setProcessingStep] = useState(""); + + const formatContextText = useCallback((data: ContextData): string => { + if (data.organizedText) { + return data.organizedText; + } + + return `===== Tantix 工单上下文信息 ===== + +🔹 用户信息 +• 用户ID: ${data.userId} +• 用户名称: ${data.userName} +• 命名空间: ${data.namespace || '无'} +• 区域: ${data.region || '无'} + +🔹 工单基础信息 +• 工单ID: ${data.ticketId} +• 标题: ${data.title} +• 模块: ${data.module || '无'} +• 分类: ${data.category || '无'} +• 优先级: ${data.priority} +• 状态: ${data.status} +• 创建时间: ${data.createdAt} + +🔹 问题描述 +${data.description} + +🔹 对话记录 +${data.recentMessages} + +=========================== +整理时间: ${new Date().toLocaleString()}`; + }, []); + + const handleFetchContext = useCallback(async () => { + if (!ticketId || !authToken) { + console.log("缺少必要参数:", { ticketId, authToken: authToken ? "存在" : "不存在" }); + toast({ + title: "参数错误", + description: `缺少必要参数: ${!ticketId ? "工单ID" : "认证令牌"}`, + variant: "destructive", + }); + return; + } + + console.log("开始获取上下文,参数:", { ticketId, authToken: authToken.substring(0, 10) + "..." }); + setIsLoading(true); + setProcessingStep("tentix 正在获取工单基本信息..."); + + try { + setProcessingStep("tentix 正在获取工单基本信息..."); + + // 修正: 更新API端点路径 + console.log("发送请求到:", "/api/optimize/get-context-data"); + const contextResponse = await fetch("/api/optimize/get-context-data", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${authToken}`, + }, + body: JSON.stringify({ ticketId }), + }); + + console.log("API响应状态:", contextResponse.status); + + if (!contextResponse.ok) { + const errorText = await contextResponse.text(); + console.error("API响应错误:", errorText); + throw new Error(`获取数据失败: ${contextResponse.status} - ${errorText}`); + } + + const contextResult = await contextResponse.json(); + if (!contextResult.success) { + throw new Error(contextResult.error || "获取数据失败"); + } + + const rawData = contextResult.data; + console.log("获取到的基础数据:", rawData); + + setProcessingStep("tentix 正在分析对话记录和上下文..."); + await new Promise(resolve => setTimeout(resolve, 800)); + + setProcessingStep("tentix 正在智能整理格式..."); + + // 修正: 更新AI整理API端点路径 + const aiResponse = await fetch("/api/optimize/ai-organize", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${authToken}`, + }, + body: JSON.stringify({ rawData }), + }); + + if (!aiResponse.ok) { + throw new Error(`AI整理失败: ${aiResponse.status}`); + } + + const aiResult = await aiResponse.json(); + if (!aiResult.success) { + throw new Error(aiResult.error || "AI整理失败"); + } + + // 修正: 根据新的响应结构获取整理后的文本 + const organizedText = aiResult.data.organizedText; + console.log("AI整理后的数据:", organizedText); + + setProcessingStep("tentix 正在生成内容..."); + await new Promise(resolve => setTimeout(resolve, 500)); + + // 创建包含整理后文本的数据对象 + const organizedData = { + ...rawData, + organizedText: organizedText + }; + + setContextData(organizedData); + const formattedText = formatContextText(organizedData); + console.log("最终格式化文本:", formattedText); + setEditableText(formattedText); + + toast({ + title: "整理成功", + description: "AI已成功整理工单上下文", + variant: "default", + }); + } catch (error: any) { + console.error("获取上下文失败:", error); + toast({ + title: "整理失败", + description: error.message || "无法获取工单上下文,请重试", + variant: "destructive", + }); + } finally { + setIsLoading(false); + setProcessingStep(""); + } + }, [ticketId, authToken, formatContextText, toast]); + + const handleCopy = useCallback(async () => { + if (!editableText.trim()) { + toast({ + title: "复制失败", + description: "没有内容可复制", + variant: "destructive", + }); + return; + } + + try { + await navigator.clipboard.writeText(editableText); + setIsCopied(true); + toast({ + title: "复制成功", + description: "上下文信息已复制到剪贴板", + }); + + setTimeout(() => setIsCopied(false), 3000); + } catch (error) { + console.error("复制失败:", error); + toast({ + title: "复制失败", + description: "无法复制到剪贴板,请手动复制", + variant: "destructive", + }); + } + }, [editableText, toast]); + + const handleRefresh = useCallback(() => { + if (contextData) { + setEditableText(formatContextText(contextData)); + toast({ + title: "已重置", + description: "内容已重置为原始格式", + }); + } + }, [contextData, formatContextText, toast]); + + useEffect(() => { + if (open && ticketId && authToken) { + handleFetchContext(); + } + }, [open, ticketId, authToken, handleFetchContext]); + + useEffect(() => { + if (!open) { + setContextData(null); + setEditableText(""); + setIsCopied(false); + } + }, [open]); + + return ( + + + {/* 标题区域 */} +
+

+ + 工单上下文整理 +

+

+ tentix 为您梳理内容 +

+
+ + {/* 内容区域 */} +
+
+ + {isLoading ? ( +
+
+
+ +
+ +
+
+

{processingStep}

+
+ +
+
+
+
+
+
+
+
+ ) : ( +