build(trial): preserve deployed source for knowledge refresh
This commit is contained in:
1 parent
8e0e52f30e
commit
2d4e44f4a0
42 files changed
+2321
-283
No files matched your search
@@ -251,6 +251,11 @@ export function ChannelAccounts() {
|
||||
{flow?.target && !targetAgent && selected?.target_agent_slug === flow.target && <option value={flow.target} disabled>{selected.target_agent_name ?? '原智能体'}(暂不可用)</option>}
|
||||
{agents.map(agent => <option key={agent.slug} value={agent.slug}>{agent.name}</option>)}
|
||||
</>;
|
||||
const agentRequiredHint = <div className="rounded-2xl border border-[#e5def0] bg-[#f6f2fb] p-5 text-sm leading-7 text-[#75618e]">
|
||||
<h2 className="font-semibold text-[#3c3549]">还没有可连接的智能体</h2>
|
||||
<p className="mt-2">先创建一个智能体,配置完成后点击“开始使用”,就能连接到微信。</p>
|
||||
<Button variant="outline" className="mt-4 rounded-xl" onClick={() => navigate('/cloud-agents?view=mine')}>去我的智能体<ArrowRight className="ml-2 h-4 w-4" /></Button>
|
||||
</div>;
|
||||
|
||||
return <section className="h-full overflow-auto bg-[#fffcf7] p-4 sm:p-7 lg:p-9" data-testid="channel-accounts-page">
|
||||
<div className="mx-auto max-w-6xl space-y-6">
|
||||
@@ -266,12 +271,13 @@ export function ChannelAccounts() {
|
||||
</div>
|
||||
</header>
|
||||
{!flow && messages}
|
||||
{!loading && loaded && !agents.length && !flow && agentRequiredHint}
|
||||
{loading ? <div role="status" className="flex items-center justify-center gap-3 rounded-3xl bg-[#f4f1fb] p-12 text-[#75618e]"><Loader2 className="h-5 w-5 animate-spin" />正在查看你的微信绑定…</div>
|
||||
: !loaded ? <div className="rounded-3xl border border-[#e4e2de] bg-white p-10 text-center text-sm text-[#677286]"><p>暂时没能读到微信绑定,请刷新后再试。</p><Button variant="outline" className="mt-5 rounded-xl" onClick={() => void refresh().catch(failure => setError(errorText(failure)))}>重新加载</Button></div>
|
||||
: !accounts.length ? <div className="rounded-[28px] border border-[#dfe9e2] bg-[#f1f8f2] px-6 py-14 text-center">
|
||||
: !accounts.length ? (agents.length > 0 && <div className="rounded-[28px] border border-[#dfe9e2] bg-[#f1f8f2] px-6 py-14 text-center">
|
||||
<Link2 className="mx-auto h-10 w-10 text-[#678b70]" /><h2 className="mt-5 text-xl font-bold text-[#303747]">把你的智能体带到微信吧</h2><p className="mx-auto mt-3 max-w-md text-sm leading-7 text-[#677286]">绑定后,就可以回到微信和它聊聊。每个微信绑定一个智能体,也可以把多个微信交给同一个智能体。</p>
|
||||
<Button className="mt-7 h-11 rounded-2xl px-6" onClick={() => { setError(''); setFlow({ mode: 'new', step: 'choose', accountId: '', target: '', remark: '' }); }}>添加第一个微信<ArrowRight className="ml-2 h-4 w-4" /></Button>
|
||||
</div> : <div className="grid grid-cols-1 gap-5 md:grid-cols-2 xl:grid-cols-3" aria-label="我的微信绑定">
|
||||
</div>) : <div className="grid grid-cols-1 gap-5 md:grid-cols-2 xl:grid-cols-3" aria-label="我的微信绑定">
|
||||
{accounts.map(account => {
|
||||
const agent = agents.find(item => item.slug === account.target_agent_slug);
|
||||
const state = accountState(account);
|
||||
@@ -298,7 +304,7 @@ export function ChannelAccounts() {
|
||||
{recoveryBlocked && <p className="rounded-2xl bg-[#fff0d8] p-3 text-sm text-[#876033]">有一次绑定操作的结果需要确认,请先关闭此窗口并处理页面上的恢复提醒。</p>}
|
||||
{flow.step === 'choose' && <>
|
||||
{flow.mode === 'manage' && selectedState && <div className="flex items-center justify-between rounded-2xl bg-[#eaf5ec] p-4 text-sm"><span>微信连接与绑定</span><span className={`rounded-full px-3 py-1.5 text-xs font-semibold ${selectedState.tone}`}>{selectedState.text}</span></div>}
|
||||
{!agents.length ? <div className="rounded-2xl border border-[#e5def0] bg-[#f6f2fb] p-5 text-sm leading-7 text-[#75618e]"><p>先准备一个可以使用的智能体,再把它带到微信里。</p><Button variant="outline" className="mt-4 rounded-xl" onClick={() => navigate('/cloud-agents?view=mine')}>去我的智能体<ArrowRight className="ml-2 h-4 w-4" /></Button></div>
|
||||
{!agents.length ? agentRequiredHint
|
||||
: <label className="space-y-2 text-sm font-medium text-[#3c3549]"><span>{flow.mode === 'new' ? '选择智能体' : '绑定的智能体'}</span><Select aria-label={flow.mode === 'new' ? '选择智能体' : '绑定的智能体'} className="h-12 rounded-xl bg-white" style={{ backgroundPosition: 'right 12px center', backgroundSize: '16px 16px', backgroundRepeat: 'no-repeat' }} value={flow.target} disabled={disabled} onChange={event => setFlow({ ...flow, target: event.target.value })}>{options}</Select></label>}
|
||||
{flow.mode === 'new' && !flow.accountId && <label className="space-y-2 text-sm font-medium text-[#3c3549]"><span>微信备注(可选)</span><Input aria-label="微信备注(可选)" className="h-12 rounded-xl bg-white" disabled={disabled} value={flow.remark} maxLength={100} onChange={event => setFlow({ ...flow, remark: event.target.value })} placeholder="给这个微信起个方便辨认的名字" /></label>}
|
||||
{flow.mode === 'manage' && selected?.blockers.length ? <p className="rounded-2xl bg-[#fff0d8] p-3 text-sm leading-6 text-[#876033]">{selected.blockers.map(item => blockerText[item] ?? '连接暂时不可用,请刷新后重试').join(';')}</p> : null}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
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 { CloudMessageBody } from './CloudMessageBody';
|
||||
import { cloudAgentsApi } from '@/lib/cloud-agents-api';
|
||||
import type { CloudAgentOperations, CloudChannelSession, CloudChannelConversation, CloudFile, CloudPendingOperation } from '../../../shared/cloud-agents';
|
||||
import { CloudApproval, cloudStatus } from './CloudChat';
|
||||
@@ -185,7 +184,7 @@ function ChannelSession({ sessionId, visible, refreshRevision, onBusy, onNewSess
|
||||
{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>)}
|
||||
<p className="mb-2 text-xs text-muted-foreground">{message.role === 'user' ? '你 · 微信' : '智能体'}</p><CloudMessageBody {...message} /></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>}
|
||||
|
||||
@@ -7,12 +7,11 @@ import type { CloudHistory, CloudInterrupt, CloudPrompt, CloudRequest, CloudRun,
|
||||
import { CloudComposerAttachments } from './CloudComposerAttachments';
|
||||
import { CloudAvatar } from './CloudAvatar';
|
||||
import './desktop-workspace.css';
|
||||
import { CloudMessageBody } from './CloudMessageBody';
|
||||
import './agent-chat.css';
|
||||
import ReactMarkdown, { defaultUrlTransform } from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { CloudPendingContext, useCloudPendingState, usePendingCloudInput } from './CloudPending';
|
||||
import { agentExamples } from './agent-guidance';
|
||||
import { consumeCloudChatEvent, type CloudChatStream } from './cloud-chat-stream';
|
||||
import { consumeCloudChatEvent, type CloudChatStream, type CloudLiveMessage } from './cloud-chat-stream';
|
||||
|
||||
export const cloudStatus = (status: string) => ({
|
||||
idle: '尚未开始', queued: '等待执行', dispatching: '准备执行', dispatched: '已提交', pending: '准备中',
|
||||
@@ -22,6 +21,8 @@ export const cloudStatus = (status: string) => ({
|
||||
const active = (status?: string) => Boolean(status && ['queued', 'dispatching', 'dispatched', 'pending', 'running', 'cancel_requested', 'submitted'].includes(status));
|
||||
const message = (e: unknown) => e instanceof Error ? e.message : '暂时无法完成操作';
|
||||
const object = (v: unknown): Record<string, unknown> => v && typeof v === 'object' ? v as Record<string, unknown> : {};
|
||||
const savedMessage = (history: CloudHistory, message: CloudLiveMessage) => history.messages.some(saved =>
|
||||
saved.role === 'assistant' && saved.run_id === message.run_id && (!saved.message_id || saved.message_id === message.message_id));
|
||||
|
||||
export function CloudChat({ slug, previewRevision: latestRevision, initialThread, initialThreadId, onSchedule, activeView = true, headerAction, attachmentHeader, previewReady = true,
|
||||
assistantName = '助手', assistantAvatarUrl, examples = agentExamples(), hasUnsavedChanges = false, saveDisabled = false, onSaveForPreview }: {
|
||||
@@ -75,8 +76,10 @@ export function CloudChat({ slug, previewRevision: latestRevision, initialThread
|
||||
// Replace the preview only after authoritative assistant history arrives.
|
||||
// Failed/cancelled Runs without a saved answer retain received fragments.
|
||||
const preview = streamReply.current;
|
||||
if (preview?.threadId === id && page.run?.agent_run_id === preview.runId && !active(page.run.status)
|
||||
&& page.messages.some(item => item.role === 'assistant' && item.run_id === preview.runId)) updateLiveReply(null);
|
||||
if (preview?.threadId === id && page.run?.agent_run_id === preview.runId && !active(page.run.status)) {
|
||||
const remaining = preview.messages.filter(item => !savedMessage(page, item));
|
||||
if (remaining.length !== preview.messages.length) updateLiveReply(remaining.length ? { ...preview, messages: remaining } : null);
|
||||
}
|
||||
setQueued(page.queued_requests ?? []);
|
||||
setRequest(active(page.run?.status) ? null : page.queued_requests?.[0] ?? null);
|
||||
if (page.run) setThreads(current => current.map(item => item.thread_id === id
|
||||
@@ -167,6 +170,9 @@ export function CloudChat({ slug, previewRevision: latestRevision, initialThread
|
||||
|
||||
const runId = request && active(request.status) ? request.run_id ?? undefined : run?.agent_run_id;
|
||||
const running = active(run?.status) || active(request?.status);
|
||||
const liveHistoryReady = history?.run && history.run.agent_run_id === liveReply?.runId
|
||||
? !active(history.run.status)
|
||||
: Boolean(history?.messages.some(item => item.run_id === liveReply?.runId));
|
||||
useEffect(() => {
|
||||
if (!runId || !running) return;
|
||||
let live = true;
|
||||
@@ -184,7 +190,8 @@ export function CloudChat({ slug, previewRevision: latestRevision, initialThread
|
||||
if (envelope.run_id !== runId || envelope.thread_id !== targetThread
|
||||
|| targetRequest && envelope.request_id && envelope.request_id !== targetRequest) return;
|
||||
const chunk = object(object(envelope.payload).chunk);
|
||||
if (next?.toolName) setProgress('正在使用 ' + next.toolName);
|
||||
const latestTool = next?.messages.at(-1)?.tool_calls.at(-1);
|
||||
if (latestTool && ['pending', 'running'].includes(latestTool.status)) setProgress('正在使用 ' + (latestTool.display_name || latestTool.name));
|
||||
if (event.type === 'interrupt') setInterrupt(chunk as CloudInterrupt);
|
||||
if (event.type === 'end') {
|
||||
source?.close();
|
||||
@@ -326,7 +333,7 @@ export function CloudChat({ slug, previewRevision: latestRevision, initialThread
|
||||
<div ref={transcript} data-testid="cloud-chat-messages" className="agent-chat-scroll"
|
||||
onScroll={e => { const el = e.currentTarget; following.current = el.scrollHeight - el.scrollTop - el.clientHeight < 64; }}><div className="agent-chat-transcript">
|
||||
{loading ? <div role="status" className="agent-chat-loading"><MessageCircle aria-hidden="true" /><p>正在读取对话…</p></div>
|
||||
: !history?.messages.length && !liveReply?.content && !liveReply?.reasoning && <div className="agent-chat-welcome">
|
||||
: !history?.messages.length && !liveReply?.messages.length && <div className="agent-chat-welcome">
|
||||
<div className="agent-chat-welcome-portrait"><CloudAvatar src={assistantAvatarUrl} name={assistantName} className="agent-chat-welcome-avatar" /><Sparkles aria-hidden="true" className="agent-chat-welcome-spark" /></div>
|
||||
<p className="agent-chat-welcome-name">{assistantName}</p>
|
||||
<h2>{mode === 'preview' ? '问它一个小问题' : 'Hi,今天想聊些什么?'}</h2>
|
||||
@@ -341,13 +348,17 @@ export function CloudChat({ slug, previewRevision: latestRevision, initialThread
|
||||
<p className="my-2 text-xs text-muted-foreground">这是修改前的回答。用同一个问题再问一次,更容易看出变化。</p>
|
||||
<TrialMessages history={previousTrial.history} onError={setError} assistantName={assistantName} assistantAvatarUrl={assistantAvatarUrl} />
|
||||
</details>}
|
||||
{history && <TrialMessages history={history} onError={setError} assistantName={assistantName} assistantAvatarUrl={assistantAvatarUrl} />}
|
||||
{liveReply && (liveReply.content || liveReply.reasoning) && <article className="agent-chat-message" data-role="assistant" aria-label="助手正在回复">
|
||||
{history && <TrialMessages history={{ ...history, messages: history.messages.filter(saved =>
|
||||
!(!liveHistoryReady && liveReply?.messages.some(item => saved.run_id === item.run_id && saved.message_id === item.message_id)),
|
||||
) }} onError={setError} assistantName={assistantName} assistantAvatarUrl={assistantAvatarUrl} />}
|
||||
{liveReply?.messages.filter(item => !liveHistoryReady || !history || !savedMessage(history, item)).map(item => <article key={item.run_id + ':' + item.message_id} className="agent-chat-message" data-role="assistant" aria-label="助手正在回复">
|
||||
<CloudAvatar src={assistantAvatarUrl} name={assistantName} className="agent-chat-message-avatar" />
|
||||
<div className="agent-chat-message-content"><p className="agent-chat-speaker">{assistantName}</p>
|
||||
{liveReply.reasoning && <CloudReasoning content={liveReply.reasoning} running={running && runId === liveReply.runId && !liveReply.ended} />}
|
||||
{liveReply.content && <div className="agent-chat-message-body whitespace-pre-wrap">{liveReply.content}</div>}</div>
|
||||
</article>}
|
||||
<div className="agent-chat-message-body"><CloudMessageBody {...item} active={running && runId === item.run_id && !liveReply.ended} onDownload={path => {
|
||||
const id = thread?.thread_id ?? request?.thread_id;
|
||||
if (id) void cloudAgentsApi.download(id, path).catch(e => setError(message(e)));
|
||||
}} /></div></div>
|
||||
</article>)}
|
||||
{onSchedule && history?.schedule_proposals?.map(proposal => <article key={proposal.id} className="space-y-3 rounded-xl border border-violet-200 bg-violet-50/30 p-4 text-sm">
|
||||
<h3 className="font-medium">自动任务建议 · {proposal.name}</h3><p className="whitespace-pre-wrap">{proposal.prompt}</p>
|
||||
<p className="text-xs text-muted-foreground">{proposal.cron_expression} · {proposal.timezone} · 尚未启用</p>
|
||||
@@ -419,26 +430,13 @@ function TrialMessages({ history, onError, assistantName = '助手', assistantAv
|
||||
return history.messages.map(item => <article key={item.id} className="agent-chat-message" data-role={item.role === 'user' ? 'user' : 'assistant'}>
|
||||
{item.role !== 'user' && <CloudAvatar src={assistantAvatarUrl} name={assistantName} className="agent-chat-message-avatar" />}
|
||||
<div className="agent-chat-message-content">{item.role !== 'user' && <p className="agent-chat-speaker">{assistantName}</p>}
|
||||
{item.role === 'assistant' && typeof item.reasoning_content === 'string' && item.reasoning_content
|
||||
&& <CloudReasoning content={item.reasoning_content} running={false} />}
|
||||
<div className="agent-chat-message-body prose prose-sm max-w-none break-words">{item.role === 'user' ? <p className="whitespace-pre-wrap">{item.content}</p> : <ReactMarkdown urlTransform={url => url.startsWith('sandbox:/') ? url.slice(8) : defaultUrlTransform(url)} remarkPlugins={[remarkGfm]} components={{
|
||||
a: ({ href, children }) => href && !/^(https?:|mailto:|#)/i.test(href)
|
||||
? <button className="text-primary underline" onClick={() => void cloudAgentsApi.download(history.thread_id, href.replace(/^\/api\/chat\/thread\/[^/]+\/artifacts\//, '/')).catch(e => onError(message(e)))}>{children}</button>
|
||||
: <a href={href} target="_blank" rel="noreferrer">{children}</a>,
|
||||
}}>{item.content}</ReactMarkdown>}</div></div>
|
||||
<div className="agent-chat-message-body">{item.role === 'user' ? <p className="whitespace-pre-wrap">{item.content}</p> :
|
||||
<CloudMessageBody content={item.content} reasoning_content={item.role === 'assistant' ? item.reasoning_content : undefined}
|
||||
tool_calls={item.role === 'assistant' ? item.tool_calls : undefined}
|
||||
onDownload={path => void cloudAgentsApi.download(history.thread_id, path).catch(e => onError(message(e)))} />}</div></div>
|
||||
</article>);
|
||||
}
|
||||
|
||||
function CloudReasoning({ content, running }: { content: string; running: boolean }) {
|
||||
const [expanded, setExpanded] = useState(running);
|
||||
useEffect(() => { setExpanded(running); }, [running]);
|
||||
return <details open={expanded} onToggle={event => setExpanded(event.currentTarget.open)}
|
||||
className="agent-chat-reasoning" data-testid="cloud-chat-reasoning">
|
||||
<summary>思考过程</summary>
|
||||
<div aria-label="智能体思考过程" className="agent-chat-reasoning-content">{content}</div>
|
||||
</details>;
|
||||
}
|
||||
|
||||
export function CloudApproval({ interrupt, runId, onResumed, submitDecision }: {
|
||||
interrupt: CloudInterrupt; runId: string; onResumed?: (run: CloudRun) => void;
|
||||
submitDecision?: (input: { operation_id: string; decision: Record<string, unknown> }) => Promise<void>;
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { CloudAgentDraft, CloudVersion } from '../../../shared/cloud-agents
|
||||
import { usePendingCloudInput } from './CloudPending';
|
||||
|
||||
const configurationLabels: Record<string, string> = {
|
||||
model: '模型', tools: '工具', knowledges: '知识库', mcps: 'MCP 连接', skills: 'Skills', preload_skills: '预加载 Skills',
|
||||
model: '模型', tools: '工具', knowledges: '知识库', mcps: 'MCP 连接', skills: '技能', preload_skills: '提前读好的技能',
|
||||
subagents: '子智能体', tool_approval_mode: '工具确认方式', max_execution_steps: '最多执行步数',
|
||||
max_output_tokens: '每次输出词元上限', max_run_seconds: '最长执行秒数',
|
||||
};
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import ReactMarkdown, { defaultUrlTransform } from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import type { CloudToolCall } from '../../../shared/cloud-agents';
|
||||
|
||||
const display = (value: unknown) => typeof value === 'string' ? value : value == null ? '' : JSON.stringify(value, null, 2);
|
||||
|
||||
export function CloudMessageBody({ content, reasoning_content, tool_calls = [], active = false, onDownload }: {
|
||||
content: string; reasoning_content?: string; tool_calls?: CloudToolCall[]; active?: boolean;
|
||||
onDownload?: (path: string) => void;
|
||||
}) {
|
||||
return <div className="min-w-0 space-y-3">
|
||||
{(reasoning_content || tool_calls.length > 0) && <details open={active && !content} className="rounded-lg border border-border bg-muted/20 p-3 text-xs text-muted-foreground" data-testid="cloud-message-process">
|
||||
<summary className="cursor-pointer font-medium">{reasoning_content ? '思考过程' : '工具调用'}{tool_calls.length > 0 && ' · ' + tool_calls.length + ' 个工具'}{active && !content ? ' · 进行中' : ''}</summary>
|
||||
<div className="mt-3 space-y-3">
|
||||
{reasoning_content && <div aria-label="智能体思考过程" className="max-h-72 overflow-auto whitespace-pre-wrap break-words border-l-2 pl-3 leading-6">{reasoning_content}</div>}
|
||||
{tool_calls.map(tool => <details key={tool.id} className="rounded-md border border-border p-2">
|
||||
<summary className="cursor-pointer break-words">{tool.display_name || tool.name} · {tool.status === 'success' ? '已完成' : tool.status === 'error' ? '失败' : active ? tool.status === 'running' ? '执行中' : '准备调用' : '未完成'}</summary>
|
||||
<div className="mt-2 space-y-2">
|
||||
{tool.display_description && <p className="leading-5">{tool.display_description}</p>}
|
||||
{tool.display_name && tool.display_name !== tool.name && <p className="break-all">工具标识:{tool.name}</p>}
|
||||
<p>输入</p><pre className="max-h-48 overflow-auto whitespace-pre-wrap break-words">{display(tool.args)}</pre>
|
||||
{tool.tool_call_result != null && <><p>结果</p><pre className="max-h-60 overflow-auto whitespace-pre-wrap break-words">{display(tool.tool_call_result.content)}</pre></>}
|
||||
{tool.error_message && <p className="whitespace-pre-wrap break-words text-destructive">{tool.error_message}</p>}
|
||||
</div>
|
||||
</details>)}
|
||||
</div>
|
||||
</details>}
|
||||
{content && <div className="prose prose-sm max-w-none break-words [&_pre]:max-w-full [&_pre]:overflow-x-auto [&_table]:block [&_table]:overflow-x-auto">
|
||||
<ReactMarkdown urlTransform={url => url.startsWith('sandbox:/') ? url.slice(8) : defaultUrlTransform(url)} remarkPlugins={[remarkGfm]} components={{
|
||||
a: ({ href, children }) => href && !/^(https?:|mailto:|#)/i.test(href)
|
||||
? onDownload ? <button className="text-primary underline" onClick={() => onDownload(href.replace(/^\/api\/chat\/thread\/[^/]+\/artifacts\//, '/'))}>{children}</button> : <span>{children}</span>
|
||||
: <a href={href} target="_blank" rel="noreferrer">{children}</a>,
|
||||
}}>{content}</ReactMarkdown>
|
||||
</div>}
|
||||
</div>;
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { History, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import { cloudAgentsApi } from '@/lib/cloud-agents-api';
|
||||
import { AppError } from '@/lib/error-model';
|
||||
import type { CloudPendingOperation } from '../../../shared/cloud-agents';
|
||||
|
||||
const labels: Record<string, string> = {
|
||||
@@ -15,43 +19,92 @@ const labels: Record<string, string> = {
|
||||
};
|
||||
const retiredChannelOperations = new Set(['channelPolicy', 'createChannelPairing', 'channelCallers', 'revokeChannelCaller']);
|
||||
|
||||
export function CloudRecovery({ pending, onResolved }: { pending: CloudPendingOperation[]; onResolved: () => void }) {
|
||||
export function CloudRecovery({ pending, onResolved, triggerContainer }: { pending: CloudPendingOperation[]; onResolved: () => void; triggerContainer?: HTMLElement | null }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [busy, setBusy] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [notice, setNotice] = useState('');
|
||||
const [result, setResult] = useState<{ operation: string; value: unknown } | null>(null);
|
||||
const [result, setResult] = useState<{ operation: string; name?: string; value: unknown } | null>(null);
|
||||
const inFlight = useRef(false);
|
||||
const awaitingKeyClose = useRef(false);
|
||||
const alive = useRef(true);
|
||||
const creationOnly = pending.length > 0 ? pending.every(item => item.operation === 'createAgent') : result?.operation === 'createAgent';
|
||||
const title = creationOnly ? '创建记录' : '待处理记录';
|
||||
useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []);
|
||||
useEffect(() => { if (triggerContainer === null && !busy) setOpen(false); }, [triggerContainer, busy]);
|
||||
const close = () => { if (!inFlight.current) setOpen(false); };
|
||||
const resolve = async (item: CloudPendingOperation, discard = false) => {
|
||||
if (inFlight.current || awaitingKeyClose.current) return;
|
||||
inFlight.current = true;
|
||||
setBusy(item.id); setError(''); setNotice('');
|
||||
try {
|
||||
const response = await cloudAgentsApi.resolvePending(item.id, discard);
|
||||
if (!alive.current) return;
|
||||
if (response.requires_input) {
|
||||
setResult(null);
|
||||
setNotice('这次微信验证还需要验证码。请进入「渠道 → 微信」,选择对应账号后继续连接验证。');
|
||||
} else if (response.retired) {
|
||||
setResult(null);
|
||||
setNotice('旧版联系人权限操作已移除,本机恢复记录已丢弃,微信账号的其他操作不受影响。');
|
||||
} else if (!discard) setResult({ operation: item.operation, value: response.result });
|
||||
} else if (!discard) {
|
||||
awaitingKeyClose.current = item.operation === 'createKey';
|
||||
setResult({ operation: item.operation,
|
||||
name: typeof item.input.name === 'string' ? item.input.name : undefined, value: response.result });
|
||||
}
|
||||
onResolved();
|
||||
} catch (e) { setError(e instanceof Error ? e.message : '暂时无法确认结果'); }
|
||||
finally { setBusy(''); }
|
||||
} catch (e) {
|
||||
if (!alive.current) return;
|
||||
const message = e instanceof Error ? e.message : '这次没能完成,请稍后再试。';
|
||||
if (e instanceof AppError && e.details?.commandOutcome === 'definitive_failure') {
|
||||
setError(`${labels[item.operation] ?? '操作'}失败:${message}`);
|
||||
onResolved();
|
||||
} else setError(message);
|
||||
}
|
||||
finally { inFlight.current = false; if (alive.current) setBusy(''); }
|
||||
};
|
||||
if (!pending.length && !result && !error && !notice) return null;
|
||||
return <aside aria-label="未确认的云端操作" className="mx-auto mb-6 max-w-6xl space-y-3 rounded-xl border border-amber-200 bg-amber-50/30 p-4 text-sm">
|
||||
{pending.length > 0 && <><h2 className="font-medium">有 {pending.length} 个操作的结果尚未确认</h2>
|
||||
<p className="text-muted-foreground">重试会确认原操作,不会新建一份任务。移除提醒只清除本机记录,云端已接受的任务仍会继续。</p></>}
|
||||
{pending.map(item => <div key={item.id} className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div><p>{retiredChannelOperations.has(item.operation) ? '旧版联系人权限操作(已移除)' : labels[item.operation] ?? '云端操作'} · {new Date(item.created_at).toLocaleString()}</p>
|
||||
if (!pending.length && !result && !error && !notice && !open) return null;
|
||||
const trigger = <DialogTrigger asChild><Button type="button" variant="ghost" className="agent-recovery-trigger" aria-label={title + (pending.length ? `(${pending.length})` : '')}>
|
||||
<History aria-hidden="true" size={16} />{title}{pending.length > 0 && <span className="agent-recovery-count" aria-hidden="true">{pending.length}</span>}
|
||||
</Button></DialogTrigger>;
|
||||
const actionsDisabled = Boolean(busy) || result?.operation === 'createKey';
|
||||
return <Dialog open={open && triggerContainer !== null} onOpenChange={next => { if (!inFlight.current) setOpen(next); }}>
|
||||
{triggerContainer === undefined ? trigger : triggerContainer && createPortal(trigger, triggerContainer)}
|
||||
<DialogContent className="agent-recovery-dialog agent-recovery-modal"
|
||||
onEscapeKeyDown={event => { if (inFlight.current) event.preventDefault(); }}
|
||||
onInteractOutside={event => { if (inFlight.current) event.preventDefault(); }}>
|
||||
<DialogHeader className="agent-recovery-heading">
|
||||
<div><DialogTitle>{title}</DialogTitle><DialogDescription>{creationOnly
|
||||
? '之前创建智能体时留下的记录,可以在这里查看和处理。'
|
||||
: '之前还没确认完成的事情,可以在这里查看和处理。'}</DialogDescription></div>
|
||||
<Button type="button" variant="ghost" size="icon" aria-label="关闭记录" disabled={Boolean(busy)} onClick={close}><X aria-hidden="true" size={18} /></Button>
|
||||
</DialogHeader>
|
||||
<div className="agent-recovery-content">
|
||||
{pending.map(item => <article key={item.id} aria-label={item.operation === 'createAgent' && typeof item.input.name === 'string' ? `创建「${item.input.name}」` : labels[item.operation] ?? '待处理记录'} className="agent-recovery-record">
|
||||
<div><h3>{item.operation === 'createAgent' && typeof item.input.name === 'string' ? `创建「${item.input.name}」`
|
||||
: retiredChannelOperations.has(item.operation) ? '旧版联系人权限操作(已移除)' : labels[item.operation] ?? '待处理记录'}</h3>
|
||||
<p className="agent-recovery-time">{new Date(item.created_at).toLocaleString()}</p>
|
||||
{item.operation === 'createAgent' && <p className="agent-recovery-record-help">还不确定有没有创建成功,先看看列表里有没有它。</p>}
|
||||
{typeof item.input.query === 'string' && <p className="mt-1 line-clamp-2 text-muted-foreground">{item.input.query}</p>}</div>
|
||||
{retiredChannelOperations.has(item.operation)
|
||||
? <Button size="sm" variant="ghost" disabled={Boolean(busy)} onClick={() => void resolve(item)}>{busy === item.id ? '正在丢弃…' : '丢弃旧记录'}</Button>
|
||||
: <div className="flex gap-2"><Button size="sm" disabled={Boolean(busy)} onClick={() => void resolve(item)}>{busy === item.id ? '正在确认…' : '重试并确认结果'}</Button>
|
||||
<Button size="sm" variant="ghost" disabled={Boolean(busy)} onClick={() => void resolve(item, true)}>移除提醒</Button></div>}
|
||||
</div>)}
|
||||
{result && <div className="space-y-2"><p>{labels[result.operation] ?? '操作'}的结果已确认。{result.operation === 'createKey' && '请保存下面的密钥,关闭后不再显示。'}</p>
|
||||
? <Button size="sm" variant="ghost" disabled={actionsDisabled} onClick={() => void resolve(item)}>{busy === item.id ? '正在丢弃…' : '丢弃旧记录'}</Button>
|
||||
: <div className="agent-recovery-record-actions"><Button size="sm" disabled={actionsDisabled} onClick={() => void resolve(item)}>{busy === item.id ? '正在处理…' : '再试一次'}</Button>
|
||||
<Button size="sm" variant="ghost" disabled={actionsDisabled} onClick={() => void resolve(item, true)}>不再提醒</Button></div>}
|
||||
</article>)}
|
||||
{result && <div className="agent-recovery-result space-y-2"><p>{result.operation === 'createAgent'
|
||||
? `「${result.name || '智能体'}」创建成功,可以回到列表继续设置。`
|
||||
: `${labels[result.operation] ?? '操作'}的结果已确认。`}{result.operation === 'createKey' && '请保存下面的密钥。关闭结果后将无法再次查看。'}</p>
|
||||
{result.operation !== 'createAgent' && <>
|
||||
<details open={result.operation === 'createKey'}><summary className="cursor-pointer">查看结果</summary>
|
||||
<pre className="max-h-48 overflow-auto whitespace-pre-wrap break-all p-2 text-xs">{JSON.stringify(result.value, null, 2)}</pre></details>
|
||||
<Button variant="ghost" size="sm" onClick={() => setResult(null)}>关闭结果</Button></div>}
|
||||
</>}
|
||||
{result.operation === 'createKey' && <p className="text-xs text-muted-foreground">先保存密钥,再点击「关闭结果」处理其他记录。</p>}
|
||||
<Button variant="ghost" size="sm" onClick={() => { awaitingKeyClose.current = false; setResult(null); }}>关闭结果</Button></div>}
|
||||
{error && <p role="alert" className="text-destructive">{error}</p>}
|
||||
{notice && <p role="status" className="text-muted-foreground">{notice}</p>}
|
||||
</aside>;
|
||||
{!pending.length && !result && !error && !notice && <p className="text-muted-foreground">记录已经处理完了。</p>}
|
||||
{pending.length > 0 && <p className="agent-recovery-footnote">不再提醒只会关闭提醒,已经保存的内容会保留。</p>}
|
||||
</div>
|
||||
<DialogFooter className="agent-recovery-footer"><Button type="button" variant="outline" disabled={Boolean(busy)} onClick={close}>返回列表</Button></DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>;
|
||||
}
|
||||
@@ -113,7 +113,7 @@ function ScheduleEditor({ slug, initial, proposal, onClose, onSaved }: { slug: s
|
||||
<h3 className="font-medium">启用前确认</h3>
|
||||
<p>当前发布版本 {context.version} · {context.configuration.model}。触发时使用当时的发布版本。</p>
|
||||
<p>能力:{['tools', 'knowledges', 'mcps', 'skills', 'subagents'].map(key => {
|
||||
const labels: Record<string, string> = { tools: '工具', knowledges: '知识库', mcps: 'MCP', skills: 'Skills', subagents: '子智能体' };
|
||||
const labels: Record<string, string> = { tools: '工具', knowledges: '知识库', mcps: 'MCP', skills: '技能', subagents: '子智能体' };
|
||||
const selected = context.configuration![key as 'tools']; return selected.length ? labels[key] + ':' + selected.join('、') : '';
|
||||
}).filter(Boolean).join(';') || '基础对话'}</p>
|
||||
<p>每次最多 {context.configuration.max_execution_steps} 步、{context.configuration.max_run_seconds} 秒,每次模型输出最多 {context.configuration.max_output_tokens} 词元。</p>
|
||||
|
||||
@@ -76,7 +76,7 @@ export function ConfigurationFields({ slug, value, disabled, onChange, section =
|
||||
</>}
|
||||
{key === 'skills' && value.skills.length > 0 && <label className="agent-resource-preload">
|
||||
<input type="checkbox" checked={value.preload_skills.length === value.skills.length}
|
||||
onChange={e => change('preload_skills', e.target.checked ? [...value.skills] : [])} />开始运行时预加载已选做事手册</label>}
|
||||
onChange={e => change('preload_skills', e.target.checked ? [...value.skills] : [])} />开始对话前,先读好已选做事手册的使用方法</label>}
|
||||
</fieldset>;
|
||||
})}
|
||||
</div>;
|
||||
|
||||
@@ -61,6 +61,26 @@
|
||||
.agent-home-loading svg { width: 38px; height: 38px; color: #8273a9; }
|
||||
.agent-home-error { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 24px; padding: 12px 16px; border: 1px solid #e3dfd6; border-radius: 14px; background: #fff; font-size: 14px; }
|
||||
.agent-home-error p { overflow-wrap: anywhere; }
|
||||
.agent-recovery-slot:empty { display: none; }
|
||||
.agent-recovery-trigger { gap: 7px; color: #606c7e; font-size: 13px; }
|
||||
.agent-recovery-count { display: inline-grid; min-width: 21px; height: 21px; padding-inline: 5px; place-items: center; border-radius: 8px; background: #ede9f6; color: #7161a5; font-size: 11px; }
|
||||
.agent-recovery-dialog.agent-recovery-modal { display: flex; max-height: calc(100dvh - 32px); flex-direction: column; gap: 20px; border-radius: 24px; }
|
||||
.agent-recovery-dialog .agent-recovery-heading { flex-shrink: 0; flex-direction: row; align-items: flex-start; justify-content: space-between; gap: 12px; text-align: left; }
|
||||
.agent-recovery-heading > div { min-width: 0; }
|
||||
.agent-recovery-heading > button { flex-shrink: 0; margin-top: -8px; margin-right: -8px; }
|
||||
.agent-recovery-heading p { margin-top: 8px; line-height: 1.7; }
|
||||
.agent-recovery-content { display: flex; min-height: 0; flex-direction: column; gap: 14px; overflow: auto; overscroll-behavior: contain; font-size: 14px; }
|
||||
.agent-recovery-record { padding: 16px; border: 1px solid #e5e0ef; border-radius: 16px; background: #f8f6fd; overflow-wrap: anywhere; }
|
||||
.agent-recovery-record h3 { font-weight: 600; line-height: 1.6; }
|
||||
.agent-recovery-time { margin-top: 3px; color: #788193; font-size: 12px; }
|
||||
.agent-recovery-record-help { margin-top: 10px; color: #606c7e; line-height: 1.7; }
|
||||
.agent-recovery-record-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
|
||||
.agent-recovery-record-actions button { min-height: 40px; border-radius: 10px; }
|
||||
.agent-recovery-footnote { color: #788193; font-size: 12px; line-height: 1.7; }
|
||||
.agent-recovery-result { padding: 16px; border: 1px solid #dbe9df; border-radius: 16px; background: #f2f8f3; line-height: 1.7; overflow-wrap: anywhere; }
|
||||
.agent-recovery-result pre { overflow: auto; }
|
||||
.agent-recovery-footer { flex-shrink: 0; }
|
||||
.agent-recovery-footer button { min-height: 44px; }
|
||||
@media (max-width: 1150px) {
|
||||
.agent-home { padding: 32px 28px 40px; }
|
||||
}
|
||||
|
||||
@@ -1,58 +1,103 @@
|
||||
export interface CloudChatStream {
|
||||
import type { CloudToolCall } from '../../../shared/cloud-agents';
|
||||
|
||||
export interface CloudLiveMessage {
|
||||
message_id: string;
|
||||
run_id: string;
|
||||
content: string;
|
||||
reasoning_content: string;
|
||||
tool_calls: (CloudToolCall & { index?: number })[];
|
||||
}
|
||||
export interface CloudStream {
|
||||
runId: string;
|
||||
cursor: string;
|
||||
messages: CloudLiveMessage[];
|
||||
finished?: boolean;
|
||||
}
|
||||
export const object = (value: unknown): Record<string, unknown> =>
|
||||
value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
|
||||
/** Redis SSE cursors order replayed batches without treating subthread output as an answer. */
|
||||
export function reduceCloudStream(state: CloudStream, data: unknown, threadId: string, cursor: string): CloudStream {
|
||||
if (cursor && /^\d+-\d+$/.test(cursor) && state.cursor) {
|
||||
const [time, sequence] = cursor.split('-').map(BigInt);
|
||||
const [previousTime, previousSequence] = state.cursor.split('-').map(BigInt);
|
||||
if (time < previousTime || (time === previousTime && sequence <= previousSequence)) return state;
|
||||
}
|
||||
const envelope = object(data);
|
||||
if (envelope.run_id && envelope.run_id !== state.runId) return state;
|
||||
const next = { ...state, cursor: /^\d+-\d+$/.test(cursor) ? cursor : state.cursor };
|
||||
if (envelope.thread_id && envelope.thread_id !== threadId) return next;
|
||||
const payload = object(envelope.payload);
|
||||
const chunks = Array.isArray(payload.items) ? payload.items : [payload.chunk];
|
||||
let messages = state.messages;
|
||||
for (const value of chunks) {
|
||||
const chunk = object(value);
|
||||
const event = object(chunk.stream_event);
|
||||
if ((chunk.thread_id && chunk.thread_id !== threadId) || (event.thread_id && event.thread_id !== threadId)) continue;
|
||||
if (['message_delta', 'tool_call', 'tool_call_delta'].includes(String(event.type))) {
|
||||
const id = typeof event.message_id === 'string' ? event.message_id
|
||||
: messages.filter(message => message.run_id === state.runId).at(-1)?.message_id ?? state.runId + ':message';
|
||||
const current = messages.find(message => message.message_id === id && message.run_id === state.runId)
|
||||
?? { message_id: id, run_id: state.runId, content: '', reasoning_content: '', tool_calls: [] };
|
||||
const updated = { ...current };
|
||||
if (event.type === 'message_delta') {
|
||||
updated.content += typeof event.content === 'string' ? event.content : '';
|
||||
updated.reasoning_content += typeof event.reasoning_content === 'string' ? event.reasoning_content
|
||||
: typeof event.additional_reasoning_content === 'string' ? event.additional_reasoning_content : '';
|
||||
} else {
|
||||
const index = typeof event.index === 'number' ? event.index : 0;
|
||||
const existing = current.tool_calls.find(tool => (event.tool_call_id && tool.id === event.tool_call_id) || tool.index === index);
|
||||
const tool = { ...existing, id: typeof event.tool_call_id === 'string' ? event.tool_call_id : existing?.id ?? id + ':' + index,
|
||||
name: typeof event.name === 'string' ? event.name : existing?.name ?? '工具',
|
||||
display_name: typeof event.display_name === 'string' ? event.display_name : existing?.display_name,
|
||||
display_description: typeof event.display_description === 'string' ? event.display_description : existing?.display_description,
|
||||
args: existing && existing.status !== 'pending' ? existing.args
|
||||
: event.type === 'tool_call' ? event.args ?? {} : String(existing?.args ?? '') + String(event.args_delta ?? ''),
|
||||
status: existing?.status ?? 'pending', index };
|
||||
updated.tool_calls = existing ? current.tool_calls.map(item => item === existing ? tool : item) : [...current.tool_calls, tool];
|
||||
}
|
||||
messages = messages.includes(current)
|
||||
? messages.map(message => message === current ? updated : message) : [...messages, updated];
|
||||
}
|
||||
const toolEvent = object(chunk.event);
|
||||
const result = object(toolEvent.data);
|
||||
if (toolEvent.method === 'tools' && typeof result.tool_call_id === 'string') {
|
||||
messages = messages.map(message => ({ ...message, tool_calls: message.tool_calls.map(tool => {
|
||||
if (message.run_id !== state.runId || tool.id !== result.tool_call_id) return tool;
|
||||
tool = { ...tool,
|
||||
display_name: typeof result.display_name === 'string' ? result.display_name : tool.display_name,
|
||||
display_description: typeof result.display_description === 'string' ? result.display_description : tool.display_description };
|
||||
if (result.event === 'tool-started') return { ...tool, args: result.input ?? tool.args, status: 'running' };
|
||||
if (result.event !== 'tool-finished') return tool;
|
||||
const output = object(result.output);
|
||||
const error = result.error || (output.status === 'error' ? output.content : null);
|
||||
return { ...tool, status: error ? 'error' : 'success',
|
||||
error_message: error ? (typeof error === 'string' ? error : JSON.stringify(error)) : null,
|
||||
tool_call_result: { content: output.content ?? result.output ?? '' } };
|
||||
}) }));
|
||||
}
|
||||
}
|
||||
return { ...next, messages };
|
||||
}
|
||||
|
||||
export interface CloudChatStream extends CloudStream {
|
||||
threadId: string;
|
||||
requestId?: string;
|
||||
cursor: string;
|
||||
content: string;
|
||||
reasoning: string;
|
||||
messageId: string;
|
||||
reasoningMessageId: string;
|
||||
ended: boolean;
|
||||
toolName: string;
|
||||
}
|
||||
|
||||
const object = (value: unknown): Record<string, unknown> => value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown> : {};
|
||||
const validCursor = (value: string) => /^\d+-\d+$/.test(value);
|
||||
function newerCursor(next: string, previous: string) {
|
||||
const [nextTime, nextIndex] = next.split('-').map(BigInt);
|
||||
const [previousTime, previousIndex] = previous.split('-').map(BigInt);
|
||||
return nextTime > previousTime || nextTime === previousTime && nextIndex > previousIndex;
|
||||
}
|
||||
|
||||
/** Project only the subscribed Run's main-thread messages; Redis IDs survive reconnects. */
|
||||
/** Keep transport identity and continuation state around the message-level projection. */
|
||||
export function consumeCloudChatEvent(previous: CloudChatStream | null, value: unknown, type: string, eventId: string,
|
||||
runId: string, threadId: string, requestId?: string): CloudChatStream | null {
|
||||
const envelope = object(value);
|
||||
if (envelope.run_id !== runId || requestId && envelope.request_id && envelope.request_id !== requestId) return previous;
|
||||
const continuation = previous?.threadId === threadId && requestId && previous.requestId === requestId;
|
||||
const current: CloudChatStream = previous?.runId === runId && previous.threadId === threadId ? previous : {
|
||||
runId, threadId, requestId, cursor: '0-0', content: '', reasoning: continuation ? previous.reasoning : '',
|
||||
messageId: '', reasoningMessageId: continuation ? previous.reasoningMessageId : '', ended: false, toolName: '',
|
||||
runId, threadId, requestId, cursor: '0-0', messages: continuation ? previous.messages : [], ended: false,
|
||||
};
|
||||
const fresh = validCursor(eventId) && newerCursor(eventId, current.cursor);
|
||||
// A synthesized terminal event can reuse the last message's Redis ID.
|
||||
if (type === 'end' && envelope.thread_id === threadId) return { ...current, ended: true, cursor: fresh ? eventId : current.cursor };
|
||||
if (validCursor(eventId) && !fresh) return previous;
|
||||
const next = { ...current, cursor: fresh ? eventId : current.cursor };
|
||||
// Child events still advance the replay cursor, but never become visible text.
|
||||
if (envelope.thread_id !== threadId) return next;
|
||||
const payload = object(envelope.payload);
|
||||
for (const item of Array.isArray(payload.items) ? payload.items : payload.chunk ? [payload.chunk] : []) {
|
||||
const event = object(object(item).stream_event);
|
||||
if (event.type === 'tool_call' && typeof event.name === 'string') next.toolName = event.name;
|
||||
if (event.type !== 'message_delta') continue;
|
||||
const id = typeof event.message_id === 'string' ? event.message_id : next.messageId;
|
||||
const reasoning = typeof event.reasoning_content === 'string' ? event.reasoning_content
|
||||
: typeof event.additional_reasoning_content === 'string' ? event.additional_reasoning_content : '';
|
||||
if (reasoning) {
|
||||
next.reasoning += (next.reasoning && next.reasoningMessageId && id !== next.reasoningMessageId ? '\n\n' : '') + reasoning;
|
||||
next.reasoningMessageId = id;
|
||||
}
|
||||
if (typeof event.content === 'string' && event.content) {
|
||||
next.content += (next.content && next.messageId && id !== next.messageId ? '\n\n' : '') + event.content;
|
||||
next.messageId = id;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
// Child events advance the cursor without becoming visible messages.
|
||||
const projected = reduceCloudStream(current, envelope.thread_id === threadId ? envelope : { ...envelope, payload: {} }, threadId, eventId);
|
||||
// Terminal events may reuse the last message cursor.
|
||||
if (type === 'end' && envelope.thread_id === threadId) return { ...current, ...projected, ended: true };
|
||||
return projected === current ? previous : { ...current, ...projected };
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { cloudAgentsApi } from '@/lib/cloud-agents-api';
|
||||
import { AppError } from '@/lib/error-model';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { type CloudAgentAvatar, type CloudAgentDraft, type CreateCloudAgent, type CloudAgentEntry, type CloudThread, type CloudRecent, type CloudPendingOperation } from '../../../shared/cloud-agents';
|
||||
import { DraftEditor } from './DraftEditor';
|
||||
@@ -39,6 +40,7 @@ function AgentWorkspace() {
|
||||
const [deleteTarget, setDeleteTarget] = useState<CloudAgentDraft | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [deleteError, setDeleteError] = useState('');
|
||||
const [recoveryTarget, setRecoveryTarget] = useState<HTMLDivElement | null>(null);
|
||||
const cancelDelete = useRef<HTMLButtonElement>(null);
|
||||
const deleteTrigger = useRef<HTMLButtonElement | null>(null);
|
||||
const createButton = useRef<HTMLButtonElement>(null);
|
||||
@@ -114,7 +116,6 @@ function AgentWorkspace() {
|
||||
void cloudAgentsApi.remember(null).catch(failure => { if (mounted.current) setError(errorMessage(failure)); });
|
||||
refreshRecovery();
|
||||
};
|
||||
const recovery = <CloudRecovery pending={pending} onResolved={refreshRecovery} />;
|
||||
|
||||
const accepted = (agent: CloudAgentDraft) => {
|
||||
if (!mounted.current) return;
|
||||
@@ -155,22 +156,21 @@ function AgentWorkspace() {
|
||||
}
|
||||
};
|
||||
|
||||
if (selected) return <div className="agent-desktop">{recovery}<DraftEditor key={selected.slug} initial={selected} entryView={entryView} startingPrompt={startingPrompt} recent={recent} onBack={() => {
|
||||
const renderWorkspace = () => {
|
||||
if (selected) return <div className="agent-desktop"><DraftEditor key={selected.slug} initial={selected} entryView={entryView} startingPrompt={startingPrompt} recent={recent} onBack={() => {
|
||||
setSelected(null);
|
||||
setEntryView(undefined);
|
||||
setStartingPrompt(undefined);
|
||||
clearRecent();
|
||||
}} onSaved={accepted} /></div>;
|
||||
if (shared || activity) return <div className="agent-desktop">{recovery}<SharedConversation shared={shared} activity={activity} recent={recent} onBack={() => { setShared(null); setActivity(null); clearRecent(); }} /></div>;
|
||||
if (shared || activity) return <div className="agent-desktop"><SharedConversation shared={shared} activity={activity} recent={recent} onBack={() => { setShared(null); setActivity(null); clearRecent(); }} /></div>;
|
||||
if (creating) return <section className="h-full overflow-auto p-6" data-testid="cloud-agents-page">
|
||||
{recovery}
|
||||
<div className="mx-auto max-w-3xl"><CreateAgentForm onCreated={agent => { setStartingPrompt(''); setEntryView('configuration'); accepted(agent); }}
|
||||
<div className="mx-auto max-w-3xl"><CreateAgentForm onCreated={agent => { setStartingPrompt(''); setEntryView('configuration'); accepted(agent); }} onSettled={refreshRecovery}
|
||||
onCancel={() => { setCreating(false); void load(); }} /></div>
|
||||
</section>;
|
||||
|
||||
return (
|
||||
<section className="agent-home" data-testid="cloud-agents-page">
|
||||
{recovery}
|
||||
<div className="agent-home-content">
|
||||
<header className="agent-home-header">
|
||||
<div className="agent-home-intro">
|
||||
@@ -183,6 +183,7 @@ function AgentWorkspace() {
|
||||
</div>}
|
||||
</div>
|
||||
<div className="agent-home-header-actions">
|
||||
<div ref={setRecoveryTarget} className="agent-recovery-slot" />
|
||||
{(section !== 'mine' || loading || error || agents.length > 0) &&
|
||||
<Button ref={createButton} className="agent-home-create" onClick={() => { setEntryView(undefined); setCreating(true); }}><Plus aria-hidden="true" />创建智能体</Button>}
|
||||
</div>
|
||||
@@ -240,9 +241,13 @@ function AgentWorkspace() {
|
||||
</Dialog>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
return <><CloudRecovery pending={pending} onResolved={refreshRecovery} triggerContainer={recoveryTarget} />{renderWorkspace()}</>;
|
||||
}
|
||||
|
||||
function CreateAgentForm({ onCreated, onCancel }: { onCreated: (agent: CloudAgentDraft) => void; onCancel: () => void }) {
|
||||
function CreateAgentForm({ onCreated, onCancel, onSettled }: {
|
||||
onCreated: (agent: CloudAgentDraft) => void; onCancel: () => void; onSettled: () => void;
|
||||
}) {
|
||||
const [name, setName] = useState('');
|
||||
const [avatar, setAvatar] = useState<CloudAgentAvatar>({ icon: null, icon_url: null });
|
||||
const [uploading, setUploading] = useState(false);
|
||||
@@ -250,6 +255,7 @@ function CreateAgentForm({ onCreated, onCancel }: { onCreated: (agent: CloudAgen
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const alive = useRef(true);
|
||||
const uncertainOperation = useRef<string | null>(null);
|
||||
const hasInput = Boolean(name || avatar.icon);
|
||||
const blocker = useBlocker(busy || uploading || intent !== null || hasInput);
|
||||
useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []);
|
||||
@@ -271,9 +277,19 @@ function CreateAgentForm({ onCreated, onCancel }: { onCreated: (agent: CloudAgen
|
||||
const agent = await cloudAgentsApi.create(input);
|
||||
if (alive.current) onCreated(agent);
|
||||
} catch (failure) {
|
||||
if (alive.current) setError(errorMessage(failure));
|
||||
if (alive.current) {
|
||||
if (failure instanceof AppError && failure.details?.commandOutcome === 'definitive_failure'
|
||||
&& uncertainOperation.current !== input.operation_id) {
|
||||
setIntent(null);
|
||||
setError('创建失败:' + errorMessage(failure));
|
||||
} else {
|
||||
// Discarding the local reminder cannot rule out acceptance of an earlier attempt.
|
||||
uncertainOperation.current = input.operation_id;
|
||||
setError('创建结果暂未确认,可重试确认结果。' + errorMessage(failure));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (alive.current) setBusy(false);
|
||||
if (alive.current) { setBusy(false); onSettled(); }
|
||||
}
|
||||
};
|
||||
|
||||
@@ -285,7 +301,7 @@ function CreateAgentForm({ onCreated, onCancel }: { onCreated: (agent: CloudAgen
|
||||
<CloudAvatarEditor value={avatar} name={name} disabled={busy || intent !== null} uploading={uploading}
|
||||
onUploading={setUploading} onChange={setAvatar} />
|
||||
</fieldset>
|
||||
{error && <p role="alert" className="text-sm text-destructive">{error}。可重试确认本次创建结果。</p>}
|
||||
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button type="submit" disabled={busy || uploading || !name.trim()}>{busy ? '正在创建…' : intent ? '重试创建' : '下一步'}</Button>
|
||||
<Button type="button" variant="ghost" disabled={busy || uploading} onClick={onCancel}>{intent ? '返回列表确认' : '取消'}</Button>
|
||||
|
||||
@@ -6,6 +6,7 @@ import moduleGameImage from '@/assets/module-game.webp';
|
||||
import moduleRobotImage from '@/assets/module-robot.webp';
|
||||
import logoWordmarkSource from '@/assets/makelore-wordmark-source.png';
|
||||
import { UserProfileDialog } from '@/components/profile/UserProfileDialog';
|
||||
import { HomeAccountMenu } from '@/components/account/HomeAccountMenu';
|
||||
import { useCurrentUserProfile } from '@/hooks/use-current-user-profile';
|
||||
import { aiModules, isAiModuleAllowed, type AiModuleId } from '@/lib/ai-modules';
|
||||
import { getAuthUserDisplayName } from '@/lib/auth-user-display';
|
||||
@@ -29,6 +30,7 @@ export function ModuleSelection({ authRequired = true }: { authRequired?: boolea
|
||||
userProfile,
|
||||
profileRequired,
|
||||
profileSyncError,
|
||||
syncProfileNow,
|
||||
} = useCurrentUserProfile(authRequired);
|
||||
const [profileDialogOpen, setProfileDialogOpen] = useState(false);
|
||||
const userDisplayName = getUserProfileDisplayName(userProfile) || getAuthUserDisplayName(authUser);
|
||||
@@ -48,8 +50,8 @@ export function ModuleSelection({ authRequired = true }: { authRequired?: boolea
|
||||
};
|
||||
|
||||
return (
|
||||
<main data-testid="ai-module-selection-page" className="relative min-h-[100dvh] overflow-auto bg-background px-4 py-6 text-foreground sm:px-8 sm:py-10">
|
||||
<div className="mx-auto flex min-h-[calc(100dvh-3rem)] max-w-6xl flex-col">
|
||||
<main data-testid="ai-module-selection-page" className="relative h-[100dvh] overflow-auto bg-background px-4 py-6 text-foreground sm:px-8 sm:py-10">
|
||||
<div className="mx-auto flex min-h-[calc(100dvh-3rem)] max-w-7xl flex-col sm:min-h-[calc(100dvh-5rem)]">
|
||||
<header
|
||||
data-testid="module-selection-drag-region"
|
||||
className="drag-region absolute inset-x-0 top-0 flex h-16 items-start justify-end px-6 pt-6 sm:px-8 sm:pt-8"
|
||||
@@ -57,12 +59,12 @@ export function ModuleSelection({ authRequired = true }: { authRequired?: boolea
|
||||
<img src={logoWordmarkSource} alt="Makelore" className="h-8 w-40 object-cover object-center" draggable="false" />
|
||||
</header>
|
||||
|
||||
<div className="grid flex-1 items-center gap-8 py-12 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.35fr)] lg:gap-12">
|
||||
<section className="min-w-0">
|
||||
<p className="text-sm font-semibold tracking-[0.08em] text-muted-foreground">
|
||||
<div className="module-selection-content">
|
||||
<section className="module-selection-intro">
|
||||
<p className="module-selection-welcome text-sm font-semibold tracking-[0.08em] text-muted-foreground">
|
||||
{userDisplayName ? `${userDisplayName},欢迎回来!` : '欢迎回来!'}
|
||||
</p>
|
||||
<h1 className="mt-3 whitespace-nowrap text-[clamp(2rem,4vw,4rem)] font-semibold leading-[1.02] tracking-[-0.055em]">今天,想创作什么?</h1>
|
||||
<h1 className="module-selection-title whitespace-nowrap font-semibold leading-[1.02] tracking-[-0.055em]">今天,想创作什么?</h1>
|
||||
</section>
|
||||
|
||||
<section
|
||||
@@ -70,7 +72,7 @@ export function ModuleSelection({ authRequired = true }: { authRequired?: boolea
|
||||
aria-label="AI 模块列表"
|
||||
className="module-selection-options"
|
||||
>
|
||||
{aiModules.map((module) => {
|
||||
{aiModules.filter((module) => module.id !== 'robot').map((module) => {
|
||||
const content = moduleSelectionContent[module.id];
|
||||
const enabledByPolicy = isAiModuleAllowed(module.id, moduleAccess);
|
||||
const enabled = module.enabled && enabledByPolicy;
|
||||
@@ -114,6 +116,13 @@ export function ModuleSelection({ authRequired = true }: { authRequired?: boolea
|
||||
})}
|
||||
</section>
|
||||
</div>
|
||||
{authenticated && authUser && <footer className="module-selection-account relative shrink-0 pb-2" aria-label="账户">
|
||||
<HomeAccountMenu key={authUser.userId ?? authUser.username} accountName={userDisplayName || authUser.username}
|
||||
avatarUrl={userProfile?.avatarUrl} onOpenProfile={() => {
|
||||
setDismissedSyncError(null); setProfileDialogOpen(true);
|
||||
void syncProfileNow().catch(() => undefined);
|
||||
}} />
|
||||
</footer>}
|
||||
</div>
|
||||
<UserProfileDialog
|
||||
open={profileDialogShouldBeOpen}
|
||||
|
||||
Reference in new issue
Block a user