* 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
182 lines
6.3 KiB
TypeScript
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>
|
|
)
|
|
}
|