@zmzai/theme 0.3.1 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zmzai/theme",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "zmzai 全品牌设计系统 — Radix UI + framer-motion + Tailwind v4 + MiSans",
@@ -76,9 +76,9 @@
76
76
  "@radix-ui/react-select": "^2.1.0",
77
77
  "@radix-ui/react-tabs": "^1.1.0",
78
78
  "@radix-ui/react-tooltip": "^1.1.0",
79
+ "@tailwindcss/vite": "^4.0.0",
79
80
  "@types/react": "^19.0.0",
80
81
  "@types/react-dom": "^19.0.0",
81
- "@tailwindcss/vite": "^4.0.0",
82
82
  "@vitejs/plugin-react": "^4.3.0",
83
83
  "class-variance-authority": "^0.7.0",
84
84
  "clsx": "^2.1.0",
@@ -91,17 +91,18 @@
91
91
  "vite": "^5.4.0"
92
92
  },
93
93
  "dependencies": {
94
- "clsx": "^2.1.0",
95
- "tailwind-merge": "^2.5.0",
96
94
  "@radix-ui/react-dialog": "^1.1.0",
97
95
  "@radix-ui/react-dropdown-menu": "^2.1.0",
96
+ "@radix-ui/react-popover": "^1.1.23",
98
97
  "@radix-ui/react-select": "^2.1.0",
99
98
  "@radix-ui/react-tooltip": "^1.1.0",
100
99
  "class-variance-authority": "^0.7.0",
100
+ "clsx": "^2.1.0",
101
101
  "framer-motion": "^11.0.0",
102
+ "highlight.js": "^11.11.1",
102
103
  "react-markdown": "^10.1.0",
103
104
  "remark-gfm": "^4.0.1",
104
- "highlight.js": "^11.11.1"
105
+ "tailwind-merge": "^2.5.0"
105
106
  },
106
107
  "packageManager": "pnpm@10.34.5+sha512.a4ee05f2f73658255bd6a89859c065a45c28a57daefae2c893a168ee2b73168c37b91e83e57ea67654ad03f03031746430e8bce38e362e042605fb8abc80192e"
107
108
  }
@@ -0,0 +1,82 @@
1
+ "use client";
2
+
3
+ import { motion } from "framer-motion";
4
+
5
+ import { Icon } from "../../components/icon/Icon";
6
+ import "./artifact-card.css";
7
+
8
+ export interface ArtifactCardProps {
9
+ /** 产物路径(文件名) */
10
+ path: string;
11
+ /** 元信息行(如 "pptx · 1.2 MiB") */
12
+ meta: string;
13
+ /** 有预览时显示的提示角标 */
14
+ previewHint?: boolean;
15
+ /** 下载直链(无则不渲染下载按钮) */
16
+ downloadUrl?: string;
17
+ /** 点击卡片(打开预览) */
18
+ onOpen?: () => void;
19
+ }
20
+
21
+ /** MIME → 卡片短类型名;未知长类型取 subtype 末段。 */
22
+ export function shortContentType(contentType: string): string {
23
+ const type = contentType.split(";")[0]!.trim().toLowerCase();
24
+ const known: Record<string, string> = {
25
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation": "pptx",
26
+ "application/vnd.openxmlformats-officedocument.presentationml.slideshow": "ppsx",
27
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
28
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx",
29
+ "application/pdf": "pdf",
30
+ "application/json": "json",
31
+ "application/zip": "zip",
32
+ "text/html": "html",
33
+ "text/markdown": "md",
34
+ "text/plain": "txt",
35
+ "text/css": "css",
36
+ };
37
+ if (known[type]) return known[type]!;
38
+ const sub = type.split("/").pop() ?? "file";
39
+ if (sub.length <= 14) return sub;
40
+ const tail = sub.split(".").pop() ?? sub;
41
+ return tail.length <= 14 ? tail : "file";
42
+ }
43
+
44
+ export function formatBytes(value: number): string {
45
+ if (value < 1024) return `${value} B`;
46
+ if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KiB`;
47
+ return `${(value / (1024 * 1024)).toFixed(1)} MiB`;
48
+ }
49
+
50
+ /**
51
+ * ArtifactCard — 产物卡:路径 + 类型/大小 meta + 预览角标 + 下载。
52
+ *
53
+ * hover 抬升 + 印章红描边渐现(炫酷),入场淡入。
54
+ * 显式 grid 定位防超长 MIME 撑爆布局。
55
+ *
56
+ * @example
57
+ * <ArtifactCard path="报告.pptx" meta={`${shortContentType(m)} · ${formatBytes(b)}`} previewHint onOpen={open} downloadUrl={url} />
58
+ */
59
+ export function ArtifactCard({ path, meta, previewHint, downloadUrl, onOpen }: ArtifactCardProps) {
60
+ return (
61
+ <motion.button
62
+ type="button"
63
+ className="zmz-artifact-card"
64
+ onClick={onOpen}
65
+ initial={{ opacity: 0, y: 6 }}
66
+ animate={{ opacity: 1, y: 0 }}
67
+ transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
68
+ whileHover={{ y: -2 }}
69
+ >
70
+ <span className="zmz-artifact-name">{path}</span>
71
+ <span className="zmz-artifact-meta">{meta}</span>
72
+ <span className="zmz-artifact-actions">
73
+ {previewHint && <span className="zmz-artifact-preview-hint">预览</span>}
74
+ {downloadUrl && (
75
+ <a className="zmz-artifact-download" href={downloadUrl} onClick={(event) => event.stopPropagation()}>
76
+ <Icon name="download" size={12} />
77
+ </a>
78
+ )}
79
+ </span>
80
+ </motion.button>
81
+ );
82
+ }
@@ -0,0 +1,12 @@
1
+ /* ArtifactCard — 产物卡。token 依赖消费端 @theme。 */
2
+ .zmz-artifact-card { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 0.15rem 0.6rem; align-items: center; text-align: left; border: 1px solid var(--color-line); border-radius: var(--radius); background: var(--color-bg); padding: 0.6rem 0.75rem; cursor: pointer; font: inherit; color: inherit; transition: border-color 0.15s var(--ease-out), box-shadow 0.15s var(--ease-out); }
3
+ .zmz-artifact-card:hover { border-color: var(--color-accent); box-shadow: 0 4px 16px color-mix(in oklab, var(--color-accent) 12%, transparent); }
4
+ /* 显式 grid 定位:name/meta 固定在左列(1fr),actions 固定右列跨两行。
5
+ 自动放置曾把超长 MIME 文本(无空格、无断行)排进右列 auto 轨道,
6
+ 撑爆布局把名字列压成 0 → 文件名竖排逐字折行。 */
7
+ .zmz-artifact-name { grid-column: 1; grid-row: 1; font-family: var(--font-mono); font-size: 0.75rem; overflow-wrap: anywhere; }
8
+ .zmz-artifact-meta { grid-column: 1; grid-row: 2; font-size: 0.6875rem; color: var(--color-muted); overflow-wrap: anywhere; }
9
+ .zmz-artifact-actions { grid-column: 2; grid-row: 1 / span 2; display: flex; align-items: center; gap: 0.5rem; }
10
+ .zmz-artifact-preview-hint { font-family: var(--font-mono); font-size: 0.625rem; letter-spacing: 0.07em; text-transform: uppercase; color: var(--color-accent-strong); }
11
+ .zmz-artifact-download { display: inline-flex; color: var(--color-muted); }
12
+ .zmz-artifact-download:hover { color: var(--color-accent); }
@@ -0,0 +1 @@
1
+ export * from "./ArtifactCard";
@@ -0,0 +1,42 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { AnimatePresence, motion } from "framer-motion";
5
+
6
+ import { Icon } from "../../components/icon/Icon";
7
+ import { DiffView } from "../../components/diff-view";
8
+ import "./edit-card.css";
9
+
10
+ /**
11
+ * EditCard — 文件改动卡:路径 + 修订号头部,展开看彩色 diff。
12
+ *
13
+ * 展开内容用 theme DiffView 渲染,AnimatePresence 渐显。
14
+ *
15
+ * @example
16
+ * <EditCard path="src/app.ts" revision="rev_abc123" diff={unifiedDiff} />
17
+ */
18
+ export function EditCard({ path, revision, diff }: { path: string; revision: string; diff: string }) {
19
+ const [open, setOpen] = useState(false);
20
+ return (
21
+ <div className="zmz-edit-card">
22
+ <button type="button" className="zmz-edit-head" onClick={() => setOpen((value) => !value)}>
23
+ <Icon name="chevron-down" size={11} className={open ? "zmz-chevron open" : "zmz-chevron"} />
24
+ <span className="zmz-edit-path">{path}</span>
25
+ <small>{revision.slice(0, 12)}</small>
26
+ </button>
27
+ <AnimatePresence initial={false}>
28
+ {open && (
29
+ <motion.div
30
+ className="zmz-edit-body"
31
+ initial={{ opacity: 0, height: 0 }}
32
+ animate={{ opacity: 1, height: "auto" }}
33
+ exit={{ opacity: 0, height: 0 }}
34
+ transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
35
+ >
36
+ <DiffView diff={diff} />
37
+ </motion.div>
38
+ )}
39
+ </AnimatePresence>
40
+ </div>
41
+ );
42
+ }
@@ -0,0 +1,9 @@
1
+ /* EditCard — 文件改动卡。token 依赖消费端 @theme。 */
2
+ .zmz-edit-card { border: 1px solid var(--color-line); border-radius: var(--radius); background: var(--color-surface); overflow: hidden; }
3
+ .zmz-edit-head { display: flex; align-items: center; gap: 0.5rem; width: 100%; text-align: left; background: none; border: 0; cursor: pointer; padding: 0.55rem 0.75rem; font: inherit; color: inherit; }
4
+ .zmz-edit-head:hover { background: var(--color-surface-strong); }
5
+ .zmz-edit-path { font-family: var(--font-mono); font-size: 0.75rem; flex: 1; overflow-wrap: anywhere; }
6
+ .zmz-edit-head small { color: var(--color-muted); font-family: var(--font-mono); font-size: 0.625rem; }
7
+ .zmz-edit-body { overflow: hidden; padding: 0 0.6rem 0.6rem; }
8
+ .zmz-chevron { transition: transform 0.15s var(--ease-out); }
9
+ .zmz-chevron.open { transform: rotate(180deg); }
@@ -0,0 +1 @@
1
+ export * from "./EditCard";
@@ -49,6 +49,11 @@ export * from "./multi-step-loader";
49
49
  export * from "./timeline";
50
50
 
51
51
  // === Business (agent workflow) ===
52
+ export * from "./message";
53
+ export * from "./reasoning";
54
+ export * from "./artifact-card";
55
+ export * from "./edit-card";
56
+ export * from "./markdown";
52
57
  export * from "./markdown";
53
58
  export * from "./diff-view";
54
59
  export * from "./tool-card";
@@ -80,3 +85,4 @@ export * from "./loader";
80
85
  export * from "./skeleton";
81
86
  export * from "./empty-state";
82
87
  export * from "./carousel";
88
+ export * from "./model-selector";
@@ -0,0 +1,76 @@
1
+ "use client";
2
+
3
+ import { type ReactNode } from "react";
4
+ import { motion } from "framer-motion";
5
+
6
+ import { Badge } from "../../components/badge";
7
+ import "./message.css";
8
+
9
+ export interface MessageItemProps {
10
+ /** 发送方角色:决定头像配色与气泡方向 */
11
+ role: "user" | "assistant";
12
+ /** 头像字(单字,如 "你"/"使")或名字首字 */
13
+ avatar: string;
14
+ /** 显示名(meta 行) */
15
+ name: string;
16
+ /** 右侧状态(assistant 执行中/已完成) */
17
+ status?: { active: boolean };
18
+ /** 时间标签(HH:MM) */
19
+ time?: string | null;
20
+ /** 消息体(文本/执行树/工具组等任意内容) */
21
+ children: ReactNode;
22
+ /** 关闭入场动画(流式追加频繁时防闪) */
23
+ noMotion?: boolean;
24
+ }
25
+
26
+ /**
27
+ * MessageItem — 消息行:头像 + 名字/状态/时间 meta + 内容体。
28
+ *
29
+ * user 气泡为描边卡片,assistant 为开放排版(适合挂执行树)。
30
+ * 默认 framer-motion 淡入上移入场(流式追加传 noMotion 关闭)。
31
+ *
32
+ * @example
33
+ * <MessageItem role="assistant" avatar="使" name="Agent" status={{ active: true }}>
34
+ * <Markdown text={reply} />
35
+ * </MessageItem>
36
+ */
37
+ export function MessageItem({ role, avatar, name, status, time, children, noMotion }: MessageItemProps) {
38
+ const body = (
39
+ <>
40
+ <span className={`zmz-message-avatar ${role === "assistant" ? "assistant" : ""}`}>{avatar}</span>
41
+ <div className="zmz-message-column">
42
+ <div className="zmz-message-meta">
43
+ <strong>{name}</strong>
44
+ {status && <Badge variant={status.active ? "accent" : "success"} size="sm">{status.active ? "执行中" : "已完成"}</Badge>}
45
+ {time && <span className="zmz-message-time">{time}</span>}
46
+ </div>
47
+ {children}
48
+ </div>
49
+ </>
50
+ );
51
+ if (noMotion) return <div className={`zmz-message ${role}`}>{body}</div>;
52
+ return (
53
+ <motion.div
54
+ className={`zmz-message ${role}`}
55
+ initial={{ opacity: 0, y: 8 }}
56
+ animate={{ opacity: 1, y: 0 }}
57
+ transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }}
58
+ >
59
+ {body}
60
+ </motion.div>
61
+ );
62
+ }
63
+
64
+ /**
65
+ * MessageStream — 消息流容器:统一行距 + 滚动区。
66
+ *
67
+ * @example
68
+ * <MessageStream scrollRef={ref}>{messages.map(...)}</MessageStream>
69
+ */
70
+ export function MessageStream({ children, scrollRef, className }: { children: ReactNode; scrollRef?: React.Ref<HTMLDivElement>; className?: string }) {
71
+ return (
72
+ <div ref={scrollRef} className={`zmz-message-stream ${className ?? ""}`}>
73
+ {children}
74
+ </div>
75
+ );
76
+ }
@@ -0,0 +1 @@
1
+ export * from "./Message";
@@ -0,0 +1,11 @@
1
+ /* Message — 消息流。token 依赖消费端 @theme。 */
2
+ .zmz-message { display: flex; gap: 0.55rem; }
3
+ .zmz-message-avatar { display: grid; width: 1.5rem; height: 1.5rem; flex: 0 0 1.5rem; place-items: center; border-radius: 50%; background: var(--color-surface-strong); color: var(--color-accent-strong); font-family: var(--font-mono); font-size: 0.6rem; font-weight: 700; }
4
+ .zmz-message-avatar.assistant { background: var(--color-accent); color: var(--color-accent-ink); }
5
+ .zmz-message-column { display: grid; min-width: 0; flex: 1; gap: 0.55rem; }
6
+ .zmz-message-meta { display: flex; align-items: baseline; gap: 0.55rem; min-height: 1.5rem; }
7
+ .zmz-message-meta strong { font-size: 0.75rem; font-weight: 700; color: var(--color-ink); }
8
+ .zmz-message-time { margin-left: auto; color: var(--color-muted); font-family: var(--font-mono); font-size: 0.6rem; }
9
+ .zmz-message-content { min-width: 0; color: var(--color-ink); font-size: 0.875rem; line-height: 1.65; overflow-wrap: anywhere; }
10
+ .zmz-message.user .zmz-message-content { padding: 0.65rem 0.8rem; border: 1px solid var(--color-line); border-radius: var(--radius); background: var(--color-surface); }
11
+ .zmz-message-stream { display: grid; gap: 0.9rem; align-content: start; overflow-y: auto; min-height: 0; }
@@ -0,0 +1,282 @@
1
+ "use client";
2
+
3
+ import { useState, useRef, useEffect, type ReactNode } from "react";
4
+ import * as Popover from "@radix-ui/react-popover";
5
+ import { cn } from "../../utils/cn";
6
+ import type {
7
+ ModelSelectorData,
8
+ ModelSelectorValue,
9
+ ModelSelectorModel,
10
+ ModelSelectorChannel,
11
+ } from "./types";
12
+ import "./model-selector.css";
13
+
14
+ export interface ModelSelectorProps {
15
+ /** 完整数据(featured + channels) */
16
+ data: ModelSelectorData;
17
+ /** 当前选中值 */
18
+ value: ModelSelectorValue;
19
+ /** 选中回调 */
20
+ onChange: (value: ModelSelectorValue) => void;
21
+ /** 触发器占位文案 */
22
+ placeholder?: string;
23
+ /** 是否显示搜索框 */
24
+ searchable?: boolean;
25
+ /** 自定义类名(作用于触发器) */
26
+ className?: string;
27
+ }
28
+
29
+ /** 在全部模型中查找当前选中模型,返回 {channel, model} 用于触发器显示。 */
30
+ function findSelectedModel(
31
+ data: ModelSelectorData,
32
+ value: ModelSelectorValue,
33
+ ): { model: ModelSelectorModel | null; channel: ModelSelectorChannel | null } {
34
+ // 先在 featured 中找
35
+ const featured = data.featured.find((m) => m.id === value.model);
36
+ if (featured) return { model: featured, channel: null };
37
+ // 再在 channels 中找
38
+ for (const channel of data.channels) {
39
+ const model = channel.models.find((m) => m.id === value.model);
40
+ if (model) return { model, channel };
41
+ }
42
+ return { model: null, channel: null };
43
+ }
44
+
45
+ /** 过滤数据(按关键词)。 */
46
+ function filterData(
47
+ data: ModelSelectorData,
48
+ query: string,
49
+ ): ModelSelectorData {
50
+ const q = query.trim().toLowerCase();
51
+ if (!q) return data;
52
+ const matchModel = (m: ModelSelectorModel) =>
53
+ m.name.toLowerCase().includes(q) ||
54
+ (m.description?.toLowerCase().includes(q) ?? false) ||
55
+ (m.channel?.toLowerCase().includes(q) ?? false);
56
+
57
+ const featured = data.featured.filter(matchModel);
58
+ const channels = data.channels
59
+ .map((ch) => ({ ...ch, models: ch.models.filter(matchModel) }))
60
+ .filter((ch) => ch.models.length > 0);
61
+ return { featured, channels };
62
+ }
63
+
64
+ export function ModelSelector({
65
+ data,
66
+ value,
67
+ onChange,
68
+ placeholder = "选择模型",
69
+ searchable = false,
70
+ className,
71
+ }: ModelSelectorProps) {
72
+ const [open, setOpen] = useState(false);
73
+ const [expandedChannels, setExpandedChannels] = useState<Set<string>>(new Set());
74
+ const [search, setSearch] = useState("");
75
+ const searchRef = useRef<HTMLInputElement>(null);
76
+
77
+ // 打开时聚焦搜索框
78
+ useEffect(() => {
79
+ if (open && searchable) {
80
+ // 等 Popover 渲染完再聚焦
81
+ const timer = setTimeout(() => searchRef.current?.focus(), 50);
82
+ return () => clearTimeout(timer);
83
+ }
84
+ }, [open, searchable]);
85
+
86
+ // 关闭时清理搜索
87
+ useEffect(() => {
88
+ if (!open) {
89
+ setSearch("");
90
+ }
91
+ }, [open]);
92
+
93
+ const { model: selectedModel, channel: selectedChannel } = findSelectedModel(data, value);
94
+ const filtered = filterData(data, search);
95
+
96
+ const hasResults = filtered.featured.length > 0 || filtered.channels.length > 0;
97
+
98
+ function toggleChannel(channelId: string) {
99
+ setExpandedChannels((prev) => {
100
+ const next = new Set(prev);
101
+ if (next.has(channelId)) next.delete(channelId);
102
+ else next.add(channelId);
103
+ return next;
104
+ });
105
+ }
106
+
107
+ function selectModel(model: ModelSelectorModel, channel?: ModelSelectorChannel) {
108
+ onChange({ channel: channel?.id ?? model.channel, model: model.id });
109
+ setOpen(false);
110
+ }
111
+
112
+ function isSelected(modelId: string): boolean {
113
+ return value.model === modelId;
114
+ }
115
+
116
+ const triggerLabel = selectedModel?.name ?? placeholder;
117
+
118
+ return (
119
+ <Popover.Root open={open} onOpenChange={setOpen}>
120
+ <Popover.Trigger asChild>
121
+ <button
122
+ type="button"
123
+ className={cn("ms-trigger", className)}
124
+ aria-label="选择模型"
125
+ >
126
+ <span className={cn(!selectedModel && "ms-trigger-placeholder")}>
127
+ {triggerLabel}
128
+ </span>
129
+ <svg
130
+ width="12"
131
+ height="12"
132
+ viewBox="0 0 12 12"
133
+ fill="none"
134
+ className={cn("ms-trigger-chevron", open && "ms-open")}
135
+ >
136
+ <path
137
+ d="M3 4.5L6 7.5L9 4.5"
138
+ stroke="currentColor"
139
+ strokeWidth="1.5"
140
+ strokeLinecap="round"
141
+ strokeLinejoin="round"
142
+ />
143
+ </svg>
144
+ </button>
145
+ </Popover.Trigger>
146
+
147
+ <Popover.Portal>
148
+ <Popover.Content
149
+ className="ms-popover"
150
+ sideOffset={4}
151
+ align="start"
152
+ onCloseAutoFocus={(e) => e.preventDefault()}
153
+ >
154
+ {searchable && (
155
+ <div className="ms-search">
156
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" className="text-ink-3" aria-hidden>
157
+ <circle cx="7" cy="7" r="4.5" />
158
+ <path d="M10.5 10.5L13.5 13.5" />
159
+ </svg>
160
+ <input
161
+ ref={searchRef}
162
+ className="ms-search-input"
163
+ placeholder="搜索模型…"
164
+ value={search}
165
+ onChange={(e) => setSearch(e.target.value)}
166
+ onKeyDown={(e) => {
167
+ if (e.key === "Escape") {
168
+ setSearch("");
169
+ setOpen(false);
170
+ }
171
+ }}
172
+ />
173
+ </div>
174
+ )}
175
+
176
+ {!hasResults ? (
177
+ <div className="ms-empty">没有找到匹配的模型</div>
178
+ ) : (
179
+ <div>
180
+ {/* Featured 区 */}
181
+ {filtered.featured.length > 0 && (
182
+ <div className="ms-featured">
183
+ {filtered.featured.map((model) => (
184
+ <button
185
+ key={model.id}
186
+ type="button"
187
+ className={cn("ms-featured-item", isSelected(model.id) && "ms-selected")}
188
+ onClick={() => selectModel(model)}
189
+ >
190
+ {model.icon && (
191
+ <span className="ms-featured-icon">{model.icon}</span>
192
+ )}
193
+ <span className="ms-featured-body">
194
+ <span className="ms-featured-name">{model.name}</span>
195
+ {model.description && (
196
+ <span className="ms-featured-desc">{model.description}</span>
197
+ )}
198
+ </span>
199
+ {isSelected(model.id) && (
200
+ <svg className="ms-featured-check" width="14" height="14" viewBox="0 0 14 14" fill="none">
201
+ <path d="M3 7L6 10L11 4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
202
+ </svg>
203
+ )}
204
+ </button>
205
+ ))}
206
+ </div>
207
+ )}
208
+
209
+ {/* 分隔线 */}
210
+ {filtered.featured.length > 0 && filtered.channels.length > 0 && (
211
+ <div className="ms-separator" />
212
+ )}
213
+
214
+ {/* 渠道分组区 */}
215
+ {filtered.channels.map((channel) => {
216
+ const isOpen = expandedChannels.has(channel.id);
217
+ return (
218
+ <div key={channel.id} className="ms-channel">
219
+ <button
220
+ type="button"
221
+ className="ms-channel-trigger"
222
+ onClick={() => toggleChannel(channel.id)}
223
+ aria-expanded={isOpen}
224
+ >
225
+ {channel.icon && (
226
+ <span className="ms-channel-icon">{channel.icon}</span>
227
+ )}
228
+ <span>{channel.name}</span>
229
+ <span className="ms-channel-count">({channel.models.length})</span>
230
+ <svg
231
+ width="12"
232
+ height="12"
233
+ viewBox="0 0 12 12"
234
+ fill="none"
235
+ className={cn("ms-channel-chevron", isOpen && "ms-open")}
236
+ >
237
+ <path
238
+ d="M4.5 3L7.5 6L4.5 9"
239
+ stroke="currentColor"
240
+ strokeWidth="1.5"
241
+ strokeLinecap="round"
242
+ strokeLinejoin="round"
243
+ />
244
+ </svg>
245
+ </button>
246
+ {isOpen && (
247
+ <div className="ms-channel-models">
248
+ {channel.models.map((model) => (
249
+ <button
250
+ key={model.id}
251
+ type="button"
252
+ className={cn("ms-model-item", isSelected(model.id) && "ms-selected")}
253
+ onClick={() => selectModel(model, channel)}
254
+ >
255
+ {model.icon && (
256
+ <span className="ms-model-icon">{model.icon}</span>
257
+ )}
258
+ <span className="ms-model-name">{model.name}</span>
259
+ {model.meta && Object.values(model.meta).length > 0 && (
260
+ <span className="ms-model-meta">
261
+ {Object.values(model.meta).join(" · ")}
262
+ </span>
263
+ )}
264
+ {isSelected(model.id) && (
265
+ <svg className="ms-model-check" width="14" height="14" viewBox="0 0 14 14" fill="none">
266
+ <path d="M3 7L6 10L11 4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
267
+ </svg>
268
+ )}
269
+ </button>
270
+ ))}
271
+ </div>
272
+ )}
273
+ </div>
274
+ );
275
+ })}
276
+ </div>
277
+ )}
278
+ </Popover.Content>
279
+ </Popover.Portal>
280
+ </Popover.Root>
281
+ );
282
+ }
@@ -0,0 +1,8 @@
1
+ export { ModelSelector } from "./ModelSelector";
2
+ export type { ModelSelectorProps } from "./ModelSelector";
3
+ export type {
4
+ ModelSelectorData,
5
+ ModelSelectorValue,
6
+ ModelSelectorModel,
7
+ ModelSelectorChannel,
8
+ } from "./types";
@@ -0,0 +1,276 @@
1
+ /* ModelSelector — 二级模型选择器样式 */
2
+
3
+ .ms-trigger {
4
+ display: inline-flex;
5
+ align-items: center;
6
+ justify-content: space-between;
7
+ gap: 0.5rem;
8
+ height: 2.25rem;
9
+ padding: 0 0.75rem;
10
+ border-radius: var(--radius-sm, 2px);
11
+ border: 1px solid var(--color-line, oklch(0.85 0.02 80));
12
+ background: var(--color-bg, oklch(0.97 0.01 80));
13
+ font-size: 0.875rem;
14
+ font-weight: 500;
15
+ color: var(--color-ink, oklch(0.2 0.02 80));
16
+ cursor: pointer;
17
+ outline: none;
18
+ transition: border-color 0.15s ease;
19
+ min-width: 10rem;
20
+ }
21
+
22
+ .ms-trigger:hover {
23
+ border-color: oklch(0.4 0.02 80 / 0.4);
24
+ }
25
+
26
+ .ms-trigger:focus-visible {
27
+ border-color: var(--color-ink, oklch(0.2 0.02 80));
28
+ }
29
+
30
+ .ms-trigger-placeholder {
31
+ color: var(--color-ink-3, oklch(0.6 0.02 80));
32
+ }
33
+
34
+ .ms-trigger-chevron {
35
+ transition: transform 0.2s ease;
36
+ }
37
+
38
+ .ms-trigger-chevron.ms-open {
39
+ transform: rotate(180deg);
40
+ }
41
+
42
+ /* Popover content */
43
+ .ms-popover {
44
+ z-index: 50;
45
+ min-width: 18rem;
46
+ max-height: 28rem;
47
+ overflow-y: auto;
48
+ border-radius: var(--radius-sm, 2px);
49
+ border: 1px solid var(--color-line, oklch(0.85 0.02 80));
50
+ background: var(--color-bg, oklch(0.97 0.01 80));
51
+ box-shadow: 0 10px 38px -10px oklch(0.2 0.02 80 / 0.25), 0 10px 20px -15px oklch(0.2 0.02 80 / 0.15);
52
+ padding: 0.25rem;
53
+ }
54
+
55
+ .ms-popover:focus {
56
+ outline: none;
57
+ }
58
+
59
+ /* Search */
60
+ .ms-search {
61
+ display: flex;
62
+ align-items: center;
63
+ gap: 0.5rem;
64
+ padding: 0.5rem 0.5rem 0.25rem;
65
+ border-bottom: 1px solid var(--color-line, oklch(0.85 0.02 80));
66
+ margin-bottom: 0.25rem;
67
+ }
68
+
69
+ .ms-search-input {
70
+ flex: 1;
71
+ height: 1.75rem;
72
+ padding: 0 0.5rem;
73
+ border: 1px solid var(--color-line, oklch(0.85 0.02 80));
74
+ border-radius: var(--radius-sm, 2px);
75
+ background: var(--color-bg, oklch(0.97 0.01 80));
76
+ font-size: 0.8125rem;
77
+ color: var(--color-ink, oklch(0.2 0.02 80));
78
+ outline: none;
79
+ }
80
+
81
+ .ms-search-input::placeholder {
82
+ color: var(--color-ink-3, oklch(0.6 0.02 80));
83
+ }
84
+
85
+ .ms-search-input:focus {
86
+ border-color: var(--color-ink, oklch(0.2 0.02 80));
87
+ }
88
+
89
+ /* Featured section */
90
+ .ms-featured {
91
+ padding: 0.25rem 0;
92
+ }
93
+
94
+ .ms-featured-item {
95
+ display: flex;
96
+ align-items: flex-start;
97
+ gap: 0.625rem;
98
+ padding: 0.5rem 0.625rem;
99
+ border-radius: var(--radius-sm, 2px);
100
+ cursor: pointer;
101
+ transition: background 0.1s ease;
102
+ border: none;
103
+ background: none;
104
+ width: 100%;
105
+ text-align: left;
106
+ font: inherit;
107
+ color: inherit;
108
+ }
109
+
110
+ .ms-featured-item:hover,
111
+ .ms-featured-item:focus-visible {
112
+ background: var(--color-surface-2, oklch(0.93 0.01 80));
113
+ outline: none;
114
+ }
115
+
116
+ .ms-featured-item.ms-selected {
117
+ background: var(--color-surface-2, oklch(0.93 0.01 80));
118
+ }
119
+
120
+ .ms-featured-icon {
121
+ flex-shrink: 0;
122
+ margin-top: 0.125rem;
123
+ display: flex;
124
+ align-items: center;
125
+ }
126
+
127
+ .ms-featured-body {
128
+ flex: 1;
129
+ min-width: 0;
130
+ }
131
+
132
+ .ms-featured-name {
133
+ font-size: 0.875rem;
134
+ font-weight: 500;
135
+ color: var(--color-ink, oklch(0.2 0.02 80));
136
+ line-height: 1.3;
137
+ }
138
+
139
+ .ms-featured-desc {
140
+ margin-top: 0.125rem;
141
+ font-size: 0.75rem;
142
+ color: var(--color-ink-2, oklch(0.5 0.02 80));
143
+ line-height: 1.3;
144
+ }
145
+
146
+ .ms-featured-check {
147
+ flex-shrink: 0;
148
+ margin-top: 0.25rem;
149
+ color: var(--color-accent, oklch(0.45 0.18 25));
150
+ }
151
+
152
+ /* Separator */
153
+ .ms-separator {
154
+ margin: 0.375rem 0.5rem;
155
+ height: 1px;
156
+ background: var(--color-line, oklch(0.85 0.02 80));
157
+ }
158
+
159
+ /* Channel group */
160
+ .ms-channel {
161
+ padding: 0.125rem 0;
162
+ }
163
+
164
+ .ms-channel-trigger {
165
+ display: flex;
166
+ align-items: center;
167
+ gap: 0.5rem;
168
+ width: 100%;
169
+ padding: 0.4375rem 0.625rem;
170
+ border: none;
171
+ background: none;
172
+ cursor: pointer;
173
+ font: inherit;
174
+ font-size: 0.8125rem;
175
+ font-weight: 500;
176
+ color: var(--color-ink-2, oklch(0.5 0.02 80));
177
+ border-radius: var(--radius-sm, 2px);
178
+ transition: background 0.1s ease, color 0.1s ease;
179
+ }
180
+
181
+ .ms-channel-trigger:hover,
182
+ .ms-channel-trigger:focus-visible {
183
+ background: var(--color-surface-2, oklch(0.93 0.01 80));
184
+ color: var(--color-ink, oklch(0.2 0.02 80));
185
+ outline: none;
186
+ }
187
+
188
+ .ms-channel-icon {
189
+ flex-shrink: 0;
190
+ display: flex;
191
+ align-items: center;
192
+ }
193
+
194
+ .ms-channel-chevron {
195
+ margin-left: auto;
196
+ transition: transform 0.2s ease;
197
+ flex-shrink: 0;
198
+ }
199
+
200
+ .ms-channel-chevron.ms-open {
201
+ transform: rotate(90deg);
202
+ }
203
+
204
+ .ms-channel-count {
205
+ font-size: 0.6875rem;
206
+ color: var(--color-ink-3, oklch(0.6 0.02 80));
207
+ font-weight: 400;
208
+ }
209
+
210
+ /* Channel models list */
211
+ .ms-channel-models {
212
+ padding: 0.125rem 0 0.25rem 0;
213
+ }
214
+
215
+ .ms-model-item {
216
+ display: flex;
217
+ align-items: center;
218
+ gap: 0.5rem;
219
+ padding: 0.375rem 0.625rem 0.375rem 1.75rem;
220
+ border: none;
221
+ background: none;
222
+ cursor: pointer;
223
+ font: inherit;
224
+ font-size: 0.8125rem;
225
+ color: var(--color-ink-2, oklch(0.5 0.02 80));
226
+ border-radius: var(--radius-sm, 2px);
227
+ transition: background 0.1s ease;
228
+ width: 100%;
229
+ text-align: left;
230
+ }
231
+
232
+ .ms-model-item:hover,
233
+ .ms-model-item:focus-visible {
234
+ background: var(--color-surface-2, oklch(0.93 0.01 80));
235
+ color: var(--color-ink, oklch(0.2 0.02 80));
236
+ outline: none;
237
+ }
238
+
239
+ .ms-model-item.ms-selected {
240
+ color: var(--color-ink, oklch(0.2 0.02 80));
241
+ font-weight: 500;
242
+ }
243
+
244
+ .ms-model-icon {
245
+ flex-shrink: 0;
246
+ display: flex;
247
+ align-items: center;
248
+ }
249
+
250
+ .ms-model-name {
251
+ flex: 1;
252
+ min-width: 0;
253
+ overflow: hidden;
254
+ text-overflow: ellipsis;
255
+ white-space: nowrap;
256
+ }
257
+
258
+ .ms-model-check {
259
+ flex-shrink: 0;
260
+ color: var(--color-accent, oklch(0.45 0.18 25));
261
+ }
262
+
263
+ .ms-model-meta {
264
+ font-size: 0.6875rem;
265
+ color: var(--color-ink-3, oklch(0.6 0.02 80));
266
+ font-weight: 400;
267
+ flex-shrink: 0;
268
+ }
269
+
270
+ /* Empty state */
271
+ .ms-empty {
272
+ padding: 1.5rem 1rem;
273
+ text-align: center;
274
+ font-size: 0.8125rem;
275
+ color: var(--color-ink-3, oklch(0.6 0.02 80));
276
+ }
@@ -0,0 +1,43 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ /** 单个模型条目。 */
4
+ export interface ModelSelectorModel {
5
+ /** 唯一标识,如 "gpt-5.6-terra" */
6
+ id: string;
7
+ /** 显示名 */
8
+ name: string;
9
+ /** 一句话描述(用于 featured 区) */
10
+ description?: string;
11
+ /** 供应商图标(消费方传入) */
12
+ icon?: ReactNode;
13
+ /** 所属渠道名(用于分组显示) */
14
+ channel?: string;
15
+ /** 附加信息,如价格、上下文等 */
16
+ meta?: Record<string, string>;
17
+ }
18
+
19
+ /** 渠道分组。 */
20
+ export interface ModelSelectorChannel {
21
+ /** 渠道 ID */
22
+ id: string;
23
+ /** 渠道名,如 "Azure OpenAI" */
24
+ name: string;
25
+ /** 渠道图标(消费方传入) */
26
+ icon?: ReactNode;
27
+ /** 该渠道下的模型 */
28
+ models: ModelSelectorModel[];
29
+ }
30
+
31
+ /** ModelSelector 完整数据。 */
32
+ export interface ModelSelectorData {
33
+ /** 顶部推荐模型(带描述,直接展示不折叠) */
34
+ featured: ModelSelectorModel[];
35
+ /** 渠道分组列表 */
36
+ channels: ModelSelectorChannel[];
37
+ }
38
+
39
+ /** 选中值。 */
40
+ export interface ModelSelectorValue {
41
+ channel?: string;
42
+ model: string;
43
+ }
@@ -0,0 +1,43 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { AnimatePresence, motion } from "framer-motion";
5
+
6
+ import { Icon } from "../../components/icon/Icon";
7
+ import "./reasoning.css";
8
+
9
+ /**
10
+ * Reasoning — 思考过程折叠块。
11
+ *
12
+ * 运行中自动展开(呼吸圆点),完成后收起为标签行,点击回看。
13
+ * 展开内容渐显(AnimatePresence)。
14
+ *
15
+ * @example
16
+ * <Reasoning text={chainOfThought} active={isStreaming} />
17
+ */
18
+ export function Reasoning({ text, active = false }: { text: string; active?: boolean }) {
19
+ const [open, setOpen] = useState(false);
20
+ const expanded = active || open;
21
+ return (
22
+ <div className="zmz-reasoning">
23
+ <button type="button" className="zmz-reasoning-toggle" aria-expanded={expanded} onClick={() => setOpen((value) => !value)}>
24
+ <span className={`zmz-reasoning-dot ${active ? "live" : ""}`} aria-hidden />
25
+ <span>思考过程</span>
26
+ <Icon name="chevron-down" size={11} className={expanded ? "zmz-chevron open" : "zmz-chevron"} />
27
+ </button>
28
+ <AnimatePresence initial={false}>
29
+ {expanded && (
30
+ <motion.pre
31
+ className="zmz-reasoning-body"
32
+ initial={{ opacity: 0, height: 0 }}
33
+ animate={{ opacity: 1, height: "auto" }}
34
+ exit={{ opacity: 0, height: 0 }}
35
+ transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
36
+ >
37
+ {text}
38
+ </motion.pre>
39
+ )}
40
+ </AnimatePresence>
41
+ </div>
42
+ );
43
+ }
@@ -0,0 +1 @@
1
+ export * from "./Reasoning";
@@ -0,0 +1,10 @@
1
+ /* Reasoning — 思考过程折叠。token 依赖消费端 @theme。 */
2
+ @keyframes zmz-reasoning-pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.4; transform: scale(0.75); } }
3
+
4
+ .zmz-reasoning { border-left: 2px solid var(--color-line); padding-left: 0.6rem; }
5
+ .zmz-reasoning-toggle { display: inline-flex; align-items: center; gap: 0.3rem; background: none; border: 0; cursor: pointer; font-family: var(--font-mono); font-size: 0.625rem; letter-spacing: 0.07em; text-transform: uppercase; color: var(--color-muted); padding: 0.15rem 0; }
6
+ .zmz-reasoning-dot { display: inline-block; width: 0.4rem; height: 0.4rem; border-radius: 50%; background: var(--color-muted); }
7
+ .zmz-reasoning-dot.live { background: var(--color-accent); animation: zmz-reasoning-pulse 1.2s ease-in-out infinite; }
8
+ .zmz-chevron { transition: transform 0.15s var(--ease-out); }
9
+ .zmz-chevron.open { transform: rotate(180deg); }
10
+ .zmz-reasoning-body { margin: 0.4rem 0 0; overflow: hidden; font-size: 0.75rem; color: var(--color-muted); white-space: pre-wrap; }