diff --git a/.codex/skills/ipollowork-plugin-ui/SKILL.md b/.codex/skills/ipollowork-plugin-ui/SKILL.md index 2bb6959ee..1aed07730 100644 --- a/.codex/skills/ipollowork-plugin-ui/SKILL.md +++ b/.codex/skills/ipollowork-plugin-ui/SKILL.md @@ -20,7 +20,7 @@ description: 为 iPolloWork 新插件或插件工作区复用 UI 组件、颜色 - 宿主 React 使用原有 `@/components/ui/*` 入口;共享包控件入口为 `@ipollowork/ui/controls`。 - iframe 插件只从 `@ipollowork/ui/runtime-contract` 导入 `requireRuntime(1)`;实际 React/控件从返回的运行时对象取得。不要从 `plugin-runtime` 或宿主私有路径导入实现,也不要给生产插件再打包 React。 - 开发构建显式选择 `buildPluginRuntime('bundled')`,将运行时放在业务脚本之前;生产包不包含运行时,保留契约中的固定 meta 标记,由宿主注入。不能仅凭 `NODE_ENV` 推断加载方式。 -- 当前运行时版本为 `1.3.0`。使用本轮颜色与字体规范时调用 `requireRuntime(1, required, 3)`,第三参数检查最低 minor;required 列出实际使用的全部函数导出(包括子组件)。旧版本或缺组件时停止初始化并提示更新客户端。不支持完整 semver 范围、最低 patch 或 manifest UI 版本协商,也不自动下载另一份组件库。 +- 当前运行时版本为 `1.4.0`。使用本轮颜色与字体规范时调用 `requireRuntime(1, required, 3)`,第三参数检查最低 minor;使用公共 Icon 时调用 `requireRuntime(1, ['Button', 'Icon'], 4)`,尺寸 S/M/L 为14/16/20px,Button 前后图标直接作为 children 组合,纯图标按钮必须有 aria-label。required 列出实际使用的全部函数导出(包括子组件)。旧版本或缺组件时停止初始化并提示更新客户端。不支持完整 semver 范围、最低 patch 或 manifest UI 版本协商,也不自动下载另一份组件库。 1.2 核心扩展及 Alert/Toast 浅背景已本地整合到 Carrie,未提交、推送或发布;隔离分支仍保留。核心样板以浏览器沙箱验证,真实客户端安装验证另记在接入契约,不把两者等同。签名包上传、打包客户端和跨客户端版本升级未验收。推荐目录的 62 个条目不等于运行时已提供 62 个独立组件。 diff --git a/.codex/skills/ipollowork-plugin-ui/references/component-catalog.md b/.codex/skills/ipollowork-plugin-ui/references/component-catalog.md index 753d82679..4a51f84ca 100644 --- a/.codex/skills/ipollowork-plugin-ui/references/component-catalog.md +++ b/.codex/skills/ipollowork-plugin-ui/references/component-catalog.md @@ -9,7 +9,7 @@ | 核心范围 | 共享公共组件 | | --- | --- | | 输入与选择 | Label、Field、Input、InputGroup、Textarea、Select、Autocomplete、Checkbox、RadioGroup、Switch、Toggle、ToggleGroup | -| 动作与菜单 | Button、DescriptiveButton、DropdownMenu、ContextMenu、Command(含 CommandInput / CommandDialog) | +| 动作与菜单 | Icon(运行时1.4,32个Lucide公共名字)、Button、DescriptiveButton、DropdownMenu、ContextMenu、Command(含 CommandInput / CommandDialog) | | 浮层 | Dialog、AlertDialog、Sheet、Popover、HoverCard、Tooltip | | 反馈与状态 | Alert(Info/Success/Warning/Error)、Toaster/toast、Badge、Progress、Skeleton、Empty | diff --git a/.codex/skills/ipollowork-plugin-ui/references/component-mapping.json b/.codex/skills/ipollowork-plugin-ui/references/component-mapping.json index f1510edb1..ebeb9e2b4 100644 --- a/.codex/skills/ipollowork-plugin-ui/references/component-mapping.json +++ b/.codex/skills/ipollowork-plugin-ui/references/component-mapping.json @@ -1,6 +1,6 @@ { "figmaFileKey": "kpx32oBzY6ecHJZX11mAvr", - "runtimeVersion": "1.3.0", + "runtimeVersion": "1.4.0", "foundations": { "figmaCollectionId": "VariableCollectionId:237:1443", "modes": { "Light": "237:0", "Dark": "237:1" }, @@ -33,6 +33,15 @@ }, "tokenSource": "packages/ui/src/common/tokens.css", "components": [ + { + "export": "Icon", + "relatedExports": ["ICON_NAMES"], + "package": "@ipollowork/ui/icon", + "source": "packages/ui/src/react/icon.tsx", + "figmaMainId": null, + "htmlMarker": null, + "scope": "32 curated Lucide glyphs; React/runtime composition; no dedicated Figma component identity" + }, { "export": "Button", "package": "@ipollowork/ui/controls", diff --git a/.codex/skills/ipollowork-plugin-ui/references/plugin-contract.md b/.codex/skills/ipollowork-plugin-ui/references/plugin-contract.md index b29acad07..552bde5dc 100644 --- a/.codex/skills/ipollowork-plugin-ui/references/plugin-contract.md +++ b/.codex/skills/ipollowork-plugin-ui/references/plugin-contract.md @@ -127,6 +127,8 @@ HTML 的 `` 保留这个原样标记: 运行时样式已包含共享 palette/tokens/theme 与控件 utilities;插件不要再次手写控件 CSS。样板把业务 CSS 放在 `@layer plugin`,避免通用 button/input 样式覆盖共享控件。更新客户端运行时后,重新打开或重载工作区才取得新实现;已打开的文档没有热替换协议。Figma 编辑不会直接修改线上插件。 +无构建步骤的原始 HTML 包可在 module script 中使用固定公共导入 `import { requireRuntime } from "@ipollowork/ui/runtime-contract";`。宿主 `withSharedUiRuntime` 仅对带标记的 HTML 内联此无 React 的检查函数;独立开发预览也需显式注入 bundled 运行时。不支持任意裸模块导入,也不放宽 CSP。 + ## 两种环境共用的业务接法 原生 HTML 在容器内标记真实 button/input/textarea,然后调用 `enhance(container)`:它查找容器的后代,不包含容器自身。 @@ -191,7 +193,7 @@ Select 保存中禁用并关联字段错误;风格提示失败恢复持久化 ## 版本兼容规则 -当前 `UI_RUNTIME_VERSION = '1.3.0'`;独立于客户端与插件版本。1.3增加公共颜色/字体变量、保证七档字号类生成并增加最低minor检查;保留原v1组件入口与Props。短片包0.1.3采用新规范,插件ID和数据路径不变。生产业务脚本不包含组件实现;旧体积统计属于1.2快照,不当作本轮产物实测。 +当前 `UI_RUNTIME_VERSION = '1.4.0'`(1.4新增公共Icon;1.3规范保持兼容);独立于客户端与插件版本。1.3增加公共颜色/字体变量、保证七档字号类生成并增加最低minor检查;保留原v1组件入口与Props。短片包0.1.3采用新规范,插件ID和数据路径不变。生产业务脚本不包含组件实现;旧体积统计属于1.2快照,不当作本轮产物实测。 | 运行时情况 | 当前代码行为 | 插件要求 | | --- | --- | --- | @@ -251,3 +253,23 @@ Select 保存中禁用并关联字段错误;风格提示失败恢复持久化 使用插件已支持的宿主能力进行打开链接、发送上下文或展示模式切换。页面按钮不能直接操纵宿主 DOM,也不能仅凭 Figma 展示推断宿主 API 可用。异步操作的最终状态来自真实结果。 首次接入优先验证一条完整小流程:填写 → 选择 → 提交中 → 成功或失败 → 重试;另验证主题更新和容器缩放。控件预览通过、模拟桥接通过与安装到宿主后通过分别记录。 + +## 公共图标与按钮 · 运行时1.4 + +共享源码使用 `import { Icon, type IconName } from '@ipollowork/ui/icon'`,或从core导入;iframe使用 `requireRuntime(1, ['Button', 'Icon'], 4)`。公共范围为 `ICON_NAMES` 列出的32个具名Lucide图标,不导出Lucide全集,不支持任意字符串。新图标必须加入同一映射并补齐验证。图标为线性描边、currentColor,默认M16px;S14px、L20px为固定像素,不随根字号或Button默认SVG选择器缩放。 + +```js +import { requireRuntime } from '@ipollowork/ui/runtime-contract'; +const ui = requireRuntime(1, ['Button', 'Icon'], 4); +const h = ui.React.createElement; +const leading = h(ui.Button, { size: 'sm', onClick: save }, + h(ui.Icon, { name: 'Plus', size: 's', 'data-icon': 'inline-start' }), '新建'); +const trailing = h(ui.Button, { onClick: download }, '下载', + h(ui.Icon, { name: 'Download', 'data-icon': 'inline-end' })); +const iconOnly = h(ui.Button, { size: 'icon', 'aria-label': '搜索', onClick: search }, + h(ui.Icon, { name: 'Search' })); +``` + +Button继续使用原children API:sm28px、default32px、lg36px;icon-sm/icon/icon-lg分别同尺寸,xs24兼容。图标默认aria-hidden装饰;独立表达状态的图标提供label,生成role=img和可访问名称。纯图标按钮名称放在Button的aria-label,内部Icon保持装饰,不重复朗读。加载由调用方管理disabled/aria-busy与LoaderCircle的animate-spin,操作成功后恢复;Icon不自动触发动作或异步任务。 + +验收范围为现有shared-ui-core样板和双模式流程:所有公共名字SVG非空、S/M/L、前置/后置/纯图标、真实鼠标与键盘、禁用/加载、亮暗窄容器及13/16px根字号。此处描述验证契约,完成状态以本轮fraimz证据为准。 diff --git a/apps/app/src/app/index.css b/apps/app/src/app/index.css index dec13cecb..c478503f0 100644 --- a/apps/app/src/app/index.css +++ b/apps/app/src/app/index.css @@ -203,20 +203,34 @@ body { } [data-chat-readable-text="true"] { - font-size: 14px; - line-height: 1.5; -} - -[data-chat-readable-text="true"] :is(p, li) { - line-height: 1.5; -} - -[data-assistant-result] .markdown-content > * { - margin-block: 0; -} - -[data-assistant-result] .markdown-content > * + * { - margin-top: 12px; + font-family: var(--ipollowork-font-sans); + font-size: var(--ui-body-size); + line-height: var(--ui-body-line); + font-weight: 400; +} + +[data-chat-readable-text="true"] :is(p, li) { line-height: inherit; } +[data-chat-readable-text="true"] :is(strong, b) { font-weight: 600; } +[data-chat-readable-text="true"].markdown-content :where(p, ul, ol, li, blockquote, pre) { margin-block: 0; } +[data-chat-readable-text="true"].markdown-content > * { margin-block: 0; } +[data-chat-readable-text="true"].markdown-content > * + * { margin-top: 8px; } +[data-chat-readable-text="true"].markdown-content li + li, +[data-chat-readable-text="true"].markdown-content li > :is(ul, ol) { margin-top: 4px; } +[data-chat-readable-text="true"].markdown-content h1 { + font-size: var(--ui-page-title-size); + line-height: var(--ui-page-title-line); +} +[data-chat-readable-text="true"].markdown-content :is(h2, h3, h4, h5, h6) { + font-size: var(--ui-section-title-size); + line-height: var(--ui-section-title-line); +} +[data-chat-readable-text="true"].markdown-content :is(h1, h2, h3, h4, h5, h6) { margin-block: 0; font-weight: 600; } +[data-chat-readable-text="true"].markdown-content > :is(h1, h2, h3, h4, h5, h6):not(:first-child) { margin-top: 16px; } +[data-chat-readable-text="true"].markdown-content :is(pre, code, [data-ipollowork-shiki]) { + font-family: var(--ipollowork-font-mono); + font-size: var(--ui-body-size); + line-height: var(--ui-body-line); + font-weight: 400; } .chat-thinking-dots { @@ -242,6 +256,9 @@ body { .chat-thinking-dots span:nth-child(2) { animation-delay: 0.2s; } .chat-thinking-dots span:nth-child(3) { animation-delay: 0.4s; } +/* Interrupted and historical tools can retain an in-flight protocol state. */ +[data-process-active="false"] .animate-spin { animation: none; } + .chat-live-activity { animation: chat-live-activity 1.8s ease-in-out infinite; } @@ -262,26 +279,15 @@ body { .chat-live-activity { animation: none; opacity: 0.75; } } -/* Keep small labels, tool details, code blocks, and file titles in the conversation readable. */ -[data-chat-transcript] :is(.text-xs, .chat-output-title) { - font-size: 14px; +[data-chat-transcript] .text-xs, +.chat-process-heading { + font-size: var(--ui-meta-size); + line-height: var(--ui-meta-line); } -.chat-tool-action[open] > summary .chat-tool-action-chevron { - transform: rotate(90deg); -} +.chat-tool-action > summary { font-size: var(--ui-control-size); line-height: var(--ui-control-line); } -html:lang(zh).ipollowork-electron.ipollowork-platform-mac [data-chat-readable-text="true"] { - font-weight: 500; -} - -html:lang(zh).ipollowork-electron.ipollowork-platform-mac [data-chat-readable-text="true"] :is(strong, b) { - font-weight: 600; -} - -html:lang(zh) .chat-process-heading { - font-family: "PingFang SC", "Microsoft YaHei", "Noto Sans SC", var(--ipollowork-font-sans); -} +.chat-tool-action[open] > summary .chat-tool-action-chevron { transform: rotate(90deg); } /* Match the existing macOS Chinese readability correction in settings/chat. PingFang's 400 fallback can render as ExtraLight in Electron; keep captions @@ -770,29 +776,30 @@ html:lang(zh).ipollowork-electron.ipollowork-platform-mac .media-composer-contro } /* Chat file links and response outputs share one visual specification. */ -.chat-output-card { position:relative; display:inline-flex; align-items:center; gap:10px; width:320px; max-width:100%; height:56px; padding:10px; border:1px solid var(--border); border-radius:12px; background:var(--background); color:var(--foreground); box-shadow:none; vertical-align:middle; text-align:left; } -.chat-output-card:hover { background:var(--muted); } +.chat-output-card { position:relative; display:inline-flex; align-items:center; gap:10px; width:320px; max-width:100%; min-height:64px; padding:9px 10px; border:1px solid var(--border); border-radius:12px; background:var(--background); color:var(--foreground); box-shadow:none; vertical-align:middle; text-align:left; } +.chat-output-card:hover { border-color:color-mix(in srgb,var(--muted-foreground) 30%,var(--border)); } .chat-output-open { display:flex; align-items:center; gap:10px; flex:1; min-width:0; height:100%; padding:0; text-align:left; } .chat-output-icon { display:grid; place-items:center; width:32px; height:32px; flex-shrink:0; border-radius:8px; background:var(--muted); color:var(--muted-foreground); } .chat-output-content { display:flex; flex:1; min-width:0; flex-direction:column; gap:2px; } -.chat-output-title { display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:12px; font-weight:500; line-height:16px; } -.chat-output-description { display:block; font-size:10px; font-weight:400; line-height:14px; color:var(--muted-foreground); } +.chat-output-title { display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; text-overflow:ellipsis; white-space:normal; font-size:var(--ui-control-size); font-weight:500; line-height:var(--ui-control-line); } +.chat-output-description { display:block; font-size:var(--ui-caption-size); font-weight:400; line-height:var(--ui-caption-line); color:var(--muted-foreground); } .chat-output-more { display:grid; place-items:center; width:28px; height:28px; flex-shrink:0; border-radius:8px; color:var(--muted-foreground); } .chat-output-more:hover { background:var(--background); color:var(--foreground); } -[data-testid="artifact-file-shell"] > .chat-output-card { padding-right:76px; } .chat-output-title code { font:inherit; background:transparent; padding:0; } /* File list actions stay inside the same card at every panel width. */ [data-testid="artifact-file-shell"] > .chat-output-card { width:100%; } .chat-output-icon svg { color:var(--muted-foreground); stroke-width:1.8; } [data-testid="artifact-file-actions"] { right:10px; } +[data-testid="artifact-file-actions"] button { box-shadow:none; } /* File cards use the same responsive rows in Markdown and output lists. */ .chat-output-grid { display:grid; min-width:0; grid-template-columns:repeat(auto-fill,minmax(min(100%,16rem),1fr)); gap:12px; } .chat-output-grid .chat-output-card { display:flex; width:100%; min-width:0; } .chat-output-card { padding-top:7px; padding-bottom:7px; } -.chat-output-icon { position:relative; width:40px; height:40px; overflow:hidden; } -.chat-output-icon .artifact-thumbnail { position:absolute; inset:0; display:block; width:40px; height:40px; max-width:none; margin:0; object-fit:cover; border-radius:8px; } +.chat-output-icon { position:relative; overflow:hidden; } +.chat-output-icon[data-artifact-thumbnail] { width:64px; height:44px; border-radius:6px; } +.chat-output-icon .artifact-thumbnail { position:absolute; inset:0; display:block; width:100%; height:100%; max-width:none; margin:0; object-fit:contain; border-radius:6px; } .chat-output-icon .artifact-thumbnail-play { position:absolute; right:2px; bottom:2px; width:14px; height:14px; padding:2px; border-radius:4px; background:rgba(0,0,0,.55); color:white; fill:currentColor; stroke:none; } diff --git a/apps/app/src/components/chat/artifact.tsx b/apps/app/src/components/chat/artifact.tsx index 4bfdfbfca..c50e71d8a 100644 --- a/apps/app/src/components/chat/artifact.tsx +++ b/apps/app/src/components/chat/artifact.tsx @@ -76,7 +76,6 @@ interface ArtifactButtonProps { onOpenVideoStudio?: (displayName?: string) => void } -const MAX_ARTIFACT_TITLE_LENGTH = 32; const EMPTY_WORKSPACE_FILES: iPolloWorkWorkspaceCatalogEntry[] = []; export type ArtifactRequestNaming = { @@ -250,12 +249,6 @@ export function filterWorkspaceFileTree(nodes: readonly WorkspaceFileTreeNode[], return matches; } -function compactArtifactTitle(name: string) { - return name.length > MAX_ARTIFACT_TITLE_LENGTH - ? `${name.slice(0, MAX_ARTIFACT_TITLE_LENGTH - 1)}...` - : name; -} - function ArtifactButton({ artifact, displayName, client, workspaceId, sessionId, artifactContext, onOpenVideoStudio }: ArtifactButtonProps) { const thumbnailRoot = useRef(null); const { loadWorkspaceThumbnail } = useOpenTargets(); @@ -301,7 +294,7 @@ function ArtifactButton({ artifact, displayName, client, workspaceId, sessionId, const presentedArtifact = presentedName === artifact.name ? artifact : { ...artifact, name: presentedName, target: { ...artifact.target, name: presentedName } }; - const title = compactArtifactTitle(presentedName); + const title = presentedName; const typeLabel = getArtifactTypeLabel(studioTarget?.surface === "video" ? "video" : artifact.type); const extension = artifact.name.includes(".") ? artifact.name.slice(artifact.name.lastIndexOf(".") + 1).toUpperCase() : typeLabel; const canDownload = Boolean(client && workspaceId && artifact.target.kind === "file"); @@ -358,12 +351,12 @@ function ArtifactButton({ artifact, displayName, client, workspaceId, sessionId, } return ( -
+
{ if (opensCurrentVideoStudio) { onOpenVideoStudio?.(presentedName); @@ -378,12 +371,12 @@ function ArtifactButton({ artifact, displayName, client, workspaceId, sessionId, {content} {( -
+
{canDownload ? ( `; + return ``; }, table(token) { const header = token.header.map((cell) => this.tablecell({ ...cell, header: true })).join(""); diff --git a/apps/app/src/components/ui/image.tsx b/apps/app/src/components/ui/image.tsx index 25846da2c..c255b8f47 100644 --- a/apps/app/src/components/ui/image.tsx +++ b/apps/app/src/components/ui/image.tsx @@ -1,4 +1,25 @@ -import { Image as SharedImage, type ImageProps } from "@ipollowork/ui/image"; +import { useEffect, useState } from "react"; +import { Image as SharedImage, type ImageProps as SharedImageProps } from "@ipollowork/ui/image"; import { t } from "@/i18n"; -export type { ImageProps, GeneratedImageLike } from "@ipollowork/ui/image"; -export function Image(props: ImageProps) { return ; } +import { cn } from "@/lib/utils"; +export type { GeneratedImageLike } from "@ipollowork/ui/image"; +export type ImageProps = SharedImageProps & { compactPreview?: boolean }; + +export function Image({ compactPreview = false, ...props }: ImageProps) { + const [expanded, setExpanded] = useState(false); + useEffect(() => setExpanded(false), [props.src, props.base64, props.uint8Array]); + const label = t(expanded ? "image.preview.show_less" : "image.preview.show_full"); + if (!compactPreview) return ; + return ( + + ); +} diff --git a/apps/app/src/react-app/domains/session/surface/composer/composer.tsx b/apps/app/src/react-app/domains/session/surface/composer/composer.tsx index c8eb8c682..0034d39c3 100644 --- a/apps/app/src/react-app/domains/session/surface/composer/composer.tsx +++ b/apps/app/src/react-app/domains/session/surface/composer/composer.tsx @@ -1287,7 +1287,7 @@ export function ReactSessionComposer(props: ComposerProps) { ) : null} {/* Keep the editor and actions at the designed height; accessories add space above. */} -
+
{/* Editor */} } onPaste={props.onPaste} @@ -1032,7 +1032,7 @@ export const LexicalPromptEditor = forwardRef } placeholder={ -
+
{props.placeholder}
} diff --git a/apps/app/src/react-app/domains/session/surface/session-surface.tsx b/apps/app/src/react-app/domains/session/surface/session-surface.tsx index 8c60fa4cf..d2745a161 100644 --- a/apps/app/src/react-app/domains/session/surface/session-surface.tsx +++ b/apps/app/src/react-app/domains/session/surface/session-surface.tsx @@ -2430,10 +2430,9 @@ export function SessionSurface(props: SessionSurfaceProps) { data-testid="session-message-scroll" // Extra top padding while the find bar is open so it never covers // the first message (short transcripts cannot scroll it clear). - className={`absolute inset-0 overflow-x-hidden overflow-y-auto overscroll-y-contain px-3 pb-4 sm:px-5 ${findOwned ? "pt-16" : "pt-4"}`} + className={`absolute inset-0 overflow-x-hidden overflow-y-auto overscroll-y-contain px-4 pb-4 md:px-8 ${findOwned ? "pt-16" : "pt-4"}`} > - {/* Chat column: tighter than the composer (800px) so messages - keep a comfortable reading width and don't feel "too big". */} + {/* Share the composer's content width and outer gutters. */}
{showDelayedLoading && pendingSessionLoad ? (
diff --git a/apps/app/src/react-app/domains/session/sync/session-sync.ts b/apps/app/src/react-app/domains/session/sync/session-sync.ts index 29b2d8b17..4e43008ec 100644 --- a/apps/app/src/react-app/domains/session/sync/session-sync.ts +++ b/apps/app/src/react-app/domains/session/sync/session-sync.ts @@ -66,7 +66,7 @@ type SyncEntry = { sessionUpdatedListeners: Set>; sessionStatusListeners: Set>; sessionErrorListeners: Set>; - pendingDeltas: Map; + pendingDeltas: Map; reconcilingSessionIds: Set; // Coalesce rapid-fire delta events from the SSE stream into one cache // commit per animation frame. Without this, a long response produces a @@ -403,6 +403,9 @@ function clearTrackedSession(input: SyncScope, entry: SyncEntry, sessionId: stri entry.deltaFlushBuffer = entry.deltaFlushBuffer.filter( (item) => item.sessionId !== sessionId, ); + for (const [key, pending] of entry.pendingDeltas) { + if (pending.sessionId === sessionId) entry.pendingDeltas.delete(key); + } const queryClient = getReactQueryClient(); queryClient.removeQueries({ queryKey: permissionKey(input.workspaceId, sessionId), exact: true }); if (entry.refs <= 0 && entry.retainedSessionTimers.size === 0) { @@ -1270,17 +1273,13 @@ function applyEvent(entry: SyncEntry, workspaceId: string, event: ConversationEv if (!isTrackedSession(entry, event.sessionId)) return; const [mapped, ...attachments] = event.parts; if (!mapped) return; - const pending = entry.pendingDeltas.get(event.partId); - const seededPart = pending && (mapped.type === "text" || mapped.type === "reasoning") - ? { - ...mapped, - text: pending.text.length > mapped.text.length ? pending.text : mapped.text, - state: "streaming" as const, - } + // Drain this frame before reading early chunks held for the declaration. + if (entry.deltaFlushBuffer.length > 0) flushDeltas(entry, workspaceId); + const pendingKey = `${event.sessionId}\u0000${event.messageId}\u0000${event.partId}`; + const pending = entry.pendingDeltas.get(pendingKey); + const seededPart = pending && (mapped.type === "text" || mapped.type === "reasoning") && !mapped.text + ? { ...mapped, text: pending.text } : mapped; - if (entry.deltaFlushBuffer.length > 0) { - entry.deltaFlushBuffer = entry.deltaFlushBuffer.filter((item) => item.partId !== event.partId); - } queryClient.setQueryData(transcriptKey(workspaceId, event.sessionId), (current = []) => { const existing = current.find((message) => message.id === event.messageId); const role = event.messageRole ?? existing?.role ?? inferStubRole(current); @@ -1296,7 +1295,7 @@ function applyEvent(entry: SyncEntry, workspaceId: string, event: ConversationEv interrupted?.protectedOptimisticUserMessageIds, ); }); - if (pending) entry.pendingDeltas.delete(event.partId); + if (pending) entry.pendingDeltas.delete(pendingKey); return; } @@ -1422,13 +1421,15 @@ function flushDeltas(entry: SyncEntry, workspaceId: string) { const ownerPart = ownerPartsById.get(item.partId); if (!ownerPart) { - const existing = entry.pendingDeltas.get(item.partId) ?? { + const pendingKey = `${item.sessionId}\u0000${item.messageId}\u0000${item.partId}`; + const existing = entry.pendingDeltas.get(pendingKey) ?? { + sessionId: item.sessionId, messageId: item.messageId, reasoning: item.reasoning, text: "", }; existing.text += item.delta; - entry.pendingDeltas.set(item.partId, existing); + entry.pendingDeltas.set(pendingKey, existing); continue; } diff --git a/apps/app/src/react-app/domains/session/video/video-avatar-panel.tsx b/apps/app/src/react-app/domains/session/video/video-avatar-panel.tsx index 7a10f8083..002bd75fa 100644 --- a/apps/app/src/react-app/domains/session/video/video-avatar-panel.tsx +++ b/apps/app/src/react-app/domains/session/video/video-avatar-panel.tsx @@ -5,6 +5,9 @@ import { ArrowLeft, Check, ChevronDown, ChevronRight, ImagePlus, Info, Loader2, import type { iPolloWorkServerClient } from "@/app/lib/ipollowork-server"; import { AlertDialog, AlertDialogAction, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogTitle } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Empty, EmptyDescription } from "@/components/ui/empty"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { Input } from "@/components/ui/input"; @@ -26,8 +29,8 @@ type Props = { }; const defaultPrompt = `${AVATAR_CAMERA_PROMPT}人物面向镜头说出所选配音,嘴唇和下颌随每个音节自然变化,停顿时放松闭口,表情随语气变化,避免反复点头和晃动。`; -const menuClassName = "video-settings-typography max-h-(--available-height) max-w-(--available-width) rounded-lg bg-popover p-1.5 text-xs [&_[role=option]]:min-h-[34px] [&_[role=option]]:px-2 [&_[role=option]]:py-1.5 [&_[role=option]]:text-xs"; -const fieldClassName = "h-[34px] data-[size=default]:h-[34px] w-full rounded-lg border-0 bg-muted/60 px-3 text-xs font-normal shadow-none"; +const menuClassName = "video-settings-typography"; +const fieldClassName = "w-full min-w-0"; function sameConfiguration(left: AvatarProfile, right: AvatarProfile) { return left.name === right.name && left.imagePath === right.imagePath && left.imageName === right.imageName @@ -292,67 +295,73 @@ export function VideoAvatarPanel({ client, workspaceId, workspaceRoot, sessionId const visibleJobs = draft ? jobs.filter(job => job.avatarProfileId === draft.id) : jobs.filter(job => !job.avatarProfileId); const taskJob = jobs.find(job => job.id === taskId); - return
+ return
{draft ? <> -
+
setDraft(current => current ? { ...current, name: event.target.value } : current)} className={fieldClassName} />
{ const file = event.target.files?.[0]; event.target.value = ""; if (file) void act(() => upload(file)); }} /> - - {imageMessage && imageMessage !== "图片已上传" ?

{imageMessage}

: null} + {draft.imageName || "上传人物图片"}{draft.imagePath ? "点击替换图片" : "PNG / JPG / WebP · 最大 20 MB"} + + {imageMessage && imageMessage !== "图片已上传" ? {imageMessage} : null}
- {source?.audioClips?.length ? <>{onOpenVoice ?
: null} :

{checking ? "正在读取配音…" : source?.audioIssue || "当前视频还没有可用的配音。可以先保存形象,设置配音后再生成。"}

{onOpenVoice ? : null}
} + {source?.audioClips?.length ? <>{onOpenVoice ?
: null} :

{checking ? "正在读取配音…" : source?.audioIssue || "当前视频还没有可用的配音。可以先保存形象,设置配音后再生成。"}

{onOpenVoice ? : null}
}

画面设置

-
{(["9:16", "16:9"] as const).map(value => )}
+ { + if (value === "9:16" || value === "16:9") setDraft(current => current ? { ...current, ratio: value } : current); + }} className="grid-cols-2 gap-2"> + {(["9:16", "16:9"] satisfies AvatarProfile["ratio"][]).map(value => )} + {selectedClip && selectedClip.duration > 15 ?

超过 15 秒将自动分段生成并拼接。

: null}
}>{avatarBackgroundForPrompt(draft.prompt) === "transparent" ? "透明背景" : "保留背景"}默认保留背景,生成后可手动智能抠图。明确要求去除背景时才自动抠图。
-