assistant/renderer/components/KnowledgePanel.tsx

151 lines
19 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 { useRef, useState } from 'react';
import { ArrowRight, BookOpen, ChevronDown, LoaderCircle, Pencil } from 'lucide-react';
import { api } from '../api';
import type { KnowledgeMethod, KnowledgeMethods, KnowledgeMethodStatus, ValidationProof } from '../types';
import { displayTime } from '../taskView';
type DisplayStatus = KnowledgeMethodStatus | 'inapplicable';
const STATUS: Record<DisplayStatus, string> = { preferred: '优先复用', trial: '可试用', exploration: '探索候选', paused: '当前场景暂停', inapplicable: '本阶段不适用', legacy: '历史记录' };
function displayStatus(method: KnowledgeMethod): DisplayStatus {
if (method.status === 'paused' && !method.applicability && !method.match_reasons.includes('unresolved_contradiction')) return 'inapplicable';
return STATUS[method.status] ? method.status : 'legacy';
}
const ORIGIN: Record<string, string> = { program_template: '预置程序模板', preset: '预置阶段模板', observed: '真实执行归纳', model: '模型建议', manual: '人工补充', legacy: '历史记录', historical: '历史记录' };
const VERIFIER: Record<string, string> = { program: '程序验收', system: '系统验收', program_assertion: '程序验收', program_evidence: '程序证据核验', model: '模型报告', human: '人工确认', manual: '人工确认', source_container_quote_and_persisted: '来源归属、原话与保存结果核对', reply_delta_and_parent: '新增回复与所属讨论核对', profile_source_header: '来源作者与主页账号核对', file_manifest_and_current_result: '文件存在及当前版本核对', same_note_saved_sources: '同篇正文与讨论的保存结果核对' };
const OUTCOME: Record<string, string> = { success: '有证据的成功', contradiction: '相关反证', blocked: '受阻', program_error: '程序异常', unknown: '结果未知', unused: '未使用', not_used: '未使用' };
const MATCH_REASON: Record<string, string> = {
scope_matches: '已通过当前已提供条件的匹配检查', platform_mismatch: '平台不匹配', stage_mismatch: '任务阶段不匹配',
capability_mismatch: '所需操作能力不匹配', source_kind_mismatch: '来源类型不匹配', page_variant_mismatch: '页面结构版本不匹配',
method_version_mismatch: '方法版本不匹配', dependency_versions_unconfirmed: '依赖能力的版本尚未确认', dependency_version_mismatch: '依赖版本不匹配',
action_not_authorized: '本次尚未授权所需动作', unresolved_contradiction: '存在未解决的相关反证', historical_validation_incomplete: '历史验证依据不完整,不参与本次优先推荐',
};
const SCOPE: Record<string, string> = { any: '不限', unknown: '未记录', xiaohongshu: '小红书', note: '笔记', comment: '评论', reply: '回复', source_capture: '来源取证', search_read: '搜索阅读', export: '名单导出', save_source: '保存来源', inspect_profile: '核对主页', expand_replies: '展开回复', follow: '关注', message: '私信' };
const FIELD: Record<string, string> = {
type: '验收类型', checks: '验收条件', description: '说明', text: '说明', title: '说明', required: '必要条件', action: '操作', outcome: '结果', capability: '能力', source_kind: '来源类型', version: '版本',
source_id: '来源标识', source_type: '来源类型', content: '来源原文', quote: '来源原话', observed_at: '观察时间', persisted: '已持久保存', verified: '核验状态',
count: '数量', reply_count: '回复数量', parent_source_id: '父来源标识', root_source_id: '根来源标识', profile_url: '主页入口', xiaohongshu_id: '页面可见小红书号',
result_version: '结果版本', goal_version: '目标版本', filename: '文件名', available: '文件可用', exists: '文件存在', path: '文件路径', matched: '归属匹配',
note_id: '笔记标识', container_selector: '来源容器定位', observed_excerpt: '页面实际摘录', quote_matched: '原话与来源相符', relations_checked: '讨论归属已核对', owner_selector: '所属讨论定位', fingerprint: '页面状态指纹', parent_scope_checked: '所属父讨论一致', owner_reply_delta: '本讨论新增回复数', new_visible_replies: '新出现的可见回复数', new_reply_fingerprints: '新增回复指纹', expected_profile_digest: '来源主页关联标识', matched_source_author: '当前主页与来源作者匹配', visible_account_label_checked: '可见账号标签已核对', persisted_profile: '主页结果已保存', export_id: '文件记录标识', current_version: '对应当前结果', row_count: '文件记录数', format: '文件格式',
};
function count(value: number | null | undefined): string { return typeof value === 'number' && Number.isFinite(value) ? String(value) : '未记录'; }
function lines(value: unknown, depth = 0): string[] {
if (depth > 4 || value === null || value === undefined) return [];
if (typeof value === 'string') return value ? [value] : [];
if (typeof value === 'number') return [String(value)];
if (typeof value === 'boolean') return [value ? '是' : '否'];
if (Array.isArray(value)) return value.flatMap(item => lines(item, depth + 1));
if (typeof value === 'object') return Object.entries(value).flatMap(([key, item]) => lines(item, depth + 1).map(line => `${FIELD[key] || key}:${line}`));
return [];
}
function ValidationProofView({ evidenceRef }: { evidenceRef: KnowledgeMethod['evidence_refs'][number] }) {
const reference = Array.isArray(evidenceRef.pre) ? evidenceRef.pre[0] : evidenceRef.pre;
const valid = Boolean(evidenceRef.task_id && typeof reference === 'string' && /^[\w.:/\-]{1,512}$/.test(reference) && !reference.includes('://') && !/^(?:https?|file|data|javascript):/i.test(reference));
const [proof, setProof] = useState<ValidationProof | null>(null);
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [failed, setFailed] = useState(false);
const requesting = useRef(false);
async function show(): Promise<void> {
if (!valid || requesting.current) return;
if (proof) { setOpen(value => !value); return; }
requesting.current = true; setLoading(true); setFailed(false); setOpen(true);
try {
const result = await api.get<ValidationProof>(`/tasks/${encodeURIComponent(evidenceRef.task_id!)}/validation-proof?reference=${encodeURIComponent(reference!)}${evidenceRef.event_id ? `&event_id=${encodeURIComponent(evidenceRef.event_id)}` : ''}`);
setProof(result);
} catch { setFailed(true); }
finally { requesting.current = false; setLoading(false); }
}
const before = proof ? lines(proof.pre) : [];
const after = proof ? lines(proof.post) : [];
return <div className="validation-proof" data-testid="validation-proof">
<button type="button" className="text-action" disabled={!valid || loading} aria-expanded={open} onClick={() => { void show(); }} title={valid ? undefined : '历史记录缺少可读取的任务内动作引用'}>{loading ? <><LoaderCircle size={12} className="spin" />正在读取验收记录…</> : proof && open ? '收起动作验收' : failed ? '重新读取动作验收' : '查看动作验收'}<ChevronDown size={12} /></button>
{!valid && <p className="scene-footnote">历史记录未保存可读取的动作引用。</p>}
{open && failed && <p className="validation-proof-error" role="status">这条验收快照暂时无法读取,已有成果仍可查看。可以重试或打开任务证据。</p>}
{open && proof && <section className="validation-proof-content" aria-label="已持久保存的动作验收快照">
<header><strong>{SCOPE[proof.capability] || proof.capability}</strong><span>{OUTCOME[proof.outcome] || proof.outcome}</span></header>
<p className="validation-proof-meta">{VERIFIER[proof.verifier] || proof.verifier || '验证方式未记录'}{proof.observed_at && <> · {displayTime(proof.observed_at)}</>}</p>
<div className="validation-proof-states"><section><h4>动作前状态</h4>{before.length ? <ul>{before.map((line, index) => <li key={index}>{line}</li>)}</ul> : <p>这条记录未保存动作前快照。</p>}</section><section><h4>动作后状态</h4>{after.length ? <ul>{after.map((line, index) => <li key={index}>{line}</li>)}</ul> : <p>这条记录未保存动作后快照。</p>}</section></div>
<p className="scene-footnote">这里读取原任务已保存的程序快照,不会重新执行动作。原话和账号资料仅在对应任务证据中展示。</p>
</section>}
</div>;
}
function MethodFeedbackForm({ methodId, taskId }: { methodId: string; taskId?: string }) {
const [open, setOpen] = useState(false);
const [text, setText] = useState('');
const [sending, setSending] = useState(false);
const [failed, setFailed] = useState(false);
const [saved, setSaved] = useState(false);
const composing = useRef(false);
const submitting = useRef(false);
async function submit(): Promise<void> {
if (!text.trim() || submitting.current || composing.current) return;
submitting.current = true; setSending(true); setFailed(false); setSaved(false);
try {
const result = await api.post<{ ok: boolean }>(`/knowledge/methods/${encodeURIComponent(methodId)}/feedback`, { text: text.trim(), ...(taskId ? { task_id: taskId } : {}) });
if (result.ok !== true) throw new Error('feedback not confirmed');
setSaved(true); setOpen(false); setText('');
} catch { setFailed(true); }
finally { submitting.current = false; setSending(false); }
}
return <div className="method-feedback" data-testid="method-feedback">
<button type="button" className="text-action" disabled={sending} aria-expanded={open} onClick={() => { setOpen(value => !value); setSaved(false); }}><Pencil size={12} />纠正或说明不适用</button>
{saved && <p className="method-feedback-saved" role="status">已记录,系统结合后续证据判断。</p>}
{open && <form className="method-feedback-form" onCompositionStart={() => { composing.current = true; }} onCompositionEnd={() => { composing.current = false; }} onKeyDown={event => { if (event.key === 'Enter' && (composing.current || event.nativeEvent.isComposing)) event.preventDefault(); }} onSubmit={event => { event.preventDefault(); void submit(); }}>
<label htmlFor={`method-feedback-${methodId}`}>补充方法的适用条件或问题<textarea id={`method-feedback-${methodId}`} rows={3} maxLength={1000} required disabled={sending} value={text} placeholder="例如:当前页面没有这个入口,或这个方法适用的条件需要补充" onChange={event => { setText(event.target.value); setFailed(false); }} /></label>
<p className="scene-footnote">这条用户反馈先保留为待判断信息,不自动记为方法反证或成功。提交不会改写业务目标,也不会重新执行浏览器。</p>
{failed && <p className="field-error" role="alert">尚未确认保存成功,填写内容已保留。请稍后重试。</p>}
<div className="method-feedback-actions"><button type="button" className="text-action" disabled={sending} onClick={() => setOpen(false)}>收起</button><button type="submit" className="secondary-button small" disabled={sending || !text.trim()}>{sending ? '正在保存…' : '保存方法反馈'}</button></div>
</form>}
</div>;
}
export function KnowledgePanel({ data, error, loading, compact = false, taskSelected, taskId, onOpenEvidence, onRetry }: {
data: KnowledgeMethods | null; error: string; loading: boolean; compact?: boolean; taskSelected: boolean; taskId?: string;
onOpenEvidence: (taskId: string) => void; onRetry: () => void;
}) {
const [filter, setFilter] = useState<'all' | DisplayStatus>('all');
const [showAll, setShowAll] = useState(false);
const methods = data?.methods || [];
const filtered = methods.filter(method => filter === 'all' || displayStatus(method) === filter);
const visible = compact && !showAll ? filtered.slice(0, 3) : filtered;
return <section className={`knowledge-panel${compact ? ' compact' : ''}`} data-testid="knowledge-panel">
<div className="knowledge-heading"><span className="scene-kind"><BookOpen size={13} />可复用的方法</span>{loading && <small role="status">正在读取…</small>}</div>
<p className="result-note">先匹配适用条件,再根据独立执行证据选择。结果满意度、名单质量与方法可靠性分别记录;无需逐条人工审核。</p>
{error && <div className="knowledge-error" role="status"><p>知识记录暂时无法读取,已有任务结果仍可查看。</p><button type="button" className="text-action" disabled={loading} onClick={onRetry}>重新读取<ArrowRight size={12} /></button></div>}
<div className="knowledge-filters" aria-label="知识状态筛选"><button type="button" aria-pressed={filter === 'all'} onClick={() => setFilter('all')}>全部<span>{methods.length}</span></button>{(Object.keys(STATUS) as DisplayStatus[]).map(status => <button key={status} type="button" aria-pressed={filter === status} onClick={() => setFilter(status)}>{STATUS[status]}<span>{methods.filter(method => displayStatus(method) === status).length}</span></button>)}</div>
{visible.length ? <div className="knowledge-methods">{visible.map(method => {
const last = method.last_validation;
const selected = Boolean(data?.selected_ids.includes(method.id));
const status = displayStatus(method);
return <article className={`knowledge-method ${status}`} key={method.id} data-testid="knowledge-method" data-method-status={status}>
<header><strong>{method.title || method.key}</strong><span className={`knowledge-status ${status}`}>{STATUS[status]}</span>{selected && <span className="knowledge-selected">本次召回</span>}</header>
<p className="knowledge-scope">{[method.platform, method.stage, method.capability, method.source_kind, method.page_variant].filter(Boolean).map(value => SCOPE[value!] || value).join(' · ')}<span>版本 {method.version}</span></p>
{method.match_reasons.length > 0 ? <ul className="knowledge-match-reasons">{method.match_reasons.map((reason, index) => <li key={`${index}-${reason}`}>{MATCH_REASON[reason] || reason}</li>)}</ul> : <p className="scene-footnote">{taskSelected ? '尚无当前任务的适用性依据。' : '选择任务后,按平台、阶段、来源和授权范围说明适用性。'}</p>}
{taskSelected && method.applicability === false && status !== 'legacy' && <p className="knowledge-scope-warning">{status === 'paused' ? '当前场景存在未解决的反证,已暂停复用。' : '本阶段不使用此方法;其他阶段的验证记录仍保留。'}</p>}
{(method.historical_unverified || status === 'legacy') && <p className="knowledge-scope-warning">历史记录不完整,仅保留审计;缺失统计不按零失败处理。</p>}
{status !== 'inapplicable' && <><dl className="knowledge-stats"><div><dt>独立成功</dt><dd>{count(method.stats?.success)}</dd></div><div><dt>相关反证</dt><dd>{count(method.stats?.contradiction)}</dd></div><div><dt>结果未知</dt><dd>{count(method.stats?.unknown)}</dd></div></dl>
<p className="knowledge-coverage">有效覆盖:{count(method.stats?.independent_tasks)} 个独立任务 · {count(method.stats?.independent_objects)} 个不同对象</p>
<p className="knowledge-last">最近验证:{last?.at ? displayTime(last.at) : '尚无可追溯验证'}{last?.outcome && <span> · {OUTCOME[last.outcome] || last.outcome}</span>}</p></>}
<details className="knowledge-details"><summary>方法、验证来源与证据<ChevronDown size={12} /></summary>
<p>方法来源:{ORIGIN[method.provenance] || method.provenance || '未记录'}{last?.verifier && <> · 最近验证方式:{VERIFIER[last.verifier] || last.verifier}</>}</p>
{last?.reason && <p>最近结果依据:{last.reason}</p>}
<p className="knowledge-reference">标识:{method.key || method.id}</p>
{lines(method.preconditions).length > 0 && <div><strong>前置条件</strong>{lines(method.preconditions).map((line, index) => <p key={index}>{line}</p>)}</div>}
{lines(method.parameters).length > 0 && <div><strong>本次需传入的参数</strong>{lines(method.parameters).map((line, index) => <p key={index}>{line}</p>)}</div>}
{lines(method.steps).length > 0 && <div><strong>操作步骤</strong><ol>{lines(method.steps).map((step, index) => <li key={`${index}-${step}`}>{step}</li>)}</ol></div>}
{lines(method.expected_result).length > 0 && <div><strong>成功标准</strong>{lines(method.expected_result).map((line, index) => <p key={index}>{line}</p>)}</div>}
{lines(method.recovery).length > 0 && <div><strong>失败恢复</strong>{lines(method.recovery).map((line, index) => <p key={index}>{line}</p>)}</div>}
{method.dependencies && method.dependencies.length > 0 && <p>依赖版本:{method.dependencies.map(dep => typeof dep === 'string' ? dep : dep.id || `${dep.key}(版本 ${dep.version})`).join('、')}</p>}
{status !== 'inapplicable' && <p className="knowledge-other-outcomes">其他记录:受阻 {count(method.stats?.blocked)} · 程序异常 {count(method.stats?.program_error)} · 未使用 {count(method.stats?.unused)}。这些结果不冒充成功或方法反证。</p>}
{method.evidence_refs.length > 0 ? <ul className="knowledge-evidence-refs">{method.evidence_refs.map((ref, index) => <li key={`${ref.event_id || ref.task_id || method.id}-${index}`}><strong>证据引用 {index + 1}</strong>{ref.event_id && <code>事件 {ref.event_id}</code>}{ref.action_id && <code>动作 {ref.action_id}</code>}{lines(ref.pre).map((line, at) => <code key={`pre-${at}`}>动作前 {line}</code>)}{lines(ref.post).map((line, at) => <code key={`post-${at}`}>动作后 {line}</code>)}<ValidationProofView key={`${ref.task_id}-${ref.event_id || ''}-${Array.isArray(ref.pre) ? ref.pre[0] : ref.pre}`} evidenceRef={ref} />{ref.task_id && <button type="button" className="text-action" onClick={() => onOpenEvidence(ref.task_id!)}>查看任务证据<ArrowRight size={12} /></button>}</li>)}</ul> : <p className="scene-footnote">{status === 'inapplicable' ? '本阶段没有此方法的验收记录;其他适用阶段的记录仍保留。' : '尚未保存可追溯的任务证据引用。'}</p>}
</details>
<footer><span>{status === 'legacy' ? '历史验证依据缺失,不参与自动排名' : status === 'inapplicable' ? '当前阶段不参与选择;不表示方法失效或历史成功归零' : status === 'exploration' ? '独立样本不足,继续受控试用' : status === 'paused' ? '保留历史依据,当前场景暂停使用' : '计数来自有效独立事件,重复重试不累加'}</span><MethodFeedbackForm methodId={method.id} taskId={taskId} /></footer>
</article>;
})}</div> : !loading && !error && <div className="empty-state compact"><BookOpen size={24} /><h3>{methods.length ? '这个分类暂无方法' : '尚无可复用的方法记录'}</h3><p>有执行依据的复盘会形成候选,后续真实任务逐次验证;缺少证据时保持为空。</p></div>}
{compact && filtered.length > 3 && <button type="button" className="text-action knowledge-show-all" onClick={() => setShowAll(value => !value)}>{showAll ? '收起方法' : `展开全部 ${filtered.length} 条方法`}<ChevronDown size={12} /></button>}
{data?.policy.version && <p className="scene-footnote">选择规则版本:{data.policy.version}。状态与样本用于复用选择,不表示未来成功或业务成交概率。</p>}
</section>;
}