feat(agents): guide first helper creation and trial
This commit is contained in:
1 parent
1d00ba39bd
commit
6e6a8c92dd
16 files changed
+594
-100
No files matched your search
@@ -9,6 +9,7 @@ import './desktop-workspace.css';
|
||||
import ReactMarkdown, { defaultUrlTransform } from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { CloudPendingContext, useCloudPendingState, usePendingCloudInput } from './CloudPending';
|
||||
import { agentExamples } from './agent-guidance';
|
||||
|
||||
export const cloudStatus = (status: string) => ({
|
||||
idle: '尚未开始', queued: '等待执行', dispatching: '准备执行', dispatched: '已提交', pending: '准备中',
|
||||
@@ -19,13 +20,18 @@ const active = (status?: string) => Boolean(status && ['queued', 'dispatching',
|
||||
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> : {};
|
||||
|
||||
export function CloudChat({ slug, previewRevision: latestRevision, initialThread, initialThreadId, onSchedule, activeView = true, headerAction, previewReady = true }: { slug: string; headerAction?: ReactNode; previewReady?: boolean; activeView?: boolean; previewRevision?: number; initialThread?: CloudThread; initialThreadId?: string; onSchedule?: (proposal: CloudScheduleProposal) => void }) {
|
||||
export function CloudChat({ slug, previewRevision: latestRevision, initialThread, initialThreadId, onSchedule, activeView = true, headerAction, previewReady = true,
|
||||
examples = agentExamples(), hasUnsavedChanges = false, saveDisabled = false, onSaveForPreview }: {
|
||||
slug: string; headerAction?: ReactNode; previewReady?: boolean; activeView?: boolean; previewRevision?: number; initialThread?: CloudThread; initialThreadId?: string; onSchedule?: (proposal: CloudScheduleProposal) => void;
|
||||
examples?: readonly string[]; hasUnsavedChanges?: boolean; saveDisabled?: boolean; onSaveForPreview?: () => Promise<number | undefined>;
|
||||
}) {
|
||||
const [previewRevision, setPreviewRevision] = useState(latestRevision === undefined ? undefined : initialThread?.draft_revision ?? latestRevision);
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
const [threads, setThreads] = useState<CloudThread[]>([]);
|
||||
const [thread, setThread] = useState<CloudThread | null>(initialThread ?? null);
|
||||
const [clientId, setClientId] = useState(() => initialThread?.client_thread_id ?? crypto.randomUUID());
|
||||
const [history, setHistory] = useState<CloudHistory | null>(null);
|
||||
const [previousTrial, setPreviousTrial] = useState<{ history: CloudHistory; revision?: number } | null>(null);
|
||||
const [request, setRequest] = useState<CloudRequest | null>(null);
|
||||
const [queued, setQueued] = useState<CloudRequest[]>([]);
|
||||
const [run, setRun] = useState<CloudRun | null>(null);
|
||||
@@ -200,7 +206,7 @@ export function CloudChat({ slug, previewRevision: latestRevision, initialThread
|
||||
setThreads(current => [next, ...current.filter(item => item.thread_id !== next.thread_id)]);
|
||||
};
|
||||
const send = async () => {
|
||||
if (busy || running || thread?.archived || (!intent && (stalePreview || !previewReady || !query.trim()))) return;
|
||||
if (busy || running || thread?.archived || (!intent && (hasUnsavedChanges || stalePreview || !previewReady || !query.trim()))) return;
|
||||
const input = intent ?? {
|
||||
request_id: crypto.randomUUID(), thread_id: thread?.thread_id ?? clientId, query: query.trim(),
|
||||
attachment_file_ids: attachmentIds,
|
||||
@@ -219,15 +225,30 @@ export function CloudChat({ slug, previewRevision: latestRevision, initialThread
|
||||
} catch (e) { if (alive.current && g === generation.current) setError(message(e)); }
|
||||
finally { if (alive.current && g === generation.current) setBusy(false); }
|
||||
};
|
||||
const choose = async (next: CloudThread | null, keepQuery = false) => {
|
||||
const choose = async (next: CloudThread | null, keepQuery = false, revision = latestRevision) => {
|
||||
following.current = true;
|
||||
if (mode === 'preview') setPreviewRevision(next?.draft_revision ?? latestRevision);
|
||||
if (next) setPreviousTrial(null);
|
||||
if (mode === 'preview') setPreviewRevision(next?.draft_revision ?? revision);
|
||||
const g = ++generation.current;
|
||||
setThread(next); setClientId(next?.client_thread_id ?? crypto.randomUUID()); setHistory(null);
|
||||
setRequest(null); setQueued([]); setRun(null); setLiveText(''); setInterrupt(null); setError(''); if (!keepQuery) setQuery('');
|
||||
setAttachmentIds([]);
|
||||
if (next) { setLoading(true); try { await readHistory(next.thread_id, g); } catch(e) { setError(message(e)); } finally { setLoading(false); } }
|
||||
};
|
||||
const trialPending = running || queued.length > 0 || Boolean(intent) || Boolean(interrupt) || attachmentIds.length > 0 || childrenPending.hasPending;
|
||||
const restartTrial = async (saveFirst: boolean) => {
|
||||
if (busy || loading || trialPending || (saveFirst ? saveDisabled || !onSaveForPreview : !previewReady)) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const revision = saveFirst ? await onSaveForPreview!() : latestRevision;
|
||||
if (!alive.current || revision === undefined) return;
|
||||
if (history?.messages.length) setPreviousTrial({ history, revision: previewRevision });
|
||||
const lastQuestion = history?.messages.filter(item => item.role === 'user').at(-1)?.content;
|
||||
await choose(null, true, revision);
|
||||
if (!query.trim() && lastQuestion) setQuery(lastQuestion);
|
||||
} catch (e) { if (alive.current) setError(message(e)); }
|
||||
finally { if (alive.current) setBusy(false); }
|
||||
};
|
||||
const cancel = async () => {
|
||||
setBusy(true); setError('');
|
||||
try {
|
||||
@@ -237,7 +258,8 @@ export function CloudChat({ slug, previewRevision: latestRevision, initialThread
|
||||
};
|
||||
return <CloudPendingContext.Provider value={childrenPending.report}><section className="agent-chat bg-background" aria-label={previewRevision === undefined ? '智能体对话' : '草稿预览'}>
|
||||
<div className="agent-chat-toolbar"><div className="flex min-h-12 shrink-0 items-center justify-between gap-3 border-b border-border py-2">
|
||||
<div className="min-w-0 text-sm">{previewRevision === undefined ? '正式对话' : '草稿预览 · 修订 ' + previewRevision}
|
||||
<div className="min-w-0 text-sm">{previewRevision === undefined ? '和助手聊一聊' : '试一试'}
|
||||
{previewRevision !== undefined && <p className="mt-1 text-xs text-muted-foreground">当前试用 · 修订 {previewRevision}</p>}
|
||||
<p className="mt-1 text-xs text-muted-foreground">由创建者支付词元点数{run?.version && ` · 版本 ${run.version}`}</p></div>
|
||||
<div className="flex shrink-0"><Button variant="ghost" size="icon" aria-label="查看历史对话" aria-expanded={showHistory} onClick={() => setShowHistory(v => !v)}><History className="h-4 w-4" /></Button>
|
||||
<Button variant="ghost" disabled={busy || Boolean(intent) || loading} onClick={() => (query.trim() || attachmentIds.length > 0 || childrenPending.hasPending) ? setLeaving(true) : void choose(null)} aria-label="新对话"><MessageSquarePlus className="h-4 w-4" /></Button>{headerAction}</div>
|
||||
@@ -247,26 +269,31 @@ export function CloudChat({ slug, previewRevision: latestRevision, initialThread
|
||||
value={thread?.thread_id ?? ''} disabled={busy || Boolean(intent) || Boolean(query.trim()) || attachmentIds.length > 0 || childrenPending.hasPending} onChange={e => void choose(threads.find(t => t.thread_id === e.target.value) ?? null)}>
|
||||
<option value="">新对话</option>{threads.map(t => <option key={t.thread_id} value={t.thread_id}>{t.mode === 'preview' ? `修订 ${t.draft_revision ?? previewRevision} · ` : ''}{t.title || '未命名对话'} · {cloudStatus(t.status)}</option>)}
|
||||
</select>}
|
||||
{stalePreview && <div className="flex shrink-0 flex-wrap items-center justify-between gap-2 border-b border-border py-2 text-xs leading-5">
|
||||
<p>配置已更新到修订 {latestRevision},当前试用仍保留修订 {previewRevision}。</p>
|
||||
<Button title="保留未发送文字,已有对话可从历史中查看" size="sm" variant="outline" disabled={!previewReady || busy || loading || Boolean(intent) || Boolean(interrupt) || attachmentIds.length > 0 || childrenPending.hasPending}
|
||||
onClick={() => void choose(null, true)}>用最新草稿试用</Button>
|
||||
{(Boolean(intent) || Boolean(interrupt) || attachmentIds.length > 0 || childrenPending.hasPending) && <p className="mt-1 text-muted-foreground">请先完成本次重试或确认,并取消附件选择,再开始新试用。</p>}
|
||||
{(stalePreview || hasUnsavedChanges) && <div className="flex shrink-0 flex-wrap items-center justify-between gap-2 border-b border-border py-2 text-sm leading-6">
|
||||
<p>{hasUnsavedChanges ? '要求改好了?保存后再试一次,看看回答有什么不同。' : '要求已保存,重新试用就会使用最新修改。'}</p>
|
||||
<Button title="保留上次试用和未发送文字,不会自动发送消息" className="min-h-10" variant="outline" disabled={busy || loading || trialPending || (hasUnsavedChanges ? saveDisabled : !previewReady)}
|
||||
onClick={() => void restartTrial(hasUnsavedChanges)}>{hasUnsavedChanges ? '保存并重新试用' : '用最新草稿试用'}</Button>
|
||||
{trialPending && <p className="text-xs text-muted-foreground">先完成当前试用或确认操作,并取消附件选择,再重新试用。</p>}
|
||||
{!previewReady && <p className="text-muted-foreground">当前草稿尚未选择模型。选择模型并保存后,可开始新试用。</p>}
|
||||
</div>}
|
||||
</div>
|
||||
<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="space-y-5 py-5 pr-2">
|
||||
{loading ? <p role="status" className="text-sm text-muted-foreground">正在读取对话…</p>
|
||||
: !history?.messages.length && !liveText && <div className="py-12 text-center text-sm leading-7 text-muted-foreground">告诉它你想完成什么。<br />关闭客户端后,已提交的任务仍在云端继续。</div>}
|
||||
{history?.messages.map(item => <article key={item.id} className={item.role === 'user' ? 'ml-8 rounded-xl bg-muted/60 p-4' : 'p-2'}>
|
||||
<p className="mb-2 text-xs text-muted-foreground">{item.role === 'user' ? '你' : '智能体'}</p>
|
||||
<div className="prose prose-sm max-w-none break-words dark:prose-invert"><ReactMarkdown urlTransform={url => url.startsWith('sandbox:/') ? url.slice(8) : defaultUrlTransform(url)} remarkPlugins={[remarkGfm]} components={{
|
||||
a: ({ href, children }) => href && !/^(https?:|mailto:|#)/i.test(href) && thread
|
||||
? <button className="text-primary underline" onClick={() => void cloudAgentsApi.download(thread.thread_id, href.replace(/^\/api\/chat\/thread\/[^/]+\/artifacts\//, '/')).catch(e => setError(message(e)))}>{children}</button>
|
||||
: <a href={href} target="_blank" rel="noreferrer">{children}</a>,
|
||||
}}>{item.content}</ReactMarkdown></div>
|
||||
</article>)}
|
||||
: !history?.messages.length && !liveText && <div className="space-y-4 py-6 text-sm leading-7">
|
||||
<h2 className="font-medium">{mode === 'preview' ? '问它一个小问题' : '今天想让它帮你做什么?'}</h2>
|
||||
<p className="text-muted-foreground">{mode === 'preview' ? '看看它的回答是否符合你的要求。不满意就改一改,再试一次。' : '从下面选一句,或写下你自己的想法。'}</p>
|
||||
<div className="grid gap-2">{examples.map(example => <Button key={example} type="button" variant="outline"
|
||||
className="h-auto min-h-10 justify-start whitespace-normal py-2 text-left font-normal leading-6"
|
||||
disabled={busy || Boolean(intent) || Boolean(query.trim()) || Boolean(thread?.archived)} onClick={() => setQuery(example)}>{example}</Button>)}</div>
|
||||
<p className="text-xs text-muted-foreground">点选只会填入输入框,由你决定何时发送。</p>
|
||||
</div>}
|
||||
{previousTrial && <details className="rounded-lg border border-border p-3 text-sm">
|
||||
<summary className="min-h-10 cursor-pointer content-center font-medium">对比上一次试用 · 修订 {previousTrial.revision}</summary>
|
||||
<p className="my-2 text-xs text-muted-foreground">这是修改前的回答。用同一个问题再问一次,更容易看出变化。</p>
|
||||
<TrialMessages history={previousTrial.history} onError={setError} />
|
||||
</details>}
|
||||
{history && <TrialMessages history={history} onError={setError} />}
|
||||
{liveText && <div className="whitespace-pre-wrap break-words p-2 text-sm leading-7">{liveText}</div>}
|
||||
{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>
|
||||
@@ -316,12 +343,23 @@ export function CloudChat({ slug, previewRevision: latestRevision, initialThread
|
||||
<div className="mt-2 flex items-center justify-between gap-3">
|
||||
<p role="status" className="text-xs text-muted-foreground">{progress || (running ? cloudStatus(run?.status ?? request?.status ?? '') : run ? cloudStatus(run.status) : '就绪')}</p>
|
||||
{running ? <Button size="sm" variant="outline" disabled={busy} onClick={() => void cancel()} type="button"><Square className="mr-2 h-3 w-3" />停止</Button>
|
||||
: <Button size="sm" type="submit" disabled={busy || loading || thread?.archived || (!intent && (stalePreview || !previewReady || !query.trim()))}><Send className="mr-2 h-3 w-3" />{busy ? '正在提交…' : intent ? '重试本次发送' : '发送'}</Button>}
|
||||
: <Button size="sm" type="submit" disabled={busy || loading || thread?.archived || (!intent && (hasUnsavedChanges || stalePreview || !previewReady || !query.trim()))}><Send className="mr-2 h-3 w-3" />{busy ? '正在提交…' : intent ? '重试本次发送' : '发送'}</Button>}
|
||||
</div>
|
||||
</form>
|
||||
</section></CloudPendingContext.Provider>;
|
||||
}
|
||||
|
||||
function TrialMessages({ history, onError }: { history: CloudHistory; onError: (error: string) => void }) {
|
||||
return history.messages.map(item => <article key={item.id} className={item.role === 'user' ? 'ml-8 rounded-xl bg-muted/60 p-4' : 'p-2'}>
|
||||
<p className="mb-2 text-xs text-muted-foreground">{item.role === 'user' ? '你' : '助手'}</p>
|
||||
<div className="prose prose-sm max-w-none break-words"><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>
|
||||
</article>);
|
||||
}
|
||||
|
||||
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>;
|
||||
@@ -357,7 +395,20 @@ export function CloudApproval({ interrupt, runId, onResumed, submitDecision }: {
|
||||
};
|
||||
return <div className="space-y-3 rounded-xl border border-amber-200 bg-amber-50/50 p-4 text-sm">
|
||||
<h3 className="font-medium">需要你的确认</h3>
|
||||
{actions.length ? <>{actions.map((action, i) => <pre key={i} className="max-h-36 overflow-auto whitespace-pre-wrap text-xs">{JSON.stringify(action, null, 2)}</pre>)}
|
||||
{actions.length ? <>
|
||||
<p>助手想执行下面{actions.length > 1 ? '这些操作' : '这个操作'}。看清内容后,再决定是否允许。</p>
|
||||
{actions.map((action, i) => {
|
||||
const detail = object(action);
|
||||
const args = object(detail.args ?? detail.arguments);
|
||||
const labels: Record<string, string> = { query: '要查找的内容', url: '网页地址', path: '位置', file_path: '文件位置', content: '内容', command: '要运行的命令' };
|
||||
const names: Record<string, string> = { execute: '运行命令', ls: '查看文件列表' };
|
||||
return <div key={i} className="space-y-2 rounded-lg border border-amber-200 p-3">
|
||||
<p className="break-words font-medium">{actions.length > 1 ? (i + 1) + '. ' : ''}{typeof detail.description === 'string' && detail.description ? detail.description : typeof detail.name === 'string' ? names[detail.name] ?? detail.name : '待确认的操作'}</p>
|
||||
{Object.entries(args).map(([key, value]) => <div key={key} className="text-sm"><p className="text-muted-foreground">{labels[key] ?? key}</p>
|
||||
<pre className="max-h-40 overflow-auto whitespace-pre-wrap break-words font-sans leading-6">{typeof value === 'string' ? value : JSON.stringify(value, null, 2)}</pre></div>)}
|
||||
<details><summary className="min-h-10 cursor-pointer content-center text-xs text-muted-foreground">查看技术详情</summary><pre className="max-h-40 overflow-auto whitespace-pre-wrap text-xs">{JSON.stringify(action, null, 2)}</pre></details>
|
||||
</div>;
|
||||
})}
|
||||
<div className="flex gap-2"><Button disabled={busy || Boolean(intent)} onClick={() => void submit({ decisions: actions.map(() => ({ type: 'approve' })) })}>允许执行</Button>
|
||||
<Button variant="outline" disabled={busy || Boolean(intent)} onClick={() => void submit({ decisions: actions.map(() => ({ type: 'reject', message: '用户拒绝执行' })) })}>拒绝</Button></div></>
|
||||
: <>{questions.map(q => <fieldset key={q.id} disabled={busy || Boolean(intent)} className="space-y-2">
|
||||
|
||||
Reference in new issue
Block a user