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:
gongzhiyong
2026-04-08 15:50:05 +08:00
co-authored by Claude Sonnet 4.6
parent 72f581355c
commit a36f039ff9
4 changed files with 357 additions and 368 deletions
+182 -346
View File
@@ -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>
+20 -18
View File
@@ -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"
+137
View File
@@ -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
View File
@@ -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"
]
} }