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
@@ -0,0 +1,32 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
|
||||
const styles = [
|
||||
{ label: '简单易懂', instruction: '使用简单易懂的语言,遇到新词先解释。' },
|
||||
{ label: '一步一步来', instruction: '把任务拆成小步骤,一次引导我完成一步。' },
|
||||
{ label: '先给我提示', instruction: '先给我提示和思考问题,让我尝试后再补充答案。' },
|
||||
] as const;
|
||||
|
||||
/** Style choices are ordinary instruction lines in the existing system_prompt. */
|
||||
export function AgentRequirements({ value, onChange }: { value: string; onChange: (value: string) => void }) {
|
||||
const selected = styles.filter(style => value.split('\n').includes(style.instruction));
|
||||
const custom = value.split('\n').filter(line => !selected.some(style => style.instruction === line)).join('\n');
|
||||
return <div className="space-y-5">
|
||||
<fieldset className="space-y-2">
|
||||
<legend className="mb-2 text-sm font-medium">希望它怎样回答?<span className="ml-2 font-normal text-muted-foreground">可多选</span></legend>
|
||||
<div className="flex flex-wrap gap-2">{styles.map(style => {
|
||||
const checked = selected.includes(style);
|
||||
return <Button key={style.label} type="button" variant={checked ? 'secondary' : 'outline'} className="min-h-10"
|
||||
aria-pressed={checked} disabled={!checked && value.length + style.instruction.length + 1 > 32000} onClick={() => onChange(checked
|
||||
? value.split('\n').filter(line => line !== style.instruction).join('\n')
|
||||
: value + (value && !value.endsWith('\n') ? '\n' : '') + style.instruction)}>{style.label}</Button>;
|
||||
})}</div>
|
||||
</fieldset>
|
||||
<label className="block space-y-2 text-sm"><span className="font-medium">特别要求</span>
|
||||
<Textarea aria-label="特别要求" className="min-h-32 resize-y leading-7" maxLength={32000 - selected.reduce((length, style) => length + style.instruction.length + 1, 0)}
|
||||
value={custom} onChange={event => onChange([event.target.value, ...selected.map(style => style.instruction)].filter(Boolean).join('\n'))}
|
||||
placeholder="例如:先给我一个提示,等我想一想,再继续。" />
|
||||
<span className="block text-xs leading-6 text-muted-foreground">写下你希望它怎么帮你。已有的自定义要求也可以直接修改。</span>
|
||||
</label>
|
||||
</div>;
|
||||
}
|
||||
@@ -20,9 +20,9 @@ export function CloudAccessPanel({ slug, revision, onPublished, onChanged, activ
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [notice, setNotice] = useState('');
|
||||
usePendingCloudInput(busy || Boolean(name.trim()));
|
||||
const publishOperation = useRef<{ operation_id: string; expected_revision: number } | null>(null);
|
||||
const applicationOperation = useRef<{ operation_id: string; name: string } | null>(null);
|
||||
usePendingCloudInput(busy || Boolean(name.trim()) || Boolean(publishOperation.current));
|
||||
const alive = useRef(true);
|
||||
const refresh = useCallback(async () => {
|
||||
const result = await cloudAgentsApi.call('access', { slug });
|
||||
@@ -47,7 +47,7 @@ export function CloudAccessPanel({ slug, revision, onPublished, onChanged, activ
|
||||
await cloudAgentsApi.call('publish', { slug, ...input });
|
||||
publishOperation.current = null;
|
||||
onPublished();
|
||||
}, '已发布,新的调用将使用这个版本');
|
||||
}, '已准备好,新的对话将使用这次保存的要求');
|
||||
const createApplication = () => act(async () => {
|
||||
const input = applicationOperation.current ?? { operation_id: crypto.randomUUID(), name: name.trim() };
|
||||
applicationOperation.current = input;
|
||||
@@ -62,17 +62,19 @@ export function CloudAccessPanel({ slug, revision, onPublished, onChanged, activ
|
||||
return <div className="space-y-8">
|
||||
<section className="rounded-xl bg-violet-50/60 p-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4"><div>
|
||||
<h2 className="font-medium">{access?.published_version ? '当前发布版本 ' + access.published_version : '尚未发布'}</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">发布保存的修订 {revision}。发布后仅自己可用,分享与应用需要分别开启。</p>
|
||||
</div><Button disabled={busy} onClick={() => void publish()}>{publishOperation.current ? '重试本次发布' : '发布当前草稿'}</Button></div>
|
||||
<h2 className="font-medium">{access?.published_version ? '助手已准备好' : '准备好使用自己的助手了吗?'}</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">{access?.published_version ? '应用最新修改后,新的对话会使用这次保存的要求。已有对话保留原来的版本。' : '开始使用后,只有你自己能用。以后还可以回来修改要求。'}</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">分享给别人或接入应用,需要另外开启。</p>
|
||||
</div><Button disabled={busy || !access} onClick={() => void publish()}>{publishOperation.current ? '重试本次发布' : access?.published_version ? '应用最新修改' : '确认开始使用'}</Button></div>
|
||||
<p className="mt-4 text-sm leading-6">自己使用、分享使用、API 调用、自动任务和个人微信渠道产生的费用,均从你的个人词元点数扣除。你可以随时停用智能体或撤销访问。</p>
|
||||
{access?.published_version && <div className="mt-4 flex items-center gap-3 text-sm"><span>{access.enabled ? '智能体已启用' : '智能体已停用'}</span>
|
||||
<Button variant="outline" size="sm" disabled={busy} onClick={() => void act(() => cloudAgentsApi.call('setEnabled', { slug, enabled: !access.enabled }))}>
|
||||
{access.enabled ? '停用智能体' : '启用智能体'}</Button></div>}
|
||||
</section>
|
||||
{access?.published_version && <LinkedChannels slug={slug} activeView={activeView} />}
|
||||
{error && <p role="alert" className="text-sm text-destructive">{error} <Button variant="ghost" onClick={() => void act(refresh)}>刷新状态</Button></p>}
|
||||
{notice && <p role="status" className="text-sm text-muted-foreground">{notice}</p>}
|
||||
{access?.published_version && <>
|
||||
<LinkedChannels slug={slug} activeView={activeView} />
|
||||
<section className="space-y-4"><h2 className="font-medium">分享给指定用户</h2>
|
||||
<p className="text-sm text-muted-foreground">选择获准使用的账号,再发送分享链接。每个人的对话和文件分别保存。</p>
|
||||
<div className="flex gap-2"><Input aria-label="查找分享用户" value={query} onChange={e => setQuery(e.target.value)} placeholder="用户名、显示名或账号 ID" maxLength={100} />
|
||||
@@ -104,6 +106,7 @@ export function CloudAccessPanel({ slug, revision, onPublished, onChanged, activ
|
||||
<CloudLifecycle slug={slug} revision={revision} versions={access?.versions.map(v => v.version) ?? []} onChanged={draft => { onChanged?.(draft); void refresh().catch(e => setError(errorText(e))); }} />
|
||||
{access && <details className="text-sm"><summary className="cursor-pointer">发布历史({access.versions.length})</summary>
|
||||
{access.versions.map(v => <p key={v.version} className="mt-3 tabular-nums">版本 {v.version} · 草稿修订 {v.draft_revision} · {new Date(v.created_at).toLocaleString()}</p>)}</details>}
|
||||
</>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { cloudAgentsApi } from '@/lib/cloud-agents-api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -7,7 +7,8 @@ import { CloudResources } from './CloudResources';
|
||||
import { CloudBudgetEditor } from './CloudCosts';
|
||||
import type { CloudAgentConfiguration, CloudCatalog } from '../../../shared/cloud-agents';
|
||||
|
||||
export function ConfigurationFields({ slug, value, disabled, onChange, section = 'all' }: {
|
||||
export function ConfigurationFields({ slug, value, disabled, onChange, section = 'all', chooseInitialModel = false }: {
|
||||
chooseInitialModel?: boolean;
|
||||
section?: 'all' | 'instructions' | 'capabilities' | 'knowledge' | 'limits';
|
||||
slug: string; value: CloudAgentConfiguration; disabled: boolean; onChange: (value: CloudAgentConfiguration) => void;
|
||||
}) {
|
||||
@@ -16,24 +17,32 @@ export function ConfigurationFields({ slug, value, disabled, onChange, section =
|
||||
const [refresh, setRefresh] = useState(0);
|
||||
const [search, setSearch] = useState('');
|
||||
const [choosing, setChoosing] = useState<string | null>(null);
|
||||
const initialModelChosen = useRef(false);
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
cloudAgentsApi.call('catalog', {}).then(v => { if (live) { setCatalog(v); setError(''); } })
|
||||
.catch(e => { if (live) setError(e instanceof Error ? e.message : '配置目录暂不可用'); });
|
||||
return () => { live = false; };
|
||||
}, [refresh]);
|
||||
useEffect(() => {
|
||||
if (!chooseInitialModel || initialModelChosen.current || !catalog?.models.length || disabled) return;
|
||||
initialModelChosen.current = true;
|
||||
if (!value.model) onChange({ ...value, model: catalog.models[0].id });
|
||||
}, [catalog, chooseInitialModel, disabled, onChange, value]);
|
||||
const change = <K extends keyof CloudAgentConfiguration>(key: K, next: CloudAgentConfiguration[K]) => onChange({ ...value, [key]: next });
|
||||
return <div className="space-y-6">
|
||||
<div hidden={section === 'instructions' || section === 'limits'} className="flex items-center justify-between"><h2 className="font-medium">{section === 'knowledge' ? '知识库' : '能力'}</h2>
|
||||
<Button type="button" variant="ghost" disabled={disabled} onClick={() => setRefresh(v => v + 1)}>刷新目录</Button></div>
|
||||
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
|
||||
<div hidden={section !== 'all' && section !== 'capabilities'} className="space-y-4"><label className="block space-y-2 text-sm"><span>模型</span>
|
||||
{error && <p role="alert" className="text-sm text-destructive">{error} <Button type="button" variant="outline" disabled={disabled} onClick={() => setRefresh(v => v + 1)}>重新读取模型</Button></p>}
|
||||
<div hidden={section !== 'all' && section !== 'capabilities' && section !== 'instructions'} className="space-y-3"><label className="block space-y-2 text-sm"><span>模型</span>
|
||||
<select aria-label="模型" className="h-10 w-full rounded-md border border-input bg-background px-3" disabled={disabled || !catalog}
|
||||
value={value.model} onChange={e => change('model', e.target.value)}>
|
||||
<option value="">选择可用模型</option>
|
||||
{value.model && !catalog?.models.some(m => m.id === value.model) && <option value={value.model}>{value.model}(目录中不可用)</option>}
|
||||
{catalog?.models.map(m => <option key={m.id} value={m.id}>{m.name}</option>)}
|
||||
</select></label>
|
||||
{section === 'instructions' && <p className="text-xs leading-6 text-muted-foreground">这是帮助它思考和回答的 AI。{chooseInitialModel && catalog?.models.length ? '新助手从目录中的第一个可用模型开始,你可以更换。' : '选择模型后,保存即可试用。'}</p>}
|
||||
{catalog && catalog.models.length === 0 && <p className="text-sm text-muted-foreground">暂时没有可用模型,请稍后刷新目录或请老师协助。<Button type="button" variant="ghost" disabled={disabled} onClick={() => setRefresh(v => v + 1)}>刷新目录</Button></p>}
|
||||
{catalog?.pricing && <p className="text-xs leading-5 text-muted-foreground tabular-nums">
|
||||
每 {catalog.pricing.unit_size.toLocaleString()} 个词元约 {catalog.pricing.points_per_unit} 词元点数,按实际模型调用结算。
|
||||
</p>}
|
||||
|
||||
@@ -5,7 +5,7 @@ import './desktop-workspace.css';
|
||||
|
||||
const preferenceKey = 'makelore.cloud-agent-editor-ratio';
|
||||
|
||||
export function DesktopSplit({ configuration, preview }: { configuration: ReactNode; preview: (action: ReactNode) => ReactNode }) {
|
||||
export function DesktopSplit({ configuration, preview, previewActivation = 0 }: { configuration: ReactNode; preview: (action: ReactNode) => ReactNode; previewActivation?: number }) {
|
||||
const root = useRef<HTMLDivElement>(null);
|
||||
const [ratio, setRatio] = useState(() => {
|
||||
try { const saved = Number(localStorage.getItem(preferenceKey)); return saved >= 25 && saved <= 75 ? saved : 46; }
|
||||
@@ -13,6 +13,12 @@ export function DesktopSplit({ configuration, preview }: { configuration: ReactN
|
||||
});
|
||||
const [focus, setFocus] = useState<'configuration' | 'preview' | null>(null);
|
||||
const [compactPanel, setCompactPanel] = useState<'configuration' | 'preview'>('configuration');
|
||||
const [lastPreviewActivation, setLastPreviewActivation] = useState(previewActivation);
|
||||
if (lastPreviewActivation !== previewActivation) {
|
||||
setLastPreviewActivation(previewActivation);
|
||||
setCompactPanel('preview');
|
||||
setFocus(null);
|
||||
}
|
||||
const resize = (next: number) => {
|
||||
const width = (root.current?.clientWidth ?? 0) - 8;
|
||||
if (width < 720) return;
|
||||
@@ -40,7 +46,7 @@ export function DesktopSplit({ configuration, preview }: { configuration: ReactN
|
||||
<div ref={root} className="agent-split" data-focus={focus ?? ''} data-compact-panel={compactPanel}
|
||||
style={{ gridTemplateColumns: focus ? 'minmax(0, 1fr)' : `minmax(340px, ${ratio}fr) 8px minmax(380px, ${100 - ratio}fr)` }}>
|
||||
<section className="agent-config-pane" aria-label="草稿配置">
|
||||
<div className="agent-pane-heading"><span>配置智能体</span>{panelToggle('configuration')}</div>
|
||||
<div className="agent-pane-heading"><span>告诉它怎么帮你</span>{panelToggle('configuration')}</div>
|
||||
{configuration}
|
||||
</section>
|
||||
<div role="separator" aria-label="调整配置与试用宽度" aria-orientation="vertical" tabIndex={0}
|
||||
|
||||
@@ -14,6 +14,8 @@ import { CloudAccessPanel } from './CloudAccess';
|
||||
import { CloudSchedules } from './CloudSchedules';
|
||||
import { CloudPendingContext, useCloudPendingState } from './CloudPending';
|
||||
import { DesktopSplit } from './DesktopSplit';
|
||||
import { AgentRequirements } from './AgentRequirements';
|
||||
import { agentExamples } from './agent-guidance';
|
||||
|
||||
type Tab = 'configuration' | 'chat' | 'tasks' | 'access';
|
||||
type Category = 'instructions' | 'capabilities' | 'knowledge' | 'limits';
|
||||
@@ -21,11 +23,14 @@ const hasDraftChanges = (draft: CloudAgentDraft, saved: CloudAgentDraft) => draf
|
||||
|| JSON.stringify(draft.configuration) !== JSON.stringify(saved.configuration);
|
||||
const errorMessage = (error: unknown) => error instanceof Error ? error.message : '暂时无法完成操作';
|
||||
|
||||
export function DraftEditor({ initial, recent, onBack, onSaved }: {
|
||||
export function DraftEditor({ initial, recent, onBack, onSaved, startingPrompt }: {
|
||||
startingPrompt?: string;
|
||||
initial: CloudAgentDraft; recent: CloudRecent | null; onBack: () => void; onSaved: (agent: CloudAgentDraft) => void;
|
||||
}) {
|
||||
const [saved, setSaved] = useState(initial);
|
||||
const [draft, setDraft] = useState(initial);
|
||||
const [draft, setDraft] = useState(() => startingPrompt === undefined ? initial : { ...initial, system_prompt: startingPrompt });
|
||||
const [advanced, setAdvanced] = useState(false);
|
||||
const [previewActivation, setPreviewActivation] = useState(0);
|
||||
const [previewCreated, setPreviewCreated] = useState(Boolean(initial.configuration?.model));
|
||||
const [remote, setRemote] = useState<CloudAgentDraft | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -75,6 +80,7 @@ export function DraftEditor({ initial, recent, onBack, onSaved }: {
|
||||
setDraft(current => !hasDraftChanges(current, draft) ? result : { ...result, name: current.name, purpose: current.purpose, system_prompt: current.system_prompt, configuration: current.configuration });
|
||||
setRemote(null); onSaved(result);
|
||||
if (exit) finishLeaving();
|
||||
return result.draft_revision;
|
||||
} catch (failure) {
|
||||
if (!alive.current) return;
|
||||
setError(errorMessage(failure));
|
||||
@@ -100,18 +106,19 @@ export function DraftEditor({ initial, recent, onBack, onSaved }: {
|
||||
<Input aria-label="名称" maxLength={100} value={draft.name} disabled={busy}
|
||||
className="h-8 max-w-96 border-transparent bg-transparent px-2 font-semibold shadow-none hover:border-input focus:border-input"
|
||||
onChange={event => setDraft({ ...draft, name: event.target.value })} />
|
||||
<p className="px-2 text-xs text-muted-foreground">{saved.published_version ? `已发布 v${saved.published_version}` : '私人草稿'} · 修订 {saved.draft_revision}</p>
|
||||
<p className="px-2 text-xs text-muted-foreground">{saved.published_version ? '我的助手' : startingPrompt !== undefined ? '第 3 步,共 3 步 · 试一试' : '制作中的助手 · 仅自己可见'}</p>
|
||||
</div>
|
||||
<span role="status" className="shrink-0 text-xs text-muted-foreground">{busy ? '正在保存…' : dirty ? '有未保存修改' : '云端已保存'}</span>
|
||||
<Button size="sm" variant={dirty ? 'default' : 'outline'} disabled={!canSave} onClick={() => void save()} title="保存草稿(Ctrl / ⌘ S)">
|
||||
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : dirty ? <Save className="mr-2 h-4 w-4" /> : <Check className="mr-2 h-4 w-4" />}
|
||||
{busy ? '保存中…' : dirty ? '保存草稿' : '已保存'}
|
||||
</Button>
|
||||
{!saved.published_version && <Button className="min-h-10" variant="outline" disabled={busy} onClick={() => navigate('access')}>开始使用</Button>}
|
||||
</header>
|
||||
<nav aria-label="智能体工作区" className="agent-workspace-nav">
|
||||
{([['configuration', '编辑'], ['chat', '对话'], ['tasks', '自动任务']] as const).map(([key, label]) =>
|
||||
<Button key={key} variant={tab === key ? 'secondary' : 'ghost'} aria-current={tab === key ? 'page' : undefined} onClick={() => navigate(key)}>{label}</Button>)}
|
||||
<Button className="ml-auto" variant={tab === 'access' ? 'secondary' : 'ghost'} aria-current={tab === 'access' ? 'page' : undefined} onClick={() => navigate('access')}>发布与访问</Button>
|
||||
<Button className="ml-auto" variant={tab === 'access' ? 'secondary' : 'ghost'} aria-current={tab === 'access' ? 'page' : undefined} onClick={() => navigate('access')}>使用与分享</Button>
|
||||
</nav>
|
||||
{error && <p role="alert" className="shrink-0 px-6 py-2 text-sm text-destructive">{error}</p>}
|
||||
{remote && <div className="max-h-52 shrink-0 space-y-2 overflow-auto border-b border-amber-200 bg-amber-50 px-6 py-3 text-sm">
|
||||
@@ -121,26 +128,31 @@ export function DraftEditor({ initial, recent, onBack, onSaved }: {
|
||||
<Button variant="outline" onClick={() => { setSaved(remote); if (remote.configuration?.model) setPreviewCreated(true); setRemote(null); setError(''); }}>保留我的内容,继续编辑</Button></div>
|
||||
</div>}
|
||||
<div hidden={tab !== 'configuration'} className="agent-workspace-view">
|
||||
{visited.has('configuration') && <DesktopSplit configuration={<>
|
||||
<nav aria-label="配置分类" className="agent-config-tabs">{([['instructions', '指令'], ['capabilities', '能力'], ['knowledge', '知识'], ['limits', '限制']] as const).map(([key, label]) =>
|
||||
<Button key={key} variant={category === key ? 'secondary' : 'ghost'} aria-current={category === key ? 'page' : undefined} onClick={() => setCategory(key)}>{label}</Button>)}</nav>
|
||||
{visited.has('configuration') && <DesktopSplit previewActivation={previewActivation} configuration={<>
|
||||
<div className="agent-config-tabs">
|
||||
<Button variant={category === 'instructions' ? 'secondary' : 'ghost'} onClick={() => setCategory('instructions')}>我的要求</Button>
|
||||
<Button variant="ghost" aria-expanded={advanced} onClick={() => { setAdvanced(!advanced); setCategory('instructions'); }}>高级设置</Button>
|
||||
</div>
|
||||
{advanced && <nav aria-label="配置分类" className="agent-config-tabs">{([['capabilities', '能力'], ['knowledge', '知识'], ['limits', '限制']] as const).map(([key, label]) =>
|
||||
<Button key={key} variant={category === key ? 'secondary' : 'ghost'} aria-current={category === key ? 'page' : undefined} onClick={() => setCategory(key)}>{label}</Button>)}</nav>}
|
||||
<div className="agent-config-scroll"><fieldset disabled={busy} className="min-w-0">
|
||||
<div hidden={category !== 'instructions'} className="space-y-6">
|
||||
<label className="block space-y-2 text-sm"><span className="font-medium">用途</span><Textarea className="min-h-20 resize-y" maxLength={2000} value={draft.purpose}
|
||||
<div hidden={category !== 'instructions'} className="mb-6 space-y-6">
|
||||
<label className="block space-y-2 text-sm"><span className="font-medium">用途</span><Textarea aria-label="用途" className="min-h-20 resize-y" maxLength={2000} value={draft.purpose}
|
||||
onChange={event => setDraft({ ...draft, purpose: event.target.value })} placeholder="一句话介绍,它可以帮你做什么。" /></label>
|
||||
<label className="block space-y-2 text-sm"><span className="font-medium">角色与指令</span><Textarea className="min-h-[min(400px,45vh)] resize-y leading-7" maxLength={32000}
|
||||
value={draft.system_prompt} onChange={event => setDraft({ ...draft, system_prompt: event.target.value })} placeholder="描述它的工作方式、回答风格,以及需要遵守的要求。" /></label>
|
||||
<p className="text-xs leading-6 text-muted-foreground">先描述你希望它怎样工作,再通过右侧试用调整。草稿只有你可见。</p>
|
||||
<AgentRequirements value={draft.system_prompt} onChange={system_prompt => setDraft({ ...draft, system_prompt })} />
|
||||
</div>
|
||||
<ConfigurationFields slug={saved.slug} section={category} value={draft.configuration ?? EMPTY_CLOUD_CONFIGURATION} disabled={busy}
|
||||
<ConfigurationFields slug={saved.slug} section={category} chooseInitialModel={startingPrompt !== undefined} value={draft.configuration ?? EMPTY_CLOUD_CONFIGURATION} disabled={busy}
|
||||
onChange={configuration => setDraft({ ...draft, configuration })} />
|
||||
</fieldset></div>
|
||||
</>} preview={expandAction => <>
|
||||
{dirty && <p role="status" className="shrink-0 rounded-md bg-muted/50 px-3 py-2 text-xs leading-5 text-muted-foreground">配置有未保存修改。保存不会清空当前试用。</p>}
|
||||
{!saved.configuration?.model && !previewCreated ? <div className="my-auto p-6 text-sm leading-7">{expandAction}<h2 className="font-medium">试试它是否懂你</h2>
|
||||
<p className="mt-2 text-muted-foreground">选择模型并保存后,在这里开始试用。</p><Button variant="outline" className="mt-4" onClick={() => setCategory('capabilities')}>选择模型</Button>
|
||||
<p className="mt-2 text-muted-foreground">看看你填好的要求,准备好后就能和它聊两句。</p>
|
||||
<Button className="mt-4 min-h-10" disabled={!canSave || !draft.configuration?.model} onClick={async () => { if (await save() !== undefined) setPreviewActivation(v => v + 1); }}>保存并开始试用</Button>
|
||||
{!draft.configuration?.model && <p className="mt-3 text-muted-foreground">请先在“我的要求”里选择一个可用模型。</p>}
|
||||
<p className="mt-4 text-xs text-muted-foreground">保存免费;发送消息按模型实际用量扣除你的词元点数。</p></div>
|
||||
: <CloudChat slug={saved.slug} previewRevision={saved.draft_revision} activeView={tab === 'configuration'} headerAction={expandAction} previewReady={Boolean(saved.configuration?.model)} />}
|
||||
: <CloudChat slug={saved.slug} previewRevision={saved.draft_revision} activeView={tab === 'configuration'} headerAction={expandAction} previewReady={Boolean(saved.configuration?.model)}
|
||||
examples={agentExamples(saved.purpose)} hasUnsavedChanges={dirty} saveDisabled={!canSave || !draft.configuration?.model}
|
||||
onSaveForPreview={save} />}
|
||||
</>} />}
|
||||
</div>
|
||||
<div hidden={tab !== 'chat'} className="agent-workspace-view p-6">
|
||||
@@ -154,7 +166,7 @@ export function DraftEditor({ initial, recent, onBack, onSaved }: {
|
||||
onSchedule={proposal => { setScheduleProposal(proposal); navigate('tasks'); }} /></div>
|
||||
<div hidden={conversationSource !== 'wechat'} className="min-h-0 flex-1 overflow-auto">{wechatVisited && <CloudChannelConversations slug={saved.slug} activeView={tab === 'chat' && conversationSource === 'wechat'} />}</div>
|
||||
</CloudPendingContext.Provider>
|
||||
: <p className="m-auto text-sm text-muted-foreground">先在编辑中试用,再发布智能体,即可开始正式对话。</p>)}
|
||||
: <div className="m-auto space-y-4 text-center text-sm"><p>试用满意后,就可以开始使用自己的助手了。</p><Button onClick={() => navigate('access')}>开始使用</Button></div>)}
|
||||
</div>
|
||||
<div hidden={tab !== 'tasks'} className="agent-management-view">
|
||||
{visited.has('tasks') && <CloudSchedules slug={saved.slug} proposal={scheduleProposal} onProposalClosed={() => setScheduleProposal(undefined)} onOpen={thread => {
|
||||
@@ -163,12 +175,13 @@ export function DraftEditor({ initial, recent, onBack, onSaved }: {
|
||||
}} />}
|
||||
</div>
|
||||
<div hidden={tab !== 'access'} className="agent-management-view">
|
||||
{dirty && <p className="mb-4 text-sm text-muted-foreground">先保存草稿,再发布或调整访问。编辑中的内容仍保留。</p>}
|
||||
{dirty && <div className="mb-4 space-y-3 text-sm"><p>先保存你的要求,再开始使用。当前试用会保留。</p><Button disabled={!canSave} onClick={() => void save()}>保存修改</Button></div>}
|
||||
<fieldset disabled={dirty || busy || remote !== null}>
|
||||
{visited.has('access') && <CloudAccessPanel activeView={tab === 'access'} slug={saved.slug} revision={saved.draft_revision} onChanged={result => { setSaved(result); if (result.configuration?.model) setPreviewCreated(true); setDraft(current => !hasDraftChanges(current, saved) ? result : { ...current, draft_revision: result.draft_revision, published_version: result.published_version }); onSaved(result); }} onPublished={() => {
|
||||
void cloudAgentsApi.get(saved.slug).then(result => { if (alive.current) {
|
||||
const publication = { published_version: result.published_version, enabled: result.enabled };
|
||||
setSaved(current => ({ ...current, ...publication })); setDraft(current => ({ ...current, ...publication })); onSaved({ ...saved, ...publication });
|
||||
if (!saved.published_version && result.published_version) navigate('chat');
|
||||
} }).catch(e => { if (alive.current) setError(errorMessage(e)); });
|
||||
}} />}
|
||||
</fieldset>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export const agentStarters = [
|
||||
{ name: '科学问答助手', purpose: '帮我理解科学知识,解答生活中的为什么。', detail: '一起探索生活中的为什么', prompt: '用生活中的例子解释科学知识。不确定时说明不确定。', examples: ['为什么雨后会出现彩虹?', '用一个生活中的例子解释浮力。'] },
|
||||
{ name: '故事搭档', purpose: '陪我构思角色和情节,一起写出自己的故事。', detail: '把一个小想法变成故事', prompt: '先听我的想法,再给我几个可以选择的故事方向,鼓励我自己接着写。', examples: ['我想写一只害怕黑夜的小狐狸,帮我想三个故事开头。', '我的故事写不下去了,可以问我几个问题吗?'] },
|
||||
{ name: '英语陪练', purpose: '陪我练习日常英语,用简单的对话帮助我表达。', detail: '从一句简单的英语开始', prompt: '每次只练习一小段对话。纠正错误时,解释原因并给一个正确示例。', examples: ['陪我练习用英语介绍自己,一次问一个问题。', '我想用英语在餐厅点餐,我们来试试。'] },
|
||||
{ name: '整理小帮手', purpose: '帮我把零散的笔记和想法整理得清楚。', detail: '让笔记和想法更有条理', prompt: '保留我提供的信息,先整理重点;缺少信息时问我,不要编造。', examples: ['帮我整理这段笔记:今天观察了绿豆,有的发芽了,有的还没动静。', '我准备做一次读书分享,帮我列一个简单提纲。'] },
|
||||
] as const;
|
||||
|
||||
export function agentExamples(purpose?: string): readonly string[] {
|
||||
return agentStarters.find(starter => starter.purpose === purpose)?.examples
|
||||
?? ['你可以怎样帮助我?请举一个例子。', '我不知道从哪里开始,请先问我一个问题。'];
|
||||
}
|
||||
@@ -27,6 +27,15 @@
|
||||
.agent-chat-files details { margin: 8px 0; padding: 0 8px; border: 0; }
|
||||
.agent-chat-composer { flex-shrink: 0; }
|
||||
.agent-chat-composer textarea { height: clamp(48px, 12vh, 80px); min-height: 48px; max-height: min(160px, 25vh); }
|
||||
.agent-desktop button { min-height: 40px; }
|
||||
.agent-desktop h2, .agent-desktop h3 { text-wrap: balance; }
|
||||
.agent-desktop p { text-wrap: pretty; }
|
||||
.agent-workspace-nav { flex-wrap: wrap; }
|
||||
@media (max-width: 640px) {
|
||||
.agent-workspace-header { flex-wrap: wrap; gap: 8px; padding-inline: 12px; }
|
||||
.agent-workspace-header > [role="status"] { margin-left: 48px; }
|
||||
.agent-workspace-nav { padding-inline: 12px; }
|
||||
}
|
||||
@container (max-width: 759px) {
|
||||
.agent-compact-switch { display: flex; flex-shrink: 0; gap: 4px; padding: 8px 20px; }
|
||||
.agent-split { grid-template-columns: minmax(0, 1fr) !important; }
|
||||
|
||||
@@ -14,6 +14,8 @@ import { CloudOverview } from './CloudOverview';
|
||||
import { CloudPendingContext, useCloudPendingState } from './CloudPending';
|
||||
import { CloudRecovery } from './CloudRecovery';
|
||||
import { ChannelAccounts } from './ChannelAccounts';
|
||||
import { AgentRequirements } from './AgentRequirements';
|
||||
import { agentStarters } from './agent-guidance';
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : '暂时无法连接智能体服务,请重试';
|
||||
@@ -27,6 +29,7 @@ function AgentWorkspace() {
|
||||
const [error, setError] = useState('');
|
||||
const [selected, setSelected] = useState<CloudAgentDraft | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [startingPrompt, setStartingPrompt] = useState<string>();
|
||||
const [section, setSection] = useState<'mine' | 'received' | 'activity'>(() => new URLSearchParams(location.search).get('view') === 'activity' ? 'activity' : 'mine');
|
||||
const [shared, setShared] = useState<CloudAgentEntry | null>(null);
|
||||
const [activity, setActivity] = useState<CloudThread | null>(null);
|
||||
@@ -104,11 +107,17 @@ function AgentWorkspace() {
|
||||
setCreating(false);
|
||||
};
|
||||
|
||||
if (selected) return <div className="agent-desktop">{recovery}<DraftEditor key={selected.slug} initial={selected} recent={recent} onBack={() => {
|
||||
if (selected) return <div className="agent-desktop">{recovery}<DraftEditor key={selected.slug} initial={selected} startingPrompt={startingPrompt} recent={recent} onBack={() => {
|
||||
setSelected(null);
|
||||
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 (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, prompt) => { setStartingPrompt(prompt); accepted(agent); }}
|
||||
onCancel={() => { setCreating(false); void load(); }} /></div>
|
||||
</section>;
|
||||
|
||||
return (
|
||||
<section className="h-full overflow-auto p-6 pb-10" data-testid="cloud-agents-page">
|
||||
@@ -127,25 +136,24 @@ function AgentWorkspace() {
|
||||
</nav>
|
||||
{section !== 'mine' ? <CloudOverview key={section} section={section} onAgent={setShared} onThread={setActivity} /> : <>
|
||||
<label className="mb-5 flex items-center gap-2 text-sm"><input type="checkbox" checked={archived} disabled={creating} onChange={e => setArchived(e.target.checked)} />查看已归档智能体</label>
|
||||
{creating && <CreateAgentForm onCreated={accepted} onCancel={() => { setCreating(false); void load(); }} />}
|
||||
{error && <div role="alert" className="mb-5 rounded-lg border border-border p-4 text-sm">
|
||||
<p>{error}</p><Button variant="outline" className="mt-3" onClick={() => void load()}>重新连接</Button>
|
||||
</div>}
|
||||
{loading && agents.length === 0 ? <p role="status" className="py-12 text-muted-foreground">正在读取云端智能体…</p>
|
||||
: !error && agents.length === 0 && !creating ? <div className="rounded-xl border border-dashed border-border px-6 py-16 text-center">
|
||||
<Sparkles className="mx-auto mb-5 h-9 w-9 text-violet-500" strokeWidth={1.4} />
|
||||
<h2 className="text-lg font-medium">你的第一个智能体,从这里开始</h2>
|
||||
<p className="mb-6 mt-2 text-sm text-muted-foreground">例如写作搭档、资料整理助手,或你的专属顾问。</p>
|
||||
<h2 className="text-lg font-medium">做一个懂你的小助手</h2>
|
||||
<p className="mb-6 mt-2 text-sm text-muted-foreground">选个用途,告诉它你的要求,再和它聊两句试试看。</p>
|
||||
<Button variant="outline" onClick={() => setCreating(true)}>创建第一个智能体</Button>
|
||||
</div> : null}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{agents.map((agent) => <button key={agent.slug} disabled={creating} onClick={() => setSelected(agent)}
|
||||
className="rounded-xl border border-border bg-background p-5 text-left transition-colors enabled:hover:bg-violet-50/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50">
|
||||
<span className="mb-4 flex items-center justify-between"><Sparkles className="h-5 w-5 text-violet-500" />
|
||||
<span className="text-xs text-muted-foreground">{agent.published_version ? '已发布 · v' + agent.published_version : '私人草稿'}</span></span>
|
||||
<span className="text-xs text-muted-foreground">{agent.archived ? '已归档' : !agent.enabled ? '已停用' : agent.published_version ? '可以使用' : '还在制作'}</span></span>
|
||||
<h2 className="truncate font-medium">{agent.name}</h2>
|
||||
<p className="mt-2 line-clamp-3 whitespace-pre-wrap text-sm leading-6 text-muted-foreground">{agent.purpose}</p>
|
||||
<p className="mt-5 text-xs text-muted-foreground">修订 {agent.draft_revision} · {agent.published_version ? '打开智能体' : '配置智能体'}</p>
|
||||
<p className="mt-5 text-xs text-muted-foreground">{agent.published_version ? '打开对话 →' : '继续制作 →'}</p>
|
||||
</button>)}
|
||||
</div>
|
||||
{cursor && <Button className="mt-5" variant="outline" disabled={loading} onClick={() => void load(cursor)}>加载更多</Button>}
|
||||
@@ -154,22 +162,24 @@ function AgentWorkspace() {
|
||||
);
|
||||
}
|
||||
|
||||
function CreateAgentForm({ onCreated, onCancel }: { onCreated: (agent: CloudAgentDraft) => void; onCancel: () => void }) {
|
||||
function CreateAgentForm({ onCreated, onCancel }: { onCreated: (agent: CloudAgentDraft, prompt: string) => void; onCancel: () => void }) {
|
||||
const [step, setStep] = useState<1 | 2>(1);
|
||||
const [name, setName] = useState('');
|
||||
const [purpose, setPurpose] = useState('');
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [intent, setIntent] = useState<CreateCloudAgent | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const alive = useRef(true);
|
||||
const blocker = useBlocker(busy || intent !== null || Boolean(name || purpose));
|
||||
const blocker = useBlocker(busy || intent !== null || Boolean(name || purpose || prompt));
|
||||
useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []);
|
||||
useEffect(() => {
|
||||
const warn = (event: BeforeUnloadEvent) => {
|
||||
if (busy || intent || name || purpose) { event.preventDefault(); event.returnValue = ''; }
|
||||
if (busy || intent || name || purpose || prompt) { event.preventDefault(); event.returnValue = ''; }
|
||||
};
|
||||
window.addEventListener('beforeunload', warn);
|
||||
return () => window.removeEventListener('beforeunload', warn);
|
||||
}, [busy, intent, name, purpose]);
|
||||
}, [busy, intent, name, purpose, prompt]);
|
||||
|
||||
const submit = async () => {
|
||||
if (busy || !name.trim() || !purpose.trim()) return;
|
||||
@@ -179,7 +189,7 @@ function CreateAgentForm({ onCreated, onCancel }: { onCreated: (agent: CloudAgen
|
||||
setError('');
|
||||
try {
|
||||
const agent = await cloudAgentsApi.create(input);
|
||||
if (alive.current) onCreated(agent);
|
||||
if (alive.current) onCreated(agent, prompt);
|
||||
} catch (failure) {
|
||||
if (alive.current) setError(errorMessage(failure));
|
||||
} finally {
|
||||
@@ -188,16 +198,30 @@ function CreateAgentForm({ onCreated, onCancel }: { onCreated: (agent: CloudAgen
|
||||
};
|
||||
|
||||
return <form className="mb-8 space-y-4 rounded-xl border border-border p-5" onSubmit={(event) => { event.preventDefault(); void submit(); }}>
|
||||
<h2 className="font-medium">创建智能体</h2>
|
||||
<p className="text-xs text-muted-foreground">第 {step} 步,共 3 步 · {step === 1 ? '选用途' : '说要求'}</p>
|
||||
<h2 className="text-lg font-medium">{step === 1 ? '你想让它帮你做什么?' : '让它更懂你'}</h2>
|
||||
{step === 1 ? <>
|
||||
<p className="text-sm text-muted-foreground">选一个开始,之后随时可以改。</p>
|
||||
<div className="grid gap-3 sm:grid-cols-2">{agentStarters.map(starter => <button type="button" key={starter.name}
|
||||
className="min-h-24 rounded-lg border border-border p-4 text-left transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => { setName(starter.name); setPurpose(starter.purpose); setPrompt(starter.prompt); setStep(2); }}>
|
||||
<span className="block font-medium">{starter.name}</span><span className="mt-2 block text-sm text-muted-foreground">{starter.detail}</span>
|
||||
</button>)}</div>
|
||||
<div className="flex gap-2"><Button type="button" variant="outline" onClick={() => setStep(2)}>我自己想一个</Button>
|
||||
<Button type="button" variant="ghost" onClick={onCancel}>取消</Button></div>
|
||||
</> : <fieldset disabled={busy || intent !== null} className="space-y-4">
|
||||
<label className="block space-y-2 text-sm"><span>名称</span><Input autoFocus maxLength={100} value={name}
|
||||
disabled={busy || intent !== null} onChange={(event) => setName(event.target.value)} placeholder="例如:我的写作搭档" /></label>
|
||||
<label className="block space-y-2 text-sm"><span>用途</span><Textarea maxLength={2000} value={purpose}
|
||||
<label className="block space-y-2 text-sm"><span>用途</span><Textarea aria-label="用途" maxLength={2000} value={purpose}
|
||||
disabled={busy || intent !== null} onChange={(event) => setPurpose(event.target.value)} placeholder="希望它帮你完成什么?" /></label>
|
||||
<AgentRequirements value={prompt} onChange={setPrompt} />
|
||||
</fieldset>}
|
||||
{error && <p role="alert" className="text-sm text-destructive">{error}。可重试确认本次创建结果。</p>}
|
||||
<div className="flex gap-3">
|
||||
<Button type="submit" disabled={busy || !name.trim() || !purpose.trim()}>{busy ? '正在创建…' : intent ? '重试创建' : '创建草稿'}</Button>
|
||||
{step === 2 && <div className="flex flex-wrap gap-3">
|
||||
<Button type="submit" disabled={busy || !name.trim() || !purpose.trim()}>{busy ? '正在创建…' : intent ? '重试创建' : '下一步:试一试'}</Button>
|
||||
{!intent && <Button type="button" variant="outline" disabled={busy} onClick={() => setStep(1)}>上一步</Button>}
|
||||
<Button type="button" variant="ghost" disabled={busy} onClick={onCancel}>{intent ? '返回列表确认' : '取消'}</Button>
|
||||
</div>
|
||||
</div>}
|
||||
{blocker.state === 'blocked' && <div role="dialog" aria-modal="true" aria-label="离开创建" className="fixed inset-0 z-50 flex items-center justify-center bg-black/20 p-6">
|
||||
<div className="max-w-md space-y-4 rounded-xl bg-background p-6 shadow-lg">
|
||||
<h2 className="font-medium">{intent ? '本次创建结果尚未确认' : '还有未提交的内容'}</h2>
|
||||
|
||||
Reference in new issue
Block a user