Polish personal Agent conversation workspace
This commit is contained in:
1 parent
1aef423df0
commit
a340c0fd94
7 files changed
+599
-53
No files matched your search
@@ -1,11 +1,13 @@
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { History, MessageSquarePlus, Send, Square } from 'lucide-react';
|
||||
import { ArrowUpRight, History, MessageCircle, MessageSquarePlus, Send, Sparkles, Square } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { cloudAgentsApi } from '@/lib/cloud-agents-api';
|
||||
import type { CloudHistory, CloudInterrupt, CloudPrompt, CloudRequest, CloudRun, CloudThread, CloudScheduleProposal } from '../../../shared/cloud-agents';
|
||||
import { CloudFiles } from './CloudFiles';
|
||||
import { CloudAvatar } from './CloudAvatar';
|
||||
import './desktop-workspace.css';
|
||||
import './agent-chat.css';
|
||||
import ReactMarkdown, { defaultUrlTransform } from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { CloudPendingContext, useCloudPendingState, usePendingCloudInput } from './CloudPending';
|
||||
@@ -21,8 +23,8 @@ 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,
|
||||
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;
|
||||
assistantName = '助手', assistantAvatarUrl, examples = agentExamples(), hasUnsavedChanges = false, saveDisabled = false, onSaveForPreview }: {
|
||||
slug: string; assistantName?: string; assistantAvatarUrl?: string | null; 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);
|
||||
@@ -256,16 +258,16 @@ export function CloudChat({ slug, previewRevision: latestRevision, initialThread
|
||||
else if (runId) await cloudAgentsApi.call('cancelRun', { run_id: runId });
|
||||
} catch (e) { setError(message(e)); } finally { setBusy(false); }
|
||||
};
|
||||
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 ? '和助手聊一聊' : '试一试'}
|
||||
return <CloudPendingContext.Provider value={childrenPending.report}><section className="agent-chat agent-chat-styled" aria-label={previewRevision === undefined ? '智能体对话' : '草稿预览'}>
|
||||
<div className="agent-chat-toolbar"><div className="agent-chat-toolbar-row">
|
||||
<div className="agent-chat-toolbar-heading"><span className="agent-chat-toolbar-icon" aria-hidden="true"><MessageCircle /></span><div className="min-w-0"><p className="agent-chat-toolbar-title">{previewRevision === undefined ? '和助手聊一聊' : '试一试'}</p>
|
||||
{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>
|
||||
<p className="mt-1 text-xs text-muted-foreground">由创建者支付词元点数{run?.version && ` · 版本 ${run.version}`}</p></div></div>
|
||||
<div className="agent-chat-toolbar-actions"><Button variant="ghost" aria-label="查看历史对话" title="查看历史对话" aria-expanded={showHistory} onClick={() => setShowHistory(v => !v)}><History aria-hidden="true" className="h-4 w-4" /><span>历史对话</span></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 aria-hidden="true" className="h-4 w-4" /><span>新对话</span></Button>{headerAction}</div>
|
||||
</div>
|
||||
{showHistory && threads.length === 0 && <p className="py-2 text-xs text-muted-foreground">还没有历史对话</p>}
|
||||
{showHistory && threads.length > 0 && <select aria-label="历史对话" className="mt-3 h-10 rounded-md border border-input bg-background px-3 text-sm"
|
||||
{showHistory && threads.length > 0 && <select aria-label="历史对话" className="agent-chat-history-select"
|
||||
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>}
|
||||
@@ -278,23 +280,28 @@ export function CloudChat({ slug, previewRevision: latestRevision, initialThread
|
||||
</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="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>
|
||||
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 && !liveText && <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>
|
||||
<p className="agent-chat-welcome-description">{mode === 'preview' ? '看看它的回答是否符合你的要求。不满意就改一改,再试一次。' : '从下面选一句,或写下你自己的想法。'}</p>
|
||||
<div className="agent-chat-examples">{examples.map((example, index) => <Button key={example} type="button" variant="outline"
|
||||
className="agent-chat-example" data-tone={index % 3}
|
||||
disabled={busy || Boolean(intent) || Boolean(query.trim()) || Boolean(thread?.archived)} onClick={() => setQuery(example)}><span>{example}</span><ArrowUpRight aria-hidden="true" /></Button>)}</div>
|
||||
<p className="agent-chat-example-note">点选只会填入输入框,由你决定何时发送。</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} />
|
||||
<TrialMessages history={previousTrial.history} onError={setError} assistantName={assistantName} assistantAvatarUrl={assistantAvatarUrl} />
|
||||
</details>}
|
||||
{history && <TrialMessages history={history} onError={setError} />}
|
||||
{liveText && <div className="whitespace-pre-wrap break-words p-2 text-sm leading-7">{liveText}</div>}
|
||||
{history && <TrialMessages history={history} onError={setError} assistantName={assistantName} assistantAvatarUrl={assistantAvatarUrl} />}
|
||||
{liveText && <article 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><div className="agent-chat-message-body whitespace-pre-wrap">{liveText}</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>
|
||||
@@ -336,27 +343,30 @@ export function CloudChat({ slug, previewRevision: latestRevision, initialThread
|
||||
}} /></div>
|
||||
{leaving && <div className="mb-3 flex flex-wrap items-center gap-2 text-sm"><span>放弃未发送的内容并新建对话?</span>
|
||||
<Button variant="outline" onClick={() => { setLeaving(false); void choose(null); }}>放弃内容</Button><Button variant="ghost" onClick={() => setLeaving(false)}>继续编辑</Button></div>}
|
||||
<form data-testid="cloud-chat-composer" className="agent-chat-composer rounded-xl border border-input p-3 shadow-sm" onSubmit={e => { e.preventDefault(); void send(); }}>
|
||||
<Textarea aria-label="消息" className="h-20 min-h-20 max-h-40 resize-y border-0 p-1 shadow-none focus-visible:ring-0" maxLength={32000}
|
||||
value={query} disabled={busy || Boolean(intent) || loading || thread?.archived} placeholder="输入消息或任务目标…" onChange={e => setQuery(e.target.value)}
|
||||
<form data-testid="cloud-chat-composer" className="agent-chat-composer" onSubmit={e => { e.preventDefault(); void send(); }}>
|
||||
<Textarea aria-label="消息" className="agent-chat-input resize-y border-0 shadow-none focus-visible:ring-0" maxLength={32000}
|
||||
value={query} disabled={busy || Boolean(intent) || loading || thread?.archived} placeholder="说说你的想法,或问一个问题…" onChange={e => setQuery(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing && e.keyCode !== 229) { e.preventDefault(); void send(); } }} />
|
||||
<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 && (hasUnsavedChanges || stalePreview || !previewReady || !query.trim()))}><Send className="mr-2 h-3 w-3" />{busy ? '正在提交…' : intent ? '重试本次发送' : '发送'}</Button>}
|
||||
<div className="agent-chat-composer-footer">
|
||||
<div className="agent-chat-composer-meta"><p role="status" className="agent-chat-status" data-active={running}>{progress || (running ? cloudStatus(run?.status ?? request?.status ?? '') : run ? cloudStatus(run.status) : '就绪')}</p><span className="agent-chat-keyboard-hint">Enter 发送 · Shift + Enter 换行</span></div>
|
||||
{running ? <Button className="agent-chat-stop" size="sm" variant="outline" disabled={busy} onClick={() => void cancel()} type="button"><Square className="mr-2 h-3 w-3" />停止</Button>
|
||||
: <Button className="agent-chat-send" 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={{
|
||||
function TrialMessages({ history, onError, assistantName = '助手', assistantAvatarUrl }: {
|
||||
history: CloudHistory; onError: (error: string) => void; assistantName?: string; assistantAvatarUrl?: string | null;
|
||||
}) {
|
||||
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"><p className="agent-chat-speaker">{item.role === 'user' ? '你' : assistantName}</p>
|
||||
<div className="agent-chat-message-body 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>
|
||||
}}>{item.content}</ReactMarkdown></div></div>
|
||||
</article>);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user