picoclaw/web/frontend/src/components/chat/chat-composer.tsx
lxowalle 2992eccbf0
feat: add request-scoped context policies (#2914)
* feat: add request-scoped context policies

Add named turn profiles under agents.defaults so callers can opt into
per-request context and tool policies without changing default chat behavior.

Profiles can disable history, system context, skill prompts, or tools, and can
limit skills/tools with allow lists. Wire profile selection through Pico message
payloads, agent turn execution, Web chat selection, and Web visual config.

Reject invalid turn profiles before saving config through Web APIs and document
the new request context policy behavior.

* fix: address turn profile review blockers

* feat: simplify request context policy config

* fix: suppress tool prompt when turn tools are disabled

* fix: enforce turn profile tool restrictions
2026-05-22 10:06:40 +08:00

182 lines
6.3 KiB
TypeScript

import { IconArrowUp, IconPhotoPlus, IconX } from "@tabler/icons-react"
import { useRef, type KeyboardEvent as ReactKeyboardEvent } from "react"
import { useTranslation } from "react-i18next"
import TextareaAutosize from "react-textarea-autosize"
import { ContextUsageRing } from "@/components/chat/context-usage-ring"
import { Button } from "@/components/ui/button"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
import type { ChatAttachment, ContextUsage } from "@/store/chat"
export type ChatInputDisabledReason =
| "gatewayUnknown"
| "gatewayStarting"
| "gatewayRestarting"
| "gatewayStopping"
| "gatewayStopped"
| "gatewayError"
| "websocketConnecting"
| "websocketDisconnected"
| "websocketError"
| "noDefaultModel"
interface ChatComposerProps {
input: string
attachments: ChatAttachment[]
onInputChange: (value: string) => void
onAddImages: () => void
onRemoveAttachment: (index: number) => void
onSend: () => void
onContextDetail?: () => void
inputDisabledReason: ChatInputDisabledReason | null
canSend: boolean
contextUsage?: ContextUsage
}
export function ChatComposer({
input,
attachments,
onInputChange,
onAddImages,
onRemoveAttachment,
onSend,
onContextDetail,
inputDisabledReason,
canSend,
contextUsage,
}: ChatComposerProps) {
const { t } = useTranslation()
const canInput = inputDisabledReason === null
const composingRef = useRef(false)
const disabledMessage =
inputDisabledReason === null
? null
: t(`chat.disabledPlaceholder.${inputDisabledReason}`)
const placeholder = disabledMessage ?? t("chat.placeholder")
const handleKeyDown = (e: ReactKeyboardEvent<HTMLTextAreaElement>) => {
const nativeEvent = e.nativeEvent as Event & {
isComposing?: boolean
keyCode?: number
}
if (
composingRef.current ||
nativeEvent.isComposing ||
nativeEvent.keyCode === 229
) {
return
}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
onSend()
}
}
return (
<div className="before:bg-background pointer-events-none relative z-10 -mt-[24px] shrink-0 overflow-y-auto px-4 pb-[calc(1rem+env(safe-area-inset-bottom))] [scrollbar-gutter:stable] before:pointer-events-none before:absolute before:inset-x-0 before:top-[24px] before:bottom-0 before:content-[''] md:px-8 md:pb-8 lg:px-24 xl:px-48">
<div className="bg-card border-border/60 pointer-events-auto relative mx-auto flex max-w-[1000px] flex-col rounded-2xl border p-3 shadow-sm">
{attachments.length > 0 && (
<div className="mb-3 flex flex-wrap gap-2 px-2">
{attachments.map((attachment, index) => (
<div
key={`${attachment.url}-${index}`}
className="bg-background relative h-20 w-20 overflow-hidden rounded-xl border"
>
<img
src={attachment.url}
alt={attachment.filename || t("chat.uploadedImage")}
className="h-full w-full object-cover"
/>
<button
type="button"
onClick={() => onRemoveAttachment(index)}
className="bg-background/85 text-foreground absolute top-1 right-1 inline-flex h-6 w-6 items-center justify-center rounded-full border shadow-sm transition hover:bg-white"
aria-label={t("chat.removeImage")}
title={t("chat.removeImage")}
>
<IconX className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
)}
<TextareaAutosize
value={input}
onChange={(e) => onInputChange(e.target.value)}
onCompositionStart={() => {
composingRef.current = true
}}
onCompositionEnd={() => {
composingRef.current = false
}}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={!canInput}
title={disabledMessage || undefined}
className={cn(
"placeholder:text-muted-foreground/50 max-h-[200px] min-h-[64px] resize-none border-0 bg-transparent px-2 py-1 text-[15px] shadow-none transition-colors focus-visible:ring-0 focus-visible:outline-none dark:bg-transparent",
!canInput && "cursor-not-allowed",
)}
minRows={1}
maxRows={8}
/>
<div className="mt-2 flex items-center justify-between px-1">
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground h-8 w-8 rounded-full"
onClick={onAddImages}
disabled={!canInput}
aria-label={t("chat.attachImage")}
title={t("chat.attachImage")}
>
<IconPhotoPlus className="size-4" />
</Button>
</div>
<div className="flex items-center gap-1.5">
{contextUsage && (
<ContextUsageRing
usage={contextUsage}
onDetailClick={onContextDetail}
/>
)}
{canInput ? (
<Tooltip delayDuration={700}>
<TooltipTrigger asChild>
<span tabIndex={!canSend ? 0 : undefined}>
<Button
type="button"
size="icon"
className="size-8 rounded-full bg-violet-500 text-white transition-transform hover:bg-violet-600 active:scale-95"
onClick={onSend}
disabled={!canSend}
aria-label={t("chat.sendMessage")}
>
<IconArrowUp className="size-4" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent
className="border-border/70 bg-muted text-foreground border text-center whitespace-pre-line shadow-lg shadow-black/10 dark:shadow-black/30"
arrowClassName="bg-muted fill-muted"
>
{t("chat.sendHint")}
</TooltipContent>
</Tooltip>
) : null}
</div>
</div>
</div>
</div>
)
}