优化全能创作

This commit is contained in:
Azmat@qq.com
2026-09-14 11:47:49 +08:00
parent 02e5dbfb60
commit 463613c0d7
7 changed files with 875 additions and 197 deletions
+90 -32
View File
@@ -194,6 +194,11 @@
animation: omniMessageIn 220ms ease both;
}
/* 追问/素材选择卡:关掉入场 opacity,poll 合并时即使短暂重挂也不会闪没 */
.omni-elicit-card {
animation: none;
}
.omni-elicit-slot {
width: min(760px, calc(100% - 44px));
margin: 0 0 24px 44px;
@@ -370,37 +375,6 @@
font-size: 12px;
}
.omni-plan-matrix {
width: 100%;
border-collapse: separate;
border-spacing: 5px;
table-layout: fixed;
}
.omni-plan-matrix th,
.omni-plan-matrix td {
height: 28px;
border-radius: 7px;
color: #6d747e;
background: #f6f8fa;
font-size: 11px;
font-weight: 600;
text-align: center;
}
.omni-plan-matrix th:first-child,
.omni-plan-matrix td:first-child {
width: 106px;
padding-left: 8px;
color: #323944;
text-align: left;
}
.omni-plan-matrix td.hit {
color: #fff;
background: var(--klein);
}
.omni-plan-summary {
display: flex;
align-items: center;
@@ -507,6 +481,13 @@
background: rgba(255, 255, 255, .96);
box-shadow: 0 15px 34px rgba(20, 27, 38, .10);
backdrop-filter: blur(14px);
transition: border-color .18s ease, box-shadow .18s ease;
}
/* 「我想改」后点亮输入区,outline:0 的 textarea 本身看不出焦点 */
.omni-session-composer.is-revise-hint {
border-color: rgba(0, 47, 167, .42);
box-shadow: 0 15px 34px rgba(20, 27, 38, .10), 0 0 0 3px rgba(0, 47, 167, .14);
}
#omniSessionPrompt {
@@ -1157,7 +1138,8 @@
.omni-prompt-file-card,
.omni-result-card,
.omni-process-card,
.omni-elicit-slot {
.omni-elicit-slot,
.omni-step-confirm-card {
width: 100%;
margin-left: 0;
}
@@ -1313,6 +1295,76 @@
pointer-events: none;
}
/* ── 步骤确认(.omni-step-confirm-card)· 策略/方案/Prompt 闸门
与 .omni-strategy-card 同宽同左缩进,避免策略卡下方出现一条瘦操作条 */
.omni-step-confirm-card {
box-sizing: border-box;
width: min(760px, calc(100% - 44px));
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
margin: 0 0 24px 44px;
padding: 14px 16px;
border: 1px solid rgba(34, 42, 54, 0.09);
border-radius: 16px;
background: #fff;
box-shadow: 0 12px 30px rgba(20, 27, 38, .065);
/* 不走入场 opacity 动画:poll 重渲染时避免「闪一下」 */
animation: none;
}
.omni-step-confirm-card.is-submitted {
opacity: 0.72;
}
.omni-step-confirm-copy {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.omni-step-confirm-copy strong {
color: #222a36;
font-size: 14px;
font-weight: 600;
}
.omni-step-confirm-copy span {
color: #575d66;
font-size: 12px;
line-height: 1.4;
}
.omni-step-confirm-actions {
display: flex;
flex-shrink: 0;
gap: 8px;
}
.omni-step-confirm-actions button {
padding: 8px 16px;
border: 0;
border-radius: 8px;
background: var(--klein);
color: #fff;
font-size: 13px;
cursor: pointer;
}
.omni-step-confirm-actions button.is-secondary {
background: #fff;
border: 1px solid rgba(34, 42, 54, 0.14);
color: #222a36;
}
.omni-step-confirm-actions button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
/* ── 确认闸门(.omni-confirm-card)· 设计稿里是方案卡下面那条操作行,这里独立成卡。
积分数字挂在按钮里 —— 用户按下去之前就该知道要花多少。 */
.omni-confirm-card {
@@ -2008,6 +2060,12 @@
}
/* 闸门气泡所在行不入场闪:5 闸门按钮需在 awaiting_user 期间保持实心可见 */
.omni-chat-row.agent:has(.omni-gate-actions),
.omni-chat-row.agent:has(.omni-gate-answered) {
animation: none;
}
.omni-gate-bubble {
display: flex;
flex-direction: column;
+261 -91
View File
@@ -343,7 +343,6 @@ function StrategyCard({ payload }: { payload: Record<string, unknown> }) {
}
type PlanTimelineItem = { start: number; end: number; stage: string; desc?: string };
type PlanMatrix = { shots?: number; rows?: Array<{ point: string; hits?: number[] }> };
function coercePlanPoints(raw: unknown): string[] {
if (Array.isArray(raw)) {
@@ -387,9 +386,6 @@ function PlanCard({ payload }: { payload: Record<string, unknown> }) {
const points = coercePlanPoints(payload.points);
const timeline = (payload.timeline as PlanTimelineItem[] | undefined) || [];
const voice = coerceVoiceChars(payload.voice_chars);
const matrix = (payload.matrix as PlanMatrix | undefined) || undefined;
const matrixRows = Array.isArray(matrix?.rows) ? matrix!.rows! : [];
const shotCount = Math.max(4, Number(matrix?.shots) || 4);
const format = (value: number) => (Number.isInteger(value) ? String(value) : value.toFixed(1));
const pointLabels = ["核心支撑 P0", "视觉支撑 P0", "转化支撑 P0"];
@@ -397,7 +393,7 @@ function PlanCard({ payload }: { payload: Record<string, unknown> }) {
<section className="omni-video-plan-card">
<header className="omni-video-plan-head">
<strong></strong>
<span></span>
<span> Prompt</span>
</header>
<section className="omni-plan-section">
<strong></strong>
@@ -429,36 +425,6 @@ function PlanCard({ payload }: { payload: Record<string, unknown> }) {
</div>
</section>
) : null}
{matrixRows.length > 0 ? (
<section className="omni-plan-section">
<strong></strong>
<table className="omni-plan-matrix">
<thead>
<tr>
<th></th>
{Array.from({ length: shotCount }, (_, index) => (
<th key={index}> {index + 1}</th>
))}
</tr>
</thead>
<tbody>
{matrixRows.map((row) => {
const hits = new Set((row.hits || []).map((n) => Number(n)));
return (
<tr key={row.point}>
<td>{row.point}</td>
{Array.from({ length: shotCount }, (_, index) => (
<td key={index} className={hits.has(index + 1) ? "hit" : undefined}>
{hits.has(index + 1) ? "✓" : ""}
</td>
))}
</tr>
);
})}
</tbody>
</table>
</section>
) : null}
<div className="omni-plan-summary">
{voice.length === 2 ? (
<span>
@@ -503,28 +469,89 @@ function PromptFileCard({
);
}
/** 视频 5 步闸门:策略/方案/Prompt 下方的「按这个继续 / 我想改」。 */
function StepConfirmCard({
message,
disabled,
onConfirm,
onRevise,
}: {
message: CreationMessage;
disabled: boolean;
onConfirm: () => void;
onRevise: () => void;
}) {
const submitted = Boolean(message.payload.submitted);
const step = String(message.payload.step || "");
const answers = (message.payload.answers as Record<string, string> | undefined) || {};
const action = String(answers.step_action || "");
const stepLabel =
step === "strategy" ? "创作策略" : step === "plan" ? "视频方案" : step === "prompt" ? "出片 Prompt" : "这一步";
return (
<section className={`omni-step-confirm-card${submitted ? " is-submitted" : ""}`}>
<div className="omni-step-confirm-copy">
<strong>{submitted ? (action === "revise" ? `已收到对${stepLabel}的修改意见` : `已确认${stepLabel}`) : `请确认${stepLabel}`}</strong>
<span>{submitted ? (action === "revise" ? "正在按你的反馈重写" : "继续下一步") : (message.text || "确认后继续;要改可以直接说。")}</span>
</div>
{submitted ? null : (
<div className="omni-step-confirm-actions">
<button
type="button"
className="is-secondary"
disabled={disabled}
onMouseDown={(event) => {
// 避免按钮抢焦点,否则 focus composer 会被立刻冲掉
event.preventDefault();
}}
onClick={(event) => {
event.stopPropagation();
onRevise();
}}
>
</button>
<button
type="button"
disabled={disabled}
onClick={(event) => {
event.stopPropagation();
onConfirm();
}}
>
</button>
</div>
)}
</section>
);
}
/** 追问默认是普通聊天气泡;下面的卡片只为兼容旧会话数据。 */
function ElicitCard({
message,
disabled,
onSubmit,
onStepRevise,
}: {
message: CreationMessage;
disabled: boolean;
/** answers 给模型读(人话),refs 给后端取卖点和参考图。**选素材必须两样都回**,
只回名字的话同名素材会配错货。 */
onSubmit: (answers: Record<string, string | string[]>, refs: CreationRef[]) => void;
/** 步骤确认「我想改」:打开输入框让用户写反馈 */
onStepRevise?: () => void;
}) {
const fields = ((message.payload.fields as CreationField[] | undefined) || []).filter(Boolean);
const submitted = Boolean(message.payload.submitted);
const saved = (message.payload.answers as Record<string, string | string[]> | undefined) || {};
const interaction = String(message.payload.interaction || "");
// hooks 必须在任何 early return 之前:step_confirm / chat / gate 共用同一组件身份
const [answers, setAnswers] = useState<Record<string, string | string[]>>(saved);
const [assetOptions, setAssetOptions] = useState<Record<string, CreationRef[]>>({});
const [assetPicked, setAssetPicked] = useState<Record<string, CreationRef>>({});
useEffect(() => {
if (submitted || interaction === "chat") return;
if (submitted || interaction === "chat" || interaction === "step_confirm") return;
// 选素材字段要现拉候选:后端只给了 asset_types,具体有哪些素材是团队数据
const assetFields = fields.filter((f) => f.type === "asset");
if (assetFields.length === 0) return;
@@ -554,6 +581,20 @@ function ElicitCard({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [message.id, submitted, interaction]);
if (interaction === "step_confirm") {
return (
<StepConfirmCard
message={message}
disabled={disabled}
onConfirm={() => onSubmit({ step_action: "confirm" }, [])}
onRevise={() => {
if (onStepRevise) onStepRevise();
else onSubmit({ step_action: "revise" }, []);
}}
/>
);
}
if (interaction === "chat") {
return (
<div className="omni-chat-row agent">
@@ -1018,15 +1059,100 @@ function turnLooksSettled(detail: CreationConversationDetail) {
return msgs.slice(userIdx + 1).some((m) => m.role !== "user");
}
/** payload 是否实质相同(忽略 key 顺序之外的引用身份) */
function samePayload(a: Record<string, unknown>, b: Record<string, unknown>) {
return JSON.stringify(a || {}) === JSON.stringify(b || {});
}
/** 两条消息内容是否可原地复用(保持 React 组件身份,避免入场动画重放) */
function sameMessageContent(prev: CreationMessage, next: CreationMessage) {
return (
prev.id === next.id
&& prev.kind === next.kind
&& prev.role === next.role
&& (prev.text || "") === (next.text || "")
&& serverSeq(prev) === serverSeq(next)
&& samePayload(prev.payload || {}, next.payload || {})
);
}
/**
* 合并服务端快照时保留尚未 ack 的乐观气泡。
* 关键:只能把 local 对上「本轮新出现」的服务端用户消息(id 未见过或 seq 更大),
* 绝不能因为历史里有一条相同文案就把 pending local 吃掉。
* 合并服务端消息时保留本地 UI 态:
* - 乐观 submitted / answers 尚未落库时不要被 poll 冲掉
* - id/seq/payload 未变则复用原对象引用,避免 elicit / StepConfirm 卸载闪烁
*/
function mergeMessagePreserveLocal(prevHit: CreationMessage | undefined, msg: CreationMessage): CreationMessage {
if (prevHit && sameMessageContent(prevHit, msg)) {
return prevHit;
}
const clientKey = prevHit?.clientKey || msg.clientKey || msg.id;
if (!prevHit) {
return msg.clientKey === clientKey ? msg : { ...msg, clientKey };
}
// 开放中的追问/闸门:服务端尚未 submitted 时,保住本地已选选项与乐观提交
if (prevHit.kind === "elicit" && msg.kind === "elicit") {
const prevPayload = prevHit.payload || {};
const nextPayload = msg.payload || {};
if (prevPayload.submitted && !nextPayload.submitted) {
return {
...msg,
clientKey,
payload: {
...nextPayload,
submitted: true,
answers: prevPayload.answers ?? nextPayload.answers,
},
};
}
// 字段定义未变时复用旧 payload 引用,避免选中态随 poll 重置
if (
!nextPayload.submitted
&& !prevPayload.submitted
&& samePayload(
{ ...prevPayload, answers: undefined, submitted: undefined },
{ ...nextPayload, answers: undefined, submitted: undefined },
)
) {
return {
...prevHit,
text: msg.text || prevHit.text,
seq: msg.seq,
created_at: msg.created_at || prevHit.created_at,
clientKey,
payload: prevPayload,
};
}
}
return { ...msg, clientKey };
}
/**
* 合并服务端快照时保留尚未 ack 的乐观气泡,并稳住未变消息的对象引用。
* local 只对上「本轮新出现」的服务端用户消息,绝不按正文吞历史。
*/
function keepUnackedLocals(prev: CreationMessage[], incoming: CreationMessage[]): CreationMessage[] {
const prevById = new Map(prev.map((m) => [m.id, m]));
const locals = prev.filter((m) => isLocalUserId(m.id));
if (!locals.length) {
return incoming.map((msg) => (msg.clientKey ? msg : { ...msg, clientKey: msg.id }));
let changed = prev.length !== incoming.length;
const merged = incoming.map((msg, index) => {
const prevHit = prevById.get(msg.id) || (prev[index]?.id === msg.id ? prev[index] : undefined);
const next = mergeMessagePreserveLocal(prevHit, msg);
if (next !== prevHit) changed = true;
return next;
});
if (!changed) {
// 长度与每条引用都未变 → 直接复用旧数组,阻断无意义重渲染
let identical = true;
for (let i = 0; i < merged.length; i += 1) {
if (merged[i] !== prev[i]) {
identical = false;
break;
}
}
if (identical) return prev;
}
return merged;
}
const knownServerIds = new Set(
prev.filter((m) => !isLocalUserId(m.id)).map((m) => m.id)
@@ -1034,11 +1160,8 @@ function keepUnackedLocals(prev: CreationMessage[], incoming: CreationMessage[])
const knownSeq = lastKnownServerSeq(prev);
const used = new Set<string>();
const merged = incoming.map((msg) => {
const prevHit = prev.find((m) => m.id === msg.id);
const withKey = {
...msg,
clientKey: prevHit?.clientKey || msg.clientKey || msg.id,
};
const prevHit = prevById.get(msg.id);
let withKey = mergeMessagePreserveLocal(prevHit, msg);
if (msg.role !== "user") return withKey;
const isNewServerUser =
!knownServerIds.has(msg.id) && serverSeq(msg) > knownSeq;
@@ -1046,7 +1169,7 @@ function keepUnackedLocals(prev: CreationMessage[], incoming: CreationMessage[])
const local = locals.find((item) => !used.has(item.id) && sameUserBubble(item, msg));
if (!local) return withKey;
used.add(local.id);
return { ...msg, clientKey: local.clientKey || local.id };
return { ...withKey, clientKey: local.clientKey || local.id };
});
const leftover = locals.filter((m) => !used.has(m.id));
// leftover 追加在末尾;不要对历史做正文去重
@@ -1161,6 +1284,7 @@ export function OmniSessionPage({
return [makeLocalUserMessage(firstMessage?.trim() || "", firstRefs || [], id)];
});
const [prompt, setPrompt] = useState("");
const [composerHint, setComposerHint] = useState<string | null>(null);
const [pendingRefs, setPendingRefs] = useState<CreationRef[]>([]);
const [sessionUploads, setSessionUploads] = useState<CreationRef[]>(firstUploads || []);
const [uploading, setUploading] = useState(false);
@@ -1204,6 +1328,7 @@ export function OmniSessionPage({
const refs = message.refs || [];
setPrompt(stripMentionText(message.text, refs));
setPendingRefs(refs);
setComposerHint(null);
requestAnimationFrame(() => {
composerRef.current?.focus();
composerRef.current?.setSelectionRange(
@@ -1213,6 +1338,21 @@ export function OmniSessionPage({
});
}, []);
/** 「我想改」:露出底部输入框并聚焦,让用户写反馈(后端会把自由文本当 revise) */
const beginStepRevise = useCallback(() => {
setComposerHint("说说要改哪里…");
const focusComposer = () => {
const el = composerRef.current;
if (!el) return;
el.scrollIntoView({ behavior: "smooth", block: "nearest" });
el.focus({ preventScroll: true });
const len = el.value.length;
el.setSelectionRange(len, len);
};
focusComposer();
requestAnimationFrame(focusComposer);
}, []);
const feedRef = useRef<HTMLElement>(null);
const firstSentRef = useRef(false);
const pendingUserIdRef = useRef<string | null>(bootLocalIdRef.current);
@@ -1363,19 +1503,7 @@ export function OmniSessionPage({
.then((detail) => {
if (cancelled) return;
setConversation(detail);
setMessages((prev) => {
const before = new Map(prev.map((m) => [m.id, m]));
let changed = prev.length !== detail.messages.length;
const merged = detail.messages.map((next) => {
const old = before.get(next.id);
if (old && old.kind === next.kind && JSON.stringify(old.payload) === JSON.stringify(next.payload)) {
return old;
}
changed = true;
return next;
});
return keepUnackedLocals(prev, changed ? merged : prev);
});
setMessages((prev) => keepUnackedLocals(prev, detail.messages || []));
})
.catch(() => {
/* 轮询失败静默重试,不打扰用户 */
@@ -1402,24 +1530,7 @@ export function OmniSessionPage({
}
return detail;
});
setMessages((prev) => {
const before = new Map(prev.map((m) => [m.id, m]));
let changed = prev.length !== detail.messages.length;
const merged = detail.messages.map((next) => {
const oldMsg = before.get(next.id);
if (
oldMsg
&& oldMsg.kind === next.kind
&& oldMsg.text === next.text
&& JSON.stringify(oldMsg.payload) === JSON.stringify(next.payload)
) {
return oldMsg;
}
changed = true;
return next;
});
return keepUnackedLocals(prev, changed ? merged : prev);
});
setMessages((prev) => keepUnackedLocals(prev, detail.messages || []));
if (localId && pendingUserIdRef.current === localId) {
// 必须是「发送之后」新出现的用户消息,不能靠正文撞上历史气泡
const acked = detail.messages.some(
@@ -1510,7 +1621,22 @@ export function OmniSessionPage({
clearThinking();
}, [conversationId, notify]);
const isPlanning = conversation?.agent_status === "planning" || streaming;
// awaiting_user 时不要被短暂 streaming 闪回拖进 planning 轮询,否则闸门按钮会跟着灰/闪
const isPlanning =
conversation?.agent_status === "planning"
|| (Boolean(streaming) && conversation?.agent_status !== "awaiting_user");
// 若状态已是等待用户而 streaming 仍残留,立刻收起,避免 send 被 streamingRef 卡住、按钮被灰掉
useEffect(() => {
if (conversation?.agent_status !== "awaiting_user") return;
if (!streaming && !streamingRef.current) return;
holdPlanningUntilRef.current = 0;
streamingRef.current = false;
setStreaming(false);
setLiveText("");
setLiveReasoning("");
setActiveTool("");
}, [conversation?.agent_status, streaming]);
// 整理方案在 Celery:靠 poll 续进度。刷新/重进只要 agent_status=planning 就会接着转。
useEffect(() => {
@@ -1537,7 +1663,8 @@ export function OmniSessionPage({
const send = useCallback(
async (payload: Parameters<typeof api.creationSend>[1]) => {
if (streamingRef.current) return;
// awaiting_user 下允许回答闸门;其它状态仍禁止并发发送
if (streamingRef.current && conversation?.agent_status !== "awaiting_user") return;
// 先占位用户气泡,再亮「正在整理方案」—— 顺序不能反
const isTextTurn = payload.kind !== "elicit_answer";
let localId: string | null = null;
@@ -1648,7 +1775,7 @@ export function OmniSessionPage({
notify(status === 409 ? "info" : "error", (error as Error).message);
}
},
[conversationId, mergeCreationDetail, notify]
[conversationId, conversation?.agent_status, mergeCreationDetail, notify]
);
const handleStop = useCallback(async () => {
@@ -1724,10 +1851,12 @@ export function OmniSessionPage({
const handleSend = () => {
// 顺序要紧:先判 streaming 再清输入框。反过来的话,流式期间敲一次回车
// 会把已经打好的内容清空,消息却没发出去。
if (streaming || uploading) return;
if (uploading) return;
if (streaming && conversation?.agent_status !== "awaiting_user") return;
const text = prompt.trim();
if (!text && pendingRefs.length === 0) return;
setPrompt("");
setComposerHint(null);
const refs = pendingRefs;
setPendingRefs([]);
void send({ kind: "text", text, refs });
@@ -1784,10 +1913,30 @@ export function OmniSessionPage({
<ElicitCard
key={message.clientKey || message.id}
message={message}
disabled={streaming}
onSubmit={(answers, refs) =>
void send({ kind: "elicit_answer", reply_to: message.id, answers, refs })
// 可见性只看 payload.submittedawaiting_user 时即使 streaming 瞬间为 true 也不灰掉/误伤闸门按钮
disabled={
Boolean(message.payload?.submitted)
|| (streaming && conversation?.agent_status !== "awaiting_user")
}
onSubmit={(answers, refs) => {
// 乐观标记已提交,避免短路径返回前按钮还能连点
setMessages((prev) =>
prev.map((item) =>
item.id === message.id
? {
...item,
payload: {
...item.payload,
submitted: true,
answers: answers as Record<string, unknown>,
},
}
: item
)
);
void send({ kind: "elicit_answer", reply_to: message.id, answers, refs });
}}
onStepRevise={beginStepRevise}
/>
);
case "strategy":
@@ -1805,7 +1954,16 @@ export function OmniSessionPage({
})}
/>
);
case "confirm":
case "confirm": {
// 视频:若仍有未确认的步骤闸门,禁止点「开始生成」(防旧会话/异常连跳)
const pendingStep = isVideo
? messages.some(
(item) =>
item.kind === "elicit" &&
item.payload?.interaction === "step_confirm" &&
!item.payload?.submitted
)
: false;
return (
<ConfirmCard
key={message.clientKey || message.id}
@@ -1813,10 +1971,15 @@ export function OmniSessionPage({
sessionParams={params}
isVideo={isVideo}
catalogModels={modelConfigs}
disabled={streaming || confirming}
disabled={
confirming
|| pendingStep
|| (streaming && conversation?.agent_status !== "awaiting_user")
}
onConfirm={(nextParams) => void handleConfirm(message, nextParams)}
/>
);
}
case "generating":
return <ProcessCard key={message.clientKey || message.id} payload={message.payload} />;
case "result":
@@ -1913,7 +2076,7 @@ export function OmniSessionPage({
{/* 流式中的临时气泡。挂 is-live 关掉入场动画 —— 它每来一个字符都会
重渲染,带动画的话整条会一直在闪;真消息落地时它被替换掉,
那一下也不该再演一次入场。 */}
{(streaming || conversation?.agent_status === "planning") && !hasGenerating ? (
{(conversation?.agent_status !== "awaiting_user") && (streaming || conversation?.agent_status === "planning") && !hasGenerating ? (
/生成图片|生成视频/.test(activeTool) && !liveText ? (
<ProcessCard payload={{ kind: /生成视频/.test(activeTool) ? "video" : "image" }} />
) : liveText ? (
@@ -1942,7 +2105,7 @@ export function OmniSessionPage({
) : null}
</main>
<footer className="omni-session-composer">
<footer className={`omni-session-composer${composerHint ? " is-revise-hint" : ""}`}>
<MentionChips
refs={pendingRefs}
tone="composer"
@@ -1952,9 +2115,9 @@ export function OmniSessionPage({
id="omniSessionPrompt"
ref={composerRef}
rows={2}
placeholder="回复创作助手,也可以继续补充图片或要求……"
placeholder={composerHint || "回复创作助手,也可以继续补充图片或要求……"}
value={prompt}
disabled={streaming || uploading}
disabled={uploading || (streaming && conversation?.agent_status !== "awaiting_user")}
onChange={(event) => {
const value = event.target.value;
setPrompt(value);
@@ -2112,7 +2275,14 @@ export function OmniSessionPage({
className={`omni-session-send${isPlanning ? " is-stop" : ""}`}
aria-label={isPlanning ? "终止" : "发送"}
title={isPlanning ? "终止整理方案" : "发送"}
disabled={isPlanning ? stopping : (streaming || uploading)}
disabled={
isPlanning
? stopping
: (
uploading
|| (streaming && conversation?.agent_status !== "awaiting_user")
)
}
onClick={() => {
if (isPlanning) void handleStop();
else handleSend();