Files
GovAI/apps/web/src/components/app-ui/completion-ui.tsx
T
selfrelease 96b944ac8d chore: 前端代码优化 - ESLint 配置、Prettier 格式化、加载骨架屏、React 组件规范
- 更新 ESLint 配置添加 react-hooks 规则
- 运行 Prettier 格式化所有前端文件
- 为 users、apps、audit、quotas 页面添加 Skeleton 加载骨架屏
- 修复动态组件渲染问题(CategoryIcon/TypeIcon 使用 React.createElement)
- 修复 useRef 渲染期间访问问题(改用 useMemo)
- 修复 effect 中 setState 同步调用问题
- 新增 global-not-found.tsx 404 页面
- 修复 knowledge 页面引号转义问题
2026-06-23 14:48:31 +08:00

294 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<string>("");
const [conversationId, setConversationId] = useState<string | undefined>();
const abortRef = useRef<AbortController | null>(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<string, FormatTemplate> = 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 (
<div className="flex h-[calc(100vh-3.5rem)] overflow-hidden">
<ConversationSidebar
appId={app.id}
currentConvId={conversationId}
onSelectConversation={handleSelectConversation}
onNewConversation={handleNewConversation}
/>
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
<div className="border-b px-3 md:px-5 py-3 flex items-center gap-2 md:gap-3 shrink-0">
<div
className={`flex h-8 w-8 items-center justify-center rounded-lg ${categoryColor} shrink-0`}
>
{CategoryIconComponent
? React.createElement(CategoryIconComponent, { className: "h-4 w-4" })
: null}
</div>
<div className="min-w-0">
<h1 className="font-semibold text-sm truncate">{app.name}</h1>
<p className="text-xs text-muted-foreground truncate">{app.description}</p>
</div>
<div className="ml-auto flex items-center gap-1.5 text-xs text-blue-800 bg-blue-100 px-2 py-1 rounded-full font-medium">
<FileText className="h-3 w-3" />
<span className="hidden sm:inline"></span>
</div>
</div>
<div className="flex-1 overflow-auto">
<div className="mx-auto w-full max-w-7xl px-3 md:px-6 lg:px-8 py-4 md:py-6 space-y-4 md:space-y-6">
{/* 格式选择区域 */}
{hasFormats && (
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-3">
<ListChecks className="h-4 w-4 text-muted-foreground" />
<label className="text-sm font-medium"></label>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-2">
{Object.entries(formatTemplates).map(([key, fmt]) => (
<button
key={key}
onClick={() => setSelectedFormat(selectedFormat === key ? "" : key)}
className={`text-left p-3 rounded-lg border transition-all ${
selectedFormat === key
? "border-emerald-500 bg-emerald-50 ring-1 ring-emerald-500"
: "border-border hover:border-emerald-300 hover:bg-emerald-50/50"
}`}
>
<div className="text-sm font-medium truncate">{fmt.name}</div>
<div className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
{fmt.description}
</div>
</button>
))}
</div>
{selectedFormat && formatTemplates[selectedFormat] && (
<div className="mt-3 p-2.5 rounded-md bg-emerald-50 border border-emerald-200/60">
<p className="text-xs text-emerald-700">
<span className="font-medium"></span>
{formatTemplates[selectedFormat].sections.join(" → ")}
</p>
</div>
)}
</CardContent>
</Card>
)}
<Card>
<CardContent className="p-5 space-y-3">
<label className="text-sm font-medium">{inputLabel}</label>
<Textarea
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={inputPlaceholder}
className="min-h-[160px] resize-none"
disabled={isLoading}
/>
<div className="flex items-center gap-2">
<Button
onClick={handleGenerate}
disabled={!input.trim() || isLoading}
className="gap-2"
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Sparkles className="h-4 w-4" />
)}
{isLoading ? "生成中..." : "生成"}
</Button>
{selectedFormat && formatTemplates[selectedFormat] && (
<span className="text-xs text-emerald-600 bg-emerald-50 px-2 py-1 rounded">
{formatTemplates[selectedFormat].name}
</span>
)}
{(output || input) && (
<Button variant="outline" onClick={handleReset} className="gap-2">
<RotateCcw className="h-4 w-4" />
</Button>
)}
</div>
</CardContent>
</Card>
{isLoading && !output && (
<Card>
<CardContent className="p-5 space-y-3">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</CardContent>
</Card>
)}
{output && (
<Card className="border-emerald-200/60 bg-emerald-50/30">
<CardContent className="p-5">
<div className="flex items-center justify-between mb-3">
<label className="text-sm font-medium text-emerald-800">{outputLabel}</label>
<Button
variant="ghost"
size="sm"
onClick={handleCopy}
className="gap-1.5 text-xs h-7"
>
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
{copied ? "已复制" : "复制"}
</Button>
</div>
<GovMarkdown content={output} />
</CardContent>
</Card>
)}
</div>
</div>
</div>
</div>
);
}