fix: display task intelligence content directly

This commit is contained in:
ji ruo committed 2026-10-20 11:35:38 +08:00
1 parent d70dd15e11
commit bd787f8883
6 files changed
+69 -90

No files matched your search

+1 -1
View File
@@ -627,4 +627,4 @@ https://github.com/oritera/Cairn
Worker 可调用 `record_intelligence`,或在 `record_fact` 的单条/批量事实中附带 `intelligence` 数组一起提交。`insert_assets` 本次提交的服务 `auth` 自动登记为未验证凭据,避免重复上报。任务、执行节点由系统绑定,记录需要真实来源证据;获取凭据不等于登录成功。 Worker 可调用 `record_intelligence`,或在 `record_fact` 的单条/批量事实中附带 `intelligence` 数组一起提交。`insert_assets` 本次提交的服务 `auth` 自动登记为未验证凭据,避免重复上报。任务、执行节点由系统绑定,记录需要真实来源证据;获取凭据不等于登录成功。
页面支持分类、标题/上报者搜索、状态筛选、分页、状态更新及来源查看。凭据和敏感数据的正文与证据默认不随列表返回,点击「显示敏感内容」后加载。直接关联任务的记录标明继承且只读;在资产页选择一项资产后点击「关联情报」可查看同一份记录。情报随任务归档、恢复和删除;新版归档格式为 v4,仍支持读取 v1–v3。此功能不自动补提取历史会话。 页面支持分类、标题/上报者搜索、状态筛选、分页、状态更新及来源查看。凭据、敏感数据的正文和来源证据直接展示在列表及详情中,无需额外点击显示。直接关联任务的记录标明继承且只读;在资产页选择一项资产后点击「关联情报」可查看同一份记录。情报随任务归档、恢复和删除;新版归档格式为 v4,仍支持读取 v1–v3。此功能不自动补提取历史会话。
+2 -5
View File
@@ -37,7 +37,6 @@ type Intelligence struct {
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
Inherited bool `json:"inherited"` Inherited bool `json:"inherited"`
Redacted bool `json:"redacted"`
} }
type IntelligenceFilter struct { type IntelligenceFilter struct {
TaskID int64 TaskID int64
@@ -238,15 +237,14 @@ func (d *DB) ListIntelligence(ctx context.Context, f IntelligenceFilter) (*Intel
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Search metadata only: secrets are never searched or echoed through list endpoints. // Search titles and reporters; return the recorded content directly in each row.
where := scope + ` AND ($3='' OR kind=$3) AND ($4='' OR i.status=$4) AND ($5='' OR i.title ILIKE '%'||$5||'%' OR i.worker ILIKE '%'||$5||'%')` where := scope + ` AND ($3='' OR kind=$3) AND ($4='' OR i.status=$4) AND ($5='' OR i.title ILIKE '%'||$5||'%' OR i.worker ILIKE '%'||$5||'%')`
args = append(args, f.Kind, f.Status, f.Query) args = append(args, f.Kind, f.Status, f.Query)
if err = d.QueryRowContext(ctx, `SELECT count(*)`+where, args...).Scan(&page.Total); err != nil { if err = d.QueryRowContext(ctx, `SELECT count(*)`+where, args...).Scan(&page.Total); err != nil {
return nil, err return nil, err
} }
args = append(args, f.Limit, f.Offset) args = append(args, f.Limit, f.Offset)
// Do not load credential/data bodies into list responses at all. cols := `i.id,i.task_id,i.node_id,i.kind,i.title,i.content,i.evidence,i.confidence,i.status,i.asset_ids,i.worker,i.created_at,i.updated_at`
cols := `i.id,i.task_id,i.node_id,i.kind,i.title,CASE WHEN kind='lead' THEN content ELSE '' END,CASE WHEN kind='lead' THEN evidence ELSE '' END,confidence,i.status,asset_ids,i.worker,i.created_at,i.updated_at`
rows, err = d.QueryContext(ctx, `SELECT `+cols+where+` ORDER BY i.id DESC LIMIT $6 OFFSET $7`, args...) rows, err = d.QueryContext(ctx, `SELECT `+cols+where+` ORDER BY i.id DESC LIMIT $6 OFFSET $7`, args...)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -258,7 +256,6 @@ func (d *DB) ListIntelligence(ctx context.Context, f IntelligenceFilter) (*Intel
return nil, e return nil, e
} }
item.Inherited = f.TaskID > 0 && item.TaskID != f.TaskID item.Inherited = f.TaskID > 0 && item.TaskID != f.TaskID
item.Redacted = item.Kind != "lead"
page.Items = append(page.Items, item) page.Items = append(page.Items, item)
} }
return page, rows.Err() return page, rows.Err()
+25 -3
View File
@@ -29,7 +29,7 @@ func TestIntelligenceValidation(t *testing.T) {
t.Fatalf("normalization failed: %+v %v", got, err) t.Fatalf("normalization failed: %+v %v", got, err)
} }
} }
func TestIntelligenceProvenanceDedupAndRedaction(t *testing.T) { func TestIntelligenceProvenanceDedupAndDirectContent(t *testing.T) {
d, err := Open(testDSN(t)) d, err := Open(testDSN(t))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -68,8 +68,8 @@ func TestIntelligenceProvenanceDedupAndRedaction(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
raw, _ := json.Marshal(page) raw, _ := json.Marshal(page)
if page.Total != 1 || !page.Items[0].Redacted || strings.Contains(string(raw), "local-fixture-secret") || strings.Contains(string(raw), "/tmp/fixture") { if page.Total != 1 || !strings.Contains(string(raw), "local-fixture-secret") || !strings.Contains(string(raw), "/tmp/fixture") {
t.Fatal("list leaked secret or lost record") t.Fatal("list omitted recorded content or evidence")
} }
if err = d.UpdateIntelligenceStatus(t.Context(), second.ID, item.ID, "verified"); !errors.Is(err, ErrIntelligenceNotFound) { if err = d.UpdateIntelligenceStatus(t.Context(), second.ID, item.ID, "verified"); !errors.Is(err, ErrIntelligenceNotFound) {
t.Fatalf("cross-task mutation accepted: %v", err) t.Fatalf("cross-task mutation accepted: %v", err)
@@ -119,3 +119,25 @@ func TestIntelligenceArchiveAssetRemapPreservesManualTriage(t *testing.T) {
t.Fatal("remap lost dedup or manual triage") t.Fatal("remap lost dedup or manual triage")
} }
} }
func TestIntelligenceSensitiveDataListIncludesContent(t *testing.T) {
d, err := Open(testDSN(t))
if err != nil {
t.Fatal(err)
}
defer d.Close()
task, err := d.CreateTask("direct sensitive data display", "local fixture", nil, 0, 0)
if err != nil {
t.Fatal(err)
}
defer d.DeleteTask(task.ID)
in := IntelligenceInput{Kind: "sensitive_data", Title: "fixture dataset", Content: "[email protected]", Evidence: "/tmp/qa-users.json", Confidence: "observed"}
_, err = d.RecordIntelligence(t.Context(), task.ID, 0, "qa", in)
if err != nil {
t.Fatal(err)
}
page, err := d.ListIntelligence(t.Context(), IntelligenceFilter{TaskID: task.ID, Kind: "sensitive_data"})
if err != nil || page.Total != 1 || page.Items[0].Content != in.Content || page.Items[0].Evidence != in.Evidence {
t.Fatalf("missing sensitive data content: %+v %v", page, err)
}
}
+3 -3
View File
@@ -32,12 +32,12 @@ func TestIntelligenceHTTPSettingsAndScope(t *testing.T) {
} }
base := fmt.Sprintf("/api/tasks/%d/intelligence", taskID) base := fmt.Sprintf("/api/tasks/%d/intelligence", taskID)
r := request("GET", base, "") r := request("GET", base, "")
if r.Code != 200 || strings.Contains(r.Body.String(), in.Content) || strings.Contains(r.Body.String(), in.Evidence) { if r.Code != 200 || !strings.Contains(r.Body.String(), in.Content) || !strings.Contains(r.Body.String(), in.Evidence) {
t.Fatalf("list leaked secret: %d %s", r.Code, r.Body) t.Fatalf("list omitted content or evidence: %d %s", r.Code, r.Body)
} }
r = request("GET", fmt.Sprintf("%s/%d", base, item.ID), "") r = request("GET", fmt.Sprintf("%s/%d", base, item.ID), "")
if r.Code != 200 || !strings.Contains(r.Body.String(), in.Content) { if r.Code != 200 || !strings.Contains(r.Body.String(), in.Content) {
t.Fatal("explicit reveal failed", r.Code, r.Body) t.Fatal("detail content missing", r.Code, r.Body)
} }
if r = request("PATCH", fmt.Sprintf("%s/%d", base, item.ID), `{"status":"verified"}`); r.Code != 200 { if r = request("PATCH", fmt.Sprintf("%s/%d", base, item.ID), `{"status":"verified"}`); r.Code != 200 {
t.Fatal(r.Code, r.Body) t.Fatal(r.Code, r.Body)
+38 -77
View File
@@ -4,7 +4,7 @@ import * as React from "react";
import Link from "next/link"; import Link from "next/link";
import { CopyIcon, EyeIcon, EyeOffIcon, RefreshCwIcon } from "lucide-react"; import { CopyIcon, RefreshCwIcon } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
@@ -52,10 +52,7 @@ export function IntelligencePanel({ scope, scopeId }: { scope: "tasks" | "assets
const [error, setError] = React.useState(""); const [error, setError] = React.useState("");
const [refresh, setRefresh] = React.useState(0); const [refresh, setRefresh] = React.useState(0);
const [selected, setSelected] = React.useState<TaskIntelligence | null>(null); const [selected, setSelected] = React.useState<TaskIntelligence | null>(null);
const [detail, setDetail] = React.useState<TaskIntelligence | null>(null);
const [revealing, setRevealing] = React.useState(false);
const [saving, setSaving] = React.useState(false); const [saving, setSaving] = React.useState(false);
const detailRequest = React.useRef(0);
React.useEffect(() => { React.useEffect(() => {
const timer = setTimeout(() => { const timer = setTimeout(() => {
setQuery(search.trim()); setQuery(search.trim());
@@ -95,38 +92,14 @@ export function IntelligencePanel({ scope, scopeId }: { scope: "tasks" | "assets
clearInterval(timer); clearInterval(timer);
}; };
}, [scope, scopeId, kind, status, query, page, refresh]); }, [scope, scopeId, kind, status, query, page, refresh]);
const open = (item: TaskIntelligence) => { const open = (item: TaskIntelligence) => setSelected(item);
detailRequest.current++; const close = () => setSelected(null);
setSelected(item);
setDetail(item.redacted ? null : item);
setRevealing(false);
};
const close = () => {
detailRequest.current++;
setSelected(null);
setDetail(null);
setRevealing(false);
};
const reveal = async () => {
if (!selected) return;
const request = ++detailRequest.current;
setRevealing(true);
try {
const result = await api.intelligenceDetail(scope, scopeId, selected.id);
if (request === detailRequest.current) setDetail(result);
} catch (e) {
if (request === detailRequest.current) toast.error("读取失败:" + (e as Error).message);
} finally {
if (request === detailRequest.current) setRevealing(false);
}
};
const update = async (value: string) => { const update = async (value: string) => {
if (!selected) return; if (!selected) return;
setSaving(true); setSaving(true);
try { try {
await api.updateIntelligence(selected.task_id, selected.id, value); await api.updateIntelligence(selected.task_id, selected.id, value);
setSelected((item) => (item ? { ...item, status: value } : null)); setSelected((item) => (item ? { ...item, status: value } : null));
setDetail((item) => (item ? { ...item, status: value } : null));
setRefresh((n) => n + 1); setRefresh((n) => n + 1);
toast.success("情报状态已更新"); toast.success("情报状态已更新");
} catch (e) { } catch (e) {
@@ -155,7 +128,7 @@ export function IntelligencePanel({ scope, scopeId }: { scope: "tasks" | "assets
<CardTitle>{scope === "tasks" ? "任务情报" : "资产关联情报"}</CardTitle> <CardTitle>{scope === "tasks" ? "任务情报" : "资产关联情报"}</CardTitle>
<CardDescription> <CardDescription>
{scope === "tasks" ? "集中查看本次获取与直接关联任务继承的情报。" : "按来源任务查看此资产关联的情报。"}{" "} {scope === "tasks" ? "集中查看本次获取与直接关联任务继承的情报。" : "按来源任务查看此资产关联的情报。"}{" "}
凭据与敏感数据按需显示,发现凭据不代表验证成功。 直接展示凭据、敏感数据及来源证据,发现凭据不代表验证成功。
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="flex min-w-0 flex-col gap-4"> <CardContent className="flex min-w-0 flex-col gap-4">
@@ -228,6 +201,7 @@ export function IntelligencePanel({ scope, scopeId }: { scope: "tasks" | "assets
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>类别 / 标题</TableHead> <TableHead>类别 / 标题</TableHead>
<TableHead>内容 / 来源证据</TableHead>
<TableHead>状态</TableHead> <TableHead>状态</TableHead>
<TableHead>来源</TableHead> <TableHead>来源</TableHead>
<TableHead>资产</TableHead> <TableHead>资产</TableHead>
@@ -242,9 +216,17 @@ export function IntelligencePanel({ scope, scopeId }: { scope: "tasks" | "assets
<div className="flex flex-col items-start gap-1"> <div className="flex flex-col items-start gap-1">
<Badge variant="secondary">{KINDS.find((k) => k.value === item.kind)?.label}</Badge> <Badge variant="secondary">{KINDS.find((k) => k.value === item.kind)?.label}</Badge>
<span className="break-words">{item.title}</span> <span className="break-words">{item.title}</span>
{item.redacted ? ( </div>
<span className="text-xs text-muted-foreground">内容已隐藏 · 查看详情后显示</span> </TableCell>
) : null} <TableCell className="min-w-64 max-w-md align-top">
<div className="flex flex-col gap-2">
<pre className="whitespace-pre-wrap break-all font-mono text-xs">
{prettyContent(item.content)}
</pre>
<div className="flex flex-col gap-1 text-xs text-muted-foreground">
<span>来源证据</span>
<p className="whitespace-pre-wrap break-all">{item.evidence}</p>
</div>
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
@@ -321,7 +303,7 @@ export function IntelligencePanel({ scope, scopeId }: { scope: "tasks" | "assets
<DialogContent className="sm:max-w-2xl"> <DialogContent className="sm:max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle>{selected?.title ?? "情报详情"}</DialogTitle> <DialogTitle>{selected?.title ?? "情报详情"}</DialogTitle>
<DialogDescription>保留实际来源;凭据与敏感数据仅在点击显示后加载。</DialogDescription> <DialogDescription>完整展示记录内容与实际来源证据。</DialogDescription>
</DialogHeader> </DialogHeader>
{selected ? ( {selected ? (
<div className="flex min-w-0 flex-col gap-4"> <div className="flex min-w-0 flex-col gap-4">
@@ -353,52 +335,31 @@ export function IntelligencePanel({ scope, scopeId }: { scope: "tasks" | "assets
{STATUS[selected.kind][selected.status]} · 请到来源任务修改状态 {STATUS[selected.kind][selected.status]} · 请到来源任务修改状态
</p> </p>
)} )}
{selected.kind !== "lead" ? ( <div className="flex items-center justify-between gap-2">
<span className="text-sm">内容</span>
<Button <Button
variant="outline" size="sm"
disabled={revealing} variant="ghost"
onClick={() => { onClick={async () => {
if (detail) { try {
detailRequest.current++; await navigator.clipboard.writeText(selected.content);
setDetail(null); toast.success("已复制");
} else void reveal(); } catch {
toast.error("复制失败,请手动选择内容复制");
}
}} }}
> >
{detail ? <EyeOffIcon data-icon="inline-start" /> : <EyeIcon data-icon="inline-start" />} <CopyIcon data-icon="inline-start" />
{revealing ? "读取中…" : detail ? "隐藏敏感内容" : "显示敏感内容"} 复制
</Button> </Button>
) : null} </div>
{detail ? ( <pre className="max-h-72 overflow-auto whitespace-pre-wrap break-all rounded-lg bg-muted p-3 text-xs">
<> {prettyContent(selected.content)}
<div className="flex items-center justify-between gap-2"> </pre>
<span className="text-sm">内容</span> <p className="text-sm">来源证据</p>
<Button <pre className="max-h-48 overflow-auto whitespace-pre-wrap break-all rounded-lg bg-muted p-3 text-xs">
size="sm" {selected.evidence}
variant="ghost" </pre>
onClick={async () => {
try {
await navigator.clipboard.writeText(detail.content);
toast.success("已复制");
} catch {
toast.error("复制失败,请手动选择内容复制");
}
}}
>
<CopyIcon data-icon="inline-start" />
复制
</Button>
</div>
<pre className="max-h-72 overflow-auto whitespace-pre-wrap break-all rounded-lg bg-muted p-3 text-xs">
{prettyContent(detail.content)}
</pre>
<p className="text-sm">来源证据</p>
<pre className="max-h-48 overflow-auto whitespace-pre-wrap break-all rounded-lg bg-muted p-3 text-xs">
{detail.evidence}
</pre>
</>
) : (
<p className="rounded-lg bg-muted p-4 text-sm text-muted-foreground">敏感内容和来源证据已隐藏</p>
)}
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground"> <div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
<span>关联资产:{selected.asset_ids.map((id) => `#${id}`).join("、") || "未关联"}</span> <span>关联资产:{selected.asset_ids.map((id) => `#${id}`).join("、") || "未关联"}</span>
<span>发现于 {time(selected.created_at)}</span> <span>发现于 {time(selected.created_at)}</span>
-1
View File
@@ -1759,7 +1759,6 @@ export interface TaskIntelligence {
created_at: string; created_at: string;
updated_at: string; updated_at: string;
inherited: boolean; inherited: boolean;
redacted: boolean;
} }
export interface IntelligencePage { export interface IntelligencePage {
items: TaskIntelligence[]; items: TaskIntelligence[];