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,87 @@
|
|
|
1
|
+
import type { HTMLAttributes } from 'react'
|
|
2
|
+
import { ResponseRootProvider, useResponseContext, DEMO_ROUNDS, DEMO_FINAL_MD } from './context'
|
|
3
|
+
import {
|
|
4
|
+
ResponseThinking,
|
|
5
|
+
ResponseSteps,
|
|
6
|
+
ResponseStepComponent,
|
|
7
|
+
ResponseContent,
|
|
8
|
+
ResponseImageGenerating,
|
|
9
|
+
ResponseDefaultLayout,
|
|
10
|
+
} from './compound'
|
|
11
|
+
import type {
|
|
12
|
+
ResponseProps,
|
|
13
|
+
ResponseCompound,
|
|
14
|
+
ResponsePhase,
|
|
15
|
+
ResponseStep,
|
|
16
|
+
ResponseStepToolType,
|
|
17
|
+
ResponseRound,
|
|
18
|
+
ResponseBlock,
|
|
19
|
+
BlockType,
|
|
20
|
+
} from './types'
|
|
21
|
+
|
|
22
|
+
// ===== Response 便捷组件 =====
|
|
23
|
+
|
|
24
|
+
function ResponseBase({
|
|
25
|
+
simulate,
|
|
26
|
+
phase,
|
|
27
|
+
rounds,
|
|
28
|
+
finalMarkdown,
|
|
29
|
+
onSimulateComplete,
|
|
30
|
+
onStepChange,
|
|
31
|
+
thinkingIndicator,
|
|
32
|
+
className,
|
|
33
|
+
...props
|
|
34
|
+
}: ResponseProps & HTMLAttributes<HTMLDivElement>) {
|
|
35
|
+
return (
|
|
36
|
+
<ResponseRootProvider
|
|
37
|
+
simulate={simulate}
|
|
38
|
+
phase={phase}
|
|
39
|
+
rounds={rounds}
|
|
40
|
+
finalMarkdown={finalMarkdown}
|
|
41
|
+
onSimulateComplete={onSimulateComplete}
|
|
42
|
+
onStepChange={onStepChange}
|
|
43
|
+
thinkingIndicator={thinkingIndicator}
|
|
44
|
+
>
|
|
45
|
+
<div {...props}>
|
|
46
|
+
<ResponseDefaultLayout className={className} />
|
|
47
|
+
</div>
|
|
48
|
+
</ResponseRootProvider>
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
ResponseBase.displayName = 'Response'
|
|
53
|
+
|
|
54
|
+
// ===== 挂载复合组件 =====
|
|
55
|
+
|
|
56
|
+
type ResponseBaseType = React.FC<ResponseProps & HTMLAttributes<HTMLDivElement>> &
|
|
57
|
+
Partial<ResponseCompound>
|
|
58
|
+
|
|
59
|
+
const ResponseWithSlots = ResponseBase as ResponseBaseType
|
|
60
|
+
|
|
61
|
+
ResponseWithSlots.Root = ResponseRootProvider
|
|
62
|
+
ResponseWithSlots.Thinking = ResponseThinking
|
|
63
|
+
ResponseWithSlots.Steps = ResponseSteps
|
|
64
|
+
ResponseWithSlots.Step = ResponseStepComponent
|
|
65
|
+
ResponseWithSlots.Content = ResponseContent
|
|
66
|
+
ResponseWithSlots.ImageGenerating = ResponseImageGenerating
|
|
67
|
+
ResponseWithSlots.DefaultLayout = ResponseDefaultLayout
|
|
68
|
+
|
|
69
|
+
export const Response = ResponseWithSlots as React.FC<
|
|
70
|
+
ResponseProps & HTMLAttributes<HTMLDivElement>
|
|
71
|
+
> &
|
|
72
|
+
ResponseCompound
|
|
73
|
+
|
|
74
|
+
// ===== 导出类型和工具 =====
|
|
75
|
+
|
|
76
|
+
export type {
|
|
77
|
+
ResponseProps,
|
|
78
|
+
ResponsePhase,
|
|
79
|
+
ResponseStep,
|
|
80
|
+
ResponseStepToolType,
|
|
81
|
+
ResponseRound,
|
|
82
|
+
ResponseBlock,
|
|
83
|
+
BlockType,
|
|
84
|
+
ResponseCompound,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export { useResponseContext, DEMO_ROUNDS, DEMO_FINAL_MD }
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import type { HTMLAttributes, ReactNode } from 'react'
|
|
2
|
+
import type { ReasoningStepDetail } from '../reasoning-step'
|
|
3
|
+
|
|
4
|
+
// ===== 基础类型 =====
|
|
5
|
+
|
|
6
|
+
export type ResponsePhase = 'thinking' | 'streaming' | 'done' | 'imageGenerating'
|
|
7
|
+
export type ResponseStepToolType = 'read' | 'edit' | 'search'
|
|
8
|
+
|
|
9
|
+
export interface ResponseStep {
|
|
10
|
+
text: string
|
|
11
|
+
description?: string
|
|
12
|
+
details?: ReasoningStepDetail[]
|
|
13
|
+
toolType?: ResponseStepToolType
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ResponseRound {
|
|
17
|
+
step: ResponseStep
|
|
18
|
+
simpleMd: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ===== Block 类型(内部) =====
|
|
22
|
+
|
|
23
|
+
export type BlockType = 'think' | 'composer' | 'md'
|
|
24
|
+
|
|
25
|
+
export interface ResponseBlock {
|
|
26
|
+
type: BlockType
|
|
27
|
+
step?: ResponseStep
|
|
28
|
+
md?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ===== 组件 Props =====
|
|
32
|
+
|
|
33
|
+
export interface ResponseRootProps {
|
|
34
|
+
/** 是否启用流式模拟 */
|
|
35
|
+
simulate?: boolean
|
|
36
|
+
/** 当前阶段 */
|
|
37
|
+
phase?: ResponsePhase
|
|
38
|
+
/** 响应轮次数据 */
|
|
39
|
+
rounds?: ResponseRound[]
|
|
40
|
+
/** 最终 Markdown 内容 */
|
|
41
|
+
finalMarkdown?: string
|
|
42
|
+
/** 模拟完成回调 */
|
|
43
|
+
onSimulateComplete?: () => void
|
|
44
|
+
/** 步骤变化回调 */
|
|
45
|
+
onStepChange?: (text: string, toolType?: ResponseStepToolType) => void
|
|
46
|
+
/** 自定义 ThinkingIndicator(主库注入 Lottie) */
|
|
47
|
+
thinkingIndicator?: ReactNode
|
|
48
|
+
/** 自定义 className */
|
|
49
|
+
className?: string
|
|
50
|
+
/** 子组件 */
|
|
51
|
+
children?: ReactNode
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ResponseProps extends Omit<HTMLAttributes<HTMLDivElement>, 'children'>, ResponseRootProps {}
|
|
55
|
+
|
|
56
|
+
export interface ResponseThinkingProps {
|
|
57
|
+
className?: string
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface ResponseStepsProps {
|
|
61
|
+
className?: string
|
|
62
|
+
children?: ReactNode
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface ResponseStepProps {
|
|
66
|
+
step: ResponseStep
|
|
67
|
+
status?: 'completed' | 'in-progress'
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface ResponseContentProps {
|
|
71
|
+
/** Markdown 内容 */
|
|
72
|
+
markdown?: string
|
|
73
|
+
/** 是否流式渲染 */
|
|
74
|
+
streaming?: boolean
|
|
75
|
+
/** 流式渲染完成回调 */
|
|
76
|
+
onStreamComplete?: () => void
|
|
77
|
+
className?: string
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface ResponseImageGeneratingProps {
|
|
81
|
+
width?: string
|
|
82
|
+
height?: string
|
|
83
|
+
className?: string
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface ResponseDefaultLayoutProps {
|
|
87
|
+
className?: string
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ===== Context 类型 =====
|
|
91
|
+
|
|
92
|
+
export interface ResponseContextValue {
|
|
93
|
+
// 阶段控制
|
|
94
|
+
phase: ResponsePhase
|
|
95
|
+
simulate: boolean
|
|
96
|
+
// 数据
|
|
97
|
+
rounds: ResponseRound[]
|
|
98
|
+
finalMarkdown: string
|
|
99
|
+
blocks: ResponseBlock[]
|
|
100
|
+
// 状态
|
|
101
|
+
visibleIndex: number
|
|
102
|
+
setVisibleIndex: React.Dispatch<React.SetStateAction<number>>
|
|
103
|
+
isSimDone: boolean
|
|
104
|
+
currentBlock: ResponseBlock | null
|
|
105
|
+
showThink: boolean
|
|
106
|
+
// 回调
|
|
107
|
+
onSimulateComplete?: () => void
|
|
108
|
+
onStepChange?: (text: string, toolType?: ResponseStepToolType) => void
|
|
109
|
+
// 注入
|
|
110
|
+
thinkingIndicator: ReactNode
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ===== 复合组件类型 =====
|
|
114
|
+
|
|
115
|
+
export interface ResponseCompound {
|
|
116
|
+
Root: React.FC<ResponseRootProps>
|
|
117
|
+
Thinking: React.FC<ResponseThinkingProps>
|
|
118
|
+
Steps: React.FC<ResponseStepsProps>
|
|
119
|
+
Step: React.FC<ResponseStepProps>
|
|
120
|
+
Content: React.FC<ResponseContentProps>
|
|
121
|
+
ImageGenerating: React.FC<ResponseImageGeneratingProps>
|
|
122
|
+
DefaultLayout: React.FC<ResponseDefaultLayoutProps>
|
|
123
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { forwardRef } from 'react'
|
|
2
|
+
import type { ButtonHTMLAttributes, ReactNode } from 'react'
|
|
3
|
+
import { cva, type VariantProps } from 'class-variance-authority'
|
|
4
|
+
import { SendPlaneLine, StopLine, MicLine } from '../basic/icons-inline'
|
|
5
|
+
|
|
6
|
+
const sendButtonVariants = cva(
|
|
7
|
+
'inline-flex items-center justify-center rounded-full p-1.5 transition-colors duration-200 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition-transform active:scale-95 active:translate-y-px',
|
|
8
|
+
{
|
|
9
|
+
variants: {
|
|
10
|
+
status: {
|
|
11
|
+
normal:
|
|
12
|
+
'SendButton-send bg-bg-highlight hover:bg-bg-highlight-hover active:opacity-80',
|
|
13
|
+
generating:
|
|
14
|
+
'bg-primary hover:bg-primary-hover active:opacity-80',
|
|
15
|
+
recording:
|
|
16
|
+
'SendButton-send bg-bg-highlight hover:bg-bg-highlight-hover active:opacity-80',
|
|
17
|
+
stopRecording:
|
|
18
|
+
'bg-error-bg hover:bg-error-bg-hover active:opacity-80',
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
defaultVariants: {
|
|
22
|
+
status: 'normal',
|
|
23
|
+
},
|
|
24
|
+
}
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
const sendButtonIconVariants = cva('inline-flex h-4 w-4 shrink-0 items-center justify-center [&>*]:block [&>*]:leading-none', {
|
|
28
|
+
variants: {
|
|
29
|
+
status: {
|
|
30
|
+
normal: 'text-text-on-primary',
|
|
31
|
+
generating: 'text-text-on-primary',
|
|
32
|
+
recording: 'text-text-on-primary',
|
|
33
|
+
stopRecording: 'text-error',
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
defaultVariants: {
|
|
37
|
+
status: 'normal',
|
|
38
|
+
},
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
const iconClass = 'w-4 h-4'
|
|
42
|
+
|
|
43
|
+
export interface SendButtonProps
|
|
44
|
+
extends ButtonHTMLAttributes<HTMLButtonElement>,
|
|
45
|
+
VariantProps<typeof sendButtonVariants> {
|
|
46
|
+
status?: 'normal' | 'generating' | 'recording' | 'stopRecording'
|
|
47
|
+
/** 主库可注入 useIcon('send');不传则用 registry 内联图标 */
|
|
48
|
+
sendIcon?: ReactNode
|
|
49
|
+
stopIcon?: ReactNode
|
|
50
|
+
micIcon?: ReactNode
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const SendButton = forwardRef<HTMLButtonElement, SendButtonProps>(
|
|
54
|
+
(
|
|
55
|
+
{
|
|
56
|
+
status = 'normal',
|
|
57
|
+
disabled = false,
|
|
58
|
+
className,
|
|
59
|
+
sendIcon,
|
|
60
|
+
stopIcon,
|
|
61
|
+
micIcon,
|
|
62
|
+
...props
|
|
63
|
+
},
|
|
64
|
+
ref
|
|
65
|
+
) => {
|
|
66
|
+
const defaultSend = sendIcon ?? <SendPlaneLine className={iconClass} />
|
|
67
|
+
const defaultStop = stopIcon ?? <StopLine className={iconClass} />
|
|
68
|
+
const defaultMic = micIcon ?? <MicLine className={iconClass} />
|
|
69
|
+
const iconByStatus =
|
|
70
|
+
status === 'normal'
|
|
71
|
+
? defaultSend
|
|
72
|
+
: status === 'generating' || status === 'stopRecording'
|
|
73
|
+
? defaultStop
|
|
74
|
+
: defaultMic
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<button
|
|
78
|
+
ref={ref}
|
|
79
|
+
type="button"
|
|
80
|
+
className={sendButtonVariants({ status, className })}
|
|
81
|
+
disabled={disabled}
|
|
82
|
+
{...props}
|
|
83
|
+
>
|
|
84
|
+
<span className={sendButtonIconVariants({ status })}>
|
|
85
|
+
{iconByStatus}
|
|
86
|
+
</span>
|
|
87
|
+
</button>
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
SendButton.displayName = 'SendButton'
|
|
93
|
+
|
|
94
|
+
export { sendButtonVariants, sendButtonIconVariants }
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
2
|
+
import { MarkdownBody } from './markdown'
|
|
3
|
+
|
|
4
|
+
function segmentForStreaming(text: string): string[] {
|
|
5
|
+
const tokens: string[] = []
|
|
6
|
+
let i = 0
|
|
7
|
+
const isCJK = (c: string) => /[\u4e00-\u9fff\u3040-\u30ff]/.test(c)
|
|
8
|
+
const isWordChar = (c: string) => /[a-zA-Z0-9']/.test(c)
|
|
9
|
+
while (i < text.length) {
|
|
10
|
+
const c = text[i]!
|
|
11
|
+
if (isCJK(c)) {
|
|
12
|
+
tokens.push(c)
|
|
13
|
+
i += 1
|
|
14
|
+
} else if (isWordChar(c)) {
|
|
15
|
+
let end = i
|
|
16
|
+
while (end < text.length && isWordChar(text[end]!)) end += 1
|
|
17
|
+
tokens.push(text.slice(i, end))
|
|
18
|
+
i = end
|
|
19
|
+
} else {
|
|
20
|
+
let end = i
|
|
21
|
+
while (end < text.length && !isCJK(text[end]!) && !isWordChar(text[end]!)) end += 1
|
|
22
|
+
tokens.push(text.slice(i, end))
|
|
23
|
+
i = end
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return tokens
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function splitCompletedBlocks(visibleText: string): { completed: string; partial: string } {
|
|
30
|
+
const blocks = visibleText.split(/\n\n+/)
|
|
31
|
+
if (blocks.length <= 1) return { completed: '', partial: visibleText }
|
|
32
|
+
const partial = blocks.pop() ?? ''
|
|
33
|
+
const completed = blocks.join('\n\n')
|
|
34
|
+
return { completed, partial }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface StreamingMarkdownBlockProps {
|
|
38
|
+
content: string
|
|
39
|
+
isActive: boolean
|
|
40
|
+
onStreamComplete?: () => void
|
|
41
|
+
tokenDelayMs?: number
|
|
42
|
+
className?: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const TOKENS_PER_TICK = 3
|
|
46
|
+
|
|
47
|
+
export function StreamingMarkdownBlock({
|
|
48
|
+
content,
|
|
49
|
+
isActive,
|
|
50
|
+
onStreamComplete,
|
|
51
|
+
tokenDelayMs = 14,
|
|
52
|
+
className,
|
|
53
|
+
}: StreamingMarkdownBlockProps) {
|
|
54
|
+
const tokens = useMemo(() => segmentForStreaming(content), [content])
|
|
55
|
+
const [visibleCount, setVisibleCount] = useState(0)
|
|
56
|
+
const onStreamCompleteRef = useRef(onStreamComplete)
|
|
57
|
+
onStreamCompleteRef.current = onStreamComplete
|
|
58
|
+
|
|
59
|
+
const visibleText = useMemo(
|
|
60
|
+
() => tokens.slice(0, visibleCount).join(''),
|
|
61
|
+
[tokens, visibleCount]
|
|
62
|
+
)
|
|
63
|
+
const { completed, partial } = useMemo(() => splitCompletedBlocks(visibleText), [visibleText])
|
|
64
|
+
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (!isActive) return
|
|
67
|
+
setVisibleCount(0)
|
|
68
|
+
const len = tokens.length
|
|
69
|
+
if (len === 0) {
|
|
70
|
+
onStreamCompleteRef.current?.()
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
let k = 0
|
|
74
|
+
const id = setInterval(() => {
|
|
75
|
+
k = Math.min(k + TOKENS_PER_TICK, len)
|
|
76
|
+
setVisibleCount(k)
|
|
77
|
+
if (k >= len) {
|
|
78
|
+
clearInterval(id)
|
|
79
|
+
onStreamCompleteRef.current?.()
|
|
80
|
+
}
|
|
81
|
+
}, tokenDelayMs)
|
|
82
|
+
return () => clearInterval(id)
|
|
83
|
+
}, [isActive, content, tokens.length, tokenDelayMs])
|
|
84
|
+
|
|
85
|
+
const isStreaming = isActive && visibleCount < tokens.length
|
|
86
|
+
|
|
87
|
+
if (tokens.length === 0) return null
|
|
88
|
+
|
|
89
|
+
if (isStreaming) {
|
|
90
|
+
return (
|
|
91
|
+
<div className={className}>
|
|
92
|
+
{completed ? (
|
|
93
|
+
<div className="streaming-completed">
|
|
94
|
+
<MarkdownBody>{completed}</MarkdownBody>
|
|
95
|
+
</div>
|
|
96
|
+
) : null}
|
|
97
|
+
{partial ? (
|
|
98
|
+
<span className="streaming-partial text-sm leading-sm text-text streaming-token-fade-in">
|
|
99
|
+
{partial}
|
|
100
|
+
</span>
|
|
101
|
+
) : null}
|
|
102
|
+
</div>
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<div className={className}>
|
|
108
|
+
<MarkdownBody>{content}</MarkdownBody>
|
|
109
|
+
</div>
|
|
110
|
+
)
|
|
111
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { cn } from '@/lib/utils'
|
|
3
|
+
import { CollapsibleCard } from '../basic/collapsible-card'
|
|
4
|
+
import { CatalogLine, CircleLine, CheckboxCircleLine, IndeterminateCircleLine, ArrowRightCircleFillLine } from '../basic/icons-inline'
|
|
5
|
+
|
|
6
|
+
const ICON_CLASS = 'w-[var(--font-size-sm)] h-[var(--font-size-sm)] shrink-0'
|
|
7
|
+
|
|
8
|
+
export type TaskStatus = 'completed' | 'uncompleted' | 'skipped' | 'cancelled'
|
|
9
|
+
|
|
10
|
+
export interface TaskPartTask {
|
|
11
|
+
id?: string
|
|
12
|
+
text: string
|
|
13
|
+
status?: TaskStatus
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface TaskPartProps {
|
|
17
|
+
tasks?: TaskPartTask[]
|
|
18
|
+
title?: string
|
|
19
|
+
currentTaskId?: string
|
|
20
|
+
defaultExpanded?: boolean
|
|
21
|
+
className?: string
|
|
22
|
+
headerIcon?: ReactNode
|
|
23
|
+
uncompletedIcon?: ReactNode
|
|
24
|
+
completedIcon?: ReactNode
|
|
25
|
+
skippedIcon?: ReactNode
|
|
26
|
+
loaderIcon?: ReactNode
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const defaultHeaderIcon = <CatalogLine className={cn(ICON_CLASS, 'text-text-secondary')} />
|
|
30
|
+
const defaultUncompleted = <CircleLine className={cn(ICON_CLASS, 'text-text-tertiary')} />
|
|
31
|
+
const defaultCompleted = <CheckboxCircleLine className={cn(ICON_CLASS, 'text-text-quaternary')} />
|
|
32
|
+
const defaultSkipped = <IndeterminateCircleLine className={cn(ICON_CLASS, 'text-text-tertiary')} />
|
|
33
|
+
const defaultLoader = <ArrowRightCircleFillLine className={cn(ICON_CLASS, 'text-text')} />
|
|
34
|
+
|
|
35
|
+
function getStatusIcon(
|
|
36
|
+
task: TaskPartTask,
|
|
37
|
+
isCurrent: boolean,
|
|
38
|
+
icons: {
|
|
39
|
+
loaderIcon?: ReactNode
|
|
40
|
+
completedIcon?: ReactNode
|
|
41
|
+
skippedIcon?: ReactNode
|
|
42
|
+
uncompletedIcon?: ReactNode
|
|
43
|
+
}
|
|
44
|
+
): ReactNode {
|
|
45
|
+
if (isCurrent) return icons.loaderIcon ?? defaultLoader
|
|
46
|
+
if (task.status === 'completed') return icons.completedIcon ?? defaultCompleted
|
|
47
|
+
if (task.status === 'skipped' || task.status === 'cancelled') return icons.skippedIcon ?? defaultSkipped
|
|
48
|
+
return icons.uncompletedIcon ?? defaultUncompleted
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function TaskPart({
|
|
52
|
+
tasks = [],
|
|
53
|
+
title = 'To-dos',
|
|
54
|
+
currentTaskId,
|
|
55
|
+
defaultExpanded = true,
|
|
56
|
+
className,
|
|
57
|
+
headerIcon,
|
|
58
|
+
uncompletedIcon,
|
|
59
|
+
completedIcon,
|
|
60
|
+
skippedIcon,
|
|
61
|
+
loaderIcon,
|
|
62
|
+
}: TaskPartProps) {
|
|
63
|
+
return (
|
|
64
|
+
<CollapsibleCard
|
|
65
|
+
headerIcon={headerIcon ?? defaultHeaderIcon}
|
|
66
|
+
headerTitle={<span className="text-left">{title}</span>}
|
|
67
|
+
defaultExpanded={defaultExpanded}
|
|
68
|
+
contentPadding="var(--spacing-1\.5) var(--spacing-2)"
|
|
69
|
+
showExpandAllBar
|
|
70
|
+
maxHeight={124}
|
|
71
|
+
className={className}
|
|
72
|
+
>
|
|
73
|
+
<div className="flex flex-col gap-2">
|
|
74
|
+
{tasks.map((task, index) => {
|
|
75
|
+
const taskId = task.id ?? index
|
|
76
|
+
const isCurrent = currentTaskId != null && currentTaskId === String(taskId)
|
|
77
|
+
const statusIcon = getStatusIcon(task, isCurrent, {
|
|
78
|
+
loaderIcon,
|
|
79
|
+
completedIcon,
|
|
80
|
+
skippedIcon,
|
|
81
|
+
uncompletedIcon,
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<div key={taskId} className="flex flex-row gap-2 items-start">
|
|
86
|
+
<div className="flex items-center justify-center shrink-0 pt-0.5 [&>svg]:w-[var(--font-size-sm)] [&>svg]:h-[var(--font-size-sm)]">
|
|
87
|
+
{statusIcon}
|
|
88
|
+
</div>
|
|
89
|
+
<span
|
|
90
|
+
className={cn(
|
|
91
|
+
'text-xs leading-xs flex-1 text-left',
|
|
92
|
+
isCurrent
|
|
93
|
+
? 'text-text'
|
|
94
|
+
: task.status === 'completed'
|
|
95
|
+
? 'text-text-quaternary line-through'
|
|
96
|
+
: 'text-text-tertiary'
|
|
97
|
+
)}
|
|
98
|
+
>
|
|
99
|
+
{task.text}
|
|
100
|
+
</span>
|
|
101
|
+
</div>
|
|
102
|
+
)
|
|
103
|
+
})}
|
|
104
|
+
</div>
|
|
105
|
+
</CollapsibleCard>
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
TaskPart.displayName = 'TaskPart'
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { CollapsibleCard } from '../basic/collapsible-card'
|
|
3
|
+
import { IconButton } from '../basic/icon-button'
|
|
4
|
+
import { TerminalBoxLine, ExternalLinkLine } from '../basic/icons-inline'
|
|
5
|
+
|
|
6
|
+
const ICON_CLASS = 'w-[var(--font-size-sm)] h-[var(--font-size-sm)] shrink-0 text-text-secondary'
|
|
7
|
+
|
|
8
|
+
export interface TerminalCodeBlockPartProps {
|
|
9
|
+
command?: string
|
|
10
|
+
output?: string
|
|
11
|
+
autoRanCommand?: string
|
|
12
|
+
className?: string
|
|
13
|
+
headerIcon?: ReactNode
|
|
14
|
+
externalLinkIcon?: ReactNode
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const defaultHeaderIcon = <TerminalBoxLine className={ICON_CLASS} />
|
|
18
|
+
const defaultExternalLinkIcon = <ExternalLinkLine className={ICON_CLASS} />
|
|
19
|
+
|
|
20
|
+
export function TerminalCodeBlockPart({
|
|
21
|
+
command,
|
|
22
|
+
output,
|
|
23
|
+
autoRanCommand,
|
|
24
|
+
className,
|
|
25
|
+
headerIcon,
|
|
26
|
+
externalLinkIcon,
|
|
27
|
+
}: TerminalCodeBlockPartProps) {
|
|
28
|
+
const displayCommand = autoRanCommand ?? (command != null ? `Auto-Ran command: ${command}` : 'Terminal')
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<CollapsibleCard
|
|
32
|
+
headerIcon={headerIcon ?? defaultHeaderIcon}
|
|
33
|
+
headerTitle={<span className="font-normal">{displayCommand}</span>}
|
|
34
|
+
headerRight={
|
|
35
|
+
<IconButton
|
|
36
|
+
variant="iconOnly"
|
|
37
|
+
size="sm"
|
|
38
|
+
icon={externalLinkIcon ?? defaultExternalLinkIcon}
|
|
39
|
+
aria-label="Open in terminal"
|
|
40
|
+
className="!min-h-0 !min-w-0 h-[var(--font-size-sm)] w-[var(--font-size-sm)] text-text-secondary hover:text-text [&_svg]:w-[var(--font-size-sm)] [&_svg]:h-[var(--font-size-sm)]"
|
|
41
|
+
/>
|
|
42
|
+
}
|
|
43
|
+
contentPadding="0"
|
|
44
|
+
className={className}
|
|
45
|
+
>
|
|
46
|
+
<div className="flex flex-col gap-0">
|
|
47
|
+
{command != null && command !== '' && (
|
|
48
|
+
<div className="px-2 py-1.5">
|
|
49
|
+
<div className="flex items-start gap-2">
|
|
50
|
+
<span className="font-mono text-xs text-text shrink-0">$</span>
|
|
51
|
+
<pre className="m-0 flex-1 overflow-x-auto text-left font-mono text-xs leading-xs whitespace-pre text-text bg-transparent scrollbar-auto">
|
|
52
|
+
<code>{command}</code>
|
|
53
|
+
</pre>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
)}
|
|
57
|
+
{output != null && output !== '' && (
|
|
58
|
+
<div className="px-2 py-1.5 overflow-x-auto text-left scrollbar-auto">
|
|
59
|
+
<pre className="m-0 p-0 font-mono text-xs leading-xs whitespace-pre-wrap break-all text-text-tertiary bg-transparent">
|
|
60
|
+
{output}
|
|
61
|
+
</pre>
|
|
62
|
+
</div>
|
|
63
|
+
)}
|
|
64
|
+
</div>
|
|
65
|
+
</CollapsibleCard>
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
TerminalCodeBlockPart.displayName = 'TerminalCodeBlockPart'
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { useMemo } from 'react'
|
|
2
|
+
import type { HTMLAttributes, ReactNode } from 'react'
|
|
3
|
+
import { cn } from '@/lib/utils'
|
|
4
|
+
import { motion } from 'framer-motion'
|
|
5
|
+
|
|
6
|
+
export interface ThinkingIndicatorProps extends HTMLAttributes<HTMLDivElement> {
|
|
7
|
+
text?: string
|
|
8
|
+
textClassName?: string
|
|
9
|
+
shimmer?: boolean
|
|
10
|
+
shimmerDuration?: number
|
|
11
|
+
shimmerSpread?: number
|
|
12
|
+
/** 主库可注入 Lottie 等自定义动画;不传则用 CSS 三点脉动 */
|
|
13
|
+
loader?: ReactNode
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** 默认:CSS 三点脉动,无 Lottie 依赖 */
|
|
17
|
+
function DefaultLoader() {
|
|
18
|
+
return (
|
|
19
|
+
<span className="relative block w-5 h-5 shrink-0 flex items-center justify-center" aria-hidden>
|
|
20
|
+
<style>{`
|
|
21
|
+
.thinking-dots span { animation: thinking-dot 1.4s ease-in-out infinite both; }
|
|
22
|
+
.thinking-dots span:nth-child(1) { animation-delay: 0s; }
|
|
23
|
+
.thinking-dots span:nth-child(2) { animation-delay: 0.2s; }
|
|
24
|
+
.thinking-dots span:nth-child(3) { animation-delay: 0.4s; }
|
|
25
|
+
@keyframes thinking-dot {
|
|
26
|
+
0%, 80%, 100% { transform: scale(0.6); opacity: 0.5; }
|
|
27
|
+
40% { transform: scale(1); opacity: 1; }
|
|
28
|
+
}
|
|
29
|
+
`}</style>
|
|
30
|
+
<span className="thinking-dots inline-flex gap-0.5">
|
|
31
|
+
<span className="w-1 h-1 rounded-full bg-current opacity-70" />
|
|
32
|
+
<span className="w-1 h-1 rounded-full bg-current opacity-70" />
|
|
33
|
+
<span className="w-1 h-1 rounded-full bg-current opacity-70" />
|
|
34
|
+
</span>
|
|
35
|
+
</span>
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function ThinkingIndicator({
|
|
40
|
+
text = 'Qoder is Thinking...',
|
|
41
|
+
textClassName,
|
|
42
|
+
shimmer = true,
|
|
43
|
+
shimmerDuration = 2,
|
|
44
|
+
shimmerSpread = 2,
|
|
45
|
+
className,
|
|
46
|
+
loader,
|
|
47
|
+
...props
|
|
48
|
+
}: ThinkingIndicatorProps) {
|
|
49
|
+
const dynamicSpread = useMemo(() => {
|
|
50
|
+
const textToUse = text || ''
|
|
51
|
+
return textToUse.length * shimmerSpread
|
|
52
|
+
}, [text, shimmerSpread])
|
|
53
|
+
|
|
54
|
+
const loaderContent = loader ?? <DefaultLoader />
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<div
|
|
58
|
+
className={cn('inline-flex items-center gap-1.5 max-w-full', className)}
|
|
59
|
+
{...props}
|
|
60
|
+
>
|
|
61
|
+
<span className="shrink-0 -translate-x-0.5" aria-hidden>
|
|
62
|
+
{loaderContent}
|
|
63
|
+
</span>
|
|
64
|
+
<div className="flex-1 min-w-0 flex items-center">
|
|
65
|
+
{shimmer ? (
|
|
66
|
+
<motion.span
|
|
67
|
+
className={cn(
|
|
68
|
+
'text-sm leading-sm relative inline-block bg-clip-text text-transparent',
|
|
69
|
+
'bg-[length:250%_100%,auto] bg-no-repeat truncate',
|
|
70
|
+
textClassName
|
|
71
|
+
)}
|
|
72
|
+
style={{
|
|
73
|
+
backgroundImage: `linear-gradient(90deg, transparent calc(50% - ${dynamicSpread}px), var(--color-text), transparent calc(50% + ${dynamicSpread}px)), linear-gradient(var(--color-text-secondary), var(--color-text-secondary))`,
|
|
74
|
+
}}
|
|
75
|
+
initial={{ backgroundPosition: '100% center' }}
|
|
76
|
+
animate={{ backgroundPosition: '0% center' }}
|
|
77
|
+
transition={{ repeat: Infinity, duration: shimmerDuration, ease: 'linear' }}
|
|
78
|
+
>
|
|
79
|
+
{text}
|
|
80
|
+
</motion.span>
|
|
81
|
+
) : (
|
|
82
|
+
<span className={cn('text-sm leading-sm text-text-secondary truncate', textClassName)}>
|
|
83
|
+
{text}
|
|
84
|
+
</span>
|
|
85
|
+
)}
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
ThinkingIndicator.displayName = 'ThinkingIndicator'
|