mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-27 17:03:49 +08:00
fix: display task intelligence content directly
This commit is contained in:
1 parent
d70dd15e11
commit
bd787f8883
6 files changed
+69
-90
No files matched your search
@@ -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
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
Reference in new issue
Block a user