535 lines
15 KiB
TypeScript
535 lines
15 KiB
TypeScript
import { memo, useCallback, useEffect, useRef, type CSSProperties, type ReactNode } from 'react'
|
|
import { Sparkle, X, PaperPlaneRight, Plus, Link } from '@phosphor-icons/react'
|
|
import { AiMessage } from './AiMessage'
|
|
import { Button } from '@/components/ui/button'
|
|
import { ActionTooltip } from '@/components/ui/action-tooltip'
|
|
import { TooltipProvider } from '@/components/ui/tooltip'
|
|
import { WikilinkChatInput } from './WikilinkChatInput'
|
|
import { extractInlineWikilinkReferences } from './inlineWikilinkText'
|
|
import {
|
|
aiAgentPermissionModeLabels,
|
|
type AiAgentPermissionMode,
|
|
} from '../lib/aiAgentPermissionMode'
|
|
import { createTranslator, type AppLocale } from '../lib/i18n'
|
|
import type { AiAgentMessage } from '../hooks/useCliAiAgent'
|
|
import type { AiAgentReadiness } from '../lib/aiAgents'
|
|
import type { NoteReference } from '../utils/ai-context'
|
|
import type { VaultEntry } from '../types'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
interface AiPanelHeaderProps {
|
|
agentLabel: string
|
|
agentReadiness: AiAgentReadiness
|
|
targetKind?: 'agent' | 'api_model'
|
|
locale?: AppLocale
|
|
permissionMode: AiAgentPermissionMode
|
|
permissionModeDisabled: boolean
|
|
onPermissionModeChange: (mode: AiAgentPermissionMode) => void
|
|
onClose: () => void
|
|
onNewChat: () => void
|
|
}
|
|
|
|
interface AiPanelContextBarProps {
|
|
activeEntry: VaultEntry
|
|
locale?: AppLocale
|
|
linkedCount: number
|
|
}
|
|
|
|
interface AiPanelMessageHistoryProps {
|
|
agentLabel: string
|
|
agentReadiness: AiAgentReadiness
|
|
locale?: AppLocale
|
|
messages: AiAgentMessage[]
|
|
isActive: boolean
|
|
onForkMessage?: (messageId: string) => void
|
|
onOpenNote?: (path: string) => void
|
|
onNavigateWikilink?: (target: string) => void
|
|
onRegenerateMessage?: (messageId: string) => void
|
|
onScrollStateChange?: (scrolled: boolean) => void
|
|
hasContext: boolean
|
|
}
|
|
|
|
interface AiPanelComposerProps {
|
|
entries: VaultEntry[]
|
|
agentLabel: string
|
|
agentReadiness: AiAgentReadiness
|
|
locale?: AppLocale
|
|
input: string
|
|
inputRef: React.RefObject<HTMLDivElement | null>
|
|
isActive: boolean
|
|
controls?: ReactNode
|
|
onChange: (value: string) => void
|
|
onSend: (text: string, references: NoteReference[]) => void
|
|
onUnsupportedAiPaste?: (message: string) => void
|
|
}
|
|
|
|
function getComposerPlaceholder(
|
|
agentLabel: string,
|
|
agentReadiness: AiAgentReadiness,
|
|
t: ReturnType<typeof createTranslator>,
|
|
): string {
|
|
if (agentReadiness === 'checking') {
|
|
return t('ai.panel.placeholder.checking')
|
|
}
|
|
|
|
if (agentReadiness === 'missing') {
|
|
return t('ai.panel.placeholder.missing', { agent: agentLabel })
|
|
}
|
|
|
|
return t('ai.panel.placeholder.ready', { agent: agentLabel })
|
|
}
|
|
|
|
function composerSendButtonStyle(canSend: boolean): CSSProperties {
|
|
return {
|
|
background: canSend ? 'var(--primary)' : 'var(--muted)',
|
|
color: canSend ? 'var(--primary-foreground)' : 'var(--muted-foreground)',
|
|
borderRadius: 8,
|
|
width: 30,
|
|
height: 30,
|
|
cursor: canSend ? 'pointer' : 'not-allowed',
|
|
}
|
|
}
|
|
|
|
function ComposerInput({
|
|
disabled,
|
|
entries,
|
|
hasControls,
|
|
input,
|
|
inputRef,
|
|
onChange,
|
|
onSend,
|
|
onUnsupportedAiPaste,
|
|
placeholder,
|
|
}: {
|
|
disabled: boolean
|
|
entries: VaultEntry[]
|
|
hasControls: boolean
|
|
input: string
|
|
inputRef: React.RefObject<HTMLDivElement | null>
|
|
onChange: (value: string) => void
|
|
onSend: (text: string, references: NoteReference[]) => void
|
|
onUnsupportedAiPaste?: (message: string) => void
|
|
placeholder: string
|
|
}) {
|
|
return (
|
|
<WikilinkChatInput
|
|
entries={entries}
|
|
value={input}
|
|
onChange={onChange}
|
|
onSend={onSend}
|
|
onUnsupportedPaste={onUnsupportedAiPaste}
|
|
disabled={disabled}
|
|
placeholder={placeholder}
|
|
placeholderClassName={hasControls ? 'px-2 py-1.5 text-[13px] leading-5' : undefined}
|
|
inputRef={inputRef}
|
|
editorClassName={cn(
|
|
'max-h-[120px] overflow-y-auto overscroll-contain',
|
|
hasControls && 'min-h-[34px] border-0 px-2 py-1.5 leading-5',
|
|
)}
|
|
editorStyle={{ maxHeight: 120, overflowY: 'auto', overscrollBehavior: 'contain' }}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function ComposerSendButton({
|
|
canSend,
|
|
entries,
|
|
input,
|
|
label,
|
|
onSend,
|
|
}: {
|
|
canSend: boolean
|
|
entries: VaultEntry[]
|
|
input: string
|
|
label: string
|
|
onSend: (text: string, references: NoteReference[]) => void
|
|
}) {
|
|
return (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
className="shrink-0 flex items-center justify-center border-none cursor-pointer transition-colors"
|
|
style={composerSendButtonStyle(canSend)}
|
|
onClick={() => onSend(input, extractInlineWikilinkReferences(input, entries))}
|
|
disabled={!canSend}
|
|
aria-label={label}
|
|
title={label}
|
|
data-testid="agent-send"
|
|
>
|
|
<PaperPlaneRight size={16} />
|
|
</Button>
|
|
)
|
|
}
|
|
|
|
function ComposerControlsRow({
|
|
children,
|
|
hasControls,
|
|
sendButton,
|
|
}: {
|
|
children?: ReactNode
|
|
hasControls: boolean
|
|
sendButton: ReactNode
|
|
}) {
|
|
if (!hasControls) return <>{sendButton}</>
|
|
|
|
return (
|
|
<div className="mt-0.5 flex items-center justify-between gap-2">
|
|
<div className="flex min-w-0 items-center gap-1">
|
|
{children}
|
|
</div>
|
|
{sendButton}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function permissionModeTooltip(
|
|
mode: AiAgentPermissionMode,
|
|
t: ReturnType<typeof createTranslator>,
|
|
): { label: string } {
|
|
return {
|
|
label: t(mode === 'power_user'
|
|
? 'ai.permission.powerUser.tooltip'
|
|
: 'ai.permission.safe.tooltip'),
|
|
}
|
|
}
|
|
|
|
function headerStatusText({
|
|
agentLabel,
|
|
agentReadiness,
|
|
modeLabel,
|
|
t,
|
|
}: {
|
|
agentLabel: string
|
|
agentReadiness: AiAgentReadiness
|
|
modeLabel: string
|
|
t: ReturnType<typeof createTranslator>
|
|
}): string {
|
|
if (agentReadiness === 'checking') return t('ai.panel.status.checking')
|
|
if (agentReadiness === 'missing') return t('ai.panel.status.missing', { agent: agentLabel })
|
|
return t('ai.panel.status.ready', { agent: agentLabel, mode: modeLabel })
|
|
}
|
|
|
|
function AiPanelEmptyState({
|
|
agentLabel,
|
|
agentReadiness,
|
|
hasContext,
|
|
locale = 'en',
|
|
}: Pick<AiPanelMessageHistoryProps, 'agentLabel' | 'agentReadiness' | 'hasContext' | 'locale'>) {
|
|
const t = createTranslator(locale)
|
|
|
|
if (agentReadiness === 'checking') {
|
|
return (
|
|
<div
|
|
className="flex flex-col items-center justify-center text-center text-muted-foreground"
|
|
style={{ paddingTop: 40 }}
|
|
>
|
|
<Sparkle size={24} style={{ marginBottom: 8, opacity: 0.5 }} />
|
|
<p style={{ fontSize: 13, margin: '0 0 4px' }}>
|
|
{t('ai.panel.empty.checkingTitle')}
|
|
</p>
|
|
<p style={{ fontSize: 11, margin: 0, opacity: 0.6 }}>
|
|
{t('ai.panel.empty.checkingDescription')}
|
|
</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (agentReadiness === 'missing') {
|
|
return (
|
|
<div
|
|
className="flex flex-col items-center justify-center text-center text-muted-foreground"
|
|
style={{ paddingTop: 40 }}
|
|
>
|
|
<Sparkle size={24} style={{ marginBottom: 8, opacity: 0.5 }} />
|
|
<p style={{ fontSize: 13, margin: '0 0 4px' }}>
|
|
{t('ai.panel.empty.missingTitle', { agent: agentLabel })}
|
|
</p>
|
|
<p style={{ fontSize: 11, margin: 0, opacity: 0.6 }}>
|
|
{t('ai.panel.empty.missingDescription')}
|
|
</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className="flex flex-col items-center justify-center text-center text-muted-foreground"
|
|
style={{ paddingTop: 40 }}
|
|
>
|
|
<Sparkle size={24} style={{ marginBottom: 8, opacity: 0.5 }} />
|
|
<p style={{ fontSize: 13, margin: '0 0 4px' }}>
|
|
{hasContext
|
|
? t('ai.panel.empty.withContextTitle', { agent: agentLabel })
|
|
: t('ai.panel.empty.noContextTitle', { agent: agentLabel })
|
|
}
|
|
</p>
|
|
<p style={{ fontSize: 11, margin: 0, opacity: 0.6 }}>
|
|
{hasContext
|
|
? t('ai.panel.empty.withContextDescription')
|
|
: t('ai.panel.empty.noContextDescription')
|
|
}
|
|
</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export const AiPanelHeader = memo(function AiPanelHeader({
|
|
agentLabel,
|
|
agentReadiness,
|
|
targetKind = 'agent',
|
|
locale = 'en',
|
|
permissionMode,
|
|
permissionModeDisabled,
|
|
onPermissionModeChange,
|
|
onClose,
|
|
onNewChat,
|
|
}: AiPanelHeaderProps) {
|
|
const t = createTranslator(locale)
|
|
const modeLabel = targetKind === 'api_model'
|
|
? t('ai.panel.mode.chat')
|
|
: aiAgentPermissionModeLabels(permissionMode, locale).short
|
|
|
|
return (
|
|
<div
|
|
className="flex shrink-0 flex-col border-b border-border"
|
|
style={{ padding: '8px 12px', gap: 8 }}
|
|
>
|
|
<div className="flex items-center" style={{ gap: 8 }}>
|
|
<Sparkle size={16} className="shrink-0 text-muted-foreground" />
|
|
<div className="flex flex-1 flex-col overflow-hidden">
|
|
<span className="text-muted-foreground" style={{ fontSize: 13, fontWeight: 600 }}>
|
|
{t('ai.panel.title')}
|
|
</span>
|
|
<span className="truncate text-[11px] text-muted-foreground">
|
|
{headerStatusText({ agentLabel, agentReadiness, modeLabel, t })}
|
|
</span>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon-xs"
|
|
className="h-6 w-6 p-0 [&_svg:not([class*=size-])]:size-4"
|
|
onClick={onNewChat}
|
|
aria-label={t('ai.panel.newChat')}
|
|
title={t('ai.panel.newChat')}
|
|
>
|
|
<Plus size={16} />
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon-xs"
|
|
className="h-6 w-6 p-0 [&_svg:not([class*=size-])]:size-4"
|
|
onClick={onClose}
|
|
aria-label={t('ai.panel.close')}
|
|
title={t('ai.panel.close')}
|
|
>
|
|
<X size={16} />
|
|
</Button>
|
|
</div>
|
|
{targetKind === 'agent' ? (
|
|
<AiPermissionModeToggle
|
|
value={permissionMode}
|
|
locale={locale}
|
|
disabled={permissionModeDisabled}
|
|
onChange={onPermissionModeChange}
|
|
/>
|
|
) : (
|
|
<div className="rounded-md border border-border bg-muted px-3 py-2 text-[11px] leading-5 text-muted-foreground">
|
|
{t('ai.panel.mode.chatDescription')}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})
|
|
|
|
function AiPermissionModeToggle({
|
|
value,
|
|
locale = 'en',
|
|
disabled,
|
|
onChange,
|
|
}: {
|
|
value: AiAgentPermissionMode
|
|
locale?: AppLocale
|
|
disabled: boolean
|
|
onChange: (mode: AiAgentPermissionMode) => void
|
|
}) {
|
|
const t = createTranslator(locale)
|
|
|
|
return (
|
|
<TooltipProvider>
|
|
<div
|
|
className="inline-flex w-full rounded-md border border-border bg-muted p-1"
|
|
role="radiogroup"
|
|
aria-label={t('ai.permission.modeAria')}
|
|
data-testid="ai-permission-mode-toggle"
|
|
>
|
|
{(['safe', 'power_user'] as const).map((mode) => {
|
|
const selected = value === mode
|
|
return (
|
|
<ActionTooltip
|
|
key={mode}
|
|
copy={permissionModeTooltip(mode, t)}
|
|
side="bottom"
|
|
contentTestId="ai-permission-mode-tooltip"
|
|
>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="ghost"
|
|
role="radio"
|
|
aria-checked={selected}
|
|
disabled={disabled}
|
|
className={
|
|
selected
|
|
? 'h-7 flex-1 border border-border bg-background text-foreground shadow-xs hover:bg-background'
|
|
: 'h-7 flex-1 text-muted-foreground hover:text-foreground'
|
|
}
|
|
onClick={() => onChange(mode)}
|
|
>
|
|
{aiAgentPermissionModeLabels(mode, locale).control}
|
|
</Button>
|
|
</ActionTooltip>
|
|
)
|
|
})}
|
|
</div>
|
|
</TooltipProvider>
|
|
)
|
|
}
|
|
|
|
export const AiPanelContextBar = memo(function AiPanelContextBar({
|
|
activeEntry,
|
|
linkedCount,
|
|
locale = 'en',
|
|
}: AiPanelContextBarProps) {
|
|
const t = createTranslator(locale)
|
|
|
|
return (
|
|
<div
|
|
className="flex shrink-0 items-center border-b border-border text-muted-foreground"
|
|
style={{ padding: '6px 12px', gap: 6, fontSize: 11 }}
|
|
data-testid="context-bar"
|
|
>
|
|
<Link size={12} className="shrink-0" />
|
|
<span className="truncate" style={{ fontWeight: 500 }}>{activeEntry.title}</span>
|
|
{linkedCount > 0 && (
|
|
<span style={{ opacity: 0.6 }}>{t('ai.panel.linkedCount', { count: linkedCount })}</span>
|
|
)}
|
|
</div>
|
|
)
|
|
})
|
|
|
|
export const AiPanelMessageHistory = memo(function AiPanelMessageHistory({
|
|
agentLabel,
|
|
agentReadiness,
|
|
locale = 'en',
|
|
messages,
|
|
isActive,
|
|
onForkMessage,
|
|
onOpenNote,
|
|
onNavigateWikilink,
|
|
onRegenerateMessage,
|
|
onScrollStateChange,
|
|
hasContext,
|
|
}: AiPanelMessageHistoryProps) {
|
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
const endRef = useRef<HTMLDivElement>(null)
|
|
|
|
const updateScrollState = useCallback(() => {
|
|
const element = containerRef.current
|
|
onScrollStateChange?.((element?.scrollTop ?? 0) > 1)
|
|
}, [onScrollStateChange])
|
|
|
|
useEffect(() => {
|
|
void isActive
|
|
void messages
|
|
endRef.current?.scrollIntoView({ behavior: 'smooth' })
|
|
if (typeof window.requestAnimationFrame === 'function') window.requestAnimationFrame(updateScrollState)
|
|
else updateScrollState()
|
|
}, [messages, isActive, updateScrollState])
|
|
|
|
return (
|
|
<div ref={containerRef} className="flex-1 overflow-y-auto" style={{ padding: 12 }} onScroll={updateScrollState}>
|
|
{messages.length === 0 && !isActive && (
|
|
<AiPanelEmptyState
|
|
agentLabel={agentLabel}
|
|
agentReadiness={agentReadiness}
|
|
locale={locale}
|
|
hasContext={hasContext}
|
|
/>
|
|
)}
|
|
{messages.map((message, index) => (
|
|
<AiMessage
|
|
key={message.id ?? index}
|
|
{...message}
|
|
locale={locale}
|
|
messageId={message.id}
|
|
onFork={onForkMessage}
|
|
onOpenNote={onOpenNote}
|
|
onNavigateWikilink={onNavigateWikilink}
|
|
onRegenerate={onRegenerateMessage}
|
|
/>
|
|
))}
|
|
<div ref={endRef} />
|
|
</div>
|
|
)
|
|
})
|
|
|
|
export function AiPanelComposer({
|
|
entries,
|
|
agentLabel,
|
|
agentReadiness,
|
|
locale = 'en',
|
|
input,
|
|
inputRef,
|
|
isActive,
|
|
controls,
|
|
onChange,
|
|
onSend,
|
|
onUnsupportedAiPaste,
|
|
}: AiPanelComposerProps) {
|
|
const t = createTranslator(locale)
|
|
const composerDisabled = isActive || agentReadiness !== 'ready'
|
|
const canSend = !composerDisabled && input.trim().length > 0
|
|
const placeholder = getComposerPlaceholder(agentLabel, agentReadiness, t)
|
|
const hasControls = controls !== undefined && controls !== null
|
|
const sendButton = (
|
|
<ComposerSendButton
|
|
canSend={canSend}
|
|
entries={entries}
|
|
input={input}
|
|
label={t('ai.panel.send')}
|
|
onSend={onSend}
|
|
/>
|
|
)
|
|
|
|
return (
|
|
<div
|
|
className="flex shrink-0 flex-col"
|
|
style={{ padding: '6px 10px' }}
|
|
>
|
|
<div className={cn(
|
|
hasControls ? 'rounded-xl border border-border bg-background px-2 py-1.5 shadow-xs' : 'flex items-end gap-2',
|
|
)}>
|
|
<div className={cn('min-w-0 flex-1', hasControls && 'w-full')}>
|
|
<ComposerInput
|
|
disabled={composerDisabled}
|
|
entries={entries}
|
|
hasControls={hasControls}
|
|
input={input}
|
|
inputRef={inputRef}
|
|
onChange={onChange}
|
|
onSend={onSend}
|
|
onUnsupportedAiPaste={onUnsupportedAiPaste}
|
|
placeholder={placeholder}
|
|
/>
|
|
</div>
|
|
<ComposerControlsRow hasControls={hasControls} sendButton={sendButton}>
|
|
{controls}
|
|
</ComposerControlsRow>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|