diff --git a/frontend/components/gemini/GeminiChat.tsx b/frontend/components/gemini/GeminiChat.tsx index 6d12afb..c0fc71b 100644 --- a/frontend/components/gemini/GeminiChat.tsx +++ b/frontend/components/gemini/GeminiChat.tsx @@ -10,6 +10,14 @@ import { GeminiTypingIndicator } from "./GeminiTypingIndicator"; import { ExtensionsPanel, TicketSummary } from "./ExtensionsPanel"; import { Ticket, ShoppingCart, Cloud } from "lucide-react"; import { cn } from "@/lib/utils"; +import { + fetchConversations, + fetchConversation, + createConversation, + fetchTickets, + streamChat, + type TicketData, +} from "@/lib/api"; // ── Types ──────────────────────────────────────────────────────────────────── interface Conversation { @@ -27,200 +35,6 @@ interface Extension { apiKey: string; } -interface TicketData { - id: string; - title: string; - status: "pending" | "processing" | "resolved"; - priority: "P0" | "P1" | "P2" | "P3"; - createdAt: string; -} - -// ── Mock data ──────────────────────────────────────────────────────────────── -const MOCK_CONVERSATIONS: Conversation[] = [ - { - id: "1", - title: "用户增长数据分析", - messages: [ - { - id: "m1", - role: "user", - content: "帮我分析本月的用户增长数据,找出关键趋势", - }, - { - id: "m2", - role: "assistant", - content: `好的,我来帮你分析本月的用户增长数据。以下是关键发现: - -## 核心指标概览 - -| 指标 | 本月 | 上月 | 环比 | -|------|------|------|------| -| 新增用户 | 12,847 | 10,234 | +25.5% | -| DAU | 45,632 | 42,100 | +8.4% | -| 用户留存率 | 68.2% | 65.7% | +2.5pp | - -## 关键趋势分析 - -### 1. 增长渠道表现 -- **自然流量**占比提升至 42%,说明品牌认知度在提升 -- **付费投放** ROI 为 1:3.2,高于行业平均水平 -- **社交媒体**引流效果显著,抖音贡献了 28% 的新用户 - -### 2. 用户行为洞察 -- 新用户首日完成核心动作的比例提升到 **73%** -- 7日留存从上月的 45% 提升至 **52%** -- 人均使用时长增加 15 分钟 - -## 建议措施 - -1. **加大自然流量投入** — 继续优化 SEO 和内容营销 -2. **优化新手引导** — 首日转化已见成效,可进一步优化 -3. **重点运营抖音渠道** — 投放预算可适当倾斜 - -需要我生成更详细的数据报表吗?`, - }, - ], - }, - { - id: "2", - title: "产品运营周报生成", - messages: [ - { - id: "m3", - role: "user", - content: "生成一份产品运营周报,包含核心指标和优化建议", - }, - { - id: "m4", - role: "assistant", - content: `# 产品运营周报 -**报告周期**: 2024年第15周(4月8日-4月14日) - -## 一、核心数据摘要 - -### 用户指标 -- **周活跃用户(WAU)**: 128,456(+12.3%) -- **日均新增用户**: 1,847(+8.7%) -- **次日留存率**: 54.2%(+3.1pp) -- **7日留存率**: 38.7%(+2.4pp) - -### 商业指标 -- **周 GMV**: ¥892,340(+18.5%) -- **付费转化率**: 4.8%(+0.6pp) -- **ARPU**: ¥6.95(+5.2%) - -## 二、本周亮点 - -1. **新功能上线效果显著** - - 智能推荐2.0版本上线后,点击率提升 32% - - 用户平均浏览深度增加 2.3 页 - -2. **营销活动成效** - - "春季大促"活动带来 23% 的订单增长 - - 优惠券核销率达到 67% - -## 三、待优化问题 - -| 问题 | 影响范围 | 建议方案 | 优先级 | -|------|----------|----------|--------| -| 支付流程跳出率偏高 | 15%订单 | 简化支付步骤 | P0 | -| 搜索无结果率上升 | 8%搜索 | 优化搜索算法 | P1 | - -## 四、下周计划 - -- [ ] 完成支付流程优化 -- [ ] 上线搜索智能纠错 -- [ ] 启动会员日活动筹备 - -需要我详细展开哪个部分?`, - }, - ], - }, - { - id: "3", - title: "用户增长策略制定", - messages: [ - { - id: "m5", - role: "user", - content: "制定下季度的用户增长策略和推广计划", - }, - { - id: "m6", - role: "assistant", - content: `# Q2 用户增长策略与推广计划 - -## 一、目标设定 - -| 目标 | Q1实际 | Q2目标 | 增长率 | -|------|--------|--------|--------| -| MAU | 320K | 450K | +40% | -| 新增用户 | 85K | 130K | +53% | -| 付费用户 | 12K | 20K | +67% | - -## 二、增长策略框架 - -### 策略一:内容营销驱动 -**目标**: 建立品牌认知,获取自然流量 - -- **短视频矩阵** - - 抖音: 日更2-3条,聚焦产品使用场景 - - 小红书: 种草笔记,KOC合作 - - B站: 深度测评和教程类内容 - -- **SEO优化** - - 核心关键词排名提升计划 - - 长尾词内容布局 - -### 策略二:裂变增长 -**目标**: 利用存量用户带动增量 - -- **邀请有礼活动** - - 双向奖励机制 - - 阶梯式奖励刺激 - -- **社群运营** - - 建立核心用户社群 - - 培养 KOC 种子用户 - -### 策略三:精准投放 -**目标**: 高效获取目标用户 - -- **渠道优先级**: 抖音 > 微信 > 快手 -- **预算分配**: 60% 效果广告 + 40% 品牌广告 - -## 三、执行节奏 - -\`\`\` -4月: 基础建设期(内容生产、渠道搭建) -5月: 快速增长期(集中投放、活动引爆) -6月: 巩固转化期(精细运营、付费转化) -\`\`\` - -## 四、预算规划 - -| 项目 | 预算 | 预期ROI | -|------|------|---------| -| 内容制作 | ¥50K | - | -| 付费投放 | ¥200K | 1:2.5 | -| 活动奖励 | ¥80K | 1:3 | - -需要我详细展开某个具体策略吗?`, - }, - ], - }, - { - id: "4", - title: "用户留存分析报告", - messages: [], - }, - { - id: "5", - title: "竞品分析与对策", - messages: [], - }, -]; - const INITIAL_EXTENSIONS: Extension[] = [ { id: "ticket", @@ -248,119 +62,20 @@ const INITIAL_EXTENSIONS: Extension[] = [ }, ]; -// 模拟工单数据(当工单系统连接后显示) -const MOCK_TICKETS: TicketData[] = [ - { - id: "TK-2024-0421", - title: "生产环境数据库连接超时问题", - status: "processing", - priority: "P0", - createdAt: "2小时前", - }, - { - id: "TK-2024-0420", - title: "用户反馈APP闪退问题", - status: "pending", - priority: "P1", - createdAt: "5小时前", - }, - { - id: "TK-2024-0419", - title: "支付接口偶发超时", - status: "processing", - priority: "P1", - createdAt: "1天前", - }, - { - id: "TK-2024-0418", - title: "后台管理系统权限配置需求", - status: "pending", - priority: "P2", - createdAt: "1天前", - }, - { - id: "TK-2024-0417", - title: "日志采集服务内存占用过高", - status: "resolved", - priority: "P2", - createdAt: "2天前", - }, - { - id: "TK-2024-0416", - title: "新增监控告警规则需求", - status: "pending", - priority: "P3", - createdAt: "3天前", - }, -]; - -// ── Simulated AI responses ─────────────────────────────────────────────── -async function simulateAIResponse(userMessage: string): Promise { - const lower = userMessage.toLowerCase(); - if (lower.includes("你好") || lower.includes("hi") || lower.includes("hello")) { - return "你好!我是运营大脑,你的智能运营助手。我今天可以怎样帮助你?我可以帮助进行数据分析、内容创作、营销策划、运营优化等各种工作。"; - } - if (lower.includes("数据") || lower.includes("分析") || lower.includes("指标")) { - return `好的,我来帮你进行数据分析。以下是初步洞察: - -## 关键指标概览 - -| 指标 | 当前值 | 环比变化 | -|------|--------|----------| -| 活跃用户 | 45,632 | +8.4% | -| 留存率 | 68.2% | +2.5pp | -| 转化率 | 4.8% | +0.6pp | - -## 分析要点 - -- **用户增长势头良好**,核心指标全面提升 -- **留存数据**有明显改善,说明产品体验优化见效 -- **转化漏斗**仍有优化空间,建议重点关注 - -你想深入分析哪个具体维度?我可以提供更详细的数据报告。`; - } - if (lower.includes("周报") || lower.includes("报告") || lower.includes("汇报")) { - return `我可以帮你生成运营报告。请告诉我: - -1. **报告类型**:日报、周报、还是月报? -2. **核心指标**:你希望重点展示哪些数据? -3. **汇报对象**:给领导还是团队内部? - -告诉我这些信息,我会为你生成一份专业的运营报告模板。`; - } - return `我已经理解了你的需求。让我来帮你处理: - -**需求分析**:${userMessage} - -基于运营经验,我的建议如下: - -## 核心要点 - -- **数据驱动** — 任何决策都应该基于数据洞察 -- **用户为中心** — 始终关注用户体验和需求 -- **持续迭代** — 小步快跑,快速验证假设 - -## 建议步骤 - -1. 明确目标和关键结果(OKR) -2. 制定可执行的行动计划 -3. 建立数据监控体系 -4. 定期复盘和调整 - -我可以帮你生成详细的执行方案、数据报告或者策略文档。你希望从哪个方面开始?`; -} - // ── Main Component ──────────────────────────────────────────────────────────── export function GeminiChat() { const [sidebarOpen, setSidebarOpen] = useState(true); - const [conversations, setConversations] = - useState(MOCK_CONVERSATIONS); + const [conversations, setConversations] = useState([]); const [activeConvId, setActiveConvId] = useState(null); const [inputValue, setInputValue] = useState(""); const [isLoading, setIsLoading] = useState(false); const [extensionsPanelOpen, setExtensionsPanelOpen] = useState(false); const [extensions, setExtensions] = useState(INITIAL_EXTENSIONS); + const [tickets, setTickets] = useState([]); + const [activeTools, setActiveTools] = useState>(new Set()); + const [selectedModel, setSelectedModel] = useState<"flash" | "pro">("pro"); const messagesEndRef = useRef(null); + const abortRef = useRef(null); const activeConversation = conversations.find((c) => c.id === activeConvId) ?? null; const messages = activeConversation?.messages ?? []; @@ -368,6 +83,30 @@ export function GeminiChat() { // 检查工单系统是否已连接 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 tickets when ticket extension connects + useEffect(() => { + if (ticketSystemConnected) { + fetchTickets() + .then(setTickets) + .catch(() => setTickets([])); + } else { + setTickets([]); + } + }, [ticketSystemConnected]); + // Scroll to bottom on new messages useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); @@ -378,10 +117,33 @@ export function GeminiChat() { setInputValue(""); }, []); - const handleSelectConversation = useCallback((id: string) => { - setActiveConvId(id); - 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); @@ -414,15 +176,22 @@ export function GeminiChat() { let convId = activeConvId; if (!convId) { - // Create new conversation - const newConv: Conversation = { - id: `conv-${Date.now()}`, - title: text.length > 50 ? text.slice(0, 50) + "…" : text, - messages: [userMsg], - }; - setConversations((prev) => [newConv, ...prev]); - setActiveConvId(newConv.id); - convId = newConv.id; + // Create new conversation via API + try { + const title = text.length > 50 ? text.slice(0, 50) + "…" : text; + const created = await createConversation(title); + convId = created.id; + const newConv: Conversation = { + id: convId, + title: created.title, + messages: [userMsg], + }; + setConversations((prev) => [newConv, ...prev]); + setActiveConvId(convId); + } catch { + setIsLoading(false); + return; + } } else { setConversations((prev) => prev.map((c) => @@ -431,24 +200,56 @@ export function GeminiChat() { ); } - try { - await new Promise((r) => setTimeout(r, 1200 + Math.random() * 800)); - const aiText = await simulateAIResponse(text); - const aiMsg: Message = { - id: `msg-${Date.now()}-ai`, - role: "assistant", - content: aiText, - timestamp: new Date(), - }; - setConversations((prev) => - prev.map((c) => - c.id === convId ? { ...c, messages: [...c.messages, aiMsg] } : c - ) - ); - } finally { - setIsLoading(false); - } - }, [inputValue, isLoading, activeConvId]); + // Create a placeholder AI message for streaming + const aiMsgId = `msg-${Date.now()}-ai`; + const aiMsg: Message = { + id: aiMsgId, + role: "assistant", + content: "", + timestamp: new Date(), + }; + + const streamConvId = convId; + + setConversations((prev) => + prev.map((c) => + c.id === streamConvId ? { ...c, messages: [...c.messages, aiMsg] } : c + ) + ); + + 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; + return { + ...c, + messages: c.messages.map((m) => + m.id === aiMsgId + ? { ...m, content: m.content + event.content } + : m + ), + }; + }) + ); + } + // tool_start / tool_end events could be handled here for UI indicators + }, + () => { + // on error + setIsLoading(false); + }, + () => { + // on done + setIsLoading(false); + } + ); + }, [inputValue, isLoading, activeConvId, activeTools, selectedModel]); const handleRegenerate = useCallback( async (msgId: string) => { @@ -466,23 +267,54 @@ export function GeminiChat() { if (!userMsg) return; setIsLoading(true); - try { - await new Promise((r) => setTimeout(r, 1000 + Math.random() * 600)); - const newText = await simulateAIResponse(userMsg.content + " (regenerated)"); - setConversations((prev) => - prev.map((c) => { - if (c.id !== activeConvId) return c; - const newMsgs = c.messages.map((m) => - m.id === msgId ? { ...m, content: newText, id: `msg-${Date.now()}` } : m + + // 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 + ), + }; + }) ); - return { ...c, messages: newMsgs }; - }) - ); - } finally { - setIsLoading(false); - } + } + }, + () => { + setIsLoading(false); + }, + () => { + setIsLoading(false); + } + ); }, - [activeConvId, conversations, isLoading] + [activeConvId, conversations, isLoading, activeTools, selectedModel] ); const handleSuggestionClick = useCallback((text: string) => { @@ -521,12 +353,12 @@ export function GeminiChat() { <> {/* 显示工单摘要(仅当工单系统已连接) */} - {ticketSystemConnected && } + {ticketSystemConnected && } ) : (
{/* 对话中也显示工单摘要 */} - {ticketSystemConnected && } + {ticketSystemConnected && } {messages.map((msg) => (
diff --git a/frontend/components/gemini/GeminiInput.tsx b/frontend/components/gemini/GeminiInput.tsx index 10ac890..ffb1923 100644 --- a/frontend/components/gemini/GeminiInput.tsx +++ b/frontend/components/gemini/GeminiInput.tsx @@ -25,6 +25,10 @@ interface GeminiInputProps { onChange: (val: string) => void; onSubmit: () => void; isLoading?: boolean; + activeTools: Set; + onActiveToolsChange: (tools: Set) => void; + selectedModel: "flash" | "pro"; + onSelectedModelChange: (model: "flash" | "pro") => void; } const TOOLS: Tool[] = [ @@ -39,11 +43,13 @@ export function GeminiInput({ onChange, onSubmit, isLoading, + activeTools, + onActiveToolsChange, + selectedModel, + onSelectedModelChange, }: GeminiInputProps) { const textareaRef = useRef(null); const fileInputRef = useRef(null); - const [selectedModel, setSelectedModel] = useState<"flash" | "pro">("pro"); - const [activeTools, setActiveTools] = useState>(new Set()); const [showTools, setShowTools] = useState(false); // Auto-resize textarea @@ -62,23 +68,19 @@ export function GeminiInput({ }; const toggleTool = (id: string) => { - setActiveTools((prev) => { - const next = new Set(prev); - if (next.has(id)) { - next.delete(id); - } else { - next.add(id); - } - return next; - }); + const next = new Set(activeTools); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + onActiveToolsChange(next); }; const removeTool = (id: string) => { - setActiveTools((prev) => { - const next = new Set(prev); - next.delete(id); - return next; - }); + const next = new Set(activeTools); + next.delete(id); + onActiveToolsChange(next); }; const canSend = value.trim().length > 0 && !isLoading; @@ -205,7 +207,7 @@ export function GeminiInput({ {/* Bottom row: Model selector */}