初始化模版工程
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user