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.
- package/README.md +188 -4
- package/dist/commands/add.js +93 -0
- package/dist/commands/diff.js +54 -0
- package/dist/commands/init.js +96 -0
- package/dist/commands/list.js +25 -0
- package/dist/index.js +37 -0
- package/dist/utils/config.js +53 -0
- package/dist/utils/registry.js +34 -0
- package/dist/utils/tokens.js +176 -0
- package/dist/utils/transform.js +19 -0
- package/package.json +33 -10
- package/registry/__tests__/basic/button.test.tsx +333 -0
- package/registry/__tests__/chat/markdown.test.tsx +387 -0
- package/registry/__tests__/chat/thinking-indicator.test.tsx +244 -0
- package/registry/__tests__/chat/tool-invocation-card.test.tsx +346 -0
- package/registry/basic/alert-dialog.tsx +180 -0
- package/registry/basic/avatar.tsx +120 -0
- package/registry/basic/button.tsx +100 -0
- package/registry/basic/collapse.tsx +94 -0
- package/registry/basic/collapsible-card.tsx +230 -0
- package/registry/basic/collapsible.tsx +21 -0
- package/registry/basic/dropdown-menu.tsx +254 -0
- package/registry/basic/icon-button.tsx +66 -0
- package/registry/basic/icons-inline.tsx +206 -0
- package/registry/basic/kbd.tsx +50 -0
- package/registry/basic/option-list.tsx +125 -0
- package/registry/basic/pagination.tsx +132 -0
- package/registry/basic/progress.tsx +42 -0
- package/registry/basic/radio-group.tsx +69 -0
- package/registry/basic/resizable.tsx +67 -0
- package/registry/basic/scrollbar.tsx +114 -0
- package/registry/basic/select.tsx +177 -0
- package/registry/basic/shimmering-text.tsx +115 -0
- package/registry/basic/sidebar-menu.tsx +177 -0
- package/registry/basic/skeleton.tsx +33 -0
- package/registry/basic/slider.tsx +55 -0
- package/registry/basic/sonner.tsx +104 -0
- package/registry/basic/spinner.tsx +17 -0
- package/registry/basic/switch.tsx +49 -0
- package/registry/basic/table.tsx +117 -0
- package/registry/basic/tabs.tsx +85 -0
- package/registry/basic/tag.tsx +161 -0
- package/registry/basic/theme-from-document.ts +10 -0
- package/registry/basic/toggle.tsx +223 -0
- package/registry/basic/tooltip.tsx +80 -0
- package/registry/basic/typography.tsx +201 -0
- package/registry/chat/ask-user-part.tsx +70 -0
- package/registry/chat/browser-action-part.tsx +166 -0
- package/registry/chat/chat-input/chat-input-folder-selector.tsx +185 -0
- package/registry/chat/chat-input/chat-input-model-switcher.tsx +131 -0
- package/registry/chat/chat-input/chat-input-textarea.tsx +67 -0
- package/registry/chat/chat-input/compound.tsx +334 -0
- package/registry/chat/chat-input/context.tsx +189 -0
- package/registry/chat/chat-input/folder-permission-dialog.tsx +61 -0
- package/registry/chat/chat-input/index.tsx +123 -0
- package/registry/chat/chat-input/types.ts +77 -0
- package/registry/chat/chat-input/useAutoResizeTextarea.ts +20 -0
- package/registry/chat/code-block-part.tsx +151 -0
- package/registry/chat/file-attachment.tsx +44 -0
- package/registry/chat/file-card.tsx +68 -0
- package/registry/chat/file-review-part.tsx +259 -0
- package/registry/chat/folder-button.tsx +169 -0
- package/registry/chat/generated-images-grid.tsx +56 -0
- package/registry/chat/generation-status-bar.tsx +72 -0
- package/registry/chat/hint-banner.tsx +165 -0
- package/registry/chat/image-attachment.tsx +166 -0
- package/registry/chat/image-generating.tsx +281 -0
- package/registry/chat/markdown.tsx +146 -0
- package/registry/chat/mermaid-part.tsx +90 -0
- package/registry/chat/permission-card.tsx +178 -0
- package/registry/chat/plan-part.tsx +168 -0
- package/registry/chat/queue-indicator.tsx +234 -0
- package/registry/chat/reasoning-step/compound.tsx +336 -0
- package/registry/chat/reasoning-step/context.tsx +114 -0
- package/registry/chat/reasoning-step/index.tsx +45 -0
- package/registry/chat/reasoning-step/types.ts +109 -0
- package/registry/chat/related-prompts.tsx +91 -0
- package/registry/chat/response/compound.tsx +210 -0
- package/registry/chat/response/context.tsx +200 -0
- package/registry/chat/response/index.tsx +87 -0
- package/registry/chat/response/types.ts +123 -0
- package/registry/chat/send-button.tsx +94 -0
- package/registry/chat/streaming-markdown-block.tsx +111 -0
- package/registry/chat/task-part.tsx +109 -0
- package/registry/chat/terminal-code-block-part.tsx +69 -0
- package/registry/chat/thinking-indicator.tsx +91 -0
- package/registry/chat/tool-invocation-card.tsx +132 -0
- package/registry/chat/user-message.tsx +38 -0
- package/registry/chat/user-question/UserQuestionCard.tsx +198 -0
- package/registry/chat/user-question/UserQuestionFooter.tsx +66 -0
- package/registry/chat/user-question/UserQuestionHeader.tsx +64 -0
- package/registry/chat/user-question/compound.tsx +324 -0
- package/registry/chat/user-question/context.tsx +456 -0
- package/registry/chat/user-question/index.tsx +95 -0
- package/registry/chat/user-question/types.ts +61 -0
- package/registry/chat/user-question/useUserQuestionKeyboard.ts +126 -0
- package/registry/chat/user-question/useUserQuestionState.ts +165 -0
- package/registry/chat/user-question-answer.tsx +62 -0
- package/registry/lib/file-icon-maps.ts +150 -0
- package/registry/lib/use-mermaid-render.ts +76 -0
- package/registry/lib/utils.ts +6 -0
- package/registry/meta.json +1 -0
- package/registry/tokens/index.css +31 -0
- package/registry/tokens/scale/computed.css +103 -0
- package/registry/tokens/scale/config.css +110 -0
- package/registry/tokens/scale/index.css +30 -0
- package/registry/tokens/scale/presets/compact.css +30 -0
- package/registry/tokens/scale/presets/dense.css +64 -0
- package/registry/tokens/scale/presets/sharp.css +40 -0
- package/registry/tokens/scale/presets/soft.css +16 -0
- package/registry/tokens/scale.css +13 -0
- package/registry/tokens/scrollbar-utility.css +35 -0
- package/registry/tokens/theme.css +633 -0
- package/registry/tokens/themes/dark-parchment.css +132 -0
- package/registry/tokens/themes/dark-qoder.css +132 -0
- package/registry/tokens/themes/light-parchment.css +123 -0
- package/registry/tokens/themes/light-qoder.css +131 -0
- 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 }
|