Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .codex/skills/ipollowork-plugin-ui/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.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.5.0`。使用本轮颜色与字体规范时调用 `requireRuntime(1, required, 3)`,第三参数检查最低 minor;使用公共 Icon 时调用 `requireRuntime(1, ['Button', 'Icon'], 4)`,尺寸 S/M/L 为14/16/20px,Button 前后图标直接作为 children 组合,纯图标按钮必须有 aria-label。required 列出实际使用的全部函数导出(包括子组件)。共享 Badge 的 info/success/warning 语义变体和前置图标组合要求 `requireRuntime(1, ['Badge', 'Icon'], 5)`。旧版本或缺组件时停止初始化并提示更新客户端。不支持完整 semver 范围、最低 patch 或 manifest UI 版本协商,也不自动下载另一份组件库。

1.2 核心扩展及 Alert/Toast 浅背景已本地整合到 Carrie,未提交、推送或发布;隔离分支仍保留。核心样板以浏览器沙箱验证,真实客户端安装验证另记在接入契约,不把两者等同。签名包上传、打包客户端和跨客户端版本升级未验收。推荐目录的 62 个条目不等于运行时已提供 62 个独立组件。

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ Select 保存中禁用并关联字段错误;风格提示失败恢复持久化

## 版本兼容规则

当前 `UI_RUNTIME_VERSION = '1.4.0'`(1.4新增公共Icon;1.3规范保持兼容);独立于客户端与插件版本。1.3增加公共颜色/字体变量、保证七档字号类生成并增加最低minor检查;保留原v1组件入口与Props。短片包0.1.3采用新规范,插件ID和数据路径不变。生产业务脚本不包含组件实现;旧体积统计属于1.2快照,不当作本轮产物实测。
当前 `UI_RUNTIME_VERSION = '1.5.0'`(1.5新增Badge的info/success/warning语义变体,使用时调用 `requireRuntime(1, ['Badge', 'Icon'], 5)`;1.4新增公共Icon;1.3规范保持兼容);独立于客户端与插件版本。1.3增加公共颜色/字体变量、保证七档字号类生成并增加最低minor检查;保留原v1组件入口与Props。短片包0.1.3采用新规范,插件ID和数据路径不变。生产业务脚本不包含组件实现;旧体积统计属于1.2快照,不当作本轮产物实测。

| 运行时情况 | 当前代码行为 | 插件要求 |
| --- | --- | --- |
Expand Down
22 changes: 18 additions & 4 deletions apps/app/src/app/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -789,13 +789,27 @@ html:lang(zh).ipollowork-electron.ipollowork-platform-mac .media-composer-contro
.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%; }
.artifact-file-shell { --ring:var(--primary); display:flex; align-items:center; gap:8px; min-width:0; width:min(100%,360px); min-height:56px; padding:8px 10px; border:1px solid var(--border); border-radius:10px; background:var(--background); box-shadow:none; }
.artifact-file-shell:has(> .chat-output-card:not(:disabled):hover) { background:var(--background); border-color:var(--primary); }
.artifact-file-shell:has(> .chat-output-card:not(:disabled):active) { background:color-mix(in srgb,var(--muted) 65%,var(--background)); }
.artifact-file-shell:has(> .chat-output-card:focus-visible) { outline:2px solid var(--ring); outline-offset:2px; }
.artifact-file-shell > .chat-output-card { flex:1; width:auto; min-width:0; min-height:36px; padding:0; border:0; border-radius:0; background:transparent; }
.artifact-file-shell > .chat-output-card:hover, .artifact-file-shell > .chat-output-card:active { background:transparent; }
.artifact-file-shell > .chat-output-card:focus-visible { box-shadow:none; }
.artifact-file-shell .chat-output-title { display:block; width:100%; white-space:nowrap; font-size:var(--ui-control-size); line-height:var(--ui-control-line); }
.artifact-file-shell .chat-output-icon:not([data-artifact-thumbnail]) { width:36px; height:36px; overflow:visible; border-radius:0; background:transparent; }
.artifact-file-shell .artifact-file-document { width:26px; height:30px; stroke-width:1.3; }
.artifact-file-shell .chat-output-icon[data-artifact-thumbnail] { width:52px; height:36px; }
.artifact-file-extension { position:absolute; right:0; bottom:2px; max-width:44px; overflow:hidden; padding:1px 3px; border-radius:3px; background:var(--muted); color:var(--muted-foreground); font-size:9px; font-weight:600; line-height:12px; }
.artifact-file-meta { display:flex; min-width:0; white-space:nowrap; font-size:var(--ui-meta-size); line-height:var(--ui-meta-line); color:var(--muted-foreground); }
.artifact-file-details { min-width:0; overflow:hidden; text-overflow:ellipsis; }
.artifact-file-saved { flex-shrink:0; white-space:pre; }
.artifact-file-meta .chat-output-description { display:inline; font:inherit; }
.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; }
[data-testid="artifact-file-actions"] button:not(:disabled):active { background:var(--muted); color:var(--foreground); }

/* 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 { display:grid; min-width:0; grid-template-columns:repeat(auto-fit,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; }
Expand Down
62 changes: 31 additions & 31 deletions apps/app/src/components/chat/artifact.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/** @jsxImportSource react */

import type { UIMessage } from "ai";
import { ChevronRight, Copy, Download, FileOutput, Folder, FolderOpen, Loader2, MessageSquarePlusIcon, MoreHorizontalIcon, RefreshCw, Search, X } from "lucide-react";
import { ChevronRight, Copy, Download, FileOutput, FileText, Folder, FolderOpen, Loader2, MessageSquarePlusIcon, MoreHorizontalIcon, RefreshCw, Search, X } from "lucide-react";
import { useCallback, useMemo, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Dialog, DialogContent, DialogTitle, DialogDescription } from "@/components/ui/dialog";
Expand Down Expand Up @@ -44,7 +44,7 @@ import { toast } from "@/components/ui/sonner";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { cn, formatFileSize } from "@/lib/utils";
import {
type ArtifactInteractionContext,
type ArtifactItem,
Expand Down Expand Up @@ -295,9 +295,10 @@ function ArtifactButton({ artifact, displayName, client, workspaceId, sessionId,
? artifact
: { ...artifact, name: presentedName, target: { ...artifact.target, name: presentedName } };
const title = presentedName;
const typeLabel = getArtifactTypeLabel(studioTarget?.surface === "video" ? "video" : artifact.type);
const typeLabel = getArtifactTypeLabel(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");
const hasThumbnail = /\.(png|jpe?g|webp|gif|avif|svg|mp4|mov|webm)$/i.test(artifact.path);

const download = async () => {
if (!client || !workspaceId || artifact.target.kind !== "file" || downloading) return;
Expand Down Expand Up @@ -325,33 +326,33 @@ function ArtifactButton({ artifact, displayName, client, workspaceId, sessionId,

const content = (
<>
<span ref={thumbnailRoot} className="contents"><DescriptiveButtonIcon className={cn("chat-output-icon")} data-artifact-thumbnail={/\.(png|jpe?g|webp|gif|avif|svg|mp4|mov|webm)$/i.test(artifact.path) ? artifact.path : undefined}>
<ArtifactIcon className={cn("shrink-0", "size-4")} type={artifact.type} />
<span ref={thumbnailRoot} className="contents"><DescriptiveButtonIcon className={cn("chat-output-icon")} data-artifact-thumbnail={hasThumbnail ? artifact.path : undefined}>
{hasThumbnail ? <ArtifactIcon className="size-4 shrink-0" type={artifact.type} /> : (
<>
<FileText className="artifact-file-document size-8" aria-hidden="true" />
<span className="artifact-file-extension" aria-hidden="true">{extension}</span>
</>
)}
</DescriptiveButtonIcon></span>
<DescriptiveButtonContent className={cn("min-w-0", "chat-output-content")}>
<div className="flex min-w-0 items-center gap-1.5">
<DescriptiveButtonTitle className={cn("chat-output-title")} data-testid="artifact-file-title" title={presentedName}>{title}</DescriptiveButtonTitle>
</div>
{(
<DescriptiveButtonDescription className={cn("chat-output-description")} data-testid="artifact-file-description">
{extension}
<DescriptiveButtonDescription className="artifact-file-meta" data-testid="artifact-file-description">
<span className="artifact-file-details">
<span className="chat-output-description">{typeLabel}</span>
{artifact.target.size !== undefined ? <span> · {formatFileSize(artifact.target.size)}</span> : null}
</span>
{artifact.target.exists === true ? <span className="artifact-file-saved" data-testid="artifact-file-saved"> · {t("artifact.status_saved")}</span> : null}
</DescriptiveButtonDescription>
)}
</DescriptiveButtonContent>
</>
);

if (!canActivate && !(client && workspaceId && sessionId && artifact.target.kind === "file")) {
return (
<div data-testid="artifact-file-card"
data-artifact-path={artifact.path} className={cn("flex h-auto max-w-full items-center justify-start gap-1.5 rounded-xl border text-left whitespace-nowrap", "chat-output-card")}>
{content}
</div>
);
}

return (
<div className={cn("group/output relative max-w-full", "min-h-16 w-full min-w-0")} data-testid="artifact-file-shell">
<div className="artifact-file-shell" data-testid="artifact-file-shell">
<DescriptiveButton
disabled={!canActivate}
data-testid="artifact-file-card"
Expand All @@ -371,20 +372,19 @@ function ArtifactButton({ artifact, displayName, client, workspaceId, sessionId,
{content}
</DescriptiveButton>
{(
<div className="pointer-events-none absolute right-3 top-1/2 flex -translate-y-1/2 items-center gap-0.5 bg-background opacity-0 transition-opacity group-hover/output:pointer-events-auto group-hover/output:opacity-100 group-focus-within/output:pointer-events-auto group-focus-within/output:opacity-100" data-testid="artifact-file-actions">
{canDownload ? (
<Button
variant="ghost"
size="icon-sm"
className="size-7 rounded-md bg-transparent text-muted-foreground/70 hover:bg-muted/40 hover:text-foreground [&_svg]:stroke-[1.5]"
aria-label={t("artifact.download_artifact")}
title={t("artifact.download_artifact")}
disabled={downloading}
onClick={() => void download()}
>
{downloading ? <Loader2 className="size-3.5 animate-spin" /> : <Download className="size-3.5" />}
</Button>
) : null}
<div className="flex shrink-0 items-center gap-0.5" data-testid="artifact-file-actions">
<Button
variant="ghost"
size="icon-sm"
className="size-7 rounded-md bg-transparent text-muted-foreground/70 hover:bg-muted/40 hover:text-foreground [&_svg]:stroke-[1.5]"
aria-label={t("artifact.download_artifact")}
title={t("artifact.download_artifact")}
disabled={!canDownload || downloading}
aria-busy={downloading}
onClick={() => void download()}
>
{downloading ? <Loader2 className="size-3.5 animate-spin" /> : <Download className="size-3.5" />}
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={(
Expand Down
Loading
Loading