mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-27 17:03:49 +08:00
feat: search approval history by content and date
This commit is contained in:
1 parent
e33eedeb17
commit
2f2b78cc78
9 files changed
+255
-11
No files matched your search
+48
-2
@@ -4,6 +4,7 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -239,6 +240,7 @@ const approvalRowColumns = `ip.id, ip.rule_id, ip.conversation_id, ip.task_id, i
|
||||
const approvalRowJoins = `
|
||||
FROM intercept_pending ip
|
||||
LEFT JOIN conversations c ON c.id = ip.conversation_id
|
||||
LEFT JOIN tasks t ON t.id::text = ip.task_id
|
||||
LEFT JOIN intercept_rules ir ON ir.id = ip.rule_id`
|
||||
|
||||
const approvalRowSelect = `SELECT ` + approvalRowColumns + approvalRowJoins
|
||||
@@ -284,11 +286,14 @@ func (d *DB) ListAllIntercepts(limit int) ([]InterceptApprovalRow, error) {
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// InterceptApprovalFilter combines exact status and decision-source filters.
|
||||
// InterceptApprovalFilter combines status, decision-source and literal substring search.
|
||||
// Empty fields include all values.
|
||||
type InterceptApprovalFilter struct {
|
||||
Status string
|
||||
DecisionSource string
|
||||
Search string
|
||||
CreatedFrom string // inclusive UTC calendar date, YYYY-MM-DD
|
||||
CreatedTo string // inclusive UTC calendar date, YYYY-MM-DD
|
||||
}
|
||||
|
||||
// ListAllInterceptsPage returns one 1-based page and the total matching count.
|
||||
@@ -342,12 +347,53 @@ func (d *DB) listInterceptsPage(taskID string, page, size int, filter InterceptA
|
||||
add("ip.task_id", taskID)
|
||||
add("ip.status", filter.Status)
|
||||
add(approvalDecisionSource, filter.DecisionSource)
|
||||
if filter.CreatedFrom != "" {
|
||||
args = append(args, filter.CreatedFrom)
|
||||
conditions = append(conditions, "ip.created_at >= $"+fmt.Sprint(len(args))+"::date")
|
||||
}
|
||||
if filter.CreatedTo != "" {
|
||||
args = append(args, filter.CreatedTo)
|
||||
conditions = append(conditions, "ip.created_at < ($"+fmt.Sprint(len(args))+"::date + INTERVAL '1 day')")
|
||||
}
|
||||
search := strings.TrimSpace(filter.Search)
|
||||
if strings.HasPrefix(search, "#") {
|
||||
id, err := strconv.ParseInt(strings.TrimPrefix(search, "#"), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
return nil, 0, fmt.Errorf("审批编号必须为 # 后接正整数")
|
||||
}
|
||||
add("ip.id", strconv.FormatInt(id, 10))
|
||||
} else if search != "" {
|
||||
// Treat SQL LIKE metacharacters literally, including URL percent escapes.
|
||||
pattern := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(search)
|
||||
args = append(args, "%"+pattern+"%")
|
||||
like := "$" + fmt.Sprint(len(args))
|
||||
conditions = append(conditions, `(
|
||||
ip.id::text ILIKE `+like+`
|
||||
OR ip.conversation_id::text ILIKE `+like+`
|
||||
OR COALESCE(ip.task_id, '') ILIKE `+like+`
|
||||
OR COALESCE(ip.agent_name, '') ILIKE `+like+`
|
||||
OR COALESCE(ip.tool_name, '') ILIKE `+like+`
|
||||
OR COALESCE(ip.tool_input::text, '') ILIKE `+like+`
|
||||
OR COALESCE(ip.reason, '') ILIKE `+like+`
|
||||
OR COALESCE(ip.audit::text, '') ILIKE `+like+`
|
||||
OR COALESCE(c.title, '') ILIKE `+like+`
|
||||
OR COALESCE(c.agent_key, '') ILIKE `+like+`
|
||||
OR COALESCE(t.name, '') ILIKE `+like+`
|
||||
OR COALESCE(t.description, '') ILIKE `+like+`
|
||||
OR COALESCE(t.goal, '') ILIKE `+like+`
|
||||
OR COALESCE(ir.name, '') ILIKE `+like+`
|
||||
)`)
|
||||
}
|
||||
where := ""
|
||||
if len(conditions) > 0 {
|
||||
where = " WHERE " + strings.Join(conditions, " AND ")
|
||||
}
|
||||
var total int
|
||||
if err := d.QueryRow("SELECT COUNT(*) FROM intercept_pending ip"+where, args...).Scan(&total); err != nil {
|
||||
countFrom := "FROM intercept_pending ip"
|
||||
if search != "" && !strings.HasPrefix(search, "#") {
|
||||
countFrom = approvalRowJoins
|
||||
}
|
||||
if err := d.QueryRow("SELECT COUNT(*) "+countFrom+where, args...).Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"strconv"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -138,4 +140,28 @@ func TestInterceptApprovalFilters(t *testing.T) {
|
||||
t.Fatal("deleted rule lost its source")
|
||||
}
|
||||
}
|
||||
|
||||
modelInput := json.RawMessage(`{"script_files":[{"path":"scripts/check.py","content":"https://example.test/a?x=100%"}]}`)
|
||||
searchID, err := d.CreateDecidedIntercept(0, 0, scope, "search-agent", "Bash", []byte(`{"command":"python3 scripts/check.py"}`), "denied", "[模型] searched", &InterceptAudit{ModelInput: modelInput, Output: "response contains example.test"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows, total, err = d.ListTaskInterceptsPage(scope, 1, 20, InterceptApprovalFilter{Search: "example.test"})
|
||||
if err != nil || total != 1 || len(rows) != 1 || rows[0].ID != searchID {
|
||||
t.Fatalf("snapshot search total=%d rows=%d err=%v", total, len(rows), err)
|
||||
}
|
||||
rows, total, err = d.ListTaskInterceptsPage(scope, 1, 20, InterceptApprovalFilter{Search: "100%"})
|
||||
if err != nil || total != 1 || len(rows) != 1 {
|
||||
t.Fatalf("literal percent search total=%d rows=%d err=%v", total, len(rows), err)
|
||||
}
|
||||
rows, total, err = d.ListTaskInterceptsPage(scope, 1, 20, InterceptApprovalFilter{Search: "#" + strconv.FormatInt(searchID, 10)})
|
||||
if err != nil || total != 1 || len(rows) != 1 || rows[0].ID != searchID {
|
||||
t.Fatalf("id search total=%d rows=%d err=%v", total, len(rows), err)
|
||||
}
|
||||
rows, total, err = d.ListTaskInterceptsPage(scope, 1, 20, InterceptApprovalFilter{
|
||||
Search: "example.test", CreatedFrom: "2099-01-01", CreatedTo: "2099-01-01",
|
||||
})
|
||||
if err != nil || total != 1 || len(rows) != 1 || rows[0].ID != searchID {
|
||||
t.Fatalf("date search total=%d rows=%d err=%v", total, len(rows), err)
|
||||
}
|
||||
}
|
||||
@@ -1112,6 +1112,7 @@ CREATE TABLE IF NOT EXISTS intercept_pending (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_intercept_pending_status ON intercept_pending(status, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_intercept_pending_task ON intercept_pending(task_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_intercept_pending_created ON intercept_pending(created_at DESC);
|
||||
-- 补旧库:reason 列(已发版,加列要带 IF NOT EXISTS)。
|
||||
ALTER TABLE intercept_pending ADD COLUMN IF NOT EXISTS reason TEXT NOT NULL DEFAULT '';
|
||||
-- Detail payloads are lazy-loaded; NULL preserves the meaning of legacy history.
|
||||
|
||||
+29
-1
@@ -10,6 +10,8 @@ import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
"github.com/Autumn-27/artex/guard"
|
||||
@@ -321,7 +323,33 @@ func (s *Server) interceptHistory(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func interceptFilterParams(q url.Values) (db.InterceptApprovalFilter, error) {
|
||||
filter := db.InterceptApprovalFilter{Status: q.Get("status"), DecisionSource: q.Get("decision_source")}
|
||||
filter := db.InterceptApprovalFilter{
|
||||
Status: q.Get("status"),
|
||||
DecisionSource: q.Get("decision_source"),
|
||||
Search: strings.TrimSpace(q.Get("q")),
|
||||
CreatedFrom: q.Get("from"),
|
||||
CreatedTo: q.Get("to"),
|
||||
}
|
||||
if utf8.RuneCountInString(filter.Search) > 200 {
|
||||
return filter, fmt.Errorf("q 不能超过 200 个字符")
|
||||
}
|
||||
if strings.HasPrefix(filter.Search, "#") {
|
||||
id, err := strconv.ParseInt(strings.TrimPrefix(filter.Search, "#"), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
return filter, fmt.Errorf("审批编号必须为 # 后接正整数")
|
||||
}
|
||||
}
|
||||
for name, value := range map[string]string{"from": filter.CreatedFrom, "to": filter.CreatedTo} {
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := time.Parse("2006-01-02", value); err != nil {
|
||||
return filter, fmt.Errorf("%s 必须是 YYYY-MM-DD 日期", name)
|
||||
}
|
||||
}
|
||||
if filter.CreatedFrom != "" && filter.CreatedTo != "" && filter.CreatedFrom > filter.CreatedTo {
|
||||
return filter, fmt.Errorf("from 不能晚于 to")
|
||||
}
|
||||
switch filter.Status {
|
||||
case "", "pending", "allowed", "denied", "timeout":
|
||||
default:
|
||||
|
||||
@@ -4,12 +4,43 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
"github.com/Autumn-27/artex/intercept"
|
||||
)
|
||||
|
||||
func TestInterceptSearchFilterParams(t *testing.T) {
|
||||
filter, err := interceptFilterParams(url.Values{"q": []string{" https://example.test/a?x=1 "}})
|
||||
if err != nil || filter.Search != "https://example.test/a?x=1" {
|
||||
t.Fatalf("search=%q err=%v", filter.Search, err)
|
||||
}
|
||||
if _, err := interceptFilterParams(url.Values{"q": []string{"#0"}}); err == nil {
|
||||
t.Fatal("zero approval id accepted")
|
||||
}
|
||||
if _, err := interceptFilterParams(url.Values{"q": []string{"#abc"}}); err == nil {
|
||||
t.Fatal("non-numeric approval id accepted")
|
||||
}
|
||||
if _, err := interceptFilterParams(url.Values{"q": []string{strings.Repeat("x", 201)}}); err == nil {
|
||||
t.Fatal("overlong search accepted")
|
||||
}
|
||||
filter, err = interceptFilterParams(url.Values{"from": []string{"2026-10-01"}, "to": []string{"2026-10-12"}})
|
||||
if err != nil || filter.CreatedFrom != "2026-10-01" || filter.CreatedTo != "2026-10-12" {
|
||||
t.Fatalf("date filter=%+v err=%v", filter, err)
|
||||
}
|
||||
for _, values := range []url.Values{
|
||||
{"from": []string{"2026-10-1"}},
|
||||
{"to": []string{"not-a-date"}},
|
||||
{"from": []string{"2026-10-13"}, "to": []string{"2026-10-12"}},
|
||||
} {
|
||||
if _, err := interceptFilterParams(values); err == nil {
|
||||
t.Fatalf("invalid date filter accepted: %v", values)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterceptFilterHTTP(t *testing.T) {
|
||||
dsn, _, err := db.DSN()
|
||||
if err != nil {
|
||||
@@ -54,7 +85,14 @@ func TestInterceptFilterHTTP(t *testing.T) {
|
||||
if rec := do(base+"?status=denied&decision_source=model", false); rec.Code != 401 {
|
||||
t.Fatalf("unprotected list: %d", rec.Code)
|
||||
}
|
||||
for _, query := range []string{"?status=invalid", "?decision_source=invalid", "?page=1&status=denied%27+OR+1%3D1--", "?size=20&decision_source=MODEL"} {
|
||||
for _, query := range []string{
|
||||
"?status=invalid",
|
||||
"?decision_source=invalid",
|
||||
"?page=1&status=denied%27+OR+1%3D1--",
|
||||
"?size=20&decision_source=MODEL",
|
||||
"?q=%23abc",
|
||||
"?from=2026-10-13&to=2026-10-12",
|
||||
} {
|
||||
if rec := do(base+query, true); rec.Code != 400 {
|
||||
t.Fatalf("invalid filter %s: %d %s", query, rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
ClipboardListIcon,
|
||||
CopyIcon,
|
||||
RefreshCwIcon,
|
||||
SearchIcon,
|
||||
ShieldAlertIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
@@ -24,6 +25,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty";
|
||||
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
@@ -764,6 +766,9 @@ export function ApprovalRecords({ taskId }: { taskId?: string }) {
|
||||
const [pageSize, setPageSize] = React.useState(20);
|
||||
const [total, setTotal] = React.useState(0);
|
||||
const [filter, setFilter] = React.useState<InterceptApprovalFilter>({});
|
||||
const [searchText, setSearchText] = React.useState("");
|
||||
const [fromDate, setFromDate] = React.useState("");
|
||||
const [toDate, setToDate] = React.useState("");
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [refreshing, setRefreshing] = React.useState(false);
|
||||
const [error, setError] = React.useState("");
|
||||
@@ -773,7 +778,7 @@ export function ApprovalRecords({ taskId }: { taskId?: string }) {
|
||||
const request = React.useRef(0);
|
||||
const decisionLock = React.useRef(false);
|
||||
const filterID = React.useId();
|
||||
const filtered = Boolean(filter.status || filter.decision_source);
|
||||
const filtered = Boolean(filter.status || filter.decision_source || filter.q || filter.from || filter.to);
|
||||
|
||||
// A task can stay mounted while the user switches between task details.
|
||||
// Reset the cursor so the new scope always starts at its newest records.
|
||||
@@ -843,6 +848,9 @@ export function ApprovalRecords({ taskId }: { taskId?: string }) {
|
||||
// An in-flight response for the old filter must not repopulate the table.
|
||||
request.current++;
|
||||
setFilter(next);
|
||||
setSearchText(next.q ?? "");
|
||||
setFromDate(next.from ?? "");
|
||||
setToDate(next.to ?? "");
|
||||
setPage(1);
|
||||
setRows([]);
|
||||
setTotal(0);
|
||||
@@ -850,6 +858,30 @@ export function ApprovalRecords({ taskId }: { taskId?: string }) {
|
||||
setLoading(true);
|
||||
};
|
||||
|
||||
const submitSearch = () => {
|
||||
const q = searchText.trim();
|
||||
changeFilter({ ...filter, q: q || undefined });
|
||||
};
|
||||
|
||||
const changeDate = (key: "from" | "to", value: string) => {
|
||||
changeFilter({ ...filter, [key]: value || undefined });
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
const q = searchText.trim();
|
||||
if (q === (filter.q ?? "")) return;
|
||||
const timer = setTimeout(() => {
|
||||
request.current++;
|
||||
setFilter((previous) => ({ ...previous, q: q || undefined }));
|
||||
setPage(1);
|
||||
setRows([]);
|
||||
setTotal(0);
|
||||
setError("");
|
||||
setLoading(true);
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchText, filter.q]);
|
||||
|
||||
const decide: Decide = async (id, decision) => {
|
||||
if (decisionLock.current) return;
|
||||
decisionLock.current = true;
|
||||
@@ -901,8 +933,53 @@ export function ApprovalRecords({ taskId }: { taskId?: string }) {
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">展开记录查看工具请求和审批裁决,以及当时的上下文与执行结果。</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
展开记录查看工具请求和审批裁决,以及当时的上下文与执行结果。输入 #编号 可精确定位审批。
|
||||
</p>
|
||||
<FieldGroup className="flex-row flex-wrap items-end gap-3" aria-label="审批记录筛选">
|
||||
<Field className="w-full sm:min-w-80 sm:flex-1">
|
||||
<FieldLabel htmlFor={`${filterID}-search`}>搜索审批内容</FieldLabel>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<SearchIcon className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
id={`${filterID}-search`}
|
||||
className="pl-8"
|
||||
value={searchText}
|
||||
maxLength={200}
|
||||
placeholder="编号、标题、URL、命令或脚本内容"
|
||||
onChange={(event) => setSearchText(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
submitSearch();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Button type="button" variant="outline" onClick={submitSearch}>
|
||||
搜索
|
||||
</Button>
|
||||
</div>
|
||||
</Field>
|
||||
<Field className="w-full sm:w-40">
|
||||
<FieldLabel htmlFor={`${filterID}-from`}>申请时间从</FieldLabel>
|
||||
<Input
|
||||
id={`${filterID}-from`}
|
||||
type="date"
|
||||
value={fromDate}
|
||||
onChange={(event) => changeDate("from", event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field className="w-full sm:w-40">
|
||||
<FieldLabel htmlFor={`${filterID}-to`}>申请时间至</FieldLabel>
|
||||
<Input
|
||||
id={`${filterID}-to`}
|
||||
type="date"
|
||||
value={toDate}
|
||||
onChange={(event) => changeDate("to", event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field className="w-full sm:w-40">
|
||||
<FieldLabel htmlFor={`${filterID}-status`}>审批状态</FieldLabel>
|
||||
<Select
|
||||
@@ -980,6 +1057,9 @@ export function ApprovalRecords({ taskId }: { taskId?: string }) {
|
||||
<section className="rounded-xl border">
|
||||
<div className="border-b px-4 py-3 font-medium text-sm">
|
||||
{filtered ? "筛选结果" : "全部记录"}({total})
|
||||
{filter.q ? (
|
||||
<span className="ml-2 break-all font-normal text-muted-foreground text-xs">搜索:{filter.q}</span>
|
||||
) : null}
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="flex flex-col gap-3 p-4">
|
||||
|
||||
+4
-1
@@ -84,7 +84,6 @@ import type {
|
||||
SSTask,
|
||||
Stats,
|
||||
Task,
|
||||
TaskSchedule,
|
||||
TaskArchive,
|
||||
TaskArchivePage,
|
||||
TaskAssetMutation,
|
||||
@@ -94,6 +93,7 @@ import type {
|
||||
TaskGoal,
|
||||
TaskLLMResolutions,
|
||||
TaskNode,
|
||||
TaskSchedule,
|
||||
TaskScopeRow,
|
||||
TaskTemplate,
|
||||
TokenTotal,
|
||||
@@ -243,6 +243,9 @@ function interceptPageQuery(page: number, size: number, filter: InterceptApprova
|
||||
const query = new URLSearchParams({ page: String(page), size: String(size) });
|
||||
if (filter.status) query.set("status", filter.status);
|
||||
if (filter.decision_source) query.set("decision_source", filter.decision_source);
|
||||
if (filter.q?.trim()) query.set("q", filter.q.trim());
|
||||
if (filter.from) query.set("from", filter.from);
|
||||
if (filter.to) query.set("to", filter.to);
|
||||
return query.toString();
|
||||
}
|
||||
|
||||
|
||||
@@ -2461,18 +2461,37 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
if (path === "/intercept/history" || (seg[0] === "intercept" && seg[1] === "task")) {
|
||||
const status = q.get("status") || "";
|
||||
const decisionSource = q.get("decision_source") || "";
|
||||
const search = (q.get("q") || "").trim();
|
||||
const from = q.get("from") || "";
|
||||
const to = q.get("to") || "";
|
||||
if (status && !["pending", "allowed", "denied", "timeout"].includes(status)) throw new Error("无效审批状态");
|
||||
if (decisionSource && !["model", "rule", "unknown"].includes(decisionSource)) throw new Error("无效判定来源");
|
||||
if ([...search].length > 200) throw new Error("搜索关键词不能超过 200 个字符");
|
||||
if (search.startsWith("#") && !/^#[1-9]\d*$/.test(search)) throw new Error("审批编号必须为 # 后接正整数");
|
||||
for (const value of [from, to]) {
|
||||
if (value && !/^\d{4}-\d{2}-\d{2}$/.test(value)) throw new Error("日期必须是 YYYY-MM-DD");
|
||||
}
|
||||
if (from && to && from > to) throw new Error("开始日期不能晚于结束日期");
|
||||
const filtered = mockInterceptHistory.filter((row) => {
|
||||
const source =
|
||||
row.decision_source || (row.rule_id ? "rule" : row.reason?.startsWith("[模型]") ? "model" : "unknown");
|
||||
let source = row.decision_source;
|
||||
if (!source) {
|
||||
if (row.rule_id) source = "rule";
|
||||
else if (row.reason?.startsWith("[模型]")) source = "model";
|
||||
else source = "unknown";
|
||||
}
|
||||
const haystack = JSON.stringify({ ...row, audit: mockInterceptDetails[row.id] ?? null }).toLowerCase();
|
||||
const day = row.created_at.slice(0, 10);
|
||||
return (
|
||||
(seg[1] !== "task" || row.task_id === decodeURIComponent(seg[2])) &&
|
||||
(!status || row.status === status) &&
|
||||
(!decisionSource || source === decisionSource)
|
||||
(!decisionSource || source === decisionSource) &&
|
||||
(!from || day >= from) &&
|
||||
(!to || day <= to) &&
|
||||
(!search ||
|
||||
(search.startsWith("#") ? row.id === Number(search.slice(1)) : haystack.includes(search.toLowerCase())))
|
||||
);
|
||||
});
|
||||
if (!q.has("page") && !q.has("size") && !status && !decisionSource)
|
||||
if (!q.has("page") && !q.has("size") && !status && !decisionSource && !search && !from && !to)
|
||||
return { items: filtered, total: filtered.length };
|
||||
const page = Math.max(1, Number(q.get("page")) || 1);
|
||||
const size = Math.min(100, Math.max(1, Number(q.get("size")) || 20));
|
||||
|
||||
@@ -1386,6 +1386,9 @@ export interface JudgeUsage {
|
||||
export interface InterceptApprovalFilter {
|
||||
status?: InterceptPending["status"];
|
||||
decision_source?: "rule" | "model" | "unknown";
|
||||
q?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
}
|
||||
|
||||
// InterceptApprovalRow enriches InterceptPending with conversation/task and rule context.
|
||||
|
||||
Reference in new issue
Block a user