auto-save 2026-05-13 09:37 (~4)
This commit is contained in:
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
40
api/main.py
40
api/main.py
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
{/* 已提取 */}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user