425 lines
42 KiB
TypeScript
425 lines
42 KiB
TypeScript
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></>;
|
||
}
|