import { useEffect, useRef, useState } from "react"; import { BookOpen, Check, Download, Eye, EyeOff, ExternalLink, Film, Image as ImageIcon, KeyRound, Loader2, MessageSquare, Send, Sparkles, Trash2, Upload, Wand2 } from "lucide-react"; import { cn } from "@/lib/utils"; // Shape returned by /api/pi/image-generate, /video-create and /video-status // (see server/pi-reader.ts GenerateResult). interface GenerateResult { success: boolean; status?: number; latencyMs?: number; message?: string; images?: string[]; videoId?: string; taskStatus?: string; progress?: number; videoUrl?: string; retryable?: boolean; } interface AgnesConfigView { baseUrl: string; hasKey: boolean; maskedKey: string; } // Documented tiers/ratios for the images endpoint. const IMAGE_MODELS = ["agnes-image-2.5-flash", "agnes-image-2.1-flash"] as const; const IMAGE_SIZES = ["1K", "2K", "3K", "4K"] as const; const IMAGE_RATIOS = ["1:1", "3:4", "4:3", "16:9", "9:16", "2:3", "3:2", "21:9"] as const; // Videos: Flash tiers only accept 720P, so size is fixed and only ratio varies. const VIDEO_MODELS = ["agnes-video-2.5-flash", "agnes-video-2.5"] as const; const VIDEO_RATIOS = ["21:9", "16:9", "4:3", "1:1", "3:4", "9:16"] as const; // Agnes currently rejects durations outside its 4–12 second range. const VIDEO_SECONDS = ["5", "10", "12"] as const; const VIDEO_MODES = [ { value: "text", label: "文生视频" }, { value: "keyframe", label: "首尾帧控制" }, { value: "reference", label: "图片/音频参考" }, ] as const; // The status endpoint rate-limits below ~5s, so poll slower than the docs suggest. const POLL_INTERVAL_MS = 8000; const DONE_STATES = /^(completed|succeeded|success)$/i; const FAILED_STATES = /^(failed|error|cancelled|canceled)$/i; const AGNES_MODEL = "agnes-3.0-flash"; const AGNES_DOCS = "https://agnes-ai.com/zh-Hans/docs/agnes-30-flash.md"; type MessageContent = string | Array< { type: "text"; text: string } | { type: "image_url"; image_url: { url: string } } >; type ChatMessage = { role: "system" | "user" | "assistant"; content: MessageContent; imageUrl?: string }; interface ChatResult { success: boolean; status?: number; latencyMs?: number; message?: string; reply?: string; usage?: { totalTokens?: number }; } function messageText(content: MessageContent): string { if (typeof content === "string") return content; return content.filter((part): part is { type: "text"; text: string } => part.type === "text").map((part) => part.text).join("\\n"); } const inputCls = "w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-100 outline-none placeholder:text-gray-600 focus:border-blue-500"; const labelCls = "mb-1.5 block text-xs font-medium text-gray-400"; function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) { return ( ); } /** Split a textarea of one-per-line URLs into a clean list. */ function parseLines(raw: string): string[] { return raw .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean); } interface LocalImageReference { id: string; name: string; dataUrl: string; size: number; } const MAX_REFERENCE_IMAGE_BYTES = 20 * 1024 * 1024; const VIDEO_REFERENCE_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp"]); function readImageAsDataUrl(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { if (typeof reader.result !== "string") { reject(new Error(`无法读取 ${file.name}`)); return; } resolve({ id: `${file.name}:${file.size}:${file.lastModified}`, name: file.name, dataUrl: reader.result, size: file.size, }); }; reader.onerror = () => reject(new Error(`无法读取 ${file.name}`)); reader.readAsDataURL(file); }); } function formatFileSize(size: number): string { if (size < 1024 * 1024) return `${Math.max(1, Math.round(size / 1024))} KB`; return `${(size / (1024 * 1024)).toFixed(1)} MB`; } export function GeneratePage() { const [tab, setTab] = useState<"image" | "video" | "chat">("image"); // ── Agnes credentials ── // The key is stored server-side in ~/.pi/agent/agnes-config.json (0600) and // never sent back to the browser, so requests below carry no credentials. const [config, setConfig] = useState(null); const [keyDraft, setKeyDraft] = useState(""); const [showKey, setShowKey] = useState(false); const [savingKey, setSavingKey] = useState(false); const [keySaved, setKeySaved] = useState(false); const [keyError, setKeyError] = useState(""); const loadConfig = () => { fetch("/api/pi/agnes-config") .then((res) => res.json()) .then((data: AgnesConfigView) => setConfig(data)) .catch(() => setConfig({ baseUrl: "", hasKey: false, maskedKey: "" })); }; useEffect(loadConfig, []); const saveKey = async () => { const apiKey = keyDraft.trim(); if (!apiKey) return; setSavingKey(true); setKeyError(""); try { const res = await fetch("/api/pi/agnes-config", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ apiKey }), }); const data = (await res.json()) as { success: boolean }; if (!data.success) throw new Error(); setKeyDraft(""); setKeySaved(true); window.setTimeout(() => setKeySaved(false), 2000); loadConfig(); } catch { setKeyError("保存失败"); } finally { setSavingKey(false); } }; const ready = !!config?.hasKey; // ── Agnes 3.0 Flash chat state ── const [chatPrompt, setChatPrompt] = useState(""); const [chatSystemPrompt, setChatSystemPrompt] = useState(""); const [chatImageUrl, setChatImageUrl] = useState(""); const [chatMaxTokens, setChatMaxTokens] = useState("1024"); const [chatTemperature, setChatTemperature] = useState("0.7"); const [chatMessages, setChatMessages] = useState([]); const [chatBusy, setChatBusy] = useState(false); const [chatError, setChatError] = useState(""); const [chatMeta, setChatMeta] = useState<{ latencyMs?: number; totalTokens?: number } | null>(null); const sendChat = async () => { const text = chatPrompt.trim(); if (!ready || !text || chatBusy) return; setChatBusy(true); setChatError(""); const content: MessageContent = chatImageUrl.trim() ? [{ type: "text", text }, { type: "image_url", image_url: { url: chatImageUrl.trim() } }] : text; const userMessage: ChatMessage = { role: "user", content, imageUrl: chatImageUrl.trim() || undefined }; const history = [ ...(chatSystemPrompt.trim() ? [{ role: "system" as const, content: chatSystemPrompt.trim() }] : []), ...chatMessages.map(({ role, content: value }) => ({ role, content: value })), userMessage, ]; setChatMessages((previous) => [...previous, userMessage]); setChatPrompt(""); setChatImageUrl(""); try { const res = await fetch("/api/pi/agnes-chat", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model: AGNES_MODEL, messages: history, maxTokens: Number(chatMaxTokens) || 1024, temperature: Number(chatTemperature), }), }); const result = (await res.json()) as ChatResult; if (!result.success || !result.reply) { setChatError(`${result.status ? `HTTP ${result.status}:` : ""}${result.message || "请求失败"}`); setChatMessages((previous) => previous.slice(0, -1)); return; } setChatMessages((previous) => [...previous, { role: "assistant", content: result.reply! }]); setChatMeta({ latencyMs: result.latencyMs, totalTokens: result.usage?.totalTokens }); } catch { setChatError("请求失败,请检查开发服务器或网络连接"); setChatMessages((previous) => previous.slice(0, -1)); } finally { setChatBusy(false); } }; // ── Image state ── const [imageModel, setImageModel] = useState(IMAGE_MODELS[0]); const [imagePrompt, setImagePrompt] = useState(""); const [imageSize, setImageSize] = useState("1K"); const [imageRatio, setImageRatio] = useState("1:1"); const [imageFormat, setImageFormat] = useState<"url" | "b64_json">("url"); const [imageRefs, setImageRefs] = useState(""); const [localImageRefs, setLocalImageRefs] = useState([]); const [imageRefError, setImageRefError] = useState(""); const imageInputRef = useRef(null); const [imageBusy, setImageBusy] = useState(false); const [imageResult, setImageResult] = useState(null); // ── Video state ── const [videoModel, setVideoModel] = useState(VIDEO_MODELS[0]); const [videoPrompt, setVideoPrompt] = useState(""); const [videoMode, setVideoMode] = useState<"text" | "keyframe" | "reference">("text"); const [videoSeconds, setVideoSeconds] = useState("5"); const [videoRatio, setVideoRatio] = useState("16:9"); const [firstFrame, setFirstFrame] = useState(""); const [lastFrame, setLastFrame] = useState(""); const [videoImages, setVideoImages] = useState(""); const [localVideoImages, setLocalVideoImages] = useState([]); const [videoImageError, setVideoImageError] = useState(""); const videoImageInputRef = useRef(null); const [videoAudios, setVideoAudios] = useState(""); const [videoBusy, setVideoBusy] = useState(false); const [videoResult, setVideoResult] = useState(null); const pollTimer = useRef(null); const addLocalImageFiles = async (files: File[]) => { setImageRefError(""); const validFiles = files.filter((file) => file.type.startsWith("image/")); if (validFiles.length !== files.length) setImageRefError("只能添加图片文件"); const oversized = validFiles.find((file) => file.size > MAX_REFERENCE_IMAGE_BYTES); if (oversized) setImageRefError(`${oversized.name} 超过 20 MB,未添加`); const readable = validFiles.filter((file) => file.size <= MAX_REFERENCE_IMAGE_BYTES); if (readable.length === 0) return; try { const loaded = await Promise.all(readable.map(readImageAsDataUrl)); setLocalImageRefs((previous) => { const existing = new Set(previous.map((item) => item.id)); return [...previous, ...loaded.filter((item) => !existing.has(item.id))]; }); } catch (error) { setImageRefError(error instanceof Error ? error.message : "读取图片失败"); } }; const imageReferenceValues = [ ...parseLines(imageRefs), ...localImageRefs.map((image) => image.dataUrl), ]; const addLocalVideoImageFiles = async (files: File[]) => { setVideoImageError(""); const validFiles = files.filter((file) => VIDEO_REFERENCE_IMAGE_TYPES.has(file.type)); if (validFiles.length !== files.length) setVideoImageError("视频参考图仅支持 JPEG、PNG、WEBP"); const oversized = validFiles.find((file) => file.size > MAX_REFERENCE_IMAGE_BYTES); if (oversized) setVideoImageError(`${oversized.name} 超过 20 MB,未添加`); const readable = validFiles.filter((file) => file.size <= MAX_REFERENCE_IMAGE_BYTES); if (readable.length === 0) return; try { const loaded = await Promise.all(readable.map(readImageAsDataUrl)); setLocalVideoImages((previous) => { const existing = new Set(previous.map((item) => item.id)); return [...previous, ...loaded.filter((item) => !existing.has(item.id))]; }); } catch (error) { setVideoImageError(error instanceof Error ? error.message : "读取图片失败"); } }; const videoReferenceValues = [ ...parseLines(videoImages), ...localVideoImages.map((image) => image.dataUrl), ]; const stopPolling = () => { if (pollTimer.current !== null) { window.clearTimeout(pollTimer.current); pollTimer.current = null; } }; useEffect(() => stopPolling, []); const runImage = async () => { if (!ready || !imageModel.trim() || !imagePrompt.trim()) return; setImageBusy(true); setImageResult(null); try { const res = await fetch("/api/pi/image-generate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model: imageModel.trim(), prompt: imagePrompt.trim(), size: imageSize, ratio: imageRatio, responseFormat: imageFormat, image: imageReferenceValues, }), }); setImageResult((await res.json()) as GenerateResult); } catch { setImageResult({ success: false, message: "请求失败,请检查开发服务器是否在运行" }); } finally { setImageBusy(false); } }; /** Poll one task tick, then reschedule until it finishes or fails hard. */ const pollVideo = (videoId: string, model: string) => { const params = new URLSearchParams({ videoId, model }); pollTimer.current = window.setTimeout(async () => { let next: GenerateResult; try { const res = await fetch(`/api/pi/video-status?${params}`); next = (await res.json()) as GenerateResult; } catch { // Network blip — keep the task alive and try again. pollVideo(videoId, model); return; } // Rate limits and gateway errors are transient; keep showing progress. if (!next.success && next.retryable) { setVideoResult((prev) => ({ ...(prev ?? {}), ...next, success: true, message: undefined })); pollVideo(videoId, model); return; } setVideoResult(next); const finished = !!next.videoUrl || !next.success || FAILED_STATES.test(next.taskStatus ?? "") || DONE_STATES.test(next.taskStatus ?? ""); if (finished) { setVideoBusy(false); stopPolling(); return; } pollVideo(videoId, model); }, POLL_INTERVAL_MS); }; const runVideo = async () => { if (!ready || !videoModel.trim() || !videoPrompt.trim()) return; stopPolling(); setVideoBusy(true); setVideoResult(null); const model = videoModel.trim(); try { const res = await fetch("/api/pi/video-create", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model, prompt: videoPrompt.trim(), mode: videoMode, seconds: videoSeconds, size: "720P", aspectRatio: videoRatio, firstFrame: videoMode === "keyframe" ? firstFrame.trim() : undefined, lastFrame: videoMode === "keyframe" ? lastFrame.trim() : undefined, images: videoMode === "reference" ? videoReferenceValues : undefined, audios: videoMode === "reference" ? parseLines(videoAudios) : undefined, }), }); const created = (await res.json()) as GenerateResult; setVideoResult(created); if (created.success && created.videoId) { pollVideo(created.videoId, model); } else { setVideoBusy(false); } } catch { setVideoResult({ success: false, message: "请求失败,请检查开发服务器是否在运行" }); setVideoBusy(false); } }; return (

AGNES GENERATION

生图 / 生视频

Agnes AI 专区,填入一个 API Key 即可生成。与提供商与模型页面的聊天配置互不影响。

{/* ── Agnes API Key ── */}

Agnes API Key

{config?.hasKey ? ( {config.maskedKey} ) : ( 未配置 )}
setKeyDraft(e.target.value)} placeholder={config?.hasKey ? "输入新 Key 以替换" : "sk-…"} autoComplete="off" className={cn(inputCls, "pr-10 font-mono")} />

保存到 ~/.pi/agent/agnes-config.json(权限 0600),仅服务端读取,不会回传浏览器。端点 {config?.baseUrl || "—"}

{keyError &&

{keyError}

}
{([ { key: "image", icon: ImageIcon, label: "图片" }, { key: "video", icon: Film, label: "视频" }, { key: "chat", icon: MessageSquare, label: "Agnes 3.0" }, ] as const).map(({ key, icon: Icon, label }) => ( ))}
{tab === "image" ? (
setImageModel(e.target.value)} className={cn(inputCls, "font-mono")} /> {IMAGE_MODELS.map((id) => (