sparkdesign 0.0.1 → 0.1.10

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.
Files changed (118) hide show
  1. package/README.md +188 -4
  2. package/dist/commands/add.js +93 -0
  3. package/dist/commands/diff.js +54 -0
  4. package/dist/commands/init.js +96 -0
  5. package/dist/commands/list.js +25 -0
  6. package/dist/index.js +37 -0
  7. package/dist/utils/config.js +53 -0
  8. package/dist/utils/registry.js +34 -0
  9. package/dist/utils/tokens.js +176 -0
  10. package/dist/utils/transform.js +19 -0
  11. package/package.json +33 -10
  12. package/registry/__tests__/basic/button.test.tsx +333 -0
  13. package/registry/__tests__/chat/markdown.test.tsx +387 -0
  14. package/registry/__tests__/chat/thinking-indicator.test.tsx +244 -0
  15. package/registry/__tests__/chat/tool-invocation-card.test.tsx +346 -0
  16. package/registry/basic/alert-dialog.tsx +180 -0
  17. package/registry/basic/avatar.tsx +120 -0
  18. package/registry/basic/button.tsx +100 -0
  19. package/registry/basic/collapse.tsx +94 -0
  20. package/registry/basic/collapsible-card.tsx +230 -0
  21. package/registry/basic/collapsible.tsx +21 -0
  22. package/registry/basic/dropdown-menu.tsx +254 -0
  23. package/registry/basic/icon-button.tsx +66 -0
  24. package/registry/basic/icons-inline.tsx +206 -0
  25. package/registry/basic/kbd.tsx +50 -0
  26. package/registry/basic/option-list.tsx +125 -0
  27. package/registry/basic/pagination.tsx +132 -0
  28. package/registry/basic/progress.tsx +42 -0
  29. package/registry/basic/radio-group.tsx +69 -0
  30. package/registry/basic/resizable.tsx +67 -0
  31. package/registry/basic/scrollbar.tsx +114 -0
  32. package/registry/basic/select.tsx +177 -0
  33. package/registry/basic/shimmering-text.tsx +115 -0
  34. package/registry/basic/sidebar-menu.tsx +177 -0
  35. package/registry/basic/skeleton.tsx +33 -0
  36. package/registry/basic/slider.tsx +55 -0
  37. package/registry/basic/sonner.tsx +104 -0
  38. package/registry/basic/spinner.tsx +17 -0
  39. package/registry/basic/switch.tsx +49 -0
  40. package/registry/basic/table.tsx +117 -0
  41. package/registry/basic/tabs.tsx +85 -0
  42. package/registry/basic/tag.tsx +161 -0
  43. package/registry/basic/theme-from-document.ts +10 -0
  44. package/registry/basic/toggle.tsx +223 -0
  45. package/registry/basic/tooltip.tsx +80 -0
  46. package/registry/basic/typography.tsx +201 -0
  47. package/registry/chat/ask-user-part.tsx +70 -0
  48. package/registry/chat/browser-action-part.tsx +166 -0
  49. package/registry/chat/chat-input/chat-input-folder-selector.tsx +185 -0
  50. package/registry/chat/chat-input/chat-input-model-switcher.tsx +131 -0
  51. package/registry/chat/chat-input/chat-input-textarea.tsx +67 -0
  52. package/registry/chat/chat-input/compound.tsx +334 -0
  53. package/registry/chat/chat-input/context.tsx +189 -0
  54. package/registry/chat/chat-input/folder-permission-dialog.tsx +61 -0
  55. package/registry/chat/chat-input/index.tsx +123 -0
  56. package/registry/chat/chat-input/types.ts +77 -0
  57. package/registry/chat/chat-input/useAutoResizeTextarea.ts +20 -0
  58. package/registry/chat/code-block-part.tsx +151 -0
  59. package/registry/chat/file-attachment.tsx +44 -0
  60. package/registry/chat/file-card.tsx +68 -0
  61. package/registry/chat/file-review-part.tsx +259 -0
  62. package/registry/chat/folder-button.tsx +169 -0
  63. package/registry/chat/generated-images-grid.tsx +56 -0
  64. package/registry/chat/generation-status-bar.tsx +72 -0
  65. package/registry/chat/hint-banner.tsx +165 -0
  66. package/registry/chat/image-attachment.tsx +166 -0
  67. package/registry/chat/image-generating.tsx +281 -0
  68. package/registry/chat/markdown.tsx +146 -0
  69. package/registry/chat/mermaid-part.tsx +90 -0
  70. package/registry/chat/permission-card.tsx +178 -0
  71. package/registry/chat/plan-part.tsx +168 -0
  72. package/registry/chat/queue-indicator.tsx +234 -0
  73. package/registry/chat/reasoning-step/compound.tsx +336 -0
  74. package/registry/chat/reasoning-step/context.tsx +114 -0
  75. package/registry/chat/reasoning-step/index.tsx +45 -0
  76. package/registry/chat/reasoning-step/types.ts +109 -0
  77. package/registry/chat/related-prompts.tsx +91 -0
  78. package/registry/chat/response/compound.tsx +210 -0
  79. package/registry/chat/response/context.tsx +200 -0
  80. package/registry/chat/response/index.tsx +87 -0
  81. package/registry/chat/response/types.ts +123 -0
  82. package/registry/chat/send-button.tsx +94 -0
  83. package/registry/chat/streaming-markdown-block.tsx +111 -0
  84. package/registry/chat/task-part.tsx +109 -0
  85. package/registry/chat/terminal-code-block-part.tsx +69 -0
  86. package/registry/chat/thinking-indicator.tsx +91 -0
  87. package/registry/chat/tool-invocation-card.tsx +132 -0
  88. package/registry/chat/user-message.tsx +38 -0
  89. package/registry/chat/user-question/UserQuestionCard.tsx +198 -0
  90. package/registry/chat/user-question/UserQuestionFooter.tsx +66 -0
  91. package/registry/chat/user-question/UserQuestionHeader.tsx +64 -0
  92. package/registry/chat/user-question/compound.tsx +324 -0
  93. package/registry/chat/user-question/context.tsx +456 -0
  94. package/registry/chat/user-question/index.tsx +95 -0
  95. package/registry/chat/user-question/types.ts +61 -0
  96. package/registry/chat/user-question/useUserQuestionKeyboard.ts +126 -0
  97. package/registry/chat/user-question/useUserQuestionState.ts +165 -0
  98. package/registry/chat/user-question-answer.tsx +62 -0
  99. package/registry/lib/file-icon-maps.ts +150 -0
  100. package/registry/lib/use-mermaid-render.ts +76 -0
  101. package/registry/lib/utils.ts +6 -0
  102. package/registry/meta.json +1 -0
  103. package/registry/tokens/index.css +31 -0
  104. package/registry/tokens/scale/computed.css +103 -0
  105. package/registry/tokens/scale/config.css +110 -0
  106. package/registry/tokens/scale/index.css +30 -0
  107. package/registry/tokens/scale/presets/compact.css +30 -0
  108. package/registry/tokens/scale/presets/dense.css +64 -0
  109. package/registry/tokens/scale/presets/sharp.css +40 -0
  110. package/registry/tokens/scale/presets/soft.css +16 -0
  111. package/registry/tokens/scale.css +13 -0
  112. package/registry/tokens/scrollbar-utility.css +35 -0
  113. package/registry/tokens/theme.css +633 -0
  114. package/registry/tokens/themes/dark-parchment.css +132 -0
  115. package/registry/tokens/themes/dark-qoder.css +132 -0
  116. package/registry/tokens/themes/light-parchment.css +123 -0
  117. package/registry/tokens/themes/light-qoder.css +131 -0
  118. package/index.js +0 -5
@@ -0,0 +1,45 @@
1
+ import { ReasoningStepRootProvider, useReasoningStepContext } from './context'
2
+ import {
3
+ ReasoningStepHeader,
4
+ ReasoningStepDetails,
5
+ ReasoningStepDetailRow,
6
+ ReasoningStepDefaultLayout,
7
+ } from './compound'
8
+ import type {
9
+ ReasoningStepProps,
10
+ ReasoningStepCompound,
11
+ ReasoningStepDetail,
12
+ ReasoningStepStatus,
13
+ } from './types'
14
+
15
+ // ===== ReasoningStep 便捷组件 =====
16
+
17
+ function ReasoningStepBase(props: ReasoningStepProps) {
18
+ return (
19
+ <ReasoningStepRootProvider {...props}>
20
+ <ReasoningStepDefaultLayout />
21
+ </ReasoningStepRootProvider>
22
+ )
23
+ }
24
+
25
+ ReasoningStepBase.displayName = 'ReasoningStep'
26
+
27
+ // ===== 挂载复合组件 =====
28
+
29
+ type ReasoningStepBaseType = React.FC<ReasoningStepProps> & Partial<ReasoningStepCompound>
30
+
31
+ const ReasoningStepWithSlots = ReasoningStepBase as ReasoningStepBaseType
32
+
33
+ ReasoningStepWithSlots.Root = ReasoningStepRootProvider
34
+ ReasoningStepWithSlots.Header = ReasoningStepHeader
35
+ ReasoningStepWithSlots.Details = ReasoningStepDetails
36
+ ReasoningStepWithSlots.DetailRow = ReasoningStepDetailRow
37
+
38
+ export const ReasoningStep = ReasoningStepWithSlots as React.FC<ReasoningStepProps> &
39
+ ReasoningStepCompound
40
+
41
+ // ===== 导出类型和工具 =====
42
+
43
+ export type { ReasoningStepProps, ReasoningStepDetail, ReasoningStepStatus }
44
+
45
+ export { useReasoningStepContext }
@@ -0,0 +1,109 @@
1
+ import type { ReactNode } from 'react'
2
+
3
+ // ===== 基础类型 =====
4
+
5
+ export type ReasoningStepDetail =
6
+ | { type: 'action'; action: string; desc?: string; details?: ReasoningStepDetail[] }
7
+ | {
8
+ type: 'file'
9
+ filename: string
10
+ lines?: string
11
+ path?: string
12
+ fileType?: string
13
+ details?: ReasoningStepDetail[]
14
+ }
15
+
16
+ export type ReasoningStepStatus = 'completed' | 'in-progress'
17
+
18
+ // ===== 组件 Props =====
19
+
20
+ export interface ReasoningStepRootProps {
21
+ /** 主文本 */
22
+ text: ReactNode
23
+ /** 描述文本 */
24
+ description?: string
25
+ /** 状态 */
26
+ status?: ReasoningStepStatus
27
+ /** 自定义图标 */
28
+ icon?: ReactNode
29
+ /** 是否显示图标 */
30
+ showIcon?: boolean
31
+ /** 详情数据 */
32
+ details?: ReasoningStepDetail[]
33
+ /** 子组件 */
34
+ children?: ReactNode
35
+ /** 动画持续时间 */
36
+ duration?: number
37
+ /** 动画扩散度 */
38
+ spread?: number
39
+ /** 禁用展开动画 */
40
+ disableExpandAnimation?: boolean
41
+ /** 默认展开 */
42
+ defaultExpanded?: boolean
43
+ /** 受控展开状态 */
44
+ expanded?: boolean
45
+ /** 展开状态变化回调 */
46
+ onExpandedChange?: (expanded: boolean) => void
47
+ /** 自定义眼睛图标 */
48
+ eyeIcon?: ReactNode
49
+ /** 自定义箭头图标 */
50
+ arrowRightIcon?: ReactNode
51
+ }
52
+
53
+ export type ReasoningStepProps = ReasoningStepRootProps
54
+
55
+ export interface ReasoningStepHeaderProps {
56
+ className?: string
57
+ }
58
+
59
+ export interface ReasoningStepDetailsProps {
60
+ className?: string
61
+ children?: ReactNode
62
+ }
63
+
64
+ export interface ReasoningStepDetailRowProps {
65
+ detail: ReasoningStepDetail
66
+ depth?: number
67
+ pathKey?: string
68
+ className?: string
69
+ }
70
+
71
+ // ===== Context 类型 =====
72
+
73
+ export interface ReasoningStepContextValue {
74
+ // 状态
75
+ status: ReasoningStepStatus
76
+ isExpanded: boolean
77
+ isHovered: boolean
78
+ setIsHovered: (hovered: boolean) => void
79
+ toggleExpanded: () => void
80
+ effectiveExpanded: boolean
81
+ effectiveShowIcon: boolean
82
+ hasExpandableContent: boolean
83
+ // 数据
84
+ text: ReactNode
85
+ description?: string
86
+ details?: ReasoningStepDetail[]
87
+ detailsChildren?: ReactNode
88
+ // 配置
89
+ duration: number
90
+ spread: number
91
+ disableExpandAnimation: boolean
92
+ detailIndent: string
93
+ // 嵌套状态
94
+ expandedNestedKeys: Set<string>
95
+ toggleNested: (pathKey: string) => void
96
+ // 图标
97
+ icon?: ReactNode
98
+ eyeIcon: ReactNode
99
+ arrowRightIcon: ReactNode
100
+ }
101
+
102
+ // ===== 复合组件类型 =====
103
+
104
+ export interface ReasoningStepCompound {
105
+ Root: React.FC<ReasoningStepRootProps>
106
+ Header: React.FC<ReasoningStepHeaderProps>
107
+ Details: React.FC<ReasoningStepDetailsProps>
108
+ DetailRow: React.FC<ReasoningStepDetailRowProps>
109
+ }
@@ -0,0 +1,91 @@
1
+ import type { ReactNode } from 'react'
2
+ import { Fragment } from 'react'
3
+ import { cn } from '@/lib/utils'
4
+ import { Tooltip } from '../basic/tooltip'
5
+ import { Chat3Line } from '../basic/icons-inline'
6
+
7
+ const iconClass = 'w-3.5 h-3.5 shrink-0 text-text-tertiary'
8
+
9
+ export interface RelatedPromptItem {
10
+ text: string
11
+ prompt: string
12
+ icon?: ReactNode
13
+ }
14
+
15
+ export interface RelatedPromptsProps {
16
+ items: RelatedPromptItem[]
17
+ onSelect?: (item: RelatedPromptItem) => void
18
+ singleLineText?: boolean
19
+ className?: string
20
+ /** 主库可注入 useIcon('chat3');不传则用 Chat3Line */
21
+ defaultIcon?: ReactNode
22
+ }
23
+
24
+ export function RelatedPrompts({
25
+ items,
26
+ onSelect,
27
+ singleLineText = false,
28
+ className,
29
+ defaultIcon,
30
+ }: RelatedPromptsProps) {
31
+ const list = items.slice(0, 3)
32
+ const icon = defaultIcon ?? <Chat3Line className={iconClass} />
33
+
34
+ if (list.length === 0) return null
35
+
36
+ return (
37
+ <div
38
+ className={cn(
39
+ 'flex flex-col gap-3 w-full min-w-0',
40
+ !singleLineText && 'max-w-lg',
41
+ className
42
+ )}
43
+ role="list"
44
+ aria-label="Related prompt suggestions"
45
+ >
46
+ {list.map((item, index) => {
47
+ const itemIcon = item.icon ?? icon
48
+
49
+ const itemContent = (
50
+ <button
51
+ type="button"
52
+ onClick={() => onSelect?.(item)}
53
+ className={cn(
54
+ 'flex items-center gap-2 w-full min-w-0 text-left',
55
+ 'rounded-md px-0 py-0 min-h-0',
56
+ 'text-xs leading-xs font-normal text-text-tertiary',
57
+ 'transition-colors',
58
+ onSelect && 'cursor-pointer hover:text-text',
59
+ !onSelect && 'cursor-default'
60
+ )}
61
+ role="listitem"
62
+ >
63
+ <span className="w-3.5 h-3.5 flex-shrink-0 flex items-center justify-center">
64
+ {itemIcon}
65
+ </span>
66
+ <span
67
+ className={cn(
68
+ 'min-w-0 flex-1',
69
+ singleLineText ? 'truncate' : 'line-clamp-2'
70
+ )}
71
+ >
72
+ {item.text}
73
+ </span>
74
+ </button>
75
+ )
76
+
77
+ if (singleLineText) {
78
+ return (
79
+ <Tooltip key={`${item.prompt}-${index}`} content={item.text} side="top">
80
+ {itemContent}
81
+ </Tooltip>
82
+ )
83
+ }
84
+
85
+ return <Fragment key={`${item.prompt}-${index}`}>{itemContent}</Fragment>
86
+ })}
87
+ </div>
88
+ )
89
+ }
90
+
91
+ RelatedPrompts.displayName = 'RelatedPrompts'
@@ -0,0 +1,210 @@
1
+ import { useMemo, type ReactNode } from 'react'
2
+ import { cn } from '../../lib/utils'
3
+ import { MarkdownBody } from '../markdown'
4
+ import { ReasoningStep } from '../reasoning-step'
5
+ import { StreamingMarkdownBlock } from '../streaming-markdown-block'
6
+ import { ImageGenerating as ImageGeneratingBase } from '../image-generating'
7
+ import { useResponseContext } from './context'
8
+ import type {
9
+ ResponseThinkingProps,
10
+ ResponseStepsProps,
11
+ ResponseStepProps,
12
+ ResponseContentProps,
13
+ ResponseImageGeneratingProps,
14
+ ResponseDefaultLayoutProps,
15
+ } from './types'
16
+
17
+ // ===== Response.Thinking =====
18
+
19
+ export function ResponseThinking({ className }: ResponseThinkingProps) {
20
+ const ctx = useResponseContext()
21
+ const effectiveShowThink = ctx.simulate
22
+ ? ctx.showThink
23
+ : ctx.phase === 'thinking' || ctx.phase === 'streaming'
24
+
25
+ if (!effectiveShowThink) return null
26
+
27
+ return <span className={className}>{ctx.thinkingIndicator}</span>
28
+ }
29
+
30
+ ResponseThinking.displayName = 'ResponseThinking'
31
+
32
+ // ===== Response.Steps =====
33
+
34
+ export function ResponseSteps({ className, children }: ResponseStepsProps) {
35
+ return <div className={cn('flex flex-col gap-1', className)}>{children}</div>
36
+ }
37
+
38
+ ResponseSteps.displayName = 'ResponseSteps'
39
+
40
+ // ===== Response.Step =====
41
+
42
+ export function ResponseStepComponent({ step, status = 'completed' }: ResponseStepProps) {
43
+ return (
44
+ <ReasoningStep
45
+ text={step.text}
46
+ description={step.description}
47
+ details={step.details}
48
+ status={status}
49
+ />
50
+ )
51
+ }
52
+
53
+ ResponseStepComponent.displayName = 'ResponseStep'
54
+
55
+ // ===== Response.Content =====
56
+
57
+ export function ResponseContent({
58
+ markdown,
59
+ streaming = false,
60
+ onStreamComplete,
61
+ className,
62
+ }: ResponseContentProps) {
63
+ const ctx = useResponseContext()
64
+ const content = markdown ?? ctx.finalMarkdown
65
+
66
+ if (streaming) {
67
+ return (
68
+ <div className={cn('mt-1', className)}>
69
+ <StreamingMarkdownBlock
70
+ content={content}
71
+ isActive={streaming}
72
+ onStreamComplete={onStreamComplete}
73
+ />
74
+ </div>
75
+ )
76
+ }
77
+
78
+ return (
79
+ <div className={cn('mt-1', className)}>
80
+ <MarkdownBody>{content}</MarkdownBody>
81
+ </div>
82
+ )
83
+ }
84
+
85
+ ResponseContent.displayName = 'ResponseContent'
86
+
87
+ // ===== Response.ImageGenerating =====
88
+
89
+ export function ResponseImageGenerating({
90
+ width = '320px',
91
+ height = '240px',
92
+ className,
93
+ }: ResponseImageGeneratingProps) {
94
+ return <ImageGeneratingBase width={width} height={height} className={className} />
95
+ }
96
+
97
+ ResponseImageGenerating.displayName = 'ResponseImageGenerating'
98
+
99
+ // ===== Response.DefaultLayout =====
100
+
101
+ export function ResponseDefaultLayout({ className }: ResponseDefaultLayoutProps) {
102
+ const ctx = useResponseContext()
103
+
104
+ // 模拟模式:流式渲染 - useMemo 必须在条件语句前调用
105
+ const contentElements = useMemo(() => {
106
+ if (!ctx.simulate) return []
107
+
108
+ const el: ReactNode[] = []
109
+ const upTo = Math.min(ctx.visibleIndex, ctx.blocks.length - 1)
110
+ let lastVisibleComposerIndex = -1
111
+ for (let j = 0; j <= upTo; j++) {
112
+ if (ctx.blocks[j]?.type === 'composer') lastVisibleComposerIndex = j
113
+ }
114
+ for (let i = 0; i <= upTo; i++) {
115
+ const b = ctx.blocks[i]
116
+ if (!b) continue
117
+ if (b.type === 'think') {
118
+ if (i === ctx.visibleIndex) {
119
+ el.push(<span key={`think-${i}`}>{ctx.thinkingIndicator}</span>)
120
+ }
121
+ continue
122
+ }
123
+ if (b.type === 'composer' && b.step) {
124
+ const inProgress = i === lastVisibleComposerIndex && !ctx.isSimDone
125
+ el.push(
126
+ <ReasoningStep
127
+ key={i}
128
+ text={b.step.text}
129
+ description={b.step.description}
130
+ details={b.step.details}
131
+ status={inProgress ? 'in-progress' : 'completed'}
132
+ />
133
+ )
134
+ }
135
+ if (b.type === 'md' && b.md) {
136
+ el.push(
137
+ <div key={`md-${i}`} className="mt-1">
138
+ <StreamingMarkdownBlock
139
+ content={b.md}
140
+ isActive={i === upTo}
141
+ onStreamComplete={() => {
142
+ ctx.setVisibleIndex((v) => Math.min(v + 1, ctx.blocks.length))
143
+ if (i + 1 >= ctx.blocks.length) ctx.onSimulateComplete?.()
144
+ }}
145
+ />
146
+ </div>
147
+ )
148
+ }
149
+ }
150
+ return el
151
+ }, [ctx])
152
+
153
+ // 静态内容元素(非模拟模式)
154
+ const staticElements = useMemo(() => {
155
+ if (ctx.simulate) return []
156
+ const staticEl: ReactNode[] = []
157
+ for (let i = 0; i < ctx.blocks.length; i++) {
158
+ const b = ctx.blocks[i]
159
+ if (!b) continue
160
+ if (b.type === 'composer' && b.step) {
161
+ staticEl.push(
162
+ <ReasoningStep
163
+ key={i}
164
+ text={b.step.text}
165
+ description={b.step.description}
166
+ details={b.step.details}
167
+ status="completed"
168
+ disableExpandAnimation
169
+ />
170
+ )
171
+ }
172
+ if (b.type === 'md' && b.md) {
173
+ staticEl.push(
174
+ <div key={`md-${i}`} className="mt-1">
175
+ <MarkdownBody>{b.md}</MarkdownBody>
176
+ </div>
177
+ )
178
+ }
179
+ }
180
+ return staticEl
181
+ }, [ctx])
182
+
183
+ // imageGenerating 阶段
184
+ if (ctx.phase === 'imageGenerating') {
185
+ return (
186
+ <div className={cn('flex flex-col gap-3 font-body', className)}>
187
+ <ResponseImageGenerating />
188
+ </div>
189
+ )
190
+ }
191
+
192
+ // 非模拟模式:静态渲染
193
+ if (!ctx.simulate) {
194
+ return (
195
+ <div className={cn('flex flex-col gap-3 font-body', className)}>
196
+ <div className="flex flex-col gap-1">{staticElements}</div>
197
+ {(ctx.phase === 'thinking' || ctx.phase === 'streaming') && ctx.thinkingIndicator}
198
+ </div>
199
+ )
200
+ }
201
+
202
+ // 模拟模式
203
+ return (
204
+ <div className={cn('flex flex-col gap-3 font-body', className)}>
205
+ <div className="flex flex-col gap-1">{contentElements}</div>
206
+ </div>
207
+ )
208
+ }
209
+
210
+ ResponseDefaultLayout.displayName = 'ResponseDefaultLayout'
@@ -0,0 +1,200 @@
1
+ import { createContext, useContext, useState, useEffect, useMemo, useRef } from 'react'
2
+ import { ThinkingIndicator } from '../thinking-indicator'
3
+ import type {
4
+ ResponseContextValue,
5
+ ResponseRootProps,
6
+ ResponseBlock,
7
+ ResponseRound,
8
+ ResponseStep,
9
+ } from './types'
10
+
11
+ // ===== Demo 数据 =====
12
+
13
+ const DEMO_STEP_1: ResponseStep = {
14
+ text: 'Scanning local photo library',
15
+ description: 'Indexing metadata and building a rename plan',
16
+ details: [
17
+ { type: 'action', action: 'Discover photos', desc: 'Found 1,000 images in /Pictures/Unsorted' },
18
+ { type: 'action', action: 'Extract metadata', desc: 'Read EXIF timestamps, GPS, and camera model' },
19
+ { type: 'action', action: 'Detect duplicates', desc: 'Hash-based matching + perceptual similarity' },
20
+ ],
21
+ }
22
+
23
+ const DEMO_STEP_2: ResponseStep = {
24
+ text: 'Classifying photos into albums',
25
+ description: 'Clustering by time, location, and scene',
26
+ details: [
27
+ { type: 'action', action: 'Time buckets', desc: 'Group into events using temporal gaps' },
28
+ { type: 'action', action: 'Location clusters', desc: 'Reverse geocode GPS into city/area labels' },
29
+ { type: 'action', action: 'Scene tags', desc: 'Detect indoor/outdoor, portraits, food, and documents' },
30
+ ],
31
+ }
32
+
33
+ const DEMO_STEP_3: ResponseStep = {
34
+ text: 'Renaming files safely',
35
+ description: 'Dry-run, collision check, then atomic move',
36
+ details: [
37
+ { type: 'action', action: 'Dry run', desc: 'Preview 1,000 rename operations' },
38
+ { type: 'action', action: 'Collision handling', desc: 'Append counter suffix to avoid overwrites' },
39
+ { type: 'action', action: 'Write changes', desc: 'Move files into album folders' },
40
+ ],
41
+ }
42
+
43
+ const DEMO_ROUNDS: ResponseRound[] = [
44
+ { step: DEMO_STEP_1, simpleMd: "I'm **scanning** your library and building the index. Found 1,000 photos in `Pictures/Unsorted`; EXIF and duplicates are being analyzed.\n\n" },
45
+ { step: DEMO_STEP_2, simpleMd: "**Classifying** into albums by time, location, and scene. Time buckets and GPS labels are done; next I'll tag indoor/outdoor and document vs. portrait.\n\n" },
46
+ { step: DEMO_STEP_3, simpleMd: "**Dry-run** is done and collision handling is in place. No overwrites; an audit log (CSV) is ready. Pending your confirmation to run the renames.\n\n" },
47
+ ]
48
+
49
+ const DEMO_FINAL_MD = `## Plan
50
+
51
+ I will organize your **1,000 local photos** by building an index, clustering them into albums, and then performing a safe rename + move operation.
52
+
53
+ ### Folder structure
54
+
55
+ \`\`\`text
56
+ Pictures/
57
+ Organized/
58
+ 2026-01-18 — San Francisco — Golden Gate Park/
59
+ 2026-01-05 — Home — Documents/
60
+ 2025-12-31 — New Year's Eve/
61
+ _Duplicates/
62
+ _Unsorted/
63
+ \`\`\`
64
+
65
+ ### Naming scheme
66
+
67
+ \`YYYY-MM-DD_HH-mm-ss__<Location>__<Scene>__<Counter>.<ext>\`
68
+
69
+ Example:
70
+
71
+ \`2026-01-18_15-42-11__San-Francisco__Outdoor__001.jpg\`
72
+
73
+ ### Safety checks
74
+
75
+ - [x] Dry-run all operations
76
+ - [x] Detect collisions and append counters
77
+ - [x] Keep an audit log (CSV)
78
+ - [ ] Execute moves (requires confirmation)
79
+
80
+ ---
81
+
82
+ ## Next step
83
+
84
+ If you want, I can generate a **preview report** first (top 50 renames + album summary) before applying changes.
85
+ `
86
+
87
+ // ===== 工具函数 =====
88
+
89
+ function buildBlocks(rounds: ResponseRound[], finalMd: string): ResponseBlock[] {
90
+ const blocks: ResponseBlock[] = []
91
+ for (const r of rounds) {
92
+ blocks.push({ type: 'think' })
93
+ blocks.push({ type: 'composer', step: r.step })
94
+ blocks.push({ type: 'md', md: r.simpleMd })
95
+ }
96
+ blocks.push({ type: 'think' })
97
+ blocks.push({ type: 'md', md: finalMd })
98
+ return blocks
99
+ }
100
+
101
+ // ===== Context =====
102
+
103
+ const ResponseContext = createContext<ResponseContextValue | null>(null)
104
+ ResponseContext.displayName = 'ResponseContext'
105
+
106
+ export function useResponseContext() {
107
+ const ctx = useContext(ResponseContext)
108
+ if (!ctx) throw new Error('Response compound components must be used within Response.Root')
109
+ return ctx
110
+ }
111
+
112
+ // ===== Default ThinkingIndicator =====
113
+
114
+ const DEFAULT_THINK = <ThinkingIndicator text="Qoder is thinking..." />
115
+
116
+ // ===== Provider =====
117
+
118
+ export function ResponseRootProvider({
119
+ simulate = true,
120
+ phase = 'thinking',
121
+ rounds,
122
+ finalMarkdown,
123
+ onSimulateComplete,
124
+ onStepChange,
125
+ thinkingIndicator,
126
+ children,
127
+ }: ResponseRootProps) {
128
+ const allRounds = rounds ?? DEMO_ROUNDS
129
+ const finalMd = finalMarkdown ?? DEMO_FINAL_MD
130
+ const thinkNode = thinkingIndicator ?? DEFAULT_THINK
131
+
132
+ const blocks = useMemo(() => buildBlocks(allRounds, finalMd), [allRounds, finalMd])
133
+ const [visibleIndex, setVisibleIndex] = useState(0)
134
+ const prevBlocksLengthRef = useRef(blocks.length)
135
+
136
+ const isSimDone = simulate ? visibleIndex >= blocks.length - 1 : phase === 'done'
137
+ const currentBlock = blocks[visibleIndex] ?? null
138
+ const showThink = currentBlock?.type === 'think'
139
+
140
+ // 步骤变化回调
141
+ useEffect(() => {
142
+ if (!simulate || blocks.length === 0) return
143
+ const block = blocks[visibleIndex] ?? null
144
+ if (block?.type === 'composer' && block.step) {
145
+ onStepChange?.(block.step.text, block.step.toolType)
146
+ } else if (block?.type === 'think') {
147
+ onStepChange?.('Thinking...')
148
+ }
149
+ if (visibleIndex >= blocks.length) {
150
+ onSimulateComplete?.()
151
+ }
152
+ }, [simulate, blocks, visibleIndex, onStepChange, onSimulateComplete])
153
+
154
+ // blocks 变化时重置
155
+ useEffect(() => {
156
+ if (!simulate || blocks.length === 0) return
157
+ if (prevBlocksLengthRef.current !== blocks.length) {
158
+ prevBlocksLengthRef.current = blocks.length
159
+ // 使用 requestAnimationFrame 避免同步 setState
160
+ requestAnimationFrame(() => setVisibleIndex(0))
161
+ }
162
+ }, [simulate, blocks.length])
163
+
164
+ // 定时器推进
165
+ useEffect(() => {
166
+ if (!simulate || blocks.length === 0) return
167
+ const block = blocks[visibleIndex] ?? null
168
+ if (block?.type === 'think' || block?.type === 'composer') {
169
+ const id = window.setTimeout(() => setVisibleIndex((v) => Math.min(v + 1, blocks.length)), 580)
170
+ return () => window.clearTimeout(id)
171
+ }
172
+ }, [simulate, blocks, visibleIndex])
173
+
174
+ const ctxValue: ResponseContextValue = {
175
+ phase,
176
+ simulate,
177
+ rounds: allRounds,
178
+ finalMarkdown: finalMd,
179
+ blocks,
180
+ visibleIndex,
181
+ setVisibleIndex,
182
+ isSimDone,
183
+ currentBlock,
184
+ showThink,
185
+ onSimulateComplete,
186
+ onStepChange,
187
+ thinkingIndicator: thinkNode,
188
+ }
189
+
190
+ return (
191
+ <ResponseContext.Provider value={ctxValue}>
192
+ {children}
193
+ </ResponseContext.Provider>
194
+ )
195
+ }
196
+
197
+ ResponseRootProvider.displayName = 'ResponseRoot'
198
+
199
+ // 导出 Demo 数据供测试使用
200
+ export { DEMO_ROUNDS, DEMO_FINAL_MD }