249 lines
13 KiB
TypeScript
249 lines
13 KiB
TypeScript
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>
|
||
);
|
||
}
|