feat: prepare Zhinian desktop client for pilot release
This commit is contained in:
@@ -6,17 +6,14 @@
|
||||
* Files are staged to disk via IPC — only lightweight path references
|
||||
* are sent with the message (no base64 over WebSocket).
|
||||
*/
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||
import { SendHorizontal, Square, X, Paperclip, FileText, Film, Music, FileArchive, File, Loader2, AtSign } from 'lucide-react';
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { Check, ChevronDown, SendHorizontal, Square, X, Paperclip, FileText, Film, Music, FileArchive, File, Loader2, BookOpen } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { hostApiFetch } from '@/lib/host-api';
|
||||
import { invokeIpc } from '@/lib/api-client';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useGatewayStore } from '@/stores/gateway';
|
||||
import { useAgentsStore } from '@/stores/agents';
|
||||
import { useChatStore } from '@/stores/chat';
|
||||
import type { AgentSummary } from '@/types/agent';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────
|
||||
@@ -32,12 +29,29 @@ export interface FileAttachment {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface KnowledgeContextDocument {
|
||||
id: string;
|
||||
name: string;
|
||||
mimeType: string;
|
||||
storedPath: string;
|
||||
textPath?: string;
|
||||
}
|
||||
|
||||
const EMPTY_KNOWLEDGE_DOCUMENTS: KnowledgeContextDocument[] = [];
|
||||
|
||||
interface ChatInputProps {
|
||||
onSend: (text: string, attachments?: FileAttachment[], targetAgentId?: string | null) => void;
|
||||
onSend: (
|
||||
text: string,
|
||||
attachments?: FileAttachment[],
|
||||
targetAgentId?: string | null,
|
||||
options?: { useKnowledgeBase?: boolean; workspaceId?: string; selectedKnowledgeDocumentIds?: string[] },
|
||||
) => void;
|
||||
onStop?: () => void;
|
||||
disabled?: boolean;
|
||||
sending?: boolean;
|
||||
isEmpty?: boolean;
|
||||
knowledgeDocuments?: KnowledgeContextDocument[];
|
||||
workspaceId?: string;
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────
|
||||
@@ -84,31 +98,26 @@ function readFileAsBase64(file: globalThis.File): Promise<string> {
|
||||
|
||||
// ── Component ────────────────────────────────────────────────────
|
||||
|
||||
export function ChatInput({ onSend, onStop, disabled = false, sending = false, isEmpty = false }: ChatInputProps) {
|
||||
export function ChatInput({
|
||||
onSend,
|
||||
onStop,
|
||||
disabled = false,
|
||||
sending = false,
|
||||
isEmpty = false,
|
||||
knowledgeDocuments = EMPTY_KNOWLEDGE_DOCUMENTS,
|
||||
workspaceId,
|
||||
}: ChatInputProps) {
|
||||
const { t } = useTranslation('chat');
|
||||
const [input, setInput] = useState('');
|
||||
const [attachments, setAttachments] = useState<FileAttachment[]>([]);
|
||||
const [targetAgentId, setTargetAgentId] = useState<string | null>(null);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [knowledgePickerOpen, setKnowledgePickerOpen] = useState(false);
|
||||
const [selectedKnowledgeIds, setSelectedKnowledgeIds] = useState<string[]>([]);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const pickerRef = useRef<HTMLDivElement>(null);
|
||||
const knowledgePickerRef = useRef<HTMLDivElement>(null);
|
||||
const isComposingRef = useRef(false);
|
||||
const gatewayStatus = useGatewayStore((s) => s.status);
|
||||
const agents = useAgentsStore((s) => s.agents);
|
||||
const currentAgentId = useChatStore((s) => s.currentAgentId);
|
||||
const currentAgentName = useMemo(
|
||||
() => (agents ?? []).find((agent) => agent.id === currentAgentId)?.name ?? currentAgentId,
|
||||
[agents, currentAgentId],
|
||||
);
|
||||
const mentionableAgents = useMemo(
|
||||
() => (agents ?? []).filter((agent) => agent.id !== currentAgentId),
|
||||
[agents, currentAgentId],
|
||||
);
|
||||
const selectedTarget = useMemo(
|
||||
() => (agents ?? []).find((agent) => agent.id === targetAgentId) ?? null,
|
||||
[agents, targetAgentId],
|
||||
);
|
||||
const showAgentPicker = mentionableAgents.length > 0;
|
||||
const knowledgeBaseAvailable = knowledgeDocuments.length > 0;
|
||||
const selectedKnowledgeDocuments = knowledgeDocuments.filter((doc) => selectedKnowledgeIds.includes(doc.id));
|
||||
|
||||
// Auto-resize textarea
|
||||
useEffect(() => {
|
||||
@@ -126,30 +135,24 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false, i
|
||||
}, [disabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!targetAgentId) return;
|
||||
if (targetAgentId === currentAgentId) {
|
||||
setTargetAgentId(null);
|
||||
setPickerOpen(false);
|
||||
return;
|
||||
}
|
||||
if (!(agents ?? []).some((agent) => agent.id === targetAgentId)) {
|
||||
setTargetAgentId(null);
|
||||
setPickerOpen(false);
|
||||
}
|
||||
}, [agents, currentAgentId, targetAgentId]);
|
||||
setSelectedKnowledgeIds((current) => {
|
||||
const next = current.filter((id) => knowledgeDocuments.some((doc) => doc.id === id));
|
||||
return next.length === current.length ? current : next;
|
||||
});
|
||||
}, [knowledgeDocuments]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pickerOpen) return;
|
||||
if (!knowledgePickerOpen) return;
|
||||
const handlePointerDown = (event: MouseEvent) => {
|
||||
if (!pickerRef.current?.contains(event.target as Node)) {
|
||||
setPickerOpen(false);
|
||||
if (!knowledgePickerRef.current?.contains(event.target as Node)) {
|
||||
setKnowledgePickerOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handlePointerDown);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handlePointerDown);
|
||||
};
|
||||
}, [pickerOpen]);
|
||||
}, [knowledgePickerOpen]);
|
||||
|
||||
// ── File staging via native dialog ─────────────────────────────
|
||||
|
||||
@@ -307,10 +310,13 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false, i
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
}
|
||||
onSend(textToSend, attachmentsToSend, targetAgentId);
|
||||
setTargetAgentId(null);
|
||||
setPickerOpen(false);
|
||||
}, [input, attachments, canSend, onSend, targetAgentId]);
|
||||
onSend(textToSend, attachmentsToSend, null, {
|
||||
useKnowledgeBase: selectedKnowledgeIds.length > 0,
|
||||
workspaceId,
|
||||
selectedKnowledgeDocumentIds: selectedKnowledgeIds,
|
||||
});
|
||||
setKnowledgePickerOpen(false);
|
||||
}, [input, attachments, canSend, onSend, selectedKnowledgeIds, workspaceId]);
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
if (!canStop) return;
|
||||
@@ -319,10 +325,6 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false, i
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Backspace' && !input && targetAgentId) {
|
||||
setTargetAgentId(null);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
const nativeEvent = e.nativeEvent as KeyboardEvent;
|
||||
if (isComposingRef.current || nativeEvent.isComposing || nativeEvent.keyCode === 229) {
|
||||
@@ -332,9 +334,17 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false, i
|
||||
handleSend();
|
||||
}
|
||||
},
|
||||
[handleSend, input, targetAgentId],
|
||||
[handleSend],
|
||||
);
|
||||
|
||||
const toggleKnowledgeDocument = useCallback((id: string) => {
|
||||
setSelectedKnowledgeIds((current) => (
|
||||
current.includes(id)
|
||||
? current.filter((item) => item !== id)
|
||||
: [...current, id]
|
||||
));
|
||||
}, []);
|
||||
|
||||
// Handle paste (Ctrl/Cmd+V with files)
|
||||
const handlePaste = useCallback(
|
||||
(e: React.ClipboardEvent) => {
|
||||
@@ -386,8 +396,8 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false, i
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"p-4 pb-6 w-full mx-auto transition-all duration-300",
|
||||
isEmpty ? "max-w-3xl" : "max-w-4xl"
|
||||
"w-full shrink-0 px-0 pb-0 pt-3 mx-auto transition-all duration-300",
|
||||
isEmpty ? "max-w-5xl" : "max-w-5xl"
|
||||
)}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
@@ -409,17 +419,21 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false, i
|
||||
|
||||
{/* Input Container */}
|
||||
<div className={`relative bg-white dark:bg-card rounded-2xl shadow-sm border px-3 pt-2.5 pb-1.5 transition-all ${dragOver ? 'border-primary ring-1 ring-primary' : 'border-black/10 dark:border-white/10'}`}>
|
||||
{selectedTarget && (
|
||||
<div className="pb-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTargetAgentId(null)}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-primary/20 bg-primary/5 px-2.5 py-1 text-[13px] font-medium text-foreground transition-colors hover:bg-primary/10"
|
||||
title={t('composer.clearTarget')}
|
||||
>
|
||||
<span>{t('composer.targetChip', { agent: selectedTarget.name })}</span>
|
||||
<X className="h-3 w-3 text-muted-foreground" />
|
||||
</button>
|
||||
{selectedKnowledgeDocuments.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 pb-1.5">
|
||||
{selectedKnowledgeDocuments.map((doc) => (
|
||||
<button
|
||||
key={doc.id}
|
||||
type="button"
|
||||
onClick={() => toggleKnowledgeDocument(doc.id)}
|
||||
className="inline-flex max-w-[220px] items-center gap-1.5 rounded-lg border border-[#1E3A8A]/20 bg-[#1E3A8A]/5 px-2.5 py-1 text-[13px] font-medium text-foreground transition-colors hover:bg-[#1E3A8A]/10"
|
||||
title={doc.name}
|
||||
>
|
||||
<BookOpen className="h-3 w-3 shrink-0 text-[#1E3A8A]" />
|
||||
<span className="truncate">{doc.name}</span>
|
||||
<X className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -457,44 +471,75 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false, i
|
||||
<Paperclip className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
|
||||
{showAgentPicker && (
|
||||
<div ref={pickerRef} className="relative shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
'h-8 w-8 rounded-lg text-muted-foreground hover:bg-black/5 dark:hover:bg-white/10 hover:text-foreground transition-colors',
|
||||
(pickerOpen || selectedTarget) && 'bg-primary/10 text-primary hover:bg-primary/20'
|
||||
)}
|
||||
onClick={() => setPickerOpen((open) => !open)}
|
||||
disabled={disabled || sending}
|
||||
title={t('composer.pickAgent')}
|
||||
>
|
||||
<AtSign className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
{pickerOpen && (
|
||||
<div className="absolute left-0 bottom-full z-20 mb-2 w-72 overflow-hidden rounded-2xl border border-black/10 bg-white p-1.5 shadow-xl dark:border-white/10 dark:bg-card">
|
||||
<div className="px-3 py-2 text-[11px] font-medium text-muted-foreground/80">
|
||||
{t('composer.agentPickerTitle', { currentAgent: currentAgentName })}
|
||||
</div>
|
||||
<div className="max-h-64 overflow-y-auto">
|
||||
{mentionableAgents.map((agent) => (
|
||||
<AgentPickerItem
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
selected={agent.id === targetAgentId}
|
||||
onSelect={() => {
|
||||
setTargetAgentId(agent.id);
|
||||
setPickerOpen(false);
|
||||
textareaRef.current?.focus();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div ref={knowledgePickerRef} className="relative shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'h-8 rounded-lg px-2 text-[12px] text-muted-foreground hover:bg-black/5 dark:hover:bg-white/10 hover:text-foreground transition-colors',
|
||||
selectedKnowledgeIds.length > 0 && 'bg-[#1E3A8A]/10 text-[#1E3A8A] hover:bg-[#1E3A8A]/15',
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
onClick={() => setKnowledgePickerOpen((open) => !open)}
|
||||
disabled={!knowledgeBaseAvailable || disabled || sending}
|
||||
data-testid="chat-composer-knowledge-button"
|
||||
title={knowledgeBaseAvailable ? '选择知识库文件作为上下文' : '当前组织空间暂无知识文件'}
|
||||
>
|
||||
<BookOpen className="mr-1.5 h-3.5 w-3.5" />
|
||||
知识库
|
||||
<span className="ml-1 text-muted-foreground">
|
||||
{selectedKnowledgeIds.length > 0 ? selectedKnowledgeIds.length : knowledgeDocuments.length}
|
||||
</span>
|
||||
<ChevronDown className="ml-1 h-3 w-3" />
|
||||
</Button>
|
||||
|
||||
{knowledgePickerOpen && (
|
||||
<div
|
||||
data-testid="chat-composer-knowledge-popover"
|
||||
className="absolute bottom-full left-0 z-30 mb-2 w-80 max-w-[calc(100vw-7rem)] overflow-hidden rounded-xl border border-black/10 bg-white p-2 shadow-xl dark:border-white/10 dark:bg-card"
|
||||
>
|
||||
<div className="flex items-center justify-between px-2 py-1.5">
|
||||
<div className="text-[12px] font-medium text-foreground">选择知识库上下文</div>
|
||||
{selectedKnowledgeIds.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedKnowledgeIds([])}
|
||||
className="text-[11px] text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="max-h-64 overflow-y-auto">
|
||||
{knowledgeDocuments.map((doc) => {
|
||||
const selected = selectedKnowledgeIds.includes(doc.id);
|
||||
return (
|
||||
<button
|
||||
key={doc.id}
|
||||
type="button"
|
||||
onClick={() => toggleKnowledgeDocument(doc.id)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left transition-colors',
|
||||
selected ? 'bg-[#1E3A8A]/10 text-foreground' : 'hover:bg-black/5 dark:hover:bg-white/5',
|
||||
)}
|
||||
>
|
||||
<span className={cn(
|
||||
'flex h-4 w-4 shrink-0 items-center justify-center rounded border',
|
||||
selected ? 'border-[#1E3A8A] bg-[#1E3A8A] text-white' : 'border-slate-300 text-transparent',
|
||||
)}>
|
||||
<Check className="h-3 w-3" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[13px] font-medium">{doc.name}</span>
|
||||
<span className="block truncate text-[11px] text-muted-foreground">
|
||||
{doc.textPath ? '已提取文本' : doc.mimeType}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Send Button — pushed to the right */}
|
||||
<Button
|
||||
@@ -609,29 +654,3 @@ function AttachmentPreview({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentPickerItem({
|
||||
agent,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
agent: AgentSummary;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
'flex w-full flex-col items-start rounded-xl px-3 py-2 text-left transition-colors',
|
||||
selected ? 'bg-primary/10 text-foreground' : 'hover:bg-black/5 dark:hover:bg-white/5'
|
||||
)}
|
||||
>
|
||||
<span className="text-[14px] font-medium text-foreground">{agent.name}</span>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{agent.modelDisplay}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user