hololake-system-architecture/product-source/guanghu-knowledge-base/src/components/AgentChat.tsx

249 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useRef, useState } from 'react';
import { renderKnowledgeMarkdown } from '../markdown';
interface ToolCall { id: string; name: string; arguments: Record<string, unknown>; }
interface ToolResult { id: string; name: string; output: string; error?: string; }
interface AgentActivity {
id: string;
kind: 'wake' | 'tool' | 'permission' | 'receipt';
label: string;
detail: string;
status: 'running' | 'completed' | 'pending' | 'failed';
timestamp: string;
tool?: string;
}
interface Message {
role: 'user' | 'assistant' | 'system';
content: string;
toolCalls?: ToolCall[];
toolResults?: ToolResult[];
activities?: AgentActivity[];
timestamp: string;
}
interface ConversationSummary {
id: string;
title: string;
createdAt: string;
updatedAt: string;
messageCount: number;
}
interface AgentStatus {
name: string;
role: string;
model: string;
configured: boolean;
operational: boolean;
tools: string[];
}
interface PendingAction {
id: string;
tool: string;
effect: 'write' | 'delete';
target: string;
summary: string;
createdAt: string;
}
interface Props {
apiBase: string;
runtimeRevision?: number;
onClose?: () => void;
onWorldChanged?: () => void;
}
export default function AgentChat({ apiBase, runtimeRevision = 0, onClose, onWorldChanged }: Props) {
const [messages, setMessages] = useState<Message[]>([]);
const [conversations, setConversations] = useState<ConversationSummary[]>([]);
const [activeConversationId, setActiveConversationId] = useState('');
const [historyOpen, setHistoryOpen] = useState(false);
const [input, setInput] = useState('');
const [sending, setSending] = useState(false);
const [status, setStatus] = useState<AgentStatus | null>(null);
const [statusError, setStatusError] = useState('');
const [pendingActions, setPendingActions] = useState<PendingAction[]>([]);
const [actionBusy, setActionBusy] = useState('');
const bottomRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => { void bootstrap(); }, [runtimeRevision]);
useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages, sending]);
async function bootstrap() {
try {
const response = await fetch(`${apiBase}/api/agent/conversations`);
const data = await response.json();
if (!data.ok) throw new Error(data.error || '无法读取对话');
setConversations(data.conversations || []);
const selected = activeConversationId || data.activeConversationId || data.conversations?.[0]?.id;
if (selected) await selectConversation(selected);
} catch {
setStatusError('HoloLake 本地服务未启动');
}
}
async function selectConversation(id: string) {
setActiveConversationId(id);
const [historyResponse, statusResponse, actionsResponse] = await Promise.all([
fetch(`${apiBase}/api/agent/conversation?conversationId=${encodeURIComponent(id)}`),
fetch(`${apiBase}/api/agent/status?conversationId=${encodeURIComponent(id)}`),
fetch(`${apiBase}/api/agent/actions?conversationId=${encodeURIComponent(id)}`),
]);
const [history, runtime, actions] = await Promise.all([historyResponse.json(), statusResponse.json(), actionsResponse.json()]);
if (history.ok) setMessages(history.messages || []);
if (actions.ok) setPendingActions(actions.actions || []);
if (runtime.ok) {
setStatusError('');
setStatus({
name: runtime.persona.name,
role: runtime.persona.role,
model: runtime.persona.model,
configured: runtime.configured,
operational: runtime.operational,
tools: runtime.tools || [],
});
}
}
async function refreshConversationList() {
const response = await fetch(`${apiBase}/api/agent/conversations`);
const data = await response.json();
if (data.ok) setConversations(data.conversations || []);
}
async function createConversation() {
const response = await fetch(`${apiBase}/api/agent/conversations`, { method: 'POST' });
const data = await response.json();
if (!data.ok) return;
await refreshConversationList();
await selectConversation(data.conversation.id);
setHistoryOpen(false);
inputRef.current?.focus();
}
async function deleteConversation(id: string) {
if (!confirm('删除这条对话记录?知识库文档不会被删除。')) return;
const response = await fetch(`${apiBase}/api/agent/conversations/${encodeURIComponent(id)}`, { method: 'DELETE' });
const data = await response.json();
if (!data.ok) return;
await refreshConversationList();
await selectConversation(data.activeConversationId);
}
async function sendMessage() {
const text = input.trim();
if (!text || sending || !activeConversationId) return;
setInput('');
setSending(true);
setMessages(previous => [...previous, { role: 'user', content: text, timestamp: new Date().toISOString() }]);
try {
const response = await fetch(`${apiBase}/api/agent/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: text, conversationId: activeConversationId }),
});
const data = await response.json();
if (!data.ok) throw new Error(data.error || '调用失败');
setMessages(previous => [...previous, {
role: 'assistant',
content: data.reply,
toolCalls: data.toolCalls || [],
toolResults: data.toolResults || [],
activities: data.activities || [],
timestamp: new Date().toISOString(),
}]);
setPendingActions(data.pendingActions || []);
await refreshConversationList();
} catch (error) {
setMessages(previous => [...previous, { role: 'assistant', content: `错误:${error instanceof Error ? error.message : String(error)}`, timestamp: new Date().toISOString() }]);
} finally {
setSending(false);
inputRef.current?.focus();
}
}
async function resolveAction(action: PendingAction, decision: 'confirm' | 'reject') {
setActionBusy(action.id);
try {
const response = await fetch(`${apiBase}/api/agent/actions/${encodeURIComponent(action.id)}/${decision}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ conversationId: activeConversationId }),
});
const data = await response.json();
if (!data.ok) throw new Error(data.error || '动作处理失败');
setPendingActions(data.actions || []);
setMessages(data.messages || messages);
await refreshConversationList();
if (decision === 'confirm') onWorldChanged?.();
} catch (error) {
setMessages(previous => [...previous, { role: 'assistant', content: `动作未执行:${error instanceof Error ? error.message : String(error)}`, timestamp: new Date().toISOString() }]);
} finally {
setActionBusy('');
}
}
return (
<div className="agent-chat">
<div className="agent-header">
<div className="agent-info">
<div className="agent-avatar" aria-hidden="true"><span /></div>
<div className="agent-meta"><h3>{status?.name || 'HoloLake'}</h3><span className="agent-role">{status?.role || '语言操作入口'}</span></div>
</div>
<div className="agent-actions">
<button type="button" onClick={createConversation} title="新建对话"></button>
<button type="button" className={historyOpen ? 'selected' : ''} onClick={() => setHistoryOpen(value => !value)} title="历史记录"></button>
<button type="button" onClick={onClose} title="关闭">×</button>
</div>
</div>
<div className="runtime-status">
<div className="runtime-row"><span className={`runtime-dot ${status?.operational ? 'online' : 'waiting'}`} /><span>{statusError || (status?.operational ? '语言操作入口可用' : '请在设置中验证模型')}</span><span className="runtime-tools">{status?.tools?.length || 0} · {status?.model || 'offline'}</span></div>
</div>
<div className={`agent-body ${historyOpen ? 'history-open' : ''}`}>
{historyOpen && (
<aside className="agent-history" aria-label="对话历史">
<div className="agent-history-heading"><strong></strong><button type="button" onClick={createConversation}></button></div>
<div className="agent-history-list">
{conversations.map(conversation => (
<div className={`agent-history-item ${conversation.id === activeConversationId ? 'selected' : ''}`} key={conversation.id}>
<button type="button" onClick={() => { void selectConversation(conversation.id); setHistoryOpen(false); }}><strong>{conversation.title}</strong><small>{new Date(conversation.updatedAt).toLocaleString('zh-CN')}</small></button>
<button type="button" className="delete" onClick={() => void deleteConversation(conversation.id)} aria-label={`删除 ${conversation.title}`}>×</button>
</div>
))}
</div>
</aside>
)}
<div className="agent-conversation">
<div className="agent-messages">
{messages.length === 0 && <div className="agent-welcome"><h2></h2><p>HoloLake 使</p></div>}
{messages.map((message, index) => (
<div key={`${message.timestamp}-${index}`} className={`msg msg-${message.role}`}>
{message.role === 'user' ? <div className="msg-bubble msg-user-bubble"><p>{message.content}</p></div> : (
<div className="msg-bubble msg-assistant-bubble">
<div className="msg-avatar" aria-hidden="true"><span /></div>
<div className="msg-content">
{message.activities && message.activities.length > 0 && (
<details className="agent-activity" open>
<summary> · {message.activities.length} </summary>
<ol>{message.activities.map(activity => <li key={activity.id} data-status={activity.status}><span /><div><strong>{activity.label}</strong><small>{activity.detail}</small></div><em>{activity.status === 'completed' ? '完成' : activity.status === 'pending' ? '待确认' : activity.status === 'failed' ? '失败' : '进行中'}</em></li>)}</ol>
</details>
)}
<div className="msg-markdown" dangerouslySetInnerHTML={{ __html: renderKnowledgeMarkdown(message.content) }} />
</div>
</div>
)}
</div>
))}
{pendingActions.length > 0 && <div className="pending-actions" aria-label="待确认动作"><div className="pending-actions-heading"></div>{pendingActions.map(action => <div className={`pending-action ${action.effect}`} key={action.id}><div><strong>{action.effect === 'delete' ? '删除' : '写入'} · {action.target}</strong><small>{action.summary}</small></div><div className="pending-action-buttons"><button onClick={() => resolveAction(action, 'reject')} disabled={Boolean(actionBusy)}></button><button className={action.effect === 'delete' ? 'danger' : 'confirm'} onClick={() => resolveAction(action, 'confirm')} disabled={Boolean(actionBusy)}>{actionBusy === action.id ? '处理中…' : '确认执行'}</button></div></div>)}</div>}
{sending && <div className="msg msg-assistant"><div className="msg-bubble msg-assistant-bubble"><div className="msg-avatar" aria-hidden="true"><span /></div><div className="msg-content typing"><span className="dot" /><span className="dot" /><span className="dot" /></div></div></div>}
<div ref={bottomRef} />
</div>
<div className="agent-input-area"><div className="agent-input-wrapper"><textarea ref={inputRef} className="agent-input" placeholder="向 HoloLake 发出语言指令…" value={input} onChange={event => setInput(event.target.value)} onKeyDown={event => { if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); void sendMessage(); } }} rows={1} disabled={sending || !status?.operational} /><button className="btn-send" onClick={sendMessage} disabled={sending || !input.trim() || !status?.operational}></button></div><div className="agent-hint"> · · </div></div>
</div>
</div>
</div>
);
}