196 lines
14 KiB
TypeScript
196 lines
14 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react';
|
|
import { Download, Folder, RefreshCw } from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import ReactMarkdown from 'react-markdown';
|
|
import remarkGfm from 'remark-gfm';
|
|
import { cloudAgentsApi } from '@/lib/cloud-agents-api';
|
|
import type { CloudAgentOperations, CloudChannelSession, CloudChannelConversation, CloudFile, CloudPendingOperation } from '../../../shared/cloud-agents';
|
|
import { CloudApproval, cloudStatus } from './CloudChat';
|
|
import { usePendingCloudInput } from './CloudPending';
|
|
|
|
const failureText = (error: unknown) => error instanceof Error ? error.message : '微信会话暂时无法读取';
|
|
const activeRun = (status?: string) => ['pending', 'queued', 'running', 'interrupted', 'preparing'].includes(status ?? '');
|
|
type Control = CloudAgentOperations['channelConversationControl']['input'];
|
|
|
|
function useVisible(active: boolean) {
|
|
const [visible, setVisible] = useState(document.visibilityState !== 'hidden');
|
|
useEffect(() => {
|
|
const update = () => setVisible(document.visibilityState !== 'hidden');
|
|
document.addEventListener('visibilitychange', update);
|
|
return () => document.removeEventListener('visibilitychange', update);
|
|
}, []);
|
|
return active && visible;
|
|
}
|
|
|
|
export function CloudChannelConversations({ slug, activeView = true }: { slug: string; activeView?: boolean }) {
|
|
const [sessions, setSessions] = useState<CloudChannelSession[]>([]);
|
|
const [selected, setSelected] = useState('');
|
|
const [offset, setOffset] = useState<number | null>(null);
|
|
const [revision, setRevision] = useState(0);
|
|
const [error, setError] = useState('');
|
|
const [loading, setLoading] = useState(false);
|
|
const [busy, setBusy] = useState(false);
|
|
const visible = useVisible(activeView);
|
|
useEffect(() => {
|
|
if (!visible) return;
|
|
let live = true;
|
|
setLoading(true);
|
|
void cloudAgentsApi.call('channelConversations', { slug }).then(page => {
|
|
if (!live) return;
|
|
setSessions(page.items); setOffset(page.next_offset); setError('');
|
|
setSelected(current => current || page.items[0]?.session_id || '');
|
|
}).catch(error => { if (live) setError(failureText(error)); }).finally(() => { if (live) setLoading(false); });
|
|
return () => { live = false; };
|
|
}, [slug, visible, revision]);
|
|
const more = async () => {
|
|
if (loading || offset === null) return;
|
|
setLoading(true);
|
|
try { const page = await cloudAgentsApi.call('channelConversations', { slug, offset }); setSessions(items => [...items, ...page.items]); setOffset(page.next_offset); }
|
|
catch (error) { setError(failureText(error)); } finally { setLoading(false); }
|
|
};
|
|
return <section aria-label="本人微信会话" className="space-y-4">
|
|
<div className="flex flex-wrap items-start justify-between gap-3"><div><h2 className="font-medium">本人微信对话</h2>
|
|
<p className="mt-1 text-sm leading-6 text-muted-foreground">查看你已配对的本人会话,处理确认和保存文件。新消息请从对应微信对话发送。</p></div>
|
|
<Button variant="ghost" size="sm" disabled={loading || busy} onClick={() => setRevision(value => value + 1)}><RefreshCw className="mr-2 h-4 w-4" />刷新会话</Button></div>
|
|
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
|
|
{loading && !sessions.length && <p role="status" className="text-sm text-muted-foreground">正在读取本人会话…</p>}
|
|
{!loading && !error && !sessions.length && <p className="rounded-xl bg-muted/30 p-6 text-sm leading-6 text-muted-foreground">还没有本人微信会话。请先在发布与访问中连接个人微信,并完成本人配对。</p>}
|
|
{!!sessions.length && <label className="block space-y-2 text-sm"><span>选择本人微信会话</span>
|
|
<select className="h-10 w-full rounded-md border border-input bg-background px-3" value={selected} disabled={busy} onChange={event => setSelected(event.target.value)}>
|
|
{!sessions.some(session => session.session_id === selected) && <option value={selected}>当前会话</option>}
|
|
{sessions.map(session => <option key={session.session_id} value={session.session_id}>{session.agent_name || '微信助手'} · 第 {session.sequence ?? 1} 段 · {new Date(session.created_at).toLocaleString()}{session.session_state === 'closed' ? ' · 历史' : ''}</option>)}
|
|
</select></label>}
|
|
{offset !== null && <Button variant="outline" size="sm" disabled={loading || busy} onClick={() => void more()}>加载更早会话</Button>}
|
|
{selected && <ChannelSession key={selected} sessionId={selected} visible={visible} refreshRevision={revision} onBusy={setBusy} onNewSession={id => { setSelected(id); setRevision(value => value + 1); }} />}
|
|
</section>;
|
|
}
|
|
|
|
function ChannelSession({ sessionId, visible, refreshRevision, onBusy, onNewSession }: {
|
|
sessionId: string; visible: boolean; refreshRevision: number; onBusy: (busy: boolean) => void; onNewSession: (id: string) => void;
|
|
}) {
|
|
const [data, setData] = useState<CloudChannelConversation | null>(null);
|
|
const [files, setFiles] = useState<CloudFile[]>([]);
|
|
const [outputs, setOutputs] = useState<CloudFile[]>([]);
|
|
const [path, setPath] = useState('/');
|
|
const [revision, setRevision] = useState(0);
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState('');
|
|
const [fileError, setFileError] = useState('');
|
|
const [notice, setNotice] = useState('');
|
|
const [pending, setPending] = useState<CloudPendingOperation[]>([]);
|
|
const [recoveryReady, setRecoveryReady] = useState(false);
|
|
const [intent, setIntent] = useState<Control | null>(null);
|
|
const locked = busy || !recoveryReady || Boolean(intent) || pending.length > 0;
|
|
usePendingCloudInput(locked);
|
|
useEffect(() => { onBusy(locked); return () => onBusy(false); }, [locked, onBusy]);
|
|
const loadRecovery = useCallback(async () => {
|
|
const recovery = await cloudAgentsApi.recovery();
|
|
setPending(recovery.pending.filter(item => item.operation === 'channelConversationControl' && item.input.session_id === sessionId));
|
|
setRecoveryReady(true);
|
|
}, [sessionId]);
|
|
useEffect(() => { void loadRecovery().catch(error => setError(failureText(error))); }, [loadRecovery]);
|
|
useEffect(() => {
|
|
if (!visible) return;
|
|
let live = true;
|
|
let timer: ReturnType<typeof setTimeout>;
|
|
const load = async () => {
|
|
try {
|
|
let next = await cloudAgentsApi.call('channelConversation', { session_id: sessionId });
|
|
while (live && next.next_offset !== null) {
|
|
const more = await cloudAgentsApi.call('channelConversation', { session_id: sessionId, offset: next.next_offset });
|
|
next = { ...more, messages: [...next.messages, ...more.messages] };
|
|
}
|
|
if (live) setData(next);
|
|
}
|
|
catch (error) { if (live) setError(failureText(error)); }
|
|
finally { if (live) timer = setTimeout(() => void load(), 5000); }
|
|
};
|
|
void load();
|
|
return () => { live = false; clearTimeout(timer); };
|
|
}, [sessionId, visible, revision, refreshRevision]);
|
|
const runId = data?.run?.agent_run_id;
|
|
const runStatus = data?.run?.status;
|
|
const threadId = data?.thread_id;
|
|
useEffect(() => {
|
|
if (!visible || !threadId) return;
|
|
let live = true;
|
|
setFileError('');
|
|
void cloudAgentsApi.call('channelFiles', { session_id: sessionId, path }).then(async page => {
|
|
if (!live) return;
|
|
setFiles(page.files); setOutputs([]);
|
|
const outputDirectory = path === '/' && page.files.find(file => file.is_dir && file.name === 'outputs');
|
|
if (outputDirectory) {
|
|
const outputPage = await cloudAgentsApi.call('channelFiles', { session_id: sessionId, path: outputDirectory.directory_path });
|
|
if (live) setOutputs(outputPage.files.filter(file => !file.is_dir));
|
|
}
|
|
}).catch(error => { if (live) setFileError(failureText(error)); });
|
|
return () => { live = false; };
|
|
}, [sessionId, path, visible, threadId, runId, runStatus, revision, refreshRevision]);
|
|
const control = async (input: Control) => {
|
|
const frozen = intent ?? input;
|
|
setIntent(frozen); setBusy(true); setError('');
|
|
try {
|
|
const result = await cloudAgentsApi.call('channelConversationControl', frozen);
|
|
setIntent(null); setPending([]);
|
|
if (frozen.action === 'new-session') onNewSession(result.session_id);
|
|
else setRevision(value => value + 1);
|
|
} catch (error) {
|
|
setError(failureText(error));
|
|
// Main retains accepted or uncertain operations. Move recovery to that
|
|
// durable record so a rejected control can be dismissed before another.
|
|
try { await loadRecovery(); setIntent(null); } catch { /* Keep the exact input available if the journal cannot be read. */ }
|
|
throw error;
|
|
}
|
|
finally { setBusy(false); }
|
|
};
|
|
const resolve = async (operation: CloudPendingOperation, discard = false) => {
|
|
setBusy(true); setError('');
|
|
try {
|
|
const response = await cloudAgentsApi.resolvePending(operation.id, discard);
|
|
await loadRecovery();
|
|
const result = response.result;
|
|
if (!discard && operation.input.action === 'new-session' && result && typeof result === 'object'
|
|
&& 'session_id' in result && typeof result.session_id === 'string') onNewSession(result.session_id);
|
|
else setRevision(value => value + 1);
|
|
}
|
|
catch (error) { setError(failureText(error)); } finally { setBusy(false); }
|
|
};
|
|
const save = async (file: CloudFile) => {
|
|
setBusy(true); setFileError(''); setNotice('');
|
|
try { const result = await cloudAgentsApi.downloadChannelArtifact(sessionId, file.path); if (result.saved) setNotice('已保存 ' + file.name); }
|
|
catch (error) { setFileError(failureText(error)); } finally { setBusy(false); }
|
|
};
|
|
const controllable = data?.session_state === 'active' && data.grant_state === 'authorized';
|
|
const running = activeRun(runStatus) || Boolean(data?.queued_requests?.length);
|
|
const renderFiles = (items: CloudFile[]) => items.map(file => <div key={file.path || file.directory_path} className="flex items-center justify-between gap-3 text-sm">
|
|
{file.is_dir ? <Button variant="ghost" disabled={busy} className="min-w-0 justify-start" onClick={() => setPath(file.directory_path)}><Folder className="mr-2 h-4 w-4" /><span className="truncate">{file.name}</span></Button>
|
|
: <><span className="min-w-0 break-all">{file.name}</span><Button variant="outline" size="sm" disabled={busy} aria-label={'保存 ' + file.name} onClick={() => void save(file)}><Download className="mr-2 h-4 w-4" />保存文件</Button></>}
|
|
</div>);
|
|
return <div className="space-y-4">
|
|
<div className="flex flex-wrap items-center justify-between gap-3"><p role="status" className="text-sm text-muted-foreground">{data?.session_state === 'closed' ? '历史会话 · 可以查看消息和保存文件' : runStatus ? cloudStatus(runStatus) : '等待微信消息'}</p>
|
|
{controllable && <div className="flex gap-2"><Button variant="outline" size="sm" disabled={locked} onClick={() => void control({ session_id: sessionId, operation_id: crypto.randomUUID(), action: 'stop' }).catch(() => undefined)}>停止本次任务</Button>
|
|
<Button variant="ghost" size="sm" disabled={locked || running} onClick={() => void control({ session_id: sessionId, operation_id: crypto.randomUUID(), action: 'new-session' }).catch(() => undefined)}>开始新对话</Button></div>}</div>
|
|
{pending.map(operation => <div key={operation.id} className="space-y-2 rounded-lg border border-amber-200 p-3 text-sm"><p>上次会话操作的结果尚未确认。恢复会核对同一次操作。</p>
|
|
<Button variant="outline" size="sm" disabled={busy} onClick={() => void resolve(operation)}>恢复上次操作</Button>
|
|
<Button variant="ghost" size="sm" disabled={busy} onClick={() => void resolve(operation, true)}>忽略本机记录</Button></div>)}
|
|
{intent && <Button variant="outline" disabled={busy} onClick={() => void control(intent).catch(() => undefined)}>重试本次操作</Button>}
|
|
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
|
|
<div className="space-y-4 rounded-xl border border-border p-4" aria-label="微信消息">
|
|
{data?.messages.filter(message => message.role !== 'system').map(message => <article key={message.id} className={message.role === 'user' ? 'ml-6 rounded-xl bg-muted/40 p-3' : 'p-1'}>
|
|
<p className="mb-2 text-xs text-muted-foreground">{message.role === 'user' ? '你 · 微信' : '智能体'}</p><div className="prose prose-sm max-w-none break-words"><ReactMarkdown remarkPlugins={[remarkGfm]} components={{ a: ({ href, children }) => href && /^(https?:|mailto:)/i.test(href) ? <a href={href} target="_blank" rel="noreferrer">{children}</a> : <span>{children}</span> }}>{message.content}</ReactMarkdown></div></article>)}
|
|
{data && !data.messages.length && <p className="text-sm text-muted-foreground">还没有消息。</p>}
|
|
</div>
|
|
{!recoveryReady && <Button variant="outline" size="sm" onClick={() => void loadRecovery().catch(error => setError(failureText(error)))}>重新读取操作记录</Button>}
|
|
{recoveryReady && controllable && runStatus === 'interrupted' && data?.run?.interrupt && runId && !pending.length && !intent && <CloudApproval key={runId} interrupt={data.run.interrupt} runId={runId}
|
|
submitDecision={input => control({ ...input, session_id: sessionId, action: 'resume', run_id: runId })} />}
|
|
<section aria-label="微信文件与产物" className="space-y-3 rounded-xl border border-border p-4"><div className="flex items-center justify-between gap-3"><h3 className="text-sm font-medium">文件与产物</h3>
|
|
<Button variant="ghost" size="sm" disabled={busy} onClick={() => setRevision(value => value + 1)}>刷新文件</Button></div>
|
|
{outputs.length > 0 && <div className="space-y-3 rounded-lg bg-muted/30 p-3"><p className="text-xs text-muted-foreground">生成的文件</p>{renderFiles(outputs)}</div>}
|
|
{path !== '/' && <Button variant="ghost" size="sm" onClick={() => setPath(path.split('/').slice(0, -1).join('/') || '/')}>上一级</Button>}
|
|
{renderFiles(files)}
|
|
{!files.length && !fileError && <p className="text-xs text-muted-foreground">暂时没有文件。运行结束时会自动更新。</p>}
|
|
{fileError && <p role="alert" className="text-sm text-destructive">{fileError}</p>}{notice && <p role="status" className="text-sm text-muted-foreground">{notice}</p>}
|
|
</section>
|
|
</div>;
|
|
}
|