30 files changed
+1886
-220
No files matched your search
@@ -209,6 +209,28 @@ func (d *DB) DeleteConversation(id int64) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteConversations removes existing threads in one statement and returns the
|
||||||
|
// ids that were actually present. Child activities and trigger runs cascade.
|
||||||
|
func (d *DB) DeleteConversations(ids []int64) ([]int64, error) {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return []int64{}, nil
|
||||||
|
}
|
||||||
|
rows, err := d.Query(`DELETE FROM conversations WHERE id=ANY($1::bigint[]) RETURNING id`, ids)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
deleted := make([]int64, 0, len(ids))
|
||||||
|
for rows.Next() {
|
||||||
|
var id int64
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
deleted = append(deleted, id)
|
||||||
|
}
|
||||||
|
return deleted, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// AppendConvActivity records one step of a conversation (human message or an agent
|
// AppendConvActivity records one step of a conversation (human message or an agent
|
||||||
// execution step) and returns its id. Mirrors ExplorationStore.AppendActivity but
|
// execution step) and returns its id. Mirrors ExplorationStore.AppendActivity but
|
||||||
// keyed by conversation_id. Reuses the Activity struct (NodeID is ignored here).
|
// keyed by conversation_id. Reuses the Activity struct (NodeID is ignored here).
|
||||||
|
|||||||
@@ -385,6 +385,7 @@ CREATE TABLE IF NOT EXISTS tasks (
|
|||||||
timeout_seconds INTEGER NOT NULL DEFAULT 0,
|
timeout_seconds INTEGER NOT NULL DEFAULT 0,
|
||||||
plan_heartbeat_seconds INTEGER NOT NULL DEFAULT 300,
|
plan_heartbeat_seconds INTEGER NOT NULL DEFAULT 300,
|
||||||
coverage_enabled BOOLEAN NOT NULL DEFAULT true,
|
coverage_enabled BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
pinned_at TIMESTAMPTZ,
|
||||||
first_run_at TIMESTAMPTZ,
|
first_run_at TIMESTAMPTZ,
|
||||||
deadline_at TIMESTAMPTZ,
|
deadline_at TIMESTAMPTZ,
|
||||||
deleted_at TIMESTAMPTZ,
|
deleted_at TIMESTAMPTZ,
|
||||||
@@ -413,10 +414,13 @@ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS queue_mode TEXT NOT NULL DEFAULT '';
|
|||||||
-- 可选的任务名称;补旧库。空串=未命名,前端展示时回退到描述。
|
-- 可选的任务名称;补旧库。空串=未命名,前端展示时回退到描述。
|
||||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS name TEXT NOT NULL DEFAULT '';
|
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS name TEXT NOT NULL DEFAULT '';
|
||||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS category_id BIGINT REFERENCES task_categories(id) ON DELETE SET NULL;
|
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS category_id BIGINT REFERENCES task_categories(id) ON DELETE SET NULL;
|
||||||
|
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS pinned_at TIMESTAMPTZ;
|
||||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS active_llm_profile_id BIGINT REFERENCES llm_profiles(id) ON DELETE SET NULL;
|
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS active_llm_profile_id BIGINT REFERENCES llm_profiles(id) ON DELETE SET NULL;
|
||||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS llm_chain_revision BIGINT NOT NULL DEFAULT 0;
|
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS llm_chain_revision BIGINT NOT NULL DEFAULT 0;
|
||||||
CREATE INDEX IF NOT EXISTS idx_tasks_category ON tasks(category_id, created_at DESC)
|
CREATE INDEX IF NOT EXISTS idx_tasks_category ON tasks(category_id, created_at DESC)
|
||||||
WHERE deleted_at IS NULL AND category_id IS NOT NULL;
|
WHERE deleted_at IS NULL AND category_id IS NOT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tasks_pinned ON tasks(pinned_at DESC)
|
||||||
|
WHERE deleted_at IS NULL AND pinned_at IS NOT NULL;
|
||||||
|
|
||||||
-- Reusable task description/goal presets. nkey is the normalized, case-insensitive
|
-- Reusable task description/goal presets. nkey is the normalized, case-insensitive
|
||||||
-- identity used to reject visually equivalent duplicate names.
|
-- identity used to reject visually equivalent duplicate names.
|
||||||
@@ -709,6 +713,21 @@ CREATE TABLE IF NOT EXISTS skill_usage (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_skill_usage_skill ON skill_usage(skill, ts DESC);
|
CREATE INDEX IF NOT EXISTS idx_skill_usage_skill ON skill_usage(skill, ts DESC);
|
||||||
CREATE INDEX IF NOT EXISTS idx_skill_usage_task ON skill_usage(task_id);
|
CREATE INDEX IF NOT EXISTS idx_skill_usage_task ON skill_usage(task_id);
|
||||||
|
|
||||||
|
-- 工具调用账本(见 db/tool_usage.go)。一次实际 CoreTool.Call 一行,只记归属维度,
|
||||||
|
-- 不保存工具参数或返回内容。刻意不设外键,任务、会话或自定义工具删除后仍保留统计。
|
||||||
|
CREATE TABLE IF NOT EXISTS tool_usage (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
ts TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
tool_key TEXT NOT NULL,
|
||||||
|
agent_key TEXT,
|
||||||
|
task_id BIGINT,
|
||||||
|
exploration_id BIGINT,
|
||||||
|
intent_id BIGINT,
|
||||||
|
session_id TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tool_usage_tool ON tool_usage(tool_key, ts DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tool_usage_task ON tool_usage(task_id);
|
||||||
|
|
||||||
-- =====================================================================
|
-- =====================================================================
|
||||||
-- H. 内置工具目录
|
-- H. 内置工具目录
|
||||||
-- =====================================================================
|
-- =====================================================================
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTaskPinOrderingAndRename(t *testing.T) {
|
||||||
|
d, err := Open(testDSN(t))
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("postgres unavailable (%v) — skipping", err)
|
||||||
|
}
|
||||||
|
defer d.Close()
|
||||||
|
|
||||||
|
suffix := time.Now().UnixNano()
|
||||||
|
first, err := d.CreateTask(fmt.Sprintf("task-pin-first-%d", suffix), "goal", nil, 0, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
second, err := d.CreateTask(fmt.Sprintf("task-pin-second-%d", suffix), "goal", nil, 0, 0)
|
||||||
|
if err != nil {
|
||||||
|
_ = d.DeleteTask(first.ID)
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = d.DeleteTask(first.ID)
|
||||||
|
_ = d.DeleteTask(second.ID)
|
||||||
|
}()
|
||||||
|
|
||||||
|
pinned := true
|
||||||
|
first, err = d.UpdateTask(first.ID, TaskPatch{Pinned: &pinned})
|
||||||
|
if err != nil || first == nil || !first.Pinned || first.PinnedAt == nil {
|
||||||
|
t.Fatalf("pin first = %+v, %v", first, err)
|
||||||
|
}
|
||||||
|
firstPinnedAt := *first.PinnedAt
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
second, err = d.UpdateTask(second.ID, TaskPatch{Pinned: &pinned})
|
||||||
|
if err != nil || second == nil || !second.Pinned || second.PinnedAt == nil {
|
||||||
|
t.Fatalf("pin second = %+v, %v", second, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
name := "renamed pinned task"
|
||||||
|
first, err = d.UpdateTask(first.ID, TaskPatch{Name: &name, Pinned: &pinned})
|
||||||
|
if err != nil || first == nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if first.Name != name || first.PinnedAt == nil || !first.PinnedAt.Equal(firstPinnedAt) {
|
||||||
|
t.Fatalf("repeat pin should preserve pin time: %+v (want %v)", first, firstPinnedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks, err := d.ListTasks()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
positions := map[int64]int{}
|
||||||
|
for index, task := range tasks {
|
||||||
|
positions[task.ID] = index
|
||||||
|
}
|
||||||
|
if positions[second.ID] >= positions[first.ID] {
|
||||||
|
t.Fatalf("newer pin must sort first: second=%d first=%d", positions[second.ID], positions[first.ID])
|
||||||
|
}
|
||||||
|
|
||||||
|
pinned = false
|
||||||
|
first, err = d.UpdateTask(first.ID, TaskPatch{Pinned: &pinned})
|
||||||
|
if err != nil || first == nil || first.Pinned || first.PinnedAt != nil {
|
||||||
|
t.Fatalf("unpin first = %+v, %v", first, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteConversationsReturnsExistingIDs(t *testing.T) {
|
||||||
|
d, err := Open(testDSN(t))
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("postgres unavailable (%v) — skipping", err)
|
||||||
|
}
|
||||||
|
defer d.Close()
|
||||||
|
|
||||||
|
first, err := d.CreateConversation("mainagent", "batch-delete-first", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
second, err := d.CreateConversation("mainagent", "batch-delete-second", nil)
|
||||||
|
if err != nil {
|
||||||
|
_ = d.DeleteConversation(first.ID)
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = d.DeleteConversation(first.ID)
|
||||||
|
_ = d.DeleteConversation(second.ID)
|
||||||
|
}()
|
||||||
|
|
||||||
|
missing := int64(1<<62 - 1)
|
||||||
|
deleted, err := d.DeleteConversations([]int64{first.ID, missing, second.ID})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
seen := make(map[int64]bool, len(deleted))
|
||||||
|
for _, id := range deleted {
|
||||||
|
seen[id] = true
|
||||||
|
}
|
||||||
|
if !seen[first.ID] || !seen[second.ID] || seen[missing] || len(deleted) != 2 {
|
||||||
|
t.Fatalf("deleted=%v, want existing ids only", deleted)
|
||||||
|
}
|
||||||
|
}
|
||||||
+36
-6
@@ -15,6 +15,8 @@ type Task struct {
|
|||||||
Name string `json:"name"` // 可选任务名称;空=未命名
|
Name string `json:"name"` // 可选任务名称;空=未命名
|
||||||
CategoryID *int64 `json:"category_id,omitempty"`
|
CategoryID *int64 `json:"category_id,omitempty"`
|
||||||
CategoryName string `json:"category_name,omitempty"`
|
CategoryName string `json:"category_name,omitempty"`
|
||||||
|
Pinned bool `json:"pinned"`
|
||||||
|
PinnedAt *time.Time `json:"pinned_at,omitempty"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Goal string `json:"goal"`
|
Goal string `json:"goal"`
|
||||||
ExplorationID int64 `json:"exploration_id"`
|
ExplorationID int64 `json:"exploration_id"`
|
||||||
@@ -304,13 +306,14 @@ func insertTaskLLMProfiles(tx *sql.Tx, taskID int64, profileIDs []int64) error {
|
|||||||
|
|
||||||
const taskCols = `id, COALESCE(name,''), category_id,
|
const taskCols = `id, COALESCE(name,''), category_id,
|
||||||
COALESCE((SELECT category.name FROM task_categories category WHERE category.id=tasks.category_id),''),
|
COALESCE((SELECT category.name FROM task_categories category WHERE category.id=tasks.category_id),''),
|
||||||
description, goal, exploration_id, status, paused, queued, queued_at, COALESCE(queue_mode,''), llm_profile_id, active_llm_profile_id, COALESCE(parent_ref,''), created_at, completed_at, COALESCE(timeout_seconds,0), COALESCE(plan_heartbeat_seconds,300), COALESCE(coverage_enabled,true), first_run_at, deadline_at`
|
description, goal, exploration_id, status, paused, queued, queued_at, COALESCE(queue_mode,''), llm_profile_id, active_llm_profile_id, COALESCE(parent_ref,''), pinned_at, created_at, completed_at, COALESCE(timeout_seconds,0), COALESCE(plan_heartbeat_seconds,300), COALESCE(coverage_enabled,true), first_run_at, deadline_at`
|
||||||
|
|
||||||
func scanTask(sc interface{ Scan(...any) error }) (*Task, error) {
|
func scanTask(sc interface{ Scan(...any) error }) (*Task, error) {
|
||||||
var t Task
|
var t Task
|
||||||
if err := sc.Scan(&t.ID, &t.Name, &t.CategoryID, &t.CategoryName, &t.Description, &t.Goal, &t.ExplorationID, &t.Status, &t.Paused, &t.Queued, &t.QueuedAt, &t.QueueMode, &t.LLMProfileID, &t.ActiveLLMProfileID, &t.ParentRef, &t.CreatedAt, &t.CompletedAt, &t.TimeoutSeconds, &t.PlanHeartbeatSeconds, &t.CoverageEnabled, &t.FirstRunAt, &t.DeadlineAt); err != nil {
|
if err := sc.Scan(&t.ID, &t.Name, &t.CategoryID, &t.CategoryName, &t.Description, &t.Goal, &t.ExplorationID, &t.Status, &t.Paused, &t.Queued, &t.QueuedAt, &t.QueueMode, &t.LLMProfileID, &t.ActiveLLMProfileID, &t.ParentRef, &t.PinnedAt, &t.CreatedAt, &t.CompletedAt, &t.TimeoutSeconds, &t.PlanHeartbeatSeconds, &t.CoverageEnabled, &t.FirstRunAt, &t.DeadlineAt); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
t.Pinned = t.PinnedAt != nil
|
||||||
return &t, nil
|
return &t, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,11 +323,11 @@ func (d *DB) SetParentRef(id int64, parentRef string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListTasks returns alive tasks, newest first.
|
// ListTasks returns alive tasks with pinned tasks first, then newest ids.
|
||||||
func (d *DB) ListTasks() ([]*Task, error) {
|
func (d *DB) ListTasks() ([]*Task, error) {
|
||||||
// 按 id 降序:id 是 BIGSERIAL,单调递增即插入顺序,最新的在最前。不用 created_at
|
// id 是 BIGSERIAL,同一时刻创建的任务也有稳定且唯一的顺序。
|
||||||
// 是因为同一时刻创建的多个任务时间戳相同,排序不稳定,列表会频繁换位(见 List)。
|
rows, err := d.Query(`SELECT ` + taskCols + ` FROM tasks WHERE deleted_at IS NULL
|
||||||
rows, err := d.Query(`SELECT ` + taskCols + ` FROM tasks WHERE deleted_at IS NULL ORDER BY id DESC`)
|
ORDER BY (pinned_at IS NOT NULL) DESC, pinned_at DESC NULLS LAST, id DESC`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -349,6 +352,33 @@ func (d *DB) ListTasks() ([]*Task, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TaskPatch updates task list metadata without changing execution state.
|
||||||
|
type TaskPatch struct {
|
||||||
|
Name *string
|
||||||
|
Pinned *bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateTask applies a partial task name/pin mutation and returns the updated row.
|
||||||
|
// Re-pinning an already pinned task preserves its original position.
|
||||||
|
func (d *DB) UpdateTask(id int64, patch TaskPatch) (*Task, error) {
|
||||||
|
task, err := scanTask(d.QueryRow(`UPDATE tasks SET
|
||||||
|
name = CASE WHEN $2::boolean THEN $3 ELSE name END,
|
||||||
|
pinned_at = CASE
|
||||||
|
WHEN $4::boolean IS NULL THEN pinned_at
|
||||||
|
WHEN $4::boolean THEN COALESCE(pinned_at, now())
|
||||||
|
ELSE NULL
|
||||||
|
END
|
||||||
|
WHERE id=$1 AND deleted_at IS NULL
|
||||||
|
RETURNING `+taskCols, id, patch.Name != nil, patch.Name, patch.Pinned))
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return task, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetTask returns one alive task (nil if not found/deleted).
|
// GetTask returns one alive task (nil if not found/deleted).
|
||||||
func (d *DB) GetTask(id int64) (*Task, error) {
|
func (d *DB) GetTask(id int64) (*Task, error) {
|
||||||
t, err := scanTask(d.QueryRow(`SELECT `+taskCols+` FROM tasks WHERE id=$1 AND deleted_at IS NULL`, id))
|
t, err := scanTask(d.QueryRow(`SELECT `+taskCols+` FROM tasks WHERE id=$1 AND deleted_at IS NULL`, id))
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
// ToolUsage is one catalog tool invocation. It stores attribution dimensions only:
|
||||||
|
// tool arguments and results are deliberately excluded from the ledger.
|
||||||
|
type ToolUsage struct {
|
||||||
|
ToolKey string `json:"tool_key"`
|
||||||
|
AgentKey string `json:"agent_key"`
|
||||||
|
TaskID int64 `json:"task_id"`
|
||||||
|
ExplorationID int64 `json:"exploration_id"`
|
||||||
|
IntentID int64 `json:"intent_id"`
|
||||||
|
SessionID string `json:"session_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// InsertToolUsage appends one ledger row. Runtime callers treat metering as
|
||||||
|
// best-effort so a statistics failure never interrupts the tool itself.
|
||||||
|
func (d *DB) InsertToolUsage(u *ToolUsage) error {
|
||||||
|
_, err := d.Exec(`
|
||||||
|
INSERT INTO tool_usage(tool_key, agent_key, task_id, exploration_id, intent_id, session_id)
|
||||||
|
VALUES ($1, NULLIF($2,''), $3, $4, $5, NULLIF($6,''))`,
|
||||||
|
u.ToolKey, u.AgentKey, nullIfZero(u.TaskID), nullIfZero(u.ExplorationID),
|
||||||
|
nullIfZero(u.IntentID), u.SessionID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToolUsageCounts returns invocation totals keyed by catalog tool key. Entries
|
||||||
|
// without calls are absent; API callers merge the result into the tools catalog.
|
||||||
|
func (d *DB) ToolUsageCounts() (map[string]int, error) {
|
||||||
|
rows, err := d.Query(`
|
||||||
|
SELECT tool_key, COUNT(*)
|
||||||
|
FROM tool_usage
|
||||||
|
GROUP BY tool_key`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := map[string]int{}
|
||||||
|
for rows.Next() {
|
||||||
|
var key string
|
||||||
|
var calls int
|
||||||
|
if err := rows.Scan(&key, &calls); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out[key] = calls
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestToolUsageLedger(t *testing.T) {
|
||||||
|
d, err := Open(testDSN(t))
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("postgres unavailable (%v) - skipping", err)
|
||||||
|
}
|
||||||
|
defer d.Close()
|
||||||
|
|
||||||
|
const (
|
||||||
|
toolA = "zz_test_tool_usage_a"
|
||||||
|
toolB = "zz_test_tool_usage_b"
|
||||||
|
)
|
||||||
|
cleanup := func() {
|
||||||
|
_, _ = d.Exec(`DELETE FROM tool_usage WHERE tool_key IN ($1,$2)`, toolA, toolB)
|
||||||
|
}
|
||||||
|
cleanup()
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
rows := []*ToolUsage{
|
||||||
|
{ToolKey: toolA, AgentKey: "worker", TaskID: 991, ExplorationID: 5, IntentID: 7},
|
||||||
|
{ToolKey: toolA, AgentKey: "planner", TaskID: 992, ExplorationID: 6},
|
||||||
|
{ToolKey: toolA, AgentKey: "chatbot", SessionID: "conv-1"},
|
||||||
|
{ToolKey: toolB, AgentKey: "worker", TaskID: 991},
|
||||||
|
}
|
||||||
|
for _, row := range rows {
|
||||||
|
if err := d.InsertToolUsage(row); err != nil {
|
||||||
|
t.Fatalf("insert %s: %v", row.ToolKey, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
counts, err := d.ToolUsageCounts()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := counts[toolA]; got != 3 {
|
||||||
|
t.Errorf("%s calls: want 3, got %d", toolA, got)
|
||||||
|
}
|
||||||
|
if got := counts[toolB]; got != 1 {
|
||||||
|
t.Errorf("%s calls: want 1, got %d", toolB, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ type Tool struct {
|
|||||||
Kind string `json:"kind"` // builtin | command | script | http
|
Kind string `json:"kind"` // builtin | command | script | http
|
||||||
Exec json.RawMessage `json:"exec"` // 自定义工具执行规格(kind!=builtin)
|
Exec json.RawMessage `json:"exec"` // 自定义工具执行规格(kind!=builtin)
|
||||||
Deferred bool `json:"deferred"` // schema 延迟(走 SearchExtraTools/ExecuteExtraTool)
|
Deferred bool `json:"deferred"` // schema 延迟(走 SearchExtraTools/ExecuteExtraTool)
|
||||||
|
Calls int `json:"calls"` // runtime ledger aggregate; not stored in tools
|
||||||
}
|
}
|
||||||
|
|
||||||
const toolCols = `key, system, description, schema, agents, enabled, kind, exec, deferred`
|
const toolCols = `key, system, description, schema, agents, enabled, kind, exec, deferred`
|
||||||
|
|||||||
+14
-14
@@ -8,11 +8,11 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/Autumn-27/norma/skill"
|
|
||||||
actool "github.com/Autumn-27/norma/tool"
|
|
||||||
"github.com/Autumn-27/artex/agent"
|
"github.com/Autumn-27/artex/agent"
|
||||||
"github.com/Autumn-27/artex/db"
|
"github.com/Autumn-27/artex/db"
|
||||||
"github.com/Autumn-27/artex/traffic"
|
"github.com/Autumn-27/artex/traffic"
|
||||||
|
"github.com/Autumn-27/norma/skill"
|
||||||
|
actool "github.com/Autumn-27/norma/tool"
|
||||||
)
|
)
|
||||||
|
|
||||||
// wireAgentAugment connects the PG agent_visibility table into the agent runtime:
|
// wireAgentAugment connects the PG agent_visibility table into the agent runtime:
|
||||||
@@ -155,7 +155,7 @@ func wireAgentAugment(pg *db.DB, skillDir string, hostTools func() ([]actool.Cor
|
|||||||
if !contains(boundAgents, a.Key) {
|
if !contains(boundAgents, a.Key) {
|
||||||
continue // only defer names this agent is actually bound to
|
continue // only defer names this agent is actually bound to
|
||||||
}
|
}
|
||||||
allNames = append(allNames, name) // schema withheld from the prompt
|
allNames = append(allNames, name) // schema withheld from the prompt
|
||||||
globalNames = append(globalNames, name) // advertised in the deferred block
|
globalNames = append(globalNames, name) // advertised in the deferred block
|
||||||
globalSet[name] = true
|
globalSet[name] = true
|
||||||
if unlock != nil {
|
if unlock != nil {
|
||||||
@@ -235,7 +235,7 @@ func wireTools(pg *db.DB, domainReg map[string]actool.CoreTool) {
|
|||||||
// interactive_shell is on, so Bash points at shell_open for interactive programs
|
// interactive_shell is on, so Bash points at shell_open for interactive programs
|
||||||
// without ever referencing a tool that isn't injected (§14.1/§14.2).
|
// without ever referencing a tool that isn't injected (§14.1/§14.2).
|
||||||
const bashInteractiveShellNote = "\n\n需要【交互输入】的程序(msfconsole / ssh 交互登录 / mysql、psql、python 等 REPL / 密码或 yes/no 提示 / nc 反弹 shell)不要用 Bash(它没有 stdin、会卡住),改用 shell_open 开交互会话(用完 shell_close)。一次性、非交互命令仍用 Bash。"
|
const bashInteractiveShellNote = "\n\n需要【交互输入】的程序(msfconsole / ssh 交互登录 / mysql、psql、python 等 REPL / 密码或 yes/no 提示 / nc 反弹 shell)不要用 Bash(它没有 stdin、会卡住),改用 shell_open 开交互会话(用完 shell_close)。一次性、非交互命令仍用 Bash。"
|
||||||
agent.ToolResolve = func(_ context.Context, agentKey string, tools []actool.CoreTool) []actool.CoreTool {
|
agent.ToolResolve = func(ctx context.Context, agentKey string, tools []actool.CoreTool) []actool.CoreTool {
|
||||||
rows, err := pg.ListTools()
|
rows, err := pg.ListTools()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[tools] 读取工具表失败,按代码默认放行: %v", err)
|
log.Printf("[tools] 读取工具表失败,按代码默认放行: %v", err)
|
||||||
@@ -245,6 +245,14 @@ func wireTools(pg *db.DB, domainReg map[string]actool.CoreTool) {
|
|||||||
for _, t := range rows {
|
for _, t := range rows {
|
||||||
byKey[t.Key] = t
|
byKey[t.Key] = t
|
||||||
}
|
}
|
||||||
|
runInfo := agent.RunInfoFrom(ctx)
|
||||||
|
resolve := func(t actool.CoreTool, row *db.Tool) actool.CoreTool {
|
||||||
|
var schema map[string]any
|
||||||
|
if len(row.Schema) > 0 {
|
||||||
|
_ = json.Unmarshal(row.Schema, &schema)
|
||||||
|
}
|
||||||
|
return meterTool(agent.DecorateTool(t, row.Description, schema), pg, row.Key, agentKey, runInfo)
|
||||||
|
}
|
||||||
out := tools[:0:0]
|
out := tools[:0:0]
|
||||||
for _, t := range tools {
|
for _, t := range tools {
|
||||||
row, known := byKey[t.Name()]
|
row, known := byKey[t.Name()]
|
||||||
@@ -255,11 +263,7 @@ func wireTools(pg *db.DB, domainReg map[string]actool.CoreTool) {
|
|||||||
if !row.Enabled || !contains(row.Agents, agentKey) {
|
if !row.Enabled || !contains(row.Agents, agentKey) {
|
||||||
continue // disabled globally or not bound to this agent → drop
|
continue // disabled globally or not bound to this agent → drop
|
||||||
}
|
}
|
||||||
var schema map[string]any
|
out = append(out, resolve(t, row))
|
||||||
if len(row.Schema) > 0 {
|
|
||||||
_ = json.Unmarshal(row.Schema, &schema)
|
|
||||||
}
|
|
||||||
out = append(out, agent.DecorateTool(t, row.Description, schema))
|
|
||||||
}
|
}
|
||||||
// inject: domain tools bound to this agent in the DB but absent from the
|
// inject: domain tools bound to this agent in the DB but absent from the
|
||||||
// incoming list. Covers agents (Auto, custom) whose base only has DefaultTools
|
// incoming list. Covers agents (Auto, custom) whose base only has DefaultTools
|
||||||
@@ -279,11 +283,7 @@ func wireTools(pg *db.DB, domainReg map[string]actool.CoreTool) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
continue // not a domain tool; custom/host tools are injected via hostTools()
|
continue // not a domain tool; custom/host tools are injected via hostTools()
|
||||||
}
|
}
|
||||||
var schema map[string]any
|
out = append(out, resolve(inst, row))
|
||||||
if len(row.Schema) > 0 {
|
|
||||||
_ = json.Unmarshal(row.Schema, &schema)
|
|
||||||
}
|
|
||||||
out = append(out, agent.DecorateTool(inst, row.Description, schema))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// shell hints: user-defined kind="shell" tools are not callable — they are
|
// shell hints: user-defined kind="shell" tools are not callable — they are
|
||||||
|
|||||||
@@ -203,6 +203,7 @@ func (s *Server) pgDeleteConversation(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.cancelConversation(c.ID)
|
||||||
if err := pg.DeleteConversation(c.ID); err != nil {
|
if err := pg.DeleteConversation(c.ID); err != nil {
|
||||||
writeErr(w, 500, err.Error())
|
writeErr(w, 500, err.Error())
|
||||||
return
|
return
|
||||||
@@ -210,6 +211,79 @@ func (s *Server) pgDeleteConversation(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, 200, map[string]any{"deleted": c.ID})
|
writeJSON(w, 200, map[string]any{"deleted": c.ID})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const maxConversationDeleteBatch = 100
|
||||||
|
|
||||||
|
type conversationDeleteItem struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) cancelConversation(id int64) {
|
||||||
|
busyKey := s.convBusyKey(id)
|
||||||
|
s.chatMu.Lock()
|
||||||
|
cancel := s.chatCancel[busyKey]
|
||||||
|
s.chatMu.Unlock()
|
||||||
|
if cancel != nil {
|
||||||
|
cancel(agent.AbortChatStoppedByUser)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pgDeleteConversationsBatch deletes up to 100 selected conversations in one
|
||||||
|
// database statement. Missing ids are reported per item so a stale list does not
|
||||||
|
// hide what was actually removed.
|
||||||
|
func (s *Server) pgDeleteConversationsBatch(w http.ResponseWriter, r *http.Request) {
|
||||||
|
pg := s.pg(w)
|
||||||
|
if pg == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var request struct {
|
||||||
|
IDs []int64 `json:"ids"`
|
||||||
|
}
|
||||||
|
if !decodeConversationRequest(w, r, &request) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ids := make([]int64, 0, len(request.IDs))
|
||||||
|
seen := make(map[int64]struct{}, len(request.IDs))
|
||||||
|
for _, id := range request.IDs {
|
||||||
|
if id <= 0 {
|
||||||
|
writeErr(w, http.StatusBadRequest, "对话 id 无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, exists := seen[id]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
if len(ids) == 0 || len(ids) > maxConversationDeleteBatch {
|
||||||
|
writeErr(w, http.StatusBadRequest, fmt.Sprintf("ids 数量必须为 1-%d", maxConversationDeleteBatch))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, id := range ids {
|
||||||
|
s.cancelConversation(id)
|
||||||
|
}
|
||||||
|
deleted, err := pg.DeleteConversations(ids)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
deletedSet := make(map[int64]struct{}, len(deleted))
|
||||||
|
for _, id := range deleted {
|
||||||
|
deletedSet[id] = struct{}{}
|
||||||
|
}
|
||||||
|
items := make([]conversationDeleteItem, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
_, ok := deletedSet[id]
|
||||||
|
item := conversationDeleteItem{ID: id, OK: ok}
|
||||||
|
if !ok {
|
||||||
|
item.Error = "conversation not found"
|
||||||
|
}
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) convBusyKey(id int64) string { return "conv-" + strconv.FormatInt(id, 10) }
|
func (s *Server) convBusyKey(id int64) string { return "conv-" + strconv.FormatInt(id, 10) }
|
||||||
|
|
||||||
// pgConversationMessages returns steps after ?since=cursor plus whether a turn is
|
// pgConversationMessages returns steps after ?since=cursor plus whether a turn is
|
||||||
|
|||||||
+5
-1
@@ -35,6 +35,8 @@ type TaskDTO struct {
|
|||||||
Name string `json:"name"` // 可选任务名称;空=未命名
|
Name string `json:"name"` // 可选任务名称;空=未命名
|
||||||
CategoryID *int64 `json:"category_id,omitempty"`
|
CategoryID *int64 `json:"category_id,omitempty"`
|
||||||
CategoryName string `json:"category_name,omitempty"`
|
CategoryName string `json:"category_name,omitempty"`
|
||||||
|
Pinned bool `json:"pinned"`
|
||||||
|
PinnedAt string `json:"pinned_at,omitempty"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Goal string `json:"goal"`
|
Goal string `json:"goal"`
|
||||||
Status string `json:"status"` // created | running | paused | done | failed
|
Status string `json:"status"` // created | running | paused | done | failed
|
||||||
@@ -86,9 +88,11 @@ func taskDTO(t *Task, status string) TaskDTO {
|
|||||||
return TaskDTO{
|
return TaskDTO{
|
||||||
ID: t.ID,
|
ID: t.ID,
|
||||||
ExplorationID: t.ExpID,
|
ExplorationID: t.ExpID,
|
||||||
Name: t.Name,
|
Name: lifecycle.Name,
|
||||||
CategoryID: lifecycle.CategoryID,
|
CategoryID: lifecycle.CategoryID,
|
||||||
CategoryName: lifecycle.CategoryName,
|
CategoryName: lifecycle.CategoryName,
|
||||||
|
Pinned: lifecycle.PinnedAt > 0,
|
||||||
|
PinnedAt: completedRFC(lifecycle.PinnedAt),
|
||||||
Description: t.Description,
|
Description: t.Description,
|
||||||
Goal: t.Goal,
|
Goal: t.Goal,
|
||||||
Status: status,
|
Status: status,
|
||||||
|
|||||||
+44
-3
@@ -32,6 +32,7 @@ type Task struct {
|
|||||||
Name string `json:"name"` // 可选任务名称;空=未命名
|
Name string `json:"name"` // 可选任务名称;空=未命名
|
||||||
CategoryID *int64 `json:"category_id,omitempty"`
|
CategoryID *int64 `json:"category_id,omitempty"`
|
||||||
CategoryName string `json:"category_name,omitempty"`
|
CategoryName string `json:"category_name,omitempty"`
|
||||||
|
PinnedAt int64 `json:"pinned_at,omitempty"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Goal string `json:"goal"`
|
Goal string `json:"goal"`
|
||||||
CreatedAt int64 `json:"created_at"`
|
CreatedAt int64 `json:"created_at"`
|
||||||
@@ -77,6 +78,8 @@ type Task struct {
|
|||||||
// updateLifecycle instead of reading or writing the corresponding Task fields
|
// updateLifecycle instead of reading or writing the corresponding Task fields
|
||||||
// directly after the task has been published by Manager.
|
// directly after the task has been published by Manager.
|
||||||
type taskLifecycleState struct {
|
type taskLifecycleState struct {
|
||||||
|
Name string
|
||||||
|
PinnedAt int64
|
||||||
Status string
|
Status string
|
||||||
Paused bool
|
Paused bool
|
||||||
Queued bool
|
Queued bool
|
||||||
@@ -102,6 +105,8 @@ func (t *Task) lifecycleSnapshot() taskLifecycleState {
|
|||||||
|
|
||||||
func (t *Task) lifecycleSnapshotLocked() taskLifecycleState {
|
func (t *Task) lifecycleSnapshotLocked() taskLifecycleState {
|
||||||
return taskLifecycleState{
|
return taskLifecycleState{
|
||||||
|
Name: t.Name,
|
||||||
|
PinnedAt: t.PinnedAt,
|
||||||
Status: t.Status,
|
Status: t.Status,
|
||||||
Paused: t.Paused,
|
Paused: t.Paused,
|
||||||
Queued: t.Queued,
|
Queued: t.Queued,
|
||||||
@@ -124,6 +129,8 @@ func (t *Task) updateLifecycle(update func(*taskLifecycleState)) {
|
|||||||
t.lifecycleMu.Lock()
|
t.lifecycleMu.Lock()
|
||||||
state := t.lifecycleSnapshotLocked()
|
state := t.lifecycleSnapshotLocked()
|
||||||
update(&state)
|
update(&state)
|
||||||
|
t.Name = state.Name
|
||||||
|
t.PinnedAt = state.PinnedAt
|
||||||
t.Status = state.Status
|
t.Status = state.Status
|
||||||
t.Paused = state.Paused
|
t.Paused = state.Paused
|
||||||
t.Queued = state.Queued
|
t.Queued = state.Queued
|
||||||
@@ -698,6 +705,7 @@ func taskFromPG(pt *pgdb.Task, store *pgdb.ExplorationStore, ic *intercept.Inter
|
|||||||
ID: strconv.FormatInt(pt.ID, 10), ExpID: pt.ExplorationID,
|
ID: strconv.FormatInt(pt.ID, 10), ExpID: pt.ExplorationID,
|
||||||
Name: pt.Name,
|
Name: pt.Name,
|
||||||
CategoryID: cloneInt64Ptr(pt.CategoryID), CategoryName: pt.CategoryName,
|
CategoryID: cloneInt64Ptr(pt.CategoryID), CategoryName: pt.CategoryName,
|
||||||
|
PinnedAt: unixOrZero(pt.PinnedAt),
|
||||||
Description: pt.Description, Goal: pt.Goal, CreatedAt: pt.CreatedAt.Unix(), Paused: pt.Paused, Queued: pt.Queued,
|
Description: pt.Description, Goal: pt.Goal, CreatedAt: pt.CreatedAt.Unix(), Paused: pt.Paused, Queued: pt.Queued,
|
||||||
QueuedAt: unixNanoOrZero(pt.QueuedAt), QueueMode: pt.QueueMode,
|
QueuedAt: unixNanoOrZero(pt.QueuedAt), QueueMode: pt.QueueMode,
|
||||||
CompletedAt: unixOrZero(pt.CompletedAt), Status: pt.Status, ParentRef: pt.ParentRef,
|
CompletedAt: unixOrZero(pt.CompletedAt), Status: pt.Status, ParentRef: pt.ParentRef,
|
||||||
@@ -714,6 +722,32 @@ func taskFromPG(pt *pgdb.Task, store *pgdb.ExplorationStore, ic *intercept.Inter
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateTaskMetadata changes list-only task metadata without interrupting any
|
||||||
|
// planner, main-agent, or worker call.
|
||||||
|
func (m *Manager) UpdateTaskMetadata(taskID string, patch pgdb.TaskPatch) (*Task, error) {
|
||||||
|
m.taskStateMu.Lock()
|
||||||
|
defer m.taskStateMu.Unlock()
|
||||||
|
id, err := strconv.ParseInt(taskID, 10, 64)
|
||||||
|
if err != nil || id <= 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
updated, err := m.pg.UpdateTask(id, patch)
|
||||||
|
if err != nil || updated == nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
m.mu.RLock()
|
||||||
|
task := m.tasks[taskID]
|
||||||
|
m.mu.RUnlock()
|
||||||
|
if task == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
task.updateLifecycle(func(state *taskLifecycleState) {
|
||||||
|
state.Name = updated.Name
|
||||||
|
state.PinnedAt = unixOrZero(updated.PinnedAt)
|
||||||
|
})
|
||||||
|
return task, nil
|
||||||
|
}
|
||||||
|
|
||||||
// CreateTask creates a task + its exploration and makes it active.
|
// CreateTask creates a task + its exploration and makes it active.
|
||||||
// timeoutSeconds is the task-level wall-clock budget (0 = 不限时).
|
// timeoutSeconds is the task-level wall-clock budget (0 = 不限时).
|
||||||
func (m *Manager) CreateTask(description, goal string, llmProfileID *int64, timeoutSeconds, planHeartbeatSeconds int) (*Task, error) {
|
func (m *Manager) CreateTask(description, goal string, llmProfileID *int64, timeoutSeconds, planHeartbeatSeconds int) (*Task, error) {
|
||||||
@@ -1558,10 +1592,17 @@ func (m *Manager) List() []*Task {
|
|||||||
for _, t := range m.tasks {
|
for _, t := range m.tasks {
|
||||||
out = append(out, t)
|
out = append(out, t)
|
||||||
}
|
}
|
||||||
// 按 id 降序(最新插入在最前)。m.tasks 是 map,遍历顺序随机,每次轮询都要重排;
|
// 置顶任务优先,组内按置顶时间倒序;普通任务按 id 倒序。m.tasks 是 map,
|
||||||
// 若按 CreatedAt 排,同一秒创建的任务时间戳相同,排序不稳定,会在列表里频繁换
|
// 每次轮询都必须重排,id 则为同刻创建任务提供稳定且唯一的兜底顺序。
|
||||||
// 位置。id 由 BIGSERIAL 单调递增分配,唯一且稳定,顺序恒定。
|
|
||||||
sort.Slice(out, func(i, j int) bool {
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
iState := out[i].lifecycleSnapshot()
|
||||||
|
jState := out[j].lifecycleSnapshot()
|
||||||
|
if (iState.PinnedAt > 0) != (jState.PinnedAt > 0) {
|
||||||
|
return iState.PinnedAt > 0
|
||||||
|
}
|
||||||
|
if iState.PinnedAt != jState.PinnedAt {
|
||||||
|
return iState.PinnedAt > jState.PinnedAt
|
||||||
|
}
|
||||||
ai, _ := strconv.ParseInt(out[i].ID, 10, 64)
|
ai, _ := strconv.ParseInt(out[i].ID, 10, 64)
|
||||||
aj, _ := strconv.ParseInt(out[j].ID, 10, 64)
|
aj, _ := strconv.ParseInt(out[j].ID, 10, 64)
|
||||||
return ai > aj
|
return ai > aj
|
||||||
|
|||||||
@@ -687,6 +687,7 @@ func (s *Server) Handler() http.Handler {
|
|||||||
mux.HandleFunc("PATCH /api/task-templates/{id}", s.pgUpdateTaskTemplate)
|
mux.HandleFunc("PATCH /api/task-templates/{id}", s.pgUpdateTaskTemplate)
|
||||||
mux.HandleFunc("DELETE /api/task-templates/{id}", s.pgDeleteTaskTemplate)
|
mux.HandleFunc("DELETE /api/task-templates/{id}", s.pgDeleteTaskTemplate)
|
||||||
mux.HandleFunc("GET /api/tasks/{id}", s.getTask)
|
mux.HandleFunc("GET /api/tasks/{id}", s.getTask)
|
||||||
|
mux.HandleFunc("PATCH /api/tasks/{id}", s.updateTaskMetadata)
|
||||||
mux.HandleFunc("PATCH /api/tasks/{id}/category", s.updateTaskCategory)
|
mux.HandleFunc("PATCH /api/tasks/{id}/category", s.updateTaskCategory)
|
||||||
mux.HandleFunc("POST /api/tasks/control/batch", s.controlTasksBatch)
|
mux.HandleFunc("POST /api/tasks/control/batch", s.controlTasksBatch)
|
||||||
mux.HandleFunc("GET /api/tasks/{id}/coverage", s.taskCoverage)
|
mux.HandleFunc("GET /api/tasks/{id}/coverage", s.taskCoverage)
|
||||||
@@ -791,6 +792,7 @@ func (s *Server) Handler() http.Handler {
|
|||||||
// conversations (chat page)
|
// conversations (chat page)
|
||||||
mux.HandleFunc("GET /api/conversations", s.pgListConversations)
|
mux.HandleFunc("GET /api/conversations", s.pgListConversations)
|
||||||
mux.HandleFunc("POST /api/conversations", s.pgCreateConversation)
|
mux.HandleFunc("POST /api/conversations", s.pgCreateConversation)
|
||||||
|
mux.HandleFunc("POST /api/conversations/delete/batch", s.pgDeleteConversationsBatch)
|
||||||
mux.HandleFunc("PATCH /api/conversations/{id}", s.pgRenameConversation)
|
mux.HandleFunc("PATCH /api/conversations/{id}", s.pgRenameConversation)
|
||||||
mux.HandleFunc("PATCH /api/conversations/{id}/profile", s.pgUpdateConversation)
|
mux.HandleFunc("PATCH /api/conversations/{id}/profile", s.pgUpdateConversation)
|
||||||
mux.HandleFunc("DELETE /api/conversations/{id}", s.pgDeleteConversation)
|
mux.HandleFunc("DELETE /api/conversations/{id}", s.pgDeleteConversation)
|
||||||
|
|||||||
@@ -734,6 +734,16 @@ func (s *Server) pgListTools(w http.ResponseWriter, r *http.Request) {
|
|||||||
if ts == nil {
|
if ts == nil {
|
||||||
ts = []*db.Tool{}
|
ts = []*db.Tool{}
|
||||||
}
|
}
|
||||||
|
// Usage is best-effort decoration. Runtime tool resolution keeps using the plain
|
||||||
|
// catalog query, so agent assembly never pays for this aggregate.
|
||||||
|
counts, countErr := pg.ToolUsageCounts()
|
||||||
|
if countErr != nil {
|
||||||
|
log.Printf("[tools] 读取调用统计失败: %v", countErr)
|
||||||
|
} else {
|
||||||
|
for _, tool := range ts {
|
||||||
|
tool.Calls = counts[tool.Key]
|
||||||
|
}
|
||||||
|
}
|
||||||
writeJSON(w, 200, map[string]any{"tools": ts})
|
writeJSON(w, 200, map[string]any{"tools": ts})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/Autumn-27/artex/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxTaskNameRunes = 200
|
||||||
|
maxTaskMetadataRequestSize = 16 << 10
|
||||||
|
)
|
||||||
|
|
||||||
|
// updateTaskMetadata changes list-only task metadata. It intentionally does not
|
||||||
|
// touch lifecycle state, scheduling, or the task's immutable description/goal.
|
||||||
|
func (s *Server) updateTaskMetadata(w http.ResponseWriter, r *http.Request) {
|
||||||
|
taskID := r.PathValue("id")
|
||||||
|
if _, ok := s.m.Task(taskID); !ok {
|
||||||
|
writeErr(w, http.StatusNotFound, "task not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, maxTaskMetadataRequestSize)
|
||||||
|
var request struct {
|
||||||
|
Name *string `json:"name"`
|
||||||
|
Pinned *bool `json:"pinned"`
|
||||||
|
}
|
||||||
|
if err := decode(r, &request); err != nil {
|
||||||
|
var tooLarge *http.MaxBytesError
|
||||||
|
if errors.As(err, &tooLarge) {
|
||||||
|
writeErr(w, http.StatusRequestEntityTooLarge, "请求正文过大")
|
||||||
|
} else {
|
||||||
|
writeErr(w, http.StatusBadRequest, err.Error())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if request.Name == nil && request.Pinned == nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, "至少需要提供 name 或 pinned")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if request.Name != nil {
|
||||||
|
name := strings.TrimSpace(*request.Name)
|
||||||
|
if name == "" {
|
||||||
|
writeErr(w, http.StatusBadRequest, "任务名称不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if utf8.RuneCountInString(name) > maxTaskNameRunes {
|
||||||
|
writeErr(w, http.StatusBadRequest, fmt.Sprintf("任务名称最多 %d 个字符", maxTaskNameRunes))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
request.Name = &name
|
||||||
|
}
|
||||||
|
task, err := s.m.UpdateTaskMetadata(taskID, db.TaskPatch{Name: request.Name, Pinned: request.Pinned})
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if task == nil {
|
||||||
|
writeErr(w, http.StatusNotFound, "task not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, taskDTO(task, s.resolvedTaskStatus(task)))
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTaskMetadataPatchReturnsRenameAndPin(t *testing.T) {
|
||||||
|
m, err := NewManager(t.TempDir(), "")
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("postgres unavailable (%v) — skipping", err)
|
||||||
|
}
|
||||||
|
defer m.Close()
|
||||||
|
task, err := m.CreateTask("metadata patch", "goal", nil, 0, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
taskID, _ := strconv.ParseInt(task.ID, 10, 64)
|
||||||
|
defer func() { _ = m.pg.DeleteTask(taskID) }()
|
||||||
|
s := New(context.Background(), m, t.TempDir(), t.TempDir(), t.TempDir())
|
||||||
|
token, err := signJWT(s.jwtKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]any{"name": " renamed task ", "pinned": true})
|
||||||
|
req := httptest.NewRequest(http.MethodPatch, "/api/tasks/"+task.ID, bytes.NewReader(body))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
s.Handler().ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("patch status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var updated struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Pinned bool `json:"pinned"`
|
||||||
|
PinnedAt string `json:"pinned_at"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &updated); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if updated.Name != "renamed task" || !updated.Pinned || updated.PinnedAt == "" {
|
||||||
|
t.Fatalf("unexpected task patch response: %+v", updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, _ = json.Marshal(map[string]any{"name": strings.Repeat("任", maxTaskNameRunes+1)})
|
||||||
|
req = httptest.NewRequest(http.MethodPatch, "/api/tasks/"+task.ID, bytes.NewReader(body))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
s.Handler().ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("overlong task name status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConversationBatchDeleteReportsMissing(t *testing.T) {
|
||||||
|
m, err := NewManager(t.TempDir(), "")
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("postgres unavailable (%v) — skipping", err)
|
||||||
|
}
|
||||||
|
defer m.Close()
|
||||||
|
s := New(context.Background(), m, t.TempDir(), t.TempDir(), t.TempDir())
|
||||||
|
first, err := m.pg.CreateConversation("mainagent", "batch-http-first", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
second, err := m.pg.CreateConversation("mainagent", "batch-http-second", nil)
|
||||||
|
if err != nil {
|
||||||
|
_ = m.pg.DeleteConversation(first.ID)
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = m.pg.DeleteConversation(first.ID)
|
||||||
|
_ = m.pg.DeleteConversation(second.ID)
|
||||||
|
}()
|
||||||
|
token, err := signJWT(s.jwtKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
missing := int64(1<<62 - 1)
|
||||||
|
body, _ := json.Marshal(map[string]any{"ids": []int64{first.ID, missing, second.ID}})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/conversations/delete/batch", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
s.Handler().ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("batch delete status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var response struct {
|
||||||
|
Items []conversationDeleteItem `json:"items"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(response.Items) != 3 || !response.Items[0].OK || response.Items[1].OK || !response.Items[2].OK {
|
||||||
|
t.Fatalf("batch delete response=%+v", response.Items)
|
||||||
|
}
|
||||||
|
for _, id := range []int64{first.ID, second.ID} {
|
||||||
|
if conversation, err := m.pg.GetConversation(id); err != nil || conversation != nil {
|
||||||
|
t.Fatalf("conversation %d still exists: %+v err=%v", id, conversation, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if response.Items[1].Error == "" {
|
||||||
|
t.Fatalf("missing conversation %s must include an error", fmt.Sprint(missing))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
actool "github.com/Autumn-27/norma/tool"
|
||||||
|
|
||||||
|
"github.com/Autumn-27/artex/agent"
|
||||||
|
"github.com/Autumn-27/artex/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
type toolUsageRecorder interface {
|
||||||
|
InsertToolUsage(*db.ToolUsage) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// meteredTool records one row immediately before a catalog tool is invoked. It
|
||||||
|
// embeds the resolved tool so schema overrides, defaults and permission behavior
|
||||||
|
// remain unchanged.
|
||||||
|
type meteredTool struct {
|
||||||
|
actool.CoreTool
|
||||||
|
recorder toolUsageRecorder
|
||||||
|
toolKey string
|
||||||
|
agentKey string
|
||||||
|
ri agent.RunInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
func meterTool(t actool.CoreTool, recorder toolUsageRecorder, toolKey, agentKey string, ri agent.RunInfo) actool.CoreTool {
|
||||||
|
if recorder == nil {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
return &meteredTool{CoreTool: t, recorder: recorder, toolKey: toolKey, agentKey: agentKey, ri: ri}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *meteredTool) Call(ctx context.Context, input json.RawMessage, tc *actool.ToolContext) (actool.Result, error) {
|
||||||
|
if err := m.recorder.InsertToolUsage(&db.ToolUsage{
|
||||||
|
ToolKey: m.toolKey,
|
||||||
|
AgentKey: m.agentKey,
|
||||||
|
TaskID: m.ri.TaskID,
|
||||||
|
ExplorationID: m.ri.ExplorationID,
|
||||||
|
IntentID: m.ri.IntentID,
|
||||||
|
SessionID: m.ri.SessionID,
|
||||||
|
}); err != nil {
|
||||||
|
log.Printf("[toolusage] insert %s: %v", m.toolKey, err)
|
||||||
|
}
|
||||||
|
return m.CoreTool.Call(ctx, input, tc)
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
actool "github.com/Autumn-27/norma/tool"
|
||||||
|
|
||||||
|
"github.com/Autumn-27/artex/agent"
|
||||||
|
"github.com/Autumn-27/artex/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
type recordingToolUsage struct {
|
||||||
|
rows []*db.ToolUsage
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *recordingToolUsage) InsertToolUsage(row *db.ToolUsage) error {
|
||||||
|
copy := *row
|
||||||
|
r.rows = append(r.rows, ©)
|
||||||
|
return r.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMeterToolRecordsAttributionAndDelegates(t *testing.T) {
|
||||||
|
calls := 0
|
||||||
|
base := actool.Build(actool.Spec{
|
||||||
|
Name: "record_fact",
|
||||||
|
Run: func(context.Context, json.RawMessage, *actool.ToolContext) (actool.Result, error) {
|
||||||
|
calls++
|
||||||
|
return actool.Result{}, nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
recorder := &recordingToolUsage{}
|
||||||
|
ri := agent.RunInfo{TaskID: 42, ExplorationID: 8, IntentID: 9, SessionID: "session-1"}
|
||||||
|
wrapped := meterTool(base, recorder, "record_fact", "worker", ri)
|
||||||
|
|
||||||
|
if _, err := wrapped.Call(context.Background(), json.RawMessage(`{"secret":"not persisted"}`), nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if calls != 1 {
|
||||||
|
t.Fatalf("delegate calls: want 1, got %d", calls)
|
||||||
|
}
|
||||||
|
if len(recorder.rows) != 1 {
|
||||||
|
t.Fatalf("usage rows: want 1, got %d", len(recorder.rows))
|
||||||
|
}
|
||||||
|
got := recorder.rows[0]
|
||||||
|
if got.ToolKey != "record_fact" || got.AgentKey != "worker" || got.TaskID != 42 ||
|
||||||
|
got.ExplorationID != 8 || got.IntentID != 9 || got.SessionID != "session-1" {
|
||||||
|
t.Fatalf("unexpected attribution: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMeterToolFailureDoesNotBreakInvocation(t *testing.T) {
|
||||||
|
calls := 0
|
||||||
|
base := actool.Build(actool.Spec{
|
||||||
|
Name: "list_facts",
|
||||||
|
Run: func(context.Context, json.RawMessage, *actool.ToolContext) (actool.Result, error) {
|
||||||
|
calls++
|
||||||
|
return actool.Result{}, nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
recorder := &recordingToolUsage{err: errors.New("ledger unavailable")}
|
||||||
|
wrapped := meterTool(base, recorder, "list_facts", "worker", agent.RunInfo{})
|
||||||
|
|
||||||
|
if _, err := wrapped.Call(context.Background(), nil, nil); err != nil {
|
||||||
|
t.Fatalf("metering error leaked into tool call: %v", err)
|
||||||
|
}
|
||||||
|
if calls != 1 {
|
||||||
|
t.Fatalf("delegate calls: want 1, got %d", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -761,6 +762,8 @@ function ConversationItem({
|
|||||||
onCancelRename,
|
onCancelRename,
|
||||||
onTogglePinned,
|
onTogglePinned,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
selectedForDelete,
|
||||||
|
onSelectedForDeleteChange,
|
||||||
}: {
|
}: {
|
||||||
conv: Conversation;
|
conv: Conversation;
|
||||||
agent?: Agent;
|
agent?: Agent;
|
||||||
@@ -774,6 +777,8 @@ function ConversationItem({
|
|||||||
onCancelRename: () => void;
|
onCancelRename: () => void;
|
||||||
onTogglePinned: () => void;
|
onTogglePinned: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
|
selectedForDelete: boolean;
|
||||||
|
onSelectedForDeleteChange: (checked: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const [deleteOpen, setDeleteOpen] = React.useState(false);
|
const [deleteOpen, setDeleteOpen] = React.useState(false);
|
||||||
const renameInputRef = React.useRef<HTMLInputElement>(null);
|
const renameInputRef = React.useRef<HTMLInputElement>(null);
|
||||||
@@ -794,6 +799,12 @@ function ConversationItem({
|
|||||||
active ? "bg-accent text-accent-foreground" : "hover:bg-accent/50",
|
active ? "bg-accent text-accent-foreground" : "hover:bg-accent/50",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedForDelete}
|
||||||
|
onCheckedChange={(checked) => onSelectedForDeleteChange(checked === true)}
|
||||||
|
aria-label={`选择对话「${conv.title || "新对话"}」`}
|
||||||
|
className="ml-1 shrink-0"
|
||||||
|
/>
|
||||||
{renaming ? (
|
{renaming ? (
|
||||||
<input
|
<input
|
||||||
ref={renameInputRef}
|
ref={renameInputRef}
|
||||||
@@ -894,6 +905,9 @@ export default function ChatPage() {
|
|||||||
const [selectedId, setSelectedId] = React.useState<number | null>(null);
|
const [selectedId, setSelectedId] = React.useState<number | null>(null);
|
||||||
const [renamingId, setRenamingId] = React.useState<number | null>(null);
|
const [renamingId, setRenamingId] = React.useState<number | null>(null);
|
||||||
const [renameText, setRenameText] = React.useState("");
|
const [renameText, setRenameText] = React.useState("");
|
||||||
|
const [selectedConversationIds, setSelectedConversationIds] = React.useState<Set<number>>(() => new Set());
|
||||||
|
const [bulkDeleteOpen, setBulkDeleteOpen] = React.useState(false);
|
||||||
|
const [bulkDeleting, setBulkDeleting] = React.useState(false);
|
||||||
// Pending text + uploaded attachments handed off from a draft that created a
|
// Pending text + uploaded attachments handed off from a draft that created a
|
||||||
// conversation via the paperclip, keyed by the new conversation id (consumed once
|
// conversation via the paperclip, keyed by the new conversation id (consumed once
|
||||||
// by ChatView on mount; ids never repeat, so leftover entries are harmless).
|
// by ChatView on mount; ids never repeat, so leftover entries are harmless).
|
||||||
@@ -923,6 +937,15 @@ export default function ChatPage() {
|
|||||||
reloadConvs();
|
reloadConvs();
|
||||||
}, [reloadConvs]);
|
}, [reloadConvs]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setSelectedConversationIds((current) => {
|
||||||
|
if (current.size === 0) return current;
|
||||||
|
const live = new Set(convs.map((conversation) => conversation.id));
|
||||||
|
const next = new Set([...current].filter((id) => live.has(id)));
|
||||||
|
return next.size === current.size ? current : next;
|
||||||
|
});
|
||||||
|
}, [convs]);
|
||||||
|
|
||||||
// Restore the open conversation from the URL (?c=<id>) on mount, so a refresh
|
// Restore the open conversation from the URL (?c=<id>) on mount, so a refresh
|
||||||
// returns to the same thread instead of the empty draft view. Runs after
|
// returns to the same thread instead of the empty draft view. Runs after
|
||||||
// hydration (not a lazy useState init) to avoid a server/client mismatch.
|
// hydration (not a lazy useState init) to avoid a server/client mismatch.
|
||||||
@@ -948,16 +971,80 @@ export default function ChatPage() {
|
|||||||
// are task-specific and stay hidden from the chat page.
|
// are task-specific and stay hidden from the chat page.
|
||||||
const chatAgents = agents.filter((a) => !a.builtin || a.role === "assistant");
|
const chatAgents = agents.filter((a) => !a.builtin || a.role === "assistant");
|
||||||
|
|
||||||
|
const selectedConversationCount = selectedConversationIds.size;
|
||||||
|
const allConversationsSelected = convs.length > 0 && selectedConversationCount === convs.length;
|
||||||
|
const someConversationsSelected = selectedConversationCount > 0 && !allConversationsSelected;
|
||||||
|
let conversationHeaderChecked: boolean | "indeterminate" = false;
|
||||||
|
if (allConversationsSelected) conversationHeaderChecked = true;
|
||||||
|
else if (someConversationsSelected) conversationHeaderChecked = "indeterminate";
|
||||||
|
|
||||||
|
function toggleConversationSelected(id: number, checked: boolean) {
|
||||||
|
setSelectedConversationIds((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
if (checked) next.add(id);
|
||||||
|
else next.delete(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAllConversations(checked: boolean) {
|
||||||
|
setSelectedConversationIds(checked ? new Set(convs.map((conversation) => conversation.id)) : new Set());
|
||||||
|
}
|
||||||
|
|
||||||
async function del(id: number) {
|
async function del(id: number) {
|
||||||
try {
|
try {
|
||||||
await api.deleteConversation(id);
|
await api.deleteConversation(id);
|
||||||
if (selectedId === id) setSelectedId(null);
|
if (selectedId === id) setSelectedId(null);
|
||||||
|
setSelectedConversationIds((current) => {
|
||||||
|
if (!current.has(id)) return current;
|
||||||
|
const next = new Set(current);
|
||||||
|
next.delete(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
reloadConvs();
|
reloadConvs();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error("删除失败:" + (e as Error).message);
|
toast.error("删除失败:" + (e as Error).message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function deleteSelectedConversations() {
|
||||||
|
const ids = [...selectedConversationIds];
|
||||||
|
if (ids.length === 0 || bulkDeleting) return;
|
||||||
|
setBulkDeleting(true);
|
||||||
|
const deleted = new Set<number>();
|
||||||
|
const failed: { id: number; error: string }[] = [];
|
||||||
|
try {
|
||||||
|
for (let offset = 0; offset < ids.length; offset += 100) {
|
||||||
|
const result = await api.deleteConversations(ids.slice(offset, offset + 100));
|
||||||
|
for (const item of result.items) {
|
||||||
|
if (item.ok) deleted.add(item.id);
|
||||||
|
else failed.push({ id: item.id, error: item.error ?? "对话不存在" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (deleted.has(selectedId ?? -1)) setSelectedId(null);
|
||||||
|
setSelectedConversationIds((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
for (const id of deleted) next.delete(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
if (deleted.size > 0) toast.success(`已删除 ${deleted.size} 个对话`);
|
||||||
|
if (failed.length > 0) {
|
||||||
|
const details = failed
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((item) => `#${item.id}(${item.error})`)
|
||||||
|
.join(";");
|
||||||
|
toast.error(`${failed.length} 个对话删除失败:${details}${failed.length > 3 ? " 等" : ""}`);
|
||||||
|
}
|
||||||
|
setBulkDeleteOpen(false);
|
||||||
|
reloadConvs();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(`批量删除失败:${(error as Error).message}`);
|
||||||
|
reloadConvs();
|
||||||
|
} finally {
|
||||||
|
setBulkDeleting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function togglePinned(conversation: Conversation) {
|
async function togglePinned(conversation: Conversation) {
|
||||||
const pinned = conversationIsPinned(conversation);
|
const pinned = conversationIsPinned(conversation);
|
||||||
try {
|
try {
|
||||||
@@ -993,10 +1080,33 @@ export default function ChatPage() {
|
|||||||
<div className="grid min-h-0 min-w-0 flex-1 grid-cols-1 grid-rows-[minmax(10rem,15rem)_minmax(0,1fr)] gap-3 md:grid-cols-[18rem_minmax(0,1fr)] md:grid-rows-[minmax(0,1fr)] md:gap-4">
|
<div className="grid min-h-0 min-w-0 flex-1 grid-cols-1 grid-rows-[minmax(10rem,15rem)_minmax(0,1fr)] gap-3 md:grid-cols-[18rem_minmax(0,1fr)] md:grid-rows-[minmax(0,1fr)] md:gap-4">
|
||||||
{/* left: conversation list */}
|
{/* left: conversation list */}
|
||||||
<div className="bg-card flex flex-col overflow-hidden rounded-lg border">
|
<div className="bg-card flex flex-col overflow-hidden rounded-lg border">
|
||||||
<div className="border-b p-2">
|
<div className="flex flex-col gap-2 border-b p-2">
|
||||||
<Button size="sm" className="w-full" onClick={() => setSelectedId(null)}>
|
<Button size="sm" className="w-full" onClick={() => setSelectedId(null)}>
|
||||||
<PlusIcon /> 新建对话
|
<PlusIcon /> 新建对话
|
||||||
</Button>
|
</Button>
|
||||||
|
{convs.length > 0 && (
|
||||||
|
<div className="flex items-center gap-2 px-1">
|
||||||
|
<Checkbox
|
||||||
|
checked={conversationHeaderChecked}
|
||||||
|
onCheckedChange={(checked) => toggleAllConversations(checked === true)}
|
||||||
|
aria-label="选择全部对话"
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground min-w-0 flex-1 text-xs tabular-nums">
|
||||||
|
{selectedConversationCount > 0 ? `已选 ${selectedConversationCount} 个` : `共 ${convs.length} 个`}
|
||||||
|
</span>
|
||||||
|
{selectedConversationCount > 0 && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
disabled={bulkDeleting}
|
||||||
|
onClick={() => setBulkDeleteOpen(true)}
|
||||||
|
>
|
||||||
|
<Trash2Icon data-icon="inline-start" />
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<ScrollArea type="auto" className="min-h-0 min-w-0 flex-1 [&_[data-slot=scroll-area-viewport]>div]:block!">
|
<ScrollArea type="auto" className="min-h-0 min-w-0 flex-1 [&_[data-slot=scroll-area-viewport]>div]:block!">
|
||||||
<div className="flex min-w-0 flex-col gap-0.5 p-2">
|
<div className="flex min-w-0 flex-col gap-0.5 p-2">
|
||||||
@@ -1016,6 +1126,8 @@ export default function ChatPage() {
|
|||||||
onCancelRename={() => setRenamingId(null)}
|
onCancelRename={() => setRenamingId(null)}
|
||||||
onTogglePinned={() => void togglePinned(c)}
|
onTogglePinned={() => void togglePinned(c)}
|
||||||
onDelete={() => del(c.id)}
|
onDelete={() => del(c.id)}
|
||||||
|
selectedForDelete={selectedConversationIds.has(c.id)}
|
||||||
|
onSelectedForDeleteChange={(checked) => toggleConversationSelected(c.id, checked)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -1056,6 +1168,28 @@ export default function ChatPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<AlertDialog open={bulkDeleteOpen} onOpenChange={setBulkDeleteOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>删除选中的 {selectedConversationCount} 个对话?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>对话消息和执行记录将一并删除,此操作不可撤销。</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={bulkDeleting}>取消</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
variant="destructive"
|
||||||
|
disabled={bulkDeleting || selectedConversationCount === 0}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
void deleteSelectedConversations();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{bulkDeleting && <Loader2Icon data-icon="inline-start" className="animate-spin" />}
|
||||||
|
{bulkDeleting ? "删除中" : "确认删除"}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ArrowUpRightIcon, ChevronRightIcon } from "lucide-react";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { ArrowDownIcon, ArrowUpIcon, ArrowUpRightIcon, ChevronRightIcon } from "lucide-react";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { toast } from "sonner";
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { StatusBadge } from "@/components/status-badge";
|
import { StatusBadge } from "@/components/status-badge";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -15,9 +18,21 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { toast } from "sonner";
|
import { useStoredSortPreference } from "@/lib/sort-preference";
|
||||||
import { statusMeta } from "@/lib/status";
|
import { statusMeta } from "@/lib/status";
|
||||||
import type { Finding, FindingStatus } from "@/lib/types";
|
import type { Finding, FindingStatus } from "@/lib/types";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type FindingSortField = "time";
|
||||||
|
|
||||||
|
const FINDING_SORT_FIELDS: readonly FindingSortField[] = ["time"];
|
||||||
|
const FINDING_SORT_PREFERENCE_KEY = "artex_task_findings_sort";
|
||||||
|
|
||||||
|
function findingLabel(finding: Finding): string {
|
||||||
|
if (finding.name?.trim()) return finding.name;
|
||||||
|
if (finding.vulnclass?.trim()) return finding.vulnclass;
|
||||||
|
return "未分类";
|
||||||
|
}
|
||||||
|
|
||||||
const FINDING_STATUSES: FindingStatus[] = [
|
const FINDING_STATUSES: FindingStatus[] = [
|
||||||
"pending",
|
"pending",
|
||||||
@@ -57,9 +72,7 @@ function Row({
|
|||||||
<StatusBadge domain="severity" value={f.severity} dot />
|
<StatusBadge domain="severity" value={f.severity} dot />
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<span className="truncate font-medium">
|
<span className="truncate font-medium">{findingLabel(f)}</span>
|
||||||
{f.name || f.vulnclass || "未分类"}
|
|
||||||
</span>
|
|
||||||
{f.inherited && f.source_task_id && (
|
{f.inherited && f.source_task_id && (
|
||||||
<Badge variant="outline" className="shrink-0">
|
<Badge variant="outline" className="shrink-0">
|
||||||
来源 #{f.source_task_id} · 只读
|
来源 #{f.source_task_id} · 只读
|
||||||
@@ -147,6 +160,12 @@ function Row({
|
|||||||
|
|
||||||
export function FindingsTab({ taskId }: { taskId: string }) {
|
export function FindingsTab({ taskId }: { taskId: string }) {
|
||||||
const [findings, setFindings] = React.useState<Finding[]>([]);
|
const [findings, setFindings] = React.useState<Finding[]>([]);
|
||||||
|
const [sortPreference, setSortPreference] = useStoredSortPreference(
|
||||||
|
FINDING_SORT_PREFERENCE_KEY,
|
||||||
|
FINDING_SORT_FIELDS,
|
||||||
|
"time",
|
||||||
|
"desc",
|
||||||
|
);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
@@ -156,7 +175,9 @@ export function FindingsTab({ taskId }: { taskId: string }) {
|
|||||||
.then((fs) => {
|
.then((fs) => {
|
||||||
if (active) setFindings(fs);
|
if (active) setFindings(fs);
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {
|
||||||
|
// Keep the last successful snapshot during transient refresh failures.
|
||||||
|
});
|
||||||
};
|
};
|
||||||
load();
|
load();
|
||||||
const t = setInterval(load, 3000);
|
const t = setInterval(load, 3000);
|
||||||
@@ -189,13 +210,35 @@ export function FindingsTab({ taskId }: { taskId: string }) {
|
|||||||
const items = findings
|
const items = findings
|
||||||
.filter((f) => f.task_id === taskId || f.inherited)
|
.filter((f) => f.task_id === taskId || f.inherited)
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const order = { critical: 0, high: 1, medium: 2, low: 3 };
|
const compared = Date.parse(a.ts) - Date.parse(b.ts);
|
||||||
return order[a.severity] - order[b.severity];
|
if (compared !== 0) return sortPreference.direction === "asc" ? compared : -compared;
|
||||||
|
return b.id.localeCompare(a.id, undefined, { numeric: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="overflow-hidden py-0">
|
<Card className="overflow-hidden py-0">
|
||||||
<CardContent className="px-0">
|
<CardContent className="px-0">
|
||||||
|
<div className="flex items-center border-b px-4 py-2 text-xs text-muted-foreground">
|
||||||
|
<span className="min-w-0 flex-1">漏洞</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-1 outline-none focus-visible:underline"
|
||||||
|
aria-label={`发现时间当前${sortPreference.direction === "asc" ? "正序" : "倒序"},点击切换排序方向`}
|
||||||
|
onClick={() =>
|
||||||
|
setSortPreference((current) => ({
|
||||||
|
field: "time",
|
||||||
|
direction: current.direction === "asc" ? "desc" : "asc",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span>发现时间</span>
|
||||||
|
{sortPreference.direction === "asc" ? (
|
||||||
|
<ArrowUpIcon className="size-3.5" />
|
||||||
|
) : (
|
||||||
|
<ArrowDownIcon className="size-3.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{items.map((f) => (
|
{items.map((f) => (
|
||||||
<Row key={f.id} f={f} contextTaskId={taskId} onStatus={onStatus} />
|
<Row key={f.id} f={f} contextTaskId={taskId} onStatus={onStatus} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -430,24 +430,34 @@ function SessionItem({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function truncateWorkerAssetLabel(value: string, maxChars = 30): string {
|
||||||
|
const chars = Array.from(value.trim());
|
||||||
|
if (chars.length <= maxChars) return chars.join("");
|
||||||
|
return `${chars.slice(0, Math.max(0, maxChars - 1)).join("")}…`;
|
||||||
|
}
|
||||||
|
|
||||||
function WorkerAssetBadge({ assets }: { assets: IntentAsset[] }) {
|
function WorkerAssetBadge({ assets }: { assets: IntentAsset[] }) {
|
||||||
const first = assets[0];
|
const displayAssets = assets.filter(
|
||||||
const firstLabel = first.label.trim() ? first.label : `#${first.asset_id}`;
|
(asset) => asset.type === "root_domain" || asset.type === "subdomain" || asset.type === "ip",
|
||||||
|
);
|
||||||
|
if (displayAssets.length === 0) return null;
|
||||||
|
|
||||||
|
const first = displayAssets[0];
|
||||||
|
const firstRawLabel = first.label.trim() || `#${first.asset_id}`;
|
||||||
|
const firstLabel = truncateWorkerAssetLabel(firstRawLabel);
|
||||||
return (
|
return (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Badge variant="outline" className="max-w-28 shrink-0 font-normal">
|
<Badge variant="outline" className="max-w-60 shrink-0 font-normal" title={firstRawLabel}>
|
||||||
<span className="truncate">当前资产:{firstLabel}</span>
|
<span className="truncate">当前资产:{firstLabel}</span>
|
||||||
{assets.length > 1 && <span className="shrink-0 tabular-nums">+{assets.length - 1}</span>}
|
{displayAssets.length > 1 && <span className="shrink-0 tabular-nums">+{displayAssets.length - 1}</span>}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right" align="start" className="max-w-sm">
|
<TooltipContent side="right" align="start" className="max-w-sm">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{assets.map((asset) => (
|
{displayAssets.map((asset) => (
|
||||||
<div key={`${asset.intent_id}-${asset.asset_id}`} className="min-w-0">
|
<div key={`${asset.intent_id}-${asset.asset_id}`} className="min-w-0">
|
||||||
<div className="break-all font-mono text-xs">
|
<div className="break-all font-mono text-xs">{asset.label.trim() || `#${asset.asset_id}`}</div>
|
||||||
{asset.label.trim() ? asset.label : `#${asset.asset_id}`}
|
|
||||||
</div>
|
|
||||||
<div className="mt-0.5 text-xs text-muted-foreground">
|
<div className="mt-0.5 text-xs text-muted-foreground">
|
||||||
{taskAssetTypeLabel(asset.type)} · {taskAssetSourceLabel(asset.source)}
|
{taskAssetTypeLabel(asset.type)} · {taskAssetSourceLabel(asset.source)}
|
||||||
{asset.inherited ? ` · 来源任务 #${asset.source_task_id}` : ""}
|
{asset.inherited ? ` · 来源任务 #${asset.source_task_id}` : ""}
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -3,14 +3,18 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
CheckIcon,
|
||||||
ChevronLeftIcon,
|
ChevronLeftIcon,
|
||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
|
CopyIcon,
|
||||||
ListChecksIcon,
|
ListChecksIcon,
|
||||||
Loader2Icon,
|
Loader2Icon,
|
||||||
RadioTowerIcon,
|
RadioTowerIcon,
|
||||||
SearchIcon,
|
SearchIcon,
|
||||||
Trash2Icon,
|
Trash2Icon,
|
||||||
|
WrapTextIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
@@ -32,6 +36,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
|||||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import type { TrafficDetail, TrafficExchange, TrafficHost, TrafficResp } from "@/lib/types";
|
import type { TrafficDetail, TrafficExchange, TrafficHost, TrafficResp } from "@/lib/types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -163,37 +168,122 @@ function MarkupBody({ body }: { body: string }) {
|
|||||||
return parts;
|
return parts;
|
||||||
}
|
}
|
||||||
|
|
||||||
function HighlightedBody({ body }: { body: string }) {
|
type BodyFormat = "json" | "markup" | "plain";
|
||||||
|
|
||||||
|
function detectBodyFormat(body: string): BodyFormat {
|
||||||
const trimmed = body.trimStart();
|
const trimmed = body.trimStart();
|
||||||
if (trimmed.startsWith("{") || trimmed.startsWith("[")) return <JsonBody body={body} />;
|
if (trimmed.startsWith("{") || trimmed.startsWith("[")) return "json";
|
||||||
if (trimmed.startsWith("<")) return <MarkupBody body={body} />;
|
if (trimmed.startsWith("<")) return "markup";
|
||||||
|
return "plain";
|
||||||
|
}
|
||||||
|
|
||||||
|
function HighlightedBody({ body, format }: { body: string; format: BodyFormat }) {
|
||||||
|
if (format === "json") return <JsonBody body={body} />;
|
||||||
|
if (format === "markup") return <MarkupBody body={body} />;
|
||||||
return body;
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
function HttpCodeBlock({ raw }: { raw: string }) {
|
function HttpCodeBlock({ raw }: { raw: string }) {
|
||||||
|
const [wrapLines, setWrapLines] = React.useState(true);
|
||||||
|
const [copied, setCopied] = React.useState(false);
|
||||||
const value = raw || "(空)";
|
const value = raw || "(空)";
|
||||||
const lines = value.replaceAll("\r\n", "\n").split("\n");
|
const lines = value.replaceAll("\r\n", "\n").split("\n");
|
||||||
const separator = lines.indexOf("");
|
const separator = lines.indexOf("");
|
||||||
const headerLines = separator >= 0 ? lines.slice(0, separator) : lines;
|
|
||||||
const body = separator >= 0 ? lines.slice(separator + 1).join("\n") : "";
|
const body = separator >= 0 ? lines.slice(separator + 1).join("\n") : "";
|
||||||
|
const bodyFormat = detectBodyFormat(body);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!copied) return;
|
||||||
|
const timer = window.setTimeout(() => setCopied(false), 1500);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [copied]);
|
||||||
|
|
||||||
|
const copyPacket = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
setCopied(true);
|
||||||
|
} catch {
|
||||||
|
toast.error("复制失败,请使用 Ctrl/Cmd+A 后复制");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderLine = (line: string, index: number) => {
|
||||||
|
if (index === 0) return <StartLine line={line} />;
|
||||||
|
if (separator < 0 || index < separator) return <HeaderLine line={line} />;
|
||||||
|
if (index === separator) return null;
|
||||||
|
return <HighlightedBody body={line} format={bodyFormat} />;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<pre className="min-w-full whitespace-pre p-5 font-mono text-xs leading-relaxed">
|
<div className="relative mx-3 my-4 overflow-hidden rounded-md border bg-background shadow-xs">
|
||||||
<code>
|
<div className="absolute top-2 right-2 flex items-center gap-0.5 rounded-md border bg-background/95 p-0.5 shadow-xs">
|
||||||
{headerLines.map((line, index) => (
|
<Tooltip>
|
||||||
<React.Fragment key={`${index}-${line}`}>
|
<TooltipTrigger asChild>
|
||||||
{index === 0 ? <StartLine line={line} /> : <HeaderLine line={line} />}
|
<Button
|
||||||
{index < headerLines.length - 1 ? "\n" : null}
|
type="button"
|
||||||
</React.Fragment>
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
aria-label={wrapLines ? "关闭自动换行" : "开启自动换行"}
|
||||||
|
aria-pressed={wrapLines}
|
||||||
|
onClick={() => setWrapLines((current) => !current)}
|
||||||
|
>
|
||||||
|
<WrapTextIcon />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">{wrapLines ? "关闭自动换行" : "开启自动换行"}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
aria-label={copied ? "已复制报文" : "复制报文"}
|
||||||
|
onClick={() => void copyPacket()}
|
||||||
|
>
|
||||||
|
{copied ? <CheckIcon /> : <CopyIcon />}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">{copied ? "已复制" : "复制报文"}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
{/* biome-ignore lint/a11y/useSemanticElements: textarea cannot preserve line numbers and syntax-highlighting markup. */}
|
||||||
|
<div
|
||||||
|
role="textbox"
|
||||||
|
aria-label="HTTP 报文代码"
|
||||||
|
aria-multiline="true"
|
||||||
|
aria-readonly="true"
|
||||||
|
tabIndex={0}
|
||||||
|
className="max-h-[calc(100vh-15rem)] min-w-0 overflow-auto bg-background py-3 font-mono text-xs outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== "a") return;
|
||||||
|
event.preventDefault();
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (!selection) return;
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(event.currentTarget);
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{lines.map((line, index) => (
|
||||||
|
<div
|
||||||
|
key={`${index}-${line}`}
|
||||||
|
data-line={index + 1}
|
||||||
|
className="grid min-w-0 grid-cols-[1.5rem_minmax(0,1fr)] leading-relaxed before:sticky before:left-0 before:self-stretch before:border-r before:bg-muted/20 before:px-1 before:text-right before:text-muted-foreground/60 before:content-[attr(data-line)]"
|
||||||
|
>
|
||||||
|
<code
|
||||||
|
className={cn(
|
||||||
|
"min-h-[1lh] min-w-0 pr-16 pl-1.5 [tab-size:4]",
|
||||||
|
wrapLines ? "break-words whitespace-pre-wrap" : "whitespace-pre",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{renderLine(line, index)}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
{separator >= 0 && (
|
</div>
|
||||||
<>
|
</div>
|
||||||
{"\n\n"}
|
|
||||||
<HighlightedBody body={body} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</code>
|
|
||||||
</pre>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -415,8 +415,6 @@ function ProfileSheet({
|
|||||||
<SheetContent
|
<SheetContent
|
||||||
side="right"
|
side="right"
|
||||||
className="flex flex-col gap-0 p-0 data-[side=right]:min-w-[420px] data-[side=right]:sm:max-w-xl"
|
className="flex flex-col gap-0 p-0 data-[side=right]:min-w-[420px] data-[side=right]:sm:max-w-xl"
|
||||||
// 表单填到一半时点外面不该丢内容(Esc / ✕ 仍可关闭)。
|
|
||||||
onInteractOutside={(e) => e.preventDefault()}
|
|
||||||
>
|
>
|
||||||
<SheetHeader className="px-4">
|
<SheetHeader className="px-4">
|
||||||
<SheetTitle className="flex items-center gap-2">
|
<SheetTitle className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -313,6 +313,7 @@ function ToolEditor({
|
|||||||
function ToolGridCard({ tool, onClick }: { tool: Tool; onClick: () => void }) {
|
function ToolGridCard({ tool, onClick }: { tool: Tool; onClick: () => void }) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className="hover:border-primary/50 hover:bg-muted/40 focus-visible:ring-ring flex flex-col gap-2 rounded-lg border p-4 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
className="hover:border-primary/50 hover:bg-muted/40 focus-visible:ring-ring flex flex-col gap-2 rounded-lg border p-4 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
||||||
>
|
>
|
||||||
@@ -337,6 +338,13 @@ function ToolGridCard({ tool, onClick }: { tool: Tool; onClick: () => void }) {
|
|||||||
已停用
|
已停用
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="ml-auto px-1.5 py-0 text-[10px] tabular-nums"
|
||||||
|
title={`累计调用 ${tool.calls ?? 0} 次`}
|
||||||
|
>
|
||||||
|
调用 {tool.calls ?? 0}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground line-clamp-1 h-4 text-xs">
|
<p className="text-muted-foreground line-clamp-1 h-4 text-xs">
|
||||||
{tool.description || "(无描述)"}
|
{tool.description || "(无描述)"}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { Dialog as SheetPrimitive } from "radix-ui"
|
import { Dialog as SheetPrimitive } from "radix-ui"
|
||||||
|
|
||||||
import { cn, radixOverlayWasOpenAtPointerDown } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { XIcon } from "lucide-react"
|
import { XIcon } from "lucide-react"
|
||||||
|
|
||||||
@@ -50,7 +50,6 @@ function SheetContent({
|
|||||||
children,
|
children,
|
||||||
side = "right",
|
side = "right",
|
||||||
showCloseButton = true,
|
showCloseButton = true,
|
||||||
onInteractOutside,
|
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||||
side?: "top" | "right" | "bottom" | "left"
|
side?: "top" | "right" | "bottom" | "left"
|
||||||
@@ -62,18 +61,6 @@ function SheetContent({
|
|||||||
<SheetPrimitive.Content
|
<SheetPrimitive.Content
|
||||||
data-slot="sheet-content"
|
data-slot="sheet-content"
|
||||||
data-side={side}
|
data-side={side}
|
||||||
// 关闭抽屉的唯一条件:点击的是遮罩(灰色背景)本身,且此刻没有任何 Radix 弹层
|
|
||||||
// (Select 下拉等)开着。其余"外部交互"一律挡掉(Esc、右上角 ✕ 仍可关):
|
|
||||||
// · 点弹层里的选项 → target 不是遮罩 → 挡;
|
|
||||||
// · 弹层开着时点抽屉外/遮罩想收起它 → 有弹层开着 → 挡(只收弹层,不关抽屉);
|
|
||||||
// · 弹层收起时焦点移动被 Radix 误判为焦点移出 → target 不是遮罩 → 挡。
|
|
||||||
// (onInteractOutside 在指针/焦点两条路径都会触发。)调用方仍可追加逻辑。
|
|
||||||
onInteractOutside={(e) => {
|
|
||||||
const target = e.detail.originalEvent.target as Element | null
|
|
||||||
const onOverlay = !!target?.closest?.("[data-slot='sheet-overlay']")
|
|
||||||
if (!onOverlay || radixOverlayWasOpenAtPointerDown()) e.preventDefault()
|
|
||||||
onInteractOutside?.(e)
|
|
||||||
}}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10",
|
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10",
|
||||||
className
|
className
|
||||||
|
|||||||
@@ -215,6 +215,9 @@ export const api = {
|
|||||||
coverage_enabled: input.coverageEnabled ?? true, // 默认开;false=关闭资产覆盖度功能
|
coverage_enabled: input.coverageEnabled ?? true, // 默认开;false=关闭资产覆盖度功能
|
||||||
}),
|
}),
|
||||||
taskCategories: () => get<{ categories: TaskCategory[] }>("/task-categories").then((r) => arr(r.categories)),
|
taskCategories: () => get<{ categories: TaskCategory[] }>("/task-categories").then((r) => arr(r.categories)),
|
||||||
|
updateTask: (id: string, input: { name?: string; pinned?: boolean }) => patch<Task>(`/tasks/${id}`, input),
|
||||||
|
renameTask: (id: string, name: string) => patch<Task>(`/tasks/${id}`, { name }),
|
||||||
|
pinTask: (id: string, pinned: boolean) => patch<Task>(`/tasks/${id}`, { pinned }),
|
||||||
createTaskCategory: (name: string) => post<TaskCategory>("/task-categories", { name }),
|
createTaskCategory: (name: string) => post<TaskCategory>("/task-categories", { name }),
|
||||||
renameTaskCategory: (id: number, name: string) => patch<TaskCategory>(`/task-categories/${id}`, { name }),
|
renameTaskCategory: (id: number, name: string) => patch<TaskCategory>(`/task-categories/${id}`, { name }),
|
||||||
deleteTaskCategory: (id: number) => del<{ deleted: number }>(`/task-categories/${id}`),
|
deleteTaskCategory: (id: number) => del<{ deleted: number }>(`/task-categories/${id}`),
|
||||||
@@ -704,6 +707,8 @@ export const api = {
|
|||||||
updateConversationProfile: (id: number, llm_profile_id: number | null) =>
|
updateConversationProfile: (id: number, llm_profile_id: number | null) =>
|
||||||
patch<{ ok: boolean }>(`/conversations/${id}/profile`, { llm_profile_id }),
|
patch<{ ok: boolean }>(`/conversations/${id}/profile`, { llm_profile_id }),
|
||||||
deleteConversation: (id: number) => del<{ deleted: number }>(`/conversations/${id}`),
|
deleteConversation: (id: number) => del<{ deleted: number }>(`/conversations/${id}`),
|
||||||
|
deleteConversations: (ids: number[]) =>
|
||||||
|
post<{ items: { id: number; ok: boolean; error?: string }[] }>("/conversations/delete/batch", { ids }),
|
||||||
// Incremental tail: steps after `since` (id ASC) — live poll + post-send fetch.
|
// Incremental tail: steps after `since` (id ASC) — live poll + post-send fetch.
|
||||||
conversationMessages: (id: number, since = 0) =>
|
conversationMessages: (id: number, since = 0) =>
|
||||||
get<{ items: Activity[]; cursor: number; running: boolean }>(`/conversations/${id}/messages?since=${since}`).then(
|
get<{ items: Activity[]; cursor: number; running: boolean }>(`/conversations/${id}/messages?since=${since}`).then(
|
||||||
|
|||||||
@@ -2935,6 +2935,7 @@ export const tools: Tool[] = [
|
|||||||
agents: ["worker", "mainagent"],
|
agents: ["worker", "mainagent"],
|
||||||
enabled: true,
|
enabled: true,
|
||||||
kind: "builtin",
|
kind: "builtin",
|
||||||
|
calls: 1842,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "report_finding",
|
key: "report_finding",
|
||||||
@@ -2953,6 +2954,7 @@ export const tools: Tool[] = [
|
|||||||
agents: ["worker"],
|
agents: ["worker"],
|
||||||
enabled: true,
|
enabled: true,
|
||||||
kind: "builtin",
|
kind: "builtin",
|
||||||
|
calls: 96,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "upsert_asset",
|
key: "upsert_asset",
|
||||||
@@ -2962,6 +2964,7 @@ export const tools: Tool[] = [
|
|||||||
agents: ["worker"],
|
agents: ["worker"],
|
||||||
enabled: true,
|
enabled: true,
|
||||||
kind: "builtin",
|
kind: "builtin",
|
||||||
|
calls: 417,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "list_goals",
|
key: "list_goals",
|
||||||
@@ -2971,6 +2974,7 @@ export const tools: Tool[] = [
|
|||||||
agents: ["planner"],
|
agents: ["planner"],
|
||||||
enabled: true,
|
enabled: true,
|
||||||
kind: "builtin",
|
kind: "builtin",
|
||||||
|
calls: 238,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "nuclei_scan",
|
key: "nuclei_scan",
|
||||||
@@ -2985,6 +2989,7 @@ export const tools: Tool[] = [
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
kind: "command",
|
kind: "command",
|
||||||
exec: { command: "nuclei", args: ["-u", "{{target}}"] },
|
exec: { command: "nuclei", args: ["-u", "{{target}}"] },
|
||||||
|
calls: 31,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -233,6 +233,16 @@ function sortMockConversations() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sortMockTasks() {
|
||||||
|
mockTasks.sort((a, b) => {
|
||||||
|
const aPinned = a.pinned_at ? 1 : 0;
|
||||||
|
const bPinned = b.pinned_at ? 1 : 0;
|
||||||
|
if (aPinned !== bPinned) return bPinned - aPinned;
|
||||||
|
if (a.pinned_at !== b.pinned_at) return (b.pinned_at ?? "").localeCompare(a.pinned_at ?? "");
|
||||||
|
return b.id.localeCompare(a.id, undefined, { numeric: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function normalizedTemplateName(value: unknown): string {
|
function normalizedTemplateName(value: unknown): string {
|
||||||
return String(value ?? "")
|
return String(value ?? "")
|
||||||
.trim()
|
.trim()
|
||||||
@@ -268,7 +278,10 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
|||||||
if (path === "/auth/change-password") return { ok: true };
|
if (path === "/auth/change-password") return { ok: true };
|
||||||
|
|
||||||
// ── tasks ──
|
// ── tasks ──
|
||||||
if (path === "/tasks" && m === "GET") return { tasks: mockTasks, active: mockActiveTask };
|
if (path === "/tasks" && m === "GET") {
|
||||||
|
sortMockTasks();
|
||||||
|
return { tasks: mockTasks, active: mockActiveTask };
|
||||||
|
}
|
||||||
if (path === "/tasks" && m === "POST") {
|
if (path === "/tasks" && m === "POST") {
|
||||||
let suffix = 1;
|
let suffix = 1;
|
||||||
while (mockTasks.some((item) => item.id === `t-new-${suffix}`)) suffix++;
|
while (mockTasks.some((item) => item.id === `t-new-${suffix}`)) suffix++;
|
||||||
@@ -436,6 +449,17 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
|||||||
if (index >= 0) mockTaskTemplates.splice(index, 1);
|
if (index >= 0) mockTaskTemplates.splice(index, 1);
|
||||||
return { deleted: id };
|
return { deleted: id };
|
||||||
}
|
}
|
||||||
|
if (seg[0] === "tasks" && seg.length === 2 && m === "PATCH") {
|
||||||
|
const task = mockTasks.find((item) => item.id === seg[1]);
|
||||||
|
if (!task) throw new Error("任务不存在");
|
||||||
|
if (typeof b.name === "string") task.name = b.name.trim();
|
||||||
|
if (typeof b.pinned === "boolean") {
|
||||||
|
task.pinned = b.pinned;
|
||||||
|
task.pinned_at = b.pinned ? (task.pinned_at ?? new Date().toISOString()) : null;
|
||||||
|
}
|
||||||
|
sortMockTasks();
|
||||||
|
return structuredClone(task);
|
||||||
|
}
|
||||||
if (seg[0] === "tasks" && seg.length === 2 && m === "DELETE") {
|
if (seg[0] === "tasks" && seg.length === 2 && m === "DELETE") {
|
||||||
const id = seg[1];
|
const id = seg[1];
|
||||||
const index = mockTasks.findIndex((item) => item.id === id);
|
const index = mockTasks.findIndex((item) => item.id === id);
|
||||||
@@ -1187,6 +1211,18 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
|||||||
if (index >= 0) mockConversations.splice(index, 1);
|
if (index >= 0) mockConversations.splice(index, 1);
|
||||||
return { deleted: id };
|
return { deleted: id };
|
||||||
}
|
}
|
||||||
|
if (path === "/conversations/delete/batch" && m === "POST") {
|
||||||
|
const ids = Array.isArray(b.ids)
|
||||||
|
? [...new Set(b.ids.map(Number).filter((id) => Number.isInteger(id) && id > 0))]
|
||||||
|
: [];
|
||||||
|
const items = ids.map((id) => {
|
||||||
|
const index = mockConversations.findIndex((item) => item.id === id);
|
||||||
|
if (index < 0) return { id, ok: false, error: "conversation not found" };
|
||||||
|
mockConversations.splice(index, 1);
|
||||||
|
return { id, ok: true };
|
||||||
|
});
|
||||||
|
return { items };
|
||||||
|
}
|
||||||
if (seg[0] === "conversations" && seg[2] === "messages" && seg.length === 3 && m === "GET") {
|
if (seg[0] === "conversations" && seg[2] === "messages" && seg.length === 3 && m === "GET") {
|
||||||
const items = D.conversationMessages[Number(seg[1])] ?? [];
|
const items = D.conversationMessages[Number(seg[1])] ?? [];
|
||||||
return { items, cursor: items.length ? items[items.length - 1].seq : 0, running: false };
|
return { items, cursor: items.length ? items[items.length - 1].seq : 0, running: false };
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { getLocalStorageValue, setLocalStorageValue } from "@/lib/local-storage.client";
|
||||||
|
|
||||||
|
export type SortDirection = "asc" | "desc";
|
||||||
|
|
||||||
|
export interface SortPreference<Field extends string> {
|
||||||
|
field: Field;
|
||||||
|
direction: SortDirection;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useStoredSortPreference<Field extends string>(
|
||||||
|
key: string,
|
||||||
|
fields: readonly Field[],
|
||||||
|
defaultField: Field,
|
||||||
|
defaultDirection: SortDirection,
|
||||||
|
) {
|
||||||
|
const [preference, setPreference] = React.useState<SortPreference<Field>>({
|
||||||
|
field: defaultField,
|
||||||
|
direction: defaultDirection,
|
||||||
|
});
|
||||||
|
const [hydrated, setHydrated] = React.useState(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const raw = getLocalStorageValue(key);
|
||||||
|
if (raw) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as Partial<SortPreference<string>>;
|
||||||
|
const field = fields.find((candidate) => candidate === parsed.field);
|
||||||
|
const direction = parsed.direction === "asc" || parsed.direction === "desc" ? parsed.direction : null;
|
||||||
|
if (field && direction) setPreference({ field, direction });
|
||||||
|
} catch {
|
||||||
|
// Ignore malformed or legacy preferences and retain the current default.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setHydrated(true);
|
||||||
|
}, [fields, key]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!hydrated) return;
|
||||||
|
setLocalStorageValue(key, JSON.stringify(preference));
|
||||||
|
}, [hydrated, key, preference]);
|
||||||
|
|
||||||
|
return [preference, setPreference] as const;
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ export interface Task {
|
|||||||
name?: string; // 可选任务名称;空/缺省=未命名,展示时回退到描述
|
name?: string; // 可选任务名称;空/缺省=未命名,展示时回退到描述
|
||||||
category_id?: number;
|
category_id?: number;
|
||||||
category_name?: string;
|
category_name?: string;
|
||||||
|
pinned?: boolean;
|
||||||
|
pinned_at?: string | null;
|
||||||
description: string;
|
description: string;
|
||||||
goal: string;
|
goal: string;
|
||||||
status: TaskStatus;
|
status: TaskStatus;
|
||||||
@@ -952,6 +954,7 @@ export interface Tool {
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
exec?: Record<string, any>; // 自定义工具执行规格(kind!=builtin)
|
exec?: Record<string, any>; // 自定义工具执行规格(kind!=builtin)
|
||||||
deferred?: boolean; // schema 延迟(SearchExtraTools/ExecuteExtraTool)
|
deferred?: boolean; // schema 延迟(SearchExtraTools/ExecuteExtraTool)
|
||||||
|
calls?: number; // persistent runtime invocation count (older APIs may omit it)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Stats ----
|
// ---- Stats ----
|
||||||
|
|||||||
Reference in new issue
Block a user