- Add fetchTicketSummary() and TicketSummaryData type to lib/api.ts - Change TicketSummary props from tickets[] array to summary object with total, by_status, and by_priority fields - Update GeminiChat to fetch summary data instead of ticket list - TicketSummary now renders status and priority breakdowns from the summary endpoint instead of computing from raw ticket data Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
399 lines
12 KiB
TypeScript
399 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useRef, useEffect, useCallback } from "react";
|
|
import { GeminiSidebar } from "./GeminiSidebar";
|
|
import { GeminiTopbar } from "./GeminiTopbar";
|
|
import { GeminiWelcome } from "./GeminiWelcome";
|
|
import { GeminiMessage, Message } from "./GeminiMessage";
|
|
import { GeminiInput } from "./GeminiInput";
|
|
import { GeminiTypingIndicator } from "./GeminiTypingIndicator";
|
|
import { ExtensionsPanel, TicketSummary } from "./ExtensionsPanel";
|
|
import { Ticket, ShoppingCart, Cloud } from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
import {
|
|
fetchConversations,
|
|
fetchConversation,
|
|
fetchTicketSummary,
|
|
streamChat,
|
|
type TicketSummaryData,
|
|
} from "@/lib/api";
|
|
|
|
// ── Types ────────────────────────────────────────────────────────────────────
|
|
interface Conversation {
|
|
id: string;
|
|
title: string;
|
|
messages: Message[];
|
|
}
|
|
|
|
interface Extension {
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
icon: React.ElementType;
|
|
connected: boolean;
|
|
apiKey: string;
|
|
}
|
|
|
|
const INITIAL_EXTENSIONS: Extension[] = [
|
|
{
|
|
id: "ticket",
|
|
name: "工单系统",
|
|
description: "连接工单系统,查看和管理技术支持工单",
|
|
icon: Ticket,
|
|
connected: false,
|
|
apiKey: "",
|
|
},
|
|
{
|
|
id: "sales",
|
|
name: "销售系统",
|
|
description: "连接销售 CRM,获取客户和订单数据",
|
|
icon: ShoppingCart,
|
|
connected: false,
|
|
apiKey: "",
|
|
},
|
|
{
|
|
id: "cloud",
|
|
name: "云管系统",
|
|
description: "连接云管平台,监控资源使用情况",
|
|
icon: Cloud,
|
|
connected: false,
|
|
apiKey: "",
|
|
},
|
|
];
|
|
|
|
// ── Main Component ────────────────────────────────────────────────────────────
|
|
export function GeminiChat() {
|
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
|
const [conversations, setConversations] = useState<Conversation[]>([]);
|
|
const [activeConvId, setActiveConvId] = useState<string | null>(null);
|
|
const [inputValue, setInputValue] = useState("");
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [extensionsPanelOpen, setExtensionsPanelOpen] = useState(false);
|
|
const [extensions, setExtensions] = useState<Extension[]>(INITIAL_EXTENSIONS);
|
|
const [ticketSummary, setTicketSummary] = useState<TicketSummaryData | null>(null);
|
|
const [activeTools, setActiveTools] = useState<Set<string>>(new Set());
|
|
const [selectedModel, setSelectedModel] = useState<"flash" | "pro">("pro");
|
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
const abortRef = useRef<AbortController | null>(null);
|
|
|
|
const activeConversation = conversations.find((c) => c.id === activeConvId) ?? null;
|
|
const messages = activeConversation?.messages ?? [];
|
|
|
|
// 检查工单系统是否已连接
|
|
const ticketSystemConnected = extensions.find((e) => e.id === "ticket")?.connected ?? false;
|
|
|
|
// Load conversations on mount
|
|
useEffect(() => {
|
|
fetchConversations()
|
|
.then((list) => {
|
|
setConversations(
|
|
list.map((c) => ({ id: c.id, title: c.title, messages: [] }))
|
|
);
|
|
})
|
|
.catch(() => {
|
|
// silently fail — user sees empty sidebar
|
|
});
|
|
}, []);
|
|
|
|
// Load ticket summary when ticket extension connects
|
|
useEffect(() => {
|
|
if (ticketSystemConnected) {
|
|
fetchTicketSummary()
|
|
.then(setTicketSummary)
|
|
.catch(() => setTicketSummary(null));
|
|
} else {
|
|
setTicketSummary(null);
|
|
}
|
|
}, [ticketSystemConnected]);
|
|
|
|
// Scroll to bottom on new messages
|
|
useEffect(() => {
|
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
}, [messages, isLoading]);
|
|
|
|
const handleNewChat = useCallback(() => {
|
|
setActiveConvId(null);
|
|
setInputValue("");
|
|
}, []);
|
|
|
|
const handleSelectConversation = useCallback(
|
|
(id: string) => {
|
|
setActiveConvId(id);
|
|
setInputValue("");
|
|
|
|
// Load messages if not yet loaded
|
|
const conv = conversations.find((c) => c.id === id);
|
|
if (conv && conv.messages.length === 0) {
|
|
fetchConversation(id)
|
|
.then((detail) => {
|
|
const msgs: Message[] = detail.messages.map((m) => ({
|
|
id: m.id,
|
|
role: m.role === "human" ? "user" : "assistant",
|
|
content: m.content,
|
|
timestamp: new Date(m.created_at),
|
|
}));
|
|
setConversations((prev) =>
|
|
prev.map((c) => (c.id === id ? { ...c, messages: msgs } : c))
|
|
);
|
|
})
|
|
.catch(() => {
|
|
// silently fail
|
|
});
|
|
}
|
|
},
|
|
[conversations]
|
|
);
|
|
|
|
const handleOpenExtensions = useCallback(() => {
|
|
setExtensionsPanelOpen(true);
|
|
}, []);
|
|
|
|
const handleUpdateExtension = useCallback(
|
|
(id: string, apiKey: string, connected: boolean) => {
|
|
setExtensions((prev) =>
|
|
prev.map((ext) =>
|
|
ext.id === id ? { ...ext, apiKey, connected } : ext
|
|
)
|
|
);
|
|
},
|
|
[]
|
|
);
|
|
|
|
const handleSend = useCallback(async () => {
|
|
const text = inputValue.trim();
|
|
if (!text || isLoading) return;
|
|
setInputValue("");
|
|
setIsLoading(true);
|
|
|
|
const userMsg: Message = {
|
|
id: `msg-${Date.now()}-user`,
|
|
role: "user",
|
|
content: text,
|
|
timestamp: new Date(),
|
|
};
|
|
|
|
let convId = activeConvId;
|
|
|
|
if (!convId) {
|
|
// Generate a UUID client-side; backend streamChat auto-creates the conversation
|
|
convId = crypto.randomUUID();
|
|
const title = text.length > 50 ? text.slice(0, 50) + "…" : text;
|
|
const newConv: Conversation = {
|
|
id: convId,
|
|
title,
|
|
messages: [userMsg],
|
|
};
|
|
setConversations((prev) => [newConv, ...prev]);
|
|
setActiveConvId(convId);
|
|
} else {
|
|
setConversations((prev) =>
|
|
prev.map((c) =>
|
|
c.id === convId ? { ...c, messages: [...c.messages, userMsg] } : c
|
|
)
|
|
);
|
|
}
|
|
|
|
const aiMsgId = `msg-${Date.now()}-ai`;
|
|
const streamConvId = convId;
|
|
|
|
abortRef.current = streamChat(
|
|
text,
|
|
streamConvId,
|
|
Array.from(activeTools),
|
|
selectedModel,
|
|
(event) => {
|
|
if (event.type === "token" && event.content) {
|
|
setConversations((prev) =>
|
|
prev.map((c) => {
|
|
if (c.id !== streamConvId) return c;
|
|
const exists = c.messages.some((m) => m.id === aiMsgId);
|
|
if (!exists) {
|
|
// First token: create the assistant message
|
|
return {
|
|
...c,
|
|
messages: [
|
|
...c.messages,
|
|
{
|
|
id: aiMsgId,
|
|
role: "assistant" as const,
|
|
content: event.content!,
|
|
timestamp: new Date(),
|
|
},
|
|
],
|
|
};
|
|
}
|
|
// Subsequent tokens: append to existing message
|
|
return {
|
|
...c,
|
|
messages: c.messages.map((m) =>
|
|
m.id === aiMsgId
|
|
? { ...m, content: m.content + event.content }
|
|
: m
|
|
),
|
|
};
|
|
})
|
|
);
|
|
}
|
|
},
|
|
() => {
|
|
// on error
|
|
setIsLoading(false);
|
|
},
|
|
() => {
|
|
// on done
|
|
setIsLoading(false);
|
|
}
|
|
);
|
|
}, [inputValue, isLoading, activeConvId, activeTools, selectedModel]);
|
|
|
|
const handleRegenerate = useCallback(
|
|
async (msgId: string) => {
|
|
if (!activeConvId || isLoading) return;
|
|
const conv = conversations.find((c) => c.id === activeConvId);
|
|
if (!conv) return;
|
|
const msgIndex = conv.messages.findIndex((m) => m.id === msgId);
|
|
if (msgIndex === -1) return;
|
|
|
|
// Find the preceding user message
|
|
const userMsg = conv.messages
|
|
.slice(0, msgIndex)
|
|
.reverse()
|
|
.find((m) => m.role === "user");
|
|
if (!userMsg) return;
|
|
|
|
setIsLoading(true);
|
|
|
|
// Clear the existing assistant message content for streaming
|
|
const newAiMsgId = `msg-${Date.now()}-regen`;
|
|
setConversations((prev) =>
|
|
prev.map((c) => {
|
|
if (c.id !== activeConvId) return c;
|
|
return {
|
|
...c,
|
|
messages: c.messages.map((m) =>
|
|
m.id === msgId ? { ...m, content: "", id: newAiMsgId } : m
|
|
),
|
|
};
|
|
})
|
|
);
|
|
|
|
const regenConvId = activeConvId;
|
|
|
|
abortRef.current = streamChat(
|
|
userMsg.content,
|
|
regenConvId,
|
|
Array.from(activeTools),
|
|
selectedModel,
|
|
(event) => {
|
|
if (event.type === "token" && event.content) {
|
|
setConversations((prev) =>
|
|
prev.map((c) => {
|
|
if (c.id !== regenConvId) return c;
|
|
return {
|
|
...c,
|
|
messages: c.messages.map((m) =>
|
|
m.id === newAiMsgId
|
|
? { ...m, content: m.content + event.content }
|
|
: m
|
|
),
|
|
};
|
|
})
|
|
);
|
|
}
|
|
},
|
|
() => {
|
|
setIsLoading(false);
|
|
},
|
|
() => {
|
|
setIsLoading(false);
|
|
}
|
|
);
|
|
},
|
|
[activeConvId, conversations, isLoading, activeTools, selectedModel]
|
|
);
|
|
|
|
const handleSuggestionClick = useCallback((text: string) => {
|
|
setInputValue(text);
|
|
}, []);
|
|
|
|
return (
|
|
<div className="flex h-screen bg-[var(--gem-bg)] overflow-hidden font-sans" role="main">
|
|
{/* Sidebar */}
|
|
<GeminiSidebar
|
|
isOpen={sidebarOpen}
|
|
conversations={conversations.map(({ id, title }) => ({ id, title }))}
|
|
activeConversationId={activeConvId}
|
|
onNewChat={handleNewChat}
|
|
onSelectConversation={handleSelectConversation}
|
|
onOpenExtensions={handleOpenExtensions}
|
|
connectedExtensionsCount={extensions.filter((e) => e.connected).length}
|
|
/>
|
|
|
|
{/* Main content area */}
|
|
<div
|
|
className={cn(
|
|
"flex flex-col flex-1 min-w-0 transition-all duration-300",
|
|
sidebarOpen ? "ml-[260px]" : "ml-0"
|
|
)}
|
|
>
|
|
{/* Top bar */}
|
|
<GeminiTopbar
|
|
sidebarOpen={sidebarOpen}
|
|
onToggleSidebar={() => setSidebarOpen((v) => !v)}
|
|
/>
|
|
|
|
{/* Messages or welcome */}
|
|
<div className="flex-1 overflow-y-auto pt-14 scrollbar-thin">
|
|
{messages.length === 0 && !isLoading ? (
|
|
<>
|
|
<GeminiWelcome onSuggestionClick={handleSuggestionClick} />
|
|
{/* 显示工单摘要(仅当工单系统已连接) */}
|
|
{ticketSystemConnected && ticketSummary && <TicketSummary summary={ticketSummary} />}
|
|
</>
|
|
) : (
|
|
<div className="max-w-3xl mx-auto px-4 pt-8 pb-4">
|
|
{/* 对话中也显示工单摘要 */}
|
|
{ticketSystemConnected && ticketSummary && <TicketSummary summary={ticketSummary} />}
|
|
{messages.map((msg) => {
|
|
// Hide empty assistant message while streaming — the typing indicator covers it
|
|
if (msg.role === "assistant" && msg.content === "" && isLoading) {
|
|
return null;
|
|
}
|
|
return (
|
|
<GeminiMessage
|
|
key={msg.id}
|
|
message={msg}
|
|
onRegenerate={msg.role === "assistant" ? handleRegenerate : undefined}
|
|
/>
|
|
);
|
|
})}
|
|
{isLoading && <GeminiTypingIndicator />}
|
|
<div ref={messagesEndRef} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Input */}
|
|
<GeminiInput
|
|
value={inputValue}
|
|
onChange={setInputValue}
|
|
onSubmit={handleSend}
|
|
isLoading={isLoading}
|
|
activeTools={activeTools}
|
|
onActiveToolsChange={setActiveTools}
|
|
selectedModel={selectedModel}
|
|
onSelectedModelChange={setSelectedModel}
|
|
/>
|
|
</div>
|
|
|
|
{/* Extensions Panel */}
|
|
<ExtensionsPanel
|
|
isOpen={extensionsPanelOpen}
|
|
onClose={() => setExtensionsPanelOpen(false)}
|
|
extensions={extensions}
|
|
onUpdateExtension={handleUpdateExtension}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|