feat: prepare Zhinian desktop client for pilot release

This commit is contained in:
inman
2026-04-29 10:23:20 +08:00
parent f9361e686a
commit 47b83b79fc
149 changed files with 15341 additions and 3590 deletions

View File

@@ -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>
);
}