assistant/renderer/App.tsx

425 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useCallback, useEffect, useRef, useState } from 'react';
import { ArrowDown, ArrowRight, ArrowUp, ChevronDown, Download, FileText, Globe, Hand, LoaderCircle, PanelRight, Pencil, Plus, Radar, Search, Settings, Sparkles, Square, ThumbsDown, ThumbsUp, X, Zap } from 'lucide-react';
import { api, errorText } from './api';
import { BrowserPanel } from './components/BrowserPanel';
import { SettingsDialog } from './components/SettingsDialog';
import { OutreachPlan, type OutreachRequest } from './components/OutreachPlan';
import { SparkMark, ParticleCompanion } from './components/ParticleCompanion';
import { MotionAssembly } from './components/MotionAssembly';
import { ConversationParticles } from './components/ConversationParticles';
import { ClarificationCard, ClarificationHistory, DeliveryFilesCard, DeliveryStatusCard, LeadResults, PhaseCard, RecoveryCard, UsageCard } from './components/SceneCards';
import { KnowledgePanel } from './components/KnowledgePanel';
import { WindowTitleBar } from './components/WindowTitleBar';
import { type BrowserViewState, type BusinessOutcomeValue, type ChatMessage, type KnowledgeMethods, type Meta, type ExportFile, type RecoveryRoute, type Task, type TaskSummary } from './types';
import { displayTime, feedbackOf, messagesOf, statusLabel, stopReasonText } from './taskView';
const KNOWLEDGE_ID = 'xiaohongshu_prospecting';
const ACTIVE = new Set(['running', 'paused']);
const STAGE_LABEL: Record<string, string> = {
brief: '理解你的目标', intent: '理解你的目标', model_setup: '等待模型配置', waiting: '等待浏览器',
search_read: '搜索与阅读', source_capture: '保存页面来源', login_or_question: '等待登录或回答',
analyze: '分析需求', results: '阶段结果', partial_results: '阶段结果', stage_result: '阶段结果', stage_results: '阶段结果',
stopped: '已停止', interrupted: '执行中断', budget: '历史任务中断', export: '整理名单',
follow: '核对关注状态', message: '跟进消息', learning: '整理操作经验',
outreach_authorization: '核对跟进范围', export_ready: '名单已就绪', checkpoint: '核对执行进度',
};
const SUGGESTIONS = [
'我做杭州装修,帮我找最近准备装修的人,整理一份名单。',
'帮我分析一篇小红书笔记和评论里的真实需求。',
'继续之前的线索,先核对账号信息,再整理导出。',
];
function asText(value: unknown): string { return typeof value === 'string' ? value : ''; }
function stringList(value: unknown): string[] { return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []; }
function PlanCard({ task, onEdit }: { task: Task; onEdit: () => void }) {
const plan = task.artifacts.plan;
const keywords = stringList(task.inputs.keywords);
const stages = stringList(plan?.stages);
const missing = task.ui?.clarification ? [] : stringList(plan?.missing);
const assumptions = stringList(plan?.assumptions);
return <details className="understanding-card" data-testid="goal-plan">
<summary><span><Radar size={15} /><strong>我理解的目标</strong></span><ChevronDown size={14} /></summary>
<p className="plan-summary">{asText(plan?.summary) || asText(task.inputs.goal) || '查看已有任务条件'}</p>
{task.ui?.goal_progress?.target != null && <p className="plan-target">本次目标:整理 {task.ui.goal_progress.target} 位满足必要条件的去重候选。字段证据分别说明。</p>}
{stages.length > 0 && <ol className="plan-stages">{stages.map((stage, index) => <li key={`${index}-${stage}`}><span>{index + 1}</span>{stage}</li>)}</ol>}
{(keywords.length > 0 || asText(task.inputs.region)) && <div className="condition-tags">{keywords.map(keyword => <span key={keyword}><Search size={12} />{keyword}</span>)}{asText(task.inputs.region) && <span>{asText(task.inputs.region)}</span>}</div>}
{assumptions.length > 0 && <p className="plan-assumptions">待核实的理解:{assumptions.join(';')}</p>}
{missing.length > 0 && <p className="plan-missing">还需要了解:{missing.join(';')}</p>}
<button type="button" className="text-action" onClick={onEdit}><Pencil size={13} />在对话里调整目标</button>
</details>;
}
function TaskFeedback({ task, busy, onFeedback }: { task: Task; busy: boolean; onFeedback: (rating: 'up' | 'down', reason?: string) => Promise<void> }) {
const current = feedbackOf(task);
const [showReason, setShowReason] = useState(false);
const [reason, setReason] = useState('');
const [sent, setSent] = useState(false);
const reasons = ['人群不对', '没有真实需求', '任务没完成', '过程太慢'];
return <div className="task-feedback" data-testid="task-feedback">
<div className="feedback-actions"><span>这次结果有帮助吗?</span>
<button type="button" className={`icon-button${current?.rating === 'up' ? ' selected' : ''}`} aria-label="结果有帮助" aria-pressed={current?.rating === 'up'} disabled={busy} onClick={() => { setShowReason(false); void onFeedback('up').catch(() => undefined); }}><ThumbsUp size={16} /></button>
<button type="button" className={`icon-button${current?.rating === 'down' ? ' selected' : ''}`} aria-label="结果没帮助" aria-pressed={current?.rating === 'down'} disabled={busy} onClick={() => { setShowReason(true); setSent(false); void onFeedback('down').catch(() => undefined); }}><ThumbsDown size={16} /></button>
{current && <small>已记录评价</small>}
</div>
<p className="scene-footnote">评价记录本次结果的满意度,不自动增加方法的技术成功次数。</p>
{showReason && <div className="feedback-reason"><div className="reason-chips">{reasons.map(value => <button type="button" key={value} className={reason === value ? 'selected' : ''} onClick={() => { setReason(value); setSent(false); }}>{value}</button>)}</div>
<label><span className="sr-only">评价原因,可选</span><textarea rows={2} maxLength={1000} placeholder="也可以补充原因(选填)" value={reason} onChange={event => { setReason(event.target.value); setSent(false); }} /></label>
<div><button type="button" className="text-action" onClick={() => setShowReason(false)}>收起</button><button type="button" className="secondary-button small" disabled={busy || !reason.trim() || sent} onClick={() => { void onFeedback('down', reason.trim()).then(() => setSent(true)).catch(() => undefined); }}>{sent ? '已补充' : '补充原因'}</button></div>
</div>}
</div>;
}
export function App() {
const [meta, setMeta] = useState<Meta | null>(null);
const [connected, setConnected] = useState(false);
const [tasks, setTasks] = useState<TaskSummary[]>([]);
const [task, setTask] = useState<Task | null>(null);
const [doc, setDoc] = useState('');
const [knowledge, setKnowledge] = useState<KnowledgeMethods | null>(null);
const [knowledgeLoading, setKnowledgeLoading] = useState(false);
const [knowledgeError, setKnowledgeError] = useState('');
const [tab, setTab] = useState<'leads' | 'progress' | 'learning'>('leads');
const [busy, setBusy] = useState(false);
const [notice, setNotice] = useState('');
const [draft, setDraft] = useState('');
const [sendingText, setSendingText] = useState('');
const [clarifying, setClarifying] = useState(false);
const [webSearch, setWebSearch] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [historyOpen, setHistoryOpen] = useState(false);
const [detailsOpen, setDetailsOpen] = useState(false);
const [showLatest, setShowLatest] = useState(false);
const [reducedMotion, setReducedMotion] = useState(() => { try { const stored = localStorage.getItem('prospecting-reduced-motion'); return stored === null ? window.matchMedia('(prefers-reduced-motion: reduce)').matches : stored === 'true'; } catch { return false; } });
const [browserOpen, setBrowserOpen] = useState(false);
const [browserState, setBrowserState] = useState<BrowserViewState | null>(null);
const [inputHint, setInputHint] = useState(false);
const selected = useRef<string | null>(null);
const selectedTask = useRef<Task | null>(null);
const refreshing = useRef(new Set<string>());
const busyRef = useRef(false);
const composing = useRef(false);
const content = useRef<HTMLDivElement>(null);
const composer = useRef<HTMLTextAreaElement>(null);
const followScroll = useRef(true);
const observedActiveTasks = useRef(new Set<string>());
const knowledgeRequest = useRef(0);
if (task?.status === 'running') observedActiveTasks.current.add(task.id);
selectedTask.current = task;
const embedded = Boolean(meta?.embedded);
const loadKnowledge = useCallback(async (id: string | null): Promise<void> => {
const request = ++knowledgeRequest.current;
setKnowledgeLoading(true); setKnowledgeError('');
try {
const result = await api.get<KnowledgeMethods>(`/knowledge/methods${id ? `?task_id=${encodeURIComponent(id)}` : ''}`);
if (selected.current === id && knowledgeRequest.current === request) setKnowledge(result);
} catch (error) {
if (selected.current === id && knowledgeRequest.current === request) setKnowledgeError(errorText(error));
} finally {
if (selected.current === id && knowledgeRequest.current === request) setKnowledgeLoading(false);
}
}, []);
const evidence = useCallback(async (id: string): Promise<void> => {
const [progress] = await Promise.allSettled([api.get<{ content: string }>(`/tasks/${id}/progress-doc`), loadKnowledge(id)]);
if (selected.current !== id) return;
if (progress.status === 'fulfilled') setDoc(progress.value.content);
}, [loadKnowledge]);
const refresh = useCallback(async (id: string): Promise<void> => {
if (refreshing.current.has(id)) return;
refreshing.current.add(id);
try {
const next = await api.get<Task>(`/tasks/${id}`);
if (selected.current !== id) return;
const previous = selectedTask.current;
setTask(next);
if (next.live_session && ACTIVE.has(next.status) && (previous?.id !== next.id || !previous.live_session)) setBrowserOpen(true);
void evidence(id);
} catch (error) {
if (selected.current === id) {
setNotice(errorText(error));
if (selectedTask.current?.id !== id) selected.current = null;
}
}
finally { refreshing.current.delete(id); }
}, [evidence]);
const connect = useCallback(async (): Promise<void> => {
try {
const [info, history] = await Promise.all([api.get<Meta>('/meta'), api.get<TaskSummary[]>('/tasks?limit=40')]);
setMeta(info); setTasks(history); setConnected(true);
const current = selectedTask.current;
const latest = history.find(item => item.id === selected.current);
if (current && latest && current.id === latest.id && (latest.status !== current.status || latest.updated_at !== current.updated_at)) await refresh(latest.id);
} catch { setConnected(false); }
}, [refresh]);
useEffect(() => { void connect(); const interval = setInterval(() => { void connect(); }, 5000); return () => clearInterval(interval); }, [connect]);
useEffect(() => { void loadKnowledge(null); }, [loadKnowledge]);
useEffect(() => { if (detailsOpen) void loadKnowledge(selected.current); }, [detailsOpen, loadKnowledge]);
useEffect(() => { if (!task || !ACTIVE.has(task.status)) return; const interval = setInterval(() => { void refresh(task.id); }, 2500); return () => clearInterval(interval); }, [task?.id, task?.status, refresh]);
useEffect(() => {
const offState = api.browser.onState(event => { if (event.taskId === selected.current) setBrowserState(event); });
const offReveal = api.browser.onReveal(event => { if (event.taskId === selected.current) setBrowserOpen(true); });
const offHide = api.browser.onHide(event => { if (event.taskId === selected.current) setBrowserOpen(false); });
const offInput = api.browser.onUserInput(event => { if (event.taskId === selected.current) setInputHint(true); });
return () => { offState(); offReveal(); offHide(); offInput(); };
}, []);
useEffect(() => {
const element = composer.current;
if (!element) return;
element.style.height = 'auto'; element.style.height = `${Math.max(36, Math.min(160, element.scrollHeight))}px`;
}, [draft]);
useEffect(() => {
if (!followScroll.current || !content.current) return;
const frame = requestAnimationFrame(() => { if (content.current) content.current.scrollTop = content.current.scrollHeight; });
return () => cancelAnimationFrame(frame);
}, [task?.id, task?.artifacts.messages?.length, task?.artifacts.summary, task?.status, sendingText]);
useEffect(() => { try { localStorage.setItem('prospecting-reduced-motion', String(reducedMotion)); } catch { /* Preferences remain local when storage is unavailable. */ } }, [reducedMotion]);
useEffect(() => {
if (!historyOpen && !detailsOpen) return;
const selector = historyOpen ? '[data-testid="history-drawer"]' : '[data-testid="details-drawer"]';
const frame = requestAnimationFrame(() => { document.querySelector<HTMLElement>(`${selector} button`)?.focus(); });
return () => cancelAnimationFrame(frame);
}, [historyOpen, detailsOpen]);
function closeDrawers(): void {
const selector = historyOpen ? '[data-testid="history-toggle"]' : '[data-testid="details-toggle"]';
setHistoryOpen(false); setDetailsOpen(false);
requestAnimationFrame(() => document.querySelector<HTMLElement>(selector)?.focus());
}
function drawerKeyDown(event: React.KeyboardEvent<HTMLElement>): void {
if (event.key === 'Escape') { event.preventDefault(); closeDrawers(); return; }
if (event.key !== 'Tab') return;
const items = Array.from(event.currentTarget.querySelectorAll<HTMLElement>('button:not(:disabled),a[href],input:not(:disabled),[tabindex="0"]'));
const first = items[0], last = items.at(-1);
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); }
}
function newTask(): void {
closeDrawers(); selected.current = null; setTask(null); setDoc(''); setKnowledge(null); setKnowledgeError(''); setDraft(''); setNotice('');
setBrowserOpen(false); setBrowserState(null); setInputHint(false); setTab('leads'); followScroll.current = true;
observedActiveTasks.current.clear();
setWebSearch(false); void loadKnowledge(null); requestAnimationFrame(() => composer.current?.focus());
}
function openTask(id: string): void {
closeDrawers(); selected.current = id; setTask(null); setNotice(''); setDoc(''); setKnowledge(null); setKnowledgeError(''); setKnowledgeLoading(false); setBrowserState(null);
observedActiveTasks.current.clear();
setBrowserOpen(false); setInputHint(false); setDraft(''); followScroll.current = true; setTab('leads'); void refresh(id);
}
async function action(path: string, body: unknown = {}): Promise<boolean> {
if (busyRef.current) return false;
busyRef.current = true; setBusy(true); setNotice('');
try {
const result = await api.post<Task>(path, body);
if (result.id) { selected.current = result.id; setTask(result); void evidence(result.id); }
else if (selected.current) await refresh(selected.current);
await connect(); return true;
} catch (error) { setNotice(errorText(error)); return false; }
finally { busyRef.current = false; setBusy(false); }
}
async function sendMessage(value = draft): Promise<void> {
const text = value.trim();
if (!text || busyRef.current || !connected || (selected.current && !task)) return;
followScroll.current = true; setSendingText(text);
const path = task ? `/tasks/${task.id}/message` : '/tasks/start';
const body = task ? { message: text } : { knowledge_id: KNOWLEDGE_ID, message: text, inputs: { use_web_search: webSearch } };
const sent = await action(path, body);
if (sent) setDraft('');
setSendingText('');
}
function toggleBrowser(): void {
const next = !browserOpen; setBrowserOpen(next);
if (task?.live_session && !embedded) void api.post(`/tasks/${task.id}/browser/${next ? 'show' : 'hide'}`, {}).catch(error => setNotice(errorText(error)));
}
async function finishTakeover(): Promise<void> {
if (!task || busyRef.current) return;
busyRef.current = true; setBusy(true); setNotice('');
try {
await api.post(`/tasks/${task.id}/takeover-done`, {});
if (task.status === 'paused') {
const next = await api.post<Task>(`/tasks/${task.id}/human`, { answer: '已完成手动处理,请继续原任务。' });
if (selected.current === task.id) { setTask(next); void evidence(task.id); }
} else await refresh(task.id);
await connect();
} catch (error) { setNotice(errorText(error)); }
finally { busyRef.current = false; setBusy(false); }
}
function openKnowledgeEvidence(id: string): void {
if (selected.current !== id) openTask(id);
else closeDrawers();
setTab('progress');
}
async function feedback(rating: 'up' | 'down', reason?: string): Promise<void> {
if (!task) return;
if (!(await action(`/tasks/${task.id}/feedback`, { rating, ...(reason ? { reason } : {}) }))) throw new Error('评价未保存');
}
async function authorizeOutreach(request: OutreachRequest): Promise<boolean> {
if (!task) return false;
return action(`/tasks/${task.id}/outreach/authorize`, request);
}
async function recheckOutreach(): Promise<boolean> {
if (!task) return false;
return action(`/tasks/${task.id}/outreach/recheck`);
}
async function acknowledgeOutreach(accountProfileUrl: string): Promise<boolean> {
if (!task) return false;
return action(`/tasks/${task.id}/outreach/acknowledge`, { account_profile_url: accountProfileUrl, confirmation: true });
}
async function correct(sourceId: string, text: string): Promise<void> {
if (!task) return;
if (!(await action(`/tasks/${task.id}/leads/${encodeURIComponent(sourceId)}/correction`, { text }))) throw new Error('纠正未保存');
}
async function recordBusinessOutcome(sourceId: string, outcome: BusinessOutcomeValue, note?: string): Promise<void> {
if (!task || busyRef.current) throw new Error('当前操作尚未完成,请稍后再记录。');
const id = task.id;
busyRef.current = true; setBusy(true); setNotice('');
try {
const result = await api.post<Task>(`/tasks/${id}/business-outcome`, { source_id: sourceId, outcome, ...(note ? { note } : {}) });
if (result.id !== id) throw new Error('返回的任务信息不匹配,请重新读取当前任务确认记录。');
if (selected.current === id) { setTask(result); void evidence(id); }
await connect();
} catch (error) { setNotice(errorText(error)); throw error; }
finally { busyRef.current = false; setBusy(false); }
}
async function exportLeads(): Promise<void> {
if (!task || busyRef.current) return;
busyRef.current = true; setBusy(true); setNotice('');
try {
const result = await api.post<ExportFile>(`/tasks/${task.id}/export`, {});
await refresh(task.id);
const openError = await api.openPath(result.path);
setNotice(openError ? `名单已保存,打开文件失败:${openError}` : `已导出 ${result.count} 条名单:${result.filename}`);
} catch (error) { setNotice(errorText(error)); }
finally { busyRef.current = false; setBusy(false); }
}
async function openExport(file: ExportFile): Promise<void> {
try {
if (file.available === false || !file.path) { setNotice('文件当前不可用,可以重新导出名单。'); return; }
const error = await api.openPath(file.path);
if (error) setNotice(`打开文件失败:${error}`);
} catch (error) { setNotice(errorText(error)); }
}
async function chooseRoute(route: RecoveryRoute, force = false): Promise<void> {
if (!task || !route.id || !route.action || !task.ui) return;
const success = await action(`/tasks/${task.id}/alternatives/${route.id}`, { goal_version: task.ui.goal_version, result_version: task.ui.result_version, ...(force && route.action === 'review_saved' ? { force: true } : {}) });
if (!success) return;
if (route.action === 'export') setNotice('名单文件已生成,可以在本次交付中打开。');
}
async function submitClarification(answers: Record<string, string>): Promise<boolean> {
if (!task?.ui?.clarification) return false;
const card = task.ui.clarification;
followScroll.current = true;
setClarifying(true);
try { return await action(`/tasks/${task.id}/clarification`, { card_id: card.id, goal_version: card.goal_version, answers }); }
finally { setClarifying(false); }
}
const leads = Array.isArray(task?.artifacts.leads) ? task.artifacts.leads : [];
const messages = task ? messagesOf(task) : [];
const timeline = doc.split('\n').filter(line => line.startsWith('### ')).map(line => line.replace(/^###\s*/, ''));
const stage = task?.current_step ? STAGE_LABEL[task.current_step] || task.current_step : '等待开始';
const stopReason = task ? stopReasonText(task) : '';
const resultAvailable = Boolean(task && (task.artifacts.summary || task.artifacts.coverage_note || leads.length || ['completed', 'failed', 'blocked', 'cancelled'].includes(task.status)));
const currentWebSearch = task ? task.inputs.use_web_search === true : webSearch;
const canResume = Boolean(task && !['running', 'collecting'].includes(task.status));
const alternatives = task?.ui?.recovery?.routes || (task?.artifacts.alternatives || []).filter((item): item is RecoveryRoute => typeof item !== 'string');
const exportFiles = task?.ui?.delivery.exports || task?.artifacts.exports || [];
const deliveryStatus = task?.ui?.delivery_status;
const isAnalyzing = task?.status === 'running' && task.current_step === 'analyze' && !task.takeover;
const loadingTask = Boolean(selected.current && !task);
const composerRunning = task?.status === 'running';
const composerSubmitting = Boolean(busy && (sendingText || clarifying));
const composerProcessing = composerRunning || composerSubmitting;
const composerSendLabel = composerSubmitting ? '正在提交消息' : composerRunning ? draft.trim() ? '发送补充说明,任务执行中' : '任务执行中' : '发送消息';
const overlayOpen = historyOpen || detailsOpen || settingsOpen;
const browserVisible = browserOpen && !overlayOpen;
const particleState = !task ? 'idle' : task.status === 'paused' || task.status === 'collecting' ? 'paused' : task.status === 'completed' ? 'completed' : task.status === 'failed' || task.status === 'blocked' ? 'error' : 'idle';
const currentState = busy && sendingText ? '正在理解你的目标' : !task ? '我在,随时开始' : task.status === 'running' ? stage : task.status === 'paused' || task.status === 'collecting' ? '等你补充,再一起继续' : deliveryStatus?.fulfillment.complete ? deliveryStatus.headline : task.status === 'completed' ? '结果已整理好' : task.status === 'cancelled' ? '已停下,进度都在' : '已有进展,随时接着做';
const stateCopy = busy && sendingText ? '把已有条件理清,需要补充时再问你。' : !task ? '交代一个目标,或向上看看之前的对话。' : task.status === 'running' ? '沿实际页面推进,留下原话、来源和可复用的方法。' : stopReason || task.artifacts.coverage_note || '名单、字段依据和执行记录都保留在这段对话里。';
return <><WindowTitleBar /><div className={`chat-app prospecting-app conversation-app blue-preview${browserVisible ? ' browser-open' : ''}${reducedMotion ? ' reduce-motion' : ''}`}>
<main className="chat-main prospect-main" inert={historyOpen || detailsOpen}>
<header className="prospect-topbar">
<div className="assistant-brand"><SparkMark /><div><h1>橘子拓客</h1><p>全天候为你服务</p></div></div>
<div className="topbar-actions">
<button type="button" className="history-trigger" data-testid="history-toggle" aria-expanded={historyOpen} onClick={() => { setHistoryOpen(true); setDetailsOpen(false); }}><span>对话记录</span></button>
{task && <button type="button" className="browser-trigger icon-button" title={browserVisible ? '收起页面' : task.live_session ? '查看页面' : '页面回放'} aria-label={browserVisible ? '收起页面' : task.live_session ? '查看页面' : '页面回放'} onClick={toggleBrowser}><Globe size={16} /></button>}
<button type="button" className="icon-button" aria-label="查看任务与设置" data-testid="details-toggle" aria-expanded={detailsOpen} onClick={() => { setDetailsOpen(true); setHistoryOpen(false); }}><PanelRight size={17} /></button>
</div>
</header>
<div className="conversation-divider"><span><i />一直在这里</span><span>想法、线索与结果,在同一段对话里接续</span></div>
<div className="prospect-content conversation-content" ref={content} aria-label="持续对话,向上滚动查看历史" onScroll={() => { const el = content.current; if (el) { const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 100; followScroll.current = nearBottom; setShowLatest(!nearBottom); } }}>
{notice && <div className="notice-banner" role="alert"><span>{notice}</span><button type="button" aria-label="关闭提示" onClick={() => setNotice('')}>×</button></div>}
{connected && !meta?.llm?.api_key_set && <div className="key-banner" data-testid="missing-key"><span><strong>需要配置 DeepSeek Key</strong><small>你的目标可以先保存。填写 Key 后即可继续理解目标和执行。</small></span><button type="button" className="secondary-button small" onClick={() => setSettingsOpen(true)}>去配置<ArrowRight size={14} /></button></div>}
{loadingTask ? <p className="conversation-thinking" role="status"><LoaderCircle size={16} className="spin" />正在读取对话…</p> : <div className="conversation-thread" aria-live="polite" aria-relevant="additions text">
<section className="intro-message"><span className="message-avatar"><SparkMark small /></span><div><div className="intro-message-meta"><strong>小橘</strong><span>小红书 · 拓客助手</span>{task && <time>{displayTime(task.created_at)}</time>}</div><p>我是小橘,你的拓客助手,全天候为你服务。发现需求、核对账号、整理名单,都可以交给我。你可以先说一件要做的事,我们会在这段对话里接着推进。</p></div></section>
{task && <>
{messages.map((message: ChatMessage) => <div className={`conversation-message ${message.role}`} key={message.id}><span className="message-speaker">{message.role === 'user' ? '你' : message.role === 'system' ? '任务记录' : '小橘'}</span><p>{message.text}</p></div>)}
{sendingText && !messages.some(message => message.role === 'user' && message.text === sendingText) && <div className="conversation-message user sending"><span className="message-speaker">你</span><p>{sendingText}</p></div>}
<ClarificationHistory task={task} />
<PlanCard task={task} onEdit={() => { composer.current?.focus(); }} />
{(!task.ui?.clarification || resultAvailable) && <section className="task-scene" data-testid="task-scene">
<PhaseCard task={task} phase={task.ui?.phase} reducedMotion={reducedMotion} showActivity={task.status === 'running' && !task.takeover && !isAnalyzing && !sendingText}>
<section className="conversation-status" data-testid="task-state"><div><span className={`status-dot ${task.status}`} /><strong>{statusLabel(task)}</strong>{stage !== statusLabel(task) && <span>{stage}</span>}{task.status === 'running' && <LoaderCircle size={14} className="spin" />}</div><div className="conversation-task-actions">{task.live_session && <button type="button" className="text-action" disabled={busy} onClick={() => { setInputHint(false); setBrowserOpen(true); if (task.takeover) void finishTakeover(); else void action(`/tasks/${task.id}/takeover`); }}><Hand size={13} />{task.takeover ? '接管完成' : '我来接管'}</button>}{ACTIVE.has(task.status) && <button type="button" className="text-action stop-action" disabled={busy} onClick={() => { void action(`/tasks/${task.id}/cancel`); }}><Square size={12} />停止</button>}</div></section>
</PhaseCard>
{inputHint && !task.takeover && <div className="inline-notice">页面正在由助手操作,需要手动操作时请点击「我来接管」。</div>}
{(task.pause_reason || stopReason) && task.status !== 'running' && !task.ui?.clarification && <section className="conversation-attention"><p data-testid="stop-reason">{stopReason || task.pause_reason}</p>{task.pause_reason && stopReason && task.pause_reason !== stopReason && <p>{task.pause_reason}</p>}{task.status === 'collecting' && <small>在下方对话框补充即可。</small>}{task.current_step === 'model_setup' && <button type="button" className="text-action" onClick={() => setSettingsOpen(true)}>打开模型设置<ArrowRight size={13} /></button>}</section>}
{task.ui?.recovery?.underlying_detail && <p className="scene-footnote">此前页面状态:{task.ui.recovery.underlying_detail}</p>}
<OutreachPlan key={`${task.id}-${asText(task.inputs.intent)}`} task={task} busy={busy} onAuthorize={authorizeOutreach} onRecheck={recheckOutreach} onAcknowledge={acknowledgeOutreach} />
<MotionAssembly active={isAnalyzing} reducedMotion={reducedMotion} revealKey={`${task.id}-${task.ui?.result_version || task.ui?.goal_version || 1}`}>
{resultAvailable && <section className="conversation-result" data-testid="task-result"><div className="result-heading"><h2>{deliveryStatus?.preflight ? '本次预检' : deliveryStatus ? '本次交付' : task.status === 'running' ? '已保存的结果' : '当前结果'}</h2>{!deliveryStatus?.preflight && <button type="button" className="text-action" disabled={busy || task.status === 'running' || !leads.length} onClick={() => { void exportLeads(); }}><Download size={14} />导出名单</button>}</div><DeliveryStatusCard delivery={deliveryStatus} />{task.artifacts.summary && (deliveryStatus ? <details className="raw-progress delivery-original-summary"><summary>查看原始阶段说明</summary><p>{task.artifacts.summary}</p></details> : <p className="task-summary">{task.artifacts.summary}</p>)}
{task.artifacts.coverage_note && <section className="coverage-note"><strong>已覆盖与未完成范围</strong><p>{task.artifacts.coverage_note}</p></section>}
<nav className="result-tabs" aria-label="任务详情"><button type="button" className={tab === 'leads' ? 'active' : ''} onClick={() => setTab('leads')}>{deliveryStatus?.preflight ? '预检结果' : task.ui?.goal_progress?.assessment_current === false ? '旧标准结果' : '候选名单'}<span>{deliveryStatus?.preflight ? deliveryStatus.preflight.recorded ? deliveryStatus.preflight.inspected_notes : '待确认' : deliveryStatus?.counts.eligible ?? task.ui?.goal_progress?.current ?? '待整理'}</span></button><button type="button" className={tab === 'progress' ? 'active' : ''} onClick={() => setTab('progress')}>执行记录<span>{timeline.length}</span></button><button type="button" className={tab === 'learning' ? 'active' : ''} onClick={() => setTab('learning')}>知识与经验<span>{knowledge?.methods.length ?? '—'}</span></button></nav>
{tab === 'leads' && <>{!deliveryStatus?.preflight && <p className="result-note">名单可直接查看或导出。需求、身份、地区、账号和跟进分别说明;候选不等于已确认客户。</p>}<LeadResults key={task.id} task={task} leads={leads} busy={busy} onCorrect={correct} onBusinessOutcome={recordBusinessOutcome} /></>}
{tab === 'progress' && <section className="evidence-section"><div className="evidence-heading"><h3>实际执行记录</h3>{task.progress_doc_path && <button type="button" className="text-action" onClick={() => { void api.openPath(task.progress_doc_path || ''); }}><FileText size={14} />打开文件</button>}</div>{timeline.length ? <ol className="prospect-timeline">{timeline.map((line, index) => <li key={`${index}-${line}`}><span>{index + 1}</span><p>{line}</p></li>)}</ol> : <p className="muted">尚无页面执行记录。</p>}<details className="raw-progress"><summary>查看完整记录</summary><pre>{doc || '记录尚未生成'}</pre></details>{task.artifacts.browser_summary && <details className="raw-progress"><summary>查看页面执行说明</summary><p>{task.artifacts.browser_summary}</p></details>}</section>}
{tab === 'learning' && <KnowledgePanel key={`result-${task.id}`} data={knowledge} error={knowledgeError} loading={knowledgeLoading} taskSelected taskId={task.id} onOpenEvidence={openKnowledgeEvidence} onRetry={() => { void loadKnowledge(task.id); }} />}
<DeliveryFilesCard files={exportFiles} busy={busy} onOpen={openExport} />
<TaskFeedback key={task.id} task={task} busy={busy || task.status === 'running'} onFeedback={feedback} />
</section>}
</MotionAssembly>
<RecoveryCard routes={alternatives} busy={busy} active={task.status === 'running'} onChoose={chooseRoute} />
{task.artifacts.analysis_cached === true && <p className="scene-footnote analysis-cache-note">本次复核复用已有判断,未新增分析调用。</p>}
{canResume && <button type="button" className="secondary-button small continue-task" disabled={busy} onClick={() => { void action(`/tasks/${task.id}/resume`); }}>{task.artifacts.analysis_pending ? '继续分析已保存来源' : task.status === 'paused' ? '已处理,继续执行' : '继续这个任务'}<ArrowRight size={13} /></button>}
</section>}
<UsageCard usage={task.ui?.usage} />
{task.ui?.clarification && <ClarificationCard key={task.ui.clarification.id} card={task.ui.clarification} busy={busy} onSubmit={submitClarification} onUseConversation={() => composer.current?.focus()} />}
</>}
{!task && sendingText && <div className="conversation-message user sending"><span className="message-speaker">你</span><p>{sendingText}</p></div>}
{busy && (sendingText || clarifying) && <div className="conversation-active-reply" role="status"><ConversationParticles active reducedMotion={reducedMotion} /><span>正在理解你的消息…</span></div>}
{!task && !sendingText && <section className="companion-stage" aria-label="助手当前状态"><ParticleCompanion state="idle" reducedMotion={reducedMotion} /><div className="companion-current"><span>小橘 · 当前状态</span><strong role="status">{currentState}</strong><p>{stateCopy}</p></div></section>}
{task && task.status !== 'running' && !sendingText && !clarifying && !task.ui?.clarification && <section className="conversation-current-quiet" aria-label="助手当前状态"><ParticleCompanion state={particleState} reducedMotion={reducedMotion} active={task.status === 'completed' && observedActiveTasks.current.has(task.id)} /><div><strong>{currentState}</strong><p>这段对话和已有成果都保留着,随时接着做。</p></div></section>}
</div>}
</div>
{showLatest && <button type="button" className="return-current" onClick={() => { followScroll.current = true; setShowLatest(false); content.current?.scrollTo({ top: content.current.scrollHeight, behavior: reducedMotion ? 'instant' : 'smooth' }); }}>回到当前状态 · {currentState}<ArrowDown size={12} /></button>}
<form className="conversation-composer-area" onSubmit={event => { event.preventDefault(); void sendMessage(); }}>
<nav className="capability-chips" aria-label="拓客任务入口"><button type="button" disabled={busy || loadingTask} onClick={() => { setDraft(SUGGESTIONS[0]); composer.current?.focus(); }}>找客户<ArrowRight size={11} /></button><button type="button" disabled={busy || loadingTask} onClick={() => { setDraft(SUGGESTIONS[1]); composer.current?.focus(); }}>分析需求<ArrowRight size={11} /></button><button type="button" disabled={busy || loadingTask} onClick={() => { setDraft(task ? '把当前已有线索核对账号后整理成名单给我。' : SUGGESTIONS[2]); composer.current?.focus(); }}>整理名单<Sparkles size={11} /></button></nav>
<div className="conversation-composer">
<textarea ref={composer} value={draft} rows={1} maxLength={2000} disabled={busy || loadingTask} aria-label="输入你的目标或补充说明" data-testid="composer-input" placeholder={task?.status === 'collecting' ? '补充必要信息,或修改刚才的目标…' : task?.status === 'paused' ? '处理好页面后告诉我,或补充你的要求…' : '告诉小橘接下来要做什么,或者继续聊聊…'} onChange={event => setDraft(event.target.value)} onCompositionStart={() => { composing.current = true; }} onCompositionEnd={() => { composing.current = false; }} onKeyDown={event => { if (event.key === 'Enter' && !event.shiftKey && !composing.current && !event.nativeEvent.isComposing) { event.preventDefault(); void sendMessage(); } }} />
<div className="composer-inner-toolbar">{!task ? <label className="conversation-search-option"><input type="checkbox" checked={webSearch} onChange={event => setWebSearch(event.target.checked)} /><Search size={12} />公开资料补充</label> : <span className="composer-scope">{currentWebSearch ? '已开启公开资料补充' : '按实际页面保存证据'}</span>}<span className="composer-destination">{connected ? '本地桌面已连接' : '正在连接本地服务'}<i className={connected ? 'online' : ''} /></span><button type="submit" className={`conversation-send${composerProcessing ? ' is-processing' : ''}`} data-testid="composer-send" data-state={composerSubmitting ? 'submitting' : composerRunning ? 'running' : 'idle'} aria-label={composerSendLabel} aria-busy={composerProcessing} title={composerSubmitting ? '正在提交消息…' : composerRunning ? '任务执行中,可输入并发送补充说明;需要停止时使用任务中的停止按钮。' : '发送消息'} disabled={busy || loadingTask || !connected || !draft.trim()}>{composerProcessing ? <LoaderCircle size={17} className="spin composer-progress" aria-hidden="true" /> : <ArrowUp size={17} aria-hidden="true" />}</button></div>
</div>
<div className="conversation-composer-toolbar"><span>原话可追溯 · 字段有依据 · 方法逐次验证</span><span>Enter 发送 · Shift + Enter 换行</span></div>
</form>
</main>
{browserVisible && <BrowserPanel task={task} state={browserState} embedded={embedded} onClose={toggleBrowser} onTakeover={() => { if (task) void action(`/tasks/${task.id}/takeover`); }} onTakeoverDone={() => { void finishTakeover(); }} />}
{(historyOpen || detailsOpen) && <div className="drawer-backdrop" onClick={closeDrawers} />}
{historyOpen && <aside className="sidebar prospect-sidebar history-drawer" role="dialog" aria-modal="true" aria-label="对话记录" data-testid="history-drawer" onKeyDown={drawerKeyDown}>
<header className="drawer-heading"><div><span>对话一直在这里</span><h2>对话记录</h2></div><button type="button" className="icon-button" aria-label="关闭对话记录" onClick={closeDrawers}><X size={17} /></button></header>
<button type="button" className="new-task" disabled={busy} onClick={newTask}><Plus size={15} />新建对话<ArrowRight size={14} /></button>
<div className="history-heading"><span>最近对话</span><span>{tasks.length}</span></div>
<ul className="task-list">{tasks.map(item => <li key={item.id}><button type="button" disabled={busy} className={`task-item${task?.id === item.id ? ' active' : ''}`} onClick={() => openTask(item.id)}><span className={`status-dot ${item.status}`} /><span className="task-item-body"><span className="task-item-title">{item.goal || item.business_name || '小红书任务'}</span><span className="task-item-meta">{statusLabel(item)} · {displayTime(item.created_at)}</span></span></button></li>)}{!tasks.length && <li className="list-empty">从一段话开始。结果和方法都会留在这里。</li>}</ul>
<div className="drawer-foot"><span><i className={`connection-dot${connected ? ' online' : ''}`} />{connected ? '本地服务已连接' : '正在连接本地服务…'}</span><p>已有来源、目标和进度会一起保留。</p></div>
</aside>}
{detailsOpen && <aside className="details-drawer" role="dialog" aria-modal="true" aria-label="任务与设置" data-testid="details-drawer" onKeyDown={drawerKeyDown}>
<header className="drawer-heading"><div><span>当前工作区</span><h2>任务与设置</h2></div><button type="button" className="icon-button" aria-label="关闭任务与设置" onClick={closeDrawers}><X size={17} /></button></header>
<section className="drawer-section"><h3>当前任务</h3><strong>{task ? statusLabel(task) : '随时开始'}</strong><p>{task?.inputs.goal || '交代一个目标,助手会按实际进展接着推进。'}</p>{task && <button type="button" className="secondary-button small" onClick={() => { closeDrawers(); setBrowserOpen(true); }}><Globe size={13} />{task.live_session ? '查看执行页面' : '查看页面回放'}</button>}</section>
<section className="drawer-section"><h3>本地桌面</h3><div className="device-summary"><span className={`connection-dot${connected ? ' online' : ''}`} /><strong>{connected ? '已连接' : '连接中'}</strong><span>小红书工作区</span></div><p>当前模型:{meta?.llm?.model || '等待连接'}</p><button type="button" className="text-action settings-entry" onClick={() => { closeDrawers(); setSettingsOpen(true); }}><Settings size={14} />模型设置<ArrowRight size={13} /></button></section>
<section className="drawer-section knowledge-drawer-section"><h3>知识与经验</h3><KnowledgePanel key={`drawer-${task?.id || 'workspace'}`} data={knowledge} error={knowledgeError} loading={knowledgeLoading} compact taskSelected={Boolean(task)} taskId={task?.id} onOpenEvidence={openKnowledgeEvidence} onRetry={() => { void loadKnowledge(selected.current); }} /></section>
<section className="drawer-section"><h3>显示偏好</h3><button type="button" className="motion-preference" aria-label="减少动态效果" aria-pressed={reducedMotion} onClick={() => setReducedMotion(value => !value)}><Zap size={14} /><span>减少动态效果<small>粒子与结果动画保留静态呈现</small></span><i /></button></section>
<div className="drawer-spacer" /><button type="button" className="new-task" disabled={busy} onClick={newTask}><Plus size={15} />新建对话<ArrowRight size={14} /></button>
</aside>}
<SettingsDialog open={settingsOpen} onClose={() => setSettingsOpen(false)} onSaved={message => { setNotice(message); void connect(); }} />
</div></>;
}