merge: official v1.5.4 release

This commit is contained in:
Codex
2026-09-19 15:36:02 +08:00
189 changed files with 13222 additions and 8821 deletions

View File

@@ -488,7 +488,7 @@ export function AssetLibraryPickerModal({
</nav>
<div className="asset-picker-grid-wrap">
<div className="asset-picker-grid">
{remoteEnabled && remoteQuery.isError ? <div role="alert">素材读取失败<Button onClick={() => void remoteQuery.refetch()}>重试</Button></div> : loading || (useRemoteItems && remoteQuery.isFetching) ? (
{remoteEnabled && remoteQuery.isError && !localItems.length ? <div className="asset-picker-empty" role="alert"><FolderOpen /><strong>素材读取失败</strong><span>服务端暂时不可用,重试不会影响本地已保存素材。</span><Button onClick={() => void remoteQuery.refetch()}>重试</Button></div> : loading || (useRemoteItems && remoteQuery.isFetching) ? (
<div className="asset-picker-empty">
<LoaderCircle className="animate-spin" />
<strong>正在读取素材</strong>
@@ -519,8 +519,8 @@ export function AssetLibraryPickerModal({
</span>
</button>
</>
) : footerNote ? (
<span className="asset-picker-footer-note">{footerNote}</span>
) : footerNote || (remoteEnabled && remoteQuery.isError) ? (
<span className="asset-picker-footer-note">{remoteEnabled && remoteQuery.isError ? "云端素材暂时不可用,当前显示本地缓存" : footerNote}</span>
) : (
<span />
)}

View File

@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Button, Input } from "antd";
import { Check, LibraryBig, LoaderCircle, Plus, Search, Sparkles, Users } from "lucide-react";
import { Check, LoaderCircle, Plus, Search, Sparkles, Users } from "lucide-react";
import { AppModal } from "@/components/ui/product/app-modal";
import type { CanvasTheme } from "@/lib/canvas-theme";
@@ -45,16 +45,20 @@ export function CanvasAgentSkillLibraryModal({
onInstall,
onLoadMore,
}: CanvasAgentSkillLibraryModalProps) {
const [tab, setTab] = useState<SkillLibraryTab>("enabled");
const [tab, setTab] = useState<SkillLibraryTab>("market");
const listRef = useRef<HTMLDivElement>(null);
const loadMoreRef = useRef<HTMLDivElement>(null);
const wasOpenRef = useRef(false);
const selectedCount = selectedSkillIds.length;
useEffect(() => {
if (open && !wasOpenRef.current) setTab(selectedCount > 0 ? "enabled" : "installed");
if (open && !wasOpenRef.current) setTab("market");
wasOpenRef.current = open;
}, [open, selectedCount]);
}, [open]);
useEffect(() => {
listRef.current?.scrollTo({ top: 0 });
}, [category, search, tab]);
const visibleSkills = useMemo(() => {
const keyword = search.trim().toLocaleLowerCase("zh-CN");
@@ -86,16 +90,21 @@ export function CanvasAgentSkillLibraryModal({
const categoryItems = useMemo(() => {
const unique = new Map<string, SkillCategory>();
categories.forEach((item) => {
if (item.value && item.label) unique.set(item.value, item);
if (item.value && item.value !== "all" && item.label) unique.set(item.value, item);
});
return [{ value: "all", label: "全部" }, ...unique.values()];
return [...unique.values()];
}, [categories]);
const emptyText = tab === "enabled"
? "本轮还没有启用 Skill,可在“已加入”中选择"
? "本轮还没有启用技能,可在“我的技能”中选择"
: tab === "installed"
? "还没有匹配的已加入 Skill,可前往“发现 Skills”添加"
: "没有匹配的公开 Skill,换个关键词或分类试试";
? "还没有匹配的已加入技能,可前往“全部”添加"
: "没有匹配的公开技能,换个关键词或分类试试";
const changeTab = (value: SkillLibraryTab) => {
setTab(value);
onCategoryChange("all");
};
return (
<AppModal
@@ -104,7 +113,16 @@ export function CanvasAgentSkillLibraryModal({
title={null}
footer={null}
centered
width="min(1180px, calc(100vw - 24px))"
width="min(1180px, 66vw)"
closable={false}
modalRender={(modal) => (
<div className="canvas-agent-skill-library-frame">
<button type="button" className="canvas-agent-skill-library-close" aria-label="关闭技能商店" onClick={onClose}>
<kbd>ESC</kbd><span>关闭</span>
</button>
{modal}
</div>
)}
onCancel={onClose}
flush
>
@@ -115,85 +133,78 @@ export function CanvasAgentSkillLibraryModal({
data-canvas-wheel-scroll
aria-label="Agent Skills 技能库"
>
<header className="canvas-agent-skill-library-header">
<div className="min-w-0">
<div className="flex items-center gap-2 text-lg font-semibold">
<LibraryBig className="size-5" aria-hidden="true" />
Skills 技能库
</div>
<p className="mt-1 text-xs" style={{ color: theme.node.muted }}>按分类浏览和搜索技能,本轮最多启用 8 个,发送消息时固定版本。</p>
</div>
<div className="canvas-agent-skill-library-count" style={{ color: theme.node.muted }}>
已启用 <strong style={{ color: selectedCount >= 8 ? "var(--destructive)" : theme.accent.primary }}>{selectedCount}</strong>/8
</div>
</header>
<div className="canvas-agent-skill-library-toolbar">
<div className="canvas-agent-skill-library-tabs" role="tablist" aria-label="Skills 视图">
<SkillTab active={tab === "enabled"} label="已启用" count={selectedCount} onClick={() => setTab("enabled")} />
<SkillTab active={tab === "installed"} label="已加入" count={installedSkills.length} onClick={() => setTab("installed")} />
<SkillTab active={tab === "market"} label="发现 Skills" onClick={() => setTab("market")} />
</div>
<aside className="canvas-agent-skill-library-sidebar">
<Input
allowClear
prefix={<Search className="size-4" style={{ color: theme.node.muted }} aria-hidden="true" />}
value={search}
onChange={(event) => onSearch(event.target.value)}
placeholder="搜索 Skill 名称、说明或作者"
aria-label="搜索 Skills"
placeholder="搜索技能"
aria-label="搜索技能名称、说明或作者"
className="canvas-agent-skill-library-search"
/>
</div>
<nav className="canvas-agent-skill-library-categories thin-scrollbar" aria-label="技能视图与分类">
<SkillTab active={tab === "market" && category === "all"} label="全部" onClick={() => changeTab("market")} />
<SkillTab active={tab === "installed"} label="我的技能" count={installedSkills.length} onClick={() => changeTab("installed")} />
<SkillTab active={tab === "enabled"} label="已启用" count={selectedCount} onClick={() => changeTab("enabled")} />
{categoryItems.map((item) => (
<button
key={item.value}
type="button"
className={`canvas-agent-skill-library-tab ${tab === "market" && category === item.value ? "is-active" : ""}`}
aria-pressed={tab === "market" && category === item.value}
onClick={() => { setTab("market"); onCategoryChange(item.value); }}
>
{item.label}
</button>
))}
</nav>
</aside>
<nav className="canvas-agent-skill-library-categories" aria-label="Skill 分类">
{categoryItems.map((item) => (
<button
key={item.value}
type="button"
className={`canvas-agent-skill-category ${category === item.value ? "is-active" : ""}`}
aria-pressed={category === item.value}
onClick={() => onCategoryChange(item.value)}
>
{item.label}
<div className="canvas-agent-skill-library-content">
<header className="canvas-agent-skill-library-header">
<h2>技能商店</h2>
<button type="button" className="canvas-agent-skill-library-count" onClick={() => changeTab("enabled")} aria-label={`查看本轮已启用的 ${selectedCount} 个技能,最多 8 个`}>
已启用 <strong>{selectedCount}</strong><span> / 8</span>
</button>
))}
</nav>
</header>
<div ref={listRef} className="canvas-agent-skill-library-list thin-scrollbar" role="tabpanel">
{visibleSkills.map((skill) => (
<SkillLibraryCard
key={skill.skillId}
skill={skill}
theme={theme}
categories={categories}
selected={selectedSkillIds.includes(skill.skillId)}
canSelect={selectedCount < 8 || selectedSkillIds.includes(skill.skillId)}
onToggle={() => onToggle(skill.skillId)}
onInstall={() => onInstall(skill)}
/>
))}
{loading && visibleSkills.length === 0 ? (
<div className="canvas-agent-skill-library-state" style={{ color: theme.node.muted }}>
<LoaderCircle className="size-5 animate-spin motion-reduce:animate-none" aria-hidden="true" />
正在读取技能库…
</div>
) : null}
{!loading && visibleSkills.length === 0 ? (
<div className="canvas-agent-skill-library-state" style={{ color: theme.node.muted }}>
<Sparkles className="size-6" aria-hidden="true" />
<span>{emptyText}</span>
{tab !== "market" ? <Button size="small" onClick={() => setTab("market")}>浏览公开 Skills</Button> : null}
</div>
) : null}
{tab === "market" && hasMore ? <div ref={loadMoreRef} className="col-span-full h-px" aria-hidden="true" /> : null}
<div ref={listRef} className="canvas-agent-skill-library-list thin-scrollbar" aria-label="技能列表" aria-busy={loading}>
{visibleSkills.map((skill) => (
<SkillLibraryCard
key={skill.skillId}
skill={skill}
theme={theme}
categories={categories}
selected={selectedSkillIds.includes(skill.skillId)}
canSelect={selectedCount < 8 || selectedSkillIds.includes(skill.skillId)}
onToggle={() => onToggle(skill.skillId)}
onInstall={() => onInstall(skill)}
/>
))}
{loading && visibleSkills.length === 0 ? (
<div className="canvas-agent-skill-library-state" style={{ color: theme.node.muted }}>
<LoaderCircle className="size-5 animate-spin motion-reduce:animate-none" aria-hidden="true" />
正在读取技能库…
</div>
) : null}
{!loading && visibleSkills.length === 0 ? (
<div className="canvas-agent-skill-library-state" style={{ color: theme.node.muted }}>
<Sparkles className="size-6" aria-hidden="true" />
<span>{emptyText}</span>
{tab !== "market" ? <Button size="small" onClick={() => changeTab("market")}>浏览公开技能</Button> : null}
</div>
) : null}
{tab === "market" && hasMore ? <div ref={loadMoreRef} className="col-span-full h-px" aria-hidden="true" /> : null}
</div>
<footer className="canvas-agent-skill-library-footer" style={{ color: theme.node.muted, borderColor: theme.node.stroke }}>
<span>{tab === "enabled" ? `${selectedCount} 个技能将在本轮生效` : tab === "installed" ? `${visibleSkills.length} 个已加入技能` : `已加载 ${marketSkills.length} 个公开技能`}</span>
{tab === "market" && hasMore ? (
<Button size="small" disabled={loading} loading={loading} onClick={() => void onLoadMore()}>{loading ? "加载中" : "加载更多"}</Button>
) : <span>{tab === "market" ? "已加载全部" : "最多启用 8 个"}</span>}
</footer>
</div>
<footer className="canvas-agent-skill-library-footer" style={{ color: theme.node.muted, borderColor: theme.node.stroke }}>
<span>{tab === "enabled" ? `${selectedCount} 个技能将在本轮生效` : tab === "installed" ? `${visibleSkills.length} 个已加入技能` : `已加载 ${marketSkills.length} 个公开技能`}</span>
{tab === "market" && hasMore ? (
<Button size="small" disabled={loading} loading={loading} onClick={() => void onLoadMore()}>{loading ? "加载中" : "加载更多"}</Button>
) : <span>{tab === "market" ? "已加载全部" : "最多启用 8 个"}</span>}
</footer>
</section>
</AppModal>
);
@@ -201,7 +212,7 @@ export function CanvasAgentSkillLibraryModal({
function SkillTab({ active, label, count, onClick }: { active: boolean; label: string; count?: number; onClick: () => void }) {
return (
<button type="button" role="tab" aria-selected={active} className={`canvas-agent-skill-library-tab ${active ? "is-active" : ""}`} onClick={onClick}>
<button type="button" aria-pressed={active} className={`canvas-agent-skill-library-tab ${active ? "is-active" : ""}`} onClick={onClick}>
{label}
{typeof count === "number" ? <span>{count}</span> : null}
</button>
@@ -219,7 +230,9 @@ function SkillLibraryCard({ skill, theme, categories, selected, canSelect, onTog
}) {
const [installing, setInstalling] = useState(false);
const [coverFailed, setCoverFailed] = useState(false);
const coverUrl = skill.showcaseMedia?.find((item) => item.showcaseUrl)?.showcaseUrl;
const cover = skill.showcaseMedia?.find((item) => item.type === "image" && item.showcaseUrl)
|| skill.showcaseMedia?.find((item) => item.showcaseUrl);
const coverUrl = cover?.showcaseUrl;
const categoryLabel = categories.find((item) => item.value === skill.tag)?.label || "其他";
const author = skill.effectiveUser?.name || "影策创作者";
const addedCount = formatSkillCount(skill.addedCount || 0);
@@ -237,48 +250,49 @@ function SkillLibraryCard({ skill, theme, categories, selected, canSelect, onTog
};
return (
<article className={`canvas-agent-skill-card ${selected ? "is-selected" : ""}`} style={{ borderColor: selected ? theme.accent.primary : theme.node.stroke }}>
<div className="canvas-agent-skill-card-cover" style={{ background: theme.node.fill }}>
<article className={`canvas-agent-skill-card ${selected ? "is-selected" : ""}`}>
<div className="canvas-agent-skill-card-cover">
{coverUrl && !coverFailed ? (
<img src={coverUrl} alt="" loading="lazy" onError={() => setCoverFailed(true)} />
cover?.type === "video"
? <video src={coverUrl} muted playsInline preload="metadata" onError={() => setCoverFailed(true)} />
: <img src={coverUrl} alt="" loading="lazy" onError={() => setCoverFailed(true)} />
) : (
<div className="canvas-agent-skill-card-placeholder" aria-hidden="true">
<Sparkles className="size-7" />
</div>
)}
<span className="canvas-agent-skill-card-category">{categoryLabel}</span>
{selected ? <span className="canvas-agent-skill-card-selected" aria-label="本轮已启用"><Check className="size-4" aria-hidden="true" /></span> : null}
</div>
<div className="canvas-agent-skill-card-body">
<div className="canvas-agent-skill-card-title-row">
<h3 title={skill.skillName}>{skill.skillName}</h3>
{skill.version ? <span className="canvas-agent-skill-card-version">v{skill.version}</span> : null}
<div className="canvas-agent-skill-card-action">
{skill.isAdded ? (
<Button
size="small"
type="default"
className={selected ? "is-selected" : ""}
disabled={!canSelect}
aria-pressed={selected}
title={!canSelect ? "本轮最多启用 8 个 Skills" : undefined}
onClick={onToggle}
>
{selected ? "已启用" : "使用"}
</Button>
) : (
<Button size="small" icon={<Plus className="size-3.5" />} loading={installing} disabled={installing || !canSelect} title={!canSelect ? "本轮最多启用 8 个 Skills" : undefined} onClick={() => void install()}>
加入
</Button>
)}
</div>
</div>
<p title={skill.description || "暂无技能说明"}>{skill.description || "暂无技能说明"}</p>
<div className="canvas-agent-skill-card-meta" style={{ color: theme.node.muted }}>
<span className="min-w-0 truncate">{author}</span>
<span aria-hidden="true">·</span>
{skill.version ? <><span aria-hidden="true">·</span><span className="canvas-agent-skill-card-version">v{skill.version}</span></> : null}
<span className="inline-flex shrink-0 items-center gap-1"><Users className="size-3.5" aria-hidden="true" />{addedCount}</span>
</div>
</div>
<div className="canvas-agent-skill-card-action">
{skill.isAdded ? (
<Button
size="small"
type="default"
className={selected ? "is-selected" : ""}
icon={selected ? <Check className="size-3.5" /> : <Plus className="size-3.5" />}
disabled={!canSelect}
aria-pressed={selected}
title={!canSelect ? "本轮最多启用 8 个 Skills" : undefined}
onClick={onToggle}
>
{selected ? "已启用" : "启用"}
</Button>
) : (
<Button size="small" icon={<Plus className="size-3.5" />} loading={installing} disabled={installing} onClick={() => void install()}>
加入
</Button>
)}
<p title={skill.description || "暂无技能说明"}>{skill.description || "暂无技能说明"}</p>
</div>
</article>
);

File diff suppressed because it is too large Load Diff

View File

@@ -118,7 +118,6 @@ export function AgentChatMessage({
<ChevronDown className="agent-reasoning-chevron" aria-hidden="true" />
</summary>
<div className="agent-reasoning-content" data-canvas-wheel-scroll>
<span className="agent-reasoning-rail" aria-hidden="true" />
<div className="agent-reasoning-text">{item.text || (item.streaming ? "正在整理思路…" : "暂无可展示的推理摘要")}</div>
</div>
</details>
@@ -235,7 +234,7 @@ export function AgentPendingToolCard({ summary, detail, theme, onReject, onAppro
return (
<div className="flex items-start gap-3">
<AgentTimelineMarker theme={theme} tone="approval" icon={<CircleAlert className="size-3.5" />} />
<div className="agent-pending-tool min-w-0 flex-1 rounded-r-lg border-l-2 py-1 pl-3 pr-1" style={{ borderColor: "#f97316", background: "rgba(249,115,22,.05)", color: theme.node.text }}>
<div className="agent-pending-tool min-w-0 flex-1 rounded-lg border py-2 pl-3 pr-3" style={{ borderColor: "rgba(249,115,22,.22)", background: "rgba(249,115,22,.05)", color: theme.node.text }}>
<div className="flex items-start gap-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2 text-[13px] font-semibold leading-5">
@@ -539,10 +538,10 @@ export function AgentChatComposer({
setPromptHeight(clampAgentPromptHeight(promptHeight + (event.key === "ArrowUp" ? 20 : -20)));
};
// 在输入值末尾检测「/关键词」打开技能候选;选中后写入稳定 token,编辑器再把它渲染为技能 chip。
// 在输入值末尾检测「/ 或 、+ 关键词」打开技能候选:中文输入法下 "/" 会打成 "、",两者等价,且都必须紧跟行首或空白,避免中文顿号误触发。选中后写入稳定 token,编辑器再把它渲染为技能 chip。
const handlePromptChange = (value: string) => {
onPromptChange(value);
const match = /(^|\s)\/([^\s/]*)$/.exec(value);
const match = /(^|\s)[/、]([^\s/、]*)$/.exec(value);
if (match && availableSlashSkills.length) {
const next = { start: match.index + match[1].length, query: match[2] };
setSlash((current) => (current && current.start === next.start && current.query === next.query ? current : next));
@@ -554,6 +553,7 @@ export function AgentChatComposer({
const applySlashSkill = (skill: Skill) => {
const token = `@[skill:${skill.skillId}] `;
// 触发符 "/" 与 "、" 都是单字符,替换长度固定为 1。
const next = slash ? `${prompt.slice(0, slash.start)}${token}${prompt.slice(slash.start + 1 + slash.query.length)}` : prompt ? `${prompt.replace(/\s+$/u, "")} ${token}` : token;
setSlash(null);
setSlashIndex(0);
@@ -674,7 +674,7 @@ export function AgentChatComposer({
value={prompt}
references={composerReferences}
includeAssetLibrary={includeAssetLibrary}
sendOnEnter={false}
sendOnEnter={canSubmit ? "both" : false}
disabled={disabled}
onChange={handlePromptChange}
onSubmit={() => { if (canSubmit) onSubmit(); }}
@@ -741,7 +741,12 @@ export function AgentChatComposer({
{left}
</div>
<div className="agent-composer-submit flex items-center gap-2">
<span className="agent-composer-send-hint">{canStop ? "运行中:发送即插话,下一步生效" : "Enter 换行 · ⌘/Ctrl+Enter 发送"}</span>
{disabled ? null : (
<span className="agent-composer-send-hint">
<span className="agent-composer-send-hint-full">{canStop ? "运行中:发送即插话,下一步生效" : "Enter 发送 · Shift+Enter 换行"}</span>
<span className="agent-composer-send-hint-compact">{canStop ? "运行中可插话" : "Enter 发送"}</span>
</span>
)}
{canStop ? <motion.button
type="button"
disabled={stopping}
@@ -761,7 +766,7 @@ export function AgentChatComposer({
type="button"
disabled={!canSubmit}
aria-label={sending ? "发送中" : canStop ? "插话" : "发送"}
title={canStop ? "插话:Agent 下一次开口时看到它" : "点击发送;⌘/Ctrl+Enter 发送"}
title={canStop ? "插话:Agent 下一次开口时看到它" : "点击发送;Enter 或 ⌘/Ctrl+Enter 发送"}
onClick={() => onSubmit()}
whileHover={canSubmit && !reducedMotion ? { scale: 1.06, y: -1 } : undefined}
whileTap={canSubmit && !reducedMotion ? { scale: 0.9, y: 1 } : undefined}
@@ -835,7 +840,6 @@ function AgentTimelineMarker({ theme, tone, icon }: { theme: (typeof canvasTheme
const color = tone === "error" ? "#ef4444" : tone === "approval" ? "#f97316" : tone === "tool" ? "#4f7cff" : tone === "agent" ? theme.accent.primary : theme.node.muted;
return (
<span className="relative flex w-6 shrink-0 self-stretch justify-center" aria-hidden="true">
<span className="absolute bottom-[-20px] top-6 w-px opacity-35" style={{ background: theme.toolbar.border }} />
<span className="relative grid size-6 place-items-center rounded-full" style={{ background: tone === "agent" ? theme.accent.primarySoft : theme.node.fill, color }}>
{icon || <span className="size-3 opacity-90" style={{ background: color, WebkitMask: "url(/icons/openai.svg) center / contain no-repeat", mask: "url(/icons/openai.svg) center / contain no-repeat" }} />}
</span>

View File

@@ -733,7 +733,7 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, re
disabled={Boolean(run && connectionStatus !== "connected") || !historyHydrated || !pendingHydrated}
sending={busy}
running={running}
placeholder={running ? "运行中可直接插话,会在它下一步生效" : "输入操作指导;用 @ 引用画布节点,用 / 引用 Skills"}
placeholder={running ? "运行中可直接插话,会在它下一步生效" : "输入操作指导;用 @ 引用画布节点,用 / 或 、 引用 Skills"}
theme={theme}
onPromptChange={setPrompt}
onSubmit={() => void submit()}

View File

@@ -96,19 +96,14 @@
}
.agent-tool-row {
/* 行为用图标和文案区分,颜色只留给真实的成功/失败状态。 */
/* 日志是事件流,不是连续卡片;只用间距和状态图标建立层次。 */
--agent-tool-accent: color-mix(in srgb, var(--foreground) 62%, transparent);
min-height: 42px;
border: 1px solid color-mix(in srgb, var(--foreground) 11%, transparent);
border-radius: 14px;
background: color-mix(in srgb, var(--foreground) 2.5%, var(--background));
padding: 9px 10px;
transition: border-color 160ms ease, background-color 160ms ease, transform 160ms ease;
}
.agent-tool-row:hover {
border-color: color-mix(in srgb, var(--foreground) 20%, transparent);
background: color-mix(in srgb, var(--foreground) 4%, var(--background));
min-height: 36px;
border: 0;
border-radius: 8px;
background: transparent;
padding: 5px 2px;
transition: transform 160ms ease;
}
/* 没有节点明细的“读取信息 / 操作画布”只保留时间线语义,不再撑成一张空卡片。 */
@@ -120,11 +115,6 @@
padding: 3px 2px;
}
.agent-tool-row--plain:hover {
border-color: transparent;
background: transparent;
}
.agent-tool-row--read {
--agent-tool-accent: color-mix(in srgb, var(--foreground) 62%, transparent);
}
@@ -151,11 +141,11 @@
flex: 0 0 auto;
align-items: center;
gap: 5px;
border: 1px solid color-mix(in srgb, var(--foreground) 16%, transparent);
border-radius: 999px;
background: color-mix(in srgb, var(--foreground) 5%, transparent);
padding: 2px 7px 2px 6px;
color: color-mix(in srgb, var(--foreground) 76%, transparent);
border: 0;
border-radius: 5px;
background: color-mix(in srgb, var(--agent-tool-accent) 9%, transparent);
padding: 2px 5px;
color: color-mix(in srgb, var(--foreground) 68%, transparent);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.01em;
@@ -235,10 +225,18 @@
.agent-composer-prompt-scroll {
min-height: 60px;
max-height: min(240px, 34vh);
min-width: 0;
overflow-y: auto;
overflow-x: hidden;
scrollbar-gutter: stable;
}
.agent-composer-prompt-scroll > [data-canvas-no-zoom] {
min-height: 0;
height: 100%;
max-height: 100%;
}
.agent-composer-resize-handle {
display: grid;
width: 100%;
@@ -366,11 +364,19 @@
white-space: nowrap;
}
/* 面板宽度包含内边距;默认 448px 面板的输入区已不足以同时容纳控制区和快捷键提示。 */
/* 面板宽度包含内边距;默认 448px 面板放不下完整快捷键说明,窄态降级为短文案而不是隐藏提示本身。 */
.agent-composer-send-hint .agent-composer-send-hint-compact {
display: none;
}
@container (max-width: 520px) {
.agent-composer-send-hint {
.agent-composer-send-hint .agent-composer-send-hint-full {
display: none;
}
.agent-composer-send-hint .agent-composer-send-hint-compact {
display: inline;
}
}
.agent-tool-status {
@@ -398,17 +404,15 @@
.agent-reasoning-card {
min-width: 0;
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--foreground) 9%, transparent);
border-radius: 14px;
background: color-mix(in srgb, var(--foreground) 2.5%, var(--background));
box-shadow: 0 5px 18px color-mix(in srgb, var(--foreground) 3%, transparent);
transition: border-color 160ms ease, background-color 160ms ease, box-shadow 160ms ease;
border: 0;
border-radius: 10px;
background: transparent;
box-shadow: none;
transition: background-color 160ms ease;
}
.agent-reasoning-card[open] {
border-color: color-mix(in srgb, var(--agent-reasoning-accent) 28%, transparent);
background: color-mix(in srgb, var(--agent-reasoning-accent) 3.5%, var(--background));
box-shadow: 0 8px 24px color-mix(in srgb, var(--agent-reasoning-accent) 7%, transparent);
}
.agent-reasoning-summary {
@@ -418,7 +422,7 @@
gap: 9px;
cursor: pointer;
list-style: none;
padding: 9px 10px;
padding: 6px 4px;
color: color-mix(in srgb, var(--foreground) 72%, transparent);
user-select: none;
}
@@ -437,9 +441,9 @@
height: 25px;
flex: 0 0 25px;
place-items: center;
border: 1px solid color-mix(in srgb, var(--agent-reasoning-accent) 24%, transparent);
border-radius: 8px;
background: color-mix(in srgb, var(--agent-reasoning-accent) 11%, transparent);
border: 0;
border-radius: 50%;
background: color-mix(in srgb, var(--agent-reasoning-accent) 10%, transparent);
color: var(--agent-reasoning-accent);
}
@@ -477,10 +481,10 @@
flex: 0 0 auto;
align-items: center;
gap: 5px;
border: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
border-radius: 999px;
background: color-mix(in srgb, var(--foreground) 4%, transparent);
padding: 2px 7px;
border: 0;
border-radius: 5px;
background: transparent;
padding: 2px 4px;
color: color-mix(in srgb, var(--foreground) 45%, transparent);
font-size: 10px;
font-weight: 600;
@@ -488,7 +492,6 @@
}
.agent-reasoning-status.is-live {
border-color: color-mix(in srgb, var(--agent-reasoning-accent) 22%, transparent);
background: color-mix(in srgb, var(--agent-reasoning-accent) 9%, transparent);
color: var(--agent-reasoning-accent);
}
@@ -524,21 +527,13 @@
position: relative;
display: flex;
max-height: min(232px, 30vh);
gap: 10px;
gap: 0;
overflow: auto;
border-top: 1px solid color-mix(in srgb, var(--foreground) 7%, transparent);
padding: 10px 12px 12px 14px;
border-top: 0;
padding: 4px 10px 10px 38px;
scrollbar-gutter: stable;
}
.agent-reasoning-rail {
width: 2px;
flex: 0 0 2px;
align-self: stretch;
border-radius: 999px;
background: linear-gradient(180deg, color-mix(in srgb, var(--agent-reasoning-accent) 72%, transparent), color-mix(in srgb, var(--agent-reasoning-accent) 12%, transparent));
}
.agent-reasoning-text {
min-width: 0;
color: color-mix(in srgb, var(--foreground) 58%, transparent);
@@ -873,170 +868,194 @@
gap: 8px;
}
.canvas-agent-skill-library-modal .ant-modal {
padding-bottom: 0;
margin-top: var(--space-10);
}
.canvas-agent-skill-library-modal .ant-modal-container {
overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--r-2xl);
padding: 0;
background: transparent;
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.18), var(--elevation-hairline) !important;
border: 0;
border-radius: var(--r-5xl);
background: var(--background);
box-shadow: var(--elevation-overlay);
}
.canvas-agent-skill-library-modal .ant-modal-body {
padding: 0;
.canvas-agent-skill-library-frame {
position: relative;
}
.canvas-agent-skill-library-modal .ant-modal-close {
top: 16px;
right: 16px;
width: 34px;
height: 34px;
.canvas-agent-skill-library-close {
position: absolute;
top: calc(-1 * var(--space-10));
inset-inline-start: var(--space-2);
inset-inline-end: auto;
width: auto;
height: var(--space-8);
border-radius: var(--r-md);
padding: 0 var(--space-2);
color: var(--foreground);
background: color-mix(in srgb, var(--foreground) 7%, transparent);
background: transparent;
overflow: visible;
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--fs-body);
}
.canvas-agent-skill-library-modal .ant-modal-close:hover {
background: color-mix(in srgb, var(--foreground) 12%, transparent);
.canvas-agent-skill-library-close kbd {
border-radius: var(--r-xs);
background: var(--primary);
padding: var(--space-half) var(--space-2);
color: var(--primary-foreground);
font-size: var(--fs-caption);
}
.canvas-agent-skill-library-shell {
position: relative;
display: grid;
height: min(780px, calc(100dvh - 36px));
grid-template-rows: auto auto auto minmax(0, 1fr) auto;
height: min(700px, 66dvh);
max-height: calc(100dvh - 88px);
grid-template-columns: 176px minmax(0, 1fr);
overflow: hidden;
border-radius: inherit;
background:
radial-gradient(circle at 0 0, color-mix(in srgb, var(--workspace-accent) 7%, transparent), transparent 34%),
var(--cn-spatial-elevated, var(--background));
}
.canvas-agent-skill-library-sidebar {
display: flex;
min-height: 0;
min-width: 0;
flex-direction: column;
gap: var(--space-5);
margin: var(--space-6) 0;
border-right: 1px solid var(--border);
padding: 0 var(--space-3);
}
.canvas-agent-skill-library-search.ant-input-affix-wrapper {
flex: 0 0 auto;
height: var(--space-12);
border: 0;
border-radius: var(--r-full);
background: var(--library-surface);
padding-inline: var(--space-4);
font-size: var(--fs-body);
}
.canvas-agent-skill-library-search .ant-input-prefix {
margin-inline-end: var(--space-2);
}
.canvas-agent-skill-library-categories {
display: flex;
min-height: 0;
flex-direction: column;
gap: var(--space-1);
overflow-y: auto;
padding: var(--space-1);
overscroll-behavior: contain;
}
.canvas-agent-skill-library-tab {
display: flex;
min-height: var(--space-12);
flex: 0 0 auto;
align-items: center;
gap: var(--space-3);
border: 0;
border-radius: var(--r-lg);
padding: var(--space-3);
color: var(--muted-foreground);
font-size: var(--fs-body-lg);
text-align: left;
overflow-wrap: anywhere;
transition: background-color var(--motion-dur-fast) ease, color var(--motion-dur-fast) ease;
}
.canvas-agent-skill-library-tab::before {
width: var(--space-1);
height: var(--space-1);
flex: 0 0 auto;
border-radius: var(--r-full);
background: currentColor;
content: "";
opacity: 0.4;
}
.canvas-agent-skill-library-tab span {
margin-left: auto;
color: var(--muted-foreground);
font-size: var(--fs-caption);
font-variant-numeric: tabular-nums;
}
.canvas-agent-skill-library-tab:hover {
background: var(--library-surface-hover);
color: var(--foreground);
}
.canvas-agent-skill-library-tab.is-active {
color: var(--foreground);
font-weight: 650;
}
.canvas-agent-skill-library-tab.is-active::before {
opacity: 1;
}
.canvas-agent-skill-library-content {
container: agent-skill-content / inline-size;
display: flex;
min-width: 0;
min-height: 0;
flex-direction: column;
}
.canvas-agent-skill-library-header {
display: flex;
min-width: 0;
flex: 0 0 auto;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 18px 64px 14px 20px;
gap: var(--space-4);
padding: var(--space-5) var(--space-6);
}
.canvas-agent-skill-library-header p {
margin-bottom: 0;
.canvas-agent-skill-library-header h2 {
margin: 0;
font-size: var(--fs-title);
font-weight: 650;
}
.canvas-agent-skill-library-count {
flex: 0 0 auto;
border: 1px solid color-mix(in srgb, var(--foreground) 9%, transparent);
border-radius: var(--r-lg);
background: color-mix(in srgb, var(--foreground) 4%, transparent);
padding: 7px 10px;
font-size: var(--fs-label);
border: 0;
border-radius: var(--r-full);
background: var(--library-surface);
padding: var(--space-2-half) var(--space-4);
color: var(--foreground);
font-size: var(--fs-caption);
font-variant-numeric: tabular-nums;
}
.canvas-agent-skill-library-toolbar {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 430px);
align-items: center;
gap: 14px;
padding: 0 20px 12px;
.canvas-agent-skill-library-count:hover {
background: var(--library-surface-hover);
}
.canvas-agent-skill-library-tabs {
display: flex;
min-width: 0;
align-items: center;
gap: 4px;
}
.canvas-agent-skill-library-tab {
display: inline-flex;
min-height: 38px;
flex: 0 0 auto;
align-items: center;
gap: 7px;
border-radius: var(--r-md);
padding: 0 12px;
color: color-mix(in srgb, var(--foreground) 56%, transparent);
font-size: var(--fs-caption);
font-weight: 560;
transition: background-color var(--motion-dur-fast) var(--motion-ease-out), color var(--motion-dur-fast) var(--motion-ease-out);
}
.canvas-agent-skill-library-tab span {
min-width: 20px;
border-radius: 999px;
background: color-mix(in srgb, currentColor 9%, transparent);
padding: 1px 6px;
font-size: var(--fs-tiny);
line-height: 18px;
text-align: center;
}
.canvas-agent-skill-library-tab:hover {
background: color-mix(in srgb, var(--foreground) 4%, transparent);
color: color-mix(in srgb, var(--foreground) 76%, transparent);
}
.canvas-agent-skill-library-tab.is-active {
background: color-mix(in srgb, var(--foreground) 5%, transparent);
color: color-mix(in srgb, var(--foreground) 78%, transparent);
}
.canvas-agent-skill-library-search {
height: 40px;
border-radius: var(--r-lg);
}
.canvas-agent-skill-library-categories {
display: flex;
min-width: 0;
gap: 8px;
overflow-x: auto;
border-top: 1px solid color-mix(in srgb, var(--foreground) 7%, transparent);
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 7%, transparent);
padding: 10px 20px;
overscroll-behavior-x: contain;
scrollbar-width: none;
}
.canvas-agent-skill-library-categories::-webkit-scrollbar {
display: none;
}
.canvas-agent-skill-category {
min-height: 34px;
flex: 0 0 auto;
border: 1px solid color-mix(in srgb, var(--foreground) 9%, transparent);
border-radius: var(--r-md);
padding: 0 13px;
color: color-mix(in srgb, var(--foreground) 58%, transparent);
font-size: var(--fs-caption);
transition: border-color var(--motion-dur-fast) var(--motion-ease-out), background-color var(--motion-dur-fast) var(--motion-ease-out), color var(--motion-dur-fast) var(--motion-ease-out);
}
.canvas-agent-skill-category:hover {
border-color: color-mix(in srgb, var(--foreground) 20%, transparent);
color: var(--foreground);
}
.canvas-agent-skill-category.is-active {
border-color: color-mix(in srgb, var(--workspace-accent) 34%, var(--border));
background: color-mix(in srgb, var(--workspace-accent) 11%, transparent);
color: var(--workspace-accent);
.canvas-agent-skill-library-count span {
color: var(--muted-foreground);
}
.canvas-agent-skill-library-list {
display: grid;
min-height: 0;
grid-template-columns: repeat(2, minmax(0, 1fr));
flex: 1;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-auto-rows: max-content;
align-content: start;
gap: 12px;
gap: var(--space-6) var(--space-4);
overflow-y: auto;
padding: 16px 20px;
padding: var(--space-1) var(--space-6) var(--space-6);
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
.canvas-agent-skill-library-state {
@@ -1045,54 +1064,52 @@
grid-column: 1 / -1;
place-items: center;
align-content: center;
gap: 10px;
padding: 28px;
font-size: var(--fs-caption);
gap: var(--space-3);
padding: var(--space-6);
font-size: var(--fs-body);
text-align: center;
}
.canvas-agent-skill-card {
display: grid;
min-width: 0;
min-height: 136px;
grid-template-columns: 164px minmax(0, 1fr) auto;
gap: 14px;
overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--r-xl);
background: color-mix(in srgb, var(--foreground) 3%, transparent);
padding: 11px;
transition: border-color var(--motion-dur-fast) var(--motion-ease-out), background-color var(--motion-dur-fast) var(--motion-ease-out), transform var(--motion-dur-fast) var(--motion-ease-out);
border: 0;
border-radius: var(--r-2xl);
transition: transform var(--motion-state) var(--motion-ease-out), background-color var(--motion-state) ease;
}
.canvas-agent-skill-card:hover {
border-color: color-mix(in srgb, var(--foreground) 22%, transparent) !important;
background: color-mix(in srgb, var(--foreground) 5%, transparent);
transform: translateY(-1px);
.canvas-agent-skill-card:hover,
.canvas-agent-skill-card:focus-within {
transform: translateY(var(--library-hover-offset));
background: var(--library-surface-hover);
}
.canvas-agent-skill-card.is-selected {
background: color-mix(in srgb, var(--workspace-accent) 7%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--workspace-accent) 24%, transparent);
.canvas-agent-skill-card:hover .canvas-agent-skill-card-cover img,
.canvas-agent-skill-card:focus-within .canvas-agent-skill-card-cover img {
transform: scale(1.025);
}
.canvas-agent-skill-card-cover {
position: relative;
min-width: 0;
overflow: hidden;
border-radius: var(--r-lg);
aspect-ratio: 16 / 10;
border-radius: var(--r-2xl);
aspect-ratio: 16 / 9;
background: var(--library-cover-surface);
transition: outline-color var(--motion-dur-fast) ease;
}
.canvas-agent-skill-card-cover img {
.canvas-agent-skill-card.is-selected .canvas-agent-skill-card-cover {
outline: 1px solid var(--library-selected-stroke);
outline-offset: 2px;
}
.canvas-agent-skill-card-cover img,
.canvas-agent-skill-card-cover video {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
transition: transform var(--motion-dur-base) var(--motion-ease-out);
}
.canvas-agent-skill-card:hover .canvas-agent-skill-card-cover img {
transform: scale(1.025);
transition: transform var(--motion-state) var(--motion-ease-out);
}
.canvas-agent-skill-card-placeholder {
@@ -1100,258 +1117,242 @@
width: 100%;
height: 100%;
place-items: center;
color: color-mix(in srgb, var(--workspace-accent) 64%, var(--foreground));
background:
radial-gradient(circle at 30% 20%, color-mix(in srgb, var(--workspace-accent) 28%, transparent), transparent 46%),
linear-gradient(145deg, color-mix(in srgb, var(--foreground) 8%, transparent), color-mix(in srgb, var(--foreground) 2%, transparent));
color: var(--muted-foreground);
background: var(--library-cover-surface);
}
.canvas-agent-skill-card-category {
position: absolute;
top: 7px;
right: 7px;
max-width: calc(100% - 14px);
bottom: var(--space-3);
left: var(--space-3);
max-width: calc(100% - var(--space-6));
overflow: hidden;
border: 1px solid color-mix(in srgb, white 18%, transparent);
border-radius: var(--r-md);
background: color-mix(in srgb, black 62%, transparent);
padding: 2px 7px;
color: white;
border-radius: var(--r-sm);
background: color-mix(in srgb, var(--color-neutral-950) 75%, transparent);
padding: var(--space-1) var(--space-2);
color: var(--color-neutral-0);
font-size: var(--fs-tiny);
line-height: 19px;
text-overflow: ellipsis;
white-space: nowrap;
backdrop-filter: blur(8px);
}
.canvas-agent-skill-card-selected {
position: absolute;
top: var(--space-3);
right: var(--space-3);
display: grid;
width: var(--space-8);
height: var(--space-8);
place-items: center;
border-radius: var(--r-full);
color: var(--control-selected-fg);
background: var(--control-selected-bg);
}
.canvas-agent-skill-card-body {
display: flex;
min-width: 0;
flex-direction: column;
padding: 4px 0 2px;
gap: var(--space-2);
padding-top: var(--space-3);
}
.canvas-agent-skill-card-title-row {
display: flex;
min-width: 0;
align-items: baseline;
gap: 8px;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
}
.canvas-agent-skill-card-title-row h3 {
min-width: 0;
overflow: hidden;
margin: 0;
color: var(--foreground);
font-size: 15px;
color: inherit;
font-size: var(--fs-heading);
font-weight: 650;
line-height: 22px;
line-height: 1.5;
text-overflow: ellipsis;
white-space: nowrap;
}
.canvas-agent-skill-card-version {
flex: 0 0 auto;
color: color-mix(in srgb, var(--foreground) 42%, transparent);
font-size: var(--fs-tiny);
}
.canvas-agent-skill-card-body > p {
display: -webkit-box;
min-width: 0;
overflow: hidden;
margin: 7px 0 10px;
color: color-mix(in srgb, var(--foreground) 54%, transparent);
font-size: var(--fs-label);
line-height: 19px;
margin: 0;
color: var(--muted-foreground);
font-size: var(--fs-body);
line-height: 1.65;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow-wrap: anywhere;
}
.canvas-agent-skill-card-meta {
display: flex;
min-width: 0;
align-items: center;
gap: 6px;
margin-top: auto;
font-size: var(--fs-label);
gap: var(--space-2);
font-size: var(--fs-caption);
}
.canvas-agent-skill-card-version {
flex: 0 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.canvas-agent-skill-card-action {
display: flex;
align-items: flex-start;
padding-top: 3px;
flex: 0 0 auto;
}
.canvas-agent-skill-card-action .ant-btn {
min-width: 76px;
border-radius: var(--r-md);
min-width: var(--space-16);
height: var(--space-8);
border: 0;
border-radius: var(--r-full);
background: var(--primary);
padding-inline: var(--space-3);
color: var(--primary-foreground);
box-shadow: none;
font-size: var(--fs-body);
}
.canvas-agent-skill-card-action .ant-btn.is-selected {
border-color: color-mix(in srgb, var(--workspace-accent) 32%, var(--border));
background: color-mix(in srgb, var(--workspace-accent) 13%, transparent);
color: var(--workspace-accent);
box-shadow: none;
.canvas-agent-skill-card-action .ant-btn:not(:disabled):hover {
background: color-mix(in srgb, var(--primary) 85%, var(--background));
color: var(--primary-foreground);
}
.canvas-agent-skill-card-action .ant-btn.is-selected,
.canvas-agent-skill-card-action .ant-btn.is-selected:not(:disabled):hover {
background: var(--control-selected-bg);
color: var(--control-selected-fg);
}
.canvas-agent-skill-card-action .ant-btn:disabled {
background: var(--library-surface);
color: var(--muted-foreground);
opacity: 0.6;
}
.canvas-agent-skill-library-footer {
display: flex;
min-height: 48px;
min-height: var(--space-12);
flex: 0 0 auto;
align-items: center;
justify-content: space-between;
gap: 16px;
border-top: 1px solid;
padding: 8px 20px;
font-size: var(--fs-label);
gap: var(--space-4);
padding: var(--space-2) var(--space-6);
font-size: var(--fs-caption);
}
.canvas-agent-skill-library-tab:focus-visible,
.canvas-agent-skill-category:focus-visible {
.canvas-agent-skill-library-close:focus-visible,
.canvas-agent-skill-library-count:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
@media (max-width: 920px) {
@container agent-skill-content (max-width: 660px) {
.canvas-agent-skill-library-list {
grid-template-columns: minmax(0, 1fr);
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 680px) {
@media (max-width: 900px) {
.canvas-agent-skill-library-modal .ant-modal {
width: calc(100vw - 32px) !important;
}
.canvas-agent-skill-library-shell {
height: calc(100dvh - 24px);
height: min(700px, calc(100dvh - 88px));
}
.canvas-agent-skill-library-header {
align-items: flex-start;
gap: 10px;
padding: 16px 56px 12px 14px;
.canvas-agent-skill-library-header,
.canvas-agent-skill-library-list,
.canvas-agent-skill-library-footer {
padding-inline: var(--space-5);
}
.canvas-agent-skill-library-header p {
display: none;
.canvas-agent-skill-library-sidebar {
padding-inline: var(--space-3);
}
}
@media (max-width: 639px) {
.canvas-agent-skill-library-modal .ant-modal-container {
border-radius: var(--r-4xl);
}
.canvas-agent-skill-library-count {
padding: 5px 8px;
}
.canvas-agent-skill-library-toolbar {
.canvas-agent-skill-library-shell {
grid-template-columns: minmax(0, 1fr);
gap: 8px;
padding: 0 14px 10px;
grid-template-rows: auto minmax(0, 1fr);
}
.canvas-agent-skill-library-tabs {
overflow-x: auto;
scrollbar-width: none;
.canvas-agent-skill-library-sidebar {
gap: var(--space-2);
margin: 0;
border-right: 0;
padding: var(--space-4) var(--space-4) 0;
}
.canvas-agent-skill-library-tabs::-webkit-scrollbar {
display: none;
.canvas-agent-skill-library-search.ant-input-affix-wrapper {
height: var(--space-10);
}
.canvas-agent-skill-library-categories {
padding: 8px 14px;
}
.canvas-agent-skill-library-list {
gap: 10px;
padding: 12px 14px;
}
.canvas-agent-skill-library-footer {
min-height: 44px;
padding: 6px 14px;
}
}
@media (max-width: 560px) {
.canvas-agent-skill-library-header {
flex-wrap: wrap;
}
.canvas-agent-skill-library-count {
position: absolute;
top: 17px;
right: 56px;
flex-direction: row;
gap: var(--space-1);
overflow-x: auto;
overscroll-behavior-x: contain;
}
.canvas-agent-skill-library-tab {
min-height: 34px;
padding: 0 9px;
font-size: var(--fs-label);
}
.canvas-agent-skill-card {
min-height: 122px;
grid-template-columns: 104px minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr) auto;
gap: 8px 10px;
padding: 9px;
}
.canvas-agent-skill-card-cover {
grid-row: 1 / 3;
align-self: stretch;
aspect-ratio: auto;
}
.canvas-agent-skill-card-body {
padding: 1px 0 0;
}
.canvas-agent-skill-card-body > p {
margin: 4px 0 6px;
font-size: var(--fs-tiny);
line-height: 17px;
}
.canvas-agent-skill-card-meta {
font-size: var(--fs-tiny);
}
.canvas-agent-skill-card-action {
justify-content: flex-end;
padding: 0;
}
.canvas-agent-skill-card-action .ant-btn {
min-width: 72px;
}
.canvas-agent-skill-library-footer > span:last-child {
display: none;
}
}
@media (max-height: 560px) {
.canvas-agent-skill-library-shell {
height: calc(100dvh - 16px);
min-height: var(--space-10);
gap: var(--space-2);
padding: var(--space-2);
font-size: var(--fs-body);
white-space: nowrap;
}
.canvas-agent-skill-library-header {
padding-top: 12px;
padding-bottom: 8px;
padding: var(--space-4);
}
.canvas-agent-skill-library-categories {
padding-top: 7px;
padding-bottom: 7px;
.canvas-agent-skill-library-header h2 {
font-size: var(--fs-heading-lg);
}
.canvas-agent-skill-library-list {
grid-template-columns: minmax(0, 1fr);
gap: var(--space-6);
padding-inline: var(--space-4);
}
.canvas-agent-skill-library-footer {
gap: var(--space-2);
padding-inline: var(--space-4);
font-size: var(--fs-label);
}
}
@media (prefers-reduced-motion: reduce) {
.canvas-agent-skill-card,
.canvas-agent-skill-card-cover,
.canvas-agent-skill-card-cover img,
.canvas-agent-skill-category,
.canvas-agent-skill-card-cover video,
.canvas-agent-skill-library-tab {
transition: none;
}
.canvas-agent-skill-card:hover,
.canvas-agent-skill-card:hover .canvas-agent-skill-card-cover img {
.canvas-agent-skill-card:focus-within,
.canvas-agent-skill-card:hover .canvas-agent-skill-card-cover img,
.canvas-agent-skill-card:focus-within .canvas-agent-skill-card-cover img {
transform: none;
}
}

View File

@@ -4,7 +4,7 @@ import { Brush, Camera, Copy, FileText, Globe2, Grid2x2, Layers3, Lock, LockOpen
import type { CanvasNodeData } from "@/types/canvas";
import type { NodeToolbarGroup } from "@/lib/canvas/tool-registry";
type ImageNodeActionToolId = "copyPrompt" | "reversePrompt" | "replace" | "resize" | "annotation" | "annotationEdit" | "textEdit" | "maskEdit" | "imageEdit" | "removeBackground" | "layerDecomposition" | "emotion" | "portraitTexture" | "crop" | "split" | "upscale" | "superResolve" | "angle" | "lighting" | "panorama" | "view";
type ImageNodeActionToolId = "copyPrompt" | "reversePrompt" | "replace" | "resize" | "annotation" | "annotationEdit" | "textEdit" | "maskEdit" | "removeBackground" | "layerDecomposition" | "emotion" | "portraitTexture" | "crop" | "split" | "upscale" | "superResolve" | "angle" | "lighting" | "panorama" | "view";
type ImageToolHandlers = {
onUpload: (node: CanvasNodeData) => void;
@@ -13,7 +13,6 @@ type ImageToolHandlers = {
onAnnotationEdit: (node: CanvasNodeData) => void;
onTextEdit: (node: CanvasNodeData) => void;
onMaskEdit: (node: CanvasNodeData) => void;
onImageEdit: (node: CanvasNodeData) => void;
onRemoveBackground: (node: CanvasNodeData) => void;
onLayerDecomposition: (node: CanvasNodeData) => void;
onEmotion: (node: CanvasNodeData) => void;
@@ -117,15 +116,6 @@ const imageToolDefinitions: ImageToolDefinition[] = [
order: 15,
run: (node, handlers) => handlers.onTextEdit(node),
},
{
id: "imageEdit",
label: "图片编辑",
description: "用文字描述要修改的内容,生成新图片",
icon: () => <WandSparkles className="size-3.5" />,
group: "primary",
order: 20,
run: (node, handlers) => handlers.onImageEdit(node),
},
{
id: "removeBackground",
label: "去除背景",

View File

@@ -31,7 +31,7 @@ export function CanvasNodeImageEditDialog({
}, [config, open, dataUrl]);
return (
<Modal open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} centered destroyOnHidden width={860} title="图片编辑">
<Modal open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} centered destroyOnHidden width={860} title={preset === "remove-background" ? "去除背景" : "图片编辑"}>
<div className="grid gap-5 md:grid-cols-[minmax(0,1fr)_300px]">
<div className="grid min-h-[320px] place-items-center overflow-hidden rounded-xl bg-black/5 p-3 dark:bg-white/[0.04]">
<img src={dataUrl} alt="待编辑图片" className="max-h-[58vh] max-w-full object-contain" draggable={false} />

File diff suppressed because it is too large Load Diff

View File

@@ -37,7 +37,6 @@ type CanvasNodeToolbarProps = {
onDownload: (node: CanvasNodeData) => void;
onSaveAsset: (node: CanvasNodeData) => void;
onMaskEdit: (node: CanvasNodeData) => void;
onImageEdit: (node: CanvasNodeData) => void;
onRemoveBackground: (node: CanvasNodeData) => void;
onLayerDecomposition: (node: CanvasNodeData) => void;
onEmotion: (node: CanvasNodeData) => void;
@@ -103,7 +102,6 @@ export function CanvasNodeToolbar({
onDownload,
onSaveAsset,
onMaskEdit,
onImageEdit,
onRemoveBackground,
onLayerDecomposition,
onEmotion,
@@ -231,13 +229,13 @@ export function CanvasNodeToolbar({
}
copyText(prompt, "提示词已复制");
};
const imageTools = buildImageToolbarTools(node, { onUpload, onToggleFreeResize, onAnnotate, onAnnotationEdit, onTextEdit, onMaskEdit, onImageEdit, onRemoveBackground, onLayerDecomposition, onEmotion, onPortraitTexture, onCrop, onUpscale, onSuperResolve, onAngle, onLighting, onPanorama, onViewImage, onCopyPrompt: copyImagePrompt, onReversePrompt });
const imageTools = buildImageToolbarTools(node, { onUpload, onToggleFreeResize, onAnnotate, onAnnotationEdit, onTextEdit, onMaskEdit, onRemoveBackground, onLayerDecomposition, onEmotion, onPortraitTexture, onCrop, onUpscale, onSuperResolve, onAngle, onLighting, onPanorama, onViewImage, onCopyPrompt: copyImagePrompt, onReversePrompt });
// 构建 ToolContext——供注册表解析工具
const nodeHoverHandlers = {
onNodeInfo: onInfo, onNodeDelete: onDelete, onNodeRetry: onRetry, onNodeEditText: onEditText, onNodeDecreaseFont: onDecreaseFont, onNodeIncreaseFont: onIncreaseFont,
onNodeToggleDialog: onToggleDialog, onNodeAnnotate: onAnnotate, onNodeGenerateImage: onGenerateImage, onNodeUpload: onUpload, onNodeDownload: onDownload,
onNodeSaveAsset: onSaveAsset, onNodeMaskEdit: onMaskEdit, onNodeImageEdit: onImageEdit, onNodeRemoveBackground: onRemoveBackground, onNodeEmotion: onEmotion, onNodePortraitTexture: onPortraitTexture, onNodeCrop: onCrop,
onNodeSaveAsset: onSaveAsset, onNodeMaskEdit: onMaskEdit, onNodeRemoveBackground: onRemoveBackground, onNodeEmotion: onEmotion, onNodePortraitTexture: onPortraitTexture, onNodeCrop: onCrop,
onNodeSplit: (target) => onSplit(target, { rows: 2, columns: 2 }), onNodeUpscale: onUpscale, onNodeSuperResolve: onSuperResolve, onNodeAngle: onAngle, onNodeViewImage: onViewImage,
onNodeExtractVideoFrames: onExtractVideoFrames, onNodeExtractAudioFromVideo: onExtractAudioFromVideo, onNodeTrimVideoSegments: onTrimVideoSegments, onNodeReversePrompt: onReversePrompt, onNodeToggleFreeResize: onToggleFreeResize,
onNodeSubtitles: onSubtitles, onNodeTimeline: onTimeline, onNodeToggleLocked: onToggleLocked, onNodeCopyPrompt: copyImagePrompt,

View File

@@ -66,7 +66,7 @@ export function CanvasProjectAssetModal({
mediaKind,
asset: media,
imageUrl: coverRepresentation ? resourceFileUrl(coverRepresentation.resourceId) : project?.mediaType === "image" && remoteResourceId ? resourceFileUrl(remoteResourceId) : undefined,
imageStorageKey: coverRepresentation ? `resource:${coverRepresentation.resourceId}` : undefined,
imageStorageKey: coverRepresentation ? `resource:${coverRepresentation.resourceId}` : remoteResourceId ? `resource:${remoteResourceId}` : undefined,
imageFit: character ? "contain" : "cover",
description: character ? `${character.character?.visualStatus === "ready" ? "形象就绪" : "形象待完善"} · ${character.character?.voiceStatus === "ready" ? "声音已绑定" : "声音未绑定"}` : project?.previewText,
searchText: [media?.tags?.join(" ") || "", project?.previewText || ""].join(" "),

View File

@@ -1,7 +1,7 @@
import { Check, Clapperboard, CloudUpload, Download, FileText, Frame, Image as ImageIcon, MoreHorizontal, Music2, Pencil, Plus, Settings2, Sparkles, Trash2, Video, X } from "lucide-react";
import type { ReactNode } from "react";
import { useNavigate, useSearchParams } from "react-router";
import { Dropdown, Input } from "antd";
import { App, Dropdown, Input } from "antd";
import { useCanvasStore, type CanvasProject } from "@/stores/canvas/use-canvas-store";
import { useCanvasUiStore } from "@/stores/canvas/use-canvas-ui-store";
@@ -13,6 +13,7 @@ import { CachedResourceImage } from "@/components/cached-resource-image";
import { MediaPlaceholder } from "@/components/ui/product/media-placeholder";
import { cn } from "@/lib/utils";
import { useSyncProgressStore } from "@/stores/use-sync-progress-store";
import { hasRemoteUserDataSyncSession, loadCanvasProjectForEditing, saveRemoteUserDataNow } from "@/services/user-data-sync";
type ProjectPreviewMedia = { node: CanvasNodeData; url: string; storageKey?: string };
const projectPreviewMediaCache = new WeakMap<CanvasNodeData[], { first?: ProjectPreviewMedia; latest?: ProjectPreviewMedia }>();
@@ -30,6 +31,7 @@ export function CanvasCreateCard({ disabled, onClick }: { disabled?: boolean; on
}
export function CanvasProjectCard({ project, projectName, variant = "library", readOnly = false, footer }: { project: CanvasProject; projectName?: string; variant?: "library" | "recent"; readOnly?: boolean; footer?: ReactNode }) {
const { message } = App.useApp();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const renameProject = useCanvasStore((state) => state.renameProject);
@@ -44,9 +46,19 @@ export function CanvasProjectCard({ project, projectName, variant = "library", r
const editing = editingId === project.id;
const selected = selectedIds.includes(project.id);
const open = () => navigate(`/canvas/${project.id}${searchParams.toString() ? `?${searchParams.toString()}` : ""}`);
const saveTitle = () => {
renameProject(project.id, editingTitle);
const saveTitle = async () => {
stopEditing();
if (!hasRemoteUserDataSyncSession()) {
renameProject(project.id, editingTitle);
return;
}
try {
await loadCanvasProjectForEditing(project.id);
renameProject(project.id, editingTitle);
await saveRemoteUserDataNow(project.id);
} catch (error) {
message.error(error instanceof Error ? error.message : "重命名失败");
}
};
const compact = variant === "recent";

View File

@@ -42,7 +42,7 @@ type Props = Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, "onChange" | "val
containerClassName?: string;
highlightLabels?: boolean;
mentionMenuWidth?: number;
sendOnEnter?: boolean;
sendOnEnter?: boolean | "both";
onContentSizeChange?: (height: number) => void;
includeAssetLibrary?: boolean;
activeDropReferenceId?: string | null;
@@ -50,6 +50,15 @@ type Props = Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, "onChange" | "val
autoLinkEnabled?: boolean;
};
// 回车提交语义由调用方决定:false 只在 ⌘/Ctrl+Enter 提交,"both" 两种都提交;Shift+Enter 始终换行。
function shouldSubmitOnEnter(event: { key: string; ctrlKey: boolean; metaKey: boolean; shiftKey: boolean }, sendOnEnter: boolean | "both") {
if (event.key !== "Enter" || event.shiftKey) return false;
const modifier = event.ctrlKey || event.metaKey;
if (sendOnEnter === false) return modifier;
if (sendOnEnter === "both") return true;
return !modifier;
}
export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Props>(function CanvasResourceMentionTextarea(
{ value, references, onSelectReference, onChange, onSubmit, onKeyDown, className, containerClassName, style, highlightLabels = true, mentionMenuWidth = 320, sendOnEnter = true, onContentSizeChange, includeAssetLibrary = false, activeDropReferenceId, onReferenceFilesDrop, autoLinkEnabled = false, ...props },
forwardedRef,
@@ -350,7 +359,15 @@ export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Pro
spellCheck={props.spellCheck}
tabIndex={props.tabIndex}
className={`${className || ""} relative z-10 cursor-text select-text whitespace-pre-wrap break-words`}
style={{ ...mergedStyle, color: style?.color || theme.node.text }}
style={{
...mergedStyle,
color: style?.color || theme.node.text,
height: "100%",
minHeight: 0,
maxHeight: "100%",
overflowY: "auto",
overflowX: "hidden",
}}
onInput={syncEditableValue}
onCompositionStart={(event) => {
composingRef.current = true;
@@ -425,7 +442,7 @@ if (event.key === "Enter" && (event.nativeEvent.isComposing || composingRef.curr
}
if (event.key === "Enter") {
event.preventDefault();
const shouldSubmit = sendOnEnter ? !event.ctrlKey && !event.metaKey && !event.shiftKey : (event.ctrlKey || event.metaKey) && !event.shiftKey;
const shouldSubmit = shouldSubmitOnEnter(event, sendOnEnter);
if (onSubmit && shouldSubmit) {
onSubmit();
return;
@@ -489,7 +506,7 @@ if (event.key === "Enter" && (event.nativeEvent.isComposing || composingRef.curr
}}
value={value}
className={`${className || ""} relative z-10`}
style={mergedStyle}
style={{ ...mergedStyle, height: "100%", minHeight: 0, maxHeight: "100%", overflowY: "auto", overflowX: "hidden" }}
onChange={(event) => {
const next = event.target.value;
onChange(next);
@@ -539,7 +556,7 @@ if (event.key === "Enter" && (event.nativeEvent.isComposing || composingRef.curr
return;
}
}
const shouldSubmit = event.key === "Enter" && (sendOnEnter ? !event.ctrlKey && !event.metaKey && !event.shiftKey : (event.ctrlKey || event.metaKey) && !event.shiftKey);
const shouldSubmit = shouldSubmitOnEnter(event, sendOnEnter);
if (shouldSubmit && onSubmit) {
event.preventDefault();
onSubmit();

View File

@@ -39,7 +39,7 @@ export function AppProviders({ children }: { children: ReactNode }) {
const isolateDevRepro = import.meta.env.DEV && typeof window !== "undefined" && window.location.pathname === "/dev/director-repro";
return (
<ConfigProvider locale={zhCN} theme={getAntThemeConfig(dark, appearance.activeSkin)}>
<ConfigProvider locale={zhCN} theme={getAntThemeConfig(dark, appearance.activeSkin)} wave={{ disabled: true }}>
<App message={{ duration: 3, maxCount: 3 }} notification={{ duration: 4.5, maxCount: 3, placement: "topRight" }}>
<QueryClientProvider client={appQueryClient}>
{isolateDevRepro ? (

View File

@@ -8,12 +8,13 @@ import { cn } from "@/lib/utils";
type LobeIconComponent = ComponentType<SVGProps<SVGSVGElement> & { size?: number | string }>;
// 只允许按需加载 Mono 组件。这里不能使用 eager glob:模型 Logo 目录有数百个 provider 模块,
// 优先使用品牌原色,缺少彩色版本时使用 Mono。不能使用 eager glob:目录有数百个模块,
// eager 会让每次进入工作区都发起数百个开发模块请求,即使用户从未打开 Logo 选择器。
const iconModules = "Bun" in globalThis ? {} : import.meta.glob("../../node_modules/@lobehub/icons/es/*/components/Mono.js", { import: "default" });
const colorIconModules = "Bun" in globalThis ? {} : import.meta.glob("../../node_modules/@lobehub/icons/es/*/components/Color.js", { import: "default" });
const iconLoaders = Object.fromEntries(
Object.entries(iconModules)
.map(([path, loader]) => [path.match(/\/([^/]+)\/components\/Mono\.js$/)?.[1], loader])
[...Object.entries(iconModules), ...Object.entries(colorIconModules)]
.map(([path, loader]) => [path.match(/\/([^/]+)\/components\/(?:Mono|Color)\.js$/)?.[1], loader])
.filter((entry): entry is [string, () => Promise<LobeIconComponent>] => Boolean(entry[0] && entry[1])),
) as Record<string, () => Promise<LobeIconComponent>>;
const iconRegistry = new Map<string, LobeIconComponent>();

View File

@@ -4,14 +4,15 @@ import { Popover } from "antd";
import { canvasThemes, type CanvasTheme } from "@/lib/canvas-theme";
import { modelCapabilityConfigFor, videoDurationOptions } from "@/lib/model-capabilities";
import { formatPriceRange, modelQuoteRequest, normalizeTierResolution, priceTierSummaryLabel, priceTiersForCurrentSelection } from "@/lib/model-pricing";
import { compatibleModelInGroup, configuredModelDisplayName, groupModelsByDisplayName, modelCompatibilityError, resolveCompatibleModel, type ModelRequirements } from "@/lib/model-selection";
import { formatPriceRange, modelQuoteDescription, modelQuoteRequest, normalizeTierResolution, priceTierSummaryLabel, priceTiersForCurrentSelection } from "@/lib/model-pricing";
import { compatibleModelInGroup, configuredModelDisplayName, modelCompatibilityError, resolveCompatibleModel, type ModelRequirements } from "@/lib/model-selection";
import { groupModelsForPicker, isDirectSystemModel, modelChannelLabel } from "@/lib/model-picker-groups";
import { cn } from "@/lib/utils";
import { modelDisplayName, modelIcon, modelOptionName, PUBLIC_MODEL_CATALOG_ID, resolveModelChannel, selectableModelsByCapability, type AiConfig, type ModelCapability } from "@/stores/use-config-store";
import { modelDisplayName, modelIcon, modelOptionName, resolveModelChannel, selectableModelsByCapability, type AiConfig, type ModelCapability } from "@/stores/use-config-store";
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
import { useUserStore } from "@/stores/use-user-store";
import { ModelLogo } from "@/components/model-logo";
import { quoteLogicalModel, type LogicalModelQuote } from "@/services/api/logical-models";
import { quoteModel, type LogicalModelQuote } from "@/services/api/logical-models";
type ModelPickerProps = {
config: AiConfig;
@@ -58,26 +59,11 @@ export function ModelPicker({
const menuRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const options = useMemo(() => Array.from(new Set(selectableModelsByCapability(config, capability).filter(Boolean))), [capability, config]);
const optionGroups = useMemo(() => {
const channelGroups = config.channels
.map((channel) => ({
key: channel.id,
label: channel.name || "未命名渠道",
scope: channel.id === PUBLIC_MODEL_CATALOG_ID ? "" : channel.scope === "system" ? "平台服务" : "我的模型",
models: groupModelsByDisplayName(
config,
options.filter((model) => resolveModelChannel(config, model).id === channel.id),
),
}))
.filter((group) => group.models.length);
// options 已由当前有效渠道重建;任何无法解析渠道的旧值都直接丢弃,
// 不再显示“其他模型 / 未指定渠道”这种不可用入口。
return channelGroups;
}, [config, options]);
const optionGroups = useMemo(() => groupModelsForPicker(config, options), [config, options]);
const storedCurrent = value?.trim() || "";
// 参数档位会在选中模型后由调用方归一到其能力配置,不能因为旧模型留下的参数而禁止切换。
const selectionRequirements = requirements ? { ...requirements, videoSeconds: undefined, imageSize: undefined, options: undefined } : undefined;
const resolvedCurrent = resolveCompatibleModel(config, storedCurrent, selectionRequirements) || storedCurrent;
const resolvedCurrent = isDirectSystemModel(config, storedCurrent) ? storedCurrent : resolveCompatibleModel(config, storedCurrent, selectionRequirements) || storedCurrent;
// 旧画布可能保存过已下架或前端历史内置模型;它们不能重新进入当前可选目录。
const current = options.includes(resolvedCurrent) ? resolvedCurrent : "";
const currentPrice = modelMenuPrice(config, current, capability, false, requirements);
@@ -102,7 +88,7 @@ export function ModelPicker({
}
const controller = new AbortController();
setRouteQuote(undefined);
quoteLogicalModel(quoteRequest.logicalModelID, quoteRequest.intent, controller.signal)
quoteModel(quoteRequest, controller.signal)
.then((payload) => setRouteQuote(payload.quote))
.catch(() => {
if (!controller.signal.aborted) setRouteQuote(undefined);
@@ -142,7 +128,7 @@ export function ModelPicker({
};
const focusMenuOption = (last = false) => {
window.requestAnimationFrame(() => {
const buttons = menuRef.current?.querySelectorAll<HTMLButtonElement>('[role="option"]');
const buttons = menuRef.current?.querySelectorAll<HTMLButtonElement>('[data-model-picker-item]:not(:disabled)');
const target = last ? buttons?.item((buttons?.length || 1) - 1) : buttons?.item(0);
target?.focus();
});
@@ -160,8 +146,14 @@ export function ModelPicker({
triggerRef.current?.focus();
return;
}
if (event.key === "ArrowLeft" && activeGroupKey !== null) {
event.preventDefault();
setActiveGroupKey(null);
focusMenuOption();
return;
}
if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return;
const buttons = Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>('[role="option"]'));
const buttons = Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>('[data-model-picker-item]:not(:disabled)'));
if (!buttons.length) return;
event.preventDefault();
const activeIndex = buttons.indexOf(document.activeElement as HTMLButtonElement);
@@ -191,32 +183,32 @@ export function ModelPicker({
>
{optionGroups.length ? (
activeGroupKey === null ? (
<div className="canvas-model-picker-brands" aria-label="选择模型品牌">
<div className="canvas-model-picker-brands" aria-label="选择产品模型">
{optionGroups.map((group) => {
const groupCurrent = group.models.find((item) => item.models.includes(current));
const firstModel = groupCurrent?.models[0] || group.models[0]?.models[0] || "";
return <button key={group.key} type="button" className="canvas-model-picker-brand" onClick={() => { setActiveGroupKey(group.key); setPreviewedModel(firstModel); }}>
<span className="canvas-model-picker-brand-icon"><ModelIcon config={config} model={firstModel} /></span>
<span className="canvas-model-picker-brand-copy"><strong>{group.label}</strong><small>{group.models.length} 个模型{group.scope ? ` · ${group.scope}` : ""}</small></span>
return <button key={group.key} type="button" data-model-picker-item className="canvas-model-picker-brand" onClick={() => { setActiveGroupKey(group.key); setPreviewedModel(firstModel); focusMenuOption(); }}>
<span className="canvas-model-picker-brand-icon"><ModelLogo icon={group.icon} size={22} /></span>
<span className="canvas-model-picker-brand-copy"><strong>{group.label}</strong><small>{group.models.length} 个{group.kind === "product" ? "渠道" : "模型"}{group.scope ? ` · ${group.scope}` : ""}</small></span>
<ChevronDown className="canvas-model-picker-brand-arrow" aria-hidden="true" />
</button>;
})}
</div>
) : <div className="canvas-model-picker-two-pane">
<div className="canvas-model-picker-brand-rail" aria-label="模型品牌">
<div className="canvas-model-picker-brand-rail" aria-label="产品模型">
{optionGroups.map((group) => {
const groupCurrent = group.models.find((item) => item.models.includes(current));
const firstModel = groupCurrent?.models[0] || group.models[0]?.models[0] || "";
return <button key={group.key} type="button" className={cn("canvas-model-picker-brand", activeGroupKey === group.key && "is-active")} aria-pressed={activeGroupKey === group.key} onClick={() => { setActiveGroupKey(group.key); setPreviewedModel(firstModel); }}>
<span className="canvas-model-picker-brand-icon"><ModelIcon config={config} model={firstModel} /></span>
<span className="canvas-model-picker-brand-copy"><strong>{group.label}</strong><small>{group.models.length} 个模型{group.scope ? ` · ${group.scope}` : ""}</small></span>
<span className="canvas-model-picker-brand-icon"><ModelLogo icon={group.icon} size={22} /></span>
<span className="canvas-model-picker-brand-copy"><strong>{group.label}</strong><small>{group.models.length} 个{group.kind === "product" ? "渠道" : "模型"}{group.scope ? ` · ${group.scope}` : ""}</small></span>
<ChevronDown className="canvas-model-picker-brand-arrow" aria-hidden="true" />
</button>;
})}
</div>
{optionGroups.filter((group) => group.key === activeGroupKey).map((group) => <section key={group.key} className="canvas-model-picker-group canvas-model-picker-model-pane min-w-0 overflow-hidden">
<div className="canvas-model-picker-secondary-head">
<button type="button" className="canvas-model-picker-back" onClick={() => setActiveGroupKey(null)} aria-label="返回品牌列表"><ChevronLeft /></button>
<button type="button" className="canvas-model-picker-back" onClick={() => { setActiveGroupKey(null); focusMenuOption(); }} aria-label="返回模型列表"><ChevronLeft /></button>
<span><strong>{group.label}</strong>{group.scope ? <small>{group.scope}</small> : null}</span>
</div>
<div className="grid min-w-0 gap-1">
@@ -229,6 +221,7 @@ export function ModelPicker({
<button
key={modelGroup.key}
type="button"
data-model-picker-item
role="option"
aria-selected={selected}
aria-disabled={Boolean(disabledReason)}
@@ -252,6 +245,8 @@ export function ModelPicker({
theme={theme}
creationVariant
showConfiguredModelName={showConfiguredModelName}
label={group.kind === "product" ? modelGroup.label : undefined}
requirements={requirements}
showPrice={showOptionPrices && creditsEnabled}
disabledReason={disabledReason}
showDescription={selected || previewedModel === displayModel}
@@ -323,6 +318,8 @@ function ModelLabel({
theme,
creationVariant,
showConfiguredModelName,
label,
requirements,
showPrice,
disabledReason,
showDescription,
@@ -333,6 +330,8 @@ function ModelLabel({
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
creationVariant: boolean;
showConfiguredModelName: boolean;
label?: string;
requirements?: ModelRequirements;
showPrice: boolean;
disabledReason?: string;
showDescription: boolean;
@@ -345,21 +344,21 @@ function ModelLabel({
const capabilitySummary =
disabledReason ||
logicalCost?.description?.trim() ||
(logicalSpec ? logicalCapabilitySummary(logicalSpec) : videoProfile ? `${formatDurationSummary(videoProfile)} · ${videoProfile.resolutions.map((item) => item.toUpperCase()).join("/")}` : meta.description);
(isDirectSystemModel(config, model) ? "" : logicalSpec ? logicalCapabilitySummary(logicalSpec) : videoProfile ? `${formatDurationSummary(videoProfile)} · ${videoProfile.resolutions.map((item) => item.toUpperCase()).join("/")}` : meta.description);
return (
<span className="flex w-full min-w-0 items-center gap-1.5 overflow-hidden py-0">
<span className="grid size-6 shrink-0 place-items-center rounded-md" style={{ background: theme.toolbar.itemHover }}>
<ModelIcon config={config} model={model} />
</span>
<span className="min-w-44 flex-1 overflow-hidden">
<span className="block min-w-0 truncate text-[var(--fs-label)] font-medium leading-none">{pickerModelDisplayName(config, model, showConfiguredModelName)}</span>
<span className="block min-w-0 truncate text-[var(--fs-label)] font-medium leading-none">{label || pickerModelDisplayName(config, model, showConfiguredModelName)}</span>
<span className={cn("canvas-model-picker-description mt-1 block truncate text-[var(--fs-tiny)]", showDescription && "is-visible")} style={{ color: theme.node.muted }} title={capabilitySummary}>
{capabilitySummary}
</span>
</span>
{showPrice ? (
<span className="ml-auto shrink-0 pl-2">
<ModelPrice price={modelMenuPrice(config, model, capability, true)} />
<ModelPrice price={modelMenuPrice(config, model, capability, !requirements, requirements)} />
</span>
) : null}
{!creationVariant && meta.time ? (
@@ -431,7 +430,7 @@ function formatDurationSummary(profile: NonNullable<ReturnType<typeof modelCapab
return `${profile.duration.min || values[0]}-${profile.duration.max || values[values.length - 1]}s`;
}
type ModelMenuPrice = { kind: "tiers"; label: string; compactLabel: string; title: string } | { kind: "estimate" } | { kind: "fixed"; value: number; unit: "次" | "秒" | "百万 Token" };
type ModelMenuPrice = { kind: "tiers"; label: string; compactLabel: string; title: string } | { kind: "estimate"; label?: string; title?: string } | { kind: "fixed"; value: number; unit: "次" | "秒" };
function modelMenuPrice(config: AiConfig, model: string, capability?: ModelCapability, summary = false, requirements?: ModelRequirements): ModelMenuPrice | null | undefined {
if (!model) return undefined;
@@ -442,32 +441,40 @@ function modelMenuPrice(config: AiConfig, model: string, capability?: ModelCapab
const tiers = cost.logicalPriceTiers || [];
if (!tiers.length) return null;
const matched = summary ? tiers : priceTiersForCurrentSelection(tiers, capability, config, requirements);
return channelTierPriceSummary(matched.length ? matched : tiers, tiers);
if (!summary && !matched.length) return { kind: "tiers", label: "当前规格无报价", compactLabel: "当前规格无报价", title: "请调整参数,或选择支持当前规格的渠道" };
return channelTierPriceSummary(matched.length ? matched : tiers, tiers, capability);
}
if (cost.billingMode === "token") {
const rate = cost.outputTokenPriceMicrocredits;
return capability === "video" && typeof rate === "number" && Number.isFinite(rate) && rate >= 0
? { kind: "estimate", label: formatPriceRange([rate / 1_000_000], "积分/百万视频 Token"), title: "按视频 Token 单价预估,优先按有效上游用量结算;未返回用量时按视频公式结算" }
: { kind: "estimate" };
}
if (cost.billingMode === "token") return { kind: "estimate" };
return { kind: "fixed", 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);
const name = showConfiguredModelName ? configuredModelDisplayName(config, model) : modelDisplayName(config, model);
return isDirectSystemModel(config, model) ? `${name} · ${modelChannelLabel(config, model)}` : name;
}
function pickerModelOptionLabel(config: AiConfig, model: string, showConfiguredModelName: boolean) {
const displayName = showConfiguredModelName ? configuredModelDisplayName(config, model) : modelDisplayName(config, model);
const channel = resolveModelChannel(config, model);
return channel.scope === "system" ? displayName : `${displayName}(${channel.name})`;
return channel.scope === "system" ? pickerModelDisplayName(config, model, showConfiguredModelName) : `${displayName}(${channel.name})`;
}
function channelTierPriceSummary(
visibleTiers: NonNullable<NonNullable<AiConfig["channels"][number]["modelCosts"]>[number]["logicalPriceTiers"]>,
allTiers: NonNullable<NonNullable<AiConfig["channels"][number]["modelCosts"]>[number]["logicalPriceTiers"]>,
capability?: ModelCapability,
): Extract<ModelMenuPrice, { kind: "tiers" }> {
const label = priceTierSummaryLabel(visibleTiers);
const label = priceTierSummaryLabel(visibleTiers, capability);
return {
kind: "tiers",
label,
compactLabel: label,
title: `系统规格价格:${allTiers.map((tier) => `${tierSpecificationLabel(tier)} ${tierPriceLabel(tier)}`).join(";")}`,
title: `系统规格价格:${allTiers.map((tier) => `${tierSpecificationLabel(tier)} ${priceTierSummaryLabel([tier], capability)}`).join(";")}${allTiers.some((tier) => tier.billingMode === "token") ? (capability === "video" ? ";优先按有效上游用量结算,未返回用量时按视频公式结算" : ";Token 费用为预估,最终按成功任务的实际用量结算") : ""}`,
};
}
@@ -491,21 +498,17 @@ function tierSpecificationLabel(tier: NonNullable<NonNullable<AiConfig["channels
tier.resolution !== "*" ? tierResolutionLabel(tier.resolution) : "",
tier.videoSeconds ? tierDurationLabel(tier.videoSeconds) : "",
selector.imageCount && selector.imageCount !== "*" ? `${selector.imageCount} 张参考图` : "",
selector.videoGenerateAudio === "true" ? "有声" : selector.videoGenerateAudio === "false" ? "无声" : "",
].filter(Boolean);
return details.length ? details.join(" / ") : "默认规格";
}
function tierPriceLabel(tier: NonNullable<NonNullable<AiConfig["channels"][number]["modelCosts"]>[number]["logicalPriceTiers"]>[number]) {
if (tier.billingMode === "token") return "按量预估";
return `${formatPriceRange([tier.unitPriceMicrocredits / 1_000_000], tier.billingMode === "per_second" ? "积分/秒" : "积分")}`;
}
function ModelPrice({ price, quote, compact = false }: { price: ModelMenuPrice | null | undefined; quote?: LogicalModelQuote; compact?: boolean }) {
if (quote) {
const amount = (quote.amountMicrocredits / 1_000_000).toLocaleString("zh-CN", { maximumFractionDigits: 3 });
const label = quote.estimated ? `预计 ${amount}` : `${amount}`;
const amount = (quote.amountMicrocredits / 1_000_000).toLocaleString("zh-CN", { maximumFractionDigits: 6 });
const label = quote.estimated ? `预估:${amount}` : `${amount}`;
return (
<span className="inline-flex shrink-0 items-center gap-0.5 text-[var(--fs-tiny)] font-bold tabular-nums text-amber-600 dark:text-amber-300" title={`${quote.estimated ? "预计" : "本次"}消耗 ${amount} 积分`}>
<span className="inline-flex shrink-0 items-center gap-0.5 text-[var(--fs-tiny)] font-bold tabular-nums text-amber-600 dark:text-amber-300" title={modelQuoteDescription(quote)}>
<Coins className="size-3" />
{compact ? label : `${label} 积分`}
</span>
@@ -522,7 +525,7 @@ function ModelPrice({ price, quote, compact = false }: { price: ModelMenuPrice |
);
}
if (price.kind === "estimate") {
return <span className="shrink-0 text-[var(--fs-tiny)] font-medium text-amber-600 dark:text-amber-300">按量预估</span>;
return <span className="shrink-0 text-[var(--fs-tiny)] font-medium text-amber-600 dark:text-amber-300" title={price.title}>{price.label || "按量预估"}</span>;
}
return (
<span className="inline-flex shrink-0 items-center gap-0.5 text-[var(--fs-tiny)] font-bold tabular-nums text-amber-600 dark:text-amber-300" title={`每${price.unit}消耗 ${price.value.toLocaleString("zh-CN", { maximumFractionDigits: 6 })} 积分`}>
@@ -554,5 +557,5 @@ function modelMenuMeta(model: string, capability?: ModelCapability): { descripti
}
export function ModelIcon({ config, model, icon }: { config?: AiConfig; model?: string; icon?: string }) {
return <ModelLogo icon={icon || (config && model ? modelIcon(config, model) : "")} size={14} className="opacity-80" />;
return <ModelLogo icon={icon || (config && model ? modelIcon(config, model) : "")} size={14} />;
}

File diff suppressed because one or more lines are too long