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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* surfaced via ExecutionGraphCard, not inside message bubbles.
|
||||
*/
|
||||
import { useState, useCallback, useEffect, memo } from 'react';
|
||||
import { Sparkles, Copy, Check, ChevronDown, ChevronRight, Wrench, FileText, Film, Music, FileArchive, File, X, FolderOpen, ZoomIn, Loader2, CheckCircle2, AlertCircle } from 'lucide-react';
|
||||
import { Copy, Check, ChevronDown, ChevronRight, Wrench, FileText, Film, Music, FileArchive, File, X, FolderOpen, ZoomIn, Loader2, CheckCircle2, AlertCircle } from 'lucide-react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import remarkMath from 'remark-math';
|
||||
@@ -16,6 +16,7 @@ import { cn } from '@/lib/utils';
|
||||
import { invokeIpc } from '@/lib/api-client';
|
||||
import type { RawMessage, AttachedFileMeta } from '@/stores/chat';
|
||||
import { extractText, extractImages, extractToolUse, formatTimestamp } from './message-utils';
|
||||
import assistantLogo from '@/assets/logo.svg';
|
||||
|
||||
interface ChatMessageProps {
|
||||
message: RawMessage;
|
||||
@@ -125,8 +126,8 @@ export const ChatMessage = memo(function ChatMessage({
|
||||
>
|
||||
{/* Avatar */}
|
||||
{!isUser && (
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full mt-1 bg-black/5 dark:bg-white/5 text-foreground">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
<div className="mt-1 flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-sky-100 bg-[#ECFBFF] shadow-sm dark:border-white/10 dark:bg-slate-900">
|
||||
<img src={assistantLogo} alt="智念助手" className="h-7 w-7 rounded-full" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,35 +1,22 @@
|
||||
/**
|
||||
* Chat Toolbar
|
||||
* Session selector, new session, and refresh.
|
||||
* Chat refresh controls.
|
||||
* Rendered in the Header when on the Chat page.
|
||||
*/
|
||||
import { useMemo } from 'react';
|
||||
import { RefreshCw, Bot } from 'lucide-react';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useChatStore } from '@/stores/chat';
|
||||
import { useAgentsStore } from '@/stores/agents';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export function ChatToolbar() {
|
||||
const refresh = useChatStore((s) => s.refresh);
|
||||
const loading = useChatStore((s) => s.loading);
|
||||
const currentAgentId = useChatStore((s) => s.currentAgentId);
|
||||
const agents = useAgentsStore((s) => s.agents);
|
||||
const { t } = useTranslation('chat');
|
||||
const currentAgentName = useMemo(
|
||||
() => (agents ?? []).find((agent) => agent.id === currentAgentId)?.name ?? currentAgentId,
|
||||
[agents, currentAgentId],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="hidden sm:flex items-center gap-1.5 rounded-full border border-black/10 bg-white/70 px-3 py-1.5 text-[12px] font-medium text-foreground/80 dark:border-white/10 dark:bg-white/5">
|
||||
<Bot className="h-3.5 w-3.5 text-primary" />
|
||||
<span>{t('toolbar.currentAgent', { agent: currentAgentName })}</span>
|
||||
</div>
|
||||
{/* Refresh */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
|
||||
@@ -9,10 +9,12 @@ import { AlertCircle, Loader2, Sparkles } from 'lucide-react';
|
||||
import { useChatStore, type RawMessage } from '@/stores/chat';
|
||||
import { useGatewayStore } from '@/stores/gateway';
|
||||
import { useAgentsStore } from '@/stores/agents';
|
||||
import { useYinianStore } from '@/stores/yinian';
|
||||
import { hostApiFetch } from '@/lib/host-api';
|
||||
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
|
||||
import { ChatMessage } from './ChatMessage';
|
||||
import { ChatInput } from './ChatInput';
|
||||
import type { KnowledgeContextDocument } from './ChatInput';
|
||||
import { ExecutionGraphCard } from './ExecutionGraphCard';
|
||||
import { ChatToolbar } from './ChatToolbar';
|
||||
import { extractImages, extractText, extractThinking, extractToolUse, stripProcessMessagePrefix } from './message-utils';
|
||||
@@ -88,9 +90,12 @@ export function Chat() {
|
||||
const clearError = useChatStore((s) => s.clearError);
|
||||
const fetchAgents = useAgentsStore((s) => s.fetchAgents);
|
||||
const agents = useAgentsStore((s) => s.agents);
|
||||
const yinianConfig = useYinianStore((s) => s.config);
|
||||
|
||||
const cleanupEmptySession = useChatStore((s) => s.cleanupEmptySession);
|
||||
const [childTranscripts, setChildTranscripts] = useState<Record<string, RawMessage[]>>({});
|
||||
const [knowledgeDocuments, setKnowledgeDocuments] = useState<KnowledgeContextDocument[]>([]);
|
||||
const workspaceId = yinianConfig?.hotel.id;
|
||||
// Persistent per-run override for the Execution Graph's expanded/collapsed
|
||||
// state. Keyed by a stable run id (trigger message id, or a fallback of
|
||||
// `${sessionKey}:${triggerIdx}`) so user toggles survive the `loadHistory`
|
||||
@@ -119,6 +124,26 @@ export function Chat() {
|
||||
void fetchAgents();
|
||||
}, [fetchAgents]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspaceId) {
|
||||
setKnowledgeDocuments([]);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
void hostApiFetch<{ documents: KnowledgeContextDocument[] }>(`/api/knowledge/files?workspaceId=${encodeURIComponent(workspaceId)}`)
|
||||
.then((result) => {
|
||||
if (!cancelled) setKnowledgeDocuments(result.documents ?? []);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setKnowledgeDocuments([]);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [workspaceId]);
|
||||
|
||||
useEffect(() => {
|
||||
const completions = messages
|
||||
.map((message) => parseSubagentCompletionInfo(message))
|
||||
@@ -569,21 +594,21 @@ export function Chat() {
|
||||
}, [userRunCards, messages, currentSessionKey]);
|
||||
|
||||
return (
|
||||
<div className={cn("relative flex min-h-0 flex-col -m-6 transition-colors duration-500 dark:bg-background")} style={{ height: 'calc(100vh - 2.5rem)' }}>
|
||||
<div className={cn("relative flex h-full min-h-0 min-w-0 flex-col overflow-hidden transition-colors duration-500 dark:bg-background")}>
|
||||
{/* Toolbar */}
|
||||
<div className="flex shrink-0 items-center justify-end px-4 py-2">
|
||||
<div className="flex shrink-0 items-center justify-end pb-3">
|
||||
<ChatToolbar />
|
||||
</div>
|
||||
|
||||
{/* Messages Area */}
|
||||
<div className="min-h-0 flex-1 overflow-hidden px-4 py-4">
|
||||
<div className="mx-auto flex h-full min-h-0 max-w-6xl flex-col gap-4 lg:flex-row lg:items-stretch">
|
||||
<div className="min-h-0 min-w-0 flex-1 overflow-hidden pb-4">
|
||||
<div className="mx-auto flex h-full min-h-0 w-full max-w-6xl flex-col gap-4 lg:flex-row lg:items-stretch">
|
||||
<div ref={scrollRef} className="min-h-0 min-w-0 flex-1 overflow-y-auto">
|
||||
<div
|
||||
ref={contentRef}
|
||||
className={cn(
|
||||
"space-y-4 transition-all duration-300",
|
||||
isEmpty ? "mx-auto w-full max-w-3xl" : "max-w-4xl",
|
||||
isEmpty ? "mx-auto w-full max-w-5xl" : "mx-auto w-full max-w-5xl",
|
||||
)}
|
||||
>
|
||||
{isEmpty ? (
|
||||
@@ -700,7 +725,7 @@ export function Chat() {
|
||||
{/* Error bar */}
|
||||
{error && (
|
||||
<div className="px-4 py-2 bg-destructive/10 border-t border-destructive/20">
|
||||
<div className="max-w-4xl mx-auto flex items-center justify-between">
|
||||
<div className="mx-auto flex max-w-5xl items-center justify-between">
|
||||
<p className="text-sm text-destructive flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{error}
|
||||
@@ -722,6 +747,8 @@ export function Chat() {
|
||||
disabled={!isGatewayRunning}
|
||||
sending={sending || hasActiveExecutionGraph}
|
||||
isEmpty={isEmpty}
|
||||
knowledgeDocuments={knowledgeDocuments}
|
||||
workspaceId={workspaceId}
|
||||
/>
|
||||
|
||||
{/* Transparent loading overlay */}
|
||||
@@ -740,28 +767,12 @@ export function Chat() {
|
||||
|
||||
function WelcomeScreen() {
|
||||
const { t } = useTranslation('chat');
|
||||
const quickActions = [
|
||||
{ key: 'askQuestions', label: t('welcome.askQuestions') },
|
||||
{ key: 'creativeTasks', label: t('welcome.creativeTasks') },
|
||||
{ key: 'brainstorming', label: t('welcome.brainstorming') },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center text-center h-[60vh]">
|
||||
<h1 className="text-4xl md:text-5xl font-serif text-foreground/80 mb-8 font-normal tracking-tight" style={{ fontFamily: 'Georgia, Cambria, "Times New Roman", Times, serif' }}>
|
||||
<h1 className="mb-8 text-3xl font-semibold tracking-normal text-foreground/85 md:text-4xl">
|
||||
{t('welcome.subtitle')}
|
||||
</h1>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-center gap-2.5 max-w-lg w-full">
|
||||
{quickActions.map(({ key, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
className="px-4 py-1.5 rounded-full border border-black/10 dark:border-white/10 text-[13px] font-medium text-foreground/70 hover:bg-black/5 dark:hover:bg-white/5 transition-colors bg-black/[0.02]"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ function cleanUserText(text: string): string {
|
||||
.replace(/^Conversation info\s*\([^)]*\):\s*```[a-z]*\n[\s\S]*?```\s*/i, '')
|
||||
// Fallback: remove "Conversation info (...): {...}" without code block wrapper
|
||||
.replace(/^Conversation info\s*\([^)]*\):\s*\{[\s\S]*?\}\s*/i, '')
|
||||
// Remove channel ingress metadata prefix like:
|
||||
// System: [2026-04-27 11:20:03 GMT+8] Feishu[default] DM | ou_xxx [msg:om_xxx]
|
||||
.replace(/^\s*System\s*:\s*\[\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}(?::\d{2})?\s+GMT[+-]\d+\]\s+[A-Za-z][\w -]*(?:\[[^\]]+\])?\s+(?:DM|Group|Room|Chat|Message)\s+\|\s+\S+(?:\s+\[msg:[^\]]+\])?\s*/i, '')
|
||||
// Remove Gateway timestamp prefix like [Fri 2026-02-13 22:39 GMT+8]
|
||||
.replace(/^\[(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}\s+[^\]]+\]\s*/i, '')
|
||||
.trim();
|
||||
|
||||
Reference in New Issue
Block a user