修改全能创作

This commit is contained in:
Azmat@qq.com
2026-09-15 17:45:28 +08:00
parent 243a3aa54b
commit d4f282bfa9
8 changed files with 1142 additions and 212 deletions
+1 -109
View File
@@ -461,6 +461,7 @@
align-items: center;
justify-content: center;
gap: 10px;
margin-top: 2px;
border: 0;
border-radius: 11px;
color: #fff;
@@ -515,60 +516,6 @@
.login-page .login-row a:hover,
.login-page .login-switch a:hover { text-decoration: underline; }
/* 三入口操作条:同宽收进卡片,小屏改纵向避免撑破 */
.login-page .login-actions {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr) minmax(0, 1fr);
align-items: stretch;
gap: 0;
min-width: 0;
width: 100%;
max-width: 100%;
overflow: hidden;
border-radius: 11px;
background: linear-gradient(135deg, #002d9f, #001d70 76%);
box-shadow: 0 14px 30px rgba(0, 25, 102, 0.32);
}
.login-page .login-actions .login-submit {
margin-top: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
}
.login-page .login-actions .login-submit:hover:not(:disabled) {
transform: none;
filter: brightness(1.08);
box-shadow: none;
}
.login-page .login-action-alt {
min-width: 0;
height: 54px;
padding: 0 8px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
border: 0;
border-radius: 0;
color: rgba(255, 255, 255, 0.88);
background: transparent;
font: inherit;
font-size: 12px;
line-height: 1.2;
cursor: pointer;
}
.login-page .login-action-alt + .login-submit,
.login-page .login-submit + .login-action-alt {
box-shadow: inset 1px 0 0 rgba(255, 255, 255, 0.16);
}
.login-page .login-action-alt span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.login-page .login-action-alt:hover { background: rgba(255, 255, 255, 0.08); }
.login-page .login-action-alt svg { width: 14px; height: 14px; flex: 0 0 auto; }
.login-page .login-switch {
margin: 18px 0 0;
text-align: center;
@@ -598,30 +545,6 @@
font-size: 12px;
transform: none;
}
.login-page .login-sso {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
min-width: 0;
}
.login-page .login-sso button {
min-width: 0;
height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 10px;
color: rgba(255, 255, 255, 0.72);
background: rgba(255, 255, 255, 0.045);
font: inherit;
font-size: 12px;
cursor: pointer;
}
.login-page .login-sso button:hover { background: rgba(255, 255, 255, 0.08); }
.login-page .login-sso svg { width: 14px; height: 14px; flex: 0 0 auto; }
.login-page .login-note {
margin: 0;
color: rgba(255, 255, 255, 0.52);
@@ -749,7 +672,6 @@
.login-page .login-form { gap: 14px; }
}
/* 窄屏:主登录在上,两侧入口两列在下 */
@media (max-width: 560px) {
.login-page .login-form-panel {
padding: 24px 14px 20px;
@@ -758,36 +680,6 @@
width: min(100%, 480px);
padding: 28px 16px 22px;
}
.login-page .login-actions {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
background: transparent;
box-shadow: none;
overflow: visible;
}
.login-page .login-actions .login-submit {
grid-column: 1 / -1;
order: -1;
border-radius: 11px;
background: linear-gradient(135deg, #002d9f, #001d70 76%);
box-shadow: 0 14px 30px rgba(0, 25, 102, 0.32);
}
.login-page .login-actions .login-submit:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 18px 36px rgba(0, 23, 94, 0.4);
filter: brightness(1.06);
}
.login-page .login-action-alt,
.login-page .login-action-alt + .login-submit,
.login-page .login-submit + .login-action-alt {
box-shadow: none;
}
.login-page .login-action-alt {
height: 44px;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 10px;
background: rgba(255, 255, 255, 0.045);
}
.login-page .login-row {
flex-wrap: wrap;
gap: 8px 12px;
+177 -18
View File
@@ -190,28 +190,12 @@
.omni-reply-guide {
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed var(--border-faint);
}
.omni-reply-guide p {
margin: 0 0 8px;
color: var(--black-alpha-56);
font-size: 12px;
line-height: 1.55;
}
.omni-reply-guide p > span {
margin-right: 6px;
color: var(--accent-black);
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: .04em;
}
.omni-reply-guide-options {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
@@ -247,6 +231,29 @@
cursor: not-allowed;
}
.omni-reply-custom-input {
display: flex;
flex: 1 1 220px;
gap: 6px;
max-width: 300px;
}
.omni-reply-custom-input .input {
min-width: 0;
height: 30px;
padding: 0 10px;
font-size: 12px;
}
.omni-reply-custom-input button {
flex: 0 0 30px;
width: 30px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
}
.omni-chat-row.is-user .omni-chat-bubble {
align-items: flex-end;
@@ -1261,10 +1268,24 @@
.omni-strategy-grid,
.omni-plan-points,
.omni-plan-timeline {
.omni-plan-timeline,
.omni-direction-list {
grid-template-columns: 1fr;
}
.omni-direction-card {
width: 100%;
margin-left: 0;
}
.omni-direction-custom {
flex-direction: column;
}
.omni-direction-custom button {
width: 100%;
}
.omni-plan-summary {
align-items: flex-start;
flex-direction: column;
@@ -1290,6 +1311,144 @@
padding: 14px 18px 16px;
}
/* 剧情反转预设的方向选择:三个完整方向直接平铺,避免只留一句「我准备了三个方向」。 */
.omni-direction-card {
box-sizing: border-box;
width: min(760px, calc(100% - 44px));
margin: 0 0 24px 44px;
overflow: hidden;
border-radius: 8px;
background: var(--surface);
box-shadow: inset 0 0 0 1px var(--border-faint);
}
.omni-direction-head {
display: flex;
align-items: center;
min-height: 62px;
padding: 12px 16px;
box-shadow: inset 0 -1px 0 var(--border-faint);
}
.omni-direction-head > div {
display: flex;
flex-direction: column;
gap: 3px;
}
.omni-direction-head strong {
color: var(--accent-black);
font-size: 14px;
font-weight: 600;
}
.omni-direction-head span {
color: var(--black-alpha-56);
font-size: 12px;
}
.omni-direction-list {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
padding: 14px 16px;
}
.omni-direction-list > button {
min-width: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 7px;
padding: 12px;
border: 0;
border-radius: 8px;
background: var(--background-base);
box-shadow: inset 0 0 0 1px var(--border-faint);
color: var(--accent-black);
text-align: left;
cursor: pointer;
transition: background 200ms ease, box-shadow 200ms ease, transform 100ms ease;
}
.omni-direction-list > button:hover:not(:disabled) {
background: var(--background-lighter);
box-shadow: inset 0 0 0 1px var(--black-alpha-24);
transform: translateY(-1px);
}
.omni-direction-list > button:focus-visible {
outline: none;
box-shadow: inset 0 0 0 1px var(--heat), 0 0 0 2px var(--heat-40);
}
.omni-direction-list > button.is-selected {
background: var(--heat-12);
box-shadow: inset 0 0 0 1px var(--heat-40);
}
.omni-direction-list > button:disabled {
cursor: default;
}
.omni-direction-index,
.omni-direction-list small {
color: var(--black-alpha-56);
font-size: 11px;
line-height: 1.4;
}
.omni-direction-list > button > strong {
font-size: 13px;
font-weight: 600;
line-height: 1.4;
}
.omni-direction-list p {
margin: 0;
color: var(--black-alpha-64);
font-size: 12px;
line-height: 1.55;
}
.omni-direction-list p b {
display: block;
margin-bottom: 2px;
color: var(--accent-black);
font-size: 11px;
font-weight: 600;
}
.omni-direction-custom {
display: flex;
gap: 8px;
padding: 0 16px 16px;
}
.omni-direction-custom .input {
min-width: 0;
flex: 1;
}
.omni-direction-custom button {
height: 36px;
flex: 0 0 auto;
padding: 0 14px;
border: 0;
border-radius: 8px;
background: var(--accent-black);
color: var(--surface);
font-size: 12px;
font-weight: 500;
cursor: pointer;
}
.omni-direction-custom button:disabled {
background: var(--black-alpha-12);
color: var(--black-alpha-48);
cursor: not-allowed;
}
.omni-elicit-field > label {
display: block;
margin-bottom: 9px;
+4 -13
View File
@@ -8,7 +8,6 @@ import {
LoaderCircle,
Lock,
LockKeyhole,
ScanLine,
ShieldCheck,
Sparkles,
Ticket,
@@ -559,18 +558,10 @@ export function AuthScreen({
</label>
<a href="#" onClick={(event) => { event.preventDefault(); showToast("重置密码", "请联系团队超管重置你的登录密码"); }}>?</a>
</div>
<div className="login-actions">
<button type="button" className="login-action-alt" onClick={() => showToast("微信扫码", "对接中 · 当前请用账号 + 密码登录")}>
<ScanLine /> <span></span>
</button>
<button className="login-submit" type="submit" disabled={busy}>
{busy ? (LOGIN_PROGRESS_COPY[loginProgress || "checking"] || "正在进入工作台") : "确认登录"}
{busy ? <LoaderCircle /> : <ArrowRight />}
</button>
<button type="button" className="login-action-alt" onClick={() => showToast("飞书 SSO", "对接中 · 当前请用账号 + 密码登录")}>
<ShieldCheck /> <span> SSO</span>
</button>
</div>
<button className="login-submit" type="submit" disabled={busy}>
{busy ? (LOGIN_PROGRESS_COPY[loginProgress || "checking"] || "正在进入工作台") : "确认登录"}
{busy ? <LoaderCircle /> : <ArrowRight />}
</button>
</form>
<p className="login-switch">
? <a href="/register" onClick={(event) => { event.preventDefault(); switchMode("register"); }}> </a>
+205 -33
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { type FormEvent, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import {
ArrowLeft,
@@ -43,7 +43,27 @@ type PromptBlock =
type ReplyOption = { label: string; text: string };
function replyOptionsForMessage(payload: Record<string, unknown>, isVideo: boolean): ReplyOption[] {
function stripNumericReplyInstruction(text: string): string {
return (text || "")
.replace(
/(?:\n)?(?:你|请)?\s*(?:直接)?(?:回复|选择|选)\s*(?:数字|编号)?\s*1\s*(?:[/、,,]\s*2)(?:\s*(?:[/、,,]\s*3))?[^。!?!\n]*(?:[。!?!]|$)/g,
"",
)
.trim();
}
function numberedReplyOptions(text: string): ReplyOption[] {
const matches = [...(text || "").matchAll(/(?:^|\n)\s*([1-3])[.、.]\s*(?:\*\*)?([^\n*]{1,80})/g)];
if (matches.length < 2) return [];
return matches.slice(0, 3).map((match) => ({
label: match[1],
text: `选择第${match[1]}个方向`,
}));
}
function replyOptionsForMessage(text: string, payload: Record<string, unknown>, isVideo: boolean): ReplyOption[] {
const numbered = numberedReplyOptions(text);
if (numbered.length) return numbered;
const stored = payload.reply_options;
if (Array.isArray(stored)) {
const options = stored
@@ -63,15 +83,55 @@ function replyOptionsForMessage(payload: Record<string, unknown>, isVideo: boole
{ label: "按这个方向出图", text: "按这个方向出图" },
{ label: "换个场景", text: "我想换个场景" },
{ label: "改人物状态", text: "我想改人物状态" },
];
];
}
function replyHintForMessage(payload: Record<string, unknown>, isVideo: boolean): string {
const stored = String(payload.reply_hint || "").trim();
if (stored) return stored;
return isVideo
? "可以回复「继续完善方案」,也可以选下方想调整的方向。"
: "可以回复「按这个方向出图」,也可以选下方想调整的方向。";
function ReplyActions({
options,
disabled,
onSubmit,
}: {
options: ReplyOption[];
disabled?: boolean;
onSubmit: (text: string) => void;
}) {
const [customIdea, setCustomIdea] = useState("");
const submitCustomIdea = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const text = customIdea.trim();
if (!text || disabled) return;
onSubmit(text);
setCustomIdea("");
};
return (
<div className="omni-reply-guide-options">
{options.map((option, index) => (
<button
type="button"
className={index === 0 && options.length < 3 ? "primary" : ""}
key={option.text}
disabled={disabled}
onClick={() => onSubmit(option.text)}
>
{option.label}
</button>
))}
<form className="omni-reply-custom-input" onSubmit={submitCustomIdea}>
<input
className="input"
value={customIdea}
disabled={disabled}
onChange={(event) => setCustomIdea(event.target.value)}
placeholder="输入自己的想法…"
aria-label="输入自己的想法"
/>
<button type="submit" aria-label="发送想法" disabled={disabled || !customIdea.trim()}>
<ArrowUp size={14} />
</button>
</form>
</div>
);
}
function parseMarkdownTable(lines: string[]): { headers: string[]; rows: string[][] } | null {
@@ -401,6 +461,25 @@ function collectSessionAssets(
}
// 视频确认卡保存的是最终用于出片的 Prompt。它不是聊天正文,而是本会话
// 的一份可复查资源;右侧资源栏始终只保留最新的一版,避免多次改稿堆出一串副本。
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
const payload = message.payload || {};
const body = String(
message.kind === "prompt_file" ? payload.body || "" : payload.video_prompt || "",
).trim();
if (!body) continue;
push({
key: `prompt:${message.id || index}`,
kind: "document",
title: String(payload.title || "视频生成Prompt.md"),
promptTitle: String(payload.title || "视频生成Prompt.md"),
promptBody: body,
});
break;
}
return out;
}
@@ -554,7 +633,15 @@ function PlanCard({ payload }: { payload: Record<string, unknown> }) {
);
}
function PromptFileCard({ payload }: { payload: Record<string, unknown> }) {
function PromptFileCard({
payload,
onView,
}: {
payload: Record<string, unknown>;
onView: (title: string, body: string) => void;
}) {
const title = String(payload.title || "视频生成Prompt.md");
const body = String(payload.body || "").trim();
return (
<section className="omni-prompt-file-card">
<span className="omni-prompt-file-icon">
@@ -564,6 +651,11 @@ function PromptFileCard({ payload }: { payload: Record<string, unknown> }) {
<strong></strong>
<small> {String(payload.ref_count ?? 0)} </small>
</div>
{body ? (
<button type="button" onClick={() => onView(title, body)}>
Prompt
</button>
) : null}
</section>
);
}
@@ -718,6 +810,7 @@ function ElicitCard({
const [answers, setAnswers] = useState<Record<string, string | string[]>>(saved);
const [assetOptions, setAssetOptions] = useState<Record<string, CreationRef[]>>({});
const [assetPicked, setAssetPicked] = useState<Record<string, CreationRef>>({});
const [customDirection, setCustomDirection] = useState("");
useEffect(() => {
if (submitted || interaction === "chat" || interaction === "step_confirm") return;
@@ -761,6 +854,76 @@ function ElicitCard({
);
}
if (interaction === "plot_twist_directions") {
const directions = Array.isArray(message.payload.directions)
? message.payload.directions
.filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === "object")
.map((item) => ({
id: String(item.id || "").trim(),
title: String(item.title || "剧情方向").trim(),
conflict: String(item.conflict || "").trim(),
productRole: String(item.product_role || "").trim(),
reversal: String(item.reversal || "").trim(),
tone: String(item.tone || "").trim(),
}))
.filter((item) => item.id)
: [];
const selected = String(saved.story_direction || "");
const submitCustomDirection = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const idea = customDirection.trim();
if (!idea || disabled) return;
onSubmit({ story_direction: idea }, []);
setCustomDirection("");
};
return (
<div className="omni-elicit-slot omni-direction-slot">
<section className={`omni-direction-card${submitted ? " is-submitted" : ""}`}>
<header className="omni-direction-head">
<div>
<strong></strong>
<span></span>
</div>
</header>
<div className="omni-direction-list">
{directions.map((direction, index) => {
const isSelected = selected === direction.id || selected === direction.title;
return (
<button
type="button"
key={direction.id}
className={isSelected ? "is-selected" : ""}
disabled={disabled || submitted}
onClick={() => onSubmit({ story_direction: direction.id }, [])}
>
<span className="omni-direction-index"> {index + 1}</span>
<strong>{direction.title}</strong>
<p><b></b>{direction.conflict}</p>
<p><b></b>{direction.productRole}</p>
<p><b></b>{direction.reversal}</p>
{direction.tone ? <small>{direction.tone}</small> : null}
</button>
);
})}
</div>
{!submitted ? (
<form className="omni-direction-custom" onSubmit={submitCustomDirection}>
<input
className="input"
value={customDirection}
disabled={disabled}
onChange={(event) => setCustomDirection(event.target.value)}
placeholder="或者写下你自己的剧情想法…"
aria-label="输入自己的剧情想法"
/>
<button type="submit" disabled={disabled || !customDirection.trim()}>使</button>
</form>
) : null}
</section>
</div>
);
}
const toggleMulti = (key: string, value: string) => {
setAnswers((prev) => {
const current = Array.isArray(prev[key]) ? (prev[key] as string[]) : [];
@@ -831,8 +994,7 @@ function ElicitCard({
<p className="omni-chat-text">{question}</p>
</div>
{!submitted ? (
<div className="omni-reply-guide" aria-label="下一步引导">
<p><span></span></p>
<div className="omni-reply-guide" aria-label="回复操作">
<div className="omni-reply-guide-options">
<button type="button" className="primary" onClick={() => document.getElementById("omniSessionPrompt")?.focus()}>
@@ -991,7 +1153,7 @@ function ConfirmCard({
const raw = String(draft.duration || "");
const digits = raw.replace(/\D/g, "");
// 与后端 video_duration 一致:「智能时长」/解析不出 → 15 秒
const duration = digits ? Math.max(4, Math.min(Number(digits), 30)) : 15;
const duration = digits ? Math.max(4, Math.min(Number(digits), 60)) : 15;
const est = estimateCost(model, { ratio: draft.ratio || "9:16", resolution, duration, refs: [] });
if (est.listed && est.points > 0) return est.points;
// 挂牌缺失时仍展示后端下发的预估(若有)
@@ -2145,6 +2307,26 @@ export function OmniSessionPage({
|| (streaming && conversation?.agent_status !== "awaiting_user")
}
onSubmit={(answers, refs) => {
const selectedDuration = String(answers.duration || "").trim();
if (selectedDuration) {
setConversation((prev) =>
prev ? { ...prev, params: { ...prev.params, duration: selectedDuration } } : prev
);
}
// 剧情反转预设选择故事深度后,顶部参数同步显示实际时长;服务端仍是最终事实来源。
if (message.payload?.interaction === "plot_twist_story_depth") {
const durationByDepth: Record<string, string> = {
"15s": "15 秒",
"30s": "30 秒",
"60s": "60 秒",
};
const duration = durationByDepth[String(answers.story_depth || "")];
if (duration) {
setConversation((prev) =>
prev ? { ...prev, params: { ...prev.params, duration } } : prev
);
}
}
// 乐观标记已提交,避免短路径返回前按钮还能连点
setMessages((prev) =>
prev.map((item) =>
@@ -2173,6 +2355,7 @@ export function OmniSessionPage({
<PromptFileCard
key={message.clientKey || message.id}
payload={message.payload}
onView={(title, body) => setPromptView({ title, body })}
/>
);
case "confirm": {
@@ -2212,15 +2395,15 @@ export function OmniSessionPage({
);
default: {
const refs = message.refs || [];
const body = stripMentionText(message.text, refs);
const rawBody = stripMentionText(message.text, refs);
const body = message.role === "assistant" ? stripNumericReplyInstruction(rawBody) : rawBody;
const pending = message.id === pendingUserId;
const showReplyGuide =
message.role === "assistant"
&& message.kind === "text"
&& messages[messages.length - 1]?.id === message.id
&& !streaming;
const replyHint = showReplyGuide ? replyHintForMessage(message.payload, isVideo) : "";
const replyOptions = showReplyGuide ? replyOptionsForMessage(message.payload, isVideo) : [];
const replyOptions = showReplyGuide ? replyOptionsForMessage(body, message.payload, isVideo) : [];
return (
<div
className={`omni-chat-row ${message.role === "user" ? "is-user" : "agent"}${pending ? " is-pending" : ""}`}
@@ -2239,23 +2422,12 @@ export function OmniSessionPage({
</div>
) : null}
{showReplyGuide ? (
<div className="omni-reply-guide" aria-label="下一步引导">
<p>
<span></span>
{replyHint}
</p>
<div className="omni-reply-guide-options">
{replyOptions.map((option, index) => (
<button
type="button"
className={index === 0 ? "primary" : ""}
key={option.text}
onClick={() => void send({ kind: "text", text: option.text })}
>
{option.label}
</button>
))}
</div>
<div className="omni-reply-guide" aria-label="回复操作">
<ReplyActions
options={replyOptions}
disabled={streaming}
onSubmit={(text) => void send({ kind: "text", text })}
/>
</div>
) : null}
{message.role === "user" && !pending ? (