96b944ac8d
- 更新 ESLint 配置添加 react-hooks 规则 - 运行 Prettier 格式化所有前端文件 - 为 users、apps、audit、quotas 页面添加 Skeleton 加载骨架屏 - 修复动态组件渲染问题(CategoryIcon/TypeIcon 使用 React.createElement) - 修复 useRef 渲染期间访问问题(改用 useMemo) - 修复 effect 中 setState 同步调用问题 - 新增 global-not-found.tsx 404 页面 - 修复 knowledge 页面引号转义问题
294 lines
11 KiB
TypeScript
294 lines
11 KiB
TypeScript
"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>
|
||
);
|
||
}
|