auto-save 2026-05-13 09:37 (~4)

This commit is contained in:
2026-05-13 09:37:15 +08:00
parent fdc3162535
commit 839a3f6d4b
4 changed files with 169 additions and 26 deletions

View File

@@ -1124,6 +1124,13 @@
"type": "session-heartbeat",
"message": "Claude 会话活跃 · 最近命令claude · 1 项未提交变更 · 最近提交auto-save 2026-05-13 09:25 (~2)",
"files_changed": 1
},
{
"ts": "2026-05-13T09:31:42+08:00",
"type": "commit",
"message": "auto-save 2026-05-13 09:31 (~3)",
"hash": "fdc3162",
"files_changed": 3
}
]
}

View File

@@ -462,6 +462,46 @@ class CreateJobReq(BaseModel):
url: str
class TranslateReq(BaseModel):
text: str
target: Literal["en", "zh"] = "en"
@app.post("/translate")
def translate_text(req: TranslateReq) -> dict:
"""单条文本翻译(给生图自定义提取元素 zh→en 用)"""
import re as _re
text = req.text.strip()
if not text:
return {"text": ""}
if not LLM_API_KEY:
raise HTTPException(503, "LLM_API_KEY 未配置")
target_label = "English" if req.target == "en" else "Simplified Chinese"
prompt = (
f"Translate the following text into concise {target_label}, suitable as an element "
"label in an image-generation prompt. Output only the translation itself — no quotes, "
"no punctuation, no explanation, no markdown.\n\n"
f"Input: {text}"
)
try:
resp = llm().chat.completions.create(
model=TRANSLATE_MODEL,
messages=[{"role": "user", "content": prompt}],
temperature=0.2,
max_tokens=200,
)
out = (resp.choices[0].message.content or "").strip()
if not out:
rc = getattr(resp.choices[0].message, "reasoning_content", "") or ""
if rc:
out = rc.strip().splitlines()[-1].strip()
out = _re.sub(r'^[\'"「『]+|[\'"」』]+$', "", out).strip()
return {"text": out}
except Exception as e:
raise HTTPException(500, f"translate failed: {e}")
@app.get("/health")
def health() -> dict:
return {

View File

@@ -1,10 +1,12 @@
"use client"
import { useEffect, useState } from "react"
import { createPortal } from "react-dom"
import { X, ChevronLeft, ChevronRight, Check, Sparkles, Wand2, Loader2, Eye, RefreshCw } from "lucide-react"
import { frameUrl, describeFrame, type KeyFrame, type Job } from "@/lib/api"
import { X, ChevronLeft, ChevronRight, Check, Sparkles, Wand2, Loader2, Eye, RefreshCw, Plus } from "lucide-react"
import { frameUrl, describeFrame, translateText, type KeyFrame, type Job } from "@/lib/api"
import { toast } from "sonner"
type CustomItem = { id: string; zh: string; en: string; translating: boolean }
interface Props {
jobId: string
frames: KeyFrame[]
@@ -18,11 +20,38 @@ interface Props {
}
export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, onChange, onToggleSelect, onJobUpdate, embedded = false }: Props) {
const [extractPrompt, setExtractPrompt] = useState("")
const [describing, setDescribing] = useState(false)
const [mounted, setMounted] = useState(false)
// 自定义提取元素 — 按 frame 隔离,切换 frame 后回到同一帧时还能看到之前加的
const [customsByFrame, setCustomsByFrame] = useState<Record<number, CustomItem[]>>({})
const [addInput, setAddInput] = useState("")
useEffect(() => setMounted(true), [])
const customs = activeIndex !== null ? (customsByFrame[activeIndex] || []) : []
const updateCustoms = (updater: (prev: CustomItem[]) => CustomItem[]) => {
if (activeIndex === null) return
const key = activeIndex
setCustomsByFrame((prev) => ({ ...prev, [key]: updater(prev[key] || []) }))
}
const addCustom = async (zhRaw: string, presetEn?: string) => {
const zh = zhRaw.trim()
if (!zh || activeIndex === null) return
const id = Math.random().toString(36).slice(2, 8)
const skipTranslate = !!presetEn
updateCustoms((prev) => [...prev, { id, zh, en: presetEn || "", translating: !skipTranslate }])
if (skipTranslate) return
try {
const en = await translateText(zh, "en")
updateCustoms((prev) => prev.map((c) => c.id === id ? { ...c, en, translating: false } : c))
} catch (e) {
updateCustoms((prev) => prev.map((c) => c.id === id ? { ...c, en: "", translating: false } : c))
toast.error("翻译失败:" + (e instanceof Error ? e.message : String(e)))
}
}
const removeCustom = (id: string) => updateCustoms((prev) => prev.filter((c) => c.id !== id))
useEffect(() => {
if (activeIndex === null) return
const onKey = (e: KeyboardEvent) => {
@@ -185,23 +214,24 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
</div>
)}
{desc.objects && desc.objects.length > 0 && (
<div className="rounded-md bg-pink-500/10 border border-pink-400/25 px-2.5 py-2">
<div className="text-[9.5px] uppercase tracking-widest text-pink-300 mb-1.5">
<div className="rounded-md bg-pink-500/10 border border-pink-400/25 px-2.5 py-1.5">
<div className="text-[9.5px] uppercase tracking-widest text-pink-300/90 mb-1.5">
·
</div>
<div className="space-y-1.5">
<div className="space-y-1">
{desc.objects.map((o, i) => (
<button
key={i}
onClick={() => setExtractPrompt(o.extract_prompt || o.name)}
className="w-full text-left rounded bg-white/[0.04] hover:bg-white/[0.08] border border-white/10 hover:border-pink-300/40 px-2 py-1.5 transition"
onClick={() => addCustom(o.name, o.extract_prompt)}
title={o.position ? `位置:${o.position}` : undefined}
className="w-full text-left rounded bg-white/[0.03] hover:bg-white/[0.08] border border-white/8 hover:border-pink-300/40 px-2 py-1 transition group/o"
>
<div className="text-white text-[11.5px] font-medium">
{o.name}
{o.position && <span className="text-white/40 ml-1.5 text-[10px]">· {o.position}</span>}
<div className="flex items-center gap-1 text-white text-[11px] font-medium">
<span className="truncate">{o.name}</span>
<Plus className="h-2.5 w-2.5 text-pink-300/60 opacity-0 group-hover/o:opacity-100 transition shrink-0 ml-auto" />
</div>
{o.extract_prompt && (
<div className="text-[10px] text-white/40 mt-0.5 truncate font-mono">{o.extract_prompt}</div>
<div className="text-[9.5px] text-white/40 mt-0.5 truncate font-mono leading-tight">{o.extract_prompt}</div>
)}
</button>
))}
@@ -212,29 +242,81 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
)}
</section>
{/* 自定义提取 */}
{/* 自定义提取 — 多条 + 中英对照 */}
<section>
<div className="flex items-center gap-1.5 mb-2 text-white text-[12.5px] font-semibold">
<Sparkles className="h-3.5 w-3.5" />
{customs.length > 0 && (
<span className="text-[10px] text-white/35 font-mono ml-0.5">· {customs.length}</span>
)}
</div>
<textarea
value={extractPrompt}
onChange={(e) => setExtractPrompt(e.target.value)}
placeholder="比如rightmost white bottle"
rows={2}
className="w-full text-[12px] px-2.5 py-1.5 rounded-md bg-black/40 border border-white/15 outline-none text-white placeholder:text-white/30 resize-none focus:ring-2 focus:ring-violet-400/50"
/>
{customs.length > 0 && (
<div className="space-y-1.5 mb-2">
{customs.map((c) => (
<div
key={c.id}
className="group/c relative rounded-md bg-white/[0.04] border border-white/10 px-2.5 py-1.5"
>
<div className="pr-5 text-white text-[11.5px] font-medium leading-tight truncate">{c.zh}</div>
<div className="pr-5 mt-0.5 text-[10px] font-mono leading-tight truncate">
{c.translating ? (
<span className="inline-flex items-center gap-1 text-white/40">
<Loader2 className="h-2.5 w-2.5 animate-spin" />
</span>
) : c.en ? (
<span className="text-white/45">{c.en}</span>
) : (
<span className="text-rose-300/60"> · </span>
)}
</div>
<button
onClick={() => removeCustom(c.id)}
className="absolute right-1.5 top-1.5 h-4 w-4 rounded-sm text-white/40 hover:text-white hover:bg-white/10 inline-flex items-center justify-center opacity-0 group-hover/c:opacity-100 transition"
title="删除"
>
<X className="h-2.5 w-2.5" />
</button>
</div>
))}
</div>
)}
<div className="flex items-center gap-1.5">
<input
value={addInput}
onChange={(e) => setAddInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.nativeEvent.isComposing) {
e.preventDefault()
if (addInput.trim()) {
addCustom(addInput)
setAddInput("")
}
}
}}
placeholder="输入中文 · 回车自动翻英文"
className="flex-1 text-[12px] px-2.5 py-1.5 rounded-md bg-black/40 border border-white/15 outline-none text-white placeholder:text-white/30 focus:ring-2 focus:ring-violet-400/40 focus:border-violet-400/40"
/>
<button
onClick={() => { if (addInput.trim()) { addCustom(addInput); setAddInput("") } }}
disabled={!addInput.trim()}
className="text-[11.5px] px-2.5 py-1.5 rounded-md bg-violet-500/60 hover:bg-violet-500/80 text-white inline-flex items-center justify-center gap-1 disabled:opacity-30 disabled:cursor-not-allowed transition shrink-0"
title="添加"
>
<Plus className="h-3.5 w-3.5" />
</button>
</div>
<button
disabled
className="mt-2 w-full text-[12px] py-1.5 rounded-md bg-violet-500/60 text-white inline-flex items-center justify-center gap-1.5 cursor-not-allowed disabled:opacity-50"
className="mt-2 w-full text-[11.5px] py-1.5 rounded-md bg-violet-500/40 text-white inline-flex items-center justify-center gap-1.5 cursor-not-allowed disabled:opacity-50"
title="即将上线:批量调 nano-banana-pro image edit"
>
<Wand2 className="h-3.5 w-3.5" />
</button>
<div className="mt-1.5 text-[10px] text-white/30 font-mono">
nano-banana-pro image edit
</div>
</section>
{/* 已提取 */}

View File

@@ -128,6 +128,20 @@ export async function describeFrame(jobId: string, frameIdx: number): Promise<Jo
return res.json()
}
export async function translateText(text: string, target: "en" | "zh" = "en"): Promise<string> {
const res = await fetch(`${API_BASE}/translate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text, target }),
})
if (!res.ok) {
const txt = await res.text().catch(() => "")
throw new Error(`translate ${res.status} ${txt.slice(0, 200)}`)
}
const j = await res.json()
return (j.text || "").toString()
}
export async function generateImage(
jobId: string,
frameIdx: number,