"use client"; import React from "react"; import { useState, useMemo, useCallback, useRef } from "react"; import { useRouter } from "next/navigation"; import { useQueryClient } from "@tanstack/react-query"; import type { App, Message, FormatTemplate } from "@/lib/types"; import api, { streamCompletion } from "@/lib/api"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; import { Card, CardContent } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; import { Sparkles, RotateCcw, Copy, Check, Loader2, FileText, ListChecks } from "lucide-react"; import { getCategoryIcon, getCategoryColor } from "@/lib/category-config"; import GovMarkdown from "@/components/ui/gov-markdown"; import { toast } from "sonner"; import ConversationSidebar from "./conversation-sidebar"; interface CompletionUIProps { app: App; } export default function CompletionUI({ app }: CompletionUIProps) { const router = useRouter(); const queryClient = useQueryClient(); const [input, setInput] = useState(""); const [output, setOutput] = useState(""); const [isLoading, setIsLoading] = useState(false); const [copied, setCopied] = useState(false); const [selectedFormat, setSelectedFormat] = useState(""); const [conversationId, setConversationId] = useState(); const abortRef = useRef(null); const handleNewConversation = useCallback(() => { setInput(""); setOutput(""); setSelectedFormat(""); setConversationId(undefined); }, []); const handleSelectConversation = useCallback( async (convId: string) => { setConversationId(convId); try { const data = await api.get<{ data: Message[] }>( `/api/v1/apps/${app.id}/conversations/${convId}/messages`, ); const msgs = data.data || []; const userMsg = msgs.find((m) => m.role === "user"); const aiMsg = msgs.find((m) => m.role === "assistant"); setInput(userMsg?.content || ""); setOutput(aiMsg?.content || ""); } catch { setInput(""); setOutput(""); } }, [app.id], ); const appConfig = useMemo(() => { try { if (typeof app.app_config === "string") return JSON.parse(app.app_config); return app.app_config || {}; } catch { return {}; } }, [app.app_config]); const inputLabel = appConfig.input_label || "输入内容"; const inputPlaceholder = appConfig.input_placeholder || "在此输入..."; const outputLabel = appConfig.output_label || "生成结果"; const formatTemplates: Record = appConfig.format_templates || {}; const hasFormats = Object.keys(formatTemplates).length > 0; const CategoryIconComponent = getCategoryIcon(app.category_slug); const categoryColor = getCategoryColor(app.category_slug); const handleGenerate = useCallback(async () => { if (!input.trim() || isLoading) return; setIsLoading(true); setOutput(""); const controller = new AbortController(); abortRef.current = controller; let finalInput = input.trim(); if (selectedFormat && formatTemplates[selectedFormat]) { const fmt = formatTemplates[selectedFormat]; finalInput = `【输出格式要求】请按照「${fmt.name}」格式生成,包含以下章节:${fmt.sections.join("、")}。\n\n${finalInput}`; } try { const res = await streamCompletion(app.id, finalInput, controller.signal); if (!res.ok) throw new Error("请求失败"); const reader = res.body?.getReader(); const decoder = new TextDecoder(); if (!reader) throw new Error("无法获取响应流"); let buffer = ""; let accumulated = ""; 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) { if (!line.startsWith("data: ")) continue; const raw = line.slice(6); if (raw === "[DONE]") break; try { const event = JSON.parse(raw); if (event.conversation_id) setConversationId(event.conversation_id); if (event.answer) { accumulated += event.answer; setOutput(accumulated); } } catch { /* skip */ } } } queryClient.invalidateQueries({ queryKey: ["conversations", app.id] }); // 延迟刷新以获取LLM生成的对话名称 setTimeout(() => { queryClient.invalidateQueries({ queryKey: ["conversations", app.id] }); }, 3000); } catch (err) { if ((err as Error).name === "AbortError") return; toast.error("生成失败,请重试"); } finally { abortRef.current = null; setIsLoading(false); } }, [input, isLoading, app.id, selectedFormat, formatTemplates, queryClient]); const handleCopy = useCallback(async () => { await navigator.clipboard.writeText(output); setCopied(true); toast.success("已复制到剪贴板"); setTimeout(() => setCopied(false), 2000); }, [output]); const handleReset = useCallback(() => { if (abortRef.current) abortRef.current.abort(); setInput(""); setOutput(""); setSelectedFormat(""); }, []); return (
{CategoryIconComponent ? React.createElement(CategoryIconComponent, { className: "h-4 w-4" }) : null}

{app.name}

{app.description}

补全型
{/* 格式选择区域 */} {hasFormats && (
{Object.entries(formatTemplates).map(([key, fmt]) => ( ))}
{selectedFormat && formatTemplates[selectedFormat] && (

包含章节: {formatTemplates[selectedFormat].sections.join(" → ")}

)}
)}