Files
socaichat/frontend/lib/api.ts
T
gongzhiyongandClaude Sonnet 4.6 0a2b339550 feat: implement Generative UI — Agent Workspace + workspace_card SSE protocol
## Backend
- Add workspace_card SSE event protocol: {id, name, props, merge}
- Add _extract_llm_text / _maybe_emit_workspace_card helpers in chat.py
- Refactor all tools to dual-output format: {llm_text, ui: {name, props}}
  - kb_search → KnowledgeResultCard
  - ticket_list/detail → TicketSummaryCard / TicketDetailCard
  - web_search → SearchResultCard
  - generate_document → DocumentResultCard
  - sandbox_run → SandboxResultCard
- Update SYSTEM_PROMPT: instruct LLM not to repeat tool data (UI shows it)

## Frontend
- Three-column layout: sidebar + chat + Agent Workspace (360px right panel)
- WorkspaceSession state model with ActivityNode + WorkspaceCard
- New components/workspace/: AgentWorkspace, ActivityTimeline, WorkspaceCardRenderer
- 6 card components: Knowledge/Ticket/Search/Document/Sandbox/ErrorCard
- GeminiChat: workspace state management, SSE routing for workspace_card events
- GeminiMessage: replace TracePanel with lightweight activity summary line
- lib/api.ts: add WorkspaceSession/ActivityNode/WorkspaceCard types

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 02:57:09 +08:00

272 lines
8.1 KiB
TypeScript

const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "https://soc-backend.azurewebsites.net";
// ── 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();
}
export interface TicketSummaryData {
total: number;
by_status: { pending: number; processing: number; resolved: number };
by_priority: { P0: number; P1: number; P2: number; P3: number };
}
export async function fetchTicketSummary(): Promise<TicketSummaryData> {
const res = await fetch(`${API_URL}/api/tickets/summary`);
if (!res.ok) throw new Error("Failed to fetch ticket summary");
return res.json();
}
// ── Attachments ─────────────────────────────────────────────────────────────
export interface AttachmentData {
id: string;
filename: string;
content_type: string;
blob_url: string;
size_bytes: number;
created_at: string;
}
export async function uploadAttachment(
file: File,
conversationId?: string,
): Promise<AttachmentData> {
const form = new FormData();
form.append("data", file);
const params = conversationId ? `?conversation_id=${conversationId}` : "";
const res = await fetch(`${API_URL}/api/attachments/upload${params}`, {
method: "POST",
body: form,
});
if (!res.ok) {
const text = await res.text().catch(() => "Unknown error");
throw new Error(`Upload failed (${res.status}): ${text}`);
}
return res.json();
}
export function getAttachmentDownloadUrl(attachmentId: string): string {
return `${API_URL}/api/attachments/${attachmentId}/download`;
}
// ── SSE Chat Stream ──────────────────────────────────────────────────────────
export interface ChatStreamEvent {
type: "token" | "status" | "tool_start" | "tool_end" | "tool_error" | "done" | "error" | "workspace_card";
// token
content?: string;
// status
stage?: string;
message?: string;
// tool_start / tool_end / tool_error
tool?: string;
call_id?: string;
title?: string;
input_summary?: string;
output_summary?: string;
error_summary?: string;
status?: "success" | "error";
duration_ms?: number;
ts?: number;
// workspace_card
id?: string;
name?: string;
props?: Record<string, unknown>;
merge?: boolean;
}
// ── Workspace 状态模型 ──────────────────────────────────────────────────────
export interface ActivityNode {
id: string;
type: "status" | "tool" | "done" | "error";
label: string;
detail?: string;
tool?: string;
callId?: string;
nodeStatus: "running" | "success" | "error" | "info";
ts: number;
linkedCardIds?: string[];
}
export interface WorkspaceCard {
id: string;
name: string;
props: Record<string, unknown>;
title: string;
priority: number;
sourceCallId?: string;
}
export interface WorkspaceSession {
id: string;
conversationId: string;
messageId: string;
title: string;
status: "idle" | "running" | "completed" | "error";
stageLabel: string;
timeline: ActivityNode[];
cards: WorkspaceCard[];
startedAt: number;
finishedAt?: number;
}
export interface TraceItem {
id: string;
type: "status" | "tool_start" | "tool_end" | "tool_error";
tool?: string;
callId?: string;
title: string;
message?: string;
inputSummary?: string;
outputSummary?: string;
errorSummary?: string;
itemStatus: "running" | "success" | "error" | "info";
durationMs?: number;
startTs: number;
}
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) {
// Process any remaining data in buffer (last line may lack trailing newline)
if (buffer.trim()) {
const trimmed = buffer.trim();
if (trimmed.startsWith("data: ")) {
const json = trimmed.slice(6);
if (json) {
try {
const event: ChatStreamEvent = JSON.parse(json);
onEvent(event);
} catch {
// skip malformed
}
}
}
}
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();
reader.cancel();
return;
}
} catch {
// skip malformed events
}
}
}
// Only call onDone here if the stream ended without a "done" event
onDone();
})
.catch((err) => {
if (err.name !== "AbortError") {
onError(err);
}
});
return controller;
}