初始化模版工程

This commit is contained in:
Cloud Bot
2026-03-20 07:33:46 +00:00
commit 23717e0ecd
386 changed files with 51675 additions and 0 deletions

View File

@@ -0,0 +1,30 @@
import { memo, useState } from 'react'
import { InteractionButtons } from './InteractionButtons'
const TAKEOVER_ACTIONS = ['尝试接管', '立即跳过'] as const
interface BrowserTakeoverInteractionProps {
disabled?: boolean
browser_type?: string
onSendMessage?: (content: string) => void
}
export const BrowserTakeoverInteraction = memo(
({ disabled, onSendMessage }: BrowserTakeoverInteractionProps) => {
const [isClicked, setIsClicked] = useState(false)
const handleClick = (action: string) => {
setIsClicked(true)
onSendMessage?.(action)
}
return (
<InteractionButtons
items={[...TAKEOVER_ACTIONS]}
disabled={disabled}
isClicked={isClicked}
onClick={handleClick}
/>
)
},
)

View File

@@ -0,0 +1,31 @@
import { memo, useState } from 'react'
import type { UserInteraction } from '../../../types'
import { InteractionButtons } from './InteractionButtons'
interface ChoiceInteractionProps {
choice_options?: UserInteraction['choice_options']
disabled?: boolean
onSendMessage?: (content: string) => void
}
export const ChoiceInteraction = memo(
({ choice_options, disabled, onSendMessage }: ChoiceInteractionProps) => {
const [isClicked, setIsClicked] = useState(false)
const handleClick = (label: string) => {
setIsClicked(true)
onSendMessage?.(label)
}
if (!choice_options?.length) return null
return (
<InteractionButtons
items={choice_options}
disabled={disabled}
isClicked={isClicked}
onClick={handleClick}
/>
)
},
)

View File

@@ -0,0 +1,61 @@
import { memo } from 'react'
import { Button } from '@/components/ui/button'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
interface ButtonItem {
label: string
disabled?: boolean
tooltip?: string
}
interface InteractionButtonsProps {
items: Array<string | ButtonItem>
disabled?: boolean
isClicked?: boolean
onClick: (label: string) => void
}
export const InteractionButtons = memo(
({ items, disabled, isClicked, onClick }: InteractionButtonsProps) => {
if (!items?.length) return null
return (
<TooltipProvider>
<div className="flex items-center flex-wrap gap-2 mt-2">
{items.map((item, idx) => {
const label = typeof item === 'string' ? item : item.label
const btnDisabled = typeof item === 'string' ? false : !!item.disabled
const tooltip = typeof item === 'string' ? undefined : item.tooltip
const button = (
<Button
key={`${label}_${idx}`}
variant="outline"
size="sm"
disabled={isClicked || disabled || btnDisabled}
onClick={() => onClick(label)}
className="h-auto cursor-pointer border-border bg-secondary/60 px-3 py-1.5 text-sm font-normal text-foreground hover:border-primary/40 hover:bg-accent hover:text-primary"
>
{label}
</Button>
)
return tooltip ? (
<Tooltip key={`${label}_${idx}`}>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent>{tooltip}</TooltipContent>
</Tooltip>
) : (
button
)
})}
</div>
</TooltipProvider>
)
},
)

View File

@@ -0,0 +1,33 @@
import { memo } from 'react'
import { cn } from '@/utils/cn'
import { MarkdownContent } from '../../../task-panel/Preview/MarkdownPreview'
interface InteractionWrapperProps {
content?: string
isLatest?: boolean
className?: string
children: React.ReactNode
}
export const InteractionWrapper = memo(
({ content, children, className }: InteractionWrapperProps) => {
const trimmedContent = content?.trim()
return (
<div className={cn('mt-2 w-full', className)}>
<div className="relative rounded-r-lg border border-solid border-border/60 bg-card/90 py-2 pl-4 pr-3">
{/* 左侧主色竖条 */}
<div className="absolute left-0 top-0 bottom-0 w-1 bg-primary rounded-l-lg" />
{trimmedContent && (
<div className="mb-2">
<MarkdownContent content={trimmedContent} />
</div>
)}
{children}
</div>
</div>
)
},
)

View File

@@ -0,0 +1,166 @@
import { memo, useEffect, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { cn } from '@/utils/cn'
import type { SlideQuestion } from '../../../types'
interface SlideFormInteractionProps {
questions?: SlideQuestion[]
disabled?: boolean
messageTime?: string
onSendMessage?: (content: string) => void
}
const COUNTDOWN_SECONDS = 30
export const SlideFormInteraction = memo(
({
questions,
disabled,
messageTime,
onSendMessage,
}: SlideFormInteractionProps) => {
const [answers, setAnswers] = useState<Record<number, string>>({})
const [submitted, setSubmitted] = useState(false)
const [seconds, setSeconds] = useState(messageTime ? COUNTDOWN_SECONDS : 0)
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
// 初始化已有答案
useEffect(() => {
if (!questions) return
const initial: Record<number, string> = {}
questions.forEach((q, i) => {
if (q.answer != null) initial[i] = q.answer
})
setAnswers(initial)
}, [questions])
// 倒计时
useEffect(() => {
if (!messageTime || seconds <= 0) return
timerRef.current = setInterval(() => {
setSeconds(s => {
if (s <= 1) {
clearInterval(timerRef.current!)
handleSubmit()
return 0
}
return s - 1
})
}, 1000)
return () => { if (timerRef.current) clearInterval(timerRef.current) }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [messageTime])
function handleSubmit() {
if (submitted) return
setSubmitted(true)
if (timerRef.current) clearInterval(timerRef.current)
const content = {
questions: questions?.map((q, idx) => ({
...q,
answer: answers[idx] ?? '',
})),
expected_user_action: 'slide_message_reply',
}
onSendMessage?.(JSON.stringify(content))
}
if (!questions?.length) return null
const isDisabled = disabled || submitted || seconds === 0
return (
<div
className={cn('mt-2 w-full overflow-hidden rounded-xl border border-border')}
style={{
background:
'radial-gradient(58% 12% at 97% 1%, var(--page-glow-2) 0%, rgba(0,0,0,0) 100%), radial-gradient(108% 26% at 10% -4%, var(--page-glow-1) 0%, rgba(0,0,0,0) 100%), var(--card)',
}}
>
{/* 标题 */}
<div className="flex items-center gap-1 px-3 py-2.5 font-medium text-primary">
<span className="text-sm"> </span>
</div>
{/* 问题列表 */}
<div className="px-3 pb-3 flex flex-col gap-3">
{questions.map((q, idx) => (
<div key={idx} className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-foreground">{q.question}</label>
{q.type === 'input' || !q.options?.length ? (
<Input
value={answers[idx] ?? ''}
onChange={e =>
setAnswers(prev => ({ ...prev, [idx]: e.target.value }))
}
disabled={isDisabled}
className="h-8 text-sm"
/>
) : (
<div className="flex flex-wrap gap-2">
{q.options.map(opt => {
const selected =
q.type === 'multiple'
? (answers[idx] ?? '').split(',').includes(opt)
: answers[idx] === opt
return (
<button
key={opt}
type="button"
disabled={isDisabled}
onClick={() => {
if (q.type === 'multiple') {
const current = (answers[idx] ?? '').split(',').filter(Boolean)
const next = selected
? current.filter(v => v !== opt)
: [...current, opt]
setAnswers(prev => ({ ...prev, [idx]: next.join(',') }))
} else {
setAnswers(prev => ({ ...prev, [idx]: opt }))
}
}}
className={cn(
'rounded-lg border px-3 py-1.5 text-sm transition-colors',
selected
? 'border-primary/40 bg-primary/10 text-primary'
: 'border-border/60 bg-secondary/60 text-foreground hover:bg-accent',
isDisabled && 'opacity-50 cursor-not-allowed',
)}
>
{opt}
</button>
)
})}
</div>
)}
</div>
))}
{/* 底部:倒计时 + 提交 */}
<div className="flex items-center justify-between mt-1">
<span className="text-sm text-muted-foreground">
{submitted || seconds === 0 ? (
'已自动运行'
) : (
<>
<span className="text-primary">{seconds}s</span>
</>
)}
</span>
<Button
size="sm"
onClick={handleSubmit}
disabled={isDisabled}
className="text-sm h-7"
>
</Button>
</div>
</div>
</div>
)
},
)

View File

@@ -0,0 +1,83 @@
import { memo } from 'react'
import type { UserInteraction as UserInteractionData } from '../../../types'
import { InteractionWrapper } from './InteractionWrapper'
import { ChoiceInteraction } from './ChoiceInteraction'
import { BrowserTakeoverInteraction } from './BrowserTakeoverInteraction'
import { SlideFormInteraction } from './SlideFormInteraction'
export { InteractionWrapper } from './InteractionWrapper'
export { InteractionButtons } from './InteractionButtons'
export { ChoiceInteraction } from './ChoiceInteraction'
export { BrowserTakeoverInteraction } from './BrowserTakeoverInteraction'
export { SlideFormInteraction } from './SlideFormInteraction'
interface UserInteractionProps {
userInteraction: UserInteractionData
disabled?: boolean
isLatest?: boolean
onSendMessage?: (content: string) => void
}
/**
* 用户交互主入口组件
* 根据 userInteraction.type 分发到对应的交互子组件
*/
export const UserInteractionWidget = memo(
({ userInteraction, disabled, isLatest, onSendMessage }: UserInteractionProps) => {
const {
type,
content,
choice_options,
questions,
browser_type,
expected_user_action,
take_over_type,
messageTime,
} = userInteraction as UserInteractionData & { messageTime?: string }
let inner: React.ReactNode = null
// 浏览器接管交互:与 Remix 一致,优先根据 expected_user_action 判断
if (
expected_user_action === 'take_over_web_browser' ||
type === 'browser_takeover' ||
type === 'browser_use_takeover'
) {
if (take_over_type !== 'reback') {
inner = (
<BrowserTakeoverInteraction
disabled={disabled}
browser_type={browser_type}
onSendMessage={onSendMessage}
/>
)
}
} else if (choice_options?.length) {
inner = (
<ChoiceInteraction
choice_options={choice_options}
disabled={disabled}
onSendMessage={onSendMessage}
/>
)
} else if (questions?.length) {
inner = (
<SlideFormInteraction
questions={questions}
disabled={disabled}
messageTime={messageTime}
onSendMessage={onSendMessage}
/>
)
}
// 有文本内容或有交互子组件时才渲染
if (!inner && !content?.trim()) return null
return (
<InteractionWrapper content={content} isLatest={isLatest}>
{inner}
</InteractionWrapper>
)
},
)