修改全能创作发现问题
This commit is contained in:
@@ -17,6 +17,7 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { api } from "../api";
|
||||
import { OmniParamBar } from "../components/omni-param-bar";
|
||||
import { MediaLightbox } from "../components/overlays";
|
||||
import type {
|
||||
CreationConversationDetail,
|
||||
@@ -149,14 +150,10 @@ function StrategyCard({ payload }: { payload: Record<string, unknown> }) {
|
||||
}
|
||||
|
||||
type PlanTimelineItem = { start: number; end: number; stage: string; desc?: string };
|
||||
type PlanMatrixRow = { point: string; hits: number[] };
|
||||
|
||||
function PlanCard({ payload }: { payload: Record<string, unknown> }) {
|
||||
const points = (payload.points as string[] | undefined) || [];
|
||||
const timeline = (payload.timeline as PlanTimelineItem[] | undefined) || [];
|
||||
const matrix = (payload.matrix as { shots?: number; rows?: PlanMatrixRow[] } | undefined) || {};
|
||||
const shots = matrix.shots || 4;
|
||||
const rows = matrix.rows || [];
|
||||
const voice = (payload.voice_chars as number[] | undefined) || [];
|
||||
const format = (value: number) => (Number.isInteger(value) ? String(value) : value.toFixed(1));
|
||||
|
||||
@@ -196,33 +193,6 @@ function PlanCard({ payload }: { payload: Record<string, unknown> }) {
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
{rows.length > 0 ? (
|
||||
<section className="omni-plan-section">
|
||||
<strong>卖点覆盖矩阵</strong>
|
||||
<table className="omni-plan-matrix">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>卖点</th>
|
||||
{Array.from({ length: shots }, (_, i) => (
|
||||
<th key={i}>镜头 {i + 1}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.point}>
|
||||
<td>{row.point}</td>
|
||||
{Array.from({ length: shots }, (_, i) => (
|
||||
<td key={i} className={row.hits?.includes(i + 1) ? "hit" : undefined}>
|
||||
{row.hits?.includes(i + 1) ? "✓" : ""}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
) : null}
|
||||
<div className="omni-plan-summary">
|
||||
{voice.length === 2 ? (
|
||||
<span>
|
||||
@@ -426,24 +396,83 @@ function ElicitCard({
|
||||
* 确认闸门:方案卡下面那条「开始生成 · 约 N 积分」。
|
||||
* 点一次就锁住 —— 连点两下后端会 409,但前端也不该让它发生第二次。
|
||||
*/
|
||||
function asStringMap(value: unknown): Record<string, string> {
|
||||
if (!value || typeof value !== "object") return {};
|
||||
const out: Record<string, string> = {};
|
||||
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (item != null && item !== "") out[key] = String(item);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function paramLine(params: Record<string, string>, isVideo: boolean) {
|
||||
|
||||
return [
|
||||
params.model,
|
||||
isVideo ? params.resolution : "",
|
||||
params.ratio,
|
||||
isVideo ? params.duration : params.count,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
}
|
||||
|
||||
function ConfirmCard({
|
||||
message,
|
||||
sessionParams,
|
||||
isVideo,
|
||||
disabled,
|
||||
onConfirm,
|
||||
}: {
|
||||
message: CreationMessage;
|
||||
sessionParams: Record<string, string>;
|
||||
isVideo: boolean;
|
||||
disabled: boolean;
|
||||
onConfirm: () => void;
|
||||
onConfirm: (params: Record<string, string>) => void;
|
||||
}) {
|
||||
const submitted = Boolean(message.payload.submitted);
|
||||
const credits = Number(message.payload.estimated_credits || 0);
|
||||
const payloadParams = asStringMap(message.payload.params);
|
||||
const snapshot = { ...sessionParams, ...payloadParams };
|
||||
const [draft, setDraft] = useState(snapshot);
|
||||
const cardIsVideo = message.payload.kind !== "image" && isVideo;
|
||||
const durationChanged =
|
||||
cardIsVideo && Boolean(draft.duration) && Boolean(snapshot.duration) && draft.duration !== snapshot.duration;
|
||||
const setField = (key: string, value: string) => setDraft((prev) => ({ ...prev, [key]: value }));
|
||||
const summary = paramLine(draft, cardIsVideo) || "当前参数";
|
||||
|
||||
return (
|
||||
<section className="omni-confirm-card">
|
||||
<span>{submitted ? "已确认,正在出片" : "方案确认后即可出片,中途不再打断"}</span>
|
||||
<button type="button" disabled={disabled || submitted} onClick={onConfirm}>
|
||||
{String(message.payload.label || "开始生成")}
|
||||
{credits > 0 ? <i>约 {credits} 积分</i> : null}
|
||||
</button>
|
||||
<div className="omni-confirm-copy">
|
||||
<strong>{submitted ? "已确认" : `即将用 ${summary} 生成`}</strong>
|
||||
<span>{submitted ? "正在提交生成" : "确认前可以改参数。改时长会按新时长重写脚本。"}</span>
|
||||
</div>
|
||||
{submitted ? null : (
|
||||
<div className="omni-confirm-params">
|
||||
<OmniParamBar
|
||||
isVideo={cardIsVideo}
|
||||
disabled={disabled}
|
||||
model={draft.model || ""}
|
||||
resolution={draft.resolution || ""}
|
||||
ratio={draft.ratio || ""}
|
||||
duration={cardIsVideo ? (draft.duration || "") : (draft.count || "")}
|
||||
onModel={(value) => setField("model", value)}
|
||||
onResolution={(value) => setField("resolution", value)}
|
||||
onRatio={(value) => setField("ratio", value)}
|
||||
onDuration={(value) => setField(cardIsVideo ? "duration" : "count", value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{durationChanged ? (
|
||||
<p className="omni-confirm-hint">改时长会重新生成脚本。确认后先重写方案,不会直接出片。</p>
|
||||
) : null}
|
||||
<div className="omni-confirm-foot">
|
||||
<span>{submitted ? "已确认,正在出片" : "点确认后才会提交生成"}</span>
|
||||
<button type="button" disabled={disabled || submitted} onClick={() => onConfirm(draft)}>
|
||||
{durationChanged ? "确认并重写脚本" : String(message.payload.label || "开始生成")}
|
||||
{!durationChanged && credits > 0 ? <i>约 {credits} 积分</i> : null}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -592,12 +621,21 @@ const MENTION_TABS: Array<{ type: CreationRef["type"]; label: string; Icon: type
|
||||
{ type: "scene", label: "场景", Icon: FolderOpen },
|
||||
];
|
||||
|
||||
function mergeSessionUploads(results: CreationRef[], uploads: CreationRef[], query: string, type: CreationRef["type"]) {
|
||||
if (type !== "asset") return results;
|
||||
const q = query.trim().toLowerCase();
|
||||
const extras = uploads.filter((item) => !q || item.name.toLowerCase().includes(q));
|
||||
const seen = new Set(extras.map((item) => item.id));
|
||||
return [...extras, ...results.filter((item) => !seen.has(item.id))];
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────── 页面
|
||||
|
||||
export function OmniSessionPage({
|
||||
conversationId,
|
||||
firstMessage,
|
||||
firstRefs,
|
||||
firstUploads,
|
||||
navigate,
|
||||
onNotify,
|
||||
}: {
|
||||
@@ -605,6 +643,7 @@ export function OmniSessionPage({
|
||||
/** 首页「开始创作」带过来的第一句话。只在刚进页面时发一次,刷新后不重发(它已经在库里)。 */
|
||||
firstMessage?: string;
|
||||
firstRefs?: CreationRef[];
|
||||
firstUploads?: CreationRef[];
|
||||
navigate: NavigateFn;
|
||||
onNotify?: (type: "success" | "error" | "info", text: string) => void;
|
||||
}) {
|
||||
@@ -612,6 +651,9 @@ export function OmniSessionPage({
|
||||
const [messages, setMessages] = useState<CreationMessage[]>([]);
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [pendingRefs, setPendingRefs] = useState<CreationRef[]>([]);
|
||||
const [sessionUploads, setSessionUploads] = useState<CreationRef[]>(firstUploads || []);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [streaming, setStreaming] = useState(false);
|
||||
const [liveText, setLiveText] = useState("");
|
||||
const [activeTool, setActiveTool] = useState("");
|
||||
@@ -708,17 +750,29 @@ export function OmniSessionPage({
|
||||
// firstSentRef 挡住 StrictMode 的双次挂载,否则会重复发一条。
|
||||
useEffect(() => {
|
||||
if (!conversation || firstSentRef.current) return;
|
||||
if (!firstMessage?.trim() || conversation.messages.length > 0) {
|
||||
const text = firstMessage?.trim() || "";
|
||||
const refs = firstRefs || [];
|
||||
if ((!text && refs.length === 0) || conversation.messages.length > 0) {
|
||||
firstSentRef.current = true;
|
||||
return;
|
||||
}
|
||||
firstSentRef.current = true;
|
||||
void send({ kind: "text", text: firstMessage.trim(), refs: firstRefs || [] });
|
||||
void send({ kind: "text", text, refs });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [conversation, firstMessage]);
|
||||
|
||||
useEffect(() => () => abortRef.current?.abort(), []);
|
||||
|
||||
useEffect(() => {
|
||||
const fromHistory = messages.flatMap((message) => message.refs || []).filter((ref) => ref.type === "asset");
|
||||
if (!fromHistory.length) return;
|
||||
setSessionUploads((prev) => {
|
||||
const seen = new Set(prev.map((item) => item.id));
|
||||
const extra = fromHistory.filter((item) => !seen.has(item.id));
|
||||
return extra.length ? [...prev, ...extra] : prev;
|
||||
});
|
||||
}, [messages]);
|
||||
|
||||
const hasGenerating = useMemo(
|
||||
() => messages.some((message) => message.kind === "generating"),
|
||||
[messages]
|
||||
@@ -863,18 +917,32 @@ export function OmniSessionPage({
|
||||
[conversationId, applyEvent, notify]
|
||||
);
|
||||
|
||||
const handleConfirm = async (message: CreationMessage) => {
|
||||
const handleConfirm = async (message: CreationMessage, nextParams: Record<string, string>) => {
|
||||
if (confirming) return;
|
||||
setConfirming(true);
|
||||
try {
|
||||
const { message: generating } = await api.confirmCreationPlan(conversationId, message.id);
|
||||
// 闸门置灰 + 追加「生成中」卡;结果由轮询回填
|
||||
setMessages((prev) => [
|
||||
...prev.map((m) =>
|
||||
m.id === message.id ? { ...m, payload: { ...m.payload, submitted: true } } : m
|
||||
),
|
||||
generating,
|
||||
]);
|
||||
const result = await api.confirmCreationPlan(conversationId, message.id, nextParams);
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === message.id
|
||||
? { ...m, payload: { ...m.payload, submitted: true, params: nextParams } }
|
||||
: m
|
||||
)
|
||||
);
|
||||
setConversation((prev) =>
|
||||
prev ? { ...prev, params: { ...prev.params, ...nextParams } } : prev
|
||||
);
|
||||
if (result.regenerate) {
|
||||
notify("info", `时长已改为 ${nextParams.duration || ""},正在按新时长重写脚本`);
|
||||
void send({
|
||||
kind: "text",
|
||||
text: `时长改成了${nextParams.duration},请按新参数重新写方案,旧方案作废`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (result.message) {
|
||||
setMessages((prev) => [...prev, result.message as CreationMessage]);
|
||||
}
|
||||
} catch (error) {
|
||||
notify("error", (error as Error).message);
|
||||
} finally {
|
||||
@@ -885,7 +953,7 @@ export function OmniSessionPage({
|
||||
const handleSend = () => {
|
||||
// 顺序要紧:先判 streaming 再清输入框。反过来的话,流式期间敲一次回车
|
||||
// 会把已经打好的内容清空,消息却没发出去。
|
||||
if (streaming) return;
|
||||
if (streaming || uploading) return;
|
||||
const text = prompt.trim();
|
||||
if (!text && pendingRefs.length === 0) return;
|
||||
setPrompt("");
|
||||
@@ -902,7 +970,7 @@ export function OmniSessionPage({
|
||||
setMentionResults([]);
|
||||
try {
|
||||
const res = await api.searchMentions({ q: query, types: [type], limit: 12 });
|
||||
setMentionResults(res.results);
|
||||
setMentionResults(mergeSessionUploads(res.results, sessionUploads, query, type));
|
||||
setTypeLabels(res.type_labels);
|
||||
} catch (error) {
|
||||
notify("error", (error as Error).message);
|
||||
@@ -971,8 +1039,10 @@ export function OmniSessionPage({
|
||||
<ConfirmCard
|
||||
key={message.id}
|
||||
message={message}
|
||||
sessionParams={params}
|
||||
isVideo={isVideo}
|
||||
disabled={streaming || confirming}
|
||||
onConfirm={() => void handleConfirm(message)}
|
||||
onConfirm={(nextParams) => void handleConfirm(message, nextParams)}
|
||||
/>
|
||||
);
|
||||
case "generating":
|
||||
@@ -1086,9 +1156,9 @@ export function OmniSessionPage({
|
||||
<textarea
|
||||
id="omniSessionPrompt"
|
||||
rows={2}
|
||||
placeholder="回复创作助手,也可以继续补充图片、视频或要求……"
|
||||
placeholder="回复创作助手,也可以继续补充图片或要求……"
|
||||
value={prompt}
|
||||
disabled={streaming}
|
||||
disabled={streaming || uploading}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value;
|
||||
setPrompt(value);
|
||||
@@ -1134,15 +1204,55 @@ export function OmniSessionPage({
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setUploadMenuOpen(false);
|
||||
notify("info", "本地上传稍后接入");
|
||||
fileInputRef.current?.click();
|
||||
}}
|
||||
>
|
||||
<Upload />
|
||||
<span>
|
||||
本地上传<small>添加电脑中的图片或视频</small>
|
||||
本地上传<small>添加电脑中的图片</small>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
hidden
|
||||
onChange={async (event) => {
|
||||
const files = Array.from(event.target.files || []);
|
||||
event.target.value = "";
|
||||
if (!files.length) return;
|
||||
const images = files.filter((file) => file.type.startsWith("image/"));
|
||||
if (images.length !== files.length) {
|
||||
notify("info", "全能创作仅支持上传图片");
|
||||
}
|
||||
if (!images.length) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
for (const file of images) {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
const data = await api.uploadFreeVideoRef(form);
|
||||
const ref: CreationRef = {
|
||||
type: "asset",
|
||||
id: data.asset_id,
|
||||
name: data.name || file.name,
|
||||
cover: data.thumb_url || data.url,
|
||||
};
|
||||
setSessionUploads((prev) => (prev.some((item) => item.id === ref.id) ? prev : [...prev, ref]));
|
||||
setPendingRefs((prev) => {
|
||||
if (prev.some((item) => item.id === ref.id) || prev.length >= MENTION_REF_LIMIT) return prev;
|
||||
return [...prev, ref];
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
notify("error", (error as Error).message);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="omni-session-mention-wrap" ref={mentionWrapRef}>
|
||||
<button
|
||||
@@ -1199,7 +1309,7 @@ export function OmniSessionPage({
|
||||
type="button"
|
||||
className="omni-session-send"
|
||||
aria-label="发送"
|
||||
disabled={streaming}
|
||||
disabled={streaming || uploading}
|
||||
onClick={handleSend}
|
||||
>
|
||||
<ArrowUp />
|
||||
|
||||
Reference in New Issue
Block a user