Replace mock data with real backend API calls
- Add frontend/lib/api.ts with typed API client for conversations, tickets, and SSE chat streaming
- Replace simulateAIResponse() with real SSE streaming via POST /api/chat/stream
- Replace MOCK_CONVERSATIONS with GET /api/conversations
- Replace MOCK_TICKETS with GET /api/tickets
- Lift activeTools and selectedModel state from GeminiInput to GeminiChat for API integration
- Load conversation messages on demand via GET /api/conversations/{id}
- Backend URL configured via NEXT_PUBLIC_API_URL in .env.local
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
72f581355c
commit
a36f039ff9
@@ -10,6 +10,14 @@ import { GeminiTypingIndicator } from "./GeminiTypingIndicator";
|
|||||||
import { ExtensionsPanel, TicketSummary } from "./ExtensionsPanel";
|
import { ExtensionsPanel, TicketSummary } from "./ExtensionsPanel";
|
||||||
import { Ticket, ShoppingCart, Cloud } from "lucide-react";
|
import { Ticket, ShoppingCart, Cloud } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
fetchConversations,
|
||||||
|
fetchConversation,
|
||||||
|
createConversation,
|
||||||
|
fetchTickets,
|
||||||
|
streamChat,
|
||||||
|
type TicketData,
|
||||||
|
} from "@/lib/api";
|
||||||
|
|
||||||
// ── Types ────────────────────────────────────────────────────────────────────
|
// ── Types ────────────────────────────────────────────────────────────────────
|
||||||
interface Conversation {
|
interface Conversation {
|
||||||
@@ -27,200 +35,6 @@ interface Extension {
|
|||||||
apiKey: string;
|
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[] = [
|
const INITIAL_EXTENSIONS: Extension[] = [
|
||||||
{
|
{
|
||||||
id: "ticket",
|
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<string> {
|
|
||||||
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 ────────────────────────────────────────────────────────────
|
// ── Main Component ────────────────────────────────────────────────────────────
|
||||||
export function GeminiChat() {
|
export function GeminiChat() {
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||||
const [conversations, setConversations] =
|
const [conversations, setConversations] = useState<Conversation[]>([]);
|
||||||
useState<Conversation[]>(MOCK_CONVERSATIONS);
|
|
||||||
const [activeConvId, setActiveConvId] = useState<string | null>(null);
|
const [activeConvId, setActiveConvId] = useState<string | null>(null);
|
||||||
const [inputValue, setInputValue] = useState("");
|
const [inputValue, setInputValue] = useState("");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [extensionsPanelOpen, setExtensionsPanelOpen] = useState(false);
|
const [extensionsPanelOpen, setExtensionsPanelOpen] = useState(false);
|
||||||
const [extensions, setExtensions] = useState<Extension[]>(INITIAL_EXTENSIONS);
|
const [extensions, setExtensions] = useState<Extension[]>(INITIAL_EXTENSIONS);
|
||||||
|
const [tickets, setTickets] = useState<TicketData[]>([]);
|
||||||
|
const [activeTools, setActiveTools] = useState<Set<string>>(new Set());
|
||||||
|
const [selectedModel, setSelectedModel] = useState<"flash" | "pro">("pro");
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
const activeConversation = conversations.find((c) => c.id === activeConvId) ?? null;
|
const activeConversation = conversations.find((c) => c.id === activeConvId) ?? null;
|
||||||
const messages = activeConversation?.messages ?? [];
|
const messages = activeConversation?.messages ?? [];
|
||||||
@@ -368,6 +83,30 @@ export function GeminiChat() {
|
|||||||
// 检查工单系统是否已连接
|
// 检查工单系统是否已连接
|
||||||
const ticketSystemConnected = extensions.find((e) => e.id === "ticket")?.connected ?? false;
|
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
|
// Scroll to bottom on new messages
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
@@ -378,10 +117,33 @@ export function GeminiChat() {
|
|||||||
setInputValue("");
|
setInputValue("");
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSelectConversation = useCallback((id: string) => {
|
const handleSelectConversation = useCallback(
|
||||||
setActiveConvId(id);
|
(id: string) => {
|
||||||
setInputValue("");
|
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(() => {
|
const handleOpenExtensions = useCallback(() => {
|
||||||
setExtensionsPanelOpen(true);
|
setExtensionsPanelOpen(true);
|
||||||
@@ -414,15 +176,22 @@ export function GeminiChat() {
|
|||||||
let convId = activeConvId;
|
let convId = activeConvId;
|
||||||
|
|
||||||
if (!convId) {
|
if (!convId) {
|
||||||
// Create new conversation
|
// Create new conversation via API
|
||||||
const newConv: Conversation = {
|
try {
|
||||||
id: `conv-${Date.now()}`,
|
const title = text.length > 50 ? text.slice(0, 50) + "…" : text;
|
||||||
title: text.length > 50 ? text.slice(0, 50) + "…" : text,
|
const created = await createConversation(title);
|
||||||
messages: [userMsg],
|
convId = created.id;
|
||||||
};
|
const newConv: Conversation = {
|
||||||
setConversations((prev) => [newConv, ...prev]);
|
id: convId,
|
||||||
setActiveConvId(newConv.id);
|
title: created.title,
|
||||||
convId = newConv.id;
|
messages: [userMsg],
|
||||||
|
};
|
||||||
|
setConversations((prev) => [newConv, ...prev]);
|
||||||
|
setActiveConvId(convId);
|
||||||
|
} catch {
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setConversations((prev) =>
|
setConversations((prev) =>
|
||||||
prev.map((c) =>
|
prev.map((c) =>
|
||||||
@@ -431,24 +200,56 @@ export function GeminiChat() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// Create a placeholder AI message for streaming
|
||||||
await new Promise((r) => setTimeout(r, 1200 + Math.random() * 800));
|
const aiMsgId = `msg-${Date.now()}-ai`;
|
||||||
const aiText = await simulateAIResponse(text);
|
const aiMsg: Message = {
|
||||||
const aiMsg: Message = {
|
id: aiMsgId,
|
||||||
id: `msg-${Date.now()}-ai`,
|
role: "assistant",
|
||||||
role: "assistant",
|
content: "",
|
||||||
content: aiText,
|
timestamp: new Date(),
|
||||||
timestamp: new Date(),
|
};
|
||||||
};
|
|
||||||
setConversations((prev) =>
|
const streamConvId = convId;
|
||||||
prev.map((c) =>
|
|
||||||
c.id === convId ? { ...c, messages: [...c.messages, aiMsg] } : c
|
setConversations((prev) =>
|
||||||
)
|
prev.map((c) =>
|
||||||
);
|
c.id === streamConvId ? { ...c, messages: [...c.messages, aiMsg] } : c
|
||||||
} finally {
|
)
|
||||||
setIsLoading(false);
|
);
|
||||||
}
|
|
||||||
}, [inputValue, isLoading, activeConvId]);
|
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(
|
const handleRegenerate = useCallback(
|
||||||
async (msgId: string) => {
|
async (msgId: string) => {
|
||||||
@@ -466,23 +267,54 @@ export function GeminiChat() {
|
|||||||
if (!userMsg) return;
|
if (!userMsg) return;
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
|
||||||
await new Promise((r) => setTimeout(r, 1000 + Math.random() * 600));
|
// Clear the existing assistant message content for streaming
|
||||||
const newText = await simulateAIResponse(userMsg.content + " (regenerated)");
|
const newAiMsgId = `msg-${Date.now()}-regen`;
|
||||||
setConversations((prev) =>
|
setConversations((prev) =>
|
||||||
prev.map((c) => {
|
prev.map((c) => {
|
||||||
if (c.id !== activeConvId) return c;
|
if (c.id !== activeConvId) return c;
|
||||||
const newMsgs = c.messages.map((m) =>
|
return {
|
||||||
m.id === msgId ? { ...m, content: newText, id: `msg-${Date.now()}` } : m
|
...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) => {
|
const handleSuggestionClick = useCallback((text: string) => {
|
||||||
@@ -521,12 +353,12 @@ export function GeminiChat() {
|
|||||||
<>
|
<>
|
||||||
<GeminiWelcome onSuggestionClick={handleSuggestionClick} />
|
<GeminiWelcome onSuggestionClick={handleSuggestionClick} />
|
||||||
{/* 显示工单摘要(仅当工单系统已连接) */}
|
{/* 显示工单摘要(仅当工单系统已连接) */}
|
||||||
{ticketSystemConnected && <TicketSummary tickets={MOCK_TICKETS} />}
|
{ticketSystemConnected && <TicketSummary tickets={tickets} />}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="max-w-3xl mx-auto px-4 pt-8 pb-4">
|
<div className="max-w-3xl mx-auto px-4 pt-8 pb-4">
|
||||||
{/* 对话中也显示工单摘要 */}
|
{/* 对话中也显示工单摘要 */}
|
||||||
{ticketSystemConnected && <TicketSummary tickets={MOCK_TICKETS} />}
|
{ticketSystemConnected && <TicketSummary tickets={tickets} />}
|
||||||
{messages.map((msg) => (
|
{messages.map((msg) => (
|
||||||
<GeminiMessage
|
<GeminiMessage
|
||||||
key={msg.id}
|
key={msg.id}
|
||||||
@@ -546,6 +378,10 @@ export function GeminiChat() {
|
|||||||
onChange={setInputValue}
|
onChange={setInputValue}
|
||||||
onSubmit={handleSend}
|
onSubmit={handleSend}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
activeTools={activeTools}
|
||||||
|
onActiveToolsChange={setActiveTools}
|
||||||
|
selectedModel={selectedModel}
|
||||||
|
onSelectedModelChange={setSelectedModel}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ interface GeminiInputProps {
|
|||||||
onChange: (val: string) => void;
|
onChange: (val: string) => void;
|
||||||
onSubmit: () => void;
|
onSubmit: () => void;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
|
activeTools: Set<string>;
|
||||||
|
onActiveToolsChange: (tools: Set<string>) => void;
|
||||||
|
selectedModel: "flash" | "pro";
|
||||||
|
onSelectedModelChange: (model: "flash" | "pro") => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TOOLS: Tool[] = [
|
const TOOLS: Tool[] = [
|
||||||
@@ -39,11 +43,13 @@ export function GeminiInput({
|
|||||||
onChange,
|
onChange,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
activeTools,
|
||||||
|
onActiveToolsChange,
|
||||||
|
selectedModel,
|
||||||
|
onSelectedModelChange,
|
||||||
}: GeminiInputProps) {
|
}: GeminiInputProps) {
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [selectedModel, setSelectedModel] = useState<"flash" | "pro">("pro");
|
|
||||||
const [activeTools, setActiveTools] = useState<Set<string>>(new Set());
|
|
||||||
const [showTools, setShowTools] = useState(false);
|
const [showTools, setShowTools] = useState(false);
|
||||||
|
|
||||||
// Auto-resize textarea
|
// Auto-resize textarea
|
||||||
@@ -62,23 +68,19 @@ export function GeminiInput({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const toggleTool = (id: string) => {
|
const toggleTool = (id: string) => {
|
||||||
setActiveTools((prev) => {
|
const next = new Set(activeTools);
|
||||||
const next = new Set(prev);
|
if (next.has(id)) {
|
||||||
if (next.has(id)) {
|
next.delete(id);
|
||||||
next.delete(id);
|
} else {
|
||||||
} else {
|
next.add(id);
|
||||||
next.add(id);
|
}
|
||||||
}
|
onActiveToolsChange(next);
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeTool = (id: string) => {
|
const removeTool = (id: string) => {
|
||||||
setActiveTools((prev) => {
|
const next = new Set(activeTools);
|
||||||
const next = new Set(prev);
|
next.delete(id);
|
||||||
next.delete(id);
|
onActiveToolsChange(next);
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const canSend = value.trim().length > 0 && !isLoading;
|
const canSend = value.trim().length > 0 && !isLoading;
|
||||||
@@ -205,7 +207,7 @@ export function GeminiInput({
|
|||||||
{/* Bottom row: Model selector */}
|
{/* Bottom row: Model selector */}
|
||||||
<div className="flex items-center justify-end px-4 pb-3 pt-0 gap-2">
|
<div className="flex items-center justify-end px-4 pb-3 pt-0 gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelectedModel("flash")}
|
onClick={() => onSelectedModelChange("flash")}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-3 py-1.5 rounded-full text-xs font-medium transition-all duration-150 cursor-pointer",
|
"px-3 py-1.5 rounded-full text-xs font-medium transition-all duration-150 cursor-pointer",
|
||||||
selectedModel === "flash"
|
selectedModel === "flash"
|
||||||
@@ -217,7 +219,7 @@ export function GeminiInput({
|
|||||||
Flash
|
Flash
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelectedModel("pro")}
|
onClick={() => onSelectedModelChange("pro")}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-3 py-1.5 rounded-full text-xs font-medium transition-all duration-150 cursor-pointer",
|
"px-3 py-1.5 rounded-full text-xs font-medium transition-all duration-150 cursor-pointer",
|
||||||
selectedModel === "pro"
|
selectedModel === "pro"
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000";
|
||||||
|
|
||||||
|
// ── Conversations ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ConversationSummary {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiMessage {
|
||||||
|
id: string;
|
||||||
|
role: "human" | "ai";
|
||||||
|
content: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConversationDetail extends ConversationSummary {
|
||||||
|
messages: ApiMessage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchConversations(): Promise<ConversationSummary[]> {
|
||||||
|
const res = await fetch(`${API_URL}/api/conversations`);
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch conversations");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchConversation(id: string): Promise<ConversationDetail> {
|
||||||
|
const res = await fetch(`${API_URL}/api/conversations/${id}`);
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch conversation");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createConversation(title?: string): Promise<ConversationSummary> {
|
||||||
|
const res = await fetch(`${API_URL}/api/conversations`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ title: title ?? "New conversation" }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to create conversation");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteConversation(id: string): Promise<void> {
|
||||||
|
const res = await fetch(`${API_URL}/api/conversations/${id}`, { method: "DELETE" });
|
||||||
|
if (!res.ok) throw new Error("Failed to delete conversation");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tickets ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface TicketData {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
status: "pending" | "processing" | "resolved";
|
||||||
|
priority: "P0" | "P1" | "P2" | "P3";
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTickets(page = 1, pageSize = 20): Promise<TicketData[]> {
|
||||||
|
const res = await fetch(`${API_URL}/api/tickets?page=${page}&page_size=${pageSize}`);
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch tickets");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SSE Chat Stream ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ChatStreamEvent {
|
||||||
|
type: "token" | "tool_start" | "tool_end" | "done";
|
||||||
|
content?: string;
|
||||||
|
tool?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function streamChat(
|
||||||
|
message: string,
|
||||||
|
conversationId: string,
|
||||||
|
tools: string[],
|
||||||
|
model: "flash" | "pro",
|
||||||
|
onEvent: (event: ChatStreamEvent) => void,
|
||||||
|
onError: (error: Error) => void,
|
||||||
|
onDone: () => void,
|
||||||
|
): AbortController {
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
fetch(`${API_URL}/api/chat/stream`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ message, conversation_id: conversationId, tools, model }),
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
.then(async (res) => {
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => "Unknown error");
|
||||||
|
throw new Error(`Chat stream failed (${res.status}): ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = res.body?.getReader();
|
||||||
|
if (!reader) throw new Error("No response body");
|
||||||
|
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = "";
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const lines = buffer.split("\n");
|
||||||
|
buffer = lines.pop() ?? "";
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed.startsWith("data: ")) continue;
|
||||||
|
const json = trimmed.slice(6);
|
||||||
|
if (!json) continue;
|
||||||
|
try {
|
||||||
|
const event: ChatStreamEvent = JSON.parse(json);
|
||||||
|
onEvent(event);
|
||||||
|
if (event.type === "done") {
|
||||||
|
onDone();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// skip malformed events
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onDone();
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (err.name !== "AbortError") {
|
||||||
|
onError(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return controller;
|
||||||
|
}
|
||||||
+18
-4
@@ -1,6 +1,10 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"target": "ES6",
|
"target": "ES6",
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
@@ -19,9 +23,19 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./*"]
|
"@/*": [
|
||||||
|
"./*"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
"include": [
|
||||||
"exclude": ["node_modules"]
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user