feat(canvas): 画布创作对话 - 统一流式 Markdown 展示与交互修复
This commit is contained in:
50
web/src/components/ai/ai-message-markdown.tsx
Normal file
50
web/src/components/ai/ai-message-markdown.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Streamdown, type Components } from "streamdown";
|
||||
import { code as streamdownCode } from "@streamdown/code";
|
||||
|
||||
import "streamdown/styles.css";
|
||||
|
||||
type AIMessageMarkdownProps = {
|
||||
children: string;
|
||||
isStreaming?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const components: Components = {
|
||||
h1: ({ children, ...props }) => <h2 {...props} className="ai-message-markdown-heading ai-message-markdown-heading-1">{children}</h2>,
|
||||
h2: ({ children, ...props }) => <h3 {...props} className="ai-message-markdown-heading ai-message-markdown-heading-2">{children}</h3>,
|
||||
h3: ({ children, ...props }) => <h4 {...props} className="ai-message-markdown-heading ai-message-markdown-heading-3">{children}</h4>,
|
||||
h4: ({ children, ...props }) => <h5 {...props} className="ai-message-markdown-heading ai-message-markdown-heading-4">{children}</h5>,
|
||||
p: ({ children, ...props }) => <p {...props} className="ai-message-markdown-paragraph">{children}</p>,
|
||||
ul: ({ children, ...props }) => <ul {...props} className="ai-message-markdown-list ai-message-markdown-list-unordered">{children}</ul>,
|
||||
ol: ({ children, ...props }) => <ol {...props} className="ai-message-markdown-list ai-message-markdown-list-ordered">{children}</ol>,
|
||||
li: ({ children, ...props }) => <li {...props} className="ai-message-markdown-list-item">{children}</li>,
|
||||
blockquote: ({ children, ...props }) => <blockquote {...props} className="ai-message-markdown-blockquote">{children}</blockquote>,
|
||||
pre: ({ children, ...props }) => <pre {...props} className="ai-message-markdown-pre">{children}</pre>,
|
||||
code: ({ children, className, ...props }) => <code {...props} className={`ai-message-markdown-code ${className || ""}`.trim()}>{children}</code>,
|
||||
a: ({ children, ...props }) => <a {...props} className="ai-message-markdown-link" target="_blank" rel="noreferrer">{children}</a>,
|
||||
hr: (props) => <hr {...props} className="ai-message-markdown-rule" />,
|
||||
table: ({ children, ...props }) => <div className="ai-message-markdown-table-wrap"><table {...props} className="ai-message-markdown-table">{children}</table></div>,
|
||||
th: ({ children, ...props }) => <th {...props} className="ai-message-markdown-table-cell ai-message-markdown-table-header">{children}</th>,
|
||||
td: ({ children, ...props }) => <td {...props} className="ai-message-markdown-table-cell">{children}</td>,
|
||||
input: ({ ...props }) => <input {...props} className="ai-message-markdown-task" disabled />,
|
||||
};
|
||||
|
||||
export function AIMessageMarkdown({ children, isStreaming = false, className = "" }: AIMessageMarkdownProps) {
|
||||
if (!children.trim()) return null;
|
||||
return (
|
||||
<Streamdown
|
||||
className={`ai-message-markdown ${className}`.trim()}
|
||||
mode="streaming"
|
||||
dir="auto"
|
||||
isAnimating={isStreaming}
|
||||
animated={isStreaming ? { animation: "fadeIn", duration: 140, sep: "word", stagger: 8 } : false}
|
||||
parseIncompleteMarkdown
|
||||
skipHtml
|
||||
lineNumbers={false}
|
||||
plugins={{ code: streamdownCode }}
|
||||
components={components}
|
||||
>
|
||||
{children}
|
||||
</Streamdown>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { ArrowUp, CheckCircle2, CircleAlert, ImagePlus, LoaderCircle, UserRound,
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import type { CanvasAgentOperationImpact } from "@/lib/canvas/canvas-agent-ops";
|
||||
import type { LocalUser } from "@/stores/use-user-store";
|
||||
import { AIMessageMarkdown } from "@/components/ai/ai-message-markdown";
|
||||
|
||||
export type CanvasAgentChatAttachment = { id: string; name: string; url: string };
|
||||
export type CanvasAgentMode = "online" | "local";
|
||||
@@ -20,7 +21,7 @@ export type CanvasAgentChatMessage = {
|
||||
|
||||
const WORKING_TEXT = "正在推演...";
|
||||
|
||||
export function AgentChatMessage({ item, theme, user, onRejectTool, onApproveTool }: { item: CanvasAgentChatMessage; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; user: LocalUser | null; onRejectTool?: (id: string) => void; onApproveTool?: (id: string) => void }) {
|
||||
export function AgentChatMessage({ item, theme, user, isStreaming = false, onRejectTool, onApproveTool }: { item: CanvasAgentChatMessage; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; user: LocalUser | null; isStreaming?: boolean; onRejectTool?: (id: string) => void; onApproveTool?: (id: string) => void }) {
|
||||
const isUser = item.role === "user";
|
||||
const isSystem = item.role === "system";
|
||||
const color = item.role === "error" ? "#dc2626" : item.role === "tool" ? "#2563eb" : theme.node.text;
|
||||
@@ -47,7 +48,7 @@ export function AgentChatMessage({ item, theme, user, onRejectTool, onApproveToo
|
||||
<div className={`flex items-start gap-2.5 ${isUser ? "justify-end" : "justify-start"}`}>
|
||||
{!isUser ? <AgentAvatar theme={theme} /> : null}
|
||||
<div className={`min-w-0 max-w-[86%] text-sm leading-6 ${isUser ? "rounded-md px-3 py-2.5 text-right" : "text-left"}`} style={{ color, ...(isUser ? { background: theme.accent.primarySoft } : {}) }}>
|
||||
<div className="whitespace-pre-wrap break-words text-left">{item.text}</div>
|
||||
{item.role === "assistant" ? <AIMessageMarkdown className="text-left" isStreaming={isStreaming}>{item.text}</AIMessageMarkdown> : <div className="whitespace-pre-wrap break-words text-left">{item.text}</div>}
|
||||
{item.attachments?.length ? <AgentMessageAttachments attachments={item.attachments} /> : null}
|
||||
{item.meta ? <div className="mt-1 text-[var(--fs-label)] opacity-45">{item.meta}</div> : null}
|
||||
</div>
|
||||
|
||||
@@ -743,7 +743,7 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, project
|
||||
<>
|
||||
{messages.map((message) => (
|
||||
<div key={message.id} className="space-y-2">
|
||||
<AgentChatMessage item={assistantMessageToChatMessage(message)} theme={theme} user={user} onRejectTool={rejectOnlineTool} onApproveTool={approveOnlineTool} />
|
||||
<AgentChatMessage item={assistantMessageToChatMessage(message)} theme={theme} user={user} isStreaming={agentBusy && message.id === messages.at(-1)?.id && message.role === "assistant"} onRejectTool={rejectOnlineTool} onApproveTool={approveOnlineTool} />
|
||||
{message.references?.length ? <MessageReferences message={message} /> : null}
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -547,7 +547,7 @@ export const CanvasLocalAgentPanel = memo(function CanvasLocalAgentPanel({ snaps
|
||||
<div ref={listRef} className="thin-scrollbar min-h-0 flex-1 space-y-4 overflow-y-auto p-4">
|
||||
{!messages.length && !pendingTool && !waiting ? <AgentChatEmptyState theme={theme} nodeCount={snapshot.nodes.length} onSelect={(text) => { setAgentState({ prompt: text }); void sendPrompt(text); }} /> : null}
|
||||
{messages.map((item) => (
|
||||
<AgentChatMessage key={item.id} item={agentMessageToChatMessage(item)} theme={theme} user={user} />
|
||||
<AgentChatMessage key={item.id} item={agentMessageToChatMessage(item)} theme={theme} user={user} isStreaming={(sending || waiting) && item.id === messages.at(-1)?.id && item.role === "assistant"} />
|
||||
))}
|
||||
{pendingTool ? <AgentPendingToolCard summary={summarizeCanvasAgentOps(pendingTool.input?.ops || []) || toolName(pendingTool.name)} detail={{ requestId: pendingTool.requestId, name: pendingTool.name, input: pendingTool.input, impact: previewCanvasAgentOps(pendingTool.input?.ops || [], snapshot) }} theme={theme} onReject={rejectPendingTool} onApprove={approvePendingTool} /> : null}
|
||||
{waiting && !pendingTool ? <AgentWorkingMessage theme={theme} /> : null}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { resolveCanvasDrawingReference } from "@/lib/canvas/canvas-drawing-refer
|
||||
import { compileCharacterReferencePrompt } from "@/lib/canvas/canvas-character-reference";
|
||||
import { nodeReferenceImage } from "@/lib/canvas/canvas-project-generation";
|
||||
import type { ModelReferenceLimits } from "@/lib/model-selection";
|
||||
import type { Asset } from "@/stores/use-asset-store";
|
||||
|
||||
export type CharacterGenerationReference = {
|
||||
nodeId: string;
|
||||
@@ -56,26 +57,27 @@ export type NodeGenerationInput = {
|
||||
character?: CharacterGenerationReference;
|
||||
};
|
||||
|
||||
export function buildNodeGenerationContext(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[], prompt: string, promptOnly = false): NodeGenerationContext {
|
||||
const inputs = buildNodeGenerationInputs(nodeId, nodes, connections);
|
||||
export function buildNodeGenerationContext(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[], prompt: string, assets: Asset[], promptOnly = false): NodeGenerationContext {
|
||||
const connectedInputs = buildNodeGenerationInputs(nodeId, nodes, connections);
|
||||
const inputs = [...connectedInputs, ...buildAssetGenerationInputs(assets)];
|
||||
const sourceNode = nodes.find((node) => node.id === nodeId);
|
||||
const storyboardInputs = getConnectedStoryboardRows(nodeId, nodes, connections);
|
||||
const hasExplicitNodeMention = /@\[node:[^\]]+\]/.test(normalizeLegacyNodeMentions(prompt, inputs));
|
||||
if ((sourceNode?.type === CanvasNodeType.Config && Boolean(sourceNode.metadata?.composerContent?.trim())) || hasExplicitNodeMention) {
|
||||
const hasExplicitResourceMention = /@\[(?:node|asset):[^\]]+\]/.test(normalizeLegacyNodeMentions(prompt, inputs));
|
||||
if ((sourceNode?.type === CanvasNodeType.Config && Boolean(sourceNode.metadata?.composerContent?.trim())) || hasExplicitResourceMention) {
|
||||
return buildComposerGenerationContext(inputs, prompt, [sourceNode?.metadata?.videoStartFrameNodeId, sourceNode?.metadata?.videoEndFrameNodeId].filter((id): id is string => Boolean(id)), promptOnly);
|
||||
}
|
||||
|
||||
const isStoryboardMedia = sourceNode?.type === CanvasNodeType.Image || sourceNode?.type === CanvasNodeType.Video;
|
||||
const basePrompt = isStoryboardMedia && storyboardInputs.length ? removeTrailingInputBlocks(prompt, storyboardInputs) : prompt;
|
||||
const textInputs = inputs.filter((input) => input.type === "text");
|
||||
const characterReferences = inputs.map((input) => input.character).filter((item): item is CharacterGenerationReference => Boolean(item));
|
||||
const textInputs = connectedInputs.filter((input) => input.type === "text");
|
||||
const characterReferences = connectedInputs.map((input) => input.character).filter((item): item is CharacterGenerationReference => Boolean(item));
|
||||
const upstreamText = textInputs
|
||||
.map((input) => input.text)
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
const referenceImages = inputs.map((input) => input.image).filter((image): image is ReferenceImage => Boolean(image));
|
||||
const referenceVideos = inputs.map((input) => input.video).filter((video): video is ReferenceVideo => Boolean(video));
|
||||
const referenceAudios = inputs.map((input) => input.audio).filter((audio): audio is ReferenceAudio => Boolean(audio));
|
||||
const referenceImages = connectedInputs.map((input) => input.image).filter((image): image is ReferenceImage => Boolean(image));
|
||||
const referenceVideos = connectedInputs.map((input) => input.video).filter((video): video is ReferenceVideo => Boolean(video));
|
||||
const referenceAudios = connectedInputs.map((input) => input.audio).filter((audio): audio is ReferenceAudio => Boolean(audio));
|
||||
|
||||
return {
|
||||
prompt: promptOnly ? prompt : upstreamText ? `${basePrompt}\n\n${upstreamText}` : basePrompt,
|
||||
@@ -112,7 +114,8 @@ function removeTrailingInputBlocks(prompt: string, inputs: NodeGenerationInput[]
|
||||
|
||||
function buildComposerGenerationContext(inputs: NodeGenerationInput[], prompt: string, videoFrameNodeIds: string[] = [], promptOnly = false): NodeGenerationContext {
|
||||
const normalizedPrompt = normalizeLegacyNodeMentions(prompt, inputs);
|
||||
const inputByNodeId = new Map(inputs.map((input) => [input.nodeId, input]));
|
||||
const inputByToken = new Map(inputs.map((input) => [generationInputToken(input), input]));
|
||||
const nodeInputById = new Map(inputs.filter((input) => !input.nodeId.startsWith("asset:")).map((input) => [input.nodeId, input]));
|
||||
const selectedInputs: NodeGenerationInput[] = [];
|
||||
const labelByNodeId = new Map<string, string>();
|
||||
const textBlocks: string[] = [];
|
||||
@@ -121,11 +124,11 @@ function buildComposerGenerationContext(inputs: NodeGenerationInput[], prompt: s
|
||||
let lastIndex = 0;
|
||||
let nextPrompt = "";
|
||||
|
||||
for (const match of normalizedPrompt.matchAll(/@\[node:([^\]]+)\]/g)) {
|
||||
for (const match of normalizedPrompt.matchAll(/@\[(node|asset):([^\]]+)\]/g)) {
|
||||
if (match.index === undefined) continue;
|
||||
hasToken = true;
|
||||
nextPrompt += normalizedPrompt.slice(lastIndex, match.index);
|
||||
const input = inputByNodeId.get(match[1]);
|
||||
const input = inputByToken.get(`${match[1]}:${match[2]}`);
|
||||
if (input) {
|
||||
let label = labelByNodeId.get(input.nodeId);
|
||||
if (!label) {
|
||||
@@ -145,7 +148,7 @@ function buildComposerGenerationContext(inputs: NodeGenerationInput[], prompt: s
|
||||
// 首尾帧是结构化生成参数,不受提示词中的 @ 引用筛选影响。
|
||||
const selectedNodeIds = new Set(selectedInputs.map((input) => input.nodeId));
|
||||
videoFrameNodeIds.forEach((nodeId) => {
|
||||
const input = inputByNodeId.get(nodeId);
|
||||
const input = nodeInputById.get(nodeId);
|
||||
if (!input?.image || selectedNodeIds.has(nodeId)) return;
|
||||
selectedInputs.push(input);
|
||||
selectedNodeIds.add(nodeId);
|
||||
@@ -189,7 +192,7 @@ function buildComposerGenerationContext(inputs: NodeGenerationInput[], prompt: s
|
||||
// 旧画布保存的是 @角色1 等显示标签;生成时升级为稳定节点 Token,避免标题或排序变化后引用错位。
|
||||
function normalizeLegacyNodeMentions(prompt: string, inputs: NodeGenerationInput[]) {
|
||||
const counts = { image: 0, drawing: 0, video: 0, audio: 0, text: 0, character: 0 };
|
||||
const labels = inputs.map((input) => {
|
||||
const labels = inputs.filter((input) => !input.nodeId.startsWith("asset:")).map((input) => {
|
||||
const kind = input.sourceKind === "drawing" ? "drawing" : input.type;
|
||||
return { label: generationLabel(kind, counts[kind]++), nodeId: input.nodeId };
|
||||
}).sort((a, b) => b.label.length - a.label.length);
|
||||
@@ -236,6 +239,22 @@ export function buildNodeGenerationInputs(nodeId: string, nodes: CanvasNodeData[
|
||||
});
|
||||
}
|
||||
|
||||
function buildAssetGenerationInputs(assets: Asset[]): NodeGenerationInput[] {
|
||||
return assets.flatMap((asset): NodeGenerationInput[] => {
|
||||
const nodeId = `asset:${asset.id}`;
|
||||
if (asset.kind === "text") return [{ nodeId, type: "text", title: asset.title, text: asset.data.content }];
|
||||
if (asset.kind === "image") return [{ nodeId, type: "image", title: asset.title, image: { id: asset.id, name: asset.title, type: asset.data.mimeType, dataUrl: asset.data.dataUrl, storageKey: asset.data.storageKey, bytes: asset.data.bytes, width: asset.data.width, height: asset.data.height } }];
|
||||
if (asset.kind === "video") return [{ nodeId, type: "video", title: asset.title, video: { id: asset.id, name: asset.title, type: asset.data.mimeType, url: asset.data.url, storageKey: asset.data.storageKey, bytes: asset.data.bytes, width: asset.data.width, height: asset.data.height, durationMs: asset.data.durationMs } }];
|
||||
if (asset.kind === "audio") return [{ nodeId, type: "audio", title: asset.title, audio: { id: asset.id, name: asset.title, type: asset.data.mimeType, url: asset.data.url, storageKey: asset.data.storageKey, bytes: asset.data.bytes, durationMs: asset.data.durationMs } }];
|
||||
if (asset.kind === "entity" && asset.category === "character") return [{ nodeId, type: "character", title: asset.title, character: { nodeId, assetId: asset.id, requestedVersionId: asset.primaryVersionId } }];
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
function generationInputToken(input: NodeGenerationInput) {
|
||||
return input.nodeId.startsWith("asset:") ? input.nodeId : `node:${input.nodeId}`;
|
||||
}
|
||||
|
||||
function getConnectedStoryboardRows(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]): NodeGenerationInput[] {
|
||||
const targetNodeIds = new Set([nodeId]);
|
||||
connections.forEach((connection) => {
|
||||
|
||||
@@ -244,6 +244,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
||||
onMissingConfig={() => navigateToSettings({ continueCreation: true })}
|
||||
showSelectedPrice={false}
|
||||
variant="creation"
|
||||
showConfiguredModelName
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-auto flex min-w-0 shrink-0 items-center gap-1">
|
||||
@@ -284,6 +285,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
||||
<CanvasResourceMentionTextarea
|
||||
value={prompt}
|
||||
references={mentionReferences}
|
||||
includeAssetLibrary
|
||||
onChange={updatePrompt}
|
||||
onContentSizeChange={expanded ? setExpandedPromptContentHeight : setPromptContentHeight}
|
||||
containerClassName="min-h-0 flex-1"
|
||||
@@ -504,10 +506,21 @@ function PromptResizeHandle({ height, min, max, onResize }: { height: number; mi
|
||||
onPointerMove={(event) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag || drag.pointerId !== event.pointerId) return;
|
||||
if (!event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
dragRef.current = null;
|
||||
return;
|
||||
}
|
||||
if ((event.buttons & 1) === 0) {
|
||||
finishResize(event);
|
||||
return;
|
||||
}
|
||||
onResize(Math.min(max, Math.max(min, drag.startHeight + event.clientY - drag.startY)));
|
||||
}}
|
||||
onPointerUp={finishResize}
|
||||
onPointerCancel={finishResize}
|
||||
onLostPointerCapture={(event) => {
|
||||
if (dragRef.current?.pointerId === event.pointerId) dragRef.current = null;
|
||||
}}
|
||||
>
|
||||
<span aria-hidden />
|
||||
</button>
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { forwardRef, useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import type { CSSProperties, ClipboardEvent, KeyboardEvent, MouseEvent, PointerEvent, TextareaHTMLAttributes } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { FileText, Image as ImageIcon, Music2, Pencil, Sparkles, UserRound, Video } from "lucide-react";
|
||||
import { ArrowLeft, ChevronRight, FileText, Folder, Image as ImageIcon, Music2, Pencil, Search, Sparkles, UserRound, Video } from "lucide-react";
|
||||
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { canvasResourceMentionToken, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
|
||||
import { buildAssetMentionReferences, canvasResourceMentionToken, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
|
||||
import { useAssetStore, type AssetCategory } from "@/stores/use-asset-store";
|
||||
import { CanvasNodeType } from "@/types/canvas";
|
||||
|
||||
type MentionState = {
|
||||
start: number;
|
||||
end: number;
|
||||
query: string;
|
||||
};
|
||||
|
||||
@@ -39,13 +41,15 @@ type Props = Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, "onChange" | "val
|
||||
mentionMenuWidth?: number;
|
||||
sendOnEnter?: boolean;
|
||||
onContentSizeChange?: (height: number) => void;
|
||||
includeAssetLibrary?: boolean;
|
||||
};
|
||||
|
||||
export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Props>(function CanvasResourceMentionTextarea(
|
||||
{ value, references, onChange, onSubmit, onKeyDown, className, containerClassName, style, highlightLabels = true, mentionMenuWidth = 256, sendOnEnter = true, onContentSizeChange, ...props },
|
||||
{ value, references, onChange, onSubmit, onKeyDown, className, containerClassName, style, highlightLabels = true, mentionMenuWidth = 320, sendOnEnter = true, onContentSizeChange, includeAssetLibrary = false, ...props },
|
||||
forwardedRef,
|
||||
) {
|
||||
const rawTheme = useThemeStore((state) => state.theme);
|
||||
const assets = useAssetStore((state) => state.assets);
|
||||
const theme = canvasThemes[rawTheme as keyof typeof canvasThemes] ?? canvasThemes.dark;
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
@@ -54,22 +58,33 @@ export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Pro
|
||||
const pendingSelectionRef = useRef<number | null>(null);
|
||||
const lastRenderedValueRef = useRef("");
|
||||
const [mention, setMention] = useState<MentionState | null>(null);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [activeIndex, setActiveIndex] = useState(-1);
|
||||
const assetReferences = useMemo(() => includeAssetLibrary ? buildAssetMentionReferences(assets) : [], [assets, includeAssetLibrary]);
|
||||
const activeCanvasReferences = useMemo(() => references.filter((item) => item.active), [references]);
|
||||
const availableReferences = useMemo(() => [...activeCanvasReferences, ...assetReferences], [activeCanvasReferences, assetReferences]);
|
||||
const candidates = useMemo(() => {
|
||||
if (!mention) return [];
|
||||
const query = mention.query.trim().toLowerCase();
|
||||
const activeItems = references.filter((item) => item.active);
|
||||
if (!query) return activeItems;
|
||||
return activeItems.filter((item) => `${item.label} ${item.title} ${item.kind} ${item.text || ""}`.toLowerCase().includes(query));
|
||||
}, [mention, references]);
|
||||
const activeReferences = useMemo(() => (highlightLabels ? references.filter((item) => item.active) : []), [highlightLabels, references]);
|
||||
if (!query) return activeCanvasReferences;
|
||||
return availableReferences.filter((item) => `${item.label} ${item.title} ${item.kind} ${item.category || ""} ${item.text || ""}`.toLowerCase().includes(query));
|
||||
}, [activeCanvasReferences, availableReferences, mention]);
|
||||
const activeReferences = useMemo(() => {
|
||||
if (!highlightLabels) return [];
|
||||
return [...activeCanvasReferences, ...assetReferences.filter((item) => value.includes(canvasResourceMentionToken(item)))];
|
||||
}, [activeCanvasReferences, assetReferences, highlightLabels, value]);
|
||||
const useRichEditor = Boolean(activeReferences.length);
|
||||
const reportContentSize = useCallback((element: HTMLElement | null) => {
|
||||
if (!element || !onContentSizeChange) return;
|
||||
const previousHeight = element.style.height;
|
||||
element.style.height = "0px";
|
||||
const height = element.scrollHeight;
|
||||
element.style.height = previousHeight;
|
||||
const previous = { height: element.style.height, minHeight: element.style.minHeight, maxHeight: element.style.maxHeight, overflow: element.style.overflow };
|
||||
element.style.height = "1px";
|
||||
element.style.minHeight = "0";
|
||||
element.style.maxHeight = "none";
|
||||
element.style.overflow = "hidden";
|
||||
const height = Math.ceil(element.scrollHeight);
|
||||
element.style.height = previous.height;
|
||||
element.style.minHeight = previous.minHeight;
|
||||
element.style.maxHeight = previous.maxHeight;
|
||||
element.style.overflow = previous.overflow;
|
||||
onContentSizeChange(height);
|
||||
}, [onContentSizeChange]);
|
||||
|
||||
@@ -96,7 +111,13 @@ export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Pro
|
||||
const container = containerRef.current;
|
||||
if (!element || !container || !onContentSizeChange) return;
|
||||
reportContentSize(element);
|
||||
const observer = new ResizeObserver(() => reportContentSize(element));
|
||||
let width = container.clientWidth;
|
||||
const observer = new ResizeObserver(() => {
|
||||
const nextWidth = container.clientWidth;
|
||||
if (nextWidth === width) return;
|
||||
width = nextWidth;
|
||||
reportContentSize(element);
|
||||
});
|
||||
observer.observe(container);
|
||||
return () => observer.disconnect();
|
||||
}, [onContentSizeChange, reportContentSize, useRichEditor]);
|
||||
@@ -123,30 +144,28 @@ export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Pro
|
||||
|
||||
const closeMention = () => {
|
||||
setMention(null);
|
||||
setActiveIndex(0);
|
||||
setActiveIndex(-1);
|
||||
};
|
||||
|
||||
const syncMention = (nextValue: string, cursor: number) => {
|
||||
const prefix = nextValue.slice(0, cursor);
|
||||
const match = /@([^\s@,.;:!?,。;:!?、)\]}】)]*)$/.exec(prefix);
|
||||
if (!match || !references.some((item) => item.active)) {
|
||||
if (!match || !availableReferences.length) {
|
||||
closeMention();
|
||||
return;
|
||||
}
|
||||
const nextMention = { start: match.index, query: match[1] };
|
||||
const isSameMention = mention?.start === nextMention.start && mention.query === nextMention.query;
|
||||
const nextMention = { start: match.index, end: cursor, query: match[1] };
|
||||
const isSameMention = mention?.start === nextMention.start && mention.end === nextMention.end && mention.query === nextMention.query;
|
||||
if (!isSameMention) {
|
||||
setMention(nextMention);
|
||||
setActiveIndex(0);
|
||||
setActiveIndex(-1);
|
||||
}
|
||||
};
|
||||
|
||||
const insertReference = (reference: CanvasResourceReference) => {
|
||||
if (!mention) return;
|
||||
const selection = useRichEditor ? getEditableSelection(editorRef.current) : null;
|
||||
const end = selection?.end ?? textareaRef.current?.selectionStart ?? value.length;
|
||||
const insertText = `${canvasResourceMentionToken(reference)} `;
|
||||
const next = `${value.slice(0, mention.start)}${insertText}${value.slice(end)}`;
|
||||
const next = `${value.slice(0, mention.start)}${insertText}${value.slice(mention.end)}`;
|
||||
closeMention();
|
||||
updateValue(next, mention.start + insertText.length);
|
||||
};
|
||||
@@ -185,7 +204,21 @@ export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Pro
|
||||
caretColor: style?.color || theme.node.text,
|
||||
} as CSSProperties;
|
||||
const menuAnchor = useRichEditor ? editorRef.current : textareaRef.current;
|
||||
const menu = mention && candidates.length && menuAnchor ? <MentionMenu anchor={menuAnchor} references={candidates} activeIndex={Math.min(activeIndex, candidates.length - 1)} theme={theme} preferredWidth={mentionMenuWidth} onSelect={insertReference} /> : null;
|
||||
const menu = mention && availableReferences.length && menuAnchor ? (
|
||||
<MentionMenu
|
||||
anchor={menuAnchor}
|
||||
connectedReferences={activeCanvasReferences}
|
||||
assetReferences={assetReferences}
|
||||
filteredReferences={candidates}
|
||||
query={mention.query}
|
||||
activeReferenceId={activeIndex >= 0 ? candidates[Math.min(activeIndex, candidates.length - 1)]?.id : undefined}
|
||||
theme={theme}
|
||||
preferredWidth={mentionMenuWidth}
|
||||
onQueryChange={(query) => setMention((current) => current ? { ...current, query } : current)}
|
||||
onClose={closeMention}
|
||||
onSelect={insertReference}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
if (useRichEditor) {
|
||||
return (
|
||||
@@ -225,17 +258,17 @@ export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Pro
|
||||
if (mention && candidates.length) {
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setActiveIndex((index) => (index + 1) % candidates.length);
|
||||
setActiveIndex((index) => index < 0 ? 0 : (index + 1) % candidates.length);
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
setActiveIndex((index) => (index - 1 + candidates.length) % candidates.length);
|
||||
setActiveIndex((index) => index < 0 ? candidates.length - 1 : (index - 1 + candidates.length) % candidates.length);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Enter" || event.key === "Tab") {
|
||||
event.preventDefault();
|
||||
insertReference(candidates[Math.min(activeIndex, candidates.length - 1)]);
|
||||
insertReference(candidates[activeIndex < 0 ? 0 : Math.min(activeIndex, candidates.length - 1)]);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
@@ -274,7 +307,11 @@ export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Pro
|
||||
onScroll={(event) => props.onScroll?.(event as unknown as React.UIEvent<HTMLTextAreaElement>)}
|
||||
onFocus={(event) => props.onFocus?.(event as unknown as React.FocusEvent<HTMLTextAreaElement>)}
|
||||
onBlur={(event) => {
|
||||
window.setTimeout(closeMention, 120);
|
||||
if (event.relatedTarget instanceof Element && event.relatedTarget.closest("[data-canvas-resource-mention-menu]")) return;
|
||||
window.setTimeout(() => {
|
||||
if (document.activeElement?.closest("[data-canvas-resource-mention-menu]")) return;
|
||||
closeMention();
|
||||
}, 120);
|
||||
props.onBlur?.(event as unknown as React.FocusEvent<HTMLTextAreaElement>);
|
||||
}}
|
||||
>
|
||||
@@ -306,17 +343,17 @@ export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Pro
|
||||
if (mention && candidates.length) {
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setActiveIndex((index) => (index + 1) % candidates.length);
|
||||
setActiveIndex((index) => index < 0 ? 0 : (index + 1) % candidates.length);
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
setActiveIndex((index) => (index - 1 + candidates.length) % candidates.length);
|
||||
setActiveIndex((index) => index < 0 ? candidates.length - 1 : (index - 1 + candidates.length) % candidates.length);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
insertReference(candidates[Math.min(activeIndex, candidates.length - 1)]);
|
||||
insertReference(candidates[activeIndex < 0 ? 0 : Math.min(activeIndex, candidates.length - 1)]);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
@@ -345,7 +382,11 @@ export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Pro
|
||||
props.onWheel?.(event);
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
window.setTimeout(closeMention, 120);
|
||||
if (event.relatedTarget instanceof Element && event.relatedTarget.closest("[data-canvas-resource-mention-menu]")) return;
|
||||
window.setTimeout(() => {
|
||||
if (document.activeElement?.closest("[data-canvas-resource-mention-menu]")) return;
|
||||
closeMention();
|
||||
}, 120);
|
||||
props.onBlur?.(event);
|
||||
}}
|
||||
/>
|
||||
@@ -393,12 +434,36 @@ function createInlinePreview(reference: CanvasResourceReference) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function MentionMenu({ anchor, references, activeIndex, theme, preferredWidth, onSelect }: { anchor: HTMLElement; references: CanvasResourceReference[]; activeIndex: number; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; preferredWidth: number; onSelect: (reference: CanvasResourceReference) => void }) {
|
||||
const ASSET_CATEGORY_LABELS: Record<AssetCategory, string> = {
|
||||
character: "角色",
|
||||
environment: "场景",
|
||||
wardrobe: "服饰",
|
||||
prop: "道具",
|
||||
weapon: "武器",
|
||||
style: "画风",
|
||||
other: "其他",
|
||||
};
|
||||
|
||||
function MentionMenu({ anchor, connectedReferences, assetReferences, filteredReferences, query, activeReferenceId, theme, preferredWidth, onQueryChange, onClose, onSelect }: {
|
||||
anchor: HTMLElement;
|
||||
connectedReferences: CanvasResourceReference[];
|
||||
assetReferences: CanvasResourceReference[];
|
||||
filteredReferences: CanvasResourceReference[];
|
||||
query: string;
|
||||
activeReferenceId?: string;
|
||||
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
|
||||
preferredWidth: number;
|
||||
onQueryChange: (query: string) => void;
|
||||
onClose: () => void;
|
||||
onSelect: (reference: CanvasResourceReference) => void;
|
||||
}) {
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
const selectedRef = useRef(false);
|
||||
const [category, setCategory] = useState<AssetCategory | null>(null);
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
const boundary = anchor.closest(".ant-modal-content")?.getBoundingClientRect() || { left: 8, top: 8, right: window.innerWidth - 8, bottom: window.innerHeight - 8 };
|
||||
const boundary = anchor.closest(".ant-modal-container")?.getBoundingClientRect() || { left: 8, top: 8, right: window.innerWidth - 8, bottom: window.innerHeight - 8 };
|
||||
const menuWidth = Math.min(preferredWidth, Math.max(0, boundary.right - boundary.left - 16));
|
||||
const maxMenuHeight = 224;
|
||||
const maxMenuHeight = Math.min(320, Math.max(0, boundary.bottom - boundary.top - 16));
|
||||
const gap = 6;
|
||||
const left = clamp(rect.left, boundary.left + 8, boundary.right - menuWidth - 8);
|
||||
const showAbove = rect.bottom + gap + maxMenuHeight > boundary.bottom && rect.top - gap - maxMenuHeight >= boundary.top;
|
||||
@@ -412,60 +477,149 @@ function MentionMenu({ anchor, references, activeIndex, theme, preferredWidth, o
|
||||
selectedRef.current = true;
|
||||
onSelect(reference);
|
||||
};
|
||||
const categoryItems = Object.entries(ASSET_CATEGORY_LABELS)
|
||||
.map(([value, label]) => ({ value: value as AssetCategory, label, count: assetReferences.filter((item) => item.category === value).length }))
|
||||
.filter((item) => item.count > 0);
|
||||
const connectedNodes = connectedReferences.filter((item) => item.kind !== "skill");
|
||||
const skillReferences = connectedReferences.filter((item) => item.kind === "skill");
|
||||
const visibleReferences = query
|
||||
? filteredReferences
|
||||
: category
|
||||
? assetReferences.filter((item) => item.category === category)
|
||||
: [];
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const closeOnOutsidePointer = (event: globalThis.PointerEvent) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Node) || menuRef.current?.contains(target) || anchor.contains(target)) return;
|
||||
onClose();
|
||||
};
|
||||
window.addEventListener("pointerdown", closeOnOutsidePointer, true);
|
||||
return () => window.removeEventListener("pointerdown", closeOnOutsidePointer, true);
|
||||
}, [anchor, onClose]);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
data-canvas-resource-mention-menu="true"
|
||||
className="fixed z-[var(--z-tooltip)] max-h-56 overflow-y-auto rounded-xl border p-1 shadow-2xl backdrop-blur-md"
|
||||
style={{ left, top, width: menuWidth, background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }}
|
||||
className="canvas-resource-mention-menu fixed z-[var(--z-tooltip)]"
|
||||
style={{ left, top, width: menuWidth, maxHeight: maxMenuHeight, background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }}
|
||||
onPointerDown={stopCanvasInteraction}
|
||||
onMouseDown={stopCanvasInteraction}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{references.map((reference, index) => (
|
||||
<button
|
||||
key={reference.id}
|
||||
type="button"
|
||||
className="flex w-full min-w-0 items-center gap-2 rounded-lg px-2 py-1.5 text-left text-xs transition"
|
||||
style={{ background: index === activeIndex ? theme.toolbar.activeBg : "transparent", color: index === activeIndex ? theme.toolbar.activeText : theme.node.text }}
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
selectReference(reference);
|
||||
<div className="canvas-resource-mention-search">
|
||||
<Search aria-hidden />
|
||||
<input
|
||||
value={query}
|
||||
placeholder="搜索素材..."
|
||||
aria-label="搜索引用素材"
|
||||
onChange={(event) => onQueryChange(event.target.value)}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
anchor.focus();
|
||||
return;
|
||||
}
|
||||
if (event.key === "Enter" && filteredReferences.length) {
|
||||
event.preventDefault();
|
||||
selectReference(filteredReferences[0]);
|
||||
}
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
selectReference(reference);
|
||||
}}
|
||||
>
|
||||
<ReferencePreview reference={reference} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium" title={reference.label}>{reference.label}</span>
|
||||
{reference.kind !== "skill" ? <span className="block truncate opacity-65">{reference.text || reference.title}</span> : null}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
/>
|
||||
</div>
|
||||
<div className="canvas-resource-mention-scroll thin-scrollbar">
|
||||
{query ? (
|
||||
<MentionReferenceList references={visibleReferences} activeReferenceId={activeReferenceId} theme={theme} onSelect={selectReference} />
|
||||
) : category ? (
|
||||
<>
|
||||
<button type="button" className="canvas-resource-mention-back" onClick={() => setCategory(null)}>
|
||||
<ArrowLeft aria-hidden />
|
||||
<span>{ASSET_CATEGORY_LABELS[category]}</span>
|
||||
<small>{visibleReferences.length}</small>
|
||||
</button>
|
||||
<MentionReferenceList references={visibleReferences} activeReferenceId={activeReferenceId} theme={theme} onSelect={selectReference} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{connectedNodes.length ? (
|
||||
<section className="canvas-resource-mention-section">
|
||||
<h4>已连接节点</h4>
|
||||
<MentionReferenceList references={connectedNodes} activeReferenceId={activeReferenceId} theme={theme} onSelect={selectReference} />
|
||||
</section>
|
||||
) : null}
|
||||
{skillReferences.length ? (
|
||||
<section className="canvas-resource-mention-section">
|
||||
<h4>技能库</h4>
|
||||
<MentionReferenceList references={skillReferences} activeReferenceId={activeReferenceId} theme={theme} onSelect={selectReference} />
|
||||
</section>
|
||||
) : null}
|
||||
{categoryItems.length ? (
|
||||
<section className="canvas-resource-mention-section">
|
||||
<h4>素材库</h4>
|
||||
{categoryItems.map((item) => (
|
||||
<button key={item.value} type="button" className="canvas-resource-mention-folder" onClick={() => setCategory(item.value)}>
|
||||
<Folder aria-hidden />
|
||||
<span>{item.label}</span>
|
||||
<small>{item.count}</small>
|
||||
<ChevronRight aria-hidden />
|
||||
</button>
|
||||
))}
|
||||
</section>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
function MentionReferenceList({ references, activeReferenceId, theme, onSelect }: { references: CanvasResourceReference[]; activeReferenceId?: string; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onSelect: (reference: CanvasResourceReference) => void }) {
|
||||
if (!references.length) return <div className="canvas-resource-mention-empty">没有匹配的素材</div>;
|
||||
return references.map((reference) => (
|
||||
<button
|
||||
key={reference.id}
|
||||
type="button"
|
||||
className="canvas-resource-mention-item"
|
||||
style={{ background: reference.id === activeReferenceId ? theme.toolbar.activeBg : "transparent", color: reference.id === activeReferenceId ? theme.toolbar.activeText : theme.node.text }}
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onSelect(reference);
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onSelect(reference);
|
||||
}}
|
||||
>
|
||||
<ReferencePreview reference={reference} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium" title={reference.label}>{reference.label}</span>
|
||||
{reference.kind !== "skill" && reference.text && reference.text !== reference.title ? <span className="block truncate opacity-55">{reference.text}</span> : null}
|
||||
</span>
|
||||
</button>
|
||||
));
|
||||
}
|
||||
|
||||
function ReferencePreview({ reference }: { reference: CanvasResourceReference }) {
|
||||
if (reference.kind === "image" && reference.previewUrl) return <img src={reference.previewUrl} alt="" className="size-9 rounded-md object-cover" />;
|
||||
if (reference.kind === "video" && reference.previewUrl) return <video src={reference.previewUrl} className="size-9 rounded-md bg-black object-cover" muted preload="metadata" />;
|
||||
if (reference.kind === "character" && reference.previewUrl) return <img src={reference.previewUrl} alt="" className="size-9 rounded-md bg-black/5 object-contain" />;
|
||||
if (reference.kind === "image" && reference.previewUrl) return <img src={reference.previewUrl} alt="" className="size-7 rounded-sm object-cover" />;
|
||||
if (reference.kind === "video" && reference.previewUrl) return <video src={reference.previewUrl} className="size-7 rounded-sm bg-black object-cover" muted preload="metadata" />;
|
||||
if (reference.kind === "character" && reference.previewUrl) return <img src={reference.previewUrl} alt="" className="size-7 rounded-sm bg-black/5 object-contain" />;
|
||||
if (reference.kind === "skill") {
|
||||
return (
|
||||
<span className="grid size-9 shrink-0 place-items-center rounded-md bg-cyan-500/12 text-cyan-600 dark:text-cyan-200">
|
||||
<Sparkles className="size-4" />
|
||||
<span className="grid size-7 shrink-0 place-items-center rounded-sm bg-cyan-500/10 text-cyan-600 dark:text-cyan-200">
|
||||
<Sparkles className="size-3.5" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const Icon = reference.sourceType === CanvasNodeType.Drawing ? Pencil : reference.kind === "character" ? UserRound : reference.kind === "audio" ? Music2 : reference.kind === "video" ? Video : reference.kind === "image" ? ImageIcon : FileText;
|
||||
return (
|
||||
<span className="grid size-9 shrink-0 place-items-center rounded-md bg-black/10">
|
||||
<Icon className="size-4" />
|
||||
<span className="grid size-7 shrink-0 place-items-center rounded-sm bg-black/10">
|
||||
<Icon className="size-3.5" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Popover } from "antd";
|
||||
|
||||
import { canvasThemes, type CanvasTheme } from "@/lib/canvas-theme";
|
||||
import { modelCapabilityConfigFor, videoDurationOptions } from "@/lib/model-capabilities";
|
||||
import { compatibleModelInGroup, groupModelsByDisplayName, modelCompatibilityError, resolveCompatibleModel, type ModelRequirements } from "@/lib/model-selection";
|
||||
import { compatibleModelInGroup, configuredModelDisplayName, groupModelsByDisplayName, modelCompatibilityError, resolveCompatibleModel, type ModelRequirements } from "@/lib/model-selection";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { modelDisplayName, modelOptionLabel, modelOptionName, resolveModelChannel, selectableModelsByCapability, type AiConfig, type ModelCapability } from "@/stores/use-config-store";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
@@ -22,9 +22,10 @@ type ModelPickerProps = {
|
||||
showSelectedPrice?: boolean;
|
||||
variant?: "default" | "creation";
|
||||
requirements?: ModelRequirements;
|
||||
showConfiguredModelName?: boolean;
|
||||
};
|
||||
|
||||
export function ModelPicker({ config, value, onChange, capability, className, fullWidth = false, placeholder = "选择模型", onMissingConfig, showSelectedPrice = true, variant = "default", requirements }: ModelPickerProps) {
|
||||
export function ModelPicker({ config, value, onChange, capability, className, fullWidth = false, placeholder = "选择模型", onMissingConfig, showSelectedPrice = true, variant = "default", requirements, showConfiguredModelName = false }: ModelPickerProps) {
|
||||
const creditsEnabled = useUserStore((state) => state.features.creditsEnabled);
|
||||
const pickerId = useId();
|
||||
// 双保险:即使 store merge 写出非法 theme,这里也兜底到 dark,避免 "reading 'node'" 崩溃
|
||||
@@ -129,7 +130,7 @@ export function ModelPicker({ config, value, onChange, capability, className, fu
|
||||
{creationVariant ? (
|
||||
<div className="creation-model-picker-heading">
|
||||
<span>选择模型</span>
|
||||
{current ? <strong>{modelDisplayName(config, current)}</strong> : null}
|
||||
{current ? <strong>{pickerModelDisplayName(config, current, showConfiguredModelName)}</strong> : null}
|
||||
</div>
|
||||
) : null}
|
||||
{optionGroups.length ? (
|
||||
@@ -155,7 +156,7 @@ export function ModelPicker({ config, value, onChange, capability, className, fu
|
||||
aria-selected={selected}
|
||||
aria-disabled={Boolean(disabledReason)}
|
||||
disabled={Boolean(disabledReason)}
|
||||
title={disabledReason || modelOptionLabel(config, displayModel)}
|
||||
title={disabledReason || pickerModelOptionLabel(config, displayModel, showConfiguredModelName)}
|
||||
className="canvas-model-picker-option disabled:cursor-not-allowed disabled:opacity-45"
|
||||
style={{ background: "transparent", color: theme.node.text }}
|
||||
onClick={() => {
|
||||
@@ -165,7 +166,7 @@ export function ModelPicker({ config, value, onChange, capability, className, fu
|
||||
window.requestAnimationFrame(() => triggerRef.current?.focus());
|
||||
}}
|
||||
>
|
||||
<ModelLabel config={config} model={displayModel} capability={capability} theme={theme} creationVariant={creationVariant} showPrice={creditsEnabled} disabledReason={disabledReason} />
|
||||
<ModelLabel config={config} model={displayModel} capability={capability} theme={theme} creationVariant={creationVariant} showConfiguredModelName={showConfiguredModelName} showPrice={creditsEnabled} disabledReason={disabledReason} />
|
||||
{selected ? <Check className="canvas-model-picker-option-check" style={{ color: theme.node.activeStroke }} /> : null}
|
||||
</button>
|
||||
);
|
||||
@@ -203,14 +204,14 @@ export function ModelPicker({ config, value, onChange, capability, className, fu
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-label={placeholder}
|
||||
title={current ? modelOptionLabel(config, current) : placeholder}
|
||||
title={current ? pickerModelOptionLabel(config, current, showConfiguredModelName) : placeholder}
|
||||
onKeyDown={handleTriggerKeyDown}
|
||||
>
|
||||
<span className="canvas-model-picker-label flex min-w-0 items-center gap-1.5">
|
||||
<span className="canvas-model-picker-trigger-icon" style={{ background: theme.toolbar.itemHover }}>
|
||||
<ModelIcon model={current} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">{current ? (creationVariant ? modelDisplayName(config, current) : modelOptionLabel(config, current)) : placeholder}</span>
|
||||
<span className="min-w-0 flex-1 truncate">{current ? (creationVariant ? pickerModelDisplayName(config, current, showConfiguredModelName) : pickerModelOptionLabel(config, current, showConfiguredModelName)) : placeholder}</span>
|
||||
{showSelectedPrice && creditsEnabled ? <ModelPrice price={currentPrice} compact /> : null}
|
||||
</span>
|
||||
<ChevronDown className={cn("canvas-model-picker-chevron", open && "is-open")} aria-hidden="true" />
|
||||
@@ -232,6 +233,7 @@ function ModelLabel({
|
||||
capability,
|
||||
theme,
|
||||
creationVariant,
|
||||
showConfiguredModelName,
|
||||
showPrice,
|
||||
disabledReason,
|
||||
}: {
|
||||
@@ -240,6 +242,7 @@ function ModelLabel({
|
||||
capability?: ModelCapability;
|
||||
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
|
||||
creationVariant: boolean;
|
||||
showConfiguredModelName: boolean;
|
||||
showPrice: boolean;
|
||||
disabledReason?: string;
|
||||
}) {
|
||||
@@ -252,7 +255,7 @@ function ModelLabel({
|
||||
<ModelIcon model={model} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 overflow-hidden">
|
||||
<span className="block min-w-0 truncate text-[var(--fs-label)] font-medium leading-none">{modelDisplayName(config, model)}</span>
|
||||
<span className="block min-w-0 truncate text-[var(--fs-label)] font-medium leading-none">{pickerModelDisplayName(config, model, showConfiguredModelName)}</span>
|
||||
<span className="mt-1 block truncate text-[var(--fs-tiny)]" style={{ color: theme.node.muted }} title={capabilitySummary}>
|
||||
{capabilitySummary}
|
||||
</span>
|
||||
@@ -281,6 +284,14 @@ function modelMenuPrice(config: AiConfig, model: string): { value: number; unit:
|
||||
return { value: cost.unitPriceMicrocredits / 1_000_000, unit: cost.billingMode === "per_second" ? "秒" : "次" };
|
||||
}
|
||||
|
||||
function pickerModelDisplayName(config: AiConfig, model: string, showConfiguredModelName: boolean) {
|
||||
return showConfiguredModelName ? configuredModelDisplayName(config, model) : modelDisplayName(config, model);
|
||||
}
|
||||
|
||||
function pickerModelOptionLabel(config: AiConfig, model: string, showConfiguredModelName: boolean) {
|
||||
return showConfiguredModelName ? `${configuredModelDisplayName(config, model)}(${resolveModelChannel(config, model).name})` : modelOptionLabel(config, model);
|
||||
}
|
||||
|
||||
function ModelPrice({ price, compact = false }: { price: { value: number; unit: "次" | "秒" } | null | undefined; compact?: boolean }) {
|
||||
if (price === undefined) return null;
|
||||
if (price === null) return compact ? null : <span className="shrink-0 text-[var(--fs-tiny)] text-foreground/40">未配置</span>;
|
||||
|
||||
Reference in New Issue
Block a user