From 876e7e8911c00c21d836d0c55ee3813d879f01c8 Mon Sep 17 00:00:00 2001 From: dj-oyu <68707227+dj-oyu@users.noreply.github.com> Date: Mon, 16 Mar 2026 17:00:43 +0900 Subject: [PATCH] feat: research interval scheduling + heartbeat web_search quota (#47) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: research heartbeat incremental progress + autonomous recall/forget - Heartbeat auto-detects active/pending research tasks and injects incremental research instructions (1-2 findings per cycle) - Guard prevents premature completion during heartbeat (min 5 findings) - Research tool gains recall/forget actions for autonomous context control - System prompt always shows lightweight research catalog via RuntimeStatus - Single-topic focus limit to avoid context pollution - FocusTracker shared between tool and Mini App for unified state - Mini App research tab: Recall/Forget buttons + focused badge - SearchTasks added for title/slug partial match queries Co-Authored-By: Claude Opus 4.6 (1M context) * feat: research interval scheduling + heartbeat web_search quota (#46) * feat: research task interval scheduling + heartbeat web_search quota - Add per-task research interval (default 24h) with `interval` and `last_researched_at` columns on research_tasks (auto-migrated) - Heartbeat only picks tasks that are "due" via ListDueTasks() - Add heartbeat-scoped web_search quota (default 3/heartbeat) enforced in WebSearchTool.Execute() via atomic counter in context - Support ParseInterval() with "d" suffix (e.g. "1d", "7d") - Touch last_researched_at on add_finding success - Add set_interval action to research tool + Mini App API - Expose interval/last_researched_at in all API responses Co-Authored-By: Claude Opus 4.6 (1M context) * feat: display research interval and last_researched_at in Mini App & Web UI - Mini App: show interval and last researched date in task list and detail - Web Frontend: add interval/last_researched_at to ResearchTask interface, task cards, and detail page - Add i18n keys for en/zh locales - Update createResearchTask and researchTaskAction API signatures Co-Authored-By: Claude Opus 4.6 (1M context) * fix: lint issues (gci, gofumpt, golines, predeclared) Co-Authored-By: Claude Opus 4.6 (1M context) * feat: Mini App interval selector UI for research tasks Add dropdown selector to change research interval (30m/1h/6h/12h/1d/3d/7d) directly from the task detail view. Changes are saved immediately via the set_interval API action. Co-Authored-By: Claude Opus 4.6 (1M context) * feat: Mini App status transition buttons (activate/complete/cancel/reopen) - Add activate (pending→active) and complete (active→completed) buttons - Backend: add 'activate' and 'complete' actions to task action handler - Buttons show contextually based on current task status Co-Authored-By: Claude Opus 4.6 (1M context) * fix: normalize interval display (24h→1d) and default to 1d - DefaultResearchInterval changed from '24h' to '1d' - SQLite migration default also '1d' - Mini App normalizes '24h' to '1d' for display and selector matching Co-Authored-By: Claude Opus 4.6 (1M context) * fix: migrate existing '24h' intervals to '1d' on startup Normalizes legacy data where the old default '24h' was written before the switch to '1d'. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: refresh task detail after interval change in Mini App researchSetInterval was not calling openResearchTask() after the API call, so the UI didn't reflect the change until manual navigation. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: use addEventListener for interval select in Mini App Inline onchange on ' + + ['30m','1h','6h','12h','1d','3d','7d'].map(function(v) { + return ''; + }).join('') + + '' + + (task.last_researched_at ? ' · Last: ' + new Date(task.last_researched_at).toLocaleString() + '' : ' · Not yet researched') + + ''; + html += '
' + 'Created: ' + new Date(task.created_at).toLocaleString() + (task.completed_at ? ' · Completed: ' + new Date(task.completed_at).toLocaleString() : '') + '
'; - if (canCancel || canReopen) { - html += '
'; - if (canCancel) html += ''; - if (canReopen) html += ''; - html += '
'; + html += '
'; + if (task.focused) { + html += ''; + } else { + html += ''; } + if (task.status === 'pending') html += ''; + if (task.status === 'active') html += ''; + if (canCancel) html += ''; + if (canReopen) html += ''; + html += '
'; html += ''; // Documents @@ -1461,6 +1488,13 @@ async function openResearchTask(id) { }).join(''); } el.innerHTML = html; + // Bind interval select via addEventListener (inline onchange unreliable in Telegram WebView) + var sel = document.getElementById('research-interval-select'); + if (sel) { + sel.addEventListener('change', function() { + researchSetInterval(sel.dataset.taskId, sel.value); + }); + } } catch (e) { el.innerHTML = '
Failed to load task.
'; } @@ -1489,6 +1523,18 @@ async function toggleResearchDoc(card, taskId, docId) { } } +async function researchSetInterval(taskId, interval) { + try { + await fetch( + API_BASE + '/miniapp/api/research/' + taskId + '?initData=' + encodeURIComponent(initData), + { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'set_interval', interval: interval }) } + ); + openResearchTask(taskId); + } catch (e) { + // ignore + } +} + async function researchAction(taskId, action) { try { await fetch( @@ -1535,12 +1581,27 @@ async function createResearchTask() { } } +async function researchSetFocus(taskId, recall) { + try { + await fetch( + API_BASE + '/miniapp/api/research/focus?initData=' + encodeURIComponent(initData), + { method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: recall ? 'recall' : 'forget', task_id: taskId }) } + ); + // Refresh the detail view to show updated focus state + openResearchTask(taskId); + } catch (e) { + // ignore + } +} + window.showNewTaskForm = showNewTaskForm; window.hideNewTaskForm = hideNewTaskForm; window.createResearchTask = createResearchTask; window.openResearchTask = openResearchTask; window.toggleResearchDoc = toggleResearchDoc; window.researchAction = researchAction; +window.researchSetFocus = researchSetFocus; window.showResearchList = showResearchList; diff --git a/pkg/miniapp/miniapp.go b/pkg/miniapp/miniapp.go index c9c6d0f8f..6b6d95077 100644 --- a/pkg/miniapp/miniapp.go +++ b/pkg/miniapp/miniapp.go @@ -41,6 +41,7 @@ type Handler struct { workspace string orchBroadcaster *orch.Broadcaster researchStore *research.ResearchStore + researchFocus *research.FocusTracker devMu sync.RWMutex devTarget *url.URL @@ -107,6 +108,7 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("/miniapp/dev/console", h.apiDevConsole) mux.HandleFunc("/miniapp/dev/", h.serveDevProxy) mux.HandleFunc("/miniapp/api/research", h.requireAuth(h.apiResearch)) + mux.HandleFunc("/miniapp/api/research/focus", h.requireAuth(h.apiResearchFocus)) mux.HandleFunc("/miniapp/api/research/", h.requireAuth(h.apiResearchDetail)) } diff --git a/pkg/miniapp/static/dist/app.js b/pkg/miniapp/static/dist/app.js index 9d7855aec..67d8bcb02 100644 --- a/pkg/miniapp/static/dist/app.js +++ b/pkg/miniapp/static/dist/app.js @@ -8638,7 +8638,8 @@ Please report this to https://github.com/markedjs/marked.`, e) { } el.innerHTML = tasks.map(function(t) { var sc = STATUS_COLORS[t.status] || STATUS_COLORS.pending; - return `
` + '
' + '' + esc(t.title) + "" + '' + t.status + "" + "
" + (t.description ? '
' + esc(t.description).substring(0, 120) + "
" : "") + '
' + t.document_count + " docs · " + new Date(t.created_at).toLocaleDateString() + "
" + "
"; + var focusBadge = t.focused ? 'focused' : ""; + return '
` + '
' + '' + esc(t.title) + "" + '
' + focusBadge + '' + t.status + "" + "
" + "
" + (t.description ? '
' + esc(t.description).substring(0, 120) + "
" : "") + '
' + t.document_count + " docs · " + new Date(t.created_at).toLocaleDateString() + "
" + "
"; }).join(""); } catch (e) { loading.classList.add("hidden"); @@ -8667,14 +8668,17 @@ Please report this to https://github.com/markedjs/marked.`, e) { html += '
' + esc(task.description) + "
"; } html += '
' + "Created: " + new Date(task.created_at).toLocaleString() + (task.completed_at ? " · Completed: " + new Date(task.completed_at).toLocaleString() : "") + "
"; - if (canCancel || canReopen) { - html += '
'; - if (canCancel) - html += ``; - if (canReopen) - html += ``; - html += "
"; + html += '
'; + if (task.focused) { + html += ``; + } else { + html += ``; } + if (canCancel) + html += ``; + if (canReopen) + html += ``; + html += "
"; html += ""; html += '
Documents (' + task.documents.length + ")
"; if (task.documents.length === 0) { @@ -8741,11 +8745,22 @@ Please report this to https://github.com/markedjs/marked.`, e) { loadResearch(); } catch (e) {} } + async function researchSetFocus(taskId, recall) { + try { + await fetch(API_BASE + "/miniapp/api/research/focus?initData=" + encodeURIComponent(initData), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: recall ? "recall" : "forget", task_id: taskId }) + }); + openResearchTask(taskId); + } catch (e) {} + } window.showNewTaskForm = showNewTaskForm; window.hideNewTaskForm = hideNewTaskForm; window.createResearchTask = createResearchTask; window.openResearchTask = openResearchTask; window.toggleResearchDoc = toggleResearchDoc; window.researchAction = researchAction; + window.researchSetFocus = researchSetFocus; window.showResearchList = showResearchList; })(); diff --git a/pkg/research/focus.go b/pkg/research/focus.go new file mode 100644 index 000000000..0fc008529 --- /dev/null +++ b/pkg/research/focus.go @@ -0,0 +1,65 @@ +package research + +import "sync" + +// FocusTracker tracks which single research task is currently "recalled" +// into the agent's active context. At most one task can be focused at a time +// to avoid context pollution. Thread-safe. +type FocusTracker struct { + mu sync.RWMutex + taskID string // currently focused task ID (empty = none) + title string // currently focused task title +} + +// NewFocusTracker creates a new FocusTracker. +func NewFocusTracker() *FocusTracker { + return &FocusTracker{} +} + +// Focus sets the single focused task, replacing any previous focus. +func (ft *FocusTracker) Focus(taskID, title string) { + ft.mu.Lock() + ft.taskID = taskID + ft.title = title + ft.mu.Unlock() +} + +// Unfocus removes a specific task from focus. +// Returns true if the task was focused. +func (ft *FocusTracker) Unfocus(taskID string) bool { + ft.mu.Lock() + defer ft.mu.Unlock() + if ft.taskID != taskID { + return false + } + ft.taskID = "" + ft.title = "" + return true +} + +// UnfocusAll clears focus. Returns 1 if something was focused, 0 otherwise. +func (ft *FocusTracker) UnfocusAll() int { + ft.mu.Lock() + defer ft.mu.Unlock() + if ft.taskID == "" { + return 0 + } + ft.taskID = "" + ft.title = "" + return 1 +} + +// Current returns the currently focused task ID and title. +// Returns empty strings if nothing is focused. +func (ft *FocusTracker) Current() (taskID, title string) { + ft.mu.RLock() + defer ft.mu.RUnlock() + return ft.taskID, ft.title +} + +// IsFocused returns whether a specific task is currently focused. +func (ft *FocusTracker) IsFocused(taskID string) bool { + ft.mu.RLock() + defer ft.mu.RUnlock() + return ft.taskID == taskID +} diff --git a/pkg/research/store.go b/pkg/research/store.go index e0e1281c4..073051eba 100644 --- a/pkg/research/store.go +++ b/pkg/research/store.go @@ -67,7 +67,69 @@ func OpenResearchStore(dbPath, workspace string) (*ResearchStore, error) { _ = db.Close() return nil, fmt.Errorf("create schema: %w", err) } - return &ResearchStore{db: db, workspace: workspace}, nil + s := &ResearchStore{db: db, workspace: workspace} + if err := s.migrateSchema(); err != nil { + _ = db.Close() + return nil, fmt.Errorf("migrate schema: %w", err) + } + return s, nil +} + +// migrateSchema adds columns introduced after the initial schema. +func (s *ResearchStore) migrateSchema() error { + migrations := []struct { + table, column, ddl string + }{ + { + "research_tasks", "interval", + `ALTER TABLE research_tasks ADD COLUMN interval TEXT NOT NULL DEFAULT '1d'`, + }, + { + "research_tasks", "last_researched_at", + `ALTER TABLE research_tasks ADD COLUMN last_researched_at TEXT NOT NULL DEFAULT ''`, + }, + } + for _, m := range migrations { + exists, err := s.columnExists(m.table, m.column) + if err != nil { + return err + } + if !exists { + if _, err := s.db.Exec(m.ddl); err != nil { + return fmt.Errorf("add column %s.%s: %w", m.table, m.column, err) + } + } + } + + // Normalize legacy '24h' default to '1d' + if _, err := s.db.Exec( + `UPDATE research_tasks SET interval = '1d' WHERE interval = '24h'`, + ); err != nil { + return fmt.Errorf("normalize interval 24h→1d: %w", err) + } + return nil +} + +func (s *ResearchStore) columnExists(table, column string) (bool, error) { + rows, err := s.db.Query(fmt.Sprintf("PRAGMA table_info(%s)", table)) + if err != nil { + return false, err + } + defer rows.Close() + for rows.Next() { + var cid int + var name, typ string + var notNull int + var dfltValue sql.NullString + var pk int + if err := rows.Scan(&cid, &name, &typ, ¬Null, &dfltValue, &pk); err != nil { + return false, err + } + if name == column { + return true, nil + } + } + return false, rows.Err() } // Close closes the database connection. @@ -76,7 +138,15 @@ func (s *ResearchStore) Close() error { } // CreateTask creates a new research task with auto-generated slug and output directory. -func (s *ResearchStore) CreateTask(title, description string) (*Task, error) { +// interval is optional; pass "" to use DefaultResearchInterval. +func (s *ResearchStore) CreateTask(title, description, interval string) (*Task, error) { + if interval == "" { + interval = DefaultResearchInterval + } + if _, err := ParseInterval(interval); err != nil { + return nil, fmt.Errorf("invalid interval %q: %w", interval, err) + } + id := uuid.New().String() slug := slugify(title) now := time.Now().UTC() @@ -90,9 +160,9 @@ func (s *ResearchStore) CreateTask(title, description string) (*Task, error) { nowStr := now.Format(time.RFC3339) _, err := s.db.Exec( - `INSERT INTO research_tasks (id, title, slug, description, status, output_dir, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - id, title, slug, description, string(StatusPending), outputDir, nowStr, nowStr, + `INSERT INTO research_tasks (id, title, slug, description, status, output_dir, interval, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + id, title, slug, description, string(StatusPending), outputDir, interval, nowStr, nowStr, ) if err != nil { return nil, fmt.Errorf("insert task: %w", err) @@ -105,16 +175,18 @@ func (s *ResearchStore) CreateTask(title, description string) (*Task, error) { Description: description, Status: StatusPending, OutputDir: outputDir, + Interval: interval, CreatedAt: now, UpdatedAt: now, }, nil } +const taskColumns = `id, title, slug, description, status, output_dir, interval, last_researched_at, created_at, updated_at, completed_at` + // GetTask retrieves a single task by ID. func (s *ResearchStore) GetTask(id string) (*Task, error) { row := s.db.QueryRow( - `SELECT id, title, slug, description, status, output_dir, created_at, updated_at, completed_at - FROM research_tasks WHERE id = ?`, id) + `SELECT `+taskColumns+` FROM research_tasks WHERE id = ?`, id) return scanTask(row) } @@ -124,12 +196,10 @@ func (s *ResearchStore) ListTasks(status TaskStatus) ([]*Task, error) { var err error if status == "" { rows, err = s.db.Query( - `SELECT id, title, slug, description, status, output_dir, created_at, updated_at, completed_at - FROM research_tasks ORDER BY created_at DESC`) + `SELECT ` + taskColumns + ` FROM research_tasks ORDER BY created_at DESC`) } else { rows, err = s.db.Query( - `SELECT id, title, slug, description, status, output_dir, created_at, updated_at, completed_at - FROM research_tasks WHERE status = ? ORDER BY created_at DESC`, string(status)) + `SELECT `+taskColumns+` FROM research_tasks WHERE status = ? ORDER BY created_at DESC`, string(status)) } if err != nil { return nil, fmt.Errorf("list tasks: %w", err) @@ -147,6 +217,33 @@ func (s *ResearchStore) ListTasks(status TaskStatus) ([]*Task, error) { return tasks, rows.Err() } +// ListDueTasks returns active/pending tasks that are due for research (interval elapsed). +func (s *ResearchStore) ListDueTasks(maxTasks int) ([]*Task, error) { + rows, err := s.db.Query( + `SELECT ` + taskColumns + ` FROM research_tasks + WHERE status IN ('active', 'pending') + ORDER BY last_researched_at ASC, created_at ASC`) + if err != nil { + return nil, fmt.Errorf("list due tasks: %w", err) + } + defer rows.Close() + + var due []*Task + for rows.Next() { + t, err := scanTaskRow(rows) + if err != nil { + return nil, err + } + if t.IsDue() { + due = append(due, t) + if len(due) >= maxTasks { + break + } + } + } + return due, rows.Err() +} + // SetTaskStatus updates task status with transition validation. func (s *ResearchStore) SetTaskStatus(id string, status TaskStatus) error { task, err := s.GetTask(id) @@ -251,17 +348,65 @@ func (s *ResearchStore) DocumentCount(taskID string) (int, error) { return count, err } +// SearchTasks searches tasks by title or slug partial match (case-insensitive). +func (s *ResearchStore) SearchTasks(query string) ([]*Task, error) { + like := "%" + query + "%" + rows, err := s.db.Query( + `SELECT `+taskColumns+` FROM research_tasks + WHERE title LIKE ? COLLATE NOCASE OR slug LIKE ? COLLATE NOCASE + ORDER BY updated_at DESC`, like, like) + if err != nil { + return nil, fmt.Errorf("search tasks: %w", err) + } + defer rows.Close() + + var tasks []*Task + for rows.Next() { + t, err := scanTaskRow(rows) + if err != nil { + return nil, err + } + tasks = append(tasks, t) + } + return tasks, rows.Err() +} + +// TouchLastResearched updates the last_researched_at timestamp for a task. +func (s *ResearchStore) TouchLastResearched(taskID string) error { + now := time.Now().UTC().Format(time.RFC3339) + _, err := s.db.Exec( + `UPDATE research_tasks SET last_researched_at = ?, updated_at = ? WHERE id = ?`, + now, now, taskID) + return err +} + +// SetInterval updates the research interval for a task. +func (s *ResearchStore) SetInterval(taskID, interval string) error { + if _, err := ParseInterval(interval); err != nil { + return fmt.Errorf("invalid interval %q: %w", interval, err) + } + now := time.Now().UTC().Format(time.RFC3339) + _, err := s.db.Exec( + `UPDATE research_tasks SET interval = ?, updated_at = ? WHERE id = ?`, + interval, now, taskID) + return err +} + // --- helpers --- func scanTask(row *sql.Row) (*Task, error) { var t Task - var statusStr, createdStr, updatedStr, completedStr string + var statusStr, lastResearchedStr, createdStr, updatedStr, completedStr string err := row.Scan(&t.ID, &t.Title, &t.Slug, &t.Description, &statusStr, - &t.OutputDir, &createdStr, &updatedStr, &completedStr) + &t.OutputDir, &t.Interval, &lastResearchedStr, &createdStr, &updatedStr, &completedStr) if err != nil { return nil, err } t.Status = TaskStatus(statusStr) + if t.Interval == "" { + t.Interval = DefaultResearchInterval + } + t.LastResearchedAt, _ = time.Parse(time.RFC3339, lastResearchedStr) t.CreatedAt, _ = time.Parse(time.RFC3339, createdStr) t.UpdatedAt, _ = time.Parse(time.RFC3339, updatedStr) if completedStr != "" { @@ -276,13 +421,17 @@ type rowScanner interface { func scanTaskRow(row rowScanner) (*Task, error) { var t Task - var statusStr, createdStr, updatedStr, completedStr string + var statusStr, lastResearchedStr, createdStr, updatedStr, completedStr string err := row.Scan(&t.ID, &t.Title, &t.Slug, &t.Description, &statusStr, - &t.OutputDir, &createdStr, &updatedStr, &completedStr) + &t.OutputDir, &t.Interval, &lastResearchedStr, &createdStr, &updatedStr, &completedStr) if err != nil { return nil, err } t.Status = TaskStatus(statusStr) + if t.Interval == "" { + t.Interval = DefaultResearchInterval + } + t.LastResearchedAt, _ = time.Parse(time.RFC3339, lastResearchedStr) t.CreatedAt, _ = time.Parse(time.RFC3339, createdStr) t.UpdatedAt, _ = time.Parse(time.RFC3339, updatedStr) if completedStr != "" { diff --git a/pkg/research/store_test.go b/pkg/research/store_test.go index 2f91e36d3..a90dd1e6e 100644 --- a/pkg/research/store_test.go +++ b/pkg/research/store_test.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "testing" + "time" ) func setupTestStore(t *testing.T) (*ResearchStore, string) { @@ -21,7 +22,7 @@ func setupTestStore(t *testing.T) (*ResearchStore, string) { func TestCreateAndGetTask(t *testing.T) { store, dir := setupTestStore(t) - task, err := store.CreateTask("Test Research", "A test description") + task, err := store.CreateTask("Test Research", "A test description", "") if err != nil { t.Fatalf("create task: %v", err) } @@ -50,8 +51,8 @@ func TestCreateAndGetTask(t *testing.T) { func TestListTasks(t *testing.T) { store, _ := setupTestStore(t) - store.CreateTask("Task A", "") - store.CreateTask("Task B", "") + store.CreateTask("Task A", "", "") + store.CreateTask("Task B", "", "") all, err := store.ListTasks("") if err != nil { @@ -81,7 +82,7 @@ func TestListTasks(t *testing.T) { func TestSetTaskStatus(t *testing.T) { store, _ := setupTestStore(t) - task, _ := store.CreateTask("Status Test", "") + task, _ := store.CreateTask("Status Test", "", "") // Valid: pending → active if err := store.SetTaskStatus(task.ID, StatusActive); err != nil { @@ -113,7 +114,7 @@ func TestSetTaskStatus(t *testing.T) { func TestAddAndListDocuments(t *testing.T) { store, dir := setupTestStore(t) - task, _ := store.CreateTask("Doc Test", "") + task, _ := store.CreateTask("Doc Test", "", "") // Create a test file filePath := filepath.Join(dir, task.OutputDir, "001-finding.md") @@ -144,7 +145,7 @@ func TestAddAndListDocuments(t *testing.T) { func TestDocumentCount(t *testing.T) { store, _ := setupTestStore(t) - task, _ := store.CreateTask("Count Test", "") + task, _ := store.CreateTask("Count Test", "", "") count, _ := store.DocumentCount(task.ID) if count != 0 { @@ -163,7 +164,7 @@ func TestDocumentCount(t *testing.T) { func TestDeleteTask(t *testing.T) { store, _ := setupTestStore(t) - task, _ := store.CreateTask("Delete Test", "") + task, _ := store.CreateTask("Delete Test", "", "") store.AddDocument(task.ID, "D1", "p1", "finding", "") if err := store.DeleteTask(task.ID); err != nil { @@ -180,7 +181,7 @@ func TestDeleteTask(t *testing.T) { func TestUpdateTask(t *testing.T) { store, _ := setupTestStore(t) - task, _ := store.CreateTask("Original", "desc") + task, _ := store.CreateTask("Original", "desc", "") if err := store.UpdateTask(task.ID, "Updated", "new desc"); err != nil { t.Fatalf("update: %v", err) } @@ -191,6 +192,106 @@ func TestUpdateTask(t *testing.T) { } } +func TestParseInterval(t *testing.T) { + cases := []struct { + input string + want string // duration string + err bool + }{ + {"1d", "24h0m0s", false}, + {"7d", "168h0m0s", false}, + {"6h", "6h0m0s", false}, + {"30m", "30m0s", false}, + {"", "", true}, + {"abc", "", true}, + } + for _, tc := range cases { + got, err := ParseInterval(tc.input) + if tc.err { + if err == nil { + t.Errorf("ParseInterval(%q) expected error", tc.input) + } + continue + } + if err != nil { + t.Errorf("ParseInterval(%q) error: %v", tc.input, err) + continue + } + if got.String() != tc.want { + t.Errorf("ParseInterval(%q) = %v, want %v", tc.input, got, tc.want) + } + } +} + +func TestTaskIsDue(t *testing.T) { + // Zero LastResearchedAt → always due + task := &Task{Interval: "1d"} + if !task.IsDue() { + t.Error("zero LastResearchedAt should be due") + } + + // Recently researched → not due + task.LastResearchedAt = time.Now().Add(-1 * time.Hour) + task.Interval = "1d" + if task.IsDue() { + t.Error("researched 1h ago with 1d interval should not be due") + } + + // Long ago → due + task.LastResearchedAt = time.Now().Add(-25 * time.Hour) + if !task.IsDue() { + t.Error("researched 25h ago with 1d interval should be due") + } +} + +func TestListDueTasks(t *testing.T) { + store, _ := setupTestStore(t) + + // Create tasks with different intervals + t1, _ := store.CreateTask("Fast", "", "1h") + t2, _ := store.CreateTask("Slow", "", "7d") + + // Both pending with no LastResearchedAt → both due + due, err := store.ListDueTasks(10) + if err != nil { + t.Fatalf("list due: %v", err) + } + if len(due) != 2 { + t.Errorf("expected 2 due tasks, got %d", len(due)) + } + + // Touch t1 → it should no longer be due (1h not elapsed) + store.TouchLastResearched(t1.ID) + due, _ = store.ListDueTasks(10) + if len(due) != 1 { + t.Errorf("expected 1 due task after touch, got %d", len(due)) + } + if len(due) > 0 && due[0].ID != t2.ID { + t.Errorf("expected Slow task to be due, got %s", due[0].Title) + } +} + +func TestSetInterval(t *testing.T) { + store, _ := setupTestStore(t) + task, _ := store.CreateTask("Interval Test", "", "") + if task.Interval != DefaultResearchInterval { + t.Errorf("default interval = %q, want %q", task.Interval, DefaultResearchInterval) + } + + if err := store.SetInterval(task.ID, "6h"); err != nil { + t.Fatalf("set interval: %v", err) + } + got, _ := store.GetTask(task.ID) + if got.Interval != "6h" { + t.Errorf("interval = %q, want 6h", got.Interval) + } + + // Invalid interval + if err := store.SetInterval(task.ID, "xyz"); err == nil { + t.Error("expected error for invalid interval") + } +} + func TestCanTransition(t *testing.T) { cases := []struct { from, to TaskStatus diff --git a/pkg/research/types.go b/pkg/research/types.go index ecbc5b4fd..dce557ba6 100644 --- a/pkg/research/types.go +++ b/pkg/research/types.go @@ -1,6 +1,11 @@ package research -import "time" +import ( + "fmt" + "strconv" + "strings" + "time" +) // TaskStatus represents the lifecycle state of a research task. type TaskStatus string @@ -13,17 +18,60 @@ const ( StatusCanceled TaskStatus = "canceled" ) +// MinFindingsForCompletion is the minimum number of findings required +// before a heartbeat execution can mark a research task as completed. +const MinFindingsForCompletion = 5 + +// DefaultResearchInterval is the default research interval for new tasks. +const DefaultResearchInterval = "1d" + +// DefaultHeartbeatSearchQuota is the max web searches per heartbeat execution. +const DefaultHeartbeatSearchQuota = 3 + // Task represents a research task tracked in the database. type Task struct { - ID string `json:"id"` - Title string `json:"title"` - Slug string `json:"slug"` - Description string `json:"description"` - Status TaskStatus `json:"status"` - OutputDir string `json:"output_dir"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - CompletedAt time.Time `json:"completed_at,omitempty"` + ID string `json:"id"` + Title string `json:"title"` + Slug string `json:"slug"` + Description string `json:"description"` + Status TaskStatus `json:"status"` + OutputDir string `json:"output_dir"` + Interval string `json:"interval"` + LastResearchedAt time.Time `json:"last_researched_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + CompletedAt time.Time `json:"completed_at,omitempty"` +} + +// IsDue returns true if enough time has elapsed since the last research. +func (t *Task) IsDue() bool { + if t.LastResearchedAt.IsZero() { + return true + } + interval, err := ParseInterval(t.Interval) + if err != nil || interval <= 0 { + interval = 24 * time.Hour + } + return time.Since(t.LastResearchedAt) >= interval +} + +// ParseInterval parses a duration string with support for "d" (days) suffix. +// Examples: "30m", "6h", "1d", "7d", "24h". +func ParseInterval(s string) (time.Duration, error) { + s = strings.TrimSpace(s) + if s == "" { + return 0, fmt.Errorf("empty interval") + } + // Handle "d" suffix: e.g. "1d" -> "24h", "7d" -> "168h" + if strings.HasSuffix(s, "d") { + numStr := strings.TrimSuffix(s, "d") + days, err := strconv.Atoi(numStr) + if err != nil { + return 0, fmt.Errorf("invalid day interval %q: %w", s, err) + } + return time.Duration(days) * 24 * time.Hour, nil + } + return time.ParseDuration(s) } // Document represents a research output document linked to a task. diff --git a/pkg/tools/context_heartbeat.go b/pkg/tools/context_heartbeat.go new file mode 100644 index 000000000..25c975207 --- /dev/null +++ b/pkg/tools/context_heartbeat.go @@ -0,0 +1,59 @@ +package tools + +import ( + "context" + "sync/atomic" +) + +type heartbeatKey struct{} + +// WithHeartbeatContext marks the context as a heartbeat execution. +func WithHeartbeatContext(ctx context.Context) context.Context { + return context.WithValue(ctx, heartbeatKey{}, true) +} + +// IsHeartbeatContext returns true if the context was created by a heartbeat execution. +func IsHeartbeatContext(ctx context.Context) bool { + v, _ := ctx.Value(heartbeatKey{}).(bool) + return v +} + +// WebSearchQuota tracks remaining web searches for a heartbeat execution. +type WebSearchQuota struct { + max int32 + remaining atomic.Int32 +} + +// TryConsume atomically decrements the quota. Returns false if exhausted. +func (q *WebSearchQuota) TryConsume() bool { + for { + cur := q.remaining.Load() + if cur <= 0 { + return false + } + if q.remaining.CompareAndSwap(cur, cur-1) { + return true + } + } +} + +// Max returns the initial quota limit. +func (q *WebSearchQuota) Max() int32 { return q.max } + +// Remaining returns the current remaining quota. +func (q *WebSearchQuota) Remaining() int32 { return q.remaining.Load() } + +type searchQuotaKey struct{} + +// WithWebSearchQuota attaches a web search quota to the context. +func WithWebSearchQuota(ctx context.Context, quota int) context.Context { + q := &WebSearchQuota{max: int32(quota)} + q.remaining.Store(int32(quota)) + return context.WithValue(ctx, searchQuotaKey{}, q) +} + +// GetWebSearchQuota returns the search quota from the context, or nil if not set. +func GetWebSearchQuota(ctx context.Context) *WebSearchQuota { + q, _ := ctx.Value(searchQuotaKey{}).(*WebSearchQuota) + return q +} diff --git a/pkg/tools/context_heartbeat_test.go b/pkg/tools/context_heartbeat_test.go new file mode 100644 index 000000000..38d347c2d --- /dev/null +++ b/pkg/tools/context_heartbeat_test.go @@ -0,0 +1,82 @@ +package tools + +import ( + "context" + "sync" + "testing" +) + +func TestWebSearchQuotaBasic(t *testing.T) { + ctx := context.Background() + + // No quota → nil + if q := GetWebSearchQuota(ctx); q != nil { + t.Error("expected nil quota on bare context") + } + + ctx = WithWebSearchQuota(ctx, 3) + q := GetWebSearchQuota(ctx) + if q == nil { + t.Fatal("expected non-nil quota") + } + if q.Max() != 3 { + t.Errorf("max = %d, want 3", q.Max()) + } + if q.Remaining() != 3 { + t.Errorf("remaining = %d, want 3", q.Remaining()) + } + + // Consume 3 + for i := range 3 { + if !q.TryConsume() { + t.Errorf("consume %d should succeed", i+1) + } + } + + // 4th should fail + if q.TryConsume() { + t.Error("consume after exhaustion should fail") + } + if q.Remaining() != 0 { + t.Errorf("remaining = %d, want 0", q.Remaining()) + } +} + +func TestWebSearchQuotaConcurrent(t *testing.T) { + ctx := WithWebSearchQuota(context.Background(), 100) + q := GetWebSearchQuota(ctx) + + var wg sync.WaitGroup + consumed := make(chan bool, 200) + + for range 200 { + wg.Add(1) + go func() { + defer wg.Done() + consumed <- q.TryConsume() + }() + } + wg.Wait() + close(consumed) + + ok := 0 + for c := range consumed { + if c { + ok++ + } + } + if ok != 100 { + t.Errorf("consumed = %d, want exactly 100", ok) + } +} + +func TestHeartbeatContext(t *testing.T) { + ctx := context.Background() + if IsHeartbeatContext(ctx) { + t.Error("bare context should not be heartbeat") + } + ctx = WithHeartbeatContext(ctx) + if !IsHeartbeatContext(ctx) { + t.Error("should be heartbeat after WithHeartbeatContext") + } +} diff --git a/pkg/tools/research.go b/pkg/tools/research.go index e231263f7..f63a880ec 100644 --- a/pkg/tools/research.go +++ b/pkg/tools/research.go @@ -12,20 +12,27 @@ import ( ) // ResearchTool provides research task management for the agent. +// It implements StatusProvider to inject a lightweight research catalog +// and focus state into the system prompt. type ResearchTool struct { store *research.ResearchStore workspace string + focus *research.FocusTracker } // NewResearchTool creates a new ResearchTool. -func NewResearchTool(store *research.ResearchStore, workspace string) *ResearchTool { - return &ResearchTool{store: store, workspace: workspace} +func NewResearchTool(store *research.ResearchStore, workspace string, focus *research.FocusTracker) *ResearchTool { + return &ResearchTool{ + store: store, + workspace: workspace, + focus: focus, + } } func (t *ResearchTool) Name() string { return "research" } func (t *ResearchTool) Description() string { - return "Manage research tasks and findings. Use list_tasks to discover pending research, set_status to update task state, add_finding to record research results as markdown documents, and get_task to view task details." + return "Manage research tasks and findings. Actions: list_tasks, get_task, set_status, set_interval, add_finding, recall (load findings into context), forget (release focus when changing topics)." } func (t *ResearchTool) Parameters() map[string]any { @@ -33,13 +40,29 @@ func (t *ResearchTool) Parameters() map[string]any { "type": "object", "properties": map[string]any{ "action": map[string]any{ - "type": "string", - "enum": []string{"list_tasks", "get_task", "set_status", "add_finding"}, + "type": "string", + "enum": []string{ + "list_tasks", + "get_task", + "set_status", + "set_interval", + "add_finding", + "recall", + "forget", + }, "description": "Action to perform.", }, + "interval": map[string]any{ + "type": "string", + "description": "Research interval (for set_interval). Examples: '6h', '1d', '7d'.", + }, + "query": map[string]any{ + "type": "string", + "description": "Search query to find tasks by title/slug (for recall/forget). Alternative to task_id.", + }, "task_id": map[string]any{ "type": "string", - "description": "Task ID (required for get_task, set_status, add_finding).", + "description": "Task ID (required for get_task, set_status, add_finding; optional for recall/forget).", }, "status_filter": map[string]any{ "type": "string", @@ -76,14 +99,50 @@ func (t *ResearchTool) Execute(ctx context.Context, args map[string]any) *ToolRe case "get_task": return t.getTask(args) case "set_status": - return t.setStatus(args) + return t.setStatus(ctx, args) + case "set_interval": + return t.setInterval(args) case "add_finding": return t.addFinding(args) + case "recall": + return t.recall(args) + case "forget": + return t.forget(args) + // Keep backward compat for load_context + case "load_context": + return t.recall(args) default: return ErrorResult("unknown action: " + action) } } +// RuntimeStatus implements StatusProvider. It injects a lightweight research +// catalog (titles only) and current focus state into the system prompt. +func (t *ResearchTool) RuntimeStatus() string { + tasks, err := t.store.ListTasks("") + if err != nil || len(tasks) == 0 { + return "" + } + + var b strings.Builder + b.WriteString("# Research Knowledge Base\n\n") + b.WriteString("Available research topics:\n") + for _, task := range tasks { + docCount, _ := t.store.DocumentCount(task.ID) + b.WriteString(fmt.Sprintf("- \"%s\" [%s, %d docs] (id: %s)\n", + task.Title, task.Status, docCount, task.ID)) + } + + if focusID, focusTitle := t.focus.Current(); focusID != "" { + b.WriteString(fmt.Sprintf("\nCurrently focused: \"%s\" (id: %s)\n", focusTitle, focusID)) + } + + b.WriteString("\nUse `research recall` to load findings when the user asks about a research topic.\n") + b.WriteString("Use `research forget` to release focus when changing topics.\n") + + return b.String() +} + func (t *ResearchTool) listTasks(args map[string]any) *ToolResult { filter, _ := args["status_filter"].(string) tasks, err := t.store.ListTasks(research.TaskStatus(filter)) @@ -140,19 +199,46 @@ func (t *ResearchTool) getTask(args map[string]any) *ToolResult { return NewToolResult(b.String()) } -func (t *ResearchTool) setStatus(args map[string]any) *ToolResult { +func (t *ResearchTool) setStatus(ctx context.Context, args map[string]any) *ToolResult { taskID, _ := args["task_id"].(string) status, _ := args["status"].(string) if taskID == "" || status == "" { return ErrorResult("task_id and status are required") } + // Guard: during heartbeat, prevent completing tasks with too few findings + if IsHeartbeatContext(ctx) && research.TaskStatus(status) == research.StatusCompleted { + docCount, err := t.store.DocumentCount(taskID) + if err != nil { + return ErrorResult(fmt.Sprintf("check document count: %v", err)) + } + if docCount < research.MinFindingsForCompletion { + return ErrorResult(fmt.Sprintf( + "cannot mark as completed during heartbeat: task has %d findings (minimum %d required). Add more findings first.", + docCount, + research.MinFindingsForCompletion, + )) + } + } + if err := t.store.SetTaskStatus(taskID, research.TaskStatus(status)); err != nil { return ErrorResult(fmt.Sprintf("set status: %v", err)) } return NewToolResult(fmt.Sprintf("Task status updated to %s.", status)) } +func (t *ResearchTool) setInterval(args map[string]any) *ToolResult { + taskID, _ := args["task_id"].(string) + interval, _ := args["interval"].(string) + if taskID == "" || interval == "" { + return ErrorResult("task_id and interval are required") + } + if err := t.store.SetInterval(taskID, interval); err != nil { + return ErrorResult(fmt.Sprintf("set interval: %v", err)) + } + return NewToolResult(fmt.Sprintf("Research interval updated to %s.", interval)) +} + var sanitizeRe = regexp.MustCompile(`[^a-zA-Z0-9_-]+`) func (t *ResearchTool) addFinding(args map[string]any) *ToolResult { @@ -199,8 +285,133 @@ func (t *ResearchTool) addFinding(args map[string]any) *ToolResult { return ErrorResult(fmt.Sprintf("add document: %v", err)) } + // Touch last_researched_at for interval tracking + _ = t.store.TouchLastResearched(taskID) + return NewToolResult(fmt.Sprintf( "Finding recorded:\n- File: %s\n- Document ID: %s\n- Seq: %d", relPath, doc.ID, doc.Seq, )) } + +// maxContextBytes caps the total size of loaded research context. +const maxContextBytes = 100 * 1024 // 100KB + +// recall loads all findings for a task and marks it as focused in the system prompt. +func (t *ResearchTool) recall(args map[string]any) *ToolResult { + task, err := t.resolveTask(args) + if err != nil { + return ErrorResult(err.Error()) + } + + docs, err := t.store.ListDocuments(task.ID) + if err != nil { + return ErrorResult(fmt.Sprintf("list documents: %v", err)) + } + + // Mark as focused + t.focus.Focus(task.ID, task.Title) + + var b strings.Builder + b.WriteString(fmt.Sprintf("# Research Context: %s\n\n", task.Title)) + b.WriteString(fmt.Sprintf("**Status**: %s | **Documents**: %d\n", task.Status, len(docs))) + if task.Description != "" { + b.WriteString(fmt.Sprintf("**Description**: %s\n", task.Description)) + } + b.WriteString("\n---\n\n") + + if len(docs) == 0 { + b.WriteString("No findings recorded yet.\n") + return NewToolResult(b.String()) + } + + totalBytes := b.Len() + loaded := 0 + for _, d := range docs { + absPath := filepath.Join(t.workspace, d.FilePath) + data, readErr := os.ReadFile(absPath) + if readErr != nil { + b.WriteString(fmt.Sprintf("## [%d] %s\n\n(file not found: %s)\n\n", d.Seq, d.Title, d.FilePath)) + loaded++ + continue + } + + content := string(data) + entrySize := len(d.Title) + len(content) + 40 // rough overhead for headers + if totalBytes+entrySize > maxContextBytes { + remaining := len(docs) - loaded + b.WriteString( + fmt.Sprintf( + "\n---\n*Context limit reached. %d more finding(s) not shown. Use get_task to see the full list.*\n", + remaining, + ), + ) + break + } + + b.WriteString(fmt.Sprintf("## [%d] %s\n\n", d.Seq, d.Title)) + b.WriteString(content) + if !strings.HasSuffix(content, "\n") { + b.WriteByte('\n') + } + b.WriteString("\n---\n\n") + totalBytes += entrySize + loaded++ + } + + return NewToolResult(b.String()) +} + +// forget removes a task from the focus set. +func (t *ResearchTool) forget(args map[string]any) *ToolResult { + taskID, _ := args["task_id"].(string) + query, _ := args["query"].(string) + + // If no args, forget all + if taskID == "" && query == "" { + count := t.focus.UnfocusAll() + if count == 0 { + return NewToolResult("No research topics were focused.") + } + return NewToolResult(fmt.Sprintf("Released focus on %d research topic(s).", count)) + } + + task, err := t.resolveTask(args) + if err != nil { + return ErrorResult(err.Error()) + } + + if !t.focus.Unfocus(task.ID) { + return NewToolResult(fmt.Sprintf("Research topic \"%s\" was not focused.", task.Title)) + } + return NewToolResult( + fmt.Sprintf("Released focus on \"%s\". Research findings are no longer in active context.", task.Title), + ) +} + +// resolveTask finds a task by task_id or query. +func (t *ResearchTool) resolveTask(args map[string]any) (*research.Task, error) { + taskID, _ := args["task_id"].(string) + query, _ := args["query"].(string) + + if taskID == "" && query == "" { + return nil, fmt.Errorf("task_id or query is required") + } + + if taskID != "" { + task, err := t.store.GetTask(taskID) + if err != nil { + return nil, fmt.Errorf("get task: %w", err) + } + return task, nil + } + + tasks, err := t.store.SearchTasks(query) + if err != nil { + return nil, fmt.Errorf("search tasks: %w", err) + } + if len(tasks) == 0 { + return nil, fmt.Errorf("no research task found matching %q", query) + } + return tasks[0], nil +} diff --git a/pkg/tools/web.go b/pkg/tools/web.go index 4e5e31f47..25ec6050f 100644 --- a/pkg/tools/web.go +++ b/pkg/tools/web.go @@ -834,6 +834,15 @@ func (t *WebSearchTool) Parameters() map[string]any { } func (t *WebSearchTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + // Enforce heartbeat web search quota + if quota := GetWebSearchQuota(ctx); quota != nil { + if !quota.TryConsume() { + return ErrorResult(fmt.Sprintf( + "web_search quota exhausted (%d/%d used this heartbeat). Work with findings you already have.", + quota.Max(), quota.Max())) + } + } + query, ok := args["query"].(string) if !ok { return ErrorResult("query is required") diff --git a/web/backend/api/research.go b/web/backend/api/research.go index 34bba7103..521c62ef4 100644 --- a/web/backend/api/research.go +++ b/web/backend/api/research.go @@ -29,16 +29,18 @@ func (h *Handler) openResearchStore() (*research.ResearchStore, error) { } type researchTaskJSON struct { - ID string `json:"id"` - Title string `json:"title"` - Slug string `json:"slug"` - Description string `json:"description"` - Status string `json:"status"` - OutputDir string `json:"output_dir"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` - CompletedAt string `json:"completed_at,omitempty"` - DocumentCount int `json:"document_count"` + ID string `json:"id"` + Title string `json:"title"` + Slug string `json:"slug"` + Description string `json:"description"` + Status string `json:"status"` + OutputDir string `json:"output_dir"` + Interval string `json:"interval"` + LastResearchedAt string `json:"last_researched_at,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + CompletedAt string `json:"completed_at,omitempty"` + DocumentCount int `json:"document_count"` } type researchDocJSON struct { @@ -60,10 +62,14 @@ func taskToJSON(t *research.Task, docCount int) researchTaskJSON { Description: t.Description, Status: string(t.Status), OutputDir: t.OutputDir, + Interval: t.Interval, CreatedAt: t.CreatedAt.Format("2006-01-02T15:04:05Z"), UpdatedAt: t.UpdatedAt.Format("2006-01-02T15:04:05Z"), DocumentCount: docCount, } + if !t.LastResearchedAt.IsZero() { + r.LastResearchedAt = t.LastResearchedAt.Format("2006-01-02T15:04:05Z") + } if !t.CompletedAt.IsZero() { r.CompletedAt = t.CompletedAt.Format("2006-01-02T15:04:05Z") } @@ -103,6 +109,7 @@ func (h *Handler) handleResearch(w http.ResponseWriter, r *http.Request) { var req struct { Title string `json:"title"` Description string `json:"description"` + Interval string `json:"interval"` } if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil { http.Error(w, `{"error":"invalid request body"}`, http.StatusBadRequest) @@ -112,7 +119,11 @@ func (h *Handler) handleResearch(w http.ResponseWriter, r *http.Request) { http.Error(w, `{"error":"title is required"}`, http.StatusBadRequest) return } - task, err := store.CreateTask(strings.TrimSpace(req.Title), strings.TrimSpace(req.Description)) + task, err := store.CreateTask( + strings.TrimSpace(req.Title), + strings.TrimSpace(req.Description), + strings.TrimSpace(req.Interval), + ) if err != nil { http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusInternalServerError) return diff --git a/web/frontend/src/api/research.ts b/web/frontend/src/api/research.ts index 236d93b12..f2cae551f 100644 --- a/web/frontend/src/api/research.ts +++ b/web/frontend/src/api/research.ts @@ -5,6 +5,8 @@ export interface ResearchTask { description: string status: "pending" | "active" | "completed" | "failed" | "canceled" output_dir: string + interval: string + last_researched_at?: string created_at: string updated_at: string completed_at?: string @@ -68,18 +70,19 @@ export async function getResearchTask( export async function createResearchTask( title: string, description: string, + interval?: string, ): Promise { return request("/api/research", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ title, description }), + body: JSON.stringify({ title, description, interval }), }) } export async function researchTaskAction( id: string, - action: "cancel" | "reopen" | "update", - data?: { title?: string; description?: string }, + action: "cancel" | "reopen" | "update" | "set_interval", + data?: { title?: string; description?: string; interval?: string }, ): Promise { return request( `/api/research/${encodeURIComponent(id)}`, diff --git a/web/frontend/src/components/research/research-page.tsx b/web/frontend/src/components/research/research-page.tsx index 5598ea1c9..b23d31545 100644 --- a/web/frontend/src/components/research/research-page.tsx +++ b/web/frontend/src/components/research/research-page.tsx @@ -218,9 +218,13 @@ function TaskCard({ task }: { task: ResearchTask }) { count: task.document_count, })} - - {new Date(task.created_at).toLocaleDateString()} - + ⏱ {task.interval || "24h"} + {task.last_researched_at ? ( + + {t("pages.research.last_researched")}:{" "} + {new Date(task.last_researched_at).toLocaleDateString()} + + ) : null} diff --git a/web/frontend/src/components/research/task-detail-page.tsx b/web/frontend/src/components/research/task-detail-page.tsx index 7ffbab5a7..ecbcdb507 100644 --- a/web/frontend/src/components/research/task-detail-page.tsx +++ b/web/frontend/src/components/research/task-detail-page.tsx @@ -171,6 +171,13 @@ export function TaskDetailPage({ taskId }: { taskId: string }) {
+
+ {t("pages.research.interval")}: {task.interval || "24h"} + {task.last_researched_at ? ( + <> · {t("pages.research.last_researched")}:{" "} + {new Date(task.last_researched_at).toLocaleString()} + ) : null} +
{t("pages.research.created_at")}:{" "} {new Date(task.created_at).toLocaleString()} diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index 80a40dab8..9dd4ab3de 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -474,6 +474,8 @@ "documents_title": "Documents ({{count}})", "documents_count": "{{count}} docs", "no_documents": "No documents yet.", + "interval": "Interval", + "last_researched": "Last researched", "created_at": "Created", "completed_at": "Completed", "output_dir": "Output", diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json index e0f8b663a..2a729626f 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -474,6 +474,8 @@ "documents_title": "文档 ({{count}})", "documents_count": "{{count}} 篇文档", "no_documents": "暂无文档。", + "interval": "调查频率", + "last_researched": "最后调查", "created_at": "创建时间", "completed_at": "完成时间", "output_dir": "输出目录",