feat: add research task system with SQLite store, agent tool, and web UI
Add a deterministic research task lifecycle managed via SQLite, enabling
the agent (via heartbeat) to discover and execute pending research tasks,
and users to create/monitor tasks from both the Mini App and web management UI.
- pkg/research: SQLite store with WAL mode, Task/Document CRUD, status
transition validation (pending→active→completed/failed, reopen support)
- pkg/tools/research: agent tool with list_tasks, get_task, set_status,
add_finding actions; findings written as markdown to research/{slug}/
- pkg/miniapp/api_research: Mini App API endpoints for task management
- web/backend/api/research: management UI API accessing same research.db
- web/frontend: research list page, task detail with document accordion,
sidebar nav item, i18n (en/zh)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3613c3104b
commit
ec1daa7dd1
19 changed files with 3778 additions and 2 deletions
|
|
@ -42,6 +42,7 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/media"
|
"github.com/sipeed/picoclaw/pkg/media"
|
||||||
"github.com/sipeed/picoclaw/pkg/miniapp"
|
"github.com/sipeed/picoclaw/pkg/miniapp"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/research"
|
||||||
"github.com/sipeed/picoclaw/pkg/skills"
|
"github.com/sipeed/picoclaw/pkg/skills"
|
||||||
"github.com/sipeed/picoclaw/pkg/state"
|
"github.com/sipeed/picoclaw/pkg/state"
|
||||||
"github.com/sipeed/picoclaw/pkg/stats"
|
"github.com/sipeed/picoclaw/pkg/stats"
|
||||||
|
|
@ -331,6 +332,19 @@ func setupAndStartServices(
|
||||||
devPreviewTool := tools.NewDevPreviewTool(handler)
|
devPreviewTool := tools.NewDevPreviewTool(handler)
|
||||||
agentLoop.RegisterTool(devPreviewTool)
|
agentLoop.RegisterTool(devPreviewTool)
|
||||||
|
|
||||||
|
// Research store + tool registration
|
||||||
|
researchStore, rsErr := research.OpenResearchStore(
|
||||||
|
filepath.Join(cfg.WorkspacePath(), "research.db"),
|
||||||
|
cfg.WorkspacePath(),
|
||||||
|
)
|
||||||
|
if rsErr != nil {
|
||||||
|
logger.ErrorCF("research", "Failed to open research store", map[string]any{"error": rsErr.Error()})
|
||||||
|
} else {
|
||||||
|
agentLoop.RegisterTool(tools.NewResearchTool(researchStore, cfg.WorkspacePath()))
|
||||||
|
handler.SetResearchStore(researchStore)
|
||||||
|
fmt.Println("✓ Research store initialized")
|
||||||
|
}
|
||||||
|
|
||||||
fmt.Printf("✓ Mini App registered at %s\n", webAppURL)
|
fmt.Printf("✓ Mini App registered at %s\n", webAppURL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
275
pkg/miniapp/api_research.go
Normal file
275
pkg/miniapp/api_research.go
Normal file
|
|
@ -0,0 +1,275 @@
|
||||||
|
package miniapp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/research"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetResearchStore injects the research store into the handler.
|
||||||
|
func (h *Handler) SetResearchStore(rs *research.ResearchStore) {
|
||||||
|
h.researchStore = rs
|
||||||
|
}
|
||||||
|
|
||||||
|
// apiResearch handles GET /miniapp/api/research (list) and POST (create).
|
||||||
|
func (h *Handler) apiResearch(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if h.researchStore == nil {
|
||||||
|
http.Error(w, `{"error":"research store not available"}`, http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
h.apiResearchList(w, r)
|
||||||
|
case http.MethodPost:
|
||||||
|
h.apiResearchCreate(w, r)
|
||||||
|
default:
|
||||||
|
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type researchTaskResponse 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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func taskToResponse(t *research.Task, docCount int) researchTaskResponse {
|
||||||
|
resp := researchTaskResponse{
|
||||||
|
ID: t.ID,
|
||||||
|
Title: t.Title,
|
||||||
|
Slug: t.Slug,
|
||||||
|
Description: t.Description,
|
||||||
|
Status: string(t.Status),
|
||||||
|
OutputDir: t.OutputDir,
|
||||||
|
CreatedAt: t.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
UpdatedAt: t.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
DocumentCount: docCount,
|
||||||
|
}
|
||||||
|
if !t.CompletedAt.IsZero() {
|
||||||
|
resp.CompletedAt = t.CompletedAt.Format("2006-01-02T15:04:05Z")
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) apiResearchList(w http.ResponseWriter, r *http.Request) {
|
||||||
|
statusFilter := r.URL.Query().Get("status")
|
||||||
|
tasks, err := h.researchStore.ListTasks(research.TaskStatus(statusFilter))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]researchTaskResponse, 0, len(tasks))
|
||||||
|
for _, t := range tasks {
|
||||||
|
docCount, _ := h.researchStore.DocumentCount(t.ID)
|
||||||
|
result = append(result, taskToResponse(t, docCount))
|
||||||
|
}
|
||||||
|
writeJSON(w, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) apiResearchCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.Title) == "" {
|
||||||
|
http.Error(w, `{"error":"title is required"}`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
task, err := h.researchStore.CreateTask(strings.TrimSpace(req.Title), strings.TrimSpace(req.Description))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
writeJSON(w, taskToResponse(task, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
// apiResearchDetail handles /miniapp/api/research/{id} and /miniapp/api/research/{id}/doc/{docId}.
|
||||||
|
func (h *Handler) apiResearchDetail(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if h.researchStore == nil {
|
||||||
|
http.Error(w, `{"error":"research store not available"}`, http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse path: /miniapp/api/research/{id} or /miniapp/api/research/{id}/doc/{docId}
|
||||||
|
path := strings.TrimPrefix(r.URL.Path, "/miniapp/api/research/")
|
||||||
|
parts := strings.Split(path, "/")
|
||||||
|
|
||||||
|
if len(parts) == 1 && parts[0] != "" {
|
||||||
|
// /miniapp/api/research/{id}
|
||||||
|
taskID := parts[0]
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
h.apiResearchGetTask(w, taskID)
|
||||||
|
case http.MethodPost:
|
||||||
|
h.apiResearchTaskAction(w, r, taskID)
|
||||||
|
default:
|
||||||
|
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(parts) == 3 && parts[1] == "doc" && parts[2] != "" {
|
||||||
|
// /miniapp/api/research/{id}/doc/{docId}
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.apiResearchGetDoc(w, parts[0], parts[2])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
type researchDocResponse 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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type researchTaskDetailResponse struct {
|
||||||
|
researchTaskResponse
|
||||||
|
Documents []researchDocResponse `json:"documents"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) apiResearchGetTask(w http.ResponseWriter, taskID string) {
|
||||||
|
task, err := h.researchStore.GetTask(taskID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"task not found"}`, http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
docs, _ := h.researchStore.ListDocuments(taskID)
|
||||||
|
docResponses := make([]researchDocResponse, 0, len(docs))
|
||||||
|
for _, d := range docs {
|
||||||
|
docResponses = append(docResponses, researchDocResponse{
|
||||||
|
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"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, researchTaskDetailResponse{
|
||||||
|
researchTaskResponse: taskToResponse(task, len(docs)),
|
||||||
|
Documents: docResponses,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) apiResearchTaskAction(w http.ResponseWriter, r *http.Request, 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 := h.researchStore.SetTaskStatus(taskID, research.StatusCanceled); err != nil {
|
||||||
|
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case "reopen":
|
||||||
|
if err := h.researchStore.SetTaskStatus(taskID, research.StatusPending); err != nil {
|
||||||
|
http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case "update":
|
||||||
|
task, err := h.researchStore.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 := h.researchStore.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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return updated task
|
||||||
|
h.apiResearchGetTask(w, taskID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) apiResearchGetDoc(w http.ResponseWriter, taskID, docID string) {
|
||||||
|
docs, err := h.researchStore.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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read file content - use workspace-relative path
|
||||||
|
absPath := found.FilePath
|
||||||
|
if !filepath.IsAbs(absPath) {
|
||||||
|
absPath = filepath.Join(h.workspace, absPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := os.ReadFile(absPath)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"failed to read document"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, map[string]any{
|
||||||
|
"id": found.ID,
|
||||||
|
"title": found.Title,
|
||||||
|
"doc_type": found.DocType,
|
||||||
|
"content": string(content),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ import (
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/orch"
|
"github.com/sipeed/picoclaw/pkg/orch"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/research"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:generate bun run --cwd frontend build
|
//go:generate bun run --cwd frontend build
|
||||||
|
|
@ -38,6 +39,7 @@ type Handler struct {
|
||||||
allowList []string
|
allowList []string
|
||||||
workspace string
|
workspace string
|
||||||
orchBroadcaster *orch.Broadcaster
|
orchBroadcaster *orch.Broadcaster
|
||||||
|
researchStore *research.ResearchStore
|
||||||
|
|
||||||
devMu sync.RWMutex
|
devMu sync.RWMutex
|
||||||
devTarget *url.URL
|
devTarget *url.URL
|
||||||
|
|
@ -103,6 +105,8 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||||
mux.HandleFunc("/miniapp/api/orchestration/ws", h.requireAuth(h.wsOrchestration))
|
mux.HandleFunc("/miniapp/api/orchestration/ws", h.requireAuth(h.wsOrchestration))
|
||||||
mux.HandleFunc("/miniapp/dev/console", h.apiDevConsole)
|
mux.HandleFunc("/miniapp/dev/console", h.apiDevConsole)
|
||||||
mux.HandleFunc("/miniapp/dev/", h.serveDevProxy)
|
mux.HandleFunc("/miniapp/dev/", h.serveDevProxy)
|
||||||
|
mux.HandleFunc("/miniapp/api/research", h.requireAuth(h.apiResearch))
|
||||||
|
mux.HandleFunc("/miniapp/api/research/", h.requireAuth(h.apiResearchDetail))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) serveIndex(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) serveIndex(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
|
||||||
316
pkg/research/store.go
Normal file
316
pkg/research/store.go
Normal file
|
|
@ -0,0 +1,316 @@
|
||||||
|
package research
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
const sqliteDriver = "sqlite"
|
||||||
|
|
||||||
|
const schema = `
|
||||||
|
CREATE TABLE IF NOT EXISTS research_tasks (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
output_dir TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
completed_at TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS research_documents (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
task_id TEXT NOT NULL REFERENCES research_tasks(id) ON DELETE CASCADE,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
doc_type TEXT NOT NULL DEFAULT 'finding',
|
||||||
|
seq INTEGER NOT NULL DEFAULT 0,
|
||||||
|
summary TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_research_documents_task ON research_documents(task_id, seq);
|
||||||
|
`
|
||||||
|
|
||||||
|
// ResearchStore manages research tasks and documents in SQLite.
|
||||||
|
type ResearchStore struct {
|
||||||
|
db *sql.DB
|
||||||
|
workspace string
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenResearchStore opens (or creates) the research SQLite database.
|
||||||
|
func OpenResearchStore(dbPath, workspace string) (*ResearchStore, error) {
|
||||||
|
connStr := "file:" + dbPath + "?_journal_mode=WAL&_foreign_keys=on&_busy_timeout=5000"
|
||||||
|
db, err := sql.Open(sqliteDriver, connStr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open research db: %w", err)
|
||||||
|
}
|
||||||
|
db.SetMaxOpenConns(1)
|
||||||
|
db.SetMaxIdleConns(1)
|
||||||
|
|
||||||
|
if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil {
|
||||||
|
_ = db.Close()
|
||||||
|
return nil, fmt.Errorf("enable foreign keys: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(schema); err != nil {
|
||||||
|
_ = db.Close()
|
||||||
|
return nil, fmt.Errorf("create schema: %w", err)
|
||||||
|
}
|
||||||
|
return &ResearchStore{db: db, workspace: workspace}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the database connection.
|
||||||
|
func (s *ResearchStore) Close() error {
|
||||||
|
return s.db.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTask creates a new research task with auto-generated slug and output directory.
|
||||||
|
func (s *ResearchStore) CreateTask(title, description string) (*Task, error) {
|
||||||
|
id := uuid.New().String()
|
||||||
|
slug := slugify(title)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
outputDir := filepath.Join("research", slug)
|
||||||
|
|
||||||
|
// Ensure output directory exists
|
||||||
|
absDir := filepath.Join(s.workspace, outputDir)
|
||||||
|
if err := os.MkdirAll(absDir, 0o755); err != nil {
|
||||||
|
return nil, fmt.Errorf("create output dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("insert task: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Task{
|
||||||
|
ID: id,
|
||||||
|
Title: title,
|
||||||
|
Slug: slug,
|
||||||
|
Description: description,
|
||||||
|
Status: StatusPending,
|
||||||
|
OutputDir: outputDir,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
return scanTask(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTasks returns tasks filtered by status. Empty status returns all.
|
||||||
|
func (s *ResearchStore) ListTasks(status TaskStatus) ([]*Task, error) {
|
||||||
|
var rows *sql.Rows
|
||||||
|
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`)
|
||||||
|
} 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))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list 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()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTaskStatus updates task status with transition validation.
|
||||||
|
func (s *ResearchStore) SetTaskStatus(id string, status TaskStatus) error {
|
||||||
|
task, err := s.GetTask(id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !CanTransition(task.Status, status) {
|
||||||
|
return fmt.Errorf("invalid transition: %s → %s", task.Status, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
completedAt := ""
|
||||||
|
if status == StatusCompleted || status == StatusFailed {
|
||||||
|
completedAt = now
|
||||||
|
}
|
||||||
|
_, err = s.db.Exec(
|
||||||
|
`UPDATE research_tasks SET status = ?, updated_at = ?, completed_at = ? WHERE id = ?`,
|
||||||
|
string(status), now, completedAt, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateTask updates a task's title and/or description.
|
||||||
|
func (s *ResearchStore) UpdateTask(id, title, description string) error {
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
_, err := s.db.Exec(
|
||||||
|
`UPDATE research_tasks SET title = ?, description = ?, updated_at = ? WHERE id = ?`,
|
||||||
|
title, description, now, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteTask deletes a task and its documents (cascade).
|
||||||
|
func (s *ResearchStore) DeleteTask(id string) error {
|
||||||
|
_, err := s.db.Exec(`DELETE FROM research_tasks WHERE id = ?`, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddDocument adds a document record linked to a task with auto-incrementing seq.
|
||||||
|
func (s *ResearchStore) AddDocument(taskID, title, filePath, docType, summary string) (*Document, error) {
|
||||||
|
id := uuid.New().String()
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
// Auto-increment seq
|
||||||
|
var maxSeq int
|
||||||
|
err := s.db.QueryRow(
|
||||||
|
`SELECT COALESCE(MAX(seq), 0) FROM research_documents WHERE task_id = ?`, taskID).Scan(&maxSeq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get max seq: %w", err)
|
||||||
|
}
|
||||||
|
seq := maxSeq + 1
|
||||||
|
|
||||||
|
nowStr := now.Format(time.RFC3339)
|
||||||
|
_, err = s.db.Exec(
|
||||||
|
`INSERT INTO research_documents (id, task_id, title, file_path, doc_type, seq, summary, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
id, taskID, title, filePath, docType, seq, summary, nowStr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("insert document: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Document{
|
||||||
|
ID: id,
|
||||||
|
TaskID: taskID,
|
||||||
|
Title: title,
|
||||||
|
FilePath: filePath,
|
||||||
|
DocType: docType,
|
||||||
|
Seq: seq,
|
||||||
|
Summary: summary,
|
||||||
|
CreatedAt: now,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListDocuments returns all documents for a task, ordered by seq.
|
||||||
|
func (s *ResearchStore) ListDocuments(taskID string) ([]*Document, error) {
|
||||||
|
rows, err := s.db.Query(
|
||||||
|
`SELECT id, task_id, title, file_path, doc_type, seq, summary, created_at
|
||||||
|
FROM research_documents WHERE task_id = ? ORDER BY seq`, taskID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list documents: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var docs []*Document
|
||||||
|
for rows.Next() {
|
||||||
|
var d Document
|
||||||
|
var createdStr string
|
||||||
|
if err := rows.Scan(
|
||||||
|
&d.ID, &d.TaskID, &d.Title, &d.FilePath,
|
||||||
|
&d.DocType, &d.Seq, &d.Summary, &createdStr,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
d.CreatedAt, _ = time.Parse(time.RFC3339, createdStr)
|
||||||
|
docs = append(docs, &d)
|
||||||
|
}
|
||||||
|
return docs, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DocumentCount returns the number of documents for a task.
|
||||||
|
func (s *ResearchStore) DocumentCount(taskID string) (int, error) {
|
||||||
|
var count int
|
||||||
|
err := s.db.QueryRow(`SELECT COUNT(*) FROM research_documents WHERE task_id = ?`, taskID).Scan(&count)
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers ---
|
||||||
|
|
||||||
|
func scanTask(row *sql.Row) (*Task, error) {
|
||||||
|
var t Task
|
||||||
|
var statusStr, createdStr, updatedStr, completedStr string
|
||||||
|
err := row.Scan(&t.ID, &t.Title, &t.Slug, &t.Description, &statusStr,
|
||||||
|
&t.OutputDir, &createdStr, &updatedStr, &completedStr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
t.Status = TaskStatus(statusStr)
|
||||||
|
t.CreatedAt, _ = time.Parse(time.RFC3339, createdStr)
|
||||||
|
t.UpdatedAt, _ = time.Parse(time.RFC3339, updatedStr)
|
||||||
|
if completedStr != "" {
|
||||||
|
t.CompletedAt, _ = time.Parse(time.RFC3339, completedStr)
|
||||||
|
}
|
||||||
|
return &t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type rowScanner interface {
|
||||||
|
Scan(dest ...any) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanTaskRow(row rowScanner) (*Task, error) {
|
||||||
|
var t Task
|
||||||
|
var statusStr, createdStr, updatedStr, completedStr string
|
||||||
|
err := row.Scan(&t.ID, &t.Title, &t.Slug, &t.Description, &statusStr,
|
||||||
|
&t.OutputDir, &createdStr, &updatedStr, &completedStr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
t.Status = TaskStatus(statusStr)
|
||||||
|
t.CreatedAt, _ = time.Parse(time.RFC3339, createdStr)
|
||||||
|
t.UpdatedAt, _ = time.Parse(time.RFC3339, updatedStr)
|
||||||
|
if completedStr != "" {
|
||||||
|
t.CompletedAt, _ = time.Parse(time.RFC3339, completedStr)
|
||||||
|
}
|
||||||
|
return &t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var nonAlphaNum = regexp.MustCompile(`[^a-z0-9]+`)
|
||||||
|
|
||||||
|
func slugify(s string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range strings.ToLower(s) {
|
||||||
|
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
||||||
|
b.WriteRune(r)
|
||||||
|
} else {
|
||||||
|
b.WriteRune('-')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slug := nonAlphaNum.ReplaceAllString(b.String(), "-")
|
||||||
|
slug = strings.Trim(slug, "-")
|
||||||
|
if len(slug) > 60 {
|
||||||
|
slug = slug[:60]
|
||||||
|
}
|
||||||
|
if slug == "" {
|
||||||
|
slug = "task"
|
||||||
|
}
|
||||||
|
// Append short UUID suffix for uniqueness
|
||||||
|
suffix := uuid.New().String()[:8]
|
||||||
|
return slug + "-" + suffix
|
||||||
|
}
|
||||||
215
pkg/research/store_test.go
Normal file
215
pkg/research/store_test.go
Normal file
|
|
@ -0,0 +1,215 @@
|
||||||
|
package research
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupTestStore(t *testing.T) (*ResearchStore, string) {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
dbPath := filepath.Join(dir, "research.db")
|
||||||
|
store, err := OpenResearchStore(dbPath, dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open store: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { store.Close() })
|
||||||
|
return store, dir
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateAndGetTask(t *testing.T) {
|
||||||
|
store, dir := setupTestStore(t)
|
||||||
|
|
||||||
|
task, err := store.CreateTask("Test Research", "A test description")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create task: %v", err)
|
||||||
|
}
|
||||||
|
if task.Title != "Test Research" {
|
||||||
|
t.Errorf("title = %q, want %q", task.Title, "Test Research")
|
||||||
|
}
|
||||||
|
if task.Status != StatusPending {
|
||||||
|
t.Errorf("status = %q, want %q", task.Status, StatusPending)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify output directory was created
|
||||||
|
absDir := filepath.Join(dir, task.OutputDir)
|
||||||
|
if _, statErr := os.Stat(absDir); os.IsNotExist(statErr) {
|
||||||
|
t.Errorf("output dir %q not created", absDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := store.GetTask(task.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get task: %v", err)
|
||||||
|
}
|
||||||
|
if got.Title != task.Title || got.Slug != task.Slug {
|
||||||
|
t.Errorf("get mismatch: got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListTasks(t *testing.T) {
|
||||||
|
store, _ := setupTestStore(t)
|
||||||
|
|
||||||
|
store.CreateTask("Task A", "")
|
||||||
|
store.CreateTask("Task B", "")
|
||||||
|
|
||||||
|
all, err := store.ListTasks("")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list all: %v", err)
|
||||||
|
}
|
||||||
|
if len(all) != 2 {
|
||||||
|
t.Errorf("list all = %d, want 2", len(all))
|
||||||
|
}
|
||||||
|
|
||||||
|
pending, err := store.ListTasks(StatusPending)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list pending: %v", err)
|
||||||
|
}
|
||||||
|
if len(pending) != 2 {
|
||||||
|
t.Errorf("list pending = %d, want 2", len(pending))
|
||||||
|
}
|
||||||
|
|
||||||
|
active, err := store.ListTasks(StatusActive)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list active: %v", err)
|
||||||
|
}
|
||||||
|
if len(active) != 0 {
|
||||||
|
t.Errorf("list active = %d, want 0", len(active))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetTaskStatus(t *testing.T) {
|
||||||
|
store, _ := setupTestStore(t)
|
||||||
|
|
||||||
|
task, _ := store.CreateTask("Status Test", "")
|
||||||
|
|
||||||
|
// Valid: pending → active
|
||||||
|
if err := store.SetTaskStatus(task.ID, StatusActive); err != nil {
|
||||||
|
t.Fatalf("pending→active: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid: active → completed
|
||||||
|
if err := store.SetTaskStatus(task.ID, StatusCompleted); err != nil {
|
||||||
|
t.Fatalf("active→completed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify completed_at is set
|
||||||
|
got, _ := store.GetTask(task.ID)
|
||||||
|
if got.CompletedAt.IsZero() {
|
||||||
|
t.Error("completed_at should be set")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid: completed → pending (reopen)
|
||||||
|
if err := store.SetTaskStatus(task.ID, StatusPending); err != nil {
|
||||||
|
t.Fatalf("completed→pending: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalid: pending → completed
|
||||||
|
if err := store.SetTaskStatus(task.ID, StatusCompleted); err == nil {
|
||||||
|
t.Error("pending→completed should fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddAndListDocuments(t *testing.T) {
|
||||||
|
store, dir := setupTestStore(t)
|
||||||
|
|
||||||
|
task, _ := store.CreateTask("Doc Test", "")
|
||||||
|
|
||||||
|
// Create a test file
|
||||||
|
filePath := filepath.Join(dir, task.OutputDir, "001-finding.md")
|
||||||
|
os.WriteFile(filePath, []byte("# Finding\nSome content"), 0o644)
|
||||||
|
|
||||||
|
doc, err := store.AddDocument(task.ID, "Finding 1", filePath, "finding", "A brief summary")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("add document: %v", err)
|
||||||
|
}
|
||||||
|
if doc.Seq != 1 {
|
||||||
|
t.Errorf("seq = %d, want 1", doc.Seq)
|
||||||
|
}
|
||||||
|
|
||||||
|
doc2, _ := store.AddDocument(task.ID, "Finding 2", "path2.md", "finding", "")
|
||||||
|
if doc2.Seq != 2 {
|
||||||
|
t.Errorf("seq = %d, want 2", doc2.Seq)
|
||||||
|
}
|
||||||
|
|
||||||
|
docs, err := store.ListDocuments(task.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list documents: %v", err)
|
||||||
|
}
|
||||||
|
if len(docs) != 2 {
|
||||||
|
t.Errorf("list docs = %d, want 2", len(docs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDocumentCount(t *testing.T) {
|
||||||
|
store, _ := setupTestStore(t)
|
||||||
|
|
||||||
|
task, _ := store.CreateTask("Count Test", "")
|
||||||
|
|
||||||
|
count, _ := store.DocumentCount(task.ID)
|
||||||
|
if count != 0 {
|
||||||
|
t.Errorf("count = %d, want 0", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
store.AddDocument(task.ID, "D1", "p1", "finding", "")
|
||||||
|
store.AddDocument(task.ID, "D2", "p2", "note", "")
|
||||||
|
|
||||||
|
count, _ = store.DocumentCount(task.ID)
|
||||||
|
if count != 2 {
|
||||||
|
t.Errorf("count = %d, want 2", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteTask(t *testing.T) {
|
||||||
|
store, _ := setupTestStore(t)
|
||||||
|
|
||||||
|
task, _ := store.CreateTask("Delete Test", "")
|
||||||
|
store.AddDocument(task.ID, "D1", "p1", "finding", "")
|
||||||
|
|
||||||
|
if err := store.DeleteTask(task.ID); err != nil {
|
||||||
|
t.Fatalf("delete: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Documents should be cascade deleted
|
||||||
|
docs, _ := store.ListDocuments(task.ID)
|
||||||
|
if len(docs) != 0 {
|
||||||
|
t.Errorf("docs after delete = %d, want 0", len(docs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateTask(t *testing.T) {
|
||||||
|
store, _ := setupTestStore(t)
|
||||||
|
|
||||||
|
task, _ := store.CreateTask("Original", "desc")
|
||||||
|
if err := store.UpdateTask(task.ID, "Updated", "new desc"); err != nil {
|
||||||
|
t.Fatalf("update: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, _ := store.GetTask(task.ID)
|
||||||
|
if got.Title != "Updated" || got.Description != "new desc" {
|
||||||
|
t.Errorf("after update: title=%q desc=%q", got.Title, got.Description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCanTransition(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
from, to TaskStatus
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{StatusPending, StatusActive, true},
|
||||||
|
{StatusPending, StatusCanceled, true},
|
||||||
|
{StatusPending, StatusCompleted, false},
|
||||||
|
{StatusActive, StatusCompleted, true},
|
||||||
|
{StatusActive, StatusFailed, true},
|
||||||
|
{StatusActive, StatusCanceled, true},
|
||||||
|
{StatusActive, StatusPending, false},
|
||||||
|
{StatusCompleted, StatusPending, true},
|
||||||
|
{StatusFailed, StatusPending, true},
|
||||||
|
{StatusCanceled, StatusPending, false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
if got := CanTransition(tc.from, tc.to); got != tc.ok {
|
||||||
|
t.Errorf("CanTransition(%s, %s) = %v, want %v", tc.from, tc.to, got, tc.ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
55
pkg/research/types.go
Normal file
55
pkg/research/types.go
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
package research
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// TaskStatus represents the lifecycle state of a research task.
|
||||||
|
type TaskStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusPending TaskStatus = "pending"
|
||||||
|
StatusActive TaskStatus = "active"
|
||||||
|
StatusCompleted TaskStatus = "completed"
|
||||||
|
StatusFailed TaskStatus = "failed"
|
||||||
|
StatusCanceled TaskStatus = "canceled"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Document represents a research output document linked to a task.
|
||||||
|
type Document struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
TaskID string `json:"task_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
FilePath string `json:"file_path"`
|
||||||
|
DocType string `json:"doc_type"` // "finding" | "summary" | "note"
|
||||||
|
Seq int `json:"seq"`
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// validTransitions defines which status transitions are allowed.
|
||||||
|
var validTransitions = map[TaskStatus]map[TaskStatus]bool{
|
||||||
|
StatusPending: {StatusActive: true, StatusCanceled: true},
|
||||||
|
StatusActive: {StatusCompleted: true, StatusFailed: true, StatusCanceled: true},
|
||||||
|
StatusCompleted: {StatusPending: true},
|
||||||
|
StatusFailed: {StatusPending: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanTransition checks whether transitioning from one status to another is allowed.
|
||||||
|
func CanTransition(from, to TaskStatus) bool {
|
||||||
|
if m, ok := validTransitions[from]; ok {
|
||||||
|
return m[to]
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
206
pkg/tools/research.go
Normal file
206
pkg/tools/research.go
Normal file
|
|
@ -0,0 +1,206 @@
|
||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/research"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ResearchTool provides research task management for the agent.
|
||||||
|
type ResearchTool struct {
|
||||||
|
store *research.ResearchStore
|
||||||
|
workspace string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewResearchTool creates a new ResearchTool.
|
||||||
|
func NewResearchTool(store *research.ResearchStore, workspace string) *ResearchTool {
|
||||||
|
return &ResearchTool{store: store, workspace: workspace}
|
||||||
|
}
|
||||||
|
|
||||||
|
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."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ResearchTool) Parameters() map[string]any {
|
||||||
|
return 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"},
|
||||||
|
"description": "Action to perform.",
|
||||||
|
},
|
||||||
|
"task_id": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Task ID (required for get_task, set_status, add_finding).",
|
||||||
|
},
|
||||||
|
"status_filter": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"enum": []string{"pending", "active", "completed", "failed", "canceled"},
|
||||||
|
"description": "Filter tasks by status (for list_tasks). Omit to list all.",
|
||||||
|
},
|
||||||
|
"status": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"enum": []string{"pending", "active", "completed", "failed", "canceled"},
|
||||||
|
"description": "New status (for set_status).",
|
||||||
|
},
|
||||||
|
"title": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Finding title (for add_finding).",
|
||||||
|
},
|
||||||
|
"content": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Markdown content to write (for add_finding).",
|
||||||
|
},
|
||||||
|
"summary": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Brief summary of the finding (for add_finding).",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"action"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ResearchTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
||||||
|
action, _ := args["action"].(string)
|
||||||
|
switch action {
|
||||||
|
case "list_tasks":
|
||||||
|
return t.listTasks(args)
|
||||||
|
case "get_task":
|
||||||
|
return t.getTask(args)
|
||||||
|
case "set_status":
|
||||||
|
return t.setStatus(args)
|
||||||
|
case "add_finding":
|
||||||
|
return t.addFinding(args)
|
||||||
|
default:
|
||||||
|
return ErrorResult("unknown action: " + action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ResearchTool) listTasks(args map[string]any) *ToolResult {
|
||||||
|
filter, _ := args["status_filter"].(string)
|
||||||
|
tasks, err := t.store.ListTasks(research.TaskStatus(filter))
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(fmt.Sprintf("list tasks: %v", err))
|
||||||
|
}
|
||||||
|
if len(tasks) == 0 {
|
||||||
|
return NewToolResult("No research tasks found.")
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(fmt.Sprintf("Found %d research task(s):\n\n", len(tasks)))
|
||||||
|
for _, task := range tasks {
|
||||||
|
docCount, _ := t.store.DocumentCount(task.ID)
|
||||||
|
b.WriteString(fmt.Sprintf("- **%s** [%s] (id: %s, docs: %d)\n %s\n",
|
||||||
|
task.Title, task.Status, task.ID, docCount, task.Description))
|
||||||
|
}
|
||||||
|
return NewToolResult(b.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ResearchTool) getTask(args map[string]any) *ToolResult {
|
||||||
|
taskID, _ := args["task_id"].(string)
|
||||||
|
if taskID == "" {
|
||||||
|
return ErrorResult("task_id is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
task, err := t.store.GetTask(taskID)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(fmt.Sprintf("get task: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
docs, _ := t.store.ListDocuments(taskID)
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(fmt.Sprintf("## %s\n", task.Title))
|
||||||
|
b.WriteString(fmt.Sprintf("- **Status**: %s\n", task.Status))
|
||||||
|
b.WriteString(fmt.Sprintf("- **ID**: %s\n", task.ID))
|
||||||
|
b.WriteString(fmt.Sprintf("- **Output dir**: %s\n", task.OutputDir))
|
||||||
|
if task.Description != "" {
|
||||||
|
b.WriteString(fmt.Sprintf("- **Description**: %s\n", task.Description))
|
||||||
|
}
|
||||||
|
b.WriteString(fmt.Sprintf("- **Created**: %s\n", task.CreatedAt.Format("2006-01-02 15:04")))
|
||||||
|
|
||||||
|
if len(docs) > 0 {
|
||||||
|
b.WriteString(fmt.Sprintf("\n### Documents (%d)\n", len(docs)))
|
||||||
|
for _, d := range docs {
|
||||||
|
b.WriteString(fmt.Sprintf("- [%d] %s (%s) — %s\n path: %s\n",
|
||||||
|
d.Seq, d.Title, d.DocType, d.Summary, d.FilePath))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
b.WriteString("\nNo documents yet.\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return NewToolResult(b.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ResearchTool) setStatus(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")
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
var sanitizeRe = regexp.MustCompile(`[^a-zA-Z0-9_-]+`)
|
||||||
|
|
||||||
|
func (t *ResearchTool) addFinding(args map[string]any) *ToolResult {
|
||||||
|
taskID, _ := args["task_id"].(string)
|
||||||
|
title, _ := args["title"].(string)
|
||||||
|
content, _ := args["content"].(string)
|
||||||
|
summary, _ := args["summary"].(string)
|
||||||
|
if taskID == "" || title == "" || content == "" {
|
||||||
|
return ErrorResult("task_id, title, and content are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
task, err := t.store.GetTask(taskID)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(fmt.Sprintf("get task: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine next seq
|
||||||
|
docs, _ := t.store.ListDocuments(taskID)
|
||||||
|
nextSeq := len(docs) + 1
|
||||||
|
|
||||||
|
// Build filename
|
||||||
|
sanitized := sanitizeRe.ReplaceAllString(strings.ToLower(title), "-")
|
||||||
|
sanitized = strings.Trim(sanitized, "-")
|
||||||
|
if len(sanitized) > 50 {
|
||||||
|
sanitized = sanitized[:50]
|
||||||
|
}
|
||||||
|
filename := fmt.Sprintf("%03d-%s.md", nextSeq, sanitized)
|
||||||
|
relPath := filepath.Join(task.OutputDir, filename)
|
||||||
|
absPath := filepath.Join(t.workspace, relPath)
|
||||||
|
|
||||||
|
// Ensure parent dir exists
|
||||||
|
if mkErr := os.MkdirAll(filepath.Dir(absPath), 0o755); mkErr != nil {
|
||||||
|
return ErrorResult(fmt.Sprintf("create dir: %v", mkErr))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write markdown
|
||||||
|
if wErr := os.WriteFile(absPath, []byte(content), 0o644); wErr != nil {
|
||||||
|
return ErrorResult(fmt.Sprintf("write file: %v", wErr))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record in DB
|
||||||
|
doc, err := t.store.AddDocument(taskID, title, relPath, "finding", summary)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResult(fmt.Sprintf("add document: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
return NewToolResult(fmt.Sprintf(
|
||||||
|
"Finding recorded:\n- File: %s\n- Document ID: %s\n- Seq: %d",
|
||||||
|
relPath, doc.ID, doc.Seq,
|
||||||
|
))
|
||||||
|
}
|
||||||
279
web/backend/api/research.go
Normal file
279
web/backend/api/research.go
Normal file
|
|
@ -0,0 +1,279 @@
|
||||||
|
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"`
|
||||||
|
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,
|
||||||
|
CreatedAt: t.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
UpdatedAt: t.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
DocumentCount: docCount,
|
||||||
|
}
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
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))
|
||||||
|
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),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -69,4 +69,7 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||||
|
|
||||||
// Launcher service parameters (port/public)
|
// Launcher service parameters (port/public)
|
||||||
h.registerLauncherConfigRoutes(mux)
|
h.registerLauncherConfigRoutes(mux)
|
||||||
|
|
||||||
|
// Research tasks (proxy to gateway)
|
||||||
|
h.registerResearchRoutes(mux)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1650
web/frontend/bun.lock
Normal file
1650
web/frontend/bun.lock
Normal file
File diff suppressed because it is too large
Load diff
101
web/frontend/src/api/research.ts
Normal file
101
web/frontend/src/api/research.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
export interface ResearchTask {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
slug: string
|
||||||
|
description: string
|
||||||
|
status: "pending" | "active" | "completed" | "failed" | "canceled"
|
||||||
|
output_dir: string
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
completed_at?: string
|
||||||
|
document_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResearchDocument {
|
||||||
|
id: string
|
||||||
|
task_id: string
|
||||||
|
title: string
|
||||||
|
file_path: string
|
||||||
|
doc_type: "finding" | "summary" | "note"
|
||||||
|
seq: number
|
||||||
|
summary: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResearchTaskDetail extends ResearchTask {
|
||||||
|
documents: ResearchDocument[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResearchDocContent {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
doc_type: string
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||||
|
const res = await fetch(path, options)
|
||||||
|
if (!res.ok) {
|
||||||
|
let message = `API error: ${res.status} ${res.statusText}`
|
||||||
|
try {
|
||||||
|
const body = (await res.json()) as { error?: string }
|
||||||
|
if (typeof body.error === "string" && body.error.trim() !== "") {
|
||||||
|
message = body.error
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
throw new Error(message)
|
||||||
|
}
|
||||||
|
return res.json() as Promise<T>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getResearchTasks(
|
||||||
|
status?: string,
|
||||||
|
): Promise<ResearchTask[]> {
|
||||||
|
const params = status ? `?status=${encodeURIComponent(status)}` : ""
|
||||||
|
return request<ResearchTask[]>(`/api/research${params}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getResearchTask(
|
||||||
|
id: string,
|
||||||
|
): Promise<ResearchTaskDetail> {
|
||||||
|
return request<ResearchTaskDetail>(
|
||||||
|
`/api/research/${encodeURIComponent(id)}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createResearchTask(
|
||||||
|
title: string,
|
||||||
|
description: string,
|
||||||
|
): Promise<ResearchTask> {
|
||||||
|
return request<ResearchTask>("/api/research", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ title, description }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function researchTaskAction(
|
||||||
|
id: string,
|
||||||
|
action: "cancel" | "reopen" | "update",
|
||||||
|
data?: { title?: string; description?: string },
|
||||||
|
): Promise<ResearchTaskDetail> {
|
||||||
|
return request<ResearchTaskDetail>(
|
||||||
|
`/api/research/${encodeURIComponent(id)}`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ action, ...data }),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getResearchDocContent(
|
||||||
|
taskId: string,
|
||||||
|
docId: string,
|
||||||
|
): Promise<ResearchDocContent> {
|
||||||
|
return request<ResearchDocContent>(
|
||||||
|
`/api/research/${encodeURIComponent(taskId)}/doc/${encodeURIComponent(docId)}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ import {
|
||||||
IconAtom,
|
IconAtom,
|
||||||
IconChevronsDown,
|
IconChevronsDown,
|
||||||
IconChevronsUp,
|
IconChevronsUp,
|
||||||
|
IconFileSearch,
|
||||||
IconKey,
|
IconKey,
|
||||||
IconListDetails,
|
IconListDetails,
|
||||||
IconMessageCircle,
|
IconMessageCircle,
|
||||||
|
|
@ -132,6 +133,12 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||||
icon: IconTools,
|
icon: IconTools,
|
||||||
translateTitle: true,
|
translateTitle: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "navigation.research",
|
||||||
|
url: "/research",
|
||||||
|
icon: IconFileSearch,
|
||||||
|
translateTitle: true,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
229
web/frontend/src/components/research/research-page.tsx
Normal file
229
web/frontend/src/components/research/research-page.tsx
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
import {
|
||||||
|
IconCircleCheck,
|
||||||
|
IconCircleDashed,
|
||||||
|
IconCircleX,
|
||||||
|
IconLoader2,
|
||||||
|
IconPlayerPlay,
|
||||||
|
IconPlus,
|
||||||
|
} from "@tabler/icons-react"
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||||
|
import { Link } from "@tanstack/react-router"
|
||||||
|
import * as React from "react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import {
|
||||||
|
type ResearchTask,
|
||||||
|
createResearchTask,
|
||||||
|
getResearchTasks,
|
||||||
|
} from "@/api/research"
|
||||||
|
import { PageHeader } from "@/components/page-header"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
SheetTrigger,
|
||||||
|
} from "@/components/ui/sheet"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const statusConfig: Record<
|
||||||
|
ResearchTask["status"],
|
||||||
|
{ icon: React.ComponentType<{ className?: string }>; color: string }
|
||||||
|
> = {
|
||||||
|
pending: { icon: IconCircleDashed, color: "text-yellow-600 bg-yellow-50" },
|
||||||
|
active: { icon: IconPlayerPlay, color: "text-blue-600 bg-blue-50" },
|
||||||
|
completed: { icon: IconCircleCheck, color: "text-green-600 bg-green-50" },
|
||||||
|
failed: { icon: IconCircleX, color: "text-red-600 bg-red-50" },
|
||||||
|
canceled: { icon: IconCircleX, color: "text-gray-500 bg-gray-50" },
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResearchPage() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [sheetOpen, setSheetOpen] = React.useState(false)
|
||||||
|
const [title, setTitle] = React.useState("")
|
||||||
|
const [description, setDescription] = React.useState("")
|
||||||
|
|
||||||
|
const { data: tasks, isLoading, error } = useQuery({
|
||||||
|
queryKey: ["research-tasks"],
|
||||||
|
queryFn: () => getResearchTasks(),
|
||||||
|
refetchInterval: 30000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: () => createResearchTask(title.trim(), description.trim()),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(t("pages.research.create_success"))
|
||||||
|
setSheetOpen(false)
|
||||||
|
setTitle("")
|
||||||
|
setDescription("")
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["research-tasks"] })
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast.error(
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: t("pages.research.create_error"),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<PageHeader title={t("navigation.research")}>
|
||||||
|
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
||||||
|
<SheetTrigger asChild>
|
||||||
|
<Button size="sm">
|
||||||
|
<IconPlus className="size-4" />
|
||||||
|
{t("pages.research.new_task")}
|
||||||
|
</Button>
|
||||||
|
</SheetTrigger>
|
||||||
|
<SheetContent>
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle>{t("pages.research.new_task")}</SheetTitle>
|
||||||
|
<SheetDescription>
|
||||||
|
{t("pages.research.new_task_description")}
|
||||||
|
</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
<form
|
||||||
|
className="mt-6 space-y-4"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (title.trim()) createMutation.mutate()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="research-title">
|
||||||
|
{t("pages.research.field_title")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="research-title"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder={t("pages.research.field_title_placeholder")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="research-desc">
|
||||||
|
{t("pages.research.field_description")}
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="research-desc"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder={t(
|
||||||
|
"pages.research.field_description_placeholder",
|
||||||
|
)}
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={!title.trim() || createMutation.isPending}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{createMutation.isPending ? (
|
||||||
|
<IconLoader2 className="size-4 animate-spin" />
|
||||||
|
) : null}
|
||||||
|
{t("pages.research.create")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-auto px-6 py-3">
|
||||||
|
<div className="w-full max-w-6xl space-y-4">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-muted-foreground py-6 text-sm">
|
||||||
|
{t("labels.loading")}
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="text-destructive py-6 text-sm">
|
||||||
|
{t("pages.research.load_error")}
|
||||||
|
</div>
|
||||||
|
) : !tasks?.length ? (
|
||||||
|
<Card className="border-dashed">
|
||||||
|
<CardContent className="text-muted-foreground py-10 text-center text-sm">
|
||||||
|
{t("pages.research.empty")}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
{tasks.map((task) => (
|
||||||
|
<TaskCard key={task.id} task={task} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TaskCard({ task }: { task: ResearchTask }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const config = statusConfig[task.status]
|
||||||
|
const StatusIcon = config.icon
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link to="/research/$taskId" params={{ taskId: task.id }}>
|
||||||
|
<Card
|
||||||
|
className={cn(
|
||||||
|
"cursor-pointer gap-3 border transition-colors hover:shadow-sm",
|
||||||
|
task.status === "active" && "border-blue-200/70",
|
||||||
|
task.status === "completed" && "border-emerald-200/70",
|
||||||
|
task.status === "failed" && "border-red-200/70",
|
||||||
|
)}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<CardTitle className="text-sm">{task.title}</CardTitle>
|
||||||
|
{task.description ? (
|
||||||
|
<CardDescription className="mt-1 line-clamp-2">
|
||||||
|
{task.description}
|
||||||
|
</CardDescription>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"flex shrink-0 items-center gap-1 rounded-md px-2 py-1 text-[11px] font-semibold",
|
||||||
|
config.color,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<StatusIcon className="size-3.5" />
|
||||||
|
{t(`pages.research.status.${task.status}`)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-muted-foreground flex items-center gap-3 text-xs">
|
||||||
|
<span>
|
||||||
|
{t("pages.research.documents_count", {
|
||||||
|
count: task.document_count,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{new Date(task.created_at).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
273
web/frontend/src/components/research/task-detail-page.tsx
Normal file
273
web/frontend/src/components/research/task-detail-page.tsx
Normal file
|
|
@ -0,0 +1,273 @@
|
||||||
|
import {
|
||||||
|
IconArrowLeft,
|
||||||
|
IconChevronDown,
|
||||||
|
IconChevronRight,
|
||||||
|
IconCircleCheck,
|
||||||
|
IconCircleDashed,
|
||||||
|
IconCircleX,
|
||||||
|
IconLoader2,
|
||||||
|
IconPlayerPlay,
|
||||||
|
} from "@tabler/icons-react"
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||||
|
import { Link } from "@tanstack/react-router"
|
||||||
|
import * as React from "react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import {
|
||||||
|
type ResearchDocument,
|
||||||
|
getResearchDocContent,
|
||||||
|
getResearchTask,
|
||||||
|
researchTaskAction,
|
||||||
|
} from "@/api/research"
|
||||||
|
import { PageHeader } from "@/components/page-header"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const statusConfig: Record<
|
||||||
|
string,
|
||||||
|
{ icon: React.ComponentType<{ className?: string }>; color: string }
|
||||||
|
> = {
|
||||||
|
pending: { icon: IconCircleDashed, color: "text-yellow-600 bg-yellow-50" },
|
||||||
|
active: { icon: IconPlayerPlay, color: "text-blue-600 bg-blue-50" },
|
||||||
|
completed: { icon: IconCircleCheck, color: "text-green-600 bg-green-50" },
|
||||||
|
failed: { icon: IconCircleX, color: "text-red-600 bg-red-50" },
|
||||||
|
canceled: { icon: IconCircleX, color: "text-gray-500 bg-gray-50" },
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaskDetailPage({ taskId }: { taskId: string }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: task,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["research-task", taskId],
|
||||||
|
queryFn: () => getResearchTask(taskId),
|
||||||
|
refetchInterval: 15000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const actionMutation = useMutation({
|
||||||
|
mutationFn: (action: "cancel" | "reopen") =>
|
||||||
|
researchTaskAction(taskId, action),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: ["research-task", taskId],
|
||||||
|
})
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["research-tasks"] })
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast.error(err instanceof Error ? err.message : "Action failed")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const canCancel =
|
||||||
|
task?.status === "pending" || task?.status === "active"
|
||||||
|
const canReopen =
|
||||||
|
task?.status === "completed" || task?.status === "failed"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<PageHeader
|
||||||
|
title={task?.title ?? t("labels.loading")}
|
||||||
|
titleExtra={
|
||||||
|
task ? (
|
||||||
|
<StatusBadge status={task.status} />
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Link to="/research">
|
||||||
|
<Button variant="ghost" size="sm">
|
||||||
|
<IconArrowLeft className="size-4" />
|
||||||
|
{t("pages.research.back")}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
{canCancel ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={actionMutation.isPending}
|
||||||
|
onClick={() => actionMutation.mutate("cancel")}
|
||||||
|
>
|
||||||
|
{actionMutation.isPending ? (
|
||||||
|
<IconLoader2 className="size-4 animate-spin" />
|
||||||
|
) : null}
|
||||||
|
{t("pages.research.action_cancel")}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{canReopen ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={actionMutation.isPending}
|
||||||
|
onClick={() => actionMutation.mutate("reopen")}
|
||||||
|
>
|
||||||
|
{actionMutation.isPending ? (
|
||||||
|
<IconLoader2 className="size-4 animate-spin" />
|
||||||
|
) : null}
|
||||||
|
{t("pages.research.action_reopen")}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-auto px-6 py-3">
|
||||||
|
<div className="w-full max-w-4xl space-y-6">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-muted-foreground py-6 text-sm">
|
||||||
|
{t("labels.loading")}
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="text-destructive py-6 text-sm">
|
||||||
|
{t("pages.research.load_error")}
|
||||||
|
</div>
|
||||||
|
) : task ? (
|
||||||
|
<>
|
||||||
|
{task.description ? (
|
||||||
|
<Card size="sm">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm">
|
||||||
|
{t("pages.research.description")}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-muted-foreground whitespace-pre-wrap text-sm">
|
||||||
|
{task.description}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-foreground/85 text-sm font-semibold">
|
||||||
|
{t("pages.research.documents_title", {
|
||||||
|
count: task.documents.length,
|
||||||
|
})}
|
||||||
|
</h3>
|
||||||
|
{task.documents.length === 0 ? (
|
||||||
|
<Card className="border-dashed">
|
||||||
|
<CardContent className="text-muted-foreground py-8 text-center text-sm">
|
||||||
|
{t("pages.research.no_documents")}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{task.documents.map((doc) => (
|
||||||
|
<DocumentAccordion
|
||||||
|
key={doc.id}
|
||||||
|
doc={doc}
|
||||||
|
taskId={taskId}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-muted-foreground space-y-1 text-xs">
|
||||||
|
<div>
|
||||||
|
{t("pages.research.created_at")}:{" "}
|
||||||
|
{new Date(task.created_at).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
{task.completed_at ? (
|
||||||
|
<div>
|
||||||
|
{t("pages.research.completed_at")}:{" "}
|
||||||
|
{new Date(task.completed_at).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div>
|
||||||
|
{t("pages.research.output_dir")}: {task.output_dir}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: string }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const config = statusConfig[status] ?? statusConfig.pending
|
||||||
|
const Icon = config.icon
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1 rounded-md px-2 py-1 text-[11px] font-semibold",
|
||||||
|
config.color,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="size-3.5" />
|
||||||
|
{t(`pages.research.status.${status}`)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DocumentAccordion({
|
||||||
|
doc,
|
||||||
|
taskId,
|
||||||
|
}: {
|
||||||
|
doc: ResearchDocument
|
||||||
|
taskId: string
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [expanded, setExpanded] = React.useState(false)
|
||||||
|
|
||||||
|
const { data: content, isLoading } = useQuery({
|
||||||
|
queryKey: ["research-doc", taskId, doc.id],
|
||||||
|
queryFn: () => getResearchDocContent(taskId, doc.id),
|
||||||
|
enabled: expanded,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card size="sm">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-center gap-3 px-4 py-3 text-left"
|
||||||
|
onClick={() => setExpanded((v) => !v)}
|
||||||
|
>
|
||||||
|
{expanded ? (
|
||||||
|
<IconChevronDown className="text-muted-foreground size-4 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<IconChevronRight className="text-muted-foreground size-4 shrink-0" />
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground text-xs font-mono">
|
||||||
|
#{doc.seq}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-medium">{doc.title}</span>
|
||||||
|
<span className="text-muted-foreground rounded bg-gray-100 px-1.5 py-0.5 text-[10px]">
|
||||||
|
{doc.doc_type}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{doc.summary ? (
|
||||||
|
<p className="text-muted-foreground mt-0.5 text-xs line-clamp-1">
|
||||||
|
{doc.summary}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{expanded ? (
|
||||||
|
<CardContent className="border-t pt-3">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-muted-foreground py-4 text-center text-sm">
|
||||||
|
{t("labels.loading")}
|
||||||
|
</div>
|
||||||
|
) : content ? (
|
||||||
|
<pre className="max-h-96 overflow-auto whitespace-pre-wrap text-sm">
|
||||||
|
{content.content}
|
||||||
|
</pre>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -12,7 +12,8 @@
|
||||||
"show_more_channels": "More",
|
"show_more_channels": "More",
|
||||||
"show_less_channels": "Less",
|
"show_less_channels": "Less",
|
||||||
"config": "Config",
|
"config": "Config",
|
||||||
"logs": "Logs"
|
"logs": "Logs",
|
||||||
|
"research": "Research"
|
||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
"welcome": "How can I help you today?",
|
"welcome": "How can I help you today?",
|
||||||
|
|
@ -455,6 +456,36 @@
|
||||||
"logs": {
|
"logs": {
|
||||||
"clear": "Clear logs",
|
"clear": "Clear logs",
|
||||||
"empty": "Waiting for logs..."
|
"empty": "Waiting for logs..."
|
||||||
|
},
|
||||||
|
"research": {
|
||||||
|
"load_error": "Failed to load research tasks.",
|
||||||
|
"empty": "No research tasks yet. Create one to get started.",
|
||||||
|
"new_task": "New Research",
|
||||||
|
"new_task_description": "Create a new research task for the agent to investigate.",
|
||||||
|
"field_title": "Title",
|
||||||
|
"field_title_placeholder": "e.g. Investigate logging gaps",
|
||||||
|
"field_description": "Description",
|
||||||
|
"field_description_placeholder": "Describe what should be researched...",
|
||||||
|
"create": "Create Task",
|
||||||
|
"create_success": "Research task created.",
|
||||||
|
"create_error": "Failed to create research task.",
|
||||||
|
"back": "Back",
|
||||||
|
"description": "Description",
|
||||||
|
"documents_title": "Documents ({{count}})",
|
||||||
|
"documents_count": "{{count}} docs",
|
||||||
|
"no_documents": "No documents yet.",
|
||||||
|
"created_at": "Created",
|
||||||
|
"completed_at": "Completed",
|
||||||
|
"output_dir": "Output",
|
||||||
|
"action_cancel": "Cancel Task",
|
||||||
|
"action_reopen": "Reopen Task",
|
||||||
|
"status": {
|
||||||
|
"pending": "Pending",
|
||||||
|
"active": "Active",
|
||||||
|
"completed": "Completed",
|
||||||
|
"failed": "Failed",
|
||||||
|
"canceled": "Canceled"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,8 @@
|
||||||
"show_more_channels": "更多",
|
"show_more_channels": "更多",
|
||||||
"show_less_channels": "收起",
|
"show_less_channels": "收起",
|
||||||
"config": "配置",
|
"config": "配置",
|
||||||
"logs": "日志"
|
"logs": "日志",
|
||||||
|
"research": "调研"
|
||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
"welcome": "今天我能为您做些什么?",
|
"welcome": "今天我能为您做些什么?",
|
||||||
|
|
@ -455,6 +456,36 @@
|
||||||
"logs": {
|
"logs": {
|
||||||
"clear": "清空日志",
|
"clear": "清空日志",
|
||||||
"empty": "等待日志中..."
|
"empty": "等待日志中..."
|
||||||
|
},
|
||||||
|
"research": {
|
||||||
|
"load_error": "加载调研任务失败。",
|
||||||
|
"empty": "暂无调研任务,创建一个开始吧。",
|
||||||
|
"new_task": "新建调研",
|
||||||
|
"new_task_description": "创建一个调研任务,由智能体自动执行调查。",
|
||||||
|
"field_title": "标题",
|
||||||
|
"field_title_placeholder": "例如:调查日志覆盖情况",
|
||||||
|
"field_description": "描述",
|
||||||
|
"field_description_placeholder": "描述需要调研的内容...",
|
||||||
|
"create": "创建任务",
|
||||||
|
"create_success": "调研任务已创建。",
|
||||||
|
"create_error": "创建调研任务失败。",
|
||||||
|
"back": "返回",
|
||||||
|
"description": "描述",
|
||||||
|
"documents_title": "文档 ({{count}})",
|
||||||
|
"documents_count": "{{count}} 篇文档",
|
||||||
|
"no_documents": "暂无文档。",
|
||||||
|
"created_at": "创建时间",
|
||||||
|
"completed_at": "完成时间",
|
||||||
|
"output_dir": "输出目录",
|
||||||
|
"action_cancel": "取消任务",
|
||||||
|
"action_reopen": "重新打开",
|
||||||
|
"status": {
|
||||||
|
"pending": "待处理",
|
||||||
|
"active": "进行中",
|
||||||
|
"completed": "已完成",
|
||||||
|
"failed": "失败",
|
||||||
|
"canceled": "已取消"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||||
|
|
||||||
import { Route as rootRouteImport } from './routes/__root'
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
|
import { Route as ResearchRouteImport } from './routes/research'
|
||||||
import { Route as ModelsRouteImport } from './routes/models'
|
import { Route as ModelsRouteImport } from './routes/models'
|
||||||
import { Route as LogsRouteImport } from './routes/logs'
|
import { Route as LogsRouteImport } from './routes/logs'
|
||||||
import { Route as CredentialsRouteImport } from './routes/credentials'
|
import { Route as CredentialsRouteImport } from './routes/credentials'
|
||||||
|
|
@ -16,11 +17,17 @@ import { Route as ConfigRouteImport } from './routes/config'
|
||||||
import { Route as AgentRouteImport } from './routes/agent'
|
import { Route as AgentRouteImport } from './routes/agent'
|
||||||
import { Route as ChannelsRouteRouteImport } from './routes/channels/route'
|
import { Route as ChannelsRouteRouteImport } from './routes/channels/route'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
|
import { Route as ResearchTaskIdRouteImport } from './routes/research/$taskId'
|
||||||
import { Route as ConfigRawRouteImport } from './routes/config.raw'
|
import { Route as ConfigRawRouteImport } from './routes/config.raw'
|
||||||
import { Route as ChannelsNameRouteImport } from './routes/channels/$name'
|
import { Route as ChannelsNameRouteImport } from './routes/channels/$name'
|
||||||
import { Route as AgentToolsRouteImport } from './routes/agent/tools'
|
import { Route as AgentToolsRouteImport } from './routes/agent/tools'
|
||||||
import { Route as AgentSkillsRouteImport } from './routes/agent/skills'
|
import { Route as AgentSkillsRouteImport } from './routes/agent/skills'
|
||||||
|
|
||||||
|
const ResearchRoute = ResearchRouteImport.update({
|
||||||
|
id: '/research',
|
||||||
|
path: '/research',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const ModelsRoute = ModelsRouteImport.update({
|
const ModelsRoute = ModelsRouteImport.update({
|
||||||
id: '/models',
|
id: '/models',
|
||||||
path: '/models',
|
path: '/models',
|
||||||
|
|
@ -56,6 +63,11 @@ const IndexRoute = IndexRouteImport.update({
|
||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const ResearchTaskIdRoute = ResearchTaskIdRouteImport.update({
|
||||||
|
id: '/$taskId',
|
||||||
|
path: '/$taskId',
|
||||||
|
getParentRoute: () => ResearchRoute,
|
||||||
|
} as any)
|
||||||
const ConfigRawRoute = ConfigRawRouteImport.update({
|
const ConfigRawRoute = ConfigRawRouteImport.update({
|
||||||
id: '/raw',
|
id: '/raw',
|
||||||
path: '/raw',
|
path: '/raw',
|
||||||
|
|
@ -85,10 +97,12 @@ export interface FileRoutesByFullPath {
|
||||||
'/credentials': typeof CredentialsRoute
|
'/credentials': typeof CredentialsRoute
|
||||||
'/logs': typeof LogsRoute
|
'/logs': typeof LogsRoute
|
||||||
'/models': typeof ModelsRoute
|
'/models': typeof ModelsRoute
|
||||||
|
'/research': typeof ResearchRouteWithChildren
|
||||||
'/agent/skills': typeof AgentSkillsRoute
|
'/agent/skills': typeof AgentSkillsRoute
|
||||||
'/agent/tools': typeof AgentToolsRoute
|
'/agent/tools': typeof AgentToolsRoute
|
||||||
'/channels/$name': typeof ChannelsNameRoute
|
'/channels/$name': typeof ChannelsNameRoute
|
||||||
'/config/raw': typeof ConfigRawRoute
|
'/config/raw': typeof ConfigRawRoute
|
||||||
|
'/research/$taskId': typeof ResearchTaskIdRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
|
@ -98,10 +112,12 @@ export interface FileRoutesByTo {
|
||||||
'/credentials': typeof CredentialsRoute
|
'/credentials': typeof CredentialsRoute
|
||||||
'/logs': typeof LogsRoute
|
'/logs': typeof LogsRoute
|
||||||
'/models': typeof ModelsRoute
|
'/models': typeof ModelsRoute
|
||||||
|
'/research': typeof ResearchRouteWithChildren
|
||||||
'/agent/skills': typeof AgentSkillsRoute
|
'/agent/skills': typeof AgentSkillsRoute
|
||||||
'/agent/tools': typeof AgentToolsRoute
|
'/agent/tools': typeof AgentToolsRoute
|
||||||
'/channels/$name': typeof ChannelsNameRoute
|
'/channels/$name': typeof ChannelsNameRoute
|
||||||
'/config/raw': typeof ConfigRawRoute
|
'/config/raw': typeof ConfigRawRoute
|
||||||
|
'/research/$taskId': typeof ResearchTaskIdRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
|
|
@ -112,10 +128,12 @@ export interface FileRoutesById {
|
||||||
'/credentials': typeof CredentialsRoute
|
'/credentials': typeof CredentialsRoute
|
||||||
'/logs': typeof LogsRoute
|
'/logs': typeof LogsRoute
|
||||||
'/models': typeof ModelsRoute
|
'/models': typeof ModelsRoute
|
||||||
|
'/research': typeof ResearchRouteWithChildren
|
||||||
'/agent/skills': typeof AgentSkillsRoute
|
'/agent/skills': typeof AgentSkillsRoute
|
||||||
'/agent/tools': typeof AgentToolsRoute
|
'/agent/tools': typeof AgentToolsRoute
|
||||||
'/channels/$name': typeof ChannelsNameRoute
|
'/channels/$name': typeof ChannelsNameRoute
|
||||||
'/config/raw': typeof ConfigRawRoute
|
'/config/raw': typeof ConfigRawRoute
|
||||||
|
'/research/$taskId': typeof ResearchTaskIdRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
|
|
@ -127,10 +145,12 @@ export interface FileRouteTypes {
|
||||||
| '/credentials'
|
| '/credentials'
|
||||||
| '/logs'
|
| '/logs'
|
||||||
| '/models'
|
| '/models'
|
||||||
|
| '/research'
|
||||||
| '/agent/skills'
|
| '/agent/skills'
|
||||||
| '/agent/tools'
|
| '/agent/tools'
|
||||||
| '/channels/$name'
|
| '/channels/$name'
|
||||||
| '/config/raw'
|
| '/config/raw'
|
||||||
|
| '/research/$taskId'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to:
|
to:
|
||||||
| '/'
|
| '/'
|
||||||
|
|
@ -140,10 +160,12 @@ export interface FileRouteTypes {
|
||||||
| '/credentials'
|
| '/credentials'
|
||||||
| '/logs'
|
| '/logs'
|
||||||
| '/models'
|
| '/models'
|
||||||
|
| '/research'
|
||||||
| '/agent/skills'
|
| '/agent/skills'
|
||||||
| '/agent/tools'
|
| '/agent/tools'
|
||||||
| '/channels/$name'
|
| '/channels/$name'
|
||||||
| '/config/raw'
|
| '/config/raw'
|
||||||
|
| '/research/$taskId'
|
||||||
id:
|
id:
|
||||||
| '__root__'
|
| '__root__'
|
||||||
| '/'
|
| '/'
|
||||||
|
|
@ -153,10 +175,12 @@ export interface FileRouteTypes {
|
||||||
| '/credentials'
|
| '/credentials'
|
||||||
| '/logs'
|
| '/logs'
|
||||||
| '/models'
|
| '/models'
|
||||||
|
| '/research'
|
||||||
| '/agent/skills'
|
| '/agent/skills'
|
||||||
| '/agent/tools'
|
| '/agent/tools'
|
||||||
| '/channels/$name'
|
| '/channels/$name'
|
||||||
| '/config/raw'
|
| '/config/raw'
|
||||||
|
| '/research/$taskId'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
|
|
@ -167,10 +191,18 @@ export interface RootRouteChildren {
|
||||||
CredentialsRoute: typeof CredentialsRoute
|
CredentialsRoute: typeof CredentialsRoute
|
||||||
LogsRoute: typeof LogsRoute
|
LogsRoute: typeof LogsRoute
|
||||||
ModelsRoute: typeof ModelsRoute
|
ModelsRoute: typeof ModelsRoute
|
||||||
|
ResearchRoute: typeof ResearchRouteWithChildren
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
interface FileRoutesByPath {
|
interface FileRoutesByPath {
|
||||||
|
'/research': {
|
||||||
|
id: '/research'
|
||||||
|
path: '/research'
|
||||||
|
fullPath: '/research'
|
||||||
|
preLoaderRoute: typeof ResearchRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/models': {
|
'/models': {
|
||||||
id: '/models'
|
id: '/models'
|
||||||
path: '/models'
|
path: '/models'
|
||||||
|
|
@ -220,6 +252,13 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof IndexRouteImport
|
preLoaderRoute: typeof IndexRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/research/$taskId': {
|
||||||
|
id: '/research/$taskId'
|
||||||
|
path: '/$taskId'
|
||||||
|
fullPath: '/research/$taskId'
|
||||||
|
preLoaderRoute: typeof ResearchTaskIdRouteImport
|
||||||
|
parentRoute: typeof ResearchRoute
|
||||||
|
}
|
||||||
'/config/raw': {
|
'/config/raw': {
|
||||||
id: '/config/raw'
|
id: '/config/raw'
|
||||||
path: '/raw'
|
path: '/raw'
|
||||||
|
|
@ -286,6 +325,18 @@ const ConfigRouteChildren: ConfigRouteChildren = {
|
||||||
const ConfigRouteWithChildren =
|
const ConfigRouteWithChildren =
|
||||||
ConfigRoute._addFileChildren(ConfigRouteChildren)
|
ConfigRoute._addFileChildren(ConfigRouteChildren)
|
||||||
|
|
||||||
|
interface ResearchRouteChildren {
|
||||||
|
ResearchTaskIdRoute: typeof ResearchTaskIdRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
const ResearchRouteChildren: ResearchRouteChildren = {
|
||||||
|
ResearchTaskIdRoute: ResearchTaskIdRoute,
|
||||||
|
}
|
||||||
|
|
||||||
|
const ResearchRouteWithChildren = ResearchRoute._addFileChildren(
|
||||||
|
ResearchRouteChildren,
|
||||||
|
)
|
||||||
|
|
||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
ChannelsRouteRoute: ChannelsRouteRouteWithChildren,
|
ChannelsRouteRoute: ChannelsRouteRouteWithChildren,
|
||||||
|
|
@ -294,6 +345,7 @@ const rootRouteChildren: RootRouteChildren = {
|
||||||
CredentialsRoute: CredentialsRoute,
|
CredentialsRoute: CredentialsRoute,
|
||||||
LogsRoute: LogsRoute,
|
LogsRoute: LogsRoute,
|
||||||
ModelsRoute: ModelsRoute,
|
ModelsRoute: ModelsRoute,
|
||||||
|
ResearchRoute: ResearchRouteWithChildren,
|
||||||
}
|
}
|
||||||
export const routeTree = rootRouteImport
|
export const routeTree = rootRouteImport
|
||||||
._addFileChildren(rootRouteChildren)
|
._addFileChildren(rootRouteChildren)
|
||||||
|
|
|
||||||
23
web/frontend/src/routes/research.tsx
Normal file
23
web/frontend/src/routes/research.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import {
|
||||||
|
Outlet,
|
||||||
|
createFileRoute,
|
||||||
|
useRouterState,
|
||||||
|
} from "@tanstack/react-router"
|
||||||
|
|
||||||
|
import { ResearchPage } from "@/components/research/research-page"
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/research")({
|
||||||
|
component: ResearchRouteLayout,
|
||||||
|
})
|
||||||
|
|
||||||
|
function ResearchRouteLayout() {
|
||||||
|
const pathname = useRouterState({
|
||||||
|
select: (state) => state.location.pathname,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (pathname === "/research") {
|
||||||
|
return <ResearchPage />
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Outlet />
|
||||||
|
}
|
||||||
12
web/frontend/src/routes/research/$taskId.tsx
Normal file
12
web/frontend/src/routes/research/$taskId.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { createFileRoute } from "@tanstack/react-router"
|
||||||
|
|
||||||
|
import { TaskDetailPage } from "@/components/research/task-detail-page"
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/research/$taskId")({
|
||||||
|
component: ResearchTaskRoute,
|
||||||
|
})
|
||||||
|
|
||||||
|
function ResearchTaskRoute() {
|
||||||
|
const { taskId } = Route.useParams()
|
||||||
|
return <TaskDetailPage taskId={taskId} />
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue