-
+
});
diff --git a/apps/server/src/artifact-files.e2e.test.ts b/apps/server/src/artifact-files.e2e.test.ts
index 288a99af4..85ee1598a 100644
--- a/apps/server/src/artifact-files.e2e.test.ts
+++ b/apps/server/src/artifact-files.e2e.test.ts
@@ -93,16 +93,22 @@ describe("artifact file routes", () => {
const root = await createWorkspaceRoot();
const { base, token } = await startiPolloWorkServer(root);
await sharp({ create: { width: 160, height: 90, channels: 3, background: "blue" } }).png().toFile(join(root, "image.png"));
+ await sharp({ create: { width: 90, height: 160, channels: 3, background: "blue" } }).png().toFile(join(root, "portrait.png"));
await writeFile(join(root, "logo.svg"), '
');
execFileSync(process.env.HYPERFRAMES_FFMPEG_PATH || "ffmpeg", ["-v", "error", "-f", "lavfi", "-i", "color=c=blue:s=160x90:d=0.2", "-pix_fmt", "yuv420p", join(root, "video.mp4")]);
- for (const path of ["image.png", "logo.svg", "video.mp4"]) {
+ for (const path of ["image.png", "portrait.png", "logo.svg", "video.mp4"]) {
const response = await fetch(`${base}/workspace/ws_1/files/raw?thumbnail=1&path=${path}`, { headers: auth(token) });
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe("image/webp");
expect(response.headers.get("access-control-expose-headers")).toContain("X-Artifact-Detail");
- expect(decodeURIComponent(response.headers.get("x-artifact-detail") ?? "")).toBe(path === "video.mp4" ? "00:00" : path === "logo.svg" ? "160 × 40" : "160 × 90");
- const image = await sharp(Buffer.from(await response.arrayBuffer())).metadata();
- expect([image.width, image.height]).toEqual([80, 80]);
+ expect(decodeURIComponent(response.headers.get("x-artifact-detail") ?? "")).toBe(path === "video.mp4" ? "00:00" : path === "logo.svg" ? "160 × 40" : path === "portrait.png" ? "90 × 160" : "160 × 90");
+ const bytes = Buffer.from(await response.arrayBuffer());
+ const image = await sharp(bytes).metadata();
+ expect([image.width, image.height]).toEqual([160, 110]);
+ const pixels = await sharp(bytes).ensureAlpha().raw().toBuffer();
+ // Wide and tall sources retain their full composition, with transparent letterboxing.
+ expect(pixels[3]).toBe(0);
+ expect(pixels[(55 * 160 + 80) * 4 + 3]).toBe(255);
}
expect((await fetch(`${base}/workspace/ws_1/files/raw?thumbnail=1&path=image.png`)).status).toBe(401);
expect((await fetch(`${base}/workspace/ws_1/files/raw?thumbnail=1&path=reports/artifact-eval.md`, { headers: auth(token) })).status).toBe(415);
diff --git a/apps/server/src/routes/files.ts b/apps/server/src/routes/files.ts
index 65f158e34..296fca04e 100644
--- a/apps/server/src/routes/files.ts
+++ b/apps/server/src/routes/files.ts
@@ -1354,7 +1354,7 @@ export function registerFileRoutes(options: RegisterFileRoutesOptions): void {
if (duration !== undefined) detail = `${Math.floor(duration / 60).toString().padStart(2, "0")}:${Math.floor(duration % 60).toString().padStart(2, "0")}`;
}
}
- const bytes = await thumbnail.rotate().resize(80, 80, { fit: /\.svg$/i.test(relativePath) ? "contain" : "cover", background: { r: 0, g: 0, b: 0, alpha: 0 } }).webp().toBuffer();
+ const bytes = await thumbnail.rotate().resize(160, 110, { fit: "contain", background: { r: 0, g: 0, b: 0, alpha: 0 } }).webp().toBuffer();
return { bytes, detail };
})();
thumbnailJobs.set(key, pending);
diff --git a/evals/flows/chat-compact-preview.flow.mjs b/evals/flows/chat-compact-preview.flow.mjs
new file mode 100644
index 000000000..d4e82d48d
--- /dev/null
+++ b/evals/flows/chat-compact-preview.flow.mjs
@@ -0,0 +1,74 @@
+import { loadVoiceoverParagraphs } from '../runner/voiceover.mjs';
+const vo = await loadVoiceoverParagraphs('chat-compact-preview');
+const route = process.env.IPOLLOWORK_EVAL_CHAT_ROUTE;
+const geometry = `(() => {
+ const rect = el => { const r=el.getBoundingClientRect();return {left:r.left,right:r.right,width:r.width,height:r.height}; };
+ const composer=document.querySelector('.composer-card');
+ const columns=[...document.querySelectorAll('[data-testid="assistant-process-column"],[data-testid="assistant-message-column"],[data-testid="assistant-streaming-previews"]')];
+ const bubble=[...document.querySelectorAll('[data-testid="user-message-bubble"]')].at(-1);
+ const card=document.querySelector('[data-testid="artifact-file-card"]');
+ if(!composer||!columns.length||!bubble||!card)return null;
+ return {composer:rect(composer),lefts:columns.map(el=>rect(el).left+parseFloat(getComputedStyle(el).paddingLeft)),bubble:rect(bubble),card:rect(card),overflow:document.documentElement.scrollWidth>innerWidth};
+})()`;
+async function size(ctx,width){
+ await ctx.client.send('Emulation.setDeviceMetricsOverride',{width,height:1000,deviceScaleFactor:1,mobile:false});
+ await ctx.waitFor(`innerWidth === ${width}`,{label:'viewport reflow'});
+ await ctx.eval(`new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(()=>resolve(true))))`,{awaitPromise:true});
+}
+function aligned(ctx,g){
+ ctx.assert(g,'All live conversation targets exist');
+ ctx.assert(g.lefts.every(x=>Math.abs(x-g.composer.left)<=1),'Assistant columns align with composer');
+ ctx.assert(Math.abs(g.card.left-g.composer.left)<=1,'File card left aligns with composer');
+ ctx.assert(Math.abs(g.bubble.right-g.composer.right)<=1,'User bubble right aligns with composer');
+ ctx.assert(!g.overflow,'No horizontal document overflow');
+ ctx.recordEvidence({type:'assertion',status:'passed',assertion:'Live content and composer share edges',actual:g});
+}
+export default {
+ id:'chat-compact-preview',title:'Compact aligned conversation with complete image previews',kind:'user-facing',preserveTheme:true,
+ requiredEnv:['IPOLLOWORK_EVAL_CHAT_ROUTE','IPOLLOWORK_EVAL_TYPOGRAPHY_ROUTE'],
+ steps:[
+ {name:'Shared conversation edges',run:async ctx=>ctx.prove('Messages and file cards align with the composer at desktop width',{
+ voiceover:vo[0],action:async()=>{
+ await ctx.client.send('Page.bringToFront');await ctx.client.send('Emulation.setFocusEmulationEnabled',{enabled:true});await size(ctx,1104);await ctx.client.send('Page.navigate',{url:new URL(route,await ctx.eval('location.origin')).href});
+ await ctx.waitFor(`Boolean(document.querySelector('[data-testid="artifact-file-card"]'))`,{timeoutMs:60000,label:'persisted conversation'});
+ await ctx.waitFor(`!document.querySelector('[data-testid="startup-logo-animation"]')`,{timeoutMs:60000,label:'startup overlay removed'});
+ await ctx.waitFor(`(() => {const b=[...document.querySelectorAll('[data-testid="user-message-bubble"]')].at(-1);const c=document.querySelector('.composer-card');return b?.textContent.includes('测试一下排队的效果')&&Math.abs(b.getBoundingClientRect().right-c.getBoundingClientRect().right)<=1;})()`,{label:'conversation navigation and layout settled'});
+ await ctx.eval(`document.querySelector('[data-testid="artifact-file-card"]').scrollIntoView({block:'center',behavior:'instant'})`);
+ },assert:async()=>aligned(ctx,await ctx.eval(geometry)),screenshot:{name:'aligned-chat',requireText:['测试一下排队的效果','本轮生成的文件'],rejectText:['Loading...']}})},
+ {name:'Compact conversation spacing',run:async ctx=>ctx.prove('Turn spacing is compact while readable body text remains comfortable',{
+ voiceover:vo[1],action:async()=>{await size(ctx,960);await ctx.eval(`document.querySelector('[data-testid="user-message-bubble"]').scrollIntoView({block:'center',behavior:'instant'})`);},
+ assert:async()=>{
+ const m=await ctx.eval(`(() => {const turn=document.querySelector('[data-message-role="user"]'); const group=[...document.querySelectorAll('[data-testid="assistant-message-group"]')].at(-1); const text=document.querySelector('[data-chat-readable-text="true"]');return {turnMargin:parseFloat(getComputedStyle(turn.parentElement).marginTop),groupGap:parseFloat(getComputedStyle(group).gap),fontSize:parseFloat(getComputedStyle(text).fontSize),lineHeight:parseFloat(getComputedStyle(text).lineHeight)};})()`);
+ ctx.assert(m.turnMargin<=16&&m.groupGap<=8,'Turn and group spacing are compact');ctx.assert(m.fontSize===13&&m.lineHeight===20,'Body typography remains readable');ctx.recordEvidence({type:'assertion',status:'passed',assertion:'Compact spacing and readable text',actual:m});
+ },screenshot:{name:'compact-spacing',requireText:['测试一下排队的效果','本次任务未完成']}})},
+ {name:'Complete image thumbnail and keyboard toggle',run:async ctx=>ctx.prove('Full image fits 420×260 and keyboard expansion and collapse work',{
+ voiceover:vo[2],action:async()=>{
+ await ctx.waitFor(`(() => {const i=document.querySelector('[data-chat-transcript] img');return i?.naturalWidth>0;})()`,{label:'decoded persisted image'});
+ await ctx.eval(`document.querySelector('[data-chat-transcript] img').scrollIntoView({block:'center',behavior:'instant'})`);
+ },assert:async()=>{
+ const measure=()=>ctx.eval(`(() => {const i=document.querySelector('[data-chat-transcript] img');const r=i.getBoundingClientRect();return {w:r.width,h:r.height,ratio:i.naturalWidth/i.naturalHeight,expanded:i.closest('button')?.getAttribute('aria-expanded')};})()`);
+ const before=await measure();ctx.assert(before.w<=420&&before.h<=260,'Thumbnail bounded by 420×260');ctx.assert(Math.abs(before.w/before.h-before.ratio)<0.01,'Image retains whole intrinsic ratio');
+ await ctx.eval(`document.querySelector('[data-chat-transcript] img').closest('button').focus()`);
+ await ctx.client.send('Input.dispatchKeyEvent',{type:'keyDown',key:'Enter',code:'Enter',text:'\r',unmodifiedText:'\r',windowsVirtualKeyCode:13});await ctx.client.send('Input.dispatchKeyEvent',{type:'keyUp',key:'Enter',code:'Enter',windowsVirtualKeyCode:13});
+ await ctx.waitFor(`document.querySelector('[data-chat-transcript] img').closest('button').getAttribute('aria-expanded')==='true'`);
+ const after=await measure();ctx.assert(after.w>before.w&&after.h>before.h,'Expansion shows larger image');
+ await ctx.client.send('Input.dispatchKeyEvent',{type:'keyDown',key:'Enter',code:'Enter',text:'\r',unmodifiedText:'\r',windowsVirtualKeyCode:13});await ctx.client.send('Input.dispatchKeyEvent',{type:'keyUp',key:'Enter',code:'Enter',windowsVirtualKeyCode:13});
+ await ctx.waitFor(`document.querySelector('[data-chat-transcript] img').closest('button').getAttribute('aria-expanded')==='false'`);
+ ctx.recordEvidence({type:'assertion',status:'passed',assertion:'Image is complete and keyboard toggles full view',actual:{before,after}});
+ },screenshot:{name:'complete-image-preview',requireText:['查看完整图片','本轮生成的文件']}})},
+ {name:'Refined details and narrow layout',run:async ctx=>ctx.prove('Compact composer and cards stay aligned in a narrow window',{
+ voiceover:vo[3],action:async()=>{await size(ctx,620);await ctx.eval(`document.querySelector('[data-testid="artifact-file-card"]').scrollIntoView({block:'center',behavior:'instant'})`);},assert:async()=>{
+ aligned(ctx,await ctx.eval(geometry));
+ const m=await ctx.eval(`(() => {const i=document.querySelector('[data-chat-transcript] img'); const icon=document.querySelector('.chat-output-icon');const b=document.querySelector('[data-testid="user-message-bubble"]');const c=document.querySelector('.composer-card');return {image:i.getBoundingClientRect().toJSON(),parent:i.closest('button').getBoundingClientRect().toJSON(),icon:icon.getBoundingClientRect().width,padding:parseFloat(getComputedStyle(b).paddingLeft),radius:parseFloat(getComputedStyle(b).borderRadius),composerHeight:c.getBoundingClientRect().height};})()`);
+ ctx.assert(m.image.right<=m.parent.right+1,'Thumbnail fits narrow content width');ctx.assert(m.icon===32&&m.padding===16&&m.radius>=12&&m.radius<=16,'File icon and bubble detail sizes');ctx.assert(m.composerHeight<=100,'Empty composer is about96px');ctx.recordEvidence({type:'assertion',status:'passed',assertion:'Narrow layout and compact details',actual:m});
+ },screenshot:{name:'narrow-compact-chat',requireText:['本轮生成的文件','测试一下排队的效果']}})}
+ ,{name:'Shared typography in real explanatory response',run:ctx=>ctx.prove('Body, headings, code, metadata and composer use shared roles with consistent spacing',{
+ voiceover:vo[4],action:async()=>{await size(ctx,1104);await ctx.client.send('Page.navigate',{url:new URL(process.env.IPOLLOWORK_EVAL_TYPOGRAPHY_ROUTE,await ctx.eval('location.origin')).href});await ctx.waitFor(`Array.from(document.querySelectorAll('.markdown-content h2')).some(n=>n.textContent.includes('本地 API'))`);await ctx.waitFor(`!document.querySelector('[data-testid="startup-logo-animation"]')`);await ctx.eval(`Array.from(document.querySelectorAll('.markdown-content h2')).find(n=>n.textContent.includes('本地 API')).scrollIntoView({block:'center'})`);},
+ assert:async()=>{
+ const s=await ctx.eval(`(() => {const m=[...document.querySelectorAll('.markdown-content')].at(-1);const read=n=>{const s=getComputedStyle(n);return {tag:n.tagName,size:s.fontSize,line:s.lineHeight,weight:s.fontWeight,top:s.marginTop,bottom:s.marginBottom}};return {body:[...m.querySelectorAll('p,li')].map(read),headings:[...m.querySelectorAll('h2')].map(read),code:[...m.querySelectorAll('code')].map(read),strong:[...m.querySelectorAll('strong')].map(read),blocks:[...m.children].map(read),items:[...m.querySelectorAll('li')].map(read),editor:read(document.querySelector('[contenteditable=true][data-lexical-editor=true]')),meta:[...document.querySelectorAll('.chat-process-heading')].map(read)};})()`);
+ ctx.assert(s.body.length>0&&s.headings.length>0&&s.code.length>0,'Real explanation includes paragraphs, lists, heading and code');
+ ctx.assert(s.body.every(n=>n.size==='13px'&&n.line==='20px'&&n.weight==='400'),'Body uses shared 13/20 regular');ctx.assert(s.headings.every(n=>n.size==='14px'&&n.line==='20px'&&n.weight==='600'),'Section headings use shared 14/20 semibold');ctx.assert(s.code.every(n=>n.size==='13px'&&n.line==='20px'),'Code matches body size and line height');ctx.assert(s.strong.every(n=>n.size==='13px'&&n.weight==='600'),'Emphasis changes weight only');
+ ctx.assert(s.blocks.every((n,i)=>n.bottom==='0px'&&n.top===(i===0?'0px':n.tag==='H2'?'16px':'8px')),'Block spacing follows 8/16 rhythm');ctx.assert(s.items.every(n=>n.bottom==='0px'&&['0px','4px'].includes(n.top)),'List rows use 4px gap without paragraph margins');ctx.assert(s.editor.size==='13px'&&s.editor.line==='20px','Composer shares body typography');ctx.assert(s.meta.every(n=>n.size==='12px'&&n.line==='18px'),'Status uses shared Meta');ctx.recordEvidence({type:'assertion',status:'passed',assertion:'Real response uses shared typography',actual:s});
+ },screenshot:{name:'shared-chat-typography',requireText:['本地 API','sidecar']}})}
+ ]
+};
diff --git a/evals/flows/conversation-active-state.flow.mjs b/evals/flows/conversation-active-state.flow.mjs
new file mode 100644
index 000000000..c32ad9398
--- /dev/null
+++ b/evals/flows/conversation-active-state.flow.mjs
@@ -0,0 +1,59 @@
+import { mountFixture } from './conversation-streaming-result.flow.mjs';
+import { loadVoiceoverParagraphs } from '../runner/voiceover.mjs';
+const vo = await loadVoiceoverParagraphs('conversation-active-state');
+const state = `(() => {const h=document.querySelector('#streaming-answer-proof');const s=h.querySelector('[data-testid=assistant-current-state]');return {label:s?.textContent,pulse:s?getComputedStyle(s).animationName:null,pulses:h.querySelectorAll('.chat-live-activity').length,pending:h.querySelectorAll('[data-testid=assistant-result-pending]').length,body:[...h.querySelectorAll('[data-assistant-result]')].map(n=>n.textContent)};})()`;
+export default {
+ id:'conversation-active-state', title:'Streaming text stays in place and only the current state pulses',kind:'user-facing',preserveTheme:true,
+ steps:[
+ {name:'Current reasoning and tool state',run:ctx=>ctx.prove('One current state pulses and no duplicate thinking placeholder appears',{
+ voiceover:vo[0],action:()=>ctx.eval(`(${mountFixture.toString()})()`,{awaitPromise:true}),assert:async()=>{
+ await ctx.waitFor(`Boolean(window.__streamingAnswerProof?.showOpenCodePhase)`);
+ for(const [phase,label] of [['reasoning','正在思考'],['tool','检查项目文件']]){
+ await ctx.eval(`window.__streamingAnswerProof.showOpenCodePhase('${phase}')`);
+ await ctx.waitFor(`document.querySelector('#streaming-answer-proof [data-testid=assistant-current-state]')?.textContent.includes('${label}')`);
+ const s=await ctx.eval(state);ctx.assert(s.pulses===1&&s.pending===0&&s.pulse==='chat-live-activity',JSON.stringify(s));ctx.recordEvidence({type:'assertion',status:'passed',assertion:phase+' current state only',actual:s});
+ }
+ },screenshot:{name:'current-tool',requireText:['检查项目文件']}})},
+ {name:'Unphased text retains DOM through later tools and completion',run:ctx=>ctx.prove('OpenCode text streams in one body and both text nodes survive completion',{
+ voiceover:vo[1],action:async()=>{
+ await ctx.eval(`window.__streamingAnswerProof.showOpenCodePhase('answer')`);
+ await ctx.waitFor(`document.querySelector('#streaming-answer-proof [data-assistant-result]')?.textContent.includes('已找到相关文件。')`);
+ await ctx.eval(`void(window.__stableText=document.querySelector('#streaming-answer-proof [data-message-id=opencode-proof-assistant]'))`);
+ },assert:async()=>{
+ const s=await ctx.eval(state);ctx.assert(s.label.includes('正在回复')&&s.pulses===1&&s.pending===0,JSON.stringify(s));
+ await ctx.eval(`window.__streamingAnswerProof.appendOpenCodeStep()`);
+ await ctx.waitFor(`document.querySelector('#streaming-answer-proof [data-message-id=opencode-proof-final]')?.textContent.includes('目录确认完成。')`);
+ ctx.assert(await ctx.eval(`window.__stableText===document.querySelector('#streaming-answer-proof [data-message-id=opencode-proof-assistant]')`),'First body node survived subsequent tool/text');
+ await ctx.eval(`void(window.__stableFinal=document.querySelector('#streaming-answer-proof [data-message-id=opencode-proof-final]'))`);
+ await ctx.eval(`window.__streamingAnswerProof.finishOpenCode()`);
+ await ctx.waitFor(`!document.querySelector('#streaming-answer-proof .chat-live-activity')`);
+ ctx.assert(await ctx.eval(`window.__stableText===document.querySelector('#streaming-answer-proof [data-message-id=opencode-proof-assistant]')&&window.__stableFinal===document.querySelector('#streaming-answer-proof [data-message-id=opencode-proof-final]')`),'Both body nodes survived completion');
+ const done=await ctx.eval(state);ctx.assert(done.body.length===2&&done.pulses===0&&done.pending===0,JSON.stringify(done));ctx.recordEvidence({type:'assertion',status:'passed',assertion:'Stable streamed body before and after completion',actual:done});
+ },screenshot:{name:'stable-completed-text',requireText:['已找到相关文件。','目录确认完成。']}})},
+ {name:'Post-processing pulses and interrupted history stops',run:ctx=>ctx.prove('Finishing pulses while actual work remains; interrupted history has no animated status or spinners',{
+ voiceover:vo[2],action:async()=>{
+ await ctx.eval(`window.__streamingAnswerProof.showCompletedWhilePostProcessing()`);
+ await ctx.waitFor(`document.querySelector('#streaming-answer-proof [data-testid=assistant-current-state]')?.textContent.includes('正在收尾')`);
+ const processing=await ctx.eval(state);ctx.assert(processing.pulses===1,JSON.stringify(processing));ctx.recordEvidence({type:'assertion',status:'passed',assertion:'Post-processing remains active',actual:processing});
+ await ctx.eval(`window.__streamingAnswerProof.showHistoricalInterruption()`);
+ await ctx.waitFor(`!document.querySelector('#streaming-answer-proof .chat-live-activity')`);
+ },assert:async()=>{
+ const s=await ctx.eval(state);ctx.assert(s.pulses===0&&s.pending===0,JSON.stringify(s));
+ const animations=await ctx.eval(`Array.from(document.querySelectorAll('#streaming-answer-proof .animate-spin')).map(n=>getComputedStyle(n).animationName)`);ctx.assert(animations.every(a=>a==='none'),JSON.stringify(animations));
+ },screenshot:{name:'static-interrupted-history',requireText:['已完成部分内容。']}})}
+ ,{name:'Compact image file card',run:ctx=>ctx.prove('Image file card shows a contained 64×44 thumbnail with two-line title and hover actions',{
+ voiceover:vo[3],action:async()=>{
+ await ctx.eval(`window.__streamingAnswerProof.showUnmatchedImage()`);
+ await ctx.waitFor(`Boolean(document.querySelector('#streaming-answer-proof [data-testid=artifact-file-card]'))`);
+ await ctx.eval(`document.querySelector('#streaming-answer-proof [data-testid=artifact-file-card]').scrollIntoView({block:'center'})`);
+ await ctx.waitFor(`document.querySelector('#streaming-answer-proof .artifact-thumbnail')?.naturalWidth>0`);
+ },assert:async()=>{
+ const s=await ctx.eval(`(() => {const c=document.querySelector('#streaming-answer-proof [data-testid=artifact-file-card]');const i=c.querySelector('[data-artifact-thumbnail]');const title=c.querySelector('.chat-output-title');const a=c.parentElement.querySelector('[data-testid=artifact-file-actions]');return {card:c.getBoundingClientRect().toJSON(),thumb:i.getBoundingClientRect().toJSON(),fit:getComputedStyle(i.querySelector('img')).objectFit,lines:getComputedStyle(title).webkitLineClamp,actionsOpacity:getComputedStyle(a).opacity,border:getComputedStyle(c).borderTopWidth,shadow:getComputedStyle(c).boxShadow,background:getComputedStyle(c).backgroundColor,surface:getComputedStyle(document.querySelector('#streaming-answer-proof')).backgroundColor,actionShadow:getComputedStyle(a).boxShadow,buttonBackground:getComputedStyle(a.querySelector('button')).backgroundColor,buttonShadow:getComputedStyle(a.querySelector('button')).boxShadow};})()`);
+ ctx.assert(s.card.height===64&&s.thumb.width===64&&s.thumb.height===44&&s.fit==='contain'&&s.lines==='2'&&s.border==='1px'&&s.shadow==='none'&&s.actionShadow==='none'&&s.background===s.surface&&s.buttonBackground==='rgba(0, 0, 0, 0)'&&s.buttonShadow==='none',JSON.stringify(s));
+ await ctx.client.send('Input.dispatchMouseEvent',{type:'mouseMoved',x:s.card.left+20,y:s.card.top+20});
+ await ctx.waitFor(`getComputedStyle(document.querySelector('#streaming-answer-proof [data-testid=artifact-file-actions]')).opacity==='1'`);
+ ctx.recordEvidence({type:'assertion',status:'passed',assertion:'Compact card thumbnail and hover',actual:s});
+ },screenshot:{name:'compact-thumbnail-card',requireText:['cover.png']}})}
+ ,{name:'Release fixture' ,run:ctx=>ctx.eval(`window.__streamingAnswerProof?.cleanup?.()`)}
+ ]
+};
diff --git a/evals/flows/conversation-streaming-result.flow.mjs b/evals/flows/conversation-streaming-result.flow.mjs
index 29b4483ee..dc005b958 100644
--- a/evals/flows/conversation-streaming-result.flow.mjs
+++ b/evals/flows/conversation-streaming-result.flow.mjs
@@ -2,7 +2,7 @@ import { loadVoiceoverParagraphs } from "../runner/voiceover.mjs";
const vo = await loadVoiceoverParagraphs("conversation-streaming-result");
-async function mountFixture() {
+export async function mountFixture() {
window.__streamingAnswerProof?.cleanup?.();
document.querySelectorAll("#streaming-answer-proof").forEach((node) => node.remove());
const resources = performance.getEntriesByType("resource").map((entry) => entry.name);
@@ -38,7 +38,18 @@ async function mountFixture() {
const queryClient = new QueryClient();
const imageUrl = `data:image/svg+xml,${encodeURIComponent('
')}`;
const openTargets = ["design/proof/report.pdf", "design/proof/cover.png"].map((path) => createWorkspaceFileOpenTarget({ path }));
- const client = { baseUrl: "http://127.0.0.1:52999", downloadWorkspaceFile: async (_workspaceId, path) => {
+ const thumbnailImage = new Image();
+ thumbnailImage.src = imageUrl;
+ await thumbnailImage.decode();
+ const thumbnailCanvas = document.createElement("canvas");
+ thumbnailCanvas.width = 160; thumbnailCanvas.height = 110;
+ const thumbnailContext = thumbnailCanvas.getContext("2d");
+ thumbnailContext.drawImage(thumbnailImage, 56, 0, 48, 110);
+ const thumbnailBlob = await new Promise(resolve => thumbnailCanvas.toBlob(resolve, "image/webp"));
+ const client = {
+ resolveArtifacts: async (_workspaceId, targets) => ({ items: targets.map(target => ({ kind: "file", value: target.value, exists: true })) }),
+ downloadWorkspaceThumbnail: async () => ({ data: new Uint8Array(await thumbnailBlob.arrayBuffer()), detail: "420 × 960" }),
+ baseUrl: "http://127.0.0.1:52999", downloadWorkspaceFile: async (_workspaceId, path) => {
window.__streamingAnswerProof.downloadedPath = path;
return { data: new Uint8Array([137, 80, 78, 71]), contentType: "image/png" };
} };
@@ -326,6 +337,7 @@ async function mountFixture() {
export default {
id: "conversation-streaming-result",
title: "Final answer streams in place below collapsible progress",
+ ...(process.env.IPOLLOWORK_EVAL_APP_ORIGIN ? { cdpTarget: { urlIncludes: process.env.IPOLLOWORK_EVAL_APP_ORIGIN } } : {}),
kind: "user-facing",
steps: [
{
@@ -344,15 +356,15 @@ export default {
liveAction: process.querySelector('[data-tool-action=command] summary')?.textContent,
commandDetailsClosed: !process.querySelector('[data-tool-action=command]')?.open,
duplicateProgress: Boolean(host.querySelector('[data-testid=assistant-streaming-progress]')),
- pending: host.querySelector('[data-testid=assistant-result-pending]')?.textContent,
+ pending: Boolean(host.querySelector('[data-testid=assistant-result-pending]')),
+ currentState: host.querySelector('[data-testid=assistant-current-state]')?.textContent,
activityKind: host.querySelector('[data-testid=assistant-live-activity]')?.getAttribute('data-activity-kind'),
thinkingDots: host.querySelectorAll('[data-testid=assistant-result-pending] .chat-thinking-dots span').length,
result: Boolean(host.querySelector('[data-assistant-result]')) };
})()`);
ctx.assert(state.open === "true" && state.commentary && state.commentaryBeforeCommand && !state.duplicateProgress && !state.result
&& state.liveAction?.includes("运行命令") && state.liveAction?.includes("检查项目文件") && state.commandDetailsClosed
- && state.pending?.includes("检查项目文件")
- && state.activityKind === "tool"
+ && !state.pending && state.currentState?.includes("检查项目文件")
&& state.thinkingDots === 0, JSON.stringify(state));
await ctx.eval("document.querySelector('#streaming-answer-proof [data-testid=assistant-process-column] button').click()");
await ctx.waitFor("document.querySelector('#streaming-answer-proof [data-testid=assistant-process-column] button')?.getAttribute('aria-expanded') === 'false'");
@@ -372,7 +384,7 @@ export default {
const afterTick = await ctx.eval("document.querySelector('#streaming-answer-proof [data-testid=assistant-process-column] button')?.textContent");
ctx.assert(beforeTick !== afterTick && afterTick.includes("已用时"), "The live elapsed time did not advance.");
},
- screenshot: { name: "streaming-process", requireText: ["对话流式输出", "我先检查相关文件", "处理中", "检查项目文件"] },
+ screenshot: { name: "streaming-process", requireText: ["对话流式输出", "我先检查相关文件", "检查项目文件"] },
}),
},
{
@@ -397,9 +409,9 @@ export default {
text: result.textContent, processText: process.textContent };
})()`);
ctx.assert(state.sameNode && !state.pending && state.belowProcess && state.text.includes("最终建议")
- && !state.processText.includes("最终建议") && state.processText.includes("正在收尾"), JSON.stringify(state));
+ && !state.processText.includes("最终建议") && state.processText.includes("正在回复"), JSON.stringify(state));
},
- screenshot: { name: "streaming-result", requireText: ["正在收尾", "最终建议"] },
+ screenshot: { name: "streaming-result", requireText: ["正在回复", "最终建议"] },
}),
},
{
@@ -436,7 +448,7 @@ export default {
})()`);
ctx.assert(state.sameNode && state.answer && state.queue && state.actionSize === 28
&& state.iconSize === 14 && state.answerBackground === 'rgba(0, 0, 0, 0)'
- && state.answerBorder === '0px' && state.answerSize === '14px' && state.processBorder === '0px', JSON.stringify(state));
+ && state.answerBorder === '0px' && state.answerSize === '13px' && state.processBorder === '0px', JSON.stringify(state));
},
screenshot: { name: "completed-result", requireText: ["用时", "已处理 1 个命令", "最终建议", "完成后再检查测试结果"] },
}),
@@ -562,14 +574,14 @@ export default {
action: async () => {
await ctx.eval(`(() => {
const image = document.querySelector('#streaming-answer-proof img[alt="cover.png"]');
- image?.parentElement?.querySelector('button')?.click();
+ image?.closest('button')?.click();
image?.scrollIntoView({ block: 'start' });
return true;
})()`);
- await ctx.waitFor("document.querySelector('#streaming-answer-proof img[alt=\"cover.png\"]')?.parentElement?.style.maxHeight === ''");
+ await ctx.waitFor("document.querySelector('#streaming-answer-proof img[alt=\"cover.png\"]')?.closest('button')?.getAttribute('aria-expanded') === 'true'");
},
assert: async () => {
- const expanded = await ctx.eval("document.querySelector('#streaming-answer-proof img[alt=\"cover.png\"]')?.parentElement?.style.maxHeight === ''");
+ const expanded = await ctx.eval("document.querySelector('#streaming-answer-proof img[alt=\"cover.png\"]')?.closest('button')?.getAttribute('aria-expanded') === 'true'");
ctx.assert(expanded, "The full image did not expand.");
},
screenshot: { name: "expanded-image-preview", requireText: ["封面已生成"] },
@@ -582,14 +594,14 @@ export default {
action: async () => {
await ctx.eval(`(() => {
const image = document.querySelector('#streaming-answer-proof img[alt="cover.png"]');
- image?.parentElement?.parentElement?.querySelector(':scope > button')?.click();
+ image?.closest('button')?.click();
document.querySelector('#streaming-answer-proof [data-assistant-result]')?.scrollIntoView({ block: 'start' });
return true;
})()`);
- await ctx.waitFor("document.querySelector('#streaming-answer-proof img[alt=\"cover.png\"]')?.parentElement?.style.maxHeight === '360px'");
+ await ctx.waitFor("document.querySelector('#streaming-answer-proof img[alt=\"cover.png\"]')?.closest('button')?.getAttribute('aria-expanded') === 'false'");
},
assert: async () => {
- ctx.assert(await ctx.eval("document.querySelector('#streaming-answer-proof img[alt=\"cover.png\"]')?.parentElement?.style.maxHeight === '360px'"), "The compact preview was not restored.");
+ ctx.assert(await ctx.eval("document.querySelector('#streaming-answer-proof img[alt=\"cover.png\"]')?.closest('button')?.getAttribute('aria-expanded') === 'false'"), "The compact preview was not restored.");
},
screenshot: { name: "compact-image-preview", requireText: ["封面已生成"] },
}),
@@ -670,7 +682,7 @@ export default {
voiceover: vo[13],
action: async () => {
await ctx.eval("window.__streamingAnswerProof.showToolFailure()");
- await ctx.waitFor("document.querySelector('#streaming-answer-proof [data-testid=assistant-result-pending]')?.textContent.includes('正在思考')");
+ await ctx.waitFor("document.querySelector('#streaming-answer-proof [data-testid=assistant-current-state]')?.textContent.includes('正在思考')");
},
assert: async () => {
const text = await ctx.eval("document.querySelector('#streaming-answer-proof')?.textContent");
@@ -805,7 +817,7 @@ export default {
&& state.onlyCommandOpen && state.icons[0]?.includes('lucide-file-search')
&& state.icons[1]?.includes('lucide-square-terminal') && state.arrowTransform !== 'none'
&& state.commands && state.resultOutside && state.fileCards === 2 && state.fileBullets === 0
- && state.resultGaps.every((gap) => Math.abs(gap - 12) <= 1), JSON.stringify(state));
+ && state.resultGaps.every((gap) => Math.abs(gap - 8) <= 1), JSON.stringify(state));
await ctx.eval("document.querySelector('#streaming-answer-proof [data-tool-action=command] summary').click()");
ctx.assert(await ctx.eval("document.querySelector('#streaming-answer-proof [data-tool-action=command]').open === false"), "Command group did not collapse again.");
await ctx.eval("document.querySelector('#streaming-answer-proof [data-tool-action=command] summary').click()");
@@ -833,42 +845,43 @@ export default {
await ctx.waitFor("document.querySelector('#streaming-answer-proof [data-testid=assistant-loading]')?.textContent.includes('正在思考...')");
ctx.assert(await ctx.eval("document.querySelectorAll('#streaming-answer-proof [data-testid=assistant-loading] .chat-thinking-dots span').length === 3"), "OpenCode waiting state lacks animated thinking dots.");
await ctx.eval("window.__streamingAnswerProof.showOpenCodePhase('reasoning')");
- await ctx.waitFor("document.querySelector('#streaming-answer-proof [data-testid=assistant-result-pending]')?.textContent.includes('正在思考...')");
+ await ctx.waitFor("document.querySelector('#streaming-answer-proof [data-testid=assistant-current-state]')?.textContent.includes('正在思考')");
await ctx.eval("window.__streamingAnswerProof.showOpenCodePhase('tool')");
await ctx.waitFor("document.querySelector('#streaming-answer-proof [data-tool-action=command]')?.textContent.includes('检查项目文件')");
},
assert: async () => {
const state = await ctx.eval(`(() => { const host = document.querySelector('#streaming-answer-proof');
- return { pending: host.querySelector('[data-testid=assistant-result-pending]')?.textContent,
+ return { pending: Boolean(host.querySelector('[data-testid=assistant-result-pending]')),
+ currentState: host.querySelector('[data-testid=assistant-current-state]')?.textContent,
activityKind: host.querySelector('[data-testid=assistant-live-activity]')?.getAttribute('data-activity-kind'),
dots: host.querySelectorAll('[data-testid=assistant-result-pending] .chat-thinking-dots span').length,
command: host.querySelector('[data-tool-action=command]')?.textContent,
result: Boolean(host.querySelector('[data-assistant-result]')) }; })()`);
- ctx.assert(state.pending?.includes('检查项目文件') && state.activityKind === 'tool' && state.dots === 0 && state.command?.includes('检查项目文件') && !state.result, JSON.stringify(state));
+ ctx.assert(!state.pending && state.currentState?.includes('检查项目文件') && state.dots === 0 && state.command?.includes('检查项目文件') && !state.result, JSON.stringify(state));
},
screenshot: { name: "opencode-thinking-and-tool", requireText: ["运行命令", "检查项目文件"] },
}),
},
{
- name: "Unphased output stays chronological until the final result",
- run: (ctx) => ctx.prove("Unphased text stays in arrival order without a premature result area; completion folds the process", {
+ name: "Unphased output remains in stable body nodes",
+ run: (ctx) => ctx.prove("Unphased text keeps stable body nodes across tools and completion", {
voiceover: vo[21],
action: async () => {
await ctx.eval("window.__streamingAnswerProof.showOpenCodePhase('answer')");
await ctx.waitFor("document.querySelector('#streaming-answer-proof [data-message-id=opencode-proof-assistant]')?.textContent.includes('已找到相关文件。')");
await ctx.eval("void (window.__orderedFirstText = document.querySelector('#streaming-answer-proof [data-message-id=opencode-proof-assistant]'))");
- ctx.assert(await ctx.eval("!document.querySelector('#streaming-answer-proof [data-assistant-result]')"), "Intermediate text created a result area.");
+ ctx.assert(await ctx.eval("Boolean(document.querySelector('#streaming-answer-proof [data-assistant-result]'))"), "Streaming text lacks a body area.");
await ctx.eval("window.__streamingAnswerProof.appendOpenCodeStep()");
await ctx.waitFor("document.querySelector('#streaming-answer-proof [data-message-id=opencode-proof-final]')?.textContent.includes('目录确认完成。')");
const ordered = await ctx.eval(`(() => {
const host = document.querySelector('#streaming-answer-proof');
const rows = [...host.querySelectorAll('[data-message-role=assistant]')];
- return { ids: rows.map(n => n.dataset.messageId), retained: rows[0] === window.__orderedFirstText,
+ return { ids: rows.map(n => n.dataset.messageId), retained: host.querySelector('[data-message-id=opencode-proof-assistant]') === window.__orderedFirstText,
result: Boolean(host.querySelector('[data-assistant-result]')) };
})()`);
- ctx.assert(ordered.retained && !ordered.result && JSON.stringify(ordered.ids) === JSON.stringify(['opencode-proof-assistant', 'opencode-proof-tool', 'opencode-proof-final']), JSON.stringify(ordered));
+ ctx.assert(ordered.retained && ordered.result && ordered.ids.includes('opencode-proof-final') && ordered.ids.includes('opencode-proof-tool'), JSON.stringify(ordered));
await ctx.eval("window.__streamingAnswerProof.finishOpenCode()");
- await ctx.waitFor("document.querySelector('#streaming-answer-proof [data-assistant-result]')?.textContent.includes('目录确认完成。')");
+ await ctx.waitFor("Array.from(document.querySelectorAll('#streaming-answer-proof [data-assistant-result]')).some(n=>n.textContent.includes('目录确认完成。'))");
},
assert: async () => {
ctx.assert(await ctx.eval("!document.querySelector('#streaming-answer-proof [data-testid=assistant-result-pending], #streaming-answer-proof [data-testid=assistant-loading]')"), "Thinking remained visible after the answer began.");
@@ -882,7 +895,7 @@ export default {
voiceover: vo[22],
action: async () => {
await ctx.eval("window.__streamingAnswerProof.showCompletedWhilePostProcessing()");
- await ctx.waitFor("document.querySelector('#streaming-answer-proof [data-testid=assistant-process-column] button')?.textContent.includes('处理中')");
+ await ctx.waitFor("document.querySelector('#streaming-answer-proof [data-testid=assistant-process-column] button')?.textContent.includes('正在收尾')");
},
assert: async () => {
const state = await ctx.eval(`(() => {
@@ -890,17 +903,17 @@ export default {
const label = process?.querySelector('button')?.textContent ?? '';
return { label, result: document.querySelector('#streaming-answer-proof [data-assistant-result]')?.textContent ?? '' };
})()`);
- ctx.assert(state.label.startsWith('处理中')
+ ctx.assert(state.label.startsWith('正在收尾')
&& state.result.includes('iPolloWork 应用正在导出 MP4'), JSON.stringify(state));
},
- screenshot: { name: "completed-engine-post-processing", requireText: ["处理中", "iPolloWork 应用正在导出 MP4"] },
+ screenshot: { name: "completed-engine-post-processing", requireText: ["正在收尾", "iPolloWork 应用正在导出 MP4"] },
}),
},
{
name: "Composer text matches conversation typography",
run: async (ctx) => {
let placeholderStyle;
- await ctx.prove("The composer uses the same 14px text and 1.5 line height for typed and placeholder text", {
+ await ctx.prove("The composer uses the shared 13px text and 20px line height for typed and placeholder text", {
voiceover: vo[23],
action: async () => {
await ctx.eval("window.__streamingAnswerProof.cleanup()");
@@ -920,7 +933,7 @@ export default {
const read = (element) => { const style = getComputedStyle(element); return [style.fontSize, style.lineHeight]; };
return { editor: read(editor), paragraph: editor.querySelector('p') ? read(editor.querySelector('p')) : null };
})()`);
- ctx.assert(Object.values({ ...styles, placeholder: placeholderStyle }).filter(Boolean).every(([size, height]) => size === "14px" && height === "21px"), JSON.stringify({ ...styles, placeholder: placeholderStyle }));
+ ctx.assert(Object.values({ ...styles, placeholder: placeholderStyle }).filter(Boolean).every(([size, height]) => size === "13px" && height === "20px"), JSON.stringify({ ...styles, placeholder: placeholderStyle }));
},
screenshot: { name: "composer-multiline-typography", requireText: ["第一行", "第二行"] },
});
diff --git a/evals/flows/shared-ui-core.flow.mjs b/evals/flows/shared-ui-core.flow.mjs
index 5e0c5a661..58fcccdee 100644
--- a/evals/flows/shared-ui-core.flow.mjs
+++ b/evals/flows/shared-ui-core.flow.mjs
@@ -23,7 +23,7 @@ async function findFrame(ctx,mode) {
for(let attempt=0;attempt<80;attempt++) {
for(const target of (await listTargets(ctx.cdpBaseUrl)).filter(t=>t.type==='iframe')) {
const c=await connect(target.webSocketDebuggerUrl);
- if(await evaluate(c,`window.ipolloworkUi?.mode===${JSON.stringify(mode)} && Boolean(document.querySelector('#receipt'))`).catch(()=>false))return c;
+ if(await evaluate(c,`window.ipolloworkUi?.mode===${JSON.stringify(mode)} && Boolean(document.querySelector('#receipt')) && Boolean(document.querySelector('#name'))`).catch(()=>false))return c;
c.close();
}
await new Promise(resolve=>setTimeout(resolve,250));
@@ -179,6 +179,43 @@ export default {
ctx.assert(await ctx.eval('document.querySelectorAll("[data-slot=alert]").length===3'),'dismiss removes only the selected Alert');
},screenshot:shot('soft-feedback'),
});
+ await ctx.prove('公共图标及按钮组合在双模式亮暗窄容器可操作', {
+ voiceover:vo[6], action:async()=>{
+ await click(parent,frame,'[id="group-图标与按钮"]');
+ await ctx.waitFor('Boolean(document.querySelector("#icon-gallery"))');
+ for(const theme of ['light','dark'])for(const width of [1280,390])for(const fontSize of [13,16]) {
+ await parent.send('Emulation.setDeviceMetricsOverride',{width,height:900,deviceScaleFactor:1,mobile:false});
+ await ctx.eval(`document.documentElement.dataset.theme=${JSON.stringify(theme)};document.documentElement.style.fontSize=${JSON.stringify(fontSize+'px')}`);
+ const metrics=await ctx.eval(`(()=>{
+ const ui=window.ipolloworkUi;
+ const all=Array.from(document.querySelectorAll('#icon-gallery svg'));
+ const glyphs=all.length===ui.ICON_NAMES.length&&all.every(svg=>svg.children.length>0&&svg.getBoundingClientRect().width===16&&svg.getAttribute('aria-label')===svg.dataset.iconName);
+ const sizes=['s','m','l'].every((size,i)=>{const svg=document.querySelector('[aria-label="搜索 '+size+'"]'),r=svg.getBoundingClientRect();return r.width===[14,16,20][i]&&r.height===[14,16,20][i]});
+ const buttons=[['sm',28,14],['default',32,16],['lg',36,20]].every(([size,height,icon])=>{const b=document.querySelector('#icon-leading-'+size),r=b.getBoundingClientRect(),s=b.querySelector('svg').getBoundingClientRect();return r.height===height&&s.width===icon&&s.height===icon&&Math.abs(r.y+r.height/2-s.y-s.height/2)<=1&&b.querySelector('svg').getAttribute('aria-hidden')==='true'});
+ const only=document.querySelector('#icon-only'),r=only.getBoundingClientRect();
+ return {glyphs,sizes,buttons,only:r.width===32&&r.height===32&&only.getAttribute('aria-label')==='图标搜索',fits:document.documentElement.scrollWidth<=innerWidth,version:ui.version};
+ })()`);
+ ctx.assert(Object.values(metrics).every(Boolean),`${mode}/${theme}/${width}/${fontSize}px: ${JSON.stringify(metrics)}`);
+ }
+ await click(parent,frame,'#icon-leading-default');
+ await ctx.waitFor('document.querySelector("#icon-click-count").textContent==="1"');
+ await ctx.eval('document.querySelector("#icon-leading-default").scrollIntoView({block:"center"})');
+ const point=await ctx.eval('(()=>{const r=document.querySelector("#icon-leading-default").getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2}})()');
+ for(const type of ['mouseMoved','mousePressed','mouseReleased'])await parent.send('Input.dispatchMouseEvent',{type,...point,button:'left',clickCount:1});
+ await ctx.waitFor('document.querySelector("#icon-click-count").textContent==="2"');
+ await click(parent,frame,'#icon-trailing');
+ await ctx.waitFor('document.querySelector("#receipt").textContent==="后置图标已执行"');
+ await click(parent,frame,'#icon-only');
+ await ctx.waitFor('document.querySelector("#receipt").textContent==="纯图标已执行"');
+ await ctx.eval('document.querySelector("#icon-disabled").click()');
+ ctx.assert(await ctx.eval('document.querySelector("#icon-disabled").disabled && document.querySelector("#receipt").textContent==="纯图标已执行"'),'disabled button cannot execute');
+ await click(parent,frame,'#icon-loading');
+ await ctx.waitFor('document.querySelector("#icon-loading").disabled && document.querySelector("#icon-loading").getAttribute("aria-busy")==="true" && document.querySelector("#icon-loading svg").dataset.iconName==="LoaderCircle"');
+ }, assert:async()=>{
+ await ctx.waitFor('!document.querySelector("#icon-loading").disabled && document.querySelector("#receipt").textContent==="保存完成" && document.querySelector("#icon-loading svg").dataset.iconName==="Save"');
+ ctx.assert(await ctx.eval('document.querySelector("#icon-loading").getAttribute("aria-busy")==="false"'),'loading state recovers');
+ }, screenshot:shot('icons-buttons'),
+ });
} finally {frame.close();ctx.client=parent;}
}})),
};
diff --git a/evals/flows/video-studio-shared-ui.flow.mjs b/evals/flows/video-studio-shared-ui.flow.mjs
new file mode 100644
index 000000000..59f2ef96b
--- /dev/null
+++ b/evals/flows/video-studio-shared-ui.flow.mjs
@@ -0,0 +1,255 @@
+import workbench from "./video-workbench-toolbar.flow.mjs";
+const studioOrigin = process.env.IPOLLOWORK_VIDEO_STUDIO_ORIGIN || "http://127.0.0.1:5198";
+const appOrigin = process.env.IPOLLOWORK_VIDEO_APP_ORIGIN || "http://127.0.0.1:5197";
+const consoleOrigin = process.env.IPOLLOWORK_VIDEO_CONSOLE_ORIGIN || "http://127.0.0.1:5278";
+export default {
+ id: "video-studio-shared-ui",
+ title: "Video Studio shared UI migration",
+ kind: "user-facing",
+ cdpTarget: { urlIncludes: studioOrigin },
+ preserveTheme: true,
+ steps: [{
+ name: "Shared export controls preserve format behavior",
+ run: async ctx => {
+ if (!String(await ctx.eval("location.href")).startsWith(studioOrigin)) {
+ await ctx.client.send("Page.navigate", {url: studioOrigin + "/#project/shared-ui-export?v=1&tab=renders"});
+ await ctx.waitFor(`location.origin === ${JSON.stringify(studioOrigin)}`);
+ }
+ await ctx.waitForText("Export");
+ if (!await ctx.hasText("Format")) await ctx.clickText("Export");
+ await ctx.waitForText("Format");
+ await ctx.prove("导出选择和按钮采用共享尺寸与字体", {
+ voiceover: "导出区使用共享选择器和按钮,统一为三十二像素高度、十三像素文字。",
+ assert: async () => {
+ const controls = await ctx.eval(`Array.from(document.querySelectorAll('[data-slot="select-trigger"], [data-slot="button"].w-full')).map(e => ({height:e.getBoundingClientRect().height,font:getComputedStyle(e).fontSize,slot:e.dataset.slot,background:getComputedStyle(e).backgroundColor}))`);
+ ctx.assert(controls.filter(c => c.slot === "select-trigger").length >= 3 && controls.every(c => c.height === 32 && c.font === "13px"), JSON.stringify(controls));
+ ctx.assert(controls.some(c => c.slot === "button" && c.background !== "rgba(0, 0, 0, 0)"), "Shared export action has a visible background");
+ }, screenshot: {name: "shared-export-controls"},
+ });
+ await ctx.prove("MOV选择保留无损格式联动", {
+ voiceover: "选择 MOV 后隐藏压缩质量选项,其他导出参数保持不变。",
+ action: async () => {
+ await ctx.eval(`(()=>{const e=document.querySelector('[data-slot="select-trigger"][aria-label="Format"]');if(e.getAttribute('aria-expanded')!=='true')e.click()})()`);
+ await ctx.waitForText("MOV (ProRes)");
+ await ctx.trustedClick('[data-slot="select-item"]:nth-child(2)');
+ await ctx.waitFor(`document.querySelector('[aria-label="Format"]').textContent.includes('MOV') && !document.querySelector('[aria-label="Quality"]')`);
+ },
+ assert: async () => {
+ const result = await ctx.eval(`({format:document.querySelector('[aria-label="Format"]').textContent,quality:!!document.querySelector('[data-slot="select-trigger"][aria-label="Quality"]'),resolution:!!document.querySelector('[aria-label="Resolution"]')})`);
+ ctx.assert(result.format.includes("MOV") && !result.quality && result.resolution, JSON.stringify(result));
+ }, screenshot: {name: "shared-export-mov"},
+ });
+ await ctx.prove("切回MP4恢复质量参数和共享菜单", {
+ voiceover: "切回 MP4,质量选项恢复,菜单使用共享选项组件。",
+ action: async () => {
+ await ctx.eval(`(()=>{const e=document.querySelector('[data-slot="select-trigger"][aria-label="Format"]');if(e.getAttribute('aria-expanded')!=='true')e.click()})()`);
+ await ctx.waitFor(`document.querySelector('[role="option"]') !== null`);
+ await ctx.trustedClick('[data-slot="select-item"]:first-child');
+ await ctx.waitFor(`document.querySelector('[data-slot="select-trigger"][aria-label="Quality"]') !== null`);
+ await ctx.eval(`document.querySelector('[data-slot="select-trigger"][aria-label="Quality"]').click()`);
+ await ctx.waitFor(`document.querySelector('[data-slot="select-item"]') !== null`);
+ },
+ assert: async () => {
+ const result = await ctx.eval(`({selects:document.querySelectorAll('[data-slot="select-trigger"]').length,options:document.querySelectorAll('[data-slot="select-item"]').length})`);
+ ctx.assert(result.selects === 4 && result.options > 0, JSON.stringify(result));
+ }, screenshot: {name: "shared-export-quality-menu"},
+ });
+ await ctx.eval(`document.querySelector('[data-slot="select-trigger"][aria-label="Quality"]').click()`);
+ await ctx.prove("长分辨率文字不侵占勾选区", {
+ voiceover: "分辨率菜单为勾选图标保留独立空间,长文字截断,勾选垂直居中。",
+ action: async () => {
+ await ctx.trustedClick('[data-slot="select-trigger"][aria-label="Resolution"]');
+ await ctx.waitFor(`Array.from(document.querySelectorAll('[data-slot="select-item"][aria-selected="true"]')).some(e=>e.textContent.includes('1280') && e.getBoundingClientRect().width>0)`);
+ },
+ assert: async () => {
+ const geometry = await ctx.eval(`(()=>{const item=Array.from(document.querySelectorAll('[data-slot="select-item"][aria-selected="true"]')).find(e=>e.textContent.includes('1280') && e.getBoundingClientRect().width>0);const text=item.querySelector('[data-slot="select-item-text"]');const icon=item.querySelector('[data-slot="select-item-indicator"]');const t=text.getBoundingClientRect(),i=icon.getBoundingClientRect(),r=item.getBoundingClientRect();return {gap:i.left-t.right,center:Math.abs((i.top+i.bottom-r.top-r.bottom)/2),inside:i.right<=r.right,overflow:getComputedStyle(text).textOverflow}})()`);
+ ctx.assert(geometry.gap >= 4 && geometry.center < 1 && geometry.inside && geometry.overflow === "ellipsis", JSON.stringify(geometry));
+ }, screenshot: {name: "resolution-checkmark-spacing"},
+ });
+ await ctx.trustedClick('[data-slot="select-trigger"][aria-label="Resolution"]');
+ },
+ }, {
+ name: "Studio layout regression geometry",
+ run: async ctx => {
+ await ctx.client.send("Emulation.setDeviceMetricsOverride", {width:1440,height:960,deviceScaleFactor:1,mobile:false});
+ const layoutUrl=process.env.IPOLLOWORK_VIDEO_LAYOUT_STUDIO_URL || studioOrigin + "/#project/shared-ui-export?v=1";
+ await ctx.client.send("Page.navigate", {url:layoutUrl});
+ await ctx.waitFor(`location.port===${JSON.stringify(new URL(layoutUrl).port)}`);
+ await ctx.client.send("Page.reload", {ignoreCache:true});
+ await ctx.waitForText("Export");
+ await ctx.waitFor(`document.querySelector(".hf-timeline-layer-header__select")!==null`);
+ await ctx.eval(`window.postMessage({type:'ipollowork:studio-host-context',projectId:decodeURIComponent(location.hash.match(/#project\\/([^?]+)/)[1]),title:'Layout proof',actions:{reload:true,saveAsTemplate:true}},location.origin)`);
+ await ctx.waitFor(`document.querySelector('.hf-studio-header-utilities button')!==null`);
+ await ctx.prove("四个操作靠右,不挤占左侧视图切换", {
+ voiceover:"保存模板、重载、属性、导出保留各自功能,按钮组整体靠右。",
+ assert:async()=> {
+ const g=await ctx.eval(`(()=>{const h=document.querySelector('header'),a=h.querySelector('.hf-studio-header-actions'),v=h.querySelector('[role=tablist]');return{right:h.getBoundingClientRect().right-a.getBoundingClientRect().right,gap:a.getBoundingClientRect().left-v.getBoundingClientRect().right,count:a.querySelectorAll('button').length}})()`);
+ ctx.assert(g.right<=12 && g.gap>200 && g.count===4,JSON.stringify(g));
+ },screenshot:{name:"header-actions-right"}
+ });
+ await ctx.eval(`window.postMessage({type:"ipollowork:studio-theme",theme:"light"},location.origin)`);
+ await ctx.waitFor(`document.documentElement.dataset.theme==="light"`);
+ if (!await ctx.eval(`document.querySelector('[aria-label="Assets"]')?.getBoundingClientRect().width>0`)) await ctx.trustedClick('[aria-label="Properties"]');
+ await ctx.waitFor(`document.querySelector('[aria-label="Assets"]')?.getBoundingClientRect().width>0`);
+ for(const label of ["Assets","Components","Animation"]) {
+ await ctx.trustedClick('[aria-label="'+label+'"]');
+ await ctx.waitFor(`document.querySelector('[data-slot="studio-search"] input')?.getBoundingClientRect().width>0`);
+ await ctx.prove(label+"搜索图标与文字分离,使用紧凑字号", {
+ voiceover:"三个素材面板的搜索框统一为十二像素字号,图标和文字之间保留六像素间距。",
+ assert:async()=>{
+ const g=await ctx.eval(`(()=>{const w=document.querySelector('[data-slot="studio-search"]'),e=w.querySelector('input'),i=w.querySelector('svg'),s=getComputedStyle(e);return{font:s.fontSize,gap:e.getBoundingClientRect().left+parseFloat(s.paddingInlineStart)-i.getBoundingClientRect().right,height:e.getBoundingClientRect().height}})()`);
+ ctx.assert(g.font==="12px"&&g.gap>=6&&g.height===32,JSON.stringify(g));
+ },screenshot:{name:label.toLowerCase()+"-search-spacing"}
+ });
+ if(label==="Components") await ctx.prove("分类按钮仅一个居中图标,菜单仍能选择分类", {
+ voiceover:"分类筛选使用独立图标按钮,不再挤入额外箭头。",
+ action:async()=>{await ctx.trustedClick('[aria-label="Component category"]');await ctx.waitFor(`document.querySelector('[role="menuitemradio"]')!==null`);},
+ assert:async()=>{
+ const g=await ctx.eval(`(()=>{const b=document.querySelector('[aria-label="Component category"]'),i=b.querySelector('svg'),r=b.getBoundingClientRect(),s=i.getBoundingClientRect();return{count:b.querySelectorAll('svg').length,cx:Math.abs((s.left+s.right-r.left-r.right)/2),cy:Math.abs((s.top+s.bottom-r.top-r.bottom)/2),items:document.querySelectorAll('[role="menuitemradio"]').length}})()`);
+ ctx.assert(g.count===1&&g.cx<1&&g.cy<1&&g.items>0,JSON.stringify(g));
+ },screenshot:{name:"component-category-centered"}
+ });
+ if(label==="Components") {
+ await ctx.trustedClick('[role="menuitemradio"]:nth-child(2)');
+ await ctx.waitFor(`document.querySelector('[role="menu"]')===null`);
+ }
+ }
+ await ctx.trustedClick('[aria-label="Layers"]');
+ await ctx.trustedClick('.hf-timeline-layer-header__select');
+ await ctx.waitFor(`document.querySelector('.hf-panel-accordion-header')!==null`);
+ await ctx.prove("属性折叠恢复左对齐独立布局", {
+ voiceover:"属性面板保留领域专用控件,不套通用按钮和输入框外观。",
+ assert:async()=>{
+ const g=await ctx.eval(`Array.from(document.querySelectorAll('.hf-panel-accordion-header')).map(e=>({slot:e.dataset.slot,align:getComputedStyle(e).textAlign,height:e.getBoundingClientRect().height,justify:getComputedStyle(e).justifyContent}))`);
+ ctx.assert(g.length>0&&g.every(e=>!e.slot&&e.align==="left"&&e.justify!=="center"),JSON.stringify(g));
+ },screenshot:{name:"property-accordion-restored"}
+ });
+ const textSelector='textarea[aria-label="Content"]';
+ const originalText=await ctx.eval(`document.querySelector('${textSelector}').value`);
+ await ctx.prove("属性文本默认显示三行,Enter换行不结束编辑", {
+ voiceover:"文本编辑默认三行,内容增加时自动扩高,Enter保留在文本框内换行。",
+ action:async()=>{
+ await ctx.trustedClick(textSelector);
+ await ctx.fill(textSelector,"第一行 正负描述的是回路作用方向\n第二行 不是好坏评价\n第三行 正反馈放大变化\n第四行 负反馈抵消变化");
+ await ctx.client.send("Input.dispatchKeyEvent",{type:"keyDown",key:"Enter",code:"Enter",text:"\r",windowsVirtualKeyCode:13});
+ await ctx.client.send("Input.dispatchKeyEvent",{type:"keyUp",key:"Enter",code:"Enter",windowsVirtualKeyCode:13});
+ await ctx.waitFor(`document.querySelector('${textSelector}').value.split('\\n').length===5`);
+ },
+ assert:async()=>{
+ const g=await ctx.eval(`(()=>{const e=document.querySelector('${textSelector}');return{rows:e.rows,height:e.clientHeight,focused:e===document.activeElement,lines:e.value.split('\\n').length}})()`);
+ ctx.assert(g.rows===3&&g.height>=100&&g.height<=160&&g.focused&&g.lines===5,JSON.stringify(g));
+ },screenshot:{name:"property-text-multiline"}
+ });
+ await ctx.prove("长文本最多八行高度,内部滚动且不截断内容", {
+ voiceover:"超过八行时停止增高,全部文字仍保留,可以在文本框内滚动。",
+ action:async()=>{await ctx.fill(textSelector,Array.from({length:12},(_,i)=>"第"+(i+1)+"行 完整保留文本内容").join("\n"));},
+ assert:async()=>{
+ const g=await ctx.eval(`(()=>{const e=document.querySelector('${textSelector}');return{height:e.clientHeight,scroll:e.scrollHeight,overflow:getComputedStyle(e).overflowY,lines:e.value.split('\\n').length}})()`);
+ ctx.assert(g.height===160&&g.scroll>g.height&&g.overflow==="auto"&&g.lines===12,JSON.stringify(g));
+ },screenshot:{name:"property-text-bounded-scroll"}
+ });
+ await ctx.fill(textSelector,originalText);
+ await ctx.prove("缩短内容后恢复三行,失焦结束编辑", {
+ voiceover:"删除长内容后文本框缩回三行,失焦沿用原有保存逻辑。",
+ action:async()=>{await ctx.eval(`document.querySelector('${textSelector}').blur()`);},
+ assert:async()=>{
+ const g=await ctx.eval(`(()=>{const e=document.querySelector('${textSelector}');return{height:e.clientHeight,value:e.value,focused:e===document.activeElement}})()`);
+ ctx.assert(g.height===60&&g.value===originalText&&!g.focused,JSON.stringify(g));
+ },screenshot:{name:"property-text-shrink"}
+ });
+ await ctx.client.send("Page.bringToFront");
+ await ctx.client.send("Emulation.setDeviceMetricsOverride", {width:680,height:960,deviceScaleFactor:1,mobile:false});
+ await ctx.waitFor(`document.visibilityState === 'visible' && innerWidth === 680 && document.documentElement.scrollWidth <= innerWidth && document.querySelector('.hf-studio-header-actions')?.getBoundingClientRect().right <= innerWidth`);
+ await ctx.prove("窄屏顶部操作仍在右侧且无溢出", {
+ voiceover:"窄屏收起操作标签,保留图标和提示。",
+ assert:async()=>{
+ const g=await ctx.eval(`(()=>{const a=document.querySelector('.hf-studio-header-actions'),r=a.getBoundingClientRect();return{right:r.right,width:innerWidth,overflow:document.documentElement.scrollWidth>innerWidth}})()`);
+ ctx.assert(g.right<=g.width&&!g.overflow,JSON.stringify(g));
+ },screenshot:{name:"header-actions-narrow"}
+ });
+ await ctx.client.send("Emulation.clearDeviceMetricsOverride");
+ },
+ }, {
+ name: "Shared input and textarea single focus border",
+ run: async ctx => {
+ const fixture = new URL("../support/compact-controls-fixture.html", import.meta.url).pathname;
+ await ctx.client.send("Page.navigate", {url: `${appOrigin}/@fs${fixture}?matrix=1`});
+ await ctx.waitFor(`document.querySelector('#matrix-name') !== null`, {timeoutMs: 90000});
+ for (const id of ["matrix-name", "matrix-text"]) {
+ await ctx.prove(`${id}单层聚焦边框`, {
+ voiceover: "共享输入框保留清晰的聚焦边框,不叠加外层光圈。",
+ action: async () => { await ctx.trustedClick(`#${id}`); },
+ assert: async () => {
+ const style = await ctx.eval(`(()=>{const e=document.getElementById(${JSON.stringify(id)}),s=getComputedStyle(e);return {focused:e===document.activeElement,slot:e.dataset.slot,border:s.borderWidth,ring:s.getPropertyValue('--tw-ring-shadow'),outline:s.outlineStyle}})()`);
+ ctx.assert(style.focused && ["input", "textarea"].includes(style.slot) && style.border === "1px" && style.outline === "none" && style.ring.includes("0px") && !style.ring.includes("3px"), JSON.stringify(style));
+ }, screenshot: {name: `${id}-single-focus-border`},
+ });
+ }
+ },
+ }, {
+ name: "Real avatar and voice forms",
+ run: async ctx => {
+ await ctx.client.send("Page.navigate", {url:appOrigin + "/tests/video-avatar-proof.html?panel=avatar&profiles=1&audio=1"});
+ await ctx.waitForText("创建数字人");
+ await ctx.clickText("创建数字人");
+ await ctx.waitFor(`document.querySelectorAll('[role="radio"]').length===2`);
+ await ctx.prove("横竖屏通过共享单选键盘选择,保存真实表单值", {
+ voiceover:"数字人横竖屏使用共享单选,键盘选择后,原有保存逻辑收到正确画幅。",
+ action: async () => {
+ await ctx.eval(`document.querySelector('[role="radio"][aria-checked="true"]').focus()`);
+ await ctx.client.send("Input.dispatchKeyEvent",{type:"keyDown",key:"ArrowRight",code:"ArrowRight",windowsVirtualKeyCode:39});
+ await ctx.client.send("Input.dispatchKeyEvent",{type:"keyUp",key:"ArrowRight",code:"ArrowRight",windowsVirtualKeyCode:39});
+ await ctx.waitFor(`document.querySelectorAll('[role="radio"]')[1].getAttribute('aria-checked')==='true'`);
+ await ctx.clickText("保存设置");
+ },
+ assert:async()=>ctx.assert(await ctx.eval(`window.avatarProof.requests.some(r=>r.action==='avatar-profile-save' && r.args.ratio==='16:9')`),"Landscape ratio reached persistence"),
+ screenshot:{name:"avatar-shared-radio"},
+ });
+ await ctx.client.send("Page.navigate",{url:appOrigin + "/tests/video-avatar-proof.html?mine=1"});
+ await ctx.waitFor(`document.querySelector('[data-testid="voice-selection-trigger"]')!==null`);
+ await ctx.trustedClick('[data-testid="voice-selection-trigger"]');
+ await ctx.clickText("我的声音");
+ await ctx.trustedClick('[data-testid="voice-clone-entry"]');
+ await ctx.waitFor(`document.querySelector('[data-testid="voice-clone-dialog"]')!==null`);
+ await ctx.prove("复刻校验错误关联输入,失败保持弹窗",{
+ voiceover:"复刻未填写名称时就地提示,错误与输入框关联,弹窗不关闭。",
+ action:async()=>{await ctx.trustedClick('[data-testid="voice-clone-dialog"] button[type=submit]');},
+ assert:async()=>{ctx.assert(await ctx.eval(`(()=>{const input=document.querySelector('[data-testid="voice-clone-dialog"] input');return input.getAttribute('aria-invalid')==='true' && !!document.getElementById(input.getAttribute('aria-describedby'))?.textContent && !window.avatarProof.requests.some(r=>r.action==='voice_clone_workspace_file')})()`),"Name error associated, no provider request");},
+ screenshot:{name:"voice-clone-field-error"},
+ });
+ },
+ }, {
+ name:"Storyboard field feedback preserves input",
+ run: async ctx => {
+ await ctx.client.send("Page.navigate",{url:appOrigin + "/tests/video-avatar-proof.html?panel=storyboard"});
+ await ctx.waitFor(`document.querySelector("#shot-material-brief") !== null`);
+ await ctx.prove("分镜设置采用共享 Field,失败保留草稿并关联错误",{
+ action:async()=>{await ctx.clickText("应用到脚本");await ctx.waitFor(`document.querySelector('[data-slot="field-error"]') !== null`);},
+ assert:async()=>{ctx.assert(await ctx.eval(`(()=>{const e=document.querySelector("#shot-material-brief"),id=e.getAttribute("aria-describedby");return e.value==="保留这个描述"&&e.getAttribute("aria-invalid")==="true"&&document.getElementById(id)?.dataset.slot==="field-error"&&!!document.querySelector('[role="dialog"]')})()`),"Failed apply retains real form values and accessible field error");},screenshot:{name:"storyboard-field-error"},
+ });
+ },
+ }, {
+ name:"Shared console retains real local editing",
+ run: async ctx => {
+ await ctx.client.send("Page.navigate",{url:consoleOrigin});
+ await ctx.waitFor("document.querySelector('#studio')?.contentDocument?.querySelector('[data-ipw-runtime]')!==null");
+ for(const step of workbench.steps) await step.run(ctx);
+ await ctx.prove("窄屏暗色共享主题切换不丢失视频",{
+ action:async()=>{await ctx.eval(`document.querySelector('#studio').contentWindow.postMessage({jsonrpc:'2.0',method:'ui/notifications/host-context-changed',params:{theme:'dark'}},location.origin)`);await ctx.waitFor(`document.querySelector('#studio').contentDocument.documentElement.dataset.theme==='dark'`);},
+ assert:async()=>{ctx.assert(await ctx.eval(`(()=>{const d=document.querySelector('#studio').contentDocument,e=d.querySelector('[data-tool=rotate]'),s=getComputedStyle(e);return !!d.querySelector('#player').videoWidth&&d.documentElement.scrollWidth<=d.documentElement.clientWidth&&s.color!==s.backgroundColor&&d.querySelector('#downloadVideo').getBoundingClientRect().right<=480})()`),"Shared dark theme preserves preview, contrast, and reachable actions");},screenshot:{name:"console-dark-narrow"},
+ });
+ },
+ }, {
+ name:"Current client compiled Studio",
+ run:async ctx=>{
+ const url=process.env.IPOLLOWORK_VIDEO_CURRENT_STUDIO_URL;
+ if(!url)return;
+ await ctx.client.send("Emulation.setDeviceMetricsOverride",{width:1180,height:900,deviceScaleFactor:1,mobile:false});
+ await ctx.client.send("Page.navigate",{url});
+ await ctx.waitFor(`document.querySelectorAll('[data-slot="tabs-trigger"]').length>=3`);
+ await ctx.prove("当前客户端的内嵌 Studio 已加载新构建",{
+ assert:async()=>{ctx.assert(await ctx.eval(`!!document.querySelector('[data-slot="button"][aria-label="Properties"]')&&!!document.querySelector('[data-slot="button"][aria-label="Export"]')&&document.querySelectorAll('[data-slot="tabs-trigger"]').length===3`),"Current running Studio serves shared Buttons and Tabs");},screenshot:{name:"current-client-built-studio"},
+ });
+ },
+ }],
+};
diff --git a/evals/flows/video-workbench-toolbar.flow.mjs b/evals/flows/video-workbench-toolbar.flow.mjs
index 4824ca582..20156a1b6 100644
--- a/evals/flows/video-workbench-toolbar.flow.mjs
+++ b/evals/flows/video-workbench-toolbar.flow.mjs
@@ -1,35 +1,35 @@
// Production plugin and local video actions; start video-workbench-fixture.ts.
const doc="document.querySelector('#studio').contentDocument";
const el=id=>`${doc}.getElementById(${JSON.stringify(id)})`;
-const click=(ctx,id)=>ctx.eval(`${el(id)}.click()`);
+const click=async(ctx,id)=>{await ctx.waitFor(`Boolean(${el(id)})`);await ctx.eval(`${el(id)}.click()`);};
const tool=(ctx,id)=>ctx.eval(`${doc}.querySelector('[data-tool=${id}]').click()`);
const input=async(ctx,label,value)=>ctx.eval(`(()=>{const e=${doc}.querySelector('input[aria-label=${JSON.stringify(label)}]');e.value=${JSON.stringify(value)};e.dispatchEvent(new Event('change',{bubbles:true}));})()`);
-export default {id:"video-workbench-toolbar",title:"视频工作台工具栏剪辑与保存",kind:"user-facing",preserveTheme:true,cdpTarget:{urlIncludes:"127.0.0.1:5278"},steps:[{name:"本地编辑与结果保存",async run(ctx){
+export default {id:"video-workbench-toolbar",title:"视频工作台共享控件、剪辑与保存",kind:"user-facing",preserveTheme:true,cdpTarget:{urlIncludes:"127.0.0.1:5278"},steps:[{name:"本地编辑与结果保存",async run(ctx){
await ctx.eval("fetch('/reset',{method:'POST'}).then(r=>r.json())",{awaitPromise:true});
await ctx.client.send('Page.reload');
await ctx.waitFor("Boolean(window.__ipolloworkControl)");
await ctx.client.send("Emulation.setDeviceMetricsOverride",{width:1180,height:900,deviceScaleFactor:1,mobile:false});
- await ctx.waitFor(`${el('player')}?.videoWidth>0 && !${el('saveCopy')}?.disabled`,{timeoutMs:30000});
+ await ctx.waitFor(`${el('player')}?.videoWidth>0 && !${el('downloadVideo')}?.disabled`,{timeoutMs:30000});
const original=await ctx.eval("fetch('/witness').then(r=>r.json()).then(s=>s.source)",{awaitPromise:true});
ctx.assert(await ctx.eval(`!${doc}.querySelector('#trimStart') && !${doc}.querySelector('#trimEnd')`),'Bottom trim sliders are removed; toolbar trim remains available');
await ctx.prove("无需 AI 授权也能用图标工具栏编辑,旧任务区已移除",{
voiceover:"视频工作台把常规剪辑放进图标工具栏。不绑定 AI 模型也能剪辑,下方不再显示旧任务列表。",
action:async()=>{await tool(ctx,'rotate');ctx.assert(await ctx.eval(`${el('player')}.style.transform.includes('90deg')`),'Rotate updates preview');await tool(ctx,'undo');ctx.assert(await ctx.eval(`${el('player')}.style.transform.includes('0deg')`),'Undo restores preview');await tool(ctx,'redo');ctx.assert(await ctx.eval(`${el('player')}.style.transform.includes('90deg')`),'Redo reapplies rotation');await tool(ctx,'undo');await ctx.eval(`${doc}.querySelector('[data-tool=trim]').focus()`);ctx.assert(await ctx.eval(`!${el('tooltip')}.hidden && ${el('tooltip')}.textContent==='裁剪时长'`),'Icon focus shows the text tooltip');},
- assert:async()=>{ctx.assert(await ctx.eval(`!${doc}.querySelector('#jobs') && !${doc}.querySelector('#previewPath')`),"Old task and path area removed");ctx.assert(await ctx.eval(`${doc}.querySelector('[data-tool=rotate]').disabled===false && ${doc}.body.innerText.includes('视频工作台')`),"Local editor is available without a model");},screenshot:{name:"toolbar",requireText:["视频工作台"]},
+ assert:async()=>{ctx.assert(await ctx.eval(`!${doc}.querySelector('#jobs') && !${doc}.querySelector('#previewPath')`),"Old task and path area removed");ctx.assert(await ctx.eval(`${doc}.querySelector('[data-tool=rotate]').disabled===false && ${doc}.querySelector('[data-tool=rotate]').dataset.ipwControl==='button'`),"Local editor is available without a model");},screenshot:{name:"toolbar",requireText:["真实剪辑和保存"]},
});
await ctx.prove("裁剪、旋转、变速和静音直接反映在预览中",{
voiceover:"通过工具栏保留中间四秒,裁切画面并旋转,设置两倍速和静音。预览立即显示这些修改。",
action:async()=>{await tool(ctx,'trim');await input(ctx,'起点(秒)','1');await input(ctx,'终点(秒)','5');await tool(ctx,'crop');await input(ctx,'宽度(%)','50');await tool(ctx,'rotate');await tool(ctx,'speed');await input(ctx,'播放速度','2');await tool(ctx,'volume');await input(ctx,'音量(%)','0');await tool(ctx,'volume');},
- assert:async()=>{ctx.assert(await ctx.eval(`${el('player')}.style.transform.includes('90deg') && ${el('player')}.playbackRate===2 && ${el('player')}.muted`),"Video preview transform, speed and sound match toolbar values");ctx.assert(await ctx.eval(`${el('editHint')}.textContent.includes('2.0 秒')`),"Trim and speed yield a two-second output");},screenshot:{name:"edited-preview",requireText:["视频工作台"]},
+ assert:async()=>{ctx.assert(await ctx.eval(`${el('player')}.style.transform.includes('90deg') && ${el('player')}.playbackRate===2 && ${el('player')}.muted`),"Video preview transform, speed and sound match toolbar values");ctx.assert(await ctx.eval(`${el('editHint')}.textContent.includes('2.0 秒')`),"Trim and speed yield a two-second output");},screenshot:{name:"edited-preview",requireText:["真实剪辑和保存"]},
});
await ctx.prove("覆盖需要确认,保存后原路径不变且结果真实转码",{
voiceover:"覆盖前需要确认。取消不会改动原视频;确认保存后仍使用原文件路径,实际视频已经变成裁剪后的版本。",
action:async()=>{await click(ctx,'overwrite');await click(ctx,'cancelOverwrite');ctx.assert((await ctx.eval("fetch('/witness').then(r=>r.json()).then(s=>s.source.revision)",{awaitPromise:true}))===original.revision,"Cancel preserves original bytes");await click(ctx,'overwrite');await click(ctx,'confirmSave');await ctx.waitFor("proofSaved.some(s=>s.saveMode==='overwrite')",{timeoutMs:30000});},
- assert:async()=>{const s=await ctx.eval("fetch('/witness').then(r=>r.json())",{awaitPromise:true});ctx.assert(s.source.width===360&&s.source.height===320,"Actual output dimensions reflect crop and rotation");ctx.assert(Math.abs(s.source.duration-2)<.15&&!s.source.hasAudio,"Actual output is trimmed, accelerated and silent");ctx.assert(s.artifacts.items.length===1&&s.source.revision!==original.revision,"Overwrite keeps one main artifact at the original path");ctx.assert(!s.calls.includes('submit'),"No AI submission occurred");},screenshot:{name:"overwrite-saved",requireText:["视频工作台"]},
+ assert:async()=>{const s=await ctx.eval("fetch('/witness').then(r=>r.json())",{awaitPromise:true});ctx.assert(s.source.width===360&&s.source.height===320,"Actual output dimensions reflect crop and rotation");ctx.assert(Math.abs(s.source.duration-2)<.15&&!s.source.hasAudio,"Actual output is trimmed, accelerated and silent");ctx.assert(s.artifacts.items.length===1&&s.source.revision!==original.revision,"Overwrite keeps one main artifact at the original path");ctx.assert(!s.calls.includes('submit'),"No AI submission occurred");},screenshot:{name:"overwrite-saved",requireText:["真实剪辑和保存"]},
});
await ctx.prove("另存为保留两版并通知宿主,窄面板仍能操作",{
voiceover:"另存为会保留原视频和带独立名称的新版本,并通知素材宿主。缩窄面板后,工具栏和保存按钮仍能操作。",
- action:async()=>{await tool(ctx,'flip');await click(ctx,'saveCopy');await ctx.waitFor("proofSaved.some(s=>s.saveMode==='copy')",{timeoutMs:30000});await ctx.client.send("Emulation.setDeviceMetricsOverride",{width:480,height:800,deviceScaleFactor:1,mobile:false});},
- assert:async()=>{const s=await ctx.eval("fetch('/witness').then(r=>r.json())",{awaitPromise:true});ctx.assert(s.artifacts.items.length===2,"Copy preserves both distinct artifacts");ctx.assert(s.artifacts.items.some(a=>a.path.includes('-edited-')),"Copy has a distinct filename");await ctx.waitFor(`${doc}.documentElement.scrollWidth <= ${doc}.documentElement.clientWidth`);ctx.assert(await ctx.eval(`${el('saveCopy')}.getBoundingClientRect().right<=480`),"Save remains reachable at narrow width");},screenshot:{name:"copy-narrow",requireText:["视频工作台"]},
+ action:async()=>{await tool(ctx,'flip');await click(ctx,'downloadVideo');await ctx.waitFor(`!document.querySelector('#proofCopy').hidden`);await ctx.trustedClick('#proofCopy');await ctx.waitFor("proofSaved.some(s=>s.saveMode==='copy')",{timeoutMs:30000});await ctx.client.send("Emulation.setDeviceMetricsOverride",{width:480,height:800,deviceScaleFactor:1,mobile:false});},
+ assert:async()=>{const s=await ctx.eval("fetch('/witness').then(r=>r.json())",{awaitPromise:true});ctx.assert(s.artifacts.items.length===2,"Copy preserves both distinct artifacts");ctx.assert(s.artifacts.items.some(a=>a.path.includes('-edited-')),"Copy has a distinct filename");await ctx.waitFor(`${doc}.documentElement.scrollWidth <= ${doc}.documentElement.clientWidth`);ctx.assert(await ctx.eval(`${el('downloadVideo')}.getBoundingClientRect().right<=480`),"Save remains reachable at narrow width");},screenshot:{name:"copy-narrow",requireText:["真实剪辑和保存"]},
});
}}]};
diff --git a/evals/support/shared-ui-core-fixture.mjs b/evals/support/shared-ui-core-fixture.mjs
index 1036117a9..fff4809eb 100644
--- a/evals/support/shared-ui-core-fixture.mjs
+++ b/evals/support/shared-ui-core-fixture.mjs
@@ -2,7 +2,8 @@
function mountLibrary() {
const ui = window.ipolloworkUi;
const h = ui.React.createElement;
- const required = ['Table','Card','Avatar','Image','Field','InputGroup','Autocomplete','Checkbox','RadioGroup','Switch','Toggle','ToggleGroup','DropdownMenu','ContextMenu','Command','AlertDialog','Sheet','Popover','HoverCard','Tooltip','Alert','Badge','Progress','Skeleton','Empty','Tabs','Accordion','Collapsible','ScrollArea','Separator','DescriptiveButton'];
+ const required = ['Table','Card','Avatar','Image','Field','InputGroup','Autocomplete','Checkbox','RadioGroup','Switch','Toggle','ToggleGroup','DropdownMenu','ContextMenu','Command','AlertDialog','Sheet','Popover','HoverCard','Tooltip','Alert','Badge','Progress','Skeleton','Empty','Tabs','Accordion','Collapsible','ScrollArea','Separator','DescriptiveButton','Button','Icon'];
+ if (Number(ui.version.split('.')[1]) < 4) throw Error('请更新客户端 UI 运行时');
if (required.some(name => typeof ui[name] !== 'function')) throw Error('缺少核心组件');
window.__libraryRequired = required;
function Demo() {
@@ -11,12 +12,25 @@ function mountLibrary() {
const [name, setName] = ui.React.useState('插件草稿');
const [error, setError] = ui.React.useState(false);
const [confirmOpen, setConfirmOpen] = ui.React.useState(false);
+ const [loading, setLoading] = ui.React.useState(false);
+ const [iconClicks, setIconClicks] = ui.React.useState(0);
const [dismissed, setDismissed] = ui.React.useState([]);
const options = ['电影','产品','角色'];
const sample = (title, ...children) => h('article', { 'data-sample': title }, h('h2', null, title), ...children);
const action = (id, title, fn) => h(ui.Button, { id, onClick: fn }, title);
const command = () => h(ui.Command, { items: options }, h(ui.CommandInput, { 'aria-label': '搜索命令' }), h(ui.CommandEmpty, null, '无匹配命令'), h(ui.CommandList, null, item => h(ui.CommandItem, { key: item, value: item, onClick: () => setReceipt('命令:'+item) }, item)));
const groups = {
+ '图标与按钮': [
+ sample('公共 Lucide 图标', h('div', {id:'icon-gallery',style:{display:'flex',flexWrap:'wrap',gap:12}}, ui.ICON_NAMES.map(name=>h('span',{key:name,title:name},h(ui.Icon,{name,label:name}))))),
+ sample('S / M / L', ...['s','m','l'].map(size=>h(ui.Icon,{key:size,name:'Search',size,label:'搜索 '+size}))),
+ sample('Button 图标组合', h('div',{style:{display:'flex',flexWrap:'wrap',gap:8}},
+ ...[['sm','s'],['default','m'],['lg','l']].map(([size,iconSize])=>h(ui.Button,{key:size,id:'icon-leading-'+size,size,onClick:()=>{setIconClicks(iconClicks+1);setReceipt('前置图标:'+size);}},h(ui.Icon,{name:'Plus',size:iconSize,'data-icon':'inline-start'}),'新建')),
+ h(ui.Button,{id:'icon-trailing',variant:'outline',onClick:()=>setReceipt('后置图标已执行')},'下载',h(ui.Icon,{name:'Download','data-icon':'inline-end'})),
+ h(ui.Button,{id:'icon-only',size:'icon',variant:'ghost','aria-label':'图标搜索',onClick:()=>setReceipt('纯图标已执行')},h(ui.Icon,{name:'Search'})),
+ h(ui.Button,{id:'icon-disabled',disabled:true,onClick:()=>setReceipt('禁用不应执行')},h(ui.Icon,{name:'Trash2','data-icon':'inline-start'}),'禁用'),
+ h(ui.Button,{id:'icon-loading',disabled:loading,'aria-busy':loading,onClick:()=>{setLoading(true);setReceipt('正在保存');setTimeout(()=>{setLoading(false);setReceipt('保存完成');},1200);}},h(ui.Icon,{name:loading?'LoaderCircle':'Save','data-icon':'inline-start',className:loading?'animate-spin':undefined}),loading?'保存中':'保存')),
+ h('output',{id:'icon-click-count'},String(iconClicks))),
+ ],
'输入与选择': [
sample('Field / Label / Input', h(ui.Field, null, h(ui.FieldLabel, { htmlFor: 'name' }, '项目名称'), h(ui.Input, { id: 'name', value: name, 'aria-invalid': error, 'aria-describedby': error ? 'name-error' : undefined, onChange: e => { setName(e.target.value); setError(false); } }), error ? h(ui.FieldError, { id: 'name-error' }, '保存失败,草稿仍保留') : h(ui.FieldDescription, null, '输入内容由插件管理')), action('fail','模拟失败',()=>setError(true)), action('retry','重试',()=>{setError(false);setReceipt('保存:'+name);})),
sample('Textarea / InputGroup', h(ui.Textarea, { 'aria-label': '补充说明', defaultValue: '多行说明' }), h(ui.InputGroup, null, h(ui.InputGroupAddon, null, '搜索'), h(ui.InputGroupInput, { 'aria-label': '分组输入', placeholder: '搜索项目' }))),
diff --git a/evals/support/video-workbench-fixture.ts b/evals/support/video-workbench-fixture.ts
index 34187c47e..e6e13b298 100644
--- a/evals/support/video-workbench-fixture.ts
+++ b/evals/support/video-workbench-fixture.ts
@@ -4,30 +4,45 @@ import { mkdtemp, readFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { execFileSync } from "node:child_process";
+import { createRequire } from "node:module";
+import { buildPluginRuntime } from "../../packages/ui/build-plugin-runtime.mjs";
import { callVideoGenerationAction } from "../../apps/server/src/extensions/video-generation";
import { listSessionArtifacts } from "../../apps/server/src/session-artifacts";
import { inspectLocalVideo } from "../../apps/server/src/extensions/video-local-edit";
import type { ServerConfig } from "../../apps/server/src/types";
+const port = Number(process.env.IPOLLOWORK_VIDEO_CONSOLE_PORT || 5278);
let root=await mkdtemp(join(tmpdir(),"ipollowork-video-workbench-proof-"));
const seed=()=>execFileSync(process.env.HYPERFRAMES_FFMPEG_PATH||"ffmpeg",["-v","error","-f","lavfi","-i","testsrc2=s=640x360:r=24:d=6","-f","lavfi","-i","sine=frequency=440:duration=6","-c:v","libx264","-threads","1","-c:a","aac","-shortest",join(root,"source.mp4")],{windowsHide:true});
seed();
const workspace={id:"proof",name:"Proof",path:root,preset:"starter",workspaceType:"local"} satisfies ServerConfig["workspaces"][number];
-const config:ServerConfig={host:"127.0.0.1",port:5278,token:"fixture",hostToken:"fixture",configPath:join(root,"server.json"),approval:{mode:"auto",timeoutMs:0},corsOrigins:[],workspaces:[workspace],authorizedRoots:[root],readOnly:false,startedAt:Date.now(),tokenSource:"generated",hostTokenSource:"generated",logFormat:"pretty",logRequests:false};
+const config:ServerConfig={host:"127.0.0.1",port,token:"fixture",hostToken:"fixture",configPath:join(root,"server.json"),approval:{mode:"auto",timeoutMs:0},corsOrigins:[],workspaces:[workspace],authorizedRoots:[root],readOnly:false,startedAt:Date.now(),tokenSource:"generated",hostTokenSource:"generated",logFormat:"pretty",logRequests:false};
const context={workspaceId:workspace.id,sessionId:"proof"};
let calls:string[]=[];
+const uiMode = process.env.IPOLLOWORK_VIDEO_UI_MODE === "bundled" ? "bundled" : "host";
+const { build } = createRequire(new URL("../../packages/ui/package.json", import.meta.url))("esbuild");
+const sharedRuntime = await buildPluginRuntime(uiMode);
+const adapter = await build({ entryPoints: [new URL("../../apps/app/src/react-app/plugin-ui/shared-ui-runtime.ts", import.meta.url).pathname], bundle:true, write:false, format:"iife", globalName:"hostAdapter", platform:"browser", target:"es2022", plugins:[{name:"fixture-host-runtime",setup(builder) {
+ builder.onResolve({filter:/^virtual:ipollowork-plugin-ui-runtime$/},()=>({path:"runtime",namespace:"fixture"}));
+ builder.onLoad({filter:/.*/,namespace:"fixture"},()=>({contents:JSON.stringify(sharedRuntime.script),loader:"json"}));
+}}] });
+const { withSharedUiRuntime } = createRequire(import.meta.url)("node:vm").runInNewContext(adapter.outputFiles[0].text + ";hostAdapter");
+function studioHtml(source: string) {
+ return withSharedUiRuntime(source).replace('data-ipw-runtime="host"', `data-ipw-runtime="${uiMode}"`);
+}
const launch={intent:"edit-video",requestId:crypto.randomUUID(),source:{path:"source.mp4",name:"source.mp4",kind:"workspace-file",preview:"video"}};
-const hostHtml=`
视频工作台本地剪辑验证视频工作台验证 · 真实剪辑和保存 · 未绑定 AI 模型`;
-const server=Bun.serve({hostname:"127.0.0.1",port:5278,async fetch(request){const url=new URL(request.url);try{
+const server=Bun.serve({hostname:"127.0.0.1",port,async fetch(request){const url=new URL(request.url);try{
if(url.pathname==="/reset"&&request.method==="POST"){root=await mkdtemp(join(tmpdir(),"ipollowork-video-workbench-proof-"));workspace.path=root;config.configPath=join(root,"server.json");config.authorizedRoots=[root];calls=[];seed();return Response.json({ok:true});}
- if(url.pathname==="/studio")return new Response(await readFile(new URL("../../examples/plugin-packages/media-studio/ui/video-console.html",import.meta.url)),{headers:{"content-type":"text/html;charset=utf-8"}});
+ if(url.pathname==="/studio")return new Response(studioHtml(await readFile(new URL("../../examples/plugin-packages/media-studio/ui/video-console.html",import.meta.url),"utf8")),{headers:{"content-type":"text/html;charset=utf-8"}});
if(url.pathname==="/setup")return Response.json({root,launch});
if(url.pathname==="/witness"){const artifacts=await listSessionArtifacts(config,workspace.id,"proof");return Response.json({calls,artifacts,source:await inspectLocalVideo(workspace,"source.mp4")});}
if(url.pathname==="/action"){const {name,arguments:args}=await request.json();if(!["status","jobs","read","inspect","local-edit","import"].includes(name))throw new Error("Billable actions are disabled in this proof");calls.push(name);const result=await callVideoGenerationAction(config,{read:async()=>({})},name,args,context);return Response.json({content:[],structuredContent:result.result});}
diff --git a/evals/voiceovers/chat-compact-preview.md b/evals/voiceovers/chat-compact-preview.md
new file mode 100644
index 000000000..44b7ac8a7
--- /dev/null
+++ b/evals/voiceovers/chat-compact-preview.md
@@ -0,0 +1,11 @@
+# chat-compact-preview — 主对话区更紧凑、对齐且图片适度预览
+
+1. 正文、文件卡片与输入框左边缘对齐,用户气泡右边缘与输入框对齐。
+
+2. 消息、状态提示和文件卡片之间的空白减少,正文仍然舒适易读。
+
+3. 图片默认完整等比显示,最大宽约 420px、高约 260px,点击查看大图,再收起回到预览。
+
+4. 气泡内边距、文件卡片图标和空输入框更轻巧,窄窗口下依然对齐且没有横向溢出。
+
+5. 正文和输入框采用共享13/20、标题14/20、状态12/18,代码与正文同字号;段落8px、标题前16px、列表项4px,强调只改变字重。
diff --git a/evals/voiceovers/conversation-active-state.md b/evals/voiceovers/conversation-active-state.md
new file mode 100644
index 000000000..86895a0da
--- /dev/null
+++ b/evals/voiceovers/conversation-active-state.md
@@ -0,0 +1,9 @@
+# conversation-active-state — 当前状态和稳定流式正文
+
+1. 正在思考时只有思考状态脉冲;工具运行时切换到当前工具状态,取消重复的思考占位。
+
+2. 正文开始输出时显示正在回复,文字一直留在同一个正文节点,后续工具和结束都不会把它重新挂载。
+
+3. 导出等后处理仍在进行时显示正在收尾;任务结束或中断后,历史状态和工具停止动画。
+
+4. 图片文件卡片使用横向小缩略图,完整等比显示;标题最多两行,白色背景、细边框、无投影,悬停出现轻量图标按钮。
diff --git a/evals/voiceovers/conversation-streaming-result.md b/evals/voiceovers/conversation-streaming-result.md
index c72a583bf..b47bce393 100644
--- a/evals/voiceovers/conversation-streaming-result.md
+++ b/evals/voiceovers/conversation-streaming-result.md
@@ -1,8 +1,8 @@
# Conversation streaming: stable final answer
-1. Send a request that makes the assistant explain what it is doing, run a command, and then answer. The live explanation and running command appear in order beneath the process heading; the raw command remains optional, alongside a subtle animated thinking ellipsis. The process folds when the answer is complete.
+1. Send a request that makes the assistant explain what it is doing, run a command, and then answer. The live explanation and running command appear in order beneath the process heading; the raw command remains optional, with only the current tool state pulsing. The process folds when the answer is complete.
-2. When the assistant starts its final answer, the answer appears below the progress area and grows in place as text streams in. Once the text is done but the run is still ending, the process row says it is finishing and keeps counting time.
+2. When the assistant starts its final answer, the answer appears below the progress area and grows in place as text streams in. While text is still streaming, the process row says it is responding and keeps counting time.
3. After the run completes, the elapsed time freezes beside the processed command count and the process details fold away. The final answer remains visible, and a follow-up entered while the run is busy stays queued for the next run.
@@ -26,7 +26,7 @@
13. When an inline preview cannot be matched to a saved output, the preview stays labeled as a preview and the separate saved-file card remains available.
-14. If one tool step fails while the assistant continues, the result area still says it is preparing the result. It does not show a separate failed-step notice or raw tool output in the main conversation.
+14. If one tool step fails while the assistant continues, the current process state still says it is thinking. It does not show a separate failed-step notice or raw tool output in the main conversation.
15. During an automatic retry, the conversation shows a calm retry status and countdown. The transport error remains in collapsed technical details.
@@ -40,10 +40,10 @@
20. Expanding a completed run shows icon-labeled action groups for file inspection and command execution. Each group starts closed, opens to reveal its original steps, and can be folded again without hiding the final answer.
-21. OpenCode keeps only one thinking label while continuing after an earlier answer, then shows the same gently animated letters and dots before its first assistant part, while reasoning, and alongside a running command.
+21. OpenCode keeps only one thinking label while continuing after an earlier answer, then shows thinking before its first assistant part. During reasoning and tools, only the current state pulses.
-22. OpenCode text and command summaries append in arrival order without moving earlier text or creating a premature result area. When the run completes, its process folds and the final answer remains visible.
+22. OpenCode text streams in stable body nodes while tools appear in the process area. Later tools and completion preserve those text nodes; completion folds the process.
23. After the model finishes generating the video source, the process still says it is in progress while the iPolloWork app exports the MP4, instead of reporting the task as complete too early.
-24. Returning to the conversation input, two typed lines and the placeholder use 14-pixel text with the same one-and-a-half line spacing as ordinary conversation text.
+24. Returning to the conversation input, two typed lines and the placeholder use shared 13-pixel text with 20-pixel line spacing as ordinary conversation text.
diff --git a/evals/voiceovers/shared-ui-core.md b/evals/voiceovers/shared-ui-core.md
index 95aef8813..c35f1922d 100644
--- a/evals/voiceovers/shared-ui-core.md
+++ b/evals/voiceovers/shared-ui-core.md
@@ -13,3 +13,5 @@
5. 关闭按钮使用同一份共享实现。在亮暗主题、不同字号、窄窗口和缩放下,图标保持居中;鼠标点击和键盘都能关闭提示。
6. 信息、成功、警告和错误提醒使用对应的语义浅背景,没有边框。切换亮暗主题后仍可区分;关闭和重试可操作,开发打包与宿主注入显示一致。
+
+7. 插件直接使用公共图标,S、M、L保持14、16、20像素。前置、后置和纯图标按钮共用Button,28、32、36像素下仍对齐;点击、键盘、禁用和加载反馈在亮暗窄窗口及两种接入方式中一致。
diff --git a/examples/plugin-packages/media-studio/ipollowork.plugin.json b/examples/plugin-packages/media-studio/ipollowork.plugin.json
index eaf300f65..978c039f0 100644
--- a/examples/plugin-packages/media-studio/ipollowork.plugin.json
+++ b/examples/plugin-packages/media-studio/ipollowork.plugin.json
@@ -18,7 +18,7 @@
"prompt": "打开素材工作台,生成或编辑图片、视频。"
},
"package": {
- "version": "1.0.4",
+ "version": "1.0.5",
"publisher": {
"id": "ipollowork",
"name": "iPolloWork"
diff --git a/examples/plugin-packages/media-studio/ui/video-console.html b/examples/plugin-packages/media-studio/ui/video-console.html
index 88dee0814..44e6b2597 100644
--- a/examples/plugin-packages/media-studio/ui/video-console.html
+++ b/examples/plugin-packages/media-studio/ui/video-console.html
@@ -2,23 +2,25 @@
+
视频工作台
@@ -156,7 +157,7 @@
-