## 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>
272 lines
8.1 KiB
TypeScript
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;
|
|
}
|