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 { 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<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 ────────────────────────────────────────────────────────────
|
||||
export function GeminiChat() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
const [conversations, setConversations] =
|
||||
useState<Conversation[]>(MOCK_CONVERSATIONS);
|
||||
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 [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 abortRef = useRef<AbortController | null>(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() {
|
||||
<>
|
||||
<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">
|
||||
{/* 对话中也显示工单摘要 */}
|
||||
{ticketSystemConnected && <TicketSummary tickets={MOCK_TICKETS} />}
|
||||
{ticketSystemConnected && <TicketSummary tickets={tickets} />}
|
||||
{messages.map((msg) => (
|
||||
<GeminiMessage
|
||||
key={msg.id}
|
||||
@@ -546,6 +378,10 @@ export function GeminiChat() {
|
||||
onChange={setInputValue}
|
||||
onSubmit={handleSend}
|
||||
isLoading={isLoading}
|
||||
activeTools={activeTools}
|
||||
onActiveToolsChange={setActiveTools}
|
||||
selectedModel={selectedModel}
|
||||
onSelectedModelChange={setSelectedModel}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -25,6 +25,10 @@ interface GeminiInputProps {
|
||||
onChange: (val: string) => void;
|
||||
onSubmit: () => void;
|
||||
isLoading?: boolean;
|
||||
activeTools: Set<string>;
|
||||
onActiveToolsChange: (tools: Set<string>) => 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<HTMLTextAreaElement>(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);
|
||||
|
||||
// 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 */}
|
||||
<div className="flex items-center justify-end px-4 pb-3 pt-0 gap-2">
|
||||
<button
|
||||
onClick={() => setSelectedModel("flash")}
|
||||
onClick={() => onSelectedModelChange("flash")}
|
||||
className={cn(
|
||||
"px-3 py-1.5 rounded-full text-xs font-medium transition-all duration-150 cursor-pointer",
|
||||
selectedModel === "flash"
|
||||
@@ -217,7 +219,7 @@ export function GeminiInput({
|
||||
Flash
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedModel("pro")}
|
||||
onClick={() => onSelectedModelChange("pro")}
|
||||
className={cn(
|
||||
"px-3 py-1.5 rounded-full text-xs font-medium transition-all duration-150 cursor-pointer",
|
||||
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": {
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"target": "ES6",
|
||||
"skipLibCheck": true,
|
||||
@@ -19,9 +23,19 @@
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user