* 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * fix: lint issues (gci, gofumpt, golines, predeclared) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * fix: use addEventListener for interval select in Mini App Inline onchange on <select> is unreliable in Telegram WebView. Switch to addEventListener('change') bound after innerHTML render. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
290 lines
8.2 KiB
Go
290 lines
8.2 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/sipeed/picoclaw/pkg/config"
|
|
"github.com/sipeed/picoclaw/pkg/research"
|
|
)
|
|
|
|
// registerResearchRoutes binds research task endpoints.
|
|
// These access research.db directly (same SQLite file as the Gateway).
|
|
func (h *Handler) registerResearchRoutes(mux *http.ServeMux) {
|
|
mux.HandleFunc("/api/research", h.handleResearch)
|
|
mux.HandleFunc("/api/research/", h.handleResearchDetail)
|
|
}
|
|
|
|
func (h *Handler) openResearchStore() (*research.ResearchStore, error) {
|
|
cfg, err := config.LoadConfig(h.configPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ws := cfg.WorkspacePath()
|
|
return research.OpenResearchStore(filepath.Join(ws, "research.db"), ws)
|
|
}
|
|
|
|
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"`
|
|
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 {
|
|
ID string `json:"id"`
|
|
TaskID string `json:"task_id"`
|
|
Title string `json:"title"`
|
|
FilePath string `json:"file_path"`
|
|
DocType string `json:"doc_type"`
|
|
Seq int `json:"seq"`
|
|
Summary string `json:"summary"`
|
|
CreatedAt string `json:"created_at"`
|
|
}
|
|
|
|
func taskToJSON(t *research.Task, docCount int) researchTaskJSON {
|
|
r := researchTaskJSON{
|
|
ID: t.ID,
|
|
Title: t.Title,
|
|
Slug: t.Slug,
|
|
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")
|
|
}
|
|
return r
|
|
}
|
|
|
|
func writeJSONRes(w http.ResponseWriter, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
// handleResearch handles GET (list) and POST (create) on /api/research.
|
|
func (h *Handler) handleResearch(w http.ResponseWriter, r *http.Request) {
|
|
store, err := h.openResearchStore()
|
|
if err != nil {
|
|
http.Error(w, `{"error":"failed to open research store"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer store.Close()
|
|
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
statusFilter := r.URL.Query().Get("status")
|
|
tasks, err := store.ListTasks(research.TaskStatus(statusFilter))
|
|
if err != nil {
|
|
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
result := make([]researchTaskJSON, 0, len(tasks))
|
|
for _, t := range tasks {
|
|
dc, _ := store.DocumentCount(t.ID)
|
|
result = append(result, taskToJSON(t, dc))
|
|
}
|
|
writeJSONRes(w, result)
|
|
|
|
case http.MethodPost:
|
|
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)
|
|
return
|
|
}
|
|
if strings.TrimSpace(req.Title) == "" {
|
|
http.Error(w, `{"error":"title is required"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
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
|
|
}
|
|
w.WriteHeader(http.StatusCreated)
|
|
writeJSONRes(w, taskToJSON(task, 0))
|
|
|
|
default:
|
|
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
// handleResearchDetail handles /api/research/{id} and /api/research/{id}/doc/{docId}.
|
|
func (h *Handler) handleResearchDetail(w http.ResponseWriter, r *http.Request) {
|
|
store, err := h.openResearchStore()
|
|
if err != nil {
|
|
http.Error(w, `{"error":"failed to open research store"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer store.Close()
|
|
|
|
path := strings.TrimPrefix(r.URL.Path, "/api/research/")
|
|
parts := strings.Split(path, "/")
|
|
|
|
if len(parts) == 1 && parts[0] != "" {
|
|
taskID := parts[0]
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
h.researchGetTask(w, store, taskID)
|
|
case http.MethodPost:
|
|
h.researchTaskAction(w, r, store, taskID)
|
|
default:
|
|
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
|
}
|
|
return
|
|
}
|
|
|
|
if len(parts) == 3 && parts[1] == "doc" && parts[2] != "" {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
h.researchGetDoc(w, store, parts[0], parts[2])
|
|
return
|
|
}
|
|
|
|
http.NotFound(w, r)
|
|
}
|
|
|
|
func (h *Handler) researchGetTask(w http.ResponseWriter, store *research.ResearchStore, taskID string) {
|
|
task, err := store.GetTask(taskID)
|
|
if err != nil {
|
|
http.Error(w, `{"error":"task not found"}`, http.StatusNotFound)
|
|
return
|
|
}
|
|
docs, _ := store.ListDocuments(taskID)
|
|
docList := make([]researchDocJSON, 0, len(docs))
|
|
for _, d := range docs {
|
|
docList = append(docList, researchDocJSON{
|
|
ID: d.ID,
|
|
TaskID: d.TaskID,
|
|
Title: d.Title,
|
|
FilePath: d.FilePath,
|
|
DocType: d.DocType,
|
|
Seq: d.Seq,
|
|
Summary: d.Summary,
|
|
CreatedAt: d.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
|
})
|
|
}
|
|
writeJSONRes(w, struct {
|
|
researchTaskJSON
|
|
Documents []researchDocJSON `json:"documents"`
|
|
}{
|
|
researchTaskJSON: taskToJSON(task, len(docs)),
|
|
Documents: docList,
|
|
})
|
|
}
|
|
|
|
func (h *Handler) researchTaskAction(
|
|
w http.ResponseWriter, r *http.Request,
|
|
store *research.ResearchStore, taskID string,
|
|
) {
|
|
var req struct {
|
|
Action string `json:"action"`
|
|
Title string `json:"title"`
|
|
Description string `json:"description"`
|
|
}
|
|
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
|
|
http.Error(w, `{"error":"invalid request body"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
switch req.Action {
|
|
case "cancel":
|
|
if err := store.SetTaskStatus(taskID, research.StatusCanceled); err != nil {
|
|
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
case "reopen":
|
|
if err := store.SetTaskStatus(taskID, research.StatusPending); err != nil {
|
|
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
case "update":
|
|
task, err := store.GetTask(taskID)
|
|
if err != nil {
|
|
http.Error(w, `{"error":"task not found"}`, http.StatusNotFound)
|
|
return
|
|
}
|
|
title := req.Title
|
|
if title == "" {
|
|
title = task.Title
|
|
}
|
|
desc := req.Description
|
|
if desc == "" {
|
|
desc = task.Description
|
|
}
|
|
if err := store.UpdateTask(taskID, title, desc); err != nil {
|
|
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
default:
|
|
http.Error(w, `{"error":"unknown action"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
h.researchGetTask(w, store, taskID)
|
|
}
|
|
|
|
func (h *Handler) researchGetDoc(w http.ResponseWriter, store *research.ResearchStore, taskID, docID string) {
|
|
docs, err := store.ListDocuments(taskID)
|
|
if err != nil {
|
|
http.Error(w, `{"error":"failed to list documents"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
var found *research.Document
|
|
for _, d := range docs {
|
|
if d.ID == docID {
|
|
found = d
|
|
break
|
|
}
|
|
}
|
|
if found == nil {
|
|
http.Error(w, `{"error":"document not found"}`, http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
absPath := found.FilePath
|
|
if !filepath.IsAbs(absPath) {
|
|
cfg, cfgErr := config.LoadConfig(h.configPath)
|
|
if cfgErr == nil {
|
|
absPath = filepath.Join(cfg.WorkspacePath(), absPath)
|
|
}
|
|
}
|
|
content, err := os.ReadFile(absPath)
|
|
if err != nil {
|
|
http.Error(w, `{"error":"failed to read document"}`, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
writeJSONRes(w, map[string]any{
|
|
"id": found.ID,
|
|
"title": found.Title,
|
|
"doc_type": found.DocType,
|
|
"content": string(content),
|
|
})
|
|
}
|